Skip to content

Guard pr merge --delete-branch against worktree conflicts - #14007

Draft
tidy-dev wants to merge 7 commits into
trunkfrom
tidy-dev-merge-worktree-guards
Draft

Guard pr merge --delete-branch against worktree conflicts#14007
tidy-dev wants to merge 7 commits into
trunkfrom
tidy-dev-merge-worktree-guards

Conversation

@tidy-dev

@tidy-dev tidy-dev commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Closes https://github.com/github/gh-cli-and-desktop/issues/271
Closes #3442

Makes gh pr merge --delete-branch behave safely under git worktrees. Today the local-cleanup half of --delete-branch unconditionally runs git 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-branch check both up front instead of blindly running git checkout <base> + git branch -d:

  1. Is the branch being deleted checked out in a worktree? git refuses to delete a branch that any worktree currently has checked out.
  2. If we're standing on that branch, can we step off it onto the base branch? Deleting the branch you're on first requires 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:

# Situation Blocked by Result
1 cwd is the linked worktree holding the PR head Q1 - head is checked out here, and a worktree can't remove itself Warn + skip local delete
2 PR head is checked out in the main worktree (run from elsewhere) Q1 - head is checked out in main, which can't be removed Warn + skip local delete
3 PR head is in a sibling linked worktree Q1 - but the sibling worktree can be removed Remove worktree, then delete branch
4 On the PR head, but base is checked out in another worktree Q2 - the required git checkout <base> would fail Warn + skip local delete
5 Sibling worktree holding the head is dirty Q1 - worktree can't be removed without discarding uncommitted work Warn + skip, worktree preserved
- No worktree involvement Neither Unchanged: checkout <base> + pull + delete

Cases 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 merge never 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-branch first 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/tmp paths 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.

$ cd /tmp/gh-wt-demo/s0-regular
$ git worktree list
/private/tmp/gh-wt-demo/s0-regular  1a2b3c4 [wtdemo-regular]

$ gh pr merge --squash --delete-branch
✓ Squashed and merged pull request tidy-dev/playground#41 (Demo: regular (non-worktree) merge)
✓ Deleted local branch wtdemo-regular and switched to branch main
✓ Deleted remote branch wtdemo-regular

Case 1 (Q1): head branch checked out in the CURRENT worktree - a worktree can't remove itself, so skip local delete.

$ cd /tmp/gh-wt-demo/s1/wt
$ git worktree list
/private/tmp/gh-wt-demo/s1/repo  1a2b3c4 [main]
/private/tmp/gh-wt-demo/s1/wt    5d6e7f8 [wtdemo-cwd]

$ gh pr merge --squash --delete-branch
✓ Squashed and merged pull request tidy-dev/playground#42 (Demo: head branch in the current worktree)
! Branch wtdemo-cwd is checked out in the current worktree (/private/tmp/gh-wt-demo/s1/wt); skipping local delete
  To finish cleanup, first navigate out of the worktree, then run:
  git worktree remove /private/tmp/gh-wt-demo/s1/wt && git branch -D wtdemo-cwd
✓ Deleted remote branch wtdemo-cwd

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.

$ cd /tmp/gh-wt-demo/s2/side
$ git worktree list
/private/tmp/gh-wt-demo/s2/repo  1a2b3c4 [wtdemo-head-in-main]
/private/tmp/gh-wt-demo/s2/side  5d6e7f8 [wtdemo-s2-side]

$ gh pr merge wtdemo-head-in-main --squash --delete-branch
✓ Squashed and merged pull request tidy-dev/playground#43 (Demo: head branch in the main worktree)
! Branch wtdemo-head-in-main is checked out in the main worktree (/private/tmp/gh-wt-demo/s2/repo); skipping local delete
  To finish cleanup, switch the main worktree off wtdemo-head-in-main, then run:
  git branch -D wtdemo-head-in-main
✓ Deleted remote branch wtdemo-head-in-main

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.

$ cd /tmp/gh-wt-demo/s3/repo
$ git worktree list
/private/tmp/gh-wt-demo/s3/repo     1a2b3c4 [main]
/private/tmp/gh-wt-demo/s3/sibling  5d6e7f8 [wtdemo-head-in-sibling]

$ gh pr merge wtdemo-head-in-sibling --squash --delete-branch
✓ Squashed and merged pull request tidy-dev/playground#44 (Demo: head branch in a sibling worktree)
✓ Removed worktree /private/tmp/gh-wt-demo/s3/sibling
✓ Deleted local branch wtdemo-head-in-sibling
✓ Deleted remote branch wtdemo-head-in-sibling

Case 4 (Q2): base branch checked out in ANOTHER worktree - on the head branch, but git checkout <base> would fail, so skip local delete.

$ cd /tmp/gh-wt-demo/s4/repo
$ git worktree list
/private/tmp/gh-wt-demo/s4/repo  1a2b3c4 [wtdemo-base-elsewhere]
/private/tmp/gh-wt-demo/s4/base  5d6e7f8 [main]

$ gh pr merge --squash --delete-branch
✓ Squashed and merged pull request tidy-dev/playground#45 (Demo: base branch checked out elsewhere)
! Base branch main is checked out in another worktree (/private/tmp/gh-wt-demo/s4/base); skipping local delete
  To finish cleanup, switch off wtdemo-base-elsewhere in another worktree, then run:
  git branch -D wtdemo-base-elsewhere
