use std::collections::HashSet; use db::{database::AppDatabase, sqlx}; use model::repos::RepoRefModel; use uuid::Uuid; use crate::{bare::GitBare, errors::GitError}; #[derive(Debug, Clone)] pub struct BranchTip { pub name: String, pub shorthand: String, pub target_oid: String, } pub fn collect_branch_tips(bare: &GitBare) -> Result, GitError> { let repo = bare.gix_repo()?; 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 branches = Vec::new(); for ref_result in iter { let reference = ref_result.map_err(|e| { GitError::Internal(format!("ref iteration error: {}", e)) })?; let full_name = reference.name().as_bstr().to_string(); if !full_name.starts_with("refs/heads/") { continue; } let target_oid = reference .target() .try_id() .map(|id| id.to_hex().to_string()) .ok_or_else(|| { GitError::Internal("ref has no direct target".to_string()) })?; let shorthand = reference.name().shorten().to_string(); branches.push(BranchTip { name: full_name, shorthand, target_oid, }); } Ok(branches) } #[tracing::instrument(skip(db, bare), fields(repo_id = %repo_id))] pub async fn sync_refs( db: &AppDatabase, bare: &GitBare, repo_id: Uuid, ) -> Result<(), GitError> { let now = chrono::Utc::now(); let pool = db.writer(); let existing: Vec = sqlx::query_as::<_, RepoRefModel>( "SELECT id, repo, name, kind, target_sha, is_default, is_protected, created_at, updated_at FROM repo_ref WHERE repo = $1 AND kind = 'branch'" ) .bind(repo_id) .fetch_all(pool) .await .map_err(|e| GitError::Internal(format!("failed to query branches: {}", e)))?; let mut existing_names: HashSet = existing.iter().map(|r| r.name.clone()).collect(); let branches = collect_branch_tips(bare)?; const PREFERRED_BRANCHES: &[&str] = &["main", "master", "trunk"]; let current_default: Option = sqlx::query_scalar::<_, String>( "SELECT default_branch FROM repo WHERE id = $1", ) .bind(repo_id) .fetch_optional(pool) .await .map_err(|e| GitError::Internal(format!("failed to re-read repo: {}", e)))? .filter(|b| !b.is_empty()); let mut auto_detected_branch: Option = None; if current_default.is_none() { for preferred in PREFERRED_BRANCHES { if branches.iter().any(|b| b.shorthand == *preferred) { auto_detected_branch = Some((*preferred).to_string()); break; } } if auto_detected_branch.is_none() { if let Some(first) = branches.first() { auto_detected_branch = Some(first.shorthand.clone()); } } } for branch in &branches { if existing_names.contains(&branch.name) { existing_names.remove(&branch.name); sqlx::query( "UPDATE repo_ref SET target_sha = $1, updated_at = $2 WHERE repo = $3 AND name = $4 AND kind = 'branch'" ) .bind(&branch.target_oid) .bind(now) .bind(repo_id) .bind(&branch.name) .execute(pool) .await .map_err(|e| GitError::Internal(format!("failed to update branch: {}", e)))?; } else { let new_id = Uuid::new_v4(); sqlx::query( "INSERT INTO repo_ref (id, repo, name, kind, target_sha, is_default, is_protected, created_at, updated_at) VALUES ($1, $2, $3, 'branch', $4, $5, false, $6, $7)" ) .bind(new_id) .bind(repo_id) .bind(&branch.name) .bind(&branch.target_oid) .bind(false) .bind(now) .bind(now) .execute(pool) .await .map_err(|e| GitError::Internal(format!("failed to insert branch: {}", e)))?; } } if !existing_names.is_empty() { let names_vec: Vec = existing_names.into_iter().collect(); sqlx::query("DELETE FROM repo_ref WHERE repo = $1 AND name = ANY($2) AND kind = 'branch'") .bind(repo_id) .bind(&names_vec) .execute(pool) .await .map_err(|e| GitError::Internal(format!("failed to delete stale branches: {}", e)))?; } if let Some(ref branch_name) = auto_detected_branch { let result = sqlx::query( "UPDATE repo SET default_branch = $1, updated_at = $2 WHERE id = $3 AND default_branch = ''" ) .bind(branch_name.clone()) .bind(now) .bind(repo_id) .execute(pool) .await .map_err(|e| GitError::Internal(format!("failed to set default branch: {}", e)))?; if result.rows_affected() > 0 { sqlx::query( "UPDATE repo_ref SET is_default = false, updated_at = $1 WHERE repo = $2 AND kind = 'branch'" ) .bind(now) .bind(repo_id) .execute(pool) .await .map_err(|e| GitError::Internal(format!("failed to clear head flags: {}", e)))?; sqlx::query( "UPDATE repo_ref SET is_default = true, updated_at = $1 WHERE repo = $2 AND name = $3 AND kind = 'branch'" ) .bind(now) .bind(repo_id) .bind(branch_name) .execute(pool) .await .map_err(|e| GitError::Internal(format!("failed to set head flag: {}", e)))?; } } Ok(()) }