Skip to content

Commit 16d3d04

Browse files
committed
feat(commands): add :Greview for unified diff review with qflist/loclist
Problem: diffs.nvim provides `:Gdiff` for single-file diffs and `gdiff_section()` for staged/unstaged sections, but has no way to view a full unified diff against an arbitrary git ref with structured navigation. Solution: add `M.greview(base, opts)` which creates a unified diff buffer (`diffs://review:{base}`), parses file/hunk positions, and populates the qflist (files with `+N`/`-M` stats) and loclist (hunks with filename and `@@` header). Add `M.review_file_at_line()` utility, `read_buffer()` review label case, and `:Greview` command with git ref tab completion.
1 parent 28d5277 commit 16d3d04

4 files changed

Lines changed: 330 additions & 0 deletions

File tree

doc/diffs.nvim.txt

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Features: ~
2626
- Optional diff prefix (`+`/`-`/` `) concealment
2727
- Gutter (line number) highlighting
2828
- |:Gdiff| unified diff against any revision
29+
- |:Greview| full-repo review diff with qflist/loclist navigation
2930
- Email quoting/patch syntax support (`> diff ...`)
3031

3132
==============================================================================
@@ -449,6 +450,46 @@ COMMANDS *diffs-commands*
449450
:Ghdiff [revision] *:Ghdiff*
450451
Like |:Gdiff| but explicitly opens in a horizontal split.
451452

453+
:Greview {base} *:Greview*
454+
Open a unified diff of the entire repository against {base} (a git ref
455+
such as `origin/main`, `HEAD~5`, or a commit SHA). Displays in a
456+
horizontal split.
457+
458+
Populates the quickfix list with file entries (one per changed file,
459+
with `+N`/`-M` stats) and the location list with hunk entries (one per
460+
`@@` header, with filename and hunk number). All entries point into the
461+
diff buffer, so |:cnext|/|:cprev| navigate between files and
462+
|:lnext|/|:lprev| navigate between hunks within the buffer.
463+
464+
The buffer is named `diffs://review:{base}`. Reloading with |:edit|
465+
refreshes the diff content.
466+
467+
If a `diffs://` window already exists in the current tabpage, the new
468+
diff replaces its buffer instead of creating another split.
469+
470+
Parameters: ~
471+
{base} (string) Git ref to diff against. Required.
472+
473+
Examples: >vim
474+
:Greview origin/main
475+
:Greview HEAD~10
476+
:Greview abc1234
477+
<
478+
479+
Lua API: >lua
480+
require('diffs.commands').greview('origin/main')
481+
require('diffs.commands').greview('origin/main', { vertical = true })
482+
require('diffs.commands').greview('origin/main', { repo_root = '/path/to/repo' })
483+
<
484+
When called from async contexts (e.g., plugin callbacks where the
485+
current buffer may not be in a git repo), pass `repo_root` explicitly
486+
to avoid repo detection failures.
487+
488+
Utilities: ~
489+
`require('diffs.commands').review_file_at_line(bufnr, lnum)` returns
490+
the filename at a given line in a review buffer by walking backwards
491+
to the nearest `diff --git` header.
492+
452493
==============================================================================
453494
MAPPINGS *diffs-mappings*
454495

lua/diffs/commands.lua

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,160 @@ function M.gdiff_section(repo_root, opts)
362362
end)
363363
end
364364

