diff --git a/lua/glance/git.lua b/lua/glance/git.lua index dc7d139..bdb3d77 100644 --- a/lua/glance/git.lua +++ b/lua/glance/git.lua @@ -36,6 +36,8 @@ M._repo_root_cwd = nil M._git_dir = nil M._git_dir_root = nil +local enrich_conflict_files + local function empty_files() return { staged = {}, changes = {}, untracked = {}, conflicts = {} } end @@ -362,6 +364,13 @@ local function discard_new_path(filepath) delete_worktree_path(filepath) end +local function ensure_parent_dir(path) + local parent = vim.fn.fnamemodify(path, ':h') + if parent ~= '.' and parent ~= '' then + vim.fn.mkdir(parent, 'p') + end +end + --- Check if the current directory is inside a git repository. function M.is_repo() local result = vim.fn.system('git rev-parse --is-inside-work-tree 2>/dev/null') @@ -421,6 +430,111 @@ local function build_file_entry(entry, section) } end +local function parse_unmerged_stage_output(output) + local entries = {} + if type(output) ~= 'string' or output == '' then + return entries + end + + 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[#entries + 1] = { + mode = mode, + oid = oid, + stage = tonumber(stage), + path = path, + } + end + end + + return entries +end + +local function entries_by_path(entries) + local by_path = {} + for _, entry in ipairs(entries or {}) do + by_path[entry.path] = by_path[entry.path] or {} + by_path[entry.path][entry.stage] = entry + end + return by_path +end + +local function sorted_unique_paths(entries) + local paths = {} + local seen = {} + for _, entry in ipairs(entries or {}) do + if type(entry.path) == 'string' and entry.path ~= '' and not seen[entry.path] then + seen[entry.path] = true + paths[#paths + 1] = entry.path + end + end + table.sort(paths) + return paths +end + +local function group_entries_for_paths(by_path, paths) + local entries = {} + for _, path in ipairs(paths or {}) do + for stage = 1, 3 do + local entry = by_path[path] and by_path[path][stage] or nil + if entry then + entries[#entries + 1] = entry + end + end + end + return entries +end + +local function stage_map(entries) + local stages = {} + for _, entry in ipairs(entries or {}) do + stages[entry.stage] = entry + end + return stages +end + +local function conflict_group_id(stages, paths) + local base = stages[1] and stages[1].path or nil + if base then + return 'base:' .. base + end + return 'paths:' .. table.concat(paths or {}, '\0') +end + +local function conflict_display_path(stages, paths) + if stages[2] and stages[2].path then + return stages[2].path + end + if stages[3] and stages[3].path then + return stages[3].path + end + if stages[1] and stages[1].path then + return stages[1].path + end + return (paths or {})[1] +end + +local function conflict_display_status(class) + local labels = { + modify_delete = 'MD', + rename_delete = 'RD', + rename_rename = 'RR', + non_text_add_add = 'AA', + binary = 'B', + } + return labels[class] or 'U' +end + +local function path_has_conflict_entry(conflict_files, path) + for _, file in ipairs(conflict_files or {}) do + if file.path == path then + return true + end + end + return false +end + --- Parse `git status --porcelain=v1` into raw entries. --- @param output string --- @return table[] @@ -561,12 +675,17 @@ local function build_status_snapshot(output, head_oid, opts) if index_signature == nil then index_signature = index_signature_at_root(opts.root or M.repo_root()) end + local files = M.build_file_sections(M.parse_porcelain_entries(output)) + if opts.enrich_conflicts ~= false then + files = enrich_conflict_files(files) + end + return { output = output, head_oid = head_oid, index_signature = index_signature, key = snapshot_key(output, head_oid, index_signature), - files = M.build_file_sections(M.parse_porcelain_entries(output)), + files = files, } end @@ -644,6 +763,7 @@ function M.get_status_snapshot_async(callback, opts) callback(build_status_snapshot(output, head_oid, { root = root, + enrich_conflicts = opts.schedule_callback ~= false, })) end) end) @@ -664,19 +784,272 @@ function M.get_unmerged_stage_entries(filepath) end local entries = {} + for _, entry in ipairs(parse_unmerged_stage_output(output)) do + entries[entry.stage] = entry + end + + return entries +end + +function M.get_all_unmerged_stage_entries() + local ok, output = M.run_git_capture({ 'ls-files', '-u' }) + if not ok then + return {} + end + + return parse_unmerged_stage_output(output) +end + +function M.get_blob_text(oid) + if type(oid) ~= 'string' or oid == '' then + return '' + end + + local root = M.repo_root() + if not root then + return '' + end + + local result = vim.system({ 'git', '-C', root, 'cat-file', '-p', oid }):wait() + if result.code ~= 0 then + return '' + end + + return result.stdout or '' +end + +function M.blob_is_binary(oid) + if type(oid) ~= 'string' or oid == '' then + return false + end + + local text = M.get_blob_text(oid) + local temp = vim.fn.tempname() + local file = io.open(temp, 'wb') + if not file then + return text:find('\0', 1, true) ~= nil + end + + file:write(text or '') + file:close() + + local result = vim.system({ 'git', 'diff', '--no-index', '--numstat', '--', '/dev/null', temp }, { text = true }):wait() + vim.fn.delete(temp, 'rf') + + if result.code ~= 0 and result.code ~= 1 then + return false + end + + local output = result.stdout or '' 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, - } + if line:match('^%-\t%-\t') then + return true end end - return entries + return false +end + +function M.unmerged_stage_entries_are_binary(entries) + for _, entry in ipairs(entries or {}) do + if entry.mode ~= '100644' and entry.mode ~= '100755' then + return true + end + if M.blob_is_binary(entry.oid) then + return true + end + end + + return false +end + +function M.classify_conflict_entries(entries) + local stages = stage_map(entries) + local paths = sorted_unique_paths(entries) + local class = 'unsupported' + + if stages[1] and stages[2] and stages[3] then + if stages[2].path ~= stages[3].path then + class = 'rename_rename' + elseif M.unmerged_stage_entries_are_binary({ stages[1], stages[2], stages[3] }) then + class = 'binary' + else + class = 'text' + end + elseif not stages[1] and stages[2] and stages[3] then + if M.unmerged_stage_entries_are_binary({ stages[2], stages[3] }) then + class = 'non_text_add_add' + else + class = 'text_add_add' + end + elseif stages[1] and (stages[2] or stages[3]) then + local content = stages[2] or stages[3] + if content.path == stages[1].path then + class = 'modify_delete' + else + class = 'rename_delete' + end + end + + local deleted_side = nil + if stages[1] and stages[2] and not stages[3] then + deleted_side = 'theirs' + elseif stages[1] and stages[3] and not stages[2] then + deleted_side = 'ours' + end + + return { + class = class, + stage_entries = stages, + paths = paths, + base_path = stages[1] and stages[1].path or nil, + ours_path = stages[2] and stages[2].path or nil, + theirs_path = stages[3] and stages[3].path or nil, + display_path = conflict_display_path(stages, paths), + group_id = conflict_group_id(stages, paths), + deleted_side = deleted_side, + } +end + +function M.get_conflict_info(file) + if type(file) ~= 'table' then + return nil, 'invalid conflict target' + end + + local all_entries = M.get_all_unmerged_stage_entries() + local by_path = entries_by_path(all_entries) + local group_paths = file.conflict_paths + local entries + + if type(group_paths) == 'table' and #group_paths > 0 then + entries = group_entries_for_paths(by_path, group_paths) + elseif type(file.path) == 'string' and file.path ~= '' then + entries = group_entries_for_paths(by_path, { file.path }) + else + entries = {} + end + + if #entries == 0 then + return nil, 'no unmerged stage entries found' + end + + local info = M.classify_conflict_entries(entries) + info.file = file + return info +end + +local function structural_conflict_groups(conflict_files, unmerged_entries) + local by_path = entries_by_path(unmerged_entries) + local groups = {} + local grouped_paths = {} + local stage1_paths = {} + + for _, entry in ipairs(unmerged_entries or {}) do + if entry.stage == 1 and not (by_path[entry.path] and (by_path[entry.path][2] or by_path[entry.path][3])) then + stage1_paths[#stage1_paths + 1] = entry.path + end + end + + table.sort(stage1_paths) + for _, base_path in ipairs(stage1_paths) do + local paths = { base_path } + for _, entry in ipairs(unmerged_entries or {}) do + if entry.path ~= base_path + and (entry.stage == 2 or entry.stage == 3) + and by_path[entry.path] + and not by_path[entry.path][1] + then + paths[#paths + 1] = entry.path + end + end + + if #paths > 1 then + table.sort(paths) + local entries = group_entries_for_paths(by_path, paths) + local info = M.classify_conflict_entries(entries) + if info.class == 'rename_delete' or info.class == 'rename_rename' then + groups[#groups + 1] = info + for _, path in ipairs(paths) do + grouped_paths[path] = true + end + end + end + end + + for _, file in ipairs(conflict_files or {}) do + if not grouped_paths[file.path] then + local entries = group_entries_for_paths(by_path, { file.path }) + local info = M.classify_conflict_entries(entries) + if info.class == 'non_text_add_add' or info.class == 'binary' or info.class == 'modify_delete' then + groups[#groups + 1] = info + grouped_paths[file.path] = true + end + end + end + + return groups, grouped_paths +end + +function enrich_conflict_files(files) + local conflicts = files and files.conflicts or nil + if not conflicts or #conflicts == 0 then + return files + end + + local unmerged_entries = M.get_all_unmerged_stage_entries() + if #unmerged_entries == 0 then + return files + end + + local groups, grouped_paths = structural_conflict_groups(conflicts, unmerged_entries) + if #groups == 0 then + return files + end + + local next_conflicts = {} + for _, file in ipairs(conflicts) do + if not grouped_paths[file.path] then + next_conflicts[#next_conflicts + 1] = file + end + end + + for _, info in ipairs(groups) do + if info.class ~= 'text' and info.class ~= 'text_add_add' then + local display_path = info.display_path + local matching = display_path and path_has_conflict_entry(conflicts, display_path) and display_path or nil + if not matching then + for _, path in ipairs(info.paths or {}) do + if path_has_conflict_entry(conflicts, path) then + matching = path + break + end + end + end + + local base_file = nil + for _, file in ipairs(conflicts) do + if file.path == matching then + base_file = file + break + end + end + base_file = vim.deepcopy(base_file or conflicts[1] or {}) + base_file.path = display_path + base_file.old_path = info.base_path ~= display_path and info.base_path or nil + base_file.display_status = conflict_display_status(info.class) + base_file.kind = 'conflicted' + base_file.conflict_class = info.class + base_file.conflict_paths = info.paths + base_file.conflict_group_id = info.group_id + next_conflicts[#next_conflicts + 1] = base_file + end + end + + table.sort(next_conflicts, function(left, right) + return tostring(left.path) < tostring(right.path) + end) + files.conflicts = next_conflicts + return files end local function ref_name_label(ref) @@ -1450,6 +1823,146 @@ function M.stage_merge_result(file) return run_git({ 'add', '--', file.path }) end +local function conflict_side_entry(info, side) + if type(info) ~= 'table' or type(info.stage_entries) ~= 'table' then + return nil + end + + if side == 'ours' then + return info.stage_entries[2] + end + if side == 'theirs' then + return info.stage_entries[3] + end + + return nil +end + +local function write_conflict_entry(entry) + local root = M.repo_root() + if not root or not entry or type(entry.path) ~= 'string' or entry.path == '' then + return false, 'invalid conflict entry' + end + + local path = root .. '/' .. entry.path + ensure_parent_dir(path) + + local file, open_err = io.open(path, 'wb') + if not file then + return false, tostring(open_err) + end + + file:write(M.get_blob_text(entry.oid)) + file:close() + return true +end + +local function conflict_paths(info) + local paths = {} + local seen = {} + for _, path in ipairs((type(info) == 'table' and info.paths) or {}) do + if type(path) == 'string' and path ~= '' and not seen[path] then + seen[path] = true + paths[#paths + 1] = path + end + end + return paths +end + +function M.special_conflict_side_kind(file_or_info, side) + local info = file_or_info + if type(info) == 'table' and not info.stage_entries then + info = M.get_conflict_info(info) + end + if not info then + return nil + end + + return conflict_side_entry(info, side) and 'content' or 'deletion' +end + +function M.apply_special_conflict_choice(file_or_info, side) + local info = file_or_info + if type(info) == 'table' and not info.stage_entries then + local err + info, err = M.get_conflict_info(info) + if not info then + return false, err + end + end + + local entry = conflict_side_entry(info, side) + local paths = conflict_paths(info) + if #paths == 0 then + return false, 'no conflict paths found' + end + + if entry then + local ok, err = write_conflict_entry(entry) + if not ok then + return false, err + end + + for _, path in ipairs(paths) do + if path ~= entry.path then + delete_worktree_path(path) + end + end + return true + end + + for _, path in ipairs(paths) do + delete_worktree_path(path) + end + return true +end + +function M.complete_special_conflict_choice(file_or_info, side) + local info = file_or_info + if type(info) == 'table' and not info.stage_entries then + local err + info, err = M.get_conflict_info(info) + if not info then + return false, err + end + end + + local entry = conflict_side_entry(info, side) + local paths = conflict_paths(info) + if #paths == 0 then + return false, 'no conflict paths found' + end + + if entry then + local ok, err = run_git({ 'add', '--', entry.path }) + if not ok then + return false, err + end + + local remove_paths = {} + for _, path in ipairs(paths) do + if path ~= entry.path then + remove_paths[#remove_paths + 1] = path + end + end + + if #remove_paths > 0 then + local args = { 'rm', '-f', '--' } + vim.list_extend(args, remove_paths) + ok, err = run_git(args) + if not ok then + return false, err + end + end + + return true + end + + local args = { 'rm', '-f', '--' } + vim.list_extend(args, paths) + return run_git(args) +end + --- Unstage all git-visible changes for a single file path set. --- @param file { path: string, old_path: string|nil }|nil --- @return boolean, string|nil diff --git a/lua/glance/merge/init.lua b/lua/glance/merge/init.lua index 67b335c..a6e9dd6 100644 --- a/lua/glance/merge/init.lua +++ b/lua/glance/merge/init.lua @@ -5,6 +5,7 @@ local git = require('glance.git') local layout = require('glance.merge.layout') local model = require('glance.merge.model') local render = require('glance.merge.render') +local special = require('glance.merge.special') local workspace = require('glance.workspace') local M = {} @@ -570,7 +571,7 @@ local function bind_result_tracking(diffview, file) end function M.is_active() - return state.active == true + return state.active == true or special.is_active() end function M.jump_to_conflict(diffview, index) @@ -643,6 +644,9 @@ function M.jump_prev(diffview) end function M.equalize_panes(diffview) + if special.is_active() then + return special.equalize_panes(diffview) + end if not state.active then return false end @@ -652,6 +656,9 @@ function M.equalize_panes(diffview) end function M.hoverable_separator_wins(diffview) + if special.is_active() then + return special.hoverable_separator_wins(diffview) + end if not state.active then return nil end @@ -680,6 +687,15 @@ local function rebuild(diffview, file, existing_model) end function M.open(diffview, file) + local info = git.get_conflict_info(file) + if special.open(diffview, file, info) then + state.active = false + state.file = nil + state.model = nil + state.active_conflict_index = nil + return + end + local merge_model, err = model.build(file) if not merge_model then state.active = false @@ -727,6 +743,10 @@ function M.open(diffview, file) end function M.refresh(diffview, file) + if special.is_active() then + return special.refresh(diffview, file) + end + if not state.active then return false end @@ -761,6 +781,10 @@ function M.refresh(diffview, file) end function M.complete(diffview) + if special.is_active() then + return special.complete(diffview) + end + if not state.active or not state.file then return false end @@ -823,6 +847,7 @@ function M.reset() state.active_conflict_index = nil state.write_in_progress = false state.sync_in_progress = false + special.reset() end return M diff --git a/lua/glance/merge/layout.lua b/lua/glance/merge/layout.lua index 7d505bc..9a9f152 100644 --- a/lua/glance/merge/layout.lua +++ b/lua/glance/merge/layout.lua @@ -7,6 +7,7 @@ M.FILETREE_ROLE = 'filetree' M.THEIRS_ROLE = 'merge_theirs' M.OURS_ROLE = 'merge_ours' M.RESULT_ROLE = 'merge_result' +M.SPECIAL_ROLE = 'merge_special' function M.workspace_spec() return { @@ -51,6 +52,40 @@ function M.open(diffview) } end +function M.special_workspace_spec() + return { + roles = { + { role = M.FILETREE_ROLE, kind = 'sidebar' }, + { role = M.SPECIAL_ROLE, kind = 'content' }, + }, + preferred_focus_role = M.SPECIAL_ROLE, + editable_role = M.SPECIAL_ROLE, + } +end + +function M.open_special(diffview) + diffview.configure_workspace(M.special_workspace_spec()) + local win, buf = diffview.open_workspace_pane(M.SPECIAL_ROLE) + return { + special = { win = win, buf = buf }, + } +end + +function M.equalize_special(diffview) + local tree_visible = filetree.win and vim.api.nvim_win_is_valid(filetree.win) + if tree_visible then + vim.api.nvim_win_set_width(filetree.win, require('glance.config').options.windows.filetree.width) + end +end + +function M.special_hoverable_separator_wins() + local wins = {} + if filetree.win and vim.api.nvim_win_is_valid(filetree.win) then + wins[#wins + 1] = filetree.win + end + return wins +end + function M.equalize(diffview) local tree_visible = filetree.win and vim.api.nvim_win_is_valid(filetree.win) local tree_width = 0 diff --git a/lua/glance/merge/special.lua b/lua/glance/merge/special.lua new file mode 100644 index 0000000..91fde52 --- /dev/null +++ b/lua/glance/merge/special.lua @@ -0,0 +1,420 @@ +local config = require('glance.config') +local filetree = require('glance.filetree') +local git = require('glance.git') +local layout = require('glance.merge.layout') +local workspace = require('glance.workspace') + +local M = {} + +local NS = vim.api.nvim_create_namespace('glance_merge_special') + +local state = { + active = false, + file = nil, + info = nil, + selection = nil, +} + +local function panel_buf(diffview) + return workspace.get_buf(diffview.workspace, layout.SPECIAL_ROLE) +end + +local function panel_win(diffview) + return workspace.get_win(diffview.workspace, layout.SPECIAL_ROLE) +end + +local function set_lines(buf, lines) + 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', false) + vim.api.nvim_buf_set_option(buf, 'readonly', true) + vim.api.nvim_buf_set_option(buf, 'buftype', 'nofile') + vim.api.nvim_buf_set_option(buf, 'swapfile', false) + vim.api.nvim_set_option_value('modified', false, { buf = buf }) +end + +local function add_highlight(buf, line, group) + if line >= 0 and line < vim.api.nvim_buf_line_count(buf) then + vim.api.nvim_buf_add_highlight(buf, NS, group, line, 0, -1) + end +end + +local function display_key(lhs) + if type(lhs) ~= 'string' then + return '' + end + return lhs:gsub('', '\\'):gsub('', '\\') +end + +local function operation_label(context) + local labels = { + merge = 'merge', + rebase = 'rebase', + cherry_pick = 'cherry-pick', + revert = 'revert', + } + return labels[context and context.kind] or 'operation' +end + +local function notify_post_complete(context, files) + files = files or {} + local conflicts = files.conflicts or {} + if #conflicts > 0 then + local noun = #conflicts == 1 and 'file' or 'files' + local verb = #conflicts == 1 and 'remains' or 'remain' + vim.notify( + string.format('glance: merge result staged; %d conflicted %s %s', #conflicts, noun, verb), + vim.log.levels.INFO + ) + return + end + + if context and context.kind == 'merge' then + vim.notify('glance: all merge conflicts are resolved; press c to commit the merge', vim.log.levels.INFO) + return + end + + if context and context.kind then + local key = display_key(config.options.merge.keymaps.continue_operation) + if key ~= '' then + vim.notify( + string.format('glance: all %s conflicts are resolved; press %s to continue', operation_label(context), key), + vim.log.levels.INFO + ) + else + vim.notify( + string.format('glance: all %s conflicts are resolved; continue the Git operation from the filetree', operation_label(context)), + vim.log.levels.INFO + ) + end + return + end + + vim.notify('glance: merge result staged', vim.log.levels.INFO) +end + +local function class_title(class) + local titles = { + modify_delete = 'Modify/Delete Conflict', + rename_delete = 'Rename/Delete Conflict', + rename_rename = 'Rename/Rename Conflict', + non_text_add_add = 'Non-Text Add/Add Conflict', + binary = 'Binary Conflict', + } + return titles[class] or 'Special Conflict' +end + +local function side_entry(info, side) + local stage = side == 'ours' and 2 or 3 + return info.stage_entries and info.stage_entries[stage] or nil +end + +local function side_status(info, side) + local entry = side_entry(info, side) + if not entry then + return 'deleted' + end + if info.class == 'rename_delete' or info.class == 'rename_rename' then + return 'renamed path' + end + if info.class == 'modify_delete' then + return 'modified' + end + if info.class == 'non_text_add_add' then + return 'added non-text' + end + return 'binary' +end + +local function side_detail(info, side) + local label = side == 'ours' and 'Ours' or 'Theirs' + local entry = side_entry(info, side) + local parts = { label } + if entry then + parts[#parts + 1] = 'stage ' .. tostring(entry.stage) + parts[#parts + 1] = side_status(info, side) + parts[#parts + 1] = entry.path + else + parts[#parts + 1] = 'deleted' + end + return ' ' .. table.concat(parts, ' | ') +end + +local function side_action_label(info, side) + local label = side == 'ours' and 'Ours' or 'Theirs' + local entry = side_entry(info, side) + if not entry then + return 'accept ' .. label .. ' deletion' + end + + if info.class == 'modify_delete' then + return 'keep ' .. label .. ' modified version' + end + if info.class == 'rename_delete' then + return 'keep ' .. label .. ' renamed path' + end + if info.class == 'rename_rename' then + return 'keep ' .. label .. ' path' + end + + return 'take ' .. label +end + +local function selection_label(info, side) + if not side then + return 'no choice selected' + end + return 'selected: ' .. side_action_label(info, side) +end + +local function destructive_choice(info, side) + if not side_entry(info, side) then + return true + end + return info.class == 'rename_rename' +end + +local function confirm_choice(info, side) + if not destructive_choice(info, side) then + return true + end + + local message = side_action_label(info, side) .. '?' + return vim.fn.confirm(message, '&Apply\n&Cancel', 2) == 1 +end + +local function render_lines(info) + local km = config.options.merge.keymaps or {} + local ours_key = display_key(km.accept_ours) + local theirs_key = display_key(km.accept_theirs) + local complete_key = display_key(km.complete_merge) + local continue_key = display_key(km.continue_operation) + local lines = { + ' ' .. class_title(info.class), + '', + } + + if info.base_path then + lines[#lines + 1] = ' Base | stage 1 | ' .. info.base_path + end + lines[#lines + 1] = side_detail(info, 'ours') + lines[#lines + 1] = side_detail(info, 'theirs') + lines[#lines + 1] = '' + lines[#lines + 1] = ' ' .. selection_label(info, state.selection) + lines[#lines + 1] = '' + + if ours_key ~= '' then + lines[#lines + 1] = ' [' .. ours_key .. '] ' .. side_action_label(info, 'ours') + end + if theirs_key ~= '' then + lines[#lines + 1] = ' [' .. theirs_key .. '] ' .. side_action_label(info, 'theirs') + end + if complete_key ~= '' then + lines[#lines + 1] = ' [' .. complete_key .. '] complete merge' + end + if continue_key ~= '' then + lines[#lines + 1] = ' [' .. continue_key .. '] continue operation' + end + + if info.class == 'rename_rename' or info.class == 'non_text_add_add' then + lines[#lines + 1] = '' + lines[#lines + 1] = ' Keep both needs another output path. Resolve that manually, then refresh Glance.' + end + + return lines +end + +local function render(diffview) + local buf = panel_buf(diffview) + local win = panel_win(diffview) + if not buf or not vim.api.nvim_buf_is_valid(buf) or not state.info then + return + end + + vim.api.nvim_buf_clear_namespace(buf, NS, 0, -1) + set_lines(buf, render_lines(state.info)) + add_highlight(buf, 0, 'GlanceAccentText') + for line = 2, 4 do + add_highlight(buf, line, 'Comment') + end + + if win and vim.api.nvim_win_is_valid(win) then + local context = git.get_operation_context() + local parts = {} + if context.prefix then + parts[#parts + 1] = context.prefix + end + parts[#parts + 1] = class_title(state.info.class) + parts[#parts + 1] = selection_label(state.info, state.selection) + vim.api.nvim_set_option_value('winbar', table.concat(parts, ' | '), { win = win }) + end +end + +local function bind_keymaps(diffview) + local buf = panel_buf(diffview) + if not buf or not vim.api.nvim_buf_is_valid(buf) then + return + end + + local km = config.options.merge.keymaps or {} + local opts = { + buffer = buf, + silent = true, + } + + if km.accept_ours then + vim.keymap.set('n', km.accept_ours, function() + M.choose(diffview, 'ours') + end, opts) + end + if km.accept_theirs then + vim.keymap.set('n', km.accept_theirs, function() + M.choose(diffview, 'theirs') + end, opts) + end + if km.complete_merge then + vim.keymap.set('n', km.complete_merge, function() + M.complete(diffview) + end, opts) + end + if km.continue_operation then + vim.keymap.set('n', km.continue_operation, function() + filetree.continue_operation() + end, opts) + end +end + +function M.supports(info) + if type(info) ~= 'table' then + return false + end + return info.class == 'modify_delete' + or info.class == 'rename_delete' + or info.class == 'rename_rename' + or info.class == 'non_text_add_add' + or info.class == 'binary' +end + +function M.is_active() + return state.active == true +end + +function M.open(diffview, file, info) + info = info or git.get_conflict_info(file) + if not M.supports(info) then + return false + end + + state.active = true + state.file = file + state.info = info + state.selection = nil + + layout.open_special(diffview) + local buf = panel_buf(diffview) + if buf then + pcall(vim.api.nvim_buf_set_name, buf, 'glance://merge/special/' .. (info.display_path or file.path)) + end + render(diffview) + layout.equalize_special(diffview) + diffview.setup_autocmds(file) + diffview.bind_buffer_keymaps() + bind_keymaps(diffview) + + local win = panel_win(diffview) + if win and vim.api.nvim_win_is_valid(win) then + vim.api.nvim_set_current_win(win) + end + + return true +end + +function M.choose(diffview, side) + if not state.active or not state.info then + return false + end + if side ~= 'ours' and side ~= 'theirs' then + return false + end + if not confirm_choice(state.info, side) then + return false + end + + local ok, err = git.apply_special_conflict_choice(state.info, side) + if not ok then + vim.notify('glance: failed to apply conflict choice: ' .. tostring(err), vim.log.levels.WARN) + return false + end + + state.selection = side + render(diffview) + filetree.note_repo_activity() + vim.notify('glance: ' .. side_action_label(state.info, side), vim.log.levels.INFO) + return true +end + +function M.complete(diffview) + if not state.active or not state.info then + return false + end + if not state.selection then + vim.notify('glance: choose a conflict resolution before completing this file', vim.log.levels.WARN) + return false + end + + local context = git.get_operation_context() + local ok, err = git.complete_special_conflict_choice(state.info, state.selection) + if not ok then + vim.notify('glance: failed to stage merge result: ' .. tostring(err), vim.log.levels.ERROR) + return false + end + + local snapshot = git.get_status_snapshot() + filetree.note_repo_activity() + diffview.close(false) + notify_post_complete(context, snapshot.files) + return true +end + +function M.refresh(diffview, file) + if not state.active then + return false + end + + file = file or state.file + local info, err = git.get_conflict_info(file) + if not info then + vim.notify('glance: failed to refresh conflict panel: ' .. tostring(err), vim.log.levels.WARN) + return false + end + + state.file = file + state.info = info + render(diffview) + return true +end + +function M.equalize_panes(diffview) + if not state.active then + return false + end + layout.equalize_special(diffview) + return true +end + +function M.hoverable_separator_wins() + if not state.active then + return nil + end + return layout.special_hoverable_separator_wins() +end + +function M.reset() + state.active = false + state.file = nil + state.info = nil + state.selection = nil +end + +return M diff --git a/lua/glance/ui.lua b/lua/glance/ui.lua index edf45c6..9633133 100644 --- a/lua/glance/ui.lua +++ b/lua/glance/ui.lua @@ -661,12 +661,12 @@ function M.open_file(file) end with_redraw_suppressed(function() - local is_binary = git.ensure_file_binary(file) - -- Close welcome pane to make room for diff M.close_welcome() - if is_binary then + if kind == 'conflicted' then + diffview.open_conflict(file) + elseif git.ensure_file_binary(file) then diffview.open_binary(file) elseif kind == 'deleted' then diffview.open_deleted(file) @@ -674,8 +674,6 @@ function M.open_file(file) diffview.open_untracked(file) elseif kind == 'added' and file.section ~= 'staged' then diffview.open_untracked(file) - elseif kind == 'conflicted' then - diffview.open_conflict(file) elseif kind == 'copied' then diffview.open_copied(file) elseif kind == 'type_changed' then diff --git a/tests/helpers/repo.lua b/tests/helpers/repo.lua index 767f510..d2c4d34 100644 --- a/tests/helpers/repo.lua +++ b/tests/helpers/repo.lua @@ -111,6 +111,27 @@ local function new_fixture(root) return self:git({ 'commit', '-m', message or 'Test fixture commit' }) end + function fixture:hash_blob(content, mode) + local path = vim.fn.tempname() + write_file(path, content, mode or 'w') + local oid = vim.trim(run_command({ 'git', '-C', self.root, 'hash-object', '-w', path })) + vim.fn.delete(path, 'rf') + return oid + end + + function fixture:update_index_info(entries) + local lines = {} + for _, entry in ipairs(entries) do + lines[#lines + 1] = string.format('%s %s %d\t%s', entry.mode or '100644', entry.oid, entry.stage, entry.path) + end + + local output = vim.fn.system({ 'git', '-C', self.root, 'update-index', '--index-info' }, table.concat(lines, '\n') .. '\n') + if vim.v.shell_error ~= 0 then + error('git update-index --index-info\n' .. output) + end + return output + end + function fixture:cleanup() vim.fn.delete(self.root, 'rf') end @@ -350,6 +371,93 @@ function scenarios.repo_conflict_zero_line(fixture) assert(not ok, 'expected zero-line merge conflict fixture') end +function scenarios.repo_conflict_modify_delete(fixture) + seed_committed_file(fixture, 'tracked.txt', 'base\n') + local main_branch = vim.trim(fixture:git({ 'rev-parse', '--abbrev-ref', 'HEAD' })) + + fixture:git({ 'checkout', '-b', 'feature' }) + fixture:write(fixture.files.tracked, 'feature modified\n') + fixture:commit_all('Feature modifies') + + fixture:git({ 'checkout', main_branch }) + fixture:remove(fixture.files.tracked) + fixture:commit_all('Main deletes') + + local ok = pcall(function() + fixture:git({ 'merge', 'feature' }) + end) + assert(not ok, 'expected modify/delete conflict fixture') +end + +function scenarios.repo_conflict_binary(fixture) + fixture.files.binary = 'assets/conflict.bin' + fixture:write(fixture.files.binary, binary_blob(0, 1, 2, 3), 'wb') + fixture:stage(fixture.files.binary) + fixture:git({ 'commit', '-m', 'Seed binary conflict fixture' }) + local main_branch = vim.trim(fixture:git({ 'rev-parse', '--abbrev-ref', 'HEAD' })) + + fixture:git({ 'checkout', '-b', 'feature' }) + fixture:write(fixture.files.binary, binary_blob(0, 1, 2, 4), 'wb') + fixture:commit_all('Feature binary change') + + fixture:git({ 'checkout', main_branch }) + fixture:write(fixture.files.binary, binary_blob(0, 1, 2, 5), 'wb') + fixture:commit_all('Main binary change') + + local ok = pcall(function() + fixture:git({ 'merge', 'feature' }) + end) + assert(not ok, 'expected binary conflict fixture') +end + +function scenarios.repo_conflict_non_text_add_add(fixture) + seed_committed_file(fixture, 'anchor.txt', 'anchor\n', 'anchor') + fixture.files.binary = 'assets/add.bin' + local main_branch = vim.trim(fixture:git({ 'rev-parse', '--abbrev-ref', 'HEAD' })) + + fixture:git({ 'checkout', '-b', 'feature' }) + fixture:write(fixture.files.binary, binary_blob(0, 7, 7), 'wb') + fixture:commit_all('Feature binary add') + + fixture:git({ 'checkout', main_branch }) + fixture:write(fixture.files.binary, binary_blob(0, 8, 8), 'wb') + fixture:commit_all('Main binary add') + + local ok = pcall(function() + fixture:git({ 'merge', 'feature' }) + end) + assert(not ok, 'expected non-text add/add conflict fixture') +end + +function scenarios.repo_conflict_rename_delete(fixture) + seed_committed_file(fixture, 'old.txt', 'base\n', 'old') + fixture.files.renamed = 'renamed.txt' + + local base_oid = fixture:hash_blob('base\n') + local ours_oid = fixture:hash_blob('ours renamed\n') + fixture:git({ 'rm', '-q', '--cached', fixture.files.old }) + fixture:update_index_info({ + { mode = '100644', oid = base_oid, stage = 1, path = fixture.files.old }, + { mode = '100644', oid = ours_oid, stage = 2, path = fixture.files.renamed }, + }) +end + +function scenarios.repo_conflict_rename_rename(fixture) + seed_committed_file(fixture, 'old.txt', 'base\n', 'old') + fixture.files.ours = 'ours.txt' + fixture.files.theirs = 'theirs.txt' + + local base_oid = fixture:hash_blob('base\n') + local ours_oid = fixture:hash_blob('ours renamed\n') + local theirs_oid = fixture:hash_blob('theirs renamed\n') + fixture:git({ 'rm', '-q', '--cached', fixture.files.old }) + fixture:update_index_info({ + { mode = '100644', oid = base_oid, stage = 1, path = fixture.files.old }, + { mode = '100644', oid = ours_oid, stage = 2, path = fixture.files.ours }, + { mode = '100644', oid = theirs_oid, stage = 3, path = fixture.files.theirs }, + }) +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 6e61759..e5e748c 100644 --- a/tests/integration/diffview_spec.lua +++ b/tests/integration/diffview_spec.lua @@ -838,6 +838,140 @@ return { end) end, }, + { + name = 'special conflict panels require a choice before completing', + run = function() + N.with_repo('repo_conflict_binary', function() + require('glance').start() + local git = require('glance.git') + 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 panel_win = workspace.get_win(diffview.workspace, 'merge_special') + local panel_buf = workspace.get_buf(diffview.workspace, 'merge_special') + A.same(diffview.content_roles(), { 'merge_special' }) + A.truthy(panel_win and vim.api.nvim_win_is_valid(panel_win)) + A.truthy(panel_buf and vim.api.nvim_buf_is_valid(panel_buf)) + A.equal(vim.api.nvim_get_option_value('buftype', { buf = panel_buf }), 'nofile') + A.equal(vim.api.nvim_get_option_value('readonly', { buf = panel_buf }), true) + A.contains(table.concat(vim.api.nvim_buf_get_lines(panel_buf, 0, -1, false), '\n'), 'Binary Conflict') + A.contains(vim.api.nvim_get_option_value('winbar', { win = panel_win }), 'no choice selected') + + N.press('\\c') + + local warned = false + for _, entry in ipairs(messages) do + if entry.msg:find('choose a conflict resolution', 1, true) then + warned = true + break + end + end + restore_notify() + + A.truthy(warned) + A.truthy(ui.diff_open) + A.equal(#git.get_changed_files().conflicts, 1) + end) + end, + }, + { + name = 'binary special conflicts can take theirs and complete', + run = function() + N.with_repo('repo_conflict_binary', function(repo) + require('glance').start() + local git = require('glance.git') + local ui = require('glance.ui') + local filetree = require('glance.filetree') + + ui.open_file(filetree.files.conflicts[1]) + N.press('\\t') + N.press('\\c') + + local changed = git.get_changed_files() + A.falsy(ui.diff_open) + A.equal(#changed.conflicts, 0) + A.equal(repo:read(repo.files.binary), string.char(0, 1, 2, 4)) + end) + end, + }, + { + name = 'modify/delete special conflicts confirm deletions before completion', + run = function() + N.with_repo('repo_conflict_modify_delete', function(repo) + require('glance').start() + local git = require('glance.git') + 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 panel_buf = workspace.get_buf(diffview.workspace, 'merge_special') + local text = table.concat(vim.api.nvim_buf_get_lines(panel_buf, 0, -1, false), '\n') + A.contains(text, 'Modify/Delete Conflict') + A.contains(text, 'Ours | deleted') + A.contains(text, 'Theirs | stage 3 | modified') + + N.with_confirm(2, function() + N.press('\\o') + end) + A.truthy(ui.diff_open) + A.truthy(vim.uv.fs_stat(repo:path(repo.files.tracked))) + + N.with_confirm(1, function() + N.press('\\o') + end) + N.press('\\c') + + local changed = git.get_changed_files() + A.falsy(ui.diff_open) + A.equal(#changed.conflicts, 0) + A.falsy(vim.uv.fs_stat(repo:path(repo.files.tracked))) + end) + end, + }, + { + name = 'rename/rename special conflicts group paths and complete one side', + run = function() + N.with_repo('repo_conflict_rename_rename', function(repo) + require('glance').start() + local git = require('glance.git') + local ui = require('glance.ui') + local filetree = require('glance.filetree') + local diffview = require('glance.diffview') + local workspace = require('glance.workspace') + + A.equal(#filetree.files.conflicts, 1) + A.equal(filetree.files.conflicts[1].conflict_class, 'rename_rename') + + ui.open_file(filetree.files.conflicts[1]) + + local panel_buf = workspace.get_buf(diffview.workspace, 'merge_special') + local text = table.concat(vim.api.nvim_buf_get_lines(panel_buf, 0, -1, false), '\n') + A.contains(text, 'Rename/Rename Conflict') + A.contains(text, repo.files.ours) + A.contains(text, repo.files.theirs) + A.contains(text, 'Keep both needs another output path') + + N.with_confirm(1, function() + N.press('\\t') + end) + N.press('\\c') + + local changed = git.get_changed_files() + A.falsy(ui.diff_open) + A.equal(#changed.conflicts, 0) + A.equal(repo:read(repo.files.theirs), 'theirs renamed\n') + A.falsy(vim.uv.fs_stat(repo:path(repo.files.ours))) + end) + end, + }, { name = 'complete merge refuses unresolved conflicts and manual edits that are not marked resolved', run = function() diff --git a/tests/unit/git_spec.lua b/tests/unit/git_spec.lua index 05cc39c..7be808c 100644 --- a/tests/unit/git_spec.lua +++ b/tests/unit/git_spec.lua @@ -1277,6 +1277,123 @@ return { end) end, }, + { + name = 'integration classifies special conflict classes from unmerged stages', + run = function() + local git = require('glance.git') + + N.with_repo('repo_conflict_modify_delete', function(repo) + local changed = git.get_changed_files() + A.equal(#changed.conflicts, 1) + local file = changed.conflicts[1] + local info = assert(git.get_conflict_info(file)) + + A.equal(file.path, repo.files.tracked) + A.equal(file.conflict_class, 'modify_delete') + A.equal(info.class, 'modify_delete') + A.equal(info.deleted_side, 'ours') + A.equal(info.theirs_path, repo.files.tracked) + end) + + N.with_repo('repo_conflict_non_text_add_add', function(repo) + local changed = git.get_changed_files() + A.equal(#changed.conflicts, 1) + local file = changed.conflicts[1] + local info = assert(git.get_conflict_info(file)) + + A.equal(file.path, repo.files.binary) + A.equal(file.conflict_class, 'non_text_add_add') + A.equal(info.class, 'non_text_add_add') + A.equal(info.ours_path, repo.files.binary) + A.equal(info.theirs_path, repo.files.binary) + end) + + N.with_repo('repo_conflict_binary', function(repo) + local changed = git.get_changed_files() + A.equal(#changed.conflicts, 1) + local file = changed.conflicts[1] + local info = assert(git.get_conflict_info(file)) + + A.equal(file.path, repo.files.binary) + A.equal(file.conflict_class, 'binary') + A.equal(info.class, 'binary') + end) + end, + }, + { + name = 'integration groups structural conflicts into logical filetree entries', + run = function() + local git = require('glance.git') + + N.with_repo('repo_conflict_rename_delete', function(repo) + local changed = git.get_changed_files() + A.equal(#changed.conflicts, 1) + local file = changed.conflicts[1] + local info = assert(git.get_conflict_info(file)) + + A.equal(file.path, repo.files.renamed) + A.equal(file.old_path, repo.files.old) + A.equal(file.display_status, 'RD') + A.equal(file.conflict_class, 'rename_delete') + A.same(file.conflict_paths, { repo.files.old, repo.files.renamed }) + A.equal(info.class, 'rename_delete') + A.equal(info.base_path, repo.files.old) + A.equal(info.ours_path, repo.files.renamed) + A.equal(info.deleted_side, 'theirs') + end) + + N.with_repo('repo_conflict_rename_rename', function(repo) + local changed = git.get_changed_files() + A.equal(#changed.conflicts, 1) + local file = changed.conflicts[1] + local info = assert(git.get_conflict_info(file)) + + A.equal(file.path, repo.files.ours) + A.equal(file.old_path, repo.files.old) + A.equal(file.display_status, 'RR') + A.equal(file.conflict_class, 'rename_rename') + A.same(file.conflict_paths, { repo.files.old, repo.files.ours, repo.files.theirs }) + A.equal(info.class, 'rename_rename') + A.equal(info.ours_path, repo.files.ours) + A.equal(info.theirs_path, repo.files.theirs) + end) + end, + }, + { + name = 'integration completes special conflict choices through the index', + run = function() + local git = require('glance.git') + + N.with_repo('repo_conflict_modify_delete', function(repo) + local file = git.get_changed_files().conflicts[1] + local info = assert(git.get_conflict_info(file)) + + local ok, err = git.apply_special_conflict_choice(info, 'theirs') + A.truthy(ok, err) + ok, err = git.complete_special_conflict_choice(info, 'theirs') + A.truthy(ok, err) + + local changed = git.get_changed_files() + A.equal(#changed.conflicts, 0) + A.equal(repo:read(repo.files.tracked), 'feature modified\n') + end) + + N.with_repo('repo_conflict_rename_rename', function(repo) + local file = git.get_changed_files().conflicts[1] + local info = assert(git.get_conflict_info(file)) + + local ok, err = git.apply_special_conflict_choice(info, 'theirs') + A.truthy(ok, err) + ok, err = git.complete_special_conflict_choice(info, 'theirs') + A.truthy(ok, err) + + local changed = git.get_changed_files() + A.equal(#changed.conflicts, 0) + A.equal(repo:read(repo.files.theirs), 'theirs renamed\n') + A.falsy(vim.uv.fs_stat(repo:path(repo.files.ours))) + end) + end, + }, { name = 'commit supports resolved merge states even when the tree matches ours', run = function()