#!/bin/sh set -eu # Feature-finish front door. Run as `npm run create:finish`. # # Mirror image of `create:branch`: asserts the merge-side preconditions, merges # the current `feature/`/`fix/`/`chore/` branch into `main` with `--no-ff`, # proves the result with `npm run verify`, and only then deletes the branch. The # push is owned by `create:release`, so the merge stays local and reviewable. # On a conflict it aborts and returns to the feature branch. # # Rationale and the rejected alternatives: development/workflow.md § Branching # model. BASE="main" PREFIXES="feature fix chore" git rev-parse --is-inside-work-tree >/dev/null 2>&1 || { echo "error: not inside a git work tree." >&2 exit 1 } 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 not be part of the merge and # silently outlive the branch deletion. DIRTY=$(git status --porcelain) if [ -n "${DIRTY}" ]; then echo "error: working tree is not clean:" >&2 echo "${DIRTY}" | sed 's/^/ /' >&2 exit 1 fi START_REF=$(git symbolic-ref --quiet --short HEAD || true) if [ -z "${START_REF}" ]; then echo "error: detached HEAD; switch to the branch you want to finish." >&2 exit 1 fi if [ "${START_REF}" = "${BASE}" ]; then echo "error: already on '${BASE}'; switch to the branch to finish." >&2 exit 1 fi MATCH=0 for p in ${PREFIXES}; do case "${START_REF}" in "${p}/"*) MATCH=1 ;; esac done if [ "${MATCH}" -ne 1 ]; then echo "error: '${START_REF}' must start with one of: ${PREFIXES}." >&2 echo " refusing to merge a branch that is not a unit of work." >&2 exit 1 fi git show-ref --verify --quiet "refs/heads/${BASE}" || { echo "error: no local '${BASE}' to merge into." >&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="" BEHIND=0 if [ -n "${UPSTREAM}" ]; then git fetch --quiet "${UPSTREAM%/*}" "${UPSTREAM#*/}" || { echo "error: '${UPSTREAM}' check failed: could not reach '${UPSTREAM%/*}'." >&2 echo " refusing to merge onto a possibly stale '${BASE}'." >&2 exit 1 } BEHIND=$(git rev-list --count "${BASE}..${UPSTREAM}") AHEAD=$(git rev-list --count "${UPSTREAM}..${BASE}") if [ "${AHEAD}" -ne 0 ] && [ "${BEHIND}" -ne 0 ]; then echo "error: '${BASE}' has diverged from '${UPSTREAM}' (ahead ${AHEAD}, behind ${BEHIND})." >&2 echo " reconcile '${BASE}' with '${UPSTREAM}' before finishing." >&2 exit 1 fi else echo "warning: '${BASE}' has no upstream; freshness against the remote is unchecked." >&2 fi echo "Finishing into ${BASE}:" git --no-pager log --oneline --no-decorate "${BASE}..${START_REF}" | sed 's/^/ /' git switch --quiet "${BASE}" if [ "${BEHIND}" -ne 0 ]; then echo "Fast-forwarding ${BASE} to ${UPSTREAM} (${BEHIND} commit(s))." git merge --quiet --ff-only "${UPSTREAM}" fi if ! git merge --quiet --no-ff -m ":twisted_rightwards_arrows: Merge ${START_REF} into ${BASE}" "${START_REF}"; then echo "error: merge of '${START_REF}' failed; aborting and returning to it." >&2 git merge --abort 2>/dev/null || true git switch --quiet "${START_REF}" exit 1 fi echo "Verify: npm run verify" if ! npm run --silent verify; then echo "error: 'npm run verify' is red after the merge." >&2 echo " the merge is local and not yet pushed; fix it on '${BASE}' and commit," >&2 echo " then drop the now-merged branch with 'git branch -d ${START_REF}'." >&2 exit 1 fi git branch --delete "${START_REF}" >/dev/null echo "Merged ${START_REF} into ${BASE} and deleted the branch." echo "Next: npm run create:release (or git push, for a merge with no release)."