From 238df23ee06f41c12cf68348c5f3a3d01c379572 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Feb 2026 17:57:53 -0500 Subject: [PATCH 1/5] feat(conflict): show branch names in virtual text labels Problem: virtual text showed generic "current"/"incoming" labels with no indication of which branch each side came from. Solution: extract the branch name from the marker line itself (e.g. <<<<<<< HEAD, >>>>>>> feature) and display as "HEAD (current)" / "feature (incoming)". --- lua/diffs/conflict.lua | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/lua/diffs/conflict.lua b/lua/diffs/conflict.lua index 52397fe..84cc2fa 100644 --- a/lua/diffs/conflict.lua +++ b/lua/diffs/conflict.lua @@ -107,8 +107,15 @@ local function apply_highlights(bufnr, regions, config) }) if config.show_virtual_text then + local ours_line = vim.api.nvim_buf_get_lines( + bufnr, + region.marker_ours, + region.marker_ours + 1, + false + )[1] or '' + local ours_name = ours_line:match('^<<<<<<<%s+(.+)$') or '' pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_ours, 0, { - virt_text = { { ' current', 'DiffsConflictMarker' } }, + virt_text = { { ' ' .. ours_name .. ' (current)', 'DiffsConflictMarker' } }, virt_text_pos = 'eol', }) end @@ -176,8 +183,15 @@ local function apply_highlights(bufnr, regions, config) }) if config.show_virtual_text then + local theirs_line = vim.api.nvim_buf_get_lines( + bufnr, + region.marker_theirs, + region.marker_theirs + 1, + false + )[1] or '' + local theirs_name = theirs_line:match('^>>>>>>>%s+(.+)$') or '' pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_theirs, 0, { - virt_text = { { ' incoming', 'DiffsConflictMarker' } }, + virt_text = { { ' ' .. theirs_name .. ' (incoming)', 'DiffsConflictMarker' } }, virt_text_pos = 'eol', }) end From bae86c5fd9ef148b1ba4b8f13a22dc9ba12c3bda Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Feb 2026 17:57:53 -0500 Subject: [PATCH 2/5] feat(conflict): show branch names in virtual text labels Problem: virtual text showed generic "current"/"incoming" labels with no indication of which branch each side came from. Solution: extract the branch name from the marker line itself (e.g. <<<<<<< HEAD, >>>>>>> feature) and display as "HEAD (current)" / "feature (incoming)". --- lua/diffs/conflict.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lua/diffs/conflict.lua b/lua/diffs/conflict.lua index 52397fe..6522026 100644 --- a/lua/diffs/conflict.lua +++ b/lua/diffs/conflict.lua @@ -108,7 +108,7 @@ local function apply_highlights(bufnr, regions, config) if config.show_virtual_text then pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_ours, 0, { - virt_text = { { ' current', 'DiffsConflictMarker' } }, + virt_text = { { ' (current)', 'DiffsConflictMarker' } }, virt_text_pos = 'eol', }) end @@ -177,7 +177,7 @@ local function apply_highlights(bufnr, regions, config) if config.show_virtual_text then pcall(vim.api.nvim_buf_set_extmark, bufnr, ns, region.marker_theirs, 0, { - virt_text = { { ' incoming', 'DiffsConflictMarker' } }, + virt_text = { { ' (incoming)', 'DiffsConflictMarker' } }, virt_text_pos = 'eol', }) end From 35cb13419ce4d092ec65ba4782c8fa363d09c6af Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Feb 2026 19:42:29 -0500 Subject: [PATCH 3/5] fix(conflict): keep TextChanged autocmd alive after resolution Problem: resolving the last conflict called M.detach(), which cleared attached_buffers[bufnr]. The TextChanged callback then returned true, permanently deleting the autocmd. Undo restored conflict markers but nothing re-highlighted or re-suppressed diagnostics. Solution: inline the cleanup in refresh() instead of calling detach(). Keep attached_buffers set so the autocmd survives. Re-suppress diagnostics when conflicts reappear after undo. --- lua/diffs/conflict.lua | 10 +++++++++- spec/conflict_spec.lua | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/lua/diffs/conflict.lua b/lua/diffs/conflict.lua index 6522026..f0e47c6 100644 --- a/lua/diffs/conflict.lua +++ b/lua/diffs/conflict.lua @@ -214,11 +214,19 @@ end local function refresh(bufnr, config) local regions = parse_buffer(bufnr) if #regions == 0 then - M.detach(bufnr) + vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) + if diagnostics_suppressed[bufnr] then + pcall(vim.diagnostic.enable, true, { bufnr = bufnr }) + diagnostics_suppressed[bufnr] = nil + end vim.api.nvim_exec_autocmds('User', { pattern = 'DiffsConflictResolved' }) return end apply_highlights(bufnr, regions, config) + if config.disable_diagnostics and not diagnostics_suppressed[bufnr] then + pcall(vim.diagnostic.enable, false, { bufnr = bufnr }) + diagnostics_suppressed[bufnr] = true + end end ---@param bufnr integer diff --git a/spec/conflict_spec.lua b/spec/conflict_spec.lua index 128c567..75eac23 100644 --- a/spec/conflict_spec.lua +++ b/spec/conflict_spec.lua @@ -631,6 +631,39 @@ describe('conflict', function() helpers.delete_buffer(bufnr) end) + it('re-highlights when markers return after resolution', function() + local bufnr = create_file_buffer({ + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }) + vim.api.nvim_set_current_buf(bufnr) + local cfg = default_config() + conflict.attach(bufnr, cfg) + + assert.is_true(#get_extmarks(bufnr) > 0) + + vim.api.nvim_win_set_cursor(0, { 2, 0 }) + conflict.resolve_ours(bufnr, cfg) + assert.are.equal(0, #get_extmarks(bufnr)) + + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { + '<<<<<<< HEAD', + 'local x = 1', + '=======', + 'local x = 2', + '>>>>>>> feature', + }) + vim.api.nvim_exec_autocmds('TextChanged', { buffer = bufnr }) + + assert.is_true(#get_extmarks(bufnr) > 0) + + conflict.detach(bufnr) + helpers.delete_buffer(bufnr) + end) + it('detaches after last conflict resolved', function() local bufnr = create_file_buffer({ '<<<<<<< HEAD', From a192830d8c886ba65758fd092daf58733b6213fa Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sat, 7 Feb 2026 19:47:32 -0500 Subject: [PATCH 4/5] fix(conflict): clear stale diagnostics before re-enabling Problem: after resolving all conflicts, vim.diagnostic.enable(true) restored diagnostics that were cached while markers were present, showing errors like "unexpected token end" on clean code. Solution: call vim.diagnostic.reset() before re-enabling to flush stale results and let the LSP re-analyze the resolved buffer. --- README.md | 4 +++- lua/diffs/conflict.lua | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ca3d72e..f0a04a8 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ syntax highlighting. - Vim syntax fallback for languages without a treesitter parser - Hunk header context highlighting (`@@ ... @@ function foo()`) - Character-level (intra-line) diff highlighting for changed characters +- Inline merge conflict detection, highlighting, and resolution keymaps - Configurable debouncing, max lines, diff prefix concealment, blend alpha, and highlight overrides @@ -63,7 +64,8 @@ luarocks install diffs.nvim compatible, but both plugins modifying line highlights may produce unexpected results - [`git-conflict.nvim`](https://github.com/akinsho/git-conflict.nvim) - - conflict marker highlighting may overlap with `diffs.nvim` + `diffs.nvim` now includes built-in conflict resolution; disable one or the + other to avoid overlap # Acknowledgements diff --git a/lua/diffs/conflict.lua b/lua/diffs/conflict.lua index f0e47c6..f4468d2 100644 --- a/lua/diffs/conflict.lua +++ b/lua/diffs/conflict.lua @@ -216,6 +216,7 @@ local function refresh(bufnr, config) if #regions == 0 then vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) if diagnostics_suppressed[bufnr] then + pcall(vim.diagnostic.reset, nil, bufnr) pcall(vim.diagnostic.enable, true, { bufnr = bufnr }) diagnostics_suppressed[bufnr] = nil end @@ -385,6 +386,7 @@ function M.detach(bufnr) attached_buffers[bufnr] = nil if diagnostics_suppressed[bufnr] then + pcall(vim.diagnostic.reset, nil, bufnr) pcall(vim.diagnostic.enable, true, { bufnr = bufnr }) diagnostics_suppressed[bufnr] = nil end From f3a72926d288aaf03c4e212e9f82a006fb300320 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 8 Feb 2026 16:28:18 -0500 Subject: [PATCH 5/5] 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' })