From bf2c91f79f1e1daaf7061bf4e39a3a6017ad7fd8 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Feb 2026 18:14:18 -0500 Subject: [PATCH] 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. --- lua/diffs/commands.lua | 98 ++++++++++++++++++++++++++++++++++++++++++ lua/diffs/git.lua | 66 ++++++++++++++++++++++++++++ plugin/diffs.lua | 2 + spec/commands_spec.lua | 37 ++++++++++++++++ spec/git_spec.lua | 68 +++++++++++++++++++++++++++++ 5 files changed, 271 insertions(+) create mode 100644 lua/diffs/commands.lua create mode 100644 lua/diffs/git.lua create mode 100644 spec/commands_spec.lua create mode 100644 spec/git_spec.lua diff --git a/lua/diffs/commands.lua b/lua/diffs/commands.lua new file mode 100644 index 0000000..8b524d7 --- /dev/null +++ b/lua/diffs/commands.lua @@ -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 diff --git a/lua/diffs/git.lua b/lua/diffs/git.lua new file mode 100644 index 0000000..a9a5453 --- /dev/null +++ b/lua/diffs/git.lua @@ -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 diff --git a/plugin/diffs.lua b/plugin/diffs.lua index af2bddb..8a11067 100644 --- a/plugin/diffs.lua +++ b/plugin/diffs.lua @@ -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) diff --git a/spec/commands_spec.lua b/spec/commands_spec.lua new file mode 100644 index 0000000..9373c6f --- /dev/null +++ b/spec/commands_spec.lua @@ -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) diff --git a/spec/git_spec.lua b/spec/git_spec.lua new file mode 100644 index 0000000..291e427 --- /dev/null +++ b/spec/git_spec.lua @@ -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)