`branch` and `release` were bare commands, but bare in this repo means "how you invoke a tier" or "runs one tool" — neither fits a command that opens or closes a unit of work. They get their own prefix now, since a prefix is how this repo records _when_ a script runs. `create:` because both members genuinely create something (a branch, a release) and it is a plain verb rather than VCS slang. No bare `create` aggregator: running "all the workflows" describes nothing anyone wants, and `publish:*` already precedents a prefix without one. The rule "reuse an existing prefix, never invent one" now says what it actually means: a new prefix is allowed when the scripts belong in the pipeline, provided it enters both lists in the same commit as its first member. That is the lesson from `use:`, which is referenced by prose yet in none of the lists — already tracked in backlog.tasks. The bare-command sentence shrinks to `build`, `clean`, `verify`.
149 lines
6.0 KiB
Bash
Executable File
149 lines
6.0 KiB
Bash
Executable File
#!/bin/sh
|
|
|
|
set -eu
|
|
|
|
# Branch front-door. Run as `npm run create: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 for the prefix name: `run:` / `perform:` (both mean only "do the
|
|
# thing named after them", so every script in the repo would fit under them and
|
|
# the taxonomy collapses); `git:` (names the tool, not the lifecycle moment, and
|
|
# advertises passthrough aliases); `start:` (describes this half, not the
|
|
# release); `cut:` (idiomatic for both, but it needs VCS slang to decode, and a
|
|
# signpost that has to be explained is not one); `flow:` (overloaded in a library
|
|
# about type-level matching); and the existing families — `check:*` is read-only
|
|
# and aggregated by `check`, so CI would run a command that mutates repo state;
|
|
# `fix:*`'s review surface is a file diff, not a branch; `maintain:*` is advisory
|
|
# and explicitly never a gate.
|
|
#
|
|
# `create:` was kept because both members really do create something: a branch,
|
|
# a release. It was added to both prefix lists in CONTRIBUTING.md in the same
|
|
# commit as its first members, because a prefix missing from those lists is
|
|
# invisible — which is the `use:` mistake this repo now carries in backlog.tasks.
|
|
# There is deliberately no bare `create` aggregator: "run all the workflows"
|
|
# describes nothing anyone wants, and `publish:*` already sets the precedent for
|
|
# a prefix without one.
|
|
#
|
|
# Also rejected: 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 (release-shaped,
|
|
# third-party, and it 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 create: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}")."
|