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>
…esolved comments `skills/dev-loop-pr-comments/read-comments.mjs` returns a pull request's unresolved comments as one list: thread-anchored review comments, review bodies and issue comments, in a single entry shape a consumer switches on by `origin`. Exclusions are signals, never inferences. A resolved thread goes on GraphQL's own `isResolved`, because REST cannot express it and a guess returns every comment the pull request ever carried; a minimised comment goes because hiding one is a human saying it is dealt with; a PENDING review is unsubmitted and a bodyless review is the envelope around inline comments already collected. Absence is carried, not filled in. An outdated comment keeps `line: null` while its stale `originalLine` travels separately, so *no line* and *line moved* stay distinguishable. Bodies never reach a shell: `gh` is spawned with an argument array, `shell: false`, and its stdout is parsed. All four connections paginate, the nested one by count because `gh --paginate` follows only the first pageInfo in a response, and any failure exits non-zero with no JSON so that an empty list can only mean no unresolved comments. The folder holds the module alone — no SKILL.md, no manifest entry — so nothing installs or invokes it yet. The normaliser is exported and importing it runs no fetch. Verified live, read-only, no mutation issued: cli/cli#14007 → 5 review-thread + 2 review-body entries against an independent query's 9 threads / 4 resolved / 5 unresolved comments / 3 reviews of which 2 carry a body; bodies byte-identical for all 5; two entries with line=null, originalLine=431, outdated=true. cli/cli#14027 → 3 threads all resolved, 2 review bodies, 1 issue comment. cli/cli#14099 with the page size forced to 1 reproduces its full-page output byte for byte, exercising every cursor including the per-thread comment refetch. Missing gh, an unknown pull request, an unresolvable repository and a bad argument each exit non-zero with empty stdout. Deviation: the changeset is hand-written as .changeset/140-read-unresolved-pr-comments.md rather than generated by `npm run changeset`, which names files after human-id words; every tracked changeset here is named <issue>-<slug>.md.
…checked, not just parsed The check suite proved read-comments.mjs PARSED. Everything that makes it safe to point at a pull request — which comments it drops, which fields it refuses to invent, and whether a body survives — was unobserved. `scripts/pr-comment-read.mjs` observes it, at the same seam the state-machine harness uses: the exported pure function, driven over fixtures, spawning nothing and reaching no network. Seven scenarios over the normaliser's public interface, none touching an internal. A resolved thread is excluded whole while an unresolved one is kept whole; a minimised comment goes at all three origins; a bodyless, whitespace-only or PENDING review is not a comment while a review that said something is. An outdated comment keeps `line: null` with `originalLine` beside it, so the scenario fails the moment anything substitutes the stale anchor. A body carrying backticks, `$(...)`, quotes, a backslash, a pipe and a newline comes back byte for byte, through the JSON round trip the reader actually prints. Every entry carries the same eleven keys whatever its origin, and an empty pull request is an empty list. Each scenario was confirmed to FAIL against a deliberately broken normaliser before being kept: dropping the `isResolved` guard admits the resolved thread's two comments, and substituting `originalLine` into `line` turns null into 431. check.sh gains one stage after the bundled-module syntax stage, streaming its own ok/FAIL line per scenario with only its exit code read — the state-machine stage's shape. Verified live, read-only, no mutation issued (the module sends query documents only): cli/cli#14027 → 3 entries (2 review-body, 1 issue-comment) against an independent query's 3 threads all resolved, 5 reviews of which 2 carry a body and 3 are bodyless, 1 issue comment. cli/cli#14007 → 7 entries (5 review-thread, 2 review-body) against 9 threads / 4 resolved / 5 unresolved carrying 5 comments none minimised, 3 reviews of which 2 carry a body, 0 issue comments; both null-line entries report originalLine=431, outdated=true, matching the independent query exactly. Both runs exited 0 with empty stderr and one parseable JSON document on stdout. `npm run check` green, including `claude plugin validate . --strict` with the manifest-unlisted skills/dev-loop-pr-comments/ folder in place.
…te their intent Step 3's table gains a `#` key and a Reason column, and stays one line per comment: what runs past a clause goes to a keyed expansion beneath it. A `disagreed with` row is marked `(!)` — plain ASCII, so it reads the same in a terminal as in GitHub's renderer — and its expansion is mandatory. A fix row's one-clause statement of intent is written once and is the string Step 5 hands the writer. Steps 4, 5 and 10 now render that one definition rather than restating it, and the ledger comment carries it as the copy that outlives the run. AC 8 — live, read-only verification of the classification as written ==================================================================== Comments read through skills/dev-loop-pr-comments/read-comments.mjs. No `gh` write of any kind, no worktree provisioned, no gate answered: this exercised Step 3 only. cli/cli#14007 — "Guard `pr merge --delete-branch` against worktree conflicts", 7 unresolved comments, all 7 classified. Supplied three of the four reasons: - out of scope for this branch — babakks, pkg/cmd/pr/merge/merge.go:410, discussion_r3719732624 ("In a follow-up we can extract git commands used in `pr checkout --worktree`"). Evidence: the separate work is a follow-up extracting the git commands `pr checkout --worktree` runs so they can reuse this branch's `ToplevelDir` — the commenter names it a follow-up himself. - already addressed — babakks, merge.go stale anchor 431 (outdated), discussion_r3719948135 ("**nitpick:** `len(worktrees)` is always `> 0`"). Evidence: ea3d3191f "Simplify main-worktree head check, drop misleading len guard" drops the `len(worktrees) > 0 &&` conjunct the comment calls misleading; merge.go is its only file. Two more rows took the same reason: discussion_r3720203296 (same commit — its new `if` is that comment's suggestion block character for character) and copilot-pull-request-reviewer's review body pullrequestreview-4810038780, whose two stated grounds are both met by d7fe64365 "Handle head branch checked out in main worktree during pr merge cleanup" — it handles that case in `deleteLocalBranch` and adds `TestParseWorktrees` to git/client_test.go, the direct unit tests the review says are missing. - disagreed with (!) — babakks, review body pullrequestreview-4863309209, the proposal to restructure `deleteLocalBranch` around "where is the head branch checked out". Evidence, in full, is the expansion; in short: the reviewer states in the same comment that the scenarios are already covered, so the restructure changes no behaviour, and rewriting the whole cleanup flow inside a pull request that exists to guard one command widens the blast radius of a bug fix. Declined outright rather than deferred, which is what separates it from the `out of scope` row above. Two rows classified fix: discussion_r3719711480 (rename `Worktree.Branch` to `Ref` at git/objects.go:82) and discussion_r3720001340 (return the errors from `Worktrees` and `ToplevelDir` instead of discarding them — still `worktrees, _ :=` at head). More than one fix row, so a real run would stop at Step 4. No comment on 14007 classified `question`: the one opening "**question:** why can't we simplify this `if`-statement" carries a ```suggestion``` block, so it asks for a change, and ea3d3191f made it — `already addressed`, above. cli/cli#13948 — "Add issue field support to gh CLI" — supplied the fourth: - question — zwick, pkg/cmd/issue/create/create.go:102, discussion_r3639448805. Evidence, verbatim: "Can I create an issue with multiple Issue Field Values?" No comment across either pull request came back `unclassified`: every one fit an intent, and every skip's evidence was produced. Deviation: the changeset was hand-written at .changeset/142-skip-reasons.md rather than generated with `npm run changeset`, which names files with a random human-id. Every existing changeset here is named `<issue>-<slug>.md`.
…n resolves to its default A leading `auto` puts the harness in unattended mode, read off the arguments once and carried as one value: no later step re-derives it, and no other argument and no profile key overrides it. Suppression removes Step 4's question and nothing else. Every comment is still classified, the table is still rendered and still written to Step 5's file, and each of the gate's questions resolves to a stated default — every fix row proceeds, a skip stays skipped with its reason and evidence, an unclassified row is reported and acted on by nothing, and a table with no fix row stops the run having provisioned nothing. Step 1's five refusals and Step 6's three profile reads are preconditions rather than gates and fire under both modes, so no unattended default is invented for a profile key. Where the gate asked, an unattended run posts the table on the pull request instead — one rendering of Step 3's table, quoted heredoc, body on stdin. Step 7 now passes the run's real mode; `skillDir` stays absent under both modes, which is the execute phase's documented no-notifier configuration. Verified: npm run check (exit 0); the table rendered from cli/cli#14007's seven unresolved comments through read-comments.mjs, read-only, seven columns on every row; a quoted heredoc carried a body holding backticks, $(...), quotes and $HOME byte for byte with nothing executed.
…on the pull request Step 10 becomes the run's conclusion rather than only its ledger, and under `unattended` it posts on every path past Step 4's table — the ones that ended before the phase was ever dispatched included. That table told the pull request a worktree, these commits and a push were coming, and the run that promised them is the only thing that can say they never came. It is a second comment beside it and never an edit to it, which keeps the two-comment count and the append-only rule one fact rather than two. A run that ended fills what sections it has, leaves out the ones it cannot, adds Step 9's account — label, stage, reason verbatim, diagnosis, attempts — and closes on what nothing else records once this session goes: the kept worktree by path, the kept table file by path, and the run handle. That handle is a derived fact, `$CLAUDE_CODE_SESSION_ID` read once, written in exactly one place and never in a message; unset or empty is a missing line and nothing else about the run changes. The table file's lifetime follows the work rather than the comment alone: deleted on the pushed path once the conclusion carrying its content has posted and never before it, kept beside every worktree that is kept, and named in the comment there — a worktree without the plan its commits were made against is one nobody can pick up. Deviation: the plan's assumption that everything after `start` gets an explanation comment is narrowed to the paths past Step 4's table. A run that ends at Step 2 — a failed read, or no unresolved comments — promised the pull request nothing and posted no table, so its account is its closing message and it writes nothing on somebody else's artifact. Verified: npm run check (exit 0); a conclusion comment for an ended run rendered from cli/cli#14007's seven real unresolved comments through read-comments.mjs, read-only with no gh write and no worktree, then fed through a quoted heredoc byte for byte — backticks, $(id), ${HOME}, quotes and apostrophes intact, nothing executed and nothing expanded; the handle line present with $CLAUDE_CODE_SESSION_ID set and absent rather than empty with it unset; an unconfigured channel silent and exit 0. Not verified here, and reported rather than claimed: acceptance criterion 8's unattended end-to-end run. The harness dispatches the Workflow tool and a dispatched subagent has none, so this lane cannot drive the execute phase.
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.