#!/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 " " 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?"