Guard pr merge --delete-branch against worktree conflicts - #14007
Guard pr merge --delete-branch against worktree conflicts#14007tidy-dev wants to merge 7 commits into
pr merge --delete-branch against worktree conflicts#14007Conversation
Rework the local-cleanup half of --delete-branch to handle git worktrees: Scenario 1 - cwd is the PR head worktree: skip local cleanup entirely and print a warning with manual cleanup instructions, since we cannot safely check out another branch or remove the worktree we are standing inside. Scenario 2 - cwd is not the PR head worktree but a sibling worktree has the branch: remove that worktree via git worktree remove, then delete the branch ref. If removal fails (e.g. dirty worktree), warn and skip rather than exiting non-zero after a successful merge. The conventional single-working-directory path (no worktrees) is unchanged. Remote branch deletion proceeds normally in all cases. Also adds git.Client.WorktreeRemove() helper. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Not ready to approve
There is a confirmed unhandled worktree conflict case that can still cause gh pr merge --delete-branch to exit non-zero due to local cleanup (head branch checked out in the main worktree while running from another linked worktree), and the new worktree parsing logic lacks direct unit tests.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Pull request overview
This PR makes gh pr merge --delete-branch safe to run in repositories using git worktree, avoiding failures or confusing partial cleanup states by detecting when the PR head branch is checked out in a linked worktree and adjusting local cleanup behavior accordingly.
Changes:
- Add git worktree discovery/removal support (
git worktree list --porcelain,git worktree remove) to enable worktree-aware cleanup decisions. - Update
pr mergelocal branch deletion logic to (a) warn+skip when run inside the head branch’s linked worktree, (b) remove a sibling linked worktree before deleting the local branch, while preserving existing behavior for normal repos. - Expand and adjust
pr mergetests to cover new worktree scenarios and stub new git calls.
File summaries
| File | Description |
|---|---|
| pkg/cmd/pr/merge/merge.go | Implements worktree-aware branching of local cleanup and adds helper to locate linked worktrees for a branch. |
| pkg/cmd/pr/merge/merge_test.go | Updates existing delete-branch tests for new git calls and adds new worktree-focused scenarios. |
| git/objects.go | Introduces a git.Worktree struct used to represent parsed worktree entries. |
| git/client.go | Adds Worktrees / WorktreeRemove APIs and parsing logic for git worktree list --porcelain. |
Review details
- Files reviewed: 4/4 changed files
- Comments generated: 3
- Review effort level: Low
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
Guard against git branch -D failing when the PR head branch is checked out in the main worktree while running from a different worktree. Warn and skip local delete instead of exiting non-zero on local cleanup. Also add unit tests for parseWorktrees and simplify its record parsing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
When deleting the PR head branch requires checking out the base branch, git fails if the base branch is checked out in another worktree. Detect that case and warn+skip local delete instead of exiting non-zero, and fix an unrealistic worktree fixture in the no-conflict test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Collapse the five per-scenario worktree deleteBranch tests into a single table-driven test with named subtests, matching the AGENTS.md testing guidance and removing repeated setup boilerplate. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…rge-worktree-guards
Trunk migrated BranchDeleteRemote to safeurl.JoinPath, which escapes the branch ref's slash (heads%2Ffeature). Update the new worktree test stubs to match the encoded path so httpmock.REST matches after merging trunk. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
babakks
left a comment
There was a problem hiding this comment.
Thanks for the PR, @tidy-dev! 🍻 I think my main comment is this one for now (skip the child ones).
I haven't fully reviewed the PR yet (i.e. tests), but wanted to share thoughts with you. Sorry if
is being so verbose.
Just acknowledging the scenarios laid out below are already covered by the current PR. So, this is just about making it easier to follow. This is also why I'm happy to keep it as is if you don't see value in the refactor, or you'd rather see it as a follow-up.
I'm having a hard time following the flow here, and I think I've worked out why and how to improve it. Obviously, the original cleanup logic was written under an assumption that no longer holds: it only ever asked "is cwd currently on the PR head branch?", because in a single working directory a branch can only ever be checked out in that one place. So "I'm on the head branch" and "the head branch is checked out somewhere" were the same statement, and the code could safely do checkout-base then delete.
The worktree scenarios have been layered on top of that same structure, reusing the old checkout-base-then-delete path and bolting guard branches around it. That reuse is what makes this hard to read: the legacy path and the new worktree guards are interleaved, several of the if arms are implicitly coupled (one only runs because an earlier one didn't), and the base branch handling is tangled up with cases that never actually touch the base branch.
Suggestion
I think it's worth stepping back and reassessing the scenarios from scratch. The key realization is that currentBranch == head is just one special case of a single broader question: "where is the PR head branch checked out, relative to cwd?" Keying off that one dimension gives a flat, mutually exclusive set of cases:
| Head branch is checked out... | git constraint in play | Action |
|---|---|---|
| nowhere (ref only) | branch not checked out anywhere, -D succeeds |
delete the ref directly |
| in the current worktree, which is the main worktree | cannot delete the branch we are on, must move off it first | switch to base, pull, delete (the only row that touches base) |
| in the current worktree, which is a linked worktree | cannot repurpose the worktree we are standing in | warn and skip |
| in another linked worktree | must un-check-out before -D, and a linked worktree is removable |
remove that worktree, then delete the ref |
| in the main worktree (we are elsewhere) | cannot remove the main worktree, cannot -D a branch checked out there |
warn and skip |
Every row reduces to the same precondition: make sure no worktree has the head branch checked out, then delete the ref. The base branch logic is only needed in a single row, which is what decouples everything else.
Here is a sketch of how the body could read (not real code):
// ---- Locate the head branch: the single master dimension ----
headWt = worktreeForBranch(worktrees, pr.HeadRefName) // nil if not checked out anywhere
mainWt = worktrees[0] // git always lists main first
// Row 1: head not checked out anywhere -> just delete the ref.
if headWt == nil:
return deleteRefAndReport(switchedTo = "")
// Row 2 & 3: head is checked out in THIS worktree.
if headWt.Path == currentWorkdir:
isMain = (currentWorkdir == mainWt.Path)
// Row 3: linked worktree we are standing in -> cannot clean up safely.
if not isMain:
warnSkip_currentLinked(currentWorkdir)
return nil
// Row 2: main worktree, sitting on head -> legacy "switch off head" path.
// This is the ONLY leaf that touches the base branch.
baseWt = worktreeForBranch(worktrees, pr.BaseRefName)
if baseWt != nil and baseWt.Path != currentWorkdir:
// base busy in another worktree -> checkout would fatal.
warnSkip_baseBusy(baseWt.Path)
return nil
if not switchToBase(): // CheckoutBranch or CheckoutNewBranch + Pull
return nil // warn inside; do not fail the merge
return deleteRefAndReport(switchedTo = pr.BaseRefName)
// Row 4 & 5: head is checked out in ANOTHER worktree.
if headWt.Path == mainWt.Path:
// Row 5: main worktree elsewhere -> cannot remove it, cannot -D.
warnSkip_headInMain(mainWt.Path)
return nil
// Row 4: another linked worktree -> remove it, then delete the ref.
if err = GitClient.WorktreeRemove(headWt.Path); err != nil:
warn("could not remove worktree %s; skipping local delete: %s", headWt.Path, err)
return nil
info("Removed worktree %s", headWt.Path)
return deleteRefAndReport(switchedTo = "")A nice side effect is that currentBranch disappears entirely (locating the head via the worktree list subsumes it and naturally handles detached HEAD), and worktreeForBranch replaces both current helpers. Happy to talk it through if you want to pair on it.
| // Branch is the fully qualified ref checked out in the worktree | ||
| // (e.g. "refs/heads/main"). It is empty when the worktree has a detached | ||
| // HEAD or is the bare main worktree. | ||
| Branch string |
| worktrees, _ := m.opts.GitClient.Worktrees(ctx) | ||
| currentWorkdir, _ := m.opts.GitClient.ToplevelDir(ctx) |
There was a problem hiding this comment.
In a follow-up we can extract git commands used in pr checkout --worktree. For instance, this TopLevelDir method can be used in there.
| if len(worktrees) > 0 && | ||
| worktrees[0].Branch == "refs/heads/"+m.pr.HeadRefName && | ||
| worktrees[0].Path != currentWorkdir { |
There was a problem hiding this comment.
Read the other comment first.
nitpick: len(worktrees) is always > 0 even if there are no worktrees other than the main working directory, or even if the main working directory is a bare clone. So, this part of the check can be misleading (the reader may read it as there are cases where worktrees can be empty).
| if len(worktrees) > 0 && | |
| worktrees[0].Branch == "refs/heads/"+m.pr.HeadRefName && | |
| worktrees[0].Path != currentWorkdir { | |
| if worktrees[0].Branch == "refs/heads/"+m.pr.HeadRefName && | |
| worktrees[0].Path != currentWorkdir { |
| worktrees, _ := m.opts.GitClient.Worktrees(ctx) | ||
| currentWorkdir, _ := m.opts.GitClient.ToplevelDir(ctx) |
There was a problem hiding this comment.
Let's now swallow the errors here. This would also secure the length check removal (see the other comment).
| worktrees, _ := m.opts.GitClient.Worktrees(ctx) | |
| currentWorkdir, _ := m.opts.GitClient.ToplevelDir(ctx) | |
| worktrees, err := m.opts.GitClient.Worktrees(ctx) | |
| if err != nil { | |
| return err | |
| } | |
| currentWorkdir, err := m.opts.GitClient.ToplevelDir(ctx) | |
| if err != nil { | |
| return err | |
| } |
| if len(worktrees) > 0 && | ||
| worktrees[0].Branch == "refs/heads/"+m.pr.HeadRefName && | ||
| worktrees[0].Path != currentWorkdir { |
There was a problem hiding this comment.
question: why can't we simplify this if-statement to:
| if len(worktrees) > 0 && | |
| worktrees[0].Branch == "refs/heads/"+m.pr.HeadRefName && | |
| worktrees[0].Path != currentWorkdir { | |
| if isInLinkedWorktree && worktrees[0].Branch == "refs/heads/"+m.pr.HeadRefName { |
The len(worktrees) > 0 guard was always true since Worktrees() always lists at least the main worktree. Reuse the existing isInLinkedWorktree predicate instead, which already implies len > 1 (keeping worktrees[0] access safe) and folds in the path comparison. Behavior is unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Closes https://github.com/github/gh-cli-and-desktop/issues/271
Closes #3442
Makes
gh pr merge --delete-branchbehave safely under git worktrees. Today the local-cleanup half of--delete-branchunconditionally runsgit checkout <base-branch>then deletes the branch. In a worktree setup this either fails (fatal: '<base>' is already used by worktree at ...) or silently repurposes the worktree, leaving a confusing partial state.The two questions
Deleting a local branch is only safe if git can answer two questions. This PR makes
--delete-branchcheck both up front instead of blindly runninggit checkout <base>+git branch -d:git checkout <base>, which fails if the base branch is itself checked out in another worktree.Every case this PR handles is just a distinct answer to those two questions:
git checkout <base>would failcheckout <base>+ pull + deleteCases 1, 2, 3, and 5 are all variations of Q1 (the head branch is checked out somewhere) - they differ only in where it's checked out and whether that worktree can be safely removed. Case 4 is the odd one out: you're free to delete the head branch, but you can't get off it because Q2 fails.
In all cases
gh pr mergenever exits non-zero solely because local worktree cleanup could not complete. The merge and remote-branch deletion always go through; only the local tidy-up is skipped, with a warning and manual instructions.Demo
The recording runs
gh pr merge --delete-branchfirst in a normal (non-worktree) checkout to show nothing regresses, then walks through all five worktree cases. (Branch names come from the setup script; PR numbers and/private/tmppaths vary per run.)Text transcript of the demo
Baseline: regular (non-worktree) merge - on the PR head branch in a plain checkout. Existing behavior: switch to base, then delete.
Case 1 (Q1): head branch checked out in the CURRENT worktree - a worktree can't remove itself, so skip local delete.
Case 2 (Q1): head branch checked out in the MAIN worktree - run from a sibling; the main worktree can't be removed, so skip local delete.
Case 3 (Q1): head branch in a SIBLING worktree - the sibling is removable, so remove it and delete the branch. No
git checkout <base>needed.Case 4 (Q2): base branch checked out in ANOTHER worktree - on the head branch, but
git checkout <base>would fail, so skip local delete.Case 5 (Q1): DIRTY sibling worktree - the head's worktree has uncommitted work, so it's kept (not force-removed) and local delete is skipped. Note the merge still succeeds and exits 0.
Design decisions
linkedWorktreeForBranch()skipsworktrees[0]- git always lists the main worktree first. By iterating[1:]we only consider linked worktrees, eliminating false positives in normal (non-worktree) repos.worktreeForBranch()scans all worktrees - a companion helper that includes the main worktree, used to detect when the base branch is checked out elsewhere (Q2) before attemptinggit checkout <base>.len(worktrees) > 1guard - if only the main worktree exists, no linked worktrees are possible so we skip worktree logic entirely.--forceongit worktree remove- a dirty worktree is reported as a warning rather than forced, preserving uncommitted work (Case 5).git checkout <base>+ pull + delete path only runs when there's no worktree involvement.Tests
pkg/cmd/pr/mergeworktree scenarios are covered by a single table-driven test (TestPrMerge_deleteBranch_worktrees) with named subtests:Additional coverage:
TestPrMerge_deleteBranch_noWorktreeConflict- normal single-working-directory repo, existing switch + pull + delete behavior unchanged.TestParseWorktrees(gitpackage) - table-driven unit tests forgit worktree list --porcelainparsing: empty output, single/multiple records, detached HEAD, bare main worktree, and no-trailing-blank-line variants.deleteLocalBranchtests were updated with worktree stubs to account for the newgit worktree listcall.