Merge pull request #76 from barrettruth/fix/bufread

fix(commands): handle :e on diffs:// buffers via BufReadCmd
This commit is contained in:
Barrett Ruth 2026-02-06 23:20:18 -05:00 committed by GitHub
commit 8d4602dbcb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 490 additions and 6 deletions

View file

@ -86,8 +86,9 @@ function M.gdiff(revision, vertical)
local diff_buf = vim.api.nvim_create_buf(false, true) local diff_buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(diff_buf, 0, -1, false, diff_lines) vim.api.nvim_buf_set_lines(diff_buf, 0, -1, false, diff_lines)
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = diff_buf }) vim.api.nvim_set_option_value('buftype', 'nowrite', { buf = diff_buf })
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = diff_buf }) vim.api.nvim_set_option_value('bufhidden', 'delete', { buf = diff_buf })
vim.api.nvim_set_option_value('swapfile', false, { buf = diff_buf })
vim.api.nvim_set_option_value('modifiable', false, { buf = diff_buf }) vim.api.nvim_set_option_value('modifiable', false, { buf = diff_buf })
vim.api.nvim_set_option_value('filetype', 'diff', { buf = diff_buf }) vim.api.nvim_set_option_value('filetype', 'diff', { buf = diff_buf })
vim.api.nvim_buf_set_name(diff_buf, 'diffs://' .. revision .. ':' .. rel_path) vim.api.nvim_buf_set_name(diff_buf, 'diffs://' .. revision .. ':' .. rel_path)
@ -176,14 +177,18 @@ function M.gdiff_file(filepath, opts)
local diff_buf = vim.api.nvim_create_buf(false, true) local diff_buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(diff_buf, 0, -1, false, diff_lines) vim.api.nvim_buf_set_lines(diff_buf, 0, -1, false, diff_lines)
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = diff_buf }) vim.api.nvim_set_option_value('buftype', 'nowrite', { buf = diff_buf })
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = diff_buf }) vim.api.nvim_set_option_value('bufhidden', 'delete', { buf = diff_buf })
vim.api.nvim_set_option_value('swapfile', false, { buf = diff_buf })
vim.api.nvim_set_option_value('modifiable', false, { buf = diff_buf }) vim.api.nvim_set_option_value('modifiable', false, { buf = diff_buf })
vim.api.nvim_set_option_value('filetype', 'diff', { buf = diff_buf }) vim.api.nvim_set_option_value('filetype', 'diff', { buf = diff_buf })
vim.api.nvim_buf_set_name(diff_buf, 'diffs://' .. diff_label .. ':' .. rel_path) vim.api.nvim_buf_set_name(diff_buf, 'diffs://' .. diff_label .. ':' .. rel_path)
if repo_root then if repo_root then
vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root) vim.api.nvim_buf_set_var(diff_buf, 'diffs_repo_root', repo_root)
end end
if old_rel_path ~= rel_path then
vim.api.nvim_buf_set_var(diff_buf, 'diffs_old_filepath', old_rel_path)
end
vim.cmd(opts.vertical and 'vsplit' or 'split') vim.cmd(opts.vertical and 'vsplit' or 'split')
vim.api.nvim_win_set_buf(0, diff_buf) vim.api.nvim_win_set_buf(0, diff_buf)
@ -231,8 +236,9 @@ function M.gdiff_section(repo_root, opts)
local diff_label = opts.staged and 'staged' or 'unstaged' local diff_label = opts.staged and 'staged' or 'unstaged'
local diff_buf = vim.api.nvim_create_buf(false, true) local diff_buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(diff_buf, 0, -1, false, result) vim.api.nvim_buf_set_lines(diff_buf, 0, -1, false, result)
vim.api.nvim_set_option_value('buftype', 'nofile', { buf = diff_buf }) vim.api.nvim_set_option_value('buftype', 'nowrite', { buf = diff_buf })
vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = diff_buf }) vim.api.nvim_set_option_value('bufhidden', 'delete', { buf = diff_buf })
vim.api.nvim_set_option_value('swapfile', false, { buf = diff_buf })
vim.api.nvim_set_option_value('modifiable', false, { buf = diff_buf }) vim.api.nvim_set_option_value('modifiable', false, { buf = diff_buf })
vim.api.nvim_set_option_value('filetype', 'diff', { buf = diff_buf }) vim.api.nvim_set_option_value('filetype', 'diff', { buf = diff_buf })
vim.api.nvim_buf_set_name(diff_buf, 'diffs://' .. diff_label .. ':all') vim.api.nvim_buf_set_name(diff_buf, 'diffs://' .. diff_label .. ':all')
@ -248,6 +254,77 @@ function M.gdiff_section(repo_root, opts)
end) end)
end end
---@param bufnr integer
function M.read_buffer(bufnr)
local name = vim.api.nvim_buf_get_name(bufnr)
local url_body = name:match('^diffs://(.+)$')
if not url_body then
return
end
local label, path = url_body:match('^([^:]+):(.+)$')
if not label or not path then
return
end
local ok, repo_root = pcall(vim.api.nvim_buf_get_var, bufnr, 'diffs_repo_root')
if not ok or not repo_root then
return
end
local diff_lines
if path == 'all' then
local cmd = { 'git', '-C', repo_root, 'diff', '--no-ext-diff', '--no-color' }
if label == 'staged' then
table.insert(cmd, '--cached')
end
diff_lines = vim.fn.systemlist(cmd)
if vim.v.shell_error ~= 0 then
diff_lines = {}
end
else
local abs_path = repo_root .. '/' .. path
local old_ok, old_rel_path = pcall(vim.api.nvim_buf_get_var, bufnr, 'diffs_old_filepath')
local old_abs_path = old_ok and old_rel_path and (repo_root .. '/' .. old_rel_path) or abs_path
local old_name = old_ok and old_rel_path or path
local old_lines, new_lines
if label == 'untracked' then
old_lines = {}
new_lines = git.get_working_content(abs_path) or {}
elseif label == 'staged' then
old_lines = git.get_file_content('HEAD', old_abs_path) or {}
new_lines = git.get_index_content(abs_path) or {}
elseif label == 'unstaged' then
old_lines = git.get_index_content(old_abs_path)
if not old_lines then
old_lines = git.get_file_content('HEAD', old_abs_path) or {}
end
new_lines = git.get_working_content(abs_path) or {}
else
old_lines = git.get_file_content(label, abs_path) or {}
new_lines = git.get_working_content(abs_path) or {}
end
diff_lines = generate_unified_diff(old_lines, new_lines, old_name, path)
end
vim.api.nvim_set_option_value('modifiable', true, { buf = bufnr })
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, diff_lines)
vim.api.nvim_set_option_value('modifiable', false, { buf = bufnr })
vim.api.nvim_set_option_value('buftype', 'nowrite', { buf = bufnr })
vim.api.nvim_set_option_value('bufhidden', 'delete', { buf = bufnr })
vim.api.nvim_set_option_value('swapfile', false, { buf = bufnr })
vim.api.nvim_set_option_value('filetype', 'diff', { buf = bufnr })
dbg('reloaded diff buffer %d (%s:%s)', bufnr, label, path)
require('diffs').attach(bufnr)
end
function M.setup() function M.setup()
vim.api.nvim_create_user_command('Gdiff', function(opts) vim.api.nvim_create_user_command('Gdiff', function(opts)
M.gdiff(opts.args ~= '' and opts.args or nil, false) M.gdiff(opts.args ~= '' and opts.args or nil, false)

View file

@ -23,6 +23,13 @@ vim.api.nvim_create_autocmd('FileType', {
end, end,
}) })
vim.api.nvim_create_autocmd('BufReadCmd', {
pattern = 'diffs://*',
callback = function(args)
require('diffs.commands').read_buffer(args.buf)
end,
})
vim.api.nvim_create_autocmd('OptionSet', { vim.api.nvim_create_autocmd('OptionSet', {
pattern = 'diff', pattern = 'diff',
callback = function() callback = function()

400
spec/read_buffer_spec.lua Normal file
View file

@ -0,0 +1,400 @@
require('spec.helpers')
local commands = require('diffs.commands')
local diffs = require('diffs')
local git = require('diffs.git')
local saved_git = {}
local saved_systemlist
local test_buffers = {}
local function mock_git(overrides)
overrides = overrides or {}
saved_git.get_file_content = git.get_file_content
saved_git.get_index_content = git.get_index_content
saved_git.get_working_content = git.get_working_content
git.get_file_content = overrides.get_file_content
or function()
return { 'local M = {}', 'return M' }
end
git.get_index_content = overrides.get_index_content
or function()
return { 'local M = {}', 'return M' }
end
git.get_working_content = overrides.get_working_content
or function()
return { 'local M = {}', 'local x = 1', 'return M' }
end
end
local function mock_systemlist(fn)
saved_systemlist = vim.fn.systemlist
vim.fn.systemlist = function(cmd)
local result = fn(cmd)
saved_systemlist({ 'true' })
return result
end
end
local function restore_mocks()
for k, v in pairs(saved_git) do
git[k] = v
end
saved_git = {}
if saved_systemlist then
vim.fn.systemlist = saved_systemlist
saved_systemlist = nil
end
end
---@param name string
---@param vars? table<string, any>
---@return integer
local function create_diffs_buffer(name, vars)
local existing = vim.fn.bufnr(name)
if existing ~= -1 then
vim.api.nvim_buf_delete(existing, { force = true })
end
local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_name(bufnr, name)
vars = vars or {}
for k, v in pairs(vars) do
vim.api.nvim_buf_set_var(bufnr, k, v)
end
table.insert(test_buffers, bufnr)
return bufnr
end
local function cleanup_buffers()
for _, bufnr in ipairs(test_buffers) do
if vim.api.nvim_buf_is_valid(bufnr) then
vim.api.nvim_buf_delete(bufnr, { force = true })
end
end
test_buffers = {}
end
describe('read_buffer', function()
after_each(function()
restore_mocks()
cleanup_buffers()
end)
describe('early returns', function()
it('does nothing on non-diffs:// buffer', function()
local bufnr = vim.api.nvim_create_buf(false, true)
table.insert(test_buffers, bufnr)
assert.has_no.errors(function()
commands.read_buffer(bufnr)
end)
assert.are.same({ '' }, vim.api.nvim_buf_get_lines(bufnr, 0, -1, false))
end)
it('does nothing on malformed url without colon separator', function()
local bufnr = create_diffs_buffer('diffs://nocolonseparator')
vim.api.nvim_buf_set_var(bufnr, 'diffs_repo_root', '/tmp')
local lines_before = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
assert.has_no.errors(function()
commands.read_buffer(bufnr)
end)
assert.are.same(lines_before, vim.api.nvim_buf_get_lines(bufnr, 0, -1, false))
end)
it('does nothing when diffs_repo_root is missing', function()
local bufnr = create_diffs_buffer('diffs://staged:missing_root.lua')
assert.has_no.errors(function()
commands.read_buffer(bufnr)
end)
assert.are.same({ '' }, vim.api.nvim_buf_get_lines(bufnr, 0, -1, false))
end)
end)
describe('buffer options', function()
it('sets buftype, bufhidden, swapfile, modifiable, filetype', function()
mock_git()
local bufnr = create_diffs_buffer('diffs://staged:options_test.lua', {
diffs_repo_root = '/tmp',
})
commands.read_buffer(bufnr)
assert.are.equal('nowrite', vim.api.nvim_get_option_value('buftype', { buf = bufnr }))
assert.are.equal('delete', vim.api.nvim_get_option_value('bufhidden', { buf = bufnr }))
assert.is_false(vim.api.nvim_get_option_value('swapfile', { buf = bufnr }))
assert.is_false(vim.api.nvim_get_option_value('modifiable', { buf = bufnr }))
assert.are.equal('diff', vim.api.nvim_get_option_value('filetype', { buf = bufnr }))
end)
end)
describe('dispatch', function()
it('calls get_file_content + get_index_content for staged label', function()
local called_get_file = false
local called_get_index = false
mock_git({
get_file_content = function()
called_get_file = true
return { 'old' }
end,
get_index_content = function()
called_get_index = true
return { 'new' }
end,
})
local bufnr = create_diffs_buffer('diffs://staged:dispatch_staged.lua', {
diffs_repo_root = '/tmp',
})
commands.read_buffer(bufnr)
assert.is_true(called_get_file)
assert.is_true(called_get_index)
end)
it('calls get_index_content + get_working_content for unstaged label', function()
local called_get_index = false
local called_get_working = false
mock_git({
get_index_content = function()
called_get_index = true
return { 'index' }
end,
get_working_content = function()
called_get_working = true
return { 'working' }
end,
})
local bufnr = create_diffs_buffer('diffs://unstaged:dispatch_unstaged.lua', {
diffs_repo_root = '/tmp',
})
commands.read_buffer(bufnr)
assert.is_true(called_get_index)
assert.is_true(called_get_working)
end)
it('calls only get_working_content for untracked label', function()
local called_get_file = false
local called_get_working = false
mock_git({
get_file_content = function()
called_get_file = true
return {}
end,
get_working_content = function()
called_get_working = true
return { 'new file' }
end,
})
local bufnr = create_diffs_buffer('diffs://untracked:dispatch_untracked.lua', {
diffs_repo_root = '/tmp',
})
commands.read_buffer(bufnr)
assert.is_false(called_get_file)
assert.is_true(called_get_working)
end)
it('calls get_file_content + get_working_content for revision label', function()
local captured_rev
local called_get_working = false
mock_git({
get_file_content = function(rev)
captured_rev = rev
return { 'old' }
end,
get_working_content = function()
called_get_working = true
return { 'new' }
end,
})
local bufnr = create_diffs_buffer('diffs://HEAD~3:dispatch_rev.lua', {
diffs_repo_root = '/tmp',
})
commands.read_buffer(bufnr)
assert.are.equal('HEAD~3', captured_rev)
assert.is_true(called_get_working)
end)
it('falls back from index to HEAD for unstaged when index returns nil', function()
local call_order = {}
mock_git({
get_index_content = function()
table.insert(call_order, 'index')
return nil
end,
get_file_content = function()
table.insert(call_order, 'head')
return { 'head content' }
end,
get_working_content = function()
return { 'working content' }
end,
})
local bufnr = create_diffs_buffer('diffs://unstaged:dispatch_fallback.lua', {
diffs_repo_root = '/tmp',
})
commands.read_buffer(bufnr)
assert.are.same({ 'index', 'head' }, call_order)
end)
it('runs git diff for section diffs with path=all', function()
local captured_cmd
mock_systemlist(function(cmd)
captured_cmd = cmd
return {
'diff --git a/file.lua b/file.lua',
'--- a/file.lua',
'+++ b/file.lua',
'@@ -1 +1 @@',
'-old',
'+new',
}
end)
local bufnr = create_diffs_buffer('diffs://unstaged:all', {
diffs_repo_root = '/home/test/repo',
})
commands.read_buffer(bufnr)
assert.is_not_nil(captured_cmd)
assert.are.equal('git', captured_cmd[1])
assert.are.equal('/home/test/repo', captured_cmd[3])
assert.are.equal('diff', captured_cmd[4])
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
assert.are.equal('diff --git a/file.lua b/file.lua', lines[1])
end)
it('passes --cached for staged section diffs', function()
local captured_cmd
mock_systemlist(function(cmd)
captured_cmd = cmd
return { 'diff --git a/f.lua b/f.lua', '@@ -1 +1 @@', '-a', '+b' }
end)
local bufnr = create_diffs_buffer('diffs://staged:all', {
diffs_repo_root = '/tmp',
})
commands.read_buffer(bufnr)
assert.is_truthy(vim.tbl_contains(captured_cmd, '--cached'))
end)
end)
describe('content', function()
it('generates valid unified diff header with correct paths', function()
mock_git({
get_file_content = function()
return { 'old' }
end,
get_working_content = function()
return { 'new' }
end,
})
local bufnr = create_diffs_buffer('diffs://HEAD:lua/diffs/init.lua', {
diffs_repo_root = '/tmp',
})
commands.read_buffer(bufnr)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
assert.are.equal('diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua', lines[1])
assert.are.equal('--- a/lua/diffs/init.lua', lines[2])
assert.are.equal('+++ b/lua/diffs/init.lua', lines[3])
end)
it('uses old_filepath for diff header in renames', function()
mock_git({
get_file_content = function(_, path)
assert.are.equal('/tmp/old_name.lua', path)
return { 'old content' }
end,
get_index_content = function()
return { 'new content' }
end,
})
local bufnr = create_diffs_buffer('diffs://staged:new_name.lua', {
diffs_repo_root = '/tmp',
diffs_old_filepath = 'old_name.lua',
})
commands.read_buffer(bufnr)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
assert.are.equal('diff --git a/old_name.lua b/new_name.lua', lines[1])
assert.are.equal('--- a/old_name.lua', lines[2])
assert.are.equal('+++ b/new_name.lua', lines[3])
end)
it('produces empty buffer when old and new are identical', function()
mock_git({
get_file_content = function()
return { 'identical' }
end,
get_working_content = function()
return { 'identical' }
end,
})
local bufnr = create_diffs_buffer('diffs://HEAD:nodiff.lua', {
diffs_repo_root = '/tmp',
})
commands.read_buffer(bufnr)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
assert.are.same({ '' }, lines)
end)
it('replaces existing buffer content on reload', function()
mock_git({
get_file_content = function()
return { 'old' }
end,
get_working_content = function()
return { 'new' }
end,
})
local bufnr = create_diffs_buffer('diffs://HEAD:replace_test.lua', {
diffs_repo_root = '/tmp',
})
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { 'stale', 'content', 'from', 'before' })
commands.read_buffer(bufnr)
local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
assert.are.equal('diff --git a/replace_test.lua b/replace_test.lua', lines[1])
for _, line in ipairs(lines) do
assert.is_not_equal('stale', line)
end
end)
end)
describe('attach integration', function()
it('calls attach on the buffer', function()
mock_git()
local attach_called_with
local original_attach = diffs.attach
diffs.attach = function(bufnr)
attach_called_with = bufnr
end
local bufnr = create_diffs_buffer('diffs://staged:attach_test.lua', {
diffs_repo_root = '/tmp',
})
commands.read_buffer(bufnr)
assert.are.equal(bufnr, attach_called_with)
diffs.attach = original_attach
end)
end)
end)