From 6072dd01567f2c2e1eaad38c8bb3c04c9f3337ca Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Wed, 4 Feb 2026 22:39:07 -0500 Subject: [PATCH] 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)