Skip to content

fix(worktrees): cleanup script could never reclaim a squash-merged branch - #524

Merged
runyourempire merged 2 commits into
mainfrom
worktree-fix-worktree-cleanup-squash
Aug 24, 2026
Merged

fix(worktrees): cleanup script could never reclaim a squash-merged branch#524
runyourempire merged 2 commits into
mainfrom
worktree-fix-worktree-cleanup-squash

Conversation

@runyourempire

Copy link
Copy Markdown
Collaborator

The defect

scripts/cleanup-orphaned-worktrees.cjs gated safety on isReachableFromMain():

return tip === mergeBase;   // pure git ancestry

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 false for every successfully merged PR — the script
protected everything, forever, and could never reclaim anything.

Measured on this tree before the fix:

Worktree-* dirs:     19
Worktree-* branches: 47
  Dirs to remove:     0
  Branches to delete: 0

…while PRs #488, #493 and #427 are all MERGED and each was reported
as branch tip NOT reachable from main — has unique commits. That is why 44
registered worktrees accumulated.

The fix

A branch now counts as merged if either:

  1. its tip is reachable from main (unchanged), or
  2. a merged PR's headRefOid is exactly the branch tip.

One gh pr list --state merged --limit 500 call, cached in a Map.

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 --limit window simply is not found — also conservative.

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 reports which mode is active.

After

Merged PR heads:     346 (squash-merge detection ACTIVE)
  Dirs to remove:     8
  Branches to delete: 30

Safety verification

Every existing guard still fires:

Case Result
Open-PR branch (#523) protected — "not merged"
The branch making this change protected — "not merged"
audit-fixes-contention (6 modified files) protected — "uncommitted changes present"
phase0-mcp-honesty, phase1-* protected — "uncommitted changes present"

Dry-run only; nothing was executed. Reflog still preserves everything for 90 days.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WpriBKDhPio1LH9KFuXmwp

runyourempire and others added 2 commits August 24, 2026 18:54
…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
runyourempire merged commit 0a354c5 into main Aug 24, 2026
10 checks passed
@runyourempire
runyourempire deleted the worktree-fix-worktree-cleanup-squash branch August 24, 2026 15:56
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant