fix: never strand a merged branch when the post-merge restack fails - #143
Conversation
`ez merge` removed the branch from stack.json and saved, then restacked the rest of the stack, then cleaned up the merged branch — with the restack behind a `?`. A restack failure therefore skipped cleanup after the entry was already gone, leaving a branch and worktree that no command can see: `ez sync` builds its cleanup candidates from `state.branches`, so an untracked branch is never a candidate. It reports "Everything is up to date" while the litter sits there. Two fixes: 1. Clean up before restacking, in both the sequential and native-stack paths. The branch is merged and its entry is already gone; whether some sibling rebases cleanly has no bearing on deleting it. 2. Give `ez sync` a safety net for orphans that already exist, or that some future ordering bug creates. `prune_orphaned_ez_worktrees` removes the worktree, branch, and remote branch for untracked branches that are merged. The safety net is deliberately narrow: only worktrees under `.worktrees/`, which ez creates and owns. External worktrees and plain local branches are left alone however merged they look, and an orphan that is not provably merged is reported with a `ez track` hint rather than deleted — an orphan with unmerged commits is lost work, not litter.
Review findings on the orphan-cleanup path added in the previous commit.
An orphan has no recorded PR number, so the only way to find its PR is
`get_pr_statuses_for`, which queries `headRefName` and takes the most recently
created match. That resolves by branch *name*, not by head SHA or head repo.
Trusting its `merged` flag alone meant a recycled branch name — or, in a fork
workflow, another contributor's merged `feat/login` — authorized deleting a
worktree and force-deleting a branch that still held unpushed commits. The
tracked cleanup path avoids this by looking PRs up by number; that is not
available here, so git is the corroboration instead: the tip must already be
in trunk, or the branch's diff against trunk must be empty (squash merge).
Also from review:
- Stop deleting the remote branch. The evidence is about the local tip and
says nothing about commits pushed from elsewhere, and sync's tracked cleanup
does not delete remote refs either — only `ez delete` and `ez merge` do.
- Anchor worktree ownership to `{main_root}/.worktrees/` instead of a bare
`contains("/.worktrees/")`, which claimed every sibling worktree when the
repo itself lives under a `.worktrees/` directory.
- Move out of the worktree before removing it when it contains the cwd, as the
tracked loop already does; otherwise the rest of the sync runs from a
deleted directory.
- Re-read `git worktree list` inside the prune rather than reusing the map
captured before the cleanup loop. A branch cleaned by the tracked pass is
out of `state.branches` and its worktree is gone, so the stale entry matched
every orphan rule and produced a spurious warning on the happy path.
Review pass appliedRan a multi-lens review over the diff. The merge reordering came back clean — verified that deleting the merged branch cannot strand its children ( The new sync deletion path needed four fixes, now in Critical — name-matched PR authorized deletion. High — remote branch deleted on weaker evidence than the local one. Nothing verified Medium — unanchored ownership check. Medium — no chdir-out before removing the current worktree, which the tracked loop handles deliberately. Running Plus one I caught before dispatching: the prune reused the worktree map captured before the cleanup loop, so every normally-cleaned branch was re-examined as an orphan and warned about on the ordinary happy path. It now re-reads Not fixed, noted deliberately: 490 unit tests + all integration suites green, clippy clean. Four new tests pin the fixes. |
The bug
Both merges in this session left orphans behind —
fix/needs-restack-from-gitandfix/merge-restack-shared-enginestill had branches and worktrees after their PRs merged, andez syncreportedEverything is up to date.merge_branchran in this order (merge.rs:410-429):The metadata is deleted before the step that can fail, and cleanup sits behind that
?. So a restack failure produces a branch and worktree with no stack entry — andez syncbuilds its cleanup candidates fromstate.branches(sync.rs:453-467), so an untracked branch is never a candidate. The orphan is invisible to every command that could remove it.This is not specific to the stale-entry bug that triggered it. Post-#142, an ordinary rebase conflict in the post-merge restack reproduces it exactly.
Fixes
1. Clean up before restacking — sequential path and native-stack path both. The branch is merged and its entry is already gone; whether some sibling rebases cleanly has no bearing on deleting it. We're already back in the main root by that point, so the move is safe.
2. A safety net in sync for orphans that already exist or that a future ordering bug creates.
prune_orphaned_ez_worktreesremoves the worktree, local branch, and remote branch for untracked-but-merged branches, running after the tracked pass so nothing is considered twice.Scope is deliberately narrow, because this deletes branches ez isn't tracking:
.worktrees/, merged.worktrees/, not mergedez trackhint.worktrees/(Superconductor, manualgit worktree add).worktrees/is the proof that ez created it, so it is ez's litter to collect. Merge detection prefers the PR'smergedflag and falls back tois_ancestor— a closed-but-unmerged PR is not merged and keeps its branch.Tests
merged_branch_is_cleaned_up_even_when_the_post_merge_restack_fails— real repo, real remote, fake gh. A sibling branch is set up to conflict with the new trunk so the post-merge restack genuinely fails; asserts the call still errors and that the merged branch's worktree, local branch, and stack entry are all gone.orphaned_ez_worktrees_only_claims_untracked_branches_in_ez_owned_worktrees— pins the safety scope table above.487 unit tests + all integration suites green, clippy clean.