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