#!/bin/sh set -eu # Branch front-door. Run as `npm run create:branch -- /`. # # Asserts the branch preconditions — clean tree, no in-progress operation, # current `main`, green baseline — and only then creates the branch, so the # expensive `npm run test` is not paid on a tree that was never eligible. # # Why the front door exists, the rejected prefix names, and the `create:` # decision: development/workflow.md § Branching model and § Script prefix # convention. BASE="main" PREFIXES="feature fix chore" NAME="${1:-}" if [ -z "${NAME}" ]; then echo "usage: npm run create: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. # `--quiet` echoes the unresolved `main@{upstream}` literal to stdout on # failure, so it cannot be paired with a `$(...) || fallback`; the non-quiet # form prints nothing on failure and the fallback runs. UPSTREAM=$(git rev-parse --abbrev-ref --symbolic-full-name "${BASE}@{upstream}" 2>/dev/null) || UPSTREAM="" 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}")."