use std::time::Duration; use gitbaby::GitBaby; use gitbaby::commit::types::TreeEntryMode; use gitbaby::tree::types::WalkTreeOptions; use models::git::git_languages; use models::repo::repos; use sea_orm::{ ActiveModelTrait, ColumnTrait, DatabaseTransaction, EntityTrait, IntoActiveModel, QueryFilter, Set, }; use time::OffsetDateTime; use uuid::Uuid; use crate::service::analysis::ext_to_language; use crate::sync::error::{SyncError, SyncResult}; use config::git::SyncSettings; pub async fn sync_repo_languages( baby: &GitBaby, txn: &DatabaseTransaction, repo: &repos::Model, settings: &SyncSettings, ) -> SyncResult<()> { let desired = match detect_languages(baby, repo, settings).await { Ok(Some(langs)) => langs, Ok(None) => return Ok(()), Err(e) => { tracing::warn!(repo_id = %repo.id, error = %e, "language detection failed; skipping"); return Ok(()); } }; let existing = git_languages::Entity::find() .filter(git_languages::Column::RepoId.eq(repo.id)) .all(txn) .await .map_err(SyncError::Db)?; let mut existing_by_lang: std::collections::HashMap = existing .into_iter() .map(|r| (r.language.clone(), r)) .collect(); let mut stale: std::collections::HashSet = existing_by_lang.keys().cloned().collect(); let now = OffsetDateTime::now_utc(); for entry in &desired { stale.remove(&entry.language); let bp = entry.percentage_bp; let size = entry.size; match existing_by_lang.get(&entry.language) { Some(row) if row.percentage_bp != bp || row.size != size => { let mut am = row.clone().into_active_model(); am.percentage_bp = Set(bp); am.size = Set(size); am.update(txn).await.map_err(SyncError::Db)?; } Some(_) => {} None => { git_languages::ActiveModel { id: Set(Uuid::now_v7()), repo_id: Set(repo.id), language: Set(entry.language.clone()), percentage_bp: Set(bp), size: Set(size), created_at: Set(now), } .insert(txn) .await .map_err(SyncError::Db)?; } } } for lang in stale { let Some(row) = existing_by_lang.remove(&lang) else { continue; }; git_languages::Entity::delete_by_id(row.id) .exec(txn) .await .map_err(SyncError::Db)?; } if let Some(primary) = desired.first() && repo.primary_language.as_deref() != Some(primary.language.as_str()) { tracing::debug!( repo_id = %repo.id, old = ?repo.primary_language, new = %primary.language, "primary language changed during sync" ); let mut am = repo.clone().into_active_model(); am.primary_language = Set(Some(primary.language.clone())); am.update(txn).await.map_err(SyncError::Db)?; } Ok(()) } #[derive(Debug, Clone)] struct LanguageEntry { language: String, percentage_bp: i32, size: i64, } async fn detect_languages( baby: &GitBaby, repo: &repos::Model, settings: &SyncSettings, ) -> SyncResult>> { let Some(revision) = resolve_language_revision(baby, repo).await else { tracing::debug!(repo_id = %repo.id, "no resolvable revision; skipping language detection"); return Ok(None); }; let timeout = Duration::from_secs(settings.language_timeout_secs); let Ok(stats) = tokio::time::timeout(timeout, walk_lang_stats(baby.clone(), revision.clone())).await else { tracing::warn!(revision = %revision, "language detection timed out"); return Ok(None); }; let entries: Vec<(String, i64)> = match stats { Ok(map) => map.into_iter().collect(), Err(e) => return Err(e), }; let mut entries = entries; entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); entries.truncate(settings.max_languages); let total: i64 = entries.iter().map(|(_, s)| *s).sum(); let out: Vec = entries .into_iter() .filter(|(_, size)| *size > 0) .map(|(language, size)| { let bp = if total > 0 { (((size as f64) / (total as f64)) * 10_000.0).round() as i32 } else { 0 }; LanguageEntry { language, percentage_bp: bp.clamp(0, 10_000), size, } }) .collect(); tracing::debug!( repo_id = %repo.id, revision = %revision, languages = out.len(), "language detection finished" ); Ok(Some(out)) } async fn resolve_language_revision(baby: &GitBaby, repo: &repos::Model) -> Option { let mut candidates: Vec = Vec::new(); candidates.push("HEAD".to_string()); let db = repo.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(_) => return Some(cand.clone()), Err(gitbaby::error::BabyError::RevisionNotFound(_)) => continue, Err(_) => continue, } } match baby .list_branches(gitbaby::branch::ListBranchesOptions::default()) .await { Ok(branches) => branches.first().map(|b| b.sha.to_string()), Err(_) => None, } } async fn walk_lang_stats( baby: GitBaby, revision: String, ) -> SyncResult> { let entries = baby .walk_tree( &revision, std::path::Path::new(""), WalkTreeOptions { recurse: true, show_trees: false, }, ) .await .map_err(SyncError::Git)?; let mut map: std::collections::HashMap = std::collections::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) }