Merge pull request 'chore(workflow): branch-and-PR working method + ADR-number reservation (ADR-0059)' (#41) from chore/dev-workflow into main
ci / gate (push) Successful in 2m6s
ci / manifests (push) Successful in 3s

Reviewed-on: #41
This commit was merged in pull request #41.
This commit is contained in:
oli
2026-06-23 22:08:28 +01:00
10 changed files with 694 additions and 11 deletions
+29
View File
@@ -272,6 +272,35 @@ Key invariants in the code:
`git commit` is preceded by an explicit message proposal `git commit` is preceded by an explicit message proposal
and user approval. No AI attribution in commit messages. and user approval. No AI attribution in commit messages.
## Branch-and-PR working method (ADR-0059)
The trackable working method for the now-public repo. Full rationale +
forks in **ADR-0059**; the public-facing subset is `CONTRIBUTING.md`. The
operational rules an agent must follow:
- **Everything lands via a PR onto a protected `main`.** No direct pushes
to `main` (one exception: the ADR-reservation ledger line, D4/D5). One
logical change per branch; conventional prefixes (`feat/ fix/ docs/
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.
- **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
`docs/adr/RESERVATIONS.log`; push = compare-and-swap, retried). The
number is stable from creation — cite it freely in commits/cross-refs.
**The human runs this script** (its push must originate from them);
agents write/test it but don't run its pushing path. Then create
`docs/adr/<NNNN>-<slug>.md` + its README index row as normal branch work.
- **Merge with `--no-ff` merge commits; never rebase or squash** (append-
only history). Push and merge are **human steps** — agents prepare
branches, edits, PR text, and commit-message proposals, but never push
or merge.
- **Paste the `/runda` / DA review into the PR** so the public repo carries
the audit trail.
## Issue tracking — Gitea via `tea` ## Issue tracking — Gitea via `tea`
Extends (does not replace) the generic Gitea/`tea` safety rules in Extends (does not replace) the generic Gitea/`tea` safety rules in
+61
View File
@@ -7,6 +7,67 @@ open pull requests there. It's approaching its first public release, so
the most useful contributions right now are bug reports and rough edges the most useful contributions right now are bug reports and rough edges
you hit while learning. you hit while learning.
## Getting set up
The toolchain is pinned with a Nix flake, so dev and CI share one Rust
version. With [Nix](https://nixos.org/download) (flakes enabled):
```sh
nix develop # a shell with the pinned toolchain
```
Run everything through `nix develop -c …` so you match CI exactly.
## The checks your change must pass
CI runs these on every push and PR; run them locally first, and **check the
exit code** (a piped `… | tail` can hide a failure):
```sh
nix develop -c cargo fmt --check
nix develop -c cargo clippy --all-targets -- -D warnings
nix develop -c cargo test
```
All three must be green, with no skipped tests. New behaviour needs tests —
the suite runs in tiers from unit up to a PTY-driven end-to-end harness.
## Branches and pull requests
- **`main` is protected** and always green; it isn't pushed to directly.
Every change lands through a pull request.
- **Branch off `main`**, one logical change per branch, named with a
conventional prefix: `feat/…`, `fix/…`, `docs/…`, `chore/…`,
`refactor/…`, `test/…`, `ci/…`.
- **Commit messages** follow [Conventional Commits](https://www.conventionalcommits.org/)
(`feat:`, `fix:`, `docs:` …) and reference the issue (`… (#123)` /
`Closes #123`).
- **Open a PR against `main`.** The CI gate must pass before it can merge;
PRs land as merge commits — history is append-only, so please don't
force-push or rebase shared branches.
- Keep one PR to one concern; don't fold unrelated changes together.
## User-facing text
Two rules bind anything a user can see (errors, help, notes):
- **Don't name the database engine** — say "the database" / "the engine".
- **Don't say "DSL"** — say "simple mode" / "advanced mode".
## Significant changes get a decision record
Architectural or otherwise consequential changes are recorded as ADRs in
[`docs/adr/`](docs/adr/) — start with [`docs/adr/README.md`](docs/adr/README.md).
If your change touches a decided area, read the relevant ADR first; if it
would change a decision, propose a new ADR rather than quietly diverging.
Opening an issue to discuss a substantial change before building it is
always welcome.
## Code style
Match the surrounding code — its naming, comment density, and idioms. The
Clippy nursery lints are enabled and must pass clean.
## License of contributions ## License of contributions
Unless you explicitly state otherwise, any contribution you intentionally Unless you explicitly state otherwise, any contribution you intentionally
+16 -11
View File
@@ -45,18 +45,23 @@ ADR numbers are a single global sequence, so two branches can each grab
the `website` branch's ADR-0042 met `main`'s ADR-0042, resolved by the `website` branch's ADR-0042 met `main`'s ADR-0042, resolved by
renumbering the former to ADR-0044.) To prevent it: renumbering the former to ADR-0044.) To prevent it:
**Assign an ADR's number at merge-to-`main`, not at creation.** While the **Reserve the number up front, via `scripts/adr-reserve.sh`** (ADR-0059,
work lives on a non-`main` branch, draft the ADR under a placeholder — an which superseded the earlier placeholder-until-merge default). The moment
`ADR-XXXX` title and a `draft-<slug>.md` filename — and reference it that you know an ADR is needed — at branch start or mid-branch — run
way from any plan or notes. Give it the next free number only when the `scripts/adr-reserve.sh <slug> "<title>"`. It atomically claims the next
branch merges to `main`, renaming the file and updating its references in free number against `main` (the remote ref is the registry; a `git push`
the same step. is the compare-and-swap, retried on contention) and records it in the
append-only ledger `docs/adr/RESERVATIONS.log`. The number is then **stable
from creation**, so it is safe to cite in commit messages (immutable under
the no-rewrite rule), in other ADRs, and in the PR from the first commit.
Create the ADR as `docs/adr/<NNNN>-<slug>.md` and add its README index row
as part of the branch's normal work.
A number is "taken" only once it appears in `main`'s `docs/adr/README.md`, A number is "taken" once its ledger line (or `NNNN-*.md` file) is on
which is the single source of truth for the next free number — never `main`; the script reads both to compute the next free number — never
compute "next" from a feature branch. A branch that genuinely needs a real compute "next" by hand from a feature branch. The full rationale (why
number up front may instead reserve one by landing a stub index entry on reserve-first beats number-on-merge, issue-number ids, or an allocator bot)
`main` first, but placeholder-until-merge is the default. is in **ADR-0059**.
### Subproject ADR namespaces ### Subproject ADR namespaces
+181
View File
@@ -0,0 +1,181 @@
# ADR-0059: Branch-and-PR working method — worktrees, merge commits, and reserve-first ADR numbering
## Status
Accepted — **2026-06-23**, on branch `chore/dev-workflow` (pending merge).
Number **0059**, the second ADR reserved via the reserve-first flow this
ADR itself defines (the first was ADR-0058). Updates the **Numbering
discipline** section of **ADR-0000** (supersedes its placeholder-until-merge
default with reserve-first). Does not affect the subproject ADR namespaces
(`docs/website/adr/`, `docs/ci/adr/`).
## Context
The repository is now public, and feature work is moving from
commit-straight-to-`main` toward branches. A public, multi-branch project
needs a written, trackable working method instead of ad-hoc habits.
The sharp edge that forced the issue is **ADR numbering**. ADR numbers are
a single global integer sequence; two branches that each grab "the next
number" collide on merge (this happened, and was the original motivation
for ADR-0000's numbering rules). The deeper problem is fundamental:
> A contiguous integer requires a single allocator. Plain git branches have
> **no shared allocator** until they reconverge at merge — so any number
> claimed independently on a branch is speculative and can collide.
Earlier work papered over this for two long-lived subprojects by giving
them their **own namespaces** (`ADR-website-NNN`, `ADR-ci-NNN`), which is
right for a nameable body of decisions but doesn't generalise to every
feature branch. ADR-0000 also wrote a **placeholder-until-merge** default
("assign the number at merge"), but it had never actually been exercised,
and exercising it revealed two fatal frictions:
1. **ADR numbers are referenced from the first commit onward** — in commit
messages (which are immutable under our no-rewrite rule, so a wrong
number there is wrong *forever*), in other ADRs, in the PR. A number
that only crystallises at merge leaves every earlier reference dangling.
2. ADRs are *not* low-collision in practice: they're born precisely on the
**long-lived feature branches most likely to run in parallel**, so
"rare collision" is not a safe assumption.
So the requirement is a number that is **stable from creation, contiguous,
and collision-free** — which is only possible by acquiring a lock *up
front*. The insight that unlocks it: **`main` is the registry and an atomic
`git push` is the lock** — git serialises ref updates, so a push is a
compare-and-swap. We just have to acquire it *before* referencing the
number, not at merge.
This is a **genuine-choice** area (reasonable practitioners run GitHub
Flow, Git Flow, trunk-based, etc.); the decisions below adapt mainstream
practice to this repo's constraints (append-only history, self-hosted
Gitea, solo-with-subagents development) rather than claiming a canonical
default.
## Decision
### D1 — Everything lands via a PR onto a protected `main`
`main` is always-green and protected; no direct pushes (one narrow
exception — D4). Every change is a branch + pull request onto `main`. One
logical change per branch/PR. Branch names carry a conventional prefix
(`feat/ fix/ docs/ chore/ refactor/ test/ ci/`) aligned with the commit
type. PRs reference their issue (`Closes #N`); the review we already
perform (the Devil's-Advocate / `/runda` pass) is pasted into the PR so the
public repo carries the audit trail.
### D2 — One worktree per branch (never switch the primary checkout)
Each branch is developed in its **own git worktree**, a sibling of the
primary checkout named `<repo>-worktree-<last-segment-of-branch>` (the
convention already in use for the `website` and `ci` worktrees). Switching
the primary checkout's branch while another shell sits in the same
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.
- **`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.
### D3 — Merge commits (`--no-ff`); never rebase or squash
Branches land with a **merge commit** (`--no-ff`). This is the only
strategy consistent with the project's append-only / no-history-rewrite
rule: squash and rebase-merge both rewrite history. Fast-forward-only is
out because it would require rebasing branches onto `main`. History gains
merge bubbles; that is the accepted cost of never rewriting.
### D4 — Reserve-first ADR numbering via `adr-reserve.sh`
ADR numbers are **reserved up front, the moment you know an ADR is
needed** — which may be mid-branch or late, so reservation is decoupled
from branch creation. `scripts/adr-reserve.sh <slug> [title]`:
1. reads an append-only ledger (`docs/adr/RESERVATIONS.log`) plus the
existing `NNNN-*.md` ADRs on `main`; next = highest + 1;
2. appends `<NNNN> <slug> <date> <title>`, commits, and **pushes to
`main`**;
3. if the push is rejected because `main` moved (someone reserved first),
it re-fetches, recomputes, and retries — the push is the compare-and-swap,
so allocations are unique by construction, not by luck.
It runs from any branch/worktree without touching the working tree (it does
the allocation in a throwaway worktree on a detached `origin/main`), and on
a *non-race* rejection (e.g. protection misconfigured) it **aborts loudly**
rather than spinning. The number is therefore **stable from the moment it
returns** — safe to cite in immutable commit messages and other ADRs from
the first commit, which is what the references-problem (Context) demands.
The ADR file is created as `docs/adr/NNNN-<slug>.md` in the working branch
and its README index row is added there as normal work; the ledger line on
`main` is the allocation record and never conflicts with feature work
(feature branches don't touch it). The ledger may be pruned later; it's a
lock record, not state. **Collisions** (two reservations in the same
instant) are resolved automatically by the retry; the README index edit is
a secondary tripwire if one ever slips through.
This **supersedes ADR-0000's placeholder-until-merge default.** Subproject
namespaces (`ADR-website-NNN`, `ADR-ci-NNN`) are unchanged — they solve a
different problem (a nameable long-lived body of decisions) and stay.
### D5 — Branch protection configuration
On `main` (Gitea → Settings → Branches):
- **Require pull requests** + **require the CI status check** (`ci / gate*`)
to pass before merge, and **block merge of out-of-date branches** (this
is what makes "`main` is the lock" airtight for the substantive merges).
- **Whitelist the repo owner for direct push.** This is the one narrow
carve-out that lets `adr-reserve.sh` push the append-only ledger line
directly; everything substantive still goes through a PR. Without it the
reserve script aborts with a clear "is the owner whitelisted?" message.
### D6 — Who does what
**Pushing and merging are human steps**; automated agents prepare branches,
edits, PR text, and commit-message proposals but never push or merge. The
reserve script is **run by the human** (so the ledger push originates from
the human's invocation, consistent with that rule) — agents may write and
test the script but not run its pushing path.
## Forks (all user-chosen, 2026-06-22/23)
- **Merge commits (`--no-ff`)** over squash or fast-forward-only — the only
option consistent with append-only history (D3).
- **Strict everything-via-PR** over allowing trivial direct commits — one
carve-out only, the reservation line (D1/D4/D5).
- **Branch protection enforced in Gitea** over convention-only — mechanical
guarantee for a public repo (D5).
- **Reserve-first contiguous integers** over: number-on-merge (rejected —
breaks immutable references), Gitea-issue-number ids (rejected — non-
contiguous, overlaps the legacy 00010057 range), or an allocator bot
(rejected — machinery + a PR-rule exemption). Reserve-first keeps the
short stable "ADR 0059" reference and needs no new infrastructure beyond
one script (D4).
- **Worktrees prescribed** over single-checkout branch switching — prevents
the same-directory clobber hazard (D2).
## Consequences
- A written, public-facing **`CONTRIBUTING.md`** (lean: setup, the CI gate,
branch/PR conventions, the user-facing copy rules, "significant changes
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
(`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).
- `docs/adr/RESERVATIONS.log` appears on `main` as the allocation ledger.
- ADR-0000's Numbering-discipline section is updated to point here.
- `CLAUDE.md` gains the operational rules an agent must follow.
## Out of scope
- **Migrating the legacy `00010057` ADRs** to any new scheme — they keep
their numbers; reserve-first applies going forward (next is `0060`).
- **Automating PR creation/merge** — humans push and merge (D6).
- **A CONTRIBUTING-side description of the reserve lock** — it's maintainer
infrastructure, not contributor guidance (kept here instead).
File diff suppressed because one or more lines are too long
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env bash
#
# adr-reserve.sh — atomically reserve the next main-sequence ADR number.
#
# WHY THIS EXISTS
# A contiguous ADR integer needs a single allocator, but git branches have
# no shared allocator until they merge — which is why two parallel branches
# that each invent "the next number" collide. This script makes `main` the
# allocator and an atomic `git push` the lock:
#
# 1. read an append-only ledger (docs/adr/RESERVATIONS.log) + the existing
# NNNN-*.md ADRs on main; next = highest + 1;
# 2. append "<NNNN> <slug> <date> <title>" to the ledger, commit, PUSH;
# 3. if the push is rejected because main moved (someone reserved first),
# re-fetch, recompute, and retry.
#
# A push to a ref is a compare-and-swap, so the remote `main` ref is the
# mutex and step 3 is the retry-on-contention. The number is therefore
# stable from the moment this returns — safe to cite in commit messages
# (which are immutable) and in other ADRs from the very first commit.
# (Decision recorded in the dev-workflow ADR.)
#
# It runs from ANY branch / worktree / directory inside the repo and never
# touches your working tree: the allocation happens in a throwaway worktree
# on a detached origin/main.
#
# PREREQUISITE
# You must be allowed to push directly to `main` (the repo owner is
# whitelisted in the branch-protection rule for exactly this). Everything
# substantive still goes through a PR; this one append-only ledger line is
# the sole sanctioned direct push. If protection rejects the push, the
# script aborts with a clear message rather than spinning.
#
# USAGE
# scripts/adr-reserve.sh <slug> [title...]
# scripts/adr-reserve.sh clause-concept-hints "Clause-concept hints"
#
# OUTPUT
# The reserved zero-padded number on stdout (e.g. 0058); progress on stderr.
set -euo pipefail
log() { printf ' reserve-adr: %s\n' "$*" >&2; }
die() { printf 'reserve-adr: ERROR: %s\n' "$*" >&2; exit 1; }
REMOTE="origin"
BRANCH="main"
LEDGER="docs/adr/RESERVATIONS.log"
MAX_ATTEMPTS=20
[ $# -ge 1 ] || die "usage: adr-reserve.sh <slug> [title...]"
slug="$1"; shift
title="${*:-}"
# Slug must match the NNNN-<slug>.md filename convention.
[[ "$slug" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]] \
|| die "slug must be lowercase-kebab-case (got: '$slug')"
root="$(git rev-parse --show-toplevel 2>/dev/null)" || die "not inside a git repo"
log "allocating next ADR number for '$slug' against $REMOTE/$BRANCH"
git -C "$root" fetch --quiet "$REMOTE" "$BRANCH" || die "git fetch $REMOTE $BRANCH failed"
# Throwaway detached worktree on origin/main — isolated from your working tree.
tmp_base="$(mktemp -d)"
wt="$tmp_base/wt"
# shellcheck disable=SC2329 # invoked indirectly via `trap cleanup EXIT`
cleanup() {
git -C "$root" worktree remove --force "$wt" >/dev/null 2>&1 || true
rm -rf "$tmp_base"
}
trap cleanup EXIT
git -C "$root" worktree add --quiet --detach "$wt" "$REMOTE/$BRANCH" \
|| die "could not create temp worktree at $wt"
# Highest number across existing top-level ADR files and the ledger, + 1.
compute_next() {
local max=0 n base
shopt -s nullglob
for f in "$wt"/docs/adr/[0-9][0-9][0-9][0-9]-*.md; do
base="$(basename "$f")"; n=$((10#${base%%-*}))
(( n > max )) && max=$n
done
if [ -f "$wt/$LEDGER" ]; then
while read -r num _; do
[[ "$num" =~ ^[0-9]{4}$ ]] || continue
n=$((10#$num)); (( n > max )) && max=$n
done < "$wt/$LEDGER"
fi
printf '%04d' $(( max + 1 ))
}
# Idempotence: if this slug is already reserved, report it and stop.
if [ -f "$wt/$LEDGER" ]; then
existing="$(awk -v s="$slug" '$2==s {print $1; exit}' "$wt/$LEDGER" || true)"
if [ -n "${existing:-}" ]; then
log "'$slug' is already reserved as ADR $existing — nothing to do"
echo "$existing"
exit 0
fi
fi
attempt=0
while (( attempt < MAX_ATTEMPTS )); do
attempt=$(( attempt + 1 ))
git -C "$wt" fetch --quiet "$REMOTE" "$BRANCH"
git -C "$wt" reset --hard --quiet "$REMOTE/$BRANCH"
num="$(compute_next)"
# Dry run: report the number we WOULD reserve and stop (no commit/push).
# Useful for a preview and for testing the read/allocation path.
if [ -n "${ADR_RESERVE_DRY_RUN:-}" ]; then
log "DRY RUN: would reserve ADR $num for '$slug' (no push)"
echo "$num"
exit 0
fi
mkdir -p "$wt/docs/adr"
printf '%s %s %s %s\n' "$num" "$slug" "$(date +%F)" "$title" >> "$wt/$LEDGER"
git -C "$wt" add "$LEDGER"
git -C "$wt" commit --quiet -m "docs(adr): reserve $num for $slug"
log "attempt $attempt: claiming ADR $num"
if push_out="$(git -C "$wt" push "$REMOTE" "HEAD:$BRANCH" 2>&1)"; then
log "reserved ADR $num for '$slug' (pushed to $REMOTE/$BRANCH)"
echo "$num"
exit 0
fi
# Distinguish a lost race (retryable) from a hard rejection (not).
if grep -qiE 'non-fast-forward|fetch first|tip of your .* is behind|stale info' <<<"$push_out"; then
log "race lost (main moved) — re-fetching and retrying"
continue
fi
printf '%s\n' "$push_out" >&2
die "push to $BRANCH was rejected and this is not a race — likely branch \
protection (is the owner whitelisted for direct pushes to $BRANCH?). Aborting."
done
die "gave up after $MAX_ATTEMPTS contended attempts — main is changing very fast?"
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# Verifies adr-reserve.sh against a local file:// origin (no network/keys).
# Exercises: number computation, dry-run, real reserve+push, sequential
# allocation, idempotence, hard-rejection abort (vs infinite loop), and the
# race-vs-abort discriminator.
set -euo pipefail
SCRIPT="$(cd "$(dirname "$0")" && pwd)/adr-reserve.sh"
PASS=0 FAIL=0
check() { # <desc> <expected> <actual>
if [ "$2" = "$3" ]; then echo "PASS: $1 ($3)"; PASS=$((PASS+1));
else echo "FAIL: $1 — expected '$2' got '$3'"; FAIL=$((FAIL+1)); fi
}
T="$(mktemp -d)"
trap 'rm -rf "$T"' EXIT
# ---- seed a bare origin with ADRs up to 0057 -------------------------------
git init -q --bare "$T/origin.git"
git clone -q "$T/origin.git" "$T/seed"
git -C "$T/seed" config user.email t@t.io
git -C "$T/seed" config user.name tester
mkdir -p "$T/seed/docs/adr"
echo "# legacy" > "$T/seed/docs/adr/0056-alpha.md"
echo "# legacy" > "$T/seed/docs/adr/0057-beta.md"
git -C "$T/seed" add -A
git -C "$T/seed" commit -qm "seed: ADRs through 0057"
git -C "$T/seed" branch -M main
git -C "$T/seed" push -q origin main
# the repo the script runs inside (origin = our bare repo)
git clone -q "$T/origin.git" "$T/work"
git -C "$T/work" config user.email t@t.io
git -C "$T/work" config user.name tester
run() { ( cd "$T/work" && "$@" ); }
echo "--- Test 1: dry-run computes 0058 (no push) ---"
out="$(run env ADR_RESERVE_DRY_RUN=1 bash "$SCRIPT" feature-one "Feature One" 2>/dev/null)"
check "dry-run number" "0058" "$out"
git -C "$T/seed" pull -q origin main
check "dry-run pushed nothing" "no" "$( [ -f "$T/seed/docs/adr/RESERVATIONS.log" ] && echo yes || echo no )"
echo "--- Test 2: real reserve → 0058, pushed to origin ---"
out="$(run bash "$SCRIPT" feature-one "Feature One" 2>/dev/null)"
check "first reserve number" "0058" "$out"
git -C "$T/seed" pull -q origin main
check "ledger landed on origin" "0058 feature-one" "$(awk 'NR==1{print $1" "$2}' "$T/seed/docs/adr/RESERVATIONS.log")"
echo "--- Test 3: second slug → 0059 (sequential) ---"
out="$(run bash "$SCRIPT" feature-two 2>/dev/null)"
check "second reserve number" "0059" "$out"
echo "--- Test 4: idempotence — re-reserving feature-one returns 0058 ---"
out="$(run bash "$SCRIPT" feature-one 2>/dev/null)"
check "idempotent re-reserve" "0058" "$out"
git -C "$T/seed" pull -q origin main
check "no duplicate ledger line" "1" "$(grep -c ' feature-one ' "$T/seed/docs/adr/RESERVATIONS.log")"
echo "--- Test 5: hard rejection (branch protection) aborts, does NOT loop ---"
cat > "$T/origin.git/hooks/pre-receive" <<'HOOK'
#!/bin/sh
echo "protected branch: push declined" >&2
exit 1
HOOK
chmod +x "$T/origin.git/hooks/pre-receive"
set +e
run bash "$SCRIPT" feature-three >/dev/null 2>"$T/err"; rc=$?
set -e
check "abort exit code nonzero" "yes" "$( [ "$rc" -ne 0 ] && echo yes || echo no )"
check "abort cites protection, not a loop" "yes" "$(grep -qi 'not a race\|protection' "$T/err" && echo yes || echo no)"
rm -f "$T/origin.git/hooks/pre-receive"
echo "--- Test 6: race discriminator matches a real non-ff message ---"
ff_msg=" ! [rejected] main -> main (non-fast-forward)
error: failed to push some refs"
check "non-ff treated as race" "yes" \
"$(grep -qiE 'non-fast-forward|fetch first|tip of your .* is behind|stale info' <<<"$ff_msg" && echo yes || echo no)"
echo
echo "==== $PASS passed, $FAIL failed ===="
[ "$FAIL" -eq 0 ]
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Smoke-tests wt-new.sh / wt-clean.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"
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; }
T="$(mktemp -d)"
trap 'rm -rf "$T"' EXIT
git init -q --bare "$T/origin.git"
git clone -q "$T/origin.git" "$T/primary"
git -C "$T/primary" config user.email t@t.io
git -C "$T/primary" config user.name tester
echo seed > "$T/primary/README.md"
git -C "$T/primary" add -A
git -C "$T/primary" commit -qm seed
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"
echo "--- wt-new rejects a non-conventional branch name ---"
set +e; run bash "$WT_NEW" my-thing >/dev/null 2>&1; rc=$?; set -e
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)"
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.
run bash "$WT_NEW" feat/thing-two >/dev/null 2>&1
target2="$T/primary-worktree-thing-two"
git -C "$target2" commit -q --allow-empty -m "thing-two work (unmerged)"
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
echo "==== $PASS passed, $FAIL failed ===="
[ "$FAIL" -eq 0 ]
+72
View File
@@ -0,0 +1,72 @@
#!/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)"
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
#
# wt-new.sh — start a branch in its own git worktree.
#
# WHY: every branch is developed in an isolated worktree, never by switching
# the primary checkout (two shells in one checkout is how you clobber work).
# This creates the branch off an up-to-date `main` and a sibling worktree
# named to match the repo's existing convention:
#
# <parent>/<repo>-worktree-<last-segment-of-branch>
# e.g. feat/clause-concept-hints -> ../rdbms-playground-worktree-clause-concept-hints
#
# USAGE
# scripts/wt-new.sh <branch>
# scripts/wt-new.sh feat/clause-concept-hints
#
# Prints the new worktree path; cd there to start working.
set -euo pipefail
log() { printf ' wt-new: %s\n' "$*" >&2; }
die() { printf 'wt-new: ERROR: %s\n' "$*" >&2; exit 1; }
[ $# -eq 1 ] || die "usage: wt-new.sh <branch> (e.g. feat/my-thing)"
branch="$1"
# Conventional prefixes keep branch names aligned with commit types.
case "$branch" in
feat/*|fix/*|docs/*|chore/*|refactor/*|test/*|ci/*) : ;;
*) die "branch should start with feat/ fix/ docs/ chore/ refactor/ test/ ci/ (got: $branch)" ;;
esac
git rev-parse --git-dir >/dev/null 2>&1 || die "not inside a git repo"
# Sibling-of-the-primary-checkout location, matching existing worktrees.
main_wt="$(git worktree list --porcelain | awk '/^worktree /{print $2; exit}')"
parent="$(dirname "$main_wt")"
repo="$(basename "$main_wt")"
seg="${branch##*/}"
target="$parent/${repo}-worktree-${seg}"
[ -e "$target" ] && die "worktree path already exists: $target"
git show-ref --verify --quiet "refs/heads/$branch" \
&& die "branch already exists: $branch (use 'git worktree add' to attach a worktree to it)"
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 \
|| die "git worktree add failed"
log "ready — cd into it:"
echo "$target"