diff --git a/.devtools/prune-merged-branches.sh b/.devtools/prune-merged-branches.sh new file mode 100755 index 0000000..b57967e --- /dev/null +++ b/.devtools/prune-merged-branches.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Deletes local branches whose pull request has been merged. +# +# Usage: prune-merged-branches.sh [--dry-run] +# +# Runs from the shared post-merge hook, so it fires on the `git pull` that brings a +# merged pull request into the default branch, and does nothing on any other branch. +# A branch goes only when all of these hold: +# - its remote branch is gone (GitHub deletes it when the pull request merges) +# - it is not checked out here or in another worktree +# - its work is in the default branch: either its tip is an ancestor of it, or, +# for a squash merge that leaves no ancestry, gh reports a merged pull request +# whose head is exactly the local tip +# +# Anything else is kept and named, so commits made after the merge, or on a branch +# whose pull request was closed unmerged, are never lost. Without gh, a squash-merged +# branch is kept rather than guessed at. The exit status is 0 either way: a cleanup +# must not make a pull look like it failed. +set -euo pipefail + +dry_run=false +case "${1:-}" in + '') ;; + --dry-run) dry_run=true ;; + -h | --help) + sed -n '2,16p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) + echo "unknown argument: $1" >&2 + exit 2 + ;; +esac + +default="$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)" +default="${default#origin/}" +default="${default:-main}" +current="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)" +[[ "${current}" == "${default}" ]] || exit 0 + +# A pull does not prune on its own unless fetch.prune is set, and offline is fine. +git fetch --prune --quiet origin 2>/dev/null || true + +checked_out="$(git worktree list --porcelain | sed -n 's#^branch refs/heads/##p')" + +git for-each-ref --format='%(refname:short)%09%(upstream:track)%09%(objectname)' refs/heads | + while IFS=$'\t' read -r branch track tip; do + [[ "${track}" == '[gone]' ]] || continue + + if grep -Fxq "${branch}" <<<"${checked_out}"; then + echo "– kept ${branch}: checked out in a worktree" + continue + fi + + merged=false + if git merge-base --is-ancestor "${tip}" "${default}" 2>/dev/null; then + merged=true + elif command -v gh >/dev/null 2>&1; then + # Captured first: grep -q closing the pipe early would fail it under pipefail. + heads="$(gh pr list --head "${branch}" --state merged \ + --json headRefOid --jq '.[].headRefOid' 2>/dev/null || true)" + if grep -Fxq "${tip}" <<<"${heads}"; then + merged=true + fi + fi + + if [[ "${merged}" != true ]]; then + echo "– kept ${branch}: not shown to be merged (closed unmerged, or commits after the merge)" + elif [[ "${dry_run}" == true ]]; then + echo "would delete ${branch}" + else + git branch --quiet -D "${branch}" + echo "✔ deleted ${branch}" + fi + done + +exit 0 diff --git a/.devtools/test-hooks.sh b/.devtools/test-hooks.sh index a278b9d..0ba78ef 100755 --- a/.devtools/test-hooks.sh +++ b/.devtools/test-hooks.sh @@ -131,6 +131,76 @@ expect fail "fails when a named base is missing" "${size}" --base no-such-branch git branch --quiet -m main trunk expect pass "skips when a guessed base is missing" "${size}" +echo "prune-merged-branches" +prune="${here}/prune-merged-branches.sh" + +# gh stands in for GitHub: it reports GH_MERGED_OID as the head of a merged pull request. +mkdir "${work}/bin" +# shellcheck disable=SC2016 # the stub expands it, not this script +printf '#!/bin/sh\n[ -z "${GH_MERGED_OID:-}" ] || echo "${GH_MERGED_OID}"\n' >"${work}/bin/gh" +chmod +x "${work}/bin/gh" +mkdir "${work}/bin-fail" +printf '#!/bin/sh\nexit 1\n' >"${work}/bin-fail/gh" +chmod +x "${work}/bin-fail/gh" +no_gh=(env PATH="${work}/bin-fail:${PATH}") + +git init --quiet --bare --initial-branch=main "${work}/origin.git" +git clone --quiet "${work}/origin.git" "${work}/prune" 2>/dev/null +cd "${work}/prune" +git commit --quiet --allow-empty -m 'chore: initial commit' +git push --quiet origin main +git remote set-head origin main >/dev/null + +# branch_off : a pushed branch with one commit, back on main afterwards. +branch_off() { + git switch --quiet -c "$1" main + echo "$1" >"$2" + git add "$2" + git commit --quiet -m "feat: add $2" + git push --quiet -u origin "$1" 2>/dev/null + git switch --quiet main +} +# remote_gone : what GitHub does to a head branch when its pull request merges. +remote_gone() { git push --quiet origin --delete "$1" 2>/dev/null; } +has_branch() { git show-ref --verify --quiet "refs/heads/$1"; } + +branch_off feat/ancestry a.txt +git merge --quiet --no-ff -m 'feat: merge ancestry' feat/ancestry +git push --quiet origin main +remote_gone feat/ancestry + +branch_off feat/squashed b.txt +squashed_tip="$(git rev-parse feat/squashed)" +git merge --quiet --squash feat/squashed >/dev/null +git commit --quiet -m 'feat: squash b' +git push --quiet origin main +remote_gone feat/squashed + +branch_off feat/closed c.txt +remote_gone feat/closed +branch_off feat/live d.txt +branch_off feat/worktree e.txt +remote_gone feat/worktree +git worktree add --quiet "${work}/wt" feat/worktree 2>/dev/null + +expect pass "dry run exits 0" "${prune}" --dry-run +expect pass "dry run deletes nothing" has_branch feat/ancestry +expect pass "ignores a branch that is not the default" bash -c "git switch --quiet -c feat/elsewhere && '${prune}' && git switch --quiet main" +expect pass "and deletes nothing there" has_branch feat/ancestry +expect pass "keeps a squash merge it cannot confirm without gh" "${no_gh[@]}" "${prune}" +expect pass "keeps it" has_branch feat/squashed +expect fail "deletes a branch merged by ancestry" has_branch feat/ancestry +expect pass "keeps a branch with a live remote" has_branch feat/live +expect pass "keeps a branch whose pull request closed unmerged" has_branch feat/closed +expect pass "keeps a branch checked out in a worktree" has_branch feat/worktree +expect pass "keeps a squash merge whose head moved on" env PATH="${work}/bin:${PATH}" GH_MERGED_OID=0000000000000000000000000000000000000000 "${prune}" +expect pass "and the branch with it" has_branch feat/squashed +expect pass "runs with a squash merge gh confirms" env PATH="${work}/bin:${PATH}" GH_MERGED_OID="${squashed_tip}" "${prune}" +expect fail "and deletes it" has_branch feat/squashed +expect pass "still keeps the unmerged and the live" has_branch feat/closed +expect pass "and the checked out one" has_branch feat/worktree +cd "${work}/repo" + if ((failures > 0)); then echo "${failures} failed" >&2 exit 1 diff --git a/.lefthook/post-merge/prune-branches.sh b/.lefthook/post-merge/prune-branches.sh new file mode 100755 index 0000000..42ce8ff --- /dev/null +++ b/.lefthook/post-merge/prune-branches.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# lefthook finds a shared hook's script here, in .lefthook// at the root of the +# repository the config came from. The cleanup itself stays in .devtools/. +# +# git passes a post-merge hook a squash flag. The script takes none, so it is not handed on. +exec "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/.devtools/prune-merged-branches.sh" diff --git a/README.md b/README.md index bee13d4..5cb74d8 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,13 @@ PR_BASE=main lefthook run pre-push --job pr-size `PR_MAX_FILES` changes the limit. Without `PR_BASE` the base is the branch below this one in a `gh stack`, else the remote's default branch. +`org.yml` also tidies up after a merge. On the default branch, `post-merge` runs +[`prune-merged-branches.sh`](.devtools/prune-merged-branches.sh), which deletes the local branches +whose remote is gone and whose work is in: the tip is an ancestor of the default branch, or, for a +squash merge, `gh` reports a merged pull request with exactly that head. A branch checked out in +any worktree, or one it cannot show to be merged, is kept and named. Run it with `--dry-run` to +see what it would do. A repository gets the hook once it bumps `ref` and runs `lefthook install`. + Commit messages are not part of `org.yml`: each repository runs commitlint against its own `.commitlintrc.yaml` in a `commit-msg` job, and `ci-pr.yml` reads the same file. diff --git a/lefthook/org.yml b/lefthook/org.yml index 7c35316..9b2f056 100644 --- a/lefthook/org.yml +++ b/lefthook/org.yml @@ -11,6 +11,9 @@ # # The scripts need bash and git; pr-size also uses gh and jq when they are present, # to find a stack's parent. Set BRANCH_NAME to check a name that is not checked out. +# +# post-merge tidies up after a merged pull request: on the default branch it deletes the +# local branches whose remote is gone and whose work is in, and keeps the rest. pre-push: jobs: @@ -21,3 +24,9 @@ pre-push: - name: pr-size script: pr-size.sh runner: bash + +post-merge: + jobs: + - name: prune-branches + script: prune-branches.sh + runner: bash