Skip to main content

mkit_core/ops/
diff.rs

1//! Tree-level structural diff.
2//!
3//! Compares two trees identified by their object hash and returns the
4//! minimal list of leaf-level changes (`added` / `removed` / `modified`
5//! / `mode_changed`). The walk is lockstep over the two sorted entry
6//! arrays, recurses into matching subtrees only when their hashes
7//! differ, and treats added/removed subtrees as bulk operations on every
8//! contained leaf.
9//!
10//! Also contains `status_diff` — the working-tree vs HEAD diff that
11//! powers `mkit status`.
12
13use std::borrow::Cow;
14use std::path::Path;
15
16use crate::hash::Hash;
17use crate::index::{Index, IndexError};
18use crate::object::{EntryMode, Object, TreeEntry};
19use crate::store::{MAX_TREE_DEPTH, ObjectSource, ObjectStore, StoreError};
20use crate::worktree::{self, WorktreeError};
21
22/// What kind of change a [`DiffEntry`] represents.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub enum DiffKind {
25    /// Path was not present in the old tree, present in the new.
26    Added,
27    /// Path was present in the old tree, absent in the new.
28    Removed,
29    /// Same path, different content hash (and possibly different mode).
30    Modified,
31    /// Same path, same content hash, different [`EntryMode`].
32    ModeChanged,
33    /// Content moved from [`DiffEntry::old_path`] to [`DiffEntry::path`].
34    /// Produced by [`detect_content_renames`] when removed and added content
35    /// is byte-identical, including different valid chunk layouts.
36    Renamed,
37}
38
39/// One leaf-level change. `path` is `/`-joined relative to the root
40/// of the compared trees; subtree directories are NOT emitted as their
41/// own entries — only the leaves they contain.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct DiffEntry {
44    pub path: String,
45    pub kind: DiffKind,
46    pub old_hash: Option<Hash>,
47    pub new_hash: Option<Hash>,
48    /// File mode on each side (`None` when the side is absent), used to
49    /// render git-shaped `new file mode`/`index` header lines.
50    pub old_mode: Option<EntryMode>,
51    pub new_mode: Option<EntryMode>,
52    /// Source path for a [`DiffKind::Renamed`] entry; `None` for every
53    /// other kind. `path` always holds the destination (new) path.
54    pub old_path: Option<String>,
55}
56
57/// Sorted (by path) sequence of [`DiffEntry`].
58#[derive(Debug, Clone, Default, PartialEq, Eq)]
59pub struct DiffResult {
60    pub entries: Vec<DiffEntry>,
61}
62
63impl DiffResult {
64    #[must_use]
65    pub fn is_empty(&self) -> bool {
66        self.entries.is_empty()
67    }
68
69    #[must_use]
70    pub fn len(&self) -> usize {
71        self.entries.len()
72    }
73}
74
75/// Detect exact renames by verified file content, including different chunk
76/// layouts. Fingerprints are memoized per object for this diff only. Duplicate
77/// content pairs in sorted-path order; unpaired paths stay as adds/removes.
78/// Callers scope detection to one diff or staging leg.
79///
80/// # Errors
81/// A missing or invalid content object aborts detection without changing entries.
82pub fn detect_content_renames<S: ObjectSource + ?Sized>(
83    store: &S,
84    entries: &mut Vec<DiffEntry>,
85) -> Result<(), StoreError> {
86    let mut memo = std::collections::HashMap::new();
87    for e in entries.iter() {
88        let hash = match e.kind {
89            DiffKind::Removed => e.old_hash,
90            DiffKind::Added => e.new_hash,
91            _ => None,
92        };
93        if let Some(h) = hash
94            && let std::collections::hash_map::Entry::Vacant(slot) = memo.entry(h)
95        {
96            slot.insert(worktree::content_fingerprint(store, &h)?);
97        }
98    }
99    let mut candidate = entries.clone();
100    pair_renames(&mut candidate, |h| memo[&h]);
101    for entry in &candidate {
102        if entry.kind == DiffKind::Renamed
103            && let (Some(a), Some(b)) = (entry.old_hash, entry.new_hash)
104            && !worktree::content_eq(store, &a, &b)?
105        {
106            return Err(StoreError::Io(std::io::Error::other(
107                "content fingerprint collision",
108            )));
109        }
110    }
111    *entries = candidate;
112    Ok(())
113}
114
115fn pair_renames<K: Eq + std::hash::Hash>(entries: &mut Vec<DiffEntry>, key: impl Fn(Hash) -> K) {
116    use std::collections::HashMap;
117
118    let mut removed: HashMap<K, Vec<usize>> = HashMap::new();
119    let mut added: HashMap<K, Vec<usize>> = HashMap::new();
120    for (i, e) in entries.iter().enumerate() {
121        match e.kind {
122            DiffKind::Removed => {
123                if let Some(h) = e.old_hash {
124                    removed.entry(key(h)).or_default().push(i);
125                }
126            }
127            DiffKind::Added => {
128                if let Some(h) = e.new_hash {
129                    added.entry(key(h)).or_default().push(i);
130                }
131            }
132            _ => {}
133        }
134    }
135
136    let mut consumed = vec![false; entries.len()];
137    let mut renames: Vec<DiffEntry> = Vec::new();
138    for (h, rem_idx) in &removed {
139        let Some(add_idx) = added.get(h) else {
140            continue;
141        };
142        let mut r = rem_idx.clone();
143        let mut a = add_idx.clone();
144        r.sort_by(|&x, &y| entries[x].path.cmp(&entries[y].path));
145        a.sort_by(|&x, &y| entries[x].path.cmp(&entries[y].path));
146        for (&ri, &ai) in r.iter().zip(a.iter()) {
147            renames.push(DiffEntry {
148                path: entries[ai].path.clone(),
149                kind: DiffKind::Renamed,
150                old_hash: entries[ri].old_hash,
151                new_hash: entries[ai].new_hash,
152                old_mode: entries[ri].old_mode,
153                new_mode: entries[ai].new_mode,
154                old_path: Some(entries[ri].path.clone()),
155            });
156            consumed[ri] = true;
157            consumed[ai] = true;
158        }
159    }
160
161    if renames.is_empty() {
162        return;
163    }
164    let mut out: Vec<DiffEntry> = Vec::with_capacity(entries.len());
165    for (i, e) in entries.drain(..).enumerate() {
166        if !consumed[i] {
167            out.push(e);
168        }
169    }
170    out.extend(renames);
171    out.sort_by(|a, b| a.path.cmp(&b.path));
172    *entries = out;
173}
174
175/// Compare two trees and return their [`DiffResult`]. `None` for either
176/// hash represents the empty tree (use cases: comparing against the
177/// initial commit, against a rolled-back state).
178///
179/// # Errors
180///
181/// Propagates [`StoreError`] when an expected tree object is missing or
182/// fails its read-time hash check.
183pub fn diff_trees<S: ObjectSource + ?Sized>(
184    store: &S,
185    old_hash: Option<Hash>,
186    new_hash: Option<Hash>,
187) -> Result<DiffResult, StoreError> {
188    diff_trees_inner(store, old_hash, new_hash, false)
189}
190
191fn diff_trees_inner<S: ObjectSource + ?Sized>(
192    store: &S,
193    old_hash: Option<Hash>,
194    new_hash: Option<Hash>,
195    ignore_regular_executable_mode: bool,
196) -> Result<DiffResult, StoreError> {
197    // Trivial cases: both empty, or identical hashes -> empty diff.
198    match (old_hash, new_hash) {
199        (None, None) => return Ok(DiffResult::default()),
200        (Some(a), Some(b)) if a == b => return Ok(DiffResult::default()),
201        _ => {}
202    }
203
204    let old_entries = load_entries(store, old_hash)?;
205    let new_entries = load_entries(store, new_hash)?;
206
207    let mut out: Vec<DiffEntry> = Vec::new();
208    diff_entries_recursive(
209        store,
210        &old_entries,
211        &new_entries,
212        "",
213        &mut out,
214        ignore_regular_executable_mode,
215        0,
216    )?;
217    // git orders diff entries by pathname. The name-sorted walk is already
218    // sorted for the common cases; sort to also cover a dir/file replacement
219    // (`d` sorts before `d/x.txt`), where a single tree position emits both a
220    // shallower and a deeper path.
221    out.sort_by(|a, b| a.path.cmp(&b.path));
222    Ok(DiffResult { entries: out })
223}
224
225/// Lockstep walk of two name-sorted entry arrays.
226fn diff_entries_recursive<S: ObjectSource + ?Sized>(
227    store: &S,
228    old_entries: &[TreeEntry],
229    new_entries: &[TreeEntry],
230    prefix: &str,
231    out: &mut Vec<DiffEntry>,
232    ignore_regular_executable_mode: bool,
233    depth: usize,
234) -> Result<(), StoreError> {
235    if depth > MAX_TREE_DEPTH {
236        return Err(StoreError::TreeTooDeep);
237    }
238    let mut i = 0usize;
239    let mut j = 0usize;
240
241    while i < old_entries.len() && j < new_entries.len() {
242        let o = &old_entries[i];
243        let n = &new_entries[j];
244        match o.name.as_slice().cmp(n.name.as_slice()) {
245            std::cmp::Ordering::Less => {
246                add_removed_entries(store, o, prefix, out, depth)?;
247                i += 1;
248            }
249            std::cmp::Ordering::Greater => {
250                add_added_entries(store, n, prefix, out, depth)?;
251                j += 1;
252            }
253            std::cmp::Ordering::Equal => {
254                if o.mode == EntryMode::Tree && n.mode == EntryMode::Tree {
255                    if o.object_hash != n.object_hash {
256                        let sub_prefix = join_path(prefix, &o.name);
257                        let old_sub = load_tree(store, o.object_hash)?;
258                        let new_sub = load_tree(store, n.object_hash)?;
259                        diff_entries_recursive(
260                            store,
261                            &old_sub,
262                            &new_sub,
263                            &sub_prefix,
264                            out,
265                            ignore_regular_executable_mode,
266                            depth + 1,
267                        )?;
268                    }
269                    // identical subtree hashes -> nothing changed below
270                } else if o.mode == EntryMode::Tree || n.mode == EntryMode::Tree {
271                    // A directory replaced by a file (or vice versa). git
272                    // models this as the old subtree's leaves all deleted plus
273                    // the new entry added — never a single Modified blob whose
274                    // tree hash would be misread as a blob by the patch
275                    // renderer.
276                    add_removed_entries(store, o, prefix, out, depth)?;
277                    add_added_entries(store, n, prefix, out, depth)?;
278                } else if worktree::content_eq(store, &o.object_hash, &n.object_hash)? {
279                    if o.mode == n.mode {
280                        i += 1;
281                        j += 1;
282                        continue;
283                    }
284                    if !ignore_regular_executable_mode || !regular_executable_pair(o.mode, n.mode) {
285                        out.push(DiffEntry {
286                            path: join_path(prefix, &o.name),
287                            kind: DiffKind::ModeChanged,
288                            old_hash: Some(o.object_hash),
289                            new_hash: Some(n.object_hash),
290                            old_mode: Some(o.mode),
291                            new_mode: Some(n.mode),
292                            old_path: None,
293                        });
294                    }
295                } else if o.object_hash != n.object_hash || o.mode != n.mode {
296                    out.push(DiffEntry {
297                        path: join_path(prefix, &o.name),
298                        kind: DiffKind::Modified,
299                        old_hash: Some(o.object_hash),
300                        new_hash: Some(n.object_hash),
301                        old_mode: Some(o.mode),
302                        new_mode: Some(n.mode),
303                        old_path: None,
304                    });
305                }
306                i += 1;
307                j += 1;
308            }
309        }
310    }
311
312    while i < old_entries.len() {
313        add_removed_entries(store, &old_entries[i], prefix, out, depth)?;
314        i += 1;
315    }
316    while j < new_entries.len() {
317        add_added_entries(store, &new_entries[j], prefix, out, depth)?;
318        j += 1;
319    }
320    Ok(())
321}
322
323fn regular_executable_pair(a: EntryMode, b: EntryMode) -> bool {
324    matches!(
325        (a, b),
326        (EntryMode::Blob, EntryMode::Executable) | (EntryMode::Executable, EntryMode::Blob)
327    )
328}
329
330fn add_removed_entries<S: ObjectSource + ?Sized>(
331    store: &S,
332    entry: &TreeEntry,
333    prefix: &str,
334    out: &mut Vec<DiffEntry>,
335    depth: usize,
336) -> Result<(), StoreError> {
337    if depth > MAX_TREE_DEPTH {
338        return Err(StoreError::TreeTooDeep);
339    }
340    if entry.mode == EntryMode::Tree {
341        let sub_prefix = join_path(prefix, &entry.name);
342        let sub = load_tree(store, entry.object_hash)?;
343        for sub_entry in &sub {
344            add_removed_entries(store, sub_entry, &sub_prefix, out, depth + 1)?;
345        }
346    } else {
347        out.push(DiffEntry {
348            path: join_path(prefix, &entry.name),
349            kind: DiffKind::Removed,
350            old_hash: Some(entry.object_hash),
351            new_hash: None,
352            old_mode: Some(entry.mode),
353            new_mode: None,
354            old_path: None,
355        });
356    }
357    Ok(())
358}
359
360fn add_added_entries<S: ObjectSource + ?Sized>(
361    store: &S,
362    entry: &TreeEntry,
363    prefix: &str,
364    out: &mut Vec<DiffEntry>,
365    depth: usize,
366) -> Result<(), StoreError> {
367    if depth > MAX_TREE_DEPTH {
368        return Err(StoreError::TreeTooDeep);
369    }
370    if entry.mode == EntryMode::Tree {
371        let sub_prefix = join_path(prefix, &entry.name);
372        let sub = load_tree(store, entry.object_hash)?;
373        for sub_entry in &sub {
374            add_added_entries(store, sub_entry, &sub_prefix, out, depth + 1)?;
375        }
376    } else {
377        out.push(DiffEntry {
378            path: join_path(prefix, &entry.name),
379            kind: DiffKind::Added,
380            old_hash: None,
381            new_hash: Some(entry.object_hash),
382            old_mode: None,
383            new_mode: Some(entry.mode),
384            old_path: None,
385        });
386    }
387    Ok(())
388}
389
390fn load_entries<S: ObjectSource + ?Sized>(
391    store: &S,
392    hash: Option<Hash>,
393) -> Result<Vec<TreeEntry>, StoreError> {
394    match hash {
395        Some(h) => load_tree(store, h),
396        None => Ok(Vec::new()),
397    }
398}
399
400fn load_tree<S: ObjectSource + ?Sized>(store: &S, h: Hash) -> Result<Vec<TreeEntry>, StoreError> {
401    match store.read_object(&h)? {
402        Object::Tree(t) => Ok(t.entries),
403        other => Err(StoreError::Decode(
404            crate::object::MkitError::InvalidObjectType(other.object_type() as u8),
405        )),
406    }
407}
408
409/// Join a path prefix and an entry name with `/`. Lossy on non-UTF-8
410/// names: we use `String` rather than `Path` to avoid platform-specific
411/// separator handling. Tree names are constrained at the object layer
412/// to forbid `/` and `\\`, so the only lossy case is non-UTF-8 byte
413/// sequences in legacy data — the caller's `path` field will then be
414/// `String::from_utf8_lossy`'s replacement, which is acceptable for a
415/// diagnostic.
416fn join_path(prefix: &str, name: &[u8]) -> String {
417    let name_str = String::from_utf8_lossy(name);
418    if prefix.is_empty() {
419        name_str.into_owned()
420    } else {
421        let mut s = String::with_capacity(prefix.len() + 1 + name_str.len());
422        s.push_str(prefix);
423        s.push('/');
424        s.push_str(&name_str);
425        s
426    }
427}
428
429// =====================================================================
430// text_patch — line-based unified diff (for `mkit diff` hunks)
431// =====================================================================
432
433/// Number of unchanged context lines emitted on each side of a hunk.
434const PATCH_CONTEXT: usize = 3;
435
436/// Default number of unchanged context lines around each hunk — git's own
437/// `-U3` default. Exposed so a caller threading an explicit `-U<n>` (e.g.
438/// the CLI's `diff -U<n>`) can fall back to the same default mkit already
439/// uses when the flag is omitted.
440pub const DEFAULT_CONTEXT_LINES: usize = PATCH_CONTEXT;
441
442/// Whitespace-comparison mode for hunk generation — the primitive behind
443/// git's `diff -w`/`--ignore-all-space` and `-b`/`--ignore-space-change`.
444/// Only line **comparison** for the edit script changes; a rendered hunk
445/// always shows each line's real, unmodified bytes. Mirrors
446/// `ops::blame`'s `-w` (ignore-all-space) semantics — see that module's
447/// `strip_ws` — plus the `-b` (ignore-space-change) mode blame does not
448/// need.
449#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
450pub enum WhitespaceMode {
451    /// Exact byte comparison (the default).
452    #[default]
453    Exact,
454    /// `-b`/`--ignore-space-change`: runs of whitespace compare equal
455    /// regardless of length, and trailing whitespace is ignored, but a
456    /// line with whitespace where the other side has none still differs.
457    IgnoreSpaceChange,
458    /// `-w`/`--ignore-all-space`: every whitespace byte is ignored, so
459    /// `foo(a, b)` and `foo(a,b)` compare equal.
460    IgnoreAllSpace,
461}
462
463/// Whitespace-normalized comparison key for a line under `mode`. `Exact`
464/// borrows the line unchanged (no allocation).
465fn ws_key(text: &[u8], mode: WhitespaceMode) -> Cow<'_, [u8]> {
466    match mode {
467        WhitespaceMode::Exact => Cow::Borrowed(text),
468        WhitespaceMode::IgnoreAllSpace => Cow::Owned(strip_all_ws(text)),
469        WhitespaceMode::IgnoreSpaceChange => Cow::Owned(collapse_ws(text)),
470    }
471}
472
473/// git's `isspace()` byte class: ASCII whitespace plus vertical tab
474/// (`\x0B`), which Rust's `is_ascii_whitespace` does not include.
475fn is_ws_byte(b: u8) -> bool {
476    b.is_ascii_whitespace() || b == 0x0B
477}
478
479/// `-w`/`--ignore-all-space`: drop every whitespace byte.
480fn strip_all_ws(line: &[u8]) -> Vec<u8> {
481    line.iter().copied().filter(|&b| !is_ws_byte(b)).collect()
482}
483
484/// `-b`/`--ignore-space-change`: collapse each run of whitespace to a
485/// single space and drop trailing whitespace, so differing *amounts* of
486/// whitespace compare equal but a line with whitespace where the other
487/// has none still differs (unlike `-w`).
488fn collapse_ws(line: &[u8]) -> Vec<u8> {
489    let mut end = line.len();
490    while end > 0 && is_ws_byte(line[end - 1]) {
491        end -= 1;
492    }
493    let line = &line[..end];
494    let mut out = Vec::with_capacity(line.len());
495    let mut i = 0;
496    while i < line.len() {
497        if is_ws_byte(line[i]) {
498            out.push(b' ');
499            while i < line.len() && is_ws_byte(line[i]) {
500                i += 1;
501            }
502        } else {
503            out.push(line[i]);
504            i += 1;
505        }
506    }
507    out
508}
509
510/// Render a unified-diff patch between two byte blobs.
511///
512/// `old_path` / `new_path` are the `a/…` and `b/…` labels for the
513/// `---`/`+++` headers (callers conventionally pass the same repo path
514/// for both). The output is a Git-compatible unified diff:
515///
516/// ```text
517/// --- a/<old_path>
518/// +++ b/<new_path>
519/// @@ -<l>,<n> +<l>,<n> @@
520///  context
521/// -removed
522/// +added
523/// ```
524///
525/// Either side that is **binary** by Git's heuristic — a NUL byte in the
526/// first 8000 bytes — yields a single `Binary files a/<old> and b/<new>
527/// differ` line instead of hunks, matching Git.
528///
529/// The algorithm is the greedy Myers diff with Git-style hunk compaction
530/// (see [`unified_hunks`]); the hunks byte-match `git diff`. Trailing-newline
531/// handling follows Git's `\ No newline at end of file` convention.
532///
533/// Note this returns a `String` via lossy UTF-8 conversion, so a non-UTF-8
534/// (but non-binary) blob's raw bytes are not preserved here — use
535/// [`unified_hunks`] for byte-exact output.
536#[must_use]
537pub fn text_patch(old_bytes: &[u8], new_bytes: &[u8], old_path: &str, new_path: &str) -> String {
538    match unified_hunks(old_bytes, new_bytes) {
539        None => format!("Binary files a/{old_path} and b/{new_path} differ\n"),
540        Some(hunks) if hunks.is_empty() => String::new(),
541        Some(hunks) => {
542            format!(
543                "--- a/{old_path}\n+++ b/{new_path}\n{}",
544                String::from_utf8_lossy(&hunks)
545            )
546        }
547    }
548}
549
550/// The hunk body of a unified diff between two blobs: the `@@ … @@` headers
551/// and their `+`/`-`/context lines, with no `---`/`+++` file headers, as raw
552/// bytes (git diffs are byte-oriented and do not require UTF-8). Returns
553/// `None` when either side is **binary** by git's heuristic — a NUL byte in
554/// the first 8000 bytes — so a caller emits a `Binary files …` line instead.
555/// An empty result means the blobs are textually identical.
556///
557/// Splitting the hunk body out lets callers wrap it in a git-shaped
558/// `diff --git` header (with `/dev/null` for adds/deletes) while keeping the
559/// algorithm — Myers diff with git-style hunk compaction — in one place.
560#[must_use]
561pub fn unified_hunks(old_bytes: &[u8], new_bytes: &[u8]) -> Option<Vec<u8>> {
562    unified_hunks_opts(old_bytes, new_bytes, PATCH_CONTEXT, WhitespaceMode::Exact)
563}
564
565/// Like [`unified_hunks`] but with an explicit context-line count and
566/// whitespace-comparison mode — the primitives behind git's `diff -U<n>`
567/// and `-w`/`-b`. `context` controls how many unchanged lines surround
568/// each hunk ([`DEFAULT_CONTEXT_LINES`] is git's own default of 3); `mode`
569/// controls which lines the edit script treats as equal (a rendered hunk
570/// always shows each line's real, unmodified bytes — only comparison
571/// changes).
572#[must_use]
573pub fn unified_hunks_opts(
574    old_bytes: &[u8],
575    new_bytes: &[u8],
576    context: usize,
577    mode: WhitespaceMode,
578) -> Option<Vec<u8>> {
579    if is_binary(old_bytes) || is_binary(new_bytes) {
580        return None;
581    }
582    let old_lines = split_lines(old_bytes);
583    let new_lines = split_lines(new_bytes);
584    let ops = edit_script(&old_lines, &new_lines, mode);
585    let hunks = group_hunks(&ops, context);
586    let mut out = Vec::new();
587    for hunk in &hunks {
588        render_hunk(&mut out, hunk, &old_lines, &new_lines);
589    }
590    Some(out)
591}
592
593/// Origin of a line within a [`PatchHunk`] — context (unchanged), added on
594/// the new side, or removed from the old side.
595#[derive(Debug, Clone, Copy, PartialEq, Eq)]
596pub enum HunkLineKind {
597    /// Unchanged line present on both sides.
598    Context,
599    /// Line added on the new side (`+`).
600    Added,
601    /// Line removed from the old side (`-`).
602    Removed,
603}
604
605/// One line of a hunk: its origin plus the raw bytes (no `+`/`-`/space
606/// prefix) and whether the source line had a trailing newline.
607#[derive(Debug, Clone, PartialEq, Eq)]
608pub struct HunkLine {
609    /// Whether the line is context, added, or removed.
610    pub kind: HunkLineKind,
611    /// Raw line bytes, without the unified-diff prefix or trailing `\n`.
612    pub text: Vec<u8>,
613    /// `true` when the source had a `\n` after this line.
614    pub has_newline: bool,
615}
616
617/// A single hunk for interactive staging (`add -p`): the 1-based old/new
618/// line ranges (as in the `@@` header) plus the ordered context/added/
619/// removed lines. Use [`apply_hunks_subset`] to materialize a chosen subset.
620#[derive(Debug, Clone, PartialEq, Eq)]
621pub struct PatchHunk {
622    /// 1-based first old-side line (0 when the old side is empty).
623    pub old_start: usize,
624    /// Number of old-side lines covered (context + removed).
625    pub old_len: usize,
626    /// 1-based first new-side line (0 when the new side is empty).
627    pub new_start: usize,
628    /// Number of new-side lines covered (context + added).
629    pub new_len: usize,
630    /// Ordered lines making up the hunk body.
631    pub lines: Vec<HunkLine>,
632}
633
634/// Enumerate the hunks between two blobs as structured [`PatchHunk`]s, using
635/// the same Myers diff + git-style compaction as [`unified_hunks`]. Returns
636/// `None` when either side is **binary** (git's NUL heuristic); an empty
637/// vector means the blobs are textually identical. Unlike `unified_hunks`,
638/// which renders bytes, this exposes each hunk's lines so a caller can let
639/// the user pick a subset to stage and rebuild the partial blob with
640/// [`apply_hunks_subset`].
641#[must_use]
642pub fn enumerate_hunks(old_bytes: &[u8], new_bytes: &[u8]) -> Option<Vec<PatchHunk>> {
643    if is_binary(old_bytes) || is_binary(new_bytes) {
644        return None;
645    }
646    let old_lines = split_lines(old_bytes);
647    let new_lines = split_lines(new_bytes);
648    let ops = edit_script(&old_lines, &new_lines, WhitespaceMode::Exact);
649    let hunks = group_hunks(&ops, PATCH_CONTEXT);
650    Some(
651        hunks
652            .iter()
653            .map(|h| to_patch_hunk(h, &old_lines, &new_lines))
654            .collect(),
655    )
656}
657
658fn to_patch_hunk(hunk: &Hunk, old: &[DiffLine<'_>], new: &[DiffLine<'_>]) -> PatchHunk {
659    let lines = hunk
660        .ops
661        .iter()
662        .map(|op| {
663            let (kind, dl) = match *op {
664                DiffOp::Equal(oi, _) => (HunkLineKind::Context, &old[oi]),
665                DiffOp::Delete(oi) => (HunkLineKind::Removed, &old[oi]),
666                DiffOp::Insert(ni) => (HunkLineKind::Added, &new[ni]),
667            };
668            HunkLine {
669                kind,
670                text: dl.text.to_vec(),
671                has_newline: dl.has_newline,
672            }
673        })
674        .collect();
675    PatchHunk {
676        old_start: hunk.old_start,
677        old_len: hunk.old_len,
678        new_start: hunk.new_start,
679        new_len: hunk.new_len,
680        lines,
681    }
682}
683
684/// Rebuild a blob from `base_bytes` applying only the hunks whose index (into
685/// the slice returned by [`enumerate_hunks`]) is in `selected`. Selected
686/// hunks have their additions applied and removals dropped; unselected hunks
687/// keep the base content unchanged. `selected` order does not matter — hunks
688/// are always applied in file order — and out-of-range indices are ignored.
689///
690/// This is the staging primitive for `add -p`: pass the index/HEAD blob as
691/// the base and the accepted hunk indices to produce the partially-staged
692/// blob. Applying *all* hunks reproduces the new blob exactly; applying
693/// *none* reproduces the base.
694#[must_use]
695pub fn apply_hunks_subset(base_bytes: &[u8], hunks: &[PatchHunk], selected: &[usize]) -> Vec<u8> {
696    let old_lines = split_lines(base_bytes);
697    let sel: std::collections::HashSet<usize> = selected.iter().copied().collect();
698    let mut out: Vec<u8> = Vec::with_capacity(base_bytes.len());
699    let mut cursor = 0usize; // 0-based index into old_lines
700    for (i, h) in hunks.iter().enumerate() {
701        // 0-based start of this hunk's old-side region. A pure insertion into
702        // an empty base has old_len == 0 and old_start == 0; otherwise the
703        // hunk always carries context, so old_start is 1-based.
704        let region_start = if h.old_len == 0 {
705            h.old_start
706        } else {
707            h.old_start - 1
708        }
709        .min(old_lines.len());
710        let region_end = (region_start + h.old_len).min(old_lines.len());
711        // Untouched base lines before this hunk.
712        for dl in &old_lines[cursor..region_start] {
713            emit_raw_line(&mut out, dl.text, dl.has_newline);
714        }
715        if sel.contains(&i) {
716            // Apply: emit the new side (context + added).
717            for l in &h.lines {
718                if l.kind != HunkLineKind::Removed {
719                    emit_raw_line(&mut out, &l.text, l.has_newline);
720                }
721            }
722        } else {
723            // Keep the base region verbatim (identical to context + removed).
724            for dl in &old_lines[region_start..region_end] {
725                emit_raw_line(&mut out, dl.text, dl.has_newline);
726            }
727        }
728        cursor = region_end;
729    }
730    for dl in &old_lines[cursor..] {
731        emit_raw_line(&mut out, dl.text, dl.has_newline);
732    }
733    out
734}
735
736fn emit_raw_line(out: &mut Vec<u8>, text: &[u8], has_newline: bool) {
737    out.extend_from_slice(text);
738    if has_newline {
739        out.push(b'\n');
740    }
741}
742
743/// A contiguous run of base lines that one side changed, anchored on base
744/// line coordinates: `base_len` base lines (0 for a pure insertion) are
745/// replaced by `new`. Used by the 3-way merge to detect overlap and splice.
746struct MergeRegion {
747    base_start: usize,
748    base_len: usize,
749    new: Vec<(Vec<u8>, bool)>,
750}
751
752/// Line-level 3-way merge of three blobs (diff3-style, conservative).
753///
754/// Returns `Some(merged)` when `ours` and `theirs` changed **disjoint**
755/// regions of `base` (so the merge is unambiguous), and `None` when their
756/// changes overlap or any input is binary — the caller then records a
757/// conflict. Identical `ours`/`theirs` merge trivially. Unlike a full
758/// diff3 this does not auto-resolve *identical overlapping* edits (those
759/// stay a conflict): it errs toward conflict, never toward a wrong merge.
760/// (#298)
761#[must_use]
762pub fn merge_blob_3way(base: &[u8], ours: &[u8], theirs: &[u8]) -> Option<Vec<u8>> {
763    if ours == theirs {
764        return Some(ours.to_vec());
765    }
766    if is_binary(base) || is_binary(ours) || is_binary(theirs) {
767        return None;
768    }
769    let base_lines = split_lines(base);
770    let ours_regions = changed_regions(&base_lines, &split_lines(ours));
771    let theirs_regions = changed_regions(&base_lines, &split_lines(theirs));
772    if regions_overlap(&ours_regions, &theirs_regions) {
773        return None;
774    }
775    Some(apply_merge_regions(
776        &base_lines,
777        &ours_regions,
778        &theirs_regions,
779    ))
780}
781
782/// Group the edit script `base → side` into base-anchored changed regions
783/// (no context — adjacent equal lines bound each region).
784fn changed_regions(base: &[DiffLine<'_>], side: &[DiffLine<'_>]) -> Vec<MergeRegion> {
785    let mut regions: Vec<MergeRegion> = Vec::new();
786    let mut cur: Option<MergeRegion> = None;
787    let mut base_idx = 0usize; // next unconsumed base line
788    for op in edit_script(base, side, WhitespaceMode::Exact) {
789        match op {
790            DiffOp::Equal(bi, _) => {
791                if let Some(r) = cur.take() {
792                    regions.push(r);
793                }
794                base_idx = bi + 1;
795            }
796            DiffOp::Delete(bi) => {
797                cur.get_or_insert(MergeRegion {
798                    base_start: base_idx,
799                    base_len: 0,
800                    new: Vec::new(),
801                })
802                .base_len += 1;
803                base_idx = bi + 1;
804            }
805            DiffOp::Insert(si) => {
806                let line = &side[si];
807                cur.get_or_insert(MergeRegion {
808                    base_start: base_idx,
809                    base_len: 0,
810                    new: Vec::new(),
811                })
812                .new
813                .push((line.text.to_vec(), line.has_newline));
814            }
815        }
816    }
817    if let Some(r) = cur.take() {
818        regions.push(r);
819    }
820    regions
821}
822
823/// `true` if any ours-region overlaps any theirs-region on base lines.
824/// Modify spans use half-open `[start, start+len)` intersection; a pure
825/// insertion conflicts only when it lands strictly inside a modify span,
826/// or coincides with another insertion (ambiguous order) — insertions at a
827/// modify boundary are adjacent and merge cleanly.
828fn regions_overlap(ours: &[MergeRegion], theirs: &[MergeRegion]) -> bool {
829    ours.iter().any(|a| {
830        theirs.iter().any(|b| {
831            let (a_s, a_e) = (a.base_start, a.base_start + a.base_len);
832            let (b_s, b_e) = (b.base_start, b.base_start + b.base_len);
833            match (a.base_len == 0, b.base_len == 0) {
834                (true, true) => a_s == b_s,
835                (true, false) => b_s < a_s && a_s < b_e,
836                (false, true) => a_s < b_s && b_s < a_e,
837                (false, false) => a_s < b_e && b_s < a_e,
838            }
839        })
840    })
841}
842
843/// Splice non-overlapping ours/theirs regions back onto `base`. Regions are
844/// applied in base order; at a shared start a pure insertion (len 0) sorts
845/// before a modify so inserted lines precede the replaced ones.
846fn apply_merge_regions(
847    base: &[DiffLine<'_>],
848    ours: &[MergeRegion],
849    theirs: &[MergeRegion],
850) -> Vec<u8> {
851    let mut all: Vec<&MergeRegion> = ours.iter().chain(theirs.iter()).collect();
852    all.sort_by_key(|r| (r.base_start, r.base_len));
853    let mut out: Vec<u8> = Vec::new();
854    let mut cursor = 0usize;
855    for r in all {
856        for line in &base[cursor..r.base_start] {
857            emit_raw_line(&mut out, line.text, line.has_newline);
858        }
859        for (text, nl) in &r.new {
860            emit_raw_line(&mut out, text, *nl);
861        }
862        cursor = r.base_start + r.base_len;
863    }
864    for line in &base[cursor..] {
865        emit_raw_line(&mut out, line.text, line.has_newline);
866    }
867    out
868}
869
870/// Added / deleted line counts between two blobs, from the same Myers edit
871/// script the unified patch uses. `None` when either side is **binary** —
872/// matching Git's heuristic of a NUL byte within the first 8000 bytes
873/// (independent of UTF-8 validity), so `diff --stat` renders `Bin …` for
874/// exactly the blobs Git would.
875///
876/// Used by `diff --stat`; kept here so the stat counts always agree with
877/// the `+`/`-` lines `text_patch` would emit for the same blobs. Lines are
878/// split on `\n` bytes, so the counts hold for any non-binary blob, including
879/// non-UTF-8 text.
880#[must_use]
881pub fn diff_line_counts(old_bytes: &[u8], new_bytes: &[u8]) -> Option<(usize, usize)> {
882    if is_binary(old_bytes) || is_binary(new_bytes) {
883        return None;
884    }
885    let old_lines = split_lines(old_bytes);
886    let new_lines = split_lines(new_bytes);
887    let mut added = 0;
888    let mut deleted = 0;
889    for op in edit_script(&old_lines, &new_lines, WhitespaceMode::Exact) {
890        match op {
891            DiffOp::Insert(_) => added += 1,
892            DiffOp::Delete(_) => deleted += 1,
893            DiffOp::Equal(_, _) => {}
894        }
895    }
896    Some((added, deleted))
897}
898
899/// Sniff window for git's binary heuristic: content is classified from at
900/// most this many leading bytes. Exposed so a caller that only needs the
901/// classification — not a diff — can read just this much of a blob (e.g.
902/// [`crate::worktree::LoadedBlob::prefix`]) instead of paying for full
903/// content, which matters for a chunked blob (mkit#606).
904pub const BINARY_SNIFF_LEN: usize = 8000;
905
906/// Git's binary heuristic (`buffer_is_binary`): a NUL byte within the
907/// first [`BINARY_SNIFF_LEN`] bytes marks the blob binary, regardless of
908/// UTF-8 validity. `bytes` may be a full blob or just its leading
909/// [`BINARY_SNIFF_LEN`]-byte prefix — since this never looks past that
910/// many bytes, the result is identical either way.
911#[must_use]
912pub fn is_binary(bytes: &[u8]) -> bool {
913    bytes.iter().take(BINARY_SNIFF_LEN).any(|&b| b == 0)
914}
915
916/// A single line (raw bytes) plus whether the source had a trailing newline
917/// after it.
918struct DiffLine<'a> {
919    text: &'a [u8],
920    /// `true` when this line was terminated by `\n` in the source.
921    has_newline: bool,
922}
923
924/// Split bytes into lines on `\n`, preserving whether the final line had a
925/// trailing newline. An empty input yields no lines.
926fn split_lines(text: &[u8]) -> Vec<DiffLine<'_>> {
927    let mut lines = Vec::new();
928    let mut rest = text;
929    while !rest.is_empty() {
930        if let Some(idx) = rest.iter().position(|&b| b == b'\n') {
931            lines.push(DiffLine {
932                text: &rest[..idx],
933                has_newline: true,
934            });
935            rest = &rest[idx + 1..];
936        } else {
937            lines.push(DiffLine {
938                text: rest,
939                has_newline: false,
940            });
941            rest = b"";
942        }
943    }
944    lines
945}
946
947/// One element of a line-level edit script.
948#[derive(Debug, Clone, Copy, PartialEq, Eq)]
949enum DiffOp {
950    /// Line present in both sides (indices into old, new).
951    Equal(usize, usize),
952    /// Line only in the old side (index into old).
953    Delete(usize),
954    /// Line only in the new side (index into new).
955    Insert(usize),
956}
957
958/// Compute a line-level edit script using the greedy Myers diff, then
959/// canonicalize hunk boundaries the way git's xdiff does (slide each run of
960/// changed lines as far down as identical neighbours allow). The result
961/// matches `git diff`'s hunks for the common cases — same algorithm, same
962/// boundary convention — modulo git's optional indent heuristic.
963fn edit_script(old: &[DiffLine<'_>], new: &[DiffLine<'_>], mode: WhitespaceMode) -> Vec<DiffOp> {
964    let (mut old_changed, mut new_changed) = myers_changed(old, new, mode);
965    compact_changes(old, &mut old_changed, mode);
966    compact_changes(new, &mut new_changed, mode);
967    script_from_flags(old, new, &old_changed, &new_changed)
968}
969
970/// Run the greedy Myers diff and mark which old lines are deletions and which
971/// new lines are insertions. Lines left unmarked are the matched (equal)
972/// lines that pair up in order.
973///
974/// Elides any common **leading** run of identical lines before running the
975/// O(ND) core on the (usually much smaller) remainder — the same "diff only
976/// the changed region" step git's `xdiff` and every other practical diff
977/// engine apply before their own O(ND) search. Real edits (an appended log
978/// entry, one changed line in a large file) leave most of the file as a
979/// shared prefix; eliding it shrinks both `n` and `m` for the quadratic core
980/// below without changing its result — the core's own very first step, at
981/// `d = 0` (the only diagonal `k = 0` there, so no tie-break choice is
982/// involved), greedily extends exactly this same leading run before doing
983/// anything else, so skipping straight to the post-prefix subproblem is
984/// provably identical to letting the core discover it itself.
985///
986/// Deliberately **not** symmetric on the trailing side: an earlier version
987/// of this function also elided a common trailing run (matching from the
988/// end backward), which is unsound in general. Unlike the prefix, the
989/// *trailing* snake the core's backtrack actually lands on depends on
990/// tie-breaks made throughout the whole search (`v[idx(k-1)] < v[idx(k+1)]`
991/// in [`myers_changed_core`]) whenever repeated/colliding lines near the
992/// tail admit more than one minimal alignment — greedily matching from the
993/// end backward can commit to a different (also minimal, but not
994/// byte-identical) alignment than the real backtrack would have chosen.
995/// Caught by review with a concrete counterexample (`old = ["a"]`,
996/// `new = ["b", "a", "a"]`: the core matches `old[0]` to the *first* `new`
997/// "a", but backward-suffix-matching commits to the *second*), confirmed by
998/// exhaustive brute-force diffing of small alphabets. See
999/// `proptest_myers_changed_matches_unelided_core` for the regression test.
1000fn myers_changed(
1001    old: &[DiffLine<'_>],
1002    new: &[DiffLine<'_>],
1003    mode: WhitespaceMode,
1004) -> (Vec<bool>, Vec<bool>) {
1005    let n = old.len();
1006    let m = new.len();
1007
1008    let max_prefix = n.min(m);
1009    let mut prefix = 0;
1010    while prefix < max_prefix && lines_equal(&old[prefix], &new[prefix], mode) {
1011        prefix += 1;
1012    }
1013
1014    let (mid_old_changed, mid_new_changed) =
1015        myers_changed_core(&old[prefix..], &new[prefix..], mode);
1016
1017    // Build the full-length result directly instead of zero-filling `n`/`m`
1018    // elements up front and then overwriting the post-prefix half via
1019    // `copy_from_slice` — the leading `resize` only zero-inits the elided
1020    // run, and reserving `n`/`m` capacity up front means `extend` never
1021    // reallocates.
1022    let mut old_changed = Vec::with_capacity(n);
1023    old_changed.resize(prefix, false);
1024    old_changed.extend(mid_old_changed);
1025    let mut new_changed = Vec::with_capacity(m);
1026    new_changed.resize(prefix, false);
1027    new_changed.extend(mid_new_changed);
1028    (old_changed, new_changed)
1029}
1030
1031// Myers indexes paths by signed diagonal `k = x - y`, so the V array and
1032// backtrack inherently convert between `isize` (diagonals, offsets) and
1033// `usize` (line indices). The values are bounded by `n + m`, well within
1034// range, so the sign/wrap casts are safe; `x`/`y`/`k`/`d`/`v` are the
1035// algorithm's canonical names.
1036#[allow(
1037    clippy::cast_sign_loss,
1038    clippy::cast_possible_wrap,
1039    clippy::many_single_char_names
1040)]
1041fn myers_changed_core(
1042    old: &[DiffLine<'_>],
1043    new: &[DiffLine<'_>],
1044    mode: WhitespaceMode,
1045) -> (Vec<bool>, Vec<bool>) {
1046    let n = old.len();
1047    let m = new.len();
1048    let mut old_changed = vec![false; n];
1049    let mut new_changed = vec![false; m];
1050    if n == 0 {
1051        new_changed.fill(true);
1052        return (old_changed, new_changed);
1053    }
1054    if m == 0 {
1055        old_changed.fill(true);
1056        return (old_changed, new_changed);
1057    }
1058
1059    let max = n + m;
1060    let offset = max as isize; // shift so diagonal k maps to a non-negative index
1061    let mut v = vec![0isize; 2 * max + 1];
1062    let mut trace: Vec<Vec<isize>> = Vec::new();
1063
1064    let idx = |k: isize| (k + offset) as usize;
1065    let mut found = max as isize;
1066    'outer: for d in 0..=max as isize {
1067        trace.push(v.clone());
1068        let mut k = -d;
1069        while k <= d {
1070            // Greedy: extend the furthest-reaching path on diagonal k.
1071            let mut x = if k == -d || (k != d && v[idx(k - 1)] < v[idx(k + 1)]) {
1072                v[idx(k + 1)] // down → an insertion (consume a new line)
1073            } else {
1074                v[idx(k - 1)] + 1 // right → a deletion (consume an old line)
1075            };
1076            let mut y = x - k;
1077            while (x as usize) < n
1078                && (y as usize) < m
1079                && lines_equal(&old[x as usize], &new[y as usize], mode)
1080            {
1081                x += 1;
1082                y += 1;
1083            }
1084            v[idx(k)] = x;
1085            if x as usize >= n && y as usize >= m {
1086                found = d;
1087                break 'outer;
1088            }
1089            k += 2;
1090        }
1091    }
1092
1093    // Backtrack through the saved V snapshots to recover the edits.
1094    let mut x = n as isize;
1095    let mut y = m as isize;
1096    for d in (0..=found).rev() {
1097        let vd = &trace[d as usize];
1098        let k = x - y;
1099        let down = k == -d || (k != d && vd[idx(k - 1)] < vd[idx(k + 1)]);
1100        let prev_k = if down { k + 1 } else { k - 1 };
1101        let prev_x = vd[idx(prev_k)];
1102        let prev_y = prev_x - prev_k;
1103        // Walk back down the snake (matched lines) — no flags set there.
1104        while x > prev_x && y > prev_y {
1105            x -= 1;
1106            y -= 1;
1107        }
1108        if d > 0 {
1109            if down {
1110                new_changed[(y - 1) as usize] = true; // insertion
1111                y -= 1;
1112            } else {
1113                old_changed[(x - 1) as usize] = true; // deletion
1114                x -= 1;
1115            }
1116        }
1117    }
1118    (old_changed, new_changed)
1119}
1120
1121/// Slide each maximal run of changed lines downward while the line leaving the
1122/// top of the run equals the line entering at the bottom — git's
1123/// `xdl_change_compact` canonical placement, so a change among identical
1124/// neighbours lands where git puts it.
1125fn compact_changes(lines: &[DiffLine<'_>], changed: &mut [bool], mode: WhitespaceMode) {
1126    let n = lines.len();
1127    let mut i = 0;
1128    while i < n {
1129        if !changed[i] {
1130            i += 1;
1131            continue;
1132        }
1133        // [start, end) is a run of changed lines.
1134        let start = i;
1135        let mut end = i;
1136        while end < n && changed[end] {
1137            end += 1;
1138        }
1139        // Slide down: the line at `start` leaves the run and the line at
1140        // `end` joins it, valid only when they are identical.
1141        let (mut s, mut e) = (start, end);
1142        while e < n && lines_equal(&lines[s], &lines[e], mode) {
1143            changed[s] = false;
1144            changed[e] = true;
1145            s += 1;
1146            e += 1;
1147        }
1148        i = e;
1149    }
1150}
1151
1152/// Build the `DiffOp` sequence from the per-side changed flags, in git's
1153/// order: within each change region, all deletions precede all insertions;
1154/// matched lines pair up as `Equal`.
1155fn script_from_flags(
1156    old: &[DiffLine<'_>],
1157    new: &[DiffLine<'_>],
1158    old_changed: &[bool],
1159    new_changed: &[bool],
1160) -> Vec<DiffOp> {
1161    let (n, m) = (old.len(), new.len());
1162    let mut ops = Vec::new();
1163    let (mut i, mut j) = (0usize, 0usize);
1164    while i < n || j < m {
1165        if i < n && j < m && !old_changed[i] && !new_changed[j] {
1166            ops.push(DiffOp::Equal(i, j));
1167            i += 1;
1168            j += 1;
1169        } else {
1170            while i < n && old_changed[i] {
1171                ops.push(DiffOp::Delete(i));
1172                i += 1;
1173            }
1174            while j < m && new_changed[j] {
1175                ops.push(DiffOp::Insert(j));
1176                j += 1;
1177            }
1178        }
1179    }
1180    ops
1181}
1182
1183/// Line equality under `mode`: trailing-newline presence always matters
1184/// (whitespace-insensitivity is about line *content*, not the `\ No
1185/// newline at end of file` marker); the text comparison itself is
1186/// normalized per [`ws_key`].
1187fn lines_equal(a: &DiffLine<'_>, b: &DiffLine<'_>, mode: WhitespaceMode) -> bool {
1188    a.has_newline == b.has_newline && ws_key(a.text, mode) == ws_key(b.text, mode)
1189}
1190
1191/// A contiguous group of edits plus surrounding context, with the
1192/// 1-based starting line numbers and lengths for the `@@` header.
1193struct Hunk {
1194    old_start: usize,
1195    old_len: usize,
1196    new_start: usize,
1197    new_len: usize,
1198    ops: Vec<DiffOp>,
1199}
1200
1201/// Group an edit script into hunks, each padded with up to `context`
1202/// unchanged lines and merged when their context windows touch.
1203fn group_hunks(ops: &[DiffOp], context: usize) -> Vec<Hunk> {
1204    // Indices of ops that are actual changes.
1205    let change_positions: Vec<usize> = ops
1206        .iter()
1207        .enumerate()
1208        .filter(|(_, op)| !matches!(op, DiffOp::Equal(_, _)))
1209        .map(|(idx, _)| idx)
1210        .collect();
1211    if change_positions.is_empty() {
1212        return Vec::new();
1213    }
1214
1215    // Build [start,end) op-index ranges around each change, merging
1216    // ranges whose context windows overlap or abut.
1217    let mut ranges: Vec<(usize, usize)> = Vec::new();
1218    for &pos in &change_positions {
1219        let start = pos.saturating_sub(context);
1220        let end = (pos + context + 1).min(ops.len());
1221        match ranges.last_mut() {
1222            Some(last) if start <= last.1 => last.1 = last.1.max(end),
1223            _ => ranges.push((start, end)),
1224        }
1225    }
1226
1227    ranges
1228        .into_iter()
1229        .map(|(start, end)| build_hunk(&ops[start..end]))
1230        .collect()
1231}
1232
1233fn build_hunk(slice: &[DiffOp]) -> Hunk {
1234    let mut old_start = None;
1235    let mut new_start = None;
1236    let mut old_len = 0usize;
1237    let mut new_len = 0usize;
1238    for op in slice {
1239        match *op {
1240            DiffOp::Equal(oi, ni) => {
1241                old_start.get_or_insert(oi);
1242                new_start.get_or_insert(ni);
1243                old_len += 1;
1244                new_len += 1;
1245            }
1246            DiffOp::Delete(oi) => {
1247                old_start.get_or_insert(oi);
1248                old_len += 1;
1249            }
1250            DiffOp::Insert(ni) => {
1251                new_start.get_or_insert(ni);
1252                new_len += 1;
1253            }
1254        }
1255    }
1256    Hunk {
1257        // Convert 0-based to 1-based; empty side starts at 0.
1258        old_start: old_start.map_or(0, |s| s + 1),
1259        old_len,
1260        new_start: new_start.map_or(0, |s| s + 1),
1261        new_len,
1262        ops: slice.to_vec(),
1263    }
1264}
1265
1266/// Format one side of an `@@` range as git does: `start,len`, but the `,len`
1267/// is omitted when `len == 1` (`@@ -1 +1 @@`).
1268fn hunk_range(start: usize, len: usize) -> String {
1269    if len == 1 {
1270        start.to_string()
1271    } else {
1272        format!("{start},{len}")
1273    }
1274}
1275
1276fn render_hunk(out: &mut Vec<u8>, hunk: &Hunk, old: &[DiffLine<'_>], new: &[DiffLine<'_>]) {
1277    let header = format!(
1278        "@@ -{} +{} @@\n",
1279        hunk_range(hunk.old_start, hunk.old_len),
1280        hunk_range(hunk.new_start, hunk.new_len)
1281    );
1282    out.extend_from_slice(header.as_bytes());
1283    for op in &hunk.ops {
1284        match *op {
1285            DiffOp::Equal(oi, _) => emit_line(out, b' ', &old[oi]),
1286            DiffOp::Delete(oi) => emit_line(out, b'-', &old[oi]),
1287            DiffOp::Insert(ni) => emit_line(out, b'+', &new[ni]),
1288        }
1289    }
1290}
1291
1292fn emit_line(out: &mut Vec<u8>, prefix: u8, line: &DiffLine<'_>) {
1293    out.push(prefix);
1294    out.extend_from_slice(line.text);
1295    out.push(b'\n');
1296    if !line.has_newline {
1297        out.extend_from_slice(b"\\ No newline at end of file\n");
1298    }
1299}
1300
1301// =====================================================================
1302// status_diff — working-tree vs HEAD (for `mkit status`)
1303// =====================================================================
1304
1305/// Staging state of a [`StatusEntry`] relative to the index.
1306///
1307/// When no index is passed to [`status_diff`], every entry has
1308/// `StatusStaging::Unstaged`.
1309#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1310pub enum StatusStaging {
1311    /// Change is not staged (worktree differs from HEAD, not in index).
1312    Unstaged,
1313    /// Change is staged (in the index, matching the worktree).
1314    Staged,
1315    /// Change exists in both index and worktree with different content
1316    /// (partially staged scenario).
1317    PartiallyStaged,
1318}
1319
1320/// One entry in the `mkit status` output. Combines a [`DiffEntry`] with
1321/// index-awareness so the caller can render three-way status output.
1322#[derive(Debug, Clone, PartialEq, Eq)]
1323pub struct StatusEntry {
1324    /// Underlying diff entry (path, kind, old/new hashes).
1325    pub diff: DiffEntry,
1326    /// Relationship of this entry to the staging index.
1327    pub staging: StatusStaging,
1328}
1329
1330/// Error type for [`status_diff`].
1331#[derive(Debug, thiserror::Error)]
1332pub enum DiffError {
1333    /// Underlying object-store error.
1334    #[error(transparent)]
1335    Store(#[from] StoreError),
1336    /// Error building the worktree snapshot.
1337    #[error(transparent)]
1338    Worktree(#[from] WorktreeError),
1339    /// Error seeding the tracked set from a tree.
1340    #[error(transparent)]
1341    Index(#[from] IndexError),
1342}
1343
1344/// Compare HEAD ↔ index and index ↔ worktree, returning a list of
1345/// [`StatusEntry`] grouped by staging state.
1346///
1347/// Pre-#102 this function diffed only HEAD↔worktree and annotated
1348/// each entry with index-state. That hid one hazard: a path staged
1349/// to the index whose worktree was later reverted to match HEAD
1350/// would diff to nothing — but `mkit commit` (which signs HEAD↔
1351/// index post-#102) would still commit the staged content. The
1352/// staged change was invisible to `mkit status`.
1353///
1354/// New shape:
1355///
1356/// - `Staged` — path differs between HEAD and the index-built tree
1357///   (the change is what `mkit commit` will sign).
1358/// - `Unstaged` — path differs between the index-built tree and the
1359///   worktree (changes the user has not yet `mkit add`-ed).
1360/// - When the same path appears in both legs (e.g. staged v2, then
1361///   worktree edited to v3), one entry is emitted per leg so callers
1362///   render both sections — matching git's two-section layout. The
1363///   `PartiallyStaged` enum variant is retained for back-compat but
1364///   no longer produced by this function.
1365///
1366/// When `index` is `None`, falls back to the legacy HEAD↔worktree
1367/// diff and labels every entry `Unstaged` — used by callers that
1368/// haven't initialized a staging index yet.
1369///
1370/// # Errors
1371///
1372/// Propagates [`WorktreeError`] (I/O, symlink validation, chunker
1373/// limit) and [`StoreError`] (missing or corrupt objects).
1374#[allow(clippy::too_many_lines)]
1375pub fn status_diff(
1376    store: &ObjectStore,
1377    head_tree: Option<&Hash>,
1378    worktree_root: &Path,
1379    index: Option<&Index>,
1380) -> Result<Vec<StatusEntry>, DiffError> {
1381    status_diff_observed(store, head_tree, worktree_root, index).map(|(entries, _)| entries)
1382}
1383
1384/// [`status_diff`] that additionally returns the worktree walk's
1385/// [`worktree::StatObservation`]s — entries whose cache was absent or
1386/// racy-smudged but whose re-hash matched the staged hash. Callers
1387/// (the `status` CLI) use them to heal the stat cache from hash-time
1388/// stats; pairing a *later* stat with the earlier hash is unsound.
1389///
1390/// # Errors
1391/// See [`status_diff`].
1392#[allow(clippy::type_complexity)]
1393pub fn status_diff_observed(
1394    store: &ObjectStore,
1395    head_tree: Option<&Hash>,
1396    worktree_root: &Path,
1397    index: Option<&Index>,
1398) -> Result<(Vec<StatusEntry>, Vec<worktree::StatObservation>), DiffError> {
1399    // Always snapshot the worktree — the index↔worktree leg uses it. The
1400    // tracked set for ignore exemption is the staging index if present, else
1401    // the HEAD tree's paths (seeded here) — without it a tracked file
1402    // matching an ignore rule would be dropped and misreported as a deletion.
1403    let head_seed;
1404    let tracked = if let Some(i) = index {
1405        Some(i)
1406    } else if let Some(ht) = head_tree {
1407        head_seed = crate::index::from_tree(store, *ht)?;
1408        Some(&head_seed)
1409    } else {
1410        None
1411    };
1412    // Snapshot objects are ephemeral: they live in an in-memory overlay
1413    // (EphemeralSink) and never touch the durable store — status pays
1414    // no durability cost, leaves no garbage in objects/, and can never
1415    // make a non-durable object visible to another writer's dedup.
1416    // Reads fall through to the store for committed objects.
1417    let snapshot = crate::store::EphemeralSink::new(store);
1418    let mut observations = Vec::new();
1419    let work_tree_hash = worktree::build_tree_filtered_observed_with_source(
1420        &snapshot,
1421        &snapshot,
1422        worktree_root,
1423        tracked,
1424        &mut observations,
1425    )?;
1426
1427    let Some(idx) = index else {
1428        // Legacy fallback: HEAD↔worktree, everything labeled Unstaged.
1429        let diff = diff_worktree_trees(&snapshot, head_tree.copied(), Some(work_tree_hash))?;
1430        return Ok((
1431            diff.entries
1432                .into_iter()
1433                .map(|d| StatusEntry {
1434                    diff: d,
1435                    staging: StatusStaging::Unstaged,
1436                })
1437                .collect(),
1438            observations,
1439        ));
1440    };
1441
1442    // Build the index tree exactly the way `mkit commit` builds it.
1443    // This is the authoritative "what would be committed right now."
1444    // Ephemeral diff snapshot (no durable publish) — cheap shape check.
1445    let index_tree = worktree::build_tree_from_index_with(store, &snapshot, idx, false)?;
1446
1447    let staged = diff_trees(&snapshot, head_tree.copied(), Some(index_tree))?;
1448    let unstaged = diff_worktree_trees(&snapshot, Some(index_tree), Some(work_tree_hash))?;
1449
1450    // Emit one entry per (path, leg). A path appearing in both legs
1451    // produces two entries — one `Staged` and one `Unstaged` — so the
1452    // status renderer shows it under BOTH "Changes to be committed"
1453    // and "Changes not staged for commit", matching git's two-section
1454    // layout. The `PartiallyStaged` enum variant is retained for API
1455    // back-compat but no longer produced.
1456    let mut out: Vec<StatusEntry> =
1457        Vec::with_capacity(staged.entries.len() + unstaged.entries.len());
1458    for d in staged.entries {
1459        out.push(StatusEntry {
1460            diff: d,
1461            staging: StatusStaging::Staged,
1462        });
1463    }
1464    for d in unstaged.entries {
1465        out.push(StatusEntry {
1466            diff: d,
1467            staging: StatusStaging::Unstaged,
1468        });
1469    }
1470    out.sort_by(|a, b| {
1471        // Stable rendering order: by path, then staged before unstaged.
1472        a.diff.path.cmp(&b.diff.path).then_with(|| {
1473            #[allow(clippy::match_same_arms)]
1474            match (a.staging, b.staging) {
1475                (StatusStaging::Staged, StatusStaging::Staged) => std::cmp::Ordering::Equal,
1476                (StatusStaging::Staged, _) => std::cmp::Ordering::Less,
1477                (_, StatusStaging::Staged) => std::cmp::Ordering::Greater,
1478                _ => std::cmp::Ordering::Equal,
1479            }
1480        })
1481    });
1482    Ok((out, observations))
1483}
1484
1485#[cfg(unix)]
1486fn diff_worktree_trees<S: ObjectSource + ?Sized>(
1487    store: &S,
1488    old_hash: Option<Hash>,
1489    new_hash: Option<Hash>,
1490) -> Result<DiffResult, StoreError> {
1491    diff_trees(store, old_hash, new_hash)
1492}
1493
1494#[cfg(not(unix))]
1495fn diff_worktree_trees<S: ObjectSource + ?Sized>(
1496    store: &S,
1497    old_hash: Option<Hash>,
1498    new_hash: Option<Hash>,
1499) -> Result<DiffResult, StoreError> {
1500    diff_trees_inner(store, old_hash, new_hash, true)
1501}
1502
1503// =====================================================================
1504// Tests
1505// =====================================================================
1506
1507#[cfg(test)]
1508#[allow(clippy::many_single_char_names)] // single-letter blob/entry names keep the tables compact
1509mod tests {
1510    use super::*;
1511    use crate::object::{Blob, Tree};
1512    use crate::serialize;
1513    use tempfile::TempDir;
1514
1515    #[test]
1516    fn diff_line_counts_counts_text_and_flags_binary() {
1517        // Plain text: +2/-1 (b→B replaced, d,e added).
1518        assert_eq!(
1519            diff_line_counts(b"a\nb\nc\n", b"a\nB\nc\nd\ne\n"),
1520            Some((3, 1))
1521        );
1522        // A NUL byte → binary by git's heuristic, even though valid UTF-8.
1523        assert_eq!(diff_line_counts(b"a\nb\n", b"a\x00b\n"), None);
1524        assert_eq!(diff_line_counts(b"x\x00y", b"z"), None);
1525        // No NUL but invalid UTF-8 → still counted as text (lossy), not binary.
1526        assert!(diff_line_counts(b"\xff\xfe\n", b"\xff\xfe\nmore\n").is_some());
1527    }
1528
1529    // --- exact rename detection -----------------------------------------
1530
1531    fn h(b: &[u8]) -> Hash {
1532        crate::hash::hash(b)
1533    }
1534    fn removed(path: &str, content: &[u8]) -> DiffEntry {
1535        DiffEntry {
1536            path: path.into(),
1537            kind: DiffKind::Removed,
1538            old_hash: Some(h(content)),
1539            new_hash: None,
1540            old_mode: Some(EntryMode::Blob),
1541            new_mode: None,
1542            old_path: None,
1543        }
1544    }
1545    fn added(path: &str, content: &[u8]) -> DiffEntry {
1546        DiffEntry {
1547            path: path.into(),
1548            kind: DiffKind::Added,
1549            old_hash: None,
1550            new_hash: Some(h(content)),
1551            old_mode: None,
1552            new_mode: Some(EntryMode::Blob),
1553            old_path: None,
1554        }
1555    }
1556
1557    #[test]
1558    fn detect_pairs_identical_content_into_one_rename() {
1559        let mut es = vec![removed("old.txt", b"hello"), added("new.txt", b"hello")];
1560        pair_renames(&mut es, |h| h);
1561        assert_eq!(es.len(), 1);
1562        assert_eq!(es[0].kind, DiffKind::Renamed);
1563        assert_eq!(es[0].path, "new.txt");
1564        assert_eq!(es[0].old_path.as_deref(), Some("old.txt"));
1565        assert_eq!(es[0].old_hash, Some(h(b"hello")));
1566        assert_eq!(es[0].new_hash, Some(h(b"hello")));
1567    }
1568
1569    #[test]
1570    fn detect_leaves_unrelated_delete_add_alone() {
1571        let mut es = vec![removed("a", b"one"), added("b", b"two")];
1572        let before = es.clone();
1573        pair_renames(&mut es, |h| h);
1574        assert_eq!(es, before);
1575    }
1576
1577    #[test]
1578    fn detect_ignores_modified_in_place() {
1579        let mut es = vec![DiffEntry {
1580            path: "f".into(),
1581            kind: DiffKind::Modified,
1582            old_hash: Some(h(b"x")),
1583            new_hash: Some(h(b"y")),
1584            old_mode: Some(EntryMode::Blob),
1585            new_mode: Some(EntryMode::Blob),
1586            old_path: None,
1587        }];
1588        let before = es.clone();
1589        pair_renames(&mut es, |h| h);
1590        assert_eq!(es, before);
1591    }
1592
1593    #[test]
1594    fn detect_pairs_duplicates_deterministically_by_path() {
1595        // Identical content at two old paths and two new paths → two
1596        // renames, paired in sorted-path order (old_a→new_a, old_b→new_b)
1597        // regardless of input order.
1598        let mut es = vec![
1599            added("new_b", b"dup"),
1600            removed("old_b", b"dup"),
1601            added("new_a", b"dup"),
1602            removed("old_a", b"dup"),
1603        ];
1604        pair_renames(&mut es, |h| h);
1605        assert_eq!(es.len(), 2);
1606        assert_eq!(
1607            (es[0].old_path.as_deref(), es[0].path.as_str()),
1608            (Some("old_a"), "new_a")
1609        );
1610        assert_eq!(
1611            (es[1].old_path.as_deref(), es[1].path.as_str()),
1612            (Some("old_b"), "new_b")
1613        );
1614    }
1615
1616    #[test]
1617    fn detect_leaves_unpaired_remainder() {
1618        // Two deletes, one add of the same content → one rename (the
1619        // sorted-first old path) plus one plain delete.
1620        let mut es = vec![
1621            removed("old_a", b"c"),
1622            removed("old_b", b"c"),
1623            added("new", b"c"),
1624        ];
1625        pair_renames(&mut es, |h| h);
1626        assert_eq!(es.len(), 2);
1627        let r = es.iter().find(|e| e.kind == DiffKind::Renamed).unwrap();
1628        assert_eq!(r.old_path.as_deref(), Some("old_a"));
1629        let d = es.iter().find(|e| e.kind == DiffKind::Removed).unwrap();
1630        assert_eq!(d.path, "old_b");
1631    }
1632
1633    #[test]
1634    fn detect_preserves_modes_on_rename_with_mode_change() {
1635        // Identical content, exec bit added on the new side: still an exact
1636        // rename, and both modes are preserved for the header renderer.
1637        let r = removed("old", b"same");
1638        let mut a = added("new", b"same");
1639        a.new_mode = Some(EntryMode::Executable);
1640        let mut es = vec![r, a];
1641        pair_renames(&mut es, |h| h);
1642        assert_eq!(es.len(), 1);
1643        assert_eq!(es[0].kind, DiffKind::Renamed);
1644        assert_eq!(es[0].old_mode, Some(EntryMode::Blob));
1645        assert_eq!(es[0].new_mode, Some(EntryMode::Executable));
1646    }
1647
1648    fn fresh_store() -> (TempDir, ObjectStore) {
1649        let dir = TempDir::new().unwrap();
1650        let store = ObjectStore::init(&crate::layout::RepoLayout::single(dir.path())).unwrap();
1651        (dir, store)
1652    }
1653
1654    fn put_blob(store: &ObjectStore, data: &[u8]) -> Hash {
1655        let obj = Object::Blob(Blob {
1656            data: data.to_vec(),
1657        });
1658        let bytes = serialize::serialize(&obj).unwrap();
1659        store.write(&bytes).unwrap()
1660    }
1661
1662    fn put_tree(store: &ObjectStore, entries: Vec<TreeEntry>) -> Hash {
1663        let obj = Object::Tree(Tree { entries });
1664        let bytes = serialize::serialize(&obj).unwrap();
1665        store.write(&bytes).unwrap()
1666    }
1667
1668    fn entry(name: &[u8], mode: EntryMode, h: Hash) -> TreeEntry {
1669        TreeEntry {
1670            name: name.to_vec(),
1671            mode,
1672            object_hash: h,
1673        }
1674    }
1675
1676    #[test]
1677    fn equal_content_different_chunk_layout_is_clean() {
1678        let (_d, s) = fresh_store();
1679        let inline = put_blob(&s, b"abcdef");
1680        let a = put_blob(&s, b"ab");
1681        let b = put_blob(&s, b"cdef");
1682        let manifest = Object::ChunkedBlob(crate::object::ChunkedBlob {
1683            total_size: 6,
1684            chunk_size: 0,
1685            chunks: vec![a, b],
1686        });
1687        let chunked = s.write(&serialize::serialize(&manifest).unwrap()).unwrap();
1688        assert_ne!(inline, chunked);
1689        let old = put_tree(&s, vec![entry(b"a", EntryMode::Blob, chunked)]);
1690        let new = put_tree(&s, vec![entry(b"a", EntryMode::Blob, inline)]);
1691        assert!(diff_trees(&s, Some(old), Some(new)).unwrap().is_empty());
1692        let moved = put_tree(&s, vec![entry(b"b", EntryMode::Blob, inline)]);
1693        let mut renamed = diff_trees(&s, Some(old), Some(moved)).unwrap();
1694        detect_content_renames(&s, &mut renamed.entries).unwrap();
1695        assert_eq!(renamed.entries.len(), 1);
1696        assert_eq!(renamed.entries[0].kind, DiffKind::Renamed);
1697        assert_eq!(renamed.entries[0].old_hash, Some(chunked));
1698        assert_eq!(renamed.entries[0].new_hash, Some(inline));
1699        let executable = put_tree(&s, vec![entry(b"a", EntryMode::Executable, inline)]);
1700        assert_eq!(
1701            diff_trees(&s, Some(old), Some(executable)).unwrap().entries[0].kind,
1702            DiffKind::ModeChanged
1703        );
1704        let merge = crate::ops::merge_trees(&s, None, Some(old), Some(new)).unwrap();
1705        assert!(!merge.has_conflicts());
1706        let work = fresh_workdir();
1707        std::fs::write(work.path().join("a"), b"abcdef").unwrap();
1708        let idx = crate::index::from_tree(&s, old).unwrap();
1709        let (status, observations) =
1710            status_diff_observed(&s, Some(&old), work.path(), Some(&idx)).unwrap();
1711        assert!(status.is_empty());
1712        assert_eq!(observations[0].object_hash, chunked);
1713    }
1714
1715    #[test]
1716    fn identical_trees_no_diff() {
1717        let (_d, s) = fresh_store();
1718        let blob = put_blob(&s, b"content");
1719        let tree = put_tree(&s, vec![entry(b"a.txt", EntryMode::Blob, blob)]);
1720        let result = diff_trees(&s, Some(tree), Some(tree)).unwrap();
1721        assert_eq!(result.len(), 0);
1722    }
1723
1724    #[test]
1725    fn added_file_detected() {
1726        let (_d, s) = fresh_store();
1727        let blob_a = put_blob(&s, b"aaa");
1728        let blob_b = put_blob(&s, b"bbb");
1729        let old = put_tree(&s, vec![entry(b"a.txt", EntryMode::Blob, blob_a)]);
1730        let new = put_tree(
1731            &s,
1732            vec![
1733                entry(b"a.txt", EntryMode::Blob, blob_a),
1734                entry(b"b.txt", EntryMode::Blob, blob_b),
1735            ],
1736        );
1737        let r = diff_trees(&s, Some(old), Some(new)).unwrap();
1738        assert_eq!(r.entries.len(), 1);
1739        assert_eq!(r.entries[0].path, "b.txt");
1740        assert_eq!(r.entries[0].kind, DiffKind::Added);
1741        assert_eq!(r.entries[0].old_hash, None);
1742        assert_eq!(r.entries[0].new_hash, Some(blob_b));
1743    }
1744
1745    #[test]
1746    fn removed_file_detected() {
1747        let (_d, s) = fresh_store();
1748        let blob_a = put_blob(&s, b"aaa");
1749        let blob_b = put_blob(&s, b"bbb");
1750        let old = put_tree(
1751            &s,
1752            vec![
1753                entry(b"a.txt", EntryMode::Blob, blob_a),
1754                entry(b"b.txt", EntryMode::Blob, blob_b),
1755            ],
1756        );
1757        let new = put_tree(&s, vec![entry(b"a.txt", EntryMode::Blob, blob_a)]);
1758        let r = diff_trees(&s, Some(old), Some(new)).unwrap();
1759        assert_eq!(r.entries.len(), 1);
1760        assert_eq!(r.entries[0].path, "b.txt");
1761        assert_eq!(r.entries[0].kind, DiffKind::Removed);
1762        assert_eq!(r.entries[0].old_hash, Some(blob_b));
1763        assert_eq!(r.entries[0].new_hash, None);
1764    }
1765
1766    #[test]
1767    fn modified_file_detected() {
1768        let (_d, s) = fresh_store();
1769        let v1 = put_blob(&s, b"version 1");
1770        let v2 = put_blob(&s, b"version 2");
1771        let old = put_tree(&s, vec![entry(b"file.txt", EntryMode::Blob, v1)]);
1772        let new = put_tree(&s, vec![entry(b"file.txt", EntryMode::Blob, v2)]);
1773        let r = diff_trees(&s, Some(old), Some(new)).unwrap();
1774        assert_eq!(r.entries.len(), 1);
1775        assert_eq!(r.entries[0].path, "file.txt");
1776        assert_eq!(r.entries[0].kind, DiffKind::Modified);
1777        assert_eq!(r.entries[0].old_hash, Some(v1));
1778        assert_eq!(r.entries[0].new_hash, Some(v2));
1779    }
1780
1781    #[test]
1782    fn mode_change_detected() {
1783        let (_d, s) = fresh_store();
1784        let blob = put_blob(&s, b"content");
1785        let old = put_tree(&s, vec![entry(b"link", EntryMode::Blob, blob)]);
1786        let new = put_tree(&s, vec![entry(b"link", EntryMode::Symlink, blob)]);
1787        let r = diff_trees(&s, Some(old), Some(new)).unwrap();
1788        assert_eq!(r.entries.len(), 1);
1789        assert_eq!(r.entries[0].path, "link");
1790        assert_eq!(r.entries[0].kind, DiffKind::ModeChanged);
1791        assert_eq!(r.entries[0].old_hash, Some(blob));
1792        assert_eq!(r.entries[0].new_hash, Some(blob));
1793    }
1794
1795    #[test]
1796    fn nested_tree_diff() {
1797        let (_d, s) = fresh_store();
1798        let v1 = put_blob(&s, b"old content");
1799        let v2 = put_blob(&s, b"new content");
1800        let other = put_blob(&s, b"unchanged");
1801        let old_sub = put_tree(
1802            &s,
1803            vec![
1804                entry(b"file.txt", EntryMode::Blob, v1),
1805                entry(b"other.txt", EntryMode::Blob, other),
1806            ],
1807        );
1808        let new_sub = put_tree(
1809            &s,
1810            vec![
1811                entry(b"file.txt", EntryMode::Blob, v2),
1812                entry(b"other.txt", EntryMode::Blob, other),
1813            ],
1814        );
1815        let old_root = put_tree(&s, vec![entry(b"subdir", EntryMode::Tree, old_sub)]);
1816        let new_root = put_tree(&s, vec![entry(b"subdir", EntryMode::Tree, new_sub)]);
1817        let r = diff_trees(&s, Some(old_root), Some(new_root)).unwrap();
1818        assert_eq!(r.entries.len(), 1);
1819        assert_eq!(r.entries[0].path, "subdir/file.txt");
1820        assert_eq!(r.entries[0].kind, DiffKind::Modified);
1821    }
1822
1823    #[test]
1824    fn diff_against_empty_tree() {
1825        let (_d, s) = fresh_store();
1826        let blob_a = put_blob(&s, b"aaa");
1827        let blob_b = put_blob(&s, b"bbb");
1828        let new = put_tree(
1829            &s,
1830            vec![
1831                entry(b"a.txt", EntryMode::Blob, blob_a),
1832                entry(b"b.txt", EntryMode::Blob, blob_b),
1833            ],
1834        );
1835        let r = diff_trees(&s, None, Some(new)).unwrap();
1836        assert_eq!(r.entries.len(), 2);
1837        assert_eq!(r.entries[0].path, "a.txt");
1838        assert_eq!(r.entries[0].kind, DiffKind::Added);
1839        assert_eq!(r.entries[1].path, "b.txt");
1840        assert_eq!(r.entries[1].kind, DiffKind::Added);
1841    }
1842
1843    #[test]
1844    fn empty_tree_against_non_empty() {
1845        let (_d, s) = fresh_store();
1846        let blob_a = put_blob(&s, b"aaa");
1847        let blob_b = put_blob(&s, b"bbb");
1848        let old = put_tree(
1849            &s,
1850            vec![
1851                entry(b"a.txt", EntryMode::Blob, blob_a),
1852                entry(b"b.txt", EntryMode::Blob, blob_b),
1853            ],
1854        );
1855        let r = diff_trees(&s, Some(old), None).unwrap();
1856        assert_eq!(r.entries.len(), 2);
1857        assert_eq!(r.entries[0].kind, DiffKind::Removed);
1858        assert_eq!(r.entries[1].kind, DiffKind::Removed);
1859    }
1860
1861    #[test]
1862    fn sorted_output() {
1863        let (_d, s) = fresh_store();
1864        let a = put_blob(&s, b"a");
1865        let b = put_blob(&s, b"b");
1866        let c = put_blob(&s, b"c");
1867        let new = put_tree(
1868            &s,
1869            vec![
1870                entry(b"a.txt", EntryMode::Blob, a),
1871                entry(b"m.txt", EntryMode::Blob, b),
1872                entry(b"z.txt", EntryMode::Blob, c),
1873            ],
1874        );
1875        let r = diff_trees(&s, None, Some(new)).unwrap();
1876        assert_eq!(r.entries.len(), 3);
1877        assert_eq!(r.entries[0].path, "a.txt");
1878        assert_eq!(r.entries[1].path, "m.txt");
1879        assert_eq!(r.entries[2].path, "z.txt");
1880    }
1881
1882    #[test]
1883    fn max_length_entry_names() {
1884        let (_d, s) = fresh_store();
1885        let blob = put_blob(&s, b"data");
1886        let long_name = vec![b'A'; 255];
1887        let new = put_tree(&s, vec![entry(&long_name, EntryMode::Blob, blob)]);
1888        let r = diff_trees(&s, None, Some(new)).unwrap();
1889        assert_eq!(r.entries.len(), 1);
1890        assert_eq!(r.entries[0].path.len(), 255);
1891        assert_eq!(r.entries[0].kind, DiffKind::Added);
1892    }
1893
1894    #[test]
1895    fn both_none_is_empty() {
1896        let (_d, s) = fresh_store();
1897        let r = diff_trees(&s, None, None).unwrap();
1898        assert!(r.is_empty());
1899    }
1900
1901    // -----------------------------------------------------------------
1902    // status_diff unit tests
1903    // -----------------------------------------------------------------
1904
1905    fn fresh_workdir() -> TempDir {
1906        TempDir::new().unwrap()
1907    }
1908
1909    #[test]
1910    fn status_diff_does_not_fsync() {
1911        // `status` is read-only from the user's perspective; its
1912        // worktree-snapshot objects are ephemeral and must never pay
1913        // durability costs (no barriers, no full flushes, no dir
1914        // flushes) — renames only.
1915        use crate::batch::testing::{Ev, RecordingSyncer};
1916        use std::sync::Arc;
1917
1918        let (_sd, mut store) = fresh_store();
1919        let work = fresh_workdir();
1920        std::fs::write(work.path().join("a.txt"), b"some content").unwrap();
1921        std::fs::write(work.path().join("b.txt"), b"other content").unwrap();
1922
1923        let rec = Arc::new(RecordingSyncer::default());
1924        store.set_syncer(rec.clone());
1925
1926        let result = status_diff(&store, None, work.path(), None).unwrap();
1927        assert!(!result.is_empty(), "untracked files must surface");
1928
1929        let evs = rec.events();
1930        assert!(
1931            evs.iter().all(|e| matches!(e, Ev::Rename { .. })),
1932            "status must not emit any flush events; got {evs:?}"
1933        );
1934    }
1935
1936    #[test]
1937    fn status_empty_worktree_no_head() {
1938        // Empty worktree, no HEAD → nothing to report.
1939        let (_sd, store) = fresh_store();
1940        let work = fresh_workdir();
1941        let result = status_diff(&store, None, work.path(), None).unwrap();
1942        assert!(result.is_empty());
1943    }
1944
1945    #[test]
1946    fn status_worktree_equals_head_is_clean() {
1947        // Worktree identical to HEAD → no changes.
1948        let (_sd, store) = fresh_store();
1949        let work = fresh_workdir();
1950        std::fs::write(work.path().join("a.txt"), b"hello").unwrap();
1951        // Build a tree from the worktree and use it as HEAD.
1952        let head_hash = worktree::build_tree(&store, work.path()).unwrap();
1953        let result = status_diff(&store, Some(&head_hash), work.path(), None).unwrap();
1954        assert!(result.is_empty(), "expected clean, got {result:?}");
1955    }
1956
1957    #[cfg(not(unix))]
1958    #[test]
1959    fn status_no_index_ignores_unrepresentable_executable_mode_on_non_unix() {
1960        let (_sd, store) = fresh_store();
1961        let work = fresh_workdir();
1962        std::fs::write(work.path().join("run.sh"), b"#!/bin/sh\n").unwrap();
1963        let h = worktree::hash_file(&store, &work.path().join("run.sh")).unwrap();
1964        let head_hash = put_tree(&store, vec![entry(b"run.sh", EntryMode::Executable, h)]);
1965
1966        let result = status_diff(&store, Some(&head_hash), work.path(), None).unwrap();
1967        assert!(
1968            result.is_empty(),
1969            "non-Unix should not report executable-only noise, got {result:?}"
1970        );
1971    }
1972
1973    #[test]
1974    fn status_added_only() {
1975        // HEAD has {a.txt}; worktree has {a.txt, b.txt} → b.txt added.
1976        let (_sd, store) = fresh_store();
1977        let work = fresh_workdir();
1978        std::fs::write(work.path().join("a.txt"), b"hello").unwrap();
1979        let head_hash = worktree::build_tree(&store, work.path()).unwrap();
1980        std::fs::write(work.path().join("b.txt"), b"world").unwrap();
1981        let result = status_diff(&store, Some(&head_hash), work.path(), None).unwrap();
1982        assert_eq!(result.len(), 1);
1983        assert_eq!(result[0].diff.path, "b.txt");
1984        assert_eq!(result[0].diff.kind, DiffKind::Added);
1985        assert_eq!(result[0].staging, StatusStaging::Unstaged);
1986    }
1987
1988    #[test]
1989    fn status_removed_only() {
1990        // HEAD has {a.txt, b.txt}; worktree has only {a.txt} → b.txt removed.
1991        let (_sd, store) = fresh_store();
1992        let work = fresh_workdir();
1993        std::fs::write(work.path().join("a.txt"), b"hello").unwrap();
1994        std::fs::write(work.path().join("b.txt"), b"world").unwrap();
1995        let head_hash = worktree::build_tree(&store, work.path()).unwrap();
1996        std::fs::remove_file(work.path().join("b.txt")).unwrap();
1997        let result = status_diff(&store, Some(&head_hash), work.path(), None).unwrap();
1998        assert_eq!(result.len(), 1);
1999        assert_eq!(result[0].diff.path, "b.txt");
2000        assert_eq!(result[0].diff.kind, DiffKind::Removed);
2001        assert_eq!(result[0].staging, StatusStaging::Unstaged);
2002    }
2003
2004    #[test]
2005    fn status_modified_only() {
2006        // HEAD has {a.txt="old"}; worktree has {a.txt="new"} → a.txt modified.
2007        let (_sd, store) = fresh_store();
2008        let work = fresh_workdir();
2009        std::fs::write(work.path().join("a.txt"), b"old").unwrap();
2010        let head_hash = worktree::build_tree(&store, work.path()).unwrap();
2011        std::fs::write(work.path().join("a.txt"), b"new").unwrap();
2012        let result = status_diff(&store, Some(&head_hash), work.path(), None).unwrap();
2013        assert_eq!(result.len(), 1);
2014        assert_eq!(result[0].diff.path, "a.txt");
2015        assert_eq!(result[0].diff.kind, DiffKind::Modified);
2016        assert_eq!(result[0].staging, StatusStaging::Unstaged);
2017    }
2018
2019    #[test]
2020    fn status_mixed_changes() {
2021        // HEAD: {a.txt, b.txt}. Worktree: a.txt modified, b.txt removed, c.txt added.
2022        let (_sd, store) = fresh_store();
2023        let work = fresh_workdir();
2024        std::fs::write(work.path().join("a.txt"), b"original").unwrap();
2025        std::fs::write(work.path().join("b.txt"), b"stays").unwrap();
2026        let head_hash = worktree::build_tree(&store, work.path()).unwrap();
2027        std::fs::write(work.path().join("a.txt"), b"changed").unwrap();
2028        std::fs::remove_file(work.path().join("b.txt")).unwrap();
2029        std::fs::write(work.path().join("c.txt"), b"new").unwrap();
2030        let result = status_diff(&store, Some(&head_hash), work.path(), None).unwrap();
2031        assert_eq!(result.len(), 3);
2032        let paths: Vec<&str> = result.iter().map(|e| e.diff.path.as_str()).collect();
2033        assert!(paths.contains(&"a.txt"), "missing a.txt: {paths:?}");
2034        assert!(paths.contains(&"b.txt"), "missing b.txt: {paths:?}");
2035        assert!(paths.contains(&"c.txt"), "missing c.txt: {paths:?}");
2036    }
2037
2038    #[test]
2039    fn status_no_head_shows_all_as_added() {
2040        // No HEAD (initial repo state) → every file shows as added.
2041        let (_sd, store) = fresh_store();
2042        let work = fresh_workdir();
2043        std::fs::write(work.path().join("a.txt"), b"aaa").unwrap();
2044        std::fs::write(work.path().join("b.txt"), b"bbb").unwrap();
2045        let result = status_diff(&store, None, work.path(), None).unwrap();
2046        assert_eq!(result.len(), 2);
2047        for e in &result {
2048            assert_eq!(e.diff.kind, DiffKind::Added);
2049            assert_eq!(e.staging, StatusStaging::Unstaged);
2050        }
2051    }
2052
2053    /// HEAD is empty; index has b.txt; worktree has b.txt with the
2054    /// same content. The HEAD↔index leg picks up b.txt as Added
2055    /// (Staged); the index↔worktree leg sees no delta. Single Staged
2056    /// entry.
2057    #[test]
2058    fn status_staged_entry_is_classified_staged() {
2059        use crate::index::{EntryStatus, Index, IndexEntry};
2060        let (_sd, store) = fresh_store();
2061        let work = fresh_workdir();
2062        std::fs::write(work.path().join("b.txt"), b"world").unwrap();
2063        let b_hash = worktree::hash_file(&store, &work.path().join("b.txt")).unwrap();
2064        let mut idx = Index::new();
2065        idx.entries.push(IndexEntry {
2066            path: "b.txt".to_string(),
2067            status: EntryStatus::Blob,
2068            object_hash: b_hash,
2069            mtime_ns: 0,
2070            size: 0,
2071            ino: 0,
2072            ctime_ns: 0,
2073        });
2074        // No HEAD — first commit scenario.
2075        let result = status_diff(&store, None, work.path(), Some(&idx)).unwrap();
2076        assert_eq!(result.len(), 1);
2077        assert_eq!(result[0].diff.path, "b.txt");
2078        assert_eq!(result[0].staging, StatusStaging::Staged);
2079    }
2080
2081    #[cfg(not(unix))]
2082    #[test]
2083    fn status_ignores_unrepresentable_executable_mode_on_non_unix_worktree() {
2084        use crate::index::{EntryStatus, Index, IndexEntry};
2085
2086        let (_sd, store) = fresh_store();
2087        let work = fresh_workdir();
2088        std::fs::write(work.path().join("run.sh"), b"#!/bin/sh\n").unwrap();
2089        let h = worktree::hash_file(&store, &work.path().join("run.sh")).unwrap();
2090        let mut idx = Index::new();
2091        idx.entries.push(IndexEntry {
2092            path: "run.sh".to_string(),
2093            status: EntryStatus::Executable,
2094            object_hash: h,
2095            mtime_ns: 0,
2096            size: 0,
2097            ino: 0,
2098            ctime_ns: 0,
2099        });
2100
2101        let result = status_diff(&store, None, work.path(), Some(&idx)).unwrap();
2102        assert_eq!(result.len(), 1, "expected only the staged addition");
2103        assert_eq!(result[0].staging, StatusStaging::Staged);
2104    }
2105
2106    // -----------------------------------------------------------------
2107    // text_patch unit tests
2108    // -----------------------------------------------------------------
2109
2110    #[test]
2111    fn text_patch_modified_line_emits_hunk() {
2112        let old = b"line1\nline2\nline3\n";
2113        let new = b"line1\nCHANGED\nline3\n";
2114        let patch = text_patch(old, new, "f.txt", "f.txt");
2115        assert!(patch.starts_with("--- a/f.txt\n+++ b/f.txt\n"), "{patch}");
2116        assert!(patch.contains("@@ -1,3 +1,3 @@"), "{patch}");
2117        assert!(patch.contains("-line2\n"), "{patch}");
2118        assert!(patch.contains("+CHANGED\n"), "{patch}");
2119        assert!(patch.contains(" line1\n"), "{patch}");
2120        assert!(patch.contains(" line3\n"), "{patch}");
2121    }
2122
2123    #[test]
2124    fn text_patch_pure_addition() {
2125        let old = b"a\nb\n";
2126        let new = b"a\nb\nc\n";
2127        let patch = text_patch(old, new, "f", "f");
2128        assert!(patch.contains("+c\n"), "{patch}");
2129        // No deletion lines in the hunk body (the `---` header aside).
2130        assert!(
2131            !patch
2132                .lines()
2133                .any(|l| l.starts_with('-') && !l.starts_with("---")),
2134            "should be no deletions: {patch}"
2135        );
2136    }
2137
2138    #[test]
2139    fn text_patch_identical_is_empty() {
2140        let patch = text_patch(b"same\n", b"same\n", "f", "f");
2141        assert!(patch.is_empty(), "{patch}");
2142    }
2143
2144    #[test]
2145    fn text_patch_binary_reports_differ() {
2146        let old = &[0x00, 0xff, 0x01][..];
2147        let new = &[0x00, 0xfe, 0x02][..];
2148        let patch = text_patch(old, new, "bin", "bin");
2149        assert_eq!(patch, "Binary files a/bin and b/bin differ\n");
2150    }
2151
2152    #[test]
2153    fn text_patch_nul_byte_is_binary_like_git() {
2154        // A NUL byte makes the blob binary by git's heuristic even though it
2155        // is valid UTF-8 — must NOT emit a textual hunk with an embedded NUL.
2156        let patch = text_patch(b"a\0b\n", b"a\0c\n", "f", "f");
2157        assert_eq!(patch, "Binary files a/f and b/f differ\n");
2158    }
2159
2160    #[test]
2161    fn unified_hunks_non_utf8_without_nul_is_text_like_git() {
2162        // Invalid UTF-8 but no NUL: git treats it as text and diffs it
2163        // byte-wise. The raw bytes survive into the hunk (single-line hunk →
2164        // `@@ -1 +1 @@`, no `,1`).
2165        let hunks = unified_hunks(b"\xff\n", b"\xfe\n").expect("not binary");
2166        assert_eq!(
2167            hunks,
2168            b"@@ -1 +1 @@\n-\xff\n+\xfe\n".to_vec(),
2169            "got {hunks:?}"
2170        );
2171    }
2172
2173    #[test]
2174    fn hunk_header_omits_count_one_like_git() {
2175        // A one-line-each change: git writes `@@ -1 +1 @@`, not `-1,1 +1,1`.
2176        let patch = text_patch(b"old\n", b"new\n", "f", "f");
2177        assert_eq!(
2178            patch, "--- a/f\n+++ b/f\n@@ -1 +1 @@\n-old\n+new\n",
2179            "{patch}"
2180        );
2181    }
2182
2183    #[test]
2184    fn text_patch_no_trailing_newline_marker() {
2185        let old = b"x\ny";
2186        let new = b"x\nz";
2187        let patch = text_patch(old, new, "f", "f");
2188        assert!(patch.contains("\\ No newline at end of file\n"), "{patch}");
2189    }
2190
2191    #[test]
2192    fn text_patch_separate_hunks_for_distant_changes() {
2193        let old = b"a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n";
2194        // Change line 1 and line 10; far enough apart for two hunks.
2195        let new = b"A\nb\nc\nd\ne\nf\ng\nh\ni\nJ\n";
2196        let patch = text_patch(old, new, "f", "f");
2197        let hunk_count = patch.matches("@@ ").count();
2198        assert_eq!(hunk_count, 2, "expected two hunks: {patch}");
2199    }
2200
2201    #[test]
2202    fn text_patch_inserts_compact_to_git_position() {
2203        // Inserting a line into a run of identical lines: git's change
2204        // compaction places the `+` at the *bottom* of the run (just before
2205        // the differing line). Verifies the Myers + compaction pipeline picks
2206        // the same canonical boundary git does.
2207        let patch = text_patch(b"a\na\nb\n", b"a\na\na\nb\n", "f", "f");
2208        assert_eq!(
2209            patch, "--- a/f\n+++ b/f\n@@ -1,3 +1,4 @@\n a\n a\n+a\n b\n",
2210            "{patch}"
2211        );
2212    }
2213
2214    #[test]
2215    fn text_patch_deletes_compact_to_git_position() {
2216        // The dual: deleting from a run of identical lines removes the bottom
2217        // one (the `-` lands just before the differing line).
2218        let patch = text_patch(b"a\na\na\nb\n", b"a\na\nb\n", "f", "f");
2219        assert_eq!(
2220            patch, "--- a/f\n+++ b/f\n@@ -1,4 +1,3 @@\n a\n a\n-a\n b\n",
2221            "{patch}"
2222        );
2223    }
2224
2225    /// HEAD empty; index has b.txt at v1; worktree has b.txt at v2.
2226    /// The HEAD↔index leg yields one Added (Staged) entry; the
2227    /// index↔worktree leg yields one Modified (Unstaged) entry. Same
2228    /// path appears in both sections — git's two-section layout.
2229    /// Pre-fix this collapsed to a single `PartiallyStaged` entry
2230    /// which hid the staged-vs-worktree distinction.
2231    #[test]
2232    fn status_partially_staged_entry_emits_both_legs() {
2233        use crate::index::{EntryStatus, Index, IndexEntry};
2234        let (_sd, store) = fresh_store();
2235        let work = fresh_workdir();
2236        // Write v1, hash it, then overwrite with v2.
2237        std::fs::write(work.path().join("b.txt"), b"v1").unwrap();
2238        let b_v1_hash = worktree::hash_file(&store, &work.path().join("b.txt")).unwrap();
2239        std::fs::write(work.path().join("b.txt"), b"v2").unwrap();
2240        let mut idx = Index::new();
2241        idx.entries.push(IndexEntry {
2242            path: "b.txt".to_string(),
2243            status: EntryStatus::Blob,
2244            object_hash: b_v1_hash,
2245            mtime_ns: 0,
2246            size: 0,
2247            ino: 0,
2248            ctime_ns: 0,
2249        });
2250        let result = status_diff(&store, None, work.path(), Some(&idx)).unwrap();
2251        assert_eq!(result.len(), 2, "expected staged + unstaged entries");
2252        let stagings: Vec<_> = result.iter().map(|e| e.staging).collect();
2253        assert!(stagings.contains(&StatusStaging::Staged));
2254        assert!(stagings.contains(&StatusStaging::Unstaged));
2255        assert!(result.iter().all(|e| e.diff.path == "b.txt"));
2256    }
2257
2258    // ---- enumerate_hunks / apply_hunks_subset (add -p primitives) ----
2259
2260    #[test]
2261    fn enumerate_hunks_binary_returns_none() {
2262        assert!(enumerate_hunks(b"a\0b", b"c").is_none());
2263        assert!(enumerate_hunks(b"text\n", b"with\0nul").is_none());
2264    }
2265
2266    #[test]
2267    fn enumerate_hunks_identical_is_empty() {
2268        assert_eq!(enumerate_hunks(b"a\nb\n", b"a\nb\n"), Some(vec![]));
2269    }
2270
2271    // 14 lines; edits at line 2 and line 13 are >2*context apart, so they
2272    // stay as two distinct hunks (changes <7 lines apart would merge).
2273    const HUNK2_OLD: &[u8] = b"l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11\nl12\nl13\nl14\n";
2274    const HUNK2_NEW: &[u8] = b"l1\nL2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11\nl12\nL13\nl14\n";
2275
2276    #[test]
2277    fn apply_all_hunks_reproduces_new_apply_none_reproduces_base() {
2278        let hunks = enumerate_hunks(HUNK2_OLD, HUNK2_NEW).unwrap();
2279        assert_eq!(hunks.len(), 2, "expected two distinct hunks");
2280        let all: Vec<usize> = (0..hunks.len()).collect();
2281        assert_eq!(
2282            apply_hunks_subset(HUNK2_OLD, &hunks, &all),
2283            HUNK2_NEW,
2284            "apply all == new"
2285        );
2286        assert_eq!(
2287            apply_hunks_subset(HUNK2_OLD, &hunks, &[]),
2288            HUNK2_OLD,
2289            "apply none == old"
2290        );
2291    }
2292
2293    #[test]
2294    fn apply_single_hunk_picks_only_that_region() {
2295        let hunks = enumerate_hunks(HUNK2_OLD, HUNK2_NEW).unwrap();
2296        // Stage only the first hunk: line 2 → L2, line 13 stays l13.
2297        let staged = apply_hunks_subset(HUNK2_OLD, &hunks, &[0]);
2298        assert_eq!(
2299            staged, b"l1\nL2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11\nl12\nl13\nl14\n",
2300            "only the first hunk applied"
2301        );
2302        // Stage only the second hunk: line 13 → L13, line 2 stays l2.
2303        let staged = apply_hunks_subset(HUNK2_OLD, &hunks, &[1]);
2304        assert_eq!(
2305            staged, b"l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\nl11\nl12\nL13\nl14\n",
2306            "only the second hunk applied"
2307        );
2308    }
2309
2310    #[test]
2311    fn apply_hunks_into_empty_base_adds_lines() {
2312        let old = b"";
2313        let new = b"alpha\nbeta\n";
2314        let hunks = enumerate_hunks(old, new).unwrap();
2315        assert_eq!(apply_hunks_subset(old, &hunks, &[0]), new);
2316        assert_eq!(apply_hunks_subset(old, &hunks, &[]), old);
2317    }
2318
2319    #[test]
2320    fn apply_hunks_preserves_missing_eof_newline() {
2321        let old = b"a\nb\nc";
2322        let new = b"a\nB\nc"; // edit the middle line; file still lacks final \n
2323        let hunks = enumerate_hunks(old, new).unwrap();
2324        let staged = apply_hunks_subset(old, &hunks, &[0]);
2325        assert_eq!(staged, new, "no trailing newline must be preserved");
2326    }
2327
2328    proptest::proptest! {
2329        /// `myers_changed` (prefix-elided) must produce byte-identical
2330        /// change-flags to `myers_changed_core` (the unmodified original
2331        /// algorithm) run directly on the same, un-elided `old`/`new` — not
2332        /// just an equally-minimal edit script. Regression test for the
2333        /// suffix-elision bug caught in review (see `myers_changed`'s doc
2334        /// comment for the full counterexample and why it's unsound). A
2335        /// tiny 3-symbol alphabet maximizes repeat/collision near a change
2336        /// boundary in a short random sequence — exactly what that bug
2337        /// needed to surface.
2338        #[test]
2339        fn proptest_myers_changed_matches_unelided_core(
2340            old in proptest::collection::vec(0u8..3, 0..10),
2341            new in proptest::collection::vec(0u8..3, 0..10),
2342        ) {
2343            fn to_lines(v: &[u8]) -> Vec<DiffLine<'_>> {
2344                const SYMBOLS: [&[u8]; 3] = [b"0", b"1", b"2"];
2345                v.iter().map(|&n| DiffLine { text: SYMBOLS[n as usize], has_newline: true }).collect()
2346            }
2347            let old_lines = to_lines(&old);
2348            let new_lines = to_lines(&new);
2349            let elided = myers_changed(&old_lines, &new_lines, WhitespaceMode::Exact);
2350            let ground_truth = myers_changed_core(&old_lines, &new_lines, WhitespaceMode::Exact);
2351            proptest::prop_assert_eq!(elided, ground_truth);
2352        }
2353
2354        /// Patch round-trip on lines built from a random shared prefix/suffix
2355        /// plus a random differing middle: applying every hunk reproduces
2356        /// `new` from `old`, and applying none reproduces `old`. Small
2357        /// line-index alphabet (0..6) lets the generator produce shared
2358        /// runs, exact duplicates, and fully disjoint content all in the
2359        /// same search space, so this exercises the trim path (long shared
2360        /// prefix), the untrimmed path (no shared prefix), and everything
2361        /// between — including a trailing run shared for reasons other than
2362        /// elision (`myers_changed` no longer elides one, but the O(ND) core
2363        /// must still handle it correctly on its own).
2364        #[test]
2365        fn proptest_hunks_roundtrip_with_shared_affix(
2366            prefix in proptest::collection::vec(0u8..6, 0..8),
2367            suffix in proptest::collection::vec(0u8..6, 0..8),
2368            old_mid in proptest::collection::vec(0u8..6, 0..8),
2369            new_mid in proptest::collection::vec(0u8..6, 0..8),
2370        ) {
2371            fn build(prefix: &[u8], mid: &[u8], suffix: &[u8]) -> Vec<u8> {
2372                let mut out = Vec::new();
2373                for &n in prefix.iter().chain(mid).chain(suffix) {
2374                    out.extend_from_slice(format!("l{n}\n").as_bytes());
2375                }
2376                out
2377            }
2378            let old = build(&prefix, &old_mid, &suffix);
2379            let new = build(&prefix, &new_mid, &suffix);
2380            let hunks = enumerate_hunks(&old, &new).unwrap();
2381            let all: Vec<usize> = (0..hunks.len()).collect();
2382            proptest::prop_assert_eq!(apply_hunks_subset(&old, &hunks, &all), new);
2383            proptest::prop_assert_eq!(apply_hunks_subset(&old, &hunks, &[]), old);
2384        }
2385    }
2386
2387    // ---- merge_blob_3way (#298) ----
2388
2389    #[test]
2390    fn merge3_identical_sides_merge_trivially() {
2391        let x = b"a\nb\n";
2392        assert_eq!(merge_blob_3way(b"base\n", x, x), Some(x.to_vec()));
2393    }
2394
2395    #[test]
2396    fn merge3_disjoint_line_edits_auto_merge() {
2397        let base = b"a\nb\nc\nd\ne\n";
2398        let ours = b"A\nb\nc\nd\ne\n"; // line 1
2399        let theirs = b"a\nb\nc\nd\nE\n"; // line 5
2400        assert_eq!(
2401            merge_blob_3way(base, ours, theirs),
2402            Some(b"A\nb\nc\nd\nE\n".to_vec())
2403        );
2404    }
2405
2406    #[test]
2407    fn merge3_adjacent_line_edits_auto_merge() {
2408        // The motivating #298 case: line 1 changed on one side, line 2 on the
2409        // other. Adjacent but non-overlapping → clean merge, no conflict.
2410        let base = b"x\ny\n";
2411        let ours = b"X\ny\n"; // line 1
2412        let theirs = b"x\nY\n"; // line 2
2413        assert_eq!(
2414            merge_blob_3way(base, ours, theirs),
2415            Some(b"X\nY\n".to_vec())
2416        );
2417    }
2418
2419    #[test]
2420    fn merge3_overlapping_line_edits_conflict() {
2421        let base = b"a\nb\nc\n";
2422        let ours = b"A\nb\nc\n"; // line 1 -> A
2423        let theirs = b"Z\nb\nc\n"; // line 1 -> Z
2424        assert_eq!(merge_blob_3way(base, ours, theirs), None);
2425    }
2426
2427    #[test]
2428    fn merge3_one_side_only_takes_that_side() {
2429        // theirs == base (no change); ours changed → take ours.
2430        let base = b"a\nb\nc\n";
2431        let ours = b"a\nB\nc\n";
2432        assert_eq!(merge_blob_3way(base, ours, base), Some(ours.to_vec()));
2433    }
2434
2435    #[test]
2436    fn merge3_disjoint_insertions_auto_merge() {
2437        let base = b"a\nb\n";
2438        let ours = b"a\nX\nb\n"; // insert X after a
2439        let theirs = b"a\nb\nY\n"; // insert Y after b
2440        assert_eq!(
2441            merge_blob_3way(base, ours, theirs),
2442            Some(b"a\nX\nb\nY\n".to_vec())
2443        );
2444    }
2445
2446    #[test]
2447    fn merge3_coincident_insertions_conflict() {
2448        let base = b"a\nb\n";
2449        let ours = b"a\nX\nb\n"; // both insert at the same gap
2450        let theirs = b"a\nY\nb\n";
2451        assert_eq!(merge_blob_3way(base, ours, theirs), None);
2452    }
2453
2454    #[test]
2455    fn merge3_binary_side_conflicts() {
2456        assert_eq!(merge_blob_3way(b"a\nb\n", b"a\0\nb\n", b"a\nb\nc\n"), None);
2457    }
2458
2459    // -----------------------------------------------------------------
2460    // unified_hunks_opts — WhitespaceMode (#712)
2461    // -----------------------------------------------------------------
2462
2463    #[test]
2464    fn ignore_all_space_treats_whitespace_only_change_as_unchanged() {
2465        let old = b"head\nfoo(a, b)\ntail\n";
2466        let new = b"head\nfoo(a,b)\ntail\n";
2467        let exact = unified_hunks_opts(old, new, PATCH_CONTEXT, WhitespaceMode::Exact).unwrap();
2468        assert!(!exact.is_empty(), "exact mode should see the change");
2469        let ws =
2470            unified_hunks_opts(old, new, PATCH_CONTEXT, WhitespaceMode::IgnoreAllSpace).unwrap();
2471        assert!(
2472            ws.is_empty(),
2473            "-w should ignore a line that only gained/lost whitespace: {ws:?}"
2474        );
2475    }
2476
2477    #[test]
2478    fn ignore_space_change_does_not_ignore_whitespace_appearing_from_nothing() {
2479        // One side has a space the other side lacks entirely — `-b` must
2480        // still see this as a change (unlike `-w`).
2481        let old = b"foo(a, b)\n";
2482        let new = b"foo(a,b)\n";
2483        let hunks =
2484            unified_hunks_opts(old, new, PATCH_CONTEXT, WhitespaceMode::IgnoreSpaceChange).unwrap();
2485        assert!(
2486            !hunks.is_empty(),
2487            "-b should still flag whitespace appearing where there was none"
2488        );
2489    }
2490
2491    #[test]
2492    fn ignore_space_change_ignores_differing_amounts() {
2493        // Both sides have whitespace at the same spot, just a different
2494        // amount — `-b` ignores this.
2495        let old = b"foo(a,   b)\n";
2496        let new = b"foo(a, b)\n";
2497        let hunks =
2498            unified_hunks_opts(old, new, PATCH_CONTEXT, WhitespaceMode::IgnoreSpaceChange).unwrap();
2499        assert!(
2500            hunks.is_empty(),
2501            "-b should ignore a pure whitespace-amount change: {hunks:?}"
2502        );
2503        // `-w` ignores it too (it ignores whitespace unconditionally).
2504        let ws_hunks =
2505            unified_hunks_opts(old, new, PATCH_CONTEXT, WhitespaceMode::IgnoreAllSpace).unwrap();
2506        assert!(ws_hunks.is_empty());
2507    }
2508
2509    #[test]
2510    fn whitespace_mode_never_changes_the_rendered_line_bytes() {
2511        // Even when `-w`/`-b` change *which* lines are considered part of a
2512        // hunk, any line that DOES render keeps its own real bytes. The
2513        // whitespace-only line sits far (beyond the context window) from
2514        // the real change, so under `-b` it is equal-and-uninvolved,
2515        // appearing in NEITHER hunk at all — not even as context.
2516        let old = b"foo(x,  y)\npad1\npad2\npad3\npad4\npad5\nCHANGED-OLD\npad6\n";
2517        let new = b"foo(x, y)\npad1\npad2\npad3\npad4\npad5\nCHANGED-NEW\npad6\n";
2518        let hunks =
2519            unified_hunks_opts(old, new, PATCH_CONTEXT, WhitespaceMode::IgnoreSpaceChange).unwrap();
2520        let text = String::from_utf8(hunks).unwrap();
2521        assert!(text.contains("-CHANGED-OLD\n"), "{text}");
2522        assert!(text.contains("+CHANGED-NEW\n"), "{text}");
2523        // The whitespace-only line is outside the context window of the
2524        // real change and compares equal under `-b`, so it never renders.
2525        assert!(!text.contains("foo(x"), "{text}");
2526    }
2527
2528    #[test]
2529    fn unified_hunks_opts_default_matches_unified_hunks() {
2530        let old = b"a\nb\nc\n";
2531        let new = b"a\nB\nc\n";
2532        assert_eq!(
2533            unified_hunks(old, new),
2534            unified_hunks_opts(old, new, PATCH_CONTEXT, WhitespaceMode::Exact)
2535        );
2536    }
2537
2538    // -----------------------------------------------------------------
2539    // unified_hunks_opts — context-line count (#712)
2540    // -----------------------------------------------------------------
2541
2542    fn ten_lines_changing_the_fifth() -> (Vec<u8>, Vec<u8>) {
2543        let lines: Vec<String> = (1..=10).map(|n| format!("l{n}")).collect();
2544        let mut old = lines.join("\n");
2545        old.push('\n');
2546        let mut changed = lines;
2547        changed[4] = "l5-changed".to_string();
2548        let mut new = changed.join("\n");
2549        new.push('\n');
2550        (old.into_bytes(), new.into_bytes())
2551    }
2552
2553    fn context_line_count(hunks: &[u8]) -> usize {
2554        String::from_utf8_lossy(hunks)
2555            .lines()
2556            .skip_while(|l| !l.starts_with("@@"))
2557            .skip(1)
2558            .filter(|l| l.starts_with(' '))
2559            .count()
2560    }
2561
2562    #[test]
2563    fn context_zero_shows_no_surrounding_lines() {
2564        let (old, new) = ten_lines_changing_the_fifth();
2565        let hunks = unified_hunks_opts(&old, &new, 0, WhitespaceMode::Exact).unwrap();
2566        assert_eq!(context_line_count(&hunks), 0);
2567        let text = String::from_utf8(hunks).unwrap();
2568        assert!(text.contains("-l5\n"), "{text}");
2569        assert!(text.contains("+l5-changed\n"), "{text}");
2570    }
2571
2572    #[test]
2573    fn context_one_shows_one_line_each_side() {
2574        let (old, new) = ten_lines_changing_the_fifth();
2575        let hunks = unified_hunks_opts(&old, &new, 1, WhitespaceMode::Exact).unwrap();
2576        assert_eq!(context_line_count(&hunks), 2);
2577    }
2578
2579    #[test]
2580    fn context_default_matches_three() {
2581        let (old, new) = ten_lines_changing_the_fifth();
2582        let hunks =
2583            unified_hunks_opts(&old, &new, DEFAULT_CONTEXT_LINES, WhitespaceMode::Exact).unwrap();
2584        assert_eq!(DEFAULT_CONTEXT_LINES, 3);
2585        assert_eq!(context_line_count(&hunks), 6);
2586    }
2587}