Skip to content

feat: open a menu of fixes on a pull request GitHub will not merge - #17

Merged
datapointchris merged 4 commits into
mainfrom
prs-fix-menu
Nov 8, 2026
Merged

datapointchris merged 4 commits into
mainfrom
prs-fix-menu

Conversation

@datapointchris

@datapointchris datapointchris commented Nov 8, 2026 •

Copy link
Copy Markdown
Owner

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 feat: draw each pull request as two lines, ruled apart and spread across the width #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 feat: a question can be a menu answered on one key, and a window can open behind the board 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 feat(prs): give the fix menu its worktree root and a Claude fix 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.

@datapoints-fleet-automation datapoints-fleet-automation Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

PR review — system-design

Used: Cloned PR 17 at its head. Read merge_worktree.go, the fix_menu.go resolve and push paths, fetch.go, and the GitHub listing fields. Ran no code against the change.
Standards: No standards cited. I ran no fleet standards search, because the one finding is a direct trust-boundary defect.
Findings: 1 correctness · 0 design · 0 prose

Correctness

system-design.1 internal/pullrequests/merge_worktree.go:168 · internal/board/fix_menu.go:181 · internal/pullrequests/github.go:67

Reach: A user with write access to the base repo opens a fork pull request that GitHub marks DIRTY. The fork's head branch is named main, which is the default for many forks. The user presses f, picks the merge fix, resolves, and confirms the push. git push origin HEAD:refs/heads/main then runs against the base repo. The merge commit contains origin/main, so the push is a fast-forward and it succeeds. If branch protection is off, the fork's unreviewed code lands on upstream main. Where the fork's branch is named feature, the push creates a stray feature branch on the base repo instead, and the pull request is not updated.

The push target ignores which repo owns the head branch.

CommitAndPush pushes to origin as HEAD:refs/heads/<pr.Branch>. pr.Branch comes from headRefName. The head is fetched via the provider's Head(pr) ref, which is the pull request's own head ref, so it works for forks. The push does not follow it.

The code never reads isCrossRepository, headRepository or any fork flag. I searched the tree for fork, isCrossRepository and headRepository. Nothing matches outside this diff.

The contract on the far side is that headRefName names a branch in the head repo. For a fork, that repo is not origin. The fix menu opens for every row blockerOf names, so fork pull requests reach it.

Fix: Read isCrossRepository and the head repository in the GitHub listing and carry them on PullRequest. For a cross-repository pull request, do not offer the commit-and-push step. Leave the merge in the worktree and say so. Or push to the head repository's URL, and only where maintainerCanModify allows it. Bitbucket needs the same check.

Findings as JSON
{
  "used": "Cloned PR 17 at its head. Read merge_worktree.go, the fix_menu.go resolve and push paths, fetch.go, and the GitHub listing fields. Ran no code against the change.",
  "standards": "No standards cited. I ran no `fleet standards search`, because the one finding is a direct trust-boundary defect.",
  "findings": [
    {
      "number": "system-design.1",
      "section": "correctness",
      "sites": [
        "internal/pullrequests/merge_worktree.go:168",
        "internal/board/fix_menu.go:181",
        "internal/pullrequests/github.go:67"
      ],
      "reach": "A user with write access to the base repo opens a fork pull request that GitHub marks DIRTY. The fork's head branch is named `main`, which is the default for many forks. The user presses `f`, picks the merge fix, resolves, and confirms the push. `git push origin HEAD:refs/heads/main` then runs against the base repo. The merge commit contains `origin/main`, so the push is a fast-forward and it succeeds. If branch protection is off, the fork's unreviewed code lands on upstream `main`. Where the fork's branch is named `feature`, the push creates a stray `feature` branch on the base repo instead, and the pull request is not updated.",
      "body": "**The push target ignores which repo owns the head branch.**\n\n`CommitAndPush` pushes to `origin` as `HEAD:refs/heads/<pr.Branch>`. `pr.Branch` comes from `headRefName`. The head is fetched via the provider's `Head(pr)` ref, which is the pull request's own head ref, so it works for forks. The push does not follow it.\n\nThe code never reads `isCrossRepository`, `headRepository` or any fork flag. I searched the tree for `fork`, `isCrossRepository` and `headRepository`. Nothing matches outside this diff.\n\nThe contract on the far side is that `headRefName` names a branch in the head repo. For a fork, that repo is not `origin`. The fix menu opens for every row `blockerOf` names, so fork pull requests reach it.\n\n**Fix:** Read `isCrossRepository` and the head repository in the GitHub listing and carry them on `PullRequest`. For a cross-repository pull request, do not offer the commit-and-push step. Leave the merge in the worktree and say so. Or push to the head repository's URL, and only where `maintainerCanModify` allows it. Bitbucket needs the same check."
    }
  ]
}

This review does not gate merge.

@datapoints-fleet-automation datapoints-fleet-automation Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

PR review — failure-paths

Used: Read the diff, fix_menu.go and merge_worktree.go at head. Traced each git, gh and tmux call to its failure outcome. I could not run the push-failure repro, because a hook blocks git merge in this sandbox.
Standards: Searched fleet standards search for "push fails after commit leaves local state, error message promises recovery". The nearest hit was help.md "An error message names what failed, what was expected, and the command that fixes it". No code-quality.md rule on partial results came up.
Findings: 2 correctness · 0 design · 0 prose

Correctness

failure-paths.1 internal/pullrequests/merge_worktree.go:163 · internal/pullrequests/merge_worktree.go:78 · internal/board/fix_menu.go:604

Reach: A push to the head branch is refused after the merge commit is made. Cause: someone else pushed to the branch since the fetch, or a hook or network failure. The user then presses f, then n. Traced only. I could not run it, because a hook blocks git merge here.

A failed push throws away the user's conflict resolution, and the message says it will be reopened.

CommitAndPush runs git commit first and git push second. When the push fails, the commit already exists. MERGE_HEAD is gone, because the commit consumed it.

commitAndPush's failed handler (fix_menu.go:604) then says "the merge waits in , and f, then n, opens it again".

The recovery path does not match that. f then n calls Start, which finds the worktree registered, with no merge under way and a clean git status. It takes the registered branch, which runs git checkout --detach <fresh head>. That discards the resolved merge commit, which is now unreferenced. Start then re-merges from scratch and the user resolves the conflicts again.

This is most likely when the push is refused because someone pushed to the branch meanwhile. The message promises the resolved state and the code deletes it without telling the user.

conflictsOf also reports waiting as false in this state, because Merging checks MERGE_HEAD. The menu therefore never shows a pending merge.

Fix: on push failure, say the merge is committed locally and unpushed, and offer to retry the push. Alternatively make Start detect a worktree whose HEAD is ahead of the head, and refuse or offer a push rather than resetting it. Alternatively git reset --soft HEAD^ after a failed push, to restore the merge-in-progress state the message describes.

failure-paths.2 internal/board/fix_menu.go:521 · internal/board/fix_menu.go:511

Reach: A pull request has two or more failing GitHub Actions runs, and a later gh run rerun fails, for example from a rate limit, a permission error, or a run that is still in progress. Traced only.

A rerun that fails partway reports one error and does not say which runs were rerun.

rerunFailed loops over the runs and returns on the first failure. Earlier runs were already rerun on GitHub. The failed handler is the default, so it shows only the single run's error.

The user does not learn that runs 1..k-1 were restarted. The checks column changes after the next read with no explanation. A retry via r re-reruns the earlier runs.

Fix: keep going through the remaining runs. Collect the failures per run. Report which runs were rerun and which were not. A failure should cost only the run that failed.

Findings as JSON
{
  "used": "Read the diff, fix_menu.go and merge_worktree.go at head. Traced each git, gh and tmux call to its failure outcome. I could not run the push-failure repro, because a hook blocks `git merge` in this sandbox.",
  "standards": "Searched `fleet standards search` for \"push fails after commit leaves local state, error message promises recovery\". The nearest hit was help.md \"An error message names what failed, what was expected, and the command that fixes it\". No code-quality.md rule on partial results came up.",
  "findings": [
    {
      "number": "failure-paths.1",
      "section": "correctness",
      "sites": [
        "internal/pullrequests/merge_worktree.go:163",
        "internal/pullrequests/merge_worktree.go:78",
        "internal/board/fix_menu.go:604"
      ],
      "reach": "A push to the head branch is refused after the merge commit is made. Cause: someone else pushed to the branch since the fetch, or a hook or network failure. The user then presses `f`, then `n`. Traced only. I could not run it, because a hook blocks `git merge` here.",
      "body": "**A failed push throws away the user's conflict resolution, and the message says it will be reopened.**\n\n`CommitAndPush` runs `git commit` first and `git push` second. When the push fails, the commit already exists. `MERGE_HEAD` is gone, because the commit consumed it.\n\n`commitAndPush`'s `failed` handler (fix_menu.go:604) then says \"the merge waits in <path>, and f, then n, opens it again\".\n\nThe recovery path does not match that. `f` then `n` calls `Start`, which finds the worktree registered, with no merge under way and a clean `git status`. It takes the `registered` branch, which runs `git checkout --detach <fresh head>`. That discards the resolved merge commit, which is now unreferenced. `Start` then re-merges from scratch and the user resolves the conflicts again.\n\nThis is most likely when the push is refused because someone pushed to the branch meanwhile. The message promises the resolved state and the code deletes it without telling the user.\n\n`conflictsOf` also reports `waiting` as false in this state, because `Merging` checks `MERGE_HEAD`. The menu therefore never shows a pending merge.\n\n**Fix:** on push failure, say the merge is committed locally and unpushed, and offer to retry the push. Alternatively make `Start` detect a worktree whose HEAD is ahead of the head, and refuse or offer a push rather than resetting it. Alternatively `git reset --soft HEAD^` after a failed push, to restore the merge-in-progress state the message describes."
    },
    {
      "number": "failure-paths.2",
      "section": "correctness",
      "sites": [
        "internal/board/fix_menu.go:521",
        "internal/board/fix_menu.go:511"
      ],
      "reach": "A pull request has two or more failing GitHub Actions runs, and a later `gh run rerun` fails, for example from a rate limit, a permission error, or a run that is still in progress. Traced only.",
      "body": "**A rerun that fails partway reports one error and does not say which runs were rerun.**\n\n`rerunFailed` loops over the runs and returns on the first failure. Earlier runs were already rerun on GitHub. The `failed` handler is the default, so it shows only the single run's error.\n\nThe user does not learn that runs 1..k-1 were restarted. The checks column changes after the next read with no explanation. A retry via `r` re-reruns the earlier runs.\n\n**Fix:** keep going through the remaining runs. Collect the failures per run. Report which runs were rerun and which were not. A failure should cost only the run that failed."
    }
  ]
}

This review does not gate merge.

@datapointchris datapointchris left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

PR review — stale-prose

Used: I cloned PR 17 at 7d607f0 and read every sentence it adds in README.md, CLAUDE.md, the prs config path help text, the Go comments, the error messages and the menu labels. I checked each one against the code at the head. I built prs and ran prs config show against a scratch config whose merge fix sits on key a. It printed refused, key "a" is the fix menu's own, and it keeps n x u l r m a and esc.
Standards: I applied pull-requests.md/Sentences the diff adds are checked for what goes stale, and the finding carries the rewrite. fleet standards search "value the code holds written out in docs goes stale" also surfaced documentation.md/A comment describes the code as it is, never the change or history that produced it.
Findings: 0 correctness · 0 design · 2 prose

Prose

stale-prose.1 README.md:346 · README.md:337 · internal/board/fix_menu.go:23

