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::verify::ShallowVerdict;
30use mkit_git_bridge::{author, gitparse, map, reconstruct, translate, verify};
31
32use super::revspec;
33use crate::exit;
34
35type CmdResult<T> = Result<T, (String, u8)>;
36
37const ATTESTATIONS_REF: &str = "refs/mkit/attestations";
38
39#[derive(Debug, Parser)]
40pub(super) struct VerifyArgs {
41    /// Bridge state name under `.mkit/git/`. Optional when exactly
42    /// one state dir exists.
43    #[arg(long = "remote-name", value_name = "NAME")]
44    pub remote_name: Option<String>,
45    /// Full fork audit (SPEC-GIT-BRIDGE §14.3): additionally
46    /// re-derive every tree/blob referenced by bridge commits from
47    /// its mkit twin and require the exact sha1.
48    #[arg(long = "fork-audit")]
49    pub fork_audit: bool,
50    /// Verify only these refs (full names). Default: every ref
51    /// recorded in the state dir.
52    #[arg(long = "ref", value_name = "REF")]
53    pub refs: Vec<String>,
54}
55
56#[derive(Debug, Parser)]
57pub(super) struct StatusArgs {}
58
59#[derive(Debug, Parser)]
60pub(super) struct FormatPatchArgs {
61    /// Commit range `A..B` (or a single rev, meaning `<rev>..HEAD`).
62    pub range: String,
63    /// Write patch files into this directory (default: current dir).
64    #[arg(short = 'o', long = "output-directory", value_name = "DIR")]
65    pub output: Option<PathBuf>,
66    /// Print all patches to stdout instead of writing files.
67    #[arg(long)]
68    pub stdout: bool,
69}
70
71// ─── shared state-dir resolution ────────────────────────────────────
72
73fn state_names(layout: &RepoLayout) -> Vec<String> {
74    let mut names = Vec::new();
75    if let Ok(rd) = std::fs::read_dir(layout.git_state_dir()) {
76        for e in rd.flatten() {
77            if e.path().is_dir()
78                && let Some(name) = e.file_name().to_str()
79                // Dot-leading entries are never valid bridge-state names
80                // (mirrors `validate_ref_name`'s dot-leading-component
81                // rejection): skips crash debris from a `remote rename`
82                // move parked at a `.rename.tmp.<pid>.<seq>` temp dir
83                // directly under this root, so it never fools zero-arg
84                // state resolution into reporting "multiple bridge states".
85                && !name.starts_with('.')
86            {
87                names.push(name.to_owned());
88            }
89        }
90    }
91    names.sort();
92    names
93}
94
95fn resolve_state(layout: &RepoLayout, remote_name: Option<&str>) -> CmdResult<(String, PathBuf)> {
96    if let Some(name) = remote_name {
97        let state = map::state_dir(layout, name).map_err(|e| (e.to_string(), exit::USAGE))?;
98        if !state.is_dir() {
99            return Err((format!("no bridge state for '{name}'"), exit::NOINPUT));
100        }
101        return Ok((name.to_owned(), state));
102    }
103    let names = state_names(layout);
104    match names.as_slice() {
105        [] => Err((
106            "no git bridge state (run `mkit git import` or `mkit git export` first)".into(),
107            exit::NOINPUT,
108        )),
109        [one] => Ok((one.clone(), layout.git_state_dir().join(one))),
110        many => Err((
111            format!(
112                "multiple bridge states ({}); pick one with --remote-name",
113                many.join(", ")
114            ),
115            exit::USAGE,
116        )),
117    }
118}
119
120fn open_repo(cwd: &Path) -> CmdResult<(RepoLayout, ObjectStore)> {
121    let layout = mkit_core::layout::discover(cwd)
122        .map_err(|e| (format!("worktree discovery: {e}"), exit::DATAERR))?;
123    if !layout.common_dir().is_dir() {
124        return Err(("not a mkit repository".into(), exit::USAGE));
125    }
126    let store =
127        ObjectStore::open(&layout).map_err(|e| (format!("open store: {e}"), exit::NOINPUT))?;
128    Ok((layout, store))
129}
130
131// ─── mkit git verify ────────────────────────────────────────────────
132
133#[derive(Default)]
134struct Counts {
135    bridge: usize,
136    unsigned: usize,
137    imported: usize,
138    derived: usize,
139}
140
141struct Audit<'a> {
142    raw_dir: PathBuf,
143    batch: CatFileBatch,
144    store: &'a ObjectStore,
145    /// sha1 → mkit twin (both directions of the unified map).
146    inv: HashMap<Sha1Id, Hash>,
147    /// mkit → sha1, for tree re-derivation child resolution.
148    fwd: HashMap<Hash, Sha1Id>,
149    pinned: Option<[u8; 32]>,
150    deep: bool,
151    failures: Vec<String>,
152    counts: Counts,
153    seen: HashSet<Sha1Id>,
154}
155
156impl Audit<'_> {
157    fn fail(&mut self, id: &Sha1Id, why: &str) {
158        self.failures.push(format!("{} {why}", sha1_hex(id)));
159    }
160
161    fn twin(&mut self, id: &Sha1Id) -> Option<Hash> {
162        let t = self.inv.get(id).copied();
163        if t.is_none() {
164            self.fail(id, "no mkit twin recorded in the map");
165        }
166        t
167    }
168
169    fn raw_path(&self, id: &Sha1Id) -> PathBuf {
170        let hex = sha1_hex(id);
171        self.raw_dir.join(&hex[..2]).join(&hex[2..])
172    }
173
174    /// Walk all commits reachable from `tip` in the staging repo,
175    /// checking each (and, with `--fork-audit`, the content closure
176    /// of every bridge commit).
177    fn walk(&mut self, tip: &Sha1Id) {
178        let mut stack = vec![*tip];
179        while let Some(id) = stack.pop() {
180            if !self.seen.insert(id) {
181                continue;
182            }
183            let (kind, body) = match self.batch.read(&id) {
184                Ok(v) => v,
185                Err(e) => {
186                    self.fail(&id, &format!("unreadable in staging: {e}"));
187                    continue;
188                }
189            };
190            match kind {
191                GitObjKind::Commit => {
192                    let parsed = match gitparse::parse_commit(&body) {
193                        Ok(p) => p,
194                        Err(e) => {
195                            self.fail(&id, &format!("unparsable commit: {e}"));
196                            continue;
197                        }
198                    };
199                    if self.raw_path(&id).exists() {
200                        self.check_imported(&id, false);
201                    } else {
202                        self.check_bridge_commit(&id, &body);
203                        if self.deep {
204                            self.audit_object(&parsed.tree);
205                        }
206                    }
207                    for p in &parsed.parents {
208                        stack.push(*p);
209                    }
210                }
211                GitObjKind::Tag => {
212                    if self.raw_path(&id).exists() {
213                        self.check_imported(&id, true);
214                    } else {
215                        self.check_bridge_tag(&id, &body);
216                    }
217                    if let Ok(t) = gitparse::parse_tag(&body) {
218                        stack.push(t.object);
219                    }
220                }
221                GitObjKind::Blob | GitObjKind::Tree => {
222                    // Refs at blobs/trees are not bridge state; the
223                    // content closure is covered by --fork-audit.
224                }
225            }
226        }
227    }
228
229    /// SPEC-GIT-IMPORT §4 / SPEC-GIT-BRIDGE §14.3 step 2: retained
230    /// raw bytes must hash to the sha1, and the mkit twin must be
231    /// signed by the pinned importer key.
232    fn check_imported(&mut self, id: &Sha1Id, is_tag: bool) {
233        match std::fs::read(self.raw_path(id)) {
234            Ok(raw) => match GitObject::parse_raw(&raw) {
235                Some(obj) if obj.id() == *id => {}
236                Some(_) => self.fail(id, "retained raw bytes hash to a DIFFERENT sha1"),
237                None => self.fail(id, "retained raw bytes are not framed git bytes"),
238            },
239            Err(e) => self.fail(id, &format!("retained raw bytes unreadable: {e}")),
240        }
241        let Some(twin) = self.twin(id) else { return };
242        match self.store.read_object(&twin) {
243            Ok(Object::Commit(c)) if !is_tag => self
244                .check_importer_sig(id, &c.signer, || mkit_core::sign::verify_commit(&c).is_ok()),
245            Ok(Object::Tag(t)) if is_tag => {
246                let signer = t.signer;
247                self.check_importer_sig(id, &signer, || mkit_core::sign::verify_tag(&t).is_ok());
248            }
249            Ok(_) => self.fail(id, "mkit twin has a different object kind"),
250            Err(e) => self.fail(id, &format!("mkit twin missing from store: {e}")),
251        }
252        self.counts.imported += 1;
253    }
254
255    fn check_importer_sig(&mut self, id: &Sha1Id, signer: &[u8; 32], ok: impl FnOnce() -> bool) {
256        match self.pinned {
257            Some(pin) if pin != *signer => {
258                self.fail(id, "twin signer is NOT the pinned importer key");
259            }
260            None => self.fail(id, "imported object but no importer key pinned"),
261            Some(_) => {
262                if !ok() {
263                    self.fail(id, "importer signature does not verify");
264                }
265            }
266        }
267    }
268
269    /// §10 shallow verification + the twin/map correspondence.
270    fn check_bridge_commit(&mut self, id: &Sha1Id, body: &[u8]) {
271        let obj = GitObject {
272            gtype: GitType::Commit,
273            body: body.to_vec(),
274        };
275        self.check_bridge_obj(id, &obj, reconstruct::reconstruct_commit);
276    }
277
278    fn check_bridge_tag(&mut self, id: &Sha1Id, body: &[u8]) {
279        let obj = GitObject {
280            gtype: GitType::Tag,
281            body: body.to_vec(),
282        };
283        self.check_bridge_obj(id, &obj, reconstruct::reconstruct_tag);
284    }
285
286    fn check_bridge_obj(
287        &mut self,
288        id: &Sha1Id,
289        obj: &GitObject,
290        rec: impl Fn(&[u8]) -> Result<reconstruct::Reconstructed, mkit_git_bridge::BridgeError>,
291    ) {
292        match verify::shallow_verify(obj) {
293            Ok(ShallowVerdict::Verified) => {}
294            Ok(ShallowVerdict::Unsigned) => self.counts.unsigned += 1,
295            Ok(ShallowVerdict::Failed) => self.fail(id, "embedded signature does NOT verify"),
296            Err(e) => {
297                self.fail(id, &format!("not bridge-shaped: {e}"));
298                return;
299            }
300        }
301        match rec(&obj.body) {
302            Ok(r) => {
303                if let Some(twin) = self.twin(id)
304                    && r.hash != twin
305                {
306                    self.fail(id, "reconstructs to a hash OTHER than its mapped twin");
307                }
308                if let Err(e) = self.store.read_object(&r.hash) {
309                    self.fail(id, &format!("reconstructed twin missing from store: {e}"));
310                }
311            }
312            Err(e) => self.fail(id, &format!("reconstruction failed: {e}")),
313        }
314        self.counts.bridge += 1;
315    }
316
317    /// §14.3 step 3: re-derive the git bytes for `id` from its mkit
318    /// twin and require the exact sha1; recurse through trees.
319    fn audit_object(&mut self, id: &Sha1Id) {
320        if !self.seen.insert(*id) {
321            return;
322        }
323        let Some(twin) = self.twin(id) else { return };
324        let obj = match self.store.read_object(&twin) {
325            Ok(o) => o,
326            Err(e) => {
327                self.fail(id, &format!("mkit twin missing from store: {e}"));
328                return;
329            }
330        };
331        let derived = match &obj {
332            Object::Blob(b) => Ok(translate::translate_blob(&b.data)),
333            Object::ChunkedBlob(m) => translate::translate_chunked(&twin, m, self.store),
334            Object::Tree(t) => {
335                let fwd = &self.fwd;
336                translate::translate_tree(t, &|h| fwd.get(h).copied())
337            }
338            _ => {
339                self.fail(id, "content position holds a non-content mkit twin");
340                return;
341            }
342        };
343        match derived {
344            Ok(g) if g.id() == *id => self.counts.derived += 1,
345            Ok(_) => self.fail(id, "re-derivation produced a DIFFERENT sha1"),
346            Err(e) => {
347                self.fail(id, &format!("re-derivation failed: {e}"));
348                return;
349            }
350        }
351        if let Object::Tree(t) = &obj {
352            for e in &t.entries {
353                if let Some(child) = self.fwd.get(&e.object_hash).copied() {
354                    self.audit_object(&child);
355                } else {
356                    self.fail(
357                        id,
358                        &format!(
359                            "tree entry {:?} has no recorded sha1",
360                            String::from_utf8_lossy(&e.name)
361                        ),
362                    );
363                }
364            }
365        }
366    }
367}
368
369pub(super) fn verify(args: &VerifyArgs) -> CmdResult<()> {
370    let cwd = std::env::current_dir().map_err(|e| (format!("cwd: {e}"), exit::NOINPUT))?;
371    let (layout, store) = open_repo(&cwd)?;
372    let (name, state) = resolve_state(&layout, args.remote_name.as_deref())?;
373    let staging = state.join("repo.git");
374    if !staging.join("objects").is_dir() {
375        return Err((
376            format!("state '{name}' has no staging repo to verify against"),
377            exit::NOINPUT,
378        ));
379    }
380
381    // Refs to audit: explicit, else everything recorded (both
382    // directions; in a fork dir the same name may have two tips).
383    let mut targets: Vec<(String, Sha1Id, &'static str)> = Vec::new();
384    let recorded_export =
385        map::load_ref_state(&state).map_err(|e| (e.to_string(), exit::GENERAL_ERROR))?;
386    let recorded_import =
387        map::load_import_ref_state(&state).map_err(|e| (e.to_string(), exit::GENERAL_ERROR))?;
388    for s in &recorded_export {
389        if s.ref_name != ATTESTATIONS_REF {
390            targets.push((s.ref_name.clone(), s.git_id, "exported"));
391        }
392    }
393    for s in &recorded_import {
394        if !targets
395            .iter()
396            .any(|(n, id, _)| *n == s.ref_name && *id == s.git_id)
397        {
398            targets.push((s.ref_name.clone(), s.git_id, "imported"));
399        }
400    }
401    if !args.refs.is_empty() {
402        targets.retain(|(n, _, _)| args.refs.iter().any(|r| r == n));
403        for r in &args.refs {
404            if !targets.iter().any(|(n, _, _)| n == r) {
405                return Err((format!("{r}: not recorded in state '{name}'"), exit::USAGE));
406            }
407        }
408    }
409    if targets.is_empty() {
410        return Err((format!("state '{name}' records no refs"), exit::NOINPUT));
411    }
412
413    let mut audit = Audit {
414        raw_dir: state.join("raw"),
415        batch: CatFileBatch::open(&staging).map_err(|e| (e.to_string(), exit::UNAVAILABLE))?,
416        store: &store,
417        inv: map::load_map_inverse(&state).map_err(|e| (e.to_string(), exit::GENERAL_ERROR))?,
418        fwd: map::load_map(&state).map_err(|e| (e.to_string(), exit::GENERAL_ERROR))?,
419        pinned: map::read_signer(&state).map_err(|e| (e.to_string(), exit::CONFIG_ERROR))?,
420        deep: args.fork_audit,
421        failures: Vec::new(),
422        counts: Counts::default(),
423        seen: HashSet::new(),
424    };
425
426    let mut stderr = std::io::stderr().lock();
427    for (ref_name, tip, origin) in &targets {
428        audit.walk(tip);
429        // §14.3 step 2, head scope: an imported tip must carry its
430        // git-import/v1 attestation (full signature verification is
431        // `mkit verify-attest`'s job; the audit checks the claim
432        // EXISTS for the recorded twin).
433        if args.fork_audit
434            && audit.raw_path(tip).exists()
435            && let Some(twin) = audit.inv.get(tip).copied()
436        {
437            let attested = mkit_attest::store::list(&layout, &twin).is_ok_and(|v| !v.is_empty());
438            if !attested {
439                audit.failures.push(format!(
440                    "{} imported head has no git-import/v1 attestation recorded",
441                    sha1_hex(tip)
442                ));
443            }
444        }
445        let _ = writeln!(stderr, "verified {ref_name} ({origin}, {})", sha1_hex(tip));
446    }
447    let c = &audit.counts;
448    let mut summary = format!(
449        "{} bridge-translated ({} unsigned), {} imported-vouched",
450        c.bridge, c.unsigned, c.imported
451    );
452    if args.fork_audit {
453        let _ = write!(summary, ", {} content-derived", c.derived);
454    }
455    if audit.failures.is_empty() && c.unsigned > 0 {
456        // §10: an all-zero mkit-signature FAILS both verification
457        // modes — reported as unsigned (never "tampered"), but never
458        // as success either.
459        return Err((
460            format!("{} unsigned object(s) ({summary})", c.unsigned),
461            exit::DATAERR,
462        ));
463    }
464    if audit.failures.is_empty() {
465        let _ = writeln!(stderr, "ok: {summary}");
466        Ok(())
467    } else {
468        for f in &audit.failures {
469            let _ = writeln!(stderr, "FAIL {f}");
470        }
471        Err((
472            format!(
473                "{} object(s) failed verification ({summary})",
474                audit.failures.len()
475            ),
476            exit::DATAERR,
477        ))
478    }
479}
480
481// ─── mkit git status ────────────────────────────────────────────────
482
483pub(super) fn status(_args: &StatusArgs) -> CmdResult<()> {
484    let cwd = std::env::current_dir().map_err(|e| (format!("cwd: {e}"), exit::NOINPUT))?;
485    let (layout, _store) = open_repo(&cwd)?;
486    let names = state_names(&layout);
487    let mut out = std::io::stdout().lock();
488    if names.is_empty() {
489        let _ = writeln!(
490            out,
491            "no git bridge state (run `mkit git import` or `mkit git export` first)"
492        );
493        return Ok(());
494    }
495    for name in &names {
496        let state = layout.git_state_dir().join(name);
497        let direction = map::read_direction(&state)
498            .ok()
499            .flatten()
500            .map_or("unknown", |d| d.as_str());
501        let _ = writeln!(out, "{name}  direction={direction}");
502        for (file, label) in [("source", "source"), ("dest", "dest")] {
503            if let Ok(v) = std::fs::read_to_string(state.join(file)) {
504                let _ = writeln!(out, "  {label}: {}", v.trim());
505            }
506        }
507        if let Ok(Some(key)) = map::read_signer(&state) {
508            let _ = writeln!(
509                out,
510                "  importer key: {}… (pinned)",
511                &mkit_git_bridge::gitobj::bytes_hex(&key)[..16]
512            );
513        }
514        for s in map::load_import_ref_state(&state).unwrap_or_default() {
515            let _ = writeln!(
516                out,
517                "  tracking {} @ {} (import)",
518                s.ref_name,
519                &sha1_hex(&s.git_id)[..12]
520            );
521        }
522        for s in map::load_ref_state(&state).unwrap_or_default() {
523            if s.ref_name == ATTESTATIONS_REF {
524                continue;
525            }
526            let _ = writeln!(
527                out,
528                "  exported {} @ {} (lease)",
529                s.ref_name,
530                &sha1_hex(&s.git_id)[..12]
531            );
532        }
533        let staging = if state.join("repo.git/objects").is_dir() {
534            "ok"
535        } else {
536            "missing"
537        };
538        let _ = writeln!(out, "  staging: {staging}");
539    }
540    Ok(())
541}
542
543// ─── mkit git format-patch ──────────────────────────────────────────
544
545/// A patch series: oldest-first hashes, the commit set, and the
546/// resolved `A`/`B` endpoint spellings (for messages).
547type Series = (Vec<Hash>, HashMap<Hash, Commit>, String, String);
548
549/// Resolve `A..B` (or `<rev>` = `<rev>..HEAD`) to the commit set and
550/// its oldest-first topological order (parents before children,
551/// timestamp as the tiebreak).
552fn range_commits(store: &ObjectStore, layout: &RepoLayout, range: &str) -> CmdResult<Series> {
553    let (a, b) = match range.split_once("..") {
554        Some((a, b)) => (
555            a.to_owned(),
556            if b.is_empty() {
557                "HEAD".to_owned()
558            } else {
559                b.to_owned()
560            },
561        ),
562        None => (range.to_owned(), "HEAD".to_owned()),
563    };
564    let resolve = |spec: &str| -> CmdResult<Hash> {
565        revspec::resolve_revision(store, layout, spec)
566            .map_err(|e| (format!("{spec}: {e}"), exit::DATAERR))
567    };
568    let exclude_tip = peel(store, resolve(&a)?);
569    let include_tip = peel(store, resolve(&b)?);
570    for (spec, h) in [(&a, exclude_tip), (&b, include_tip)] {
571        if !matches!(store.read_object(&h), Ok(Object::Commit(_))) {
572            // A non-commit endpoint would silently exclude nothing
573            // and render the entire history as the series.
574            return Err((format!("{spec}: not a commit"), exit::DATAERR));
575        }
576    }
577
578    // Ancestors of A drop out of the patch series.
579    let mut excluded: HashSet<Hash> = HashSet::new();
580    let mut stack = vec![exclude_tip];
581    while let Some(h) = stack.pop() {
582        if !excluded.insert(h) {
583            continue;
584        }
585        if let Ok(Object::Commit(c)) = store.read_object(&h) {
586            stack.extend(c.parents.iter().copied());
587        }
588    }
589    let mut commits: HashMap<Hash, Commit> = HashMap::new();
590    let mut stack = vec![include_tip];
591    while let Some(h) = stack.pop() {
592        if excluded.contains(&h) || commits.contains_key(&h) {
593            continue;
594        }
595        let c = match store.read_object(&h) {
596            Ok(Object::Commit(c)) => c,
597            Ok(_) => {
598                return Err((
599                    format!("not a commit: {}", mkit_core::to_hex(&h)),
600                    exit::DATAERR,
601                ));
602            }
603            Err(e) => {
604                return Err((
605                    format!("read {}: {e}", mkit_core::to_hex(&h)),
606                    exit::DATAERR,
607                ));
608            }
609        };
610        stack.extend(c.parents.iter().copied());
611        commits.insert(h, c);
612    }
613
614    let mut remaining: Vec<Hash> = commits.keys().copied().collect();
615    remaining.sort_by_key(|h| (commits[h].timestamp, *h));
616    let mut ordered: Vec<Hash> = Vec::with_capacity(remaining.len());
617    let mut placed: HashSet<Hash> = HashSet::new();
618    while !remaining.is_empty() {
619        let before = ordered.len();
620        remaining.retain(|h| {
621            let ready = commits[h]
622                .parents
623                .iter()
624                .all(|p| placed.contains(p) || !commits.contains_key(p));
625            if ready {
626                ordered.push(*h);
627                placed.insert(*h);
628            }
629            !ready
630        });
631        if ordered.len() == before {
632            return Err(("commit graph cycle (corrupt store?)".into(), exit::DATAERR));
633        }
634    }
635    Ok((ordered, commits, a, b))
636}
637
638pub(super) fn format_patch(args: &FormatPatchArgs) -> CmdResult<()> {
639    let cwd = std::env::current_dir().map_err(|e| (format!("cwd: {e}"), exit::NOINPUT))?;
640    let (layout, store) = open_repo(&cwd)?;
641    let (ordered, commits, a, b) = range_commits(&store, &layout, &args.range)?;
642
643    let mut skipped_merges = 0usize;
644    let series: Vec<&Hash> = ordered
645        .iter()
646        .filter(|h| {
647            let m = commits[*h].parents.len() > 1;
648            if m {
649                skipped_merges += 1;
650            }
651            !m
652        })
653        .collect();
654    if skipped_merges > 0 {
655        eprintln!("warning: {skipped_merges} merge commit(s) skipped (patches are linear)");
656    }
657    if series.is_empty() {
658        eprintln!("no commits in range {a}..{b}");
659        return Ok(());
660    }
661
662    let total = series.len();
663    let outdir = args.output.clone().unwrap_or_else(|| cwd.clone());
664    if !args.stdout {
665        std::fs::create_dir_all(&outdir)
666            .map_err(|e| (format!("create {}: {e}", outdir.display()), exit::CANTCREAT))?;
667    }
668    let mut stdout = std::io::stdout().lock();
669    for (i, h) in series.iter().enumerate() {
670        let c = &commits[*h];
671        let text = render_patch(&store, h, c, i + 1, total)?;
672        if args.stdout {
673            stdout
674                .write_all(text.as_bytes())
675                .map_err(|e| (format!("write: {e}"), exit::GENERAL_ERROR))?;
676        } else {
677            let name = format!("{:04}-{}.patch", i + 1, slug(&subject_of(c)));
678            let path = outdir.join(&name);
679            std::fs::write(&path, &text)
680                .map_err(|e| (format!("write {}: {e}", path.display()), exit::CANTCREAT))?;
681            let _ = writeln!(stdout, "{name}");
682        }
683    }
684    Ok(())
685}
686
687fn peel(store: &ObjectStore, mut h: Hash) -> Hash {
688    while let Ok(Object::Tag(t)) = store.read_object(&h) {
689        h = t.target;
690    }
691    h
692}
693
694fn subject_of(c: &Commit) -> String {
695    let msg = String::from_utf8_lossy(&c.message);
696    msg.lines().next().unwrap_or("").to_owned()
697}
698
699fn slug(subject: &str) -> String {
700    let mut out = String::new();
701    for ch in subject.chars() {
702        if ch.is_ascii_alphanumeric() {
703            out.push(ch);
704        } else if !out.ends_with('-') && !out.is_empty() {
705            out.push('-');
706        }
707    }
708    let trimmed = out.trim_end_matches('-');
709    let cut = trimmed.chars().take(52).collect::<String>();
710    if cut.is_empty() { "patch".into() } else { cut }
711}
712
713fn render_patch(
714    store: &ObjectStore,
715    hash: &Hash,
716    c: &Commit,
717    n: usize,
718    total: usize,
719) -> CmdResult<String> {
720    let mut out = String::new();
721    // mbox separator: git's fixed magic date; the id field is the
722    // mkit commit hash (git only needs the "From " prefix).
723    let _ = writeln!(
724        out,
725        "From {} Mon Sep 17 00:00:00 2001",
726        mkit_core::to_hex(hash)
727    );
728    let _ = writeln!(out, "From: {}", from_header(c));
729    let _ = writeln!(out, "Date: {}", rfc2822(c.timestamp));
730    let msg = String::from_utf8_lossy(&c.message);
731    let mut lines = msg.lines();
732    let subject = lines.next().unwrap_or("");
733    if total > 1 {
734        let _ = write!(out, "Subject: [PATCH {n}/{total}] {subject}\n\n");
735    } else {
736        let _ = write!(out, "Subject: [PATCH] {subject}\n\n");
737    }
738    let body: Vec<&str> = lines.skip_while(|l| l.is_empty()).collect();
739    for l in &body {
740        // git mailsplit treats a date-shaped "From <x> <ctime>" body
741        // line as a new-message separator and FAILS the apply — on
742        // git's own format-patch output too. We do one better and
743        // escape exactly that shape; `git am` applies cleanly and the
744        // line round-trips with a leading '>' (the classic mboxrd
745        // artifact), instead of a broken series.
746        if is_mbox_from_line(l) {
747            out.push('>');
748        }
749        out.push_str(l);
750        out.push('\n');
751    }
752    out.push_str("---\n");
753
754    let old_tree = match c.parents.first() {
755        Some(p) => match store.read_object(p) {
756            Ok(Object::Commit(pc)) => Some(pc.tree_hash),
757            _ => None,
758        },
759        None => None,
760    };
761    let diff = mkit_core::ops::diff::diff_trees(store, old_tree, Some(c.tree_hash))
762        .map_err(|e| (format!("diff: {e}"), exit::GENERAL_ERROR))?;
763    let mut buf: Vec<u8> = Vec::new();
764    for e in &diff.entries {
765        let mut one: Vec<u8> = Vec::new();
766        // Deliberately NOT wrapped in `DisplaySource` (#625): this patch
767        // body is format-patch-style output that `git am` applies into new
768        // commits elsewhere, not a render a human just glances at.
769        // Corruption here must surface as a loud `HashMismatch`, not
770        // propagate into someone's history — keep this read verified.
771        super::diff::emit_entry_patch(
772            &mut one,
773            store,
774            e,
775            mkit_core::ops::DEFAULT_CONTEXT_LINES,
776            mkit_core::ops::WhitespaceMode::Exact,
777        )
778        .map_err(|m| (m, exit::GENERAL_ERROR))?;
779        // `git am` cannot apply the textual "Binary files differ"
780        // notice (and we don't emit git's base85 binary literals), so
781        // a series touching binary content would fail at the
782        // MAINTAINER's end — refuse here instead.
783        if one
784            .split(|&b| b == b'\n')
785            .any(|l| l.starts_with(b"Binary files "))
786        {
787            return Err((
788                format!(
789                    "{}: binary change in commit {} — format-patch emits text \
790                     patches only; use `mkit git export` for binary content",
791                    e.path,
792                    &mkit_core::to_hex(hash)[..12]
793                ),
794                exit::DATAERR,
795            ));
796        }
797        buf.extend_from_slice(&one);
798    }
799    out.push_str(&String::from_utf8_lossy(&buf));
800    out.push_str("-- \nmkit git format-patch\n\n");
801    Ok(out)
802}
803
804/// `From:` header. An opaque identity that already looks like a git
805/// person ("Name <email>") passes through; anything else renders the
806/// bridge's display name with its sentinel email (matching what
807/// `mkit git export` would emit).
808fn from_header(c: &Commit) -> String {
809    if let Ok(s) = std::str::from_utf8(&c.author.bytes)
810        && c.author.kind == mkit_core::object::IdentityKind::Opaque
811        && s.contains('<')
812        && s.ends_with('>')
813        && !s.chars().any(char::is_control)
814    {
815        return s.to_owned();
816    }
817    format!(
818        "{} <{}>",
819        author::display_name(&c.author),
820        author::BRIDGE_EMAIL
821    )
822}
823
824/// The shape `git mailsplit` splits on: `From <token> … <ctime> …`
825/// where ctime is `Www Mmm [D]D HH:MM:SS YYYY`. mailsplit accepts
826/// trailing tokens after the date (verified: a `+0000` timezone
827/// suffix still splits), so the ctime window is searched ANYWHERE
828/// after the first token, not anchored at the line end. Looser lines
829/// (a plain "From the start..." sentence) do NOT split and must not
830/// be escaped.
831fn is_mbox_from_line(line: &str) -> bool {
832    const WDAYS: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
833    const MONS: [&str; 12] = [
834        "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
835    ];
836    let Some(rest) = line.strip_prefix("From ") else {
837        return false;
838    };
839    let tokens: Vec<&str> = rest.split_whitespace().collect();
840    // At least one token (the "sender") before the date window.
841    tokens.len() >= 6
842        && tokens.windows(5).skip(1).any(|w| {
843            let [wday, mon, day, time, year] = w else {
844                return false;
845            };
846            WDAYS.contains(wday)
847                && MONS.contains(mon)
848                && (1..=2).contains(&day.len())
849                && day.bytes().all(|b| b.is_ascii_digit())
850                && time.len() == 8
851                && time.as_bytes()[2] == b':'
852                && time.as_bytes()[5] == b':'
853                && year.len() == 4
854                && year.bytes().all(|b| b.is_ascii_digit())
855        })
856}
857
858/// RFC 2822 date from a unix timestamp (UTC).
859fn rfc2822(ts: u64) -> String {
860    const WDAY: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
861    const MON: [&str; 12] = [
862        "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
863    ];
864    #[allow(clippy::cast_possible_wrap)] // ts/86400 < i64::MAX
865    let days = (ts / 86_400) as i64;
866    let secs = ts % 86_400;
867    let (y, m, d) = civil_from_days(days);
868    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // rem_euclid(7) ∈ 0..7
869    let wd = ((days + 4).rem_euclid(7)) as usize; // 1970-01-01 = Thu
870    format!(
871        "{}, {} {} {} {:02}:{:02}:{:02} +0000",
872        WDAY[wd],
873        d,
874        MON[(m - 1) as usize],
875        y,
876        secs / 3600,
877        secs % 3600 / 60,
878        secs % 60
879    )
880}
881
882/// Days-since-epoch → (year, month, day). Howard Hinnant's
883/// `civil_from_days`, exact over the proleptic Gregorian calendar.
884#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // d ∈ 1..=31, m ∈ 1..=12
885fn civil_from_days(z: i64) -> (i64, u32, u32) {
886    let z = z + 719_468;
887    let era = z.div_euclid(146_097);
888    let doe = z.rem_euclid(146_097);
889    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
890    let y = yoe + era * 400;
891    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
892    let mp = (5 * doy + 2) / 153;
893    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
894    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
895    (if m <= 2 { y + 1 } else { y }, m, d)
896}
897
898#[cfg(test)]
899mod tests {
900    use super::*;
901
902    #[test]
903    fn rfc2822_known_dates() {
904        assert_eq!(rfc2822(0), "Thu, 1 Jan 1970 00:00:00 +0000");
905        // date -u -r 1700000000 → Tue Nov 14 22:13:20 UTC 2023
906        assert_eq!(rfc2822(1_700_000_000), "Tue, 14 Nov 2023 22:13:20 +0000");
907        // Leap-day check: 2024-02-29 12:00:00 UTC = 1709208000
908        assert_eq!(rfc2822(1_709_208_000), "Thu, 29 Feb 2024 12:00:00 +0000");
909    }
910
911    #[test]
912    fn mbox_from_line_shapes() {
913        assert!(is_mbox_from_line(
914            "From 1234567890abcdef1234567890abcdef12345678 Mon Sep 17 00:00:00 2001"
915        ));
916        assert!(is_mbox_from_line("From x Thu Jan 1 00:00:00 1970"));
917        // mailsplit also splits with trailing tokens after the date
918        // (verified against native git) — a timezone suffix must not
919        // defeat the escape.
920        assert!(is_mbox_from_line(
921            "From sender Fri Jun 12 12:00:00 2026 +0000"
922        ));
923        // The shapes mailsplit does NOT split on stay unescaped.
924        assert!(!is_mbox_from_line("From the start, this was true."));
925        assert!(!is_mbox_from_line("From: someone <a@b>"));
926        assert!(!is_mbox_from_line("From abc Mon Sep 17 00:00 2001")); // bad time
927    }
928
929    #[test]
930    fn slugs() {
931        assert_eq!(slug("Add foo, bar & baz!"), "Add-foo-bar-baz");
932        assert_eq!(slug("???"), "patch");
933    }
934
935    /// PR #659 review, finding 1's missing test: a `.rename.tmp.<pid>.0`
936    /// orphan under `.mkit/git/` — the crash debris `remote.rs`'s
937    /// `rename_state_dir` can leave behind between its two renames —
938    /// must not count as a second bridge state. Before the
939    /// dot-leading-segment rejection in `validate_ref_name`, `state_names`
940    /// had no filtering of its own and would have listed the orphan
941    /// alongside the legitimate state, turning zero-arg resolution
942    /// ("exactly one state dir") into a spurious "multiple bridge
943    /// states" error. The companion assertion for `refs/remotes/` (the
944    /// other state root) lives in
945    /// `remote_tracking_native::orphaned_rename_temp_dir_is_inert_in_listings`,
946    /// which exercises `show-ref`/`for-each-ref` directly since those
947    /// don't require the `git-bridge` feature this module is gated on.
948    #[test]
949    fn state_names_and_resolve_state_skip_dot_leading_orphans() {
950        let dir = tempfile::tempdir().unwrap();
951        let layout = RepoLayout::single(dir.path());
952        let state_dir = layout.git_state_dir();
953        std::fs::create_dir_all(state_dir.join("orig")).unwrap();
954        std::fs::write(state_dir.join("orig").join("marker.txt"), b"real state\n").unwrap();
955        // Crash debris: dot-leading, fully populated, directly under
956        // the same root as the legitimate state dir.
957        let orphan = state_dir.join(".rename.tmp.99999.0");
958        std::fs::create_dir_all(&orphan).unwrap();
959        std::fs::write(orphan.join("marker.txt"), b"orphaned bridge state\n").unwrap();
960
961        assert_eq!(
962            state_names(&layout),
963            vec!["orig".to_string()],
964            "state_names must skip the dot-leading orphan"
965        );
966        let (name, path) = resolve_state(&layout, None).expect(
967            "zero-arg resolution must pick the lone legitimate state \
968             instead of erroring 'multiple bridge states' over the orphan",
969        );
970        assert_eq!(name, "orig");
971        assert_eq!(path, state_dir.join("orig"));
972    }
973}