chore(workflow): branch-and-PR working method + ADR-number reservation (ADR-0059)
ci / gate (push) Successful in 2m5s
ci / manifests (push) Successful in 4s
ci / gate (pull_request) Successful in 2m5s
ci / manifests (pull_request) Successful in 4s

Adopt a trackable working method now the repo is public:
- PRs onto a protected main; --no-ff merge commits; one worktree per branch.
- Reserve-first ADR numbering: scripts/adr-reserve.sh claims the next number
  atomically against main (push = compare-and-swap; ledger
  docs/adr/RESERVATIONS.log), so a number is stable from creation.
- Worktree helpers scripts/wt-new.sh + wt-clean.sh.
- Local-origin test harnesses (reserve 10/10, worktrees 7/7, shellcheck clean).

Record the decision in ADR-0059, supersede ADR-0000's placeholder-until-merge
numbering default, and add the lean CONTRIBUTING.md sections + CLAUDE.md
operational rules.
This commit is contained in:
claude@clouddev1
2026-06-23 20:41:42 +00:00
parent e845a6ee72
commit b5c848efcb
10 changed files with 694 additions and 11 deletions
+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"