feat: add :Gdiff command for unified diff against git revision

Compares current buffer against any git revision (default HEAD), opens result
in vsplit with full diffs.nvim syntax highlighting.
This commit is contained in:
Barrett Ruth 2026-02-04 18:14:18 -05:00
parent 2ce76e7683
commit bf2c91f79f
5 changed files with 271 additions and 0 deletions

98
lua/diffs/commands.lua Normal file
View file

@ -0,0 +1,98 @@
local M = {}
local git = require('diffs.git')
local dbg = require('diffs.log').dbg
---@param old_lines string[]
---@param new_lines string[]
---@param old_name string
---@param new_name string
---@return string[]
local function generate_unified_diff(old_lines, new_lines, old_name, new_name)
local old_content = table.concat(old_lines, '\n')
local new_content = table.concat(new_lines, '\n')
local diff_output = vim.diff(old_content, new_content, {
result_type = 'unified',
ctxlen = 3,
})
if not diff_output or diff_output == '' then
return {}
end
local diff_lines = vim.split(diff_output, '\n', { plain = true })
local result = {
'diff --git a/' .. old_name .. ' b/' .. new_name,
'--- a/' .. old_name,
'+++ b/' .. new_name,
}
for _, line in ipairs(diff_lines) do
table.insert(result, line)
end
return result
end
---@param revision? string
function M.gdiff(revision)
revision = revision or 'HEAD'
local bufnr = vim.api.nvim_get_current_buf()
local filepath = vim.api.nvim_buf_get_name(bufnr)
if filepath == '' then
vim.notify('diffs: buffer has no file', vim.log.levels.ERROR)
return
end
local rel_path = git.get_relative_path(filepath)
if not rel_path then
vim.notify('diffs: not in a git repository', vim.log.levels.ERROR)
return
end
local old_lines, err = git.get_file_content(revision, filepath)
if not old_lines then
vim.notify('diffs: ' .. (err or 'unknown error'), vim.log.levels.ERROR)
return
end
local new_lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local diff_lines = generate_unified_diff(old_lines, new_lines, rel_path, rel_path)
if #diff_lines == 0 then
vim.notify('diffs: no changes', vim.log.levels.INFO)
return
end
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_set_option_value('buftype', 'nofile', { buf = diff_buf })
vim.api.nvim_set_option_value('bufhidden', 'wipe', { 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_buf_set_name(diff_buf, 'diffs://' .. revision .. ':' .. rel_path)
vim.cmd('vsplit')
vim.api.nvim_win_set_buf(0, diff_buf)
dbg('opened diff buffer %d for %s against %s', diff_buf, rel_path, revision)
vim.schedule(function()
require('diffs').attach(diff_buf)
end)
end
function M.setup()
vim.api.nvim_create_user_command('Gdiff', function(opts)
M.gdiff(opts.args ~= '' and opts.args or nil)
end, {
nargs = '?',
desc = 'Show unified diff against git revision (default: HEAD)',
})
end
return M

66
lua/diffs/git.lua Normal file
View file

@ -0,0 +1,66 @@
local M = {}
---@param filepath string
---@return string?
function M.get_git_dir(filepath)
local dir = vim.fn.fnamemodify(filepath, ':h')
local result = vim.fn.systemlist({ 'git', '-C', dir, 'rev-parse', '--git-dir' })
if vim.v.shell_error ~= 0 then
return nil
end
local git_dir = result[1]
if not git_dir then
return nil
end
if vim.startswith(git_dir, '/') then
return git_dir
end
return vim.fn.fnamemodify(dir .. '/' .. git_dir, ':p'):gsub('/+$', '')
end
---@param filepath string
---@return string?
function M.get_repo_root(filepath)
local dir = vim.fn.fnamemodify(filepath, ':h')
local result = vim.fn.systemlist({ 'git', '-C', dir, 'rev-parse', '--show-toplevel' })
if vim.v.shell_error ~= 0 then
return nil
end
return result[1]
end
---@param revision string
---@param filepath string
---@return string[]?, string?
function M.get_file_content(revision, filepath)
local repo_root = M.get_repo_root(filepath)
if not repo_root then
return nil, 'not in a git repository'
end
local rel_path = vim.fn.fnamemodify(filepath, ':.')
if vim.startswith(filepath, repo_root) then
rel_path = filepath:sub(#repo_root + 2)
end
local result = vim.fn.systemlist({ 'git', '-C', repo_root, 'show', revision .. ':' .. rel_path })
if vim.v.shell_error ~= 0 then
return nil, 'failed to get file at revision: ' .. revision
end
return result, nil
end
---@param filepath string
---@return string?
function M.get_relative_path(filepath)
local repo_root = M.get_repo_root(filepath)
if not repo_root then
return nil
end
if vim.startswith(filepath, repo_root) then
return filepath:sub(#repo_root + 2)
end
return vim.fn.fnamemodify(filepath, ':.')
end
return M

View file

@ -3,6 +3,8 @@ if vim.g.loaded_diffs then
end
vim.g.loaded_diffs = 1
require('diffs.commands').setup()
vim.api.nvim_create_autocmd('FileType', {
pattern = { 'fugitive', 'git' },
callback = function(args)

37
spec/commands_spec.lua Normal file
View file

@ -0,0 +1,37 @@
require('spec.helpers')
describe('commands', function()
describe('setup', function()
it('registers Gdiff command', function()
require('diffs.commands').setup()
local commands = vim.api.nvim_get_commands({})
assert.is_not_nil(commands.Gdiff)
end)
end)
describe('unified diff generation', function()
local old_lines = { 'local M = {}', 'return M' }
local new_lines = { 'local M = {}', 'local x = 1', 'return M' }
it('generates valid unified diff', function()
local old_content = table.concat(old_lines, '\n')
local new_content = table.concat(new_lines, '\n')
local diff_output = vim.diff(old_content, new_content, {
result_type = 'unified',
ctxlen = 3,
})
assert.is_not_nil(diff_output)
assert.is_true(diff_output:find('@@ ') ~= nil)
assert.is_true(diff_output:find('+local x = 1') ~= nil)
end)
it('returns empty for identical content', function()
local content = table.concat(old_lines, '\n')
local diff_output = vim.diff(content, content, {
result_type = 'unified',
ctxlen = 3,
})
assert.are.equal('', diff_output)
end)
end)
end)

68
spec/git_spec.lua Normal file
View file

@ -0,0 +1,68 @@
require('spec.helpers')
local git = require('diffs.git')
describe('git', function()
describe('get_git_dir', function()
it('returns git dir for current repo', function()
local cwd = vim.fn.getcwd()
local git_dir = git.get_git_dir(cwd .. '/lua/diffs/init.lua')
assert.is_not_nil(git_dir)
assert.is_true(vim.fn.isdirectory(git_dir) == 1)
end)
it('returns nil for non-git directory', function()
local git_dir = git.get_git_dir('/tmp')
assert.is_nil(git_dir)
end)
end)
describe('get_repo_root', function()
it('returns repo root for current repo', function()
local cwd = vim.fn.getcwd()
local root = git.get_repo_root(cwd .. '/lua/diffs/init.lua')
assert.is_not_nil(root)
assert.are.equal(cwd, root)
end)
it('returns nil for non-git directory', function()
local root = git.get_repo_root('/tmp')
assert.is_nil(root)
end)
end)
describe('get_file_content', function()
it('returns file content at HEAD', function()
local cwd = vim.fn.getcwd()
local content, err = git.get_file_content('HEAD', cwd .. '/lua/diffs/init.lua')
assert.is_nil(err)
assert.is_not_nil(content)
assert.is_true(#content > 0)
end)
it('returns error for non-existent file', function()
local cwd = vim.fn.getcwd()
local content, err = git.get_file_content('HEAD', cwd .. '/does_not_exist.lua')
assert.is_nil(content)
assert.is_not_nil(err)
end)
it('returns error for non-git directory', function()
local content, err = git.get_file_content('HEAD', '/tmp/some_file.txt')
assert.is_nil(content)
assert.is_not_nil(err)
end)
end)
describe('get_relative_path', function()
it('returns relative path within repo', function()
local cwd = vim.fn.getcwd()
local rel = git.get_relative_path(cwd .. '/lua/diffs/init.lua')
assert.are.equal('lua/diffs/init.lua', rel)
end)
it('returns nil for non-git directory', function()
local rel = git.get_relative_path('/tmp/some_file.txt')
assert.is_nil(rel)
end)
end)
end)