Skip to main content

mkit_cli/commands/
git_import.rs

1//! `mkit git import` / `mkit git fetch` / `mkit git pull` — the
2//! importer-signed git→mkit direction (SPEC-GIT-IMPORT; feature
3//! `git-bridge`).
4//!
5//! Import clones a `--mirror` staging repo under
6//! `.mkit/git/<remote>/repo.git`, translates through the
7//! mkit-git-bridge engine under a DEDICATED import key (pinned in the
8//! state dir), lands branches in `refs/remotes/<remote>/*` (plus
9//! `refs/heads/<default>` + worktree checkout in the fresh-clone
10//! form), retains raw commit/tag bytes, and mints git-import/v1
11//! attestations per head. Fetch updates tracking refs only; pull adds
12//! the native fast-forward of the current branch. Integration with
13//! local work is NATIVE (`mkit merge <remote>/<branch>`).
14
15use clap::Parser;
16use mkit_attest::Signer as _;
17use mkit_attest::{Envelope, PAYLOAD_TYPE_IN_TOTO, Sig, statement, store as attest_store};
18use mkit_core::layout::RepoLayout;
19use mkit_core::object::{Object, ObjectType};
20use mkit_core::sign::{KeyPair, sign_commit, sign_tag};
21use mkit_core::store::BulkWriter;
22use mkit_core::{Hash, ObjectStore, refs};
23use mkit_git_bridge::error::BridgeError;
24use mkit_git_bridge::gitobj::{Sha1Id, bytes_hex, sha1_hex};
25use mkit_git_bridge::gitsrc::{self, CatFileBatch};
26use mkit_git_bridge::import::{
27    DepthMemo, IMPORT_SPEC_VERSION, ImportOptions, ImportSigner, Importer, ObjectSink,
28};
29use mkit_git_bridge::map::{self, Direction};
30use mkit_git_bridge::remoteid::remote_identity;
31use std::collections::HashMap;
32use std::fmt::Write as _;
33use std::path::{Path, PathBuf};
34
35use crate::exit;
36use crate::format;
37
38/// SPEC-GIT-IMPORT §5 predicate type.
39const PREDICATE_TYPE: &str =
40    "https://github.com/officialunofficial/mkit/spec/predicate/git-import/v1";
41
42/// Default dedicated import key path (SPEC-GIT-IMPORT §4).
43const IMPORT_KEY_FILE: &str = "keys/git-import.key";
44
45/// Crash marker: present while a bulk import session is open; found
46/// at start ⇒ the previous session crashed ⇒ discard the map cache
47/// (objects may be torn; the map must not vouch for them).
48const IMPORTING_MARKER: &str = "importing";
49
50#[derive(Debug, Parser)]
51pub struct ImportArgs {
52    /// Upstream git URL or local path.
53    pub url: String,
54    /// Directory for the fresh-clone form (omit to import into the
55    /// current mkit repository as tracking refs only).
56    pub dir: Option<String>,
57    /// Bridge state name under `.mkit/git/<name>/`.
58    #[arg(long = "remote-name", value_name = "NAME", default_value = "upstream")]
59    pub remote_name: String,
60    /// Path to the import signing key (32-byte seed file). Default:
61    /// `.mkit/keys/git-import.key`, generated on first use.
62    #[arg(long = "key", value_name = "PATH")]
63    pub key: Option<String>,
64    /// Machine-readable JSON on stdout.
65    #[arg(long)]
66    pub json: bool,
67}
68
69#[derive(Debug, Parser)]
70pub struct FetchArgs {
71    /// Bridge state name under `.mkit/git/<name>/`.
72    #[arg(long = "remote-name", value_name = "NAME", default_value = "upstream")]
73    pub remote_name: String,
74    /// Path to the import signing key (default: the pinned key file).
75    #[arg(long = "key", value_name = "PATH")]
76    pub key: Option<String>,
77    /// Machine-readable JSON on stdout.
78    #[arg(long)]
79    pub json: bool,
80}
81
82type CmdResult<T> = Result<T, (String, u8)>;
83
84// ─── entry points ───────────────────────────────────────────────────
85
86#[must_use]
87pub fn run_import(opts: &ImportArgs) -> u8 {
88    let outcome = match opts.dir.as_deref() {
89        Some(dir) => fresh_clone(opts, dir),
90        None => std::env::current_dir()
91            .map_err(|e| (format!("cwd: {e}"), exit::CONFIG_ERROR))
92            .and_then(|cwd| {
93                mkit_core::layout::discover(&cwd)
94                    .map_err(|e| (format!("worktree discovery: {e}"), exit::DATAERR))
95                    .and_then(|l| import_into(&l, opts, true))
96            }),
97    };
98    finish(outcome, opts.json)
99}
100
101#[must_use]
102pub fn run_fetch(opts: &FetchArgs, pull: bool) -> u8 {
103    let outcome = std::env::current_dir()
104        .map_err(|e| (format!("cwd: {e}"), exit::CONFIG_ERROR))
105        .and_then(|cwd| {
106            mkit_core::layout::discover(&cwd)
107                .map_err(|e| (format!("worktree discovery: {e}"), exit::DATAERR))
108                .and_then(|l| fetch_and_maybe_pull(&l, opts, pull))
109        });
110    finish(outcome, opts.json)
111}
112
113fn finish(outcome: CmdResult<Summary>, json: bool) -> u8 {
114    match outcome {
115        Ok(summary) => {
116            summary.print(json);
117            if summary.imported.is_empty() && !summary.skipped.is_empty() {
118                emit_err(
119                    &format!(
120                        "every requested ref was skipped ({} refusals)",
121                        summary.skipped.len()
122                    ),
123                    exit::GENERAL_ERROR,
124                )
125            } else {
126                exit::OK
127            }
128        }
129        Err((msg, code)) => emit_err(&msg, code),
130    }
131}
132
133// ─── the forms ──────────────────────────────────────────────────────
134
135/// `mkit git import <url> <dir>`: init a fresh repo, import, check
136/// out the upstream default branch.
137/// An option-shaped "url" must never reach a git argv (argument
138/// injection: `--upload-pack=...` etc.), and an empty one would make
139/// git operate on whatever directory it happens to be in. Checked
140/// FIRST — before any directory is created or stamped.
141fn validate_url(url: &str) -> CmdResult<()> {
142    if url.trim().is_empty() {
143        return Err(("empty git URL or path".into(), exit::USAGE));
144    }
145    if url.starts_with('-') {
146        return Err((
147            format!("{url:?} is not a valid git URL or path"),
148            exit::USAGE,
149        ));
150    }
151    Ok(())
152}
153
154fn fresh_clone(opts: &ImportArgs, dir: &str) -> CmdResult<Summary> {
155    validate_url(&opts.url)?;
156    let target = PathBuf::from(dir);
157    if target.exists() && std::fs::read_dir(&target).map_or(true, |mut d| d.next().is_some()) {
158        return Err((
159            format!("destination '{dir}' already exists"),
160            exit::CANTCREAT,
161        ));
162    }
163    let created = !target.exists();
164    std::fs::create_dir_all(&target).map_err(|e| (format!("mkdir: {e}"), exit::CANTCREAT))?;
165    let layout = mkit_core::layout::discover(&target)
166        .map_err(|e| (format!("worktree discovery: {e}"), exit::DATAERR))?;
167    ObjectStore::init(&layout).map_err(|e| (format!("init: {e}"), exit::CANTCREAT))?;
168    refs::init(&layout).map_err(|e| (format!("refs init: {e}"), exit::CANTCREAT))?;
169    let mut summary = match import_into(&layout, opts, false) {
170        Ok(s) => s,
171        Err(e) => {
172            // Undo this run's work so a corrected retry is not refused
173            // with "destination already exists" — but only remove the
174            // DIRECTORY itself if this run created it (a pre-existing
175            // empty dir may carry meaning: ownership, mode, mountpoint).
176            if created {
177                let _ = std::fs::remove_dir_all(&target);
178            } else if let Ok(rd) = std::fs::read_dir(&target) {
179                for entry in rd.flatten() {
180                    let p = entry.path();
181                    let _ = if p.is_dir() {
182                        std::fs::remove_dir_all(&p)
183                    } else {
184                        std::fs::remove_file(&p)
185                    };
186                }
187            }
188            return Err(e);
189        }
190    };
191
192    // Check out the upstream default branch.
193    let staging = map::state_dir(&layout, &opts.remote_name)
194        .map_err(|e| (e.to_string(), exit::USAGE))?
195        .join("repo.git");
196    let default = gitsrc::default_branch(&staging)
197        .map_err(|e| (format!("default branch: {e}"), exit::GENERAL_ERROR))?
198        .and_then(|r| r.strip_prefix("refs/heads/").map(str::to_owned));
199    if let Some(branch) = default
200        && let Some(head) = refs::read_remote_ref(&layout, &opts.remote_name, &branch)
201            .map_err(|e| (format!("read tracking ref: {e}"), exit::GENERAL_ERROR))?
202    {
203        checkout_initial(&layout, &branch, &head)?;
204        summary.checked_out = Some(branch);
205    }
206    Ok(summary)
207}
208
209fn checkout_initial(layout: &RepoLayout, branch: &str, head: &Hash) -> CmdResult<()> {
210    let store =
211        ObjectStore::open(layout).map_err(|e| (format!("open store: {e}"), exit::GENERAL_ERROR))?;
212    let tree = match store.read_object(head) {
213        Ok(Object::Commit(c)) => c.tree_hash,
214        Ok(Object::Tag(_) | _) | Err(_) => {
215            return Err(("imported head is not a commit".into(), exit::DATAERR));
216        }
217    };
218    super::write_ref_recording_history(
219        layout,
220        branch,
221        mkit_core::refs::RefWriteCondition::Missing,
222        head,
223    )
224    .map_err(|e| (format!("write branch: {e}"), exit::CANTCREAT))?;
225    refs::write_head_branch(layout, branch)
226        .map_err(|e| (format!("write HEAD: {e}"), exit::CANTCREAT))?;
227    super::restore_worktree_and_index(layout, &store, tree)
228        .map_err(|e| (format!("checkout: {e}"), exit::GENERAL_ERROR))?;
229    Ok(())
230}
231
232/// Import/refresh into an existing repo: tracking refs + tags only.
233fn import_into(layout: &RepoLayout, opts: &ImportArgs, require_repo: bool) -> CmdResult<Summary> {
234    if require_repo {
235        ObjectStore::open(layout)
236            .map_err(|e| (format!("open repository: {e}"), exit::GENERAL_ERROR))?;
237    }
238    super::git::git_version().map_err(|e| (e, exit::UNAVAILABLE))?;
239
240    validate_url(&opts.url)?;
241
242    let state =
243        map::state_dir(layout, &opts.remote_name).map_err(|e| (e.to_string(), exit::USAGE))?;
244    // One bridge operation per state dir at a time: concurrent runs
245    // would race the crash marker / map discard / bulk session.
246    let _state_lock = mkit_core::repo_lock::acquire_default(
247        layout.common_dir(),
248        &format!("git-{}.lock", opts.remote_name),
249    )
250    .map_err(|e| {
251        (
252            format!(
253                "bridge state '{}' is busy (another mkit git operation?): {e}",
254                opts.remote_name
255            ),
256            exit::TEMPFAIL,
257        )
258    })?;
259    // VALIDATE existing bindings before any network/disk work, but
260    // RECORD new ones only after the clone + sha256 check succeed: a
261    // typo'd URL must not permanently burn the state name (there is
262    // no CLI command to unbind it).
263    validate_import_bindings(layout, &state, &opts.url)?;
264    let kp = load_or_create_import_key(layout, opts.key.as_deref())?;
265    if let Some(pinned) =
266        map::read_signer(&state).map_err(|e| (e.to_string(), exit::CONFIG_ERROR))?
267        && pinned != kp.public.0
268    {
269        // Surface the §4 designated-importer refusal before the clone.
270        // Deliberately NOT an unconditional bind_signer call: that
271        // would WRITE a fresh pin pre-clone, violating the
272        // validate-then-bind contract (the pin is recorded with the
273        // other bindings only after the clone succeeds).
274        map::bind_signer(&state, &kp.public.0).map_err(|e| (e.to_string(), exit::CONFIG_ERROR))?;
275    }
276
277    // Staging mirror: clone once, fetch thereafter. Local paths must
278    // be absolutized — the clone runs `git -C <state>`, which would
279    // resolve a relative path against the state dir.
280    let clone_url = absolutize_clone_url(&opts.url);
281    let staging = state.join("repo.git");
282    // The state dir itself is just a directory (bindings come after a
283    // successful clone); `git -C <state>` needs it to exist.
284    std::fs::create_dir_all(&state)
285        .map_err(|e| (format!("create state dir: {e}"), exit::CANTCREAT))?;
286    if staging.join("objects").is_dir() {
287        // Explicit refspecs (not the mirror's +refs/*:refs/*) so
288        // --prune is scoped to upstream namespaces: fork-mode export
289        // state living in this repo (refs/mkit-export/*, the
290        // attestation chain ref) must survive an upstream fetch.
291        super::git::git_in(
292            &staging,
293            &[
294                "fetch",
295                "--quiet",
296                "--prune",
297                "origin",
298                "+refs/heads/*:refs/heads/*",
299                "+refs/tags/*:refs/tags/*",
300            ],
301        )
302        .map_err(|e| (format!("fetch upstream: {e}"), exit::UNAVAILABLE))?;
303    } else {
304        super::git::git_in(
305            state.as_path(),
306            &["clone", "--mirror", "--quiet", &clone_url, "repo.git"],
307        )
308        .map_err(|e| (format!("clone upstream: {e}"), exit::UNAVAILABLE))?;
309    }
310    if gitsrc::is_sha256_repo(&staging).map_err(|e| (e.to_string(), exit::GENERAL_ERROR))? {
311        return Err((
312            "SHA-256 repositories are out of scope for git-import v1 (SPEC-GIT-IMPORT §2)".into(),
313            exit::DATAERR,
314        ));
315    }
316    // Clone validated — NOW record the bindings.
317    bind_import_state(layout, &state, &opts.url)?;
318    map::bind_signer(&state, &kp.public.0).map_err(|e| (e.to_string(), exit::CONFIG_ERROR))?;
319    translate_upstream(layout, &state, &staging, opts, &kp)
320}
321
322/// Read-only twin of [`bind_import_state`]: refuse direction/source
323/// mismatches without writing anything.
324fn validate_import_bindings(layout: &RepoLayout, state: &Path, url: &str) -> CmdResult<()> {
325    match map::read_direction(state).map_err(|e| (e.to_string(), exit::GENERAL_ERROR))? {
326        None | Some(Direction::Import | Direction::Fork) => {}
327        Some(other) => {
328            return Err((
329                format!(
330                    "state dir is bound to direction '{}' (one direction per state dir)",
331                    other.as_str()
332                ),
333                exit::USAGE,
334            ));
335        }
336    }
337    let identity = remote_identity(url);
338    match std::fs::read_to_string(state.join("source")) {
339        Ok(recorded) if recorded.trim() != identity => Err((
340            format!(
341                "state '{}' is bound to {}; use a different --remote-name for {url}",
342                state.file_name().unwrap_or_default().to_string_lossy(),
343                recorded.trim(),
344            ),
345            exit::USAGE,
346        )),
347        Ok(_) => Ok(()),
348        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
349            if let Some(other) = other_state_with_source(layout, state, &identity) {
350                return Err((
351                    format!(
352                        "{url} is already imported as state '{other}'; use \
353                         `--remote-name {other}` instead of creating a duplicate \
354                         import (SPEC-GIT-IMPORT §6.1)"
355                    ),
356                    exit::USAGE,
357                ));
358            }
359            Ok(())
360        }
361        Err(e) => Err((format!("read source binding: {e}"), exit::GENERAL_ERROR)),
362    }
363}
364
365/// `mkit git fetch` / `pull`.
366fn fetch_and_maybe_pull(layout: &RepoLayout, opts: &FetchArgs, pull: bool) -> CmdResult<Summary> {
367    let import_opts = ImportArgs {
368        url: String::new(), // resolved from the recorded source below
369        dir: None,
370        remote_name: opts.remote_name.clone(),
371        key: opts.key.clone(),
372        json: opts.json,
373    };
374    let state =
375        map::state_dir(layout, &opts.remote_name).map_err(|e| (e.to_string(), exit::USAGE))?;
376    if read_source(&state)?.is_none() {
377        return Err((
378            format!(
379                "no import state for '{}' — run `mkit git import <url>` first",
380                opts.remote_name
381            ),
382            exit::CONFIG_ERROR,
383        ));
384    }
385    // Re-fetch through the staging mirror's own recorded origin (the
386    // canonical identity strips `.git`, so it is NOT a fetch URL).
387    let origin = super::git::git_in(
388        &state.join("repo.git"),
389        &["config", "--get", "remote.origin.url"],
390    )
391    .map(|s| s.trim().to_owned())
392    .map_err(|e| (format!("staging origin: {e}"), exit::GENERAL_ERROR))?;
393    let import_opts = ImportArgs {
394        url: origin,
395        ..import_opts
396    };
397    let mut summary = import_into(layout, &import_opts, true)?;
398
399    if pull {
400        summary.pulled = fast_forward_current(layout, &opts.remote_name)?;
401    }
402    Ok(summary)
403}
404
405/// FF the current branch from its tracking ref (native machinery).
406fn fast_forward_current(layout: &RepoLayout, remote: &str) -> CmdResult<Option<String>> {
407    let store =
408        ObjectStore::open(layout).map_err(|e| (format!("open store: {e}"), exit::GENERAL_ERROR))?;
409    let Ok(refs::Head::Branch(branch)) = refs::read_head(layout) else {
410        return Ok(None); // detached/unborn: fetch-only semantics
411    };
412    let Some(target) = refs::read_remote_ref(layout, remote, &branch)
413        .map_err(|e| (format!("read tracking ref: {e}"), exit::GENERAL_ERROR))?
414    else {
415        return Ok(None);
416    };
417    let Some(current) = refs::read_ref(layout, &branch)
418        .map_err(|e| (format!("read branch: {e}"), exit::GENERAL_ERROR))?
419    else {
420        return Ok(None);
421    };
422    if current == target {
423        return Ok(None);
424    }
425    let ancestor = mkit_core::ops::merge::is_ancestor(&store, current, target)
426        .map_err(|e| (format!("ancestry: {e}"), exit::GENERAL_ERROR))?;
427    if !ancestor {
428        return Err((
429            format!(
430                "pull would not fast-forward branch '{branch}'; integrate with \
431                 `mkit merge {remote}/{branch}` (or `mkit rebase {remote}/{branch}`)"
432            ),
433            exit::GENERAL_ERROR,
434        ));
435    }
436    let tree = match store.read_object(&target) {
437        Ok(Object::Commit(c)) => c.tree_hash,
438        _ => return Err(("tracking ref is not a commit".into(), exit::DATAERR)),
439    };
440    // Same discipline as native pull (remote_dispatch::pull_all): the
441    // worktree lock spans safety check → ref write → restore, so a
442    // concurrent commit/checkout cannot interleave after the safety
443    // check; a failed restore rolls the branch ref back instead of
444    // leaving it advanced over a stale worktree.
445    let _wt_lock = super::acquire_worktree_lock(layout)
446        .map_err(|code| ("worktree is busy (another mkit command?)".to_owned(), code))?;
447    super::ensure_restore_safe(layout, &store, tree).map_err(|e| (e, exit::GENERAL_ERROR))?;
448    super::write_ref_recording_history(
449        layout,
450        &branch,
451        mkit_core::refs::RefWriteCondition::Match(current),
452        &target,
453    )
454    .map_err(|e| (format!("advance branch: {e}"), exit::CANTCREAT))?;
455    if let Err(e) = super::restore_worktree_and_index(layout, &store, tree) {
456        let rollback = super::write_ref_recording_history(
457            layout,
458            &branch,
459            mkit_core::refs::RefWriteCondition::Match(target),
460            &current,
461        );
462        let extra = match rollback {
463            Ok(()) => String::new(),
464            Err(rb) => format!("; additionally failed to roll back the branch ref: {rb}"),
465        };
466        return Err((format!("{e}{extra}"), exit::GENERAL_ERROR));
467    }
468    Ok(Some(branch))
469}
470
471// ─── translation core wiring ────────────────────────────────────────
472
473struct Summary {
474    imported: Vec<(String, Sha1Id, Hash)>,
475    skipped: Vec<(String, String)>,
476    normalized: bool,
477    checked_out: Option<String>,
478    pulled: Option<String>,
479}
480
481impl Summary {
482    fn print(&self, json: bool) {
483        if json {
484            // ok mirrors the exit code: an all-skipped run exits
485            // non-zero and must not claim success on stdout.
486            let ok = !self.imported.is_empty() || self.skipped.is_empty();
487            let mut out = format!("{{\"ok\":{ok},\"imported\":[");
488            for (i, (r, s1, b3)) in self.imported.iter().enumerate() {
489                if i > 0 {
490                    out.push(',');
491                }
492                let _ = write!(
493                    out,
494                    "{{\"ref\":\"{}\",\"git\":\"{}\",\"mkit\":\"{}\"}}",
495                    format::json_escape(r),
496                    sha1_hex(s1),
497                    mkit_core::to_hex(b3)
498                );
499            }
500            out.push_str("],\"skipped\":[");
501            for (i, (r, why)) in self.skipped.iter().enumerate() {
502                if i > 0 {
503                    out.push(',');
504                }
505                let _ = write!(
506                    out,
507                    "{{\"ref\":\"{}\",\"reason\":\"{}\"}}",
508                    format::json_escape(r),
509                    format::json_escape(why)
510                );
511            }
512            out.push(']');
513            if let Some(b) = &self.checked_out {
514                let _ = write!(out, ",\"checkedOut\":\"{}\"", format::json_escape(b));
515            }
516            if let Some(b) = &self.pulled {
517                let _ = write!(out, ",\"fastForwarded\":\"{}\"", format::json_escape(b));
518            }
519            out.push('}');
520            println!("{out}");
521            return;
522        }
523        for (r, s1, b3) in &self.imported {
524            println!(
525                "imported {r} {} -> {}",
526                &sha1_hex(s1)[..8],
527                &mkit_core::to_hex(b3)[..8]
528            );
529        }
530        if self.normalized {
531            eprintln!(
532                "warning: historic tree modes were normalized (declared-lossy; \
533                 originals retained in the staging mirror)"
534            );
535        }
536        if let Some(b) = &self.checked_out {
537            eprintln!("checked out '{b}'");
538        }
539        if let Some(b) = &self.pulled {
540            eprintln!("fast-forwarded '{b}'");
541        }
542    }
543}
544
545/// Bulk sink: deferred-fsync writes + the kind probe the tag path
546/// needs (review finding: without `kind_of`, chunked tag targets
547/// misclassify and fork hashes between sink choices).
548struct BulkSink<'a> {
549    bw: BulkWriter<'a>,
550    store: &'a ObjectStore,
551}
552
553impl ObjectSink for BulkSink<'_> {
554    fn write_object(&mut self, bytes: &[u8]) -> Result<Hash, BridgeError> {
555        self.bw
556            .write(bytes)
557            .map_err(|e| BridgeError::Source(format!("bulk write: {e}")))
558    }
559
560    fn kind_of(&self, h: &Hash) -> Option<ObjectType> {
561        self.store.read_object(h).ok().map(|o| o.object_type())
562    }
563}
564
565#[allow(clippy::too_many_lines)] // linear pipeline; stages are commented
566fn translate_upstream(
567    layout: &RepoLayout,
568    state: &Path,
569    staging: &Path,
570    opts: &ImportArgs,
571    kp: &KeyPair,
572) -> CmdResult<Summary> {
573    let store =
574        ObjectStore::open(layout).map_err(|e| (format!("open store: {e}"), exit::GENERAL_ERROR))?;
575
576    // Crash marker: a leftover marker means torn objects may exist
577    // that the (durably-fsynced) map still vouches for — discard the
578    // map and re-translate EVERY ref from scratch (per-key determinism
579    // reproduces the exact same hashes, SPEC-GIT-IMPORT §1.2).
580    // refs-import is KEPT: its hashes are reproducible (it cannot
581    // vouch for torn objects) and it carries memory the surrounding
582    // logic needs — tag ownership for the clobber guard and the prune
583    // baseline for upstream deletions. The recovery pass instead
584    // bypasses its unchanged-ref short-circuit and rev-list
585    // exclusions below, so the map is fully rebuilt.
586    let marker = state.join(IMPORTING_MARKER);
587    let mut recovering = marker.exists();
588    if recovering {
589        let _ = std::fs::remove_file(state.join("map"));
590        eprintln!("note: previous import was interrupted; rebuilding the map cache");
591    }
592
593    let mut sha_map = map::load_map_inverse(state)
594        .map_err(|e| (format!("load map: {e}"), exit::GENERAL_ERROR))?;
595    let prior_state = map::load_import_ref_state(state)
596        .map_err(|e| (format!("load ref state: {e}"), exit::GENERAL_ERROR))?;
597    // The map is a DISPOSABLE cache (§12.3): refs recorded with a
598    // missing OR partially-corrupt map behind them (no crash marker)
599    // must trigger the full rebuild — surviving lines of a corrupt
600    // file are not evidence the rest exists, and the unchanged-ref
601    // short-circuit would otherwise leave holes a later passthrough
602    // export turns into re-translated history.
603    let map_intact = map::map_is_intact(state).map_err(|e| (e.to_string(), exit::GENERAL_ERROR))?;
604    // Tip-presence check: every recorded ref tip is appended to the
605    // map BEFORE the ref state persists, so a recorded tip with no
606    // map entry means the map tail was lost (truncation at a clean
607    // line boundary parses as intact).
608    let tips_mapped = prior_state
609        .iter()
610        .all(|st| sha_map.contains_key(&st.git_id));
611    if !recovering
612        && (!map_intact || !tips_mapped || (sha_map.is_empty() && !prior_state.is_empty()))
613    {
614        recovering = true;
615        let _ = std::fs::remove_file(state.join("map"));
616        sha_map.clear();
617        eprintln!("note: map cache missing or corrupt; rebuilding from the staging mirror");
618    }
619
620    let upstream_refs =
621        gitsrc::list_refs(staging).map_err(|e| (e.to_string(), exit::GENERAL_ERROR))?;
622
623    // In-store divergence probe (SPEC-GIT-IMPORT §6.1, content side):
624    // bounded walk-back digests vs existing commits under other keys.
625    // Only on FIRST contact (empty map = fresh import or post-crash
626    // rebuild): afterwards the pinned key + bound source already
627    // guarantee consistency, and the probe scans the whole store —
628    // too heavy for every routine fetch.
629    if sha_map.is_empty() {
630        divergence_probe(&store, staging, &upstream_refs, &kp.public.0)?;
631    }
632
633    // Written only now, after the read-only probe: the marker brackets
634    // exactly the window where torn objects can exist (store writes
635    // until the map/ref-state commit below).
636    write_durable(&marker, b"").map_err(|e| (format!("marker: {e}"), exit::CANTCREAT))?;
637
638    let direction = map::read_direction(state)
639        .map_err(|e| (e.to_string(), exit::GENERAL_ERROR))?
640        .unwrap_or(Direction::Import);
641    let fork_mode = direction == Direction::Fork;
642
643    let mut batch = CatFileBatch::open(staging).map_err(|e| (e.to_string(), exit::UNAVAILABLE))?;
644    let mut sink = BulkSink {
645        bw: store.bulk_writer(),
646        store: &store,
647    };
648    let raw_dir = state.join("raw");
649    let mut raw_dirs: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
650    let mut retain = |id: &Sha1Id, raw: &[u8]| -> Result<(), BridgeError> {
651        let hex = sha1_hex(id);
652        let dir = raw_dir.join(&hex[..2]);
653        std::fs::create_dir_all(&dir)?;
654        let path = dir.join(&hex[2..]);
655        // Temp + content-fsync + rename: a torn final file would be
656        // permanent (the exists() short-circuit is what makes re-runs
657        // cheap, so the final path must never hold partial bytes).
658        if !path.exists() {
659            let tmp = dir.join(format!(".{}.tmp", &hex[2..]));
660            {
661                use std::io::Write as _;
662                let mut f = std::fs::File::create(&tmp)?;
663                f.write_all(raw)?;
664                f.sync_all()?;
665            }
666            std::fs::rename(&tmp, &path)?;
667        }
668        // Collect the dir even when the file already existed: a
669        // previous crashed run may have renamed it without ever
670        // reaching the batch dir-fsync.
671        raw_dirs.insert(dir);
672        Ok(())
673    };
674    let public = kp.public.0;
675    let mut sc = |c: &mkit_core::object::Commit| {
676        Ok(sign_commit(c, kp)
677            .map_err(|e| BridgeError::Source(e.to_string()))?
678            .0)
679    };
680    let mut st = |t: &mkit_core::object::Tag| {
681        Ok(sign_tag(t, kp)
682            .map_err(|e| BridgeError::Source(e.to_string()))?
683            .0)
684    };
685
686    let prior_by_ref: HashMap<&str, &map::RefState> = prior_state
687        .iter()
688        .map(|s| (s.ref_name.as_str(), s))
689        .collect();
690    // Exclusions must still exist in the mirror: after an upstream
691    // force-push plus gc, a pruned old tip would make `rev-list ^tip`
692    // abort every future fetch ("fatal: bad object"). A recovery pass
693    // excludes nothing — the discarded map must be rebuilt over the
694    // FULL history (and the empty exclusion set keeps rev-list's
695    // parents-first order covering every commit, recursion depth 1).
696    let exclude: Vec<Sha1Id> = if recovering {
697        Vec::new()
698    } else {
699        prior_state
700            .iter()
701            .map(|s| s.git_id)
702            .filter(|id| gitsrc::object_exists(staging, id).unwrap_or(false))
703            .collect()
704    };
705
706    let mut imported: Vec<(String, Sha1Id, Hash)> = Vec::new();
707    let mut skipped: Vec<(String, String)> = Vec::new();
708    let mut all_pairs: Vec<(Sha1Id, Hash)> = Vec::new();
709    let mut normalized = false;
710
711    for uref in &upstream_refs {
712        // Ref-name legality on the mkit side (grammar + tags).
713        let mkit_legal = if let Some(b) = uref.name.strip_prefix("refs/heads/") {
714            refs::validate_ref_name(b)
715        } else if let Some(t) = uref.name.strip_prefix("refs/tags/") {
716            refs::validate_ref_name(t)
717        } else {
718            false
719        };
720        if !mkit_legal {
721            let why = format!("ref name {:?} is outside the mkit ref grammar", uref.name);
722            eprintln!("warning: skipping {}: {why}", uref.name);
723            skipped.push((uref.name.clone(), why));
724            continue;
725        }
726        // Unchanged since last import? (Never during recovery: the
727        // recorded tip is fine but the discarded map must be rebuilt
728        // by actually re-translating the ref's closure.)
729        if !recovering
730            && let Some(prev) = prior_by_ref.get(uref.name.as_str())
731            && prev.git_id == uref.id
732        {
733            imported.push((uref.name.clone(), uref.id, prev.mkit_hash));
734            continue;
735        }
736        // Translate: commits in topo order (no deep recursion), then
737        // the tip object itself (tag objects ride on top).
738        let commit_tip = uref.peeled.unwrap_or(uref.id);
739        let order = gitsrc::rev_list(staging, &[commit_tip], &exclude)
740            .map_err(|e| (e.to_string(), exit::GENERAL_ERROR))?;
741        let mut imp = Importer {
742            source: &mut batch,
743            sink: &mut sink,
744            signer: ImportSigner {
745                public,
746                sign_commit: &mut sc,
747                sign_tag: &mut st,
748            },
749            map: &mut sha_map,
750            retain_raw: &mut retain,
751            options: ImportOptions { fork_mode },
752            depth_memo: DepthMemo::default(),
753        };
754        // Pairs are caller-owned and persisted EVEN when the ref
755        // refuses: the sink already wrote those objects, and a later
756        // ref sharing the history memo-hits without re-emitting them.
757        let mut ref_pairs: Vec<(Sha1Id, Hash)> = Vec::new();
758        let result = imp.import_commits(&order, &uref.id, &mut ref_pairs, &mut normalized);
759        all_pairs.extend_from_slice(&ref_pairs);
760        match result {
761            Ok(head) => {
762                imported.push((uref.name.clone(), uref.id, head));
763            }
764            Err(BridgeError::Refused(r)) => {
765                eprintln!("warning: skipping {}: {r}", uref.name);
766                skipped.push((uref.name.clone(), r.to_string()));
767            }
768            Err(e) => return Err((format!("import {}: {e}", uref.name), exit::GENERAL_ERROR)),
769        }
770    }
771    drop(batch);
772
773    // Durability order: objects (dir fsync) → map (file fsync) →
774    // tracking refs → marker removal.
775    sink.bw
776        .commit()
777        .map_err(|e| (format!("commit bulk writes: {e}"), exit::CANTCREAT))?;
778    for dir in &raw_dirs {
779        if let Ok(d) = std::fs::File::open(dir) {
780            let _ = d.sync_all();
781        }
782    }
783    map::append_map_import(state, &all_pairs)
784        .map_err(|e| (format!("persist map: {e}"), exit::GENERAL_ERROR))?;
785
786    let mut new_state: Vec<map::RefState> = Vec::new();
787    for (name, git_id, mkit_hash) in &imported {
788        if let Some(branch) = name.strip_prefix("refs/heads/") {
789            // Force-push detection: tracking refs move with a warning
790            // when the old tip is no longer an ancestor of the new.
791            if let Some(prev) = prior_by_ref.get(name.as_str())
792                && prev.git_id != *git_id
793                && !gitsrc::is_ancestor(staging, &prev.git_id, git_id).unwrap_or(true)
794            {
795                eprintln!(
796                    "warning: upstream force-pushed {name}; tracking ref rewound \
797                     (rebase local branches that built on the old history)"
798                );
799            }
800            refs::write_remote_ref(layout, &opts.remote_name, branch, mkit_hash)
801                .map_err(|e| (format!("tracking ref {name}: {e}"), exit::CANTCREAT))?;
802        } else if let Some(tag) = name.strip_prefix("refs/tags/") {
803            // Never clobber a locally-moved tag: only write when the
804            // tag is absent or still where THIS import last put it
805            // (mirrors git fetch's would-clobber refusal).
806            let existing = refs::read_tag(layout, tag)
807                .map_err(|e| (format!("tag ref {name}: {e}"), exit::GENERAL_ERROR))?;
808            let ours_before = prior_by_ref.get(name.as_str()).map(|p| p.mkit_hash);
809            match existing {
810                Some(cur) if cur != *mkit_hash && Some(cur) != ours_before => {
811                    eprintln!(
812                        "warning: not updating tag '{tag}': it was moved locally \
813                         (delete it with `mkit tag -d {tag}` to track the upstream tag)"
814                    );
815                }
816                Some(cur) if cur == *mkit_hash => {}
817                _ => {
818                    refs::update_tag(
819                        layout,
820                        tag,
821                        mkit_core::refs::RefWriteCondition::Any,
822                        mkit_hash,
823                    )
824                    .map_err(|e| (format!("tag ref {name}: {e}"), exit::CANTCREAT))?;
825                }
826            }
827        }
828        new_state.push(map::RefState {
829            ref_name: name.clone(),
830            mkit_hash: *mkit_hash,
831            git_id: *git_id,
832        });
833    }
834    // Upstream deletions propagate to the tracking refs (like
835    // `git fetch --prune` for refs/remotes); local TAGS are kept,
836    // matching git's default (no --prune-tags).
837    let current: std::collections::HashSet<&str> =
838        upstream_refs.iter().map(|u| u.name.as_str()).collect();
839    for prev in &prior_state {
840        if let Some(branch) = prev.ref_name.strip_prefix("refs/heads/")
841            && !current.contains(prev.ref_name.as_str())
842        {
843            match refs::delete_remote_ref(layout, &opts.remote_name, branch) {
844                Ok(()) => eprintln!(
845                    "warning: upstream deleted {}; tracking ref {}/{branch} removed",
846                    prev.ref_name, opts.remote_name
847                ),
848                Err(mkit_core::refs::RefError::NotFound(_)) => {}
849                Err(e) => {
850                    return Err((format!("prune tracking ref {branch}: {e}"), exit::CANTCREAT));
851                }
852            }
853        }
854    }
855
856    if normalized {
857        // Sticky, and recorded BEFORE the marker can be removed: a
858        // crash here re-runs the (idempotent) stamp; losing it would
859        // permanently unblock a fork upgrade §3.3 forbids.
860        map::mark_normalized(state).map_err(|e| (e.to_string(), exit::CANTCREAT))?;
861    }
862
863    // Attestations BEFORE the ref-state persist: minting is
864    // idempotent (content-addressed envelopes), but the
865    // "claim already exists" skip keys on recorded-state equality —
866    // a crash between persist and mint would skip those heads
867    // forever on re-run.
868    mint_attestations(
869        layout,
870        &opts.url,
871        &opts.remote_name,
872        &imported,
873        &prior_by_ref,
874        kp,
875    )?;
876
877    map::store_import_ref_state(state, &new_state)
878        .map_err(|e| (format!("persist ref state: {e}"), exit::GENERAL_ERROR))?;
879    std::fs::remove_file(&marker).map_err(|e| (format!("marker: {e}"), exit::GENERAL_ERROR))?;
880
881    Ok(Summary {
882        imported,
883        skipped,
884        normalized,
885        checked_out: None,
886        pulled: None,
887    })
888}
889
890/// `std::fs::write` + content fsync + parent-dir fsync — for tiny
891/// state files whose EXISTENCE is the signal (the crash marker).
892fn write_durable(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
893    {
894        use std::io::Write as _;
895        let mut f = std::fs::File::create(path)?;
896        f.write_all(bytes)?;
897        f.sync_all()?;
898    }
899    if let Some(parent) = path.parent()
900        && let Ok(d) = std::fs::File::open(parent)
901    {
902        let _ = d.sync_all();
903    }
904    Ok(())
905}
906
907/// SPEC-GIT-IMPORT §5: subject = mkit head, predicate carries the git
908/// locator + canonical remote identity, signed with the import key.
909fn mint_attestations(
910    layout: &RepoLayout,
911    url: &str,
912    remote_name: &str,
913    imported: &[(String, Sha1Id, Hash)],
914    prior: &HashMap<&str, &map::RefState>,
915    kp: &KeyPair,
916) -> CmdResult<()> {
917    let remote_url = remote_identity(url);
918    let obj_store = mkit_core::store::ObjectStore::open(layout)
919        .map_err(|e| (format!("not a mkit repo: {e}"), exit::GENERAL_ERROR))?;
920    for (name, git_id, mkit_hash) in imported {
921        if let Some(prev) = prior.get(name.as_str())
922            && prev.mkit_hash == *mkit_hash
923        {
924            continue; // unchanged head: claim already exists
925        }
926        // §5: subject/refName carry the FULL MKIT ref — imported
927        // branches live under refs/remotes/<name>/, not refs/heads/.
928        let mkit_ref = name.strip_prefix("refs/heads/").map_or_else(
929            || name.clone(),
930            |branch| format!("refs/remotes/{remote_name}/{branch}"),
931        );
932        let predicate = format!(
933            "{{\"gitCommit\":\"{}\",\"refName\":\"{}\",\"remoteUrl\":\"{}\",\"schemaVersion\":1,\"specVersion\":1}}",
934            sha1_hex(git_id),
935            format::json_escape(&mkit_ref),
936            format::json_escape(&remote_url)
937        );
938        let head_bytes = super::read_object_bytes(&obj_store, mkit_hash)?;
939        let stmt = statement::encode(&statement::Statement {
940            subjects: vec![statement::Subject {
941                name: Some(mkit_ref),
942                digest_blake3_hex: mkit_core::to_hex(mkit_hash),
943                digest_sha256_hex: statement::sha256_hex(&head_bytes),
944            }],
945            predicate_type: PREDICATE_TYPE.to_owned(),
946            predicate_jcs: predicate.as_bytes(),
947        })
948        .map_err(|e| (format!("encode statement: {e}"), exit::GENERAL_ERROR))?;
949        let pae = mkit_attest::pae_of(PAYLOAD_TYPE_IN_TOTO, stmt.as_bytes());
950        let mut signer = mkit_attest::RepoKeySigner::new(KeyPair {
951            public: kp.public,
952            secret: mkit_core::sign::SecretSeed(kp.secret.0),
953        });
954        let sig = signer
955            .sign(&pae)
956            .map_err(|e| (format!("sign attestation: {e}"), exit::GENERAL_ERROR))?;
957        let keyid = signer
958            .keyid()
959            .map_err(|e| (format!("attestation keyid: {e}"), exit::GENERAL_ERROR))?;
960        let envelope = Envelope {
961            payload_type: PAYLOAD_TYPE_IN_TOTO.to_owned(),
962            payload: stmt.into_bytes(),
963            signatures: vec![Sig { keyid, sig }],
964        };
965        let encoded = envelope
966            .encode()
967            .map_err(|e| (format!("encode envelope: {e}"), exit::GENERAL_ERROR))?;
968        attest_store::save(layout, mkit_hash, encoded.as_bytes())
969            .map_err(|e| (format!("save attestation: {e}"), exit::CANTCREAT))?;
970    }
971    Ok(())
972}
973
974// ─── state, keys, probes ────────────────────────────────────────────
975
976/// Bind direction=import (or accept fork) + record the canonical
977/// source identity; refuse a different source for this state name.
978fn bind_import_state(layout: &RepoLayout, state: &Path, url: &str) -> CmdResult<()> {
979    map::bind_direction(state, Direction::Import)
980        .or_else(|_| {
981            // fork is the allowed superset (import + passthrough).
982            match map::read_direction(state) {
983                Ok(Some(Direction::Fork)) => Ok(()),
984                _ => Err(BridgeError::Source(
985                    "state dir direction conflict (one direction per state dir)".into(),
986                )),
987            }
988        })
989        .map_err(|e| (e.to_string(), exit::USAGE))?;
990    let identity = remote_identity(url);
991    let src_file = state.join("source");
992    match std::fs::read_to_string(&src_file) {
993        Ok(recorded) if recorded.trim() != identity => Err((
994            format!(
995                "state '{}' is bound to {}; use a different --remote-name for {}",
996                state.file_name().unwrap_or_default().to_string_lossy(),
997                recorded.trim(),
998                url
999            ),
1000            exit::USAGE,
1001        )),
1002        Ok(_) => Ok(()),
1003        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1004            // SPEC-GIT-IMPORT §6.1 check 1: one upstream, one state
1005            // dir. A second state for the same canonical source would
1006            // duplicate the whole history under (possibly) another
1007            // key — almost always a mistake.
1008            if let Some(other) = other_state_with_source(layout, state, &identity) {
1009                return Err((
1010                    format!(
1011                        "{url} is already imported as state '{other}'; use \
1012                         `--remote-name {other}` instead of creating a duplicate \
1013                         import (SPEC-GIT-IMPORT §6.1)"
1014                    ),
1015                    exit::USAGE,
1016                ));
1017            }
1018            map::write_binding(state, "source", &identity)
1019                .map_err(|e| (format!("record source: {e}"), exit::CANTCREAT))
1020        }
1021        Err(e) => Err((format!("read source binding: {e}"), exit::GENERAL_ERROR)),
1022    }?;
1023    map::bind_import_spec(state, IMPORT_SPEC_VERSION).map_err(|e| (e.to_string(), exit::USAGE))
1024}
1025
1026/// Absolutize a local-path clone URL verbatim (no `.git` stripping —
1027/// that is identity normalization, not path resolution).
1028fn absolutize_clone_url(url: &str) -> String {
1029    let looks_like_url = url.contains("://")
1030        || url
1031            .split('/')
1032            .next()
1033            .is_some_and(|first| first.contains(':'));
1034    if looks_like_url {
1035        return url.to_owned();
1036    }
1037    let p = Path::new(url);
1038    p.canonicalize()
1039        .map_or_else(|_| url.to_owned(), |c| c.to_string_lossy().into_owned())
1040}
1041
1042fn read_source(state: &Path) -> CmdResult<Option<String>> {
1043    match std::fs::read_to_string(state.join("source")) {
1044        Ok(s) => Ok(Some(s.trim().to_owned())),
1045        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
1046        Err(e) => Err((format!("read source: {e}"), exit::GENERAL_ERROR)),
1047    }
1048}
1049
1050/// The name of another state dir already bound to `identity`, if any.
1051fn other_state_with_source(
1052    layout: &RepoLayout,
1053    this_state: &Path,
1054    identity: &str,
1055) -> Option<String> {
1056    for entry in std::fs::read_dir(layout.git_state_dir()).ok()?.flatten() {
1057        if entry.path() == this_state {
1058            continue;
1059        }
1060        if let Ok(src) = std::fs::read_to_string(entry.path().join("source"))
1061            && src.trim() == identity
1062        {
1063            return Some(entry.file_name().to_string_lossy().into_owned());
1064        }
1065    }
1066    None
1067}
1068
1069/// SPEC-GIT-IMPORT §4: a dedicated import key by default, generated
1070/// on first use with a loud notice.
1071fn load_or_create_import_key(layout: &RepoLayout, flag: Option<&str>) -> CmdResult<KeyPair> {
1072    let path = flag.map_or_else(|| layout.common_dir().join(IMPORT_KEY_FILE), PathBuf::from);
1073    match mkit_core::sign::load_key(&path) {
1074        Ok(kp) => {
1075            // §4: say which key signs this import (operators juggling
1076            // several imports need to see a key mixup immediately).
1077            eprintln!(
1078                "note: signing imported history with key {}… ({})",
1079                &mkit_core::to_hex(&{
1080                    let mut h = [0u8; 32];
1081                    h.copy_from_slice(&kp.public.0);
1082                    h
1083                })[..16],
1084                path.display()
1085            );
1086            Ok(kp)
1087        }
1088        // Generate ONLY when the file is truly absent: any other load
1089        // failure (symlink, truncation, transient IO) must surface —
1090        // overwriting an existing-but-unreadable key would destroy
1091        // the seed the pinned state depends on, irreversibly.
1092        Err(_) if flag.is_none() && !path.exists() => {
1093            // The default key file is shared by every remote-name, but
1094            // the bridge locks are per-state — two concurrent
1095            // first-time imports would each generate a key and the
1096            // rename-replacing save would orphan one pinned signer
1097            // forever. Serialize generation and re-check under the
1098            // lock.
1099            let _key_lock =
1100                mkit_core::repo_lock::acquire_default(layout.common_dir(), "git-import-key.lock")
1101                    .map_err(|e| (format!("import key generation busy: {e}"), exit::TEMPFAIL))?;
1102            if let Ok(kp) = mkit_core::sign::load_key(&path) {
1103                return Ok(kp);
1104            }
1105            let kp = KeyPair::generate()
1106                .map_err(|e| (format!("generate import key: {e}"), exit::GENERAL_ERROR))?;
1107            if let Some(parent) = path.parent() {
1108                std::fs::create_dir_all(parent)
1109                    .map_err(|e| (format!("mkdir keys: {e}"), exit::CANTCREAT))?;
1110            }
1111            mkit_core::sign::save_key(&path, &kp)
1112                .map_err(|e| (format!("save import key: {e}"), exit::CANTCREAT))?;
1113            eprintln!(
1114                "note: generated a DEDICATED import key at {} — collaborative \
1115                 tracking of one upstream requires sharing this key (org/bot \
1116                 key); a different key produces an unrelated fork \
1117                 (SPEC-GIT-IMPORT §4)",
1118                path.display()
1119            );
1120            Ok(kp)
1121        }
1122        Err(e) => Err((
1123            format!("load import key {}: {e}", path.display()),
1124            exit::NOINPUT,
1125        )),
1126    }
1127}
1128
1129/// SPEC-GIT-IMPORT §6.1 content probe: walk back from each upstream
1130/// head (bounded) and refuse when a commit with the same framed-bytes
1131/// digest exists locally under a DIFFERENT signer key.
1132fn divergence_probe(
1133    store: &ObjectStore,
1134    staging: &Path,
1135    upstream_refs: &[gitsrc::UpstreamRef],
1136    our_key: &[u8; 32],
1137) -> CmdResult<()> {
1138    // Collect candidate digests: heads + up to 32 first-parents back.
1139    let mut batch = CatFileBatch::open(staging).map_err(|e| (e.to_string(), exit::UNAVAILABLE))?;
1140    let mut digests: HashMap<Hash, Sha1Id> = HashMap::new();
1141    for uref in upstream_refs {
1142        let mut cur = uref.peeled.unwrap_or(uref.id);
1143        for _ in 0..32 {
1144            let Ok((kind, body)) = batch.read(&cur) else {
1145                break;
1146            };
1147            if kind != gitsrc::GitObjKind::Commit {
1148                break;
1149            }
1150            let mut framed = format!("commit {}\0", body.len()).into_bytes();
1151            framed.extend_from_slice(&body);
1152            digests.insert(mkit_core::hash::hash(&framed), cur);
1153            let Ok(parsed) = mkit_git_bridge::gitparse::parse_commit(&body) else {
1154                break;
1155            };
1156            match parsed.parents.first() {
1157                Some(p) => cur = *p,
1158                None => break,
1159            }
1160        }
1161    }
1162    if digests.is_empty() {
1163        return Ok(());
1164    }
1165    // Linear scan (imports are rare interactive operations).
1166    let hashes = store
1167        .iter_object_hashes()
1168        .map_err(|e| (format!("scan store: {e}"), exit::GENERAL_ERROR))?;
1169    for h in hashes {
1170        let Ok(Object::Commit(c)) = store.read_object(&h) else {
1171            continue;
1172        };
1173        if digests.contains_key(&c.content_digest) && c.signer != *our_key {
1174            return Err((
1175                format!(
1176                    "this upstream is already imported here under key {}…; pull from \
1177                     the designated importer over mkit transport, or install that key \
1178                     (SPEC-GIT-IMPORT §4/§6.1)",
1179                    &bytes_hex(&c.signer)[..16]
1180                ),
1181                exit::CONFIG_ERROR,
1182            ));
1183        }
1184    }
1185    Ok(())
1186}
1187
1188use super::error as emit_err;