Skip to content

Guard worktree removal or branch cleanup with an expected branch OID #3700

Open

Description

@NathanaelRea

Motivation

An orchestrator may need Worktrunk to run pre/post-remove hooks and remove a worktree while retaining responsibility for branch identity. Between preflight and deletion, a branch name can be reused or advanced.

Request

Add an optional expected branch OID token to removal or a safe branch-scoped cleanup operation. Worktrunk should fail closed if the branch no longer resolves to that OID.

This would permit atomic lifecycle cleanup without allowing --force to delete a changed branch and without requiring broad Worktrunk state cleanup.

Activity

  1. worktrunk-bot commented on Aug 1, 2026

    @worktrunk-bot
    Collaborator

    Thanks for the suggestion — this fits Worktrunk's fail-closed data-safety posture well, and part of the mechanism you're asking for already exists internally.

    wt remove already deletes the branch with a compare-and-swap, not a plain git branch -d: delete_branch_if_safe snapshots the branch tip, then cas_delete_branch_outcome runs git update-ref -d <ref> <expected-oid>, which fails closed (RetainedRaced, branch kept) if a hook or concurrent process advanced the branch in the meantime.

    The gap is exactly the one you describe: that expected OID is captured by Worktrunk at its own snapshot, so the guard only covers Worktrunk's internal window (snapshot → delete) — not your orchestrator's window (preflight → the moment wt remove starts). Between those, a branch could be reused or advanced and Worktrunk would delete against its own fresh read without knowing your expectation was already violated. And the --force / -D path skips the CAS entirely (branch -D, no expected value), which is the "changed branch deleted under --force" case you're calling out.

    So a caller-supplied token would slot in cleanly: an optional expected_oid on RemoveArgs threaded down to the expected_sha argument of cas_delete_branch_outcome, extending the existing CAS back to your observed OID and, unlike the internal snapshot, also applying on the force path. The primitive is already there; what's missing is the external interface to feed it a caller-chosen value.

    I'll leave it to a maintainer to weigh the exact surface (a new --expected-oid <sha> flag vs. a branch-scoped cleanup subcommand) and prioritize.

  2. max-sixty commented on Aug 2, 2026

    @max-sixty
    Owner

    Thanks — the fail-closed posture here is right, and I want to check how much of the window actually bites you before adding a flag for it.

    Where things stand: #3710 makes the guard's outcome visible. wt remove --format=json reported the branch as branch_deleted: bool, which collapsed a CAS refusal into the same false as --no-delete-branch. It now reports branch_outcome, one of deleted, deferred, not_attempted, retained_unmerged, retained_checked_out, retained_raced, retained_failed. So a caller can already detect the race you describe and retry, where before it couldn't tell that case apart. That's an output, not the input this issue asks for — it doesn't close the request.

    On the input: git's primitive is there and Worktrunk already calls it (git update-ref -d <ref> <old-oid>, which deletes only if the ref still holds <old-oid>), so the mechanism isn't the obstacle. What I'd like to understand before deciding is which window is actually costing you something.

    Is the concern the default path or the force path? On the default path Worktrunk won't delete a branch whose content isn't integrated into the target. Commits that landed in your blind spot are by construction not integrated, so the branch is retained and you get retained_raced or retained_unmerged — you were never going to lose them. What an expected-OID adds there is "delete exactly the object I inspected", which is a different and narrower guarantee than "don't lose work".

    --force-delete / -D is a different story: it skips the integration check and the CAS entirely and runs git branch -D. That is where a moved branch really can be deleted out from under you, and where a --expected-oid would carry its weight. Your phrasing — "without allowing --force to delete a changed branch" — suggests this is the case you're in, but I'd rather not guess.

    So, concretely:

    1. Does your orchestrator pass -D / --force-delete, or does it rely on the integration check?
    2. Have you observed a branch being reused or advanced in that window, or is this hardening against a race you've reasoned about rather than hit?
    3. Would branch_outcome plus a retry be enough for your flow, or do you need wt to fail closed against your own observed OID rather than reporting what it found?

    Which of those it is decides whether a gap is left to close. The force path has no guard at all. On the default path the integration check already provides most of what an expected-OID would, and the reporting change may cover the rest — so I'd want to know it's the former before weighing new CLI surface for it. No decision either way yet.

    This was written by Claude Code on behalf of max-sixty

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions