fix(commands): handle :e on diffs:// buffers via BufReadCmd

Problem: running :e on a :Gdiff buffer cleared all content because
diffs:// buffers had no BufReadCmd handler. Neovim tried to read the
buffer name as a file path, found nothing on disk, and emptied the
buffer. This affected all three buffer creation paths (gdiff,
gdiff_file, gdiff_section).

Solution: register a BufReadCmd autocmd for diffs://* that parses the
URL and regenerates diff content from git. Change buffer options from
nofile/wipe to nowrite/delete (matching fugitive's approach) so
buffer-local autocmds and variables survive across unload/reload
cycles. Store old filepath as buffer variable for rename support.
This commit is contained in:
Barrett Ruth 2026-02-06 22:21:33 -05:00
parent b6f1c5b749
commit f948982848
3 changed files with 490 additions and 6 deletions

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)