Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

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(())
}