From c55e169ffa748c3ce7440c9db5c29a44b44163d2 Mon Sep 17 00:00:00 2001 From: Tom Tupper Date: Tue, 28 Jul 2026 11:58:57 -0500 Subject: [PATCH 1/3] chore: worktree hygiene rule and safe prune script Sixty-plus stale worktrees of merged branches accumulated on the dev machine, each carrying a multi-gigabyte virtualenv, and together they exhausted the disk. Add a merge-ritual rule (the worktree's creator removes it after the PR merges) and scripts/prune_merged_worktrees.sh, which sweeps only worktrees that are clean, already ancestors of origin/dev, and untouched for 48 hours, so in-flight agent checkouts are never removed. Branches and commits are never deleted. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01262jKrQwepcSQtv1vk2chH --- CLAUDE.md | 12 ++++++ scripts/prune_merged_worktrees.sh | 66 +++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) create mode 100755 scripts/prune_merged_worktrees.sh diff --git a/CLAUDE.md b/CLAUDE.md index edb9826f6..a8e98f76d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -459,6 +459,18 @@ comment rated 1–3, reply with the severity-based rationale for not changing th current PR, note any follow-up when appropriate, and resolve the thread. Time spent implementing low-severity feedback is time not spent on real work. +### Worktree Hygiene + +Working in a dedicated git worktree is encouraged, but a worktree's life ends +with its PR. After a PR merges, the agent or developer who created its +worktree removes it (`git worktree remove `; branches and commits are +untouched). Do not accumulate merged worktrees: each one carries a +multi-gigabyte virtualenv and node_modules, and dozens of stale ones have +exhausted the dev machine's disk before. `scripts/prune_merged_worktrees.sh` +sweeps safely (only clean worktrees whose HEAD is already an ancestor of +origin/dev and that have been untouched for 48 hours are removed; pass +`--dry-run` to preview). + ### PR Review Loop Foxlight PRs are never opened as drafts. After opening or updating a PR, keep it diff --git a/scripts/prune_merged_worktrees.sh b/scripts/prune_merged_worktrees.sh new file mode 100755 index 000000000..6762ea712 --- /dev/null +++ b/scripts/prune_merged_worktrees.sh @@ -0,0 +1,66 @@ +#!/bin/bash +# Remove git worktrees whose work has already merged into origin/dev. +# +# A worktree is removed only when ALL of these hold: +# 1. its HEAD is an ancestor of origin/dev (merge commits make this a +# reliable merged-content check for this repository), +# 2. its tree is clean (no staged, unstaged, or untracked changes), +# 3. it has not been touched for at least MIN_AGE_HOURS (default 48), +# so an agent's in-flight checkout is never yanked mid-run. +# +# Branches and commits are never deleted; `git worktree remove` only +# deletes the working directory. Pass --dry-run to preview. +# +# Usage: scripts/prune_merged_worktrees.sh [--dry-run] [repo-path] + +set -euo pipefail + +DRY_RUN=0 +if [ "${1:-}" = "--dry-run" ]; then + DRY_RUN=1 + shift +fi +REPO="${1:-$(git rev-parse --show-toplevel 2>/dev/null || true)}" +if [ -z "$REPO" ]; then + echo "error: not inside a git repository and no repo path given" >&2 + exit 2 +fi +MIN_AGE_HOURS="${MIN_AGE_HOURS:-48}" +NOW_EPOCH=$(date +%s) + +git -C "$REPO" fetch -q origin dev + +MAIN_WT=$(git -C "$REPO" worktree list --porcelain | awk '/^worktree /{print $2; exit}') + +git -C "$REPO" worktree list --porcelain | awk '/^worktree /{print $2}' | while read -r wt_path; do + [ "$wt_path" = "$MAIN_WT" ] && continue + if ! wt_head=$(git -C "$wt_path" rev-parse HEAD 2>/dev/null); then + # Directory or linkage already gone; `git worktree prune` handles it. + continue + fi + if [ -n "$(git -C "$wt_path" status --porcelain 2>/dev/null)" ]; then + echo "keep (dirty): $wt_path" + continue + fi + if ! git -C "$REPO" merge-base --is-ancestor "$wt_head" origin/dev; then + echo "keep (unmerged): $wt_path" + continue + fi + wt_mtime=$(stat -f %m "$wt_path/.git" 2>/dev/null || stat -c %Y "$wt_path/.git" 2>/dev/null || echo 0) + age_hours=$(( (NOW_EPOCH - wt_mtime) / 3600 )) + if [ "$age_hours" -lt "$MIN_AGE_HOURS" ]; then + echo "keep (recent, ${age_hours}h): $wt_path" + continue + fi + if [ "$DRY_RUN" -eq 1 ]; then + echo "would remove: $wt_path" + else + git -C "$REPO" worktree remove "$wt_path" + echo "removed: $wt_path" + fi +done + +if [ "$DRY_RUN" -eq 0 ]; then + git -C "$REPO" worktree prune +fi +git -C "$REPO" worktree list From 02b6b18e679868d47a956acf559e36ffe42749a1 Mon Sep 17 00:00:00 2001 From: Tom Tupper Date: Tue, 28 Jul 2026 13:25:55 -0500 Subject: [PATCH 2/3] fix: space-safe worktree parsing and a real activity signal Porcelain paths are now stripped with sed rather than awk field splitting, so paths containing spaces survive. The staleness guard now takes the newest mtime of the linkage file and the admin git-dir's HEAD and index, because the linkage file is written once at creation and never again, which made every worktree look idle regardless of use. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01262jKrQwepcSQtv1vk2chH --- scripts/prune_merged_worktrees.sh | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/scripts/prune_merged_worktrees.sh b/scripts/prune_merged_worktrees.sh index 6762ea712..5f661a3c9 100755 --- a/scripts/prune_merged_worktrees.sh +++ b/scripts/prune_merged_worktrees.sh @@ -30,9 +30,11 @@ NOW_EPOCH=$(date +%s) git -C "$REPO" fetch -q origin dev -MAIN_WT=$(git -C "$REPO" worktree list --porcelain | awk '/^worktree /{print $2; exit}') +# Porcelain lines are "worktree "; strip the prefix with sed so paths +# containing spaces survive intact. +MAIN_WT=$(git -C "$REPO" worktree list --porcelain | sed -n 's/^worktree //p' | head -n 1) -git -C "$REPO" worktree list --porcelain | awk '/^worktree /{print $2}' | while read -r wt_path; do +git -C "$REPO" worktree list --porcelain | sed -n 's/^worktree //p' | while IFS= read -r wt_path; do [ "$wt_path" = "$MAIN_WT" ] && continue if ! wt_head=$(git -C "$wt_path" rev-parse HEAD 2>/dev/null); then # Directory or linkage already gone; `git worktree prune` handles it. @@ -46,7 +48,21 @@ git -C "$REPO" worktree list --porcelain | awk '/^worktree /{print $2}' | while echo "keep (unmerged): $wt_path" continue fi - wt_mtime=$(stat -f %m "$wt_path/.git" 2>/dev/null || stat -c %Y "$wt_path/.git" 2>/dev/null || echo 0) + # Activity signal: the linked worktree's admin dir (its real git-dir) + # has entries (HEAD, index, logs) whose mtimes move on checkouts, + # commits, and resets; the .git linkage file alone is written once at + # creation and never again, so it cannot distinguish an active checkout + # from an abandoned one. Take the newest of the linkage file and the + # admin dir's HEAD/index. + wt_gitdir=$(git -C "$wt_path" rev-parse --absolute-git-dir 2>/dev/null || echo "") + wt_mtime=0 + for probe in "$wt_path/.git" "$wt_gitdir/HEAD" "$wt_gitdir/index"; do + [ -e "$probe" ] || continue + probe_mtime=$(stat -f %m "$probe" 2>/dev/null || stat -c %Y "$probe" 2>/dev/null || echo 0) + if [ "$probe_mtime" -gt "$wt_mtime" ]; then + wt_mtime=$probe_mtime + fi + done age_hours=$(( (NOW_EPOCH - wt_mtime) / 3600 )) if [ "$age_hours" -lt "$MIN_AGE_HOURS" ]; then echo "keep (recent, ${age_hours}h): $wt_path" From 933fd51e2868a61d7f1026ba703b5763098edd48 Mon Sep 17 00:00:00 2001 From: Tom Tupper Date: Tue, 28 Jul 2026 14:02:08 -0500 Subject: [PATCH 3/3] fix: GNU-safe mtime probing and self-removal guard GNU stat -f is filesystem mode where %m is the mount point, so the BSD-first probe order succeeded with a non-mtime string on Linux and made recently-touched worktrees look ancient; GNU -c %Y now probes first. The loop also skips the worktree the script is invoked from, since $REPO resolves to the linked worktree when run inside one. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01262jKrQwepcSQtv1vk2chH --- scripts/prune_merged_worktrees.sh | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/scripts/prune_merged_worktrees.sh b/scripts/prune_merged_worktrees.sh index 5f661a3c9..cc27cf9e5 100755 --- a/scripts/prune_merged_worktrees.sh +++ b/scripts/prune_merged_worktrees.sh @@ -25,6 +25,7 @@ if [ -z "$REPO" ]; then echo "error: not inside a git repository and no repo path given" >&2 exit 2 fi +REPO=$(cd "$REPO" && pwd) MIN_AGE_HOURS="${MIN_AGE_HOURS:-48}" NOW_EPOCH=$(date +%s) @@ -36,6 +37,10 @@ MAIN_WT=$(git -C "$REPO" worktree list --porcelain | sed -n 's/^worktree //p' | git -C "$REPO" worktree list --porcelain | sed -n 's/^worktree //p' | while IFS= read -r wt_path; do [ "$wt_path" = "$MAIN_WT" ] && continue + # Never remove the worktree this script is running from: when invoked + # inside a linked worktree, $REPO resolves to that worktree, and + # removing one's own working directory mid-run is never intended. + [ "$wt_path" = "$REPO" ] && continue if ! wt_head=$(git -C "$wt_path" rev-parse HEAD 2>/dev/null); then # Directory or linkage already gone; `git worktree prune` handles it. continue @@ -58,7 +63,10 @@ git -C "$REPO" worktree list --porcelain | sed -n 's/^worktree //p' | while IFS= wt_mtime=0 for probe in "$wt_path/.git" "$wt_gitdir/HEAD" "$wt_gitdir/index"; do [ -e "$probe" ] || continue - probe_mtime=$(stat -f %m "$probe" 2>/dev/null || stat -c %Y "$probe" 2>/dev/null || echo 0) + # GNU stat first (-c %Y); BSD stat second (-f %m). The reverse + # order is a trap: on GNU, -f is filesystem mode and %m is the + # mount point, which SUCCEEDS with a non-mtime string. + probe_mtime=$(stat -c %Y "$probe" 2>/dev/null || stat -f %m "$probe" 2>/dev/null || echo 0) if [ "$probe_mtime" -gt "$wt_mtime" ]; then wt_mtime=$probe_mtime fi