1use 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
30const PREDICATE_TYPE: &str =
32 "https://github.com/officialunofficial/mkit/spec/predicate/git-bridge/v1";
33
34const 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(ExportArgs),
42 Import(super::git_import::ImportArgs),
44 Fetch(super::git_import::FetchArgs),
46 Pull(super::git_import::FetchArgs),
48 Verify(super::git_tools::VerifyArgs),
52 Status(super::git_tools::StatusArgs),
54 FormatPatch(super::git_tools::FormatPatchArgs),
56}
57
58#[derive(Debug, Parser)]
59struct ExportArgs {
60 dest: String,
63 #[arg(long = "remote-name", value_name = "NAME", default_value = "mirror")]
65 remote_name: String,
66 #[arg(long = "ref", value_name = "REF")]
69 refs: Vec<String>,
70 #[arg(long = "no-attest")]
72 no_attest: bool,
73 #[arg(long, value_name = "ALG")]
76 algorithm: Option<String>,
77 #[arg(long, value_name = "KIND")]
80 signer: Option<String>,
81 #[arg(long)]
86 passthrough: bool,
87 #[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)] fn 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 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 let state =
192 map::state_dir(layout, &opts.remote_name).map_err(|e| (e.to_string(), exit::USAGE))?;
193 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 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 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 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 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 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 let push_dest = ensure_dest(&opts.dest)?;
326 let fresh_state = !opts.passthrough && !state.join("dest").exists();
331
332 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 }
361 Err(e) => return Err((format!("read dest binding: {e}"), exit::GENERAL_ERROR)),
362 }
363 }
364
365 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 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 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 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 let attestable: Vec<Exported> = exported
439 .iter()
440 .filter(|e| {
441 if !opts.passthrough {
442 return true;
443 }
444 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 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 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 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 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 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 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 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 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
657fn 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; }
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 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#[allow(clippy::too_many_lines)] fn 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 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 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 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 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 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 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 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
890pub(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
926fn ensure_dest(dest: &str) -> CmdResult<String> {
931 if dest.starts_with('-') {
932 return Err((format!("invalid destination {dest:?}"), exit::USAGE));
934 }
935 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 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
1034fn 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
1051fn 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;