1use std::collections::BTreeMap;
31
32use gunnar_proto::{ChangeKind, FileChange};
33use gunnar_store::git_storage_trait::{GitServe, ObjType};
34use gunnar_store::object::{EntryKind, NamedTreeEntry, TreeIter};
35
36const BINARY_PROBE_BYTES: usize = 8000;
42
43const MAX_EDIT_DISTANCE: usize = 4096;
51
52type Entries = BTreeMap<Vec<u8>, Owned>;
54
55#[derive(Debug, Clone)]
57struct Owned {
58 oid: gix_hash::ObjectId,
59 kind: EntryKind,
60 mode: u32,
61}
62
63#[derive(Debug)]
65pub enum DiffError {
66 Missing(gix_hash::ObjectId),
68 WrongKind {
70 oid: gix_hash::ObjectId,
71 want: &'static str,
72 got: ObjType,
73 },
74 Backend(String),
76 Corrupt {
78 oid: gix_hash::ObjectId,
79 reason: String,
80 },
81}
82
83impl std::fmt::Display for DiffError {
84 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85 match self {
86 DiffError::Missing(oid) => write!(f, "object {oid} is not in this repository"),
87 DiffError::WrongKind { oid, want, got } => {
88 write!(f, "object {oid} is a {got:?} where a {want} was needed")
89 }
90 DiffError::Backend(why) => write!(f, "the repository could not be read: {why}"),
91 DiffError::Corrupt { oid, reason } => write!(f, "tree {oid} does not decode: {reason}"),
92 }
93 }
94}
95
96type Result<T> = std::result::Result<T, DiffError>;
97
98pub fn compare(
111 git: &dyn GitServe,
112 hash: gix_hash::Kind,
113 old_tree: Option<gix_hash::ObjectId>,
114 new_tree: Option<gix_hash::ObjectId>,
115 path_prefixes: &[String],
116) -> Result<Vec<FileChange>> {
117 let mut out = Vec::new();
118 walk(git, hash, "", old_tree, new_tree, path_prefixes, &mut out)?;
119 Ok(out)
120}
121
122fn dir_is_interesting(dir: &str, prefixes: &[String]) -> bool {
129 if prefixes.is_empty() || dir.is_empty() {
130 return true;
131 }
132 prefixes
133 .iter()
134 .any(|p| p.starts_with(dir) || dir.starts_with(p.as_str()))
135}
136
137fn path_matches(path: &str, prefixes: &[String]) -> bool {
138 prefixes.is_empty() || prefixes.iter().any(|p| path.starts_with(p.as_str()))
139}
140
141fn read(git: &dyn GitServe, oid: gix_hash::ObjectId) -> Result<(ObjType, Vec<u8>)> {
142 git.read(oid.as_bytes())
143 .map_err(|err| DiffError::Backend(err.to_string()))?
144 .ok_or(DiffError::Missing(oid))
145}
146
147fn entries(git: &dyn GitServe, hash: gix_hash::Kind, tree: gix_hash::ObjectId) -> Result<Entries> {
148 let (kind, bytes) = read(git, tree)?;
149 if kind != ObjType::Tree {
150 return Err(DiffError::WrongKind {
151 oid: tree,
152 want: "tree",
153 got: kind,
154 });
155 }
156 let mut map = Entries::new();
157 for entry in TreeIter::from_bytes(&bytes, hash).named() {
158 let NamedTreeEntry {
159 oid,
160 kind,
161 name,
162 mode,
163 } = entry.map_err(|err| DiffError::Corrupt {
164 oid: tree,
165 reason: err.to_string(),
166 })?;
167 map.insert(name.to_vec(), Owned { oid, kind, mode });
168 }
169 Ok(map)
170}
171
172fn join(dir: &str, name: &[u8]) -> String {
173 let name = String::from_utf8_lossy(name);
177 if dir.is_empty() {
178 name.into_owned()
179 } else {
180 format!("{dir}/{name}")
181 }
182}
183
184#[allow(clippy::too_many_arguments)]
185fn walk(
186 git: &dyn GitServe,
187 hash: gix_hash::Kind,
188 dir: &str,
189 old_tree: Option<gix_hash::ObjectId>,
190 new_tree: Option<gix_hash::ObjectId>,
191 prefixes: &[String],
192 out: &mut Vec<FileChange>,
193) -> Result<()> {
194 if old_tree == new_tree {
199 return Ok(());
200 }
201 if !dir_is_interesting(dir, prefixes) {
202 return Ok(());
203 }
204
205 let old = match old_tree {
206 Some(id) => entries(git, hash, id)?,
207 None => Entries::new(),
208 };
209 let new = match new_tree {
210 Some(id) => entries(git, hash, id)?,
211 None => Entries::new(),
212 };
213
214 let mut names: Vec<&Vec<u8>> = old.keys().chain(new.keys()).collect();
216 names.sort_unstable();
217 names.dedup();
218
219 for name in names {
220 let path = join(dir, name);
221 match (old.get(name), new.get(name)) {
222 (Some(a), Some(b)) => {
223 match (a.kind == EntryKind::Tree, b.kind == EntryKind::Tree) {
224 (true, true) => {
225 walk(git, hash, &path, Some(a.oid), Some(b.oid), prefixes, out)?
226 }
227 (true, false) => {
233 walk(git, hash, &path, Some(a.oid), None, prefixes, out)?;
234 emit_added(git, &path, b, prefixes, out)?;
235 }
236 (false, true) => {
237 emit_deleted(git, &path, a, prefixes, out)?;
238 walk(git, hash, &path, None, Some(b.oid), prefixes, out)?;
239 }
240 (false, false) => {
241 if a.oid == b.oid && a.mode == b.mode {
242 continue;
243 }
244 if !path_matches(&path, prefixes) {
245 continue;
246 }
247 let kind = if a.kind != b.kind {
252 ChangeKind::TypeChanged
253 } else {
254 ChangeKind::Modified
255 };
256 let (insertions, deletions, binary) =
257 if kind == ChangeKind::TypeChanged || a.oid == b.oid {
258 (0, 0, false)
262 } else {
263 line_counts(git, Some(a.oid), Some(b.oid))?
264 };
265 out.push(FileChange {
266 kind: kind as i32,
267 path,
268 old_path: String::new(),
269 old_oid: a.oid.to_string(),
270 new_oid: b.oid.to_string(),
271 old_mode: a.mode,
272 new_mode: b.mode,
273 insertions,
274 deletions,
275 binary,
276 });
277 }
278 }
279 }
280 (Some(a), None) => {
281 if a.kind == EntryKind::Tree {
282 walk(git, hash, &path, Some(a.oid), None, prefixes, out)?;
283 } else {
284 emit_deleted(git, &path, a, prefixes, out)?;
285 }
286 }
287 (None, Some(b)) => {
288 if b.kind == EntryKind::Tree {
289 walk(git, hash, &path, None, Some(b.oid), prefixes, out)?;
290 } else {
291 emit_added(git, &path, b, prefixes, out)?;
292 }
293 }
294 (None, None) => unreachable!("the name came from one of the two maps"),
295 }
296 }
297 Ok(())
298}
299
300fn emit_added(
301 git: &dyn GitServe,
302 path: &str,
303 entry: &Owned,
304 prefixes: &[String],
305 out: &mut Vec<FileChange>,
306) -> Result<()> {
307 if !path_matches(path, prefixes) {
308 return Ok(());
309 }
310 let (insertions, deletions, binary) = line_counts(git, None, Some(entry.oid))?;
311 out.push(FileChange {
312 kind: ChangeKind::Added as i32,
313 path: path.to_owned(),
314 old_path: String::new(),
315 old_oid: String::new(),
316 new_oid: entry.oid.to_string(),
317 old_mode: 0,
318 new_mode: entry.mode,
319 insertions,
320 deletions,
321 binary,
322 });
323 Ok(())
324}
325
326fn emit_deleted(
327 git: &dyn GitServe,
328 path: &str,
329 entry: &Owned,
330 prefixes: &[String],
331 out: &mut Vec<FileChange>,
332) -> Result<()> {
333 if !path_matches(path, prefixes) {
334 return Ok(());
335 }
336 let (insertions, deletions, binary) = line_counts(git, Some(entry.oid), None)?;
337 out.push(FileChange {
338 kind: ChangeKind::Deleted as i32,
339 path: path.to_owned(),
340 old_path: String::new(),
341 old_oid: entry.oid.to_string(),
342 new_oid: String::new(),
343 old_mode: entry.mode,
344 new_mode: 0,
345 insertions,
346 deletions,
347 binary,
348 });
349 Ok(())
350}
351
352fn line_counts(
359 git: &dyn GitServe,
360 old: Option<gix_hash::ObjectId>,
361 new: Option<gix_hash::ObjectId>,
362) -> Result<(u64, u64, bool)> {
363 let load = |id: Option<gix_hash::ObjectId>| -> Result<Option<Vec<u8>>> {
364 match id {
365 None => Ok(None),
366 Some(id) => match read(git, id)? {
367 (ObjType::Blob, bytes) => Ok(Some(bytes)),
368 _ => Ok(None),
371 },
372 }
373 };
374 let old = load(old)?;
375 let new = load(new)?;
376 let binary = [old.as_deref(), new.as_deref()]
377 .into_iter()
378 .flatten()
379 .any(is_binary);
380 if binary {
381 return Ok((0, 0, true));
382 }
383 let old_lines = lines(old.as_deref().unwrap_or_default());
384 let new_lines = lines(new.as_deref().unwrap_or_default());
385 let (insertions, deletions) = edit_counts(&old_lines, &new_lines);
386 Ok((insertions as u64, deletions as u64, false))
387}
388
389pub(crate) fn is_binary(bytes: &[u8]) -> bool {
395 bytes.iter().take(BINARY_PROBE_BYTES).any(|byte| *byte == 0)
396}
397
398fn lines(bytes: &[u8]) -> Vec<&[u8]> {
406 let mut out: Vec<&[u8]> = bytes.split(|b| *b == b'\n').collect();
407 if bytes.is_empty() || matches!(out.last(), Some(last) if last.is_empty()) {
408 out.pop();
409 }
410 out
411}
412
413fn edit_counts(old: &[&[u8]], new: &[&[u8]]) -> (usize, usize) {
440 let mut start = 0;
444 while start < old.len() && start < new.len() && old[start] == new[start] {
445 start += 1;
446 }
447 let mut end = 0;
448 while end < old.len() - start
449 && end < new.len() - start
450 && old[old.len() - 1 - end] == new[new.len() - 1 - end]
451 {
452 end += 1;
453 }
454 let a = &old[start..old.len() - end];
455 let b = &new[start..new.len() - end];
456
457 if a.is_empty() {
458 return (b.len(), 0);
459 }
460 if b.is_empty() {
461 return (0, a.len());
462 }
463
464 let (n, m) = (a.len(), b.len());
465 let max = (n + m).min(MAX_EDIT_DISTANCE);
466 let mut v = vec![0usize; 2 * max + 2];
468 for d in 0..=max {
469 let mut k = -(d as isize);
471 while k <= d as isize {
472 let idx = (k + max as isize) as usize;
473 let mut x = if k == -(d as isize) || (k != d as isize && v[idx - 1] < v[idx + 1]) {
474 v[idx + 1]
475 } else {
476 v[idx - 1] + 1
477 };
478 let mut y = (x as isize - k) as usize;
479 while x < n && y < m && a[x] == b[y] {
480 x += 1;
481 y += 1;
482 }
483 v[idx] = x;
484 if x >= n && y >= m {
485 let delta = m as isize - n as isize;
487 let insertions = ((d as isize + delta) / 2) as usize;
488 let deletions = d - insertions;
489 return (insertions, deletions);
490 }
491 k += 2;
492 }
493 }
494 (b.len(), a.len())
496}
497
498#[derive(Debug, Clone, Copy, PartialEq, Eq)]
500pub enum Op {
501 Equal {
503 old: usize,
504 new: usize,
505 count: usize,
506 },
507 Delete { old: usize, count: usize },
509 Insert { new: usize, count: usize },
511}
512
513pub fn diff_ops(old: &[&[u8]], new: &[&[u8]]) -> Vec<Op> {
526 let (n, m) = (old.len(), new.len());
527 if n == 0 && m == 0 {
528 return Vec::new();
529 }
530 let max = (n + m).min(MAX_EDIT_DISTANCE);
531 let mut trace: Vec<Vec<usize>> = Vec::new();
534 let mut v = vec![0usize; 2 * max + 2];
535 let mut found = None;
536 'outer: for d in 0..=max {
537 trace.push(v.clone());
538 let mut k = -(d as isize);
539 while k <= d as isize {
540 let idx = (k + max as isize) as usize;
541 let mut x = if k == -(d as isize) || (k != d as isize && v[idx - 1] < v[idx + 1]) {
542 v[idx + 1]
543 } else {
544 v[idx - 1] + 1
545 };
546 let mut y = (x as isize - k) as usize;
547 while x < n && y < m && old[x] == new[y] {
548 x += 1;
549 y += 1;
550 }
551 v[idx] = x;
552 if x >= n && y >= m {
553 found = Some(d);
554 break 'outer;
555 }
556 k += 2;
557 }
558 }
559 let Some(d_end) = found else {
560 let mut out = Vec::new();
562 if n > 0 {
563 out.push(Op::Delete { old: 0, count: n });
564 }
565 if m > 0 {
566 out.push(Op::Insert { new: 0, count: m });
567 }
568 return out;
569 };
570
571 let mut ops: Vec<Op> = Vec::new();
573 let (mut x, mut y) = (n, m);
574 for d in (1..=d_end).rev() {
575 let v = &trace[d];
576 let k = x as isize - y as isize;
577 let idx = (k + max as isize) as usize;
578 let down = k == -(d as isize) || (k != d as isize && v[idx - 1] < v[idx + 1]);
579 let prev_k = if down { k + 1 } else { k - 1 };
580 let prev_x = v[(prev_k + max as isize) as usize];
581 let prev_y = (prev_x as isize - prev_k) as usize;
582 while x > prev_x && y > prev_y {
584 x -= 1;
585 y -= 1;
586 push_run(
587 &mut ops,
588 Op::Equal {
589 old: x,
590 new: y,
591 count: 1,
592 },
593 );
594 }
595 if down {
596 y -= 1;
597 push_run(&mut ops, Op::Insert { new: y, count: 1 });
598 } else {
599 x -= 1;
600 push_run(&mut ops, Op::Delete { old: x, count: 1 });
601 }
602 }
603 while x > 0 && y > 0 {
604 x -= 1;
605 y -= 1;
606 push_run(
607 &mut ops,
608 Op::Equal {
609 old: x,
610 new: y,
611 count: 1,
612 },
613 );
614 }
615 ops.reverse();
616 ops
617}
618
619fn push_run(ops: &mut Vec<Op>, op: Op) {
621 match (ops.last_mut(), op) {
622 (
623 Some(Op::Equal { old, new, count }),
624 Op::Equal {
625 old: o,
626 new: nw,
627 count: 1,
628 },
629 ) if *old == o + 1 && *new == nw + 1 => {
630 *old = o;
631 *new = nw;
632 *count += 1;
633 }
634 (Some(Op::Delete { old, count }), Op::Delete { old: o, count: 1 }) if *old == o + 1 => {
635 *old = o;
636 *count += 1;
637 }
638 (Some(Op::Insert { new, count }), Op::Insert { new: nw, count: 1 }) if *new == nw + 1 => {
639 *new = nw;
640 *count += 1;
641 }
642 _ => ops.push(op),
643 }
644}
645
646pub fn trace_line(old: &[&[u8]], new: &[&[u8]], new_line: usize) -> Option<usize> {
652 for op in diff_ops(old, new) {
653 if let Op::Equal { old, new, count } = op {
654 if new_line >= new && new_line < new + count {
655 return Some(old + (new_line - new));
656 }
657 }
658 }
659 None
660}
661
662pub fn split_lines(bytes: &[u8]) -> Vec<&[u8]> {
664 lines(bytes)
665}
666
667#[cfg(test)]
668mod tests {
669 use super::{edit_counts, is_binary, lines, MAX_EDIT_DISTANCE};
670
671 fn count(old: &str, new: &str) -> (usize, usize) {
672 let old_lines = lines(old.as_bytes());
673 let new_lines = lines(new.as_bytes());
674 edit_counts(&old_lines, &new_lines)
675 }
676
677 #[test]
678 fn identical_files_have_no_edits() {
679 assert_eq!(count("a\nb\nc\n", "a\nb\nc\n"), (0, 0));
680 }
681
682 #[test]
683 fn one_line_replaced_is_one_in_and_one_out() {
684 assert_eq!(count("a\nb\nc\n", "a\nB\nc\n"), (1, 1));
687 }
688
689 #[test]
690 fn appended_lines_are_insertions_only() {
691 assert_eq!(count("a\n", "a\nb\nc\n"), (2, 0));
692 }
693
694 #[test]
695 fn removed_lines_are_deletions_only() {
696 assert_eq!(count("a\nb\nc\n", "a\n"), (0, 2));
697 }
698
699 #[test]
700 fn a_line_moved_is_counted_as_one_move_not_two_rewrites() {
701 assert_eq!(count("b\na\nc\n", "a\nc\nb\n"), (1, 1));
705 }
706
707 #[test]
708 fn an_empty_file_gaining_content_is_all_insertions() {
709 assert_eq!(count("", "a\nb\n"), (2, 0));
710 assert_eq!(count("a\nb\n", ""), (0, 2));
711 }
712
713 #[test]
714 fn a_file_with_no_trailing_newline_has_the_same_line_count() {
715 assert_eq!(lines(b"a\nb").len(), 2);
716 assert_eq!(lines(b"a\nb\n").len(), 2);
717 assert_eq!(lines(b"").len(), 0);
718 }
719
720 #[test]
723 fn a_change_past_the_bound_reports_an_upper_bound_and_never_zero() {
724 let old: String = (0..MAX_EDIT_DISTANCE + 100)
725 .map(|i| format!("old {i}\n"))
726 .collect();
727 let new: String = (0..MAX_EDIT_DISTANCE + 100)
728 .map(|i| format!("new {i}\n"))
729 .collect();
730 let (insertions, deletions) = count(&old, &new);
731 assert!(
732 insertions > 0 && deletions > 0,
733 "a wholesale rewrite reported {insertions} insertions and {deletions} deletions; a \
734 zero here is indistinguishable from `nothing changed`"
735 );
736 }
737
738 #[test]
739 fn a_nul_byte_makes_a_file_binary_and_a_high_byte_does_not() {
740 assert!(is_binary(b"\x89PNG\r\n\x1a\n\0\0"));
741 assert!(!is_binary("räksmörgås\n".as_bytes()));
743 }
744}
745
746#[cfg(test)]
747mod script_tests {
748 use super::{diff_ops, lines, trace_line, Op};
749
750 fn split(text: &str) -> Vec<&[u8]> {
751 lines(text.as_bytes())
752 }
753
754 fn covers(old: &str, new: &str) {
758 let (o, n) = (split(old), split(new));
759 let mut old_seen = vec![0usize; o.len()];
760 let mut new_seen = vec![0usize; n.len()];
761 for op in diff_ops(&o, &n) {
762 match op {
763 Op::Equal { old, new, count } => {
764 for i in 0..count {
765 old_seen[old + i] += 1;
766 new_seen[new + i] += 1;
767 assert_eq!(
768 o[old + i],
769 n[new + i],
770 "an Equal run names two lines that are not equal"
771 );
772 }
773 }
774 Op::Delete { old, count } => {
775 for i in 0..count {
776 old_seen[old + i] += 1;
777 }
778 }
779 Op::Insert { new, count } => {
780 for i in 0..count {
781 new_seen[new + i] += 1;
782 }
783 }
784 }
785 }
786 assert!(
787 old_seen.iter().all(|c| *c == 1),
788 "the script does not cover every old line exactly once: {old_seen:?}"
789 );
790 assert!(
791 new_seen.iter().all(|c| *c == 1),
792 "the script does not cover every new line exactly once: {new_seen:?}"
793 );
794 }
795
796 #[test]
797 fn the_script_covers_both_files_in_every_shape_that_matters() {
798 covers("", "");
799 covers("a\n", "");
800 covers("", "a\n");
801 covers("a\nb\nc\n", "a\nb\nc\n");
802 covers("a\nb\nc\n", "a\nB\nc\n");
803 covers("a\nb\nc\n", "c\nb\na\n");
804 covers("a\nb\nc\nd\ne\n", "a\nc\ne\n");
805 covers("x\n", "a\nb\nc\nd\n");
806 covers(
807 &(0..40).map(|i| format!("l{i}\n")).collect::<String>(),
808 &(0..40)
809 .map(|i| {
810 if i % 7 == 0 {
811 format!("L{i}\n")
812 } else {
813 format!("l{i}\n")
814 }
815 })
816 .collect::<String>(),
817 );
818 }
819
820 #[test]
823 fn a_surviving_line_traces_back_and_a_new_line_does_not() {
824 let old = split("alpha\nbeta\ngamma\n");
825 let new = split("alpha\nINSERTED\nbeta\ngamma\n");
826
827 assert_eq!(trace_line(&old, &new, 0), Some(0), "`alpha` did not move");
828 assert_eq!(
829 trace_line(&old, &new, 1),
830 None,
831 "the inserted line must have no ancestor — that is where a blame stops"
832 );
833 assert_eq!(
834 trace_line(&old, &new, 2),
835 Some(1),
836 "`beta` moved down one and must trace back to where it was"
837 );
838 assert_eq!(trace_line(&old, &new, 3), Some(2));
839 }
840
841 #[test]
843 fn a_changed_line_has_no_ancestor() {
844 let old = split("keep\nold value\nkeep\n");
845 let new = split("keep\nnew value\nkeep\n");
846 assert_eq!(trace_line(&old, &new, 0), Some(0));
847 assert_eq!(
848 trace_line(&old, &new, 1),
849 None,
850 "the changed line must be attributed to the commit that changed it"
851 );
852 assert_eq!(trace_line(&old, &new, 2), Some(2));
853 }
854}