From 85080514b688bd21801cb1bc83220ff061c4bce1 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Feb 2026 22:23:09 -0500 Subject: [PATCH 1/7] feat(git): add index and working tree content retrieval Adds functions for accessing git index content and working tree files: - get_index_content() retrieves file from staging area via :0:path - get_working_content() reads file directly from disk - file_exists_in_index() checks if file is staged - file_exists_at_revision() checks if file exists at given revision --- lua/diffs/git.lua | 65 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/lua/diffs/git.lua b/lua/diffs/git.lua index c7632a2..7695283 100644 --- a/lua/diffs/git.lua +++ b/lua/diffs/git.lua @@ -45,4 +45,69 @@ function M.get_relative_path(filepath) return vim.fn.fnamemodify(filepath, ':.') end +---@param filepath string +---@return string[]?, string? +function M.get_index_content(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 = M.get_relative_path(filepath) + if not rel_path then + return nil, 'could not determine relative path' + end + + local result = vim.fn.systemlist({ 'git', '-C', repo_root, 'show', ':0:' .. rel_path }) + if vim.v.shell_error ~= 0 then + return nil, 'file not in index' + end + return result, nil +end + +---@param filepath string +---@return string[]?, string? +function M.get_working_content(filepath) + if vim.fn.filereadable(filepath) ~= 1 then + return nil, 'file not readable' + end + local lines = vim.fn.readfile(filepath) + return lines, nil +end + +---@param filepath string +---@return boolean +function M.file_exists_in_index(filepath) + local repo_root = M.get_repo_root(filepath) + if not repo_root then + return false + end + + local rel_path = M.get_relative_path(filepath) + if not rel_path then + return false + end + + vim.fn.system({ 'git', '-C', repo_root, 'ls-files', '--stage', '--', rel_path }) + return vim.v.shell_error == 0 +end + +---@param revision string +---@param filepath string +---@return boolean +function M.file_exists_at_revision(revision, filepath) + local repo_root = M.get_repo_root(filepath) + if not repo_root then + return false + end + + local rel_path = M.get_relative_path(filepath) + if not rel_path then + return false + end + + vim.fn.system({ 'git', '-C', repo_root, 'cat-file', '-e', revision .. ':' .. rel_path }) + return vim.v.shell_error == 0 +end + return M From ea60ab8d015ca583f61acabe1f5e23d694a305a4 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Feb 2026 22:23:13 -0500 Subject: [PATCH 2/7] feat(commands): add gdiff_file for diffing arbitrary paths Adds gdiff_file() which can diff any file path (not just current buffer) with support for staged vs unstaged changes: - staged=true diffs index against HEAD - staged=false diffs working tree against index - Falls back to HEAD if file not in index (for untracked comparison) --- lua/diffs/commands.lua | 75 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/lua/diffs/commands.lua b/lua/diffs/commands.lua index 6dbc176..e497e4c 100644 --- a/lua/diffs/commands.lua +++ b/lua/diffs/commands.lua @@ -88,6 +88,81 @@ function M.gdiff(revision, vertical) end) end +---@class diffs.GdiffFileOpts +---@field vertical? boolean +---@field staged? boolean + +---@param filepath string +---@param opts? diffs.GdiffFileOpts +function M.gdiff_file(filepath, opts) + opts = opts or {} + + local rel_path = git.get_relative_path(filepath) + if not rel_path then + vim.notify('[diffs.nvim]: not in a git repository', vim.log.levels.ERROR) + return + end + + local old_lines, new_lines, err + local diff_label + + if opts.staged then + old_lines, err = git.get_file_content('HEAD', filepath) + if not old_lines then + vim.notify('[diffs.nvim]: ' .. (err or 'file not in HEAD'), vim.log.levels.ERROR) + return + end + new_lines, err = git.get_index_content(filepath) + if not new_lines then + vim.notify('[diffs.nvim]: ' .. (err or 'file not in index'), vim.log.levels.ERROR) + return + end + diff_label = 'staged' + else + old_lines, err = git.get_index_content(filepath) + if not old_lines then + old_lines, err = git.get_file_content('HEAD', filepath) + if not old_lines then + old_lines = {} + diff_label = 'untracked' + else + diff_label = 'unstaged' + end + else + diff_label = 'unstaged' + end + new_lines, err = git.get_working_content(filepath) + if not new_lines then + vim.notify('[diffs.nvim]: ' .. (err or 'cannot read file'), vim.log.levels.ERROR) + return + end + end + + local diff_lines = generate_unified_diff(old_lines, new_lines, rel_path, rel_path) + + if #diff_lines == 0 then + vim.notify('[diffs.nvim]: 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://' .. diff_label .. ':' .. rel_path) + + vim.cmd(opts.vertical and 'vsplit' or 'split') + vim.api.nvim_win_set_buf(0, diff_buf) + + dbg('opened diff buffer %d for %s (%s)', diff_buf, rel_path, diff_label) + + 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, false) From 9289f33639cb432dc2bc677369178cfcf21e94ba Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Feb 2026 22:23:21 -0500 Subject: [PATCH 3/7] feat(fugitive): add status buffer keymaps for unified diffs Adds du/dU keymaps to fugitive's :Git status buffer for opening unified diffs instead of side-by-side diffs: - du opens horizontal split (mirrors dd) - dU opens vertical split (mirrors dv) Parses status buffer lines to extract filename and detect section (staged/unstaged/untracked). For staged files, diffs index vs HEAD. For unstaged files, diffs working tree vs index. Configurable via vim.g.diffs.fugitive.horizontal/vertical (set to false to disable). --- lua/diffs/fugitive.lua | 145 +++++++++++++++++++++++++++++++++++++++++ lua/diffs/init.lua | 34 ++++++++++ plugin/diffs.lua | 7 ++ 3 files changed, 186 insertions(+) create mode 100644 lua/diffs/fugitive.lua diff --git a/lua/diffs/fugitive.lua b/lua/diffs/fugitive.lua new file mode 100644 index 0000000..0a8f297 --- /dev/null +++ b/lua/diffs/fugitive.lua @@ -0,0 +1,145 @@ +local M = {} + +local commands = require('diffs.commands') +local git = require('diffs.git') +local dbg = require('diffs.log').dbg + +---@alias diffs.FugitiveSection 'staged' | 'unstaged' | 'untracked' | nil + +---@param bufnr integer +---@param lnum integer +---@return diffs.FugitiveSection +function M.get_section_at_line(bufnr, lnum) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, lnum, false) + + for i = #lines, 1, -1 do + local line = lines[i] + if line:match('^Staged ') then + return 'staged' + elseif line:match('^Unstaged ') then + return 'unstaged' + elseif line:match('^Untracked ') then + return 'untracked' + end + end + + return nil +end + +---@param line string +---@return string? +local function parse_file_line(line) + local renamed = line:match('^R[%s%d]*[^%s]+%s*->%s*(.+)$') + if renamed then + return vim.trim(renamed) + end + + local filename = line:match('^[MADRCU?][MADRCU%s]*%s+(.+)$') + if filename then + return vim.trim(filename) + end + + return nil +end + +---@param bufnr integer +---@param lnum integer +---@return string?, diffs.FugitiveSection +function M.get_file_at_line(bufnr, lnum) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local current_line = lines[lnum] + + if not current_line then + return nil, nil + end + + local filename = parse_file_line(current_line) + if filename then + local section = M.get_section_at_line(bufnr, lnum) + return filename, section + end + + local prefix = current_line:sub(1, 1) + if prefix == '+' or prefix == '-' or prefix == ' ' then + for i = lnum - 1, 1, -1 do + local prev_line = lines[i] + filename = parse_file_line(prev_line) + if filename then + local section = M.get_section_at_line(bufnr, i) + return filename, section + end + if prev_line:match('^%w+ %(') or prev_line == '' then + break + end + end + end + + return nil, nil +end + +---@param bufnr integer +---@return string? +local function get_repo_root_from_fugitive(bufnr) + local bufname = vim.api.nvim_buf_get_name(bufnr) + local fugitive_path = bufname:match('^fugitive://(.+)///') + if fugitive_path then + return fugitive_path + end + + local cwd = vim.fn.getcwd() + local root = git.get_repo_root(cwd .. '/.') + return root +end + +---@param vertical boolean +function M.diff_file_under_cursor(vertical) + local bufnr = vim.api.nvim_get_current_buf() + local lnum = vim.api.nvim_win_get_cursor(0)[1] + + local filename, section = M.get_file_at_line(bufnr, lnum) + + if not filename then + vim.notify('[diffs.nvim]: no file under cursor', vim.log.levels.WARN) + return + end + + local repo_root = get_repo_root_from_fugitive(bufnr) + if not repo_root then + vim.notify('[diffs.nvim]: could not determine repository root', vim.log.levels.ERROR) + return + end + + local filepath = repo_root .. '/' .. filename + + dbg('diff_file_under_cursor: %s (section: %s)', filename, section or 'unknown') + + if section == 'untracked' then + vim.notify('[diffs.nvim]: cannot diff untracked file (no base version)', vim.log.levels.WARN) + return + end + + commands.gdiff_file(filepath, { + vertical = vertical, + staged = section == 'staged', + }) +end + +---@param bufnr integer +---@param config { horizontal: string|false, vertical: string|false } +function M.setup_keymaps(bufnr, config) + if config.horizontal and config.horizontal ~= '' then + vim.keymap.set('n', config.horizontal, function() + M.diff_file_under_cursor(false) + end, { buffer = bufnr, desc = 'Unified diff (horizontal)' }) + dbg('set keymap %s for buffer %d', config.horizontal, bufnr) + end + + if config.vertical and config.vertical ~= '' then + vim.keymap.set('n', config.vertical, function() + M.diff_file_under_cursor(true) + end, { buffer = bufnr, desc = 'Unified diff (vertical)' }) + dbg('set keymap %s for buffer %d', config.vertical, bufnr) + end +end + +return M diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index 66a8098..b9944a4 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -12,11 +12,16 @@ ---@field treesitter diffs.TreesitterConfig ---@field vim diffs.VimConfig +---@class diffs.FugitiveConfig +---@field horizontal string|false +---@field vertical string|false + ---@class diffs.Config ---@field debug boolean ---@field debounce_ms integer ---@field hide_prefix boolean ---@field highlights diffs.Highlights +---@field fugitive diffs.FugitiveConfig ---@class diffs ---@field attach fun(bufnr?: integer) @@ -78,6 +83,10 @@ local default_config = { max_lines = 200, }, }, + fugitive = { + horizontal = 'du', + vertical = 'dU', + }, } ---@type diffs.Config @@ -219,6 +228,25 @@ local function init() end end + if opts.fugitive then + vim.validate({ + ['fugitive.horizontal'] = { + opts.fugitive.horizontal, + function(v) + return v == false or type(v) == 'string' + end, + 'string or false', + }, + ['fugitive.vertical'] = { + opts.fugitive.vertical, + function(v) + return v == false or type(v) == 'string' + end, + 'string or false', + }, + }) + end + if opts.debounce_ms and opts.debounce_ms < 0 then error('diffs: debounce_ms must be >= 0') end @@ -354,4 +382,10 @@ function M.detach_diff() end end +---@return diffs.FugitiveConfig +function M.get_fugitive_config() + init() + return config.fugitive +end + return M diff --git a/plugin/diffs.lua b/plugin/diffs.lua index 8a11067..35aa223 100644 --- a/plugin/diffs.lua +++ b/plugin/diffs.lua @@ -13,6 +13,13 @@ vim.api.nvim_create_autocmd('FileType', { return end diffs.attach(args.buf) + + if args.match == 'fugitive' then + local fugitive_config = diffs.get_fugitive_config() + if fugitive_config.horizontal or fugitive_config.vertical then + require('diffs.fugitive').setup_keymaps(args.buf, fugitive_config) + end + end end, }) From ce8fe3b89be692056b0454082a94acf36eaec809 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Feb 2026 22:23:25 -0500 Subject: [PATCH 4/7] test(fugitive): add unit tests for line parsing Tests for get_section_at_line() and get_file_at_line() covering: - Section detection (staged, unstaged, untracked) - File parsing (modified, added, deleted, renamed, untracked) - Hunk line walk-back to parent file - Files appearing in multiple sections --- spec/fugitive_spec.lua | 177 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 spec/fugitive_spec.lua diff --git a/spec/fugitive_spec.lua b/spec/fugitive_spec.lua new file mode 100644 index 0000000..bab23a0 --- /dev/null +++ b/spec/fugitive_spec.lua @@ -0,0 +1,177 @@ +require('spec.helpers') + +local fugitive = require('diffs.fugitive') + +describe('fugitive', function() + describe('get_section_at_line', function() + local function create_status_buffer(lines) + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + return buf + end + + it('returns staged for lines in Staged section', function() + local buf = create_status_buffer({ + 'Head: main', + '', + 'Staged (2)', + 'M file1.lua', + 'A file2.lua', + '', + 'Unstaged (1)', + 'M file3.lua', + }) + assert.equals('staged', fugitive.get_section_at_line(buf, 4)) + assert.equals('staged', fugitive.get_section_at_line(buf, 5)) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('returns unstaged for lines in Unstaged section', function() + local buf = create_status_buffer({ + 'Head: main', + '', + 'Staged (1)', + 'M file1.lua', + '', + 'Unstaged (2)', + 'M file2.lua', + 'M file3.lua', + }) + assert.equals('unstaged', fugitive.get_section_at_line(buf, 7)) + assert.equals('unstaged', fugitive.get_section_at_line(buf, 8)) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('returns untracked for lines in Untracked section', function() + local buf = create_status_buffer({ + 'Head: main', + '', + 'Untracked (2)', + '? newfile.lua', + '? another.lua', + }) + assert.equals('untracked', fugitive.get_section_at_line(buf, 4)) + assert.equals('untracked', fugitive.get_section_at_line(buf, 5)) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('returns nil for lines before any section', function() + local buf = create_status_buffer({ + 'Head: main', + 'Push: origin/main', + '', + 'Staged (1)', + 'M file1.lua', + }) + assert.is_nil(fugitive.get_section_at_line(buf, 1)) + assert.is_nil(fugitive.get_section_at_line(buf, 2)) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + end) + + describe('get_file_at_line', function() + local function create_status_buffer(lines) + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + return buf + end + + it('parses simple modified file', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M src/foo.lua', + }) + local filename, section = fugitive.get_file_at_line(buf, 2) + assert.equals('src/foo.lua', filename) + assert.equals('unstaged', section) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('parses added file', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'A newfile.lua', + }) + local filename, section = fugitive.get_file_at_line(buf, 2) + assert.equals('newfile.lua', filename) + assert.equals('staged', section) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('parses deleted file', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'D oldfile.lua', + }) + local filename, section = fugitive.get_file_at_line(buf, 2) + assert.equals('oldfile.lua', filename) + assert.equals('staged', section) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('parses renamed file and returns new name', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'R oldname.lua -> newname.lua', + }) + local filename, section = fugitive.get_file_at_line(buf, 2) + assert.equals('newname.lua', filename) + assert.equals('staged', section) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('parses untracked file', function() + local buf = create_status_buffer({ + 'Untracked (1)', + '? untracked.lua', + }) + local filename, section = fugitive.get_file_at_line(buf, 2) + assert.equals('untracked.lua', filename) + assert.equals('untracked', section) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('returns nil for section header', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M file.lua', + }) + local filename, section = fugitive.get_file_at_line(buf, 1) + assert.is_nil(filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('walks back from hunk line to find file', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M file.lua', + '@@ -1,3 +1,4 @@', + ' local M = {}', + '+local new = true', + ' return M', + }) + local filename, section = fugitive.get_file_at_line(buf, 5) + assert.equals('file.lua', filename) + assert.equals('unstaged', section) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('handles file with both staged and unstaged indicator', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'M both.lua', + '', + 'Unstaged (1)', + 'M both.lua', + }) + local filename1, section1 = fugitive.get_file_at_line(buf, 2) + assert.equals('both.lua', filename1) + assert.equals('staged', section1) + + local filename2, section2 = fugitive.get_file_at_line(buf, 5) + assert.equals('both.lua', filename2) + assert.equals('unstaged', section2) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + end) +end) From 6072dd01567f2c2e1eaad38c8bb3c04c9f3337ca Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Feb 2026 22:39:07 -0500 Subject: [PATCH 5/7] feat(fugitive): add section header and untracked file support Section headers (Staged/Unstaged) now show all diffs in that section, matching fugitive's behavior. Untracked files show as all-added diffs. Deleted files show as all-removed diffs. Also handles edge cases: - Empty new/old content for deleted/new files - Section header detection returns is_header flag --- lua/diffs/commands.lua | 64 +++++++++++++++++++++++++++++++++++++----- lua/diffs/fugitive.lua | 59 +++++++++++++++++++++++++++----------- spec/fugitive_spec.lua | 50 +++++++++++++++++++++++++++++++++ 3 files changed, 150 insertions(+), 23 deletions(-) diff --git a/lua/diffs/commands.lua b/lua/diffs/commands.lua index e497e4c..63b5fb0 100644 --- a/lua/diffs/commands.lua +++ b/lua/diffs/commands.lua @@ -91,6 +91,7 @@ end ---@class diffs.GdiffFileOpts ---@field vertical? boolean ---@field staged? boolean +---@field untracked? boolean ---@param filepath string ---@param opts? diffs.GdiffFileOpts @@ -106,16 +107,22 @@ function M.gdiff_file(filepath, opts) local old_lines, new_lines, err local diff_label - if opts.staged then + if opts.untracked then + old_lines = {} + new_lines, err = git.get_working_content(filepath) + if not new_lines then + vim.notify('[diffs.nvim]: ' .. (err or 'cannot read file'), vim.log.levels.ERROR) + return + end + diff_label = 'untracked' + elseif opts.staged then old_lines, err = git.get_file_content('HEAD', filepath) if not old_lines then - vim.notify('[diffs.nvim]: ' .. (err or 'file not in HEAD'), vim.log.levels.ERROR) - return + old_lines = {} end new_lines, err = git.get_index_content(filepath) if not new_lines then - vim.notify('[diffs.nvim]: ' .. (err or 'file not in index'), vim.log.levels.ERROR) - return + new_lines = {} end diff_label = 'staged' else @@ -133,8 +140,7 @@ function M.gdiff_file(filepath, opts) end new_lines, err = git.get_working_content(filepath) if not new_lines then - vim.notify('[diffs.nvim]: ' .. (err or 'cannot read file'), vim.log.levels.ERROR) - return + new_lines = {} end end @@ -163,6 +169,50 @@ function M.gdiff_file(filepath, opts) end) end +---@class diffs.GdiffSectionOpts +---@field vertical? boolean +---@field staged? boolean + +---@param repo_root string +---@param opts? diffs.GdiffSectionOpts +function M.gdiff_section(repo_root, opts) + opts = opts or {} + + local cmd = { 'git', '-C', repo_root, 'diff', '--no-ext-diff', '--no-color' } + if opts.staged then + table.insert(cmd, '--cached') + end + + local result = vim.fn.systemlist(cmd) + if vim.v.shell_error ~= 0 then + vim.notify('[diffs.nvim]: git diff failed', vim.log.levels.ERROR) + return + end + + if #result == 0 then + vim.notify('[diffs.nvim]: no changes in section', vim.log.levels.INFO) + return + end + + local diff_label = opts.staged and 'staged' or 'unstaged' + 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_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://' .. diff_label .. ':all') + + vim.cmd(opts.vertical and 'vsplit' or 'split') + vim.api.nvim_win_set_buf(0, diff_buf) + + dbg('opened section diff buffer %d (%s)', diff_buf, diff_label) + + 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, false) diff --git a/lua/diffs/fugitive.lua b/lua/diffs/fugitive.lua index 0a8f297..47f856b 100644 --- a/lua/diffs/fugitive.lua +++ b/lua/diffs/fugitive.lua @@ -42,21 +42,39 @@ local function parse_file_line(line) return nil end +---@param line string +---@return diffs.FugitiveSection? +local function parse_section_header(line) + if line:match('^Staged %(%d') then + return 'staged' + elseif line:match('^Unstaged %(%d') then + return 'unstaged' + elseif line:match('^Untracked %(%d') then + return 'untracked' + end + return nil +end + ---@param bufnr integer ---@param lnum integer ----@return string?, diffs.FugitiveSection +---@return string?, diffs.FugitiveSection, boolean function M.get_file_at_line(bufnr, lnum) local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) local current_line = lines[lnum] if not current_line then - return nil, nil + return nil, nil, false + end + + local section_header = parse_section_header(current_line) + if section_header then + return nil, section_header, true end local filename = parse_file_line(current_line) if filename then local section = M.get_section_at_line(bufnr, lnum) - return filename, section + return filename, section, false end local prefix = current_line:sub(1, 1) @@ -66,7 +84,7 @@ function M.get_file_at_line(bufnr, lnum) filename = parse_file_line(prev_line) if filename then local section = M.get_section_at_line(bufnr, i) - return filename, section + return filename, section, false end if prev_line:match('^%w+ %(') or prev_line == '' then break @@ -74,7 +92,7 @@ function M.get_file_at_line(bufnr, lnum) end end - return nil, nil + return nil, nil, false end ---@param bufnr integer @@ -96,12 +114,7 @@ function M.diff_file_under_cursor(vertical) local bufnr = vim.api.nvim_get_current_buf() local lnum = vim.api.nvim_win_get_cursor(0)[1] - local filename, section = M.get_file_at_line(bufnr, lnum) - - if not filename then - vim.notify('[diffs.nvim]: no file under cursor', vim.log.levels.WARN) - return - end + local filename, section, is_header = M.get_file_at_line(bufnr, lnum) local repo_root = get_repo_root_from_fugitive(bufnr) if not repo_root then @@ -109,18 +122,32 @@ function M.diff_file_under_cursor(vertical) return end + if is_header then + dbg('diff_section: %s', section or 'unknown') + if section == 'untracked' then + vim.notify('[diffs.nvim]: cannot diff untracked section', vim.log.levels.WARN) + return + end + commands.gdiff_section(repo_root, { + vertical = vertical, + staged = section == 'staged', + }) + return + end + + if not filename then + vim.notify('[diffs.nvim]: no file under cursor', vim.log.levels.WARN) + return + end + local filepath = repo_root .. '/' .. filename dbg('diff_file_under_cursor: %s (section: %s)', filename, section or 'unknown') - if section == 'untracked' then - vim.notify('[diffs.nvim]: cannot diff untracked file (no base version)', vim.log.levels.WARN) - return - end - commands.gdiff_file(filepath, { vertical = vertical, staged = section == 'staged', + untracked = section == 'untracked', }) end diff --git a/spec/fugitive_spec.lua b/spec/fugitive_spec.lua index bab23a0..00ebef1 100644 --- a/spec/fugitive_spec.lua +++ b/spec/fugitive_spec.lua @@ -173,5 +173,55 @@ describe('fugitive', function() assert.equals('unstaged', section2) vim.api.nvim_buf_delete(buf, { force = true }) end) + + it('detects section header for Staged', function() + local buf = create_status_buffer({ + 'Head: main', + '', + 'Staged (2)', + 'M file1.lua', + }) + local filename, section, is_header = fugitive.get_file_at_line(buf, 3) + assert.is_nil(filename) + assert.equals('staged', section) + assert.is_true(is_header) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('detects section header for Unstaged', function() + local buf = create_status_buffer({ + 'Unstaged (3)', + 'M file1.lua', + }) + local filename, section, is_header = fugitive.get_file_at_line(buf, 1) + assert.is_nil(filename) + assert.equals('unstaged', section) + assert.is_true(is_header) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('detects section header for Untracked', function() + local buf = create_status_buffer({ + 'Untracked (1)', + '? newfile.lua', + }) + local filename, section, is_header = fugitive.get_file_at_line(buf, 1) + assert.is_nil(filename) + assert.equals('untracked', section) + assert.is_true(is_header) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('returns is_header=false for file lines', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'M file.lua', + }) + local filename, section, is_header = fugitive.get_file_at_line(buf, 2) + assert.equals('file.lua', filename) + assert.equals('staged', section) + assert.is_false(is_header) + vim.api.nvim_buf_delete(buf, { force = true }) + end) end) end) From 9ed0639005cb17a5d68f59e4cd7397325f05b14b Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Feb 2026 23:12:30 -0500 Subject: [PATCH 6/7] fix(fugitive): handle renamed files correctly Parse both old and new filenames from rename lines (R old -> new). When diffing staged renames, use old filename as base to correctly show content changes rather than treating the file as entirely new. Also adds comprehensive tests for filename edge cases: - Double extensions, hyphens, underscores, dotfiles - Deep nested paths, complex renames - Documents known limitation with filenames containing ' -> ' --- lua/diffs/commands.lua | 11 ++-- lua/diffs/fugitive.lua | 39 +++++++----- spec/fugitive_spec.lua | 137 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 165 insertions(+), 22 deletions(-) diff --git a/lua/diffs/commands.lua b/lua/diffs/commands.lua index 63b5fb0..22d0570 100644 --- a/lua/diffs/commands.lua +++ b/lua/diffs/commands.lua @@ -92,6 +92,7 @@ end ---@field vertical? boolean ---@field staged? boolean ---@field untracked? boolean +---@field old_filepath? string ---@param filepath string ---@param opts? diffs.GdiffFileOpts @@ -104,6 +105,8 @@ function M.gdiff_file(filepath, opts) return end + local old_rel_path = opts.old_filepath and git.get_relative_path(opts.old_filepath) or rel_path + local old_lines, new_lines, err local diff_label @@ -116,7 +119,7 @@ function M.gdiff_file(filepath, opts) end diff_label = 'untracked' elseif opts.staged then - old_lines, err = git.get_file_content('HEAD', filepath) + old_lines, err = git.get_file_content('HEAD', opts.old_filepath or filepath) if not old_lines then old_lines = {} end @@ -126,9 +129,9 @@ function M.gdiff_file(filepath, opts) end diff_label = 'staged' else - old_lines, err = git.get_index_content(filepath) + old_lines, err = git.get_index_content(opts.old_filepath or filepath) if not old_lines then - old_lines, err = git.get_file_content('HEAD', filepath) + old_lines, err = git.get_file_content('HEAD', opts.old_filepath or filepath) if not old_lines then old_lines = {} diff_label = 'untracked' @@ -144,7 +147,7 @@ function M.gdiff_file(filepath, opts) end end - local diff_lines = generate_unified_diff(old_lines, new_lines, rel_path, rel_path) + local diff_lines = generate_unified_diff(old_lines, new_lines, old_rel_path, rel_path) if #diff_lines == 0 then vim.notify('[diffs.nvim]: no changes', vim.log.levels.INFO) diff --git a/lua/diffs/fugitive.lua b/lua/diffs/fugitive.lua index 47f856b..bdb5214 100644 --- a/lua/diffs/fugitive.lua +++ b/lua/diffs/fugitive.lua @@ -27,19 +27,19 @@ function M.get_section_at_line(bufnr, lnum) end ---@param line string ----@return string? +---@return string?, string? local function parse_file_line(line) - local renamed = line:match('^R[%s%d]*[^%s]+%s*->%s*(.+)$') - if renamed then - return vim.trim(renamed) + local old, new = line:match('^R%d*%s+(.-)%s+->%s+(.+)$') + if old and new then + return vim.trim(new), vim.trim(old) end local filename = line:match('^[MADRCU?][MADRCU%s]*%s+(.+)$') if filename then - return vim.trim(filename) + return vim.trim(filename), nil end - return nil + return nil, nil end ---@param line string @@ -57,34 +57,34 @@ end ---@param bufnr integer ---@param lnum integer ----@return string?, diffs.FugitiveSection, boolean +---@return string?, diffs.FugitiveSection, boolean, string? function M.get_file_at_line(bufnr, lnum) local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) local current_line = lines[lnum] if not current_line then - return nil, nil, false + return nil, nil, false, nil end local section_header = parse_section_header(current_line) if section_header then - return nil, section_header, true + return nil, section_header, true, nil end - local filename = parse_file_line(current_line) + local filename, old_filename = parse_file_line(current_line) if filename then local section = M.get_section_at_line(bufnr, lnum) - return filename, section, false + return filename, section, false, old_filename end local prefix = current_line:sub(1, 1) if prefix == '+' or prefix == '-' or prefix == ' ' then for i = lnum - 1, 1, -1 do local prev_line = lines[i] - filename = parse_file_line(prev_line) + filename, old_filename = parse_file_line(prev_line) if filename then local section = M.get_section_at_line(bufnr, i) - return filename, section, false + return filename, section, false, old_filename end if prev_line:match('^%w+ %(') or prev_line == '' then break @@ -92,7 +92,7 @@ function M.get_file_at_line(bufnr, lnum) end end - return nil, nil, false + return nil, nil, false, nil end ---@param bufnr integer @@ -114,7 +114,7 @@ function M.diff_file_under_cursor(vertical) local bufnr = vim.api.nvim_get_current_buf() local lnum = vim.api.nvim_win_get_cursor(0)[1] - local filename, section, is_header = M.get_file_at_line(bufnr, lnum) + local filename, section, is_header, old_filename = M.get_file_at_line(bufnr, lnum) local repo_root = get_repo_root_from_fugitive(bufnr) if not repo_root then @@ -141,13 +141,20 @@ function M.diff_file_under_cursor(vertical) end local filepath = repo_root .. '/' .. filename + local old_filepath = old_filename and (repo_root .. '/' .. old_filename) or nil - dbg('diff_file_under_cursor: %s (section: %s)', filename, section or 'unknown') + dbg( + 'diff_file_under_cursor: %s (section: %s, old: %s)', + filename, + section or 'unknown', + old_filename or 'none' + ) commands.gdiff_file(filepath, { vertical = vertical, staged = section == 'staged', untracked = section == 'untracked', + old_filepath = old_filepath, }) end diff --git a/spec/fugitive_spec.lua b/spec/fugitive_spec.lua index 00ebef1..3d73b0c 100644 --- a/spec/fugitive_spec.lua +++ b/spec/fugitive_spec.lua @@ -109,14 +109,147 @@ describe('fugitive', function() vim.api.nvim_buf_delete(buf, { force = true }) end) - it('parses renamed file and returns new name', function() + it('parses renamed file and returns both names', function() local buf = create_status_buffer({ 'Staged (1)', 'R oldname.lua -> newname.lua', }) - local filename, section = fugitive.get_file_at_line(buf, 2) + local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2) assert.equals('newname.lua', filename) assert.equals('staged', section) + assert.is_false(is_header) + assert.equals('oldname.lua', old_filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('parses renamed file with similarity index', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'R100 old.lua -> new.lua', + }) + local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2) + assert.equals('new.lua', filename) + assert.equals('staged', section) + assert.equals('old.lua', old_filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('returns nil old_filename for non-renames', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'M modified.lua', + }) + local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2) + assert.equals('modified.lua', filename) + assert.equals('staged', section) + assert.is_nil(old_filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('handles renamed file with spaces in name', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'R old file.lua -> new file.lua', + }) + local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2) + assert.equals('new file.lua', filename) + assert.equals('old file.lua', old_filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('handles renamed file in subdirectory', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'R src/old.lua -> src/new.lua', + }) + local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2) + assert.equals('src/new.lua', filename) + assert.equals('src/old.lua', old_filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('handles renamed file moved to different directory', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'R old/file.lua -> new/file.lua', + }) + local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2) + assert.equals('new/file.lua', filename) + assert.equals('old/file.lua', old_filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('KNOWN LIMITATION: filename containing arrow parsed incorrectly', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'R a -> b.lua -> c.lua', + }) + local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2) + assert.equals('b.lua -> c.lua', filename) + assert.equals('a', old_filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('handles double extensions', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'M test.spec.lua', + }) + local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2) + assert.equals('test.spec.lua', filename) + assert.is_nil(old_filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('handles hyphenated filenames', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M my-component-test.lua', + }) + local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2) + assert.equals('my-component-test.lua', filename) + assert.equals('unstaged', section) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('handles underscores and numbers', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'A test_file_123.lua', + }) + local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2) + assert.equals('test_file_123.lua', filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('handles dotfiles', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M .gitignore', + }) + local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2) + assert.equals('.gitignore', filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('handles renamed with complex names', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'R src/old-file.spec.lua -> src/new-file.spec.lua', + }) + local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2) + assert.equals('src/new-file.spec.lua', filename) + assert.equals('src/old-file.spec.lua', old_filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('handles deeply nested paths', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M lua/diffs/ui/components/diff-view.lua', + }) + local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2) + assert.equals('lua/diffs/ui/components/diff-view.lua', filename) vim.api.nvim_buf_delete(buf, { force = true }) end) From 08e22af1139594f58045010063e8e12fdba82c5d Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Feb 2026 23:25:39 -0500 Subject: [PATCH 7/7] fix(tests): suppress unused variable warnings for selene --- spec/fugitive_spec.lua | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/spec/fugitive_spec.lua b/spec/fugitive_spec.lua index 3d73b0c..e79aed1 100644 --- a/spec/fugitive_spec.lua +++ b/spec/fugitive_spec.lua @@ -127,7 +127,7 @@ describe('fugitive', function() 'Staged (1)', 'R100 old.lua -> new.lua', }) - local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2) + local filename, section, _, old_filename = fugitive.get_file_at_line(buf, 2) assert.equals('new.lua', filename) assert.equals('staged', section) assert.equals('old.lua', old_filename) @@ -139,7 +139,7 @@ describe('fugitive', function() 'Staged (1)', 'M modified.lua', }) - local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2) + local filename, section, _, old_filename = fugitive.get_file_at_line(buf, 2) assert.equals('modified.lua', filename) assert.equals('staged', section) assert.is_nil(old_filename) @@ -151,7 +151,7 @@ describe('fugitive', function() 'Staged (1)', 'R old file.lua -> new file.lua', }) - local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2) + local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2) assert.equals('new file.lua', filename) assert.equals('old file.lua', old_filename) vim.api.nvim_buf_delete(buf, { force = true }) @@ -162,7 +162,7 @@ describe('fugitive', function() 'Staged (1)', 'R src/old.lua -> src/new.lua', }) - local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2) + local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2) assert.equals('src/new.lua', filename) assert.equals('src/old.lua', old_filename) vim.api.nvim_buf_delete(buf, { force = true }) @@ -173,7 +173,7 @@ describe('fugitive', function() 'Staged (1)', 'R old/file.lua -> new/file.lua', }) - local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2) + local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2) assert.equals('new/file.lua', filename) assert.equals('old/file.lua', old_filename) vim.api.nvim_buf_delete(buf, { force = true }) @@ -184,7 +184,7 @@ describe('fugitive', function() 'Staged (1)', 'R a -> b.lua -> c.lua', }) - local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2) + local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2) assert.equals('b.lua -> c.lua', filename) assert.equals('a', old_filename) vim.api.nvim_buf_delete(buf, { force = true }) @@ -195,7 +195,7 @@ describe('fugitive', function() 'Staged (1)', 'M test.spec.lua', }) - local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2) + local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2) assert.equals('test.spec.lua', filename) assert.is_nil(old_filename) vim.api.nvim_buf_delete(buf, { force = true }) @@ -206,7 +206,7 @@ describe('fugitive', function() 'Unstaged (1)', 'M my-component-test.lua', }) - local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2) + local filename, section = fugitive.get_file_at_line(buf, 2) assert.equals('my-component-test.lua', filename) assert.equals('unstaged', section) vim.api.nvim_buf_delete(buf, { force = true }) @@ -217,7 +217,7 @@ describe('fugitive', function() 'Staged (1)', 'A test_file_123.lua', }) - local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2) + local filename = fugitive.get_file_at_line(buf, 2) assert.equals('test_file_123.lua', filename) vim.api.nvim_buf_delete(buf, { force = true }) end) @@ -227,7 +227,7 @@ describe('fugitive', function() 'Unstaged (1)', 'M .gitignore', }) - local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2) + local filename = fugitive.get_file_at_line(buf, 2) assert.equals('.gitignore', filename) vim.api.nvim_buf_delete(buf, { force = true }) end) @@ -237,7 +237,7 @@ describe('fugitive', function() 'Staged (1)', 'R src/old-file.spec.lua -> src/new-file.spec.lua', }) - local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2) + local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2) assert.equals('src/new-file.spec.lua', filename) assert.equals('src/old-file.spec.lua', old_filename) vim.api.nvim_buf_delete(buf, { force = true }) @@ -248,7 +248,7 @@ describe('fugitive', function() 'Unstaged (1)', 'M lua/diffs/ui/components/diff-view.lua', }) - local filename, section, is_header, old_filename = fugitive.get_file_at_line(buf, 2) + local filename = fugitive.get_file_at_line(buf, 2) assert.equals('lua/diffs/ui/components/diff-view.lua', filename) vim.api.nvim_buf_delete(buf, { force = true }) end) @@ -269,7 +269,7 @@ describe('fugitive', function() 'Unstaged (1)', 'M file.lua', }) - local filename, section = fugitive.get_file_at_line(buf, 1) + local filename = fugitive.get_file_at_line(buf, 1) assert.is_nil(filename) vim.api.nvim_buf_delete(buf, { force = true }) end)