fix(workflow): explicit wt-rm + wt-new --no-track (worktree-helper corrections) #43
@@ -284,8 +284,10 @@ operational rules an agent must follow:
|
||||
chore/ refactor/ test/ ci/`) matching the commit type.
|
||||
- **One worktree per branch — never switch the primary checkout.** Start a
|
||||
branch with `scripts/wt-new.sh <branch>` (creates branch off `origin/main`
|
||||
+ a sibling `<repo>-worktree-<segment>` worktree); tidy merged ones with
|
||||
`scripts/wt-clean.sh`. This prevents the same-directory clobber hazard.
|
||||
+ a sibling `<repo>-worktree-<segment>` worktree); remove a finished one
|
||||
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
|
||||
(branch start *or* mid-branch): `scripts/adr-reserve.sh <slug> "<title>"`
|
||||
atomically claims the next number against `main` (ledger
|
||||
|
||||
@@ -75,9 +75,17 @@ Helpers:
|
||||
|
||||
- **`scripts/wt-new.sh <branch>`** — fetch `main`, create the branch off
|
||||
`origin/main`, and add the sibling worktree; prints its path.
|
||||
- **`scripts/wt-clean.sh [--dry-run]`** — prune stale entries and remove
|
||||
worktrees whose branch is merged into `origin/main` (refuses dirty ones;
|
||||
never touches the primary or current worktree). Run it whenever.
|
||||
- **`scripts/wt-rm.sh <branch>`** — remove the worktree for the **named**
|
||||
branch, and nothing else. It does **not** scan for "merged" worktrees to
|
||||
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
|
||||
|
||||
@@ -164,7 +172,7 @@ test the script but not run its pushing path.
|
||||
get an ADR"). The maintainer-facing mechanics (worktrees, the reserve
|
||||
lock, the whitelist) live here and in `CLAUDE.md`, not in CONTRIBUTING.
|
||||
- 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,
|
||||
idempotence, race-retry discriminator, and hard-abort paths are all
|
||||
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
|
||||
# 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 ===="
|
||||
|
||||
@@ -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