d3d11f4
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<String, git_languages::Model> = existing
.into_iter()
.map(|r| (r.language.clone(), r))
.collect();
let mut stale: std::collections::HashSet<String> = 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<Option<Vec<LanguageEntry>>> {
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<LanguageEntry> = 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<String> {
let mut candidates: Vec<String> = 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<std::collections::HashMap<String, i64>> {
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<String, i64> = 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)
}