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/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/lua/diffs/merge.lua b/lua/diffs/merge.lua index 9bb9d68..571b639 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,12 +336,16 @@ 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 ---@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/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) 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)', diff --git a/spec/merge_spec.lua b/spec/merge_spec.lua index 2f788a6..047bde6 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({ @@ -617,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')