These sentences copy values the code holds. Each is true at the head. The next change to the list or the constants makes it wrong, and nothing ties it back.

  • README.md:346: "A fix may not take a key the menu offers itself: n, x, u, l, r, m or a." This copies menuKeys in internal/board/fix_menu.go:77. The sentence also leaves out esc and ctrl+c, which RefusedFix refuses too. prs config show already prints the live list: on a fix keyed a it returned refused, key "a" is the fix menu's own, and it keeps n x u l r m a and esc.
    Rewrite: "A fix may not take a key the menu offers itself, and prs config show says which keys those are."
  • README.md:337: "blockers names some of conflicts, behind, failing-checks and draft." This copies config.BlockerKinds. The help text for prs config path already builds the same list from strings.Join(config.BlockerKinds, ", ").
    Rewrite: "blockers names the kinds of blocker the fix is offered for, which prs config path --help lists."
  • internal/board/fix_menu.go:23: "The first four are the kinds a merge fix names in its blockers and receives in {blocker}." The count and the order both go stale when a kind is added or a constant moves.
    Rewrite: "The ones taken from config are the kinds a merge fix names in its blockers and receives in {blocker}."

stale-prose.2 README.md:267

This sentence narrates the change. README.md:267 says "so a Bitbucket row has no menu, and m there asks to merge as before." The phrase "as before" points at the repo's state before this PR. A reader of the README has no "before".

Rewrite: "so a Bitbucket row has no menu, and m there asks to merge."

Findings as JSON
{
  "used": "I cloned PR 17 at 7d607f0 and read every sentence it adds in README.md, CLAUDE.md, the `prs config path` help text, the Go comments, the error messages and the menu labels. I checked each one against the code at the head. I built `prs` and ran `prs config show` against a scratch config whose merge fix sits on key `a`. It printed `refused, key \"a\" is the fix menu's own, and it keeps n x u l r m a and esc`.",
  "standards": "I applied `pull-requests.md/Sentences the diff adds are checked for what goes stale, and the finding carries the rewrite`. `fleet standards search \"value the code holds written out in docs goes stale\"` also surfaced `documentation.md/A comment describes the code as it is, never the change or history that produced it`.",
  "findings": [
    {
      "number": "stale-prose.1",
      "section": "prose",
      "sites": [
        "README.md:346",
        "README.md:337",
        "internal/board/fix_menu.go:23"
      ],
      "reach": "",
      "body": "**These sentences copy values the code holds.** Each is true at the head. The next change to the list or the constants makes it wrong, and nothing ties it back.\n\n- README.md:346: \"A fix may not take a key the menu offers itself: `n`, `x`, `u`, `l`, `r`, `m` or `a`.\" This copies `menuKeys` in `internal/board/fix_menu.go:77`. The sentence also leaves out `esc` and `ctrl+c`, which `RefusedFix` refuses too. `prs config show` already prints the live list: on a fix keyed `a` it returned `refused, key \"a\" is the fix menu's own, and it keeps n x u l r m a and esc`.\n  Rewrite: \"A fix may not take a key the menu offers itself, and `prs config show` says which keys those are.\"\n- README.md:337: \"`blockers` names some of `conflicts`, `behind`, `failing-checks` and `draft`.\" This copies `config.BlockerKinds`. The help text for `prs config path` already builds the same list from `strings.Join(config.BlockerKinds, \", \")`.\n  Rewrite: \"`blockers` names the kinds of blocker the fix is offered for, which `prs config path --help` lists.\"\n- internal/board/fix_menu.go:23: \"The first four are the kinds a merge fix names in its blockers and receives in {blocker}.\" The count and the order both go stale when a kind is added or a constant moves.\n  Rewrite: \"The ones taken from config are the kinds a merge fix names in its blockers and receives in {blocker}.\""
    },
    {
      "number": "stale-prose.2",
      "section": "prose",
      "sites": [
        "README.md:267"
      ],
      "reach": "",
      "body": "**This sentence narrates the change.** README.md:267 says \"so a Bitbucket row has no menu, and `m` there asks to merge as before.\" The phrase \"as before\" points at the repo's state before this PR. A reader of the README has no \"before\".\n\nRewrite: \"so a Bitbucket row has no menu, and `m` there asks to merge.\""
    }
  ]
}

This review does not gate merge.

@datapoints-fleet-automation datapoints-fleet-automation Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

PR review — useless-test

Used: I cloned PR 17 and checked it out detached, then read the diff to cmd/prs/tui_test.go beside internal/board/fix_menu.go, rows.go, work.go and config.go. go test -count=1 ./... passed internal/board. Every test in cmd/prs failed at setup with bind: invalid argument, because the stand-in's gh socket path under my own TMPDIR is longer than the unix socket limit, and unshare was not permitted, so I judged those tests from reading them, not from a run.
Standards: testing.md § "Test behavior the user cares about, not what this commit happened to change", pull-requests.md § "Tests the diff adds are checked for whether they test the program or the change", testing.md § "Never assert on rendered output — assert the value it was built from".
Findings: 0 correctness · 0 design · 0 prose

No findings.

Findings as JSON
{
  "used": "I cloned PR 17 and checked it out detached, then read the diff to `cmd/prs/tui_test.go` beside `internal/board/fix_menu.go`, `rows.go`, `work.go` and `config.go`. `go test -count=1 ./...` passed `internal/board`. Every test in `cmd/prs` failed at setup with `bind: invalid argument`, because the stand-in's gh socket path under my own TMPDIR is longer than the unix socket limit, and `unshare` was not permitted, so I judged those tests from reading them, not from a run.",
  "standards": "testing.md § \"Test behavior the user cares about, not what this commit happened to change\", pull-requests.md § \"Tests the diff adds are checked for whether they test the program or the change\", testing.md § \"Never assert on rendered output — assert the value it was built from\".",
  "findings": []
}

This review does not gate merge.

@datapointchris datapointchris left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

PR review — code-architecture

Used: I cloned the repo and checked out PR 17 at 7d607f0. I read the whole diff and then the head versions of fix_menu.go, actions.go, rows.go, tmux_window_watch.go and config.go. I used rg to find every other site that reads MergeStateStatus, Mergeable, BlockerKinds, ShellQuotedPlaceholders, menuKeys, mergeable and pinFor. I did not run the binary.
Standards: I ran fleet standards search four times. It found two rules that apply: code-quality.md § "Ask whatever owns a fact, the function or enum that computes it, never duplicate its logic at a second site", and go.md § "Dispatch over a closed vocabulary names every member". The search for a security property kept in a parallel list returned no matching rule. The search for a reserved-key list kept beside its constants also returned no matching rule.
Findings: 0 correctness · 5 design · 0 prose

Design and architecture

code-architecture.1 internal/board/fix_menu.go:42 · internal/board/fix_menu.go:48 · internal/board/fix_menu.go:50 · internal/board/fix_menu.go:56 · internal/board/fix_menu.go:301 · internal/board/rows.go:106 · internal/board/rows.go:129 · internal/board/actions.go:144

Reach: The next GitHub merge state, or a change to what an existing state means, has to edit mergeMeanings and blockerOf together. The code itself expects new states. stateMeaning's comment says "because GitHub adds states". This PR is already that kind of change: it adds a precedence (draft, then conflict, then behind, then unknown, then failing checks) that the row's precedence in stateMeaning does not hold.

Two places decide what GitHub's merge state means. mergeMeanings in rows.go is the table that maps each state to its word, ready and computing. Before this PR, stateMeaning and mergeBlocker read only that table. The new blockerOf decides the same states again with string literals: "DRAFT", "DIRTY", "BEHIND", "UNSTABLE" and "BLOCKED". It also repeats the draft-then-CONFLICTING override from stateMeaning (rows.go:133-136). fixesFor compares against "UNSTABLE" a third time (fix_menu.go:301) to decide whether "merge anyway" is offered.

The two classifications already differ. blockerOf treats MergeStateStatus == "DRAFT" as a draft, but stateMeaning checks only pr.Draft. A state missing from the table reads as its own words in the row, but blockerOf treats it as needing no fix. The table and the switch carry the same facts, so the next edit to one has to be made to the other by hand. This is what code-quality.md § "Ask whatever owns a fact…" forbids.

Fix: give each mergeMeanings entry its blocker kind and a mergesAnyway flag. Put the draft/conflict override in one shared mergeStateOf(pr) string that both stateMeaning and blockerOf call. blockerOf then becomes a table lookup plus the failing-checks rule. The "UNSTABLE" check at line 301 reads the flag.

code-architecture.2 internal/config/config.go:189 · internal/config/config.go:227 · internal/config/config.go:232 · internal/config/config.go:514

Reach: The next placeholder whose value comes from outside prs needs two edits that must agree: an entry in fixPlaceholders and an entry in ShellQuotedPlaceholders. A pull request's title or an author login would be such a placeholder. This PR already added five quoted names and one unquoted name ({blocker}), so placeholders are growing. If the second edit is missed, the shell receives an unquoted branch-controlled string, and nothing fails at load or in a test.

Whether a placeholder gets shell-quoted is kept in a list separate from the placeholder itself. fixPlaceholders maps each name to its value. ShellQuotedPlaceholders is a hand-sorted string slice of the names to quote. filled checks membership at fill time, and insideQuotes checks the same list at load time. Nothing ties the two together. A new map entry is filled bare by default, so a missed edit leaves the command open to shell injection, which is exactly the risk the PR body says this design prevents.

Fix: make each placeholder entry a struct {fill func(Fields) string; outside bool}. Derive ShellQuotedPlaceholders from the map in the same way FixPlaceholders() is already derived. Then one entry decides both the value and whether it is quoted.

code-architecture.3 internal/config/config.go:131 · internal/config/config.go:139 · internal/board/fix_menu.go:27 · internal/board/fix_menu.go:42 · internal/board/fix_menu.go:253 · cmd/prs/config.go:84 · README.md:337

Reach: The next fixable blocker kind would cost edits in five places. heldBlocker (a required review) is the likeliest next kind, because the menu already names it but offers it no choices. Adding it means editing the constants and BlockerKinds in config, the alias constants in board, a case in blockerOf, a case in fixesFor, and the help text in cmd/prs/config.go and README.

The list of blocker kinds is owned by the config package, but what each kind means is decided in board. config.go declares Conflicts, Behind, FailingChecks and Draft, plus BlockerKinds for validating blockers: at load. board redeclares each of them as a blocker constant (fix_menu.go:27-30). board also adds two more kinds that config does not know about: computingBlocker and heldBlocker. As a result, the list config accepts and the list board produces are maintained separately, and no compile-time check catches a kind that one side has and the other lacks.

Fix: move the blocker vocabulary next to blockerOf, in board or pullrequests. Each entry should say whether a merge fix may name it. Have config validate blockers: against a list passed in, the same way board.RefusedFix already checks keys after load. Then adding a kind means adding one entry and one fixesFor case.

code-architecture.4 internal/board/fix_menu.go:66 · internal/board/fix_menu.go:77 · internal/board/fix_menu.go:82 · internal/board/fix_menu.go:233

Reach: The next built-in choice in the menu needs both a new key constant and a matching entry in menuKeys. This PR adds eight such choices, so more are likely, for example 'open in browser' or 'rerun all'. If the menuKeys edit is missed, a configured fix can take the same key. askFix then runs whichever choice comes first in the slice, and nothing reports the clash.

The menu's reserved keys are a hand-kept list beside the choices that use them. menuKeys repeats seven of the eight key constants. It leaves out readyKey only because that key equals rerunKey. RefusedFix checks configured fixes against this list. The choices themselves are built in fixesFor, checkFixes, conflictFixes and unmergedMenu, and none of them reads menuKeys.

Fix: check for duplicates where the choices are put together. One check over the final []fixChoice, inside askFix or fixesFor, would refuse a key that appears twice. Alternatively, build menuKeys from a single table of the built-in choices, so that declaring a choice also reserves its key.

code-architecture.5 internal/board/fix_menu.go:303 · internal/board/actions.go:403 · internal/board/actions.go:602

Reach: The next change to how prs merges has to be made at three sites. Examples are a merge-method option, a change to which head pinFor pins, or telling the person where the pin came from. This PR is the change that created the third copy.

The steps of a merge are written out three times. "Merge anyway" (fix_menu.go:303-312) calls b.mergeable(pr), then refused, then b.pinFor(pr), then b.start(b.mergeWork(...)). askMerge (actions.go:403-411) does the same sequence with a confirmation question added. The batch path (actions.go:602-606) is a third copy. Each copy decides for itself which refusal stops the merge and which head gets pinned.

