Commit 1eb4e58
fix(remove): atomic CAS branch deletion unifies safe-delete semantics (#2903)
Three follow-ups to #2870 plus a CAS-based unification of safe-delete
semantics. (#2900 was the small/incremental path for the first two
items; it has been closed as superseded — its commits are all contained
here.)
## Item 1 — drop the defensive `check_lock` (refactor)
The `check_lock` `RwLock<()>` in `step_prune` was carried over from
#2808's Windows `.git/config` race fix. After #2870 restructured the
flow, the phase ordering already serializes integration-check readers
against removals: the background `par_iter` is the only reader, the `for
... in rx` loop exits only once that thread has finished, and removals
run strictly after. Belt-and-suspenders per CLAUDE.md — removed.
## Item 2 — warn when background branch deletion silently retains (fix)
Both branch-deletion paths in `execute_instant_removal_or_fallback`
swallowed errors at `log::debug!`. Promoted the surprise
`Ok(NotDeleted)` (planner predicted deletion, branch survived — a hook
moved the tip) to a `warning_message` with a `wt remove -D <branch>`
recovery hint. Suppressed when the planner already predicted retention
so the existing `print_hints` unmerged-branch message doesn't duplicate.
`Err` failures now log at warn level (developer-facing) rather than
user-actionable warnings — the failure modes (`git update-ref` exec
error, refs DB I/O) aren't actionable beyond re-running.
## Item 3 / CAS rewrite — atomic safe-delete (the big one)
`delete_branch_if_safe` now uses `git update-ref -d refs/heads/<branch>
<expected-sha>` instead of `git branch -D`. The expected SHA comes from
the snapshot the integration check already consulted, so the
check→delete sequence becomes one atomic step against a known SHA. If
anything moved the ref in between, git rejects the CAS — the new
`BranchDeletionOutcome::RetainedRaced` outcome is returned and surfaced
to the user. Force-delete keeps `git branch -D` (explicit override).
This unifies the divergent safe-delete semantics in
`execute_instant_removal_or_fallback`:
- **Fast path** + **SynchronousForNonCurrent fallback** routed through
`delete_branch_if_safe`, so they pick up CAS for free.
- **Detached fallback** (rename failed AND current worktree): the shell
`&& git branch -d <branch>` is replaced by a foreground integration
check + atomic `&& git update-ref -d <ref> <expected-sha>` tail
(`build_cas_branch_delete_tail`). Squash-merged / patch-id / ancestor
branches the planner accepts are now accepted at delete time too,
matching the fast path.
- **Branch-only deletion** (`handle_branch_only_output`) switches from
bare `git branch -D` (effectively a force-delete after a stale
integration check) to `delete_branch_if_safe`. CAS protects it too.
## Follow-up consolidation (commit `971e8b4`)
The "branch kept because its tip moved during the delete" message had
drifted into three shapes — two near-identical inline strings plus a
gap: the foreground worktree path routed `RetainedRaced` through
`show_unmerged_hint` and printed the generic "Branch unmerged; run `-D`"
hint, whose bare `-D` would force-delete the just-arrived racing
commits. All three emit paths (`warn_if_branch_retained`,
`handle_branch_only_output`, `print_hints`) now route through one
`retained_raced_branch_message` helper, with a dedicated `RetainedRaced`
early-return arm in `print_hints`; `show_unmerged_hint` is now
`NotDeleted`-only. Also converged the recovery command to the canonical
`wt remove -D <branch>` (via `suggest_command`) and inlined the
single-caller `snapshot_sha` wrapper.
### Race coverage
CAS closes the TOCTOU window inside `delete_branch_if_safe` (between its
own `capture_refs` and `update-ref -d`). Hook-driven races continue to
be caught by the existing fresh integration check that runs after
pre-remove hooks; CAS narrows the residual window from "between check
and delete" to "never". The race rejection is unit-simulated (the ref is
moved externally), not yet driven end-to-end through a real `wt remove`
+ hook.
### Tests
Unit tests in `git::remove::tests`:
`cas_rejects_delete_when_branch_advances` (pins the CAS rejection
mechanism), `cas_deletes_when_branch_unchanged`,
`cas_propagates_error_when_ref_vanished`, and
`deletes_via_fallback_when_branch_absent_from_snapshot`. In
`output::handlers::tests`: every `warn_if_branch_retained` arm,
`build_remove_command_with_tail_appends_only_when_present`, and
`retained_raced_branch_message_lead_in_varies`.
`test_prune_fallback_config_race_canary`'s wrapper-git script matches
the new `update-ref -d refs/heads/<branch>` shape.
`cargo run -- hook pre-merge --yes` passes locally: 4263 nextest tests +
doctests + pre-commit + clippy + docs, no snapshot drift.
### Known gaps (deferred)
- No end-to-end integration test of a real mid-delete race (a
`pre-remove` hook advancing the branch); the mechanism is unit-covered.
- The FAQ [What can Worktrunk
delete?](docs/content/faq.md#what-can-worktrunk-delete) doesn't yet
mention the fail-closed-on-race behavior.
> _This was written by Claude Code on behalf of Maximilian Roos_
---------
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: worktrunk-bot <254187624+worktrunk-bot@users.noreply.github.com>1 parent 02c0621 commit 1eb4e58
3 files changed
Lines changed: 514 additions & 42 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
225 | 225 | | |
226 | 226 | | |
227 | 227 | | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
228 | 234 | | |
229 | 235 | | |
230 | 236 | | |
| |||
412 | 418 | | |
413 | 419 | | |
414 | 420 | | |
415 | | - | |
416 | | - | |
| 421 | + | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
417 | 443 | | |
418 | 444 | | |
419 | 445 | | |
| |||
424 | 450 | | |
425 | 451 | | |
426 | 452 | | |
| 453 | + | |
| 454 | + | |
| 455 | + | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
| 461 | + | |
| 462 | + | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
| 477 | + | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
| 482 | + | |
| 483 | + | |
| 484 | + | |
| 485 | + | |
| 486 | + | |
| 487 | + | |
| 488 | + | |
| 489 | + | |
| 490 | + | |
| 491 | + | |
| 492 | + | |
427 | 493 | | |
428 | 494 | | |
429 | 495 | | |
| |||
445 | 511 | | |
446 | 512 | | |
447 | 513 | | |
| 514 | + | |
| 515 | + | |
| 516 | + | |
| 517 | + | |
| 518 | + | |
| 519 | + | |
| 520 | + | |
| 521 | + | |
| 522 | + | |
| 523 | + | |
| 524 | + | |
| 525 | + | |
| 526 | + | |
| 527 | + | |
| 528 | + | |
| 529 | + | |
| 530 | + | |
| 531 | + | |
| 532 | + | |
| 533 | + | |
| 534 | + | |
| 535 | + | |
| 536 | + | |
| 537 | + | |
| 538 | + | |
| 539 | + | |
| 540 | + | |
| 541 | + | |
| 542 | + | |
| 543 | + | |
| 544 | + | |
| 545 | + | |
| 546 | + | |
| 547 | + | |
| 548 | + | |
| 549 | + | |
| 550 | + | |
| 551 | + | |
| 552 | + | |
| 553 | + | |
| 554 | + | |
| 555 | + | |
| 556 | + | |
| 557 | + | |
| 558 | + | |
| 559 | + | |
| 560 | + | |
| 561 | + | |
| 562 | + | |
| 563 | + | |
| 564 | + | |
| 565 | + | |
| 566 | + | |
| 567 | + | |
| 568 | + | |
| 569 | + | |
| 570 | + | |
| 571 | + | |
| 572 | + | |
| 573 | + | |
| 574 | + | |
| 575 | + | |
| 576 | + | |
| 577 | + | |
| 578 | + | |
| 579 | + | |
| 580 | + | |
| 581 | + | |
| 582 | + | |
| 583 | + | |
| 584 | + | |
| 585 | + | |
| 586 | + | |
| 587 | + | |
| 588 | + | |
| 589 | + | |
| 590 | + | |
| 591 | + | |
| 592 | + | |
| 593 | + | |
| 594 | + | |
| 595 | + | |
| 596 | + | |
| 597 | + | |
| 598 | + | |
| 599 | + | |
| 600 | + | |
| 601 | + | |
| 602 | + | |
| 603 | + | |
| 604 | + | |
| 605 | + | |
| 606 | + | |
| 607 | + | |
| 608 | + | |
| 609 | + | |
| 610 | + | |
| 611 | + | |
| 612 | + | |
| 613 | + | |
| 614 | + | |
| 615 | + | |
| 616 | + | |
| 617 | + | |
| 618 | + | |
| 619 | + | |
| 620 | + | |
| 621 | + | |
| 622 | + | |
| 623 | + | |
| 624 | + | |
| 625 | + | |
| 626 | + | |
| 627 | + | |
| 628 | + | |
| 629 | + | |
| 630 | + | |
| 631 | + | |
| 632 | + | |
| 633 | + | |
| 634 | + | |
| 635 | + | |
| 636 | + | |
| 637 | + | |
| 638 | + | |
| 639 | + | |
| 640 | + | |
| 641 | + | |
| 642 | + | |
| 643 | + | |
448 | 644 | | |
449 | 645 | | |
450 | 646 | | |
451 | 647 | | |
452 | 648 | | |
453 | 649 | | |
| 650 | + | |
454 | 651 | | |
455 | 652 | | |
456 | 653 | | |
| |||
0 commit comments