How can I find and delete stale branches in a GitHub repository using Python?
Quick Answer
Use the GitHub REST API via requests to paginate through all branches, fetch each branch's HEAD commit date, and flag branches older than a threshold that aren't in a protected list. Run in dry-run mode first, since branch deletion in Git is effectively irreversible unless someone has a local clone with that branch still checked out.
Detailed Answer
This is like a librarian periodically clearing out books from the "browsing table" that haven't been picked up in months, returning them to the shelves, except a few books on that table have a permanent "do not reshelve" sign on them, the protected branches, and the librarian double-checks the due date on every other book individually rather than assuming the whole table is fair game just because it looks cluttered.
This script exists because feature branches accumulate relentlessly in any actively developed repository — branches for abandoned experiments, branches merged via squash-merge which can leave the original branch technically "unmerged" from Git's perspective even though its changes already landed on main, and branches from contributors who left the team all pile up, making it hard to tell which branches represent real, active work versus digital clutter. Automating cleanup matters because manual branch hygiene never happens consistently — nobody wants to spend their afternoon guessing which of 400 branches are safe to delete.
The script authenticates with a GITHUB_TOKEN, paginates through the branches endpoint, again, pagination matters because GitHub caps each page at 100 branches and any repo with real history will exceed that, and for each non-protected branch, follows the branch's commit URL to fetch the actual commit object and read the committer's date — this is necessary because the branches endpoint itself doesn't directly expose a clean "last activity" timestamp without this follow-up call. Branches in the PROTECTED_BRANCHES set are skipped outright regardless of age, and the deletion step itself, a DELETE to the git refs endpoint, only executes when DRY_RUN is explicitly set to False.
In production, this typically runs as a scheduled GitHub Action with the token stored as a repository secret, generating a dry-run report for review before anyone flips it to actually delete branches. What matters operationally: rate limiting, since GitHub's REST API enforces request quotas, and a repository with thousands of branches making one extra API call per branch to fetch commit dates can approach those limits, so production versions often batch or cache commit lookups, and add explicit handling for rate-limit responses rather than letting the whole run fail partway through with no record of what was already checked.
The non-obvious gotcha is that "stale" as measured by commit date doesn't account for branches that are intentionally long-lived without frequent commits — a release branch that's cut once and then only merged from occasionally, or a long-running feature-flagged branch that's deliberately kept in sync but rarely committed to directly, can look identical to an abandoned branch by this metric alone. Teams that rely purely on commit age without also checking whether a branch has an open, active pull request end up deleting branches that were very much still in use, just not recently committed to — a mature version of this script cross-checks for open pull requests referencing the branch before ever considering it a deletion candidate.