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}");
}
}