From 603c966c71c71000ac4ddd3ac691b16ef3e7be3c Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 9 Feb 2026 13:53:02 -0500 Subject: [PATCH 1/4] fix(fugitive): handle git-quoted filenames in status buffer Problem: git quotes filenames containing spaces, unicode, or special characters (e.g. M "path with spaces/file.lua"). parse_file_line passed the quotes through, causing file-not-found on diff operations. Solution: add unquote() helper that strips surrounding quotes and unescapes \\, \", \n, \t, and octal \NNN sequences. Apply it to both filename returns in parse_file_line. --- lua/diffs/fugitive.lua | 49 ++++++++++++++++++++++++++++++++++++++-- spec/fugitive_spec.lua | 51 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/lua/diffs/fugitive.lua b/lua/diffs/fugitive.lua index b11985c..d4c3782 100644 --- a/lua/diffs/fugitive.lua +++ b/lua/diffs/fugitive.lua @@ -26,17 +26,62 @@ function M.get_section_at_line(bufnr, lnum) return nil end +---@param s string +---@return string +local function unquote(s) + if s:sub(1, 1) ~= '"' then + return s + end + local inner = s:sub(2, -2) + local result = {} + local i = 1 + while i <= #inner do + if inner:sub(i, i) == '\\' and i < #inner then + local next_char = inner:sub(i + 1, i + 1) + if next_char == 'n' then + table.insert(result, '\n') + i = i + 2 + elseif next_char == 't' then + table.insert(result, '\t') + i = i + 2 + elseif next_char == '"' then + table.insert(result, '"') + i = i + 2 + elseif next_char == '\\' then + table.insert(result, '\\') + i = i + 2 + elseif next_char:match('%d') then + local oct = inner:match('^(%d%d%d)', i + 1) + if oct then + table.insert(result, string.char(tonumber(oct, 8))) + i = i + 4 + else + table.insert(result, next_char) + i = i + 2 + end + else + table.insert(result, next_char) + i = i + 2 + end + else + table.insert(result, inner:sub(i, i)) + i = i + 1 + end + end + return table.concat(result) +end + ---@param line string ---@return string?, string?, string? local function parse_file_line(line) local old, new = line:match('^R%d*%s+(.-)%s+->%s+(.+)$') if old and new then - return vim.trim(new), vim.trim(old), 'R' + return unquote(vim.trim(new)), unquote(vim.trim(old)), 'R' end local status, filename = line:match('^([MADRCU?])[MADRCU%s]*%s+(.+)$') if status and filename then - return vim.trim(filename), nil, status + return unquote(vim.trim(filename)), nil, status end return nil, nil, nil diff --git a/spec/fugitive_spec.lua b/spec/fugitive_spec.lua index 244e1b7..3ea6c59 100644 --- a/spec/fugitive_spec.lua +++ b/spec/fugitive_spec.lua @@ -243,6 +243,57 @@ describe('fugitive', function() vim.api.nvim_buf_delete(buf, { force = true }) end) + it('unquotes git-quoted filenames with spaces', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M "path with spaces/file.lua"', + }) + local filename = fugitive.get_file_at_line(buf, 2) + assert.equals('path with spaces/file.lua', filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('unquotes escaped quotes in filenames', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M "file\\"name.lua"', + }) + local filename = fugitive.get_file_at_line(buf, 2) + assert.equals('file"name.lua', filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('unquotes octal escapes in filenames', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M "\\303\\251le.lua"', + }) + local filename = fugitive.get_file_at_line(buf, 2) + assert.equals('\195\169le.lua', filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('passes through unquoted filenames unchanged', function() + local buf = create_status_buffer({ + 'Unstaged (1)', + 'M normal.lua', + }) + local filename = fugitive.get_file_at_line(buf, 2) + assert.equals('normal.lua', filename) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('unquotes renamed files with quotes', function() + local buf = create_status_buffer({ + 'Staged (1)', + 'R100 "old name.lua" -> "new name.lua"', + }) + local filename, _, _, old_filename = fugitive.get_file_at_line(buf, 2) + assert.equals('new name.lua', filename) + assert.equals('old name.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)', From 946724096fa72b453a6b39584f4b81cc5fea50b8 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 9 Feb 2026 13:53:23 -0500 Subject: [PATCH 2/4] fix(conflict): notify on navigation wrap-around Problem: goto_next and goto_prev wrapped silently when reaching the last or first conflict, giving no indication to the user. Solution: add vim.notify before the wrap-around jump in both functions. --- lua/diffs/conflict.lua | 2 ++ spec/conflict_spec.lua | 54 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/lua/diffs/conflict.lua b/lua/diffs/conflict.lua index 894c0b8..7b5491c 100644 --- a/lua/diffs/conflict.lua +++ b/lua/diffs/conflict.lua @@ -323,6 +323,7 @@ function M.goto_next(bufnr) return end end + vim.notify('[diffs.nvim]: wrapped to first conflict', vim.log.levels.INFO) vim.api.nvim_win_set_cursor(0, { regions[1].marker_ours + 1, 0 }) end @@ -340,6 +341,7 @@ function M.goto_prev(bufnr) return end end + vim.notify('[diffs.nvim]: wrapped to last conflict', vim.log.levels.INFO) vim.api.nvim_win_set_cursor(0, { regions[#regions].marker_ours + 1, 0 }) end diff --git a/spec/conflict_spec.lua b/spec/conflict_spec.lua index 75eac23..fe4a1e7 100644 --- a/spec/conflict_spec.lua +++ b/spec/conflict_spec.lua @@ -531,6 +531,33 @@ describe('conflict', function() helpers.delete_buffer(bufnr) end) + it('goto_next notifies on wrap-around', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'a', + '=======', + 'b', + '>>>>>>> feat', + }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 5, 0 }) + + local notified = false + local orig_notify = vim.notify + vim.notify = function(msg) + if msg:match('wrapped to first conflict') then + notified = true + end + end + + conflict.goto_next(bufnr) + vim.notify = orig_notify + + assert.is_true(notified) + + helpers.delete_buffer(bufnr) + end) + it('goto_prev jumps to previous conflict', function() local bufnr = create_file_buffer({ '<<<<<<< HEAD', @@ -575,6 +602,33 @@ describe('conflict', function() helpers.delete_buffer(bufnr) end) + it('goto_prev notifies on wrap-around', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'a', + '=======', + 'b', + '>>>>>>> feat', + }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + + local notified = false + local orig_notify = vim.notify + vim.notify = function(msg) + if msg:match('wrapped to last conflict') then + notified = true + end + end + + conflict.goto_prev(bufnr) + vim.notify = orig_notify + + assert.is_true(notified) + + helpers.delete_buffer(bufnr) + end) + it('goto_next does nothing with no conflicts', function() local bufnr = create_file_buffer({ 'normal line' }) vim.api.nvim_set_current_buf(bufnr) From 910be50201f90eb51a28fdb5de2c864d8ccb07ba Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 9 Feb 2026 13:54:03 -0500 Subject: [PATCH 3/4] fix(merge): notify on navigation wrap-around Problem: goto_next and goto_prev in the merge module wrapped silently when reaching the last or first unresolved hunk. Solution: add vim.notify before the wrap-around jump in both functions. --- lua/diffs/merge.lua | 2 ++ spec/merge_spec.lua | 76 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/lua/diffs/merge.lua b/lua/diffs/merge.lua index 9bb9d68..ed75093 100644 --- a/lua/diffs/merge.lua +++ b/lua/diffs/merge.lua @@ -298,6 +298,7 @@ function M.goto_next(bufnr) end end + vim.notify('[diffs.nvim]: wrapped to first hunk', vim.log.levels.INFO) vim.api.nvim_win_set_cursor(0, { candidates[1].start_line + 1, 0 }) end @@ -335,6 +336,7 @@ function M.goto_prev(bufnr) end end + vim.notify('[diffs.nvim]: wrapped to last hunk', vim.log.levels.INFO) vim.api.nvim_win_set_cursor(0, { candidates[#candidates].start_line + 1, 0 }) end diff --git a/spec/merge_spec.lua b/spec/merge_spec.lua index 2f788a6..2037ee9 100644 --- a/spec/merge_spec.lua +++ b/spec/merge_spec.lua @@ -508,6 +508,44 @@ describe('merge', function() helpers.delete_buffer(w_bufnr) end) + it('goto_next notifies on wrap-around', function() + local working_path = '/tmp/diffs_test_wrap_notify.lua' + local w_bufnr = create_working_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }, working_path) + + local d_bufnr = create_diff_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,1 +1,1 @@', + '-local x = 1', + '+local x = 2', + }, working_path) + vim.api.nvim_set_current_buf(d_bufnr) + vim.api.nvim_win_set_cursor(0, { 6, 0 }) + + local notified = false + local orig_notify = vim.notify + vim.notify = function(msg) + if msg:match('wrapped to first hunk') then + notified = true + end + end + + merge.goto_next(d_bufnr) + vim.notify = orig_notify + + assert.is_true(notified) + + helpers.delete_buffer(d_bufnr) + helpers.delete_buffer(w_bufnr) + end) + it('goto_prev jumps to previous conflict hunk', function() local working_path = '/tmp/diffs_test_prev.lua' local w_bufnr = create_working_buffer({ @@ -576,6 +614,44 @@ describe('merge', function() helpers.delete_buffer(w_bufnr) end) + it('goto_prev notifies on wrap-around', function() + local working_path = '/tmp/diffs_test_prev_wrap_notify.lua' + local w_bufnr = create_working_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }, working_path) + + local d_bufnr = create_diff_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,1 +1,1 @@', + '-local x = 1', + '+local x = 2', + }, working_path) + vim.api.nvim_set_current_buf(d_bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + + local notified = false + local orig_notify = vim.notify + vim.notify = function(msg) + if msg:match('wrapped to last hunk') then + notified = true + end + end + + merge.goto_prev(d_bufnr) + vim.notify = orig_notify + + assert.is_true(notified) + + helpers.delete_buffer(d_bufnr) + helpers.delete_buffer(w_bufnr) + end) + it('skips resolved hunks', function() local working_path = '/tmp/diffs_test_skip_resolved.lua' local w_bufnr = create_working_buffer({ From e40bc055b49aa5cedf7760336138f709eb06dab1 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 9 Feb 2026 13:54:52 -0500 Subject: [PATCH 4/4] fix(merge): clear resolved state on buffer re-read Problem: resolved_hunks and virtual text persisted when a diff buffer was re-read, showing stale (resolved) markers for hunks that were no longer resolved. Solution: clear resolved_hunks[bufnr] and the merge namespace at the top of setup_keymaps so each buffer init starts fresh. --- lua/diffs/merge.lua | 3 +++ spec/merge_spec.lua | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/lua/diffs/merge.lua b/lua/diffs/merge.lua index ed75093..571b639 100644 --- a/lua/diffs/merge.lua +++ b/lua/diffs/merge.lua @@ -343,6 +343,9 @@ end ---@param bufnr integer ---@param config diffs.ConflictConfig function M.setup_keymaps(bufnr, config) + resolved_hunks[bufnr] = nil + vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) + local km = config.keymaps local maps = { diff --git a/spec/merge_spec.lua b/spec/merge_spec.lua index 2037ee9..047bde6 100644 --- a/spec/merge_spec.lua +++ b/spec/merge_spec.lua @@ -693,6 +693,48 @@ describe('merge', function() end) end) + describe('setup_keymaps', function() + it('clears resolved state on re-init', function() + local working_path = '/tmp/diffs_test_reinit.lua' + local w_bufnr = create_working_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }, working_path) + + local d_bufnr = create_diff_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,1 +1,1 @@', + '-local x = 1', + '+local x = 2', + }, working_path) + vim.api.nvim_set_current_buf(d_bufnr) + vim.api.nvim_win_set_cursor(0, { 5, 0 }) + + local cfg = default_config() + merge.resolve_ours(d_bufnr, cfg) + assert.is_true(merge.is_resolved(d_bufnr, 1)) + + local extmarks = + vim.api.nvim_buf_get_extmarks(d_bufnr, merge.get_namespace(), 0, -1, { details = true }) + assert.is_true(#extmarks > 0) + + merge.setup_keymaps(d_bufnr, cfg) + + assert.is_false(merge.is_resolved(d_bufnr, 1)) + extmarks = + vim.api.nvim_buf_get_extmarks(d_bufnr, merge.get_namespace(), 0, -1, { details = true }) + assert.are.equal(0, #extmarks) + + helpers.delete_buffer(d_bufnr) + helpers.delete_buffer(w_bufnr) + end) + end) + describe('fugitive integration', function() it('parse_file_line returns status for unmerged files', function() local fugitive = require('diffs.fugitive')