feat(worktree): nightly worktree prune — the counterpart create never had - #40
Conversation
…er had Nightly made a worktree per task and never removed one. A real repo held five stale checkouts at ~93MB each, the oldest two months old, every one of them fully merged. Nothing surfaced them: `worktree list` reports them as ordinary state, and `doctor` never looked. `remove_worktree` already existed, but it is a driver primitive — it forces, and it deletes the branch with `-D`. That is the right shape for a driver that already knows the task is finished and the wrong shape for an operator cleaning up by hand. So the safety judgement lives in a new `assess_worktree`, separate from the removal, and the rule is that a worktree is disposable only when losing it cannot lose work: clean tree, and no commits the base branch does not already have. The failure directions are not symmetric — an over-eager prune destroys work, a timid one wastes disk — so every ambiguity resolves toward keep. A git invocation that *fails* is a blocker rather than a shrug, because "I could not tell" and "nothing to lose" must never collapse into the same answer. A zero exit with non-numeric stdout is likewise a blocker, not a parse of zero. Branches are deleted alongside the worktrees they belonged to; leaving them would trade stale worktrees for stale branches. `--force` removes blocked worktrees but deliberately keeps the branch of any that holds unmerged commits, so the history stays reachable. `--force` does not override the current-worktree guard: that is impossibility, not caution. Verified end-to-end against a real repo with three worktrees (merged, unmerged, dirty) — the dry run touched nothing, the plain prune took only the merged one and its branch, and after `--force` the unmerged commit was still reachable through its surviving branch. Mutation-verified: dropping the uncommitted-changes blocker, treating an unreadable `rev-list` as zero, and letting `--force` past the current-worktree guard each fail the suite. 1557 tests pass (18 new); `nightly verify` clean on all five checks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Warning Review limit reached
Next review available in: 6 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (4)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Shipped an hour ago, and its first real run refused to clean up its own worktree: PR #40 was squash-merged, so the branch's commit is not an ancestor of `main` and the strict ancestry check read it as unfinished work. In a repo that squash-merges every PR — this one — that is *every* worktree, which is exactly the accumulation the command exists to stop. A squash merge lands the branch's content on the base as one new commit, so the originals never become ancestors of it. Ancestry cannot see the merge; the forge can. `%(upstream:track)` renders `[gone]` for a branch that was pushed and then had its remote ref deleted, which is what a merged PR leaves behind. That evidence is weaker than ancestry, so it buys less. It clears the *worktree* only: the branch is not marked spent, the ref survives, and the commits stay reachable. If the PR was closed rather than merged, nothing is lost but disk — which was the whole complaint. The distinction is now explicit rather than implied. `disposable` governs the directory, `branch_is_spent` governs the ref, and only ancestry sets the latter. An empty `%(upstream:track)` — a branch that never left the machine — is absence of evidence, not evidence of a merge, and still blocks. Verified end-to-end against a real squash-merge: a branch pushed to a remote, squash-merged to main, and its remote ref deleted. The worktree was removed, the branch preserved, and `git log nightly/squashed` still resolved afterward. 1563 tests pass (6 new); `nightly verify` clean on all five checks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Shipped an hour ago, and its first real run refused to clean up its own worktree: PR #40 was squash-merged, so the branch's commit is not an ancestor of `main` and the strict ancestry check read it as unfinished work. In a repo that squash-merges every PR — this one — that is *every* worktree, which is exactly the accumulation the command exists to stop. A squash merge lands the branch's content on the base as one new commit, so the originals never become ancestors of it. Ancestry cannot see the merge; the forge can. `%(upstream:track)` renders `[gone]` for a branch that was pushed and then had its remote ref deleted, which is what a merged PR leaves behind. That evidence is weaker than ancestry, so it buys less. It clears the *worktree* only: the branch is not marked spent, the ref survives, and the commits stay reachable. If the PR was closed rather than merged, nothing is lost but disk — which was the whole complaint. The distinction is now explicit rather than implied. `disposable` governs the directory, `branch_is_spent` governs the ref, and only ancestry sets the latter. An empty `%(upstream:track)` — a branch that never left the machine — is absence of evidence, not evidence of a merge, and still blocks. Verified end-to-end against a real squash-merge: a branch pushed to a remote, squash-merged to main, and its remote ref deleted. The worktree was removed, the branch preserved, and `git log nightly/squashed` still resolved afterward. 1563 tests pass (6 new); `nightly verify` clean on all five checks. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
worktree createshipped without a counterpart. Nightly made a worktree per task and never removed one, so they accumulated silently — a real repo held five stale checkouts at ~93MB each, the oldest two months old, every one of them fully merged. Nothing surfaced them:worktree listreports them as ordinary state, anddoctornever looked.Why this isn't just a wrapper around
remove_worktreeThat primitive already exists, but it forces and deletes the branch with
-D. That is the right shape for a driver that already knows the task is finished, and the wrong shape for an operator cleaning up by hand.So the safety judgement lives in a new
assess_worktree, separate from the removal. A worktree is disposable only when losing it cannot lose work: clean tree, and no commits--basedoes not already have.The failure directions are not symmetric. An over-eager prune destroys work; a timid one wastes disk. Only the first is unrecoverable, so every ambiguity resolves toward keep:
--forcedoes not override that — it is impossibility, not caution.Branches are deleted alongside the worktrees they belonged to, since leaving them would trade stale worktrees for stale branches.
--forceremoves blocked worktrees but keeps the branch of any holding unmerged commits, so that history stays reachable.Verification
Verified end-to-end against a real repo with three worktrees — merged, unmerged, and dirty:
The dry run touched nothing. The plain prune took only the merged one and its branch. After
--force, the unmerged commit was still reachable through its surviving branch.Mutation-verified: dropping the uncommitted-changes blocker, treating an unreadable
rev-listas zero, and letting--forcepast the current-worktree guard each fail the suite.1557 tests pass (18 new).
nightly verifyclean on all five checks.🤖 Generated with Claude Code