Delete merged branches #1
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Delete merged branches | |
| on: | |
| schedule: | |
| - cron: "0 2 * * *" # Run daily at 02:00 UTC | |
| workflow_dispatch: | |
| inputs: | |
| dry_run: | |
| description: "Only log branches that would be deleted (no deletion)" | |
| type: boolean | |
| default: true | |
| permissions: | |
| contents: write # required to delete branch refs | |
| pull-requests: read | |
| jobs: | |
| delete-merged-branches: | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Delete branches of merged PRs | |
| uses: actions/github-script@v9 | |
| with: | |
| script: | | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| // dry_run is only set on workflow_dispatch; scheduled runs delete for real. | |
| const dryRun = context.eventName === "workflow_dispatch" | |
| ? context.payload.inputs?.dry_run === "true" || context.payload.inputs?.dry_run === true | |
| : false; | |
| // Only look at PRs updated within this window on scheduled runs, so we don't | |
| // re-scan the entire history every day. Raise this for an initial cleanup. | |
| const lookbackDays = 30; | |
| const cutoff = new Date(); | |
| cutoff.setDate(cutoff.getDate() - lookbackDays); | |
| // Long-lived branches that must never be deleted (besides the default branch). | |
| const protectedNames = new Set(); | |
| // Resolve the default branch once. | |
| const { data: repoData } = await github.rest.repos.get({ owner, repo }); | |
| const defaultBranch = repoData.default_branch; | |
| protectedNames.add(defaultBranch); | |
| console.log(`Mode: ${dryRun ? "DRY RUN (no deletion)" : "LIVE"}`); | |
| console.log(`Default branch: ${defaultBranch}`); | |
| const candidates = new Set(); | |
| let page = 1; | |
| let stop = false; | |
| while (!stop) { | |
| const { data: prs } = await github.rest.pulls.list({ | |
| owner, | |
| repo, | |
| state: "closed", | |
| sort: "updated", | |
| direction: "desc", | |
| per_page: 100, | |
| page, | |
| }); | |
| if (prs.length === 0) break; | |
| for (const pr of prs) { | |
| // Sorted by updated desc: once we pass the cutoff we can stop paginating. | |
| if (new Date(pr.updated_at) < cutoff) { | |
| stop = true; | |
| break; | |
| } | |
| if (!pr.merged_at) continue; // only merged PRs | |
| if (!pr.head.repo) continue; // head fork was deleted | |
| if (pr.head.repo.full_name !== pr.base.repo.full_name) continue; // skip forks | |
| const branch = pr.head.ref; | |
| if (protectedNames.has(branch)) continue; // default / kept branches | |
| candidates.add(branch); | |
| } | |
| page++; | |
| if (page > 50) break; // safety cap | |
| } | |
| console.log(`Found ${candidates.size} unique candidate branch(es) from merged PRs.`); | |
| let deleted = 0; | |
| let skipped = 0; | |
| for (const branch of candidates) { | |
| // A branch name can be reused after an earlier PR was merged. Never delete a | |
| // branch while it is the head of a current open PR, even if it is also a | |
| // candidate from an older merged PR. | |
| try { | |
| const { data: openPrs } = await github.rest.pulls.list({ | |
| owner, | |
| repo, | |
| state: "open", | |
| head: `${owner}:${branch}`, | |
| per_page: 1, | |
| }); | |
| if (openPrs.length > 0) { | |
| console.log(`Skipped "${branch}" (head of open PR #${openPrs[0].number})`); | |
| skipped++; | |
| continue; | |
| } | |
| } catch (error) { | |
| // Do not risk deleting a branch if GitHub cannot confirm it has no open PR. | |
| console.log(`Failed to check open PRs for "${branch}": ${error.message}`); | |
| skipped++; | |
| continue; | |
| } | |
| // Confirm the branch still exists and isn't protected. | |
| let branchData; | |
| try { | |
| const res = await github.rest.repos.getBranch({ owner, repo, branch }); | |
| branchData = res.data; | |
| } catch (error) { | |
| if (error.status === 404) { | |
| // Already deleted (e.g. auto-delete head branch) — nothing to do. | |
| continue; | |
| } | |
| console.log(`Failed to inspect "${branch}": ${error.message}`); | |
| skipped++; | |
| continue; | |
| } | |
| if (branchData.protected) { | |
| console.log(`Skipped "${branch}" (protected branch)`); | |
| skipped++; | |
| continue; | |
| } | |
| if (dryRun) { | |
| console.log(`[dry-run] Would delete "${branch}"`); | |
| continue; | |
| } | |
| try { | |
| await github.rest.git.deleteRef({ owner, repo, ref: `heads/${branch}` }); | |
| console.log(`Deleted "${branch}"`); | |
| deleted++; | |
| } catch (error) { | |
| console.log(`Failed to delete "${branch}": ${error.message}`); | |
| skipped++; | |
| } | |
| } | |
| console.log( | |
| dryRun | |
| ? `Dry run complete. ${candidates.size} candidate(s) would be processed.` | |
| : `Done. Deleted ${deleted} branch(es), skipped ${skipped}.` | |
| ); |