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

nordisk/edda

crates/nornir-xtask-support/src/lockcheck.rs

//! # The lock check: every `Cargo.lock` of a constellation of heads, against each other
//!
//! On 2026-10-23 mixed sibling locks cost eight release attempts: holger on ssh-key 0.6
//! against siblings on 0.7, korp-installer 0.2.6 and 0.2.7 in one lock, and three locks
//! (holger-estate-front, mock-upcloud-kvm, monetize-*) stale against the edda and
//! Skidbladnir mains. Each was found by a release, never before. This module asks the
//! question for every lock at once:
//!
//! * **stale**: `cargo metadata --locked` in the lock's own directory (so its
//!   `.cargo/config.toml`, and with it the sibling patches, is read as cargo reads it)
//!   fails, so the lock no longer matches the manifests and the sibling heads beside it;
//! * **twice**: a crate appears at two versions where that is refused: a crate named
//!   in the single-version list (korp-installer by default, [`DEFAULT_SINGLE`]), or a
//!   crate a sibling checkout provides (a path package) that the lock ALSO carries from
//!   the registry at another version.
//!
//! A repo whose sibling patches are opt-in (holger's `.cargo/estate-siblings.toml`) has
//! them switched on first, the way its `cargo xtask` launcher does.

use std::collections::BTreeMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;

use crate::Result;

/// Crates that must never appear at two versions in one lock.
pub const DEFAULT_SINGLE: &[&str] = &["korp-installer"];

/// One `[[package]]` of a lock.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LockPackage {
    pub name: String,
    pub version: String,
    /// `None` for a path package (a workspace member or a sibling checkout).
    pub source: Option<String>,
}

impl Finding {
    /// Does this finding make the check red?
    pub fn is_red(&self) -> bool {
        !matches!(self, Finding::Drift { .. })
    }
}

/// What the check found in one lock.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Finding {
    /// `cargo metadata --locked` refused the lock; the detail is cargo's error, the fix
    /// the command that makes it `--locked` clean again (tried, then undone).
    Stale { lock: PathBuf, detail: String, fix: String },
    /// `name` appears at more than one version where that is refused (the
    /// single-version list).
    Twice { lock: PathBuf, name: String, versions: Vec<String> },
    /// A sibling's crate appears beside a registry copy of it at another version. Not
    /// refused (two copies link), so a note, never red: it is how two `TestResultRow`
    /// types came to live in one graph.
    Drift { lock: PathBuf, name: String, versions: Vec<String> },
    /// The lock could not be read.
    Unreadable { lock: PathBuf, detail: String },
    /// The repo's declared toolchain image (`[build.toolchain] image_sha256`) is not
    /// the reference pin (edda's, the repo of the Build Thing): its Build Things look
    /// for an image no other box holds (holger on `3d5115ba…`, only on oden, 2026-10-23).
    PinDiffers { repo: PathBuf, image: String, pin: String, reference: String },
}

impl std::fmt::Display for Finding {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Finding::Stale { lock, detail, fix } if fix.is_empty() => write!(f, "STALE {}: {detail}", lock.display()),
            Finding::Stale { lock, detail, fix } => write!(f, "STALE {}: {detail} | fix: {fix}", lock.display()),
            Finding::Twice { lock, name, versions } => {
                write!(f, "TWICE {}: {name} at {}", lock.display(), versions.join(" and "))
            }
            Finding::Drift { lock, name, versions } => {
                write!(f, "note  {}: {name} at {} (a sibling beside its registry copy)", lock.display(), versions.join(" and "))
            }
            Finding::Unreadable { lock, detail } => write!(f, "UNREADABLE {}: {detail}", lock.display()),
            Finding::PinDiffers { repo, image, pin, reference } => write!(
                f,
                "PIN   {}: {image}@sha256:{} is not the reference pin sha256:{}",
                repo.display(),
                &pin[..pin.len().min(12)],
                &reference[..reference.len().min(12)]
            ),
        }
    }
}

/// The packages of a `Cargo.lock` text. Not a TOML parser: a lock is machine-written
/// and its `[[package]]` tables carry `name`, `version` and `source` as plain lines.
pub fn parse_lock(text: &str) -> Vec<LockPackage> {
    let mut out = Vec::new();
    let mut cur: Option<LockPackage> = None;
    for line in text.lines() {
        let l = line.trim();
        if l.starts_with('[') {
            if let Some(p) = cur.take() {
                out.push(p);
            }
            if l == "[[package]]" {
                cur = Some(LockPackage { name: String::new(), version: String::new(), source: None });
            }
            continue;
        }
        let Some(p) = cur.as_mut() else { continue };
        let Some((k, v)) = l.split_once('=') else { continue };
        let v = v.trim().trim_matches('"').to_string();
        match k.trim() {
            "name" => p.name = v,
            "version" => p.version = v,
            "source" => p.source = Some(v),
            _ => {}
        }
    }
    if let Some(p) = cur.take() {
        out.push(p);
    }
    out.retain(|p| !p.name.is_empty());
    out
}

/// The crates of `pkgs` that appear at two versions where that is refused (by name) or
/// noted (a path package beside a registry copy): `(name, versions, refused)`.
pub fn twice(pkgs: &[LockPackage], single: &[&str]) -> Vec<(String, Vec<String>, bool)> {
    let mut by: BTreeMap<&str, Vec<&LockPackage>> = BTreeMap::new();
    for p in pkgs {
        by.entry(p.name.as_str()).or_default().push(p);
    }
    let mut out = Vec::new();
    for (name, ps) in by {
        let mut versions: Vec<String> = ps.iter().map(|p| p.version.clone()).collect();
        versions.sort();
        versions.dedup();
        if versions.len() < 2 {
            continue;
        }
        let path_and_registry = ps.iter().any(|p| p.source.is_none()) && ps.iter().any(|p| p.source.is_some());
        if single.contains(&name) || path_and_registry {
            out.push((name.to_string(), versions, single.contains(&name)));
        }
    }
    out
}

/// Every `Cargo.lock` of `repo` that sits beside a `Cargo.toml`, skipping build output,
/// hidden directories, vendored trees and test fixtures (a `fixtures` directory: gunnar's
/// `xtask/tests/fixtures/*` are stale on purpose, the material its tests judge).
pub fn locks_of(repo: &Path) -> Vec<PathBuf> {
    fn walk(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) {
        if depth > 6 {
            return;
        }
        let lock = dir.join("Cargo.lock");
        if lock.is_file() && dir.join("Cargo.toml").is_file() {
            out.push(lock);
        }
        let Ok(rd) = std::fs::read_dir(dir) else { return };
        let mut subs: Vec<PathBuf> = rd
            .flatten()
            .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
            .map(|e| e.path())
            .filter(|p| {
                let n = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
                !(n.starts_with('.') || n == "target" || n.starts_with("target-") || n == "node_modules" || n == "vendor" || n == "fixtures")
            })
            .collect();
        subs.sort();
        for s in subs {
            walk(&s, depth + 1, out);
        }
    }
    let mut out = Vec::new();
    walk(repo, 0, &mut out);
    out
}

/// Switch on a repo's opt-in sibling patches (`.cargo/estate-siblings.toml` copied to
/// `.cargo/siblings.toml`) when every sibling it names is checked out beside it.
/// Returns whether the patches are on.
pub fn siblings_on(repo: &Path) -> Result<bool> {
    let template = repo.join(".cargo/estate-siblings.toml");
    let Ok(text) = std::fs::read_to_string(&template) else { return Ok(false) };
    let parent = repo.parent().unwrap_or(repo);
    for part in text.split("path = \"").skip(1) {
        let Some(path) = part.split('"').next() else { continue };
        let Some(rest) = path.strip_prefix("../") else { continue };
        let top = rest.split('/').next().unwrap_or(rest);
        if !parent.join(top).is_dir() {
            return Ok(false);
        }
    }
    let copy = repo.join(".cargo/siblings.toml");
    if std::fs::read_to_string(&copy).ok().as_deref() != Some(text.as_str()) {
        std::fs::write(&copy, &text).map_err(|e| format!("write {}: {e}", copy.display()))?;
    }
    Ok(true)
}

fn cargo() -> std::ffi::OsString {
    std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())
}

/// Is `lock` in the git index of `repo`? Outside a git checkout every lock counts.
pub fn tracked(repo: &Path, lock: &Path) -> bool {
    let Ok(rel) = lock.strip_prefix(repo) else { return true };
    let rel = rel.to_string_lossy().replace('\\', "/");
    match nornir_git::gitio::open_for_status(repo) {
        Ok(r) => match r.index_or_empty() {
            Ok(index) => index.entry_by_path(rel.as_str().into()).is_some(),
            Err(_) => true,
        },
        Err(_) => true,
    }
}

/// cargo's error out of its stderr: from the first `error:` line, warnings dropped.
pub fn cargo_error(stderr: &str) -> String {
    let lines: Vec<&str> = stderr.lines().map(str::trim_end).filter(|l| !l.trim().is_empty()).collect();
    let start = lines.iter().position(|l| l.starts_with("error")).unwrap_or(0);
    lines[start..]
        .iter()
        .filter(|l| !l.starts_with("warning:") && !l.trim_start().starts_with("Updating") && !l.trim_start().starts_with("help:"))
        .take(5)
        .map(|l| l.trim())
        .collect::<Vec<_>>()
        .join(" | ")
}

/// The packages whose version moved between two locks, as `name@old` specs (a name
/// with two versions in the old lock is named once per version).
pub fn moved(old: &[LockPackage], new: &[LockPackage]) -> Vec<String> {
    let mut out = Vec::new();
    for o in old {
        let same = new.iter().any(|n| n.name == o.name && n.version == o.version);
        let still = new.iter().any(|n| n.name == o.name);
        if !same && still {
            let spec = format!("{}@{}", o.name, o.version);
            if !out.contains(&spec) {
                out.push(spec);
            }
        }
    }
    out
}

fn cargo_in(dir: &Path, args: &[&str]) -> bool {
    Command::new(cargo())
        .current_dir(dir)
        .args(args)
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status()
        .map(|s| s.success())
        .unwrap_or(false)
}

/// The exact command that makes the stale lock in `dir` `cargo metadata --locked`
/// clean: `cargo update -p <name@old>...` for the packages a fresh resolution moves,
/// else `cargo update --workspace`. Each candidate is TRIED on the lock and the lock is
/// put back as it was, whatever happened; the first one that leaves it clean is named.
pub fn fix_line(dir: &Path, committed: &str) -> String {
    let lock = dir.join("Cargo.lock");
    let restore = || {
        let _ = std::fs::write(&lock, committed);
    };
    // What a fresh, minimal resolution moves.
    let fresh = if cargo_in(dir, &["metadata", "--format-version", "1"]) {
        std::fs::read_to_string(&lock).unwrap_or_default()
    } else {
        String::new()
    };
    restore();
    let moved = moved(&parse_lock(committed), &parse_lock(&fresh));
    let mut candidates: Vec<Vec<String>> = Vec::new();
    if !moved.is_empty() {
        let mut c = vec!["update".to_string()];
        for m in &moved {
            c.push("-p".into());
            c.push(m.clone());
        }
        candidates.push(c);
    }
    candidates.push(vec!["update".into(), "--workspace".into()]);
    let mut found = String::new();
    for c in candidates {
        let args: Vec<&str> = c.iter().map(String::as_str).collect();
        let ok = cargo_in(dir, &args) && cargo_in(dir, &["metadata", "--locked", "--format-version", "1"]);
        restore();
        if ok {
            found = format!("cd {} && cargo {}", dir.display(), c.join(" "));
            break;
        }
    }
    if found.is_empty() {
        found = format!("cd {} && cargo metadata --format-version 1 >/dev/null (no cargo update line alone makes it clean)", dir.display());
    }
    found
}

// ── lockfollow: the mechanical fix ───────────────────────────────────────────

/// One package version that differs between two locks.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Change {
    pub name: String,
    /// The version in the old lock (`None`: added).
    pub old: Option<String>,
    /// The version in the new lock (`None`: removed).
    pub new: Option<String>,
    /// A path package (a workspace member or a sibling checkout), not a registry crate.
    pub path: bool,
}

impl std::fmt::Display for Change {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let kind = if self.path { "sibling" } else { "REGISTRY" };
        match (&self.old, &self.new) {
            (Some(o), Some(n)) => write!(f, "{kind} {} {o} -> {n}", self.name),
            (Some(o), None) => write!(f, "{kind} {} {o} removed", self.name),
            (None, Some(n)) => write!(f, "{kind} {} {n} added", self.name),
            (None, None) => write!(f, "{kind} {}", self.name),
        }
    }
}

