Skip to main content

mkit_cli/commands/
git_tools.rs

1//! `mkit git verify` / `status` / `format-patch` (feature
2//! `git-bridge`): bridge-state inspection and audit.
3//!
4//! `verify` re-checks a state dir's staging mirror against the local
5//! store: bridge-translated objects shallow-verify (SPEC-GIT-BRIDGE
6//! §10) and must reconstruct to their mapped mkit twin; imported
7//! objects must have retained raw bytes hashing to their sha1 and a
8//! twin signed by the pinned importer key (SPEC-GIT-IMPORT §4).
9//! `--fork-audit` adds §14.3 step 3: every tree/blob referenced by a
10//! bridge commit re-derives from its mkit twin and must reproduce the
11//! exact sha1.
12//!
13//! `format-patch` renders native commits as `git am`-able mbox
14//! patches, so contributions can flow to a git upstream even without
15//! a writable fork (the no-export collaboration path).
16
17use std::collections::{HashMap, HashSet};
18use std::fmt::Write as _;
19use std::io::Write;
20use std::path::{Path, PathBuf};
21
22use clap::Parser;
23use mkit_core::Hash;
24use mkit_core::layout::RepoLayout;
25use mkit_core::object::{Commit, Object};
26use mkit_core::store::ObjectStore;
27use mkit_git_bridge::gitobj::{GitObject, GitType, Sha1Id, sha1_hex};
28use mkit_git_bridge::gitsrc::{CatFileBatch, GitObjKind};
29use mkit_git_bridge::{author, gitparse, map};
30
31use super::revspec;
32use crate::exit;
33
34type CmdResult<T> = Result<T, (String, u8)>;
35
36const ATTESTATIONS_REF: &str = "refs/mkit/attestations";
37
38#[derive(Debug, Parser)]
39pub(super) struct VerifyArgs {
40    /// Bridge state name under `.mkit/git/`. Optional when exactly
41    /// one state dir exists.
42    #[arg(long = "remote-name", value_name = "NAME")]
43    pub remote_name: Option<String>,
44    /// Full fork audit (SPEC-GIT-BRIDGE §14.3): additionally
45    /// re-derive every tree/blob referenced by bridge commits from
46    /// its mkit twin and require the exact sha1.
47    #[arg(long = "fork-audit")]
48    pub fork_audit: bool,
49    /// Verify only these refs (full names). Default: every ref
50    /// recorded in the state dir.
51    #[arg(long = "ref", value_name = "REF")]
52    pub refs: Vec<String>,
53}
54
55#[derive(Debug, Parser)]
56pub(super) struct StatusArgs {}
57
58#[derive(Debug, Parser)]
59pub(super) struct FormatPatchArgs {
60    /// Commit range `A..B` (or a single rev, meaning `<rev>..HEAD`).
61    pub range: String,
62    /// Write patch files into this directory (default: current dir).
63    #[arg(short = 'o', long = "output-directory", value_name = "DIR")]
64    pub output: Option<PathBuf>,
65    /// Print all patches to stdout instead of writing files.
66    #[arg(long)]
67    pub stdout: bool,
68}
69
70// ─── shared state-dir resolution ────────────────────────────────────
71
72fn state_names(layout: &RepoLayout) -> Vec<String> {
73    let mut names = Vec::new();
74    if let Ok(rd) = std::fs::read_dir(layout.git_state_dir()) {
75        for e in rd.flatten() {
76            if e.path().is_dir()
77                && let Some(name) = e.file_name().to_str()
78                // Dot-leading entries are never valid bridge-state names
79                // (mirrors `validate_ref_name`'s dot-leading-component
80                // rejection): skips crash debris from a `remote rename`
81                // move parked at a `.rename.tmp.<pid>.<seq>` temp dir
82                // directly under this root, so it never fools zero-arg
83                // state resolution into reporting "multiple bridge states".
84                && !name.starts_with('.')
85            {
86                names.push(name.to_owned());
87            }
88        }
89    }
90    names.sort();
91    names
92}
93
94fn resolve_state(layout: &RepoLayout, remote_name: Option<&str>) -> CmdResult<(String, PathBuf)> {
95    if let Some(name) = remote_name {
96        let state = map::state_dir(layout, name).map_err(|e| (e.to_string(), exit::USAGE))?;
97        if !state.is_dir() {
98            return Err((format!("no bridge state for '{name}'"), exit::NOINPUT));
99        }
100        return Ok((name.to_owned(), state));
101    }
102    let names = state_names(layout);
103    match names.as_slice() {
104        [] => Err((
105            "no git bridge state (run `mkit git import` or `mkit git export` first)".into(),
106            exit::NOINPUT,
107        )),
108        [one] => Ok((one.clone(), layout.git_state_dir().join(one))),
109        many => Err((
110            format!(
111                "multiple bridge states ({}); pick one with --remote-name",
112                many.join(", ")
113            ),
114            exit::USAGE,
115        )),
116    }
117}
118
119fn open_repo(cwd: &Path) -> CmdResult<(RepoLayout, ObjectStore)> {
120    let layout = mkit_core::layout::discover(cwd)
121        .map_err(|e| (format!("worktree discovery: {e}"), exit::DATAERR))?;
122    if !layout.common_dir().is_dir() {
123        return Err(("not a mkit repository".into(), exit::USAGE));
124    }
125    let store =
126        ObjectStore::open(&layout).map_err(|e| (format!("open store: {e}"), exit::NOINPUT))?;
127    Ok((layout, store))
128}
129
130// ─── mkit git verify ────────────────────────────────────────────────
131
132mod audit;
133
134pub(super) fn verify(args: &VerifyArgs) -> CmdResult<()> {
135    audit::run(args)
136}
137
138// ─── mkit git status ────────────────────────────────────────────────
139
140pub(super) fn status(_args: &StatusArgs) -> CmdResult<()> {
141    let cwd = std::env::current_dir().map_err(|e| (format!("cwd: {e}"), exit::NOINPUT))?;
142    let (layout, _store) = open_repo(&cwd)?;
143    let names = state_names(&layout);
144    let mut out = std::io::stdout().lock();
145    if names.is_empty() {
146        let _ = writeln!(
147            out,
148            "no git bridge state (run `mkit git import` or `mkit git export` first)"
149        );
150        return Ok(());
151    }
152    for name in &names {
153        let state = layout.git_state_dir().join(name);
154        let direction = map::read_direction(&state)
155            .ok()
156            .flatten()
157            .map_or("unknown", |d| d.as_str());
158        let _ = writeln!(out, "{name}  direction={direction}");
159        for (file, label) in [("source", "source"), ("dest", "dest")] {
160            if let Ok(v) = std::fs::read_to_string(state.join(file)) {
161                let _ = writeln!(out, "  {label}: {}", v.trim());
162            }
163        }
164        if let Ok(Some(key)) = map::read_signer(&state) {
165            let _ = writeln!(
166                out,
167                "  importer key: {}… (pinned)",
168                &mkit_git_bridge::gitobj::bytes_hex(&key)[..16]
169            );
170        }
171        for s in map::load_import_ref_state(&state).unwrap_or_default() {
172            let _ = writeln!(
173                out,
174                "  tracking {} @ {} (import)",
175                s.ref_name,
176                &sha1_hex(&s.git_id)[..12]
177            );
178        }
179        for s in map::load_ref_state(&state).unwrap_or_default() {
180            if s.ref_name == ATTESTATIONS_REF {
181                continue;
182            }
183            let _ = writeln!(
184                out,
185                "  exported {} @ {} (lease)",
186                s.ref_name,
187                &sha1_hex(&s.git_id)[..12]
188            );
189        }
190        let staging = if state.join("repo.git/objects").is_dir() {
191            "ok"
192        } else {
193            "missing"
194        };
195        let _ = writeln!(out, "  staging: {staging}");
196    }
197    Ok(())
198}
199
200// ─── mkit git format-patch ──────────────────────────────────────────
201
202/// A patch series: oldest-first hashes, the commit set, and the
203/// resolved `A`/`B` endpoint spellings (for messages).
204type Series = (Vec<Hash>, HashMap<Hash, Commit>, String, String);
205
206/// Resolve `A..B` (or `<rev>` = `<rev>..HEAD`) to the commit set and
207/// its oldest-first topological order (parents before children,
208/// timestamp as the tiebreak).
209fn range_commits(store: &ObjectStore, layout: &RepoLayout, range: &str) -> CmdResult<Series> {
210    let (a, b) = match range.split_once("..") {
211        Some((a, b)) => (
212            a.to_owned(),
213            if b.is_empty() {
214                "HEAD".to_owned()
215            } else {
216                b.to_owned()
217            },
218        ),
219        None => (range.to_owned(), "HEAD".to_owned()),
220    };
221    let resolve = |spec: &str| -> CmdResult<Hash> {
222        revspec::resolve_revision(store, layout, spec)
223            .map_err(|e| (format!("{spec}: {e}"), exit::DATAERR))
224    };
225    let exclude_tip = peel(store, resolve(&a)?);
226    let include_tip = peel(store, resolve(&b)?);
227    for (spec, h) in [(&a, exclude_tip), (&b, include_tip)] {
228        if !matches!(store.read_object(&h), Ok(Object::Commit(_))) {
229            // A non-commit endpoint would silently exclude nothing
230            // and render the entire history as the series.
231            return Err((format!("{spec}: not a commit"), exit::DATAERR));
232        }
233    }
234
235    // Ancestors of A drop out of the patch series.
236    let mut excluded: HashSet<Hash> = HashSet::new();
237    let mut stack = vec![exclude_tip];
238    while let Some(h) = stack.pop() {
239        if !excluded.insert(h) {
240            continue;
241        }
242        if let Ok(Object::Commit(c)) = store.read_object(&h) {
243            stack.extend(c.parents.iter().copied());
244        }
245    }
246    let mut commits: HashMap<Hash, Commit> = HashMap::new();
247    let mut stack = vec![include_tip];
248    while let Some(h) = stack.pop() {
249        if excluded.contains(&h) || commits.contains_key(&h) {
250            continue;
251        }
252        let c = match store.read_object(&h) {
253            Ok(Object::Commit(c)) => c,
254            Ok(_) => {
255                return Err((
256                    format!("not a commit: {}", mkit_core::to_hex(&h)),
257                    exit::DATAERR,
258                ));
259            }
260            Err(e) => {
261                return Err((
262                    format!("read {}: {e}", mkit_core::to_hex(&h)),
263                    exit::DATAERR,
264                ));
265            }
266        };
267        stack.extend(c.parents.iter().copied());
268        commits.insert(h, c);
269    }
270
271    let mut remaining: Vec<Hash> = commits.keys().copied().collect();
272    remaining.sort_by_key(|h| (commits[h].timestamp, *h));
273    let mut ordered: Vec<Hash> = Vec::with_capacity(remaining.len());
274    let mut placed: HashSet<Hash> = HashSet::new();
275    while !remaining.is_empty() {
276        let before = ordered.len();
277        remaining.retain(|h| {
278            let ready = commits[h]
279                .parents
280                .iter()
281                .all(|p| placed.contains(p) || !commits.contains_key(p));
282            if ready {
283                ordered.push(*h);
284                placed.insert(*h);
285            }
286            !ready
287        });
288        if ordered.len() == before {
289            return Err(("commit graph cycle (corrupt store?)".into(), exit::DATAERR));
290        }
291    }
292    Ok((ordered, commits, a, b))
293}
294
295pub(super) fn format_patch(args: &FormatPatchArgs) -> CmdResult<()> {
296    let cwd = std::env::current_dir().map_err(|e| (format!("cwd: {e}"), exit::NOINPUT))?;
297    let (layout, store) = open_repo(&cwd)?;
298    let (ordered, commits, a, b) = range_commits(&store, &layout, &args.range)?;
299
300    let mut skipped_merges = 0usize;
301    let series: Vec<&Hash> = ordered
302        .iter()
303        .filter(|h| {
304            let m = commits[*h].parents.len() > 1;
305            if m {
306                skipped_merges += 1;
307            }
308            !m
309        })
310        .collect();
311    if skipped_merges > 0 {
312        eprintln!("warning: {skipped_merges} merge commit(s) skipped (patches are linear)");
313    }
314    if series.is_empty() {
315        eprintln!("no commits in range {a}..{b}");
316        return Ok(());
317    }
318
319    let total = series.len();
320    let outdir = args.output.clone().unwrap_or_else(|| cwd.clone());
321    if !args.stdout {
322        std::fs::create_dir_all(&outdir)
323            .map_err(|e| (format!("create {}: {e}", outdir.display()), exit::CANTCREAT))?;
324    }
325    let mut stdout = std::io::stdout().lock();
326    for (i, h) in series.iter().enumerate() {
327        let c = &commits[*h];
328        let text = render_patch(&store, h, c, i + 1, total)?;
329        if args.stdout {
330            stdout
331                .write_all(text.as_bytes())
332                .map_err(|e| (format!("write: {e}"), exit::GENERAL_ERROR))?;
333        } else {
334            let name = format!("{:04}-{}.patch", i + 1, slug(&subject_of(c)));
335            let path = outdir.join(&name);
336            std::fs::write(&path, &text)
337                .map_err(|e| (format!("write {}: {e}", path.display()), exit::CANTCREAT))?;
338            let _ = writeln!(stdout, "{name}");
339        }
340    }
341    Ok(())
342}
343
344fn peel(store: &ObjectStore, mut h: Hash) -> Hash {
345    while let Ok(Object::Tag(t)) = store.read_object(&h) {
346        h = t.target;
347    }
348    h
349}
350
351fn subject_of(c: &Commit) -> String {
352    let msg = String::from_utf8_lossy(&c.message);
353    msg.lines().next().unwrap_or("").to_owned()
354}
355
356fn slug(subject: &str) -> String {
357    let mut out = String::new();
358    for ch in subject.chars() {
359        if ch.is_ascii_alphanumeric() {
360            out.push(ch);
361        } else if !out.ends_with('-') && !out.is_empty() {
362            out.push('-');
363        }
364    }
365    let trimmed = out.trim_end_matches('-');
366    let cut = trimmed.chars().take(52).collect::<String>();
367    if cut.is_empty() { "patch".into() } else { cut }
368}
369
370fn render_patch(
371    store: &ObjectStore,
372    hash: &Hash,
373    c: &Commit,
374    n: usize,
375    total: usize,
376) -> CmdResult<String> {
377    let mut out = String::new();
378    // mbox separator: git's fixed magic date; the id field is the
379    // mkit commit hash (git only needs the "From " prefix).
380    let _ = writeln!(
381        out,
382        "From {} Mon Sep 17 00:00:00 2001",
383        mkit_core::to_hex(hash)
384    );
385    let _ = writeln!(out, "From: {}", from_header(c));
386    let _ = writeln!(out, "Date: {}", rfc2822(c.timestamp));
387    let msg = String::from_utf8_lossy(&c.message);
388    let mut lines = msg.lines();
389    let subject = lines.next().unwrap_or("");
390    if total > 1 {
391        let _ = write!(out, "Subject: [PATCH {n}/{total}] {subject}\n\n");
392    } else {
393        let _ = write!(out, "Subject: [PATCH] {subject}\n\n");
394    }
395    let body: Vec<&str> = lines.skip_while(|l| l.is_empty()).collect();
396    for l in &body {
397        // git mailsplit treats a date-shaped "From <x> <ctime>" body
398        // line as a new-message separator and FAILS the apply — on
399        // git's own format-patch output too. We do one better and
400        // escape exactly that shape; `git am` applies cleanly and the
401        // line round-trips with a leading '>' (the classic mboxrd
402        // artifact), instead of a broken series.
403        if is_mbox_from_line(l) {
404            out.push('>');
405        }
406        out.push_str(l);
407        out.push('\n');
408    }
409    out.push_str("---\n");
410
411    let old_tree = match c.parents.first() {
412        Some(p) => match store.read_object(p) {
413            Ok(Object::Commit(pc)) => Some(pc.tree_hash),
414            _ => None,
415        },
416        None => None,
417    };
418    let diff = mkit_core::ops::diff::diff_trees(store, old_tree, Some(c.tree_hash))
419        .map_err(|e| (format!("diff: {e}"), exit::GENERAL_ERROR))?;
420    let mut buf: Vec<u8> = Vec::new();
421    for e in &diff.entries {
422        let mut one: Vec<u8> = Vec::new();
423        // Deliberately NOT wrapped in `DisplaySource` (#625): this patch
424        // body is format-patch-style output that `git am` applies into new
425        // commits elsewhere, not a render a human just glances at.
426        // Corruption here must surface as a loud `HashMismatch`, not
427        // propagate into someone's history — keep this read verified.
428        super::diff::emit_entry_patch(
429            &mut one,
430            store,
431            e,
432            mkit_core::ops::DEFAULT_CONTEXT_LINES,
433            mkit_core::ops::WhitespaceMode::Exact,
434        )
435        .map_err(|m| (m, exit::GENERAL_ERROR))?;
436        // `git am` cannot apply the textual "Binary files differ"
437        // notice (and we don't emit git's base85 binary literals), so
438        // a series touching binary content would fail at the
439        // MAINTAINER's end — refuse here instead.
440        if one
441            .split(|&b| b == b'\n')
442            .any(|l| l.starts_with(b"Binary files "))
443        {
444            return Err((
445                format!(
446                    "{}: binary change in commit {} — format-patch emits text \
447                     patches only; use `mkit git export` for binary content",
448                    e.path,
449                    &mkit_core::to_hex(hash)[..12]
450                ),
451                exit::DATAERR,
452            ));
453        }
454        buf.extend_from_slice(&one);
455    }
456    out.push_str(&String::from_utf8_lossy(&buf));
457    out.push_str("-- \nmkit git format-patch\n\n");
458    Ok(out)
459}
460
461/// `From:` header. An opaque identity that already looks like a git
462/// person ("Name <email>") passes through; anything else renders the
463/// bridge's display name with its sentinel email (matching what
464/// `mkit git export` would emit).
465fn from_header(c: &Commit) -> String {
466    if let Ok(s) = std::str::from_utf8(&c.author.bytes)
467        && c.author.kind == mkit_core::object::IdentityKind::Opaque
468        && s.contains('<')
469        && s.ends_with('>')
470        && !s.chars().any(char::is_control)
471    {
472        return s.to_owned();
473    }
474    format!(
475        "{} <{}>",
476        author::display_name(&c.author),
477        author::BRIDGE_EMAIL
478    )
479}
480
481/// The shape `git mailsplit` splits on: `From <token> … <ctime> …`
482/// where ctime is `Www Mmm [D]D HH:MM:SS YYYY`. mailsplit accepts
483/// trailing tokens after the date (verified: a `+0000` timezone
484/// suffix still splits), so the ctime window is searched ANYWHERE
485/// after the first token, not anchored at the line end. Looser lines
486/// (a plain "From the start..." sentence) do NOT split and must not
487/// be escaped.
488fn is_mbox_from_line(line: &str) -> bool {
489    const WDAYS: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
490    const MONS: [&str; 12] = [
491        "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
492    ];
493    let Some(rest) = line.strip_prefix("From ") else {
494        return false;
495    };
496    let tokens: Vec<&str> = rest.split_whitespace().collect();
497    // At least one token (the "sender") before the date window.
498    tokens.len() >= 6
499        && tokens.windows(5).skip(1).any(|w| {
500            let [wday, mon, day, time, year] = w else {
501                return false;
502            };
503            WDAYS.contains(wday)
504                && MONS.contains(mon)
505                && (1..=2).contains(&day.len())
506                && day.bytes().all(|b| b.is_ascii_digit())
507                && time.len() == 8
508                && time.as_bytes()[2] == b':'
509                && time.as_bytes()[5] == b':'
510                && year.len() == 4
511                && year.bytes().all(|b| b.is_ascii_digit())
512        })
513}
514
515/// RFC 2822 date from a unix timestamp (UTC).
516fn rfc2822(ts: u64) -> String {
517    const WDAY: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
518    const MON: [&str; 12] = [
519        "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
520    ];
521    #[allow(clippy::cast_possible_wrap)] // ts/86400 < i64::MAX
522    let days = (ts / 86_400) as i64;
523    let secs = ts % 86_400;
524    let (y, m, d) = civil_from_days(days);
525    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // rem_euclid(7) ∈ 0..7
526    let wd = ((days + 4).rem_euclid(7)) as usize; // 1970-01-01 = Thu
527    format!(
528        "{}, {} {} {} {:02}:{:02}:{:02} +0000",
529        WDAY[wd],
530        d,
531        MON[(m - 1) as usize],
532        y,
533        secs / 3600,
534        secs % 3600 / 60,
535        secs % 60
536    )
537}
538
539/// Days-since-epoch → (year, month, day). Howard Hinnant's
540/// `civil_from_days`, exact over the proleptic Gregorian calendar.
541#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // d ∈ 1..=31, m ∈ 1..=12
542fn civil_from_days(z: i64) -> (i64, u32, u32) {
543    let z = z + 719_468;
544    let era = z.div_euclid(146_097);
545    let doe = z.rem_euclid(146_097);
546    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
547    let y = yoe + era * 400;
548    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
549    let mp = (5 * doy + 2) / 153;
550    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
551    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
552    (if m <= 2 { y + 1 } else { y }, m, d)
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558
559    #[test]
560    fn rfc2822_known_dates() {
561        assert_eq!(rfc2822(0), "Thu, 1 Jan 1970 00:00:00 +0000");
562        // date -u -r 1700000000 → Tue Nov 14 22:13:20 UTC 2023
563        assert_eq!(rfc2822(1_700_000_000), "Tue, 14 Nov 2023 22:13:20 +0000");
564        // Leap-day check: 2024-02-29 12:00:00 UTC = 1709208000
565        assert_eq!(rfc2822(1_709_208_000), "Thu, 29 Feb 2024 12:00:00 +0000");
566    }
567
568    #[test]
569    fn mbox_from_line_shapes() {
570        assert!(is_mbox_from_line(
571            "From 1234567890abcdef1234567890abcdef12345678 Mon Sep 17 00:00:00 2001"
572        ));
573        assert!(is_mbox_from_line("From x Thu Jan 1 00:00:00 1970"));
574        // mailsplit also splits with trailing tokens after the date
575        // (verified against native git) — a timezone suffix must not
576        // defeat the escape.
577        assert!(is_mbox_from_line(
578            "From sender Fri Jun 12 12:00:00 2026 +0000"
579        ));
580        // The shapes mailsplit does NOT split on stay unescaped.
581        assert!(!is_mbox_from_line("From the start, this was true."));
582        assert!(!is_mbox_from_line("From: someone <a@b>"));
583        assert!(!is_mbox_from_line("From abc Mon Sep 17 00:00 2001")); // bad time
584    }
585
586    #[test]
587    fn slugs() {
588        assert_eq!(slug("Add foo, bar & baz!"), "Add-foo-bar-baz");
589        assert_eq!(slug("???"), "patch");
590    }
591
592    /// PR #659 review, finding 1's missing test: a `.rename.tmp.<pid>.0`
593    /// orphan under `.mkit/git/` — the crash debris `remote.rs`'s
594    /// `rename_state_dir` can leave behind between its two renames —
595    /// must not count as a second bridge state. Before the
596    /// dot-leading-segment rejection in `validate_ref_name`, `state_names`
597    /// had no filtering of its own and would have listed the orphan
598    /// alongside the legitimate state, turning zero-arg resolution
599    /// ("exactly one state dir") into a spurious "multiple bridge
600    /// states" error. The companion assertion for `refs/remotes/` (the
601    /// other state root) lives in
602    /// `remote_tracking_native::orphaned_rename_temp_dir_is_inert_in_listings`,
603    /// which exercises `show-ref`/`for-each-ref` directly since those
604    /// don't require the `git-bridge` feature this module is gated on.
605    #[test]
606    fn state_names_and_resolve_state_skip_dot_leading_orphans() {
607        let dir = tempfile::tempdir().unwrap();
608        let layout = RepoLayout::single(dir.path());
609        let state_dir = layout.git_state_dir();
610        std::fs::create_dir_all(state_dir.join("orig")).unwrap();
611        std::fs::write(state_dir.join("orig").join("marker.txt"), b"real state\n").unwrap();
612        // Crash debris: dot-leading, fully populated, directly under
613        // the same root as the legitimate state dir.
614        let orphan = state_dir.join(".rename.tmp.99999.0");
615        std::fs::create_dir_all(&orphan).unwrap();
616        std::fs::write(orphan.join("marker.txt"), b"orphaned bridge state\n").unwrap();
617
618        assert_eq!(
619            state_names(&layout),
620            vec!["orig".to_string()],
621            "state_names must skip the dot-leading orphan"
622        );
623        let (name, path) = resolve_state(&layout, None).expect(
624            "zero-arg resolution must pick the lone legitimate state \
625             instead of erroring 'multiple bridge states' over the orphan",
626        );
627        assert_eq!(name, "orig");
628        assert_eq!(path, state_dir.join("orig"));
629    }
630}