Working-tree freshness (AUT6 / funnel n-003)
The problem a SHA cannot solve
index_status already reports each git member's last_seen_sha. A commit SHA identifies committed state. It cannot detect uncommitted edits: two different working trees can sit on the exact same HEAD. So a thin client that only compares SHAs can think the cached warehouse is fresh while the live tree has unstaged/uncommitted changes the index never saw.
The freshness signal adds a working-tree content digest that does move when the tree changes — even with no commit.
What we compute
nornir::gitio::worktree_freshness(root) -> WorktreeFreshness { dirty, digest }
-
Computed in-process via
gix(thestatusfeature) — no shelling out to thegitbinary, matching nornir's pure-Rust ethos. -
It runs gix's status: tree↔index (staged), index↔worktree (tracked modifications), and the dirwalk for untracked files.
-
The digest is a SHA-256 over the sorted porcelain status. Each entry is one line:
<tag> <rela-path> <size>:<mtime_nanos>
where <tag> is S staged, M modified, U untracked, R rewrite, and the size:mtime come from stat-ing the on-disk file (so a content edit moves the digest even when the set of changed paths is unchanged). Missing/deleted files contribute -.
- A clean tree (no staged, no modified, no untracked) hashes to the sentinel
CLEAN_WORKTREE_DIGEST = "clean"anddirty = false. Two clean checkouts of the same commit therefore always agree → no false "stale".
Where it surfaces
Authority gate (always fresh)
The release-blocking gate runs against the working tree, so it is always fresh by construction (it reads the live files, not the index).
Per-member status (the staleness badge)
registry::MemberState gains worktree_digest + worktree_dirty. The monitor poll loop (fetch_workspace) computes them for the server's clone right after a successful fetch and persists them in the registry redb (best-effort: a digest error never fails the fetch). They flow out over gRPC (WorkspaceMember.worktree_digest / worktree_dirty).
The index_status MCP tool reports, per member:
worktree_digest/worktree_dirty— the server clone's indexed freshness.local_worktree_digest/local_worktree_dirty— when a local source for that member is reachable (resolved from the localnornir-workspace.toml), the live digest of the caller's own checkout.-
staleness— an advisory signal:fresh— local tree agrees / clean.local_uncommitted— the local tree differs from the indexed digest: "indexed @ , local working tree has uncommitted changes — advisory".server_dirty— no local source, but the server's clone is dirty (a remote thin client trusts the server's clean/dirty report).unknown— never computed.
A remote thin client with no local source simply trusts the server's clean/dirty (it cannot diff a tree it does not have).
Viz
WorkspaceInfo.freshness carries (member, dirty, digest). The ℹ info panel renders a ⚠ uncommitted marker next to a dirty member. WorkspacePanel exposes a state_json() ({ member: { dirty, digest } } + any_dirty) folded into the app's top-level state_json under workspace_freshness, so the headless matrix asserts the signal without scraping pixels (LAW #6).
Tests (inject-assert, LAW 1)
src/gitio.rs :: freshness_tests:
worktree_edit_changes_digest_and_dirty_but_not_head_sha— build a real one- file git repo (pure-gix fixture), compute the digest clean; modify a tracked file and assert (a) the digest changes, (b)clean → dirtyflips, and crucially (c)head_shadoes NOT move — the whole point: the SHA cannot see the uncommitted edit, only the digest can. Then add an untracked file and assert the digest changes again, still no HEAD movement.clean_digest_is_stable_and_matches_sentinel— two reads of an untouched tree agree and equal thecleansentinel (so a thin client never reports a false "stale").
Flow (plain text, no mermaid)
server clone ──(monitor fetch)──► gix status ──► (digest, dirty)
│
▼
registry.redb (MemberState)
│ gRPC
▼
thin client ──(local source?)──► local gix status ──► compare ──► staleness
│ no local source (fresh /
▼ local_uncommitted /
trust server's clean/dirty ────────────────────────────────────► server_dirty)