Compare commits
12
Commits
8ccecc4245
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44b495dfed | ||
|
|
78272e2fd0 | ||
|
|
e1d7419a72 | ||
|
|
6e28e58e45 | ||
|
|
5c51a00e72 | ||
|
|
ec7a8ff3be | ||
|
|
f06ee3b460 | ||
|
|
279ef98ab8 | ||
|
|
4570c4e1ea | ||
|
|
3585cca5ea | ||
|
|
63d11402ae | ||
|
|
b5c848efcb |
@@ -14,11 +14,16 @@
|
||||
name: ci
|
||||
on:
|
||||
push:
|
||||
# Branch pushes only — a tag push hits the same commit the branch push
|
||||
# already gated, so `branches: ['**']` drops the redundant tag-triggered
|
||||
# run (the release workflow owns tags). Pushing commits + a tag together
|
||||
# still gates the commits via the branch push.
|
||||
branches: ['**']
|
||||
# Only `main` (the post-merge gate + canonical branch). Feature branches
|
||||
# are gated via `pull_request` instead. Running both on a push to a PR'd
|
||||
# branch was pure duplication on Gitea: unlike GitHub it has no merge-
|
||||
# preview ref — its `pull_request` checks out `refs/pull/N/head`, the same
|
||||
# commit a branch push would, so the two runs were byte-identical
|
||||
# (docs.gitea.com/usage/actions/faq). Gating on `pull_request` is also
|
||||
# forward-compatible: if Gitea ever adds the merge ref, these runs upgrade
|
||||
# to testing the merged result for free. Tags stay unmatched (release.yaml
|
||||
# owns them).
|
||||
branches: [main]
|
||||
# Skip the gate for changes that can't affect clippy/test — docs, markdown,
|
||||
# and the website subproject (it has its own workflow, website.yaml, that
|
||||
# builds + publishes it). A push touching crate code *and* these still runs
|
||||
|
||||
@@ -272,6 +272,46 @@ 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 <branch>` (creates branch off `origin/main`
|
||||
with `--no-track` + a sibling `<repo>-worktree-<segment>` worktree; first
|
||||
push is `git push -u origin <branch>`); remove a finished one
|
||||
with `scripts/wt-rm.sh <branch>` (removes only the *named* worktree — it
|
||||
never scans or auto-removes, so long-lived branches like `website`/`ci`
|
||||
are never at risk). 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 <slug> "<title>"`
|
||||
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.
|
||||
- **Handoff / session docs are owner-direct-pushed to `main`, not PR'd** (a
|
||||
deliberate extension of the reservation-ledger carve-out). Reason: a
|
||||
docs-only change is `paths-ignore`d by `ci.yaml`, so it posts no `ci / gate`
|
||||
run — and branch protection *requires* `ci / gate*`, so a docs-only PR
|
||||
stalls forever on a required check that never arrives. Direct-push (owner
|
||||
is whitelisted, docs paths burn no CI) sidesteps that. The same applies to
|
||||
small docs amendments like this one. If docs-via-PR is ever wanted, first
|
||||
add a "skipped → success" gate shim (or drop the docs paths-ignore).
|
||||
|
||||
## Issue tracking — Gitea via `tea`
|
||||
|
||||
Extends (does not replace) the generic Gitea/`tea` safety rules in
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
# 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 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-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 `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).
|
||||
|
||||
## 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-ignore`s
|
||||
`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-ignore`d, 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.
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,144 @@
|
||||
# Session handoff — 2026-06-25 (80)
|
||||
|
||||
A long session. It began as the next open-issue item — **#37, clause-concept
|
||||
hints** — but the act of cutting a branch for it surfaced that this
|
||||
now-public repo had **no written, trackable working method**. So the bulk of
|
||||
the session became establishing one (**ADR-0059**) and hardening it through
|
||||
real use, with two follow-up fixes the dogfooding turned up. Both arcs are
|
||||
fully merged; the tree is clean.
|
||||
|
||||
## §1. State
|
||||
|
||||
**Branch `main` at `78272e2`.** Everything below is merged; **no open PRs**;
|
||||
worktrees are just the primary (`main`) + the long-lived `website`. All
|
||||
feature/fix worktrees were cleaned up with `wt-rm`.
|
||||
|
||||
**Suite green at #37's merge:** **1831 lib + 8 e2e_pty + 503 it + 200 typing
|
||||
(2542 total), 0 failed, 1 ignored**; `fmt --check` + `clippy -D warnings`
|
||||
clean (via `nix develop -c`). The new workflow scripts carry their own
|
||||
local-`origin` harnesses: `test-adr-reserve.sh` **10/10**, `test-wt.sh`
|
||||
**16/16**, all `shellcheck`-clean.
|
||||
|
||||
**Issues:** closed this session — **#37** (done, ADR-0058) and **#38**
|
||||
(won't-do, see §4). Still open — **#40** (winget release notes, deferred to
|
||||
next release; see §4). (#36/#39 were closed in earlier sessions.)
|
||||
|
||||
## §2. What shipped — #37 clause-concept hints (ADR-0058, merged #42)
|
||||
|
||||
The deferred ADR-0053 extension: a tier-3 **`hint.concept.*`** layer shown by
|
||||
**F1** when the cursor sits **inside a recognized clause**, layered under the
|
||||
per-form `hint.cmd.*` block. Seven topics — referential actions, `1:n`/`m:n`
|
||||
cardinality, primary key, unique, check, foreign key.
|
||||
|
||||
- **Mechanism:** a new transparent `Node::Concept { topic, inner }` grammar
|
||||
wrapper records the clause's byte span into `WalkContext::concept_spans`
|
||||
(**append-only — survives the clause being fully matched**, unlike
|
||||
`pending_hint_mode` which clears on match and so only knows the slot
|
||||
boundary). `concept_topic_at_cursor` walks the full buffer and returns the
|
||||
**innermost** containing span. No use of the dormant `WalkBound::Position`.
|
||||
- **Two user forks:** detection = "anywhere inside the clause"; **all four
|
||||
clause families** in v1. **Mode-keyed examples** (`example.simple` /
|
||||
`example.advanced`) honour ADR-0053 D6 for topics reachable in both modes;
|
||||
`emit_tier3_block` picks by live mode.
|
||||
- **`/runda` caught two design gaps before any code** (clause map missed the
|
||||
simple-mode constraint suffix; single-`example` model collided with D6) —
|
||||
both fixed. **Comprehensiveness gate**: a recursive `Node::Concept` visitor
|
||||
cross-checks grammar wrappers ↔ `CONCEPT_TOPICS` ↔ catalogue blocks.
|
||||
- 21 new tests (14 resolver, 3 gates, 3 F1 integration, 1 snapshot).
|
||||
|
||||
## §3. The working method (ADR-0059, merged #41; fixes #43, #44)
|
||||
|
||||
The session's main artifact — the trackable flow for the public repo:
|
||||
|
||||
- **PRs onto a protected `main`** (always-green; one direct-push carve-out,
|
||||
§4), one logical change per branch, conventional prefixes, the `/runda`/DA
|
||||
review pasted into each PR. **Merge commits (`--no-ff`)**; never
|
||||
rebase/squash (append-only).
|
||||
- **One worktree per branch** (`<repo>-worktree-<segment>`) — never switch the
|
||||
primary checkout. `scripts/wt-new.sh <branch>` (off `origin/main`,
|
||||
`--no-track`) / `scripts/wt-rm.sh <branch>` (removes only the **named**
|
||||
worktree).
|
||||
- **Reserve-first ADR numbering** — `scripts/adr-reserve.sh <slug>`. The fix
|
||||
for the real collision problem: a contiguous integer needs an allocator and
|
||||
plain git branches have none, so **`main` is the registry and an atomic
|
||||
`git push` is the lock** (compare-and-swap, retried on contention),
|
||||
recorded in the append-only ledger **`docs/adr/RESERVATIONS.log`**. The
|
||||
number is **stable from creation** (safe in immutable commit messages +
|
||||
cross-refs). Supersedes ADR-0000's placeholder-until-merge default;
|
||||
subproject namespaces (`ADR-website/ci-NNN`) unchanged.
|
||||
- **Branch protection** (you applied in Gitea): require PR + the `ci / gate*`
|
||||
check + up-to-date-before-merge; **owner whitelisted for direct push** (the
|
||||
reservation ledger line, now also handoff/session docs — §4).
|
||||
- **Fixes found by dogfooding:**
|
||||
- **#43** — `wt-clean` (auto-sweep of all merged worktrees) → explicit
|
||||
**`wt-rm <branch>`**. The auto-sweep would have deleted long-lived
|
||||
branches (`website`, `ci`) since they're merged into `main` too. Also
|
||||
**`wt-new --no-track`**: branching off `origin/main` had set the new
|
||||
branch's upstream to `origin/main`, so a bare `git push` errored on a
|
||||
name-mismatch and suggested the dangerous `HEAD:main`.
|
||||
- **#44** — CI **dedupe**: `push: branches: [main]` (was `['**']`) so a
|
||||
push to a PR'd branch no longer runs the gate twice. On **Gitea** the
|
||||
`push` and `pull_request` runs were byte-identical — Gitea has **no
|
||||
merge-preview ref**; its `pull_request` checks out `refs/pull/N/head`, the
|
||||
same commit a branch push would (`docs.gitea.com/usage/actions/faq`).
|
||||
Keeping the gate on `pull_request` is forward-compatible: if Gitea ever
|
||||
adds the merge ref, the runs upgrade to testing the merged result for free.
|
||||
|
||||
## §4. Decisions
|
||||
|
||||
- **#38 closed — won't build** the pre-submit-diagnostic tier-3 route. The
|
||||
ADR-0053 D6 deferral reasons still hold: `Diagnostic` carries no class key
|
||||
(a class field would have to thread through every diagnostic-creation
|
||||
site — broad change) for marginal value (tier-2 already surfaces
|
||||
diagnostics live; many classes duplicate the runtime-error/clause-concept
|
||||
tiers). Reconsider only if a diagnostic class has genuinely unique teaching
|
||||
value. Rationale recorded on the issue.
|
||||
- **#40 deferred** (open): wire `CHANGELOG` into komac's winget
|
||||
`--release-notes-url`. Best coupled to the **next `v*` release + first real
|
||||
winget *update* test** — the initial winget PR to `microsoft/winget-pkgs`
|
||||
is still unconfirmed after several days, so end-to-end validation needs a
|
||||
confirmed package first.
|
||||
- **Docs direct-push carve-out** (this handoff is the first to use it):
|
||||
handoff / session docs (and the reservation ledger) are **owner
|
||||
direct-pushed to `main`**, not PR'd. See §5 for *why* a PR doesn't work.
|
||||
|
||||
## §5. Process lessons (now in ADR-0059 / CLAUDE.md)
|
||||
|
||||
- **Update a branch in ONE lane** — locally (`git fetch && git merge
|
||||
origin/main`), **not** the Gitea "Update branch" button when a conflict is
|
||||
possible. The button silently mis-resolved the README clash on #42
|
||||
(dropped the `0059` index row) and, combined with a local merge, diverged
|
||||
the PR branch; healed with an append-only merge (no force-push).
|
||||
- **Primary checkout rests on `main`; feature work in worktrees.** Mid-session
|
||||
the primary was stuck on `feat` (bootstrap artifact), which is what made
|
||||
worktree cleanup awkward — there was no `main` checkout holding the
|
||||
scripts. Resolved by switching the primary to `main` and moving `feat` to a
|
||||
worktree.
|
||||
- **Docs-only PRs are blocked by the required gate.** `paths-ignore`
|
||||
(`docs/**`, `**/*.md`) means a docs-only change posts **no `ci / gate`
|
||||
run**, and branch protection requires `ci / gate*` — so the required check
|
||||
never arrives and the PR is stuck "expected/waiting." That is the concrete
|
||||
reason handoff/session docs are direct-pushed (§4). If docs-via-PR is ever
|
||||
wanted, add a "skipped → success" gate shim (or drop the paths-ignore).
|
||||
- **Gitea CI specifics worth remembering:** no merge-preview ref (above); the
|
||||
reserve script's CAS via atomic push; `--no-track` on branch creation to
|
||||
avoid the inherited-`origin/main` upstream.
|
||||
|
||||
## §6. Next
|
||||
|
||||
- **Next session is free to pick a feature** — no specific item is queued;
|
||||
consult `docs/requirements.md` for the remaining backlog (e.g. **TT4 /
|
||||
Tier-4-in-CI** still not wired; **D3 packaging** — winget pending
|
||||
confirmation; the larger deferred UX items: tutorial system, session-log +
|
||||
markdown export V4, multi-line input I1, ER-diagram export V3).
|
||||
- **#40** when the next release happens (with the first winget update test).
|
||||
- **Use the new flow:** `wt-new` to start a branch, `adr-reserve` the moment
|
||||
an ADR is needed, PR onto `main`, `wt-rm` when done. Push/merge stay your
|
||||
steps; agents prepare but never push.
|
||||
|
||||
## §7. Process pins
|
||||
|
||||
- Commits user-confirmed, no AI attribution, append-only. **Push/merge are
|
||||
the user's steps.** This handoff + the §4 carve-out doc amendments are
|
||||
direct-pushed to `main` by the owner (docs-only, paths-ignored,
|
||||
owner-whitelisted).
|
||||
Executable
+141
@@ -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?"
|
||||
Executable
+82
@@ -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 ]
|
||||
Executable
+87
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env bash
|
||||
# Smoke-tests wt-new.sh / wt-rm.sh against a local file:// origin.
|
||||
set -euo pipefail
|
||||
|
||||
here="$(cd "$(dirname "$0")" && pwd)"
|
||||
WT_NEW="$here/wt-new.sh"
|
||||
WT_RM="$here/wt-rm.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; }
|
||||
exists() { [ -d "$1" ] && echo yes || echo no; }
|
||||
has_branch() { git -C "$T/primary" show-ref --verify --quiet "refs/heads/$1" && echo yes || echo no; }
|
||||
|
||||
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" && "$@" ); }
|
||||
target1="$T/primary-worktree-thing-one"
|
||||
target2="$T/primary-worktree-thing-two"
|
||||
|
||||
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" "$target1" "$out"
|
||||
check "worktree dir exists" "yes" "$(exists "$target1")"
|
||||
check "worktree on the new branch" "feat/thing-one" "$(git -C "$target1" rev-parse --abbrev-ref HEAD)"
|
||||
# It must NOT inherit origin/main as upstream (else a bare `git push` errors
|
||||
# with a name-mismatch and suggests the dangerous `HEAD:main`). No upstream
|
||||
# until the first `git push -u` sets the right one.
|
||||
check "new branch has no upstream" "none" "$(git -C "$target1" rev-parse --abbrev-ref '@{upstream}' 2>/dev/null || echo none)"
|
||||
|
||||
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 )"
|
||||
|
||||
# Second worktree, and make the merge states explicit:
|
||||
# thing-one → merged into origin/main (push its tip to main),
|
||||
# thing-two → ahead of main (unmerged), clean.
|
||||
run bash "$WT_NEW" feat/thing-two >/dev/null 2>&1
|
||||
git -C "$target1" commit -q --allow-empty -m "thing-one work"
|
||||
git -C "$target1" push -q origin HEAD:main
|
||||
git -C "$target2" commit -q --allow-empty -m "thing-two work (unmerged)"
|
||||
git -C "$T/primary" fetch -q origin
|
||||
|
||||
echo "--- wt-rm removes ONLY the named worktree (the other is untouched) ---"
|
||||
run bash "$WT_RM" feat/thing-one >/dev/null 2>&1
|
||||
check "named worktree removed" "no" "$(exists "$target1")"
|
||||
check "UNNAMED worktree untouched" "yes" "$(exists "$target2")"
|
||||
check "merged branch deleted" "no" "$(has_branch feat/thing-one)"
|
||||
|
||||
echo "--- wt-rm errors on an unknown branch ---"
|
||||
set +e; run bash "$WT_RM" feat/nope >/dev/null 2>&1; rc=$?; set -e
|
||||
check "unknown branch rejected" "yes" "$( [ "$rc" -ne 0 ] && echo yes || echo no )"
|
||||
|
||||
echo "--- wt-rm refuses the primary checkout ---"
|
||||
set +e; run bash "$WT_RM" main >/dev/null 2>&1; rc=$?; set -e
|
||||
check "primary refused (nonzero)" "yes" "$( [ "$rc" -ne 0 ] && echo yes || echo no )"
|
||||
check "primary still present" "yes" "$(exists "$T/primary")"
|
||||
|
||||
echo "--- wt-rm refuses a dirty worktree ---"
|
||||
touch "$target2/untracked-file"
|
||||
set +e; run bash "$WT_RM" feat/thing-two >/dev/null 2>&1; rc=$?; set -e
|
||||
check "dirty refused (nonzero)" "yes" "$( [ "$rc" -ne 0 ] && echo yes || echo no )"
|
||||
check "dirty worktree kept" "yes" "$(exists "$target2")"
|
||||
rm -f "$target2/untracked-file"
|
||||
|
||||
echo "--- wt-rm removes the worktree but KEEPS an unmerged branch ---"
|
||||
run bash "$WT_RM" feat/thing-two >/dev/null 2>&1
|
||||
check "unmerged worktree removed" "no" "$(exists "$target2")"
|
||||
check "unmerged branch preserved" "yes" "$(has_branch feat/thing-two)"
|
||||
|
||||
echo
|
||||
echo "==== $PASS passed, $FAIL failed ===="
|
||||
[ "$FAIL" -eq 0 ]
|
||||
Executable
+60
@@ -0,0 +1,60 @@
|
||||
#!/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"
|
||||
# `--no-track`: branch off origin/main for a fresh base, but DON'T inherit
|
||||
# origin/main as the upstream. Without this, branching off a remote-tracking
|
||||
# ref makes git set the new branch's upstream to origin/main, so a later bare
|
||||
# `git push` fails with a name-mismatch and suggests the dangerous
|
||||
# `git push origin HEAD:main`. With no upstream, the first `git push -u origin
|
||||
# <branch>` sets the correct one.
|
||||
git worktree add --quiet --no-track -b "$branch" "$target" origin/main \
|
||||
|| die "git worktree add failed"
|
||||
|
||||
log "ready — cd into it:"
|
||||
echo "$target"
|
||||
Executable
+68
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# wt-rm.sh — remove the worktree for a named branch.
|
||||
#
|
||||
# EXPLICIT BY DESIGN: it removes ONLY the branch you name. There is no
|
||||
# scanning and no "is it merged into main?" heuristic to decide *what* to
|
||||
# remove — long-lived branches (e.g. `website`, the `ci` line) are merged
|
||||
# into main too, so auto-detection would happily delete them. Naming the
|
||||
# target is the safety.
|
||||
#
|
||||
# Safety still comes from git's own refusals, not from cleverness here:
|
||||
# - the worktree won't be removed if it has uncommitted/untracked changes
|
||||
# (no `--force`);
|
||||
# - the local branch is deleted ONLY if it is fully merged into
|
||||
# `origin/main` — otherwise the worktree goes but the branch (and its
|
||||
# unmerged commits) is kept.
|
||||
#
|
||||
# USAGE
|
||||
# scripts/wt-rm.sh <branch>
|
||||
# scripts/wt-rm.sh feat/clause-concept-hints
|
||||
#
|
||||
# For a worktree whose directory you already deleted by hand, use the
|
||||
# built-in `git worktree prune` (it only clears bookkeeping — never a
|
||||
# directory or a branch).
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
log() { printf ' wt-rm: %s\n' "$*" >&2; }
|
||||
die() { printf 'wt-rm: ERROR: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
[ $# -eq 1 ] || die "usage: wt-rm.sh <branch>"
|
||||
branch="$1"
|
||||
|
||||
git rev-parse --git-dir >/dev/null 2>&1 || die "not inside a git repo"
|
||||
|
||||
# Resolve the named branch to its worktree path.
|
||||
path=""; cur=""
|
||||
while read -r key val; do
|
||||
case "$key" in
|
||||
worktree) cur="$val" ;;
|
||||
branch) [ "${val#refs/heads/}" = "$branch" ] && path="$cur" ;;
|
||||
esac
|
||||
done < <(git worktree list --porcelain)
|
||||
|
||||
[ -n "$path" ] || die "no worktree is checked out on branch '$branch' (see: git worktree list)"
|
||||
|
||||
main_wt="$(git worktree list --porcelain | awk '/^worktree /{print $2; exit}')"
|
||||
here="$(git rev-parse --show-toplevel)"
|
||||
[ "$path" != "$main_wt" ] || die "refusing to remove the primary checkout ($path)"
|
||||
[ "$path" != "$here" ] || die "refusing to remove the worktree you're standing in — run this from elsewhere"
|
||||
|
||||
log "removing worktree $path (branch $branch)"
|
||||
git worktree remove "$path" \
|
||||
|| die "could not remove $path — it likely has uncommitted or untracked changes; clean it (or remove by hand) first"
|
||||
|
||||
# Delete the local branch only if it is safely merged into origin/main;
|
||||
# otherwise keep it so unmerged commits are never lost. (No auto-fetch — it
|
||||
# judges against your current origin/main; run `git fetch` first if you want
|
||||
# a freshly-merged branch to be eligible.)
|
||||
if git rev-parse --verify --quiet origin/main >/dev/null \
|
||||
&& git merge-base --is-ancestor "$branch" origin/main; then
|
||||
git branch -D "$branch" >/dev/null
|
||||
log "deleted local branch $branch (merged into origin/main)"
|
||||
else
|
||||
log "kept local branch $branch — not merged into origin/main; delete with 'git branch -D $branch' if you're sure"
|
||||
fi
|
||||
|
||||
log "done"
|
||||
Reference in New Issue
Block a user