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. mkit emits no rename (`2`) records — it has no
61//! rename detection — and no `--branch` header lines. Path quoting and
62//! `-z` 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::ops::{DiffKind, StatusEntry, StatusStaging, status_diff_observed};
72use mkit_core::refs;
73use mkit_core::store::ObjectStore;
74
75use crate::clap_shim;
76use crate::exit;
77use crate::format;
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
80enum PorcelainVersion {
81    V1,
82    V2,
83}
84
85#[derive(Debug, Parser)]
86#[command(
87    name = "mkit status",
88    about = "Show working-tree changes relative to HEAD."
89)]
90struct StatusOpts {
91    /// Emit machine-readable XY-code-plus-path on stdout. Default
92    /// `v1` matches `git status --porcelain=v1`.
93    #[arg(long, value_name = "VERSION", num_args = 0..=1, default_missing_value = "v1")]
94    porcelain: Option<PorcelainVersion>,
95
96    /// Short format. Alias for `--porcelain=v1`: emits the same
97    /// XY-code-plus-path lines on stdout.
98    #[arg(short = 's', long = "short")]
99    short: bool,
100
101    /// NUL-terminate entries instead of newline, and emit raw (unquoted)
102    /// paths — like `git status -z`. Implies porcelain output. Without
103    /// `-z`, paths with special bytes are C-style quoted.
104    #[arg(short = 'z')]
105    z: bool,
106}
107
108#[must_use]
109pub fn run(args: &[String]) -> u8 {
110    let opts = match clap_shim::parse::<StatusOpts>("mkit status", args) {
111        Ok(o) => o,
112        Err(code) => return code,
113    };
114    // `-s`/`--short` is an alias for `--porcelain=v1`; `-z` also implies
115    // porcelain output. All select the line-oriented XY renderer on stdout.
116    let porcelain = opts.porcelain.is_some() || opts.short || opts.z;
117
118    let cwd = match std::env::current_dir() {
119        Ok(p) => p,
120        Err(e) => return emit_err(&format!("cwd: {e}"), exit::NOINPUT),
121    };
122    let store = match ObjectStore::open(&cwd) {
123        Ok(s) => s,
124        Err(e) => return emit_err(&format!("not a mkit repo: {e}"), exit::GENERAL_ERROR),
125    };
126    let mkit_dir = cwd.join(mkit_core::MKIT_DIR);
127
128    // Resolve HEAD tree hash (None on a HEAD-less repo). Use the shared
129    // helper so a `Remix` HEAD is compared against its tree like every
130    // other command, not treated as "no HEAD".
131    let head_tree: Option<mkit_core::Hash> = match super::current_head_tree(&cwd, &store) {
132        Ok(t) => t,
133        Err(e) => return emit_err(&format!("status: {e}"), exit::GENERAL_ERROR),
134    };
135
136    // Load the index, falling back to None only when absent/empty.
137    // Corrupt or invalid persisted state must surface instead of
138    // silently reverting to the HEAD<->worktree comparison.
139    let idx = match index::read_index(&cwd) {
140        Ok(idx) if idx.entries.is_empty() => None,
141        Ok(idx) => Some(idx),
142        Err(e) => return emit_err(&format!("read index: {e}"), exit::GENERAL_ERROR),
143    };
144
145    let (entries, observations) =
146        match status_diff_observed(&store, head_tree.as_ref(), &cwd, idx.as_ref()) {
147            Ok(v) => v,
148            Err(e) => return emit_err(&format!("status: {e}"), exit::GENERAL_ERROR),
149        };
150
151    // Opportunistic stat-cache refresh, like `git status`: entries the
152    // racy-clean rule forced us to re-hash and whose re-hash matched
153    // the staged hash get their cache re-recorded from the HASH-TIME
154    // stat (never a later one — see StatObservation). Purely an
155    // optimisation — skipped on lock contention or any error.
156    if idx.is_some() {
157        refresh_stat_cache(&cwd, &observations);
158    }
159
160    if porcelain {
161        if opts.porcelain == Some(PorcelainVersion::V2) {
162            render_porcelain_v2(&store, head_tree.as_ref(), &cwd, &entries, opts.z)
163        } else {
164            render_porcelain(&entries, opts.z)
165        }
166    } else {
167        render_human(&mkit_dir, &entries)
168    }
169}
170
171/// Re-record the stat cache from the worktree walk's hash-time
172/// [`StatObservation`]s. Sound by construction:
173///
174/// - each observation pairs a hash with the stat captured from the
175///   opened fd BEFORE its content was read — a modification after that
176///   stat lands a newer mtime/ctime, so the recorded pair can only
177///   under-claim, never hide an edit;
178/// - the rewrite happens under the worktree lock against a freshly
179///   re-read index, matching path AND hash, so a concurrent `add` is
180///   never clobbered;
181/// - a v1 on-disk index is left untouched: `status` is a query and must
182///   not one-way-upgrade the format under an older binary's feet (the
183///   first mutating command performs the upgrade instead).
184///
185/// Lock contention or any error skips the refresh — it is an
186/// optimisation.
187fn refresh_stat_cache(root: &Path, observations: &[mkit_core::worktree::StatObservation]) {
188    if observations.is_empty() {
189        return;
190    }
191    // Version sniff: never auto-upgrade a v1 index from a query command.
192    match std::fs::File::open(mkit_core::index::index_path(root)) {
193        Ok(mut f) => {
194            use std::io::Read as _;
195            let mut header = [0u8; 5];
196            if f.read_exact(&mut header).is_err() || header[4] != mkit_core::index::FORMAT_VERSION {
197                return;
198            }
199        }
200        Err(_) => return,
201    }
202    // Try-take the worktree lock with a near-zero timeout and no error
203    // output; a concurrent mutator wins and we silently skip.
204    let Ok(_lock) = mkit_core::repo_lock::acquire(
205        &root.join(mkit_core::MKIT_DIR),
206        super::WORKTREE_LOCK,
207        std::time::Duration::from_millis(10),
208    ) else {
209        return;
210    };
211    let Ok(mut fresh) = index::read_index(root) else {
212        return;
213    };
214    let by_path: std::collections::HashMap<&str, &mkit_core::worktree::StatObservation> =
215        observations.iter().map(|o| (o.path.as_str(), o)).collect();
216    let mut updated = false;
217    for e in &mut fresh.entries {
218        let Some(obs) = by_path.get(e.path.as_str()) else {
219            continue;
220        };
221        // Heal any clean-but-stale stat cache, not just the zero-mtime
222        // first-observation case: a metadata-only touch (chmod, link
223        // count, atime-bump that moved ctime) leaves nonzero-but-stale
224        // fields whose content still hashes to the cached object. Those
225        // would re-hash on EVERY future `status` until refreshed. When
226        // the hash still matches, write back whichever stat fields drifted.
227        if e.object_hash == obs.object_hash
228            && (e.mtime_ns != obs.mtime_ns
229                || e.size != obs.size
230                || e.ino != obs.ino
231                || e.ctime_ns != obs.ctime_ns)
232        {
233            e.mtime_ns = obs.mtime_ns;
234            e.size = obs.size;
235            e.ino = obs.ino;
236            e.ctime_ns = obs.ctime_ns;
237            updated = true;
238        }
239    }
240    if updated {
241        let _ = index::write_index(root, &fresh);
242    }
243}
244
245/// `--porcelain[=v1]` output — XY-code-plus-path, one entry per record.
246/// Empty stdout means clean. Matches `git status --porcelain` for the
247/// codes mkit and git share; `T ` (`ModeChanged`) is the only non-git
248/// extension.
249///
250/// With `z = false` (default), records are newline-terminated and a path
251/// containing special bytes is C-style quoted (matching git's default
252/// `core.quotePath`). With `z = true` (`-z`), records are NUL-terminated
253/// and paths are emitted **raw** (unquoted) — the round-trip-safe form
254/// for paths that contain newlines or other special bytes.
255fn render_porcelain(entries: &[StatusEntry], z: bool) -> u8 {
256    let mut stdout = std::io::stdout().lock();
257    for (xy, path) in combine_porcelain(entries) {
258        // `xy` is two ASCII status columns by construction.
259        let code = std::str::from_utf8(&xy).unwrap_or("??");
260        if z {
261            let _ = write!(stdout, "{code} {path}\0");
262        } else if let Some(quoted) = super::c_quote_path(path) {
263            let _ = writeln!(stdout, "{code} {quoted}");
264        } else {
265            let _ = writeln!(stdout, "{code} {path}");
266        }
267    }
268    exit::OK
269}
270
271/// `--porcelain=v2` output — git's richer per-path format. Each changed
272/// tracked path is a `1 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <path>` line
273/// (mkit emits no rename `2` lines — it has no rename detection — and no
274/// submodules, so `<sub>` is always `N...`); untracked paths are `? <path>`.
275///
276/// `<XY>` uses `.` for an unchanged column (vs v1's space). `<mH>`/`<mI>` are
277/// the HEAD/index octal modes, `<mW>` the worktree mode (`000000` when the
278/// side is absent); `<hH>`/`<hI>` are the HEAD/index object ids (full 64-hex
279/// BLAKE3 — longer than git's SHA-1, the documented hash-length divergence).
280/// Without `--branch` there are no header lines, matching git.
281fn render_porcelain_v2(
282    store: &ObjectStore,
283    head_tree: Option<&Hash>,
284    root: &Path,
285    entries: &[StatusEntry],
286    z: bool,
287) -> u8 {
288    // HEAD paths (mode+id) via a flattened tree; the effective staging index
289    // (seeded from HEAD when no index file exists) for the index columns.
290    let head_index = match head_tree {
291        Some(h) => match index::from_tree(store, *h) {
292            Ok(i) => i,
293            Err(e) => return emit_err(&format!("read HEAD tree: {e}"), exit::GENERAL_ERROR),
294        },
295        None => Index::new(),
296    };
297    let work_index = match super::read_or_seed_index_from_head(root, store) {
298        Ok(i) => i,
299        Err(e) => return emit_err(&e, exit::GENERAL_ERROR),
300    };
301
302    let mut stdout = std::io::stdout().lock();
303    for (xy, path) in combine_porcelain(entries) {
304        if xy == [b'?', b'?'] {
305            emit_v2_record(&mut stdout, "? ", path, z);
306            continue;
307        }
308        // v2 uses `.` for an unchanged column, not a space.
309        let x = if xy[0] == b' ' { '.' } else { xy[0] as char };
310        let y = if xy[1] == b' ' { '.' } else { xy[1] as char };
311        let (m_head, h_head) = v2_mode_and_id(&head_index, path);
312        let (m_index, h_index) = v2_mode_and_id(&work_index, path);
313        let m_work = worktree_mode(root, path);
314        let prefix = format!("1 {x}{y} N... {m_head} {m_index} {m_work} {h_head} {h_index} ");
315        emit_v2_record(&mut stdout, &prefix, path, z);
316    }
317    exit::OK
318}
319
320/// Write one v2 record: `<prefix><path>` with git's quoting/termination —
321/// raw + NUL under `-z`, else C-style quoted + newline.
322fn emit_v2_record(out: &mut impl Write, prefix: &str, path: &str, z: bool) {
323    if z {
324        let _ = write!(out, "{prefix}{path}\0");
325    } else if let Some(quoted) = super::c_quote_path(path) {
326        let _ = writeln!(out, "{prefix}{quoted}");
327    } else {
328        let _ = writeln!(out, "{prefix}{path}");
329    }
330}
331
332/// The octal mode and full object id for `path` in `index` (a real index or a
333/// flattened HEAD tree). Absent / removed → `000000` and the all-zero id.
334fn v2_mode_and_id(index: &Index, path: &str) -> (&'static str, String) {
335    match index.find_entry(path) {
336        Some(i) if index.entries[i].status != EntryStatus::Removed => {
337            let e = &index.entries[i];
338            (git_mode(e.status), format::hex_hash(&e.object_hash))
339        }
340        _ => ("000000", format::hex_hash(&mkit_core::hash::ZERO)),
341    }
342}
343
344/// git octal mode for an index entry's status.
345fn git_mode(status: EntryStatus) -> &'static str {
346    match status {
347        EntryStatus::Executable => "100755",
348        EntryStatus::Symlink => "120000",
349        _ => "100644",
350    }
351}
352
353/// The worktree octal mode for `path`. `000000` unless the path is a
354/// *stageable* worktree object — a regular file or a symlink. A directory
355/// (or any other non-file type) at a tracked file path is **not** a valid
356/// worktree side for that path: status reports the tracked file as deleted
357/// (`mW = 000000`) and surfaces anything inside as a separate `?` record,
358/// so reporting `040000` here would misrepresent it as still present.
359fn worktree_mode(root: &Path, path: &str) -> &'static str {
360    let Ok(meta) = std::fs::symlink_metadata(root.join(path)) else {
361        return "000000";
362    };
363    if meta.is_symlink() {
364        "120000"
365    } else if meta.is_file() {
366        if is_executable(&meta) {
367            "100755"
368        } else {
369            "100644"
370        }
371    } else {
372        "000000"
373    }
374}
375
376#[cfg(unix)]
377fn is_executable(meta: &std::fs::Metadata) -> bool {
378    use std::os::unix::fs::PermissionsExt;
379    meta.permissions().mode() & 0o111 != 0
380}
381
382#[cfg(not(unix))]
383fn is_executable(_meta: &std::fs::Metadata) -> bool {
384    false
385}
386
387/// Collapse `status_diff`'s per-(staging) entries into porcelain records,
388/// matching `git status --porcelain`.
389///
390/// A path that is staged **and** further changed in the worktree produces
391/// a single combined code (e.g. `MM`, `AM`) rather than two records: `X`
392/// is the staged (index-vs-HEAD) side, `Y` the unstaged (worktree-vs-index)
393/// side, and `porcelain_code` already returns each side in its column, so
394/// we OR the non-space columns together.
395///
396/// **Untracked entries are the exception** — git treats them as a separate
397/// category, never folded into a tracked path's `XY`. A path can be both
398/// staged-for-deletion *and* present as untracked on disk (`mkit rm
399/// --cached <f>` with the file still there): git emits **two** records,
400/// `D  <f>` then `?? <f>`. So an untracked entry (`Unstaged` + `Added`)
401/// always becomes its own `??` record and is never merged — otherwise the
402/// `??` would clobber the staged `D `, hiding a deletion `commit` records.
403///
404/// Output order matches git: all tracked-change records first (first-seen
405/// order), then all untracked records.
406fn combine_porcelain(entries: &[StatusEntry]) -> Vec<([u8; 2], &str)> {
407    let mut tracked_order: Vec<&str> = Vec::new();
408    let mut tracked: std::collections::HashMap<&str, [u8; 2]> = std::collections::HashMap::new();
409    let mut untracked: Vec<&str> = Vec::new();
410    for e in entries {
411        // Untracked: a worktree path the index doesn't know about. Never
412        // merged — it is always its own `??` record (see doc comment).
413        if e.staging == StatusStaging::Unstaged && e.diff.kind == DiffKind::Added {
414            untracked.push(&e.diff.path);
415            continue;
416        }
417        let c = porcelain_code(e.staging, e.diff.kind).as_bytes();
418        let slot = tracked.entry(&e.diff.path).or_insert_with(|| {
419            tracked_order.push(&e.diff.path);
420            [b' ', b' ']
421        });
422        // Fill each column from whichever entry sets it (non-space wins).
423        if c[0] != b' ' {
424            slot[0] = c[0];
425        }
426        if c[1] != b' ' {
427            slot[1] = c[1];
428        }
429    }
430    let mut out: Vec<([u8; 2], &str)> =
431        tracked_order.into_iter().map(|p| (tracked[p], p)).collect();
432    out.extend(untracked.into_iter().map(|p| ([b'?', b'?'], p)));
433    out
434}
435
436/// Map (staging, kind) → two-char XY code per the porcelain format.
437fn porcelain_code(staging: StatusStaging, kind: DiffKind) -> &'static str {
438    match (staging, kind) {
439        (StatusStaging::Staged, DiffKind::Added) => "A ",
440        (StatusStaging::Staged, DiffKind::Removed) => "D ",
441        (StatusStaging::Staged, DiffKind::Modified) => "M ",
442        (StatusStaging::Staged, DiffKind::ModeChanged) => "T ",
443        // Unstaged Added with an index present means the worktree has
444        // a path the index doesn't know about — i.e. untracked. With
445        // no index, every worktree-only entry is also untracked.
446        (StatusStaging::Unstaged, DiffKind::Added) => "??",
447        (StatusStaging::Unstaged, DiffKind::Removed) => " D",
448        (StatusStaging::Unstaged, DiffKind::Modified) => " M",
449        (StatusStaging::Unstaged, DiffKind::ModeChanged) => " T",
450        // PartiallyStaged is documented as retained-for-back-compat
451        // and no longer produced by status_diff post-#102, but render
452        // defensively in case it ever resurfaces. `MM` matches git's
453        // double-mod indicator.
454        (StatusStaging::PartiallyStaged, DiffKind::Added) => "AM",
455        (StatusStaging::PartiallyStaged, DiffKind::Removed) => "MD",
456        (StatusStaging::PartiallyStaged, DiffKind::Modified) => "MM",
457        (StatusStaging::PartiallyStaged, DiffKind::ModeChanged) => "MT",
458    }
459}
460
461/// Default human output. All lines go to stderr — stdout is reserved
462/// for porcelain/data callers. A consumer that wants the human format
463/// in a pipeline can `mkit status 2>&1` explicitly; the default
464/// pipeline behaviour stays empty-on-clean.
465fn render_human(mkit_dir: &std::path::Path, entries: &[StatusEntry]) -> u8 {
466    let mut stderr = std::io::stderr().lock();
467
468    // Branch / HEAD line.
469    match refs::read_head(mkit_dir) {
470        Ok(refs::Head::Branch(name)) => {
471            let _ = writeln!(stderr, "on branch {name}");
472        }
473        Ok(refs::Head::Detached(h)) => {
474            let _ = writeln!(stderr, "detached HEAD at {}", mkit_core::hash::to_hex(&h));
475        }
476        Err(_) => {
477            let _ = writeln!(stderr, "no HEAD yet");
478        }
479    }
480
481    if entries.is_empty() {
482        let _ = writeln!(stderr, "nothing to commit, working tree clean");
483        return exit::OK;
484    }
485
486    let staged: Vec<_> = entries
487        .iter()
488        .filter(|e| e.staging == StatusStaging::Staged)
489        .collect();
490    let unstaged: Vec<_> = entries
491        .iter()
492        .filter(|e| e.staging == StatusStaging::Unstaged)
493        .collect();
494    let partial: Vec<_> = entries
495        .iter()
496        .filter(|e| e.staging == StatusStaging::PartiallyStaged)
497        .collect();
498
499    if !staged.is_empty() {
500        let _ = writeln!(stderr, "\nChanges to be committed:");
501        for e in &staged {
502            let tag = diff_tag(e.diff.kind);
503            let _ = writeln!(stderr, "  {tag}  {}", e.diff.path);
504        }
505    }
506    if !unstaged.is_empty() {
507        let _ = writeln!(stderr, "\nChanges not staged for commit:");
508        for e in &unstaged {
509            let tag = diff_tag(e.diff.kind);
510            let _ = writeln!(stderr, "  {tag}  {}", e.diff.path);
511        }
512    }
513    if !partial.is_empty() {
514        let _ = writeln!(stderr, "\nChanges partially staged:");
515        for e in &partial {
516            let tag = diff_tag(e.diff.kind);
517            let _ = writeln!(stderr, "  {tag}  {}", e.diff.path);
518        }
519    }
520
521    exit::OK
522}
523
524fn diff_tag(kind: DiffKind) -> &'static str {
525    match kind {
526        DiffKind::Added => "A",
527        DiffKind::Removed => "D",
528        DiffKind::Modified => "M",
529        DiffKind::ModeChanged => "T",
530    }
531}
532
533fn emit_err(msg: &str, code: u8) -> u8 {
534    let mut stderr = std::io::stderr().lock();
535    let _ = writeln!(stderr, "error: {msg}");
536    code
537}
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542
543    #[test]
544    fn porcelain_code_matrix() {
545        // Spot-check the matrix corners.
546        assert_eq!(porcelain_code(StatusStaging::Staged, DiffKind::Added), "A ",);
547        assert_eq!(
548            porcelain_code(StatusStaging::Staged, DiffKind::Removed),
549            "D ",
550        );
551        assert_eq!(
552            porcelain_code(StatusStaging::Staged, DiffKind::Modified),
553            "M ",
554        );
555        assert_eq!(
556            porcelain_code(StatusStaging::Unstaged, DiffKind::Added),
557            "??",
558        );
559        assert_eq!(
560            porcelain_code(StatusStaging::Unstaged, DiffKind::Modified),
561            " M",
562        );
563        assert_eq!(
564            porcelain_code(StatusStaging::Unstaged, DiffKind::Removed),
565            " D",
566        );
567    }
568
569    fn entry(path: &str, staging: StatusStaging, kind: DiffKind) -> StatusEntry {
570        StatusEntry {
571            diff: mkit_core::ops::DiffEntry {
572                path: path.to_string(),
573                kind,
574                old_hash: None,
575                new_hash: None,
576                old_mode: None,
577                new_mode: None,
578            },
579            staging,
580        }
581    }
582
583    fn combined(entries: &[StatusEntry]) -> Vec<(String, String)> {
584        combine_porcelain(entries)
585            .into_iter()
586            .map(|(xy, p)| (std::str::from_utf8(&xy).unwrap().to_string(), p.to_string()))
587            .collect()
588    }
589
590    #[test]
591    fn combine_merges_staged_and_unstaged_same_path_into_one_record() {
592        use DiffKind::Modified;
593        use StatusStaging::{Staged, Unstaged};
594        // Staged modify + further worktree modify on the same path → one
595        // `MM a.txt` record, not two (git porcelain semantics).
596        let entries = [
597            entry("a.txt", Staged, Modified),
598            entry("a.txt", Unstaged, Modified),
599        ];
600        assert_eq!(combined(&entries), vec![("MM".into(), "a.txt".into())]);
601    }
602
603    #[test]
604    fn combine_staged_add_plus_worktree_modify_is_am() {
605        let entries = [
606            entry("n.txt", StatusStaging::Staged, DiffKind::Added),
607            entry("n.txt", StatusStaging::Unstaged, DiffKind::Modified),
608        ];
609        assert_eq!(combined(&entries), vec![("AM".into(), "n.txt".into())]);
610    }
611
612    #[test]
613    fn combine_preserves_lone_records_and_untracked() {
614        let entries = [
615            entry("staged.txt", StatusStaging::Staged, DiffKind::Added),
616            entry("dirty.txt", StatusStaging::Unstaged, DiffKind::Modified),
617            entry("new.txt", StatusStaging::Unstaged, DiffKind::Added), // untracked → ??
618        ];
619        assert_eq!(
620            combined(&entries),
621            vec![
622                ("A ".into(), "staged.txt".into()),
623                (" M".into(), "dirty.txt".into()),
624                ("??".into(), "new.txt".into()),
625            ]
626        );
627    }
628
629    #[test]
630    fn combine_keeps_staged_delete_and_untracked_at_same_path_separate() {
631        use DiffKind::{Added, Removed};
632        use StatusStaging::{Staged, Unstaged};
633        // `mkit rm --cached a.txt` with the file still on disk: the index
634        // dropped a.txt (staged delete vs HEAD → `D `) but the worktree
635        // still has it, unknown to the index (untracked → `??`). Git emits
636        // BOTH records — the staged deletion must not be clobbered by `??`.
637        let entries = [
638            entry("a.txt", Staged, Removed),
639            entry("a.txt", Unstaged, Added),
640        ];
641        assert_eq!(
642            combined(&entries),
643            vec![("D ".into(), "a.txt".into()), ("??".into(), "a.txt".into())]
644        );
645    }
646
647    #[test]
648    fn combine_orders_all_tracked_before_untracked_like_git() {
649        use DiffKind::{Added, Modified, Removed};
650        use StatusStaging::{Staged, Unstaged};
651        // Mixed: staged-delete-with-untracked (a.txt), a tracked unstaged
652        // modify (m.txt), and a pure untracked file (b.txt). Git groups all
653        // tracked changes first, then all `??` records.
654        let entries = [
655            entry("a.txt", Staged, Removed),
656            entry("a.txt", Unstaged, Added),
657            entry("m.txt", Unstaged, Modified),
658            entry("b.txt", Unstaged, Added),
659        ];
660        assert_eq!(
661            combined(&entries),
662            vec![
663                ("D ".into(), "a.txt".into()),
664                (" M".into(), "m.txt".into()),
665                ("??".into(), "a.txt".into()),
666                ("??".into(), "b.txt".into()),
667            ]
668        );
669    }
670
671    #[test]
672    fn porcelain_codes_are_two_chars() {
673        use DiffKind::{Added, ModeChanged, Modified, Removed};
674        use StatusStaging::{PartiallyStaged, Staged, Unstaged};
675        for s in [Staged, Unstaged, PartiallyStaged] {
676            for k in [Added, Removed, Modified, ModeChanged] {
677                assert_eq!(porcelain_code(s, k).len(), 2, "{s:?} + {k:?}");
678            }
679        }
680    }
681}