From 8716fb7d58cc80240379ac4e3b10f6a5903cc87f Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Sun, 8 Feb 2026 17:49:09 -0500 Subject: [PATCH] test(merge): add merge module tests --- spec/merge_spec.lua | 665 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 665 insertions(+) create mode 100644 spec/merge_spec.lua diff --git a/spec/merge_spec.lua b/spec/merge_spec.lua new file mode 100644 index 0000000..dfa14a8 --- /dev/null +++ b/spec/merge_spec.lua @@ -0,0 +1,665 @@ +local conflict = require('diffs.conflict') +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)