use std::collections::HashMap; use crate::rpcgit::commit::types::TreeEntryMode; use crate::rpcgit::error::BabyError; use models::git::git_languages; use models::repo::repos; use sea_orm::{ ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set, Unchanged, }; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use uuid::Uuid; use crate::context::facade::GitContext; use crate::service::GitService; use crate::service::cache::{self, ANALYSIS_TTL}; use crate::share::errors::{GitError, map_baby}; #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct LanguageStatDto { pub language: String, pub percentage_bp: i32, pub size: i64, pub is_primary: bool, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct ContributorStatDto { pub name: String, pub email: String, pub commits: i64, pub additions: i64, pub deletions: i64, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct CodeActivityAuthorDto { pub name: String, pub email: String, pub commits: i64, pub additions: i64, pub deletions: i64, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct CodeActivityDto { pub authors: Vec, pub total_commits: i64, pub total_additions: i64, pub total_deletions: i64, } pub fn ext_to_language(name: &str) -> Option<&'static str> { let base = name.rsplit('/').next().unwrap_or(name); match base { "Dockerfile" => return Some("Dockerfile"), "Makefile" => return Some("Makefile"), "Gemfile" => return Some("Ruby"), _ => {} } let ext = base.rsplit('.').next()?.to_ascii_lowercase(); let lang = match ext.as_str() { "rs" => "Rust", "go" => "Go", "py" => "Python", "js" => "JavaScript", "ts" => "TypeScript", "tsx" => "TypeScript", "jsx" => "JavaScript", "java" => "Java", "c" | "h" => "C", "cpp" | "cc" | "hpp" => "C++", "cs" => "C#", "php" => "PHP", "rb" => "Ruby", "swift" => "Swift", "kt" | "kts" => "Kotlin", "scala" => "Scala", "md" | "markdown" => "Markdown", "yaml" | "yml" => "YAML", "json" => "JSON", "toml" => "TOML", "xml" => "XML", "html" | "htm" => "HTML", "css" => "CSS", "scss" | "sass" => "SCSS", "sql" => "SQL", "sh" | "bash" | "zsh" => "Shell", "dockerfile" => "Dockerfile", "proto" => "Protocol Buffers", "vue" => "Vue", "lua" => "Lua", "dart" => "Dart", _ => return None, }; Some(lang) } impl GitService { /// Recompute language breakdown from the tree at HEAD and persist into /// `git_language` (percentage in basis points) + `repos.primary_language`. pub async fn analysis_languages( &self, ctx: &GitContext, ) -> Result, GitError> { if ctx.repo_uninitialized() { return Ok(Vec::new()); } let baby = self.baby(&ctx); let Some(revision) = resolve_analysis_revision(&baby, None, &ctx.model.default_branch).await? else { let db = self.db_write(); git_languages::Entity::delete_many() .filter(git_languages::Column::RepoId.eq(ctx.model.id)) .exec(db) .await?; let am = repos::ActiveModel { id: Unchanged(ctx.model.id), primary_language: Set(None), updated_at: Set(OffsetDateTime::now_utc()), ..Default::default() }; am.update(db).await?; return Ok(Vec::new()); }; let key = format!("repo.{}.analysis_languages.v1.{}", ctx.model.id, revision); cache::cached_fetch(&ctx.cache, &key, ANALYSIS_TTL, || async { let langs = language_stats(&baby, &revision).await?; let total: i64 = langs.values().sum(); let mut rows: Vec<(String, i64)> = langs.into_iter().collect(); rows.sort_by_key(|(_, size)| std::cmp::Reverse(*size)); let db = self.db_write(); git_languages::Entity::delete_many() .filter(git_languages::Column::RepoId.eq(ctx.model.id)) .exec(db) .await?; let now = OffsetDateTime::now_utc(); let primary = rows.first().map(|(name, _)| name.clone()); for (language, size) in &rows { let bp = if total > 0 { ((*size as f64 / total as f64) * 10_000.0).round() as i32 } else { 0 }; git_languages::ActiveModel { id: Set(Uuid::now_v7()), repo_id: Set(ctx.model.id), language: Set(language.clone()), percentage_bp: Set(bp), size: Set(*size), created_at: Set(now), } .insert(db) .await?; } let am = repos::ActiveModel { id: Unchanged(ctx.model.id), primary_language: Set(primary), updated_at: Set(now), ..Default::default() }; am.update(db).await?; let primary_name = rows.first().map(|(n, _)| n.clone()); Ok(rows .into_iter() .map(|(language, size)| { let is_primary = primary_name.as_deref() == Some(language.as_str()); LanguageStatDto { language, percentage_bp: if total > 0 { ((size as f64 / total as f64) * 10_000.0).round() as i32 } else { 0 }, size, is_primary, } }) .collect()) }) .await } pub async fn analysis_languages_read( &self, ctx: &GitContext, ) -> Result, GitError> { let rows = git_languages::Entity::find() .filter(git_languages::Column::RepoId.eq(ctx.model.id)) .order_by_desc(git_languages::Column::Size) .all(self.db_read()) .await?; Ok(rows .into_iter() .enumerate() .map(|(i, r)| LanguageStatDto { language: r.language, percentage_bp: r.percentage_bp, size: r.size, is_primary: i == 0, }) .collect()) } pub async fn analysis_contributors( &self, ctx: &GitContext, ) -> Result, GitError> { if ctx.repo_uninitialized() { return Ok(Vec::new()); } let baby = self.baby(&ctx); let Some(revision) = resolve_analysis_revision(&baby, None, &ctx.model.default_branch).await? else { return Ok(Vec::new()); }; let rev_oid = baby.rev_parse(&revision).await.map_err(map_baby)?; let key = format!("repo.{}.analysis_contributors.v2.{}", ctx.model.id, rev_oid); cache::cached_fetch(&ctx.cache, &key, ANALYSIS_TTL, || async { let entries = baby.commit_short_stats(&revision).await.map_err(map_baby)?; let mut map: HashMap<(String, String), (i64, i64, i64)> = HashMap::new(); for e in &entries { let slot = map .entry((e.author_name.clone(), e.author_email.clone())) .or_default(); slot.0 += 1; slot.1 += e.additions as i64; slot.2 += e.deletions as i64; } let mut out: Vec = map .into_iter() .map( |((name, email), (commits, additions, deletions))| ContributorStatDto { name, email, commits, additions, deletions, }, ) .collect(); out.sort_by_key(|b| std::cmp::Reverse(b.commits)); Ok(out) }) .await } pub async fn analysis_code_activity( &self, ctx: &GitContext, branch: Option, ) -> Result { let baby = self.baby(&ctx); let Some(revision) = resolve_analysis_revision(&baby, branch.as_deref(), &ctx.model.default_branch).await? else { return Ok(CodeActivityDto { authors: Vec::new(), total_commits: 0, total_additions: 0, total_deletions: 0, }); }; let rev_oid = baby.rev_parse(&revision).await.map_err(map_baby)?; let key = format!( "repo.{}.analysis_code_activity.v2.{}", ctx.model.id, rev_oid ); cache::cached_fetch(&ctx.cache, &key, ANALYSIS_TTL, || async { let entries = baby.commit_short_stats(&revision).await.map_err(map_baby)?; let mut authors: Vec = Vec::new(); for e in &entries { match authors.iter_mut().find(|a| a.email == e.author_email) { Some(a) => { a.commits += 1; a.additions += e.additions as i64; a.deletions += e.deletions as i64; } None => authors.push(CodeActivityAuthorDto { name: e.author_name.clone(), email: e.author_email.clone(), commits: 1, additions: e.additions as i64, deletions: e.deletions as i64, }), } } authors.sort_by_key(|b| std::cmp::Reverse(b.commits)); Ok(CodeActivityDto { total_commits: entries.len() as i64, total_additions: entries.iter().map(|e| e.additions as i64).sum(), total_deletions: entries.iter().map(|e| e.deletions as i64).sum(), authors, }) }) .await } } async fn resolve_analysis_revision( baby: &crate::rpcgit::GitBaby, preferred: Option<&str>, default_branch: &str, ) -> Result, GitError> { let mut candidates: Vec = Vec::new(); if let Some(b) = preferred && !b.trim().is_empty() { candidates.push(b.to_string()); } candidates.push("HEAD".to_string()); let db = default_branch.trim(); if !db.is_empty() { let head = format!("refs/heads/{db}"); if !candidates.contains(&head) { candidates.push(head); } } for cand in candidates { match baby.rev_parse(&cand).await { Ok(oid) => return Ok(Some(oid.to_string())), Err(BabyError::RevisionNotFound(_)) => continue, Err(e) => return Err(map_baby(e)), } } let branches = baby .list_branches(crate::rpcgit::branch::ListBranchesOptions::default()) .await .map_err(map_baby)?; Ok(branches.into_iter().next().map(|b| b.sha.to_string())) } pub(crate) async fn language_stats( baby: &crate::rpcgit::GitBaby, revision: &str, ) -> Result, GitError> { let entries = baby .walk_tree( revision, std::path::Path::new(""), crate::rpcgit::tree::types::WalkTreeOptions { recurse: true, show_trees: false, }, ) .await .map_err(map_baby)?; let mut map: HashMap = HashMap::new(); for e in entries { if (e.mode == TreeEntryMode::Blob || e.mode == TreeEntryMode::Exec) && let Some(lang) = ext_to_language(&e.name) { *map.entry(lang.to_string()).or_default() += e.size.unwrap_or(0) as i64; } } Ok(map) }