//! 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 { let tip = self.tip_id()?; if let Some(hit) = self.cache_get_rev::("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 = 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 = 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, crate::Error> { let tip = self.tip_id()?; if let Some(hit) = self.cache_get_rev::>("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 = by_author.into_values().collect(); self.cache_put_rev("analysis", "contributors", "all", tip, &v); Ok(v) } fn analysis_code_activity_stats( &self, _from: Option, branch: Option<&str>, ) -> Result { let tip = self.tip_id()?; let key = format!("{branch:?}"); if let Some(hit) = self.cache_get_rev::("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 { 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(), }) } }