Skip to main content

sley_diff_merge/
lib.rs

1use sley_core::{GitError, ObjectFormat, ObjectId, RepoPath, Result, object_id_for_bytes};
2
3mod name;
4pub mod range;
5pub mod render;
6pub mod ws;
7
8pub use sley_core::BString;
9use sley_index::{BorrowedIndex, Index, IndexStatCache};
10use sley_object::{Commit, EncodedObject, ObjectType, Tree, TreeEntries, TreeEntry};
11use sley_odb::{FileObjectDatabase, ObjectReader, ObjectWriter};
12use sley_refs::{FileRefStore, RefTarget};
13use std::collections::{BTreeMap, BTreeSet, HashMap};
14use std::fs;
15use std::path::{Path, PathBuf};
16
17// ===========================================================================
18// Gitlink (submodule) resolution helpers.
19//
20// A gitlink is a mode-160000 tree/index entry whose oid names the commit an
21// embedded repository has checked out. These helpers resolve, for a directory
22// in the working tree, (a) the embedded repository's git directory — either a
23// `.git` directory or a `.git` *file* carrying a `gitdir: <path>` pointer (the
24// layout `git submodule add`/`update` creates, pointing into the
25// superproject's `.git/modules/<name>`) — and (b) the commit its HEAD names.
26// They are the native equivalent of upstream's `resolve_gitlink_ref()`.
27// ===========================================================================
28
29/// Resolve the git directory of an embedded repository whose working tree is
30/// at `sub_root`. A `.git` directory is returned as-is; a `.git` file is
31/// followed through its `gitdir: <path>` pointer (a relative pointer resolves
32/// against `sub_root`). Returns `None` when there is no `.git` entry or the
33/// pointer does not name an existing directory.
34pub fn gitlink_git_dir(sub_root: &Path) -> Option<PathBuf> {
35    let dot_git = sub_root.join(".git");
36    let metadata = fs::symlink_metadata(&dot_git).ok()?;
37    if metadata.is_dir() {
38        return Some(dot_git);
39    }
40    if !metadata.is_file() {
41        return None;
42    }
43    let contents = fs::read_to_string(&dot_git).ok()?;
44    let target = contents.strip_prefix("gitdir:")?.trim();
45    if target.is_empty() {
46        return None;
47    }
48    let target = PathBuf::from(target);
49    let git_dir = if target.is_absolute() {
50        target
51    } else {
52        sub_root.join(target)
53    };
54    if git_dir.is_dir() {
55        Some(git_dir)
56    } else {
57        None
58    }
59}
60
61/// When `sub_root` holds a *broken* gitlink — a `.git` file whose `gitdir:`
62/// pointer names a directory that no longer exists (e.g. the submodule's git
63/// directory was moved out of `.git/modules/`) — return that unresolved gitdir
64/// path. git's status / diff-index fail fatally ("not a git repository: …")
65/// here. Returns `None` for a valid gitlink (a `.git` directory, or a `.git`
66/// file with a live gitdir) and for an *unpopulated* gitlink (no `.git` entry at
67/// all), both of which git treats as non-fatal (the latter as unchanged).
68pub fn gitlink_broken_gitdir(sub_root: &Path) -> Option<PathBuf> {
69    let dot_git = sub_root.join(".git");
70    let metadata = fs::symlink_metadata(&dot_git).ok()?;
71    if !metadata.is_file() {
72        // No `.git` (unpopulated) or a real `.git` directory — not broken.
73        return None;
74    }
75    let contents = fs::read_to_string(&dot_git).ok()?;
76    let target = contents.strip_prefix("gitdir:")?.trim();
77    if target.is_empty() {
78        return None;
79    }
80    let target_path = if Path::new(target).is_absolute() {
81        PathBuf::from(target)
82    } else {
83        sub_root.join(target)
84    };
85    if target_path.is_dir() {
86        None
87    } else {
88        Some(target_path)
89    }
90}
91
92/// Resolve the commit checked out in the embedded repository at `sub_root`
93/// (the value a gitlink entry for that path records): its git directory's
94/// HEAD, followed through symbolic refs. `None` when `sub_root` is not a
95/// repository or its HEAD does not resolve to a commit (e.g. an unborn
96/// branch) — upstream's `resolve_gitlink_ref() < 0` case.
97pub fn gitlink_head_oid(sub_root: &Path, format: ObjectFormat) -> Option<ObjectId> {
98    let git_dir = gitlink_git_dir(sub_root)?;
99    let store = FileRefStore::new(&git_dir, format);
100    let mut target = store.read_ref("HEAD").ok()??;
101    // Follow symbolic-ref chains defensively (git caps the depth too).
102    for _ in 0..10 {
103        match target {
104            RefTarget::Direct(oid) => return Some(oid),
105            RefTarget::Symbolic(name) => target = store.read_ref(&name).ok()??,
106        }
107    }
108    None
109}
110
111// ===========================================================================
112// Line-level diff (Myers O(ND)) and 3-way blob merge (diff3).
113//
114// These operate purely on in-memory blobs and never touch the ODB or the
115// filesystem. They are the engine the CLI layers `git merge`, `cherry-pick`,
116// and `revert` on top of.
117// ===========================================================================
118
119/// A single line of a blob, slicing into the original buffer.
120///
121/// `content` includes the line's own trailing newline byte when present;
122/// `has_newline` records whether this line ended with `\n` in the source. Only
123/// the final line of a blob can have `has_newline == false` (a file with "no
124/// newline at end of file"). Comparing two `DiffLine`s for equality compares
125/// both the bytes and the trailing-newline flag, so a line that gained or lost
126/// its terminating newline is treated as a real change, matching git.
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub struct DiffLine<'a> {
129    /// The raw bytes of the line, including the trailing `\n` if it had one.
130    pub content: &'a [u8],
131    /// Whether the line was terminated by a newline in the source blob.
132    pub has_newline: bool,
133}
134
135impl<'a> DiffLine<'a> {
136    /// The line bytes without any trailing newline.
137    pub fn bytes_without_newline(&self) -> &'a [u8] {
138        if self.has_newline {
139            self.content.strip_suffix(b"\n").unwrap_or(self.content)
140        } else {
141            self.content
142        }
143    }
144}
145
146/// Split a blob into lines, preserving the exact bytes of each line.
147///
148/// Each returned [`DiffLine`] borrows from `blob`; its `content` includes the
149/// terminating `\n`. The returned vector is empty for an empty blob. A blob
150/// whose final byte is not `\n` yields a final line with `has_newline ==
151/// false` — git's "\ No newline at end of file" case.
152pub fn split_lines(blob: &[u8]) -> Vec<DiffLine<'_>> {
153    let mut lines = Vec::new();
154    let mut start = 0usize;
155    let len = blob.len();
156    let mut idx = 0usize;
157    while idx < len {
158        if blob[idx] == b'\n' {
159            lines.push(DiffLine {
160                content: &blob[start..=idx],
161                has_newline: true,
162            });
163            idx += 1;
164            start = idx;
165        } else {
166            idx += 1;
167        }
168    }
169    if start < len {
170        lines.push(DiffLine {
171            content: &blob[start..len],
172            has_newline: false,
173        });
174    }
175    lines
176}
177
178/// A run-length entry in a Myers edit script.
179///
180/// Each variant carries the number of consecutive lines it applies to:
181/// - [`DiffOp::Equal`] — `n` lines common to both `old` and `new`.
182/// - [`DiffOp::Delete`] — `n` lines present in `old` but not `new`.
183/// - [`DiffOp::Insert`] — `n` lines present in `new` but not `old`.
184///
185/// Walking the script in order and consuming `old`/`new` lines accordingly
186/// reconstructs `new` from `old`.
187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
188pub enum DiffOp {
189    /// `n` lines are identical in both sequences.
190    Equal(usize),
191    /// `n` lines are removed from the old sequence.
192    Delete(usize),
193    /// `n` lines are added in the new sequence.
194    Insert(usize),
195}
196
197/// Compute a minimal line-level edit script transforming `old` into `new`
198/// using Myers' O(ND) difference algorithm.
199///
200/// Lines are compared for equality by their full bytes (see [`DiffLine`]). The
201/// result is a coalesced sequence of [`DiffOp`] runs; consecutive ops of the
202/// same kind are merged so the script is compact. The script is a standard
203/// (shortest-edit-script) diff: the number of `Delete` + `Insert` lines is
204/// minimal.
205pub fn myers_diff_lines(old: &[DiffLine<'_>], new: &[DiffLine<'_>]) -> Vec<DiffOp> {
206    // Trim a common prefix and suffix first. This keeps the O(ND) search small
207    // for the typical case of a localized edit and does not affect minimality.
208    let n_total = old.len();
209    let m_total = new.len();
210    let mut prefix = 0usize;
211    while prefix < n_total && prefix < m_total && old[prefix] == new[prefix] {
212        prefix += 1;
213    }
214    let mut suffix = 0usize;
215    while suffix < n_total - prefix
216        && suffix < m_total - prefix
217        && old[n_total - 1 - suffix] == new[m_total - 1 - suffix]
218    {
219        suffix += 1;
220    }
221
222    let old_mid = &old[prefix..n_total - suffix];
223    let new_mid = &new[prefix..m_total - suffix];
224
225    let mut ops: Vec<DiffOp> = Vec::new();
226    if prefix > 0 {
227        ops.push(DiffOp::Equal(prefix));
228    }
229    myers_core(old_mid, new_mid, &mut ops);
230    if suffix > 0 {
231        ops.push(DiffOp::Equal(suffix));
232    }
233    coalesce_ops(ops)
234}
235
236/// Classic forward Myers O(ND) shortest-edit-script search over the trimmed
237/// sub-problem, followed by a backtrack through the stored traces.
238///
239/// `old`/`new` are the trimmed (no common prefix/suffix) line slices. Per-line
240/// ops are appended to `out` in order; they are coalesced by the caller. This
241/// is the algorithm from Myers' 1986 paper, which yields a shortest edit script
242/// (minimal number of insertions + deletions).
243fn myers_core(old: &[DiffLine<'_>], new: &[DiffLine<'_>], out: &mut Vec<DiffOp>) {
244    let n = old.len() as isize;
245    let m = new.len() as isize;
246    if n == 0 {
247        if m > 0 {
248            out.push(DiffOp::Insert(m as usize));
249        }
250        return;
251    }
252    if m == 0 {
253        out.push(DiffOp::Delete(n as usize));
254        return;
255    }
256
257    let max = (n + m) as usize;
258    let offset = max as isize; // shift so diagonal k maps to index (k + offset)
259    let width = 2 * max + 1;
260    // v[k + offset] holds the furthest-reaching x on diagonal k for the current d.
261    let mut v = vec![0isize; width];
262    // Save a snapshot of v after each d so we can backtrack the chosen path.
263    let mut trace: Vec<Vec<isize>> = Vec::new();
264
265    let mut found_d: Option<usize> = None;
266    'search: for d in 0..=(max as isize) {
267        trace.push(v.clone());
268        let mut k = -d;
269        while k <= d {
270            let kidx = (k + offset) as usize;
271            // Decide whether we arrived here by moving down (insert, from k+1)
272            // or right (delete, from k-1). Prefer the move that reaches further.
273            let mut x = if k == -d
274                || (k != d && v[(k - 1 + offset) as usize] < v[(k + 1 + offset) as usize])
275            {
276                // Move down: x stays, y increases (insertion from new).
277                v[(k + 1 + offset) as usize]
278            } else {
279                // Move right: x increases (deletion from old).
280                v[(k - 1 + offset) as usize] + 1
281            };
282            let mut y = x - k;
283            // Follow the diagonal (matching lines) as far as possible.
284            while x < n && y < m && old[x as usize] == new[y as usize] {
285                x += 1;
286                y += 1;
287            }
288            v[kidx] = x;
289            if x >= n && y >= m {
290                found_d = Some(d as usize);
291                break 'search;
292            }
293            k += 2;
294        }
295    }
296
297    // A shortest edit path always exists, so found_d is set; if somehow not,
298    // fall back to a delete-all/insert-all script (still correct, not minimal).
299    let Some(d_end) = found_d else {
300        out.push(DiffOp::Delete(n as usize));
301        out.push(DiffOp::Insert(m as usize));
302        return;
303    };
304
305    backtrack(n, m, &trace, d_end, offset, out);
306}
307
308/// Reconstruct the edit script from the saved Myers traces.
309///
310/// Walks backward from `(n, m)` to `(0, 0)`, emitting per-line `Delete`,
311/// `Insert`, and `Equal` ops, then reverses them into forward order before
312/// appending to `out`. `n`/`m` are the lengths of the (trimmed) old/new slices.
313fn backtrack(
314    n: isize,
315    m: isize,
316    trace: &[Vec<isize>],
317    d_end: usize,
318    offset: isize,
319    out: &mut Vec<DiffOp>,
320) {
321    let mut x = n;
322    let mut y = m;
323    let mut rev: Vec<DiffOp> = Vec::new();
324
325    for d in (0..=d_end).rev() {
326        let v = &trace[d];
327        let k = x - y;
328        // Determine the predecessor diagonal, mirroring the forward step rule.
329        let prev_k = if k == -(d as isize)
330            || (k != d as isize && v[(k - 1 + offset) as usize] < v[(k + 1 + offset) as usize])
331        {
332            k + 1 // came from a down move (insert)
333        } else {
334            k - 1 // came from a right move (delete)
335        };
336        let prev_x = v[(prev_k + offset) as usize];
337        let prev_y = prev_x - prev_k;
338
339        // Emit the diagonal (equal) moves taken after reaching the predecessor.
340        while x > prev_x && y > prev_y {
341            rev.push(DiffOp::Equal(1));
342            x -= 1;
343            y -= 1;
344        }
345        if d > 0 {
346            if x == prev_x {
347                // Down move: an insertion of new[prev_y].
348                rev.push(DiffOp::Insert(1));
349            } else {
350                // Right move: a deletion of old[prev_x].
351                rev.push(DiffOp::Delete(1));
352            }
353            x = prev_x;
354            y = prev_y;
355        }
356    }
357
358    rev.reverse();
359    out.extend(rev);
360}
361
362/// Merge adjacent ops of the same kind so the script is compact.
363fn coalesce_ops(ops: Vec<DiffOp>) -> Vec<DiffOp> {
364    let mut out: Vec<DiffOp> = Vec::with_capacity(ops.len());
365    for op in ops {
366        match (out.last_mut(), op) {
367            (Some(DiffOp::Equal(prev)), DiffOp::Equal(n)) => *prev += n,
368            (Some(DiffOp::Delete(prev)), DiffOp::Delete(n)) => *prev += n,
369            (Some(DiffOp::Insert(prev)), DiffOp::Insert(n)) => *prev += n,
370            _ => out.push(op),
371        }
372    }
373    out
374}
375
376// ===========================================================================
377// Whitespace-ignoring line comparison (git xdiff's XDF_WHITESPACE_FLAGS).
378//
379// git's xdiff compares two records (lines, including the trailing `\n`) for
380// equality under whitespace-ignore flags via `xdl_recmatch`. Rather than
381// re-implement the Myers core to take a custom equality predicate, we map each
382// flavour to a *canonicalization* of the line bytes that produces identical
383// output iff `xdl_recmatch` would return 1, then diff on the canonicalized
384// lines while emitting the original bytes. This is exact: it is a behavioural
385// port of `xdiff/xutils.c:xdl_recmatch` and `xdl_blankline`.
386// ===========================================================================
387
388/// Whitespace-ignore flags for line comparison, mirroring git's
389/// `XDF_WHITESPACE_FLAGS` (`-w`, `-b`, `--ignore-space-at-eol`,
390/// `--ignore-cr-at-eol`). Only one of the whitespace flavours is honoured per
391/// git's precedence (`-w` ⊃ `-b` ⊃ `--ignore-space-at-eol` ⊃
392/// `--ignore-cr-at-eol`); when several are set, the strongest wins, matching
393/// the cascade in `xdl_recmatch`.
394#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
395pub struct WsIgnore {
396    /// `-w` / `--ignore-all-space`: ignore all whitespace when comparing lines.
397    pub all_space: bool,
398    /// `-b` / `--ignore-space-change`: ignore changes in amount of whitespace.
399    pub space_change: bool,
400    /// `--ignore-space-at-eol`: ignore whitespace at end of line.
401    pub space_at_eol: bool,
402    /// `--ignore-cr-at-eol`: ignore a carriage-return at end of line.
403    pub cr_at_eol: bool,
404}
405
406impl WsIgnore {
407    /// No whitespace-ignore flavour active (the exact, byte-for-byte comparison).
408    pub const EMPTY: Self = Self {
409        all_space: false,
410        space_change: false,
411        space_at_eol: false,
412        cr_at_eol: false,
413    };
414
415    /// True when no whitespace-ignore flavour is active.
416    pub fn is_empty(&self) -> bool {
417        !(self.all_space || self.space_change || self.space_at_eol || self.cr_at_eol)
418    }
419}
420
421/// `XDL_ISSPACE` — git uses C `isspace` over the unsigned byte (space, `\t`,
422/// `\n`, `\r`, `\x0b` vertical tab, `\x0c` form feed).
423#[inline]
424fn xdl_isspace(c: u8) -> bool {
425    matches!(c, b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c)
426}
427
428/// Canonicalize a line's bytes (including any trailing `\n`) for whitespace-
429/// insensitive comparison, exactly mirroring `xdl_recmatch`'s acceptance set:
430/// two original lines are equal under `ignore` iff their canonical forms are
431/// byte-identical.
432///
433/// * `all_space` (`-w`): drop every whitespace byte.
434/// * `space_change` (`-b`): collapse each run of whitespace to a single `' '`
435///   and strip trailing whitespace (a run on one side matches a run on the
436///   other regardless of length; leading/internal whitespace must still align,
437///   trailing whitespace is dropped entirely).
438/// * `space_at_eol`: strip trailing whitespace only.
439/// * `cr_at_eol`: drop a single `\r` immediately before a terminating `\n`.
440///
441/// Exposed crate-internally so the change-compaction pass in [`crate::render`]
442/// can compare lines for sliding under the exact same equality the line-level
443/// diff uses (git's `recs_match` on the whitespace-canonicalized record).
444pub(crate) fn canonicalize_line_for_match(line: &[u8], ignore: WsIgnore) -> Vec<u8> {
445    canonicalize_line(line, ignore)
446}
447
448fn canonicalize_line(line: &[u8], ignore: WsIgnore) -> Vec<u8> {
449    if ignore.all_space {
450        return line.iter().copied().filter(|&c| !xdl_isspace(c)).collect();
451    }
452    if ignore.space_change {
453        let mut out = Vec::with_capacity(line.len());
454        let mut i = 0usize;
455        while i < line.len() {
456            if xdl_isspace(line[i]) {
457                // Collapse the whole whitespace run to a single space.
458                while i < line.len() && xdl_isspace(line[i]) {
459                    i += 1;
460                }
461                out.push(b' ');
462            } else {
463                out.push(line[i]);
464                i += 1;
465            }
466        }
467        // Strip a trailing collapsed-space (trailing whitespace is ignored).
468        if out.last() == Some(&b' ') {
469            out.pop();
470        }
471        return out;
472    }
473    if ignore.space_at_eol {
474        let mut end = line.len();
475        while end > 0 && xdl_isspace(line[end - 1]) {
476            end -= 1;
477        }
478        return line[..end].to_vec();
479    }
480    if ignore.cr_at_eol {
481        // Drop a `\r` directly before a terminating `\n`.
482        if let Some(stripped) = line.strip_suffix(b"\n") {
483            if let Some(without_cr) = stripped.strip_suffix(b"\r") {
484                let mut out = without_cr.to_vec();
485                out.push(b'\n');
486                return out;
487            }
488        } else if let Some(without_cr) = line.strip_suffix(b"\r") {
489            // Incomplete final line: a bare trailing `\r` is also ignored.
490            return without_cr.to_vec();
491        }
492        return line.to_vec();
493    }
494    line.to_vec()
495}
496
497/// `xdl_blankline`: a line is "blank" when, after applying the active
498/// whitespace flags, it has no content. With no whitespace flags, git treats a
499/// record of size ≤ 1 (empty, or a lone `\n`) as blank; with flags, a line all
500/// of whose bytes are whitespace is blank.
501fn line_is_blank(line: &[u8], ignore: WsIgnore) -> bool {
502    if ignore.is_empty() {
503        line.len() <= 1
504    } else {
505        line.iter().all(|&c| xdl_isspace(c))
506    }
507}
508
509/// Compute a line-level edit script transforming `old` into `new`, comparing
510/// lines under the whitespace-ignore flags `ignore` while the returned ops
511/// still index the *original* lines position-for-position.
512///
513/// When `ignore.is_empty()`, this is identical to [`myers_diff_lines`]. With
514/// flags, lines are canonicalized (see [`canonicalize_line`]) for the equality
515/// test only; the ops consume the same number of old/new lines as the originals
516/// so the caller can render the original bytes.
517pub fn myers_diff_lines_ws(
518    old: &[DiffLine<'_>],
519    new: &[DiffLine<'_>],
520    ignore: WsIgnore,
521    algorithm: DiffAlgorithm,
522) -> Vec<DiffOp> {
523    if ignore.is_empty() {
524        return diff_lines_with_algorithm(old, new, algorithm);
525    }
526    let old_canon: Vec<Vec<u8>> = old
527        .iter()
528        .map(|l| canonicalize_line(l.content, ignore))
529        .collect();
530    let new_canon: Vec<Vec<u8>> = new
531        .iter()
532        .map(|l| canonicalize_line(l.content, ignore))
533        .collect();
534    let old_lines: Vec<DiffLine<'_>> = old_canon
535        .iter()
536        .map(|c| DiffLine {
537            content: c.as_slice(),
538            has_newline: true,
539        })
540        .collect();
541    let new_lines: Vec<DiffLine<'_>> = new_canon
542        .iter()
543        .map(|c| DiffLine {
544            content: c.as_slice(),
545            has_newline: true,
546        })
547        .collect();
548    diff_lines_with_algorithm(&old_lines, &new_lines, algorithm)
549}
550
551// ===========================================================================
552// Alternative diff algorithms: patience and histogram.
553//
554// Both share the recursive "anchor and recurse" shape used by git's xdiff
555// implementations of `--patience` and `--histogram`:
556//
557//   1. trim the common prefix and suffix of the current line range,
558//   2. pick one or more common lines that are confidently aligned (the
559//      "anchors") according to the algorithm's rule,
560//   3. recurse on the gaps to the left of, between, and to the right of the
561//      anchors,
562//   4. when no anchor can be found, fall back to the Myers shortest-edit-script
563//      search for that range so the result is still a valid LCS-correct diff.
564//
565// They operate purely on slices of [`DiffLine`]s and emit the same coalesced
566// [`DiffOp`] run sequence as [`myers_diff_lines`], so any caller can swap
567// algorithms freely. The two functions differ only in the anchor-selection
568// rule in steps 2/3.
569// ===========================================================================
570
571/// A hashable key for a line, used to bucket equal lines when finding anchors.
572///
573/// Mirrors [`DiffLine`]'s `PartialEq`: two lines are the same iff their bytes
574/// and their trailing-newline flag match. Keying on this tuple lets us hash
575/// lines without changing the public [`DiffLine`] type.
576type LineKey<'a> = (&'a [u8], bool);
577
578#[inline]
579fn line_key<'a>(line: &DiffLine<'a>) -> LineKey<'a> {
580    (line.content, line.has_newline)
581}
582
583/// Compute a line-level edit script transforming `old` into `new` using the
584/// patience diff algorithm (Bram Cohen's algorithm, as in `git diff
585/// --patience`).
586///
587/// Patience diff anchors on lines that occur *exactly once* in both `old` and
588/// `new`; it aligns those unique lines via a longest-increasing-subsequence
589/// ("patience sorting") pass and recurses into the gaps, falling back to Myers
590/// when a gap has no unique common line. The result is a valid LCS-correct edit
591/// script with the same shape as [`myers_diff_lines`]: walking it reconstructs
592/// `new` from `old`, and every [`DiffOp::Equal`] run covers genuinely equal
593/// lines. Patience tends to produce more human-readable hunks than Myers when
594/// blocks of lines are moved or repeated, though it is not guaranteed to be a
595/// shortest edit script.
596pub fn patience_diff_lines(old: &[DiffLine<'_>], new: &[DiffLine<'_>]) -> Vec<DiffOp> {
597    patience_diff_lines_anchored(old, new, &[])
598}
599
600/// As [`patience_diff_lines`], but pins lines whose content has any of `anchors`
601/// as a byte prefix into the common subsequence (git's `--anchored=<text>`).
602///
603/// Mirrors xdiff's `xpatience.c`: an anchor line that is unique in both ranges is
604/// forced to remain aligned (so *other* lines are moved instead), taken greedily
605/// in old-side order; an anchor that would break the increasing order with an
606/// already-pinned anchor is dropped. Anchors that are non-unique or absent have
607/// no effect, exactly as in git. With `anchors` empty this is plain patience.
608pub fn patience_diff_lines_anchored(
609    old: &[DiffLine<'_>],
610    new: &[DiffLine<'_>],
611    anchors: &[Vec<u8>],
612) -> Vec<DiffOp> {
613    let mut ops: Vec<DiffOp> = Vec::new();
614    patience_recurse(old, new, 0, old.len(), 0, new.len(), anchors, &mut ops);
615    coalesce_ops(ops)
616}
617
618/// Compute a line-level edit script transforming `old` into `new` using the
619/// histogram diff algorithm (as in `git diff --histogram`, derived from JGit).
620///
621/// Histogram diff is a patience-style unique-anchor algorithm with a fallback:
622/// it builds an occurrence histogram of `old` and, scanning `new`, picks the
623/// longest run of matching lines whose `old` line has the *fewest* occurrences
624/// (preferring truly unique lines, like patience, but still able to anchor on
625/// low-frequency lines when no globally-unique line exists). It then recurses
626/// on the regions on either side of that run, falling back to Myers only when
627/// no common line exists in a region. The result is a valid LCS-correct edit
628/// script with the same shape as [`myers_diff_lines`].
629pub fn histogram_diff_lines(old: &[DiffLine<'_>], new: &[DiffLine<'_>]) -> Vec<DiffOp> {
630    let mut ops: Vec<DiffOp> = Vec::new();
631    histogram_recurse(old, new, 0, old.len(), 0, new.len(), &mut ops);
632    coalesce_ops(ops)
633}
634
635/// Dispatch to the line-diff implementation selected by `algorithm`.
636///
637/// All variants return the same coalesced [`DiffOp`] run sequence as
638/// [`myers_diff_lines`], so callers can switch algorithms without changing how
639/// they consume the result.
640///
641/// - [`DiffAlgorithm::Myers`] and [`DiffAlgorithm::Minimal`] use the Myers
642///   O(ND) shortest-edit-script search ([`myers_diff_lines`]); that search is
643///   already minimal in deletions + insertions, so `Minimal` is an alias for
644///   it here rather than a distinct slower mode.
645/// - [`DiffAlgorithm::Patience`] uses [`patience_diff_lines`].
646/// - [`DiffAlgorithm::Histogram`] uses [`histogram_diff_lines`].
647pub fn diff_lines_with_algorithm(
648    old: &[DiffLine<'_>],
649    new: &[DiffLine<'_>],
650    algorithm: DiffAlgorithm,
651) -> Vec<DiffOp> {
652    match algorithm {
653        DiffAlgorithm::Myers | DiffAlgorithm::Minimal => myers_diff_lines(old, new),
654        DiffAlgorithm::Patience => patience_diff_lines(old, new),
655        DiffAlgorithm::Histogram => histogram_diff_lines(old, new),
656    }
657}
658
659/// Emit ops for an empty-on-one-side range; returns `true` if it handled it.
660///
661/// Covers the recursion base cases where one side of `old[a0..a1]` /
662/// `new[b0..b1]` is empty: a pure deletion, a pure insertion, or nothing at
663/// all. Used by both the patience and histogram recursions before they look
664/// for an anchor.
665fn emit_trivial_range(a0: usize, a1: usize, b0: usize, b1: usize, out: &mut Vec<DiffOp>) -> bool {
666    let old_len = a1 - a0;
667    let new_len = b1 - b0;
668    if old_len == 0 && new_len == 0 {
669        return true;
670    }
671    if old_len == 0 {
672        out.push(DiffOp::Insert(new_len));
673        return true;
674    }
675    if new_len == 0 {
676        out.push(DiffOp::Delete(old_len));
677        return true;
678    }
679    false
680}
681
682/// Trim the common prefix/suffix of `old[a0..a1]` vs `new[b0..b1]`.
683///
684/// Emits an `Equal` for the matched prefix immediately, returns the inner
685/// (still-differing) range, and reports the matched-suffix length so the caller
686/// can emit its `Equal` *after* it has processed the inner range. This keeps
687/// the per-range work proportional to the actual edit, mirroring the prefix /
688/// suffix trim in [`myers_diff_lines`].
689fn trim_common(
690    old: &[DiffLine<'_>],
691    new: &[DiffLine<'_>],
692    mut a0: usize,
693    mut a1: usize,
694    mut b0: usize,
695    mut b1: usize,
696    out: &mut Vec<DiffOp>,
697) -> (usize, usize, usize, usize, usize) {
698    let mut prefix = 0usize;
699    while a0 < a1 && b0 < b1 && old[a0] == new[b0] {
700        a0 += 1;
701        b0 += 1;
702        prefix += 1;
703    }
704    if prefix > 0 {
705        out.push(DiffOp::Equal(prefix));
706    }
707    let mut suffix = 0usize;
708    while a1 > a0 && b1 > b0 && old[a1 - 1] == new[b1 - 1] {
709        a1 -= 1;
710        b1 -= 1;
711        suffix += 1;
712    }
713    (a0, a1, b0, b1, suffix)
714}
715
716/// Recursive patience-diff worker over `old[a0..a1]` vs `new[b0..b1]`.
717///
718/// `anchors` carries the `--anchored=<text>` prefixes (empty for plain
719/// patience); they are re-evaluated at every recursion level, since a line that
720/// is non-unique in the whole file can become unique within a sub-range.
721#[allow(clippy::too_many_arguments)]
722fn patience_recurse(
723    old: &[DiffLine<'_>],
724    new: &[DiffLine<'_>],
725    a0: usize,
726    a1: usize,
727    b0: usize,
728    b1: usize,
729    anchors: &[Vec<u8>],
730    out: &mut Vec<DiffOp>,
731) {
732    if emit_trivial_range(a0, a1, b0, b1, out) {
733        return;
734    }
735    let (a0, a1, b0, b1, suffix) = trim_common(old, new, a0, a1, b0, b1, out);
736    if !emit_trivial_range(a0, a1, b0, b1, out) {
737        match patience_anchors(old, new, a0, a1, b0, b1, anchors) {
738            Some(aligned) => {
739                // Walk the aligned anchors in order, recursing into each gap
740                // before emitting the anchor line as Equal.
741                let mut cur_a = a0;
742                let mut cur_b = b0;
743                for (ai, bi) in aligned {
744                    patience_recurse(old, new, cur_a, ai, cur_b, bi, anchors, out);
745                    out.push(DiffOp::Equal(1));
746                    cur_a = ai + 1;
747                    cur_b = bi + 1;
748                }
749                // Tail after the last anchor.
750                patience_recurse(old, new, cur_a, a1, cur_b, b1, anchors, out);
751            }
752            // No unique common line in this range: defer to Myers, which always
753            // yields a valid (and minimal) script for the leftover block.
754            None => myers_core(&old[a0..a1], &new[b0..b1], out),
755        }
756    }
757    if suffix > 0 {
758        out.push(DiffOp::Equal(suffix));
759    }
760}
761
762/// Find the patience anchors for `old[a0..a1]` vs `new[b0..b1]`.
763///
764/// An anchor is a line that occurs exactly once in `old[a0..a1]` and exactly
765/// once in `new[b0..b1]`. The matched (old_index, new_index) pairs are reduced
766/// to their longest increasing subsequence by new-index (the patience-sort LCS)
767/// so the returned anchors are strictly increasing in *both* indices and can be
768/// used as split points. Returns `None` when there are no such unique common
769/// lines (the caller then falls back to Myers).
770fn patience_anchors(
771    old: &[DiffLine<'_>],
772    new: &[DiffLine<'_>],
773    a0: usize,
774    a1: usize,
775    b0: usize,
776    b1: usize,
777    anchors: &[Vec<u8>],
778) -> Option<Vec<(usize, usize)>> {
779    // Count occurrences and remember the (single) position of each line in each
780    // side's range. `count > 1` poisons the position so we can ignore it.
781    struct Occ {
782        count: usize,
783        pos: usize,
784    }
785    let mut in_old: HashMap<LineKey<'_>, Occ> = HashMap::new();
786    for (i, line) in old.iter().enumerate().take(a1).skip(a0) {
787        in_old
788            .entry(line_key(line))
789            .and_modify(|o| o.count += 1)
790            .or_insert(Occ { count: 1, pos: i });
791    }
792    let mut in_new: HashMap<LineKey<'_>, Occ> = HashMap::new();
793    for (j, line) in new.iter().enumerate().take(b1).skip(b0) {
794        in_new
795            .entry(line_key(line))
796            .and_modify(|o| o.count += 1)
797            .or_insert(Occ { count: 1, pos: j });
798    }
799
800    // Collect lines unique in both, ordered by their position in `old`.
801    let mut pairs: Vec<(usize, usize)> = Vec::new();
802    for (i, line) in old.iter().enumerate().take(a1).skip(a0) {
803        let key = line_key(line);
804        let Some(o) = in_old.get(&key) else { continue };
805        if o.count != 1 || o.pos != i {
806            continue;
807        }
808        // A line unique in both ranges is a candidate anchor.
809        if let Some(n) = in_new.get(&key)
810            && n.count == 1
811        {
812            pairs.push((i, n.pos));
813        }
814    }
815    if pairs.is_empty() {
816        return None;
817    }
818
819    // Patience sort: longest increasing subsequence of new-indices. `pairs` is
820    // already sorted by old-index, so an LIS by new-index yields a set of
821    // anchors increasing in both coordinates. With `--anchored` text(s) present,
822    // pin the matching (unique-in-both) lines into the subsequence instead.
823    let lis = if anchors.is_empty() {
824        longest_increasing_by_new(&pairs)
825    } else {
826        let is_anchor: Vec<bool> = pairs
827            .iter()
828            .map(|&(_, nj)| line_matches_anchor(new[nj].content, anchors))
829            .collect();
830        longest_increasing_by_new_anchored(&pairs, &is_anchor)
831    };
832    if lis.is_empty() { None } else { Some(lis) }
833}
834
835/// Whether `line` begins with any of the `--anchored` prefixes (git's
836/// `is_anchor`: a byte-prefix `strncmp` against the line's content, trailing
837/// newline included). An empty anchor prefix matches every line, matching git.
838fn line_matches_anchor(line: &[u8], anchors: &[Vec<u8>]) -> bool {
839    anchors.iter().any(|anchor| line.starts_with(anchor))
840}
841
842/// Longest increasing subsequence of `pairs` (sorted by old-index) keyed on the
843/// new-index, returned as the chosen (old_index, new_index) pairs in order.
844///
845/// This is the patience-sorting core: standard O(k log k) LIS with predecessor
846/// links so the actual subsequence (not just its length) is recovered. Because
847/// the input is pre-sorted by old-index and the new-indices are distinct, the
848/// result is strictly increasing in both coordinates.
849fn longest_increasing_by_new(pairs: &[(usize, usize)]) -> Vec<(usize, usize)> {
850    if pairs.is_empty() {
851        return Vec::new();
852    }
853    // tails[len-1] = index into `pairs` of the smallest possible tail value of
854    // an increasing subsequence of length `len`.
855    let mut tails: Vec<usize> = Vec::new();
856    // prev[i] = index into `pairs` of the predecessor of pairs[i] in its LIS.
857    let mut prev: Vec<Option<usize>> = vec![None; pairs.len()];
858
859    for i in 0..pairs.len() {
860        let val = pairs[i].1;
861        // Binary search for the first tail whose new-index is >= val.
862        let mut lo = 0usize;
863        let mut hi = tails.len();
864        while lo < hi {
865            let mid = lo + (hi - lo) / 2;
866            if pairs[tails[mid]].1 < val {
867                lo = mid + 1;
868            } else {
869                hi = mid;
870            }
871        }
872        if lo > 0 {
873            prev[i] = Some(tails[lo - 1]);
874        }
875        if lo == tails.len() {
876            tails.push(i);
877        } else {
878            tails[lo] = i;
879        }
880    }
881
882    // Reconstruct by following predecessor links from the last tail.
883    let mut result: Vec<(usize, usize)> = Vec::with_capacity(tails.len());
884    let mut cur = tails.last().copied();
885    while let Some(i) = cur {
886        result.push(pairs[i]);
887        cur = prev[i];
888    }
889    result.reverse();
890    result
891}
892
893/// Longest increasing subsequence of `pairs` (sorted by old-index, keyed on the
894/// new-index) that is *forced* to pass through every includible anchor.
895///
896/// A direct port of git's anchored `find_longest_common_sequence`
897/// (xdiff/xpatience.c): entries are processed in old-index order and placed into
898/// the patience-sort `sequence` by their new-index. When an anchor entry
899/// (`is_anchor[i]`) is placed at position `k`, `anchor_i` is pinned to `k` and
900/// the running length is forced to `k + 1`; thereafter positions `<= anchor_i`
901/// can never be overridden, so the result must contain that anchor. A later
902/// anchor whose placement would fall at or before `anchor_i` is skipped, exactly
903/// matching git's greedy handling of mutually-incompatible anchors.
904fn longest_increasing_by_new_anchored(
905    pairs: &[(usize, usize)],
906    is_anchor: &[bool],
907) -> Vec<(usize, usize)> {
908    if pairs.is_empty() {
909        return Vec::new();
910    }
911    // sequence[k] = index into `pairs` of the smallest-new-index tail of an
912    // increasing subsequence of length k+1; `prev` links to the predecessor.
913    let mut sequence: Vec<usize> = Vec::with_capacity(pairs.len());
914    let mut prev: Vec<Option<usize>> = vec![None; pairs.len()];
915    let mut longest: usize = 0;
916    let mut anchor_i: isize = -1;
917    for (e, &(_, val)) in pairs.iter().enumerate() {
918        // i = largest position in sequence[0..longest] whose new-index < val,
919        // or -1 if none (git's fast-path + `binary_search`).
920        let i: isize = if longest == 0 || val > pairs[sequence[longest - 1]].1 {
921            longest as isize - 1
922        } else {
923            let mut lo = 0usize;
924            let mut hi = longest;
925            while lo < hi {
926                let mid = lo + (hi - lo) / 2;
927                if pairs[sequence[mid]].1 < val {
928                    lo = mid + 1;
929                } else {
930                    hi = mid;
931                }
932            }
933            lo as isize - 1
934        };
935        prev[e] = if i < 0 {
936            None
937        } else {
938            Some(sequence[i as usize])
939        };
940        let pos = (i + 1) as usize;
941        if (pos as isize) <= anchor_i {
942            continue;
943        }
944        if pos == sequence.len() {
945            sequence.push(e);
946        } else {
947            sequence[pos] = e;
948        }
949        if is_anchor[e] {
950            anchor_i = pos as isize;
951            longest = pos + 1;
952        } else if pos == longest {
953            longest += 1;
954        }
955    }
956    if longest == 0 {
957        return Vec::new();
958    }
959    let mut result: Vec<(usize, usize)> = Vec::with_capacity(longest);
960    let mut cur = Some(sequence[longest - 1]);
961    while let Some(i) = cur {
962        result.push(pairs[i]);
963        cur = prev[i];
964    }
965    result.reverse();
966    result
967}
968
969/// Recursive histogram-diff worker over `old[a0..a1]` vs `new[b0..b1]`.
970fn histogram_recurse(
971    old: &[DiffLine<'_>],
972    new: &[DiffLine<'_>],
973    a0: usize,
974    a1: usize,
975    b0: usize,
976    b1: usize,
977    out: &mut Vec<DiffOp>,
978) {
979    if emit_trivial_range(a0, a1, b0, b1, out) {
980        return;
981    }
982    let (a0, a1, b0, b1, suffix) = trim_common(old, new, a0, a1, b0, b1, out);
983    if !emit_trivial_range(a0, a1, b0, b1, out) {
984        match histogram_region(old, new, a0, a1, b0, b1) {
985            Some(region) => {
986                // Recurse left of the matched run, emit the run as Equal, then
987                // recurse right of it.
988                histogram_recurse(old, new, a0, region.old_start, b0, region.new_start, out);
989                out.push(DiffOp::Equal(region.len));
990                histogram_recurse(
991                    old,
992                    new,
993                    region.old_start + region.len,
994                    a1,
995                    region.new_start + region.len,
996                    b1,
997                    out,
998                );
999            }
1000            // No common line at all in this range: hand it to Myers.
1001            None => myers_core(&old[a0..a1], &new[b0..b1], out),
1002        }
1003    }
1004    if suffix > 0 {
1005        out.push(DiffOp::Equal(suffix));
1006    }
1007}
1008
1009/// The longest common run chosen by the histogram heuristic for one range.
1010struct HistogramRegion {
1011    old_start: usize,
1012    new_start: usize,
1013    len: usize,
1014}
1015
1016/// Choose the histogram anchor run for `old[a0..a1]` vs `new[b0..b1]`.
1017///
1018/// Builds an occurrence histogram of the `old` range, then scans the `new`
1019/// range. For each `new` line that also appears in `old`, it extends a matching
1020/// run backward and forward and scores candidate alignments, preferring the run
1021/// whose anchoring `old` line has the *fewest* occurrences (ties broken by run
1022/// length, then by earliest position). This is the JGit/`git --histogram`
1023/// heuristic: rare lines make the most reliable anchors. Returns `None` if no
1024/// `new` line appears in the `old` range.
1025fn histogram_region(
1026    old: &[DiffLine<'_>],
1027    new: &[DiffLine<'_>],
1028    a0: usize,
1029    a1: usize,
1030    b0: usize,
1031    b1: usize,
1032) -> Option<HistogramRegion> {
1033    // Occurrence count and the list of positions of each line within old[a0..a1].
1034    let mut buckets: HashMap<LineKey<'_>, Vec<usize>> = HashMap::new();
1035    for (i, line) in old.iter().enumerate().take(a1).skip(a0) {
1036        buckets.entry(line_key(line)).or_default().push(i);
1037    }
1038
1039    let mut best: Option<HistogramRegion> = None;
1040    // Lower occurrence count is better; among equal counts, longer run wins.
1041    let mut best_count = usize::MAX;
1042    let mut best_len = 0usize;
1043
1044    let mut bj = b0;
1045    while bj < b1 {
1046        let key = line_key(&new[bj]);
1047        let Some(positions) = buckets.get(&key) else {
1048            bj += 1;
1049            continue;
1050        };
1051        let occ = positions.len();
1052        // For every place this line sits in `old`, measure the maximal matching
1053        // run that passes through (positions[*], bj).
1054        let mut next_bj = bj + 1;
1055        for &ai in positions {
1056            // Extend backward while lines keep matching and we stay in range.
1057            let mut start_a = ai;
1058            let mut start_b = bj;
1059            while start_a > a0 && start_b > b0 && old[start_a - 1] == new[start_b - 1] {
1060                start_a -= 1;
1061                start_b -= 1;
1062            }
1063            // Extend forward from the run start.
1064            let mut len = 0usize;
1065            while start_a + len < a1
1066                && start_b + len < b1
1067                && old[start_a + len] == new[start_b + len]
1068            {
1069                len += 1;
1070            }
1071            // Score this run by the rarest occurrence count along it; using the
1072            // anchor line's own count is the standard, cheaper approximation.
1073            let run_count = occ;
1074            let better = run_count < best_count || (run_count == best_count && len > best_len);
1075            if better && len > 0 {
1076                best_count = run_count;
1077                best_len = len;
1078                best = Some(HistogramRegion {
1079                    old_start: start_a,
1080                    new_start: start_b,
1081                    len,
1082                });
1083                // Skip past this matched run in `new` so we do not re-evaluate
1084                // every interior line of the same run from scratch.
1085                if start_b + len > next_bj {
1086                    next_bj = start_b + len;
1087                }
1088            }
1089        }
1090        bj = next_bj.max(bj + 1);
1091    }
1092
1093    best
1094}
1095
1096/// Which conflict-marker style [`merge_blobs`] emits.
1097#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1098pub enum ConflictStyle {
1099    /// Standard two-section markers (`<<<<<<<` / `=======` / `>>>>>>>`).
1100    #[default]
1101    Merge,
1102    /// `diff3` style: also include the common-ancestor section between `ours`
1103    /// and the `=======` divider, delimited by `|||||||`.
1104    Diff3,
1105}
1106
1107/// Labels and style controlling [`merge_blobs`] conflict markers.
1108#[derive(Debug, Clone, Copy)]
1109pub struct MergeBlobOptions<'a> {
1110    /// Label after the opening `<<<<<<<` marker (typically the local branch).
1111    pub ours_label: &'a str,
1112    /// Label after the closing `>>>>>>>` marker (typically the other branch).
1113    pub theirs_label: &'a str,
1114    /// Label after the `|||||||` marker (only used for [`ConflictStyle::Diff3`]).
1115    pub base_label: &'a str,
1116    /// Which marker style to emit.
1117    pub style: ConflictStyle,
1118    /// How to resolve a textual conflict. [`MergeFavor::Union`] keeps both sides'
1119    /// lines with no markers (and a non-conflicted result); other values leave
1120    /// markers (favouring ours/theirs is applied by the caller at the file level).
1121    pub favor: MergeFavor,
1122    /// Whitespace-insensitivity for the 3-way line matching, mirroring
1123    /// `-Xignore-space-change`/`-Xignore-all-space`/`-Xignore-space-at-eol` (git's
1124    /// `ll_opts.xdl_opts`). When non-empty, regions that differ only by ignored
1125    /// whitespace are not conflicts, and unchanged spans emit ours' actual bytes
1126    /// (xdl_merge copies the common parts from file1). Empty (the default) is the
1127    /// exact, byte-for-byte merge.
1128    pub ws_ignore: WsIgnore,
1129    /// Number of marker bytes in `<<<<<<<` / `=======` / `>>>>>>>` lines.
1130    pub marker_size: usize,
1131}
1132
1133impl Default for MergeBlobOptions<'_> {
1134    fn default() -> Self {
1135        Self {
1136            ours_label: "ours",
1137            theirs_label: "theirs",
1138            base_label: "base",
1139            style: ConflictStyle::Merge,
1140            favor: MergeFavor::None,
1141            ws_ignore: WsIgnore::EMPTY,
1142            marker_size: 7,
1143        }
1144    }
1145}
1146
1147/// The outcome of a 3-way blob merge.
1148#[derive(Debug, Clone, PartialEq, Eq)]
1149pub struct MergeBlobResult {
1150    /// The merged blob bytes, including any conflict markers.
1151    pub content: Vec<u8>,
1152    /// True when at least one region conflicted and markers were written.
1153    pub conflicted: bool,
1154}
1155
1156/// Perform a 3-way merge of three blobs using the diff3 algorithm.
1157///
1158/// `base` is the common ancestor; `ours` and `theirs` are the two sides. The
1159/// merge diffs base→ours and base→theirs (with [`myers_diff_lines`]) and walks
1160/// the base in lockstep:
1161/// - regions unchanged on both sides emit the base lines unchanged;
1162/// - regions changed on exactly one side take that side's lines;
1163/// - regions changed on both sides emit the side lines if they are
1164///   byte-identical, otherwise a conflict (and [`MergeBlobResult::conflicted`]
1165///   is set).
1166///
1167/// An empty `base` is supported: every line is then "added on both sides", so
1168/// the result is the shared content if `ours == theirs`, else a single
1169/// conflict (add/add).
1170pub fn merge_blobs(
1171    base: &[u8],
1172    ours: &[u8],
1173    theirs: &[u8],
1174    options: &MergeBlobOptions<'_>,
1175) -> MergeBlobResult {
1176    let base_lines = split_lines(base);
1177    let ours_lines = split_lines(ours);
1178    let theirs_lines = split_lines(theirs);
1179
1180    // Per-side matched (equal) base regions, paired with the corresponding side
1181    // ranges, computed via Myers. Under `ws_ignore`, lines that differ only by
1182    // ignored whitespace match, so whitespace-only changes are absorbed into the
1183    // stable spans rather than surfacing as conflicts.
1184    let ours_matches = matching_regions(&base_lines, &ours_lines, options.ws_ignore);
1185    let theirs_matches = matching_regions(&base_lines, &theirs_lines, options.ws_ignore);
1186
1187    // Intersect the two match lists to get segments of base that are unchanged
1188    // on BOTH sides, each carrying the exact aligned side indices. Between these
1189    // common-stable segments lie the (potentially conflicting) changed regions.
1190    let stable = common_stable_segments(&ours_matches, &theirs_matches);
1191
1192    let mut writer = MergeWriter::new(options);
1193    // Cursors: next unconsumed line in base, ours, theirs.
1194    let mut base_idx = 0usize;
1195    let mut our_idx = 0usize;
1196    let mut their_idx = 0usize;
1197
1198    for seg in &stable {
1199        // Unstable (changed) region preceding this stable segment.
1200        let base_region = &base_lines[base_idx..seg.base_start];
1201        let our_region = &ours_lines[our_idx..seg.ours_start];
1202        let their_region = &theirs_lines[their_idx..seg.theirs_start];
1203        emit_region(
1204            &mut writer,
1205            base_region,
1206            our_region,
1207            their_region,
1208            options.ws_ignore,
1209        );
1210
1211        // The stable segment matched on both sides. Emit ours' actual bytes
1212        // (xdl_merge copies common spans from file1): identical to base under an
1213        // exact match, and ours' whitespace under `ws_ignore`.
1214        writer.emit_lines(&ours_lines[seg.ours_start..seg.ours_start + seg.len]);
1215
1216        base_idx = seg.base_start + seg.len;
1217        our_idx = seg.ours_start + seg.len;
1218        their_idx = seg.theirs_start + seg.len;
1219    }
1220
1221    // Trailing unstable region after the last stable segment (or the whole input
1222    // when there are no common-stable segments).
1223    emit_region(
1224        &mut writer,
1225        &base_lines[base_idx..],
1226        &ours_lines[our_idx..],
1227        &theirs_lines[their_idx..],
1228        options.ws_ignore,
1229    );
1230
1231    writer.finish()
1232}
1233
1234/// Resolve and emit one changed region (the gap between two common-stable
1235/// segments) according to diff3 rules.
1236fn emit_region(
1237    writer: &mut MergeWriter<'_>,
1238    base_region: &[DiffLine<'_>],
1239    our_region: &[DiffLine<'_>],
1240    their_region: &[DiffLine<'_>],
1241    ws_ignore: WsIgnore,
1242) {
1243    if our_region.is_empty() && their_region.is_empty() {
1244        return;
1245    }
1246    // Under `ws_ignore`, "changed" means changed beyond ignored whitespace; with
1247    // the empty default the comparison is exact byte equality.
1248    let our_changed = !regions_match(our_region, base_region, ws_ignore);
1249    let their_changed = !regions_match(their_region, base_region, ws_ignore);
1250    match (our_changed, their_changed) {
1251        (false, false) => writer.emit_lines(our_region),
1252        (true, false) => writer.emit_lines(our_region),
1253        (false, true) => writer.emit_lines(their_region),
1254        (true, true) => {
1255            if regions_match(our_region, their_region, ws_ignore) {
1256                // Both sides made the same change (up to ignored whitespace): no
1257                // conflict. xdl_merge keeps ours' bytes.
1258                writer.emit_lines(our_region);
1259            } else {
1260                writer.emit_conflict_refined(our_region, base_region, their_region);
1261            }
1262        }
1263    }
1264}
1265
1266/// Whether two line slices are equal, exactly when `ws_ignore` is empty and up to
1267/// the active whitespace-ignore canonicalization otherwise.
1268fn regions_match(a: &[DiffLine<'_>], b: &[DiffLine<'_>], ws_ignore: WsIgnore) -> bool {
1269    if ws_ignore.is_empty() {
1270        return a == b;
1271    }
1272    a.len() == b.len()
1273        && a.iter().zip(b).all(|(x, y)| {
1274            canonicalize_line(x.content, ws_ignore) == canonicalize_line(y.content, ws_ignore)
1275        })
1276}
1277
1278/// One unit produced by zealous conflict refinement: either context lines shared
1279/// by both sides (emitted verbatim) or a minimal conflict spanning the named
1280/// ours/theirs line ranges.
1281enum RefineItem {
1282    Context(std::ops::Range<usize>),
1283    Conflict(std::ops::Range<usize>, std::ops::Range<usize>),
1284}
1285
1286/// git's `xdl_refine_conflicts` + `xdl_simplify_non_conflicts` (level
1287/// `XDL_MERGE_ZEALOUS`): re-diff the two conflicting sides against each other,
1288/// factor the lines they share out of the conflict as context, and split the
1289/// remainder into the minimal set of conflicting hunks — then re-merge any two
1290/// conflicts separated by 3 or fewer context lines (the smaller-output rule).
1291///
1292/// Ranges index into `ours`/`theirs`; `Context` ranges are in ours coordinates
1293/// (the shared lines are identical on both sides).
1294fn refine_conflict_items(ours: &[DiffLine<'_>], theirs: &[DiffLine<'_>]) -> Vec<RefineItem> {
1295    // Coalesce the ours-vs-theirs diff into alternating context (equal) and
1296    // conflict (changed) runs.
1297    let ops = myers_diff_lines(ours, theirs);
1298    let mut raw: Vec<RefineItem> = Vec::new();
1299    let mut oi = 0usize;
1300    let mut ti = 0usize;
1301    let mut pending: Option<(usize, usize, usize, usize)> = None; // o0,o1,t0,t1
1302    for op in ops {
1303        match op {
1304            DiffOp::Equal(n) => {
1305                if let Some((o0, o1, t0, t1)) = pending.take() {
1306                    raw.push(RefineItem::Conflict(o0..o1, t0..t1));
1307                }
1308                raw.push(RefineItem::Context(oi..oi + n));
1309                oi += n;
1310                ti += n;
1311            }
1312            DiffOp::Delete(n) => {
1313                let entry = pending.get_or_insert((oi, oi, ti, ti));
1314                entry.1 = oi + n;
1315                oi += n;
1316            }
1317            DiffOp::Insert(n) => {
1318                let entry = pending.get_or_insert((oi, oi, ti, ti));
1319                entry.3 = ti + n;
1320                ti += n;
1321            }
1322        }
1323    }
1324    if let Some((o0, o1, t0, t1)) = pending.take() {
1325        raw.push(RefineItem::Conflict(o0..o1, t0..t1));
1326    }
1327
1328    // Merge two conflicts when the context between them is <= 3 lines: the
1329    // absorbed context lines are identical on both sides, so they fold into the
1330    // combined conflict's ours and theirs ranges alike.
1331    let mut out: Vec<RefineItem> = Vec::new();
1332    let mut idx = 0usize;
1333    while idx < raw.len() {
1334        match &raw[idx] {
1335            RefineItem::Context(range) => {
1336                let small = range.len() <= 3;
1337                let prev_conflict = matches!(out.last(), Some(RefineItem::Conflict(..)));
1338                let next_conflict = matches!(raw.get(idx + 1), Some(RefineItem::Conflict(..)));
1339                if small && prev_conflict && next_conflict {
1340                    let Some(RefineItem::Conflict(po, pt)) = out.pop() else {
1341                        unreachable!()
1342                    };
1343                    let RefineItem::Conflict(no, nt) = &raw[idx + 1] else {
1344                        unreachable!()
1345                    };
1346                    out.push(RefineItem::Conflict(po.start..no.end, pt.start..nt.end));
1347                    idx += 2;
1348                } else {
1349                    out.push(RefineItem::Context(range.clone()));
1350                    idx += 1;
1351                }
1352            }
1353            RefineItem::Conflict(o, t) => {
1354                out.push(RefineItem::Conflict(o.clone(), t.clone()));
1355                idx += 1;
1356            }
1357        }
1358    }
1359    out
1360}
1361
1362/// A matched (equal) region between `base` and one side: `base_start..+len`
1363/// lines of base equal `side_start..+len` lines of that side.
1364#[derive(Debug, Clone, Copy)]
1365struct MatchRegion {
1366    base_start: usize,
1367    side_start: usize,
1368    len: usize,
1369}
1370
1371/// A run of base lines unchanged on *both* sides, with the aligned side starts.
1372#[derive(Debug, Clone, Copy)]
1373struct StableSegment {
1374    base_start: usize,
1375    ours_start: usize,
1376    theirs_start: usize,
1377    len: usize,
1378}
1379
1380/// Compute the matched regions between base and a side using [`myers_diff_lines`].
1381///
1382/// Each `Equal(n)` run becomes a [`MatchRegion`]; the regions are returned in
1383/// increasing base order. (Equal runs are coalesced by the diff, so adjacent
1384/// regions are already maximal.)
1385fn matching_regions(
1386    base: &[DiffLine<'_>],
1387    side: &[DiffLine<'_>],
1388    ws_ignore: WsIgnore,
1389) -> Vec<MatchRegion> {
1390    let ops = if ws_ignore.is_empty() {
1391        myers_diff_lines(base, side)
1392    } else {
1393        // The 3-way content merge uses the Myers line diff (git's ll-merge xdl
1394        // default); the whitespace flags affect only the equality test.
1395        myers_diff_lines_ws(base, side, ws_ignore, DiffAlgorithm::Myers)
1396    };
1397    let mut regions = Vec::new();
1398    let mut base_idx = 0usize;
1399    let mut side_idx = 0usize;
1400    for op in ops {
1401        match op {
1402            DiffOp::Equal(n) => {
1403                regions.push(MatchRegion {
1404                    base_start: base_idx,
1405                    side_start: side_idx,
1406                    len: n,
1407                });
1408                base_idx += n;
1409                side_idx += n;
1410            }
1411            DiffOp::Delete(n) => base_idx += n,
1412            DiffOp::Insert(n) => side_idx += n,
1413        }
1414    }
1415    regions
1416}
1417
1418/// Intersect the ours/theirs match lists (both in base coordinates) to find the
1419/// base ranges unchanged on both sides, recording the aligned side indices.
1420///
1421/// For each overlapping pair of base ranges `[bs, be)` the ours-side index of
1422/// `bs` is `o.side_start + (bs - o.base_start)` and likewise for theirs; both
1423/// map contiguously across the overlap. The returned segments are in increasing
1424/// base order and never overlap.
1425fn common_stable_segments(ours: &[MatchRegion], theirs: &[MatchRegion]) -> Vec<StableSegment> {
1426    let mut segments = Vec::new();
1427    let mut oi = 0usize;
1428    let mut ti = 0usize;
1429    while oi < ours.len() && ti < theirs.len() {
1430        let o = ours[oi];
1431        let t = theirs[ti];
1432        let o_end = o.base_start + o.len;
1433        let t_end = t.base_start + t.len;
1434        let lo = o.base_start.max(t.base_start);
1435        let hi = o_end.min(t_end);
1436        if lo < hi {
1437            segments.push(StableSegment {
1438                base_start: lo,
1439                ours_start: o.side_start + (lo - o.base_start),
1440                theirs_start: t.side_start + (lo - t.base_start),
1441                len: hi - lo,
1442            });
1443        }
1444        // Advance whichever range ends first.
1445        if o_end <= t_end {
1446            oi += 1;
1447        } else {
1448            ti += 1;
1449        }
1450    }
1451    segments
1452}
1453
1454/// Accumulates merged output and renders conflict markers byte-for-byte like
1455/// upstream git.
1456struct MergeWriter<'a> {
1457    out: Vec<u8>,
1458    conflicted: bool,
1459    options: &'a MergeBlobOptions<'a>,
1460}
1461
1462impl<'a> MergeWriter<'a> {
1463    fn new(options: &'a MergeBlobOptions<'a>) -> Self {
1464        Self {
1465            out: Vec::new(),
1466            conflicted: false,
1467            options,
1468        }
1469    }
1470
1471    /// Append raw line bytes (each line already carries its own newline, except
1472    /// possibly a final no-newline line).
1473    fn emit_lines(&mut self, lines: &[DiffLine<'_>]) {
1474        for line in lines {
1475            self.out.extend_from_slice(line.content);
1476        }
1477    }
1478
1479    /// Emit a conflict hunk. Conflict markers always begin on their own line,
1480    /// so if the preceding emitted content did not end in a newline (a
1481    /// no-newline-at-end side), insert one first — matching git, which prints
1482    /// the "\ No newline at end of file" content followed by a newline before
1483    /// the next marker.
1484    fn emit_conflict(
1485        &mut self,
1486        ours: &[DiffLine<'_>],
1487        base: &[DiffLine<'_>],
1488        theirs: &[DiffLine<'_>],
1489    ) {
1490        // Union: keep both sides' lines (ours then theirs) with no markers, and do
1491        // NOT flag a conflict — git's `XDL_MERGE_FAVOR_UNION`.
1492        if self.options.favor == MergeFavor::Union {
1493            self.emit_section(ours);
1494            self.ensure_newline();
1495            self.emit_section(theirs);
1496            return;
1497        }
1498        self.conflicted = true;
1499        self.write_marker(b'<', self.options.ours_label);
1500        self.emit_section(ours);
1501        if self.options.style == ConflictStyle::Diff3 {
1502            self.ensure_newline();
1503            self.write_marker(b'|', self.options.base_label);
1504            self.emit_section(base);
1505        }
1506        self.ensure_newline();
1507        self.write_divider();
1508        self.emit_section(theirs);
1509        self.ensure_newline();
1510        self.write_marker(b'>', self.options.theirs_label);
1511    }
1512
1513    /// Emit a conflict with git's zealous refinement applied. The default
1514    /// (non-diff3) merge re-diffs the two sides to shrink the conflict to the
1515    /// lines that genuinely differ (`xdl_refine_conflicts`); diff3-style output
1516    /// keeps the conflict whole (the base section straddles it), a favored merge
1517    /// resolves at a coarser granularity, and an empty side cannot be refined —
1518    /// all three fall back to a single unrefined conflict hunk.
1519    fn emit_conflict_refined(
1520        &mut self,
1521        ours: &[DiffLine<'_>],
1522        base: &[DiffLine<'_>],
1523        theirs: &[DiffLine<'_>],
1524    ) {
1525        if self.options.style == ConflictStyle::Diff3
1526            || self.options.favor != MergeFavor::None
1527            || ours.is_empty()
1528            || theirs.is_empty()
1529        {
1530            self.emit_conflict(ours, base, theirs);
1531            return;
1532        }
1533        for item in refine_conflict_items(ours, theirs) {
1534            match item {
1535                RefineItem::Context(range) => self.emit_lines(&ours[range]),
1536                RefineItem::Conflict(o, t) => self.emit_conflict(&ours[o], &[], &theirs[t]),
1537            }
1538        }
1539    }
1540
1541    /// Emit one side's lines inside a conflict, preserving their exact bytes.
1542    fn emit_section(&mut self, lines: &[DiffLine<'_>]) {
1543        for line in lines {
1544            self.out.extend_from_slice(line.content);
1545        }
1546    }
1547
1548    /// Ensure the buffer ends with a newline before writing the next marker, so
1549    /// markers always start a fresh line even after a no-newline final line.
1550    fn ensure_newline(&mut self) {
1551        if !self.out.is_empty() && self.out.last() != Some(&b'\n') {
1552            self.out.push(b'\n');
1553        }
1554    }
1555
1556    /// Write a marker line: N copies of `ch`, then (if the label is non-empty)
1557    /// a space and the label, then a newline. No trailing space for an empty
1558    /// label — byte-for-byte with upstream git.
1559    fn write_marker(&mut self, ch: u8, label: &str) {
1560        for _ in 0..self.options.marker_size {
1561            self.out.push(ch);
1562        }
1563        if !label.is_empty() {
1564            self.out.push(b' ');
1565            self.out.extend_from_slice(label.as_bytes());
1566        }
1567        self.out.push(b'\n');
1568    }
1569
1570    /// Write the `=======` divider line (never labelled).
1571    fn write_divider(&mut self) {
1572        for _ in 0..self.options.marker_size {
1573            self.out.push(b'=');
1574        }
1575        self.out.push(b'\n');
1576    }
1577
1578    fn finish(self) -> MergeBlobResult {
1579        MergeBlobResult {
1580            content: self.out,
1581            conflicted: self.conflicted,
1582        }
1583    }
1584}
1585
1586#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1587pub enum DiffAlgorithm {
1588    Myers,
1589    Minimal,
1590    Patience,
1591    Histogram,
1592}
1593
1594#[derive(Debug, Clone, PartialEq, Eq)]
1595pub enum FileChange {
1596    Add { path: RepoPath },
1597    Delete { path: RepoPath },
1598    Modify { path: RepoPath },
1599    Rename { old: RepoPath, new: RepoPath },
1600    Copy { source: RepoPath, dest: RepoPath },
1601}
1602
1603#[derive(Debug, Clone, PartialEq, Eq)]
1604pub struct Conflict {
1605    pub path: RepoPath,
1606    pub ours: Vec<u8>,
1607    pub theirs: Vec<u8>,
1608}
1609
1610#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1611pub enum NameStatus {
1612    Added,
1613    Deleted,
1614    Modified,
1615    /// A path whose file type (`S_IFMT` bits of the mode) changed between the two
1616    /// sides — regular↔symlink, regular↔gitlink, symlink↔gitlink. git renders this
1617    /// as `T` (`DIFF_STATUS_TYPE_CHANGED`, set by `diffcore.h`'s
1618    /// `DIFF_PAIR_TYPE_CHANGED` before rename/modify resolution). An exec-bit-only
1619    /// change (100644↔100755) is NOT a typechange — same `S_IFMT`.
1620    TypeChanged,
1621    Renamed(u8),
1622    Copied(u8),
1623    /// An unmerged (conflicted) path: the index holds higher-stage entries.
1624    /// git emits a standalone `U <path>` pair (`diff_unmerge`) for it in
1625    /// addition to the regular worktree-vs-stage-2 modify.
1626    Unmerged,
1627}
1628
1629impl NameStatus {
1630    pub const fn code(self) -> char {
1631        match self {
1632            Self::Added => 'A',
1633            Self::Deleted => 'D',
1634            Self::Modified => 'M',
1635            Self::TypeChanged => 'T',
1636            Self::Renamed(_) => 'R',
1637            Self::Copied(_) => 'C',
1638            Self::Unmerged => 'U',
1639        }
1640    }
1641
1642    pub fn label(self) -> String {
1643        match self {
1644            Self::Renamed(score) => format!("R{score:03}"),
1645            Self::Copied(score) => format!("C{score:03}"),
1646            _ => self.code().to_string(),
1647        }
1648    }
1649}
1650
1651/// The bit mask isolating the file-type bits of a git mode (`S_IFMT`). Regular
1652/// files are `0o100000`, symlinks `0o120000`, gitlinks `0o160000`, trees
1653/// `0o040000`.
1654pub const S_IFMT: u32 = 0o170000;
1655
1656/// Whether a pair of (non-zero) modes constitutes a git "typechange": the file
1657/// type bits (`S_IFMT`) differ. Mirrors `diffcore.h`'s `DIFF_PAIR_TYPE_CHANGED`
1658/// (`(S_IFMT & one->mode) != (S_IFMT & two->mode)`). An exec-bit-only change
1659/// (`0o100644` ↔ `0o100755`) is NOT a typechange — same `S_IFMT`.
1660#[must_use]
1661pub const fn is_type_change(old_mode: u32, new_mode: u32) -> bool {
1662    (old_mode & S_IFMT) != (new_mode & S_IFMT)
1663}
1664
1665/// Classify a both-sides-present change whose entries already differ: a
1666/// [`NameStatus::TypeChanged`] when the modes' `S_IFMT` bits differ, otherwise a
1667/// plain [`NameStatus::Modified`]. git sets `DIFF_STATUS_TYPE_CHANGED` before any
1668/// rename/modify resolution (`diff.c` ~6650).
1669#[must_use]
1670pub const fn modify_or_type_change(old_mode: u32, new_mode: u32) -> NameStatus {
1671    if is_type_change(old_mode, new_mode) {
1672        NameStatus::TypeChanged
1673    } else {
1674        NameStatus::Modified
1675    }
1676}
1677
1678#[derive(Debug, Clone, PartialEq, Eq)]
1679pub struct NameStatusEntry {
1680    pub status: NameStatus,
1681    pub path: BString,
1682    pub old_path: Option<BString>,
1683    pub old_mode: Option<u32>,
1684    pub new_mode: Option<u32>,
1685    pub old_oid: Option<ObjectId>,
1686    pub new_oid: Option<ObjectId>,
1687}
1688
1689impl NameStatusEntry {
1690    pub fn line(&self) -> String {
1691        if let Some(old_path) = &self.old_path {
1692            format!(
1693                "{}\t{}\t{}",
1694                self.status.label(),
1695                String::from_utf8_lossy(old_path.as_bytes()),
1696                String::from_utf8_lossy(self.path.as_bytes())
1697            )
1698        } else {
1699            format!(
1700                "{}\t{}",
1701                self.status.label(),
1702                String::from_utf8_lossy(self.path.as_bytes())
1703            )
1704        }
1705    }
1706}
1707
1708#[derive(Debug, Clone, PartialEq, Eq)]
1709pub struct IndexGitlinkEntry {
1710    pub path: BString,
1711    pub oid: ObjectId,
1712}
1713
1714#[derive(Debug, Clone, PartialEq, Eq)]
1715pub struct IndexWorktreeDiff {
1716    pub entries: Vec<NameStatusEntry>,
1717    pub staged_gitlinks: Vec<IndexGitlinkEntry>,
1718}
1719
1720#[derive(Clone, Copy)]
1721pub struct IndexWorktreeValidationEntry<'a> {
1722    pub path: &'a [u8],
1723    pub mode: u32,
1724    pub oid: ObjectId,
1725    pub size: u32,
1726}
1727
1728pub struct IndexWorktreeValidatedEntry {
1729    pub mode: u32,
1730    pub oid: ObjectId,
1731}
1732
1733pub type IndexWorktreeStatCleanValidator<'a> = dyn FnMut(
1734        IndexWorktreeValidationEntry<'_>,
1735        &Path,
1736        &fs::Metadata,
1737    ) -> Result<Option<IndexWorktreeValidatedEntry>>
1738    + 'a;
1739
1740#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1741pub struct DiffNameStatusOptions {
1742    pub detect_renames: bool,
1743    pub detect_copies: bool,
1744    pub find_copies_harder: bool,
1745    pub rename_empty: bool,
1746}
1747
1748impl Default for DiffNameStatusOptions {
1749    fn default() -> Self {
1750        Self {
1751            detect_renames: true,
1752            detect_copies: false,
1753            find_copies_harder: false,
1754            rename_empty: true,
1755        }
1756    }
1757}
1758
1759/// git's default minimum similarity (as a percentage) for a pair of files to be
1760/// reported as a rename or copy. Matches `git`'s built-in `-M`/`-C` threshold
1761/// of 50% (`DEFAULT_RENAME_SCORE` is `MAX_SCORE / 2`).
1762pub const DEFAULT_RENAME_THRESHOLD: u8 = 50;
1763
1764/// Options controlling inexact (similarity-based) rename and copy detection,
1765/// layered additively on top of [`DiffNameStatusOptions`].
1766///
1767/// This is a separate struct rather than new fields on [`DiffNameStatusOptions`]
1768/// so that existing callers — which build `DiffNameStatusOptions` with a struct
1769/// literal — keep compiling unchanged. Code that wants inexact detection uses
1770/// the `*_with_rename_options` entry points and this type instead.
1771///
1772/// [`Default`] preserves the existing behaviour exactly: `detect_inexact` is
1773/// `false`, so unless a caller opts in, only exact-OID rename/copy detection
1774/// runs (identical to the plain `*_with_options` functions). When
1775/// `detect_inexact` is enabled, files added on one side are paired with the most
1776/// similar deleted/modified file on the other side whose similarity meets the
1777/// relevant threshold; exact-OID matches still take priority and are always
1778/// scored 100.
1779#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1780pub struct RenameDetectionOptions {
1781    /// The base name-status options (rename/copy enable flags, find-copies-harder,
1782    /// rename-empty). Exact detection honours these exactly as before.
1783    pub base: DiffNameStatusOptions,
1784    /// Enable inexact (content-similarity) detection. When `false`, only exact
1785    /// OID matches are detected, matching the legacy `*_with_options` behaviour.
1786    pub detect_inexact: bool,
1787    /// Minimum similarity percentage (`0..=100`) for an inexact *rename*. Pairs
1788    /// scoring below this are not reported as renames. Defaults to
1789    /// [`DEFAULT_RENAME_THRESHOLD`].
1790    pub rename_threshold: u8,
1791    /// Minimum similarity percentage (`0..=100`) for an inexact *copy*. Defaults
1792    /// to [`DEFAULT_RENAME_THRESHOLD`]; git uses the same default for `-C` as for
1793    /// `-M` unless `-C<n>` overrides it.
1794    pub copy_threshold: u8,
1795    /// Cap on the inexact rename matrix (git's `diff.renameLimit` /
1796    /// `merge.renameLimit`): when the number of candidate sources times the
1797    /// number of candidate destinations exceeds `rename_limit²`, inexact
1798    /// detection is skipped entirely (only exact-OID renames survive). `0` means
1799    /// unlimited — git's `too_many_rename_candidates` treats a non-positive limit
1800    /// the same way.
1801    pub rename_limit: usize,
1802}
1803
1804#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1805pub struct RenameLimitDiagnostics {
1806    pub inexact_renames_skipped: bool,
1807    pub inexact_copies_skipped: bool,
1808    pub inexact_copies_degraded: bool,
1809}
1810
1811impl RenameLimitDiagnostics {
1812    pub fn any_skipped(self) -> bool {
1813        self.inexact_renames_skipped || self.inexact_copies_skipped
1814    }
1815
1816    pub fn any_warning(self) -> bool {
1817        self.any_skipped() || self.inexact_copies_degraded
1818    }
1819}
1820
1821#[derive(Debug, Clone, PartialEq, Eq)]
1822pub struct NameStatusWithRenameDiagnostics {
1823    pub entries: Vec<NameStatusEntry>,
1824    pub rename_limit: RenameLimitDiagnostics,
1825}
1826
1827impl Default for RenameDetectionOptions {
1828    fn default() -> Self {
1829        Self {
1830            base: DiffNameStatusOptions::default(),
1831            detect_inexact: false,
1832            rename_threshold: DEFAULT_RENAME_THRESHOLD,
1833            copy_threshold: DEFAULT_RENAME_THRESHOLD,
1834            rename_limit: 0,
1835        }
1836    }
1837}
1838
1839impl RenameDetectionOptions {
1840    /// Build inexact-enabled options from a base [`DiffNameStatusOptions`], using
1841    /// the default thresholds for both renames and copies.
1842    pub fn inexact(base: DiffNameStatusOptions) -> Self {
1843        Self {
1844            base,
1845            detect_inexact: true,
1846            ..Self::default()
1847        }
1848    }
1849}
1850
1851pub fn diff_name_status_head_worktree(
1852    worktree_root: impl AsRef<Path>,
1853    git_dir: impl AsRef<Path>,
1854    format: ObjectFormat,
1855) -> Result<Vec<NameStatusEntry>> {
1856    diff_name_status_head_worktree_with_options(
1857        worktree_root,
1858        git_dir,
1859        format,
1860        DiffNameStatusOptions::default(),
1861    )
1862}
1863
1864pub fn diff_name_status_head_worktree_with_options(
1865    worktree_root: impl AsRef<Path>,
1866    git_dir: impl AsRef<Path>,
1867    format: ObjectFormat,
1868    options: DiffNameStatusOptions,
1869) -> Result<Vec<NameStatusEntry>> {
1870    let worktree_root = worktree_root.as_ref();
1871    let git_dir = git_dir.as_ref();
1872    let db = FileObjectDatabase::from_git_dir(git_dir, format);
1873    let head = head_tree_entries(git_dir, format, &db)?;
1874    let IndexSnapshot {
1875        entries: index,
1876        stat_cache,
1877        missing_skip_worktree_entries,
1878        present_skip_worktree_entries,
1879    } = read_index_snapshot(git_dir, format)?;
1880    let index_gitlinks = index_gitlinks(&index);
1881    let candidate_paths = candidate_path_set(head.keys().chain(index.keys()));
1882    let worktree = worktree_entries_for_path_set(
1883        worktree_root,
1884        format,
1885        &candidate_paths,
1886        &index_gitlinks,
1887        Some(&stat_cache),
1888        Some(&missing_skip_worktree_entries),
1889        Some(&present_skip_worktree_entries),
1890    )?;
1891    let changes = diff_name_status_maps_for_path_set(&head, &worktree, &candidate_paths, options)?;
1892    Ok(mark_unstaged_worktree_oids_unresolved(
1893        changes, &index, &worktree,
1894    ))
1895}
1896
1897/// HEAD-vs-worktree name-status with full rename/copy options, including inexact
1898/// (similarity) detection when enabled. Worktree blob content is read directly
1899/// from the working tree; HEAD-side blobs come from the object database.
1900pub fn diff_name_status_head_worktree_with_rename_options(
1901    worktree_root: impl AsRef<Path>,
1902    git_dir: impl AsRef<Path>,
1903    format: ObjectFormat,
1904    options: RenameDetectionOptions,
1905) -> Result<Vec<NameStatusEntry>> {
1906    let worktree_root = worktree_root.as_ref();
1907    let git_dir = git_dir.as_ref();
1908    let db = FileObjectDatabase::from_git_dir(git_dir, format);
1909    let head = head_tree_entries(git_dir, format, &db)?;
1910    let IndexSnapshot {
1911        entries: index,
1912        stat_cache,
1913        missing_skip_worktree_entries,
1914        present_skip_worktree_entries,
1915    } = read_index_snapshot(git_dir, format)?;
1916    let index_gitlinks = index_gitlinks(&index);
1917    let candidate_paths = candidate_path_set(head.keys().chain(index.keys()));
1918    let worktree = worktree_entries_for_path_set(
1919        worktree_root,
1920        format,
1921        &candidate_paths,
1922        &index_gitlinks,
1923        Some(&stat_cache),
1924        Some(&missing_skip_worktree_entries),
1925        Some(&present_skip_worktree_entries),
1926    )?;
1927    let cache = worktree_blob_cache_for_path_set(
1928        worktree_root,
1929        &head,
1930        &worktree,
1931        &candidate_paths,
1932        Some(&present_skip_worktree_entries),
1933        options,
1934    )?;
1935    let changes = diff_name_status_maps_with_renames_for_path_set(
1936        &head,
1937        &worktree,
1938        &candidate_paths,
1939        options,
1940        |oid| cache_or_odb_blob(&cache, &db, oid),
1941    )?;
1942    Ok(mark_unstaged_worktree_oids_unresolved(
1943        changes, &index, &worktree,
1944    ))
1945}
1946
1947pub fn diff_name_status_head_index(
1948    git_dir: impl AsRef<Path>,
1949    format: ObjectFormat,
1950) -> Result<Vec<NameStatusEntry>> {
1951    diff_name_status_head_index_with_options(git_dir, format, DiffNameStatusOptions::default())
1952}
1953
1954pub fn diff_name_status_head_index_with_options(
1955    git_dir: impl AsRef<Path>,
1956    format: ObjectFormat,
1957    options: DiffNameStatusOptions,
1958) -> Result<Vec<NameStatusEntry>> {
1959    let git_dir = git_dir.as_ref();
1960    let db = FileObjectDatabase::from_git_dir(git_dir, format);
1961    let head = head_tree_entries(git_dir, format, &db)?;
1962    let index = read_index_entries(git_dir, format)?;
1963    diff_name_status_maps(&head, &index, head.keys().chain(index.keys()), options)
1964}
1965
1966/// HEAD-vs-index name-status with full rename/copy options, including inexact
1967/// (similarity) detection when enabled. All blob content (both sides) comes from
1968/// the object database.
1969pub fn diff_name_status_head_index_with_rename_options(
1970    git_dir: impl AsRef<Path>,
1971    format: ObjectFormat,
1972    options: RenameDetectionOptions,
1973) -> Result<Vec<NameStatusEntry>> {
1974    Ok(
1975        diff_name_status_head_index_with_rename_options_and_diagnostics(git_dir, format, options)?
1976            .entries,
1977    )
1978}
1979
1980pub fn diff_name_status_head_index_with_rename_options_and_diagnostics(
1981    git_dir: impl AsRef<Path>,
1982    format: ObjectFormat,
1983    options: RenameDetectionOptions,
1984) -> Result<NameStatusWithRenameDiagnostics> {
1985    let git_dir = git_dir.as_ref();
1986    let db = FileObjectDatabase::from_git_dir(git_dir, format);
1987    let head = head_tree_entries(git_dir, format, &db)?;
1988    let index = read_index_entries(git_dir, format)?;
1989    diff_name_status_maps_with_renames_and_diagnostics(
1990        &head,
1991        &index,
1992        head.keys().chain(index.keys()),
1993        options,
1994        |oid| read_blob_bytes(&db, oid),
1995    )
1996}
1997
1998/// Read an arbitrary tree object's flattened blob entries (recursively) keyed by
1999/// repository-relative path. This is the tree-side counterpart used by
2000/// `git diff-index <tree-ish>`: unlike [`head_tree_entries`] it does not consult
2001/// `HEAD`, so any commit/tag (peeled to a tree) or tree oid can be compared.
2002///
2003/// The canonical empty tree (`git hash-object -t tree /dev/null`) is treated as
2004/// always present and yields no entries, even when the object was never written
2005/// to the database. git makes the same guarantee, which keeps the common idiom
2006/// `git diff-index --cached <empty-tree-sha>` working in a fresh repository.
2007fn tree_entries(
2008    tree_oid: &ObjectId,
2009    format: ObjectFormat,
2010    db: &FileObjectDatabase,
2011) -> Result<BTreeMap<Vec<u8>, TrackedEntry>> {
2012    let mut entries = BTreeMap::new();
2013    if *tree_oid == empty_tree_oid(format)? {
2014        return Ok(entries);
2015    }
2016    collect_tree_entries(db, format, tree_oid, Vec::new(), &mut entries)?;
2017    Ok(entries)
2018}
2019
2020/// The well-known oid of the empty tree for `format` (the hash of a zero-length
2021/// tree object). git hard-codes this value and treats it as always existing.
2022fn empty_tree_oid(format: ObjectFormat) -> Result<ObjectId> {
2023    object_id_for_bytes(format, "tree", b"")
2024}
2025
2026/// Name-status diff of an arbitrary tree against the index, the engine behind
2027/// `git diff-index --cached <tree-ish>`. Exact rename/copy detection follows
2028/// `options`; all blob content comes from the object database.
2029pub fn diff_name_status_tree_index_with_options(
2030    git_dir: impl AsRef<Path>,
2031    format: ObjectFormat,
2032    tree_oid: &ObjectId,
2033    options: DiffNameStatusOptions,
2034) -> Result<Vec<NameStatusEntry>> {
2035    let git_dir = git_dir.as_ref();
2036    let db = FileObjectDatabase::from_git_dir(git_dir, format);
2037    let tree = tree_entries(tree_oid, format, &db)?;
2038    let index = read_index_entries(git_dir, format)?;
2039    diff_name_status_maps(&tree, &index, tree.keys().chain(index.keys()), options)
2040}
2041
2042/// Tree-vs-index name-status with full rename/copy options, including inexact
2043/// (similarity) detection when enabled. Both sides read blob content from the
2044/// object database. Counterpart of
2045/// [`diff_name_status_head_index_with_rename_options`] for an arbitrary tree.
2046pub fn diff_name_status_tree_index_with_rename_options(
2047    git_dir: impl AsRef<Path>,
2048    format: ObjectFormat,
2049    tree_oid: &ObjectId,
2050    options: RenameDetectionOptions,
2051) -> Result<Vec<NameStatusEntry>> {
2052    Ok(
2053        diff_name_status_tree_index_with_rename_options_and_diagnostics(
2054            git_dir, format, tree_oid, options,
2055        )?
2056        .entries,
2057    )
2058}
2059
2060pub fn diff_name_status_tree_index_with_rename_options_and_diagnostics(
2061    git_dir: impl AsRef<Path>,
2062    format: ObjectFormat,
2063    tree_oid: &ObjectId,
2064    options: RenameDetectionOptions,
2065) -> Result<NameStatusWithRenameDiagnostics> {
2066    let git_dir = git_dir.as_ref();
2067    let db = FileObjectDatabase::from_git_dir(git_dir, format);
2068    let tree = tree_entries(tree_oid, format, &db)?;
2069    let index = read_index_entries(git_dir, format)?;
2070    diff_name_status_maps_with_renames_and_diagnostics(
2071        &tree,
2072        &index,
2073        tree.keys().chain(index.keys()),
2074        options,
2075        |oid| read_blob_bytes(&db, oid),
2076    )
2077}
2078
2079/// Name-status diff of an arbitrary tree against the working tree, the engine
2080/// behind plain `git diff-index <tree-ish>` (no `--cached`). New-side oids for
2081/// paths whose worktree contents differ from the index are cleared (rendered as
2082/// zeros), matching git, which only reports the worktree blob oid when it is
2083/// known-clean against the index.
2084pub fn diff_name_status_tree_worktree_with_options(
2085    worktree_root: impl AsRef<Path>,
2086    git_dir: impl AsRef<Path>,
2087    format: ObjectFormat,
2088    tree_oid: &ObjectId,
2089    options: DiffNameStatusOptions,
2090) -> Result<Vec<NameStatusEntry>> {
2091    let worktree_root = worktree_root.as_ref();
2092    let git_dir = git_dir.as_ref();
2093    let db = FileObjectDatabase::from_git_dir(git_dir, format);
2094    let tree = tree_entries(tree_oid, format, &db)?;
2095    let IndexSnapshot {
2096        entries: index,
2097        stat_cache,
2098        missing_skip_worktree_entries,
2099        present_skip_worktree_entries,
2100    } = read_index_snapshot(git_dir, format)?;
2101    let index_gitlinks = index_gitlinks(&index);
2102    let candidate_paths = candidate_path_set(tree.keys().chain(index.keys()));
2103    let worktree = worktree_entries_for_path_set(
2104        worktree_root,
2105        format,
2106        &candidate_paths,
2107        &index_gitlinks,
2108        Some(&stat_cache),
2109        Some(&missing_skip_worktree_entries),
2110        Some(&present_skip_worktree_entries),
2111    )?;
2112    let changes = diff_name_status_maps_for_path_set(&tree, &worktree, &candidate_paths, options)?;
2113    Ok(mark_unstaged_worktree_oids_unresolved(
2114        changes, &index, &worktree,
2115    ))
2116}
2117
2118/// Tree-vs-worktree name-status with full rename/copy options, including inexact
2119/// (similarity) detection when enabled. Worktree blob content is read directly
2120/// from the working tree (via an oid-keyed cache); tree-side blobs come from the
2121/// object database. As with [`diff_name_status_tree_worktree_with_options`],
2122/// new-side oids for paths that differ from the index are cleared.
2123pub fn diff_name_status_tree_worktree_with_rename_options(
2124    worktree_root: impl AsRef<Path>,
2125    git_dir: impl AsRef<Path>,
2126    format: ObjectFormat,
2127    tree_oid: &ObjectId,
2128    options: RenameDetectionOptions,
2129) -> Result<Vec<NameStatusEntry>> {
2130    let worktree_root = worktree_root.as_ref();
2131    let git_dir = git_dir.as_ref();
2132    let db = FileObjectDatabase::from_git_dir(git_dir, format);
2133    let tree = tree_entries(tree_oid, format, &db)?;
2134    let IndexSnapshot {
2135        entries: index,
2136        stat_cache,
2137        missing_skip_worktree_entries,
2138        present_skip_worktree_entries,
2139    } = read_index_snapshot(git_dir, format)?;
2140    let index_gitlinks = index_gitlinks(&index);
2141    let candidate_paths = candidate_path_set(tree.keys().chain(index.keys()));
2142    let worktree = worktree_entries_for_path_set(
2143        worktree_root,
2144        format,
2145        &candidate_paths,
2146        &index_gitlinks,
2147        Some(&stat_cache),
2148        Some(&missing_skip_worktree_entries),
2149        Some(&present_skip_worktree_entries),
2150    )?;
2151    let cache = worktree_blob_cache_for_path_set(
2152        worktree_root,
2153        &tree,
2154        &worktree,
2155        &candidate_paths,
2156        Some(&present_skip_worktree_entries),
2157        options,
2158    )?;
2159    let changes = diff_name_status_maps_with_renames_for_path_set(
2160        &tree,
2161        &worktree,
2162        &candidate_paths,
2163        options,
2164        |oid| cache_or_odb_blob(&cache, &db, oid),
2165    )?;
2166    Ok(mark_unstaged_worktree_oids_unresolved(
2167        changes, &index, &worktree,
2168    ))
2169}
2170
2171pub fn diff_name_status_index_worktree(
2172    worktree_root: impl AsRef<Path>,
2173    git_dir: impl AsRef<Path>,
2174    format: ObjectFormat,
2175) -> Result<Vec<NameStatusEntry>> {
2176    diff_name_status_index_worktree_with_options(
2177        worktree_root,
2178        git_dir,
2179        format,
2180        DiffNameStatusOptions::default(),
2181    )
2182}
2183
2184pub fn diff_name_status_index_worktree_with_options(
2185    worktree_root: impl AsRef<Path>,
2186    git_dir: impl AsRef<Path>,
2187    format: ObjectFormat,
2188    options: DiffNameStatusOptions,
2189) -> Result<Vec<NameStatusEntry>> {
2190    Ok(diff_name_status_index_worktree_with_options_and_gitlinks(
2191        worktree_root,
2192        git_dir,
2193        format,
2194        options,
2195    )?
2196    .entries)
2197}
2198
2199pub fn diff_name_status_index_worktree_with_options_and_gitlinks(
2200    worktree_root: impl AsRef<Path>,
2201    git_dir: impl AsRef<Path>,
2202    format: ObjectFormat,
2203    options: DiffNameStatusOptions,
2204) -> Result<IndexWorktreeDiff> {
2205    let IndexWorktreeDiff {
2206        entries,
2207        staged_gitlinks,
2208    } = diff_name_status_index_worktree_changes(
2209        worktree_root.as_ref(),
2210        git_dir.as_ref(),
2211        format,
2212        None,
2213    )?;
2214    let entries = apply_name_status_options_to_index_worktree_changes(entries, options)?;
2215    Ok(IndexWorktreeDiff {
2216        entries,
2217        staged_gitlinks,
2218    })
2219}
2220
2221pub fn diff_name_status_index_worktree_with_options_and_gitlinks_validated(
2222    worktree_root: impl AsRef<Path>,
2223    git_dir: impl AsRef<Path>,
2224    format: ObjectFormat,
2225    options: DiffNameStatusOptions,
2226    stat_clean_validator: &mut IndexWorktreeStatCleanValidator<'_>,
2227) -> Result<IndexWorktreeDiff> {
2228    let IndexWorktreeDiff {
2229        entries,
2230        staged_gitlinks,
2231    } = diff_name_status_index_worktree_changes(
2232        worktree_root.as_ref(),
2233        git_dir.as_ref(),
2234        format,
2235        Some(stat_clean_validator),
2236    )?;
2237    let entries = apply_name_status_options_to_index_worktree_changes(entries, options)?;
2238    Ok(IndexWorktreeDiff {
2239        entries,
2240        staged_gitlinks,
2241    })
2242}
2243
2244/// Index-vs-worktree name-status with full rename/copy options, including inexact
2245/// (similarity) detection when enabled. Worktree blob content is read directly
2246/// from the working tree; index-side blobs come from the object database.
2247pub fn diff_name_status_index_worktree_with_rename_options(
2248    worktree_root: impl AsRef<Path>,
2249    git_dir: impl AsRef<Path>,
2250    format: ObjectFormat,
2251    options: RenameDetectionOptions,
2252) -> Result<Vec<NameStatusEntry>> {
2253    Ok(
2254        diff_name_status_index_worktree_with_rename_options_and_gitlinks(
2255            worktree_root,
2256            git_dir,
2257            format,
2258            options,
2259        )?
2260        .entries,
2261    )
2262}
2263
2264pub fn diff_name_status_index_worktree_with_rename_options_and_gitlinks(
2265    worktree_root: impl AsRef<Path>,
2266    git_dir: impl AsRef<Path>,
2267    format: ObjectFormat,
2268    options: RenameDetectionOptions,
2269) -> Result<IndexWorktreeDiff> {
2270    let IndexWorktreeDiff {
2271        entries,
2272        staged_gitlinks,
2273    } = diff_name_status_index_worktree_changes(
2274        worktree_root.as_ref(),
2275        git_dir.as_ref(),
2276        format,
2277        None,
2278    )?;
2279    // Index-vs-worktree diffs only consider tracked index paths; untracked
2280    // worktree files are not additions, so rename/copy detection has no add
2281    // destinations to pair. Apply the base options for completeness.
2282    let entries = apply_name_status_options_to_index_worktree_changes(entries, options.base)?;
2283    Ok(IndexWorktreeDiff {
2284        entries,
2285        staged_gitlinks,
2286    })
2287}
2288
2289fn diff_name_status_index_worktree_changes(
2290    worktree_root: &Path,
2291    git_dir: &Path,
2292    format: ObjectFormat,
2293    mut stat_clean_validator: Option<&mut IndexWorktreeStatCleanValidator<'_>>,
2294) -> Result<IndexWorktreeDiff> {
2295    let index_path = sley_index::repository_index_path(git_dir);
2296    let index_metadata = match fs::metadata(&index_path) {
2297        Ok(metadata) => metadata,
2298        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
2299            return Ok(IndexWorktreeDiff {
2300                entries: Vec::new(),
2301                staged_gitlinks: Vec::new(),
2302            });
2303        }
2304        Err(err) => return Err(err.into()),
2305    };
2306    let index_bytes = fs::read(&index_path)?;
2307    if let Ok(index) = BorrowedIndex::parse(&index_bytes, format)
2308        && index.extension(&sley_index::INDEX_EXT_LINK)?.is_none()
2309        && !index.entries.iter().any(borrowed_entry_is_sparse_dir)
2310    {
2311        let (has_non_normal_stage, staged_gitlinks) =
2312            index_worktree_metadata_for_entries(&index.entries);
2313        if has_non_normal_stage {
2314            return diff_name_status_index_worktree_changes_from_snapshot(
2315                worktree_root,
2316                git_dir,
2317                format,
2318            );
2319        }
2320        let stat_cache =
2321            IndexStatCache::from_index_mtime_only(sley_index::file_mtime_parts(&index_metadata));
2322        let entries = diff_name_status_index_worktree_changes_for_borrowed_entries(
2323            worktree_root,
2324            format,
2325            &index.entries,
2326            &stat_cache,
2327            stat_clean_validator.as_deref_mut(),
2328        )?;
2329        return Ok(IndexWorktreeDiff {
2330            entries,
2331            staged_gitlinks,
2332        });
2333    }
2334    let index = expand_sparse_index_for_worktree_diff(
2335        sley_index::read_repository_index(git_dir, format)?,
2336        git_dir,
2337        format,
2338    )?;
2339    let (has_non_normal_stage, staged_gitlinks) =
2340        index_worktree_metadata_for_entries(&index.entries);
2341    if has_non_normal_stage {
2342        return diff_name_status_index_worktree_changes_from_snapshot(
2343            worktree_root,
2344            git_dir,
2345            format,
2346        );
2347    }
2348    let stat_cache =
2349        IndexStatCache::from_index_mtime_only(sley_index::file_mtime_parts(&index_metadata));
2350    let entries = diff_name_status_index_worktree_changes_for_entries(
2351        worktree_root,
2352        format,
2353        &index.entries,
2354        &stat_cache,
2355        stat_clean_validator.as_deref_mut(),
2356    )?;
2357    Ok(IndexWorktreeDiff {
2358        entries,
2359        staged_gitlinks,
2360    })
2361}
2362
2363fn borrowed_entry_is_sparse_dir(entry: &sley_index::IndexEntryRef<'_>) -> bool {
2364    entry.mode == sley_index::SPARSE_DIR_MODE && entry.is_skip_worktree()
2365}
2366
2367fn expand_sparse_index_for_worktree_diff(
2368    mut index: Index,
2369    git_dir: &Path,
2370    format: ObjectFormat,
2371) -> Result<Index> {
2372    if !index
2373        .entries
2374        .iter()
2375        .any(sley_index::IndexEntry::is_sparse_dir)
2376    {
2377        return Ok(index);
2378    }
2379
2380    let db = FileObjectDatabase::from_git_dir(git_dir, format);
2381    let mut expanded = Vec::with_capacity(index.entries.len());
2382    for entry in std::mem::take(&mut index.entries) {
2383        if !entry.is_sparse_dir() {
2384            expanded.push(entry);
2385            continue;
2386        }
2387
2388        let dir_prefix = entry.path.as_bytes();
2389        for (rel_path, (mode, oid)) in flatten_tree(&db, format, &entry.oid)? {
2390            let mut path = dir_prefix.to_vec();
2391            path.extend_from_slice(&rel_path);
2392            let mut expanded_entry = sley_index::IndexEntry {
2393                ctime_seconds: 0,
2394                ctime_nanoseconds: 0,
2395                mtime_seconds: 0,
2396                mtime_nanoseconds: 0,
2397                dev: 0,
2398                ino: 0,
2399                mode,
2400                uid: 0,
2401                gid: 0,
2402                size: 0,
2403                oid,
2404                flags: 0,
2405                flags_extended: 0,
2406                path: BString::from(path),
2407            };
2408            expanded_entry.set_skip_worktree(true);
2409            expanded_entry.refresh_name_length();
2410            expanded.push(expanded_entry);
2411        }
2412    }
2413
2414    expanded.sort_by(|left, right| left.path.as_bytes().cmp(right.path.as_bytes()));
2415    index.entries = expanded;
2416    index.clear_sparse_extension()?;
2417    Ok(index)
2418}
2419
2420fn diff_name_status_index_worktree_changes_for_borrowed_entries(
2421    worktree_root: &Path,
2422    format: ObjectFormat,
2423    entries: &[sley_index::IndexEntryRef<'_>],
2424    stat_cache: &IndexStatCache,
2425    stat_clean_validator: Option<&mut IndexWorktreeStatCleanValidator<'_>>,
2426) -> Result<Vec<NameStatusEntry>> {
2427    const PARALLEL_SCAN_MIN_ENTRIES: usize = 2048;
2428    let workers = std::thread::available_parallelism()
2429        .map(|count| count.get())
2430        .unwrap_or(1)
2431        .min(8);
2432    if stat_clean_validator.is_some() || workers <= 1 || entries.len() < PARALLEL_SCAN_MIN_ENTRIES {
2433        return diff_name_status_index_worktree_changes_for_borrowed_entry_chunk(
2434            worktree_root,
2435            format,
2436            entries,
2437            stat_cache,
2438            stat_clean_validator,
2439        );
2440    }
2441    let chunk_size = entries.len().div_ceil(workers);
2442    std::thread::scope(|scope| {
2443        let mut handles = Vec::new();
2444        for chunk in entries.chunks(chunk_size) {
2445            handles.push(scope.spawn(move || {
2446                diff_name_status_index_worktree_changes_for_borrowed_entry_chunk(
2447                    worktree_root,
2448                    format,
2449                    chunk,
2450                    stat_cache,
2451                    None,
2452                )
2453            }));
2454        }
2455        let mut changes = Vec::new();
2456        for handle in handles {
2457            let chunk_changes = handle
2458                .join()
2459                .map_err(|_| GitError::Command("diff worker panicked".into()))??;
2460            changes.extend(chunk_changes);
2461        }
2462        Ok(changes)
2463    })
2464}
2465
2466fn diff_name_status_index_worktree_changes_for_entries(
2467    worktree_root: &Path,
2468    format: ObjectFormat,
2469    entries: &[sley_index::IndexEntry],
2470    stat_cache: &IndexStatCache,
2471    stat_clean_validator: Option<&mut IndexWorktreeStatCleanValidator<'_>>,
2472) -> Result<Vec<NameStatusEntry>> {
2473    const PARALLEL_SCAN_MIN_ENTRIES: usize = 2048;
2474    let workers = std::thread::available_parallelism()
2475        .map(|count| count.get())
2476        .unwrap_or(1)
2477        .min(8);
2478    if stat_clean_validator.is_some() || workers <= 1 || entries.len() < PARALLEL_SCAN_MIN_ENTRIES {
2479        return diff_name_status_index_worktree_changes_for_entry_chunk(
2480            worktree_root,
2481            format,
2482            entries,
2483            stat_cache,
2484            stat_clean_validator,
2485        );
2486    }
2487    let chunk_size = entries.len().div_ceil(workers);
2488    std::thread::scope(|scope| {
2489        let mut handles = Vec::new();
2490        for chunk in entries.chunks(chunk_size) {
2491            handles.push(scope.spawn(move || {
2492                diff_name_status_index_worktree_changes_for_entry_chunk(
2493                    worktree_root,
2494                    format,
2495                    chunk,
2496                    stat_cache,
2497                    None,
2498                )
2499            }));
2500        }
2501        let mut changes = Vec::new();
2502        for handle in handles {
2503            let chunk_changes = handle
2504                .join()
2505                .map_err(|_| GitError::Command("diff worker panicked".into()))??;
2506            changes.extend(chunk_changes);
2507        }
2508        Ok(changes)
2509    })
2510}
2511
2512fn diff_name_status_index_worktree_changes_for_entry_chunk(
2513    worktree_root: &Path,
2514    format: ObjectFormat,
2515    entries: &[sley_index::IndexEntry],
2516    stat_cache: &IndexStatCache,
2517    mut stat_clean_validator: Option<&mut IndexWorktreeStatCleanValidator<'_>>,
2518) -> Result<Vec<NameStatusEntry>> {
2519    let mut changes = Vec::new();
2520    let mut path = PathBuf::from(worktree_root);
2521    for entry in entries {
2522        worktree_path_for_repo_path_into(&mut path, worktree_root, entry.path.as_bytes());
2523        if let Some(change) = index_worktree_change_for_entry(
2524            &path,
2525            format,
2526            entry,
2527            stat_cache,
2528            &mut stat_clean_validator,
2529        )? {
2530            changes.push(change);
2531        }
2532    }
2533    Ok(changes)
2534}
2535
2536fn diff_name_status_index_worktree_changes_for_borrowed_entry_chunk(
2537    worktree_root: &Path,
2538    format: ObjectFormat,
2539    entries: &[sley_index::IndexEntryRef<'_>],
2540    stat_cache: &IndexStatCache,
2541    mut stat_clean_validator: Option<&mut IndexWorktreeStatCleanValidator<'_>>,
2542) -> Result<Vec<NameStatusEntry>> {
2543    let mut changes = Vec::new();
2544    let mut path = PathBuf::from(worktree_root);
2545    for entry in entries {
2546        worktree_path_for_repo_path_into(&mut path, worktree_root, entry.path);
2547        if let Some(change) = index_worktree_change_for_entry(
2548            &path,
2549            format,
2550            entry,
2551            stat_cache,
2552            &mut stat_clean_validator,
2553        )? {
2554            changes.push(change);
2555        }
2556    }
2557    Ok(changes)
2558}
2559
2560fn index_worktree_metadata_for_entries(
2561    entries: &[impl WorktreeIndexEntry],
2562) -> (bool, Vec<IndexGitlinkEntry>) {
2563    let mut needs_snapshot = false;
2564    let mut staged_gitlinks = Vec::new();
2565    for entry in entries {
2566        if entry.stage() != sley_index::Stage::Normal {
2567            needs_snapshot = true;
2568        }
2569        // Intent-to-add entries (`git add -N`) must take the snapshot path, which
2570        // diffs them as new files rather than loading their empty-blob id.
2571        if entry.is_intent_to_add() {
2572            needs_snapshot = true;
2573        }
2574        if sley_index::is_gitlink(entry.mode()) {
2575            staged_gitlinks.push(IndexGitlinkEntry {
2576                path: BString::from_bytes(entry.git_path()),
2577                oid: entry.oid(),
2578            });
2579        }
2580    }
2581    (needs_snapshot, staged_gitlinks)
2582}
2583
2584fn diff_name_status_index_worktree_changes_from_snapshot(
2585    worktree_root: &Path,
2586    git_dir: &Path,
2587    format: ObjectFormat,
2588) -> Result<IndexWorktreeDiff> {
2589    let IndexSnapshot {
2590        entries: index,
2591        stat_cache,
2592        ..
2593    } = read_index_snapshot(git_dir, format)?;
2594    // Intent-to-add (`git add -N`) paths are placeholders: git's `run_diff_files`
2595    // diffs them as a brand-new file (`/dev/null` → worktree), never loading the
2596    // recorded empty-blob id. `read_index_snapshot` drops the ITA flag, so read
2597    // the set of ITA stage-0 paths separately and override their verdict below.
2598    let intent_to_add_paths = read_intent_to_add_paths(git_dir, format)?;
2599    // `read_index_snapshot` collapses each path to a single entry; for an
2600    // unmerged path it keeps the last-written stage. To match git's
2601    // `run_diff_files` we need the conflict stages, so read them separately:
2602    // git diffs the worktree against the "ours" stage (stage 2, the default
2603    // `diff_unmerged_stage`) and additionally emits a standalone `U <path>`
2604    // pair via `diff_unmerge` (diff-lib.c).
2605    let unmerged = read_unmerged_stages(git_dir, format)?;
2606    let index_gitlinks = index_gitlinks(&index);
2607    let staged_gitlinks = index_gitlinks
2608        .iter()
2609        .map(|(path, oid)| IndexGitlinkEntry {
2610            path: BString::from_bytes(path),
2611            oid: *oid,
2612        })
2613        .collect();
2614    let mut changes = Vec::new();
2615    for (git_path, left) in &index {
2616        // For a conflicted path git first queues the `U` pair, then compares the
2617        // worktree against stage 2 (ours). The snapshot's collapsed `left` may
2618        // be the wrong stage, so override it with the stage-2 entry when present.
2619        let conflict_stages = unmerged.get(git_path);
2620        let right = worktree_entry_for_path(
2621            worktree_root,
2622            format,
2623            git_path,
2624            &index_gitlinks,
2625            Some(&stat_cache),
2626            None,
2627            None,
2628        )?;
2629        if conflict_stages.is_some() {
2630            // git's `diff_unmerge` makes a pair with a null old side and the
2631            // worktree mode on the new side (diff-lib.c `wt_mode`); the oids stay
2632            // zero. The raw line is `:000000 <wt_mode> 0..0 0..0 U <path>`.
2633            changes.push(NameStatusEntry {
2634                status: NameStatus::Unmerged,
2635                path: git_path.clone().into(),
2636                old_path: None,
2637                old_mode: None,
2638                new_mode: right.as_ref().map(|entry| entry.mode),
2639                old_oid: None,
2640                new_oid: None,
2641            });
2642        }
2643        // The index side for the modify comparison: stage 2 (ours) for a
2644        // conflict, otherwise the normal stage-0 entry. If the conflict has no
2645        // stage-2 (deleted on our side / added by them), git has no entry to
2646        // diff the worktree against, so it emits only the `U` line.
2647        let left = match conflict_stages {
2648            Some(stages) => match stages.ours.as_ref() {
2649                Some(ours) => ours,
2650                None => continue,
2651            },
2652            None => left,
2653        };
2654        // Intent-to-add placeholder: git's `run_diff_files` diffs it as a new
2655        // file. With the worktree file present, queue an `Added` pair whose old
2656        // side is null (`/dev/null` → worktree blob); with the file gone, an ITA
2657        // entry yields no diff-files entry (there is nothing to add).
2658        if intent_to_add_paths.contains(git_path.as_slice()) {
2659            if let Some(right) = right {
2660                changes.push(NameStatusEntry {
2661                    status: NameStatus::Added,
2662                    path: git_path.clone().into(),
2663                    old_path: None,
2664                    old_mode: None,
2665                    new_mode: Some(right.mode),
2666                    old_oid: None,
2667                    new_oid: Some(right.oid),
2668                });
2669            }
2670            continue;
2671        }
2672        let Some(right) = right else {
2673            changes.push(NameStatusEntry {
2674                status: NameStatus::Deleted,
2675                path: git_path.clone().into(),
2676                old_path: None,
2677                old_mode: Some(left.mode),
2678                new_mode: None,
2679                old_oid: Some(left.oid),
2680                new_oid: None,
2681            });
2682            continue;
2683        };
2684        if right != *left {
2685            changes.push(NameStatusEntry {
2686                status: modify_or_type_change(left.mode, right.mode),
2687                path: git_path.clone().into(),
2688                old_path: None,
2689                old_mode: Some(left.mode),
2690                new_mode: Some(right.mode),
2691                old_oid: Some(left.oid),
2692                new_oid: Some(right.oid),
2693            });
2694        }
2695    }
2696    Ok(IndexWorktreeDiff {
2697        entries: changes,
2698        staged_gitlinks,
2699    })
2700}
2701
2702/// The conflict stages recorded for one unmerged index path.
2703struct ConflictStages {
2704    ours: Option<TrackedEntry>,
2705}
2706
2707/// Read the higher-stage (conflict) index entries, keyed by path, recording the
2708/// "ours" (stage 2) entry git diffs the worktree against. Paths with only a
2709/// stage-0 entry are absent from the result.
2710fn read_unmerged_stages(
2711    git_dir: &Path,
2712    format: ObjectFormat,
2713) -> Result<BTreeMap<Vec<u8>, ConflictStages>> {
2714    let index_path = sley_index::repository_index_path(git_dir);
2715    if !index_path.exists() {
2716        return Ok(BTreeMap::new());
2717    }
2718    let index = sley_index::read_repository_index(git_dir, format)?;
2719    let mut out: BTreeMap<Vec<u8>, ConflictStages> = BTreeMap::new();
2720    for entry in &index.entries {
2721        let stage = entry.stage();
2722        if stage == sley_index::Stage::Normal {
2723            continue;
2724        }
2725        let path = entry.path.clone().into_bytes();
2726        let slot = out.entry(path).or_insert(ConflictStages { ours: None });
2727        if stage == sley_index::Stage::Ours {
2728            slot.ours = Some(TrackedEntry {
2729                mode: entry.mode,
2730                oid: entry.oid,
2731            });
2732        }
2733    }
2734    Ok(out)
2735}
2736
2737fn apply_name_status_options_to_index_worktree_changes(
2738    mut changes: Vec<NameStatusEntry>,
2739    options: DiffNameStatusOptions,
2740) -> Result<Vec<NameStatusEntry>> {
2741    if options.detect_renames {
2742        changes = detect_exact_renames_from_changes(changes, options.rename_empty);
2743    } else if options.detect_copies {
2744        changes.sort_by(|left, right| diff_entry_sort_path(left).cmp(diff_entry_sort_path(right)));
2745    }
2746    Ok(changes)
2747}
2748
2749fn detect_exact_renames_from_changes(
2750    changes: Vec<NameStatusEntry>,
2751    rename_empty: bool,
2752) -> Vec<NameStatusEntry> {
2753    let added = changes
2754        .iter()
2755        .enumerate()
2756        .filter(|(_, entry)| entry.status == NameStatus::Added)
2757        .map(|(idx, entry)| (idx, entry.path.clone()))
2758        .collect::<Vec<_>>();
2759    let mut sources = changes
2760        .iter()
2761        .enumerate()
2762        .filter(|(_, entry)| entry.status == NameStatus::Deleted)
2763        .filter_map(|(idx, entry)| {
2764            entry
2765                .old_oid
2766                .map(|oid| (idx, entry.path.clone(), oid, entry.old_mode))
2767        })
2768        .collect::<Vec<_>>();
2769    sources.sort_by(|left, right| left.1.cmp(&right.1).then_with(|| left.0.cmp(&right.0)));
2770
2771    let mut src_used = vec![false; sources.len()];
2772    let mut consumed_added = BTreeSet::new();
2773    let mut consumed_deleted = BTreeSet::new();
2774    let mut result = Vec::new();
2775
2776    for (idx, new_path) in &added {
2777        let Some(right_oid) = changes[*idx].new_oid else {
2778            continue;
2779        };
2780        if !rename_empty && is_empty_blob_oid(&right_oid) {
2781            continue;
2782        };
2783        let mut best: Option<usize> = None;
2784        let mut best_score = -1i32;
2785        for (source_idx, (_, src_path, src_oid, _)) in sources.iter().enumerate() {
2786            if src_used[source_idx] || *src_oid != right_oid {
2787                continue;
2788            }
2789            let score = 1 + i32::from(path_basename(src_path) == path_basename(new_path));
2790            if score > best_score {
2791                best = Some(source_idx);
2792                best_score = score;
2793                if score == 2 {
2794                    break;
2795                }
2796            }
2797        }
2798        if let Some(source_idx) = best {
2799            src_used[source_idx] = true;
2800            consumed_added.insert(*idx);
2801            let (old_idx, old_path, old_oid, old_mode) = &sources[source_idx];
2802            consumed_deleted.insert(*old_idx);
2803            if old_path.as_bytes() == new_path.as_bytes()
2804                && *old_mode == changes[*idx].new_mode
2805                && *old_oid == right_oid
2806            {
2807                continue;
2808            }
2809            result.push(NameStatusEntry {
2810                status: NameStatus::Renamed(100),
2811                path: new_path.clone(),
2812                old_path: Some(old_path.clone()),
2813                old_mode: *old_mode,
2814                new_mode: changes[*idx].new_mode,
2815                old_oid: Some(*old_oid),
2816                new_oid: Some(right_oid),
2817            });
2818        }
2819    }
2820
2821    for (index, entry) in changes.into_iter().enumerate() {
2822        if consumed_added.contains(&index) || consumed_deleted.contains(&index) {
2823            continue;
2824        }
2825        result.push(entry);
2826    }
2827    result.sort_by(|left, right| diff_entry_sort_path(left).cmp(diff_entry_sort_path(right)));
2828    result
2829}
2830
2831/// Index-vs-worktree name-status for **`git diff-files`** (plumbing), which
2832/// selects changed paths by the cached *stat* rather than by content.
2833///
2834/// This is the crucial difference from [`diff_name_status_index_worktree_with_options`]
2835/// (the engine behind porcelain `git diff`): porcelain `git diff` refreshes the
2836/// index first, so a stat-dirty-but-content-identical entry (a `touch`ed file, or
2837/// a freshly `rm --cached`-then-`reset --no-refresh` entry with a zeroed cached
2838/// stat) is re-stamped clean and suppressed. `git diff-files` does **not** refresh
2839/// — it reports every entry whose cached stat fails to prove it clean as `M`,
2840/// without re-hashing the content to "rescue" it (`builtin/diff.c` →
2841/// `run_diff_files` → `ie_match_stat`). The raw / name-only / name-status output
2842/// and the `--quiet`/`--exit-code` status therefore list such entries even when
2843/// the content is byte-identical; patch/stat output, which diffs actual content,
2844/// renders them as an empty hunk.
2845///
2846/// We layer that stat-based selection on top of the content-based diff: the
2847/// content diff already catches adds/deletes/genuine-content modifies (with
2848/// rename detection), and we then append a `Modified` entry for any stage-0 path
2849/// whose worktree file is present and whose cached stat is dirty per
2850/// [`IndexStatCache::index_entry_worktree_stat_dirty`] but which the content diff
2851/// did not already report. Content-identical stat-dirty entries cannot be rename
2852/// sources/targets (their content is unchanged), so they never interact with the
2853/// rename machinery — they are plain `M`.
2854pub fn diff_name_status_index_worktree_for_diff_files_with_options(
2855    worktree_root: impl AsRef<Path>,
2856    git_dir: impl AsRef<Path>,
2857    format: ObjectFormat,
2858    options: DiffNameStatusOptions,
2859) -> Result<Vec<NameStatusEntry>> {
2860    let worktree_root = worktree_root.as_ref();
2861    let git_dir = git_dir.as_ref();
2862    let changes =
2863        diff_name_status_index_worktree_with_options(worktree_root, git_dir, format, options)?;
2864    augment_with_stat_dirty_entries(worktree_root, git_dir, format, changes)
2865}
2866
2867/// As [`diff_name_status_index_worktree_for_diff_files_with_options`], but with
2868/// full rename/copy options (the `git diff-files -M/-C` path). The stat-dirty
2869/// augmentation is identical; only the underlying content diff differs.
2870pub fn diff_name_status_index_worktree_for_diff_files_with_rename_options(
2871    worktree_root: impl AsRef<Path>,
2872    git_dir: impl AsRef<Path>,
2873    format: ObjectFormat,
2874    options: RenameDetectionOptions,
2875) -> Result<Vec<NameStatusEntry>> {
2876    let worktree_root = worktree_root.as_ref();
2877    let git_dir = git_dir.as_ref();
2878    let changes = diff_name_status_index_worktree_with_rename_options(
2879        worktree_root,
2880        git_dir,
2881        format,
2882        options,
2883    )?;
2884    augment_with_stat_dirty_entries(worktree_root, git_dir, format, changes)
2885}
2886
2887/// Append a `Modified` entry for every stage-0 index path whose worktree file is
2888/// present and whose cached stat is dirty (`ce_match_stat` "changed") but which
2889/// `content_changes` did not already report. The result is re-sorted by path so
2890/// the merged set keeps git's diff-queue ordering. New-side oids on the added
2891/// entries are left `None` (rendered as zeros in raw output), matching git, which
2892/// reports the worktree blob oid only for entries it has hashed.
2893fn augment_with_stat_dirty_entries(
2894    worktree_root: &Path,
2895    git_dir: &Path,
2896    format: ObjectFormat,
2897    mut content_changes: Vec<NameStatusEntry>,
2898) -> Result<Vec<NameStatusEntry>> {
2899    let IndexSnapshot {
2900        entries: index,
2901        stat_cache,
2902        ..
2903    } = read_index_snapshot(git_dir, format)?;
2904    // Paths the content diff already accounts for (by new-side path, the position
2905    // git queues a pair at — a rename's destination, a modify/add/delete's path).
2906    let already_reported: BTreeSet<&[u8]> = content_changes
2907        .iter()
2908        .map(|entry| entry.path.as_bytes())
2909        .collect();
2910    let mut extras = Vec::new();
2911    for (git_path, tracked) in &index {
2912        if already_reported.contains(git_path.as_slice()) {
2913            continue;
2914        }
2915        let Some(cached) = stat_cache.entry_for_git_path(git_path) else {
2916            continue;
2917        };
2918        // Gitlinks (submodules) have their own dirtiness model and are not stat-
2919        // compared here; the content diff already handles changed gitlink oids.
2920        if sley_index::is_gitlink(tracked.mode) {
2921            continue;
2922        }
2923        let path = worktree_path_for_repo_path(worktree_root, git_path);
2924        let Ok(metadata) = fs::symlink_metadata(&path) else {
2925            // A missing worktree file is a deletion, which the content diff
2926            // already reports; nothing to add here.
2927            continue;
2928        };
2929        if !(metadata.is_file() || metadata.file_type().is_symlink()) {
2930            continue;
2931        }
2932        match stat_cache.index_entry_worktree_stat_verdict(cached, &metadata) {
2933            sley_index::StatVerdict::Clean => continue,
2934            sley_index::StatVerdict::Dirty => {}
2935            // A racily-clean entry must be resolved by content: git re-hashes it
2936            // (`ce_compare_data`) and only reports `M` when the worktree bytes
2937            // actually differ from the cached oid — so a `touch`ed-then-re-`add`ed
2938            // file (same-second mtime as the index) stays clean.
2939            sley_index::StatVerdict::RacyNeedsContentCheck => {
2940                if worktree_oid_matches_index(worktree_root, git_path, &metadata, tracked, format)?
2941                {
2942                    continue;
2943                }
2944            }
2945        }
2946        extras.push(NameStatusEntry {
2947            status: NameStatus::Modified,
2948            path: git_path.clone().into(),
2949            old_path: None,
2950            old_mode: Some(tracked.mode),
2951            new_mode: Some(tracked.mode),
2952            old_oid: Some(tracked.oid),
2953            new_oid: None,
2954        });
2955    }
2956    if !extras.is_empty() {
2957        content_changes.extend(extras);
2958        content_changes
2959            .sort_by(|left, right| diff_entry_sort_path(left).cmp(diff_entry_sort_path(right)));
2960    }
2961    Ok(content_changes)
2962}
2963
2964/// Hash the worktree file or symlink at `path` into the `(mode, oid)` blob entry
2965/// git would record for it. `metadata` must already describe a regular file or a
2966/// symlink — gitlink and directory classification is the caller's concern, since
2967/// those need the index/HEAD context this leaf does not have. This is the single
2968/// owner of the symlink-vs-regular body-source and mode split that `diff-files`
2969/// ([`worktree_oid_matches_index`]), the candidate-path collector
2970/// ([`worktree_entry_for_path`]), and the index↔worktree walk
2971/// ([`index_worktree_change_for_entry`]) all share; before consolidation each
2972/// open-coded it with a bare `0o120000`.
2973fn classify_worktree_entry(
2974    path: &Path,
2975    metadata: &fs::Metadata,
2976    format: ObjectFormat,
2977) -> Result<TrackedEntry> {
2978    let is_symlink = metadata.file_type().is_symlink();
2979    let body = if is_symlink {
2980        symlink_target_bytes(path)?
2981    } else {
2982        fs::read(path)?
2983    };
2984    let oid = EncodedObject::new(ObjectType::Blob, body).object_id(format)?;
2985    let mode = if is_symlink {
2986        sley_index::SYMLINK_MODE
2987    } else {
2988        file_mode(metadata)
2989    };
2990    Ok(TrackedEntry { mode, oid })
2991}
2992
2993/// Whether the worktree file at `git_path` hashes to the index entry's oid (mode
2994/// included). Used to resolve a racily-clean `diff-files` entry: git re-hashes the
2995/// content and only reports it changed when the bytes truly differ. Shares the
2996/// worktree-oid computation with [`worktree_entry_for_path`] via
2997/// [`classify_worktree_entry`].
2998fn worktree_oid_matches_index(
2999    worktree_root: &Path,
3000    git_path: &[u8],
3001    metadata: &fs::Metadata,
3002    index_entry: &TrackedEntry,
3003    format: ObjectFormat,
3004) -> Result<bool> {
3005    let path = worktree_path_for_repo_path(worktree_root, git_path);
3006    let entry = classify_worktree_entry(&path, metadata, format)?;
3007    Ok(entry.oid == index_entry.oid && entry.mode == index_entry.mode)
3008}
3009
3010pub fn diff_name_status_trees_with_options(
3011    db: &FileObjectDatabase,
3012    format: ObjectFormat,
3013    left_tree: &ObjectId,
3014    right_tree: &ObjectId,
3015    options: DiffNameStatusOptions,
3016) -> Result<Vec<NameStatusEntry>> {
3017    if !options.detect_copies {
3018        let mut changes = changed_tree_name_status_entries(db, format, left_tree, right_tree)?;
3019        if options.detect_renames {
3020            changes = detect_exact_renames_from_changes(changes, options.rename_empty);
3021        }
3022        return Ok(changes);
3023    }
3024
3025    // `--find-copies-harder` may pair an *unchanged* left-side file as a copy
3026    // source, so it needs the complete left map; every other mode only consults
3027    // changed paths, so the pruned simultaneous walk (which skips identical
3028    // subtrees) suffices and produces byte-identical output.
3029    let needs_full_maps = options.detect_copies && options.find_copies_harder;
3030    let (left_entries, right_entries) = if needs_full_maps {
3031        collect_full_tree_pair(db, format, left_tree, right_tree)?
3032    } else {
3033        changed_tree_entries(db, format, left_tree, right_tree)?
3034    };
3035    diff_name_status_maps(
3036        &left_entries,
3037        &right_entries,
3038        left_entries.keys().chain(right_entries.keys()),
3039        options,
3040    )
3041}
3042
3043pub fn diff_name_status_empty_tree_with_options(
3044    db: &FileObjectDatabase,
3045    format: ObjectFormat,
3046    right_tree: &ObjectId,
3047    options: DiffNameStatusOptions,
3048) -> Result<Vec<NameStatusEntry>> {
3049    let left_entries = BTreeMap::new();
3050    let mut right_entries = BTreeMap::new();
3051    collect_tree_entries(db, format, right_tree, Vec::new(), &mut right_entries)?;
3052    diff_name_status_maps(&left_entries, &right_entries, right_entries.keys(), options)
3053}
3054
3055/// Diff two trees with full rename/copy options, including inexact (similarity)
3056/// detection when [`RenameDetectionOptions::detect_inexact`] is set.
3057///
3058/// Blob bytes for similarity scoring are read from `db`. This is the inexact-
3059/// aware counterpart of [`diff_name_status_trees_with_options`]; passing
3060/// `RenameDetectionOptions::default()` (or `RenameDetectionOptions { base, ..
3061/// default }` with `detect_inexact: false`) reproduces the exact-only behaviour.
3062pub fn diff_name_status_trees_with_rename_options(
3063    db: &FileObjectDatabase,
3064    format: ObjectFormat,
3065    left_tree: &ObjectId,
3066    right_tree: &ObjectId,
3067    options: RenameDetectionOptions,
3068) -> Result<Vec<NameStatusEntry>> {
3069    Ok(diff_name_status_trees_with_rename_options_and_diagnostics(
3070        db, format, left_tree, right_tree, options,
3071    )?
3072    .entries)
3073}
3074
3075pub fn diff_name_status_trees_with_rename_options_and_diagnostics(
3076    db: &FileObjectDatabase,
3077    format: ObjectFormat,
3078    left_tree: &ObjectId,
3079    right_tree: &ObjectId,
3080    options: RenameDetectionOptions,
3081) -> Result<NameStatusWithRenameDiagnostics> {
3082    let base = options.base;
3083    if !base.detect_copies {
3084        let mut diagnostics = RenameLimitDiagnostics::default();
3085        let mut changes = changed_tree_name_status_entries(db, format, left_tree, right_tree)?;
3086        if base.detect_renames {
3087            changes = detect_exact_renames_from_changes(changes, base.rename_empty);
3088        }
3089        if base.detect_renames && options.detect_inexact {
3090            diagnostics.inexact_renames_skipped = inexact_rename_limit_exceeded(&changes, &options);
3091            changes = detect_inexact_renames(changes, &options, &|oid| read_blob_bytes(db, oid));
3092        }
3093        return Ok(NameStatusWithRenameDiagnostics {
3094            entries: changes,
3095            rename_limit: diagnostics,
3096        });
3097    }
3098
3099    // See `diff_name_status_trees_with_options`: only `--find-copies-harder`
3100    // needs unchanged left entries as copy sources; otherwise the pruned walk
3101    // (skipping identical subtrees) yields identical output far more cheaply.
3102    let needs_full_maps = options.base.detect_copies && options.base.find_copies_harder;
3103    let (left_entries, right_entries) = if needs_full_maps {
3104        collect_full_tree_pair(db, format, left_tree, right_tree)?
3105    } else {
3106        changed_tree_entries(db, format, left_tree, right_tree)?
3107    };
3108    diff_name_status_maps_with_renames_and_diagnostics(
3109        &left_entries,
3110        &right_entries,
3111        left_entries.keys().chain(right_entries.keys()),
3112        options,
3113        |oid| read_blob_bytes(db, oid),
3114    )
3115}
3116
3117/// Diff the empty tree against `right_tree` with full rename/copy options.
3118///
3119/// As with [`diff_name_status_trees_with_rename_options`], inexact detection is
3120/// gated on [`RenameDetectionOptions::detect_inexact`]; the left (empty) side
3121/// has no sources, so only copies among the right-side additions can match when
3122/// `find_copies_harder` is set.
3123pub fn diff_name_status_empty_tree_with_rename_options(
3124    db: &FileObjectDatabase,
3125    format: ObjectFormat,
3126    right_tree: &ObjectId,
3127    options: RenameDetectionOptions,
3128) -> Result<Vec<NameStatusEntry>> {
3129    let left_entries = BTreeMap::new();
3130    let mut right_entries = BTreeMap::new();
3131    collect_tree_entries(db, format, right_tree, Vec::new(), &mut right_entries)?;
3132    diff_name_status_maps_with_renames(
3133        &left_entries,
3134        &right_entries,
3135        right_entries.keys(),
3136        options,
3137        |oid| read_blob_bytes(db, oid),
3138    )
3139}
3140
3141/// Read a blob's raw bytes from the ODB, returning `None` if the object cannot
3142/// be read or is not a blob. Used as the similarity-scoring blob fetcher; a
3143/// missing object simply makes a candidate pair non-similar rather than failing
3144/// the whole diff.
3145fn read_blob_bytes(db: &FileObjectDatabase, oid: &ObjectId) -> Option<Vec<u8>> {
3146    match db.read_object(oid) {
3147        Ok(object) if object.object_type == ObjectType::Blob => Some(object.body.clone()),
3148        _ => None,
3149    }
3150}
3151
3152/// Build the raw per-path add/delete/modify change list (before any rename or
3153/// copy detection) from the two entry maps and the candidate path set.
3154fn raw_name_status_changes_for_unique_paths<'a>(
3155    left_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
3156    right_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
3157    paths: impl Iterator<Item = &'a Vec<u8>>,
3158) -> Vec<NameStatusEntry> {
3159    let mut changes = Vec::new();
3160    for path in paths {
3161        let left = left_entries.get(path);
3162        let right = right_entries.get(path);
3163        let status = match (left, right) {
3164            (None, Some(_)) => Some(NameStatus::Added),
3165            (Some(_), None) => Some(NameStatus::Deleted),
3166            (Some(left), Some(right)) if left != right => {
3167                Some(modify_or_type_change(left.mode, right.mode))
3168            }
3169            _ => None,
3170        };
3171        if let Some(status) = status {
3172            changes.push(NameStatusEntry {
3173                status,
3174                path: path.clone().into(),
3175                old_path: None,
3176                old_mode: left.map(|entry| entry.mode),
3177                new_mode: right.map(|entry| entry.mode),
3178                old_oid: left.map(|entry| entry.oid),
3179                new_oid: right.map(|entry| entry.oid),
3180            });
3181        }
3182    }
3183    changes
3184}
3185
3186fn diff_name_status_maps<'a>(
3187    left_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
3188    right_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
3189    candidate_paths: impl Iterator<Item = &'a Vec<u8>>,
3190    options: DiffNameStatusOptions,
3191) -> Result<Vec<NameStatusEntry>> {
3192    let paths = candidate_path_set(candidate_paths);
3193    diff_name_status_maps_for_path_set(left_entries, right_entries, &paths, options)
3194}
3195
3196fn diff_name_status_maps_for_path_set(
3197    left_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
3198    right_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
3199    candidate_paths: &BTreeSet<Vec<u8>>,
3200    options: DiffNameStatusOptions,
3201) -> Result<Vec<NameStatusEntry>> {
3202    diff_name_status_maps_for_unique_paths(
3203        left_entries,
3204        right_entries,
3205        candidate_paths.iter(),
3206        options,
3207    )
3208}
3209
3210fn diff_name_status_maps_for_unique_paths<'a>(
3211    left_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
3212    right_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
3213    candidate_paths: impl Iterator<Item = &'a Vec<u8>>,
3214    options: DiffNameStatusOptions,
3215) -> Result<Vec<NameStatusEntry>> {
3216    let mut changes =
3217        raw_name_status_changes_for_unique_paths(left_entries, right_entries, candidate_paths);
3218    if options.detect_renames {
3219        changes = detect_exact_renames(changes, left_entries, right_entries, options.rename_empty);
3220    }
3221    if options.detect_copies {
3222        changes = detect_exact_copies(
3223            changes,
3224            left_entries,
3225            right_entries,
3226            options.find_copies_harder,
3227            options.rename_empty,
3228        );
3229    }
3230    Ok(changes)
3231}
3232
3233/// Like [`diff_name_status_maps`], but additionally runs inexact (similarity)
3234/// rename/copy detection when `options.detect_inexact` is set.
3235///
3236/// `fetch_blob` resolves an [`ObjectId`] to that blob's raw bytes; it is only
3237/// consulted for the candidate pairs considered during inexact detection, and
3238/// only when inexact detection is enabled. A pair whose blob bytes cannot be
3239/// fetched is simply skipped (treated as not similar), so a missing object never
3240/// fails the whole diff.
3241fn diff_name_status_maps_with_renames<'a>(
3242    left_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
3243    right_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
3244    candidate_paths: impl Iterator<Item = &'a Vec<u8>>,
3245    options: RenameDetectionOptions,
3246    fetch_blob: impl Fn(&ObjectId) -> Option<Vec<u8>>,
3247) -> Result<Vec<NameStatusEntry>> {
3248    Ok(diff_name_status_maps_with_renames_and_diagnostics(
3249        left_entries,
3250        right_entries,
3251        candidate_paths,
3252        options,
3253        fetch_blob,
3254    )?
3255    .entries)
3256}
3257
3258fn diff_name_status_maps_with_renames_and_diagnostics<'a>(
3259    left_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
3260    right_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
3261    candidate_paths: impl Iterator<Item = &'a Vec<u8>>,
3262    options: RenameDetectionOptions,
3263    fetch_blob: impl Fn(&ObjectId) -> Option<Vec<u8>>,
3264) -> Result<NameStatusWithRenameDiagnostics> {
3265    let paths = candidate_path_set(candidate_paths);
3266    diff_name_status_maps_with_renames_for_path_set_and_diagnostics(
3267        left_entries,
3268        right_entries,
3269        &paths,
3270        options,
3271        fetch_blob,
3272    )
3273}
3274
3275fn diff_name_status_maps_with_renames_for_path_set(
3276    left_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
3277    right_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
3278    candidate_paths: &BTreeSet<Vec<u8>>,
3279    options: RenameDetectionOptions,
3280    fetch_blob: impl Fn(&ObjectId) -> Option<Vec<u8>>,
3281) -> Result<Vec<NameStatusEntry>> {
3282    Ok(
3283        diff_name_status_maps_with_renames_for_path_set_and_diagnostics(
3284            left_entries,
3285            right_entries,
3286            candidate_paths,
3287            options,
3288            fetch_blob,
3289        )?
3290        .entries,
3291    )
3292}
3293
3294fn diff_name_status_maps_with_renames_for_path_set_and_diagnostics(
3295    left_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
3296    right_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
3297    candidate_paths: &BTreeSet<Vec<u8>>,
3298    options: RenameDetectionOptions,
3299    fetch_blob: impl Fn(&ObjectId) -> Option<Vec<u8>>,
3300) -> Result<NameStatusWithRenameDiagnostics> {
3301    diff_name_status_maps_with_renames_for_unique_paths_and_diagnostics(
3302        left_entries,
3303        right_entries,
3304        candidate_paths.iter(),
3305        options,
3306        fetch_blob,
3307    )
3308}
3309
3310fn diff_name_status_maps_with_renames_for_unique_paths_and_diagnostics<'a>(
3311    left_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
3312    right_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
3313    candidate_paths: impl Iterator<Item = &'a Vec<u8>>,
3314    options: RenameDetectionOptions,
3315    fetch_blob: impl Fn(&ObjectId) -> Option<Vec<u8>>,
3316) -> Result<NameStatusWithRenameDiagnostics> {
3317    let base = options.base;
3318    let mut diagnostics = RenameLimitDiagnostics::default();
3319    let mut changes =
3320        raw_name_status_changes_for_unique_paths(left_entries, right_entries, candidate_paths);
3321    if base.detect_renames {
3322        changes = detect_exact_renames(changes, left_entries, right_entries, base.rename_empty);
3323    }
3324    // Inexact rename detection runs after exact renames so exact matches keep
3325    // priority (and their score of 100). It only fires when rename detection is
3326    // enabled at all, mirroring git's `-M`.
3327    if base.detect_renames && options.detect_inexact {
3328        diagnostics.inexact_renames_skipped = inexact_rename_limit_exceeded(&changes, &options);
3329        changes = detect_inexact_renames(changes, &options, &fetch_blob);
3330    }
3331    if base.detect_copies {
3332        changes = detect_exact_copies(
3333            changes,
3334            left_entries,
3335            right_entries,
3336            base.find_copies_harder,
3337            base.rename_empty,
3338        );
3339    }
3340    if base.detect_copies && options.detect_inexact {
3341        match inexact_copy_limit_outcome(&changes, left_entries, &options) {
3342            InexactCopyLimitOutcome::Full => {}
3343            InexactCopyLimitOutcome::DegradedToChangedSources => {
3344                diagnostics.inexact_copies_degraded = true;
3345            }
3346            InexactCopyLimitOutcome::Skip => {
3347                diagnostics.inexact_copies_skipped = true;
3348            }
3349        }
3350        changes = detect_inexact_copies(changes, left_entries, &options, &fetch_blob);
3351    }
3352    Ok(NameStatusWithRenameDiagnostics {
3353        entries: changes,
3354        rename_limit: diagnostics,
3355    })
3356}
3357
3358fn changed_tree_name_status_entries(
3359    db: &FileObjectDatabase,
3360    format: ObjectFormat,
3361    left_tree: &ObjectId,
3362    right_tree: &ObjectId,
3363) -> Result<Vec<NameStatusEntry>> {
3364    let mut changes = Vec::new();
3365    if left_tree != right_tree {
3366        diff_tree_pair_entries(db, format, left_tree, right_tree, &[], &mut changes)?;
3367    }
3368    sort_name_status_entries_by_path(&mut changes);
3369    Ok(changes)
3370}
3371
3372fn diff_tree_pair_entries(
3373    db: &FileObjectDatabase,
3374    format: ObjectFormat,
3375    left_tree: &ObjectId,
3376    right_tree: &ObjectId,
3377    prefix: &[u8],
3378    changes: &mut Vec<NameStatusEntry>,
3379) -> Result<()> {
3380    let left_entries = read_tree_object(db, format, left_tree)?.entries;
3381    let right_entries = read_tree_object(db, format, right_tree)?.entries;
3382    let mut left_idx = 0usize;
3383    let mut right_idx = 0usize;
3384
3385    while left_idx < left_entries.len() || right_idx < right_entries.len() {
3386        match (left_entries.get(left_idx), right_entries.get(right_idx)) {
3387            (Some(left_entry), Some(right_entry)) => match left_entry.name.cmp(&right_entry.name) {
3388                std::cmp::Ordering::Equal => {
3389                    let left_name = left_entry.name.clone();
3390                    let right_name = right_entry.name.clone();
3391                    let left_start = left_idx;
3392                    let right_start = right_idx;
3393                    while left_idx < left_entries.len() && left_entries[left_idx].name == left_name
3394                    {
3395                        left_idx += 1;
3396                    }
3397                    while right_idx < right_entries.len()
3398                        && right_entries[right_idx].name == right_name
3399                    {
3400                        right_idx += 1;
3401                    }
3402
3403                    let left_group = &left_entries[left_start..left_idx];
3404                    let right_group = &right_entries[right_start..right_idx];
3405                    let paired = left_group.len().min(right_group.len());
3406                    for idx in 0..paired {
3407                        diff_tree_entry_pair_entries(
3408                            db,
3409                            format,
3410                            prefix,
3411                            Some(&left_group[idx]),
3412                            Some(&right_group[idx]),
3413                            changes,
3414                        )?;
3415                    }
3416                    for entry in &left_group[paired..] {
3417                        diff_tree_entry_pair_entries(
3418                            db,
3419                            format,
3420                            prefix,
3421                            Some(entry),
3422                            None,
3423                            changes,
3424                        )?;
3425                    }
3426                    for entry in &right_group[paired..] {
3427                        diff_tree_entry_pair_entries(
3428                            db,
3429                            format,
3430                            prefix,
3431                            None,
3432                            Some(entry),
3433                            changes,
3434                        )?;
3435                    }
3436                }
3437                std::cmp::Ordering::Less => {
3438                    let left_name = left_entry.name.clone();
3439                    while left_idx < left_entries.len() && left_entries[left_idx].name == left_name
3440                    {
3441                        diff_tree_entry_pair_entries(
3442                            db,
3443                            format,
3444                            prefix,
3445                            Some(&left_entries[left_idx]),
3446                            None,
3447                            changes,
3448                        )?;
3449                        left_idx += 1;
3450                    }
3451                }
3452                std::cmp::Ordering::Greater => {
3453                    let right_name = right_entry.name.clone();
3454                    while right_idx < right_entries.len()
3455                        && right_entries[right_idx].name == right_name
3456                    {
3457                        diff_tree_entry_pair_entries(
3458                            db,
3459                            format,
3460                            prefix,
3461                            None,
3462                            Some(&right_entries[right_idx]),
3463                            changes,
3464                        )?;
3465                        right_idx += 1;
3466                    }
3467                }
3468            },
3469            (Some(left_entry), None) => {
3470                let left_name = left_entry.name.clone();
3471                while left_idx < left_entries.len() && left_entries[left_idx].name == left_name {
3472                    diff_tree_entry_pair_entries(
3473                        db,
3474                        format,
3475                        prefix,
3476                        Some(&left_entries[left_idx]),
3477                        None,
3478                        changes,
3479                    )?;
3480                    left_idx += 1;
3481                }
3482            }
3483            (None, Some(right_entry)) => {
3484                let right_name = right_entry.name.clone();
3485                while right_idx < right_entries.len() && right_entries[right_idx].name == right_name
3486                {
3487                    diff_tree_entry_pair_entries(
3488                        db,
3489                        format,
3490                        prefix,
3491                        None,
3492                        Some(&right_entries[right_idx]),
3493                        changes,
3494                    )?;
3495                    right_idx += 1;
3496                }
3497            }
3498            (None, None) => break,
3499        }
3500    }
3501
3502    Ok(())
3503}
3504
3505fn diff_tree_entry_pair_entries(
3506    db: &FileObjectDatabase,
3507    format: ObjectFormat,
3508    prefix: &[u8],
3509    left_entry: Option<&TreeEntry>,
3510    right_entry: Option<&TreeEntry>,
3511    changes: &mut Vec<NameStatusEntry>,
3512) -> Result<()> {
3513    let left_is_tree = left_entry.is_some_and(|entry| entry.mode == TREE_ENTRY_MODE);
3514    let right_is_tree = right_entry.is_some_and(|entry| entry.mode == TREE_ENTRY_MODE);
3515
3516    if let (Some(left_entry), Some(right_entry)) = (left_entry, right_entry) {
3517        if left_is_tree && right_is_tree {
3518            if left_entry.oid == right_entry.oid {
3519                return Ok(());
3520            }
3521            let path = join_tree_path(prefix, left_entry.name.as_bytes());
3522            return diff_tree_pair_entries(
3523                db,
3524                format,
3525                &left_entry.oid,
3526                &right_entry.oid,
3527                &path,
3528                changes,
3529            );
3530        }
3531        if !left_is_tree && !right_is_tree {
3532            if left_entry.mode == right_entry.mode && left_entry.oid == right_entry.oid {
3533                return Ok(());
3534            }
3535            let path = join_tree_path(prefix, left_entry.name.as_bytes());
3536            changes.push(NameStatusEntry {
3537                status: modify_or_type_change(left_entry.mode, right_entry.mode),
3538                path: path.into(),
3539                old_path: None,
3540                old_mode: Some(left_entry.mode),
3541                new_mode: Some(right_entry.mode),
3542                old_oid: Some(left_entry.oid),
3543                new_oid: Some(right_entry.oid),
3544            });
3545            return Ok(());
3546        }
3547    }
3548
3549    if let Some(left_entry) = left_entry {
3550        let path = join_tree_path(prefix, left_entry.name.as_bytes());
3551        collect_tree_side_changes(
3552            db,
3553            format,
3554            path,
3555            left_entry.mode,
3556            left_entry.oid,
3557            NameStatus::Deleted,
3558            changes,
3559        )?;
3560    }
3561    if let Some(right_entry) = right_entry {
3562        let path = join_tree_path(prefix, right_entry.name.as_bytes());
3563        collect_tree_side_changes(
3564            db,
3565            format,
3566            path,
3567            right_entry.mode,
3568            right_entry.oid,
3569            NameStatus::Added,
3570            changes,
3571        )?;
3572    }
3573    Ok(())
3574}
3575
3576fn collect_tree_side_changes(
3577    db: &FileObjectDatabase,
3578    format: ObjectFormat,
3579    path: Vec<u8>,
3580    mode: u32,
3581    oid: ObjectId,
3582    status: NameStatus,
3583    changes: &mut Vec<NameStatusEntry>,
3584) -> Result<()> {
3585    if mode == TREE_ENTRY_MODE {
3586        for entry in read_tree_object(db, format, &oid)?.entries {
3587            let child_path = join_tree_path(&path, entry.name.as_bytes());
3588            collect_tree_side_changes(
3589                db, format, child_path, entry.mode, entry.oid, status, changes,
3590            )?;
3591        }
3592        return Ok(());
3593    }
3594
3595    changes.push(NameStatusEntry {
3596        status,
3597        path: path.into(),
3598        old_path: None,
3599        old_mode: (status == NameStatus::Deleted).then_some(mode),
3600        new_mode: (status == NameStatus::Added).then_some(mode),
3601        old_oid: (status == NameStatus::Deleted).then_some(oid),
3602        new_oid: (status == NameStatus::Added).then_some(oid),
3603    });
3604    Ok(())
3605}
3606
3607fn sort_name_status_entries_by_path(changes: &mut [NameStatusEntry]) {
3608    changes.sort_by(|left, right| diff_entry_sort_path(left).cmp(diff_entry_sort_path(right)));
3609}
3610
3611fn detect_exact_renames(
3612    changes: Vec<NameStatusEntry>,
3613    left_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
3614    right_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
3615    rename_empty: bool,
3616) -> Vec<NameStatusEntry> {
3617    let added = changes
3618        .iter()
3619        .enumerate()
3620        .filter(|(_, entry)| entry.status == NameStatus::Added)
3621        .map(|(idx, entry)| (idx, entry.path.clone()))
3622        .collect::<Vec<_>>();
3623    // Candidate sources in path order (git's `rename_src` ordering), so the
3624    // best-source search tie-breaks deterministically.
3625    let mut sources = changes
3626        .iter()
3627        .filter(|entry| entry.status == NameStatus::Deleted)
3628        .filter_map(|entry| {
3629            left_entries
3630                .get(entry.path.as_bytes())
3631                .map(|left| (entry.path.clone(), left.oid))
3632        })
3633        .collect::<Vec<_>>();
3634    sources.sort_by(|a, b| a.0.cmp(&b.0));
3635    let mut src_used = vec![false; sources.len()];
3636    let mut consumed = BTreeSet::new();
3637    let mut renamed_old_paths = BTreeSet::new();
3638    let mut result = Vec::new();
3639
3640    // git's `find_identical_files`: for each destination, among the still-unused
3641    // sources with the identical OID, prefer one that shares the destination's
3642    // basename (score 2 short-circuits; otherwise the first such source wins).
3643    // Iterating destinations (not sources) is what lets a same-basename source
3644    // win over an alphabetically-earlier different-basename one.
3645    for (idx, new_path) in &added {
3646        let Some(right) = right_entries.get(new_path.as_bytes()) else {
3647            continue;
3648        };
3649        if !rename_empty && is_empty_blob_oid(&right.oid) {
3650            continue;
3651        }
3652        let mut best: Option<usize> = None;
3653        let mut best_score = -1i32;
3654        for (si, (src_path, src_oid)) in sources.iter().enumerate() {
3655            if src_used[si] || *src_oid != right.oid {
3656                continue;
3657            }
3658            let score = 1 + i32::from(path_basename(src_path) == path_basename(new_path));
3659            if score > best_score {
3660                best = Some(si);
3661                best_score = score;
3662                if score == 2 {
3663                    break;
3664                }
3665            }
3666        }
3667        if let Some(si) = best {
3668            src_used[si] = true;
3669            consumed.insert(*idx);
3670            let old_path = sources[si].0.clone();
3671            let left = &left_entries[old_path.as_bytes()];
3672            renamed_old_paths.insert(old_path.clone());
3673            result.push(NameStatusEntry {
3674                status: NameStatus::Renamed(100),
3675                path: new_path.clone(),
3676                old_path: Some(old_path),
3677                old_mode: Some(left.mode),
3678                new_mode: Some(right.mode),
3679                old_oid: Some(left.oid),
3680                new_oid: Some(right.oid),
3681            });
3682        }
3683    }
3684
3685    for (idx, entry) in changes.into_iter().enumerate() {
3686        if entry.status == NameStatus::Added && consumed.contains(&idx) {
3687            continue;
3688        }
3689        if entry.status == NameStatus::Deleted && renamed_old_paths.contains(&entry.path) {
3690            continue;
3691        }
3692        result.push(entry);
3693    }
3694    result.sort_by(|left, right| diff_entry_sort_path(left).cmp(diff_entry_sort_path(right)));
3695    result
3696}
3697
3698fn detect_exact_copies(
3699    changes: Vec<NameStatusEntry>,
3700    left_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
3701    right_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
3702    find_copies_harder: bool,
3703    rename_empty: bool,
3704) -> Vec<NameStatusEntry> {
3705    let changed_sources = changes
3706        .iter()
3707        .filter(|entry| matches!(entry.status, NameStatus::Deleted | NameStatus::Modified))
3708        .map(|entry| entry.path.clone())
3709        .collect::<BTreeSet<_>>();
3710    let source_paths = left_entries
3711        .keys()
3712        .filter(|path| find_copies_harder || changed_sources.contains(path.as_slice()))
3713        .cloned()
3714        .collect::<Vec<_>>();
3715
3716    let mut result = Vec::new();
3717    for entry in changes {
3718        if entry.status != NameStatus::Added {
3719            result.push(entry);
3720            continue;
3721        }
3722        let Some(right) = right_entries.get(entry.path.as_bytes()) else {
3723            result.push(entry);
3724            continue;
3725        };
3726        if let Some(old_path) = source_paths.iter().find(|old_path| {
3727            old_path.as_slice() != entry.path.as_bytes()
3728                && left_entries.get(*old_path).is_some_and(|left| {
3729                    left.oid == right.oid && (rename_empty || !is_empty_blob_oid(&left.oid))
3730                })
3731        }) {
3732            result.push(NameStatusEntry {
3733                status: NameStatus::Copied(100),
3734                path: entry.path,
3735                old_path: Some(old_path.clone().into()),
3736                old_mode: left_entries
3737                    .get(old_path.as_slice())
3738                    .map(|entry| entry.mode),
3739                new_mode: entry.new_mode,
3740                old_oid: left_entries.get(old_path.as_slice()).map(|entry| entry.oid),
3741                new_oid: entry.new_oid,
3742            });
3743        } else {
3744            result.push(entry);
3745        }
3746    }
3747    result.sort_by(|left, right| diff_entry_sort_path(left).cmp(diff_entry_sort_path(right)));
3748    result
3749}
3750
3751/// Old-side metadata of a rename source, snapshotted before the source delete
3752/// entry is consumed so it can be attached to the renamed destination.
3753#[derive(Debug, Clone)]
3754struct RenameSourceMeta {
3755    path: BString,
3756    mode: Option<u32>,
3757    oid: Option<ObjectId>,
3758}
3759
3760/// A scored candidate pairing of a deleted source with an added destination,
3761/// used to order inexact-rename assignment best-match-first.
3762struct ScoredPair {
3763    /// Index into the `deleted` candidate list.
3764    src: usize,
3765    /// Index into the `added` candidate list.
3766    dst: usize,
3767    /// Similarity percentage in `0..=100`.
3768    score: u8,
3769}
3770
3771fn rename_limit_exceeded(source_count: usize, dest_count: usize, rename_limit: usize) -> bool {
3772    rename_limit > 0
3773        && source_count
3774            .saturating_mul(dest_count)
3775            .gt(&rename_limit.saturating_mul(rename_limit))
3776}
3777
3778#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3779enum InexactCopyLimitOutcome {
3780    Full,
3781    DegradedToChangedSources,
3782    Skip,
3783}
3784
3785fn inexact_rename_candidates(
3786    changes: &[NameStatusEntry],
3787    options: &RenameDetectionOptions,
3788) -> (usize, usize) {
3789    let mut deleted = 0usize;
3790    let mut added = 0usize;
3791    for entry in changes {
3792        match entry.status {
3793            NameStatus::Deleted => {
3794                if entry
3795                    .old_oid
3796                    .as_ref()
3797                    .is_some_and(|oid| options.base.rename_empty || !is_empty_blob_oid(oid))
3798                {
3799                    deleted += 1;
3800                }
3801            }
3802            NameStatus::Added => {
3803                if entry
3804                    .new_oid
3805                    .as_ref()
3806                    .is_some_and(|oid| options.base.rename_empty || !is_empty_blob_oid(oid))
3807                {
3808                    added += 1;
3809                }
3810            }
3811            _ => {}
3812        }
3813    }
3814    (deleted, added)
3815}
3816
3817fn inexact_rename_limit_exceeded(
3818    changes: &[NameStatusEntry],
3819    options: &RenameDetectionOptions,
3820) -> bool {
3821    let (source_count, dest_count) = inexact_rename_candidates(changes, options);
3822    rename_limit_exceeded(source_count, dest_count, options.rename_limit)
3823}
3824
3825fn changed_copy_sources(changes: &[NameStatusEntry]) -> BTreeSet<BString> {
3826    changes
3827        .iter()
3828        .filter(|entry| matches!(entry.status, NameStatus::Deleted | NameStatus::Modified))
3829        .map(|entry| entry.path.clone())
3830        .collect()
3831}
3832
3833fn inexact_copy_source_count(
3834    changed_sources: &BTreeSet<BString>,
3835    left_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
3836    options: &RenameDetectionOptions,
3837    find_copies_harder: bool,
3838) -> usize {
3839    left_entries
3840        .iter()
3841        .filter(|(path, _)| find_copies_harder || changed_sources.contains(path.as_slice()))
3842        .filter(|(_, tracked)| options.base.rename_empty || !is_empty_blob_oid(&tracked.oid))
3843        .count()
3844}
3845
3846fn inexact_copy_dest_count(changes: &[NameStatusEntry], options: &RenameDetectionOptions) -> usize {
3847    changes
3848        .iter()
3849        .filter(|entry| entry.status == NameStatus::Added)
3850        .filter(|entry| {
3851            entry
3852                .new_oid
3853                .as_ref()
3854                .is_some_and(|oid| options.base.rename_empty || !is_empty_blob_oid(oid))
3855        })
3856        .count()
3857}
3858
3859fn inexact_copy_limit_outcome(
3860    changes: &[NameStatusEntry],
3861    left_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
3862    options: &RenameDetectionOptions,
3863) -> InexactCopyLimitOutcome {
3864    let changed_sources = changed_copy_sources(changes);
3865    let dest_count = inexact_copy_dest_count(changes, options);
3866    let source_count = inexact_copy_source_count(
3867        &changed_sources,
3868        left_entries,
3869        options,
3870        options.base.find_copies_harder,
3871    );
3872    if !rename_limit_exceeded(source_count, dest_count, options.rename_limit) {
3873        return InexactCopyLimitOutcome::Full;
3874    }
3875    if options.base.find_copies_harder {
3876        let changed_source_count =
3877            inexact_copy_source_count(&changed_sources, left_entries, options, false);
3878        if !rename_limit_exceeded(changed_source_count, dest_count, options.rename_limit) {
3879            return InexactCopyLimitOutcome::DegradedToChangedSources;
3880        }
3881    }
3882    InexactCopyLimitOutcome::Skip
3883}
3884
3885/// Inexact rename detection: pair still-unmatched deleted files with still-
3886/// unmatched added files by content similarity, replacing the best matches
3887/// (similarity >= `rename_threshold`) with [`NameStatus::Renamed`].
3888///
3889/// Exact renames have already run, so the only `Deleted`/`Added` entries left
3890/// here are ones with no identical-OID partner. Assignment is greedy by
3891/// descending score (then by source/destination order for determinism), and
3892/// each source and destination is used at most once — matching git's
3893/// `diffcore-rename` behaviour. Empty blobs are never used as a rename source
3894/// when `rename_empty` is false, mirroring exact detection.
3895fn detect_inexact_renames(
3896    changes: Vec<NameStatusEntry>,
3897    options: &RenameDetectionOptions,
3898    fetch_blob: &impl Fn(&ObjectId) -> Option<Vec<u8>>,
3899) -> Vec<NameStatusEntry> {
3900    let threshold = options.rename_threshold;
3901    // A threshold above 100 can never be met; nothing to do.
3902    if threshold > 100 {
3903        return changes;
3904    }
3905
3906    let mut deleted_indices = Vec::new();
3907    let mut added_indices = Vec::new();
3908    for (idx, entry) in changes.iter().enumerate() {
3909        match entry.status {
3910            NameStatus::Deleted => {
3911                let Some(oid) = entry.old_oid.as_ref() else {
3912                    continue;
3913                };
3914                if !options.base.rename_empty && is_empty_blob_oid(oid) {
3915                    continue;
3916                }
3917                deleted_indices.push(idx);
3918            }
3919            NameStatus::Added => {
3920                let Some(oid) = entry.new_oid.as_ref() else {
3921                    continue;
3922                };
3923                if !options.base.rename_empty && is_empty_blob_oid(oid) {
3924                    continue;
3925                }
3926                added_indices.push(idx);
3927            }
3928            _ => {}
3929        }
3930    }
3931
3932    if deleted_indices.is_empty() || added_indices.is_empty() {
3933        return changes;
3934    }
3935
3936    // git's `too_many_rename_candidates`: if the rename matrix would exceed a
3937    // `rename_limit` square, skip inexact detection wholesale (exact-OID renames
3938    // were already resolved upstream). A non-positive limit is unlimited.
3939    if rename_limit_exceeded(
3940        deleted_indices.len(),
3941        added_indices.len(),
3942        options.rename_limit,
3943    ) {
3944        return changes;
3945    }
3946
3947    // Fetch blob bytes only after the cap check, so a low rename limit avoids
3948    // both the quadratic scoring matrix and the broad source/destination reads.
3949    let mut deleted: Vec<(usize, Vec<u8>)> = Vec::new();
3950    for idx in deleted_indices {
3951        let Some(oid) = changes[idx].old_oid.as_ref() else {
3952            continue;
3953        };
3954        if let Some(bytes) = fetch_blob(oid) {
3955            deleted.push((idx, bytes));
3956        }
3957    }
3958    let mut added: Vec<(usize, Vec<u8>)> = Vec::new();
3959    for idx in added_indices {
3960        let Some(oid) = changes[idx].new_oid.as_ref() else {
3961            continue;
3962        };
3963        if let Some(bytes) = fetch_blob(oid) {
3964            added.push((idx, bytes));
3965        }
3966    }
3967    if deleted.is_empty() || added.is_empty() {
3968        return changes;
3969    }
3970
3971    let mut src_used = vec![false; deleted.len()];
3972    let mut dst_used = vec![false; added.len()];
3973    // destination changes-index -> (source changes-index, score).
3974    let mut rename_of: BTreeMap<usize, (usize, u8)> = BTreeMap::new();
3975
3976    // Basename pre-pass (git's `find_basename_matches`): before the global
3977    // matrix, pair unique-basename src/dst at the stricter basename score, so a
3978    // same-basename rename wins over a globally-more-similar different basename.
3979    // git only does this for pure rename detection (`!want_copies`); when copies
3980    // are also wanted it culls differently and skips the basename heuristic.
3981    if !options.base.detect_copies {
3982        let src_paths: Vec<&[u8]> = deleted
3983            .iter()
3984            .map(|(idx, _)| &changes[*idx].path[..])
3985            .collect();
3986        let dst_paths: Vec<&[u8]> = added
3987            .iter()
3988            .map(|(idx, _)| &changes[*idx].path[..])
3989            .collect();
3990        let basename_pairs = basename_rename_matches(
3991            &src_paths,
3992            &dst_paths,
3993            &src_used,
3994            &dst_used,
3995            threshold,
3996            |si, di| Some(blob_similarity(&deleted[si].1, &added[di].1)),
3997        );
3998        for (si, di, score) in basename_pairs {
3999            src_used[si] = true;
4000            dst_used[di] = true;
4001            rename_of.insert(added[di].0, (deleted[si].0, score));
4002        }
4003    }
4004
4005    // Score every remaining (delete, add) pair; keep only those meeting the
4006    // threshold.
4007    let mut pairs: Vec<ScoredPair> = Vec::new();
4008    for (si, (_, src_bytes)) in deleted.iter().enumerate() {
4009        if src_used[si] {
4010            continue;
4011        }
4012        for (di, (_, dst_bytes)) in added.iter().enumerate() {
4013            if dst_used[di] {
4014                continue;
4015            }
4016            let score = blob_similarity(src_bytes, dst_bytes);
4017            if score >= threshold {
4018                pairs.push(ScoredPair {
4019                    src: si,
4020                    dst: di,
4021                    score,
4022                });
4023            }
4024        }
4025    }
4026    // Best score first; ties broken by source then destination order so the
4027    // result is deterministic regardless of input ordering.
4028    pairs.sort_by(|a, b| {
4029        b.score
4030            .cmp(&a.score)
4031            .then_with(|| a.src.cmp(&b.src))
4032            .then_with(|| a.dst.cmp(&b.dst))
4033    });
4034
4035    for pair in pairs {
4036        if src_used[pair.src] || dst_used[pair.dst] {
4037            continue;
4038        }
4039        src_used[pair.src] = true;
4040        dst_used[pair.dst] = true;
4041        let src_change_idx = deleted[pair.src].0;
4042        let dst_change_idx = added[pair.dst].0;
4043        rename_of.insert(dst_change_idx, (src_change_idx, pair.score));
4044    }
4045
4046    if rename_of.is_empty() {
4047        return changes;
4048    }
4049
4050    // Snapshot the source (delete) entries' metadata before we consume them, so
4051    // each renamed destination can carry the correct old path/mode/oid.
4052    let consumed_sources: BTreeSet<usize> =
4053        rename_of.values().map(|(src_idx, _)| *src_idx).collect();
4054    let source_meta: BTreeMap<usize, RenameSourceMeta> = consumed_sources
4055        .iter()
4056        .map(|&src_idx| {
4057            let src = &changes[src_idx];
4058            (
4059                src_idx,
4060                RenameSourceMeta {
4061                    path: src.path.clone(),
4062                    mode: src.old_mode,
4063                    oid: src.old_oid,
4064                },
4065            )
4066        })
4067        .collect();
4068
4069    let mut result = Vec::with_capacity(changes.len());
4070    for (idx, entry) in changes.into_iter().enumerate() {
4071        if consumed_sources.contains(&idx) {
4072            // This delete became the source of a rename; drop it.
4073            continue;
4074        }
4075        if let Some((src_idx, score)) = rename_of.get(&idx) {
4076            // The destination becomes a rename from the matched source. Pull the
4077            // old-side metadata from the snapshot; the new-side metadata stays as
4078            // the destination's.
4079            let meta = source_meta
4080                .get(src_idx)
4081                .cloned()
4082                .unwrap_or(RenameSourceMeta {
4083                    path: BString::default(),
4084                    mode: None,
4085                    oid: None,
4086                });
4087            result.push(NameStatusEntry {
4088                status: NameStatus::Renamed(*score),
4089                path: entry.path,
4090                old_path: Some(meta.path),
4091                old_mode: meta.mode,
4092                new_mode: entry.new_mode,
4093                old_oid: meta.oid,
4094                new_oid: entry.new_oid,
4095            });
4096            continue;
4097        }
4098        result.push(entry);
4099    }
4100
4101    result.sort_by(|left, right| diff_entry_sort_path(left).cmp(diff_entry_sort_path(right)));
4102    result
4103}
4104
4105/// Inexact copy detection: for each still-`Added` file, find the most similar
4106/// candidate *source* on the left side (similarity >= `copy_threshold`) and, if
4107/// found, report it as a [`NameStatus::Copied`]. The source is not removed
4108/// (copies leave the original in place).
4109///
4110/// Candidate sources follow the same rule as exact copy detection: with
4111/// `find_copies_harder` every left-side path is eligible; otherwise only paths
4112/// that were themselves changed (deleted or modified) on this diff. Exact copies
4113/// have already run, so any remaining `Added` here had no identical-OID source.
4114fn detect_inexact_copies(
4115    changes: Vec<NameStatusEntry>,
4116    left_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
4117    options: &RenameDetectionOptions,
4118    fetch_blob: &impl Fn(&ObjectId) -> Option<Vec<u8>>,
4119) -> Vec<NameStatusEntry> {
4120    let threshold = options.copy_threshold;
4121    if threshold > 100 {
4122        return changes;
4123    }
4124
4125    let changed_sources = changed_copy_sources(&changes);
4126    let limit_outcome = inexact_copy_limit_outcome(&changes, left_entries, options);
4127    if limit_outcome == InexactCopyLimitOutcome::Skip {
4128        return changes;
4129    }
4130    let find_copies_harder = options.base.find_copies_harder
4131        && limit_outcome != InexactCopyLimitOutcome::DegradedToChangedSources;
4132    let mut source_candidates: Vec<(Vec<u8>, &TrackedEntry)> = Vec::new();
4133    for (path, tracked) in left_entries {
4134        if !(find_copies_harder || changed_sources.contains(path.as_slice())) {
4135            continue;
4136        }
4137        if !options.base.rename_empty && is_empty_blob_oid(&tracked.oid) {
4138            continue;
4139        }
4140        source_candidates.push((path.clone(), tracked));
4141    }
4142    if source_candidates.is_empty() {
4143        return changes;
4144    }
4145
4146    let mut sources: Vec<(Vec<u8>, &TrackedEntry, Vec<u8>)> = Vec::new();
4147    for (path, tracked) in source_candidates {
4148        if let Some(bytes) = fetch_blob(&tracked.oid) {
4149            sources.push((path, tracked, bytes));
4150        }
4151    }
4152    if sources.is_empty() {
4153        return changes;
4154    }
4155
4156    let mut result = Vec::with_capacity(changes.len());
4157    for entry in changes {
4158        if entry.status != NameStatus::Added {
4159            result.push(entry);
4160            continue;
4161        }
4162        let Some(new_oid) = entry.new_oid.as_ref() else {
4163            result.push(entry);
4164            continue;
4165        };
4166        let Some(dst_bytes) = fetch_blob(new_oid) else {
4167            result.push(entry);
4168            continue;
4169        };
4170
4171        // Pick the best-scoring source path that meets the threshold. Ties are
4172        // broken by path order (BTreeMap iteration is sorted) so the choice is
4173        // deterministic.
4174        let mut best: Option<(usize, u8)> = None;
4175        for (i, (src_path, _, src_bytes)) in sources.iter().enumerate() {
4176            if src_path.as_slice() == entry.path.as_bytes() {
4177                continue;
4178            }
4179            let score = blob_similarity(src_bytes, &dst_bytes);
4180            if score < threshold {
4181                continue;
4182            }
4183            match best {
4184                Some((_, best_score)) if best_score >= score => {}
4185                _ => best = Some((i, score)),
4186            }
4187        }
4188
4189        if let Some((src_idx, score)) = best {
4190            let (src_path, src_tracked, _) = &sources[src_idx];
4191            result.push(NameStatusEntry {
4192                status: NameStatus::Copied(score),
4193                path: entry.path,
4194                old_path: Some(src_path.clone().into()),
4195                old_mode: Some(src_tracked.mode),
4196                new_mode: entry.new_mode,
4197                old_oid: Some(src_tracked.oid),
4198                new_oid: entry.new_oid,
4199            });
4200        } else {
4201            result.push(entry);
4202        }
4203    }
4204    result.sort_by(|left, right| diff_entry_sort_path(left).cmp(diff_entry_sort_path(right)));
4205    result
4206}
4207
4208fn is_empty_blob_oid(oid: &ObjectId) -> bool {
4209    object_id_for_bytes(oid.format(), "blob", b"").is_ok_and(|empty| empty == *oid)
4210}
4211
4212// ===========================================================================
4213// Content similarity (the engine for inexact `-M`/`-C` rename/copy detection).
4214//
4215// This mirrors upstream git's similarity estimate from `diffcore-delta.c`
4216// (the span-hash counting) and `diffcore-rename.c` (the score formula), so the
4217// `R<score>`/`C<score>` we emit match git's percentages.
4218//
4219// The metric, precisely:
4220//
4221//   1. Each blob is broken into *spans*. Starting at a byte, we accumulate a
4222//      rolling hash of the bytes and end the span at the first `\n` (inclusive)
4223//      or once the span reaches `MAX_SPAN_BYTES` (64) bytes, whichever comes
4224//      first. (The 64-byte cap keeps a file with no/few newlines — e.g. a
4225//      binary blob or one very long line — from collapsing into a single span,
4226//      so similarity still tracks shared substrings.) Each span yields a
4227//      `(hash, byte_count)` pair, where `byte_count` is the span's length in
4228//      bytes. This is the exact loop git uses in `hash_chars()`.
4229//
4230//   2. The two blobs' spans are reduced to multisets keyed by hash: for each
4231//      hash we keep the total number of bytes spanned by entries with that
4232//      hash, on each side. `common_bytes` is then the sum over all hashes of
4233//      `min(bytes_on_src, bytes_on_dst)` — the bytes that exist on both sides.
4234//      This is git's `src_copied`.
4235//
4236//   3. The score is `common_bytes / max(size_src, size_dst)`, scaled to a
4237//      percentage and rounded to the nearest integer:
4238//
4239//          score% = round(common_bytes * 100 / max(size_src, size_dst))
4240//
4241//      git computes an internal score `src_copied * MAX_SCORE / max_size` with
4242//      `MAX_SCORE == 60000` and reports `round(score * 100 / MAX_SCORE)`; that
4243//      is algebraically the same rounded percentage, which we compute directly
4244//      to avoid intermediate precision loss.
4245//
4246// Edge cases match git: two empty blobs are 100% similar (identical content);
4247// an empty blob vs a non-empty one is 0%. Equal byte buffers are always 100%.
4248
4249/// Maximum number of bytes in a single similarity span before it is force-cut.
4250///
4251/// git uses 64 (`hash_chars()` breaks a span once `++chunks >= 64`).
4252const MAX_SPAN_BYTES: usize = 64;
4253
4254/// Compute the content similarity of two blobs as an integer percentage in
4255/// `0..=100`, using git's span-hash counting metric (see the module comment
4256/// above for the exact definition).
4257///
4258/// The result is symmetric (`blob_similarity(a, b) == blob_similarity(b, a)`)
4259/// because the score divides the common-byte count by the larger of the two
4260/// sizes. Byte-identical blobs return `100`; a non-empty blob compared against
4261/// an empty one returns `0`; two empty blobs return `100`.
4262///
4263/// This is the same number git prints as `similarity index N%` and uses to
4264/// decide `-M`/`-C` rename and copy detection.
4265pub fn blob_similarity(a: &[u8], b: &[u8]) -> u8 {
4266    // Fast paths that also pin down the empty-blob conventions.
4267    if a == b {
4268        return 100;
4269    }
4270    let max_size = a.len().max(b.len());
4271    if max_size == 0 {
4272        // Both empty (and not caught by `a == b` only if both are empty, which
4273        // they are here) -> identical.
4274        return 100;
4275    }
4276
4277    let src = span_hash_counts(a, blob_is_text(a));
4278    let dst = span_hash_counts(b, blob_is_text(b));
4279    let common = common_span_bytes(&src, &dst);
4280
4281    // Match git's diffcore-rename integer math exactly. git computes an internal
4282    // score `src_copied * MAX_SCORE / max_size` (MAX_SCORE == 60000) with integer
4283    // truncation, then reports the similarity index as `score * 100 / MAX_SCORE`,
4284    // truncated again. This two-step truncation -- *not* a single rounded
4285    // `common * 100 / max_size` -- is what yields git's exact percentages: e.g.
4286    // common=4, max_size=6 gives 4*60000/6=40000 then 40000*100/60000=66 (git's
4287    // `R066`), whereas a rounded single step would give 67.
4288    const MAX_SCORE: u64 = 60000;
4289    let internal = (common as u64 * MAX_SCORE) / max_size as u64;
4290    let score = internal * 100 / MAX_SCORE;
4291    score.min(100) as u8
4292}
4293
4294/// The basename of a slash-separated path: the portion after the last `/`
4295/// (git's `get_basename`).
4296pub fn path_basename(path: &[u8]) -> &[u8] {
4297    match path.iter().rposition(|&byte| byte == b'/') {
4298        Some(slash) => &path[slash + 1..],
4299        None => path,
4300    }
4301}
4302
4303/// The stricter score a basename match must reach: git's `min_basename_score`
4304/// with the default `GIT_BASENAME_FACTOR` of 0.5, i.e. halfway between the
4305/// rename threshold and 100%. (For the default 50% threshold this is 75%.)
4306pub fn basename_min_score(threshold: u8) -> u8 {
4307    let threshold = threshold.min(100);
4308    threshold + (100 - threshold) / 2
4309}
4310
4311/// git's `find_basename_matches`: among the still-unmatched rename sources and
4312/// destinations, pair those whose basename is UNIQUE on *both* sides and whose
4313/// similarity meets [`basename_min_score`]. Returns the `(src_local, dst_local,
4314/// score)` pairings to apply *before* the full O(n·m) similarity matrix, so a
4315/// same-basename rename wins over a globally-more-similar different-basename
4316/// candidate (diffcore-rename.c).
4317///
4318/// `src_paths`/`dst_paths` are the candidate paths, indexed in parallel with the
4319/// `src_used`/`dst_used` flags (entries already consumed by exact-OID matching).
4320/// `similarity(src_local, dst_local)` returns the blob similarity for a pair, or
4321/// `None` when a blob is unreadable / ineligible. Only unique basenames are
4322/// considered: git's plain-diff path has no directory-rename fallback, so an
4323/// ambiguous basename is skipped entirely.
4324pub fn basename_rename_matches(
4325    src_paths: &[&[u8]],
4326    dst_paths: &[&[u8]],
4327    src_used: &[bool],
4328    dst_used: &[bool],
4329    threshold: u8,
4330    mut similarity: impl FnMut(usize, usize) -> Option<u8>,
4331) -> Vec<(usize, usize, u8)> {
4332    let min_score = basename_min_score(threshold);
4333    // basename -> Some(unique local index), or None once a second candidate with
4334    // the same basename appears (ambiguous).
4335    let mut src_by_base: HashMap<&[u8], Option<usize>> = HashMap::new();
4336    for (si, path) in src_paths.iter().enumerate() {
4337        if src_used.get(si).copied().unwrap_or(false) {
4338            continue;
4339        }
4340        src_by_base
4341            .entry(path_basename(path))
4342            .and_modify(|slot| *slot = None)
4343            .or_insert(Some(si));
4344    }
4345    let mut dst_by_base: HashMap<&[u8], Option<usize>> = HashMap::new();
4346    for (di, path) in dst_paths.iter().enumerate() {
4347        if dst_used.get(di).copied().unwrap_or(false) {
4348            continue;
4349        }
4350        dst_by_base
4351            .entry(path_basename(path))
4352            .and_modify(|slot| *slot = None)
4353            .or_insert(Some(di));
4354    }
4355    let mut matches = Vec::new();
4356    let mut dst_taken = vec![false; dst_paths.len()];
4357    for (si, path) in src_paths.iter().enumerate() {
4358        if src_used.get(si).copied().unwrap_or(false) {
4359            continue;
4360        }
4361        let base = path_basename(path);
4362        // Both basenames must be unique among the remaining candidates.
4363        let Some(Some(src_idx)) = src_by_base.get(base).copied() else {
4364            continue;
4365        };
4366        if src_idx != si {
4367            continue;
4368        }
4369        let Some(Some(dst_idx)) = dst_by_base.get(base).copied() else {
4370            continue;
4371        };
4372        if dst_used.get(dst_idx).copied().unwrap_or(false) || dst_taken[dst_idx] {
4373            continue;
4374        }
4375        let Some(score) = similarity(si, dst_idx) else {
4376            continue;
4377        };
4378        if score < min_score {
4379            continue;
4380        }
4381        dst_taken[dst_idx] = true;
4382        matches.push((si, dst_idx, score));
4383    }
4384    matches
4385}
4386
4387/// Break `data` into spans and return, per span hash, the total number of bytes
4388/// covered by spans with that hash. Spans end at a newline (inclusive) or once
4389/// they reach [`MAX_SPAN_BYTES`] bytes — exactly git's `hash_chars()` loop.
4390///
4391/// The returned map is `hash -> total_span_bytes`. Summing all values yields
4392/// `data.len()`, so the byte accounting is exact.
4393fn span_hash_counts(data: &[u8], is_text: bool) -> BTreeMap<u64, usize> {
4394    let mut counts: BTreeMap<u64, usize> = BTreeMap::new();
4395    let mut idx = 0usize;
4396    let len = data.len();
4397    while idx < len {
4398        // Roll a hash over the bytes of this span. The mixing mirrors git's
4399        // two-accumulator scheme from `diffcore-delta.c`; the exact constants do
4400        // not matter for correctness (any good per-span hash works), only that
4401        // identical spans collide and distinct spans rarely do.
4402        let mut accum1: u32 = 0;
4403        let mut accum2: u32 = 0;
4404        let mut span_len = 0usize;
4405        loop {
4406            let c = data[idx] as u32;
4407            idx += 1;
4408            // Ignore CR in a CRLF sequence for text blobs, so a file that only
4409            // differs by LF<->CRLF is still scored as (near-)identical — git's
4410            // `hash_chars()` does the same, which is what makes a CRLF-only
4411            // rename detectable.
4412            if is_text && c == u32::from(b'\r') && idx < len && data[idx] == b'\n' {
4413                continue;
4414            }
4415            span_len += 1;
4416            accum1 = (accum1 << 7) ^ (accum2 >> 25);
4417            accum2 = (accum2 << 7) ^ (accum1 >> 25);
4418            accum1 = accum1.wrapping_add(c);
4419            let newline = c == u32::from(b'\n');
4420            if span_len >= MAX_SPAN_BYTES || newline || idx >= len {
4421                break;
4422            }
4423        }
4424        // Fold the two accumulators (and the span length) into one 64-bit key.
4425        // Including the length keeps spans of different lengths from colliding
4426        // when their rolling-hash states happen to coincide.
4427        let hash = ((accum1 as u64) << 32) ^ (accum2 as u64) ^ ((span_len as u64) << 1);
4428        *counts.entry(hash).or_insert(0) += span_len;
4429    }
4430    counts
4431}
4432
4433/// Sum, over every hash present in both maps, the smaller of the two byte
4434/// counts. This is git's `src_copied`: the number of bytes that appear on both
4435/// sides (counting multiplicity via the per-hash byte totals).
4436/// git `diffcore_count_changes()`: span-hash byte accounting between two
4437/// blobs. Returns `(src_copied, literal_added)` — the bytes of `src` that
4438/// survive into `dst`, and the bytes of `dst` not accounted for by `src`.
4439/// `--dirstat`'s default "changes" damage is
4440/// `(src.len() - src_copied) + literal_added`.
4441pub fn count_changes(src: &[u8], dst: &[u8]) -> (usize, usize) {
4442    let src_counts = span_hash_counts(src, blob_is_text(src));
4443    let dst_counts = span_hash_counts(dst, blob_is_text(dst));
4444    let copied = common_span_bytes(&src_counts, &dst_counts);
4445    (copied, dst.len() - copied)
4446}
4447
4448/// Whether a blob is treated as text for span hashing (git's
4449/// `diff_filespec_is_binary` / `buffer_is_binary`): a NUL byte within the first
4450/// 8000 bytes marks it binary, in which case CRs are hashed literally.
4451fn blob_is_text(data: &[u8]) -> bool {
4452    const FIRST_FEW_BYTES: usize = 8000;
4453    !data.iter().take(FIRST_FEW_BYTES).any(|&byte| byte == 0)
4454}
4455
4456fn common_span_bytes(src: &BTreeMap<u64, usize>, dst: &BTreeMap<u64, usize>) -> usize {
4457    let mut common = 0usize;
4458    // Iterate the smaller map for a few less lookups.
4459    let (small, large) = if src.len() <= dst.len() {
4460        (src, dst)
4461    } else {
4462        (dst, src)
4463    };
4464    for (hash, small_bytes) in small {
4465        if let Some(large_bytes) = large.get(hash) {
4466            common += (*small_bytes).min(*large_bytes);
4467        }
4468    }
4469    common
4470}
4471
4472fn diff_entry_sort_path(entry: &NameStatusEntry) -> &[u8] {
4473    // git's diffcore re-inserts rename/copy pairs at their *destination*'s
4474    // position, so the queue (raw, numstat, stat, ...) sorts by the new path.
4475    entry.path.as_bytes()
4476}
4477
4478fn mark_unstaged_worktree_oids_unresolved(
4479    changes: Vec<NameStatusEntry>,
4480    index_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
4481    worktree_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
4482) -> Vec<NameStatusEntry> {
4483    changes
4484        .into_iter()
4485        .map(|mut entry| {
4486            let worktree_entry = worktree_entries.get(entry.path.as_bytes());
4487            if worktree_entry != index_entries.get(entry.path.as_bytes()) {
4488                entry.new_oid = None;
4489            }
4490            entry
4491        })
4492        .collect()
4493}
4494
4495#[derive(Debug, Clone, PartialEq, Eq)]
4496struct TrackedEntry {
4497    mode: u32,
4498    oid: ObjectId,
4499}
4500
4501/// A path-keyed map of tracked entries: one flattened side of a tree (or index/
4502/// worktree) snapshot.
4503type TrackedEntryMap = BTreeMap<Vec<u8>, TrackedEntry>;
4504
4505/// The `(left, right)` sides produced by a tree-vs-tree comparison.
4506type TrackedEntryPair = (TrackedEntryMap, TrackedEntryMap);
4507
4508struct IndexSnapshot {
4509    entries: BTreeMap<Vec<u8>, TrackedEntry>,
4510    stat_cache: IndexStatCache,
4511    missing_skip_worktree_entries: BTreeMap<Vec<u8>, TrackedEntry>,
4512    present_skip_worktree_entries: BTreeMap<Vec<u8>, TrackedEntry>,
4513}
4514
4515fn read_index_entries(
4516    git_dir: &Path,
4517    format: ObjectFormat,
4518) -> Result<BTreeMap<Vec<u8>, TrackedEntry>> {
4519    let index_path = sley_index::repository_index_path(git_dir);
4520    if !index_path.exists() {
4521        return Ok(BTreeMap::new());
4522    }
4523    let index = expand_sparse_index_for_worktree_diff(
4524        sley_index::read_repository_index(git_dir, format)?,
4525        git_dir,
4526        format,
4527    )?;
4528    Ok(index
4529        .entries
4530        .into_iter()
4531        .filter(|entry| entry.stage() == sley_index::Stage::Normal && !entry.is_intent_to_add())
4532        .map(|entry| {
4533            (
4534                entry.path.into_bytes(),
4535                TrackedEntry {
4536                    mode: entry.mode,
4537                    oid: entry.oid,
4538                },
4539            )
4540        })
4541        .collect())
4542}
4543
4544/// Collect the set of stage-0 paths flagged intent-to-add (`git add -N`) in the
4545/// index. These diff as new files rather than as modifications of their recorded
4546/// empty-blob id.
4547fn read_intent_to_add_paths(
4548    git_dir: &Path,
4549    format: ObjectFormat,
4550) -> Result<std::collections::HashSet<Vec<u8>>> {
4551    let index_path = sley_index::repository_index_path(git_dir);
4552    if !index_path.exists() {
4553        return Ok(std::collections::HashSet::new());
4554    }
4555    let index = expand_sparse_index_for_worktree_diff(
4556        sley_index::read_repository_index(git_dir, format)?,
4557        git_dir,
4558        format,
4559    )?;
4560    Ok(index
4561        .entries
4562        .iter()
4563        .filter(|entry| entry.stage() == sley_index::Stage::Normal && entry.is_intent_to_add())
4564        .map(|entry| entry.path.as_bytes().to_vec())
4565        .collect())
4566}
4567
4568fn read_index_snapshot(git_dir: &Path, format: ObjectFormat) -> Result<IndexSnapshot> {
4569    let index_path = sley_index::repository_index_path(git_dir);
4570    let index_metadata = match fs::metadata(&index_path) {
4571        Ok(metadata) => metadata,
4572        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
4573            return Ok(IndexSnapshot {
4574                entries: BTreeMap::new(),
4575                stat_cache: IndexStatCache::default(),
4576                missing_skip_worktree_entries: BTreeMap::new(),
4577                present_skip_worktree_entries: BTreeMap::new(),
4578            });
4579        }
4580        Err(err) => return Err(err.into()),
4581    };
4582    let raw_index = sley_index::read_repository_index(git_dir, format)?;
4583    let sparse_checkout_active = raw_index.is_sparse()
4584        || raw_index
4585            .entries
4586            .iter()
4587            .any(|entry| entry.mode == sley_index::SPARSE_DIR_MODE && entry.is_skip_worktree())
4588        || sparse_checkout_config_enabled(git_dir);
4589    let index = expand_sparse_index_for_worktree_diff(raw_index, git_dir, format)?;
4590    let stat_cache =
4591        IndexStatCache::from_index_mtime(&index, sley_index::file_mtime_parts(&index_metadata));
4592    let mut entries = BTreeMap::new();
4593    let mut missing_skip_worktree_entries = BTreeMap::new();
4594    let mut present_skip_worktree_entries = BTreeMap::new();
4595    for entry in index.entries {
4596        let is_skip_worktree = entry.stage() == sley_index::Stage::Normal
4597            && entry.is_skip_worktree()
4598            && !entry.is_intent_to_add();
4599        let path = entry.path.into_bytes();
4600        let tracked = TrackedEntry {
4601            mode: entry.mode,
4602            oid: entry.oid,
4603        };
4604        if is_skip_worktree {
4605            missing_skip_worktree_entries.insert(path.clone(), tracked.clone());
4606            if !sparse_checkout_active {
4607                present_skip_worktree_entries.insert(path.clone(), tracked.clone());
4608            }
4609        }
4610        entries.insert(path, tracked);
4611    }
4612    Ok(IndexSnapshot {
4613        entries,
4614        stat_cache,
4615        missing_skip_worktree_entries,
4616        present_skip_worktree_entries,
4617    })
4618}
4619
4620fn sparse_checkout_config_enabled(git_dir: &Path) -> bool {
4621    sley_config::GitConfig::read(git_dir.join("config"))
4622        .ok()
4623        .and_then(|config| config.get_bool("core", None, "sparseCheckout"))
4624        == Some(true)
4625        || sley_config::GitConfig::read(git_dir.join("config.worktree"))
4626            .ok()
4627            .and_then(|config| config.get_bool("core", None, "sparseCheckout"))
4628            == Some(true)
4629}
4630
4631trait WorktreeIndexEntry {
4632    fn git_path(&self) -> &[u8];
4633    fn stage(&self) -> sley_index::Stage;
4634    fn mode(&self) -> u32;
4635    fn oid(&self) -> ObjectId;
4636    fn size(&self) -> u32;
4637    fn is_intent_to_add(&self) -> bool;
4638    fn is_skip_worktree(&self) -> bool;
4639    fn reusable_with(&self, stat_cache: &IndexStatCache, metadata: &fs::Metadata) -> bool;
4640}
4641
4642impl WorktreeIndexEntry for sley_index::IndexEntry {
4643    fn git_path(&self) -> &[u8] {
4644        self.path.as_bytes()
4645    }
4646
4647    fn stage(&self) -> sley_index::Stage {
4648        sley_index::IndexEntry::stage(self)
4649    }
4650
4651    fn mode(&self) -> u32 {
4652        self.mode
4653    }
4654
4655    fn oid(&self) -> ObjectId {
4656        self.oid
4657    }
4658
4659    fn size(&self) -> u32 {
4660        self.size
4661    }
4662
4663    fn is_intent_to_add(&self) -> bool {
4664        sley_index::IndexEntry::is_intent_to_add(self)
4665    }
4666
4667    fn is_skip_worktree(&self) -> bool {
4668        sley_index::IndexEntry::is_skip_worktree(self)
4669    }
4670
4671    fn reusable_with(&self, stat_cache: &IndexStatCache, metadata: &fs::Metadata) -> bool {
4672        stat_cache.reusable_index_entry(self, metadata).is_some()
4673    }
4674}
4675
4676impl WorktreeIndexEntry for sley_index::IndexEntryRef<'_> {
4677    fn git_path(&self) -> &[u8] {
4678        self.path
4679    }
4680
4681    fn stage(&self) -> sley_index::Stage {
4682        sley_index::IndexEntryRef::stage(self)
4683    }
4684
4685    fn mode(&self) -> u32 {
4686        self.mode
4687    }
4688
4689    fn oid(&self) -> ObjectId {
4690        self.oid
4691    }
4692
4693    fn size(&self) -> u32 {
4694        self.size
4695    }
4696
4697    fn is_intent_to_add(&self) -> bool {
4698        sley_index::IndexEntryRef::is_intent_to_add(self)
4699    }
4700
4701    fn is_skip_worktree(&self) -> bool {
4702        sley_index::IndexEntryRef::is_skip_worktree(self)
4703    }
4704
4705    fn reusable_with(&self, stat_cache: &IndexStatCache, metadata: &fs::Metadata) -> bool {
4706        stat_cache.reusable_index_entry_ref(self, metadata)
4707    }
4708}
4709
4710fn tracked_entry_from_index(entry: &impl WorktreeIndexEntry) -> TrackedEntry {
4711    TrackedEntry {
4712        mode: entry.mode(),
4713        oid: entry.oid(),
4714    }
4715}
4716
4717fn head_tree_entries(
4718    git_dir: &Path,
4719    format: ObjectFormat,
4720    db: &FileObjectDatabase,
4721) -> Result<BTreeMap<Vec<u8>, TrackedEntry>> {
4722    let refs = FileRefStore::new(git_dir, format);
4723    let Some(head) = refs.read_ref("HEAD")? else {
4724        return Ok(BTreeMap::new());
4725    };
4726    let commit_oid = match head {
4727        RefTarget::Direct(oid) => Some(oid),
4728        RefTarget::Symbolic(name) => match refs.read_ref(&name)? {
4729            Some(RefTarget::Direct(oid)) => Some(oid),
4730            _ => None,
4731        },
4732    };
4733    let Some(commit_oid) = commit_oid else {
4734        return Ok(BTreeMap::new());
4735    };
4736    let object = db.read_object(&commit_oid)?;
4737    if object.object_type != ObjectType::Commit {
4738        return Err(GitError::InvalidObject(format!(
4739            "HEAD {commit_oid} is not a commit"
4740        )));
4741    }
4742    let commit = Commit::parse_ref(format, &object.body)?;
4743    let mut entries = BTreeMap::new();
4744    collect_tree_entries(db, format, &commit.tree, Vec::new(), &mut entries)?;
4745    Ok(entries)
4746}
4747
4748/// Flatten `tree_oid` into `entries` (keyed by `prefix`-rooted full paths),
4749/// adapting the canonical [`flatten_tree`] tuples into [`TrackedEntry`].
4750///
4751/// `flatten_tree` flattens from an empty prefix; each of its paths is rejoined
4752/// under `prefix` with [`join_tree_path`], reproducing the recursive
4753/// prefix-building this helper previously did inline. Used by the full
4754/// (non-pruned) flatten paths: `--find-copies-harder` and the changed-subtree
4755/// add/delete sides of the simultaneous diff walk.
4756fn collect_tree_entries(
4757    db: &FileObjectDatabase,
4758    format: ObjectFormat,
4759    tree_oid: &ObjectId,
4760    prefix: Vec<u8>,
4761    entries: &mut BTreeMap<Vec<u8>, TrackedEntry>,
4762) -> Result<()> {
4763    for (rel_path, (mode, oid)) in flatten_tree(db, format, tree_oid)? {
4764        let path = join_tree_path(&prefix, &rel_path);
4765        entries.insert(path, TrackedEntry { mode, oid });
4766    }
4767    Ok(())
4768}
4769
4770/// Git's mode value for a subtree (directory) entry inside a tree object.
4771const TREE_ENTRY_MODE: u32 = 0o040000;
4772
4773/// Read `tree_oid` and parse it as a tree, erroring if the object is some other
4774/// type. Shared by the simultaneous tree-diff walk so both sides validate the
4775/// object type identically to [`collect_tree_entries`].
4776fn read_tree_object(
4777    db: &FileObjectDatabase,
4778    format: ObjectFormat,
4779    tree_oid: &ObjectId,
4780) -> Result<Tree> {
4781    let object = db.read_object(tree_oid)?;
4782    if object.object_type != ObjectType::Tree {
4783        return Err(GitError::InvalidObject(format!(
4784            "expected tree {tree_oid}, found {}",
4785            object.object_type.as_str()
4786        )));
4787    }
4788    Tree::parse(format, &object.body)
4789}
4790
4791/// Append `name` to `prefix` with a `/` separator (mirroring the path
4792/// construction in [`collect_tree_entries`]), returning the joined path.
4793fn join_tree_path(prefix: &[u8], name: &[u8]) -> Vec<u8> {
4794    let mut path = Vec::with_capacity(prefix.len() + 1 + name.len());
4795    path.extend_from_slice(prefix);
4796    if !path.is_empty() {
4797        path.push(b'/');
4798    }
4799    path.extend_from_slice(name);
4800    path
4801}
4802
4803/// Fully flatten both trees into independent `left`/`right` maps (every blob on
4804/// each side, no pruning). Used only on the `--find-copies-harder` path, where
4805/// copy detection may reach into otherwise-unchanged subtrees for a source.
4806fn collect_full_tree_pair(
4807    db: &FileObjectDatabase,
4808    format: ObjectFormat,
4809    left_tree: &ObjectId,
4810    right_tree: &ObjectId,
4811) -> Result<TrackedEntryPair> {
4812    let mut left = BTreeMap::new();
4813    collect_tree_entries(db, format, left_tree, Vec::new(), &mut left)?;
4814    let mut right = BTreeMap::new();
4815    collect_tree_entries(db, format, right_tree, Vec::new(), &mut right)?;
4816    Ok((left, right))
4817}
4818
4819/// Walk two trees *simultaneously*, collecting into `left` and `right` only the
4820/// blob entries that differ between the two sides — every entry that is present
4821/// and byte-identical (same mode + same OID) on both sides is omitted, and any
4822/// subtree whose OID is identical on both sides is skipped wholesale without
4823/// being read or recursed into. This is the core optimization git relies on to
4824/// make tree diffs cheap: equal subtrees are pruned in O(1).
4825///
4826/// The resulting `left`/`right` maps are exactly the subset of the fully
4827/// flattened maps (as produced by [`collect_tree_entries`]) restricted to the
4828/// paths that participate in an Added/Deleted/Modified change. Because
4829/// [`raw_name_status_changes`] emits nothing for a path that is identical on both
4830/// sides, diffing these pruned maps yields byte-identical name-status output to
4831/// diffing the full maps. (Callers that need the *complete* left map — i.e.
4832/// `--find-copies-harder`, where an unchanged file may be a copy source — must
4833/// still use [`collect_tree_entries`]; see the tree-diff entry points.)
4834fn changed_tree_entries(
4835    db: &FileObjectDatabase,
4836    format: ObjectFormat,
4837    left_tree: &ObjectId,
4838    right_tree: &ObjectId,
4839) -> Result<TrackedEntryPair> {
4840    let mut left = BTreeMap::new();
4841    let mut right = BTreeMap::new();
4842    // Identical root trees produce no changes at all and need not be read.
4843    if left_tree != right_tree {
4844        diff_tree_pair(
4845            db,
4846            format,
4847            left_tree,
4848            right_tree,
4849            &[],
4850            &mut left,
4851            &mut right,
4852        )?;
4853    }
4854    Ok((left, right))
4855}
4856
4857/// Recursively diff two subtrees rooted at `prefix`, appending differing blob
4858/// entries to `left` / `right`. Invariant: the two OIDs are already known to
4859/// differ (identical subtrees are pruned by the caller before recursing).
4860fn diff_tree_pair(
4861    db: &FileObjectDatabase,
4862    format: ObjectFormat,
4863    left_tree: &ObjectId,
4864    right_tree: &ObjectId,
4865    prefix: &[u8],
4866    left: &mut BTreeMap<Vec<u8>, TrackedEntry>,
4867    right: &mut BTreeMap<Vec<u8>, TrackedEntry>,
4868) -> Result<()> {
4869    let left_entries = read_tree_object(db, format, left_tree)?.entries;
4870    let right_entries = read_tree_object(db, format, right_tree)?.entries;
4871
4872    // Index the right side by name so the union of names can be walked without
4873    // relying on git's directory-aware entry ordering. (Iterating the union of
4874    // names, rather than a positional merge, keeps correctness independent of
4875    // entry order.)
4876    let mut right_by_name: HashMap<&[u8], &TreeEntry> = HashMap::with_capacity(right_entries.len());
4877    for entry in &right_entries {
4878        right_by_name.insert(entry.name.as_bytes(), entry);
4879    }
4880
4881    for left_entry in &left_entries {
4882        match right_by_name.remove(left_entry.name.as_bytes()) {
4883            Some(right_entry) => {
4884                merge_tree_entry(
4885                    db,
4886                    format,
4887                    prefix,
4888                    Some(left_entry),
4889                    Some(right_entry),
4890                    left,
4891                    right,
4892                )?;
4893            }
4894            None => {
4895                merge_tree_entry(db, format, prefix, Some(left_entry), None, left, right)?;
4896            }
4897        }
4898    }
4899    // Names only present on the right are pure additions.
4900    for right_entry in &right_entries {
4901        if right_by_name.contains_key(right_entry.name.as_bytes()) {
4902            merge_tree_entry(db, format, prefix, None, Some(right_entry), left, right)?;
4903        }
4904    }
4905    Ok(())
4906}
4907
4908/// Reconcile a single name that may appear on the left side, the right side, or
4909/// both, recording any resulting blob change(s) into `left` / `right`. This
4910/// reproduces exactly the union-of-flattened-maps semantics:
4911///
4912/// * tree vs tree with equal OID -> pruned (no read, no recursion);
4913/// * tree vs tree with differing OID -> recurse;
4914/// * blob vs blob, equal mode+OID -> unchanged, emitted nowhere;
4915/// * blob vs blob, differing mode or OID -> both sides recorded (a Modify);
4916/// * a tree on one side and a non-tree on the other (or a name present on only
4917///   one side) -> the flattened paths differ (`name/...` vs `name`), so the two
4918///   are unrelated: the tree side is flattened wholesale and the blob side is
4919///   recorded independently (an Add and/or a Delete).
4920fn merge_tree_entry(
4921    db: &FileObjectDatabase,
4922    format: ObjectFormat,
4923    prefix: &[u8],
4924    left_entry: Option<&TreeEntry>,
4925    right_entry: Option<&TreeEntry>,
4926    left: &mut BTreeMap<Vec<u8>, TrackedEntry>,
4927    right: &mut BTreeMap<Vec<u8>, TrackedEntry>,
4928) -> Result<()> {
4929    let left_is_tree = left_entry.is_some_and(|entry| entry.mode == TREE_ENTRY_MODE);
4930    let right_is_tree = right_entry.is_some_and(|entry| entry.mode == TREE_ENTRY_MODE);
4931
4932    if let (Some(left_entry), Some(right_entry)) = (left_entry, right_entry) {
4933        if left_is_tree && right_is_tree {
4934            // Two subtrees under the same name: prune if identical, else recurse.
4935            if left_entry.oid == right_entry.oid {
4936                return Ok(());
4937            }
4938            let path = join_tree_path(prefix, left_entry.name.as_bytes());
4939            return diff_tree_pair(
4940                db,
4941                format,
4942                &left_entry.oid,
4943                &right_entry.oid,
4944                &path,
4945                left,
4946                right,
4947            );
4948        }
4949        if !left_is_tree && !right_is_tree {
4950            // Two blobs under the same name. Identical mode+OID means unchanged
4951            // (nothing emitted); otherwise both sides are recorded so the diff
4952            // sees a Modify, matching the full-map `left != right` comparison.
4953            if left_entry.mode == right_entry.mode && left_entry.oid == right_entry.oid {
4954                return Ok(());
4955            }
4956            let path = join_tree_path(prefix, left_entry.name.as_bytes());
4957            left.insert(
4958                path.clone(),
4959                TrackedEntry {
4960                    mode: left_entry.mode,
4961                    oid: left_entry.oid,
4962                },
4963            );
4964            right.insert(
4965                path,
4966                TrackedEntry {
4967                    mode: right_entry.mode,
4968                    oid: right_entry.oid,
4969                },
4970            );
4971            return Ok(());
4972        }
4973        // Mixed: tree on one side, blob on the other. Their flattened paths
4974        // never collide, so handle each side as if the name existed only there.
4975    }
4976
4977    // Left side (if any): record as deletions.
4978    if let Some(left_entry) = left_entry {
4979        let path = join_tree_path(prefix, left_entry.name.as_bytes());
4980        if left_is_tree {
4981            collect_tree_entries(db, format, &left_entry.oid, path, left)?;
4982        } else {
4983            left.insert(
4984                path,
4985                TrackedEntry {
4986                    mode: left_entry.mode,
4987                    oid: left_entry.oid,
4988                },
4989            );
4990        }
4991    }
4992    // Right side (if any): record as additions.
4993    if let Some(right_entry) = right_entry {
4994        let path = join_tree_path(prefix, right_entry.name.as_bytes());
4995        if right_is_tree {
4996            collect_tree_entries(db, format, &right_entry.oid, path, right)?;
4997        } else {
4998            right.insert(
4999                path,
5000                TrackedEntry {
5001                    mode: right_entry.mode,
5002                    oid: right_entry.oid,
5003                },
5004            );
5005        }
5006    }
5007    Ok(())
5008}
5009
5010fn index_gitlinks(index: &BTreeMap<Vec<u8>, TrackedEntry>) -> BTreeMap<Vec<u8>, ObjectId> {
5011    index
5012        .iter()
5013        .filter(|(_, entry)| sley_index::is_gitlink(entry.mode))
5014        .map(|(path, entry)| (path.clone(), entry.oid))
5015        .collect()
5016}
5017
5018fn candidate_path_set<'a>(candidate_paths: impl Iterator<Item = &'a Vec<u8>>) -> BTreeSet<Vec<u8>> {
5019    candidate_paths.cloned().collect()
5020}
5021
5022fn worktree_entries_for_path_set(
5023    worktree_root: &Path,
5024    format: ObjectFormat,
5025    candidates: &BTreeSet<Vec<u8>>,
5026    index_gitlinks: &BTreeMap<Vec<u8>, ObjectId>,
5027    stat_cache: Option<&IndexStatCache>,
5028    missing_skip_worktree_entries: Option<&BTreeMap<Vec<u8>, TrackedEntry>>,
5029    present_skip_worktree_entries: Option<&BTreeMap<Vec<u8>, TrackedEntry>>,
5030) -> Result<BTreeMap<Vec<u8>, TrackedEntry>> {
5031    worktree_entries_for_unique_paths(
5032        worktree_root,
5033        format,
5034        candidates.iter(),
5035        index_gitlinks,
5036        stat_cache,
5037        missing_skip_worktree_entries,
5038        present_skip_worktree_entries,
5039    )
5040}
5041
5042fn worktree_entries_for_unique_paths<'a>(
5043    worktree_root: &Path,
5044    format: ObjectFormat,
5045    candidates: impl Iterator<Item = &'a Vec<u8>>,
5046    index_gitlinks: &BTreeMap<Vec<u8>, ObjectId>,
5047    stat_cache: Option<&IndexStatCache>,
5048    missing_skip_worktree_entries: Option<&BTreeMap<Vec<u8>, TrackedEntry>>,
5049    present_skip_worktree_entries: Option<&BTreeMap<Vec<u8>, TrackedEntry>>,
5050) -> Result<BTreeMap<Vec<u8>, TrackedEntry>> {
5051    let mut entries = BTreeMap::new();
5052    for git_path in candidates {
5053        if let Some(entry) = worktree_entry_for_path(
5054            worktree_root,
5055            format,
5056            git_path,
5057            index_gitlinks,
5058            stat_cache,
5059            missing_skip_worktree_entries,
5060            present_skip_worktree_entries,
5061        )? {
5062            entries.insert(git_path.clone(), entry);
5063        }
5064    }
5065    Ok(entries)
5066}
5067
5068fn worktree_entry_for_path(
5069    worktree_root: &Path,
5070    format: ObjectFormat,
5071    git_path: &[u8],
5072    index_gitlinks: &BTreeMap<Vec<u8>, ObjectId>,
5073    stat_cache: Option<&IndexStatCache>,
5074    missing_skip_worktree_entries: Option<&BTreeMap<Vec<u8>, TrackedEntry>>,
5075    present_skip_worktree_entries: Option<&BTreeMap<Vec<u8>, TrackedEntry>>,
5076) -> Result<Option<TrackedEntry>> {
5077    if let Some(entry) = present_skip_worktree_entries.and_then(|entries| {
5078        entries
5079            .get(git_path)
5080            .cloned()
5081            .filter(|entry| !sley_index::is_gitlink(entry.mode))
5082    }) {
5083        return Ok(Some(entry));
5084    }
5085    let path = worktree_path_for_repo_path(worktree_root, git_path);
5086    let metadata = match fs::symlink_metadata(&path) {
5087        Ok(metadata) => metadata,
5088        Err(err) if is_missing_worktree_path_error(&err) => {
5089            return Ok(missing_skip_worktree_entries.and_then(|entries| {
5090                entries
5091                    .get(git_path)
5092                    .cloned()
5093                    .filter(|entry| !sley_index::is_gitlink(entry.mode))
5094            }));
5095        }
5096        Err(err) => return Err(GitError::Io(err.to_string())),
5097    };
5098    let file_type = metadata.file_type();
5099    if let Some(staged_oid) = index_gitlinks.get(git_path)
5100        && metadata.is_dir()
5101    {
5102        let oid = gitlink_head_oid(&path, format).unwrap_or(*staged_oid);
5103        return Ok(Some(TrackedEntry {
5104            mode: sley_index::GITLINK_MODE,
5105            oid,
5106        }));
5107    }
5108    if metadata.is_dir() {
5109        if let Some(oid) = gitlink_head_oid(&path, format) {
5110            return Ok(Some(TrackedEntry {
5111                mode: sley_index::GITLINK_MODE,
5112                oid,
5113            }));
5114        }
5115        return Ok(None);
5116    }
5117    if !(metadata.is_file() || file_type.is_symlink()) {
5118        return Ok(None);
5119    }
5120    if let Some(entry) = stat_cache.and_then(|cache| cache.reusable_entry(git_path, &metadata)) {
5121        return Ok(Some(tracked_entry_from_index(entry)));
5122    }
5123    Ok(Some(classify_worktree_entry(&path, &metadata, format)?))
5124}
5125
5126fn index_worktree_change_for_entry(
5127    path: &Path,
5128    format: ObjectFormat,
5129    index_entry: &impl WorktreeIndexEntry,
5130    stat_cache: &IndexStatCache,
5131    stat_clean_validator: &mut Option<&mut IndexWorktreeStatCleanValidator<'_>>,
5132) -> Result<Option<NameStatusEntry>> {
5133    let git_path = index_entry.git_path();
5134    let metadata = match fs::symlink_metadata(path) {
5135        Ok(metadata) => metadata,
5136        Err(err) if is_missing_worktree_path_error(&err) && index_entry.is_skip_worktree() => {
5137            return Ok(None);
5138        }
5139        Err(err) if is_missing_worktree_path_error(&err) => {
5140            return Ok(Some(index_worktree_deleted_entry(index_entry)));
5141        }
5142        Err(err) => return Err(GitError::Io(err.to_string())),
5143    };
5144    let file_type = metadata.file_type();
5145    let right = if metadata.is_dir() {
5146        if sley_index::is_gitlink(index_entry.mode()) {
5147            let oid = gitlink_head_oid(path, format).unwrap_or(index_entry.oid());
5148            Some(TrackedEntry {
5149                mode: sley_index::GITLINK_MODE,
5150                oid,
5151            })
5152        } else {
5153            gitlink_head_oid(path, format).map(|oid| TrackedEntry {
5154                mode: sley_index::GITLINK_MODE,
5155                oid,
5156            })
5157        }
5158    } else if metadata.is_file() || file_type.is_symlink() {
5159        let validated = if let Some(validator) = stat_clean_validator.as_deref_mut() {
5160            validator(
5161                IndexWorktreeValidationEntry {
5162                    path: index_entry.git_path(),
5163                    mode: index_entry.mode(),
5164                    oid: index_entry.oid(),
5165                    size: index_entry.size(),
5166                },
5167                path,
5168                &metadata,
5169            )?
5170        } else {
5171            None
5172        };
5173        if index_entry.reusable_with(stat_cache, &metadata) {
5174            return Ok(None);
5175        }
5176        Some(match validated {
5177            Some(entry) => TrackedEntry {
5178                mode: entry.mode,
5179                oid: entry.oid,
5180            },
5181            None => classify_worktree_entry(path, &metadata, format)?,
5182        })
5183    } else {
5184        None
5185    };
5186    let Some(right) = right else {
5187        return Ok(Some(index_worktree_deleted_entry(index_entry)));
5188    };
5189    let left = tracked_entry_from_index(index_entry);
5190    if right == left {
5191        return Ok(None);
5192    }
5193    Ok(Some(NameStatusEntry {
5194        status: modify_or_type_change(left.mode, right.mode),
5195        path: git_path.to_vec().into(),
5196        old_path: None,
5197        old_mode: Some(left.mode),
5198        new_mode: Some(right.mode),
5199        old_oid: Some(left.oid),
5200        new_oid: Some(right.oid),
5201    }))
5202}
5203
5204fn index_worktree_deleted_entry(index_entry: &impl WorktreeIndexEntry) -> NameStatusEntry {
5205    NameStatusEntry {
5206        status: NameStatus::Deleted,
5207        path: index_entry.git_path().to_vec().into(),
5208        old_path: None,
5209        old_mode: Some(index_entry.mode()),
5210        new_mode: None,
5211        old_oid: Some(index_entry.oid()),
5212        new_oid: None,
5213    }
5214}
5215
5216fn is_missing_worktree_path_error(err: &std::io::Error) -> bool {
5217    matches!(
5218        err.kind(),
5219        std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
5220    )
5221}
5222
5223fn worktree_blob_cache_for_path_set(
5224    worktree_root: &Path,
5225    left_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
5226    right_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
5227    candidate_paths: &BTreeSet<Vec<u8>>,
5228    odb_backed_worktree_entries: Option<&BTreeMap<Vec<u8>, TrackedEntry>>,
5229    options: RenameDetectionOptions,
5230) -> Result<HashMap<ObjectId, Vec<u8>>> {
5231    worktree_blob_cache_for_unique_paths(
5232        worktree_root,
5233        left_entries,
5234        right_entries,
5235        candidate_paths.iter(),
5236        odb_backed_worktree_entries,
5237        options,
5238    )
5239}
5240
5241fn worktree_blob_cache_for_unique_paths<'a>(
5242    worktree_root: &Path,
5243    left_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
5244    right_entries: &BTreeMap<Vec<u8>, TrackedEntry>,
5245    candidate_paths: impl Iterator<Item = &'a Vec<u8>>,
5246    odb_backed_worktree_entries: Option<&BTreeMap<Vec<u8>, TrackedEntry>>,
5247    options: RenameDetectionOptions,
5248) -> Result<HashMap<ObjectId, Vec<u8>>> {
5249    if !options.detect_inexact || !(options.base.detect_renames || options.base.detect_copies) {
5250        return Ok(HashMap::new());
5251    }
5252    let base = options.base;
5253    let mut changes =
5254        raw_name_status_changes_for_unique_paths(left_entries, right_entries, candidate_paths);
5255    if base.detect_renames {
5256        changes = detect_exact_renames(changes, left_entries, right_entries, base.rename_empty);
5257    }
5258    if base.detect_copies {
5259        changes = detect_exact_copies(
5260            changes,
5261            left_entries,
5262            right_entries,
5263            base.find_copies_harder,
5264            base.rename_empty,
5265        );
5266    }
5267    let has_rename_source = base.detect_renames
5268        && changes.iter().any(|entry| {
5269            entry.status == NameStatus::Deleted
5270                && entry
5271                    .old_oid
5272                    .as_ref()
5273                    .is_some_and(|oid| base.rename_empty || !is_empty_blob_oid(oid))
5274        });
5275    let has_copy_source = base.detect_copies
5276        && (base.find_copies_harder
5277            || changes
5278                .iter()
5279                .any(|entry| matches!(entry.status, NameStatus::Deleted | NameStatus::Modified)));
5280    if !has_rename_source && !has_copy_source {
5281        return Ok(HashMap::new());
5282    }
5283    let candidate_oids = changes
5284        .iter()
5285        .filter(|entry| entry.status == NameStatus::Added)
5286        .filter_map(|entry| entry.new_oid)
5287        .filter(|oid| base.rename_empty || !is_empty_blob_oid(oid))
5288        .collect::<BTreeSet<_>>();
5289    if candidate_oids.is_empty() {
5290        return Ok(HashMap::new());
5291    }
5292    let mut cache = HashMap::new();
5293    for (git_path, entry) in right_entries {
5294        if odb_backed_worktree_entries.is_some_and(|entries| entries.contains_key(git_path)) {
5295            continue;
5296        }
5297        if sley_index::is_gitlink(entry.mode) || !candidate_oids.contains(&entry.oid) {
5298            continue;
5299        }
5300        let path = worktree_path_for_repo_path(worktree_root, git_path);
5301        let body = if sley_index::is_symlink_mode(entry.mode) {
5302            symlink_target_bytes(&path)?
5303        } else {
5304            fs::read(&path)?
5305        };
5306        cache.entry(entry.oid).or_insert(body);
5307    }
5308    Ok(cache)
5309}
5310
5311/// A blob fetcher that consults an in-memory `oid -> bytes` cache first (e.g.
5312/// freshly-read worktree files) and falls back to the object database.
5313fn cache_or_odb_blob(
5314    cache: &HashMap<ObjectId, Vec<u8>>,
5315    db: &FileObjectDatabase,
5316    oid: &ObjectId,
5317) -> Option<Vec<u8>> {
5318    if let Some(bytes) = cache.get(oid) {
5319        return Some(bytes.clone());
5320    }
5321    read_blob_bytes(db, oid)
5322}
5323
5324#[cfg(unix)]
5325fn worktree_path_for_repo_path(worktree_root: &Path, path: &[u8]) -> PathBuf {
5326    use std::ffi::OsStr;
5327    use std::os::unix::ffi::OsStrExt;
5328
5329    let mut out = PathBuf::from(worktree_root);
5330    out.push(OsStr::from_bytes(path));
5331    out
5332}
5333
5334#[cfg(unix)]
5335fn worktree_path_for_repo_path_into(out: &mut PathBuf, worktree_root: &Path, path: &[u8]) {
5336    use std::ffi::OsStr;
5337    use std::os::unix::ffi::OsStrExt;
5338
5339    out.clear();
5340    out.push(worktree_root);
5341    out.push(OsStr::from_bytes(path));
5342}
5343
5344#[cfg(not(unix))]
5345fn worktree_path_for_repo_path(worktree_root: &Path, path: &[u8]) -> PathBuf {
5346    worktree_root.join(repo_path_to_path(path))
5347}
5348
5349#[cfg(not(unix))]
5350fn worktree_path_for_repo_path_into(out: &mut PathBuf, worktree_root: &Path, path: &[u8]) {
5351    out.clear();
5352    out.push(worktree_root);
5353    out.push(repo_path_to_path(path));
5354}
5355
5356#[cfg(not(unix))]
5357fn repo_path_to_path(path: &[u8]) -> PathBuf {
5358    let mut out = PathBuf::new();
5359    for component in String::from_utf8_lossy(path).split('/') {
5360        if !component.is_empty() {
5361            out.push(component);
5362        }
5363    }
5364    out
5365}
5366
5367#[cfg(unix)]
5368fn file_mode(metadata: &fs::Metadata) -> u32 {
5369    use std::os::unix::fs::PermissionsExt;
5370    if metadata.permissions().mode() & 0o111 != 0 {
5371        0o100755
5372    } else {
5373        0o100644
5374    }
5375}
5376
5377#[cfg(not(unix))]
5378fn file_mode(_metadata: &fs::Metadata) -> u32 {
5379    0o100644
5380}
5381
5382/// Read a symbolic link's target as git stores it: the raw target path bytes,
5383/// with no trailing newline. This is the "content" of a symlink blob (mode
5384/// `120000`) — git's `diff_populate_filespec` uses `strbuf_readlink` for a
5385/// worktree symlink rather than dereferencing it.
5386#[cfg(unix)]
5387pub fn symlink_target_bytes(path: &Path) -> Result<Vec<u8>> {
5388    use std::os::unix::ffi::OsStrExt;
5389    let target = fs::read_link(path)?;
5390    Ok(target.as_os_str().as_bytes().to_vec())
5391}
5392
5393/// See the unix variant: the raw symlink target bytes git stores as the blob.
5394#[cfg(not(unix))]
5395pub fn symlink_target_bytes(path: &Path) -> Result<Vec<u8>> {
5396    let target = fs::read_link(path)?;
5397    Ok(target.to_string_lossy().replace('\\', "/").into_bytes())
5398}
5399
5400// ---------------------------------------------------------------------------
5401// Unified / git diff patch parsing and application (engine for `git apply`/`git am`).
5402//
5403// Operates purely on in-memory byte buffers; the caller is responsible for
5404// reading/writing blobs from the working tree or the object database. The
5405// parser understands the textual format git produces (`diff --git`, `---`/`+++`
5406// file headers, `@@` hunk headers, context/`+`/`-` body lines, the
5407// `\ No newline at end of file` marker, `/dev/null` for added/deleted files,
5408// file mode headers, and `rename from`/`rename to` headers).
5409// ---------------------------------------------------------------------------
5410
5411/// A single line inside a hunk. The stored bytes never include the trailing
5412/// line terminator; whether the line is terminated by `\n` is tracked
5413/// separately on the [`Hunk`] (see [`Hunk::old_no_newline`] /
5414/// [`Hunk::new_no_newline`]) so the no-final-newline case can be reproduced
5415/// byte-for-byte.
5416#[derive(Debug, Clone, PartialEq, Eq)]
5417pub enum HunkLine {
5418    /// A line present in both the old and new versions.
5419    Context(Vec<u8>),
5420    /// A line added by the patch (present only in the new version).
5421    Insert(Vec<u8>),
5422    /// A line removed by the patch (present only in the old version).
5423    Delete(Vec<u8>),
5424}
5425
5426impl HunkLine {
5427    /// The line content, without any trailing newline.
5428    pub fn content(&self) -> &[u8] {
5429        match self {
5430            Self::Context(bytes) | Self::Insert(bytes) | Self::Delete(bytes) => bytes,
5431        }
5432    }
5433}
5434
5435/// A single `@@ -old_start,old_len +new_start,new_len @@` hunk.
5436///
5437/// `old_start` / `new_start` are 1-based line numbers as they appear in the
5438/// patch header. The `*_no_newline` flags record that the final line on that
5439/// side of the hunk is *not* terminated by a newline (the `\ No newline at end
5440/// of file` marker).
5441#[derive(Debug, Clone, PartialEq, Eq)]
5442pub struct Hunk {
5443    pub old_start: usize,
5444    pub old_len: usize,
5445    pub new_start: usize,
5446    pub new_len: usize,
5447    pub lines: Vec<HunkLine>,
5448    /// The last context/deleted line of the old file lacks a trailing newline.
5449    pub old_no_newline: bool,
5450    /// The last context/inserted line of the new file lacks a trailing newline.
5451    pub new_no_newline: bool,
5452    /// The 1-based line number (in the patch input) of each entry in `lines`,
5453    /// used by `git apply`'s whitespace-error reporting (git's `state->linenr`).
5454    /// Empty when the patch was not parsed from input (e.g. synthesised hunks).
5455    pub line_input_lines: Vec<usize>,
5456}
5457
5458/// A patch targeting a single file. Produced by [`parse_unified_patch`].
5459#[derive(Debug, Clone, PartialEq, Eq)]
5460pub struct FilePatch {
5461    /// Path on the `a/` (old) side, or `None` for a newly created file.
5462    pub old_path: Option<Vec<u8>>,
5463    /// Path on the `b/` (new) side, or `None` for a deleted file.
5464    pub new_path: Option<Vec<u8>>,
5465    /// Mode of the old file, when a mode header was present.
5466    pub old_mode: Option<u32>,
5467    /// Mode of the new file, when a mode header was present.
5468    pub new_mode: Option<u32>,
5469    pub hunks: Vec<Hunk>,
5470    /// The patch creates a new file (`--- /dev/null` / `new file mode`).
5471    pub is_new: bool,
5472    /// The patch deletes the file (`+++ /dev/null` / `deleted file mode`).
5473    pub is_delete: bool,
5474    /// The patch renames the file (`rename from`/`rename to`).
5475    pub is_rename: bool,
5476    /// The patch copies the file (`copy from`/`copy to`).
5477    pub is_copy: bool,
5478    /// Similarity score from `similarity index N%`, used for rename/copy summaries.
5479    pub similarity: Option<u8>,
5480    /// Dissimilarity score from `dissimilarity index N%`, used for rewrite summaries.
5481    pub dissimilarity: Option<u8>,
5482    /// Hex object id prefixes from the `index <old>..<new>[ mode]` line, if any.
5483    /// Carried verbatim (abbreviated or full); the binary apply and the `-3`
5484    /// fallback need these to resolve the pre-/post-image blobs.
5485    pub old_oid_hex: Option<Vec<u8>>,
5486    pub new_oid_hex: Option<Vec<u8>>,
5487    /// True when the patch is binary: either a `GIT binary patch` block (with
5488    /// `binary` payload) or a metadata-only `Binary files ... differ` line
5489    /// (no payload — the postimage must be reconstructed from the object store).
5490    pub is_binary: bool,
5491    /// The `GIT binary patch` payload, when this is a binary file patch. The
5492    /// fragment bytes are still zlib-deflated (the caller inflates them with
5493    /// the recorded original length), matching git's two-hunk forward/reverse
5494    /// layout.
5495    pub binary: Option<BinaryPatch>,
5496    /// True for git (`diff --git`) patches, whose names are relative to the
5497    /// repository top-level; false for traditional diffs, whose names are
5498    /// relative to the current directory (git's `is_toplevel_relative`). The
5499    /// `apply` cwd-prefix is only prepended to non-toplevel-relative patches.
5500    pub is_toplevel_relative: bool,
5501}
5502
5503/// A `GIT binary patch` payload: a mandatory forward hunk (preimage → postimage)
5504/// and an optional reverse hunk (postimage → preimage), mirroring git's
5505/// `parse_binary`.
5506#[derive(Debug, Clone, PartialEq, Eq)]
5507pub struct BinaryPatch {
5508    pub forward: BinaryHunk,
5509    pub reverse: Option<BinaryHunk>,
5510}
5511
5512/// One binary hunk: the encoding method and the still-deflated data, plus the
5513/// declared original (inflated) length.
5514#[derive(Debug, Clone, PartialEq, Eq)]
5515pub struct BinaryHunk {
5516    pub method: BinaryMethod,
5517    /// Length of the data *after* inflation (the `literal <N>` / `delta <N>`
5518    /// number). The caller inflates `deflated` to exactly this many bytes.
5519    pub origlen: usize,
5520    /// base85-decoded, still zlib-deflated bytes.
5521    pub deflated: Vec<u8>,
5522}
5523
5524/// How a binary hunk encodes the postimage.
5525#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5526pub enum BinaryMethod {
5527    /// The inflated bytes ARE the postimage (`literal <N>`).
5528    Literal,
5529    /// The inflated bytes are a git delta to apply to the preimage (`delta <N>`).
5530    Delta,
5531}
5532
5533/// Outcome of applying a [`FilePatch`] to a base buffer.
5534#[derive(Debug, Clone, PartialEq, Eq)]
5535pub enum ApplyOutcome {
5536    /// The patch applied cleanly; carries the resulting file bytes.
5537    Applied(Vec<u8>),
5538    /// At least one hunk's context/deleted lines did not match the base.
5539    Rejected,
5540}
5541
5542/// The minimum number of context lines git's `apply` insists on keeping when
5543/// it tries to fuzz a hunk into place — git's `apply_state.p_context`, which is
5544/// initialised to `UINT_MAX` (the `-C<n>` option lowers it). The fuzz loop in
5545/// `apply_one_fragment` stops the moment both leading and trailing context have
5546/// been reduced to this floor; with the default `UINT_MAX` floor that test is
5547/// already satisfied on the first failure, so **the default `git apply` / `git
5548/// am` path does no context fuzz and no begin/end relaxation at all** — a hunk
5549/// whose full preimage does not match at a valid position is simply rejected.
5550/// We keep the floor configurable so the structure mirrors git's, but the
5551/// shared apply engine only ever runs with the default.
5552const MIN_FUZZ_CONTEXT: usize = usize::MAX;
5553
5554/// Parse a unified/git diff into one [`FilePatch`] per file it touches.
5555///
5556/// The parser is intentionally lenient about leading commentary (commit
5557/// messages, `index <oid>..<oid>` lines, etc.): anything that is not part of a
5558/// recognised header or hunk body is skipped. It errors only on structurally
5559/// invalid hunks (bad `@@` headers, body lines that overflow the declared hunk
5560/// counts, or hunk bodies that appear with no preceding file header).
5561pub fn parse_unified_patch(input: &[u8]) -> Result<Vec<FilePatch>> {
5562    parse_unified_patch_with_recount(input, false)
5563}
5564
5565/// Parse a unified/git diff, optionally ignoring hunk header line counts and
5566/// recounting them from the hunk body. This mirrors `git apply --recount`.
5567pub fn parse_unified_patch_with_recount(input: &[u8], recount: bool) -> Result<Vec<FilePatch>> {
5568    parse_unified_patch_with_options(input, recount, &PatchPathOptions::default())
5569}
5570
5571/// Path-resolution options for [`parse_unified_patch_with_options`], mirroring
5572/// `git apply`'s `-p<n>` strip (`p_value`) and `--directory=<root>` prefix.
5573#[derive(Clone)]
5574pub struct PatchPathOptions {
5575    /// Number of leading path components to strip (`-p<n>`); default 1.
5576    pub p_value: usize,
5577    /// Whether `p_value` was given explicitly. When false, traditional (non-git)
5578    /// diffs guess it from the `---`/`+++` lines.
5579    pub p_value_known: bool,
5580    /// `--directory=<dir>` root, normalised with a trailing slash (or empty).
5581    pub root: Vec<u8>,
5582    /// The cwd prefix (`state->prefix`): the current directory relative to the
5583    /// work tree, with a trailing slash (empty at the top level). Used only to
5584    /// guess `-p<n>` for traditional patches run from a subdirectory; the prefix
5585    /// itself is prepended to names by the caller, not here.
5586    pub prefix: Vec<u8>,
5587}
5588
5589impl Default for PatchPathOptions {
5590    fn default() -> Self {
5591        PatchPathOptions {
5592            p_value: 1,
5593            p_value_known: false,
5594            root: Vec::new(),
5595            prefix: Vec::new(),
5596        }
5597    }
5598}
5599
5600/// Parse a unified/git diff, applying `-p<n>` strip and `--directory` prefix to
5601/// every resolved pathname exactly as `git apply` does.
5602pub fn parse_unified_patch_with_options(
5603    input: &[u8],
5604    recount: bool,
5605    options: &PatchPathOptions,
5606) -> Result<Vec<FilePatch>> {
5607    let lines = split_patch_lines(input);
5608    let mut parser = PatchParser {
5609        lines: &lines,
5610        index: 0,
5611        recount,
5612        p_value: options.p_value,
5613        p_value_known: options.p_value_known,
5614        root: options.root.clone(),
5615        prefix: options.prefix.clone(),
5616    };
5617    parser.parse()
5618}
5619
5620/// Apply a single-file patch to `base`, returning the patched bytes.
5621///
5622/// This mirrors git's `apply.c` (`apply_one_fragment` / `find_pos` /
5623/// `match_fragment`) for the default, no-whitespace-fuzz settings `git am`
5624/// and `git apply` use:
5625///
5626/// * Each hunk builds a *preimage* (context + deleted lines) and *postimage*
5627///   (context + inserted lines).
5628/// * A hunk anchored at the file start (`old_start <= 1`) must match the
5629///   beginning of the file (`match_beginning`); a hunk with no trailing context
5630///   must match the end of the file (`match_end`).
5631/// * The full preimage is matched byte-for-byte; the search starts at the
5632///   recorded position and ping-pongs outward across the whole image.
5633/// * Fuzz is applied *only* by dropping leading/trailing context lines (never
5634///   by jumping to a spurious context-only match); if no position matches even
5635///   after dropping all context, the hunk — and thus the whole patch — is
5636///   [`ApplyOutcome::Rejected`].
5637///
5638/// Rejecting (rather than spuriously applying at a wrong offset) is what lets
5639/// `git am -3` correctly fall back to its 3-way merge path.
5640///
5641/// New-file patches (empty/ignored base) and the no-final-newline case are
5642/// handled byte-accurately. Clean exact-position applies are byte-identical to
5643/// the previous behaviour.
5644pub fn apply_file_patch(base: &[u8], patch: &FilePatch) -> ApplyOutcome {
5645    apply_file_patch_with_options(base, patch, &ApplyFileOptions::default())
5646}
5647
5648/// Options for [`apply_file_patch_with_options`], mirroring the `git apply`
5649/// flags that change fragment placement.
5650#[derive(Clone, Default)]
5651pub struct ApplyFileOptions {
5652    /// `--unidiff-zero`: trust the line numbers of context-free hunks instead of
5653    /// forcing them to anchor at the file's beginning/end.
5654    pub unidiff_zero: bool,
5655}
5656
5657/// Reverse a file patch (`git apply -R`): swap the old/new names, modes, hunk
5658/// ranges, and no-newline flags, exchange add↔delete status, and flip every
5659/// `Insert`/`Delete` line. Applying the result undoes the original patch.
5660pub fn reverse_file_patch(patch: &FilePatch) -> FilePatch {
5661    let hunks = patch
5662        .hunks
5663        .iter()
5664        .map(|hunk| {
5665            let lines = hunk
5666                .lines
5667                .iter()
5668                .map(|line| match line {
5669                    HunkLine::Context(b) => HunkLine::Context(b.clone()),
5670                    HunkLine::Insert(b) => HunkLine::Delete(b.clone()),
5671                    HunkLine::Delete(b) => HunkLine::Insert(b.clone()),
5672                })
5673                .collect();
5674            Hunk {
5675                old_start: hunk.new_start,
5676                old_len: hunk.new_len,
5677                new_start: hunk.old_start,
5678                new_len: hunk.old_len,
5679                lines,
5680                old_no_newline: hunk.new_no_newline,
5681                new_no_newline: hunk.old_no_newline,
5682                // Reversal keeps the line order (only the +/- sense flips), so the
5683                // per-line patch-input line numbers carry over unchanged.
5684                line_input_lines: hunk.line_input_lines.clone(),
5685            }
5686        })
5687        .collect();
5688    // git's `reverse_patches` only swaps the modes when the patch actually
5689    // carries a new mode (a mode change) or is a deletion; a content-only patch
5690    // keeps its (old) mode so the type-mismatch check still compares against it.
5691    let (old_mode, new_mode) = if patch.new_mode.is_some() || patch.is_delete {
5692        (patch.new_mode, patch.old_mode)
5693    } else {
5694        (patch.old_mode, patch.new_mode)
5695    };
5696    FilePatch {
5697        old_path: patch.new_path.clone(),
5698        new_path: patch.old_path.clone(),
5699        old_mode,
5700        new_mode,
5701        hunks,
5702        is_new: patch.is_delete,
5703        is_delete: patch.is_new,
5704        is_rename: patch.is_rename,
5705        is_copy: patch.is_copy,
5706        similarity: patch.similarity,
5707        dissimilarity: patch.dissimilarity,
5708        // Swap the index OIDs so a reverse-applied binary patch resolves the
5709        // (formerly new) preimage and (formerly old) postimage correctly.
5710        old_oid_hex: patch.new_oid_hex.clone(),
5711        new_oid_hex: patch.old_oid_hex.clone(),
5712        is_binary: patch.is_binary,
5713        binary: patch.binary.as_ref().map(|binary| BinaryPatch {
5714            // `-R` swaps forward and reverse hunks (git's apply_in_reverse).
5715            forward: binary
5716                .reverse
5717                .clone()
5718                .unwrap_or_else(|| binary.forward.clone()),
5719            reverse: Some(binary.forward.clone()),
5720        }),
5721        is_toplevel_relative: patch.is_toplevel_relative,
5722    }
5723}
5724
5725/// Apply a single-file patch with explicit fragment-placement options.
5726pub fn apply_file_patch_with_options(
5727    base: &[u8],
5728    patch: &FilePatch,
5729    options: &ApplyFileOptions,
5730) -> ApplyOutcome {
5731    // A pure deletion with no hunks yields an empty file.
5732    if patch.is_delete && patch.hunks.is_empty() {
5733        return ApplyOutcome::Applied(Vec::new());
5734    }
5735    // A new file: the only sensible base is empty; ignore whatever was passed
5736    // and build the result from the inserted lines.
5737    let base_for_match: &[u8] = if patch.is_new { b"" } else { base };
5738
5739    // The "image" git mutates as each hunk applies. We splice in place so later
5740    // hunks see the effect of earlier ones (git carries the running offset for
5741    // the same reason).
5742    let mut image = split_blob_lines(base_for_match);
5743
5744    // git seeds the search for hunk N at `newpos-1` *plus* the offset earlier
5745    // hunks drifted by, so a uniform shift only costs the search once.
5746    let mut running_offset: isize = 0;
5747
5748    for hunk in &patch.hunks {
5749        match apply_one_hunk(&mut image, hunk, running_offset, options.unidiff_zero) {
5750            Some(drift) => running_offset += drift,
5751            None => return ApplyOutcome::Rejected,
5752        }
5753    }
5754
5755    ApplyOutcome::Applied(join_lines(&image))
5756}
5757
5758/// The outcome of a hunk-by-hunk apply (`git apply --reject`).
5759#[derive(Debug, Clone, PartialEq, Eq)]
5760pub struct RejectApply {
5761    /// The bytes after every hunk that applied (rejected hunks are skipped).
5762    pub content: Vec<u8>,
5763    /// Indices into `patch.hunks` of the hunks that did not apply.
5764    pub rejected: Vec<usize>,
5765}
5766
5767/// Apply a single-file patch hunk-by-hunk, collecting the hunks that do not
5768/// apply rather than rejecting the whole patch — `git apply --reject`.
5769///
5770/// Each hunk is tried independently against the running image; an applied hunk
5771/// contributes its offset to later hunks (git's `apply_fragments` carries the
5772/// running line shift), a rejected hunk is recorded and left out. The returned
5773/// `content` is the image after all applicable hunks; `rejected` lists the
5774/// 0-based indices of the hunks the caller must write to `<file>.rej`.
5775pub fn apply_file_patch_rejecting(
5776    base: &[u8],
5777    patch: &FilePatch,
5778    options: &ApplyFileOptions,
5779) -> RejectApply {
5780    if patch.is_delete && patch.hunks.is_empty() {
5781        return RejectApply {
5782            content: Vec::new(),
5783            rejected: Vec::new(),
5784        };
5785    }
5786    let base_for_match: &[u8] = if patch.is_new { b"" } else { base };
5787    let mut image = split_blob_lines(base_for_match);
5788    let mut running_offset: isize = 0;
5789    let mut rejected = Vec::new();
5790    for (index, hunk) in patch.hunks.iter().enumerate() {
5791        match apply_one_hunk(&mut image, hunk, running_offset, options.unidiff_zero) {
5792            Some(drift) => running_offset += drift,
5793            None => rejected.push(index),
5794        }
5795    }
5796    RejectApply {
5797        content: join_lines(&image),
5798        rejected,
5799    }
5800}
5801
5802/// Reconstruct the unified-diff text of one hunk for a `.rej` file. Mirrors the
5803/// raw fragment text git copies into `<file>.rej`: the `@@ -os[,oc] +ns[,nc] @@`
5804/// header (the `,1` count is omitted, matching git) followed by each line with
5805/// its ` `/`+`/`-` prefix, plus the `\ No newline at end of file` note where the
5806/// old/new side's final line is unterminated.
5807pub fn render_reject_hunk(hunk: &Hunk) -> Vec<u8> {
5808    fn range(start: usize, count: usize) -> String {
5809        if count == 1 {
5810            start.to_string()
5811        } else {
5812            format!("{start},{count}")
5813        }
5814    }
5815    let mut out = Vec::new();
5816    out.extend_from_slice(b"@@ -");
5817    out.extend_from_slice(range(hunk.old_start, hunk.old_len).as_bytes());
5818    out.extend_from_slice(b" +");
5819    out.extend_from_slice(range(hunk.new_start, hunk.new_len).as_bytes());
5820    out.extend_from_slice(b" @@\n");
5821    // The last old-side line is the last Context/Delete; the last new-side line
5822    // is the last Context/Insert. Their no-newline state drives the markers.
5823    let last_old = hunk
5824        .lines
5825        .iter()
5826        .rposition(|line| matches!(line, HunkLine::Context(_) | HunkLine::Delete(_)));
5827    let last_new = hunk
5828        .lines
5829        .iter()
5830        .rposition(|line| matches!(line, HunkLine::Context(_) | HunkLine::Insert(_)));
5831    for (index, line) in hunk.lines.iter().enumerate() {
5832        let (prefix, content) = match line {
5833            HunkLine::Context(bytes) => (b' ', bytes),
5834            HunkLine::Insert(bytes) => (b'+', bytes),
5835            HunkLine::Delete(bytes) => (b'-', bytes),
5836        };
5837        out.push(prefix);
5838        out.extend_from_slice(content);
5839        out.push(b'\n');
5840        let old_incomplete = hunk.old_no_newline && Some(index) == last_old;
5841        let new_incomplete = hunk.new_no_newline && Some(index) == last_new;
5842        if old_incomplete || new_incomplete {
5843            out.extend_from_slice(b"\\ No newline at end of file\n");
5844        }
5845    }
5846    out
5847}
5848
5849// ---------------------------------------------------------------------------
5850// Whitespace-aware apply (`git apply --whitespace=fix` / `--ignore-space-change`)
5851//
5852// A faithful port of git's `apply_one_fragment` matching path (`match_fragment`,
5853// `find_pos`, `line_by_line_fuzzy_match`, `update_pre_post_images`,
5854// `update_image`). It adds the matching concerns that need the whitespace rule —
5855// blank-at-EOF tolerance, whitespace-corrected / whitespace-ignoring context
5856// matching, and removal of newly-added blank lines at EOF — on top of the plain
5857// exact-match engine above. The patch's `+` lines are expected to already carry
5858// their whitespace fixes (the apply command's whitespace pass applies them);
5859// this routine fixes the *context* lines as part of matching.
5860// ---------------------------------------------------------------------------
5861
5862/// Options for [`apply_file_patch_ws`].
5863#[derive(Clone, Copy)]
5864pub struct WsApplyOptions {
5865    /// `--unidiff-zero`.
5866    pub unidiff_zero: bool,
5867    /// The per-path whitespace rule.
5868    pub ws_rule: ws::WsRule,
5869    /// `--whitespace=fix` (git's `correct_ws_error`).
5870    pub ws_fix: bool,
5871    /// `--ignore-space-change` / `--ignore-whitespace` (git's `ignore_ws_change`).
5872    pub ws_ignore_change: bool,
5873}
5874
5875/// Outcome of [`apply_file_patch_ws`].
5876pub enum WsApplyOutcome {
5877    /// Applied; carries the bytes and the count of blank lines removed at EOF.
5878    Applied {
5879        content: Vec<u8>,
5880        blank_at_eof_removed: usize,
5881    },
5882    /// At least one hunk could not be placed.
5883    Rejected,
5884}
5885
5886/// A pre/postimage line carrying git's `LINE_COMMON` flag (set for context lines,
5887/// clear for added/deleted lines).
5888#[derive(Clone)]
5889struct WsImageLine {
5890    content: Vec<u8>,
5891    no_newline: bool,
5892    common: bool,
5893}
5894
5895impl WsImageLine {
5896    fn bytes(&self) -> Vec<u8> {
5897        let mut out = self.content.clone();
5898        if !self.no_newline {
5899            out.push(b'\n');
5900        }
5901        out
5902    }
5903}
5904
5905fn line_bytes(line: &Line) -> Vec<u8> {
5906    let mut out = line.content.clone();
5907    if !line.no_newline {
5908        out.push(b'\n');
5909    }
5910    out
5911}
5912
5913/// Split ws-fixed line bytes back into a [`WsImageLine`] (content sans trailing
5914/// newline, plus the no-newline flag).
5915fn ws_line_from_bytes(bytes: Vec<u8>, common: bool) -> WsImageLine {
5916    if bytes.last() == Some(&b'\n') {
5917        WsImageLine {
5918            content: bytes[..bytes.len() - 1].to_vec(),
5919            no_newline: false,
5920            common,
5921        }
5922    } else {
5923        WsImageLine {
5924            content: bytes,
5925            no_newline: true,
5926            common,
5927        }
5928    }
5929}
5930
5931/// Whitespace-aware single-file apply — git's `apply_one_fragment` matching path.
5932pub fn apply_file_patch_ws(
5933    base: &[u8],
5934    patch: &FilePatch,
5935    opts: &WsApplyOptions,
5936) -> WsApplyOutcome {
5937    if patch.is_delete && patch.hunks.is_empty() {
5938        return WsApplyOutcome::Applied {
5939            content: Vec::new(),
5940            blank_at_eof_removed: 0,
5941        };
5942    }
5943    let base_for_match: &[u8] = if patch.is_new { b"" } else { base };
5944    let mut image = split_blob_lines(base_for_match);
5945    let mut running_offset: isize = 0;
5946    let mut blank_removed = 0usize;
5947    for hunk in &patch.hunks {
5948        match apply_one_fragment_ws(&mut image, hunk, running_offset, opts, &mut blank_removed) {
5949            Some(drift) => running_offset += drift,
5950            None => return WsApplyOutcome::Rejected,
5951        }
5952    }
5953    WsApplyOutcome::Applied {
5954        content: join_lines(&image),
5955        blank_at_eof_removed: blank_removed,
5956    }
5957}
5958
5959fn apply_one_fragment_ws(
5960    image: &mut Vec<Line>,
5961    hunk: &Hunk,
5962    running_offset: isize,
5963    opts: &WsApplyOptions,
5964    blank_removed: &mut usize,
5965) -> Option<isize> {
5966    let blank_eof = opts.ws_rule & ws::WS_BLANK_AT_EOF != 0;
5967    let mut preimage: Vec<WsImageLine> = Vec::new();
5968    let mut postimage: Vec<WsImageLine> = Vec::new();
5969    let mut leading = 0usize;
5970    let mut trailing = 0usize;
5971    let mut seen_change = false;
5972    // git's `new_blank_lines_at_end`: blank lines added at the end, where a blank
5973    // *context* line does not reset the run but a non-blank line does.
5974    let mut new_blank_lines_at_end = 0usize;
5975    for hl in &hunk.lines {
5976        let mut added_blank_line = false;
5977        let mut is_blank_context = false;
5978        match hl {
5979            HunkLine::Context(bytes) => {
5980                if blank_eof && ws::ws_blank_line(bytes) {
5981                    is_blank_context = true;
5982                }
5983                preimage.push(WsImageLine {
5984                    content: bytes.clone(),
5985                    no_newline: false,
5986                    common: true,
5987                });
5988                postimage.push(WsImageLine {
5989                    content: bytes.clone(),
5990                    no_newline: false,
5991                    common: true,
5992                });
5993                if !seen_change {
5994                    leading += 1;
5995                }
5996                trailing += 1;
5997            }
5998            HunkLine::Delete(bytes) => {
5999                preimage.push(WsImageLine {
6000                    content: bytes.clone(),
6001                    no_newline: false,
6002                    common: false,
6003                });
6004                seen_change = true;
6005                trailing = 0;
6006            }
6007            HunkLine::Insert(bytes) => {
6008                postimage.push(WsImageLine {
6009                    content: bytes.clone(),
6010                    no_newline: false,
6011                    common: false,
6012                });
6013                if blank_eof && ws::ws_blank_line(bytes) {
6014                    added_blank_line = true;
6015                }
6016                seen_change = true;
6017                trailing = 0;
6018            }
6019        }
6020        if added_blank_line {
6021            new_blank_lines_at_end += 1;
6022        } else if is_blank_context {
6023            // leave the running count alone
6024        } else {
6025            new_blank_lines_at_end = 0;
6026        }
6027    }
6028    if hunk.old_no_newline
6029        && let Some(last) = preimage.last_mut()
6030    {
6031        last.no_newline = true;
6032    }
6033    if hunk.new_no_newline
6034        && let Some(last) = postimage.last_mut()
6035    {
6036        last.no_newline = true;
6037    }
6038
6039    let mut match_beginning = hunk.old_start == 0 || (hunk.old_start == 1 && !opts.unidiff_zero);
6040    let mut match_end = !opts.unidiff_zero && trailing == 0;
6041
6042    let mut expected = if preimage.is_empty() {
6043        new_side_position(hunk, running_offset)
6044    } else {
6045        expected_position(hunk, running_offset)
6046    };
6047    let hunk_expected = expected;
6048    let mut leading_v = leading;
6049    let mut trailing_v = trailing;
6050
6051    let applied_pos = loop {
6052        if let Some(pos) = find_pos_ws(
6053            image,
6054            &mut preimage,
6055            &mut postimage,
6056            expected,
6057            opts,
6058            match_beginning,
6059            match_end,
6060        ) {
6061            break pos;
6062        }
6063        #[allow(clippy::absurd_extreme_comparisons)]
6064        if leading_v <= MIN_FUZZ_CONTEXT && trailing_v <= MIN_FUZZ_CONTEXT {
6065            return None;
6066        }
6067        if match_beginning || match_end {
6068            match_beginning = false;
6069            match_end = false;
6070            continue;
6071        }
6072        if leading_v >= trailing_v {
6073            preimage.remove(0);
6074            postimage.remove(0);
6075            expected -= 1;
6076            leading_v -= 1;
6077        }
6078        if trailing_v > leading_v {
6079            preimage.pop();
6080            postimage.pop();
6081            trailing_v -= 1;
6082        }
6083    };
6084
6085    // Remove the blank lines added at EOF when the hunk lands at (or beyond) the
6086    // end of the image — git's `--whitespace=fix` blank-at-EOF correction.
6087    if new_blank_lines_at_end > 0
6088        && preimage.len() + applied_pos >= image.len()
6089        && blank_eof
6090        && opts.ws_fix
6091    {
6092        for _ in 0..new_blank_lines_at_end {
6093            postimage.pop();
6094        }
6095        *blank_removed += new_blank_lines_at_end;
6096    }
6097
6098    // git's `update_image`: the preimage may extend beyond EOF, so only the part
6099    // that falls within the image is removed.
6100    let preimage_limit = preimage.len().min(image.len() - applied_pos);
6101    let replacement: Vec<Line> = postimage
6102        .iter()
6103        .map(|line| Line {
6104            content: line.content.clone(),
6105            no_newline: line.no_newline,
6106        })
6107        .collect();
6108    image.splice(applied_pos..applied_pos + preimage_limit, replacement);
6109    Some(applied_pos as isize - hunk_expected)
6110}
6111
6112/// Port of git's `find_pos`: ping-pong outward from `expected` calling
6113/// [`match_fragment_ws`] at each candidate line. On a match the preimage and the
6114/// common lines of the postimage may be rewritten in place (whitespace fix).
6115fn find_pos_ws(
6116    image: &[Line],
6117    preimage: &mut Vec<WsImageLine>,
6118    postimage: &mut Vec<WsImageLine>,
6119    expected: isize,
6120    opts: &WsApplyOptions,
6121    match_beginning: bool,
6122    match_end: bool,
6123) -> Option<usize> {
6124    let line_nr = image.len();
6125    let pre_nr = preimage.len();
6126    let mut line: isize = if match_beginning {
6127        0
6128    } else if match_end {
6129        line_nr as isize - pre_nr as isize
6130    } else {
6131        expected
6132    };
6133    if line < 0 {
6134        line = 0;
6135    }
6136    if line as usize > line_nr {
6137        line = line_nr as isize;
6138    }
6139    let start = line as usize;
6140    let mut backwards = start;
6141    let mut forwards = start;
6142    let mut current = start;
6143    let mut i: u64 = 0;
6144    loop {
6145        if match_fragment_ws(
6146            image,
6147            preimage,
6148            postimage,
6149            current,
6150            opts,
6151            match_beginning,
6152            match_end,
6153        ) {
6154            return Some(current);
6155        }
6156        loop {
6157            if backwards == 0 && forwards == line_nr {
6158                return None;
6159            }
6160            if i & 1 == 1 {
6161                if backwards == 0 {
6162                    i += 1;
6163                    continue;
6164                }
6165                backwards -= 1;
6166                current = backwards;
6167            } else {
6168                if forwards == line_nr {
6169                    i += 1;
6170                    continue;
6171                }
6172                forwards += 1;
6173                current = forwards;
6174            }
6175            break;
6176        }
6177        i += 1;
6178    }
6179}
6180
6181/// Port of git's `match_fragment`. Returns whether `preimage` matches `image` at
6182/// `current_lno`, trying (in order) an exact match, then — when `--whitespace=fix`
6183/// or `--ignore-space-change` is in effect — a whitespace-corrected or
6184/// whitespace-ignoring match, rewriting the preimage and the postimage's common
6185/// lines to the matched whitespace on success.
6186fn match_fragment_ws(
6187    image: &[Line],
6188    preimage: &mut Vec<WsImageLine>,
6189    postimage: &mut Vec<WsImageLine>,
6190    current_lno: usize,
6191    opts: &WsApplyOptions,
6192    match_beginning: bool,
6193    match_end: bool,
6194) -> bool {
6195    let blank_eof = opts.ws_rule & ws::WS_BLANK_AT_EOF != 0;
6196    let preimage_limit: usize;
6197    if preimage.len() + current_lno <= image.len() {
6198        preimage_limit = preimage.len();
6199        if match_end && (preimage.len() + current_lno != image.len()) {
6200            return false;
6201        }
6202    } else if opts.ws_fix && blank_eof {
6203        // The hunk extends beyond EOF and we are removing blank lines there; only
6204        // the in-image prefix must match, the rest of the preimage must be blank.
6205        preimage_limit = image.len() - current_lno;
6206    } else {
6207        return false;
6208    }
6209
6210    if match_beginning && current_lno != 0 {
6211        return false;
6212    }
6213
6214    if preimage_limit == preimage.len() {
6215        // Try an exact byte match of the whole preimage.
6216        let mut exact = true;
6217        if match_end && current_lno + preimage_limit != image.len() {
6218            exact = false;
6219        }
6220        if exact {
6221            for i in 0..preimage_limit {
6222                let img = &image[current_lno + i];
6223                let pre = &preimage[i];
6224                if img.content != pre.content || img.no_newline != pre.no_newline {
6225                    exact = false;
6226                    break;
6227                }
6228            }
6229        }
6230        if exact {
6231            return true;
6232        }
6233    } else {
6234        // The preimage extends beyond EOF: there must be at least one non-blank
6235        // context line within the in-image prefix.
6236        let mut all_blank = true;
6237        for line in preimage.iter().take(preimage_limit) {
6238            if !line.content.iter().all(|&b| ws::is_space(b)) {
6239                all_blank = false;
6240                break;
6241            }
6242        }
6243        if all_blank {
6244            return false;
6245        }
6246    }
6247
6248    // No exact match. Try fuzzy / whitespace-corrected matching.
6249    if opts.ws_ignore_change {
6250        return fuzzy_match_ws(image, preimage, postimage, current_lno, preimage_limit);
6251    }
6252    if !opts.ws_fix {
6253        return false;
6254    }
6255
6256    // Whitespace-corrected match: fix the in-image preimage lines and the target
6257    // lines, requiring equality; the beyond-EOF preimage lines must become blank.
6258    let mut fixed: Vec<WsImageLine> = Vec::with_capacity(preimage.len());
6259    for i in 0..preimage_limit {
6260        let fixed_pre = ws::ws_fix_bytes(&preimage[i].bytes(), opts.ws_rule);
6261        let fixed_tgt = ws::ws_fix_bytes(&line_bytes(&image[current_lno + i]), opts.ws_rule);
6262        if fixed_pre != fixed_tgt {
6263            return false;
6264        }
6265        fixed.push(ws_line_from_bytes(fixed_pre, preimage[i].common));
6266    }
6267    for line in preimage.iter().skip(preimage_limit) {
6268        let fixed_pre = ws::ws_fix_bytes(&line.bytes(), opts.ws_rule);
6269        if !fixed_pre.iter().all(|&b| ws::is_space(b)) {
6270            return false;
6271        }
6272        fixed.push(ws_line_from_bytes(fixed_pre, line.common));
6273    }
6274    update_pre_post_images_ws(preimage, postimage, fixed);
6275    true
6276}
6277
6278/// Port of git's `line_by_line_fuzzy_match` (the `--ignore-space-change` path):
6279/// compare each line ignoring whitespace runs; on success the matched lines use
6280/// the *target's* whitespace in-image and the *preimage's* whitespace beyond EOF.
6281fn fuzzy_match_ws(
6282    image: &[Line],
6283    preimage: &mut Vec<WsImageLine>,
6284    postimage: &mut Vec<WsImageLine>,
6285    current_lno: usize,
6286    preimage_limit: usize,
6287) -> bool {
6288    for i in 0..preimage_limit {
6289        if !fuzzy_matchlines(&line_bytes(&image[current_lno + i]), &preimage[i].bytes()) {
6290            return false;
6291        }
6292    }
6293    // The beyond-EOF preimage lines must be all whitespace.
6294    for line in preimage.iter().skip(preimage_limit) {
6295        if !line.bytes().iter().all(|&b| ws::is_space(b)) {
6296            return false;
6297        }
6298    }
6299    // Build the fixed preimage: in-image lines take the target's whitespace, the
6300    // beyond-EOF lines keep the preimage's whitespace.
6301    let mut fixed: Vec<WsImageLine> = Vec::with_capacity(preimage.len());
6302    for i in 0..preimage_limit {
6303        let img = &image[current_lno + i];
6304        fixed.push(WsImageLine {
6305            content: img.content.clone(),
6306            no_newline: img.no_newline,
6307            common: preimage[i].common,
6308        });
6309    }
6310    for line in preimage.iter().skip(preimage_limit) {
6311        fixed.push(line.clone());
6312    }
6313    update_pre_post_images_ws(preimage, postimage, fixed);
6314    true
6315}
6316
6317/// Port of git's `fuzzy_matchlines`: compare two lines ignoring whitespace
6318/// differences (any whitespace run matches any other; line endings are ignored).
6319fn fuzzy_matchlines(s1: &[u8], s2: &[u8]) -> bool {
6320    let trim = |s: &[u8]| {
6321        let mut end = s.len();
6322        while end > 0 && (s[end - 1] == b'\r' || s[end - 1] == b'\n') {
6323            end -= 1;
6324        }
6325        end
6326    };
6327    let end1 = trim(s1);
6328    let end2 = trim(s2);
6329    let (mut i, mut j) = (0usize, 0usize);
6330    while i < end1 && j < end2 {
6331        if ws::is_space(s1[i]) {
6332            if !ws::is_space(s2[j]) {
6333                return false;
6334            }
6335            while i < end1 && ws::is_space(s1[i]) {
6336                i += 1;
6337            }
6338            while j < end2 && ws::is_space(s2[j]) {
6339                j += 1;
6340            }
6341        } else if s1[i] != s2[j] {
6342            return false;
6343        } else {
6344            i += 1;
6345            j += 1;
6346        }
6347    }
6348    i == end1 && j == end2
6349}
6350
6351/// Port of git's `update_pre_post_images`: replace the preimage with the fixed
6352/// lines (carrying the original common flags), then rewrite each *common* line of
6353/// the postimage to use the fixed preimage's content. A common postimage line
6354/// whose fixed-preimage counterpart ran out (a trailing blank trimmed at EOF) is
6355/// dropped (git's `reduced`).
6356fn update_pre_post_images_ws(
6357    preimage: &mut Vec<WsImageLine>,
6358    postimage: &mut Vec<WsImageLine>,
6359    fixed: Vec<WsImageLine>,
6360) {
6361    *preimage = fixed;
6362    let mut new_post: Vec<WsImageLine> = Vec::with_capacity(postimage.len());
6363    let mut ctx = 0usize;
6364    for line in postimage.iter() {
6365        if !line.common {
6366            new_post.push(line.clone());
6367            continue;
6368        }
6369        while ctx < preimage.len() && !preimage[ctx].common {
6370            ctx += 1;
6371        }
6372        if ctx >= preimage.len() {
6373            // preimage ran out (a fixed-away trailing blank): drop this line.
6374            continue;
6375        }
6376        new_post.push(WsImageLine {
6377            content: preimage[ctx].content.clone(),
6378            no_newline: preimage[ctx].no_newline,
6379            common: true,
6380        });
6381        ctx += 1;
6382    }
6383    *postimage = new_post;
6384}
6385
6386/// Splice a single hunk into `image`, returning the offset (applied position −
6387/// expected position) so later hunks can carry it forward, or `None` if the
6388/// hunk cannot be located (which rejects the whole patch).
6389///
6390/// Faithful to git's `apply_one_fragment`: build preimage/postimage, try the
6391/// full preimage at progressively-reduced context, and on a match replace the
6392/// matched preimage region with the postimage.
6393fn apply_one_hunk(
6394    image: &mut Vec<Line>,
6395    hunk: &Hunk,
6396    running_offset: isize,
6397    unidiff_zero: bool,
6398) -> Option<isize> {
6399    // preimage = context + deletes (the old side we must find in the image).
6400    // postimage = context + inserts (what replaces it). They share their
6401    // leading/trailing *context* runs, which fuzz peels off symmetrically.
6402    let mut preimage: Vec<Line> = Vec::new();
6403    let mut postimage: Vec<Line> = Vec::new();
6404    let mut leading = 0usize; // context lines before the first +/-
6405    let mut trailing = 0usize; // context lines after the last +/-
6406    let mut seen_change = false;
6407    for hl in &hunk.lines {
6408        match hl {
6409            HunkLine::Context(bytes) => {
6410                preimage.push(Line {
6411                    content: bytes.clone(),
6412                    no_newline: false,
6413                });
6414                postimage.push(Line {
6415                    content: bytes.clone(),
6416                    no_newline: false,
6417                });
6418                if !seen_change {
6419                    leading += 1;
6420                }
6421                trailing += 1;
6422            }
6423            HunkLine::Delete(bytes) => {
6424                preimage.push(Line {
6425                    content: bytes.clone(),
6426                    no_newline: false,
6427                });
6428                seen_change = true;
6429                trailing = 0;
6430            }
6431            HunkLine::Insert(bytes) => {
6432                postimage.push(Line {
6433                    content: bytes.clone(),
6434                    no_newline: false,
6435                });
6436                seen_change = true;
6437                trailing = 0;
6438            }
6439        }
6440    }
6441
6442    // Mark the no-final-newline state on the last preimage/postimage line so the
6443    // exact-match check and the spliced result reproduce a missing terminal
6444    // newline byte-for-byte.
6445    if hunk.old_no_newline
6446        && let Some(last) = preimage.last_mut()
6447    {
6448        last.no_newline = true;
6449    }
6450    if hunk.new_no_newline
6451        && let Some(last) = postimage.last_mut()
6452    {
6453        last.no_newline = true;
6454    }
6455
6456    // A hunk that is `@@ -1,L ... @@` (or `@@ -0,0 ... @@` for an add-to-empty)
6457    // must match the beginning, and a hunk with no trailing context must match
6458    // the end — UNLESS `--unidiff-zero` was given, which tells apply to trust the
6459    // line numbers of a context-free hunk (`match_beginning = !oldpos ||
6460    // (oldpos == 1 && !unidiff_zero)`, `match_end = !unidiff_zero && !trailing`).
6461    let mut match_beginning = hunk.old_start == 0 || (hunk.old_start == 1 && !unidiff_zero);
6462    let mut match_end = !unidiff_zero && trailing == 0;
6463
6464    // git anchors the search at `newpos-1` (0-based), carried by the running
6465    // offset from earlier hunks. The anchor (`pos` in git) shifts up whenever a
6466    // *leading* context line is peeled, because the preimage then begins one
6467    // line later in its own content. For a context-free pure insertion the
6468    // preimage is empty and matches anywhere, so the anchor alone decides the
6469    // result — there we must use the new-side line number exactly as git's
6470    // `newpos - 1` does (the old-side `oldpos` differs for `@@ -1,0 +2,1 @@`
6471    // inserts).
6472    let mut expected = if preimage.is_empty() {
6473        new_side_position(hunk, running_offset)
6474    } else {
6475        expected_position(hunk, running_offset)
6476    };
6477    // The full hunk's expected position never moves, so the returned drift is
6478    // measured against it (not the context-reduced anchor).
6479    let hunk_expected = expected;
6480
6481    loop {
6482        if let Some(pos) = find_hunk_pos(image, &preimage, expected, match_beginning, match_end) {
6483            // Splice: drop the matched preimage lines, insert the postimage.
6484            let take = preimage.len();
6485            let replacement: Vec<Line> = postimage.clone();
6486            image.splice(pos..pos + take, replacement);
6487            return Some(pos as isize - hunk_expected);
6488        }
6489
6490        // No position matched. Mirror git's guard *order* exactly: it first
6491        // checks whether context is already at the floor (`p_context`) and, if
6492        // so, gives up BEFORE relaxing match_beginning/match_end or peeling
6493        // context. With the default `UINT_MAX` floor this fires on the very
6494        // first failure, so the default path never fuzzes and never relaxes the
6495        // begin/end anchors — it rejects. (The comparison is intentionally
6496        // against the floor so the structure stays faithful to git even though
6497        // the default floor makes it unconditionally true.)
6498        #[allow(clippy::absurd_extreme_comparisons)]
6499        if leading <= MIN_FUZZ_CONTEXT && trailing <= MIN_FUZZ_CONTEXT {
6500            return None;
6501        }
6502
6503        // git relaxes the begin/end anchors before peeling context: a hunk that
6504        // "must match the start/end" but didn't is retried free-floating first.
6505        if match_beginning || match_end {
6506            match_beginning = false;
6507            match_end = false;
6508            continue;
6509        }
6510
6511        // Reduce context: peel the larger side (both if equal), exactly as git.
6512        if leading >= trailing {
6513            // Drop the first context line from pre+post; the anchor slides up.
6514            preimage.remove(0);
6515            postimage.remove(0);
6516            expected -= 1;
6517            leading -= 1;
6518        }
6519        if trailing > leading {
6520            preimage.pop();
6521            postimage.pop();
6522            trailing -= 1;
6523        }
6524    }
6525}
6526
6527/// A line with its content (sans terminator) and whether it is newline-terminated.
6528#[derive(Debug, Clone, PartialEq, Eq)]
6529struct Line {
6530    content: Vec<u8>,
6531    no_newline: bool,
6532}
6533
6534/// Split a blob into [`Line`]s. A trailing `\n` does not produce an empty final
6535/// line; instead the last real line is marked `no_newline = false`. A file that
6536/// does not end in `\n` marks its final line `no_newline = true`. An empty blob
6537/// produces no lines.
6538fn split_blob_lines(data: &[u8]) -> Vec<Line> {
6539    let mut lines = Vec::new();
6540    let mut start = 0usize;
6541    while start < data.len() {
6542        match data[start..].iter().position(|&b| b == b'\n') {
6543            Some(rel) => {
6544                let end = start + rel;
6545                lines.push(Line {
6546                    content: data[start..end].to_vec(),
6547                    no_newline: false,
6548                });
6549                start = end + 1;
6550            }
6551            None => {
6552                lines.push(Line {
6553                    content: data[start..].to_vec(),
6554                    no_newline: true,
6555                });
6556                start = data.len();
6557            }
6558        }
6559    }
6560    lines
6561}
6562
6563/// Reassemble lines into a byte buffer, honouring per-line newline state.
6564fn join_lines(lines: &[Line]) -> Vec<u8> {
6565    let mut out = Vec::new();
6566    for line in lines {
6567        out.extend_from_slice(&line.content);
6568        if !line.no_newline {
6569            out.push(b'\n');
6570        }
6571    }
6572    out
6573}
6574
6575/// The naive 0-based position where a hunk expects to apply, given the running
6576/// offset accumulated from earlier hunks.
6577fn expected_position(hunk: &Hunk, running_offset: isize) -> isize {
6578    // `old_start` is 1-based; an empty old side (new-file hunk) uses 0.
6579    let base = if hunk.old_start == 0 {
6580        0
6581    } else {
6582        hunk.old_start as isize - 1
6583    };
6584    base + running_offset
6585}
6586
6587/// git's `pos = frag->newpos ? newpos - 1 : 0` anchor, used for a context-free
6588/// pure insertion whose empty preimage matches anywhere.
6589fn new_side_position(hunk: &Hunk, running_offset: isize) -> isize {
6590    let base = if hunk.new_start == 0 {
6591        0
6592    } else {
6593        hunk.new_start as isize - 1
6594    };
6595    base + running_offset
6596}
6597
6598/// Find the 0-based line index in `image` where `preimage` (the hunk's context
6599/// + deleted lines, possibly already context-reduced by fuzz) matches.
6600///
6601/// Port of git's `find_pos`: start the search at `expected` (clamped, or forced
6602/// to 0/end when `match_beginning`/`match_end`), then ping-pong outward across
6603/// the *whole* image — backward and forward alternately — until both ends are
6604/// exhausted. Returns the first matching line index, or `None`.
6605fn find_hunk_pos(
6606    image: &[Line],
6607    preimage: &[Line],
6608    expected: isize,
6609    match_beginning: bool,
6610    match_end: bool,
6611) -> Option<usize> {
6612    let line_nr = image.len();
6613    let pre_nr = preimage.len();
6614
6615    // git: if we must match the beginning, start at 0; if we must match the
6616    // end, start where the preimage would end exactly at EOF.
6617    let mut line: isize = if match_beginning {
6618        0
6619    } else if match_end {
6620        line_nr as isize - pre_nr as isize
6621    } else {
6622        expected
6623    };
6624    if line < 0 {
6625        line = 0;
6626    }
6627    if line as usize > line_nr {
6628        line = line_nr as isize;
6629    }
6630
6631    let start = line as usize;
6632    let mut backwards = start;
6633    let mut forwards = start;
6634    let mut current = start;
6635
6636    let mut i: u64 = 0;
6637    loop {
6638        if preimage_matches_at(image, preimage, current, match_beginning, match_end) {
6639            return Some(current);
6640        }
6641
6642        loop {
6643            // Both ends exhausted: no match anywhere.
6644            if backwards == 0 && forwards == line_nr {
6645                return None;
6646            }
6647            if i & 1 == 1 {
6648                // Step backward.
6649                if backwards == 0 {
6650                    i += 1;
6651                    continue;
6652                }
6653                backwards -= 1;
6654                current = backwards;
6655            } else {
6656                // Step forward.
6657                if forwards == line_nr {
6658                    i += 1;
6659                    continue;
6660                }
6661                forwards += 1;
6662                current = forwards;
6663            }
6664            break;
6665        }
6666        i += 1;
6667    }
6668}
6669
6670/// Whether `preimage` matches `image` starting at line `pos`.
6671///
6672/// Port of git's `match_fragment` for the default (no whitespace-fuzz) path:
6673/// a byte-exact full-preimage match. Honours `match_beginning` (pos must be 0)
6674/// and `match_end` (the preimage must reach *exactly* the end of the image),
6675/// and reproduces git's terminal-newline semantics — a preimage line marked
6676/// "no newline" only matches when it is the image's final line and that line is
6677/// itself newline-free.
6678fn preimage_matches_at(
6679    image: &[Line],
6680    preimage: &[Line],
6681    pos: usize,
6682    match_beginning: bool,
6683    match_end: bool,
6684) -> bool {
6685    if match_beginning && pos != 0 {
6686        return false;
6687    }
6688    // The whole preimage must fall within the image.
6689    if pos + preimage.len() > image.len() {
6690        return false;
6691    }
6692    if match_end && pos + preimage.len() != image.len() {
6693        return false;
6694    }
6695    for (i, pre) in preimage.iter().enumerate() {
6696        let img = &image[pos + i];
6697        if img.content != pre.content {
6698            return false;
6699        }
6700        // git compares the raw byte buffers, so a missing terminal newline on
6701        // either side only matches the other when both agree. A preimage line
6702        // that lacks a newline can only sit on the image's final line (which
6703        // must itself lack one); a preimage line that *has* a newline cannot
6704        // match a newline-free image line.
6705        if pre.no_newline != img.no_newline {
6706            return false;
6707        }
6708    }
6709    true
6710}
6711
6712/// Split raw patch bytes into lines, preserving the *content* without the
6713/// trailing `\n` (a final unterminated line is kept). Carriage returns are kept
6714/// as-is so CRLF patch bodies round-trip.
6715fn split_patch_lines(input: &[u8]) -> Vec<&[u8]> {
6716    let mut lines = Vec::new();
6717    let mut start = 0usize;
6718    while start < input.len() {
6719        match input[start..].iter().position(|&b| b == b'\n') {
6720            Some(rel) => {
6721                let end = start + rel;
6722                lines.push(&input[start..end]);
6723                start = end + 1;
6724            }
6725            None => {
6726                lines.push(&input[start..]);
6727                start = input.len();
6728            }
6729        }
6730    }
6731    lines
6732}
6733
6734struct PatchParser<'a> {
6735    lines: &'a [&'a [u8]],
6736    index: usize,
6737    recount: bool,
6738    /// `-p<n>` strip count (git's `state->p_value`); shared across the input so
6739    /// a guessed value sticks for subsequent traditional patches.
6740    p_value: usize,
6741    p_value_known: bool,
6742    /// `--directory` root (normalised, trailing slash) prepended to every name.
6743    root: Vec<u8>,
6744    /// The cwd prefix (`state->prefix`), used only to guess `-p<n>` for
6745    /// traditional patches run from a subdirectory.
6746    prefix: Vec<u8>,
6747}
6748
6749impl<'a> PatchParser<'a> {
6750    fn parse(&mut self) -> Result<Vec<FilePatch>> {
6751        let mut patches = Vec::new();
6752        while self.index < self.lines.len() {
6753            let line = self.lines[self.index];
6754            if line.starts_with(b"diff --git ") {
6755                patches.push(self.parse_file(Some(line))?);
6756            } else if line.starts_with(b"--- ") {
6757                // A bare unified diff with no `diff --git` header.
6758                patches.push(self.parse_file(None)?);
6759            } else if line.starts_with(b"@@ ") {
6760                return Err(GitError::InvalidFormat(
6761                    "hunk header encountered before any file header".to_string(),
6762                ));
6763            } else {
6764                // Skip commentary / unrelated lines.
6765                self.index += 1;
6766            }
6767        }
6768        Ok(patches)
6769    }
6770
6771    /// Parse one file's headers and hunks. When `diff_line` is `Some`, the
6772    /// current line is the `diff --git` header (already inspected by the
6773    /// caller); otherwise parsing starts at a `--- ` line of a traditional diff.
6774    fn parse_file(&mut self, diff_line: Option<&[u8]>) -> Result<FilePatch> {
6775        match diff_line {
6776            Some(diff_line) => self.parse_git_file(diff_line),
6777            None => self.parse_traditional_file(),
6778        }
6779    }
6780
6781    /// p_value with one component removed — git uses `p_value - 1` for the
6782    /// `rename`/`copy from`/`to` extended headers, whose names lack the `a/`/`b/`
6783    /// prefix the `---`/`+++` lines carry.
6784    fn p_minus_one(&self) -> usize {
6785        self.p_value.saturating_sub(1)
6786    }
6787
6788    /// Parse a git (`diff --git`) file section, resolving every pathname through
6789    /// git's `git_header_name` / `find_name` with the active `-p<n>`/`--directory`.
6790    fn parse_git_file(&mut self, diff_line: &[u8]) -> Result<FilePatch> {
6791        let mut patch = empty_file_patch();
6792        // `def_name`: the common name from the `diff --git` line, used when the
6793        // section carries no explicit `---`/`+++`/rename names.
6794        let rest = &diff_line[b"diff --git ".len()..];
6795        let mut def_name = name::git_header_name(self.p_value, rest);
6796        if let (Some(d), false) = (def_name.as_mut(), self.root.is_empty()) {
6797            let mut s = self.root.clone();
6798            s.extend_from_slice(d);
6799            *d = s;
6800        }
6801        self.index += 1;
6802
6803        // Git patches name files relative to the repository top-level, so the
6804        // `apply` cwd-prefix is never prepended to them (git's is_toplevel_relative).
6805        patch.is_toplevel_relative = true;
6806
6807        // Set once a `GIT binary patch` / `Binary files … differ` body is seen,
6808        // so the file is not run through the textual hunk parser afterwards.
6809        let mut binary_seen = false;
6810
6811        // Extended headers until the first `---`/`@@`/next `diff --git`.
6812        while self.index < self.lines.len() {
6813            let line = self.lines[self.index];
6814            if line.starts_with(b"--- ") {
6815                self.parse_git_old_header(&line[b"--- ".len()..], &mut patch);
6816                self.index += 1;
6817                break;
6818            } else if line.starts_with(b"@@ ") {
6819                // No `---`/`+++` (e.g. pure rename or mode change with no body).
6820                break;
6821            } else if line.starts_with(b"diff --git ") {
6822                // Next file began with no body for this one.
6823                break;
6824            } else if let Some(rest) = strip_prefix(line, b"old mode ") {
6825                patch.old_mode = Some(self.parse_mode_line(rest)?);
6826            } else if let Some(rest) = strip_prefix(line, b"new mode ") {
6827                patch.new_mode = Some(self.parse_mode_line(rest)?);
6828            } else if let Some(rest) = strip_prefix(line, b"new file mode ") {
6829                patch.is_new = true;
6830                patch.new_mode = Some(self.parse_mode_line(rest)?);
6831                patch.new_path = def_name.clone();
6832            } else if let Some(rest) = strip_prefix(line, b"deleted file mode ") {
6833                patch.is_delete = true;
6834                patch.old_mode = Some(self.parse_mode_line(rest)?);
6835                patch.old_path = def_name.clone();
6836            } else if let Some(rest) = strip_prefix(line, b"index ") {
6837                // `index <old>..<new>[ <mode>]`: capture the blob OIDs (needed by
6838                // the binary apply and the `-3` fallback) and the unchanged-file
6839                // mode (git's gitdiff_index → gitdiff_oldmode).
6840                self.parse_index_line(rest, &mut patch)?;
6841            } else if let Some(rest) = strip_prefix(line, b"rename from ") {
6842                patch.is_rename = true;
6843                patch.old_path = name::find_name(rest, None, self.p_minus_one(), 0, &self.root);
6844            } else if let Some(rest) = strip_prefix(line, b"rename to ") {
6845                patch.is_rename = true;
6846                patch.new_path = name::find_name(rest, None, self.p_minus_one(), 0, &self.root);
6847            } else if let Some(rest) = strip_prefix(line, b"copy from ") {
6848                patch.is_copy = true;
6849                patch.old_path = name::find_name(rest, None, self.p_minus_one(), 0, &self.root);
6850            } else if let Some(rest) = strip_prefix(line, b"copy to ") {
6851                patch.is_copy = true;
6852                patch.new_path = name::find_name(rest, None, self.p_minus_one(), 0, &self.root);
6853            } else if let Some(rest) = strip_prefix(line, b"similarity index ") {
6854                patch.similarity = parse_percent(rest);
6855            } else if let Some(rest) = strip_prefix(line, b"dissimilarity index ") {
6856                patch.dissimilarity = parse_percent(rest);
6857            } else if line == b"GIT binary patch" {
6858                // The binary payload follows (no `---`/`+++`, no `@@` hunks).
6859                let gitbin_line = self.index + 1;
6860                patch.is_binary = true;
6861                patch.binary = Some(self.parse_binary_block(gitbin_line)?);
6862                binary_seen = true;
6863                break;
6864            } else if apply_is_binary_files_differ(line) {
6865                // A `--binary`-less diff records only `Binary files … differ`;
6866                // the postimage has to come from the object store at apply time.
6867                patch.is_binary = true;
6868                binary_seen = true;
6869                self.index += 1;
6870                break;
6871            } else {
6872                // Unrecognised commentary line — ignore.
6873                self.index += 1;
6874                continue;
6875            }
6876            self.index += 1;
6877        }
6878
6879        // `+++` header (the old-file branch above already advanced past `---`).
6880        if !binary_seen
6881            && self.index < self.lines.len()
6882            && self.lines[self.index].starts_with(b"+++ ")
6883        {
6884            let line = self.lines[self.index];
6885            self.parse_git_new_header(&line[b"+++ ".len()..], &mut patch);
6886            self.index += 1;
6887        }
6888
6889        // No explicit names anywhere: fall back to `def_name`, or fail like git
6890        // when `-p<n>` stripped every component away.
6891        if patch.old_path.is_none() && patch.new_path.is_none() {
6892            match &def_name {
6893                Some(d) => {
6894                    patch.old_path = Some(d.clone());
6895                    patch.new_path = Some(d.clone());
6896                }
6897                None => {
6898                    return Err(GitError::InvalidFormat(format!(
6899                        "git diff header lacks filename information when removing {} \
6900                         leading pathname components",
6901                        self.p_value
6902                    )));
6903                }
6904            }
6905        }
6906
6907        // Binary patches carry no `@@` hunks.
6908        if !binary_seen {
6909            self.parse_hunks(&mut patch)?;
6910        }
6911        Ok(patch)
6912    }
6913
6914    /// Parse a `index <old>..<new>[ <mode>]` line, capturing the blob OIDs and,
6915    /// when present, the unchanged-file mode (git's `gitdiff_index`).
6916    fn parse_index_line(&self, rest: &[u8], patch: &mut FilePatch) -> Result<()> {
6917        let Some(dotdot) = find_subslice(rest, b"..") else {
6918            return Ok(());
6919        };
6920        let old = &rest[..dotdot];
6921        let after = &rest[dotdot + 2..];
6922        // `new` runs to the first space (mode) or end of line.
6923        let (new, mode_part) = match after.iter().position(|&b| b == b' ') {
6924            Some(space) => (&after[..space], Some(&after[space + 1..])),
6925            None => (after, None),
6926        };
6927        if !old.is_empty() {
6928            patch.old_oid_hex = Some(old.to_vec());
6929        }
6930        if !new.is_empty() {
6931            patch.new_oid_hex = Some(new.to_vec());
6932        }
6933        if let Some(mode) = mode_part
6934            && !mode.is_empty()
6935        {
6936            patch.old_mode = Some(self.parse_mode_line(mode)?);
6937        }
6938        Ok(())
6939    }
6940
6941    /// Parse a `<octal mode>` field, mirroring git's `parse_mode_line`: leading
6942    /// octal digits terminated by whitespace or end of line. Errors otherwise.
6943    fn parse_mode_line(&self, rest: &[u8]) -> Result<u32> {
6944        let mut value: u32 = 0;
6945        let mut i = 0;
6946        while i < rest.len() && (b'0'..=b'7').contains(&rest[i]) {
6947            value = value
6948                .checked_mul(8)
6949                .and_then(|value| value.checked_add((rest[i] - b'0') as u32))
6950                .ok_or_else(|| self.invalid_mode_error(rest))?;
6951            i += 1;
6952        }
6953        if i == 0 || (i < rest.len() && !rest[i].is_ascii_whitespace()) {
6954            return Err(self.invalid_mode_error(rest));
6955        }
6956        Ok(value)
6957    }
6958
6959    fn invalid_mode_error(&self, rest: &[u8]) -> GitError {
6960        GitError::InvalidFormat(format!(
6961            "invalid mode on line {}: {}",
6962            self.index + 1,
6963            lossy(rest)
6964        ))
6965    }
6966
6967    /// Parse a `GIT binary patch` body: a mandatory forward hunk and an optional
6968    /// reverse hunk, each base85-encoded over zlib-deflated data. Mirrors git's
6969    /// `parse_binary`. `gitbin_line` is the 1-based line of the `GIT binary patch`
6970    /// marker (used in the "unrecognized binary patch" message).
6971    fn parse_binary_block(&mut self, gitbin_line: usize) -> Result<BinaryPatch> {
6972        // self.index points at "GIT binary patch"; advance past it.
6973        self.index += 1;
6974        let forward = match self.parse_binary_hunk()? {
6975            Some(hunk) => hunk,
6976            None => {
6977                return Err(GitError::InvalidFormat(format!(
6978                    "binary-unrecognized:{gitbin_line}"
6979                )));
6980            }
6981        };
6982        let reverse = self.parse_binary_hunk()?;
6983        Ok(BinaryPatch { forward, reverse })
6984    }
6985
6986    /// Parse one binary hunk (method line + base85 data lines + blank terminator),
6987    /// or `Ok(None)` when the current line is not a `literal`/`delta` method line.
6988    fn parse_binary_hunk(&mut self) -> Result<Option<BinaryHunk>> {
6989        if self.index >= self.lines.len() {
6990            return Ok(None);
6991        }
6992        let line = self.lines[self.index];
6993        let (method, num) = if let Some(rest) = strip_prefix(line, b"delta ") {
6994            (BinaryMethod::Delta, rest)
6995        } else if let Some(rest) = strip_prefix(line, b"literal ") {
6996            (BinaryMethod::Literal, rest)
6997        } else {
6998            return Ok(None);
6999        };
7000        let origlen = parse_leading_usize(num);
7001        self.index += 1;
7002
7003        let mut deflated = Vec::new();
7004        loop {
7005            if self.index >= self.lines.len() {
7006                // Ran out of input before the blank terminator (truncated patch).
7007                return Err(self.corrupt_binary_error());
7008            }
7009            let data = self.lines[self.index];
7010            if data.is_empty() {
7011                // Blank line terminates the hunk.
7012                self.index += 1;
7013                break;
7014            }
7015            // git counts the trailing newline in its line length; our split-off
7016            // lines do not carry it, so `git llen == data.len() + 1`.
7017            let len = data.len();
7018            if len < 6 || !(len - 1).is_multiple_of(5) {
7019                return Err(self.corrupt_binary_error());
7020            }
7021            let max_byte_length = (len - 1) / 5 * 4;
7022            let byte_length = match data[0] {
7023                b'A'..=b'Z' => (data[0] - b'A') as usize + 1,
7024                b'a'..=b'z' => (data[0] - b'a') as usize + 27,
7025                _ => return Err(self.corrupt_binary_error()),
7026            };
7027            if max_byte_length < byte_length || byte_length <= max_byte_length.saturating_sub(4) {
7028                return Err(self.corrupt_binary_error());
7029            }
7030            let decoded = decode_base85(&data[1..], byte_length)
7031                .ok_or_else(|| self.corrupt_binary_error())?;
7032            deflated.extend_from_slice(&decoded);
7033            self.index += 1;
7034        }
7035        Ok(Some(BinaryHunk {
7036            method,
7037            origlen,
7038            deflated,
7039        }))
7040    }
7041
7042    fn corrupt_binary_error(&self) -> GitError {
7043        GitError::InvalidFormat(format!("binary-corrupt:{}", self.index + 1))
7044    }
7045
7046    fn parse_git_old_header(&self, rest: &[u8], patch: &mut FilePatch) {
7047        if name::is_dev_null(rest) {
7048            patch.is_new = true;
7049            patch.old_path = None;
7050        } else if patch.old_path.is_none() {
7051            patch.old_path = name::find_name(rest, None, self.p_value, name::TERM_TAB, &self.root);
7052        }
7053    }
7054
7055    fn parse_git_new_header(&self, rest: &[u8], patch: &mut FilePatch) {
7056        if name::is_dev_null(rest) {
7057            patch.is_delete = true;
7058            patch.new_path = None;
7059        } else if patch.new_path.is_none() {
7060            patch.new_path = name::find_name(rest, None, self.p_value, name::TERM_TAB, &self.root);
7061        }
7062    }
7063
7064    /// Parse a traditional (non-git) diff section, mirroring git's
7065    /// `parse_traditional_patch`: guess the strip count, recognise epoch
7066    /// timestamps as creation/deletion, and prefer the shorter of the two names.
7067    fn parse_traditional_file(&mut self) -> Result<FilePatch> {
7068        let mut patch = empty_file_patch();
7069        let first_line = self.lines[self.index];
7070        let first = first_line[b"--- ".len()..].to_vec();
7071        self.index += 1;
7072        let second = if self.index < self.lines.len() && self.lines[self.index].starts_with(b"+++ ")
7073        {
7074            let s = self.lines[self.index][b"+++ ".len()..].to_vec();
7075            self.index += 1;
7076            Some(s)
7077        } else {
7078            None
7079        };
7080
7081        if let Some(second) = &second {
7082            if !self.p_value_known {
7083                let p0 = name::guess_p_value(&first, &self.root, &self.prefix);
7084                let q0 = name::guess_p_value(second, &self.root, &self.prefix);
7085                let p = if p0.is_none() { q0 } else { p0 };
7086                if let Some(pv) = p
7087                    && Some(pv) == q0
7088                {
7089                    self.p_value = pv;
7090                    self.p_value_known = true;
7091                }
7092            }
7093
7094            let name = if name::is_dev_null(&first) {
7095                patch.is_new = true;
7096                let name = name::find_name_traditional(second, None, self.p_value, &self.root);
7097                patch.new_path = name.clone();
7098                name
7099            } else if name::is_dev_null(second) {
7100                patch.is_delete = true;
7101                let name = name::find_name_traditional(&first, None, self.p_value, &self.root);
7102                patch.old_path = name.clone();
7103                name
7104            } else {
7105                let first_name =
7106                    name::find_name_traditional(&first, None, self.p_value, &self.root);
7107                let name = name::find_name_traditional(
7108                    second,
7109                    first_name.as_deref(),
7110                    self.p_value,
7111                    &self.root,
7112                );
7113                if name::has_epoch_timestamp(&first) {
7114                    patch.is_new = true;
7115                    patch.new_path = name.clone();
7116                } else if name::has_epoch_timestamp(second) {
7117                    patch.is_delete = true;
7118                    patch.old_path = name.clone();
7119                } else {
7120                    patch.old_path = name.clone();
7121                    patch.new_path = name.clone();
7122                }
7123                name
7124            };
7125            // git's `parse_traditional_patch`: a name that strips away every
7126            // component (e.g. `-p2` against a one-component `file_in_root`) is a
7127            // hard error — the whole apply fails rather than silently skipping the
7128            // unresolved file.
7129            if name.is_none() {
7130                return Err(GitError::InvalidFormat(format!(
7131                    "unable to find filename in patch at line {}",
7132                    self.index
7133                )));
7134            }
7135        }
7136
7137        self.parse_hunks(&mut patch)?;
7138        Ok(patch)
7139    }
7140
7141    /// Parse the hunk bodies that follow a file header, stopping at the next
7142    /// file header.
7143    fn parse_hunks(&mut self, patch: &mut FilePatch) -> Result<()> {
7144        while self.index < self.lines.len() {
7145            let line = self.lines[self.index];
7146            // git's `parse_single_patch` only treats a line as a fragment when it
7147            // begins with `@@ -` (old side first). A `@@ +…` line — e.g. the
7148            // malformed header a Subversion-generated diff emits — is not a hunk;
7149            // it (and the lines after it) are skipped as commentary, so a deletion
7150            // with no real hunk still applies from its metadata alone.
7151            if line.starts_with(b"@@ -") {
7152                let hunk = self.parse_hunk()?;
7153                patch.hunks.push(hunk);
7154            } else if line.starts_with(b"diff --git ") {
7155                break;
7156            } else if line.starts_with(b"--- ") {
7157                // Start of a subsequent bare diff.
7158                break;
7159            } else {
7160                // Trailing commentary between/after hunks.
7161                self.index += 1;
7162            }
7163        }
7164        Ok(())
7165    }
7166
7167    fn parse_hunk(&mut self) -> Result<Hunk> {
7168        let header = self.lines[self.index];
7169        let (old_start, old_len, new_start, new_len) = parse_hunk_header(header)?;
7170        self.index += 1;
7171
7172        let mut hunk = Hunk {
7173            old_start,
7174            old_len,
7175            new_start,
7176            new_len,
7177            lines: Vec::new(),
7178            old_no_newline: false,
7179            new_no_newline: false,
7180            line_input_lines: Vec::new(),
7181        };
7182        let mut old_seen = 0usize;
7183        let mut new_seen = 0usize;
7184
7185        while self.index < self.lines.len() {
7186            // Stop when both sides are satisfied. In recount mode the header
7187            // counts are intentionally ignored; the next hunk/file header ends
7188            // the body.
7189            if !self.recount && old_seen >= old_len && new_seen >= new_len {
7190                break;
7191            }
7192            let line = self.lines[self.index];
7193            if self.recount
7194                && (line.starts_with(b"@@ ")
7195                    || line.starts_with(b"diff --git ")
7196                    || line.starts_with(b"diff a/")
7197                    || line.starts_with(b"--- "))
7198            {
7199                break;
7200            }
7201            if line.is_empty() {
7202                // A wholly empty line in a unified diff is a context line whose
7203                // content is the empty string (git emits a bare ` `, but some
7204                // tooling/email transport strips the trailing space).
7205                hunk.lines.push(HunkLine::Context(Vec::new()));
7206                hunk.line_input_lines.push(self.index + 1);
7207                old_seen += 1;
7208                new_seen += 1;
7209                self.index += 1;
7210                continue;
7211            }
7212            match line[0] {
7213                b' ' => {
7214                    hunk.lines.push(HunkLine::Context(line[1..].to_vec()));
7215                    hunk.line_input_lines.push(self.index + 1);
7216                    old_seen += 1;
7217                    new_seen += 1;
7218                }
7219                b'+' => {
7220                    hunk.lines.push(HunkLine::Insert(line[1..].to_vec()));
7221                    hunk.line_input_lines.push(self.index + 1);
7222                    new_seen += 1;
7223                }
7224                b'-' => {
7225                    hunk.lines.push(HunkLine::Delete(line[1..].to_vec()));
7226                    hunk.line_input_lines.push(self.index + 1);
7227                    old_seen += 1;
7228                }
7229                b'\\' => {
7230                    // `\ No newline at end of file` — applies to the line just
7231                    // emitted. Set the appropriate side flag(s).
7232                    self.mark_no_newline(&mut hunk);
7233                    self.index += 1;
7234                    continue;
7235                }
7236                _ => {
7237                    return Err(GitError::InvalidFormat(format!(
7238                        "corrupt-hunk-body:{}",
7239                        self.index + 1
7240                    )));
7241                }
7242            }
7243            self.index += 1;
7244        }
7245
7246        // A trailing `\ No newline` may follow the final body line even after
7247        // the counts are satisfied; consume it.
7248        if self.index < self.lines.len() && self.lines[self.index].starts_with(b"\\") {
7249            self.mark_no_newline(&mut hunk);
7250            self.index += 1;
7251        }
7252
7253        if self.recount {
7254            hunk.old_len = old_seen;
7255            hunk.new_len = new_seen;
7256        } else if old_seen != old_len || new_seen != new_len {
7257            return Err(GitError::InvalidFormat(format!(
7258                "hunk body line counts mismatch: header declared -{old_len},+{new_len} \
7259                 but body had -{old_seen},+{new_seen}"
7260            )));
7261        }
7262
7263        Ok(hunk)
7264    }
7265
7266    /// Set the no-newline flag based on the kind of the most recently pushed
7267    /// hunk line.
7268    fn mark_no_newline(&self, hunk: &mut Hunk) {
7269        match hunk.lines.last() {
7270            Some(HunkLine::Context(_)) => {
7271                hunk.old_no_newline = true;
7272                hunk.new_no_newline = true;
7273            }
7274            Some(HunkLine::Insert(_)) => hunk.new_no_newline = true,
7275            Some(HunkLine::Delete(_)) => hunk.old_no_newline = true,
7276            None => {}
7277        }
7278    }
7279}
7280
7281/// An all-empty [`FilePatch`] for the parser to fill in.
7282fn empty_file_patch() -> FilePatch {
7283    FilePatch {
7284        old_path: None,
7285        new_path: None,
7286        old_mode: None,
7287        new_mode: None,
7288        hunks: Vec::new(),
7289        is_new: false,
7290        is_delete: false,
7291        is_rename: false,
7292        is_copy: false,
7293        similarity: None,
7294        dissimilarity: None,
7295        old_oid_hex: None,
7296        new_oid_hex: None,
7297        is_binary: false,
7298        binary: None,
7299        is_toplevel_relative: false,
7300    }
7301}
7302
7303/// Parse an `@@ -l,s +l,s @@` header into `(old_start, old_len, new_start,
7304/// new_len)`. A missing `,s` means a length of 1.
7305fn parse_hunk_header(line: &[u8]) -> Result<(usize, usize, usize, usize)> {
7306    let err = || GitError::InvalidFormat(format!("malformed hunk header: {}", lossy(line)));
7307    let rest = strip_prefix(line, b"@@ ").ok_or_else(err)?;
7308    // Up to the closing ` @@`.
7309    let close = find_subslice(rest, b" @@").ok_or_else(err)?;
7310    let ranges = &rest[..close];
7311    let mut parts = ranges.split(|&b| b == b' ').filter(|p| !p.is_empty());
7312    let old = parts.next().ok_or_else(err)?;
7313    let new = parts.next().ok_or_else(err)?;
7314    let old = strip_prefix(old, b"-").ok_or_else(err)?;
7315    let new = strip_prefix(new, b"+").ok_or_else(err)?;
7316    let (old_start, old_len) = parse_range(old).ok_or_else(err)?;
7317    let (new_start, new_len) = parse_range(new).ok_or_else(err)?;
7318    Ok((old_start, old_len, new_start, new_len))
7319}
7320
7321/// Parse `start[,len]` into `(start, len)`, defaulting `len` to 1.
7322fn parse_range(range: &[u8]) -> Option<(usize, usize)> {
7323    match range.iter().position(|&b| b == b',') {
7324        Some(comma) => {
7325            let start = parse_usize(&range[..comma])?;
7326            let len = parse_usize(&range[comma + 1..])?;
7327            Some((start, len))
7328        }
7329        None => Some((parse_usize(range)?, 1)),
7330    }
7331}
7332
7333fn parse_usize(bytes: &[u8]) -> Option<usize> {
7334    if bytes.is_empty() {
7335        return None;
7336    }
7337    let mut value: usize = 0;
7338    for &b in bytes {
7339        if !b.is_ascii_digit() {
7340            return None;
7341        }
7342        value = value.checked_mul(10)?.checked_add((b - b'0') as usize)?;
7343    }
7344    Some(value)
7345}
7346
7347fn parse_percent(bytes: &[u8]) -> Option<u8> {
7348    let trimmed = trim_ascii_end(bytes)
7349        .strip_suffix(b"%")
7350        .unwrap_or(trim_ascii_end(bytes));
7351    let value = parse_usize(trimmed)?;
7352    u8::try_from(value).ok().filter(|value| *value <= 100)
7353}
7354
7355fn strip_prefix<'b>(line: &'b [u8], prefix: &[u8]) -> Option<&'b [u8]> {
7356    if line.starts_with(prefix) {
7357        Some(&line[prefix.len()..])
7358    } else {
7359        None
7360    }
7361}
7362
7363/// Whether a diff body line is a metadata-only binary marker (`Binary files …
7364/// differ` / `Files … differ`), git's binhdr detection.
7365fn apply_is_binary_files_differ(line: &[u8]) -> bool {
7366    line.ends_with(b" differ")
7367        && (line.starts_with(b"Binary files ") || line.starts_with(b"Files "))
7368}
7369
7370/// Parse leading decimal digits (git uses `strtoul`, which ignores trailing
7371/// junk). Returns 0 when there are no leading digits.
7372fn parse_leading_usize(bytes: &[u8]) -> usize {
7373    let mut value = 0usize;
7374    for &b in bytes {
7375        if !b.is_ascii_digit() {
7376            break;
7377        }
7378        value = value.saturating_mul(10).saturating_add((b - b'0') as usize);
7379    }
7380    value
7381}
7382
7383/// git's base85 alphabet (`base85.c` `en85`).
7384const BASE85_ALPHABET: &[u8; 85] =
7385    b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!#$%&()*+-;<=>?@^_`{|}~";
7386
7387fn base85_value(ch: u8) -> Option<u32> {
7388    BASE85_ALPHABET
7389        .iter()
7390        .position(|&c| c == ch)
7391        .map(|index| index as u32)
7392}
7393
7394/// Decode `len` bytes from a base85 buffer (5 chars → 4 bytes, big-endian), a
7395/// port of git's `decode_85`. Returns `None` on an invalid alphabet character or
7396/// an overflowing 5-char group. `buffer` must contain `ceil(len/4) * 5` chars.
7397fn decode_base85(buffer: &[u8], len: usize) -> Option<Vec<u8>> {
7398    let mut out = Vec::with_capacity(len);
7399    let mut pos = 0usize;
7400    let mut remaining = len;
7401    while remaining > 0 {
7402        let mut acc: u32 = 0;
7403        // First four characters never overflow a u32 (85^4 < 2^32).
7404        for _ in 0..4 {
7405            let de = base85_value(*buffer.get(pos)?)?;
7406            pos += 1;
7407            acc = acc * 85 + de;
7408        }
7409        let de = base85_value(*buffer.get(pos)?)?;
7410        pos += 1;
7411        // The fifth character can overflow; reject it as git does.
7412        if 0xffff_ffffu32 / 85 < acc {
7413            return None;
7414        }
7415        acc *= 85;
7416        if 0xffff_ffffu32 - de < acc {
7417            return None;
7418        }
7419        acc += de;
7420
7421        let cnt = remaining.min(4);
7422        remaining -= cnt;
7423        let bytes = acc.to_be_bytes();
7424        out.extend_from_slice(&bytes[..cnt]);
7425    }
7426    Some(out)
7427}
7428
7429/// Apply a git delta (`delta.c` `patch_delta`) to reconstruct the postimage from
7430/// `base`. The delta begins with the base size and result size as varints,
7431/// followed by copy (`0x80` bit set: offset/size from base) and insert (literal
7432/// bytes) opcodes. Returns `None` on any malformed/inconsistent delta.
7433pub fn git_patch_delta(base: &[u8], delta: &[u8]) -> Option<Vec<u8>> {
7434    let mut data = 0usize;
7435    let read_hdr_size = |data: &mut usize| -> Option<usize> {
7436        let mut size = 0usize;
7437        let mut shift = 0u32;
7438        loop {
7439            let cmd = *delta.get(*data)?;
7440            *data += 1;
7441            size |= ((cmd & 0x7f) as usize).checked_shl(shift)?;
7442            shift += 7;
7443            if cmd & 0x80 == 0 {
7444                break;
7445            }
7446        }
7447        Some(size)
7448    };
7449
7450    let base_size = read_hdr_size(&mut data)?;
7451    if base_size != base.len() {
7452        return None;
7453    }
7454    let result_size = read_hdr_size(&mut data)?;
7455    let mut out = Vec::with_capacity(result_size);
7456
7457    while data < delta.len() {
7458        let cmd = delta[data];
7459        data += 1;
7460        if cmd & 0x80 != 0 {
7461            // Copy from base.
7462            let mut cp_off = 0usize;
7463            let mut cp_size = 0usize;
7464            if cmd & 0x01 != 0 {
7465                cp_off = *delta.get(data)? as usize;
7466                data += 1;
7467            }
7468            if cmd & 0x02 != 0 {
7469                cp_off |= (*delta.get(data)? as usize) << 8;
7470                data += 1;
7471            }
7472            if cmd & 0x04 != 0 {
7473                cp_off |= (*delta.get(data)? as usize) << 16;
7474                data += 1;
7475            }
7476            if cmd & 0x08 != 0 {
7477                cp_off |= (*delta.get(data)? as usize) << 24;
7478                data += 1;
7479            }
7480            if cmd & 0x10 != 0 {
7481                cp_size = *delta.get(data)? as usize;
7482                data += 1;
7483            }
7484            if cmd & 0x20 != 0 {
7485                cp_size |= (*delta.get(data)? as usize) << 8;
7486                data += 1;
7487            }
7488            if cmd & 0x40 != 0 {
7489                cp_size |= (*delta.get(data)? as usize) << 16;
7490                data += 1;
7491            }
7492            if cp_size == 0 {
7493                cp_size = 0x10000;
7494            }
7495            let end = cp_off.checked_add(cp_size)?;
7496            if end > base.len() || cp_size > result_size {
7497                return None;
7498            }
7499            out.extend_from_slice(&base[cp_off..end]);
7500        } else if cmd != 0 {
7501            // Insert literal bytes from the delta.
7502            let len = cmd as usize;
7503            let end = data.checked_add(len)?;
7504            if end > delta.len() {
7505                return None;
7506            }
7507            out.extend_from_slice(&delta[data..end]);
7508            data = end;
7509        } else {
7510            // Opcode 0 is reserved.
7511            return None;
7512        }
7513    }
7514
7515    if data != delta.len() || out.len() != result_size {
7516        return None;
7517    }
7518    Some(out)
7519}
7520
7521fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
7522    if needle.is_empty() || needle.len() > haystack.len() {
7523        return None;
7524    }
7525    haystack
7526        .windows(needle.len())
7527        .position(|window| window == needle)
7528}
7529
7530fn trim_ascii_end(bytes: &[u8]) -> &[u8] {
7531    let mut end = bytes.len();
7532    while end > 0 && (bytes[end - 1] == b' ' || bytes[end - 1] == b'\r') {
7533        end -= 1;
7534    }
7535    &bytes[..end]
7536}
7537
7538fn lossy(bytes: &[u8]) -> String {
7539    String::from_utf8_lossy(bytes).into_owned()
7540}
7541
7542// ===========================================================================
7543// Library tree-merge seam (`merge_trees`).
7544//
7545// This is the single 3-way tree-merge engine that every merge porcelain calls.
7546// Before it existed the logic was duplicated across the CLI: `merge-tree
7547// --write-tree` had its own copy and `git merge` / `cherry-pick` / `revert`
7548// had a second copy. Both copies implemented the identical per-path diff3
7549// resolution; the only differences were *rendering* (write-tree emits a tree +
7550// stage list + messages; the porcelains stage an index + materialize a
7551// worktree). This seam computes the merge once and returns a per-path result
7552// rich enough for both renderings, so the resolution lives in exactly one
7553// place.
7554//
7555// The result is byte-identical to the old per-command copies on every cell
7556// they already handled (clean merges, content / add-add / modify-delete
7557// conflicts, mode merges). On top of that it adds rename-aware resolution: a
7558// file renamed on one side and modified on the other follows the rename,
7559// gated by [`MergeTreesOptions::detect_renames`] (the classic merge-ort
7560// non-recursive rename case).
7561// ===========================================================================
7562
7563/// Flattened tree: repository-relative path -> (mode, blob/symlink/gitlink oid).
7564pub type MergeEntryMap = BTreeMap<Vec<u8>, (u32, ObjectId)>;
7565
7566/// Whether to favour one side wholesale for textual conflicts (`-Xours` /
7567/// `-Xtheirs`), or to leave conflict markers in place.
7568#[derive(Clone, Copy, PartialEq, Eq, Debug)]
7569pub enum MergeFavor {
7570    /// Leave conflict markers in place (the default).
7571    None,
7572    /// On a textual conflict, take ours' content wholesale.
7573    Ours,
7574    /// On a textual conflict, take theirs' content wholesale.
7575    Theirs,
7576    /// On a textual conflict, keep BOTH sides' lines (ours then theirs) with no
7577    /// markers — git's `merge=union` attribute / `--union` (`XDL_MERGE_FAVOR_UNION`).
7578    Union,
7579}
7580
7581/// Options controlling a [`merge_trees`] run.
7582pub struct MergeTreesOptions<'a> {
7583    /// Conflict-marker label for ours (e.g. a branch name or `HEAD`).
7584    pub ours_label: &'a str,
7585    /// Conflict-marker label for theirs.
7586    pub theirs_label: &'a str,
7587    /// Diff3 ancestor label (the `|||||||` side); merge porcelains use
7588    /// `"merged common ancestors"`.
7589    pub ancestor_label: &'a str,
7590    /// `-Xours` / `-Xtheirs` favouring for textual conflicts.
7591    pub favor: MergeFavor,
7592    /// Optional per-path favor, used only when [`Self::favor`] is
7593    /// [`MergeFavor::None`]. Merge porcelains use this for attributes such as
7594    /// `merge=union` without changing the command-line `-X` override.
7595    pub path_favor: Option<&'a dyn Fn(&[u8]) -> MergeFavor>,
7596    /// Optional per-path conflict marker length resolver for attributes such as
7597    /// `conflict-marker-size`.
7598    pub path_marker_size: Option<&'a dyn Fn(&[u8]) -> usize>,
7599    /// Enable rename-aware merging: a file renamed on one side and modified on
7600    /// the other follows the rename. When `false`, the merge is purely
7601    /// path-keyed (the historical behaviour).
7602    pub detect_renames: bool,
7603    /// Minimum similarity (`0..=100`) for inexact rename detection.
7604    pub rename_threshold: u8,
7605    /// Cap on the inexact rename matrix (`merge.renameLimit`/`diff.renameLimit`).
7606    /// `0` means unlimited; otherwise inexact detection is skipped when the
7607    /// candidate source × destination count exceeds `rename_limit²`.
7608    pub rename_limit: usize,
7609    /// Directory-rename detection mode. When [`DirectoryRenames::False`], a file
7610    /// added on one side under a directory that the *other* side renamed stays
7611    /// put. When enabled, such files are re-homed into the renamed directory,
7612    /// matching `merge.directoryRenames`. Requires `detect_renames` to have any
7613    /// effect (directory renames are inferred from the file renames it finds).
7614    pub directory_renames: DirectoryRenames,
7615    /// Conflict-marker style for textual conflicts (`merge.conflictStyle`).
7616    pub style: ConflictStyle,
7617    /// Whitespace-insensitivity for textual 3-way merges, mirroring
7618    /// `-Xignore-space-change`/`-Xignore-all-space`/`-Xignore-space-at-eol`.
7619    pub ws_ignore: WsIgnore,
7620}
7621
7622/// How directory-rename detection behaves, mirroring git's
7623/// `merge.directoryRenames` configuration.
7624#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
7625pub enum DirectoryRenames {
7626    /// Disable directory-rename detection (`merge.directoryRenames=false`).
7627    #[default]
7628    False,
7629    /// Apply directory renames silently (`merge.directoryRenames=true`).
7630    True,
7631    /// Detect directory renames but treat each re-homed path as a conflict
7632    /// requiring confirmation (`merge.directoryRenames=conflict`). git's default.
7633    Conflict,
7634}
7635
7636impl Default for MergeTreesOptions<'_> {
7637    fn default() -> Self {
7638        Self {
7639            ours_label: "ours",
7640            theirs_label: "theirs",
7641            ancestor_label: "merged common ancestors",
7642            favor: MergeFavor::None,
7643            path_favor: None,
7644            path_marker_size: None,
7645            detect_renames: false,
7646            rename_threshold: DEFAULT_RENAME_THRESHOLD,
7647            rename_limit: 0,
7648            directory_renames: DirectoryRenames::False,
7649            style: ConflictStyle::Merge,
7650            ws_ignore: WsIgnore::EMPTY,
7651        }
7652    }
7653}
7654
7655fn merge_favor_for_path(options: &MergeTreesOptions<'_>, path: &[u8]) -> MergeFavor {
7656    if options.favor != MergeFavor::None {
7657        return options.favor;
7658    }
7659    options
7660        .path_favor
7661        .map(|resolver| resolver(path))
7662        .unwrap_or(MergeFavor::None)
7663}
7664
7665fn merge_marker_size_for_path(options: &MergeTreesOptions<'_>, path: &[u8]) -> usize {
7666    options
7667        .path_marker_size
7668        .map(|resolver| resolver(path))
7669        .unwrap_or(7)
7670}
7671
7672/// The kind of conflict recorded for a path, used to render the stable
7673/// conflict-type token and human message.
7674#[derive(Debug, Clone, PartialEq, Eq)]
7675pub enum MergeConflictKind {
7676    /// Both sides changed the file content differently (or both added it with
7677    /// differing content — an add/add).
7678    Content { add_add: bool },
7679    /// The file was deleted on one side and modified on the other.
7680    ModifyDelete {
7681        /// The side label that deleted the path.
7682        deleted_in: String,
7683        /// The side label that modified (and thus kept) the path.
7684        modified_in: String,
7685    },
7686    /// A file renamed on one side, with a content conflict against the other
7687    /// side's change at the destination.
7688    RenameContent {
7689        /// The original (pre-rename) path.
7690        old_path: Vec<u8>,
7691    },
7692    /// Two paths were renamed to the same destination, producing a
7693    /// rename/rename(2to1) conflict.
7694    RenameRenameTwoToOne {
7695        /// Ours' pre-destination path.
7696        ours_path: Vec<u8>,
7697        /// Theirs' pre-destination path.
7698        theirs_path: Vec<u8>,
7699    },
7700    /// One source path was renamed to different destinations on each side,
7701    /// producing a rename/rename(1to2) conflict.
7702    RenameRenameOneToTwo {
7703        /// The pre-rename source path.
7704        old_path: Vec<u8>,
7705        /// Ours' destination path.
7706        ours_path: Vec<u8>,
7707        /// Theirs' destination path.
7708        theirs_path: Vec<u8>,
7709        /// The label for our side.
7710        ours_label: String,
7711        /// The label for their side.
7712        theirs_label: String,
7713    },
7714    /// An auxiliary higher-stage entry for a rename/rename(1to2) conflict. The
7715    /// user-facing message is emitted by [`RenameRenameOneToTwo`].
7716    RenameRenameOneToTwoStage,
7717    /// A directory was split evenly across multiple destinations, so no
7718    /// directory rename could be applied for paths the other side left there.
7719    DirRenameSplit {
7720        /// The original directory with no unique destination.
7721        source_dir: Vec<u8>,
7722    },
7723    /// A file renamed on one side whose source was deleted on the other side.
7724    RenameDelete {
7725        /// The pre-rename source path.
7726        old_path: Vec<u8>,
7727        /// The side label that performed the rename.
7728        renamed_in: String,
7729        /// The side label that deleted the source.
7730        deleted_in: String,
7731    },
7732    /// A file collides with a directory at the same path in the merged result:
7733    /// the directory wins at the original path and the file is moved aside to
7734    /// `path~<branch>` (merge-ort's D/F conflict, `unique_path`). git emits
7735    /// `CONFLICT (file/directory): directory in the way of <old> from <branch>;
7736    /// moving it to <new> instead.`
7737    FileDirectory {
7738        /// The original (pre-move) path now occupied by the directory.
7739        original_path: Vec<u8>,
7740        /// The side label whose file was moved aside.
7741        moved_from: String,
7742    },
7743    /// A path was added/renamed under a directory the other side renamed, so the
7744    /// merge silently moved it into the renamed directory but, in
7745    /// `merge.directoryRenames=conflict` mode, flags it for the user to confirm.
7746    /// git emits `CONFLICT (file location): ... suggesting it should perhaps be
7747    /// moved to <new_path>.` The tree still contains the re-homed content.
7748    DirRenameLocation {
7749        /// The pre-re-home path (`old_path` in git's message): where the side
7750        /// placed the file before directory-rename detection moved it.
7751        old_path: Vec<u8>,
7752        /// `Some(source)` when the file was *renamed* into `old_path` by this
7753        /// side (git's "renamed to" wording, naming the original `source`);
7754        /// `None` when it was a fresh add (git's "added in" wording).
7755        renamed_from: Option<Vec<u8>>,
7756        /// The side label that added/renamed the file (`branch_with_new_path`).
7757        added_in: String,
7758        /// The side label that renamed the directory (`branch_with_dir_rename`).
7759        dir_renamed_in: String,
7760        /// True when the directory rename moved the file back onto its own base
7761        /// source path (rename-to-self) and the other side modified that path. The
7762        /// `CONFLICT (file location)` message is the same, but git records the
7763        /// path UNMERGED (stages 1/2/3) instead of staging the re-homed content
7764        /// cleanly: the index writers stage these 1/2/3, not at stage 0.
7765        back_to_self: bool,
7766    },
7767    /// A directory rename would have moved one or more paths onto this path, but
7768    /// it is already occupied (a file/dir in the way) or several sources map
7769    /// here. git emits `CONFLICT (implicit dir rename): Existing file/dir at
7770    /// <path> in the way of implicit directory rename(s) putting the following
7771    /// path(s) there: <sources>.` The path keeps its original content; the
7772    /// re-homed sources are left where they were.
7773    DirRenameImplicitCollision {
7774        /// The source path(s) the directory rename tried to move onto this path.
7775        sources: Vec<Vec<u8>>,
7776    },
7777    /// The two sides hold different object types at one path (regular↔symlink,
7778    /// regular↔gitlink, symlink↔gitlink). git's `process_entry` (merge-ort.c
7779    /// ~4220) renames each *regular-file* side to `path~<branch>` so each type
7780    /// can be recorded somewhere, ignoring `-Xours`/`-Xtheirs`, and emits a
7781    /// single `CONFLICT (distinct types)` line. (gitlink↔gitlink and
7782    /// symlink↔symlink share an `S_IFMT` and never reach this arm.) This kind is
7783    /// attached to the leaf that carries the message — the side left at
7784    /// `original_path` when only one side moved, else ours; the other renamed
7785    /// leaf carries [`DistinctTypesStage`].
7786    DistinctTypes {
7787        /// The original colliding path (git's message subject and sort key).
7788        original_path: Vec<u8>,
7789        /// `Some(p)` when ours was renamed aside to `p`; `None` when ours stayed
7790        /// at `original_path`.
7791        ours_renamed: Option<Vec<u8>>,
7792        /// `Some(p)` when theirs was renamed aside to `p`; `None` when theirs
7793        /// stayed at `original_path`.
7794        theirs_renamed: Option<Vec<u8>>,
7795    },
7796    /// The non-message-carrying leaf of a [`DistinctTypes`] conflict. The
7797    /// user-facing line is emitted once by the [`DistinctTypes`] leaf.
7798    DistinctTypesStage,
7799}
7800
7801/// One resolved/conflicted path in the merged tree.
7802#[derive(Debug, Clone)]
7803pub struct MergedPath {
7804    /// Destination path in the merged tree.
7805    pub path: Vec<u8>,
7806    /// The per-stage (1=base, 2=ours, 3=theirs) entries when conflicted; all
7807    /// `None` for a clean resolution.
7808    pub stages: MergeStages,
7809    /// `Some((mode, oid))` is the final leaf written to the merged tree; `None`
7810    /// means the path is absent in the result (a clean delete).
7811    pub result: Option<(u32, ObjectId)>,
7812    /// When conflicted, the worktree bytes + mode to materialize (content with
7813    /// conflict markers, or the surviving side's bytes). `None` for a clean
7814    /// path.
7815    pub worktree: Option<(u32, Vec<u8>)>,
7816    /// `Some(..)` exactly when this path conflicted.
7817    pub conflict: Option<MergeConflictKind>,
7818    /// True when this path went through a textual 3-way content merge (both
7819    /// sides diverged and both were mergeable files). Drives the "Auto-merging
7820    /// <path>" informational message, which `git merge-tree` emits for every
7821    /// such path — clean or conflicted.
7822    pub auto_merged: bool,
7823}
7824
7825impl MergedPath {
7826    /// True when this path resolved cleanly (no conflict recorded).
7827    pub fn is_clean(&self) -> bool {
7828        self.conflict.is_none()
7829    }
7830}
7831
7832/// Per-stage higher-order index entries for a conflicted path.
7833#[derive(Debug, Clone, Default)]
7834pub struct MergeStages {
7835    pub base: Option<(u32, ObjectId)>,
7836    pub ours: Option<(u32, ObjectId)>,
7837    pub theirs: Option<(u32, ObjectId)>,
7838}
7839
7840/// The outcome of a 3-way tree merge: the merged top-level tree plus per-path
7841/// detail and a clean/conflicted flag.
7842#[derive(Debug, Clone)]
7843pub struct MergeTreesResult {
7844    /// Object id of the merged top-level tree (always written, even on
7845    /// conflict — conflicted blobs go in with their marker content).
7846    pub tree: ObjectId,
7847    /// Per-path results, sorted by path.
7848    pub paths: Vec<MergedPath>,
7849    /// False if any path conflicted.
7850    pub clean: bool,
7851    /// Original paths removed by rename or directory-rename rewrites. These are
7852    /// cleanup-only paths for porcelains materializing a conflicted merge; they
7853    /// are absent from the merged tree.
7854    pub cleanup_paths: Vec<Vec<u8>>,
7855    /// Non-conflict informational messages produced while detecting renames.
7856    pub info_messages: Vec<MergeInfoMessage>,
7857}
7858
7859impl MergeTreesResult {
7860    /// Iterate over the paths that conflicted, in path order.
7861    pub fn conflicts(&self) -> impl Iterator<Item = &MergedPath> {
7862        self.paths.iter().filter(|entry| entry.conflict.is_some())
7863    }
7864}
7865
7866/// Non-conflict merge information that porcelain commands may print.
7867#[derive(Debug, Clone, PartialEq, Eq)]
7868pub enum MergeInfoMessage {
7869    /// A directory rename was skipped because the suggested target directory was
7870    /// itself renamed away on this side.
7871    DirRenameSkippedDueToRerename {
7872        old_dir: Vec<u8>,
7873        path: Vec<u8>,
7874        new_dir: Vec<u8>,
7875    },
7876    /// A path was updated due to a directory rename in
7877    /// `merge.directoryRenames=true` mode.
7878    DirRenameApplied {
7879        old_path: Vec<u8>,
7880        new_path: Vec<u8>,
7881        renamed_from: Option<Vec<u8>>,
7882        added_in: String,
7883        dir_renamed_in: String,
7884    },
7885    /// A directory-rename location conflict that overlaps another conflict at
7886    /// the same final path, such as a content conflict. The path's primary
7887    /// conflict kind remains attached to the path; this carries git's extra
7888    /// `CONFLICT (file location)` line.
7889    DirRenameLocationConflict {
7890        old_path: Vec<u8>,
7891        new_path: Vec<u8>,
7892        renamed_from: Option<Vec<u8>>,
7893        added_in: String,
7894        dir_renamed_in: String,
7895    },
7896    /// A rename/delete conflict whose conflicted destination was later moved
7897    /// aside by directory/file conflict handling. The primary per-path conflict
7898    /// remains `FileDirectory`; this preserves git's extra rename/delete line.
7899    RenameDeleteConflict {
7900        old_path: Vec<u8>,
7901        new_path: Vec<u8>,
7902        renamed_in: String,
7903        deleted_in: String,
7904    },
7905}
7906
7907/// Read a tree object (by oid) into a flattened path -> (mode, oid) map,
7908/// descending into subtrees. The canonical empty tree yields an empty map.
7909pub fn flatten_tree(
7910    reader: &impl ObjectReader,
7911    format: ObjectFormat,
7912    tree_oid: &ObjectId,
7913) -> Result<MergeEntryMap> {
7914    let mut entries = BTreeMap::new();
7915    if *tree_oid == empty_tree_oid(format)? {
7916        return Ok(entries);
7917    }
7918    collect_flat_tree(reader, format, tree_oid, Vec::new(), &mut entries)?;
7919    Ok(entries)
7920}
7921
7922pub fn corrupted_cache_tree_error() -> GitError {
7923    GitError::InvalidFormat("error: corrupted cache-tree has entries not present in index".into())
7924}
7925
7926pub fn tree_has_duplicate_leaf_paths(
7927    reader: &impl ObjectReader,
7928    format: ObjectFormat,
7929    tree_oid: &ObjectId,
7930) -> Result<bool> {
7931    if *tree_oid == empty_tree_oid(format)? {
7932        return Ok(false);
7933    }
7934    let mut seen = BTreeSet::new();
7935    collect_duplicate_leaf_paths(reader, format, tree_oid, Vec::new(), &mut seen)
7936}
7937
7938fn collect_duplicate_leaf_paths(
7939    reader: &impl ObjectReader,
7940    format: ObjectFormat,
7941    tree_oid: &ObjectId,
7942    prefix: Vec<u8>,
7943    seen: &mut BTreeSet<Vec<u8>>,
7944) -> Result<bool> {
7945    let object = reader.read_object(tree_oid)?;
7946    if object.object_type != ObjectType::Tree {
7947        return Err(GitError::InvalidObject(format!(
7948            "expected tree {}, found {}",
7949            tree_oid,
7950            object.object_type.as_str()
7951        )));
7952    }
7953    for entry in TreeEntries::new(format, &object.body) {
7954        let entry = entry?;
7955        let mut path = prefix.clone();
7956        if !path.is_empty() {
7957            path.push(b'/');
7958        }
7959        path.extend_from_slice(entry.name);
7960        if entry.mode == 0o040000 {
7961            if collect_duplicate_leaf_paths(reader, format, &entry.oid, path, seen)? {
7962                return Ok(true);
7963            }
7964        } else if !seen.insert(path) {
7965            return Ok(true);
7966        }
7967    }
7968    Ok(false)
7969}
7970
7971fn collect_flat_tree(
7972    reader: &impl ObjectReader,
7973    format: ObjectFormat,
7974    tree_oid: &ObjectId,
7975    prefix: Vec<u8>,
7976    entries: &mut MergeEntryMap,
7977) -> Result<()> {
7978    let object = reader.read_object(tree_oid)?;
7979    if object.object_type != ObjectType::Tree {
7980        return Err(GitError::InvalidObject(format!(
7981            "expected tree {}, found {}",
7982            tree_oid,
7983            object.object_type.as_str()
7984        )));
7985    }
7986    for entry in TreeEntries::new(format, &object.body) {
7987        let entry = entry?;
7988        let mut path = prefix.clone();
7989        if !path.is_empty() {
7990            path.push(b'/');
7991        }
7992        path.extend_from_slice(entry.name);
7993        if entry.mode == 0o040000 {
7994            collect_flat_tree(reader, format, &entry.oid, path, entries)?;
7995        } else {
7996            entries.insert(path, (entry.mode, entry.oid));
7997        }
7998    }
7999    Ok(())
8000}
8001
8002/// True for a plain file blob (regular or executable) — i.e. a mode whose
8003/// content can be textually 3-way merged. Symlinks and gitlinks are excluded.
8004pub fn is_mergeable_file_mode(mode: u32) -> bool {
8005    mode == 0o100644 || mode == 0o100755
8006}
8007
8008/// 3-way merge of three trees into a single merged tree.
8009///
8010/// `base` is the common-ancestor tree (`None` for unrelated histories — every
8011/// path is then treated as added on both sides). `ours`/`theirs` are the two
8012/// sides. Cleanly-merged blob content and the resulting (sub)trees are written
8013/// to `db`; the returned [`MergeTreesResult`] carries the merged top-level tree
8014/// oid plus per-path detail.
8015///
8016/// This is the shared engine behind `git merge-tree --write-tree`, `git merge`,
8017/// `git cherry-pick`, and `git revert`. It is behaviour-preserving relative to
8018/// the per-command copies it replaced, and additionally resolves renames when
8019/// [`MergeTreesOptions::detect_renames`] is set.
8020pub fn merge_trees(
8021    db: &FileObjectDatabase,
8022    format: ObjectFormat,
8023    base: Option<&ObjectId>,
8024    ours: &ObjectId,
8025    theirs: &ObjectId,
8026    options: &MergeTreesOptions<'_>,
8027) -> Result<MergeTreesResult> {
8028    let base_map = match base {
8029        Some(tree) => flatten_tree(db, format, tree)?,
8030        None => MergeEntryMap::new(),
8031    };
8032    let ours_map = flatten_tree(db, format, ours)?;
8033    let theirs_map = flatten_tree(db, format, theirs)?;
8034    merge_entry_maps(db, format, &base_map, &ours_map, &theirs_map, options)
8035}
8036
8037/// [`merge_trees`] operating on already-flattened entry maps. The merge
8038/// porcelains often hold the flattened maps already (e.g. cherry-pick builds
8039/// `theirs` from a picked commit's tree), so this avoids re-reading them.
8040pub fn merge_entry_maps(
8041    db: &FileObjectDatabase,
8042    format: ObjectFormat,
8043    base_map: &MergeEntryMap,
8044    ours_map: &MergeEntryMap,
8045    theirs_map: &MergeEntryMap,
8046    options: &MergeTreesOptions<'_>,
8047) -> Result<MergeTreesResult> {
8048    // Rename-aware step: detect files renamed on exactly one side relative to
8049    // base, so a modification on the other side follows the rename. This is the
8050    // non-recursive merge-ort rename case. We compute a rewrite map that, for a
8051    // one-sided rename old->new, presents the *other* side's `old` content at
8052    // `new` (and drops `old`), letting the path-keyed core below do the 3-way
8053    // content merge at the destination.
8054    let (mut renames, side_renames) = if options.detect_renames {
8055        let (renames, ours_side, theirs_side) =
8056            detect_merge_renames(db, format, base_map, ours_map, theirs_map, options)?;
8057        (renames, Some((ours_side, theirs_side)))
8058    } else {
8059        (MergeRenames::default(), None)
8060    };
8061
8062    // Build the effective per-side maps with file renames applied.
8063    let (mut eff_base, mut eff_ours, mut eff_theirs) =
8064        apply_merge_renames(base_map, ours_map, theirs_map, &renames);
8065
8066    // Directory-rename detection: when one side renamed a whole directory and
8067    // the other side added a file under (or renamed a file into) the old
8068    // directory, re-home that path into the renamed directory — including
8069    // transitive renames (a file the other side renamed into a directory this
8070    // side renamed follows on into the final directory). This is the
8071    // merge.directoryRenames behaviour, applied as a rewrite of the rename/add
8072    // destination paths so every merged path consults directory renames.
8073    let mut dir_rename_dirty = false;
8074    let mut rehomed_paths: BTreeMap<Vec<u8>, RehomeSides> = BTreeMap::new();
8075    let mut dir_rename_two_to_one: Vec<DirRenameTwoToOne> = Vec::new();
8076    let mut dir_rename_collisions: Vec<DirRenameCollision> = Vec::new();
8077    let mut dir_rename_splits: BTreeSet<Vec<u8>> = BTreeSet::new();
8078    let mut dir_rename_back_to_self: BTreeSet<Vec<u8>> = BTreeSet::new();
8079    let mut info_messages = Vec::new();
8080    let mut cleanup_paths: BTreeSet<Vec<u8>> = renames
8081        .dest_to_source
8082        .values()
8083        .map(|rename| rename.source.clone())
8084        .collect();
8085    if options.directory_renames != DirectoryRenames::False
8086        && let Some((ours_side, theirs_side)) = &side_renames
8087    {
8088        let dir_renames = compute_directory_renames(ours_map, theirs_map, ours_side, theirs_side);
8089        let outcome = apply_directory_renames(
8090            base_map,
8091            &eff_base,
8092            &eff_ours,
8093            &eff_theirs,
8094            ours_side,
8095            theirs_side,
8096            &dir_renames,
8097            &renames.dest_to_source,
8098        );
8099        eff_base = outcome.base;
8100        eff_ours = outcome.ours;
8101        eff_theirs = outcome.theirs;
8102        rehomed_paths = outcome.rehomed;
8103        dir_rename_collisions = outcome.collisions;
8104        dir_rename_splits = outcome.splits;
8105        dir_rename_back_to_self = outcome.back_to_self;
8106        info_messages = outcome.info_messages;
8107        dir_rename_dirty = outcome.dirty;
8108        remap_rename_destinations(&mut renames, &rehomed_paths);
8109        drop_collapsed_rename_rename_conflicts(&mut renames);
8110        dir_rename_two_to_one = collect_dir_rename_two_to_one(&renames, &rehomed_paths);
8111    }
8112    for info in rehomed_paths
8113        .values()
8114        .flat_map(|sides| [&sides.ours, &sides.theirs])
8115        .flatten()
8116    {
8117        cleanup_paths.insert(info.old_path.clone());
8118    }
8119    if options.directory_renames == DirectoryRenames::True {
8120        for (dest, sides) in &rehomed_paths {
8121            for info in [&sides.ours, &sides.theirs].into_iter().flatten() {
8122                let (added_in, dir_renamed_in) = if info.added_on_ours {
8123                    (
8124                        options.ours_label.to_string(),
8125                        options.theirs_label.to_string(),
8126                    )
8127                } else {
8128                    (
8129                        options.theirs_label.to_string(),
8130                        options.ours_label.to_string(),
8131                    )
8132                };
8133                info_messages.push(MergeInfoMessage::DirRenameApplied {
8134                    old_path: info.old_path.clone(),
8135                    new_path: dest.clone(),
8136                    renamed_from: info.renamed_from.clone(),
8137                    added_in,
8138                    dir_renamed_in,
8139                });
8140            }
8141        }
8142    }
8143    // In =conflict mode, every re-homed path is reported as a location conflict
8144    // (the tree still gets the re-homed content, but the merge is marked dirty).
8145    let dir_rename_conflict_paths: BTreeMap<Vec<u8>, RehomeSides> =
8146        if options.directory_renames == DirectoryRenames::Conflict {
8147            rehomed_paths.clone()
8148        } else {
8149            BTreeMap::new()
8150        };
8151
8152    let mut all_paths = BTreeSet::new();
8153    all_paths.extend(eff_base.keys().cloned());
8154    all_paths.extend(eff_ours.keys().cloned());
8155    all_paths.extend(eff_theirs.keys().cloned());
8156
8157    let mut paths: Vec<MergedPath> = Vec::new();
8158    let mut leaves: MergeEntryMap = BTreeMap::new();
8159    let mut clean = true;
8160
8161    for path in all_paths {
8162        let base = eff_base.get(&path).cloned();
8163        let ours = eff_ours.get(&path).cloned();
8164        let theirs = eff_theirs.get(&path).cloned();
8165        let rename = renames.dest_to_source.get(&path);
8166        let old_path = rename.map(|r| r.source.clone());
8167        let favor = merge_favor_for_path(options, &path);
8168
8169        // Trivial resolutions (identical to the historical per-command logic).
8170        if ours == theirs {
8171            if let Some(entry) = ours {
8172                leaves.insert(path.clone(), entry);
8173            }
8174            paths.push(clean_path(path, ours));
8175            continue;
8176        }
8177        if ours == base {
8178            if let Some(entry) = &theirs {
8179                leaves.insert(path.clone(), *entry);
8180            }
8181            paths.push(clean_path(path, theirs));
8182            continue;
8183        }
8184        if theirs == base {
8185            if let Some(entry) = &ours {
8186                leaves.insert(path.clone(), *entry);
8187            }
8188            paths.push(clean_path(path, ours));
8189            continue;
8190        }
8191
8192        // Both sides diverged. Decide how to combine.
8193        let content_mergeable = matches!(&ours, Some((mode, _)) if is_mergeable_file_mode(*mode))
8194            && matches!(&theirs, Some((mode, _)) if is_mergeable_file_mode(*mode))
8195            && match &base {
8196                Some((mode, _)) => is_mergeable_file_mode(*mode),
8197                None => true,
8198            };
8199
8200        if let (true, Some((ours_mode, ours_oid)), Some((theirs_mode, theirs_oid))) =
8201            (content_mergeable, &ours, &theirs)
8202        {
8203            let add_add = base.is_none();
8204            let base_bytes = match &base {
8205                Some((_, oid)) => merge_blob_bytes(db, oid)?,
8206                None => Vec::new(),
8207            };
8208            let ours_bytes = merge_blob_bytes(db, ours_oid)?;
8209            let theirs_bytes = merge_blob_bytes(db, theirs_oid)?;
8210            // When this destination came from a one-sided rename, git qualifies
8211            // the conflict-marker labels with the per-side path (the renaming
8212            // side shows the new path, the other side the old path), e.g.
8213            // `<<<<<<< HEAD:old.txt` / `>>>>>>> feature:new.txt`.
8214            let rehome = rehomed_paths.get(&path);
8215            // git's `merge_3way` qualifies all three labels with their per-side
8216            // path (`<name>:<path>`) whenever the three paths are not identical —
8217            // pathnames[0] is the base/ancestor path (the rename source). When
8218            // they are identical (no rename), it uses the bare names.
8219            let (base_label_owned, ours_label, theirs_label) = match rename {
8220                Some(MergeRename { source, side }) => {
8221                    let (ours_path, theirs_path) = match side {
8222                        // theirs renamed -> ours kept the source path.
8223                        RenameSide::Theirs => (source.as_slice(), path.as_slice()),
8224                        // ours renamed -> theirs kept the source path.
8225                        RenameSide::Ours => (path.as_slice(), source.as_slice()),
8226                    };
8227                    (
8228                        qualify_label(options.ancestor_label, source.as_slice()),
8229                        qualify_label(options.ours_label, ours_path),
8230                        qualify_label(options.theirs_label, theirs_path),
8231                    )
8232                }
8233                None => {
8234                    let ours_path = rehome
8235                        .and_then(|info| info.ours.as_ref())
8236                        .map_or(path.as_slice(), |info| info.old_path.as_slice());
8237                    let theirs_path = rehome
8238                        .and_then(|info| info.theirs.as_ref())
8239                        .map_or(path.as_slice(), |info| info.old_path.as_slice());
8240                    if ours_path != path.as_slice() || theirs_path != path.as_slice() {
8241                        (
8242                            qualify_label(options.ancestor_label, path.as_slice()),
8243                            qualify_label(options.ours_label, ours_path),
8244                            qualify_label(options.theirs_label, theirs_path),
8245                        )
8246                    } else {
8247                        (
8248                            options.ancestor_label.to_string(),
8249                            options.ours_label.to_string(),
8250                            options.theirs_label.to_string(),
8251                        )
8252                    }
8253                }
8254            };
8255            let result = merge_blobs(
8256                &base_bytes,
8257                &ours_bytes,
8258                &theirs_bytes,
8259                &MergeBlobOptions {
8260                    ours_label: &ours_label,
8261                    theirs_label: &theirs_label,
8262                    base_label: &base_label_owned,
8263                    style: options.style,
8264                    favor,
8265                    ws_ignore: options.ws_ignore,
8266                    marker_size: merge_marker_size_for_path(options, &path),
8267                },
8268            );
8269
8270            let base_mode = base.as_ref().map(|(mode, _)| *mode);
8271            let (resolved_mode, mode_conflict) =
8272                merge_file_modes(base_mode, *ours_mode, *theirs_mode);
8273
8274            if !result.conflicted && !mode_conflict {
8275                let oid = db.write_object(EncodedObject::new(ObjectType::Blob, result.content))?;
8276                leaves.insert(path.clone(), (resolved_mode, oid));
8277                paths.push(clean_path_auto(path, Some((resolved_mode, oid)), true));
8278            } else if favor != MergeFavor::None && !mode_conflict {
8279                let chosen = if favor == MergeFavor::Ours {
8280                    ours
8281                } else {
8282                    theirs
8283                };
8284                if let Some(entry) = chosen {
8285                    leaves.insert(path.clone(), entry);
8286                }
8287                paths.push(clean_path_auto(path, chosen, true));
8288            } else {
8289                clean = false;
8290                let oid =
8291                    db.write_object(EncodedObject::new(ObjectType::Blob, result.content.clone()))?;
8292                leaves.insert(path.clone(), (resolved_mode, oid));
8293                let worktree_mode = if *ours_mode == *theirs_mode {
8294                    *ours_mode
8295                } else {
8296                    0o100644
8297                };
8298                let conflict = if let Some(old) = &old_path {
8299                    MergeConflictKind::RenameContent {
8300                        old_path: old.clone(),
8301                    }
8302                } else if add_add {
8303                    match rehome.and_then(|info| Some((info.ours.as_ref()?, info.theirs.as_ref()?)))
8304                    {
8305                        Some((ours_info, theirs_info)) => MergeConflictKind::RenameRenameTwoToOne {
8306                            ours_path: ours_info.old_path.clone(),
8307                            theirs_path: theirs_info.old_path.clone(),
8308                        },
8309                        None => MergeConflictKind::Content { add_add },
8310                    }
8311                } else {
8312                    MergeConflictKind::Content { add_add }
8313                };
8314                paths.push(MergedPath {
8315                    path: path.clone(),
8316                    stages: stages_for(&base, &ours, &theirs),
8317                    result: Some((resolved_mode, oid)),
8318                    worktree: Some((worktree_mode, result.content)),
8319                    conflict: Some(conflict),
8320                    auto_merged: true,
8321                });
8322            }
8323        } else if base.is_some() && (ours.is_none() || theirs.is_none()) {
8324            // modify/delete.
8325            clean = false;
8326            let (deleted_in, modified_in, surviving) = if ours.is_none() {
8327                (
8328                    options.ours_label.to_string(),
8329                    options.theirs_label.to_string(),
8330                    theirs,
8331                )
8332            } else {
8333                (
8334                    options.theirs_label.to_string(),
8335                    options.ours_label.to_string(),
8336                    ours,
8337                )
8338            };
8339            let worktree = match &surviving {
8340                Some((mode, oid)) => Some((*mode, merge_worktree_bytes(db, *mode, oid)?)),
8341                None => None,
8342            };
8343            if let Some(entry) = surviving {
8344                leaves.insert(path.clone(), entry);
8345            }
8346            paths.push(MergedPath {
8347                path: path.clone(),
8348                stages: stages_for(&base, &ours, &theirs),
8349                result: surviving,
8350                worktree,
8351                conflict: Some(MergeConflictKind::ModifyDelete {
8352                    deleted_in,
8353                    modified_in,
8354                }),
8355                auto_merged: false,
8356            });
8357        } else if let (Some(&(ours_mode, ours_oid)), Some(&(theirs_mode, theirs_oid))) =
8358            (ours.as_ref(), theirs.as_ref())
8359            && sley_index::is_symlink_mode(ours_mode)
8360            && sley_index::is_symlink_mode(theirs_mode)
8361        {
8362            // Both sides are symlinks that diverged from the base and from each
8363            // other (the trivial oid resolutions above already took the agreeing
8364            // cases). A symlink is never textually merged; git's
8365            // `handle_content_merge` symlink arm (merge-ort.c) resolves CLEAN to
8366            // a side under `-Xours`/`-Xtheirs`, and otherwise records a CONFLICT
8367            // carrying ours' target.
8368            match favor {
8369                MergeFavor::Ours => {
8370                    leaves.insert(path.clone(), (ours_mode, ours_oid));
8371                    paths.push(clean_path_auto(
8372                        path.clone(),
8373                        Some((ours_mode, ours_oid)),
8374                        false,
8375                    ));
8376                }
8377                MergeFavor::Theirs => {
8378                    leaves.insert(path.clone(), (theirs_mode, theirs_oid));
8379                    paths.push(clean_path_auto(
8380                        path.clone(),
8381                        Some((theirs_mode, theirs_oid)),
8382                        false,
8383                    ));
8384                }
8385                MergeFavor::None | MergeFavor::Union => {
8386                    clean = false;
8387                    leaves.insert(path.clone(), (ours_mode, ours_oid));
8388                    let worktree =
8389                        Some((ours_mode, merge_worktree_bytes(db, ours_mode, &ours_oid)?));
8390                    paths.push(MergedPath {
8391                        path: path.clone(),
8392                        stages: stages_for(&base, &ours, &theirs),
8393                        result: Some((ours_mode, ours_oid)),
8394                        worktree,
8395                        conflict: Some(MergeConflictKind::Content {
8396                            add_add: base.is_none(),
8397                        }),
8398                        auto_merged: false,
8399                    });
8400                }
8401            }
8402        } else if let (Some((ours_mode, ours_oid)), Some((theirs_mode, theirs_oid))) =
8403            (ours, theirs)
8404            && is_type_change(ours_mode, theirs_mode)
8405        {
8406            // Distinct types at one path: both sides present with different
8407            // `S_IFMT` (regular↔symlink, regular↔gitlink, symlink↔gitlink).
8408            // Mirror merge-ort's `process_entry`: rename each regular-file side
8409            // to `path~<branch>` so each type is recorded somewhere; ignore
8410            // `-Xours`/`-Xtheirs`. gitlink↔gitlink and symlink↔symlink share an
8411            // `S_IFMT` and are handled by the arms above.
8412            clean = false;
8413            // git renames the regular-file side(s): only the regular side when
8414            // exactly one is regular, both when neither is (symlink↔gitlink).
8415            let (rename_ours, rename_theirs) = if is_mergeable_file_mode(ours_mode) {
8416                (true, false)
8417            } else if is_mergeable_file_mode(theirs_mode) {
8418                (false, true)
8419            } else {
8420                (true, true)
8421            };
8422            // git keeps the base stage (index stage 1) for a side only when that
8423            // side shares the base's file type.
8424            let ours_base = base.filter(|(mode, _)| !is_type_change(*mode, ours_mode));
8425            let theirs_base = base.filter(|(mode, _)| !is_type_change(*mode, theirs_mode));
8426            // Name and reserve ours' aside-path first so the two renamed paths
8427            // can never collide (`unique_df_path` consults `leaves`/`paths`).
8428            let ours_path = if rename_ours {
8429                unique_df_path(&path, options.ours_label, &leaves, &paths)
8430            } else {
8431                path.clone()
8432            };
8433            leaves.insert(ours_path.clone(), (ours_mode, ours_oid));
8434            let theirs_path = if rename_theirs {
8435                unique_df_path(&path, options.theirs_label, &leaves, &paths)
8436            } else {
8437                path.clone()
8438            };
8439            leaves.insert(theirs_path.clone(), (theirs_mode, theirs_oid));
8440
8441            // The message is emitted once, by the leaf left at `original_path`
8442            // when only one side moved (matching git's keying), else by ours.
8443            let ours_carries_message = !rename_ours || rename_theirs;
8444            let distinct = MergeConflictKind::DistinctTypes {
8445                original_path: path.clone(),
8446                ours_renamed: rename_ours.then(|| ours_path.clone()),
8447                theirs_renamed: rename_theirs.then(|| theirs_path.clone()),
8448            };
8449            let ours_worktree = Some((ours_mode, merge_worktree_bytes(db, ours_mode, &ours_oid)?));
8450            paths.push(MergedPath {
8451                path: ours_path,
8452                stages: MergeStages {
8453                    base: ours_base,
8454                    ours: Some((ours_mode, ours_oid)),
8455                    theirs: None,
8456                },
8457                result: Some((ours_mode, ours_oid)),
8458                worktree: ours_worktree,
8459                conflict: Some(if ours_carries_message {
8460                    distinct.clone()
8461                } else {
8462                    MergeConflictKind::DistinctTypesStage
8463                }),
8464                auto_merged: false,
8465            });
8466            let theirs_worktree = Some((
8467                theirs_mode,
8468                merge_worktree_bytes(db, theirs_mode, &theirs_oid)?,
8469            ));
8470            paths.push(MergedPath {
8471                path: theirs_path,
8472                stages: MergeStages {
8473                    base: theirs_base,
8474                    ours: None,
8475                    theirs: Some((theirs_mode, theirs_oid)),
8476                },
8477                result: Some((theirs_mode, theirs_oid)),
8478                worktree: theirs_worktree,
8479                conflict: Some(if ours_carries_message {
8480                    MergeConflictKind::DistinctTypesStage
8481                } else {
8482                    distinct
8483                }),
8484                auto_merged: false,
8485            });
8486        } else {
8487            // add/add of non-files, mode changes on same-type entries, etc. Keep
8488            // the surviving side's content and record a generic content conflict.
8489            clean = false;
8490            let add_add = base.is_none();
8491            let surviving = ours.or(theirs);
8492            let worktree = match &surviving {
8493                Some((mode, oid)) => Some((*mode, merge_worktree_bytes(db, *mode, oid)?)),
8494                None => None,
8495            };
8496            if let Some(entry) = surviving {
8497                leaves.insert(path.clone(), entry);
8498            }
8499            paths.push(MergedPath {
8500                path: path.clone(),
8501                stages: stages_for(&base, &ours, &theirs),
8502                result: surviving,
8503                worktree,
8504                conflict: Some(MergeConflictKind::Content { add_add }),
8505                auto_merged: false,
8506            });
8507        }
8508    }
8509
8510    if !renames.rename_rename_one_to_two.is_empty() {
8511        apply_rename_rename_one_to_two_conflicts(
8512            db,
8513            base_map,
8514            &eff_ours,
8515            &eff_theirs,
8516            &renames.rename_rename_one_to_two,
8517            &mut paths,
8518            &mut leaves,
8519            options,
8520        )?;
8521        clean = false;
8522    }
8523
8524    if !dir_rename_two_to_one.is_empty() {
8525        apply_dir_rename_two_to_one_conflicts(
8526            db,
8527            &eff_ours,
8528            &eff_theirs,
8529            &dir_rename_two_to_one,
8530            &mut paths,
8531            &mut leaves,
8532            options,
8533        )?;
8534        clean = false;
8535    }
8536
8537    // Rename/rename(2to1) and rename/add: two distinct contents collide on one
8538    // destination (and the rename source(s) are consumed). Detected from the full
8539    // per-side rename sets, applied here so the destination carries both sides'
8540    // content-merged stages instead of the path-keyed core's raw add/add.
8541    if !renames.rename_rename_two_to_one.is_empty() || !renames.rename_adds.is_empty() {
8542        apply_rename_two_to_one_and_add_conflicts(
8543            db,
8544            base_map,
8545            ours_map,
8546            theirs_map,
8547            &renames,
8548            &mut paths,
8549            &mut leaves,
8550            options,
8551        )?;
8552        clean = false;
8553    }
8554
8555    // Rename/delete conflicts: a file renamed on one side whose source the other
8556    // side deleted. The merge core resolved the destination cleanly (only the
8557    // renaming side has it), but git flags this as a conflict — keep the renamed
8558    // content in the tree, record higher-order stages, and mark the merge dirty.
8559    if !renames.rename_deletes.is_empty() {
8560        for (dest, rd) in &renames.rename_deletes {
8561            // Skip if another conflict already claimed this destination.
8562            let Some(slot) = paths.iter_mut().find(|p| &p.path == dest) else {
8563                continue;
8564            };
8565            if slot.conflict.is_some() {
8566                continue;
8567            }
8568            let base_entry = base_map.get(&rd.source).copied();
8569            let renamed_entry = slot.result;
8570            // The renamed content sits on the renaming side; the deleting side
8571            // contributes no stage at the destination.
8572            let (ours_stage, theirs_stage) = match rd.side {
8573                RenameSide::Ours => (renamed_entry, None),
8574                RenameSide::Theirs => (None, renamed_entry),
8575            };
8576            let (renamed_in, deleted_in) = match rd.side {
8577                RenameSide::Ours => (
8578                    options.ours_label.to_string(),
8579                    options.theirs_label.to_string(),
8580                ),
8581                RenameSide::Theirs => (
8582                    options.theirs_label.to_string(),
8583                    options.ours_label.to_string(),
8584                ),
8585            };
8586            let worktree = match &renamed_entry {
8587                Some((mode, oid)) => Some((*mode, merge_worktree_bytes(db, *mode, oid)?)),
8588                None => None,
8589            };
8590            slot.stages = MergeStages {
8591                base: base_entry,
8592                ours: ours_stage,
8593                theirs: theirs_stage,
8594            };
8595            slot.worktree = worktree;
8596            slot.conflict = Some(MergeConflictKind::RenameDelete {
8597                old_path: rd.source.clone(),
8598                renamed_in,
8599                deleted_in,
8600            });
8601            clean = false;
8602        }
8603    }
8604
8605    // Directory-rename outcomes that make the merge dirty. A collision/split
8606    // detected while re-homing (two paths onto one destination, an ambiguous
8607    // split source, or a file in the way) marks the merge unclean regardless of
8608    // mode. In =conflict mode, every silently re-homed path is *also* reported
8609    // as a location conflict: the tree keeps the re-homed content but git wants
8610    // the user to confirm the suggested move.
8611    if dir_rename_dirty {
8612        clean = false;
8613    }
8614    // Implicit-directory-rename collisions (a directory rename would put a path
8615    // onto an existing file/dir, or N paths onto one destination). git emits
8616    // `CONFLICT (implicit dir rename): Existing file/dir at <dest> in the way ...`
8617    // regardless of mode, and the merge is unclean. Attach the conflict to the
8618    // blocked destination path (which keeps its original content).
8619    for collision in &dir_rename_collisions {
8620        clean = false;
8621        if let Some(slot) = paths.iter_mut().find(|p| p.path == collision.dest)
8622            && slot.conflict.is_none()
8623        {
8624            slot.conflict = Some(MergeConflictKind::DirRenameImplicitCollision {
8625                sources: collision.sources.clone(),
8626            });
8627        } else if !paths.iter().any(|p| p.path == collision.dest) {
8628            paths.push(MergedPath {
8629                path: collision.dest.clone(),
8630                stages: MergeStages::default(),
8631                result: None,
8632                worktree: None,
8633                conflict: Some(MergeConflictKind::DirRenameImplicitCollision {
8634                    sources: collision.sources.clone(),
8635                }),
8636                auto_merged: false,
8637            });
8638        }
8639    }
8640    for source_dir in &dir_rename_splits {
8641        clean = false;
8642        paths.push(MergedPath {
8643            path: source_dir.clone(),
8644            stages: MergeStages::default(),
8645            result: None,
8646            worktree: None,
8647            conflict: Some(MergeConflictKind::DirRenameSplit {
8648                source_dir: source_dir.clone(),
8649            }),
8650            auto_merged: false,
8651        });
8652    }
8653    if !dir_rename_conflict_paths.is_empty() {
8654        clean = false;
8655        for (dest, infos) in &dir_rename_conflict_paths {
8656            for info in [&infos.ours, &infos.theirs].into_iter().flatten() {
8657                let (added_in, dir_renamed_in) = if info.added_on_ours {
8658                    // The path was added/renamed by ours, into a dir theirs renamed.
8659                    (
8660                        options.ours_label.to_string(),
8661                        options.theirs_label.to_string(),
8662                    )
8663                } else {
8664                    (
8665                        options.theirs_label.to_string(),
8666                        options.ours_label.to_string(),
8667                    )
8668                };
8669                // Rename-to-self via a directory rename (merge-ort 12i2): the
8670                // re-home landed the file back on its own base source path where
8671                // the other side modified it. git records this UNMERGED (UU) even
8672                // though the trivial 3-way at the destination resolves cleanly
8673                // (the renamed side's content equals base). Stage the three
8674                // versions so the index carries the conflict.
8675                let back_to_self = dir_rename_back_to_self.contains(dest);
8676                if let Some(slot) = paths.iter_mut().find(|p| &p.path == dest)
8677                    && slot.conflict.is_none()
8678                {
8679                    if back_to_self {
8680                        slot.stages = MergeStages {
8681                            base: eff_base.get(dest).copied(),
8682                            ours: eff_ours.get(dest).copied(),
8683                            theirs: eff_theirs.get(dest).copied(),
8684                        };
8685                        slot.worktree = match &slot.result {
8686                            Some((mode, oid)) => {
8687                                Some((*mode, merge_worktree_bytes(db, *mode, oid)?))
8688                            }
8689                            None => slot.worktree.clone(),
8690                        };
8691                    }
8692                    slot.conflict = Some(MergeConflictKind::DirRenameLocation {
8693                        old_path: info.old_path.clone(),
8694                        renamed_from: info.renamed_from.clone(),
8695                        added_in,
8696                        dir_renamed_in,
8697                        back_to_self,
8698                    });
8699                } else {
8700                    info_messages.push(MergeInfoMessage::DirRenameLocationConflict {
8701                        old_path: info.old_path.clone(),
8702                        new_path: dest.clone(),
8703                        renamed_from: info.renamed_from.clone(),
8704                        added_in,
8705                        dir_renamed_in,
8706                    });
8707                }
8708            }
8709        }
8710    }
8711
8712    // Directory/file (D/F) conflict resolution (merge-ort `process_entry`): a
8713    // path that ends up as a *file* in the merged result while another result
8714    // path lives *under* it (so the path is simultaneously a directory) cannot
8715    // coexist. git keeps the directory at the original path and moves the file
8716    // aside to `path~<branch>` via `unique_path`, where `<branch>` is the side
8717    // that contributed the file. We resolve this on the flattened `leaves` after
8718    // every per-path decision is made, so renames/dir-renames have settled first.
8719    resolve_directory_file_conflicts(
8720        db,
8721        &mut paths,
8722        &mut leaves,
8723        &mut clean,
8724        &eff_ours,
8725        &eff_theirs,
8726        options,
8727        &mut info_messages,
8728    )?;
8729
8730    let tree = write_merged_tree(db, &leaves)?;
8731
8732    cleanup_paths.retain(|path| !leaves.contains_key(path));
8733
8734    Ok(MergeTreesResult {
8735        tree,
8736        paths,
8737        clean,
8738        cleanup_paths: cleanup_paths.into_iter().collect(),
8739        info_messages,
8740    })
8741}
8742
8743/// Flatten a branch label the way git's `add_flattened_path` does for
8744/// `unique_path`: any `/` in the branch name becomes `_` so the synthesized
8745/// `path~branch` stays a single path component family.
8746fn flatten_branch_label(branch: &str) -> String {
8747    branch.replace('/', "_")
8748}
8749
8750/// Pick a `path~<branch>` name not already present in `leaves` (or claimed by an
8751/// existing `paths` entry), mirroring merge-ort's `unique_path`: start from
8752/// `path~branch`, then append `_0`, `_1`, … on collision.
8753fn unique_df_path(
8754    path: &[u8],
8755    branch: &str,
8756    leaves: &MergeEntryMap,
8757    paths: &[MergedPath],
8758) -> Vec<u8> {
8759    let mut base = path.to_vec();
8760    base.push(b'~');
8761    base.extend_from_slice(flatten_branch_label(branch).as_bytes());
8762    let taken = |candidate: &[u8]| {
8763        leaves.contains_key(candidate) || paths.iter().any(|p| p.path == candidate)
8764    };
8765    if !taken(&base) {
8766        return base;
8767    }
8768    let mut suffix = 0usize;
8769    loop {
8770        let mut candidate = base.clone();
8771        candidate.push(b'_');
8772        candidate.extend_from_slice(suffix.to_string().as_bytes());
8773        if !taken(&candidate) {
8774            return candidate;
8775        }
8776        suffix += 1;
8777    }
8778}
8779
8780/// Resolve directory/file collisions in the merged leaf set. For every file leaf
8781/// whose path is also a directory (some other leaf lives under `path/`), move the
8782/// file to `path~<branch>` and record a [`MergeConflictKind::FileDirectory`].
8783#[allow(clippy::too_many_arguments)]
8784fn resolve_directory_file_conflicts(
8785    db: &FileObjectDatabase,
8786    paths: &mut Vec<MergedPath>,
8787    leaves: &mut MergeEntryMap,
8788    clean: &mut bool,
8789    eff_ours: &MergeEntryMap,
8790    eff_theirs: &MergeEntryMap,
8791    options: &MergeTreesOptions<'_>,
8792    info_messages: &mut Vec<MergeInfoMessage>,
8793) -> Result<()> {
8794    // A path is a "directory" in the result iff some leaf key has it as a strict
8795    // `path/` prefix. Collect every such directory prefix once.
8796    let mut directory_prefixes: BTreeSet<Vec<u8>> = BTreeSet::new();
8797    for key in leaves.keys() {
8798        let mut idx = 0;
8799        while let Some(pos) = key[idx..].iter().position(|b| *b == b'/') {
8800            let end = idx + pos;
8801            directory_prefixes.insert(key[..end].to_vec());
8802            idx = end + 1;
8803        }
8804    }
8805    if directory_prefixes.is_empty() {
8806        return Ok(());
8807    }
8808
8809    // File leaves that collide with a directory of the same name.
8810    let colliding: Vec<Vec<u8>> = leaves
8811        .keys()
8812        .filter(|key| directory_prefixes.contains(*key))
8813        .cloned()
8814        .collect();
8815
8816    for original in colliding {
8817        let Some(entry) = leaves.remove(&original) else {
8818            continue;
8819        };
8820        // The moved-aside file must be materialized in the worktree at its new
8821        // path; read its blob bytes once so the porcelain has worktree content.
8822        let moved_bytes = merge_worktree_bytes(db, entry.0, &entry.1)?;
8823        // Which side contributed the file? git keys off `dirmask`: the file lives
8824        // on the side that is NOT the directory. We read it off the effective side
8825        // maps — whichever side has this path as a plain file. When only theirs has
8826        // it, use the theirs label; otherwise (ours has it, or both do) ours wins,
8827        // matching git's index-1 bias for the moved-aside name.
8828        let ours_has_file = eff_ours.contains_key(&original);
8829        let theirs_has_file = eff_theirs.contains_key(&original);
8830        let from_ours = ours_has_file || !theirs_has_file;
8831        let branch = if from_ours {
8832            options.ours_label
8833        } else {
8834            options.theirs_label
8835        };
8836        let new_path = unique_df_path(&original, branch, leaves, paths);
8837        leaves.insert(new_path.clone(), entry);
8838        *clean = false;
8839
8840        // Relocate the path's MergedPath: update its destination and stamp the D/F
8841        // conflict. If the path had no MergedPath (defensive), synthesize one.
8842        if let Some(slot) = paths.iter_mut().find(|p| p.path == original) {
8843            if let Some(MergeConflictKind::RenameDelete {
8844                old_path,
8845                renamed_in,
8846                deleted_in,
8847            }) = &slot.conflict
8848            {
8849                info_messages.push(MergeInfoMessage::RenameDeleteConflict {
8850                    old_path: old_path.clone(),
8851                    new_path: original.clone(),
8852                    renamed_in: renamed_in.clone(),
8853                    deleted_in: deleted_in.clone(),
8854                });
8855            }
8856            slot.path = new_path.clone();
8857            slot.result = Some(entry);
8858            // Preserve any pre-existing higher-order stages; a clean file leaf has
8859            // none, so seed ours/theirs from the effective maps for `ls-files -u`.
8860            if slot.stages.base.is_none()
8861                && slot.stages.ours.is_none()
8862                && slot.stages.theirs.is_none()
8863            {
8864                slot.stages = MergeStages {
8865                    base: None,
8866                    ours: if from_ours { Some(entry) } else { None },
8867                    theirs: if from_ours { None } else { Some(entry) },
8868                };
8869            }
8870            // Keep the slot's existing `auto_merged`: git only emits
8871            // `Auto-merging <new_path>` for the moved file when a real content
8872            // merge ran (a rename or both-sides change drives filemask>=6 through
8873            // handle_content_merge). A plain one-sided add (filemask 2/4) is moved
8874            // aside silently, so we must NOT force the flag on here.
8875            slot.worktree = Some((entry.0, moved_bytes));
8876            slot.conflict = Some(MergeConflictKind::FileDirectory {
8877                original_path: original.clone(),
8878                moved_from: branch.to_string(),
8879            });
8880        } else {
8881            paths.push(MergedPath {
8882                path: new_path.clone(),
8883                stages: MergeStages {
8884                    base: None,
8885                    ours: if from_ours { Some(entry) } else { None },
8886                    theirs: if from_ours { None } else { Some(entry) },
8887                },
8888                result: Some(entry),
8889                worktree: Some((entry.0, moved_bytes)),
8890                conflict: Some(MergeConflictKind::FileDirectory {
8891                    original_path: original.clone(),
8892                    moved_from: branch.to_string(),
8893                }),
8894                auto_merged: false,
8895            });
8896        }
8897    }
8898
8899    // Keep `paths` sorted by destination path (callers and tests assume order).
8900    paths.sort_by(|a, b| a.path.cmp(&b.path));
8901    Ok(())
8902}
8903
8904/// Construct a clean (non-conflicted) [`MergedPath`].
8905fn clean_path(path: Vec<u8>, result: Option<(u32, ObjectId)>) -> MergedPath {
8906    clean_path_auto(path, result, false)
8907}
8908
8909/// Like [`clean_path`] but records whether the path went through a textual
8910/// 3-way content merge (for the "Auto-merging" message).
8911fn clean_path_auto(
8912    path: Vec<u8>,
8913    result: Option<(u32, ObjectId)>,
8914    auto_merged: bool,
8915) -> MergedPath {
8916    MergedPath {
8917        path,
8918        stages: MergeStages::default(),
8919        result,
8920        worktree: None,
8921        conflict: None,
8922        auto_merged,
8923    }
8924}
8925
8926/// Snapshot the present stages for a conflicted path.
8927fn stages_for(
8928    base: &Option<(u32, ObjectId)>,
8929    ours: &Option<(u32, ObjectId)>,
8930    theirs: &Option<(u32, ObjectId)>,
8931) -> MergeStages {
8932    MergeStages {
8933        base: *base,
8934        ours: *ours,
8935        theirs: *theirs,
8936    }
8937}
8938
8939/// Read a blob's raw bytes, requiring it to be a blob object.
8940fn merge_blob_bytes(reader: &impl ObjectReader, oid: &ObjectId) -> Result<Vec<u8>> {
8941    let object = reader.read_object(oid)?;
8942    if object.object_type != ObjectType::Blob {
8943        return Err(GitError::InvalidObject(format!(
8944            "expected blob {}, found {}",
8945            oid,
8946            object.object_type.as_str()
8947        )));
8948    }
8949    Ok(object.body.clone())
8950}
8951
8952fn merge_worktree_bytes(reader: &impl ObjectReader, mode: u32, oid: &ObjectId) -> Result<Vec<u8>> {
8953    if sley_index::is_gitlink(mode) {
8954        Ok(Vec::new())
8955    } else {
8956        merge_blob_bytes(reader, oid)
8957    }
8958}
8959
8960/// 3-way merge of a file mode. Returns the resolved mode and whether the modes
8961/// conflict (both sides changed it to different non-base values).
8962fn merge_file_modes(base: Option<u32>, ours: u32, theirs: u32) -> (u32, bool) {
8963    if ours == theirs {
8964        return (ours, false);
8965    }
8966    match base {
8967        Some(base) if ours == base => (theirs, false),
8968        Some(base) if theirs == base => (ours, false),
8969        _ => (ours, true),
8970    }
8971}
8972
8973/// Build a top-level tree object from a flat map of `path -> (mode, oid)`
8974/// leaves, writing every (sub)tree object to `db`.
8975fn write_merged_tree(db: &FileObjectDatabase, leaves: &MergeEntryMap) -> Result<ObjectId> {
8976    let mut root = MergeTreeNode::default();
8977    for (path, (mode, oid)) in leaves {
8978        root.insert(path, *mode, *oid);
8979    }
8980    root.write(db)
8981}
8982
8983#[derive(Default)]
8984struct MergeTreeNode {
8985    blobs: BTreeMap<Vec<u8>, (u32, ObjectId)>,
8986    subtrees: BTreeMap<Vec<u8>, MergeTreeNode>,
8987}
8988
8989impl MergeTreeNode {
8990    fn insert(&mut self, path: &[u8], mode: u32, oid: ObjectId) {
8991        match path.iter().position(|byte| *byte == b'/') {
8992            Some(slash) => {
8993                let component = path[..slash].to_vec();
8994                let rest = &path[slash + 1..];
8995                self.subtrees
8996                    .entry(component)
8997                    .or_default()
8998                    .insert(rest, mode, oid);
8999            }
9000            None => {
9001                self.blobs.insert(path.to_vec(), (mode, oid));
9002            }
9003        }
9004    }
9005
9006    fn write(&self, db: &FileObjectDatabase) -> Result<ObjectId> {
9007        let mut entries: Vec<TreeEntry> = Vec::new();
9008        for (name, (mode, oid)) in &self.blobs {
9009            entries.push(TreeEntry {
9010                mode: *mode,
9011                name: BString::from(name.clone()),
9012                oid: *oid,
9013            });
9014        }
9015        for (name, subtree) in &self.subtrees {
9016            let oid = subtree.write(db)?;
9017            entries.push(TreeEntry {
9018                mode: 0o040000,
9019                name: BString::from(name.clone()),
9020                oid,
9021            });
9022        }
9023        entries.sort_by_key(merge_tree_sort_key);
9024        let tree = Tree { entries };
9025        db.write_object(EncodedObject::new(ObjectType::Tree, tree.write()))
9026    }
9027}
9028
9029fn merge_tree_sort_key(entry: &TreeEntry) -> Vec<u8> {
9030    let mut key = entry.name.as_bytes().to_vec();
9031    if entry.mode == 0o040000 {
9032        key.push(b'/');
9033    }
9034    key
9035}
9036
9037// --- Rename-aware non-recursive merge -------------------------------------
9038
9039/// Which side of the merge performed a rename.
9040#[derive(Clone, Copy, PartialEq, Eq)]
9041enum RenameSide {
9042    Ours,
9043    Theirs,
9044}
9045
9046/// One detected one-sided rename: its source path and which side renamed it.
9047#[derive(Clone)]
9048struct MergeRename {
9049    source: Vec<u8>,
9050    side: RenameSide,
9051}
9052
9053/// A file renamed on one side whose source was *deleted* on the other side — a
9054/// rename/delete conflict. git keeps the renamed content at the destination but
9055/// flags the merge as conflicted.
9056#[derive(Clone)]
9057struct RenameDelete {
9058    /// The pre-rename source path (deleted on the other side).
9059    source: Vec<u8>,
9060    /// Which side performed the rename (the other side deleted the source).
9061    side: RenameSide,
9062}
9063
9064/// The rename pairings discovered for one merge: which destination paths came
9065/// from which source path, and which side renamed (so the other side's change
9066/// can follow the rename and conflict labels can be path-qualified like git).
9067#[derive(Default)]
9068struct MergeRenames {
9069    /// One-sided renames keyed by *destination* path. Only renames where the
9070    /// OTHER side kept/modified the source in place are recorded (the case
9071    /// where the modification must follow the rename).
9072    dest_to_source: BTreeMap<Vec<u8>, MergeRename>,
9073    /// Rename/delete conflicts: a file renamed on one side whose source the
9074    /// other side deleted. Keyed by destination path.
9075    rename_deletes: BTreeMap<Vec<u8>, RenameDelete>,
9076    /// Rename/rename(1to2) conflicts keyed by source path.
9077    rename_rename_one_to_two: BTreeMap<Vec<u8>, RenameRenameOneToTwo>,
9078    /// Rename/rename(2to1) conflicts keyed by the shared *destination* path:
9079    /// ours renamed `ours_source`->dest and theirs renamed `theirs_source`->dest.
9080    rename_rename_two_to_one: BTreeMap<Vec<u8>, RenameRenameTwoToOne>,
9081    /// Rename/add conflicts keyed by *destination*: one side renamed a file to
9082    /// `dest` while the other side added a different file at the same `dest`.
9083    rename_adds: BTreeMap<Vec<u8>, RenameAdd>,
9084}
9085
9086#[derive(Clone)]
9087struct RenameRenameOneToTwo {
9088    ours_dest: Vec<u8>,
9089    theirs_dest: Vec<u8>,
9090}
9091
9092/// A rename/rename(2to1): two distinct sources renamed onto one destination, one
9093/// rename per side. Each side's content at the destination is the 3-way merge of
9094/// its rename (the other side's change to that source follows the rename).
9095#[derive(Clone)]
9096struct RenameRenameTwoToOne {
9097    /// The source ours renamed onto the destination.
9098    ours_source: Vec<u8>,
9099    /// The source theirs renamed onto the destination.
9100    theirs_source: Vec<u8>,
9101}
9102
9103/// A rename/add: one side renamed a file onto `dest`, the other side added an
9104/// unrelated file at `dest`. The renaming side's content is the 3-way merge of
9105/// its rename; the adding side contributes its added blob verbatim.
9106#[derive(Clone)]
9107struct RenameAdd {
9108    /// The pre-rename source path on the renaming side.
9109    source: Vec<u8>,
9110    /// Which side performed the rename (the other side added at `dest`).
9111    side: RenameSide,
9112}
9113
9114/// Every file rename observed on one side (base->side), as `(old, new)` pairs.
9115/// Unlike [`MergeRenames`] this is the *complete* rename set on a side — it is
9116/// the input to directory-rename inference, which needs to see all the per-file
9117/// moves between directories, not just the ones the other side kept in place.
9118struct SideRenames {
9119    pairs: Vec<(Vec<u8>, Vec<u8>)>,
9120}
9121
9122/// Detect one-sided renames usable for a non-recursive merge: a path present in
9123/// `base`, deleted on one side and present (renamed) at a new path on that same
9124/// side, while the OTHER side still has the original path (modified or
9125/// unchanged). Such a rename lets the other side's change move to the
9126/// destination.
9127///
9128/// Also returns the complete per-side rename set so the caller can infer
9129/// directory renames (which need every file move, not just the merge-relevant
9130/// ones).
9131fn detect_merge_renames(
9132    db: &FileObjectDatabase,
9133    format: ObjectFormat,
9134    base_map: &MergeEntryMap,
9135    ours_map: &MergeEntryMap,
9136    theirs_map: &MergeEntryMap,
9137    options: &MergeTreesOptions<'_>,
9138) -> Result<(MergeRenames, SideRenames, SideRenames)> {
9139    let mut renames = MergeRenames::default();
9140
9141    // Renames on ours: the other side that must carry its change is theirs.
9142    let ours_side = collect_side_renames(
9143        db,
9144        format,
9145        base_map,
9146        ours_map,
9147        theirs_map,
9148        RenameSide::Ours,
9149        options.rename_threshold,
9150        options.rename_limit,
9151        &mut renames,
9152    )?;
9153    // Renames on theirs: the other side that carries its change is ours.
9154    let theirs_side = collect_side_renames(
9155        db,
9156        format,
9157        base_map,
9158        theirs_map,
9159        ours_map,
9160        RenameSide::Theirs,
9161        options.rename_threshold,
9162        options.rename_limit,
9163        &mut renames,
9164    )?;
9165
9166    collect_rename_rename_one_to_two(&mut renames, &ours_side, &theirs_side);
9167    collect_rename_rename_two_to_one_and_adds(
9168        &mut renames,
9169        &ours_side,
9170        &theirs_side,
9171        base_map,
9172        ours_map,
9173        theirs_map,
9174    );
9175
9176    Ok((renames, ours_side, theirs_side))
9177}
9178
9179/// Detect rename/rename(2to1) and rename/add conflicts from the complete per-side
9180/// rename sets. Both arise when a one-sided rename's destination is *occupied* on
9181/// the other side (so [`collect_side_renames`] left it out of `dest_to_source`):
9182///
9183/// * 2to1 — both sides renamed (distinct sources) onto the same destination.
9184/// * rename/add — one side renamed onto a path the other side *added* fresh
9185///   (the destination is new to the other side, not a base path it kept and not
9186///   itself a rename destination on that side).
9187fn collect_rename_rename_two_to_one_and_adds(
9188    renames: &mut MergeRenames,
9189    ours_side: &SideRenames,
9190    theirs_side: &SideRenames,
9191    base_map: &MergeEntryMap,
9192    ours_map: &MergeEntryMap,
9193    theirs_map: &MergeEntryMap,
9194) {
9195    let ours_by_dest: BTreeMap<&[u8], &[u8]> = ours_side
9196        .pairs
9197        .iter()
9198        .map(|(old, new)| (new.as_slice(), old.as_slice()))
9199        .collect();
9200    let theirs_by_dest: BTreeMap<&[u8], &[u8]> = theirs_side
9201        .pairs
9202        .iter()
9203        .map(|(old, new)| (new.as_slice(), old.as_slice()))
9204        .collect();
9205
9206    // 2to1: a destination that is a rename target on BOTH sides from different
9207    // sources. (Same source on both sides is a rename/rename(1to1), handled by
9208    // the path-keyed core; same source to two dests is the 1to2 case above.)
9209    for (dest, ours_src) in &ours_by_dest {
9210        let Some(theirs_src) = theirs_by_dest.get(dest) else {
9211            continue;
9212        };
9213        if ours_src == theirs_src {
9214            continue;
9215        }
9216        // Don't disturb a destination the 1to2 pass already claimed.
9217        if renames.rename_rename_one_to_two.contains_key(*dest) {
9218            continue;
9219        }
9220        renames.rename_rename_two_to_one.insert(
9221            dest.to_vec(),
9222            RenameRenameTwoToOne {
9223                ours_source: ours_src.to_vec(),
9224                theirs_source: theirs_src.to_vec(),
9225            },
9226        );
9227    }
9228
9229    // rename/add on ours: ours renamed onto `dest`, which theirs added (present
9230    // on theirs, absent from base, and not a theirs rename target).
9231    for (dest, ours_src) in &ours_by_dest {
9232        if renames.rename_rename_two_to_one.contains_key(*dest)
9233            || renames.rename_rename_one_to_two.contains_key(*dest)
9234        {
9235            continue;
9236        }
9237        if theirs_map.contains_key(*dest)
9238            && !base_map.contains_key(*dest)
9239            && !theirs_by_dest.contains_key(dest)
9240        {
9241            renames.rename_adds.insert(
9242                dest.to_vec(),
9243                RenameAdd {
9244                    source: ours_src.to_vec(),
9245                    side: RenameSide::Ours,
9246                },
9247            );
9248        }
9249    }
9250    // rename/add on theirs: symmetric.
9251    for (dest, theirs_src) in &theirs_by_dest {
9252        if renames.rename_rename_two_to_one.contains_key(*dest)
9253            || renames.rename_rename_one_to_two.contains_key(*dest)
9254            || renames.rename_adds.contains_key(*dest)
9255        {
9256            continue;
9257        }
9258        if ours_map.contains_key(*dest)
9259            && !base_map.contains_key(*dest)
9260            && !ours_by_dest.contains_key(dest)
9261        {
9262            renames.rename_adds.insert(
9263                dest.to_vec(),
9264                RenameAdd {
9265                    source: theirs_src.to_vec(),
9266                    side: RenameSide::Theirs,
9267                },
9268            );
9269        }
9270    }
9271}
9272
9273fn collect_rename_rename_one_to_two(
9274    renames: &mut MergeRenames,
9275    ours_side: &SideRenames,
9276    theirs_side: &SideRenames,
9277) {
9278    let ours_by_source: BTreeMap<&[u8], &[u8]> = ours_side
9279        .pairs
9280        .iter()
9281        .map(|(old, new)| (old.as_slice(), new.as_slice()))
9282        .collect();
9283    for (old, theirs_new) in &theirs_side.pairs {
9284        let Some(ours_new) = ours_by_source.get(old.as_slice()) else {
9285            continue;
9286        };
9287        if *ours_new == theirs_new.as_slice() {
9288            continue;
9289        }
9290        renames.rename_deletes.remove(*ours_new);
9291        renames.rename_deletes.remove(theirs_new);
9292        renames.dest_to_source.remove(*ours_new);
9293        renames.dest_to_source.remove(theirs_new);
9294        renames.rename_rename_one_to_two.insert(
9295            old.clone(),
9296            RenameRenameOneToTwo {
9297                ours_dest: (*ours_new).to_vec(),
9298                theirs_dest: theirs_new.clone(),
9299            },
9300        );
9301    }
9302}
9303
9304/// Collect renames that occurred on `side` (relative to `base`). Records the
9305/// merge-relevant subset (renames the `other` side still references) into
9306/// `renames`, and returns the *complete* per-side rename set for directory-rename
9307/// inference. `db`/`format` resolve blob bytes for similarity scoring.
9308#[allow(clippy::too_many_arguments)]
9309fn collect_side_renames(
9310    db: &FileObjectDatabase,
9311    format: ObjectFormat,
9312    base_map: &MergeEntryMap,
9313    side_map: &MergeEntryMap,
9314    other_map: &MergeEntryMap,
9315    side: RenameSide,
9316    threshold: u8,
9317    rename_limit: usize,
9318    renames: &mut MergeRenames,
9319) -> Result<SideRenames> {
9320    // Diff base->side with inexact rename detection; the resulting `Renamed`
9321    // entries name (old_path -> new_path) pairs on this side.
9322    let base_tree = entry_map_as_tracked(base_map);
9323    let side_tree = entry_map_as_tracked(side_map);
9324    let options = RenameDetectionOptions {
9325        base: DiffNameStatusOptions {
9326            detect_renames: true,
9327            detect_copies: false,
9328            find_copies_harder: false,
9329            rename_empty: false,
9330        },
9331        detect_inexact: true,
9332        rename_threshold: threshold,
9333        copy_threshold: threshold,
9334        rename_limit,
9335    };
9336    let changes = diff_name_status_maps_with_renames(
9337        &base_tree,
9338        &side_tree,
9339        base_tree.keys().chain(side_tree.keys()),
9340        options,
9341        |oid| merge_blob_bytes(db, oid).ok(),
9342    )?;
9343
9344    let mut pairs = Vec::new();
9345    for change in changes {
9346        let NameStatus::Renamed(_) = change.status else {
9347            continue;
9348        };
9349        let Some(old_path) = change.old_path.as_ref() else {
9350            continue;
9351        };
9352        let old = old_path.as_bytes().to_vec();
9353        let new = change.path.as_bytes().to_vec();
9354        // Complete rename set, fed to directory-rename inference.
9355        pairs.push((old.clone(), new.clone()));
9356
9357        // Only act when the destination is genuinely new (not already present
9358        // in either side from a different origin) and the OTHER side still
9359        // references the source path — i.e. the other side modified/kept `old`,
9360        // and its change should follow the rename to `new`.
9361        if !other_map.contains_key(&old) {
9362            // The source path is gone on the other side. If it existed in base
9363            // (so the other side *deleted* it) and the other side did not also
9364            // produce `new`, this is a rename/delete conflict: this side renamed
9365            // the file, the other side deleted its source.
9366            if base_map.contains_key(&old) && !other_map.contains_key(&new) {
9367                renames
9368                    .rename_deletes
9369                    .entry(new.clone())
9370                    .or_insert(RenameDelete {
9371                        source: old.clone(),
9372                        side,
9373                    });
9374            }
9375            continue;
9376        }
9377        // If the other side ALSO renamed/created `new`, that is a rename/rename
9378        // or rename/add corner case we leave to the path-keyed core (stage-b).
9379        if other_map.contains_key(&new) {
9380            continue;
9381        }
9382        // Skip if both sides renamed the same source to the same dest (already
9383        // recorded) or to anything (first writer wins; the path-keyed core then
9384        // sees identical dest entries and resolves trivially).
9385        renames
9386            .dest_to_source
9387            .entry(new)
9388            .or_insert(MergeRename { source: old, side });
9389    }
9390
9391    let _ = format;
9392    Ok(SideRenames { pairs })
9393}
9394
9395/// Rewrite the three side maps so that each detected one-sided rename old->new
9396/// presents the OTHER side's `old` entry at `new`, and removes `old` from
9397/// every side. The path-keyed merge core then performs the 3-way content merge
9398/// at `new` with base=base[old], one side = the renaming side's new content,
9399/// the other side = the modifying side's old content.
9400fn apply_merge_renames(
9401    base_map: &MergeEntryMap,
9402    ours_map: &MergeEntryMap,
9403    theirs_map: &MergeEntryMap,
9404    renames: &MergeRenames,
9405) -> (MergeEntryMap, MergeEntryMap, MergeEntryMap) {
9406    if renames.dest_to_source.is_empty() {
9407        return (base_map.clone(), ours_map.clone(), theirs_map.clone());
9408    }
9409    let mut base = base_map.clone();
9410    let mut ours = ours_map.clone();
9411    let mut theirs = theirs_map.clone();
9412
9413    for (new, rename) in &renames.dest_to_source {
9414        let old = &rename.source;
9415        // Move base[old] to base[new] so the destination has a proper ancestor.
9416        if let Some(entry) = base.remove(old) {
9417            base.entry(new.clone()).or_insert(entry);
9418        }
9419        // For each side, if it still has `old`, move that entry to `new`.
9420        for side in [&mut ours, &mut theirs] {
9421            if let Some(entry) = side.remove(old) {
9422                side.entry(new.clone()).or_insert(entry);
9423            }
9424        }
9425    }
9426    (base, ours, theirs)
9427}
9428
9429// --- Directory-rename detection -------------------------------------------
9430
9431/// The parent directory of `path`, or `None` for a top-level path.
9432fn parent_dir(path: &[u8]) -> Option<&[u8]> {
9433    path.iter().rposition(|b| *b == b'/').map(|i| &path[..i])
9434}
9435
9436/// Apply a directory rename `old_dir -> new_dir` to `path` (which must live
9437/// under `old_dir`). E.g. `old_dir=z`, `new_dir=y`, `path=z/d` -> `y/d`; an
9438/// empty `new_dir` (rename into the repo root) drops the directory prefix.
9439fn apply_dir_rename(old_dir: &[u8], new_dir: &[u8], path: &[u8]) -> Vec<u8> {
9440    // The portion of `path` after `old_dir/` (handle root-target by stepping
9441    // past the separator, exactly as git's apply_dir_rename does).
9442    let rest_start = if new_dir.is_empty() {
9443        old_dir.len() + 1
9444    } else {
9445        old_dir.len()
9446    };
9447    let mut out = new_dir.to_vec();
9448    out.extend_from_slice(&path[rest_start..]);
9449    out
9450}
9451
9452/// Find the longest renamed ancestor directory of `path`: walk parent dirs from
9453/// the deepest up and return the first one present in `dir_renames`. Mirrors
9454/// merge-ort's `check_dir_renamed`.
9455fn check_dir_renamed<'a>(
9456    path: &[u8],
9457    dir_renames: &'a BTreeMap<Vec<u8>, Vec<u8>>,
9458) -> Option<(&'a [u8], &'a [u8])> {
9459    let mut cur = parent_dir(path);
9460    while let Some(dir) = cur {
9461        if let Some((old_dir, new_dir)) = dir_renames.get_key_value(dir) {
9462            return Some((old_dir.as_slice(), new_dir.as_slice()));
9463        }
9464        cur = parent_dir(dir);
9465    }
9466    None
9467}
9468
9469/// The provisional directory renames computed for both sides, plus the source
9470/// directories whose rename was ambiguous (a "split").
9471struct DirectoryRenameMaps {
9472    /// `old_dir -> new_dir` directory renames detected on ours' side. A path
9473    /// added/renamed by theirs under `old_dir` re-homes into `new_dir`.
9474    ours: BTreeMap<Vec<u8>, Vec<u8>>,
9475    /// Directory renames detected on theirs' side.
9476    theirs: BTreeMap<Vec<u8>, Vec<u8>>,
9477    /// Source directories whose split was unclear on ours' side (no unique
9478    /// majority target); paths on theirs that would need to follow such a rename
9479    /// are a conflict, not silent.
9480    ours_split: BTreeSet<Vec<u8>>,
9481    /// Source directories whose split was unclear on theirs' side.
9482    theirs_split: BTreeSet<Vec<u8>>,
9483}
9484
9485/// Infer directory renames from the complete per-side file-rename sets, mirroring
9486/// merge-ort's `get_provisional_directory_renames` + `handle_directory_level_conflicts`.
9487/// For every file moved `.../old_dir/x -> .../new_dir/x`, the ancestor pairs are
9488/// tallied (`dir_rename_count`) and collapsed to `old_dir -> best_new_dir` where
9489/// `best` is the unique highest count. A tie marks the source directory as a
9490/// "split". A rename is only kept if the source directory was *entirely removed*
9491/// on that side (the `dirs_removed` gate). A directory renamed on BOTH sides is
9492/// dropped from both maps (ambiguous).
9493fn compute_directory_renames(
9494    ours_map: &MergeEntryMap,
9495    theirs_map: &MergeEntryMap,
9496    ours_side: &SideRenames,
9497    theirs_side: &SideRenames,
9498) -> DirectoryRenameMaps {
9499    let ours = compute_side_dir_renames(&ours_side.pairs, ours_map);
9500    let theirs = compute_side_dir_renames(&theirs_side.pairs, theirs_map);
9501
9502    // A directory renamed on BOTH sides (to whatever target) is ambiguous;
9503    // git's handle_directory_level_conflicts drops it from both maps so neither
9504    // side's directory rename is applied.
9505    let mut ours_map_out = ours.renames;
9506    let mut theirs_map_out = theirs.renames;
9507    let dup: Vec<Vec<u8>> = ours_map_out
9508        .keys()
9509        .filter(|k| theirs_map_out.contains_key(*k))
9510        .cloned()
9511        .collect();
9512    for k in dup {
9513        ours_map_out.remove(&k);
9514        theirs_map_out.remove(&k);
9515    }
9516
9517    DirectoryRenameMaps {
9518        ours: ours_map_out,
9519        theirs: theirs_map_out,
9520        ours_split: ours.split,
9521        theirs_split: theirs.split,
9522    }
9523}
9524
9525/// Per-side directory-rename computation result.
9526struct SideDirRenames {
9527    renames: BTreeMap<Vec<u8>, Vec<u8>>,
9528    split: BTreeSet<Vec<u8>>,
9529}
9530
9531/// Compute one side's `old_dir -> new_dir` map from its file renames, gated on
9532/// the source directory being fully removed on that side.
9533fn compute_side_dir_renames(
9534    pairs: &[(Vec<u8>, Vec<u8>)],
9535    side_map: &MergeEntryMap,
9536) -> SideDirRenames {
9537    // dir_rename_count: count[old_dir][new_dir]. Built by walking every rename's
9538    // ancestor directories while the *trailing* path components match, exactly
9539    // as merge-ort's update_dir_rename_counts does. For
9540    //   a/b/c/d/e/foo.c -> a/b/some/thing/else/e/foo.c
9541    // this records both
9542    //   a/b/c/d/e => a/b/some/thing/else/e   AND   a/b/c/d => a/b/some/thing/else
9543    // but stops once the trailing components diverge.
9544    let mut counts: BTreeMap<Vec<u8>, BTreeMap<Vec<u8>, usize>> = BTreeMap::new();
9545    for (old, new) in pairs {
9546        update_dir_rename_counts(&mut counts, old, new);
9547    }
9548
9549    let mut renames = BTreeMap::new();
9550    let mut split = BTreeSet::new();
9551    for (old_dir, targets) in counts {
9552        let mut max = 0usize;
9553        let mut bad_max = 0usize;
9554        let mut best: Option<Vec<u8>> = None;
9555        for (target, count) in &targets {
9556            if *count == max {
9557                bad_max = max;
9558            } else if *count > max {
9559                max = *count;
9560                best = Some(target.clone());
9561            }
9562        }
9563        if max == 0 {
9564            continue;
9565        }
9566        if bad_max == max {
9567            split.insert(old_dir);
9568            continue;
9569        }
9570        // dirs_removed gate: the source directory must be entirely gone on this
9571        // side. New files that recreate the old directory count too; otherwise
9572        // cases like "both sides renamed z/ -> y/, but one side added z/d"
9573        // incorrectly look like both sides performed a whole-directory rename.
9574        if let Some(best) = best
9575            && directory_fully_removed(&old_dir, side_map)
9576        {
9577            renames.insert(old_dir, best);
9578        }
9579    }
9580
9581    SideDirRenames { renames, split }
9582}
9583
9584/// Tally the ancestor directory-rename pairs implied by a single file rename
9585/// `old -> new`, mirroring merge-ort's `update_dir_rename_counts`. Starting from
9586/// the immediate parent dirs, we strip one trailing component at a time and
9587/// record `old_ancestor -> new_ancestor` as long as the *remaining* trailing
9588/// suffix still matches between the two paths.
9589fn update_dir_rename_counts(
9590    counts: &mut BTreeMap<Vec<u8>, BTreeMap<Vec<u8>, usize>>,
9591    old: &[u8],
9592    new: &[u8],
9593) {
9594    // Work on owned copies we progressively truncate at each '/'.
9595    let mut old_dir = old.to_vec();
9596    let mut new_dir = new.to_vec();
9597    let mut first = true;
9598    loop {
9599        // Strip the trailing component (basename on the first pass, then a dir
9600        // each pass) to ascend one level.
9601        let old_has = dir_munge(&mut old_dir);
9602        let new_has = dir_munge(&mut new_dir);
9603
9604        // On the first pass we only stripped the basename; the dirs need not
9605        // match. On later passes the *trailing* components must agree, otherwise
9606        // the rename no longer implies this ancestor pairing.
9607        if !first {
9608            let old_sub = trailing_component(old, &old_dir);
9609            let new_sub = trailing_component(new, &new_dir);
9610            if old_sub != new_sub {
9611                break;
9612            }
9613        }
9614
9615        if old_dir == new_dir {
9616            // Same directory at this level — no rename implied, and no deeper
9617            // ancestor can differ usefully either.
9618            break;
9619        }
9620        *counts
9621            .entry(old_dir.clone())
9622            .or_default()
9623            .entry(new_dir.clone())
9624            .or_default() += 1;
9625
9626        first = false;
9627        // Hitting the toplevel ("") on either side ends the ascent.
9628        if old_dir.is_empty() || new_dir.is_empty() {
9629            break;
9630        }
9631        // If the two ancestors are identical from here up, stop (git stops once
9632        // the suffix-equal walk reaches a common prefix).
9633        if !old_has || !new_has {
9634            break;
9635        }
9636    }
9637}
9638
9639/// Truncate `buf` at its last '/', leaving the parent directory (or empty for a
9640/// toplevel name). Returns whether a '/' was present (i.e. there is a deeper
9641/// ancestor to ascend into).
9642fn dir_munge(buf: &mut Vec<u8>) -> bool {
9643    match buf.iter().rposition(|b| *b == b'/') {
9644        Some(i) => {
9645            buf.truncate(i);
9646            true
9647        }
9648        None => {
9649            buf.clear();
9650            false
9651        }
9652    }
9653}
9654
9655/// The trailing path component that was stripped from `full` to reach `dir`
9656/// (i.e. the suffix of `full` after `dir/`). Used to compare whether the two
9657/// sides of a rename share the same trailing directory chain.
9658fn trailing_component<'a>(full: &'a [u8], dir: &[u8]) -> &'a [u8] {
9659    if dir.is_empty() {
9660        full
9661    } else {
9662        // full = dir + "/" + suffix
9663        &full[dir.len() + 1..]
9664    }
9665}
9666
9667/// True when no path under `dir/` exists on `side` (the directory was entirely
9668/// removed there). Mirrors merge-ort's `dirs_removed` precondition.
9669fn directory_fully_removed(dir: &[u8], side_map: &MergeEntryMap) -> bool {
9670    let mut prefix = dir.to_vec();
9671    prefix.push(b'/');
9672    for path in side_map.keys() {
9673        if path.starts_with(&prefix) {
9674            return false;
9675        }
9676    }
9677    true
9678}
9679
9680/// A path on one side whose location is rewritten by a directory rename the
9681/// *other* side performed. The rewrite applies equally to a freshly added file
9682/// and to a file the side itself renamed (a transitive rename).
9683struct DirRenameMove {
9684    /// The path as it currently sits in the side's effective map (the side's own
9685    /// rename, if any, already applied).
9686    from: Vec<u8>,
9687    /// The re-homed destination, after applying the other side's directory rename.
9688    to: Vec<u8>,
9689    /// `Some(source)` when `from` is a rename destination produced by this side
9690    /// (transitive rename); `None` for a fresh add. Drives git's
9691    /// "renamed to"/"added in" message wording.
9692    renamed_from: Option<Vec<u8>>,
9693}
9694
9695struct DirRenameTwoToOne {
9696    dest: Vec<u8>,
9697    ours_source: Vec<u8>,
9698    theirs_source: Vec<u8>,
9699    ours_label_path: Vec<u8>,
9700    theirs_label_path: Vec<u8>,
9701}
9702
9703/// Provenance of a re-homed path, for `=conflict`-mode `CONFLICT (file location)`
9704/// reporting.
9705#[derive(Clone)]
9706struct RehomeInfo {
9707    /// The pre-re-home path on the adding/renaming side.
9708    old_path: Vec<u8>,
9709    /// `Some(source)` for a transitive rename, `None` for a fresh add.
9710    renamed_from: Option<Vec<u8>>,
9711    /// Whether the *adding/renaming* side was ours (true) or theirs (false). The
9712    /// caller resolves this to a branch label.
9713    added_on_ours: bool,
9714}
9715
9716/// Per-side provenance for a destination created by directory-rename rehoming.
9717#[derive(Clone, Default)]
9718struct RehomeSides {
9719    ours: Option<RehomeInfo>,
9720    theirs: Option<RehomeInfo>,
9721}
9722
9723/// An implicit-directory-rename collision: one or more paths a directory rename
9724/// would re-home onto `dest`, which is blocked because `dest` is already
9725/// occupied (a file in the way) or because multiple sources map to it. git emits
9726/// `CONFLICT (implicit dir rename): Existing file/dir at <dest> in the way ...`.
9727struct DirRenameCollision {
9728    /// The blocked destination path (the file/dir already there).
9729    dest: Vec<u8>,
9730    /// The source path(s) the directory rename tried to move onto `dest`.
9731    sources: Vec<Vec<u8>>,
9732}
9733
9734/// Outcome of applying directory renames to all three effective maps.
9735struct DirRenameOutcome {
9736    /// Rewritten base/ours/theirs maps with re-homed paths moved to their
9737    /// destinations. `base` moves too so a re-homed content-merge keeps its
9738    /// ancestor at the new location.
9739    base: MergeEntryMap,
9740    ours: MergeEntryMap,
9741    theirs: MergeEntryMap,
9742    /// Re-homed destination path -> provenance (for `=conflict`-mode reporting).
9743    rehomed: BTreeMap<Vec<u8>, RehomeSides>,
9744    /// Implicit-dir-rename collisions (file in the way / N-to-1), for the
9745    /// `CONFLICT (implicit dir rename)` message; always conflicts regardless of
9746    /// mode.
9747    collisions: Vec<DirRenameCollision>,
9748    /// Split source dirs that were relevant to a path on the other side.
9749    splits: BTreeSet<Vec<u8>>,
9750    /// Destinations where a directory rename moved a file back onto its own base
9751    /// source path (rename-to-self) and the other side modified that path. git
9752    /// records these as an unmerged file-location conflict (`UU`) rather than a
9753    /// clean auto-resolution; the trivial 3-way at the destination would
9754    /// otherwise resolve cleanly because the renamed side's content equals base.
9755    back_to_self: BTreeSet<Vec<u8>>,
9756    /// True if a directory-level collision or split made the merge dirty even in
9757    /// `=true` mode (e.g. two paths re-homed onto one destination).
9758    dirty: bool,
9759    info_messages: Vec<MergeInfoMessage>,
9760}
9761
9762/// Apply directory renames to both sides' effective maps.
9763///
9764/// This mirrors merge-ort's `collect_renames` + `check_for_directory_rename` +
9765/// `apply_directory_rename_modifications`: every path a side *added* or *renamed*
9766/// that lives under a directory the OTHER side renamed has its destination
9767/// rewritten to follow that rename — making the directory rename a property of
9768/// the rename-detection pass that every path consults, not a per-file special
9769/// case. Handles:
9770///   - transitive renames (a file the side renamed into a dir the other side
9771///     renamed follows on into the final directory),
9772///   - `dir_rename_exclusions` (never re-home into a directory THIS side itself
9773///     renamed — that would create a spurious rename/rename(1to2)),
9774///   - collisions (N paths mapping to one destination -> conflict),
9775///   - splits (a source dir with no majority target -> conflict, leave in place).
9776#[allow(clippy::too_many_arguments)]
9777fn apply_directory_renames(
9778    base_map: &MergeEntryMap,
9779    eff_base: &MergeEntryMap,
9780    eff_ours: &MergeEntryMap,
9781    eff_theirs: &MergeEntryMap,
9782    ours_side: &SideRenames,
9783    theirs_side: &SideRenames,
9784    dir_renames: &DirectoryRenameMaps,
9785    file_rename_dests: &BTreeMap<Vec<u8>, MergeRename>,
9786) -> DirRenameOutcome {
9787    let mut base = eff_base.clone();
9788    let mut ours = eff_ours.clone();
9789    let mut theirs = eff_theirs.clone();
9790    let mut rehomed = BTreeMap::new();
9791    let mut collisions = Vec::new();
9792    let mut splits = BTreeSet::new();
9793    let mut back_to_self = BTreeSet::new();
9794    let mut info_messages = Vec::new();
9795    let mut dirty = false;
9796
9797    // Ours' paths follow THEIRS' directory renames; the exclusions are OURS' own
9798    // renamed-into dirs (never re-home a path into a directory this same side
9799    // renamed). Symmetrically for theirs.
9800    let ours_excl = exclusion_dirs(&dir_renames.ours);
9801    let theirs_excl = exclusion_dirs(&dir_renames.theirs);
9802
9803    // Plan ours' moves (following theirs' dir-renames) and theirs' moves
9804    // (following ours' dir-renames). Planning before applying lets us detect
9805    // collisions (N paths onto one destination) across the whole side.
9806    let ours_moves = plan_rehome(
9807        base_map,
9808        &ours,
9809        ours_side,
9810        &dir_renames.theirs,
9811        &ours_excl,
9812        &dir_renames.theirs_split,
9813        &mut collisions,
9814        &mut splits,
9815        &mut info_messages,
9816        &mut dirty,
9817    );
9818    let theirs_moves = plan_rehome(
9819        base_map,
9820        &theirs,
9821        theirs_side,
9822        &dir_renames.ours,
9823        &theirs_excl,
9824        &dir_renames.ours_split,
9825        &mut collisions,
9826        &mut splits,
9827        &mut info_messages,
9828        &mut dirty,
9829    );
9830
9831    apply_rehome_moves(
9832        base_map,
9833        file_rename_dests,
9834        &mut base,
9835        &mut ours,
9836        &mut theirs,
9837        ours_moves,
9838        true,
9839        &mut rehomed,
9840        &mut collisions,
9841        &mut back_to_self,
9842        &mut dirty,
9843    );
9844    apply_rehome_moves(
9845        base_map,
9846        file_rename_dests,
9847        &mut base,
9848        &mut ours,
9849        &mut theirs,
9850        theirs_moves,
9851        false,
9852        &mut rehomed,
9853        &mut collisions,
9854        &mut back_to_self,
9855        &mut dirty,
9856    );
9857
9858    DirRenameOutcome {
9859        base,
9860        ours,
9861        theirs,
9862        rehomed,
9863        collisions,
9864        splits,
9865        back_to_self,
9866        dirty,
9867        info_messages,
9868    }
9869}
9870
9871/// The set of *source* directories a side renamed away from. A directory rename
9872/// the other side wants to apply into one of these dirs is skipped (it would
9873/// produce a spurious rename/rename(1to2)); git's `dir_rename_exclusions`.
9874fn exclusion_dirs(side_dir_renames: &BTreeMap<Vec<u8>, Vec<u8>>) -> BTreeSet<Vec<u8>> {
9875    side_dir_renames.keys().cloned().collect()
9876}
9877
9878/// Re-home `target`'s added/renamed paths that fall under a directory the other
9879/// side renamed (`renamer_dirs`: `old_dir -> new_dir`).
9880///
9881/// Candidates are paths present on this side and absent in base — i.e. both
9882/// Plan the directory-rename moves for one side: which of its added/renamed
9883/// paths re-home where, following `renamer_dirs` (the OTHER side's dir-renames).
9884///
9885/// Candidates are paths present on this side and absent in base — both freshly
9886/// added files AND this side's own rename destinations (the latter give the
9887/// transitive-rename behaviour). A candidate whose target directory is in
9888/// `exclusions` (a dir this side itself renamed) is skipped. Splits mark the
9889/// merge dirty; N-to-1 collisions (multiple sources onto one destination) record
9890/// a `DirRenameCollision` and yield no move. Returns the surviving single moves
9891/// (one per destination).
9892#[allow(clippy::too_many_arguments)]
9893fn plan_rehome(
9894    base_map: &MergeEntryMap,
9895    side: &MergeEntryMap,
9896    side_renames: &SideRenames,
9897    renamer_dirs: &BTreeMap<Vec<u8>, Vec<u8>>,
9898    exclusions: &BTreeSet<Vec<u8>>,
9899    split_dirs: &BTreeSet<Vec<u8>>,
9900    collisions: &mut Vec<DirRenameCollision>,
9901    splits: &mut BTreeSet<Vec<u8>>,
9902    info_messages: &mut Vec<MergeInfoMessage>,
9903    dirty: &mut bool,
9904) -> Vec<DirRenameMove> {
9905    if renamer_dirs.is_empty() && split_dirs.is_empty() {
9906        return Vec::new();
9907    }
9908
9909    // This side's rename destinations -> sources; eligible for a transitive
9910    // rewrite and carry the original source for message wording.
9911    let side_rename_src: BTreeMap<&[u8], &[u8]> = side_renames
9912        .pairs
9913        .iter()
9914        .map(|(o, n)| (n.as_slice(), o.as_slice()))
9915        .collect();
9916
9917    let candidates: Vec<Vec<u8>> = side
9918        .keys()
9919        .filter(|p| !base_map.contains_key(*p) || side_rename_src.contains_key(p.as_slice()))
9920        .cloned()
9921        .collect();
9922
9923    // dest -> the moves wanting to land there (collision detection).
9924    let mut planned: BTreeMap<Vec<u8>, Vec<DirRenameMove>> = BTreeMap::new();
9925    for path in candidates {
9926        if let Some(split_dir) = check_dir_split(&path, split_dirs) {
9927            splits.insert(split_dir.to_vec());
9928            *dirty = true;
9929            continue;
9930        }
9931        let Some((old_dir, new_dir)) = check_dir_renamed(&path, renamer_dirs) else {
9932            continue;
9933        };
9934        // dir_rename_exclusions: don't apply a rename INTO a directory this side
9935        // itself renamed; that would cause a spurious rename/rename(1to2). The
9936        // file instead follows this side's own rename, so leave it.
9937        let new_dir_is_exclusion = exclusions.contains(new_dir);
9938        let new_dir_inside_exclusion = exclusions
9939            .iter()
9940            .any(|dir| directory_contains_proper(dir, new_dir));
9941        if new_dir_is_exclusion
9942            || (new_dir_inside_exclusion
9943                && !side_has_pure_add_under_dir(side, base_map, &side_rename_src, old_dir))
9944        {
9945            info_messages.push(MergeInfoMessage::DirRenameSkippedDueToRerename {
9946                old_dir: old_dir.to_vec(),
9947                path: path.clone(),
9948                new_dir: new_dir.to_vec(),
9949            });
9950            continue;
9951        }
9952        let dest = apply_dir_rename(old_dir, new_dir, &path);
9953        if dest == path {
9954            // Directory rename causes a rename-to-self: already in place.
9955            continue;
9956        }
9957        let renamed_from = side_rename_src.get(path.as_slice()).map(|s| s.to_vec());
9958        planned
9959            .entry(dest.clone())
9960            .or_default()
9961            .push(DirRenameMove {
9962                from: path,
9963                to: dest,
9964                renamed_from,
9965            });
9966    }
9967
9968    let mut moves = Vec::new();
9969    for (dest, group) in planned {
9970        if group.len() > 1 {
9971            // Multiple paths map to one destination: an implicit-dir-rename
9972            // collision. git leaves all of them in place and conflicts.
9973            *dirty = true;
9974            collisions.push(DirRenameCollision {
9975                dest,
9976                sources: group.into_iter().map(|m| m.from).collect(),
9977            });
9978            continue;
9979        }
9980        moves.push(group.into_iter().next().expect("non-empty"));
9981    }
9982    moves
9983}
9984
9985fn check_dir_split<'a>(path: &[u8], split_dirs: &'a BTreeSet<Vec<u8>>) -> Option<&'a [u8]> {
9986    let mut dir = parent_dir(path)?;
9987    loop {
9988        if let Some(split_dir) = split_dirs.get(dir) {
9989            return Some(split_dir);
9990        }
9991        dir = parent_dir(dir)?;
9992    }
9993}
9994
9995fn directory_contains_proper(parent: &[u8], child: &[u8]) -> bool {
9996    !parent.is_empty()
9997        && child.len() > parent.len()
9998        && child.starts_with(parent)
9999        && child[parent.len()] == b'/'
10000}
10001
10002fn side_has_pure_add_under_dir(
10003    side: &MergeEntryMap,
10004    base_map: &MergeEntryMap,
10005    side_rename_src: &BTreeMap<&[u8], &[u8]>,
10006    dir: &[u8],
10007) -> bool {
10008    side.keys().any(|path| {
10009        path_is_under_dir(path, dir)
10010            && !base_map.contains_key(path)
10011            && !side_rename_src.contains_key(path.as_slice())
10012    })
10013}
10014
10015fn path_is_under_dir(path: &[u8], dir: &[u8]) -> bool {
10016    !dir.is_empty() && path.len() > dir.len() && path.starts_with(dir) && path[dir.len()] == b'/'
10017}
10018
10019/// Apply a side's planned re-home moves to all three effective maps.
10020///
10021/// `side_is_ours` says whether the moves originate from ours' (true) or theirs'
10022/// (false) paths — used both for `=conflict`-mode provenance and to decide which
10023/// side's entry the move primarily belongs to. A move whose source is a
10024/// content-merge path (present on the other side and in base too) re-homes
10025/// across `base`/`ours`/`theirs` together, so the 3-way merge follows it to the
10026/// new location; a pure add re-homes only its own side.
10027#[allow(clippy::too_many_arguments)]
10028fn apply_rehome_moves(
10029    original_base: &MergeEntryMap,
10030    file_rename_dests: &BTreeMap<Vec<u8>, MergeRename>,
10031    base: &mut MergeEntryMap,
10032    ours: &mut MergeEntryMap,
10033    theirs: &mut MergeEntryMap,
10034    moves: Vec<DirRenameMove>,
10035    side_is_ours: bool,
10036    rehomed: &mut BTreeMap<Vec<u8>, RehomeSides>,
10037    collisions: &mut Vec<DirRenameCollision>,
10038    back_to_self: &mut BTreeSet<Vec<u8>>,
10039    dirty: &mut bool,
10040) {
10041    for mv in moves {
10042        // A file in the way at the destination is only a blocker when it is
10043        // present on this same side (or in base). If the other side already
10044        // occupies the destination, applying this move produces the normal
10045        // two-sided conflict at that path (e.g. t6423 1d's rename/rename(2to1)).
10046        let occupied_on_this_side = if side_is_ours {
10047            ours.contains_key(&mv.to) || map_has_directory_at(ours, &mv.to)
10048        } else {
10049            theirs.contains_key(&mv.to) || map_has_directory_at(theirs, &mv.to)
10050        };
10051        let occupied_by_cross_rename =
10052            file_rename_dests
10053                .get(&mv.to)
10054                .is_some_and(|rename| match (side_is_ours, rename.side) {
10055                    (true, RenameSide::Theirs) | (false, RenameSide::Ours) => true,
10056                    (true, RenameSide::Ours) | (false, RenameSide::Theirs) => false,
10057                });
10058        let base_entry_at_dest = original_base.get(&mv.to).copied();
10059        let base_entry_at_source = original_base.get(&mv.from).copied();
10060        let other_side_entry_at_dest = if side_is_ours {
10061            theirs.get(&mv.to).copied()
10062        } else {
10063            ours.get(&mv.to).copied()
10064        };
10065        let other_side_entry_at_source = if side_is_ours {
10066            theirs.get(&mv.from).copied()
10067        } else {
10068            ours.get(&mv.from).copied()
10069        };
10070        let base_entry_for_shifted_source = base_entry_at_source.or(base_entry_at_dest);
10071        let rename_back_to_modified_source = mv
10072            .renamed_from
10073            .as_ref()
10074            .is_some_and(|source| source == &mv.to)
10075            && base_entry_at_dest.is_some()
10076            && (other_side_entry_at_dest.is_some_and(|entry| Some(entry) != base_entry_at_dest)
10077                || other_side_entry_at_source
10078                    .is_some_and(|entry| Some(entry) != base_entry_for_shifted_source));
10079        if ((base_entry_at_dest.is_some() && !rename_back_to_modified_source)
10080            || (occupied_on_this_side && !occupied_by_cross_rename))
10081            && mv.to != mv.from
10082        {
10083            *dirty = true;
10084            collisions.push(DirRenameCollision {
10085                dest: mv.to.clone(),
10086                sources: vec![mv.from.clone()],
10087            });
10088            continue;
10089        }
10090        let mut moved = false;
10091        if occupied_by_cross_rename {
10092            base.remove(&mv.from);
10093            if side_is_ours {
10094                if let Some(entry) = ours.remove(&mv.from) {
10095                    ours.insert(mv.to.clone(), entry);
10096                    moved = true;
10097                }
10098                theirs.remove(&mv.from);
10099            } else {
10100                ours.remove(&mv.from);
10101                if let Some(entry) = theirs.remove(&mv.from) {
10102                    theirs.insert(mv.to.clone(), entry);
10103                    moved = true;
10104                }
10105            }
10106        } else {
10107            // Move the path on every map that holds it (base for the ancestor,
10108            // and whichever sides carry content at the path). This keeps a
10109            // content-merge keyed consistently at the re-homed destination.
10110            for m in [&mut *base, &mut *ours, &mut *theirs] {
10111                if let Some(entry) = m.remove(&mv.from) {
10112                    m.insert(mv.to.clone(), entry);
10113                    moved = true;
10114                }
10115            }
10116        }
10117        if moved {
10118            if rename_back_to_modified_source {
10119                back_to_self.insert(mv.to.clone());
10120            }
10121            let info = RehomeInfo {
10122                old_path: mv.from.clone(),
10123                renamed_from: mv.renamed_from.clone(),
10124                added_on_ours: side_is_ours,
10125            };
10126            let entry = rehomed.entry(mv.to.clone()).or_default();
10127            if side_is_ours {
10128                entry.ours = Some(info);
10129            } else {
10130                entry.theirs = Some(info);
10131            }
10132        }
10133    }
10134}
10135
10136fn collect_dir_rename_two_to_one(
10137    renames: &MergeRenames,
10138    rehomed: &BTreeMap<Vec<u8>, RehomeSides>,
10139) -> Vec<DirRenameTwoToOne> {
10140    let mut conflicts = Vec::new();
10141    for (dest, sides) in rehomed {
10142        let Some(file_rename) = renames.dest_to_source.get(dest) else {
10143            continue;
10144        };
10145        match file_rename.side {
10146            RenameSide::Ours => {
10147                let Some(info) = sides.theirs.as_ref() else {
10148                    continue;
10149                };
10150                let Some(theirs_source) = info.renamed_from.as_ref() else {
10151                    continue;
10152                };
10153                conflicts.push(DirRenameTwoToOne {
10154                    dest: dest.clone(),
10155                    ours_source: file_rename.source.clone(),
10156                    theirs_source: theirs_source.clone(),
10157                    ours_label_path: dest.clone(),
10158                    theirs_label_path: info.old_path.clone(),
10159                });
10160            }
10161            RenameSide::Theirs => {
10162                let Some(info) = sides.ours.as_ref() else {
10163                    continue;
10164                };
10165                let Some(ours_source) = info.renamed_from.as_ref() else {
10166                    continue;
10167                };
10168                conflicts.push(DirRenameTwoToOne {
10169                    dest: dest.clone(),
10170                    ours_source: ours_source.clone(),
10171                    theirs_source: file_rename.source.clone(),
10172                    ours_label_path: info.old_path.clone(),
10173                    theirs_label_path: dest.clone(),
10174                });
10175            }
10176        }
10177    }
10178    conflicts
10179}
10180
10181fn map_has_directory_at(map: &MergeEntryMap, path: &[u8]) -> bool {
10182    let mut prefix = path.to_vec();
10183    prefix.push(b'/');
10184    map.keys().any(|candidate| candidate.starts_with(&prefix))
10185}
10186
10187fn remap_rename_destinations(renames: &mut MergeRenames, rehomed: &BTreeMap<Vec<u8>, RehomeSides>) {
10188    if rehomed.is_empty() {
10189        return;
10190    }
10191    let mut remapped_deletes = BTreeMap::new();
10192    for (dest, rd) in std::mem::take(&mut renames.rename_deletes) {
10193        let new_dest = rehomed
10194            .iter()
10195            .find_map(|(new_dest, sides)| {
10196                let moved = sides
10197                    .ours
10198                    .as_ref()
10199                    .is_some_and(|info| info.old_path == dest)
10200                    || sides
10201                        .theirs
10202                        .as_ref()
10203                        .is_some_and(|info| info.old_path == dest);
10204                moved.then(|| new_dest.clone())
10205            })
10206            .unwrap_or(dest);
10207        remapped_deletes.insert(new_dest, rd);
10208    }
10209    renames.rename_deletes = remapped_deletes;
10210
10211    for rename in renames.rename_rename_one_to_two.values_mut() {
10212        for (dest, sides) in rehomed {
10213            if sides
10214                .ours
10215                .as_ref()
10216                .is_some_and(|info| info.old_path == rename.ours_dest)
10217            {
10218                rename.ours_dest = dest.clone();
10219            }
10220            if sides
10221                .theirs
10222                .as_ref()
10223                .is_some_and(|info| info.old_path == rename.theirs_dest)
10224            {
10225                rename.theirs_dest = dest.clone();
10226            }
10227        }
10228    }
10229}
10230
10231fn drop_collapsed_rename_rename_conflicts(renames: &mut MergeRenames) {
10232    renames
10233        .rename_rename_one_to_two
10234        .retain(|_, rename| rename.ours_dest != rename.theirs_dest);
10235}
10236
10237fn apply_dir_rename_two_to_one_conflicts(
10238    db: &FileObjectDatabase,
10239    eff_ours: &MergeEntryMap,
10240    eff_theirs: &MergeEntryMap,
10241    conflicts: &[DirRenameTwoToOne],
10242    paths: &mut [MergedPath],
10243    leaves: &mut MergeEntryMap,
10244    options: &MergeTreesOptions<'_>,
10245) -> Result<()> {
10246    for conflict in conflicts {
10247        let Some(slot) = paths.iter_mut().find(|path| path.path == conflict.dest) else {
10248            continue;
10249        };
10250        let ours_entry = eff_ours.get(&conflict.dest).copied();
10251        let theirs_entry = eff_theirs.get(&conflict.dest).copied();
10252        let (Some((ours_mode, ours_oid)), Some((theirs_mode, theirs_oid))) =
10253            (ours_entry, theirs_entry)
10254        else {
10255            continue;
10256        };
10257        let ours_bytes = merge_blob_bytes(db, &ours_oid)?;
10258        let theirs_bytes = merge_blob_bytes(db, &theirs_oid)?;
10259        let (resolved_mode, mode_conflict) = merge_file_modes(None, ours_mode, theirs_mode);
10260        let favor = merge_favor_for_path(options, &conflict.dest);
10261        let result = if is_mergeable_file_mode(ours_mode) && is_mergeable_file_mode(theirs_mode) {
10262            merge_blobs(
10263                &[],
10264                &ours_bytes,
10265                &theirs_bytes,
10266                &MergeBlobOptions {
10267                    ours_label: &qualify_label(options.ours_label, &conflict.ours_label_path),
10268                    theirs_label: &qualify_label(options.theirs_label, &conflict.theirs_label_path),
10269                    base_label: options.ancestor_label,
10270                    style: options.style,
10271                    favor,
10272                    ws_ignore: options.ws_ignore,
10273                    marker_size: merge_marker_size_for_path(options, &conflict.dest),
10274                },
10275            )
10276        } else {
10277            MergeBlobResult {
10278                content: ours_bytes.clone(),
10279                conflicted: true,
10280            }
10281        };
10282        let oid = db.write_object(EncodedObject::new(ObjectType::Blob, result.content.clone()))?;
10283        leaves.insert(conflict.dest.clone(), (resolved_mode, oid));
10284        slot.stages = MergeStages {
10285            base: None,
10286            ours: ours_entry,
10287            theirs: theirs_entry,
10288        };
10289        slot.result = Some((resolved_mode, oid));
10290        slot.worktree = Some((
10291            if ours_mode == theirs_mode {
10292                ours_mode
10293            } else {
10294                0o100644
10295            },
10296            result.content,
10297        ));
10298        slot.conflict = Some(MergeConflictKind::RenameRenameTwoToOne {
10299            ours_path: conflict.ours_source.clone(),
10300            theirs_path: conflict.theirs_source.clone(),
10301        });
10302        slot.auto_merged = !mode_conflict;
10303    }
10304    Ok(())
10305}
10306
10307/// 3-way merge one rename's content into a single leaf entry: `base` is the
10308/// source's ancestor blob, `ours`/`theirs` the two sides' content (one of which
10309/// is the renamed file, the other the other side's change to the source). Both
10310/// present and differing → a real content merge; otherwise the surviving side's
10311/// entry is carried as-is.
10312fn rename_merged_leaf(
10313    db: &FileObjectDatabase,
10314    base: Option<(u32, ObjectId)>,
10315    ours: Option<(u32, ObjectId)>,
10316    theirs: Option<(u32, ObjectId)>,
10317    path: &[u8],
10318    options: &MergeTreesOptions<'_>,
10319) -> Result<Option<(u32, ObjectId)>> {
10320    match (ours, theirs) {
10321        (None, None) => Ok(None),
10322        (Some(entry), None) | (None, Some(entry)) => Ok(Some(entry)),
10323        (Some((ours_mode, ours_oid)), Some((theirs_mode, theirs_oid))) => {
10324            if (ours_mode, ours_oid) == (theirs_mode, theirs_oid) {
10325                return Ok(Some((ours_mode, ours_oid)));
10326            }
10327            if !is_mergeable_file_mode(ours_mode) || !is_mergeable_file_mode(theirs_mode) {
10328                return Ok(Some((ours_mode, ours_oid)));
10329            }
10330            let base_bytes = match base {
10331                Some((_, oid)) => merge_blob_bytes(db, &oid)?,
10332                None => Vec::new(),
10333            };
10334            let favor = merge_favor_for_path(options, path);
10335            let result = merge_blobs(
10336                &base_bytes,
10337                &merge_blob_bytes(db, &ours_oid)?,
10338                &merge_blob_bytes(db, &theirs_oid)?,
10339                &MergeBlobOptions {
10340                    ours_label: options.ours_label,
10341                    theirs_label: options.theirs_label,
10342                    base_label: options.ancestor_label,
10343                    style: options.style,
10344                    favor,
10345                    ws_ignore: options.ws_ignore,
10346                    marker_size: merge_marker_size_for_path(options, path),
10347                },
10348            );
10349            let (mode, _) = merge_file_modes(base.map(|(mode, _)| mode), ours_mode, theirs_mode);
10350            let oid = db.write_object(EncodedObject::new(ObjectType::Blob, result.content))?;
10351            Ok(Some((mode, oid)))
10352        }
10353    }
10354}
10355
10356/// Apply rename/rename(2to1) and rename/add conflicts: two distinct contents
10357/// land on one destination path. Each side's content at the destination is the
10358/// 3-way merge of its own rename (so the other side's change to the renamed
10359/// source follows the rename); the two results become stages 2 and 3 with no
10360/// common ancestor, and the worktree holds their two-way merge. The rename
10361/// source paths are consumed (removed from the path set) so they don't surface as
10362/// a spurious modify/delete.
10363#[allow(clippy::too_many_arguments)]
10364fn apply_rename_two_to_one_and_add_conflicts(
10365    db: &FileObjectDatabase,
10366    base_map: &MergeEntryMap,
10367    ours_map: &MergeEntryMap,
10368    theirs_map: &MergeEntryMap,
10369    renames: &MergeRenames,
10370    paths: &mut Vec<MergedPath>,
10371    leaves: &mut MergeEntryMap,
10372    options: &MergeTreesOptions<'_>,
10373) -> Result<()> {
10374    let mut consumed_sources: Vec<Vec<u8>> = Vec::new();
10375
10376    for (dest, conflict) in &renames.rename_rename_two_to_one {
10377        // Ours renamed `ours_source`->dest; theirs' change to `ours_source`
10378        // follows the rename. Symmetric for theirs.
10379        let ours_leaf = rename_merged_leaf(
10380            db,
10381            base_map.get(&conflict.ours_source).copied(),
10382            ours_map.get(dest).copied(),
10383            theirs_map.get(&conflict.ours_source).copied(),
10384            dest,
10385            options,
10386        )?;
10387        let theirs_leaf = rename_merged_leaf(
10388            db,
10389            base_map.get(&conflict.theirs_source).copied(),
10390            ours_map.get(&conflict.theirs_source).copied(),
10391            theirs_map.get(dest).copied(),
10392            dest,
10393            options,
10394        )?;
10395        write_two_sided_dest_conflict(
10396            db,
10397            dest,
10398            ours_leaf,
10399            theirs_leaf,
10400            MergeConflictKind::RenameRenameTwoToOne {
10401                ours_path: conflict.ours_source.clone(),
10402                theirs_path: conflict.theirs_source.clone(),
10403            },
10404            options,
10405            paths,
10406            leaves,
10407        )?;
10408        consumed_sources.push(conflict.ours_source.clone());
10409        consumed_sources.push(conflict.theirs_source.clone());
10410    }
10411
10412    for (dest, add) in &renames.rename_adds {
10413        let (ours_leaf, theirs_leaf) = match add.side {
10414            RenameSide::Ours => (
10415                rename_merged_leaf(
10416                    db,
10417                    base_map.get(&add.source).copied(),
10418                    ours_map.get(dest).copied(),
10419                    theirs_map.get(&add.source).copied(),
10420                    dest,
10421                    options,
10422                )?,
10423                theirs_map.get(dest).copied(),
10424            ),
10425            RenameSide::Theirs => (
10426                ours_map.get(dest).copied(),
10427                rename_merged_leaf(
10428                    db,
10429                    base_map.get(&add.source).copied(),
10430                    ours_map.get(&add.source).copied(),
10431                    theirs_map.get(dest).copied(),
10432                    dest,
10433                    options,
10434                )?,
10435            ),
10436        };
10437        write_two_sided_dest_conflict(
10438            db,
10439            dest,
10440            ours_leaf,
10441            theirs_leaf,
10442            MergeConflictKind::Content { add_add: true },
10443            options,
10444            paths,
10445            leaves,
10446        )?;
10447        consumed_sources.push(add.source.clone());
10448    }
10449
10450    // The rename source paths are consumed by the rename: the other side's
10451    // change to them followed the rename to the destination, so they resolve to
10452    // a clean deletion (not the path-keyed core's modify/delete). Marking them
10453    // `Resolved(None)` lets the worktree writer remove the now-stale source file
10454    // rather than leaving it as a stray untracked file.
10455    for source in &consumed_sources {
10456        leaves.remove(source);
10457        if let Some(slot) = paths.iter_mut().find(|path| &path.path == source) {
10458            slot.stages = MergeStages::default();
10459            slot.result = None;
10460            slot.worktree = None;
10461            slot.conflict = None;
10462            slot.auto_merged = false;
10463        } else {
10464            paths.push(MergedPath {
10465                path: source.clone(),
10466                stages: MergeStages::default(),
10467                result: None,
10468                worktree: None,
10469                conflict: None,
10470                auto_merged: false,
10471            });
10472        }
10473    }
10474    Ok(())
10475}
10476
10477/// Record a destination path that holds two unmerged contents (rename/rename
10478/// 2to1 or rename/add): stage 2 = `ours_leaf`, stage 3 = `theirs_leaf`, no
10479/// common ancestor, worktree = their two-way merge. Replaces any existing slot
10480/// (the path-keyed core's add/add result) for the destination.
10481#[allow(clippy::too_many_arguments)]
10482fn write_two_sided_dest_conflict(
10483    db: &FileObjectDatabase,
10484    dest: &[u8],
10485    ours_leaf: Option<(u32, ObjectId)>,
10486    theirs_leaf: Option<(u32, ObjectId)>,
10487    kind: MergeConflictKind,
10488    options: &MergeTreesOptions<'_>,
10489    paths: &mut Vec<MergedPath>,
10490    leaves: &mut MergeEntryMap,
10491) -> Result<()> {
10492    let ours_bytes = match ours_leaf {
10493        Some((mode, oid)) => Some((mode, merge_worktree_bytes(db, mode, &oid)?)),
10494        None => None,
10495    };
10496    let theirs_bytes = match theirs_leaf {
10497        Some((mode, oid)) => Some((mode, merge_worktree_bytes(db, mode, &oid)?)),
10498        None => None,
10499    };
10500    let (worktree_mode, worktree_content, result_leaf) = match (&ours_bytes, &theirs_bytes) {
10501        (Some((ours_mode, ours_content)), Some((theirs_mode, theirs_content))) => {
10502            let favor = merge_favor_for_path(options, dest);
10503            let merged = merge_blobs(
10504                &[],
10505                ours_content,
10506                theirs_content,
10507                &MergeBlobOptions {
10508                    ours_label: options.ours_label,
10509                    theirs_label: options.theirs_label,
10510                    base_label: options.ancestor_label,
10511                    style: options.style,
10512                    favor,
10513                    ws_ignore: options.ws_ignore,
10514                    marker_size: merge_marker_size_for_path(options, dest),
10515                },
10516            );
10517            let mode = if ours_mode == theirs_mode {
10518                *ours_mode
10519            } else {
10520                0o100644
10521            };
10522            let oid =
10523                db.write_object(EncodedObject::new(ObjectType::Blob, merged.content.clone()))?;
10524            (mode, merged.content, Some((mode, oid)))
10525        }
10526        (Some((mode, content)), None) | (None, Some((mode, content))) => {
10527            (*mode, content.clone(), ours_leaf.or(theirs_leaf))
10528        }
10529        (None, None) => (0o100644, Vec::new(), None),
10530    };
10531
10532    let slot = MergedPath {
10533        path: dest.to_vec(),
10534        stages: MergeStages {
10535            base: None,
10536            ours: ours_leaf,
10537            theirs: theirs_leaf,
10538        },
10539        result: result_leaf,
10540        worktree: Some((worktree_mode, worktree_content)),
10541        conflict: Some(kind),
10542        auto_merged: true,
10543    };
10544    if let Some(existing) = paths.iter_mut().find(|path| path.path == dest) {
10545        *existing = slot;
10546    } else {
10547        paths.push(slot);
10548    }
10549    if let Some(leaf) = result_leaf {
10550        leaves.insert(dest.to_vec(), leaf);
10551    } else {
10552        leaves.remove(dest);
10553    }
10554    Ok(())
10555}
10556
10557#[allow(clippy::too_many_arguments)]
10558fn apply_rename_rename_one_to_two_conflicts(
10559    db: &FileObjectDatabase,
10560    base_map: &MergeEntryMap,
10561    eff_ours: &MergeEntryMap,
10562    eff_theirs: &MergeEntryMap,
10563    conflicts: &BTreeMap<Vec<u8>, RenameRenameOneToTwo>,
10564    paths: &mut Vec<MergedPath>,
10565    leaves: &mut MergeEntryMap,
10566    options: &MergeTreesOptions<'_>,
10567) -> Result<()> {
10568    for (old_path, conflict) in conflicts {
10569        let base_entry = base_map.get(old_path).copied();
10570        let ours_entry = eff_ours.get(&conflict.ours_dest).copied();
10571        let theirs_entry = eff_theirs.get(&conflict.theirs_dest).copied();
10572        let theirs_add_at_ours_dest = eff_theirs.get(&conflict.ours_dest).copied();
10573        let ours_add_at_theirs_dest = eff_ours.get(&conflict.theirs_dest).copied();
10574
10575        leaves.remove(old_path);
10576        leaves.remove(&conflict.ours_dest);
10577        leaves.remove(&conflict.theirs_dest);
10578        paths.retain(|path| {
10579            path.path != *old_path
10580                && path.path != conflict.ours_dest
10581                && path.path != conflict.theirs_dest
10582        });
10583
10584        paths.push(MergedPath {
10585            path: old_path.clone(),
10586            stages: MergeStages {
10587                base: base_entry,
10588                ours: None,
10589                theirs: None,
10590            },
10591            result: None,
10592            worktree: None,
10593            conflict: Some(MergeConflictKind::RenameRenameOneToTwo {
10594                old_path: old_path.clone(),
10595                ours_path: conflict.ours_dest.clone(),
10596                theirs_path: conflict.theirs_dest.clone(),
10597                ours_label: options.ours_label.to_string(),
10598                theirs_label: options.theirs_label.to_string(),
10599            }),
10600            auto_merged: false,
10601        });
10602
10603        let ours_worktree = match ours_entry {
10604            Some((mode, oid)) => Some((mode, merge_worktree_bytes(db, mode, &oid)?)),
10605            None => None,
10606        };
10607        paths.push(MergedPath {
10608            path: conflict.ours_dest.clone(),
10609            stages: MergeStages {
10610                base: None,
10611                ours: ours_entry,
10612                theirs: theirs_add_at_ours_dest,
10613            },
10614            result: None,
10615            worktree: ours_worktree,
10616            conflict: Some(MergeConflictKind::RenameRenameOneToTwoStage),
10617            auto_merged: false,
10618        });
10619
10620        let theirs_worktree = match theirs_entry {
10621            Some((mode, oid)) => Some((mode, merge_worktree_bytes(db, mode, &oid)?)),
10622            None => None,
10623        };
10624        paths.push(MergedPath {
10625            path: conflict.theirs_dest.clone(),
10626            stages: MergeStages {
10627                base: None,
10628                ours: ours_add_at_theirs_dest,
10629                theirs: theirs_entry,
10630            },
10631            result: None,
10632            worktree: theirs_worktree,
10633            conflict: Some(MergeConflictKind::RenameRenameOneToTwoStage),
10634            auto_merged: false,
10635        });
10636    }
10637    Ok(())
10638}
10639
10640/// Build a path-qualified conflict-marker label `"<label>:<path>"`, as git does
10641/// for renamed files (so the two sides of a conflict name their distinct paths).
10642fn qualify_label(label: &str, path: &[u8]) -> String {
10643    format!("{label}:{}", String::from_utf8_lossy(path))
10644}
10645
10646/// Adapt a flat `path -> (mode, oid)` map into the `TrackedEntry` map the
10647/// name-status diff core consumes.
10648fn entry_map_as_tracked(map: &MergeEntryMap) -> BTreeMap<Vec<u8>, TrackedEntry> {
10649    map.iter()
10650        .map(|(path, (mode, oid))| {
10651            (
10652                path.clone(),
10653                TrackedEntry {
10654                    mode: *mode,
10655                    oid: *oid,
10656                },
10657            )
10658        })
10659        .collect()
10660}
10661
10662#[cfg(test)]
10663mod tests {
10664    use super::*;
10665    use sley_formats::RepositoryLayout;
10666    use sley_object::TreeEntry;
10667    use sley_odb::ObjectWriter;
10668    use std::path::PathBuf;
10669    use std::sync::atomic::{AtomicU64, Ordering};
10670
10671    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
10672
10673    #[test]
10674    fn name_status_reports_added_from_index() {
10675        let root = temp_root();
10676        let layout = RepositoryLayout::init_at(&root, ObjectFormat::Sha1, false)
10677            .expect("test operation should succeed");
10678        let db = FileObjectDatabase::from_git_dir(&layout.git_dir, ObjectFormat::Sha1);
10679        let oid = db
10680            .write_object(EncodedObject::new(ObjectType::Blob, b"hello\n".to_vec()))
10681            .expect("test operation should succeed");
10682        let index = Index {
10683            version: 2,
10684            entries: vec![sley_index::IndexEntry {
10685                ctime_seconds: 0,
10686                ctime_nanoseconds: 0,
10687                mtime_seconds: 0,
10688                mtime_nanoseconds: 0,
10689                dev: 0,
10690                ino: 0,
10691                mode: 0o100644,
10692                uid: 0,
10693                gid: 0,
10694                size: 6,
10695                oid,
10696                flags: "hello.txt".len() as u16,
10697                flags_extended: 0,
10698                path: BString::from(b"hello.txt"),
10699            }],
10700            extensions: Vec::new(),
10701            checksum: None,
10702        };
10703        fs::write(
10704            layout.git_dir.join("index"),
10705            index
10706                .write_v2_sha1()
10707                .expect("test operation should succeed"),
10708        )
10709        .expect("test operation should succeed");
10710        fs::write(root.join("hello.txt"), b"hello\n").expect("test operation should succeed");
10711        let changes = diff_name_status_head_worktree(&root, &layout.git_dir, ObjectFormat::Sha1)
10712            .expect("test operation should succeed");
10713        assert_eq!(changes[0].line(), "A\thello.txt");
10714        fs::remove_dir_all(root).expect("test operation should succeed");
10715    }
10716
10717    #[test]
10718    fn tree_worktree_diff_treats_absent_skip_worktree_entries_as_clean() {
10719        let root = temp_root();
10720        let layout = RepositoryLayout::init_at(&root, ObjectFormat::Sha1, false)
10721            .expect("test operation should succeed");
10722        let mut db = FileObjectDatabase::from_git_dir(&layout.git_dir, ObjectFormat::Sha1);
10723        let oid = write_blob(&mut db, b"clean\n");
10724        let tree = write_tree(
10725            &mut db,
10726            &[(b"removeme", 0o100644, oid), (b"untouched", 0o100644, oid)],
10727        );
10728        write_index(
10729            &layout.git_dir,
10730            vec![
10731                skip_worktree_entry(b"removeme", oid),
10732                skip_worktree_entry(b"untouched", oid),
10733            ],
10734        );
10735
10736        let changes = diff_name_status_tree_worktree_with_options(
10737            &root,
10738            &layout.git_dir,
10739            ObjectFormat::Sha1,
10740            &tree,
10741            DiffNameStatusOptions::default(),
10742        )
10743        .expect("test operation should succeed");
10744
10745        assert!(
10746            changes.is_empty(),
10747            "absent sparse entries should not appear as deletes: {changes:?}"
10748        );
10749        fs::remove_dir_all(root).expect("test operation should succeed");
10750    }
10751
10752    #[test]
10753    fn tree_worktree_diff_trusts_present_skip_worktree_entry_when_sparse_disabled() {
10754        let root = temp_root();
10755        let layout = RepositoryLayout::init_at(&root, ObjectFormat::Sha1, false)
10756            .expect("test operation should succeed");
10757        let mut db = FileObjectDatabase::from_git_dir(&layout.git_dir, ObjectFormat::Sha1);
10758        let oid = write_blob(&mut db, b"clean\n");
10759        let tree = write_tree(&mut db, &[(b"modified", 0o100644, oid)]);
10760        write_index(&layout.git_dir, vec![skip_worktree_entry(b"modified", oid)]);
10761        fs::write(root.join("modified"), b"dirty\n").expect("test operation should succeed");
10762
10763        let changes = diff_name_status_tree_worktree_with_options(
10764            &root,
10765            &layout.git_dir,
10766            ObjectFormat::Sha1,
10767            &tree,
10768            DiffNameStatusOptions::default(),
10769        )
10770        .expect("test operation should succeed");
10771
10772        assert!(
10773            changes.is_empty(),
10774            "present skip-worktree dirt should be ignored when sparse checkout is disabled: {changes:?}"
10775        );
10776        fs::remove_dir_all(root).expect("test operation should succeed");
10777    }
10778
10779    #[test]
10780    fn tree_worktree_diff_adds_index_blob_for_present_skip_worktree_entry() {
10781        let root = temp_root();
10782        let layout = RepositoryLayout::init_at(&root, ObjectFormat::Sha1, false)
10783            .expect("test operation should succeed");
10784        let mut db = FileObjectDatabase::from_git_dir(&layout.git_dir, ObjectFormat::Sha1);
10785        let oid = write_blob(&mut db, b"");
10786        let tree = write_tree(&mut db, &[]);
10787        write_index(&layout.git_dir, vec![skip_worktree_entry(b"added", oid)]);
10788        fs::write(root.join("added"), b"dirty\n").expect("test operation should succeed");
10789
10790        let changes = diff_name_status_tree_worktree_with_options(
10791            &root,
10792            &layout.git_dir,
10793            ObjectFormat::Sha1,
10794            &tree,
10795            DiffNameStatusOptions::default(),
10796        )
10797        .expect("test operation should succeed");
10798
10799        assert_eq!(changes.len(), 1);
10800        assert_eq!(changes[0].line(), "A\tadded");
10801        assert_eq!(changes[0].new_oid, Some(oid));
10802        fs::remove_dir_all(root).expect("test operation should succeed");
10803    }
10804
10805    #[test]
10806    fn index_worktree_diff_returns_staged_gitlinks() {
10807        let root = temp_root();
10808        let layout = RepositoryLayout::init_at(&root, ObjectFormat::Sha1, false)
10809            .expect("test operation should succeed");
10810        let oid = ObjectId::from_hex(
10811            ObjectFormat::Sha1,
10812            "1111111111111111111111111111111111111111",
10813        )
10814        .expect("test operation should succeed");
10815        let index = Index {
10816            version: 2,
10817            entries: vec![sley_index::IndexEntry {
10818                ctime_seconds: 0,
10819                ctime_nanoseconds: 0,
10820                mtime_seconds: 0,
10821                mtime_nanoseconds: 0,
10822                dev: 0,
10823                ino: 0,
10824                mode: sley_index::GITLINK_MODE,
10825                uid: 0,
10826                gid: 0,
10827                size: 0,
10828                oid,
10829                flags: "deps/sub".len() as u16,
10830                flags_extended: 0,
10831                path: BString::from(b"deps/sub"),
10832            }],
10833            extensions: Vec::new(),
10834            checksum: None,
10835        };
10836        fs::write(
10837            layout.git_dir.join("index"),
10838            index
10839                .write_v2_sha1()
10840                .expect("test operation should succeed"),
10841        )
10842        .expect("test operation should succeed");
10843
10844        let diff = diff_name_status_index_worktree_with_options_and_gitlinks(
10845            &root,
10846            &layout.git_dir,
10847            ObjectFormat::Sha1,
10848            DiffNameStatusOptions::default(),
10849        )
10850        .expect("test operation should succeed");
10851
10852        assert_eq!(diff.entries.len(), 1);
10853        let gitlinks = diff.staged_gitlinks;
10854        assert_eq!(gitlinks.len(), 1);
10855        assert_eq!(gitlinks[0].path.as_bytes(), b"deps/sub");
10856        assert_eq!(gitlinks[0].oid, oid);
10857        fs::remove_dir_all(root).expect("test operation should succeed");
10858    }
10859
10860    #[cfg(unix)]
10861    #[test]
10862    fn index_worktree_diff_ignores_untracked_dangling_symlink() {
10863        use std::os::unix::fs::symlink;
10864
10865        let root = temp_root();
10866        let layout = RepositoryLayout::init_at(&root, ObjectFormat::Sha1, false)
10867            .expect("test operation should succeed");
10868        let db = FileObjectDatabase::from_git_dir(&layout.git_dir, ObjectFormat::Sha1);
10869        let oid = db
10870            .write_object(EncodedObject::new(ObjectType::Blob, b"clean\n".to_vec()))
10871            .expect("test operation should succeed");
10872        let index = Index {
10873            version: 2,
10874            entries: vec![sley_index::IndexEntry {
10875                ctime_seconds: 0,
10876                ctime_nanoseconds: 0,
10877                mtime_seconds: 0,
10878                mtime_nanoseconds: 0,
10879                dev: 0,
10880                ino: 0,
10881                mode: 0o100644,
10882                uid: 0,
10883                gid: 0,
10884                size: 6,
10885                oid,
10886                flags: "tracked.txt".len() as u16,
10887                flags_extended: 0,
10888                path: BString::from(b"tracked.txt"),
10889            }],
10890            extensions: Vec::new(),
10891            checksum: None,
10892        };
10893        fs::write(
10894            layout.git_dir.join("index"),
10895            index
10896                .write_v2_sha1()
10897                .expect("test operation should succeed"),
10898        )
10899        .expect("test operation should succeed");
10900        fs::write(root.join("tracked.txt"), b"clean\n").expect("test operation should succeed");
10901        symlink("missing-target", root.join("untracked-link"))
10902            .expect("test operation should succeed");
10903
10904        let changes = diff_name_status_index_worktree_with_options(
10905            &root,
10906            &layout.git_dir,
10907            ObjectFormat::Sha1,
10908            DiffNameStatusOptions {
10909                detect_renames: false,
10910                detect_copies: false,
10911                find_copies_harder: false,
10912                rename_empty: true,
10913            },
10914        )
10915        .expect("untracked dangling symlink should be ignored");
10916        assert!(changes.is_empty());
10917        fs::remove_dir_all(root).expect("test operation should succeed");
10918    }
10919
10920    #[test]
10921    fn index_worktree_diff_trusts_non_racy_stat_cache() {
10922        let root = temp_root();
10923        let layout = RepositoryLayout::init_at(&root, ObjectFormat::Sha1, false)
10924            .expect("test operation should succeed");
10925        let worktree_path = root.join("tracked.txt");
10926        fs::write(&worktree_path, b"clean\n").expect("test operation should succeed");
10927        let metadata = fs::symlink_metadata(&worktree_path).expect("test operation should succeed");
10928        let (mtime_seconds, mtime_nanoseconds) =
10929            sley_index::file_mtime_parts(&metadata).expect("test operation should succeed");
10930        let bogus_oid = ObjectId::from_hex(
10931            ObjectFormat::Sha1,
10932            "1111111111111111111111111111111111111111",
10933        )
10934        .expect("test operation should succeed");
10935        let index = Index {
10936            version: 2,
10937            entries: vec![sley_index::IndexEntry {
10938                ctime_seconds: 0,
10939                ctime_nanoseconds: 0,
10940                mtime_seconds: mtime_seconds as u32,
10941                mtime_nanoseconds: mtime_nanoseconds as u32,
10942                dev: 0,
10943                ino: 0,
10944                mode: sley_index::worktree_metadata_mode(&metadata),
10945                uid: 0,
10946                gid: 0,
10947                size: metadata.len() as u32,
10948                oid: bogus_oid,
10949                flags: "tracked.txt".len() as u16,
10950                flags_extended: 0,
10951                path: BString::from(b"tracked.txt"),
10952            }],
10953            extensions: Vec::new(),
10954            checksum: None,
10955        };
10956        std::thread::sleep(std::time::Duration::from_millis(1100));
10957        fs::write(
10958            layout.git_dir.join("index"),
10959            index
10960                .write_v2_sha1()
10961                .expect("test operation should succeed"),
10962        )
10963        .expect("test operation should succeed");
10964
10965        let changes = diff_name_status_index_worktree(&root, &layout.git_dir, ObjectFormat::Sha1)
10966            .expect("test operation should succeed");
10967        assert!(
10968            changes.is_empty(),
10969            "a clean non-racy stat match must reuse the cached index oid"
10970        );
10971        fs::remove_dir_all(root).expect("test operation should succeed");
10972    }
10973
10974    fn temp_root() -> PathBuf {
10975        let path = std::env::temp_dir().join(format!(
10976            "sley-diff-{}-{}",
10977            std::process::id(),
10978            TEMP_COUNTER.fetch_add(1, Ordering::Relaxed)
10979        ));
10980        fs::create_dir_all(&path).expect("test operation should succeed");
10981        path
10982    }
10983
10984    // ---- line diff / blob merge tests ---------------------------------------
10985
10986    fn merge_opts() -> MergeBlobOptions<'static> {
10987        MergeBlobOptions {
10988            ours_label: "ours",
10989            theirs_label: "theirs",
10990            base_label: "base",
10991            style: ConflictStyle::Merge,
10992            favor: MergeFavor::None,
10993            ws_ignore: WsIgnore::EMPTY,
10994            marker_size: 7,
10995        }
10996    }
10997
10998    #[test]
10999    fn split_lines_preserves_content_and_newlines() {
11000        let lines = split_lines(b"a\nb\nc\n");
11001        assert_eq!(lines.len(), 3);
11002        assert_eq!(lines[0].content, b"a\n");
11003        assert!(lines[0].has_newline);
11004        assert_eq!(lines[2].content, b"c\n");
11005        assert!(lines[2].has_newline);
11006        assert!(split_lines(b"").is_empty());
11007    }
11008
11009    #[test]
11010    fn split_lines_tracks_missing_final_newline() {
11011        let lines = split_lines(b"a\nb");
11012        assert_eq!(lines.len(), 2);
11013        assert!(lines[0].has_newline);
11014        assert!(!lines[1].has_newline);
11015        assert_eq!(lines[1].content, b"b");
11016        assert_eq!(lines[1].bytes_without_newline(), b"b");
11017        // A line that lost its newline must not compare equal to one that has it.
11018        let with_nl = split_lines(b"b\n");
11019        assert_ne!(lines[1], with_nl[0]);
11020    }
11021
11022    #[test]
11023    fn myers_replace_single_line() {
11024        let old = split_lines(b"a\nb\nc\n");
11025        let new = split_lines(b"a\nx\nc\n");
11026        assert_eq!(
11027            myers_diff_lines(&old, &new),
11028            vec![
11029                DiffOp::Equal(1),
11030                DiffOp::Delete(1),
11031                DiffOp::Insert(1),
11032                DiffOp::Equal(1),
11033            ]
11034        );
11035    }
11036
11037    #[test]
11038    fn myers_identical_is_single_equal() {
11039        let old = split_lines(b"a\nb\nc\n");
11040        let new = split_lines(b"a\nb\nc\n");
11041        assert_eq!(myers_diff_lines(&old, &new), vec![DiffOp::Equal(3)]);
11042    }
11043
11044    #[test]
11045    fn myers_pure_insert_and_delete() {
11046        let empty = split_lines(b"");
11047        let two = split_lines(b"a\nb\n");
11048        assert_eq!(myers_diff_lines(&empty, &two), vec![DiffOp::Insert(2)]);
11049        assert_eq!(myers_diff_lines(&two, &empty), vec![DiffOp::Delete(2)]);
11050
11051        let old = split_lines(b"a\nb\nc\nd\n");
11052        let new = split_lines(b"a\nc\nd\n");
11053        assert_eq!(
11054            myers_diff_lines(&old, &new),
11055            vec![DiffOp::Equal(1), DiffOp::Delete(1), DiffOp::Equal(2)]
11056        );
11057    }
11058
11059    #[test]
11060    fn myers_reconstructs_new_and_is_minimal() {
11061        // Apply the script to `old` and confirm it yields `new`; also count edits.
11062        let old = split_lines(b"the\nquick\nbrown\nfox\n");
11063        let new = split_lines(b"the\nlazy\nbrown\ncat\n");
11064        let ops = myers_diff_lines(&old, &new);
11065        let mut oi = 0usize;
11066        let mut ni = 0usize;
11067        let mut edits = 0usize;
11068        let mut rebuilt: Vec<u8> = Vec::new();
11069        for op in &ops {
11070            match *op {
11071                DiffOp::Equal(n) => {
11072                    for _ in 0..n {
11073                        assert_eq!(old[oi], new[ni]);
11074                        rebuilt.extend_from_slice(old[oi].content);
11075                        oi += 1;
11076                        ni += 1;
11077                    }
11078                }
11079                DiffOp::Delete(n) => {
11080                    oi += n;
11081                    edits += n;
11082                }
11083                DiffOp::Insert(n) => {
11084                    for _ in 0..n {
11085                        rebuilt.extend_from_slice(new[ni].content);
11086                        ni += 1;
11087                    }
11088                    edits += n;
11089                }
11090            }
11091        }
11092        assert_eq!(rebuilt, b"the\nlazy\nbrown\ncat\n");
11093        // Two lines changed -> 2 deletes + 2 inserts is the minimal SES here.
11094        assert_eq!(edits, 4);
11095    }
11096
11097    #[test]
11098    fn merge_non_overlapping_changes_is_clean() {
11099        let base = b"a\nb\nc\nd\ne\n";
11100        let ours = b"A\nb\nc\nd\ne\n";
11101        let theirs = b"a\nb\nc\nd\nE\n";
11102        let result = merge_blobs(base, ours, theirs, &merge_opts());
11103        assert!(!result.conflicted);
11104        assert_eq!(result.content, b"A\nb\nc\nd\nE\n");
11105    }
11106
11107    #[test]
11108    fn merge_identical_changes_no_conflict() {
11109        let base = b"a\nb\nc\n";
11110        let ours = b"a\nX\nc\n";
11111        let theirs = b"a\nX\nc\n";
11112        let result = merge_blobs(base, ours, theirs, &merge_opts());
11113        assert!(!result.conflicted);
11114        assert_eq!(result.content, b"a\nX\nc\n");
11115    }
11116
11117    #[test]
11118    fn merge_overlapping_change_emits_exact_markers() {
11119        let base = b"a\nb\nc\n";
11120        let ours = b"a\nOURS\nc\n";
11121        let theirs = b"a\nTHEIRS\nc\n";
11122        let result = merge_blobs(base, ours, theirs, &merge_opts());
11123        assert!(result.conflicted);
11124        assert_eq!(
11125            result.content,
11126            b"a\n<<<<<<< ours\nOURS\n=======\nTHEIRS\n>>>>>>> theirs\nc\n".to_vec(),
11127        );
11128    }
11129
11130    #[test]
11131    fn merge_diff3_style_includes_base_section() {
11132        let base = b"a\nb\nc\n";
11133        let ours = b"a\nOURS\nc\n";
11134        let theirs = b"a\nTHEIRS\nc\n";
11135        let options = MergeBlobOptions {
11136            style: ConflictStyle::Diff3,
11137            ..merge_opts()
11138        };
11139        let result = merge_blobs(base, ours, theirs, &options);
11140        assert!(result.conflicted);
11141        assert_eq!(
11142            result.content,
11143            b"a\n<<<<<<< ours\nOURS\n||||||| base\nb\n=======\nTHEIRS\n>>>>>>> theirs\nc\n"
11144                .to_vec(),
11145        );
11146    }
11147
11148    #[test]
11149    fn merge_empty_label_omits_trailing_space() {
11150        let base = b"a\nb\nc\n";
11151        let ours = b"a\nOURS\nc\n";
11152        let theirs = b"a\nTHEIRS\nc\n";
11153        let options = MergeBlobOptions {
11154            ours_label: "",
11155            theirs_label: "",
11156            base_label: "",
11157            style: ConflictStyle::Merge,
11158            favor: MergeFavor::None,
11159            ws_ignore: WsIgnore::EMPTY,
11160            marker_size: 7,
11161        };
11162        let result = merge_blobs(base, ours, theirs, &options);
11163        assert!(result.conflicted);
11164        // No trailing space after the 7 marker chars when the label is empty.
11165        assert_eq!(
11166            result.content,
11167            b"a\n<<<<<<<\nOURS\n=======\nTHEIRS\n>>>>>>>\nc\n".to_vec(),
11168        );
11169    }
11170
11171    #[test]
11172    fn merge_add_add_empty_base_conflicts() {
11173        let result = merge_blobs(b"", b"x\ny\n", b"p\nq\n", &merge_opts());
11174        assert!(result.conflicted);
11175        assert_eq!(
11176            result.content,
11177            b"<<<<<<< ours\nx\ny\n=======\np\nq\n>>>>>>> theirs\n".to_vec(),
11178        );
11179    }
11180
11181    #[test]
11182    fn merge_ignore_space_change_resolves_clean_keeping_ours() {
11183        // ours: only-whitespace change (collapsed run); theirs: real change.
11184        // Under -Xignore-space-change the whitespace-only line is not a conflict
11185        // and ours' actual bytes survive (xdl_merge copies common spans from
11186        // file1); theirs' real change to a different line wins on its own line.
11187        let base = b"alpha   beta\nsecond line\n";
11188        let ours = b"alpha beta\nsecond line\n"; // collapsed the run
11189        let theirs = b"alpha   beta\nsecond CHANGED\n"; // real change on line 2
11190        let options = MergeBlobOptions {
11191            ws_ignore: WsIgnore {
11192                space_change: true,
11193                ..WsIgnore::EMPTY
11194            },
11195            ..merge_opts()
11196        };
11197        let result = merge_blobs(base, ours, theirs, &options);
11198        assert!(
11199            !result.conflicted,
11200            "whitespace-only divergence is not a conflict"
11201        );
11202        assert_eq!(result.content, b"alpha beta\nsecond CHANGED\n".to_vec());
11203    }
11204
11205    #[test]
11206    fn merge_ignore_space_change_still_conflicts_on_real_divergence() {
11207        // Both sides make a real (non-whitespace) change to the same line: still
11208        // a conflict even under -Xignore-space-change.
11209        let base = b"one\n";
11210        let ours = b"OURS\n";
11211        let theirs = b"THEIRS\n";
11212        let options = MergeBlobOptions {
11213            ws_ignore: WsIgnore {
11214                space_change: true,
11215                ..WsIgnore::EMPTY
11216            },
11217            ..merge_opts()
11218        };
11219        let result = merge_blobs(base, ours, theirs, &options);
11220        assert!(result.conflicted);
11221    }
11222
11223    #[test]
11224    fn merge_add_add_empty_base_identical_is_clean() {
11225        let result = merge_blobs(b"", b"x\ny\n", b"x\ny\n", &merge_opts());
11226        assert!(!result.conflicted);
11227        assert_eq!(result.content, b"x\ny\n");
11228    }
11229
11230    #[test]
11231    fn merge_deletion_one_side_takes_deletion() {
11232        // ours deletes line b; theirs leaves it -> clean, deletion wins.
11233        let result = merge_blobs(b"a\nb\nc\n", b"a\nc\n", b"a\nb\nc\n", &merge_opts());
11234        assert!(!result.conflicted);
11235        assert_eq!(result.content, b"a\nc\n");
11236    }
11237
11238    #[test]
11239    fn merge_deletion_vs_modification_conflicts() {
11240        // ours deletes b; theirs modifies b -> conflict.
11241        let result = merge_blobs(b"a\nb\nc\n", b"a\nc\n", b"a\nB!\nc\n", &merge_opts());
11242        assert!(result.conflicted);
11243        // ours side of the conflict is empty (the line was deleted).
11244        assert_eq!(
11245            result.content,
11246            b"a\n<<<<<<< ours\n=======\nB!\n>>>>>>> theirs\nc\n".to_vec(),
11247        );
11248    }
11249
11250    #[test]
11251    fn merge_missing_final_newline_marker_starts_on_own_line() {
11252        // Both sides drop the trailing newline AND conflict at the end. The
11253        // closing marker section must still begin on its own line.
11254        let base = b"a\nb";
11255        let ours = b"a\nOURS";
11256        let theirs = b"a\nTHEIRS";
11257        let result = merge_blobs(base, ours, theirs, &merge_opts());
11258        assert!(result.conflicted);
11259        assert_eq!(
11260            result.content,
11261            b"a\n<<<<<<< ours\nOURS\n=======\nTHEIRS\n>>>>>>> theirs\n".to_vec(),
11262        );
11263    }
11264
11265    #[test]
11266    fn merge_clean_preserves_missing_final_newline() {
11267        // ours removes the trailing newline; theirs is unchanged -> ours wins,
11268        // and the result keeps the missing newline.
11269        let result = merge_blobs(b"a\nb\n", b"a\nb", b"a\nb\n", &merge_opts());
11270        assert!(!result.conflicted);
11271        assert_eq!(result.content, b"a\nb");
11272    }
11273
11274    #[test]
11275    fn merge_both_append_identical_tail_is_clean() {
11276        let result = merge_blobs(b"a\n", b"a\nz\n", b"a\nz\n", &merge_opts());
11277        assert!(!result.conflicted);
11278        assert_eq!(result.content, b"a\nz\n");
11279    }
11280
11281    #[test]
11282    fn merge_when_ours_equals_base_yields_theirs() {
11283        // Regression: a side that did not change must not suppress the other
11284        // side's edits anywhere in the file.
11285        let base = b"b\na\n";
11286        let theirs = b"b\nb\nc\na\nc\n";
11287        let result = merge_blobs(base, base, theirs, &merge_opts());
11288        assert!(!result.conflicted);
11289        assert_eq!(result.content, theirs.to_vec());
11290    }
11291    fn applied(outcome: ApplyOutcome) -> Vec<u8> {
11292        match outcome {
11293            ApplyOutcome::Applied(bytes) => bytes,
11294            ApplyOutcome::Rejected => panic!("expected Applied, got Rejected"),
11295        }
11296    }
11297
11298    #[test]
11299    fn parse_multi_file_patch() {
11300        let patch = b"\
11301diff --git a/one.txt b/one.txt
11302index aaaaaaa..bbbbbbb 100644
11303--- a/one.txt
11304+++ b/one.txt
11305@@ -1,3 +1,3 @@
11306 alpha
11307-beta
11308+BETA
11309 gamma
11310diff --git a/two.txt b/two.txt
11311index ccccccc..ddddddd 100644
11312--- a/two.txt
11313+++ b/two.txt
11314@@ -1,2 +1,3 @@
11315 first
11316+inserted
11317 second
11318";
11319        let patches = parse_unified_patch(patch).expect("test operation should succeed");
11320        assert_eq!(patches.len(), 2);
11321
11322        assert_eq!(patches[0].old_path.as_deref(), Some(b"one.txt".as_slice()));
11323        assert_eq!(patches[0].new_path.as_deref(), Some(b"one.txt".as_slice()));
11324        // The `index <a>..<b> 100644` line carries the unchanged-file mode, which
11325        // git's gitdiff_index records as old_mode.
11326        assert_eq!(patches[0].old_mode, Some(0o100644));
11327        assert_eq!(
11328            patches[0].old_oid_hex.as_deref(),
11329            Some(b"aaaaaaa".as_slice())
11330        );
11331        assert_eq!(
11332            patches[0].new_oid_hex.as_deref(),
11333            Some(b"bbbbbbb".as_slice())
11334        );
11335        assert_eq!(patches[0].hunks.len(), 1);
11336        let h = &patches[0].hunks[0];
11337        assert_eq!(
11338            (h.old_start, h.old_len, h.new_start, h.new_len),
11339            (1, 3, 1, 3)
11340        );
11341        assert_eq!(
11342            h.lines,
11343            vec![
11344                HunkLine::Context(b"alpha".to_vec()),
11345                HunkLine::Delete(b"beta".to_vec()),
11346                HunkLine::Insert(b"BETA".to_vec()),
11347                HunkLine::Context(b"gamma".to_vec()),
11348            ]
11349        );
11350
11351        assert_eq!(patches[1].new_path.as_deref(), Some(b"two.txt".as_slice()));
11352        assert_eq!(patches[1].hunks[0].new_len, 3);
11353    }
11354
11355    #[test]
11356    fn parse_default_hunk_range_length() {
11357        // `@@ -1 +1,2 @@` (no comma) means a length of 1 on the old side.
11358        let patch = b"\
11359--- a/x
11360+++ b/x
11361@@ -1 +1,2 @@
11362 line
11363+added
11364";
11365        let patches = parse_unified_patch(patch).expect("test operation should succeed");
11366        let h = &patches[0].hunks[0];
11367        assert_eq!(
11368            (h.old_start, h.old_len, h.new_start, h.new_len),
11369            (1, 1, 1, 2)
11370        );
11371    }
11372
11373    #[test]
11374    fn parse_hunk_header_before_file_errors() {
11375        let patch = b"@@ -1,1 +1,1 @@\n context\n";
11376        assert!(parse_unified_patch(patch).is_err());
11377    }
11378
11379    #[test]
11380    fn parse_mismatched_counts_errors() {
11381        // Header promises two old lines but only one is present.
11382        let patch = b"--- a/x\n+++ b/x\n@@ -1,2 +1,2 @@\n only\n+new\n";
11383        assert!(parse_unified_patch(patch).is_err());
11384    }
11385
11386    #[test]
11387    fn apply_clean_hunk() {
11388        let base = b"alpha\nbeta\ngamma\n";
11389        let patch = parse_unified_patch(
11390            b"--- a/x\n+++ b/x\n@@ -1,3 +1,3 @@\n alpha\n-beta\n+BETA\n gamma\n",
11391        )
11392        .expect("test operation should succeed");
11393        let out = applied(apply_file_patch(base, &patch[0]));
11394        assert_eq!(out, b"alpha\nBETA\ngamma\n");
11395    }
11396
11397    #[test]
11398    fn apply_with_line_offset() {
11399        // The hunk's recorded position (line 2) is a couple of lines above where
11400        // the matching context actually lives (line 4); the outward search must
11401        // find it. The hunk is NOT anchored at the file start (old_start > 1, so
11402        // no match_beginning) and has trailing context (`tail`, so no
11403        // match_end), which is exactly the shape a real drifted patch takes —
11404        // verified against `git apply` ("Hunk #1 succeeded at 4 (offset 2)").
11405        let base = b"pre1\npre2\npre3\nalpha\nbeta\ngamma\ntail\n";
11406        let patch = parse_unified_patch(
11407            b"--- a/x\n+++ b/x\n@@ -2,4 +2,4 @@\n alpha\n-beta\n+BETA\n gamma\n tail\n",
11408        )
11409        .expect("test operation should succeed");
11410        let out = applied(apply_file_patch(base, &patch[0]));
11411        assert_eq!(out, b"pre1\npre2\npre3\nalpha\nBETA\ngamma\ntail\n");
11412    }
11413
11414    #[test]
11415    fn apply_with_negative_line_offset() {
11416        // Recorded position is well past the real location; search backward.
11417        let base = b"alpha\nbeta\ngamma\n";
11418        let patch = parse_unified_patch(
11419            b"--- a/x\n+++ b/x\n@@ -50,3 +50,3 @@\n alpha\n-beta\n+BETA\n gamma\n",
11420        )
11421        .expect("test operation should succeed");
11422        let out = applied(apply_file_patch(base, &patch[0]));
11423        assert_eq!(out, b"alpha\nBETA\ngamma\n");
11424    }
11425
11426    #[test]
11427    fn apply_multiple_hunks() {
11428        let base = b"a\nb\nc\nd\ne\nf\ng\nh\n";
11429        let patch = parse_unified_patch(
11430            b"--- a/x\n+++ b/x\n\
11431@@ -1,3 +1,3 @@\n a\n-b\n+B\n c\n\
11432@@ -6,3 +6,3 @@\n f\n-g\n+G\n h\n",
11433        )
11434        .expect("test operation should succeed");
11435        let out = applied(apply_file_patch(base, &patch[0]));
11436        assert_eq!(out, b"a\nB\nc\nd\ne\nf\nG\nh\n");
11437    }
11438
11439    #[test]
11440    fn reject_on_context_mismatch() {
11441        let base = b"alpha\nDIFFERENT\ngamma\n";
11442        let patch = parse_unified_patch(
11443            b"--- a/x\n+++ b/x\n@@ -1,3 +1,3 @@\n alpha\n-beta\n+BETA\n gamma\n",
11444        )
11445        .expect("test operation should succeed");
11446        assert_eq!(apply_file_patch(base, &patch[0]), ApplyOutcome::Rejected);
11447    }
11448
11449    #[test]
11450    fn reject_when_match_end_required_but_not_at_eof() {
11451        // git's `apply.c`: a hunk with NO trailing context must match the END of
11452        // the file (`match_end`). Here the leading context (`tail`/`anchor`)
11453        // matches at the middle of the base, but there are further lines after
11454        // it, so the preimage does not reach EOF. git rejects this; the old
11455        // sley matcher wrongly applied it (duplicating the appended block). This
11456        // is the t4150-am cell-34 lever: rejection forces `am -3`'s 3-way path.
11457        let base = b"one\ntwo\nanchor\nalready\nappended\n";
11458        // Hunk: context `anchor`, then append `added1`/`added2`. No trailing
11459        // context => match_end. At line 3 (`anchor`) the preimage is just one
11460        // line and does not reach EOF, so it must be rejected.
11461        let patch =
11462            parse_unified_patch(b"--- a/x\n+++ b/x\n@@ -3,1 +3,3 @@\n anchor\n+added1\n+added2\n")
11463                .expect("test operation should succeed");
11464        assert_eq!(apply_file_patch(base, &patch[0]), ApplyOutcome::Rejected);
11465    }
11466
11467    #[test]
11468    fn append_at_eof_matches_when_context_reaches_end() {
11469        // The mirror of the rejection case: the same shape applies cleanly when
11470        // the matching context IS the last line of the file (preimage reaches
11471        // EOF), so `match_end` is satisfied.
11472        let base = b"one\ntwo\nanchor\n";
11473        let patch =
11474            parse_unified_patch(b"--- a/x\n+++ b/x\n@@ -3,1 +3,3 @@\n anchor\n+added1\n+added2\n")
11475                .expect("test operation should succeed");
11476        let out = applied(apply_file_patch(base, &patch[0]));
11477        assert_eq!(out, b"one\ntwo\nanchor\nadded1\nadded2\n");
11478    }
11479
11480    #[test]
11481    fn reject_when_match_beginning_required_but_not_at_start() {
11482        // A hunk anchored at line 1 (`old_start <= 1`) must match the START of
11483        // the file (`match_beginning`). If the matching context only appears
11484        // later, git rejects rather than wandering to it.
11485        let base = b"junk\nalpha\nbeta\ngamma\n";
11486        let patch =
11487            parse_unified_patch(b"--- a/x\n+++ b/x\n@@ -1,2 +1,3 @@\n alpha\n+INSERT\n beta\n")
11488                .expect("test operation should succeed");
11489        assert_eq!(apply_file_patch(base, &patch[0]), ApplyOutcome::Rejected);
11490    }
11491
11492    #[test]
11493    fn no_default_fuzz_rejects_on_trailing_context_mismatch() {
11494        // `git apply` / `git am` keep `p_context = UINT_MAX` by default, so they
11495        // do NOT fuzz a hunk in by dropping context. Here the trailing context
11496        // line (`gamma`) differs from the base (`DIVERGED`), and because the
11497        // anchor is line 1 the hunk must match the beginning with its FULL
11498        // preimage. Verified against real `git apply`: this is rejected.
11499        let base = b"alpha\nbeta\nDIVERGED\n";
11500        let patch = parse_unified_patch(
11501            b"--- a/x\n+++ b/x\n@@ -1,3 +1,3 @@\n alpha\n-beta\n+BETA\n gamma\n",
11502        )
11503        .expect("test operation should succeed");
11504        assert_eq!(apply_file_patch(base, &patch[0]), ApplyOutcome::Rejected);
11505    }
11506
11507    #[test]
11508    fn parse_and_apply_new_file() {
11509        let patch = parse_unified_patch(
11510            b"\
11511diff --git a/new.txt b/new.txt
11512new file mode 100644
11513index 0000000..1111111
11514--- /dev/null
11515+++ b/new.txt
11516@@ -0,0 +1,2 @@
11517+hello
11518+world
11519",
11520        )
11521        .expect("test operation should succeed");
11522        assert!(patches_first_is_new(&patch));
11523        assert_eq!(patch[0].old_path, None);
11524        assert_eq!(patch[0].new_path.as_deref(), Some(b"new.txt".as_slice()));
11525        assert_eq!(patch[0].new_mode, Some(0o100644));
11526        // Base is ignored for a new file.
11527        let out = applied(apply_file_patch(b"garbage that is ignored", &patch[0]));
11528        assert_eq!(out, b"hello\nworld\n");
11529    }
11530
11531    fn patches_first_is_new(patches: &[FilePatch]) -> bool {
11532        patches.first().map(|p| p.is_new).unwrap_or(false)
11533    }
11534
11535    #[test]
11536    fn parse_and_apply_delete_file() {
11537        let patch = parse_unified_patch(
11538            b"\
11539diff --git a/gone.txt b/gone.txt
11540deleted file mode 100644
11541index 1111111..0000000
11542--- a/gone.txt
11543+++ /dev/null
11544@@ -1,2 +0,0 @@
11545-hello
11546-world
11547",
11548        )
11549        .expect("test operation should succeed");
11550        assert!(patch[0].is_delete);
11551        assert_eq!(patch[0].old_path.as_deref(), Some(b"gone.txt".as_slice()));
11552        assert_eq!(patch[0].new_path, None);
11553        assert_eq!(patch[0].old_mode, Some(0o100644));
11554        let out = applied(apply_file_patch(b"hello\nworld\n", &patch[0]));
11555        assert_eq!(out, b"");
11556    }
11557
11558    #[test]
11559    fn parse_rename_headers() {
11560        let patch = parse_unified_patch(
11561            b"\
11562diff --git a/old/name.txt b/new/name.txt
11563similarity index 100%
11564rename from old/name.txt
11565rename to new/name.txt
11566",
11567        )
11568        .expect("test operation should succeed");
11569        assert!(patch[0].is_rename);
11570        assert_eq!(
11571            patch[0].old_path.as_deref(),
11572            Some(b"old/name.txt".as_slice())
11573        );
11574        assert_eq!(
11575            patch[0].new_path.as_deref(),
11576            Some(b"new/name.txt".as_slice())
11577        );
11578        assert!(patch[0].hunks.is_empty());
11579    }
11580
11581    #[test]
11582    fn parse_mode_change_headers() {
11583        let patch = parse_unified_patch(
11584            b"\
11585diff --git a/script.sh b/script.sh
11586old mode 100644
11587new mode 100755
11588",
11589        )
11590        .expect("test operation should succeed");
11591        assert_eq!(patch[0].old_mode, Some(0o100644));
11592        assert_eq!(patch[0].new_mode, Some(0o100755));
11593        assert!(!patch[0].is_new);
11594        assert!(!patch[0].is_delete);
11595    }
11596
11597    #[test]
11598    fn no_final_newline_base_preserved_when_untouched() {
11599        // The change is on line 1; the final line has no newline and is not
11600        // modified, so its no-newline state must survive. This uses the patch
11601        // shape real `git diff` emits for such a change — `@@ -1,3 +1,3 @@` with
11602        // the two unchanged lines as trailing context (the `\ No newline`
11603        // marker rides the last context line). A hand-rolled `@@ -1,1 +1,1 @@`
11604        // with NO trailing context would (correctly) be rejected by git, since
11605        // a no-trailing-context hunk anchored at line 1 must span the whole
11606        // file (`match_beginning` && `match_end`).
11607        let base = b"alpha\nbeta\nnotail"; // "notail" has no trailing \n
11608        let patch = parse_unified_patch(
11609            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",
11610        )
11611        .expect("test operation should succeed");
11612        let out = applied(apply_file_patch(base, &patch[0]));
11613        assert_eq!(out, b"ALPHA\nbeta\nnotail");
11614    }
11615
11616    #[test]
11617    fn no_final_newline_added_by_patch() {
11618        // Old file ends with a newline; patch rewrites the last line to one
11619        // without a trailing newline.
11620        let base = b"alpha\nbeta\n";
11621        let patch = parse_unified_patch(
11622            b"--- a/x\n+++ b/x\n@@ -2,1 +2,1 @@\n-beta\n+beta-notail\n\\ No newline at end of file\n",
11623        )
11624        .expect("test operation should succeed");
11625        assert!(patch[0].hunks[0].new_no_newline);
11626        assert!(!patch[0].hunks[0].old_no_newline);
11627        let out = applied(apply_file_patch(base, &patch[0]));
11628        assert_eq!(out, b"alpha\nbeta-notail");
11629    }
11630
11631    #[test]
11632    fn no_final_newline_in_base_matched_and_kept() {
11633        // Both sides lack a trailing newline; context match must require the
11634        // base's final line to itself be newline-free.
11635        let base = b"alpha\nbeta"; // no trailing newline
11636        let patch = parse_unified_patch(
11637            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",
11638        )
11639        .expect("test operation should succeed");
11640        assert!(patch[0].hunks[0].old_no_newline);
11641        assert!(patch[0].hunks[0].new_no_newline);
11642        let out = applied(apply_file_patch(base, &patch[0]));
11643        assert_eq!(out, b"ALPHA\nbeta");
11644    }
11645
11646    #[test]
11647    fn no_final_newline_mismatch_rejected() {
11648        // Patch asserts the old file has no trailing newline, but the base does.
11649        // That must be rejected rather than silently mis-applied.
11650        let base = b"alpha\nbeta\n"; // HAS trailing newline
11651        let patch = parse_unified_patch(
11652            b"--- a/x\n+++ b/x\n@@ -2,1 +2,1 @@\n-beta\n\\ No newline at end of file\n+beta2\n",
11653        )
11654        .expect("test operation should succeed");
11655        assert!(patch[0].hunks[0].old_no_newline);
11656        assert_eq!(apply_file_patch(base, &patch[0]), ApplyOutcome::Rejected);
11657    }
11658
11659    #[test]
11660    fn delete_with_no_final_newline() {
11661        // Deleting the entire content of a file that had no trailing newline.
11662        let base = b"only line no newline";
11663        let patch = parse_unified_patch(
11664            b"--- a/x\n+++ /dev/null\n@@ -1,1 +0,0 @@\n-only line no newline\n\\ No newline at end of file\n",
11665        )
11666        .expect("test operation should succeed");
11667        assert!(patch[0].is_delete);
11668        let out = applied(apply_file_patch(base, &patch[0]));
11669        assert_eq!(out, b"");
11670    }
11671
11672    #[test]
11673    fn apply_pure_insertion_hunk() {
11674        let base = b"first\nsecond\n";
11675        let patch =
11676            parse_unified_patch(b"--- a/x\n+++ b/x\n@@ -1,2 +1,3 @@\n first\n+middle\n second\n")
11677                .expect("test operation should succeed");
11678        let out = applied(apply_file_patch(base, &patch[0]));
11679        assert_eq!(out, b"first\nmiddle\nsecond\n");
11680    }
11681
11682    #[test]
11683    fn apply_pure_deletion_hunk() {
11684        let base = b"first\nmiddle\nsecond\n";
11685        let patch =
11686            parse_unified_patch(b"--- a/x\n+++ b/x\n@@ -1,3 +1,2 @@\n first\n-middle\n second\n")
11687                .expect("test operation should succeed");
11688        let out = applied(apply_file_patch(base, &patch[0]));
11689        assert_eq!(out, b"first\nsecond\n");
11690    }
11691
11692    #[test]
11693    fn apply_then_reparse_round_trip() {
11694        // Hand-written unified diff -> apply -> the result is exactly the new
11695        // file content the diff describes. Re-parsing the same patch yields an
11696        // identical structure (idempotent parse).
11697        let base = b"l1\nl2\nl3\nl4\nl5\n";
11698        let text = b"--- a/f\n+++ b/f\n@@ -2,3 +2,4 @@\n l2\n-l3\n+L3\n+L3b\n l4\n";
11699        let p1 = parse_unified_patch(text).expect("test operation should succeed");
11700        let p2 = parse_unified_patch(text).expect("test operation should succeed");
11701        assert_eq!(p1, p2);
11702        let out = applied(apply_file_patch(base, &p1[0]));
11703        assert_eq!(out, b"l1\nl2\nL3\nL3b\nl4\nl5\n");
11704    }
11705
11706    #[test]
11707    fn empty_context_line_without_trailing_space() {
11708        // Some transports strip the single leading space from blank context
11709        // lines; the parser treats a wholly empty body line as blank context.
11710        let base = b"a\n\nb\n";
11711        let patch = parse_unified_patch(b"--- a/x\n+++ b/x\n@@ -1,3 +1,3 @@\n a\n\n-b\n+B\n")
11712            .expect("test operation should succeed");
11713        assert_eq!(patch[0].hunks[0].lines[1], HunkLine::Context(Vec::new()));
11714        let out = applied(apply_file_patch(base, &patch[0]));
11715        assert_eq!(out, b"a\n\nB\n");
11716    }
11717
11718    #[test]
11719    fn split_blob_lines_handles_edge_cases() {
11720        assert!(split_blob_lines(b"").is_empty());
11721        let single = split_blob_lines(b"abc");
11722        assert_eq!(single.len(), 1);
11723        assert!(single[0].no_newline);
11724        let terminated = split_blob_lines(b"abc\n");
11725        assert_eq!(terminated.len(), 1);
11726        assert!(!terminated[0].no_newline);
11727        let blank_then_eof = split_blob_lines(b"x\n");
11728        assert_eq!(blank_then_eof.len(), 1);
11729    }
11730
11731    // ---- content similarity & inexact rename/copy detection -----------------
11732
11733    #[test]
11734    fn similarity_identical_and_empty_conventions() {
11735        // Byte-identical blobs are always 100% similar.
11736        assert_eq!(blob_similarity(b"hello\nworld\n", b"hello\nworld\n"), 100);
11737        // Two empty blobs are identical -> 100.
11738        assert_eq!(blob_similarity(b"", b""), 100);
11739        // An empty blob vs a non-empty one shares nothing -> 0.
11740        assert_eq!(blob_similarity(b"", b"hello\n"), 0);
11741        assert_eq!(blob_similarity(b"hello\n", b""), 0);
11742    }
11743
11744    #[test]
11745    fn similarity_one_changed_line_is_75_and_symmetric() {
11746        // A = one/two/three/four/five (bytes: 4+4+6+5+5 = 24).
11747        // B changes "three\n" -> "THREE\n" (same total size 24).
11748        // Common spans: one,two,four,five = 4+4+5+5 = 18 bytes.
11749        // score = round(18 * 100 / max(24, 24)) = round(75) = 75.
11750        // Verified against `git diff -M` which reports "similarity index 75%".
11751        let a = b"one\ntwo\nthree\nfour\nfive\n";
11752        let b = b"one\ntwo\nTHREE\nfour\nfive\n";
11753        assert_eq!(blob_similarity(a, b), 75);
11754        // The metric is symmetric.
11755        assert_eq!(blob_similarity(b, a), 75);
11756    }
11757
11758    #[test]
11759    fn similarity_one_edited_line_of_three_is_66_not_67() {
11760        // "a\nb\nc\n" -> "a\nB\nc\n": one of three lines edited (4 common bytes of
11761        // 6). git reports `R066` / "similarity index 66%". git's two-step integer
11762        // math is `4 * 60000 / 6 = 40000`, then `40000 * 100 / 60000 = 66` (both
11763        // truncated); a single rounded `4 * 100 / 6` would give 67. This pins the
11764        // MAX_SCORE-based rounding so it stays aligned with diffcore-rename.
11765        assert_eq!(blob_similarity(b"a\nb\nc\n", b"a\nB\nc\n"), 66);
11766        assert_eq!(blob_similarity(b"a\nB\nc\n", b"a\nb\nc\n"), 66);
11767    }
11768
11769    #[test]
11770    fn similarity_small_append_is_88() {
11771        // A: 8 lines totalling 46 bytes. B: same 8 lines + "ADDED\n" (6 bytes) = 52.
11772        // Common = the 46 original bytes; score = round(46*100/52) = 88.
11773        // Verified against `git diff -M` -> "similarity index 88%".
11774        let a = b"alpha\nbeta\ngamma\ndelta\nepsilon\nzeta\neta\ntheta\n";
11775        let b = b"alpha\nbeta\ngamma\ndelta\nepsilon\nzeta\neta\ntheta\nADDED\n";
11776        assert_eq!(blob_similarity(a, b), 88);
11777    }
11778
11779    #[test]
11780    fn similarity_half_rewrite_is_50() {
11781        // 6 lines, last 3 rewritten. Common = l1,l2,l3 = 9 bytes; total each 18.
11782        // score = round(9*100/18) = 50. Verified against `git diff -M`.
11783        let a = b"l1\nl2\nl3\nl4\nl5\nl6\n";
11784        let b = b"l1\nl2\nl3\nX4\nX5\nX6\n";
11785        assert_eq!(blob_similarity(a, b), 50);
11786    }
11787
11788    // ---- tree-diff based inexact detection ----------------------------------
11789
11790    /// Write a blob and return its oid.
11791    fn write_blob(db: &mut FileObjectDatabase, bytes: &[u8]) -> ObjectId {
11792        db.write_object(EncodedObject::new(ObjectType::Blob, bytes.to_vec()))
11793            .expect("test operation should succeed")
11794    }
11795
11796    /// Write a tree from `(name, mode, oid)` entries (sorted by name as git
11797    /// requires) and return its oid.
11798    fn write_tree(db: &mut FileObjectDatabase, entries: &[(&[u8], u32, ObjectId)]) -> ObjectId {
11799        let mut tree_entries: Vec<TreeEntry> = entries
11800            .iter()
11801            .map(|(name, mode, oid)| TreeEntry {
11802                mode: *mode,
11803                name: BString::from(*name),
11804                oid: *oid,
11805            })
11806            .collect();
11807        tree_entries.sort_by(|a, b| a.name.cmp(&b.name));
11808        let tree = Tree {
11809            entries: tree_entries,
11810        };
11811        db.write_object(EncodedObject::new(ObjectType::Tree, tree.write()))
11812            .expect("test operation should succeed")
11813    }
11814
11815    fn write_tree_in_order(
11816        db: &mut FileObjectDatabase,
11817        entries: &[(&[u8], u32, ObjectId)],
11818    ) -> ObjectId {
11819        let tree_entries: Vec<TreeEntry> = entries
11820            .iter()
11821            .map(|(name, mode, oid)| TreeEntry {
11822                mode: *mode,
11823                name: BString::from(*name),
11824                oid: *oid,
11825            })
11826            .collect();
11827        let tree = Tree {
11828            entries: tree_entries,
11829        };
11830        db.write_object(EncodedObject::new(ObjectType::Tree, tree.write()))
11831            .expect("test operation should succeed")
11832    }
11833
11834    fn skip_worktree_entry(path: &[u8], oid: ObjectId) -> sley_index::IndexEntry {
11835        let mut entry = sley_index::IndexEntry {
11836            ctime_seconds: 0,
11837            ctime_nanoseconds: 0,
11838            mtime_seconds: 0,
11839            mtime_nanoseconds: 0,
11840            dev: 0,
11841            ino: 0,
11842            mode: 0o100644,
11843            uid: 0,
11844            gid: 0,
11845            size: 0,
11846            oid,
11847            flags: path.len() as u16,
11848            flags_extended: 0,
11849            path: BString::from(path),
11850        };
11851        entry.set_skip_worktree(true);
11852        entry
11853    }
11854
11855    fn write_index(git_dir: &Path, mut entries: Vec<sley_index::IndexEntry>) {
11856        entries.sort_by(|left, right| left.path.as_bytes().cmp(right.path.as_bytes()));
11857        let index = Index {
11858            version: 3,
11859            entries,
11860            extensions: Vec::new(),
11861            checksum: None,
11862        };
11863        fs::write(
11864            git_dir.join("index"),
11865            index.write_sha1().expect("test operation should succeed"),
11866        )
11867        .expect("test operation should succeed");
11868    }
11869
11870    #[test]
11871    fn inexact_rename_detected_with_plausible_score() {
11872        // a.txt (one changed line vs the new b.txt) should be detected as a
11873        // rename with score 75 (see `similarity_one_changed_line_is_75`).
11874        let root = temp_root();
11875        let layout = RepositoryLayout::init_at(&root, ObjectFormat::Sha1, false)
11876            .expect("test operation should succeed");
11877        let mut db = FileObjectDatabase::from_git_dir(&layout.git_dir, ObjectFormat::Sha1);
11878
11879        let old = write_blob(&mut db, b"one\ntwo\nthree\nfour\nfive\n");
11880        let new = write_blob(&mut db, b"one\ntwo\nTHREE\nfour\nfive\n");
11881        let left = write_tree(&mut db, &[(b"a.txt", 0o100644, old)]);
11882        let right = write_tree(&mut db, &[(b"b.txt", 0o100644, new)]);
11883
11884        let opts = RenameDetectionOptions {
11885            base: DiffNameStatusOptions {
11886                detect_renames: true,
11887                detect_copies: false,
11888                find_copies_harder: false,
11889                rename_empty: true,
11890            },
11891            detect_inexact: true,
11892            rename_threshold: DEFAULT_RENAME_THRESHOLD,
11893            copy_threshold: DEFAULT_RENAME_THRESHOLD,
11894            rename_limit: 0,
11895        };
11896        let entries = diff_name_status_trees_with_rename_options(
11897            &db,
11898            ObjectFormat::Sha1,
11899            &left,
11900            &right,
11901            opts,
11902        )
11903        .expect("test operation should succeed");
11904
11905        assert_eq!(
11906            entries.len(),
11907            1,
11908            "expected a single rename entry: {entries:?}"
11909        );
11910        assert_eq!(entries[0].status, NameStatus::Renamed(75));
11911        assert_eq!(
11912            entries[0].old_path.as_ref().map(|p| p.as_bytes()),
11913            Some(b"a.txt".as_slice())
11914        );
11915        assert_eq!(entries[0].path, b"b.txt");
11916        assert_eq!(entries[0].line(), "R075\ta.txt\tb.txt");
11917        fs::remove_dir_all(root).expect("test operation should succeed");
11918    }
11919
11920    #[test]
11921    fn tree_diff_preserves_duplicate_entry_multiplicity() {
11922        let root = temp_root();
11923        let layout = RepositoryLayout::init_at(&root, ObjectFormat::Sha1, false)
11924            .expect("test operation should succeed");
11925        let mut db = FileObjectDatabase::from_git_dir(&layout.git_dir, ObjectFormat::Sha1);
11926
11927        let blob_one = write_blob(&mut db, b"one\n");
11928        let blob_two = write_blob(&mut db, b"two\n");
11929        let inner_one_a = write_tree_in_order(&mut db, &[(b"inner", 0o100644, blob_one)]);
11930        let inner_one_b = write_tree_in_order(
11931            &mut db,
11932            &[
11933                (b"inner", 0o100644, blob_two),
11934                (b"inner", 0o100644, blob_two),
11935                (b"inner", 0o100644, blob_two),
11936            ],
11937        );
11938        let outer_one = write_tree_in_order(
11939            &mut db,
11940            &[
11941                (b"outer", TREE_ENTRY_MODE, inner_one_a),
11942                (b"outer", TREE_ENTRY_MODE, inner_one_b),
11943            ],
11944        );
11945        let inner_two = write_tree_in_order(
11946            &mut db,
11947            &[
11948                (b"inner", 0o100644, blob_one),
11949                (b"inner", 0o100644, blob_two),
11950                (b"inner", 0o100644, blob_two),
11951                (b"inner", 0o100644, blob_two),
11952            ],
11953        );
11954        let outer_two = write_tree_in_order(&mut db, &[(b"outer", TREE_ENTRY_MODE, inner_two)]);
11955        let outer_three = write_tree_in_order(&mut db, &[(b"renamed", 0o100644, blob_one)]);
11956
11957        let raw_options = DiffNameStatusOptions {
11958            detect_renames: false,
11959            detect_copies: false,
11960            find_copies_harder: false,
11961            rename_empty: true,
11962        };
11963        let raw = diff_name_status_trees_with_options(
11964            &db,
11965            ObjectFormat::Sha1,
11966            &outer_one,
11967            &outer_two,
11968            raw_options,
11969        )
11970        .expect("test operation should succeed");
11971        assert_eq!(
11972            status_lines(&raw),
11973            vec![
11974                "A\touter/inner",
11975                "A\touter/inner",
11976                "A\touter/inner",
11977                "D\touter/inner",
11978                "D\touter/inner",
11979                "D\touter/inner",
11980            ]
11981        );
11982
11983        let rename_options = RenameDetectionOptions {
11984            base: DiffNameStatusOptions {
11985                detect_renames: true,
11986                detect_copies: false,
11987                find_copies_harder: false,
11988                rename_empty: true,
11989            },
11990            detect_inexact: true,
11991            rename_threshold: DEFAULT_RENAME_THRESHOLD,
11992            copy_threshold: DEFAULT_RENAME_THRESHOLD,
11993            rename_limit: 0,
11994        };
11995        let no_op_renames = diff_name_status_trees_with_rename_options(
11996            &db,
11997            ObjectFormat::Sha1,
11998            &outer_one,
11999            &outer_two,
12000            rename_options,
12001        )
12002        .expect("test operation should succeed");
12003        assert!(no_op_renames.is_empty());
12004
12005        let renamed = diff_name_status_trees_with_rename_options(
12006            &db,
12007            ObjectFormat::Sha1,
12008            &outer_one,
12009            &outer_three,
12010            rename_options,
12011        )
12012        .expect("test operation should succeed");
12013        assert_eq!(
12014            status_lines(&renamed),
12015            vec![
12016                "D\touter/inner",
12017                "D\touter/inner",
12018                "D\touter/inner",
12019                "R100\touter/inner\trenamed",
12020            ]
12021        );
12022
12023        fs::remove_dir_all(root).expect("test operation should succeed");
12024    }
12025
12026    #[test]
12027    fn inexact_rename_below_threshold_not_detected() {
12028        // A half-rewrite scores 50%. With a 60% threshold it must NOT be paired;
12029        // the change shows up as a separate Add + Delete instead.
12030        let root = temp_root();
12031        let layout = RepositoryLayout::init_at(&root, ObjectFormat::Sha1, false)
12032            .expect("test operation should succeed");
12033        let mut db = FileObjectDatabase::from_git_dir(&layout.git_dir, ObjectFormat::Sha1);
12034
12035        let old = write_blob(&mut db, b"l1\nl2\nl3\nl4\nl5\nl6\n");
12036        let new = write_blob(&mut db, b"l1\nl2\nl3\nX4\nX5\nX6\n");
12037        let left = write_tree(&mut db, &[(b"a.txt", 0o100644, old)]);
12038        let right = write_tree(&mut db, &[(b"b.txt", 0o100644, new)]);
12039
12040        let opts = RenameDetectionOptions {
12041            base: DiffNameStatusOptions {
12042                detect_renames: true,
12043                detect_copies: false,
12044                find_copies_harder: false,
12045                rename_empty: true,
12046            },
12047            detect_inexact: true,
12048            rename_threshold: 60,
12049            copy_threshold: 60,
12050            rename_limit: 0,
12051        };
12052        let entries = diff_name_status_trees_with_rename_options(
12053            &db,
12054            ObjectFormat::Sha1,
12055            &left,
12056            &right,
12057            opts,
12058        )
12059        .expect("test operation should succeed");
12060
12061        let statuses: Vec<_> = entries.iter().map(|e| e.status).collect();
12062        assert!(
12063            statuses.contains(&NameStatus::Added) && statuses.contains(&NameStatus::Deleted),
12064            "expected separate add/delete below threshold, got {entries:?}"
12065        );
12066        assert!(
12067            !statuses.iter().any(|s| matches!(s, NameStatus::Renamed(_))),
12068            "no rename should be reported below threshold: {entries:?}"
12069        );
12070
12071        // Sanity: lowering the threshold to 50 *does* detect it (boundary is
12072        // inclusive), and the score is exactly 50.
12073        let opts_low = RenameDetectionOptions {
12074            rename_threshold: 50,
12075            ..opts
12076        };
12077        let entries_low = diff_name_status_trees_with_rename_options(
12078            &db,
12079            ObjectFormat::Sha1,
12080            &left,
12081            &right,
12082            opts_low,
12083        )
12084        .expect("test operation should succeed");
12085        assert_eq!(entries_low.len(), 1);
12086        assert_eq!(entries_low[0].status, NameStatus::Renamed(50));
12087        fs::remove_dir_all(root).expect("test operation should succeed");
12088    }
12089
12090    #[test]
12091    fn exact_rename_scores_100_and_takes_priority() {
12092        // Identical content moved to a new path is an exact rename: score 100,
12093        // detected even with inexact disabled, and still 100 with it enabled.
12094        let root = temp_root();
12095        let layout = RepositoryLayout::init_at(&root, ObjectFormat::Sha1, false)
12096            .expect("test operation should succeed");
12097        let mut db = FileObjectDatabase::from_git_dir(&layout.git_dir, ObjectFormat::Sha1);
12098
12099        let oid = write_blob(&mut db, b"identical\ncontent\nhere\n");
12100        let left = write_tree(&mut db, &[(b"old.txt", 0o100644, oid)]);
12101        let right = write_tree(&mut db, &[(b"new.txt", 0o100644, oid)]);
12102
12103        for inexact in [false, true] {
12104            let opts = RenameDetectionOptions {
12105                base: DiffNameStatusOptions {
12106                    detect_renames: true,
12107                    detect_copies: false,
12108                    find_copies_harder: false,
12109                    rename_empty: true,
12110                },
12111                detect_inexact: inexact,
12112                rename_threshold: DEFAULT_RENAME_THRESHOLD,
12113                copy_threshold: DEFAULT_RENAME_THRESHOLD,
12114                rename_limit: 0,
12115            };
12116            let entries = diff_name_status_trees_with_rename_options(
12117                &db,
12118                ObjectFormat::Sha1,
12119                &left,
12120                &right,
12121                opts,
12122            )
12123            .expect("test operation should succeed");
12124            assert_eq!(entries.len(), 1, "inexact={inexact}: {entries:?}");
12125            assert_eq!(entries[0].status, NameStatus::Renamed(100));
12126            assert_eq!(
12127                entries[0].old_path.as_ref().map(|p| p.as_bytes()),
12128                Some(b"old.txt".as_slice())
12129            );
12130            assert_eq!(entries[0].path, b"new.txt");
12131        }
12132        fs::remove_dir_all(root).expect("test operation should succeed");
12133    }
12134
12135    #[test]
12136    fn inexact_copy_detected_with_score() {
12137        // orig.txt is unchanged and a near-copy (one line differs, 80% similar)
12138        // is added. With copy detection + find_copies_harder + inexact, the new
12139        // file is reported as a copy with score 80 (matches `git diff -C
12140        // --find-copies-harder`).
12141        let root = temp_root();
12142        let layout = RepositoryLayout::init_at(&root, ObjectFormat::Sha1, false)
12143            .expect("test operation should succeed");
12144        let mut db = FileObjectDatabase::from_git_dir(&layout.git_dir, ObjectFormat::Sha1);
12145
12146        let orig = write_blob(&mut db, b"aaa\nbbb\nccc\nddd\neee\n");
12147        let copy = write_blob(&mut db, b"aaa\nbbb\nccc\nddd\nEEE\n");
12148        let left = write_tree(&mut db, &[(b"orig.txt", 0o100644, orig.clone())]);
12149        let right = write_tree(
12150            &mut db,
12151            &[(b"orig.txt", 0o100644, orig), (b"copy.txt", 0o100644, copy)],
12152        );
12153
12154        let opts = RenameDetectionOptions {
12155            base: DiffNameStatusOptions {
12156                detect_renames: true,
12157                detect_copies: true,
12158                find_copies_harder: true,
12159                rename_empty: true,
12160            },
12161            detect_inexact: true,
12162            rename_threshold: DEFAULT_RENAME_THRESHOLD,
12163            copy_threshold: DEFAULT_RENAME_THRESHOLD,
12164            rename_limit: 0,
12165        };
12166        let entries = diff_name_status_trees_with_rename_options(
12167            &db,
12168            ObjectFormat::Sha1,
12169            &left,
12170            &right,
12171            opts,
12172        )
12173        .expect("test operation should succeed");
12174
12175        let copy_entry = entries
12176            .iter()
12177            .find(|e| e.path == b"copy.txt")
12178            .unwrap_or_else(|| panic!("no copy.txt entry: {entries:?}"));
12179        assert_eq!(copy_entry.status, NameStatus::Copied(80));
12180        assert_eq!(
12181            copy_entry.old_path.as_ref().map(|p| p.as_bytes()),
12182            Some(b"orig.txt".as_slice())
12183        );
12184        // The source remains present (copies do not consume the original).
12185        assert!(
12186            entries.iter().all(|e| e.status != NameStatus::Deleted),
12187            "copy must not delete the source: {entries:?}"
12188        );
12189        fs::remove_dir_all(root).expect("test operation should succeed");
12190    }
12191
12192    #[test]
12193    fn inexact_copy_skipped_over_rename_limit() {
12194        // git's `too_many_rename_candidates`: when the copy matrix
12195        // (sources × dests) exceeds `rename_limit²`, inexact copy detection is
12196        // skipped wholesale and the new file is reported as a plain Add — the
12197        // same `A` real git emits (`git diff -C --find-copies-harder -l1` warns
12198        // "rename detection was skipped" and shows `A copy.txt`). A `rename_limit`
12199        // comfortably above the matrix still detects the copy, proving the gate
12200        // fires *only* over-limit and not on any positive limit.
12201        let root = temp_root();
12202        let layout = RepositoryLayout::init_at(&root, ObjectFormat::Sha1, false)
12203            .expect("test operation should succeed");
12204        let mut db = FileObjectDatabase::from_git_dir(&layout.git_dir, ObjectFormat::Sha1);
12205
12206        let orig = write_blob(&mut db, b"aaa\nbbb\nccc\nddd\neee\n");
12207        let extra = write_blob(&mut db, b"111\n222\n333\n444\n555\n");
12208        let copy = write_blob(&mut db, b"aaa\nbbb\nccc\nddd\nEEE\n");
12209        // Two unchanged left files → under `--find-copies-harder` both are copy
12210        // sources, so the matrix is 2 (sources) × 1 (dest) = 2.
12211        let left = write_tree(
12212            &mut db,
12213            &[
12214                (b"orig.txt", 0o100644, orig.clone()),
12215                (b"extra.txt", 0o100644, extra.clone()),
12216            ],
12217        );
12218        let right = write_tree(
12219            &mut db,
12220            &[
12221                (b"orig.txt", 0o100644, orig),
12222                (b"extra.txt", 0o100644, extra),
12223                (b"copy.txt", 0o100644, copy),
12224            ],
12225        );
12226
12227        let opts_for = |rename_limit| RenameDetectionOptions {
12228            base: DiffNameStatusOptions {
12229                detect_renames: true,
12230                detect_copies: true,
12231                find_copies_harder: true,
12232                rename_empty: true,
12233            },
12234            detect_inexact: true,
12235            rename_threshold: DEFAULT_RENAME_THRESHOLD,
12236            copy_threshold: DEFAULT_RENAME_THRESHOLD,
12237            rename_limit,
12238        };
12239
12240        // Over limit: 2 × 1 = 2 > 1² ⇒ copy detection skipped, copy.txt is Added.
12241        let over = diff_name_status_trees_with_rename_options(
12242            &db,
12243            ObjectFormat::Sha1,
12244            &left,
12245            &right,
12246            opts_for(1),
12247        )
12248        .expect("test operation should succeed");
12249        let copy_over = over
12250            .iter()
12251            .find(|e| e.path == b"copy.txt")
12252            .unwrap_or_else(|| panic!("no copy.txt entry: {over:?}"));
12253        assert_eq!(
12254            copy_over.status,
12255            NameStatus::Added,
12256            "over rename_limit, copy must degrade to a plain Add: {over:?}"
12257        );
12258
12259        // Under limit: 2 × 1 = 2 ≤ 4² ⇒ copy still detected (score 80).
12260        let under = diff_name_status_trees_with_rename_options(
12261            &db,
12262            ObjectFormat::Sha1,
12263            &left,
12264            &right,
12265            opts_for(4),
12266        )
12267        .expect("test operation should succeed");
12268        let copy_under = under
12269            .iter()
12270            .find(|e| e.path == b"copy.txt")
12271            .unwrap_or_else(|| panic!("no copy.txt entry: {under:?}"));
12272        assert_eq!(
12273            copy_under.status,
12274            NameStatus::Copied(80),
12275            "below rename_limit, copy detection is unaffected: {under:?}"
12276        );
12277
12278        fs::remove_dir_all(root).expect("test operation should succeed");
12279    }
12280
12281    #[test]
12282    fn inexact_rename_with_small_edit_scores_88() {
12283        // A rename that also appends a single line scores 88% (see
12284        // `similarity_small_append_is_88`).
12285        let root = temp_root();
12286        let layout = RepositoryLayout::init_at(&root, ObjectFormat::Sha1, false)
12287            .expect("test operation should succeed");
12288        let mut db = FileObjectDatabase::from_git_dir(&layout.git_dir, ObjectFormat::Sha1);
12289
12290        let old = write_blob(
12291            &mut db,
12292            b"alpha\nbeta\ngamma\ndelta\nepsilon\nzeta\neta\ntheta\n",
12293        );
12294        let new = write_blob(
12295            &mut db,
12296            b"alpha\nbeta\ngamma\ndelta\nepsilon\nzeta\neta\ntheta\nADDED\n",
12297        );
12298        let left = write_tree(&mut db, &[(b"src.txt", 0o100644, old)]);
12299        let right = write_tree(&mut db, &[(b"dst.txt", 0o100644, new)]);
12300
12301        let opts = RenameDetectionOptions::inexact(DiffNameStatusOptions {
12302            detect_renames: true,
12303            detect_copies: false,
12304            find_copies_harder: false,
12305            rename_empty: true,
12306        });
12307        let entries = diff_name_status_trees_with_rename_options(
12308            &db,
12309            ObjectFormat::Sha1,
12310            &left,
12311            &right,
12312            opts,
12313        )
12314        .expect("test operation should succeed");
12315
12316        assert_eq!(entries.len(), 1, "{entries:?}");
12317        assert_eq!(entries[0].status, NameStatus::Renamed(88));
12318        assert_eq!(
12319            entries[0].old_path.as_ref().map(|p| p.as_bytes()),
12320            Some(b"src.txt".as_slice())
12321        );
12322        assert_eq!(entries[0].path, b"dst.txt");
12323        fs::remove_dir_all(root).expect("test operation should succeed");
12324    }
12325
12326    #[test]
12327    fn inexact_disabled_default_preserves_exact_only_behavior() {
12328        // With RenameDetectionOptions::default() (detect_inexact == false), a
12329        // similar-but-not-identical pair is NOT a rename — identical to the
12330        // legacy exact-only path. Defaults must not silently turn on inexact.
12331        assert!(!RenameDetectionOptions::default().detect_inexact);
12332        assert_eq!(
12333            RenameDetectionOptions::default().rename_threshold,
12334            DEFAULT_RENAME_THRESHOLD
12335        );
12336
12337        let root = temp_root();
12338        let layout = RepositoryLayout::init_at(&root, ObjectFormat::Sha1, false)
12339            .expect("test operation should succeed");
12340        let mut db = FileObjectDatabase::from_git_dir(&layout.git_dir, ObjectFormat::Sha1);
12341
12342        let old = write_blob(&mut db, b"one\ntwo\nthree\nfour\nfive\n");
12343        let new = write_blob(&mut db, b"one\ntwo\nTHREE\nfour\nfive\n");
12344        let left = write_tree(&mut db, &[(b"a.txt", 0o100644, old)]);
12345        let right = write_tree(&mut db, &[(b"b.txt", 0o100644, new)]);
12346
12347        let entries = diff_name_status_trees_with_rename_options(
12348            &db,
12349            ObjectFormat::Sha1,
12350            &left,
12351            &right,
12352            RenameDetectionOptions::default(),
12353        )
12354        .expect("test operation should succeed");
12355        let statuses: Vec<_> = entries.iter().map(|e| e.status).collect();
12356        assert!(statuses.contains(&NameStatus::Added));
12357        assert!(statuses.contains(&NameStatus::Deleted));
12358        assert!(!statuses.iter().any(|s| matches!(s, NameStatus::Renamed(_))));
12359        fs::remove_dir_all(root).expect("test operation should succeed");
12360    }
12361
12362    // ---- patience / histogram diff tests ------------------------------------
12363
12364    /// Apply an edit script to `old` and return the reconstructed `new` bytes.
12365    ///
12366    /// Panics (test-only) if the script ever references a line out of range or
12367    /// claims a line is `Equal` when the corresponding `old`/`new` lines differ
12368    /// — that is exactly the invariant a correct LCS diff must uphold.
12369    fn apply_ops(old: &[DiffLine<'_>], new: &[DiffLine<'_>], ops: &[DiffOp]) -> Vec<u8> {
12370        let mut oi = 0usize;
12371        let mut ni = 0usize;
12372        let mut rebuilt: Vec<u8> = Vec::new();
12373        for op in ops {
12374            match *op {
12375                DiffOp::Equal(n) => {
12376                    for _ in 0..n {
12377                        // Equal must mean genuinely-equal lines (LCS-correct).
12378                        assert_eq!(old[oi], new[ni], "Equal op covered unequal lines");
12379                        rebuilt.extend_from_slice(old[oi].content);
12380                        oi += 1;
12381                        ni += 1;
12382                    }
12383                }
12384                DiffOp::Delete(n) => oi += n,
12385                DiffOp::Insert(n) => {
12386                    for _ in 0..n {
12387                        rebuilt.extend_from_slice(new[ni].content);
12388                        ni += 1;
12389                    }
12390                }
12391            }
12392        }
12393        // The script must consume every line of both sides exactly once.
12394        assert_eq!(oi, old.len(), "script did not consume all of old");
12395        assert_eq!(ni, new.len(), "script did not consume all of new");
12396        rebuilt
12397    }
12398
12399    /// Assert that `ops` is a valid LCS-correct script: it reconstructs `new`
12400    /// from `old`, and consecutive ops are coalesced (no two same-kind in a row).
12401    fn assert_valid_script(old_bytes: &[u8], new_bytes: &[u8], ops: &[DiffOp]) {
12402        let old = split_lines(old_bytes);
12403        let new = split_lines(new_bytes);
12404        let rebuilt = apply_ops(&old, &new, ops);
12405        assert_eq!(rebuilt, new_bytes, "script did not rebuild new");
12406        for pair in ops.windows(2) {
12407            let same_kind = matches!(
12408                (pair[0], pair[1]),
12409                (DiffOp::Equal(_), DiffOp::Equal(_))
12410                    | (DiffOp::Delete(_), DiffOp::Delete(_))
12411                    | (DiffOp::Insert(_), DiffOp::Insert(_))
12412            );
12413            assert!(!same_kind, "ops not coalesced: {:?}", ops);
12414        }
12415    }
12416
12417    /// Run all three real algorithms over a byte pair and assert each produces a
12418    /// valid, coalesced, LCS-correct script.
12419    fn check_all_algorithms(old_bytes: &[u8], new_bytes: &[u8]) {
12420        let old = split_lines(old_bytes);
12421        let new = split_lines(new_bytes);
12422        for algo in [
12423            DiffAlgorithm::Myers,
12424            DiffAlgorithm::Minimal,
12425            DiffAlgorithm::Patience,
12426            DiffAlgorithm::Histogram,
12427        ] {
12428            let ops = diff_lines_with_algorithm(&old, &new, algo);
12429            assert_valid_script(old_bytes, new_bytes, &ops);
12430        }
12431    }
12432
12433    #[test]
12434    fn patience_and_histogram_match_myers_on_simple_cases() {
12435        // For localized single-line edits with no repeated lines, all three
12436        // algorithms agree with the canonical Myers script.
12437        let cases: &[(&[u8], &[u8], Vec<DiffOp>)] = &[
12438            (
12439                b"a\nb\nc\n",
12440                b"a\nx\nc\n",
12441                vec![
12442                    DiffOp::Equal(1),
12443                    DiffOp::Delete(1),
12444                    DiffOp::Insert(1),
12445                    DiffOp::Equal(1),
12446                ],
12447            ),
12448            (b"a\nb\nc\n", b"a\nb\nc\n", vec![DiffOp::Equal(3)]),
12449            (b"", b"a\nb\n", vec![DiffOp::Insert(2)]),
12450            (b"a\nb\n", b"", vec![DiffOp::Delete(2)]),
12451            (
12452                b"a\nb\nc\nd\n",
12453                b"a\nc\nd\n",
12454                vec![DiffOp::Equal(1), DiffOp::Delete(1), DiffOp::Equal(2)],
12455            ),
12456        ];
12457        for (old_bytes, new_bytes, expected) in cases {
12458            let old = split_lines(old_bytes);
12459            let new = split_lines(new_bytes);
12460            assert_eq!(&patience_diff_lines(&old, &new), expected);
12461            assert_eq!(&histogram_diff_lines(&old, &new), expected);
12462            assert_eq!(&myers_diff_lines(&old, &new), expected);
12463        }
12464    }
12465
12466    #[test]
12467    fn patience_handles_both_empty() {
12468        let empty = split_lines(b"");
12469        assert!(patience_diff_lines(&empty, &empty).is_empty());
12470        assert!(histogram_diff_lines(&empty, &empty).is_empty());
12471    }
12472
12473    #[test]
12474    fn patience_aligns_unique_anchors_across_moved_block() {
12475        // Reordering two unique blocks: patience anchors on the unique lines and
12476        // produces a delete-then-insert (or insert-then-delete) that still
12477        // reconstructs `new`. Validity is the contract; exact shape may differ
12478        // from Myers, so we only assert reconstruction here.
12479        check_all_algorithms(
12480            b"alpha\nbeta\ngamma\ndelta\n",
12481            b"gamma\ndelta\nalpha\nbeta\n",
12482        );
12483    }
12484
12485    #[test]
12486    fn histogram_differs_from_myers_keeping_block_contiguous() {
12487        // A case where histogram diverges from Myers. With old = "b a" and a new
12488        // that surrounds an intact "b a" with inserted "b" lines, Myers splits
12489        // the common run into two single-line Equals (matching the leading and
12490        // trailing `b`/`a` separately), while histogram anchors on the rare line
12491        // and keeps the original two lines together as one Equal(2) block.
12492        let old = b"b\na\n";
12493        let new = b"a\nb\nb\na\nb\n";
12494        let old_l = split_lines(old);
12495        let new_l = split_lines(new);
12496
12497        let myers = myers_diff_lines(&old_l, &new_l);
12498        let histogram = histogram_diff_lines(&old_l, &new_l);
12499
12500        // All variants must reconstruct `new`.
12501        assert_valid_script(old, new, &myers);
12502        assert_valid_script(old, new, &histogram);
12503
12504        // Exact, pinned shapes: Myers interleaves single-line equals; histogram
12505        // keeps "b\na\n" contiguous.
12506        assert_eq!(
12507            myers,
12508            vec![
12509                DiffOp::Insert(1),
12510                DiffOp::Equal(1),
12511                DiffOp::Insert(1),
12512                DiffOp::Equal(1),
12513                DiffOp::Insert(1),
12514            ]
12515        );
12516        assert_eq!(
12517            histogram,
12518            vec![DiffOp::Insert(2), DiffOp::Equal(2), DiffOp::Insert(1)]
12519        );
12520        // The contract the task calls out: histogram differs from Myers here.
12521        assert_ne!(myers, histogram);
12522    }
12523
12524    #[test]
12525    fn patience_differs_from_myers_on_repeated_lines() {
12526        // A case where patience diverges from Myers. old = "b a", new = "a a b".
12527        // Myers deletes the leading `b` and appends; patience anchors on the
12528        // single unique-in-both line `a`... but `a` occurs twice in `new`, so it
12529        // is NOT unique there; patience instead falls through to its recursive
12530        // structure and produces the mirror script. Both reconstruct `new`.
12531        let old = b"b\na\n";
12532        let new = b"a\na\nb\n";
12533        let old_l = split_lines(old);
12534        let new_l = split_lines(new);
12535
12536        let myers = myers_diff_lines(&old_l, &new_l);
12537        let patience = patience_diff_lines(&old_l, &new_l);
12538
12539        assert_valid_script(old, new, &myers);
12540        assert_valid_script(old, new, &patience);
12541
12542        assert_eq!(
12543            myers,
12544            vec![DiffOp::Delete(1), DiffOp::Equal(1), DiffOp::Insert(2)]
12545        );
12546        assert_eq!(
12547            patience,
12548            vec![DiffOp::Insert(2), DiffOp::Equal(1), DiffOp::Delete(1)]
12549        );
12550        assert_ne!(myers, patience);
12551    }
12552
12553    #[test]
12554    fn realistic_function_insertion_all_valid() {
12555        // A more lifelike example: a new function is inserted ahead of an
12556        // existing one that shares structural lines ("}", blank line). We don't
12557        // pin exact shapes (they depend on trim interactions) but every
12558        // algorithm must produce a valid LCS-correct script.
12559        let old = b"int f() {\n    return 1;\n}\n";
12560        let new = b"int g() {\n    return 2;\n}\n\nint f() {\n    return 1;\n}\n";
12561        check_all_algorithms(old, new);
12562    }
12563
12564    #[test]
12565    fn histogram_anchors_on_rare_line_when_no_unique_line_exists() {
12566        // No line is globally unique on both sides (every distinct line repeats
12567        // on at least one side), so plain patience would fall straight to Myers.
12568        // Histogram still anchors on the least-frequent shared line. We assert
12569        // both produce valid, reconstructing scripts.
12570        check_all_algorithms(b"x\nx\nmid\nx\nx\n", b"x\nmid\nx\nx\nx\n");
12571        check_all_algorithms(
12572            b"dup\ndup\nrare\ndup\ndup\n",
12573            b"dup\nrare\ndup\ndup\ndup\ndup\n",
12574        );
12575    }
12576
12577    #[test]
12578    fn all_algorithms_treat_missing_final_newline_as_change() {
12579        // "b" (no newline) vs "b\n" is a real change for every algorithm.
12580        let old = split_lines(b"a\nb");
12581        let new = split_lines(b"a\nb\n");
12582        for algo in [
12583            DiffAlgorithm::Myers,
12584            DiffAlgorithm::Minimal,
12585            DiffAlgorithm::Patience,
12586            DiffAlgorithm::Histogram,
12587        ] {
12588            let ops = diff_lines_with_algorithm(&old, &new, algo);
12589            assert_eq!(
12590                ops,
12591                vec![DiffOp::Equal(1), DiffOp::Delete(1), DiffOp::Insert(1)],
12592                "algorithm {:?} mishandled missing final newline",
12593                algo
12594            );
12595        }
12596    }
12597
12598    #[test]
12599    fn dispatcher_routes_each_variant() {
12600        let old = split_lines(b"a\nb\nc\n");
12601        let new = split_lines(b"a\nx\nc\n");
12602        assert_eq!(
12603            diff_lines_with_algorithm(&old, &new, DiffAlgorithm::Myers),
12604            myers_diff_lines(&old, &new)
12605        );
12606        // Minimal aliases Myers (the Myers search is already a minimal SES).
12607        assert_eq!(
12608            diff_lines_with_algorithm(&old, &new, DiffAlgorithm::Minimal),
12609            myers_diff_lines(&old, &new)
12610        );
12611        assert_eq!(
12612            diff_lines_with_algorithm(&old, &new, DiffAlgorithm::Patience),
12613            patience_diff_lines(&old, &new)
12614        );
12615        assert_eq!(
12616            diff_lines_with_algorithm(&old, &new, DiffAlgorithm::Histogram),
12617            histogram_diff_lines(&old, &new)
12618        );
12619    }
12620
12621    #[test]
12622    fn patience_recurses_into_gaps_between_anchors() {
12623        // Unique anchors `head`/`tail` bracket an inner edit; patience must
12624        // recurse into the middle gap and diff `mid1`->`MID` there.
12625        let old = b"head\nmid1\nmid2\ntail\n";
12626        let new = b"head\nMID\nmid2\ntail\n";
12627        let old_l = split_lines(old);
12628        let new_l = split_lines(new);
12629        let ops = patience_diff_lines(&old_l, &new_l);
12630        assert_eq!(
12631            ops,
12632            vec![
12633                DiffOp::Equal(1),
12634                DiffOp::Delete(1),
12635                DiffOp::Insert(1),
12636                DiffOp::Equal(2),
12637            ]
12638        );
12639        assert_valid_script(old, new, &ops);
12640    }
12641
12642    #[test]
12643    fn patience_falls_back_to_myers_with_no_unique_lines() {
12644        // Every line is duplicated within its own side, so there are no
12645        // unique-in-both anchors; patience must defer to Myers but still return
12646        // a valid script.
12647        let old = b"a\na\nb\nb\n";
12648        let new = b"a\na\na\nb\n";
12649        let old_l = split_lines(old);
12650        let new_l = split_lines(new);
12651        let ops = patience_diff_lines(&old_l, &new_l);
12652        // The contract for the fallback path is validity, not minimality: after
12653        // the greedy prefix/suffix trim (which git's patience does too) the
12654        // leftover block is handed to Myers, and the whole script must still
12655        // reconstruct `new`.
12656        assert_valid_script(old, new, &ops);
12657    }
12658
12659    #[test]
12660    fn algorithms_agree_with_myers_when_all_lines_distinct() {
12661        // When every line is globally unique, patience's anchor set is the full
12662        // LCS, so patience and histogram must produce exactly the Myers script.
12663        let cases: &[(&[u8], &[u8])] = &[
12664            (b"a\nb\nc\nd\ne\n", b"a\nc\nd\nf\ne\n"),
12665            (b"1\n2\n3\n4\n5\n6\n", b"1\n3\n2\n4\n6\n5\n"),
12666            (b"q\nw\ne\nr\nt\ny\n", b"q\nw\nx\nr\nt\nz\n"),
12667        ];
12668        for (old_bytes, new_bytes) in cases {
12669            let old = split_lines(old_bytes);
12670            let new = split_lines(new_bytes);
12671            let myers = myers_diff_lines(&old, &new);
12672            assert_eq!(
12673                patience_diff_lines(&old, &new),
12674                myers,
12675                "patience must equal Myers when all lines are distinct: {:?}",
12676                old_bytes
12677            );
12678            assert_eq!(
12679                histogram_diff_lines(&old, &new),
12680                myers,
12681                "histogram must equal Myers when all lines are distinct: {:?}",
12682                old_bytes
12683            );
12684        }
12685    }
12686
12687    #[test]
12688    fn fuzz_all_algorithms_reconstruct_new() {
12689        // A small deterministic LCG drives many random small inputs over a tiny
12690        // alphabet (so lines repeat and exercise the anchor/fallback paths).
12691        // Every algorithm must produce a valid LCS-correct script for each pair.
12692        let mut state: u64 = 0x9E37_79B9_7F4A_7C15;
12693        let mut next = || {
12694            state = state
12695                .wrapping_mul(6364136223846793005)
12696                .wrapping_add(1442695040888963407);
12697            (state >> 33) as u32
12698        };
12699        let alphabet = [b"a\n", b"b\n", b"c\n", b"d\n"];
12700        let build = |rng: &mut dyn FnMut() -> u32| -> Vec<u8> {
12701            let len = (rng() % 9) as usize; // 0..=8 lines
12702            let mut buf = Vec::new();
12703            for _ in 0..len {
12704                let pick = (rng() % alphabet.len() as u32) as usize;
12705                buf.extend_from_slice(alphabet[pick]);
12706            }
12707            // Occasionally drop the trailing newline to exercise that path.
12708            if !buf.is_empty() && rng().is_multiple_of(4) {
12709                buf.pop();
12710            }
12711            buf
12712        };
12713        for _ in 0..400 {
12714            let old_bytes = build(&mut next);
12715            let new_bytes = build(&mut next);
12716            check_all_algorithms(&old_bytes, &new_bytes);
12717        }
12718    }
12719
12720    #[test]
12721    fn exhaustive_small_inputs_all_algorithms_reconstruct() {
12722        // Brute force over a 3-symbol alphabet up to 5 lines per side: every
12723        // algorithm must produce a valid LCS-correct script for *every* pair.
12724        // This is the strongest correctness net for the recursion/fallback
12725        // paths; apply_ops asserts both reconstruction and Equal-correctness.
12726        let syms = [b"a\n".to_vec(), b"b\n".to_vec(), b"c\n".to_vec()];
12727        let make = |n: usize, mut code: usize| -> Vec<u8> {
12728            let mut v = Vec::new();
12729            for _ in 0..n {
12730                v.extend_from_slice(&syms[code % 3]);
12731                code /= 3;
12732            }
12733            v
12734        };
12735        for la in 0..=5usize {
12736            for lb in 0..=5usize {
12737                for ca in 0..3usize.pow(la as u32) {
12738                    for cb in 0..3usize.pow(lb as u32) {
12739                        let ob = make(la, ca);
12740                        let nb = make(lb, cb);
12741                        let ol = split_lines(&ob);
12742                        let nl = split_lines(&nb);
12743                        assert_eq!(apply_ops(&ol, &nl, &myers_diff_lines(&ol, &nl)), nb);
12744                        assert_eq!(apply_ops(&ol, &nl, &patience_diff_lines(&ol, &nl)), nb);
12745                        assert_eq!(apply_ops(&ol, &nl, &histogram_diff_lines(&ol, &nl)), nb);
12746                    }
12747                }
12748            }
12749        }
12750    }
12751
12752    #[test]
12753    fn fuzz_distinct_lines_patience_histogram_equal_myers() {
12754        // When inputs are permutations/subsequences of globally-unique lines,
12755        // patience and histogram must match Myers exactly. We generate sequences
12756        // of distinct tokens to guarantee global uniqueness on both sides.
12757        let mut state: u64 = 0x1234_5678_9ABC_DEF0;
12758        let mut next = || {
12759            state = state
12760                .wrapping_mul(6364136223846793005)
12761                .wrapping_add(1442695040888963407);
12762            (state >> 33) as u32
12763        };
12764        for _ in 0..200 {
12765            // Random subset+order of tokens "0\n".."9\n" for each side; tokens
12766            // are globally unique, so any common line is unique in both.
12767            let pick_subseq = |rng: &mut dyn FnMut() -> u32| -> Vec<u8> {
12768                let mut buf = Vec::new();
12769                for t in 0..10u32 {
12770                    if rng().is_multiple_of(2) {
12771                        buf.extend_from_slice(format!("{t}\n").as_bytes());
12772                    }
12773                }
12774                buf
12775            };
12776            let old_bytes = pick_subseq(&mut next);
12777            let new_bytes = pick_subseq(&mut next);
12778            let old = split_lines(&old_bytes);
12779            let new = split_lines(&new_bytes);
12780            let myers = myers_diff_lines(&old, &new);
12781            assert_eq!(patience_diff_lines(&old, &new), myers);
12782            assert_eq!(histogram_diff_lines(&old, &new), myers);
12783        }
12784    }
12785
12786    // ===================================================================
12787    // Subtree-skip-by-OID tree-diff optimization: the pruned simultaneous
12788    // walk (`changed_tree_entries`) must produce byte-identical name-status
12789    // output to the legacy "flatten both sides fully" walk
12790    // (`collect_full_tree_pair`) on every representative diff shape.
12791    // ===================================================================
12792
12793    /// Format a name-status result into stable, comparable lines.
12794    fn status_lines(entries: &[NameStatusEntry]) -> Vec<String> {
12795        entries.iter().map(|entry| entry.line()).collect()
12796    }
12797
12798    /// Assert the pruned walk and the full flatten agree, both as raw map diffs
12799    /// and through the public tree-diff entry points, for the given options.
12800    fn assert_tree_diff_matches_full(
12801        db: &FileObjectDatabase,
12802        left: &ObjectId,
12803        right: &ObjectId,
12804        options: DiffNameStatusOptions,
12805    ) {
12806        // Reference ("old") behaviour: fully flatten both trees, then diff.
12807        let (full_left, full_right) = collect_full_tree_pair(db, ObjectFormat::Sha1, left, right)
12808            .expect("test operation should succeed");
12809        let reference = diff_name_status_maps(
12810            &full_left,
12811            &full_right,
12812            full_left.keys().chain(full_right.keys()),
12813            options,
12814        )
12815        .expect("test operation should succeed");
12816
12817        // Optimized ("new") behaviour: prune identical subtrees, then diff.
12818        let (pruned_left, pruned_right) = changed_tree_entries(db, ObjectFormat::Sha1, left, right)
12819            .expect("test operation should succeed");
12820        let pruned = diff_name_status_maps(
12821            &pruned_left,
12822            &pruned_right,
12823            pruned_left.keys().chain(pruned_right.keys()),
12824            options,
12825        )
12826        .expect("test operation should succeed");
12827
12828        assert_eq!(
12829            status_lines(&reference),
12830            status_lines(&pruned),
12831            "pruned map diff diverged from full map diff for {options:?}"
12832        );
12833
12834        // And the public entry point (which itself selects pruned vs full) must
12835        // match the reference too.
12836        let public =
12837            diff_name_status_trees_with_options(db, ObjectFormat::Sha1, left, right, options)
12838                .expect("test operation should succeed");
12839        assert_eq!(
12840            status_lines(&reference),
12841            status_lines(&public),
12842            "public tree diff diverged from full map diff for {options:?}"
12843        );
12844
12845        // The pruned maps must be a subset of the full maps and must contain
12846        // exactly the paths that actually changed (no identical entries leak in,
12847        // no changed entries get dropped).
12848        for (path, tracked) in &pruned_left {
12849            assert_eq!(
12850                full_left.get(path),
12851                Some(tracked),
12852                "pruned left entry not present (or differs) in full left map: {:?}",
12853                String::from_utf8_lossy(path)
12854            );
12855        }
12856        for (path, tracked) in &pruned_right {
12857            assert_eq!(
12858                full_right.get(path),
12859                Some(tracked),
12860                "pruned right entry not present (or differs) in full right map: {:?}",
12861                String::from_utf8_lossy(path)
12862            );
12863        }
12864        // Every path the full diff reports as changed must survive pruning on
12865        // whichever side(s) it lives.
12866        for entry in &reference {
12867            let path = entry.path.as_bytes();
12868            match entry.status {
12869                NameStatus::Added => assert!(
12870                    pruned_right.contains_key(path),
12871                    "added path dropped by pruning: {:?}",
12872                    String::from_utf8_lossy(path)
12873                ),
12874                NameStatus::Deleted => assert!(
12875                    pruned_left.contains_key(path),
12876                    "deleted path dropped by pruning: {:?}",
12877                    String::from_utf8_lossy(path)
12878                ),
12879                NameStatus::Modified => {
12880                    assert!(
12881                        pruned_left.contains_key(path) && pruned_right.contains_key(path),
12882                        "modified path dropped by pruning: {:?}",
12883                        String::from_utf8_lossy(path)
12884                    );
12885                }
12886                _ => {}
12887            }
12888        }
12889    }
12890
12891    /// Run the equivalence assertion across the option matrix that the pruned
12892    /// path serves (everything except `--find-copies-harder`, which uses the
12893    /// full maps and is checked separately).
12894    fn assert_tree_diff_matches_full_all_modes(
12895        db: &FileObjectDatabase,
12896        left: &ObjectId,
12897        right: &ObjectId,
12898    ) {
12899        for detect_renames in [false, true] {
12900            for detect_copies in [false, true] {
12901                let options = DiffNameStatusOptions {
12902                    detect_renames,
12903                    detect_copies,
12904                    find_copies_harder: false,
12905                    rename_empty: true,
12906                };
12907                assert_tree_diff_matches_full(db, left, right, options);
12908            }
12909        }
12910    }
12911
12912    /// Build a DB pre-seeded with a fixed bank of blobs for the structural tests.
12913    fn structural_db() -> (PathBuf, FileObjectDatabase) {
12914        let root = temp_root();
12915        let layout = RepositoryLayout::init_at(&root, ObjectFormat::Sha1, false)
12916            .expect("test operation should succeed");
12917        let db = FileObjectDatabase::from_git_dir(&layout.git_dir, ObjectFormat::Sha1);
12918        (root, db)
12919    }
12920
12921    #[test]
12922    fn pruned_walk_skips_identical_subtree_and_matches_full() {
12923        // A large shared subtree (`shared/`) is byte-identical on both sides; the
12924        // only change lives in `app/`. The pruned walk must skip `shared/`
12925        // entirely yet still produce the exact same diff as flattening it.
12926        let (root, mut db) = structural_db();
12927
12928        // shared/ — identical on both sides, several nested files.
12929        let s1 = write_blob(&mut db, b"shared one\n");
12930        let s2 = write_blob(&mut db, b"shared two\n");
12931        let s3 = write_blob(&mut db, b"deep nested\n");
12932        let shared_inner = write_tree(&mut db, &[(b"c.txt", 0o100644, s3.clone())]);
12933        let shared = write_tree(
12934            &mut db,
12935            &[
12936                (b"a.txt", 0o100644, s1.clone()),
12937                (b"b.txt", 0o100644, s2.clone()),
12938                (b"inner", 0o040000, shared_inner.clone()),
12939            ],
12940        );
12941
12942        // app/ — one file modified between sides.
12943        let app_old = write_blob(&mut db, b"version 1\n");
12944        let app_new = write_blob(&mut db, b"version 2\n");
12945        let app_left = write_tree(&mut db, &[(b"main.rs", 0o100644, app_old)]);
12946        let app_right = write_tree(&mut db, &[(b"main.rs", 0o100644, app_new)]);
12947
12948        let left = write_tree(
12949            &mut db,
12950            &[
12951                (b"app", 0o040000, app_left),
12952                (b"shared", 0o040000, shared.clone()),
12953            ],
12954        );
12955        let right = write_tree(
12956            &mut db,
12957            &[(b"app", 0o040000, app_right), (b"shared", 0o040000, shared)],
12958        );
12959
12960        // Sanity: the only change is the nested app/main.rs modification.
12961        let (pruned_left, pruned_right) =
12962            changed_tree_entries(&db, ObjectFormat::Sha1, &left, &right)
12963                .expect("test operation should succeed");
12964        assert_eq!(
12965            pruned_left.keys().collect::<Vec<_>>(),
12966            vec![&b"app/main.rs".to_vec()],
12967            "pruning should leave only the changed path on the left"
12968        );
12969        assert_eq!(
12970            pruned_right.keys().collect::<Vec<_>>(),
12971            vec![&b"app/main.rs".to_vec()],
12972            "pruning should leave only the changed path on the right"
12973        );
12974        assert!(
12975            !pruned_left.contains_key(b"shared/a.txt".as_slice()),
12976            "identical shared subtree must not appear in pruned maps"
12977        );
12978
12979        assert_tree_diff_matches_full_all_modes(&db, &left, &right);
12980        fs::remove_dir_all(root).expect("test operation should succeed");
12981    }
12982
12983    #[test]
12984    fn pruned_walk_matches_full_for_add_delete_modify_nested() {
12985        // Mixed shape: a top-level add, a top-level delete, a nested modify, and
12986        // an untouched nested subtree that must be skipped.
12987        let (root, mut db) = structural_db();
12988
12989        let keep = write_blob(&mut db, b"unchanged\n");
12990        let untouched_dir = write_tree(&mut db, &[(b"keep.txt", 0o100644, keep.clone())]);
12991
12992        let nested_old = write_blob(&mut db, b"nested old\n");
12993        let nested_new = write_blob(&mut db, b"nested new\n");
12994        let dir_left = write_tree(
12995            &mut db,
12996            &[
12997                (b"changed.txt", 0o100644, nested_old),
12998                (b"stable.txt", 0o100644, keep.clone()),
12999            ],
13000        );
13001        let dir_right = write_tree(
13002            &mut db,
13003            &[
13004                (b"changed.txt", 0o100644, nested_new),
13005                (b"stable.txt", 0o100644, keep.clone()),
13006            ],
13007        );
13008
13009        let only_left = write_blob(&mut db, b"will be deleted\n");
13010        let only_right = write_blob(&mut db, b"freshly added\n");
13011
13012        let left = write_tree(
13013            &mut db,
13014            &[
13015                (b"dir", 0o040000, dir_left),
13016                (b"gone.txt", 0o100644, only_left),
13017                (b"untouched", 0o040000, untouched_dir.clone()),
13018            ],
13019        );
13020        let right = write_tree(
13021            &mut db,
13022            &[
13023                (b"dir", 0o040000, dir_right),
13024                (b"new.txt", 0o100644, only_right),
13025                (b"untouched", 0o040000, untouched_dir),
13026            ],
13027        );
13028
13029        let entries = diff_name_status_trees_with_options(
13030            &db,
13031            ObjectFormat::Sha1,
13032            &left,
13033            &right,
13034            DiffNameStatusOptions {
13035                detect_renames: false,
13036                detect_copies: false,
13037                find_copies_harder: false,
13038                rename_empty: true,
13039            },
13040        )
13041        .expect("test operation should succeed");
13042        assert_eq!(
13043            status_lines(&entries),
13044            vec![
13045                "M\tdir/changed.txt".to_string(),
13046                "D\tgone.txt".to_string(),
13047                "A\tnew.txt".to_string(),
13048            ],
13049            "unexpected raw status for mixed nested diff"
13050        );
13051
13052        assert_tree_diff_matches_full_all_modes(&db, &left, &right);
13053        fs::remove_dir_all(root).expect("test operation should succeed");
13054    }
13055
13056    #[test]
13057    fn pruned_walk_matches_full_for_rename_across_dirs() {
13058        // An exact rename (same blob oid) moving between directories. Rename
13059        // detection runs on the pruned add/delete set and must match the full
13060        // walk's result exactly.
13061        let (root, mut db) = structural_db();
13062
13063        let moved = write_blob(&mut db, b"i get moved across directories\n");
13064        let companion = write_blob(&mut db, b"i stay put\n");
13065        let stable_dir = write_tree(&mut db, &[(b"keep.txt", 0o100644, companion.clone())]);
13066
13067        let src_dir = write_tree(&mut db, &[(b"file.txt", 0o100644, moved.clone())]);
13068        let dst_dir = write_tree(&mut db, &[(b"renamed.txt", 0o100644, moved.clone())]);
13069
13070        let left = write_tree(
13071            &mut db,
13072            &[
13073                (b"src", 0o040000, src_dir),
13074                (b"stable", 0o040000, stable_dir.clone()),
13075            ],
13076        );
13077        let right = write_tree(
13078            &mut db,
13079            &[
13080                (b"dst", 0o040000, dst_dir),
13081                (b"stable", 0o040000, stable_dir),
13082            ],
13083        );
13084
13085        let entries = diff_name_status_trees_with_options(
13086            &db,
13087            ObjectFormat::Sha1,
13088            &left,
13089            &right,
13090            DiffNameStatusOptions {
13091                detect_renames: true,
13092                detect_copies: false,
13093                find_copies_harder: false,
13094                rename_empty: true,
13095            },
13096        )
13097        .expect("test operation should succeed");
13098        assert_eq!(
13099            status_lines(&entries),
13100            vec!["R100\tsrc/file.txt\tdst/renamed.txt".to_string()],
13101            "rename across dirs should be detected on pruned set"
13102        );
13103
13104        assert_tree_diff_matches_full_all_modes(&db, &left, &right);
13105        fs::remove_dir_all(root).expect("test operation should succeed");
13106    }
13107
13108    #[test]
13109    fn pruned_walk_matches_full_for_binary_and_mode_change() {
13110        // Binary blob modification plus an executable-bit (mode) change on an
13111        // otherwise-identical blob. Mode-only changes must still register as a
13112        // Modify (the pruned walk compares mode + oid, like the full map).
13113        let (root, mut db) = structural_db();
13114
13115        let bin_old = write_blob(&mut db, &[0u8, 159, 146, 150, 0, 255, 1, 2, 3]);
13116        let bin_new = write_blob(&mut db, &[0u8, 159, 146, 150, 0, 254, 9, 8, 7]);
13117        let script = write_blob(&mut db, b"#!/bin/sh\necho hi\n");
13118
13119        let left = write_tree(
13120            &mut db,
13121            &[
13122                (b"image.bin", 0o100644, bin_old),
13123                (b"run.sh", 0o100644, script.clone()),
13124            ],
13125        );
13126        let right = write_tree(
13127            &mut db,
13128            &[
13129                (b"image.bin", 0o100644, bin_new),
13130                // same blob oid, executable bit flipped on
13131                (b"run.sh", 0o100755, script),
13132            ],
13133        );
13134
13135        let entries = diff_name_status_trees_with_options(
13136            &db,
13137            ObjectFormat::Sha1,
13138            &left,
13139            &right,
13140            DiffNameStatusOptions {
13141                detect_renames: false,
13142                detect_copies: false,
13143                find_copies_harder: false,
13144                rename_empty: true,
13145            },
13146        )
13147        .expect("test operation should succeed");
13148        assert_eq!(
13149            status_lines(&entries),
13150            vec!["M\timage.bin".to_string(), "M\trun.sh".to_string()],
13151            "binary edit and mode-only change should both be Modify"
13152        );
13153
13154        assert_tree_diff_matches_full_all_modes(&db, &left, &right);
13155        fs::remove_dir_all(root).expect("test operation should succeed");
13156    }
13157
13158    #[test]
13159    fn pruned_walk_matches_full_for_dir_replaced_by_file() {
13160        // A name that is a directory on the left and a regular file on the right
13161        // (and vice versa). The flattened paths differ (`thing/...` vs `thing`),
13162        // so the pruned walk must treat them as unrelated add/delete pairs,
13163        // exactly as the full flatten does.
13164        let (root, mut db) = structural_db();
13165
13166        let inner_a = write_blob(&mut db, b"inner a\n");
13167        let inner_b = write_blob(&mut db, b"inner b\n");
13168        let thing_dir = write_tree(
13169            &mut db,
13170            &[(b"a.txt", 0o100644, inner_a), (b"b.txt", 0o100644, inner_b)],
13171        );
13172        let thing_file = write_blob(&mut db, b"now i am a file\n");
13173
13174        // other/ is a file on the left, a directory on the right.
13175        let other_file = write_blob(&mut db, b"i was a file\n");
13176        let other_inner = write_blob(&mut db, b"now nested\n");
13177        let other_dir = write_tree(&mut db, &[(b"x.txt", 0o100644, other_inner)]);
13178
13179        let left = write_tree(
13180            &mut db,
13181            &[
13182                (b"other", 0o100644, other_file),
13183                (b"thing", 0o040000, thing_dir),
13184            ],
13185        );
13186        let right = write_tree(
13187            &mut db,
13188            &[
13189                (b"other", 0o040000, other_dir),
13190                (b"thing", 0o100644, thing_file),
13191            ],
13192        );
13193
13194        let entries = diff_name_status_trees_with_options(
13195            &db,
13196            ObjectFormat::Sha1,
13197            &left,
13198            &right,
13199            DiffNameStatusOptions {
13200                detect_renames: false,
13201                detect_copies: false,
13202                find_copies_harder: false,
13203                rename_empty: true,
13204            },
13205        )
13206        .expect("test operation should succeed");
13207        assert_eq!(
13208            status_lines(&entries),
13209            vec![
13210                "D\tother".to_string(),
13211                "A\tother/x.txt".to_string(),
13212                "A\tthing".to_string(),
13213                "D\tthing/a.txt".to_string(),
13214                "D\tthing/b.txt".to_string(),
13215            ],
13216            "dir<->file swap should flatten to independent adds/deletes"
13217        );
13218
13219        assert_tree_diff_matches_full_all_modes(&db, &left, &right);
13220        fs::remove_dir_all(root).expect("test operation should succeed");
13221    }
13222
13223    #[test]
13224    fn pruned_walk_matches_full_for_identical_trees() {
13225        // Two identical root trees: zero changes, and the root must be skipped
13226        // without reading anything below it.
13227        let (root, mut db) = structural_db();
13228
13229        let blob = write_blob(&mut db, b"same\n");
13230        let sub = write_tree(&mut db, &[(b"f.txt", 0o100644, blob.clone())]);
13231        let tree = write_tree(
13232            &mut db,
13233            &[(b"sub", 0o040000, sub), (b"top.txt", 0o100644, blob)],
13234        );
13235
13236        let (pruned_left, pruned_right) =
13237            changed_tree_entries(&db, ObjectFormat::Sha1, &tree, &tree)
13238                .expect("test operation should succeed");
13239        assert!(
13240            pruned_left.is_empty() && pruned_right.is_empty(),
13241            "identical trees must produce no changed entries"
13242        );
13243
13244        let entries = diff_name_status_trees_with_options(
13245            &db,
13246            ObjectFormat::Sha1,
13247            &tree,
13248            &tree,
13249            DiffNameStatusOptions::default(),
13250        )
13251        .expect("test operation should succeed");
13252        assert!(entries.is_empty(), "identical trees must produce no diff");
13253
13254        assert_tree_diff_matches_full_all_modes(&db, &tree, &tree);
13255        fs::remove_dir_all(root).expect("test operation should succeed");
13256    }
13257
13258    #[test]
13259    fn find_copies_harder_uses_full_left_map_and_finds_unchanged_source() {
13260        // `--find-copies-harder` must still see an *unchanged* file as a copy
13261        // source. This is the case where the public entry point deliberately
13262        // falls back to the full flatten; verify the full-map fallback both
13263        // behaves correctly and matches a direct full-map computation.
13264        let (root, mut db) = structural_db();
13265
13266        // `template.txt` is unchanged between sides (lives in an untouched
13267        // subtree), and `copy.txt` is added on the right with the same content.
13268        let template = write_blob(&mut db, b"reusable boilerplate content\n");
13269        let lib_dir = write_tree(&mut db, &[(b"template.txt", 0o100644, template.clone())]);
13270
13271        let trigger_old = write_blob(&mut db, b"trigger old\n");
13272        let trigger_new = write_blob(&mut db, b"trigger new\n");
13273
13274        let left = write_tree(
13275            &mut db,
13276            &[
13277                (b"lib", 0o040000, lib_dir.clone()),
13278                (b"trigger.txt", 0o100644, trigger_old),
13279            ],
13280        );
13281        let right = write_tree(
13282            &mut db,
13283            &[
13284                (b"copy.txt", 0o100644, template.clone()),
13285                (b"lib", 0o040000, lib_dir),
13286                (b"trigger.txt", 0o100644, trigger_new),
13287            ],
13288        );
13289
13290        let options = DiffNameStatusOptions {
13291            detect_renames: true,
13292            detect_copies: true,
13293            find_copies_harder: true,
13294            rename_empty: true,
13295        };
13296
13297        // Reference via the full flatten (the old algorithm).
13298        let (full_left, full_right) =
13299            collect_full_tree_pair(&db, ObjectFormat::Sha1, &left, &right)
13300                .expect("test operation should succeed");
13301        let reference = diff_name_status_maps(
13302            &full_left,
13303            &full_right,
13304            full_left.keys().chain(full_right.keys()),
13305            options,
13306        )
13307        .expect("test operation should succeed");
13308
13309        let public =
13310            diff_name_status_trees_with_options(&db, ObjectFormat::Sha1, &left, &right, options)
13311                .expect("test operation should succeed");
13312        assert_eq!(
13313            status_lines(&reference),
13314            status_lines(&public),
13315            "find-copies-harder public diff must match full-map reference"
13316        );
13317        // The copy must be detected from the unchanged template source.
13318        assert!(
13319            public
13320                .iter()
13321                .any(|entry| matches!(entry.status, NameStatus::Copied(_))
13322                    && entry.old_path.as_ref().map(|p| p.as_bytes())
13323                        == Some(b"lib/template.txt".as_slice())
13324                    && entry.path == b"copy.txt"),
13325            "copy from unchanged source must be found with find_copies_harder: {public:?}"
13326        );
13327        fs::remove_dir_all(root).expect("test operation should succeed");
13328    }
13329
13330    #[test]
13331    fn pruned_walk_matches_full_with_inexact_rename_options() {
13332        // Exercise the rename-options entry point (which also selects pruned vs
13333        // full) with inexact detection enabled, across an untouched subtree.
13334        let (root, mut db) = structural_db();
13335
13336        let untouched = write_blob(&mut db, b"untouched file\n");
13337        let untouched_dir = write_tree(&mut db, &[(b"u.txt", 0o100644, untouched.clone())]);
13338
13339        // a.txt -> b.txt with one changed line (a 75% inexact rename).
13340        let old = write_blob(&mut db, b"one\ntwo\nthree\nfour\nfive\n");
13341        let new = write_blob(&mut db, b"one\ntwo\nTHREE\nfour\nfive\n");
13342
13343        let left = write_tree(
13344            &mut db,
13345            &[
13346                (b"a.txt", 0o100644, old),
13347                (b"keep", 0o040000, untouched_dir.clone()),
13348            ],
13349        );
13350        let right = write_tree(
13351            &mut db,
13352            &[
13353                (b"b.txt", 0o100644, new),
13354                (b"keep", 0o040000, untouched_dir),
13355            ],
13356        );
13357
13358        let options = RenameDetectionOptions {
13359            base: DiffNameStatusOptions {
13360                detect_renames: true,
13361                detect_copies: false,
13362                find_copies_harder: false,
13363                rename_empty: true,
13364            },
13365            detect_inexact: true,
13366            rename_threshold: DEFAULT_RENAME_THRESHOLD,
13367            copy_threshold: DEFAULT_RENAME_THRESHOLD,
13368            rename_limit: 0,
13369        };
13370
13371        // Reference: full flatten + same detection.
13372        let (full_left, full_right) =
13373            collect_full_tree_pair(&db, ObjectFormat::Sha1, &left, &right)
13374                .expect("test operation should succeed");
13375        let reference = diff_name_status_maps_with_renames(
13376            &full_left,
13377            &full_right,
13378            full_left.keys().chain(full_right.keys()),
13379            options,
13380            |oid| read_blob_bytes(&db, oid),
13381        )
13382        .expect("test operation should succeed");
13383
13384        let public = diff_name_status_trees_with_rename_options(
13385            &db,
13386            ObjectFormat::Sha1,
13387            &left,
13388            &right,
13389            options,
13390        )
13391        .expect("test operation should succeed");
13392
13393        assert_eq!(
13394            status_lines(&reference),
13395            status_lines(&public),
13396            "inexact rename via pruned walk must match full-map reference"
13397        );
13398        assert_eq!(
13399            status_lines(&public),
13400            vec!["R075\ta.txt\tb.txt".to_string()],
13401            "expected a 75% inexact rename"
13402        );
13403        fs::remove_dir_all(root).expect("test operation should succeed");
13404    }
13405}