Skip to content
Merged
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
67 changes: 67 additions & 0 deletions .github/workflows/branch-cleanup.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
name: Branch Cleanup

on:
schedule:
- cron: '0 3 * * *' # daily at 03:00 UTC
workflow_dispatch: # allow manual trigger

jobs:
delete-merged-branches:
name: Delete merged branches
runs-on: ubuntu-latest
permissions:
contents: write

steps:
- name: Delete merged branches
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const protectedBranches = ['main', 'master', 'develop'];

// Resolve the repository's default branch dynamically
const { data: repo } = await github.rest.repos.get({
owner: context.repo.owner,
repo: context.repo.repo,
});
const defaultBranch = repo.default_branch;
core.info(`Default branch: ${defaultBranch}`);

// Paginate through all branches
const branches = await github.paginate(github.rest.repos.listBranches, {
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});

for (const branch of branches) {
if (protectedBranches.includes(branch.name)) {
core.info(`Skipping protected branch: ${branch.name}`);
continue;
Comment on lines +21 to +41

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The workflow can delete the repository’s default branch if it isn’t named one of main|master|develop. repos.listBranches includes the default branch, and comparing ${defaultBranch}...${branch.name} for the default branch yields ahead_by === 0, triggering deleteRef. Add an explicit guard to always skip branch.name === defaultBranch (or add defaultBranch to the protected list after resolving it).

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

}

// Check whether the branch has been fully merged into the default branch
let compare;
try {
({ data: compare } = await github.rest.repos.compareCommitsWithBasehead({
owner: context.repo.owner,
repo: context.repo.repo,
basehead: `${defaultBranch}...${branch.name}`,
}));
} catch (err) {
core.info(`Could not compare ${branch.name} – skipping (${err.message})`);
continue;
}

if (compare.ahead_by === 0) {
core.info(`Deleting merged branch: ${branch.name}`);
await github.rest.git.deleteRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: `heads/${branch.name}`,
});
Comment on lines +57 to +63

Copilot AI Mar 4, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Branch deletion errors aren’t caught. If git.deleteRef fails (e.g., branch protection rules, insufficient permissions, transient API errors), the whole job will fail mid-loop and leave remaining branches unprocessed. Wrap the delete call in a try/catch (similar to the compare call) and log+continue, and/or skip branches where branch.protected === true instead of relying only on a name allowlist.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback

} else {
core.info(`Branch ${branch.name} has ${compare.ahead_by} unmerged commit(s) – skipping`);
}
}