From b5c848efcb75ceacbdc75bcff794a8f8dfdd24e1 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Tue, 23 Jun 2026 20:41:42 +0000 Subject: [PATCH] chore(workflow): branch-and-PR working method + ADR-number reservation (ADR-0059) 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. --- CLAUDE.md | 29 +++ CONTRIBUTING.md | 61 ++++++ .../adr/0000-record-architecture-decisions.md | 27 +-- docs/adr/0059-dev-workflow.md | 181 ++++++++++++++++++ docs/adr/README.md | 1 + scripts/adr-reserve.sh | 141 ++++++++++++++ scripts/test-adr-reserve.sh | 82 ++++++++ scripts/test-wt.sh | 57 ++++++ scripts/wt-clean.sh | 72 +++++++ scripts/wt-new.sh | 54 ++++++ 10 files changed, 694 insertions(+), 11 deletions(-) create mode 100644 docs/adr/0059-dev-workflow.md create mode 100755 scripts/adr-reserve.sh create mode 100755 scripts/test-adr-reserve.sh create mode 100755 scripts/test-wt.sh create mode 100755 scripts/wt-clean.sh create mode 100755 scripts/wt-new.sh diff --git a/CLAUDE.md b/CLAUDE.md index e9bad8f..6511e69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -272,6 +272,35 @@ Key invariants in the code: `git commit` is preceded by an explicit message proposal 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 ` (creates branch off `origin/main` + + a sibling `-worktree-` 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 ""` + 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` Extends (does not replace) the generic Gitea/`tea` safety rules in diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a94ee0c..57a89c5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 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 Unless you explicitly state otherwise, any contribution you intentionally diff --git a/docs/adr/0000-record-architecture-decisions.md b/docs/adr/0000-record-architecture-decisions.md index 9b16a4f..3895fa9 100644 --- a/docs/adr/0000-record-architecture-decisions.md +++ b/docs/adr/0000-record-architecture-decisions.md @@ -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 renumbering the former to ADR-0044.) To prevent it: -**Assign an ADR's number at merge-to-`main`, not at creation.** While the -work lives on a non-`main` branch, draft the ADR under a placeholder — an -`ADR-XXXX` title and a `draft-<slug>.md` filename — and reference it that -way from any plan or notes. Give it the next free number only when the -branch merges to `main`, renaming the file and updating its references in -the same step. +**Reserve the number up front, via `scripts/adr-reserve.sh`** (ADR-0059, +which superseded the earlier placeholder-until-merge default). The moment +you know an ADR is needed — at branch start or mid-branch — run +`scripts/adr-reserve.sh <slug> "<title>"`. It atomically claims the next +free number against `main` (the remote ref is the registry; a `git push` +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`, -which is the single source of truth for the next free number — never -compute "next" from a feature branch. A branch that genuinely needs a real -number up front may instead reserve one by landing a stub index entry on -`main` first, but placeholder-until-merge is the default. +A number is "taken" once its ledger line (or `NNNN-*.md` file) is on +`main`; the script reads both to compute the next free number — never +compute "next" by hand from a feature branch. The full rationale (why +reserve-first beats number-on-merge, issue-number ids, or an allocator bot) +is in **ADR-0059**. ### Subproject ADR namespaces diff --git a/docs/adr/0059-dev-workflow.md b/docs/adr/0059-dev-workflow.md new file mode 100644 index 0000000..59367bb --- /dev/null +++ b/docs/adr/0059-dev-workflow.md @@ -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 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 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 `0001–0057` 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). diff --git a/docs/adr/README.md b/docs/adr/README.md index 47fd168..5668af3 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -70,3 +70,4 @@ This directory contains the project's ADRs, recorded per - [ADR-0055 — `curl | sh` install script (`scripts/install.sh`)](0055-curl-sh-install-script.md) — **Accepted + implemented 2026-06-17** (plan: `docs/plans/20260616-public-availability.md`, step 2; tracked by plan + ADR, no Gitea issue — user decision). A one-line installer (`curl -fsSL <gitea-raw>/scripts/install.sh | sh`) so beginners don't hand-pick an asset + `chmod +x`. **POSIX `sh`** (shellcheck-clean), detects `uname` OS/arch → target triple (**Linux → the fully-static `*-musl`** build, macOS → `*-apple-darwin`; `amd64`/`arm64` aliased; **Windows rejected** → Scoop/winget/releases page), resolves the version from the **`releases/latest`** API (or `RDBMS_VERSION` to pin), downloads the asset **and its `.sha256` and verifies it** (mismatch aborts), installs to `~/.local/bin` (`RDBMS_INSTALL_DIR` override) with a PATH hint. Testing seams: `RDBMS_OS`/`RDBMS_ARCH` + `--print-target`. macOS note: `curl` downloads aren't Gatekeeper-quarantined so the ad-hoc binary runs as-is (Developer-ID + notarization is the postponed signing task). **Verified end-to-end against the live public `v0.1.0`** (all platform mappings, pinned + latest, checksum incl. tamper-rejection, install + run). Rejected: website-domain hosting (extra moving part; Gitea raw is simplest); deferred: uploading the script as a release asset, and a **shellcheck CI gate** (shellcheck isn't in the flake — touches ADR-ci-002). **Amendment 1 (2026-06-17):** added a Windows **`scripts/install.ps1`** (`irm | iex`; maps host CPU → our `*-windows-gnu`/`-gnullvm` `.exe`, SHA-256-verifies, installs to `%LOCALAPPDATA%\Programs\…` + user PATH) — user chose both a one-liner *and* Scoop/winget; **written but untested from this env** (no PowerShell — validate on Windows). - [ADR-0056 — crates.io publish-readiness + `cargo binstall` metadata (D3)](0056-crates-io-and-cargo-binstall.md) — **Prepared 2026-06-17** (plan step 3a; tracked by plan + ADR). Makes the crate **ready to publish** to crates.io (user decision) and adds `cargo-binstall` metadata; the actual `cargo publish` is a **gated, irreversible maintainer step**. Manifest: drops `publish = false`; adds `homepage` (relplay.org), `keywords`, `categories`, and an `exclude` (`/website`,`/docs`,`/.gitea`,`/.codegraph`) trimming the crate from 585 files/8.3 MiB → **353/913 KiB compressed** (code-only). Authors **`README.md`** (engine-neutral, simple/advanced-mode wording; install via curl|sh/binstall/source/prebuilt) and **`LICENSE-MIT`** (© Lazy Evaluation Ltd — *confirm holder*); the canonical **`LICENSE-APACHE`** is deferred to the maintainer (don't ship retyped legal text) — the SPDX `license` field already satisfies crates.io. **binstall** (syntax verified vs cargo-binstall SUPPORT.md): `pkg-fmt = "bin"` (bare binaries), `pkg-url` spelled `v{ version }` (the placeholder omits the `v`), plus per-target **`overrides`** mapping the common host triples to the assets we ship — `*-linux-gnu` → the static `*-linux-musl` build, `*-pc-windows-msvc` → `*-gnu`/`-gnullvm` `.exe` (macOS matches directly; the docs promise no automatic fallback). **Ordering:** publish at a **new tagged version whose release exists**, after the release — **not `0.1.0`** (diverges from the already-released 0.1.0 binaries that predate `--version`). Verified: `cargo publish --dry-run` packages + verify-builds; `cargo metadata` confirms the binstall block + 4 overrides. **Unverified:** a real `cargo binstall` run (not a dep; nothing on crates.io yet) — validate at first publish. Rejected: cargo-dist (GitHub-centric). Maintainer follow-ups: confirm © holder, add canonical `LICENSE-APACHE`, real binstall validation. **Amendment 1 (2026-06-18):** `0.2.0` **published live** (crates.io; `cargo install` + `cargo binstall` verified — the unverified-overrides caveat is resolved), via a new **manual `workflow_dispatch`** workflow `.gitea/workflows/publish.yaml` (mirrors `release-macos.yaml`; `tag` input; `cargo publish` with a crate-scoped `CARGO_REGISTRY_TOKEN` secret). Publish stays **manual** by decision — irreversible (keeps the token off every tag push), the split release (tag Linux/Windows + dispatched macOS) makes a human the "all assets up" gate, and crates.io has no Gitea-Actions trusted-publishing path. Each registry is its **own idempotent job** (crates.io job no-ops if the version exists) so Scoop/Homebrew/winget can be added as sibling jobs without interfering. **Amendment 2 (2026-06-19):** **Scoop + Homebrew wired** (D3 §3b/§3c) as sibling `publish.yaml` jobs (`scoop-bucket`, `homebrew-tap`) that render manifests from the release `.sha256` sidecars and push to **org-level, multi-package** repos `lazyeval/scoop-bucket` + `lazyeval/homebrew-tap`. Credential: a scoped bot user **`lazyeval-ci`** (Gitea PATs scope by permission-category, not per-repo, so an `oli` token would over-reach to the main repo) on a `lazyeval` org team with Write to the package repos only; its PAT is the `LAZYEVAL_PKG_TOKEN` secret on `oli/rdbms-playground`. Render scripts (`scripts/render-{scoop-manifest,homebrew-formula}.sh`) are **dependency-free bash** (CI image `node:22-slim` has no jq/ruby), tested by `scripts/test-package-renders.sh`. Scoop: `#/`-rename fragment + `checkver`, no `autoupdate`. Homebrew: `on_macos`/`on_linux`×arch bare-binary formula, no Windows. **Unverified:** real `scoop`/`brew install`, the `HEAD:main` branch assumption, macOS Gatekeeper-via-brew (ad-hoc sign). **Remaining D3:** winget. **Amendment 3 (2026-06-19):** **Scoop + Homebrew validated end-to-end** on real Windows + macOS (install + run). Found + fixed a **presigned-URL/HEAD gotcha**: Gitea (≥1.25) 303-redirects release downloads to a **method-bound SigV4 presigned S3 URL**; Homebrew resolves via HEAD, captures the HEAD-signed URL, then GETs it → 403 (GET-only tools unaffected). Fix is a **server-side Caddy rule** (lives in the Gitea edge config, NOT this repo — record it: lost on a rebuild → brew 403s again) answering HEAD on `…/releases/download/…` directly (200, no redirect) so brew's download GET re-runs the redirect fresh. ad-hoc mac signature **runs** via brew; Developer-ID + notarization still parked on the Apple org conversion. **Remaining D3:** winget only. **Amendment 4 (2026-06-20):** **winget wired** (D3 §3d) — the 4th `publish.yaml` sibling job opens a PR to the central, human-gated `microsoft/winget-pkgs` via **komac** (pinned 2.16.0; `LazyEvaluation.RdbmsPlayground`, `portable`, x64+arm64). Async + Microsoft-validated, unlike the bucket/tap. Auth: a **classic `public_repo` PAT** (fine-grained can't open the cross-fork PR, komac #310) on a **dedicated GitHub bot** → `WINGET_GITHUB_TOKEN`, job-scoped, passed via curl config file (not argv). Idempotent via **two guards** (already-merged version + already-open PR) so re-dispatch is safe. Unsigned is fine to submit (portable; only MSIX needs signing) — may earn an AV/SmartScreen manual-review label first time. **One-time `komac new` bootstrap is manual** (interactive); CI `komac update` handles releases after. Completes the D3 package-manager set. - [ADR-0057 — Non-functional-requirement verification strategy](0057-nfr-verification-strategy.md) — **Accepted 2026-06-22.** How NFR-1..7 are verified now that public binaries ship. **Gated:** NFR-5/NFR-7 colour contrast — `src/theme.rs` unit tests assert every text foreground clears **WCAG-AA 4.5:1** on both themes, the advanced-mode border clears the **3:1** UI bar (plain border decorative-exempt, documented), and every syntax-token pair clears **CIEDE2000 ΔE2000 ≥ 15** (metric reference-validated). Writing these **caught two shipped light-theme defects** (`tok_string` 4.42:1, `tok_flag` 3.15:1) + two near-duplicate dark pairs — all fixed (`65eab71`); dev tool `scripts/palette-preview.py`. **Measured, generously bounded:** NFR-1 startup + NFR-3 idle RSS via the Tier-4 PTY harness (debug-binary gross-regression bounds, not a tight gate per user decision); recorded **release** figures — startup **~29 ms** (< 500 ms), idle RSS **~10 MB** (< 50 MB). **By argument:** NFR-2 off-thread responsiveness (worker thread ADR-0010 + separate input task). **Reviewer note:** NFR-4 distinctive design, NFR-6 cross-platform parity (CI matrix: Linux+macOS execute, Windows build-only; one documented divergence — true-colour quantisation on non-`Tc` terminals). Latent finding **issue #39** (fast DDL→insert vs stale schema cache) deferred. +- [ADR-0059 — Branch-and-PR working method: worktrees, merge commits, reserve-first ADR numbering](0059-dev-workflow.md) — **Accepted 2026-06-23.** The trackable working method for the now-public repo. **PRs onto a protected `main`** (always-green; no direct pushes bar one carve-out), one logical change per branch, conventional branch/commit prefixes, the `/runda` review pasted into the PR. **One worktree per branch** (`<repo>-worktree-<segment>`, the existing convention) — never switch the primary checkout — via `scripts/wt-new.sh` / `wt-clean.sh`. **Merge commits (`--no-ff`)**, never rebase/squash (the only strategy consistent with append-only history). **Reserve-first ADR numbering** (`scripts/adr-reserve.sh`): the fix for the real collision problem — a contiguous integer needs an allocator, plain git branches have none, so **`main` is the registry and an atomic `git push` is the lock** (CAS, retried on contention) recorded in an append-only ledger `docs/adr/RESERVATIONS.log`; the number is **stable from creation** (safe in immutable commit messages + cross-refs from commit #1), which `number-on-merge` is not. Supersedes ADR-0000's placeholder-until-merge default; subproject namespaces (`ADR-website/ci-NNN`) unchanged. **Branch protection:** require PR + the `ci / gate*` check + up-to-date-before-merge, with the **owner whitelisted for direct push** (the sole sanctioned direct commit — the reservation ledger line). **Push/merge stay human steps**; agents prepare but never push. Forks user-chosen: merge-commit; strict-PR; protection enforced; reserve-first integers (vs number-on-merge / issue-number ids / allocator bot); worktrees prescribed. Scripts ship with local-`origin` test harnesses (reserve 10/10, worktrees 7/7, all shellcheck-clean). First two reserve-first numbers: ADR-0058 + this one. diff --git a/scripts/adr-reserve.sh b/scripts/adr-reserve.sh new file mode 100755 index 0000000..13e185e --- /dev/null +++ b/scripts/adr-reserve.sh @@ -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?" diff --git a/scripts/test-adr-reserve.sh b/scripts/test-adr-reserve.sh new file mode 100755 index 0000000..eb9447d --- /dev/null +++ b/scripts/test-adr-reserve.sh @@ -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 ] diff --git a/scripts/test-wt.sh b/scripts/test-wt.sh new file mode 100755 index 0000000..a4225e3 --- /dev/null +++ b/scripts/test-wt.sh @@ -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 ] diff --git a/scripts/wt-clean.sh b/scripts/wt-clean.sh new file mode 100755 index 0000000..15e1c48 --- /dev/null +++ b/scripts/wt-clean.sh @@ -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)" diff --git a/scripts/wt-new.sh b/scripts/wt-new.sh new file mode 100755 index 0000000..4391987 --- /dev/null +++ b/scripts/wt-new.sh @@ -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"