Skip to main content

sley_diff_merge/
lib.rs

1//! Git diff and merge engine.
2
3mod line_diff;
4mod blob_merge;
5mod name_status;
6mod patch;
7mod merge_trees;
8mod name;
9pub mod format;
10pub mod range;
11pub mod render;
12pub mod ws;
13
14pub use sley_core::BString;
15
16pub use line_diff::*;
17pub use blob_merge::*;
18pub use name_status::*;
19pub use patch::*;
20pub use merge_trees::*;
21pub use format::*;
22
23#[cfg(test)]
24mod tests {
25    use super::*;
26    use crate::name_status::{
27        changed_tree_entries, collect_full_tree_pair, diff_name_status_maps,
28        diff_name_status_maps_with_renames, read_blob_bytes, TREE_ENTRY_MODE,
29    };
30    use crate::patch::{parse_leading_usize, split_blob_lines};
31    use sley_core::{ObjectFormat, ObjectId};
32    use sley_formats::RepositoryLayout;
33    use sley_index::Index;
34    use sley_object::{EncodedObject, ObjectType, Tree, TreeEntry};
35    use sley_odb::{FileObjectDatabase, ObjectWriter};
36    use std::fs;
37    use std::path::{Path, PathBuf};
38    use std::sync::atomic::{AtomicU64, Ordering};
39
40    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
41
42    #[test]
43    fn name_status_reports_added_from_index() {
44        let root = temp_root();
45        let layout = RepositoryLayout::init_at(&root, ObjectFormat::Sha1, false)
46            .expect("test operation should succeed");
47        let db = FileObjectDatabase::from_git_dir(&layout.git_dir, ObjectFormat::Sha1);
48        let oid = db
49            .write_object(EncodedObject::new(ObjectType::Blob, b"hello\n".to_vec()))
50            .expect("test operation should succeed");
51        let index = Index {
52            version: 2,
53            entries: vec![sley_index::IndexEntry {
54                ctime_seconds: 0,
55                ctime_nanoseconds: 0,
56                mtime_seconds: 0,
57                mtime_nanoseconds: 0,
58                dev: 0,
59                ino: 0,
60                mode: 0o100644,
61                uid: 0,
62                gid: 0,
63                size: 6,
64                oid,
65                flags: "hello.txt".len() as u16,
66                flags_extended: 0,
67                path: BString::from(b"hello.txt"),
68            }],
69            extensions: Vec::new(),
70            checksum: None,
71        };
72        fs::write(
73            layout.git_dir.join("index"),
74            index
75                .write_v2_sha1()
76                .expect("test operation should succeed"),
77        )
78        .expect("test operation should succeed");
79        fs::write(root.join("hello.txt"), b"hello\n").expect("test operation should succeed");
80        let changes = diff_name_status_head_worktree(&root, &layout.git_dir, ObjectFormat::Sha1)
81            .expect("test operation should succeed");
82        assert_eq!(changes[0].line(), "A\thello.txt");
83        fs::remove_dir_all(root).expect("test operation should succeed");
84    }
85
86    #[test]
87    fn tree_worktree_diff_treats_absent_skip_worktree_entries_as_clean() {
88        let root = temp_root();
89        let layout = RepositoryLayout::init_at(&root, ObjectFormat::Sha1, false)
90            .expect("test operation should succeed");
91        let mut db = FileObjectDatabase::from_git_dir(&layout.git_dir, ObjectFormat::Sha1);
92        let oid = write_blob(&mut db, b"clean\n");
93        let tree = write_tree(
94            &mut db,
95            &[(b"removeme", 0o100644, oid), (b"untouched", 0o100644, oid)],
96        );
97        write_index(
98            &layout.git_dir,
99            vec![
100                skip_worktree_entry(b"removeme", oid),
101                skip_worktree_entry(b"untouched", oid),
102            ],
103        );
104
105        let changes = diff_name_status_tree_worktree_with_options(
106            &root,
107            &layout.git_dir,
108            ObjectFormat::Sha1,
109            &tree,
110            DiffNameStatusOptions::default(),
111        )
112        .expect("test operation should succeed");
113
114        assert!(
115            changes.is_empty(),
116            "absent sparse entries should not appear as deletes: {changes:?}"
117        );
118        fs::remove_dir_all(root).expect("test operation should succeed");
119    }
120
121    #[test]
122    fn tree_worktree_diff_trusts_present_skip_worktree_entry_when_sparse_disabled() {
123        let root = temp_root();
124        let layout = RepositoryLayout::init_at(&root, ObjectFormat::Sha1, false)
125            .expect("test operation should succeed");
126        let mut db = FileObjectDatabase::from_git_dir(&layout.git_dir, ObjectFormat::Sha1);
127        let oid = write_blob(&mut db, b"clean\n");
128        let tree = write_tree(&mut db, &[(b"modified", 0o100644, oid)]);
129        write_index(&layout.git_dir, vec![skip_worktree_entry(b"modified", oid)]);
130        fs::write(root.join("modified"), b"dirty\n").expect("test operation should succeed");
131
132        let changes = diff_name_status_tree_worktree_with_options(
133            &root,
134            &layout.git_dir,
135            ObjectFormat::Sha1,
136            &tree,
137            DiffNameStatusOptions::default(),
138        )
139        .expect("test operation should succeed");
140
141        assert!(
142            changes.is_empty(),
143            "present skip-worktree dirt should be ignored when sparse checkout is disabled: {changes:?}"
144        );
145        fs::remove_dir_all(root).expect("test operation should succeed");
146    }
147
148    #[test]
149    fn tree_worktree_diff_adds_index_blob_for_present_skip_worktree_entry() {
150        let root = temp_root();
151        let layout = RepositoryLayout::init_at(&root, ObjectFormat::Sha1, false)
152            .expect("test operation should succeed");
153        let mut db = FileObjectDatabase::from_git_dir(&layout.git_dir, ObjectFormat::Sha1);
154        let oid = write_blob(&mut db, b"");
155        let tree = write_tree(&mut db, &[]);
156        write_index(&layout.git_dir, vec![skip_worktree_entry(b"added", oid)]);
157        fs::write(root.join("added"), b"dirty\n").expect("test operation should succeed");
158
159        let changes = diff_name_status_tree_worktree_with_options(
160            &root,
161            &layout.git_dir,
162            ObjectFormat::Sha1,
163            &tree,
164            DiffNameStatusOptions::default(),
165        )
166        .expect("test operation should succeed");
167
168        assert_eq!(changes.len(), 1);
169        assert_eq!(changes[0].line(), "A\tadded");
170        assert_eq!(changes[0].new_oid, Some(oid));
171        fs::remove_dir_all(root).expect("test operation should succeed");
172    }
173
174    #[test]
175    fn index_worktree_diff_returns_staged_gitlinks() {
176        let root = temp_root();
177        let layout = RepositoryLayout::init_at(&root, ObjectFormat::Sha1, false)
178            .expect("test operation should succeed");
179        let oid = ObjectId::from_hex(
180            ObjectFormat::Sha1,
181            "1111111111111111111111111111111111111111",
182        )
183        .expect("test operation should succeed");
184        let index = Index {
185            version: 2,
186            entries: vec![sley_index::IndexEntry {
187                ctime_seconds: 0,
188                ctime_nanoseconds: 0,
189                mtime_seconds: 0,
190                mtime_nanoseconds: 0,
191                dev: 0,
192                ino: 0,
193                mode: sley_index::GITLINK_MODE,
194                uid: 0,
195                gid: 0,
196                size: 0,
197                oid,
198                flags: "deps/sub".len() as u16,
199                flags_extended: 0,
200                path: BString::from(b"deps/sub"),
201            }],
202            extensions: Vec::new(),
203            checksum: None,
204        };
205        fs::write(
206            layout.git_dir.join("index"),
207            index
208                .write_v2_sha1()
209                .expect("test operation should succeed"),
210        )
211        .expect("test operation should succeed");
212
213        let diff = diff_name_status_index_worktree_with_options_and_gitlinks(
214            &root,
215            &layout.git_dir,
216            ObjectFormat::Sha1,
217            DiffNameStatusOptions::default(),
218        )
219        .expect("test operation should succeed");
220
221        assert_eq!(diff.entries.len(), 1);
222        let gitlinks = diff.staged_gitlinks;
223        assert_eq!(gitlinks.len(), 1);
224        assert_eq!(gitlinks[0].path.as_bytes(), b"deps/sub");
225        assert_eq!(gitlinks[0].oid, oid);
226        fs::remove_dir_all(root).expect("test operation should succeed");
227    }
228
229    #[cfg(unix)]
230    #[test]
231    fn index_worktree_diff_ignores_untracked_dangling_symlink() {
232        use std::os::unix::fs::symlink;
233
234        let root = temp_root();
235        let layout = RepositoryLayout::init_at(&root, ObjectFormat::Sha1, false)
236            .expect("test operation should succeed");
237        let db = FileObjectDatabase::from_git_dir(&layout.git_dir, ObjectFormat::Sha1);
238        let oid = db
239            .write_object(EncodedObject::new(ObjectType::Blob, b"clean\n".to_vec()))
240            .expect("test operation should succeed");
241        let index = Index {
242            version: 2,
243            entries: vec![sley_index::IndexEntry {
244                ctime_seconds: 0,
245                ctime_nanoseconds: 0,
246                mtime_seconds: 0,
247                mtime_nanoseconds: 0,
248                dev: 0,
249                ino: 0,
250                mode: 0o100644,
251                uid: 0,
252                gid: 0,
253                size: 6,
254                oid,
255                flags: "tracked.txt".len() as u16,
256                flags_extended: 0,
257                path: BString::from(b"tracked.txt"),
258            }],
259            extensions: Vec::new(),
260            checksum: None,
261        };
262        fs::write(
263            layout.git_dir.join("index"),
264            index
265                .write_v2_sha1()
266                .expect("test operation should succeed"),
267        )
268        .expect("test operation should succeed");
269        fs::write(root.join("tracked.txt"), b"clean\n").expect("test operation should succeed");
270        symlink("missing-target", root.join("untracked-link"))
271            .expect("test operation should succeed");
272
273        let changes = diff_name_status_index_worktree_with_options(
274            &root,
275            &layout.git_dir,
276            ObjectFormat::Sha1,
277            DiffNameStatusOptions {
278                detect_renames: false,
279                detect_copies: false,
280                find_copies_harder: false,
281                rename_empty: true,
282            
283                ..Default::default()
284            },
285        )
286        .expect("untracked dangling symlink should be ignored");
287        assert!(changes.is_empty());
288        fs::remove_dir_all(root).expect("test operation should succeed");
289    }
290
291    #[test]
292    fn index_worktree_diff_trusts_non_racy_stat_cache() {
293        let root = temp_root();
294        let layout = RepositoryLayout::init_at(&root, ObjectFormat::Sha1, false)
295            .expect("test operation should succeed");
296        let worktree_path = root.join("tracked.txt");
297        fs::write(&worktree_path, b"clean\n").expect("test operation should succeed");
298        let metadata = fs::symlink_metadata(&worktree_path).expect("test operation should succeed");
299        let (mtime_seconds, mtime_nanoseconds) =
300            sley_index::file_mtime_parts(&metadata).expect("test operation should succeed");
301        let bogus_oid = ObjectId::from_hex(
302            ObjectFormat::Sha1,
303            "1111111111111111111111111111111111111111",
304        )
305        .expect("test operation should succeed");
306        let index = Index {
307            version: 2,
308            entries: vec![sley_index::IndexEntry {
309                ctime_seconds: 0,
310                ctime_nanoseconds: 0,
311                mtime_seconds: mtime_seconds as u32,
312                mtime_nanoseconds: mtime_nanoseconds as u32,
313                dev: 0,
314                ino: 0,
315                mode: sley_index::worktree_metadata_mode(&metadata),
316                uid: 0,
317                gid: 0,
318                size: metadata.len() as u32,
319                oid: bogus_oid,
320                flags: "tracked.txt".len() as u16,
321                flags_extended: 0,
322                path: BString::from(b"tracked.txt"),
323            }],
324            extensions: Vec::new(),
325            checksum: None,
326        };
327        std::thread::sleep(std::time::Duration::from_millis(1100));
328        fs::write(
329            layout.git_dir.join("index"),
330            index
331                .write_v2_sha1()
332                .expect("test operation should succeed"),
333        )
334        .expect("test operation should succeed");
335
336        let changes = diff_name_status_index_worktree(&root, &layout.git_dir, ObjectFormat::Sha1)
337            .expect("test operation should succeed");
338        assert!(
339            changes.is_empty(),
340            "a clean non-racy stat match must reuse the cached index oid"
341        );
342        fs::remove_dir_all(root).expect("test operation should succeed");
343    }
344
345    fn temp_root() -> PathBuf {
346        let path = std::env::temp_dir().join(format!(
347            "sley-diff-{}-{}",
348            std::process::id(),
349            TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
350        ));
351        fs::create_dir_all(&path).expect("test operation should succeed");
352        path
353    }
354
355    // ---- line diff / blob merge tests ---------------------------------------
356
357    fn merge_opts() -> MergeBlobOptions<'static> {
358        MergeBlobOptions {
359            ours_label: "ours",
360            theirs_label: "theirs",
361            base_label: "base",
362            style: ConflictStyle::Merge,
363            favor: MergeFavor::None,
364            ws_ignore: WsIgnore::EMPTY,
365            marker_size: 7,
366        }
367    }
368
369    #[test]
370    fn split_lines_preserves_content_and_newlines() {
371        let lines = split_lines(b"a\nb\nc\n");
372        assert_eq!(lines.len(), 3);
373        assert_eq!(lines[0].content, b"a\n");
374        assert!(lines[0].has_newline);
375        assert_eq!(lines[2].content, b"c\n");
376        assert!(lines[2].has_newline);
377        assert!(split_lines(b"").is_empty());
378    }
379
380    #[test]
381    fn split_lines_tracks_missing_final_newline() {
382        let lines = split_lines(b"a\nb");
383        assert_eq!(lines.len(), 2);
384        assert!(lines[0].has_newline);
385        assert!(!lines[1].has_newline);
386        assert_eq!(lines[1].content, b"b");
387        assert_eq!(lines[1].bytes_without_newline(), b"b");
388        // A line that lost its newline must not compare equal to one that has it.
389        let with_nl = split_lines(b"b\n");
390        assert_ne!(lines[1], with_nl[0]);
391    }
392
393    #[test]
394    fn myers_replace_single_line() {
395        let old = split_lines(b"a\nb\nc\n");
396        let new = split_lines(b"a\nx\nc\n");
397        assert_eq!(
398            myers_diff_lines(&old, &new),
399            vec![
400                DiffOp::Equal(1),
401                DiffOp::Delete(1),
402                DiffOp::Insert(1),
403                DiffOp::Equal(1),
404            ]
405        );
406    }
407
408    #[test]
409    fn myers_identical_is_single_equal() {
410        let old = split_lines(b"a\nb\nc\n");
411        let new = split_lines(b"a\nb\nc\n");
412        assert_eq!(myers_diff_lines(&old, &new), vec![DiffOp::Equal(3)]);
413    }
414
415    #[test]
416    fn myers_pure_insert_and_delete() {
417        let empty = split_lines(b"");
418        let two = split_lines(b"a\nb\n");
419        assert_eq!(myers_diff_lines(&empty, &two), vec![DiffOp::Insert(2)]);
420        assert_eq!(myers_diff_lines(&two, &empty), vec![DiffOp::Delete(2)]);
421
422        let old = split_lines(b"a\nb\nc\nd\n");
423        let new = split_lines(b"a\nc\nd\n");
424        assert_eq!(
425            myers_diff_lines(&old, &new),
426            vec![DiffOp::Equal(1), DiffOp::Delete(1), DiffOp::Equal(2)]
427        );
428    }
429
430    #[test]
431    fn myers_reconstructs_new_and_is_minimal() {
432        // Apply the script to `old` and confirm it yields `new`; also count edits.
433        let old = split_lines(b"the\nquick\nbrown\nfox\n");
434        let new = split_lines(b"the\nlazy\nbrown\ncat\n");
435        let ops = myers_diff_lines(&old, &new);
436        let mut oi = 0usize;
437        let mut ni = 0usize;
438        let mut edits = 0usize;
439        let mut rebuilt: Vec<u8> = Vec::new();
440        for op in &ops {
441            match *op {
442                DiffOp::Equal(n) => {
443                    for _ in 0..n {
444                        assert_eq!(old[oi], new[ni]);
445                        rebuilt.extend_from_slice(old[oi].content);
446                        oi += 1;
447                        ni += 1;
448                    }
449                }
450                DiffOp::Delete(n) => {
451                    oi += n;
452                    edits += n;
453                }
454                DiffOp::Insert(n) => {
455                    for _ in 0..n {
456                        rebuilt.extend_from_slice(new[ni].content);
457                        ni += 1;
458                    }
459                    edits += n;
460                }
461            }
462        }
463        assert_eq!(rebuilt, b"the\nlazy\nbrown\ncat\n");
464        // Two lines changed -> 2 deletes + 2 inserts is the minimal SES here.
465        assert_eq!(edits, 4);
466    }
467
468    #[test]
469    fn merge_non_overlapping_changes_is_clean() {
470        let base = b"a\nb\nc\nd\ne\n";
471        let ours = b"A\nb\nc\nd\ne\n";
472        let theirs = b"a\nb\nc\nd\nE\n";
473        let result = merge_blobs(base, ours, theirs, &merge_opts());
474        assert!(!result.conflicted);
475        assert_eq!(result.content, b"A\nb\nc\nd\nE\n");
476    }
477
478    #[test]
479    fn merge_identical_changes_no_conflict() {
480        let base = b"a\nb\nc\n";
481        let ours = b"a\nX\nc\n";
482        let theirs = b"a\nX\nc\n";
483        let result = merge_blobs(base, ours, theirs, &merge_opts());
484        assert!(!result.conflicted);
485        assert_eq!(result.content, b"a\nX\nc\n");
486    }
487
488    #[test]
489    fn merge_overlapping_change_emits_exact_markers() {
490        let base = b"a\nb\nc\n";
491        let ours = b"a\nOURS\nc\n";
492        let theirs = b"a\nTHEIRS\nc\n";
493        let result = merge_blobs(base, ours, theirs, &merge_opts());
494        assert!(result.conflicted);
495        assert_eq!(
496            result.content,
497            b"a\n<<<<<<< ours\nOURS\n=======\nTHEIRS\n>>>>>>> theirs\nc\n".to_vec(),
498        );
499    }
500
501    #[test]
502    fn merge_diff3_style_includes_base_section() {
503        let base = b"a\nb\nc\n";
504        let ours = b"a\nOURS\nc\n";
505        let theirs = b"a\nTHEIRS\nc\n";
506        let options = MergeBlobOptions {
507            style: ConflictStyle::Diff3,
508            ..merge_opts()
509        };
510        let result = merge_blobs(base, ours, theirs, &options);
511        assert!(result.conflicted);
512        assert_eq!(
513            result.content,
514            b"a\n<<<<<<< ours\nOURS\n||||||| base\nb\n=======\nTHEIRS\n>>>>>>> theirs\nc\n"
515                .to_vec(),
516        );
517    }
518
519    #[test]
520    fn merge_empty_label_omits_trailing_space() {
521        let base = b"a\nb\nc\n";
522        let ours = b"a\nOURS\nc\n";
523        let theirs = b"a\nTHEIRS\nc\n";
524        let options = MergeBlobOptions {
525            ours_label: "",
526            theirs_label: "",
527            base_label: "",
528            style: ConflictStyle::Merge,
529            favor: MergeFavor::None,
530            ws_ignore: WsIgnore::EMPTY,
531            marker_size: 7,
532        };
533        let result = merge_blobs(base, ours, theirs, &options);
534        assert!(result.conflicted);
535        // No trailing space after the 7 marker chars when the label is empty.
536        assert_eq!(
537            result.content,
538            b"a\n<<<<<<<\nOURS\n=======\nTHEIRS\n>>>>>>>\nc\n".to_vec(),
539        );
540    }
541
542    #[test]
543    fn merge_add_add_empty_base_conflicts() {
544        let result = merge_blobs(b"", b"x\ny\n", b"p\nq\n", &merge_opts());
545        assert!(result.conflicted);
546        assert_eq!(
547            result.content,
548            b"<<<<<<< ours\nx\ny\n=======\np\nq\n>>>>>>> theirs\n".to_vec(),
549        );
550    }
551
552    #[test]
553    fn merge_ignore_space_change_resolves_clean_keeping_ours() {
554        // ours: only-whitespace change (collapsed run); theirs: real change.
555        // Under -Xignore-space-change the whitespace-only line is not a conflict
556        // and ours' actual bytes survive (xdl_merge copies common spans from
557        // file1); theirs' real change to a different line wins on its own line.
558        let base = b"alpha   beta\nsecond line\n";
559        let ours = b"alpha beta\nsecond line\n"; // collapsed the run
560        let theirs = b"alpha   beta\nsecond CHANGED\n"; // real change on line 2
561        let options = MergeBlobOptions {
562            ws_ignore: WsIgnore {
563                space_change: true,
564                ..WsIgnore::EMPTY
565            },
566            ..merge_opts()
567        };
568        let result = merge_blobs(base, ours, theirs, &options);
569        assert!(
570            !result.conflicted,
571            "whitespace-only divergence is not a conflict"
572        );
573        assert_eq!(result.content, b"alpha beta\nsecond CHANGED\n".to_vec());
574    }
575
576    #[test]
577    fn merge_ignore_space_change_still_conflicts_on_real_divergence() {
578        // Both sides make a real (non-whitespace) change to the same line: still
579        // a conflict even under -Xignore-space-change.
580        let base = b"one\n";
581        let ours = b"OURS\n";
582        let theirs = b"THEIRS\n";
583        let options = MergeBlobOptions {
584            ws_ignore: WsIgnore {
585                space_change: true,
586                ..WsIgnore::EMPTY
587            },
588            ..merge_opts()
589        };
590        let result = merge_blobs(base, ours, theirs, &options);
591        assert!(result.conflicted);
592    }
593
594    #[test]
595    fn merge_add_add_empty_base_identical_is_clean() {
596        let result = merge_blobs(b"", b"x\ny\n", b"x\ny\n", &merge_opts());
597        assert!(!result.conflicted);
598        assert_eq!(result.content, b"x\ny\n");
599    }
600
601    #[test]
602    fn merge_deletion_one_side_takes_deletion() {
603        // ours deletes line b; theirs leaves it -> clean, deletion wins.
604        let result = merge_blobs(b"a\nb\nc\n", b"a\nc\n", b"a\nb\nc\n", &merge_opts());
605        assert!(!result.conflicted);
606        assert_eq!(result.content, b"a\nc\n");
607    }
608
609    #[test]
610    fn merge_deletion_vs_modification_conflicts() {
611        // ours deletes b; theirs modifies b -> conflict.
612        let result = merge_blobs(b"a\nb\nc\n", b"a\nc\n", b"a\nB!\nc\n", &merge_opts());
613        assert!(result.conflicted);
614        // ours side of the conflict is empty (the line was deleted).
615        assert_eq!(
616            result.content,
617            b"a\n<<<<<<< ours\n=======\nB!\n>>>>>>> theirs\nc\n".to_vec(),
618        );
619    }
620
621    #[test]
622    fn merge_missing_final_newline_marker_starts_on_own_line() {
623        // Both sides drop the trailing newline AND conflict at the end. The
624        // closing marker section must still begin on its own line.
625        let base = b"a\nb";
626        let ours = b"a\nOURS";
627        let theirs = b"a\nTHEIRS";
628        let result = merge_blobs(base, ours, theirs, &merge_opts());
629        assert!(result.conflicted);
630        assert_eq!(
631            result.content,
632            b"a\n<<<<<<< ours\nOURS\n=======\nTHEIRS\n>>>>>>> theirs\n".to_vec(),
633        );
634    }
635
636    #[test]
637    fn merge_clean_preserves_missing_final_newline() {
638        // ours removes the trailing newline; theirs is unchanged -> ours wins,
639        // and the result keeps the missing newline.
640        let result = merge_blobs(b"a\nb\n", b"a\nb", b"a\nb\n", &merge_opts());
641        assert!(!result.conflicted);
642        assert_eq!(result.content, b"a\nb");
643    }
644
645    #[test]
646    fn merge_both_append_identical_tail_is_clean() {
647        let result = merge_blobs(b"a\n", b"a\nz\n", b"a\nz\n", &merge_opts());
648        assert!(!result.conflicted);
649        assert_eq!(result.content, b"a\nz\n");
650    }
651
652    #[test]
653    fn merge_when_ours_equals_base_yields_theirs() {
654        // Regression: a side that did not change must not suppress the other
655        // side's edits anywhere in the file.
656        let base = b"b\na\n";
657        let theirs = b"b\nb\nc\na\nc\n";
658        let result = merge_blobs(base, base, theirs, &merge_opts());
659        assert!(!result.conflicted);
660        assert_eq!(result.content, theirs.to_vec());
661    }
662    fn applied(outcome: ApplyOutcome) -> Vec<u8> {
663        match outcome {
664            ApplyOutcome::Applied(bytes) => bytes,
665            ApplyOutcome::Rejected => panic!("expected Applied, got Rejected"),
666        }
667    }
668
669    #[test]
670    fn parse_multi_file_patch() {
671        let patch = b"\
672diff --git a/one.txt b/one.txt
673index aaaaaaa..bbbbbbb 100644
674--- a/one.txt
675+++ b/one.txt
676@@ -1,3 +1,3 @@
677 alpha
678-beta
679+BETA
680 gamma
681diff --git a/two.txt b/two.txt
682index ccccccc..ddddddd 100644
683--- a/two.txt
684+++ b/two.txt
685@@ -1,2 +1,3 @@
686 first
687+inserted
688 second
689";
690        let patches = parse_unified_patch(patch).expect("test operation should succeed");
691        assert_eq!(patches.len(), 2);
692
693        assert_eq!(patches[0].old_path.as_deref(), Some(b"one.txt".as_slice()));
694        assert_eq!(patches[0].new_path.as_deref(), Some(b"one.txt".as_slice()));
695        // The `index <a>..<b> 100644` line carries the unchanged-file mode, which
696        // git's gitdiff_index records as old_mode.
697        assert_eq!(patches[0].old_mode, Some(0o100644));
698        assert_eq!(
699            patches[0].old_oid_hex.as_deref(),
700            Some(b"aaaaaaa".as_slice())
701        );
702        assert_eq!(
703            patches[0].new_oid_hex.as_deref(),
704            Some(b"bbbbbbb".as_slice())
705        );
706        assert_eq!(patches[0].hunks.len(), 1);
707        let h = &patches[0].hunks[0];
708        assert_eq!(
709            (h.old_start, h.old_len, h.new_start, h.new_len),
710            (1, 3, 1, 3)
711        );
712        assert_eq!(
713            h.lines,
714            vec![
715                HunkLine::Context(b"alpha".to_vec()),
716                HunkLine::Delete(b"beta".to_vec()),
717                HunkLine::Insert(b"BETA".to_vec()),
718                HunkLine::Context(b"gamma".to_vec()),
719            ]
720        );
721
722        assert_eq!(patches[1].new_path.as_deref(), Some(b"two.txt".as_slice()));
723        assert_eq!(patches[1].hunks[0].new_len, 3);
724    }
725
726    #[test]
727    fn parse_default_hunk_range_length() {
728        // `@@ -1 +1,2 @@` (no comma) means a length of 1 on the old side.
729        let patch = b"\
730--- a/x
731+++ b/x
732@@ -1 +1,2 @@
733 line
734+added
735";
736        let patches = parse_unified_patch(patch).expect("test operation should succeed");
737        let h = &patches[0].hunks[0];
738        assert_eq!(
739            (h.old_start, h.old_len, h.new_start, h.new_len),
740            (1, 1, 1, 2)
741        );
742    }
743
744    #[test]
745    fn parse_hunk_header_before_file_errors() {
746        let patch = b"@@ -1,1 +1,1 @@\n context\n";
747        assert!(parse_unified_patch(patch).is_err());
748    }
749
750    #[test]
751    fn parse_mismatched_counts_errors() {
752        // Header promises two old lines but only one is present.
753        let patch = b"--- a/x\n+++ b/x\n@@ -1,2 +1,2 @@\n only\n+new\n";
754        assert!(parse_unified_patch(patch).is_err());
755    }
756
757    #[test]
758    fn apply_clean_hunk() {
759        let base = b"alpha\nbeta\ngamma\n";
760        let patch = parse_unified_patch(
761            b"--- a/x\n+++ b/x\n@@ -1,3 +1,3 @@\n alpha\n-beta\n+BETA\n gamma\n",
762        )
763        .expect("test operation should succeed");
764        let out = applied(apply_file_patch(base, &patch[0]));
765        assert_eq!(out, b"alpha\nBETA\ngamma\n");
766    }
767
768    #[test]
769    fn apply_with_line_offset() {
770        // The hunk's recorded position (line 2) is a couple of lines above where
771        // the matching context actually lives (line 4); the outward search must
772        // find it. The hunk is NOT anchored at the file start (old_start > 1, so
773        // no match_beginning) and has trailing context (`tail`, so no
774        // match_end), which is exactly the shape a real drifted patch takes —
775        // verified against `git apply` ("Hunk #1 succeeded at 4 (offset 2)").
776        let base = b"pre1\npre2\npre3\nalpha\nbeta\ngamma\ntail\n";
777        let patch = parse_unified_patch(
778            b"--- a/x\n+++ b/x\n@@ -2,4 +2,4 @@\n alpha\n-beta\n+BETA\n gamma\n tail\n",
779        )
780        .expect("test operation should succeed");
781        let out = applied(apply_file_patch(base, &patch[0]));
782        assert_eq!(out, b"pre1\npre2\npre3\nalpha\nBETA\ngamma\ntail\n");
783    }
784
785    #[test]
786    fn apply_with_negative_line_offset() {
787        // Recorded position is well past the real location; search backward.
788        let base = b"alpha\nbeta\ngamma\n";
789        let patch = parse_unified_patch(
790            b"--- a/x\n+++ b/x\n@@ -50,3 +50,3 @@\n alpha\n-beta\n+BETA\n gamma\n",
791        )
792        .expect("test operation should succeed");
793        let out = applied(apply_file_patch(base, &patch[0]));
794        assert_eq!(out, b"alpha\nBETA\ngamma\n");
795    }
796
797    #[test]
798    fn apply_multiple_hunks() {
799        let base = b"a\nb\nc\nd\ne\nf\ng\nh\n";
800        let patch = parse_unified_patch(
801            b"--- a/x\n+++ b/x\n\
802@@ -1,3 +1,3 @@\n a\n-b\n+B\n c\n\
803@@ -6,3 +6,3 @@\n f\n-g\n+G\n h\n",
804        )
805        .expect("test operation should succeed");
806        let out = applied(apply_file_patch(base, &patch[0]));
807        assert_eq!(out, b"a\nB\nc\nd\ne\nf\nG\nh\n");
808    }
809
810    #[test]
811    fn reject_on_context_mismatch() {
812        let base = b"alpha\nDIFFERENT\ngamma\n";
813        let patch = parse_unified_patch(
814            b"--- a/x\n+++ b/x\n@@ -1,3 +1,3 @@\n alpha\n-beta\n+BETA\n gamma\n",
815        )
816        .expect("test operation should succeed");
817        assert_eq!(apply_file_patch(base, &patch[0]), ApplyOutcome::Rejected);
818    }
819
820    #[test]
821    fn reject_when_match_end_required_but_not_at_eof() {
822        // git's `apply.c`: a hunk with NO trailing context must match the END of
823        // the file (`match_end`). Here the leading context (`tail`/`anchor`)
824        // matches at the middle of the base, but there are further lines after
825        // it, so the preimage does not reach EOF. git rejects this; the old
826        // sley matcher wrongly applied it (duplicating the appended block). This
827        // is the t4150-am cell-34 lever: rejection forces `am -3`'s 3-way path.
828        let base = b"one\ntwo\nanchor\nalready\nappended\n";
829        // Hunk: context `anchor`, then append `added1`/`added2`. No trailing
830        // context => match_end. At line 3 (`anchor`) the preimage is just one
831        // line and does not reach EOF, so it must be rejected.
832        let patch =
833            parse_unified_patch(b"--- a/x\n+++ b/x\n@@ -3,1 +3,3 @@\n anchor\n+added1\n+added2\n")
834                .expect("test operation should succeed");
835        assert_eq!(apply_file_patch(base, &patch[0]), ApplyOutcome::Rejected);
836    }
837
838    #[test]
839    fn append_at_eof_matches_when_context_reaches_end() {
840        // The mirror of the rejection case: the same shape applies cleanly when
841        // the matching context IS the last line of the file (preimage reaches
842        // EOF), so `match_end` is satisfied.
843        let base = b"one\ntwo\nanchor\n";
844        let patch =
845            parse_unified_patch(b"--- a/x\n+++ b/x\n@@ -3,1 +3,3 @@\n anchor\n+added1\n+added2\n")
846                .expect("test operation should succeed");
847        let out = applied(apply_file_patch(base, &patch[0]));
848        assert_eq!(out, b"one\ntwo\nanchor\nadded1\nadded2\n");
849    }
850
851    #[test]
852    fn reject_when_match_beginning_required_but_not_at_start() {
853        // A hunk anchored at line 1 (`old_start <= 1`) must match the START of
854        // the file (`match_beginning`). If the matching context only appears
855        // later, git rejects rather than wandering to it.
856        let base = b"junk\nalpha\nbeta\ngamma\n";
857        let patch =
858            parse_unified_patch(b"--- a/x\n+++ b/x\n@@ -1,2 +1,3 @@\n alpha\n+INSERT\n beta\n")
859                .expect("test operation should succeed");
860        assert_eq!(apply_file_patch(base, &patch[0]), ApplyOutcome::Rejected);
861    }
862
863    #[test]
864    fn no_default_fuzz_rejects_on_trailing_context_mismatch() {
865        // `git apply` / `git am` keep `p_context = UINT_MAX` by default, so they
866        // do NOT fuzz a hunk in by dropping context. Here the trailing context
867        // line (`gamma`) differs from the base (`DIVERGED`), and because the
868        // anchor is line 1 the hunk must match the beginning with its FULL
869        // preimage. Verified against real `git apply`: this is rejected.
870        let base = b"alpha\nbeta\nDIVERGED\n";
871        let patch = parse_unified_patch(
872            b"--- a/x\n+++ b/x\n@@ -1,3 +1,3 @@\n alpha\n-beta\n+BETA\n gamma\n",
873        )
874        .expect("test operation should succeed");
875        assert_eq!(apply_file_patch(base, &patch[0]), ApplyOutcome::Rejected);
876    }
877
878    #[test]
879    fn parse_and_apply_new_file() {
880        let patch = parse_unified_patch(
881            b"\
882diff --git a/new.txt b/new.txt
883new file mode 100644
884index 0000000..1111111
885--- /dev/null
886+++ b/new.txt
887@@ -0,0 +1,2 @@
888+hello
889+world
890",
891        )
892        .expect("test operation should succeed");
893        assert!(patches_first_is_new(&patch));
894        assert_eq!(patch[0].old_path, None);
895        assert_eq!(patch[0].new_path.as_deref(), Some(b"new.txt".as_slice()));
896        assert_eq!(patch[0].new_mode, Some(0o100644));
897        // Base is ignored for a new file.
898        let out = applied(apply_file_patch(b"garbage that is ignored", &patch[0]));
899        assert_eq!(out, b"hello\nworld\n");
900    }
901
902    fn patches_first_is_new(patches: &[FilePatch]) -> bool {
903        patches.first().map(|p| p.is_new).unwrap_or(false)
904    }
905
906    #[test]
907    fn parse_and_apply_delete_file() {
908        let patch = parse_unified_patch(
909            b"\
910diff --git a/gone.txt b/gone.txt
911deleted file mode 100644
912index 1111111..0000000
913--- a/gone.txt
914+++ /dev/null
915@@ -1,2 +0,0 @@
916-hello
917-world
918",
919        )
920        .expect("test operation should succeed");
921        assert!(patch[0].is_delete);
922        assert_eq!(patch[0].old_path.as_deref(), Some(b"gone.txt".as_slice()));
923        assert_eq!(patch[0].new_path, None);
924        assert_eq!(patch[0].old_mode, Some(0o100644));
925        let out = applied(apply_file_patch(b"hello\nworld\n", &patch[0]));
926        assert_eq!(out, b"");
927    }
928
929    #[test]
930    fn parse_rename_headers() {
931        let patch = parse_unified_patch(
932            b"\
933diff --git a/old/name.txt b/new/name.txt
934similarity index 100%
935rename from old/name.txt
936rename to new/name.txt
937",
938        )
939        .expect("test operation should succeed");
940        assert!(patch[0].is_rename);
941        assert_eq!(
942            patch[0].old_path.as_deref(),
943            Some(b"old/name.txt".as_slice())
944        );
945        assert_eq!(
946            patch[0].new_path.as_deref(),
947            Some(b"new/name.txt".as_slice())
948        );
949        assert!(patch[0].hunks.is_empty());
950    }
951
952    #[test]
953    fn parse_mode_change_headers() {
954        let patch = parse_unified_patch(
955            b"\
956diff --git a/script.sh b/script.sh
957old mode 100644
958new mode 100755
959",
960        )
961        .expect("test operation should succeed");
962        assert_eq!(patch[0].old_mode, Some(0o100644));
963        assert_eq!(patch[0].new_mode, Some(0o100755));
964        assert!(!patch[0].is_new);
965        assert!(!patch[0].is_delete);
966    }
967
968    #[test]
969    fn no_final_newline_base_preserved_when_untouched() {
970        // The change is on line 1; the final line has no newline and is not
971        // modified, so its no-newline state must survive. This uses the patch
972        // shape real `git diff` emits for such a change — `@@ -1,3 +1,3 @@` with
973        // the two unchanged lines as trailing context (the `\ No newline`
974        // marker rides the last context line). A hand-rolled `@@ -1,1 +1,1 @@`
975        // with NO trailing context would (correctly) be rejected by git, since
976        // a no-trailing-context hunk anchored at line 1 must span the whole
977        // file (`match_beginning` && `match_end`).
978        let base = b"alpha\nbeta\nnotail"; // "notail" has no trailing \n
979        let patch = parse_unified_patch(
980            b"--- a/x\n+++ b/x\n@@ -1,3 +1,3 @@\n-alpha\n+ALPHA\n beta\n notail\n\\ No newline at end of file\n",
981        )
982        .expect("test operation should succeed");
983        let out = applied(apply_file_patch(base, &patch[0]));
984        assert_eq!(out, b"ALPHA\nbeta\nnotail");
985    }
986
987    #[test]
988    fn no_final_newline_added_by_patch() {
989        // Old file ends with a newline; patch rewrites the last line to one
990        // without a trailing newline.
991        let base = b"alpha\nbeta\n";
992        let patch = parse_unified_patch(
993            b"--- a/x\n+++ b/x\n@@ -2,1 +2,1 @@\n-beta\n+beta-notail\n\\ No newline at end of file\n",
994        )
995        .expect("test operation should succeed");
996        assert!(patch[0].hunks[0].new_no_newline);
997        assert!(!patch[0].hunks[0].old_no_newline);
998        let out = applied(apply_file_patch(base, &patch[0]));
999        assert_eq!(out, b"alpha\nbeta-notail");
1000    }
1001
1002    #[test]
1003    fn no_final_newline_in_base_matched_and_kept() {
1004        // Both sides lack a trailing newline; context match must require the
1005        // base's final line to itself be newline-free.
1006        let base = b"alpha\nbeta"; // no trailing newline
1007        let patch = parse_unified_patch(
1008            b"--- a/x\n+++ b/x\n@@ -1,2 +1,2 @@\n-alpha\n+ALPHA\n beta\n\\ No newline at end of file\n",
1009        )
1010        .expect("test operation should succeed");
1011        assert!(patch[0].hunks[0].old_no_newline);
1012        assert!(patch[0].hunks[0].new_no_newline);
1013        let out = applied(apply_file_patch(base, &patch[0]));
1014        assert_eq!(out, b"ALPHA\nbeta");
1015    }
1016
1017    #[test]
1018    fn no_final_newline_mismatch_rejected() {
1019        // Patch asserts the old file has no trailing newline, but the base does.
1020        // That must be rejected rather than silently mis-applied.
1021        let base = b"alpha\nbeta\n"; // HAS trailing newline
1022        let patch = parse_unified_patch(
1023            b"--- a/x\n+++ b/x\n@@ -2,1 +2,1 @@\n-beta\n\\ No newline at end of file\n+beta2\n",
1024        )
1025        .expect("test operation should succeed");
1026        assert!(patch[0].hunks[0].old_no_newline);
1027        assert_eq!(apply_file_patch(base, &patch[0]), ApplyOutcome::Rejected);
1028    }
1029
1030    #[test]
1031    fn delete_with_no_final_newline() {
1032        // Deleting the entire content of a file that had no trailing newline.
1033        let base = b"only line no newline";
1034        let patch = parse_unified_patch(
1035            b"--- a/x\n+++ /dev/null\n@@ -1,1 +0,0 @@\n-only line no newline\n\\ No newline at end of file\n",
1036        )
1037        .expect("test operation should succeed");
1038        assert!(patch[0].is_delete);
1039        let out = applied(apply_file_patch(base, &patch[0]));
1040        assert_eq!(out, b"");
1041    }
1042
1043    #[test]
1044    fn apply_pure_insertion_hunk() {
1045        let base = b"first\nsecond\n";
1046        let patch =
1047            parse_unified_patch(b"--- a/x\n+++ b/x\n@@ -1,2 +1,3 @@\n first\n+middle\n second\n")
1048                .expect("test operation should succeed");
1049        let out = applied(apply_file_patch(base, &patch[0]));
1050        assert_eq!(out, b"first\nmiddle\nsecond\n");
1051    }
1052
1053    #[test]
1054    fn apply_pure_deletion_hunk() {
1055        let base = b"first\nmiddle\nsecond\n";
1056        let patch =
1057            parse_unified_patch(b"--- a/x\n+++ b/x\n@@ -1,3 +1,2 @@\n first\n-middle\n second\n")
1058                .expect("test operation should succeed");
1059        let out = applied(apply_file_patch(base, &patch[0]));
1060        assert_eq!(out, b"first\nsecond\n");
1061    }
1062
1063    #[test]
1064    fn apply_then_reparse_round_trip() {
1065        // Hand-written unified diff -> apply -> the result is exactly the new
1066        // file content the diff describes. Re-parsing the same patch yields an
1067        // identical structure (idempotent parse).
1068        let base = b"l1\nl2\nl3\nl4\nl5\n";
1069        let text = b"--- a/f\n+++ b/f\n@@ -2,3 +2,4 @@\n l2\n-l3\n+L3\n+L3b\n l4\n";
1070        let p1 = parse_unified_patch(text).expect("test operation should succeed");
1071        let p2 = parse_unified_patch(text).expect("test operation should succeed");
1072        assert_eq!(p1, p2);
1073        let out = applied(apply_file_patch(base, &p1[0]));
1074        assert_eq!(out, b"l1\nl2\nL3\nL3b\nl4\nl5\n");
1075    }
1076
1077    #[test]
1078    fn empty_context_line_without_trailing_space() {
1079        // Some transports strip the single leading space from blank context
1080        // lines; the parser treats a wholly empty body line as blank context.
1081        let base = b"a\n\nb\n";
1082        let patch = parse_unified_patch(b"--- a/x\n+++ b/x\n@@ -1,3 +1,3 @@\n a\n\n-b\n+B\n")
1083            .expect("test operation should succeed");
1084        assert_eq!(patch[0].hunks[0].lines[1], HunkLine::Context(Vec::new()));
1085        let out = applied(apply_file_patch(base, &patch[0]));
1086        assert_eq!(out, b"a\n\nB\n");
1087    }
1088
1089    #[test]
1090    fn split_blob_lines_handles_edge_cases() {
1091        assert!(split_blob_lines(b"").is_empty());
1092        let single = split_blob_lines(b"abc");
1093        assert_eq!(single.len(), 1);
1094        assert!(single[0].no_newline);
1095        let terminated = split_blob_lines(b"abc\n");
1096        assert_eq!(terminated.len(), 1);
1097        assert!(!terminated[0].no_newline);
1098        let blank_then_eof = split_blob_lines(b"x\n");
1099        assert_eq!(blank_then_eof.len(), 1);
1100    }
1101
1102    // ---- content similarity & inexact rename/copy detection -----------------
1103
1104    #[test]
1105    fn similarity_identical_and_empty_conventions() {
1106        // Byte-identical blobs are always 100% similar.
1107        assert_eq!(blob_similarity(b"hello\nworld\n", b"hello\nworld\n"), 100);
1108        // Two empty blobs are identical -> 100.
1109        assert_eq!(blob_similarity(b"", b""), 100);
1110        // An empty blob vs a non-empty one shares nothing -> 0.
1111        assert_eq!(blob_similarity(b"", b"hello\n"), 0);
1112        assert_eq!(blob_similarity(b"hello\n", b""), 0);
1113    }
1114
1115    #[test]
1116    fn similarity_one_changed_line_is_75_and_symmetric() {
1117        // A = one/two/three/four/five (bytes: 4+4+6+5+5 = 24).
1118        // B changes "three\n" -> "THREE\n" (same total size 24).
1119        // Common spans: one,two,four,five = 4+4+5+5 = 18 bytes.
1120        // score = round(18 * 100 / max(24, 24)) = round(75) = 75.
1121        // Verified against `git diff -M` which reports "similarity index 75%".
1122        let a = b"one\ntwo\nthree\nfour\nfive\n";
1123        let b = b"one\ntwo\nTHREE\nfour\nfive\n";
1124        assert_eq!(blob_similarity(a, b), 75);
1125        // The metric is symmetric.
1126        assert_eq!(blob_similarity(b, a), 75);
1127    }
1128
1129    #[test]
1130    fn similarity_one_edited_line_of_three_is_66_not_67() {
1131        // "a\nb\nc\n" -> "a\nB\nc\n": one of three lines edited (4 common bytes of
1132        // 6). git reports `R066` / "similarity index 66%". git's two-step integer
1133        // math is `4 * 60000 / 6 = 40000`, then `40000 * 100 / 60000 = 66` (both
1134        // truncated); a single rounded `4 * 100 / 6` would give 67. This pins the
1135        // MAX_SCORE-based rounding so it stays aligned with diffcore-rename.
1136        assert_eq!(blob_similarity(b"a\nb\nc\n", b"a\nB\nc\n"), 66);
1137        assert_eq!(blob_similarity(b"a\nB\nc\n", b"a\nb\nc\n"), 66);
1138    }
1139
1140    #[test]
1141    fn similarity_small_append_is_88() {
1142        // A: 8 lines totalling 46 bytes. B: same 8 lines + "ADDED\n" (6 bytes) = 52.
1143        // Common = the 46 original bytes; score = round(46*100/52) = 88.
1144        // Verified against `git diff -M` -> "similarity index 88%".
1145        let a = b"alpha\nbeta\ngamma\ndelta\nepsilon\nzeta\neta\ntheta\n";
1146        let b = b"alpha\nbeta\ngamma\ndelta\nepsilon\nzeta\neta\ntheta\nADDED\n";
1147        assert_eq!(blob_similarity(a, b), 88);
1148    }
1149
1150    #[test]
1151    fn similarity_half_rewrite_is_50() {
1152        // 6 lines, last 3 rewritten. Common = l1,l2,l3 = 9 bytes; total each 18.
1153        // score = round(9*100/18) = 50. Verified against `git diff -M`.
1154        let a = b"l1\nl2\nl3\nl4\nl5\nl6\n";
1155        let b = b"l1\nl2\nl3\nX4\nX5\nX6\n";
1156        assert_eq!(blob_similarity(a, b), 50);
1157    }
1158
1159    // ---- tree-diff based inexact detection ----------------------------------
1160
1161    /// Write a blob and return its oid.
1162    fn write_blob(db: &mut FileObjectDatabase, bytes: &[u8]) -> ObjectId {
1163        db.write_object(EncodedObject::new(ObjectType::Blob, bytes.to_vec()))
1164            .expect("test operation should succeed")
1165    }
1166
1167    /// Write a tree from `(name, mode, oid)` entries (sorted by name as git
1168    /// requires) and return its oid.
1169    fn write_tree(db: &mut FileObjectDatabase, entries: &[(&[u8], u32, ObjectId)]) -> ObjectId {
1170        let mut tree_entries: Vec<TreeEntry> = entries
1171            .iter()
1172            .map(|(name, mode, oid)| TreeEntry {
1173                mode: *mode,
1174                name: BString::from(*name),
1175                oid: *oid,
1176            })
1177            .collect();
1178        tree_entries.sort_by(|a, b| a.name.cmp(&b.name));
1179        let tree = Tree {
1180            entries: tree_entries,
1181        };
1182        db.write_object(EncodedObject::new(ObjectType::Tree, tree.write()))
1183            .expect("test operation should succeed")
1184    }
1185
1186    fn write_tree_in_order(
1187        db: &mut FileObjectDatabase,
1188        entries: &[(&[u8], u32, ObjectId)],
1189    ) -> ObjectId {
1190        let tree_entries: Vec<TreeEntry> = entries
1191            .iter()
1192            .map(|(name, mode, oid)| TreeEntry {
1193                mode: *mode,
1194                name: BString::from(*name),
1195                oid: *oid,
1196            })
1197            .collect();
1198        let tree = Tree {
1199            entries: tree_entries,
1200        };
1201        db.write_object(EncodedObject::new(ObjectType::Tree, tree.write()))
1202            .expect("test operation should succeed")
1203    }
1204
1205    fn skip_worktree_entry(path: &[u8], oid: ObjectId) -> sley_index::IndexEntry {
1206        let mut entry = sley_index::IndexEntry {
1207            ctime_seconds: 0,
1208            ctime_nanoseconds: 0,
1209            mtime_seconds: 0,
1210            mtime_nanoseconds: 0,
1211            dev: 0,
1212            ino: 0,
1213            mode: 0o100644,
1214            uid: 0,
1215            gid: 0,
1216            size: 0,
1217            oid,
1218            flags: path.len() as u16,
1219            flags_extended: 0,
1220            path: BString::from(path),
1221        };
1222        entry.set_skip_worktree(true);
1223        entry
1224    }
1225
1226    fn write_index(git_dir: &Path, mut entries: Vec<sley_index::IndexEntry>) {
1227        entries.sort_by(|left, right| left.path.as_bytes().cmp(right.path.as_bytes()));
1228        let index = Index {
1229            version: 3,
1230            entries,
1231            extensions: Vec::new(),
1232            checksum: None,
1233        };
1234        fs::write(
1235            git_dir.join("index"),
1236            index.write_sha1().expect("test operation should succeed"),
1237        )
1238        .expect("test operation should succeed");
1239    }
1240
1241    #[test]
1242    fn inexact_rename_detected_with_plausible_score() {
1243        // a.txt (one changed line vs the new b.txt) should be detected as a
1244        // rename with score 75 (see `similarity_one_changed_line_is_75`).
1245        let root = temp_root();
1246        let layout = RepositoryLayout::init_at(&root, ObjectFormat::Sha1, false)
1247            .expect("test operation should succeed");
1248        let mut db = FileObjectDatabase::from_git_dir(&layout.git_dir, ObjectFormat::Sha1);
1249
1250        let old = write_blob(&mut db, b"one\ntwo\nthree\nfour\nfive\n");
1251        let new = write_blob(&mut db, b"one\ntwo\nTHREE\nfour\nfive\n");
1252        let left = write_tree(&mut db, &[(b"a.txt", 0o100644, old)]);
1253        let right = write_tree(&mut db, &[(b"b.txt", 0o100644, new)]);
1254
1255        let opts = DiffNameStatusOptions {
1256                detect_renames: true,
1257                detect_copies: false,
1258                find_copies_harder: false,
1259                rename_empty: true,
1260            detect_inexact: true,
1261            rename_threshold: DEFAULT_RENAME_THRESHOLD,
1262            copy_threshold: DEFAULT_RENAME_THRESHOLD,
1263            rename_limit: 0,
1264        
1265                ..Default::default()
1266            };
1267        let diff = diff_name_status_trees_with_options_and_diagnostics(
1268            &db,
1269            ObjectFormat::Sha1,
1270            &left,
1271            &right,
1272            opts,
1273        )
1274        .expect("test operation should succeed");
1275        let entries = diff.entries;
1276
1277        assert_eq!(
1278            entries.len(),
1279            1,
1280            "expected a single rename entry: {entries:?}"
1281        );
1282        assert_eq!(entries[0].status, NameStatus::Renamed(75));
1283        assert_eq!(
1284            entries[0].old_path.as_ref().map(|p| p.as_bytes()),
1285            Some(b"a.txt".as_slice())
1286        );
1287        assert_eq!(entries[0].path, b"b.txt");
1288        assert_eq!(entries[0].line(), "R075\ta.txt\tb.txt");
1289        fs::remove_dir_all(root).expect("test operation should succeed");
1290    }
1291
1292    #[test]
1293    fn tree_diff_preserves_duplicate_entry_multiplicity() {
1294        let root = temp_root();
1295        let layout = RepositoryLayout::init_at(&root, ObjectFormat::Sha1, false)
1296            .expect("test operation should succeed");
1297        let mut db = FileObjectDatabase::from_git_dir(&layout.git_dir, ObjectFormat::Sha1);
1298
1299        let blob_one = write_blob(&mut db, b"one\n");
1300        let blob_two = write_blob(&mut db, b"two\n");
1301        let inner_one_a = write_tree_in_order(&mut db, &[(b"inner", 0o100644, blob_one)]);
1302        let inner_one_b = write_tree_in_order(
1303            &mut db,
1304            &[
1305                (b"inner", 0o100644, blob_two),
1306                (b"inner", 0o100644, blob_two),
1307                (b"inner", 0o100644, blob_two),
1308            ],
1309        );
1310        let outer_one = write_tree_in_order(
1311            &mut db,
1312            &[
1313                (b"outer", TREE_ENTRY_MODE, inner_one_a),
1314                (b"outer", TREE_ENTRY_MODE, inner_one_b),
1315            ],
1316        );
1317        let inner_two = write_tree_in_order(
1318            &mut db,
1319            &[
1320                (b"inner", 0o100644, blob_one),
1321                (b"inner", 0o100644, blob_two),
1322                (b"inner", 0o100644, blob_two),
1323                (b"inner", 0o100644, blob_two),
1324            ],
1325        );
1326        let outer_two = write_tree_in_order(&mut db, &[(b"outer", TREE_ENTRY_MODE, inner_two)]);
1327        let outer_three = write_tree_in_order(&mut db, &[(b"renamed", 0o100644, blob_one)]);
1328
1329        let raw_options = DiffNameStatusOptions {
1330            detect_renames: false,
1331            detect_copies: false,
1332            find_copies_harder: false,
1333            rename_empty: true,
1334            ..Default::default()
1335        };
1336        let raw = diff_name_status_trees_with_options(
1337            &db,
1338            ObjectFormat::Sha1,
1339            &outer_one,
1340            &outer_two,
1341            raw_options,
1342        )
1343        .expect("test operation should succeed");
1344        assert_eq!(
1345            status_lines(&raw),
1346            vec![
1347                "A\touter/inner",
1348                "A\touter/inner",
1349                "A\touter/inner",
1350                "D\touter/inner",
1351                "D\touter/inner",
1352                "D\touter/inner",
1353            ]
1354        );
1355
1356        let rename_options = DiffNameStatusOptions {
1357                detect_renames: true,
1358                detect_copies: false,
1359                find_copies_harder: false,
1360                rename_empty: true,
1361            detect_inexact: true,
1362            rename_threshold: DEFAULT_RENAME_THRESHOLD,
1363            copy_threshold: DEFAULT_RENAME_THRESHOLD,
1364            rename_limit: 0,
1365        
1366                ..Default::default()
1367            };
1368        let no_op_renames = diff_name_status_trees_with_options_and_diagnostics(
1369            &db,
1370            ObjectFormat::Sha1,
1371            &outer_one,
1372            &outer_two,
1373            rename_options,
1374        )
1375        .expect("test operation should succeed");
1376        assert!(no_op_renames.entries.is_empty());
1377
1378        let renamed = diff_name_status_trees_with_options_and_diagnostics(
1379            &db,
1380            ObjectFormat::Sha1,
1381            &outer_one,
1382            &outer_three,
1383            rename_options,
1384        )
1385        .expect("test operation should succeed");
1386        assert_eq!(
1387            status_lines(&renamed.entries),
1388            vec![
1389                "D\touter/inner",
1390                "D\touter/inner",
1391                "D\touter/inner",
1392                "R100\touter/inner\trenamed",
1393            ]
1394        );
1395
1396        fs::remove_dir_all(root).expect("test operation should succeed");
1397    }
1398
1399    #[test]
1400    fn inexact_rename_below_threshold_not_detected() {
1401        // A half-rewrite scores 50%. With a 60% threshold it must NOT be paired;
1402        // the change shows up as a separate Add + Delete instead.
1403        let root = temp_root();
1404        let layout = RepositoryLayout::init_at(&root, ObjectFormat::Sha1, false)
1405            .expect("test operation should succeed");
1406        let mut db = FileObjectDatabase::from_git_dir(&layout.git_dir, ObjectFormat::Sha1);
1407
1408        let old = write_blob(&mut db, b"l1\nl2\nl3\nl4\nl5\nl6\n");
1409        let new = write_blob(&mut db, b"l1\nl2\nl3\nX4\nX5\nX6\n");
1410        let left = write_tree(&mut db, &[(b"a.txt", 0o100644, old)]);
1411        let right = write_tree(&mut db, &[(b"b.txt", 0o100644, new)]);
1412
1413        let opts = DiffNameStatusOptions {
1414                detect_renames: true,
1415                detect_copies: false,
1416                find_copies_harder: false,
1417                rename_empty: true,
1418            detect_inexact: true,
1419            rename_threshold: 60,
1420            copy_threshold: 60,
1421            rename_limit: 0,
1422        
1423                ..Default::default()
1424            };
1425        let diff = diff_name_status_trees_with_options_and_diagnostics(
1426            &db,
1427            ObjectFormat::Sha1,
1428            &left,
1429            &right,
1430            opts,
1431        )
1432        .expect("test operation should succeed");
1433        let entries = diff.entries;
1434
1435        let statuses: Vec<_> = entries.iter().map(|e| e.status).collect();
1436        assert!(
1437            statuses.contains(&NameStatus::Added) && statuses.contains(&NameStatus::Deleted),
1438            "expected separate add/delete below threshold, got {entries:?}"
1439        );
1440        assert!(
1441            !statuses.iter().any(|s| matches!(s, NameStatus::Renamed(_))),
1442            "no rename should be reported below threshold: {entries:?}"
1443        );
1444
1445        // Sanity: lowering the threshold to 50 *does* detect it (boundary is
1446        // inclusive), and the score is exactly 50.
1447        let opts_low = DiffNameStatusOptions {
1448            rename_threshold: 50,
1449            ..opts
1450        };
1451        let entries_low = diff_name_status_trees_with_options_and_diagnostics(
1452            &db,
1453            ObjectFormat::Sha1,
1454            &left,
1455            &right,
1456            opts_low,
1457        )
1458        .expect("test operation should succeed");
1459        assert_eq!(entries_low.entries.len(), 1);
1460        assert_eq!(entries_low.entries[0].status, NameStatus::Renamed(50));
1461        fs::remove_dir_all(root).expect("test operation should succeed");
1462    }
1463
1464    #[test]
1465    fn exact_rename_scores_100_and_takes_priority() {
1466        // Identical content moved to a new path is an exact rename: score 100,
1467        // detected even with inexact disabled, and still 100 with it enabled.
1468        let root = temp_root();
1469        let layout = RepositoryLayout::init_at(&root, ObjectFormat::Sha1, false)
1470            .expect("test operation should succeed");
1471        let mut db = FileObjectDatabase::from_git_dir(&layout.git_dir, ObjectFormat::Sha1);
1472
1473        let oid = write_blob(&mut db, b"identical\ncontent\nhere\n");
1474        let left = write_tree(&mut db, &[(b"old.txt", 0o100644, oid)]);
1475        let right = write_tree(&mut db, &[(b"new.txt", 0o100644, oid)]);
1476
1477        for inexact in [false, true] {
1478            let opts = DiffNameStatusOptions {
1479                    detect_renames: true,
1480                    detect_copies: false,
1481                    find_copies_harder: false,
1482                    rename_empty: true,
1483                detect_inexact: inexact,
1484                rename_threshold: DEFAULT_RENAME_THRESHOLD,
1485                copy_threshold: DEFAULT_RENAME_THRESHOLD,
1486                rename_limit: 0,
1487            
1488                ..Default::default()
1489            };
1490            let diff = diff_name_status_trees_with_options_and_diagnostics(
1491                &db,
1492                ObjectFormat::Sha1,
1493                &left,
1494                &right,
1495                opts,
1496            )
1497            .expect("test operation should succeed");
1498        let entries = diff.entries;
1499            assert_eq!(entries.len(), 1, "inexact={inexact}: {entries:?}");
1500            assert_eq!(entries[0].status, NameStatus::Renamed(100));
1501            assert_eq!(
1502                entries[0].old_path.as_ref().map(|p| p.as_bytes()),
1503                Some(b"old.txt".as_slice())
1504            );
1505            assert_eq!(entries[0].path, b"new.txt");
1506        }
1507        fs::remove_dir_all(root).expect("test operation should succeed");
1508    }
1509
1510    #[test]
1511    fn inexact_copy_detected_with_score() {
1512        // orig.txt is unchanged and a near-copy (one line differs, 80% similar)
1513        // is added. With copy detection + find_copies_harder + inexact, the new
1514        // file is reported as a copy with score 80 (matches `git diff -C
1515        // --find-copies-harder`).
1516        let root = temp_root();
1517        let layout = RepositoryLayout::init_at(&root, ObjectFormat::Sha1, false)
1518            .expect("test operation should succeed");
1519        let mut db = FileObjectDatabase::from_git_dir(&layout.git_dir, ObjectFormat::Sha1);
1520
1521        let orig = write_blob(&mut db, b"aaa\nbbb\nccc\nddd\neee\n");
1522        let copy = write_blob(&mut db, b"aaa\nbbb\nccc\nddd\nEEE\n");
1523        let left = write_tree(&mut db, &[(b"orig.txt", 0o100644, orig.clone())]);
1524        let right = write_tree(
1525            &mut db,
1526            &[(b"orig.txt", 0o100644, orig), (b"copy.txt", 0o100644, copy)],
1527        );
1528
1529        let opts = DiffNameStatusOptions {
1530                detect_renames: true,
1531                detect_copies: true,
1532                find_copies_harder: true,
1533                rename_empty: true,
1534            detect_inexact: true,
1535            rename_threshold: DEFAULT_RENAME_THRESHOLD,
1536            copy_threshold: DEFAULT_RENAME_THRESHOLD,
1537            rename_limit: 0,
1538        
1539                ..Default::default()
1540            };
1541        let diff = diff_name_status_trees_with_options_and_diagnostics(
1542            &db,
1543            ObjectFormat::Sha1,
1544            &left,
1545            &right,
1546            opts,
1547        )
1548        .expect("test operation should succeed");
1549        let entries = diff.entries;
1550
1551        let copy_entry = entries
1552            .iter()
1553            .find(|e| e.path == b"copy.txt")
1554            .unwrap_or_else(|| panic!("no copy.txt entry: {entries:?}"));
1555        assert_eq!(copy_entry.status, NameStatus::Copied(80));
1556        assert_eq!(
1557            copy_entry.old_path.as_ref().map(|p| p.as_bytes()),
1558            Some(b"orig.txt".as_slice())
1559        );
1560        // The source remains present (copies do not consume the original).
1561        assert!(
1562            entries.iter().all(|e| e.status != NameStatus::Deleted),
1563            "copy must not delete the source: {entries:?}"
1564        );
1565        fs::remove_dir_all(root).expect("test operation should succeed");
1566    }
1567
1568    #[test]
1569    fn inexact_copy_skipped_over_rename_limit() {
1570        // git's `too_many_rename_candidates`: when the copy matrix
1571        // (sources × dests) exceeds `rename_limit²`, inexact copy detection is
1572        // skipped wholesale and the new file is reported as a plain Add — the
1573        // same `A` real git emits (`git diff -C --find-copies-harder -l1` warns
1574        // "rename detection was skipped" and shows `A copy.txt`). A `rename_limit`
1575        // comfortably above the matrix still detects the copy, proving the gate
1576        // fires *only* over-limit and not on any positive limit.
1577        let root = temp_root();
1578        let layout = RepositoryLayout::init_at(&root, ObjectFormat::Sha1, false)
1579            .expect("test operation should succeed");
1580        let mut db = FileObjectDatabase::from_git_dir(&layout.git_dir, ObjectFormat::Sha1);
1581
1582        let orig = write_blob(&mut db, b"aaa\nbbb\nccc\nddd\neee\n");
1583        let extra = write_blob(&mut db, b"111\n222\n333\n444\n555\n");
1584        let copy = write_blob(&mut db, b"aaa\nbbb\nccc\nddd\nEEE\n");
1585        // Two unchanged left files → under `--find-copies-harder` both are copy
1586        // sources, so the matrix is 2 (sources) × 1 (dest) = 2.
1587        let left = write_tree(
1588            &mut db,
1589            &[
1590                (b"orig.txt", 0o100644, orig.clone()),
1591                (b"extra.txt", 0o100644, extra.clone()),
1592            ],
1593        );
1594        let right = write_tree(
1595            &mut db,
1596            &[
1597                (b"orig.txt", 0o100644, orig),
1598                (b"extra.txt", 0o100644, extra),
1599                (b"copy.txt", 0o100644, copy),
1600            ],
1601        );
1602
1603        let opts_for = |rename_limit| DiffNameStatusOptions {
1604                detect_renames: true,
1605                detect_copies: true,
1606                find_copies_harder: true,
1607                rename_empty: true,
1608            detect_inexact: true,
1609            rename_threshold: DEFAULT_RENAME_THRESHOLD,
1610            copy_threshold: DEFAULT_RENAME_THRESHOLD,
1611            rename_limit,
1612        
1613                ..Default::default()
1614            };
1615
1616        // Over limit: 2 × 1 = 2 > 1² ⇒ copy detection skipped, copy.txt is Added.
1617        let over = diff_name_status_trees_with_options_and_diagnostics(
1618            &db,
1619            ObjectFormat::Sha1,
1620            &left,
1621            &right,
1622            opts_for(1),
1623        )
1624        .expect("test operation should succeed");
1625        let copy_over = over
1626            .entries
1627            .iter()
1628            .find(|e| e.path == b"copy.txt")
1629            .unwrap_or_else(|| panic!("no copy.txt entry: {over:?}"));
1630        assert_eq!(
1631            copy_over.status,
1632            NameStatus::Added,
1633            "over rename_limit, copy must degrade to a plain Add: {over:?}"
1634        );
1635
1636        // Under limit: 2 × 1 = 2 ≤ 4² ⇒ copy still detected (score 80).
1637        let under = diff_name_status_trees_with_options_and_diagnostics(
1638            &db,
1639            ObjectFormat::Sha1,
1640            &left,
1641            &right,
1642            opts_for(4),
1643        )
1644        .expect("test operation should succeed");
1645        let copy_under = under
1646            .entries
1647            .iter()
1648            .find(|e| e.path == b"copy.txt")
1649            .unwrap_or_else(|| panic!("no copy.txt entry: {under:?}"));
1650        assert_eq!(
1651            copy_under.status,
1652            NameStatus::Copied(80),
1653            "below rename_limit, copy detection is unaffected: {under:?}"
1654        );
1655
1656        fs::remove_dir_all(root).expect("test operation should succeed");
1657    }
1658
1659    #[test]
1660    fn inexact_rename_with_small_edit_scores_88() {
1661        // A rename that also appends a single line scores 88% (see
1662        // `similarity_small_append_is_88`).
1663        let root = temp_root();
1664        let layout = RepositoryLayout::init_at(&root, ObjectFormat::Sha1, false)
1665            .expect("test operation should succeed");
1666        let mut db = FileObjectDatabase::from_git_dir(&layout.git_dir, ObjectFormat::Sha1);
1667
1668        let old = write_blob(
1669            &mut db,
1670            b"alpha\nbeta\ngamma\ndelta\nepsilon\nzeta\neta\ntheta\n",
1671        );
1672        let new = write_blob(
1673            &mut db,
1674            b"alpha\nbeta\ngamma\ndelta\nepsilon\nzeta\neta\ntheta\nADDED\n",
1675        );
1676        let left = write_tree(&mut db, &[(b"src.txt", 0o100644, old)]);
1677        let right = write_tree(&mut db, &[(b"dst.txt", 0o100644, new)]);
1678
1679        let opts = DiffNameStatusOptions {
1680            detect_renames: true,
1681            detect_copies: false,
1682            find_copies_harder: false,
1683            rename_empty: true,
1684            ..Default::default()
1685        }
1686        .inexact();
1687        let diff = diff_name_status_trees_with_options_and_diagnostics(
1688            &db,
1689            ObjectFormat::Sha1,
1690            &left,
1691            &right,
1692            opts,
1693        )
1694        .expect("test operation should succeed");
1695        let entries = diff.entries;
1696
1697        assert_eq!(entries.len(), 1, "{entries:?}");
1698        assert_eq!(entries[0].status, NameStatus::Renamed(88));
1699        assert_eq!(
1700            entries[0].old_path.as_ref().map(|p| p.as_bytes()),
1701            Some(b"src.txt".as_slice())
1702        );
1703        assert_eq!(entries[0].path, b"dst.txt");
1704        fs::remove_dir_all(root).expect("test operation should succeed");
1705    }
1706
1707    #[test]
1708    fn inexact_disabled_default_preserves_exact_only_behavior() {
1709        // With DiffNameStatusOptions::default() (detect_inexact == false), a
1710        // similar-but-not-identical pair is NOT a rename — identical to the
1711        // legacy exact-only path. Defaults must not silently turn on inexact.
1712        assert!(!DiffNameStatusOptions::default().detect_inexact);
1713        assert_eq!(
1714            DiffNameStatusOptions::default().rename_threshold,
1715            DEFAULT_RENAME_THRESHOLD
1716        );
1717
1718        let root = temp_root();
1719        let layout = RepositoryLayout::init_at(&root, ObjectFormat::Sha1, false)
1720            .expect("test operation should succeed");
1721        let mut db = FileObjectDatabase::from_git_dir(&layout.git_dir, ObjectFormat::Sha1);
1722
1723        let old = write_blob(&mut db, b"one\ntwo\nthree\nfour\nfive\n");
1724        let new = write_blob(&mut db, b"one\ntwo\nTHREE\nfour\nfive\n");
1725        let left = write_tree(&mut db, &[(b"a.txt", 0o100644, old)]);
1726        let right = write_tree(&mut db, &[(b"b.txt", 0o100644, new)]);
1727
1728        let diff = diff_name_status_trees_with_options_and_diagnostics(
1729            &db,
1730            ObjectFormat::Sha1,
1731            &left,
1732            &right,
1733            DiffNameStatusOptions::default(),
1734        )
1735        .expect("test operation should succeed");
1736        let entries = diff.entries;
1737        let statuses: Vec<_> = entries.iter().map(|e| e.status).collect();
1738        assert!(statuses.contains(&NameStatus::Added));
1739        assert!(statuses.contains(&NameStatus::Deleted));
1740        assert!(!statuses.iter().any(|s| matches!(s, NameStatus::Renamed(_))));
1741        fs::remove_dir_all(root).expect("test operation should succeed");
1742    }
1743
1744    // ---- patience / histogram diff tests ------------------------------------
1745
1746    /// Apply an edit script to `old` and return the reconstructed `new` bytes.
1747    ///
1748    /// Panics (test-only) if the script ever references a line out of range or
1749    /// claims a line is `Equal` when the corresponding `old`/`new` lines differ
1750    /// — that is exactly the invariant a correct LCS diff must uphold.
1751    fn apply_ops(old: &[DiffLine<'_>], new: &[DiffLine<'_>], ops: &[DiffOp]) -> Vec<u8> {
1752        let mut oi = 0usize;
1753        let mut ni = 0usize;
1754        let mut rebuilt: Vec<u8> = Vec::new();
1755        for op in ops {
1756            match *op {
1757                DiffOp::Equal(n) => {
1758                    for _ in 0..n {
1759                        // Equal must mean genuinely-equal lines (LCS-correct).
1760                        assert_eq!(old[oi], new[ni], "Equal op covered unequal lines");
1761                        rebuilt.extend_from_slice(old[oi].content);
1762                        oi += 1;
1763                        ni += 1;
1764                    }
1765                }
1766                DiffOp::Delete(n) => oi += n,
1767                DiffOp::Insert(n) => {
1768                    for _ in 0..n {
1769                        rebuilt.extend_from_slice(new[ni].content);
1770                        ni += 1;
1771                    }
1772                }
1773            }
1774        }
1775        // The script must consume every line of both sides exactly once.
1776        assert_eq!(oi, old.len(), "script did not consume all of old");
1777        assert_eq!(ni, new.len(), "script did not consume all of new");
1778        rebuilt
1779    }
1780
1781    /// Assert that `ops` is a valid LCS-correct script: it reconstructs `new`
1782    /// from `old`, and consecutive ops are coalesced (no two same-kind in a row).
1783    fn assert_valid_script(old_bytes: &[u8], new_bytes: &[u8], ops: &[DiffOp]) {
1784        let old = split_lines(old_bytes);
1785        let new = split_lines(new_bytes);
1786        let rebuilt = apply_ops(&old, &new, ops);
1787        assert_eq!(rebuilt, new_bytes, "script did not rebuild new");
1788        for pair in ops.windows(2) {
1789            let same_kind = matches!(
1790                (pair[0], pair[1]),
1791                (DiffOp::Equal(_), DiffOp::Equal(_))
1792                    | (DiffOp::Delete(_), DiffOp::Delete(_))
1793                    | (DiffOp::Insert(_), DiffOp::Insert(_))
1794            );
1795            assert!(!same_kind, "ops not coalesced: {:?}", ops);
1796        }
1797    }
1798
1799    /// Run all three real algorithms over a byte pair and assert each produces a
1800    /// valid, coalesced, LCS-correct script.
1801    fn check_all_algorithms(old_bytes: &[u8], new_bytes: &[u8]) {
1802        let old = split_lines(old_bytes);
1803        let new = split_lines(new_bytes);
1804        for algo in [
1805            DiffAlgorithm::Myers,
1806            DiffAlgorithm::Minimal,
1807            DiffAlgorithm::Patience,
1808            DiffAlgorithm::Histogram,
1809        ] {
1810            let ops = diff_lines_with_algorithm(&old, &new, algo);
1811            assert_valid_script(old_bytes, new_bytes, &ops);
1812        }
1813    }
1814
1815    #[test]
1816    fn patience_and_histogram_match_myers_on_simple_cases() {
1817        // For localized single-line edits with no repeated lines, all three
1818        // algorithms agree with the canonical Myers script.
1819        let cases: &[(&[u8], &[u8], Vec<DiffOp>)] = &[
1820            (
1821                b"a\nb\nc\n",
1822                b"a\nx\nc\n",
1823                vec![
1824                    DiffOp::Equal(1),
1825                    DiffOp::Delete(1),
1826                    DiffOp::Insert(1),
1827                    DiffOp::Equal(1),
1828                ],
1829            ),
1830            (b"a\nb\nc\n", b"a\nb\nc\n", vec![DiffOp::Equal(3)]),
1831            (b"", b"a\nb\n", vec![DiffOp::Insert(2)]),
1832            (b"a\nb\n", b"", vec![DiffOp::Delete(2)]),
1833            (
1834                b"a\nb\nc\nd\n",
1835                b"a\nc\nd\n",
1836                vec![DiffOp::Equal(1), DiffOp::Delete(1), DiffOp::Equal(2)],
1837            ),
1838        ];
1839        for (old_bytes, new_bytes, expected) in cases {
1840            let old = split_lines(old_bytes);
1841            let new = split_lines(new_bytes);
1842            assert_eq!(&patience_diff_lines(&old, &new), expected);
1843            assert_eq!(&histogram_diff_lines(&old, &new), expected);
1844            assert_eq!(&myers_diff_lines(&old, &new), expected);
1845        }
1846    }
1847
1848    #[test]
1849    fn patience_handles_both_empty() {
1850        let empty = split_lines(b"");
1851        assert!(patience_diff_lines(&empty, &empty).is_empty());
1852        assert!(histogram_diff_lines(&empty, &empty).is_empty());
1853    }
1854
1855    #[test]
1856    fn patience_aligns_unique_anchors_across_moved_block() {
1857        // Reordering two unique blocks: patience anchors on the unique lines and
1858        // produces a delete-then-insert (or insert-then-delete) that still
1859        // reconstructs `new`. Validity is the contract; exact shape may differ
1860        // from Myers, so we only assert reconstruction here.
1861        check_all_algorithms(
1862            b"alpha\nbeta\ngamma\ndelta\n",
1863            b"gamma\ndelta\nalpha\nbeta\n",
1864        );
1865    }
1866
1867    #[test]
1868    fn histogram_differs_from_myers_keeping_block_contiguous() {
1869        // A case where histogram diverges from Myers. With old = "b a" and a new
1870        // that surrounds an intact "b a" with inserted "b" lines, Myers splits
1871        // the common run into two single-line Equals (matching the leading and
1872        // trailing `b`/`a` separately), while histogram anchors on the rare line
1873        // and keeps the original two lines together as one Equal(2) block.
1874        let old = b"b\na\n";
1875        let new = b"a\nb\nb\na\nb\n";
1876        let old_l = split_lines(old);
1877        let new_l = split_lines(new);
1878
1879        let myers = myers_diff_lines(&old_l, &new_l);
1880        let histogram = histogram_diff_lines(&old_l, &new_l);
1881
1882        // All variants must reconstruct `new`.
1883        assert_valid_script(old, new, &myers);
1884        assert_valid_script(old, new, &histogram);
1885
1886        // Exact, pinned shapes: Myers interleaves single-line equals; histogram
1887        // keeps "b\na\n" contiguous.
1888        assert_eq!(
1889            myers,
1890            vec![
1891                DiffOp::Insert(1),
1892                DiffOp::Equal(1),
1893                DiffOp::Insert(1),
1894                DiffOp::Equal(1),
1895                DiffOp::Insert(1),
1896            ]
1897        );
1898        assert_eq!(
1899            histogram,
1900            vec![DiffOp::Insert(2), DiffOp::Equal(2), DiffOp::Insert(1)]
1901        );
1902        // The contract the task calls out: histogram differs from Myers here.
1903        assert_ne!(myers, histogram);
1904    }
1905
1906    #[test]
1907    fn patience_differs_from_myers_on_repeated_lines() {
1908        // A case where patience diverges from Myers. old = "b a", new = "a a b".
1909        // Myers deletes the leading `b` and appends; patience anchors on the
1910        // single unique-in-both line `a`... but `a` occurs twice in `new`, so it
1911        // is NOT unique there; patience instead falls through to its recursive
1912        // structure and produces the mirror script. Both reconstruct `new`.
1913        let old = b"b\na\n";
1914        let new = b"a\na\nb\n";
1915        let old_l = split_lines(old);
1916        let new_l = split_lines(new);
1917
1918        let myers = myers_diff_lines(&old_l, &new_l);
1919        let patience = patience_diff_lines(&old_l, &new_l);
1920
1921        assert_valid_script(old, new, &myers);
1922        assert_valid_script(old, new, &patience);
1923
1924        assert_eq!(
1925            myers,
1926            vec![DiffOp::Delete(1), DiffOp::Equal(1), DiffOp::Insert(2)]
1927        );
1928        assert_eq!(
1929            patience,
1930            vec![DiffOp::Insert(2), DiffOp::Equal(1), DiffOp::Delete(1)]
1931        );
1932        assert_ne!(myers, patience);
1933    }
1934
1935    #[test]
1936    fn realistic_function_insertion_all_valid() {
1937        // A more lifelike example: a new function is inserted ahead of an
1938        // existing one that shares structural lines ("}", blank line). We don't
1939        // pin exact shapes (they depend on trim interactions) but every
1940        // algorithm must produce a valid LCS-correct script.
1941        let old = b"int f() {\n    return 1;\n}\n";
1942        let new = b"int g() {\n    return 2;\n}\n\nint f() {\n    return 1;\n}\n";
1943        check_all_algorithms(old, new);
1944    }
1945
1946    #[test]
1947    fn histogram_anchors_on_rare_line_when_no_unique_line_exists() {
1948        // No line is globally unique on both sides (every distinct line repeats
1949        // on at least one side), so plain patience would fall straight to Myers.
1950        // Histogram still anchors on the least-frequent shared line. We assert
1951        // both produce valid, reconstructing scripts.
1952        check_all_algorithms(b"x\nx\nmid\nx\nx\n", b"x\nmid\nx\nx\nx\n");
1953        check_all_algorithms(
1954            b"dup\ndup\nrare\ndup\ndup\n",
1955            b"dup\nrare\ndup\ndup\ndup\ndup\n",
1956        );
1957    }
1958
1959    #[test]
1960    fn all_algorithms_treat_missing_final_newline_as_change() {
1961        // "b" (no newline) vs "b\n" is a real change for every algorithm.
1962        let old = split_lines(b"a\nb");
1963        let new = split_lines(b"a\nb\n");
1964        for algo in [
1965            DiffAlgorithm::Myers,
1966            DiffAlgorithm::Minimal,
1967            DiffAlgorithm::Patience,
1968            DiffAlgorithm::Histogram,
1969        ] {
1970            let ops = diff_lines_with_algorithm(&old, &new, algo);
1971            assert_eq!(
1972                ops,
1973                vec![DiffOp::Equal(1), DiffOp::Delete(1), DiffOp::Insert(1)],
1974                "algorithm {:?} mishandled missing final newline",
1975                algo
1976            );
1977        }
1978    }
1979
1980    #[test]
1981    fn dispatcher_routes_each_variant() {
1982        let old = split_lines(b"a\nb\nc\n");
1983        let new = split_lines(b"a\nx\nc\n");
1984        assert_eq!(
1985            diff_lines_with_algorithm(&old, &new, DiffAlgorithm::Myers),
1986            myers_diff_lines(&old, &new)
1987        );
1988        // Minimal aliases Myers (the Myers search is already a minimal SES).
1989        assert_eq!(
1990            diff_lines_with_algorithm(&old, &new, DiffAlgorithm::Minimal),
1991            myers_diff_lines(&old, &new)
1992        );
1993        assert_eq!(
1994            diff_lines_with_algorithm(&old, &new, DiffAlgorithm::Patience),
1995            patience_diff_lines(&old, &new)
1996        );
1997        assert_eq!(
1998            diff_lines_with_algorithm(&old, &new, DiffAlgorithm::Histogram),
1999            histogram_diff_lines(&old, &new)
2000        );
2001    }
2002
2003    #[test]
2004    fn patience_recurses_into_gaps_between_anchors() {
2005        // Unique anchors `head`/`tail` bracket an inner edit; patience must
2006        // recurse into the middle gap and diff `mid1`->`MID` there.
2007        let old = b"head\nmid1\nmid2\ntail\n";
2008        let new = b"head\nMID\nmid2\ntail\n";
2009        let old_l = split_lines(old);
2010        let new_l = split_lines(new);
2011        let ops = patience_diff_lines(&old_l, &new_l);
2012        assert_eq!(
2013            ops,
2014            vec![
2015                DiffOp::Equal(1),
2016                DiffOp::Delete(1),
2017                DiffOp::Insert(1),
2018                DiffOp::Equal(2),
2019            ]
2020        );
2021        assert_valid_script(old, new, &ops);
2022    }
2023
2024    #[test]
2025    fn patience_falls_back_to_myers_with_no_unique_lines() {
2026        // Every line is duplicated within its own side, so there are no
2027        // unique-in-both anchors; patience must defer to Myers but still return
2028        // a valid script.
2029        let old = b"a\na\nb\nb\n";
2030        let new = b"a\na\na\nb\n";
2031        let old_l = split_lines(old);
2032        let new_l = split_lines(new);
2033        let ops = patience_diff_lines(&old_l, &new_l);
2034        // The contract for the fallback path is validity, not minimality: after
2035        // the greedy prefix/suffix trim (which git's patience does too) the
2036        // leftover block is handed to Myers, and the whole script must still
2037        // reconstruct `new`.
2038        assert_valid_script(old, new, &ops);
2039    }
2040
2041    #[test]
2042    fn algorithms_agree_with_myers_when_all_lines_distinct() {
2043        // When every line is globally unique, patience's anchor set is the full
2044        // LCS, so patience and histogram must produce exactly the Myers script.
2045        let cases: &[(&[u8], &[u8])] = &[
2046            (b"a\nb\nc\nd\ne\n", b"a\nc\nd\nf\ne\n"),
2047            (b"1\n2\n3\n4\n5\n6\n", b"1\n3\n2\n4\n6\n5\n"),
2048            (b"q\nw\ne\nr\nt\ny\n", b"q\nw\nx\nr\nt\nz\n"),
2049        ];
2050        for (old_bytes, new_bytes) in cases {
2051            let old = split_lines(old_bytes);
2052            let new = split_lines(new_bytes);
2053            let myers = myers_diff_lines(&old, &new);
2054            assert_eq!(
2055                patience_diff_lines(&old, &new),
2056                myers,
2057                "patience must equal Myers when all lines are distinct: {:?}",
2058                old_bytes
2059            );
2060            assert_eq!(
2061                histogram_diff_lines(&old, &new),
2062                myers,
2063                "histogram must equal Myers when all lines are distinct: {:?}",
2064                old_bytes
2065            );
2066        }
2067    }
2068
2069    #[test]
2070    fn fuzz_all_algorithms_reconstruct_new() {
2071        // A small deterministic LCG drives many random small inputs over a tiny
2072        // alphabet (so lines repeat and exercise the anchor/fallback paths).
2073        // Every algorithm must produce a valid LCS-correct script for each pair.
2074        let mut state: u64 = 0x9E37_79B9_7F4A_7C15;
2075        let mut next = || {
2076            state = state
2077                .wrapping_mul(6364136223846793005)
2078                .wrapping_add(1442695040888963407);
2079            (state >> 33) as u32
2080        };
2081        let alphabet = [b"a\n", b"b\n", b"c\n", b"d\n"];
2082        let build = |rng: &mut dyn FnMut() -> u32| -> Vec<u8> {
2083            let len = (rng() % 9) as usize; // 0..=8 lines
2084            let mut buf = Vec::new();
2085            for _ in 0..len {
2086                let pick = (rng() % alphabet.len() as u32) as usize;
2087                buf.extend_from_slice(alphabet[pick]);
2088            }
2089            // Occasionally drop the trailing newline to exercise that path.
2090            if !buf.is_empty() && rng().is_multiple_of(4) {
2091                buf.pop();
2092            }
2093            buf
2094        };
2095        for _ in 0..400 {
2096            let old_bytes = build(&mut next);
2097            let new_bytes = build(&mut next);
2098            check_all_algorithms(&old_bytes, &new_bytes);
2099        }
2100    }
2101
2102    #[test]
2103    fn exhaustive_small_inputs_all_algorithms_reconstruct() {
2104        // Brute force over a 3-symbol alphabet up to 5 lines per side: every
2105        // algorithm must produce a valid LCS-correct script for *every* pair.
2106        // This is the strongest correctness net for the recursion/fallback
2107        // paths; apply_ops asserts both reconstruction and Equal-correctness.
2108        let syms = [b"a\n".to_vec(), b"b\n".to_vec(), b"c\n".to_vec()];
2109        let make = |n: usize, mut code: usize| -> Vec<u8> {
2110            let mut v = Vec::new();
2111            for _ in 0..n {
2112                v.extend_from_slice(&syms[code % 3]);
2113                code /= 3;
2114            }
2115            v
2116        };
2117        for la in 0..=5usize {
2118            for lb in 0..=5usize {
2119                for ca in 0..3usize.pow(la as u32) {
2120                    for cb in 0..3usize.pow(lb as u32) {
2121                        let ob = make(la, ca);
2122                        let nb = make(lb, cb);
2123                        let ol = split_lines(&ob);
2124                        let nl = split_lines(&nb);
2125                        assert_eq!(apply_ops(&ol, &nl, &myers_diff_lines(&ol, &nl)), nb);
2126                        assert_eq!(apply_ops(&ol, &nl, &patience_diff_lines(&ol, &nl)), nb);
2127                        assert_eq!(apply_ops(&ol, &nl, &histogram_diff_lines(&ol, &nl)), nb);
2128                    }
2129                }
2130            }
2131        }
2132    }
2133
2134    #[test]
2135    fn fuzz_distinct_lines_patience_histogram_equal_myers() {
2136        // When inputs are permutations/subsequences of globally-unique lines,
2137        // patience and histogram must match Myers exactly. We generate sequences
2138        // of distinct tokens to guarantee global uniqueness on both sides.
2139        let mut state: u64 = 0x1234_5678_9ABC_DEF0;
2140        let mut next = || {
2141            state = state
2142                .wrapping_mul(6364136223846793005)
2143                .wrapping_add(1442695040888963407);
2144            (state >> 33) as u32
2145        };
2146        for _ in 0..200 {
2147            // Random subset+order of tokens "0\n".."9\n" for each side; tokens
2148            // are globally unique, so any common line is unique in both.
2149            let pick_subseq = |rng: &mut dyn FnMut() -> u32| -> Vec<u8> {
2150                let mut buf = Vec::new();
2151                for t in 0..10u32 {
2152                    if rng().is_multiple_of(2) {
2153                        buf.extend_from_slice(format!("{t}\n").as_bytes());
2154                    }
2155                }
2156                buf
2157            };
2158            let old_bytes = pick_subseq(&mut next);
2159            let new_bytes = pick_subseq(&mut next);
2160            let old = split_lines(&old_bytes);
2161            let new = split_lines(&new_bytes);
2162            let myers = myers_diff_lines(&old, &new);
2163            assert_eq!(patience_diff_lines(&old, &new), myers);
2164            assert_eq!(histogram_diff_lines(&old, &new), myers);
2165        }
2166    }
2167
2168    // ===================================================================
2169    // Subtree-skip-by-OID tree-diff optimization: the pruned simultaneous
2170    // walk (`changed_tree_entries`) must produce byte-identical name-status
2171    // output to the legacy "flatten both sides fully" walk
2172    // (`collect_full_tree_pair`) on every representative diff shape.
2173    // ===================================================================
2174
2175    /// Format a name-status result into stable, comparable lines.
2176    fn status_lines(entries: &[NameStatusEntry]) -> Vec<String> {
2177        entries.iter().map(|entry| entry.line()).collect()
2178    }
2179
2180    /// Assert the pruned walk and the full flatten agree, both as raw map diffs
2181    /// and through the public tree-diff entry points, for the given options.
2182    fn assert_tree_diff_matches_full(
2183        db: &FileObjectDatabase,
2184        left: &ObjectId,
2185        right: &ObjectId,
2186        options: DiffNameStatusOptions,
2187    ) {
2188        // Reference ("old") behaviour: fully flatten both trees, then diff.
2189        let (full_left, full_right) = collect_full_tree_pair(db, ObjectFormat::Sha1, left, right)
2190            .expect("test operation should succeed");
2191        let reference = diff_name_status_maps(
2192            &full_left,
2193            &full_right,
2194            full_left.keys().chain(full_right.keys()),
2195            options,
2196        )
2197        .expect("test operation should succeed");
2198
2199        // Optimized ("new") behaviour: prune identical subtrees, then diff.
2200        let (pruned_left, pruned_right) = changed_tree_entries(db, ObjectFormat::Sha1, left, right)
2201            .expect("test operation should succeed");
2202        let pruned = diff_name_status_maps(
2203            &pruned_left,
2204            &pruned_right,
2205            pruned_left.keys().chain(pruned_right.keys()),
2206            options,
2207        )
2208        .expect("test operation should succeed");
2209
2210        assert_eq!(
2211            status_lines(&reference),
2212            status_lines(&pruned),
2213            "pruned map diff diverged from full map diff for {options:?}"
2214        );
2215
2216        // And the public entry point (which itself selects pruned vs full) must
2217        // match the reference too.
2218        let public =
2219            diff_name_status_trees_with_options(db, ObjectFormat::Sha1, left, right, options)
2220                .expect("test operation should succeed");
2221        assert_eq!(
2222            status_lines(&reference),
2223            status_lines(&public),
2224            "public tree diff diverged from full map diff for {options:?}"
2225        );
2226
2227        // The pruned maps must be a subset of the full maps and must contain
2228        // exactly the paths that actually changed (no identical entries leak in,
2229        // no changed entries get dropped).
2230        for (path, tracked) in &pruned_left {
2231            assert_eq!(
2232                full_left.get(path),
2233                Some(tracked),
2234                "pruned left entry not present (or differs) in full left map: {:?}",
2235                String::from_utf8_lossy(path)
2236            );
2237        }
2238        for (path, tracked) in &pruned_right {
2239            assert_eq!(
2240                full_right.get(path),
2241                Some(tracked),
2242                "pruned right entry not present (or differs) in full right map: {:?}",
2243                String::from_utf8_lossy(path)
2244            );
2245        }
2246        // Every path the full diff reports as changed must survive pruning on
2247        // whichever side(s) it lives.
2248        for entry in &reference {
2249            let path = entry.path.as_bytes();
2250            match entry.status {
2251                NameStatus::Added => assert!(
2252                    pruned_right.contains_key(path),
2253                    "added path dropped by pruning: {:?}",
2254                    String::from_utf8_lossy(path)
2255                ),
2256                NameStatus::Deleted => assert!(
2257                    pruned_left.contains_key(path),
2258                    "deleted path dropped by pruning: {:?}",
2259                    String::from_utf8_lossy(path)
2260                ),
2261                NameStatus::Modified => {
2262                    assert!(
2263                        pruned_left.contains_key(path) && pruned_right.contains_key(path),
2264                        "modified path dropped by pruning: {:?}",
2265                        String::from_utf8_lossy(path)
2266                    );
2267                }
2268                _ => {}
2269            }
2270        }
2271    }
2272
2273    /// Run the equivalence assertion across the option matrix that the pruned
2274    /// path serves (everything except `--find-copies-harder`, which uses the
2275    /// full maps and is checked separately).
2276    fn assert_tree_diff_matches_full_all_modes(
2277        db: &FileObjectDatabase,
2278        left: &ObjectId,
2279        right: &ObjectId,
2280    ) {
2281        for detect_renames in [false, true] {
2282            for detect_copies in [false, true] {
2283                let options = DiffNameStatusOptions {
2284                    detect_renames,
2285                    detect_copies,
2286                    find_copies_harder: false,
2287                    rename_empty: true,
2288                    ..Default::default()
2289                };
2290                assert_tree_diff_matches_full(db, left, right, options);
2291            }
2292        }
2293    }
2294
2295    /// Build a DB pre-seeded with a fixed bank of blobs for the structural tests.
2296    fn structural_db() -> (PathBuf, FileObjectDatabase) {
2297        let root = temp_root();
2298        let layout = RepositoryLayout::init_at(&root, ObjectFormat::Sha1, false)
2299            .expect("test operation should succeed");
2300        let db = FileObjectDatabase::from_git_dir(&layout.git_dir, ObjectFormat::Sha1);
2301        (root, db)
2302    }
2303
2304    #[test]
2305    fn pruned_walk_skips_identical_subtree_and_matches_full() {
2306        // A large shared subtree (`shared/`) is byte-identical on both sides; the
2307        // only change lives in `app/`. The pruned walk must skip `shared/`
2308        // entirely yet still produce the exact same diff as flattening it.
2309        let (root, mut db) = structural_db();
2310
2311        // shared/ — identical on both sides, several nested files.
2312        let s1 = write_blob(&mut db, b"shared one\n");
2313        let s2 = write_blob(&mut db, b"shared two\n");
2314        let s3 = write_blob(&mut db, b"deep nested\n");
2315        let shared_inner = write_tree(&mut db, &[(b"c.txt", 0o100644, s3.clone())]);
2316        let shared = write_tree(
2317            &mut db,
2318            &[
2319                (b"a.txt", 0o100644, s1.clone()),
2320                (b"b.txt", 0o100644, s2.clone()),
2321                (b"inner", 0o040000, shared_inner.clone()),
2322            ],
2323        );
2324
2325        // app/ — one file modified between sides.
2326        let app_old = write_blob(&mut db, b"version 1\n");
2327        let app_new = write_blob(&mut db, b"version 2\n");
2328        let app_left = write_tree(&mut db, &[(b"main.rs", 0o100644, app_old)]);
2329        let app_right = write_tree(&mut db, &[(b"main.rs", 0o100644, app_new)]);
2330
2331        let left = write_tree(
2332            &mut db,
2333            &[
2334                (b"app", 0o040000, app_left),
2335                (b"shared", 0o040000, shared.clone()),
2336            ],
2337        );
2338        let right = write_tree(
2339            &mut db,
2340            &[(b"app", 0o040000, app_right), (b"shared", 0o040000, shared)],
2341        );
2342
2343        // Sanity: the only change is the nested app/main.rs modification.
2344        let (pruned_left, pruned_right) =
2345            changed_tree_entries(&db, ObjectFormat::Sha1, &left, &right)
2346                .expect("test operation should succeed");
2347        assert_eq!(
2348            pruned_left.keys().collect::<Vec<_>>(),
2349            vec![&b"app/main.rs".to_vec()],
2350            "pruning should leave only the changed path on the left"
2351        );
2352        assert_eq!(
2353            pruned_right.keys().collect::<Vec<_>>(),
2354            vec![&b"app/main.rs".to_vec()],
2355            "pruning should leave only the changed path on the right"
2356        );
2357        assert!(
2358            !pruned_left.contains_key(b"shared/a.txt".as_slice()),
2359            "identical shared subtree must not appear in pruned maps"
2360        );
2361
2362        assert_tree_diff_matches_full_all_modes(&db, &left, &right);
2363        fs::remove_dir_all(root).expect("test operation should succeed");
2364    }
2365
2366    #[test]
2367    fn pruned_walk_matches_full_for_add_delete_modify_nested() {
2368        // Mixed shape: a top-level add, a top-level delete, a nested modify, and
2369        // an untouched nested subtree that must be skipped.
2370        let (root, mut db) = structural_db();
2371
2372        let keep = write_blob(&mut db, b"unchanged\n");
2373        let untouched_dir = write_tree(&mut db, &[(b"keep.txt", 0o100644, keep.clone())]);
2374
2375        let nested_old = write_blob(&mut db, b"nested old\n");
2376        let nested_new = write_blob(&mut db, b"nested new\n");
2377        let dir_left = write_tree(
2378            &mut db,
2379            &[
2380                (b"changed.txt", 0o100644, nested_old),
2381                (b"stable.txt", 0o100644, keep.clone()),
2382            ],
2383        );
2384        let dir_right = write_tree(
2385            &mut db,
2386            &[
2387                (b"changed.txt", 0o100644, nested_new),
2388                (b"stable.txt", 0o100644, keep.clone()),
2389            ],
2390        );
2391
2392        let only_left = write_blob(&mut db, b"will be deleted\n");
2393        let only_right = write_blob(&mut db, b"freshly added\n");
2394
2395        let left = write_tree(
2396            &mut db,
2397            &[
2398                (b"dir", 0o040000, dir_left),
2399                (b"gone.txt", 0o100644, only_left),
2400                (b"untouched", 0o040000, untouched_dir.clone()),
2401            ],
2402        );
2403        let right = write_tree(
2404            &mut db,
2405            &[
2406                (b"dir", 0o040000, dir_right),
2407                (b"new.txt", 0o100644, only_right),
2408                (b"untouched", 0o040000, untouched_dir),
2409            ],
2410        );
2411
2412        let entries = diff_name_status_trees_with_options(
2413            &db,
2414            ObjectFormat::Sha1,
2415            &left,
2416            &right,
2417            DiffNameStatusOptions {
2418                detect_renames: false,
2419                detect_copies: false,
2420                find_copies_harder: false,
2421                rename_empty: true,
2422            
2423                ..Default::default()
2424            },
2425        )
2426        .expect("test operation should succeed");
2427        assert_eq!(
2428            status_lines(&entries),
2429            vec![
2430                "M\tdir/changed.txt".to_string(),
2431                "D\tgone.txt".to_string(),
2432                "A\tnew.txt".to_string(),
2433            ],
2434            "unexpected raw status for mixed nested diff"
2435        );
2436
2437        assert_tree_diff_matches_full_all_modes(&db, &left, &right);
2438        fs::remove_dir_all(root).expect("test operation should succeed");
2439    }
2440
2441    #[test]
2442    fn pruned_walk_matches_full_for_rename_across_dirs() {
2443        // An exact rename (same blob oid) moving between directories. Rename
2444        // detection runs on the pruned add/delete set and must match the full
2445        // walk's result exactly.
2446        let (root, mut db) = structural_db();
2447
2448        let moved = write_blob(&mut db, b"i get moved across directories\n");
2449        let companion = write_blob(&mut db, b"i stay put\n");
2450        let stable_dir = write_tree(&mut db, &[(b"keep.txt", 0o100644, companion.clone())]);
2451
2452        let src_dir = write_tree(&mut db, &[(b"file.txt", 0o100644, moved.clone())]);
2453        let dst_dir = write_tree(&mut db, &[(b"renamed.txt", 0o100644, moved.clone())]);
2454
2455        let left = write_tree(
2456            &mut db,
2457            &[
2458                (b"src", 0o040000, src_dir),
2459                (b"stable", 0o040000, stable_dir.clone()),
2460            ],
2461        );
2462        let right = write_tree(
2463            &mut db,
2464            &[
2465                (b"dst", 0o040000, dst_dir),
2466                (b"stable", 0o040000, stable_dir),
2467            ],
2468        );
2469
2470        let entries = diff_name_status_trees_with_options(
2471            &db,
2472            ObjectFormat::Sha1,
2473            &left,
2474            &right,
2475            DiffNameStatusOptions {
2476                detect_renames: true,
2477                detect_copies: false,
2478                find_copies_harder: false,
2479                rename_empty: true,
2480            
2481                ..Default::default()
2482            },
2483        )
2484        .expect("test operation should succeed");
2485        assert_eq!(
2486            status_lines(&entries),
2487            vec!["R100\tsrc/file.txt\tdst/renamed.txt".to_string()],
2488            "rename across dirs should be detected on pruned set"
2489        );
2490
2491        assert_tree_diff_matches_full_all_modes(&db, &left, &right);
2492        fs::remove_dir_all(root).expect("test operation should succeed");
2493    }
2494
2495    #[test]
2496    fn pruned_walk_matches_full_for_binary_and_mode_change() {
2497        // Binary blob modification plus an executable-bit (mode) change on an
2498        // otherwise-identical blob. Mode-only changes must still register as a
2499        // Modify (the pruned walk compares mode + oid, like the full map).
2500        let (root, mut db) = structural_db();
2501
2502        let bin_old = write_blob(&mut db, &[0u8, 159, 146, 150, 0, 255, 1, 2, 3]);
2503        let bin_new = write_blob(&mut db, &[0u8, 159, 146, 150, 0, 254, 9, 8, 7]);
2504        let script = write_blob(&mut db, b"#!/bin/sh\necho hi\n");
2505
2506        let left = write_tree(
2507            &mut db,
2508            &[
2509                (b"image.bin", 0o100644, bin_old),
2510                (b"run.sh", 0o100644, script.clone()),
2511            ],
2512        );
2513        let right = write_tree(
2514            &mut db,
2515            &[
2516                (b"image.bin", 0o100644, bin_new),
2517                // same blob oid, executable bit flipped on
2518                (b"run.sh", 0o100755, script),
2519            ],
2520        );
2521
2522        let entries = diff_name_status_trees_with_options(
2523            &db,
2524            ObjectFormat::Sha1,
2525            &left,
2526            &right,
2527            DiffNameStatusOptions {
2528                detect_renames: false,
2529                detect_copies: false,
2530                find_copies_harder: false,
2531                rename_empty: true,
2532            
2533                ..Default::default()
2534            },
2535        )
2536        .expect("test operation should succeed");
2537        assert_eq!(
2538            status_lines(&entries),
2539            vec!["M\timage.bin".to_string(), "M\trun.sh".to_string()],
2540            "binary edit and mode-only change should both be Modify"
2541        );
2542
2543        assert_tree_diff_matches_full_all_modes(&db, &left, &right);
2544        fs::remove_dir_all(root).expect("test operation should succeed");
2545    }
2546
2547    #[test]
2548    fn pruned_walk_matches_full_for_dir_replaced_by_file() {
2549        // A name that is a directory on the left and a regular file on the right
2550        // (and vice versa). The flattened paths differ (`thing/...` vs `thing`),
2551        // so the pruned walk must treat them as unrelated add/delete pairs,
2552        // exactly as the full flatten does.
2553        let (root, mut db) = structural_db();
2554
2555        let inner_a = write_blob(&mut db, b"inner a\n");
2556        let inner_b = write_blob(&mut db, b"inner b\n");
2557        let thing_dir = write_tree(
2558            &mut db,
2559            &[(b"a.txt", 0o100644, inner_a), (b"b.txt", 0o100644, inner_b)],
2560        );
2561        let thing_file = write_blob(&mut db, b"now i am a file\n");
2562
2563        // other/ is a file on the left, a directory on the right.
2564        let other_file = write_blob(&mut db, b"i was a file\n");
2565        let other_inner = write_blob(&mut db, b"now nested\n");
2566        let other_dir = write_tree(&mut db, &[(b"x.txt", 0o100644, other_inner)]);
2567
2568        let left = write_tree(
2569            &mut db,
2570            &[
2571                (b"other", 0o100644, other_file),
2572                (b"thing", 0o040000, thing_dir),
2573            ],
2574        );
2575        let right = write_tree(
2576            &mut db,
2577            &[
2578                (b"other", 0o040000, other_dir),
2579                (b"thing", 0o100644, thing_file),
2580            ],
2581        );
2582
2583        let entries = diff_name_status_trees_with_options(
2584            &db,
2585            ObjectFormat::Sha1,
2586            &left,
2587            &right,
2588            DiffNameStatusOptions {
2589                detect_renames: false,
2590                detect_copies: false,
2591                find_copies_harder: false,
2592                rename_empty: true,
2593            
2594                ..Default::default()
2595            },
2596        )
2597        .expect("test operation should succeed");
2598        assert_eq!(
2599            status_lines(&entries),
2600            vec![
2601                "D\tother".to_string(),
2602                "A\tother/x.txt".to_string(),
2603                "A\tthing".to_string(),
2604                "D\tthing/a.txt".to_string(),
2605                "D\tthing/b.txt".to_string(),
2606            ],
2607            "dir<->file swap should flatten to independent adds/deletes"
2608        );
2609
2610        assert_tree_diff_matches_full_all_modes(&db, &left, &right);
2611        fs::remove_dir_all(root).expect("test operation should succeed");
2612    }
2613
2614    #[test]
2615    fn pruned_walk_matches_full_for_identical_trees() {
2616        // Two identical root trees: zero changes, and the root must be skipped
2617        // without reading anything below it.
2618        let (root, mut db) = structural_db();
2619
2620        let blob = write_blob(&mut db, b"same\n");
2621        let sub = write_tree(&mut db, &[(b"f.txt", 0o100644, blob.clone())]);
2622        let tree = write_tree(
2623            &mut db,
2624            &[(b"sub", 0o040000, sub), (b"top.txt", 0o100644, blob)],
2625        );
2626
2627        let (pruned_left, pruned_right) =
2628            changed_tree_entries(&db, ObjectFormat::Sha1, &tree, &tree)
2629                .expect("test operation should succeed");
2630        assert!(
2631            pruned_left.is_empty() && pruned_right.is_empty(),
2632            "identical trees must produce no changed entries"
2633        );
2634
2635        let entries = diff_name_status_trees_with_options(
2636            &db,
2637            ObjectFormat::Sha1,
2638            &tree,
2639            &tree,
2640            DiffNameStatusOptions::default(),
2641        )
2642        .expect("test operation should succeed");
2643        assert!(entries.is_empty(), "identical trees must produce no diff");
2644
2645        assert_tree_diff_matches_full_all_modes(&db, &tree, &tree);
2646        fs::remove_dir_all(root).expect("test operation should succeed");
2647    }
2648
2649    #[test]
2650    fn find_copies_harder_uses_full_left_map_and_finds_unchanged_source() {
2651        // `--find-copies-harder` must still see an *unchanged* file as a copy
2652        // source. This is the case where the public entry point deliberately
2653        // falls back to the full flatten; verify the full-map fallback both
2654        // behaves correctly and matches a direct full-map computation.
2655        let (root, mut db) = structural_db();
2656
2657        // `template.txt` is unchanged between sides (lives in an untouched
2658        // subtree), and `copy.txt` is added on the right with the same content.
2659        let template = write_blob(&mut db, b"reusable boilerplate content\n");
2660        let lib_dir = write_tree(&mut db, &[(b"template.txt", 0o100644, template.clone())]);
2661
2662        let trigger_old = write_blob(&mut db, b"trigger old\n");
2663        let trigger_new = write_blob(&mut db, b"trigger new\n");
2664
2665        let left = write_tree(
2666            &mut db,
2667            &[
2668                (b"lib", 0o040000, lib_dir.clone()),
2669                (b"trigger.txt", 0o100644, trigger_old),
2670            ],
2671        );
2672        let right = write_tree(
2673            &mut db,
2674            &[
2675                (b"copy.txt", 0o100644, template.clone()),
2676                (b"lib", 0o040000, lib_dir),
2677                (b"trigger.txt", 0o100644, trigger_new),
2678            ],
2679        );
2680
2681        let options = DiffNameStatusOptions {
2682            detect_renames: true,
2683            detect_copies: true,
2684            find_copies_harder: true,
2685            rename_empty: true,
2686            ..Default::default()
2687        };
2688
2689        // Reference via the full flatten (the old algorithm).
2690        let (full_left, full_right) =
2691            collect_full_tree_pair(&db, ObjectFormat::Sha1, &left, &right)
2692                .expect("test operation should succeed");
2693        let reference = diff_name_status_maps(
2694            &full_left,
2695            &full_right,
2696            full_left.keys().chain(full_right.keys()),
2697            options,
2698        )
2699        .expect("test operation should succeed");
2700
2701        let public =
2702            diff_name_status_trees_with_options(&db, ObjectFormat::Sha1, &left, &right, options)
2703                .expect("test operation should succeed");
2704        assert_eq!(
2705            status_lines(&reference),
2706            status_lines(&public),
2707            "find-copies-harder public diff must match full-map reference"
2708        );
2709        // The copy must be detected from the unchanged template source.
2710        assert!(
2711            public
2712                .iter()
2713                .any(|entry| matches!(entry.status, NameStatus::Copied(_))
2714                    && entry.old_path.as_ref().map(|p| p.as_bytes())
2715                        == Some(b"lib/template.txt".as_slice())
2716                    && entry.path == b"copy.txt"),
2717            "copy from unchanged source must be found with find_copies_harder: {public:?}"
2718        );
2719        fs::remove_dir_all(root).expect("test operation should succeed");
2720    }
2721
2722    #[test]
2723    fn pruned_walk_matches_full_with_inexact_rename_options() {
2724        // Exercise the rename-options entry point (which also selects pruned vs
2725        // full) with inexact detection enabled, across an untouched subtree.
2726        let (root, mut db) = structural_db();
2727
2728        let untouched = write_blob(&mut db, b"untouched file\n");
2729        let untouched_dir = write_tree(&mut db, &[(b"u.txt", 0o100644, untouched.clone())]);
2730
2731        // a.txt -> b.txt with one changed line (a 75% inexact rename).
2732        let old = write_blob(&mut db, b"one\ntwo\nthree\nfour\nfive\n");
2733        let new = write_blob(&mut db, b"one\ntwo\nTHREE\nfour\nfive\n");
2734
2735        let left = write_tree(
2736            &mut db,
2737            &[
2738                (b"a.txt", 0o100644, old),
2739                (b"keep", 0o040000, untouched_dir.clone()),
2740            ],
2741        );
2742        let right = write_tree(
2743            &mut db,
2744            &[
2745                (b"b.txt", 0o100644, new),
2746                (b"keep", 0o040000, untouched_dir),
2747            ],
2748        );
2749
2750        let options = DiffNameStatusOptions {
2751                detect_renames: true,
2752                detect_copies: false,
2753                find_copies_harder: false,
2754                rename_empty: true,
2755            detect_inexact: true,
2756            rename_threshold: DEFAULT_RENAME_THRESHOLD,
2757            copy_threshold: DEFAULT_RENAME_THRESHOLD,
2758            rename_limit: 0,
2759        
2760                ..Default::default()
2761            };
2762
2763        // Reference: full flatten + same detection.
2764        let (full_left, full_right) =
2765            collect_full_tree_pair(&db, ObjectFormat::Sha1, &left, &right)
2766                .expect("test operation should succeed");
2767        let reference = diff_name_status_maps_with_renames(
2768            &full_left,
2769            &full_right,
2770            full_left.keys().chain(full_right.keys()),
2771            options,
2772            |oid| read_blob_bytes(&db, oid),
2773        )
2774        .expect("test operation should succeed");
2775
2776        let public = diff_name_status_trees_with_options_and_diagnostics(
2777            &db,
2778            ObjectFormat::Sha1,
2779            &left,
2780            &right,
2781            options,
2782        )
2783        .expect("test operation should succeed");
2784
2785        assert_eq!(
2786            status_lines(&reference),
2787            status_lines(&public.entries),
2788            "inexact rename via pruned walk must match full-map reference"
2789        );
2790        assert_eq!(
2791            status_lines(&public.entries),
2792            vec!["R075\ta.txt\tb.txt".to_string()],
2793            "expected a 75% inexact rename"
2794        );
2795        fs::remove_dir_all(root).expect("test operation should succeed");
2796    }
2797
2798    fn write_delta_varint(out: &mut Vec<u8>, mut value: u64) {
2799        loop {
2800            let mut byte = (value as u8) & 0x7f;
2801            value >>= 7;
2802            if value != 0 {
2803                byte |= 0x80;
2804            }
2805            out.push(byte);
2806            if value == 0 {
2807                break;
2808            }
2809        }
2810    }
2811
2812    /// Delta whose result-size varint lies while carrying a tiny real instruction
2813    /// stream (sley#35 regression shape).
2814    fn lying_result_size_delta(declared_result_size: usize) -> (Vec<u8>, Vec<u8>) {
2815        let base = b"hello";
2816        let result = b"hello world";
2817        let mut delta = Vec::new();
2818        write_delta_varint(&mut delta, base.len() as u64);
2819        write_delta_varint(&mut delta, declared_result_size as u64);
2820        let suffix = &result[base.len()..];
2821        delta.push(0x90);
2822        delta.push(base.len() as u8);
2823        delta.push(suffix.len() as u8);
2824        delta.extend_from_slice(suffix);
2825        (base.to_vec(), delta)
2826    }
2827
2828    #[test]
2829    fn bounded_inflate_reserve_caps_attacker_declared_size() {
2830        use sley_pack::inflate::{bounded_inflate_reserve, MAX_INFLATE_RESERVE};
2831        assert_eq!(bounded_inflate_reserve(u64::MAX as usize, 10), 10 * 1032);
2832        assert_eq!(
2833            bounded_inflate_reserve(usize::MAX, usize::MAX),
2834            MAX_INFLATE_RESERVE
2835        );
2836        assert_eq!(bounded_inflate_reserve(1000, 500), 1000);
2837        assert_eq!(bounded_inflate_reserve(0, 0), 64);
2838    }
2839
2840    #[test]
2841    fn parse_leading_usize_errors_on_overflow() {
2842        assert_eq!(parse_leading_usize(b""), Ok(0));
2843        assert_eq!(parse_leading_usize(b"42 junk"), Ok(42));
2844        assert!(parse_leading_usize(b"999999999999999999999999999999999999999999").is_err());
2845    }
2846
2847    /// Regression (sley#35): `git_patch_delta` must not OOM on a lying result-size
2848    /// varint; the post-decode length check rejects the bomb cleanly.
2849    #[test]
2850    fn git_patch_delta_rejects_result_size_bomb_without_oom() {
2851        let bombs = [usize::MAX, 1024 * 1024 * 1024 * 1024];
2852        for declared in bombs {
2853            let (base, delta) = lying_result_size_delta(declared);
2854            let handle = std::thread::spawn(move || git_patch_delta(&base, &delta));
2855            let join_result = handle.join();
2856            assert!(
2857                join_result.is_ok(),
2858                "delta bomb (declared={declared}) panicked/aborted instead of erroring cleanly"
2859            );
2860            assert!(
2861                join_result.expect("parse thread should not panic on a delta bomb").is_none(),
2862                "delta bomb (declared={declared}) should be rejected as invalid"
2863            );
2864        }
2865    }
2866
2867    #[test]
2868    fn git_patch_delta_applies_legitimate_delta_after_result_size_bound() {
2869        let (base, delta) = lying_result_size_delta(b"hello world".len());
2870        let patched = git_patch_delta(&base, &delta).expect("legitimate delta should resolve");
2871        assert_eq!(patched, b"hello world");
2872    }
2873}