use crate::AppService; use crate::error::AppError; use crate::git::{ CommitDiffFile, CommitDiffHunk, CommitDiffStats, CommitGraph, CommitMeta, CommitRefInfo, CommitReflogEntry, CommitSignature, CommitSort, CommitWalkOptions, }; use models::repos::repo; use redis::AsyncCommands; use serde::{Deserialize, Serialize}; use session::Session; #[derive(Debug, Clone, Deserialize, utoipa::ToSchema, utoipa::IntoParams)] pub struct CommitGetQuery { #[serde(default)] pub oid: String, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct CommitMetaResponse { pub oid: String, pub message: String, pub summary: String, pub author: CommitSignatureResponse, pub committer: CommitSignatureResponse, pub tree_id: String, pub parent_ids: Vec, pub encoding: Option, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct CommitSignatureResponse { pub name: String, pub email: String, pub time_secs: i64, pub offset_minutes: i32, } impl From for CommitSignatureResponse { fn from(s: CommitSignature) -> Self { Self { name: s.name, email: s.email, time_secs: s.time_secs, offset_minutes: s.offset_minutes, } } } impl From for CommitMetaResponse { fn from(c: CommitMeta) -> Self { Self { oid: c.oid.to_string(), message: c.message, summary: c.summary, author: CommitSignatureResponse::from(c.author), committer: CommitSignatureResponse::from(c.committer), tree_id: c.tree_id.to_string(), parent_ids: c.parent_ids.into_iter().map(|p| p.to_string()).collect(), encoding: c.encoding, } } } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct CommitExistsResponse { pub oid: String, pub exists: bool, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct CommitIsCommitResponse { pub oid: String, pub is_commit: bool, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct CommitMessageResponse { pub oid: String, pub message: String, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct CommitSummaryResponse { pub oid: String, pub summary: String, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct CommitShortIdResponse { pub oid: String, pub short_id: String, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct CommitAuthorResponse { pub oid: String, pub author: CommitSignatureResponse, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct CommitTreeIdResponse { pub oid: String, pub tree_id: String, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct CommitParentCountResponse { pub oid: String, pub parent_count: usize, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct CommitParentIdsResponse { pub oid: String, pub parent_ids: Vec, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct CommitIsMergeResponse { pub oid: String, pub is_merge: bool, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct CommitIsTipResponse { pub oid: String, pub is_tip: bool, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct CommitRefCountResponse { pub oid: String, pub ref_count: usize, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct CommitCountResponse { pub count: usize, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct CommitRefInfoResponse { pub name: String, pub target: String, pub is_remote: bool, pub is_tag: bool, } impl From for CommitRefInfoResponse { fn from(r: CommitRefInfo) -> Self { Self { name: r.name, target: r.target.to_string(), is_remote: r.is_remote, is_tag: r.is_tag, } } } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct CommitBranchesResponse { #[serde(default)] pub data: std::collections::HashMap>, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct CommitTagsResponse { #[serde(default)] pub data: std::collections::HashMap>, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct CommitReflogEntryResponse { pub new_sha: String, pub old_sha: String, pub committer_name: String, pub committer_email: String, pub time_secs: i64, pub message: Option, pub ref_name: String, } impl From for CommitReflogEntryResponse { fn from(e: CommitReflogEntry) -> Self { Self { new_sha: e.oid_new.to_string(), old_sha: e.oid_old.to_string(), committer_name: e.committer_name, committer_email: e.committer_email, time_secs: e.time_secs, message: e.message, ref_name: e.ref_name, } } } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct CommitGraphResponse { pub lines: Vec, pub max_parents: usize, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct CommitGraphLineResponse { pub oid: String, pub graph_chars: String, pub refs: String, pub short_message: String, } impl From for CommitGraphLineResponse { fn from(l: git::CommitGraphLine) -> Self { Self { oid: l.oid.to_string(), graph_chars: l.graph_chars, refs: l.refs, short_message: l.short_message, } } } impl From for CommitGraphResponse { fn from(g: CommitGraph) -> Self { Self { lines: g .lines .into_iter() .map(CommitGraphLineResponse::from) .collect(), max_parents: g.max_parents, } } } /// Response for the gitgraph-react compatible API endpoint. #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct LaneInfo { /// 0-based lane index. pub lane_index: usize, /// Branch name if this lane has a branch tip (None for unnamed lanes). pub branch_name: Option, /// SHA of the commit where this lane/branch starts. pub start_oid: String, /// SHA of the commit where this lane ends (None if lane continues to last commit). pub end_oid: Option, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct CommitGraphReactResponse { pub commits: Vec, pub lanes: Vec, pub max_parents: usize, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct CommitGraphReactCommit { pub oid: String, pub hash_abbrev: String, pub subject: String, pub body: Option, pub author_name: String, pub author_email: String, pub author_timestamp: i64, pub author_time_offset: i32, pub committer_name: String, pub committer_email: String, pub committer_timestamp: i64, pub committer_time_offset: i32, pub parent_hashes: Vec, pub encoding: Option, /// 0-based lane index used to assign branch color. pub lane_index: usize, /// Raw ASCII graph characters for supplementary rendering. pub graph_chars: String, /// Parsed refs string (e.g. "main, v1.0.0"). pub refs: String, /// Tag names present on this commit. pub tags: Vec, /// Branch names this commit is the tip of. pub branches: Vec, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct CommitDiffStatsResponse { pub oid: String, pub files_changed: usize, pub insertions: usize, pub deletions: usize, } impl From for CommitDiffStatsResponse { fn from(s: CommitDiffStats) -> Self { Self { oid: s.oid, files_changed: s.files_changed, insertions: s.insertions, deletions: s.deletions, } } } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct CommitDiffFileResponse { pub path: Option, pub status: String, pub is_binary: bool, pub size: u64, } impl From for CommitDiffFileResponse { fn from(f: CommitDiffFile) -> Self { Self { path: f.path, status: f.status, is_binary: f.is_binary, size: f.size, } } } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct CommitDiffHunkResponse { pub old_start: u32, pub old_lines: u32, pub new_start: u32, pub new_lines: u32, pub header: String, } impl From for CommitDiffHunkResponse { fn from(h: CommitDiffHunk) -> Self { Self { old_start: h.old_start, old_lines: h.old_lines, new_start: h.new_start, new_lines: h.new_lines, header: h.header, } } } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema, utoipa::IntoParams)] pub struct CommitLogQuery { pub rev: Option, /// Number of results per page (default 50, max 100). #[serde(default = "default_per_page")] pub per_page: usize, /// Page number (1-indexed, default 1). #[serde(default)] pub page: usize, } fn default_per_page() -> usize { 50 } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct CommitLogResponse { pub data: Vec, pub total: usize, pub page: usize, pub per_page: usize, pub total_pages: usize, } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema, utoipa::IntoParams)] pub struct CommitWalkQuery { pub rev: Option, pub limit: Option, #[serde(default)] pub first_parent_only: bool, #[serde(default)] pub topological: bool, #[serde(default)] pub reverse: bool, } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema, utoipa::IntoParams)] pub struct CommitAncestorsQuery { pub oid: String, pub limit: Option, } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema, utoipa::IntoParams)] pub struct CommitDescendantsQuery { pub oid: String, pub limit: Option, } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema, utoipa::IntoParams)] pub struct CommitResolveQuery { pub rev: String, } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct CommitCherryPickRequest { pub cherrypick_oid: String, pub author_name: String, pub author_email: String, pub committer_name: String, pub committer_email: String, pub message: Option, pub mainline: Option, pub update_ref: Option, } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct CommitCherryPickAbortRequest { pub reset_type: Option, } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct CommitRevertRequest { pub revert_oid: String, pub author_name: String, pub author_email: String, pub committer_name: String, pub committer_email: String, pub message: Option, pub mainline: Option, pub update_ref: Option, } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct CommitRevertAbortRequest { pub reset_type: Option, } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct CommitCreateResponse { pub oid: String, } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct CommitCreateRequest { pub author_name: String, pub author_email: String, pub committer_name: String, pub committer_email: String, pub message: String, pub tree_id: String, pub parent_ids: Vec, pub update_ref: Option, } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct CommitAmendRequest { pub oid: String, pub author_name: Option, pub author_email: Option, pub committer_name: Option, pub committer_email: Option, pub message: Option, pub message_encoding: Option, pub tree_id: Option, pub update_ref: Option, } #[derive(Debug, Clone, Deserialize)] pub struct CommitDiffQuery { pub oid: 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 { pub async fn git_commit_get( &self, namespace: String, repo_name: String, query: CommitGetQuery, ctx: &Session, ) -> Result { let cache_key = format!("git:commit:get:{}:{}:{}", namespace, repo_name, query.oid); if let Ok(mut conn) = self.cache.conn().await { if let Ok(cached) = conn.get::<_, String>(cache_key.clone()).await { if let Ok(cached) = serde_json::from_str(&cached) { return Ok(cached); } } } let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let oid_str = query.oid.clone(); let meta = git_spawn!(repo, domain -> { let oid = git::CommitOid::new(&oid_str); domain.commit_get(&oid) })?; let response = CommitMetaResponse::from(meta); if let Ok(mut conn) = self.cache.conn().await { if let Err(e) = conn .set_ex::( cache_key, serde_json::to_string(&response).unwrap_or_default(), 3600, ) .await { slog::debug!(self.logs, "cache set failed (non-fatal): {}", e); } } Ok(response) } pub async fn git_commit_exists( &self, namespace: String, repo_name: String, query: CommitGetQuery, ctx: &Session, ) -> Result { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let oid_str = query.oid.clone(); let exists = tokio::task::spawn_blocking(move || { let domain = git::GitDomain::from_model(repo)?; let oid = git::CommitOid::new(&oid_str); Ok::<_, git::GitError>(domain.commit_exists(&oid)) }) .await .map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))? .map_err(AppError::from)?; Ok(CommitExistsResponse { oid: query.oid, exists, }) } pub async fn git_commit_is_commit( &self, namespace: String, repo_name: String, query: CommitGetQuery, ctx: &Session, ) -> Result { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let oid_str = query.oid.clone(); let is_commit = tokio::task::spawn_blocking(move || { let domain = git::GitDomain::from_model(repo)?; let oid = git::CommitOid::new(&oid_str); Ok::<_, git::GitError>(domain.commit_is_commit(&oid)) }) .await .map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))? .map_err(AppError::from)?; Ok(CommitIsCommitResponse { oid: query.oid, is_commit, }) } pub async fn git_commit_message( &self, namespace: String, repo_name: String, query: CommitGetQuery, ctx: &Session, ) -> Result { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let oid_str = query.oid.clone(); let message = git_spawn!(repo, domain -> { let oid = git::CommitOid::new(&oid_str); domain.commit_message(&oid) })?; Ok(CommitMessageResponse { oid: query.oid, message, }) } pub async fn git_commit_summary( &self, namespace: String, repo_name: String, query: CommitGetQuery, ctx: &Session, ) -> Result { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let oid_str = query.oid.clone(); let summary = git_spawn!(repo, domain -> { let oid = git::CommitOid::new(&oid_str); domain.commit_summary(&oid) })?; Ok(CommitSummaryResponse { oid: query.oid, summary, }) } pub async fn git_commit_short_id( &self, namespace: String, repo_name: String, query: CommitGetQuery, ctx: &Session, ) -> Result { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let oid_str = query.oid.clone(); let short_id = git_spawn!(repo, domain -> { let oid = git::CommitOid::new(&oid_str); domain.commit_short_id(&oid) })?; Ok(CommitShortIdResponse { oid: query.oid, short_id, }) } pub async fn git_commit_author( &self, namespace: String, repo_name: String, query: CommitGetQuery, ctx: &Session, ) -> Result { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let oid_str = query.oid.clone(); let author = git_spawn!(repo, domain -> { let oid = git::CommitOid::new(&oid_str); domain.commit_author(&oid) })?; Ok(CommitAuthorResponse { oid: query.oid, author: CommitSignatureResponse::from(author), }) } pub async fn git_commit_tree_id( &self, namespace: String, repo_name: String, query: CommitGetQuery, ctx: &Session, ) -> Result { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let oid_str = query.oid.clone(); let tree_id = git_spawn!(repo, domain -> { let oid = git::CommitOid::new(&oid_str); domain.commit_tree_id(&oid) })?; Ok(CommitTreeIdResponse { oid: query.oid, tree_id: tree_id.to_string(), }) } pub async fn git_commit_parent_count( &self, namespace: String, repo_name: String, query: CommitGetQuery, ctx: &Session, ) -> Result { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let oid_str = query.oid.clone(); let parent_count = git_spawn!(repo, domain -> { let oid = git::CommitOid::new(&oid_str); domain.commit_parent_count(&oid) })?; Ok(CommitParentCountResponse { oid: query.oid, parent_count, }) } pub async fn git_commit_parent_ids( &self, namespace: String, repo_name: String, query: CommitGetQuery, ctx: &Session, ) -> Result { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let oid_str = query.oid.clone(); let parent_ids = git_spawn!(repo, domain -> { let oid = git::CommitOid::new(&oid_str); domain.commit_parent_ids(&oid) })?; Ok(CommitParentIdsResponse { oid: query.oid, parent_ids: parent_ids.into_iter().map(|p| p.to_string()).collect(), }) } pub async fn git_commit_parent( &self, namespace: String, repo_name: String, oid: String, index: usize, ctx: &Session, ) -> Result { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let oid_str = oid.clone(); let parent = git_spawn!(repo, domain -> { let commit_oid = git::CommitOid::new(&oid_str); domain.commit_parent(&commit_oid, index) })?; Ok(CommitMetaResponse::from(parent)) } pub async fn git_commit_first_parent( &self, namespace: String, repo_name: String, query: CommitGetQuery, ctx: &Session, ) -> Result, AppError> { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let oid_str = query.oid.clone(); let parent = git_spawn!(repo, domain -> { let oid = git::CommitOid::new(&oid_str); domain.commit_first_parent(&oid) })?; Ok(parent.map(CommitMetaResponse::from)) } pub async fn git_commit_is_merge( &self, namespace: String, repo_name: String, query: CommitGetQuery, ctx: &Session, ) -> Result { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let oid_str = query.oid.clone(); let is_merge = git_spawn!(repo, domain -> { let oid = git::CommitOid::new(&oid_str); domain.commit_is_merge(&oid) })?; Ok(CommitIsMergeResponse { oid: query.oid, is_merge, }) } pub async fn git_commit_log( &self, namespace: String, repo_name: String, query: CommitLogQuery, ctx: &Session, ) -> Result { let page = if query.page == 0 { 1 } else { query.page }; let per_page = query.per_page.clamp(1, 100); let offset = page.saturating_sub(1) * per_page; let repo = self .utils_find_repo(namespace.clone(), repo_name.clone(), ctx) .await?; let rev_clone = query.rev.clone(); let rev_for_count = query.rev.clone(); let commits = git_spawn!(repo, domain -> { domain.commit_log(rev_clone.as_deref(), offset, per_page) })?; let data: Vec = commits.into_iter().map(CommitMetaResponse::from).collect(); // Get total count for pagination metadata. let total_cache_key = format!( "git:commit:count:{}:{}:{:?}", namespace, repo_name, rev_for_count, ); let total: usize = if let Ok(mut conn) = self.cache.conn().await { if let Ok(cached) = conn.get::<_, String>(total_cache_key.clone()).await { if let Ok(cached) = serde_json::from_str::(&cached) { cached.count } else { 0 } } else { 0 } } else { 0 }; let total_pages = if total == 0 { 0 } else { (total + per_page - 1) / per_page }; Ok(CommitLogResponse { data, total, page, per_page, total_pages, }) } pub async fn git_commit_count( &self, namespace: String, repo_name: String, from: Option, to: Option, ctx: &Session, ) -> Result { let cache_key = format!( "git:commit:count:{}:{}:{:?}:{:?}", namespace, repo_name, from, to, ); if let Ok(mut conn) = self.cache.conn().await { if let Ok(cached) = conn.get::<_, String>(cache_key.clone()).await { if let Ok(cached) = serde_json::from_str(&cached) { return Ok(cached); } } } let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let from_clone = from.clone(); let to_clone = to.clone(); let count = git_spawn!(repo, domain -> { domain.commit_count(from_clone.as_deref(), to_clone.as_deref()) })?; let response = CommitCountResponse { count }; if let Ok(mut conn) = self.cache.conn().await { if let Err(e) = conn .set_ex::( cache_key, serde_json::to_string(&response).unwrap_or_default(), 300, ) .await { slog::debug!(self.logs, "cache set failed (non-fatal): {}", e); } } Ok(response) } pub async fn git_commit_refs( &self, namespace: String, repo_name: String, query: CommitGetQuery, ctx: &Session, ) -> Result, AppError> { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let oid_str = query.oid.clone(); let refs = git_spawn!(repo, domain -> { let oid = git::CommitOid::new(&oid_str); domain.commit_refs(&oid) })?; Ok(refs.into_iter().map(CommitRefInfoResponse::from).collect()) } pub async fn git_commit_branches( &self, namespace: String, repo_name: String, query: CommitGetQuery, ctx: &Session, ) -> Result { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; if query.oid.is_empty() { // Batch: fetch all refs, return branches grouped by commit OID let all_refs = git_spawn!(repo, domain -> { domain.refs_grouped() })?; let data: std::collections::HashMap> = all_refs .into_iter() .map(|(oid, (branches, _))| (oid, branches)) .collect(); return Ok(CommitBranchesResponse { data }); } // Per-commit: existing behavior let oid_str = query.oid.clone(); let oid_for_spawn = oid_str.clone(); let branches = git_spawn!(repo, domain -> { let oid = git::CommitOid::new(&oid_for_spawn); domain.commit_branches(&oid) })?; let mut data = std::collections::HashMap::new(); data.insert(oid_str, branches); Ok(CommitBranchesResponse { data }) } pub async fn git_commit_tags( &self, namespace: String, repo_name: String, query: CommitGetQuery, ctx: &Session, ) -> Result { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; if query.oid.is_empty() { // Batch: fetch all refs, return tags grouped by commit OID let all_refs = git_spawn!(repo, domain -> { domain.refs_grouped() })?; let data: std::collections::HashMap> = all_refs .into_iter() .map(|(oid, (_, tags))| (oid, tags)) .collect(); return Ok(CommitTagsResponse { data }); } // Per-commit: existing behavior let oid_str = query.oid.clone(); let oid_for_spawn = oid_str.clone(); let tags = git_spawn!(repo, domain -> { let oid = git::CommitOid::new(&oid_for_spawn); domain.commit_tags(&oid) })?; let mut data = std::collections::HashMap::new(); data.insert(oid_str, tags); Ok(CommitTagsResponse { data }) } pub async fn git_commit_is_tip( &self, namespace: String, repo_name: String, query: CommitGetQuery, ctx: &Session, ) -> Result { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let oid_str = query.oid.clone(); let is_tip = git_spawn!(repo, domain -> { let oid = git::CommitOid::new(&oid_str); domain.commit_is_tip(&oid) })?; Ok(CommitIsTipResponse { oid: query.oid, is_tip, }) } pub async fn git_commit_ref_count( &self, namespace: String, repo_name: String, query: CommitGetQuery, ctx: &Session, ) -> Result { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let oid_str = query.oid.clone(); let ref_count = git_spawn!(repo, domain -> { let oid = git::CommitOid::new(&oid_str); domain.commit_ref_count(&oid) })?; Ok(CommitRefCountResponse { oid: query.oid, ref_count, }) } pub async fn git_commit_reflog( &self, namespace: String, repo_name: String, _query: CommitGetQuery, refname: Option, ctx: &Session, ) -> Result, AppError> { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let entries = git_spawn!(repo, domain -> { domain.reflog_entries(refname.as_deref()) })?; Ok(entries .into_iter() .map(CommitReflogEntryResponse::from) .collect()) } pub async fn git_commit_graph( &self, namespace: String, repo_name: String, query: CommitWalkQuery, ctx: &Session, ) -> Result { let cache_key = format!( "git:commit:graph:{}:{}:{:?}:{}", namespace, repo_name, query.rev, query.limit.unwrap_or(0), ); if let Ok(mut conn) = self.cache.conn().await { if let Ok(cached) = conn.get::<_, String>(cache_key.clone()).await { if let Ok(cached) = serde_json::from_str(&cached) { return Ok(cached); } } } let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let rev_clone = query.rev.clone(); let limit = query.limit.unwrap_or(0); let graph = git_spawn!(repo, domain -> { domain.commit_graph_simple(rev_clone.as_deref(), limit) })?; let response = CommitGraphResponse::from(graph); if let Ok(mut conn) = self.cache.conn().await { if let Err(e) = conn .set_ex::( cache_key, serde_json::to_string(&response).unwrap_or_default(), 300, ) .await { slog::debug!(self.logs, "cache set failed (non-fatal): {}", e); } } Ok(response) } pub async fn git_commit_graph_react( &self, namespace: String, repo_name: String, query: CommitWalkQuery, ctx: &Session, ) -> Result { let cache_key = format!( "git:commit:graph:react:{}:{}:{:?}:{}:v2", namespace, repo_name, query.rev, query.limit.unwrap_or(0), ); if let Ok(mut conn) = self.cache.conn().await { if let Ok(cached) = conn.get::<_, String>(cache_key.clone()).await { if let Ok(cached) = serde_json::from_str(&cached) { return Ok(cached); } } } let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let rev_clone = query.rev.clone(); let limit = query.limit.unwrap_or(0); let (graph, refs_grouped) = git_spawn!(repo, domain -> { let g = domain.commit_graph_simple(rev_clone.as_deref(), limit)?; let refs = domain.refs_grouped().unwrap_or_default(); Ok::<_, git::GitError>((g, refs)) })?; let max_parents = graph.max_parents; let lines = graph.lines; let oid_to_lane_index: std::collections::HashMap = lines .iter() .map(|l| (l.oid.to_string(), l.lane_index)) .collect(); let commits: Vec = lines .into_iter() .map(|line| { let meta = line.meta; let oid_str = line.oid.to_string(); let (tags, branches) = refs_grouped.get(&oid_str).cloned().unwrap_or_default(); let tags: Vec = tags .into_iter() .map(|s| { s.trim_start_matches("refs/tags/") .trim_start_matches("refs/heads/") .to_string() }) .collect(); let branches: Vec = branches .into_iter() .map(|s| { s.trim_start_matches("refs/tags/") .trim_start_matches("refs/heads/") .to_string() }) .collect(); CommitGraphReactCommit { oid: oid_str, hash_abbrev: line.oid.to_string()[..7.min(line.oid.to_string().len())] .to_string(), subject: line.short_message, body: None, author_name: meta.author.name, author_email: meta.author.email, author_timestamp: meta.author.time_secs, author_time_offset: meta.author.offset_minutes, committer_name: meta.committer.name, committer_email: meta.committer.email, committer_timestamp: meta.committer.time_secs, committer_time_offset: meta.committer.offset_minutes, parent_hashes: meta.parent_ids.into_iter().map(|p| p.to_string()).collect(), encoding: meta.encoding, lane_index: line.lane_index, graph_chars: line.graph_chars, refs: line.refs, tags, branches, } }) .collect(); // Compute lane lifecycle. // Build a map: commit_oid -> Vec (all lanes active at this commit). let mut oid_lanes: std::collections::HashMap> = std::collections::HashMap::new(); for commit in &commits { oid_lanes .entry(commit.oid.clone()) .or_default() .push(commit.lane_index); } // Track lane state: lane_index -> Option (when lane starts with a branch). let mut lane_branch: std::collections::BTreeMap> = std::collections::BTreeMap::new(); let mut lane_start_oid: std::collections::BTreeMap = std::collections::BTreeMap::new(); let mut lane_end_oid: std::collections::BTreeMap> = std::collections::BTreeMap::new(); for commit in &commits { let lane = commit.lane_index; if !lane_start_oid.contains_key(&lane) { lane_start_oid.insert(lane, commit.oid.clone()); // If this commit has branches, assign the first one to this lane. let branch = commit.branches.first().cloned(); lane_branch.insert(lane, branch); } } // Determine lane end: a lane ends at the last commit that is on that lane // AND whose children are NOT on the same lane. for (i, commit) in commits.iter().enumerate() { let lane = commit.lane_index; let child_lanes: Vec = if i + 1 < commits.len() { commits[i + 1] .parent_hashes .iter() .filter_map(|p| oid_to_lane_index.get(p)) .copied() .collect() } else { vec![] }; if !child_lanes.contains(&lane) { // This commit's children don't continue on this lane → lane ends here. lane_end_oid.insert(lane, Some(commit.oid.clone())); } } // Use BTreeMap ordered iteration instead of sorted keys. let lanes: Vec = lane_start_oid .into_iter() .map(|(lane_index, start_oid)| LaneInfo { lane_index, branch_name: lane_branch.remove(&lane_index).flatten(), start_oid, end_oid: lane_end_oid.remove(&lane_index).flatten(), }) .collect(); let response = CommitGraphReactResponse { commits, lanes, max_parents, }; if let Ok(mut conn) = self.cache.conn().await { if let Err(e) = conn .set_ex::( cache_key, serde_json::to_string(&response).unwrap_or_default(), 300, ) .await { slog::debug!(self.logs, "cache set failed (non-fatal): {}", e); } } Ok(response) } pub async fn git_commit_walk( &self, namespace: String, repo_name: String, query: CommitWalkQuery, ctx: &Session, ) -> Result, AppError> { let cache_key = format!( "git:commit:walk:{}:{}:{:?}:{}:{}:{}:{}", namespace, repo_name, query.rev, query.limit.unwrap_or(0), query.first_parent_only, query.topological, query.reverse, ); if let Ok(mut conn) = self.cache.conn().await { if let Ok(cached) = conn.get::<_, String>(cache_key.clone()).await { if let Ok(cached) = serde_json::from_str(&cached) { return Ok(cached); } } } let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let rev_clone = query.rev.clone(); let limit = query.limit.unwrap_or(0); let first_parent_only = query.first_parent_only; let topological = query.topological; let reverse = query.reverse; let sort = if topological && reverse { CommitSort(CommitSort::TOPOLOGICAL.0 | CommitSort::TIME.0 | CommitSort::REVERSE.0) } else if topological { CommitSort(CommitSort::TOPOLOGICAL.0 | CommitSort::TIME.0) } else if reverse { CommitSort(CommitSort::TIME.0 | CommitSort::REVERSE.0) } else { CommitSort(CommitSort::TOPOLOGICAL.0 | CommitSort::TIME.0) }; let commits = git_spawn!(repo, domain -> { domain.commit_walk(CommitWalkOptions { rev: rev_clone, sort, limit, first_parent_only, }) })?; let response: Vec = commits.into_iter().map(CommitMetaResponse::from).collect(); if let Ok(mut conn) = self.cache.conn().await { if let Err(e) = conn .set_ex::( cache_key, serde_json::to_string(&response).unwrap_or_default(), 300, ) .await { slog::debug!(self.logs, "cache set failed (non-fatal): {}", e); } } Ok(response) } pub async fn git_commit_ancestors( &self, namespace: String, repo_name: String, query: CommitAncestorsQuery, ctx: &Session, ) -> Result, AppError> { let cache_key = format!( "git:commit:ancestors:{}:{}:{}:{}", namespace, repo_name, query.oid, query.limit.unwrap_or(0), ); if let Ok(mut conn) = self.cache.conn().await { if let Ok(cached) = conn.get::<_, String>(cache_key.clone()).await { if let Ok(cached) = serde_json::from_str(&cached) { return Ok(cached); } } } let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let oid_str = query.oid.clone(); let limit = query.limit.unwrap_or(0); let commits = git_spawn!(repo, domain -> { let oid = git::CommitOid::new(&oid_str); domain.commit_ancestors(&oid, limit) })?; let response: Vec = commits.into_iter().map(CommitMetaResponse::from).collect(); if let Ok(mut conn) = self.cache.conn().await { if let Err(e) = conn .set_ex::( cache_key, serde_json::to_string(&response).unwrap_or_default(), 300, ) .await { slog::debug!(self.logs, "cache set failed (non-fatal): {}", e); } } Ok(response) } pub async fn git_commit_descendants( &self, namespace: String, repo_name: String, query: CommitDescendantsQuery, ctx: &Session, ) -> Result, AppError> { let cache_key = format!( "git:commit:descendants:{}:{}:{}:{}", namespace, repo_name, query.oid, query.limit.unwrap_or(0), ); if let Ok(mut conn) = self.cache.conn().await { if let Ok(cached) = conn.get::<_, String>(cache_key.clone()).await { if let Ok(cached) = serde_json::from_str(&cached) { return Ok(cached); } } } let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let oid_str = query.oid.clone(); let limit = query.limit.unwrap_or(0); let commits = git_spawn!(repo, domain -> { let oid = git::CommitOid::new(&oid_str); domain.commit_descendants(&oid, limit) })?; let response: Vec = commits.into_iter().map(CommitMetaResponse::from).collect(); if let Ok(mut conn) = self.cache.conn().await { if let Err(e) = conn .set_ex::( cache_key, serde_json::to_string(&response).unwrap_or_default(), 300, ) .await { slog::debug!(self.logs, "cache set failed (non-fatal): {}", e); } } Ok(response) } pub async fn git_commit_resolve_rev( &self, namespace: String, repo_name: String, query: CommitResolveQuery, ctx: &Session, ) -> Result { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let rev_str = query.rev.clone(); let oid = git_spawn!(repo, domain -> { domain.resolve_rev(&rev_str) })?; Ok(oid.to_string()) } pub async fn git_commit_create( &self, namespace: String, repo_name: String, request: CommitCreateRequest, ctx: &Session, ) -> Result { let repo: repo::Model = self .utils_check_repo_admin(namespace, repo_name, ctx) .await?; let parent_ids: Vec<_> = request .parent_ids .iter() .map(|p| git::CommitOid::new(p)) .collect(); let author = git::CommitSignature { name: request.author_name, email: request.author_email, time_secs: chrono::Utc::now().timestamp(), offset_minutes: 0, }; let committer = git::CommitSignature { name: request.committer_name, email: request.committer_email, time_secs: chrono::Utc::now().timestamp(), offset_minutes: 0, }; let tree_id = git::CommitOid::new(&request.tree_id); let update_ref = request.update_ref.clone(); let oid = git_spawn!(repo, domain -> { domain.commit_create( update_ref.as_deref(), &author, &committer, &request.message, &tree_id, &parent_ids, ) })?; Ok(CommitCreateResponse { oid: oid.to_string(), }) } pub async fn git_commit_amend( &self, namespace: String, repo_name: String, request: CommitAmendRequest, ctx: &Session, ) -> Result { let repo: repo::Model = self .utils_check_repo_admin(namespace, repo_name, ctx) .await?; let oid = git::CommitOid::new(&request.oid); let author = if let (Some(name), Some(email)) = (&request.author_name, &request.author_email) { Some(git::CommitSignature { name: name.clone(), email: email.clone(), time_secs: 0, offset_minutes: 0, }) } else { None }; let committer = if let (Some(name), Some(email)) = (&request.committer_name, &request.committer_email) { Some(git::CommitSignature { name: name.clone(), email: email.clone(), time_secs: 0, offset_minutes: 0, }) } else { None }; let tree_id = request.tree_id.as_ref().map(|t| git::CommitOid::new(t)); let update_ref = request.update_ref.clone(); let message_encoding = request.message_encoding.clone(); let message = request.message.clone(); let new_oid = git_spawn!(repo, domain -> { domain.commit_amend( &oid, update_ref.as_deref(), author.as_ref(), committer.as_ref(), message_encoding.as_deref(), message.as_deref(), tree_id.as_ref(), ) })?; Ok(CommitCreateResponse { oid: new_oid.to_string(), }) } pub async fn git_commit_cherry_pick( &self, namespace: String, repo_name: String, request: CommitCherryPickRequest, ctx: &Session, ) -> Result { let repo: repo::Model = self .utils_check_repo_admin(namespace, repo_name, ctx) .await?; let cherrypick_oid = git::CommitOid::new(&request.cherrypick_oid); let author = git::CommitSignature { name: request.author_name, email: request.author_email, time_secs: chrono::Utc::now().timestamp(), offset_minutes: 0, }; let committer = git::CommitSignature { name: request.committer_name, email: request.committer_email, time_secs: chrono::Utc::now().timestamp(), offset_minutes: 0, }; let message = request.message.clone(); let mainline = request.mainline.unwrap_or(0); let update_ref = request.update_ref.clone(); let oid = git_spawn!(repo, domain -> { domain.commit_cherry_pick( &cherrypick_oid, &author, &committer, message.as_deref(), mainline, update_ref.as_deref(), ) })?; Ok(CommitCreateResponse { oid: oid.to_string(), }) } pub async fn git_commit_cherry_pick_abort( &self, namespace: String, repo_name: String, request: CommitCherryPickAbortRequest, ctx: &Session, ) -> Result<(), AppError> { let repo: repo::Model = self .utils_check_repo_admin(namespace, repo_name, ctx) .await?; let reset_type = request.reset_type.clone(); git_spawn!(repo, domain -> { domain.commit_cherry_pick_abort(reset_type.as_deref().unwrap_or("hard")) })?; Ok(()) } pub async fn git_commit_revert( &self, namespace: String, repo_name: String, request: CommitRevertRequest, ctx: &Session, ) -> Result { let repo: repo::Model = self .utils_check_repo_admin(namespace, repo_name, ctx) .await?; let revert_oid = git::CommitOid::new(&request.revert_oid); let author = git::CommitSignature { name: request.author_name, email: request.author_email, time_secs: chrono::Utc::now().timestamp(), offset_minutes: 0, }; let committer = git::CommitSignature { name: request.committer_name, email: request.committer_email, time_secs: chrono::Utc::now().timestamp(), offset_minutes: 0, }; let message = request.message.clone(); let mainline = request.mainline.unwrap_or(0); let update_ref = request.update_ref.clone(); let oid = git_spawn!(repo, domain -> { domain.commit_revert( &revert_oid, &author, &committer, message.as_deref(), mainline, update_ref.as_deref(), ) })?; Ok(CommitCreateResponse { oid: oid.to_string(), }) } pub async fn git_commit_revert_abort( &self, namespace: String, repo_name: String, request: CommitRevertAbortRequest, ctx: &Session, ) -> Result<(), AppError> { let repo: repo::Model = self .utils_check_repo_admin(namespace, repo_name, ctx) .await?; let reset_type = request.reset_type.clone(); git_spawn!(repo, domain -> { domain.commit_revert_abort(reset_type.as_deref().unwrap_or("hard")) })?; Ok(()) } }