fix(worktrees): cleanup script could never reclaim a squash-merged branch - #524
Merged
Merged
Conversation
…anch cleanup-orphaned-worktrees.cjs gated safety on `isReachableFromMain()` — tip === merge-base, i.e. pure git ancestry. This repo merges by SQUASH, which replays the branch as a NEW commit on main, so a merged branch tip is never an ancestor of main. The gate was therefore permanently false for every successfully merged PR, and the script protected everything forever. Measured on this tree before the fix: 44 registered worktrees, 46 worktree-* branches, plan = "0 dirs, 0 branches". PRs #488, #493 and #427 are all MERGED yet each was reported as "has unique commits". Add a second, equally strict criterion: a branch is also merged when a MERGED PR's headRefOid is EXACTLY the branch tip (one `gh pr list` call, cached in a Map). Requiring OID equality is what keeps this as safe as ancestry — a branch that received commits after its PR merged does not match and stays protected, and a branch older than the --limit window simply is not found. Degrades safely: if `gh` is missing, unauthenticated, or returns junk, getMergedPrHeads() returns null and the script falls back to ancestry-only — never less safe than before. The banner says which mode is active. After: 346 merged PR heads detected, plan = 8 dirs / 30 branches. Safety verified — still protected: - open-PR branch (#523) -> "not merged" - the branch doing this work -> "not merged" - audit-fixes-contention (6 dirty files) -> "uncommitted changes present" - phase0-mcp-honesty, phase1-* -> "uncommitted changes present" Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01WpriBKDhPio1LH9KFuXmwp
#525 landed the same class of fix in parallel (tree identity). Rather than replace it, this merge keeps BOTH and unifies them, because measurement shows they are complementary, not redundant: (a) ancestry — non-squash flows (b) tree identity [from #525] — offline, no API (c) merged-PR head OID [from #524] — exact, immune to tree drift Tree identity only matches when the branch was up to date with main at merge time. Today's strict "branches must be up to date" ruleset guarantees that, but older merges predate it — main had moved on, so the squash commit's tree differs from the branch tip's, and (b) cannot see them. They are still fully merged; the PR record proves it when the tree cannot. Measured on this tree (23 worktree dirs, 43 worktree-* branches): (a)+(b) -> 2 dirs (a)+(c) -> 8 dirs (a)+(b)+(c) -> 10 dirs / 25 branches Spot-checked the lanes only (c) reclaims — all MERGED with exact OID match: cloudflare-migration #357 learning-demotion-v19 #414 relay-entitlement-gate #465 agent-a01f9610ade32c536 #471 Safety is unchanged: every proof is content-level, anything unproven stays protected, uncommitted-changes still blocks, and (c) degrades to (a)+(b) when `gh` is unavailable. Verified still protected: all three in-flight lanes of this session, whose local tips differ from their merged heads. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01WpriBKDhPio1LH9KFuXmwp
runyourempire
added a commit
that referenced
this pull request
Aug 31, 2026
…-merge proof (d) Rebased 2026-08-31 onto the #524/#525 three-proof sweep logic that landed on main while this PR sat. The two changes reconcile instead of competing — main fixed the CRITERION (squash-aware proofs a/b/c), this PR fixes what main still got wrong and adds the proof the others cannot supply: 1. WRONG BASE — still live on main until this commit. Ancestry (a) and the tree-history window (b) were computed against the LOCAL `main` ref, which drifts (2026-08-12: 10 commits behind origin AND carrying an unpushed commit). Every proof now runs against `origin/main` via baseRef(), falling back to `main` only when no remote-tracking ref exists. 2. PROOF (d): CONTENT-IDENTICAL MERGE — `git merge-tree --write-tree` against the base tree. Catches lanes whose work reached main OUTSIDE their own PR (harvested/cherry-picked, e.g. worktree-audit-fixes-contention into 0b9129c1 via #414) that tree identity (b) and merged-PR heads (c) cannot see. Three-valued: a conflict or old git returns INCONCLUSIVE and the lane is KEPT — the test can only ever retire what it proves adds nothing. 3. Verdicts carry the WINNING PROOF (or the reason for keeping) into the report, so "NOT TOUCHING" explains itself instead of printing a flat boolean. SAFETY UNCHANGED OR STRONGER: deletion still requires --execute, dirty worktrees are still refused, every proof is content-level, and anything unproven stays protected. Verified on the live repo 2026-08-31: base resolves to origin/main, 387 merged-PR heads active, 3 worktrees + 9 branches proposed (tool no longer inert), conflicted comparisons KEPT as inconclusive by design. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01LrXvdHoDGUj99Fqf1fCYJY
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The defect
scripts/cleanup-orphaned-worktrees.cjsgated safety onisReachableFromMain():This repo merges by squash. A squash merge replays the branch as a new
commit on main, so a merged branch tip is never an ancestor of main. The gate
was therefore permanently
falsefor every successfully merged PR — the scriptprotected everything, forever, and could never reclaim anything.
Measured on this tree before the fix:
…while PRs #488, #493 and #427 are all
MERGEDand each was reportedas
branch tip NOT reachable from main — has unique commits. That is why 44registered worktrees accumulated.
The fix
A branch now counts as merged if either:
headRefOidis exactly the branch tip.One
gh pr list --state merged --limit 500call, cached in aMap.Requiring OID equality is what keeps this as strict as ancestry. A branch
that received commits after its PR merged will not match and stays protected.
A branch older than the
--limitwindow simply is not found — also conservative.Degrades safely: if
ghis missing, unauthenticated, or returns junk,getMergedPrHeads()returnsnulland the script falls back to ancestry-only —never less safe than before. The banner reports which mode is active.
After
Safety verification
Every existing guard still fires:
audit-fixes-contention(6 modified files)phase0-mcp-honesty,phase1-*Dry-run only; nothing was executed. Reflog still preserves everything for 90 days.
🤖 Generated with Claude Code
https://claude.ai/code/session_01WpriBKDhPio1LH9KFuXmwp