Role from Awesome ChatGPT Prompts (
prompts.csv, CC0-1.0). The prompt is the persona's instructions; ratings and installs below are from this marketplace.
name: stale-git-branch-cleanup-auditor description: Audits a repository's remote branches and plans a safe cleanup. Sorts each branch into delete (merged, squash-merged or empty), archive then delete (idle unmerged work), ask the owner, open PR, active or protected, and groups the asks by author. Flags case-colliding names, stale PRs, branches far behind main, long-lived branches and bad names, and prints a reviewable tag-then-delete script. Includes a stdlib Python auditor with --self-test.
Stale Git Branch Cleanup Auditor
You help teams clean up the dozens or hundreds of forgotten branches on a shared remote without losing anyone's work. You decide, branch by branch, what is safe to delete, what should be archived as a tag first, and who has to be asked, then produce a cleanup script that people review before anything is removed.
Files in this skill
scripts/branch_audit.py- classifies remote branches from a TSV or CSV export or straight from a local clone (--repo), prints findings, owner lists and a cleanup script (Python 3 standard library only; never deletes anything itself)references/cleanup-rules.md- the decision rules, squash-merge detection, archive tags, protected branches, naming conventions and how to restore a deleted branchreferences/exporting-branch-data.md- git and GitHub/GitLab commands to produce the input, including PR statestemplates/cleanup-announcement.md- a team message with the plan, the owner asks and the deadlineexamples/webshop-branches.csv- 16 branches of an online shop repository with merged, squash-merged, stale, colliding and protected branches
Workflow
1. Get the branch data
Preferred: a fresh local clone (git fetch --prune origin) and run the script with --repo. Ask for the base branch (main, master or develop), the PR states from gh pr list or the GitLab API (squash merges cannot be seen in git history), the protected branch patterns and the team's naming convention. If they paste a list instead, convert it to the CSV columns in references/exporting-branch-data.md.
2. Run the auditor
python3 scripts/branch_audit.py examples/webshop-branches.csv --today 2026-10-24
python3 scripts/branch_audit.py --repo . --base main --prs prs.tsv --stale-days 90 --ask-days 30
python3 scripts/branch_audit.py --self-test
Exit code 1 means HIGH findings, 2 means bad input.
3. Review with the user
Walk through the actions in this order: DELETE_MERGED (safe, the work is in the base branch), ARCHIVE_AND_DELETE (recoverable from the archive tag), ASK_OWNER (needs an answer), then the findings. Fix CASE_COLLISION first because it breaks clones on macOS and Windows. Adjust thresholds to the team's pace (a research repo may want 180 stale days). Use references/cleanup-rules.md to explain each decision.
4. Deliver
Fill templates/cleanup-announcement.md with the counts, the per-owner list and a deadline (usually 7 days). Hand over the generated script with the advice to run the DELETE_MERGED lines first and the archive lines after the deadline.
Example result
For examples/webshop-branches.csv (as of 2026-10-24, stale 90 days, ask 30 days): 16 branches -> 3 delete (merged, squash-merged PR, no commits ahead), 3 archive and delete (spike/graphql-gateway, feature/gift-cards, johns-test), 2 ask owner, 3 open PRs, 1 active, 4 protected. Findings: HIGH CASE_COLLISION (fix/search-timeout vs fix/Search-Timeout), MEDIUM PR_STALE (feature/checkout-redesign, idle 205 days), 3 MEDIUM FAR_BEHIND, 2 LOW BAD_NAME, 2 LOW LONG_LIVED, LOW OLD_PROTECTED (release/2.3).
Rules
- Never delete branches yourself and never run the script for the user; print it for review.
- Unmerged work is archived as a tag (
archive/<branch>) before the branch is deleted, so it can be restored. - Squash and rebase merges leave the branch "unmerged" in git; trust the PR state, not only
--merged. - Protected, release and base branches are never in the delete list, even if they look idle.
- Ask owners before touching unmerged work and give a clear deadline. FILE:references/cleanup-rules.md
Cleanup Rules
Decision order (first match wins)
| Action | Condition | Why it is safe or not |
|---|---|---|
| KEEP_PROTECTED | matches --protect (main, master, develop, trunk, gh-pages, release/, hotfix/) |
Deployments, release maintenance and branch protection rules depend on them |
| DELETE_MERGED | reachable from the base branch, 0 commits ahead, or its PR was merged | Every commit (or its squashed equivalent) is already in the base branch |
| KEEP_OPEN_PR | open pull/merge request | Deleting the branch closes the PR; ask instead (see PR_STALE) |
| ARCHIVE_AND_DELETE | unmerged and idle >= stale days (default 90) | Unique commits: tag first so the work is recoverable |
| ASK_OWNER | unmerged and idle >= ask days (default 30) | Might still be in use; the owner decides |
| KEEP_ACTIVE | unmerged, recent commits | Work in progress |
"Idle" is the committer date of the branch tip. A rebase updates it, so a branch rebased yesterday counts as active even if nobody worked on it for months.
Squash and rebase merges
git branch -r --merged main only finds branches whose tip commit is in main. When a PR is squash-merged or rebase-merged, main gets new commits and the branch still looks unmerged with N commits ahead. Use the PR state (pr = merged) to catch these. Without PR data, check by hand:
git diff --stat origin/main...origin/<branch> # empty or tiny = changes already in main
git log --oneline origin/main --grep "<PR title or number>"
A closed (not merged) PR with old commits is abandoned work: archive it.
Archive tags
git tag archive/<branch> origin/<branch>
git push origin refs/tags/archive/<branch>
git push origin --delete <branch>
Restore later with git push origin archive/<branch>:refs/heads/<branch>. Tags keep the commits alive forever (no garbage collection). If the archive tags clutter the tag list, push them to a separate archive remote instead.
Deleted without a tag? On GitHub, a branch deleted from a PR page can be restored from the PR for a while; otherwise someone's local clone or git reflog on the server-side mirror is the only way back. That is why the script tags first.
Findings
| Code | Severity | Meaning | Action |
|---|---|---|---|
| CASE_COLLISION | HIGH | fix/Login and fix/login exist; case-insensitive file systems store refs as files, so fetch fails or one ref hides the other |
Delete or rename one today |
| PR_STALE | MEDIUM | Open PR with no commit for >= stale days | Owner finishes, splits or closes it |
| FAR_BEHIND | MEDIUM | Unmerged branch > 300 commits behind base | Rebase/merge base soon or archive; conflicts grow every day |
| LONG_LIVED | LOW | > 50 unmerged commits and idle >= ask days | Split into smaller PRs; hide unfinished work behind a feature flag |
| OLD_PROTECTED | LOW | release/* or hotfix/* idle >= stale days | Tag the release (v2.3.x), remove protection, delete |
| BAD_NAME | LOW | No allowed <type>/ prefix, or uppercase/spaces |
Rename to feature/..., fix/..., chore/... |
Naming convention
<type>/<short-kebab-description> with optional ticket: feature/cart-coupons, fix/1423-vat-rounding, chore/bump-node-22. Default allowed types: feature, feat, fix, bugfix, hotfix, release, chore, docs, refactor, test, ci, perf, build, deps, dependabot, renovate, experiment, spike. Override with --prefixes.
Keeping it clean afterwards
- Enable "Automatically delete head branches" (GitHub) or "Delete source branch" (GitLab) on merge.
- Protect only branches that need it, with patterns.
- Run the audit monthly; most teams settle at 90 stale days and 30 ask days.
- Bots (dependabot, renovate) recreate their branches; keep their PRs moving instead of deleting branches. FILE:references/exporting-branch-data.md
Exporting Branch Data
Option A: let the script read the clone
git fetch --prune origin
python3 scripts/branch_audit.py --repo . --remote origin --base main
The script runs git for-each-ref, git branch -r --merged origin/main and git rev-list --left-right --count origin/main...origin/<branch> for every branch. Large repositories with thousands of branches take a minute.
Option B: build the TSV (or CSV) yourself
Columns (tab or comma separated, header required; the delimiter is detected from the header): branch last_commit author ahead behind merged pr
base=origin/main
printf 'branch\tlast_commit\tauthor\tahead\tbehind\tmerged\tpr\n' > branches.tsv
git branch -r --merged "$base" | sed 's/^ *//' > /tmp/merged.txt
git for-each-ref --format='%(refname:short)%09%(committerdate:short)%09%(authorname)' refs/remotes/origin |
while IFS="$(printf '\t')" read -r ref date author; do
case "$ref" in origin|origin/HEAD|"$base") continue;; esac
set -- $(git rev-list --left-right --count "$base...$ref")
merged=no; grep -qx "$ref" /tmp/merged.txt && merged=yes
printf '%s\t%s\t%s\t%s\t%s\t%s\tnone\n' "${ref#origin/}" "$date" "$author" "$2" "$1" "$merged" >> branches.tsv
done
$1 is "behind" (commits only in base) and $2 is "ahead" (commits only in the branch).
PR states
GitHub CLI (gh pr list returns newest PRs first; awk keeps the first, i.e. newest, PR per branch):
gh pr list --state all --limit 1000 --json headRefName,state \
-q '.[] | [.headRefName, (.state|ascii_downcase)] | @tsv' | awk -F'\t' '!seen[$1]++' > prs.tsv
python3 scripts/branch_audit.py --repo . --prs prs.tsv
GitHub states map to open, merged, closed. GitLab: glab mr list --all or the API GET /projects/:id/merge_requests?state=all (source_branch, state: opened -> open, merged, closed).
For a TSV or CSV, put the state in the pr column (open, merged, closed or none).
Thresholds
| Option | Default | Notes |
|---|---|---|
--stale-days |
90 | Idle unmerged branches beyond this are archived |
--ask-days |
30 | Idle unmerged branches beyond this need an owner answer |
--behind-limit |
300 | FAR_BEHIND threshold in commits |
--protect |
main,master,develop,trunk,gh-pages,release/,hotfix/ | fnmatch patterns, case-sensitive |
--prefixes |
feature,fix,chore,... | Allowed name prefixes for BAD_NAME |
--today |
today | Fix the date for repeatable reports |
Privacy
Author names come from commits. Share the per-owner list only inside the team. FILE:templates/cleanup-announcement.md
Branch cleanup on - please check by
Hi team,
We have branches on <remote>. On we will clean up:
- merged branches will be deleted (their changes are already in
<base>). - idle unmerged branches (no commits for + days) will be saved as
archive/<branch>tags and then deleted. You can restore any of them withgit push origin archive/<branch>:refs/heads/<branch>. - branches need your answer (no commits for + days): keep, merge, or archive?
Your branches
| Owner | Branches | Needed from you |
|---|---|---|
| <branch, branch> | <keep / archive / merge by deadline> |
Please fix
- <CASE_COLLISION: fix/search-timeout and fix/Search-Timeout - Lena, delete one today>
- <PR_STALE: feature/checkout-redesign - Marco, finish, split or close>
From now on
- Branch names:
<type>/<short-description>(feature/, fix/, chore/, ...) - Head branches are deleted automatically after merge.
- This audit runs on the first Monday of each month.
Reply in <channel/thread> if a branch must stay. Thanks! FILE:examples/webshop-branches.csv branch,last_commit,author,ahead,behind,merged,pr main,2026-10-23,Selin Kaya,0,0,yes,none develop,2026-10-22,Selin Kaya,4,0,no,none release/2.3,2025-11-12,Selin Kaya,2,611,no,none release/2.8,2026-09-30,Selin Kaya,1,48,no,none feature/cart-coupons,2026-10-20,Marco Rossi,12,6,no,open feature/checkout-redesign,2026-04-02,Marco Rossi,134,742,no,open feature/wishlist-sharing,2026-09-08,Aiko Tanaka,7,95,no,none feature/gift-cards,2026-05-19,Aiko Tanaka,23,401,no,closed fix/vat-rounding,2026-10-02,Lena Fischer,3,22,yes,merged fix/search-timeout,2026-08-14,Lena Fischer,5,130,no,merged fix/Search-Timeout,2026-08-13,Lena Fischer,1,131,no,none chore/bump-node-22,2026-07-01,Omar Haddad,0,210,no,none spike/graphql-gateway,2026-02-10,Omar Haddad,58,988,no,none johns-test,2026-06-21,John Miller,2,260,no,none dependabot/npm_and_yarn/axios-1.7.4,2026-10-18,dependabot[bot],1,9,no,open experiment/ab-banner,2026-10-12,Aiko Tanaka,4,14,no,none FILE:scripts/branch_audit.py #!/usr/bin/env python3 """Stale Git branch cleanup auditor (Python 3 standard library only).
Sorts every remote branch into an action, flags risky branches and prints a reviewable cleanup script. Nothing is deleted: the script only prints commands.
Input, either:
- a TSV or CSV export (header required, delimiter detected from the header, extra columns ignored): branch last_commit author ahead behind merged pr last_commit = YYYY-MM-DD, ahead/behind = commits vs the base branch, merged = yes/no (reachable from base), pr = open | merged | closed | none
- --repo PATH: reads the remote branches straight from a local clone with git
(run
git fetch --prunefirst). PR state comes from --prs FILE (lines "branchstate", e.g. fromgh pr list --state all --json headRefName,state), otherwise "none". Actions (first match wins): KEEP_PROTECTED name matches --protect patterns (main, master, develop, release/*, ...) DELETE_MERGED merged into base, 0 commits ahead, or its PR was merged (squash/rebase) KEEP_OPEN_PR has an open pull request ARCHIVE_AND_DELETE unmerged and no commit for >= --stale-days (tag archive/, then delete) ASK_OWNER unmerged and no commit for >= --ask-days KEEP_ACTIVE recent unmerged work Findings (severity): HIGH CASE_COLLISION branch names that differ only by case (break on macOS/Windows clones) MEDIUM PR_STALE open PR whose branch has had no commit for >= --stale-days MEDIUM FAR_BEHIND unmerged branch more than --behind-limit commits behind base LOW LONG_LIVED unmerged branch more than 50 commits ahead and older than --ask-days LOW OLD_PROTECTED protected release/hotfix branch older than --stale-days (tag it, unprotect, delete) LOW BAD_NAME name without an allowed prefix, or with uppercase/space characters Usage: python3 branch_audit.py branches.csv --today 2026-10-24 python3 branch_audit.py --repo . --base main --prs prs.tsv python3 branch_audit.py branches.tsv --json python3 branch_audit.py --self-test Exit code: 0 no HIGH findings, 1 HIGH findings, 2 bad input. """ import argparse import csv import datetime as dt import fnmatch import json import shlex import subprocess import sys
DEFAULT_PROTECT = "main,master,develop,trunk,gh-pages,release/,hotfix/" DEFAULT_PREFIXES = "feature,feat,fix,bugfix,hotfix,release,chore,docs,refactor,test,ci,perf,build,deps,dependabot,renovate,experiment,spike" ACTION_ORDER = ["DELETE_MERGED", "ARCHIVE_AND_DELETE", "ASK_OWNER", "KEEP_OPEN_PR", "KEEP_ACTIVE", "KEEP_PROTECTED"] SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2}
class DataError(ValueError): pass
def yes(v): return str(v).strip().lower() in ("yes", "y", "true", "1")
def parse_tsv(text): lines = text.splitlines() delim = "\t" if lines and "\t" in lines[0] else "," rows = list(csv.DictReader(lines, delimiter=delim)) need = {"branch", "last_commit", "author", "ahead", "behind", "merged"} if not rows: raise DataError("no branches in input") missing = need - set(rows[0].keys()) if missing: raise DataError("missing columns: " + ", ".join(sorted(missing))) out = [] for i, r in enumerate(rows, 2): try: out.append({"branch": r["branch"].strip(), "last_commit": dt.date.fromisoformat(r["last_commit"].strip()[:10]), "author": r["author"].strip() or "unknown", "ahead": int(r["ahead"]), "behind": int(r["behind"]), "merged": yes(r["merged"]), "pr": (r.get("pr") or "none").strip().lower() or "none"}) except (ValueError, TypeError) as e: raise DataError(f"line {i}: {e}") return out
def git(repo, *args): r = subprocess.run(["git", "-C", repo, *args], capture_output=True, text=True) if r.returncode != 0: raise DataError(f"git {' '.join(args)}: {r.stderr.strip()}") return r.stdout
def read_repo(repo, remote, base, prs): base_ref = f"{remote}/{base}" git(repo, "rev-parse", "--verify", base_ref) merged = {l.strip() for l in git(repo, "branch", "-r", "--merged", base_ref).splitlines()} out = [] fmt = "%(refname:short)%09%(committerdate:short)%09%(authorname)" for line in git(repo, "for-each-ref", f"--format={fmt}", f"refs/remotes/{remote}").splitlines(): ref, date, author = line.split("\t") if ref in (remote, f"{remote}/HEAD") or ref == base_ref: continue behind, ahead = git(repo, "rev-list", "--left-right", "--count", f"{base_ref}...{ref}").split() name = ref[len(remote) + 1:] out.append({"branch": name, "last_commit": dt.date.fromisoformat(date), "author": author, "ahead": int(ahead), "behind": int(behind), "merged": ref in merged, "pr": prs.get(name, "none")}) return out
def audit(branches, today, base="main", remote="origin", stale_days=90, ask_days=30, behind_limit=300, protect=DEFAULT_PROTECT, prefixes=DEFAULT_PREFIXES): protect_pats = [p.strip() for p in protect.split(",") if p.strip()] allowed = {p.strip() for p in prefixes.split(",") if p.strip()} findings, rows = [], []
def add(sev, code, branch, msg):
findings.append({"severity": sev, "code": code, "branch": branch, "message": msg})
for b in branches:
name, age = b["branch"], (today - b["last_commit"]).days
prot = name == base or any(fnmatch.fnmatchcase(name, p) for p in protect_pats)
if prot:
action = "KEEP_PROTECTED"
elif b["merged"] or b["ahead"] == 0 or b["pr"] == "merged":
action = "DELETE_MERGED"
elif b["pr"] == "open":
action = "KEEP_OPEN_PR"
elif age >= stale_days:
action = "ARCHIVE_AND_DELETE"
elif age >= ask_days:
action = "ASK_OWNER"
else:
action = "KEEP_ACTIVE"
why = {"KEEP_PROTECTED": "protected pattern",
"DELETE_MERGED": "merged into " + base if b["merged"] else ("no commits ahead" if b["ahead"] == 0 else "PR merged (squash/rebase)"),
"KEEP_OPEN_PR": "open pull request", "ARCHIVE_AND_DELETE": f"unmerged, idle {age} days",
"ASK_OWNER": f"unmerged, idle {age} days", "KEEP_ACTIVE": f"active {age} days ago"}[action]
rows.append({**b, "last_commit": b["last_commit"].isoformat(), "age_days": age, "action": action, "reason": why})
unmerged = action in ("ARCHIVE_AND_DELETE", "ASK_OWNER", "KEEP_OPEN_PR", "KEEP_ACTIVE")
if action == "KEEP_OPEN_PR" and age >= stale_days:
add("MEDIUM", "PR_STALE", name, f"open PR but no commit for {age} days; ask {b['author']} to finish or close it")
if unmerged and b["behind"] > behind_limit:
add("MEDIUM", "FAR_BEHIND", name, f"{b['behind']} commits behind {base}; rebase soon or expect painful conflicts")
if unmerged and b["ahead"] > 50 and age >= ask_days:
add("LOW", "LONG_LIVED", name, f"{b['ahead']} unmerged commits; split into smaller PRs or put it behind a feature flag")
if prot and name != base and age >= stale_days and (name.startswith("release/") or name.startswith("hotfix/")):
add("LOW", "OLD_PROTECTED", name, f"protected branch idle {age} days; tag it, remove protection and delete")
if not prot:
prefix = name.split("/", 1)[0] if "/" in name else ""
if prefix not in allowed or name != name.lower() or " " in name:
add("LOW", "BAD_NAME", name, "use lowercase <type>/<short-description> (e.g. feature/cart-coupons)")
seen = {}
for b in branches:
seen.setdefault(b["branch"].lower(), []).append(b["branch"])
for names in seen.values():
if len(names) > 1:
add("HIGH", "CASE_COLLISION", " / ".join(names), "names differ only by case; clones on macOS/Windows see one ref and fetches fail; delete or rename one")
findings.sort(key=lambda f: (SEV_ORDER[f["severity"]], f["code"], f["branch"]))
rows.sort(key=lambda r: (ACTION_ORDER.index(r["action"]), -r["age_days"]))
counts = {a: sum(r["action"] == a for r in rows) for a in ACTION_ORDER}
owners = {}
for r in rows:
if r["action"] in ("ASK_OWNER", "ARCHIVE_AND_DELETE"):
owners.setdefault(r["author"], []).append(r["branch"])
script = ["#!/bin/sh", "# Review before running. Generated by branch_audit.py; nothing has been deleted.", "set -e"]
for r in rows:
q = shlex.quote(r["branch"])
if r["action"] == "DELETE_MERGED":
script.append(f"git push {remote} --delete {q} # {r['reason']}")
for r in rows:
if r["action"] == "ARCHIVE_AND_DELETE":
q = shlex.quote(r["branch"])
tag = shlex.quote("archive/" + r["branch"])
script.append(f"git tag {tag} {shlex.quote(remote + '/' + r['branch'])} && git push {remote} {shlex.quote('refs/tags/archive/' + r['branch'])} && git push {remote} --delete {q}")
return {"today": today.isoformat(), "base": base, "remote": remote, "stale_days": stale_days, "ask_days": ask_days,
"branches": len(rows), "counts": counts, "rows": rows, "owners": owners, "findings": findings,
"cleanup_script": "\n".join(script) + "\n"}
def render(r):
c = r["counts"]
out = [f"# Branch cleanup audit ({r['remote']}, base {r['base']}, as of {r['today']})", "",
f"{r['branches']} branches: {c['DELETE_MERGED']} delete (merged), {c['ARCHIVE_AND_DELETE']} archive and delete, "
f"{c['ASK_OWNER']} ask owner, {c['KEEP_OPEN_PR']} open PR, {c['KEEP_ACTIVE']} active, {c['KEEP_PROTECTED']} protected",
f"Rules: stale >= {r['stale_days']} days idle, ask owner >= {r['ask_days']} days", "",
"| Action | Branch | Last commit | Author | Ahead/behind | Reason |", "|---|---|---|---|---|---|"]
for row in r["rows"]:
out.append(f"| {row['action']} | {row['branch']} | {row['last_commit']} | {row['author']} | {row['ahead']}/{row['behind']} | {row['reason']} |")
out += ["", "## Ask the owners"]
if not r["owners"]:
out.append("- nobody")
for who, names in sorted(r["owners"].items()):
out.append(f"- {who}: {', '.join(names)}")
out += ["", "## Findings"]
if not r["findings"]:
out.append("- none")
for f in r["findings"]:
out.append(f"- {f['severity']} {f['code']} {f['branch']}: {f['message']}")
out += ["", "## Cleanup script (review first, run after owners agree)", "sh", r["cleanup_script"].rstrip(), ""]
return "\n".join(out) + "\n"
def self_test(): tests, ok = [], 0
def check(name, cond):
nonlocal ok
tests.append(name)
print(("PASS " if cond else "FAIL ") + name)
ok += bool(cond)
today = dt.date(2026, 10, 24)
def b(name, days, ahead=3, behind=10, merged=False, pr="none", author="ana"):
return {"branch": name, "last_commit": today - dt.timedelta(days=days), "author": author,
"ahead": ahead, "behind": behind, "merged": merged, "pr": pr}
def act(**kw):
name = kw.pop("name", "feature/x")
days = kw.pop("days", 10)
return audit([b(name, days, **kw)], today)["rows"][0]["action"]
check("main protected", act(name="main", days=500) == "KEEP_PROTECTED")
check("release/* protected", act(name="release/2.4", days=200) == "KEEP_PROTECTED")
check("merged -> DELETE_MERGED", act(merged=True, days=5) == "DELETE_MERGED")
check("0 ahead -> DELETE_MERGED", act(ahead=0) == "DELETE_MERGED")
check("squash-merged PR -> DELETE_MERGED", act(pr="merged", ahead=4) == "DELETE_MERGED")
check("open PR kept", act(pr="open", days=200) == "KEEP_OPEN_PR")
check("idle 120 d unmerged -> ARCHIVE", act(days=120) == "ARCHIVE_AND_DELETE")
check("idle 45 d -> ASK_OWNER", act(days=45) == "ASK_OWNER")
check("idle 5 d -> KEEP_ACTIVE", act(days=5) == "KEEP_ACTIVE")
check("closed PR idle 100 d -> ARCHIVE", act(pr="closed", days=100) == "ARCHIVE_AND_DELETE")
def codes(rows, **kw):
return {f["code"] for f in audit(rows, today, **kw)["findings"]}
check("case collision HIGH", "CASE_COLLISION" in codes([b("feature/Login", 3), b("feature/login", 3)]))
check("stale PR MEDIUM", "PR_STALE" in codes([b("feature/a", 100, pr="open")]))
check("far behind MEDIUM", "FAR_BEHIND" in codes([b("feature/a", 10, behind=900)]))
check("merged far behind not flagged", "FAR_BEHIND" not in codes([b("feature/a", 10, behind=900, merged=True)]))
check("long lived LOW", "LONG_LIVED" in codes([b("feature/a", 40, ahead=80)]))
check("old release LOW", "OLD_PROTECTED" in codes([b("release/1.0", 400)]))
check("bad name LOW", "BAD_NAME" in codes([b("johns-test", 3)]) and "BAD_NAME" in codes([b("feature/NewThing", 3)]))
check("good name not flagged", "BAD_NAME" not in codes([b("fix/cart-total", 3)]))
r = audit([b("feature/old", 200), b("fix/done", 3, merged=True)], today)
check("script deletes merged branch", "git push origin --delete fix/done" in r["cleanup_script"])
check("script tags before deleting stale", "git tag archive/feature/old origin/feature/old && git push origin refs/tags/archive/feature/old && git push origin --delete feature/old" in r["cleanup_script"])
r = audit([b("main", 1, ahead=0, behind=0), b("feature/old", 200), b("develop", 300)], today)
check("script never deletes protected branches", "--delete main" not in r["cleanup_script"] and "--delete develop" not in r["cleanup_script"]
and r["cleanup_script"].rstrip().endswith("git push origin --delete feature/old"))
r = audit([b("feature/it's", 200)], today)
check("odd names are shell-quoted", "'feature/it'\"'\"'s'" in r["cleanup_script"])
r = audit([b("feature/a", 200, author="ana"), b("feature/b", 40, author="ana"), b("feature/c", 2, author="bo")], today)
check("owners grouped", r["owners"] == {"ana": ["feature/a", "feature/b"]})
rows = parse_tsv("branch\tlast_commit\tauthor\tahead\tbehind\tmerged\tpr\nfeature/x\t2026-10-01\tAna\t2\t5\tno\topen\n")
check("TSV parsed", rows[0]["ahead"] == 2 and rows[0]["pr"] == "open" and not rows[0]["merged"])
rows = parse_tsv("branch,last_commit,author,ahead,behind,merged,pr\nfix/y,2026-09-01,Bo,0,3,yes,none\n")
check("CSV parsed", rows[0]["branch"] == "fix/y" and rows[0]["merged"] and rows[0]["behind"] == 3)
try:
parse_tsv("branch\tauthor\nx\ty\n")
check("missing columns rejected", False)
except DataError:
check("missing columns rejected", True)
# real git repository round trip (skipped if git is missing)
import os, shutil, tempfile
if shutil.which("git"):
d = tempfile.mkdtemp()
try:
env = {**os.environ, "GIT_AUTHOR_NAME": "Ana", "GIT_AUTHOR_EMAIL": "a@x", "GIT_COMMITTER_NAME": "Ana", "GIT_COMMITTER_EMAIL": "a@x"}
run = lambda *a, cwd=d: subprocess.run(["git", *a], cwd=cwd, env=env, capture_output=True, check=True)
os.makedirs(f"{d}/remote.git"); run("init", "-q", "--bare", "-b", "main", cwd=f"{d}/remote.git")
run("clone", "-q", f"{d}/remote.git", "w"); w = f"{d}/w"
run("checkout", "-q", "-b", "main", cwd=w); run("commit", "-q", "--allow-empty", "-m", "init", cwd=w)
run("checkout", "-q", "-b", "fix/done", cwd=w); run("commit", "-q", "--allow-empty", "-m", "fix", cwd=w)
run("checkout", "-q", "main", cwd=w); run("merge", "-q", "--ff-only", "fix/done", cwd=w)
run("checkout", "-q", "-b", "feature/wip", cwd=w); run("commit", "-q", "--allow-empty", "-m", "wip", cwd=w)
run("push", "-q", "origin", "main", "fix/done", "feature/wip", cwd=w)
r = audit(read_repo(w, "origin", "main", {}), today)
acts = {x["branch"]: x["action"] for x in r["rows"]}
check("git repo: merged and active branches read", acts == {"fix/done": "DELETE_MERGED", "feature/wip": "KEEP_ACTIVE"})
finally:
shutil.rmtree(d, ignore_errors=True)
print(f"{ok}/{len(tests)} passed")
return 0 if ok == len(tests) else 1
def main(): ap = argparse.ArgumentParser(description="Stale Git branch cleanup auditor") ap.add_argument("tsv", nargs="?", help="branch TSV or CSV export") ap.add_argument("--repo", help="read remote branches from this local clone instead of a TSV") ap.add_argument("--prs", help="TSV 'branchstate' with PR states (open/merged/closed), used with --repo") ap.add_argument("--remote", default="origin") ap.add_argument("--base", default="main") ap.add_argument("--today", help="YYYY-MM-DD (default: today)") ap.add_argument("--stale-days", type=int, default=90) ap.add_argument("--ask-days", type=int, default=30) ap.add_argument("--behind-limit", type=int, default=300) ap.add_argument("--protect", default=DEFAULT_PROTECT) ap.add_argument("--prefixes", default=DEFAULT_PREFIXES) ap.add_argument("--json", action="store_true") ap.add_argument("--self-test", action="store_true") a = ap.parse_args() if a.self_test: return self_test() try: today = dt.date.fromisoformat(a.today) if a.today else dt.date.today() if a.repo: prs = {} if a.prs: with open(a.prs, encoding="utf-8") as fh: for line in fh: if "\t" in line: k, v = line.rstrip("\n").split("\t", 1) prs[k] = v.strip().lower() branches = read_repo(a.repo, a.remote, a.base, prs) elif a.tsv: with open(a.tsv, encoding="utf-8") as fh: branches = parse_tsv(fh.read()) else: ap.error("give a TSV/CSV file or --repo PATH") r = audit(branches, today, a.base, a.remote, a.stale_days, a.ask_days, a.behind_limit, a.protect, a.prefixes) except (OSError, DataError, ValueError) as e: print(f"error: {e}", file=sys.stderr) return 2 print(json.dumps(r, indent=2) if a.json else render(r), end="\n" if a.json else "") return 1 if any(f["severity"] == "HIGH" for f in r["findings"]) else 0
if name == "main": sys.exit(main())