✓ Deleted remote branch wtdemo-base-elsewhere

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.

$ cd /tmp/gh-wt-demo/s5/repo
$ git worktree list
/private/tmp/gh-wt-demo/s5/repo     1a2b3c4 [main]
/private/tmp/gh-wt-demo/s5/sibling  5d6e7f8 [wtdemo-dirty-sibling]
$ git -C /tmp/gh-wt-demo/s5/sibling status --short
 M scenario-wtdemo-dirty-sibling.txt

$ gh pr merge wtdemo-dirty-sibling --squash --delete-branch
✓ Squashed and merged pull request tidy-dev/playground#46 (Demo: dirty sibling worktree)
! Could not remove worktree /private/tmp/gh-wt-demo/s5/sibling; skipping local branch delete: failed to run git: fatal: '/private/tmp/gh-wt-demo/s5/sibling' contains modified or untracked files, use --force to delete it
✓ Deleted remote branch wtdemo-dirty-sibling

Design decisions

  • linkedWorktreeForBranch() skips worktrees[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 attempting git checkout <base>.
  • len(worktrees) > 1 guard - if only the main worktree exists, no linked worktrees are possible so we skip worktree logic entirely.
  • No --force on git worktree remove - a dirty worktree is reported as a warning rather than forced, preserving uncommitted work (Case 5).
  • Conventional single-working-directory path unchanged - the git checkout <base> + pull + delete path only runs when there's no worktree involvement.

Tests

pkg/cmd/pr/merge worktree scenarios are covered by a single table-driven test (TestPrMerge_deleteBranch_worktrees) with named subtests:

Scenario Behavior
cwd is the head branch's linked worktree Warn + skip local delete
Head branch is in the main worktree (run elsewhere) Warn + skip local delete
Head branch is in a sibling linked worktree Remove worktree + delete branch
Base branch is checked out in another worktree Warn + skip local delete
Dirty sibling worktree Warn, skip branch delete

Additional coverage:

  • TestPrMerge_deleteBranch_noWorktreeConflict - normal single-working-directory repo, existing switch + pull + delete behavior unchanged.
  • TestParseWorktrees (git package) - table-driven unit tests for git worktree list --porcelain parsing: empty output, single/multiple records, detached HEAD, bare main worktree, and no-trailing-blank-line variants.
  • The 9 existing deleteLocalBranch tests were updated with worktree stubs to account for the new git worktree list call.

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>
@tidy-dev
tidy-dev marked this pull request as ready for review July 29, 2026 15:20
@tidy-dev
tidy-dev requested a review from a team as a code owner July 29, 2026 15:20
@tidy-dev
tidy-dev requested a review from babakks July 29, 2026 15:23

Copilot AI 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.

🟡 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 merge local 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 merge tests 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.

Comment thread pkg/cmd/pr/merge/merge.go
Comment thread pkg/cmd/pr/merge/merge_test.go Outdated
Comment thread git/client.go
@tidy-dev
tidy-dev marked this pull request as draft July 30, 2026 13:00
Comment thread git/client.go
tidy-dev and others added 5 commits August 4, 2026 12:04
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>
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 babakks left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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 :copilot: 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.

Comment thread git/objects.go
// 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Let's call it Ref as per docs.

Comment thread pkg/cmd/pr/merge/merge.go
Comment on lines +409 to +410
worktrees, _ := m.opts.GitClient.Worktrees(ctx)
currentWorkdir, _ := m.opts.GitClient.ToplevelDir(ctx)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

In a follow-up we can extract git commands used in pr checkout --worktree. For instance, this TopLevelDir method can be used in there.

Comment thread pkg/cmd/pr/merge/merge.go Outdated
Comment on lines +429 to +431
if len(worktrees) > 0 &&
worktrees[0].Branch == "refs/heads/"+m.pr.HeadRefName &&
worktrees[0].Path != currentWorkdir {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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).

Suggested change
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 {

Comment thread pkg/cmd/pr/merge/merge.go
Comment on lines +409 to +410
worktrees, _ := m.opts.GitClient.Worktrees(ctx)
currentWorkdir, _ := m.opts.GitClient.ToplevelDir(ctx)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Let's now swallow the errors here. This would also secure the length check removal (see the other comment).

Suggested change
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
}

Comment thread pkg/cmd/pr/merge/merge.go Outdated
Comment on lines +429 to +431
if len(worktrees) > 0 &&
worktrees[0].Branch == "refs/heads/"+m.pr.HeadRefName &&
worktrees[0].Path != currentWorkdir {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

question: why can't we simplify this if-statement to:

Suggested change
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>
ieuanign added a commit to ieuanign/skills that referenced this pull request Aug 9, 2026
…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.
ieuanign added a commit to ieuanign/skills that referenced this pull request Aug 9, 2026
…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.
ieuanign added a commit to ieuanign/skills that referenced this pull request Aug 9, 2026
…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`.
ieuanign added a commit to ieuanign/skills that referenced this pull request Aug 9, 2026
…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.
ieuanign added a commit to ieuanign/skills that referenced this pull request Aug 9, 2026
…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.
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.

Support gh pr merge -d with git worktrees

4 participants