Compare commits

..

5 commits

Author SHA1 Message Date
f3a72926d2 doc: add plug mappings for merge conflict resolution 2026-02-08 16:28:18 -05:00
Barrett Ruth
669cca53ae
Merge pull request #96 from barrettruth/feat/conflict
feat(conflict): detect and resolve inline merge conflict markers
2026-02-08 15:23:30 -05:00
a192830d8c 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.
2026-02-07 19:47:45 -05:00
35cb13419c 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.
2026-02-07 19:42:29 -05:00
bae86c5fd9 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)".
2026-02-07 17:58:51 -05:00
5 changed files with 122 additions and 47 deletions

View file

@ -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

View file

@ -280,6 +280,41 @@ Example configuration: >lua
vim.keymap.set('n', '<leader>gD', '<Plug>(diffs-gvdiff)')
<
*<Plug>(diffs-conflict-ours)*
<Plug>(diffs-conflict-ours)
Accept current (ours) change. Replaces the
conflict block with ours content.
*<Plug>(diffs-conflict-theirs)*
<Plug>(diffs-conflict-theirs)
Accept incoming (theirs) change. Replaces the
conflict block with theirs content.
*<Plug>(diffs-conflict-both)*
<Plug>(diffs-conflict-both)
Accept both changes (ours then theirs).
*<Plug>(diffs-conflict-none)*
<Plug>(diffs-conflict-none)
Reject both changes (delete entire block).
*<Plug>(diffs-conflict-next)*
<Plug>(diffs-conflict-next)
Jump to next conflict marker. Wraps around.
*<Plug>(diffs-conflict-prev)*
<Plug>(diffs-conflict-prev)
Jump to previous conflict marker. Wraps around.
Example configuration: >lua
vim.keymap.set('n', 'co', '<Plug>(diffs-conflict-ours)')
vim.keymap.set('n', 'ct', '<Plug>(diffs-conflict-theirs)')
vim.keymap.set('n', 'cb', '<Plug>(diffs-conflict-both)')
vim.keymap.set('n', 'cn', '<Plug>(diffs-conflict-none)')
vim.keymap.set('n', ']x', '<Plug>(diffs-conflict-next)')
vim.keymap.set('n', '[x', '<Plug>(diffs-conflict-prev)')
<
Diff buffer mappings: ~
*diffs-q*
q Close the diff window. Available in all `diffs://`

View file

@ -107,15 +107,8 @@ 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 = { { ' ' .. ours_name .. ' (current)', 'DiffsConflictMarker' } },
virt_text = { { ' (current)', 'DiffsConflictMarker' } },
virt_text_pos = 'eol',
})
end
@ -183,15 +176,8 @@ 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 = { { ' ' .. theirs_name .. ' (incoming)', 'DiffsConflictMarker' } },
virt_text = { { ' (incoming)', 'DiffsConflictMarker' } },
virt_text_pos = 'eol',
})
end
@ -228,11 +214,20 @@ 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.reset, nil, bufnr)
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
@ -353,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, '<Plug>(diffs-conflict-ours)' },
{ km.theirs, '<Plug>(diffs-conflict-theirs)' },
{ km.both, '<Plug>(diffs-conflict-both)' },
{ km.none, '<Plug>(diffs-conflict-none)' },
{ km.next, '<Plug>(diffs-conflict-next)' },
{ km.prev, '<Plug>(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
@ -391,6 +370,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

View file

@ -57,3 +57,28 @@ end, { desc = 'Unified diff (horizontal)' })
vim.keymap.set('n', '<Plug>(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', '<Plug>(diffs-conflict-ours)', function()
conflict_action(require('diffs.conflict').resolve_ours)
end, { desc = 'Accept current (ours) change' })
vim.keymap.set('n', '<Plug>(diffs-conflict-theirs)', function()
conflict_action(require('diffs.conflict').resolve_theirs)
end, { desc = 'Accept incoming (theirs) change' })
vim.keymap.set('n', '<Plug>(diffs-conflict-both)', function()
conflict_action(require('diffs.conflict').resolve_both)
end, { desc = 'Accept both changes' })
vim.keymap.set('n', '<Plug>(diffs-conflict-none)', function()
conflict_action(require('diffs.conflict').resolve_none)
end, { desc = 'Reject both changes' })
vim.keymap.set('n', '<Plug>(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', '<Plug>(diffs-conflict-prev)', function()
require('diffs.conflict').goto_prev(vim.api.nvim_get_current_buf())
end, { desc = 'Jump to previous conflict' })

View file

@ -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',