Skip to content

Commit b03e5ec

Browse files
catomeanclaude
andauthored
feat(fleet): reclaim the checkouts whose work already shipped (#42)
The fleet creates a git worktree per agent session and removes none of them. On 2026-08-26 there were 24, holding 5.2 GB, and every single one belonged to a PR that had already merged — 23 were byte-for-byte clean. That is a recurring manual sweep, so it becomes a script rather than something re-derived from scratch each time the disk gets tight. stranded-work.sh and this are the two halves of one question. That one asks "is any work trapped on this disk?"; this asks "is any checkout still here after its work shipped?" They turn on the same distinction, too: an upstream that is configured but no longer resolves means the branch was pushed and its remote branch deleted. Reading that backwards is what made stranded-work report 24 merged PRs as stranded yesterday. Because this one deletes checkouts, the entire design is the refusal list. A worktree is removable only if it was PUSHED, is CLEAN, is UNUSED, and GitHub says MERGED — and each of those is a case where removing it would have destroyed the only copy of something: no upstream the branch exists on exactly one disk any dirt a worktree is where the unfinished edit lives live session someone is working in it right now not MERGED CLOSED is abandoned, not gone; empty is gh failing, not consent The predicate is pure so all nine tests run without a repo, a network call or a session, including "no argument means dry-run" — if that ever inverts, the first person to run it bare loses every checkout. Verified on the real fleet: 23 removed, 5.2 GB freed, the four it refused being one never-pushed branch, one live session, one dirty tree, and one superseded local branch. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 373bdce commit b03e5ec

4 files changed

Lines changed: 248 additions & 0 deletions

File tree

.bashrc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,10 @@ git-health() {
303303
# otherwise. It prints from cache so it never costs a prompt, and refreshes in
304304
# the background when that cache goes stale.
305305
fleet-stranded() { bash "$HOME/dev/dotfiles/scripts/fleet/stranded-work.sh" "${1:-}"; }
306+
# Companion to the above: stranded-work asks "is any work trapped here?",
307+
# fleet-gc asks "is any checkout still here after its work shipped?". Dry-run
308+
# unless given --go; it deletes checkouts, so that default is deliberate.
309+
fleet-gc() { bash "$HOME/dev/dotfiles/scripts/fleet/gc-merged-worktrees.sh" "${1:-}"; }
306310

307311
if [ -z "${BASH_EXECUTION_STRING:-}" ] && [ -f "$HOME/dev/dotfiles/scripts/fleet/stranded-work.sh" ]; then
308312
bash "$HOME/dev/dotfiles/scripts/fleet/stranded-work.sh" --shell 2>/dev/null

.github/workflows/ci.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,14 @@ jobs:
9090
- name: The stranded-work guard can go red, and stays quiet when it should
9191
run: bash scripts/fleet/test-stranded-work.sh
9292

93+
# This one DELETES CHECKOUTS, so its only interesting failure is a false
94+
# positive. Every refusal in the predicate is a case that would otherwise
95+
# have destroyed the single existing copy of some work, and each is pinned
96+
# separately — a combined "unsafe" fixture is exactly what hides one guard
97+
# silently inverting.
98+
- name: The worktree GC refuses everything it should
99+
run: bash scripts/fleet/test-gc-merged-worktrees.sh
100+
93101
# The sweep decides what ships in every repo that calls it, so it is the
94102
# last script here that should be untested — and until now it was. These
95103
# run the REAL script against a fake `gh`, exercising shipped control flow
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Remove the worktrees whose work already shipped.
4+
#
5+
# The fleet works almost entirely in per-session `git worktree` checkouts. They
6+
# are created automatically and removed by nobody, so they accumulate: on
7+
# 2026-08-26 there were 24 of them holding 5.2 GB, and every single one belonged
8+
# to a PR that had already merged. Twenty-three were byte-for-byte clean.
9+
#
10+
# That is a recurring manual sweep, which is the definition of something that
11+
# should be a committed script rather than a thing an agent re-derives. But a
12+
# script that deletes checkouts has to be paranoid, so the whole design is the
13+
# four-part predicate in safe_to_remove(): a worktree is removable only if it
14+
# was PUSHED, is CLEAN, is UNUSED, and GitHub says MERGED.
15+
#
16+
# Dry-run is the default. Nothing is deleted without `--go`.
17+
#
18+
# Usage:
19+
# gc-merged-worktrees.sh # show what would go (default)
20+
# gc-merged-worktrees.sh --go # actually remove
21+
#
22+
# Env: FLEET_ROOT (default ~/dev), SESSIONS_DIR (default ~/.claude/sessions)
23+
24+
set -uo pipefail
25+
26+
ROOT="${FLEET_ROOT:-$HOME/dev}"
27+
SESSIONS_DIR="${SESSIONS_DIR:-$HOME/.claude/sessions}"
28+
29+
GO=0
30+
case "${1:-}" in
31+
--go) GO=1 ;;
32+
--dry|"") GO=0 ;;
33+
*) echo "unknown argument: $1" >&2; exit 2 ;;
34+
esac
35+
36+
# ---------------------------------------------------------------------------
37+
# The predicate. Pure except for the four facts it is handed, so the tests can
38+
# drive every branch without building a GitHub repo or a live agent session.
39+
#
40+
# Echoes "ok" or the reason it refused. Returns 0 only for "ok".
41+
#
42+
# pushed : "yes" if the branch has an upstream configured. A branch with no
43+
# upstream never left the machine — removing it destroys the only
44+
# copy. This is the one that must never be loosened.
45+
# dirty_n : count of uncommitted files. Any is a refusal; a worktree is
46+
# exactly where someone's unfinished edit lives.
47+
# in_use : "yes" if a live session's cwd is inside it.
48+
# pr_state : whatever `gh pr list --state all` said, or "" if it said nothing.
49+
# MERGED is required — CLOSED means abandoned, and abandoned work
50+
# is still a decision someone might want to revisit.
51+
# ---------------------------------------------------------------------------
52+
safe_to_remove() { # <pushed> <dirty_n> <in_use> <pr_state>
53+
local pushed="$1" dirty_n="$2" in_use="$3" pr_state="$4"
54+
if [ "$pushed" != yes ]; then echo "never pushed — this is the only copy"; return 1; fi
55+
if [ "${dirty_n:-0}" -ne 0 ]; then echo "$dirty_n uncommitted file(s)"; return 1; fi
56+
if [ "$in_use" = yes ]; then echo "a live session is in it"; return 1; fi
57+
if [ "$pr_state" != MERGED ]; then echo "PR state '${pr_state:-none}', not MERGED"; return 1; fi
58+
echo ok
59+
}
60+
61+
if [ -n "${GC_WORKTREES_LIB_ONLY:-}" ]; then
62+
return 0 2>/dev/null || exit 0
63+
fi
64+
65+
# ---------------------------------------------------------------------------
66+
live_cwds() {
67+
# A session that is inside a worktree is the case worth being careful about;
68+
# missing registry files simply mean no sessions, not an error.
69+
jq -r 'select(.status!=null) | .cwd' "$SESSIONS_DIR"/*.json 2>/dev/null | sort -u
70+
}
71+
72+
LIVE="$(live_cwds)"
73+
removed=0 skipped=0 freed=0
74+
75+
for repo in "$ROOT"/*/; do
76+
repo="${repo%/}"; [ -d "$repo/.git" ] || continue
77+
slug="$(git -C "$repo" remote get-url origin 2>/dev/null \
78+
| sed -E 's#.*github.com[:/]##; s#\.git$##')" || continue
79+
[ -n "$slug" ] || continue
80+
81+
# `worktree list` reports the main checkout first — never a candidate.
82+
while read -r wt; do
83+
[ -n "$wt" ] && [ -e "$wt" ] || continue
84+
branch="$(git -C "$wt" symbolic-ref --quiet --short HEAD)" || continue
85+
86+
# "Pushed" means an upstream is CONFIGURED, not that it still resolves.
87+
# After a squash-merge GitHub deletes the remote branch and `fetch --prune`
88+
# drops the tracking ref, leaving branch.<n>.merge pointing at nothing — so
89+
# a resolvable upstream means the branch is still open, and an unresolvable
90+
# one is the signal we are looking for. (Same distinction stranded-work.sh
91+
# turns on; getting it backwards there reported 24 merged PRs as stranded.)
92+
upstream="$(git -C "$wt" for-each-ref --format='%(upstream:short)' "refs/heads/$branch")"
93+
[ -n "$upstream" ] && pushed=yes || pushed=no
94+
if [ "$pushed" = yes ] && \
95+
git -C "$wt" rev-parse --verify -q "refs/remotes/$upstream" >/dev/null 2>&1; then
96+
continue # remote branch still exists — the PR is open, leave it alone
97+
fi
98+
99+
dirty_n="$(git -C "$wt" status --porcelain 2>/dev/null | wc -l)"
100+
printf '%s\n' "$LIVE" | grep -qxF "$wt" && in_use=yes || in_use=no
101+
102+
pr_state=""
103+
if [ "$pushed" = yes ] && [ "$dirty_n" -eq 0 ] && [ "$in_use" = no ]; then
104+
# Only ask GitHub once the cheap local checks have passed — this is the
105+
# only network call, and it is the authority, re-asked at removal time
106+
# rather than trusted from an earlier scan.
107+
pr_state="$(gh pr list -R "$slug" --head "$branch" --state all \
108+
--json state --jq '.[0].state' 2>/dev/null)"
109+
fi
110+
111+
reason="$(safe_to_remove "$pushed" "$dirty_n" "$in_use" "$pr_state")"
112+
if [ "$reason" != ok ]; then
113+
printf 'keep %-52s %s\n' "${wt#"$ROOT"/}" "$reason"
114+
skipped=$((skipped + 1))
115+
continue
116+
fi
117+
118+
mb="$(du -sm "$wt" 2>/dev/null | cut -f1)"
119+
if [ "$GO" -eq 1 ]; then
120+
if git -C "$repo" worktree remove --force "$wt" 2>/dev/null; then
121+
git -C "$repo" branch -D "$branch" >/dev/null 2>&1
122+
printf 'removed %-52s %sMB (%s)\n' "${wt#"$ROOT"/}" "$mb" "$branch"
123+
removed=$((removed + 1)); freed=$((freed + mb))
124+
else
125+
printf 'FAILED %-52s could not remove\n' "${wt#"$ROOT"/}"
126+
skipped=$((skipped + 1))
127+
fi
128+
else
129+
printf 'would %-52s %sMB (%s)\n' "${wt#"$ROOT"/}" "$mb" "$branch"
130+
removed=$((removed + 1)); freed=$((freed + mb))
131+
fi
132+
done < <(git -C "$repo" worktree list --porcelain 2>/dev/null \
133+
| awk '/^worktree /{print $2}' | tail -n +2)
134+
done
135+
136+
echo "---"
137+
if [ "$GO" -eq 1 ]; then
138+
echo "removed $removed worktrees, ${freed}MB freed; $skipped kept"
139+
else
140+
echo "$removed removable (${freed}MB), $skipped kept — rerun with --go to remove"
141+
fi
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Tests for the worktree GC predicate.
4+
#
5+
# This script deletes checkouts, so the only interesting failure mode is a FALSE
6+
# POSITIVE: removing something that was not safe. Every refusal below is a case
7+
# that would have destroyed work, so each is tested individually rather than
8+
# through one combined "unsafe" fixture — a single guard silently inverting is
9+
# exactly what a combined test hides.
10+
#
11+
# The predicate is pure by design so all of this runs without a GitHub repo, a
12+
# network call, or a live agent session.
13+
14+
set -uo pipefail
15+
16+
HERE="$(cd "$(dirname "$0")" && pwd)"
17+
SCRIPT="$HERE/gc-merged-worktrees.sh"
18+
19+
PASS=0
20+
FAIL=0
21+
ok() { printf ' ✓ %s\n' "$1"; PASS=$((PASS + 1)); }
22+
no() { printf ' ✗ %s\n' "$1"; FAIL=$((FAIL + 1)); }
23+
24+
export GC_WORKTREES_LIB_ONLY=1
25+
# shellcheck source=/dev/null
26+
source "$SCRIPT" ""
27+
unset GC_WORKTREES_LIB_ONLY
28+
29+
# safe_to_remove <pushed> <dirty_n> <in_use> <pr_state>
30+
say() { safe_to_remove "$@"; }
31+
32+
echo "the one case that may be removed:"
33+
34+
out="$(say yes 0 no MERGED)"; rc=$?
35+
[ $rc -eq 0 ] && [ "$out" = ok ] \
36+
&& ok "pushed + clean + unused + MERGED is removable" \
37+
|| no "the happy path must return ok (rc=$rc, out='$out')"
38+
39+
echo
40+
echo "every refusal — each of these would have destroyed work:"
41+
42+
# The one that matters most. A branch with no upstream exists on exactly one
43+
# disk; this is the case the whole guard exists to protect.
44+
out="$(say no 0 no MERGED)"; rc=$?
45+
[ $rc -ne 0 ] && [[ "$out" == *"only copy"* ]] \
46+
&& ok "never-pushed is refused even when everything else looks fine" \
47+
|| no "unpushed work must never be removable (rc=$rc, out='$out')"
48+
49+
out="$(say yes 1 no MERGED)"; rc=$?
50+
[ $rc -ne 0 ] && [[ "$out" == *"uncommitted"* ]] \
51+
&& ok "a single uncommitted file is enough to refuse" \
52+
|| no "dirty worktrees must be refused (rc=$rc, out='$out')"
53+
54+
out="$(say yes 0 yes MERGED)"; rc=$?
55+
[ $rc -ne 0 ] && [[ "$out" == *"live session"* ]] \
56+
&& ok "a worktree with a live session in it is refused" \
57+
|| no "in-use worktrees must be refused (rc=$rc, out='$out')"
58+
59+
# CLOSED is not MERGED. An abandoned PR is still a decision someone may want to
60+
# revisit, and the branch content exists nowhere else once the remote is gone.
61+
out="$(say yes 0 no CLOSED)"; rc=$?
62+
[ $rc -ne 0 ] && [[ "$out" == *"CLOSED"* ]] \
63+
&& ok "a CLOSED (abandoned) PR is refused — closed is not merged" \
64+
|| no "CLOSED must be refused (rc=$rc, out='$out')"
65+
66+
out="$(say yes 0 no OPEN)"; rc=$?
67+
[ $rc -ne 0 ] \
68+
&& ok "an OPEN PR is refused" \
69+
|| no "OPEN must be refused (rc=$rc, out='$out')"
70+
71+
# `gh` returning nothing is ambiguous — no PR, no auth, rate limit, network
72+
# down. Ambiguity must resolve to "keep", never to "delete".
73+
out="$(say yes 0 no "")"; rc=$?
74+
[ $rc -ne 0 ] && [[ "$out" == *"none"* ]] \
75+
&& ok "an empty gh answer keeps the worktree — ambiguity is not consent" \
76+
|| no "empty PR state must be refused (rc=$rc, out='$out')"
77+
78+
echo
79+
echo "the script itself:"
80+
81+
out="$(bash "$SCRIPT" --nonsense 2>&1)"; rc=$?
82+
[ $rc -eq 2 ] \
83+
&& ok "an unknown argument fails loudly rather than defaulting to --go" \
84+
|| no "unknown args must exit 2 (rc=$rc)"
85+
86+
# Default must be dry-run. If this ever inverts, the first person to run the
87+
# script with no arguments loses 5 GB of checkouts.
88+
out="$(FLEET_ROOT="$(mktemp -d)" bash "$SCRIPT" 2>&1)"; rc=$?
89+
[[ "$out" == *"rerun with --go"* ]] \
90+
&& ok "no arguments means dry-run, and says so" \
91+
|| no "the default must be dry-run (out='$out')"
92+
93+
echo
94+
printf ' %d passed, %d failed\n' "$PASS" "$FAIL"
95+
[ "$FAIL" -eq 0 ]

0 commit comments

Comments
 (0)