/// Every package version that differs between `old` and `new`. A name whose one
/// version moved is ONE change (old -> new); otherwise each version that left is a
/// removal and each that came an addition.
pub fn lock_diff(old: &[LockPackage], new: &[LockPackage]) -> Vec<Change> {
    let mut names: Vec<&str> = old.iter().chain(new.iter()).map(|p| p.name.as_str()).collect();
    names.sort();
    names.dedup();
    let mut out = Vec::new();
    for name in names {
        let o: Vec<&LockPackage> = old.iter().filter(|p| p.name == name).collect();
        let n: Vec<&LockPackage> = new.iter().filter(|p| p.name == name).collect();
        let gone: Vec<&&LockPackage> = o.iter().filter(|p| !n.iter().any(|q| q.version == p.version && q.source == p.source)).collect();
        let came: Vec<&&LockPackage> = n.iter().filter(|p| !o.iter().any(|q| q.version == p.version && q.source == p.source)).collect();
        // A sibling follow: a path package moved, or a registry copy went away in
        // favour of the path copy of the same name (the lock unified onto the sibling).
        let path_stays = n.iter().any(|p| p.source.is_none());
        if gone.len() == 1 && came.len() == 1 {
            out.push(Change {
                name: name.into(),
                old: Some(gone[0].version.clone()),
                new: Some(came[0].version.clone()),
                path: came[0].source.is_none(),
            });
            continue;
        }
        for g in gone {
            out.push(Change { name: name.into(), old: Some(g.version.clone()), new: None, path: g.source.is_none() || path_stays });
        }
        for c in came {
            out.push(Change { name: name.into(), old: None, new: Some(c.version.clone()), path: c.source.is_none() });
        }
    }
    out
}

/// The committed text of `lock` (HEAD), read in process through nornir-git's gix.
pub fn committed_text(repo: &Path, lock: &Path) -> Option<String> {
    let rel = lock.strip_prefix(repo).ok()?.to_string_lossy().replace('\\', "/");
    let r = nornir_git::gitio::open_for_status(repo).ok()?;
    let spec = format!("HEAD:{rel}");
    let id = r.rev_parse_single(spec.as_str()).ok()?;
    let obj = id.object().ok()?;
    String::from_utf8(obj.data.clone()).ok()
}

/// What `lockfollow` did, or would do, to one lock.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Follow {
    /// `cargo metadata --locked` is already clean.
    Clean,
    /// Followed (or, with `apply = false`, would be): the `cargo update` line run and
    /// every change it made, each a sibling path crate.
    Followed { line: String, changes: Vec<Change> },
    /// Refused: following it would change more than sibling path crates (or cargo
    /// could not resolve it at all). The lock is left as committed.
    Refused { why: String, changes: Vec<Change> },
}

/// **lockfollow for one lock**: make the lock in `dir` follow its sibling checkouts,
/// mechanically and safely. Starting from the committed text, `cargo update -p
/// <name>@<old>` runs ONLY for the sibling (path) crates whose version moved, never a
/// blanket update and never for a registry crate; the result must change nothing but
/// sibling path crates and be `cargo metadata --locked` clean, or the lock is put back
/// and the follow is refused with what would have changed. `apply = false` plans only.
pub fn follow_lock(dir: &Path, committed: &str, apply: bool) -> Follow {
    let lock = dir.join("Cargo.lock");
    let restore = || {
        let _ = std::fs::write(&lock, committed);
    };
    restore();
    if cargo_in(dir, &["metadata", "--locked", "--format-version", "1"]) {
        return Follow::Clean;
    }
    let old = parse_lock(committed);
    // What a fresh, minimal resolution would change. A lock that carries a registry
    // copy beside a sibling's path copy (the note of the check) may not resolve at all
    // once the sibling moved: the registry copy is moved onto the sibling first.
    let drift: Vec<String> = twice(&old, &[])
        .iter()
        .flat_map(|(name, _, _)| {
            old.iter()
                .filter(|p| p.name == *name && p.source.is_some())
                .map(|p| format!("{}@{}", p.name, p.version))
                .collect::<Vec<_>>()
        })
        .collect();
    let mut resolved = cargo_in(dir, &["metadata", "--format-version", "1"]);
    if !resolved && !drift.is_empty() {
        restore();
        let mut a: Vec<String> = vec!["update".into()];
        for d in &drift {
            a.push("-p".into());
            a.push(d.clone());
        }
        let argv: Vec<&str> = a.iter().map(String::as_str).collect();
        resolved = cargo_in(dir, &argv);
    }
    if !resolved {
        restore();
        return Follow::Refused { why: "cargo cannot resolve this lock's workspace at all".into(), changes: Vec::new() };
    }
    let fresh = std::fs::read_to_string(&lock).unwrap_or_default();
    restore();
    let wanted = lock_diff(&old, &parse_lock(&fresh));
    if let Some(bad) = wanted.iter().find(|c| !c.path) {
        return Follow::Refused {
            why: format!("following it would change a registry crate ({bad}); that is not a sibling follow"),
            changes: wanted,
        };
    }
    let mut specs: Vec<String> = wanted.iter().filter_map(|c| c.old.as_ref().map(|o| format!("{}@{o}", c.name))).collect();
    specs.dedup();
    let mut args: Vec<String> = vec!["update".into()];
    for s in &specs {
        args.push("-p".into());
        args.push(s.clone());
    }
    let mut line = if specs.is_empty() {
        // Only the lock's non-package tables moved (a sibling patch now unused):
        // cargo's own minimal rewrite is the follow.
        "cargo metadata --format-version 1".to_string()
    } else {
        format!("cargo {}", args.join(" "))
    };
    let argv: Vec<&str> = if specs.is_empty() {
        vec!["metadata", "--format-version", "1"]
    } else {
        args.iter().map(String::as_str).collect()
    };
    if !cargo_in(dir, &argv) {
        // `cargo update -p <name>@<old>` cannot name a sibling's old version when the
        // workspace has its own crate of that name (nornir's nornir-build-thing 0.1.0
        // beside edda's 0.2.5 -> 0.2.6): cargo no longer loads the old entry. The fresh
        // resolution moved sibling path crates only (checked above), so it is the follow;
        // the registry and --locked checks below still hold it.
        let _ = std::fs::write(&lock, &fresh);
        line = "cargo metadata --format-version 1".to_string();
    }
    let result = std::fs::read_to_string(&lock).unwrap_or_default();
    let made = lock_diff(&old, &parse_lock(&result));
    if let Some(bad) = made.iter().find(|c| !c.path) {
        restore();
        return Follow::Refused { why: format!("`{line}` also changed a registry crate ({bad})"), changes: made };
    }
    if !cargo_in(dir, &["metadata", "--locked", "--format-version", "1"]) {
        restore();
        return Follow::Refused { why: format!("`{line}` did not leave the lock --locked clean"), changes: made };
    }
    if !apply {
        restore();
    }
    Follow::Followed { line: format!("cd {} && {line}", dir.display()), changes: made }
}

