fix(workflow): wt-rm removes only the named worktree (was auto-sweeping merged ones)

The first wt-clean.sh removed every worktree whose branch was merged into
origin/main — but long-lived branches (website, the ci line) are merged too,
so it would have deleted their worktrees. Caught on review before any real use.

Replace the auto-sweep with an explicit `wt-rm <branch>` that removes only the
named worktree; safety comes from git's own refusals — it won't remove a dirty
worktree (no --force) and deletes the local branch only when it's merged into
origin/main, otherwise keeps it (and its unmerged commits).

Update test-wt.sh to 15 checks (incl. "unnamed worktree untouched", primary
refused, dirty refused, unmerged branch preserved) and the wt-clean references
in ADR-0059 / CLAUDE.md / the README index.
This commit is contained in:
claude@clouddev1
2026-06-23 21:27:41 +00:00
parent 3585cca5ea
commit 4570c4e1ea
6 changed files with 126 additions and 94 deletions
+41 -15
View File
@@ -1,13 +1,15 @@
#!/usr/bin/env bash
# Smoke-tests wt-new.sh / wt-clean.sh against a local file:// origin.
# Smoke-tests wt-new.sh / wt-rm.sh against a local file:// origin.
set -euo pipefail
here="$(cd "$(dirname "$0")" && pwd)"
WT_NEW="$here/wt-new.sh"
WT_CLEAN="$here/wt-clean.sh"
WT_RM="$here/wt-rm.sh"
PASS=0 FAIL=0
check() { if [ "$2" = "$3" ]; then echo "PASS: $1 ($3)"; PASS=$((PASS+1));
else echo "FAIL: $1 — expected '$2' got '$3'"; FAIL=$((FAIL+1)); fi; }
exists() { [ -d "$1" ] && echo yes || echo no; }
has_branch() { git -C "$T/primary" show-ref --verify --quiet "refs/heads/$1" && echo yes || echo no; }
T="$(mktemp -d)"
trap 'rm -rf "$T"' EXIT
@@ -23,7 +25,8 @@ git -C "$T/primary" branch -M main
git -C "$T/primary" push -q origin main
run() { ( cd "$T/primary" && "$@" ); }
target="$T/primary-worktree-thing-one"
target1="$T/primary-worktree-thing-one"
target2="$T/primary-worktree-thing-two"
echo "--- wt-new rejects a non-conventional branch name ---"
set +e; run bash "$WT_NEW" my-thing >/dev/null 2>&1; rc=$?; set -e
@@ -31,26 +34,49 @@ check "bad-prefix rejected" "yes" "$( [ "$rc" -ne 0 ] && echo yes || echo no )"
echo "--- wt-new creates branch + sibling worktree ---"
out="$(run bash "$WT_NEW" feat/thing-one 2>/dev/null)"
check "prints target path" "$target" "$out"
check "worktree dir exists" "yes" "$( [ -d "$target" ] && echo yes || echo no )"
check "worktree on the new branch" "feat/thing-one" "$(git -C "$target" rev-parse --abbrev-ref HEAD)"
check "prints target path" "$target1" "$out"
check "worktree dir exists" "yes" "$(exists "$target1")"
check "worktree on the new branch" "feat/thing-one" "$(git -C "$target1" rev-parse --abbrev-ref HEAD)"
echo "--- wt-new refuses to clobber an existing branch ---"
set +e; run bash "$WT_NEW" feat/thing-one >/dev/null 2>&1; rc=$?; set -e
check "duplicate rejected" "yes" "$( [ "$rc" -ne 0 ] && echo yes || echo no )"
echo "--- wt-clean removes a MERGED worktree (and keeps an unmerged one) ---"
# thing-one: make it merged into origin/main (push its tip to main).
git -C "$target" commit -q --allow-empty -m "thing-one work"
git -C "$target" push -q origin HEAD:main
# thing-two: a second worktree that is NOT merged.
# Second worktree, and make the merge states explicit:
# thing-one merged into origin/main (push its tip to main),
# thing-two → ahead of main (unmerged), clean.
run bash "$WT_NEW" feat/thing-two >/dev/null 2>&1
target2="$T/primary-worktree-thing-two"
git -C "$target1" commit -q --allow-empty -m "thing-one work"
git -C "$target1" push -q origin HEAD:main
git -C "$target2" commit -q --allow-empty -m "thing-two work (unmerged)"
git -C "$T/primary" fetch -q origin
run bash "$WT_CLEAN" >/dev/null 2>&1
check "merged worktree removed" "no" "$( [ -d "$target" ] && echo yes || echo no )"
check "unmerged worktree kept" "yes" "$( [ -d "$target2" ] && echo yes || echo no )"
echo "--- wt-rm removes ONLY the named worktree (the other is untouched) ---"
run bash "$WT_RM" feat/thing-one >/dev/null 2>&1
check "named worktree removed" "no" "$(exists "$target1")"
check "UNNAMED worktree untouched" "yes" "$(exists "$target2")"
check "merged branch deleted" "no" "$(has_branch feat/thing-one)"
echo "--- wt-rm errors on an unknown branch ---"
set +e; run bash "$WT_RM" feat/nope >/dev/null 2>&1; rc=$?; set -e
check "unknown branch rejected" "yes" "$( [ "$rc" -ne 0 ] && echo yes || echo no )"
echo "--- wt-rm refuses the primary checkout ---"
set +e; run bash "$WT_RM" main >/dev/null 2>&1; rc=$?; set -e
check "primary refused (nonzero)" "yes" "$( [ "$rc" -ne 0 ] && echo yes || echo no )"
check "primary still present" "yes" "$(exists "$T/primary")"
echo "--- wt-rm refuses a dirty worktree ---"
touch "$target2/untracked-file"
set +e; run bash "$WT_RM" feat/thing-two >/dev/null 2>&1; rc=$?; set -e
check "dirty refused (nonzero)" "yes" "$( [ "$rc" -ne 0 ] && echo yes || echo no )"
check "dirty worktree kept" "yes" "$(exists "$target2")"
rm -f "$target2/untracked-file"
echo "--- wt-rm removes the worktree but KEEPS an unmerged branch ---"
run bash "$WT_RM" feat/thing-two >/dev/null 2>&1
check "unmerged worktree removed" "no" "$(exists "$target2")"
check "unmerged branch preserved" "yes" "$(has_branch feat/thing-two)"
echo
echo "==== $PASS passed, $FAIL failed ===="
-72
View File
@@ -1,72 +0,0 @@
#!/usr/bin/env bash
#
# wt-clean.sh — tidy up worktrees whose branch has merged.
#
# WHAT it does (safe by default):
# 1. `git worktree prune` — drop admin entries for worktrees whose
# directory is already gone.
# 2. For every *other* worktree (never the primary checkout, never the one
# you're standing in) whose branch is fully merged into origin/main:
# remove the worktree and delete the local branch.
#
# SAFETY: it refuses to remove a worktree with uncommitted changes (no
# --force) and only ever touches branches git confirms are merged into
# origin/main, so unmerged work is never lost. Run it whenever; it's not
# tied to any moment.
#
# USAGE
# scripts/wt-clean.sh # do it
# scripts/wt-clean.sh --dry-run # show what it would remove
set -euo pipefail
log() { printf ' wt-clean: %s\n' "$*" >&2; }
dry=0
[ "${1:-}" = "--dry-run" ] && dry=1
git rev-parse --git-dir >/dev/null 2>&1 || { echo "wt-clean: not in a git repo" >&2; exit 1; }
log "fetching origin/main"
git fetch --quiet --prune origin main || true
log "pruning stale worktree entries"
if (( dry )); then
git worktree prune --dry-run -v
else
git worktree prune -v
fi
# The primary checkout and the current worktree are off-limits.
main_wt="$(git worktree list --porcelain | awk '/^worktree /{print $2; exit}')"
here="$(git rev-parse --show-toplevel)"
# Branches fully merged into origin/main (whitespace-trimmed, sans markers).
merged="$(git branch --format='%(refname:short)' --merged origin/main)"
is_merged() { grep -qxF "$1" <<<"$merged"; }
removed=0
# Iterate worktrees: paired "worktree <path>" / "branch refs/heads/<name>".
path=""; while read -r key val; do
case "$key" in
worktree) path="$val" ;;
branch)
br="${val#refs/heads/}"
if [ "$path" != "$main_wt" ] && [ "$path" != "$here" ] && is_merged "$br"; then
if (( dry )); then
log "would remove worktree $path (branch $br, merged)"
else
if git worktree remove "$path" 2>/dev/null; then
git branch -d "$br" >/dev/null 2>&1 || true
log "removed worktree $path + branch $br"
removed=$((removed+1))
else
log "skipped $path — has uncommitted changes (remove it by hand)"
fi
fi
fi
;;
esac
done < <(git worktree list --porcelain)
(( dry )) || log "done — removed $removed merged worktree(s)"
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
#
# wt-rm.sh — remove the worktree for a named branch.
#
# EXPLICIT BY DESIGN: it removes ONLY the branch you name. There is no
# scanning and no "is it merged into main?" heuristic to decide *what* to
# remove — long-lived branches (e.g. `website`, the `ci` line) are merged
# into main too, so auto-detection would happily delete them. Naming the
# target is the safety.
#
# Safety still comes from git's own refusals, not from cleverness here:
# - the worktree won't be removed if it has uncommitted/untracked changes
# (no `--force`);
# - the local branch is deleted ONLY if it is fully merged into
# `origin/main` — otherwise the worktree goes but the branch (and its
# unmerged commits) is kept.
#
# USAGE
# scripts/wt-rm.sh <branch>
# scripts/wt-rm.sh feat/clause-concept-hints
#
# For a worktree whose directory you already deleted by hand, use the
# built-in `git worktree prune` (it only clears bookkeeping — never a
# directory or a branch).
set -euo pipefail
log() { printf ' wt-rm: %s\n' "$*" >&2; }
die() { printf 'wt-rm: ERROR: %s\n' "$*" >&2; exit 1; }
[ $# -eq 1 ] || die "usage: wt-rm.sh <branch>"
branch="$1"
git rev-parse --git-dir >/dev/null 2>&1 || die "not inside a git repo"
# Resolve the named branch to its worktree path.
path=""; cur=""
while read -r key val; do
case "$key" in
worktree) cur="$val" ;;
branch) [ "${val#refs/heads/}" = "$branch" ] && path="$cur" ;;
esac
done < <(git worktree list --porcelain)
[ -n "$path" ] || die "no worktree is checked out on branch '$branch' (see: git worktree list)"
main_wt="$(git worktree list --porcelain | awk '/^worktree /{print $2; exit}')"
here="$(git rev-parse --show-toplevel)"
[ "$path" != "$main_wt" ] || die "refusing to remove the primary checkout ($path)"
[ "$path" != "$here" ] || die "refusing to remove the worktree you're standing in — run this from elsewhere"
log "removing worktree $path (branch $branch)"
git worktree remove "$path" \
|| die "could not remove $path — it likely has uncommitted or untracked changes; clean it (or remove by hand) first"
# Delete the local branch only if it is safely merged into origin/main;
# otherwise keep it so unmerged commits are never lost. (No auto-fetch — it
# judges against your current origin/main; run `git fetch` first if you want
# a freshly-merged branch to be eligible.)
if git rev-parse --verify --quiet origin/main >/dev/null \
&& git merge-base --is-ancestor "$branch" origin/main; then
git branch -D "$branch" >/dev/null
log "deleted local branch $branch (merged into origin/main)"
else
log "kept local branch $branch — not merged into origin/main; delete with 'git branch -D $branch' if you're sure"
fi
log "done"