Fix: pull out one mergeNow(m, pr) tea.Cmd or mergeCmd(pr) (work, error) that does the provider check, the refusal and the pin. askMerge asks first and then calls it. "Merge anyway" calls it directly.

Findings as JSON
{
  "used": "I cloned the repo and checked out PR 17 at 7d607f0. I read the whole diff and then the head versions of fix_menu.go, actions.go, rows.go, tmux_window_watch.go and config.go. I used rg to find every other site that reads MergeStateStatus, Mergeable, BlockerKinds, ShellQuotedPlaceholders, menuKeys, mergeable and pinFor. I did not run the binary.",
  "standards": "I ran `fleet standards search` four times. It found two rules that apply: code-quality.md § \"Ask whatever owns a fact, the function or enum that computes it, never duplicate its logic at a second site\", and go.md § \"Dispatch over a closed vocabulary names every member\". The search for a security property kept in a parallel list returned no matching rule. The search for a reserved-key list kept beside its constants also returned no matching rule.",
  "findings": [
    {
      "number": "code-architecture.1",
      "section": "design",
      "sites": [
        "internal/board/fix_menu.go:42",
        "internal/board/fix_menu.go:48",
        "internal/board/fix_menu.go:50",
        "internal/board/fix_menu.go:56",
        "internal/board/fix_menu.go:301",
        "internal/board/rows.go:106",
        "internal/board/rows.go:129",
        "internal/board/actions.go:144"
      ],
      "reach": "The next GitHub merge state, or a change to what an existing state means, has to edit `mergeMeanings` and `blockerOf` together. The code itself expects new states. `stateMeaning`'s comment says \"because GitHub adds states\". This PR is already that kind of change: it adds a precedence (draft, then conflict, then behind, then unknown, then failing checks) that the row's precedence in `stateMeaning` does not hold.",
      "body": "**Two places decide what GitHub's merge state means.** `mergeMeanings` in rows.go is the table that maps each state to its word, `ready` and `computing`. Before this PR, `stateMeaning` and `mergeBlocker` read only that table. The new `blockerOf` decides the same states again with string literals: `\"DRAFT\"`, `\"DIRTY\"`, `\"BEHIND\"`, `\"UNSTABLE\"` and `\"BLOCKED\"`. It also repeats the draft-then-`CONFLICTING` override from `stateMeaning` (rows.go:133-136). `fixesFor` compares against `\"UNSTABLE\"` a third time (fix_menu.go:301) to decide whether \"merge anyway\" is offered.\n\nThe two classifications already differ. `blockerOf` treats `MergeStateStatus == \"DRAFT\"` as a draft, but `stateMeaning` checks only `pr.Draft`. A state missing from the table reads as its own words in the row, but `blockerOf` treats it as needing no fix. The table and the switch carry the same facts, so the next edit to one has to be made to the other by hand. This is what code-quality.md § \"Ask whatever owns a fact…\" forbids.\n\n**Fix:** give each `mergeMeanings` entry its blocker kind and a `mergesAnyway` flag. Put the draft/conflict override in one shared `mergeStateOf(pr) string` that both `stateMeaning` and `blockerOf` call. `blockerOf` then becomes a table lookup plus the failing-checks rule. The `\"UNSTABLE\"` check at line 301 reads the flag."
    },
    {
      "number": "code-architecture.2",
      "section": "design",
      "sites": [
        "internal/config/config.go:189",
        "internal/config/config.go:227",
        "internal/config/config.go:232",
        "internal/config/config.go:514"
      ],
      "reach": "The next placeholder whose value comes from outside prs needs two edits that must agree: an entry in `fixPlaceholders` and an entry in `ShellQuotedPlaceholders`. A pull request's title or an author login would be such a placeholder. This PR already added five quoted names and one unquoted name (`{blocker}`), so placeholders are growing. If the second edit is missed, the shell receives an unquoted branch-controlled string, and nothing fails at load or in a test.",
      "body": "**Whether a placeholder gets shell-quoted is kept in a list separate from the placeholder itself.** `fixPlaceholders` maps each name to its value. `ShellQuotedPlaceholders` is a hand-sorted string slice of the names to quote. `filled` checks membership at fill time, and `insideQuotes` checks the same list at load time. Nothing ties the two together. A new map entry is filled bare by default, so a missed edit leaves the command open to shell injection, which is exactly the risk the PR body says this design prevents.\n\n**Fix:** make each placeholder entry a struct `{fill func(Fields) string; outside bool}`. Derive `ShellQuotedPlaceholders` from the map in the same way `FixPlaceholders()` is already derived. Then one entry decides both the value and whether it is quoted."
    },
    {
      "number": "code-architecture.3",
      "section": "design",
      "sites": [
        "internal/config/config.go:131",
        "internal/config/config.go:139",
        "internal/board/fix_menu.go:27",
        "internal/board/fix_menu.go:42",
        "internal/board/fix_menu.go:253",
        "cmd/prs/config.go:84",
        "README.md:337"
      ],
      "reach": "The next fixable blocker kind would cost edits in five places. `heldBlocker` (a required review) is the likeliest next kind, because the menu already names it but offers it no choices. Adding it means editing the constants and `BlockerKinds` in config, the alias constants in board, a case in `blockerOf`, a case in `fixesFor`, and the help text in cmd/prs/config.go and README.",
      "body": "**The list of blocker kinds is owned by the config package, but what each kind means is decided in board.** config.go declares `Conflicts`, `Behind`, `FailingChecks` and `Draft`, plus `BlockerKinds` for validating `blockers:` at load. board redeclares each of them as a `blocker` constant (fix_menu.go:27-30). board also adds two more kinds that config does not know about: `computingBlocker` and `heldBlocker`. As a result, the list config accepts and the list board produces are maintained separately, and no compile-time check catches a kind that one side has and the other lacks.\n\n**Fix:** move the blocker vocabulary next to `blockerOf`, in board or pullrequests. Each entry should say whether a merge fix may name it. Have config validate `blockers:` against a list passed in, the same way `board.RefusedFix` already checks keys after load. Then adding a kind means adding one entry and one `fixesFor` case."
    },
    {
      "number": "code-architecture.4",
      "section": "design",
      "sites": [
        "internal/board/fix_menu.go:66",
        "internal/board/fix_menu.go:77",
        "internal/board/fix_menu.go:82",
        "internal/board/fix_menu.go:233"
      ],
      "reach": "The next built-in choice in the menu needs both a new key constant and a matching entry in `menuKeys`. This PR adds eight such choices, so more are likely, for example 'open in browser' or 'rerun all'. If the `menuKeys` edit is missed, a configured fix can take the same key. `askFix` then runs whichever choice comes first in the slice, and nothing reports the clash.",
      "body": "**The menu's reserved keys are a hand-kept list beside the choices that use them.** `menuKeys` repeats seven of the eight key constants. It leaves out `readyKey` only because that key equals `rerunKey`. `RefusedFix` checks configured fixes against this list. The choices themselves are built in `fixesFor`, `checkFixes`, `conflictFixes` and `unmergedMenu`, and none of them reads `menuKeys`.\n\n**Fix:** check for duplicates where the choices are put together. One check over the final `[]fixChoice`, inside `askFix` or `fixesFor`, would refuse a key that appears twice. Alternatively, build `menuKeys` from a single table of the built-in choices, so that declaring a choice also reserves its key."
    },
    {
      "number": "code-architecture.5",
      "section": "design",
      "sites": [
        "internal/board/fix_menu.go:303",
        "internal/board/actions.go:403",
        "internal/board/actions.go:602"
      ],
      "reach": "The next change to how prs merges has to be made at three sites. Examples are a merge-method option, a change to which head `pinFor` pins, or telling the person where the pin came from. This PR is the change that created the third copy.",
      "body": "**The steps of a merge are written out three times.** \"Merge anyway\" (fix_menu.go:303-312) calls `b.mergeable(pr)`, then `refused`, then `b.pinFor(pr)`, then `b.start(b.mergeWork(...))`. `askMerge` (actions.go:403-411) does the same sequence with a confirmation question added. The batch path (actions.go:602-606) is a third copy. Each copy decides for itself which refusal stops the merge and which head gets pinned.\n\n**Fix:** pull out one `mergeNow(m, pr) tea.Cmd` or `mergeCmd(pr) (work, error)` that does the provider check, the refusal and the pin. `askMerge` asks first and then calls it. \"Merge anyway\" calls it directly."
    }
  ]
}

This review does not gate merge.

@datapointchris datapointchris left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

PR review — contracts

Used: I built the head into a sandbox binary and ran prs config show and prs config path --help against configs inside my own directory. The first config was a copy of the live ~/.config/prs/config.yml with a merge_fixes entry added. The second bound a window action to f. I searched all 115 checkouts in the registry for window_actions, merge_fixes, worktree_root, WORKTREE_ROOT and make-mergeable. I read dotfiles apps/common/worktree for the fleet's existing worktree root. Other surfaces break no existing caller: the live config binds window actions only to r and C, so the newly reserved f touches nothing. Window commands filled through FillCommand leave {number}, {repo} and {slug} bare, as before. cmd/prs/list.schema.json did not change.
Standards: data.md § "A shared file is named in config; only the tools own default is compiled in" (resolve env var, then config key, then own-XDG default); configuration.md § "Every path a tool writes is an XDG base directory"; git-workflow.md § "Concurrent sessions isolate with a worktree, and it lands on main" (~/.worktrees/<repo>/<slug>).
Findings: 0 correctness · 1 design · 0 prose

Design and architecture

contracts.1 internal/config/config.go:393 · cmd/prs/config.go:41 · README.md:326 · dotfiles:apps/common/worktree:534

Reach: The next caller to misread it is a machine that sets WORKTREE_ROOT or keeps its worktrees in ~/.worktrees. On that machine prs puts its worktrees under $XDG_DATA_HOME/prs/worktrees. worktree list scans only $WORKTREE_ROOT and cannot see them. This PR's own risk section relies on git worktree list to find worktrees prs leaves behind after a revert. The next change it costs is a migration of every worktree already made, once prs adopts the variable.

prs adds a second worktree root and never reads the fleet's WORKTREE_ROOT.

The fleet already has a worktree root. dotfiles apps/common/worktree resolves it as $WORKTREE_ROOT, then ~/.worktrees. Its docstring calls that root "the only index of them". Its layout is <root>/<repo>/<slug>. This PR's worktree_root key uses the same layout, <root>/<checkout's directory>/pr-<n>. Its resolution skips the variable and goes straight from the config key to $XDG_DATA_HOME/prs/worktrees.

I ran it with WORKTREE_ROOT=/x/wt XDG_DATA_HOME=…/sb/data prs config show. It printed:

worktree  …/sb/data/prs/worktrees  from the default

The variable was ignored. One meaning, "where worktrees live", now has two names and two defaults. The help text adds a third path: the example in prs config path --help and the README reads worktree_root: ~/worktrees, not ~/.worktrees.

data.md § "A shared file is named in config; only the tools own default is compiled in" covers this case: "a directory tree one indexes". It says to resolve "environment variable, then config key, then a default under the tool's own XDG directory". The default here complies. The environment-variable layer is missing.

Fix:

  • Resolve WORKTREE_ROOT ahead of worktree_root, and keep $XDG_DATA_HOME/prs/worktrees as the default.
  • Have config show report "from WORKTREE_ROOT" when the variable supplies the value.
  • Change the example in cmd/prs/config.go:41 and README.md:326 to ~/.worktrees, or to a path that does not look like a typo of it.
  • Make this change before the release ships, while no worktree exists under the old default.