/// `cargo xtask ci lockfollow [--check]` over every tracked lock of `repo` (sibling
/// patches on). Returns false when a stale lock was refused.
pub fn follow_repo(repo: &Path, apply: bool, w: &mut dyn Write) -> Result<bool> {
    let on = siblings_on(repo)?;
    let locks: Vec<PathBuf> = locks_of(repo).into_iter().filter(|l| tracked(repo, l)).collect();
    let _ = writeln!(w, "lockfollow: {} ({} locks{}{})", repo.display(), locks.len(), if on { ", sibling patches on" } else { "" }, if apply { "" } else { ", --check: nothing written" });
    let mut ok = true;
    for lock in locks {
        let dir = lock.parent().unwrap_or(repo).to_path_buf();
        // The committed text, never the live file: a `cargo xtask` run has already
        // rewritten the live lock, and following from it would call that "clean".
        let Some(committed) = committed_text(repo, &lock) else {
            let _ = writeln!(w, "  UNREADABLE {}: HEAD's lock could not be read (a linked worktree whose gitdir is not visible here?)", lock.display());
            ok = false;
            continue;
        };
        match follow_lock(&dir, &committed, apply) {
            Follow::Clean => {
                let _ = writeln!(w, "  clean     {}", lock.display());
            }
            Follow::Followed { line, changes } => {
                let _ = writeln!(w, "  {} {}  ({line})", if apply { "FOLLOWED " } else { "would follow" }, lock.display());
                for c in changes {
                    let _ = writeln!(w, "      {c}");
                }
            }
            Follow::Refused { why, changes } => {
                ok = false;
                let _ = writeln!(w, "  REFUSED   {}: {why}", lock.display());
                for c in changes {
                    let _ = writeln!(w, "      {c}");
                }
            }
        }
    }
    Ok(ok)
}

/// Check one lock./// Check one lock.
pub fn check_lock(lock: &Path, single: &[&str]) -> Vec<Finding> {
    let dir = lock.parent().unwrap_or(Path::new("."));
    let mut out = Vec::new();
    let text = match std::fs::read_to_string(lock) {
        Ok(t) => t,
        Err(e) => return vec![Finding::Unreadable { lock: lock.into(), detail: e.to_string() }],
    };
    let run = Command::new(cargo())
        .current_dir(dir)
        .args(["metadata", "--locked", "--format-version", "1", "--manifest-path", "Cargo.toml"])
        .stdout(std::process::Stdio::null())
        .output();
    match run {
        Ok(o) if o.status.success() => {}
        Ok(o) => {
            let fix = match follow_lock(dir, &text, false) {
                Follow::Followed { line, .. } => format!("cargo xtask ci lockfollow (runs: {line})"),
                Follow::Refused { why, .. } => format!("{} (lockfollow refuses: {why})", fix_line(dir, &text)),
                Follow::Clean => String::new(),
            };
            out.push(Finding::Stale { lock: lock.into(), detail: cargo_error(&String::from_utf8_lossy(&o.stderr)), fix });
        }
        Err(e) => out.push(Finding::Unreadable { lock: lock.into(), detail: format!("cargo metadata: {e}") }),
    }
    for (name, versions, refused) in twice(&parse_lock(&text), single) {
        out.push(if refused {
            Finding::Twice { lock: lock.into(), name, versions }
        } else {
            Finding::Drift { lock: lock.into(), name, versions }
        });
    }
    out
}

/// Check every lock of `repo` (sibling patches on), writing one line per lock to `w`.
pub fn check_repo(repo: &Path, single: &[&str], w: &mut dyn Write) -> Result<Vec<Finding>> {
    let on = siblings_on(repo)?;
    let mut all = Vec::new();
    // Only TRACKED locks: an ignored one (holger's xtask/boot, Skidbladnir's root) is
    // the checkout's own business, not a lock anyone ships.
    let locks: Vec<PathBuf> = locks_of(repo).into_iter().filter(|l| tracked(repo, l)).collect();
    // `cargo xtask` itself is a `cargo run`, which rewrites a stale lock before any
    // step runs. So a lock that differs from the committed one IS the finding: the
    // committed lock did not match its manifests and siblings.
    // A checkout whose git state cannot be read (a linked worktree whose gitdir is not
    // visible here, a bare tree) is said so and is red: "clean" must never mean
    // "could not look".
    let changed = match nornir_git::gitio::worktree_changed_paths(repo) {
        Ok(c) => c,
        Err(e) => {
            let _ = writeln!(w, "  UNREADABLE {}: git status could not be read ({e}); no lock here is judged", repo.display());
            return Ok(vec![Finding::Unreadable { lock: repo.join("Cargo.lock"), detail: format!("git status could not be read: {e}") }]);
        }
    };
    for lock in &locks {
        let rel = lock.strip_prefix(repo).map(|p| p.to_string_lossy().into_owned()).unwrap_or_default();
        if changed.contains(&rel) {
            let dir = lock.parent().unwrap_or(repo).display().to_string();
            all.push(Finding::Stale {
                lock: lock.clone(),
                detail: "the lock differs from the committed one (a cargo run in this checkout rewrote it, so the committed lock is stale)".into(),
                fix: format!("cd {dir} && cargo metadata --format-version 1 >/dev/null && git add Cargo.lock (commit the lock cargo resolves)"),
            });
        }
    }
    let _ = writeln!(w, "lockcheck: {} ({} locks{})", repo.display(), locks.len(), if on { ", sibling patches on" } else { "" });
    for x in &all {
        let _ = writeln!(w, "  {x}");
    }
    for lock in locks {
        let f = check_lock(&lock, single);
        if f.is_empty() && !all.iter().any(|x| matches!(x, Finding::Stale { lock: l, .. } if *l == lock)) {
            let _ = writeln!(w, "  ok    {}", lock.display());
        }
        for x in &f {
            let _ = writeln!(w, "  {x}");
        }
        all.extend(f);
    }
    Ok(all)
}

