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

nordisk/edda

crates/nornir-git/src/fleet.rs

//! # fleet — WHICH REF IS EACH CHECKOUT AT, and is anything unpushed?
//!
//! The question this module answers is the one asked most often and, until now,
//! answered only by a shell script: *"är allt commitat och pushat till main?"*
//!
//! ## Why it is a library and not a script
//!
//! `workspace_nordisk/.nornir/fleet-refs.sh` has answered it since 2026-08-16 and
//! answers it well — but it is a shell script, no Rust verb dispatches to it, it
//! is named in no `CLAUDE.md`, and the constellation's own law is *no shelling*.
//! A question asked in 14 of 180 typed messages over five days deserves a verb.
//!
//! Every primitive was already here ([`crate::gitops::is_ancestor`],
//! [`crate::gitio::porcelain_status_in`], the ref store); this module is the
//! wiring, and it is the ONE writer of the verdict so a reader and a guard cannot
//! disagree about what "clean" means.
//!
//! ## The four axes, and why the fourth is not the third
//!
//! * `dirty` — uncommitted work in the tree (`git status --porcelain`).
//! * `behind` — the default ref has commits this checkout has not got.
//! * `ahead` — this checkout has commits the default ref has not got.
//! * `at_risk` — the ahead commits that **no remote ref anywhere contains**.
//!
//! `ahead` and `at_risk` are DIFFERENT facts and conflating them is the mistake
//! `fleet-refs.sh:131-137` writes out by hand: work pushed to a side branch shows
//! as `ahead` of `origin/main` and is perfectly safe, because a remote holds it.
//! Only "no remote has this commit" is the shape that loses work when a disk
//! dies. A row is [`FleetRow::fit`] when it is on its default ref, not behind,
//! not dirty and carries nothing at risk.
//!
//! ## What it does NOT do
//!
//! It does not fetch. Every verdict is against the remote-tracking refs as they
//! stand on disk, so a stale `origin/main` yields a stale `behind` — the caller
//! fetches first if it wants a fresh answer, and [`FleetRow::default_ref`] names
//! the ref that was compared against so the reader can tell.

use std::collections::HashSet;
use std::path::{Path, PathBuf};

use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize};

use crate::gitio;

/// Upper bound on a single ancestry walk. A constellation repo is ~2 000 commits;
/// this is generous enough never to bite in practice and stops one pathological
/// history from turning a fleet sweep into a hang. When it fires the row says so
/// through [`FleetRow::truncated`] rather than quietly reporting a smaller number
/// — a bounded walk that lies about being complete is exactly the silent-green
/// shape this constellation keeps finding.
pub const MAX_WALK: usize = 50_000;

/// One checkout's answer to "is everything committed and pushed?".
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FleetRow {
    /// Directory name (`dwarves`, `wt-recur-edda`), for the table's first column.
    pub name: String,
    /// Absolute path of the checkout.
    pub path: PathBuf,
    /// Checked-out branch's short name. `None` ⇒ detached HEAD.
    pub branch: Option<String>,
    /// Full `HEAD` commit sha, empty on an unborn branch.
    pub head_sha: String,
    /// The remote-tracking ref every count below is measured against, short form
    /// (`origin/main`, `origin/master`). `None` ⇒ no remote default could be
    /// resolved, and `ahead`/`behind` are then both 0 and MEANINGLESS — read
    /// this field before believing them.
    pub default_ref: Option<String>,
    /// Commits reachable from `HEAD` but not from [`Self::default_ref`].
    pub ahead: usize,
    /// Commits reachable from [`Self::default_ref`] but not from `HEAD`.
    pub behind: usize,
    /// Number of `git status --porcelain` entries (staged + modified + untracked).
    pub dirty: usize,
    /// The subset of the ahead commits that NO ref under `refs/remotes/` contains
    /// — the work that exists only on this disk. Short shas, newest first.
    pub at_risk: Vec<String>,
    /// An ancestry walk hit [`MAX_WALK`], so the counts above are lower bounds.
    pub truncated: bool,
    /// This checkout could not be read at all (not a repo, corrupt refs). Every
    /// count is 0 and means nothing; the row exists so a broken checkout is
    /// REPORTED rather than skipped into invisibility.
    pub error: Option<String>,
}

