From 5490cf0a9c979a7bfeb66f9d0d564e94cc38c478 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 28 May 2026 23:32:56 +0000 Subject: [PATCH] chore: add scheduled workflow to prune merged and stale remote branches Co-authored-by: shanselman <2892+shanselman@users.noreply.github.com> --- .github/workflows/prune-branches.yml | 136 +++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 .github/workflows/prune-branches.yml diff --git a/.github/workflows/prune-branches.yml b/.github/workflows/prune-branches.yml new file mode 100644 index 00000000..85f0cbf1 --- /dev/null +++ b/.github/workflows/prune-branches.yml @@ -0,0 +1,136 @@ +name: Prune stale branches + +on: + schedule: + - cron: "42 4 * * 1" # every Monday at 04:42 UTC + workflow_dispatch: + +permissions: {} + +jobs: + prune: + permissions: + contents: write + runs-on: ubuntu-latest + steps: + - name: Delete merged and stale remote branches + uses: actions/github-script@v9 + with: + github-token: ${{ github.token }} + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const protectedBranches = new Set(["master", "main", "release"]); + const staleDays = 60; + const cutoff = new Date(Date.now() - staleDays * 24 * 60 * 60 * 1000); + + // Get the default branch SHA for merge detection + const { data: repoData } = await github.rest.repos.get({ owner, repo }); + const defaultBranch = repoData.default_branch; + const { data: defaultRef } = await github.rest.git.getRef({ + owner, + repo, + ref: `heads/${defaultBranch}`, + }); + const defaultSha = defaultRef.object.sha; + + // List all branches (paginated) + let branches = []; + let page = 1; + while (true) { + const { data } = await github.rest.repos.listBranches({ + owner, + repo, + per_page: 100, + page, + }); + if (data.length === 0) break; + branches = branches.concat(data); + page++; + } + + let deletedMerged = 0; + let deletedStale = 0; + let skipped = 0; + + for (const branch of branches) { + const name = branch.name; + + // Never delete protected branches + if (protectedBranches.has(name) || branch.protected) { + continue; + } + + // Never delete the memory branch used by repo-assist + if (name.startsWith("memory/")) { + continue; + } + + // Check if merged into default branch + try { + const { status } = await github.rest.repos.compareCommits({ + owner, + repo, + base: defaultBranch, + head: name, + }); + // 'identical' or 'behind' means fully merged + if (status === "identical" || status === "behind") { + core.info(`Deleting merged branch: ${name}`); + await github.rest.git.deleteRef({ + owner, + repo, + ref: `heads/${name}`, + }); + deletedMerged++; + continue; + } + } catch (e) { + // compare can 404 for diverged histories; fall through to staleness check + } + + // Check if branch has an open PR — skip if so + const { data: prs } = await github.rest.pulls.list({ + owner, + repo, + head: `${owner}:${name}`, + state: "open", + per_page: 1, + }); + if (prs.length > 0) { + skipped++; + continue; + } + + // Check staleness by last commit date + try { + const { data: branchData } = await github.rest.repos.getBranch({ + owner, + repo, + branch: name, + }); + const lastCommitDate = new Date( + branchData.commit.commit.committer.date + ); + if (lastCommitDate < cutoff) { + core.info( + `Deleting stale branch (${staleDays}+ days): ${name} (last commit: ${lastCommitDate.toISOString()})` + ); + await github.rest.git.deleteRef({ + owner, + repo, + ref: `heads/${name}`, + }); + deletedStale++; + } else { + skipped++; + } + } catch (e) { + core.warning(`Error checking branch ${name}: ${e.message}`); + skipped++; + } + } + + core.info( + `Done. Deleted ${deletedMerged} merged + ${deletedStale} stale branches. Skipped ${skipped}.` + );