From c0b5aafeaa74928599f012e1fcb9c795cc530c80 Mon Sep 17 00:00:00 2001 From: Rohan Bansal Date: Thu, 2 Apr 2026 19:01:18 -0400 Subject: [PATCH 1/2] 3-pane ui, result :write buffer --- .gitignore | 1 + lua/glance/diffview.lua | 80 ++- lua/glance/git.lua | 208 ++++++- lua/glance/merge/init.lua | 381 ++++++++++++ lua/glance/merge/layout.lua | 97 +++ lua/glance/merge/model.lua | 893 ++++++++++++++++++++++++++++ lua/glance/merge/render.lua | 168 ++++++ tests/helpers/repo.lua | 82 +++ tests/helpers/state.lua | 14 + tests/integration/diffview_spec.lua | 298 ++++++++-- tests/unit/git_spec.lua | 97 +++ tests/unit/merge_model_spec.lua | 205 +++++++ 12 files changed, 2429 insertions(+), 95 deletions(-) create mode 100644 lua/glance/merge/init.lua create mode 100644 lua/glance/merge/layout.lua create mode 100644 lua/glance/merge/model.lua create mode 100644 lua/glance/merge/render.lua create mode 100644 tests/unit/merge_model_spec.lua diff --git a/.gitignore b/.gitignore index 2091bb0..c586cd1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ **/.plans **/.notes/ **/.nvimlog +nvim.log **/.DS_Store **/.pyc **/.venv/ diff --git a/lua/glance/diffview.lua b/lua/glance/diffview.lua index e60108d..3852e48 100644 --- a/lua/glance/diffview.lua +++ b/lua/glance/diffview.lua @@ -611,37 +611,7 @@ end --- Open a single editable pane for a conflicted working-tree file. function M.open_conflict(file) - local root = git.repo_root() - if not root then return end - - prepare_default_workspace() - open_single_pane() - - local full_path = root .. '/' .. file.path - vim.cmd('edit ' .. vim.fn.fnameescape(full_path)) - M.new_buf = vim.api.nvim_get_current_buf() - - if watch_options().enabled then - M.watch_file(full_path) - end - - apply_conflict_highlights(M.new_buf) - set_window_label(M.new_win, 'Conflict: unresolved markers') - - M.equalize_panes() - M.setup_autocmds(file) - M.bind_buffer_keymaps() - - local opts = { buffer = M.new_buf, silent = true } - vim.keymap.set('n', ']x', jump_to_next_conflict, opts) - vim.keymap.set('n', '[x', jump_to_prev_conflict, opts) - vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI' }, { - group = M.autocmd_group, - buffer = M.new_buf, - callback = function() - apply_conflict_highlights(M.new_buf) - end, - }) + require('glance.merge').open(M, file) end --- Open a single read-only placeholder pane for visible-but-unsupported states. @@ -826,6 +796,8 @@ function M.setup_autocmds(file) }) local editable_buf = M.editable_buf() + local merge = package.loaded['glance.merge'] + local merge_active = merge and merge.is_active and merge.is_active() -- When the workspace's editable buffer is saved, refresh the diff. if editable_buf and vim.api.nvim_buf_get_option(editable_buf, 'buftype') == '' then @@ -840,16 +812,18 @@ function M.setup_autocmds(file) end, }) - vim.api.nvim_create_autocmd('BufWritePost', { - group = M.autocmd_group, - buffer = editable_buf, - callback = function() - vim.schedule(function() - M.refresh(file) - filetree.note_repo_activity() - end) - end, - }) + if not merge_active then + vim.api.nvim_create_autocmd('BufWritePost', { + group = M.autocmd_group, + buffer = editable_buf, + callback = function() + vim.schedule(function() + M.refresh(file) + filetree.note_repo_activity() + end) + end, + }) + end end end @@ -880,6 +854,14 @@ function M.content_wins() end function M.hoverable_separator_wins() + local merge = package.loaded['glance.merge'] + if merge and merge.is_active and merge.is_active() then + local wins = merge.hoverable_separator_wins(M) + if wins then + return wins + end + end + sync_filetree_pane() local wins = {} @@ -1026,6 +1008,11 @@ function M.close(force) M.reset_workspace() + local merge = package.loaded['glance.merge'] + if merge and merge.reset then + merge.reset() + end + local ui = require('glance.ui') ui.close_diff() end, debug.traceback) @@ -1044,6 +1031,12 @@ function M.refresh(file) return end + local merge = package.loaded['glance.merge'] + if merge and merge.is_active and merge.is_active() then + merge.refresh(M, file) + return + end + local kind = git.infer_stage_kind(file) local old_lines = git.get_file_content(old_content_path(file), old_content_ref(file)) local old_side_open = M.old_buf and vim.api.nvim_buf_is_valid(M.old_buf) @@ -1108,6 +1101,11 @@ end --- Explicitly size panes: file tree gets its fixed width, diff panes split the rest. function M.equalize_panes() + local merge = package.loaded['glance.merge'] + if merge and merge.is_active and merge.is_active() and merge.equalize_panes(M) then + return + end + sync_filetree_pane() local tree_visible = filetree.win and vim.api.nvim_win_is_valid(filetree.win) local tree_width = 0 diff --git a/lua/glance/git.lua b/lua/glance/git.lua index b8e485e..bd1d560 100644 --- a/lua/glance/git.lua +++ b/lua/glance/git.lua @@ -92,6 +92,31 @@ local function git_dir_at_root(root) return M._git_dir end +local function read_trimmed_file(path) + if type(path) ~= 'string' or path == '' then + return nil + end + + local stat = vim.uv.fs_stat(path) + if not stat or stat.type ~= 'file' then + return nil + end + + local file = io.open(path, 'r') + if not file then + return nil + end + + local content = file:read('*a') + file:close() + content = vim.trim(content or '') + if content == '' then + return nil + end + + return content +end + local function format_timespec(spec) if type(spec) == 'table' then return tostring(spec.sec or 0) .. ':' .. tostring(spec.nsec or 0) @@ -619,6 +644,151 @@ function M.git_dir() return git_dir_at_root(M.repo_root()) end +function M.get_unmerged_stage_entries(filepath) + if type(filepath) ~= 'string' or filepath == '' then + return {} + end + + local ok, output = M.run_git_capture({ 'ls-files', '-u', '--', filepath }) + if not ok then + return {} + end + + local entries = {} + for line in output:gmatch('[^\n]+') do + local mode, oid, stage, path = line:match('^(%d+)%s+([0-9a-f]+)%s+(%d)%s+(.+)$') + if mode and oid and stage and path then + entries[tonumber(stage)] = { + mode = mode, + oid = oid, + stage = tonumber(stage), + path = path, + } + end + end + + return entries +end + +local function ref_name_label(ref) + local ok, output = M.run_git_capture({ 'name-rev', '--name-only', '--always', ref }, { + allowed_codes = { 0, 128 }, + }) + if not ok then + return nil + end + + local label = vim.trim(output) + if label == '' or label == 'undefined' or label == ref then + return nil + end + + if label:match('^refs/heads/') then + return label:gsub('^refs/heads/', '') + end + + return label +end + +local function short_ref_oid(ref) + local ok, output = M.run_git_capture({ 'rev-parse', '--short', ref }, { + allowed_codes = { 0, 128 }, + }) + if not ok then + return nil + end + + local oid = vim.trim(output) + if oid == '' then + return nil + end + + return oid +end + +local function ref_display(ref) + if type(ref) ~= 'string' or ref == '' then + return nil + end + + local label = ref_name_label(ref) + if label and label ~= ref then + return ref .. ' (' .. label .. ')' + end + + local oid = short_ref_oid(ref) + if oid then + return ref .. ' (' .. oid .. ')' + end + + return ref +end + +function M.get_operation_context() + local git_dir = M.git_dir() + if not git_dir then + return { + kind = nil, + prefix = nil, + ours_ref = 'HEAD', + ours_display = ref_display('HEAD') or 'HEAD', + theirs_ref = nil, + theirs_display = nil, + } + end + + local context = { + kind = nil, + prefix = nil, + ours_ref = 'HEAD', + ours_display = ref_display('HEAD') or 'HEAD', + theirs_ref = nil, + theirs_display = nil, + } + + if vim.uv.fs_stat(git_dir .. '/rebase-merge') or vim.uv.fs_stat(git_dir .. '/rebase-apply') then + context.kind = 'rebase' + context.prefix = 'Rebasing' + if vim.uv.fs_stat(git_dir .. '/REBASE_HEAD') then + context.theirs_ref = 'REBASE_HEAD' + context.theirs_display = ref_display('REBASE_HEAD') or 'REBASE_HEAD' + else + local onto = read_trimmed_file(git_dir .. '/rebase-merge/onto') + or read_trimmed_file(git_dir .. '/rebase-apply/onto') + if onto then + context.theirs_ref = onto + context.theirs_display = short_ref_oid(onto) or onto + end + end + return context + end + + if vim.uv.fs_stat(git_dir .. '/MERGE_HEAD') then + context.kind = 'merge' + context.theirs_ref = 'MERGE_HEAD' + context.theirs_display = ref_display('MERGE_HEAD') or 'MERGE_HEAD' + return context + end + + if vim.uv.fs_stat(git_dir .. '/CHERRY_PICK_HEAD') then + context.kind = 'cherry_pick' + context.prefix = 'Cherry-picking' + context.theirs_ref = 'CHERRY_PICK_HEAD' + context.theirs_display = ref_display('CHERRY_PICK_HEAD') or 'CHERRY_PICK_HEAD' + return context + end + + if vim.uv.fs_stat(git_dir .. '/REVERT_HEAD') then + context.kind = 'revert' + context.prefix = 'Reverting' + context.theirs_ref = 'REVERT_HEAD' + context.theirs_display = ref_display('REVERT_HEAD') or 'REVERT_HEAD' + return context + end + + return context +end + function M.repo_watch_paths() local git_dir = M.git_dir() if not git_dir then @@ -657,30 +827,22 @@ end --- Retrieve file content at a specific git ref. --- @param filepath string Path relative to repo root --- @param ref string|nil "HEAD", ":" (index), or nil (working tree / disk) ---- @return string[] Lines of file content -function M.get_file_content(filepath, ref) +--- @return string Raw file text +function M.get_file_text(filepath, ref) if ref == nil then -- Read from working tree (disk) local root = M.repo_root() if not root then - return {} + return '' end local full_path = root .. '/' .. filepath - local f = io.open(full_path, 'r') + local f = io.open(full_path, 'rb') if not f then - return {} + return '' end local content = f:read('*a') f:close() - local lines = {} - for line in (content .. '\n'):gmatch('(.-)\n') do - table.insert(lines, line) - end - -- Remove trailing empty line from the split - if #lines > 0 and lines[#lines] == '' then - table.remove(lines) - end - return lines + return content or '' end -- ref is "HEAD" or ":" (index) @@ -693,11 +855,19 @@ function M.get_file_content(filepath, ref) local result = vim.fn.system('git show ' .. vim.fn.shellescape(git_ref) .. ' 2>/dev/null') if vim.v.shell_error ~= 0 then + return '' + end + + return result or '' +end + +local function split_content_lines(text) + if type(text) ~= 'string' or text == '' then return {} end local lines = {} - for line in (result .. '\n'):gmatch('(.-)\n') do + for line in (text .. '\n'):gmatch('(.-)\n') do table.insert(lines, line) end if #lines > 0 and lines[#lines] == '' then @@ -706,6 +876,14 @@ function M.get_file_content(filepath, ref) return lines end +--- Retrieve file content at a specific git ref. +--- @param filepath string Path relative to repo root +--- @param ref string|nil "HEAD", ":" (index), or nil (working tree / disk) +--- @return string[] Lines of file content +function M.get_file_content(filepath, ref) + return split_content_lines(M.get_file_text(filepath, ref)) +end + function M.entry_paths(file) local paths = {} local seen = {} diff --git a/lua/glance/merge/init.lua b/lua/glance/merge/init.lua new file mode 100644 index 0000000..7a9f599 --- /dev/null +++ b/lua/glance/merge/init.lua @@ -0,0 +1,381 @@ +local config = require('glance.config') +local filetree = require('glance.filetree') +local git = require('glance.git') +local layout = require('glance.merge.layout') +local model = require('glance.merge.model') +local render = require('glance.merge.render') +local workspace = require('glance.workspace') + +local M = {} + +local state = { + active = false, + file = nil, + model = nil, + active_conflict_index = nil, + write_in_progress = false, +} + +local function panes(diffview) + return { + theirs = { + win = workspace.get_win(diffview.workspace, layout.THEIRS_ROLE), + buf = workspace.get_buf(diffview.workspace, layout.THEIRS_ROLE), + }, + ours = { + win = workspace.get_win(diffview.workspace, layout.OURS_ROLE), + buf = workspace.get_buf(diffview.workspace, layout.OURS_ROLE), + }, + result = { + win = workspace.get_win(diffview.workspace, layout.RESULT_ROLE), + buf = workspace.get_buf(diffview.workspace, layout.RESULT_ROLE), + }, + } +end + +local function result_win(diffview) + local win = workspace.get_win(diffview.workspace, layout.RESULT_ROLE) + if win and vim.api.nvim_win_is_valid(win) then + return win + end + return nil +end + +local function result_buf(diffview) + local buf = workspace.get_buf(diffview.workspace, layout.RESULT_ROLE) + if buf and vim.api.nvim_buf_is_valid(buf) then + return buf + end + return nil +end + +local function result_modified(diffview) + local buf = result_buf(diffview) + if not buf then + return false + end + + if vim.api.nvim_get_option_value('buftype', { buf = buf }) ~= '' then + return false + end + + return vim.api.nvim_get_option_value('modified', { buf = buf }) +end + +local function unresolved_indices() + local indices = {} + if not state.model then + return indices + end + + for index, conflict in ipairs(state.model.conflicts) do + if not conflict.handled then + indices[#indices + 1] = index + end + end + + return indices +end + +local function first_unresolved_index() + local unresolved = unresolved_indices() + return unresolved[1] +end + +local function focus_result(diffview) + local win = result_win(diffview) + if not win then + return false + end + + vim.api.nvim_set_current_win(win) + return true +end + +local function write_text(path, text) + local file = assert(io.open(path, 'w')) + file:write(text or '') + file:close() +end + +local function edit_result_buffer(diffview, file) + local root = git.repo_root() + if not root then + return nil + end + + local current = panes(diffview).result + if not current.win or not vim.api.nvim_win_is_valid(current.win) then + return nil + end + + local scratch = current.buf + vim.api.nvim_set_current_win(current.win) + vim.cmd('edit ' .. vim.fn.fnameescape(root .. '/' .. file.path)) + + local buf = vim.api.nvim_get_current_buf() + workspace.set_pane(diffview.workspace, layout.RESULT_ROLE, { + win = current.win, + buf = buf, + }) + + if scratch and vim.api.nvim_buf_is_valid(scratch) and scratch ~= buf then + vim.api.nvim_buf_delete(scratch, { force = true }) + end + + return buf +end + +local function bind_write_command(diffview, file) + local buf = result_buf(diffview) + if not buf then + return + end + + vim.api.nvim_create_autocmd('BufWriteCmd', { + group = diffview.autocmd_group, + buffer = buf, + callback = function() + if state.write_in_progress then + return + end + + local root = git.repo_root() + if not root then + vim.notify('glance: not inside a git repository', vim.log.levels.WARN) + return + end + + local current_lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + local prepared, err = model.prepare_write(file, current_lines, { + current_ends_with_newline = vim.api.nvim_get_option_value('endofline', { buf = buf }), + previous_model = state.model, + }) + if not prepared then + vim.notify('glance: failed to save merge result: ' .. err, vim.log.levels.WARN) + return + end + + state.write_in_progress = true + local ok, write_err = xpcall(function() + write_text(root .. '/' .. file.path, prepared.persisted_text) + vim.api.nvim_set_option_value('modified', false, { buf = buf }) + filetree.note_repo_activity() + M.refresh(diffview, file) + end, debug.traceback) + state.write_in_progress = false + + if not ok then + vim.notify('glance: failed to save merge result: ' .. tostring(write_err), vim.log.levels.WARN) + vim.api.nvim_set_option_value('modified', true, { buf = buf }) + return + end + end, + }) +end + +local function bind_navigation_keymaps(diffview) + for _, role in ipairs({ layout.THEIRS_ROLE, layout.OURS_ROLE, layout.RESULT_ROLE }) do + local buf = workspace.get_buf(diffview.workspace, role) + if buf and vim.api.nvim_buf_is_valid(buf) then + vim.keymap.set('n', ']x', function() + M.jump_next(diffview) + end, { + buffer = buf, + silent = true, + }) + vim.keymap.set('n', '[x', function() + M.jump_prev(diffview) + end, { + buffer = buf, + silent = true, + }) + end + end +end + +function M.is_active() + return state.active == true +end + +function M.jump_to_conflict(diffview, index) + if not state.model or not state.model.conflicts[index] then + return false + end + + local conflict = state.model.conflicts[index] + local win = result_win(diffview) + if not win then + return false + end + + local line = conflict.result_range.start + if conflict.result_range.count == 0 then + local buf = result_buf(diffview) + local line_count = buf and vim.api.nvim_buf_is_valid(buf) and vim.api.nvim_buf_line_count(buf) or 1 + line = math.min(math.max(line, 1), math.max(line_count, 1)) + end + + vim.api.nvim_set_current_win(win) + vim.api.nvim_win_set_cursor(win, { math.max(line, 1), 0 }) + state.active_conflict_index = index + return true +end + +function M.jump_next(diffview) + local unresolved = unresolved_indices() + if #unresolved == 0 then + return false + end + + local target = unresolved[1] + if state.active_conflict_index then + for index, conflict_index in ipairs(unresolved) do + if conflict_index > state.active_conflict_index then + target = conflict_index + break + end + if index == #unresolved then + target = unresolved[1] + end + end + end + + return M.jump_to_conflict(diffview, target) +end + +function M.jump_prev(diffview) + local unresolved = unresolved_indices() + if #unresolved == 0 then + return false + end + + local target = unresolved[#unresolved] + if state.active_conflict_index then + for index = #unresolved, 1, -1 do + if unresolved[index] < state.active_conflict_index then + target = unresolved[index] + break + end + if index == 1 then + target = unresolved[#unresolved] + end + end + end + + return M.jump_to_conflict(diffview, target) +end + +function M.equalize_panes(diffview) + if not state.active then + return false + end + + layout.equalize(diffview) + return true +end + +function M.hoverable_separator_wins(diffview) + if not state.active then + return nil + end + + return layout.hoverable_separator_wins(diffview) +end + +local function rebuild(diffview, file) + local merge_model, err = model.build(file) + if not merge_model then + return nil, err + end + + state.file = file + state.model = merge_model + render.apply(diffview, panes(diffview), merge_model, file) + layout.equalize(diffview) + return merge_model +end + +function M.open(diffview, file) + local merge_model, err = model.build(file) + if not merge_model then + state.active = false + state.file = nil + state.model = nil + state.active_conflict_index = nil + diffview.open_placeholder(file, err) + return + end + + state.active = true + state.file = file + state.model = merge_model + state.active_conflict_index = nil + + layout.open(diffview) + local buf = edit_result_buffer(diffview, file) + if not buf then + return + end + + rebuild(diffview, file) + + local root = git.repo_root() + if root and config.options.watch.enabled then + diffview.watch_file(root .. '/' .. file.path) + end + + diffview.setup_autocmds(file) + bind_write_command(diffview, file) + diffview.bind_buffer_keymaps() + bind_navigation_keymaps(diffview) + + local first = first_unresolved_index() or 1 + if not M.jump_to_conflict(diffview, first) then + focus_result(diffview) + end +end + +function M.refresh(diffview, file) + if not state.active then + return false + end + + file = file or state.file + if not file then + return false + end + + if result_modified(diffview) then + return false + end + + local previous_active = state.active_conflict_index + local merge_model, err = rebuild(diffview, file) + if not merge_model then + vim.notify('glance: failed to refresh merge view: ' .. err, vim.log.levels.WARN) + return false + end + + if previous_active and merge_model.conflicts[previous_active] then + if M.jump_to_conflict(diffview, previous_active) then + return true + end + end + + local first = first_unresolved_index() or 1 + if not M.jump_to_conflict(diffview, first) then + focus_result(diffview) + end + return true +end + +function M.reset() + state.active = false + state.file = nil + state.model = nil + state.active_conflict_index = nil + state.write_in_progress = false +end + +return M diff --git a/lua/glance/merge/layout.lua b/lua/glance/merge/layout.lua new file mode 100644 index 0000000..7d505bc --- /dev/null +++ b/lua/glance/merge/layout.lua @@ -0,0 +1,97 @@ +local filetree = require('glance.filetree') +local workspace = require('glance.workspace') + +local M = {} + +M.FILETREE_ROLE = 'filetree' +M.THEIRS_ROLE = 'merge_theirs' +M.OURS_ROLE = 'merge_ours' +M.RESULT_ROLE = 'merge_result' + +function M.workspace_spec() + return { + roles = { + { role = M.FILETREE_ROLE, kind = 'sidebar' }, + { role = M.THEIRS_ROLE, kind = 'content' }, + { role = M.OURS_ROLE, kind = 'content' }, + { role = M.RESULT_ROLE, kind = 'content' }, + }, + preferred_focus_role = M.RESULT_ROLE, + editable_role = M.RESULT_ROLE, + } +end + +function M.open(diffview) + diffview.configure_workspace(M.workspace_spec()) + + local result_win, result_buf = diffview.open_workspace_pane(M.RESULT_ROLE) + + vim.cmd('leftabove new') + local theirs_win = vim.api.nvim_get_current_win() + local theirs_buf = vim.api.nvim_get_current_buf() + workspace.set_pane(diffview.workspace, M.THEIRS_ROLE, { + win = theirs_win, + buf = theirs_buf, + }) + diffview.set_win_options(theirs_win) + + vim.cmd('rightbelow vnew') + local ours_win = vim.api.nvim_get_current_win() + local ours_buf = vim.api.nvim_get_current_buf() + workspace.set_pane(diffview.workspace, M.OURS_ROLE, { + win = ours_win, + buf = ours_buf, + }) + diffview.set_win_options(ours_win) + + return { + result = { win = result_win, buf = result_buf }, + theirs = { win = theirs_win, buf = theirs_buf }, + ours = { win = ours_win, buf = ours_buf }, + } +end + +function M.equalize(diffview) + local tree_visible = filetree.win and vim.api.nvim_win_is_valid(filetree.win) + local tree_width = 0 + + if tree_visible then + tree_width = require('glance.config').options.windows.filetree.width + vim.api.nvim_win_set_width(filetree.win, tree_width) + end + + local theirs_win = workspace.get_win(diffview.workspace, M.THEIRS_ROLE) + local ours_win = workspace.get_win(diffview.workspace, M.OURS_ROLE) + + if not theirs_win or not vim.api.nvim_win_is_valid(theirs_win) then + return + end + if not ours_win or not vim.api.nvim_win_is_valid(ours_win) then + return + end + + local separators = (tree_visible and 1 or 0) + 1 + local available = math.max(vim.o.columns - tree_width - separators, 2) + local left_width = math.max(math.floor(available / 2), 1) + local right_width = math.max(available - left_width, 1) + + vim.api.nvim_win_set_width(theirs_win, left_width) + vim.api.nvim_win_set_width(ours_win, right_width) +end + +function M.hoverable_separator_wins(diffview) + local wins = {} + + if filetree.win and vim.api.nvim_win_is_valid(filetree.win) then + wins[#wins + 1] = filetree.win + end + + local theirs_win = workspace.get_win(diffview.workspace, M.THEIRS_ROLE) + if theirs_win and vim.api.nvim_win_is_valid(theirs_win) then + wins[#wins + 1] = theirs_win + end + + return wins +end + +return M diff --git a/lua/glance/merge/model.lua b/lua/glance/merge/model.lua new file mode 100644 index 0000000..e286822 --- /dev/null +++ b/lua/glance/merge/model.lua @@ -0,0 +1,893 @@ +local git = require('glance.git') + +local M = {} + +local function split_text(text) + if type(text) ~= 'string' or text == '' then + return {}, false + end + + local lines = {} + for line in (text .. '\n'):gmatch('(.-)\n') do + lines[#lines + 1] = line + end + if #lines > 0 and lines[#lines] == '' then + table.remove(lines) + end + + return lines, text:sub(-1) == '\n' +end + +local function join_text(lines, ends_with_newline) + if type(lines) ~= 'table' or #lines == 0 then + return ends_with_newline and '\n' or '' + end + + local text = table.concat(lines, '\n') + if ends_with_newline then + text = text .. '\n' + end + + return text +end + +local function write_text(path, text) + local file = assert(io.open(path, 'w')) + file:write(text or '') + file:close() +end + +local function same_lines(left, right) + if #left ~= #right then + return false + end + + for index = 1, #left do + if left[index] ~= right[index] then + return false + end + end + + return true +end + +local function same_lines_at(lines, start_idx, needle) + if #needle == 0 then + return true + end + + if start_idx < 1 or (start_idx + #needle - 1) > #lines then + return false + end + + for index = 1, #needle do + if lines[start_idx + index - 1] ~= needle[index] then + return false + end + end + + return true +end + +local function slice_lines(lines, start_idx, end_idx) + if start_idx > end_idx then + return {} + end + + local slice = {} + for index = start_idx, end_idx do + slice[#slice + 1] = lines[index] + end + return slice +end + +local function find_next_sequence(lines, needle, start_idx) + start_idx = math.max(start_idx or 1, 1) + if #needle == 0 then + return start_idx + end + + local last_start = #lines - #needle + 1 + for index = start_idx, last_start do + if same_lines_at(lines, index, needle) then + return index + end + end + + return nil +end + +local function find_sequence_positions(lines, needle, start_idx) + local positions = {} + start_idx = math.max(start_idx or 1, 1) + + if #needle == 0 then + positions[1] = start_idx + return positions + end + + local last_start = #lines - #needle + 1 + for index = start_idx, last_start do + if same_lines_at(lines, index, needle) then + positions[#positions + 1] = index + end + end + + return positions +end + +local function contains_conflict_markers(lines) + for _, line in ipairs(lines) do + if line:match('^<<<<<<<') + or line:match('^|||||||') + or line:match('^=======') + or line:match('^>>>>>>>') + then + return true + end + end + + return false +end + +local function parse_conflict_block(lines, start_idx) + local first = lines[start_idx] + if type(first) ~= 'string' or not first:match('^<<<<<<<') then + return nil + end + + local block = { + ours_lines = {}, + base_lines = {}, + theirs_lines = {}, + full_lines = {}, + } + + local index = start_idx + 1 + while index <= #lines do + local line = lines[index] + if line:match('^|||||||') or line:match('^=======') then + break + end + block.ours_lines[#block.ours_lines + 1] = line + index = index + 1 + end + + if index > #lines then + return nil + end + + if lines[index]:match('^|||||||') then + index = index + 1 + while index <= #lines and not lines[index]:match('^=======') do + block.base_lines[#block.base_lines + 1] = lines[index] + index = index + 1 + end + if index > #lines then + return nil + end + end + + if not lines[index]:match('^=======') then + return nil + end + + index = index + 1 + while index <= #lines and not lines[index]:match('^>>>>>>>') do + block.theirs_lines[#block.theirs_lines + 1] = lines[index] + index = index + 1 + end + + if index > #lines then + return nil + end + + block.full_lines = slice_lines(lines, start_idx, index) + return block, index + 1 +end + +local function parse_canonical_sequence(lines) + local stable_segments = {} + local conflicts = {} + local stable = {} + local index = 1 + + while index <= #lines do + if lines[index]:match('^<<<<<<<') then + stable_segments[#stable_segments + 1] = stable + stable = {} + + local block, next_index = parse_conflict_block(lines, index) + if not block then + return nil, nil, 'failed to parse canonical merge output' + end + + conflicts[#conflicts + 1] = { + id = #conflicts + 1, + ours_lines = block.ours_lines, + base_lines = block.base_lines, + theirs_lines = block.theirs_lines, + canonical_lines = block.full_lines, + } + index = next_index + else + stable[#stable + 1] = lines[index] + index = index + 1 + end + end + + stable_segments[#stable_segments + 1] = stable + return stable_segments, conflicts +end + +local function canonical_merge_lines(base_text, ours_text, theirs_text) + local tempdir = vim.fn.tempname() .. '-glance-merge' + vim.fn.mkdir(tempdir, 'p') + + local base_path = tempdir .. '/base' + local ours_path = tempdir .. '/ours' + local theirs_path = tempdir .. '/theirs' + + write_text(base_path, base_text) + write_text(ours_path, ours_text) + write_text(theirs_path, theirs_text) + + local result = vim.system({ + 'git', + 'merge-file', + '--stdout', + '--diff3', + '-L', + 'Ours', + '-L', + 'Base', + '-L', + 'Theirs', + ours_path, + base_path, + theirs_path, + }, { text = true }):wait() + + vim.fn.delete(tempdir, 'rf') + + if result.code < 0 or result.code >= 128 then + local message = vim.trim((result.stderr ~= '' and result.stderr) or (result.stdout or '')) + if message == '' then + message = 'git merge-file failed' + end + return nil, message + end + + local lines = split_text(result.stdout or '') + return lines +end + +local function source_range(lines, needle, start_idx) + local start = find_next_sequence(lines, needle, start_idx) + if not start then + return { + start = nil, + count = #needle, + } + end + + return { + start = start, + count = #needle, + } +end + +local function assign_source_ranges(conflicts, base_lines, ours_lines, theirs_lines) + local base_cursor = 1 + local ours_cursor = 1 + local theirs_cursor = 1 + + for _, conflict in ipairs(conflicts) do + conflict.base_range = source_range(base_lines, conflict.base_lines, base_cursor) + conflict.ours_range = source_range(ours_lines, conflict.ours_lines, ours_cursor) + conflict.theirs_range = source_range(theirs_lines, conflict.theirs_lines, theirs_cursor) + + if conflict.base_range.start then + base_cursor = conflict.base_range.start + conflict.base_range.count + end + if conflict.ours_range.start then + ours_cursor = conflict.ours_range.start + conflict.ours_range.count + end + if conflict.theirs_range.start then + theirs_cursor = conflict.theirs_range.start + conflict.theirs_range.count + end + end +end + +local function clean_candidates(conflict) + return { + { state = 'ours', lines = conflict.ours_lines }, + { state = 'theirs', lines = conflict.theirs_lines }, + { state = 'both_ours_then_theirs', lines = vim.list_extend(vim.deepcopy(conflict.ours_lines), conflict.theirs_lines) }, + { state = 'both_theirs_then_ours', lines = vim.list_extend(vim.deepcopy(conflict.theirs_lines), conflict.ours_lines) }, + { state = 'base_only', lines = conflict.base_lines }, + } +end + +local function collect_outcomes_strict(conflict, current_lines, cursor, next_stable, is_last) + local outcomes = {} + local seen = {} + + local function add_outcome(state, current_segment, next_cursor, kind) + local key = table.concat({ + state, + tostring(next_cursor), + tostring(#current_segment), + kind, + }, ':') + if seen[key] then + return + end + + seen[key] = true + outcomes[#outcomes + 1] = { + state = state, + current_lines = current_segment, + next_cursor = next_cursor, + kind = kind, + } + end + + local block, block_next = parse_conflict_block(current_lines, cursor) + if block then + if same_lines(block.ours_lines, conflict.ours_lines) + and same_lines(block.theirs_lines, conflict.theirs_lines) + and (#block.base_lines == 0 or same_lines(block.base_lines, conflict.base_lines)) + then + add_outcome('unresolved', block.full_lines, block_next, 'marker') + else + add_outcome('manual_unresolved', block.full_lines, block_next, 'marker') + end + end + + for _, candidate in ipairs(clean_candidates(conflict)) do + if same_lines_at(current_lines, cursor, candidate.lines) then + add_outcome(candidate.state, candidate.lines, cursor + #candidate.lines, 'clean') + end + end + + if is_last then + if #next_stable == 0 then + add_outcome('manual_unresolved', slice_lines(current_lines, cursor, #current_lines), #current_lines + 1, 'manual') + else + for _, position in ipairs(find_sequence_positions(current_lines, next_stable, cursor)) do + if position + #next_stable - 1 == #current_lines then + add_outcome('manual_unresolved', slice_lines(current_lines, cursor, position - 1), position, 'manual') + end + end + end + elseif #next_stable > 0 then + for _, position in ipairs(find_sequence_positions(current_lines, next_stable, cursor)) do + if position > cursor then + add_outcome('manual_unresolved', slice_lines(current_lines, cursor, position - 1), position, 'manual') + end + end + end + + return outcomes +end + +local function infer_conflict_states_strict(stable_segments, conflicts, current_lines) + local memo = {} + + local function solve(index, cursor) + local key = index .. ':' .. cursor + if memo[key] ~= nil then + return memo[key] or nil + end + + if index > #conflicts then + local suffix = stable_segments[#conflicts + 1] or {} + if same_lines_at(current_lines, cursor, suffix) and (cursor + #suffix - 1) == #current_lines then + memo[key] = {} + return memo[key] + end + memo[key] = false + return nil + end + + local before = stable_segments[index] or {} + if not same_lines_at(current_lines, cursor, before) then + memo[key] = false + return nil + end + + local after_before = cursor + #before + local next_stable = stable_segments[index + 1] or {} + local outcomes = collect_outcomes_strict(conflicts[index], current_lines, after_before, next_stable, index == #conflicts) + + for _, outcome in ipairs(outcomes) do + local tail = solve(index + 1, outcome.next_cursor) + if tail then + local resolved = { outcome } + for _, item in ipairs(tail) do + resolved[#resolved + 1] = item + end + memo[key] = resolved + return resolved + end + end + + memo[key] = false + return nil + end + + return solve(1, 1) +end + +local function conflict_marker_ranges(lines) + local ranges = {} + local index = 1 + + while index <= #lines do + if lines[index]:match('^<<<<<<<') then + local _, next_index = parse_conflict_block(lines, index) + if next_index then + ranges[#ranges + 1] = { + start = index, + stop = next_index - 1, + } + index = next_index + else + index = index + 1 + end + else + index = index + 1 + end + end + + return ranges +end + +local function position_in_ranges(position, ranges) + for _, range in ipairs(ranges) do + if position >= range.start and position <= range.stop then + return true + end + end + + return false +end + +local function occurrence_key(occurrence) + return table.concat({ + occurrence.state, + occurrence.kind, + tostring(occurrence.start), + tostring(occurrence.stop), + }, ':') +end + +local function collect_relaxed_occurrences(conflict, current_lines, cursor, marker_ranges) + local occurrences = {} + local seen = {} + + local function add_occurrence(occurrence) + local key = occurrence_key(occurrence) + if seen[key] then + return + end + + seen[key] = true + occurrences[#occurrences + 1] = occurrence + end + + for index = cursor, #current_lines do + if current_lines[index]:match('^<<<<<<<') then + local block, next_index = parse_conflict_block(current_lines, index) + if block then + local state = 'manual_unresolved' + if same_lines(block.ours_lines, conflict.ours_lines) + and same_lines(block.theirs_lines, conflict.theirs_lines) + and (#block.base_lines == 0 or same_lines(block.base_lines, conflict.base_lines)) + then + state = 'unresolved' + end + + add_occurrence({ + state = state, + current_lines = block.full_lines, + kind = 'marker', + start = index, + stop = next_index - 1, + }) + end + end + end + + for _, candidate in ipairs(clean_candidates(conflict)) do + if #candidate.lines == 0 then + for position = cursor, #current_lines + 1 do + add_occurrence({ + state = candidate.state, + current_lines = candidate.lines, + kind = 'clean', + 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 + add_occurrence({ + state = candidate.state, + current_lines = candidate.lines, + kind = 'clean', + start = position, + stop = position + #candidate.lines - 1, + }) + end + end + end + end + + table.sort(occurrences, function(left, right) + if left.start ~= right.start then + return left.start < right.start + end + if left.stop ~= right.stop then + return left.stop < right.stop + end + if left.kind ~= right.kind then + return left.kind < right.kind + end + return left.state < right.state + end) + + return occurrences +end + +local function infer_conflict_states_relaxed(canonical_stable_segments, conflicts, current_lines) + local marker_ranges = conflict_marker_ranges(current_lines) + local memo = {} + + local function solve(index, cursor) + local key = index .. ':' .. cursor + if memo[key] ~= nil then + return memo[key] or nil + end + + if index > #conflicts then + local suffix = slice_lines(current_lines, cursor, #current_lines) + local solution = { + cost = math.abs(#suffix - #(canonical_stable_segments[#conflicts + 1] or {})), + stable_segments = { suffix }, + outcomes = {}, + } + memo[key] = solution + return solution + end + + 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 + local tail = solve(index + 1, occurrence.stop + 1) + if tail then + local stable_before = slice_lines(current_lines, cursor, occurrence.start - 1) + local cost = math.abs(#stable_before - #expected_stable) + tail.cost + + local stable_segments = { stable_before } + for _, segment in ipairs(tail.stable_segments) do + stable_segments[#stable_segments + 1] = segment + end + + local outcomes = { + { + state = occurrence.state, + current_lines = occurrence.current_lines, + next_cursor = occurrence.stop + 1, + kind = occurrence.kind, + }, + } + for _, item in ipairs(tail.outcomes) do + outcomes[#outcomes + 1] = item + end + + local solution = { + cost = cost, + stable_segments = stable_segments, + outcomes = outcomes, + } + + if not best + or solution.cost < best.cost + or (solution.cost == best.cost and occurrence.start < best.outcomes[1].next_cursor) + then + best = solution + end + end + end + + memo[key] = best or false + return best + end + + local resolved = solve(1, 1) + if not resolved then + return nil, nil + end + + return resolved.stable_segments, resolved.outcomes +end + +local function display_lines_for(conflict) + if conflict.state == 'unresolved' then + return conflict.base_lines + end + + if conflict.state == 'manual_unresolved' then + if conflict.current_kind == 'marker' or contains_conflict_markers(conflict.current_lines) then + return conflict.base_lines + end + return conflict.current_lines + end + + return conflict.current_lines +end + +local function display_ends_with_newline(conflict, current_ends_with_newline) + if conflict.state == 'unresolved' then + return conflict.base_ends_with_newline + end + + if conflict.state == 'manual_unresolved' then + if conflict.current_kind == 'marker' or contains_conflict_markers(conflict.current_lines) then + return conflict.base_ends_with_newline + end + return current_ends_with_newline + end + + if conflict.state == 'ours' then + return conflict.ours_ends_with_newline + end + if conflict.state == 'theirs' then + return conflict.theirs_ends_with_newline + end + if conflict.state == 'both_ours_then_theirs' then + return conflict.theirs_ends_with_newline + end + if conflict.state == 'both_theirs_then_ours' then + return conflict.ours_ends_with_newline + end + if conflict.state == 'base_only' then + return conflict.base_ends_with_newline + end + + return current_ends_with_newline +end + +local function apply_states(stable_segments, conflicts, outcomes) + if not outcomes then + for _, conflict in ipairs(conflicts) do + conflict.state = 'unresolved' + conflict.current_lines = conflict.canonical_lines + conflict.current_kind = 'marker' + conflict.handled = false + end + return + end + + for index, conflict in ipairs(conflicts) do + local outcome = outcomes[index] + conflict.state = outcome.state + conflict.current_lines = outcome.current_lines + conflict.current_kind = outcome.kind + conflict.handled = conflict.state ~= 'unresolved' and conflict.state ~= 'manual_unresolved' + end +end + +local function build_result_projection(stable_segments, conflicts, current_ends_with_newline) + local lines = {} + local unresolved_count = 0 + + for index, conflict in ipairs(conflicts) do + local before = stable_segments[index] or {} + for _, line in ipairs(before) do + lines[#lines + 1] = line + end + + local display_lines = display_lines_for(conflict) + conflict.display_lines = display_lines + conflict.result_range = { + start = #lines + 1, + count = #display_lines, + } + if not conflict.handled then + unresolved_count = unresolved_count + 1 + end + + for _, line in ipairs(display_lines) do + lines[#lines + 1] = line + end + end + + local suffix = stable_segments[#conflicts + 1] or {} + for _, line in ipairs(suffix) do + lines[#lines + 1] = line + end + + if #suffix > 0 or #conflicts == 0 then + return lines, unresolved_count, current_ends_with_newline + end + + return lines, unresolved_count, display_ends_with_newline(conflicts[#conflicts], current_ends_with_newline) +end + +local function build_model(file, opts) + opts = opts or {} + local stage_entries = git.get_unmerged_stage_entries(file.path) + if not stage_entries[2] or not stage_entries[3] then + return nil, 'Glance merge inspector currently supports text conflicts with stage 2 and stage 3 entries only' + end + + local base_text = stage_entries[1] and git.get_file_text(file.path, ':1') or '' + local ours_text = git.get_file_text(file.path, ':2') + local theirs_text = git.get_file_text(file.path, ':3') + + local current_lines = opts.current_lines + local current_ends_with_newline = opts.current_ends_with_newline + if not current_lines then + local current_text = git.get_file_text(file.path) + current_lines, current_ends_with_newline = split_text(current_text) + elseif current_ends_with_newline == nil then + current_ends_with_newline = true + end + + local base_lines, base_ends_with_newline = split_text(base_text) + local ours_lines, ours_ends_with_newline = split_text(ours_text) + local theirs_lines, theirs_ends_with_newline = split_text(theirs_text) + + local canonical_lines, canonical_err = canonical_merge_lines(base_text, ours_text, theirs_text) + if not canonical_lines then + return nil, canonical_err + end + + local stable_segments, conflicts, parse_err = parse_canonical_sequence(canonical_lines) + if not stable_segments then + return nil, parse_err + end + + if #conflicts == 0 then + return nil, 'Glance merge inspector only opens text conflicts with merge hunks' + end + + assign_source_ranges(conflicts, base_lines, ours_lines, theirs_lines) + for _, conflict in ipairs(conflicts) do + conflict.base_ends_with_newline = base_ends_with_newline + conflict.ours_ends_with_newline = ours_ends_with_newline + conflict.theirs_ends_with_newline = theirs_ends_with_newline + end + local resolved_stable_segments = stable_segments + local outcomes = infer_conflict_states_strict(stable_segments, conflicts, current_lines) + if not outcomes then + resolved_stable_segments, outcomes = infer_conflict_states_relaxed(stable_segments, conflicts, current_lines) + end + + apply_states(stable_segments, conflicts, outcomes) + resolved_stable_segments = resolved_stable_segments or stable_segments + + local result_lines, unresolved_count, result_ends_with_newline = + build_result_projection(resolved_stable_segments, conflicts, current_ends_with_newline) + + return { + file = file, + operation = git.get_operation_context(), + stage_entries = stage_entries, + canonical_stable_segments = stable_segments, + stable_segments = resolved_stable_segments, + conflicts = conflicts, + base_lines = base_lines, + ours_lines = ours_lines, + theirs_lines = theirs_lines, + current_lines = current_lines, + current_ends_with_newline = current_ends_with_newline, + result_lines = result_lines, + result_ends_with_newline = result_ends_with_newline, + unresolved_count = unresolved_count, + inference_failed = outcomes == nil, + } +end + +local function persisted_conflict_lines(conflict) + if conflict.state == 'unresolved' then + return conflict.canonical_lines + end + + if conflict.state == 'manual_unresolved' then + if conflict.current_kind == 'marker' or contains_conflict_markers(conflict.current_lines) then + return conflict.current_lines + end + + return nil, 'cannot safely save unresolved manual merge edits yet' + end + + return conflict.current_lines +end + +local function build_persisted_lines(merge_model) + local lines = {} + local stable_segments = merge_model.stable_segments or merge_model.canonical_stable_segments or {} + + for index, conflict in ipairs(merge_model.conflicts) do + for _, line in ipairs(stable_segments[index] or {}) do + lines[#lines + 1] = line + end + + local persisted_lines, err = persisted_conflict_lines(conflict) + if not persisted_lines then + return nil, err + end + + for _, line in ipairs(persisted_lines) do + lines[#lines + 1] = line + end + end + + for _, line in ipairs(stable_segments[#merge_model.conflicts + 1] or {}) do + lines[#lines + 1] = line + end + + return lines +end + +local function reconcile_with_previous_model(merge_model, previous_model) + if type(previous_model) ~= 'table' or type(previous_model.conflicts) ~= 'table' then + return + end + + for index, conflict in ipairs(merge_model.conflicts) do + local previous = previous_model.conflicts[index] + -- Untouched unresolved conflicts project base text in the clean result pane, so + -- base_only stays ambiguous until a later slice adds explicit resolution actions. + if previous and previous.handled == false and conflict.state == 'base_only' then + conflict.state = 'unresolved' + conflict.handled = false + end + end + + local unresolved_count = 0 + for _, conflict in ipairs(merge_model.conflicts) do + if not conflict.handled then + unresolved_count = unresolved_count + 1 + end + end + merge_model.unresolved_count = unresolved_count +end + +function M.build(file, opts) + return build_model(file, opts) +end + +function M.prepare_write(file, current_lines, opts) + opts = opts or {} + local merge_model, err = build_model(file, { + current_lines = current_lines, + current_ends_with_newline = opts.current_ends_with_newline, + }) + if not merge_model then + return nil, err + end + reconcile_with_previous_model(merge_model, opts.previous_model) + if merge_model.inference_failed then + return nil, 'cannot safely map the current merge result back to git conflict state' + end + + local persisted_lines, persisted_err = build_persisted_lines(merge_model) + if not persisted_lines then + return nil, persisted_err + end + + return { + model = merge_model, + persisted_lines = persisted_lines, + persisted_text = join_text(persisted_lines, merge_model.current_ends_with_newline), + } +end + +return M diff --git a/lua/glance/merge/render.lua b/lua/glance/merge/render.lua new file mode 100644 index 0000000..697b48e --- /dev/null +++ b/lua/glance/merge/render.lua @@ -0,0 +1,168 @@ +local layout = require('glance.merge.layout') + +local M = {} + +local NS = vim.api.nvim_create_namespace('glance_merge') + +local function set_lines(buf, lines, opts) + opts = opts or {} + + vim.api.nvim_buf_set_option(buf, 'readonly', false) + vim.api.nvim_buf_set_option(buf, 'modifiable', true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + vim.api.nvim_buf_set_option(buf, 'modifiable', opts.modifiable == true) + vim.api.nvim_buf_set_option(buf, 'readonly', opts.readonly ~= false) + + if opts.swapfile ~= nil then + vim.api.nvim_buf_set_option(buf, 'swapfile', opts.swapfile) + end + + if opts.buftype then + vim.api.nvim_buf_set_option(buf, 'buftype', opts.buftype) + end + + if opts.modified ~= nil then + vim.api.nvim_set_option_value('modified', opts.modified, { buf = buf }) + end +end + +local function set_window_label(win, label) + if not win or not vim.api.nvim_win_is_valid(win) then + return + end + + vim.api.nvim_set_option_value('winbar', label or '', { win = win }) +end + +local function role_label(model, role) + local op = model.operation or {} + local parts = {} + + if op.prefix then + parts[#parts + 1] = op.prefix + end + + if role == layout.THEIRS_ROLE then + parts[#parts + 1] = 'Theirs' + parts[#parts + 1] = 'stage 3' + if op.theirs_display then + parts[#parts + 1] = op.theirs_display + end + elseif role == layout.OURS_ROLE then + parts[#parts + 1] = 'Ours' + parts[#parts + 1] = 'stage 2' + if op.ours_display then + parts[#parts + 1] = op.ours_display + end + elseif role == layout.RESULT_ROLE then + parts[#parts + 1] = 'Result' + parts[#parts + 1] = string.format('%d unresolved', model.unresolved_count) + if model.inference_failed then + parts[#parts + 1] = 'inference fallback' + end + end + + return table.concat(parts, ' | ') +end + +local function clear_buffer(buf) + if not buf or not vim.api.nvim_buf_is_valid(buf) then + return + end + + vim.api.nvim_buf_clear_namespace(buf, NS, 0, -1) +end + +local function add_line_range(buf, start_line, count, group) + if not buf or not vim.api.nvim_buf_is_valid(buf) then + return + end + if not start_line or count <= 0 then + return + end + + for index = start_line, start_line + count - 1 do + vim.api.nvim_buf_add_highlight(buf, NS, group, index - 1, 0, -1) + end +end + +local function decorate_sources(buffers, model) + clear_buffer(buffers.theirs) + clear_buffer(buffers.ours) + clear_buffer(buffers.result) + + for _, conflict in ipairs(model.conflicts) do + add_line_range(buffers.theirs, conflict.theirs_range.start, conflict.theirs_range.count, 'GlanceDiffChangeOld') + add_line_range(buffers.ours, conflict.ours_range.start, conflict.ours_range.count, 'GlanceDiffChangeNew') + + local group = conflict.handled and 'GlanceAccentText' or 'DiffChange' + add_line_range(buffers.result, conflict.result_range.start, conflict.result_range.count, group) + + local label = conflict.handled and ('handled: ' .. conflict.state) or 'unresolved' + local line_count = math.max(vim.api.nvim_buf_line_count(buffers.result), 1) + local anchor_line = math.min(math.max(conflict.result_range.start, 1), line_count) - 1 + + if conflict.result_range.count > 0 then + vim.api.nvim_buf_set_extmark(buffers.result, NS, conflict.result_range.start - 1, 0, { + virt_text = { { label, 'Comment' } }, + virt_text_pos = 'eol', + }) + else + vim.api.nvim_buf_set_extmark(buffers.result, NS, anchor_line, 0, { + virt_lines = { { { label, 'Comment' } } }, + }) + end + end +end + +local function prepare_source_buffer(diffview, buf, name, lines, path) + pcall(vim.api.nvim_buf_set_name, buf, name) + set_lines(buf, lines, { + buftype = 'nofile', + modifiable = false, + readonly = true, + swapfile = false, + modified = false, + }) + diffview.set_filetype_from_path(buf, path) +end + +local function prepare_result_buffer(diffview, buf, lines, path, ends_with_newline) + set_lines(buf, lines, { + modifiable = true, + readonly = false, + modified = false, + }) + diffview.set_filetype_from_path(buf, path) + vim.api.nvim_set_option_value('endofline', ends_with_newline ~= false, { buf = buf }) +end + +function M.apply(diffview, panes, model, file) + prepare_source_buffer( + diffview, + panes.theirs.buf, + 'glance://merge/theirs/' .. file.path, + model.theirs_lines, + file.path + ) + prepare_source_buffer( + diffview, + panes.ours.buf, + 'glance://merge/ours/' .. file.path, + model.ours_lines, + file.path + ) + prepare_result_buffer(diffview, panes.result.buf, model.result_lines, file.path, model.result_ends_with_newline) + + set_window_label(panes.theirs.win, role_label(model, layout.THEIRS_ROLE)) + set_window_label(panes.ours.win, role_label(model, layout.OURS_ROLE)) + set_window_label(panes.result.win, role_label(model, layout.RESULT_ROLE)) + + decorate_sources({ + theirs = panes.theirs.buf, + ours = panes.ours.buf, + result = panes.result.buf, + }, model) +end + +return M diff --git a/tests/helpers/repo.lua b/tests/helpers/repo.lua index d66c5e3..d99c87d 100644 --- a/tests/helpers/repo.lua +++ b/tests/helpers/repo.lua @@ -211,6 +211,88 @@ function scenarios.repo_conflict(fixture) assert(not ok, 'expected merge conflict fixture') end +function scenarios.repo_conflict_add_add(fixture) + seed_committed_file(fixture, 'anchor.txt', 'anchor\n', 'anchor') + fixture.files.tracked = 'tracked.txt' + local main_branch = vim.trim(fixture:git({ 'rev-parse', '--abbrev-ref', 'HEAD' })) + + fixture:git({ 'checkout', '-b', 'feature' }) + fixture:write(fixture.files.tracked, 'feature add\n') + fixture:commit_all('Feature add') + + fixture:git({ 'checkout', main_branch }) + fixture:write(fixture.files.tracked, 'main add\n') + fixture:commit_all('Main add') + + local ok = pcall(function() + fixture:git({ 'merge', 'feature' }) + end) + assert(not ok, 'expected add/add conflict fixture') +end + +function scenarios.repo_conflict_multi(fixture) + seed_committed_file(fixture, 'tracked.txt', table.concat({ + 'intro', + 'first base', + 'gap one', + 'gap two', + 'gap three', + 'second base', + 'outro', + '', + }, '\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({ + 'intro', + 'first feature', + 'gap one', + 'gap two', + 'gap three', + 'second feature', + 'outro', + '', + }, '\n')) + fixture:commit_all('Feature multi conflict') + + fixture:git({ 'checkout', main_branch }) + fixture:write(fixture.files.tracked, table.concat({ + 'intro', + 'first main', + 'gap one', + 'gap two', + 'gap three', + 'second main', + 'outro', + '', + }, '\n')) + fixture:commit_all('Main multi conflict') + + local ok = pcall(function() + fixture:git({ 'merge', 'feature' }) + end) + assert(not ok, 'expected multi-conflict fixture') +end + +function scenarios.repo_conflict_noeol(fixture) + seed_committed_file(fixture, 'tracked.txt', 'base') + local main_branch = vim.trim(fixture:git({ 'rev-parse', '--abbrev-ref', 'HEAD' })) + + fixture:git({ 'checkout', '-b', 'feature' }) + fixture:write(fixture.files.tracked, 'feature') + fixture:commit_all('Feature change') + + fixture:git({ 'checkout', main_branch }) + fixture:write(fixture.files.tracked, 'main') + fixture:commit_all('Main change') + + local ok = pcall(function() + fixture:git({ 'merge', 'feature' }) + end) + assert(not ok, 'expected merge conflict fixture without trailing newline') +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/helpers/state.lua b/tests/helpers/state.lua index 42d2a76..0a9fe5d 100644 --- a/tests/helpers/state.lua +++ b/tests/helpers/state.lua @@ -47,6 +47,14 @@ function M.reset() local minimap = loaded['glance.minimap'] local repo_sync = loaded['glance.repo_sync'] local ui = loaded['glance.ui'] + local workspace = loaded['glance.workspace'] + + local diff_wins = {} + local diff_bufs = {} + if diffview and diffview.workspace and workspace and workspace.collect_windows and workspace.collect_buffers then + diff_wins = workspace.collect_windows(diffview.workspace, { with_pane = true }) + diff_bufs = workspace.collect_buffers(diffview.workspace, { with_pane = true }) + end if diffview and diffview.stop_watching then pcall(diffview.stop_watching) @@ -74,12 +82,18 @@ function M.reset() close_window(commit_editor and commit_editor.win) close_window(log_view and log_view.win) + for _, win in ipairs(diff_wins) do + close_window(win) + end delete_buffer(minimap and minimap.buf) delete_buffer(commit_editor and commit_editor.buf) delete_buffer(log_view and log_view.buf) delete_buffer(ui and ui.welcome_buf) delete_buffer(diffview and diffview.old_buf) delete_buffer(diffview and diffview.new_buf) + for _, buf in ipairs(diff_bufs) do + delete_buffer(buf) + end delete_buffer(filetree and filetree.buf) if diffview then diff --git a/tests/integration/diffview_spec.lua b/tests/integration/diffview_spec.lua index 01a4171..f2ca9b2 100644 --- a/tests/integration/diffview_spec.lua +++ b/tests/integration/diffview_spec.lua @@ -107,7 +107,7 @@ return { end, }, { - name = 'conflicted files open a single editable pane with conflict markers', + name = 'conflicted files open a 3-pane merge inspector with a clean result projection', run = function() N.with_repo('repo_conflict', function() require('glance').start() @@ -115,71 +115,291 @@ return { local filetree = require('glance.filetree') local diffview = require('glance.diffview') local minimap = require('glance.minimap') - local conflict_ns = vim.api.nvim_create_namespace('glance_conflict') + local workspace = require('glance.workspace') ui.open_file(filetree.files.conflicts[1]) - A.equal(diffview.old_win, nil) - A.truthy(diffview.new_win and vim.api.nvim_win_is_valid(diffview.new_win)) - A.equal(vim.api.nvim_get_option_value('buftype', { buf = diffview.new_buf }), '') - A.equal(vim.api.nvim_get_option_value('readonly', { buf = diffview.new_buf }), false) - A.equal(vim.api.nvim_get_option_value('diff', { win = diffview.new_win }), false) - A.equal(vim.api.nvim_get_option_value('winbar', { win = diffview.new_win }), 'Conflict: unresolved markers') + local theirs_win = workspace.get_win(diffview.workspace, 'merge_theirs') + local ours_win = workspace.get_win(diffview.workspace, 'merge_ours') + local result_win = workspace.get_win(diffview.workspace, 'merge_result') + local theirs_buf = workspace.get_buf(diffview.workspace, 'merge_theirs') + local ours_buf = workspace.get_buf(diffview.workspace, 'merge_ours') + local result_buf = workspace.get_buf(diffview.workspace, 'merge_result') + + A.same(diffview.content_roles(), { + 'merge_theirs', + 'merge_ours', + 'merge_result', + }) + A.truthy(theirs_win and vim.api.nvim_win_is_valid(theirs_win)) + A.truthy(ours_win and vim.api.nvim_win_is_valid(ours_win)) + A.truthy(result_win and vim.api.nvim_win_is_valid(result_win)) + A.equal(vim.api.nvim_get_option_value('diff', { win = theirs_win }), false) + A.equal(vim.api.nvim_get_option_value('diff', { win = ours_win }), false) + A.equal(vim.api.nvim_get_option_value('diff', { win = result_win }), false) + A.equal(vim.api.nvim_get_option_value('buftype', { buf = theirs_buf }), 'nofile') + A.equal(vim.api.nvim_get_option_value('readonly', { buf = theirs_buf }), true) + A.equal(vim.api.nvim_get_option_value('buftype', { buf = ours_buf }), 'nofile') + A.equal(vim.api.nvim_get_option_value('readonly', { buf = ours_buf }), true) + A.equal(vim.api.nvim_get_option_value('buftype', { buf = result_buf }), '') + A.equal(vim.api.nvim_get_option_value('readonly', { buf = result_buf }), false) + A.equal(vim.api.nvim_get_option_value('modifiable', { buf = result_buf }), true) A.equal(minimap.win, nil) - A.truthy(#vim.api.nvim_buf_get_extmarks(diffview.new_buf, conflict_ns, 0, -1, {}) > 0) + A.same(vim.api.nvim_buf_get_lines(theirs_buf, 0, -1, false), { 'feature' }) + A.same(vim.api.nvim_buf_get_lines(ours_buf, 0, -1, false), { 'main' }) + A.same(vim.api.nvim_buf_get_lines(result_buf, 0, -1, false), { 'base' }) + A.contains(vim.api.nvim_get_option_value('winbar', { win = theirs_win }), 'Theirs | stage 3 | MERGE_HEAD') + A.contains(vim.api.nvim_get_option_value('winbar', { win = ours_win }), 'Ours | stage 2 | HEAD') + A.contains(vim.api.nvim_get_option_value('winbar', { win = result_win }), 'Result | 1 unresolved') + A.equal(vim.api.nvim_get_current_win(), result_win) + A.equal(vim.api.nvim_win_get_cursor(result_win)[1], 1) + end) + end, + }, + { + name = 'merge refresh preserves unsaved result edits', + run = function() + N.with_repo('repo_conflict', function() + require('glance').start() + local ui = require('glance.ui') + local filetree = require('glance.filetree') + local diffview = require('glance.diffview') + local workspace = require('glance.workspace') - local text = table.concat(vim.api.nvim_buf_get_lines(diffview.new_buf, 0, -1, false), '\n') - A.contains(text, '<<<<<<<') - A.contains(text, '=======') - A.contains(text, '>>>>>>>') + ui.open_file(filetree.files.conflicts[1]) - local keymaps = vim.api.nvim_buf_get_keymap(diffview.new_buf, 'n') - local found_next = false - local found_prev = false - for _, map in ipairs(keymaps) do - if map.lhs == ']x' then - found_next = true - elseif map.lhs == '[x' then - found_prev = true - end - end - A.truthy(found_next) - A.truthy(found_prev) + local result_buf = workspace.get_buf(diffview.workspace, 'merge_result') + vim.api.nvim_buf_set_lines(result_buf, 0, -1, false, { + 'manual draft line 1', + 'manual draft line 2', + }) + + diffview.refresh(filetree.files.conflicts[1]) + + A.same(vim.api.nvim_buf_get_lines(result_buf, 0, -1, false), { + 'manual draft line 1', + 'manual draft line 2', + }) + A.equal(vim.api.nvim_get_option_value('modified', { buf = result_buf }), true) end) end, }, { - name = 'conflict navigation keymaps jump to unresolved markers', + name = 'add/add conflicts stay visible and navigable even when the result projection is empty', run = function() - N.with_repo('repo_conflict', function() + N.with_repo('repo_conflict_add_add', function() 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]) - vim.api.nvim_set_current_win(diffview.new_win) - local lines = vim.api.nvim_buf_get_lines(diffview.new_buf, 0, -1, false) - local marker_line - for index, line in ipairs(lines) do - if line:match('^<<<<<<<') then - marker_line = index + local result_buf = workspace.get_buf(diffview.workspace, 'merge_result') + local result_win = workspace.get_win(diffview.workspace, 'merge_result') + local theirs_win = workspace.get_win(diffview.workspace, 'merge_theirs') + local merge_ns = vim.api.nvim_get_namespaces().glance_merge + local marks = vim.api.nvim_buf_get_extmarks(result_buf, merge_ns, 0, -1, { details = true }) + + A.truthy(#marks > 0) + A.equal(vim.api.nvim_get_current_win(), result_win) + A.equal(vim.api.nvim_win_get_cursor(result_win)[1], 1) + + vim.api.nvim_set_current_win(theirs_win) + N.press(']x') + A.equal(vim.api.nvim_get_current_win(), result_win) + A.equal(vim.api.nvim_win_get_cursor(result_win)[1], 1) + end) + end, + }, + { + name = 'merge writes preserve the clean result buffer while persisting unresolved marker form to disk', + run = function() + N.with_repo('repo_conflict_multi', 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 expected_result = { + 'intro updated', + 'first main', + 'gap one adjusted', + 'gap two', + 'gap three', + 'second base', + 'outro updated', + } + + vim.api.nvim_buf_set_lines(result_buf, 0, -1, false, expected_result) + vim.api.nvim_buf_call(result_buf, function() + vim.cmd('write') + end) + + A.same(vim.api.nvim_buf_get_lines(result_buf, 0, -1, false), expected_result) + 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 }), 'Result | 1 unresolved') + A.equal(repo:read(repo.files.tracked), table.concat({ + 'intro updated', + 'first main', + 'gap one adjusted', + 'gap two', + 'gap three', + '<<<<<<< Ours', + 'second main', + '||||||| Base', + 'second base', + '=======', + 'second feature', + '>>>>>>> Theirs', + 'outro updated', + '', + }, '\n')) + + diffview.close(true) + ui.open_file(filetree.files.conflicts[1]) + + local reopened_buf = workspace.get_buf(diffview.workspace, 'merge_result') + A.same(vim.api.nvim_buf_get_lines(reopened_buf, 0, -1, false), expected_result) + end) + end, + }, + { + name = 'merge writes preserve no-trailing-newline state', + run = function() + N.with_repo('repo_conflict_noeol', 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') + 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' }) + vim.api.nvim_set_option_value('endofline', false, { buf = result_buf }) + vim.api.nvim_buf_call(result_buf, function() + vim.cmd('write') + end) + + A.equal(repo:read(repo.files.tracked), 'main') + A.equal(vim.api.nvim_get_option_value('endofline', { buf = result_buf }), false) + end) + end, + }, + { + name = 'merge writes persist a fully resolved clean result without conflict markers', + run = function() + N.with_repo('repo_conflict', 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') + vim.api.nvim_buf_set_lines(result_buf, 0, -1, false, { 'main' }) + + vim.api.nvim_buf_call(result_buf, function() + vim.cmd('write') + end) + + A.same(vim.api.nvim_buf_get_lines(result_buf, 0, -1, false), { 'main' }) + 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 }), 'Result | 0 unresolved') + A.equal(repo:read(repo.files.tracked), 'main\n') + end) + end, + }, + { + name = 'merge writes fail closed for unresolved manual result edits that cannot be serialized safely', + run = function() + N.with_repo('repo_conflict', 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') + local messages, restore_notify = N.capture_notifications() + + ui.open_file(filetree.files.conflicts[1]) + + local result_buf = workspace.get_buf(diffview.workspace, 'merge_result') + vim.api.nvim_buf_set_lines(result_buf, 0, -1, false, { + 'custom unresolved draft', + 'with extra context', + }) + + vim.api.nvim_buf_call(result_buf, function() + vim.cmd('write') + end) + + restore_notify() + + local warned = false + for _, entry in ipairs(messages) do + if entry.msg:find('cannot safely', 1, true) then + warned = true break end end - A.truthy(marker_line) + A.truthy(warned) + A.equal(vim.api.nvim_get_option_value('modified', { buf = result_buf }), true) + A.truthy(repo:read(repo.files.tracked):match('^<<<<<<<') ~= nil) + end) + end, + }, + { + name = 'merge conflict navigation keymaps move through unresolved conflicts in the result pane', + run = function() + N.with_repo('repo_conflict_multi', function() + 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]) - vim.api.nvim_win_set_cursor(diffview.new_win, { #lines, 0 }) + local theirs_win = workspace.get_win(diffview.workspace, 'merge_theirs') + local result_win = workspace.get_win(diffview.workspace, 'merge_result') + local result_buf = workspace.get_buf(diffview.workspace, 'merge_result') + + A.same(vim.api.nvim_buf_get_lines(result_buf, 0, -1, false), { + 'intro', + 'first base', + 'gap one', + 'gap two', + 'gap three', + 'second base', + 'outro', + }) + A.equal(vim.api.nvim_get_current_win(), result_win) + A.equal(vim.api.nvim_win_get_cursor(result_win)[1], 2) + + vim.api.nvim_set_current_win(theirs_win) N.press(']x') - A.equal(vim.api.nvim_win_get_cursor(diffview.new_win)[1], marker_line) + A.equal(vim.api.nvim_get_current_win(), result_win) + A.equal(vim.api.nvim_win_get_cursor(result_win)[1], 6) + N.press(']x') - A.equal(vim.api.nvim_win_get_cursor(diffview.new_win)[1], marker_line) + A.equal(vim.api.nvim_win_get_cursor(result_win)[1], 2) - vim.api.nvim_win_set_cursor(diffview.new_win, { #lines, 0 }) N.press('[x') - A.equal(vim.api.nvim_win_get_cursor(diffview.new_win)[1], marker_line) + A.equal(vim.api.nvim_win_get_cursor(result_win)[1], 6) end) end, }, diff --git a/tests/unit/git_spec.lua b/tests/unit/git_spec.lua index b091579..54fdc4c 100644 --- a/tests/unit/git_spec.lua +++ b/tests/unit/git_spec.lua @@ -1259,6 +1259,103 @@ return { end) end, }, + { + name = 'get_operation_context reports rebase conflict metadata from git sentinels', + run = function() + N.with_repo('repo_no_changes', function(repo) + local git = require('glance.git') + local main_branch = vim.trim(repo:git({ 'rev-parse', '--abbrev-ref', 'HEAD' })) + + repo:write(repo.files.tracked, 'base\n') + repo:commit_all('Normalize fixture') + + repo:git({ 'checkout', '-b', 'topic' }) + repo:write(repo.files.tracked, 'topic change\n') + repo:commit_all('Topic change') + + repo:git({ 'checkout', main_branch }) + repo:write(repo.files.tracked, 'main change\n') + repo:commit_all('Main change') + + repo:git({ 'checkout', 'topic' }) + + local ok = pcall(function() + repo:git({ 'rebase', main_branch }) + end) + A.falsy(ok) + + local context = git.get_operation_context() + + A.equal(context.kind, 'rebase') + A.equal(context.prefix, 'Rebasing') + A.equal(context.ours_ref, 'HEAD') + A.truthy(type(context.theirs_display) == 'string' and context.theirs_display ~= '') + end) + end, + }, + { + name = 'get_operation_context reports cherry-pick conflict metadata from git sentinels', + run = function() + N.with_repo('repo_no_changes', function(repo) + local git = require('glance.git') + local main_branch = vim.trim(repo:git({ 'rev-parse', '--abbrev-ref', 'HEAD' })) + + repo:write(repo.files.tracked, 'base\n') + repo:commit_all('Normalize fixture') + + repo:git({ 'checkout', '-b', 'topic' }) + repo:write(repo.files.tracked, 'topic change\n') + repo:commit_all('Topic change') + local topic_commit = vim.trim(repo:git({ 'rev-parse', 'HEAD' })) + + repo:git({ 'checkout', main_branch }) + repo:write(repo.files.tracked, 'main change\n') + repo:commit_all('Main change') + + local ok = pcall(function() + repo:git({ 'cherry-pick', topic_commit }) + end) + A.falsy(ok) + + local context = git.get_operation_context() + + A.equal(context.kind, 'cherry_pick') + A.equal(context.prefix, 'Cherry-picking') + A.equal(context.theirs_ref, 'CHERRY_PICK_HEAD') + A.contains(context.theirs_display, 'CHERRY_PICK_HEAD') + end) + end, + }, + { + name = 'get_operation_context reports revert conflict metadata from git sentinels', + run = function() + N.with_repo('repo_no_changes', function(repo) + local git = require('glance.git') + + repo:write(repo.files.tracked, 'base\n') + repo:commit_all('Normalize fixture') + + repo:write(repo.files.tracked, 'topic change\n') + repo:commit_all('Topic change') + local revert_target = vim.trim(repo:git({ 'rev-parse', 'HEAD' })) + + repo:write(repo.files.tracked, 'main change\n') + repo:commit_all('Main change') + + local ok = pcall(function() + repo:git({ 'revert', '--no-edit', revert_target }) + end) + A.falsy(ok) + + local context = git.get_operation_context() + + A.equal(context.kind, 'revert') + A.equal(context.prefix, 'Reverting') + A.equal(context.theirs_ref, 'REVERT_HEAD') + A.contains(context.theirs_display, 'REVERT_HEAD') + end) + end, + }, { name = 'integration classifies type changes from git status', run = function() diff --git a/tests/unit/merge_model_spec.lua b/tests/unit/merge_model_spec.lua new file mode 100644 index 0000000..c7df4d2 --- /dev/null +++ b/tests/unit/merge_model_spec.lua @@ -0,0 +1,205 @@ +local A = require('tests.helpers.assert') +local N = require('tests.helpers.nvim') + +return { + name = 'merge_model', + cases = { + { + name = 'build reconstructs a text conflict from stages and shows base in the result projection', + 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] + local built = assert(merge_model.build(file)) + + A.equal(built.operation.kind, 'merge') + A.equal(built.unresolved_count, 1) + A.same(built.base_lines, { 'base' }) + A.same(built.ours_lines, { 'main' }) + A.same(built.theirs_lines, { 'feature' }) + A.equal(built.conflicts[1].state, 'unresolved') + A.same(built.result_lines, { 'base' }) + A.contains(built.operation.theirs_display, 'MERGE_HEAD') + A.contains(built.operation.theirs_display, 'feature') + A.equal(repo:read(repo.files.tracked):match('^<<<<<<<') ~= nil, true) + end) + end, + }, + { + name = 'build rehydrates a clean ours resolution without conflict markers', + 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' }) + end) + end, + }, + { + name = 'build keeps mixed handled and unresolved conflicts in order', + run = function() + N.with_repo('repo_conflict_multi', 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, table.concat({ + 'intro', + 'first main', + 'gap one', + 'gap two', + 'gap three', + '<<<<<<< HEAD', + 'second main', + '=======', + 'second feature', + '>>>>>>> feature', + 'outro', + '', + }, '\n')) + + local built = assert(merge_model.build(file)) + + A.equal(built.unresolved_count, 1) + A.equal(#built.conflicts, 2) + A.equal(built.conflicts[1].state, 'ours') + A.equal(built.conflicts[2].state, 'unresolved') + A.same(built.result_lines, { + 'intro', + 'first main', + 'gap one', + 'gap two', + 'gap three', + 'second base', + 'outro', + }) + A.equal(built.conflicts[1].result_range.start, 2) + A.equal(built.conflicts[2].result_range.start, 6) + end) + end, + }, + { + name = 'build preserves stable edits around recognized conflict states', + run = function() + N.with_repo('repo_conflict_multi', 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, table.concat({ + 'intro updated', + 'first main', + 'gap one adjusted', + 'gap two', + 'gap three', + '<<<<<<< HEAD', + 'second main', + '=======', + 'second feature', + '>>>>>>> feature', + 'outro updated', + '', + }, '\n')) + + local built = assert(merge_model.build(file)) + + A.equal(built.unresolved_count, 1) + A.equal(built.conflicts[1].state, 'ours') + A.equal(built.conflicts[2].state, 'unresolved') + A.same(built.result_lines, { + 'intro updated', + 'first main', + 'gap one adjusted', + 'gap two', + 'gap three', + 'second base', + 'outro updated', + }) + end) + end, + }, + { + name = 'prepare_write reconstructs marker form while preserving stable edits and handled conflicts', + run = function() + N.with_repo('repo_conflict_multi', function(repo) + 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, { + 'intro updated', + 'first main', + 'gap one adjusted', + 'gap two', + 'gap three', + 'second base', + 'outro updated', + }, { + previous_model = previous_model, + })) + + A.equal(prepared.model.unresolved_count, 1) + A.same(prepared.persisted_lines, { + 'intro updated', + 'first main', + 'gap one adjusted', + 'gap two', + 'gap three', + '<<<<<<< Ours', + 'second main', + '||||||| Base', + 'second base', + '=======', + 'second feature', + '>>>>>>> Theirs', + 'outro updated', + }) + end) + end, + }, + { + name = 'build keeps add/add conflicts addressable even without base lines', + run = function() + N.with_repo('repo_conflict_add_add', 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.same(built.base_lines, {}) + A.equal(built.conflicts[1].result_range.start, 1) + A.equal(built.conflicts[1].result_range.count, 0) + end) + end, + }, + { + name = 'prepare_write preserves no-trailing-newline state', + 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 prepared = assert(merge_model.prepare_write(file, { 'main' }, { + current_ends_with_newline = false, + previous_model = assert(merge_model.build(file)), + })) + + A.equal(prepared.persisted_text, 'main') + end) + end, + }, + }, +} From 22ebabcfe37a52a35668fc9157993cfa6a912ca3 Mon Sep 17 00:00:00 2001 From: Rohan Bansal Date: Sun, 26 Apr 2026 15:30:29 -0400 Subject: [PATCH 2/2] trigger ci again