impl FleetRow {
    /// `true` when nothing about this checkout needs a human: it sits on its
    /// remote default ref, is level with it, has a clean tree and holds no commit
    /// that only this disk has.
    ///
    /// A row with an [`error`](Self::error), or one whose
    /// [`default_ref`](Self::default_ref) could not be resolved, is NEVER fit —
    /// "I could not tell" must not read as "all good".
    pub fn fit(&self) -> bool {
        if self.error.is_some() || self.default_ref.is_none() {
            return false;
        }
        let on_default = match (&self.branch, &self.default_ref) {
            (Some(b), Some(d)) => d.rsplit_once('/').map(|(_, n)| n) == Some(b.as_str()),
            _ => false,
        };
        on_default && self.behind == 0 && self.dirty == 0 && self.at_risk.is_empty()
    }

    /// One line, fixed columns — the shape a human scans down.
    pub fn line(&self) -> String {
        let branch = self.branch.clone().unwrap_or_else(|| "DETACHED".into());
        let def = self.default_ref.clone().unwrap_or_else(|| "?".into());
        let risk = if self.at_risk.is_empty() {
            String::new()
        } else {
            format!("  AT-RISK {} ({})", self.at_risk.len(), self.at_risk.join(","))
        };
        let err = match &self.error {
            Some(e) => format!("  ERROR {e}"),
            None => String::new(),
        };
        format!(
            "{}{:<22} {:<32} def={:<16} +{}/-{} dirty={}{}{}",
            if self.fit() { "  " } else { "← " },
            self.name,
            branch,
            def,
            self.ahead,
            self.behind,
            self.dirty,
            risk,
            err,
        )
    }
}

/// Every checkout directly under `root` — every entry that HAS a `.git`, file or
/// directory.
///
/// `.git` is tested with `exists()`, not `is_dir()`: a linked worktree's `.git`
/// is a FILE, and testing for a directory hid 36 of oden's 60 checkouts when
/// `fleet-refs.sh` first shipped (its `:87-88` records the same bug). A sweep
/// that silently skips two thirds of the fleet is worse than no sweep.
///
/// Rows come back sorted by name. Reading one checkout never fails the sweep —
/// its failure lands in [`FleetRow::error`].
pub fn survey_root(root: &Path) -> Result<Vec<FleetRow>> {
    let mut rows = Vec::new();
    let entries = std::fs::read_dir(root)
        .with_context(|| format!("read fleet root {}", root.display()))?;
    for e in entries {
        let e = match e {
            Ok(e) => e,
            Err(_) => continue,
        };
        let p = e.path();
        if !p.is_dir() {
            continue;
        }
        if !p.join(".git").exists() {
            continue;
        }
        rows.push(survey_one(&p));
    }
    rows.sort_by(|a, b| a.name.cmp(&b.name));
    Ok(rows)
}

/// One checkout. Never returns `Err`: an unreadable checkout comes back as a row
/// carrying [`FleetRow::error`], because a sweep that drops the repos it could
/// not read reports a cleaner fleet than it measured.
pub fn survey_one(path: &Path) -> FleetRow {
    let name = path
        .file_name()
        .map(|s| s.to_string_lossy().into_owned())
        .unwrap_or_else(|| path.display().to_string());
    let mut row = FleetRow {
        name,
        path: path.to_path_buf(),
        branch: None,
        head_sha: String::new(),
        default_ref: None,
        ahead: 0,
        behind: 0,
        dirty: 0,
        at_risk: Vec::new(),
        truncated: false,
        error: None,
    };
    if let Err(e) = fill(path, &mut row) {
        row.error = Some(e.to_string());
    }
    row
}

