|
| 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 |
0 commit comments