diff --git a/README.md b/README.md index f456380b..732742b6 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ These scripts run pull request reviews through Claude Code or Codex. The `review | Script | Purpose | | ------ | ------- | | [`check-disk-space`](bin/check-disk-space) | Disk space monitor with warning (85%) and critical (90%) macOS notifications. | -| [`disk-cleanup`](bin/disk-cleanup) | Modular cleanup orchestrator (Docker, Homebrew, Node, Python, Rust, Xcode caches). | +| [`disk-cleanup`](bin/disk-cleanup) | Modular cleanup orchestrator (Docker, OrbStack, Homebrew, Node, Python, Rust, Xcode caches, orphaned and merged worktrees). | | [`kube-region`](bin/kube-region) | Switch `kubectl` context between PostHog environments with AWS SSO integration. | | [`copy-html-to-clipboard.swift`](bin/copy-html-to-clipboard.swift) | Pipe HTML on stdin to the macOS clipboard as rich text. | | [`set-defaults.sh`](bin/set-defaults.sh) | Apply macOS defaults from `macos/set-defaults.sh`. | diff --git a/bin/cleanup-modules/orbstack.sh b/bin/cleanup-modules/orbstack.sh new file mode 100644 index 00000000..c5b73a4d --- /dev/null +++ b/bin/cleanup-modules/orbstack.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# OrbStack cleanup module +# Removes data images that OrbStack set aside as corrupt + +# shellcheck disable=SC2154 +# (log_message and run_cleanup are provided by parent script) + +cleanup_orbstack() { + local mode="${1:-conservative}" data_dir image size + + for data_dir in "$HOME/Library/Group Containers/"*.dev.orbstack/data; do + [ -d "$data_dir" ] || continue + log_message "Running OrbStack cleanup..." + + # OrbStack renames a data image it cannot repair to *.corrupt and starts + # a new data.img.raw. Without the new image, the .corrupt one may be the + # only copy of the Docker data. + if [ ! -f "$data_dir/data.img.raw" ]; then + log_message "No live OrbStack data image in $data_dir, keeping corrupt images" + continue + fi + + for image in "$data_dir"/*.corrupt; do + [ -f "$image" ] || continue + size=$(du -sh "$image" 2>/dev/null | cut -f1) + # The new image starts empty, so the corrupt one may hold the only + # copy of older containers and volumes. + if [ "$mode" = "aggressive" ]; then + run_cleanup "Remove corrupt OrbStack data image $image ($size)" "rm -f $(printf '%q' "$image")" + else + log_message " Corrupt OrbStack data image (--aggressive removes it): $image ($size)" + fi + done + done + return 0 +} diff --git a/bin/cleanup-modules/worktrees.sh b/bin/cleanup-modules/worktrees.sh new file mode 100644 index 00000000..39998277 --- /dev/null +++ b/bin/cleanup-modules/worktrees.sh @@ -0,0 +1,335 @@ +#!/bin/bash +# Worktree cleanup module +# Finds worktree directories that no git repo tracks and worktrees whose PR merged + +# shellcheck disable=SC2154 +# (log_message and run_cleanup are provided by parent script) + +# Branch names with slashes put worktrees at ///, +# so the scan looks one level deeper than that before giving up. +WORKTREE_SCAN_MAX_DEPTH=5 + +# A directory with an entry modified this recently may be a worktree that +# `git worktree add` is still creating, or one someone still edits. The module +# never reports such a directory as an orphan. +WORKTREE_ORPHAN_MIN_AGE_MINUTES=60 + +cleanup_worktrees() { + local mode="${1:-conservative}" + local roots=( + "$HOME/dev/worktrees" + "$HOME/.supacode/repos" + "$HOME/.agents/skills/review-code/.worktrees" + ) + local root kind path size reason + local worktrees=() orphans=() + + log_message "Running worktree cleanup (mode: $mode)..." + + for root in "${roots[@]}"; do + # lsof reports resolved paths, so the scan works on resolved paths too. + root=$(cd "$root" 2>/dev/null && pwd -P) || continue + while IFS=$'\t' read -r kind path; do + if [ "$kind" = worktree ]; then + worktrees+=("$path") + else + orphans+=("$path") + fi + done < <(scan_worktree_dir "$root" 0) + done + + # An empty list would make every directory look idle. The module therefore + # removes nothing when lsof reports no working directories. + local cwds + cwds=$(process_working_dirs) + if [ -z "$cwds" ]; then + log_message "Could not list process working directories, keeping all worktree directories" + return 0 + fi + + if [ ${#orphans[@]} -eq 0 ]; then + log_message "No orphaned worktree directories" + fi + for path in ${orphans[@]+"${orphans[@]}"}; do + if process_in_dir "$path" "$cwds"; then + log_message " Orphaned worktree directory has a process working in it, keeping: $path" + continue + fi + size=$(du -sh "$path" 2>/dev/null | cut -f1) + # Git cannot tell whether a checkout it no longer tracks holds edits. + if dir_holds_checkout "$path"; then + log_message " Orphaned checkout that may hold uncommitted edits, check it and delete it by hand: $path ($size)" + elif [ "$mode" = "aggressive" ]; then + run_cleanup "Remove orphaned worktree directory $path ($size)" \ + "$(printf '%s && rm -rf %q' "$(make_writable_cmd "$path")" "$path")" + else + log_message " Orphaned worktree directory: $path ($size)" + fi + done + + if ! command -v gh >/dev/null 2>&1 || ! command -v jq >/dev/null 2>&1; then + log_message "gh or jq not installed, skipping merged worktree check" + return 0 + fi + + local pr number merged_oid common_dir + for path in ${worktrees[@]+"${worktrees[@]}"}; do + pr=$(merged_pr_for_worktree "$path") || continue + number="${pr%%$'\t'*}" + merged_oid="${pr#*$'\t'}" + if reason=$(worktree_removal_blocker "$path" "$merged_oid"); then + log_message " Worktree for merged PR #$number has $reason, keeping: $path" + continue + elif supacode_may_have_session "$path"; then + log_message " Worktree for merged PR #$number has an open Supacode session, keeping: $path" + continue + elif process_in_dir "$path" "$cwds"; then + log_message " Worktree for merged PR #$number has a process working in it, keeping: $path" + continue + fi + size=$(du -sh "$path" 2>/dev/null | cut -f1) + if [ "$mode" = "aggressive" ]; then + common_dir=$(git -C "$path" rev-parse --path-format=absolute --git-common-dir) + # The second --force overrides the lock Supacode puts on worktrees it + # manages. worktree_removal_blocker refuses every other lock. + run_cleanup "Remove worktree for merged PR #$number: $path ($size)" \ + "$(printf '%s && git -C %q worktree remove --force --force %q' "$(make_writable_cmd "$path")" "$common_dir" "$path")" + else + log_message " Worktree for merged PR #$number: $path ($size)" + fi + done + + if [ "$mode" != "aggressive" ]; then + log_message "Run with --aggressive to remove the directories listed above" + fi + return 0 +} + +# Prints "worktreepath" for each live worktree or clone under $1 and +# "orphanpath" for the topmost directories that hold none. Returns 0 when +# $1 holds a live worktree or a checkout that may be live. At depth 0 it reports +# orphaned children even when the root holds no live worktree, so a root is +# never reported as an orphan. The scan stops at a checkout and at the depth +# limit, so orphan_reportable checks the whole tree of each orphan it reports. +scan_worktree_dir() { + local dir="$1" depth="$2" child holds_worktree=1 + local orphans=() + + if [ -e "$dir/.git" ]; then + if is_live_worktree "$dir"; then + printf 'worktree\t%s\n' "$dir" + return 0 + fi + checkout_proven_stale "$dir" && return 1 + return 0 + fi + [ "$depth" -lt "$WORKTREE_SCAN_MAX_DEPTH" ] || return 1 + + for child in "$dir"/*/; do + child="${child%/}" + [ -d "$child" ] && [ ! -L "$child" ] || continue + # The output has one path per line, so a name with a newline would + # make the caller act on a different path. + case "$child" in *$'\n'*) holds_worktree=0; continue ;; esac + if scan_worktree_dir "$child" $((depth + 1)); then + holds_worktree=0 + else + orphans+=("$child") + fi + done + + if [ "$holds_worktree" -eq 0 ] || [ "$depth" -eq 0 ]; then + for child in ${orphans[@]+"${orphans[@]}"}; do + if orphan_reportable "$child"; then + printf 'orphan\t%s\n' "$child" + fi + done + fi + return "$holds_worktree" +} + +# Prints the admin dir that the .git file in checkout $1 names, as an absolute path. +git_file_admin_dir() { + local dir="$1" line="" + IFS= read -r line 2>/dev/null <"$dir/.git" || [ -n "$line" ] || return 1 + case "$line" in "gitdir: "*) line="${line#gitdir: }" ;; *) return 1 ;; esac + case "$line" in /*) ;; *) line="$dir/$line" ;; esac + printf '%s\n' "$line" +} + +# Prints the .git file path that admin dir $1 records for its worktree, as an +# absolute path. +admin_dir_git_file() { + local admin="$1" recorded="" + IFS= read -r recorded 2>/dev/null <"$admin/gitdir" || [ -n "$recorded" ] || return 1 + case "$recorded" in /*) ;; *) recorded="$admin/$recorded" ;; esac + printf '%s\n' "$recorded" +} + +# Succeeds when $1/.git is a clone, or a .git file whose admin dir records $1 as +# its worktree or is not a worktree admin dir at all (a submodule). Paths are +# compared with -ef because APFS ignores case and git records paths as given. +is_live_worktree() { + local dir="$1" admin + [ -d "$dir/.git" ] && return 0 + admin=$(git_file_admin_dir "$dir") || return 1 + [ -d "$admin" ] || return 1 + [ -f "$admin/gitdir" ] || return 0 + [ "$(admin_dir_git_file "$admin")" -ef "$dir/.git" ] +} + +# Succeeds only when no repo can still use checkout $1. That holds when the +# repo that the .git file names still exists but has dropped the admin dir, or +# when the admin dir belongs to another checkout that still points back at it. +# A renamed repo or a worktree moved with mv fails here, because +# `git worktree repair` can still recover it. +checkout_proven_stale() { + local dir="$1" admin recorded other_admin + [ -d "$dir/.git" ] && return 1 + admin=$(git_file_admin_dir "$dir") || return 1 + if [ ! -e "$admin" ]; then + case "$admin" in */worktrees/*) ;; *) return 1 ;; esac + [ -f "${admin%/worktrees/*}/HEAD" ] + return + fi + [ -f "$admin/gitdir" ] || return 1 + recorded=$(admin_dir_git_file "$admin") || return 1 + [ "$recorded" -ef "$dir/.git" ] && return 1 + other_admin=$(git_file_admin_dir "${recorded%/.git}") || return 1 + [ "$other_admin" -ef "$admin" ] +} + +# Succeeds when $1 is worth reporting as an orphan: nothing under it changed +# within the freshness window, it holds a file other than .DS_Store, and no +# live checkout sits anywhere under it. +orphan_reportable() { + local dir="$1" found git_path + [ -n "$(find "$dir" -mmin -"$WORKTREE_ORPHAN_MIN_AGE_MINUTES" -print -quit 2>/dev/null)" ] && return 1 + [ -n "$(find "$dir" ! -type d ! -name .DS_Store -print -quit 2>/dev/null)" ] || return 1 + # find exits non-zero when it cannot read a directory, which could hide a checkout. + found=$(find "$dir" -name .git -prune -print) || return 1 + while IFS= read -r git_path; do + [ -n "$git_path" ] || continue + is_live_worktree "${git_path%/.git}" && return 1 + done <<<"$found" + return 0 +} + +# Succeeds when a .git entry sits anywhere under $1, or when find cannot tell. +dir_holds_checkout() { + local found + found=$(find "$1" -name .git -print -quit 2>/dev/null) || return 0 + [ -n "$found" ] +} + +# Prints a command that gives the owner write permission on every directory +# under $1. Go makes its module cache read-only. `rm -rf` and +# `git worktree remove` fail partway through a read-only directory. +make_writable_cmd() { + printf 'find %q -type d ! -perm -u+w -exec chmod u+w {} +' "$1" +} + +# Prints the working directory of every process this user can see, one per line. +process_working_dirs() { + lsof -a -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' +} + +# Succeeds when a line of $2 is directory $1 or a path inside it. +process_in_dir() { + local dir="$1" cwds="$2" cwd + while IFS= read -r cwd; do + case "$cwd" in "$dir" | "$dir"/*) return 0 ;; esac + done <<<"$cwds" + return 1 +} + +# Succeeds when Supacode has a tab open in worktree $1 or cannot answer, for +# example because the app or the CLI is missing. Fails when Supacode does not +# know the worktree or has no tab open there. A merged worktree with a session +# may still be in use, for example to watch a deploy. Supacode IDs are +# URL-encoded paths as Supacode was given them, so each one is decoded and +# compared with -ef. +supacode_may_have_session() { + local wt="$1" ids id tabs + command -v supacode >/dev/null 2>&1 || return 0 + ids=$(supacode worktree list --timeout 10 2>/dev/null) || return 0 + while IFS= read -r id; do + [ -n "$id" ] || continue + [ "$(printf '%b' "${id//%/\\x}")" -ef "$wt" ] || continue + tabs=$(supacode tab list --worktree "$id" --timeout 10 2>/dev/null) || return 0 + [ -n "$tabs" ] && return 0 + done <<<"$ids" + return 1 +} + +# Prints "" for the most recent merged GitHub PR from the +# branch checked out in worktree $1. Fails for a detached HEAD, a non-GitHub +# remote, or a branch with no merged PR. +merged_pr_for_worktree() { + local wt="$1" branch url repo + branch=$(git -C "$wt" symbolic-ref --quiet --short HEAD) || return 1 + url=$(git -C "$wt" remote get-url origin 2>/dev/null) || return 1 + case "$url" in *github.com[:/]*) ;; *) return 1 ;; esac + repo=$(printf '%s\n' "$url" | sed -E 's#.*github\.com[:/]##; s#\.git$##') + gh pr list --repo "$repo" --head "$branch" --state merged --json number,headRefOid 2>/dev/null | + jq -er '.[0] // empty | "\(.number)\t\(.headRefOid)"' +} + +# Succeeds when ignored path $1 is build output or a cache that a build or an +# install recreates. +ignored_path_regenerable() { + case "/${1%/}/" in + */node_modules/* | */__pycache__/* | */.venv/* | */.flox/* | */target/* | */dist/* | */build/* | \ + */.turbo/* | */.next/* | */.cache/* | */.parcel-cache/* | */.mypy_cache/* | */.pytest_cache/* | \ + */.ruff_cache/* | */.hypothesis/* | */coverage/*) return 0 ;; + esac + case "${1##*/}" in + .DS_Store | .eslintcache | tsconfig.tsbuildinfo | *.pyc) return 0 ;; + esac + return 1 +} + +# Prints what removing worktree $1 would lose and succeeds, or fails when +# removal loses nothing. Removal loses nothing when HEAD is the PR's merged head +# commit $2, git status runs and shows no change, every ignored file is build +# output, no file hides an edit behind skip-worktree or assume-unchanged, no +# other checkout lives inside it, and no tool but Supacode has locked it. +worktree_removal_blocker() { + local wt="$1" merged_oid="$2" status line flags admin + if [ "$(git -C "$wt" rev-parse HEAD 2>/dev/null)" != "$merged_oid" ]; then + echo "commits after the merge" + return 0 + fi + if ! status=$(git -C "$wt" status --porcelain --ignored=matching --untracked-files=normal --ignore-submodules=none 2>/dev/null); then + echo "a git status that failed" + return 0 + fi + while IFS= read -r line; do + [ -n "$line" ] || continue + case "$line" in + "!! "*) + ignored_path_regenerable "${line#!! }" && continue + echo "ignored files that are not build output, such as ${line#!! }" + return 0 + ;; + *) + echo "uncommitted or untracked changes" + return 0 + ;; + esac + done <<<"$status" + if ! flags=$(git -C "$wt" ls-files -v 2>/dev/null) || grep -q '^[a-zS]' <<<"$flags"; then + echo "files marked skip-worktree or assume-unchanged" + return 0 + fi + if [ -n "$(find "$wt" -mindepth 2 -name .git -print -quit 2>/dev/null)" ]; then + echo "another checkout inside it" + return 0 + fi + admin=$(git -C "$wt" rev-parse --absolute-git-dir 2>/dev/null) || { echo "an unreadable git dir"; return 0; } + if [ -f "$admin/locked" ] && ! grep -qF '"owner":"supacode"' "$admin/locked"; then + echo "a lock that Supacode does not hold" + return 0 + fi + return 1 +} diff --git a/bin/disk-cleanup b/bin/disk-cleanup index 757741e4..ab56b33c 100755 --- a/bin/disk-cleanup +++ b/bin/disk-cleanup @@ -26,7 +26,7 @@ Usage: disk-cleanup [OPTIONS] Options: --aggressive Run cleanup in aggressive mode (removes more data) --include-cargo Also clean shared CARGO_TARGET_DIR (large but slow to rebuild) - --module MODULE Run only the specified module (homebrew, docker, node, python, rust) + --module MODULE Run only the specified module (homebrew, worktrees, node, docker, orbstack, python, rust) --dry-run Show what would be cleaned without actually cleaning --help Show this help message @@ -39,7 +39,13 @@ Examples: Available modules: homebrew - Homebrew packages and cache + worktrees - Worktree directories no git repo tracks, and worktrees whose PR merged + (lists them; --aggressive removes them, skipping any that may hold + edits or notes, have an open Supacode tab, or have a process working + in them) docker - Docker containers, images, networks, volumes + orbstack - Data images OrbStack set aside as corrupt (lists them; + --aggressive removes them) node - npm, yarn, pnpm caches python - pip, UV, pytest, puppeteer caches rust - Rust target directories and cargo cache @@ -144,8 +150,10 @@ if [ -n "$SPECIFIC_MODULE" ]; then else # Run all modules in order run_module "homebrew" + run_module "worktrees" run_module "node" run_module "docker" + run_module "orbstack" run_module "python" run_module "rust" diff --git a/bin/lib/test-cleanup-orbstack.sh b/bin/lib/test-cleanup-orbstack.sh new file mode 100755 index 00000000..b3a98ab6 --- /dev/null +++ b/bin/lib/test-cleanup-orbstack.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# Tests for bin/cleanup-modules/orbstack.sh. +# +# Usage: test-cleanup-orbstack.sh +# +# Builds an OrbStack data directory under a fake HOME and drives the module. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=bin/lib/test-helpers.sh +source "$SCRIPT_DIR/test-helpers.sh" +# shellcheck source=bin/cleanup-modules/orbstack.sh +source "$SCRIPT_DIR/../cleanup-modules/orbstack.sh" + +log_message() { :; } +run_cleanup() { + [ "$DRY_RUN" = true ] && return 0 + eval "$2" || echo "Failed: $1" +} +DRY_RUN=false + +FAKE_HOME=$(mktemp -d) +trap 'rm -rf "$FAKE_HOME"' EXIT +HOME="$FAKE_HOME" +DATA="$FAKE_HOME/Library/Group Containers/TEAMID.dev.orbstack/data" +mkdir -p "$DATA" + +touch "$DATA/data.img.raw.corrupt" +cleanup_orbstack aggressive +assert "keeps the corrupt image when no live image exists" test -f "$DATA/data.img.raw.corrupt" + +touch "$DATA/data.img.raw" "$DATA/swap.img" +ln -s data.img.raw "$DATA/data.img" +DRY_RUN=true cleanup_orbstack aggressive +assert "dry run keeps the corrupt image" test -f "$DATA/data.img.raw.corrupt" + +cleanup_orbstack conservative +assert "conservative mode keeps the corrupt image" test -f "$DATA/data.img.raw.corrupt" + +cleanup_orbstack aggressive +assert_not "removes the corrupt image once a live image exists" test -e "$DATA/data.img.raw.corrupt" +assert "keeps the live image" test -f "$DATA/data.img.raw" +assert "keeps the swap image" test -f "$DATA/swap.img" + +cleanup_orbstack aggressive +assert "runs cleanly with nothing to remove" test -f "$DATA/data.img.raw" + +print_results diff --git a/bin/lib/test-cleanup-worktrees.sh b/bin/lib/test-cleanup-worktrees.sh new file mode 100755 index 00000000..9f6397e6 --- /dev/null +++ b/bin/lib/test-cleanup-worktrees.sh @@ -0,0 +1,442 @@ +#!/bin/bash +# Tests for bin/cleanup-modules/worktrees.sh: orphaned worktree directory +# detection and the merged-PR worktree removal guard. +# +# Usage: test-cleanup-worktrees.sh +# +# Builds a throwaway repo and worktree roots under a fake HOME, stubs gh, and +# drives the module's functions directly. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=bin/lib/test-helpers.sh +source "$SCRIPT_DIR/test-helpers.sh" +# shellcheck source=bin/cleanup-modules/worktrees.sh +source "$SCRIPT_DIR/../cleanup-modules/worktrees.sh" + +log_message() { echo "$1"; } +# Like disk-cleanup's run_cleanup, this stub reports a failed command and +# continues. A removal that fails then shows up as a failed assertion. +run_cleanup() { + [ "$DRY_RUN" = true ] && return 0 + eval "$2" || echo "Failed: $1" +} +DRY_RUN=false + +# Resolve through symlinks (macOS /var -> /private/var) so the literal paths we +# build match the canonical paths the module compares. +FAKE_HOME=$(cd "$(mktemp -d)" && pwd -P) +STUB_DIR="$FAKE_HOME/stubs" +REPO_DIR="$FAKE_HOME/dev/acme/widgets" +ROOT="$FAKE_HOME/dev/worktrees" +cleanup() { + git -C "$REPO_DIR" worktree list --porcelain 2>/dev/null | + sed -n 's/^worktree //p' | + while read -r wt; do git -C "$REPO_DIR" worktree unlock "$wt" 2>/dev/null || true; done + chmod -R u+w "$FAKE_HOME" + rm -rf "$FAKE_HOME" +} +trap cleanup EXIT + +mkdir -p "$STUB_DIR" "$REPO_DIR" "$ROOT" +git -C "$REPO_DIR" init -q +git -C "$REPO_DIR" config user.email test@example.com +git -C "$REPO_DIR" config user.name "Test" +git -C "$REPO_DIR" checkout -q -b main +git -C "$REPO_DIR" remote add origin https://github.com/acme/widgets.git +echo one > "$REPO_DIR/file" +git -C "$REPO_DIR" add file +git -C "$REPO_DIR" commit -qm "one" + +mkworktree() { + local branch="$1" path="$ROOT/widgets/$1" + git -C "$REPO_DIR" worktree add -q "$path" -b "$branch" main + printf '%s\n' "$path" +} + +# Sets the mtime of every entry under the given paths to 2020, so the module's +# freshness guard treats them as settled rather than as a worktree being created. +age() { find "$@" -exec touch -h -t 202001010000 {} +; } + +# lsof stub: prints each line of $LSOF_STUB/cwds as a process working directory. +LSOF_STUB="$FAKE_HOME/lsof-stub" +mkdir -p "$LSOF_STUB" +echo /elsewhere >"$LSOF_STUB/cwds" +cat >"$LSOF_STUB/lsof" <<'STUB' +#!/bin/bash +while IFS= read -r cwd; do printf 'p1\nfcwd\nn%s\n' "$cwd"; done <"$(dirname "$0")/cwds" +STUB +chmod +x "$LSOF_STUB/lsof" +ORPHAN_PATH="$LSOF_STUB:/usr/bin:/bin" + +# supacode stub: every command fails when $STUB_DIR/down exists. +# `supacode worktree list` prints $STUB_DIR/ids, and `supacode tab list +# --worktree ` prints $STUB_DIR/tabs- when present. +cat >"$STUB_DIR/supacode" <<'STUB' +#!/bin/bash +dir="$(dirname "$0")" +command="$1 $2" +id="" +while [ $# -gt 0 ]; do + [ "$1" = "--worktree" ] && id="$2" + shift +done +if [ -e "$dir/down" ]; then echo "Error: Supacode is not running" >&2; exit 1; fi +case "$command" in + "worktree list") cat "$dir/ids" 2>/dev/null ;; + "tab list") [ -f "$dir/tabs-$id" ] && cat "$dir/tabs-$id" ;; +esac +exit 0 +STUB +chmod +x "$STUB_DIR/supacode" +supacode_id() { jq -rn --arg p "$1/" '$p | @uri'; } +# Registers path $1 with the Supacode stub, with an open tab when $2 is "tab". +supacode_register() { + local id + id=$(supacode_id "$1") + echo "$id" >>"$STUB_DIR/ids" + if [ "${2:-}" = tab ]; then echo "TAB-1" >"$STUB_DIR/tabs-$id"; fi +} + +# gh stub: `gh pr list --head ...` prints $STUB_DIR/pr-.json, +# with slashes in the branch replaced by dashes, or [] when that file is absent. +cat >"$STUB_DIR/gh" <<'STUB' +#!/bin/bash +branch="" +while [ $# -gt 0 ]; do + [ "$1" = "--head" ] && branch="$2" + shift +done +file="$(dirname "$0")/pr-${branch//\//-}.json" +if [ -f "$file" ]; then cat "$file"; else echo '[]'; fi +STUB +chmod +x "$STUB_DIR/gh" +stub_merged_pr() { + local branch="$1" oid="$2" + printf '[{"number":42,"headRefOid":"%s"}]\n' "$oid" >"$STUB_DIR/pr-${branch//\//-}.json" +} + +scan() { scan_worktree_dir "$ROOT" 0; } +has_line() { grep -qxF "$1" <<<"$2"; } + +# Runs the module under the fake HOME with PATH $1 and the mode in $2. +run_module() { ( HOME="$FAKE_HOME" PATH="$1" cleanup_worktrees "$2" >/dev/null ); } +merged_pr() { PATH="$STUB_DIR:$PATH" merged_pr_for_worktree "$1"; } +removable() { ! worktree_removal_blocker "$@" >/dev/null; } + +# Gives $1 a read-only directory tree, like a Go module cache. +mk_readonly_gomod() { + mkdir -p "$1/.flox/cache/go/mod/pkg" + echo x >"$1/.flox/cache/go/mod/pkg/file" + chmod 555 "$1/.flox/cache/go/mod/pkg" "$1/.flox/cache/go/mod" +} + +# ── Orphan detection ────────────────────────────────────────────────────── + +live=$(mkworktree haacked/live) + +# A checkout whose admin dir git has since dropped. +stale=$(mkworktree haacked/stale) +git -C "$REPO_DIR" worktree remove --force "$stale" +mkdir -p "$stale/src" +echo "gitdir: $REPO_DIR/.git/worktrees/stale" >"$stale/.git" +echo work >"$stale/src/file" + +# A copy of a live worktree: its admin dir points at the original, not here. +copy="$ROOT/widgets/haacked/copy" +mkdir -p "$copy" +cp "$live/.git" "$copy/.git" + +# Leftovers a running process recreated after the worktree was removed. +residue="$ROOT/widgets/haacked/residue" +mkdir -p "$residue/.flox/log" +echo log >"$residue/.flox/log/run.log" + +# The module reports a repo directory with no live worktree once, at the top. +mkdir -p "$ROOT/gadgets/haacked/one/node_modules/pkg" "$ROOT/gadgets/haacked/two" +echo x >"$ROOT/gadgets/haacked/one/node_modules/pkg/index.js" +echo x >"$ROOT/gadgets/haacked/two/log" + +# Leftovers holding a read-only directory, like a Go module cache. +readonly_orphan="$ROOT/widgets/haacked/readonly" +mk_readonly_gomod "$readonly_orphan" + +# Leftovers that a process still has as its working directory. +busy_orphan="$ROOT/widgets/haacked/busy" +mkdir -p "$busy_orphan/logs" +echo log >"$busy_orphan/logs/run.log" +echo "$busy_orphan/logs" >>"$LSOF_STUB/cwds" + +# Directories with nothing to reclaim. +mkdir -p "$ROOT/widgets/empty" "$ROOT/widgets/ds-store-only/haacked/nested" +touch "$ROOT/widgets/ds-store-only/haacked/.DS_Store" + +# A full clone is never an orphan. +git clone -q "$REPO_DIR" "$ROOT/clone" + +# A live worktree deeper than the scan looks. +git -C "$REPO_DIR" worktree add -q "$ROOT/deep/a/b/c/d/e" -b haacked/a/b/c/d/e main + +# A live worktree in a hidden directory of leftovers, like a Claude agent worktree. +hidden_parent="$ROOT/hidden/haacked/residue" +mkdir -p "$hidden_parent/logs" +echo log >"$hidden_parent/logs/run.log" +git -C "$REPO_DIR" worktree add -q "$hidden_parent/.claude/worktrees/agent" -b agent-hidden main + +# A live worktree nested inside a checkout whose admin dir git dropped. +nested_stale="$ROOT/nested/haacked/stale" +mkdir -p "$nested_stale" +echo "gitdir: $REPO_DIR/.git/worktrees/nested-stale" >"$nested_stale/.git" +git -C "$REPO_DIR" worktree add -q "$nested_stale/.claude/worktrees/agent" -b agent-nested main + +# Worktrees of a repo that was renamed afterward. git worktree repair recovers them. +moved_repo="$FAKE_HOME/dev/acme/renamed" +git clone -q "$REPO_DIR" "$moved_repo" +git -C "$moved_repo" worktree add -q "$ROOT/renamed/haacked/wt" -b wt +echo work >"$ROOT/renamed/haacked/wt/work.txt" +mv "$moved_repo" "$moved_repo-new" +mkdir -p "$ROOT/renamed/haacked/residue" +echo log >"$ROOT/renamed/haacked/residue/run.log" + +# A live worktree moved with mv. Its admin dir still records the old path. +git -C "$REPO_DIR" worktree add -q "$ROOT/widgets/haacked/before-move" -b moved main +mv "$ROOT/widgets/haacked/before-move" "$ROOT/widgets/haacked/after-move" + +# A live worktree whose .git file has no trailing newline. +no_newline=$(mkworktree haacked/no-newline) +printf 'gitdir: %s' "$REPO_DIR/.git/worktrees/no-newline" >"$no_newline/.git" + +# A stale checkout holding a package that ships a submodule .git file whose +# relative gitdir names a repo inside the checkout that does not exist. +packaged="$ROOT/widgets/haacked/packaged" +mkdir -p "$packaged/.venv/site-packages/pkg/core" +echo "gitdir: $REPO_DIR/.git/worktrees/packaged" >"$packaged/.git" +echo "gitdir: ../../.git/modules/core" >"$packaged/.venv/site-packages/pkg/core/.git" +echo x >"$packaged/.venv/site-packages/pkg/core/lib.py" + +# A live worktree that git recorded through a differently cased path. APFS +# ignores case, so both spellings name the same directory. +cased="$ROOT/widgets/haacked/cased" +git -C "$REPO_DIR" worktree add -q "$FAKE_HOME/dev/WORKTREES/widgets/haacked/cased" -b cased main +echo work >"$cased/work.txt" + +# A live worktree with edits next to leftovers whose name holds a newline. +feature=$(mkworktree haacked/feature) +echo work >"$feature/work.txt" +mkdir -p "$ROOT/widgets/haacked/feature +x" +echo log >"$ROOT/widgets/haacked/feature +x/run.log" + +# Leftovers a process works in, under a root that is a symlink. lsof reports +# the resolved path. +REVIEW_REAL="$FAKE_HOME/review-real" +mkdir -p "$REVIEW_REAL/repo/busy" "$FAKE_HOME/.agents/skills/review-code" +ln -s "$REVIEW_REAL" "$FAKE_HOME/.agents/skills/review-code/.worktrees" +echo log >"$REVIEW_REAL/repo/busy/run.log" +echo "$REVIEW_REAL/repo/busy" >>"$LSOF_STUB/cwds" + +# A stale checkout with an old top directory and a file edited just now. +deep_edit="$ROOT/widgets/haacked/deep-edit" +mkdir -p "$deep_edit/src" +echo "gitdir: $REPO_DIR/.git/worktrees/deep-edit" >"$deep_edit/.git" +echo old >"$deep_edit/src/file" + +age "$ROOT" "$REVIEW_REAL" + +# A directory changed just now, next to old leftovers whose name extends it with +# a newline. +mkdir -p "$ROOT/widgets/haacked/recent" +echo work >"$ROOT/widgets/haacked/recent/work.txt" +mkdir -p "$ROOT/widgets/haacked/recent +x" +echo log >"$ROOT/widgets/haacked/recent +x/run.log" +age "$ROOT/widgets/haacked/recent +x" +echo new >"$deep_edit/src/file" + +# Created within the freshness window, like a worktree mid-checkout. +fresh="$ROOT/widgets/haacked/fresh" +mkdir -p "$fresh" +echo x >"$fresh/file" + +out=$(scan) +assert "live worktree is reported as a worktree" has_line "worktree $live" "$out" +assert "full clone is reported as a worktree" has_line "worktree $ROOT/clone" "$out" +assert "checkout whose admin dir is gone is an orphan" has_line "orphan $stale" "$out" +assert "copy pointing at another worktree's admin dir is an orphan" has_line "orphan $copy" "$out" +assert "residue without .git is an orphan" has_line "orphan $residue" "$out" +assert "repo dir with no live worktree is reported once at the top" has_line "orphan $ROOT/gadgets" "$out" +assert_not "leaves under an orphaned repo dir are not reported separately" has_line "orphan $ROOT/gadgets/haacked/one" "$out" +assert_not "live worktree is not an orphan" has_line "orphan $live" "$out" +assert_not "parent of a live worktree is not an orphan" has_line "orphan $ROOT/widgets/haacked" "$out" +assert_not "full clone is not an orphan" has_line "orphan $ROOT/clone" "$out" +assert_not "empty dir is not reported" has_line "orphan $ROOT/widgets/empty" "$out" +assert_not "dir holding only empty dirs and .DS_Store is not reported" has_line "orphan $ROOT/widgets/ds-store-only" "$out" +assert_not "freshly created dir is not reported" has_line "orphan $fresh" "$out" +assert_not "dir holding a live worktree deeper than the scan is not an orphan" has_line "orphan $ROOT/deep" "$out" +assert_not "leftovers holding a live worktree in a hidden dir are not an orphan" has_line "orphan $ROOT/hidden" "$out" +assert_not "stale checkout holding a live worktree is not an orphan" has_line "orphan $ROOT/nested" "$out" +assert_not "worktree of a renamed repo is not an orphan" has_line "orphan $ROOT/renamed" "$out" +assert "leftovers next to a renamed repo's worktree are an orphan" has_line "orphan $ROOT/renamed/haacked/residue" "$out" +assert_not "worktree moved with mv is not an orphan" has_line "orphan $ROOT/widgets/haacked/after-move" "$out" +assert "worktree whose .git file has no trailing newline is live" has_line "worktree $no_newline" "$out" +assert "stale checkout holding a dangling packaged .git file is an orphan" has_line "orphan $packaged" "$out" +assert_not "stale checkout with a file edited just now is not reported" has_line "orphan $deep_edit" "$out" +assert "worktree recorded through a differently cased path is live" has_line "worktree $cased" "$out" +assert_not "worktree recorded through a differently cased path is not an orphan" has_line "orphan $cased" "$out" + +# A submodule-style .git file points at an admin dir with no gitdir file. +sub="$ROOT/widgets/sub" +mkdir -p "$sub" "$REPO_DIR/.git/modules/sub" +echo "gitdir: $REPO_DIR/.git/modules/sub" >"$sub/.git" +age "$sub" +assert_not "dir whose admin dir exists without a gitdir file is not an orphan" has_line "orphan $sub" "$(scan)" +rm -rf "$sub" "$REPO_DIR/.git/modules" + +# ── Orphan removal by mode ─────────────────────────────────────────────── + +run_module "$ORPHAN_PATH" conservative +assert "conservative mode keeps orphans" test -d "$residue" +assert "conservative mode keeps the stale checkout" test -f "$stale/src/file" + +mv "$LSOF_STUB/cwds" "$LSOF_STUB/cwds.saved" +: >"$LSOF_STUB/cwds" +run_module "$ORPHAN_PATH" aggressive +assert "aggressive mode keeps orphans when lsof reports nothing" test -d "$residue" +mv "$LSOF_STUB/cwds.saved" "$LSOF_STUB/cwds" + +git -C "$REPO_DIR" worktree prune +run_module "$ORPHAN_PATH" aggressive +assert_not "aggressive mode removes residue" test -e "$residue" +assert "aggressive mode keeps a stale checkout, which may hold edits" test -f "$stale/src/file" +assert "aggressive mode keeps a copy of a worktree, which may hold edits" test -f "$copy/.git" +assert "aggressive mode keeps a worktree moved with mv after git pruned it" test -f "$ROOT/widgets/haacked/after-move/file" +assert "aggressive mode keeps the worktree recorded through a differently cased path" test -f "$cased/work.txt" +assert "aggressive mode keeps a worktree next to a name with a newline" test -f "$feature/work.txt" +assert "aggressive mode keeps a recent dir next to a name with a newline" test -f "$ROOT/widgets/haacked/recent/work.txt" +assert "aggressive mode keeps leftovers a process works in under a symlinked root" test -f "$REVIEW_REAL/repo/busy/run.log" +assert_not "aggressive mode removes the orphaned repo dir" test -e "$ROOT/gadgets" +assert "aggressive mode keeps the live worktree" test -f "$live/file" +assert "aggressive mode keeps the full clone" test -d "$ROOT/clone/.git" +assert "aggressive mode keeps the fresh dir" test -d "$fresh" +assert_not "aggressive mode removes an orphan holding a read-only dir" test -e "$readonly_orphan" +assert "aggressive mode keeps an orphan a process works in" test -f "$busy_orphan/logs/run.log" +assert "aggressive mode keeps a live worktree deeper than the scan" test -f "$ROOT/deep/a/b/c/d/e/file" +assert "aggressive mode keeps a live worktree in a hidden dir" test -f "$hidden_parent/.claude/worktrees/agent/file" +assert "aggressive mode keeps a live worktree inside a stale checkout" test -f "$nested_stale/.claude/worktrees/agent/file" +assert "aggressive mode keeps the renamed repo's worktree" test -f "$ROOT/renamed/haacked/wt/work.txt" +assert "aggressive mode keeps the worktree moved with mv" test -f "$ROOT/widgets/haacked/after-move/file" + +DRY_RUN=true run_module "$ORPHAN_PATH" aggressive +assert "dry run keeps the fresh dir" test -d "$fresh" + +# ── Merged-PR worktrees ────────────────────────────────────────────────── + +printf '.flox/\n.claude/\nnode_modules/\n.notes/\n' >>"$REPO_DIR/.git/info/exclude" +merged_clean=$(mkworktree haacked/merged-clean) +merged_case=$(mkworktree haacked/merged-case) +merged_locked=$(mkworktree haacked/merged-locked) +merged_notes=$(mkworktree haacked/merged-notes) +merged_skip=$(mkworktree haacked/merged-skip) +merged_nested=$(mkworktree haacked/merged-nested) +merged_tab=$(mkworktree haacked/merged-tab) +merged_busy=$(mkworktree haacked/merged-busy) +merged_unknown=$(mkworktree haacked/merged-unknown) +merged_dirty=$(mkworktree haacked/merged-dirty) +merged_ahead=$(mkworktree haacked/merged-ahead) +unmerged=$(mkworktree haacked/unmerged) +detached="$ROOT/widgets/detached" +git -C "$REPO_DIR" worktree add -q --detach "$detached" main +git -C "$REPO_DIR" worktree lock --reason '{"owner":"supacode","version":"0.11.6"}' "$merged_clean" +git -C "$REPO_DIR" worktree lock --reason "manual" "$merged_locked" + +for wt in "$merged_clean" "$merged_case" "$merged_locked" "$merged_notes" "$merged_skip" "$merged_nested" "$merged_tab" "$merged_busy" "$merged_unknown" "$merged_dirty" "$merged_ahead"; do + stub_merged_pr "haacked/$(basename "$wt")" "$(git -C "$wt" rev-parse HEAD)" +done +mk_readonly_gomod "$merged_clean" +mkdir -p "$merged_clean/node_modules/pkg" +echo x >"$merged_clean/node_modules/pkg/index.js" +mkdir -p "$merged_notes/.notes" +echo notes >"$merged_notes/.notes/handoff.md" +echo edit >"$merged_skip/file" +git -C "$merged_skip" update-index --skip-worktree file +git -C "$REPO_DIR" worktree add -q "$merged_nested/.claude/worktrees/agent" -b agent-merged main +echo work >"$merged_nested/.claude/worktrees/agent/work.txt" +supacode_register "$merged_clean" +supacode_register "$merged_tab" tab +supacode_register "$(dirname "$merged_case")/MERGED-CASE" tab +echo "$merged_busy" >>"$LSOF_STUB/cwds" +echo edit >"$merged_dirty/file" +echo more >"$merged_ahead/file" +git -C "$merged_ahead" commit -qam "after merge" +rm -rf "$fresh" + +assert "merged worktree reports the PR number and merged head" \ + test "$(merged_pr "$merged_clean")" = "42 $(git -C "$merged_clean" rev-parse HEAD)" +assert_not "worktree without a merged PR reports nothing" merged_pr "$unmerged" +assert_not "detached worktree reports nothing" merged_pr "$detached" + +assert "clean merged worktree with ignored build output is removable" removable "$merged_clean" "$(git -C "$merged_clean" rev-parse HEAD)" +assert_not "merged worktree with uncommitted changes is not removable" \ + removable "$merged_dirty" "$(git -C "$REPO_DIR" rev-parse main)" +assert_not "merged worktree with commits after the merged head is not removable" \ + removable "$merged_ahead" "$(git -C "$REPO_DIR" rev-parse main)" +echo untracked >"$merged_clean/new-file" +assert_not "merged worktree with untracked files is not removable" \ + removable "$merged_clean" "$(git -C "$merged_clean" rev-parse HEAD)" +git -C "$REPO_DIR" config status.showUntrackedFiles no +assert_not "untracked files count even when the repo hides them from status" \ + removable "$merged_clean" "$(git -C "$merged_clean" rev-parse HEAD)" +git -C "$REPO_DIR" config --unset status.showUntrackedFiles +rm "$merged_clean/new-file" + +broken_index=$(mkworktree haacked/broken-index) +echo edit >"$broken_index/file" +echo garbage >"$(git -C "$broken_index" rev-parse --git-dir)/index" +assert_not "worktree whose status cannot be read is not removable" \ + removable "$broken_index" "$(git -C "$REPO_DIR" rev-parse main)" +git -C "$REPO_DIR" worktree remove --force "$broken_index" + +assert_not "merged worktree holding another worktree is not removable" \ + removable "$merged_nested" "$(git -C "$merged_nested" rev-parse HEAD)" +assert_not "merged worktree with ignored notes is not removable" \ + removable "$merged_notes" "$(git -C "$merged_notes" rev-parse HEAD)" +assert_not "merged worktree with a skip-worktree edit is not removable" \ + removable "$merged_skip" "$(git -C "$merged_skip" rev-parse HEAD)" +assert_not "merged worktree with a lock Supacode does not hold is not removable" \ + removable "$merged_locked" "$(git -C "$merged_locked" rev-parse HEAD)" + +without_supacode() { PATH=/usr/bin:/bin supacode_may_have_session "$1"; } +assert "a missing supacode CLI counts as a possible session" without_supacode "$merged_clean" + +MERGED_PATH="$STUB_DIR:$LSOF_STUB:$PATH" +run_module "$MERGED_PATH" conservative +assert "conservative mode keeps the merged worktree" test -d "$merged_clean" + +touch "$STUB_DIR/down" +run_module "$MERGED_PATH" aggressive +assert "aggressive mode keeps merged worktrees when Supacode cannot answer" test -d "$merged_clean" +rm "$STUB_DIR/down" + +run_module "$MERGED_PATH" aggressive +assert_not "aggressive mode removes the clean, locked merged worktree with a read-only dir" test -e "$merged_clean" +assert_not "aggressive mode removes a merged worktree Supacode does not know" test -e "$merged_unknown" +assert "aggressive mode keeps a merged worktree with an open Supacode tab" test -d "$merged_tab" +assert "aggressive mode keeps a merged worktree Supacode knows by another spelling with a tab" test -d "$merged_case" +assert "aggressive mode keeps a merged worktree with ignored notes" test -f "$merged_notes/.notes/handoff.md" +assert "aggressive mode keeps a merged worktree with a skip-worktree edit" test -d "$merged_skip" +assert "aggressive mode keeps a merged worktree with a manual lock" test -d "$merged_locked" +assert "aggressive mode keeps a merged worktree a process works in" test -d "$merged_busy" +assert "aggressive mode keeps a merged worktree holding another worktree" test -f "$merged_nested/.claude/worktrees/agent/work.txt" +assert_not "git no longer lists the removed merged worktree" \ + grep -qxF "worktree $merged_clean" <<<"$(git -C "$REPO_DIR" worktree list --porcelain)" +assert "aggressive mode keeps the dirty merged worktree" test -f "$merged_dirty/file" +assert "aggressive mode keeps the merged worktree with later commits" test -d "$merged_ahead" +assert "aggressive mode keeps the unmerged worktree" test -d "$unmerged" +assert "aggressive mode keeps the detached worktree" test -d "$detached" +assert "aggressive mode keeps the merged branch" git -C "$REPO_DIR" show-ref --verify --quiet refs/heads/haacked/merged-clean + +print_results