crates/mimir-ingest/src/checkout.rs
//! Incremental checkout: move the consumer's clone from its current HEAD to a
//! target commit by writing ONLY the paths whose blob or mode differs (and
//! deleting the ones that are gone). Untouched files keep their mtimes, so
//! cargo's fingerprints see exactly the crates the commit changed.
//!
//! The full-tree writer (`nornir_git::gitio::detach_head_and_checkout`)
//! empties the worktree and rewrites every file: every local crate looks
//! dirty to cargo on every commit. It stays the fallback for a clone whose
//! HEAD cannot be read.
//!
//! Content goes through gix's worktree filter pipeline with `.gitattributes`
//! read from the TARGET tree (the same source the full writer uses), so
//! eol / ident / LFS smudge behave as in a normal checkout.
use std::collections::{BTreeMap, BTreeSet};
use std::io::Read;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, anyhow};
use gix::bstr::{BString, ByteSlice};
use gix::object::tree::EntryKind;
/// What one incremental checkout did.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct CheckoutStats {
/// Files (or symlinks) written.
pub written: usize,
/// Files removed.
pub deleted: usize,
/// `true` when the clone's HEAD was unreadable and the full writer ran.
pub full: bool,
}
/// Seat a repo's shipped sibling patches: when `<tree>/.cargo/estate-siblings.toml`
/// exists, write its content to `<tree>/.cargo/siblings.toml` (only when it differs).
///
/// A repo that patches its siblings through an untracked, optional
/// `include = [{ path = "siblings.toml", optional = true }]` (holger) ships the
/// content as `estate-siblings.toml`; a developer seats it with
/// `cargo xtask siblings`. A consumer clone has nobody to run that, so without
/// this the member does not load at all (oden 2026-10-23: "no matching package
/// named `appliance-grow`"). Returns `true` when it wrote the file.
pub fn seat_estate_siblings(tree: &Path) -> Result<bool> {
let cargo = tree.join(".cargo");
let shipped = match std::fs::read(cargo.join("estate-siblings.toml")) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(e) => return Err(e).with_context(|| format!("read {}", cargo.join("estate-siblings.toml").display())),
};
let seat = cargo.join("siblings.toml");
if std::fs::read(&seat).is_ok_and(|have| have == shipped) {
return Ok(false);
}
std::fs::write(&seat, &shipped).with_context(|| format!("write {}", seat.display()))?;
Ok(true)
}
/// Check out `sha` in `worktree`, writing only what differs from HEAD.
pub fn checkout_changed(worktree: &Path, sha: &str) -> Result<CheckoutStats> {
let mut repo = gix::open(worktree).with_context(|| format!("gix::open {}", worktree.display()))?;
nornir_git::gitio::disarm_long_running_filters(&mut repo)?;
let target = gix::ObjectId::from_hex(sha.as_bytes()).map_err(|e| anyhow!("parse sha {sha}: {e}"))?;
let Ok(current) = repo.head_id().map(|id| id.detach()) else {
nornir_git::gitio::detach_head_and_checkout(worktree, sha)?;
seat_estate_siblings(worktree)?;
return Ok(CheckoutStats { full: true, ..Default::default() });
};
let old_tree = repo.find_commit(current)?.tree_id().map_err(gix::Error::from)?.detach();
let new_tree = repo.find_commit(target).with_context(|| format!("find commit {sha}"))?.tree_id().map_err(gix::Error::from)?.detach();
let old = flatten(&repo, old_tree)?;
let new = flatten(&repo, new_tree)?;
let mut stats = CheckoutStats::default();
// 1. Deletions (and type changes) first, so a file that becomes a
// directory — or the reverse — has room.
let mut touched_dirs: BTreeSet<PathBuf> = BTreeSet::new();
for (path, (kind, _)) in &old {
let replaced = new.get(path).is_some_and(|(k, _)| k != kind);
if !new.contains_key(path) || replaced {
let p = worktree.join(path.to_path().context("non-utf8 path")?);
match std::fs::remove_file(&p) {
Ok(()) => stats.deleted += 1,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(e).with_context(|| format!("remove {}", p.display())),
}
if let Some(d) = p.parent() {
touched_dirs.insert(d.to_path_buf());
}
}
}
// Prune directories the deletions emptied (deepest first, never the root).
for d in touched_dirs.iter().rev() {
let mut d = d.clone();
while d != worktree && d.starts_with(worktree) && std::fs::remove_dir(&d).is_ok() {
match d.parent() {
Some(p) => d = p.to_path_buf(),
None => break,
}
}
}
// 2. Writes: new paths and paths whose blob or mode changed.
let index = repo.index_from_tree(&new_tree).context("index from target tree")?;
let attrs = repo
.attributes_only(&index, gix::worktree::stack::state::attributes::Source::IdMapping)
.context("attributes of the target tree")?
.detach();
// no content filter runs on the consumer's checkout (git-lfs and its
// kind): the files as stored, whatever filter the box configures
nornir_git::gitio::skip_content_filters(&mut repo).context("skip the content filters")?;
let mut pipeline = gix::filter::Pipeline::new(&repo, attrs).context("worktree filter pipeline")?;
for (path, (kind, oid)) in &new {
if old.get(path) == Some(&(*kind, *oid)) {
continue;
}
let p = worktree.join(path.to_path().context("non-utf8 path")?);
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent).with_context(|| format!("mkdir {}", parent.display()))?;
}
let blob = repo.find_object(*oid).with_context(|| format!("blob {oid} for {path}"))?.detach();
if *kind == EntryKind::Link {
let _ = std::fs::remove_file(&p);
let target = blob.data.to_path().context("symlink target")?;
#[cfg(unix)]
std::os::unix::fs::symlink(target, &p).with_context(|| format!("symlink {}", p.display()))?;
#[cfg(not(unix))]
return Err(anyhow!("symlink {} -> {} on a non-unix host", p.display(), target.display()));
} else {
let mut out = Vec::with_capacity(blob.data.len());
pipeline
.convert_to_worktree(&blob.data, path.as_bstr(), Default::default())
.with_context(|| format!("filter {path}"))?
.read_to_end(&mut out)
.with_context(|| format!("filter output {path}"))?;
std::fs::write(&p, &out).with_context(|| format!("write {}", p.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = if *kind == EntryKind::BlobExecutable { 0o755 } else { 0o644 };
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(mode))?;
}
}
stats.written += 1;
}
// 3. HEAD (detached) and the index follow the target.
detach_head(&repo, target)?;
let mut index = index;
index
.write(gix::index::write::Options::default())
.map_err(gix::Error::from)
.context("persist index")?;
seat_estate_siblings(worktree)?;
Ok(stats)
}
/// Every blob / symlink under `tree`: path → (kind, oid). Submodules are not
/// checked out (as in the full writer).
fn flatten(repo: &gix::Repository, tree: gix::ObjectId) -> Result<BTreeMap<BString, (EntryKind, gix::ObjectId)>> {
let mut rec = gix::traverse::tree::Recorder::default();
repo.find_tree(tree)?.traverse().breadthfirst(&mut rec).context("walk tree")?;
let mut out = BTreeMap::new();
for e in rec.records {
let kind = e.mode.kind();
if matches!(kind, EntryKind::Blob | EntryKind::BlobExecutable | EntryKind::Link) {
out.insert(e.filepath, (kind, e.oid));
}
}
Ok(out)
}
fn detach_head(repo: &gix::Repository, target: gix::ObjectId) -> Result<()> {
use gix::refs::Target;
use gix::refs::transaction::{Change, LogChange, PreviousValue, RefEdit, RefLog};
let mut repo = repo.clone();
let _ = repo.committer_or_set_generic_fallback();
repo.edit_reference(RefEdit {
change: Change::Update {
log: LogChange {
mode: RefLog::AndReference,
force_create_reflog: false,
message: "mimir-ingest: incremental checkout".into(),
},
expected: PreviousValue::Any,
new: Target::Object(target),
},
name: "HEAD".try_into().map_err(|e| anyhow!("HEAD ref name: {e:?}"))?,
deref: false,
})
.context("detach HEAD")?;
Ok(())
}