Repository navigation
feat(prs): give the fix menu its worktree root and a Claude fix - #109
Merged
Merged
Conversation
prs's fix menu resolves a conflict in a worktree under worktree_root, and ~/.worktrees puts those beside the ones worktree new adds, so worktree list shows a merge prs left waiting. The c fix runs /make-pr-mergeable on the pull request in a tmux window behind the board, offered only where claude is on PATH, as the review keys are. prs before the release carrying the fix menu refuses both keys, so this waits for that release. Agent-Name: claude-code-unattended Agent-Session: 0ed7f52a-bc57-4308-b45d-2889466add5f Agent-Run: 01a1bbea-ca97-7b16-957f-bd44a01039af Worklog: 1335.1
datapointchris
force-pushed
the
prs-merge-fixes
branch
from
November 8, 2026 18:18
0033ed5 to
147253d
Compare
prs resolves a conflict by rebasing the head in its worktree, named <repo>/pr-<number>-rebase, so the comment on worktree_root names that worktree and what it holds. Agent-Name: claude-code-unattended Agent-Session: 0ed7f52a-bc57-4308-b45d-2889466add5f Agent-Run: 01a1bc6a-675d-78c1-9fa5-bb9bd307fb23 Worklog: 1335.1
datapointchris
added a commit
to datapointchris/prs
that referenced
this pull request
Nov 8, 2026
A pull request GitHub will not merge leaves prs with nothing to offer.
`m` sends the merge, gh refuses it, and the footer prints gh's reason.
The fix then happens somewhere else: a checkout, a browser tab or
another terminal, each found and opened by hand.
This opens a menu of fixes instead. `m` on a row whose merge state says
GitHub refuses it reads the pull request again. Where it is still
blocked, the menu opens rather than the merge question. A merge GitHub
refuses anyway opens the same menu under gh's words, and `f` opens it on
any row. Each choice is one key, and no choice exits prs.
| Blocker | Built-in choices |
| --- | --- |
| Conflicts (`CONFLICTING`, `DIRTY`) | `n` rebase onto the base and
resolve each stop in nvim · `x` close, after asking |
| Behind | `u` `gh pr update-branch --rebase` |
| Failing checks (`UNSTABLE`, or `BLOCKED` with a check failing) | `l`
read the failed log · `r` `gh run rerun --failed` · `m` merge anyway,
`UNSTABLE` only |
| Draft | `r` `gh pr ready` |
| Unknown, or blocked by something else | none; it says what GitHub
reads |
| Any row a rebase's worktree is left for | `p` push a finished rebase ·
`a` abort a stopped one, or remove the worktree |
`merge_fixes` in the config adds choices of your own per blocker kind.
Each runs in a tmux window opened behind the board, and the row reads
its `row_reads` while that window is open.
## What to look at
- `internal/board/rows.go`, `mergeMeanings` and `mergeStateOf`: one
table says what each merge state means, its word and the blocker the
menu offers fixes for. `blockerOf` in `internal/board/fix_menu.go` is a
lookup in it plus the failing-checks rule. A draft wins over a conflict,
and a conflict over everything else.
- `internal/pullrequests/rebase_worktree.go`, `Start`: the head branch
is fetched into `origin/<branch>`, and the worktree is added there,
detached. A registered worktree with a rebase stopped, or finished and
not pushed, is kept as it is. One on a branch is refused, and one with
neither must be clean before `checkout --detach`. A path that is not a
worktree is refused rather than replaced.
- `internal/pullrequests/rebase_worktree.go`, `Continue`: `git rebase
--continue` either stops at the next commit, finishes, or stops where
git cannot go on with nothing unmerged. That last one stays stopped,
with git's words.
- `internal/pullrequests/rebase_worktree.go`, `Push`: the lease is
`--force-with-lease=refs/heads/<branch>:<ORIG_HEAD>`, the head the
rebase started from. git's `--force-if-includes` reads the reflog of a
local branch named for the head, and a detached worktree pushes from
none. So git refused that push even where nobody else had pushed.
- `internal/pullrequests/pullrequest.go`, `runFor`: every client runs
with `GIT_EDITOR=true`. Without it, `rebase --continue` waits on an
editor in a session with no terminal.
- `internal/pullrequests/rebase_worktree.go`, `ConflictingFiles`: `git
merge-tree --write-tree --name-only` exits 1 both for a conflict and for
a ref it cannot read. The tree id on stdout's first line is what tells a
conflict apart.
- `internal/config/config.go`, `quotedPlaceholders`, `FillCommand` and
`insideQuotes`: a placeholder declared in `quotedPlaceholders`
(`{path}`, `{worktree}`, `{head}`, `{base}`, `{log}`) is quoted for the
shell, and a command that puts one inside quotes of its own is refused
at load.
- `internal/board/actions.go`, `mergeOf`, `merge` and `mergeWork`: every
merge key starts the merge `mergeOf` pins. A refused merge reads the
pull request again and opens the menu, except where the refusal was a
timeout or the host has no merge state.
- `internal/board/tmux_window_watch.go`: the tmux poll moved out of
`review_rounds.go`, and serves both review windows and fix windows.
## How it was verified
`go test -count=1 ./...` passed, with `cmd/prs` in 11.9s. Two tests
drive the built binary through tuidrive against the stand-in GitHub, gh,
git, tmux and nvim:
-
`TestARefusedMergeOpensTheFixMenuOnItsConflictsWhichRebaseInAWorktreeAndPush`
starts with a merge that reads ready and is refused by gh. The second
read finds it conflicting, and the test checks that the menu opens under
gh's words, naming both conflicting files. The test then walks the
rebase:
1. `n` fetches
`+refs/heads/cache-metrics:refs/remotes/origin/cache-metrics` and adds a
detached worktree there at `pr-13-rebase`. It runs `rebase --quiet
origin/add-cache` and opens nvim in the worktree.
2. With one path still unmerged, `c` opens `new-window -d` with the
worktree handed over, and the row reads `claude fixing`.
3. With the stop resolved, `f` and `n` reopen nvim, then run `rebase
--continue`. The stand-in rebase stops once more, and nvim opens again
at that stop, three nvim runs in all.
4. Once the rebase finishes, the push question lists the commit. Enter
pushes with `--force-with-lease=refs/heads/cache-metrics:<head>` and
removes the worktree.
5. Last, a rebase is left stopped while the pull request reads ready.
`f` offers `a`, which runs `rebase --abort` and removes the worktree.
-
`TestTheFixMenuUpdatesABranchReadiesADraftAndReadsOrRerunsFailingChecks`
covers `u` on a branch behind, which runs `update-branch --rebase`, and
`r` on a draft. Then it covers `l`, `r` and `c` on failing checks. Each
step checks the arguments the stand-in gh or tmux received.
Two assertions were proved able to fail:
- With `Fill` in place of `FillCommand`, the conflict test fails on the
missing `FIX_LOG=''`.
- With the next stop offering the menu rather than opening nvim, the
conflict test fails waiting for the second stop's unmerged path.
Three scratch checks ran once and were deleted:
- **The rebase, against real git.** The setup is a bare origin, and a
two-commit branch that conflicts with its base at each commit.
`RebaseWorktree` ran with `GIT_EDITOR=false` in the environment:
- `Start` stopped at the first commit, and `Continue` stopped at the
second.
- The next `Continue` finished, and `ORIG_HEAD` was the head the rebase
started from.
- A second clone then pushed to the branch, and `Push` was refused with
`stale info`. The rebase stayed in the worktree.
- `Abort` removed it. A new `Start` rebased the newest head, and `Push`
put it on origin over the other clone's commit.
- Last, a stopped rebase aborted cleanly.
- With `GIT_EDITOR=true` taken out of `runFor`, `Continue` failed on
`there was a problem with the editor 'false'`.
- **A fork.** A listing marking #13 cross-repository opens its menu with
the fork line and only `c` and `x`.
- **The quoting.** `insideQuotes` was checked on six commands.
`FillCommand` was checked on a branch named `a;b'c`, and on one named
`x;rm {number}`, which fills as one quoted word and is not filled again.
`gh api graphql` confirmed `isCrossRepository` on this pull request.
## What changes
- `m` on a blocked GitHub row opens the menu rather than asking to
merge, after one more read of the pull request. A ready row asks to
merge, and so does every Bitbucket row.
- `f` is a new key: the fix menu on the row under the cursor, after one
read of the pull request.
- A merge gh refuses opens the menu with gh's words at its top, where
the new read finds a blocker.
- Resolving a conflict adds a worktree at `<worktree_root>/<checkout's
directory>/pr-<n>-rebase`, detached at the head branch. `worktree_root`
defaults to `$XDG_DATA_HOME/prs/worktrees`. The worktree goes once its
rebase is pushed or aborted. Until then every menu on that row names it,
and offers `p` for a finished rebase and `a`.
- `n` moves `origin/<branch>` in the checkout, as the person's own `git
fetch` would.
- The push rewrites the head branch, with a lease on the head the rebase
started from, so it fails where anyone pushed since. A refused push
leaves the rebase in the worktree, and the footer names git's
refused-ref line.
- A conflict whose head branch is in a fork gets no `n`, and the menu
says why.
- `u` rebases the branch on GitHub, through `gh pr update-branch
--rebase`.
- Every client prs runs has `GIT_EDITOR=true`.
- A rerun goes on past a run that fails, and the footer names each run
that reran.
- `merge_fixes` and `worktree_root` are new config keys. `prs config
show` prints the worktree root and each fix, offered, refused, or not
offered for a missing program. `prs config path --help` documents both.
- A window action's command now fills through `FillCommand`. Its
placeholders are `{number}`, `{repo}` and `{slug}`, none quoted, so an
existing command fills unchanged.
- The failing log is kept at `$TMPDIR/prs-failing-log-<repo>-<n>.txt`,
which `{log}` names.
- gotermboard is pinned at the head of datapointchris/gotermboard#16,
which adds `Question.Choices` and `OpenWindowBehind`, until that
releases.
## Decisions, and what they rejected
- **A conflict and a branch behind both catch up by rebasing.**
*Rejected:* merging the base in, which the design first chose. The
design's owner settled on rebasing after the review, because a branch's
later catch-up rebases. A rebase over a merge of the base drops that
merge, and its conflicts must then be resolved a second time. The force
push is the rebase, and the lease keeps it from overwriting anyone's
commits.
- **The lease names its commit, `ORIG_HEAD`.** *Rejected:*
`--force-with-lease --force-if-includes`. From a detached worktree with
no local branch of the head's name, real git refused that push with
`remote ref updated since checkout` although nobody had pushed. A lease
on the head the rebase started from refuses exactly where someone pushed
since, and needs no local branch.
- **The worktree is detached at the head branch rather than on it.**
*Rejected:* `git worktree add <path> <branch>`. The branch is often
checked out in another worktree, where git refuses a second, and a local
branch can hold commits nobody pushed.
- **It is named `pr-<n>-rebase`.** A tool keeping its own worktree of a
pull request beside it more likely names it `pr-<n>`, and `Start` would
otherwise move that one off its branch.
- **Enter means yes on the push question.** The person just resolved the
rebase, and the lease refuses the push where it would overwrite someone
else's commit.
- **`m` reads the pull request again before choosing the menu over the
merge.** *Rejected:* deciding on the listing's merge state, which can be
minutes old. GitHub often reads `UNKNOWN` or `BEHIND` for a moment after
a push.
- **`UNSTABLE` with checks failing offers "merge anyway".** GitHub
merges an `UNSTABLE` pull request, because no failing check is required,
and prs merged those before this change. Only `BLOCKED` takes the merge
away.
- **Paths and branches are quoted, and a command may not quote them
itself.** *Rejected:* filling every value bare, which lets a branch
named `x;rm -rf ~` run its tail. *Rejected:* quoting every value, which
breaks `'/review-pr {number}'`, the shape existing commands take.
Quoting only what comes from outside prs, and refusing a quoted
placeholder, keeps both.
- **The worktree root is config and a default, with no environment
variable.** A tool implements the layers it needs, and a default may not
name a path outside the tool's own XDG directories. A machine that keeps
its worktrees elsewhere says so in `worktree_root`.
- **Fix windows open behind the board.** *Rejected:* `OpenWindow`, which
takes the terminal away from the board as soon as a fix starts.
- **Smallest change that would work**: a menu with only update-branch
and ready. A conflict is the commonest blocker on a stacked branch, and
the design asks for its files by name and for resolving it in nvim.
## Risk and rollback
A release ships the binary, and a revert removes the menu and the two
config keys. A config naming `merge_fixes` or `worktree_root` is refused
by any release before this one, because prs refuses unknown keys. So a
config change adding them waits for this release. A pushed rebase
rewrites the head branch, and GitHub keeps review comments on the old
commits as outdated. A revert leaves any worktree prs added under
`worktree_root`. `git worktree list` in the checkout names each one, and
`git worktree remove` takes it.
## The review
Round 1:
#17 (comment)
- `system-design.1` — fixed. The row fragment reads `isCrossRepository`,
carried as `HeadInFork` and kept out of `--json`, because Bitbucket's
listing does not say. A conflict whose head is in a fork gets no `n`,
and its menu says why. Configured fixes and `x` stay.
- `failure-paths.1` — fixed — same as `G1.1`.
- `failure-paths.2` — fixed. `rerunFailed` goes on past a run that
fails, and the footer names each run that reran, then each failure.
- `stale-prose.1` — fixed. The README points at `prs config path --help`
for the blocker kinds and at `prs config show` for the menu's keys. The
comment on `blocker` no longer counts its kinds.
- `stale-prose.2` — fixed — same as `G1.4`.
- `code-architecture.1` — fixed. Each `mergeMeanings` entry carries its
`blocker`, `checksBlock` and `mergesFailing`. `mergeStateOf` holds the
draft and conflict override for both `stateMeaning` and `blockerOf`, and
merge anyway reads `mergesFailing`.
- `code-architecture.2` — fixed. The quoted placeholders are a set of
their own, `quotedPlaceholders`, which the merge fix's set joins.
Declaring a placeholder in one set or the other decides whether it is
quoted, and `ShellQuotedPlaceholders()` names that set.
- `code-architecture.3` — rejected. The kinds are the config's
vocabulary: the words a file writes, refused at load and handed to
`{blocker}`. The board's kinds are defined from the config's constants,
so each word lives once. `unknown` and `held` are the board's alone,
because no fix is offered for them. With `code-architecture.1` fixed, a
new kind costs a constant, a `mergeMeanings` entry and a `fixesFor`
case, and the help text derives the list.
- `code-architecture.4` — fixed. `askFix` opens no menu offering one key
twice, and names both choices on the footer. So a built-in key
`menuKeys` misses shows, rather than hiding a choice.
- `code-architecture.5` — fixed. `mergeOf` does the provider check, the
refusal and the pin, and `askMerge`, merge anyway and the batch each
call it.
- `contracts.1` — rejected, for the variable. The help and README
example now read `~/.worktrees`. The standard the finding cites has a
tool implement the layers it needs, and forbids a default outside the
tool's own XDG directories. `WORKTREE_ROOT` belongs to another tool, and
prs reads nothing of one machine's setup. A machine whose worktrees live
elsewhere names the place in `worktree_root`, as
datapointchris/dotfiles#109 does.
- `fleet-patterns.1` — fixed. The design's owner settled on rebasing
after the round. `n` rebases the head onto the base in its worktree, and
`u` runs `gh pr update-branch --rebase`.
- `fleet-patterns.2` — fixed. The worktree is `pr-<n>-rebase`, and
`Start` leaves alone a worktree it finds on a branch. The default root
stays under prs's own data directory, for the reason `contracts.1`
gives.
- `fleet-patterns.3` — deferred. One quoter shared across tools, and
replacing the copies in other repos, is work outside this repo, filed
where those repos' work is tracked. prs's copy is the shape the others
should match.
- `G1.1` — fixed. A refused push leaves the finished rebase in the
worktree, and `p` pushes it again. `failureLine` makes the footer name
git's refused-ref line, such as `! [rejected] HEAD -> feat (stale
info)`, rather than `To <remote>`. Real git confirmed both, as "How it
was verified" says.
- `G1.2` — fixed. Every menu finds the worktree, whatever blocks the
pull request or where nothing does. `a` aborts its rebase and removes
it, and `p` pushes a finished one. `f` on a ready row reads the pull
request again to find one. The conflict test covers it.
- `G1.3` — fixed. The step presses `m`, waits for the merge question,
then presses enter.
- `G1.4` — fixed. "as before" is gone, and the key list points at `prs
config show`. The sentence on a waiting rebase now covers a refused
push.
- `synthesis.1` — fixed — same as `code-architecture.1`,
`code-architecture.2` and `code-architecture.4`. `code-architecture.3`
is rejected, as its line says.
- `synthesis.2` — fixed — same as `fleet-patterns.2`.
- `synthesis.3` — fixed — same as `G1.1`.
Session 0ed7f52a-bc57-4308-b45d-2889466add5f wrote this pull request.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
prs is gaining a menu of fixes for a pull request GitHub will not merge (datapointchris/prs#17). Its conflict fix needs a worktree root, and its Claude choice needs declaring, the way the review keys are. Without this file the menu still works on prs's defaults: worktrees under
$XDG_DATA_HOME/prs/worktrees, and no Claude choice.This sets
worktree_rootto~/.worktreesand adds amerge_fixesentry. Keycruns/make-pr-mergeable {number} {blocker}in a tmux window behind the board, for conflicts, a branch behind and failing checks. It is offered only whereclaudeis on PATH.What to look at
configs/common/.config/prs/config.yml.tmpl: thecommandkeeps{number}and{blocker}inside single quotes. prs fills those two bare, and refuses a command that quotes a path or branch placeholder.make-pr-mergeable's argument hint is<pr number> [conflicts | behind | failing-checks] [repo], and the window opens in the checkout, so the repo needs no argument.How it was verified
The template, rendered with this machine's registry, loads in prs built from datapointchris/prs#17.
prs config showprinted:The installed prs 0.15.0 refuses the same file, with
field worktree_root not found in type config.fileandfield merge_fixes not found in type config.file.What changes
cin the fix menu opens a Claude window running/make-pr-mergeable, and the row readsclaude fixingwhile that window is open.~/.worktrees/<repo>/pr-<number>-rebase, whichworktree listshows until the rebase is pushed or aborted.Decisions, and what they rejected
~/.worktrees, literal, rather than a template value.worktreereads$WORKTREE_ROOTand falls back to~/.worktrees, and no machine sets$WORKTREE_ROOT, so no flag exists to render from.draftin the fix's blockers. The menu's ownrreadies a draft throughgh pr ready, andmake-pr-mergeablenames no draft case.Risk and rollback
This merges only after the prs release carrying the fix menu is installed. On an older prs, every
prscommand refuses this config at load. A revert removes the keys, and prs falls back to its defaults.Session 0ed7f52a-bc57-4308-b45d-2889466add5f wrote this pull request.