Skip to main content

mkit_cli/commands/
status.rs

1//! `mkit status` — show working-tree changes relative to HEAD.
2//!
3//! ## Default (human) output
4//!
5//! ```text
6//! on branch <name>           # to stderr  (or "detached HEAD at <hash>" / "no HEAD yet")
7//!                            #
8//! Changes to be committed:   # to stderr
9//!   A  added.txt             # to stderr
10//!   D  deleted.txt           # to stderr
11//!
12//! Changes not staged for commit:    # to stderr
13//!   M  modified.txt                 # to stderr
14//! ```
15//!
16//! Banners and section headers go to stderr; per-file lines also go to
17//! stderr in default mode because they are formatted for humans.
18//! Scripts should use `--porcelain` (see below) for stdout output
19//! that is safe to parse.
20//!
21//! ## `--porcelain[=v1]` / `-s` (`--short`) output
22//!
23//! `-s`/`--short` is an alias for `--porcelain=v1`; both select the
24//! same renderer. Compatible with `git status --porcelain` — one entry
25//! per line,
26//! two-character XY status code, space, path:
27//!
28//! ```text
29//! M  modified-staged.txt
30//!  M unstaged-edit.txt
31//! A  newly-staged.txt
32//! ?? untracked.txt
33//! ```
34//!
35//! `X` is the staged-vs-HEAD state; `Y` is the worktree-vs-index
36//! state. mkit's `DiffKind::ModeChanged` renders as `T` (a non-git
37//! extension). `??` is the conventional code for untracked files.
38//!
39//! Paths containing special bytes are C-style quoted (matching git's
40//! default `core.quotePath`). With `-z`, records are NUL-terminated and
41//! paths are emitted raw (unquoted) — the round-trip-safe form for paths
42//! with newlines or other special bytes; `-z` implies porcelain.
43//!
44//! Empty stdout means "nothing to commit, working tree clean."
45//!
46//! ## `--porcelain=v2` output
47//!
48//! Selects git's richer per-path format. Each changed tracked path is a
49//! `1` record:
50//!
51//! ```text
52//! 1 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <path>
53//! ```
54//!
55//! where `XY` uses `.` (not space) for an unchanged column, `<sub>` is
56//! always `N...` (mkit is never a submodule), `<mH>/<mI>/<mW>` are the
57//! octal file modes in HEAD / index / worktree, and `<hH>/<hI>` are the
58//! HEAD and index object ids (full 64-hex BLAKE3; git's are 40-hex
59//! SHA-1, so the differential harness masks length). Untracked paths are
60//! `? <path>` records, and a rename emits a `2` record (`R100`, exact
61//! content). There are no `--branch` header lines. Path quoting and `-z`
62//! semantics match the v1 renderer.
63
64use std::io::Write;
65
66use std::path::Path;
67
68use clap::{Parser, ValueEnum};
69use mkit_core::Hash;
70use mkit_core::index::{self, EntryStatus, Index};
71use mkit_core::layout::RepoLayout;
72use mkit_core::ops::{
73    DiffEntry, DiffKind, StatusEntry, StatusStaging, detect_exact_renames, status_diff_observed,
74};
75use mkit_core::refs;
76use mkit_core::store::ObjectStore;
77
78use crate::clap_shim;
79use crate::exit;
80use crate::format;
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
83enum PorcelainVersion {
84    V1,
85    V2,
86}
87
88#[derive(Debug, Parser)]
89#[command(
90    name = "mkit status",
91    about = "Show working-tree changes relative to HEAD."
92)]
93struct StatusOpts {
94    /// Emit machine-readable XY-code-plus-path on stdout. Default
95    /// `v1` matches `git status --porcelain=v1`.
96    #[arg(long, value_name = "VERSION", num_args = 0..=1, default_missing_value = "v1")]
97    porcelain: Option<PorcelainVersion>,
98
99    /// Short format. Alias for `--porcelain=v1`: emits the same
100    /// XY-code-plus-path lines on stdout.
101    #[arg(short = 's', long = "short")]
102    short: bool,
103
104    /// NUL-terminate entries instead of newline, and emit raw (unquoted)
105    /// paths — like `git status -z`. Implies porcelain output. Without
106    /// `-z`, paths with special bytes are C-style quoted.
107    #[arg(short = 'z')]
108    z: bool,
109
110    /// Turn off rename detection (on by default, like git). A move then
111    /// reports as a separate deletion and addition.
112    #[arg(long = "no-renames")]
113    no_renames: bool,
114
115    /// Detect renames, optionally with a similarity threshold. Accepted
116    /// for git familiarity; mkit pairs by identical content (exact, 100%),
117    /// so any threshold ≤ 100 selects the same exact matches.
118    #[arg(long = "find-renames", value_name = "N", num_args = 0..=1, require_equals = true)]
119    find_renames: Option<String>,
120}
121
122#[must_use]
123pub fn run(args: &[String]) -> u8 {
124    let opts = match clap_shim::parse::<StatusOpts>("mkit status", args) {
125        Ok(o) => o,
126        Err(code) => return code,
127    };
128    // `-s`/`--short` is an alias for `--porcelain=v1`; `-z` also implies
129    // porcelain output. All select the line-oriented XY renderer on stdout.
130    let porcelain = opts.porcelain.is_some() || opts.short || opts.z;
131
132    let cwd = match std::env::current_dir() {
133        Ok(p) => p,
134        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
135    };
136    let layout = match super::resolve_layout(&cwd) {
137        Ok(layout) => layout,
138        Err(code) => return code,
139    };
140    let store = match ObjectStore::open(&layout) {
141        Ok(s) => s,
142        Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
143    };
144
145    // Resolve HEAD tree hash (None on a HEAD-less repo). Use the shared
146    // helper so a `Remix` HEAD is compared against its tree like every
147    // other command, not treated as "no HEAD".
148    let head_tree: Option<mkit_core::Hash> = match super::current_head_tree(&layout, &store) {
149        Ok(t) => t,
150        Err(e) => return emit_err(&format!("status: {e}"), exit::GENERAL_ERROR),
151    };
152
153    // Load the index, falling back to None only when absent/empty.
154    // Corrupt or invalid persisted state must surface instead of
155    // silently reverting to the HEAD<->worktree comparison.
156    let idx = match index::read_index(&layout) {
157        Ok(idx) if idx.entries.is_empty() => None,
158        Ok(idx) => Some(idx),
159        Err(e) => return emit_err(&format!("read index: {e}"), exit::GENERAL_ERROR),
160    };
161
162    // A provided `--find-renames` threshold must be a number (`50`, `50%`)
163    // — reject garbage like git does, even though the exact matcher then
164    // ignores the magnitude.
165    if let Some(t) = &opts.find_renames {
166        let n = t.trim_end_matches('%');
167        if !n.is_empty() && n.parse::<u8>().is_err() {
168            return emit_err(&format!("invalid --find-renames value: {t}"), exit::USAGE);
169        }
170    }
171
172    let (mut entries, observations) =
173        match status_diff_observed(&store, head_tree.as_ref(), &cwd, idx.as_ref()) {
174            Ok(v) => v,
175            Err(e) => return emit_err(&format!("status: {e}"), exit::GENERAL_ERROR),
176        };
177
178    // Opportunistic stat-cache refresh, like `git status`: entries the
179    // racy-clean rule forced us to re-hash and whose re-hash matched
180    // the staged hash get their cache re-recorded from the HASH-TIME
181    // stat (never a later one — see StatObservation). Purely an
182    // optimisation — skipped on lock contention or any error.
183    if idx.is_some() {
184        refresh_stat_cache(&layout, &observations);
185    }
186
187    // Rename detection (on by default, like git): pair identical-content
188    // staged deletes and adds into a single `R` entry.
189    if !opts.no_renames {
190        entries = detect_status_renames(entries);
191    }
192
193    if porcelain {
194        if opts.porcelain == Some(PorcelainVersion::V2) {
195            render_porcelain_v2(&store, head_tree.as_ref(), &layout, &entries, opts.z)
196        } else {
197            render_porcelain(&entries, opts.z)
198        }
199    } else {
200        render_human(&layout, &entries)
201    }
202}
203
204/// Re-record the stat cache from the worktree walk's hash-time
205/// [`StatObservation`]s. Sound by construction:
206///
207/// - each observation pairs a hash with the stat captured from the
208///   opened fd BEFORE its content was read — a modification after that
209///   stat lands a newer mtime/ctime, so the recorded pair can only
210///   under-claim, never hide an edit;
211/// - the rewrite happens under the worktree lock against a freshly
212///   re-read index, matching path AND hash, so a concurrent `add` is
213///   never clobbered;
214/// - a v1 on-disk index is left untouched: `status` is a query and must
215///   not one-way-upgrade the format under an older binary's feet (the
216///   first mutating command performs the upgrade instead).
217///
218/// Lock contention or any error skips the refresh — it is an
219/// optimisation.
220fn refresh_stat_cache(layout: &RepoLayout, observations: &[mkit_core::worktree::StatObservation]) {
221    if observations.is_empty() {
222        return;
223    }
224    // Version sniff: never auto-upgrade a v1 index from a query command.
225    match std::fs::File::open(mkit_core::index::index_path(layout)) {
226        Ok(mut f) => {
227            use std::io::Read as _;
228            let mut header = [0u8; 5];
229            if f.read_exact(&mut header).is_err() || header[4] != mkit_core::index::FORMAT_VERSION {
230                return;
231            }
232        }
233        Err(_) => return,
234    }
235    // Try-take the worktree lock with a near-zero timeout and no error
236    // output; a concurrent mutator wins and we silently skip.
237    let Ok(_lock) = mkit_core::repo_lock::acquire(
238        layout.worktree_state_dir(),
239        super::WORKTREE_LOCK,
240        std::time::Duration::from_millis(10),
241    ) else {
242        return;
243    };
244    let Ok(mut fresh) = index::read_index(layout) else {
245        return;
246    };
247    let by_path: std::collections::HashMap<&str, &mkit_core::worktree::StatObservation> =
248        observations.iter().map(|o| (o.path.as_str(), o)).collect();
249    let mut updated = false;
250    for e in &mut fresh.entries {
251        let Some(obs) = by_path.get(e.path.as_str()) else {
252            continue;
253        };
254        // Heal any clean-but-stale stat cache, not just the zero-mtime
255        // first-observation case: a metadata-only touch (chmod, link
256        // count, atime-bump that moved ctime) leaves nonzero-but-stale
257        // fields whose content still hashes to the cached object. Those
258        // would re-hash on EVERY future `status` until refreshed. When
259        // the hash still matches, write back whichever stat fields drifted.
260        if e.object_hash == obs.object_hash
261            && (e.mtime_ns != obs.mtime_ns
262                || e.size != obs.size
263                || e.ino != obs.ino
264                || e.ctime_ns != obs.ctime_ns)
265        {
266            e.mtime_ns = obs.mtime_ns;
267            e.size = obs.size;
268            e.ino = obs.ino;
269            e.ctime_ns = obs.ctime_ns;
270            updated = true;
271        }
272    }
273    if updated {
274        let _ = index::write_index(layout, &fresh);
275    }
276}
277
278/// `--porcelain[=v1]` output — XY-code-plus-path, one entry per record.
279/// Empty stdout means clean. Matches `git status --porcelain` for the
280/// codes mkit and git share; `T ` (`ModeChanged`) is the only non-git
281/// extension.
282///
283/// With `z = false` (default), records are newline-terminated and a path
284/// containing special bytes is C-style quoted (matching git's default
285/// `core.quotePath`). With `z = true` (`-z`), records are NUL-terminated
286/// and paths are emitted **raw** (unquoted) — the round-trip-safe form
287/// for paths that contain newlines or other special bytes.
288fn render_porcelain(entries: &[StatusEntry], z: bool) -> u8 {
289    let disp = |p: &str| super::c_quote_path(p).unwrap_or_else(|| p.to_string());
290    let mut stdout = std::io::stdout().lock();
291    for (xy, path, old_path) in combine_porcelain(entries) {
292        // `xy` is two ASCII status columns by construction.
293        let code = std::str::from_utf8(&xy).unwrap_or("??");
294        match old_path {
295            // Rename: git renders `old -> new` by default, and `new\0old\0`
296            // under `-z` (destination first — verified against git).
297            Some(old) if z => {
298                let _ = write!(stdout, "{code} {path}\0{old}\0");
299            }
300            Some(old) => {
301                let _ = writeln!(stdout, "{code} {} -> {}", disp(old), disp(path));
302            }
303            None if z => {
304                let _ = write!(stdout, "{code} {path}\0");
305            }
306            None => {
307                let _ = writeln!(stdout, "{code} {}", disp(path));
308            }
309        }
310    }
311    exit::OK
312}
313
314/// `--porcelain=v2` output — git's richer per-path format. Each changed
315/// tracked path is a `1 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <path>` line, a
316/// rename is a `2 … <Xscore> <new>\t<old>` line (mkit pairs exact-content
317/// moves, so the score is always `R100`), and (mkit having no submodules)
318/// `<sub>` is always `N...`; untracked paths are `? <path>`.
319///
320/// `<XY>` uses `.` for an unchanged column (vs v1's space). `<mH>`/`<mI>` are
321/// the HEAD/index octal modes, `<mW>` the worktree mode (`000000` when the
322/// side is absent); `<hH>`/`<hI>` are the HEAD/index object ids (full 64-hex
323/// BLAKE3 — longer than git's SHA-1, the documented hash-length divergence).
324/// Without `--branch` there are no header lines, matching git.
325fn render_porcelain_v2(
326    store: &ObjectStore,
327    head_tree: Option<&Hash>,
328    layout: &RepoLayout,
329    entries: &[StatusEntry],
330    z: bool,
331) -> u8 {
332    // HEAD paths (mode+id) via a flattened tree; the effective staging index
333    // (seeded from HEAD when no index file exists) for the index columns.
334    let head_index = match head_tree {
335        Some(h) => match index::from_tree(store, *h) {
336            Ok(i) => i,
337            Err(e) => return emit_err(&format!("read HEAD tree: {e}"), exit::GENERAL_ERROR),
338        },
339        None => Index::new(),
340    };
341    let work_index = match super::read_or_seed_index_from_head(layout, store) {
342        Ok(i) => i,
343        Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
344    };
345
346    let mut stdout = std::io::stdout().lock();
347    for (xy, path, old_path) in combine_porcelain(entries) {
348        if xy == [b'?', b'?'] {
349            emit_v2_record(&mut stdout, "? ", path, z);
350            continue;
351        }
352        // v2 uses `.` for an unchanged column, not a space.
353        let x = if xy[0] == b' ' { '.' } else { xy[0] as char };
354        let y = if xy[1] == b' ' { '.' } else { xy[1] as char };
355        if let Some(old) = old_path {
356            // `2` rename record. The HEAD side (mH/hH) describes the SOURCE
357            // path; the index side (mI/hI) the DESTINATION. Exact content
358            // means hH == hI and the score is `R100`. Verified vs git.
359            let (m_head, h_head) = v2_mode_and_id(&head_index, old);
360            let (m_index, h_index) = v2_mode_and_id(&work_index, path);
361            let m_work = worktree_mode(layout.worktree_root(), path);
362            let prefix =
363                format!("2 {x}{y} N... {m_head} {m_index} {m_work} {h_head} {h_index} R100 ");
364            emit_v2_rename_record(&mut stdout, &prefix, path, old, z);
365            continue;
366        }
367        let (m_head, h_head) = v2_mode_and_id(&head_index, path);
368        let (m_index, h_index) = v2_mode_and_id(&work_index, path);
369        let m_work = worktree_mode(layout.worktree_root(), path);
370        let prefix = format!("1 {x}{y} N... {m_head} {m_index} {m_work} {h_head} {h_index} ");
371        emit_v2_record(&mut stdout, &prefix, path, z);
372    }
373    exit::OK
374}
375
376/// Write a v2 `2` rename record: `<prefix><dest><sep><src>` where `<sep>`
377/// is a TAB by default and NUL under `-z` (destination first, then the
378/// source — matching git). Paths are C-quoted when not in `-z` mode.
379fn emit_v2_rename_record(out: &mut impl Write, prefix: &str, new: &str, old: &str, z: bool) {
380    if z {
381        let _ = write!(out, "{prefix}{new}\0{old}\0");
382    } else {
383        let nq = super::c_quote_path(new).unwrap_or_else(|| new.to_string());
384        let oq = super::c_quote_path(old).unwrap_or_else(|| old.to_string());
385        let _ = writeln!(out, "{prefix}{nq}\t{oq}");
386    }
387}
388
389/// Write one v2 record: `<prefix><path>` with git's quoting/termination —
390/// raw + NUL under `-z`, else C-style quoted + newline.
391fn emit_v2_record(out: &mut impl Write, prefix: &str, path: &str, z: bool) {
392    if z {
393        let _ = write!(out, "{prefix}{path}\0");
394    } else if let Some(quoted) = super::c_quote_path(path) {
395        let _ = writeln!(out, "{prefix}{quoted}");
396    } else {
397        let _ = writeln!(out, "{prefix}{path}");
398    }
399}
400
401/// The octal mode and full object id for `path` in `index` (a real index or a
402/// flattened HEAD tree). Absent / removed → `000000` and the all-zero id.
403fn v2_mode_and_id(index: &Index, path: &str) -> (&'static str, String) {
404    match index.find_entry(path) {
405        Some(i) if index.entries[i].status != EntryStatus::Removed => {
406            let e = &index.entries[i];
407            (git_mode(e.status), format::hex_hash(&e.object_hash))
408        }
409        _ => ("000000", format::hex_hash(&mkit_core::hash::ZERO)),
410    }
411}
412
413/// git octal mode for an index entry's status.
414fn git_mode(status: EntryStatus) -> &'static str {
415    match status {
416        EntryStatus::Executable => "100755",
417        EntryStatus::Symlink => "120000",
418        _ => "100644",
419    }
420}
421
422/// The worktree octal mode for `path`. `000000` unless the path is a
423/// *stageable* worktree object — a regular file or a symlink. A directory
424/// (or any other non-file type) at a tracked file path is **not** a valid
425/// worktree side for that path: status reports the tracked file as deleted
426/// (`mW = 000000`) and surfaces anything inside as a separate `?` record,
427/// so reporting `040000` here would misrepresent it as still present.
428fn worktree_mode(root: &Path, path: &str) -> &'static str {
429    let Ok(meta) = std::fs::symlink_metadata(root.join(path)) else {
430        return "000000";
431    };
432    if meta.is_symlink() {
433        "120000"
434    } else if meta.is_file() {
435        if is_executable(&meta) {
436            "100755"
437        } else {
438            "100644"
439        }
440    } else {
441        "000000"
442    }
443}
444
445#[cfg(unix)]
446fn is_executable(meta: &std::fs::Metadata) -> bool {
447    use std::os::unix::fs::PermissionsExt;
448    meta.permissions().mode() & 0o111 != 0
449}
450
451#[cfg(not(unix))]
452fn is_executable(_meta: &std::fs::Metadata) -> bool {
453    false
454}
455
456/// Collapse `status_diff`'s per-(staging) entries into porcelain records,
457/// matching `git status --porcelain`.
458///
459/// A path that is staged **and** further changed in the worktree produces
460/// a single combined code (e.g. `MM`, `AM`) rather than two records: `X`
461/// is the staged (index-vs-HEAD) side, `Y` the unstaged (worktree-vs-index)
462/// side, and `porcelain_code` already returns each side in its column, so
463/// we OR the non-space columns together.
464///
465/// **Untracked entries are the exception** — git treats them as a separate
466/// category, never folded into a tracked path's `XY`. A path can be both
467/// staged-for-deletion *and* present as untracked on disk (`mkit rm
468/// --cached <f>` with the file still there): git emits **two** records,
469/// `D  <f>` then `?? <f>`. So an untracked entry (`Unstaged` + `Added`)
470/// always becomes its own `??` record and is never merged — otherwise the
471/// `??` would clobber the staged `D `, hiding a deletion `commit` records.
472///
473/// Output order matches git: all tracked-change records first (first-seen
474/// order), then all untracked records.
475fn combine_porcelain(entries: &[StatusEntry]) -> Vec<([u8; 2], &str, Option<&str>)> {
476    let mut tracked_order: Vec<&str> = Vec::new();
477    // value = (XY columns, source path for a rename).
478    let mut tracked: std::collections::HashMap<&str, ([u8; 2], Option<&str>)> =
479        std::collections::HashMap::new();
480    let mut untracked: Vec<&str> = Vec::new();
481    for e in entries {
482        // Untracked: a worktree path the index doesn't know about. Never
483        // merged — it is always its own `??` record (see doc comment).
484        if e.staging == StatusStaging::Unstaged && e.diff.kind == DiffKind::Added {
485            untracked.push(&e.diff.path);
486            continue;
487        }
488        let c = porcelain_code(e.staging, e.diff.kind).as_bytes();
489        let slot = tracked.entry(&e.diff.path).or_insert_with(|| {
490            tracked_order.push(&e.diff.path);
491            ([b' ', b' '], None)
492        });
493        // Fill each column from whichever entry sets it (non-space wins).
494        if c[0] != b' ' {
495            slot.0[0] = c[0];
496        }
497        if c[1] != b' ' {
498            slot.0[1] = c[1];
499        }
500        // A rename keyed by its destination path carries the source path
501        // (e.g. an `RM` entry: renamed in index, modified in worktree).
502        if e.diff.kind == DiffKind::Renamed {
503            slot.1 = e.diff.old_path.as_deref();
504        }
505    }
506    let mut out: Vec<([u8; 2], &str, Option<&str>)> = tracked_order
507        .into_iter()
508        .map(|p| {
509            let s = tracked[p];
510            (s.0, p, s.1)
511        })
512        .collect();
513    out.extend(untracked.into_iter().map(|p| ([b'?', b'?'], p, None)));
514    out
515}
516
517/// Map (staging, kind) → two-char XY code per the porcelain format.
518fn porcelain_code(staging: StatusStaging, kind: DiffKind) -> &'static str {
519    match (staging, kind) {
520        (StatusStaging::Staged, DiffKind::Added) => "A ",
521        (StatusStaging::Staged, DiffKind::Removed) => "D ",
522        (StatusStaging::Staged, DiffKind::Modified) => "M ",
523        (StatusStaging::Staged, DiffKind::ModeChanged) => "T ",
524        // Unstaged Added with an index present means the worktree has
525        // a path the index doesn't know about — i.e. untracked. With
526        // no index, every worktree-only entry is also untracked.
527        (StatusStaging::Unstaged, DiffKind::Added) => "??",
528        (StatusStaging::Unstaged, DiffKind::Removed) => " D",
529        (StatusStaging::Unstaged, DiffKind::Modified) => " M",
530        (StatusStaging::Unstaged, DiffKind::ModeChanged) => " T",
531        // PartiallyStaged is documented as retained-for-back-compat
532        // and no longer produced by status_diff post-#102, but render
533        // defensively in case it ever resurfaces. `MM` matches git's
534        // double-mod indicator.
535        (StatusStaging::PartiallyStaged, DiffKind::Added) => "AM",
536        (StatusStaging::PartiallyStaged, DiffKind::Removed) => "MD",
537        (StatusStaging::PartiallyStaged, DiffKind::Modified) => "MM",
538        (StatusStaging::PartiallyStaged, DiffKind::ModeChanged) => "MT",
539        // Renames are detected per staging leg, so they only ever appear
540        // as a clean staged (`R `) or unstaged (` R`) move; PartiallyStaged
541        // can't be produced for a rename but is rendered defensively.
542        (StatusStaging::Staged | StatusStaging::PartiallyStaged, DiffKind::Renamed) => "R ",
543        (StatusStaging::Unstaged, DiffKind::Renamed) => " R",
544    }
545}
546
547/// Default human output, git-shaped. All lines go to stderr — stdout is
548/// reserved for porcelain/data callers (an mkit convention; documented in
549/// docs/CLI.md). A consumer that wants the human format in a pipeline can
550/// `mkit status 2>&1` explicitly; the default pipeline behaviour stays
551/// empty-on-clean. The (use "mkit …") hints name mkit commands, not git.
552fn render_human(layout: &RepoLayout, entries: &[StatusEntry]) -> u8 {
553    let mut stderr = std::io::stderr().lock();
554
555    // Branch / HEAD line, matching git's banners.
556    match refs::read_head(layout) {
557        Ok(refs::Head::Branch(name)) => {
558            let _ = writeln!(stderr, "On branch {name}");
559            if refs::resolve_head(layout).ok().flatten().is_none() {
560                let _ = writeln!(stderr, "\nNo commits yet");
561            }
562        }
563        Ok(refs::Head::Detached(h)) => {
564            let _ = writeln!(
565                stderr,
566                "HEAD detached at {}",
567                crate::format::short_hash(&h, crate::format::SUMMARY_ABBREV)
568            );
569        }
570        Err(_) => {
571            let _ = writeln!(stderr, "On branch main\n\nNo commits yet");
572        }
573    }
574
575    if entries.is_empty() {
576        let _ = writeln!(stderr, "\nnothing to commit, working tree clean");
577        return exit::OK;
578    }
579
580    // An untracked path is an unstaged addition (porcelain `??`); git lists
581    // those in their own section, separate from tracked-but-unstaged edits.
582    let staged: Vec<_> = entries
583        .iter()
584        .filter(|e| e.staging == StatusStaging::Staged)
585        .collect();
586    let partial: Vec<_> = entries
587        .iter()
588        .filter(|e| e.staging == StatusStaging::PartiallyStaged)
589        .collect();
590    let unstaged: Vec<_> = entries
591        .iter()
592        .filter(|e| e.staging == StatusStaging::Unstaged && e.diff.kind != DiffKind::Added)
593        .collect();
594    let untracked: Vec<_> = entries
595        .iter()
596        .filter(|e| e.staging == StatusStaging::Unstaged && e.diff.kind == DiffKind::Added)
597        .collect();
598
599    if !staged.is_empty() {
600        let _ = writeln!(stderr, "\nChanges to be committed:");
601        let _ = writeln!(
602            stderr,
603            "  (use \"mkit restore --staged <file>...\" to unstage)"
604        );
605        for e in &staged {
606            let _ = writeln!(stderr, "	{:<12}{}", human_label(e.diff.kind), human_path(e));
607        }
608    }
609    if !partial.is_empty() {
610        let _ = writeln!(stderr, "\nChanges both staged and not staged:");
611        for e in &partial {
612            let _ = writeln!(stderr, "	{:<12}{}", human_label(e.diff.kind), human_path(e));
613        }
614    }
615    if !unstaged.is_empty() {
616        let _ = writeln!(stderr, "\nChanges not staged for commit:");
617        let _ = writeln!(
618            stderr,
619            "  (use \"mkit add <file>...\" to update what will be committed)"
620        );
621        let _ = writeln!(
622            stderr,
623            "  (use \"mkit restore <file>...\" to discard changes in working directory)"
624        );
625        for e in &unstaged {
626            let _ = writeln!(stderr, "	{:<12}{}", human_label(e.diff.kind), human_path(e));
627        }
628    }
629    if !untracked.is_empty() {
630        let _ = writeln!(stderr, "\nUntracked files:");
631        let _ = writeln!(
632            stderr,
633            "  (use \"mkit add <file>...\" to include in what will be committed)"
634        );
635        for e in &untracked {
636            let _ = writeln!(stderr, "\t{}", e.diff.path);
637        }
638    }
639
640    // Footer guidance, like git's.
641    if staged.is_empty() && partial.is_empty() {
642        if !unstaged.is_empty() {
643            let _ = writeln!(
644                stderr,
645                "\nno changes added to commit (use \"mkit add\" and/or \"mkit commit -a\")"
646            );
647        } else if !untracked.is_empty() {
648            let _ = writeln!(
649                stderr,
650                "\nnothing added to commit but untracked files present (use \"mkit add\" to track)"
651            );
652        }
653    }
654
655    exit::OK
656}
657
658/// git's word label for a change kind.
659fn human_label(kind: DiffKind) -> &'static str {
660    match kind {
661        DiffKind::Added => "new file:",
662        DiffKind::Removed => "deleted:",
663        DiffKind::Modified => "modified:",
664        DiffKind::ModeChanged => "typechange:",
665        DiffKind::Renamed => "renamed:",
666    }
667}
668
669/// The path column for the human listing. A rename renders `old -> new`
670/// (git's form); every other kind is just its path.
671fn human_path(e: &StatusEntry) -> String {
672    match (e.diff.kind, &e.diff.old_path) {
673        (DiffKind::Renamed, Some(old)) => format!("{old} -> {}", e.diff.path),
674        _ => e.diff.path.clone(),
675    }
676}
677
678/// Pair identical-content staged deletes and adds into single `Renamed`
679/// entries, matching git's rename detection in `status`.
680///
681/// Scoped to the staged leg: `git mv` (and `mkit mv`) stage both sides, so
682/// they share a staging state and — because mkit is content-addressed — an
683/// object id. An *unstaged* move leaves the destination untracked (`??`),
684/// which git never folds into a rename, so the worktree leg is left alone.
685fn detect_status_renames(entries: Vec<StatusEntry>) -> Vec<StatusEntry> {
686    let (staged, others): (Vec<StatusEntry>, Vec<StatusEntry>) = entries
687        .into_iter()
688        .partition(|e| e.staging == StatusStaging::Staged);
689    let mut staged_diffs: Vec<DiffEntry> = staged.into_iter().map(|e| e.diff).collect();
690    detect_exact_renames(&mut staged_diffs);
691    let mut out: Vec<StatusEntry> = staged_diffs
692        .into_iter()
693        .map(|d| StatusEntry {
694            diff: d,
695            staging: StatusStaging::Staged,
696        })
697        .chain(others)
698        .collect();
699    // Restore status's canonical order: by path, staged before unstaged.
700    out.sort_by(|a, b| {
701        a.diff
702            .path
703            .cmp(&b.diff.path)
704            .then_with(|| staging_rank(a.staging).cmp(&staging_rank(b.staging)))
705    });
706    out
707}
708
709fn staging_rank(s: StatusStaging) -> u8 {
710    match s {
711        StatusStaging::Staged => 0,
712        StatusStaging::PartiallyStaged => 1,
713        StatusStaging::Unstaged => 2,
714    }
715}
716
717use super::error as emit_err;
718
719#[cfg(test)]
720mod tests {
721    use super::*;
722
723    #[test]
724    fn porcelain_code_matrix() {
725        // Spot-check the matrix corners.
726        assert_eq!(porcelain_code(StatusStaging::Staged, DiffKind::Added), "A ",);
727        assert_eq!(
728            porcelain_code(StatusStaging::Staged, DiffKind::Removed),
729            "D ",
730        );
731        assert_eq!(
732            porcelain_code(StatusStaging::Staged, DiffKind::Modified),
733            "M ",
734        );
735        assert_eq!(
736            porcelain_code(StatusStaging::Unstaged, DiffKind::Added),
737            "??",
738        );
739        assert_eq!(
740            porcelain_code(StatusStaging::Unstaged, DiffKind::Modified),
741            " M",
742        );
743        assert_eq!(
744            porcelain_code(StatusStaging::Unstaged, DiffKind::Removed),
745            " D",
746        );
747    }
748
749    fn entry(path: &str, staging: StatusStaging, kind: DiffKind) -> StatusEntry {
750        StatusEntry {
751            diff: mkit_core::ops::DiffEntry {
752                path: path.to_string(),
753                kind,
754                old_hash: None,
755                new_hash: None,
756                old_mode: None,
757                new_mode: None,
758                old_path: None,
759            },
760            staging,
761        }
762    }
763
764    fn combined(entries: &[StatusEntry]) -> Vec<(String, String)> {
765        combine_porcelain(entries)
766            .into_iter()
767            .map(|(xy, p, _)| (std::str::from_utf8(&xy).unwrap().to_string(), p.to_string()))
768            .collect()
769    }
770
771    #[test]
772    fn combine_merges_staged_and_unstaged_same_path_into_one_record() {
773        use DiffKind::Modified;
774        use StatusStaging::{Staged, Unstaged};
775        // Staged modify + further worktree modify on the same path → one
776        // `MM a.txt` record, not two (git porcelain semantics).
777        let entries = [
778            entry("a.txt", Staged, Modified),
779            entry("a.txt", Unstaged, Modified),
780        ];
781        assert_eq!(combined(&entries), vec![("MM".into(), "a.txt".into())]);
782    }
783
784    #[test]
785    fn combine_staged_add_plus_worktree_modify_is_am() {
786        let entries = [
787            entry("n.txt", StatusStaging::Staged, DiffKind::Added),
788            entry("n.txt", StatusStaging::Unstaged, DiffKind::Modified),
789        ];
790        assert_eq!(combined(&entries), vec![("AM".into(), "n.txt".into())]);
791    }
792
793    #[test]
794    fn combine_preserves_lone_records_and_untracked() {
795        let entries = [
796            entry("staged.txt", StatusStaging::Staged, DiffKind::Added),
797            entry("dirty.txt", StatusStaging::Unstaged, DiffKind::Modified),
798            entry("new.txt", StatusStaging::Unstaged, DiffKind::Added), // untracked → ??
799        ];
800        assert_eq!(
801            combined(&entries),
802            vec![
803                ("A ".into(), "staged.txt".into()),
804                (" M".into(), "dirty.txt".into()),
805                ("??".into(), "new.txt".into()),
806            ]
807        );
808    }
809
810    #[test]
811    fn combine_keeps_staged_delete_and_untracked_at_same_path_separate() {
812        use DiffKind::{Added, Removed};
813        use StatusStaging::{Staged, Unstaged};
814        // `mkit rm --cached a.txt` with the file still on disk: the index
815        // dropped a.txt (staged delete vs HEAD → `D `) but the worktree
816        // still has it, unknown to the index (untracked → `??`). Git emits
817        // BOTH records — the staged deletion must not be clobbered by `??`.
818        let entries = [
819            entry("a.txt", Staged, Removed),
820            entry("a.txt", Unstaged, Added),
821        ];
822        assert_eq!(
823            combined(&entries),
824            vec![("D ".into(), "a.txt".into()), ("??".into(), "a.txt".into())]
825        );
826    }
827
828    #[test]
829    fn combine_orders_all_tracked_before_untracked_like_git() {
830        use DiffKind::{Added, Modified, Removed};
831        use StatusStaging::{Staged, Unstaged};
832        // Mixed: staged-delete-with-untracked (a.txt), a tracked unstaged
833        // modify (m.txt), and a pure untracked file (b.txt). Git groups all
834        // tracked changes first, then all `??` records.
835        let entries = [
836            entry("a.txt", Staged, Removed),
837            entry("a.txt", Unstaged, Added),
838            entry("m.txt", Unstaged, Modified),
839            entry("b.txt", Unstaged, Added),
840        ];
841        assert_eq!(
842            combined(&entries),
843            vec![
844                ("D ".into(), "a.txt".into()),
845                (" M".into(), "m.txt".into()),
846                ("??".into(), "a.txt".into()),
847                ("??".into(), "b.txt".into()),
848            ]
849        );
850    }
851
852    #[test]
853    fn porcelain_codes_are_two_chars() {
854        use DiffKind::{Added, ModeChanged, Modified, Removed};
855        use StatusStaging::{PartiallyStaged, Staged, Unstaged};
856        for s in [Staged, Unstaged, PartiallyStaged] {
857            for k in [Added, Removed, Modified, ModeChanged] {
858                assert_eq!(porcelain_code(s, k).len(), 2, "{s:?} + {k:?}");
859            }
860        }
861    }
862}