A set of small, composable tools that let you browse, read, check out, run, review, and comment on GitHub pull requests entirely from the terminal — no browser, no mouse. Built for a fish + tmux + Neovim + gh + git-delta setup, but every piece is optional and swappable.
Pass the following handoff document to your own coding agent to start
implementing a version of this workflow tailored to you and your work
environment. The handoff document captures all of the important parts of
how I implemented the workflow and why:
# Terminal-native GitHub PR review workflow
A set of small, composable tools that let you **browse, read, check out, run, review, and comment
on GitHub pull requests entirely from the terminal** — no browser, no mouse. Built for a
fish + tmux + Neovim(LazyVim) + `gh` + `git-delta` setup, but every piece is optional and
swappable (see _For the implementing agent_).
---
## 1. Quick reference — the workflow & commands
Once installed, this is the whole day-to-day surface.
### From the shell / tmux
| You want to… | Do this |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **GitHub hub** | tmux `prefix + g` → **Pull requests** · **Issues** · **New issue**. Every menu has a `?`-toggled panel (context help, or the rendered issue). |
| ↳ **Pull requests** | pick a PR → **View** (description + comments, paged) · **Review** (worktree) · **Comment** (plain, not a review) · **Approve** · **Merge…** (squash / merge commit / rebase / auto) · **Close…**. `?` previews the diff. View returns to the menu; merge/close/comment ask an explicit y/N. |
| ↳ **Issues** | fuzzy-search open issues → the rendered issue (body + comments) is shown while you choose → **View** (whole thread, paged; `q` returns to the menu) · **Comment** (write in nvim, read it back, confirm) · **Close…** (completed / not planned) |
| ↳ **New issue** | title at the prompt → body in nvim → read it back → confirm. Nothing is created until you confirm. |
| **List open PRs** | `prs` (alias for `gh pr list`) |
| **Read a PR's diff** | `prdiff <n>` (add `-s` for side-by-side), or `?` in the popup |
| **Open a PR to work on** | `prco <n>` → puts you in a tmux session on that PR's code. If the PR's branch is **already checked out in one of your own worktrees it reuses that**; otherwise it creates a managed worktree at `~/.local/share/pr-worktrees/<repo>/pr-<n>`. Commit + push works either way. |
| **Write inline review comments** | `prreview <n>` → annotate the review file in nvim → `prsubmit <n>` |
| **Quick approve** | `gh pr review <n> --approve` |
| **Tear down a PR checkout** | `prclean <n>` (or bare `prclean` to fuzzy-pick). Refuses if there's uncommitted or unpushed work (`-f` forces). **Only ever removes worktrees it created** — your own worktrees are never touched. |
### In Neovim — review mode
Review mode pins gitsigns' baseline to the PR's **target branch**, so the whole codebase shows the
PR's changes as you browse normally (not just your uncommitted edits). It auto-activates in a
managed `prco` worktree; in a **reused** worktree just press `<leader>gq` or `<leader>gp` — they
detect the PR live.
| Key | Does |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `<leader>gq` | **Every changed hunk in the repo → quickfix.** The main entry point: step through the PR's changes in the real files, editing normally. |
| `<leader>ghp` | **Preview this hunk inline** — shows exactly what was added/removed at the cursor _(LazyVim default)_ |
| `]h` / `[h` | Next / previous hunk _(LazyVim default)_ |
| `<leader>gw` | Toggle **inline diff** for the file — deleted lines shown inline + word-level highlighting; reads like a diff but stays editable |
| `<leader>gp` | **Diffview panel** — changed-files list + side-by-side. The right pane is the _working tree_, so you can fix a mistake and `:w` without leaving the diff |
| `<leader>gv` | Open this PR's **prr review file** as a normal buffer _in this nvim_ (downloads it if needed) — write comments next to the code you're reviewing |
| `<leader>gV` | **Submit** the prr review (writes the buffer, then confirms before posting) |
| `<leader>gP` | Review mode off (gutters back to normal) |
### Typical loops
**Fast review (no checkout):**
`prefix + g` → pick PR → **Diff** to read → **Review** (comments in nvim) or **Approve**. Done.
**Deep review (read + fix the real code):**
```
prco 142 # tmux session on the PR's code (reused or managed worktree)
nvim .
<leader>gq # all of the PR's changes -> quickfix
<leader>ghp # on a hunk: see exactly what changed here
…fix it in place, :w
]h # next change
<leader>gp # side-by-side for a tricky file (right pane is editable)
<leader>gv # open the prr review file right here; write comments
<leader>gV # submit the review (confirms first)
git commit && git push # pushes back to the PR branch
prclean 142 # only if prco created the worktree; safe by default
```
`prreview`/`prsubmit` (Module A) do the same from a shell, but they spawn a _second_ `$EDITOR`. If
you're already in nvim in the PR's worktree, `<leader>gv`/`<leader>gV` keep everything in one place.
---
## 2. What this is and why (for anyone with no prior context)
**The problem.** If you live in the terminal (tmux, a TUI editor, `gh`, `lazygit`), reviewing
GitHub PRs is the one task that keeps yanking you back to a browser: reading diffs, checking out
branches to try them, leaving line comments. That context-switch is slow and breaks flow.
**The goal.** Make the everyday GitHub loop native to the terminal:
- **Browse/triage** open PRs and issues from a fuzzy picker.
- **Read** diffs with good syntax highlighting, and issues rendered properly.
- **Manage issues** — search, read, comment, close, create — without opening a browser.
- **Try locally** in a way that never disturbs your current work — each PR gets its own git
_worktree_ and tmux _session_, cleanly isolated and trivially torn down (and if you already keep
a worktree for that branch, it's reused rather than duplicated).
- **Review in your editor** by navigating the actual checked-out codebase, with the PR's changed
lines marked relative to its target branch (not just "what I edited").
- **Comment** with real inline GitHub review comments, written in your editor.
**The design ethos:** a handful of tiny scripts + your existing tools, one source of truth,
nothing bespoke where a standard tool already fits. Each part below is independently useful —
adopt only what you want.
---
## 3. For the implementing agent (read this first)
> ### ⚠️ Default: build this as specified — then check in about variations
>
> The reader has most likely seen this workflow demonstrated and wants **the same thing on their
> machine**. So **reproduce it as described by default.** It's a coherent whole: the modules were
> designed to fit together, and the defaults encode decisions arrived at by actually hitting the
> problems listed in _Known gotchas_. Treat those defaults as load-bearing.
>
> **Do not open with "so which parts would you like?"** — that dumps design work on someone who
> just wants the setup working. Instead:
>
> 1. **State the plan and confirm it.** e.g. _"I'll set up the full workflow: PR checkout into
> isolated worktrees + tmux sessions, in-editor review in Neovim, inline review comments via
> prr, and the `prefix + g` GitHub popup for PRs and issues. Want all of that, or shall I leave
> anything out?"_ One question, easy to say "yes, all of it" to.
> 2. **Interview where their environment forces a decision** — different shell, editor,
> multiplexer, OS, or a keybinding that's already taken. These are compatibility questions, not
> preference questions; every module lists alternatives for exactly this. Don't silently
> substitute — ask, then adapt.
> 3. **Call out the genuinely optional pieces** so opting out is cheap and informed: `prr`
> (Module B — only needed for _inline, line-level_ review comments; `gh pr review` already
> covers approve and top-level comments) and the shell abbreviation (Module E).
> 4. **Then invite variations** — different keymaps, a different worktree location, extra
> top-level popup entries (releases, Actions runs, notifications, "review requested of me").
> Build those if asked. Don't propose a redesign unprompted.
>
> Deviate when the user asks, or when their environment requires it. Don't deviate because
> something looks tidier — that's how the gotchas get reintroduced.
>
> That said, nothing here _requires_ all-or-nothing: the modules are independent, so a user who
> only wants one part can have it, and more can be added later. Just let that be their call.
You are setting this up on **someone else's machine**, which will differ from the author's. Before
installing anything:
1. **Inventory what's already installed** (§4) — so you know what actually needs adding, and where
their stack differs from the author's. This is reconnaissance, not a prompt to re-litigate
whether each module is wanted; that's settled by the single confirmation above.
2. **Then walk the dependencies module by module.** For each missing one, say briefly what it's
for and install it. Pause only where their environment points at an alternative (no Neovim, no
tmux, a different pager) — surface the choice, take their answer, move on.
3. **Adapt to their environment.** Paths, shell (fish vs zsh/bash), editor, terminal multiplexer,
and leader keys will differ. Genericize accordingly.
4. **Preserve modularity.** Keep the scripts as separate files so the user can delete/replace any
one without breaking the others.
The author's baseline (for reference; the user's may differ): macOS, fish shell, tmux, Neovim
(LazyVim), Ghostty, `gh` authenticated over SSH, `git-delta` as the git pager.
### Known gotchas — every one of these was hit in practice
Don't "simplify" these away; each is load-bearing. Details in the module sections.
1. **A PR's branch may already be checked out in another worktree.** Git refuses to check a branch
out twice, so `gh pr checkout` fails hard. Extremely common if the user keeps a worktree per
feature branch and opens PRs from them. `prco` detects and reuses. _(Module A)_
2. **Never delete a worktree you didn't create.** Reused worktrees are the user's real work;
`prclean` must only touch its own managed paths. _(Module A)_
3. **`prclean` must not blindly `--force`.** Check for uncommitted changes _and_ commits not on any
remote before removing, or you silently destroy work. _(Module A)_
4. **`set -e` inside a tmux popup hides all errors** — the popup tears down before anything can be
read, so failures look like "nothing happened." Wrap actions so failures pause. _(Module D)_
5. **diffview: diff a single rev, not a `base...HEAD` range.** A range diffs two historical commits
→ both panes read-only. A single rev puts the working tree on the right → editable. _(Module C)_
6. **gitsigns' global `change_base` doesn't reach buffers that attach later.** They keep
`base = nil` and diff against HEAD, so no gutter signs on anything you open afterwards — the
feature silently appears to do nothing. Re-apply per buffer on attach. _(Module C)_
7. **LazyVim's gitsigns keymaps are buffer-local** (set in `on_attach`), so they're invisible to
`nvim_get_keymap`. Check `nvim_buf_get_keymap(0,'n')` in an attached buffer before concluding a
key is missing or free. _(Module C)_
8. **fzf runs `--preview`/`execute` strings through `$SHELL -c`.** If the user's `$SHELL` is fish
(or any non-POSIX shell), bash-isms in a preview string break at runtime with something like
`fish: ${ is not a valid variable in fish`. Ours hit this with a `VAR=value cmd` prefix and
`${VAR:-default}`. **Keep preview strings to plain command invocations** — put any real logic
behind a flag on your own script (`pr-popup --issue {1}`) where you control the interpreter.
_(Module D)_
9. **`gh` prints raw `key: value` dumps when piped.** `gh issue view` in a preview pane looks
nothing like the terminal output. Set `GH_FORCE_TTY` (fzf exports `FZF_PREVIEW_COLUMNS`, so
`GH_FORCE_TTY=$FZF_PREVIEW_COLUMNS` renders it properly _and_ wraps to the pane). _(Module D)_
10. **Don't let `gh` open the editor for you.** `gh issue comment --editor` and `gh issue create`'s
interactive body step errored inside the popup. Owning that step — open `$EDITOR` on a temp
file yourself, then pass `--body-file` — is predictable, works everywhere, and lets you show
the text back and confirm before anything posts. _(Module D)_
11. **`gh <kind> view` omits comments unless you pass `--comments`.** Easy to miss, because the
output looks complete — you get the description and assume that's the whole thing, when the
entire discussion is silently absent. Applies to both `gh pr view` and `gh issue view`, in
previews _and_ full views. _(Module D)_
12. **Tooling that opens the user's editor will surface _their_ pre-existing config errors.** Our
temp files are `.md`, which exposed a broken `FileType markdown` autocmd in the author's nvim
(it `require`d a plugin removed in a migration) — it looked like our bug but fired on every
markdown buffer. If the editor errors on open, reproduce with a bare
`nvim --headless <file> -c 'messages'` before blaming the tooling. _(Module D)_
---
## 4. Dependencies at a glance
| Module | Hard deps | Optional / alternatives |
| ----------------------------------------------------------- | ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **A. Core scripts** (browse/diff/checkout/cleanup) | `git`, `gh` (authenticated), `fzf`, `tmux` | `git-delta` for pretty diffs → else use `gh pr diff \| less -R`, `difftastic`, or `bat` |
| **B. Review authoring** (`prr`) | `prr`, a GitHub token | Alternatives: `gh pr review` (no inline comments), `octo.nvim`, GitHub web |
| **C. In-editor review** (Neovim) | Neovim, `gitsigns.nvim` (with `change_base`), `diffview.nvim`, a plugin manager | Alternatives: `octo.nvim`, manual `:DiffviewOpen`, VS Code + GitLens, JetBrains |
| **D. GitHub popup** (`prefix + g`) — PRs, issues, new issue | `tmux` ≥ 3.2 (`display-popup`), `fzf`, `gh` | Or run the script as a normal command (no tmux); or use `gh-dash` / `lazygit` custom commands instead. Easy to extend to releases, Actions, notifications… |
| **E. Shell abbreviation** | `fish` | zsh/bash alias equivalent |
**Common setup for the scripts:** put them in a directory on your `PATH` (e.g. `~/bin` or
`~/.local/bin`), `chmod +x` them. They are POSIX-ish bash and avoid Bash-4 features so they run on
macOS's stock bash 3.2. `gh` must be logged in (`gh auth login`).
---
## Module A — Core scripts (`~/bin/pr*`)
The foundation: browse, diff, check-out-to-worktree, and clean up. Depends on `git`, `gh`, `fzf`,
`tmux` (and `delta` for `prdiff`, easily swapped).
**Key design points / gotchas:**
- Worktrees are stored at `~/.local/share/pr-worktrees/<repo>/pr-<n>` — _outside_ the repo, so
nothing needs `.gitignore`, and one repo can have many PRs checked out at once.
- **If you already run a worktree-per-branch workflow, this matters:** git refuses to check out a
branch that's already checked out in another worktree, so `gh pr checkout` fails hard for any PR
whose branch you already have locally (very common when the PRs are your own feature branches).
`prco` therefore checks first — if the PR's head branch is already in a worktree, it **reuses**
that worktree and just opens a session there (named after the directory), rather than creating a
competing checkout. Those reused worktrees are _not_ managed: `prclean` will never delete them,
and no `pr-review-base` marker is written into them (use `<leader>gp` in nvim there, which
detects the PR live).
- `prco` uses `gh pr checkout` (not a bare fetch) so you can commit and push back to the PR branch.
Pushing back to a **fork** PR still requires the PR author enabled "allow maintainer edits."
- `prclean` is **safe by default**: it refuses to delete a worktree with uncommitted changes or
commits not pushed to any remote. `-f` overrides. It also removes the local branch and the
tmux session, and works even when run from inside the PR's own session.
- tmux tip: set `set -g detach-on-destroy off` so killing a PR session lands you on another
session instead of detaching.
### `prdiff` — view a PR's diff through delta
```bash
#!/usr/bin/env bash
# prdiff <pr-number> [-s] — view a GitHub PR's diff through delta
# -s / --side-by-side : force side-by-side (overrides your delta config)
# Runs inside a GitHub repo. delta pages with less; navigate=true → n/N jumps files.
set -eu
n="${1:-}"
if [ -z "$n" ]; then echo "usage: prdiff <pr-number> [-s]" >&2; exit 2; fi
case "${2:-}" in
-s|--side-by-side) gh pr diff "$n" | delta --side-by-side ;;
*) gh pr diff "$n" | delta ;;
esac
```
_No delta?_ Replace the two `delta` lines with `gh pr diff "$n" | less -R` (uses gh's own
coloring), or pipe to `difftastic`/`bat`.
_If delta warns `Unknown theme '<name>', using default`:_ the theme was configured but never
installed, so you've been silently getting the fallback. delta uses bat's theme machinery — drop
the `.tmTheme` into `$(bat --config-dir)/themes/` and run `bat cache --build`. (Many colourschemes
ship one; e.g. tokyonight.nvim has them under `extras/sublime/`.) Rebuild again after a bat upgrade.
### `prco` — check a PR out into an isolated worktree + tmux session
```bash
#!/usr/bin/env bash
# prco [--no-switch] <pr-number>
# Open a PR in a dedicated tmux session.
#
# If the PR's head branch is ALREADY checked out in one of your existing worktrees, that
# worktree is reused (git can't check a branch out twice). prco does not own it and
# `prclean` will never delete it — you just get a session there.
#
# Otherwise the PR is checked out into a managed worktree under
# ~/.local/share/pr-worktrees/<repo>/pr-<n> (never inside the repo), via `gh pr checkout`
# so committing + pushing back to the PR branch works (fork push-back still requires the
# PR's "allow maintainer edits"). Managed worktrees also record the PR's base branch in
# $gitdir/pr-review-base so nvim auto-enters review mode (gitsigns + diffview).
# In a reused worktree, use <leader>gp in nvim instead — it detects the PR live.
#
# --no-switch : create the session but don't switch to it (prints how to jump instead).
set -eu
switch=1
if [ "${1:-}" = "--no-switch" ]; then switch=0; shift; fi
n="${1:-}"
if [ -z "$n" ]; then echo "usage: prco [--no-switch] <pr-number>" >&2; exit 2; fi
# Resolve the MAIN worktree so repo name/paths are stable even when invoked from a linked one.
main=$(git worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2; exit}')
if [ -z "$main" ]; then echo "prco: not inside a git repository" >&2; exit 1; fi
name=$(basename "$main")
# Is this PR's branch already checked out in a worktree? If so, reuse it.
head=$(gh pr view "$n" --json headRefName -q .headRefName 2>/dev/null || true)
existing=""
if [ -n "$head" ]; then
existing=$(git -C "$main" worktree list --porcelain 2>/dev/null \
| awk -v b="refs/heads/$head" '/^worktree /{p=$2} $1=="branch" && $2==b {print p; exit}')
fi
if [ -n "$existing" ]; then
# --- Reuse an existing worktree (yours). Session named after the dir, matching `t`. ---
wt="$existing"
# printf (not echo/basename alone) so the trailing newline isn't turned into a stray '_'.
session=$(printf '%s' "$(basename "$wt")" | tr -c 'A-Za-z0-9_-' '_')
echo "PR #$n is on '$head', already checked out at:"
echo " $wt"
echo "Using that worktree (prco doesn't manage it; prclean won't touch it)."
else
# --- Create a managed worktree for this PR. ---
wt="$HOME/.local/share/pr-worktrees/$name/pr-$n"
session="pr-$n"
if [ ! -d "$wt" ]; then
mkdir -p "$(dirname "$wt")"
git -C "$main" worktree add --detach "$wt" HEAD >/dev/null
if ! ( cd "$wt" && gh pr checkout "$n" ); then
echo "prco: 'gh pr checkout $n' failed" >&2
git -C "$main" worktree remove --force "$wt" 2>/dev/null || true
rm -rf "$wt"
rmdir "$(dirname "$wt")" 2>/dev/null || true
exit 1
fi
fi
# Record the PR's base branch for nvim review mode, and fetch it so gitsigns/diffview
# can diff against it offline. Managed worktrees only — never touch your own worktrees.
gitdir=$(git -C "$wt" rev-parse --absolute-git-dir 2>/dev/null || true)
if [ -n "$gitdir" ] && [ ! -f "$gitdir/pr-review-base" ]; then
base=$(gh pr view "$n" --json baseRefName -q .baseRefName 2>/dev/null || true)
if [ -n "$base" ]; then
git -C "$wt" fetch -q origin "$base" 2>/dev/null || true
printf 'origin/%s\n' "$base" > "$gitdir/pr-review-base" 2>/dev/null || true
fi
fi
fi
# Create the session (detached) only if it doesn't already exist — no attach side effect.
tmux has-session -t "=$session" 2>/dev/null || tmux new-session -d -s "$session" -c "$wt"
if [ "$switch" = 1 ]; then
if [ -n "${TMUX:-}" ]; then tmux switch-client -t "$session"; else tmux attach -t "$session"; fi
else
echo "Session '$session' ready at $wt"
echo " jump with: prefix+T (or: tmux switch-client -t $session)"
fi
```
_Not using Module C (nvim)?_ The `pr-review-base` block is harmless but unnecessary — you can
delete it. _No tmux?_ Replace the session lines with a plain `cd "$wt"` and open a shell/editor
there yourself.
### `prreview` / `prsubmit` — write & submit inline review comments (needs Module B)
```bash
#!/usr/bin/env bash
# prreview <pr-number>
# Download the current repo's PR into a prr review file and open it in $EDITOR (nvim).
# Add inline comments in the review file, save/quit, then run `prsubmit <pr-number>`.
# Re-run to reopen an in-progress review without re-downloading.
set -eu
n="${1:-}"
if [ -z "$n" ]; then echo "usage: prreview <pr-number>" >&2; exit 2; fi
slug=$(gh repo view --json nameWithOwner -q .nameWithOwner)
# First time: fetch + open. If a review already exists, just reopen it.
prr get "$slug/$n" --open 2>/dev/null || prr edit "$slug/$n"
```
```bash
#!/usr/bin/env bash
# prsubmit <pr-number>
# Submit the prr review for the current repo's PR (posts inline comments as a GitHub review).
set -eu
n="${1:-}"
if [ -z "$n" ]; then echo "usage: prsubmit <pr-number>" >&2; exit 2; fi
slug=$(gh repo view --json nameWithOwner -q .nameWithOwner)
prr submit "$slug/$n"
echo "Submitted review for $slug#$n"
```
### `prclean` — safe, complete teardown
```bash
#!/usr/bin/env bash
# prclean [-f] [<pr-number>]
# Tear a PR checkout all the way down: worktree + its tmux session + local branch + prr review.
# No <pr-number> → fzf-pick from the PR checkouts that exist for this repo.
# SAFE by default: refuses if the worktree has uncommitted changes or commits not on any
# remote. Pass -f to discard anyway. Works even when run from inside the PR's own session.
set -eu
force=0
case "${1:-}" in -f|--force) force=1; shift ;; esac
main=$(git worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2; exit}')
if [ -z "$main" ]; then echo "prclean: not inside a git repository" >&2; exit 1; fi
name=$(basename "$main")
base="$HOME/.local/share/pr-worktrees/$name"
n="${1:-}"
if [ -z "$n" ]; then
if [ ! -d "$base" ] || [ -z "$(ls -A "$base" 2>/dev/null)" ]; then
echo "No PR checkouts for $name."; exit 0
fi
n=$(ls -1 "$base" 2>/dev/null | sed -n 's/^pr-//p' \
| fzf --prompt="clean PR ❯ " --header="$name — checked-out PRs" \
--preview="git -C '$base/pr-{}' status -sb 2>/dev/null") || exit 0
[ -n "$n" ] || exit 0
fi
wt="$base/pr-$n"
session="pr-$n"
if [ ! -d "$wt" ]; then
echo "prclean: no worktree at $wt"
tmux kill-session -t "=$session" 2>/dev/null && echo "(killed stray session $session)" || true
exit 0
fi
# --- Safety gate: don't discard work unless forced ---
if [ "$force" != 1 ]; then
dirty=$(git -C "$wt" status --porcelain 2>/dev/null || true)
unpushed=$(git -C "$wt" log --oneline HEAD --not --remotes 2>/dev/null | head -1 || true)
if [ -n "$dirty" ] || [ -n "$unpushed" ]; then
echo "prclean: pr-$n still has work:" >&2
[ -n "$dirty" ] && echo " - uncommitted changes" >&2
[ -n "$unpushed" ] && echo " - commits not pushed to any remote" >&2
echo " Push/commit first, or discard with: prclean -f $n" >&2
exit 1
fi
fi
branch=$(git -C "$wt" rev-parse --abbrev-ref HEAD 2>/dev/null || true)
slug=$( (cd "$main" && gh repo view --json nameWithOwner -q .nameWithOwner) 2>/dev/null || true)
# --- Do all the filesystem/git/review teardown BEFORE killing the session, so this still
# completes cleanly even when prclean is running inside the session being removed. ---
git -C "$main" worktree remove --force "$wt" 2>/dev/null || rm -rf "$wt"
git -C "$main" worktree prune 2>/dev/null || true
if [ -n "$branch" ] && [ "$branch" != "HEAD" ]; then
if [ "$force" = 1 ]; then
git -C "$main" branch -D "$branch" 2>/dev/null || true
else
git -C "$main" branch -d "$branch" 2>/dev/null \
|| echo "note: kept branch '$branch' (unmerged) — remove with: git -C '$main' branch -D '$branch'"
fi
fi
[ -n "$slug" ] && prr remove -f "$slug/$n" 2>/dev/null || true
rmdir "$base" 2>/dev/null || true
echo "Cleaned pr-$n (worktree${branch:+ + branch $branch} + session + review)."
# --- Session last: move the client off it first (detach-on-destroy off also covers this),
# then kill. If we were running inside it, everything above already finished. ---
if [ -n "${TMUX:-}" ] && [ "$(tmux display-message -p '#{session_name}' 2>/dev/null || true)" = "$session" ]; then
other=$(tmux list-sessions -F '#{session_name}' 2>/dev/null | grep -vx "$session" | head -1 || true)
[ -n "$other" ] && tmux switch-client -t "$other" 2>/dev/null || true
fi
tmux kill-session -t "=$session" 2>/dev/null || true
```
_The `prr remove` line is a no-op if you skip Module B._
**Install Module A:** save the files above into a `PATH` dir (author used `~/bin`) with those
exact names (no extension), then `chmod +x ~/bin/prdiff ~/bin/prco ~/bin/prreview ~/bin/prsubmit ~/bin/prclean`.
---
## Module B — Review authoring with `prr`
`prr` (github.com/danobi/prr) brings mailing-list-style review: it downloads a PR as a local file,
you annotate lines inline in your editor, then submit — producing a real GitHub review with inline
comments. Used by `prreview`/`prsubmit` above and the popup's **Review**/**Submit** actions.
**Install:** `brew install prr` (it's in Homebrew core) or `cargo install prr`.
**Configure** `~/.config/prr/config.toml` (chmod 600 — it holds a token):
```toml
[prr]
token = "<YOUR_GITHUB_TOKEN>" # a GitHub token with 'repo' scope (see below)
workdir = "~/.cache/prr" # any writable dir; where review files live
```
**Token options** (pick one, discuss with the user):
- Reuse the `gh` CLI token: `gh auth token` prints it; it typically has `repo` scope, which is what
`prr` needs to post reviews. Convenient, but if `gh` rotates it, `prr` breaks — then regenerate.
- A dedicated fine-grained/classic **Personal Access Token** with `repo` (or `public_repo`) scope.
More stable; recommended if this is a shared/long-lived setup.
> ⚠️ The token is a secret. Never commit `config.toml`; keep it `chmod 600`. It is **redacted** here.
**Commands:** `prr get owner/repo/<n> --open` → annotate in `$EDITOR` → `prr submit owner/repo/<n>`.
`prr edit` reopens; `prr apply` checks a PR out into the working dir; `prr remove` deletes a local review.
### The review-file format (the non-obvious part)
This is where people get stuck — the commands are easy, the file format is unfamiliar. The model is
**email/mailing-list**: the entire PR diff is quoted with `> `, and **anything you type that isn't
quoted becomes a review comment.**
Three things you can write:
1. **Inline comment** — unquoted text on a new line _immediately after_ the quoted diff line you're
commenting on. It posts as a line comment anchored there:
```
> - {
> - id: 'ram',
> - cooldownMs: 3000,
> - },
Was removing `ram` intentional? It's still referenced in the intel tree.
> {
> id: 'penetrating-laser',
```
2. **PR-level comment** — unquoted text at the very top, _before_ the first `> diff --git` line.
Only one allowed per review.
3. **Verdict directive** — a standalone line anywhere: `@prr approve`, `@prr reject`
(= request changes), or `@prr comment` (comment-only).
Rules and gotchas:
- Don't edit the `> ` lines — they're the diff; only add unquoted lines between them.
- The comment must _directly_ follow the line it refers to; that's how prr computes the anchor.
- `[...]` on its own line elides a chunk of quoted diff you don't care about.
- **You cannot approve your own PR** — GitHub rejects self-approval with a 422, so `@prr approve`
fails when testing on your own PRs. Use `@prr comment`.
- Nothing is sent until `prr submit`, so it's safe to open, scribble, and walk away.
**Docs** (the docs _home page_ is only a short intro — the detail is in these chapters):
- Review file syntax: https://doc.dxuuu.xyz/prr/review.html
- Tutorial: https://doc.dxuuu.xyz/prr/tutorial.html
- Config: https://doc.dxuuu.xyz/prr/config.html · Install: https://doc.dxuuu.xyz/prr/install.html
- Everything on one page (best for Ctrl-F): https://doc.dxuuu.xyz/prr/print.html
- Repo: https://github.com/danobi/prr
### Strongly recommended: the `.prr` Vim/Neovim plugin
Without it a review file is undifferentiated plain text — the quoted diff and your own comments look
identical, and a real review is thousands of lines (one measured example: 3,139 lines across 42
files). The prr repo ships a plugin under its `vim/` directory giving syntax colouring, filetype
detection and **folding** (level 1 = per file, level 2 = per hunk), so `zM` collapses a huge review
to a file list. Your comment lines are then the only *un*highlighted text, which makes "where do I
type" obvious.
**Gotcha:** the plugin is in the repo's `vim/` **subdirectory**, so a plugin manager that adds the
repo root to `runtimepath` won't find `ftdetect/ftplugin/syntax`. Register the filetype yourself and
append the subdirectory. lazy.nvim spec (`lua/plugins/prr.lua`):
```lua
return {
"danobi/prr",
lazy = false, -- three tiny vim files; must be on the rtp before any .prr file is opened
init = function()
vim.filetype.add({ extension = { prr = "prr" } })
end,
config = function(plugin)
vim.opt.runtimepath:append(plugin.dir .. "/vim")
-- open reviews collapsed to the file list; delete if you prefer fully expanded
vim.api.nvim_create_autocmd("FileType", {
pattern = "prr",
group = vim.api.nvim_create_augroup("prr_fold", { clear = true }),
callback = function() vim.opt_local.foldlevel = 0 end,
})
end,
}
```
Vundle equivalent (from the docs): `Plugin 'danobi/prr', {'rtp': 'vim/'}`.
Verify with: `:set ft?` → `prr`, `:echo b:current_syntax` → `prr`, `:set foldmethod?` → `expr`.
**Alternatives to Module B:** `gh pr review <n> --approve|--comment|--request-changes` (top-level
only, no per-line comments); `octo.nvim` (full review UI in Neovim); the GitHub web UI.
---
## Module C — In-editor PR review (Neovim: gitsigns + diffview)
Turns a checked-out PR worktree into a navigable review: as you open any file the normal way, the
gutter marks exactly the lines this PR changes **relative to its target branch** (merge-base of
`origin/<base>...HEAD`, i.e. GitHub "Files changed" semantics).
Three keymaps:
- `<leader>gp` — diffview panel (changed files + side-by-side). **Diff a single rev, not a
`base...HEAD` range**: a range diffs two historical commits so both panes are read-only, whereas
a single rev puts the _working tree_ on the right — so you can fix a mistake and `:w` without
leaving the diff. (Verified: range → 0 editable panes; single rev → the real file is editable.)
- `<leader>gq` — dump **every changed hunk in the repo** into the quickfix list. This is the
"just browse the codebase and see where the changes are" answer: jump between hunks in the real
files, with gutters on, editing normally — no diff view involved.
- `<leader>gP` — turn review mode off (gutters back to uncommitted-vs-HEAD).
- `<leader>gw` — toggle **inline diff**: deleted/old lines rendered inline plus word-level
highlighting, so a file reads as a diff while staying editable. Good for reading a whole file.
The gitsigns baseline persists after you close diffview, so once review mode is on you can browse
and edit the whole codebase with PR gutters showing.
**Reading an individual change:** a gutter sign tells you _where_ but not _what_. gitsigns already
covers this and LazyVim binds it **buffer-locally** (so it won't show up in a global keymap dump —
check `nvim_buf_get_keymap` inside an attached buffer):
`<leader>ghp` = **preview hunk inline** (expands the old lines in place — the main one),
`]h`/`[h` next/prev hunk, `]H`/`[H` first/last, `<leader>ghd`/`<leader>ghD` diff this file,
`<leader>ghb`/`<leader>ghB` blame. If a distro doesn't provide these, map
`require("gitsigns").preview_hunk_inline` (or `preview_hunk` for a float) yourself.
**How it works:** `prco` writes the PR's base branch into the worktree's private gitdir
(`$gitdir/pr-review-base`). On nvim startup in that worktree, an autocmd reads it and calls
gitsigns' `change_base`. Because each PR gets its own throwaway nvim (dedicated tmux session), the
global base change is naturally scoped and never affects your main editor.
**Depends on:** Neovim; `gitsigns.nvim` (must expose `change_base` — mainline does);
`diffview.nvim` (`sindrets/diffview.nvim`); a plugin manager. Author used **LazyVim** + `lazy.nvim`;
the files below are lazy.nvim plugin specs. If the user uses a different manager/distro, adapt the
spec wrapper — the logic in `pr_review.lua` is manager-agnostic.
**File 1 — the logic:** `~/.config/nvim/lua/util/pr_review.lua`
```lua
-- PR review helpers. See lua/plugins/pr-review.lua for the wiring.
--
-- Points gitsigns' diff baseline at the PR's target branch (the merge-base of
-- origin/<base>...HEAD — GitHub "Files changed" semantics) so the whole codebase reads normally
-- but every gutter marks exactly the PR's changed lines.
--
-- Entry points:
-- <leader>gp panel — diffview against the merge-base. Diffing a SINGLE rev (not
-- `base...HEAD`) makes the right-hand side the WORKING TREE, so you can
-- edit and :w fixes straight from the diff. A `base...HEAD` range would
-- be two historical blobs = read-only.
-- <leader>gq hunks — every changed hunk in the repo -> quickfix, for normal browsing/editing.
-- <leader>gP off — reset the baseline back to normal (uncommitted-vs-HEAD).
local M = {}
function M.absolute_git_dir()
local out = vim.fn.systemlist({ "git", "rev-parse", "--absolute-git-dir" })
if vim.v.shell_error ~= 0 or not out[1] or out[1] == "" then return nil end
return out[1]
end
-- Base ref recorded by prco, e.g. "origin/main". nil when not a prco worktree.
function M.marker_base()
local gd = M.absolute_git_dir()
if not gd then return nil end
local f = gd .. "/pr-review-base"
if vim.fn.filereadable(f) == 0 then return nil end
local line = (vim.fn.readfile(f) or {})[1]
if line and line ~= "" then return vim.trim(line) end
return nil
end
-- Live fallback: ask gh for the current branch's PR base (network).
function M.live_base()
local out = vim.fn.systemlist({ "gh", "pr", "view", "--json", "baseRefName", "-q", ".baseRefName" })
if vim.v.shell_error ~= 0 or not out[1] or out[1] == "" then return nil end
return "origin/" .. vim.trim(out[1])
end
function M.ensure_ref(base_ref)
vim.fn.system({ "git", "rev-parse", "--verify", "--quiet", base_ref })
if vim.v.shell_error ~= 0 then
vim.fn.system({ "git", "fetch", "origin", (base_ref:gsub("^origin/", "")) })
end
end
-- The fork point: what the PR actually branched from.
function M.merge_base(base_ref)
local mb = vim.fn.systemlist({ "git", "merge-base", base_ref, "HEAD" })
if vim.v.shell_error == 0 and mb[1] and mb[1] ~= "" then return vim.trim(mb[1]) end
return base_ref
end
local function gitsigns()
-- gitsigns is lazy-loaded on file events; force it so change_base has actually run setup.
pcall(function() require("lazy").load({ plugins = { "gitsigns.nvim" } }) end)
local ok, gs = pcall(require, "gitsigns")
return ok and gs or nil
end
-- The active review revision (merge-base sha), or nil when review mode is off.
M._rev = nil
local AUG = vim.api.nvim_create_augroup("pr_review_base", { clear = true })
-- gitsigns' global change_base only updates buffers that are ALREADY attached — a buffer that
-- attaches later keeps bcache.base = nil and diffs against HEAD (no gutter signs). Since review
-- mode is enabled before you browse, that's every file you open. So re-apply per buffer on attach.
-- gitsigns attaches asynchronously, hence the short retry.
function M.apply(bufnr, tries)
if not M._rev then return end
tries = tries or 8
if not vim.api.nvim_buf_is_valid(bufnr) or vim.bo[bufnr].buftype ~= "" then return end
if vim.b[bufnr].pr_review_based == M._rev then return end
local gs = gitsigns()
if not gs then return end
vim.api.nvim_buf_call(bufnr, function() pcall(gs.change_base, M._rev, false) end)
if gs.get_hunks(bufnr) ~= nil then
vim.b[bufnr].pr_review_based = M._rev
elseif tries > 1 then
vim.defer_fn(function() M.apply(bufnr, tries - 1) end, 60)
end
end
function M.set_base(base_ref, notify)
if not base_ref then return false end
local gs = gitsigns()
if not gs then return false end
M._rev = M.merge_base(base_ref)
pcall(gs.change_base, M._rev, true) -- global default + already-attached buffers
-- Catch every buffer opened from here on (quickfix jumps, telescope, neo-tree, …).
vim.api.nvim_clear_autocmds({ group = AUG })
vim.api.nvim_create_autocmd({ "BufReadPost", "BufWinEnter" }, {
group = AUG,
callback = function(ev) vim.schedule(function() M.apply(ev.buf) end) end,
})
-- …and any already loaded.
for _, b in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_is_loaded(b) then M.apply(b) end
end
if notify then
vim.notify("PR review ON — gutters vs " .. base_ref, vim.log.levels.INFO, { title = "PR review" })
end
return true
end
-- Resolve the base (marker first, then live gh) and turn review mode on. Returns the base ref.
function M.activate(notify)
local base = M.marker_base() or M.live_base()
if not base then
vim.notify("PR review: couldn't determine the PR base branch", vim.log.levels.WARN, { title = "PR review" })
return nil
end
M.ensure_ref(base)
M.set_base(base, notify)
return base
end
-- Auto-activate from the marker (offline, managed prco worktrees only). No-op elsewhere.
function M.auto()
local base = M.marker_base()
if base then M.set_base(base, true) end
end
-- <leader>gp — diffview against the merge-base; right-hand side is the working tree (EDITABLE).
function M.open_panel()
local base = M.activate(false)
if not base then return end
vim.cmd("DiffviewOpen " .. M.merge_base(base))
end
-- <leader>gq — every changed hunk in the repo into the quickfix list, so you can browse and edit
-- the real files normally (gutters stay on) instead of sitting inside a diff.
function M.changed_hunks()
local base = M.activate(false)
if not base then return end
local gs = gitsigns()
if not gs then return end
-- 'all' scans the whole repo against the current base.
pcall(gs.setqflist, "all", { open = true })
end
-- ── prr review file, opened as a normal buffer in THIS nvim ──────────────────────────────────
-- (`prreview` in the shell spawns a second $EDITOR; in a PR worktree you're already in nvim, so
-- just open the .prr file here alongside the code you're reviewing.)
local function sh(cmd)
local out = vim.fn.systemlist(cmd)
if vim.v.shell_error ~= 0 or not out[1] or out[1] == "" then return nil end
return vim.trim(out[1])
end
-- owner/repo and the PR number for the branch checked out here.
function M.pr_target()
local slug = sh({ "gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner" })
local num = sh({ "gh", "pr", "view", "--json", "number", "-q", ".number" })
if not slug or not num then return nil end
return slug, num
end
-- Where prr keeps review files, from ~/.config/prr/config.toml.
function M.prr_workdir()
local cfg = vim.fn.expand("~/.config/prr/config.toml")
if vim.fn.filereadable(cfg) == 0 then return nil end
for _, line in ipairs(vim.fn.readfile(cfg)) do
local w = line:match('^%s*workdir%s*=%s*"([^"]+)"')
if w then return vim.fn.expand(w) end
end
return nil
end
-- <leader>gv — open (downloading first if needed) this PR's prr review file in the current nvim.
function M.review()
local slug, num = M.pr_target()
if not slug then
vim.notify("prr: no PR found for this branch", vim.log.levels.WARN, { title = "PR review" })
return
end
local wd = M.prr_workdir()
if not wd then
vim.notify("prr: no workdir in ~/.config/prr/config.toml", vim.log.levels.ERROR, { title = "PR review" })
return
end
local path = ("%s/%s/%s.prr"):format(wd, slug, num)
if vim.fn.filereadable(path) == 0 then
vim.fn.system({ "prr", "get", ("%s/%s"):format(slug, num) })
if vim.fn.filereadable(path) == 0 then
vim.notify("prr get failed for " .. slug .. "/" .. num, vim.log.levels.ERROR, { title = "PR review" })
return
end
end
vim.cmd("edit " .. vim.fn.fnameescape(path))
vim.notify(("Review %s#%s — comment on UNquoted lines, then <leader>gV to submit"):format(slug, num),
vim.log.levels.INFO, { title = "PR review" })
end
-- <leader>gV — submit the review. Posts to GitHub, so confirm first.
function M.submit()
local slug, num = M.pr_target()
if not slug then
vim.notify("prr: no PR found for this branch", vim.log.levels.WARN, { title = "PR review" })
return
end
if vim.bo.modified and vim.api.nvim_buf_get_name(0):match("%.prr$") then vim.cmd("write") end
if vim.fn.confirm(("Submit prr review to GitHub for %s#%s?"):format(slug, num), "&No\n&Yes", 1) ~= 2 then
vim.notify("Submit cancelled", vim.log.levels.INFO, { title = "PR review" })
return
end
local out = vim.fn.systemlist({ "prr", "submit", ("%s/%s"):format(slug, num) })
if vim.v.shell_error ~= 0 then
vim.notify("prr submit failed:\n" .. table.concat(out, "\n"), vim.log.levels.ERROR, { title = "PR review" })
else
vim.notify(("Submitted review for %s#%s"):format(slug, num), vim.log.levels.INFO, { title = "PR review" })
end
end
-- <leader>gw — read the whole file as a diff without pressing anything per-hunk:
-- deleted/old lines shown inline, plus word-level highlighting of what changed within a line.
-- (Per-hunk on demand is LazyVim's <leader>ghp = preview_hunk_inline.)
M._inline = false
function M.toggle_inline()
local gs = gitsigns()
if not gs then return end
pcall(gs.toggle_deleted)
pcall(gs.toggle_word_diff)
M._inline = not M._inline
vim.notify("Inline diff " .. (M._inline and "ON — deleted lines + word diff" or "OFF"),
vim.log.levels.INFO, { title = "PR review" })
end
-- <leader>gP — back to normal (uncommitted-vs-HEAD gutters).
function M.off()
local gs = gitsigns()
if not gs then return end
M._rev = nil
vim.api.nvim_clear_autocmds({ group = AUG })
pcall(gs.change_base, nil, true)
for _, b in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_is_loaded(b) and vim.bo[b].buftype == "" then
vim.api.nvim_buf_call(b, function() pcall(gs.change_base, nil, false) end)
vim.b[b].pr_review_based = nil
end
end
vim.notify("PR review OFF — gutters back to normal", vim.log.levels.INFO, { title = "PR review" })
end
return M
```
**File 2 — the plugin spec / wiring:** `~/.config/nvim/lua/plugins/pr-review.lua`
```lua
-- PR review mode: diffview panel + gitsigns baseline pinned to the PR's target branch.
-- Auto-activates in `prco` worktrees (via the $gitdir/pr-review-base marker prco writes).
-- Logic lives in lua/util/pr_review.lua.
return {
"sindrets/diffview.nvim",
cmd = { "DiffviewOpen", "DiffviewClose", "DiffviewFileHistory", "DiffviewToggleFiles", "DiffviewFocusFiles" },
opts = {},
keys = {
{ "<leader>gp", function() require("util.pr_review").open_panel() end, desc = "PR review: diffview panel (editable)" },
{ "<leader>gq", function() require("util.pr_review").changed_hunks() end, desc = "PR review: changed hunks -> quickfix" },
{ "<leader>gw", function() require("util.pr_review").toggle_inline() end, desc = "PR review: inline diff (deleted + word diff)" },
{ "<leader>gv", function() require("util.pr_review").review() end, desc = "PR review: open prr review file here" },
{ "<leader>gV", function() require("util.pr_review").submit() end, desc = "PR review: submit prr review" },
{ "<leader>gP", function() require("util.pr_review").off() end, desc = "PR review: off (normal gutters)" },
},
init = function()
vim.api.nvim_create_autocmd("User", {
pattern = "VeryLazy",
group = vim.api.nvim_create_augroup("pr_review_auto", { clear = true }),
callback = function() require("util.pr_review").auto() end,
})
end,
}
```
**Notes / gotchas:**
- **The big one — gitsigns' global `change_base` does NOT apply to buffers that attach later.**
`change_base(rev, true)` sets the global default and refreshes _already-attached_ buffers, but a
buffer opened afterwards keeps `bcache.base = nil` and silently diffs against HEAD → no gutter
signs. Because review mode is switched on _before_ you start browsing, that's every file you
open — the feature appears to do nothing. The module works around it by re-applying
`change_base(rev, false)` per buffer from a `BufReadPost`/`BufWinEnter` autocmd (with a short
retry, since gitsigns attaches asynchronously). Measured in a real repo: without the workaround
a modified file reported 0 hunks; with it, 19. Don't remove `M.apply`/the autocmd.
- `<leader>gp` is the conventional "git PR" key in LazyVim's `octo`/`gh` extras. If the user has
either extra enabled, pick a different key to avoid a clash.
- `require("util.pr_review")` assumes the logic file is at `lua/util/pr_review.lua` on the nvim
runtimepath (standard for LazyVim). Adjust the module path if their layout differs.
- The `User VeryLazy` autocmd + `require("lazy")...load` calls are lazy.nvim-specific. On another
manager, trigger `M.auto()` from a `VimEnter`/`BufReadPre` autocmd and drop the `lazy.load` line
(ensure gitsigns is loaded some other way).
- To leave review mode manually: `:Gitsigns change_base` (reset). In this workflow you usually just
close the ephemeral PR-worktree nvim.
**Alternatives to Module C:** `octo.nvim` (browse/comment/approve PRs in-editor);
`:DiffviewOpen <base>...HEAD` by hand without the auto-marker; VS Code + GitLens; JetBrains.
---
## Module D — GitHub popup (`prefix + g`): PRs, issues, new issue
A fuzzy GitHub menu you can summon from anywhere without opening another TUI. Top level is
**Pull requests · Issues · New issue**; each branch is a small function around `gh`.
**Depends on:** `tmux` ≥ 3.2 (`display-popup`), `fzf`, `gh`, and Module A for the PR actions.
**Division of labour:** this popup handles the _conversation and administrative_ side (read the
description and comments, comment, approve, merge, close, create). Reading a PR's **code** and
writing **line-level review comments** happens in nvim after checking out (Module C). Keeping
those separate is what stopped the menu sprawling.
Two distinctions the menus deliberately make explicit, because they confuse people:
- **Comment on PR** (a plain conversation comment, `gh pr comment`) vs. **review comments**
(line-anchored, part of an approve/request-changes verdict — Module B via `<leader>gv`). The
cheatsheet panel for each points at the other.
- **View** (PRs _and_ issues) is read-only and _returns to the action menu_ when you quit the
pager, so the natural loop is read the discussion → decide → act. That's why both action menus
sit in a `while` loop with `continue` for view and `break` for everything else. A preview pane
is fine for glancing; it's the wrong place to read a long thread.
**Two functions serve both PRs and issues** — `gh pr` and `gh issue` take identical flags for
these, so don't fork them or the two flows will drift apart:
`view_item <kind> <n>` (paged `gh <kind> view --comments`) and
`comment_on <kind> <n>` (edit → confirm → `gh <kind> comment --body-file`).
**Extending it:** the shape is deliberately boring — one fzf menu → one function → one `gh` call.
Adding `gh release`, `gh run`, `gh api` for notifications, "PRs awaiting my review", or a
multi-repo picker is a new entry in the top-level menu plus a function. Do that rather than
cramming more into the existing branches.
### Design notes worth keeping
- **Preview strings must be shell-agnostic.** fzf runs them via `$SHELL -c`; on a fish machine
bash syntax dies at runtime. Both the cheatsheet and the issue detail are rendered by
re-invoking the script itself (`pr-popup --cheat <key>` / `pr-popup --issue <n>`), so the
preview string is a plain command and all real logic stays in bash. Do the same for anything
you add.
- **`GH_FORCE_TTY`** makes `gh` render properly instead of dumping `key: value` when piped, and
wraps to the pane when given `$FZF_PREVIEW_COLUMNS`.
- **Own the editor step.** Don't use `gh ... --editor` or gh's interactive body prompt; open
`$EDITOR` on a temp file and pass `--body-file`. Predictable, and you can show the text back
and confirm before posting. `gh issue comment` and `gh pr comment` take identical flags, so one
`comment_on <kind> <n>` serves both — don't fork it, or issues and PRs will drift apart.
- **Confirm outward-facing actions.** Merge, close (PR and issue), comment, and create all take
an explicit `y/N`. An fzf pick is too easy to fat-finger for something irreversible. Approve is
deliberately one-step (reversible, and wanted to be quick).
- **Never let a failure kill the popup silently.** With `set -e` the popup tears down before the
error can be read; the `run()` wrapper pauses instead.
### Script — `~/bin/pr-popup`
```bash
#!/usr/bin/env bash
# pr-popup — fzf-driven GitHub manager for a tmux display-popup (bound to prefix+g).
#
# Top level: Pull requests · Issues · New issue
# Pull requests → browse, then View / Review (worktree) / Comment / Approve / Merge… / Close…
# Issues → browse + search, then View / Comment / Close…
# New issue → title prompt → body in nvim → confirm
#
# Runs inside the popup with cwd = the pane's repo (display-popup -d #{pane_current_path}).
# Reading a PR and writing review comments happens in nvim once it's checked out
# (<leader>gq / gv / gp) — this menu is for acting on things. `?` toggles the preview pane.
set -eu
# The popup inherits tmux's server env, which may lack these dirs — make sure gh, delta,
# fzf, and the pr* scripts all resolve.
export PATH="$HOME/bin:/opt/homebrew/bin:$PATH"
FZF=$(command -v fzf 2>/dev/null || echo /opt/homebrew/bin/fzf)
pause() { printf '\n'; read -r -p "Press Enter to close… " _ || true; }
# Run an action, but never let a failure silently kill the popup — without this, `set -e`
# tears the popup down before you can read the error.
run() {
if ! "$@"; then
rc=$?
printf '\n\033[31m✖ %s failed (exit %s)\033[0m\n' "$1" "$rc" >&2
pause
exit 1
fi
}
# Outward-facing / awkward-to-undo actions shouldn't fire on an fzf pick alone.
confirm() {
local ans
printf '\n%s [y/N] ' "$1"
read -r ans || return 1
case "$ans" in [yY] | [yY][eE][sS]) return 0 ;; *) return 1 ;; esac
}
# ── shared: composing a comment ──────────────────────────────────────────────────────────────
# Open $EDITOR on a scratch file and hand the result to gh as --body-file, rather than letting
# gh launch the editor itself (`--editor` / its interactive body prompt). gh's own editor path
# errored inside the popup; owning it is predictable and lets you confirm before posting.
edit_body() { # $1 = file to edit; returns 0 if it ended up non-empty
"${EDITOR:-nvim}" "$1" || true
grep -q '[^[:space:]]' "$1" 2>/dev/null
}
# `gh issue comment` and `gh pr comment` take the same flags, so one function serves both.
comment_on() { # $1 = issue|pr, $2 = number
local kind="$1" n="$2" dir file
dir=$(mktemp -d); file="$dir/comment.md"; : > "$file"
printf 'Opening %s for your comment on %s #%s (save & quit when done)…\n' "${EDITOR:-nvim}" "$kind" "$n"
if ! edit_body "$file"; then
printf '\nEmpty comment — nothing posted.\n'; rm -rf "$dir"; pause; return 0
fi
printf '\n\033[2m--- your comment ---\033[0m\n'; cat "$file"; printf '\033[2m--------------------\033[0m\n'
if ! confirm "Post this comment on $kind #$n?"; then
printf '\nCancelled.\n'; rm -rf "$dir"; pause; return 0
fi
if gh "$kind" comment "$n" --body-file "$file"; then
printf '\nCommented on #%s.\n' "$n"
else
printf '\n\033[31m✖ comment failed\033[0m\n' >&2
fi
rm -rf "$dir"; pause
}
# The description + full comment chain, rendered and paged. Read-only. `gh pr view` and
# `gh issue view` both take --comments, so one function serves both.
view_item() { # $1 = pr|issue, $2 = number
local kind="$1" n="$2" w
w=$(tput cols 2>/dev/null || echo 100)
GH_FORCE_TTY="$w" gh "$kind" view "$n" --comments | ${PAGER:-less} -R
}
# ── pull requests ────────────────────────────────────────────────────────────────────────────
# What (if anything) is there to clean up locally for this PR? `prclean` only manages worktrees
# prco created — if prco reused one of YOUR worktrees, prclean is deliberately a no-op, so
# suggesting it would be misleading.
cleanup_hint() {
local n="$1" main name managed head existing
main=$(git worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2; exit}')
[ -n "${main:-}" ] || return 0
name=$(basename "$main")
managed="$HOME/.local/share/pr-worktrees/$name/pr-$n"
if [ -d "$managed" ]; then
printf 'Local cleanup: prclean %s\n' "$n"
return 0
fi
head=$(gh pr view "$n" --json headRefName -q .headRefName 2>/dev/null || true)
if [ -n "${head:-}" ]; then
existing=$(git -C "$main" worktree list --porcelain 2>/dev/null \
| awk -v b="refs/heads/$head" '/^worktree /{p=$2} $1=="branch" && $2==b {print p; exit}')
if [ -n "${existing:-}" ]; then
printf 'Your worktree is untouched: %s\n' "$existing"
printf ' branch %s — remove it yourself when done (prclean only manages its own).\n' "$head"
return 0
fi
fi
printf 'No local checkout for #%s.\n' "$n"
}
merge_pr() {
local n="$1" method label
local flags
method=$(printf '%s\t%s\n' \
squash "Squash and merge" \
merge "Create a merge commit" \
rebase "Rebase and merge" \
auto "Auto-merge when checks pass (squash)" \
| "$FZF" --delimiter='\t' --with-nth=2 --prompt="Merge #$n how? ❯ " \
| cut -f1) || return 0
[ -n "${method:-}" ] || return 0
case "$method" in
squash) flags=(--squash); label="squash and merge" ;;
merge) flags=(--merge); label="merge commit" ;;
rebase) flags=(--rebase); label="rebase and merge" ;;
auto) flags=(--squash --auto); label="auto-merge when checks pass (squash)" ;;
*) return 0 ;;
esac
if ! confirm "Merge #$n via $label?"; then printf '\nCancelled.\n'; pause; return 0; fi
# Deliberately no --delete-branch: with a worktree-per-branch workflow the local branch is
# usually checked out somewhere, which makes the delete fail after the merge has landed.
if gh pr merge "$n" "${flags[@]}"; then
printf '\nMerged #%s (%s).\n' "$n" "$label"
cleanup_hint "$n"
else
printf '\n\033[31m✖ merge failed\033[0m\n' >&2
fi
pause
}
close_pr() {
local n="$1"
if ! confirm "Close #$n WITHOUT merging?"; then printf '\nCancelled.\n'; pause; return 0; fi
if gh pr close "$n"; then
printf '\nClosed #%s.\n' "$n"
cleanup_hint "$n"
else
printf '\n\033[31m✖ close failed\033[0m\n' >&2
fi
pause
}
pr_flow() {
local list pr action
list=$(gh pr list --limit 50 --json number,title,author,headRefName \
--jq '.[] | "\(.number)\t\(.title)\t@\(.author.login)\t\(.headRefName)"' 2>/dev/null || true)
if [ -z "$list" ]; then
echo "No open PRs in $slug."; pause; return 0
fi
# '?' toggles a delta diff preview (hidden by default to stay snappy — it's a network call).
pr=$(printf '%s\n' "$list" \
| "$FZF" --delimiter='\t' --with-nth=1,2,3 \
--prompt="PR ❯ " --header="$slug — open PRs (? = diff preview)" \
--preview='gh pr diff {1} | delta' \
--preview-window='right,60%,hidden,wrap' \
--bind='?:toggle-preview' \
| cut -f1) || return 0
[ -n "${pr:-}" ] || return 0
# Loop so "View PR" returns here — read the conversation, then decide what to do about it.
while true; do
action=$(printf '%s\t%s\n' \
view "View PR — description + comments" \
review "Review PR — worktree + tmux session" \
comment "Comment on PR (not a review)" \
approve "Approve PR (no comment)" \
merge "Merge PR…" \
close "Close PR (without merging)…" \
| "$FZF" --delimiter='\t' --with-nth=2 --prompt="Action for #$pr ❯ " \
--header="$slug #$pr (? toggles this panel)" \
--preview="'$0' --cheat {1}" \
--preview-window='right,62%,wrap' \
--bind='?:toggle-preview' \
| cut -f1) || return 0
[ -n "${action:-}" ] || return 0
case "$action" in
view) view_item pr "$pr"; continue ;; # back to the menu when you quit the pager
review) run prco "$pr"; break ;; # switches the client to the PR session
comment) comment_on pr "$pr"; break ;;
approve) run gh pr review "$pr" --approve; printf '\nApproved #%s.\n' "$pr"; pause; break ;;
merge) merge_pr "$pr"; break ;;
close) close_pr "$pr"; break ;;
esac
done
}
# ── issues ───────────────────────────────────────────────────────────────────────────────────
close_issue() {
local n="$1" reason
reason=$(printf '%s\t%s\n' \
completed "Close as completed" \
"not planned" "Close as not planned" \
| "$FZF" --delimiter='\t' --with-nth=2 --prompt="Close #$n how? ❯ " \
| cut -f1) || return 0
[ -n "${reason:-}" ] || return 0
if ! confirm "Close issue #$n as \"$reason\"?"; then printf '\nCancelled.\n'; pause; return 0; fi
if gh issue close "$n" --reason "$reason"; then
printf '\nClosed issue #%s (%s).\n' "$n" "$reason"
else
printf '\n\033[31m✖ close failed\033[0m\n' >&2
fi
pause
}
issue_flow() {
local list issue action
list=$(gh issue list --limit 100 --json number,title,author \
--jq '.[] | "\(.number)\t\(.title)\t@\(.author.login)"' 2>/dev/null || true)
if [ -z "$list" ]; then
echo "No open issues in $slug."; pause; return 0
fi
# Type to fuzzy-search titles. '?' toggles the rendered issue detail.
issue=$(printf '%s\n' "$list" \
| "$FZF" --delimiter='\t' --with-nth=1,2,3 \
--prompt="Issue ❯ " --header="$slug — open issues (type to search · ? = detail)" \
--preview="'$0' --issue {1}" \
--preview-window='right,60%,hidden,wrap' \
--bind='?:toggle-preview' \
| cut -f1) || return 0
[ -n "${issue:-}" ] || return 0
# Detail is shown by default here, so you decide with the issue in front of you. That's a pane
# though — "View issue" pages the whole thread properly and returns here afterwards.
while true; do
action=$(printf '%s\t%s\n' \
view "View issue — description + comments" \
comment "Comment on this issue" \
close "Close issue…" \
| "$FZF" --delimiter='\t' --with-nth=2 --prompt="Issue #$issue ❯ " \
--header="$slug #$issue (? toggles detail)" \
--preview="'$0' --issue $issue" \
--preview-window='right,62%,wrap' \
--bind='?:toggle-preview' \
| cut -f1) || return 0
[ -n "${action:-}" ] || return 0
case "$action" in
view) view_item issue "$issue"; continue ;; # back to the menu when you quit the pager
comment) comment_on issue "$issue"; break ;;
close) close_issue "$issue"; break ;;
esac
done
}
new_issue() {
local title dir file
printf 'New issue in %s\n\n' "$slug"
printf 'Title: '
read -r title || return 0
if [ -z "${title:-}" ]; then printf '\nNo title — cancelled.\n'; pause; return 0; fi
dir=$(mktemp -d); file="$dir/issue.md"; : > "$file"
printf '\nOpening %s for the body — leave it empty for a title-only issue…\n' "${EDITOR:-nvim}"
edit_body "$file" || true
printf '\n\033[1mTitle:\033[0m %s\n' "$title"
if grep -q '[^[:space:]]' "$file" 2>/dev/null; then
printf '\033[2m--- body ---\033[0m\n'; cat "$file"; printf '\033[2m------------\033[0m\n'
else
printf '\033[2m(no body)\033[0m\n'
fi
if ! confirm "Create this issue in $slug?"; then
printf '\nCancelled — nothing created.\n'; rm -rf "$dir"; pause; return 0
fi
if gh issue create --title "$title" --body-file "$file"; then
printf '\nIssue created.\n'
else
printf '\n\033[31m✖ issue creation failed\033[0m\n' >&2
fi
rm -rf "$dir"; pause
}
# ── cheatsheet (rendered into fzf's preview pane) ────────────────────────────────────────────
# Abridged here — in the real script each branch is a `cat <<EOF` block using $B/$D/$C/$Y/$R ANSI
# vars for bold/dim/cyan/yellow/reset. Keep lines <= 55 columns so they fit the pane. Write these
# for YOUR keys and workflow; they are the in-context documentation for the whole thing.
# NOTE: the issue *action* menu previews the issue itself, not this cheatsheet — so issue actions
# don't need panels here; document them in the `issues` panel instead.
cheat() {
local B D C Y R
B=$'\033[1m'; D=$'\033[2m'; C=$'\033[36m'; Y=$'\033[33m'; R=$'\033[0m'
case "${1:-}" in
prs) ;; # top level: what the PR actions do; "? previews the diff"
issues) ;; # top level: search, detail, View/Comment/Close semantics
newissue) ;; # top level: title -> body -> confirm; nothing created until confirmed
view) ;; # gh pr view --comments; read-only, paged, returns to the menu
review) ;; # what prco does + the nvim keys (,gq ,ghp ]h ,gw ,gp ,gv ,gV ,gP)
# + prr syntax (quoted "> " diff, unquoted = comment, @prr verdicts, zM/za/zR)
comment) ;; # plain conversation comment vs. line-level review — point at Review PR + ,gv/,gV
approve) ;; # gh pr review --approve; runs immediately; self-approval 422
merge) ;; # the four methods + "asks y/N" + why no --delete-branch + prclean
close) ;; # gh pr close; asks y/N; reversible; prclean
esac
}
# Preview hooks. IMPORTANT: fzf runs preview/execute strings via `$SHELL -c`, and $SHELL here is
# fish — so a preview string must contain NO bash syntax (no `VAR=val cmd` prefix, no
# `${VAR:-default}`). Keep them plain command invocations and do the real work back in this
# script, where we know we're in bash.
if [ "${1:-}" = "--cheat" ]; then cheat "${2:-}"; exit 0; fi
if [ "${1:-}" = "--issue" ]; then
# GH_FORCE_TTY makes gh render the pretty view instead of a key:value dump when piped.
GH_FORCE_TTY="${FZF_PREVIEW_COLUMNS:-80}" exec gh issue view "${2:-}" --comments
fi
# Must be inside a GitHub repo.
if ! slug=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null); then
echo "Not inside a GitHub repository."; pause; exit 0
fi
# ── top level ────────────────────────────────────────────────────────────────────────────────
top=$(printf '%s\t%s\n' \
prs "Pull requests" \
issues "Issues" \
newissue "New issue…" \
| "$FZF" --delimiter='\t' --with-nth=2 --prompt="GitHub ❯ " \
--header="$slug (? toggles this panel)" \
--preview="'$0' --cheat {1}" \
--preview-window='right,62%,wrap' \
--bind='?:toggle-preview' \
| cut -f1) || exit 0
[ -n "${top:-}" ] || exit 0
case "$top" in
prs) pr_flow ;;
issues) issue_flow ;;
newissue) new_issue ;;
esac
```
`chmod +x ~/bin/pr-popup`. (The name is historical — it manages issues too. Rename it if you like;
just update the tmux binding.)
**tmux binding** (add to `~/.tmux.conf`; `g` is unbound by default — check the user's config):
```tmux
bind-key g display-popup -E -d "#{pane_current_path}" -w 90% -h 85% "$HOME/bin/pr-popup"
```
Reload with your reload binding or `tmux source-file ~/.tmux.conf`. If your tmux doesn't expand
`$HOME` there, use an absolute path. Pick a different key if `prefix + g` is taken.
**Alternatives to Module D:** run the script directly as a shell command (no tmux needed); or use
`gh-dash` for a richer standalone dashboard; or drive it from `lazygit` custom commands.
## Module E — shell convenience (fish)
One abbreviation for listing PRs. Author uses fish:
```fish
abbr -a prs "gh pr list"
```
**bash/zsh equivalent:** `alias prs='gh pr list'` in `~/.bashrc` / `~/.zshrc`.
The `pr*` scripts themselves need no shell integration — they're on `PATH` and callable from any
shell, tmux, or editor.
---
## 5. Install order (suggested)
Install all of it unless the user said otherwise (§3). This order builds the foundation first, then
the entry point, so you can demo progress as you go.
1. Ensure `git`, `gh` (`gh auth login`), `fzf`, `tmux` are present. Add `~/bin` to `PATH`.
2. **Module A** scripts → `chmod +x`. Test `prdiff <n>`, `prco <n>`, `prclean <n>` on a repo with an
open PR. Everything else builds on these.
3. **Module D** popup + tmux binding — the everyday entry point; `prefix + g` should now work.
4. **Module C** (Neovim) → add both lua files, install `diffview.nvim`, restart nvim.
5. **Module B** (`prr`) → install + configure token (redacted). _The most reasonable one to skip:_
it exists purely for inline, line-level review comments. If skipped, drop `prreview`/`prsubmit`
from Module A and the `<leader>gv`/`<leader>gV` keymaps from Module C.
6. **Module E** shell abbr/alias.
Each step is independently verifiable, so check in as you go rather than at the end.
## 6. Verification quick-checks
- `prdiff <n>` renders a colored diff.
- `prco <n>` on a PR whose branch you **don't** have locally → creates
`~/.local/share/pr-worktrees/<repo>/pr-<n>` + tmux session `pr-<n>`.
- `prco <n>` on a PR whose branch you **do** already have checked out → prints "already checked out
at …" and opens a session on _that_ worktree, creating nothing. (This is the case that used to
fail outright.)
- In the PR's worktree, `nvim .` → `<leader>gq` lists every changed hunk in the quickfix; jumping to
one shows **gutter signs in that file** (if the quickfix has entries but the file shows no signs,
the `M.apply`/attach workaround is missing — see Module C gotchas); `<leader>ghp` previews the
hunk inline; `<leader>gp` opens diffview with an **editable** right-hand pane.
- `prreview <n>` opens a review file; `prsubmit <n>` posts it (Module B).
- `prclean <n>` refuses if there's uncommitted/unpushed work; otherwise removes worktree + branch +
session. On a _reused_ worktree it must report "no worktree at …" and leave your worktree intact.
- `prefix + g` opens the popup showing **Pull requests / Issues / New issue**, each with a
`?`-toggled panel; a failing action prints the error and waits for Enter rather than closing the
popup instantly (Module D).
- **PR → View** shows the description and comments rendered (not a raw dump), and quitting the
pager returns you to the action menu rather than closing the popup.
- **PR → Comment** posts a plain conversation comment; confirm it lands on the PR's _Conversation_
tab, not as a line-level review comment.
- **Issues**: the list is fuzzy-searchable; `?` shows the issue _rendered_ (title, state, author,
markdown body, **and its comments**) — if you instead see a raw `key: value` dump, `GH_FORCE_TTY`
isn't being set; if the body appears but the discussion doesn't, `--comments` is missing.
- **Issue → View** pages the whole thread and returns to the action menu on `q`, same as PR → View.
- **Issue previews on a fish/non-POSIX `$SHELL`**: if the preview pane shows something like
`fish: ${ is not a valid variable`, a preview string still contains bash syntax — move it behind
a flag on the script (gotcha 8).
- **Comment / New issue**: your editor opens on a temp file, the text is echoed back, and nothing
posts until you answer `y`. An empty comment or empty title cancels. If the editor itself throws
autocmd errors, check the user's own editor config first (gotcha 11).
- **Nothing destructive fires from a menu pick alone** — merge, close (PR _and_ issue), comment and
create all require an explicit `y/N`.
# Terminal-native GitHub PR review workflow
A set of small, composable tools that let you **browse, read, check out, run, review, and comment
on GitHub pull requests entirely from the terminal** — no browser, no mouse. Built for a
fish + tmux + Neovim(LazyVim) + `gh` + `git-delta` setup, but every piece is optional and
swappable (see _For the implementing agent_).
---
## 1. Quick reference — the workflow & commands
Once installed, this is the whole day-to-day surface.
### From the shell / tmux
| You want to… | Do this |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **GitHub hub** | tmux `prefix + g` → **Pull requests** · **Issues** · **New issue**. Every menu has a `?`-toggled panel (context help, or the rendered issue). |
| ↳ **Pull requests** | pick a PR → **View** (description + comments, paged) · **Review** (worktree) · **Comment** (plain, not a review) · **Approve** · **Merge…** (squash / merge commit / rebase / auto) · **Close…**. `?` previews the diff. View returns to the menu; merge/close/comment ask an explicit y/N. |
| ↳ **Issues** | fuzzy-search open issues → the rendered issue (body + comments) is shown while you choose → **View** (whole thread, paged; `q` returns to the menu) · **Comment** (write in nvim, read it back, confirm) · **Close…** (completed / not planned) |
| ↳ **New issue** | title at the prompt → body in nvim → read it back → confirm. Nothing is created until you confirm. |
| **List open PRs** | `prs` (alias for `gh pr list`) |
| **Read a PR's diff** | `prdiff <n>` (add `-s` for side-by-side), or `?` in the popup |
| **Open a PR to work on** | `prco <n>` → puts you in a tmux session on that PR's code. If the PR's branch is **already checked out in one of your own worktrees it reuses that**; otherwise it creates a managed worktree at `~/.local/share/pr-worktrees/<repo>/pr-<n>`. Commit + push works either way. |
| **Write inline review comments** | `prreview <n>` → annotate the review file in nvim → `prsubmit <n>` |
| **Quick approve** | `gh pr review <n> --approve` |
| **Tear down a PR checkout** | `prclean <n>` (or bare `prclean` to fuzzy-pick). Refuses if there's uncommitted or unpushed work (`-f` forces). **Only ever removes worktrees it created** — your own worktrees are never touched. |
### In Neovim — review mode
Review mode pins gitsigns' baseline to the PR's **target branch**, so the whole codebase shows the
PR's changes as you browse normally (not just your uncommitted edits). It auto-activates in a
managed `prco` worktree; in a **reused** worktree just press `<leader>gq` or `<leader>gp` — they
detect the PR live.
| Key | Does |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `<leader>gq` | **Every changed hunk in the repo → quickfix.** The main entry point: step through the PR's changes in the real files, editing normally. |
| `<leader>ghp` | **Preview this hunk inline** — shows exactly what was added/removed at the cursor _(LazyVim default)_ |
| `]h` / `[h` | Next / previous hunk _(LazyVim default)_ |
| `<leader>gw` | Toggle **inline diff** for the file — deleted lines shown inline + word-level highlighting; reads like a diff but stays editable |
| `<leader>gp` | **Diffview panel** — changed-files list + side-by-side. The right pane is the _working tree_, so you can fix a mistake and `:w` without leaving the diff |
| `<leader>gv` | Open this PR's **prr review file** as a normal buffer _in this nvim_ (downloads it if needed) — write comments next to the code you're reviewing |
| `<leader>gV` | **Submit** the prr review (writes the buffer, then confirms before posting) |
| `<leader>gP` | Review mode off (gutters back to normal) |
### Typical loops
**Fast review (no checkout):**
`prefix + g` → pick PR → **Diff** to read → **Review** (comments in nvim) or **Approve**. Done.
**Deep review (read + fix the real code):**
```
prco 142 # tmux session on the PR's code (reused or managed worktree)
nvim .
<leader>gq # all of the PR's changes -> quickfix
<leader>ghp # on a hunk: see exactly what changed here
…fix it in place, :w
]h # next change
<leader>gp # side-by-side for a tricky file (right pane is editable)
<leader>gv # open the prr review file right here; write comments
<leader>gV # submit the review (confirms first)
git commit && git push # pushes back to the PR branch
prclean 142 # only if prco created the worktree; safe by default
```
`prreview`/`prsubmit` (Module A) do the same from a shell, but they spawn a _second_ `$EDITOR`. If
you're already in nvim in the PR's worktree, `<leader>gv`/`<leader>gV` keep everything in one place.
---
## 2. What this is and why (for anyone with no prior context)
**The problem.** If you live in the terminal (tmux, a TUI editor, `gh`, `lazygit`), reviewing
GitHub PRs is the one task that keeps yanking you back to a browser: reading diffs, checking out
branches to try them, leaving line comments. That context-switch is slow and breaks flow.
**The goal.** Make the everyday GitHub loop native to the terminal:
- **Browse/triage** open PRs and issues from a fuzzy picker.
- **Read** diffs with good syntax highlighting, and issues rendered properly.
- **Manage issues** — search, read, comment, close, create — without opening a browser.
- **Try locally** in a way that never disturbs your current work — each PR gets its own git
_worktree_ and tmux _session_, cleanly isolated and trivially torn down (and if you already keep
a worktree for that branch, it's reused rather than duplicated).
- **Review in your editor** by navigating the actual checked-out codebase, with the PR's changed
lines marked relative to its target branch (not just "what I edited").
- **Comment** with real inline GitHub review comments, written in your editor.
**The design ethos:** a handful of tiny scripts + your existing tools, one source of truth,
nothing bespoke where a standard tool already fits. Each part below is independently useful —
adopt only what you want.
---
## 3. For the implementing agent (read this first)
> ### ⚠️ Default: build this as specified — then check in about variations
>
> The reader has most likely seen this workflow demonstrated and wants **the same thing on their
> machine**. So **reproduce it as described by default.** It's a coherent whole: the modules were
> designed to fit together, and the defaults encode decisions arrived at by actually hitting the
> problems listed in _Known gotchas_. Treat those defaults as load-bearing.
>
> **Do not open with "so which parts would you like?"** — that dumps design work on someone who
> just wants the setup working. Instead:
>
> 1. **State the plan and confirm it.** e.g. _"I'll set up the full workflow: PR checkout into
> isolated worktrees + tmux sessions, in-editor review in Neovim, inline review comments via
> prr, and the `prefix + g` GitHub popup for PRs and issues. Want all of that, or shall I leave
> anything out?"_ One question, easy to say "yes, all of it" to.
> 2. **Interview where their environment forces a decision** — different shell, editor,
> multiplexer, OS, or a keybinding that's already taken. These are compatibility questions, not
> preference questions; every module lists alternatives for exactly this. Don't silently
> substitute — ask, then adapt.
> 3. **Call out the genuinely optional pieces** so opting out is cheap and informed: `prr`
> (Module B — only needed for _inline, line-level_ review comments; `gh pr review` already
> covers approve and top-level comments) and the shell abbreviation (Module E).
> 4. **Then invite variations** — different keymaps, a different worktree location, extra
> top-level popup entries (releases, Actions runs, notifications, "review requested of me").
> Build those if asked. Don't propose a redesign unprompted.
>
> Deviate when the user asks, or when their environment requires it. Don't deviate because
> something looks tidier — that's how the gotchas get reintroduced.
>
> That said, nothing here _requires_ all-or-nothing: the modules are independent, so a user who
> only wants one part can have it, and more can be added later. Just let that be their call.
You are setting this up on **someone else's machine**, which will differ from the author's. Before
installing anything:
1. **Inventory what's already installed** (§4) — so you know what actually needs adding, and where
their stack differs from the author's. This is reconnaissance, not a prompt to re-litigate
whether each module is wanted; that's settled by the single confirmation above.
2. **Then walk the dependencies module by module.** For each missing one, say briefly what it's
for and install it. Pause only where their environment points at an alternative (no Neovim, no
tmux, a different pager) — surface the choice, take their answer, move on.
3. **Adapt to their environment.** Paths, shell (fish vs zsh/bash), editor, terminal multiplexer,
and leader keys will differ. Genericize accordingly.
4. **Preserve modularity.** Keep the scripts as separate files so the user can delete/replace any
one without breaking the others.
The author's baseline (for reference; the user's may differ): macOS, fish shell, tmux, Neovim
(LazyVim), Ghostty, `gh` authenticated over SSH, `git-delta` as the git pager.
### Known gotchas — every one of these was hit in practice
Don't "simplify" these away; each is load-bearing. Details in the module sections.
1. **A PR's branch may already be checked out in another worktree.** Git refuses to check a branch
out twice, so `gh pr checkout` fails hard. Extremely common if the user keeps a worktree per
feature branch and opens PRs from them. `prco` detects and reuses. _(Module A)_
2. **Never delete a worktree you didn't create.** Reused worktrees are the user's real work;
`prclean` must only touch its own managed paths. _(Module A)_
3. **`prclean` must not blindly `--force`.** Check for uncommitted changes _and_ commits not on any
remote before removing, or you silently destroy work. _(Module A)_
4. **`set -e` inside a tmux popup hides all errors** — the popup tears down before anything can be
read, so failures look like "nothing happened." Wrap actions so failures pause. _(Module D)_
5. **diffview: diff a single rev, not a `base...HEAD` range.** A range diffs two historical commits
→ both panes read-only. A single rev puts the working tree on the right → editable. _(Module C)_
6. **gitsigns' global `change_base` doesn't reach buffers that attach later.** They keep
`base = nil` and diff against HEAD, so no gutter signs on anything you open afterwards — the
feature silently appears to do nothing. Re-apply per buffer on attach. _(Module C)_
7. **LazyVim's gitsigns keymaps are buffer-local** (set in `on_attach`), so they're invisible to
`nvim_get_keymap`. Check `nvim_buf_get_keymap(0,'n')` in an attached buffer before concluding a
key is missing or free. _(Module C)_
8. **fzf runs `--preview`/`execute` strings through `$SHELL -c`.** If the user's `$SHELL` is fish
(or any non-POSIX shell), bash-isms in a preview string break at runtime with something like
`fish: ${ is not a valid variable in fish`. Ours hit this with a `VAR=value cmd` prefix and
`${VAR:-default}`. **Keep preview strings to plain command invocations** — put any real logic
behind a flag on your own script (`pr-popup --issue {1}`) where you control the interpreter.
_(Module D)_
9. **`gh` prints raw `key: value` dumps when piped.** `gh issue view` in a preview pane looks
nothing like the terminal output. Set `GH_FORCE_TTY` (fzf exports `FZF_PREVIEW_COLUMNS`, so
`GH_FORCE_TTY=$FZF_PREVIEW_COLUMNS` renders it properly _and_ wraps to the pane). _(Module D)_
10. **Don't let `gh` open the editor for you.** `gh issue comment --editor` and `gh issue create`'s
interactive body step errored inside the popup. Owning that step — open `$EDITOR` on a temp
file yourself, then pass `--body-file` — is predictable, works everywhere, and lets you show
the text back and confirm before anything posts. _(Module D)_
11. **`gh <kind> view` omits comments unless you pass `--comments`.** Easy to miss, because the
output looks complete — you get the description and assume that's the whole thing, when the
entire discussion is silently absent. Applies to both `gh pr view` and `gh issue view`, in
previews _and_ full views. _(Module D)_
12. **Tooling that opens the user's editor will surface _their_ pre-existing config errors.** Our
temp files are `.md`, which exposed a broken `FileType markdown` autocmd in the author's nvim
(it `require`d a plugin removed in a migration) — it looked like our bug but fired on every
markdown buffer. If the editor errors on open, reproduce with a bare
`nvim --headless <file> -c 'messages'` before blaming the tooling. _(Module D)_
---
## 4. Dependencies at a glance
| Module | Hard deps | Optional / alternatives |
| ----------------------------------------------------------- | ------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **A. Core scripts** (browse/diff/checkout/cleanup) | `git`, `gh` (authenticated), `fzf`, `tmux` | `git-delta` for pretty diffs → else use `gh pr diff \| less -R`, `difftastic`, or `bat` |
| **B. Review authoring** (`prr`) | `prr`, a GitHub token | Alternatives: `gh pr review` (no inline comments), `octo.nvim`, GitHub web |
| **C. In-editor review** (Neovim) | Neovim, `gitsigns.nvim` (with `change_base`), `diffview.nvim`, a plugin manager | Alternatives: `octo.nvim`, manual `:DiffviewOpen`, VS Code + GitLens, JetBrains |
| **D. GitHub popup** (`prefix + g`) — PRs, issues, new issue | `tmux` ≥ 3.2 (`display-popup`), `fzf`, `gh` | Or run the script as a normal command (no tmux); or use `gh-dash` / `lazygit` custom commands instead. Easy to extend to releases, Actions, notifications… |
| **E. Shell abbreviation** | `fish` | zsh/bash alias equivalent |
**Common setup for the scripts:** put them in a directory on your `PATH` (e.g. `~/bin` or
`~/.local/bin`), `chmod +x` them. They are POSIX-ish bash and avoid Bash-4 features so they run on
macOS's stock bash 3.2. `gh` must be logged in (`gh auth login`).
---
## Module A — Core scripts (`~/bin/pr*`)
The foundation: browse, diff, check-out-to-worktree, and clean up. Depends on `git`, `gh`, `fzf`,
`tmux` (and `delta` for `prdiff`, easily swapped).
**Key design points / gotchas:**
- Worktrees are stored at `~/.local/share/pr-worktrees/<repo>/pr-<n>` — _outside_ the repo, so
nothing needs `.gitignore`, and one repo can have many PRs checked out at once.
- **If you already run a worktree-per-branch workflow, this matters:** git refuses to check out a
branch that's already checked out in another worktree, so `gh pr checkout` fails hard for any PR
whose branch you already have locally (very common when the PRs are your own feature branches).
`prco` therefore checks first — if the PR's head branch is already in a worktree, it **reuses**
that worktree and just opens a session there (named after the directory), rather than creating a
competing checkout. Those reused worktrees are _not_ managed: `prclean` will never delete them,
and no `pr-review-base` marker is written into them (use `<leader>gp` in nvim there, which
detects the PR live).
- `prco` uses `gh pr checkout` (not a bare fetch) so you can commit and push back to the PR branch.
Pushing back to a **fork** PR still requires the PR author enabled "allow maintainer edits."
- `prclean` is **safe by default**: it refuses to delete a worktree with uncommitted changes or
commits not pushed to any remote. `-f` overrides. It also removes the local branch and the
tmux session, and works even when run from inside the PR's own session.
- tmux tip: set `set -g detach-on-destroy off` so killing a PR session lands you on another
session instead of detaching.
### `prdiff` — view a PR's diff through delta
```bash
#!/usr/bin/env bash
# prdiff <pr-number> [-s] — view a GitHub PR's diff through delta
# -s / --side-by-side : force side-by-side (overrides your delta config)
# Runs inside a GitHub repo. delta pages with less; navigate=true → n/N jumps files.
set -eu
n="${1:-}"
if [ -z "$n" ]; then echo "usage: prdiff <pr-number> [-s]" >&2; exit 2; fi
case "${2:-}" in
-s|--side-by-side) gh pr diff "$n" | delta --side-by-side ;;
*) gh pr diff "$n" | delta ;;
esac
```
_No delta?_ Replace the two `delta` lines with `gh pr diff "$n" | less -R` (uses gh's own
coloring), or pipe to `difftastic`/`bat`.
_If delta warns `Unknown theme '<name>', using default`:_ the theme was configured but never
installed, so you've been silently getting the fallback. delta uses bat's theme machinery — drop
the `.tmTheme` into `$(bat --config-dir)/themes/` and run `bat cache --build`. (Many colourschemes
ship one; e.g. tokyonight.nvim has them under `extras/sublime/`.) Rebuild again after a bat upgrade.
### `prco` — check a PR out into an isolated worktree + tmux session
```bash
#!/usr/bin/env bash
# prco [--no-switch] <pr-number>
# Open a PR in a dedicated tmux session.
#
# If the PR's head branch is ALREADY checked out in one of your existing worktrees, that
# worktree is reused (git can't check a branch out twice). prco does not own it and
# `prclean` will never delete it — you just get a session there.
#
# Otherwise the PR is checked out into a managed worktree under
# ~/.local/share/pr-worktrees/<repo>/pr-<n> (never inside the repo), via `gh pr checkout`
# so committing + pushing back to the PR branch works (fork push-back still requires the
# PR's "allow maintainer edits"). Managed worktrees also record the PR's base branch in
# $gitdir/pr-review-base so nvim auto-enters review mode (gitsigns + diffview).
# In a reused worktree, use <leader>gp in nvim instead — it detects the PR live.
#
# --no-switch : create the session but don't switch to it (prints how to jump instead).
set -eu
switch=1
if [ "${1:-}" = "--no-switch" ]; then switch=0; shift; fi
n="${1:-}"
if [ -z "$n" ]; then echo "usage: prco [--no-switch] <pr-number>" >&2; exit 2; fi
# Resolve the MAIN worktree so repo name/paths are stable even when invoked from a linked one.
main=$(git worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2; exit}')
if [ -z "$main" ]; then echo "prco: not inside a git repository" >&2; exit 1; fi
name=$(basename "$main")
# Is this PR's branch already checked out in a worktree? If so, reuse it.
head=$(gh pr view "$n" --json headRefName -q .headRefName 2>/dev/null || true)
existing=""
if [ -n "$head" ]; then
existing=$(git -C "$main" worktree list --porcelain 2>/dev/null \
| awk -v b="refs/heads/$head" '/^worktree /{p=$2} $1=="branch" && $2==b {print p; exit}')
fi
if [ -n "$existing" ]; then
# --- Reuse an existing worktree (yours). Session named after the dir, matching `t`. ---
wt="$existing"
# printf (not echo/basename alone) so the trailing newline isn't turned into a stray '_'.
session=$(printf '%s' "$(basename "$wt")" | tr -c 'A-Za-z0-9_-' '_')
echo "PR #$n is on '$head', already checked out at:"
echo " $wt"
echo "Using that worktree (prco doesn't manage it; prclean won't touch it)."
else
# --- Create a managed worktree for this PR. ---
wt="$HOME/.local/share/pr-worktrees/$name/pr-$n"
session="pr-$n"
if [ ! -d "$wt" ]; then
mkdir -p "$(dirname "$wt")"
git -C "$main" worktree add --detach "$wt" HEAD >/dev/null
if ! ( cd "$wt" && gh pr checkout "$n" ); then
echo "prco: 'gh pr checkout $n' failed" >&2
git -C "$main" worktree remove --force "$wt" 2>/dev/null || true
rm -rf "$wt"
rmdir "$(dirname "$wt")" 2>/dev/null || true
exit 1
fi
fi
# Record the PR's base branch for nvim review mode, and fetch it so gitsigns/diffview
# can diff against it offline. Managed worktrees only — never touch your own worktrees.
gitdir=$(git -C "$wt" rev-parse --absolute-git-dir 2>/dev/null || true)
if [ -n "$gitdir" ] && [ ! -f "$gitdir/pr-review-base" ]; then
base=$(gh pr view "$n" --json baseRefName -q .baseRefName 2>/dev/null || true)
if [ -n "$base" ]; then
git -C "$wt" fetch -q origin "$base" 2>/dev/null || true
printf 'origin/%s\n' "$base" > "$gitdir/pr-review-base" 2>/dev/null || true
fi
fi
fi
# Create the session (detached) only if it doesn't already exist — no attach side effect.
tmux has-session -t "=$session" 2>/dev/null || tmux new-session -d -s "$session" -c "$wt"
if [ "$switch" = 1 ]; then
if [ -n "${TMUX:-}" ]; then tmux switch-client -t "$session"; else tmux attach -t "$session"; fi
else
echo "Session '$session' ready at $wt"
echo " jump with: prefix+T (or: tmux switch-client -t $session)"
fi
```
_Not using Module C (nvim)?_ The `pr-review-base` block is harmless but unnecessary — you can
delete it. _No tmux?_ Replace the session lines with a plain `cd "$wt"` and open a shell/editor
there yourself.
### `prreview` / `prsubmit` — write & submit inline review comments (needs Module B)
```bash
#!/usr/bin/env bash
# prreview <pr-number>
# Download the current repo's PR into a prr review file and open it in $EDITOR (nvim).
# Add inline comments in the review file, save/quit, then run `prsubmit <pr-number>`.
# Re-run to reopen an in-progress review without re-downloading.
set -eu
n="${1:-}"
if [ -z "$n" ]; then echo "usage: prreview <pr-number>" >&2; exit 2; fi
slug=$(gh repo view --json nameWithOwner -q .nameWithOwner)
# First time: fetch + open. If a review already exists, just reopen it.
prr get "$slug/$n" --open 2>/dev/null || prr edit "$slug/$n"
```
```bash
#!/usr/bin/env bash
# prsubmit <pr-number>
# Submit the prr review for the current repo's PR (posts inline comments as a GitHub review).
set -eu
n="${1:-}"
if [ -z "$n" ]; then echo "usage: prsubmit <pr-number>" >&2; exit 2; fi
slug=$(gh repo view --json nameWithOwner -q .nameWithOwner)
prr submit "$slug/$n"
echo "Submitted review for $slug#$n"
```
### `prclean` — safe, complete teardown
```bash
#!/usr/bin/env bash
# prclean [-f] [<pr-number>]
# Tear a PR checkout all the way down: worktree + its tmux session + local branch + prr review.
# No <pr-number> → fzf-pick from the PR checkouts that exist for this repo.
# SAFE by default: refuses if the worktree has uncommitted changes or commits not on any
# remote. Pass -f to discard anyway. Works even when run from inside the PR's own session.
set -eu
force=0
case "${1:-}" in -f|--force) force=1; shift ;; esac
main=$(git worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2; exit}')
if [ -z "$main" ]; then echo "prclean: not inside a git repository" >&2; exit 1; fi
name=$(basename "$main")
base="$HOME/.local/share/pr-worktrees/$name"
n="${1:-}"
if [ -z "$n" ]; then
if [ ! -d "$base" ] || [ -z "$(ls -A "$base" 2>/dev/null)" ]; then
echo "No PR checkouts for $name."; exit 0
fi
n=$(ls -1 "$base" 2>/dev/null | sed -n 's/^pr-//p' \
| fzf --prompt="clean PR ❯ " --header="$name — checked-out PRs" \
--preview="git -C '$base/pr-{}' status -sb 2>/dev/null") || exit 0
[ -n "$n" ] || exit 0
fi
wt="$base/pr-$n"
session="pr-$n"
if [ ! -d "$wt" ]; then
echo "prclean: no worktree at $wt"
tmux kill-session -t "=$session" 2>/dev/null && echo "(killed stray session $session)" || true
exit 0
fi
# --- Safety gate: don't discard work unless forced ---
if [ "$force" != 1 ]; then
dirty=$(git -C "$wt" status --porcelain 2>/dev/null || true)
unpushed=$(git -C "$wt" log --oneline HEAD --not --remotes 2>/dev/null | head -1 || true)
if [ -n "$dirty" ] || [ -n "$unpushed" ]; then
echo "prclean: pr-$n still has work:" >&2
[ -n "$dirty" ] && echo " - uncommitted changes" >&2
[ -n "$unpushed" ] && echo " - commits not pushed to any remote" >&2
echo " Push/commit first, or discard with: prclean -f $n" >&2
exit 1
fi
fi
branch=$(git -C "$wt" rev-parse --abbrev-ref HEAD 2>/dev/null || true)
slug=$( (cd "$main" && gh repo view --json nameWithOwner -q .nameWithOwner) 2>/dev/null || true)
# --- Do all the filesystem/git/review teardown BEFORE killing the session, so this still
# completes cleanly even when prclean is running inside the session being removed. ---
git -C "$main" worktree remove --force "$wt" 2>/dev/null || rm -rf "$wt"
git -C "$main" worktree prune 2>/dev/null || true
if [ -n "$branch" ] && [ "$branch" != "HEAD" ]; then
if [ "$force" = 1 ]; then
git -C "$main" branch -D "$branch" 2>/dev/null || true
else
git -C "$main" branch -d "$branch" 2>/dev/null \
|| echo "note: kept branch '$branch' (unmerged) — remove with: git -C '$main' branch -D '$branch'"
fi
fi
[ -n "$slug" ] && prr remove -f "$slug/$n" 2>/dev/null || true
rmdir "$base" 2>/dev/null || true
echo "Cleaned pr-$n (worktree${branch:+ + branch $branch} + session + review)."
# --- Session last: move the client off it first (detach-on-destroy off also covers this),
# then kill. If we were running inside it, everything above already finished. ---
if [ -n "${TMUX:-}" ] && [ "$(tmux display-message -p '#{session_name}' 2>/dev/null || true)" = "$session" ]; then
other=$(tmux list-sessions -F '#{session_name}' 2>/dev/null | grep -vx "$session" | head -1 || true)
[ -n "$other" ] && tmux switch-client -t "$other" 2>/dev/null || true
fi
tmux kill-session -t "=$session" 2>/dev/null || true
```
_The `prr remove` line is a no-op if you skip Module B._
**Install Module A:** save the files above into a `PATH` dir (author used `~/bin`) with those
exact names (no extension), then `chmod +x ~/bin/prdiff ~/bin/prco ~/bin/prreview ~/bin/prsubmit ~/bin/prclean`.
---
## Module B — Review authoring with `prr`
`prr` (github.com/danobi/prr) brings mailing-list-style review: it downloads a PR as a local file,
you annotate lines inline in your editor, then submit — producing a real GitHub review with inline
comments. Used by `prreview`/`prsubmit` above and the popup's **Review**/**Submit** actions.
**Install:** `brew install prr` (it's in Homebrew core) or `cargo install prr`.
**Configure** `~/.config/prr/config.toml` (chmod 600 — it holds a token):
```toml
[prr]
token = "<YOUR_GITHUB_TOKEN>" # a GitHub token with 'repo' scope (see below)
workdir = "~/.cache/prr" # any writable dir; where review files live
```
**Token options** (pick one, discuss with the user):
- Reuse the `gh` CLI token: `gh auth token` prints it; it typically has `repo` scope, which is what
`prr` needs to post reviews. Convenient, but if `gh` rotates it, `prr` breaks — then regenerate.
- A dedicated fine-grained/classic **Personal Access Token** with `repo` (or `public_repo`) scope.
More stable; recommended if this is a shared/long-lived setup.
> ⚠️ The token is a secret. Never commit `config.toml`; keep it `chmod 600`. It is **redacted** here.
**Commands:** `prr get owner/repo/<n> --open` → annotate in `$EDITOR` → `prr submit owner/repo/<n>`.
`prr edit` reopens; `prr apply` checks a PR out into the working dir; `prr remove` deletes a local review.
### The review-file format (the non-obvious part)
This is where people get stuck — the commands are easy, the file format is unfamiliar. The model is
**email/mailing-list**: the entire PR diff is quoted with `> `, and **anything you type that isn't
quoted becomes a review comment.**
Three things you can write:
1. **Inline comment** — unquoted text on a new line _immediately after_ the quoted diff line you're
commenting on. It posts as a line comment anchored there:
```
> - {
> - id: 'ram',
> - cooldownMs: 3000,
> - },
Was removing `ram` intentional? It's still referenced in the intel tree.
> {
> id: 'penetrating-laser',
```
2. **PR-level comment** — unquoted text at the very top, _before_ the first `> diff --git` line.
Only one allowed per review.
3. **Verdict directive** — a standalone line anywhere: `@prr approve`, `@prr reject`
(= request changes), or `@prr comment` (comment-only).
Rules and gotchas:
- Don't edit the `> ` lines — they're the diff; only add unquoted lines between them.
- The comment must _directly_ follow the line it refers to; that's how prr computes the anchor.
- `[...]` on its own line elides a chunk of quoted diff you don't care about.
- **You cannot approve your own PR** — GitHub rejects self-approval with a 422, so `@prr approve`
fails when testing on your own PRs. Use `@prr comment`.
- Nothing is sent until `prr submit`, so it's safe to open, scribble, and walk away.
**Docs** (the docs _home page_ is only a short intro — the detail is in these chapters):
- Review file syntax: https://doc.dxuuu.xyz/prr/review.html
- Tutorial: https://doc.dxuuu.xyz/prr/tutorial.html
- Config: https://doc.dxuuu.xyz/prr/config.html · Install: https://doc.dxuuu.xyz/prr/install.html
- Everything on one page (best for Ctrl-F): https://doc.dxuuu.xyz/prr/print.html
- Repo: https://github.com/danobi/prr
### Strongly recommended: the `.prr` Vim/Neovim plugin
Without it a review file is undifferentiated plain text — the quoted diff and your own comments look
identical, and a real review is thousands of lines (one measured example: 3,139 lines across 42
files). The prr repo ships a plugin under its `vim/` directory giving syntax colouring, filetype
detection and **folding** (level 1 = per file, level 2 = per hunk), so `zM` collapses a huge review
to a file list. Your comment lines are then the only *un*highlighted text, which makes "where do I
type" obvious.
**Gotcha:** the plugin is in the repo's `vim/` **subdirectory**, so a plugin manager that adds the
repo root to `runtimepath` won't find `ftdetect/ftplugin/syntax`. Register the filetype yourself and
append the subdirectory. lazy.nvim spec (`lua/plugins/prr.lua`):
```lua
return {
"danobi/prr",
lazy = false, -- three tiny vim files; must be on the rtp before any .prr file is opened
init = function()
vim.filetype.add({ extension = { prr = "prr" } })
end,
config = function(plugin)
vim.opt.runtimepath:append(plugin.dir .. "/vim")
-- open reviews collapsed to the file list; delete if you prefer fully expanded
vim.api.nvim_create_autocmd("FileType", {
pattern = "prr",
group = vim.api.nvim_create_augroup("prr_fold", { clear = true }),
callback = function() vim.opt_local.foldlevel = 0 end,
})
end,
}
```
Vundle equivalent (from the docs): `Plugin 'danobi/prr', {'rtp': 'vim/'}`.
Verify with: `:set ft?` → `prr`, `:echo b:current_syntax` → `prr`, `:set foldmethod?` → `expr`.
**Alternatives to Module B:** `gh pr review <n> --approve|--comment|--request-changes` (top-level
only, no per-line comments); `octo.nvim` (full review UI in Neovim); the GitHub web UI.
---
## Module C — In-editor PR review (Neovim: gitsigns + diffview)
Turns a checked-out PR worktree into a navigable review: as you open any file the normal way, the
gutter marks exactly the lines this PR changes **relative to its target branch** (merge-base of
`origin/<base>...HEAD`, i.e. GitHub "Files changed" semantics).
Three keymaps:
- `<leader>gp` — diffview panel (changed files + side-by-side). **Diff a single rev, not a
`base...HEAD` range**: a range diffs two historical commits so both panes are read-only, whereas
a single rev puts the _working tree_ on the right — so you can fix a mistake and `:w` without
leaving the diff. (Verified: range → 0 editable panes; single rev → the real file is editable.)
- `<leader>gq` — dump **every changed hunk in the repo** into the quickfix list. This is the
"just browse the codebase and see where the changes are" answer: jump between hunks in the real
files, with gutters on, editing normally — no diff view involved.
- `<leader>gP` — turn review mode off (gutters back to uncommitted-vs-HEAD).
- `<leader>gw` — toggle **inline diff**: deleted/old lines rendered inline plus word-level
highlighting, so a file reads as a diff while staying editable. Good for reading a whole file.
The gitsigns baseline persists after you close diffview, so once review mode is on you can browse
and edit the whole codebase with PR gutters showing.
**Reading an individual change:** a gutter sign tells you _where_ but not _what_. gitsigns already
covers this and LazyVim binds it **buffer-locally** (so it won't show up in a global keymap dump —
check `nvim_buf_get_keymap` inside an attached buffer):
`<leader>ghp` = **preview hunk inline** (expands the old lines in place — the main one),
`]h`/`[h` next/prev hunk, `]H`/`[H` first/last, `<leader>ghd`/`<leader>ghD` diff this file,
`<leader>ghb`/`<leader>ghB` blame. If a distro doesn't provide these, map
`require("gitsigns").preview_hunk_inline` (or `preview_hunk` for a float) yourself.
**How it works:** `prco` writes the PR's base branch into the worktree's private gitdir
(`$gitdir/pr-review-base`). On nvim startup in that worktree, an autocmd reads it and calls
gitsigns' `change_base`. Because each PR gets its own throwaway nvim (dedicated tmux session), the
global base change is naturally scoped and never affects your main editor.
**Depends on:** Neovim; `gitsigns.nvim` (must expose `change_base` — mainline does);
`diffview.nvim` (`sindrets/diffview.nvim`); a plugin manager. Author used **LazyVim** + `lazy.nvim`;
the files below are lazy.nvim plugin specs. If the user uses a different manager/distro, adapt the
spec wrapper — the logic in `pr_review.lua` is manager-agnostic.
**File 1 — the logic:** `~/.config/nvim/lua/util/pr_review.lua`
```lua
-- PR review helpers. See lua/plugins/pr-review.lua for the wiring.
--
-- Points gitsigns' diff baseline at the PR's target branch (the merge-base of
-- origin/<base>...HEAD — GitHub "Files changed" semantics) so the whole codebase reads normally
-- but every gutter marks exactly the PR's changed lines.
--
-- Entry points:
-- <leader>gp panel — diffview against the merge-base. Diffing a SINGLE rev (not
-- `base...HEAD`) makes the right-hand side the WORKING TREE, so you can
-- edit and :w fixes straight from the diff. A `base...HEAD` range would
-- be two historical blobs = read-only.
-- <leader>gq hunks — every changed hunk in the repo -> quickfix, for normal browsing/editing.
-- <leader>gP off — reset the baseline back to normal (uncommitted-vs-HEAD).
local M = {}
function M.absolute_git_dir()
local out = vim.fn.systemlist({ "git", "rev-parse", "--absolute-git-dir" })
if vim.v.shell_error ~= 0 or not out[1] or out[1] == "" then return nil end
return out[1]
end
-- Base ref recorded by prco, e.g. "origin/main". nil when not a prco worktree.
function M.marker_base()
local gd = M.absolute_git_dir()
if not gd then return nil end
local f = gd .. "/pr-review-base"
if vim.fn.filereadable(f) == 0 then return nil end
local line = (vim.fn.readfile(f) or {})[1]
if line and line ~= "" then return vim.trim(line) end
return nil
end
-- Live fallback: ask gh for the current branch's PR base (network).
function M.live_base()
local out = vim.fn.systemlist({ "gh", "pr", "view", "--json", "baseRefName", "-q", ".baseRefName" })
if vim.v.shell_error ~= 0 or not out[1] or out[1] == "" then return nil end
return "origin/" .. vim.trim(out[1])
end
function M.ensure_ref(base_ref)
vim.fn.system({ "git", "rev-parse", "--verify", "--quiet", base_ref })
if vim.v.shell_error ~= 0 then
vim.fn.system({ "git", "fetch", "origin", (base_ref:gsub("^origin/", "")) })
end
end
-- The fork point: what the PR actually branched from.
function M.merge_base(base_ref)
local mb = vim.fn.systemlist({ "git", "merge-base", base_ref, "HEAD" })
if vim.v.shell_error == 0 and mb[1] and mb[1] ~= "" then return vim.trim(mb[1]) end
return base_ref
end
local function gitsigns()
-- gitsigns is lazy-loaded on file events; force it so change_base has actually run setup.
pcall(function() require("lazy").load({ plugins = { "gitsigns.nvim" } }) end)
local ok, gs = pcall(require, "gitsigns")
return ok and gs or nil
end
-- The active review revision (merge-base sha), or nil when review mode is off.
M._rev = nil
local AUG = vim.api.nvim_create_augroup("pr_review_base", { clear = true })
-- gitsigns' global change_base only updates buffers that are ALREADY attached — a buffer that
-- attaches later keeps bcache.base = nil and diffs against HEAD (no gutter signs). Since review
-- mode is enabled before you browse, that's every file you open. So re-apply per buffer on attach.
-- gitsigns attaches asynchronously, hence the short retry.
function M.apply(bufnr, tries)
if not M._rev then return end
tries = tries or 8
if not vim.api.nvim_buf_is_valid(bufnr) or vim.bo[bufnr].buftype ~= "" then return end
if vim.b[bufnr].pr_review_based == M._rev then return end
local gs = gitsigns()
if not gs then return end
vim.api.nvim_buf_call(bufnr, function() pcall(gs.change_base, M._rev, false) end)
if gs.get_hunks(bufnr) ~= nil then
vim.b[bufnr].pr_review_based = M._rev
elseif tries > 1 then
vim.defer_fn(function() M.apply(bufnr, tries - 1) end, 60)
end
end
function M.set_base(base_ref, notify)
if not base_ref then return false end
local gs = gitsigns()
if not gs then return false end
M._rev = M.merge_base(base_ref)
pcall(gs.change_base, M._rev, true) -- global default + already-attached buffers
-- Catch every buffer opened from here on (quickfix jumps, telescope, neo-tree, …).
vim.api.nvim_clear_autocmds({ group = AUG })
vim.api.nvim_create_autocmd({ "BufReadPost", "BufWinEnter" }, {
group = AUG,
callback = function(ev) vim.schedule(function() M.apply(ev.buf) end) end,
})
-- …and any already loaded.
for _, b in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_is_loaded(b) then M.apply(b) end
end
if notify then
vim.notify("PR review ON — gutters vs " .. base_ref, vim.log.levels.INFO, { title = "PR review" })
end
return true
end
-- Resolve the base (marker first, then live gh) and turn review mode on. Returns the base ref.
function M.activate(notify)
local base = M.marker_base() or M.live_base()
if not base then
vim.notify("PR review: couldn't determine the PR base branch", vim.log.levels.WARN, { title = "PR review" })
return nil
end
M.ensure_ref(base)
M.set_base(base, notify)
return base
end
-- Auto-activate from the marker (offline, managed prco worktrees only). No-op elsewhere.
function M.auto()
local base = M.marker_base()
if base then M.set_base(base, true) end
end
-- <leader>gp — diffview against the merge-base; right-hand side is the working tree (EDITABLE).
function M.open_panel()
local base = M.activate(false)
if not base then return end
vim.cmd("DiffviewOpen " .. M.merge_base(base))
end
-- <leader>gq — every changed hunk in the repo into the quickfix list, so you can browse and edit
-- the real files normally (gutters stay on) instead of sitting inside a diff.
function M.changed_hunks()
local base = M.activate(false)
if not base then return end
local gs = gitsigns()
if not gs then return end
-- 'all' scans the whole repo against the current base.
pcall(gs.setqflist, "all", { open = true })
end
-- ── prr review file, opened as a normal buffer in THIS nvim ──────────────────────────────────
-- (`prreview` in the shell spawns a second $EDITOR; in a PR worktree you're already in nvim, so
-- just open the .prr file here alongside the code you're reviewing.)
local function sh(cmd)
local out = vim.fn.systemlist(cmd)
if vim.v.shell_error ~= 0 or not out[1] or out[1] == "" then return nil end
return vim.trim(out[1])
end
-- owner/repo and the PR number for the branch checked out here.
function M.pr_target()
local slug = sh({ "gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner" })
local num = sh({ "gh", "pr", "view", "--json", "number", "-q", ".number" })
if not slug or not num then return nil end
return slug, num
end
-- Where prr keeps review files, from ~/.config/prr/config.toml.
function M.prr_workdir()
local cfg = vim.fn.expand("~/.config/prr/config.toml")
if vim.fn.filereadable(cfg) == 0 then return nil end
for _, line in ipairs(vim.fn.readfile(cfg)) do
local w = line:match('^%s*workdir%s*=%s*"([^"]+)"')
if w then return vim.fn.expand(w) end
end
return nil
end
-- <leader>gv — open (downloading first if needed) this PR's prr review file in the current nvim.
function M.review()
local slug, num = M.pr_target()
if not slug then
vim.notify("prr: no PR found for this branch", vim.log.levels.WARN, { title = "PR review" })
return
end
local wd = M.prr_workdir()
if not wd then
vim.notify("prr: no workdir in ~/.config/prr/config.toml", vim.log.levels.ERROR, { title = "PR review" })
return
end
local path = ("%s/%s/%s.prr"):format(wd, slug, num)
if vim.fn.filereadable(path) == 0 then
vim.fn.system({ "prr", "get", ("%s/%s"):format(slug, num) })
if vim.fn.filereadable(path) == 0 then
vim.notify("prr get failed for " .. slug .. "/" .. num, vim.log.levels.ERROR, { title = "PR review" })
return
end
end
vim.cmd("edit " .. vim.fn.fnameescape(path))
vim.notify(("Review %s#%s — comment on UNquoted lines, then <leader>gV to submit"):format(slug, num),
vim.log.levels.INFO, { title = "PR review" })
end
-- <leader>gV — submit the review. Posts to GitHub, so confirm first.
function M.submit()
local slug, num = M.pr_target()
if not slug then
vim.notify("prr: no PR found for this branch", vim.log.levels.WARN, { title = "PR review" })
return
end
if vim.bo.modified and vim.api.nvim_buf_get_name(0):match("%.prr$") then vim.cmd("write") end
if vim.fn.confirm(("Submit prr review to GitHub for %s#%s?"):format(slug, num), "&No\n&Yes", 1) ~= 2 then
vim.notify("Submit cancelled", vim.log.levels.INFO, { title = "PR review" })
return
end
local out = vim.fn.systemlist({ "prr", "submit", ("%s/%s"):format(slug, num) })
if vim.v.shell_error ~= 0 then
vim.notify("prr submit failed:\n" .. table.concat(out, "\n"), vim.log.levels.ERROR, { title = "PR review" })
else
vim.notify(("Submitted review for %s#%s"):format(slug, num), vim.log.levels.INFO, { title = "PR review" })
end
end
-- <leader>gw — read the whole file as a diff without pressing anything per-hunk:
-- deleted/old lines shown inline, plus word-level highlighting of what changed within a line.
-- (Per-hunk on demand is LazyVim's <leader>ghp = preview_hunk_inline.)
M._inline = false
function M.toggle_inline()
local gs = gitsigns()
if not gs then return end
pcall(gs.toggle_deleted)
pcall(gs.toggle_word_diff)
M._inline = not M._inline
vim.notify("Inline diff " .. (M._inline and "ON — deleted lines + word diff" or "OFF"),
vim.log.levels.INFO, { title = "PR review" })
end
-- <leader>gP — back to normal (uncommitted-vs-HEAD gutters).
function M.off()
local gs = gitsigns()
if not gs then return end
M._rev = nil
vim.api.nvim_clear_autocmds({ group = AUG })
pcall(gs.change_base, nil, true)
for _, b in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_is_loaded(b) and vim.bo[b].buftype == "" then
vim.api.nvim_buf_call(b, function() pcall(gs.change_base, nil, false) end)
vim.b[b].pr_review_based = nil
end
end
vim.notify("PR review OFF — gutters back to normal", vim.log.levels.INFO, { title = "PR review" })
end
return M
```
**File 2 — the plugin spec / wiring:** `~/.config/nvim/lua/plugins/pr-review.lua`
```lua
-- PR review mode: diffview panel + gitsigns baseline pinned to the PR's target branch.
-- Auto-activates in `prco` worktrees (via the $gitdir/pr-review-base marker prco writes).
-- Logic lives in lua/util/pr_review.lua.
return {
"sindrets/diffview.nvim",
cmd = { "DiffviewOpen", "DiffviewClose", "DiffviewFileHistory", "DiffviewToggleFiles", "DiffviewFocusFiles" },
opts = {},
keys = {
{ "<leader>gp", function() require("util.pr_review").open_panel() end, desc = "PR review: diffview panel (editable)" },
{ "<leader>gq", function() require("util.pr_review").changed_hunks() end, desc = "PR review: changed hunks -> quickfix" },
{ "<leader>gw", function() require("util.pr_review").toggle_inline() end, desc = "PR review: inline diff (deleted + word diff)" },
{ "<leader>gv", function() require("util.pr_review").review() end, desc = "PR review: open prr review file here" },
{ "<leader>gV", function() require("util.pr_review").submit() end, desc = "PR review: submit prr review" },
{ "<leader>gP", function() require("util.pr_review").off() end, desc = "PR review: off (normal gutters)" },
},
init = function()
vim.api.nvim_create_autocmd("User", {
pattern = "VeryLazy",
group = vim.api.nvim_create_augroup("pr_review_auto", { clear = true }),
callback = function() require("util.pr_review").auto() end,
})
end,
}
```
**Notes / gotchas:**
- **The big one — gitsigns' global `change_base` does NOT apply to buffers that attach later.**
`change_base(rev, true)` sets the global default and refreshes _already-attached_ buffers, but a
buffer opened afterwards keeps `bcache.base = nil` and silently diffs against HEAD → no gutter
signs. Because review mode is switched on _before_ you start browsing, that's every file you
open — the feature appears to do nothing. The module works around it by re-applying
`change_base(rev, false)` per buffer from a `BufReadPost`/`BufWinEnter` autocmd (with a short
retry, since gitsigns attaches asynchronously). Measured in a real repo: without the workaround
a modified file reported 0 hunks; with it, 19. Don't remove `M.apply`/the autocmd.
- `<leader>gp` is the conventional "git PR" key in LazyVim's `octo`/`gh` extras. If the user has
either extra enabled, pick a different key to avoid a clash.
- `require("util.pr_review")` assumes the logic file is at `lua/util/pr_review.lua` on the nvim
runtimepath (standard for LazyVim). Adjust the module path if their layout differs.
- The `User VeryLazy` autocmd + `require("lazy")...load` calls are lazy.nvim-specific. On another
manager, trigger `M.auto()` from a `VimEnter`/`BufReadPre` autocmd and drop the `lazy.load` line
(ensure gitsigns is loaded some other way).
- To leave review mode manually: `:Gitsigns change_base` (reset). In this workflow you usually just
close the ephemeral PR-worktree nvim.
**Alternatives to Module C:** `octo.nvim` (browse/comment/approve PRs in-editor);
`:DiffviewOpen <base>...HEAD` by hand without the auto-marker; VS Code + GitLens; JetBrains.
---
## Module D — GitHub popup (`prefix + g`): PRs, issues, new issue
A fuzzy GitHub menu you can summon from anywhere without opening another TUI. Top level is
**Pull requests · Issues · New issue**; each branch is a small function around `gh`.
**Depends on:** `tmux` ≥ 3.2 (`display-popup`), `fzf`, `gh`, and Module A for the PR actions.
**Division of labour:** this popup handles the _conversation and administrative_ side (read the
description and comments, comment, approve, merge, close, create). Reading a PR's **code** and
writing **line-level review comments** happens in nvim after checking out (Module C). Keeping
those separate is what stopped the menu sprawling.
Two distinctions the menus deliberately make explicit, because they confuse people:
- **Comment on PR** (a plain conversation comment, `gh pr comment`) vs. **review comments**
(line-anchored, part of an approve/request-changes verdict — Module B via `<leader>gv`). The
cheatsheet panel for each points at the other.
- **View** (PRs _and_ issues) is read-only and _returns to the action menu_ when you quit the
pager, so the natural loop is read the discussion → decide → act. That's why both action menus
sit in a `while` loop with `continue` for view and `break` for everything else. A preview pane
is fine for glancing; it's the wrong place to read a long thread.
**Two functions serve both PRs and issues** — `gh pr` and `gh issue` take identical flags for
these, so don't fork them or the two flows will drift apart:
`view_item <kind> <n>` (paged `gh <kind> view --comments`) and
`comment_on <kind> <n>` (edit → confirm → `gh <kind> comment --body-file`).
**Extending it:** the shape is deliberately boring — one fzf menu → one function → one `gh` call.
Adding `gh release`, `gh run`, `gh api` for notifications, "PRs awaiting my review", or a
multi-repo picker is a new entry in the top-level menu plus a function. Do that rather than
cramming more into the existing branches.
### Design notes worth keeping
- **Preview strings must be shell-agnostic.** fzf runs them via `$SHELL -c`; on a fish machine
bash syntax dies at runtime. Both the cheatsheet and the issue detail are rendered by
re-invoking the script itself (`pr-popup --cheat <key>` / `pr-popup --issue <n>`), so the
preview string is a plain command and all real logic stays in bash. Do the same for anything
you add.
- **`GH_FORCE_TTY`** makes `gh` render properly instead of dumping `key: value` when piped, and
wraps to the pane when given `$FZF_PREVIEW_COLUMNS`.
- **Own the editor step.** Don't use `gh ... --editor` or gh's interactive body prompt; open
`$EDITOR` on a temp file and pass `--body-file`. Predictable, and you can show the text back
and confirm before posting. `gh issue comment` and `gh pr comment` take identical flags, so one
`comment_on <kind> <n>` serves both — don't fork it, or issues and PRs will drift apart.
- **Confirm outward-facing actions.** Merge, close (PR and issue), comment, and create all take
an explicit `y/N`. An fzf pick is too easy to fat-finger for something irreversible. Approve is
deliberately one-step (reversible, and wanted to be quick).
- **Never let a failure kill the popup silently.** With `set -e` the popup tears down before the
error can be read; the `run()` wrapper pauses instead.
### Script — `~/bin/pr-popup`
```bash
#!/usr/bin/env bash
# pr-popup — fzf-driven GitHub manager for a tmux display-popup (bound to prefix+g).
#
# Top level: Pull requests · Issues · New issue
# Pull requests → browse, then View / Review (worktree) / Comment / Approve / Merge… / Close…
# Issues → browse + search, then View / Comment / Close…
# New issue → title prompt → body in nvim → confirm
#
# Runs inside the popup with cwd = the pane's repo (display-popup -d #{pane_current_path}).
# Reading a PR and writing review comments happens in nvim once it's checked out
# (<leader>gq / gv / gp) — this menu is for acting on things. `?` toggles the preview pane.
set -eu
# The popup inherits tmux's server env, which may lack these dirs — make sure gh, delta,
# fzf, and the pr* scripts all resolve.
export PATH="$HOME/bin:/opt/homebrew/bin:$PATH"
FZF=$(command -v fzf 2>/dev/null || echo /opt/homebrew/bin/fzf)
pause() { printf '\n'; read -r -p "Press Enter to close… " _ || true; }
# Run an action, but never let a failure silently kill the popup — without this, `set -e`
# tears the popup down before you can read the error.
run() {
if ! "$@"; then
rc=$?
printf '\n\033[31m✖ %s failed (exit %s)\033[0m\n' "$1" "$rc" >&2
pause
exit 1
fi
}
# Outward-facing / awkward-to-undo actions shouldn't fire on an fzf pick alone.
confirm() {
local ans
printf '\n%s [y/N] ' "$1"
read -r ans || return 1
case "$ans" in [yY] | [yY][eE][sS]) return 0 ;; *) return 1 ;; esac
}
# ── shared: composing a comment ──────────────────────────────────────────────────────────────
# Open $EDITOR on a scratch file and hand the result to gh as --body-file, rather than letting
# gh launch the editor itself (`--editor` / its interactive body prompt). gh's own editor path
# errored inside the popup; owning it is predictable and lets you confirm before posting.
edit_body() { # $1 = file to edit; returns 0 if it ended up non-empty
"${EDITOR:-nvim}" "$1" || true
grep -q '[^[:space:]]' "$1" 2>/dev/null
}
# `gh issue comment` and `gh pr comment` take the same flags, so one function serves both.
comment_on() { # $1 = issue|pr, $2 = number
local kind="$1" n="$2" dir file
dir=$(mktemp -d); file="$dir/comment.md"; : > "$file"
printf 'Opening %s for your comment on %s #%s (save & quit when done)…\n' "${EDITOR:-nvim}" "$kind" "$n"
if ! edit_body "$file"; then
printf '\nEmpty comment — nothing posted.\n'; rm -rf "$dir"; pause; return 0
fi
printf '\n\033[2m--- your comment ---\033[0m\n'; cat "$file"; printf '\033[2m--------------------\033[0m\n'
if ! confirm "Post this comment on $kind #$n?"; then
printf '\nCancelled.\n'; rm -rf "$dir"; pause; return 0
fi
if gh "$kind" comment "$n" --body-file "$file"; then
printf '\nCommented on #%s.\n' "$n"
else
printf '\n\033[31m✖ comment failed\033[0m\n' >&2
fi
rm -rf "$dir"; pause
}
# The description + full comment chain, rendered and paged. Read-only. `gh pr view` and
# `gh issue view` both take --comments, so one function serves both.
view_item() { # $1 = pr|issue, $2 = number
local kind="$1" n="$2" w
w=$(tput cols 2>/dev/null || echo 100)
GH_FORCE_TTY="$w" gh "$kind" view "$n" --comments | ${PAGER:-less} -R
}
# ── pull requests ────────────────────────────────────────────────────────────────────────────
# What (if anything) is there to clean up locally for this PR? `prclean` only manages worktrees
# prco created — if prco reused one of YOUR worktrees, prclean is deliberately a no-op, so
# suggesting it would be misleading.
cleanup_hint() {
local n="$1" main name managed head existing
main=$(git worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2; exit}')
[ -n "${main:-}" ] || return 0
name=$(basename "$main")
managed="$HOME/.local/share/pr-worktrees/$name/pr-$n"
if [ -d "$managed" ]; then
printf 'Local cleanup: prclean %s\n' "$n"
return 0
fi
head=$(gh pr view "$n" --json headRefName -q .headRefName 2>/dev/null || true)
if [ -n "${head:-}" ]; then
existing=$(git -C "$main" worktree list --porcelain 2>/dev/null \
| awk -v b="refs/heads/$head" '/^worktree /{p=$2} $1=="branch" && $2==b {print p; exit}')
if [ -n "${existing:-}" ]; then
printf 'Your worktree is untouched: %s\n' "$existing"
printf ' branch %s — remove it yourself when done (prclean only manages its own).\n' "$head"
return 0
fi
fi
printf 'No local checkout for #%s.\n' "$n"
}
merge_pr() {
local n="$1" method label
local flags
method=$(printf '%s\t%s\n' \
squash "Squash and merge" \
merge "Create a merge commit" \
rebase "Rebase and merge" \
auto "Auto-merge when checks pass (squash)" \
| "$FZF" --delimiter='\t' --with-nth=2 --prompt="Merge #$n how? ❯ " \
| cut -f1) || return 0
[ -n "${method:-}" ] || return 0
case "$method" in
squash) flags=(--squash); label="squash and merge" ;;
merge) flags=(--merge); label="merge commit" ;;
rebase) flags=(--rebase); label="rebase and merge" ;;
auto) flags=(--squash --auto); label="auto-merge when checks pass (squash)" ;;
*) return 0 ;;
esac
if ! confirm "Merge #$n via $label?"; then printf '\nCancelled.\n'; pause; return 0; fi
# Deliberately no --delete-branch: with a worktree-per-branch workflow the local branch is
# usually checked out somewhere, which makes the delete fail after the merge has landed.
if gh pr merge "$n" "${flags[@]}"; then
printf '\nMerged #%s (%s).\n' "$n" "$label"
cleanup_hint "$n"
else
printf '\n\033[31m✖ merge failed\033[0m\n' >&2
fi
pause
}
close_pr() {
local n="$1"
if ! confirm "Close #$n WITHOUT merging?"; then printf '\nCancelled.\n'; pause; return 0; fi
if gh pr close "$n"; then
printf '\nClosed #%s.\n' "$n"
cleanup_hint "$n"
else
printf '\n\033[31m✖ close failed\033[0m\n' >&2
fi
pause
}
pr_flow() {
local list pr action
list=$(gh pr list --limit 50 --json number,title,author,headRefName \
--jq '.[] | "\(.number)\t\(.title)\t@\(.author.login)\t\(.headRefName)"' 2>/dev/null || true)
if [ -z "$list" ]; then
echo "No open PRs in $slug."; pause; return 0
fi
# '?' toggles a delta diff preview (hidden by default to stay snappy — it's a network call).
pr=$(printf '%s\n' "$list" \
| "$FZF" --delimiter='\t' --with-nth=1,2,3 \
--prompt="PR ❯ " --header="$slug — open PRs (? = diff preview)" \
--preview='gh pr diff {1} | delta' \
--preview-window='right,60%,hidden,wrap' \
--bind='?:toggle-preview' \
| cut -f1) || return 0
[ -n "${pr:-}" ] || return 0
# Loop so "View PR" returns here — read the conversation, then decide what to do about it.
while true; do
action=$(printf '%s\t%s\n' \
view "View PR — description + comments" \
review "Review PR — worktree + tmux session" \
comment "Comment on PR (not a review)" \
approve "Approve PR (no comment)" \
merge "Merge PR…" \
close "Close PR (without merging)…" \
| "$FZF" --delimiter='\t' --with-nth=2 --prompt="Action for #$pr ❯ " \
--header="$slug #$pr (? toggles this panel)" \
--preview="'$0' --cheat {1}" \
--preview-window='right,62%,wrap' \
--bind='?:toggle-preview' \
| cut -f1) || return 0
[ -n "${action:-}" ] || return 0
case "$action" in
view) view_item pr "$pr"; continue ;; # back to the menu when you quit the pager
review) run prco "$pr"; break ;; # switches the client to the PR session
comment) comment_on pr "$pr"; break ;;
approve) run gh pr review "$pr" --approve; printf '\nApproved #%s.\n' "$pr"; pause; break ;;
merge) merge_pr "$pr"; break ;;
close) close_pr "$pr"; break ;;
esac
done
}
# ── issues ───────────────────────────────────────────────────────────────────────────────────
close_issue() {
local n="$1" reason
reason=$(printf '%s\t%s\n' \
completed "Close as completed" \
"not planned" "Close as not planned" \
| "$FZF" --delimiter='\t' --with-nth=2 --prompt="Close #$n how? ❯ " \
| cut -f1) || return 0
[ -n "${reason:-}" ] || return 0
if ! confirm "Close issue #$n as \"$reason\"?"; then printf '\nCancelled.\n'; pause; return 0; fi
if gh issue close "$n" --reason "$reason"; then
printf '\nClosed issue #%s (%s).\n' "$n" "$reason"
else
printf '\n\033[31m✖ close failed\033[0m\n' >&2
fi
pause
}
issue_flow() {
local list issue action
list=$(gh issue list --limit 100 --json number,title,author \
--jq '.[] | "\(.number)\t\(.title)\t@\(.author.login)"' 2>/dev/null || true)
if [ -z "$list" ]; then
echo "No open issues in $slug."; pause; return 0
fi
# Type to fuzzy-search titles. '?' toggles the rendered issue detail.
issue=$(printf '%s\n' "$list" \
| "$FZF" --delimiter='\t' --with-nth=1,2,3 \
--prompt="Issue ❯ " --header="$slug — open issues (type to search · ? = detail)" \
--preview="'$0' --issue {1}" \
--preview-window='right,60%,hidden,wrap' \
--bind='?:toggle-preview' \
| cut -f1) || return 0
[ -n "${issue:-}" ] || return 0
# Detail is shown by default here, so you decide with the issue in front of you. That's a pane
# though — "View issue" pages the whole thread properly and returns here afterwards.
while true; do
action=$(printf '%s\t%s\n' \
view "View issue — description + comments" \
comment "Comment on this issue" \
close "Close issue…" \
| "$FZF" --delimiter='\t' --with-nth=2 --prompt="Issue #$issue ❯ " \
--header="$slug #$issue (? toggles detail)" \
--preview="'$0' --issue $issue" \
--preview-window='right,62%,wrap' \
--bind='?:toggle-preview' \
| cut -f1) || return 0
[ -n "${action:-}" ] || return 0
case "$action" in
view) view_item issue "$issue"; continue ;; # back to the menu when you quit the pager
comment) comment_on issue "$issue"; break ;;
close) close_issue "$issue"; break ;;
esac
done
}
new_issue() {
local title dir file
printf 'New issue in %s\n\n' "$slug"
printf 'Title: '
read -r title || return 0
if [ -z "${title:-}" ]; then printf '\nNo title — cancelled.\n'; pause; return 0; fi
dir=$(mktemp -d); file="$dir/issue.md"; : > "$file"
printf '\nOpening %s for the body — leave it empty for a title-only issue…\n' "${EDITOR:-nvim}"
edit_body "$file" || true
printf '\n\033[1mTitle:\033[0m %s\n' "$title"
if grep -q '[^[:space:]]' "$file" 2>/dev/null; then
printf '\033[2m--- body ---\033[0m\n'; cat "$file"; printf '\033[2m------------\033[0m\n'
else
printf '\033[2m(no body)\033[0m\n'
fi
if ! confirm "Create this issue in $slug?"; then
printf '\nCancelled — nothing created.\n'; rm -rf "$dir"; pause; return 0
fi
if gh issue create --title "$title" --body-file "$file"; then
printf '\nIssue created.\n'
else
printf '\n\033[31m✖ issue creation failed\033[0m\n' >&2
fi
rm -rf "$dir"; pause
}
# ── cheatsheet (rendered into fzf's preview pane) ────────────────────────────────────────────
# Abridged here — in the real script each branch is a `cat <<EOF` block using $B/$D/$C/$Y/$R ANSI
# vars for bold/dim/cyan/yellow/reset. Keep lines <= 55 columns so they fit the pane. Write these
# for YOUR keys and workflow; they are the in-context documentation for the whole thing.
# NOTE: the issue *action* menu previews the issue itself, not this cheatsheet — so issue actions
# don't need panels here; document them in the `issues` panel instead.
cheat() {
local B D C Y R
B=$'\033[1m'; D=$'\033[2m'; C=$'\033[36m'; Y=$'\033[33m'; R=$'\033[0m'
case "${1:-}" in
prs) ;; # top level: what the PR actions do; "? previews the diff"
issues) ;; # top level: search, detail, View/Comment/Close semantics
newissue) ;; # top level: title -> body -> confirm; nothing created until confirmed
view) ;; # gh pr view --comments; read-only, paged, returns to the menu
review) ;; # what prco does + the nvim keys (,gq ,ghp ]h ,gw ,gp ,gv ,gV ,gP)
# + prr syntax (quoted "> " diff, unquoted = comment, @prr verdicts, zM/za/zR)
comment) ;; # plain conversation comment vs. line-level review — point at Review PR + ,gv/,gV
approve) ;; # gh pr review --approve; runs immediately; self-approval 422
merge) ;; # the four methods + "asks y/N" + why no --delete-branch + prclean
close) ;; # gh pr close; asks y/N; reversible; prclean
esac
}
# Preview hooks. IMPORTANT: fzf runs preview/execute strings via `$SHELL -c`, and $SHELL here is
# fish — so a preview string must contain NO bash syntax (no `VAR=val cmd` prefix, no
# `${VAR:-default}`). Keep them plain command invocations and do the real work back in this
# script, where we know we're in bash.
if [ "${1:-}" = "--cheat" ]; then cheat "${2:-}"; exit 0; fi
if [ "${1:-}" = "--issue" ]; then
# GH_FORCE_TTY makes gh render the pretty view instead of a key:value dump when piped.
GH_FORCE_TTY="${FZF_PREVIEW_COLUMNS:-80}" exec gh issue view "${2:-}" --comments
fi
# Must be inside a GitHub repo.
if ! slug=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null); then
echo "Not inside a GitHub repository."; pause; exit 0
fi
# ── top level ────────────────────────────────────────────────────────────────────────────────
top=$(printf '%s\t%s\n' \
prs "Pull requests" \
issues "Issues" \
newissue "New issue…" \
| "$FZF" --delimiter='\t' --with-nth=2 --prompt="GitHub ❯ " \
--header="$slug (? toggles this panel)" \
--preview="'$0' --cheat {1}" \
--preview-window='right,62%,wrap' \
--bind='?:toggle-preview' \
| cut -f1) || exit 0
[ -n "${top:-}" ] || exit 0
case "$top" in
prs) pr_flow ;;
issues) issue_flow ;;
newissue) new_issue ;;
esac
```
`chmod +x ~/bin/pr-popup`. (The name is historical — it manages issues too. Rename it if you like;
just update the tmux binding.)
**tmux binding** (add to `~/.tmux.conf`; `g` is unbound by default — check the user's config):
```tmux
bind-key g display-popup -E -d "#{pane_current_path}" -w 90% -h 85% "$HOME/bin/pr-popup"
```
Reload with your reload binding or `tmux source-file ~/.tmux.conf`. If your tmux doesn't expand
`$HOME` there, use an absolute path. Pick a different key if `prefix + g` is taken.
**Alternatives to Module D:** run the script directly as a shell command (no tmux needed); or use
`gh-dash` for a richer standalone dashboard; or drive it from `lazygit` custom commands.
## Module E — shell convenience (fish)
One abbreviation for listing PRs. Author uses fish:
```fish
abbr -a prs "gh pr list"
```
**bash/zsh equivalent:** `alias prs='gh pr list'` in `~/.bashrc` / `~/.zshrc`.
The `pr*` scripts themselves need no shell integration — they're on `PATH` and callable from any
shell, tmux, or editor.
---
## 5. Install order (suggested)
Install all of it unless the user said otherwise (§3). This order builds the foundation first, then
the entry point, so you can demo progress as you go.
1. Ensure `git`, `gh` (`gh auth login`), `fzf`, `tmux` are present. Add `~/bin` to `PATH`.
2. **Module A** scripts → `chmod +x`. Test `prdiff <n>`, `prco <n>`, `prclean <n>` on a repo with an
open PR. Everything else builds on these.
3. **Module D** popup + tmux binding — the everyday entry point; `prefix + g` should now work.
4. **Module C** (Neovim) → add both lua files, install `diffview.nvim`, restart nvim.
5. **Module B** (`prr`) → install + configure token (redacted). _The most reasonable one to skip:_
it exists purely for inline, line-level review comments. If skipped, drop `prreview`/`prsubmit`
from Module A and the `<leader>gv`/`<leader>gV` keymaps from Module C.
6. **Module E** shell abbr/alias.
Each step is independently verifiable, so check in as you go rather than at the end.
## 6. Verification quick-checks
- `prdiff <n>` renders a colored diff.
- `prco <n>` on a PR whose branch you **don't** have locally → creates
`~/.local/share/pr-worktrees/<repo>/pr-<n>` + tmux session `pr-<n>`.
- `prco <n>` on a PR whose branch you **do** already have checked out → prints "already checked out
at …" and opens a session on _that_ worktree, creating nothing. (This is the case that used to
fail outright.)
- In the PR's worktree, `nvim .` → `<leader>gq` lists every changed hunk in the quickfix; jumping to
one shows **gutter signs in that file** (if the quickfix has entries but the file shows no signs,
the `M.apply`/attach workaround is missing — see Module C gotchas); `<leader>ghp` previews the
hunk inline; `<leader>gp` opens diffview with an **editable** right-hand pane.
- `prreview <n>` opens a review file; `prsubmit <n>` posts it (Module B).
- `prclean <n>` refuses if there's uncommitted/unpushed work; otherwise removes worktree + branch +
session. On a _reused_ worktree it must report "no worktree at …" and leave your worktree intact.
- `prefix + g` opens the popup showing **Pull requests / Issues / New issue**, each with a
`?`-toggled panel; a failing action prints the error and waits for Enter rather than closing the
popup instantly (Module D).
- **PR → View** shows the description and comments rendered (not a raw dump), and quitting the
pager returns you to the action menu rather than closing the popup.
- **PR → Comment** posts a plain conversation comment; confirm it lands on the PR's _Conversation_
tab, not as a line-level review comment.
- **Issues**: the list is fuzzy-searchable; `?` shows the issue _rendered_ (title, state, author,
markdown body, **and its comments**) — if you instead see a raw `key: value` dump, `GH_FORCE_TTY`
isn't being set; if the body appears but the discussion doesn't, `--comments` is missing.
- **Issue → View** pages the whole thread and returns to the action menu on `q`, same as PR → View.
- **Issue previews on a fish/non-POSIX `$SHELL`**: if the preview pane shows something like
`fish: ${ is not a valid variable`, a preview string still contains bash syntax — move it behind
a flag on the script (gotcha 8).
- **Comment / New issue**: your editor opens on a temp file, the text is echoed back, and nothing
posts until you answer `y`. An empty comment or empty title cancels. If the editor itself throws
autocmd errors, check the user's own editor config first (gotcha 11).
- **Nothing destructive fires from a menu pick alone** — merge, close (PR _and_ issue), comment and
create all require an explicit `y/N`.
A set of small, composable tools that let you browse, read, check out, run, review, and comment
on GitHub pull requests entirely from the terminal — no browser, no mouse. Built for a
fish + tmux + Neovim(LazyVim) + gh + git-delta setup, but every piece is optional and
swappable (see For the implementing agent).
1. Quick reference — the workflow & commands
Once installed, this is the whole day-to-day surface.
From the shell / tmux
You want to…
Do this
GitHub hub
tmux prefix + g → Pull requests · Issues · New issue. Every menu has a ?-toggled panel (context help, or the rendered issue).
↳ Pull requests
pick a PR → View (description + comments, paged) · Review (worktree) · Comment (plain, not a review) · Approve · Merge… (squash / merge commit / rebase / auto) · Close…. ? previews the diff. View returns to the menu; merge/close/comment ask an explicit y/N.
↳ Issues
fuzzy-search open issues → the rendered issue (body + comments) is shown while you choose → View (whole thread, paged; q returns to the menu) · Comment (write in nvim, read it back, confirm) · Close… (completed / not planned)
↳ New issue
title at the prompt → body in nvim → read it back → confirm. Nothing is created until you confirm.
List open PRs
prs (alias for gh pr list)
Read a PR’s diff
prdiff <n> (add -s for side-by-side), or ? in the popup
Open a PR to work on
prco <n> → puts you in a tmux session on that PR’s code. If the PR’s branch is already checked out in one of your own worktrees it reuses that; otherwise it creates a managed worktree at ~/.local/share/pr-worktrees/<repo>/pr-<n>. Commit + push works either way.
Write inline review comments
prreview <n> → annotate the review file in nvim → prsubmit <n>
Quick approve
gh pr review <n> --approve
Tear down a PR checkout
prclean <n> (or bare prclean to fuzzy-pick). Refuses if there’s uncommitted or unpushed work (-f forces). Only ever removes worktrees it created — your own worktrees are never touched.
In Neovim — review mode
Review mode pins gitsigns’ baseline to the PR’s target branch, so the whole codebase shows the
PR’s changes as you browse normally (not just your uncommitted edits). It auto-activates in a
managed prco worktree; in a reused worktree just press <leader>gq or <leader>gp — they
detect the PR live.
Key
Does
<leader>gq
Every changed hunk in the repo → quickfix. The main entry point: step through the PR’s changes in the real files, editing normally.
<leader>ghp
Preview this hunk inline — shows exactly what was added/removed at the cursor (LazyVim default)
]h / [h
Next / previous hunk (LazyVim default)
<leader>gw
Toggle inline diff for the file — deleted lines shown inline + word-level highlighting; reads like a diff but stays editable
<leader>gp
Diffview panel — changed-files list + side-by-side. The right pane is the working tree, so you can fix a mistake and :w without leaving the diff
<leader>gv
Open this PR’s prr review file as a normal buffer in this nvim (downloads it if needed) — write comments next to the code you’re reviewing
<leader>gV
Submit the prr review (writes the buffer, then confirms before posting)
<leader>gP
Review mode off (gutters back to normal)
Typical loops
Fast review (no checkout):prefix + g → pick PR → Diff to read → Review (comments in nvim) or Approve. Done.
Deep review (read + fix the real code):
prco 142 # tmux session on the PR's code (reused or managed worktree)nvim . <leader>gq # all of the PR's changes -> quickfix <leader>ghp # on a hunk: see exactly what changed here …fix it in place, :w ]h # next change <leader>gp # side-by-side for a tricky file (right pane is editable) <leader>gv # open the prr review file right here; write comments <leader>gV # submit the review (confirms first)git commit && git push # pushes back to the PR branchprclean 142 # only if prco created the worktree; safe by default
prreview/prsubmit (Module A) do the same from a shell, but they spawn a second$EDITOR. If
you’re already in nvim in the PR’s worktree, <leader>gv/<leader>gV keep everything in one place.
2. What this is and why (for anyone with no prior context)
The problem. If you live in the terminal (tmux, a TUI editor, gh, lazygit), reviewing
GitHub PRs is the one task that keeps yanking you back to a browser: reading diffs, checking out
branches to try them, leaving line comments. That context-switch is slow and breaks flow.
The goal. Make the everyday GitHub loop native to the terminal:
Browse/triage open PRs and issues from a fuzzy picker.
Read diffs with good syntax highlighting, and issues rendered properly.
Manage issues — search, read, comment, close, create — without opening a browser.
Try locally in a way that never disturbs your current work — each PR gets its own git
worktree and tmux session, cleanly isolated and trivially torn down (and if you already keep
a worktree for that branch, it’s reused rather than duplicated).
Review in your editor by navigating the actual checked-out codebase, with the PR’s changed
lines marked relative to its target branch (not just “what I edited”).
Comment with real inline GitHub review comments, written in your editor.
The design ethos: a handful of tiny scripts + your existing tools, one source of truth,
nothing bespoke where a standard tool already fits. Each part below is independently useful —
adopt only what you want.
3. For the implementing agent (read this first)
⚠️ Default: build this as specified — then check in about variations
The reader has most likely seen this workflow demonstrated and wants the same thing on their
machine. So reproduce it as described by default. It’s a coherent whole: the modules were
designed to fit together, and the defaults encode decisions arrived at by actually hitting the
problems listed in Known gotchas. Treat those defaults as load-bearing.
Do not open with “so which parts would you like?” — that dumps design work on someone who
just wants the setup working. Instead:
State the plan and confirm it. e.g. “I’ll set up the full workflow: PR checkout into
isolated worktrees + tmux sessions, in-editor review in Neovim, inline review comments via
prr, and the prefix + g GitHub popup for PRs and issues. Want all of that, or shall I leave
anything out?” One question, easy to say “yes, all of it” to.
Interview where their environment forces a decision — different shell, editor,
multiplexer, OS, or a keybinding that’s already taken. These are compatibility questions, not
preference questions; every module lists alternatives for exactly this. Don’t silently
substitute — ask, then adapt.
Call out the genuinely optional pieces so opting out is cheap and informed: prr
(Module B — only needed for inline, line-level review comments; gh pr review already
covers approve and top-level comments) and the shell abbreviation (Module E).
Then invite variations — different keymaps, a different worktree location, extra
top-level popup entries (releases, Actions runs, notifications, “review requested of me”).
Build those if asked. Don’t propose a redesign unprompted.
Deviate when the user asks, or when their environment requires it. Don’t deviate because
something looks tidier — that’s how the gotchas get reintroduced.
That said, nothing here requires all-or-nothing: the modules are independent, so a user who
only wants one part can have it, and more can be added later. Just let that be their call.
You are setting this up on someone else’s machine, which will differ from the author’s. Before
installing anything:
Inventory what’s already installed (§4) — so you know what actually needs adding, and where
their stack differs from the author’s. This is reconnaissance, not a prompt to re-litigate
whether each module is wanted; that’s settled by the single confirmation above.
Then walk the dependencies module by module. For each missing one, say briefly what it’s
for and install it. Pause only where their environment points at an alternative (no Neovim, no
tmux, a different pager) — surface the choice, take their answer, move on.
Adapt to their environment. Paths, shell (fish vs zsh/bash), editor, terminal multiplexer,
and leader keys will differ. Genericize accordingly.
Preserve modularity. Keep the scripts as separate files so the user can delete/replace any
one without breaking the others.
The author’s baseline (for reference; the user’s may differ): macOS, fish shell, tmux, Neovim
(LazyVim), Ghostty, gh authenticated over SSH, git-delta as the git pager.
Known gotchas — every one of these was hit in practice
Don’t “simplify” these away; each is load-bearing. Details in the module sections.
A PR’s branch may already be checked out in another worktree. Git refuses to check a branch
out twice, so gh pr checkout fails hard. Extremely common if the user keeps a worktree per
feature branch and opens PRs from them. prco detects and reuses. (Module A)
Never delete a worktree you didn’t create. Reused worktrees are the user’s real work;
prclean must only touch its own managed paths. (Module A)
prclean must not blindly --force. Check for uncommitted changes and commits not on any
remote before removing, or you silently destroy work. (Module A)
set -e inside a tmux popup hides all errors — the popup tears down before anything can be
read, so failures look like “nothing happened.” Wrap actions so failures pause. (Module D)
diffview: diff a single rev, not a base...HEAD range. A range diffs two historical commits
→ both panes read-only. A single rev puts the working tree on the right → editable. (Module C)
gitsigns’ global change_base doesn’t reach buffers that attach later. They keep
base = nil and diff against HEAD, so no gutter signs on anything you open afterwards — the
feature silently appears to do nothing. Re-apply per buffer on attach. (Module C)
LazyVim’s gitsigns keymaps are buffer-local (set in on_attach), so they’re invisible to
nvim_get_keymap. Check nvim_buf_get_keymap(0,'n') in an attached buffer before concluding a
key is missing or free. (Module C)
fzf runs --preview/execute strings through $SHELL -c. If the user’s $SHELL is fish
(or any non-POSIX shell), bash-isms in a preview string break at runtime with something like
fish: ${ is not a valid variable in fish. Ours hit this with a VAR=value cmd prefix and
${VAR:-default}. Keep preview strings to plain command invocations — put any real logic
behind a flag on your own script (pr-popup --issue {1}) where you control the interpreter.
(Module D)
gh prints raw key: value dumps when piped.gh issue view in a preview pane looks
nothing like the terminal output. Set GH_FORCE_TTY (fzf exports FZF_PREVIEW_COLUMNS, so
GH_FORCE_TTY=$FZF_PREVIEW_COLUMNS renders it properly and wraps to the pane). (Module D)
Don’t let gh open the editor for you.gh issue comment --editor and gh issue create’s
interactive body step errored inside the popup. Owning that step — open $EDITOR on a temp
file yourself, then pass --body-file — is predictable, works everywhere, and lets you show
the text back and confirm before anything posts. (Module D)
gh <kind> view omits comments unless you pass --comments. Easy to miss, because the
output looks complete — you get the description and assume that’s the whole thing, when the
entire discussion is silently absent. Applies to both gh pr view and gh issue view, in
previews and full views. (Module D)
Tooling that opens the user’s editor will surface their pre-existing config errors. Our
temp files are .md, which exposed a broken FileType markdown autocmd in the author’s nvim
(it required a plugin removed in a migration) — it looked like our bug but fired on every
markdown buffer. If the editor errors on open, reproduce with a bare
nvim --headless <file> -c 'messages' before blaming the tooling. (Module D)
4. Dependencies at a glance
Module
Hard deps
Optional / alternatives
A. Core scripts (browse/diff/checkout/cleanup)
git, gh (authenticated), fzf, tmux
git-delta for pretty diffs → else use gh pr diff | less -R, difftastic, or bat
B. Review authoring (prr)
prr, a GitHub token
Alternatives: gh pr review (no inline comments), octo.nvim, GitHub web
C. In-editor review (Neovim)
Neovim, gitsigns.nvim (with change_base), diffview.nvim, a plugin manager
Alternatives: octo.nvim, manual :DiffviewOpen, VS Code + GitLens, JetBrains
D. GitHub popup (prefix + g) — PRs, issues, new issue
tmux ≥ 3.2 (display-popup), fzf, gh
Or run the script as a normal command (no tmux); or use gh-dash / lazygit custom commands instead. Easy to extend to releases, Actions, notifications…
E. Shell abbreviation
fish
zsh/bash alias equivalent
Common setup for the scripts: put them in a directory on your PATH (e.g. ~/bin or
~/.local/bin), chmod +x them. They are POSIX-ish bash and avoid Bash-4 features so they run on
macOS’s stock bash 3.2. gh must be logged in (gh auth login).
Module A — Core scripts (~/bin/pr*)
The foundation: browse, diff, check-out-to-worktree, and clean up. Depends on git, gh, fzf,
tmux (and delta for prdiff, easily swapped).
Key design points / gotchas:
Worktrees are stored at ~/.local/share/pr-worktrees/<repo>/pr-<n> — outside the repo, so
nothing needs .gitignore, and one repo can have many PRs checked out at once.
If you already run a worktree-per-branch workflow, this matters: git refuses to check out a
branch that’s already checked out in another worktree, so gh pr checkout fails hard for any PR
whose branch you already have locally (very common when the PRs are your own feature branches).
prco therefore checks first — if the PR’s head branch is already in a worktree, it reuses
that worktree and just opens a session there (named after the directory), rather than creating a
competing checkout. Those reused worktrees are not managed: prclean will never delete them,
and no pr-review-base marker is written into them (use <leader>gp in nvim there, which
detects the PR live).
prco uses gh pr checkout (not a bare fetch) so you can commit and push back to the PR branch.
Pushing back to a fork PR still requires the PR author enabled “allow maintainer edits.”
prclean is safe by default: it refuses to delete a worktree with uncommitted changes or
commits not pushed to any remote. -f overrides. It also removes the local branch and the
tmux session, and works even when run from inside the PR’s own session.
tmux tip: set set -g detach-on-destroy off so killing a PR session lands you on another
session instead of detaching.
prdiff — view a PR’s diff through delta
#!/usr/bin/env bash# prdiff <pr-number> [-s] — view a GitHub PR's diff through delta# -s / --side-by-side : force side-by-side (overrides your delta config)# Runs inside a GitHub repo. delta pages with less; navigate=true → n/N jumps files.set -eun="${1:-}"if [ -z "$n" ]; then echo "usage: prdiff <pr-number> [-s]" >&2; exit 2; ficase "${2:-}" in -s|--side-by-side) gh pr diff "$n" | delta --side-by-side ;; *) gh pr diff "$n" | delta ;;esac
No delta? Replace the two delta lines with gh pr diff "$n" | less -R (uses gh’s own
coloring), or pipe to difftastic/bat.
If delta warns Unknown theme '<name>', using default: the theme was configured but never
installed, so you’ve been silently getting the fallback. delta uses bat’s theme machinery — drop
the .tmTheme into $(bat --config-dir)/themes/ and run bat cache --build. (Many colourschemes
ship one; e.g. tokyonight.nvim has them under extras/sublime/.) Rebuild again after a bat upgrade.
prco — check a PR out into an isolated worktree + tmux session
#!/usr/bin/env bash# prco [--no-switch] <pr-number># Open a PR in a dedicated tmux session.## If the PR's head branch is ALREADY checked out in one of your existing worktrees, that# worktree is reused (git can't check a branch out twice). prco does not own it and# `prclean` will never delete it — you just get a session there.## Otherwise the PR is checked out into a managed worktree under# ~/.local/share/pr-worktrees/<repo>/pr-<n> (never inside the repo), via `gh pr checkout`# so committing + pushing back to the PR branch works (fork push-back still requires the# PR's "allow maintainer edits"). Managed worktrees also record the PR's base branch in# $gitdir/pr-review-base so nvim auto-enters review mode (gitsigns + diffview).# In a reused worktree, use <leader>gp in nvim instead — it detects the PR live.## --no-switch : create the session but don't switch to it (prints how to jump instead).set -euswitch=1if [ "${1:-}" = "--no-switch" ]; then switch=0; shift; fin="${1:-}"if [ -z "$n" ]; then echo "usage: prco [--no-switch] <pr-number>" >&2; exit 2; fi# Resolve the MAIN worktree so repo name/paths are stable even when invoked from a linked one.main=$(git worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2; exit}')if [ -z "$main" ]; then echo "prco: not inside a git repository" >&2; exit 1; finame=$(basename "$main")# Is this PR's branch already checked out in a worktree? If so, reuse it.head=$(gh pr view "$n" --json headRefName -q .headRefName 2>/dev/null || true)existing=""if [ -n "$head" ]; then existing=$(git -C "$main" worktree list --porcelain 2>/dev/null \ | awk -v b="refs/heads/$head" '/^worktree /{p=$2} $1=="branch" && $2==b {print p; exit}')fiif [ -n "$existing" ]; then # --- Reuse an existing worktree (yours). Session named after the dir, matching `t`. --- wt="$existing" # printf (not echo/basename alone) so the trailing newline isn't turned into a stray '_'. session=$(printf '%s' "$(basename "$wt")" | tr -c 'A-Za-z0-9_-' '_') echo "PR #$n is on '$head', already checked out at:" echo " $wt" echo "Using that worktree (prco doesn't manage it; prclean won't touch it)."else # --- Create a managed worktree for this PR. --- wt="$HOME/.local/share/pr-worktrees/$name/pr-$n" session="pr-$n" if [ ! -d "$wt" ]; then mkdir -p "$(dirname "$wt")" git -C "$main" worktree add --detach "$wt" HEAD >/dev/null if ! ( cd "$wt" && gh pr checkout "$n" ); then echo "prco: 'gh pr checkout $n' failed" >&2 git -C "$main" worktree remove --force "$wt" 2>/dev/null || true rm -rf "$wt" rmdir "$(dirname "$wt")" 2>/dev/null || true exit 1 fi fi # Record the PR's base branch for nvim review mode, and fetch it so gitsigns/diffview # can diff against it offline. Managed worktrees only — never touch your own worktrees. gitdir=$(git -C "$wt" rev-parse --absolute-git-dir 2>/dev/null || true) if [ -n "$gitdir" ] && [ ! -f "$gitdir/pr-review-base" ]; then base=$(gh pr view "$n" --json baseRefName -q .baseRefName 2>/dev/null || true) if [ -n "$base" ]; then git -C "$wt" fetch -q origin "$base" 2>/dev/null || true printf 'origin/%s\n' "$base" > "$gitdir/pr-review-base" 2>/dev/null || true fi fifi# Create the session (detached) only if it doesn't already exist — no attach side effect.tmux has-session -t "=$session" 2>/dev/null || tmux new-session -d -s "$session" -c "$wt"if [ "$switch" = 1 ]; then if [ -n "${TMUX:-}" ]; then tmux switch-client -t "$session"; else tmux attach -t "$session"; fielse echo "Session '$session' ready at $wt" echo " jump with: prefix+T (or: tmux switch-client -t $session)"fi
Not using Module C (nvim)? The pr-review-base block is harmless but unnecessary — you can
delete it. No tmux? Replace the session lines with a plain cd "$wt" and open a shell/editor
there yourself.
#!/usr/bin/env bash# prreview <pr-number># Download the current repo's PR into a prr review file and open it in $EDITOR (nvim).# Add inline comments in the review file, save/quit, then run `prsubmit <pr-number>`.# Re-run to reopen an in-progress review without re-downloading.set -eun="${1:-}"if [ -z "$n" ]; then echo "usage: prreview <pr-number>" >&2; exit 2; fislug=$(gh repo view --json nameWithOwner -q .nameWithOwner)# First time: fetch + open. If a review already exists, just reopen it.prr get "$slug/$n" --open 2>/dev/null || prr edit "$slug/$n"
#!/usr/bin/env bash# prsubmit <pr-number># Submit the prr review for the current repo's PR (posts inline comments as a GitHub review).set -eun="${1:-}"if [ -z "$n" ]; then echo "usage: prsubmit <pr-number>" >&2; exit 2; fislug=$(gh repo view --json nameWithOwner -q .nameWithOwner)prr submit "$slug/$n"echo "Submitted review for $slug#$n"
prclean — safe, complete teardown
#!/usr/bin/env bash# prclean [-f] [<pr-number>]# Tear a PR checkout all the way down: worktree + its tmux session + local branch + prr review.# No <pr-number> → fzf-pick from the PR checkouts that exist for this repo.# SAFE by default: refuses if the worktree has uncommitted changes or commits not on any# remote. Pass -f to discard anyway. Works even when run from inside the PR's own session.set -euforce=0case "${1:-}" in -f|--force) force=1; shift ;; esacmain=$(git worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2; exit}')if [ -z "$main" ]; then echo "prclean: not inside a git repository" >&2; exit 1; finame=$(basename "$main")base="$HOME/.local/share/pr-worktrees/$name"n="${1:-}"if [ -z "$n" ]; then if [ ! -d "$base" ] || [ -z "$(ls -A "$base" 2>/dev/null)" ]; then echo "No PR checkouts for $name."; exit 0 fi n=$(ls -1 "$base" 2>/dev/null | sed -n 's/^pr-//p' \ | fzf --prompt="clean PR ❯ " --header="$name — checked-out PRs" \ --preview="git -C '$base/pr-{}' status -sb 2>/dev/null") || exit 0 [ -n "$n" ] || exit 0fiwt="$base/pr-$n"session="pr-$n"if [ ! -d "$wt" ]; then echo "prclean: no worktree at $wt" tmux kill-session -t "=$session" 2>/dev/null && echo "(killed stray session $session)" || true exit 0fi# --- Safety gate: don't discard work unless forced ---if [ "$force" != 1 ]; then dirty=$(git -C "$wt" status --porcelain 2>/dev/null || true) unpushed=$(git -C "$wt" log --oneline HEAD --not --remotes 2>/dev/null | head -1 || true) if [ -n "$dirty" ] || [ -n "$unpushed" ]; then echo "prclean: pr-$n still has work:" >&2 [ -n "$dirty" ] && echo " - uncommitted changes" >&2 [ -n "$unpushed" ] && echo " - commits not pushed to any remote" >&2 echo " Push/commit first, or discard with: prclean -f $n" >&2 exit 1 fifibranch=$(git -C "$wt" rev-parse --abbrev-ref HEAD 2>/dev/null || true)slug=$( (cd "$main" && gh repo view --json nameWithOwner -q .nameWithOwner) 2>/dev/null || true)# --- Do all the filesystem/git/review teardown BEFORE killing the session, so this still# completes cleanly even when prclean is running inside the session being removed. ---git -C "$main" worktree remove --force "$wt" 2>/dev/null || rm -rf "$wt"git -C "$main" worktree prune 2>/dev/null || trueif [ -n "$branch" ] && [ "$branch" != "HEAD" ]; then if [ "$force" = 1 ]; then git -C "$main" branch -D "$branch" 2>/dev/null || true else git -C "$main" branch -d "$branch" 2>/dev/null \ || echo "note: kept branch '$branch' (unmerged) — remove with: git -C '$main' branch -D '$branch'" fifi[ -n "$slug" ] && prr remove -f "$slug/$n" 2>/dev/null || truermdir "$base" 2>/dev/null || trueecho "Cleaned pr-$n (worktree${branch:+ + branch $branch} + session + review)."# --- Session last: move the client off it first (detach-on-destroy off also covers this),# then kill. If we were running inside it, everything above already finished. ---if [ -n "${TMUX:-}" ] && [ "$(tmux display-message -p '#{session_name}' 2>/dev/null || true)" = "$session" ]; then other=$(tmux list-sessions -F '#{session_name}' 2>/dev/null | grep -vx "$session" | head -1 || true) [ -n "$other" ] && tmux switch-client -t "$other" 2>/dev/null || truefitmux kill-session -t "=$session" 2>/dev/null || true
The prr remove line is a no-op if you skip Module B.
Install Module A: save the files above into a PATH dir (author used ~/bin) with those
exact names (no extension), then chmod +x ~/bin/prdiff ~/bin/prco ~/bin/prreview ~/bin/prsubmit ~/bin/prclean.
Module B — Review authoring with prr
prr (github.com/danobi/prr) brings mailing-list-style review: it downloads a PR as a local file,
you annotate lines inline in your editor, then submit — producing a real GitHub review with inline
comments. Used by prreview/prsubmit above and the popup’s Review/Submit actions.
Install:brew install prr (it’s in Homebrew core) or cargo install prr.
Configure~/.config/prr/config.toml (chmod 600 — it holds a token):
[prr]token = "<YOUR_GITHUB_TOKEN>" # a GitHub token with 'repo' scope (see below)workdir = "~/.cache/prr" # any writable dir; where review files live
Token options (pick one, discuss with the user):
Reuse the gh CLI token: gh auth token prints it; it typically has repo scope, which is what
prr needs to post reviews. Convenient, but if gh rotates it, prr breaks — then regenerate.
A dedicated fine-grained/classic Personal Access Token with repo (or public_repo) scope.
More stable; recommended if this is a shared/long-lived setup.
⚠️ The token is a secret. Never commit config.toml; keep it chmod 600. It is redacted here.
Commands:prr get owner/repo/<n> --open → annotate in $EDITOR → prr submit owner/repo/<n>.
prr edit reopens; prr apply checks a PR out into the working dir; prr remove deletes a local review.
The review-file format (the non-obvious part)
This is where people get stuck — the commands are easy, the file format is unfamiliar. The model is
email/mailing-list: the entire PR diff is quoted with > , and anything you type that isn’t
quoted becomes a review comment.
Three things you can write:
Inline comment — unquoted text on a new line immediately after the quoted diff line you’re
commenting on. It posts as a line comment anchored there:
> - {> - id: 'ram',> - cooldownMs: 3000,> - },Was removing `ram` intentional? It's still referenced in the intel tree.> {> id: 'penetrating-laser',
PR-level comment — unquoted text at the very top, before the first > diff --git line.
Only one allowed per review.
Verdict directive — a standalone line anywhere: @prr approve, @prr reject
(= request changes), or @prr comment (comment-only).
Rules and gotchas:
Don’t edit the > lines — they’re the diff; only add unquoted lines between them.
The comment must directly follow the line it refers to; that’s how prr computes the anchor.
[...] on its own line elides a chunk of quoted diff you don’t care about.
You cannot approve your own PR — GitHub rejects self-approval with a 422, so @prr approve
fails when testing on your own PRs. Use @prr comment.
Nothing is sent until prr submit, so it’s safe to open, scribble, and walk away.
Docs (the docs home page is only a short intro — the detail is in these chapters):
Without it a review file is undifferentiated plain text — the quoted diff and your own comments look
identical, and a real review is thousands of lines (one measured example: 3,139 lines across 42
files). The prr repo ships a plugin under its vim/ directory giving syntax colouring, filetype
detection and folding (level 1 = per file, level 2 = per hunk), so zM collapses a huge review
to a file list. Your comment lines are then the only unhighlighted text, which makes “where do I
type” obvious.
Gotcha: the plugin is in the repo’s vim/subdirectory, so a plugin manager that adds the
repo root to runtimepath won’t find ftdetect/ftplugin/syntax. Register the filetype yourself and
append the subdirectory. lazy.nvim spec (lua/plugins/prr.lua):
return { "danobi/prr", lazy = false, -- three tiny vim files; must be on the rtp before any .prr file is opened init = function() vim.filetype.add({ extension = { prr = "prr" } }) end, config = function(plugin) vim.opt.runtimepath:append(plugin.dir .. "/vim") -- open reviews collapsed to the file list; delete if you prefer fully expanded vim.api.nvim_create_autocmd("FileType", { pattern = "prr", group = vim.api.nvim_create_augroup("prr_fold", { clear = true }), callback = function() vim.opt_local.foldlevel = 0 end, }) end,}
Alternatives to Module B:gh pr review <n> --approve|--comment|--request-changes (top-level
only, no per-line comments); octo.nvim (full review UI in Neovim); the GitHub web UI.
Module C — In-editor PR review (Neovim: gitsigns + diffview)
Turns a checked-out PR worktree into a navigable review: as you open any file the normal way, the
gutter marks exactly the lines this PR changes relative to its target branch (merge-base of
origin/<base>...HEAD, i.e. GitHub “Files changed” semantics).
Three keymaps:
<leader>gp — diffview panel (changed files + side-by-side). Diff a single rev, not a
base...HEAD range: a range diffs two historical commits so both panes are read-only, whereas
a single rev puts the working tree on the right — so you can fix a mistake and :w without
leaving the diff. (Verified: range → 0 editable panes; single rev → the real file is editable.)
<leader>gq — dump every changed hunk in the repo into the quickfix list. This is the
“just browse the codebase and see where the changes are” answer: jump between hunks in the real
files, with gutters on, editing normally — no diff view involved.
<leader>gP — turn review mode off (gutters back to uncommitted-vs-HEAD).
<leader>gw — toggle inline diff: deleted/old lines rendered inline plus word-level
highlighting, so a file reads as a diff while staying editable. Good for reading a whole file.
The gitsigns baseline persists after you close diffview, so once review mode is on you can browse
and edit the whole codebase with PR gutters showing.
Reading an individual change: a gutter sign tells you where but not what. gitsigns already
covers this and LazyVim binds it buffer-locally (so it won’t show up in a global keymap dump —
check nvim_buf_get_keymap inside an attached buffer):
<leader>ghp = preview hunk inline (expands the old lines in place — the main one),
]h/[h next/prev hunk, ]H/[H first/last, <leader>ghd/<leader>ghD diff this file,
<leader>ghb/<leader>ghB blame. If a distro doesn’t provide these, map
require("gitsigns").preview_hunk_inline (or preview_hunk for a float) yourself.
How it works:prco writes the PR’s base branch into the worktree’s private gitdir
($gitdir/pr-review-base). On nvim startup in that worktree, an autocmd reads it and calls
gitsigns’ change_base. Because each PR gets its own throwaway nvim (dedicated tmux session), the
global base change is naturally scoped and never affects your main editor.
Depends on: Neovim; gitsigns.nvim (must expose change_base — mainline does);
diffview.nvim (sindrets/diffview.nvim); a plugin manager. Author used LazyVim + lazy.nvim;
the files below are lazy.nvim plugin specs. If the user uses a different manager/distro, adapt the
spec wrapper — the logic in pr_review.lua is manager-agnostic.
File 1 — the logic:~/.config/nvim/lua/util/pr_review.lua
-- PR review helpers. See lua/plugins/pr-review.lua for the wiring.---- Points gitsigns' diff baseline at the PR's target branch (the merge-base of-- origin/<base>...HEAD — GitHub "Files changed" semantics) so the whole codebase reads normally-- but every gutter marks exactly the PR's changed lines.---- Entry points:-- <leader>gp panel — diffview against the merge-base. Diffing a SINGLE rev (not-- `base...HEAD`) makes the right-hand side the WORKING TREE, so you can-- edit and :w fixes straight from the diff. A `base...HEAD` range would-- be two historical blobs = read-only.-- <leader>gq hunks — every changed hunk in the repo -> quickfix, for normal browsing/editing.-- <leader>gP off — reset the baseline back to normal (uncommitted-vs-HEAD).local M = {}function M.absolute_git_dir() local out = vim.fn.systemlist({ "git", "rev-parse", "--absolute-git-dir" }) if vim.v.shell_error ~= 0 or not out[1] or out[1] == "" then return nil end return out[1]end-- Base ref recorded by prco, e.g. "origin/main". nil when not a prco worktree.function M.marker_base() local gd = M.absolute_git_dir() if not gd then return nil end local f = gd .. "/pr-review-base" if vim.fn.filereadable(f) == 0 then return nil end local line = (vim.fn.readfile(f) or {})[1] if line and line ~= "" then return vim.trim(line) end return nilend-- Live fallback: ask gh for the current branch's PR base (network).function M.live_base() local out = vim.fn.systemlist({ "gh", "pr", "view", "--json", "baseRefName", "-q", ".baseRefName" }) if vim.v.shell_error ~= 0 or not out[1] or out[1] == "" then return nil end return "origin/" .. vim.trim(out[1])endfunction M.ensure_ref(base_ref) vim.fn.system({ "git", "rev-parse", "--verify", "--quiet", base_ref }) if vim.v.shell_error ~= 0 then vim.fn.system({ "git", "fetch", "origin", (base_ref:gsub("^origin/", "")) }) endend-- The fork point: what the PR actually branched from.function M.merge_base(base_ref) local mb = vim.fn.systemlist({ "git", "merge-base", base_ref, "HEAD" }) if vim.v.shell_error == 0 and mb[1] and mb[1] ~= "" then return vim.trim(mb[1]) end return base_refendlocal function gitsigns() -- gitsigns is lazy-loaded on file events; force it so change_base has actually run setup. pcall(function() require("lazy").load({ plugins = { "gitsigns.nvim" } }) end) local ok, gs = pcall(require, "gitsigns") return ok and gs or nilend-- The active review revision (merge-base sha), or nil when review mode is off.M._rev = nillocal AUG = vim.api.nvim_create_augroup("pr_review_base", { clear = true })-- gitsigns' global change_base only updates buffers that are ALREADY attached — a buffer that-- attaches later keeps bcache.base = nil and diffs against HEAD (no gutter signs). Since review-- mode is enabled before you browse, that's every file you open. So re-apply per buffer on attach.-- gitsigns attaches asynchronously, hence the short retry.function M.apply(bufnr, tries) if not M._rev then return end tries = tries or 8 if not vim.api.nvim_buf_is_valid(bufnr) or vim.bo[bufnr].buftype ~= "" then return end if vim.b[bufnr].pr_review_based == M._rev then return end local gs = gitsigns() if not gs then return end vim.api.nvim_buf_call(bufnr, function() pcall(gs.change_base, M._rev, false) end) if gs.get_hunks(bufnr) ~= nil then vim.b[bufnr].pr_review_based = M._rev elseif tries > 1 then vim.defer_fn(function() M.apply(bufnr, tries - 1) end, 60) endendfunction M.set_base(base_ref, notify) if not base_ref then return false end local gs = gitsigns() if not gs then return false end M._rev = M.merge_base(base_ref) pcall(gs.change_base, M._rev, true) -- global default + already-attached buffers -- Catch every buffer opened from here on (quickfix jumps, telescope, neo-tree, …). vim.api.nvim_clear_autocmds({ group = AUG }) vim.api.nvim_create_autocmd({ "BufReadPost", "BufWinEnter" }, { group = AUG, callback = function(ev) vim.schedule(function() M.apply(ev.buf) end) end, }) -- …and any already loaded. for _, b in ipairs(vim.api.nvim_list_bufs()) do if vim.api.nvim_buf_is_loaded(b) then M.apply(b) end end if notify then vim.notify("PR review ON — gutters vs " .. base_ref, vim.log.levels.INFO, { title = "PR review" }) end return trueend-- Resolve the base (marker first, then live gh) and turn review mode on. Returns the base ref.function M.activate(notify) local base = M.marker_base() or M.live_base() if not base then vim.notify("PR review: couldn't determine the PR base branch", vim.log.levels.WARN, { title = "PR review" }) return nil end M.ensure_ref(base) M.set_base(base, notify) return baseend-- Auto-activate from the marker (offline, managed prco worktrees only). No-op elsewhere.function M.auto() local base = M.marker_base() if base then M.set_base(base, true) endend-- <leader>gp — diffview against the merge-base; right-hand side is the working tree (EDITABLE).function M.open_panel() local base = M.activate(false) if not base then return end vim.cmd("DiffviewOpen " .. M.merge_base(base))end-- <leader>gq — every changed hunk in the repo into the quickfix list, so you can browse and edit-- the real files normally (gutters stay on) instead of sitting inside a diff.function M.changed_hunks() local base = M.activate(false) if not base then return end local gs = gitsigns() if not gs then return end -- 'all' scans the whole repo against the current base. pcall(gs.setqflist, "all", { open = true })end-- ── prr review file, opened as a normal buffer in THIS nvim ──────────────────────────────────-- (`prreview` in the shell spawns a second $EDITOR; in a PR worktree you're already in nvim, so-- just open the .prr file here alongside the code you're reviewing.)local function sh(cmd) local out = vim.fn.systemlist(cmd) if vim.v.shell_error ~= 0 or not out[1] or out[1] == "" then return nil end return vim.trim(out[1])end-- owner/repo and the PR number for the branch checked out here.function M.pr_target() local slug = sh({ "gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner" }) local num = sh({ "gh", "pr", "view", "--json", "number", "-q", ".number" }) if not slug or not num then return nil end return slug, numend-- Where prr keeps review files, from ~/.config/prr/config.toml.function M.prr_workdir() local cfg = vim.fn.expand("~/.config/prr/config.toml") if vim.fn.filereadable(cfg) == 0 then return nil end for _, line in ipairs(vim.fn.readfile(cfg)) do local w = line:match('^%s*workdir%s*=%s*"([^"]+)"') if w then return vim.fn.expand(w) end end return nilend-- <leader>gv — open (downloading first if needed) this PR's prr review file in the current nvim.function M.review() local slug, num = M.pr_target() if not slug then vim.notify("prr: no PR found for this branch", vim.log.levels.WARN, { title = "PR review" }) return end local wd = M.prr_workdir() if not wd then vim.notify("prr: no workdir in ~/.config/prr/config.toml", vim.log.levels.ERROR, { title = "PR review" }) return end local path = ("%s/%s/%s.prr"):format(wd, slug, num) if vim.fn.filereadable(path) == 0 then vim.fn.system({ "prr", "get", ("%s/%s"):format(slug, num) }) if vim.fn.filereadable(path) == 0 then vim.notify("prr get failed for " .. slug .. "/" .. num, vim.log.levels.ERROR, { title = "PR review" }) return end end vim.cmd("edit " .. vim.fn.fnameescape(path)) vim.notify(("Review %s#%s — comment on UNquoted lines, then <leader>gV to submit"):format(slug, num), vim.log.levels.INFO, { title = "PR review" })end-- <leader>gV — submit the review. Posts to GitHub, so confirm first.function M.submit() local slug, num = M.pr_target() if not slug then vim.notify("prr: no PR found for this branch", vim.log.levels.WARN, { title = "PR review" }) return end if vim.bo.modified and vim.api.nvim_buf_get_name(0):match("%.prr$") then vim.cmd("write") end if vim.fn.confirm(("Submit prr review to GitHub for %s#%s?"):format(slug, num), "&No\n&Yes", 1) ~= 2 then vim.notify("Submit cancelled", vim.log.levels.INFO, { title = "PR review" }) return end local out = vim.fn.systemlist({ "prr", "submit", ("%s/%s"):format(slug, num) }) if vim.v.shell_error ~= 0 then vim.notify("prr submit failed:\n" .. table.concat(out, "\n"), vim.log.levels.ERROR, { title = "PR review" }) else vim.notify(("Submitted review for %s#%s"):format(slug, num), vim.log.levels.INFO, { title = "PR review" }) endend-- <leader>gw — read the whole file as a diff without pressing anything per-hunk:-- deleted/old lines shown inline, plus word-level highlighting of what changed within a line.-- (Per-hunk on demand is LazyVim's <leader>ghp = preview_hunk_inline.)M._inline = falsefunction M.toggle_inline() local gs = gitsigns() if not gs then return end pcall(gs.toggle_deleted) pcall(gs.toggle_word_diff) M._inline = not M._inline vim.notify("Inline diff " .. (M._inline and "ON — deleted lines + word diff" or "OFF"), vim.log.levels.INFO, { title = "PR review" })end-- <leader>gP — back to normal (uncommitted-vs-HEAD gutters).function M.off() local gs = gitsigns() if not gs then return end M._rev = nil vim.api.nvim_clear_autocmds({ group = AUG }) pcall(gs.change_base, nil, true) for _, b in ipairs(vim.api.nvim_list_bufs()) do if vim.api.nvim_buf_is_loaded(b) and vim.bo[b].buftype == "" then vim.api.nvim_buf_call(b, function() pcall(gs.change_base, nil, false) end) vim.b[b].pr_review_based = nil end end vim.notify("PR review OFF — gutters back to normal", vim.log.levels.INFO, { title = "PR review" })endreturn M
File 2 — the plugin spec / wiring:~/.config/nvim/lua/plugins/pr-review.lua
The big one — gitsigns’ global change_base does NOT apply to buffers that attach later.change_base(rev, true) sets the global default and refreshes already-attached buffers, but a
buffer opened afterwards keeps bcache.base = nil and silently diffs against HEAD → no gutter
signs. Because review mode is switched on before you start browsing, that’s every file you
open — the feature appears to do nothing. The module works around it by re-applying
change_base(rev, false) per buffer from a BufReadPost/BufWinEnter autocmd (with a short
retry, since gitsigns attaches asynchronously). Measured in a real repo: without the workaround
a modified file reported 0 hunks; with it, 19. Don’t remove M.apply/the autocmd.
<leader>gp is the conventional “git PR” key in LazyVim’s octo/gh extras. If the user has
either extra enabled, pick a different key to avoid a clash.
require("util.pr_review") assumes the logic file is at lua/util/pr_review.lua on the nvim
runtimepath (standard for LazyVim). Adjust the module path if their layout differs.
The User VeryLazy autocmd + require("lazy")...load calls are lazy.nvim-specific. On another
manager, trigger M.auto() from a VimEnter/BufReadPre autocmd and drop the lazy.load line
(ensure gitsigns is loaded some other way).
To leave review mode manually: :Gitsigns change_base (reset). In this workflow you usually just
close the ephemeral PR-worktree nvim.
Alternatives to Module C:octo.nvim (browse/comment/approve PRs in-editor);
:DiffviewOpen <base>...HEAD by hand without the auto-marker; VS Code + GitLens; JetBrains.
Module D — GitHub popup (prefix + g): PRs, issues, new issue
A fuzzy GitHub menu you can summon from anywhere without opening another TUI. Top level is
Pull requests · Issues · New issue; each branch is a small function around gh.
Depends on:tmux ≥ 3.2 (display-popup), fzf, gh, and Module A for the PR actions.
Division of labour: this popup handles the conversation and administrative side (read the
description and comments, comment, approve, merge, close, create). Reading a PR’s code and
writing line-level review comments happens in nvim after checking out (Module C). Keeping
those separate is what stopped the menu sprawling.
Two distinctions the menus deliberately make explicit, because they confuse people:
Comment on PR (a plain conversation comment, gh pr comment) vs. review comments
(line-anchored, part of an approve/request-changes verdict — Module B via <leader>gv). The
cheatsheet panel for each points at the other.
View (PRs and issues) is read-only and returns to the action menu when you quit the
pager, so the natural loop is read the discussion → decide → act. That’s why both action menus
sit in a while loop with continue for view and break for everything else. A preview pane
is fine for glancing; it’s the wrong place to read a long thread.
Two functions serve both PRs and issues — gh pr and gh issue take identical flags for
these, so don’t fork them or the two flows will drift apart:
view_item <kind> <n> (paged gh <kind> view --comments) and
comment_on <kind> <n> (edit → confirm → gh <kind> comment --body-file).
Extending it: the shape is deliberately boring — one fzf menu → one function → one gh call.
Adding gh release, gh run, gh api for notifications, “PRs awaiting my review”, or a
multi-repo picker is a new entry in the top-level menu plus a function. Do that rather than
cramming more into the existing branches.
Design notes worth keeping
Preview strings must be shell-agnostic. fzf runs them via $SHELL -c; on a fish machine
bash syntax dies at runtime. Both the cheatsheet and the issue detail are rendered by
re-invoking the script itself (pr-popup --cheat <key> / pr-popup --issue <n>), so the
preview string is a plain command and all real logic stays in bash. Do the same for anything
you add.
GH_FORCE_TTY makes gh render properly instead of dumping key: value when piped, and
wraps to the pane when given $FZF_PREVIEW_COLUMNS.
Own the editor step. Don’t use gh ... --editor or gh’s interactive body prompt; open
$EDITOR on a temp file and pass --body-file. Predictable, and you can show the text back
and confirm before posting. gh issue comment and gh pr comment take identical flags, so one
comment_on <kind> <n> serves both — don’t fork it, or issues and PRs will drift apart.
Confirm outward-facing actions. Merge, close (PR and issue), comment, and create all take
an explicit y/N. An fzf pick is too easy to fat-finger for something irreversible. Approve is
deliberately one-step (reversible, and wanted to be quick).
Never let a failure kill the popup silently. With set -e the popup tears down before the
error can be read; the run() wrapper pauses instead.
Script — ~/bin/pr-popup
#!/usr/bin/env bash# pr-popup — fzf-driven GitHub manager for a tmux display-popup (bound to prefix+g).## Top level: Pull requests · Issues · New issue# Pull requests → browse, then View / Review (worktree) / Comment / Approve / Merge… / Close…# Issues → browse + search, then View / Comment / Close…# New issue → title prompt → body in nvim → confirm## Runs inside the popup with cwd = the pane's repo (display-popup -d #{pane_current_path}).# Reading a PR and writing review comments happens in nvim once it's checked out# (<leader>gq / gv / gp) — this menu is for acting on things. `?` toggles the preview pane.set -eu# The popup inherits tmux's server env, which may lack these dirs — make sure gh, delta,# fzf, and the pr* scripts all resolve.export PATH="$HOME/bin:/opt/homebrew/bin:$PATH"FZF=$(command -v fzf 2>/dev/null || echo /opt/homebrew/bin/fzf)pause() { printf '\n'; read -r -p "Press Enter to close… " _ || true; }# Run an action, but never let a failure silently kill the popup — without this, `set -e`# tears the popup down before you can read the error.run() { if ! "$@"; then rc=$? printf '\n\033[31m✖ %s failed (exit %s)\033[0m\n' "$1" "$rc" >&2 pause exit 1 fi}# Outward-facing / awkward-to-undo actions shouldn't fire on an fzf pick alone.confirm() { local ans printf '\n%s [y/N] ' "$1" read -r ans || return 1 case "$ans" in [yY] | [yY][eE][sS]) return 0 ;; *) return 1 ;; esac}# ── shared: composing a comment ──────────────────────────────────────────────────────────────# Open $EDITOR on a scratch file and hand the result to gh as --body-file, rather than letting# gh launch the editor itself (`--editor` / its interactive body prompt). gh's own editor path# errored inside the popup; owning it is predictable and lets you confirm before posting.edit_body() { # $1 = file to edit; returns 0 if it ended up non-empty "${EDITOR:-nvim}" "$1" || true grep -q '[^[:space:]]' "$1" 2>/dev/null}# `gh issue comment` and `gh pr comment` take the same flags, so one function serves both.comment_on() { # $1 = issue|pr, $2 = number local kind="$1" n="$2" dir file dir=$(mktemp -d); file="$dir/comment.md"; : > "$file" printf 'Opening %s for your comment on %s #%s (save & quit when done)…\n' "${EDITOR:-nvim}" "$kind" "$n" if ! edit_body "$file"; then printf '\nEmpty comment — nothing posted.\n'; rm -rf "$dir"; pause; return 0 fi printf '\n\033[2m--- your comment ---\033[0m\n'; cat "$file"; printf '\033[2m--------------------\033[0m\n' if ! confirm "Post this comment on $kind #$n?"; then printf '\nCancelled.\n'; rm -rf "$dir"; pause; return 0 fi if gh "$kind" comment "$n" --body-file "$file"; then printf '\nCommented on #%s.\n' "$n" else printf '\n\033[31m✖ comment failed\033[0m\n' >&2 fi rm -rf "$dir"; pause}# The description + full comment chain, rendered and paged. Read-only. `gh pr view` and# `gh issue view` both take --comments, so one function serves both.view_item() { # $1 = pr|issue, $2 = number local kind="$1" n="$2" w w=$(tput cols 2>/dev/null || echo 100) GH_FORCE_TTY="$w" gh "$kind" view "$n" --comments | ${PAGER:-less} -R}# ── pull requests ────────────────────────────────────────────────────────────────────────────# What (if anything) is there to clean up locally for this PR? `prclean` only manages worktrees# prco created — if prco reused one of YOUR worktrees, prclean is deliberately a no-op, so# suggesting it would be misleading.cleanup_hint() { local n="$1" main name managed head existing main=$(git worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2; exit}') [ -n "${main:-}" ] || return 0 name=$(basename "$main") managed="$HOME/.local/share/pr-worktrees/$name/pr-$n" if [ -d "$managed" ]; then printf 'Local cleanup: prclean %s\n' "$n" return 0 fi head=$(gh pr view "$n" --json headRefName -q .headRefName 2>/dev/null || true) if [ -n "${head:-}" ]; then existing=$(git -C "$main" worktree list --porcelain 2>/dev/null \ | awk -v b="refs/heads/$head" '/^worktree /{p=$2} $1=="branch" && $2==b {print p; exit}') if [ -n "${existing:-}" ]; then printf 'Your worktree is untouched: %s\n' "$existing" printf ' branch %s — remove it yourself when done (prclean only manages its own).\n' "$head" return 0 fi fi printf 'No local checkout for #%s.\n' "$n"}merge_pr() { local n="$1" method label local flags method=$(printf '%s\t%s\n' \ squash "Squash and merge" \ merge "Create a merge commit" \ rebase "Rebase and merge" \ auto "Auto-merge when checks pass (squash)" \ | "$FZF" --delimiter='\t' --with-nth=2 --prompt="Merge #$n how? ❯ " \ | cut -f1) || return 0 [ -n "${method:-}" ] || return 0 case "$method" in squash) flags=(--squash); label="squash and merge" ;; merge) flags=(--merge); label="merge commit" ;; rebase) flags=(--rebase); label="rebase and merge" ;; auto) flags=(--squash --auto); label="auto-merge when checks pass (squash)" ;; *) return 0 ;; esac if ! confirm "Merge #$n via $label?"; then printf '\nCancelled.\n'; pause; return 0; fi # Deliberately no --delete-branch: with a worktree-per-branch workflow the local branch is # usually checked out somewhere, which makes the delete fail after the merge has landed. if gh pr merge "$n" "${flags[@]}"; then printf '\nMerged #%s (%s).\n' "$n" "$label" cleanup_hint "$n" else printf '\n\033[31m✖ merge failed\033[0m\n' >&2 fi pause}close_pr() { local n="$1" if ! confirm "Close #$n WITHOUT merging?"; then printf '\nCancelled.\n'; pause; return 0; fi if gh pr close "$n"; then printf '\nClosed #%s.\n' "$n" cleanup_hint "$n" else printf '\n\033[31m✖ close failed\033[0m\n' >&2 fi pause}pr_flow() { local list pr action list=$(gh pr list --limit 50 --json number,title,author,headRefName \ --jq '.[] | "\(.number)\t\(.title)\t@\(.author.login)\t\(.headRefName)"' 2>/dev/null || true) if [ -z "$list" ]; then echo "No open PRs in $slug."; pause; return 0 fi # '?' toggles a delta diff preview (hidden by default to stay snappy — it's a network call). pr=$(printf '%s\n' "$list" \ | "$FZF" --delimiter='\t' --with-nth=1,2,3 \ --prompt="PR ❯ " --header="$slug — open PRs (? = diff preview)" \ --preview='gh pr diff {1} | delta' \ --preview-window='right,60%,hidden,wrap' \ --bind='?:toggle-preview' \ | cut -f1) || return 0 [ -n "${pr:-}" ] || return 0 # Loop so "View PR" returns here — read the conversation, then decide what to do about it. while true; do action=$(printf '%s\t%s\n' \ view "View PR — description + comments" \ review "Review PR — worktree + tmux session" \ comment "Comment on PR (not a review)" \ approve "Approve PR (no comment)" \ merge "Merge PR…" \ close "Close PR (without merging)…" \ | "$FZF" --delimiter='\t' --with-nth=2 --prompt="Action for #$pr ❯ " \ --header="$slug #$pr (? toggles this panel)" \ --preview="'$0' --cheat {1}" \ --preview-window='right,62%,wrap' \ --bind='?:toggle-preview' \ | cut -f1) || return 0 [ -n "${action:-}" ] || return 0 case "$action" in view) view_item pr "$pr"; continue ;; # back to the menu when you quit the pager review) run prco "$pr"; break ;; # switches the client to the PR session comment) comment_on pr "$pr"; break ;; approve) run gh pr review "$pr" --approve; printf '\nApproved #%s.\n' "$pr"; pause; break ;; merge) merge_pr "$pr"; break ;; close) close_pr "$pr"; break ;; esac done}# ── issues ───────────────────────────────────────────────────────────────────────────────────close_issue() { local n="$1" reason reason=$(printf '%s\t%s\n' \ completed "Close as completed" \ "not planned" "Close as not planned" \ | "$FZF" --delimiter='\t' --with-nth=2 --prompt="Close #$n how? ❯ " \ | cut -f1) || return 0 [ -n "${reason:-}" ] || return 0 if ! confirm "Close issue #$n as \"$reason\"?"; then printf '\nCancelled.\n'; pause; return 0; fi if gh issue close "$n" --reason "$reason"; then printf '\nClosed issue #%s (%s).\n' "$n" "$reason" else printf '\n\033[31m✖ close failed\033[0m\n' >&2 fi pause}issue_flow() { local list issue action list=$(gh issue list --limit 100 --json number,title,author \ --jq '.[] | "\(.number)\t\(.title)\t@\(.author.login)"' 2>/dev/null || true) if [ -z "$list" ]; then echo "No open issues in $slug."; pause; return 0 fi # Type to fuzzy-search titles. '?' toggles the rendered issue detail. issue=$(printf '%s\n' "$list" \ | "$FZF" --delimiter='\t' --with-nth=1,2,3 \ --prompt="Issue ❯ " --header="$slug — open issues (type to search · ? = detail)" \ --preview="'$0' --issue {1}" \ --preview-window='right,60%,hidden,wrap' \ --bind='?:toggle-preview' \ | cut -f1) || return 0 [ -n "${issue:-}" ] || return 0 # Detail is shown by default here, so you decide with the issue in front of you. That's a pane # though — "View issue" pages the whole thread properly and returns here afterwards. while true; do action=$(printf '%s\t%s\n' \ view "View issue — description + comments" \ comment "Comment on this issue" \ close "Close issue…" \ | "$FZF" --delimiter='\t' --with-nth=2 --prompt="Issue #$issue ❯ " \ --header="$slug #$issue (? toggles detail)" \ --preview="'$0' --issue $issue" \ --preview-window='right,62%,wrap' \ --bind='?:toggle-preview' \ | cut -f1) || return 0 [ -n "${action:-}" ] || return 0 case "$action" in view) view_item issue "$issue"; continue ;; # back to the menu when you quit the pager comment) comment_on issue "$issue"; break ;; close) close_issue "$issue"; break ;; esac done}new_issue() { local title dir file printf 'New issue in %s\n\n' "$slug" printf 'Title: ' read -r title || return 0 if [ -z "${title:-}" ]; then printf '\nNo title — cancelled.\n'; pause; return 0; fi dir=$(mktemp -d); file="$dir/issue.md"; : > "$file" printf '\nOpening %s for the body — leave it empty for a title-only issue…\n' "${EDITOR:-nvim}" edit_body "$file" || true printf '\n\033[1mTitle:\033[0m %s\n' "$title" if grep -q '[^[:space:]]' "$file" 2>/dev/null; then printf '\033[2m--- body ---\033[0m\n'; cat "$file"; printf '\033[2m------------\033[0m\n' else printf '\033[2m(no body)\033[0m\n' fi if ! confirm "Create this issue in $slug?"; then printf '\nCancelled — nothing created.\n'; rm -rf "$dir"; pause; return 0 fi if gh issue create --title "$title" --body-file "$file"; then printf '\nIssue created.\n' else printf '\n\033[31m✖ issue creation failed\033[0m\n' >&2 fi rm -rf "$dir"; pause}# ── cheatsheet (rendered into fzf's preview pane) ────────────────────────────────────────────# Abridged here — in the real script each branch is a `cat <<EOF` block using $B/$D/$C/$Y/$R ANSI# vars for bold/dim/cyan/yellow/reset. Keep lines <= 55 columns so they fit the pane. Write these# for YOUR keys and workflow; they are the in-context documentation for the whole thing.# NOTE: the issue *action* menu previews the issue itself, not this cheatsheet — so issue actions# don't need panels here; document them in the `issues` panel instead.cheat() { local B D C Y R B=$'\033[1m'; D=$'\033[2m'; C=$'\033[36m'; Y=$'\033[33m'; R=$'\033[0m' case "${1:-}" in prs) ;; # top level: what the PR actions do; "? previews the diff" issues) ;; # top level: search, detail, View/Comment/Close semantics newissue) ;; # top level: title -> body -> confirm; nothing created until confirmed view) ;; # gh pr view --comments; read-only, paged, returns to the menu review) ;; # what prco does + the nvim keys (,gq ,ghp ]h ,gw ,gp ,gv ,gV ,gP) # + prr syntax (quoted "> " diff, unquoted = comment, @prr verdicts, zM/za/zR) comment) ;; # plain conversation comment vs. line-level review — point at Review PR + ,gv/,gV approve) ;; # gh pr review --approve; runs immediately; self-approval 422 merge) ;; # the four methods + "asks y/N" + why no --delete-branch + prclean close) ;; # gh pr close; asks y/N; reversible; prclean esac}# Preview hooks. IMPORTANT: fzf runs preview/execute strings via `$SHELL -c`, and $SHELL here is# fish — so a preview string must contain NO bash syntax (no `VAR=val cmd` prefix, no# `${VAR:-default}`). Keep them plain command invocations and do the real work back in this# script, where we know we're in bash.if [ "${1:-}" = "--cheat" ]; then cheat "${2:-}"; exit 0; fiif [ "${1:-}" = "--issue" ]; then # GH_FORCE_TTY makes gh render the pretty view instead of a key:value dump when piped. GH_FORCE_TTY="${FZF_PREVIEW_COLUMNS:-80}" exec gh issue view "${2:-}" --commentsfi# Must be inside a GitHub repo.if ! slug=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null); then echo "Not inside a GitHub repository."; pause; exit 0fi# ── top level ────────────────────────────────────────────────────────────────────────────────top=$(printf '%s\t%s\n' \ prs "Pull requests" \ issues "Issues" \ newissue "New issue…" \ | "$FZF" --delimiter='\t' --with-nth=2 --prompt="GitHub ❯ " \ --header="$slug (? toggles this panel)" \ --preview="'$0' --cheat {1}" \ --preview-window='right,62%,wrap' \ --bind='?:toggle-preview' \ | cut -f1) || exit 0[ -n "${top:-}" ] || exit 0case "$top" in prs) pr_flow ;; issues) issue_flow ;; newissue) new_issue ;;esac
chmod +x ~/bin/pr-popup. (The name is historical — it manages issues too. Rename it if you like;
just update the tmux binding.)
tmux binding (add to ~/.tmux.conf; g is unbound by default — check the user’s config):
Reload with your reload binding or tmux source-file ~/.tmux.conf. If your tmux doesn’t expand
$HOME there, use an absolute path. Pick a different key if prefix + g is taken.
Alternatives to Module D: run the script directly as a shell command (no tmux needed); or use
gh-dash for a richer standalone dashboard; or drive it from lazygit custom commands.
Module E — shell convenience (fish)
One abbreviation for listing PRs. Author uses fish:
abbr -a prs "gh pr list"
bash/zsh equivalent:alias prs='gh pr list' in ~/.bashrc / ~/.zshrc.
The pr* scripts themselves need no shell integration — they’re on PATH and callable from any
shell, tmux, or editor.
5. Install order (suggested)
Install all of it unless the user said otherwise (§3). This order builds the foundation first, then
the entry point, so you can demo progress as you go.
Ensure git, gh (gh auth login), fzf, tmux are present. Add ~/bin to PATH.
Module A scripts → chmod +x. Test prdiff <n>, prco <n>, prclean <n> on a repo with an
open PR. Everything else builds on these.
Module D popup + tmux binding — the everyday entry point; prefix + g should now work.
Module C (Neovim) → add both lua files, install diffview.nvim, restart nvim.
Module B (prr) → install + configure token (redacted). The most reasonable one to skip:
it exists purely for inline, line-level review comments. If skipped, drop prreview/prsubmit
from Module A and the <leader>gv/<leader>gV keymaps from Module C.
Module E shell abbr/alias.
Each step is independently verifiable, so check in as you go rather than at the end.
6. Verification quick-checks
prdiff <n> renders a colored diff.
prco <n> on a PR whose branch you don’t have locally → creates
~/.local/share/pr-worktrees/<repo>/pr-<n> + tmux session pr-<n>.
prco <n> on a PR whose branch you do already have checked out → prints “already checked out
at …” and opens a session on that worktree, creating nothing. (This is the case that used to
fail outright.)
In the PR’s worktree, nvim . → <leader>gq lists every changed hunk in the quickfix; jumping to
one shows gutter signs in that file (if the quickfix has entries but the file shows no signs,
the M.apply/attach workaround is missing — see Module C gotchas); <leader>ghp previews the
hunk inline; <leader>gp opens diffview with an editable right-hand pane.
prreview <n> opens a review file; prsubmit <n> posts it (Module B).
prclean <n> refuses if there’s uncommitted/unpushed work; otherwise removes worktree + branch +
session. On a reused worktree it must report “no worktree at …” and leave your worktree intact.
prefix + g opens the popup showing Pull requests / Issues / New issue, each with a
?-toggled panel; a failing action prints the error and waits for Enter rather than closing the
popup instantly (Module D).
PR → View shows the description and comments rendered (not a raw dump), and quitting the
pager returns you to the action menu rather than closing the popup.
PR → Comment posts a plain conversation comment; confirm it lands on the PR’s Conversation
tab, not as a line-level review comment.
Issues: the list is fuzzy-searchable; ? shows the issue rendered (title, state, author,
markdown body, and its comments) — if you instead see a raw key: value dump, GH_FORCE_TTY
isn’t being set; if the body appears but the discussion doesn’t, --comments is missing.
Issue → View pages the whole thread and returns to the action menu on q, same as PR → View.
Issue previews on a fish/non-POSIX $SHELL: if the preview pane shows something like
fish: ${ is not a valid variable, a preview string still contains bash syntax — move it behind
a flag on the script (gotcha 8).
Comment / New issue: your editor opens on a temp file, the text is echoed back, and nothing
posts until you answer y. An empty comment or empty title cancels. If the editor itself throws
autocmd errors, check the user’s own editor config first (gotcha 11).
Nothing destructive fires from a menu pick alone — merge, close (PR and issue), comment and
create all require an explicit y/N.