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)