From 8efd13b2f439f3c0d6f8d1029802774d2f573a48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20M=C3=BCller?= Date: Tue, 8 Sep 2026 22:09:51 +0200 Subject: [PATCH] :sparkles: Add branch front-door command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The branching model assumed a clean, current `main` and a green baseline before any edit, but both were prose, and prose nobody checks silently becomes a suggestion. `npm run branch -- /` asserts the precondition and only then creates the branch, so a later failure is attributable to the change that caused it. Checks run cheap-first (`--porcelain` deliberately catches untracked files, which would otherwise ride onto the new branch) and the suite runs last, so an ineligible tree never pays for it. `main`'s remote is derived from its upstream rather than hardcoded: this repo has both `origin` and `origin_https`, and `main` tracks the latter, so a `git fetch origin main` currency check would compare against a ref that is never updated here. The baseline runs after switching to `main`, so a red `main` restores the starting branch instead of stranding the caller on it. The prefix stays a judgment call: the script validates it against the documented vocabulary instead of inferring it. Prose kept as index only — AGENTS.md points agents at the command from the task workflow, CONTRIBUTING.md owns the model and the bare-script tier (dropping its stale hardcoded count of "two" conveniences). --- AGENTS.md | 2 +- CONTRIBUTING.md | 4 +- package.json | 1 + scripts/branch.sh | 135 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 140 insertions(+), 2 deletions(-) create mode 100755 scripts/branch.sh diff --git a/AGENTS.md b/AGENTS.md index 341558c..33f4ce7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ Never start a long-lived / blocking process such as `npm run watch`. It runs unt ### Working on tasks -- **Task with subtasks** (a task that has indented children): create a branch off `main` using the prefix inferred from the task content (`feature/…` / `fix/…` / `chore/…`), check it out, work on each subtask with commits, then present a concise handover for the user to review. Use this fixed shape: +- **Task with subtasks** (a task that has indented children): create the branch with `npm run branch -- /`, inferring the prefix from the task content (`feature/…` / `fix/…` / `chore/…`) — do not hand-write `git switch -c`, the command enforces the clean-tree / current-`main` / green-baseline precondition. Work on each subtask with commits, then present a concise handover for the user to review. Use this fixed shape: ```md ## Handover — diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index edfaa24..d981352 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,6 +10,7 @@ CI and review will bounce these even though `npm run check` and the linters don' - **A new `npm run` script must reuse an existing prefix** (`check:` / `fix:` / `test:` / `watch:` / `maintain:` / `publish:`). If none fits, that's a signal the script doesn't belong in the pipeline — not a reason to invent a new prefix. (see [Script prefix convention](#script-prefix-convention)) - **`oxlint-disable` directives live in source, not `.oxlintrc.json`.** The trade-off must sit next to the code it silences. This is a _human_ last-resort convention; agents must not add these — see [AGENTS.md § Never do](./AGENTS.md#never-do). (rationale: README § Tooling decisions) - **Don't put slow / network / whole-project scans in `check` or pre-commit.** Advisory scans are not correctness gates; they belong under `maintain:`. (see [Feedback tiers](#feedback-tiers) and [Script prefix convention](#script-prefix-convention)) +- **New work starts with `npm run branch`, never a hand-written `git switch -c`/`git checkout -b`.** The command carries the branch precondition; branching around it skips the clean-tree, current-`main` and green-baseline checks, and the skip is invisible until a failure can no longer be attributed. (see [Branching model](#branching-model)) - **There is no local `npm run publish`, and `publish:publint` / `publish:attw` don't go in `check`.** (see [Publishing workflow](#publishing-workflow)) ## Commit messages @@ -31,7 +32,7 @@ Script names in `package.json` use a prefix that signals _when_ the script is in A new script should pick the prefix that matches its lifecycle, not invent a new one. If no existing prefix fits, that's a signal the script doesn't belong in the standard pipeline. -Separately, some top-level scripts are **bare** (no prefix): the entry points that either run a single tool (`build`, `clean`) or aggregate a `prefix:*` family (`check`, `fix`, `test`, `watch`, `maintain`), plus two conveniences that compose across tiers: `verify` — composing `check` + `test:unit` into one whole-project correctness gate (it deliberately uses `test:unit` rather than `test` because `check` already runs `check:tsc`, so the type checker runs exactly once); and `release` — the maintainer-only release front-door (see [Publishing workflow](#publishing-workflow)). Bare commands are how you invoke a tier; the `prefix:*` scripts are what those tiers are made of. +Separately, some top-level scripts are **bare** (no prefix): the entry points that either run a single tool (`build`, `clean`) or aggregate a `prefix:*` family (`check`, `fix`, `test`, `watch`, `maintain`), plus conveniences that compose across tiers: `verify` — composing `check` + `test:unit` into one whole-project correctness gate (it deliberately uses `test:unit` rather than `test` because `check` already runs `check:tsc`, so the type checker runs exactly once); and the two scripts that bracket a unit of work and own its git state — `branch` — assert the precondition the branching model assumes, then create the branch (see [Branching model](#branching-model)); and `release` — the maintainer-only release front-door (see [Publishing workflow](#publishing-workflow)). Bare commands are how you invoke a tier; the `prefix:*` scripts are what those tiers are made of. ## Feedback tiers @@ -77,6 +78,7 @@ This is why every test in the suite pairs an `expectTypeOf(...)` with an `assert - **Base branch:** `main` - **Branch naming:** `feature/` / `fix/` / `chore/` +- **Starting work:** `npm run branch -- /`. It refuses, without changing anything, unless the working tree is clean (untracked files included), no merge/rebase/cherry-pick is in progress, `main` matches its upstream, and `npm run test` is green on `main` — so a later failure is always attributable to your edits. The prefix is still _your_ call, inferred from the task; the script validates it rather than guessing it. - **Merging:** `git checkout main && git merge --no-ff ` (local PR — review the diff yourself before closing the branch). - CI runs `npm run check` + `npm run test:ci` on every push to `main` — this is the authoritative gate. - **Releases are NOT triggered by pushes.** Only the maintainer triggers a release (see [Publishing workflow](#publishing-workflow)). diff --git a/package.json b/package.json index 42526e9..27d3a3f 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "fix": "npm run fix:oxlint && npm run fix:oxfmt", "fix:oxfmt": "oxfmt ${LEFTHOOK_FILES:-.}", "fix:oxlint": "oxlint --fix src", + "branch": "./scripts/branch.sh", "release": "./scripts/release.sh", "maintain": "npm run maintain:knip; npm run maintain:outdated", "maintain:knip": "knip --include dependencies,exports,files", diff --git a/scripts/branch.sh b/scripts/branch.sh new file mode 100755 index 0000000..572812a --- /dev/null +++ b/scripts/branch.sh @@ -0,0 +1,135 @@ +#!/bin/sh + +set -eu + +# Branch front-door. Run as `npm run branch -- /`. +# +# How we got here (short): the branching model says every change starts from a +# clean, current `main`, and the type-driven loop only produces trustworthy +# results if the baseline was green *before* the first edit. Both facts were +# prose. Prose rots silently — a rule nobody checks is a suggestion — so the +# precondition became this script: it asserts, then branches, and the branch +# only appears if the assertions passed. Cheap checks run first, `npm run test` +# runs last: the expensive gate is not paid on a tree that was never eligible. +# +# Rejected: a new `flow:` / `git:` prefix (CONTRIBUTING.md § Script prefix +# convention forbids inventing one, and `use:git-commit-message` shows what +# happens when a prefix is added without amending the vocabulary that declares +# them); `check:*` (read-only and aggregated by `check`, so CI would run a +# command that mutates repo state); `fix:*` (its review surface is a file diff, +# not a branch); `maintain:*` (advisory, never a gate — the inverse of this); +# `start` (npm reserves it for running the package); a full git-flow CLI wrapping +# the merge too (merging ends in "review the diff yourself", which is judgment, +# and only the start half carries a verification burden); and reusing `pubv`'s +# preflight (it is release-shaped, third-party, and would make branch start pay +# a build + pack it has no use for). +# +# Every refusal is non-mutating except the baseline test, which runs on `main` +# after we switch there — so a red `main` restores the branch you started on +# rather than stranding you on it. + +BASE="main" +PREFIXES="feature fix chore" +NAME="${1:-}" + +if [ -z "${NAME}" ]; then + echo "usage: npm run branch -- / (prefix: ${PREFIXES})" >&2 + exit 2 +fi + +git rev-parse --is-inside-work-tree >/dev/null 2>&1 || { + echo "error: not inside a git work tree." >&2 + exit 1 +} + +MATCH=0 +for p in ${PREFIXES}; do + case "${NAME}" in + "${p}/"*) MATCH=1 ;; + esac +done +if [ "${MATCH}" -ne 1 ]; then + echo "error: '${NAME}' must start with one of: ${PREFIXES}." >&2 + echo " the prefix is inferred from the task, not defaulted here." >&2 + exit 1 +fi +git check-ref-format --branch "${NAME}" >/dev/null 2>&1 || { + echo "error: '${NAME}' is not a valid branch name." >&2 + exit 1 +} +git show-ref --verify --quiet "refs/heads/${NAME}" && { + echo "error: branch '${NAME}' already exists; switch to it instead." >&2 + exit 1 +} + +START_REF=$(git symbolic-ref --quiet --short HEAD || true) +if [ -z "${START_REF}" ]; then + echo "error: detached HEAD; switch to a branch first." >&2 + exit 1 +fi + +STATE_ROOT=$(git rev-parse --absolute-git-dir) +for state in MERGE_HEAD rebase-merge rebase-apply CHERRY_PICK_HEAD BISECT_LOG; do + [ -e "${STATE_ROOT}/${state}" ] && { + echo "error: a '${state}' operation is in progress; finish or abort it first." >&2 + exit 1 + } +done +# `--porcelain` is deliberately stricter than `git diff --quiet`: it also reports +# untracked files, which would otherwise ride silently onto the new branch. +DIRTY=$(git status --porcelain) +if [ -n "${DIRTY}" ]; then + echo "error: working tree is not clean:" >&2 + echo "${DIRTY}" | sed 's/^/ /' >&2 + exit 1 +fi + +git show-ref --verify --quiet "refs/heads/${BASE}" || { + echo "error: no local '${BASE}' to branch from." >&2 + exit 1 +} + +# Derive the remote rather than hardcoding it: this repo has `origin` (ssh) and +# `origin_https`, and `main` tracks the latter — `git fetch origin main` would +# check currency against a ref that is never updated here. +UPSTREAM=$(git rev-parse --quiet --abbrev-ref --symbolic-full-name "${BASE}@{upstream}" 2>/dev/null || true) +if [ -n "${UPSTREAM}" ]; then + git fetch --quiet "${UPSTREAM%/*}" "${UPSTREAM#*/}" || { + echo "error: '${UPSTREAM}' check failed: could not reach '${UPSTREAM%/*}'." >&2 + echo " refusing to branch on a possibly stale '${BASE}'." >&2 + exit 1 + } + BEHIND=$(git rev-list --count "${BASE}..${UPSTREAM}") + AHEAD=$(git rev-list --count "${UPSTREAM}..${BASE}") + if [ "${BEHIND}" -ne 0 ] || [ "${AHEAD}" -ne 0 ]; then + echo "error: '${BASE}' has diverged from '${UPSTREAM}' (ahead ${AHEAD}, behind ${BEHIND})." >&2 + [ "${AHEAD}" -ne 0 ] && echo " not yet pushed commits on '${BASE}': push them, or rebase this work onto them." >&2 + [ "${BEHIND}" -ne 0 ] && echo " update it: git switch ${BASE} && git pull --ff-only" >&2 + exit 1 + fi +else + echo "warning: '${BASE}' has no upstream; freshness against the remote is unchecked." >&2 +fi + +restore() { + git switch --quiet "${START_REF}" 2>/dev/null || true +} +trap 'restore' EXIT HUP INT TERM + +if [ "${START_REF}" != "${BASE}" ]; then + git switch --quiet "${BASE}" +fi + +echo "Baseline: npm run test" +if ! npm run --silent test; then + echo "error: baseline is red on '${BASE}'; fix that first so later failures stay attributable." >&2 + exit 1 +fi + +git switch --quiet --no-track -c "${NAME}" +trap - EXIT HUP INT TERM + +# push.default=upstream is set here, so an inherited upstream would make a bare +# `git push` target main. Branching local-from-local does not set one anyway; +# --no-track says so out loud. +echo "Created ${NAME} from ${BASE} $(git rev-parse --short "${BASE}")."