From 4fc0cdee74449d3444b54d2dd9e7450175f6213a Mon Sep 17 00:00:00 2001 From: Chris Birch Date: Sun, 8 Nov 2026 01:21:13 -0500 Subject: [PATCH 1/4] feat(nvim): show the pull request beside a diff prs opened prs starts nvim's diff with PRS_DIFF_DOCUMENT naming the pull request's markdown document and PRS_DIFF_THREADS naming its review threads. core/pull-request-panel reads them, and does nothing where the first is unset, so every other nvim is untouched. - gr shows the document in a split at the right edge, and hides it. gR does the same in a float. - or gf on a path or path:line mention in the document selects that file in the open diffview through its Lua API, and puts the cursor on the line, on the side the thread there names. Where the cursor is on no file the diff changes, the first backticked one on the line is taken, so a thread's heading or a row of the files table jumps from anywhere on it. - Each review thread shows as virtual text at the end of its line, on the base's side for LEFT and the head's for RIGHT. tests/nvim/pull_request_panel_spec.lua drives it in a headless Neovim against a real git repo and the diffview.nvim lazy installed. It clears the variables git exports to a hook that name the repo being committed, as tests/conftest.py does, so its git and diffview's act on the repo it builds. task test:nvim runs every spec there, and a commit hook runs that task when the nvim config or the specs change. Agent-Name: claude-code-unattended Agent-Session: 6ab7d888-63b1-4910-ab41-c89e8e170c7a Agent-Run: 01a1b9ba-16f0-7b20-87db-b565f27ef2a8 --- .pre-commit-config.yaml | 11 + Taskfile.yml | 8 + configs/common/.config/nvim/init.lua | 2 + .../nvim/lua/core/pull-request-panel.lua | 332 ++++++++++++++++++ tests/README.md | 13 + tests/nvim/pull_request_panel_spec.lua | 311 ++++++++++++++++ 6 files changed, 677 insertions(+) create mode 100644 configs/common/.config/nvim/lua/core/pull-request-panel.lua create mode 100644 tests/nvim/pull_request_panel_spec.lua diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 295292fdb..b195871c1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -423,6 +423,17 @@ repos: files: ^(configs/|shell/) stages: [pre-commit] + # The nvim config's Lua, each spec driven in a headless Neovim. A spec + # loads the plugins lazy installed, which a machine running this config + # has once nvim has started there. + - id: nvim-specs + name: nvim specs (headless Neovim) + entry: task test:nvim + language: system + pass_filenames: false + files: ^(configs/common/\.config/nvim/|tests/nvim/) + stages: [pre-commit] + # Tests a broken run would satisfy: every assertion a negative claim about # an external effect, with nothing pinning how the run ended. A command # that died before reaching the write leaves exactly that state. An diff --git a/Taskfile.yml b/Taskfile.yml index 019efaeda..4c09506ba 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -40,6 +40,14 @@ tasks: cmds: - uv run pytest tests/e2e --docker {{.CLI_ARGS}} + # Each spec drives a clean headless Neovim against the config's own Lua and the + # plugins lazy installed, and exits non-zero on a failure. Every spec runs, so + # one run reports each that failed. + test:nvim: + desc: The nvim config's Lua specs, each in a headless Neovim + cmds: + - status=0; for spec in tests/nvim/*_spec.lua; do nvim --headless -u NONE -l "$spec" || status=1; done; exit $status + test:watch: desc: Re-run the suite on file changes (requires entr) cmds: diff --git a/configs/common/.config/nvim/init.lua b/configs/common/.config/nvim/init.lua index f7d0ce5dc..e4f31e152 100644 --- a/configs/common/.config/nvim/init.lua +++ b/configs/common/.config/nvim/init.lua @@ -19,5 +19,7 @@ if not profiles.is_vscode then require('core.autocmds') -- Custom floating terminal (Space t t). VSCode has its own terminal. require('core.floaterminal') + -- The pull request beside a diff prs opened. Nothing where prs did not start nvim. + require('core.pull-request-panel').setup() require('lsp') end diff --git a/configs/common/.config/nvim/lua/core/pull-request-panel.lua b/configs/common/.config/nvim/lua/core/pull-request-panel.lua new file mode 100644 index 000000000..3dd177456 --- /dev/null +++ b/configs/common/.config/nvim/lua/core/pull-request-panel.lua @@ -0,0 +1,332 @@ +-- The pull request beside its diff, in an nvim that prs started with `d`. +-- +-- prs writes the pull request's markdown document and its review threads to two +-- files, and names them in PRS_DIFF_DOCUMENT and PRS_DIFF_THREADS. Where neither is +-- set, setup() does nothing, so every other nvim is untouched. +-- +-- - gr shows the document in a split at the right edge, and hides it. +-- - gR shows it in a float, and hides it. +-- - or gf on a `path` or `path:line` mention in the document selects that file +-- in the open diffview, through its Lua API, and puts the cursor on the line. +-- Where the cursor is on no file the diff changes, the first backticked one on +-- the line is taken, so a thread's heading or a row of the files table jumps +-- from anywhere on it. +-- - Each review thread shows as virtual text at the end of its line, on the side of +-- the diff its `side` names: LEFT is the base's copy of the file, RIGHT the head's. +-- octo.nvim's reviews.show_virtual_text is the model. +-- +-- The threads file's shape is prs's cmd/prs/diff-threads.schema.json. + +local M = {} + +M.namespace = vim.api.nvim_create_namespace('pull-request-threads') + +local state = { + document = nil, + threads = {}, + title = nil, + buf = -1, + panel = -1, + float = -1, +} + +local function say(message, level) + vim.notify(message, level or vim.log.levels.INFO, { title = 'Pull request' }) +end + +local function read_threads(path) + if not path or path == '' or vim.fn.filereadable(path) == 0 then + return {} + end + local ok, decoded = pcall(vim.json.decode, table.concat(vim.fn.readfile(path), '\n'), { + luanil = { object = true, array = true }, + }) + if not ok or type(decoded) ~= 'table' then + say('Could not read the review threads in ' .. path, vim.log.levels.WARN) + return {} + end + state.title = decoded.title + return decoded.threads or {} +end + +-- The open DiffView: this tab's, or else the first one open. +local function diff_view() + local loaded, lib = pcall(require, 'diffview.lib') + if not loaded then + return nil + end + local view = lib.get_current_view() + if not view then + view = lib.views[1] + end + if view and view.files and view.cur_layout then + return view + end + return nil +end + +local function thread_text(thread) + local first = thread.comments and thread.comments[1] + local who = first and first.author ~= '' and first.author or 'someone' + local said = first and (first.body or ''):match('[^\r\n]+') or '' + local text = ' ' .. who .. ': ' .. said + local replies = (thread.comment_count or #(thread.comments or {})) - 1 + if replies > 0 then + text = text .. (' (+%d)'):format(replies) + end + if thread.resolved then + return { { text .. ' · resolved', 'Comment' } } + end + return { { text, 'DiagnosticVirtualTextInfo' } } +end + +-- Puts each thread on the file the diff shows now, on both sides, and clears what an +-- earlier call put there. The entry's path is the head's, which is the path GitHub +-- gives a thread, so a renamed file's base side matches it too. +function M.decorate() + local view = diff_view() + local entry = view and view.cur_entry + if not entry or not entry.layout then + return + end + for side, symbol in pairs({ LEFT = 'a', RIGHT = 'b' }) do + local window = entry.layout[symbol] + local bufnr = window and window.file and window.file.bufnr + if bufnr and vim.api.nvim_buf_is_valid(bufnr) then + vim.api.nvim_buf_clear_namespace(bufnr, M.namespace, 0, -1) + local count = vim.api.nvim_buf_line_count(bufnr) + for _, thread in ipairs(state.threads) do + if thread.path == entry.path and thread.side == side and type(thread.line) == 'number' and thread.line <= count then + vim.api.nvim_buf_set_extmark(bufnr, M.namespace, thread.line - 1, 0, { + virt_text = thread_text(thread), + virt_text_pos = 'eol', + hl_mode = 'combine', + }) + end + end + end + end +end + +-- Each mention the cursor could mean, best first: the word under it, then each +-- backticked span on the line. A mention is a path with an optional :line. +function M.mentions(text, col) + local found = {} + local function add(token) + token = (token or ''):gsub('^%./', ''):gsub('[.:,;]+$', '') + if token == '' then + return + end + local path, line = token:match('^(.-):(%d+)$') + if not path then + path, line = token:match('^(.-):(%d+):%d+$') + end + table.insert(found, { path = path or token, line = tonumber(line) }) + end + local stop = '[%s`|()%[%]<>"\',;*]' + local first, last = col + 1, col + 1 + while first > 1 and not text:sub(first - 1, first - 1):match(stop) do + first = first - 1 + end + while last <= #text and not text:sub(last, last):match(stop) do + last = last + 1 + end + add(text:sub(first, last - 1)) + for span in text:gmatch('`([^`]+)`') do + add(span) + end + return found +end + +-- The path of the diff's file a mention names: the same path, or else the one file +-- whose path ends in it. +local function resolve(view, mentioned) + local ending + for _, file in view.files:iter() do + if file.path == mentioned then + return file.path + end + if vim.endswith(file.path, '/' .. mentioned) then + if ending then + return nil + end + ending = file.path + end + end + return ending +end + +local function side_of(path, line) + for _, thread in ipairs(state.threads) do + if thread.path == path and thread.line == line then + return thread.side + end + end + return 'RIGHT' +end + +-- An entry's layout holds its files, and the view's current layout holds the windows +-- they are shown in. +local function place_cursor(view, path, line) + local entry = view.cur_entry + if not entry or entry.path ~= path then + return + end + local window = side_of(path, line) == 'LEFT' and view.cur_layout.a or view.cur_layout.b + if not (window and window.id and vim.api.nvim_win_is_valid(window.id)) then + return + end + local count = vim.api.nvim_buf_line_count(vim.api.nvim_win_get_buf(window.id)) + vim.api.nvim_set_current_win(window.id) + vim.api.nvim_win_set_cursor(window.id, { math.min(line, count), 0 }) + vim.cmd('normal! zz') +end + +-- Selects the file the line under the cursor mentions in the open diffview. +function M.jump() + local view = diff_view() + if not view then + say('No diffview is open to jump in.', vim.log.levels.WARN) + return + end + local cursor = vim.api.nvim_win_get_cursor(0) + local mentions = M.mentions(vim.api.nvim_get_current_line(), cursor[2]) + local path, line + for _, mention in ipairs(mentions) do + path = resolve(view, mention.path) + if path then + line = mention.line + break + end + end + if not path then + local named = mentions[1] and mentions[1].path or 'Nothing under the cursor' + say(named .. ' is not a file this diff changes.', vim.log.levels.WARN) + return + end + if vim.api.nvim_win_is_valid(state.float) and vim.api.nvim_get_current_win() == state.float then + vim.api.nvim_win_close(state.float, true) + end + if vim.api.nvim_get_current_tabpage() ~= view.tabpage then + vim.api.nvim_set_current_tabpage(view.tabpage) + end + local async = require('diffview.async') + async.void(function() + async.await(view:set_file_by_path(path, true, true)) + if line then + vim.schedule(function() + place_cursor(view, path, line) + end) + end + end)() +end + +local function document_buffer() + if vim.api.nvim_buf_is_valid(state.buf) then + return state.buf + end + if vim.fn.filereadable(state.document) == 0 then + say(state.document .. ' is gone, so there is no pull request to show.', vim.log.levels.WARN) + return nil + end + local buf = vim.fn.bufadd(state.document) + vim.bo[buf].swapfile = false + vim.fn.bufload(buf) + vim.bo[buf].filetype = 'markdown' + vim.bo[buf].modifiable = false + vim.bo[buf].readonly = true + vim.bo[buf].bufhidden = 'hide' + for _, key in ipairs({ '', 'gf' }) do + vim.keymap.set('n', key, M.jump, { buffer = buf, desc = 'Pull request: select this file in the diff' }) + end + state.buf = buf + return buf +end + +-- A window split off a diff window inherits its diff, scrollbind and folds, which +-- would bind the document to the diff's scrolling. +local function dress(win) + local wo = vim.wo[win][0] + wo.diff = false + wo.scrollbind = false + wo.cursorbind = false + wo.foldenable = false + wo.wrap = true + wo.linebreak = true + wo.number = false + wo.relativenumber = false + wo.signcolumn = 'no' +end + +local function shown_here(win) + return vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_tabpage(win) == vim.api.nvim_get_current_tabpage() +end + +function M.toggle_panel() + if shown_here(state.panel) then + vim.api.nvim_win_close(state.panel, true) + state.panel = -1 + return + end + if vim.api.nvim_win_is_valid(state.panel) then + vim.api.nvim_win_close(state.panel, true) + end + local buf = document_buffer() + if not buf then + return + end + state.panel = vim.api.nvim_open_win(buf, true, { + split = 'right', + win = -1, + width = math.max(40, math.floor(vim.o.columns * 0.4)), + }) + dress(state.panel) + vim.wo[state.panel][0].winfixwidth = true +end + +function M.toggle_float() + if vim.api.nvim_win_is_valid(state.float) then + vim.api.nvim_win_close(state.float, true) + state.float = -1 + return + end + local buf = document_buffer() + if not buf then + return + end + local width = math.floor(vim.o.columns * 0.8) + local height = math.floor(vim.o.lines * 0.8) + state.float = vim.api.nvim_open_win(buf, true, { + relative = 'editor', + width = width, + height = height, + col = math.floor((vim.o.columns - width) / 2), + row = math.floor((vim.o.lines - height) / 2), + border = 'rounded', + title = state.title and (' ' .. state.title .. ' ') or nil, + title_pos = state.title and 'center' or nil, + }) + dress(state.float) +end + +-- Reads the two variables prs sets, and does nothing where the document's is unset. +-- Returns whether it set anything up. +function M.setup() + local document = vim.env.PRS_DIFF_DOCUMENT + if not document or document == '' then + return false + end + state.document = document + state.threads = read_threads(vim.env.PRS_DIFF_THREADS) + vim.keymap.set('n', 'gr', M.toggle_panel, { desc = 'Git: pull request beside the diff' }) + vim.keymap.set('n', 'gR', M.toggle_float, { desc = 'Git: pull request in a float' }) + vim.api.nvim_create_autocmd('User', { + desc = 'Pull request: show its review threads on the diff', + group = vim.api.nvim_create_augroup('pull-request-threads', { clear = true }), + pattern = { 'DiffviewDiffBufWinEnter', 'DiffviewViewEnter' }, + callback = M.decorate, + }) + return true +end + +return M diff --git a/tests/README.md b/tests/README.md index 6579d72ec..56081a5f6 100644 --- a/tests/README.md +++ b/tests/README.md @@ -32,6 +32,19 @@ check that reads what `dotfiles apply` actually put on disk. **Speed:** Fast (~5 seconds) +### nvim Config Specs + +```bash +task test:nvim +``` + +Runs each `tests/nvim/*_spec.lua` in a clean headless Neovim, with no test +framework. A spec prepends the config to `runtimepath`, drives the real API and +exits non-zero on a failure. One that needs a plugin loads the copy lazy +installed, so it runs on a machine where nvim has started with this config. + +**Speed:** Fast (~2 seconds) + ### Installation Tests #### Validate File References diff --git a/tests/nvim/pull_request_panel_spec.lua b/tests/nvim/pull_request_panel_spec.lua new file mode 100644 index 000000000..af983f162 --- /dev/null +++ b/tests/nvim/pull_request_panel_spec.lua @@ -0,0 +1,311 @@ +-- The pull request beside a diff prs opened, driven in a headless Neovim against a +-- real git repo and the diffview.nvim lazy installed. Run it directly: +-- nvim --headless -u NONE -l tests/nvim/pull_request_panel_spec.lua +-- It exits non-zero on a failure, so the commit hook gates on it. + +local repo_root = vim.fn.fnamemodify(debug.getinfo(1, 'S').source:sub(2), ':p:h:h:h') +vim.opt.runtimepath:prepend(repo_root .. '/configs/common/.config/nvim') + +-- print loses its newline once diffview has drawn, so each line is written whole. +local function say(line) + io.stdout:write(line .. '\n') +end + +local diffview = vim.fn.stdpath('data') .. '/lazy/diffview.nvim' +if vim.fn.isdirectory(diffview) == 0 then + say('FAIL diffview.nvim is not at ' .. diffview .. ', where lazy installs it the first time nvim starts') + vim.cmd('cquit') +end +vim.opt.runtimepath:prepend(diffview) +vim.cmd.runtime('plugin/diffview.lua') +vim.g.mapleader = ' ' + +local failures = {} +local function check(name, ok) + if not ok then + table.insert(failures, name) + end + say((ok and 'ok ' or 'FAIL ') .. name) +end + +local said = {} +vim.notify = function(message, _, _) + table.insert(said, message) +end + +local function press(keys) + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(keys, true, false, true), 'x', false) +end + +-- git exports these when it runs a hook, naming the repo being committed. Left set, +-- every git below and every git diffview runs would act on that repo instead of the +-- one built here. tests/conftest.py clears the same list for the Python suite. +for _, name in ipairs({ + 'GIT_DIR', + 'GIT_WORK_TREE', + 'GIT_COMMON_DIR', + 'GIT_INDEX_FILE', + 'GIT_OBJECT_DIRECTORY', + 'GIT_ALTERNATE_OBJECT_DIRECTORIES', + 'GIT_PREFIX', + 'GIT_NAMESPACE', +}) do + vim.env[name] = nil +end + +local tmp = vim.fn.tempname() +vim.fn.mkdir(tmp, 'p') + +-- A branch that changes line 3 of cache/cache.go, adds lines 13 to 15, and adds +-- cache/metrics.go. +local repo = tmp .. '/widget' +vim.fn.mkdir(repo .. '/cache', 'p') +local function git(...) + local out = vim.fn.system(vim.list_extend({ + 'git', + '-C', + repo, + '-c', + 'user.name=spec', + '-c', + 'user.email=spec@example.com', + '-c', + 'commit.gpgsign=false', + '-c', + 'core.hooksPath=/dev/null', + }, { ... })) + assert(vim.v.shell_error == 0, out) +end +local base = { 'package cache', '', 'import "time"' } +for n = 4, 12 do + table.insert(base, '// line ' .. n) +end +local head = vim.list_extend(vim.deepcopy(base), { '// line 13', '// line 14 reads the wall clock', '// line 15' }) +head[3] = 'import "sync"' +git('init', '--quiet', '-b', 'main') +local made = vim.trim(vim.fn.system({ 'git', '-C', repo, 'rev-parse', '--absolute-git-dir' })) +if made ~= vim.fn.resolve(repo) .. '/.git' then + say('FAIL git init made ' .. made .. ' rather than a repo in ' .. repo .. ', so nothing is written to it') + vim.cmd('cquit') +end +vim.fn.writefile(base, repo .. '/cache/cache.go') +git('add', '.') +git('commit', '--quiet', '-m', 'base') +git('checkout', '--quiet', '-b', 'cache-metrics') +vim.fn.writefile(head, repo .. '/cache/cache.go') +vim.fn.writefile({ 'package cache', '', 'var hits int' }, repo .. '/cache/metrics.go') +git('add', '.') +git('commit', '--quiet', '-m', 'head') + +-- The two files prs writes, in the shapes it writes them. +local document = tmp .. '/prs-diff-widget-13.md' +vim.fn.writefile({ + '# Count cache hits', + '', + '`acme/widget` #13 · opened by octo · `cache-metrics` into `main`', + '', + '# Description', + '', + 'The counter lives in metrics.go, and the design is in `docs/design.md`.', + '', + '# Files', + '', + '| File | Change | Added | Removed |', + '| --- | --- | ---: | ---: |', + '| `cache/cache.go` | modified | 4 | 1 |', + '| `cache/metrics.go` | added | 3 | 0 |', + '', + '# Comments', + '', + '## Review threads', + '', + '### `cache/cache.go:14` · unresolved', + '', + '### `cache/cache.go:3` · resolved', + '', + '### `cache/cache.go`, on a line gone from the diff · unresolved, outdated', +}, document) +local function comment(author, body) + return { author = author, body = body, created_at = '2026-11-02T10:14:00Z', url = 'https://github.com/acme/widget/pull/13#discussion_r1' } +end +local threads = tmp .. '/prs-diff-widget-13.threads.json' +vim.fn.writefile({ + vim.json.encode({ + slug = 'acme/widget', + number = 13, + title = 'Count cache hits', + url = 'https://github.com/acme/widget/pull/13', + base = 'origin/main', + head = '1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b', + threads_read = true, + threads = { + { + path = 'cache/cache.go', + line = 14, + side = 'RIGHT', + resolved = false, + outdated = false, + comments = { comment('mona', 'This expiry reads the wall clock.\nTake a clock instead.'), comment('octo', 'Done.') }, + comment_count = 2, + }, + { + path = 'cache/cache.go', + line = 3, + side = 'LEFT', + resolved = true, + outdated = false, + comments = { comment('hubot', 'Why did the time import go?') }, + comment_count = 1, + }, + { + path = 'cache/cache.go', + line = vim.NIL, + side = 'RIGHT', + resolved = false, + outdated = true, + comments = { comment('mona', 'This line moved.') }, + comment_count = 1, + }, + }, + }), +}, threads) + +local panel = require('core.pull-request-panel') + +vim.env.PRS_DIFF_DOCUMENT = nil +check('outside a diff prs opened, setup sets up nothing', panel.setup() == false and vim.fn.maparg(' gr', 'n') == '') + +vim.env.PRS_DIFF_DOCUMENT = document +vim.env.PRS_DIFF_THREADS = threads +check('with PRS_DIFF_DOCUMENT set, setup maps both keys', panel.setup() == true and vim.fn.maparg(' gR', 'n') ~= '') + +vim.cmd.cd(repo) +vim.cmd('DiffviewOpen main...cache-metrics') +local lib = require('diffview.lib') +local function view() + return lib.get_current_view() +end +local function showing(path) + return vim.wait(5000, function() + local v = view() + local entry = v and v.cur_entry + local b = entry and entry.path == path and entry.layout.b + return b and b.file and b.file.bufnr and vim.api.nvim_buf_line_count(b.file.bufnr) > 1 or false + end, 20) +end +check('the diff opens on cache/cache.go', showing('cache/cache.go')) + +local function marks(symbol) + local bufnr = view().cur_entry.layout[symbol].file.bufnr + local found = {} + for _, mark in ipairs(vim.api.nvim_buf_get_extmarks(bufnr, panel.namespace, 0, -1, { details = true })) do + table.insert(found, { row = mark[2], text = mark[4].virt_text[1][1] }) + end + return found +end +vim.wait(2000, function() + return #marks('b') > 0 and #marks('a') > 0 +end, 20) +local right, left = marks('b'), marks('a') +check( + "a RIGHT thread shows on its line of the head's copy, with its reply count", + #right == 1 and right[1].row == 13 and right[1].text == ' mona: This expiry reads the wall clock. (+1)' +) +check( + "a LEFT thread shows on its line of the base's copy, marked resolved", + #left == 1 and left[1].row == 2 and left[1].text == ' hubot: Why did the time import go? · resolved' +) + +local function windows_showing_document() + local found = {} + for _, win in ipairs(vim.api.nvim_tabpage_list_wins(0)) do + if vim.api.nvim_buf_get_name(vim.api.nvim_win_get_buf(win)) == document then + table.insert(found, win) + end + end + return found +end + +press(' gr') +local shown = windows_showing_document() +local win = shown[1] +check( + 'gr shows the document in a split at the right edge, unbound from the diff', + #shown == 1 + and vim.api.nvim_win_get_config(win).relative == '' + and vim.api.nvim_win_get_position(win)[2] + vim.api.nvim_win_get_width(win) == vim.o.columns + and not vim.wo[win].diff + and not vim.wo[win].scrollbind +) +press(' gr') +check('gr again hides it', #windows_showing_document() == 0) + +press(' gR') +shown = windows_showing_document() +check('gR shows the document in a float', #shown == 1 and vim.api.nvim_win_get_config(shown[1]).relative == 'editor') +press(' gR') +check('gR again hides it', #windows_showing_document() == 0) + +-- Puts the cursor in the document on the first line holding text, at its column. +local function on(text) + local document_win = windows_showing_document()[1] + vim.api.nvim_set_current_win(document_win) + for row, line in ipairs(vim.api.nvim_buf_get_lines(0, 0, -1, false)) do + local col = line:find(text, 1, true) + if col then + vim.api.nvim_win_set_cursor(document_win, { row, col - 1 }) + return + end + end + error(text .. ' is not in the document') +end + +local function at(path, symbol, row) + return vim.wait(5000, function() + local entry = view().cur_entry + if entry.path ~= path then + return false + end + local window = view().cur_layout[symbol] + return vim.api.nvim_get_current_win() == window.id and vim.api.nvim_win_get_cursor(window.id)[1] == row + end, 20) +end + +press(' gr') +on('metrics.go, and') +press('') +check('a bare file name under the cursor selects the one file it ends', showing('cache/metrics.go')) + +on('| `cache/cache.go`') +press('') +check('a row of the files table selects its file from the start of the row', showing('cache/cache.go')) + +on('### `cache/cache.go:14`') +press('') +check("a path:line on a RIGHT thread puts the cursor on that line of the head's copy", at('cache/cache.go', 'b', 14)) + +on('### `cache/cache.go:3`') +press('gf') +check("gf on a LEFT thread's path:line puts the cursor on that line of the base's copy", at('cache/cache.go', 'a', 3)) + +said = {} +on('docs/design.md') +press('') +check( + 'a path the diff does not change says so and stays on the file shown', + said[1] == 'docs/design.md is not a file this diff changes.' and view().cur_entry.path == 'cache/cache.go' +) + +press(' gr') +press(' gR') +on('| `cache/metrics.go`') +press('') +check('a jump from the float closes it', showing('cache/metrics.go') and #windows_showing_document() == 0) + +vim.fn.delete(tmp, 'rf') +if #failures > 0 then + say('FAILED: ' .. table.concat(failures, ', ')) + vim.cmd('cquit') +end +say('all pull request panel tests passed') +vim.cmd('qall!') From 2561ac6cff5608b9f18898771232ae2799a4c153 Mon Sep 17 00:00:00 2001 From: Chris Birch Date: Sun, 8 Nov 2026 01:22:35 -0500 Subject: [PATCH 2/4] fix(nvim): leave the repo's session alone in a diff prs opened prs starts nvim in the repo's checkout with only -c arguments, which auto-session reads as a session start. At exit it saved the diff over the repo's session: the next nvim there reopened a diffview:// buffer, and the pull request's document, both gone once that nvim exited. auto-session now stays unloaded where PRS_DIFF_DOCUMENT is set, so the diff neither saves over the session nor restores it around the diff. Agent-Name: claude-code-unattended Agent-Session: 6ab7d888-63b1-4910-ab41-c89e8e170c7a Agent-Run: 01a1b9ba-16f0-7b20-87db-b565f27ef2a8 --- configs/common/.config/nvim/lua/plugins/auto-session.lua | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/configs/common/.config/nvim/lua/plugins/auto-session.lua b/configs/common/.config/nvim/lua/plugins/auto-session.lua index 4fa12c812..6eafaa3f9 100644 --- a/configs/common/.config/nvim/lua/plugins/auto-session.lua +++ b/configs/common/.config/nvim/lua/plugins/auto-session.lua @@ -1,6 +1,12 @@ return { 'rmagatti/auto-session', lazy = false, + -- prs starts nvim in the repo's checkout to show a pull request's diff, with + -- PRS_DIFF_DOCUMENT set. Saving there at exit would replace the repo's session + -- with the diff's buffers, which are gone once nvim exits. + cond = function() + return not vim.env.PRS_DIFF_DOCUMENT + end, opts = { suppressed_dirs = { '~/', '~/Downloads', '~/Documents', '~/Desktop/', '/' }, }, From 568d8cfb66e3c68d662ce2ef2ada8ed5a5ff6486 Mon Sep 17 00:00:00 2001 From: Chris Birch Date: Sun, 8 Nov 2026 01:52:44 -0500 Subject: [PATCH 3/4] fix(nvim): core.profiles knows a diff prs opened, and the panel jumps from any thread line core.profiles reads PRS_DIFF_DOCUMENT and PRS_DIFF_THREADS once and clears them, so an nvim started inside the diff, such as lazygit's editor, is an ordinary one. Its plugin_enabled leaves auto-session out of a prs diff. A cond on the plugin's own spec replaced lazy's default cond, which let auto-session load inside VSCode. The panel takes the file a mention names exactly before any file whose path ends in it, so a root main.go beside cmd/a/main.go and cmd/b/main.go selects itself. A line naming no changed file takes the heading above it, so each line of a thread's comments jumps to the thread's line. A refused jump names only a mention shaped like a path. A threads file that is missing, unreadable or written before prs read the threads now warns that none show on the diff. The spec passes with SKIP where lazy has not installed diffview.nvim, and clears the repository variables git rev-parse --local-env-vars lists. Agent-Name: claude-code-unattended Agent-Session: 6ab7d888-63b1-4910-ab41-c89e8e170c7a Agent-Run: 01a1b9da-2b49-72dc-aa5e-712514e0b4ee --- .pre-commit-config.yaml | 4 +- .../common/.config/nvim/lua/core/profiles.lua | 34 ++- .../nvim/lua/core/pull-request-panel.lua | 107 ++++++--- .../.config/nvim/lua/plugins/auto-session.lua | 6 - tests/README.md | 3 +- tests/nvim/pull_request_panel_spec.lua | 214 +++++++++++------- 6 files changed, 241 insertions(+), 127 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b195871c1..63151b851 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -424,8 +424,8 @@ repos: stages: [pre-commit] # The nvim config's Lua, each spec driven in a headless Neovim. A spec - # loads the plugins lazy installed, which a machine running this config - # has once nvim has started there. + # loads the plugins lazy installed, and passes saying SKIP on a machine + # where nvim has not started yet, so lazy installed none. - id: nvim-specs name: nvim specs (headless Neovim) entry: task test:nvim diff --git a/configs/common/.config/nvim/lua/core/profiles.lua b/configs/common/.config/nvim/lua/core/profiles.lua index 475b099e1..2643bbc41 100644 --- a/configs/common/.config/nvim/lua/core/profiles.lua +++ b/configs/common/.config/nvim/lua/core/profiles.lua @@ -1,5 +1,6 @@ --- Which plugins load, which is now only a question when Neovim is embedded in --- VSCode through vscode-neovim. Everywhere else the whole set loads. +-- Which kind of Neovim this is, and which plugins load in it. Two kinds load +-- less than the whole set: one embedded in VSCode through vscode-neovim, and one +-- prs started to show a pull request's diff. -- -- An allowlist rather than a blocklist, because the spec list is not fully -- enumerable here: colorscheme-manager.lua synthesizes specs at startup by @@ -29,6 +30,32 @@ local vscode_enabled = { ['typos'] = true, } +-- prs starts nvim in a repo's checkout to show one pull request's diff, with +-- PRS_DIFF_DOCUMENT naming the pull request's markdown document and +-- PRS_DIFF_THREADS its review threads. Both are read once and cleared, so an +-- nvim started from this one, such as lazygit's editor, is an ordinary nvim. +local function taken(name) + local value = vim.env[name] + vim.env[name] = nil + if value == nil or value == '' then + return nil + end + return value +end + +local document, threads = taken('PRS_DIFF_DOCUMENT'), taken('PRS_DIFF_THREADS') + +---@type { document: string, threads: string? }? +M.pull_request_diff = document and { document = document, threads = threads } or nil + +-- Each keeps state for the directory nvim started in, which in a pull request's +-- diff is the repo's own checkout. +local pull_request_diff_disabled = { + -- Saving at exit would replace the repo's session with the diff's buffers, + -- which are gone once nvim exits. + ['auto-session'] = true, +} + --- Plugin condition function for lazy.nvim defaults.cond ---@param plugin LazyPlugin ---@return boolean @@ -36,6 +63,9 @@ function M.plugin_enabled(plugin) if M.is_vscode then return vscode_enabled[plugin.name] == true end + if M.pull_request_diff then + return not pull_request_diff_disabled[plugin.name] + end return true end diff --git a/configs/common/.config/nvim/lua/core/pull-request-panel.lua b/configs/common/.config/nvim/lua/core/pull-request-panel.lua index 3dd177456..dc2c17d72 100644 --- a/configs/common/.config/nvim/lua/core/pull-request-panel.lua +++ b/configs/common/.config/nvim/lua/core/pull-request-panel.lua @@ -1,21 +1,26 @@ --- The pull request beside its diff, in an nvim that prs started with `d`. +-- The pull request beside its diff, in an nvim prs started to show a pull +-- request's diff. -- -- prs writes the pull request's markdown document and its review threads to two --- files, and names them in PRS_DIFF_DOCUMENT and PRS_DIFF_THREADS. Where neither is --- set, setup() does nothing, so every other nvim is untouched. +-- files, and names them in PRS_DIFF_DOCUMENT and PRS_DIFF_THREADS, which +-- core.profiles reads. Where PRS_DIFF_DOCUMENT was unset, setup() does nothing, so +-- every other nvim is untouched. -- -- - gr shows the document in a split at the right edge, and hides it. -- - gR shows it in a float, and hides it. -- - or gf on a `path` or `path:line` mention in the document selects that file -- in the open diffview, through its Lua API, and puts the cursor on the line. -- Where the cursor is on no file the diff changes, the first backticked one on --- the line is taken, so a thread's heading or a row of the files table jumps --- from anywhere on it. +-- the line is taken, then the nearest heading above, so a row of the files +-- table, a thread's heading and each line of its comments jump to it. -- - Each review thread shows as virtual text at the end of its line, on the side of -- the diff its `side` names: LEFT is the base's copy of the file, RIGHT the head's. -- octo.nvim's reviews.show_virtual_text is the model. -- --- The threads file's shape is prs's cmd/prs/diff-threads.schema.json. +-- The threads file is JSON. This reads its title, threads_read, and each thread's +-- path, line, side, resolved, comments and comment_count. + +local profiles = require('core.profiles') local M = {} @@ -34,8 +39,15 @@ local function say(message, level) vim.notify(message, level or vim.log.levels.INFO, { title = 'Pull request' }) end +-- A diff with no thread on it reads as a pull request nobody commented on, so each +-- way of ending up with no threads to show says so. local function read_threads(path) - if not path or path == '' or vim.fn.filereadable(path) == 0 then + if not path then + say('prs named no review threads, so none show on the diff.', vim.log.levels.WARN) + return {} + end + if vim.fn.filereadable(path) == 0 then + say(path .. ' is gone, so no review threads show on the diff.', vim.log.levels.WARN) return {} end local ok, decoded = pcall(vim.json.decode, table.concat(vim.fn.readfile(path), '\n'), { @@ -46,6 +58,9 @@ local function read_threads(path) return {} end state.title = decoded.title + if decoded.threads_read == false then + say('prs had not read the review threads when the diff opened, so none show on it.', vim.log.levels.WARN) + end return decoded.threads or {} end @@ -110,7 +125,7 @@ end -- Each mention the cursor could mean, best first: the word under it, then each -- backticked span on the line. A mention is a path with an optional :line. -function M.mentions(text, col) +local function mentions(text, col) local found = {} local function add(token) token = (token or ''):gsub('^%./', ''):gsub('[.:,;]+$', '') @@ -138,22 +153,44 @@ function M.mentions(text, col) return found end --- The path of the diff's file a mention names: the same path, or else the one file --- whose path ends in it. +-- The path of the diff's file a mention names: the same path, wherever diffview +-- lists it, or else the one file whose path ends in it. local function resolve(view, mentioned) - local ending + local endings = {} for _, file in view.files:iter() do if file.path == mentioned then return file.path end if vim.endswith(file.path, '/' .. mentioned) then - if ending then - return nil - end - ending = file.path + table.insert(endings, file.path) + end + end + if #endings == 1 then + return endings[1] + end + return nil +end + +-- The first of the mentions that names a file the diff changes, and its line. +local function first_resolved(view, found) + for _, mention in ipairs(found) do + local path = resolve(view, mention.path) + if path then + return path, mention.line end end - return ending + return nil +end + +-- What a jump that found no file says: the first mention shaped like a path, since +-- the word under the cursor may be any word in a comment. +local function refusal(found) + for _, mention in ipairs(found) do + if mention.path:find('[/.]') then + return mention.path .. ' is not a file this diff changes.' + end + end + return 'No file this diff changes is named on this line.' end local function side_of(path, line) @@ -182,26 +219,28 @@ local function place_cursor(view, path, line) vim.cmd('normal! zz') end --- Selects the file the line under the cursor mentions in the open diffview. +-- Selects the file the line under the cursor mentions in the open diffview. A line +-- naming none takes the nearest heading above it, which for a thread's comments is +-- the thread's own path:line. function M.jump() local view = diff_view() if not view then say('No diffview is open to jump in.', vim.log.levels.WARN) return end - local cursor = vim.api.nvim_win_get_cursor(0) - local mentions = M.mentions(vim.api.nvim_get_current_line(), cursor[2]) - local path, line - for _, mention in ipairs(mentions) do - path = resolve(view, mention.path) - if path then - line = mention.line - break + local row, col = unpack(vim.api.nvim_win_get_cursor(0)) + local here = mentions(vim.api.nvim_get_current_line(), col) + local path, line = first_resolved(view, here) + if not path then + for _, text in ipairs(vim.fn.reverse(vim.api.nvim_buf_get_lines(0, 0, row, false))) do + if text:match('^#+%s') then + path, line = first_resolved(view, mentions(text, 0)) + break + end end end if not path then - local named = mentions[1] and mentions[1].path or 'Nothing under the cursor' - say(named .. ' is not a file this diff changes.', vim.log.levels.WARN) + say(refusal(here), vim.log.levels.WARN) return end if vim.api.nvim_win_is_valid(state.float) and vim.api.nvim_get_current_win() == state.float then @@ -309,15 +348,14 @@ function M.toggle_float() dress(state.float) end --- Reads the two variables prs sets, and does nothing where the document's is unset. --- Returns whether it set anything up. +-- Maps the keys and hangs the threads on the diff, in a pull request's diff alone. function M.setup() - local document = vim.env.PRS_DIFF_DOCUMENT - if not document or document == '' then - return false + local diff = profiles.pull_request_diff + if not diff then + return end - state.document = document - state.threads = read_threads(vim.env.PRS_DIFF_THREADS) + state.document = diff.document + state.threads = read_threads(diff.threads) vim.keymap.set('n', 'gr', M.toggle_panel, { desc = 'Git: pull request beside the diff' }) vim.keymap.set('n', 'gR', M.toggle_float, { desc = 'Git: pull request in a float' }) vim.api.nvim_create_autocmd('User', { @@ -326,7 +364,6 @@ function M.setup() pattern = { 'DiffviewDiffBufWinEnter', 'DiffviewViewEnter' }, callback = M.decorate, }) - return true end return M diff --git a/configs/common/.config/nvim/lua/plugins/auto-session.lua b/configs/common/.config/nvim/lua/plugins/auto-session.lua index 6eafaa3f9..4fa12c812 100644 --- a/configs/common/.config/nvim/lua/plugins/auto-session.lua +++ b/configs/common/.config/nvim/lua/plugins/auto-session.lua @@ -1,12 +1,6 @@ return { 'rmagatti/auto-session', lazy = false, - -- prs starts nvim in the repo's checkout to show a pull request's diff, with - -- PRS_DIFF_DOCUMENT set. Saving there at exit would replace the repo's session - -- with the diff's buffers, which are gone once nvim exits. - cond = function() - return not vim.env.PRS_DIFF_DOCUMENT - end, opts = { suppressed_dirs = { '~/', '~/Downloads', '~/Documents', '~/Desktop/', '/' }, }, diff --git a/tests/README.md b/tests/README.md index 56081a5f6..8333ff288 100644 --- a/tests/README.md +++ b/tests/README.md @@ -41,7 +41,8 @@ task test:nvim Runs each `tests/nvim/*_spec.lua` in a clean headless Neovim, with no test framework. A spec prepends the config to `runtimepath`, drives the real API and exits non-zero on a failure. One that needs a plugin loads the copy lazy -installed, so it runs on a machine where nvim has started with this config. +installed. Where nvim has never started with this config, lazy has installed +nothing, so the spec prints SKIP and passes. **Speed:** Fast (~2 seconds) diff --git a/tests/nvim/pull_request_panel_spec.lua b/tests/nvim/pull_request_panel_spec.lua index af983f162..02f40598e 100644 --- a/tests/nvim/pull_request_panel_spec.lua +++ b/tests/nvim/pull_request_panel_spec.lua @@ -1,7 +1,8 @@ -- The pull request beside a diff prs opened, driven in a headless Neovim against a -- real git repo and the diffview.nvim lazy installed. Run it directly: -- nvim --headless -u NONE -l tests/nvim/pull_request_panel_spec.lua --- It exits non-zero on a failure, so the commit hook gates on it. +-- It exits non-zero on a failure, so the commit hook gates on it. Where lazy has not +-- installed diffview.nvim there is no diff to drive, so it says so and passes. local repo_root = vim.fn.fnamemodify(debug.getinfo(1, 'S').source:sub(2), ':p:h:h:h') vim.opt.runtimepath:prepend(repo_root .. '/configs/common/.config/nvim') @@ -13,8 +14,8 @@ end local diffview = vim.fn.stdpath('data') .. '/lazy/diffview.nvim' if vim.fn.isdirectory(diffview) == 0 then - say('FAIL diffview.nvim is not at ' .. diffview .. ', where lazy installs it the first time nvim starts') - vim.cmd('cquit') + say('SKIP diffview.nvim is not at ' .. diffview .. ', where lazy installs it the first time nvim starts') + vim.cmd('qall!') end vim.opt.runtimepath:prepend(diffview) vim.cmd.runtime('plugin/diffview.lua') @@ -28,38 +29,40 @@ local function check(name, ok) say((ok and 'ok ' or 'FAIL ') .. name) end +-- What the checks stand on. Without it the checks after it say nothing. +local function needs(what, ok) + if not ok then + say('FAIL ' .. what) + vim.cmd('cquit') + end +end + local said = {} -vim.notify = function(message, _, _) - table.insert(said, message) +vim.notify = function(message, level, _) + table.insert(said, { text = message, level = level }) end local function press(keys) vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(keys, true, false, true), 'x', false) end --- git exports these when it runs a hook, naming the repo being committed. Left set, --- every git below and every git diffview runs would act on that repo instead of the --- one built here. tests/conftest.py clears the same list for the Python suite. -for _, name in ipairs({ - 'GIT_DIR', - 'GIT_WORK_TREE', - 'GIT_COMMON_DIR', - 'GIT_INDEX_FILE', - 'GIT_OBJECT_DIRECTORY', - 'GIT_ALTERNATE_OBJECT_DIRECTORIES', - 'GIT_PREFIX', - 'GIT_NAMESPACE', -}) do +-- git exports its repository variables when it runs a hook, naming the repo being +-- committed. Left set, every git below and every git diffview runs would act on +-- that repo rather than the one built here. +for _, name in ipairs(vim.fn.systemlist({ 'git', 'rev-parse', '--local-env-vars' })) do vim.env[name] = nil end local tmp = vim.fn.tempname() vim.fn.mkdir(tmp, 'p') --- A branch that changes line 3 of cache/cache.go, adds lines 13 to 15, and adds --- cache/metrics.go. +-- A branch that changes line 3 of cache/cache.go and adds lines 13 to 15, adds +-- cache/metrics.go, and changes a main.go at the root and one in each of cmd/a and +-- cmd/b. local repo = tmp .. '/widget' -vim.fn.mkdir(repo .. '/cache', 'p') +for _, dir in ipairs({ '/cache', '/cmd/a', '/cmd/b' }) do + vim.fn.mkdir(repo .. dir, 'p') +end local function git(...) local out = vim.fn.system(vim.list_extend({ 'git', @@ -82,22 +85,26 @@ for n = 4, 12 do end local head = vim.list_extend(vim.deepcopy(base), { '// line 13', '// line 14 reads the wall clock', '// line 15' }) head[3] = 'import "sync"' +local mains = { 'main.go', 'cmd/a/main.go', 'cmd/b/main.go' } git('init', '--quiet', '-b', 'main') local made = vim.trim(vim.fn.system({ 'git', '-C', repo, 'rev-parse', '--absolute-git-dir' })) -if made ~= vim.fn.resolve(repo) .. '/.git' then - say('FAIL git init made ' .. made .. ' rather than a repo in ' .. repo .. ', so nothing is written to it') - vim.cmd('cquit') -end +needs('git init made ' .. made .. ' rather than a repo in ' .. repo, made == vim.fn.resolve(repo) .. '/.git') vim.fn.writefile(base, repo .. '/cache/cache.go') +for _, main in ipairs(mains) do + vim.fn.writefile({ 'package main' }, repo .. '/' .. main) +end git('add', '.') git('commit', '--quiet', '-m', 'base') git('checkout', '--quiet', '-b', 'cache-metrics') vim.fn.writefile(head, repo .. '/cache/cache.go') vim.fn.writefile({ 'package cache', '', 'var hits int' }, repo .. '/cache/metrics.go') +for _, main in ipairs(mains) do + vim.fn.writefile({ 'package main', '', 'func main() {}' }, repo .. '/' .. main) +end git('add', '.') git('commit', '--quiet', '-m', 'head') --- The two files prs writes, in the shapes it writes them. +-- A document and a threads file shaped like the ones prs writes. local document = tmp .. '/prs-diff-widget-13.md' vim.fn.writefile({ '# Count cache hits', @@ -114,70 +121,113 @@ vim.fn.writefile({ '| --- | --- | ---: | ---: |', '| `cache/cache.go` | modified | 4 | 1 |', '| `cache/metrics.go` | added | 3 | 0 |', + '| `cmd/a/main.go` | modified | 2 | 0 |', + '| `cmd/b/main.go` | modified | 2 | 0 |', + '| `main.go` | modified | 2 | 0 |', '', '# Comments', '', '## Review threads', '', + '2 of 3 unresolved.', + '', '### `cache/cache.go:14` · unresolved', '', - '### `cache/cache.go:3` · resolved', + '**mona** · 3h ago', + '', + 'This expiry reads the wall clock.', + 'Take a clock instead.', + '', + '**octo** · 2h ago', + '', + 'Done.', '', '### `cache/cache.go`, on a line gone from the diff · unresolved, outdated', + '', + '**mona** · 3h ago', + '', + 'This line moved.', + '', + '### `cache/cache.go:3` · resolved', + '', + '**hubot** · 3h ago', + '', + 'Why did the time import go?', }, document) local function comment(author, body) return { author = author, body = body, created_at = '2026-11-02T10:14:00Z', url = 'https://github.com/acme/widget/pull/13#discussion_r1' } end -local threads = tmp .. '/prs-diff-widget-13.threads.json' -vim.fn.writefile({ - vim.json.encode({ - slug = 'acme/widget', - number = 13, - title = 'Count cache hits', - url = 'https://github.com/acme/widget/pull/13', - base = 'origin/main', - head = '1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b', - threads_read = true, - threads = { - { - path = 'cache/cache.go', - line = 14, - side = 'RIGHT', - resolved = false, - outdated = false, - comments = { comment('mona', 'This expiry reads the wall clock.\nTake a clock instead.'), comment('octo', 'Done.') }, - comment_count = 2, - }, - { - path = 'cache/cache.go', - line = 3, - side = 'LEFT', - resolved = true, - outdated = false, - comments = { comment('hubot', 'Why did the time import go?') }, - comment_count = 1, - }, - { - path = 'cache/cache.go', - line = vim.NIL, - side = 'RIGHT', - resolved = false, - outdated = true, - comments = { comment('mona', 'This line moved.') }, - comment_count = 1, - }, - }, - }), -}, threads) +local function threads_file(name, threads_read, threads) + local path = tmp .. '/' .. name + vim.fn.writefile({ + vim.json.encode({ + slug = 'acme/widget', + number = 13, + title = 'Count cache hits', + url = 'https://github.com/acme/widget/pull/13', + base = 'origin/main', + head = '1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b', + threads_read = threads_read, + threads = threads, + }), + }, path) + return path +end +local threads = threads_file('prs-diff-widget-13.threads.json', true, { + { + path = 'cache/cache.go', + line = 14, + side = 'RIGHT', + resolved = false, + outdated = false, + comments = { comment('mona', 'This expiry reads the wall clock.\nTake a clock instead.'), comment('octo', 'Done.') }, + comment_count = 2, + }, + { + path = 'cache/cache.go', + line = 3, + side = 'LEFT', + resolved = true, + outdated = false, + comments = { comment('hubot', 'Why did the time import go?') }, + comment_count = 1, + }, + { + path = 'cache/cache.go', + line = vim.NIL, + side = 'RIGHT', + resolved = false, + outdated = true, + comments = { comment('mona', 'This line moved.') }, + comment_count = 1, + }, +}) +local threads_unread = threads_file('prs-diff-widget-13.unread.threads.json', false, {}) -local panel = require('core.pull-request-panel') +-- Loads the config's modules afresh, as an nvim started with these variables would. +local function started_with(document_path, threads_path) + vim.env.PRS_DIFF_DOCUMENT = document_path + vim.env.PRS_DIFF_THREADS = threads_path + package.loaded['core.profiles'] = nil + package.loaded['core.pull-request-panel'] = nil + local panel = require('core.pull-request-panel') + panel.setup() + return panel +end -vim.env.PRS_DIFF_DOCUMENT = nil -check('outside a diff prs opened, setup sets up nothing', panel.setup() == false and vim.fn.maparg(' gr', 'n') == '') +started_with(nil, nil) +check('an nvim prs did not start maps no pull request key', vim.fn.maparg(' gr', 'n') == '') -vim.env.PRS_DIFF_DOCUMENT = document -vim.env.PRS_DIFF_THREADS = threads -check('with PRS_DIFF_DOCUMENT set, setup maps both keys', panel.setup() == true and vim.fn.maparg(' gR', 'n') ~= '') +said = {} +started_with(document, threads_unread) +check('a diff opened before prs read the review threads warns that none show', #said == 1 and said[1].level == vim.log.levels.WARN) + +said = {} +local panel = started_with(document, threads) +check( + 'a program the diff nvim starts is an ordinary one, with no PRS_DIFF_DOCUMENT', + vim.trim(vim.fn.system({ 'printenv', 'PRS_DIFF_DOCUMENT' })) == '' +) vim.cmd.cd(repo) vim.cmd('DiffviewOpen main...cache-metrics') @@ -193,7 +243,7 @@ local function showing(path) return b and b.file and b.file.bufnr and vim.api.nvim_buf_line_count(b.file.bufnr) > 1 or false end, 20) end -check('the diff opens on cache/cache.go', showing('cache/cache.go')) +needs('the diff opens on cache/cache.go', showing('cache/cache.go')) local function marks(symbol) local bufnr = view().cur_entry.layout[symbol].file.bufnr @@ -280,9 +330,13 @@ on('| `cache/cache.go`') press('') check('a row of the files table selects its file from the start of the row', showing('cache/cache.go')) -on('### `cache/cache.go:14`') +on('| `main.go`') +press('') +check('a root file selects itself where nested files share its name', showing('main.go')) + +on('Take a clock instead.') press('') -check("a path:line on a RIGHT thread puts the cursor on that line of the head's copy", at('cache/cache.go', 'b', 14)) +check("a RIGHT thread's comment puts the cursor on its heading's line of the head's copy", at('cache/cache.go', 'b', 14)) on('### `cache/cache.go:3`') press('gf') @@ -292,8 +346,8 @@ said = {} on('docs/design.md') press('') check( - 'a path the diff does not change says so and stays on the file shown', - said[1] == 'docs/design.md is not a file this diff changes.' and view().cur_entry.path == 'cache/cache.go' + 'a path the diff does not change is named in a notice, and the diff stays on its file', + #said == 1 and said[1].text:find('docs/design.md', 1, true) ~= nil and view().cur_entry.path == 'cache/cache.go' ) press(' gr') @@ -307,5 +361,3 @@ if #failures > 0 then say('FAILED: ' .. table.concat(failures, ', ')) vim.cmd('cquit') end -say('all pull request panel tests passed') -vim.cmd('qall!') From 3c6c2c7f553dc8c1eaab50aaf3680c343c6ec7f3 Mon Sep 17 00:00:00 2001 From: Chris Birch Date: Sun, 8 Nov 2026 01:54:19 -0500 Subject: [PATCH 4/4] test: clear every repository variable git lists, rather than a copy of eight git exports its repository variables to a hook, and a test that builds a throwaway repo under one acts on the repo being committed. The hand-kept list named eight and missed seven git prints, among them GIT_CONFIG_PARAMETERS and GIT_IMPLICIT_WORK_TREE. git rev-parse --local-env-vars is git's own list. Agent-Name: claude-code-unattended Agent-Session: 6ab7d888-63b1-4910-ab41-c89e8e170c7a Agent-Run: 01a1b9da-2b49-72dc-aa5e-712514e0b4ee Worklog: 1326.1 --- tests/conftest.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index df7e62182..4c9111a74 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -118,17 +118,8 @@ def answerable_names() -> tuple[str, ...]: return tuple(sorted(set(declared_names()) | set(settings.SHARED_PATHS) | twins)) -GIT_LOCATION = ( - 'GIT_DIR', - 'GIT_WORK_TREE', - 'GIT_COMMON_DIR', - 'GIT_INDEX_FILE', - 'GIT_OBJECT_DIRECTORY', - 'GIT_ALTERNATE_OBJECT_DIRECTORIES', - 'GIT_PREFIX', - 'GIT_NAMESPACE', -) -"""The variables that decide *which repository* a `git` command acts on. +GIT_LOCATION = tuple(subprocess.run(['git', 'rev-parse', '--local-env-vars'], capture_output=True, text=True, check=True).stdout.split()) +"""The variables that decide *which repository* a `git` command acts on, as git itself lists them. Dropped for the whole session, because a dozen fixtures build a throwaway repo in `tmp_path` and every one of them would otherwise operate on the caller's.