Findings as JSON
{
  "used": "I built the head into a sandbox binary and ran `prs config show` and `prs config path --help` against configs inside my own directory. The first config was a copy of the live `~/.config/prs/config.yml` with a `merge_fixes` entry added. The second bound a window action to `f`. I searched all 115 checkouts in the registry for `window_actions`, `merge_fixes`, `worktree_root`, `WORKTREE_ROOT` and `make-mergeable`. I read dotfiles `apps/common/worktree` for the fleet's existing worktree root. Other surfaces break no existing caller: the live config binds window actions only to `r` and `C`, so the newly reserved `f` touches nothing. Window commands filled through `FillCommand` leave `{number}`, `{repo}` and `{slug}` bare, as before. `cmd/prs/list.schema.json` did not change.",
  "standards": "data.md § \"A shared file is named in config; only the tools own default is compiled in\" (resolve env var, then config key, then own-XDG default); configuration.md § \"Every path a tool writes is an XDG base directory\"; git-workflow.md § \"Concurrent sessions isolate with a worktree, and it lands on main\" (`~/.worktrees/<repo>/<slug>`).",
  "findings": [
    {
      "number": "contracts.1",
      "section": "design",
      "sites": [
        "internal/config/config.go:393",
        "cmd/prs/config.go:41",
        "README.md:326",
        "dotfiles:apps/common/worktree:534"
      ],
      "reach": "The next caller to misread it is a machine that sets `WORKTREE_ROOT` or keeps its worktrees in `~/.worktrees`. On that machine prs puts its worktrees under `$XDG_DATA_HOME/prs/worktrees`. `worktree list` scans only `$WORKTREE_ROOT` and cannot see them. This PR's own risk section relies on `git worktree list` to find worktrees prs leaves behind after a revert. The next change it costs is a migration of every worktree already made, once prs adopts the variable.",
      "body": "**prs adds a second worktree root and never reads the fleet's `WORKTREE_ROOT`.**\n\nThe fleet already has a worktree root. dotfiles `apps/common/worktree` resolves it as `$WORKTREE_ROOT`, then `~/.worktrees`. Its docstring calls that root \"the only index of them\". Its layout is `<root>/<repo>/<slug>`. This PR's `worktree_root` key uses the same layout, `<root>/<checkout's directory>/pr-<n>`. Its resolution skips the variable and goes straight from the config key to `$XDG_DATA_HOME/prs/worktrees`.\n\nI ran it with `WORKTREE_ROOT=/x/wt XDG_DATA_HOME=…/sb/data prs config show`. It printed:\n\n```\nworktree  …/sb/data/prs/worktrees  from the default\n```\n\nThe variable was ignored. One meaning, \"where worktrees live\", now has two names and two defaults. The help text adds a third path: the example in `prs config path --help` and the README reads `worktree_root: ~/worktrees`, not `~/.worktrees`.\n\ndata.md § \"A shared file is named in config; only the tools own default is compiled in\" covers this case: \"a directory tree one indexes\". It says to resolve \"environment variable, then config key, then a default under the tool's own XDG directory\". The default here complies. The environment-variable layer is missing.\n\n**Fix:**\n- Resolve `WORKTREE_ROOT` ahead of `worktree_root`, and keep `$XDG_DATA_HOME/prs/worktrees` as the default.\n- Have `config show` report \"from WORKTREE_ROOT\" when the variable supplies the value.\n- Change the example in `cmd/prs/config.go:41` and `README.md:326` to `~/.worktrees`, or to a path that does not look like a typo of it.\n- Make this change before the release ships, while no worktree exists under the old default."
    }
  ]
}

This review does not gate merge.

@datapointchris datapointchris left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

PR review — fleet-patterns

Used: I checked out PR 17 at 7d607f0 and read three files: merge_worktree.go, provider.go and FillCommand/shellQuoted in config.go. I compared them with the make-pr-mergeable skill, dotfiles apps/common/worktree, fleet fix/worktree.go and audit/checkout.go, fleet's ci log reader, gotermboard 056debb, and every shellQuote copy in fleet, slate and runthrough. I used indy search and rg across the registry checkouts to find them.
Standards: I cited git-workflow.md/A branch catches up only when something forces it, and then by rebasing and code-quality.md/Parsing or escaping JSON, YAML, CSV, a URL or a shell word follows the formats grammar, never a regex of the values seen so far. Searches for a tmux-window rule and a worktree-for-a-PR rule found no rule that applies.
Findings: 0 correctness · 3 design · 0 prose

Design and architecture

fleet-patterns.1 internal/pullrequests/merge_worktree.go:97 · internal/pullrequests/provider.go:111 · claude:skills/make-pr-mergeable/SKILL.md:19 · claude:skills/make-pr-mergeable/SKILL.md:52 · claude:skills/make-pr-mergeable/SKILL.md:80

Reach: Every conflicting or behind GitHub row someone fixes from the menu (n then push, or u). The same pull request can also reach /make-pr-mergeable, which the conflict test hands off to through c, and that skill rebases. Any later change to how a blocked branch catches up has to be made in prs and in the skill.

prs merges the base into a blocked branch, and the fleet's existing fix rebases it.

Chris already has a solution for making a pull request mergeable: the make-pr-mergeable skill. It uses the same three blockers this menu uses: conflicts, behind and failing checks. For a conflict it runs git rebase origin/<base> and then git push --force-with-lease --force-if-includes. For a branch behind it runs gh pr update-branch <n> --rebase. Its "never" list says it "never merges the base into the head branch".

The git-workflow.md rule "A branch catches up only when something forces it, and then by rebasing" says the same thing: "Never git merge main into a branch".

This PR does the opposite at both sites:

  • MergeWorktree.Start runs git merge --no-ff --no-commit origin/<base> and pushes a merge commit.
  • UpdateBranch runs gh pr update-branch with no --rebase, so GitHub writes a merge commit too.

The PR body gives a reason: a rebase "rewrites a branch a reviewer has read and needs a force push". The standard already weighs that reason and rejects it. It says "The force push is not a workaround for the rebase; it is the rebase", and it requires the lease flags so the push stays safe. The body neither cites the rule nor argues against it.

The cost is that one pull request now gets two different histories depending on who fixes it. If n resolves a conflict in prs and the skill later rebases the same branch, git drops the merge commit and replays the commits from before it.

Fix: follow the skill and the rule. Use gh pr update-branch --rebase for a branch behind. For a conflict, rebase in the worktree and push with --force-with-lease --force-if-includes. If merging really is the better choice for prs, change the rule and the skill in the same change, so the fleet keeps one answer.

fleet-patterns.2 internal/config/config.go:393 · internal/pullrequests/merge_worktree.go:52 · internal/pullrequests/merge_worktree.go:79 · dotfiles:apps/common/worktree:39 · dotfiles:apps/common/worktree:536 · claude:skills/make-pr-mergeable/SKILL.md:70

Reach: There are two cases. In the first, worktree_root is left at its default. Then every worktree prs adds sits outside the index that worktree list, show, choose and sweep read, so an abandoned one is only found by hand. The PR body's rollback note says exactly that. In the second, worktree_root is pointed at ~/.worktrees to join that index. Then prs and the skill both claim <root>/<repo>/pr-<n>, and Start runs checkout --detach in the skill's worktree.

prs starts a second worktree index rather than using $WORKTREE_ROOT.

The fleet already has one place for worktrees. In dotfiles apps/common/worktree, the index is $WORKTREE_ROOT, which defaults to ~/.worktrees (line 536). Line 39 states that "$WORKTREE_ROOT is the whole index — a repo has a directory there exactly when it has a worktree". Its sweep command is what collects worktrees that are finished. The make-pr-mergeable skill adds its pull-request worktree at ${WORKTREE_ROOT}/<repo>/pr-<n> and leaves it in place.

prs uses the same layout, <root>/<checkout dir>/pr-<n> (MergeWorktreeAt). Its default root is a new one, $XDG_DATA_HOME/prs/worktrees (worktreeRoot), and the body gives no reason for that. The layout matches the skill but the root does not, which causes two problems:

  • At the default, these worktrees are missing from every worktree listing and from sweep. The PR's rollback section has to tell the reader to run git worktree list by hand.
  • Pointed at the shared index, pr-<n> collides with the skill's worktree. The skill leaves that worktree clean on the head branch. Start sees a registered, clean worktree with no merge under way and runs checkout --quiet --detach <head> in it (merge_worktree.go:83). That moves the worktree the skill left off its branch.

Fix: default worktree_root to $WORKTREE_ROOT, falling back to ~/.worktrees, so the existing tools see these worktrees. Give prs's worktree a name the skill does not use, such as pr-<n>-merge. Alternatively, make the skill and prs share one convention and have each refuse a worktree that is on a branch.

fleet-patterns.3 internal/config/config.go:268 · slate:cli/internal/cli/argument_values.go:29 · fleet:syncthing/fleet.go:513 · fleet:eval/fleettool.go:249 · fleet:cmd/standards_address.go:426 · runthrough:src/runthrough/config.py:82

Reach: The next bug fix in one shell quoter will not reach the others. The copies already disagree on which characters they leave unquoted. prs is the one copy whose output gets executed, with branch names chosen by whoever opens a pull request.

shellQuoted is a sixth hand-written shell quoter in the fleet, and the copies already disagree.

The Go copies are these four:

  • fleet has three private shellQuote functions, in syncthing/fleet.go:513, eval/fleettool.go:249 and cmd/standards_address.go:426. Each always single-quotes.
  • slate's copy (argument_values.go:29) leaves a value bare unless it contains a space, a tab, a quote, a backslash, $ or a backtick. That means a;b, a|b and a&b stay bare.
  • prs's new shellQuoted follows shlex.quote and uses an allowlist regex of safe characters.

runthrough uses Python's shlex.quote directly.

The prs version is the correct shape, and the code-quality.md rule on escaping a shell word through the format's own encoder backs it. It is also the only copy whose output is run rather than printed as a hint. That is where it matters most that every copy is the same.

This is not a contract between a client and a server. It is evidence that Go code in the fleet has no shared encoder for a shell word, so each repo writes its own and they drift apart. slate's copy shows the drift already.

Fix: move one ShellQuote (shlex semantics) into goclikit, which every Go tool here already imports. Use it in prs. Then replace the fleet and slate copies with it. Until that happens, the prs copy is the one the others should match.

