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_exact_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        detect_exact_renames(&mut result.entries);
247    }
248
249    let normalized: Vec<String> = pathspecs.iter().map(|p| normalize_pathspec(p)).collect();
250    let selected: Vec<&mkit_core::ops::DiffEntry> = result
251        .entries
252        .iter()
253        .filter(|e| normalized.is_empty() || path_matches_any(&e.path, &normalized))
254        .collect();
255
256    // `--exit-code`/`--quiet` report difference via the exit status (1 =
257    // changed, 0 = clean). `--quiet` additionally suppresses output.
258    let report_exit = opts.exit_code || opts.quiet;
259    let diff_status = if report_exit && !selected.is_empty() {
260        exit::GENERAL_ERROR
261    } else {
262        exit::OK
263    };
264    if opts.quiet {
265        return diff_status;
266    }
267
268    let mut stdout = std::io::stdout().lock();
269    if opts.stat {
270        // `render_stat` hoists its own `DisplaySource` wrapping (#625).
271        return match render_stat(&mut stdout, &snapshot, selected.into_iter()) {
272            Ok(()) => diff_status,
273            Err(msg) => emit_err(&msg, exit::GENERAL_ERROR),
274        };
275    }
276    // The patch paths below print what they render here — nothing durable
277    // is published from this path — so skip the BLAKE3 re-verify on every
278    // changed blob (#625).
279    let display = DisplaySource::new(&snapshot);
280    for e in selected {
281        let res = if opts.name_only || opts.name_status {
282            emit_entry_name(&mut stdout, e, opts.name_status, opts.z);
283            Ok(())
284        } else if use_color {
285            // Render the entry to a buffer, then colorize line-by-line so
286            // the byte-exact patch machinery stays color-agnostic.
287            let mut buf: Vec<u8> = Vec::new();
288            match emit_entry_patch(&mut buf, &display, e, context, ws_mode) {
289                // Colorize on RAW BYTES (not via from_utf8_lossy) so a
290                // non-UTF-8 patch body round-trips byte-for-byte, matching
291                // the uncolored path.
292                Ok(()) => stdout
293                    .write_all(&colorize_patch(&buf))
294                    .map_err(|err| format!("write: {err}")),
295                Err(msg) => Err(msg),
296            }
297        } else {
298            emit_entry_patch(&mut stdout, &display, e, context, ws_mode)
299        };
300        if let Err(msg) = res {
301            return emit_err(&msg, exit::GENERAL_ERROR);
302        }
303    }
304    diff_status
305}
306
307/// ANSI-colorize a unified-diff patch line-by-line, matching git's default
308/// palette: metadata bold, hunk headers cyan, additions green, deletions
309/// red. Context lines are left uncolored. Operates on raw bytes so a
310/// non-UTF-8 patch body round-trips unchanged (only ASCII line prefixes
311/// drive the coloring).
312fn colorize_patch(text: &[u8]) -> Vec<u8> {
313    const RESET: &[u8] = b"\x1b[0m";
314    let mut out: Vec<u8> = Vec::with_capacity(text.len() + 64);
315    for line in text.split_inclusive(|&b| b == b'\n') {
316        let (body, nl): (&[u8], &[u8]) = if line.last() == Some(&b'\n') {
317            (&line[..line.len() - 1], b"\n")
318        } else {
319            (line, b"")
320        };
321        let code: Option<&[u8]> = if body.starts_with(b"@@") {
322            Some(b"\x1b[36m") // hunk header: cyan
323        } else if body.starts_with(b"diff ")
324            || body.starts_with(b"index ")
325            || body.starts_with(b"new file")
326            || body.starts_with(b"deleted file")
327            || body.starts_with(b"old mode")
328            || body.starts_with(b"new mode")
329            || body.starts_with(b"rename ")
330            || body.starts_with(b"similarity ")
331            || body.starts_with(b"--- ")
332            || body.starts_with(b"+++ ")
333        {
334            Some(b"\x1b[1m") // metadata: bold
335        } else if body.first() == Some(&b'+') {
336            Some(b"\x1b[32m") // addition: green
337        } else if body.first() == Some(&b'-') {
338            Some(b"\x1b[31m") // deletion: red
339        } else {
340            None
341        };
342        match code {
343            Some(c) => {
344                out.extend_from_slice(c);
345                out.extend_from_slice(body);
346                out.extend_from_slice(RESET);
347                out.extend_from_slice(nl);
348            }
349            None => out.extend_from_slice(line),
350        }
351    }
352    out
353}
354
355/// Display name for stat/summary rows: C-style quoted like git's default
356/// `core.quotePath` when the path has special bytes, else the raw path.
357fn c_quote_name(path: &str) -> String {
358    super::c_quote_path(path).unwrap_or_else(|| path.to_string())
359}
360
361/// Status letter for `--name-status`. mkit's `ModeChanged` maps to `T`
362/// (git's type-change letter) — a documented mkit extension, since mkit
363/// tracks a pure mode flip as its own diff kind.
364fn name_status_letter(kind: DiffKind) -> char {
365    match kind {
366        DiffKind::Added => 'A',
367        DiffKind::Removed => 'D',
368        DiffKind::Modified => 'M',
369        DiffKind::ModeChanged => 'T',
370        // Renames carry a similarity score (`R100`) and two paths, so
371        // `--name-status` formats them specially in `emit_entry_name`;
372        // this bare letter is the fallback / name-only case.
373        DiffKind::Renamed => 'R',
374    }
375}
376
377/// Emit one `--name-only` / `--name-status` record for a changed entry.
378///
379/// Newline mode: `<path>\n` (name-only) or `<letter>\t<path>\n`
380/// (name-status); a path with special bytes is C-style quoted like git's
381/// default `core.quotePath`. `-z` mode: paths are raw (unquoted) and
382/// records are NUL-terminated — `<path>\0`, or `<letter>\0<path>\0` where
383/// the status letter and path are each their own NUL-terminated field
384/// (matching `git diff --name-status -z`).
385fn emit_entry_name(out: &mut impl Write, e: &DiffEntry, name_status: bool, z: bool) {
386    // `--name-status` rename: git emits `R100<sep><src><sep><dst>` (source
387    // first, unlike status's porcelain `-z`), TAB-separated by default and
388    // NUL-separated under `-z`. Verified against git.
389    if name_status && e.kind == DiffKind::Renamed {
390        let src = e.old_path.as_deref().unwrap_or(&e.path);
391        if z {
392            let _ = write!(out, "R100\0{src}\0{}\0", e.path);
393        } else {
394            let sq = super::c_quote_path(src).unwrap_or_else(|| src.to_string());
395            let dq = super::c_quote_path(&e.path).unwrap_or_else(|| e.path.clone());
396            let _ = writeln!(out, "R100\t{sq}\t{dq}");
397        }
398        return;
399    }
400    if z {
401        if name_status {
402            let _ = write!(out, "{}\0", name_status_letter(e.kind));
403        }
404        let _ = write!(out, "{}\0", e.path);
405        return;
406    }
407    let path = super::c_quote_path(&e.path);
408    let shown = path.as_deref().unwrap_or(&e.path);
409    if name_status {
410        let _ = writeln!(out, "{}\t{shown}", name_status_letter(e.kind));
411    } else {
412        let _ = writeln!(out, "{shown}");
413    }
414}
415
416/// `(old_tree, new_tree, pathspecs)` triple computed from the args.
417type DiffEndpoints = (Option<Hash>, Option<Hash>, Vec<String>);
418
419/// Decide the `old_tree` / `new_tree` / pathspecs triple from the
420/// `staged` flag and the positional args. Returns `(message, exit_code)`
421/// on error so the caller can route it through `emit_err`.
422///
423/// Cases:
424/// - `--staged <rev>...` (any positionals) — usage contradiction
425///   (#223): `--staged` already fixes both endpoints (HEAD vs index).
426/// - `<a>..<b> [paths…]` — range form; both ends resolved to trees.
427/// - `<a> <b> [paths…]` — two revisions, when both resolve.
428/// - `<a> [paths…]` — one revision vs worktree (or vs index w/--staged
429///   only in the no-positional case, handled above).
430/// - no leading revision — default HEAD-vs-worktree / HEAD-vs-index,
431///   all positionals are pathspecs.
432/// `--merge-base` endpoint resolution. One revision: `merge-base(rev,
433/// HEAD)` vs the worktree; two revisions: `merge-base(a, b)` vs `b`.
434/// Trailing positionals are pathspecs. Annotated tags are peeled to their
435/// commit before the merge-base walk, like git.
436fn resolve_merge_base_endpoints(
437    store: &ObjectStore,
438    snapshot: &EphemeralSink<'_>,
439    layout: &RepoLayout,
440    args: &[String],
441) -> Result<DiffEndpoints, (String, u8)> {
442    let first = args.first().ok_or_else(|| {
443        (
444            "`--merge-base` requires at least one revision".to_string(),
445            exit::USAGE,
446        )
447    })?;
448    let a = peel_tags(
449        store,
450        revspec::resolve_revision(store, layout, first)
451            .map_err(|e| (format!("bad revision '{first}': {e}"), exit::DATAERR))?,
452    );
453
454    // A second positional that resolves to a revision selects the
455    // two-revision form. One that only *looks* like a revision but fails to
456    // resolve is a hard error (#207); anything else is a pathspec, leaving
457    // the single-revision (vs worktree) form.
458    if let Some(second) = args.get(1) {
459        match revspec::resolve_revision(store, layout, second) {
460            Ok(h) => {
461                let b = peel_tags(store, h);
462                let base = merge_base_of(store, a, b)?;
463                let old = object_to_tree(store, &base).map_err(|e| (e, exit::GENERAL_ERROR))?;
464                let new = object_to_tree(store, &b).map_err(|e| (e, exit::GENERAL_ERROR))?;
465                return Ok((Some(old), Some(new), args[2..].to_vec()));
466            }
467            // A 2nd positional that fails to resolve is treated as a pathspec
468            // ONLY when it is clearly path-shaped (names an existing worktree
469            // path, a tracked path, or contains `/`). Otherwise it is an
470            // ambiguous bad revision — a typo'd `<b>` — which we surface,
471            // rather than silently falling back to the single-rev form and
472            // emitting an empty diff (matching git's "ambiguous argument").
473            Err(e)
474                if matches!(e, revspec::RevError::Unknown(_))
475                    && looks_like_pathspec(layout, second) => {}
476            Err(e) => return Err((format!("bad revision '{second}': {e}"), exit::DATAERR)),
477        }
478    }
479
480    // Single revision: merge-base(rev, HEAD) vs the worktree.
481    let head = refs::resolve_head(layout)
482        .map_err(|e| (format!("resolve HEAD: {e}"), exit::GENERAL_ERROR))?
483        .ok_or_else(|| {
484            (
485                "HEAD has no commit to take a merge base with".to_string(),
486                exit::GENERAL_ERROR,
487            )
488        })?;
489    let head = peel_tags(store, head);
490    let base = merge_base_of(store, a, head)?;
491    let old = object_to_tree(store, &base).map_err(|e| (e, exit::GENERAL_ERROR))?;
492    let new = worktree_tree_filtered(store, snapshot, layout)?;
493    Ok((Some(old), Some(new), args[1..].to_vec()))
494}
495
496/// Resolve the single merge base of `a` and `b`, mapping "no base" to a
497/// clear error (matches git's `--merge-base` failure on unrelated histories).
498fn merge_base_of(store: &ObjectStore, a: Hash, b: Hash) -> Result<Hash, (String, u8)> {
499    find_merge_base(store, a, b)
500        .map_err(|e| (format!("merge base: {e}"), exit::GENERAL_ERROR))?
501        .ok_or_else(|| {
502            (
503                "no merge base between the given revisions".to_string(),
504                exit::DATAERR,
505            )
506        })
507}
508
509#[allow(clippy::too_many_arguments)]
510fn resolve_diff_endpoints(
511    store: &ObjectStore,
512    snapshot: &EphemeralSink<'_>,
513    layout: &RepoLayout,
514    staged: bool,
515    merge_base: bool,
516    args: &[String],
517) -> Result<DiffEndpoints, (String, u8)> {
518    // `--merge-base <a> [<b>] [paths…]` — diff the merge base of the given
519    // revision(s) rather than the revisions themselves. Resolved before
520    // any other form (clap already rejects `--merge-base --staged`).
521    if merge_base {
522        return resolve_merge_base_endpoints(store, snapshot, layout, args);
523    }
524
525    // #223: `--staged` with explicit revisions is contradictory —
526    // `--staged` already pins HEAD vs the index. Pathspecs are fine, but
527    // a leading argument that *looks* like a revision is not, and must
528    // fail closed: if it resolves it is the contradiction (#223), and if
529    // it does not it is a bad revision (#207). Either way we error rather
530    // than silently treating a typo'd hash as a no-match pathspec (which
531    // would empty-succeed and diverge from `git diff --cached <bad-rev>`).
532    // A non-rev-looking leading arg (e.g. `path/`, `file.txt`) still falls
533    // through as a pathspec filter.
534    if staged {
535        if let Some(first) = args.first()
536            && looks_like_rev_request(first)
537        {
538            if revspec::resolve_revision(store, layout, strip_range_end(first).0).is_ok() {
539                return Err((
540                    "`--staged` diffs HEAD vs the index; it cannot take an explicit revision"
541                        .to_string(),
542                    exit::USAGE,
543                ));
544            }
545            return Err((
546                format!("bad revision '{first}': not a known ref, commit, or short hash"),
547                exit::DATAERR,
548            ));
549        }
550        // No leading revision: HEAD vs index, all positionals = pathspecs.
551        let head = head_tree(store, layout).map_err(|e| (e, exit::GENERAL_ERROR))?;
552        let idx = index_tree(layout, store, snapshot).map_err(|e| (e, exit::GENERAL_ERROR))?;
553        return Ok((head, idx, args.to_vec()));
554    }
555
556    // Symmetric range `A...B` = diff the merge base of A and B against B
557    // (git semantics). Must be checked before `A..B` (which it contains).
558    if let Some(first) = args.first()
559        && let Some((a, b)) = split_symmetric(first)
560    {
561        // Peel annotated/signed tags to their commit before merge-base
562        // resolution, like git (and like `log` does for its range bases).
563        let commit_a = peel_tags(
564            store,
565            revspec::resolve_revision(store, layout, a)
566                .map_err(|e| (format!("bad revision '{a}': {e}"), exit::DATAERR))?,
567        );
568        let commit_b = peel_tags(
569            store,
570            revspec::resolve_revision(store, layout, b)
571                .map_err(|e| (format!("bad revision '{b}': {e}"), exit::DATAERR))?,
572        );
573        let mb = find_merge_base(store, commit_a, commit_b)
574            .map_err(|e| (format!("merge base: {e}"), exit::GENERAL_ERROR))?
575            .ok_or_else(|| {
576                (
577                    format!("no merge base between '{a}' and '{b}'"),
578                    exit::DATAERR,
579                )
580            })?;
581        let old = object_to_tree(store, &mb).map_err(|e| (e, exit::GENERAL_ERROR))?;
582        let new = object_to_tree(store, &commit_b).map_err(|e| (e, exit::GENERAL_ERROR))?;
583        return Ok((Some(old), Some(new), args[1..].to_vec()));
584    }
585
586    // Range form `A..B` as the first positional.
587    if let Some(first) = args.first()
588        && let Some((a, b)) = split_range(first)
589    {
590        let old = rev_to_tree(store, layout, a)?;
591        let new = rev_to_tree(store, layout, b)?;
592        return Ok((Some(old), Some(new), args[1..].to_vec()));
593    }
594
595    // Try to peel one or two leading revisions.
596    let first_rev = args.first().and_then(|a| try_rev_to_tree(store, layout, a));
597    match first_rev {
598        None => {
599            // No leading revision → default HEAD vs worktree; all
600            // positionals are pathspecs. If the first arg *looked* like
601            // a revision but failed to resolve, error loudly (#207)
602            // rather than silently treating it as a pathspec.
603            if let Some(first) = args.first()
604                && looks_like_rev_request(first)
605            {
606                return Err((
607                    format!("bad revision '{first}': not a known ref, commit, or short hash"),
608                    exit::DATAERR,
609                ));
610            }
611            let head = head_tree(store, layout).map_err(|e| (e, exit::GENERAL_ERROR))?;
612            let new = Some(worktree_tree_filtered(store, snapshot, layout)?);
613            Ok((head, new, args.to_vec()))
614        }
615        Some(Err(e)) => Err(e),
616        Some(Ok(old)) => {
617            // One revision resolved. Is the second positional also a
618            // revision? If so, two-rev mode; otherwise rev-vs-worktree.
619            let second_rev = args.get(1).and_then(|a| try_rev_to_tree(store, layout, a));
620            match second_rev {
621                Some(Ok(new)) => Ok((Some(old), Some(new), args[2..].to_vec())),
622                Some(Err(e)) => Err(e),
623                None => {
624                    let new = Some(worktree_tree_filtered(store, snapshot, layout)?);
625                    Ok((Some(old), new, args[1..].to_vec()))
626                }
627            }
628        }
629    }
630}
631
632/// Resolve a revision spec to a tree hash, mapping a commit/remix to its
633/// tree and accepting a bare tree hash as itself. `(message, code)` on
634/// failure.
635/// Snapshot the worktree, seeding the tracked set from the index (or HEAD
636/// when no index file exists) so a tracked file matching an ignore rule is
637/// not dropped from the snapshot and misreported as a deletion.
638fn worktree_tree_filtered(
639    store: &ObjectStore,
640    snapshot: &EphemeralSink<'_>,
641    layout: &RepoLayout,
642) -> Result<Hash, (String, u8)> {
643    let idx =
644        super::read_or_seed_index_from_head(layout, store).map_err(|e| (e, exit::GENERAL_ERROR))?;
645    worktree::build_tree_filtered(snapshot, layout.worktree_root(), Some(&idx))
646        .map_err(|e| (format!("build tree: {e}"), exit::GENERAL_ERROR))
647}
648
649fn rev_to_tree(store: &ObjectStore, layout: &RepoLayout, spec: &str) -> Result<Hash, (String, u8)> {
650    let h = revspec::resolve_revision(store, layout, spec)
651        .map_err(|e| (format!("bad revision '{spec}': {e}"), exit::DATAERR))?;
652    object_to_tree(store, &h).map_err(|e| (e, exit::GENERAL_ERROR))
653}
654
655/// Like [`rev_to_tree`] but distinguishes "not a revision at all" (None)
656/// from "looks like a revision but is broken" (`Some(Err(..))`).
657fn try_rev_to_tree(
658    store: &ObjectStore,
659    layout: &RepoLayout,
660    spec: &str,
661) -> Option<Result<Hash, (String, u8)>> {
662    match revspec::resolve_revision(store, layout, spec) {
663        Ok(h) => Some(object_to_tree(store, &h).map_err(|e| (e, exit::GENERAL_ERROR))),
664        Err(revspec::RevError::Unknown(_)) => {
665            // Not a known ref/object. If it still *looks* like a
666            // revision request (ref-shaped or hash-shaped), surface the
667            // failure; otherwise it is a pathspec.
668            if looks_like_rev_request(spec) {
669                Some(Err((
670                    format!("bad revision '{spec}': not a known ref, commit, or short hash"),
671                    exit::DATAERR,
672                )))
673            } else {
674                None
675            }
676        }
677        Err(e) => Some(Err((format!("bad revision '{spec}': {e}"), exit::DATAERR))),
678    }
679}
680
681/// Follow `Object::Tag` targets to the first non-tag object, so an
682/// annotated/signed tag resolves to the commit it points at. Delegates to
683/// the shared `log::peel_tags` (kept as a local alias for the call sites).
684fn peel_tags(store: &ObjectStore, h: Hash) -> Hash {
685    super::log::peel_tags(store, h)
686}
687
688/// Map a resolved object hash to a tree hash: commit/remix → its tree,
689/// a tree → itself.
690pub(super) fn object_to_tree(store: &ObjectStore, h: &Hash) -> Result<Hash, String> {
691    match store.read_object(h) {
692        Ok(Object::Commit(c)) => Ok(c.tree_hash),
693        Ok(Object::Remix(r)) => Ok(r.tree_hash),
694        Ok(Object::Tree(_)) => Ok(*h),
695        Ok(_) => Err(format!(
696            "{} is not a commit, remix, or tree",
697            mkit_core::hash::to_hex(h)
698        )),
699        Err(e) => Err(read_err(e)),
700    }
701}
702
703/// Split an `A..B` range. Returns `None` if there is no `..`.
704fn split_range(s: &str) -> Option<(&str, &str)> {
705    let (a, b) = s.split_once("..")?;
706    if a.is_empty() || b.is_empty() {
707        return None;
708    }
709    Some((a, b))
710}
711
712/// Split a symmetric `A...B` range. An empty side defaults to `HEAD`
713/// (`A...` = `A...HEAD`, `...B` = `HEAD...B`).
714fn split_symmetric(s: &str) -> Option<(&str, &str)> {
715    let (a, b) = s.split_once("...")?;
716    Some((
717        if a.is_empty() { "HEAD" } else { a },
718        if b.is_empty() { "HEAD" } else { b },
719    ))
720}
721
722/// The left-hand end of a possible range, used for the `--staged`
723/// contradiction probe. Returns `(rev, is_range)`.
724fn strip_range_end(s: &str) -> (&str, bool) {
725    match s.split_once("..") {
726        Some((a, _)) if !a.is_empty() => (a, true),
727        _ => (s, false),
728    }
729}
730
731/// Heuristic for #207: does this argument look like the user *intended*
732/// a revision (so a resolve failure should be a hard error) rather than
733/// a pathspec? True for hash-shaped tokens, `A..B` ranges, and the
734/// literal `HEAD` (possibly with `~`/`^` navigation). A plain
735/// filesystem-y token (`src/`, `./x`, `*.rs`) is treated as a pathspec.
736fn looks_like_rev_request(s: &str) -> bool {
737    if s.contains("..") {
738        return true;
739    }
740    // A `~` or `^` navigation suffix is revision syntax, not a path.
741    let base = s.split(['~', '^']).next().unwrap_or(s);
742    if base == "HEAD" {
743        return true;
744    }
745    // Hash-shaped: ≥ MIN_SHORT_HASH hex chars with no path separators.
746    base.len() >= revspec::MIN_SHORT_HASH
747        && !base.contains('/')
748        && !base.contains('.')
749        && base.bytes().all(|b| b.is_ascii_hexdigit())
750}
751
752/// Is `arg` clearly a pathspec rather than a (typo'd) revision? True when it
753/// names an existing worktree path OR matches a tracked index path (a file/dir
754/// tracked but deleted from the worktree is still a valid pathspec, as in
755/// git). A bare `/` is NOT enough — branch names routinely contain `/` (e.g.
756/// `feature/x`), so a typo'd branch like `feature/typo` must surface as a bad
757/// revision rather than silently degrade into an empty-output pathspec filter.
758fn looks_like_pathspec(layout: &RepoLayout, arg: &str) -> bool {
759    if layout.worktree_root().join(arg).symlink_metadata().is_ok() {
760        return true;
761    }
762    // Normalize the spec the same way the path filter will (e.g. `./a.txt` ->
763    // `a.txt`) before matching the index, so a tracked-but-deleted file passed
764    // as `./a.txt` isn't misread as a bad revision.
765    let spec = normalize_pathspec(arg);
766    let Ok(idx) = mkit_core::index::read_index(layout) else {
767        return false;
768    };
769    let prefix = format!("{spec}/");
770    idx.entries
771        .iter()
772        .any(|e| e.path == spec || e.path.starts_with(&prefix))
773}
774
775fn head_tree(store: &ObjectStore, layout: &RepoLayout) -> Result<Option<Hash>, String> {
776    let head = refs::resolve_head(layout).map_err(|e| format!("resolve HEAD: {e}"))?;
777    match head {
778        None => Ok(None),
779        Some(h) => match store.read_object(&h) {
780            Ok(Object::Commit(c)) => Ok(Some(c.tree_hash)),
781            Ok(Object::Remix(r)) => Ok(Some(r.tree_hash)),
782            Ok(_) => Ok(None),
783            Err(e) => Err(format!("read HEAD: {e}")),
784        },
785    }
786}
787
788fn index_tree(
789    layout: &RepoLayout,
790    store: &ObjectStore,
791    snapshot: &EphemeralSink<'_>,
792) -> Result<Option<Hash>, String> {
793    let idx = super::read_or_seed_index_from_head(layout, store)?;
794    // Ephemeral diff snapshot — nothing durable is published, so skip the
795    // re-hash; the read path verifies any object actually touched.
796    let tree = worktree::build_tree_from_index_with(store, snapshot, &idx, false)
797        .map_err(|e| format!("build index tree: {e}"))?;
798    Ok(Some(tree))
799}
800
801/// Normalize a pathspec to the index/diff path form: strip a leading
802/// `./`, collapse `\\` to `/`, drop a trailing `/`. The repo root in any
803/// spelling (`.`, `./`, `/`, or empty) normalizes to the empty string, which
804/// [`path_matches_any`] treats as "match everything" (matching git, where
805/// `diff -- .` is the whole-tree diff).
806fn normalize_pathspec(spec: &str) -> String {
807    let s = spec.replace('\\', "/");
808    let s = s.strip_prefix("./").unwrap_or(&s);
809    let s = s.strip_suffix('/').unwrap_or(s);
810    if s == "." {
811        String::new()
812    } else {
813        s.to_string()
814    }
815}
816
817fn path_matches_any(path: &str, specs: &[String]) -> bool {
818    specs
819        .iter()
820        // An empty spec is the repo root (`.`/`./`) → matches every path.
821        .any(|spec| spec.is_empty() || super::index_path_matches_or_descends(path, spec))
822}
823
824/// Abbreviated all-zero blob id git prints for an absent side of `index`.
825const ZERO_ABBREV: &str = "0000000";
826
827/// git octal mode string for a [`DiffEntry`] side (`None` → regular file).
828fn git_octal(mode: Option<EntryMode>) -> &'static str {
829    match mode {
830        Some(EntryMode::Executable) => "100755",
831        Some(EntryMode::Symlink) => "120000",
832        Some(EntryMode::Tree) => "040000",
833        _ => "100644",
834    }
835}
836
837/// Abbreviated blob id for an `index` line side (`None` → all-zero).
838fn abbrev(h: Option<Hash>) -> String {
839    h.map_or_else(|| ZERO_ABBREV.to_string(), |h| format::short_hash(&h, 7))
840}
841
842/// Emit a git-shaped `diff --git` header plus unified-diff hunks for one
843/// changed entry. The `index <old>..<new>` ids are abbreviated BLAKE3
844/// prefixes (longer than git's SHA-1 prefixes for the same `core.abbrev`),
845/// the one inherent divergence; everything else matches `git diff`.
846///
847/// Shared with `mkit show`, so a commit's diff body is byte-identical to
848/// `mkit diff <parent> <commit>` when both use the default `context`/`ws`
849/// (git's `-U3`, exact comparison).
850///
851/// `context` is the `-U<n>` unchanged-context-line count and `ws` is the
852/// `-w`/`-b` whitespace-comparison mode; pass
853/// [`mkit_core::ops::DEFAULT_CONTEXT_LINES`] / [`WhitespaceMode::Exact`]
854/// for git's defaults.
855pub(super) fn emit_entry_patch<S: ObjectSource + ?Sized>(
856    out: &mut impl Write,
857    store: &S,
858    e: &DiffEntry,
859    context: usize,
860    ws: WhitespaceMode,
861) -> Result<(), String> {
862    // git C-style quotes special-byte paths in the header (core.quotePath),
863    // quoting the whole `a/<path>` / `b/<path>` token as a unit. For a
864    // rename the `a/` side is the source path, the `b/` side the dest.
865    let a_src = if e.kind == DiffKind::Renamed {
866        e.old_path.as_deref().unwrap_or(&e.path)
867    } else {
868        e.path.as_str()
869    };
870    let a_path = quoted_side('a', a_src);
871    let b_path = quoted_side('b', &e.path);
872    let _ = writeln!(out, "diff --git {a_path} {b_path}");
873
874    match e.kind {
875        DiffKind::Renamed => {
876            // Exact rename: identical content, so 100% similar and no hunk.
877            let from = super::c_quote_path(a_src).unwrap_or_else(|| a_src.to_string());
878            let to = super::c_quote_path(&e.path).unwrap_or_else(|| e.path.clone());
879            let _ = writeln!(out, "similarity index 100%");
880            let _ = writeln!(out, "rename from {from}");
881            let _ = writeln!(out, "rename to {to}");
882            return Ok(());
883        }
884        DiffKind::ModeChanged => {
885            // Identical content, mode flip — only the mode lines, no hunks.
886            let _ = writeln!(out, "old mode {}", git_octal(e.old_mode));
887            let _ = writeln!(out, "new mode {}", git_octal(e.new_mode));
888            return Ok(());
889        }
890        DiffKind::Added => {
891            let _ = writeln!(out, "new file mode {}", git_octal(e.new_mode));
892            let _ = writeln!(out, "index {}..{}", ZERO_ABBREV, abbrev(e.new_hash));
893        }
894        DiffKind::Removed => {
895            let _ = writeln!(out, "deleted file mode {}", git_octal(e.old_mode));
896            let _ = writeln!(out, "index {}..{}", abbrev(e.old_hash), ZERO_ABBREV);
897        }
898        DiffKind::Modified if e.old_mode != e.new_mode => {
899            // Content and mode both changed: mode lines, index without mode.
900            let _ = writeln!(out, "old mode {}", git_octal(e.old_mode));
901            let _ = writeln!(out, "new mode {}", git_octal(e.new_mode));
902            let _ = writeln!(out, "index {}..{}", abbrev(e.old_hash), abbrev(e.new_hash));
903        }
904        DiffKind::Modified => {
905            let _ = writeln!(
906                out,
907                "index {}..{} {}",
908                abbrev(e.old_hash),
909                abbrev(e.new_hash),
910                git_octal(e.new_mode)
911            );
912        }
913    }
914
915    let old_bytes = match e.old_hash {
916        Some(h) => read_blob(store, &h)?,
917        None => Vec::new(),
918    };
919    let new_bytes = match e.new_hash {
920        Some(h) => read_blob(store, &h)?,
921        None => Vec::new(),
922    };
923    // `--- a/p` / `+++ b/p` (quoted), with `/dev/null` for the absent side.
924    let (minus, plus) = match e.kind {
925        DiffKind::Added => ("/dev/null".to_string(), b_path.clone()),
926        DiffKind::Removed => (a_path.clone(), "/dev/null".to_string()),
927        _ => (a_path.clone(), b_path.clone()),
928    };
929    match unified_hunks_opts(&old_bytes, &new_bytes, context, ws) {
930        None => {
931            let _ = writeln!(out, "Binary files {minus} and {plus} differ");
932        }
933        Some(hunks) if hunks.is_empty() => {}
934        Some(hunks) => {
935            let _ = writeln!(out, "--- {minus}");
936            let _ = writeln!(out, "+++ {plus}");
937            let _ = out.write_all(&hunks);
938        }
939    }
940    Ok(())
941}
942
943/// The git-quoted `a/<path>` / `b/<path>` token for a patch header: C-style
944/// quoted (with surrounding quotes) when the path has special bytes, else the
945/// plain `<side>/<path>`.
946fn quoted_side(side: char, path: &str) -> String {
947    let s = format!("{side}/{path}");
948    super::c_quote_path(&s).unwrap_or(s)
949}
950
951/// Read a blob's bytes from the store, reassembling chunked blobs via
952/// the shared core helper so diff/cat/checkout agree (#203).
953fn read_blob<S: ObjectSource + ?Sized>(store: &S, h: &Hash) -> Result<Vec<u8>, String> {
954    worktree::read_blob(store, h).map_err(read_err)
955}
956
957/// The one place the CLI's "read object: …" error wording is defined.
958fn read_err<E: std::fmt::Display>(e: E) -> String {
959    format!("read object: {e}")
960}
961
962use super::error as emit_err;
963
964#[cfg(test)]
965mod tests {
966    use super::*;
967
968    fn de(path: &str, kind: DiffKind) -> DiffEntry {
969        DiffEntry {
970            path: path.to_string(),
971            kind,
972            old_hash: None,
973            new_hash: None,
974            old_mode: None,
975            new_mode: None,
976            old_path: None,
977        }
978    }
979
980    fn render(e: &DiffEntry, name_status: bool, z: bool) -> String {
981        let mut buf = Vec::new();
982        emit_entry_name(&mut buf, e, name_status, z);
983        String::from_utf8(buf).unwrap()
984    }
985
986    #[test]
987    fn name_status_letters_cover_every_kind() {
988        assert_eq!(name_status_letter(DiffKind::Added), 'A');
989        assert_eq!(name_status_letter(DiffKind::Removed), 'D');
990        assert_eq!(name_status_letter(DiffKind::Modified), 'M');
991        assert_eq!(name_status_letter(DiffKind::ModeChanged), 'T');
992    }
993    #[test]
994    fn name_only_newline_plain_path() {
995        assert_eq!(
996            render(&de("a.txt", DiffKind::Modified), false, false),
997            "a.txt\n"
998        );
999    }
1000
1001    #[test]
1002    fn name_status_newline_is_letter_tab_path() {
1003        assert_eq!(
1004            render(&de("a.txt", DiffKind::Added), true, false),
1005            "A\ta.txt\n"
1006        );
1007    }
1008
1009    #[test]
1010    fn name_only_quotes_special_path_in_newline_mode() {
1011        // A tab is C-style quoted like git core.quotePath.
1012        assert_eq!(
1013            render(&de("a\tb.txt", DiffKind::Modified), false, false),
1014            "\"a\\tb.txt\"\n"
1015        );
1016    }
1017
1018    #[test]
1019    fn z_mode_is_raw_and_nul_terminated() {
1020        // name-only -z: `<path>\0`, path emitted raw (unquoted).
1021        assert_eq!(
1022            render(&de("a\tb.txt", DiffKind::Modified), false, true),
1023            "a\tb.txt\0"
1024        );
1025        // name-status -z: `<letter>\0<path>\0` — two NUL-terminated fields.
1026        assert_eq!(
1027            render(&de("del.txt", DiffKind::Removed), true, true),
1028            "D\0del.txt\0"
1029        );
1030    }
1031}