Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 41 additions & 75 deletions lua/glance/merge/model.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -458,30 +459,22 @@ 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)
end

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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep clean matches from being treated as conflict markers

Using manual_outcome(...) for clean candidate matches means any resolved content line that merely starts with a marker token (for example a literal ======= line in normal text) is reclassified as unresolved, because manual_outcome only does prefix checks via contains_conflict_markers. In that case a fully resolved file can reopen as unresolved and subsequent saves can re-emit merge markers instead of preserving the clean result, which is a behavioral regression from the previous clean-candidate path.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in polyphilz/3-pane-7

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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -641,27 +635,29 @@ 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,
})
end
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,
})
Expand All @@ -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 = {}

Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions tests/helpers/repo.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
78 changes: 76 additions & 2 deletions tests/integration/diffview_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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')
Expand Down Expand Up @@ -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')
Expand All @@ -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')
Expand All @@ -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,
},
Expand Down
Loading
Loading