Findings as JSON
{
  "used": "I checked out PR 17 at 7d607f0 and read three files: merge_worktree.go, provider.go and FillCommand/shellQuoted in config.go. I compared them with the make-pr-mergeable skill, dotfiles apps/common/worktree, fleet fix/worktree.go and audit/checkout.go, fleet's ci log reader, gotermboard 056debb, and every shellQuote copy in fleet, slate and runthrough. I used indy search and rg across the registry checkouts to find them.",
  "standards": "I cited `git-workflow.md/A branch catches up only when something forces it, and then by rebasing` and `code-quality.md/Parsing or escaping JSON, YAML, CSV, a URL or a shell word follows the formats grammar, never a regex of the values seen so far`. Searches for a tmux-window rule and a worktree-for-a-PR rule found no rule that applies.",
  "findings": [
    {
      "number": "fleet-patterns.1",
      "section": "design",
      "sites": [
        "internal/pullrequests/merge_worktree.go:97",
        "internal/pullrequests/provider.go:111",
        "claude:skills/make-pr-mergeable/SKILL.md:19",
        "claude:skills/make-pr-mergeable/SKILL.md:52",
        "claude:skills/make-pr-mergeable/SKILL.md:80"
      ],
      "reach": "Every conflicting or behind GitHub row someone fixes from the menu (`n` then push, or `u`). The same pull request can also reach `/make-pr-mergeable`, which the conflict test hands off to through `c`, and that skill rebases. Any later change to how a blocked branch catches up has to be made in prs and in the skill.",
      "body": "**prs merges the base into a blocked branch, and the fleet's existing fix rebases it.**\n\nChris already has a solution for making a pull request mergeable: the `make-pr-mergeable` skill. It uses the same three blockers this menu uses: conflicts, behind and failing checks. For a conflict it runs `git rebase origin/<base>` and then `git push --force-with-lease --force-if-includes`. For a branch behind it runs `gh pr update-branch <n> --rebase`. Its \"never\" list says it \"never merges the base into the head branch\".\n\nThe `git-workflow.md` rule \"A branch catches up only when something forces it, and then by rebasing\" says the same thing: \"Never `git merge main` into a branch\".\n\nThis PR does the opposite at both sites:\n- `MergeWorktree.Start` runs `git merge --no-ff --no-commit origin/<base>` and pushes a merge commit.\n- `UpdateBranch` runs `gh pr update-branch` with no `--rebase`, so GitHub writes a merge commit too.\n\nThe PR body gives a reason: a rebase \"rewrites a branch a reviewer has read and needs a force push\". The standard already weighs that reason and rejects it. It says \"The force push is not a workaround for the rebase; it **is** the rebase\", and it requires the lease flags so the push stays safe. The body neither cites the rule nor argues against it.\n\nThe cost is that one pull request now gets two different histories depending on who fixes it. If `n` resolves a conflict in prs and the skill later rebases the same branch, git drops the merge commit and replays the commits from before it.\n\n**Fix:** follow the skill and the rule. Use `gh pr update-branch --rebase` for a branch behind. For a conflict, rebase in the worktree and push with `--force-with-lease --force-if-includes`. If merging really is the better choice for prs, change the rule and the skill in the same change, so the fleet keeps one answer."
    },
    {
      "number": "fleet-patterns.2",
      "section": "design",
      "sites": [
        "internal/config/config.go:393",
        "internal/pullrequests/merge_worktree.go:52",
        "internal/pullrequests/merge_worktree.go:79",
        "dotfiles:apps/common/worktree:39",
        "dotfiles:apps/common/worktree:536",
        "claude:skills/make-pr-mergeable/SKILL.md:70"
      ],
      "reach": "There are two cases. In the first, `worktree_root` is left at its default. Then every worktree prs adds sits outside the index that `worktree list`, `show`, `choose` and `sweep` read, so an abandoned one is only found by hand. The PR body's rollback note says exactly that. In the second, `worktree_root` is pointed at `~/.worktrees` to join that index. Then prs and the skill both claim `<root>/<repo>/pr-<n>`, and `Start` runs `checkout --detach` in the skill's worktree.",
      "body": "**prs starts a second worktree index rather than using `$WORKTREE_ROOT`.**\n\nThe fleet already has one place for worktrees. In dotfiles `apps/common/worktree`, the index is `$WORKTREE_ROOT`, which defaults to `~/.worktrees` (line 536). Line 39 states that \"$WORKTREE_ROOT is the whole index — a repo has a directory there exactly when it has a worktree\". Its `sweep` command is what collects worktrees that are finished. The `make-pr-mergeable` skill adds its pull-request worktree at `${WORKTREE_ROOT}/<repo>/pr-<n>` and leaves it in place.\n\nprs uses the same layout, `<root>/<checkout dir>/pr-<n>` (`MergeWorktreeAt`). Its default root is a new one, `$XDG_DATA_HOME/prs/worktrees` (`worktreeRoot`), and the body gives no reason for that. The layout matches the skill but the root does not, which causes two problems:\n- At the default, these worktrees are missing from every `worktree` listing and from `sweep`. The PR's rollback section has to tell the reader to run `git worktree list` by hand.\n- Pointed at the shared index, `pr-<n>` collides with the skill's worktree. The skill leaves that worktree clean on the head branch. `Start` sees a registered, clean worktree with no merge under way and runs `checkout --quiet --detach <head>` in it (merge_worktree.go:83). That moves the worktree the skill left off its branch.\n\n**Fix:** default `worktree_root` to `$WORKTREE_ROOT`, falling back to `~/.worktrees`, so the existing tools see these worktrees. Give prs's worktree a name the skill does not use, such as `pr-<n>-merge`. Alternatively, make the skill and prs share one convention and have each refuse a worktree that is on a branch."
    },
    {
      "number": "fleet-patterns.3",
      "section": "design",
      "sites": [
        "internal/config/config.go:268",
        "slate:cli/internal/cli/argument_values.go:29",
        "fleet:syncthing/fleet.go:513",
        "fleet:eval/fleettool.go:249",
        "fleet:cmd/standards_address.go:426",
        "runthrough:src/runthrough/config.py:82"
      ],
      "reach": "The next bug fix in one shell quoter will not reach the others. The copies already disagree on which characters they leave unquoted. prs is the one copy whose output gets executed, with branch names chosen by whoever opens a pull request.",
      "body": "**`shellQuoted` is a sixth hand-written shell quoter in the fleet, and the copies already disagree.**\n\nThe Go copies are these four:\n- fleet has three private `shellQuote` functions, in `syncthing/fleet.go:513`, `eval/fleettool.go:249` and `cmd/standards_address.go:426`. Each always single-quotes.\n- slate's copy (`argument_values.go:29`) leaves a value bare unless it contains a space, a tab, a quote, a backslash, `$` or a backtick. That means `a;b`, `a|b` and `a&b` stay bare.\n- prs's new `shellQuoted` follows `shlex.quote` and uses an allowlist regex of safe characters.\n\nrunthrough uses Python's `shlex.quote` directly.\n\nThe prs version is the correct shape, and the `code-quality.md` rule on escaping a shell word through the format's own encoder backs it. It is also the only copy whose output is run rather than printed as a hint. That is where it matters most that every copy is the same.\n\nThis is not a contract between a client and a server. It is evidence that Go code in the fleet has no shared encoder for a shell word, so each repo writes its own and they drift apart. slate's copy shows the drift already.\n\n**Fix:** move one `ShellQuote` (shlex semantics) into goclikit, which every Go tool here already imports. Use it in prs. Then replace the fleet and slate copies with it. Until that happens, the prs copy is the one the others should match."
    }
  ]
}

This review does not gate merge.

@datapoints-fleet-automation datapoints-fleet-automation Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

PR review — G1

Used: Built the branch with go build ./cmd/prs. Drove MergeWorktree.Start, CommitAndPush and a second Start from a scratch Go test against a local bare origin, a checkout and a conflicting PR branch, with git config pointed at a scratch file. Ran go vet ./... and go test -count=1 ./.... Then ran the failing tuidrive test alone twice at load average 52.
Standards: Cited help.md § "An error message names what failed, what was expected, and the command that fixes it", documentation.md § "A comment describes the code as it is, never the change or history that produced it" and pull-requests.md § "Sentences the diff adds are checked for what goes stale, and the finding carries the rewrite". Searches for a rule on TUI tests that race an async key found none.
Findings: 2 correctness · 1 design · 1 prose

Correctness

G1.1 internal/pullrequests/merge_worktree.go:166 · internal/pullrequests/merge_worktree.go:79 · internal/pullrequests/merge_worktree.go:83 · internal/board/fix_menu.go:685 · internal/pullrequests/pullrequest.go:176

Reach: Someone pushes to the head branch while the user resolves in nvim, or a pre-push hook fails. The PR body expects this: "The push is a plain git push origin HEAD:refs/heads/<branch>, so it fails where the branch moved." The user then follows prs's own advice, f then n.

A refused push throws away the user's conflict resolution, and the footer says the opposite.

CommitAndPush commits before it pushes. When the push fails, the commit has already used up MERGE_HEAD. Merging then reads false. The footer still says …; the merge waits in <path>, and f, then n, opens it again. Pressing f then n runs Start, which hits case registered:. The tree is clean, so Start runs git checkout --detach <new head> and merges again. The resolved merge commit becomes a detached orphan that only the reflog can find, and the conflict is back.

What I ran: a scratch test against a local bare origin. Steps:

  1. Start → merging=true.
  2. Resolved f by hand and staged it.
  3. Pushed a new commit to feat from another clone.
  4. CommitAndPush → error. Merging → false. The worktree's git log shows Merge branch 'main' into feat.
  5. Fetch + Start → merging=true. f reads <<<<<<< HEAD\nfeat\n=======\nmain\n>>>>>>> origin/main. git status reads UU f.

The push error also drops git's reason. Run keeps only the first stderr line. For a rejected push that line is To <remote>. The user saw git could not push the merge in …/pr-7 to feat: To …/origin.git, which names neither "rejected" nor "fetch first". help.md § "An error message names what failed, what was expected, and the command that fixes it" also reaches this.

The same loss happens when an earlier n left a merge waiting on a head that has since moved. Start returns at registered && merging without checking the head. The push is refused, and the next n starts over.

Fix:

  • When the push fails, undo the commit with git reset --soft HEAD^ plus git update-ref MERGE_HEAD origin/<base>, so the merge is still under way. Or keep the commit and have Start offer to push a worktree whose HEAD already has origin/<base> as a second parent.
  • Make the footer say what is actually kept.
  • For git push, report the ! or error: line rather than the first one.

G1.2 internal/board/fix_menu.go:633 · internal/board/fix_menu.go:260 · internal/board/fix_menu.go:123 · internal/pullrequests/merge_worktree.go:174

Reach: The unmerged menu offers merge fixes with {worktree} set to the merge under way, and the README's example c hand-to-claude fix names conflicts. Such a session finishes the merge in the worktree and pushes. The worktree is also stranded when the user leaves a merge waiting and the conflict is then fixed anywhere else, or the PR is merged or closed.

A merge that ends any way but prs's own push or abort leaves its worktree behind, and no key in prs removes it.

Only two prs actions call MergeWorktree.Remove:

  • the commit-and-push question after n (CommitAndPush)
  • the a abort choice in the unmerged menu (Abort)

Both are reached only from the n flow. Once GitHub stops reading the PR as conflicting, the n and a choices are gone:

  • fixesFor builds conflict choices only under case conflictsBlocker.
  • f on a now-clean row says GitHub reads … as ready, which needs no fix; m merges it.

So a worktree that a configured fix committed and pushed from stays at <worktree_root>/<checkout>/pr-<n> for good. The same goes for one the user left with esc before the conflict was fixed elsewhere. Each one shows in the checkout's git worktree list. The PR body admits leftover worktrees only for a revert, yet they pile up in ordinary use.

Fix: have the menu and f find a worktree at MergeWorktreeAt(...) whatever the blocker. Remove it when no merge is under way and HEAD is on origin. Otherwise offer a there. Or remove it once the row leaves the open listings.

Design and architecture

G1.3 cmd/prs/tui_test.go:1000

Reach: Any go test ./cmd/prs on a loaded machine. I ran it twice at load average 52 and it failed both times. The next change that adds latency to m's read makes it fail on idle machines too.

TestEveryKeyThatActsLeavesPrsOnTheListAndAMergedRowMovesToRecentlyClosedAtOnce now races the read that m starts.

The listing has #13 as DIRTY. With this branch, m there starts readThenFix in the background. The branch added readsAs(13, "CLEAN") so that the read ends at the merge question. Line 1000 still sends Press("g", "j", "j", "j", "j", "m", "enter") in one go. When enter arrives before the read lands, it opens #13's document. The test then times out waiting for gh could not merge #13: …. The screen dump shows the document's Description and Checks sections.

The PR body says the suite passed. It passes only where the stand-in read finishes before tuidrive's next keystroke.

Fix: send m alone, then term.WaitFor("merge acme/widget #13 — Count cache hits? [Y/n]"), then enter. Other steps that press m on a row the listing calls blocked need the same treatment.

Prose

G1.4 README.md:267 · README.md:346 · CLAUDE.md:131 · README.md:278

Three README and CLAUDE.md sentences will go stale.

  1. README.md:267 says `m` there asks to merge as before. "As before" describes the repo before this branch, which a later reader cannot see. documentation.md § "A comment describes the code as it is, never the change or history that produced it" covers this. Rewrite: "prs reads no merge state from Bitbucket, so a Bitbucket row has no menu, and m there asks to merge."

  2. README.md:346 says A fix may not take a key the menu offers itself: `n`, `x`, `u`, `l`, `r`, `m` or `a`. This copies menuKeys by hand. The next menu choice makes it wrong, and RefusedFix and prs config show already print the live list. Rewrite: "A fix may not take a key the menu offers itself, and prs config show names the fix and the keys it clashes with."

  3. CLAUDE.md:131 says "A merge left unmerged waits in the worktree, and f then n opens it again rather than merging twice". README.md:278 says the same thing. Both are true only until the commit runs. After a refused push, the next n merges a second time (see the first correctness finding). Once that is fixed, rewrite: "A merge not yet pushed waits in the worktree, committed or not, and f then n opens it again rather than merging twice."

