From 7781904d65533e705add0e8b9fb20c56fc453df9 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 8 Feb 2026 17:48:15 -0500 Subject: [PATCH] feat: add unified diff conflict resolution for unmerged files Problem: pressing du on a UU file in fugitive status fell through to the unstaged path, where get_index_content(:0:) fails because unmerged files have no stage 0 entry. The fallback produced a useless diff of HEAD vs working file with conflict markers shown as changes. Solution: add a merge.lua module that diffs git show :2: (ours) vs :3: (theirs), displays the result with full syntax and intra-line highlighting, and provides resolution keymaps (doo/dot/dob/don/]x/[x) that write back to the working file's conflict markers. Hunks are matched to conflict regions by comparing diff del-lines against each region's ours content. Resolved hunks are tracked per-buffer with virtual text. commands.lua gains an unmerged branch in gdiff_file and read_buffer, and plugin/diffs.lua registers Plug(diffs-merge-*) mappings. --- lua/diffs/commands.lua | 26 ++- lua/diffs/merge.lua | 392 +++++++++++++++++++++++++++++++++++++++++ plugin/diffs.lua | 25 +++ 3 files changed, 441 insertions(+), 2 deletions(-) create mode 100644 lua/diffs/merge.lua diff --git a/lua/diffs/commands.lua b/lua/diffs/commands.lua index dc6cfe2..2a2abbb 100644 --- a/lua/diffs/commands.lua +++ b/lua/diffs/commands.lua @@ -138,6 +138,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 +158,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 +247,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() @@ -334,7 +353,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/merge.lua b/lua/diffs/merge.lua new file mode 100644 index 0000000..4aedf96 --- /dev/null +++ b/lua/diffs/merge.lua @@ -0,0 +1,392 @@ +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 diff_bufnr integer +---@param hunk diffs.MergeHunkInfo +---@param working_bufnr integer +---@param replacement string[] +---@param config diffs.ConflictConfig +local function do_resolve(diff_bufnr, hunk, working_bufnr, replacement, config) + 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, replacement) + conflict.refresh(working_bufnr, config) + mark_resolved(diff_bufnr, hunk.index) + add_resolved_virtual_text(diff_bufnr, hunk) +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' })