/// The repo whose toolchain pin is the reference: the Build Thing's own.
pub const PIN_REFERENCE_REPO: &str = "edda";

/// A repo's declared toolchain image and pin: `image` and `image_sha256` of the
/// `[build.toolchain]` table in `.nornir/build-variants.toml`, if it declares one.
pub fn toolchain_pin(repo: &Path) -> Option<(String, String)> {
    let text = std::fs::read_to_string(repo.join(".nornir/build-variants.toml")).ok()?;
    let mut inside = false;
    let (mut image, mut pin) = (None, None);
    for line in text.lines() {
        let l = line.trim();
        if l.starts_with('[') {
            inside = l == "[build.toolchain]";
            continue;
        }
        if !inside {
            continue;
        }
        let Some((k, v)) = l.split_once('=') else { continue };
        let v = v.split('#').next().unwrap_or("").trim().trim_matches('"').to_string();
        match k.trim() {
            "image" => image = Some(v),
            "image_sha256" => pin = Some(v),
            _ => {}
        }
    }
    Some((image?, pin?))
}

/// Every repo under `dir` whose pin for the reference repo's image differs from the
/// reference repo's pin. Nothing when the reference repo is absent or pins nothing.
pub fn pin_findings(repos: &[PathBuf], dir: &Path) -> Vec<Finding> {
    let Some((image, reference)) = toolchain_pin(&dir.join(PIN_REFERENCE_REPO)) else { return Vec::new() };
    repos
        .iter()
        .filter_map(|r| {
            let (i, p) = toolchain_pin(r)?;
            (i == image && p != reference).then(|| Finding::PinDiffers {
                repo: r.clone(),
                image: i,
                pin: p,
                reference: reference.clone(),
            })
        })
        .collect()
}

/// The checkouts under `dir` (every subdirectory with a `.git`), or the named ones.
pub fn repos_under(dir: &Path, named: &[String]) -> Vec<PathBuf> {
    if !named.is_empty() {
        return named.iter().map(|n| dir.join(n)).collect();
    }
    let mut v: Vec<PathBuf> = std::fs::read_dir(dir)
        .map(|rd| rd.flatten().map(|e| e.path()).filter(|p| p.join(".git").exists()).collect())
        .unwrap_or_default();
    v.sort();
    v
}

/// The whole constellation under `dir`: every lock of every checkout. Green when no
/// returned finding [is red](Finding::is_red).
pub fn check_constellation(dir: &Path, named: &[String], single: &[&str], w: &mut dyn Write) -> Result<Vec<Finding>> {
    let mut all = Vec::new();
    let repos = repos_under(dir, named);
    for repo in &repos {
        all.extend(check_repo(repo, single, w)?);
    }
    let pins = pin_findings(&repos, dir);
    for p in &pins {
        let _ = writeln!(w, "  {p}");
    }
    all.extend(pins);
    let red = all.iter().filter(|f| f.is_red()).count();
    let _ = writeln!(w, "lockcheck: {red} red finding(s), {} note(s)", all.len() - red);
    Ok(all)
}

/// `--single <crate>` (repeatable) on top of [`DEFAULT_SINGLE`].
pub fn single_from_args(args: &[String]) -> Vec<String> {
    let mut v: Vec<String> = DEFAULT_SINGLE.iter().map(|s| s.to_string()).collect();
    let mut it = args.iter();
    while let Some(a) = it.next() {
        if a == "--single" {
            if let Some(n) = it.next() {
                v.push(n.clone());
            }
        }
    }
    v
}

