Skip to main content

mkit_cli/commands/
git.rs

1//! `mkit git` — the git bridge subcommands (feature `git-bridge`):
2//! deterministic export to git mirrors (SPEC-GIT-BRIDGE) and, as the
3//! phases land, importer-signed import (SPEC-GIT-IMPORT).
4//!
5//! Translation happens into a local bare staging repo under
6//! `.mkit/git/<remote>/repo.git`, then a single `git push` with
7//! per-ref `--force-with-lease` moves the mirror. Per-ref refusals
8//! (remix ancestry, git-illegal ref names, non-canonical chunking)
9//! skip that ref with an actionable warning and export the rest
10//! (SPEC-GIT-BRIDGE §8, §12).
11
12use clap::Parser;
13use mkit_attest::{Envelope, PAYLOAD_TYPE_IN_TOTO, Sig, statement, store as attest_store};
14use mkit_core::layout::RepoLayout;
15use mkit_core::object::Object;
16use mkit_core::{Hash, ObjectStore, refs};
17use mkit_git_bridge::gitobj::{GitObject, GitType, Sha1Id, sha1_from_hex, sha1_hex};
18use mkit_git_bridge::translate::translate_closure;
19use mkit_git_bridge::{BridgeError, map, refname};
20use std::collections::HashMap;
21use std::fmt::Write as _;
22use std::path::{Path, PathBuf};
23use std::process::{Command, Stdio};
24
25use crate::clap_shim;
26use crate::commands::attest_factory;
27use crate::exit;
28use crate::format;
29
30/// SPEC-GIT-BRIDGE §11 predicate type.
31const PREDICATE_TYPE: &str =
32    "https://github.com/officialunofficial/mkit/spec/predicate/git-bridge/v1";
33
34/// Mirror-side ref carrying published bridge attestations (§11).
35const ATTESTATIONS_REF: &str = "refs/mkit/attestations";
36
37#[derive(Debug, Parser)]
38#[command(name = "mkit git", about = "Git-bridge subcommands (SPEC-GIT-BRIDGE).")]
39enum Cmd {
40    /// Export refs to a git mirror (one-way, deterministic).
41    Export(ExportArgs),
42    /// Import a git upstream as an importer-signed downstream fork.
43    Import(super::git_import::ImportArgs),
44    /// Fetch new upstream commits into refs/remotes/<name>/* only.
45    Fetch(super::git_import::FetchArgs),
46    /// Fetch, then fast-forward the current branch from its tracking ref.
47    Pull(super::git_import::FetchArgs),
48    /// Verify bridge state: shallow-verify translated objects, check
49    /// imported objects against the pinned importer key (--fork-audit
50    /// re-derives the referenced content too).
51    Verify(super::git_tools::VerifyArgs),
52    /// Show every bridge state dir: direction, endpoints, key, refs.
53    Status(super::git_tools::StatusArgs),
54    /// Render native commits as `git am`-able patches.
55    FormatPatch(super::git_tools::FormatPatchArgs),
56}
57
58#[derive(Debug, Parser)]
59struct ExportArgs {
60    /// Destination: a git URL or a local path (a missing local path
61    /// is initialized as a bare repository).
62    dest: String,
63    /// Name for the per-remote bridge state under `.mkit/git/<name>/`.
64    #[arg(long = "remote-name", value_name = "NAME", default_value = "mirror")]
65    remote_name: String,
66    /// Export only these refs (full names, e.g. `refs/heads/main`).
67    /// Default: every local branch and tag.
68    #[arg(long = "ref", value_name = "REF")]
69    refs: Vec<String>,
70    /// Skip minting/publishing git-bridge provenance attestations.
71    #[arg(long = "no-attest")]
72    no_attest: bool,
73    /// Attestation algorithm: `ed25519`, `secp256k1`, or `p256`
74    /// (default: `attest.default_algorithm` from config, else ed25519).
75    #[arg(long, value_name = "ALG")]
76    algorithm: Option<String>,
77    /// Attestation signer kind: `repo-key`, `external`, or `keystore`
78    /// (default: the configured attest signer, like `mkit attest`).
79    #[arg(long, value_name = "KIND")]
80    signer: Option<String>,
81    /// Fork mode (SPEC-GIT-BRIDGE §14): re-emit imported history as
82    /// the ORIGINAL git objects (shared SHAs with the upstream) and
83    /// bridge-translate only native commits on top. Requires this
84    /// remote-name's import state; upgrades its direction to `fork`.
85    #[arg(long)]
86    passthrough: bool,
87    /// Machine-readable JSON on stdout.
88    #[arg(long)]
89    json: bool,
90}
91
92#[must_use]
93pub fn run(args: &[String]) -> u8 {
94    let cmd = match clap_shim::parse::<Cmd>("mkit git", args) {
95        Ok(c) => c,
96        Err(code) => return code,
97    };
98    match cmd {
99        Cmd::Export(opts) => {
100            let cwd = match std::env::current_dir() {
101                Ok(c) => c,
102                Err(e) => return emit_err(&format!("cwd: {e}"), exit::CONFIG_ERROR),
103            };
104            let layout = match super::resolve_layout(&cwd) {
105                Ok(layout) => layout,
106                Err(code) => return code,
107            };
108            match export(&layout, &opts) {
109                Ok(code) => code,
110                Err((msg, code)) => emit_err(&msg, code),
111            }
112        }
113        Cmd::Import(opts) => super::git_import::run_import(&opts),
114        Cmd::Fetch(opts) => super::git_import::run_fetch(&opts, false),
115        Cmd::Pull(opts) => super::git_import::run_fetch(&opts, true),
116        Cmd::Verify(opts) => run_simple(|| super::git_tools::verify(&opts)),
117        Cmd::Status(opts) => run_simple(|| super::git_tools::status(&opts)),
118        Cmd::FormatPatch(opts) => run_simple(|| super::git_tools::format_patch(&opts)),
119    }
120}
121
122fn gitsrc_is_ancestor(staging: &Path, old: &Sha1Id, new: &Sha1Id) -> CmdResult<bool> {
123    mkit_git_bridge::gitsrc::is_ancestor(staging, old, new)
124        .map_err(|e| (e.to_string(), exit::GENERAL_ERROR))
125}
126
127fn json_report(ok: bool, exported: &[Exported], skipped: &[(String, String)]) -> String {
128    let mut out = format!("{{\"ok\":{ok},\"exported\":[");
129    for (i, e) in exported.iter().enumerate() {
130        if i > 0 {
131            out.push(',');
132        }
133        let _ = write!(
134            out,
135            "{{\"ref\":\"{}\",\"mkit\":\"{}\",\"git\":\"{}\"}}",
136            format::json_escape(&e.ref_name),
137            mkit_core::to_hex(&e.mkit_hash),
138            sha1_hex(&e.git_id)
139        );
140    }
141    out.push_str("],\"skipped\":[");
142    for (i, (r, why)) in skipped.iter().enumerate() {
143        if i > 0 {
144            out.push(',');
145        }
146        let _ = write!(
147            out,
148            "{{\"ref\":\"{}\",\"reason\":\"{}\"}}",
149            format::json_escape(r),
150            format::json_escape(why)
151        );
152    }
153    out.push_str("]}");
154    out
155}
156
157fn run_simple(f: impl FnOnce() -> Result<(), (String, u8)>) -> u8 {
158    match f() {
159        Ok(()) => exit::OK,
160        Err((msg, code)) => emit_err(&msg, code),
161    }
162}
163
164struct Exported {
165    ref_name: String,
166    mkit_hash: Hash,
167    git_id: Sha1Id,
168}
169
170type CmdResult<T> = Result<T, (String, u8)>;
171
172#[allow(clippy::too_many_lines)] // linear pipeline; stages are commented
173fn export(layout: &RepoLayout, opts: &ExportArgs) -> CmdResult<u8> {
174    let store = ObjectStore::open(layout)
175        .map_err(|e| (format!("open repository: {e}"), exit::GENERAL_ERROR))?;
176    git_version().map_err(|e| (e, exit::UNAVAILABLE))?;
177
178    // An option-shaped dest must never reach a git argv, and an empty
179    // one would `git init --bare` the caller's working directory.
180    if opts.dest.trim().is_empty() {
181        return Err(("empty git URL or path".into(), exit::USAGE));
182    }
183    if opts.dest.starts_with('-') {
184        return Err((
185            format!("{:?} is not a valid git URL or path", opts.dest),
186            exit::USAGE,
187        ));
188    }
189
190    // ── per-remote bridge state + bare staging repo ────────────────
191    let state =
192        map::state_dir(layout, &opts.remote_name).map_err(|e| (e.to_string(), exit::USAGE))?;
193    // One bridge operation per state dir at a time (shared with the
194    // import side: fetch + passthrough export on a fork dir race the
195    // staging mirror and the map).
196    let _state_lock = mkit_core::repo_lock::acquire_default(
197        layout.common_dir(),
198        &format!("git-{}.lock", opts.remote_name),
199    )
200    .map_err(|e| {
201        (
202            format!(
203                "bridge state '{}' is busy (another mkit git operation?): {e}",
204                opts.remote_name
205            ),
206            exit::TEMPFAIL,
207        )
208    })?;
209
210    // ORIGIN GUARD (SPEC-GIT-BRIDGE §14.2), FIRST — before any state
211    // is stamped or the dest is initialized, so a refusal has no side
212    // effects. Export toward a recorded git-import source would pass
213    // its ls-remote-seeded lease and force-replace upstream history
214    // with a disconnected re-translation. The only supported path is
215    // passthrough export through the SAME state that imported it
216    // (whose map re-emits the upstream's own objects) — passthrough
217    // through a DIFFERENT state is just as disconnected as a plain
218    // export.
219    let dest_identity = mkit_git_bridge::remoteid::remote_identity(&opts.dest);
220    if let Some(import_state) = recorded_import_source(layout, &dest_identity)
221        && !(opts.passthrough && import_state == opts.remote_name)
222    {
223        return Err((
224            format!(
225                "{} is a recorded git-import source (state '{import_state}'); \
226                 export toward an imported-from upstream would replace its \
227                 history with a disconnected re-translation. Passthrough export \
228                 through that state (`--passthrough --remote-name {import_state}`) \
229                 is the supported path (SPEC-GIT-BRIDGE §14.2)",
230                opts.dest
231            ),
232            exit::USAGE,
233        ));
234    }
235
236    // Direction binding (SPEC-GIT-IMPORT §6): plain export owns its
237    // state dir; --passthrough upgrades an IMPORT state dir to fork.
238    if opts.passthrough {
239        if mkit_git_bridge::map::read_direction(&state)
240            .ok()
241            .flatten()
242            .is_none()
243        {
244            return Err((
245                format!(
246                    "--passthrough requires import state for '{}' — run \
247                     `mkit git import <url>` first (SPEC-GIT-BRIDGE §14.1)",
248                    opts.remote_name
249                ),
250                exit::USAGE,
251            ));
252        }
253        // §3.3 stickiness: history imported with historic-mode
254        // normalization cannot reproduce its original sha1s — a fork
255        // built on it would fail every fork audit as false tampering.
256        if mkit_git_bridge::map::read_normalized(&state)
257            .map_err(|e| (e.to_string(), exit::GENERAL_ERROR))?
258        {
259            return Err((
260                format!(
261                    "state '{}' contains historic-mode-normalized trees; fork mode \
262                     cannot reproduce their original sha1s (SPEC-GIT-IMPORT §3.3). \
263                     Re-import under a new --remote-name to get fork-strict refusals",
264                    opts.remote_name
265                ),
266                exit::USAGE,
267            ));
268        }
269        mkit_git_bridge::map::bind_direction(&state, mkit_git_bridge::map::Direction::Fork)
270            .map_err(|e| (e.to_string(), exit::USAGE))?;
271    } else {
272        // Validate the direction early (mismatch refusals must fire
273        // before any work) but WRITE a fresh stamp only after the
274        // push succeeds — a typo'd remote dest must not burn the
275        // state name (mirrors the import side's validate-then-bind).
276        match mkit_git_bridge::map::read_direction(&state)
277            .map_err(|e| (e.to_string(), exit::GENERAL_ERROR))?
278        {
279            None | Some(mkit_git_bridge::map::Direction::Export) => {}
280            Some(other) => {
281                return Err((
282                    format!(
283                        "state dir is bound to direction '{}'; 'export' is not allowed \
284                         here (one direction per state dir — use a different \
285                         --remote-name)",
286                        other.as_str()
287                    ),
288                    exit::USAGE,
289                ));
290            }
291        }
292    }
293
294    let staging = state.join("repo.git");
295    if !staging.join("objects").is_dir() {
296        if opts.passthrough {
297            return Err((
298                "fork-mode staging mirror missing — re-run `mkit git import` to restore it".into(),
299                exit::CONFIG_ERROR,
300            ));
301        }
302        // (Re)initializing staging invalidates the map cache: cached
303        // sha1s would point at objects the fresh staging repo does not
304        // have, wedging update-ref/push (§12.3: cache is disposable).
305        let _ = std::fs::remove_file(state.join("map"));
306        std::fs::create_dir_all(&staging)
307            .map_err(|e| (format!("create staging dir: {e}"), exit::CANTCREAT))?;
308        git_in(&staging, &["init", "--bare", "--quiet", "."])
309            .map_err(|e| (format!("init staging repo: {e}"), exit::CANTCREAT))?;
310    }
311    let mut known =
312        map::load_map(&state).map_err(|e| (format!("load map cache: {e}"), exit::GENERAL_ERROR))?;
313    let prior_state = map::load_ref_state(&state)
314        .map_err(|e| (format!("load ref state: {e}"), exit::GENERAL_ERROR))?;
315
316    // Validate/prepare the destination up front (before any signing or
317    // state mutation). PLAIN export binds this state dir to one dest
318    // by canonical identity (SPEC-GIT-IMPORT §8): recorded leases are
319    // statements about one mirror and are wrong for another. FORK
320    // mode does NOT bind — its leases come from a fresh per-push
321    // observation guarded by the explicit fast-forward check, so the
322    // triangular workflow (import upstream U, push fork F, later
323    // contribute to U) stays possible; the last dest is recorded for
324    // `mkit git status` only.
325    let push_dest = ensure_dest(&opts.dest)?;
326    // A state dir is FRESH when nothing has ever bound it: if the
327    // remote contact below fails, the whole dir (staging, map cache)
328    // is removed so the name is not burned and a later import cannot
329    // land on mixed leftovers.
330    let fresh_state = !opts.passthrough && !state.join("dest").exists();
331
332    // Recompute AFTER ensure_dest: a fresh local mirror did not exist
333    // when the origin-guard identity above was taken, so its lexical
334    // fallback differs from the canonicalized spelling every later
335    // run produces — binding the early value would wedge the state on
336    // the second export. (The guard itself is unaffected: recorded
337    // import sources always exist.)
338    let bound_identity = mkit_git_bridge::remoteid::remote_identity(&opts.dest);
339
340    let dest_file = state.join("dest");
341    if opts.passthrough {
342        mkit_git_bridge::map::write_binding(&state, "dest", &bound_identity)
343            .map_err(|e| (format!("record dest: {e}"), exit::CANTCREAT))?;
344    } else {
345        match std::fs::read_to_string(&dest_file) {
346            Ok(recorded) if recorded.trim() != bound_identity => {
347                return Err((
348                    format!(
349                        "state '{}' is bound to {}; use a different --remote-name for {}",
350                        opts.remote_name,
351                        recorded.trim(),
352                        opts.dest
353                    ),
354                    exit::USAGE,
355                ));
356            }
357            Ok(_) => {}
358            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
359                // Recorded after the push succeeds (see below).
360            }
361            Err(e) => return Err((format!("read dest binding: {e}"), exit::GENERAL_ERROR)),
362        }
363    }
364
365    // ── ref selection (§12.1) ──────────────────────────────────────
366    let requested = collect_refs(layout, &opts.refs)?;
367    if requested.is_empty() {
368        return Err(("nothing to export: no branches or tags".into(), exit::USAGE));
369    }
370
371    let mut exported: Vec<Exported> = Vec::new();
372    let mut skipped: Vec<(String, String)> = Vec::new();
373    let mut new_pairs: Vec<(Hash, Sha1Id)> = Vec::new();
374
375    for (ref_name, head) in requested {
376        if let Err(refusal) = refname::check_git_legal(&ref_name) {
377            warn_skip(&mut skipped, &ref_name, &refusal.to_string());
378            continue;
379        }
380        let result = translate_closure(&store, &head, &mut known, &mut |h, g| {
381            let id = g.write_loose(&staging)?;
382            new_pairs.push((*h, id));
383            Ok(())
384        });
385        match result {
386            Ok(batch) => {
387                // Fork mode writes under a private namespace so the
388                // import mirror's own refs (upstream state) stay
389                // untouched; the push refspec maps it back.
390                let local_ref = if opts.passthrough {
391                    format!("refs/mkit-export/{ref_name}")
392                } else {
393                    ref_name.clone()
394                };
395                git_in(
396                    &staging,
397                    &["update-ref", &local_ref, &sha1_hex(&batch.root)],
398                )
399                .map_err(|e| (format!("update-ref {ref_name}: {e}"), exit::GENERAL_ERROR))?;
400                exported.push(Exported {
401                    ref_name,
402                    mkit_hash: head,
403                    git_id: batch.root,
404                });
405            }
406            Err(BridgeError::Refused(r)) => {
407                // Objects already written are harmless content-addressed
408                // orphans; the map pairs stay valid (determinism).
409                warn_skip(&mut skipped, &ref_name, &r.to_string());
410            }
411            Err(e) => return Err((format!("translate {ref_name}: {e}"), exit::GENERAL_ERROR)),
412        }
413    }
414
415    map::append_map(&state, &new_pairs)
416        .map_err(|e| (format!("persist map cache: {e}"), exit::GENERAL_ERROR))?;
417
418    if exported.is_empty() {
419        if opts.json {
420            // Same shape as the success report, ok:false — a JSON
421            // consumer gets per-ref skip reasons here just like the
422            // import side does.
423            println!("{}", json_report(false, &exported, &skipped));
424        }
425        return Err((
426            format!(
427                "every requested ref was skipped ({} refusals)",
428                skipped.len()
429            ),
430            exit::GENERAL_ERROR,
431        ));
432    }
433
434    // ── provenance attestations (§11) ──────────────────────────────
435    // §11 scoping: fork-mode heads whose tip passed through (came
436    // from the import map) carry no translation claim — their
437    // provenance is git-import/v1.
438    let attestable: Vec<Exported> = exported
439        .iter()
440        .filter(|e| {
441            if !opts.passthrough {
442                return true;
443            }
444            // An imported tip's raw git bytes are retained under
445            // state/raw/ — that head passed through and its
446            // provenance is git-import/v1, not a translation claim.
447            let hex = sha1_hex(&e.git_id);
448            !state.join("raw").join(&hex[..2]).join(&hex[2..]).exists()
449        })
450        .map(|e| Exported {
451            ref_name: e.ref_name.clone(),
452            mkit_hash: e.mkit_hash,
453            git_id: e.git_id,
454        })
455        .collect();
456    let attest_head: Option<Sha1Id> = if opts.no_attest || attestable.is_empty() {
457        None
458    } else {
459        Some(publish_attestations(
460            layout,
461            &store,
462            &staging,
463            &opts.dest,
464            &attestable,
465            opts,
466            &prior_state,
467        )?)
468    };
469
470    // ── push with per-ref CAS leases (§12.2) ───────────────────────
471    // Lease expectation per ref: recorded state, else (state lost or
472    // never recorded) the mirror's CURRENT value via ls-remote — a
473    // fresh observation is still a CAS, and it is what makes wiped
474    // bridge state rebuildable against an existing mirror (§12.3).
475    let prior: HashMap<&str, &map::RefState> = prior_state
476        .iter()
477        .map(|s| (s.ref_name.as_str(), s))
478        .collect();
479    let mut to_push: Vec<(&str, Sha1Id)> = exported
480        .iter()
481        .map(|e| (e.ref_name.as_str(), e.git_id))
482        .collect();
483    if let Some(head) = attest_head {
484        to_push.push((ATTESTATIONS_REF, head));
485    }
486    // Fork mode ALWAYS observes: it pushes to a repository mkit does
487    // not own, so the remote moving between exports is the normal
488    // case — a recorded lease from our last push would go stale the
489    // moment a third-party commit lands, and `mkit git fetch` only
490    // updates the import side. The fresh observation is safe to lease
491    // against because the explicit fast-forward guard below refuses
492    // anything we have not integrated. Plain export keeps recorded
493    // leases (the mirror is owned by this repo; the lease IS the
494    // tamper check) and observes only refs it has no lease for.
495    let needs_observation =
496        opts.passthrough || to_push.iter().any(|(name, _)| !prior.contains_key(*name));
497    let observed: HashMap<String, Sha1Id> = if needs_observation {
498        match ls_remote(&staging, &push_dest) {
499            Ok(o) => o,
500            Err(e) => {
501                if fresh_state {
502                    let _ = std::fs::remove_dir_all(&state);
503                }
504                return Err(e);
505            }
506        }
507    } else {
508        HashMap::new()
509    };
510    // One expectation rule shared by the FF guard and the push lease.
511    // Passthrough: the observation is AUTHORITATIVE — fork mode is
512    // not dest-bound, so a lease recorded against one destination is
513    // meaningless for another (absent on the remote means "must not
514    // exist", never "fall back to what we pushed elsewhere").
515    let expectation = |name: &str| -> Option<Sha1Id> {
516        if opts.passthrough {
517            observed.get(name).copied()
518        } else {
519            prior
520                .get(name)
521                .map(|s| s.git_id)
522                .or_else(|| observed.get(name).copied())
523        }
524    };
525    // Fork mode pushes to repositories mkit does NOT own (the
526    // upstream itself, or a real fork): a lease seeded from a fresh
527    // ls-remote observation passes unconditionally, so require
528    // fast-forward explicitly — the expected value must be an
529    // ancestor of what we push, and tags must not move. Plain export
530    // keeps the default mirror-owned semantics (the mirror is owned by this repo).
531    if opts.passthrough {
532        for (name, new_id) in &to_push {
533            if *name == ATTESTATIONS_REF {
534                continue;
535            }
536            let Some(expect) = expectation(name) else {
537                continue;
538            };
539            if expect == *new_id {
540                continue;
541            }
542            if name.starts_with("refs/tags/") {
543                return Err((
544                    format!(
545                        "{name} already exists on {} at a different object; fork-mode \
546                         export never moves an existing tag",
547                        opts.dest
548                    ),
549                    exit::USAGE,
550                ));
551            }
552            let ff = mkit_git_bridge::gitsrc::object_exists(&staging, &expect)
553                .map_err(|e| (e.to_string(), exit::GENERAL_ERROR))?
554                && gitsrc_is_ancestor(&staging, &expect, new_id)?;
555            if !ff {
556                return Err((
557                    format!(
558                        "{name} on {} has commits this repo has not integrated; \
559                         run `mkit git fetch` and `mkit merge {}/{}` first \
560                         (fork-mode export refuses non-fast-forward pushes)",
561                        opts.dest,
562                        opts.remote_name,
563                        name.strip_prefix("refs/heads/").unwrap_or(name)
564                    ),
565                    exit::DATAERR,
566                ));
567            }
568        }
569    }
570
571    // --atomic: either every ref (incl. attestations) lands or none
572    // does, so recorded state can never go stale per-ref.
573    let mut push_args: Vec<String> = vec!["push".into(), "--quiet".into(), "--atomic".into()];
574    for (name, _) in &to_push {
575        let expect = expectation(name)
576            .map(|id| sha1_hex(&id))
577            .unwrap_or_default();
578        push_args.push(format!("--force-with-lease={name}:{expect}"));
579    }
580    push_args.push(push_dest.clone());
581    for (name, _) in &to_push {
582        if opts.passthrough && *name != ATTESTATIONS_REF {
583            push_args.push(format!("refs/mkit-export/{name}:{name}"));
584        } else {
585            push_args.push(format!("{name}:{name}"));
586        }
587    }
588    let push_arg_refs: Vec<&str> = push_args.iter().map(String::as_str).collect();
589    git_in(&staging, &push_arg_refs).map_err(|e| {
590        if fresh_state {
591            let _ = std::fs::remove_dir_all(&state);
592        }
593        let hint = if e.contains("stale info") {
594            "\nhint: the mirror moved since the last export; if that \
595             change is yours/expected, remove .mkit/git/<name>/refs to \
596             reseed leases from the mirror and re-run"
597        } else {
598            ""
599        };
600        (
601            format!("push to {}: {e}{hint}", opts.dest),
602            exit::GENERAL_ERROR,
603        )
604    })?;
605
606    // Push succeeded: record the fresh plain-export bindings the
607    // validation above deferred (idempotent for already-bound dirs).
608    if !opts.passthrough {
609        mkit_git_bridge::map::bind_direction(&state, mkit_git_bridge::map::Direction::Export)
610            .map_err(|e| (e.to_string(), exit::CANTCREAT))?;
611        if !dest_file.exists() {
612            mkit_git_bridge::map::write_binding(&state, "dest", &bound_identity)
613                .map_err(|e| (format!("record dest: {e}"), exit::CANTCREAT))?;
614        }
615    }
616
617    // ── record the new lease expectations ──────────────────────────
618    // Merge over prior state: refs not in this export keep their
619    // recorded leases (a --ref subset or a skip must not wipe them).
620    let mut merged: Vec<map::RefState> = prior_state
621        .iter()
622        .filter(|s| !to_push.iter().any(|(n, _)| *n == s.ref_name))
623        .cloned()
624        .collect();
625    merged.extend(exported.iter().map(|e| map::RefState {
626        ref_name: e.ref_name.clone(),
627        mkit_hash: e.mkit_hash,
628        git_id: e.git_id,
629    }));
630    if let Some(head) = attest_head {
631        merged.push(map::RefState {
632            ref_name: ATTESTATIONS_REF.to_owned(),
633            mkit_hash: mkit_core::hash::ZERO,
634            git_id: head,
635        });
636    }
637    merged.sort_by(|a, b| a.ref_name.cmp(&b.ref_name));
638    map::store_ref_state(&state, &merged)
639        .map_err(|e| (format!("persist ref state: {e}"), exit::GENERAL_ERROR))?;
640
641    // ── report ─────────────────────────────────────────────────────
642    if opts.json {
643        println!("{}", json_report(true, &exported, &skipped));
644    } else {
645        for e in &exported {
646            println!(
647                "exported {} {} -> {}",
648                e.ref_name,
649                mkit_core::to_hex(&e.mkit_hash),
650                sha1_hex(&e.git_id)
651            );
652        }
653    }
654    Ok(exit::OK)
655}
656
657/// Default export set: every branch and tag, as full ref names.
658fn collect_refs(layout: &RepoLayout, explicit: &[String]) -> CmdResult<Vec<(String, Hash)>> {
659    if !explicit.is_empty() {
660        let mut out = Vec::new();
661        let mut seen = std::collections::HashSet::new();
662        for name in explicit {
663            if !seen.insert(name.as_str()) {
664                continue; // duplicate --ref would duplicate the refspec
665            }
666            let short = name
667                .strip_prefix("refs/heads/")
668                .or_else(|| name.strip_prefix("refs/tags/"));
669            let Some(short) = short else {
670                return Err((
671                    format!("--ref {name}: expected refs/heads/... or refs/tags/..."),
672                    exit::USAGE,
673                ));
674            };
675            // read_ref/read_tag take namespace-relative short names.
676            let hash = if name.starts_with("refs/heads/") {
677                refs::read_ref(layout, short)
678            } else {
679                refs::read_tag(layout, short)
680            }
681            .map_err(|e| (format!("read {name}: {e}"), exit::GENERAL_ERROR))?
682            .ok_or_else(|| (format!("--ref {name}: not found"), exit::DATAERR))?;
683            out.push((name.clone(), hash));
684        }
685        return Ok(out);
686    }
687    let mut out = Vec::new();
688    let branches = refs::list_refs(layout)
689        .map_err(|e| (format!("list branches: {e}"), exit::GENERAL_ERROR))?;
690    for r in branches {
691        if let Some(h) = r.hash {
692            out.push((format!("refs/heads/{}", r.name), h));
693        }
694    }
695    let tags =
696        refs::list_tags(layout).map_err(|e| (format!("list tags: {e}"), exit::GENERAL_ERROR))?;
697    for r in tags {
698        if let Some(h) = r.hash {
699            out.push((format!("refs/tags/{}", r.name), h));
700        }
701    }
702    Ok(out)
703}
704
705/// Mint one DSSE attestation per exported head (subject = mkit hash,
706/// predicate carries the git locator), save it locally like `mkit
707/// attest` does, and publish the set on the staging repo's
708/// `refs/mkit/attestations` flat tree. Returns the staging ref's
709/// resulting head — unchanged trees return the existing commit, so a
710/// previously failed push retries with the same refspec instead of
711/// silently dropping the attestations ref.
712#[allow(clippy::too_many_lines)] // mint loop + tree/commit assembly; splitting would scatter §11
713fn publish_attestations(
714    layout: &RepoLayout,
715    store: &ObjectStore,
716    staging: &Path,
717    dest: &str,
718    exported: &[Exported],
719    opts: &ExportArgs,
720    prior_state: &[map::RefState],
721) -> CmdResult<Sha1Id> {
722    // Same signer resolution as `mkit attest` (SPEC-GIT-BRIDGE §11:
723    // "the exporter's configured signer"): flag, else config default.
724    let cfg = crate::config::read_or_default(layout)
725        .map_err(|e| (format!("read config: {e}"), exit::CONFIG_ERROR))?;
726    let alg_str = opts
727        .algorithm
728        .clone()
729        .unwrap_or_else(|| cfg.attest.default_algorithm_or_fallback().to_owned());
730    let algorithm =
731        attest_factory::parse_algorithm(&alg_str).map_err(|e| (format!("{e}"), exit::USAGE))?;
732    let signer_kind = opts
733        .signer
734        .clone()
735        .unwrap_or_else(|| cfg.attest.signer_or_fallback().to_owned());
736    let mut signer =
737        attest_factory::build_signer(layout, algorithm, &signer_kind, &cfg).map_err(|e| {
738            (
739                format!("build bridge signer: {e}"),
740                crate::commands::attest::factory_error_code(&e),
741            )
742        })?;
743
744    // Existing published entries (name → blob id) so re-exports merge.
745    let mut entries: Vec<(String, Sha1Id)> = Vec::new();
746    let old_commit = read_ref_in(staging, ATTESTATIONS_REF)?;
747    if let Some(old) = &old_commit {
748        for (name, id) in ls_tree(staging, old)? {
749            entries.push((name, id));
750        }
751    }
752
753    // Mint only for new/moved heads: a head whose recorded state is
754    // unchanged AND whose claim is already on the published ref needs
755    // no fresh envelope. This keeps no-op re-exports no-op even with
756    // nondeterministic signers (e.g. P-256), instead of growing the
757    // tree and local store every run.
758    let already_published = |e: &Exported| -> bool {
759        old_commit.is_some()
760            && prior_state.iter().any(|s| {
761                s.ref_name == e.ref_name && s.mkit_hash == e.mkit_hash && s.git_id == e.git_id
762            })
763    };
764    let mut max_ts = 0u64;
765    for e in exported {
766        if already_published(e) {
767            max_ts = max_ts.max(head_timestamp(store, &e.mkit_hash));
768            continue;
769        }
770        // Deterministic synthetic-commit timestamp: newest exported head.
771        max_ts = max_ts.max(head_timestamp(store, &e.mkit_hash));
772        let predicate = format!(
773            "{{\"gitCommit\":\"{}\",\"mirror\":\"{}\",\"refName\":\"{}\",\"schemaVersion\":1,\"specVersion\":1}}",
774            sha1_hex(&e.git_id),
775            format::json_escape(dest),
776            format::json_escape(&e.ref_name)
777        );
778        let head_bytes = super::read_object_bytes(store, &e.mkit_hash)?;
779        let stmt = statement::encode(&statement::Statement {
780            subjects: vec![statement::Subject {
781                name: Some(e.ref_name.clone()),
782                digest_blake3_hex: mkit_core::to_hex(&e.mkit_hash),
783                digest_sha256_hex: statement::sha256_hex(&head_bytes),
784            }],
785            predicate_type: PREDICATE_TYPE.to_owned(),
786            predicate_jcs: predicate.as_bytes(),
787        })
788        .map_err(|e| (format!("encode statement: {e}"), exit::GENERAL_ERROR))?;
789        let pae = mkit_attest::pae_of(PAYLOAD_TYPE_IN_TOTO, stmt.as_bytes());
790        let sig = signer
791            .sign(&pae)
792            .map_err(|e| (format!("sign bridge attestation: {e}"), exit::GENERAL_ERROR))?;
793        let keyid = signer
794            .keyid()
795            .map_err(|e| (format!("bridge signer keyid: {e}"), exit::GENERAL_ERROR))?;
796        let envelope = Envelope {
797            payload_type: PAYLOAD_TYPE_IN_TOTO.to_owned(),
798            payload: stmt.into_bytes(),
799            signatures: vec![Sig { keyid, sig }],
800        };
801        let encoded = envelope
802            .encode()
803            .map_err(|e| (format!("encode envelope: {e}"), exit::GENERAL_ERROR))?;
804        attest_store::save(layout, &e.mkit_hash, encoded.as_bytes())
805            .map_err(|e| (format!("save attestation: {e}"), exit::CANTCREAT))?;
806
807        let blob = GitObject {
808            gtype: GitType::Blob,
809            body: encoded.into_bytes(),
810        };
811        let blob_id = blob
812            .write_loose(staging)
813            .map_err(|e| (format!("write attestation blob: {e}"), exit::CANTCREAT))?;
814        // Entry name = attestation id (BLAKE3 of the envelope bytes,
815        // matching the local store's naming). Naming by git sha would
816        // collide when two refs share a head — each ref still gets
817        // its own envelope (distinct refName in the predicate).
818        let att_id = mkit_attest::attestation_id(blob.body.as_slice());
819        let name = format!("{}.dsse", mkit_core::to_hex(&att_id));
820        entries.retain(|(n, _)| n != &name);
821        entries.push((name, blob_id));
822    }
823
824    // Flat tree, git sort order (all blobs, so plain byte-lex).
825    entries.sort_by(|a, b| a.0.cmp(&b.0));
826    let mut tree_body = Vec::new();
827    for (name, id) in &entries {
828        tree_body.extend_from_slice(b"100644 ");
829        tree_body.extend_from_slice(name.as_bytes());
830        tree_body.push(0);
831        tree_body.extend_from_slice(id);
832    }
833    let tree = GitObject {
834        gtype: GitType::Tree,
835        body: tree_body,
836    };
837    let tree_id = tree
838        .write_loose(staging)
839        .map_err(|e| (format!("write attestation tree: {e}"), exit::CANTCREAT))?;
840
841    // Unchanged tree ⇒ keep the existing commit (no new history; the
842    // caller still pushes the ref so an earlier failed push retries).
843    if let Some(old) = &old_commit
844        && commit_tree_id(staging, old)? == Some(tree_id)
845    {
846        return Ok(*old);
847    }
848
849    let person = format!("mkit-git-bridge <bridge@mkit.invalid> {max_ts} +0000");
850    let mut body = Vec::new();
851    body.extend_from_slice(format!("tree {}\n", sha1_hex(&tree_id)).as_bytes());
852    if let Some(old) = &old_commit {
853        body.extend_from_slice(format!("parent {}\n", sha1_hex(old)).as_bytes());
854    }
855    body.extend_from_slice(format!("author {person}\ncommitter {person}\n").as_bytes());
856    body.extend_from_slice(b"\nmkit git-bridge attestations\n");
857    let commit = GitObject {
858        gtype: GitType::Commit,
859        body,
860    };
861    let commit_id = commit
862        .write_loose(staging)
863        .map_err(|e| (format!("write attestation commit: {e}"), exit::CANTCREAT))?;
864    git_in(
865        staging,
866        &["update-ref", ATTESTATIONS_REF, &sha1_hex(&commit_id)],
867    )
868    .map_err(|e| {
869        (
870            format!("update-ref {ATTESTATIONS_REF}: {e}"),
871            exit::GENERAL_ERROR,
872        )
873    })?;
874    Ok(commit_id)
875}
876
877fn head_timestamp(store: &ObjectStore, h: &Hash) -> u64 {
878    match store.read_object(h) {
879        Ok(Object::Commit(c)) => c.timestamp,
880        Ok(Object::Tag(t)) => t.timestamp,
881        _ => 0,
882    }
883}
884
885fn warn_skip(skipped: &mut Vec<(String, String)>, ref_name: &str, why: &str) {
886    eprintln!("warning: skipping {ref_name}: {why}");
887    skipped.push((ref_name.to_owned(), why.to_owned()));
888}
889
890// ─── git subprocess helpers ─────────────────────────────────────────
891
892pub(crate) fn git_version() -> Result<(), String> {
893    let mut c = Command::new("git");
894    mkit_git_bridge::gitsrc::apply_hygiene(&mut c);
895    match c
896        .arg("--version")
897        .stdout(Stdio::null())
898        .stderr(Stdio::null())
899        .status()
900    {
901        Ok(s) if s.success() => Ok(()),
902        Ok(s) => Err(format!("`git --version` exited with {s}")),
903        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
904            Err("`git` not found on PATH; mkit git export shells out to it".into())
905        }
906        Err(e) => Err(format!("spawn git: {e}")),
907    }
908}
909
910pub(crate) fn git_in(dir: &Path, args: &[&str]) -> Result<String, String> {
911    let out = mkit_git_bridge::gitsrc::git_command(dir)
912        .args(args)
913        .output()
914        .map_err(|e| format!("spawn git: {e}"))?;
915    if out.status.success() {
916        Ok(String::from_utf8_lossy(&out.stdout).into_owned())
917    } else {
918        Err(format!(
919            "git {} failed: {}",
920            args.first().copied().unwrap_or(""),
921            String::from_utf8_lossy(&out.stderr).trim()
922        ))
923    }
924}
925
926/// A missing or empty *local path* destination is initialized as a
927/// bare repo; URLs pass through untouched. Local paths come back
928/// absolutized because the push runs `git -C <staging>`, which would
929/// otherwise resolve them against the staging directory.
930fn ensure_dest(dest: &str) -> CmdResult<String> {
931    if dest.starts_with('-') {
932        // Would be parsed as a git option in the push argv.
933        return Err((format!("invalid destination {dest:?}"), exit::USAGE));
934    }
935    // git's own rule: "://" means a URL, and otherwise a colon BEFORE
936    // the first slash means an scp-style remote (user@ is optional) —
937    // except a DOS drive prefix (`C:\` / `C:/`), which is a path.
938    let dos_drive = dest.len() >= 2
939        && dest.as_bytes()[0].is_ascii_alphabetic()
940        && dest.as_bytes()[1] == b':'
941        && matches!(dest.as_bytes().get(2), None | Some(b'/' | b'\\'));
942    let looks_like_url = !dos_drive
943        && (dest.contains("://")
944            || dest
945                .split('/')
946                .next()
947                .is_some_and(|first| first.contains(':')));
948    if looks_like_url {
949        return Ok(dest.to_owned());
950    }
951    let path = PathBuf::from(dest);
952    let needs_init = if path.exists() {
953        let is_repo = mkit_git_bridge::gitsrc::git_command(&path)
954            .args(["rev-parse", "--git-dir"])
955            .stdout(Stdio::null())
956            .stderr(Stdio::null())
957            .status()
958            .is_ok_and(|s| s.success());
959        if is_repo {
960            false
961        } else {
962            let empty = std::fs::read_dir(&path)
963                .map_err(|e| (format!("read {dest}: {e}"), exit::CONFIG_ERROR))?
964                .next()
965                .is_none();
966            if !empty {
967                return Err((
968                    format!("{dest} exists and is neither a git repository nor empty"),
969                    exit::CANTCREAT,
970                ));
971            }
972            true
973        }
974    } else {
975        std::fs::create_dir_all(&path)
976            .map_err(|e| (format!("create {dest}: {e}"), exit::CANTCREAT))?;
977        true
978    };
979    if needs_init {
980        git_in(&path, &["init", "--bare", "--quiet", "."])
981            .map_err(|e| (format!("init {dest}: {e}"), exit::CANTCREAT))?;
982    }
983    let abs = path
984        .canonicalize()
985        .map_err(|e| (format!("resolve {dest}: {e}"), exit::CONFIG_ERROR))?;
986    Ok(abs.to_string_lossy().into_owned())
987}
988
989fn read_ref_in(repo: &Path, name: &str) -> CmdResult<Option<Sha1Id>> {
990    let out = mkit_git_bridge::gitsrc::git_command(repo)
991        .args(["rev-parse", "--verify", "--quiet", name])
992        .output()
993        .map_err(|e| (format!("spawn git: {e}"), exit::GENERAL_ERROR))?;
994    if !out.status.success() {
995        return Ok(None);
996    }
997    let hex = String::from_utf8_lossy(&out.stdout);
998    Ok(sha1_from_hex(hex.trim()))
999}
1000
1001fn ls_tree(repo: &Path, commit: &Sha1Id) -> CmdResult<Vec<(String, Sha1Id)>> {
1002    let spec = format!("{}^{{tree}}", sha1_hex(commit));
1003    let out = git_in(repo, &["ls-tree", &spec])
1004        .map_err(|e| (format!("ls-tree: {e}"), exit::GENERAL_ERROR))?;
1005    let mut entries = Vec::new();
1006    for line in out.lines() {
1007        // "<mode> blob <id>\t<name>"
1008        let Some((meta, name)) = line.split_once('\t') else {
1009            continue;
1010        };
1011        let Some(id_hex) = meta.split(' ').nth(2) else {
1012            continue;
1013        };
1014        if let Some(id) = sha1_from_hex(id_hex) {
1015            entries.push((name.to_owned(), id));
1016        }
1017    }
1018    Ok(entries)
1019}
1020
1021fn commit_tree_id(repo: &Path, commit: &Sha1Id) -> CmdResult<Option<Sha1Id>> {
1022    let spec = format!("{}^{{tree}}", sha1_hex(commit));
1023    let out = mkit_git_bridge::gitsrc::git_command(repo)
1024        .args(["rev-parse", "--verify", "--quiet", &spec])
1025        .output()
1026        .map_err(|e| (format!("spawn git: {e}"), exit::GENERAL_ERROR))?;
1027    if !out.status.success() {
1028        return Ok(None);
1029    }
1030    let hex = String::from_utf8_lossy(&out.stdout);
1031    Ok(sha1_from_hex(hex.trim()))
1032}
1033
1034/// Read the destination's current refs (one round-trip). Used to seed
1035/// lease expectations when recorded state is missing (§12.3).
1036fn ls_remote(staging: &Path, dest: &str) -> CmdResult<HashMap<String, Sha1Id>> {
1037    let out = git_in(staging, &["ls-remote", "--quiet", dest, "refs/*"])
1038        .map_err(|e| (format!("ls-remote {dest}: {e}"), exit::GENERAL_ERROR))?;
1039    let mut refs = HashMap::new();
1040    for line in out.lines() {
1041        let Some((hex, name)) = line.split_once('\t') else {
1042            continue;
1043        };
1044        if let Some(id) = sha1_from_hex(hex.trim()) {
1045            refs.insert(name.trim().to_owned(), id);
1046        }
1047    }
1048    Ok(refs)
1049}
1050
1051/// The state-dir name whose recorded import `source` matches the
1052/// given canonical identity, if any.
1053fn recorded_import_source(layout: &RepoLayout, identity: &str) -> Option<String> {
1054    let entries = std::fs::read_dir(layout.git_state_dir()).ok()?;
1055    for entry in entries.flatten() {
1056        let name = entry.file_name().to_string_lossy().into_owned();
1057        let src = entry.path().join("source");
1058        if let Ok(recorded) = std::fs::read_to_string(src)
1059            && recorded.trim() == identity
1060        {
1061            return Some(name);
1062        }
1063    }
1064    None
1065}
1066
1067use super::error as emit_err;