fn fill(path: &Path, row: &mut FleetRow) -> Result<()> {
    let repo = gix::open(path).with_context(|| format!("gix::open {}", path.display()))?;

    // HEAD — branch name and sha. An unborn branch (fresh `git init`) has a
    // referent but no commit; that is not an error, it is a repo with nothing in
    // it, and it must not be reported as fit.
    let mut head = repo.head().context("read HEAD")?;
    row.branch = head.referent_name().map(|n| n.shorten().to_string());
    row.head_sha = match head.peel_to_commit() {
        Ok(c) => c.id().to_string(),
        Err(_) => String::new(),
    };

    // Dirty count — the porcelain entries, through the SAME reader every other
    // caller uses so "dirty" has one definition in this crate.
    row.dirty = gitio::porcelain_status_in(&repo)
        .map(|(_, entries)| entries.len())
        .unwrap_or(0);

    // The remote default. `origin/HEAD` is the truth when it exists — asking the
    // repo beats assuming `main`, and znippy (master) vs znippy-zoomies (main)
    // is exactly why CLAUDE.md says "ask the repo, not this file".
    let default_full = resolve_default_ref(&repo);
    row.default_ref = default_full
        .as_ref()
        .map(|f| f.trim_start_matches("refs/remotes/").to_string());

    let (Some(def), false) = (&default_full, row.head_sha.is_empty()) else {
        return Ok(());
    };
    let Some(def_id) = repo.rev_parse_single(def.as_str()).ok().map(|i| i.detach()) else {
        row.default_ref = None;
        return Ok(());
    };
    let head_id = repo
        .rev_parse_single("HEAD")
        .map_err(|e| anyhow!("rev-parse HEAD: {e}"))?
        .detach();

    let (head_set, t1) = ancestors_of(&repo, head_id)?;
    let (def_set, t2) = ancestors_of(&repo, def_id)?;
    row.truncated = t1 || t2;

    // Ahead in COMMIT ORDER (newest first), because the shas are printed and a
    // reader wants the tip first. `ancestors_of` walks newest-first already.
    let ahead: Vec<gix::ObjectId> = walk_ordered(&repo, head_id)?
        .into_iter()
        .filter(|o| !def_set.contains(o))
        .collect();
    row.ahead = ahead.len();
    row.behind = def_set.iter().filter(|o| !head_set.contains(*o)).count();

    row.at_risk = commits_no_remote_holds(&repo, &ahead)?
        .iter()
        .map(|o| o.to_string().chars().take(7).collect())
        .collect();
    Ok(())
}

/// The remote-tracking ref this checkout should be measured against, as a full
/// ref name. `refs/remotes/origin/HEAD` first (the repo's own answer), then the
/// two conventional tips — in that order, so a repo whose default is `master`
/// (znippy) is never judged against a stale `main` that also exists.
fn resolve_default_ref(repo: &gix::Repository) -> Option<String> {
    if let Ok(r) = repo.find_reference("refs/remotes/origin/HEAD") {
        if let gix::refs::TargetRef::Symbolic(name) = r.target() {
            return Some(name.as_bstr().to_string());
        }
    }
    for cand in ["refs/remotes/origin/main", "refs/remotes/origin/master"] {
        if repo.find_reference(cand).is_ok() {
            return Some(cand.to_string());
        }
    }
    None
}

fn walk_ordered(repo: &gix::Repository, tip: gix::ObjectId) -> Result<Vec<gix::ObjectId>> {
    let mut out = Vec::new();
    let platform = repo
        .find_object(tip)
        .context("find tip")?
        .try_into_commit()
        .context("tip is not a commit")?
        .id()
        .ancestors()
        .all()
        .context("ancestry walk")?;
    for info in platform {
        let info = info.context("read commit")?;
        out.push(info.id);
        if out.len() >= MAX_WALK {
            break;
        }
    }
    Ok(out)
}

fn ancestors_of(
    repo: &gix::Repository,
    tip: gix::ObjectId,
) -> Result<(HashSet<gix::ObjectId>, bool)> {
    let v = walk_ordered(repo, tip)?;
    let truncated = v.len() >= MAX_WALK;
    Ok((v.into_iter().collect(), truncated))
}

