Skip to content

Worktree delete: keep Git's safe delete; ask the review host instead of a local squash proof - #24269

Closed
brennanb2025 wants to merge 7 commits into
mainfrom
brennanb2025/branch-cleanup-forge-merged
Closed

brennanb2025 wants to merge 7 commits into
mainfrom
brennanb2025/branch-cleanup-forge-merged

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 13 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1395 $\color{#cf222e}{\Huge{\mathbf{−}}}$​799 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​596
Prod 21 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​622 $\color{#cf222e}{\Huge{\mathbf{−}}}$​539 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​83

ELI5

When you delete a workspace, Orca also deletes its branch with Git's safe delete. If Git refused (usually because the pull request was squash-merged), Orca used to quietly do practice merges in your repository to prove the branch was already merged, which left junk files in the repository and sometimes fetched from the network. Now Orca first checks, without the network, whether the branch's commit is already part of the branch it was created from; if so it deletes it. Otherwise it asks the review host (GitHub, GitLab, Bitbucket, Azure DevOps or Gitea) once: "was this exact commit merged?" If yes, the branch is deleted. If not, or if the answer does not come back, the branch is kept and you get the usual "branch kept" message with a "Force Delete Branch" button.

What Changed

The problem. After a workspace delete, Orca runs git branch -d. Git refuses when it cannot see the branch's commits in the branch it compares against, which is normal after a squash or rebase merge whose remote branch was already deleted. On every such refusal Orca ran a local "already merged" proof: a git merge-tree --write-tree practice merge of the branch into each saved base (repeated per patch-id candidate), a git cherry and git patch-id scan, and, if the local base did not prove it, a git fetch --prune of the base's remote with no timeout. Each practice merge wrote unreferenced Git objects into the user's repository, the same junk-object problem #22356 set out to contain. On SSH the same proof ran on the remote host through the relay.

A second refusal case the proof was quietly covering. Orca creates workspace branches without an upstream (--no-track), so git branch -d compares the branch against whatever the main checkout has checked out, not against the branch the workspace was created from. A workspace with no commits of its own is therefore refused when local main is behind origin/main, when the main checkout is on another branch, or when the workspace was opened on someone else's review branch. The old proof deleted these; dropping it without a replacement would have kept them with a warning about "local commits" that do not exist.

The change. That local proof is gone, on local, WSL and SSH alike. The rule is now, in order:

  1. git branch -d, unchanged.
  2. If Git refuses: delete the branch when its commit is already in the history of a branch it was created from: its saved creation base (branch.<name>.base, which Orca writes when it creates the workspace), the remote's default branch (origin/HEAD), or the branch the main checkout has checked out (a detached main checkout counts for nothing, since no branch keeps those commits). Each candidate is first resolved to the branch it names, and one that is the workspace's own branch is never counted, compared without regard to letter case because macOS and Windows treat feat and Feat as the same ref: in a bare repository, HEAD can name the workspace's branch without it being checked out anywhere. One git merge-base --is-ancestor per candidate; local reads only, no fetch, nothing written. This is the check -d itself would have made if the branch tracked its base.
  3. If no base holds it: ask the review host (GitHub, GitLab, Bitbucket, Azure DevOps or Gitea) once, with a 10 second limit, and delete only if the review is merged and its head commit is exactly the branch's commit, or the branch's commit is confirmed to be part of the merged review (GitHub: the commit-to-pull-request lookup Orca already does; GitLab: one of the merge request's pushed versions).
  4. Anything else keeps the branch with today's "Workspace deleted, branch kept" (or "Worktree deleted, branch kept") message and its "Force Delete Branch" button: not merged, no review, a different commit, no network, no login, a rate limit, a timeout, or an unknown provider.

Every delete in steps 2 and 3 is guarded: it only removes the branch while it still points at that commit, using the same guarded delete primitive as the "Force Delete Branch" button, and refuses if the branch is checked out anywhere. Orca never uses git branch -D here.

Case Before After
Pushed branch whose remote branch still exists, or a merge-commit / fast-forward merge -d deletes it Same
Workspace with no commits of its own, while local main is behind origin/main or the main checkout is on another branch Deleted (the local proof) Same: deleted by the base check, no network
Workspace opened on someone's open review branch, no commits of your own Deleted (the local proof) Same: deleted by the base check, no network
Squash- or rebase-merged PR, remote branch deleted, local branch at the PR's head, review host reachable Local practice-merge proof (plus a fetch if the local base did not have the squash), then deleted Review host says merged at this commit, so deleted. No message.
Same, but offline, not logged in, or no supported review host Deleted when a local copy of the base already had the squash commit; otherwise the proof's fetch failed and the branch was kept Kept, with the "branch kept" message and "Force Delete Branch" (CLI prints its usual warning). Intended.
Review host added commits after your local head (GitLab "Apply suggestion" or "Rebase", GitHub "Update branch") Deleted GitHub: deleted when GitHub lists the commit as part of the PR. GitLab: deleted when the commit was one of the merge request's versions. Bitbucket, Azure DevOps, Gitea: kept, one click to delete.
PR merged, then reverted on the base Kept (the practice merge saw changes) Deleted: the review host says it merged at this commit, and the commits stay on the host. Intended.
Stacked PR merged into a parent branch that was later abandoned Kept (no saved base contained it) Deleted, for the same reason. Intended.
Squash-merged PR with new local commits after the merge Kept Kept
Local commits never pushed, no PR Kept, unless every commit was cherry-picked into the base Kept
PR closed without merging Kept Kept
Bare repository: deleting the workspace of the branch HEAD names, which has an upstream and unpushed commits (Git's -d refuses) Deleted, unpushed commits included (the proof compared the branch with itself) Kept, with the message: the branch is never counted as its own base
Windows delete where Git dropped the workspace but a locked file stopped the folder delete (Orca finishes it), a workspace whose folder was already gone, or a workspace Git still lists although only a leftover .git file is left (any platform) Kept, with the message (Git's state is uncertain there, so -d and the base check are not run) Review host asked (the delete waits for the answer, up to 10 seconds; on the desktop app the next delete in that repository waits too); deleted only when merged at this commit, else kept as before. Intended: the guarded delete is safe whether or not -d ran.
Delete interrupted by quitting Orca, finished at the next start -d and the local proof The same rule as an uninterrupted delete (-d, base check, review host), whether or not Git had already dropped the workspace before the quit
SSH repo The relay ran the proof on the remote The relay runs -d and the base check on the remote; this machine asks the review host; the guarded delete runs on the remote over the existing "delete preserved branch" request
WSL repo Proof ran in the distro -d, the base check and the guarded delete run in the distro; the review-host question runs as it does for every other WSL review lookup
Folder workspace, "keep branch on delete", failed-create rollback No branch / never deleted / -D Unchanged

Timing. Git's branch delete, the base check and the guarded delete wait in the per-repository queue that already serializes branch deletes. The review-host question runs outside that queue and after the file watchers are released. Local deletes on macOS, Linux and Windows run their Git delete in the background, so several deletes started together ask at the same time; each delete's reply still waits for its own answer. So on every platform, when Git refuses the branch delete and no base holds the branch, that workspace stays "Deleting…" until the review host answers: usually a second or two, at most 10 seconds. WSL deletes, Workspace Cleanup (which deletes one workspace at a time) and removing an SSH host's workspaces ask one after another, and on WSL the wait also holds the next delete in that repository. A workspace Git still lists although its folder is gone or is only a leftover .git file asks before the desktop app moves on to the next delete in that repository, so it holds that delete too. When one question hits the 10 second limit, later kept branches in that repository on that host are kept without asking for the next 60 seconds, so a hung review host costs about one 10 second wait per repository per minute on every path, not one per workspace. Before, the same spot could wait on a git fetch --prune with no limit.

The mechanism.

  • New src/shared/git-branch-base-containment.ts: reads the branch's base candidates and runs merge-base --is-ancestor, taking a Git runner so the local Git layer (src/main/git/worktree-branch-removal.ts) and the SSH relay (src/relay/git-handler-worktree-remove.ts) share it. Every candidate is resolved through symbolic refs first; one that names the branch itself (in any letter case) is dropped, a detached HEAD is not counted, and a saved base that is a bare commit id is ignored, since it keeps nothing reachable once the branch is gone. Neither side knows about review hosts.
  • New src/main/source-control/forge-merged-branch-cleanup.ts: settlePreservedBranchWithForge (ask once with a 10 second limit, delete at the exact commit, keep on any failure), the provider check, the short in-memory "did not answer" window (keyed by execution host and repository, expires by time, opened only by a timeout, never stored), and two entry points, settleKeptBranch (local and WSL) and settleKeptSshBranch. Each asks the review host about a kept branch and then removes the fork remote Orca added for a pull request from someone else's fork, if no local branch still tracks it, so that removal always sees the branch already deleted.
  • These two entry points are now the only way removal code reaches that fork-remote removal: every removal path (registered workspaces; a workspace Git still lists though its folder is gone or is only a leftover .git file; Windows recovery; deletes interrupted by quitting; leftover folders Git no longer lists; on the desktop app and the CLI, local and SSH) goes through them, and kept-branch-settlement-route.test.ts fails if a removal file calls the fork-remote removal directly. The leftover-folder paths have no branch result, so nothing is asked there. The test cannot see a caller that hands over an empty result or skips the fork-remote removal. Only the "Force Delete Branch" button handlers are allowed to call it directly, since they act after the fact.
  • New deletePreservedBranchAtHead in src/main/git/worktree-removal.ts runs the existing guarded delete through the same per-repository queue as removal branch deletes.
  • New src/main/gitlab/merge-request-versions.ts reads a merge request's version heads (GET …/merge_requests/:iid/versions, newest 100).
  • Deleted src/shared/git-branch-cleanup.ts (the practice merge, git cherry, patch-id scan and fetch --prune), the local and relay callers of it, and their tests.
  • docs/reference/git-compatibility.md: the merge-tree-write-tree row no longer mentions branch cleanup.

Not changed. No new stored data and no settings. The SSH relay protocol is unchanged: the relay's remove result is the same shape, and the guarded delete uses the existing git.forceDeletePreservedBranch request. Each desktop talks only to a relay of its own version (the relay refuses a different version when it connects), so the relay change ships with the desktop. The real mixed-version case is a paired remote Orca server: it runs its own removal code, so an older server keeps its old proof, and a newer one asks the review host with the server's own GitHub or GitLab login (a server without a login keeps the branch). Git 2.25 is enough: branch -d, merge-base --is-ancestor, update-ref -d <ref> <old>, worktree list --porcelain and config --remove-section all predate it, and branch cleanup no longer needs Git 2.38.

Why

The practice merge was a local guess at a question that has two exact answers. If the branch's commit is already in the history of the branch it was created from, deleting it loses nothing, and Git answers that locally with a few reads. If not, the review host answers "was this exact commit merged?" exactly. Together that is cheaper (local reads, then one review lookup, where GitLab adds one read of the merge request's versions, instead of fetches and practice merges), writes nothing into the repository, works the same for SSH, and covers cases the practice merge got wrong (a revert after merge, an abandoned stack parent).

The safety argument: in step 2 the commit stays reachable from another branch in the same repository, which is Git's own -d guarantee. In step 3 the host merged the review, so every commit of it is kept on the host; if the local branch still points at the review's head, or at a commit the host confirms belongs to the review, deleting the local branch loses nothing the host does not hold. One extra local commit and both checks fail, so the branch is kept. The delete itself refuses if the branch moved in the meantime.

Alternatives considered:

  • Keep the practice merge but put its objects in a scratch folder (fix(git): keep merge-tree's throwaway trees out of the real object store #22356). That fixes the junk objects but keeps the fetches and practice merges, and needs scratch-folder, partial-clone and WSL path handling to protect a check that has exact answers.
  • Ask the host when the workspace delete starts, so the answer is ready if Git refuses. Not done: in the common case -d succeeds, so that would spend a host request on almost every delete to save at most one 10 second wait on the rarer refusal.
  • Run the host question inside the per-repository branch-delete queue. Not done: one slow or unreachable host would then delay every later delete in that repository by 10 seconds each.
  • Fetch the base before the local check. Not done: a delete must not wait on the network; a stale local base only means the branch goes on to the review-host question.

Where this fits

Orca runs git merge-tree in two places: the conflicting-file list on a conflicting pull request, and (before this PR) the branch-cleanup proof after a workspace delete. Each run wrote junk objects into the user's repository.

This PR deletes nothing the conflicting-file list uses: src/shared/git-merge-tree-capability.ts, the merge-tree-write-tree capability entry, its row in the compatibility doc (now describing only the conflict list) and its real-Git compatibility test all stay. It deletes only the branch-cleanup proof (src/shared/git-branch-cleanup.ts) and its local and relay callers.

#22356 also edits those branch-cleanup files. Whichever of the two lands second drops #22356's branch-cleanup part and keeps its conflict-list part.

The review-host step reuses Orca's existing pull request lookup. When GitHub returns an open pull request that has merge conflicts, that lookup still computes the conflicting-file list, the same work the pull request card's refresh already does today.

This work is part of the wider worktree-create latency plan: #23699 is its step 1, and #23698 is tracked separately.

Differences from the common pattern

  • Deleting the local branch automatically when a workspace is deleted: Intended, Orca's existing behavior, not changed here. The common pattern either never deletes the branch on a workspace delete (it keeps it so the workspace can be restored) or deletes it only when the user opts in for that delete. Orca deletes so deleted workspaces don't leave branches behind, and only through Git's safe delete and the checks above, so unmerged work is never lost.
  • Keeping the branch when it is not proven merged: matches (the common pattern keeps it). The after-the-fact one-click "Force Delete Branch" is Intended, Orca's own: where the common pattern deletes, it asks before the delete rather than after.
  • No local squash or patch-equivalence proof: matches.
  • A local "is the branch's commit already in its base's history" check before consulting the review host: matches the common pattern, which runs this ancestry check first wherever it consults merged state at all.
  • Intended: Orca runs that check against the base as it is locally, without fetching it first, so the local check never waits on a fetch. A stale local base only sends the branch on to the review-host question.
  • Intended: treating the review host's "merged at this exact commit" as permission to delete the local branch when the local check fails. Where the common pattern consults the review host's merged state, it is an extra condition after the local ancestry check and is never used to delete a branch. Here it is the last step, used only when the review is merged at this exact commit (or the host confirms the commit is part of it), and the delete is guarded on that commit.
  • Intended: also accepting a commit that GitHub's commit-to-pull-request lookup or GitLab's merge request versions confirm as part of the merged review. This extends the rule above to commits the host added after your last push.
  • Intended: Bitbucket, Azure DevOps and Gitea pull requests whose host added commits after your local head are kept. You have a one-click delete, and there is no Orca-observed failure that justifies more.
  • Temporary: the workspace delete waits for the review host's answer before it reports done. Branch cleanup is bookkeeping, and bookkeeping should never hold up a user action. When it happens: Git refuses the branch delete and no base holds the branch, which in practice is a squash- or rebase-merged pull request whose remote branch is already gone. How long: usually 1-2 seconds, at most 10 seconds; after a timeout, later deletes in that repository skip the question for 60 seconds. What you see: the workspace card stays "Deleting…" for that time, then disappears, with or without the "branch kept" message. Follow-up: reply as soon as the workspace is gone, and report the branch outcome afterwards through an event (desktop, mobile and CLI, negotiated so older clients keep working).
  • Intended: the 60 second "review host did not answer" window. It lives only in memory, expires by time and is opened only by a timeout, so nothing can strand; its only effect is keeping a branch (one click to delete) instead of waiting another 10 seconds.

Linked Issue

Part of the plan described in "Where this fits" (replaces the approach in #22356 for branch cleanup only; #22356 still covers the conflicting-file list).

Visual Proof

Recorded on a remote Mac in an isolated rig without credentials. Main is the before, this PR (3bc6b08) is the after.

after: squash-merged branch kept with Force Delete Branch
after: Force Delete Branch deleted it
before: the same branch deleted silently by the old proof
after: workspace with no commits deleted, no message
after: workspace on someone's pull request branch deleted, no message
after: branch with an unpushed commit kept
after: merge-commit branch deleted

A review host saying "merged" needs a credential in the rig, which our QA rules forbid; mocked-review-host and real-Git tests cover it.

Testing

  • Typecheck (node project, which includes src/shared and the relay), after merging current main: passes.

  • Unit tests, after merging current main: 95 test files that import a changed module or exercise workspace removal (desktop and CLI removal, relay Git handler, recovery, background removal, fork-remote removal, orca-runtime.test.ts): 2261 passed, 2 skipped.

  • New tests:

    • Base check, real Git, local and relay: an untouched workspace is deleted while local main is behind origin/main (base known only from origin/HEAD), while the main checkout is on another branch, and when opened on someone's review branch; a workspace with one unpushed commit is kept with its head; in a bare repository (both a bare clone and a .bare folder behind a .git file), the branch HEAD names, with an upstream and an unpushed commit, is kept; so is a branch with an upstream and an unpushed commit while the main checkout is detached at it, and (on macOS, whose filesystem ignores letter case) a bare repository's branch Feat whose HEAD says feat. Unit: base candidates (no repeats, short names qualified, a detached HEAD not counted, the branch named in another letter case ignored, bare commit ids ignored, the branch itself ignored directly or through a symbolic ref, a malformed head runs no Git).
    • The provider check: merged at the same commit; open, closed or no review; a different commit; GitHub's confirmed commit; GitLab versions (in and not in); a Bitbucket short hash of 12+ characters (and not shorter, and not for other providers).
    • Asking the review host about a kept branch: deletes it when confirmed; keeps it when not confirmed, when the lookup fails, when it times out, and when the guarded delete refuses; never asks when Git already deleted the branch; after a timeout, later branches in the same repository are kept without asking until the window expires, another repository still asks, and a fast lookup error opens no window.
    • Runtime callers: the host is asked only after the watcher gate is released, and the delete runs before the fork-remote removal; four parallel removals in one repository against a host that never answers finish in one 10 second limit; three SSH removals one after another against that host also finish in one limit, with one question; SSH deletes go through the relay request in the same order, and a relay without it keeps the branch; if reading the workspace's saved data throws, the delete still finishes and the branch is kept (local and SSH).
    • A delete interrupted by a quit after Git dropped the workspace asks the review host on the next start and deletes the merged branch.
    • A workspace Git still lists although only a leftover .git file is left asks the review host about the branch it kept and deletes it when merged at that commit.
    • A removal whose branch Git already deleted reads no saved workspace data and asks nothing.
    • No removal file calls the fork-remote removal directly.
    • Desktop callers: a local branch whose linked PR merged at its commit is deleted before the fork-remote cleanup and leaves no "Force Delete Branch" action pending; an SSH branch is deleted through the relay.
    • Git layer: a refused -d runs no practice merge, cherry, patch-id or fetch; the guarded delete waits in the same per-repository queue as removal branch deletes.
    • GitLab versions reader: parses version heads, skips non-GitLab repositories, releases its request slot on failure.
  • Each new rule was checked to fail its test when reverted: the base check (local: 3 tests red; relay: 1), treating every head as contained (the keep tests red), the origin/HEAD candidate, ignoring commit-id bases, the window opening, expiring and staying closed on fast errors, asking about the branch an interrupted delete kept, asking about the branch a leftover .git file row kept, reverting any one removal file's switch to the two entry points (the test that only allows these two entry points goes red), reading the workspace's saved data only when the review host must be asked, never counting the branch as its own base (the bare-repository tests red), and turning a throw while reading saved data into a kept branch. Earlier rules (merged, exact commit, GitHub containment, GitLab versions, Bitbucket minimum, keep on timeout and on delete failure, the question outside the queue, after the watcher gate) were checked the same way in the first round.

  • Lint and the changed-code quality gate pass.

  • Platforms: macOS unit tests; live app QA on a remote Mac (see Visual Proof).

  • I manually tested these changes locally

  • Automated tests added/updated, or explained why not below

AI Disclosure

Review

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

Ensure no issues in: Security, Cross-platoform support (Linux, Windows, Mac), Remote SSH, Mobile, general backwards compatibility, performance

  • SSH: the relay now runs the local base check instead of its proof; the review-host question runs on this machine.
  • On every platform, when Git refuses the branch delete and no base holds the branch, that workspace stays "Deleting…" until the review host answers (at most 10 seconds); on WSL the wait also holds the next delete in that repository, and after a timeout later deletes there skip the question for a minute (see Timing).
  • Every removal path that ran Git's branch delete, or kept the branch without running it (a workspace Git still lists though its folder is gone or is only a leftover .git file, Windows recovery, a delete interrupted by quitting), now asks the same way; the guarded delete makes that safe whether or not -d ran.
  • Mobile and other clients: no change on the wire.
  • Folder workspaces: not affected (no branch).

Author: @BrennanKB5

Checklist

  • This PR is small and focused
  • I explained what changed and why (ELI5, the user-facing before/after, the mechanism, and why over the alternatives)
  • Before/after screenshots or videos attached for UI changes, or N/A with reason
  • Self-reviewed for correctness, security, and performance
  • Cross-platform, SSH/remote, and path/shortcut impact considered (or N/A)
  • pnpm lint, pnpm typecheck, pnpm test, and pnpm build pass (or CI will cover; local preferred)

…a local merge proof

When `git branch -d` refuses after a worktree delete, Orca no longer runs a
background merge-tree / cherry / patch-id proof (which wrote objects into the
repo and ran practice merges, and fetched with --prune). It asks the review
host once, capped at 10 s and outside the per-repo branch-delete queue, and
deletes the branch at its exact head only when the merged review's head is
that commit (or GitHub/GitLab confirm it belongs to the review). Otherwise
the branch is kept with the existing "branch kept" toast. Never -D.
…head

Orca cuts workspace branches with --no-track, so `git branch -d` compares
them against the main checkout's HEAD, not their base. A workspace with no
commits of its own was refused (and kept with a "local commits" warning)
whenever local main was behind origin/main, the main checkout was on
another branch, or the workspace was opened on someone's review branch.

When -d refuses, check with `merge-base --is-ancestor` whether the head is
in the history of the branch's saved base (branch.<b>.base), the remote's
default branch (origin/HEAD) or HEAD, and if so delete it with the existing
guarded update-ref delete. Local reads only: no fetch, no objects written.
The same shared check runs on the SSH relay. Only a branch no base holds
goes on to the review-host question.
…e every kept branch

Serial delete paths (WSL deletes inside the per-repo acceptance queue,
Workspace Cleanup's one-at-a-time loop, SSH host removal) asked the review
host once per workspace, so a hung host cost 10 s per workspace. After a
lookup hits the 10 s cap, later kept branches in that repo on that host are
kept without asking for 60 s. In memory only, it expires by time; a fast
lookup error does not open it.

Which removal paths asked the review host was decided caller by caller:
stale-registration removals and quit-interrupted removals that Git had
already unregistered kept the branch without asking. settleKeptBranch /
settleKeptSshBranch are now the only route from removal code to the
push-target remote cleanup, every removal path goes through them, and a
ratchet test fails if a removal file calls the cleanup directly. The
worktree metadata read now happens only when a branch was kept.
…adata read

A prunable registration's kept branch is now settled with the review host on the desktop path; a removal whose branch Git deleted reads no worktree metadata.
…ttle check never throw

In a bare repository HEAD names a branch without checking it out, so
neither `git branch -d` nor the checked-out guard refuses it. When HEAD named
the workspace's own branch, the base check compared the branch with itself
and deleted it, even with unpushed commits Git had just refused to drop.
Every base candidate (saved base, origin/HEAD, HEAD) is now resolved through
symbolic refs and dropped when it names the branch itself; a detached HEAD
is pinned to the commit it names.

The review-host check closures are async, so a throw while reading the
workspace's metadata becomes a rejection the settle absorbs: the delete
finishes and the branch is kept. The ratchet test and module comment now
state exactly what the route rule enforces.
…wn ref case-insensitively

A detached HEAD is no branch: counting it deleted a branch whose upstream
does not hold its unpushed commits (Git refuses that delete) while the main
checkout was only looking at it. HEAD now counts only when it names a branch.

On macOS and Windows a ref spelled in another case (`refs/heads/feat` for
`Feat`) opens the branch's own ref file, so the own-ref check is now
case-insensitive. Both changes only keep more branches.
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Review status: ready (head bf821c2)

The problem. After a workspace delete, Orca runs Git's safe branch delete (git branch -d). When Git refused, usually after a squash or rebase merge whose remote branch was already deleted, Orca ran a local "practice merge" proof (git merge-tree, git cherry, patch-id, and an uncapped git fetch --prune). That proof wrote junk objects into the user's repository and could hang on the network.

What changes for you. When you delete a workspace:

  • The branch is still deleted when Git can tell it is merged. That includes a workspace with no commits of its own, even when the main checkout is on another branch, local main is behind, or the workspace was opened on someone else's pull request branch.
  • A squash- or rebase-merged branch is deleted when GitHub, GitLab, Bitbucket, Azure DevOps or Gitea says the pull request merged at exactly that commit.
  • In every other case the branch is kept with the usual "branch kept" message and its "Force Delete Branch" button. That includes being offline, not logged in, or having no review host; before, the local proof could still delete the branch in those cases.
  • Nothing is written into your repository any more.

What the review fixed (3 review rounds, plus a readiness check):

  • P1, found in the PR as first opened: a workspace with no commits of its own could be kept, with a false "to avoid losing local commits" warning. Git compares against whatever the main checkout has checked out, not the branch the workspace came from. Fixed: when Git refuses, Orca first checks locally whether the branch's commit is already in its base (branch.<name>.base, origin/HEAD, or the branch the main checkout is on). This uses git merge-base --is-ancestor: no fetch, nothing written, works on Git 2.25. The same check runs locally and on the SSH relay.
  • P2, introduced by that fix and fixed: in a bare repository whose HEAD names the branch being deleted, the check compared the branch with itself and would have deleted it with unpushed commits. Main's old proof had the same hole. Now a candidate that is the branch itself is never counted. The comparison ignores letter case (macOS and Windows treat feat and Feat as one ref), and a detached HEAD never counts as a base.
  • P2: WSL deletes, Workspace Cleanup and SSH host removal ask the review host one workspace at a time, so a hung host could cost 10 seconds per workspace. Now, after one timeout, that repository skips the question for 60 seconds and keeps the branch. The window lives only in memory and expires by time.
  • P3: every removal path that ends with a kept branch now asks the same way: interrupted-by-quit deletes, Windows recovery, and a workspace that is only a leftover .git file. A test enforces the single route.
  • The review-host check can no longer throw into a delete. The description was rewritten to match the code.

Deferred:

  • Temporary: the "Deleting…" wait. When Git refuses and no base holds the branch (in practice, a squash- or rebase-merged pull request whose remote branch is gone), the workspace card stays "Deleting…" until the review host answers: usually 1-2 seconds, at most 10 seconds. Branch cleanup should not hold up the delete. Follow-up: reply as soon as the workspace is gone, and report the branch outcome afterwards through an event (desktop, mobile and CLI, negotiated so older clients keep working).
  • Share one guarded "delete branch, then remove the fork remote" step between the automatic path and the two "Force Delete Branch" handlers; only the automatic one waits in the per-repository queue today.
  • Eight removal sites that call the shared settle step with an empty result only to reach the fork-remote cleanup.
  • A stale entry for the deleted test in config/scripts/ci-shard-timings.json (harmless).
  • A runtime behavior test for the stale-registration path (covered by the route test only).
  • Whichever of Conflicting PRs: stop running a background merge to list files; the agent's own merge shows them #24266 and this PR lands second deletes src/shared/git-merge-tree-capability.ts, its capability entry, doc row and compat test.
  • Until Conflicting PRs: stop running a background merge to list files; the agent's own merge shows them #24266 lands, a GitHub lookup of an open conflicting pull request can still compute its conflict file list.

Verified:

  • 95 test files including orca-runtime.test.ts: 2264 passed, 2 skipped.
  • Each new rule fails its test when reverted.
  • Node typecheck, oxlint and the changed-code quality gate pass. CI is green.
  • Real-Git tests cover the zero-commit cases, the bare-repository and letter-case cases, a detached HEAD, and an interrupted delete.

Live app on a remote Mac, isolated rig, no credentials:
Results (PR head 3bc6b08 vs main daf63e6):

Case Before (main) After (this PR)
No commits of its own, main checkout on another branch n/a Deleted, no message (plain -d refused; the base check deleted it)
Workspace opened on someone's pull request branch, no commits n/a Deleted, no message
Squash-merged branch, no review host Deleted silently (0.6 s) Kept with "Worktree deleted, branch kept"; "Deleting…" lasted ~0.2 s; "Force Delete Branch" then deleted it
Branch with an unpushed commit n/a Kept with the message
Merged with a merge commit n/a Deleted, no message
Review host says merged n/a BLOCKED: needs a credential in the rig

Loose Git objects stayed at 0 in every case (no practice merges).

after: squash-merged branch kept
after: Force Delete Branch worked
before: squash-merged branch deleted
after: zero-commit workspace deleted
after: PR-review workspace deleted
after: unpushed commit kept
after: merge-commit branch deleted

Worth knowing: when there is no review host, the kept message still says "so Orca kept it to avoid losing local commits". That wording predates this PR, but it now also shows for a squash-merged branch kept offline. Rewording it is a product call, left as a follow-up.

Not verified:

  • A review host actually reporting "merged": that needs a GitHub or GitLab credential in the rig, which our QA rules forbid. It is covered by mocked-review-host tests and real-Git tests.
  • Live SSH, WSL and Windows runs: covered by tests only.
  • QA ran at 3bc6b08. The only later commit narrows the base check (letter case, detached HEAD), and no QA scenario reaches those edges.

@brennanb2025
brennanb2025 marked this pull request as ready for review October 1, 2026 05:49
@greptile-apps

greptile-apps Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 2/5

[Medium risk] Worktree deletion now asks the review host instead of using local merge proofs.

This PR should not merge until branch deletion requires reliable proof that the preserved commits remain available.

Findings

  1. P1 Security Historical GitLab head authorizes deletion ▶
  2. P2 Timed-out lookups keep running ▶

Summary

Replaces local merge proofs during worktree deletion with a local base-ancestry check, then a bounded hosted-review lookup and guarded branch deletion. It also routes local, runtime, and SSH removal results through a common settlement step.

  • GitLab historical heads and stale remote-tracking bases can each authorize deletion without proving the removed commits remain retained.
  • The lookup deadline does not stop underlying provider work.
Diagram
%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Remove worktree] --> B[git branch -d]
  B -->|Succeeds| Z[Finish removal]
  B -->|Refuses| C[Check local base ancestry]
  C -->|Contained| D[Guarded delete at recorded head]
  C -->|Not contained| E[Ask hosted review]
  E -->|Merged head confirmed| D
  E -->|Unconfirmed or timed out| F[Keep branch and offer force delete]
  D --> Z
  F --> Z
Loading

Reviews (1) · Last reviewed commit: "fix(worktrees): base check ignores a det..."

Comment on lines +233 to +240
// Why: a GitLab suggestion or rebase moves the MR head past the local one; any pushed version counts.
const versionHeads = await getMergeRequestVersionHeadShas(
repo.path,
review.number,
hostedReviewSshConnectionId(executionHostId),
{ localGitExecOptions }
)
return versionHeads.includes(head)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 security Historical GitLab head authorizes deletion

If a merge request was force-pushed from commit A to an unrelated commit B and then merged, A can still appear in its version history. This check treats that earlier push as proof A was merged and deletes a preserved branch at A, even though A’s commits may not be retained elsewhere. How this was verified: The deletion decision accepts any historical GitLab version head after checking only that the merge request is merged.

Knowledge Base Used: Workspace and worktree lifecycle

Comment on lines +81 to +95
const answer = await withTimeout<boolean | 'timed-out'>(
confirmMergedAtHead(branchName, head).catch((error: unknown) => {
console.warn(
`[worktrees] Could not ask the review host whether "${branchName}" merged`,
error
)
return false
}),
FORGE_MERGED_LOOKUP_TIMEOUT_MS,
'timed-out'
)
if (answer === 'timed-out') {
// Why only a timeout: a fast error costs the next delete nothing, a hang costs it the cap.
unansweredUntilByReviewHost.set(reviewHostKey, Date.now() + FORGE_UNANSWERED_WINDOW_MS)
return result

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Timed-out lookups keep running

The ten-second deadline finishes branch settlement but does not cancel the review lookup. A slow GitLab versions request can continue running and hold a provider request slot after removal finishes, until its separate execution deadline. This leaves background work consuming capacity despite the timeout and cooldown.

Knowledge Base Used: Source control and reviews

@greptile-apps

greptile-apps Bot commented Oct 1, 2026

Copy link
Copy Markdown

Comments Outside Diff

These findings could not be posted inline.

  • P1 Stale base permits branch deletion src/shared/git-branch-base-containment.ts:355 ▶

    If a remote branch was force-pushed after the last fetch, its local tracking ref can still contain the workspace branch’s head. The new ancestry check then authorizes deletion against that stale ref. When the next fetch updates the ref, the deleted branch’s unmerged commits can become unreachable. Guarding the delete against changes to the workspace branch does not protect against changes to its base.

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

✅ No new issues found.

Reviewed changes
Replaces the local squash-merge proof that ran after a refused git branch -d with a local base-containment check followed by a single review-host lookup.

  • Base containment — new src/shared/git-branch-base-containment.ts walks saved base, origin/HEAD and the main checkout's HEAD, resolved through symbolic refs, dropping the branch's own ref (case-insensitively) and a detached HEAD; one merge-base --is-ancestor per candidate, local reads only. Shared by the local Git layer and the SSH relay.
  • Review-host settlement — new src/main/source-control/forge-merged-branch-cleanup.ts asks once with a 10s cap (settlePreservedBranchWithForge) and deletes only at the exact head, with an in-memory 60s "host did not answer" window keyed by execution host + repo path. settleKeptBranch / settleKeptSshBranch are now the only removal-side route to the fork-remote cleanup.
  • Guarded delete — deletePreservedBranchAtHead shares the per-repo branch-delete queue; every delete still goes through update-ref -d <ref> <oldHead> and refuses a checked-out branch.
  • Removal paths — registered local/remote, unregistered, Windows recovery, interrupted and runtime variants all settle a kept branch; a ratchet test pins the allowed direct callers of the push-target cleanup.
  • GitLab — new getMergeRequestVersionHeadShas reads the MR /versions heads to confirm a head the host advanced past the local one.
  • Docs — the merge-tree-write-tree compatibility row no longer claims branch-cleanup use.

Verified in this run: no data-loss path in the base check (surviving-ref ancestry + guarded update-ref), no dropped or mis-propagated preservedBranch in the migrated callers, and the GitLab reader follows the established glab admission pattern. The behavioral notes (delete can wait up to 10s; temporary dependency on #24266 for the GitHub conflict lookup; mixed-version remote servers keep their old proof) are documented and intended.

Pullfrog  | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@coderabbitai

coderabbitai Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: e5aa4fd8-a8a2-483e-a8b3-c2b21401cb43

📥 Commits

Reviewing files that changed from the base of the PR and between a4606cc and bf821c2.

📒 Files selected for processing (34)
  • docs/reference/git-compatibility.md
  • src/main/git/remove-worktree-branch-cleanup.test.ts
  • src/main/git/worktree-branch-removal.ts
  • src/main/git/worktree-removal-real-git.test.ts
  • src/main/git/worktree-removal.ts
  • src/main/git/worktree.ts
  • src/main/gitlab/merge-request-versions.test.ts
  • src/main/gitlab/merge-request-versions.ts
  • src/main/ipc/worktrees-preserved-branch-fork-remote.test.ts
  • src/main/ipc/worktrees-removal-recovery.test.ts
  • src/main/ipc/worktrees-test-module-mocks.ts
  • src/main/ipc/worktrees/removal/execute-worktree-removal.ts
  • src/main/ipc/worktrees/removal/remove-registered-local-worktree.ts
  • src/main/ipc/worktrees/removal/remove-registered-remote-worktree.ts
  • src/main/ipc/worktrees/removal/remove-unregistered-worktree.ts
  • src/main/project-runtime-git-options.ts
  • src/main/runtime/orca-runtime-remove-managed-worktree.ts
  • src/main/runtime/runtime-interrupted-local-worktree-removal.test.ts
  • src/main/runtime/runtime-interrupted-local-worktree-removal.ts
  • src/main/runtime/runtime-registered-local-worktree-removal.ts
  • src/main/runtime/runtime-registered-remote-worktree-removal.ts
  • src/main/runtime/runtime-removal-forge-settlement.test.ts
  • src/main/runtime/runtime-unregistered-worktree-removal.ts
  • src/main/source-control/forge-merged-branch-cleanup.test.ts
  • src/main/source-control/forge-merged-branch-cleanup.ts
  • src/main/source-control/kept-branch-settlement-route.test.ts
  • src/relay/git-handler-branch-cleanup.test.ts
  • src/relay/git-handler-branch-cleanup.ts
  • src/relay/git-handler-worktree-remove-base-history.test.ts
  • src/relay/git-handler-worktree-remove.ts
  • src/shared/git-branch-base-containment.test.ts
  • src/shared/git-branch-base-containment.ts
  • src/shared/git-branch-cleanup.test.ts
  • src/shared/git-branch-cleanup.ts
💤 Files with no reviewable changes (2)
  • src/shared/git-branch-cleanup.test.ts
  • src/shared/git-branch-cleanup.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 8 remain after this review.


📝 Walkthrough

Walkthrough

Worktree removal now checks whether a branch head is in its base history before using guarded force deletion. If the branch remains preserved, local, WSL, and SSH removal paths can check linked forge reviews and delete the branch only when the review is confirmed merged at that head. GitLab merge-request version heads support head matching. Settlement runs before push-target cleanup.

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to bf821

Orca now decides whether to delete a workspace's branch from local history and confirmation from the review host. If anything is uncertain, it keeps the branch. A retained branch remains recoverable with the existing Force Delete action. No concrete defect blocking merge was found.

Security Architecture Review

Security architecture risk: 🟡 Moderate · up to bf821

GitLab review-version history is treated as proof that an older commit was merged. An older version can contain changes discarded before the final merge, so workspace removal could delete a branch that should remain available for recovery. Exact-head and checkout protections limit the affected branch but do not correct this authorization decision.

Retained concerns

  • Medium · security · inferred: The new GitLab fallback equates presence in any historical merge-request version with successful integration. If head A is force-pushed away and a different head B is merged, a preserved local branch still at A can pass confirmation and be deleted automatically. Expected-head deletion protects against branch movement, but does not establish that A's changes were merged; successful deletion also suppresses the preserved-branch recovery result.
Security review details

Security Blast Radius

  • inferred — The identified exposure is repository- and branch-scoped, not cross-tenant authority. The GitLab decision can affect preserved branches through local or SSH workspace removal and recovery. An adversarial scenario requires control over review-source history and the ability to cause a rewritten version to be merged, followed by removal of the affected workspace.

Security Findings and Attack Paths

  • inferred — A discarded historical GitLab head can cross the boundary from review history into destructive local authorization: version membership returns true, the unchanged local head passes guarded deletion, and settlement removes the recovery indicator. This can remove the preserved branch reference even when its discarded changes were never integrated; permanent loss of all remotely stored copies is not established.

Trust Boundaries and Controls

  • observed — Review lookup carries repository path, repository execution host, linked review identifiers and the preserved head. Local deletion uses an expected-head ref update, refuses existing checkouts, and attempts restoration after a concurrent checkout. These identity and ownership controls constrain deletion but do not prove that a historical review version was merged.

Hardening Proposals

  • proposed — Require proof that an older GitLab head's contents survived into the final merged version. When only historical version membership is available, retain the branch and its recovery result rather than treating that history as automatic deletion authority.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 36.17% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 47 functions across 31 files. (1 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly summarizes the main change: Git safe deletion remains, while local squash-proof logic is replaced by review-host verification.
Description check ✅ Passed The description is detailed and covers the user impact, implementation, rationale, testing, compatibility, and alternatives. The linked issue field does not contain a direct issue link, and some check…
Full details: Docstring Coverage

Explanation

Docstring coverage is 36.17% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 47 functions across 31 files. (1 skipped: 1 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


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.

@brennanb2025
brennanb2025 marked this pull request as draft October 1, 2026 06:36
@brennanb2025

Copy link
Copy Markdown
Contributor Author

Plan change (corrects two items in the status comment above). The conflicting-file list on a conflicting pull request stays exactly as it is: it keeps using git merge-tree, and #22356 keeps its objects out of the user's repository. #24266 is on hold.

@brennanb2025

Copy link
Copy Markdown
Contributor Author

Closing. Asking the review host instead of the local squash-merge check changes which branches get deleted after a workspace is removed, and offline cleanup would stop working. #22356 fixes the throwaway objects that check left behind while keeping the same deletion rule.

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