1use 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
38const PREDICATE_TYPE: &str =
40 "https://github.com/officialunofficial/mkit/spec/predicate/git-import/v1";
41
42const IMPORT_KEY_FILE: &str = "keys/git-import.key";
44
45const IMPORTING_MARKER: &str = "importing";
49
50#[derive(Debug, Parser)]
51pub struct ImportArgs {
52 pub url: String,
54 pub dir: Option<String>,
57 #[arg(long = "remote-name", value_name = "NAME", default_value = "upstream")]
59 pub remote_name: String,
60 #[arg(long = "key", value_name = "PATH")]
63 pub key: Option<String>,
64 #[arg(long)]
66 pub json: bool,
67}
68
69#[derive(Debug, Parser)]
70pub struct FetchArgs {
71 #[arg(long = "remote-name", value_name = "NAME", default_value = "upstream")]
73 pub remote_name: String,
74 #[arg(long = "key", value_name = "PATH")]
76 pub key: Option<String>,
77 #[arg(long)]
79 pub json: bool,
80}
81
82type CmdResult<T> = Result<T, (String, u8)>;
83
84#[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
133fn 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 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 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
232fn 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 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_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 map::bind_signer(&state, &kp.public.0).map_err(|e| (e.to_string(), exit::CONFIG_ERROR))?;
275 }
276
277 let clone_url = absolutize_clone_url(&opts.url);
281 let staging = state.join("repo.git");
282 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 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 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
322fn 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
365fn fetch_and_maybe_pull(layout: &RepoLayout, opts: &FetchArgs, pull: bool) -> CmdResult<Summary> {
367 let import_opts = ImportArgs {
368 url: String::new(), 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 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
405fn 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); };
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 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 ¤t,
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
471struct 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 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
545struct 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)] fn 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 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 let map_intact = map::map_is_intact(state).map_err(|e| (e.to_string(), exit::GENERAL_ERROR))?;
604 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 if sha_map.is_empty() {
630 divergence_probe(&store, staging, &upstream_refs, &kp.public.0)?;
631 }
632
633 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 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 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 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 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 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 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 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 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 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 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 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 map::mark_normalized(state).map_err(|e| (e.to_string(), exit::CANTCREAT))?;
861 }
862
863 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
890fn 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
907fn 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; }
926 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
974fn bind_import_state(layout: &RepoLayout, state: &Path, url: &str) -> CmdResult<()> {
979 map::bind_direction(state, Direction::Import)
980 .or_else(|_| {
981 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 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
1026fn 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
1050fn 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
1069fn 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 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 Err(_) if flag.is_none() && !path.exists() => {
1093 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
1129fn divergence_probe(
1133 store: &ObjectStore,
1134 staging: &Path,
1135 upstream_refs: &[gitsrc::UpstreamRef],
1136 our_key: &[u8; 32],
1137) -> CmdResult<()> {
1138 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 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;