Findings as JSON
{
  "used": "Built the branch with `go build ./cmd/prs`. Drove `MergeWorktree.Start`, `CommitAndPush` and a second `Start` from a scratch Go test against a local bare origin, a checkout and a conflicting PR branch, with git config pointed at a scratch file. Ran `go vet ./...` and `go test -count=1 ./...`. Then ran the failing tuidrive test alone twice at load average 52.",
  "standards": "Cited `help.md` § \"An error message names what failed, what was expected, and the command that fixes it\", `documentation.md` § \"A comment describes the code as it is, never the change or history that produced it\" and `pull-requests.md` § \"Sentences the diff adds are checked for what goes stale, and the finding carries the rewrite\". Searches for a rule on TUI tests that race an async key found none.",
  "findings": [
    {
      "number": "G1.1",
      "section": "correctness",
      "sites": [
        "internal/pullrequests/merge_worktree.go:166",
        "internal/pullrequests/merge_worktree.go:79",
        "internal/pullrequests/merge_worktree.go:83",
        "internal/board/fix_menu.go:685",
        "internal/pullrequests/pullrequest.go:176"
      ],
      "reach": "Someone pushes to the head branch while the user resolves in nvim, or a pre-push hook fails. The PR body expects this: \"The push is a plain `git push origin HEAD:refs/heads/<branch>`, so it fails where the branch moved.\" The user then follows prs's own advice, `f` then `n`.",
      "body": "**A refused push throws away the user's conflict resolution, and the footer says the opposite.**\n\n`CommitAndPush` commits before it pushes. When the push fails, the commit has already used up `MERGE_HEAD`. `Merging` then reads false. The footer still says `…; the merge waits in <path>, and f, then n, opens it again`. Pressing `f` then `n` runs `Start`, which hits `case registered:`. The tree is clean, so `Start` runs `git checkout --detach <new head>` and merges again. The resolved merge commit becomes a detached orphan that only the reflog can find, and the conflict is back.\n\nWhat I ran: a scratch test against a local bare origin. Steps:\n\n1. `Start` → `merging=true`.\n2. Resolved `f` by hand and staged it.\n3. Pushed a new commit to `feat` from another clone.\n4. `CommitAndPush` → error. `Merging` → `false`. The worktree's `git log` shows `Merge branch 'main' into feat`.\n5. `Fetch` + `Start` → `merging=true`. `f` reads `<<<<<<< HEAD\\nfeat\\n=======\\nmain\\n>>>>>>> origin/main`. `git status` reads `UU f`.\n\n**The push error also drops git's reason.** `Run` keeps only the first stderr line. For a rejected push that line is `To <remote>`. The user saw `git could not push the merge in …/pr-7 to feat: To …/origin.git`, which names neither \"rejected\" nor \"fetch first\". `help.md` § \"An error message names what failed, what was expected, and the command that fixes it\" also reaches this.\n\nThe same loss happens when an earlier `n` left a merge waiting on a head that has since moved. `Start` returns at `registered && merging` without checking the head. The push is refused, and the next `n` starts over.\n\nFix:\n\n- When the push fails, undo the commit with `git reset --soft HEAD^` plus `git update-ref MERGE_HEAD origin/<base>`, so the merge is still under way. Or keep the commit and have `Start` offer to push a worktree whose `HEAD` already has `origin/<base>` as a second parent.\n- Make the footer say what is actually kept.\n- For `git push`, report the `!` or `error:` line rather than the first one."
    },
    {
      "number": "G1.2",
      "section": "correctness",
      "sites": [
        "internal/board/fix_menu.go:633",
        "internal/board/fix_menu.go:260",
        "internal/board/fix_menu.go:123",
        "internal/pullrequests/merge_worktree.go:174"
      ],
      "reach": "The unmerged menu offers merge fixes with `{worktree}` set to the merge under way, and the README's example `c` hand-to-claude fix names `conflicts`. Such a session finishes the merge in the worktree and pushes. The worktree is also stranded when the user leaves a merge waiting and the conflict is then fixed anywhere else, or the PR is merged or closed.",
      "body": "**A merge that ends any way but prs's own push or abort leaves its worktree behind, and no key in prs removes it.**\n\nOnly two prs actions call `MergeWorktree.Remove`:\n\n- the commit-and-push question after `n` (`CommitAndPush`)\n- the `a` abort choice in the unmerged menu (`Abort`)\n\nBoth are reached only from the `n` flow. Once GitHub stops reading the PR as conflicting, the `n` and `a` choices are gone:\n\n- `fixesFor` builds conflict choices only under `case conflictsBlocker`.\n- `f` on a now-clean row says `GitHub reads … as ready, which needs no fix; m merges it`.\n\nSo a worktree that a configured fix committed and pushed from stays at `<worktree_root>/<checkout>/pr-<n>` for good. The same goes for one the user left with esc before the conflict was fixed elsewhere. Each one shows in the checkout's `git worktree list`. The PR body admits leftover worktrees only for a revert, yet they pile up in ordinary use.\n\nFix: have the menu and `f` find a worktree at `MergeWorktreeAt(...)` whatever the blocker. Remove it when no merge is under way and `HEAD` is on origin. Otherwise offer `a` there. Or remove it once the row leaves the open listings."
    },
    {
      "number": "G1.3",
      "section": "design",
      "sites": [
        "cmd/prs/tui_test.go:1000"
      ],
      "reach": "Any `go test ./cmd/prs` on a loaded machine. I ran it twice at load average 52 and it failed both times. The next change that adds latency to `m`'s read makes it fail on idle machines too.",
      "body": "**`TestEveryKeyThatActsLeavesPrsOnTheListAndAMergedRowMovesToRecentlyClosedAtOnce` now races the read that `m` starts.**\n\nThe listing has `#13` as `DIRTY`. With this branch, `m` there starts `readThenFix` in the background. The branch added `readsAs(13, \"CLEAN\")` so that the read ends at the merge question. Line 1000 still sends `Press(\"g\", \"j\", \"j\", \"j\", \"j\", \"m\", \"enter\")` in one go. When `enter` arrives before the read lands, it opens #13's document. The test then times out waiting for `gh could not merge #13: …`. The screen dump shows the document's Description and Checks sections.\n\nThe PR body says the suite passed. It passes only where the stand-in read finishes before tuidrive's next keystroke.\n\nFix: send `m` alone, then `term.WaitFor(\"merge acme/widget #13 — Count cache hits? [Y/n]\")`, then `enter`. Other steps that press `m` on a row the listing calls blocked need the same treatment."
    },
    {
      "number": "G1.4",
      "section": "prose",
      "sites": [
        "README.md:267",
        "README.md:346",
        "CLAUDE.md:131",
        "README.md:278"
      ],
      "reach": "",
      "body": "**Three README and CLAUDE.md sentences will go stale.**\n\n1. README.md:267 says `` `m` there asks to merge as before``. \"As before\" describes the repo before this branch, which a later reader cannot see. `documentation.md` § \"A comment describes the code as it is, never the change or history that produced it\" covers this. Rewrite: \"prs reads no merge state from Bitbucket, so a Bitbucket row has no menu, and `m` there asks to merge.\"\n\n2. README.md:346 says ``A fix may not take a key the menu offers itself: `n`, `x`, `u`, `l`, `r`, `m` or `a`.`` This copies `menuKeys` by hand. The next menu choice makes it wrong, and `RefusedFix` and `prs config show` already print the live list. Rewrite: \"A fix may not take a key the menu offers itself, and `prs config show` names the fix and the keys it clashes with.\"\n\n3. CLAUDE.md:131 says \"A merge left unmerged waits in the worktree, and `f` then `n` opens it again rather than merging twice\". README.md:278 says the same thing. Both are true only until the commit runs. After a refused push, the next `n` merges a second time (see the first correctness finding). Once that is fixed, rewrite: \"A merge not yet pushed waits in the worktree, committed or not, and `f` then `n` opens it again rather than merging twice.\""
    }
  ]
}

This review does not gate merge.

@datapointchris datapointchris left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

PR review — synthesis

Used: the 18 findings of round 1, from 8 review(s), and nothing else
Standards: none, because this step reads only the findings
Findings: 0 correctness · 3 design · 0 prose

Design and architecture

synthesis.1 explains code-architecture.1 · code-architecture.2 · code-architecture.3 · code-architecture.4 · stale-prose.1 · G1.4

A fact is kept twice, in a hand-written copy beside the code or list that owns it. Each copy goes stale or disagrees when the owner changes. The copies: the menu's reserved keys (menuKeys) and the README list of them; the blocker kinds (config.BlockerKinds) and the README list; the GitHub merge-state meaning in mergeMeanings and blockerOf; the shell-quoted placeholder list and fixPlaceholders; the blocker vocabulary in config and board.

synthesis.2 explains fleet-patterns.2 · contracts.1

prs invents its own worktree root and layout instead of using the fleet's $WORKTREE_ROOT index. The default root differs from the fleet's. The pr-<n> name collides with the skill's worktree. The fleet's worktree tools cannot see prs's worktrees.

synthesis.3 explains G1.1 · failure-paths.1

CommitAndPush commits before it pushes. A failed push leaves a committed merge that Start and the footer treat as if no merge were under way. The resolved merge is lost, and the message promises it is kept.

One defect, raised more than once

G1.1 · failure-paths.1: A failed push after CommitAndPush has committed the merge consumes MERGE_HEAD. f then n makes Start detach and re-merge, which discards the resolved merge, while the footer says the merge waits.

G1.4 · stale-prose.1: README.md:346 copies the menu's reserved-key list by hand, and it goes stale when the menu changes.

fleet-patterns.2 · contracts.1: prs ignores the fleet's WORKTREE_ROOT and defaults its worktree root to $XDG_DATA_HOME/prs/worktrees, so the fleet's worktree tools cannot see prs's worktrees.

Shapes as JSON
{
  "shapes": [
    {
      "number": "synthesis.1",
      "section": "design",
      "explains": [
        "code-architecture.1",
        "code-architecture.2",
        "code-architecture.3",
        "code-architecture.4",
        "stale-prose.1",
        "G1.4"
      ],
      "body": "A fact is kept twice, in a hand-written copy beside the code or list that owns it. Each copy goes stale or disagrees when the owner changes. The copies: the menu's reserved keys (`menuKeys`) and the README list of them; the blocker kinds (`config.BlockerKinds`) and the README list; the GitHub merge-state meaning in `mergeMeanings` and `blockerOf`; the shell-quoted placeholder list and `fixPlaceholders`; the blocker vocabulary in config and board."
    },
    {
      "number": "synthesis.2",
      "section": "design",
      "explains": [
        "fleet-patterns.2",
        "contracts.1"
      ],
      "body": "prs invents its own worktree root and layout instead of using the fleet's `$WORKTREE_ROOT` index. The default root differs from the fleet's. The `pr-<n>` name collides with the skill's worktree. The fleet's `worktree` tools cannot see prs's worktrees."
    },
    {
      "number": "synthesis.3",
      "section": "design",
      "explains": [
        "G1.1",
        "failure-paths.1"
      ],
      "body": "`CommitAndPush` commits before it pushes. A failed push leaves a committed merge that `Start` and the footer treat as if no merge were under way. The resolved merge is lost, and the message promises it is kept."
    }
  ],
  "duplicates": [
    {
      "defect": "A failed push after `CommitAndPush` has committed the merge consumes MERGE_HEAD. `f` then `n` makes `Start` detach and re-merge, which discards the resolved merge, while the footer says the merge waits.",
      "findings": [
        "G1.1",
        "failure-paths.1"
      ]
    },
    {
      "defect": "README.md:346 copies the menu's reserved-key list by hand, and it goes stale when the menu changes.",
      "findings": [
        "G1.4",
        "stale-prose.1"
      ]
    },
    {
      "defect": "prs ignores the fleet's `WORKTREE_ROOT` and defaults its worktree root to `$XDG_DATA_HOME/prs/worktrees`, so the fleet's worktree tools cannot see prs's worktrees.",
      "findings": [
        "fleet-patterns.2",
        "contracts.1"
      ]
    }
  ]
}

