use crate::AppService; use crate::error::AppError; use crate::git::{DiffDelta, DiffOptions, DiffResult, DiffStats, GitError, SideBySideDiffResult}; use redis::AsyncCommands; use serde::{Deserialize, Serialize}; use session::Session; #[derive(Debug, Clone, Deserialize, utoipa::ToSchema, utoipa::IntoParams)] pub struct DiffQuery { #[serde(default)] pub old_tree: Option, #[serde(default)] pub new_tree: Option, #[serde(default)] pub context_lines: Option, #[serde(default)] pub pathspec: Option>, #[serde(default)] pub include_untracked: Option, #[serde(default)] pub include_ignored: Option, #[serde(default)] pub ignore_whitespace: Option, #[serde(default)] pub force_text: Option, #[serde(default)] pub reverse: Option, } impl DiffQuery { fn to_diff_options(&self) -> DiffOptions { let mut opts = DiffOptions::new(); if let Some(n) = self.context_lines { opts = opts.context_lines(n); } if let Some(ref paths) = self.pathspec { for p in paths { opts = opts.pathspec(p); } } if self.include_untracked.unwrap_or(false) { opts = opts.include_untracked(); } if self.include_ignored.unwrap_or(false) { opts = opts.include_ignored(); } if self.ignore_whitespace.unwrap_or(false) { opts = opts.ignore_whitespace(); } if self.force_text.unwrap_or(false) { opts = opts.force_text(); } if self.reverse.unwrap_or(false) { opts = opts.reverse(); } opts } } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct DiffCommitQuery { #[serde(default)] pub commit: String, #[serde(flatten)] #[serde(default)] pub diff_opts: DiffQuery, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct DiffStatsResponse { pub files_changed: usize, pub insertions: usize, pub deletions: usize, } impl From for DiffStatsResponse { fn from(s: DiffStats) -> Self { Self { files_changed: s.files_changed, insertions: s.insertions, deletions: s.deletions, } } } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct DiffDeltaResponse { pub status: String, pub old_file: DiffFileResponse, pub new_file: DiffFileResponse, pub nfiles: u16, pub hunks: Vec, pub lines: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct DiffFileResponse { pub oid: Option, pub path: Option, pub size: u64, pub is_binary: bool, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct DiffHunkResponse { pub old_start: u32, pub old_lines: u32, pub new_start: u32, pub new_lines: u32, pub header: String, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct DiffLineResponse { pub content: String, pub origin: String, pub old_lineno: Option, pub new_lineno: Option, pub num_lines: u32, pub content_offset: i64, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct DiffResultResponse { pub stats: DiffStatsResponse, pub deltas: Vec, } impl From for DiffResultResponse { fn from(r: DiffResult) -> Self { Self { stats: DiffStatsResponse::from(r.stats), deltas: r.deltas.into_iter().map(DiffDeltaResponse::from).collect(), } } } impl From for DiffDeltaResponse { fn from(d: DiffDelta) -> Self { Self { status: format!("{:?}", d.status).to_lowercase(), old_file: DiffFileResponse { oid: d.old_file.oid.map(|o| o.to_string()), path: d.old_file.path, size: d.old_file.size, is_binary: d.old_file.is_binary, }, new_file: DiffFileResponse { oid: d.new_file.oid.map(|o| o.to_string()), path: d.new_file.path, size: d.new_file.size, is_binary: d.new_file.is_binary, }, nfiles: d.nfiles, hunks: d .hunks .into_iter() .map(|h| DiffHunkResponse { old_start: h.old_start, old_lines: h.old_lines, new_start: h.new_start, new_lines: h.new_lines, header: h.header, }) .collect(), lines: d .lines .into_iter() .map(|l| DiffLineResponse { content: l.content, origin: l.origin.to_string(), old_lineno: l.old_lineno, new_lineno: l.new_lineno, num_lines: l.num_lines, content_offset: l.content_offset, }) .collect(), } } } #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct DiffPatchIdResponse { pub old_tree: String, pub new_tree: String, pub patch_id: String, } // --------------------------------------------------------------------------- // Side-by-side diff response types // --------------------------------------------------------------------------- #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] #[serde(rename_all = "lowercase")] pub enum SideBySideChangeTypeResponse { Unchanged, Added, Removed, Modified, Empty, } impl From for SideBySideChangeTypeResponse { fn from(v: crate::git::SideBySideChangeType) -> Self { match v { crate::git::SideBySideChangeType::Unchanged => Self::Unchanged, crate::git::SideBySideChangeType::Added => Self::Added, crate::git::SideBySideChangeType::Removed => Self::Removed, crate::git::SideBySideChangeType::Modified => Self::Modified, crate::git::SideBySideChangeType::Empty => Self::Empty, } } } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct SideBySideLineResponse { pub left_line_no: Option, pub right_line_no: Option, pub left_content: String, pub right_content: String, pub change_type: SideBySideChangeTypeResponse, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct SideBySideFileResponse { pub path: String, pub additions: usize, pub deletions: usize, pub is_binary: bool, pub is_rename: bool, pub lines: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct SideBySideDiffResponse { pub files: Vec, pub total_additions: usize, pub total_deletions: usize, } impl From for SideBySideDiffResponse { fn from(r: SideBySideDiffResult) -> Self { Self { files: r .files .into_iter() .map(|f| SideBySideFileResponse { path: f.path, additions: f.additions, deletions: f.deletions, is_binary: f.is_binary, is_rename: f.is_rename, lines: f .lines .into_iter() .map(|l| SideBySideLineResponse { left_line_no: l.left_line_no, right_line_no: l.right_line_no, left_content: l.left_content, right_content: l.right_content, change_type: l.change_type.into(), }) .collect(), }) .collect(), total_additions: r.total_additions, total_deletions: r.total_deletions, } } } /// Query parameters for side-by-side diff. #[derive(Debug, Clone, Deserialize, utoipa::IntoParams, utoipa::ToSchema)] pub struct SideBySideDiffQuery { /// OID (SHA) of the base / old commit or tree. pub base: String, /// OID (SHA) of the head / new commit or tree. pub head: String, /// Optional path filter — only include files matching this prefix. #[serde(default)] pub pathspec: Option>, /// Number of context lines around changes (default 3). #[serde(default)] pub context_lines: Option, } impl AppService { pub async fn git_diff_tree_to_tree( &self, namespace: String, repo_name: String, query: DiffQuery, ctx: &Session, ) -> Result { let repo = self .utils_find_repo(namespace.clone(), repo_name.clone(), ctx) .await?; let cache_key = format!( "git:diff:{}:{}:{}:{}", namespace, repo_name, query.old_tree.as_deref().unwrap_or(""), query.new_tree.as_deref().unwrap_or(""), ); 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 old_tree = query .old_tree .as_ref() .map(|s| git::CommitOid::new(s.as_str())); let new_tree = git::CommitOid::new( query .new_tree .as_deref() .ok_or_else(|| AppError::BadRequest("new_tree is required".into()))?, ); let opts = query.to_diff_options(); let result = tokio::task::spawn_blocking(move || { let domain = git::GitDomain::from_model(repo)?; domain.diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), Some(opts)) }) .await .map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))? .map_err(AppError::from)?; let response = DiffResultResponse::from(result); 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(), 60 * 60, ) .await { slog::debug!(self.logs, "cache set failed (non-fatal): {}", e); } } Ok(response) } pub async fn git_diff_commit_to_workdir( &self, namespace: String, repo_name: String, query: DiffCommitQuery, ctx: &Session, ) -> Result { let repo = self .utils_find_repo(namespace.clone(), repo_name.clone(), ctx) .await?; let cache_key = format!("git:diff:c2wd:{}:{}:{}", namespace, repo_name, query.commit,); 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 commit = git::CommitOid::new(&query.commit); let opts = query.diff_opts.to_diff_options(); let result = tokio::task::spawn_blocking(move || { let domain = git::GitDomain::from_model(repo)?; domain.diff_commit_to_workdir(&commit, Some(opts)) }) .await .map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))? .map_err(AppError::from)?; let response = DiffResultResponse::from(result); 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(), 60 * 60, ) .await { slog::debug!(self.logs, "cache set failed (non-fatal): {}", e); } } Ok(response) } pub async fn git_diff_commit_to_index( &self, namespace: String, repo_name: String, query: DiffCommitQuery, ctx: &Session, ) -> Result { let repo = self .utils_find_repo(namespace.clone(), repo_name.clone(), ctx) .await?; let cache_key = format!( "git:diff:c2idx:{}:{}:{}", namespace, repo_name, query.commit, ); 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 commit = git::CommitOid::new(&query.commit); let opts = query.diff_opts.to_diff_options(); let result = tokio::task::spawn_blocking(move || { let domain = git::GitDomain::from_model(repo)?; domain.diff_commit_to_index(&commit, Some(opts)) }) .await .map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))? .map_err(AppError::from)?; let response = DiffResultResponse::from(result); 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(), 60 * 60, ) .await { slog::debug!(self.logs, "cache set failed (non-fatal): {}", e); } } Ok(response) } pub async fn git_diff_workdir_to_index( &self, namespace: String, repo_name: String, query: DiffQuery, ctx: &Session, ) -> Result { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let opts = query.to_diff_options(); let result = tokio::task::spawn_blocking(move || { let domain = git::GitDomain::from_model(repo)?; domain.diff_workdir_to_index(Some(opts)) }) .await .map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))? .map_err(AppError::from)?; Ok(DiffResultResponse::from(result)) } pub async fn git_diff_index_to_tree( &self, namespace: String, repo_name: String, query: DiffQuery, ctx: &Session, ) -> Result { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let tree = git::CommitOid::new( query .new_tree .as_deref() .ok_or_else(|| AppError::BadRequest("new_tree is required".into()))?, ); let opts = query.to_diff_options(); let result = tokio::task::spawn_blocking(move || { let domain = git::GitDomain::from_model(repo)?; domain.diff_index_to_tree(&tree, Some(opts)) }) .await .map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))? .map_err(AppError::from)?; Ok(DiffResultResponse::from(result)) } pub async fn git_diff_stats( &self, namespace: String, repo_name: String, query: DiffQuery, ctx: &Session, ) -> Result { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let old_tree = git::CommitOid::new( query .old_tree .as_deref() .ok_or_else(|| AppError::BadRequest("old_tree is required".into()))?, ); let new_tree = git::CommitOid::new( query .new_tree .as_deref() .ok_or_else(|| AppError::BadRequest("new_tree is required".into()))?, ); let stats = tokio::task::spawn_blocking(move || { let domain = git::GitDomain::from_model(repo)?; domain.diff_stats(&old_tree, &new_tree) }) .await .map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))? .map_err(AppError::from)?; Ok(DiffStatsResponse::from(stats)) } pub async fn git_diff_patch_id( &self, namespace: String, repo_name: String, query: DiffQuery, ctx: &Session, ) -> Result { let repo = self.utils_find_repo(namespace, repo_name, ctx).await?; let old_tree = git::CommitOid::new( query .old_tree .as_deref() .ok_or_else(|| AppError::BadRequest("old_tree is required".into()))?, ); let new_tree = git::CommitOid::new( query .new_tree .as_deref() .ok_or_else(|| AppError::BadRequest("new_tree is required".into()))?, ); let patch_id = tokio::task::spawn_blocking(move || { let domain = git::GitDomain::from_model(repo)?; domain.diff_patch_id(&old_tree, &new_tree) }) .await .map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))? .map_err(AppError::from)?; Ok(DiffPatchIdResponse { old_tree: query.old_tree.unwrap_or_default(), new_tree: query.new_tree.unwrap_or_default(), patch_id, }) } /// Generate a side-by-side diff between two commits or trees. pub async fn git_diff_side_by_side( &self, namespace: String, repo_name: String, query: SideBySideDiffQuery, ctx: &Session, ) -> Result { let repo = self .utils_find_repo(namespace.clone(), repo_name.clone(), ctx) .await?; let cache_key = format!( "git:diff:sbs:{}:{}:{}:{}", namespace, repo_name, query.base, query.head, ); 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 base = git::CommitOid::new(&query.base); let head = git::CommitOid::new(&query.head); let mut opts = DiffOptions::new(); if let Some(n) = query.context_lines { opts = opts.context_lines(n); } if let Some(ref paths) = query.pathspec { for p in paths { opts = opts.pathspec(p); } } let result = tokio::task::spawn_blocking(move || { let domain = git::GitDomain::from_model(repo)?; let diff_result = domain.diff_tree_to_tree(Some(&base), Some(&head), Some(opts))?; Ok::<_, GitError>(git::diff_to_side_by_side(&diff_result)) }) .await .map_err(|e| AppError::InternalServerError(format!("Task join error: {}", e)))? .map_err(AppError::from)?; let response = SideBySideDiffResponse::from(result); 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(), 60 * 60, ) .await { slog::debug!(self.logs, "cache set failed (non-fatal): {}", e); } } Ok(response) } }