From f3a72926d288aaf03c4e212e9f82a006fb300320 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 8 Feb 2026 16:28:18 -0500 Subject: [PATCH 1/8] doc: add plug mappings for merge conflict resolution --- doc/diffs.nvim.txt | 35 +++++++++++++++++++++++++++++++++++ lua/diffs/conflict.lua | 42 +++++++++++++----------------------------- plugin/diffs.lua | 25 +++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 29 deletions(-) diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index 028d8ba..7663150 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -280,6 +280,41 @@ Example configuration: >lua vim.keymap.set('n', 'gD', '(diffs-gvdiff)') < + *(diffs-conflict-ours)* +(diffs-conflict-ours) + Accept current (ours) change. Replaces the + conflict block with ours content. + + *(diffs-conflict-theirs)* +(diffs-conflict-theirs) + Accept incoming (theirs) change. Replaces the + conflict block with theirs content. + + *(diffs-conflict-both)* +(diffs-conflict-both) + Accept both changes (ours then theirs). + + *(diffs-conflict-none)* +(diffs-conflict-none) + Reject both changes (delete entire block). + + *(diffs-conflict-next)* +(diffs-conflict-next) + Jump to next conflict marker. Wraps around. + + *(diffs-conflict-prev)* +(diffs-conflict-prev) + Jump to previous conflict marker. Wraps around. + +Example configuration: >lua + vim.keymap.set('n', 'co', '(diffs-conflict-ours)') + vim.keymap.set('n', 'ct', '(diffs-conflict-theirs)') + vim.keymap.set('n', 'cb', '(diffs-conflict-both)') + vim.keymap.set('n', 'cn', '(diffs-conflict-none)') + vim.keymap.set('n', ']x', '(diffs-conflict-next)') + vim.keymap.set('n', '[x', '(diffs-conflict-prev)') +< + Diff buffer mappings: ~ *diffs-q* q Close the diff window. Available in all `diffs://` diff --git a/lua/diffs/conflict.lua b/lua/diffs/conflict.lua index f4468d2..9e62a15 100644 --- a/lua/diffs/conflict.lua +++ b/lua/diffs/conflict.lua @@ -348,35 +348,19 @@ end local function setup_keymaps(bufnr, config) local km = config.keymaps - if km.ours then - vim.keymap.set('n', km.ours, function() - M.resolve_ours(bufnr, config) - end, { buffer = bufnr }) - end - if km.theirs then - vim.keymap.set('n', km.theirs, function() - M.resolve_theirs(bufnr, config) - end, { buffer = bufnr }) - end - if km.both then - vim.keymap.set('n', km.both, function() - M.resolve_both(bufnr, config) - end, { buffer = bufnr }) - end - if km.none then - vim.keymap.set('n', km.none, function() - M.resolve_none(bufnr, config) - end, { buffer = bufnr }) - end - if km.next then - vim.keymap.set('n', km.next, function() - M.goto_next(bufnr) - end, { buffer = bufnr }) - end - if km.prev then - vim.keymap.set('n', km.prev, function() - M.goto_prev(bufnr) - end, { buffer = bufnr }) + local maps = { + { km.ours, '(diffs-conflict-ours)' }, + { km.theirs, '(diffs-conflict-theirs)' }, + { km.both, '(diffs-conflict-both)' }, + { km.none, '(diffs-conflict-none)' }, + { km.next, '(diffs-conflict-next)' }, + { km.prev, '(diffs-conflict-prev)' }, + } + + for _, map in ipairs(maps) do + if map[1] then + vim.keymap.set('n', map[1], map[2], { buffer = bufnr }) + end end end diff --git a/plugin/diffs.lua b/plugin/diffs.lua index e4fc690..5d3c8b2 100644 --- a/plugin/diffs.lua +++ b/plugin/diffs.lua @@ -57,3 +57,28 @@ end, { desc = 'Unified diff (horizontal)' }) vim.keymap.set('n', '(diffs-gvdiff)', function() cmds.gdiff(nil, true) end, { desc = 'Unified diff (vertical)' }) + +local function conflict_action(fn) + local bufnr = vim.api.nvim_get_current_buf() + local config = require('diffs').get_conflict_config() + fn(bufnr, config) +end + +vim.keymap.set('n', '(diffs-conflict-ours)', function() + conflict_action(require('diffs.conflict').resolve_ours) +end, { desc = 'Accept current (ours) change' }) +vim.keymap.set('n', '(diffs-conflict-theirs)', function() + conflict_action(require('diffs.conflict').resolve_theirs) +end, { desc = 'Accept incoming (theirs) change' }) +vim.keymap.set('n', '(diffs-conflict-both)', function() + conflict_action(require('diffs.conflict').resolve_both) +end, { desc = 'Accept both changes' }) +vim.keymap.set('n', '(diffs-conflict-none)', function() + conflict_action(require('diffs.conflict').resolve_none) +end, { desc = 'Reject both changes' }) +vim.keymap.set('n', '(diffs-conflict-next)', function() + require('diffs.conflict').goto_next(vim.api.nvim_get_current_buf()) +end, { desc = 'Jump to next conflict' }) +vim.keymap.set('n', '(diffs-conflict-prev)', function() + require('diffs.conflict').goto_prev(vim.api.nvim_get_current_buf()) +end, { desc = 'Jump to previous conflict' }) From 49fc446aaeb2743c329bec31966d3de23b8c6cbf Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Sun, 8 Feb 2026 16:29:39 -0500 Subject: [PATCH 2/8] doc: add plug mappings for merge conflict resolution (#98) --- doc/diffs.nvim.txt | 35 +++++++++++++++++++++++++++++++++++ lua/diffs/conflict.lua | 42 +++++++++++++----------------------------- plugin/diffs.lua | 25 +++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 29 deletions(-) diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index 028d8ba..7663150 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -280,6 +280,41 @@ Example configuration: >lua vim.keymap.set('n', 'gD', '(diffs-gvdiff)') < + *(diffs-conflict-ours)* +(diffs-conflict-ours) + Accept current (ours) change. Replaces the + conflict block with ours content. + + *(diffs-conflict-theirs)* +(diffs-conflict-theirs) + Accept incoming (theirs) change. Replaces the + conflict block with theirs content. + + *(diffs-conflict-both)* +(diffs-conflict-both) + Accept both changes (ours then theirs). + + *(diffs-conflict-none)* +(diffs-conflict-none) + Reject both changes (delete entire block). + + *(diffs-conflict-next)* +(diffs-conflict-next) + Jump to next conflict marker. Wraps around. + + *(diffs-conflict-prev)* +(diffs-conflict-prev) + Jump to previous conflict marker. Wraps around. + +Example configuration: >lua + vim.keymap.set('n', 'co', '(diffs-conflict-ours)') + vim.keymap.set('n', 'ct', '(diffs-conflict-theirs)') + vim.keymap.set('n', 'cb', '(diffs-conflict-both)') + vim.keymap.set('n', 'cn', '(diffs-conflict-none)') + vim.keymap.set('n', ']x', '(diffs-conflict-next)') + vim.keymap.set('n', '[x', '(diffs-conflict-prev)') +< + Diff buffer mappings: ~ *diffs-q* q Close the diff window. Available in all `diffs://` diff --git a/lua/diffs/conflict.lua b/lua/diffs/conflict.lua index f4468d2..9e62a15 100644 --- a/lua/diffs/conflict.lua +++ b/lua/diffs/conflict.lua @@ -348,35 +348,19 @@ end local function setup_keymaps(bufnr, config) local km = config.keymaps - if km.ours then - vim.keymap.set('n', km.ours, function() - M.resolve_ours(bufnr, config) - end, { buffer = bufnr }) - end - if km.theirs then - vim.keymap.set('n', km.theirs, function() - M.resolve_theirs(bufnr, config) - end, { buffer = bufnr }) - end - if km.both then - vim.keymap.set('n', km.both, function() - M.resolve_both(bufnr, config) - end, { buffer = bufnr }) - end - if km.none then - vim.keymap.set('n', km.none, function() - M.resolve_none(bufnr, config) - end, { buffer = bufnr }) - end - if km.next then - vim.keymap.set('n', km.next, function() - M.goto_next(bufnr) - end, { buffer = bufnr }) - end - if km.prev then - vim.keymap.set('n', km.prev, function() - M.goto_prev(bufnr) - end, { buffer = bufnr }) + local maps = { + { km.ours, '(diffs-conflict-ours)' }, + { km.theirs, '(diffs-conflict-theirs)' }, + { km.both, '(diffs-conflict-both)' }, + { km.none, '(diffs-conflict-none)' }, + { km.next, '(diffs-conflict-next)' }, + { km.prev, '(diffs-conflict-prev)' }, + } + + for _, map in ipairs(maps) do + if map[1] then + vim.keymap.set('n', map[1], map[2], { buffer = bufnr }) + end end end diff --git a/plugin/diffs.lua b/plugin/diffs.lua index e4fc690..5d3c8b2 100644 --- a/plugin/diffs.lua +++ b/plugin/diffs.lua @@ -57,3 +57,28 @@ end, { desc = 'Unified diff (horizontal)' }) vim.keymap.set('n', '(diffs-gvdiff)', function() cmds.gdiff(nil, true) end, { desc = 'Unified diff (vertical)' }) + +local function conflict_action(fn) + local bufnr = vim.api.nvim_get_current_buf() + local config = require('diffs').get_conflict_config() + fn(bufnr, config) +end + +vim.keymap.set('n', '(diffs-conflict-ours)', function() + conflict_action(require('diffs.conflict').resolve_ours) +end, { desc = 'Accept current (ours) change' }) +vim.keymap.set('n', '(diffs-conflict-theirs)', function() + conflict_action(require('diffs.conflict').resolve_theirs) +end, { desc = 'Accept incoming (theirs) change' }) +vim.keymap.set('n', '(diffs-conflict-both)', function() + conflict_action(require('diffs.conflict').resolve_both) +end, { desc = 'Accept both changes' }) +vim.keymap.set('n', '(diffs-conflict-none)', function() + conflict_action(require('diffs.conflict').resolve_none) +end, { desc = 'Reject both changes' }) +vim.keymap.set('n', '(diffs-conflict-next)', function() + require('diffs.conflict').goto_next(vim.api.nvim_get_current_buf()) +end, { desc = 'Jump to next conflict' }) +vim.keymap.set('n', '(diffs-conflict-prev)', function() + require('diffs.conflict').goto_prev(vim.api.nvim_get_current_buf()) +end, { desc = 'Jump to previous conflict' }) From a2053a132bc0cccf5ab7d9247c047ea90beddd9d Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Mon, 9 Feb 2026 12:21:13 -0500 Subject: [PATCH 3/8] feat: unified diff conflict resolution for unmerged files (#99) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Pressing `du` on a `UU` (unmerged) file in the fugitive status buffer had no effect. There was no way to see a proper ours-vs-theirs diff with syntax highlighting and intra-line changes, or to resolve conflicts from within a unified diff view. Additionally, pressing `du` on a section header containing only unmerged files showed "no changes in section" because `git diff` produces combined (`diff --cc`) output for unmerged files, which was stripped entirely. ## Solution Fetch `:2:` (ours) and `:3:` (theirs) from the git index and generate a standard unified diff. The existing highlight pipeline (treesitter + intra-line) applies automatically. Resolution keymaps (`doo`/`dot`/`dob`/`don`) on hunks in the diff view write changes back to the working file's conflict markers. Navigation (`]x`/`[x`) jumps between unresolved conflict hunks. For section diffs, combined diff entries are now replaced with generated ours-vs-theirs unified diffs instead of being stripped. Works for merge, cherry-pick, and rebase conflicts — git populates `:2:`/`:3:` the same way for all three. Closes #61 --- doc/diffs.nvim.txt | 52 ++++ lua/diffs/commands.lua | 75 ++++- lua/diffs/conflict.lua | 22 +- lua/diffs/fugitive.lua | 31 +- lua/diffs/merge.lua | 374 +++++++++++++++++++++++ plugin/diffs.lua | 25 ++ spec/commands_spec.lua | 72 +++++ spec/merge_spec.lua | 664 +++++++++++++++++++++++++++++++++++++++++ 8 files changed, 1287 insertions(+), 28 deletions(-) create mode 100644 lua/diffs/merge.lua create mode 100644 spec/merge_spec.lua diff --git a/doc/diffs.nvim.txt b/doc/diffs.nvim.txt index 7663150..6bd848c 100644 --- a/doc/diffs.nvim.txt +++ b/doc/diffs.nvim.txt @@ -315,6 +315,32 @@ Example configuration: >lua vim.keymap.set('n', '[x', '(diffs-conflict-prev)') < + *(diffs-merge-ours)* +(diffs-merge-ours) + Accept ours in a merge diff view. Resolves the + conflict in the working file with ours content. + + *(diffs-merge-theirs)* +(diffs-merge-theirs) + Accept theirs in a merge diff view. + + *(diffs-merge-both)* +(diffs-merge-both) + Accept both (ours then theirs) in a merge diff view. + + *(diffs-merge-none)* +(diffs-merge-none) + Reject both in a merge diff view. + + *(diffs-merge-next)* +(diffs-merge-next) + Jump to next unresolved conflict hunk in merge diff. + + *(diffs-merge-prev)* +(diffs-merge-prev) + Jump to previous unresolved conflict hunk in merge + diff. + Diff buffer mappings: ~ *diffs-q* q Close the diff window. Available in all `diffs://` @@ -345,6 +371,7 @@ Behavior by file status: ~ A Staged (empty) index file as all-added D Staged HEAD (empty) file as all-removed R Staged HEAD:oldname index:newname content diff + U Unstaged :2: (ours) :3: (theirs) merge diff ? Untracked (empty) working tree file as all-added On section headers, the keymap runs `git diff` (or `git diff --cached` for @@ -458,6 +485,31 @@ User events: ~ }) < +============================================================================== +MERGE DIFF RESOLUTION *diffs-merge* + +When pressing `du`/`dU` on an unmerged (`U`) file in the fugitive status +buffer, diffs.nvim opens a unified diff of ours (`git show :2:path`) vs +theirs (`git show :3:path`) with full treesitter and intra-line highlighting. + +The same conflict resolution keymaps (`doo`/`dot`/`dob`/`don`/`]x`/`[x`) +are available on the diff buffer. They resolve conflicts in the working +file by matching diff hunks to conflict markers: + +- `doo` replaces the conflict region with ours content +- `dot` replaces the conflict region with theirs content +- `dob` replaces with both (ours then theirs) +- `don` removes the conflict region entirely +- `]x`/`[x` navigate between unresolved conflict hunks + +Resolved hunks are marked with `(resolved)` virtual text. Hunks that +correspond to auto-merged content (no conflict markers) show an +informational notification and are left unchanged. + +The working file buffer is modified in place; save it when ready. +Phase 1 inline conflict highlights (see |diffs-conflict|) are refreshed +automatically after each resolution. + ============================================================================== API *diffs-api* diff --git a/lua/diffs/commands.lua b/lua/diffs/commands.lua index dc6cfe2..01f1d3a 100644 --- a/lua/diffs/commands.lua +++ b/lua/diffs/commands.lua @@ -36,6 +36,24 @@ function M.find_hunk_line(diff_lines, hunk_position) return nil end +---@param lines string[] +---@return string[] +function M.filter_combined_diffs(lines) + local result = {} + local skip = false + for _, line in ipairs(lines) do + if line:match('^diff %-%-cc ') then + skip = true + elseif line:match('^diff %-%-git ') then + skip = false + end + if not skip then + table.insert(result, line) + end + end + return result +end + ---@param old_lines string[] ---@param new_lines string[] ---@param old_name string @@ -69,6 +87,33 @@ local function generate_unified_diff(old_lines, new_lines, old_name, new_name) return result end +---@param raw_lines string[] +---@param repo_root string +---@return string[] +local function replace_combined_diffs(raw_lines, repo_root) + local unmerged_files = {} + for _, line in ipairs(raw_lines) do + local cc_file = line:match('^diff %-%-cc (.+)$') + if cc_file then + table.insert(unmerged_files, cc_file) + end + end + + local result = M.filter_combined_diffs(raw_lines) + + for _, filename in ipairs(unmerged_files) do + local filepath = repo_root .. '/' .. filename + local old_lines = git.get_file_content(':2', filepath) or {} + local new_lines = git.get_file_content(':3', filepath) or {} + local diff_lines = generate_unified_diff(old_lines, new_lines, filename, filename) + for _, dl in ipairs(diff_lines) do + table.insert(result, dl) + end + end + + return result +end + ---@param revision? string ---@param vertical? boolean function M.gdiff(revision, vertical) @@ -138,6 +183,7 @@ end ---@field vertical? boolean ---@field staged? boolean ---@field untracked? boolean +---@field unmerged? boolean ---@field old_filepath? string ---@field hunk_position? { hunk_header: string, offset: integer } @@ -157,7 +203,17 @@ function M.gdiff_file(filepath, opts) local old_lines, new_lines, err local diff_label - if opts.untracked then + if opts.unmerged then + old_lines = git.get_file_content(':2', filepath) + if not old_lines then + old_lines = {} + end + new_lines = git.get_file_content(':3', filepath) + if not new_lines then + new_lines = {} + end + diff_label = 'unmerged' + elseif opts.untracked then old_lines = {} new_lines, err = git.get_working_content(filepath) if not new_lines then @@ -236,6 +292,14 @@ function M.gdiff_file(filepath, opts) end M.setup_diff_buf(diff_buf) + + if diff_label == 'unmerged' then + vim.api.nvim_buf_set_var(diff_buf, 'diffs_unmerged', true) + vim.api.nvim_buf_set_var(diff_buf, 'diffs_working_path', filepath) + local conflict_config = require('diffs').get_conflict_config() + require('diffs.merge').setup_keymaps(diff_buf, conflict_config) + end + dbg('opened diff buffer %d for %s (%s)', diff_buf, rel_path, diff_label) vim.schedule(function() @@ -263,6 +327,8 @@ function M.gdiff_section(repo_root, opts) return end + result = replace_combined_diffs(result, repo_root) + if #result == 0 then vim.notify('[diffs.nvim]: no changes in section', vim.log.levels.INFO) return @@ -325,6 +391,8 @@ function M.read_buffer(bufnr) if vim.v.shell_error ~= 0 then diff_lines = {} end + + diff_lines = replace_combined_diffs(diff_lines, repo_root) else local abs_path = repo_root .. '/' .. path @@ -334,7 +402,10 @@ function M.read_buffer(bufnr) local old_lines, new_lines - if label == 'untracked' then + if label == 'unmerged' then + old_lines = git.get_file_content(':2', abs_path) or {} + new_lines = git.get_file_content(':3', abs_path) or {} + elseif label == 'untracked' then old_lines = {} new_lines = git.get_working_content(abs_path) or {} elseif label == 'staged' then diff --git a/lua/diffs/conflict.lua b/lua/diffs/conflict.lua index 9e62a15..894c0b8 100644 --- a/lua/diffs/conflict.lua +++ b/lua/diffs/conflict.lua @@ -199,7 +199,7 @@ end ---@param bufnr integer ---@param region diffs.ConflictRegion ---@param replacement string[] -local function replace_region(bufnr, region, replacement) +function M.replace_region(bufnr, region, replacement) vim.api.nvim_buf_set_lines( bufnr, region.marker_ours, @@ -211,7 +211,7 @@ end ---@param bufnr integer ---@param config diffs.ConflictConfig -local function refresh(bufnr, config) +function M.refresh(bufnr, config) local regions = parse_buffer(bufnr) if #regions == 0 then vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) @@ -244,8 +244,8 @@ function M.resolve_ours(bufnr, config) return end local lines = vim.api.nvim_buf_get_lines(bufnr, region.ours_start, region.ours_end, false) - replace_region(bufnr, region, lines) - refresh(bufnr, config) + M.replace_region(bufnr, region, lines) + M.refresh(bufnr, config) end ---@param bufnr integer @@ -262,8 +262,8 @@ function M.resolve_theirs(bufnr, config) return end local lines = vim.api.nvim_buf_get_lines(bufnr, region.theirs_start, region.theirs_end, false) - replace_region(bufnr, region, lines) - refresh(bufnr, config) + M.replace_region(bufnr, region, lines) + M.refresh(bufnr, config) end ---@param bufnr integer @@ -288,8 +288,8 @@ function M.resolve_both(bufnr, config) for _, l in ipairs(theirs) do table.insert(combined, l) end - replace_region(bufnr, region, combined) - refresh(bufnr, config) + M.replace_region(bufnr, region, combined) + M.refresh(bufnr, config) end ---@param bufnr integer @@ -305,8 +305,8 @@ function M.resolve_none(bufnr, config) if not region then return end - replace_region(bufnr, region, {}) - refresh(bufnr, config) + M.replace_region(bufnr, region, {}) + M.refresh(bufnr, config) end ---@param bufnr integer @@ -417,7 +417,7 @@ function M.attach(bufnr, config) if not attached_buffers[bufnr] then return true end - refresh(bufnr, config) + M.refresh(bufnr, config) end, }) diff --git a/lua/diffs/fugitive.lua b/lua/diffs/fugitive.lua index a588a22..b11985c 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?, 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) + return vim.trim(new), vim.trim(old), 'R' end - local filename = line:match('^[MADRCU?][MADRCU%s]*%s+(.+)$') - if filename then - return vim.trim(filename), nil + local status, filename = line:match('^([MADRCU?])[MADRCU%s]*%s+(.+)$') + if status and filename then + return vim.trim(filename), nil, status end - return nil, nil + return nil, nil, nil end ---@param line string @@ -57,34 +57,34 @@ end ---@param bufnr integer ---@param lnum integer ----@return string?, diffs.FugitiveSection, boolean, string? +---@return string?, diffs.FugitiveSection, boolean, string?, 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, nil + return nil, nil, false, nil, nil end local section_header = parse_section_header(current_line) if section_header then - return nil, section_header, true, nil + return nil, section_header, true, nil, nil end - local filename, old_filename = parse_file_line(current_line) + local filename, old_filename, status = parse_file_line(current_line) if filename then local section = M.get_section_at_line(bufnr, lnum) - return filename, section, false, old_filename + return filename, section, false, old_filename, status 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, old_filename = parse_file_line(prev_line) + filename, old_filename, status = parse_file_line(prev_line) if filename then local section = M.get_section_at_line(bufnr, i) - return filename, section, false, old_filename + return filename, section, false, old_filename, status 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, nil + return nil, nil, false, nil, nil end ---@class diffs.HunkPosition @@ -150,7 +150,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, old_filename = M.get_file_at_line(bufnr, lnum) + local filename, section, is_header, old_filename, status = M.get_file_at_line(bufnr, lnum) local repo_root = get_repo_root_from_fugitive(bufnr) if not repo_root then @@ -192,6 +192,7 @@ function M.diff_file_under_cursor(vertical) vertical = vertical, staged = section == 'staged', untracked = section == 'untracked', + unmerged = status == 'U', old_filepath = old_filepath, hunk_position = hunk_position, }) diff --git a/lua/diffs/merge.lua b/lua/diffs/merge.lua new file mode 100644 index 0000000..9bb9d68 --- /dev/null +++ b/lua/diffs/merge.lua @@ -0,0 +1,374 @@ +local M = {} + +local conflict = require('diffs.conflict') + +local ns = vim.api.nvim_create_namespace('diffs-merge') + +---@type table> +local resolved_hunks = {} + +---@class diffs.MergeHunkInfo +---@field index integer +---@field start_line integer +---@field end_line integer +---@field del_lines string[] +---@field add_lines string[] + +---@param bufnr integer +---@return diffs.MergeHunkInfo[] +function M.parse_hunks(bufnr) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local hunks = {} + local current = nil + + for i, line in ipairs(lines) do + local idx = i - 1 + if line:match('^@@') then + if current then + current.end_line = idx - 1 + table.insert(hunks, current) + end + current = { + index = #hunks + 1, + start_line = idx, + end_line = idx, + del_lines = {}, + add_lines = {}, + } + elseif current then + local prefix = line:sub(1, 1) + if prefix == '-' then + table.insert(current.del_lines, line:sub(2)) + elseif prefix == '+' then + table.insert(current.add_lines, line:sub(2)) + elseif prefix ~= ' ' and prefix ~= '\\' then + current.end_line = idx - 1 + table.insert(hunks, current) + current = nil + end + if current then + current.end_line = idx + end + end + end + + if current then + table.insert(hunks, current) + end + + return hunks +end + +---@param bufnr integer +---@return diffs.MergeHunkInfo? +function M.find_hunk_at_cursor(bufnr) + local hunks = M.parse_hunks(bufnr) + local cursor_line = vim.api.nvim_win_get_cursor(0)[1] - 1 + + for _, hunk in ipairs(hunks) do + if cursor_line >= hunk.start_line and cursor_line <= hunk.end_line then + return hunk + end + end + + return nil +end + +---@param hunk diffs.MergeHunkInfo +---@param working_bufnr integer +---@return diffs.ConflictRegion? +function M.match_hunk_to_conflict(hunk, working_bufnr) + local working_lines = vim.api.nvim_buf_get_lines(working_bufnr, 0, -1, false) + local regions = conflict.parse(working_lines) + + for _, region in ipairs(regions) do + local ours_lines = {} + for line = region.ours_start + 1, region.ours_end do + table.insert(ours_lines, working_lines[line]) + end + + if #ours_lines == #hunk.del_lines then + local match = true + for j = 1, #ours_lines do + if ours_lines[j] ~= hunk.del_lines[j] then + match = false + break + end + end + if match then + return region + end + end + end + + return nil +end + +---@param diff_bufnr integer +---@return integer? +function M.get_or_load_working_buf(diff_bufnr) + local ok, working_path = pcall(vim.api.nvim_buf_get_var, diff_bufnr, 'diffs_working_path') + if not ok or not working_path then + return nil + end + + local existing = vim.fn.bufnr(working_path) + if existing ~= -1 then + return existing + end + + local bufnr = vim.fn.bufadd(working_path) + vim.fn.bufload(bufnr) + return bufnr +end + +---@param diff_bufnr integer +---@param hunk_index integer +local function mark_resolved(diff_bufnr, hunk_index) + if not resolved_hunks[diff_bufnr] then + resolved_hunks[diff_bufnr] = {} + end + resolved_hunks[diff_bufnr][hunk_index] = true +end + +---@param diff_bufnr integer +---@param hunk_index integer +---@return boolean +function M.is_resolved(diff_bufnr, hunk_index) + return resolved_hunks[diff_bufnr] and resolved_hunks[diff_bufnr][hunk_index] or false +end + +---@param diff_bufnr integer +---@param hunk diffs.MergeHunkInfo +local function add_resolved_virtual_text(diff_bufnr, hunk) + pcall(vim.api.nvim_buf_set_extmark, diff_bufnr, ns, hunk.start_line, 0, { + virt_text = { { ' (resolved)', 'Comment' } }, + virt_text_pos = 'eol', + }) +end + +---@param bufnr integer +---@param config diffs.ConflictConfig +function M.resolve_ours(bufnr, config) + local hunk = M.find_hunk_at_cursor(bufnr) + if not hunk then + return + end + if M.is_resolved(bufnr, hunk.index) then + vim.notify('[diffs.nvim]: hunk already resolved', vim.log.levels.INFO) + return + end + local working_bufnr = M.get_or_load_working_buf(bufnr) + if not working_bufnr then + return + end + local region = M.match_hunk_to_conflict(hunk, working_bufnr) + if not region then + vim.notify('[diffs.nvim]: hunk does not correspond to a conflict region', vim.log.levels.INFO) + return + end + local lines = vim.api.nvim_buf_get_lines(working_bufnr, region.ours_start, region.ours_end, false) + conflict.replace_region(working_bufnr, region, lines) + conflict.refresh(working_bufnr, config) + mark_resolved(bufnr, hunk.index) + add_resolved_virtual_text(bufnr, hunk) +end + +---@param bufnr integer +---@param config diffs.ConflictConfig +function M.resolve_theirs(bufnr, config) + local hunk = M.find_hunk_at_cursor(bufnr) + if not hunk then + return + end + if M.is_resolved(bufnr, hunk.index) then + vim.notify('[diffs.nvim]: hunk already resolved', vim.log.levels.INFO) + return + end + local working_bufnr = M.get_or_load_working_buf(bufnr) + if not working_bufnr then + return + end + local region = M.match_hunk_to_conflict(hunk, working_bufnr) + if not region then + vim.notify('[diffs.nvim]: hunk does not correspond to a conflict region', vim.log.levels.INFO) + return + end + local lines = + vim.api.nvim_buf_get_lines(working_bufnr, region.theirs_start, region.theirs_end, false) + conflict.replace_region(working_bufnr, region, lines) + conflict.refresh(working_bufnr, config) + mark_resolved(bufnr, hunk.index) + add_resolved_virtual_text(bufnr, hunk) +end + +---@param bufnr integer +---@param config diffs.ConflictConfig +function M.resolve_both(bufnr, config) + local hunk = M.find_hunk_at_cursor(bufnr) + if not hunk then + return + end + if M.is_resolved(bufnr, hunk.index) then + vim.notify('[diffs.nvim]: hunk already resolved', vim.log.levels.INFO) + return + end + local working_bufnr = M.get_or_load_working_buf(bufnr) + if not working_bufnr then + return + end + local region = M.match_hunk_to_conflict(hunk, working_bufnr) + if not region then + vim.notify('[diffs.nvim]: hunk does not correspond to a conflict region', vim.log.levels.INFO) + return + end + local ours = vim.api.nvim_buf_get_lines(working_bufnr, region.ours_start, region.ours_end, false) + local theirs = + vim.api.nvim_buf_get_lines(working_bufnr, region.theirs_start, region.theirs_end, false) + local combined = {} + for _, l in ipairs(ours) do + table.insert(combined, l) + end + for _, l in ipairs(theirs) do + table.insert(combined, l) + end + conflict.replace_region(working_bufnr, region, combined) + conflict.refresh(working_bufnr, config) + mark_resolved(bufnr, hunk.index) + add_resolved_virtual_text(bufnr, hunk) +end + +---@param bufnr integer +---@param config diffs.ConflictConfig +function M.resolve_none(bufnr, config) + local hunk = M.find_hunk_at_cursor(bufnr) + if not hunk then + return + end + if M.is_resolved(bufnr, hunk.index) then + vim.notify('[diffs.nvim]: hunk already resolved', vim.log.levels.INFO) + return + end + local working_bufnr = M.get_or_load_working_buf(bufnr) + if not working_bufnr then + return + end + local region = M.match_hunk_to_conflict(hunk, working_bufnr) + if not region then + vim.notify('[diffs.nvim]: hunk does not correspond to a conflict region', vim.log.levels.INFO) + return + end + conflict.replace_region(working_bufnr, region, {}) + conflict.refresh(working_bufnr, config) + mark_resolved(bufnr, hunk.index) + add_resolved_virtual_text(bufnr, hunk) +end + +---@param bufnr integer +function M.goto_next(bufnr) + local hunks = M.parse_hunks(bufnr) + if #hunks == 0 then + return + end + + local working_bufnr = M.get_or_load_working_buf(bufnr) + if not working_bufnr then + return + end + + local cursor_line = vim.api.nvim_win_get_cursor(0)[1] - 1 + + local candidates = {} + for _, hunk in ipairs(hunks) do + if not M.is_resolved(bufnr, hunk.index) then + if M.match_hunk_to_conflict(hunk, working_bufnr) then + table.insert(candidates, hunk) + end + end + end + + if #candidates == 0 then + return + end + + for _, hunk in ipairs(candidates) do + if hunk.start_line > cursor_line then + vim.api.nvim_win_set_cursor(0, { hunk.start_line + 1, 0 }) + return + end + end + + vim.api.nvim_win_set_cursor(0, { candidates[1].start_line + 1, 0 }) +end + +---@param bufnr integer +function M.goto_prev(bufnr) + local hunks = M.parse_hunks(bufnr) + if #hunks == 0 then + return + end + + local working_bufnr = M.get_or_load_working_buf(bufnr) + if not working_bufnr then + return + end + + local cursor_line = vim.api.nvim_win_get_cursor(0)[1] - 1 + + local candidates = {} + for _, hunk in ipairs(hunks) do + if not M.is_resolved(bufnr, hunk.index) then + if M.match_hunk_to_conflict(hunk, working_bufnr) then + table.insert(candidates, hunk) + end + end + end + + if #candidates == 0 then + return + end + + for i = #candidates, 1, -1 do + if candidates[i].start_line < cursor_line then + vim.api.nvim_win_set_cursor(0, { candidates[i].start_line + 1, 0 }) + return + end + end + + 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) + local km = config.keymaps + + local maps = { + { km.ours, '(diffs-merge-ours)' }, + { km.theirs, '(diffs-merge-theirs)' }, + { km.both, '(diffs-merge-both)' }, + { km.none, '(diffs-merge-none)' }, + { km.next, '(diffs-merge-next)' }, + { km.prev, '(diffs-merge-prev)' }, + } + + for _, map in ipairs(maps) do + if map[1] then + vim.keymap.set('n', map[1], map[2], { buffer = bufnr }) + end + end + + vim.api.nvim_create_autocmd('BufWipeout', { + buffer = bufnr, + callback = function() + resolved_hunks[bufnr] = nil + end, + }) +end + +---@return integer +function M.get_namespace() + return ns +end + +return M diff --git a/plugin/diffs.lua b/plugin/diffs.lua index 5d3c8b2..face556 100644 --- a/plugin/diffs.lua +++ b/plugin/diffs.lua @@ -82,3 +82,28 @@ end, { desc = 'Jump to next conflict' }) vim.keymap.set('n', '(diffs-conflict-prev)', function() require('diffs.conflict').goto_prev(vim.api.nvim_get_current_buf()) end, { desc = 'Jump to previous conflict' }) + +local function merge_action(fn) + local bufnr = vim.api.nvim_get_current_buf() + local config = require('diffs').get_conflict_config() + fn(bufnr, config) +end + +vim.keymap.set('n', '(diffs-merge-ours)', function() + merge_action(require('diffs.merge').resolve_ours) +end, { desc = 'Accept ours in merge diff' }) +vim.keymap.set('n', '(diffs-merge-theirs)', function() + merge_action(require('diffs.merge').resolve_theirs) +end, { desc = 'Accept theirs in merge diff' }) +vim.keymap.set('n', '(diffs-merge-both)', function() + merge_action(require('diffs.merge').resolve_both) +end, { desc = 'Accept both in merge diff' }) +vim.keymap.set('n', '(diffs-merge-none)', function() + merge_action(require('diffs.merge').resolve_none) +end, { desc = 'Reject both in merge diff' }) +vim.keymap.set('n', '(diffs-merge-next)', function() + require('diffs.merge').goto_next(vim.api.nvim_get_current_buf()) +end, { desc = 'Jump to next conflict hunk' }) +vim.keymap.set('n', '(diffs-merge-prev)', function() + require('diffs.merge').goto_prev(vim.api.nvim_get_current_buf()) +end, { desc = 'Jump to previous conflict hunk' }) diff --git a/spec/commands_spec.lua b/spec/commands_spec.lua index daba5d5..1bf9e61 100644 --- a/spec/commands_spec.lua +++ b/spec/commands_spec.lua @@ -40,6 +40,78 @@ describe('commands', function() end) end) + describe('filter_combined_diffs', function() + it('strips diff --cc entries entirely', function() + local lines = { + 'diff --cc main.lua', + 'index d13ab94,b113aee..0000000', + '--- a/main.lua', + '+++ b/main.lua', + '@@@ -1,7 -1,7 +1,11 @@@', + ' local M = {}', + '++<<<<<<< HEAD', + ' + return 1', + '++=======', + '+ return 2', + '++>>>>>>> theirs', + ' end', + } + local result = commands.filter_combined_diffs(lines) + assert.are.equal(0, #result) + end) + + it('preserves diff --git entries', function() + local lines = { + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,3 +1,3 @@', + ' local M = {}', + '-local x = 1', + '+local x = 2', + ' return M', + } + local result = commands.filter_combined_diffs(lines) + assert.are.equal(8, #result) + assert.are.same(lines, result) + end) + + it('strips combined but keeps unified in mixed output', function() + local lines = { + 'diff --cc conflict.lua', + 'index aaa,bbb..000', + '@@@ -1,1 -1,1 +1,5 @@@', + '++<<<<<<< HEAD', + 'diff --git a/clean.lua b/clean.lua', + '--- a/clean.lua', + '+++ b/clean.lua', + '@@ -1,1 +1,1 @@', + '-old', + '+new', + } + local result = commands.filter_combined_diffs(lines) + assert.are.equal(6, #result) + assert.are.equal('diff --git a/clean.lua b/clean.lua', result[1]) + assert.are.equal('+new', result[6]) + end) + + it('returns empty for empty input', function() + local result = commands.filter_combined_diffs({}) + assert.are.equal(0, #result) + end) + + it('returns empty when all entries are combined', function() + local lines = { + 'diff --cc a.lua', + 'some content', + 'diff --cc b.lua', + 'more content', + } + local result = commands.filter_combined_diffs(lines) + assert.are.equal(0, #result) + end) + end) + describe('find_hunk_line', function() it('finds matching @@ header and returns target line', function() local diff_lines = { diff --git a/spec/merge_spec.lua b/spec/merge_spec.lua new file mode 100644 index 0000000..2f788a6 --- /dev/null +++ b/spec/merge_spec.lua @@ -0,0 +1,664 @@ +local helpers = require('spec.helpers') +local merge = require('diffs.merge') + +local function default_config(overrides) + local cfg = { + enabled = true, + disable_diagnostics = false, + show_virtual_text = true, + keymaps = { + ours = 'doo', + theirs = 'dot', + both = 'dob', + none = 'don', + next = ']x', + prev = '[x', + }, + } + if overrides then + cfg = vim.tbl_deep_extend('force', cfg, overrides) + end + return cfg +end + +local function create_diff_buffer(lines, working_path) + local bufnr = helpers.create_buffer(lines) + if working_path then + vim.api.nvim_buf_set_var(bufnr, 'diffs_working_path', working_path) + end + return bufnr +end + +local function create_working_buffer(lines, name) + local bufnr = vim.api.nvim_create_buf(true, false) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + if name then + vim.api.nvim_buf_set_name(bufnr, name) + end + return bufnr +end + +describe('merge', function() + describe('parse_hunks', function() + it('parses a single hunk', function() + local bufnr = helpers.create_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,3 +1,3 @@', + ' local M = {}', + '-local x = 1', + '+local x = 2', + ' return M', + }) + + local hunks = merge.parse_hunks(bufnr) + assert.are.equal(1, #hunks) + assert.are.equal(3, hunks[1].start_line) + assert.are.equal(7, hunks[1].end_line) + assert.are.same({ 'local x = 1' }, hunks[1].del_lines) + assert.are.same({ 'local x = 2' }, hunks[1].add_lines) + + helpers.delete_buffer(bufnr) + end) + + it('parses multiple hunks', function() + local bufnr = helpers.create_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,3 +1,3 @@', + ' local M = {}', + '-local x = 1', + '+local x = 2', + ' return M', + '@@ -10,3 +10,3 @@', + ' function M.foo()', + '- return 1', + '+ return 2', + ' end', + }) + + local hunks = merge.parse_hunks(bufnr) + assert.are.equal(2, #hunks) + assert.are.equal(3, hunks[1].start_line) + assert.are.equal(8, hunks[2].start_line) + + helpers.delete_buffer(bufnr) + end) + + it('parses add-only hunk', function() + local bufnr = helpers.create_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,2 +1,3 @@', + ' local M = {}', + '+local new = true', + ' return M', + }) + + local hunks = merge.parse_hunks(bufnr) + assert.are.equal(1, #hunks) + assert.are.same({}, hunks[1].del_lines) + assert.are.same({ 'local new = true' }, hunks[1].add_lines) + + helpers.delete_buffer(bufnr) + end) + + it('parses delete-only hunk', function() + local bufnr = helpers.create_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + '@@ -1,3 +1,2 @@', + ' local M = {}', + '-local old = false', + ' return M', + }) + + local hunks = merge.parse_hunks(bufnr) + assert.are.equal(1, #hunks) + assert.are.same({ 'local old = false' }, hunks[1].del_lines) + assert.are.same({}, hunks[1].add_lines) + + helpers.delete_buffer(bufnr) + end) + + it('returns empty for buffer with no hunks', function() + local bufnr = helpers.create_buffer({ + 'diff --git a/file.lua b/file.lua', + '--- a/file.lua', + '+++ b/file.lua', + }) + + local hunks = merge.parse_hunks(bufnr) + assert.are.equal(0, #hunks) + + helpers.delete_buffer(bufnr) + end) + end) + + describe('match_hunk_to_conflict', function() + it('matches hunk to conflict region', function() + local working_bufnr = create_working_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }, '/tmp/diffs_test_match.lua') + + local hunk = { + index = 1, + start_line = 3, + end_line = 7, + del_lines = { 'local x = 1' }, + add_lines = { 'local x = 2' }, + } + + local region = merge.match_hunk_to_conflict(hunk, working_bufnr) + assert.is_not_nil(region) + assert.are.equal(0, region.marker_ours) + + helpers.delete_buffer(working_bufnr) + end) + + it('returns nil for auto-merged content', function() + local working_bufnr = create_working_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }, '/tmp/diffs_test_auto.lua') + + local hunk = { + index = 1, + start_line = 3, + end_line = 7, + del_lines = { 'local y = 3' }, + add_lines = { 'local y = 4' }, + } + + local region = merge.match_hunk_to_conflict(hunk, working_bufnr) + assert.is_nil(region) + + helpers.delete_buffer(working_bufnr) + end) + + it('matches with empty ours section', function() + local working_bufnr = create_working_buffer({ + '<<<<<<< HEAD', + '=======', + 'local x = 2', + '>>>>>>> feature', + }, '/tmp/diffs_test_empty_ours.lua') + + local hunk = { + index = 1, + start_line = 3, + end_line = 5, + del_lines = {}, + add_lines = { 'local x = 2' }, + } + + local region = merge.match_hunk_to_conflict(hunk, working_bufnr) + assert.is_not_nil(region) + + helpers.delete_buffer(working_bufnr) + end) + + it('matches correct region among multiple conflicts', function() + local working_bufnr = create_working_buffer({ + '<<<<<<< HEAD', + 'local a = 1', + '=======', + 'local a = 2', + '>>>>>>> feature', + 'middle', + '<<<<<<< HEAD', + 'local b = 3', + '=======', + 'local b = 4', + '>>>>>>> feature', + }, '/tmp/diffs_test_multi.lua') + + local hunk = { + index = 2, + start_line = 8, + end_line = 12, + del_lines = { 'local b = 3' }, + add_lines = { 'local b = 4' }, + } + + local region = merge.match_hunk_to_conflict(hunk, working_bufnr) + assert.is_not_nil(region) + assert.are.equal(6, region.marker_ours) + + helpers.delete_buffer(working_bufnr) + end) + + it('matches with diff3 format', function() + local working_bufnr = create_working_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '||||||| base', + 'local x = 0', + '=======', + 'local x = 2', + '>>>>>>> feature', + }, '/tmp/diffs_test_diff3.lua') + + local hunk = { + index = 1, + start_line = 3, + end_line = 7, + del_lines = { 'local x = 1' }, + add_lines = { 'local x = 2' }, + } + + local region = merge.match_hunk_to_conflict(hunk, working_bufnr) + assert.is_not_nil(region) + assert.are.equal(2, region.marker_base) + + helpers.delete_buffer(working_bufnr) + end) + end) + + describe('resolution', function() + local diff_bufnr, working_bufnr + + local function setup_buffers() + local working_path = '/tmp/diffs_test_resolve.lua' + working_bufnr = create_working_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }, working_path) + + diff_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(diff_bufnr) + end + + local function cleanup() + helpers.delete_buffer(diff_bufnr) + helpers.delete_buffer(working_bufnr) + end + + it('resolve_ours keeps ours content in working file', function() + setup_buffers() + vim.api.nvim_win_set_cursor(0, { 5, 0 }) + + merge.resolve_ours(diff_bufnr, default_config()) + + local lines = vim.api.nvim_buf_get_lines(working_bufnr, 0, -1, false) + assert.are.equal(1, #lines) + assert.are.equal('local x = 1', lines[1]) + + cleanup() + end) + + it('resolve_theirs keeps theirs content in working file', function() + setup_buffers() + vim.api.nvim_win_set_cursor(0, { 5, 0 }) + + merge.resolve_theirs(diff_bufnr, default_config()) + + local lines = vim.api.nvim_buf_get_lines(working_bufnr, 0, -1, false) + assert.are.equal(1, #lines) + assert.are.equal('local x = 2', lines[1]) + + cleanup() + end) + + it('resolve_both keeps ours then theirs in working file', function() + setup_buffers() + vim.api.nvim_win_set_cursor(0, { 5, 0 }) + + merge.resolve_both(diff_bufnr, default_config()) + + local lines = vim.api.nvim_buf_get_lines(working_bufnr, 0, -1, false) + assert.are.equal(2, #lines) + assert.are.equal('local x = 1', lines[1]) + assert.are.equal('local x = 2', lines[2]) + + cleanup() + end) + + it('resolve_none removes entire block from working file', function() + setup_buffers() + vim.api.nvim_win_set_cursor(0, { 5, 0 }) + + merge.resolve_none(diff_bufnr, default_config()) + + local lines = vim.api.nvim_buf_get_lines(working_bufnr, 0, -1, false) + assert.are.equal(1, #lines) + assert.are.equal('', lines[1]) + + cleanup() + end) + + it('tracks resolved hunks', function() + setup_buffers() + vim.api.nvim_win_set_cursor(0, { 5, 0 }) + + assert.is_false(merge.is_resolved(diff_bufnr, 1)) + merge.resolve_ours(diff_bufnr, default_config()) + assert.is_true(merge.is_resolved(diff_bufnr, 1)) + + cleanup() + end) + + it('adds virtual text for resolved hunks', function() + setup_buffers() + vim.api.nvim_win_set_cursor(0, { 5, 0 }) + + merge.resolve_ours(diff_bufnr, default_config()) + + local extmarks = + vim.api.nvim_buf_get_extmarks(diff_bufnr, merge.get_namespace(), 0, -1, { details = true }) + local has_resolved_text = false + for _, mark in ipairs(extmarks) do + if mark[4] and mark[4].virt_text then + for _, chunk in ipairs(mark[4].virt_text) do + if chunk[1]:match('resolved') then + has_resolved_text = true + end + end + end + end + assert.is_true(has_resolved_text) + + cleanup() + end) + + it('notifies when hunk is already resolved', function() + setup_buffers() + vim.api.nvim_win_set_cursor(0, { 5, 0 }) + + merge.resolve_ours(diff_bufnr, default_config()) + + local notified = false + local orig_notify = vim.notify + vim.notify = function(msg) + if msg:match('already resolved') then + notified = true + end + end + + merge.resolve_ours(diff_bufnr, default_config()) + vim.notify = orig_notify + + assert.is_true(notified) + + cleanup() + end) + + it('notifies when hunk does not match a conflict', function() + local working_path = '/tmp/diffs_test_no_conflict.lua' + local w_bufnr = create_working_buffer({ + 'local y = 1', + }, 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 notified = false + local orig_notify = vim.notify + vim.notify = function(msg) + if msg:match('does not correspond') then + notified = true + end + end + + merge.resolve_ours(d_bufnr, default_config()) + vim.notify = orig_notify + + assert.is_true(notified) + + helpers.delete_buffer(d_bufnr) + helpers.delete_buffer(w_bufnr) + end) + end) + + describe('navigation', function() + it('goto_next jumps to next conflict hunk', function() + local working_path = '/tmp/diffs_test_nav.lua' + local w_bufnr = create_working_buffer({ + '<<<<<<< HEAD', + 'local a = 1', + '=======', + 'local a = 2', + '>>>>>>> feature', + 'middle', + '<<<<<<< HEAD', + 'local b = 3', + '=======', + 'local b = 4', + '>>>>>>> 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 a = 1', + '+local a = 2', + '@@ -5,1 +5,1 @@', + '-local b = 3', + '+local b = 4', + }, working_path) + vim.api.nvim_set_current_buf(d_bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + + merge.goto_next(d_bufnr) + assert.are.equal(4, vim.api.nvim_win_get_cursor(0)[1]) + + merge.goto_next(d_bufnr) + assert.are.equal(7, vim.api.nvim_win_get_cursor(0)[1]) + + helpers.delete_buffer(d_bufnr) + helpers.delete_buffer(w_bufnr) + end) + + it('goto_next wraps around', function() + local working_path = '/tmp/diffs_test_wrap.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 }) + + merge.goto_next(d_bufnr) + assert.are.equal(4, vim.api.nvim_win_get_cursor(0)[1]) + + 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({ + '<<<<<<< HEAD', + 'local a = 1', + '=======', + 'local a = 2', + '>>>>>>> feature', + 'middle', + '<<<<<<< HEAD', + 'local b = 3', + '=======', + 'local b = 4', + '>>>>>>> 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 a = 1', + '+local a = 2', + '@@ -5,1 +5,1 @@', + '-local b = 3', + '+local b = 4', + }, working_path) + vim.api.nvim_set_current_buf(d_bufnr) + vim.api.nvim_win_set_cursor(0, { 9, 0 }) + + merge.goto_prev(d_bufnr) + assert.are.equal(7, vim.api.nvim_win_get_cursor(0)[1]) + + merge.goto_prev(d_bufnr) + assert.are.equal(4, vim.api.nvim_win_get_cursor(0)[1]) + + helpers.delete_buffer(d_bufnr) + helpers.delete_buffer(w_bufnr) + end) + + it('goto_prev wraps around', function() + local working_path = '/tmp/diffs_test_prev_wrap.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 }) + + merge.goto_prev(d_bufnr) + assert.are.equal(4, vim.api.nvim_win_get_cursor(0)[1]) + + 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({ + '<<<<<<< HEAD', + 'local a = 1', + '=======', + 'local a = 2', + '>>>>>>> feature', + 'middle', + '<<<<<<< HEAD', + 'local b = 3', + '=======', + 'local b = 4', + '>>>>>>> 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 a = 1', + '+local a = 2', + '@@ -5,1 +5,1 @@', + '-local b = 3', + '+local b = 4', + }, working_path) + vim.api.nvim_set_current_buf(d_bufnr) + vim.api.nvim_win_set_cursor(0, { 5, 0 }) + + merge.resolve_ours(d_bufnr, default_config()) + + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + merge.goto_next(d_bufnr) + assert.are.equal(7, vim.api.nvim_win_get_cursor(0)[1]) + + 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') + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { + 'Unstaged (1)', + 'U conflict.lua', + }) + local filename, section, is_header, old_filename, status = fugitive.get_file_at_line(buf, 2) + assert.are.equal('conflict.lua', filename) + assert.are.equal('unstaged', section) + assert.is_false(is_header) + assert.is_nil(old_filename) + assert.are.equal('U', status) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('parse_file_line returns status for modified files', function() + local fugitive = require('diffs.fugitive') + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { + 'Unstaged (1)', + 'M file.lua', + }) + local _, _, _, _, status = fugitive.get_file_at_line(buf, 2) + assert.are.equal('M', status) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + + it('walkback from hunk line propagates status', function() + local fugitive = require('diffs.fugitive') + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { + 'Unstaged (1)', + 'U conflict.lua', + '@@ -1,3 +1,4 @@', + ' local M = {}', + '+local new = true', + }) + local _, _, _, _, status = fugitive.get_file_at_line(buf, 5) + assert.are.equal('U', status) + vim.api.nvim_buf_delete(buf, { force = true }) + end) + end) +end) From f5a090baaea8aca3310b9f1081e7077ae14c94e7 Mon Sep 17 00:00:00 2001 From: Barrett Ruth <62671086+barrettruth@users.noreply.github.com> Date: Mon, 9 Feb 2026 12:39:13 -0500 Subject: [PATCH 4/8] perf: cache repo root and harden async paths (#100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem `get_repo_root()` shells out to `git rev-parse` on every call, causing 4-6 redundant subprocesses per `gdiff_file()` invocation. Three other minor issues: `highlight_vim_syntax()` leaks a scratch buffer if `nvim_buf_call` errors, `lib.ensure()` silently drops callbacks during download so hunks highlighted mid-download permanently miss intra-line highlights, and the debounce timer callback can operate on a deleted buffer. ## Solution Cache `get_repo_root()` results by parent directory — repo roots don't change within a session. Wrap `nvim_buf_call` and `nvim_buf_delete` in pcall so the scratch buffer is always cleaned up. Replace the early `callback(nil)` in `lib.ensure()` with a pending callback queue that fires once the download completes. Guard the debounce timer callback with `nvim_buf_is_valid`. --- lua/diffs/git.lua | 6 ++++++ lua/diffs/highlight.lua | 4 ++-- lua/diffs/init.lua | 4 +++- lua/diffs/lib.lua | 30 +++++++++++++++++++----------- 4 files changed, 30 insertions(+), 14 deletions(-) diff --git a/lua/diffs/git.lua b/lua/diffs/git.lua index 7695283..1aa6328 100644 --- a/lua/diffs/git.lua +++ b/lua/diffs/git.lua @@ -1,13 +1,19 @@ local M = {} +local repo_root_cache = {} + ---@param filepath string ---@return string? function M.get_repo_root(filepath) local dir = vim.fn.fnamemodify(filepath, ':h') + if repo_root_cache[dir] ~= nil then + return repo_root_cache[dir] + end local result = vim.fn.systemlist({ 'git', '-C', dir, 'rev-parse', '--show-toplevel' }) if vim.v.shell_error ~= 0 then return nil end + repo_root_cache[dir] = result[1] return result[1] end diff --git a/lua/diffs/highlight.lua b/lua/diffs/highlight.lua index 306b0e5..6cdfe92 100644 --- a/lua/diffs/highlight.lua +++ b/lua/diffs/highlight.lua @@ -238,7 +238,7 @@ local function highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, local spans = {} - vim.api.nvim_buf_call(scratch, function() + pcall(vim.api.nvim_buf_call, scratch, function() vim.cmd('setlocal syntax=' .. ft) vim.cmd('redraw') @@ -256,7 +256,7 @@ local function highlight_vim_syntax(bufnr, ns, hunk, code_lines, covered_lines, spans = M.coalesce_syntax_spans(query_fn, code_lines) end) - vim.api.nvim_buf_delete(scratch, { force = true }) + pcall(vim.api.nvim_buf_delete, scratch, { force = true }) local hunk_line_count = #hunk.lines local extmark_count = 0 diff --git a/lua/diffs/init.lua b/lua/diffs/init.lua index cdc29d1..69a5cf9 100644 --- a/lua/diffs/init.lua +++ b/lua/diffs/init.lua @@ -200,7 +200,9 @@ local function create_debounced_highlight(bufnr) timer = nil t:close() end - highlight_buffer(bufnr) + if vim.api.nvim_buf_is_valid(bufnr) then + highlight_buffer(bufnr) + end end) ) end diff --git a/lua/diffs/lib.lua b/lua/diffs/lib.lua index 5b3254b..8376925 100644 --- a/lua/diffs/lib.lua +++ b/lua/diffs/lib.lua @@ -8,6 +8,9 @@ local cached_handle = nil ---@type boolean local download_in_progress = false +---@type fun(handle: table?)[] +local pending_callbacks = {} + ---@return string local function get_os() local os_name = jit.os:lower() @@ -164,9 +167,10 @@ function M.ensure(callback) return end + table.insert(pending_callbacks, callback) + if download_in_progress then - dbg('download already in progress') - callback(nil) + dbg('download already in progress, queued callback') return end @@ -192,21 +196,25 @@ function M.ensure(callback) vim.system(cmd, {}, function(result) download_in_progress = false vim.schedule(function() + local handle = nil if result.code ~= 0 then vim.notify('[diffs] failed to download libvscode_diff', vim.log.levels.WARN) dbg('curl failed: %s', result.stderr or '') - callback(nil) - return + else + local f = io.open(version_path(), 'w') + if f then + f:write(EXPECTED_VERSION) + f:close() + end + vim.notify('[diffs] libvscode_diff downloaded', vim.log.levels.INFO) + handle = M.load() end - local f = io.open(version_path(), 'w') - if f then - f:write(EXPECTED_VERSION) - f:close() + local cbs = pending_callbacks + pending_callbacks = {} + for _, cb in ipairs(cbs) do + cb(handle) end - - vim.notify('[diffs] libvscode_diff downloaded', vim.log.levels.INFO) - callback(M.load()) end) end) end From 603c966c71c71000ac4ddd3ac691b16ef3e7be3c Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Mon, 9 Feb 2026 13:53:02 -0500 Subject: [PATCH 5/8] 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 6/8] 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 7/8] 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 8/8] 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')