Skip to content

feat(worktree): nightly worktree prune — the counterpart create never had - #40

Merged
ulmentflam merged 1 commit into
mainfrom
nightly/worktree-prune
Jul 28, 2026
Merged

feat(worktree): nightly worktree prune — the counterpart create never had#40
ulmentflam merged 1 commit into
mainfrom
nightly/worktree-prune

Conversation

@ulmentflam

Copy link
Copy Markdown
Owner

worktree create shipped 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 list reports them as ordinary state, and doctor never looked.

nightly worktree prune --dry-run   # what would go, and why the rest stays
nightly worktree prune             # remove them, and their branches

Why this isn't just a wrapper around remove_worktree

That 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 --base does 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:

  • A git invocation that fails is a blocker, not a shrug — "I could not tell" and "nothing to lose" must never collapse into the same answer.
  • A zero exit with non-numeric stdout is a blocker, not a parse of zero.
  • The worktree you are standing in is never removed, and --force does 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. --force removes 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:

✓ would remove nightly/merged
· kept nightly/dirty — 1 uncommitted change(s)
· kept nightly/unmerged — 1 commit(s) not in `main`

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-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.

🤖 Generated with Claude Code

…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>
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@ulmentflam, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 6 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c8b49516-a352-4815-b2cf-39e9a776d4de

📥 Commits

Reviewing files that changed from the base of the PR and between aeded3f and 99f928e.

📒 Files selected for processing (4)
  • README.md
  • packages/nightly-core/src/nightly_core/cli.py
  • packages/nightly-core/src/nightly_core/worktree.py
  • packages/nightly-core/tests/test_worktree_prune.py
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch nightly/worktree-prune

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@ulmentflam
ulmentflam merged commit adf5b03 into main Jul 28, 2026
3 checks passed
@ulmentflam
ulmentflam deleted the nightly/worktree-prune branch July 28, 2026 21:42
ulmentflam added a commit that referenced this pull request Jul 29, 2026
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>
ulmentflam added a commit that referenced this pull request Jul 29, 2026
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>
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