use clap::Parser;
use mkit_attest::Signer as _;
use mkit_attest::{Envelope, PAYLOAD_TYPE_IN_TOTO, Sig, statement, store as attest_store};
use mkit_core::layout::RepoLayout;
use mkit_core::object::{Object, ObjectType};
use mkit_core::sign::{KeyPair, sign_commit, sign_tag};
use mkit_core::store::BulkWriter;
use mkit_core::{Hash, ObjectStore, refs};
use mkit_git_bridge::error::BridgeError;
use mkit_git_bridge::gitobj::{Sha1Id, bytes_hex, sha1_hex};
use mkit_git_bridge::gitsrc::{self, CatFileBatch};
use mkit_git_bridge::import::{
DepthMemo, IMPORT_SPEC_VERSION, ImportOptions, ImportSigner, Importer, ObjectSink,
};
use mkit_git_bridge::map::{self, Direction};
use mkit_git_bridge::remoteid::remote_identity;
use std::collections::HashMap;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use crate::exit;
use crate::format;
const PREDICATE_TYPE: &str =
"https://github.com/officialunofficial/mkit/spec/predicate/git-import/v1";
const IMPORT_KEY_FILE: &str = "keys/git-import.key";
const IMPORTING_MARKER: &str = "importing";
#[derive(Debug, Parser)]
pub struct ImportArgs {
pub url: String,
pub dir: Option<String>,
#[arg(long = "remote-name", value_name = "NAME", default_value = "upstream")]
pub remote_name: String,
#[arg(long = "key", value_name = "PATH")]
pub key: Option<String>,
#[arg(long)]
pub json: bool,
}
#[derive(Debug, Parser)]
pub struct FetchArgs {
#[arg(long = "remote-name", value_name = "NAME", default_value = "upstream")]
pub remote_name: String,
#[arg(long = "key", value_name = "PATH")]
pub key: Option<String>,
#[arg(long)]
pub json: bool,
}
type CmdResult<T> = Result<T, (String, u8)>;
#[must_use]
pub fn run_import(opts: &ImportArgs) -> u8 {
let outcome = match opts.dir.as_deref() {
Some(dir) => fresh_clone(opts, dir),
None => std::env::current_dir()
.map_err(|e| (format!("cwd: {e}"), exit::CONFIG_ERROR))
.and_then(|cwd| {
mkit_core::layout::discover(&cwd)
.map_err(|e| (format!("worktree discovery: {e}"), exit::DATAERR))
.and_then(|l| import_into(&l, opts, true))
}),
};
finish(outcome, opts.json)
}
#[must_use]
pub fn run_fetch(opts: &FetchArgs, pull: bool) -> u8 {
let outcome = std::env::current_dir()
.map_err(|e| (format!("cwd: {e}"), exit::CONFIG_ERROR))
.and_then(|cwd| {
mkit_core::layout::discover(&cwd)
.map_err(|e| (format!("worktree discovery: {e}"), exit::DATAERR))
.and_then(|l| fetch_and_maybe_pull(&l, opts, pull))
});
finish(outcome, opts.json)
}
fn finish(outcome: CmdResult<Summary>, json: bool) -> u8 {
match outcome {
Ok(summary) => {
summary.print(json);
if summary.imported.is_empty() && !summary.skipped.is_empty() {
emit_err(
&format!(
"every requested ref was skipped ({} refusals)",
summary.skipped.len()
),
exit::GENERAL_ERROR,
)
} else {
exit::OK
}
}
Err((msg, code)) => emit_err(&msg, code),
}
}
fn validate_url(url: &str) -> CmdResult<()> {
if url.trim().is_empty() {
return Err(("empty git URL or path".into(), exit::USAGE));
}
if url.starts_with('-') {
return Err((
format!("{url:?} is not a valid git URL or path"),
exit::USAGE,
));
}
Ok(())
}
fn fresh_clone(opts: &ImportArgs, dir: &str) -> CmdResult<Summary> {
validate_url(&opts.url)?;
let target = PathBuf::from(dir);
if target.exists() && std::fs::read_dir(&target).map_or(true, |mut d| d.next().is_some()) {
return Err((
format!("destination '{dir}' already exists"),
exit::CANTCREAT,
));
}
let created = !target.exists();
std::fs::create_dir_all(&target).map_err(|e| (format!("mkdir: {e}"), exit::CANTCREAT))?;
let layout = mkit_core::layout::discover(&target)
.map_err(|e| (format!("worktree discovery: {e}"), exit::DATAERR))?;
ObjectStore::init(&layout).map_err(|e| (format!("init: {e}"), exit::CANTCREAT))?;
refs::init(&layout).map_err(|e| (format!("refs init: {e}"), exit::CANTCREAT))?;
let mut summary = match import_into(&layout, opts, false) {
Ok(s) => s,
Err(e) => {
if created {
let _ = std::fs::remove_dir_all(&target);
} else if let Ok(rd) = std::fs::read_dir(&target) {
for entry in rd.flatten() {
let p = entry.path();
let _ = if p.is_dir() {
std::fs::remove_dir_all(&p)
} else {
std::fs::remove_file(&p)
};
}
}
return Err(e);
}
};
let staging = map::state_dir(&layout, &opts.remote_name)
.map_err(|e| (e.to_string(), exit::USAGE))?
.join("repo.git");
let default = gitsrc::default_branch(&staging)
.map_err(|e| (format!("default branch: {e}"), exit::GENERAL_ERROR))?
.and_then(|r| r.strip_prefix("refs/heads/").map(str::to_owned));
if let Some(branch) = default
&& let Some(head) = refs::read_remote_ref(&layout, &opts.remote_name, &branch)
.map_err(|e| (format!("read tracking ref: {e}"), exit::GENERAL_ERROR))?
{
checkout_initial(&layout, &branch, &head)?;
summary.checked_out = Some(branch);
}
Ok(summary)
}
fn checkout_initial(layout: &RepoLayout, branch: &str, head: &Hash) -> CmdResult<()> {
let store =
ObjectStore::open(layout).map_err(|e| (format!("open store: {e}"), exit::GENERAL_ERROR))?;
let tree = match store.read_object(head) {
Ok(Object::Commit(c)) => c.tree_hash,
Ok(Object::Tag(_) | _) | Err(_) => {
return Err(("imported head is not a commit".into(), exit::DATAERR));
}
};
super::write_ref_recording_history(
layout,
branch,
mkit_core::refs::RefWriteCondition::Missing,
head,
)
.map_err(|e| (format!("write branch: {e}"), exit::CANTCREAT))?;
refs::write_head_branch(layout, branch)
.map_err(|e| (format!("write HEAD: {e}"), exit::CANTCREAT))?;
super::restore_worktree_and_index(layout, &store, tree)
.map_err(|e| (format!("checkout: {e}"), exit::GENERAL_ERROR))?;
Ok(())
}
fn import_into(layout: &RepoLayout, opts: &ImportArgs, require_repo: bool) -> CmdResult<Summary> {
if require_repo {
ObjectStore::open(layout)
.map_err(|e| (format!("open repository: {e}"), exit::GENERAL_ERROR))?;
}
super::git::git_version().map_err(|e| (e, exit::UNAVAILABLE))?;
validate_url(&opts.url)?;
let state =
map::state_dir(layout, &opts.remote_name).map_err(|e| (e.to_string(), exit::USAGE))?;
let _state_lock = mkit_core::repo_lock::acquire_default(
layout.common_dir(),
&format!("git-{}.lock", opts.remote_name),
)
.map_err(|e| {
(
format!(
"bridge state '{}' is busy (another mkit git operation?): {e}",
opts.remote_name
),
exit::TEMPFAIL,
)
})?;
validate_import_bindings(layout, &state, &opts.url)?;
let kp = load_or_create_import_key(layout, opts.key.as_deref())?;
if let Some(pinned) =
map::read_signer(&state).map_err(|e| (e.to_string(), exit::CONFIG_ERROR))?
&& pinned != kp.public.0
{
map::bind_signer(&state, &kp.public.0).map_err(|e| (e.to_string(), exit::CONFIG_ERROR))?;
}
let clone_url = absolutize_clone_url(&opts.url);
let staging = state.join("repo.git");
std::fs::create_dir_all(&state)
.map_err(|e| (format!("create state dir: {e}"), exit::CANTCREAT))?;
if staging.join("objects").is_dir() {
super::git::git_in(
&staging,
&[
"fetch",
"--quiet",
"--prune",
"origin",
"+refs/heads/*:refs/heads/*",
"+refs/tags/*:refs/tags/*",
],
)
.map_err(|e| (format!("fetch upstream: {e}"), exit::UNAVAILABLE))?;
} else {
super::git::git_in(
state.as_path(),
&["clone", "--mirror", "--quiet", &clone_url, "repo.git"],
)
.map_err(|e| (format!("clone upstream: {e}"), exit::UNAVAILABLE))?;
}
if gitsrc::is_sha256_repo(&staging).map_err(|e| (e.to_string(), exit::GENERAL_ERROR))? {
return Err((
"SHA-256 repositories are out of scope for git-import v1 (SPEC-GIT-IMPORT §2)".into(),
exit::DATAERR,
));
}
bind_import_state(layout, &state, &opts.url)?;
map::bind_signer(&state, &kp.public.0).map_err(|e| (e.to_string(), exit::CONFIG_ERROR))?;
translate_upstream(layout, &state, &staging, opts, &kp)
}
fn validate_import_bindings(layout: &RepoLayout, state: &Path, url: &str) -> CmdResult<()> {
match map::read_direction(state).map_err(|e| (e.to_string(), exit::GENERAL_ERROR))? {
None | Some(Direction::Import | Direction::Fork) => {}
Some(other) => {
return Err((
format!(
"state dir is bound to direction '{}' (one direction per state dir)",
other.as_str()
),
exit::USAGE,
));
}
}
let identity = remote_identity(url);
match std::fs::read_to_string(state.join("source")) {
Ok(recorded) if recorded.trim() != identity => Err((
format!(
"state '{}' is bound to {}; use a different --remote-name for {url}",
state.file_name().unwrap_or_default().to_string_lossy(),
recorded.trim(),
),
exit::USAGE,
)),
Ok(_) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
if let Some(other) = other_state_with_source(layout, state, &identity) {
return Err((
format!(
"{url} is already imported as state '{other}'; use \
`--remote-name {other}` instead of creating a duplicate \
import (SPEC-GIT-IMPORT §6.1)"
),
exit::USAGE,
));
}
Ok(())
}
Err(e) => Err((format!("read source binding: {e}"), exit::GENERAL_ERROR)),
}
}
fn fetch_and_maybe_pull(layout: &RepoLayout, opts: &FetchArgs, pull: bool) -> CmdResult<Summary> {
let import_opts = ImportArgs {
url: String::new(), dir: None,
remote_name: opts.remote_name.clone(),
key: opts.key.clone(),
json: opts.json,
};
let state =
map::state_dir(layout, &opts.remote_name).map_err(|e| (e.to_string(), exit::USAGE))?;
if read_source(&state)?.is_none() {
return Err((
format!(
"no import state for '{}' — run `mkit git import <url>` first",
opts.remote_name
),
exit::CONFIG_ERROR,
));
}
let origin = super::git::git_in(
&state.join("repo.git"),
&["config", "--get", "remote.origin.url"],
)
.map(|s| s.trim().to_owned())
.map_err(|e| (format!("staging origin: {e}"), exit::GENERAL_ERROR))?;
let import_opts = ImportArgs {
url: origin,
..import_opts
};
let mut summary = import_into(layout, &import_opts, true)?;
if pull {
summary.pulled = fast_forward_current(layout, &opts.remote_name)?;
}
Ok(summary)
}
fn fast_forward_current(layout: &RepoLayout, remote: &str) -> CmdResult<Option<String>> {
let store =
ObjectStore::open(layout).map_err(|e| (format!("open store: {e}"), exit::GENERAL_ERROR))?;
let Ok(refs::Head::Branch(branch)) = refs::read_head(layout) else {
return Ok(None); };
let Some(target) = refs::read_remote_ref(layout, remote, &branch)
.map_err(|e| (format!("read tracking ref: {e}"), exit::GENERAL_ERROR))?
else {
return Ok(None);
};
let Some(current) = refs::read_ref(layout, &branch)
.map_err(|e| (format!("read branch: {e}"), exit::GENERAL_ERROR))?
else {
return Ok(None);
};
if current == target {
return Ok(None);
}
let ancestor = mkit_core::ops::merge::is_ancestor(&store, current, target)
.map_err(|e| (format!("ancestry: {e}"), exit::GENERAL_ERROR))?;
if !ancestor {
return Err((
format!(
"pull would not fast-forward branch '{branch}'; integrate with \
`mkit merge {remote}/{branch}` (or `mkit rebase {remote}/{branch}`)"
),
exit::GENERAL_ERROR,
));
}
let tree = match store.read_object(&target) {
Ok(Object::Commit(c)) => c.tree_hash,
_ => return Err(("tracking ref is not a commit".into(), exit::DATAERR)),
};
let _wt_lock = super::acquire_worktree_lock(layout)
.map_err(|code| ("worktree is busy (another mkit command?)".to_owned(), code))?;
super::ensure_restore_safe(layout, &store, tree).map_err(|e| (e, exit::GENERAL_ERROR))?;
super::write_ref_recording_history(
layout,
&branch,
mkit_core::refs::RefWriteCondition::Match(current),
&target,
)
.map_err(|e| (format!("advance branch: {e}"), exit::CANTCREAT))?;
if let Err(e) = super::restore_worktree_and_index(layout, &store, tree) {
let rollback = super::write_ref_recording_history(
layout,
&branch,
mkit_core::refs::RefWriteCondition::Match(target),
¤t,
);
let extra = match rollback {
Ok(()) => String::new(),
Err(rb) => format!("; additionally failed to roll back the branch ref: {rb}"),
};
return Err((format!("{e}{extra}"), exit::GENERAL_ERROR));
}
Ok(Some(branch))
}
struct Summary {
imported: Vec<(String, Sha1Id, Hash)>,
skipped: Vec<(String, String)>,
normalized: bool,
checked_out: Option<String>,
pulled: Option<String>,
}
impl Summary {
fn print(&self, json: bool) {
if json {
let ok = !self.imported.is_empty() || self.skipped.is_empty();
let mut out = format!("{{\"ok\":{ok},\"imported\":[");
for (i, (r, s1, b3)) in self.imported.iter().enumerate() {
if i > 0 {
out.push(',');
}
let _ = write!(
out,
"{{\"ref\":\"{}\",\"git\":\"{}\",\"mkit\":\"{}\"}}",
format::json_escape(r),
sha1_hex(s1),
mkit_core::to_hex(b3)
);
}
out.push_str("],\"skipped\":[");
for (i, (r, why)) in self.skipped.iter().enumerate() {
if i > 0 {
out.push(',');
}
let _ = write!(
out,
"{{\"ref\":\"{}\",\"reason\":\"{}\"}}",
format::json_escape(r),
format::json_escape(why)
);
}
out.push(']');
if let Some(b) = &self.checked_out {
let _ = write!(out, ",\"checkedOut\":\"{}\"", format::json_escape(b));
}
if let Some(b) = &self.pulled {
let _ = write!(out, ",\"fastForwarded\":\"{}\"", format::json_escape(b));
}
out.push('}');
println!("{out}");
return;
}
for (r, s1, b3) in &self.imported {
println!(
"imported {r} {} -> {}",
&sha1_hex(s1)[..8],
&mkit_core::to_hex(b3)[..8]
);
}
if self.normalized {
eprintln!(
"warning: historic tree modes were normalized (declared-lossy; \
originals retained in the staging mirror)"
);
}
if let Some(b) = &self.checked_out {
eprintln!("checked out '{b}'");
}
if let Some(b) = &self.pulled {
eprintln!("fast-forwarded '{b}'");
}
}
}
struct BulkSink<'a> {
bw: BulkWriter<'a>,
store: &'a ObjectStore,
}
impl ObjectSink for BulkSink<'_> {
fn write_object(&mut self, bytes: &[u8]) -> Result<Hash, BridgeError> {
self.bw
.write(bytes)
.map_err(|e| BridgeError::Source(format!("bulk write: {e}")))
}
fn kind_of(&self, h: &Hash) -> Option<ObjectType> {
self.store.read_object(h).ok().map(|o| o.object_type())
}
}
#[allow(clippy::too_many_lines)] fn translate_upstream(
layout: &RepoLayout,
state: &Path,
staging: &Path,
opts: &ImportArgs,
kp: &KeyPair,
) -> CmdResult<Summary> {
let store =
ObjectStore::open(layout).map_err(|e| (format!("open store: {e}"), exit::GENERAL_ERROR))?;
let marker = state.join(IMPORTING_MARKER);
let mut recovering = marker.exists();
if recovering {
let _ = std::fs::remove_file(state.join("map"));
eprintln!("note: previous import was interrupted; rebuilding the map cache");
}
let mut sha_map = map::load_map_inverse(state)
.map_err(|e| (format!("load map: {e}"), exit::GENERAL_ERROR))?;
let prior_state = map::load_import_ref_state(state)
.map_err(|e| (format!("load ref state: {e}"), exit::GENERAL_ERROR))?;
let map_intact = map::map_is_intact(state).map_err(|e| (e.to_string(), exit::GENERAL_ERROR))?;
let tips_mapped = prior_state
.iter()
.all(|st| sha_map.contains_key(&st.git_id));
if !recovering
&& (!map_intact || !tips_mapped || (sha_map.is_empty() && !prior_state.is_empty()))
{
recovering = true;
let _ = std::fs::remove_file(state.join("map"));
sha_map.clear();
eprintln!("note: map cache missing or corrupt; rebuilding from the staging mirror");
}
let upstream_refs =
gitsrc::list_refs(staging).map_err(|e| (e.to_string(), exit::GENERAL_ERROR))?;
if sha_map.is_empty() {
divergence_probe(&store, staging, &upstream_refs, &kp.public.0)?;
}
write_durable(&marker, b"").map_err(|e| (format!("marker: {e}"), exit::CANTCREAT))?;
let direction = map::read_direction(state)
.map_err(|e| (e.to_string(), exit::GENERAL_ERROR))?
.unwrap_or(Direction::Import);
let fork_mode = direction == Direction::Fork;
let mut batch = CatFileBatch::open(staging).map_err(|e| (e.to_string(), exit::UNAVAILABLE))?;
let mut sink = BulkSink {
bw: store.bulk_writer(),
store: &store,
};
let raw_dir = state.join("raw");
let mut raw_dirs: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
let mut retain = |id: &Sha1Id, raw: &[u8]| -> Result<(), BridgeError> {
let hex = sha1_hex(id);
let dir = raw_dir.join(&hex[..2]);
std::fs::create_dir_all(&dir)?;
let path = dir.join(&hex[2..]);
if !path.exists() {
let tmp = dir.join(format!(".{}.tmp", &hex[2..]));
{
use std::io::Write as _;
let mut f = std::fs::File::create(&tmp)?;
f.write_all(raw)?;
f.sync_all()?;
}
std::fs::rename(&tmp, &path)?;
}
raw_dirs.insert(dir);
Ok(())
};
let public = kp.public.0;
let mut sc = |c: &mkit_core::object::Commit| {
Ok(sign_commit(c, kp)
.map_err(|e| BridgeError::Source(e.to_string()))?
.0)
};
let mut st = |t: &mkit_core::object::Tag| {
Ok(sign_tag(t, kp)
.map_err(|e| BridgeError::Source(e.to_string()))?
.0)
};
let prior_by_ref: HashMap<&str, &map::RefState> = prior_state
.iter()
.map(|s| (s.ref_name.as_str(), s))
.collect();
let exclude: Vec<Sha1Id> = if recovering {
Vec::new()
} else {
prior_state
.iter()
.map(|s| s.git_id)
.filter(|id| gitsrc::object_exists(staging, id).unwrap_or(false))
.collect()
};
let mut imported: Vec<(String, Sha1Id, Hash)> = Vec::new();
let mut skipped: Vec<(String, String)> = Vec::new();
let mut all_pairs: Vec<(Sha1Id, Hash)> = Vec::new();
let mut normalized = false;
for uref in &upstream_refs {
let mkit_legal = if let Some(b) = uref.name.strip_prefix("refs/heads/") {
refs::validate_ref_name(b)
} else if let Some(t) = uref.name.strip_prefix("refs/tags/") {
refs::validate_ref_name(t)
} else {
false
};
if !mkit_legal {
let why = format!("ref name {:?} is outside the mkit ref grammar", uref.name);
eprintln!("warning: skipping {}: {why}", uref.name);
skipped.push((uref.name.clone(), why));
continue;
}
if !recovering
&& let Some(prev) = prior_by_ref.get(uref.name.as_str())
&& prev.git_id == uref.id
{
imported.push((uref.name.clone(), uref.id, prev.mkit_hash));
continue;
}
let commit_tip = uref.peeled.unwrap_or(uref.id);
let order = gitsrc::rev_list(staging, &[commit_tip], &exclude)
.map_err(|e| (e.to_string(), exit::GENERAL_ERROR))?;
let mut imp = Importer {
source: &mut batch,
sink: &mut sink,
signer: ImportSigner {
public,
sign_commit: &mut sc,
sign_tag: &mut st,
},
map: &mut sha_map,
retain_raw: &mut retain,
options: ImportOptions { fork_mode },
depth_memo: DepthMemo::default(),
};
let mut ref_pairs: Vec<(Sha1Id, Hash)> = Vec::new();
let result = imp.import_commits(&order, &uref.id, &mut ref_pairs, &mut normalized);
all_pairs.extend_from_slice(&ref_pairs);
match result {
Ok(head) => {
imported.push((uref.name.clone(), uref.id, head));
}
Err(BridgeError::Refused(r)) => {
eprintln!("warning: skipping {}: {r}", uref.name);
skipped.push((uref.name.clone(), r.to_string()));
}
Err(e) => return Err((format!("import {}: {e}", uref.name), exit::GENERAL_ERROR)),
}
}
drop(batch);
sink.bw
.commit()
.map_err(|e| (format!("commit bulk writes: {e}"), exit::CANTCREAT))?;
for dir in &raw_dirs {
if let Ok(d) = std::fs::File::open(dir) {
let _ = d.sync_all();
}
}
map::append_map_import(state, &all_pairs)
.map_err(|e| (format!("persist map: {e}"), exit::GENERAL_ERROR))?;
let mut new_state: Vec<map::RefState> = Vec::new();
for (name, git_id, mkit_hash) in &imported {
if let Some(branch) = name.strip_prefix("refs/heads/") {
if let Some(prev) = prior_by_ref.get(name.as_str())
&& prev.git_id != *git_id
&& !gitsrc::is_ancestor(staging, &prev.git_id, git_id).unwrap_or(true)
{
eprintln!(
"warning: upstream force-pushed {name}; tracking ref rewound \
(rebase local branches that built on the old history)"
);
}
refs::write_remote_ref(layout, &opts.remote_name, branch, mkit_hash)
.map_err(|e| (format!("tracking ref {name}: {e}"), exit::CANTCREAT))?;
} else if let Some(tag) = name.strip_prefix("refs/tags/") {
let existing = refs::read_tag(layout, tag)
.map_err(|e| (format!("tag ref {name}: {e}"), exit::GENERAL_ERROR))?;
let ours_before = prior_by_ref.get(name.as_str()).map(|p| p.mkit_hash);
match existing {
Some(cur) if cur != *mkit_hash && Some(cur) != ours_before => {
eprintln!(
"warning: not updating tag '{tag}': it was moved locally \
(delete it with `mkit tag -d {tag}` to track the upstream tag)"
);
}
Some(cur) if cur == *mkit_hash => {}
_ => {
refs::update_tag(
layout,
tag,
mkit_core::refs::RefWriteCondition::Any,
mkit_hash,
)
.map_err(|e| (format!("tag ref {name}: {e}"), exit::CANTCREAT))?;
}
}
}
new_state.push(map::RefState {
ref_name: name.clone(),
mkit_hash: *mkit_hash,
git_id: *git_id,
});
}
let current: std::collections::HashSet<&str> =
upstream_refs.iter().map(|u| u.name.as_str()).collect();
for prev in &prior_state {
if let Some(branch) = prev.ref_name.strip_prefix("refs/heads/")
&& !current.contains(prev.ref_name.as_str())
{
match refs::delete_remote_ref(layout, &opts.remote_name, branch) {
Ok(()) => eprintln!(
"warning: upstream deleted {}; tracking ref {}/{branch} removed",
prev.ref_name, opts.remote_name
),
Err(mkit_core::refs::RefError::NotFound(_)) => {}
Err(e) => {
return Err((format!("prune tracking ref {branch}: {e}"), exit::CANTCREAT));
}
}
}
}
if normalized {
map::mark_normalized(state).map_err(|e| (e.to_string(), exit::CANTCREAT))?;
}
mint_attestations(
layout,
&opts.url,
&opts.remote_name,
&imported,
&prior_by_ref,
kp,
)?;
map::store_import_ref_state(state, &new_state)
.map_err(|e| (format!("persist ref state: {e}"), exit::GENERAL_ERROR))?;
std::fs::remove_file(&marker).map_err(|e| (format!("marker: {e}"), exit::GENERAL_ERROR))?;
Ok(Summary {
imported,
skipped,
normalized,
checked_out: None,
pulled: None,
})
}
fn write_durable(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
{
use std::io::Write as _;
let mut f = std::fs::File::create(path)?;
f.write_all(bytes)?;
f.sync_all()?;
}
if let Some(parent) = path.parent()
&& let Ok(d) = std::fs::File::open(parent)
{
let _ = d.sync_all();
}
Ok(())
}
fn mint_attestations(
layout: &RepoLayout,
url: &str,
remote_name: &str,
imported: &[(String, Sha1Id, Hash)],
prior: &HashMap<&str, &map::RefState>,
kp: &KeyPair,
) -> CmdResult<()> {
let remote_url = remote_identity(url);
let obj_store = mkit_core::store::ObjectStore::open(layout)
.map_err(|e| (format!("not a mkit repo: {e}"), exit::GENERAL_ERROR))?;
for (name, git_id, mkit_hash) in imported {
if let Some(prev) = prior.get(name.as_str())
&& prev.mkit_hash == *mkit_hash
{
continue; }
let mkit_ref = name.strip_prefix("refs/heads/").map_or_else(
|| name.clone(),
|branch| format!("refs/remotes/{remote_name}/{branch}"),
);
let predicate = format!(
"{{\"gitCommit\":\"{}\",\"refName\":\"{}\",\"remoteUrl\":\"{}\",\"schemaVersion\":1,\"specVersion\":1}}",
sha1_hex(git_id),
format::json_escape(&mkit_ref),
format::json_escape(&remote_url)
);
let head_bytes = super::read_object_bytes(&obj_store, mkit_hash)?;
let stmt = statement::encode(&statement::Statement {
subjects: vec![statement::Subject {
name: Some(mkit_ref),
digest_blake3_hex: mkit_core::to_hex(mkit_hash),
digest_sha256_hex: statement::sha256_hex(&head_bytes),
}],
predicate_type: PREDICATE_TYPE.to_owned(),
predicate_jcs: predicate.as_bytes(),
})
.map_err(|e| (format!("encode statement: {e}"), exit::GENERAL_ERROR))?;
let pae = mkit_attest::pae_of(PAYLOAD_TYPE_IN_TOTO, stmt.as_bytes());
let mut signer = mkit_attest::RepoKeySigner::new(KeyPair {
public: kp.public,
secret: mkit_core::sign::SecretSeed(kp.secret.0),
});
let sig = signer
.sign(&pae)
.map_err(|e| (format!("sign attestation: {e}"), exit::GENERAL_ERROR))?;
let keyid = signer
.keyid()
.map_err(|e| (format!("attestation keyid: {e}"), exit::GENERAL_ERROR))?;
let envelope = Envelope {
payload_type: PAYLOAD_TYPE_IN_TOTO.to_owned(),
payload: stmt.into_bytes(),
signatures: vec![Sig { keyid, sig }],
};
let encoded = envelope
.encode()
.map_err(|e| (format!("encode envelope: {e}"), exit::GENERAL_ERROR))?;
attest_store::save(layout, mkit_hash, encoded.as_bytes())
.map_err(|e| (format!("save attestation: {e}"), exit::CANTCREAT))?;
}
Ok(())
}
fn bind_import_state(layout: &RepoLayout, state: &Path, url: &str) -> CmdResult<()> {
map::bind_direction(state, Direction::Import)
.or_else(|_| {
match map::read_direction(state) {
Ok(Some(Direction::Fork)) => Ok(()),
_ => Err(BridgeError::Source(
"state dir direction conflict (one direction per state dir)".into(),
)),
}
})
.map_err(|e| (e.to_string(), exit::USAGE))?;
let identity = remote_identity(url);
let src_file = state.join("source");
match std::fs::read_to_string(&src_file) {
Ok(recorded) if recorded.trim() != identity => Err((
format!(
"state '{}' is bound to {}; use a different --remote-name for {}",
state.file_name().unwrap_or_default().to_string_lossy(),
recorded.trim(),
url
),
exit::USAGE,
)),
Ok(_) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
if let Some(other) = other_state_with_source(layout, state, &identity) {
return Err((
format!(
"{url} is already imported as state '{other}'; use \
`--remote-name {other}` instead of creating a duplicate \
import (SPEC-GIT-IMPORT §6.1)"
),
exit::USAGE,
));
}
map::write_binding(state, "source", &identity)
.map_err(|e| (format!("record source: {e}"), exit::CANTCREAT))
}
Err(e) => Err((format!("read source binding: {e}"), exit::GENERAL_ERROR)),
}?;
map::bind_import_spec(state, IMPORT_SPEC_VERSION).map_err(|e| (e.to_string(), exit::USAGE))
}
fn absolutize_clone_url(url: &str) -> String {
let looks_like_url = url.contains("://")
|| url
.split('/')
.next()
.is_some_and(|first| first.contains(':'));
if looks_like_url {
return url.to_owned();
}
let p = Path::new(url);
p.canonicalize()
.map_or_else(|_| url.to_owned(), |c| c.to_string_lossy().into_owned())
}
fn read_source(state: &Path) -> CmdResult<Option<String>> {
match std::fs::read_to_string(state.join("source")) {
Ok(s) => Ok(Some(s.trim().to_owned())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err((format!("read source: {e}"), exit::GENERAL_ERROR)),
}
}
fn other_state_with_source(
layout: &RepoLayout,
this_state: &Path,
identity: &str,
) -> Option<String> {
for entry in std::fs::read_dir(layout.git_state_dir()).ok()?.flatten() {
if entry.path() == this_state {
continue;
}
if let Ok(src) = std::fs::read_to_string(entry.path().join("source"))
&& src.trim() == identity
{
return Some(entry.file_name().to_string_lossy().into_owned());
}
}
None
}
fn load_or_create_import_key(layout: &RepoLayout, flag: Option<&str>) -> CmdResult<KeyPair> {
let path = flag.map_or_else(|| layout.common_dir().join(IMPORT_KEY_FILE), PathBuf::from);
match mkit_core::sign::load_key(&path) {
Ok(kp) => {
eprintln!(
"note: signing imported history with key {}… ({})",
&mkit_core::to_hex(&{
let mut h = [0u8; 32];
h.copy_from_slice(&kp.public.0);
h
})[..16],
path.display()
);
Ok(kp)
}
Err(_) if flag.is_none() && !path.exists() => {
let _key_lock =
mkit_core::repo_lock::acquire_default(layout.common_dir(), "git-import-key.lock")
.map_err(|e| (format!("import key generation busy: {e}"), exit::TEMPFAIL))?;
if let Ok(kp) = mkit_core::sign::load_key(&path) {
return Ok(kp);
}
let kp = KeyPair::generate()
.map_err(|e| (format!("generate import key: {e}"), exit::GENERAL_ERROR))?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| (format!("mkdir keys: {e}"), exit::CANTCREAT))?;
}
mkit_core::sign::save_key(&path, &kp)
.map_err(|e| (format!("save import key: {e}"), exit::CANTCREAT))?;
eprintln!(
"note: generated a DEDICATED import key at {} — collaborative \
tracking of one upstream requires sharing this key (org/bot \
key); a different key produces an unrelated fork \
(SPEC-GIT-IMPORT §4)",
path.display()
);
Ok(kp)
}
Err(e) => Err((
format!("load import key {}: {e}", path.display()),
exit::NOINPUT,
)),
}
}
fn divergence_probe(
store: &ObjectStore,
staging: &Path,
upstream_refs: &[gitsrc::UpstreamRef],
our_key: &[u8; 32],
) -> CmdResult<()> {
let mut batch = CatFileBatch::open(staging).map_err(|e| (e.to_string(), exit::UNAVAILABLE))?;
let mut digests: HashMap<Hash, Sha1Id> = HashMap::new();
for uref in upstream_refs {
let mut cur = uref.peeled.unwrap_or(uref.id);
for _ in 0..32 {
let Ok((kind, body)) = batch.read(&cur) else {
break;
};
if kind != gitsrc::GitObjKind::Commit {
break;
}
let mut framed = format!("commit {}\0", body.len()).into_bytes();
framed.extend_from_slice(&body);
digests.insert(mkit_core::hash::hash(&framed), cur);
let Ok(parsed) = mkit_git_bridge::gitparse::parse_commit(&body) else {
break;
};
match parsed.parents.first() {
Some(p) => cur = *p,
None => break,
}
}
}
if digests.is_empty() {
return Ok(());
}
let hashes = store
.iter_object_hashes()
.map_err(|e| (format!("scan store: {e}"), exit::GENERAL_ERROR))?;
for h in hashes {
let Ok(Object::Commit(c)) = store.read_object(&h) else {
continue;
};
if digests.contains_key(&c.content_digest) && c.signer != *our_key {
return Err((
format!(
"this upstream is already imported here under key {}…; pull from \
the designated importer over mkit transport, or install that key \
(SPEC-GIT-IMPORT §4/§6.1)",
&bytes_hex(&c.signer)[..16]
),
exit::CONFIG_ERROR,
));
}
}
Ok(())
}
use super::error as emit_err;