365+
---@class diffs.GreviewOpts
366+
---@field vertical? boolean
367+
---@field repo_root? string
368+
369+
---@param base string
370+
---@param opts? diffs.GreviewOpts
371+
---@return integer?
372+
function M.greview(base, opts)
373+
opts = opts or {}
374+
375+
if not base or base == '' then
376+
vim.notify('[diffs.nvim]: greview requires a base ref', vim.log.levels.ERROR)
377+
return nil
378+
end
379+
380+
local repo_root = opts.repo_root
381+
if not repo_root then
382+
local bufnr = vim.api.nvim_get_current_buf()
383+
local filepath = vim.api.nvim_buf_get_name(bufnr)
384+
repo_root = git.get_repo_root(filepath ~= '' and filepath or nil)
385+
end
386+
if not repo_root then
387+
local cwd = vim.fn.getcwd()
388+
repo_root = git.get_repo_root(cwd .. '/.')
389+
end
390+
if not repo_root then
391+
vim.notify('[diffs.nvim]: not in a git repository', vim.log.levels.ERROR)
392+
return nil
393+
end
394+
395+
local target_name = 'diffs://review:' .. base
396+
local existing_buf = vim.fn.bufnr(target_name)
397+
if existing_buf ~= -1 then
398+
pcall(vim.api.nvim_buf_delete, existing_buf, { force = true })
399+
end
400+
401+
local cmd = { 'git', '-C', repo_root, 'diff', '--no-ext-diff', '--no-color', base }
402+
local result = vim.fn.systemlist(cmd)
403+
if vim.v.shell_error ~= 0 then
404+
vim.notify('[diffs.nvim]: git diff failed', vim.log.levels.ERROR)
405+
return nil
406+
end
407+
result = replace_combined_diffs(result, repo_root)
408+
if #result == 0 then
409+
vim.notify('[diffs.nvim]: no diff against ' .. base, vim.log.levels.INFO)
410+
return nil
411+
end
412+
413+
local diff_buf = vim.api.nvim_create_buf(false, true)
414+
vim.api.nvim_buf_set_lines(diff_buf, 0, -1, false, result)
415+
vim.api.nvim_set_option_value('buftype', 'nowrite', { buf = diff_buf })
416+
vim.api.nvim_set_option_value('bufhidden', 'delete', { buf = diff_buf })
417+
vim.api.nvim_set_option_value('swapfile', false, { buf = diff_buf })
418+
vim.api.nvim_set_option_value('modifiable', false, { buf = diff_buf })
419+
vim.api.nvim_set_option_value('filetype', 'diff', { buf = diff_buf })
420+
vim.api.nvim_buf_set_name(diff_buf, 'diffs://review:' .. base)
421+
vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root)
422+
423+
local qf_items = {}
424+
local loc_items = {}
425+
local current_file = nil
426+
local file_adds, file_dels = {}, {}
427+
local file_hunk_count = {}
428+
429+
for i, line in ipairs(result) do
430+
local file = line:match('^diff %-%-git a/.+ b/(.+)$')
431+
if file then
432+
current_file = file
433+
file_adds[file] = 0
434+
file_dels[file] = 0
435+
file_hunk_count[file] = 0
436+
table.insert(qf_items, {
437+
bufnr = diff_buf,
438+
lnum = i,
439+
text = file,
440+
})
441+
elseif current_file and line:match('^@@') then
442+
file_hunk_count[current_file] = file_hunk_count[current_file] + 1
443+
local header = line:match('^(@@.-@@)')
444+
table.insert(loc_items, {
445+
bufnr = diff_buf,
446+
lnum = i,
447+
text = ('%s (hunk %d) %s'):format(
448+
current_file,
449+
file_hunk_count[current_file],
450+
header or ''
451+
),
452+
})
453+
elseif current_file then
454+
local ch = line:sub(1, 1)
455+
if ch == '+' and not line:match('^%+%+%+') then
456+
file_adds[current_file] = file_adds[current_file] + 1
457+
elseif ch == '-' and not line:match('^%-%-%-') then
458+
file_dels[current_file] = file_dels[current_file] + 1
459+
end
460+
end
461+
end
462+
463+
for _, item in ipairs(qf_items) do
464+
local file = item.text
465+
local a = file_adds[file] or 0
466+
local d = file_dels[file] or 0
467+
local parts = { file }
468+
if a > 0 then
469+
parts[#parts + 1] = '+' .. a
470+
end
471+
if d > 0 then
472+
parts[#parts + 1] = '-' .. d
473+
end
474+
item.text = table.concat(parts, ' ')
475+
end
476+
477+
vim.fn.setqflist({}, ' ', {
478+
title = 'review: ' .. base,
479+
items = qf_items,
480+
})
481+
vim.fn.setloclist(0, {}, ' ', {
482+
title = 'review hunks: ' .. base,
483+
items = loc_items,
484+
})
485+
486+
local existing_win = M.find_diffs_window()
487+
if existing_win then
488+
vim.api.nvim_set_current_win(existing_win)
489+
vim.api.nvim_win_set_buf(existing_win, diff_buf)
490+
else
491+
vim.cmd(opts.vertical and 'vsplit' or 'split')
492+
vim.api.nvim_win_set_buf(0, diff_buf)
493+
end
494+
495+
M.setup_diff_buf(diff_buf)
496+
dbg('opened review buffer %d against %s', diff_buf, base)
497+
498+
vim.schedule(function()
499+
require('diffs').attach(diff_buf)
500+
end)
501+
502+
return diff_buf
503+
end
504+
505+
---@param buf integer
506+
---@param lnum integer
507+
---@return string?
508+
function M.review_file_at_line(buf, lnum)
509+
local lines = vim.api.nvim_buf_get_lines(buf, 0, lnum, false)
510+
for i = #lines, 1, -1 do
511+
local file = lines[i]:match('^diff %-%-git a/.+ b/(.+)$')
512+
if file then
513+
return file
514+
end
515+
end
516+
return nil
517+
end
518+
365519
---@param bufnr integer
366520
function M.read_buffer(bufnr)
367521
local name = vim.api.nvim_buf_get_name(bufnr)
@@ -392,6 +546,13 @@ function M.read_buffer(bufnr)
392546
diff_lines = {}
393547
end
394548

549+
diff_lines = replace_combined_diffs(diff_lines, repo_root)
550+
elseif label == 'review' then
551+
local cmd = { 'git', '-C', repo_root, 'diff', '--no-ext-diff', '--no-color', path }
552+
diff_lines = vim.fn.systemlist(cmd)
553+
if vim.v.shell_error ~= 0 then
554+
diff_lines = {}
555+
end
395556
diff_lines = replace_combined_diffs(diff_lines, repo_root)
396557
else
397558
local abs_path = repo_root .. '/' .. path
@@ -459,6 +620,32 @@ function M.setup()
459620
nargs = '?',
460621
desc = 'Show unified diff against git revision in horizontal split',
461622
})
623+
624+
vim.api.nvim_create_user_command('Greview', function(opts)
625+
M.greview(opts.args ~= '' and opts.args or nil)
626+
end, {
627+
nargs = 1,
628+
complete = function(arglead)
629+
local refs = vim.fn.systemlist({
630+
'git', 'for-each-ref', '--format=%(refname:short)',
631+
'refs/heads/', 'refs/remotes/', 'refs/tags/',
632+
})
633+
if vim.v.shell_error ~= 0 then
634+
return {}
635+
end
636+
if arglead == '' then
637+
return refs
638+
end
639+
local matches = {}
640+
for _, ref in ipairs(refs) do
641+
if ref:find(arglead, 1, true) == 1 then
642+
table.insert(matches, ref)
643+
end
644+
end
645+
return matches
646+
end,
647+
desc = 'Show unified diff against a git ref with qflist/loclist',
648+
})
462649
end
463650

