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