Commit eafced3
fix(remove): atomic CAS branch deletion unifies safe-delete semantics
Three follow-ups to #2870 collapse into one rewrite of
`delete_branch_if_safe`: it now performs an atomic compare-and-swap
delete via `git update-ref -d refs/heads/<branch> <expected-sha>`,
where the expected SHA comes from the snapshot the integration check
already consulted. If the ref moves between the integration check and
the delete (a hook, a concurrent push), git rejects the CAS — the
branch is retained (fail-closed) and we surface the new
`BranchDeletionOutcome::RetainedRaced` outcome rather than dropping
the unmerged commits silently. Force-delete still uses `git branch -D`
(the user's explicit override).
This unifies the divergent safe-delete semantics in
`execute_instant_removal_or_fallback`:
- Fast path and `SynchronousForNonCurrent` fallback already routed
through `delete_branch_if_safe`, so they pick up CAS for free.
- Detached fallback used to bake `&& git branch -d <branch>` into
the shell, which only honored git's reachability-from-HEAD check
(so squash-merged / patch-id / ancestor branches the planner
accepted as integrated were rejected at delete time). Now
`build_cas_branch_delete_tail` runs worktrunk's full integration
check in the foreground and, if integrated, appends an atomic
`git update-ref -d <ref> <expected-sha>` to the detached shell —
same semantics as the other paths, plus CAS safety against tip
movement between the foreground check and the detached delete.
- `handle_branch_only_output` also switches its
safe-delete path from `git branch -D` (an unsafe force-delete after
a stale integration check) to `delete_branch_if_safe`, so the
CAS protects branch-only deletions the same way.
The display layer absorbs the new variant: `flag_note` treats
`RetainedRaced` like `NotDeleted` for the success badge,
`handle_branch_deletion_result` shows the unmerged hint, and the
branch-only output path emits a dedicated "moved during deletion"
warning with a `wt remove --force-delete <branch>` recovery hint.
CAS protects the TOCTOU window inside `delete_branch_if_safe` (between
its own snapshot capture and `update-ref -d`). Hook-driven races are
still caught by the existing fresh integration check that runs after
pre-remove hooks; CAS narrows the residual unprotected window from
\"between check and delete\" to \"never\".
Tests: `git update-ref -d <ref> <sha>` is structured — exit 0 on
deletion, non-zero on stale-SHA rejection (\"cannot lock ref ... is at
X but expected Y\"). The CAS helper re-checks ref presence via
`rev-parse --verify --quiet` rather than parsing the error message,
keeping with the structured-output policy. Two new unit tests in
`git::remove::tests`:
- `cas_rejects_delete_when_branch_advances` — captures a snapshot,
moves the branch externally, then calls `delete_branch_if_safe`
and asserts `RetainedRaced` plus surviving tip at the post-race
SHA. Pins the CAS rejection mechanism.
- `cas_deletes_when_branch_unchanged` — sanity check the common
integrated case still deletes.
`test_prune_fallback_config_race_canary`'s wrapper-git script now
matches `update-ref -d refs/heads/<branch>` alongside the
`branch -d|-D <branch>` shapes.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>1 parent 0fbf824 commit eafced3
3 files changed
Lines changed: 280 additions & 32 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 | + | |
417 | 441 | | |
418 | 442 | | |
419 | 443 | | |
| |||
424 | 448 | | |
425 | 449 | | |
426 | 450 | | |
| 451 | + | |
| 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 | + | |
| 493 | + | |
| 494 | + | |
| 495 | + | |
| 496 | + | |
| 497 | + | |
| 498 | + | |
427 | 499 | | |
428 | 500 | | |
429 | 501 | | |
| |||
445 | 517 | | |
446 | 518 | | |
447 | 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 | + | |
448 | 590 | | |
449 | 591 | | |
450 | 592 | | |
451 | 593 | | |
452 | 594 | | |
453 | 595 | | |
| 596 | + | |
454 | 597 | | |
455 | 598 | | |
456 | 599 | | |
| |||
0 commit comments