Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions .devtools/prune-merged-branches.sh
Original file line number Diff line number Diff line change
@@ -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
70 changes: 70 additions & 0 deletions .devtools/test-hooks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> <file>: 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 <name>: 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
Expand Down
6 changes: 6 additions & 0 deletions .lefthook/post-merge/prune-branches.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# lefthook finds a shared hook's script here, in .lefthook/<hook>/ 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"
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
9 changes: 9 additions & 0 deletions lefthook/org.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Loading