640d223
//! analysis usecase: implements AnalysisOps for GitRepository.
//!
//! Provides language stats, contributor stats, and code activity computed
//! from a bare git repository using gix. Language detection here uses a
//! simple extension-based heuristic; production deployments should plug
//! a real language detector (e.g. enry) via a feature flag.
use std::collections::HashMap;
use crate::GitRepository;
use crate::analysis::impls::AnalysisOps;
use crate::analysis::types::{
CodeActivityAuthor, CodeActivityStats, ContributorStat, LanguageStat, LanguageStats,
RefreshResult,
};
/// Map a file extension to a language name. Extracted as a function so tests
/// can override it.
fn ext_to_language(name: &str) -> Option<&'static str> {
let ext = name.rsplit('.').next()?.to_ascii_lowercase();
match ext.as_str() {
"rs" => Some("Rust"),
"go" => Some("Go"),
"py" => Some("Python"),
"js" => Some("JavaScript"),
"ts" => Some("TypeScript"),
"tsx" => Some("TypeScript"),
"jsx" => Some("JavaScript"),
"java" => Some("Java"),
"kt" => Some("Kotlin"),
"swift" => Some("Swift"),
"c" => Some("C"),
"h" => Some("C"),
"cpp" | "cc" | "cxx" | "hpp" => Some("C++"),
"rb" => Some("Ruby"),
"php" => Some("PHP"),
"cs" => Some("C#"),
"scala" => Some("Scala"),
"sh" | "bash" => Some("Shell"),
"html" => Some("HTML"),
"css" => Some("CSS"),
"scss" => Some("SCSS"),
"less" => Some("LESS"),
"md" => Some("Markdown"),
"json" => Some("JSON"),
"yaml" | "yml" => Some("YAML"),
"toml" => Some("TOML"),
"xml" => Some("XML"),
"sql" => Some("SQL"),
"dart" => Some("Dart"),
"lua" => Some("Lua"),
"vue" => Some("Vue"),
"svelte" => Some("Svelte"),
_ => None,
}
}
impl AnalysisOps for GitRepository {
fn analysis_languages(&self) -> Result<LanguageStats, crate::Error> {
let tip = self.tip_id()?;
if let Some(hit) = self.cache_get_rev::<LanguageStats>("analysis", "languages", "all", tip)
{
return Ok(hit);
}
let repo = self.repo()?;
let head = repo
.head_id()
.map_err(|e| crate::Error::Ref(e.to_string()))?;
let commit = repo
.find_commit(head.detach())
.map_err(|e| crate::Error::Find(e.to_string()))?;
let tree_id = commit
.tree_id()
.map_err(|e| crate::Error::Find(e.to_string()))?
.detach();
let mut by_lang: HashMap<String, i64> = HashMap::new();
let mut stack: Vec<(gix::ObjectId, String)> = vec![(tree_id, String::new())];
while let Some((id, prefix)) = stack.pop() {
let tree = repo
.find_tree(id)
.map_err(|e| crate::Error::Find(e.to_string()))?;
let refs = tree
.decode()
.map_err(|e| crate::Error::Find(e.to_string()))?;
for e in refs.entries.iter() {
let name = e.filename.to_string();
let path = if prefix.is_empty() {
name.clone()
} else {
format!("{prefix}/{name}")
};
let kind = e.mode.kind();
match kind {
gix::objs::tree::EntryKind::Tree => {
stack.push((e.oid.to_owned(), path));
}
gix::objs::tree::EntryKind::Blob
| gix::objs::tree::EntryKind::BlobExecutable => {
if let Some(lang) = ext_to_language(&path) {
let obj = repo
.find_object(e.oid)
.map_err(|e| crate::Error::Find(e.to_string()))?;
*by_lang.entry(lang.to_string()).or_insert(0) += obj.data.len() as i64;
}
}
_ => {}
}
}
}
let total: i64 = by_lang.values().sum();
let mut languages: Vec<LanguageStat> = by_lang
.into_iter()
.map(|(language, size)| {
let percentage = if total > 0 {
(size as f64 / total as f64 * 10000.0).round() / 100.0
} else {
0.0
};
LanguageStat {
language,
percentage: percentage as f32,
size,
is_primary: false,
commit_id: None,
color: None,
}
})
.collect();
languages.sort_by_key(|x| std::cmp::Reverse(x.size));
if let Some(first) = languages.first_mut() {
first.is_primary = true;
}
let v = LanguageStats { languages };
self.cache_put_rev("analysis", "languages", "all", tip, &v);
Ok(v)
}
fn analysis_contributors(&self) -> Result<Vec<ContributorStat>, crate::Error> {
let tip = self.tip_id()?;
if let Some(hit) =
self.cache_get_rev::<Vec<ContributorStat>>("analysis", "contributors", "all", tip)
{
return Ok(hit);
}
let repo = self.repo()?;
let head = repo
.head_id()
.map_err(|e| crate::Error::Ref(e.to_string()))?;
let mut walk = repo
.rev_walk([head.detach()])
.all()
.map_err(|e| crate::Error::RevParse(e.to_string()))?;
let mut by_author: HashMap<(String, String), ContributorStat> = HashMap::new();
for info in walk.by_ref() {
let info = info.map_err(|e| crate::Error::Find(e.to_string()))?;
let commit = repo
.find_commit(info.id().detach())
.map_err(|e| crate::Error::Find(e.to_string()))?;
let decoded = commit
.decode()
.map_err(|e| crate::Error::Find(e.to_string()))?
.into_owned()
.map_err(|e| crate::Error::Find(e.to_string()))?;
let sig = &decoded.author;
let key = (sig.name.to_string(), sig.email.to_string());
let entry = by_author.entry(key).or_insert_with(|| ContributorStat {
name: sig.name.to_string(),
email: sig.email.to_string(),
commits: 0,
additions: 0,
deletions: 0,
});
entry.commits += 1;
}
let v: Vec<ContributorStat> = by_author.into_values().collect();
self.cache_put_rev("analysis", "contributors", "all", tip, &v);
Ok(v)
}
fn analysis_code_activity_stats(
&self,
_from: Option<i64>,
branch: Option<&str>,
) -> Result<CodeActivityStats, crate::Error> {
let tip = self.tip_id()?;
let key = format!("{branch:?}");
if let Some(hit) =
self.cache_get_rev::<CodeActivityStats>("analysis", "activity", &key, tip)
{
return Ok(hit);
}
let repo = self.repo()?;
let tip = if let Some(b) = branch {
crate::validate::validate_short_name("branch", b)?;
let full = format!("refs/heads/{b}");
repo.find_reference(full.as_str())
.map_err(|e| crate::Error::Ref(e.to_string()))?
.target()
.try_id()
.ok_or_else(|| crate::Error::NotFound {
name: b.to_string(),
})?
.to_owned()
} else {
repo.head_id()
.map_err(|e| crate::Error::Ref(e.to_string()))?
.detach()
};
let mut walk = repo
.rev_walk([tip])
.all()
.map_err(|e| crate::Error::RevParse(e.to_string()))?;
let mut by_author: HashMap<(String, String), CodeActivityAuthor> = HashMap::new();
let mut total_commits = 0i64;
for info in walk.by_ref() {
let info = info.map_err(|e| crate::Error::Find(e.to_string()))?;
let commit = repo
.find_commit(info.id().detach())
.map_err(|e| crate::Error::Find(e.to_string()))?;
let decoded = commit
.decode()
.map_err(|e| crate::Error::Find(e.to_string()))?
.into_owned()
.map_err(|e| crate::Error::Find(e.to_string()))?;
let sig = &decoded.author;
let key = (sig.name.to_string(), sig.email.to_string());
let entry = by_author.entry(key).or_insert_with(|| CodeActivityAuthor {
name: sig.name.to_string(),
email: sig.email.to_string(),
commits: 0,
additions: 0,
deletions: 0,
});
entry.commits += 1;
total_commits += 1;
}
let total_additions = 0i64;
let total_deletions = 0i64;
let v = CodeActivityStats {
authors: by_author.into_values().collect(),
total_commits,
total_additions,
total_deletions,
};
self.cache_put_rev("analysis", "activity", &key, tip, &v);
Ok(v)
}
fn analysis_refresh(&self, commit_id: Option<&str>) -> Result<RefreshResult, crate::Error> {
let repo = self.repo()?;
let target = if let Some(c) = commit_id {
gix::hash::ObjectId::from_hex(c.as_bytes())
.map_err(|e| crate::Error::InvalidRevSpec(e.to_string()))?
} else {
repo.head_id()
.map_err(|e| crate::Error::Ref(e.to_string()))?
.detach()
};
Ok(RefreshResult {
refreshed: true,
commit_id: target.to_string(),
})
}
}