Merge pull request 'fix(workflow): explicit wt-rm + wt-new --no-track (worktree-helper corrections)' (#43) from fix/wt-rm-explicit-target into main
ci / gate (push) Successful in 2m8s
ci / manifests (push) Successful in 4s

Reviewed-on: #43
This commit was merged in pull request #43.
This commit is contained in:
oli
2026-06-23 22:42:01 +01:00
7 changed files with 142 additions and 96 deletions
+5 -2
View File
@@ -284,8 +284,11 @@ 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 with `--no-track` + a sibling `<repo>-worktree-<segment>` worktree; first
`scripts/wt-clean.sh`. This prevents the same-directory clobber hazard. push is `git push -u origin <branch>`); 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 - **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
+16 -5
View File
@@ -74,10 +74,21 @@ directory is how work gets clobbered — worktrees make that impossible.
Helpers: 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` (with `--no-track`, so it doesn't inherit `origin/main` as
- **`scripts/wt-clean.sh [--dry-run]`** — prune stale entries and remove upstream — otherwise a bare `git push` errors on the name mismatch and
worktrees whose branch is merged into `origin/main` (refuses dirty ones; suggests the dangerous `HEAD:main`), and add the sibling worktree; prints
never touches the primary or current worktree). Run it whenever. its path. First push is `git push -u origin <branch>`.
- **`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 ### D3 — Merge commits (`--no-ff`); never rebase or squash
@@ -164,7 +175,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
View File
File diff suppressed because one or more lines are too long
+45 -15
View File
@@ -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,53 @@ 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)"
# It must NOT inherit origin/main as upstream (else a bare `git push` errors
# with a name-mismatch and suggests the dangerous `HEAD:main`). No upstream
# until the first `git push -u` sets the right one.
check "new branch has no upstream" "none" "$(git -C "$target1" rev-parse --abbrev-ref '@{upstream}' 2>/dev/null || echo none)"
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 ===="
-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)"
+7 -1
View File
@@ -47,7 +47,13 @@ log "fetching origin/main"
git fetch --quiet origin main || die "git fetch origin main failed" git fetch --quiet origin main || die "git fetch origin main failed"
log "creating branch '$branch' + worktree at $target" log "creating branch '$branch' + worktree at $target"
git worktree add --quiet -b "$branch" "$target" origin/main \ # `--no-track`: branch off origin/main for a fresh base, but DON'T inherit
# origin/main as the upstream. Without this, branching off a remote-tracking
# ref makes git set the new branch's upstream to origin/main, so a later bare
# `git push` fails with a name-mismatch and suggests the dangerous
# `git push origin HEAD:main`. With no upstream, the first `git push -u origin
# <branch>` sets the correct one.
git worktree add --quiet --no-track -b "$branch" "$target" origin/main \
|| die "git worktree add failed" || die "git worktree add failed"
log "ready — cd into it:" log "ready — cd into it:"
+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"