Skip to content
Merged
Show file tree
Hide file tree
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
2 changes: 2 additions & 0 deletions .github/workflows/branch-hygiene.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ github.token }}
BRANCH_HYGIENE_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
BRANCH_HYGIENE_ARCHIVE_REF: archive/legacy-branches-2026-09-21
BRANCH_HYGIENE_DRY_RUN: "1"
run: node scripts/branch-hygiene.mjs

Expand All @@ -56,4 +57,5 @@ jobs:
env:
GITHUB_TOKEN: ${{ github.token }}
BRANCH_HYGIENE_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
BRANCH_HYGIENE_ARCHIVE_REF: archive/legacy-branches-2026-09-21
run: node scripts/branch-hygiene.mjs
212 changes: 178 additions & 34 deletions scripts/branch-hygiene.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ function sameRepositoryHead(pull, repository) {
return pull?.head?.repo?.full_name === repository
}

function encodeRef(name) {
return String(name || "").split("/").map(encodeURIComponent).join("/")
}

function validSha(value) {
return /^[0-9a-f]{40}$/i.test(String(value || ""))
}

export function planBranchCleanup({ repository, defaultBranch, branches, openPulls, closedPulls }) {
const openHeads = new Set(
openPulls
Expand Down Expand Up @@ -49,7 +57,76 @@ export function planBranchCleanup({ repository, defaultBranch, branches, openPul
retained.push({ name, reason: "no-exact-merged-head" })
continue
}
candidates.push({ name, sha, pull: merged.number })
candidates.push({ name, sha, pull: merged.number, source: "merged-pr" })
}

candidates.sort((a, b) => a.name.localeCompare(b.name))
retained.sort((a, b) => a.name.localeCompare(b.name))
return { candidates, retained }
}

export function planArchivedCleanup({
repository,
defaultBranch,
archiveRef,
manifest,
branches,
openPulls,
}) {
if (!manifest || typeof manifest !== "object") throw new Error("archive manifest must be an object")
if (manifest.repository !== repository) throw new Error(`archive manifest repository mismatch: ${manifest.repository}`)
if (manifest.archiveBranch !== archiveRef) throw new Error(`archive manifest branch mismatch: ${manifest.archiveBranch}`)
if (!Array.isArray(manifest.archivedBranches)) throw new Error("archive manifest archivedBranches must be an array")

const branchByName = new Map(
branches
.map((branch) => [String(branch?.name || "").trim(), branch])
.filter(([name]) => Boolean(name)),
)
const openHeads = new Set(
openPulls
.filter((pull) => sameRepositoryHead(pull, repository))
.map((pull) => String(pull?.head?.ref || "").trim())
.filter(Boolean),
)

const seenNames = new Set()
const candidates = []
const retained = []

for (const entry of manifest.archivedBranches) {
const name = String(entry?.name || "").trim()
const sha = String(entry?.sha || "").trim()
if (!name || !validSha(sha)) throw new Error(`invalid archived branch entry: ${JSON.stringify(entry)}`)
if (seenNames.has(name)) throw new Error(`duplicate archived branch entry: ${name}`)
seenNames.add(name)

if (name === defaultBranch || name === archiveRef) {
retained.push({ name, reason: "reserved" })
continue
}

const branch = branchByName.get(name)
if (!branch) {
retained.push({ name, reason: "already-absent" })
continue
}
if (branch?.protected === true) {
retained.push({ name, reason: "protected" })
continue
}
if (openHeads.has(name)) {
retained.push({ name, reason: "open-pr" })
continue
}

const currentSha = String(branch?.commit?.sha || "").trim()
if (currentSha !== sha) {
retained.push({ name, reason: "head-changed", expectedSha: sha, currentSha })
continue
}

candidates.push({ name, sha, source: "archive-manifest" })
}

candidates.sort((a, b) => a.name.localeCompare(b.name))
Expand Down Expand Up @@ -91,11 +168,83 @@ async function paginate(client, pathname, query = {}) {
throw new Error(`Pagination safety limit exceeded for ${pathname}`)
}

async function loadArchive(client, root, repository, archiveRef) {
if (!archiveRef) return null

const archivePath = "archive/legacy-branches-2026-09-21.json"
const encodedRef = encodeURIComponent(archiveRef)
const payload = await request(client, "GET", `${root}/contents/${archivePath}?ref=${encodedRef}`)
if (payload?.encoding !== "base64" || typeof payload?.content !== "string") {
throw new Error("archive manifest content is not base64")
}

const raw = Buffer.from(payload.content.replace(/\s+/g, ""), "base64").toString("utf8")
const manifest = JSON.parse(raw)
if (manifest.repository !== repository) throw new Error("archive manifest repository does not match current repository")
if (manifest.archiveBranch !== archiveRef) throw new Error("archive manifest archiveBranch does not match configured ref")

const ref = await request(client, "GET", `${root}/git/ref/heads/${encodeRef(archiveRef)}`)
const archiveSha = String(ref?.object?.sha || "").trim()
if (!validSha(archiveSha)) throw new Error("archive branch did not resolve to a commit SHA")

return { manifest, archiveSha, archivePath }
}

async function verifyArchivedReachability(client, root, archiveSha, items) {
const verified = []
for (const item of items) {
const compare = await request(client, "GET", `${root}/compare/${item.sha}...${archiveSha}`)
if (!["ahead", "identical"].includes(compare?.status)) {
throw new Error(`archive ref does not retain ${item.name} @ ${item.sha}; compare status=${compare?.status || "unknown"}`)
}
verified.push(item)
}
return verified
}

async function deleteCandidates(client, root, items, dryRun, label) {
const deleted = []
const skipped = []
const failures = []

for (const item of items) {
console.log(`${dryRun ? "DRY-RUN" : "DELETE"} [${label}] ${item.name} @ ${item.sha}`)
if (dryRun) continue

try {
const encodedRef = encodeRef(item.name)
const refPath = `${root}/git/ref/heads/${encodedRef}`
const current = await request(client, "GET", refPath)
const currentSha = String(current?.object?.sha || "").trim()
if (currentSha !== item.sha) {
console.log(`SKIP [${label}] ${item.name}: head changed from ${item.sha} to ${currentSha || "unknown"}`)
skipped.push({ ...item, reason: "head-changed-at-delete", currentSha })
continue
}
await request(client, "DELETE", `${root}/git/refs/heads/${encodedRef}`)
deleted.push(item)
} catch (error) {
failures.push({ item, error: error instanceof Error ? error.message : String(error) })
}
}

return { deleted, skipped, failures }
}

function countReasons(items) {
return Object.fromEntries(
[...new Set(items.map((item) => item.reason))]
.sort()
.map((reason) => [reason, items.filter((item) => item.reason === reason).length]),
)
}

async function main() {
const repository = String(process.env.GITHUB_REPOSITORY || "").trim()
const token = String(process.env.GITHUB_TOKEN || "").trim()
const api = String(process.env.GITHUB_API_URL || "https://api.github.com").replace(/\/$/, "")
const defaultBranch = String(process.env.BRANCH_HYGIENE_DEFAULT_BRANCH || "main").trim()
const archiveRef = String(process.env.BRANCH_HYGIENE_ARCHIVE_REF || "").trim()
const dryRun = process.env.BRANCH_HYGIENE_DRY_RUN === "1"

if (!/^[^/]+\/[^/]+$/.test(repository)) throw new Error("GITHUB_REPOSITORY must be owner/name")
Expand All @@ -110,58 +259,53 @@ async function main() {
paginate(client, `${root}/pulls`, { state: "closed" }),
])

const { candidates, retained } = planBranchCleanup({
const mergedPlan = planBranchCleanup({
repository,
defaultBranch,
branches,
openPulls,
closedPulls,
})

console.log(`Branch hygiene: ${branches.length} branches, ${candidates.length} exact merged-head candidates, ${retained.length} retained.`)
for (const item of candidates) {
console.log(`${dryRun ? "DRY-RUN" : "DELETE"} ${item.name} @ ${item.sha} (merged PR #${item.pull})`)
let archivePlan = { candidates: [], retained: [] }
let archiveSha = ""
if (archiveRef) {
const archive = await loadArchive(client, root, repository, archiveRef)
archiveSha = archive.archiveSha
archivePlan = planArchivedCleanup({
repository,
defaultBranch,
archiveRef,
manifest: archive.manifest,
branches,
openPulls,
})
await verifyArchivedReachability(client, root, archiveSha, archivePlan.candidates)
}

const deleted = []
const failures = []
if (!dryRun) {
for (const item of candidates) {
try {
const encodedRef = item.name.split("/").map(encodeURIComponent).join("/")
const refPath = `${root}/git/ref/heads/${encodedRef}`
const current = await request(client, "GET", refPath)
const currentSha = String(current?.object?.sha || "").trim()
if (currentSha !== item.sha) {
console.log(`SKIP ${item.name}: head changed from ${item.sha} to ${currentSha || "unknown"}`)
continue
}
await request(client, "DELETE", `${root}/git/refs/heads/${encodedRef}`)
deleted.push(item)
} catch (error) {
failures.push({ item, error: error instanceof Error ? error.message : String(error) })
}
}
}
const mergedResult = await deleteCandidates(client, root, mergedPlan.candidates, dryRun, "merged-pr")

const retainedCounts = Object.fromEntries(
[...new Set(retained.map((item) => item.reason))]
.sort()
.map((reason) => [reason, retained.filter((item) => item.reason === reason).length]),
)
const mergedNames = new Set(mergedPlan.candidates.map((item) => item.name))
const archiveCandidates = archivePlan.candidates.filter((item) => !mergedNames.has(item.name))
const archiveResult = await deleteCandidates(client, root, archiveCandidates, dryRun, "archive")

const failures = [...mergedResult.failures, ...archiveResult.failures]
const summary = [
"## Branch hygiene",
"",
`- Repository: \`${repository}\``,
`- Default branch: \`${defaultBranch}\``,
`- Branches scanned: ${branches.length}`,
`- Exact merged-head candidates: ${candidates.length}`,
`- Deleted: ${dryRun ? 0 : deleted.length}`,
`- Exact merged-head candidates: ${mergedPlan.candidates.length}`,
`- Archived inactive candidates: ${archiveCandidates.length}`,
`- Archive ref: \`${archiveRef || "disabled"}\``,
`- Archive SHA: \`${archiveSha || "n/a"}\``,
`- Deleted: ${dryRun ? 0 : mergedResult.deleted.length + archiveResult.deleted.length}`,
`- Dry run: ${dryRun}`,
`- Retained by reason: \`${JSON.stringify(retainedCounts)}\``,
`- Retained merged-plan reasons: \`${JSON.stringify(countReasons(mergedPlan.retained))}\``,
`- Retained archive-plan reasons: \`${JSON.stringify(countReasons(archivePlan.retained))}\``,
"",
"Safety rule: delete only a non-default, unprotected, same-repository branch with no open PR when a merged PR recorded exactly the branch's current head SHA.",
"Safety rules: merged branches require an exact merged-PR head SHA. Archived branches additionally require an unchanged current SHA, no open PR, and proof that the archived tip is an ancestor of the configured archive ref before deletion.",
].join("\n")

if (process.env.GITHUB_STEP_SUMMARY) await appendFile(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`, "utf8")
Expand Down
Loading