use std::collections::{HashMap, HashSet}; use chrono::{DateTime, Utc}; use db::{database::AppDatabase, sqlx}; use model::repos::RepoCommitterModel; use uuid::Uuid; use crate::{bare::GitBare, cmd::oid::ObjectId, errors::GitError}; #[tracing::instrument(skip(db, bare), fields(repo_id = %repo_id))] pub async fn sync_commits( db: &AppDatabase, bare: &GitBare, repo_id: Uuid, ) -> Result<(), GitError> { let repo = bare.gix_repo()?; let pool = db.writer(); let existing_oids: Vec = sqlx::query_scalar::<_, String>( "SELECT sha FROM repo_commit WHERE repo = $1", ) .bind(repo_id) .fetch_all(pool) .await .map_err(|e| { GitError::Internal(format!("failed to query commits: {}", e)) })?; let existing_set: HashSet = existing_oids.into_iter().collect(); let head_id = repo .head_id() .map_err(|e| { GitError::Internal(format!("failed to resolve HEAD: {}", e)) })? .detach(); let tips = { let refs = repo.references().map_err(|e| { GitError::Internal(format!("failed to open references: {}", e)) })?; let iter = refs.all().map_err(|e| { GitError::Internal(format!("failed to iterate refs: {}", e)) })?; let mut tips = vec![head_id]; for ref_result in iter { let reference = ref_result.map_err(|e| { GitError::Internal(format!("ref iteration error: {}", e)) })?; let name = reference.name().as_bstr().to_string(); if !name.starts_with("refs/heads/") { continue; } if let Some(target_id) = reference.target().try_id() { let hex = target_id.to_hex().to_string(); if let Ok(gix_id) = gix::hash::ObjectId::from_hex(hex.as_bytes()) { tips.push(gix_id); } } } tips }; let platform = repo.rev_walk(tips).sorting( gix::revision::walk::Sorting::ByCommitTime( gix::traverse::commit::simple::CommitTimeOrder::NewestFirst, ), ); let walk = platform .all() .map_err(|e| GitError::Internal(format!("rev_walk failed: {}", e)))?; let mut new_commits: Vec = Vec::new(); for info in walk { let info = info.map_err(|e| { GitError::Internal(format!("walk step error: {}", e)) })?; let hex = info.id().detach().to_hex().to_string(); if !existing_set.contains(&hex) { new_commits.push(info.id().detach()); } } if new_commits.is_empty() { return Ok(()); } let mut committer_map: HashMap = HashMap::new(); // email → (committer_id, name) let existing_committers: Vec = sqlx::query_as::<_, RepoCommitterModel>( "SELECT id, repo, \"user\", name, email, created_at, updated_at FROM repo_committer WHERE repo = $1", ) .bind(repo_id) .fetch_all(pool) .await .map_err(|e| GitError::Internal(format!("failed to query repo_committer: {}", e)))?; for model in &existing_committers { committer_map .insert(model.email.clone(), (model.id, model.name.clone())); } let email_map = resolve_user_ids(db, &committer_map).await?; let now = Utc::now(); for gix_id in &new_commits { let hex_oid = gix_id.to_hex().to_string(); let oid = ObjectId::new(&hex_oid); let commit_meta = bare.commit_info(oid).map_err(|e| { GitError::Internal(format!( "commit_info failed for {}: {}", hex_oid, e )) })?; let author_committer_id = ensure_committer( &mut committer_map, pool, repo_id, &commit_meta.author.email, &commit_meta.author.name, &email_map, now, ) .await?; let committer_committer_id = ensure_committer( &mut committer_map, pool, repo_id, &commit_meta.committer.email, &commit_meta.committer.name, &email_map, now, ) .await?; let parent_shas = commit_meta .parent_ids .iter() .map(|p| p.as_str()) .collect::>() .join("."); let authored_at = git_time_to_datetime( commit_meta.author.time_secs, commit_meta.author.offset_minutes, ); let committed_at = git_time_to_datetime( commit_meta.committer.time_secs, commit_meta.committer.offset_minutes, ); let new_id = Uuid::new_v4(); sqlx::query( "INSERT INTO repo_commit (id, repo, sha, tree_sha, parent_shas, author, committer, message, authored_at, committed_at, created_at) \ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)" ) .bind(new_id) .bind(repo_id) .bind(&hex_oid) .bind(commit_meta.tree_id.as_str()) .bind(&parent_shas) .bind(author_committer_id) .bind(committer_committer_id) .bind(&commit_meta.message) .bind(authored_at) .bind(committed_at) .bind(now) .execute(pool) .await .map_err(|e| GitError::Internal(format!("failed to insert commit: {}", e)))?; } Ok(()) } async fn resolve_user_ids( db: &AppDatabase, committer_map: &HashMap, ) -> Result, GitError> { if committer_map.is_empty() { return Ok(HashMap::new()); } let pool = db.writer(); let email_vec: Vec = committer_map.keys().cloned().collect(); let rows: Vec<(Uuid, String)> = sqlx::query_as( "SELECT \"user\", email FROM user_email WHERE email = ANY($1) AND active = true", ) .bind(&email_vec) .fetch_all(pool) .await .map_err(|e| GitError::Internal(format!("failed to query user emails: {}", e)))?; let mut map = HashMap::new(); for (user_id, email) in rows { map.insert(email, user_id); } Ok(map) } async fn ensure_committer( committer_map: &mut HashMap, pool: &sqlx::Pool, repo_id: Uuid, email: &str, name: &str, email_map: &HashMap, now: DateTime, ) -> Result { if let Some((id, _)) = committer_map.get(email) { return Ok(*id); } let user_id = email_map.get(email).copied(); let new_id = Uuid::new_v4(); sqlx::query( "INSERT INTO repo_committer (id, repo, \"user\", name, email, created_at, updated_at) \ VALUES ($1, $2, $3, $4, $5, $6, $7)" ) .bind(new_id) .bind(repo_id) .bind(user_id) .bind(name) .bind(email) .bind(now) .bind(now) .execute(pool) .await .map_err(|e| GitError::Internal(format!("failed to insert repo_committer: {}", e)))?; committer_map.insert(email.to_string(), (new_id, name.to_string())); Ok(new_id) } fn git_time_to_datetime(secs: i64, offset_minutes: i32) -> DateTime { let utc_secs = secs - (offset_minutes as i64 * 60); DateTime::from_timestamp(utc_secs, 0).unwrap_or_else(|| Utc::now()) }