use crate::AppService; use crate::error::AppError; use crate::git::{BranchDiff, BranchInfo, BranchSummary}; use models::repos::repo; use models::repos::repo as repo_model; use models::repos::repo_branch; use models::repos::repo_branch_protect; use sea_orm::prelude::Expr; use sea_orm::{ColumnTrait, EntityTrait, ExprTrait, PaginatorTrait, QueryFilter}; use serde::{Deserialize, Serialize}; use session::Session; use uuid::Uuid; #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct BranchListQuery { #[serde(default)] pub remote_only: Option, #[serde(default)] pub all: Option, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct BranchInfoResponse { pub name: String, pub oid: String, pub is_head: bool, pub is_remote: bool, pub is_current: bool, pub upstream: Option, } impl From for BranchInfoResponse { fn from(b: BranchInfo) -> Self { Self { name: b.name, oid: b.oid.to_string(), is_head: b.is_head, is_remote: b.is_remote, is_current: b.is_current, upstream: b.upstream, } } } impl From for BranchInfoResponse { fn from(b: repo_branch::Model) -> Self { // is_remote: full ref path starts with "refs/remotes/" let is_remote = b.name.starts_with("refs/remotes/"); // shorthand name for display (strip prefix) let name = if b.name.starts_with("refs/heads/") { b.name .strip_prefix("refs/heads/") .unwrap_or(&b.name) .to_string() } else if b.name.starts_with("refs/remotes/") { b.name .strip_prefix("refs/remotes/") .unwrap_or(&b.name) .to_string() } else { b.name.clone() }; Self { name, oid: b.oid, is_head: b.head, is_remote, // is_current: not stored in DB, always false when from DB is_current: false, // upstream: stored as pattern "refs/remotes/{}/{}", return as-is upstream: b.upstream, } } } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct BranchSummaryResponse { pub local_count: usize, pub remote_count: usize, pub all_count: usize, } impl From for BranchSummaryResponse { fn from(s: BranchSummary) -> Self { Self { local_count: s.local_count, remote_count: s.remote_count, all_count: s.all_count, } } } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct BranchDiffResponse { pub ahead: usize, pub behind: usize, pub diverged: bool, } impl From for BranchDiffResponse { fn from(d: BranchDiff) -> Self { Self { ahead: d.ahead, behind: d.behind, diverged: d.diverged, } } } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct BranchExistsResponse { pub name: String, pub exists: bool, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct BranchIsHeadResponse { pub name: String, pub is_head: bool, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct BranchIsDetachedResponse { pub is_detached: bool, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct BranchIsMergedResponse { pub branch: String, pub into: String, pub is_merged: bool, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct BranchMergeBaseResponse { pub branch1: String, pub branch2: String, pub base: String, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct BranchIsAncestorResponse { pub child: String, pub ancestor: String, pub is_ancestor: bool, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct BranchFastForwardResponse { pub oid: String, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct BranchTrackingDiffResponse { pub name: String, pub ahead: usize, pub behind: usize, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct BranchIsConflictedResponse { pub is_conflicted: bool, } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct BranchCreateRequest { pub name: String, pub oid: Option, #[serde(default)] pub force: bool, } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct BranchRenameRequest { pub old_name: String, pub new_name: String, } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct BranchMoveRequest { pub name: String, pub new_name: String, #[serde(default)] pub force: bool, } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct BranchSetUpstreamRequest { pub name: String, pub upstream: Option, } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct BranchDiffQuery { pub local: String, pub remote: String, } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct BranchIsMergedQuery { pub branch: String, pub into: String, } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct BranchIsAncestorQuery { pub child: String, pub ancestor: String, } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct BranchMergeBaseQuery { pub branch1: String, pub branch2: String, } macro_rules! git_spawn { ($repo:expr, $domain:ident -> $body:expr) => {{ let repo_clone = $repo.clone(); tokio::task::spawn_blocking(move || { let $domain = git::GitDomain::from_model(repo_clone)?; $body }) .await .map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))? .map_err(AppError::from) }}; } impl AppService { /// Check and enforce branch protection rules before deleting (or renaming/moving away from) a branch. async fn check_protection_for_deletion( &self, repo_id: Uuid, branch: &str, ) -> Result<(), AppError> { let protection = repo_branch_protect::Entity::find() .filter(repo_branch_protect::Column::Repo.eq(repo_id)) .filter(repo_branch_protect::Column::Branch.eq(branch)) .one(&self.db) .await .map_err(AppError::from)?; if let Some(rule) = protection { if rule.forbid_deletion { return Err(AppError::Forbidden(format!( "Deletion of protected branch '{}' is forbidden", branch ))); } } Ok(()) } /// Check and enforce branch protection rules before creating (or renaming/moving to) a branch. async fn check_protection_for_push(&self, repo_id: Uuid, branch: &str) -> Result<(), AppError> { let protection = repo_branch_protect::Entity::find() .filter(repo_branch_protect::Column::Repo.eq(repo_id)) .filter(repo_branch_protect::Column::Branch.eq(branch)) .one(&self.db) .await .map_err(AppError::from)?; if let Some(rule) = protection { if rule.forbid_push { return Err(AppError::Forbidden(format!( "Push to protected branch '{}' is forbidden", branch ))); } } Ok(()) } pub async fn git_branch_list( &self, namespace: String, repo_name: String, query: BranchListQuery, ctx: &Session, ) -> Result, AppError> { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let branches = repo_branch::Entity::find() .filter(repo_branch::Column::Repo.eq(repo.id)) .filter({ if query.all.unwrap_or(false) { // all: no filter Expr::value(true) } else if query.remote_only.unwrap_or(false) { Expr::col(repo_branch::Column::Name).like("refs/remotes/%") } else { Expr::col(repo_branch::Column::Name).like("refs/heads/%") } }) .all(&self.db) .await .map_err(AppError::from)?; Ok(branches.into_iter().map(BranchInfoResponse::from).collect()) } pub async fn git_branch_summary( &self, namespace: String, repo_name: String, ctx: &Session, ) -> Result { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let local_count: usize = repo_branch::Entity::find() .filter(repo_branch::Column::Repo.eq(repo.id)) .filter(Expr::col(repo_branch::Column::Name).like("refs/heads/%")) .count(&self.db) .await .map_err(AppError::from)? as usize; let remote_count: usize = repo_branch::Entity::find() .filter(repo_branch::Column::Repo.eq(repo.id)) .filter(Expr::col(repo_branch::Column::Name).like("refs/remotes/%")) .count(&self.db) .await .map_err(AppError::from)? as usize; Ok(BranchSummaryResponse { local_count, remote_count, all_count: local_count + remote_count, }) } pub async fn git_branch_get( &self, namespace: String, repo_name: String, name: String, ctx: &Session, ) -> Result { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; // Normalize: try shorthand forms, then full ref paths let candidates = if name.starts_with("refs/") { vec![name.clone()] } else if name.starts_with("heads/") { vec![format!("refs/{}", name)] } else { vec![ format!("refs/heads/{}", name), format!("refs/remotes/{}", name), name.clone(), ] }; for candidate in &candidates { if let Some(b) = repo_branch::Entity::find() .filter(repo_branch::Column::Repo.eq(repo.id)) .filter(repo_branch::Column::Name.eq(candidate)) .one(&self.db) .await .map_err(AppError::from)? { return Ok(BranchInfoResponse::from(b)); } } // Fallback to git let name_clone = name.clone(); let info = git_spawn!(repo, domain -> { domain.branch_get(&name_clone) })?; Ok(BranchInfoResponse::from(info)) } pub async fn git_branch_current( &self, namespace: String, repo_name: String, ctx: &Session, ) -> Result, AppError> { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; // Try git first (normal path). Errors (e.g. empty repo) fall through to DB fallback. if let Ok(Some(b)) = git_spawn!(repo, domain -> { domain.branch_current() }) { return Ok(Some(BranchInfoResponse::from(b))); } // Fallback: repo may be empty or sync not yet run, but default_branch // is already set in repo metadata. Look it up in DB. // repo.default_branch is shorthand (e.g. "main"), DB stores full ref (e.g. "refs/heads/main") if !repo.default_branch.is_empty() { let full_ref = format!("refs/heads/{}", repo.default_branch); if let Some(branch) = repo_branch::Entity::find() .filter(repo_branch::Column::Repo.eq(repo.id)) .filter(repo_branch::Column::Name.eq(&full_ref)) .one(&self.db) .await .map_err(AppError::from)? { return Ok(Some(BranchInfoResponse::from(branch))); } } Ok(None) } pub async fn git_branch_exists( &self, namespace: String, repo_name: String, name: String, ctx: &Session, ) -> Result { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; // Try shorthand → full ref path candidates let candidates = if name.starts_with("refs/") { vec![name.clone()] } else { vec![ format!("refs/heads/{}", name), format!("refs/remotes/{}", name), name.clone(), ] }; for candidate in &candidates { let found = repo_branch::Entity::find() .filter(repo_branch::Column::Repo.eq(repo.id)) .filter(repo_branch::Column::Name.eq(candidate)) .one(&self.db) .await .map_err(AppError::from)?; if found.is_some() { return Ok(BranchExistsResponse { name, exists: true }); } } // Fallback to git let name_clone = name.clone(); let exists = git_spawn!(repo, domain -> { Ok::<_, git::GitError>(domain.branch_exists(&name_clone)) })?; Ok(BranchExistsResponse { name, exists }) } pub async fn git_branch_is_head( &self, namespace: String, repo_name: String, name: String, ctx: &Session, ) -> Result { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let name_clone = name.clone(); let is_head = git_spawn!(repo, domain -> { domain.branch_is_head(&name_clone) })?; Ok(BranchIsHeadResponse { name, is_head }) } pub async fn git_branch_is_detached( &self, namespace: String, repo_name: String, ctx: &Session, ) -> Result { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let is_detached = git_spawn!(repo, domain -> { Ok::<_, git::GitError>(domain.branch_is_detached()) })?; Ok(BranchIsDetachedResponse { is_detached }) } pub async fn git_branch_upstream( &self, namespace: String, repo_name: String, name: String, ctx: &Session, ) -> Result, AppError> { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let name_clone = name.clone(); let upstream = git_spawn!(repo, domain -> { domain.branch_upstream(&name_clone) })?; Ok(upstream.map(BranchInfoResponse::from)) } pub async fn git_branch_diff( &self, namespace: String, repo_name: String, query: BranchDiffQuery, ctx: &Session, ) -> Result { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let local = query.local.clone(); let remote = query.remote.clone(); let diff = git_spawn!(repo, domain -> { domain.branch_diff(&local, &remote) })?; Ok(BranchDiffResponse::from(diff)) } pub async fn git_branch_tracking_difference( &self, namespace: String, repo_name: String, name: String, ctx: &Session, ) -> Result { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let name_clone = name.clone(); let (ahead, behind) = git_spawn!(repo, domain -> { domain.branch_tracking_difference(&name_clone) })?; Ok(BranchTrackingDiffResponse { name, ahead, behind, }) } pub async fn git_branch_create( &self, namespace: String, repo_name: String, request: BranchCreateRequest, ctx: &Session, ) -> Result { let repo: repo::Model = self .utils_check_repo_admin(namespace.clone(), repo_name.clone(), ctx) .await?; self.check_protection_for_push(repo.id, &request.name) .await?; let name = request.name.clone(); let force = request.force; let name_for_spawn = name.clone(); let info = if let Some(oid) = request.oid { let commit_oid = git::CommitOid::new(&oid); let name_clone = name_for_spawn.clone(); git_spawn!(repo, domain -> { domain.branch_create(&name_clone, &commit_oid, force) })? } else { git_spawn!(repo, domain -> { domain.branch_create_from_head(&name_for_spawn, force) })? }; let response = BranchInfoResponse::from(info); let project_id = match repo_model::Entity::find_by_id(repo.id).one(&self.db).await { Ok(Some(r)) => r.project, Ok(None) => Uuid::nil(), Err(e) => { slog::warn!( self.logs, "failed to look up project_id for activity log: {}", e ); Uuid::nil() } }; let user_uid = ctx.user().unwrap_or(Uuid::nil()); let _ = self .project_log_activity( project_id, Some(repo.id), user_uid, super::super::project::activity::ActivityLogParams { event_type: "branch_create".to_string(), title: format!("{} created branch '{}'", user_uid, name), repo_id: Some(repo.id), content: None, event_id: None, event_sub_id: None, metadata: Some(serde_json::json!({"branch_name": name})), is_private: false, }, ) .await; Ok(response) } pub async fn git_branch_delete( &self, namespace: String, repo_name: String, name: String, ctx: &Session, ) -> Result<(), AppError> { let repo: repo::Model = self .utils_check_repo_admin(namespace.clone(), repo_name.clone(), ctx) .await?; self.check_protection_for_deletion(repo.id, &name).await?; let name_for_spawn = name.clone(); git_spawn!(repo, domain -> { domain.branch_delete(&name_for_spawn) })?; let project_id = match repo_model::Entity::find_by_id(repo.id).one(&self.db).await { Ok(Some(r)) => r.project, Ok(None) => Uuid::nil(), Err(e) => { slog::warn!( self.logs, "failed to look up project_id for activity log: {}", e ); Uuid::nil() } }; let user_uid = ctx.user().unwrap_or(Uuid::nil()); let _ = self .project_log_activity( project_id, Some(repo.id), user_uid, super::super::project::activity::ActivityLogParams { event_type: "branch_delete".to_string(), title: format!("{} deleted branch '{}'", user_uid, name), repo_id: Some(repo.id), content: None, event_id: None, event_sub_id: None, metadata: Some(serde_json::json!({"branch_name": name})), is_private: false, }, ) .await; Ok(()) } pub async fn git_branch_delete_remote( &self, namespace: String, repo_name: String, name: String, ctx: &Session, ) -> Result<(), AppError> { let repo: repo::Model = self .utils_check_repo_admin(namespace.clone(), repo_name.clone(), ctx) .await?; self.check_protection_for_deletion(repo.id, &name).await?; let name_clone = name.clone(); git_spawn!(repo, domain -> { domain.branch_delete_remote(&name_clone) })?; Ok(()) } pub async fn git_branch_rename( &self, namespace: String, repo_name: String, request: BranchRenameRequest, ctx: &Session, ) -> Result { let repo: repo::Model = self .utils_check_repo_admin(namespace.clone(), repo_name.clone(), ctx) .await?; // Check: source branch cannot be deleted if protected self.check_protection_for_deletion(repo.id, &request.old_name) .await?; // Check: target branch cannot be pushed if protected self.check_protection_for_push(repo.id, &request.new_name) .await?; let old_name = request.old_name.clone(); let new_name = request.new_name.clone(); let old_name_for_spawn = old_name.clone(); let new_name_for_spawn = new_name.clone(); let info = git_spawn!(repo, domain -> { domain.branch_rename(&old_name_for_spawn, &new_name_for_spawn) })?; let response = BranchInfoResponse::from(info); let project_id = match repo_model::Entity::find_by_id(repo.id).one(&self.db).await { Ok(Some(r)) => r.project, Ok(None) => Uuid::nil(), Err(e) => { slog::warn!( self.logs, "failed to look up project_id for activity log: {}", e ); Uuid::nil() } }; let user_uid = ctx.user().unwrap_or(Uuid::nil()); let _ = self .project_log_activity( project_id, Some(repo.id), user_uid, super::super::project::activity::ActivityLogParams { event_type: "branch_rename".to_string(), title: format!( "{} renamed branch '{}' to '{}'", user_uid, old_name, new_name ), repo_id: Some(repo.id), content: None, event_id: None, event_sub_id: None, metadata: Some(serde_json::json!({"old_name": old_name, "new_name": new_name})), is_private: false, }, ) .await; Ok(response) } pub async fn git_branch_move( &self, namespace: String, repo_name: String, request: BranchMoveRequest, ctx: &Session, ) -> Result { let repo: repo::Model = self .utils_check_repo_admin(namespace.clone(), repo_name.clone(), ctx) .await?; // Check: source branch cannot be deleted if protected self.check_protection_for_deletion(repo.id, &request.name) .await?; // Check: target branch cannot be pushed if protected self.check_protection_for_push(repo.id, &request.new_name) .await?; let name = request.name.clone(); let new_name = request.new_name.clone(); let force = request.force; let name_for_spawn = name.clone(); let new_name_for_spawn = new_name.clone(); let info = git_spawn!(repo, domain -> { domain.branch_move(&name_for_spawn, &new_name_for_spawn, force) })?; let response = BranchInfoResponse::from(info); let project_id = match repo_model::Entity::find_by_id(repo.id).one(&self.db).await { Ok(Some(r)) => r.project, Ok(None) => Uuid::nil(), Err(e) => { slog::warn!( self.logs, "failed to look up project_id for activity log: {}", e ); Uuid::nil() } }; let user_uid = ctx.user().unwrap_or(Uuid::nil()); let _ = self .project_log_activity( project_id, Some(repo.id), user_uid, super::super::project::activity::ActivityLogParams { event_type: "branch_rename".to_string(), title: format!("{} renamed branch '{}' to '{}'", user_uid, name, new_name), repo_id: Some(repo.id), content: None, event_id: None, event_sub_id: None, metadata: Some(serde_json::json!({"old_name": name, "new_name": new_name})), is_private: false, }, ) .await; Ok(response) } pub async fn git_branch_set_upstream( &self, namespace: String, repo_name: String, request: BranchSetUpstreamRequest, ctx: &Session, ) -> Result<(), AppError> { let repo: repo::Model = self .utils_check_repo_admin(namespace, repo_name, ctx) .await?; let name = request.name.clone(); let upstream = request.upstream.clone(); git_spawn!(repo, domain -> { domain.branch_set_upstream(&name, upstream.as_deref()) })?; Ok(()) } pub async fn git_branch_is_merged( &self, namespace: String, repo_name: String, query: BranchIsMergedQuery, ctx: &Session, ) -> Result { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let branch = query.branch.clone(); let into = query.into.clone(); let is_merged = git_spawn!(repo, domain -> { domain.branch_is_merged(&branch, &into) })?; Ok(BranchIsMergedResponse { branch: query.branch, into: query.into, is_merged, }) } pub async fn git_branch_merge_base( &self, namespace: String, repo_name: String, query: BranchMergeBaseQuery, ctx: &Session, ) -> Result { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let branch1 = query.branch1.clone(); let branch2 = query.branch2.clone(); let base = git_spawn!(repo, domain -> { domain.branch_merge_base(&branch1, &branch2) })?; Ok(BranchMergeBaseResponse { branch1: query.branch1, branch2: query.branch2, base: base.to_string(), }) } pub async fn git_branch_is_ancestor( &self, namespace: String, repo_name: String, query: BranchIsAncestorQuery, ctx: &Session, ) -> Result { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let child = query.child.clone(); let ancestor = query.ancestor.clone(); let is_ancestor = git_spawn!(repo, domain -> { domain.branch_is_ancestor(&child, &ancestor) })?; Ok(BranchIsAncestorResponse { child: query.child, ancestor: query.ancestor, is_ancestor, }) } pub async fn git_branch_fast_forward( &self, namespace: String, repo_name: String, target: String, force: Option, ctx: &Session, ) -> Result { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let target_clone = target.clone(); let new_oid = git_spawn!(repo, domain -> { domain.branch_fast_forward(&target_clone, force.unwrap_or(false)) })?; Ok(BranchFastForwardResponse { oid: new_oid.to_string(), }) } pub async fn git_branch_is_conflicted( &self, namespace: String, repo_name: String, ctx: &Session, ) -> Result { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let is_conflicted = git_spawn!(repo, domain -> { Ok::<_, git::GitError>(domain.branch_is_conflicted()) })?; Ok(BranchIsConflictedResponse { is_conflicted }) } }