464651
return M

spec/commands_spec.lua

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,79 @@ describe('commands', function()
112112
end)
113113
end)
114114

115+
describe('setup registers Greview command', function()
116+
it('registers Greview command', function()
117+
commands.setup()
118+
local cmds = vim.api.nvim_get_commands({})
119+
assert.is_not_nil(cmds.Greview)
120+
end)
121+
end)
122+
123+
describe('review_file_at_line', function()
124+
local test_buffers = {}
125+
126+
after_each(function()
127+
for _, bufnr in ipairs(test_buffers) do
128+
if vim.api.nvim_buf_is_valid(bufnr) then
129+
vim.api.nvim_buf_delete(bufnr, { force = true })
130+
end
131+
end
132+
test_buffers = {}
133+
end)
134+
135+
it('returns filename at cursor line', function()
136+
local bufnr = vim.api.nvim_create_buf(false, true)
137+
table.insert(test_buffers, bufnr)
138+
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, {
139+
'diff --git a/foo.lua b/foo.lua',
140+
'--- a/foo.lua',
141+
'+++ b/foo.lua',
142+
'@@ -1,3 +1,4 @@',
143+
' local M = {}',
144+
'+local x = 1',
145+
' return M',
146+
})
147+
148+
assert.are.equal('foo.lua', commands.review_file_at_line(bufnr, 6))
149+
end)
150+
151+
it('returns correct file in multi-file diff', function()
152+
local bufnr = vim.api.nvim_create_buf(false, true)
153+
table.insert(test_buffers, bufnr)
154+
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, {
155+
'diff --git a/foo.lua b/foo.lua',
156+
'@@ -1 +1 @@',
157+
'-old',
158+
'+new',
159+
'diff --git a/bar.lua b/bar.lua',
160+
'@@ -1 +1 @@',
161+
'-old',
162+
'+new',
163+
})
164+
165+
assert.are.equal('foo.lua', commands.review_file_at_line(bufnr, 3))
166+
assert.are.equal('bar.lua', commands.review_file_at_line(bufnr, 7))
167+
end)
168+
169+
it('returns nil before any diff header', function()
170+
local bufnr = vim.api.nvim_create_buf(false, true)
171+
table.insert(test_buffers, bufnr)
172+
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, {
173+
'some preamble text',
174+
'diff --git a/foo.lua b/foo.lua',
175+
})
176+
177+
assert.is_nil(commands.review_file_at_line(bufnr, 1))
178+
end)
179+
180+
it('returns nil on empty buffer', function()
181+
local bufnr = vim.api.nvim_create_buf(false, true)
182+
table.insert(test_buffers, bufnr)
183+
184+
assert.is_nil(commands.review_file_at_line(bufnr, 1))
185+
end)
186+
end)
187+
115188
describe('find_hunk_line', function()
116189
it('finds matching @@ header and returns target line', function()
117190
local diff_lines = {

spec/read_buffer_spec.lua

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,35 @@ describe('read_buffer', function()
286286

287287
assert.is_truthy(vim.tbl_contains(captured_cmd, '--cached'))
288288
end)
289+
290+
it('runs git diff with base ref for review label', function()
291+
local captured_cmd
292+
mock_systemlist(function(cmd)
293+
captured_cmd = cmd
294+
return {
295+
'diff --git a/file.lua b/file.lua',
296+
'--- a/file.lua',
297+
'+++ b/file.lua',
298+
'@@ -1 +1 @@',
299+
'-old',
300+
'+new',
301+
}
302+
end)
303+
304+
local bufnr = create_diffs_buffer('diffs://review:origin/main', {
305+
diffs_repo_root = '/home/test/repo',
306+
})
307+
commands.read_buffer(bufnr)
308+
309+
assert.is_not_nil(captured_cmd)
310+
assert.are.equal('git', captured_cmd[1])
311+
assert.are.equal('/home/test/repo', captured_cmd[3])
312+
assert.are.equal('diff', captured_cmd[4])
313+
assert.are.equal('origin/main', captured_cmd[#captured_cmd])
314+
315+
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
316+
assert.are.equal('diff --git a/file.lua b/file.lua', lines[1])
317+
end)
289318
end)
290319

291320
describe('content', function()

0 commit comments

Comments
 (0)