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
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
126 changes: 126 additions & 0 deletions prune-merged-branches.py
Original file line number Diff line number Diff line change
@@ -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())
114 changes: 114 additions & 0 deletions tests/test-prune-merged-branches.sh
Original file line number Diff line number Diff line change
@@ -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" <<MOCK
#!/usr/bin/bash
if [[ "\$1" == api ]]; then echo main; exit 0; fi
while [[ \$# -gt 0 ]]; do
if [[ "\$1" == --head ]]; then cat "$work/prs/\$2.json" 2>/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."
Loading