diff --git a/.bashrc b/.bashrc index 8de91f1..9fda9bf 100644 --- a/.bashrc +++ b/.bashrc @@ -303,6 +303,10 @@ git-health() { # 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:-}"; } +# Companion to the above: stranded-work asks "is any work trapped here?", +# fleet-gc asks "is any checkout still here after its work shipped?". Dry-run +# unless given --go; it deletes checkouts, so that default is deliberate. +fleet-gc() { bash "$HOME/dev/dotfiles/scripts/fleet/gc-merged-worktrees.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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6f857e..d6af7d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,6 +90,14 @@ jobs: - name: The stranded-work guard can go red, and stays quiet when it should run: bash scripts/fleet/test-stranded-work.sh + # This one DELETES CHECKOUTS, so its only interesting failure is a false + # positive. Every refusal in the predicate is a case that would otherwise + # have destroyed the single existing copy of some work, and each is pinned + # separately — a combined "unsafe" fixture is exactly what hides one guard + # silently inverting. + - name: The worktree GC refuses everything it should + run: bash scripts/fleet/test-gc-merged-worktrees.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/scripts/fleet/gc-merged-worktrees.sh b/scripts/fleet/gc-merged-worktrees.sh new file mode 100755 index 0000000..95f5af7 --- /dev/null +++ b/scripts/fleet/gc-merged-worktrees.sh @@ -0,0 +1,141 @@ +#!/usr/bin/env bash +# +# Remove the worktrees whose work already shipped. +# +# The fleet works almost entirely in per-session `git worktree` checkouts. They +# are created automatically and removed by nobody, so they accumulate: on +# 2026-08-26 there were 24 of them holding 5.2 GB, and every single one belonged +# to a PR that had already merged. Twenty-three were byte-for-byte clean. +# +# That is a recurring manual sweep, which is the definition of something that +# should be a committed script rather than a thing an agent re-derives. But a +# script that deletes checkouts has to be paranoid, so the whole design is the +# four-part predicate in safe_to_remove(): a worktree is removable only if it +# was PUSHED, is CLEAN, is UNUSED, and GitHub says MERGED. +# +# Dry-run is the default. Nothing is deleted without `--go`. +# +# Usage: +# gc-merged-worktrees.sh # show what would go (default) +# gc-merged-worktrees.sh --go # actually remove +# +# Env: FLEET_ROOT (default ~/dev), SESSIONS_DIR (default ~/.claude/sessions) + +set -uo pipefail + +ROOT="${FLEET_ROOT:-$HOME/dev}" +SESSIONS_DIR="${SESSIONS_DIR:-$HOME/.claude/sessions}" + +GO=0 +case "${1:-}" in + --go) GO=1 ;; + --dry|"") GO=0 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; +esac + +# --------------------------------------------------------------------------- +# The predicate. Pure except for the four facts it is handed, so the tests can +# drive every branch without building a GitHub repo or a live agent session. +# +# Echoes "ok" or the reason it refused. Returns 0 only for "ok". +# +# pushed : "yes" if the branch has an upstream configured. A branch with no +# upstream never left the machine — removing it destroys the only +# copy. This is the one that must never be loosened. +# dirty_n : count of uncommitted files. Any is a refusal; a worktree is +# exactly where someone's unfinished edit lives. +# in_use : "yes" if a live session's cwd is inside it. +# pr_state : whatever `gh pr list --state all` said, or "" if it said nothing. +# MERGED is required — CLOSED means abandoned, and abandoned work +# is still a decision someone might want to revisit. +# --------------------------------------------------------------------------- +safe_to_remove() { # + local pushed="$1" dirty_n="$2" in_use="$3" pr_state="$4" + if [ "$pushed" != yes ]; then echo "never pushed — this is the only copy"; return 1; fi + if [ "${dirty_n:-0}" -ne 0 ]; then echo "$dirty_n uncommitted file(s)"; return 1; fi + if [ "$in_use" = yes ]; then echo "a live session is in it"; return 1; fi + if [ "$pr_state" != MERGED ]; then echo "PR state '${pr_state:-none}', not MERGED"; return 1; fi + echo ok +} + +if [ -n "${GC_WORKTREES_LIB_ONLY:-}" ]; then + return 0 2>/dev/null || exit 0 +fi + +# --------------------------------------------------------------------------- +live_cwds() { + # A session that is inside a worktree is the case worth being careful about; + # missing registry files simply mean no sessions, not an error. + jq -r 'select(.status!=null) | .cwd' "$SESSIONS_DIR"/*.json 2>/dev/null | sort -u +} + +LIVE="$(live_cwds)" +removed=0 skipped=0 freed=0 + +for repo in "$ROOT"/*/; do + repo="${repo%/}"; [ -d "$repo/.git" ] || continue + slug="$(git -C "$repo" remote get-url origin 2>/dev/null \ + | sed -E 's#.*github.com[:/]##; s#\.git$##')" || continue + [ -n "$slug" ] || continue + + # `worktree list` reports the main checkout first — never a candidate. + while read -r wt; do + [ -n "$wt" ] && [ -e "$wt" ] || continue + branch="$(git -C "$wt" symbolic-ref --quiet --short HEAD)" || continue + + # "Pushed" means an upstream is CONFIGURED, not that it still resolves. + # After a squash-merge GitHub deletes the remote branch and `fetch --prune` + # drops the tracking ref, leaving branch..merge pointing at nothing — so + # a resolvable upstream means the branch is still open, and an unresolvable + # one is the signal we are looking for. (Same distinction stranded-work.sh + # turns on; getting it backwards there reported 24 merged PRs as stranded.) + upstream="$(git -C "$wt" for-each-ref --format='%(upstream:short)' "refs/heads/$branch")" + [ -n "$upstream" ] && pushed=yes || pushed=no + if [ "$pushed" = yes ] && \ + git -C "$wt" rev-parse --verify -q "refs/remotes/$upstream" >/dev/null 2>&1; then + continue # remote branch still exists — the PR is open, leave it alone + fi + + dirty_n="$(git -C "$wt" status --porcelain 2>/dev/null | wc -l)" + printf '%s\n' "$LIVE" | grep -qxF "$wt" && in_use=yes || in_use=no + + pr_state="" + if [ "$pushed" = yes ] && [ "$dirty_n" -eq 0 ] && [ "$in_use" = no ]; then + # Only ask GitHub once the cheap local checks have passed — this is the + # only network call, and it is the authority, re-asked at removal time + # rather than trusted from an earlier scan. + pr_state="$(gh pr list -R "$slug" --head "$branch" --state all \ + --json state --jq '.[0].state' 2>/dev/null)" + fi + + reason="$(safe_to_remove "$pushed" "$dirty_n" "$in_use" "$pr_state")" + if [ "$reason" != ok ]; then + printf 'keep %-52s %s\n' "${wt#"$ROOT"/}" "$reason" + skipped=$((skipped + 1)) + continue + fi + + mb="$(du -sm "$wt" 2>/dev/null | cut -f1)" + if [ "$GO" -eq 1 ]; then + if git -C "$repo" worktree remove --force "$wt" 2>/dev/null; then + git -C "$repo" branch -D "$branch" >/dev/null 2>&1 + printf 'removed %-52s %sMB (%s)\n' "${wt#"$ROOT"/}" "$mb" "$branch" + removed=$((removed + 1)); freed=$((freed + mb)) + else + printf 'FAILED %-52s could not remove\n' "${wt#"$ROOT"/}" + skipped=$((skipped + 1)) + fi + else + printf 'would %-52s %sMB (%s)\n' "${wt#"$ROOT"/}" "$mb" "$branch" + removed=$((removed + 1)); freed=$((freed + mb)) + fi + done < <(git -C "$repo" worktree list --porcelain 2>/dev/null \ + | awk '/^worktree /{print $2}' | tail -n +2) +done + +echo "---" +if [ "$GO" -eq 1 ]; then + echo "removed $removed worktrees, ${freed}MB freed; $skipped kept" +else + echo "$removed removable (${freed}MB), $skipped kept — rerun with --go to remove" +fi diff --git a/scripts/fleet/test-gc-merged-worktrees.sh b/scripts/fleet/test-gc-merged-worktrees.sh new file mode 100755 index 0000000..c7d7e85 --- /dev/null +++ b/scripts/fleet/test-gc-merged-worktrees.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# +# Tests for the worktree GC predicate. +# +# This script deletes checkouts, so the only interesting failure mode is a FALSE +# POSITIVE: removing something that was not safe. Every refusal below is a case +# that would have destroyed work, so each is tested individually rather than +# through one combined "unsafe" fixture — a single guard silently inverting is +# exactly what a combined test hides. +# +# The predicate is pure by design so all of this runs without a GitHub repo, a +# network call, or a live agent session. + +set -uo pipefail + +HERE="$(cd "$(dirname "$0")" && pwd)" +SCRIPT="$HERE/gc-merged-worktrees.sh" + +PASS=0 +FAIL=0 +ok() { printf ' ✓ %s\n' "$1"; PASS=$((PASS + 1)); } +no() { printf ' ✗ %s\n' "$1"; FAIL=$((FAIL + 1)); } + +export GC_WORKTREES_LIB_ONLY=1 +# shellcheck source=/dev/null +source "$SCRIPT" "" +unset GC_WORKTREES_LIB_ONLY + +# safe_to_remove +say() { safe_to_remove "$@"; } + +echo "the one case that may be removed:" + +out="$(say yes 0 no MERGED)"; rc=$? +[ $rc -eq 0 ] && [ "$out" = ok ] \ + && ok "pushed + clean + unused + MERGED is removable" \ + || no "the happy path must return ok (rc=$rc, out='$out')" + +echo +echo "every refusal — each of these would have destroyed work:" + +# The one that matters most. A branch with no upstream exists on exactly one +# disk; this is the case the whole guard exists to protect. +out="$(say no 0 no MERGED)"; rc=$? +[ $rc -ne 0 ] && [[ "$out" == *"only copy"* ]] \ + && ok "never-pushed is refused even when everything else looks fine" \ + || no "unpushed work must never be removable (rc=$rc, out='$out')" + +out="$(say yes 1 no MERGED)"; rc=$? +[ $rc -ne 0 ] && [[ "$out" == *"uncommitted"* ]] \ + && ok "a single uncommitted file is enough to refuse" \ + || no "dirty worktrees must be refused (rc=$rc, out='$out')" + +out="$(say yes 0 yes MERGED)"; rc=$? +[ $rc -ne 0 ] && [[ "$out" == *"live session"* ]] \ + && ok "a worktree with a live session in it is refused" \ + || no "in-use worktrees must be refused (rc=$rc, out='$out')" + +# CLOSED is not MERGED. An abandoned PR is still a decision someone may want to +# revisit, and the branch content exists nowhere else once the remote is gone. +out="$(say yes 0 no CLOSED)"; rc=$? +[ $rc -ne 0 ] && [[ "$out" == *"CLOSED"* ]] \ + && ok "a CLOSED (abandoned) PR is refused — closed is not merged" \ + || no "CLOSED must be refused (rc=$rc, out='$out')" + +out="$(say yes 0 no OPEN)"; rc=$? +[ $rc -ne 0 ] \ + && ok "an OPEN PR is refused" \ + || no "OPEN must be refused (rc=$rc, out='$out')" + +# `gh` returning nothing is ambiguous — no PR, no auth, rate limit, network +# down. Ambiguity must resolve to "keep", never to "delete". +out="$(say yes 0 no "")"; rc=$? +[ $rc -ne 0 ] && [[ "$out" == *"none"* ]] \ + && ok "an empty gh answer keeps the worktree — ambiguity is not consent" \ + || no "empty PR state must be refused (rc=$rc, out='$out')" + +echo +echo "the script itself:" + +out="$(bash "$SCRIPT" --nonsense 2>&1)"; rc=$? +[ $rc -eq 2 ] \ + && ok "an unknown argument fails loudly rather than defaulting to --go" \ + || no "unknown args must exit 2 (rc=$rc)" + +# Default must be dry-run. If this ever inverts, the first person to run the +# script with no arguments loses 5 GB of checkouts. +out="$(FLEET_ROOT="$(mktemp -d)" bash "$SCRIPT" 2>&1)"; rc=$? +[[ "$out" == *"rerun with --go"* ]] \ + && ok "no arguments means dry-run, and says so" \ + || no "the default must be dry-run (out='$out')" + +echo +printf ' %d passed, %d failed\n' "$PASS" "$FAIL" +[ "$FAIL" -eq 0 ]