511 lines
17 KiB
Rust
511 lines
17 KiB
Rust
//! 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<DiffOptions>,
|
|
) -> GitResult<DiffResult> {
|
|
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<DiffOptions>,
|
|
) -> GitResult<DiffResult> {
|
|
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<DiffOptions>,
|
|
) -> GitResult<DiffResult> {
|
|
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<DiffOptions>) -> GitResult<DiffResult> {
|
|
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<DiffOptions>,
|
|
) -> GitResult<DiffResult> {
|
|
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<DiffStats> {
|
|
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<String> {
|
|
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<DiffResult> {
|
|
let stats = diff
|
|
.stats()
|
|
.map_err(|e| GitError::Internal(e.to_string()))?;
|
|
|
|
let delta_count = diff.deltas().len();
|
|
let deltas: RefCell<Vec<DiffDelta>> = RefCell::new(Vec::with_capacity(delta_count));
|
|
let delta_hunks: RefCell<Vec<DiffHunk>> = RefCell::new(Vec::new());
|
|
let delta_lines: RefCell<Vec<DiffLine>> = RefCell::new(Vec::new());
|
|
let counter: RefCell<usize> = 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<git2::DiffHunk<'_>>,
|
|
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<SideBySideFile> = 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<SideBySideLine> {
|
|
let mut result: Vec<SideBySideLine> = 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
|
|
}
|