This review does not gate merge.

@datapointchris

Copy link
Copy Markdown
Owner Author

PR review round 1

Ran: G1, useless-test, code-architecture, fleet-patterns, system-design, stale-prose, failure-paths, contracts, synthesis
Did not run: none
Rules: 1 proposed, filed into the fleet inbox as proposals.

reviewer model effort duration uncached input cache write cache read output findings review
G1 opus high 10m 05s 54 95,973 1,818,487 17,800 4 review
useless-test opus high 1m 42s 22 31,318 288,784 4,346 0 review
code-architecture opus medium 1m 25s 18 45,107 272,787 7,069 5 review
fleet-patterns opus medium 4m 19s 48 53,953 910,709 11,885 3 review
system-design sonnet medium 0m 48s 12 16,767 101,884 2,565 1 review
stale-prose opus medium 0m 56s 18 29,067 198,041 3,860 2 review
failure-paths sonnet medium 1m 04s 16 24,052 141,487 3,868 2 review
contracts opus medium 2m 05s 40 52,475 695,713 7,604 1 review
synthesis sonnet medium 0m 16s 6 14,183 16,203 2,020 3 review
total 234 362,895 4,444,095 61,017 21
Round record
{
  "grammar": 6,
  "round": 1,
  "repo": "datapointchris/prs",
  "pr": 17,
  "head": "7d607f0eabc71e3f94acae361f1a6fdfeef311e0",
  "experiment": "none",
  "dispatch": "20261108T165856Z-pr-review",
  "roster": "44e66c70a21627e0922b633d0e7db86363a01f6c",
  "harness": "d046ba6e84043927ddeed9c2ffe22b851426a206",
  "machine": "archlinux",
  "started": "2026-11-08T16:59:52Z",
  "finished": "2026-11-08T17:12:49Z",
  "reviewers": [
    {
      "label": "G1",
      "agent": "pr-reviewer",
      "model": "opus",
      "effort": "high",
      "experiment": "none",
      "arm": "default",
      "reason": "STATIC",
      "prompt_profile": "pr-reviewer-prompts/general.md",
      "prompt_sha": "00271016dd7d",
      "status": "ran",
      "why": "",
      "attempts": 1,
      "seconds": 605,
      "tokens": {
        "input": 54,
        "cache_write": 95973,
        "cache_read": 1818487,
        "output": 17800
      },
      "tokens_from": "result",
      "tokens_by_model": {
        "claude-opus-5-5": {
          "input": 54,
          "cache_write": 95973,
          "cache_read": 1818487,
          "output": 17800
        }
      },
      "session": "01a1bc1a-b43b-7d0f-a0c7-adf76e0c31ab",
      "worklog": "1335.1",
      "worklog_refused": "",
      "claude_code": "2.1.319",
      "review_url": "https://github.com/datapointchris/prs/pull/17#pullrequestreview-5818804937",
      "review_id": 5818804937,
      "findings": {
        "correctness": 2,
        "design": 1,
        "prose": 1
      },
      "numbers": [
        "G1.1",
        "G1.2",
        "G1.3",
        "G1.4"
      ],
      "rules": {
        "proposed": 1,
        "keys": [
          "rule/testing/a-tui-test-waits-for-what-a-key-opens-before-it-presses-the-next-key"
        ],
        "filed": true,
        "why": "",
        "path": "/home/fleet/.cache/run-pr-reviewers/datapointchris-prs-pr17-round1-20261108T165856Z/records/G1.rules.json"
      },
      "head": "7d607f0eabc71e3f94acae361f1a6fdfeef311e0",
      "kept": false,
      "records": "/home/fleet/.cache/run-pr-reviewers/datapointchris-prs-pr17-round1-20261108T165856Z/records",
      "started": "2026-11-08T17:01:53Z",
      "finished": "2026-11-08T17:12:06Z",
      "account": "fleetizen",
      "api_key_source": "none",
      "machine": "queue"
    },
    {
      "label": "useless-test",
      "agent": "pr-useless-test-reviewer",
      "model": "opus",
      "effort": "high",
      "experiment": "none",
      "arm": "default",
      "reason": "STATIC",
      "prompt_profile": "pr-reviewer-prompts/useless-test.md",
      "prompt_sha": "f0b52bbf8a48",
      "status": "ran",
      "why": "",
      "attempts": 1,
      "seconds": 102,
      "tokens": {
        "input": 22,
        "cache_write": 31318,
        "cache_read": 288784,
        "output": 4346
      },
      "tokens_from": "result",
      "tokens_by_model": {
        "claude-opus-5-5": {
          "input": 22,
          "cache_write": 31318,
          "cache_read": 288784,
          "output": 4346
        }
      },
      "session": "01a1bc1a-04e0-7e11-b745-9e9dd1bbc854",
      "worklog": "1335.1",
      "worklog_refused": "",
      "claude_code": "2.1.319",
      "review_url": "https://github.com/datapointchris/prs/pull/17#pullrequestreview-5818772491",
      "review_id": 5818772491,
      "findings": {
        "correctness": 0,
        "design": 0,
        "prose": 0
      },
      "numbers": [],
      "rules": {},
      "head": "7d607f0eabc71e3f94acae361f1a6fdfeef311e0",
      "kept": false,
      "records": "/home/fleet/.cache/run-pr-reviewers/datapointchris-prs-pr17-round1-20261108T165856Z/records",
      "started": "2026-11-08T17:00:22Z",
      "finished": "2026-11-08T17:02:08Z",
      "account": "live",
      "api_key_source": "none",
      "machine": "queue"
    },
    {
      "label": "code-architecture",
      "agent": "pr-code-architecture-reviewer",
      "model": "opus",
      "effort": "medium",
      "experiment": "none",
      "arm": "default",
      "reason": "STATIC",
      "prompt_profile": "pr-reviewer-prompts/persona.md",
      "prompt_sha": "283f4014efc3",
      "status": "ran",
      "why": "",
      "attempts": 1,
      "seconds": 85,
      "tokens": {
        "input": 18,
        "cache_write": 45107,
        "cache_read": 272787,
        "output": 7069
      },
      "tokens_from": "result",
      "tokens_by_model": {
        "claude-opus-5-5": {
          "input": 18,
          "cache_write": 45107,
          "cache_read": 272787,
          "output": 7069
        }
      },
      "session": "01a1bc1b-2e61-7170-82bb-d3206a0e77ad",
      "worklog": "1335.1",
      "worklog_refused": "",
      "claude_code": "2.1.319",
      "review_url": "https://github.com/datapointchris/prs/pull/17#pullrequestreview-5818773707",
      "review_id": 5818773707,
      "findings": {
        "correctness": 0,
        "design": 5,
        "prose": 0
      },
      "numbers": [
        "code-architecture.1",
        "code-architecture.2",
        "code-architecture.3",
        "code-architecture.4",
        "code-architecture.5"
      ],
      "rules": {},
      "head": "7d607f0eabc71e3f94acae361f1a6fdfeef311e0",
      "kept": false,
      "records": "/home/chris/.cache/run-pr-reviewers/datapointchris-prs-pr17-round1-20261108T165856Z/records",
      "started": "2026-11-08T17:01:04Z",
      "finished": "2026-11-08T17:02:31Z",
      "account": "fleetizen",
      "api_key_source": "none",
      "machine": "archlinux"
    },
    {
      "label": "fleet-patterns",
      "agent": "pr-fleet-patterns-reviewer",
      "model": "opus",
      "effort": "medium",
      "experiment": "none",
      "arm": "default",
      "reason": "STATIC",
      "prompt_profile": "pr-reviewer-prompts/persona.md",
      "prompt_sha": "efa99c642e6d",
      "status": "ran",
      "why": "",
      "attempts": 1,
      "seconds": 259,
      "tokens": {
        "input": 48,
        "cache_write": 53953,
        "cache_read": 910709,
        "output": 11885
      },
      "tokens_from": "result",
      "tokens_by_model": {
        "claude-opus-5-5": {
          "input": 48,
          "cache_write": 53953,
          "cache_read": 910709,
          "output": 11885
        }
      },
      "session": "01a1bc1b-3393-7d0d-9310-507b0aabd3c4",
      "worklog": "1335.1",
      "worklog_refused": "",
      "claude_code": "2.1.319",
      "review_url": "https://github.com/datapointchris/prs/pull/17#pullrequestreview-5818783174",
      "review_id": 5818783174,
      "findings": {
        "correctness": 0,
        "design": 3,
        "prose": 0
      },
      "numbers": [
        "fleet-patterns.1",
        "fleet-patterns.2",
        "fleet-patterns.3"
      ],
      "rules": {},
      "head": "7d607f0eabc71e3f94acae361f1a6fdfeef311e0",
      "kept": false,
      "records": "/home/chris/.cache/run-pr-reviewers/datapointchris-prs-pr17-round1-20261108T165856Z/records",
      "started": "2026-11-08T17:01:07Z",
      "finished": "2026-11-08T17:05:28Z",
      "account": "fleetizen",
      "api_key_source": "none",
      "machine": "archlinux"
    },
    {
      "label": "system-design",
      "agent": "pr-system-design-reviewer",
      "model": "sonnet",
      "effort": "medium",
      "experiment": "none",
      "arm": "default",
      "reason": "STATIC",
      "prompt_profile": "pr-reviewer-prompts/persona.md",
      "prompt_sha": "bc2eb8615f41",
      "status": "ran",
      "why": "",
      "attempts": 1,
      "seconds": 48,
      "tokens": {
        "input": 12,
        "cache_write": 16767,
        "cache_read": 101884,
        "output": 2565
      },
      "tokens_from": "result",
      "tokens_by_model": {
        "claude-sonnet-5-5": {
          "input": 12,
          "cache_write": 16767,
          "cache_read": 101884,
          "output": 2565
        }
      },
      "session": "01a1bc19-76a1-7132-a244-4e0da16bc0ea",
      "worklog": "1335.1",
      "worklog_refused": "",
      "claude_code": "2.1.319",
      "review_url": "https://github.com/datapointchris/prs/pull/17#pullrequestreview-5818768120",
      "review_id": 5818768120,
      "findings": {
        "correctness": 1,
        "design": 0,
        "prose": 0
      },
      "numbers": [
        "system-design.1"
      ],
      "rules": {},
      "head": "7d607f0eabc71e3f94acae361f1a6fdfeef311e0",
      "kept": false,
      "records": "/home/fleet/.cache/run-pr-reviewers/datapointchris-prs-pr17-round1-20261108T165856Z/records",
      "started": "2026-11-08T16:59:52Z",
      "finished": "2026-11-08T17:00:45Z",
      "account": "live",
      "api_key_source": "none",
      "machine": "queue"
    },
    {
      "label": "stale-prose",
      "agent": "pr-stale-prose-reviewer",
      "model": "opus",
      "effort": "medium",
      "experiment": "none",
      "arm": "default",
      "reason": "STATIC",
      "prompt_profile": "pr-reviewer-prompts/persona.md",
      "prompt_sha": "4359b874a436",
      "status": "ran",
      "why": "",
      "attempts": 1,
      "seconds": 56,
      "tokens": {
        "input": 18,
        "cache_write": 29067,
        "cache_read": 198041,
        "output": 3860
      },
      "tokens_from": "result",
      "tokens_by_model": {
        "claude-opus-5-5": {
          "input": 18,
          "cache_write": 29067,
          "cache_read": 198041,
          "output": 3860
        }
      },
      "session": "01a1bc1b-30ba-77b5-a1c7-108f8a967130",
      "worklog": "1335.1",
      "worklog_refused": "",
      "claude_code": "2.1.319",
      "review_url": "https://github.com/datapointchris/prs/pull/17#pullrequestreview-5818772306",
      "review_id": 5818772306,
      "findings": {
        "correctness": 0,
        "design": 0,
        "prose": 2
      },
      "numbers": [
        "stale-prose.1",
        "stale-prose.2"
      ],
      "rules": {},
      "head": "7d607f0eabc71e3f94acae361f1a6fdfeef311e0",
      "kept": false,
      "records": "/home/chris/.cache/run-pr-reviewers/datapointchris-prs-pr17-round1-20261108T165856Z/records",
      "started": "2026-11-08T17:01:06Z",
      "finished": "2026-11-08T17:02:04Z",
      "account": "fleetizen",
      "api_key_source": "none",
      "machine": "archlinux"
    },
    {
      "label": "failure-paths",
      "agent": "pr-failure-paths-reviewer",
      "model": "sonnet",
      "effort": "medium",
      "experiment": "none",
      "arm": "default",
      "reason": "STATIC",
      "prompt_profile": "pr-reviewer-prompts/persona.md",
      "prompt_sha": "4239ed2be477",
      "status": "ran",
      "why": "",
      "attempts": 1,
      "seconds": 64,
      "tokens": {
        "input": 16,
        "cache_write": 24052,
        "cache_read": 141487,
        "output": 3868
      },
      "tokens_from": "result",
      "tokens_by_model": {
        "claude-sonnet-5-5": {
          "input": 16,
          "cache_write": 24052,
          "cache_read": 141487,
          "output": 3868
        }
      },
      "session": "01a1bc19-799e-73e9-890e-e199b258e17c",
      "worklog": "1335.1",
      "worklog_refused": "",
      "claude_code": "2.1.319",
      "review_url": "https://github.com/datapointchris/prs/pull/17#pullrequestreview-5818769083",
      "review_id": 5818769083,
      "findings": {
        "correctness": 2,
        "design": 0,
        "prose": 0
      },
      "numbers": [
        "failure-paths.1",
        "failure-paths.2"
      ],
      "rules": {},
      "head": "7d607f0eabc71e3f94acae361f1a6fdfeef311e0",
      "kept": false,
      "records": "/home/fleet/.cache/run-pr-reviewers/datapointchris-prs-pr17-round1-20261108T165856Z/records",
      "started": "2026-11-08T16:59:52Z",
      "finished": "2026-11-08T17:01:04Z",
      "account": "live",
      "api_key_source": "none",
      "machine": "queue"
    },
    {
      "label": "contracts",
      "agent": "pr-contracts-reviewer",
      "model": "opus",
      "effort": "medium",
      "experiment": "none",
      "arm": "default",
      "reason": "STATIC",
      "prompt_profile": "pr-reviewer-prompts/persona.md",
      "prompt_sha": "3cf737a7c111",
      "status": "ran",
      "why": "",
      "attempts": 1,
      "seconds": 125,
      "tokens": {
        "input": 40,
        "cache_write": 52475,
        "cache_read": 695713,
        "output": 7604
      },
      "tokens_from": "result",
      "tokens_by_model": {
        "claude-opus-5-5": {
          "input": 40,
          "cache_write": 52475,
          "cache_read": 695713,
          "output": 7604
        }
      },
      "session": "01a1bc1a-af78-7323-aeb3-e62bf1a13176",
      "worklog": "1335.1",
      "worklog_refused": "",
      "claude_code": "2.1.319",
      "review_url": "https://github.com/datapointchris/prs/pull/17#pullrequestreview-5818774045",
      "review_id": 5818774045,
      "findings": {
        "correctness": 0,
        "design": 1,
        "prose": 0
      },
      "numbers": [
        "contracts.1"
      ],
      "rules": {},
      "head": "7d607f0eabc71e3f94acae361f1a6fdfeef311e0",
      "kept": false,
      "records": "/home/chris/.cache/run-pr-reviewers/datapointchris-prs-pr17-round1-20261108T165856Z/records",
      "started": "2026-11-08T17:00:30Z",
      "finished": "2026-11-08T17:02:38Z",
      "account": "fleetizen",
      "api_key_source": "none",
      "machine": "archlinux"
    },
    {
      "label": "synthesis",
      "agent": null,
      "model": "sonnet",
      "effort": "medium",
      "experiment": "none",
      "arm": "default",
      "reason": "STATIC",
      "prompt_profile": "pr-reviewer-prompts/synthesis.md",
      "prompt_sha": "8832c4b84e09",
      "status": "ran",
      "why": "",
      "attempts": 2,
      "seconds": 16,
      "tokens": {
        "input": 6,
        "cache_write": 14183,
        "cache_read": 16203,
        "output": 2020
      },
      "tokens_from": "result",
      "tokens_by_model": {
        "claude-sonnet-5-5": {
          "input": 6,
          "cache_write": 14183,
          "cache_read": 16203,
          "output": 2020
        }
      },
      "session": "01a1bc25-9951-7353-85dd-4e871cb86167",
      "worklog": "1335.1",
      "worklog_refused": "",
      "claude_code": "2.1.319",
      "review_url": "https://github.com/datapointchris/prs/pull/17#pullrequestreview-5818807487",
      "review_id": 5818807487,
      "findings": {
        "correctness": 0,
        "design": 3,
        "prose": 0
      },
      "numbers": [
        "synthesis.1",
        "synthesis.2",
        "synthesis.3"
      ],
      "rules": {},
      "head": "7d607f0eabc71e3f94acae361f1a6fdfeef311e0",
      "kept": false,
      "records": "/home/chris/.cache/run-pr-reviewers/datapointchris-prs-pr17-round1-20261108T165856Z/records",
      "started": "2026-11-08T17:12:30Z",
      "finished": "2026-11-08T17:12:49Z",
      "account": "fleetizen",
      "api_key_source": "none",
      "machine": "archlinux"
    }
  ],
  "rules": {
    "proposed": 1,
    "keys": [
      "rule/testing/a-tui-test-waits-for-what-a-key-opens-before-it-presses-the-next-key"
    ],
    "unfiled": []
  },
  "kept": []
}

