//! Diff operations. use std::cell::RefCell; use crate::commit::types::CommitOid; use crate::diff::types::{ DiffDelta, DiffHunk, DiffLine, DiffOptions, DiffResult, DiffStats, SideBySideChangeType, SideBySideDiffResult, SideBySideFile, SideBySideLine, }; use crate::{GitDomain, GitError, GitResult}; impl GitDomain { pub fn diff_tree_to_tree( &self, old_tree: Option<&CommitOid>, new_tree: Option<&CommitOid>, opts: Option, ) -> GitResult { let old_tree = match old_tree { Some(oid) => { let o = oid .to_oid() .map_err(|_| GitError::InvalidOid(oid.to_string()))?; Some( self.repo() .find_tree(o) .map_err(|e| GitError::Internal(e.to_string()))?, ) } None => None, }; let new_tree = match new_tree { Some(oid) => { let o = oid .to_oid() .map_err(|_| GitError::InvalidOid(oid.to_string()))?; Some( self.repo() .find_tree(o) .map_err(|e| GitError::Internal(e.to_string()))?, ) } None => None, }; let mut git_opts = opts .map(|o| o.to_git2()) .unwrap_or_else(git2::DiffOptions::new); // git2 requires at least one tree to be Some let diff = match (old_tree.as_ref(), new_tree.as_ref()) { (Some(old), Some(new)) => { self.repo() .diff_tree_to_tree(Some(old), Some(new), Some(&mut git_opts)) } (Some(old), None) => { self.repo() .diff_tree_to_tree(Some(old), None, Some(&mut git_opts)) } (None, Some(new)) => { self.repo() .diff_tree_to_tree(None, Some(new), Some(&mut git_opts)) } (None, None) => { return Err(GitError::Internal( "Both old_tree and new_tree are None".into(), )); } } .map_err(|e| GitError::Internal(e.to_string()))?; build_diff_result(&diff) } pub fn diff_commit_to_workdir( &self, commit: &CommitOid, opts: Option, ) -> GitResult { let oid = commit .to_oid() .map_err(|_| GitError::InvalidOid(commit.to_string()))?; let commit = self .repo() .find_commit(oid) .map_err(|e| GitError::Internal(e.to_string()))?; let tree = self .repo() .find_tree(commit.tree_id()) .map_err(|e| GitError::Internal(e.to_string()))?; let mut git_opts = opts .map(|o| o.to_git2()) .unwrap_or_else(git2::DiffOptions::new); let diff = self .repo() .diff_tree_to_workdir(Some(&tree), Some(&mut git_opts)) .map_err(|e| GitError::Internal(e.to_string()))?; build_diff_result(&diff) } pub fn diff_commit_to_index( &self, commit: &CommitOid, opts: Option, ) -> GitResult { let oid = commit .to_oid() .map_err(|_| GitError::InvalidOid(commit.to_string()))?; let commit = self .repo() .find_commit(oid) .map_err(|e| GitError::Internal(e.to_string()))?; let tree = self .repo() .find_tree(commit.tree_id()) .map_err(|e| GitError::Internal(e.to_string()))?; // Get the index as a tree for comparison. let mut index = self .repo() .index() .map_err(|e| GitError::Internal(e.to_string()))?; let index_tree_oid = index .write_tree() .map_err(|e| GitError::Internal(e.to_string()))?; let index_tree = self .repo() .find_tree(index_tree_oid) .map_err(|e| GitError::Internal(e.to_string()))?; let mut git_opts = opts .map(|o| o.to_git2()) .unwrap_or_else(git2::DiffOptions::new); let diff = self .repo() .diff_tree_to_tree(Some(&tree), Some(&index_tree), Some(&mut git_opts)) .map_err(|e| GitError::Internal(e.to_string()))?; build_diff_result(&diff) } pub fn diff_workdir_to_index(&self, opts: Option) -> GitResult { let mut git_opts = opts .map(|o| o.to_git2()) .unwrap_or_else(git2::DiffOptions::new); let diff = self .repo() .diff_tree_to_workdir(None, Some(&mut git_opts)) .map_err(|e| GitError::Internal(e.to_string()))?; build_diff_result(&diff) } pub fn diff_index_to_tree( &self, tree: &CommitOid, opts: Option, ) -> GitResult { let oid = tree .to_oid() .map_err(|_| GitError::InvalidOid(tree.to_string()))?; let tree = self .repo() .find_tree(oid) .map_err(|e| GitError::Internal(e.to_string()))?; let mut git_opts = opts .map(|o| o.to_git2()) .unwrap_or_else(git2::DiffOptions::new); let diff = self .repo() .diff_tree_to_tree(Some(&tree), None, Some(&mut git_opts)) .map_err(|e| GitError::Internal(e.to_string()))?; build_diff_result(&diff) } pub fn diff_stats(&self, old_tree: &CommitOid, new_tree: &CommitOid) -> GitResult { let old_oid = old_tree .to_oid() .map_err(|_| GitError::InvalidOid(old_tree.to_string()))?; let new_oid = new_tree .to_oid() .map_err(|_| GitError::InvalidOid(new_tree.to_string()))?; let old_tree = self .repo() .find_tree(old_oid) .map_err(|e| GitError::Internal(e.to_string()))?; let new_tree = self .repo() .find_tree(new_oid) .map_err(|e| GitError::Internal(e.to_string()))?; let diff = self .repo() .diff_tree_to_tree(Some(&old_tree), Some(&new_tree), None) .map_err(|e| GitError::Internal(e.to_string()))?; let stats = diff .stats() .map_err(|e| GitError::Internal(e.to_string()))?; Ok(DiffStats::from_git2(&stats)) } pub fn diff_patch_id(&self, old_tree: &CommitOid, new_tree: &CommitOid) -> GitResult { let old_oid = old_tree .to_oid() .map_err(|_| GitError::InvalidOid(old_tree.to_string()))?; let new_oid = new_tree .to_oid() .map_err(|_| GitError::InvalidOid(new_tree.to_string()))?; let old_tree = self .repo() .find_tree(old_oid) .map_err(|e| GitError::Internal(e.to_string()))?; let new_tree = self .repo() .find_tree(new_oid) .map_err(|e| GitError::Internal(e.to_string()))?; let diff = self .repo() .diff_tree_to_tree(Some(&old_tree), Some(&new_tree), None) .map_err(|e| GitError::Internal(e.to_string()))?; let patch_id = diff .patchid(None) .map_err(|e| GitError::Internal(e.to_string()))?; Ok(patch_id.to_string()) } } fn build_diff_result(diff: &git2::Diff<'_>) -> GitResult { let stats = diff .stats() .map_err(|e| GitError::Internal(e.to_string()))?; let delta_count = diff.deltas().len(); let deltas: RefCell> = RefCell::new(Vec::with_capacity(delta_count)); let delta_hunks: RefCell> = RefCell::new(Vec::new()); let delta_lines: RefCell> = RefCell::new(Vec::new()); let counter: RefCell = RefCell::new(0); let mut file_cb = |_delta: git2::DiffDelta<'_>, _progress: f32| -> bool { let count = *counter.borrow(); if count > 0 { let prev_idx = count - 1; let hunks = delta_hunks.take(); let lines = delta_lines.take(); if let Some(prev_delta) = diff.get_delta(prev_idx) { deltas .borrow_mut() .push(DiffDelta::from_git2(&prev_delta, hunks, lines)); } } *counter.borrow_mut() = count + 1; true }; let mut hunk_cb = |_delta: git2::DiffDelta<'_>, hunk: git2::DiffHunk<'_>| -> bool { delta_hunks.borrow_mut().push(DiffHunk::from_git2(&hunk)); true }; let mut line_cb = |_delta: git2::DiffDelta<'_>, _hunk: Option>, line: git2::DiffLine<'_>| -> bool { delta_lines.borrow_mut().push(DiffLine::from_git2(&line)); true }; diff.foreach(&mut file_cb, None, Some(&mut hunk_cb), Some(&mut line_cb)) .map_err(|e| GitError::Internal(e.to_string()))?; { let count = *counter.borrow(); if count > 0 { let last_idx = count - 1; let hunks = delta_hunks.take(); let lines = delta_lines.take(); if let Some(last_delta) = diff.get_delta(last_idx) { deltas .borrow_mut() .push(DiffDelta::from_git2(&last_delta, hunks, lines)); } } } Ok(DiffResult { stats: DiffStats::from_git2(&stats), deltas: deltas.into_inner(), }) } /// The algorithm walks the unified diff line-by-line and produces rows where: /// - **Added** lines appear only on the right side. /// - **Deleted** lines appear only on the left side. /// - When a deletion is immediately followed by an addition they are rendered as a /// **Modified** pair (two separate rows). /// - **Context** lines appear on both sides with the same content. /// - **Empty** filler rows are inserted so that left and right line numbers stay aligned. pub fn diff_to_side_by_side(diff: &DiffResult) -> SideBySideDiffResult { let mut files: Vec = Vec::with_capacity(diff.deltas.len()); let mut total_additions = 0usize; let mut total_deletions = 0usize; for delta in &diff.deltas { let (path, is_binary, is_rename) = if delta.status == crate::diff::types::DiffDeltaStatus::Renamed { ( delta .new_file .path .clone() .or_else(|| delta.old_file.path.clone()) .unwrap_or_default(), delta.new_file.is_binary || delta.old_file.is_binary, true, ) } else { ( delta .new_file .path .clone() .or_else(|| delta.old_file.path.clone()) .unwrap_or_default(), delta.new_file.is_binary, false, ) }; if is_binary { files.push(SideBySideFile { path, additions: 0, deletions: 0, is_binary: true, is_rename, lines: vec![], }); continue; } let lines = build_side_by_side_lines(&delta.lines); let additions = lines .iter() .filter(|l| matches!(l.change_type, SideBySideChangeType::Added)) .count(); let deletions = lines .iter() .filter(|l| matches!(l.change_type, SideBySideChangeType::Removed)) .count(); total_additions += additions; total_deletions += deletions; files.push(SideBySideFile { path, additions, deletions, is_binary: false, is_rename, lines, }); } SideBySideDiffResult { files, total_additions, total_deletions, } } fn build_side_by_side_lines(unified: &[DiffLine]) -> Vec { let mut result: Vec = Vec::with_capacity(unified.len() * 2); let mut i = 0; while i < unified.len() { let line = &unified[i]; match line.origin { '+' => { // Collect a run of consecutive additions. let mut added_lines: Vec<&DiffLine> = vec![line]; while i + 1 < unified.len() && unified[i + 1].origin == '+' { i += 1; added_lines.push(&unified[i]); } // Peek at the previous deletion run to pair with additions. let mut deleted_lines: Vec<&DiffLine> = vec![]; // Backtrack to find deletions right before this run. let Some(j_base) = i.checked_sub(added_lines.len()) else { // Additions at start of diff — no preceding deletions to pair with. // Emit all as unpaired Added below. for k in 0..added_lines.len() { let add = added_lines[k]; result.push(SideBySideLine { left_line_no: None, right_line_no: add.new_lineno, left_content: String::new(), right_content: add.content.clone(), change_type: SideBySideChangeType::Added, }); } i += 1; continue; }; let mut j = j_base; while j > 0 && unified[j].origin == '-' { j -= 1; } // Collect deletions from j+1 up to (but not including) the first addition. let del_start = if j > 0 { j + 1 } else { 0 }; for k in del_start..i { if unified[k].origin == '-' { deleted_lines.push(&unified[k]); } } // If we have paired deletions, emit them as Modified pairs. let pairs = deleted_lines.len().min(added_lines.len()); for k in 0..pairs { let del = deleted_lines[k]; let add = added_lines[k]; result.push(SideBySideLine { left_line_no: del.old_lineno, right_line_no: add.new_lineno, left_content: del.content.clone(), right_content: add.content.clone(), change_type: SideBySideChangeType::Modified, }); } // Remaining unpaired additions. for k in pairs..added_lines.len() { let add = added_lines[k]; result.push(SideBySideLine { left_line_no: None, right_line_no: add.new_lineno, left_content: String::new(), right_content: add.content.clone(), change_type: SideBySideChangeType::Added, }); } // Remaining unpaired deletions (only possible if deletions > additions). for k in pairs..deleted_lines.len() { let del = deleted_lines[k]; result.push(SideBySideLine { left_line_no: del.old_lineno, right_line_no: None, left_content: del.content.clone(), right_content: String::new(), change_type: SideBySideChangeType::Removed, }); } } '-' => { // Collect a run of consecutive deletions. let mut deleted_lines: Vec<&DiffLine> = vec![line]; while i + 1 < unified.len() && unified[i + 1].origin == '-' { i += 1; deleted_lines.push(&unified[i]); } // Emit each deletion (unless already paired above). // We defer pairing to the addition-handling block above, // so here we just emit unpaired deletions. for del in &deleted_lines { result.push(SideBySideLine { left_line_no: del.old_lineno, right_line_no: None, left_content: del.content.clone(), right_content: String::new(), change_type: SideBySideChangeType::Removed, }); } } _ => { // Context line — appears on both sides. result.push(SideBySideLine { left_line_no: line.old_lineno, right_line_no: line.new_lineno, left_content: line.content.clone(), right_content: line.content.clone(), change_type: SideBySideChangeType::Unchanged, }); } } i += 1; } result }