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:
parent
2ce76e7683
commit
bf2c91f79f
5 changed files with 271 additions and 0 deletions
98
lua/diffs/commands.lua
Normal file
98
lua/diffs/commands.lua
Normal 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
66
lua/diffs/git.lua
Normal 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
|
||||
|
|
@ -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
37
spec/commands_spec.lua
Normal 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
68
spec/git_spec.lua
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue