diff --git a/.bashrc b/.bashrc index 22dd0dd..8de91f1 100644 --- a/.bashrc +++ b/.bashrc @@ -291,6 +291,23 @@ git-health() { echo " $clean clean, $dirty dirty, $ahead with unpushed commits" } +# ═══════════════════════════════════════════════════ +# Stranded work — finished work that is going nowhere +# ═══════════════════════════════════════════════════ +# git-health above answers "what is dirty", which is every repo someone is +# working in. That number was 118 for orangecat every day for ten days and +# changed nobody's behaviour, because a count that is equally true at minute +# five and at day ten reads as noise. +# +# This one only speaks when work has AGED past a threshold, and says nothing +# otherwise. It prints from cache so it never costs a prompt, and refreshes in +# the background when that cache goes stale. +fleet-stranded() { bash "$HOME/dev/dotfiles/scripts/fleet/stranded-work.sh" "${1:-}"; } + +if [ -z "${BASH_EXECUTION_STRING:-}" ] && [ -f "$HOME/dev/dotfiles/scripts/fleet/stranded-work.sh" ]; then + bash "$HOME/dev/dotfiles/scripts/fleet/stranded-work.sh" --shell 2>/dev/null +fi + # ═══════════════════════════════════════════════════ # Zellij Auto-Attach # ═══════════════════════════════════════════════════ @@ -304,7 +321,7 @@ if [ -z "${ZELLIJ:-}" ] && [ -z "${BASH_EXECUTION_STRING:-}" ] && command -v zel if zellij list-sessions -ns 2>/dev/null | grep -q .; then zellij attach else - zellij -l projects + zellij fi fi diff --git a/.config/git/ignore b/.config/git/ignore index 3865b18..3f86ce2 100644 --- a/.config/git/ignore +++ b/.config/git/ignore @@ -1,2 +1,13 @@ +# Global ignores — for scratch that MY tooling creates in every repo I touch. +# +# The test for belonging here rather than in a project's .gitignore: would a +# different contributor, on a machine without my setup, ever see this path? If +# not, it is not the project's business. Putting it in 18 repos' .gitignore +# instead means 18 diffs, 18 reviews, and a 19th repo that gets missed. **/.claude/settings.local.json .envrc + +# Playwright MCP writes here whenever an agent drives a browser. It appeared in +# 9 repos this way and left 2 of them permanently dirty — and a permanently +# dirty tree is what lets genuinely stranded work hide in the noise. +.playwright-mcp/ diff --git a/.config/zellij/layouts/projects.kdl b/.config/zellij/layouts/projects.kdl deleted file mode 100644 index 8dfa9d1..0000000 --- a/.config/zellij/layouts/projects.kdl +++ /dev/null @@ -1,44 +0,0 @@ -// Project workspace layout — shell panes that survive restart. -// Each tab opens a shell in the project directory. -// Type 'codex' or 'claude' to start an agent in any project tab. -// Type 'cockpit' in the Cockpit tab to start the web UI. - -layout { - default_tab_template { - pane size=1 borderless=true { - plugin location="zellij:tab-bar" - } - children - pane size=1 borderless=true { - plugin location="zellij:status-bar" - } - } - - tab name="Revamp-Info" { - pane cwd="/home/g/dev/revamp-info" - } - tab name="evig" { - pane cwd="/home/g/dev/evig" - } - tab name="AOZ" { - pane cwd="/home/g/dev/aoz-housing" - } - tab name="OC" { - pane cwd="/home/g/dev/orangecat" - } - tab name="Kivvi" { - pane cwd="/home/g/dev/kivvi" - } - tab name="OpenClaw" { - pane cwd="/home/g/dev/openclaw" - } - tab name="Cockpit" { - pane cwd="/home/g/dev/cockpit" - } - tab name="Dotfiles" { - pane cwd="/home/g/dev/dotfiles" - } - tab name="Scratch" focus=true { - pane cwd="/home/g" - } -} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13d6708..e6f857e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,6 +82,14 @@ jobs: - name: The duplication ratchet can still go red run: bash scripts/ci/test-shared-inventory.sh + # The stranded-work guard replaces a check that failed by being ignorable: + # git-health reported orangecat's 118 dirty files every day for ten days + # and changed nothing. This one keys on AGE and stays silent when healthy, + # so both failure modes are gates — it must go red on aged work, and it + # must stay quiet on a fresh tree, or it gets muted and is then absent. + - name: The stranded-work guard can go red, and stays quiet when it should + run: bash scripts/fleet/test-stranded-work.sh + # The sweep decides what ships in every repo that calls it, so it is the # last script here that should be untested — and until now it was. These # run the REAL script against a fake `gh`, exercising shipped control flow diff --git a/.gitignore b/.gitignore index b636136..6313533 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ __pycache__/ *.pyc .claude/hooks/dangerous-commands.log .claude/hooks/fleetcrown-capture.sh + +# Per-session agent worktrees — scratch, never part of the repo. +.claude/worktrees/ diff --git a/install.sh b/install.sh index c5d6eda..9aff895 100755 --- a/install.sh +++ b/install.sh @@ -45,7 +45,6 @@ link .bashrc link .config/kitty/kitty.conf link .config/starship.toml link .config/zellij/config.kdl -link .config/zellij/layouts/projects.kdl # Project mapping link .config/claude-projects.conf diff --git a/scripts/fleet/stranded-work.sh b/scripts/fleet/stranded-work.sh new file mode 100755 index 0000000..7d812f6 --- /dev/null +++ b/scripts/fleet/stranded-work.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +# +# Which repos are holding finished work that is going nowhere? +# +# This fleet already had `git-health` (in .bashrc), and it did not prevent the +# thing it looks like it prevents. On 2026-08-24 orangecat held 118 uncommitted +# files and fleetcrown 88, both untouched for ten days — a completed EntityType +# rename and a completed publishing workstream, existing on exactly one disk. +# `git-health` had been reporting "118 dirty" that whole time. +# +# That is the design error worth naming: DIRTY IS NOT A DEFECT. Every tree is +# dirty while someone is working in it. A number that is equally true at minute +# five and at day ten carries no signal, so it gets read as background noise and +# then not read at all. +# +# AGE is the signal. Dirty for an hour is work. Dirty for a week is stranded. +# +# So this reports only what has aged past a threshold, and says NOTHING when the +# fleet is healthy. Silence-when-clean is not politeness, it is the mechanism: +# a check that prints on every shell gets muted, and a muted check is absent. +# +# It is also why this cannot be a CI job. The work it looks for has never left +# the machine — GitHub cannot see it by definition. It has to run where the +# uncommitted files are. +# +# Usage: +# stranded-work.sh # report (silent when clean) +# stranded-work.sh --check # exit 1 if anything is stranded +# stranded-work.sh --shell # print cache, refresh in background; never blocks +# stranded-work.sh --scan # emit raw TSV, no verdict (feeds the tests) +# +# Env: FLEET_ROOT (default ~/dev), STRANDED_DAYS (default 3), STRANDED_CACHE + +set -uo pipefail + +ROOT="${FLEET_ROOT:-$HOME/dev}" +DAYS="${STRANDED_DAYS:-3}" +CACHE="${STRANDED_CACHE:-$HOME/.cache/fleet-stranded}" +CACHE_TTL_SEC="${STRANDED_CACHE_TTL:-14400}" # 4h + +MODE=report +case "${1:-}" in + --check) MODE=check ;; + --shell) MODE=shell ;; + --scan) MODE=scan ;; + "") MODE=report ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; +esac + +now_epoch() { date +%s; } + +# --------------------------------------------------------------------------- +# Scanning half — needs git and a filesystem. +# Emits TSV: name \t dirty_count \t dirty_age_days \t unpushed_count \t unpushed_age_days \t branch +# An age of -1 means "nothing of that kind", so the deciding half never has to +# distinguish absent from zero. +# --------------------------------------------------------------------------- +scan_repo() { + local dir="$1" name branch now + # Optional $2 overrides the display name, so a worktree can report as + # "repo/branch-dir" instead of a bare directory name that says nothing about + # which repo it belongs to. + name="${2:-$(basename "$dir")}" + now="$(now_epoch)" + + branch="$(git -C "$dir" rev-parse --abbrev-ref HEAD 2>/dev/null || echo '?')" + + local dirty_count=0 oldest_mtime="" dirty_age=-1 + local line path + while IFS= read -r line; do + [ -n "$line" ] || continue + dirty_count=$((dirty_count + 1)) + # Porcelain is "XY path". Strip the 2-char status and its space FIRST, then + # resolve renames ("R old -> new") to the path that still exists. Doing it + # the other way round eats three characters of the new filename. + path="${line:3}" + path="${path##* -> }" + [ -e "$dir/$path" ] || continue # deletions have no mtime to read + local m + m="$(stat -c %Y "$dir/$path" 2>/dev/null)" || continue + if [ -z "$oldest_mtime" ] || [ "$m" -lt "$oldest_mtime" ]; then oldest_mtime="$m"; fi + done < <(git -C "$dir" status --porcelain 2>/dev/null) + + if [ -n "$oldest_mtime" ]; then + dirty_age=$(( (now - oldest_mtime) / 86400 )) + fi + + # Unpushed: measured against the upstream when there is one, else against the + # remote default branch. A branch that was never pushed is the worse case, not + # an exempt one — that is where four of fleetcrown's commits were hiding. + local base unpushed_count=0 unpushed_age=-1 oldest_commit + if git -C "$dir" rev-parse --verify -q '@{u}' >/dev/null 2>&1; then + base='@{u}' + elif git -C "$dir" rev-parse --verify -q origin/main >/dev/null 2>&1; then + base='origin/main' + elif git -C "$dir" rev-parse --verify -q origin/master >/dev/null 2>&1; then + base='origin/master' + else + base='' + fi + + if [ -n "$base" ]; then + unpushed_count="$(git -C "$dir" rev-list --count "$base"..HEAD 2>/dev/null || echo 0)" + if [ "${unpushed_count:-0}" -gt 0 ]; then + oldest_commit="$(git -C "$dir" log "$base"..HEAD --format=%ct 2>/dev/null | tail -1)" + [ -n "$oldest_commit" ] && unpushed_age=$(( (now - oldest_commit) / 86400 )) + fi + fi + + printf '%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$name" "$dirty_count" "$dirty_age" "${unpushed_count:-0}" "$unpushed_age" "$branch" +} + +# Worktrees hold real branches, and this guard could not see them. +# +# Measured 2026-08-25 in fleetcrown alone: 9 agent worktrees under +# .claude/worktrees/ held 13 commits across 9 branches, NONE of them pushed, +# the oldest 3 weeks old — while `fleet-stranded` reported the fleet clean. +# The loop below only ever looked at $ROOT/*/, so every branch created by +# `_claude_autoworktree_enter` was invisible to the one check built to find +# exactly this. A guard with a blind spot shaped like the fleet's dominant +# workflow is worse than none: it certifies the thing it cannot see. +# +# `git worktree list --porcelain` reports the main checkout first; skip it, +# since the loop already scanned it. +scan_worktrees() { + local repo="$1" repo_name="$2" first=1 wt + while IFS= read -r wt; do + if [ "$first" -eq 1 ]; then first=0; continue; fi # main checkout + [ -e "$wt" ] || continue + scan_repo "$wt" "${repo_name}/$(basename "$wt")" + done < <(git -C "$repo" worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2}') +} + +scan_all() { + local d name + for d in "$ROOT"/*/; do + [ -d "$d/.git" ] || continue + d="${d%/}" + name="$(basename "$d")" + scan_repo "$d" "$name" + scan_worktrees "$d" "$name" + done +} + +# --------------------------------------------------------------------------- +# Deciding half — pure text. No git, no filesystem, no clock. +# +# Kept separate for the same reason shared-inventory.sh splits counting from +# ratcheting: a rule you can only exercise by aging a real repo three days is a +# rule nobody re-tests after editing it. +# +# Reads TSV on stdin. Prints one line per stranded repo. Returns 1 if any. +# --------------------------------------------------------------------------- +decide() { + local threshold="$1" found=0 + local name dirty dirty_age unpushed unpushed_age branch + while IFS=$'\t' read -r name dirty dirty_age unpushed unpushed_age branch; do + [ -n "$name" ] || continue + local why="" + if [ "$dirty" -gt 0 ] && [ "$dirty_age" -ge "$threshold" ]; then + why="${dirty} uncommitted, oldest ${dirty_age}d" + fi + if [ "$unpushed" -gt 0 ] && [ "$unpushed_age" -ge "$threshold" ]; then + [ -n "$why" ] && why="${why}; " + why="${why}${unpushed} unpushed, oldest ${unpushed_age}d" + fi + [ -n "$why" ] || continue + printf ' %-20s %s (%s)\n' "$name" "$why" "$branch" + found=1 + done + return $((found == 0 ? 0 : 1)) +} + +render() { + local body + body="$(scan_all | decide "$DAYS")" + local rc=$? + if [ $rc -ne 0 ]; then + printf 'Stranded work — finished but going nowhere (>%sd):\n%s\n' "$DAYS" "$body" + fi + return $rc +} + +# The tests source this file to drive decide() and scan_repo() directly, so what +# they exercise is the shipped rule rather than a restatement of it that can +# quietly drift away from the one actually running. +if [ -n "${STRANDED_LIB_ONLY:-}" ]; then + return 0 2>/dev/null || exit 0 +fi + +case "$MODE" in + scan) + scan_all + ;; + report) + render + exit 0 + ;; + check) + render + exit $? + ;; + shell) + # Never block a shell. Print what we knew as of the last scan, then refresh + # in the background if that knowledge is stale. A guard that costs three + # seconds of every prompt is a guard that gets deleted. + mkdir -p "$(dirname "$CACHE")" + [ -s "$CACHE" ] && cat "$CACHE" + stale=1 + if [ -f "$CACHE" ]; then + age=$(( $(now_epoch) - $(stat -c %Y "$CACHE" 2>/dev/null || echo 0) )) + [ "$age" -lt "$CACHE_TTL_SEC" ] && stale=0 + fi + if [ "$stale" -eq 1 ]; then + ( render > "$CACHE.tmp" 2>/dev/null; mv -f "$CACHE.tmp" "$CACHE" 2>/dev/null ) \ + >/dev/null 2>&1 & + disown 2>/dev/null || true + fi + exit 0 + ;; +esac diff --git a/scripts/fleet/test-stranded-work.sh b/scripts/fleet/test-stranded-work.sh new file mode 100755 index 0000000..f4c7a85 --- /dev/null +++ b/scripts/fleet/test-stranded-work.sh @@ -0,0 +1,212 @@ +#!/usr/bin/env bash +# +# Negative tests for the stranded-work guard. +# +# The guard's entire value is that it can go RED, and its entire usability is +# that it stays SILENT otherwise. Both halves are failure modes: +# +# - If it never fires, it certifies the thing it was built to catch. That is +# how 206 finished files sat on one disk for ten days while `git-health` +# cheerfully reported them every single day. +# - If it fires on healthy work, it gets muted, and a muted check is absent. +# This is the more likely death, so "young dirty tree says nothing" is +# tested as carefully as "old dirty tree speaks up". +# +# The deciding half is pure text and is tested against fixtures. The scanning +# half is tested against REAL git repos built in a tmpdir with backdated files, +# because the thing most likely to be wrong is the mtime and rename parsing, +# and no fixture would exercise that. + +set -uo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +SCRIPT="$HERE/stranded-work.sh" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +PASS=0 +FAIL=0 +ok() { printf ' ✓ %s\n' "$1"; PASS=$((PASS + 1)); } +no() { printf ' ✗ %s\n' "$1"; FAIL=$((FAIL + 1)); } + +export STRANDED_LIB_ONLY=1 +# shellcheck source=/dev/null +source "$SCRIPT" "" +unset STRANDED_LIB_ONLY + +# name dirty dirty_age unpushed unpushed_age branch +row() { printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$@"; } + +echo "deciding half — fixtures, no git:" + +out="$(row clean 0 -1 0 -1 main | decide 3)"; rc=$? +[ $rc -eq 0 ] && [ -z "$out" ] \ + && ok "a clean repo says nothing and exits 0" \ + || no "a clean repo should be silent (rc=$rc, out='$out')" + +out="$(row busy 42 0 0 -1 main | decide 3)"; rc=$? +[ $rc -eq 0 ] && [ -z "$out" ] \ + && ok "42 files dirty since today is work, not a finding" \ + || no "a fresh dirty tree must stay silent (rc=$rc, out='$out')" + +out="$(row busy 42 2 0 -1 main | decide 3)"; rc=$? +[ $rc -eq 0 ] && [ -z "$out" ] \ + && ok "dirty for 2d is below a 3d threshold and stays silent" \ + || no "2d < 3d must stay silent (rc=$rc)" + +out="$(row oc 118 10 0 -1 chore/x | decide 3)"; rc=$? +[ $rc -ne 0 ] && [[ "$out" == *"118 uncommitted"* ]] && [[ "$out" == *"10d"* ]] \ + && ok "the real orangecat case goes red and names the age" \ + || no "118 files dirty 10d must go red (rc=$rc, out='$out')" + +out="$(row edge 1 3 0 -1 main | decide 3)"; rc=$? +[ $rc -ne 0 ] \ + && ok "exactly at the threshold counts as stranded (>=, not >)" \ + || no "age == threshold must fire" + +out="$(row fc 0 -1 4 11 fix/y | decide 3)"; rc=$? +[ $rc -ne 0 ] && [[ "$out" == *"4 unpushed"* ]] \ + && ok "committed-but-never-pushed is caught too (fleetcrown's 4)" \ + || no "unpushed commits must go red (rc=$rc, out='$out')" + +out="$(row both 9 5 2 6 main | decide 3)"; rc=$? +[ $rc -ne 0 ] && [[ "$out" == *"uncommitted"* ]] && [[ "$out" == *"unpushed"* ]] \ + && ok "a repo stranded both ways reports both reasons" \ + || no "both reasons should appear (out='$out')" + +out="$(row young 0 -1 3 0 main | decide 3)"; rc=$? +[ $rc -eq 0 ] && [ -z "$out" ] \ + && ok "commits pushed-pending since today stay silent" \ + || no "fresh unpushed commits must stay silent (rc=$rc)" + +out="$(printf '%s%s%s' "$(row a 0 -1 0 -1 main)"$'\n' "$(row b 7 9 0 -1 main)"$'\n' "$(row c 0 -1 0 -1 main)"$'\n' | decide 3)"; rc=$? +[ $rc -ne 0 ] && [ "$(printf '%s\n' "$out" | grep -c .)" -eq 1 ] \ + && ok "only the stranded repo is listed, not the healthy ones" \ + || no "exactly one line expected (out='$out')" + +echo +echo "scanning half — real git repos with backdated files:" + +mkrepo_at() { + local d="$1/$2"; mkdir -p "$d"; git -C "$d" init -q + git -C "$d" config user.email t@t; git -C "$d" config user.name t + echo seed > "$d/seed.txt"; git -C "$d" add -A + git -C "$d" commit -qm seed --no-verify + printf '%s' "$d" +} + +mkrepo() { + local d="$TMP/$1"; mkdir -p "$d"; git -C "$d" init -q + git -C "$d" config user.email t@t; git -C "$d" config user.name t + echo seed > "$d/seed.txt"; git -C "$d" add -A + git -C "$d" commit -qm seed --no-verify + printf '%s' "$d" +} + +d="$(mkrepo fresh)" +line="$(scan_repo "$d")" +[ "$(printf '%s' "$line" | cut -f2)" = "0" ] \ + && ok "a committed repo scans as 0 dirty" \ + || no "expected 0 dirty, got '$line'" + +d="$(mkrepo aged)" +echo change >> "$d/seed.txt" +echo new > "$d/untracked.txt" +touch -d '9 days ago' "$d/seed.txt" "$d/untracked.txt" +line="$(scan_repo "$d")" +n="$(printf '%s' "$line" | cut -f2)"; age="$(printf '%s' "$line" | cut -f3)" +[ "$n" = "2" ] && [ "$age" -ge 8 ] \ + && ok "modified + untracked both counted, age read from mtime (${age}d)" \ + || no "expected 2 files aged ~9d, got n=$n age=$age" + +# A deleted file has no mtime to stat. It must still count, and must not abort +# the scan or poison the age — this is the case that silently breaks naive +# implementations, and orangecat's rename deleted 18 files. +d="$(mkrepo deleted)" +echo x > "$d/gone.txt"; git -C "$d" add -A; git -C "$d" commit -qm two --no-verify +rm "$d/gone.txt" +line="$(scan_repo "$d")" +n="$(printf '%s' "$line" | cut -f2)" +[ "$n" = "1" ] \ + && ok "a deletion counts as dirty without breaking the mtime scan" \ + || no "expected 1 dirty for a deletion, got '$line'" + +d="$(mkrepo renamed)" +git -C "$d" mv seed.txt moved.txt +touch -d '7 days ago' "$d/moved.txt" +line="$(scan_repo "$d")" +n="$(printf '%s' "$line" | cut -f2)"; age="$(printf '%s' "$line" | cut -f3)" +[ "$n" = "1" ] && [ "$age" -ge 6 ] \ + && ok "a rename resolves to its new path, not the vanished old one (${age}d)" \ + || no "expected the renamed path to be statted, got n=$n age=$age" + +d="$(mkrepo unpushed)" +echo more >> "$d/seed.txt"; git -C "$d" add -A +GIT_COMMITTER_DATE="$(date -d '12 days ago' -Iseconds)" \ + git -C "$d" -c user.email=t@t -c user.name=t commit -qm "old work" --no-verify \ + --date="$(date -d '12 days ago' -Iseconds)" +line="$(scan_repo "$d")" +[ "$(printf '%s' "$line" | cut -f2)" = "0" ] \ + && ok "a repo with no remote scans without erroring" \ + || no "no-remote repo should scan cleanly, got '$line'" + +echo +echo "worktrees — the blind spot that made this guard certify a lie:" + +# The fleet works almost entirely in `git worktree` checkouts created per agent +# session. Scanning only $FLEET_ROOT/*/ therefore missed the majority of real +# work: 26 unpushed commits across the fleet on 2026-08-25, oldest 25 days, +# while the guard printed nothing. These pin the fix. +wt_root="$TMP/wtfleet"; mkdir -p "$wt_root" +d="$(mkrepo_at "$wt_root" withwt)" +# A real remote, because "unpushed" is meaningless without one — scan_repo +# measures against @{u} or origin/main and correctly reports nothing when +# neither exists. +git init -q --bare "$TMP/withwt.git" +git -C "$d" remote add origin "$TMP/withwt.git" +git -C "$d" branch -M main +git -C "$d" push -q -u origin main 2>/dev/null +git -C "$d" worktree add -q "$d/.claude/worktrees/feature" -b feat/x 2>/dev/null +echo work > "$d/.claude/worktrees/feature/file.txt" +git -C "$d/.claude/worktrees/feature" add -A +GIT_COMMITTER_DATE="$(date -d '9 days ago' -Iseconds)" \ + git -C "$d/.claude/worktrees/feature" -c user.email=t@t -c user.name=t \ + commit -qm "stranded in a worktree" --no-verify --date="$(date -d '9 days ago' -Iseconds)" + +out="$(FLEET_ROOT="$wt_root" STRANDED_DAYS=3 bash "$SCRIPT" --check 2>&1)"; rc=$? +[ $rc -ne 0 ] && [[ "$out" == *"withwt/feature"* ]] \ + && ok "an aged commit inside a worktree is found, and named repo/worktree" \ + || no "worktree work must be reported (rc=$rc, out='$out')" + +[[ "$out" == *"feat/x"* ]] \ + && ok "...and reports the worktree's own branch, not the parent's" \ + || no "expected the worktree branch feat/x (out='$out')" + +# The main checkout must not be double-counted as one of its own worktrees. +[ "$(printf '%s\n' "$out" | grep -c 'withwt')" -eq 1 ] \ + && ok "the main checkout is not re-scanned as its own worktree" \ + || no "expected exactly one withwt line (out='$out')" + +echo +echo "end to end:" + +out="$(FLEET_ROOT="$TMP" STRANDED_DAYS=3 bash "$SCRIPT" --check 2>&1)"; rc=$? +[ $rc -ne 0 ] && [[ "$out" == *"Stranded work"* ]] \ + && ok "--check exits non-zero and prints a header when the fleet is stranded" \ + || no "--check should have gone red (rc=$rc)" + +clean_root="$TMP/onlyclean"; mkdir -p "$clean_root" +cp -r "$TMP/fresh" "$clean_root/fresh" +out="$(FLEET_ROOT="$clean_root" STRANDED_DAYS=3 bash "$SCRIPT" --check 2>&1)"; rc=$? +[ $rc -eq 0 ] && [ -z "$out" ] \ + && ok "--check is silent and exits 0 on a healthy fleet" \ + || no "healthy fleet must print nothing (rc=$rc, out='$out')" + +out="$(FLEET_ROOT="$TMP" bash "$SCRIPT" --nonsense 2>&1)"; rc=$? +[ $rc -eq 2 ] \ + && ok "an unknown argument fails loudly rather than defaulting to a pass" \ + || no "bad args should exit 2, got $rc" + +echo +echo " $PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ]