From 5ab69cb67eef98d616e3571472d8ca449db3e443 Mon Sep 17 00:00:00 2001 From: William Jacoby Date: Fri, 6 Nov 2026 18:41:43 -0600 Subject: [PATCH] Add prune-merged-branches.py Deletes local branches whose work is already merged on GitHub, in one checkout or a folder of them: git branch -d when main contains the branch, and git branch -D for squash merges only when the merged PR's head is exactly the branch's tip. Branches checked out in a worktree, the default branch, wiki clones and non-GitHub checkouts are left alone. tests/test-prune-merged-branches.sh covers it offline in CI. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 5 +- README.md | 1 + prune-merged-branches.py | 126 ++++++++++++++++++++++++++++ tests/test-prune-merged-branches.sh | 114 +++++++++++++++++++++++++ 4 files changed, 245 insertions(+), 1 deletion(-) create mode 100755 prune-merged-branches.py create mode 100755 tests/test-prune-merged-branches.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 747988f..0357dc9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: persist-credentials: false - name: ShellCheck - run: shellcheck -x git-extensions.sh git-release-style.sh git-release-langpack.sh lib/github-release.sh lib/release-source.sh tests/test-release-scripts.sh tests/test-release-api.sh phpbb-test-board/setup-board.sh phpbb-test-board/run-unit-tests.sh phpbb-test-board/php-lint.sh phpbb-test-board/db-matrix.sh tests/test-phpbb-test-board.sh tests/test-pr-wait.sh tests/test-normalize-composer.sh + run: shellcheck -x git-extensions.sh git-release-style.sh git-release-langpack.sh lib/github-release.sh lib/release-source.sh tests/test-release-scripts.sh tests/test-release-api.sh phpbb-test-board/setup-board.sh phpbb-test-board/run-unit-tests.sh phpbb-test-board/php-lint.sh phpbb-test-board/db-matrix.sh tests/test-phpbb-test-board.sh tests/test-pr-wait.sh tests/test-normalize-composer.sh tests/test-prune-merged-branches.sh - name: Tests run: tests/test-release-scripts.sh @@ -35,3 +35,6 @@ jobs: - name: normalize-composer.py tests run: tests/test-normalize-composer.sh + + - name: prune-merged-branches.py tests + run: tests/test-prune-merged-branches.sh diff --git a/README.md b/README.md index 56595b4..4110986 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Build phpBB package ZIPs, publish GitHub Releases, wait for PR checks, and test | [`git-release-langpack.sh`](git-release-langpack.sh) | Validate, package, and release language packs | | [`pr-wait.py`](pr-wait.py) | Wait for a pull request's checks | | [`normalize-composer.py`](normalize-composer.py) | Bring an extension's `composer.json` to the phpBB 3.3.19 / PHP 8.3.36 baseline | +| [`prune-merged-branches.py`](prune-merged-branches.py) | Delete local branches already merged on GitHub, squash merges included | | [`phpbb-test-board/`](phpbb-test-board/) | Set up a local board, run checks, and take screenshots | ## Quick start diff --git a/prune-merged-branches.py b/prune-merged-branches.py new file mode 100755 index 0000000..1f0f5b0 --- /dev/null +++ b/prune-merged-branches.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +""" +Delete local git branches whose work is already merged on GitHub. + +Works on one checkout, or on every checkout directly inside a folder. In each +one, a local branch other than the default branch, and not checked out in any +worktree, is deleted when: + - it is an ancestor of origin's default branch (a normal merge): + `git branch -d`; + - or a pull request from it was merged on GitHub with exactly this branch's + tip as its head commit (a squash or rebase merge, which `git branch -d` + refuses): `git branch -D`. Because the tip must match, a branch that got + commits after its PR was merged is kept. +Every other branch is kept and listed with the reason. Checkouts whose origin +isn't on GitHub, and wiki clones, are skipped. + +Inputs: a checkout, or a folder of checkouts; needs git and an authenticated gh +Outputs: one line per deleted or kept branch, then a summary +Exit status: 0 on success, 1 when a git or gh call failed for a checkout, + 2 for bad arguments. +""" +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path + + +def git(repo: Path, *args: str) -> str: + """Run git in repo and return its stdout; raise CalledProcessError on failure.""" + return subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True, check=True).stdout + + +def github_slug(repo: Path) -> str: + """Return OWNER/NAME from origin's URL, or '' when origin isn't a GitHub repository.""" + try: + # The configured URL, not `git remote get-url`, which applies url.insteadOf rewrites. + url = git(repo, "config", "--get", "remote.origin.url").strip() + except subprocess.CalledProcessError: + return "" + m = re.search(r"github\.com[:/]([^/]+/[^/]+?)(?:\.git)?/?$", url) + if not m or m.group(1).endswith(".wiki"): + return "" + return m.group(1) + + +def checkouts(path: Path) -> list: + """Return path itself when it is a checkout, else the checkouts directly inside it.""" + if (path / ".git").exists(): + return [path] + return sorted(p for p in path.iterdir() if (p / ".git").is_dir()) + + +def prune(repo: Path, slug: str, dry_run: bool) -> tuple: + """Delete one checkout's merged branches; return (deleted, kept, failures).""" + deleted = kept = failures = 0 + git(repo, "fetch", "-q", "--prune", "origin") + default = subprocess.run(["gh", "api", f"repos/{slug}", "-q", ".default_branch"], + capture_output=True, text=True, check=True).stdout.strip() + checked_out = set(re.findall(r"^branch refs/heads/(.+)$", git(repo, "worktree", "list", "--porcelain"), re.M)) + refs = git(repo, "for-each-ref", "--format=%(refname:short) %(objectname)", "refs/heads").splitlines() + for line in filter(None, refs): + branch, sha = line.split(" ") + if branch == default or branch in checked_out: + continue + ancestor = subprocess.run(["git", "-C", str(repo), "merge-base", "--is-ancestor", sha, + f"origin/{default}"]).returncode == 0 + how = f"merged into {default}" if ancestor else "" + if not ancestor: + prs = subprocess.run(["gh", "pr", "list", "-R", slug, "--head", branch, "--state", "merged", + "--json", "number,headRefOid"], capture_output=True, text=True) + if prs.returncode != 0: + print(f"{repo.name} {branch}: kept (could not ask GitHub)") + kept += 1 + continue + match = [p for p in json.loads(prs.stdout or "[]") if p["headRefOid"] == sha] + if match: + how = f"PR #{match[0]['number']} merged from this exact commit" + if not how: + print(f"{repo.name} {branch}: kept (not merged, or has commits after its merge)") + kept += 1 + continue + if not dry_run: + try: + git(repo, "branch", "-d" if ancestor else "-D", branch) + except subprocess.CalledProcessError as e: + print(f"{repo.name} {branch}: FAILED to delete: {(e.stderr or '').strip()[:200]}") + failures += 1 + continue + print(f"{repo.name} {branch}: {'would delete' if dry_run else 'deleted'} ({how}, {sha[:7]})") + deleted += 1 + return deleted, kept, failures + + +def main() -> int: + ap = argparse.ArgumentParser( + description="Delete local branches already merged on GitHub (squash merges included, " + "only when the merged PR's head is the branch's exact tip).", + epilog="Exit status: 0 success, 1 a git or gh call failed, 2 bad arguments.") + ap.add_argument("path", nargs="?", type=Path, help="a git checkout, or a folder whose direct subfolders are checkouts") + ap.add_argument("-n", "--dry-run", action="store_true", help="list what would be deleted, delete nothing") + args = ap.parse_args() + if args.path is None: + ap.print_help() + return 2 + if not args.path.is_dir(): + ap.error(f"{args.path} is not a folder") + + totals = [0, 0, 0] + for repo in checkouts(args.path.resolve()): + slug = github_slug(repo) + if not slug: + continue + try: + counts = prune(repo, slug, args.dry_run) + except subprocess.CalledProcessError as e: + print(f"{repo.name}: FAILED: {(e.stderr or '').strip()[:200]}") + counts = (0, 0, 1) + totals = [a + b for a, b in zip(totals, counts)] + print(f"== {'would delete' if args.dry_run else 'deleted'} {totals[0]}, kept {totals[1]}, failures {totals[2]}") + return 1 if totals[2] else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test-prune-merged-branches.sh b/tests/test-prune-merged-branches.sh new file mode 100755 index 0000000..ed8ae92 --- /dev/null +++ b/tests/test-prune-merged-branches.sh @@ -0,0 +1,114 @@ +#!/usr/bin/bash +# Offline checks for prune-merged-branches.py. A local bare repository stands +# in for GitHub (through git's url.insteadOf) and a fake gh answers the two +# questions the script asks: the default branch and the merged PRs of a branch. +# Usage: tests/test-prune-merged-branches.sh +set -Eeuo pipefail + +root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +tool="$root/prune-merged-branches.py" +work=$(mktemp -d) +trap 'rm -rf -- "$work"' EXIT +export GIT_CONFIG_GLOBAL="$work/gitconfig" +export GIT_CONFIG_NOSYSTEM=1 +git config --global user.name test +git config --global user.email test@example.invalid +git config --global init.defaultBranch main +git config --global url."file://$work/remote/".insteadOf "https://github.com/" + +failures=0 +fail() { printf 'FAIL: %s\n' "$1"; failures=$((failures + 1)); } +pass() { printf 'ok: %s\n' "$1"; } +# check NAME CONDITION...: pass when the [[ ]] condition holds. +check() { local name=$1; shift; if "$@"; then pass "$name"; else fail "$name"; fi; } +has() { [[ " $2 " == *" $1 "* ]]; } +lacks() { [[ " $2 " != *" $1 "* ]]; } + +# Fake gh: "api repos/OWNER/NAME" prints main; "pr list ... --head BRANCH" +# prints $work/prs/BRANCH.json, or [] when there is none. +mkdir -p "$work/bin" "$work/prs" +cat >"$work/bin/gh" </dev/null || echo '[]'; exit 0; fi + shift +done +exit 1 +MOCK +chmod +x "$work/bin/gh" +export PATH="$work/bin:$PATH" + +# The "GitHub" repository and a checkout of it. +mkdir -p "$work/remote/acme" "$work/checkouts" +git init -q --bare "$work/remote/acme/demo.git" +git clone -q https://github.com/acme/demo.git "$work/checkouts/demo" 2>/dev/null +repo="$work/checkouts/demo" +git -C "$repo" commit -q --allow-empty -m base +git -C "$repo" push -q origin main + +commit_on() { + # commit_on BRANCH: make BRANCH from main with one commit; print its sha. + git -C "$repo" switch -q -c "$1" main + git -C "$repo" commit -q --allow-empty -m "$1" + git -C "$repo" rev-parse HEAD + git -C "$repo" switch -q main +} + +# Merged normally: main contains its tip. +commit_on merged-normally >/dev/null +git -C "$repo" merge -q --ff-only merged-normally +git -C "$repo" push -q origin main +# Squash-merged from exactly its tip. +tip=$(commit_on squashed) +printf '[{"number": 7, "headRefOid": "%s"}]' "$tip" >"$work/prs/squashed.json" +# Squash-merged, then given another commit: the PR head is the old tip. +old=$(commit_on squashed-then-more) +git -C "$repo" switch -q squashed-then-more +git -C "$repo" commit -q --allow-empty -m "after the merge" +git -C "$repo" switch -q main +printf '[{"number": 8, "headRefOid": "%s"}]' "$old" >"$work/prs/squashed-then-more.json" +# Never merged. +commit_on unmerged >/dev/null +# Merged, but checked out in another worktree. +tip=$(commit_on in-worktree) +printf '[{"number": 9, "headRefOid": "%s"}]' "$tip" >"$work/prs/in-worktree.json" +git -C "$repo" worktree add -q "$work/wt" in-worktree + +# A wiki clone and a checkout not on GitHub are skipped without errors. +git init -q --bare "$work/remote/acme/demo.wiki.git" +git clone -q https://github.com/acme/demo.wiki.git "$work/checkouts/demo.wiki" 2>/dev/null +git init -q "$work/checkouts/local-only" + +branches() { git -C "$repo" for-each-ref --format='%(refname:short)' refs/heads | sort | tr '\n' ' '; } +before=$(branches) + +out=$("$tool" --dry-run "$work/checkouts") +if [[ "$(branches)" == "$before" ]]; then pass "--dry-run deletes nothing"; else fail "--dry-run deletes nothing"; fi +if [[ "$out" == *"== would delete 2, kept 2, failures 0"* ]]; then pass "--dry-run summary"; else fail "--dry-run summary: $out"; fi + +status=0 +out=$("$tool" "$work/checkouts") || status=$? +after=$(branches) +if [[ $status -eq 0 ]]; then pass "exit 0"; else fail "exit 0 (got $status): $out"; fi +check "a normally merged branch is deleted" lacks merged-normally "$after" +check "a branch squash-merged from its tip is deleted" lacks squashed "$after" +check "a branch with commits after its merge is kept" has squashed-then-more "$after" +check "an unmerged branch is kept" has unmerged "$after" +check "a branch checked out in a worktree is kept" has in-worktree "$after" +check "the default branch is kept" has main "$after" +if [[ "$out" != *demo.wiki* && "$out" != *local-only* ]]; then pass "wiki and non-GitHub checkouts are skipped"; else fail "skipped checkouts: $out"; fi + +# A single checkout as the path works too. +out=$("$tool" --dry-run "$repo") +if [[ "$out" == *"== would delete 0, kept 2, failures 0"* ]]; then pass "a single checkout as the path"; else fail "single checkout: $out"; fi + +status=0 +"$tool" >/dev/null || status=$? +if [[ $status -eq 2 ]]; then pass "no arguments prints help and exits 2"; else fail "no arguments exits 2 (got $status)"; fi + +if ((failures)); then + printf '%d test(s) failed\n' "$failures" + exit 1 +fi +echo "All tests passed."