Skip to main content

mkit_cli/commands/
diff.rs

1//! `mkit diff` — show changes as a unified patch.
2//!
3//! Modes:
4//!
5//! - no args — HEAD tree vs a fresh worktree snapshot;
6//! - `--staged` / `--cached` — HEAD tree vs the staged index tree
7//!   (what `mkit commit` would record);
8//! - one revision (`<rev>`) — that revision's tree vs the worktree (or
9//!   vs the staged index with `--staged`);
10//! - two revisions (`<a> <b>`) or a range (`<a>..<b>`) — diff the two
11//!   resolved trees against each other.
12//!
13//! A leading positional that is not a resolvable revision is treated as
14//! the start of the pathspec list; a leading positional that *looks*
15//! like a revision (ref / commit / range) but fails to resolve is a
16//! hard error rather than a silent empty diff (#207).
17//!
18//! Trailing positional paths (pathspecs) filter the output to entries
19//! at or below those paths. The default output is a Git-compatible
20//! unified diff: a git-shaped `diff --git a/<p> b/<p>` header per changed
21//! path (with `new file mode`/`deleted file mode`/`index`/`--- a/p`/`+++ b/p`
22//! lines, `/dev/null` for adds/deletes) followed by Myers-diff hunks (or a
23//! `Binary files … differ` line). The `index` ids are abbreviated BLAKE3
24//! prefixes — the one inherent divergence from `git diff`.
25//!
26//! `--name-only` / `--name-status` switch to summary output: one record
27//! per changed path — just the path, or an `A`/`D`/`M` status letter
28//! (`T` for an mkit mode change) plus the path. Special-byte paths are
29//! C-style quoted (git `core.quotePath`); `-z` instead NUL-terminates
30//! records and emits raw paths (and, for `--name-status`, NUL-terminates
31//! the status letter and path as separate fields).
32//!
33//! `-w`/`--ignore-all-space` and `-b`/`--ignore-space-change` change
34//! which lines the hunk generator treats as equal (`-w` wins if both are
35//! given); `-U<n>`/`--unified=<n>` sets the number of unchanged context
36//! lines around each hunk (default 3). Neither affects the bytes of a
37//! line that does render — only which lines end up part of a hunk.
38
39use std::io::Write;
40
41use clap::Parser;
42use mkit_core::hash::Hash;
43use mkit_core::layout::RepoLayout;
44use mkit_core::object::{EntryMode, Object};
45use mkit_core::ops::merge::find_merge_base;
46use mkit_core::ops::{
47    DEFAULT_CONTEXT_LINES, DiffEntry, DiffKind, WhitespaceMode, detect_content_renames, diff_trees,
48    unified_hunks_opts,
49};
50use mkit_core::refs;
51use mkit_core::store::{DisplaySource, EphemeralSink, ObjectSource, ObjectStore};
52use mkit_core::worktree;
53
54use super::revspec;
55use crate::clap_shim;
56use crate::exit;
57use crate::format;
58
59mod stat;
60pub(super) use stat::render_stat;
61
62#[derive(Debug, Parser)]
63#[command(
64    name = "mkit diff",
65    about = "Show changes as a unified patch (HEAD vs worktree, --staged, or two trees)."
66)]
67#[allow(clippy::struct_excessive_bools)] // clap option flags, not a state machine
68struct DiffOpts {
69    /// Diff the staged index tree against HEAD (the change `mkit commit`
70    /// would record) instead of HEAD vs worktree.
71    #[arg(long, visible_alias = "cached")]
72    staged: bool,
73
74    /// Show only the names of changed files, one per line, instead of a
75    /// patch (like `git diff --name-only`).
76    #[arg(long, conflicts_with = "name_status")]
77    name_only: bool,
78
79    /// Show a status letter (`A`/`D`/`M`; `T` for an mkit mode change)
80    /// and the name of each changed file (like `git diff --name-status`).
81    #[arg(long)]
82    name_status: bool,
83
84    /// Show a diffstat: per-file changed-line counts and a `+`/`-` graph,
85    /// plus a summary line (like `git diff --stat`). Honors `COLUMNS`
86    /// (default 80) for the graph width.
87    #[arg(long, conflicts_with_all = ["name_only", "name_status"])]
88    stat: bool,
89
90    /// Diff against the merge base of the revisions, like `git diff
91    /// --merge-base`. With one revision: `merge-base(<rev>, HEAD)` vs the
92    /// worktree. With two: `merge-base(<a>, <b>)` vs `<b>`. (Equivalent to
93    /// the `<a>...<b>` symmetric range, but spelled as a flag.)
94    #[arg(long = "merge-base", conflicts_with = "staged")]
95    merge_base: bool,
96
97    /// NUL-terminate `--name-only` / `--name-status` records and emit raw
98    /// (unquoted) paths — like `git diff -z`. In `--name-status`, the
99    /// status letter and path are each NUL-terminated. Only valid with
100    /// `--name-only` / `--name-status`.
101    #[arg(short = 'z')]
102    z: bool,
103
104    /// Exit with 1 when there are differences, 0 when there are none (the
105    /// patch is still printed) — like `git diff --exit-code`. The CI
106    /// idiom for "fail if the tree changed".
107    #[arg(long = "exit-code")]
108    exit_code: bool,
109
110    /// Like `--exit-code` but print nothing (`git diff --quiet`).
111    #[arg(long)]
112    quiet: bool,
113
114    /// Turn off rename detection (on by default, like git). A move then
115    /// shows as a separate deletion and addition.
116    #[arg(long = "no-renames")]
117    no_renames: bool,
118
119    /// Detect renames, optionally with a similarity threshold (`-M`,
120    /// `--find-renames[=N]`). mkit pairs by identical content (exact,
121    /// 100%), so any threshold ≤ 100 selects the same matches.
122    #[arg(short = 'M', long = "find-renames", value_name = "N", num_args = 0..=1, default_missing_value = "100")]
123    find_renames: Option<String>,
124
125    /// Colorize the patch: `always`, `auto` (default, tty-only), or
126    /// `never` (like `git diff --color[=<when>]`). Honors `NO_COLOR` /
127    /// `CLICOLOR_FORCE` under `auto`.
128    #[arg(long = "color", value_name = "WHEN", num_args = 0..=1, require_equals = true, default_missing_value = "always", conflicts_with = "no_color")]
129    color: Option<String>,
130
131    /// Disable colorized output (`git diff --no-color`).
132    #[arg(long = "no-color")]
133    no_color: bool,
134
135    /// Ignore whitespace when comparing lines — like git's `-w` /
136    /// `--ignore-all-space`. A line that differs from its counterpart only
137    /// in whitespace is treated as unchanged context; the printed line
138    /// still shows its own real (unmodified) bytes. Takes precedence over
139    /// `-b` when both are given.
140    #[arg(short = 'w', long = "ignore-all-space")]
141    ignore_all_space: bool,
142
143    /// Ignore changes in the *amount* of whitespace — like git's `-b` /
144    /// `--ignore-space-change`. Runs of whitespace compare equal
145    /// regardless of length, but a line with whitespace where the other
146    /// side has none still differs (unlike `-w`).
147    #[arg(short = 'b', long = "ignore-space-change")]
148    ignore_space_change: bool,
149
150    /// Number of unchanged context lines shown around each hunk (default
151    /// 3) — like git's `-U<n>` / `--unified=<n>`.
152    #[arg(short = 'U', long = "unified", value_name = "N")]
153    unified: Option<usize>,
154
155    /// Optional revisions (refs, full/short hashes, `HEAD~n`, or an
156    /// `A..B` range) followed by optional pathspecs to limit the
157    /// output. With no revisions, diffs HEAD vs worktree (or HEAD vs
158    /// index with --staged). A leading argument that is not a resolvable
159    /// revision starts the pathspec list.
160    args: Vec<String>,
161}
162
163impl DiffOpts {
164    /// Resolve `-w`/`-b` into the single [`WhitespaceMode`] the hunk
165    /// renderer consumes. `-w` wins when both are given, matching git
166    /// (the more aggressive mode takes precedence rather than erroring).
167    fn whitespace_mode(&self) -> WhitespaceMode {
168        if self.ignore_all_space {
169            WhitespaceMode::IgnoreAllSpace
170        } else if self.ignore_space_change {
171            WhitespaceMode::IgnoreSpaceChange
172        } else {
173            WhitespaceMode::Exact
174        }
175    }
176}
177
178#[must_use]
179pub fn run(args: &[String]) -> u8 {
180    let opts = match clap_shim::parse::<DiffOpts>("mkit diff", args) {
181        Ok(o) => o,
182        Err(code) => return code,
183    };
184    // `-z` only governs the `--name-only` / `--name-status` record
185    // framing (per the parity matrix); it has no defined meaning for the
186    // unified-patch output yet, so reject it rather than silently ignore.
187    if opts.z && !(opts.name_only || opts.name_status) {
188        return emit_err(
189            "`-z` is only valid with `--name-only` or `--name-status`",
190            exit::USAGE,
191        );
192    }
193    let Some(color_choice) = crate::term::ColorChoice::parse(opts.color.as_deref()) else {
194        return emit_err("--color expects always, auto, or never", exit::USAGE);
195    };
196    let use_color = !opts.no_color
197        && color_choice.resolve(std::io::IsTerminal::is_terminal(&std::io::stdout()));
198    let ws_mode = opts.whitespace_mode();
199    let context = opts.unified.unwrap_or(DEFAULT_CONTEXT_LINES);
200    let cwd = match std::env::current_dir() {
201        Ok(p) => p,
202        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
203    };
204    let layout = match super::resolve_layout(&cwd) {
205        Ok(layout) => layout,
206        Err(code) => return code,
207    };
208    let store = match ObjectStore::open(&layout) {
209        Ok(s) => s,
210        Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
211    };
212
213    // Worktree/index snapshot trees are ephemeral: they live in this
214    // in-memory overlay, never in the durable store — no flush cost,
215    // no garbage objects. Reads fall through to the store.
216    let snapshot = EphemeralSink::new(&store);
217
218    let (old_tree, new_tree, pathspecs) = match resolve_diff_endpoints(
219        &store,
220        &snapshot,
221        &layout,
222        opts.staged,
223        opts.merge_base,
224        &opts.args,
225    ) {
226        Ok(v) => v,
227        Err((msg, code)) => return emit_err(&msg, code),
228    };
229
230    let mut result = match diff_trees(&snapshot, old_tree, new_tree) {
231        Ok(r) => r,
232        Err(e) => return emit_err(&format!("diff: {e}"), exit::GENERAL_ERROR),
233    };
234
235    // Rename detection (on by default, like git's `diff.renames`): collapse
236    // identical-content delete/add pairs into `R` entries before filtering
237    // and rendering. A provided threshold must parse, but the exact matcher
238    // ignores its magnitude.
239    if let Some(t) = &opts.find_renames {
240        let n = t.trim_end_matches('%');
241        if !n.is_empty() && n.parse::<u8>().is_err() {
242            return emit_err(&format!("invalid --find-renames value: {t}"), exit::USAGE);
243        }
244    }
245    if !opts.no_renames
246        && let Err(e) = detect_content_renames(&snapshot, &mut result.entries)
247    {
248        return emit_err(&format!("rename detection: {e}"), exit::GENERAL_ERROR);
249    }
250
251    let normalized: Vec<String> = pathspecs.iter().map(|p| normalize_pathspec(p)).collect();
252    let selected: Vec<&mkit_core::ops::DiffEntry> = result
253        .entries
254        .iter()
255        .filter(|e| normalized.is_empty() || path_matches_any(&e.path, &normalized))
256        .collect();
257
258    // `--exit-code`/`--quiet` report difference via the exit status (1 =
259    // changed, 0 = clean). `--quiet` additionally suppresses output.
260    let report_exit = opts.exit_code || opts.quiet;
261    let diff_status = if report_exit && !selected.is_empty() {
262        exit::GENERAL_ERROR
263    } else {
264        exit::OK
265    };
266    if opts.quiet {
267        return diff_status;
268    }
269
270    let mut stdout = std::io::stdout().lock();
271    if opts.stat {
272        // `render_stat` hoists its own `DisplaySource` wrapping (#625).
273        return match render_stat(&mut stdout, &snapshot, selected.into_iter()) {
274            Ok(()) => diff_status,
275            Err(msg) => emit_err(&msg, exit::GENERAL_ERROR),
276        };
277    }
278    // The patch paths below print what they render here — nothing durable
279    // is published from this path — so skip the BLAKE3 re-verify on every
280    // changed blob (#625).
281    let display = DisplaySource::new(&snapshot);
282    for e in selected {
283        let res = if opts.name_only || opts.name_status {
284            emit_entry_name(&mut stdout, e, opts.name_status, opts.z);
285            Ok(())
286        } else if use_color {
287            // Render the entry to a buffer, then colorize line-by-line so
288            // the byte-exact patch machinery stays color-agnostic.
289            let mut buf: Vec<u8> = Vec::new();
290            match emit_entry_patch(&mut buf, &display, e, context, ws_mode) {
291                // Colorize on RAW BYTES (not via from_utf8_lossy) so a
292                // non-UTF-8 patch body round-trips byte-for-byte, matching
293                // the uncolored path.
294                Ok(()) => stdout
295                    .write_all(&colorize_patch(&buf))
296                    .map_err(|err| format!("write: {err}")),
297                Err(msg) => Err(msg),
298            }
299        } else {
300            emit_entry_patch(&mut stdout, &display, e, context, ws_mode)
301        };
302        if let Err(msg) = res {
303            return emit_err(&msg, exit::GENERAL_ERROR);
304        }
305    }
306    diff_status
307}
308
309/// ANSI-colorize a unified-diff patch line-by-line, matching git's default
310/// palette: metadata bold, hunk headers cyan, additions green, deletions
311/// red. Context lines are left uncolored. Operates on raw bytes so a
312/// non-UTF-8 patch body round-trips unchanged (only ASCII line prefixes
313/// drive the coloring).
314fn colorize_patch(text: &[u8]) -> Vec<u8> {
315    const RESET: &[u8] = b"\x1b[0m";
316    let mut out: Vec<u8> = Vec::with_capacity(text.len() + 64);
317    for line in text.split_inclusive(|&b| b == b'\n') {
318        let (body, nl): (&[u8], &[u8]) = if line.last() == Some(&b'\n') {
319            (&line[..line.len() - 1], b"\n")
320        } else {
321            (line, b"")
322        };
323        let code: Option<&[u8]> = if body.starts_with(b"@@") {
324            Some(b"\x1b[36m") // hunk header: cyan
325        } else if body.starts_with(b"diff ")
326            || body.starts_with(b"index ")
327            || body.starts_with(b"new file")
328            || body.starts_with(b"deleted file")
329            || body.starts_with(b"old mode")
330            || body.starts_with(b"new mode")
331            || body.starts_with(b"rename ")
332            || body.starts_with(b"similarity ")
333            || body.starts_with(b"--- ")
334            || body.starts_with(b"+++ ")
335        {
336            Some(b"\x1b[1m") // metadata: bold
337        } else if body.first() == Some(&b'+') {
338            Some(b"\x1b[32m") // addition: green
339        } else if body.first() == Some(&b'-') {
340            Some(b"\x1b[31m") // deletion: red
341        } else {
342            None
343        };
344        match code {
345            Some(c) => {
346                out.extend_from_slice(c);
347                out.extend_from_slice(body);
348                out.extend_from_slice(RESET);
349                out.extend_from_slice(nl);
350            }
351            None => out.extend_from_slice(line),
352        }
353    }
354    out
355}
356
357/// Display name for stat/summary rows: C-style quoted like git's default
358/// `core.quotePath` when the path has special bytes, else the raw path.
359fn c_quote_name(path: &str) -> String {
360    super::c_quote_path(path).unwrap_or_else(|| path.to_string())
361}
362
363/// Status letter for `--name-status`. mkit's `ModeChanged` maps to `T`
364/// (git's type-change letter) — a documented mkit extension, since mkit
365/// tracks a pure mode flip as its own diff kind.
366fn name_status_letter(kind: DiffKind) -> char {
367    match kind {
368        DiffKind::Added => 'A',
369        DiffKind::Removed => 'D',
370        DiffKind::Modified => 'M',
371        DiffKind::ModeChanged => 'T',
372        // Renames carry a similarity score (`R100`) and two paths, so
373        // `--name-status` formats them specially in `emit_entry_name`;
374        // this bare letter is the fallback / name-only case.
375        DiffKind::Renamed => 'R',
376    }
377}
378
379/// Emit one `--name-only` / `--name-status` record for a changed entry.
380///
381/// Newline mode: `<path>\n` (name-only) or `<letter>\t<path>\n`
382/// (name-status); a path with special bytes is C-style quoted like git's
383/// default `core.quotePath`. `-z` mode: paths are raw (unquoted) and
384/// records are NUL-terminated — `<path>\0`, or `<letter>\0<path>\0` where
385/// the status letter and path are each their own NUL-terminated field
386/// (matching `git diff --name-status -z`).
387fn emit_entry_name(out: &mut impl Write, e: &DiffEntry, name_status: bool, z: bool) {
388    // `--name-status` rename: git emits `R100<sep><src><sep><dst>` (source
389    // first, unlike status's porcelain `-z`), TAB-separated by default and
390    // NUL-separated under `-z`. Verified against git.
391    if name_status && e.kind == DiffKind::Renamed {
392        let src = e.old_path.as_deref().unwrap_or(&e.path);
393        if z {
394            let _ = write!(out, "R100\0{src}\0{}\0", e.path);
395        } else {
396            let sq = super::c_quote_path(src).unwrap_or_else(|| src.to_string());
397            let dq = super::c_quote_path(&e.path).unwrap_or_else(|| e.path.clone());
398            let _ = writeln!(out, "R100\t{sq}\t{dq}");
399        }
400        return;
401    }
402    if z {
403        if name_status {
404            let _ = write!(out, "{}\0", name_status_letter(e.kind));
405        }
406        let _ = write!(out, "{}\0", e.path);
407        return;
408    }
409    let path = super::c_quote_path(&e.path);
410    let shown = path.as_deref().unwrap_or(&e.path);
411    if name_status {
412        let _ = writeln!(out, "{}\t{shown}", name_status_letter(e.kind));
413    } else {
414        let _ = writeln!(out, "{shown}");
415    }
416}
417
418/// `(old_tree, new_tree, pathspecs)` triple computed from the args.
419type DiffEndpoints = (Option<Hash>, Option<Hash>, Vec<String>);
420
421/// Decide the `old_tree` / `new_tree` / pathspecs triple from the
422/// `staged` flag and the positional args. Returns `(message, exit_code)`
423/// on error so the caller can route it through `emit_err`.
424///
425/// Cases:
426/// - `--staged <rev>...` (any positionals) — usage contradiction
427///   (#223): `--staged` already fixes both endpoints (HEAD vs index).
428/// - `<a>..<b> [paths…]` — range form; both ends resolved to trees.
429/// - `<a> <b> [paths…]` — two revisions, when both resolve.
430/// - `<a> [paths…]` — one revision vs worktree (or vs index w/--staged
431///   only in the no-positional case, handled above).
432/// - no leading revision — default HEAD-vs-worktree / HEAD-vs-index,
433///   all positionals are pathspecs.
434/// `--merge-base` endpoint resolution. One revision: `merge-base(rev,
435/// HEAD)` vs the worktree; two revisions: `merge-base(a, b)` vs `b`.
436/// Trailing positionals are pathspecs. Annotated tags are peeled to their
437/// commit before the merge-base walk, like git.
438fn resolve_merge_base_endpoints(
439    store: &ObjectStore,
440    snapshot: &EphemeralSink<'_>,
441    layout: &RepoLayout,
442    args: &[String],
443) -> Result<DiffEndpoints, (String, u8)> {
444    let first = args.first().ok_or_else(|| {
445        (
446            "`--merge-base` requires at least one revision".to_string(),
447            exit::USAGE,
448        )
449    })?;
450    let a = peel_tags(
451        store,
452        revspec::resolve_revision(store, layout, first)
453            .map_err(|e| (format!("bad revision '{first}': {e}"), exit::DATAERR))?,
454    );
455
456    // A second positional that resolves to a revision selects the
457    // two-revision form. One that only *looks* like a revision but fails to
458    // resolve is a hard error (#207); anything else is a pathspec, leaving
459    // the single-revision (vs worktree) form.
460    if let Some(second) = args.get(1) {
461        match revspec::resolve_revision(store, layout, second) {
462            Ok(h) => {
463                let b = peel_tags(store, h);
464                let base = merge_base_of(store, a, b)?;
465                let old = object_to_tree(store, &base).map_err(|e| (e, exit::GENERAL_ERROR))?;
466                let new = object_to_tree(store, &b).map_err(|e| (e, exit::GENERAL_ERROR))?;
467                return Ok((Some(old), Some(new), args[2..].to_vec()));
468            }
469            // A 2nd positional that fails to resolve is treated as a pathspec
470            // ONLY when it is clearly path-shaped (names an existing worktree
471            // path, a tracked path, or contains `/`). Otherwise it is an
472            // ambiguous bad revision — a typo'd `<b>` — which we surface,
473            // rather than silently falling back to the single-rev form and
474            // emitting an empty diff (matching git's "ambiguous argument").
475            Err(e)
476                if matches!(e, revspec::RevError::Unknown(_))
477                    && looks_like_pathspec(layout, second) => {}
478            Err(e) => return Err((format!("bad revision '{second}': {e}"), exit::DATAERR)),
479        }
480    }
481
482    // Single revision: merge-base(rev, HEAD) vs the worktree.
483    let head = refs::resolve_head(layout)
484        .map_err(|e| (format!("resolve HEAD: {e}"), exit::GENERAL_ERROR))?
485        .ok_or_else(|| {
486            (
487                "HEAD has no commit to take a merge base with".to_string(),
488                exit::GENERAL_ERROR,
489            )
490        })?;
491    let head = peel_tags(store, head);
492    let base = merge_base_of(store, a, head)?;
493    let old = object_to_tree(store, &base).map_err(|e| (e, exit::GENERAL_ERROR))?;
494    let new = worktree_tree_filtered(store, snapshot, layout)?;
495    Ok((Some(old), Some(new), args[1..].to_vec()))
496}
497
498/// Resolve the single merge base of `a` and `b`, mapping "no base" to a
499/// clear error (matches git's `--merge-base` failure on unrelated histories).
500fn merge_base_of(store: &ObjectStore, a: Hash, b: Hash) -> Result<Hash, (String, u8)> {
501    find_merge_base(store, a, b)
502        .map_err(|e| (format!("merge base: {e}"), exit::GENERAL_ERROR))?
503        .ok_or_else(|| {
504            (
505                "no merge base between the given revisions".to_string(),
506                exit::DATAERR,
507            )
508        })
509}
510
511#[allow(clippy::too_many_arguments)]
512fn resolve_diff_endpoints(
513    store: &ObjectStore,
514    snapshot: &EphemeralSink<'_>,
515    layout: &RepoLayout,
516    staged: bool,
517    merge_base: bool,
518    args: &[String],
519) -> Result<DiffEndpoints, (String, u8)> {
520    // `--merge-base <a> [<b>] [paths…]` — diff the merge base of the given
521    // revision(s) rather than the revisions themselves. Resolved before
522    // any other form (clap already rejects `--merge-base --staged`).
523    if merge_base {
524        return resolve_merge_base_endpoints(store, snapshot, layout, args);
525    }
526
527    // #223: `--staged` with explicit revisions is contradictory —
528    // `--staged` already pins HEAD vs the index. Pathspecs are fine, but
529    // a leading argument that *looks* like a revision is not, and must
530    // fail closed: if it resolves it is the contradiction (#223), and if
531    // it does not it is a bad revision (#207). Either way we error rather
532    // than silently treating a typo'd hash as a no-match pathspec (which
533    // would empty-succeed and diverge from `git diff --cached <bad-rev>`).
534    // A non-rev-looking leading arg (e.g. `path/`, `file.txt`) still falls
535    // through as a pathspec filter.
536    if staged {
537        if let Some(first) = args.first()
538            && looks_like_rev_request(first)
539        {
540            if revspec::resolve_revision(store, layout, strip_range_end(first).0).is_ok() {
541                return Err((
542                    "`--staged` diffs HEAD vs the index; it cannot take an explicit revision"
543                        .to_string(),
544                    exit::USAGE,
545                ));
546            }
547            return Err((
548                format!("bad revision '{first}': not a known ref, commit, or short hash"),
549                exit::DATAERR,
550            ));
551        }
552        // No leading revision: HEAD vs index, all positionals = pathspecs.
553        let head = head_tree(store, layout).map_err(|e| (e, exit::GENERAL_ERROR))?;
554        let idx = index_tree(layout, store, snapshot).map_err(|e| (e, exit::GENERAL_ERROR))?;
555        return Ok((head, idx, args.to_vec()));
556    }
557
558    // Symmetric range `A...B` = diff the merge base of A and B against B
559    // (git semantics). Must be checked before `A..B` (which it contains).
560    if let Some(first) = args.first()
561        && let Some((a, b)) = split_symmetric(first)
562    {
563        // Peel annotated/signed tags to their commit before merge-base
564        // resolution, like git (and like `log` does for its range bases).
565        let commit_a = peel_tags(
566            store,
567            revspec::resolve_revision(store, layout, a)
568                .map_err(|e| (format!("bad revision '{a}': {e}"), exit::DATAERR))?,
569        );
570        let commit_b = peel_tags(
571            store,
572            revspec::resolve_revision(store, layout, b)
573                .map_err(|e| (format!("bad revision '{b}': {e}"), exit::DATAERR))?,
574        );
575        let mb = find_merge_base(store, commit_a, commit_b)
576            .map_err(|e| (format!("merge base: {e}"), exit::GENERAL_ERROR))?
577            .ok_or_else(|| {
578                (
579                    format!("no merge base between '{a}' and '{b}'"),
580                    exit::DATAERR,
581                )
582            })?;
583        let old = object_to_tree(store, &mb).map_err(|e| (e, exit::GENERAL_ERROR))?;
584        let new = object_to_tree(store, &commit_b).map_err(|e| (e, exit::GENERAL_ERROR))?;
585        return Ok((Some(old), Some(new), args[1..].to_vec()));
586    }
587
588    // Range form `A..B` as the first positional.
589    if let Some(first) = args.first()
590        && let Some((a, b)) = split_range(first)
591    {
592        let old = rev_to_tree(store, layout, a)?;
593        let new = rev_to_tree(store, layout, b)?;
594        return Ok((Some(old), Some(new), args[1..].to_vec()));
595    }
596
597    // Try to peel one or two leading revisions.
598    let first_rev = args.first().and_then(|a| try_rev_to_tree(store, layout, a));
599    match first_rev {
600        None => {
601            // No leading revision → default HEAD vs worktree; all
602            // positionals are pathspecs. If the first arg *looked* like
603            // a revision but failed to resolve, error loudly (#207)
604            // rather than silently treating it as a pathspec.
605            if let Some(first) = args.first()
606                && looks_like_rev_request(first)
607            {
608                return Err((
609                    format!("bad revision '{first}': not a known ref, commit, or short hash"),
610                    exit::DATAERR,
611                ));
612            }
613            let head = head_tree(store, layout).map_err(|e| (e, exit::GENERAL_ERROR))?;
614            let new = Some(worktree_tree_filtered(store, snapshot, layout)?);
615            Ok((head, new, args.to_vec()))
616        }
617        Some(Err(e)) => Err(e),
618        Some(Ok(old)) => {
619            // One revision resolved. Is the second positional also a
620            // revision? If so, two-rev mode; otherwise rev-vs-worktree.
621            let second_rev = args.get(1).and_then(|a| try_rev_to_tree(store, layout, a));
622            match second_rev {
623                Some(Ok(new)) => Ok((Some(old), Some(new), args[2..].to_vec())),
624                Some(Err(e)) => Err(e),
625                None => {
626                    let new = Some(worktree_tree_filtered(store, snapshot, layout)?);
627                    Ok((Some(old), new, args[1..].to_vec()))
628                }
629            }
630        }
631    }
632}
633
634/// Resolve a revision spec to a tree hash, mapping a commit/remix to its
635/// tree and accepting a bare tree hash as itself. `(message, code)` on
636/// failure.
637/// Snapshot the worktree, seeding the tracked set from the index (or HEAD
638/// when no index file exists) so a tracked file matching an ignore rule is
639/// not dropped from the snapshot and misreported as a deletion.
640fn worktree_tree_filtered(
641    store: &ObjectStore,
642    snapshot: &EphemeralSink<'_>,
643    layout: &RepoLayout,
644) -> Result<Hash, (String, u8)> {
645    let idx =
646        super::read_or_seed_index_from_head(layout, store).map_err(|e| (e, exit::GENERAL_ERROR))?;
647    worktree::build_tree_filtered_observed_with_source(
648        snapshot,
649        snapshot,
650        layout.worktree_root(),
651        Some(&idx),
652        &mut Vec::new(),
653    )
654    .map_err(|e| (format!("build tree: {e}"), exit::GENERAL_ERROR))
655}
656
657fn rev_to_tree(store: &ObjectStore, layout: &RepoLayout, spec: &str) -> Result<Hash, (String, u8)> {
658    let h = revspec::resolve_revision(store, layout, spec)
659        .map_err(|e| (format!("bad revision '{spec}': {e}"), exit::DATAERR))?;
660    object_to_tree(store, &h).map_err(|e| (e, exit::GENERAL_ERROR))
661}
662
663/// Like [`rev_to_tree`] but distinguishes "not a revision at all" (None)
664/// from "looks like a revision but is broken" (`Some(Err(..))`).
665fn try_rev_to_tree(
666    store: &ObjectStore,
667    layout: &RepoLayout,
668    spec: &str,
669) -> Option<Result<Hash, (String, u8)>> {
670    match revspec::resolve_revision(store, layout, spec) {
671        Ok(h) => Some(object_to_tree(store, &h).map_err(|e| (e, exit::GENERAL_ERROR))),
672        Err(revspec::RevError::Unknown(_)) => {
673            // Not a known ref/object. If it still *looks* like a
674            // revision request (ref-shaped or hash-shaped), surface the
675            // failure; otherwise it is a pathspec.
676            if looks_like_rev_request(spec) {
677                Some(Err((
678                    format!("bad revision '{spec}': not a known ref, commit, or short hash"),
679                    exit::DATAERR,
680                )))
681            } else {
682                None
683            }
684        }
685        Err(e) => Some(Err((format!("bad revision '{spec}': {e}"), exit::DATAERR))),
686    }
687}
688
689/// Follow `Object::Tag` targets to the first non-tag object, so an
690/// annotated/signed tag resolves to the commit it points at. Delegates to
691/// the shared `log::peel_tags` (kept as a local alias for the call sites).
692fn peel_tags(store: &ObjectStore, h: Hash) -> Hash {
693    super::log::peel_tags(store, h)
694}
695
696/// Map a resolved object hash to a tree hash: commit/remix → its tree,
697/// a tree → itself.
698pub(super) fn object_to_tree(store: &ObjectStore, h: &Hash) -> Result<Hash, String> {
699    match store.read_object(h) {
700        Ok(Object::Commit(c)) => Ok(c.tree_hash),
701        Ok(Object::Remix(r)) => Ok(r.tree_hash),
702        Ok(Object::Tree(_)) => Ok(*h),
703        Ok(_) => Err(format!(
704            "{} is not a commit, remix, or tree",
705            mkit_core::hash::to_hex(h)
706        )),
707        Err(e) => Err(read_err(e)),
708    }
709}
710
711/// Split an `A..B` range. Returns `None` if there is no `..`.
712fn split_range(s: &str) -> Option<(&str, &str)> {
713    let (a, b) = s.split_once("..")?;
714    if a.is_empty() || b.is_empty() {
715        return None;
716    }
717    Some((a, b))
718}
719
720/// Split a symmetric `A...B` range. An empty side defaults to `HEAD`
721/// (`A...` = `A...HEAD`, `...B` = `HEAD...B`).
722fn split_symmetric(s: &str) -> Option<(&str, &str)> {
723    let (a, b) = s.split_once("...")?;
724    Some((
725        if a.is_empty() { "HEAD" } else { a },
726        if b.is_empty() { "HEAD" } else { b },
727    ))
728}
729
730/// The left-hand end of a possible range, used for the `--staged`
731/// contradiction probe. Returns `(rev, is_range)`.
732fn strip_range_end(s: &str) -> (&str, bool) {
733    match s.split_once("..") {
734        Some((a, _)) if !a.is_empty() => (a, true),
735        _ => (s, false),
736    }
737}
738
739/// Heuristic for #207: does this argument look like the user *intended*
740/// a revision (so a resolve failure should be a hard error) rather than
741/// a pathspec? True for hash-shaped tokens, `A..B` ranges, and the
742/// literal `HEAD` (possibly with `~`/`^` navigation). A plain
743/// filesystem-y token (`src/`, `./x`, `*.rs`) is treated as a pathspec.
744fn looks_like_rev_request(s: &str) -> bool {
745    if s.contains("..") {
746        return true;
747    }
748    // A `~` or `^` navigation suffix is revision syntax, not a path.
749    let base = s.split(['~', '^']).next().unwrap_or(s);
750    if base == "HEAD" {
751        return true;
752    }
753    // Hash-shaped: ≥ MIN_SHORT_HASH hex chars with no path separators.
754    base.len() >= revspec::MIN_SHORT_HASH
755        && !base.contains('/')
756        && !base.contains('.')
757        && base.bytes().all(|b| b.is_ascii_hexdigit())
758}
759
760/// Is `arg` clearly a pathspec rather than a (typo'd) revision? True when it
761/// names an existing worktree path OR matches a tracked index path (a file/dir
762/// tracked but deleted from the worktree is still a valid pathspec, as in
763/// git). A bare `/` is NOT enough — branch names routinely contain `/` (e.g.
764/// `feature/x`), so a typo'd branch like `feature/typo` must surface as a bad
765/// revision rather than silently degrade into an empty-output pathspec filter.
766fn looks_like_pathspec(layout: &RepoLayout, arg: &str) -> bool {
767    if layout.worktree_root().join(arg).symlink_metadata().is_ok() {
768        return true;
769    }
770    // Normalize the spec the same way the path filter will (e.g. `./a.txt` ->
771    // `a.txt`) before matching the index, so a tracked-but-deleted file passed
772    // as `./a.txt` isn't misread as a bad revision.
773    let spec = normalize_pathspec(arg);
774    let Ok(idx) = mkit_core::index::read_index(layout) else {
775        return false;
776    };
777    let prefix = format!("{spec}/");
778    idx.entries
779        .iter()
780        .any(|e| e.path == spec || e.path.starts_with(&prefix))
781}
782
783fn head_tree(store: &ObjectStore, layout: &RepoLayout) -> Result<Option<Hash>, String> {
784    let head = refs::resolve_head(layout).map_err(|e| format!("resolve HEAD: {e}"))?;
785    match head {
786        None => Ok(None),
787        Some(h) => match store.read_object(&h) {
788            Ok(Object::Commit(c)) => Ok(Some(c.tree_hash)),
789            Ok(Object::Remix(r)) => Ok(Some(r.tree_hash)),
790            Ok(_) => Ok(None),
791            Err(e) => Err(format!("read HEAD: {e}")),
792        },
793    }
794}
795
796fn index_tree(
797    layout: &RepoLayout,
798    store: &ObjectStore,
799    snapshot: &EphemeralSink<'_>,
800) -> Result<Option<Hash>, String> {
801    let idx = super::read_or_seed_index_from_head(layout, store)?;
802    // Ephemeral diff snapshot — nothing durable is published, so skip the
803    // re-hash; the read path verifies any object actually touched.
804    let tree = worktree::build_tree_from_index_with(store, snapshot, &idx, false)
805        .map_err(|e| format!("build index tree: {e}"))?;
806    Ok(Some(tree))
807}
808
809/// Normalize a pathspec to the index/diff path form: strip a leading
810/// `./`, collapse `\\` to `/`, drop a trailing `/`. The repo root in any
811/// spelling (`.`, `./`, `/`, or empty) normalizes to the empty string, which
812/// [`path_matches_any`] treats as "match everything" (matching git, where
813/// `diff -- .` is the whole-tree diff).
814fn normalize_pathspec(spec: &str) -> String {
815    let s = spec.replace('\\', "/");
816    let s = s.strip_prefix("./").unwrap_or(&s);
817    let s = s.strip_suffix('/').unwrap_or(s);
818    if s == "." {
819        String::new()
820    } else {
821        s.to_string()
822    }
823}
824
825fn path_matches_any(path: &str, specs: &[String]) -> bool {
826    specs
827        .iter()
828        // An empty spec is the repo root (`.`/`./`) → matches every path.
829        .any(|spec| spec.is_empty() || super::index_path_matches_or_descends(path, spec))
830}
831
832/// Abbreviated all-zero blob id git prints for an absent side of `index`.
833const ZERO_ABBREV: &str = "0000000";
834
835/// git octal mode string for a [`DiffEntry`] side (`None` → regular file).
836fn git_octal(mode: Option<EntryMode>) -> &'static str {
837    match mode {
838        Some(EntryMode::Executable) => "100755",
839        Some(EntryMode::Symlink) => "120000",
840        Some(EntryMode::Tree) => "040000",
841        _ => "100644",
842    }
843}
844
845/// Abbreviated blob id for an `index` line side (`None` → all-zero).
846fn abbrev(h: Option<Hash>) -> String {
847    h.map_or_else(|| ZERO_ABBREV.to_string(), |h| format::short_hash(&h, 7))
848}
849
850/// Emit a git-shaped `diff --git` header plus unified-diff hunks for one
851/// changed entry. The `index <old>..<new>` ids are abbreviated BLAKE3
852/// prefixes (longer than git's SHA-1 prefixes for the same `core.abbrev`),
853/// the one inherent divergence; everything else matches `git diff`.
854///
855/// Shared with `mkit show`, so a commit's diff body is byte-identical to
856/// `mkit diff <parent> <commit>` when both use the default `context`/`ws`
857/// (git's `-U3`, exact comparison).
858///
859/// `context` is the `-U<n>` unchanged-context-line count and `ws` is the
860/// `-w`/`-b` whitespace-comparison mode; pass
861/// [`mkit_core::ops::DEFAULT_CONTEXT_LINES`] / [`WhitespaceMode::Exact`]
862/// for git's defaults.
863pub(super) fn emit_entry_patch<S: ObjectSource + ?Sized>(
864    out: &mut impl Write,
865    store: &S,
866    e: &DiffEntry,
867    context: usize,
868    ws: WhitespaceMode,
869) -> Result<(), String> {
870    // git C-style quotes special-byte paths in the header (core.quotePath),
871    // quoting the whole `a/<path>` / `b/<path>` token as a unit. For a
872    // rename the `a/` side is the source path, the `b/` side the dest.
873    let a_src = if e.kind == DiffKind::Renamed {
874        e.old_path.as_deref().unwrap_or(&e.path)
875    } else {
876        e.path.as_str()
877    };
878    let a_path = quoted_side('a', a_src);
879    let b_path = quoted_side('b', &e.path);
880    let _ = writeln!(out, "diff --git {a_path} {b_path}");
881
882    match e.kind {
883        DiffKind::Renamed => {
884            // Exact rename: identical content, so 100% similar and no hunk.
885            let from = super::c_quote_path(a_src).unwrap_or_else(|| a_src.to_string());
886            let to = super::c_quote_path(&e.path).unwrap_or_else(|| e.path.clone());
887            let _ = writeln!(out, "similarity index 100%");
888            let _ = writeln!(out, "rename from {from}");
889            let _ = writeln!(out, "rename to {to}");
890            return Ok(());
891        }
892        DiffKind::ModeChanged => {
893            // Identical content, mode flip — only the mode lines, no hunks.
894            let _ = writeln!(out, "old mode {}", git_octal(e.old_mode));
895            let _ = writeln!(out, "new mode {}", git_octal(e.new_mode));
896            return Ok(());
897        }
898        DiffKind::Added => {
899            let _ = writeln!(out, "new file mode {}", git_octal(e.new_mode));
900            let _ = writeln!(out, "index {}..{}", ZERO_ABBREV, abbrev(e.new_hash));
901        }
902        DiffKind::Removed => {
903            let _ = writeln!(out, "deleted file mode {}", git_octal(e.old_mode));
904            let _ = writeln!(out, "index {}..{}", abbrev(e.old_hash), ZERO_ABBREV);
905        }
906        DiffKind::Modified if e.old_mode != e.new_mode => {
907            // Content and mode both changed: mode lines, index without mode.
908            let _ = writeln!(out, "old mode {}", git_octal(e.old_mode));
909            let _ = writeln!(out, "new mode {}", git_octal(e.new_mode));
910            let _ = writeln!(out, "index {}..{}", abbrev(e.old_hash), abbrev(e.new_hash));
911        }
912        DiffKind::Modified => {
913            let _ = writeln!(
914                out,
915                "index {}..{} {}",
916                abbrev(e.old_hash),
917                abbrev(e.new_hash),
918                git_octal(e.new_mode)
919            );
920        }
921    }
922
923    let old_bytes = match e.old_hash {
924        Some(h) => read_blob(store, &h)?,
925        None => Vec::new(),
926    };
927    let new_bytes = match e.new_hash {
928        Some(h) => read_blob(store, &h)?,
929        None => Vec::new(),
930    };
931    // `--- a/p` / `+++ b/p` (quoted), with `/dev/null` for the absent side.
932    let (minus, plus) = match e.kind {
933        DiffKind::Added => ("/dev/null".to_string(), b_path.clone()),
934        DiffKind::Removed => (a_path.clone(), "/dev/null".to_string()),
935        _ => (a_path.clone(), b_path.clone()),
936    };
937    match unified_hunks_opts(&old_bytes, &new_bytes, context, ws) {
938        None => {
939            let _ = writeln!(out, "Binary files {minus} and {plus} differ");
940        }
941        Some(hunks) if hunks.is_empty() => {}
942        Some(hunks) => {
943            let _ = writeln!(out, "--- {minus}");
944            let _ = writeln!(out, "+++ {plus}");
945            let _ = out.write_all(&hunks);
946        }
947    }
948    Ok(())
949}
950
951/// The git-quoted `a/<path>` / `b/<path>` token for a patch header: C-style
952/// quoted (with surrounding quotes) when the path has special bytes, else the
953/// plain `<side>/<path>`.
954fn quoted_side(side: char, path: &str) -> String {
955    let s = format!("{side}/{path}");
956    super::c_quote_path(&s).unwrap_or(s)
957}
958
959/// Read a blob's bytes from the store, reassembling chunked blobs via
960/// the shared core helper so diff/cat/checkout agree (#203).
961fn read_blob<S: ObjectSource + ?Sized>(store: &S, h: &Hash) -> Result<Vec<u8>, String> {
962    worktree::read_blob(store, h).map_err(read_err)
963}
964
965/// The one place the CLI's "read object: …" error wording is defined.
966fn read_err<E: std::fmt::Display>(e: E) -> String {
967    format!("read object: {e}")
968}
969
970use super::error as emit_err;
971
972#[cfg(test)]
973mod tests {
974    use super::*;
975
976    fn de(path: &str, kind: DiffKind) -> DiffEntry {
977        DiffEntry {
978            path: path.to_string(),
979            kind,
980            old_hash: None,
981            new_hash: None,
982            old_mode: None,
983            new_mode: None,
984            old_path: None,
985        }
986    }
987
988    fn render(e: &DiffEntry, name_status: bool, z: bool) -> String {
989        let mut buf = Vec::new();
990        emit_entry_name(&mut buf, e, name_status, z);
991        String::from_utf8(buf).unwrap()
992    }
993
994    #[test]
995    fn name_status_letters_cover_every_kind() {
996        assert_eq!(name_status_letter(DiffKind::Added), 'A');
997        assert_eq!(name_status_letter(DiffKind::Removed), 'D');
998        assert_eq!(name_status_letter(DiffKind::Modified), 'M');
999        assert_eq!(name_status_letter(DiffKind::ModeChanged), 'T');
1000    }
1001    #[test]
1002    fn name_only_newline_plain_path() {
1003        assert_eq!(
1004            render(&de("a.txt", DiffKind::Modified), false, false),
1005            "a.txt\n"
1006        );
1007    }
1008
1009    #[test]
1010    fn name_status_newline_is_letter_tab_path() {
1011        assert_eq!(
1012            render(&de("a.txt", DiffKind::Added), true, false),
1013            "A\ta.txt\n"
1014        );
1015    }
1016
1017    #[test]
1018    fn name_only_quotes_special_path_in_newline_mode() {
1019        // A tab is C-style quoted like git core.quotePath.
1020        assert_eq!(
1021            render(&de("a\tb.txt", DiffKind::Modified), false, false),
1022            "\"a\\tb.txt\"\n"
1023        );
1024    }
1025
1026    #[test]
1027    fn z_mode_is_raw_and_nul_terminated() {
1028        // name-only -z: `<path>\0`, path emitted raw (unquoted).
1029        assert_eq!(
1030            render(&de("a\tb.txt", DiffKind::Modified), false, true),
1031            "a\tb.txt\0"
1032        );
1033        // name-status -z: `<letter>\0<path>\0` — two NUL-terminated fields.
1034        assert_eq!(
1035            render(&de("del.txt", DiffKind::Removed), true, true),
1036            "D\0del.txt\0"
1037        );
1038    }
1039}