From 4570c4e1eabb20498d82d4ab76b121c65ab71d70 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Tue, 23 Jun 2026 21:27:41 +0000 Subject: [PATCH 1/2] fix(workflow): wt-rm removes only the named worktree (was auto-sweeping merged ones) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` 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. --- CLAUDE.md | 6 ++- docs/adr/0059-dev-workflow.md | 16 ++++++-- docs/adr/README.md | 2 +- scripts/test-wt.sh | 56 +++++++++++++++++++-------- scripts/wt-clean.sh | 72 ----------------------------------- scripts/wt-rm.sh | 68 +++++++++++++++++++++++++++++++++ 6 files changed, 126 insertions(+), 94 deletions(-) delete mode 100755 scripts/wt-clean.sh create mode 100755 scripts/wt-rm.sh diff --git a/CLAUDE.md b/CLAUDE.md index 6511e69..e78e42d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ` (creates branch off `origin/main` - + a sibling `-worktree-` worktree); tidy merged ones with - `scripts/wt-clean.sh`. This prevents the same-directory clobber hazard. + + a sibling `-worktree-` worktree); remove a finished one + with `scripts/wt-rm.sh ` (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 ""` atomically claims the next number against `main` (ledger diff --git a/docs/adr/0059-dev-workflow.md b/docs/adr/0059-dev-workflow.md index 59367bb..c01f20f 100644 --- a/docs/adr/0059-dev-workflow.md +++ b/docs/adr/0059-dev-workflow.md @@ -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). diff --git a/docs/adr/README.md b/docs/adr/README.md index 5668af3..593fe9d 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -70,4 +70,4 @@ This directory contains the project's ADRs, recorded per - [ADR-0055 — `curl | sh` install script (`scripts/install.sh`)](0055-curl-sh-install-script.md) — **Accepted + implemented 2026-06-17** (plan: `docs/plans/20260616-public-availability.md`, step 2; tracked by plan + ADR, no Gitea issue — user decision). A one-line installer (`curl -fsSL <gitea-raw>/scripts/install.sh | sh`) so beginners don't hand-pick an asset + `chmod +x`. **POSIX `sh`** (shellcheck-clean), detects `uname` OS/arch → target triple (**Linux → the fully-static `*-musl`** build, macOS → `*-apple-darwin`; `amd64`/`arm64` aliased; **Windows rejected** → Scoop/winget/releases page), resolves the version from the **`releases/latest`** API (or `RDBMS_VERSION` to pin), downloads the asset **and its `.sha256` and verifies it** (mismatch aborts), installs to `~/.local/bin` (`RDBMS_INSTALL_DIR` override) with a PATH hint. Testing seams: `RDBMS_OS`/`RDBMS_ARCH` + `--print-target`. macOS note: `curl` downloads aren't Gatekeeper-quarantined so the ad-hoc binary runs as-is (Developer-ID + notarization is the postponed signing task). **Verified end-to-end against the live public `v0.1.0`** (all platform mappings, pinned + latest, checksum incl. tamper-rejection, install + run). Rejected: website-domain hosting (extra moving part; Gitea raw is simplest); deferred: uploading the script as a release asset, and a **shellcheck CI gate** (shellcheck isn't in the flake — touches ADR-ci-002). **Amendment 1 (2026-06-17):** added a Windows **`scripts/install.ps1`** (`irm | iex`; maps host CPU → our `*-windows-gnu`/`-gnullvm` `.exe`, SHA-256-verifies, installs to `%LOCALAPPDATA%\Programs\…` + user PATH) — user chose both a one-liner *and* Scoop/winget; **written but untested from this env** (no PowerShell — validate on Windows). - [ADR-0056 — crates.io publish-readiness + `cargo binstall` metadata (D3)](0056-crates-io-and-cargo-binstall.md) — **Prepared 2026-06-17** (plan step 3a; tracked by plan + ADR). Makes the crate **ready to publish** to crates.io (user decision) and adds `cargo-binstall` metadata; the actual `cargo publish` is a **gated, irreversible maintainer step**. Manifest: drops `publish = false`; adds `homepage` (relplay.org), `keywords`, `categories`, and an `exclude` (`/website`,`/docs`,`/.gitea`,`/.codegraph`) trimming the crate from 585 files/8.3 MiB → **353/913 KiB compressed** (code-only). Authors **`README.md`** (engine-neutral, simple/advanced-mode wording; install via curl|sh/binstall/source/prebuilt) and **`LICENSE-MIT`** (© Lazy Evaluation Ltd — *confirm holder*); the canonical **`LICENSE-APACHE`** is deferred to the maintainer (don't ship retyped legal text) — the SPDX `license` field already satisfies crates.io. **binstall** (syntax verified vs cargo-binstall SUPPORT.md): `pkg-fmt = "bin"` (bare binaries), `pkg-url` spelled `v{ version }` (the placeholder omits the `v`), plus per-target **`overrides`** mapping the common host triples to the assets we ship — `*-linux-gnu` → the static `*-linux-musl` build, `*-pc-windows-msvc` → `*-gnu`/`-gnullvm` `.exe` (macOS matches directly; the docs promise no automatic fallback). **Ordering:** publish at a **new tagged version whose release exists**, after the release — **not `0.1.0`** (diverges from the already-released 0.1.0 binaries that predate `--version`). Verified: `cargo publish --dry-run` packages + verify-builds; `cargo metadata` confirms the binstall block + 4 overrides. **Unverified:** a real `cargo binstall` run (not a dep; nothing on crates.io yet) — validate at first publish. Rejected: cargo-dist (GitHub-centric). Maintainer follow-ups: confirm © holder, add canonical `LICENSE-APACHE`, real binstall validation. **Amendment 1 (2026-06-18):** `0.2.0` **published live** (crates.io; `cargo install` + `cargo binstall` verified — the unverified-overrides caveat is resolved), via a new **manual `workflow_dispatch`** workflow `.gitea/workflows/publish.yaml` (mirrors `release-macos.yaml`; `tag` input; `cargo publish` with a crate-scoped `CARGO_REGISTRY_TOKEN` secret). Publish stays **manual** by decision — irreversible (keeps the token off every tag push), the split release (tag Linux/Windows + dispatched macOS) makes a human the "all assets up" gate, and crates.io has no Gitea-Actions trusted-publishing path. Each registry is its **own idempotent job** (crates.io job no-ops if the version exists) so Scoop/Homebrew/winget can be added as sibling jobs without interfering. **Amendment 2 (2026-06-19):** **Scoop + Homebrew wired** (D3 §3b/§3c) as sibling `publish.yaml` jobs (`scoop-bucket`, `homebrew-tap`) that render manifests from the release `.sha256` sidecars and push to **org-level, multi-package** repos `lazyeval/scoop-bucket` + `lazyeval/homebrew-tap`. Credential: a scoped bot user **`lazyeval-ci`** (Gitea PATs scope by permission-category, not per-repo, so an `oli` token would over-reach to the main repo) on a `lazyeval` org team with Write to the package repos only; its PAT is the `LAZYEVAL_PKG_TOKEN` secret on `oli/rdbms-playground`. Render scripts (`scripts/render-{scoop-manifest,homebrew-formula}.sh`) are **dependency-free bash** (CI image `node:22-slim` has no jq/ruby), tested by `scripts/test-package-renders.sh`. Scoop: `#/`-rename fragment + `checkver`, no `autoupdate`. Homebrew: `on_macos`/`on_linux`×arch bare-binary formula, no Windows. **Unverified:** real `scoop`/`brew install`, the `HEAD:main` branch assumption, macOS Gatekeeper-via-brew (ad-hoc sign). **Remaining D3:** winget. **Amendment 3 (2026-06-19):** **Scoop + Homebrew validated end-to-end** on real Windows + macOS (install + run). Found + fixed a **presigned-URL/HEAD gotcha**: Gitea (≥1.25) 303-redirects release downloads to a **method-bound SigV4 presigned S3 URL**; Homebrew resolves via HEAD, captures the HEAD-signed URL, then GETs it → 403 (GET-only tools unaffected). Fix is a **server-side Caddy rule** (lives in the Gitea edge config, NOT this repo — record it: lost on a rebuild → brew 403s again) answering HEAD on `…/releases/download/…` directly (200, no redirect) so brew's download GET re-runs the redirect fresh. ad-hoc mac signature **runs** via brew; Developer-ID + notarization still parked on the Apple org conversion. **Remaining D3:** winget only. **Amendment 4 (2026-06-20):** **winget wired** (D3 §3d) — the 4th `publish.yaml` sibling job opens a PR to the central, human-gated `microsoft/winget-pkgs` via **komac** (pinned 2.16.0; `LazyEvaluation.RdbmsPlayground`, `portable`, x64+arm64). Async + Microsoft-validated, unlike the bucket/tap. Auth: a **classic `public_repo` PAT** (fine-grained can't open the cross-fork PR, komac #310) on a **dedicated GitHub bot** → `WINGET_GITHUB_TOKEN`, job-scoped, passed via curl config file (not argv). Idempotent via **two guards** (already-merged version + already-open PR) so re-dispatch is safe. Unsigned is fine to submit (portable; only MSIX needs signing) — may earn an AV/SmartScreen manual-review label first time. **One-time `komac new` bootstrap is manual** (interactive); CI `komac update` handles releases after. Completes the D3 package-manager set. - [ADR-0057 — Non-functional-requirement verification strategy](0057-nfr-verification-strategy.md) — **Accepted 2026-06-22.** How NFR-1..7 are verified now that public binaries ship. **Gated:** NFR-5/NFR-7 colour contrast — `src/theme.rs` unit tests assert every text foreground clears **WCAG-AA 4.5:1** on both themes, the advanced-mode border clears the **3:1** UI bar (plain border decorative-exempt, documented), and every syntax-token pair clears **CIEDE2000 ΔE2000 ≥ 15** (metric reference-validated). Writing these **caught two shipped light-theme defects** (`tok_string` 4.42:1, `tok_flag` 3.15:1) + two near-duplicate dark pairs — all fixed (`65eab71`); dev tool `scripts/palette-preview.py`. **Measured, generously bounded:** NFR-1 startup + NFR-3 idle RSS via the Tier-4 PTY harness (debug-binary gross-regression bounds, not a tight gate per user decision); recorded **release** figures — startup **~29 ms** (< 500 ms), idle RSS **~10 MB** (< 50 MB). **By argument:** NFR-2 off-thread responsiveness (worker thread ADR-0010 + separate input task). **Reviewer note:** NFR-4 distinctive design, NFR-6 cross-platform parity (CI matrix: Linux+macOS execute, Windows build-only; one documented divergence — true-colour quantisation on non-`Tc` terminals). Latent finding **issue #39** (fast DDL→insert vs stale schema cache) deferred. -- [ADR-0059 — Branch-and-PR working method: worktrees, merge commits, reserve-first ADR numbering](0059-dev-workflow.md) — **Accepted 2026-06-23.** The trackable working method for the now-public repo. **PRs onto a protected `main`** (always-green; no direct pushes bar one carve-out), one logical change per branch, conventional branch/commit prefixes, the `/runda` review pasted into the PR. **One worktree per branch** (`<repo>-worktree-<segment>`, the existing convention) — never switch the primary checkout — via `scripts/wt-new.sh` / `wt-clean.sh`. **Merge commits (`--no-ff`)**, never rebase/squash (the only strategy consistent with append-only history). **Reserve-first ADR numbering** (`scripts/adr-reserve.sh`): the fix for the real collision problem — a contiguous integer needs an allocator, plain git branches have none, so **`main` is the registry and an atomic `git push` is the lock** (CAS, retried on contention) recorded in an append-only ledger `docs/adr/RESERVATIONS.log`; the number is **stable from creation** (safe in immutable commit messages + cross-refs from commit #1), which `number-on-merge` is not. Supersedes ADR-0000's placeholder-until-merge default; subproject namespaces (`ADR-website/ci-NNN`) unchanged. **Branch protection:** require PR + the `ci / gate*` check + up-to-date-before-merge, with the **owner whitelisted for direct push** (the sole sanctioned direct commit — the reservation ledger line). **Push/merge stay human steps**; agents prepare but never push. Forks user-chosen: merge-commit; strict-PR; protection enforced; reserve-first integers (vs number-on-merge / issue-number ids / allocator bot); worktrees prescribed. Scripts ship with local-`origin` test harnesses (reserve 10/10, worktrees 7/7, all shellcheck-clean). First two reserve-first numbers: ADR-0058 + this one. +- [ADR-0059 — Branch-and-PR working method: worktrees, merge commits, reserve-first ADR numbering](0059-dev-workflow.md) — **Accepted 2026-06-23.** The trackable working method for the now-public repo. **PRs onto a protected `main`** (always-green; no direct pushes bar one carve-out), one logical change per branch, conventional branch/commit prefixes, the `/runda` review pasted into the PR. **One worktree per branch** (`<repo>-worktree-<segment>`, the existing convention) — never switch the primary checkout — via `scripts/wt-new.sh` / `wt-rm.sh` (the latter removes only the *named* worktree; no merged-detection sweep — long-lived branches like `website`/`ci` are merged into main too and must never be auto-removed). **Merge commits (`--no-ff`)**, never rebase/squash (the only strategy consistent with append-only history). **Reserve-first ADR numbering** (`scripts/adr-reserve.sh`): the fix for the real collision problem — a contiguous integer needs an allocator, plain git branches have none, so **`main` is the registry and an atomic `git push` is the lock** (CAS, retried on contention) recorded in an append-only ledger `docs/adr/RESERVATIONS.log`; the number is **stable from creation** (safe in immutable commit messages + cross-refs from commit #1), which `number-on-merge` is not. Supersedes ADR-0000's placeholder-until-merge default; subproject namespaces (`ADR-website/ci-NNN`) unchanged. **Branch protection:** require PR + the `ci / gate*` check + up-to-date-before-merge, with the **owner whitelisted for direct push** (the sole sanctioned direct commit — the reservation ledger line). **Push/merge stay human steps**; agents prepare but never push. Forks user-chosen: merge-commit; strict-PR; protection enforced; reserve-first integers (vs number-on-merge / issue-number ids / allocator bot); worktrees prescribed. Scripts ship with local-`origin` test harnesses (reserve 10/10, worktrees 15/15, all shellcheck-clean). First two reserve-first numbers: ADR-0058 + this one. (`wt-clean` auto-sweep → explicit `wt-rm <branch>` shortly after, on review — see the fix PR.) diff --git a/scripts/test-wt.sh b/scripts/test-wt.sh index a4225e3..4b69c60 100755 --- a/scripts/test-wt.sh +++ b/scripts/test-wt.sh @@ -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 ====" diff --git a/scripts/wt-clean.sh b/scripts/wt-clean.sh deleted file mode 100755 index 15e1c48..0000000 --- a/scripts/wt-clean.sh +++ /dev/null @@ -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)" diff --git a/scripts/wt-rm.sh b/scripts/wt-rm.sh new file mode 100755 index 0000000..6407010 --- /dev/null +++ b/scripts/wt-rm.sh @@ -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" From 279ef98ab804000e9429886282049b5fe0d40b3b Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" <claude-clouddev1@oliversturm.ch> Date: Tue, 23 Jun 2026 21:36:26 +0000 Subject: [PATCH 2/2] fix(workflow): wt-new branches with --no-track (no inherited origin/main upstream) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Branching off origin/main set the new branch's upstream to origin/main, so a bare `git push` failed with a name-mismatch and suggested the dangerous `git push origin HEAD:main`. Add --no-track so the branch carries no upstream until the first `git push -u origin <branch>` sets the right one — keeping the fresh-from-origin/main base without the tracking side-effect. Add a test-wt.sh assertion that a new branch has no upstream, and note the behaviour + first-push form in ADR-0059 / CLAUDE.md. --- CLAUDE.md | 3 ++- docs/adr/0059-dev-workflow.md | 5 ++++- scripts/test-wt.sh | 4 ++++ scripts/wt-new.sh | 8 +++++++- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e78e42d..de9c4c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -284,7 +284,8 @@ 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); remove a finished one + with `--no-track` + a sibling `<repo>-worktree-<segment>` worktree; first + 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. diff --git a/docs/adr/0059-dev-workflow.md b/docs/adr/0059-dev-workflow.md index c01f20f..de02db7 100644 --- a/docs/adr/0059-dev-workflow.md +++ b/docs/adr/0059-dev-workflow.md @@ -74,7 +74,10 @@ directory is how work gets clobbered — worktrees make that impossible. Helpers: - **`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 + upstream — otherwise a bare `git push` errors on the name mismatch and + suggests the dangerous `HEAD:main`), and add the sibling worktree; prints + 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 diff --git a/scripts/test-wt.sh b/scripts/test-wt.sh index 4b69c60..ac6b65c 100755 --- a/scripts/test-wt.sh +++ b/scripts/test-wt.sh @@ -37,6 +37,10 @@ out="$(run bash "$WT_NEW" feat/thing-one 2>/dev/null)" 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)" +# 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 ---" set +e; run bash "$WT_NEW" feat/thing-one >/dev/null 2>&1; rc=$?; set -e diff --git a/scripts/wt-new.sh b/scripts/wt-new.sh index 4391987..50ba890 100755 --- a/scripts/wt-new.sh +++ b/scripts/wt-new.sh @@ -47,7 +47,13 @@ log "fetching origin/main" git fetch --quiet origin main || die "git fetch origin main failed" 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" log "ready — cd into it:"