use std::collections::HashMap; use gitbaby::GitBaby; use gitbaby::branch::ListBranchesOptions; use models::git::git_branches; use models::repo::repos; use sea_orm::{ ActiveModelTrait, ColumnTrait, DatabaseTransaction, EntityTrait, IntoActiveModel, QueryFilter, Set, }; use time::OffsetDateTime; use uuid::Uuid; use crate::sync::error::{SyncError, SyncResult}; const PREFERRED_DEFAULT_BRANCHES: &[&str] = &["main", "master", "trunk"]; #[derive(Debug, Clone)] pub(crate) struct BranchTip { pub name: String, pub target_sha: String, } pub async fn sync_branches( baby: &GitBaby, txn: &DatabaseTransaction, repo: &repos::Model, ) -> SyncResult { let collected = collect_branch_tips(baby).await?; let existing = git_branches::Entity::find() .filter(git_branches::Column::RepoId.eq(repo.id)) .all(txn) .await .map_err(SyncError::Db)?; let mut existing_by_name: HashMap = existing .into_iter() .filter(|b| !b.is_deleted) .map(|b| (b.name.clone(), b)) .collect(); tracing::debug!( repo_id = %repo.id, tips = collected.len(), db_branches = existing_by_name.len(), "syncing branches" ); let mut stale: std::collections::HashSet = existing_by_name.keys().cloned().collect(); let (mut updated, mut inserted) = (0usize, 0usize); for tip in &collected { stale.remove(&tip.name); match existing_by_name.get(&tip.name) { Some(row) if row.commit_sha != tip.target_sha => { let mut am = row.clone().into_active_model(); am.commit_sha = Set(tip.target_sha.clone()); am.updated_at = Set(OffsetDateTime::now_utc()); am.update(txn).await.map_err(SyncError::Db)?; updated += 1; } Some(_) => {} None => { let now = OffsetDateTime::now_utc(); git_branches::ActiveModel { id: Set(Uuid::now_v7()), repo_id: Set(repo.id), name: Set(tip.name.clone()), commit_sha: Set(tip.target_sha.clone()), is_deleted: Set(false), is_protected: Set(false), deleted_at: Set(None), created_at: Set(now), updated_at: Set(now), } .insert(txn) .await .map_err(SyncError::Db)?; inserted += 1; } } } let mut deleted = 0usize; for name in stale { if existing_by_name.get(&name).is_some_and(|r| r.is_protected) { tracing::debug!(branch = %name, "stale branch is protected; keeping row"); continue; } let Some(row) = existing_by_name.remove(&name) else { continue; }; let mut am = row.into_active_model(); am.is_deleted = Set(true); am.deleted_at = Set(Some(OffsetDateTime::now_utc())); am.updated_at = Set(OffsetDateTime::now_utc()); am.update(txn).await.map_err(SyncError::Db)?; deleted += 1; } tracing::debug!( repo_id = %repo.id, updated, inserted, deleted, "branch sync wrote changes" ); reconcile_default_branch(baby, txn, repo, &collected).await?; Ok(collected.len()) } pub(crate) async fn collect_branch_tips(baby: &GitBaby) -> SyncResult> { let branches = baby .list_branches(ListBranchesOptions::default()) .await .map_err(SyncError::Git)?; Ok(branches .into_iter() .map(|b| BranchTip { name: b.name, target_sha: b.sha.to_string(), }) .collect()) } async fn reconcile_default_branch( baby: &GitBaby, txn: &DatabaseTransaction, repo: &repos::Model, collected: &[BranchTip], ) -> SyncResult<()> { if collected.is_empty() { return Ok(()); } let desired = if collected.iter().any(|b| b.name == repo.default_branch) { repo.default_branch.clone() } else { PREFERRED_DEFAULT_BRANCHES .iter() .copied() .find(|name| collected.iter().any(|b| b.name == *name)) .or_else(|| collected.first().map(|b| b.name.as_str())) .unwrap_or(&repo.default_branch) .to_string() }; if desired != repo.default_branch { tracing::info!( repo_id = %repo.id, old = %repo.default_branch, new = %desired, "default branch changed during sync" ); let mut am = repo.clone().into_active_model(); am.default_branch = Set(desired.clone()); am.update(txn).await.map_err(SyncError::Db)?; } let head_ok = matches!( baby.get_default_branch().await, Ok(current) if current == desired ); if !head_ok && let Err(e) = baby.set_default_branch(&desired).await { tracing::warn!( repo_id = %repo.id, branch = %desired, error = %e, "failed to update on-disk HEAD symbolic-ref" ); } Ok(()) }