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:
@@ -284,8 +284,10 @@ operational rules an agent must follow:
|
|||||||
chore/ refactor/ test/ ci/`) matching the commit type.
|
chore/ refactor/ test/ ci/`) matching the commit type.
|
||||||
- **One worktree per branch — never switch the primary checkout.** Start a
|
- **One worktree per branch — never switch the primary checkout.** Start a
|
||||||
branch with `scripts/wt-new.sh <branch>` (creates branch off `origin/main`
|
branch with `scripts/wt-new.sh <branch>` (creates branch off `origin/main`
|
||||||
+ a sibling `<repo>-worktree-<segment>` worktree); tidy merged ones with
|
+ a sibling `<repo>-worktree-<segment>` worktree); remove a finished one
|
||||||
`scripts/wt-clean.sh`. This prevents the same-directory clobber hazard.
|
with `scripts/wt-rm.sh <branch>` (removes only the *named* worktree — it
|
||||||
|
never scans or auto-removes, so long-lived branches like `website`/`ci`
|
||||||
|
are never at risk). This prevents the same-directory clobber hazard.
|
||||||
- **Reserve ADR numbers up front** the moment an ADR is known to be needed
|
- **Reserve ADR numbers up front** the moment an ADR is known to be needed
|
||||||
(branch start *or* mid-branch): `scripts/adr-reserve.sh <slug> "<title>"`
|
(branch start *or* mid-branch): `scripts/adr-reserve.sh <slug> "<title>"`
|
||||||
atomically claims the next number against `main` (ledger
|
atomically claims the next number against `main` (ledger
|
||||||
|
|||||||
@@ -75,9 +75,17 @@ Helpers:
|
|||||||
|
|
||||||
- **`scripts/wt-new.sh <branch>`** — fetch `main`, create the branch off
|
- **`scripts/wt-new.sh <branch>`** — fetch `main`, create the branch off
|
||||||
`origin/main`, and add the sibling worktree; prints its path.
|
`origin/main`, and add the sibling worktree; prints its path.
|
||||||
- **`scripts/wt-clean.sh [--dry-run]`** — prune stale entries and remove
|
- **`scripts/wt-rm.sh <branch>`** — remove the worktree for the **named**
|
||||||
worktrees whose branch is merged into `origin/main` (refuses dirty ones;
|
branch, and nothing else. It does **not** scan for "merged" worktrees to
|
||||||
never touches the primary or current worktree). Run it whenever.
|
auto-remove: long-lived branches (`website`, the `ci` line) are merged
|
||||||
|
into `main` too, so a merged-detection sweep would wrongly delete them
|
||||||
|
(caught immediately on review, before any real use — the initial design
|
||||||
|
was an auto-sweep `wt-clean`). Naming the target is the safety. Git's own
|
||||||
|
refusals do the rest: it won't remove a dirty worktree (no `--force`),
|
||||||
|
and it deletes the local branch only if it is merged into `origin/main`
|
||||||
|
(otherwise the worktree goes but the branch + its unmerged commits stay).
|
||||||
|
Stale bookkeeping for a hand-deleted directory is the built-in
|
||||||
|
`git worktree prune`'s job.
|
||||||
|
|
||||||
### D3 — Merge commits (`--no-ff`); never rebase or squash
|
### D3 — Merge commits (`--no-ff`); never rebase or squash
|
||||||
|
|
||||||
@@ -164,7 +172,7 @@ test the script but not run its pushing path.
|
|||||||
get an ADR"). The maintainer-facing mechanics (worktrees, the reserve
|
get an ADR"). The maintainer-facing mechanics (worktrees, the reserve
|
||||||
lock, the whitelist) live here and in `CLAUDE.md`, not in CONTRIBUTING.
|
lock, the whitelist) live here and in `CLAUDE.md`, not in CONTRIBUTING.
|
||||||
- Three scripts enter `scripts/`: `adr-reserve.sh`, `wt-new.sh`,
|
- Three scripts enter `scripts/`: `adr-reserve.sh`, `wt-new.sh`,
|
||||||
`wt-clean.sh`, each with a local-`origin` test harness
|
`wt-rm.sh`, each with a local-`origin` test harness
|
||||||
(`test-adr-reserve.sh`, `test-wt.sh`) — the reserve script's allocation,
|
(`test-adr-reserve.sh`, `test-wt.sh`) — the reserve script's allocation,
|
||||||
idempotence, race-retry discriminator, and hard-abort paths are all
|
idempotence, race-retry discriminator, and hard-abort paths are all
|
||||||
covered (the real-remote push is exercised by the human running it).
|
covered (the real-remote push is exercised by the human running it).
|
||||||
|
|||||||
+1
-1
File diff suppressed because one or more lines are too long
+41
-15
@@ -1,13 +1,15 @@
|
|||||||
#!/usr/bin/env bash
|
#!/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
|
set -euo pipefail
|
||||||
|
|
||||||
here="$(cd "$(dirname "$0")" && pwd)"
|
here="$(cd "$(dirname "$0")" && pwd)"
|
||||||
WT_NEW="$here/wt-new.sh"
|
WT_NEW="$here/wt-new.sh"
|
||||||
WT_CLEAN="$here/wt-clean.sh"
|
WT_RM="$here/wt-rm.sh"
|
||||||
PASS=0 FAIL=0
|
PASS=0 FAIL=0
|
||||||
check() { if [ "$2" = "$3" ]; then echo "PASS: $1 ($3)"; PASS=$((PASS+1));
|
check() { if [ "$2" = "$3" ]; then echo "PASS: $1 ($3)"; PASS=$((PASS+1));
|
||||||
else echo "FAIL: $1 — expected '$2' got '$3'"; FAIL=$((FAIL+1)); fi; }
|
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)"
|
T="$(mktemp -d)"
|
||||||
trap 'rm -rf "$T"' EXIT
|
trap 'rm -rf "$T"' EXIT
|
||||||
@@ -23,7 +25,8 @@ git -C "$T/primary" branch -M main
|
|||||||
git -C "$T/primary" push -q origin main
|
git -C "$T/primary" push -q origin main
|
||||||
|
|
||||||
run() { ( cd "$T/primary" && "$@" ); }
|
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 ---"
|
echo "--- wt-new rejects a non-conventional branch name ---"
|
||||||
set +e; run bash "$WT_NEW" my-thing >/dev/null 2>&1; rc=$?; set -e
|
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 ---"
|
echo "--- wt-new creates branch + sibling worktree ---"
|
||||||
out="$(run bash "$WT_NEW" feat/thing-one 2>/dev/null)"
|
out="$(run bash "$WT_NEW" feat/thing-one 2>/dev/null)"
|
||||||
check "prints target path" "$target" "$out"
|
check "prints target path" "$target1" "$out"
|
||||||
check "worktree dir exists" "yes" "$( [ -d "$target" ] && echo yes || echo no )"
|
check "worktree dir exists" "yes" "$(exists "$target1")"
|
||||||
check "worktree on the new branch" "feat/thing-one" "$(git -C "$target" rev-parse --abbrev-ref HEAD)"
|
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 ---"
|
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
|
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 )"
|
check "duplicate rejected" "yes" "$( [ "$rc" -ne 0 ] && echo yes || echo no )"
|
||||||
|
|
||||||
echo "--- wt-clean removes a MERGED worktree (and keeps an unmerged one) ---"
|
# Second worktree, and make the merge states explicit:
|
||||||
# thing-one: make it merged into origin/main (push its tip to main).
|
# thing-one → merged into origin/main (push its tip to main),
|
||||||
git -C "$target" commit -q --allow-empty -m "thing-one work"
|
# thing-two → ahead of main (unmerged), clean.
|
||||||
git -C "$target" push -q origin HEAD:main
|
|
||||||
# thing-two: a second worktree that is NOT merged.
|
|
||||||
run bash "$WT_NEW" feat/thing-two >/dev/null 2>&1
|
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 "$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
|
echo "--- wt-rm removes ONLY the named worktree (the other is untouched) ---"
|
||||||
check "merged worktree removed" "no" "$( [ -d "$target" ] && echo yes || echo no )"
|
run bash "$WT_RM" feat/thing-one >/dev/null 2>&1
|
||||||
check "unmerged worktree kept" "yes" "$( [ -d "$target2" ] && echo yes || echo no )"
|
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
|
||||||
echo "==== $PASS passed, $FAIL failed ===="
|
echo "==== $PASS passed, $FAIL failed ===="
|
||||||
|
|||||||
@@ -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)"
|
|
||||||
Executable
+68
@@ -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"
|
||||||
Reference in New Issue
Block a user