use std::collections::BTreeMap;
use gunnar_proto::{ChangeKind, FileChange};
use gunnar_store::git_storage_trait::{GitServe, ObjType};
use gunnar_store::object::{EntryKind, NamedTreeEntry, TreeIter};
const BINARY_PROBE_BYTES: usize = 8000;
const MAX_EDIT_DISTANCE: usize = 4096;
type Entries = BTreeMap<Vec<u8>, Owned>;
#[derive(Debug, Clone)]
struct Owned {
oid: gix_hash::ObjectId,
kind: EntryKind,
mode: u32,
}
#[derive(Debug)]
pub enum DiffError {
Missing(gix_hash::ObjectId),
WrongKind {
oid: gix_hash::ObjectId,
want: &'static str,
got: ObjType,
},
Backend(String),
Corrupt {
oid: gix_hash::ObjectId,
reason: String,
},
}
impl std::fmt::Display for DiffError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DiffError::Missing(oid) => write!(f, "object {oid} is not in this repository"),
DiffError::WrongKind { oid, want, got } => {
write!(f, "object {oid} is a {got:?} where a {want} was needed")
}
DiffError::Backend(why) => write!(f, "the repository could not be read: {why}"),
DiffError::Corrupt { oid, reason } => write!(f, "tree {oid} does not decode: {reason}"),
}
}
}
type Result<T> = std::result::Result<T, DiffError>;
pub fn compare(
git: &dyn GitServe,
hash: gix_hash::Kind,
old_tree: Option<gix_hash::ObjectId>,
new_tree: Option<gix_hash::ObjectId>,
path_prefixes: &[String],
) -> Result<Vec<FileChange>> {
let mut out = Vec::new();
walk(git, hash, "", old_tree, new_tree, path_prefixes, &mut out)?;
Ok(out)
}
fn dir_is_interesting(dir: &str, prefixes: &[String]) -> bool {
if prefixes.is_empty() || dir.is_empty() {
return true;
}
prefixes
.iter()
.any(|p| p.starts_with(dir) || dir.starts_with(p.as_str()))
}
fn path_matches(path: &str, prefixes: &[String]) -> bool {
prefixes.is_empty() || prefixes.iter().any(|p| path.starts_with(p.as_str()))
}
fn read(git: &dyn GitServe, oid: gix_hash::ObjectId) -> Result<(ObjType, Vec<u8>)> {
git.read(oid.as_bytes())
.map_err(|err| DiffError::Backend(err.to_string()))?
.ok_or(DiffError::Missing(oid))
}
fn entries(git: &dyn GitServe, hash: gix_hash::Kind, tree: gix_hash::ObjectId) -> Result<Entries> {
let (kind, bytes) = read(git, tree)?;
if kind != ObjType::Tree {
return Err(DiffError::WrongKind {
oid: tree,
want: "tree",
got: kind,
});
}
let mut map = Entries::new();
for entry in TreeIter::from_bytes(&bytes, hash).named() {
let NamedTreeEntry {
oid,
kind,
name,
mode,
} = entry.map_err(|err| DiffError::Corrupt {
oid: tree,
reason: err.to_string(),
})?;
map.insert(name.to_vec(), Owned { oid, kind, mode });
}
Ok(map)
}
fn join(dir: &str, name: &[u8]) -> String {
let name = String::from_utf8_lossy(name);
if dir.is_empty() {
name.into_owned()
} else {
format!("{dir}/{name}")
}
}
#[allow(clippy::too_many_arguments)]
fn walk(
git: &dyn GitServe,
hash: gix_hash::Kind,
dir: &str,
old_tree: Option<gix_hash::ObjectId>,
new_tree: Option<gix_hash::ObjectId>,
prefixes: &[String],
out: &mut Vec<FileChange>,
) -> Result<()> {
if old_tree == new_tree {
return Ok(());
}
if !dir_is_interesting(dir, prefixes) {
return Ok(());
}
let old = match old_tree {
Some(id) => entries(git, hash, id)?,
None => Entries::new(),
};
let new = match new_tree {
Some(id) => entries(git, hash, id)?,
None => Entries::new(),
};
let mut names: Vec<&Vec<u8>> = old.keys().chain(new.keys()).collect();
names.sort_unstable();
names.dedup();
for name in names {
let path = join(dir, name);
match (old.get(name), new.get(name)) {
(Some(a), Some(b)) => {
match (a.kind == EntryKind::Tree, b.kind == EntryKind::Tree) {
(true, true) => {
walk(git, hash, &path, Some(a.oid), Some(b.oid), prefixes, out)?
}
(true, false) => {
walk(git, hash, &path, Some(a.oid), None, prefixes, out)?;
emit_added(git, &path, b, prefixes, out)?;
}
(false, true) => {
emit_deleted(git, &path, a, prefixes, out)?;
walk(git, hash, &path, None, Some(b.oid), prefixes, out)?;
}
(false, false) => {
if a.oid == b.oid && a.mode == b.mode {
continue;
}
if !path_matches(&path, prefixes) {
continue;
}
let kind = if a.kind != b.kind {
ChangeKind::TypeChanged
} else {
ChangeKind::Modified
};
let (insertions, deletions, binary) =
if kind == ChangeKind::TypeChanged || a.oid == b.oid {
(0, 0, false)
} else {
line_counts(git, Some(a.oid), Some(b.oid))?
};
out.push(FileChange {
kind: kind as i32,
path,
old_path: String::new(),
old_oid: a.oid.to_string(),
new_oid: b.oid.to_string(),
old_mode: a.mode,
new_mode: b.mode,
insertions,
deletions,
binary,
});
}
}
}
(Some(a), None) => {
if a.kind == EntryKind::Tree {
walk(git, hash, &path, Some(a.oid), None, prefixes, out)?;
} else {
emit_deleted(git, &path, a, prefixes, out)?;
}
}
(None, Some(b)) => {
if b.kind == EntryKind::Tree {
walk(git, hash, &path, None, Some(b.oid), prefixes, out)?;
} else {
emit_added(git, &path, b, prefixes, out)?;
}
}
(None, None) => unreachable!("the name came from one of the two maps"),
}
}
Ok(())
}
fn emit_added(
git: &dyn GitServe,
path: &str,
entry: &Owned,
prefixes: &[String],
out: &mut Vec<FileChange>,
) -> Result<()> {
if !path_matches(path, prefixes) {
return Ok(());
}
let (insertions, deletions, binary) = line_counts(git, None, Some(entry.oid))?;
out.push(FileChange {
kind: ChangeKind::Added as i32,
path: path.to_owned(),
old_path: String::new(),
old_oid: String::new(),
new_oid: entry.oid.to_string(),
old_mode: 0,
new_mode: entry.mode,
insertions,
deletions,
binary,
});
Ok(())
}
fn emit_deleted(
git: &dyn GitServe,
path: &str,
entry: &Owned,
prefixes: &[String],
out: &mut Vec<FileChange>,
) -> Result<()> {
if !path_matches(path, prefixes) {
return Ok(());
}
let (insertions, deletions, binary) = line_counts(git, Some(entry.oid), None)?;
out.push(FileChange {
kind: ChangeKind::Deleted as i32,
path: path.to_owned(),
old_path: String::new(),
old_oid: entry.oid.to_string(),
new_oid: String::new(),
old_mode: entry.mode,
new_mode: 0,
insertions,
deletions,
binary,
});
Ok(())
}
fn line_counts(
git: &dyn GitServe,
old: Option<gix_hash::ObjectId>,
new: Option<gix_hash::ObjectId>,
) -> Result<(u64, u64, bool)> {
let load = |id: Option<gix_hash::ObjectId>| -> Result<Option<Vec<u8>>> {
match id {
None => Ok(None),
Some(id) => match read(git, id)? {
(ObjType::Blob, bytes) => Ok(Some(bytes)),
_ => Ok(None),
},
}
};
let old = load(old)?;
let new = load(new)?;
let binary = [old.as_deref(), new.as_deref()]
.into_iter()
.flatten()
.any(is_binary);
if binary {
return Ok((0, 0, true));
}
let old_lines = lines(old.as_deref().unwrap_or_default());
let new_lines = lines(new.as_deref().unwrap_or_default());
let (insertions, deletions) = edit_counts(&old_lines, &new_lines);
Ok((insertions as u64, deletions as u64, false))
}
pub(crate) fn is_binary(bytes: &[u8]) -> bool {
bytes.iter().take(BINARY_PROBE_BYTES).any(|byte| *byte == 0)
}
fn lines(bytes: &[u8]) -> Vec<&[u8]> {
let mut out: Vec<&[u8]> = bytes.split(|b| *b == b'\n').collect();
if bytes.is_empty() || matches!(out.last(), Some(last) if last.is_empty()) {
out.pop();
}
out
}
fn edit_counts(old: &[&[u8]], new: &[&[u8]]) -> (usize, usize) {
let mut start = 0;
while start < old.len() && start < new.len() && old[start] == new[start] {
start += 1;
}
let mut end = 0;
while end < old.len() - start
&& end < new.len() - start
&& old[old.len() - 1 - end] == new[new.len() - 1 - end]
{
end += 1;
}
let a = &old[start..old.len() - end];
let b = &new[start..new.len() - end];
if a.is_empty() {
return (b.len(), 0);
}
if b.is_empty() {
return (0, a.len());
}
let (n, m) = (a.len(), b.len());
let max = (n + m).min(MAX_EDIT_DISTANCE);
let mut v = vec![0usize; 2 * max + 2];
for d in 0..=max {
let mut k = -(d as isize);
while k <= d as isize {
let idx = (k + max as isize) as usize;
let mut x = if k == -(d as isize) || (k != d as isize && v[idx - 1] < v[idx + 1]) {
v[idx + 1]
} else {
v[idx - 1] + 1
};
let mut y = (x as isize - k) as usize;
while x < n && y < m && a[x] == b[y] {
x += 1;
y += 1;
}
v[idx] = x;
if x >= n && y >= m {
let delta = m as isize - n as isize;
let insertions = ((d as isize + delta) / 2) as usize;
let deletions = d - insertions;
return (insertions, deletions);
}
k += 2;
}
}
(b.len(), a.len())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Op {
Equal {
old: usize,
new: usize,
count: usize,
},
Delete { old: usize, count: usize },
Insert { new: usize, count: usize },
}
pub fn diff_ops(old: &[&[u8]], new: &[&[u8]]) -> Vec<Op> {
let (n, m) = (old.len(), new.len());
if n == 0 && m == 0 {
return Vec::new();
}
let max = (n + m).min(MAX_EDIT_DISTANCE);
let mut trace: Vec<Vec<usize>> = Vec::new();
let mut v = vec![0usize; 2 * max + 2];
let mut found = None;
'outer: for d in 0..=max {
trace.push(v.clone());
let mut k = -(d as isize);
while k <= d as isize {
let idx = (k + max as isize) as usize;
let mut x = if k == -(d as isize) || (k != d as isize && v[idx - 1] < v[idx + 1]) {
v[idx + 1]
} else {
v[idx - 1] + 1
};
let mut y = (x as isize - k) as usize;
while x < n && y < m && old[x] == new[y] {
x += 1;
y += 1;
}
v[idx] = x;
if x >= n && y >= m {
found = Some(d);
break 'outer;
}
k += 2;
}
}
let Some(d_end) = found else {
let mut out = Vec::new();
if n > 0 {
out.push(Op::Delete { old: 0, count: n });
}
if m > 0 {
out.push(Op::Insert { new: 0, count: m });
}
return out;
};
let mut ops: Vec<Op> = Vec::new();
let (mut x, mut y) = (n, m);
for d in (1..=d_end).rev() {
let v = &trace[d];
let k = x as isize - y as isize;
let idx = (k + max as isize) as usize;
let down = k == -(d as isize) || (k != d as isize && v[idx - 1] < v[idx + 1]);
let prev_k = if down { k + 1 } else { k - 1 };
let prev_x = v[(prev_k + max as isize) as usize];
let prev_y = (prev_x as isize - prev_k) as usize;
while x > prev_x && y > prev_y {
x -= 1;
y -= 1;
push_run(
&mut ops,
Op::Equal {
old: x,
new: y,
count: 1,
},
);
}
if down {
y -= 1;
push_run(&mut ops, Op::Insert { new: y, count: 1 });
} else {
x -= 1;
push_run(&mut ops, Op::Delete { old: x, count: 1 });
}
}
while x > 0 && y > 0 {
x -= 1;
y -= 1;
push_run(
&mut ops,
Op::Equal {
old: x,
new: y,
count: 1,
},
);
}
ops.reverse();
ops
}
fn push_run(ops: &mut Vec<Op>, op: Op) {
match (ops.last_mut(), op) {
(
Some(Op::Equal { old, new, count }),
Op::Equal {
old: o,
new: nw,
count: 1,
},
) if *old == o + 1 && *new == nw + 1 => {
*old = o;
*new = nw;
*count += 1;
}
(Some(Op::Delete { old, count }), Op::Delete { old: o, count: 1 }) if *old == o + 1 => {
*old = o;
*count += 1;
}
(Some(Op::Insert { new, count }), Op::Insert { new: nw, count: 1 }) if *new == nw + 1 => {
*new = nw;
*count += 1;
}
_ => ops.push(op),
}
}
pub fn trace_line(old: &[&[u8]], new: &[&[u8]], new_line: usize) -> Option<usize> {
for op in diff_ops(old, new) {
if let Op::Equal { old, new, count } = op {
if new_line >= new && new_line < new + count {
return Some(old + (new_line - new));
}
}
}
None
}
pub fn split_lines(bytes: &[u8]) -> Vec<&[u8]> {
lines(bytes)
}
#[cfg(test)]
mod tests {
use super::{edit_counts, is_binary, lines, MAX_EDIT_DISTANCE};
fn count(old: &str, new: &str) -> (usize, usize) {
let old_lines = lines(old.as_bytes());
let new_lines = lines(new.as_bytes());
edit_counts(&old_lines, &new_lines)
}
#[test]
fn identical_files_have_no_edits() {
assert_eq!(count("a\nb\nc\n", "a\nb\nc\n"), (0, 0));
}
#[test]
fn one_line_replaced_is_one_in_and_one_out() {
assert_eq!(count("a\nb\nc\n", "a\nB\nc\n"), (1, 1));
}
#[test]
fn appended_lines_are_insertions_only() {
assert_eq!(count("a\n", "a\nb\nc\n"), (2, 0));
}
#[test]
fn removed_lines_are_deletions_only() {
assert_eq!(count("a\nb\nc\n", "a\n"), (0, 2));
}
#[test]
fn a_line_moved_is_counted_as_one_move_not_two_rewrites() {
assert_eq!(count("b\na\nc\n", "a\nc\nb\n"), (1, 1));
}
#[test]
fn an_empty_file_gaining_content_is_all_insertions() {
assert_eq!(count("", "a\nb\n"), (2, 0));
assert_eq!(count("a\nb\n", ""), (0, 2));
}
#[test]
fn a_file_with_no_trailing_newline_has_the_same_line_count() {
assert_eq!(lines(b"a\nb").len(), 2);
assert_eq!(lines(b"a\nb\n").len(), 2);
assert_eq!(lines(b"").len(), 0);
}
#[test]
fn a_change_past_the_bound_reports_an_upper_bound_and_never_zero() {
let old: String = (0..MAX_EDIT_DISTANCE + 100)
.map(|i| format!("old {i}\n"))
.collect();
let new: String = (0..MAX_EDIT_DISTANCE + 100)
.map(|i| format!("new {i}\n"))
.collect();
let (insertions, deletions) = count(&old, &new);
assert!(
insertions > 0 && deletions > 0,
"a wholesale rewrite reported {insertions} insertions and {deletions} deletions; a \
zero here is indistinguishable from `nothing changed`"
);
}
#[test]
fn a_nul_byte_makes_a_file_binary_and_a_high_byte_does_not() {
assert!(is_binary(b"\x89PNG\r\n\x1a\n\0\0"));
assert!(!is_binary("räksmörgås\n".as_bytes()));
}
}
#[cfg(test)]
mod script_tests {
use super::{diff_ops, lines, trace_line, Op};
fn split(text: &str) -> Vec<&[u8]> {
lines(text.as_bytes())
}
fn covers(old: &str, new: &str) {
let (o, n) = (split(old), split(new));
let mut old_seen = vec![0usize; o.len()];
let mut new_seen = vec![0usize; n.len()];
for op in diff_ops(&o, &n) {
match op {
Op::Equal { old, new, count } => {
for i in 0..count {
old_seen[old + i] += 1;
new_seen[new + i] += 1;
assert_eq!(
o[old + i],
n[new + i],
"an Equal run names two lines that are not equal"
);
}
}
Op::Delete { old, count } => {
for i in 0..count {
old_seen[old + i] += 1;
}
}
Op::Insert { new, count } => {
for i in 0..count {
new_seen[new + i] += 1;
}
}
}
}
assert!(
old_seen.iter().all(|c| *c == 1),
"the script does not cover every old line exactly once: {old_seen:?}"
);
assert!(
new_seen.iter().all(|c| *c == 1),
"the script does not cover every new line exactly once: {new_seen:?}"
);
}
#[test]
fn the_script_covers_both_files_in_every_shape_that_matters() {
covers("", "");
covers("a\n", "");
covers("", "a\n");
covers("a\nb\nc\n", "a\nb\nc\n");
covers("a\nb\nc\n", "a\nB\nc\n");
covers("a\nb\nc\n", "c\nb\na\n");
covers("a\nb\nc\nd\ne\n", "a\nc\ne\n");
covers("x\n", "a\nb\nc\nd\n");
covers(
&(0..40).map(|i| format!("l{i}\n")).collect::<String>(),
&(0..40)
.map(|i| {
if i % 7 == 0 {
format!("L{i}\n")
} else {
format!("l{i}\n")
}
})
.collect::<String>(),
);
}
#[test]
fn a_surviving_line_traces_back_and_a_new_line_does_not() {
let old = split("alpha\nbeta\ngamma\n");
let new = split("alpha\nINSERTED\nbeta\ngamma\n");
assert_eq!(trace_line(&old, &new, 0), Some(0), "`alpha` did not move");
assert_eq!(
trace_line(&old, &new, 1),
None,
"the inserted line must have no ancestor — that is where a blame stops"
);
assert_eq!(
trace_line(&old, &new, 2),
Some(1),
"`beta` moved down one and must trace back to where it was"
);
assert_eq!(trace_line(&old, &new, 3), Some(2));
}
#[test]
fn a_changed_line_has_no_ancestor() {
let old = split("keep\nold value\nkeep\n");
let new = split("keep\nnew value\nkeep\n");
assert_eq!(trace_line(&old, &new, 0), Some(0));
assert_eq!(
trace_line(&old, &new, 1),
None,
"the changed line must be attributed to the commit that changed it"
);
assert_eq!(trace_line(&old, &new, 2), Some(2));
}
}