diff --git a/lua/glance/merge/model.lua b/lua/glance/merge/model.lua index 39d6588..4b435df 100644 --- a/lua/glance/merge/model.lua +++ b/lua/glance/merge/model.lua @@ -444,6 +444,7 @@ end local function collect_outcomes_strict(conflict, current_lines, cursor, next_stable, is_last, opts) local outcomes = {} local seen = {} + local manual_state = manual_clean_state(opts) local function add_outcome(outcome, next_cursor) local key = outcome_key(outcome, next_cursor) @@ -458,11 +459,11 @@ local function collect_outcomes_strict(conflict, current_lines, cursor, next_sta local block, block_next = parse_conflict_block(current_lines, cursor) if block then - add_outcome(exact_marker_outcome(conflict, block) or { - state = 'manual_unresolved', - current_lines = block.full_lines, - kind = 'marker', - display_lines = conflict.base_lines, + add_outcome(exact_marker_outcome(conflict, block) or { + state = 'manual_unresolved', + current_lines = block.full_lines, + kind = 'marker', + display_lines = conflict.base_lines, ours_handled = false, theirs_handled = false, }, block_next) @@ -470,18 +471,10 @@ local function collect_outcomes_strict(conflict, current_lines, cursor, next_sta for _, candidate in ipairs(clean_candidates(conflict)) do if same_lines_at(current_lines, cursor, candidate.lines) then - add_outcome({ - state = candidate.state, - current_lines = candidate.lines, - kind = 'clean', - display_lines = candidate.lines, - ours_handled = true, - theirs_handled = true, - }, cursor + #candidate.lines) + add_outcome(manual_outcome(conflict, manual_state, candidate.lines), cursor + #candidate.lines) end end - local manual_state = manual_clean_state(opts) if is_last then if #next_stable == 0 then add_outcome(manual_outcome(conflict, manual_state, slice_lines(current_lines, cursor, #current_lines)), #current_lines + 1) @@ -596,9 +589,10 @@ local function occurrence_key(occurrence) }, ':') end -local function collect_relaxed_occurrences(conflict, current_lines, cursor, marker_ranges) +local function collect_relaxed_occurrences(conflict, current_lines, cursor, marker_ranges, opts) local occurrences = {} local seen = {} + local manual_state = manual_clean_state(opts) local function add_occurrence(occurrence) local key = occurrence_key(occurrence) @@ -641,13 +635,14 @@ local function collect_relaxed_occurrences(conflict, current_lines, cursor, mark for _, candidate in ipairs(clean_candidates(conflict)) do if #candidate.lines == 0 then for position = cursor, #current_lines + 1 do + local outcome = manual_outcome(conflict, manual_state, candidate.lines) add_occurrence({ - state = candidate.state, - current_lines = candidate.lines, - kind = 'clean', - display_lines = candidate.lines, - ours_handled = true, - theirs_handled = true, + state = outcome.state, + current_lines = outcome.current_lines, + kind = outcome.kind, + display_lines = outcome.display_lines, + ours_handled = outcome.ours_handled, + theirs_handled = outcome.theirs_handled, start = position, stop = position - 1, }) @@ -655,13 +650,14 @@ local function collect_relaxed_occurrences(conflict, current_lines, cursor, mark else for _, position in ipairs(find_sequence_positions(current_lines, candidate.lines, cursor)) do if not position_in_ranges(position, marker_ranges) then + local outcome = manual_outcome(conflict, manual_state, candidate.lines) add_occurrence({ - state = candidate.state, - current_lines = candidate.lines, - kind = 'clean', - display_lines = candidate.lines, - ours_handled = true, - theirs_handled = true, + state = outcome.state, + current_lines = outcome.current_lines, + kind = outcome.kind, + display_lines = outcome.display_lines, + ours_handled = outcome.ours_handled, + theirs_handled = outcome.theirs_handled, start = position, stop = position + #candidate.lines - 1, }) @@ -686,7 +682,7 @@ local function collect_relaxed_occurrences(conflict, current_lines, cursor, mark return occurrences end -local function infer_conflict_states_relaxed(canonical_stable_segments, conflicts, current_lines) +local function infer_conflict_states_relaxed(canonical_stable_segments, conflicts, current_lines, opts) local marker_ranges = conflict_marker_ranges(current_lines) local memo = {} @@ -710,7 +706,7 @@ local function infer_conflict_states_relaxed(canonical_stable_segments, conflict local best = nil local expected_stable = canonical_stable_segments[index] or {} - for _, occurrence in ipairs(collect_relaxed_occurrences(conflicts[index], current_lines, cursor, marker_ranges)) do + for _, occurrence in ipairs(collect_relaxed_occurrences(conflicts[index], current_lines, cursor, marker_ranges, opts)) do local tail = solve(index + 1, occurrence.stop + 1) if tail then local stable_before = slice_lines(current_lines, cursor, occurrence.start - 1) @@ -933,7 +929,7 @@ local function build_model(file, opts) local resolved_stable_segments = stable_segments local outcomes = infer_conflict_states_strict(stable_segments, conflicts, current_lines, opts) if not outcomes then - resolved_stable_segments, outcomes = infer_conflict_states_relaxed(stable_segments, conflicts, current_lines) + resolved_stable_segments, outcomes = infer_conflict_states_relaxed(stable_segments, conflicts, current_lines, opts) if stable_segments_contain_conflict_markers(resolved_stable_segments) then resolved_stable_segments = stable_segments outcomes = nil @@ -952,20 +948,21 @@ local function build_model(file, opts) and type(previous_result) == 'table' and same_lines(conflict.current_result_lines or {}, previous_result) then - if previous.state == 'manual_resolved' and conflict.state == 'manual_unresolved' then - conflict.state = 'manual_resolved' - conflict.ours_handled = true - conflict.theirs_handled = true - conflict.handled = true - elseif previous.handled == false then - conflict.state = previous.state - conflict.ours_handled = previous.ours_handled == true - conflict.theirs_handled = previous.theirs_handled == true - conflict.handled = false + conflict.state = previous.state + conflict.ours_handled = previous.ours_handled == true + conflict.theirs_handled = previous.theirs_handled == true + conflict.handled = previous.handled == true + + if conflict.state == 'manual_unresolved' or conflict.state == 'manual_resolved' then + conflict.current_result_lines = vim.deepcopy(previous_result) + conflict.current_lines = conflict.current_result_lines + conflict.current_kind = 'manual' + else + conflict.current_result_lines = + current_result_lines_for(conflict, conflict.state, vim.deepcopy(previous_result)) + conflict.current_lines = conflict.current_result_lines + conflict.current_kind = 'clean' end - - conflict.current_result_lines = previous_result - conflict.current_lines = previous_result end end end @@ -1060,14 +1057,6 @@ local function build_persisted_lines(merge_model) return lines end -local function state_includes_ours(state) - return state == 'ours' or state == 'both_ours_then_theirs' or state == 'both_theirs_then_ours' -end - -local function state_includes_theirs(state) - return state == 'theirs' or state == 'both_ours_then_theirs' or state == 'both_theirs_then_ours' -end - local function finalize_conflict_action(conflict) if conflict.state == 'manual_resolved' then conflict.ours_handled = true @@ -1091,29 +1080,6 @@ local function finalize_conflict_action(conflict) or (conflict.ours_handled and conflict.theirs_handled) end -local function accept_state(current_state, side) - local includes_ours = state_includes_ours(current_state) - local includes_theirs = state_includes_theirs(current_state) - - if side == 'ours' then - if includes_ours then - return current_state - end - if includes_theirs then - return 'both_theirs_then_ours' - end - return 'ours' - end - - if includes_theirs then - return current_state - end - if includes_ours then - return 'both_ours_then_theirs' - end - return 'theirs' -end - function M.build(file, opts) return build_model(file, opts) end @@ -1163,7 +1129,7 @@ function M.apply_action(merge_model, index, action) if action == 'accept_ours' or action == 'accept_theirs' then local side = action == 'accept_ours' and 'ours' or 'theirs' - conflict.state = accept_state(conflict.state, side) + conflict.state = side == 'ours' and 'ours' or 'theirs' if side == 'ours' then conflict.ours_handled = true else diff --git a/tests/helpers/repo.lua b/tests/helpers/repo.lua index d99c87d..e594ac4 100644 --- a/tests/helpers/repo.lua +++ b/tests/helpers/repo.lua @@ -293,6 +293,38 @@ function scenarios.repo_conflict_noeol(fixture) assert(not ok, 'expected merge conflict fixture without trailing newline') end +function scenarios.repo_conflict_zero_line(fixture) + seed_committed_file(fixture, 'tracked.txt', table.concat({ + 'alpha', + 'omega', + '', + }, '\n')) + local main_branch = vim.trim(fixture:git({ 'rev-parse', '--abbrev-ref', 'HEAD' })) + + fixture:git({ 'checkout', '-b', 'feature' }) + fixture:write(fixture.files.tracked, table.concat({ + 'alpha', + 'feature insert', + 'omega', + '', + }, '\n')) + fixture:commit_all('Feature inserts between stable lines') + + fixture:git({ 'checkout', main_branch }) + fixture:write(fixture.files.tracked, table.concat({ + 'alpha', + 'main insert', + 'omega', + '', + }, '\n')) + fixture:commit_all('Main inserts between stable lines') + + local ok = pcall(function() + fixture:git({ 'merge', 'feature' }) + end) + assert(not ok, 'expected zero-line merge conflict fixture') +end + function scenarios.repo_type_change(fixture) seed_committed_file(fixture, 'tracked.txt', 'alpha\nbeta\ngamma\n') fixture:remove(fixture.files.tracked) diff --git a/tests/integration/diffview_spec.lua b/tests/integration/diffview_spec.lua index fa2f211..7eceef8 100644 --- a/tests/integration/diffview_spec.lua +++ b/tests/integration/diffview_spec.lua @@ -241,6 +241,67 @@ return { end) end, }, + { + name = 'zero-line conflicts stay visible, editable, and resettable without changing semantic state', + run = function() + N.with_repo('repo_conflict_zero_line', function(repo) + require('glance').start() + local ui = require('glance.ui') + local filetree = require('glance.filetree') + local diffview = require('glance.diffview') + local workspace = require('glance.workspace') + + ui.open_file(filetree.files.conflicts[1]) + + local result_buf = workspace.get_buf(diffview.workspace, 'merge_result') + local result_win = workspace.get_win(diffview.workspace, 'merge_result') + local marks = merge_extmarks(result_buf) + local placeholder = '' + + for _, mark in ipairs(marks) do + if mark[4] and mark[4].virt_lines and mark[4].virt_lines[1] and mark[4].virt_lines[1][1] then + placeholder = mark[4].virt_lines[1][1][1] + break + end + end + + A.same(vim.api.nvim_buf_get_lines(result_buf, 0, -1, false), { 'alpha', 'omega' }) + A.contains(placeholder, 'unresolved') + A.equal(vim.api.nvim_win_get_cursor(result_win)[1], 2) + + vim.api.nvim_buf_set_lines(result_buf, 1, 1, false, { 'draft insert' }) + vim.api.nvim_exec_autocmds('TextChanged', { + buffer = result_buf, + modeline = false, + }) + + A.contains(vim.api.nvim_get_option_value('winbar', { win = result_win }), 'manual unresolved') + A.contains(vim.api.nvim_get_option_value('winbar', { win = result_win }), '1 unresolved') + + N.press('\\r') + + A.same(vim.api.nvim_buf_get_lines(result_buf, 0, -1, false), { 'alpha', 'omega' }) + A.contains(vim.api.nvim_get_option_value('winbar', { win = result_win }), 'unresolved') + A.contains(vim.api.nvim_get_option_value('winbar', { win = result_win }), '1 unresolved') + + vim.api.nvim_buf_call(result_buf, function() + vim.cmd('write') + end) + + A.equal(repo:read(repo.files.tracked), table.concat({ + 'alpha', + '<<<<<<< Ours', + 'main insert', + '||||||| Base', + '=======', + 'feature insert', + '>>>>>>> Theirs', + 'omega', + '', + }, '\n')) + end) + end, + }, { name = 'merge writes preserve the clean result buffer while persisting unresolved marker form to disk', run = function() @@ -265,6 +326,9 @@ return { 'outro updated', } + N.press('\\o') + N.press('\\T') + vim.api.nvim_buf_set_lines(result_buf, 0, -1, false, expected_result) vim.api.nvim_buf_call(result_buf, function() vim.cmd('write') @@ -313,7 +377,8 @@ return { local result_buf = workspace.get_buf(diffview.workspace, 'merge_result') A.equal(vim.api.nvim_get_option_value('endofline', { buf = result_buf }), false) - vim.api.nvim_buf_set_lines(result_buf, 0, -1, false, { 'main' }) + N.press('\\o') + N.press('\\T') vim.api.nvim_set_option_value('endofline', false, { buf = result_buf }) vim.api.nvim_buf_call(result_buf, function() vim.cmd('write') @@ -338,7 +403,9 @@ return { local result_buf = workspace.get_buf(diffview.workspace, 'merge_result') local result_win = workspace.get_win(diffview.workspace, 'merge_result') - vim.api.nvim_buf_set_lines(result_buf, 0, -1, false, { 'main' }) + + N.press('\\o') + N.press('\\T') vim.api.nvim_buf_call(result_buf, function() vim.cmd('write') @@ -348,6 +415,13 @@ return { A.equal(vim.api.nvim_get_option_value('modified', { buf = result_buf }), false) A.contains(vim.api.nvim_get_option_value('winbar', { win = result_win }), '0 unresolved') A.equal(repo:read(repo.files.tracked), 'main\n') + + diffview.close(true) + ui.open_file(filetree.files.conflicts[1]) + + local reopened_win = workspace.get_win(diffview.workspace, 'merge_result') + A.contains(vim.api.nvim_get_option_value('winbar', { win = reopened_win }), 'manual resolved') + A.contains(vim.api.nvim_get_option_value('winbar', { win = reopened_win }), '0 unresolved') end) end, }, diff --git a/tests/unit/merge_model_spec.lua b/tests/unit/merge_model_spec.lua index 3423fcb..f33b451 100644 --- a/tests/unit/merge_model_spec.lua +++ b/tests/unit/merge_model_spec.lua @@ -1,6 +1,26 @@ local A = require('tests.helpers.assert') local N = require('tests.helpers.nvim') +local function snapshot(conflict) + return { + state = conflict.state, + ours_handled = conflict.ours_handled == true, + theirs_handled = conflict.theirs_handled == true, + handled = conflict.handled == true, + current_result_lines = vim.deepcopy(conflict.current_result_lines or conflict.current_lines or {}), + } +end + +local function expected_snapshot(state, ours_handled, theirs_handled, lines) + return { + state = state, + ours_handled = ours_handled == true, + theirs_handled = theirs_handled == true, + handled = state == 'manual_resolved' or (ours_handled == true and theirs_handled == true), + current_result_lines = vim.deepcopy(lines), + } +end + return { name = 'merge_model', cases = { @@ -27,26 +47,33 @@ return { end, }, { - name = 'build rehydrates a clean ours resolution without conflict markers', + name = 'build treats clean canonical matches without conflict markers as manual_resolved', run = function() N.with_repo('repo_conflict', function(repo) local git = require('glance.git') local merge_model = require('glance.merge.model') local file = git.get_changed_files().conflicts[1] - - repo:write(repo.files.tracked, 'main\n') - - local built = assert(merge_model.build(file)) - - A.equal(built.unresolved_count, 0) - A.equal(built.conflicts[1].state, 'ours') - A.truthy(built.conflicts[1].handled) - A.same(built.result_lines, { 'main' }) + local cases = { + { text = 'main\n', lines = { 'main' } }, + { text = 'feature\n', lines = { 'feature' } }, + { text = 'base\n', lines = { 'base' } }, + { text = 'main\nfeature\n', lines = { 'main', 'feature' } }, + } + + for _, case in ipairs(cases) do + repo:write(repo.files.tracked, case.text) + local built = assert(merge_model.build(file)) + + A.equal(built.unresolved_count, 0) + A.equal(built.conflicts[1].state, 'manual_resolved') + A.truthy(built.conflicts[1].handled) + A.same(built.result_lines, case.lines) + end end) end, }, { - name = 'build keeps mixed handled and unresolved conflicts in order', + name = 'build keeps mixed manual_resolved and unresolved conflicts in order', run = function() N.with_repo('repo_conflict_multi', function(repo) local git = require('glance.git') @@ -72,7 +99,7 @@ return { A.equal(built.unresolved_count, 1) A.equal(#built.conflicts, 2) - A.equal(built.conflicts[1].state, 'ours') + A.equal(built.conflicts[1].state, 'manual_resolved') A.equal(built.conflicts[2].state, 'unresolved') A.same(built.result_lines, { 'intro', @@ -89,7 +116,7 @@ return { end, }, { - name = 'build preserves stable edits around recognized conflict states', + name = 'build preserves stable edits around manual_resolved and unresolved conflicts', run = function() N.with_repo('repo_conflict_multi', function(repo) local git = require('glance.git') @@ -114,7 +141,7 @@ return { local built = assert(merge_model.build(file)) A.equal(built.unresolved_count, 1) - A.equal(built.conflicts[1].state, 'ours') + A.equal(built.conflicts[1].state, 'manual_resolved') A.equal(built.conflicts[2].state, 'unresolved') A.same(built.result_lines, { 'intro updated', @@ -129,14 +156,17 @@ return { end, }, { - name = 'prepare_write reconstructs marker form while preserving stable edits and handled conflicts', + name = 'prepare_write reconstructs marker form while preserving stable edits and explicit handled conflicts', run = function() - N.with_repo('repo_conflict_multi', function(repo) + N.with_repo('repo_conflict_multi', function() local git = require('glance.git') local merge_model = require('glance.merge.model') local file = git.get_changed_files().conflicts[1] local previous_model = assert(merge_model.build(file)) + assert(merge_model.apply_action(previous_model, 1, 'accept_ours')) + assert(merge_model.apply_action(previous_model, 1, 'ignore_theirs')) + local prepared = assert(merge_model.prepare_write(file, { 'intro updated', 'first main', @@ -150,6 +180,8 @@ return { })) A.equal(prepared.model.unresolved_count, 1) + A.equal(prepared.model.conflicts[1].state, 'ours') + A.truthy(prepared.model.conflicts[1].handled) A.same(prepared.persisted_lines, { 'intro updated', 'first main', @@ -185,16 +217,20 @@ return { end, }, { - name = 'prepare_write preserves no-trailing-newline state', + name = 'prepare_write preserves no-trailing-newline state after an explicit full resolution', run = function() N.with_repo('repo_conflict_noeol', function() local git = require('glance.git') local merge_model = require('glance.merge.model') local file = git.get_changed_files().conflicts[1] + local previous_model = assert(merge_model.build(file)) + + assert(merge_model.apply_action(previous_model, 1, 'accept_ours')) + assert(merge_model.apply_action(previous_model, 1, 'ignore_theirs')) local prepared = assert(merge_model.prepare_write(file, { 'main' }, { current_ends_with_newline = false, - previous_model = assert(merge_model.build(file)), + previous_model = previous_model, })) A.equal(prepared.persisted_text, 'main') @@ -202,7 +238,7 @@ return { end, }, { - name = 'prepare_write does not treat plain base text as resolved without an explicit action', + name = 'prepare_write keeps plain base text unresolved without an explicit action', run = function() N.with_repo('repo_conflict', function() local git = require('glance.git') @@ -228,6 +264,514 @@ return { end) end, }, + { + name = 'rebuild preserves explicit in-session states when the clean result text is unchanged', + run = function() + N.with_repo('repo_conflict', function() + local git = require('glance.git') + local merge_model = require('glance.merge.model') + local file = git.get_changed_files().conflicts[1] + + local pending = assert(merge_model.build(file)) + assert(merge_model.apply_action(pending, 1, 'accept_ours')) + + local rebuilt_pending = assert(merge_model.build(file, { + current_lines = { 'main' }, + current_ends_with_newline = true, + previous_model = pending, + manual_clean_state = 'manual_unresolved', + })) + + A.same(snapshot(rebuilt_pending.conflicts[1]), { + state = 'ours', + ours_handled = true, + theirs_handled = false, + handled = false, + current_result_lines = { 'main' }, + }) + + local handled_base = assert(merge_model.build(file)) + assert(merge_model.apply_action(handled_base, 1, 'ignore_ours')) + assert(merge_model.apply_action(handled_base, 1, 'ignore_theirs')) + + local rebuilt_base = assert(merge_model.build(file, { + current_lines = { 'base' }, + current_ends_with_newline = true, + previous_model = handled_base, + manual_clean_state = 'manual_unresolved', + })) + + A.same(snapshot(rebuilt_base.conflicts[1]), { + state = 'base_only', + ours_handled = true, + theirs_handled = true, + handled = true, + current_result_lines = { 'base' }, + }) + end) + end, + }, + { + name = 'text-conflict action transitions use replacement semantics instead of additive semantics', + run = function() + N.with_repo('repo_conflict', function() + local git = require('glance.git') + local merge_model = require('glance.merge.model') + local file = git.get_changed_files().conflicts[1] + local cases = { + { + actions = { 'accept_ours' }, + expected = { + state = 'ours', + ours_handled = true, + theirs_handled = false, + handled = false, + current_result_lines = { 'main' }, + }, + }, + { + actions = { 'accept_theirs' }, + expected = { + state = 'theirs', + ours_handled = false, + theirs_handled = true, + handled = false, + current_result_lines = { 'feature' }, + }, + }, + { + actions = { 'ignore_ours' }, + expected = { + state = 'base_only', + ours_handled = true, + theirs_handled = false, + handled = false, + current_result_lines = { 'base' }, + }, + }, + { + actions = { 'ignore_theirs' }, + expected = { + state = 'base_only', + ours_handled = false, + theirs_handled = true, + handled = false, + current_result_lines = { 'base' }, + }, + }, + { + actions = { 'accept_theirs', 'accept_ours' }, + expected = { + state = 'ours', + ours_handled = true, + theirs_handled = true, + handled = true, + current_result_lines = { 'main' }, + }, + }, + { + actions = { 'accept_ours', 'accept_theirs' }, + expected = { + state = 'theirs', + ours_handled = true, + theirs_handled = true, + handled = true, + current_result_lines = { 'feature' }, + }, + }, + { + actions = { 'ignore_theirs', 'accept_ours' }, + expected = { + state = 'ours', + ours_handled = true, + theirs_handled = true, + handled = true, + current_result_lines = { 'main' }, + }, + }, + { + actions = { 'ignore_ours', 'accept_theirs' }, + expected = { + state = 'theirs', + ours_handled = true, + theirs_handled = true, + handled = true, + current_result_lines = { 'feature' }, + }, + }, + { + actions = { 'accept_ours', 'ignore_theirs' }, + expected = { + state = 'ours', + ours_handled = true, + theirs_handled = true, + handled = true, + current_result_lines = { 'main' }, + }, + }, + { + actions = { 'accept_theirs', 'ignore_ours' }, + expected = { + state = 'theirs', + ours_handled = true, + theirs_handled = true, + handled = true, + current_result_lines = { 'feature' }, + }, + }, + { + actions = { 'ignore_theirs', 'ignore_ours' }, + expected = { + state = 'base_only', + ours_handled = true, + theirs_handled = true, + handled = true, + current_result_lines = { 'base' }, + }, + }, + { + actions = { 'accept_both_ours_then_theirs', 'accept_ours' }, + expected = { + state = 'ours', + ours_handled = true, + theirs_handled = true, + handled = true, + current_result_lines = { 'main' }, + }, + }, + { + actions = { 'accept_both_theirs_then_ours', 'accept_theirs' }, + expected = { + state = 'theirs', + ours_handled = true, + theirs_handled = true, + handled = true, + current_result_lines = { 'feature' }, + }, + }, + { + actions = { 'accept_ours', 'reset_conflict' }, + expected = { + state = 'unresolved', + ours_handled = false, + theirs_handled = false, + handled = false, + current_result_lines = { 'base' }, + }, + }, + } + + for _, case in ipairs(cases) do + local built = assert(merge_model.build(file)) + for _, action in ipairs(case.actions) do + assert(merge_model.apply_action(built, 1, action)) + end + + A.same(snapshot(built.conflicts[1]), case.expected) + end + end) + end, + }, + { + name = 'text-conflict action matrix covers every reachable state and action pair', + run = function() + N.with_repo('repo_conflict', function() + local git = require('glance.git') + local merge_model = require('glance.merge.model') + local file = git.get_changed_files().conflicts[1] + local lines = { + base = { 'base' }, + ours = { 'main' }, + theirs = { 'feature' }, + both_ours_then_theirs = { 'main', 'feature' }, + both_theirs_then_ours = { 'feature', 'main' }, + manual = { 'custom merge draft' }, + } + local invalid_mark_resolved = 'mark resolved is only available for manual unresolved conflicts' + local invalid_manual_ignore = 'ignore actions are not available for manual merge states' + + local expected = { + unresolved = expected_snapshot('unresolved', false, false, lines.base), + pending_ours = expected_snapshot('ours', true, false, lines.ours), + pending_theirs = expected_snapshot('theirs', false, true, lines.theirs), + pending_base_from_ignore_ours = expected_snapshot('base_only', true, false, lines.base), + pending_base_from_ignore_theirs = expected_snapshot('base_only', false, true, lines.base), + handled_ours = expected_snapshot('ours', true, true, lines.ours), + handled_theirs = expected_snapshot('theirs', true, true, lines.theirs), + handled_both_ours_then_theirs = + expected_snapshot('both_ours_then_theirs', true, true, lines.both_ours_then_theirs), + handled_both_theirs_then_ours = + expected_snapshot('both_theirs_then_ours', true, true, lines.both_theirs_then_ours), + handled_base = expected_snapshot('base_only', true, true, lines.base), + manual_unresolved = expected_snapshot('manual_unresolved', false, false, lines.manual), + manual_resolved = expected_snapshot('manual_resolved', true, true, lines.manual), + } + + local function build_base_model() + return assert(merge_model.build(file)) + end + + local function build_manual_variant(mark_resolved) + local previous_model = build_base_model() + local manual_model = assert(merge_model.build(file, { + current_lines = lines.manual, + current_ends_with_newline = true, + previous_model = previous_model, + manual_clean_state = 'manual_unresolved', + })) + if mark_resolved then + assert(merge_model.apply_action(manual_model, 1, 'mark_resolved')) + end + return manual_model + end + + local variants = { + { + name = 'unresolved', + model = build_base_model(), + }, + { + name = 'pending_ours', + model = (function() + local model = build_base_model() + assert(merge_model.apply_action(model, 1, 'accept_ours')) + return model + end)(), + }, + { + name = 'pending_theirs', + model = (function() + local model = build_base_model() + assert(merge_model.apply_action(model, 1, 'accept_theirs')) + return model + end)(), + }, + { + name = 'pending_base_from_ignore_ours', + model = (function() + local model = build_base_model() + assert(merge_model.apply_action(model, 1, 'ignore_ours')) + return model + end)(), + }, + { + name = 'pending_base_from_ignore_theirs', + model = (function() + local model = build_base_model() + assert(merge_model.apply_action(model, 1, 'ignore_theirs')) + return model + end)(), + }, + { + name = 'handled_ours', + model = (function() + local model = build_base_model() + assert(merge_model.apply_action(model, 1, 'accept_theirs')) + assert(merge_model.apply_action(model, 1, 'accept_ours')) + return model + end)(), + }, + { + name = 'handled_theirs', + model = (function() + local model = build_base_model() + assert(merge_model.apply_action(model, 1, 'accept_ours')) + assert(merge_model.apply_action(model, 1, 'accept_theirs')) + return model + end)(), + }, + { + name = 'handled_both_ours_then_theirs', + model = (function() + local model = build_base_model() + assert(merge_model.apply_action(model, 1, 'accept_both_ours_then_theirs')) + return model + end)(), + }, + { + name = 'handled_both_theirs_then_ours', + model = (function() + local model = build_base_model() + assert(merge_model.apply_action(model, 1, 'accept_both_theirs_then_ours')) + return model + end)(), + }, + { + name = 'handled_base', + model = (function() + local model = build_base_model() + assert(merge_model.apply_action(model, 1, 'ignore_ours')) + assert(merge_model.apply_action(model, 1, 'ignore_theirs')) + return model + end)(), + }, + { + name = 'manual_unresolved', + model = build_manual_variant(false), + }, + { + name = 'manual_resolved', + model = build_manual_variant(true), + }, + } + + local transitions = { + unresolved = { + mark_resolved = { err = invalid_mark_resolved }, + reset_conflict = expected.unresolved, + accept_both_ours_then_theirs = expected.handled_both_ours_then_theirs, + accept_both_theirs_then_ours = expected.handled_both_theirs_then_ours, + accept_ours = expected.pending_ours, + accept_theirs = expected.pending_theirs, + ignore_ours = expected.pending_base_from_ignore_ours, + ignore_theirs = expected.pending_base_from_ignore_theirs, + }, + pending_ours = { + mark_resolved = { err = invalid_mark_resolved }, + reset_conflict = expected.unresolved, + accept_both_ours_then_theirs = expected.handled_both_ours_then_theirs, + accept_both_theirs_then_ours = expected.handled_both_theirs_then_ours, + accept_ours = expected.pending_ours, + accept_theirs = expected.handled_theirs, + ignore_ours = expected.pending_ours, + ignore_theirs = expected.handled_ours, + }, + pending_theirs = { + mark_resolved = { err = invalid_mark_resolved }, + reset_conflict = expected.unresolved, + accept_both_ours_then_theirs = expected.handled_both_ours_then_theirs, + accept_both_theirs_then_ours = expected.handled_both_theirs_then_ours, + accept_ours = expected.handled_ours, + accept_theirs = expected.pending_theirs, + ignore_ours = expected.handled_theirs, + ignore_theirs = expected.pending_theirs, + }, + pending_base_from_ignore_ours = { + mark_resolved = { err = invalid_mark_resolved }, + reset_conflict = expected.unresolved, + accept_both_ours_then_theirs = expected.handled_both_ours_then_theirs, + accept_both_theirs_then_ours = expected.handled_both_theirs_then_ours, + accept_ours = expected.pending_ours, + accept_theirs = expected.handled_theirs, + ignore_ours = expected.pending_base_from_ignore_ours, + ignore_theirs = expected.handled_base, + }, + pending_base_from_ignore_theirs = { + mark_resolved = { err = invalid_mark_resolved }, + reset_conflict = expected.unresolved, + accept_both_ours_then_theirs = expected.handled_both_ours_then_theirs, + accept_both_theirs_then_ours = expected.handled_both_theirs_then_ours, + accept_ours = expected.handled_ours, + accept_theirs = expected.pending_theirs, + ignore_ours = expected.handled_base, + ignore_theirs = expected.pending_base_from_ignore_theirs, + }, + handled_ours = { + mark_resolved = { err = invalid_mark_resolved }, + reset_conflict = expected.unresolved, + accept_both_ours_then_theirs = expected.handled_both_ours_then_theirs, + accept_both_theirs_then_ours = expected.handled_both_theirs_then_ours, + accept_ours = expected.handled_ours, + accept_theirs = expected.handled_theirs, + ignore_ours = expected.handled_ours, + ignore_theirs = expected.handled_ours, + }, + handled_theirs = { + mark_resolved = { err = invalid_mark_resolved }, + reset_conflict = expected.unresolved, + accept_both_ours_then_theirs = expected.handled_both_ours_then_theirs, + accept_both_theirs_then_ours = expected.handled_both_theirs_then_ours, + accept_ours = expected.handled_ours, + accept_theirs = expected.handled_theirs, + ignore_ours = expected.handled_theirs, + ignore_theirs = expected.handled_theirs, + }, + handled_both_ours_then_theirs = { + mark_resolved = { err = invalid_mark_resolved }, + reset_conflict = expected.unresolved, + accept_both_ours_then_theirs = expected.handled_both_ours_then_theirs, + accept_both_theirs_then_ours = expected.handled_both_theirs_then_ours, + accept_ours = expected.handled_ours, + accept_theirs = expected.handled_theirs, + ignore_ours = expected.handled_both_ours_then_theirs, + ignore_theirs = expected.handled_both_ours_then_theirs, + }, + handled_both_theirs_then_ours = { + mark_resolved = { err = invalid_mark_resolved }, + reset_conflict = expected.unresolved, + accept_both_ours_then_theirs = expected.handled_both_ours_then_theirs, + accept_both_theirs_then_ours = expected.handled_both_theirs_then_ours, + accept_ours = expected.handled_ours, + accept_theirs = expected.handled_theirs, + ignore_ours = expected.handled_both_theirs_then_ours, + ignore_theirs = expected.handled_both_theirs_then_ours, + }, + handled_base = { + mark_resolved = { err = invalid_mark_resolved }, + reset_conflict = expected.unresolved, + accept_both_ours_then_theirs = expected.handled_both_ours_then_theirs, + accept_both_theirs_then_ours = expected.handled_both_theirs_then_ours, + accept_ours = expected.handled_ours, + accept_theirs = expected.handled_theirs, + ignore_ours = expected.handled_base, + ignore_theirs = expected.handled_base, + }, + manual_unresolved = { + mark_resolved = expected.manual_resolved, + reset_conflict = expected.unresolved, + accept_both_ours_then_theirs = expected.handled_both_ours_then_theirs, + accept_both_theirs_then_ours = expected.handled_both_theirs_then_ours, + accept_ours = expected.pending_ours, + accept_theirs = expected.pending_theirs, + ignore_ours = { err = invalid_manual_ignore }, + ignore_theirs = { err = invalid_manual_ignore }, + }, + manual_resolved = { + mark_resolved = { err = invalid_mark_resolved }, + reset_conflict = expected.unresolved, + accept_both_ours_then_theirs = expected.handled_both_ours_then_theirs, + accept_both_theirs_then_ours = expected.handled_both_theirs_then_ours, + accept_ours = expected.handled_ours, + accept_theirs = expected.handled_theirs, + ignore_ours = { err = invalid_manual_ignore }, + ignore_theirs = { err = invalid_manual_ignore }, + }, + } + + local actions = { + 'mark_resolved', + 'reset_conflict', + 'accept_both_ours_then_theirs', + 'accept_both_theirs_then_ours', + 'accept_ours', + 'accept_theirs', + 'ignore_ours', + 'ignore_theirs', + } + local exercised = 0 + + for _, variant in ipairs(variants) do + A.same(snapshot(variant.model.conflicts[1]), expected[variant.name]) + for _, action in ipairs(actions) do + local merge_state = vim.deepcopy(variant.model) + local outcome = transitions[variant.name][action] + local updated, err = merge_model.apply_action(merge_state, 1, action) + + exercised = exercised + 1 + if outcome.err then + A.equal(updated, nil) + A.contains(err or '', outcome.err) + else + A.truthy(updated) + A.same(snapshot(merge_state.conflicts[1]), outcome) + end + end + end + + A.equal(exercised, #variants * #actions) + end) + end, + }, { name = 'accept ours persists and rehydrates as a partial unresolved state', run = function() @@ -345,5 +889,74 @@ return { end) end, }, + { + name = 'zero-line conflicts stay unresolved when empty and reset back to unresolved empty', + run = function() + N.with_repo('repo_conflict_zero_line', function() + local git = require('glance.git') + local merge_model = require('glance.merge.model') + local file = git.get_changed_files().conflicts[1] + local built = assert(merge_model.build(file)) + + A.equal(built.unresolved_count, 1) + A.equal(built.conflicts[1].state, 'unresolved') + A.equal(built.conflicts[1].result_range.start, 2) + A.equal(built.conflicts[1].result_range.count, 0) + A.same(built.result_lines, { 'alpha', 'omega' }) + + local edited = assert(merge_model.build(file, { + current_lines = { 'alpha', 'draft', 'omega' }, + current_ends_with_newline = true, + previous_model = built, + manual_clean_state = 'manual_unresolved', + })) + + A.equal(edited.conflicts[1].state, 'manual_unresolved') + A.falsy(edited.conflicts[1].handled) + + local reverted = assert(merge_model.build(file, { + current_lines = { 'alpha', 'omega' }, + current_ends_with_newline = true, + previous_model = edited, + manual_clean_state = 'manual_unresolved', + })) + + A.equal(reverted.conflicts[1].state, 'manual_unresolved') + A.falsy(reverted.conflicts[1].handled) + + assert(merge_model.apply_action(reverted, 1, 'reset_conflict')) + A.equal(reverted.conflicts[1].state, 'unresolved') + A.same(reverted.conflicts[1].current_result_lines, {}) + A.falsy(reverted.conflicts[1].handled) + end) + end, + }, + { + name = 'prepare_write serializes unresolved zero-line conflicts without changing their semantic state', + run = function() + N.with_repo('repo_conflict_zero_line', function() + local git = require('glance.git') + local merge_model = require('glance.merge.model') + local file = git.get_changed_files().conflicts[1] + local previous_model = assert(merge_model.build(file)) + + local prepared = assert(merge_model.prepare_write(file, { 'alpha', 'omega' }, { + previous_model = previous_model, + })) + + A.equal(prepared.model.conflicts[1].state, 'unresolved') + A.same(prepared.persisted_lines, { + 'alpha', + '<<<<<<< Ours', + 'main insert', + '||||||| Base', + '=======', + 'feature insert', + '>>>>>>> Theirs', + 'omega', + }) + end) + end, + }, }, }