/// A [`crate::ci::Step`] that lock-checks the repo at `root` itself.
pub fn step(root: &Path) -> crate::ci::Step {
    let root = root.to_path_buf();
    crate::ci::Step::in_process("lockcheck", move |w| {
        let single: Vec<&str> = DEFAULT_SINGLE.to_vec();
        let red: Vec<String> = check_repo(&root, &single, w)?.iter().filter(|f| f.is_red()).map(|f| f.to_string()).collect();
        if red.is_empty() {
            Ok(None)
        } else {
            Err(red.join("; ").into())
        }
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    const LOCK: &str = r#"# This file is automatically @generated by Cargo.
version = 4

[[package]]
name = "holger"
version = "0.9.0"

[[package]]
name = "korp-installer"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab"

[[package]]
name = "korp-installer"
version = "0.2.7"
dependencies = [
 "x",
]

[[package]]
name = "nordisk-pairing"
version = "0.5.26"

[[package]]
name = "nordisk-pairing"
version = "0.5.25"
source = "registry+https://github.com/rust-lang/crates.io-index"

[[package]]
name = "ssh-key"
version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"

[[package]]
name = "ssh-key"
version = "0.7.0-rc.11"
source = "registry+https://github.com/rust-lang/crates.io-index"

[metadata]
"#;

    #[test]
    fn a_lock_is_read_package_by_package() {
        let p = parse_lock(LOCK);
        assert_eq!(p.len(), 7);
        assert_eq!(p[0], LockPackage { name: "holger".into(), version: "0.9.0".into(), source: None });
        assert!(p[1].source.as_deref().unwrap().starts_with("registry+"));
    }

    #[test]
    fn korp_installer_twice_and_a_sibling_beside_its_registry_copy_are_refused() {
        let t = twice(&parse_lock(LOCK), DEFAULT_SINGLE);
        let names: Vec<&str> = t.iter().map(|(n, _, _)| n.as_str()).collect();
        // ssh-key at two REGISTRY versions is cargo's business (two majors may coexist);
        // korp-installer is refused by name; nordisk-pairing because a sibling provides it.
        assert_eq!(names, vec!["korp-installer", "nordisk-pairing"]);
        assert_eq!(t[0].1, vec!["0.2.6".to_string(), "0.2.7".to_string()]);
        assert!(t[0].2, "korp-installer twice is refused");
        assert!(!t[1].2, "a sibling beside its registry copy is a note");
        let named = twice(&parse_lock(LOCK), &["ssh-key"]);
        assert!(named.iter().any(|(n, _, r)| n == "ssh-key" && *r), "a named crate is refused at two versions");
    }

    #[test]
    fn locks_are_found_beside_manifests_and_build_output_and_fixtures_are_skipped() {
        let d = std::env::temp_dir().join(format!("nornir-lockcheck-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&d);
        for sub in ["", "front", "target/debug/x", ".hidden", "xtask/tests/fixtures/cve_vulnerable"] {
            std::fs::create_dir_all(d.join(sub)).unwrap();
            std::fs::write(d.join(sub).join("Cargo.toml"), "").unwrap();
            std::fs::write(d.join(sub).join("Cargo.lock"), "").unwrap();
        }
        std::fs::create_dir_all(d.join("nomanifest")).unwrap();
        std::fs::write(d.join("nomanifest/Cargo.lock"), "").unwrap();
        let got: Vec<PathBuf> = locks_of(&d).into_iter().map(|p| p.strip_prefix(&d).unwrap().to_path_buf()).collect();
        assert_eq!(got, vec![PathBuf::from("Cargo.lock"), PathBuf::from("front/Cargo.lock")]);
        let _ = std::fs::remove_dir_all(&d);
    }

    #[test]
    fn the_sibling_patches_go_on_only_when_every_sibling_is_beside_the_repo() {
        let d = std::env::temp_dir().join(format!("nornir-siblings-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&d);
        let repo = d.join("holger");
        std::fs::create_dir_all(repo.join(".cargo")).unwrap();
        let tpl = "[patch.crates-io]\njera = { path = \"../edda/crates/jera\" }\n";
        std::fs::write(repo.join(".cargo/estate-siblings.toml"), tpl).unwrap();
        assert!(!siblings_on(&repo).unwrap(), "edda is not beside it");
        assert!(!repo.join(".cargo/siblings.toml").exists());
        std::fs::create_dir_all(d.join("edda")).unwrap();
        assert!(siblings_on(&repo).unwrap());
        assert_eq!(std::fs::read_to_string(repo.join(".cargo/siblings.toml")).unwrap(), tpl);
        let _ = std::fs::remove_dir_all(&d);
    }

    #[test]
    fn cargos_error_is_kept_and_its_warnings_dropped() {
        let e = "warning: patch `x v1 (/s/x)` was not used in the crate graph\nhelp: Check that\n    Updating crates.io index\nerror: cannot update the lock file /s/Cargo.lock because --locked was passed\n";
        assert_eq!(cargo_error(e), "error: cannot update the lock file /s/Cargo.lock because --locked was passed");
    }

    #[test]
    fn a_lock_rewritten_after_its_commit_is_stale() {
        let d = std::env::temp_dir().join(format!("nornir-lockcheck-git-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&d);
        std::fs::create_dir_all(&d).unwrap();
        std::fs::write(d.join("Cargo.toml"), "[package]\nname = \"x\"\nversion = \"0.1.0\"\nedition = \"2021\"\n[lib]\npath = \"lib.rs\"\n").unwrap();
        std::fs::write(d.join("lib.rs"), "").unwrap();
        std::fs::write(d.join("Cargo.lock"), "version = 4\n\n[[package]]\nname = \"x\"\nversion = \"0.1.0\"\n").unwrap();
        nornir_git::gitio::init(&d).unwrap();
        nornir_git::gitio::commit_all(&d, "lock").unwrap();
        let mut sink = Vec::new();
        let clean = check_repo(&d, DEFAULT_SINGLE, &mut sink).unwrap();
        assert!(!clean.iter().any(|f| f.is_red()), "{clean:?}");
        std::fs::write(d.join("Cargo.lock"), "version = 4\n\n[[package]]\nname = \"x\"\nversion = \"0.1.0\"\n\n").unwrap();
        std::fs::create_dir_all(d.join("boot")).unwrap();
        std::fs::write(d.join("boot/Cargo.toml"), "").unwrap();
        std::fs::write(d.join("boot/Cargo.lock"), "garbage").unwrap();
        assert!(!tracked(&d, &d.join("boot/Cargo.lock")) && tracked(&d, &d.join("Cargo.lock")));
        let after = check_repo(&d, DEFAULT_SINGLE, &mut sink).unwrap();
        assert!(!after.iter().any(|f| format!("{f}").contains("boot")), "an untracked lock is not checked: {after:?}");
        let committed = committed_text(&d, &d.join("Cargo.lock")).expect("HEAD's lock is read");
        assert!(committed.ends_with("version = \"0.1.0\"\n"), "the committed text, not the rewritten one: {committed:?}");
        assert!(after.iter().any(|f| matches!(f, Finding::Stale { detail, .. } if detail.contains("committed"))), "{after:?}");
        let _ = std::fs::remove_dir_all(&d);
    }

    #[test]
    fn a_repo_pinned_off_the_reference_toolchain_is_red_and_another_image_is_not_compared() {
        let d = std::env::temp_dir().join(format!("nornir-pins-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&d);
        let pin = |repo: &str, image: &str, sha: &str| {
            std::fs::create_dir_all(d.join(repo).join(".nornir")).unwrap();
            std::fs::write(
                d.join(repo).join(".nornir/build-variants.toml"),
                format!("[[build.variant]]\nname = \"x\"\nimage = \"not-this\"\n\n[build.toolchain]\nimage = \"{image}\"\nimage_sha256 = \"{sha}\" # pinned\nrust_version = \"1.97.1\"\n"),
            )
            .unwrap();
        };
        pin("edda", "nordisk-cargo-toolchain", "86d575e8aa");
        pin("holger", "nordisk-cargo-toolchain", "3d5115baaa");
        pin("dwarves", "nordisk-cargo-toolchain", "86d575e8aa");
        pin("facett", "nordisk-pip-toolchain", "1111");
        assert_eq!(toolchain_pin(&d.join("holger")), Some(("nordisk-cargo-toolchain".into(), "3d5115baaa".into())));
        let repos: Vec<PathBuf> = ["dwarves", "edda", "facett", "holger"].iter().map(|r| d.join(r)).collect();
        let f = pin_findings(&repos, &d);
        assert_eq!(f.len(), 1, "{f:?}");
        assert!(f[0].is_red() && f[0].to_string().contains("holger") && f[0].to_string().contains("3d5115baaa"), "{}", f[0]);
        let _ = std::fs::remove_dir_all(&d);
    }

    #[test]
    fn the_packages_a_fresh_resolution_moves_are_named_by_their_old_version() {
        let old = parse_lock(LOCK);
        let mut new = old.clone();
        new.retain(|p| !(p.name == "korp-installer" && p.version == "0.2.6"));
        for p in new.iter_mut().filter(|p| p.name == "ssh-key") {
            p.version = "0.7.0".into();
        }
        new.push(LockPackage { name: "brand-new".into(), version: "1.0.0".into(), source: None });
        let m = moved(&old, &new);
        assert_eq!(m, vec!["korp-installer@0.2.6".to_string(), "ssh-key@0.6.7".to_string(), "ssh-key@0.7.0-rc.11".to_string()]);
    }

    #[test]
    fn a_stale_lock_is_reported_with_the_command_that_fixes_it_and_is_left_as_it_was() {
        let d = std::env::temp_dir().join(format!("nornir-lockfix-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&d);
        std::fs::create_dir_all(d.join("dep")).unwrap();
        std::fs::write(d.join("dep/Cargo.toml"), "[package]\nname = \"dep\"\nversion = \"0.2.0\"\nedition = \"2021\"\n[lib]\npath = \"lib.rs\"\n").unwrap();
        std::fs::write(d.join("dep/lib.rs"), "").unwrap();
        std::fs::write(d.join("Cargo.toml"), "[package]\nname = \"top\"\nversion = \"0.1.0\"\nedition = \"2021\"\n[lib]\npath = \"lib.rs\"\n[dependencies]\ndep = { path = \"dep\" }\n[workspace]\n").unwrap();
        std::fs::write(d.join("lib.rs"), "").unwrap();
        // The lock still names dep 0.1.0: the sibling moved on, the lock did not.
        let stale = "version = 4\n\n[[package]]\nname = \"dep\"\nversion = \"0.1.0\"\n\n[[package]]\nname = \"top\"\nversion = \"0.1.0\"\ndependencies = [\n \"dep\",\n]\n";
        std::fs::write(d.join("Cargo.lock"), stale).unwrap();
        let f = check_lock(&d.join("Cargo.lock"), DEFAULT_SINGLE);
        let fix = f.iter().find_map(|x| match x {
            Finding::Stale { fix, .. } => Some(fix.clone()),
            _ => None,
        });
        let fix = fix.expect("the stale lock is found");
        assert!(fix.starts_with("cargo xtask ci lockfollow") && fix.contains("cargo update -p dep@0.1.0"), "{fix}");
        assert_eq!(std::fs::read_to_string(d.join("Cargo.lock")).unwrap(), stale, "the lock is left as it was");
        let _ = std::fs::remove_dir_all(&d);
    }

    #[test]
    fn a_lock_diff_names_each_move_and_whether_it_is_a_sibling() {
        let old = parse_lock(LOCK);
        let mut new = old.clone();
        for p in new.iter_mut() {
            if p.name == "holger" {
                p.version = "0.9.1".into();
            }
        }
        new.retain(|p| !(p.name == "korp-installer" && p.version == "0.2.6"));
        let d = lock_diff(&old, &new);
        assert_eq!(d.len(), 2, "{d:?}");
        assert_eq!(d[0].to_string(), "sibling holger 0.9.0 -> 0.9.1");
        assert_eq!(d[1].to_string(), "sibling korp-installer 0.2.6 removed", "the registry copy left in favour of the sibling's 0.2.7");
        let mut gone = old.clone();
        gone.retain(|p| p.name != "ssh-key" || p.version != "0.6.7");
        let d = lock_diff(&old, &gone);
        assert_eq!(d[0].to_string(), "REGISTRY ssh-key 0.6.7 removed", "no sibling stays: a registry change");
    }

    fn sibling_fixture(tag: &str, dep_version: &str) -> (PathBuf, String) {
        let d = std::env::temp_dir().join(format!("nornir-lockfollow-{tag}-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&d);
        std::fs::create_dir_all(d.join("dep")).unwrap();
        std::fs::write(d.join("dep/Cargo.toml"), format!("[package]\nname = \"dep\"\nversion = \"{dep_version}\"\nedition = \"2021\"\n[lib]\npath = \"lib.rs\"\n")).unwrap();
        std::fs::write(d.join("dep/lib.rs"), "").unwrap();
        std::fs::write(d.join("Cargo.toml"), "[package]\nname = \"top\"\nversion = \"0.1.0\"\nedition = \"2021\"\n[lib]\npath = \"lib.rs\"\n[dependencies]\ndep = { path = \"dep\" }\n[workspace]\n").unwrap();
        std::fs::write(d.join("lib.rs"), "").unwrap();
        let stale = "version = 4\n\n[[package]]\nname = \"dep\"\nversion = \"0.1.0\"\n\n[[package]]\nname = \"top\"\nversion = \"0.1.0\"\ndependencies = [\n \"dep\",\n]\n".to_string();
        std::fs::write(d.join("Cargo.lock"), &stale).unwrap();
        (d, stale)
    }

    #[test]
    fn lockfollow_moves_only_the_sibling_and_check_writes_nothing() {
        let (d, stale) = sibling_fixture("ok", "0.2.0");
        match follow_lock(&d, &stale, false) {
            Follow::Followed { line, changes } => {
                assert!(line.ends_with("cargo update -p dep@0.1.0"), "{line}");
                assert_eq!(changes.len(), 1);
                assert_eq!(changes[0].to_string(), "sibling dep 0.1.0 -> 0.2.0");
            }
            other => panic!("{other:?}"),
        }
        assert_eq!(std::fs::read_to_string(d.join("Cargo.lock")).unwrap(), stale, "--check writes nothing");
        assert!(matches!(follow_lock(&d, &stale, true), Follow::Followed { .. }));
        assert!(std::fs::read_to_string(d.join("Cargo.lock")).unwrap().contains("version = \"0.2.0\""));
        assert_eq!(follow_lock(&d, &std::fs::read_to_string(d.join("Cargo.lock")).unwrap(), true), Follow::Clean);
        let _ = std::fs::remove_dir_all(&d);
    }

    #[test]
    fn lockfollow_refuses_a_follow_that_would_change_a_registry_crate() {
        let (d, _) = sibling_fixture("reg", "0.2.0");
        // The committed lock names a registry crate nothing depends on any more: a
        // fresh resolution would remove it, which is not a sibling follow.
        let stale = "version = 4\n\n[[package]]\nname = \"dep\"\nversion = \"0.1.0\"\n\n[[package]]\nname = \"itoa\"\nversion = \"1.0.0\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\nchecksum = \"00\"\n\n[[package]]\nname = \"top\"\nversion = \"0.1.0\"\ndependencies = [\n \"dep\",\n]\n";
        std::fs::write(d.join("Cargo.lock"), stale).unwrap();
        match follow_lock(&d, stale, true) {
            Follow::Refused { why, changes } => {
                assert!(why.contains("registry crate") && why.contains("itoa"), "{why}");
                assert!(changes.iter().any(|c| c.name == "itoa" && !c.path));
            }
            other => panic!("{other:?}"),
        }
        assert_eq!(std::fs::read_to_string(d.join("Cargo.lock")).unwrap(), stale, "a refused follow leaves the lock as committed");
        let _ = std::fs::remove_dir_all(&d);
    }

    /// nornir's shape (2026-10-24): the workspace has its OWN crate named like the sibling
    /// (nornir's nornir-build-thing 0.1.0 beside edda's 0.2.5), and the sibling moves
    /// (0.2.5 -> 0.2.6). `cargo update -p nornir-build-thing@0.2.5` cannot name the old
    /// version any more, but the fresh resolution moves the sibling and nothing else: that
    /// IS the follow, not a refusal.
    #[test]
    fn a_sibling_named_like_a_local_crate_is_followed() {
        let d = std::env::temp_dir().join(format!("nornir-lockfollow-samename-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&d);
        let pkg = |dir: &str, name: &str, version: &str, deps: &str| {
            std::fs::create_dir_all(d.join(dir)).unwrap();
            std::fs::write(d.join(dir).join("lib.rs"), "").unwrap();
            std::fs::write(
                d.join(dir).join("Cargo.toml"),
                format!("[package]\nname = \"{name}\"\nversion = \"{version}\"\nedition = \"2021\"\n[lib]\npath = \"lib.rs\"\n[dependencies]\n{deps}"),
            )
            .unwrap();
        };
        pkg("sib", "nbt", "0.2.5", "");
        pkg("ws/local", "nbt", "0.1.0", "up = { package = \"nbt\", path = \"../../sib\" }\n");
        pkg("ws", "top", "0.1.0", "nbt = { path = \"local\" }\n");
        let mut m = std::fs::read_to_string(d.join("ws/Cargo.toml")).unwrap();
        m.push_str("[workspace]\nmembers = [\"local\"]\n");
        std::fs::write(d.join("ws/Cargo.toml"), m).unwrap();
        let ws = d.join("ws");
        assert!(cargo_in(&ws, &["metadata", "--format-version", "1"]), "the fixture resolves");
        let committed = std::fs::read_to_string(ws.join("Cargo.lock")).unwrap();
        assert!(committed.contains("0.2.5") && committed.contains("0.1.0"), "{committed}");
        pkg("sib", "nbt", "0.2.6", "");
        match follow_lock(&ws, &committed, true) {
            Follow::Followed { changes, .. } => assert!(changes.iter().all(|c| c.path), "{changes:?}"),
            other => panic!("the sibling move is a follow: {other:?}"),
        }
        let after = std::fs::read_to_string(ws.join("Cargo.lock")).unwrap();
        assert!(after.contains("0.2.6") && after.contains("0.1.0") && !after.contains("0.2.5"), "{after}");
        assert!(cargo_in(&ws, &["metadata", "--locked", "--format-version", "1"]), "the followed lock is --locked clean");
        let _ = std::fs::remove_dir_all(&d);
    }

    #[test]
    fn the_committed_text_is_read_in_a_linked_worktree_too() {
        let d = std::env::temp_dir().join(format!("nornir-lock-wt-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&d);
        let main = d.join("main");
        std::fs::create_dir_all(&main).unwrap();
        std::fs::write(main.join("Cargo.toml"), "[package]\nname = \"x\"\nversion = \"0.1.0\"\nedition = \"2021\"\n[lib]\npath = \"lib.rs\"\n").unwrap();
        std::fs::write(main.join("lib.rs"), "").unwrap();
        std::fs::write(main.join("Cargo.lock"), "version = 4\n\n[[package]]\nname = \"x\"\nversion = \"0.1.0\"\n").unwrap();
        nornir_git::gitio::init(&main).unwrap();
        nornir_git::gitio::commit_all(&main, "lock").unwrap();
        // The git CLI only lays the fixture (a linked worktree), as a developer does.
        let wt = d.join("wt");
        let ok = std::process::Command::new("git").args(["-C", main.to_str().unwrap(), "worktree", "add", "-q", wt.to_str().unwrap(), "HEAD"]).status().map(|s| s.success()).unwrap_or(false);
        if !ok {
            eprintln!("no git CLI here: the worktree fixture cannot be laid");
            return;
        }
        std::fs::write(wt.join("Cargo.lock"), "version = 4\n\n[[package]]\nname = \"x\"\nversion = \"0.2.0\"\n").unwrap();
        let got = committed_text(&wt, &wt.join("Cargo.lock")).expect("HEAD's lock is read through the worktree");
        assert!(got.contains("0.1.0") && !got.contains("0.2.0"), "the committed text, not the live file: {got:?}");
        let _ = std::fs::remove_dir_all(&d);
    }

    #[test]
    fn a_checkout_whose_git_cannot_be_read_is_said_unreadable_never_clean() {
        let d = std::env::temp_dir().join(format!("nornir-lock-nogit-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&d);
        std::fs::create_dir_all(&d).unwrap();
        std::fs::write(d.join("Cargo.toml"), "[package]\nname = \"x\"\nversion = \"0.1.0\"\nedition = \"2021\"\n[lib]\npath = \"lib.rs\"\n").unwrap();
        std::fs::write(d.join("lib.rs"), "").unwrap();
        std::fs::write(d.join("Cargo.lock"), "version = 4\n\n[[package]]\nname = \"x\"\nversion = \"0.1.0\"\n").unwrap();
        // A worktree's gitfile pointing at a gitdir that is not here.
        std::fs::write(d.join(".git"), "gitdir: /nonexistent/repo/.git/worktrees/x\n").unwrap();
        let mut sink = Vec::new();
        let f = check_repo(&d, DEFAULT_SINGLE, &mut sink).unwrap();
        assert!(f.iter().any(|x| matches!(x, Finding::Unreadable { .. })) && f.iter().all(|x| x.is_red()), "{f:?}");
        assert!(!follow_repo(&d, false, &mut sink).unwrap(), "lockfollow is not ok either");
        let said = String::from_utf8_lossy(&sink);
        assert!(said.contains("UNREADABLE") && !said.contains("clean"), "{said}");
        let _ = std::fs::remove_dir_all(&d);
    }

    #[test]
    fn single_names_add_to_the_default() {
        let a: Vec<String> = ["--single", "ssh-key", "x"].iter().map(|s| s.to_string()).collect();
        assert_eq!(single_from_args(&a), vec!["korp-installer".to_string(), "ssh-key".to_string()]);
    }
}