✨ Add branch front-door command

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 -- <prefix>/<desc>` 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).
This commit is contained in:
tmu committed 2026-09-08 22:14:34 +02:00
1 parent 93cb37441d
commit 8efd13b2f4
4 files changed
+140 -2

No files matched your search

+135
View File
@@ -0,0 +1,135 @@
#!/bin/sh
set -eu
# Branch front-door. Run as `npm run branch -- <prefix>/<desc>`.
#
# 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>/<desc> (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}")."