The merge half of the branching model was still prose, so it drifted per session. create:finish mirrors create:branch: it asserts the merge-side preconditions, fast-forwards a stale main (divergence is refused), merges --no-ff, runs npm run verify, and deletes the branch only when green. The push stays with create:release so the merge is reviewable first.
134 lines
5.1 KiB
Bash
Executable File
134 lines
5.1 KiB
Bash
Executable File
#!/bin/sh
|
|
|
|
set -eu
|
|
|
|
# Feature-finish front door. Run as `npm run create:finish`.
|
|
#
|
|
# Why this exists: `create:branch` opens a unit of work, but the close half
|
|
# (`git checkout main && git merge --no-ff <branch>`) stayed prose in the
|
|
# branching model, so it drifted per contributor and per session. This is the
|
|
# mirror image of `create:branch`: it asserts the same preconditions (clean
|
|
# tree, no in-progress operation, `main` matching its upstream), merges the
|
|
# current `feature/`/`fix/`/`chore/` branch into `main`, proves the result with
|
|
# `npm run verify`, and only then deletes the branch.
|
|
#
|
|
# `create:branch`'s comment argued against wrapping the merge as "judgment —
|
|
# review the diff yourself". That judgment still lives here, just moved: the
|
|
# maintainer reviews the handover *before* invoking this, and the script only
|
|
# commits the merge, never the push. The push is owned by `create:release`, so
|
|
# the release commit and its tag leave together and a local merge stays
|
|
# reviewable (and can be reverted with `git revert -m 1`) until then. `--no-ff`
|
|
# keeps the unit of work visible in `git log`.
|
|
#
|
|
# Unlike `create:branch` a stale `main` is fast-forwarded instead of refused:
|
|
# the tree is clean (checked above) and `main` is not the checked-out branch
|
|
# yet, so there is no local state to lose. True divergence (local commits *and*
|
|
# upstream commits) is still refused — that needs a human.
|
|
#
|
|
# On a merge conflict we abort and return to the feature branch, so a failed
|
|
# finish never strands you on a half-merged `main`.
|
|
|
|
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.
|
|
UPSTREAM=$(git rev-parse --quiet --abbrev-ref --symbolic-full-name "${BASE}@{upstream}" 2>/dev/null || true)
|
|
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 --no-edit "${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)."
|