Files
rdbms-playground/docs/adr/0059-dev-workflow.md
T
claude@clouddev1 279ef98ab8
ci / manifests (push) Successful in 4s
ci / gate (push) Successful in 2m6s
ci / gate (pull_request) Successful in 2m3s
ci / manifests (pull_request) Successful in 4s
fix(workflow): wt-new branches with --no-track (no inherited origin/main upstream)
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.
2026-06-23 21:36:26 +00:00

10 KiB
Raw Blame History

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 (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 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

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-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).
  • 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).