Session handoff covering the clause-concept-hints feature (ADR-0058, #42) and the working-method establishment (ADR-0059, #41; fixes #43/#44). Records the docs direct-push carve-out: handoff/session docs and small docs amendments are owner-direct-pushed to main, because a docs-only PR posts no ci/gate run (paths-ignored) and stalls on the required check. Amends CLAUDE.md, ADR-0059 (Amendment 1), and its README index row.
11 KiB
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:
- 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.
- 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>— fetchmain, create the branch offorigin/main(with--no-track, so it doesn't inheritorigin/mainas upstream — otherwise a baregit pusherrors on the name mismatch and suggests the dangerousHEAD:main), and add the sibling worktree; prints its path. First push isgit 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, theciline) are merged intomaintoo, so a merged-detection sweep would wrongly delete them (caught immediately on review, before any real use — the initial design was an auto-sweepwt-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 intoorigin/main(otherwise the worktree goes but the branch + its unmerged commits stay). Stale bookkeeping for a hand-deleted directory is the built-ingit 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]:
- reads an append-only ledger (
docs/adr/RESERVATIONS.log) plus the existingNNNN-*.mdADRs onmain; next = highest + 1; - appends
<NNNN> <slug> <date> <title>, commits, and pushes tomain; - if the push is rejected because
mainmoved (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 "mainis 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.shpush 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 0001–0057 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 inCLAUDE.md, not in CONTRIBUTING. - Three scripts enter
scripts/:adr-reserve.sh,wt-new.sh,wt-rm.sh, each with a local-origintest 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.logappears onmainas the allocation ledger.- ADR-0000's Numbering-discipline section is updated to point here.
CLAUDE.mdgains the operational rules an agent must follow.
Out of scope
- Migrating the legacy
0001–0057ADRs to any new scheme — they keep their numbers; reserve-first applies going forward (next is0060). - 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).
Amendment 1 (2026-06-25): docs direct-push carve-out
Extends the D5 owner-whitelist: handoff / session docs (and small docs
amendments like this one) are owner-direct-pushed to main, not PR'd.
Discovered while preparing the first handoff under this flow: a docs-only
PR cannot merge under the protection rules. ci.yaml paths-ignores
docs/** and **/*.md, so a docs-only change posts no ci / gate run;
but D5 requires ci / gate* before merge — so the required check never
arrives and the PR stalls on "expected/waiting". Rather than weaken the gate
or add CI machinery, this trivial low-risk class (handoffs, session notes,
small docs fixes) rides the existing owner-direct-push carve-out: the owner
is already whitelisted, and a docs-only push to main is itself
paths-ignored, so it burns no CI.
Substantive changes — code, and the ADRs/docs that accompany code — still go
through a PR as normal (their non-docs files trigger the gate). If
docs-via-PR is ever wanted (e.g. a docs-only ADR through review), the clean
fix is a "skipped → success" gate shim (a job that reports ci / gate
success when the real gate is path-skipped) or dropping the docs
paths-ignore; until then, direct-push is the sanctioned path for docs-only
changes.