/// Of `candidates`, the ones no ref under `refs/remotes/` reaches.
///
/// Short-circuits hard: with nothing ahead it walks nothing at all, and it stops
/// the moment every candidate has been accounted for — which is why this is
/// affordable per checkout across a whole fleet.
fn commits_no_remote_holds(
    repo: &gix::Repository,
    candidates: &[gix::ObjectId],
) -> Result<Vec<gix::ObjectId>> {
    if candidates.is_empty() {
        return Ok(Vec::new());
    }
    let mut remaining: Vec<gix::ObjectId> = candidates.to_vec();
    let refs = repo.references().map_err(|e| anyhow!("open ref store: {e}"))?;
    let iter = refs
        .prefixed(gix::bstr::BStr::new("refs/remotes/"))
        .map_err(|e| anyhow!("iterate remote refs: {e}"))?;
    for r in iter {
        let Ok(mut r) = r else { continue };
        let Ok(tip) = r.peel_to_id() else {
            continue;
        };
        let Ok((set, _)) = ancestors_of(repo, tip.detach()) else {
            continue;
        };
        remaining.retain(|o| !set.contains(o));
        if remaining.is_empty() {
            break;
        }
    }
    Ok(remaining)
}

/// The whole fleet in one string: a row per checkout, then a verdict line naming
/// the ratio. The ratio is the point — "12 of 19 fit" is an answer, "12 fit" is
/// a number.
pub fn report(rows: &[FleetRow]) -> String {
    let fit = rows.iter().filter(|r| r.fit()).count();
    let mut s = String::new();
    for r in rows {
        s.push_str(&r.line());
        s.push('\n');
    }
    let risk: usize = rows.iter().map(|r| r.at_risk.len()).sum();
    s.push_str(&format!(
        "── {fit} of {} checkouts FIT (on default ref · not behind · clean · nothing at risk); \
         {risk} commit(s) exist on this disk only\n",
        rows.len()
    ));
    s
}

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

    /// Build a repo with one commit on `main` and an `origin/main` remote-tracking
    /// ref pointing at it — the shape a freshly-pulled clean checkout has.
    fn clean_repo(dir: &Path) -> String {
        gitio::init(dir).unwrap();
        fs::write(dir.join("a.txt"), "one").unwrap();
        let sha = gitio::commit_all(dir, "one").unwrap();
        // The default branch `gitio::init` creates is `main`; mirror it as the
        // remote-tracking ref so there is something to be measured against.
        crate::gitops::update_ref(dir, "refs/remotes/origin/main", &sha).unwrap();
        sha
    }

    #[test]
    fn a_clean_checkout_level_with_its_remote_is_fit() {
        let td = tempfile::tempdir().unwrap();
        let d = td.path().join("repo");
        fs::create_dir(&d).unwrap();
        clean_repo(&d);
        let row = survey_one(&d);
        assert_eq!(row.error, None, "{row:?}");
        assert_eq!(row.branch.as_deref(), Some("main"), "{row:?}");
        assert_eq!(row.default_ref.as_deref(), Some("origin/main"), "{row:?}");
        assert_eq!((row.ahead, row.behind, row.dirty), (0, 0, 0), "{row:?}");
        assert!(row.at_risk.is_empty(), "{row:?}");
        assert!(row.fit(), "a clean, level, on-default checkout must be FIT: {row:?}");
    }

    /// The whole point of the verb. An unpushed commit is `ahead` AND `at_risk`,
    /// and the row is not fit. This asserts the APPLIED OUTPUT — the sha the
    /// reader is shown — not merely that some counter moved.
    #[test]
    fn a_commit_no_remote_holds_is_reported_at_risk_by_its_own_sha() {
        let td = tempfile::tempdir().unwrap();
        let d = td.path().join("repo");
        fs::create_dir(&d).unwrap();
        clean_repo(&d);
        fs::write(d.join("b.txt"), "two").unwrap();
        let unpushed = gitio::commit_all(&d, "two").unwrap();

        let row = survey_one(&d);
        assert_eq!(row.ahead, 1, "{row:?}");
        assert_eq!(row.behind, 0, "{row:?}");
        let short: String = unpushed.chars().take(7).collect();
        assert_eq!(row.at_risk, vec![short.clone()], "{row:?}");
        assert!(!row.fit(), "an unpushed commit must not read as fit: {row:?}");
        assert!(
            row.line().contains(&short) && row.line().contains("AT-RISK"),
            "the sha a human has to act on must be ON the line: {}",
            row.line()
        );
    }

    /// AHEAD IS NOT AT-RISK. Same commit, same `ahead` count — but once ANY remote
    /// ref holds it, the work is safe and the row must say so. This is the
    /// distinction `fleet-refs.sh` had to write out by hand, and the one a naive
    /// `@{u}..HEAD` implementation gets wrong; without this test the module could
    /// report every side-branch push as data loss and still look green.
    #[test]
    fn a_commit_pushed_to_a_side_branch_is_ahead_but_not_at_risk() {
        let td = tempfile::tempdir().unwrap();
        let d = td.path().join("repo");
        fs::create_dir(&d).unwrap();
        clean_repo(&d);
        fs::write(d.join("b.txt"), "two").unwrap();
        let sha = gitio::commit_all(&d, "two").unwrap();
        // Somebody pushed it to a side branch: a remote ref now holds it.
        crate::gitops::update_ref(&d, "refs/remotes/origin/lane/side", &sha).unwrap();

        let row = survey_one(&d);
        assert_eq!(row.ahead, 1, "still ahead of origin/main: {row:?}");
        assert!(
            row.at_risk.is_empty(),
            "a remote holds this commit — it is not at risk: {row:?}"
        );
    }

    /// A dirty tree is not fit even when the history is perfect.
    #[test]
    fn an_uncommitted_file_alone_makes_a_checkout_unfit() {
        let td = tempfile::tempdir().unwrap();
        let d = td.path().join("repo");
        fs::create_dir(&d).unwrap();
        clean_repo(&d);
        fs::write(d.join("scratch.txt"), "not committed").unwrap();

        let row = survey_one(&d);
        assert_eq!((row.ahead, row.behind), (0, 0), "{row:?}");
        assert!(row.dirty >= 1, "the untracked file must count: {row:?}");
        assert!(!row.fit(), "{row:?}");
    }

    /// Behind is measured, and it is a different axis from ahead.
    #[test]
    fn a_checkout_whose_remote_moved_on_reports_behind_not_ahead() {
        let td = tempfile::tempdir().unwrap();
        let d = td.path().join("repo");
        fs::create_dir(&d).unwrap();
        clean_repo(&d);
        // Advance the branch, mirror it to the remote, then move the local branch
        // back — the shape of a checkout that has not pulled.
        let base = gitio::head_sha(&d).unwrap();
        fs::write(d.join("b.txt"), "two").unwrap();
        let ahead_sha = gitio::commit_all(&d, "two").unwrap();
        crate::gitops::update_ref(&d, "refs/remotes/origin/main", &ahead_sha).unwrap();
        crate::gitops::update_ref(&d, "refs/heads/main", &base).unwrap();

        let row = survey_one(&d);
        assert_eq!(row.behind, 1, "{row:?}");
        assert_eq!(row.ahead, 0, "{row:?}");
        assert!(row.at_risk.is_empty(), "{row:?}");
        assert!(!row.fit(), "behind is not fit — a stale checkout gives stale verdicts: {row:?}");
    }

    /// Parked on a side branch: not on the default ref ⇒ not fit, even with a
    /// clean tree and nothing at risk. This is the trap that produced four
    /// phantom findings in one session — a verdict read at the wrong ref.
    #[test]
    fn a_checkout_parked_off_its_default_branch_is_not_fit() {
        let td = tempfile::tempdir().unwrap();
        let d = td.path().join("repo");
        fs::create_dir(&d).unwrap();
        clean_repo(&d);
        crate::gitops::create_or_reset_branch_at_head(&d, "lane/parked").unwrap();

        let row = survey_one(&d);
        assert_eq!(row.branch.as_deref(), Some("lane/parked"), "{row:?}");
        assert_eq!((row.ahead, row.behind, row.dirty), (0, 0, 0), "{row:?}");
        assert!(
            !row.fit(),
            "level and clean but PARKED — the checkout is not where a reader assumes: {row:?}"
        );
    }

    /// A repo with no remote at all cannot be judged, and "I could not tell" must
    /// not render as fit. The RED control for `fit()`'s unknown arm.
    #[test]
    fn a_repo_with_no_remote_ref_is_never_reported_fit() {
        let td = tempfile::tempdir().unwrap();
        let d = td.path().join("repo");
        fs::create_dir(&d).unwrap();
        gitio::init(&d).unwrap();
        fs::write(d.join("a.txt"), "one").unwrap();
        gitio::commit_all(&d, "one").unwrap();

        let row = survey_one(&d);
        assert_eq!(row.default_ref, None, "{row:?}");
        assert!(!row.fit(), "unknown must not read as clean: {row:?}");
    }

    /// The sweep must see a LINKED WORKTREE, whose `.git` is a FILE. Testing
    /// `.git` with `is_dir()` hid 36 of oden's 60 checkouts when the shell script
    /// first shipped; this is the guard that stops the Rust port re-inheriting it.
    #[test]
    fn the_sweep_counts_a_worktree_whose_dot_git_is_a_file() {
        let td = tempfile::tempdir().unwrap();
        let root = td.path();
        let d = root.join("repo");
        fs::create_dir(&d).unwrap();
        clean_repo(&d);
        // A linked worktree is exactly "a directory holding a .git FILE".
        let wt = root.join("repo-wt");
        fs::create_dir(&wt).unwrap();
        fs::write(wt.join(".git"), format!("gitdir: {}\n", d.join(".git").display())).unwrap();

        let rows = survey_root(root).unwrap();
        let names: Vec<&str> = rows.iter().map(|r| r.name.as_str()).collect();
        assert!(
            names.contains(&"repo-wt"),
            "a .git FILE is a checkout too — got {names:?}"
        );
        assert!(names.contains(&"repo"), "got {names:?}");
    }

    /// A directory that is NOT a checkout must not appear — the non-vacuity floor
    /// for the test above, so "counts the worktree" cannot be satisfied by a sweep
    /// that simply lists every directory.
    fn _doc() {}
    #[test]
    fn the_sweep_does_not_list_a_plain_directory() {
        let td = tempfile::tempdir().unwrap();
        let root = td.path();
        let d = root.join("repo");
        fs::create_dir(&d).unwrap();
        clean_repo(&d);
        fs::create_dir(root.join("just-a-folder")).unwrap();

        let rows = survey_root(root).unwrap();
        let names: Vec<&str> = rows.iter().map(|r| r.name.as_str()).collect();
        assert!(!names.contains(&"just-a-folder"), "got {names:?}");
        assert_eq!(names, vec!["repo"], "got {names:?}");
    }

    /// The report's verdict is a RATIO, and it counts what the rows say.
    #[test]
    fn the_report_line_states_the_ratio_and_the_at_risk_total() {
        let td = tempfile::tempdir().unwrap();
        let root = td.path();
        for n in ["clean", "dirty"] {
            let d = root.join(n);
            fs::create_dir(&d).unwrap();
            clean_repo(&d);
        }
        fs::write(root.join("dirty").join("x.txt"), "loose").unwrap();
        let d = root.join("unpushed");
        fs::create_dir(&d).unwrap();
        clean_repo(&d);
        fs::write(d.join("b.txt"), "two").unwrap();
        gitio::commit_all(&d, "two").unwrap();

        let rows = survey_root(root).unwrap();
        let text = report(&rows);
        assert!(text.contains("1 of 3 checkouts FIT"), "{text}");
        assert!(text.contains("1 commit(s) exist on this disk only"), "{text}");
    }
}