Skip to content

fix(worktree): prune refused every squash-merged worktree - #41

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

fix(worktree): prune refused every squash-merged worktree#41
ulmentflam merged 1 commit into
mainfrom
nightly/worktree-prune

Conversation

@ulmentflam

@ulmentflam ulmentflam commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Follow-up to #40, which shipped an hour ago and then refused to clean up its own worktree.

PR #40 was squash-merged, so its branch's commit is not an ancestor of main, and the strict ancestry check read it as unfinished work:

· kept nightly/worktree-prune — 1 commit(s) not in `main`

In a repo that squash-merges every PR — this one — that is every worktree. The command would have kept accumulating exactly the state it exists to clear.

Why ancestry can't see it

A squash merge lands the branch's content on the base as one new commit, so the originals never become ancestors of it. No ancestry check can detect that 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, so it buys less

A [gone] upstream is not proof of a merge — a PR closed without merging leaves the same trace. So 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 entire complaint.

The distinction is now explicit rather than implied:

  • disposable governs the directory
  • branch_is_spent governs the ref, and only ancestry sets it

An empty %(upstream:track) — a branch that never left the machine — is absence of evidence, not evidence of a merge, and still blocks.

Output states which evidence was used, so an operator can tell this case from an ordinary merged one:

✓ removed nightly/squashed — 1 commit(s) not in `main`, but its remote branch
  was deleted (merged PR) — removing the worktree, keeping the branch

Verification

Verified end-to-end against a genuine squash merge, not a fake: a branch pushed to a real remote, squash-merged to main, and its remote ref deleted. git merge-base --is-ancestor confirmed the commit is not reachable from main. 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.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved worktree prune handling for squash-merged branches by recognizing deleted remote branches as supporting evidence.
    • Prevented active, never-pushed, or still-tracked branches from being removed incorrectly.
    • Branch references are now deleted only when ancestry confirms they are fully merged.
  • Improvements

    • Pruning reports notes when removal relies on remote-branch evidence.
    • Updated documentation to clarify worktree and branch removal safety criteria.

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

worktree prune now handles squash-merged branches using deleted upstream refs when ancestry is insufficient, reports the weaker evidence, and deletes branch refs only when ancestry proves the branch is fully contained. Tests cover the resulting cleanup and retention cases.

Changes

Squash-aware worktree pruning

Layer / File(s) Summary
Prune verdict and squash-merge assessment
packages/nightly-core/src/nightly_core/worktree.py
PruneVerdict records notes and ancestry-proven branch spending; assessment checks upstream tracking state when commits remain outside the base branch.
Prune cleanup and reporting
packages/nightly-core/src/nightly_core/worktree.py, packages/nightly-core/src/nightly_core/cli.py, README.md
Worktree removal uses branch_is_spent for branch deletion, CLI output includes weaker-evidence notes, and the README documents squash-merge handling.
Squash-merge pruning validation
packages/nightly-core/tests/test_worktree_prune.py
Tests cover deleted, live, absent, and ancestry-proven upstream states, branch retention or deletion, worktree removal, and reported notes.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CLI
  participant prune_worktrees
  participant Git
  participant remove_worktree
  CLI->>prune_worktrees: request prune
  prune_worktrees->>Git: assess ancestry and upstream state
  Git-->>prune_worktrees: containment and tracking evidence
  prune_worktrees->>remove_worktree: remove directory and branch_is_spent
  remove_worktree-->>CLI: cleanup status
  CLI-->>CLI: append verdict notes
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: fixing prune behavior for squash-merged worktrees.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 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.

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
ulmentflam force-pushed the nightly/worktree-prune branch from ab80beb to 6345873 Compare July 29, 2026 03:01
@ulmentflam
ulmentflam merged commit e01409b into main Jul 29, 2026
2 of 3 checks passed
@ulmentflam
ulmentflam deleted the nightly/worktree-prune branch July 29, 2026 03:03

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/nightly-core/src/nightly_core/cli.py`:
- Around line 1695-1696: Update the prune command’s documentation near the
referenced help text to reflect that commits ahead of --base may be pruned when
[gone] upstream squash-merge evidence exists, and that pruning such a worktree
does not necessarily delete its branch. Preserve the existing documentation for
cases without this evidence and align the wording with the verdict.notes
handling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 4b12d9e8-e3ab-43c1-8e8b-481b5ad73eee

📥 Commits

Reviewing files that changed from the base of the PR and between adf5b03 and 6345873.

📒 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

Comment on lines +1695 to +1696
elif verdict.notes:
note += f" — {' '.join(verdict.notes)}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Update the command’s documented prune contract.

Line 1658 still says commits ahead of --base always keep a worktree, and Lines 1663-1666 imply every removed worktree’s branch is deleted. Both are now false for [gone] upstream squash-merge evidence.

Suggested documentation update
-    A worktree is removed only when losing it cannot lose work — clean
-    tree, and no commits `--base` does not already have. Anything else is
-    kept and the reason printed. A check that cannot be *run* counts as a
-    blocker, never as consent.
+    A non-forced worktree is removed only when it is clean and either all
+    of its commits are already in `--base`, or its tracked remote branch
+    is gone (covering squash merges). The latter removes only the
+    worktree; its branch is retained.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
elif verdict.notes:
note += f" — {' '.join(verdict.notes)}"
A non-forced worktree is removed only when it is clean and either all
of its commits are already in `--base`, or its tracked remote branch
is gone (covering squash merges). The latter removes only the
worktree; its branch is retained.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/nightly-core/src/nightly_core/cli.py` around lines 1695 - 1696,
Update the prune command’s documentation near the referenced help text to
reflect that commits ahead of --base may be pruned when [gone] upstream
squash-merge evidence exists, and that pruning such a worktree does not
necessarily delete its branch. Preserve the existing documentation for cases
without this evidence and align the wording with the verdict.notes handling.

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