Skip to content

fix(server): make worktree removal idempotent and keep bulk delete going - #4615

Open
m-de-graaff wants to merge 3 commits into
pingdotgg:mainfrom
m-de-graaff:fix/idempotent-worktree-removal
Open

fix(server): make worktree removal idempotent and keep bulk delete going#4615
m-de-graaff wants to merge 3 commits into
pingdotgg:mainfrom
m-de-graaff:fix/idempotent-worktree-removal

Conversation

@m-de-graaff

@m-de-graaff m-de-graaff commented Jul 27, 2026

Copy link
Copy Markdown

Fixes #4513

Problem

git worktree remove <path> exits 128 with fatal: '<path>' is not a working tree whenever git no longer registers the path as a worktree. Two ordinary flows hit that:

  • two threads recorded the same worktree_path, so the first delete removes the worktree and the second one fails;
  • a worktree removed outside the app (or pruned), leaving the thread row pointing at a path git has forgotten.

In both cases the thread is deleted and the worktree is already gone, but vcs.removeWorktree fails, so the user gets "Failed to delete threads" / "Thread deleted, but worktree removal failed". Worse, the bulk delete loop in both sidebars returned on the first failure, so one stale worktree aborted the rest of the selection and the batch had to be retried until the selection emptied.

The issue reports 22 alive threads pointing at paths git no longer tracks in a single workspace, so this is not a rare edge.

Fix

apps/server/src/vcs/GitVcsDriverCore.tsremoveWorktree treats an unregistered path as success: the requested state already holds. Real failures (a dirty worktree without --force, a permissions problem) still error as before. The command now runs through executeGitWithStableDiagnostics so LC_ALL=C makes the stderr match locale-proof.

I verified against git directly that every "already gone" variant produces exactly that stderr and exit 128 — unregistered path, pruned worktree, and a plain directory — while a registered worktree whose directory was deleted still succeeds normally.

apps/web/src/components/Sidebar.tsx, SidebarV2.tsx — bulk delete collects failures and continues instead of returning, toasts once at the end, and deselects only the threads that actually deleted. Interruption still stops the loop.

The issue also asks for git's stderr in the error message; #4467 already does that, so this PR leaves it alone.

Tests

apps/server/src/vcs/GitVcsDriverCore.test.ts:

  • removing a path git does not register is a no-op (this test previously asserted the failure this PR removes);
  • removing the same worktree twice is a no-op — the shared-worktree repro;
  • a dirty worktree without --force still fails, with the error unwrapped, keeping the original assertion's coverage.

vp test run src/vcs/GitVcsDriverCore.test.ts — 30 passed. Typecheck clean for apps/server and apps/web; lint and format checked on the changed files.

🤖 Generated with Claude Code


Note

Medium Risk
Changes Git worktree cleanup and multi-thread delete behavior; incorrect idempotency matching could hide real git errors, but scope is localized to removeWorktree and sidebar delete loops.

Overview
Thread deletes no longer fail when Git has already forgotten a worktree, and bulk delete keeps going instead of stopping on the first bad row.

removeWorktree now runs git worktree remove with stable (LC_ALL=C) diagnostics and treats the “is not a working tree” case as success, so stale or duplicate worktree_path values (shared path, pruned worktree, etc.) do not block thread deletion. Real failures—e.g. a dirty worktree without --force—still surface as GitCommandError.

In Sidebar and SidebarV2, multi-select delete only adds thread keys to deletedThreadKeys after a successful delete (so orphaned-worktree logic does not treat batch mates as already gone). Non-interruption failures are collected, the loop continues, one error toast runs at the end, and selection clears only for threads that actually deleted. User cancellation still aborts the batch.

Reviewed by Cursor Bugbot for commit b6dccf4. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Make worktree removal idempotent and keep bulk thread deletion going past failures

  • removeWorktree in GitVcsDriverCore.ts now treats a path that Git no longer registers as a worktree as a no-op (returns success) instead of throwing; real failures surface as GitCommandError without synthetic wrapping.
  • Bulk thread deletion in Sidebar.tsx and SidebarV2.tsx continues past non-interruption failures, removes only successfully deleted threads from the selection, and shows a single error toast after the loop.
  • Behavioral Change: bulk delete previously aborted on the first failure; it now processes all threads and reports the first error at the end.

Macroscope summarized b6dccf4.

`git worktree remove` exits 128 with "is not a working tree" whenever the
path is no longer registered with git — which happens whenever a thread's
recorded worktree_path has drifted, or when two threads share one worktree
and the first delete already removed it. That failed the whole thread
deletion even though the thread was gone and the worktree was already
absent, and the sidebar's bulk delete returned on the first failure, so one
stale worktree aborted the rest of the selection.

Treat an unregistered path as success (the requested state already holds)
and run the command under LC_ALL=C so the stderr match is locale-proof. Real
failures — a dirty worktree without --force, for example — still error.

In both sidebars, collect failures instead of returning, report once at the
end, and deselect only the threads that actually deleted.

Fixes pingdotgg#4513

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3f6703ea-c7ee-4b68-a9df-225ce093710d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:M 30-99 changed lines (additions + deletions). labels Jul 27, 2026
Comment thread apps/web/src/components/Sidebar.tsx
@macroscopeapp

macroscopeapp Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Approved

Straightforward bug fix making worktree removal idempotent and improving bulk delete resilience. Changes are defensive, self-contained, and limited to error handling improvements with updated tests.

You can customize Macroscope's approvability policy. Learn more.

`deletedThreadKeys` was seeded with every selected thread before the loop
ran, so the first delete saw all its batch mates as gone and removed a
worktree they still pointed at. Now that a failure no longer aborts the
batch, a thread that fails to delete would keep a live reference to a
worktree the loop already removed.

Grow the set as deletions land, matching SidebarV2, and deselect only the
threads that actually deleted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@m-de-graaff

Copy link
Copy Markdown
Author

Pre-seeded batch keys skew orphan check — deletedThreadKeys is pre-populated with all selected threads before the deletion loop.

Fixed in bd9c32b. The finding is correct, and this PR made it reachable more often.

const deletedThreadKeys = new Set(threadKeys) predates this PR, but it was previously bounded by the loop returning on the first failure. Now that the loop continues, a thread whose delete fails stays alive while every later iteration still counts it as gone — so a batch mate can remove a worktree that live thread still points at.

Sidebar.tsx now grows the set as deletions actually land and deselects only the threads that deleted, which is what SidebarV2.tsx already did (its comment says exactly why). The two loops are now identical in this respect.

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

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit b6dccf4. Configure here.

);
}
removeFromSelection(threadKeys);
removeFromSelection([...deletedThreadKeys]);

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.

Stale bulk selection keys persist

Medium Severity

After bulk delete, removeFromSelection only receives keys recorded in deletedThreadKeys, but the loop runs over selectedThreadEntries, which can be a subset of selectedThreadKeys. Selection entries with no resolvable thread shell are never deleted and are no longer cleared, so multi-select can stay active for keys that no longer map to rows.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b6dccf4. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:M 30-99 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Bulk thread delete aborts on "git worktree remove failed" when the worktree is already gone, and the error hides git's stderr

1 participant