m on a row whose merge state says GitHub refuses it reads the pull request again, and
where it is still blocked opens a menu of fixes rather than asking to merge. 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, so a reflex enter chooses nothing, and no choice exits prs.

- Conflicts name the conflicting files from git merge-tree before anything is checked out.
  n merges origin/<base> into a worktree detached at the head, under worktree_root, and
  hands it to nvim's diffview. With nothing left unmerged it asks to commit the merge and
  push it to the head branch as a plain push, then removes the worktree. With paths left
  unmerged it offers nvim again, git merge --abort, or a configured fix.
- A branch behind its base updates with gh pr update-branch.
- Failing checks show the failed jobs' log on the Reader, rerun with gh run rerun --failed,
  or, where GitHub would merge them failing, merge anyway.
- A draft is marked ready with gh pr ready.

merge_fixes in the config adds a choice per blocker kind. Its command runs in a tmux
window opened behind the board, and the row reads row_reads while that window is open,
watched by the same tmux poll a review round's window uses. A command's paths and branches
are quoted for the shell, because anyone who can open a pull request names its branch, and
a command that puts one inside quotes of its own is refused. Bitbucket reports no merge
state, so a Bitbucket row keeps asking to merge as before.

The gotermboard pin is the head of the pull request adding Question.Choices and
OpenWindowBehind, until it releases.

Agent-Name: claude-code-unattended
Agent-Session: 0ed7f52a-bc57-4308-b45d-2889466add5f
Agent-Run: 01a1bbea-ca97-7b16-957f-bd44a01039af
Worklog: 1335.1
…ny row

A push origin refused, because someone pushed to the branch since, used to leave the merge
committed while the footer said it waited, and f then n merged again over the resolution.
CommitAndPush now takes the commit back and leaves the merge under way, resolved. git
refuses a soft reset during a merge and refuses update-ref on MERGE_HEAD, so the reset
comes first and MERGE_HEAD is written where git merge writes it. A failing client's message
names git's refused-ref line, as ! [rejected] HEAD -> feat (fetch first), rather than the
To <remote> line above it.

- Every fix menu looks for a worktree an earlier n left, whatever blocks the pull request
  or where nothing does, and a aborts its merge and removes it. f reads a ready row again
  to look for one.
- The worktree is pr-<n>-merge, apart from a pr-<n> worktree another tool keeps, and Start
  leaves alone a worktree it meets on a branch.
- A conflict whose head branch is in a fork gets no n, because the push goes to origin.
  The row reads isCrossRepository, kept out of --json since Bitbucket does not say.
- A rerun goes on past a run that fails, and names each run that reran.
- mergeMeanings says each merge state's blocker, so blockerOf and the row's word read one
  table, through mergeStateOf.
- The shell-quoted placeholders are a set of their own, which the merge fix's set
  joins, so declaring one decides whether it is quoted, and ShellQuotedPlaceholders names
  that set.
- Every merge key starts the merge mergeOf pins.
- A fix menu offering one key twice opens on neither and says so.
- The key test waits for the merge question before pressing enter, since m reads the row
  again first.
… rebasing

n rebases the pull request's head onto origin/<base> in its worktree, rather than merging
the base in, so the branch's history stays linear. The worktree is pr-<n>-rebase, added at
the head branch fetched into origin/<branch>. Each stop opens nvim, and nvim exiting with
nothing unmerged runs git rebase --continue, then opens nvim again at the next stop. Paths
left unmerged, or a stop git cannot go on from, offer nvim again, git rebase --abort, or a
merge fix.

- A finished rebase lists its commits and asks to force-push them, enter meaning yes. The
  lease is on ORIG_HEAD, the head the rebase started from, so origin refuses the push where
  anyone pushed since, and the rebase waits in the worktree for p. git's
  --force-if-includes reads the reflog of a local branch named for the head, which a
  detached worktree pushes from none of, so it refuses even where nobody pushed.
- Every client runs with GIT_EDITOR=true, so rebase --continue keeps each commit's message
  rather than wait on an editor with no terminal to draw on.
- u runs gh pr update-branch --rebase.
- A menu on any row offers p for a finished rebase its worktree holds, beside a.

Agent-Name: claude-code-unattended
Agent-Session: 0ed7f52a-bc57-4308-b45d-2889466add5f
Agent-Run: 01a1bc6a-675d-78c1-9fa5-bb9bd307fb23
Worklog: 1335.1
…u and OpenWindowBehind

The fix menu was pinned to the library pull request's head commit. v0.42.0 is
the first tag holding that commit, with the choice line drawn through the
board's painter.

Agent-Name: claude-code
Agent-Session: 60bee577-11ae-4c13-9517-75a25f6211a6
@datapointchris
datapointchris merged commit ee453db into main Nov 8, 2026
2 checks passed
@datapointchris
datapointchris deleted the prs-fix-menu branch November 8, 2026 19:07
datapointchris added a commit to datapointchris/dotfiles that referenced this pull request Nov 8, 2026
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_root` to `~/.worktrees` and adds a `merge_fixes`
entry. Key `c` runs `/make-pr-mergeable {number} {blocker}` in a tmux
window behind the board, for conflicts, a branch behind and failing
checks. It is offered only where `claude` is on PATH.

## What to look at

- `configs/common/.config/prs/config.yml.tmpl`: the `command` keeps
`{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 show` printed:

```text
worktree  /home/chris/.worktrees  from worktree_root in the config file
fix c     hand to claude  for conflicts, behind, failing-checks  offered
```

The installed prs 0.15.0 refuses the same file, with `field
worktree_root not found in type config.file` and `field merge_fixes not
found in type config.file`.

## What changes

- Once the prs release reads them, `c` in the fix menu opens a Claude
window running `/make-pr-mergeable`, and the row reads `claude fixing`
while that window is open.
- A conflict resolved in nvim adds
`~/.worktrees/<repo>/pr-<number>-rebase`, which `worktree list` shows
until the rebase is pushed or aborted.

## Decisions, and what they rejected

- **`~/.worktrees`, literal, rather than a template value.** `worktree`
reads `$WORKTREE_ROOT` and falls back to `~/.worktrees`, and no machine
sets `$WORKTREE_ROOT`, so no flag exists to render from.
- **No `draft` in the fix's blockers.** The menu's own `r` readies a
draft through `gh pr ready`, and `make-pr-mergeable` names no draft
case.

## Risk and rollback

This merges only after the prs release carrying the fix menu is
installed. On an older prs, every `prs` command 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant