From 5f860fe26f1df4545db0302a98fdddb08dc108f4 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:09:30 +0100 Subject: [PATCH 01/25] fix: keep the merge view open when staging a resolved file fails --- lua/differ/merge/init.lua | 5 ++++- test/nvim/mergetool_spec.lua | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/lua/differ/merge/init.lua b/lua/differ/merge/init.lua index 4c5ee7f..511d675 100644 --- a/lua/differ/merge/init.lua +++ b/lua/differ/merge/init.lua @@ -1040,7 +1040,10 @@ function lay_out(root, relpath, model, layout) ) end elseif #session.regions == 0 then - require("differ.git").stage(session.root, session.path) + -- a failed add has already notified; the view stays up so a later :w retries + if not require("differ.git").stage(session.root, session.path) then + return + end notify(session.path .. " resolved and staged") -- advance to the next conflicted file (or finish + close) once the write -- settles; re-opening the session from inside this BufWritePost isn't safe. diff --git a/test/nvim/mergetool_spec.lua b/test/nvim/mergetool_spec.lua index 726d9d8..8b31e28 100644 --- a/test/nvim/mergetool_spec.lua +++ b/test/nvim/mergetool_spec.lua @@ -464,6 +464,38 @@ describe(":Differ mergetool resolution", function() assert.are.same({ "g.txt" }, require("differ.git").conflicted(root)) end) + it("neither reports nor advances when the stage fails", function() + local root = conflict_repo_two() + vim.cmd.edit(root .. "/f.txt") + merge.open({}) + local s = merge.current() + local git_mod = require("differ.git") + local stage, orig_notify = git_mod.stage, vim.notify + local messages = {} + git_mod.stage = function() + return false + end + vim.notify = function(msg) + messages[#messages + 1] = msg + end + local ok, err = pcall(function() + fire(s.result_buf, "differ: take ours") + vim.api.nvim_set_current_win(s.result_win) + vim.cmd("silent write") + -- give a scheduled advance the chance to run + vim.wait(200, function() + return merge.current() == nil or merge.current().path ~= "f.txt" + end) + end) + git_mod.stage, vim.notify = stage, orig_notify + assert(ok, err) + for _, msg in ipairs(messages) do + assert.is_nil(msg:find("resolved and staged", 1, true)) + end + assert.are.equal("f.txt", merge.current().path) + assert.are.same({ "f.txt", "g.txt" }, require("differ.git").conflicted(root)) + end) + it("reports done and closes the session once the last conflict is written", function() local root = conflict_repo() vim.cmd.edit(root .. "/f.txt") From 2f80c66e15d9e6d1ce35d6a9aaa1dfa982608569 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:32:15 +0100 Subject: [PATCH 02/25] fix: keep lsp diagnostics off the merge result's conflict markers --- lua/differ/merge/init.lua | 14 ++++++++++---- test/nvim/mergetool_spec.lua | 21 +++++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/lua/differ/merge/init.lua b/lua/differ/merge/init.lua index 511d675..79bf066 100644 --- a/lua/differ/merge/init.lua +++ b/lua/differ/merge/init.lua @@ -74,6 +74,7 @@ local INPUT_HL = { ---@field keymaps table -- resolved merge keymaps (for the g? cheatsheet) ---@field saved_autoformat any -- the result buffer's prior disable_autoformat, restored on close ---@field saved_markdown_render boolean|nil -- the result buffer's prior render-markdown state, restored on close +---@field saved_diagnostics boolean -- the result buffer's prior diagnostic enable state, restored on close ---@field autoformat_warned boolean|nil -- set once a save is seen to have reformatted the markers ---@field diag_aug integer|nil -- the DiagnosticChanged hook that hushes the result ---@field aug integer|nil -- the session augroup on the result buffer @@ -170,10 +171,10 @@ local function live_session() end -- the result is the real file, so the user's LSP/linter parses it and reports errors over --- the conflict markers (invalid source). just toggling display (enable/disable) leaves the --- diagnostics stored, so they still leak through hover floats and the loc/qf list — clear --- them outright and keep them cleared as the producers re-publish (DiagnosticChanged), --- until the session closes. returns the augroup id so close can drop the hook +-- the conflict markers (invalid source). disabling display alone leaves the diagnostics +-- stored for hover floats and the loc/qf list, so also clear them outright and keep them +-- cleared as the producers re-publish (DiagnosticChanged), until the session closes. +-- returns the augroup id so close can drop the hook ---@param buf integer ---@return integer|nil augroup local function suppress_diagnostics(buf) @@ -758,6 +759,7 @@ function M.close() if s.saved_markdown_render then set_markdown_render(s.result_buf, true) -- re-render markdown if it was on end + vim.diagnostic.enable(s.saved_diagnostics, { bufnr = s.result_buf }) for _, ns in ipairs({ merge_ns, flash_ns, anchor_ns }) do vim.api.nvim_buf_clear_namespace(s.result_buf, ns, 0, -1) end @@ -939,6 +941,7 @@ function lay_out(root, relpath, model, layout) bound_keys = bound, diag_aug = suppress_diagnostics(result_buf), -- the markers aren't valid source saved_markdown_render = quiet_markdown_render(result_buf), -- don't conceal the markers + saved_diagnostics = vim.diagnostic.is_enabled({ bufnr = result_buf }), } -- one anchor per original conflict, spanning its block so a splice retires it for _, r in ipairs(model.regions) do @@ -969,6 +972,9 @@ function lay_out(root, relpath, model, layout) -- restored on close; honoured by any format_on_save gate that checks the flag session.saved_autoformat = vim.b[result_buf].disable_autoformat vim.b[result_buf].disable_autoformat = true + -- the reset alone leaves a renderer registered after the hook with a stale cache: the + -- nested empty DiagnosticChanged reaches it before the outer one + vim.diagnostic.enable(false, { bufnr = result_buf }) local function rb(action, fn, desc) bind(result_buf, km[action], fn, desc) bound[#bound + 1] = km[action] diff --git a/test/nvim/mergetool_spec.lua b/test/nvim/mergetool_spec.lua index 8b31e28..596bc02 100644 --- a/test/nvim/mergetool_spec.lua +++ b/test/nvim/mergetool_spec.lua @@ -382,6 +382,27 @@ describe(":Differ mergetool", function() merge.close() assert.is_nil(vim.b[buf].disable_autoformat) -- restored to its prior (unset) value end) + + it("disables diagnostics on the result buffer and restores them on close", function() + local root = conflict_repo() + vim.cmd.edit(root .. "/f.txt") + merge.open({}) + local buf = merge.current().result_buf + local seen = {} + vim.api.nvim_create_autocmd("DiagnosticChanged", { + buffer = buf, + callback = function() + seen[#seen + 1] = vim.diagnostic.is_enabled({ bufnr = buf }) + end, + }) + local ns = vim.api.nvim_create_namespace("differ.test.diag") + vim.diagnostic.set(ns, buf, { { lnum = 0, col = 0, message = "x", severity = 1 } }) + assert.is_true(#seen > 0) + assert.is_false(vim.tbl_contains(seen, true)) -- a later renderer sees them off + assert.are.same({}, vim.diagnostic.get(buf)) + merge.close() + assert.is_true(vim.diagnostic.is_enabled({ bufnr = buf })) + end) end) -- fire a buffer-local keymap by its description, so the test doesn't depend on From 370099e683c64e889dbf44b62409eb8e8ad5aa3e Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:48:46 +0100 Subject: [PATCH 03/25] feat: name the in-progress operation's --continue once every conflict is resolved --- lua/differ/git/init.lua | 30 +++++++++ lua/differ/merge/init.lua | 17 +++-- test/nvim/mergetool_spec.lua | 119 +++++++++++++++++++++++++++++++++-- 3 files changed, 155 insertions(+), 11 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index e17d15a..d4690dd 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -347,6 +347,36 @@ function M.conflicted(root) return out and rev.parse_unmerged(out) or {} end +-- git-dir markers of the operations with a `--continue`, checked in order: a rebase +-- replaying a merge or a pick also writes MERGE_HEAD/CHERRY_PICK_HEAD, and `git am` shares +-- rebase-apply/ +local OPERATION_MARKERS = { + { "rebase-merge", "rebase" }, + { "rebase-apply/applying", "am" }, + { "rebase-apply", "rebase" }, + { "MERGE_HEAD", "merge" }, + { "CHERRY_PICK_HEAD", "cherry-pick" }, + { "REVERT_HEAD", "revert" }, +} + +-- the in-progress operation that has a `--continue`; nil for conflicts left by one that +-- doesn't (stash pop, checkout -m) +---@param root string +---@return "merge"|"rebase"|"am"|"cherry-pick"|"revert"|nil +function M.operation(root) + local dir = git({ "rev-parse", "--absolute-git-dir" }, root) + if not dir then + return nil + end + dir = chomp(dir) + for _, marker in ipairs(OPERATION_MARKERS) do + if vim.uv.fs_stat(dir .. "/" .. marker[1]) then + return marker[2] + end + end + return nil +end + -- repo-relative paths of untracked files, respecting .gitignore, in git's order ---@param root string ---@return string[] diff --git a/lua/differ/merge/init.lua b/lua/differ/merge/init.lua index 79bf066..70bf4dd 100644 --- a/lua/differ/merge/init.lua +++ b/lua/differ/merge/init.lua @@ -820,16 +820,21 @@ end local lay_out -- once a file resolves + stages, open the next conflicted file (git's order, the staged file --- already dropped off), reusing the session layout. when none remain, report done and close, --- returning to the invoking tab. a next file that fails to build leaves the resolved view up --- rather than looping. no commit hint: the merge may be a rebase/cherry-pick/etc., so naming --- one command would be wrong as often as right +-- already dropped off), reusing the session layout. when none remain, report done, naming +-- the in-progress operation's `--continue` when there is one, and close, returning to the +-- invoking tab. a next file that fails to build leaves the resolved view up rather than looping ---@param root string ---@param layout "default"|"diff4" local function advance(root, layout) - local remaining = require("differ.git").conflicted(root) + local git = require("differ.git") + local remaining = git.conflicted(root) if #remaining == 0 then - notify("all conflicts resolved and staged") + local op = git.operation(root) + if op then + notify(("all conflicts resolved: git %s --continue"):format(op)) + else + notify("all conflicts resolved and staged") + end return M.close() end local relpath = remaining[1] diff --git a/test/nvim/mergetool_spec.lua b/test/nvim/mergetool_spec.lua index 596bc02..b4f326a 100644 --- a/test/nvim/mergetool_spec.lua +++ b/test/nvim/mergetool_spec.lua @@ -26,11 +26,8 @@ local function write(path, content) fd:close() end --- a repo where merging `feature` into `main` conflicts on f.txt (both changed line 2). --- `style` picks the merge.conflictStyle: nil for git's default, "diff3" to get the --- ||||||| base marker as well ----@param style string|nil -local function conflict_repo(style) +-- a repo on `main` where `main` and `feature` both changed line 2 of f.txt +local function diverged_repo() local root = vim.fn.tempname() vim.fn.mkdir(root, "p") git_ok(root, "init", "-q") @@ -43,6 +40,15 @@ local function conflict_repo(style) git_ok(root, "checkout", "-q", "main") write(root .. "/f.txt", "a\nOURS\nc\n") git_ok(root, "commit", "-q", "-am", "ours") + return root +end + +-- a repo where merging `feature` into `main` conflicts on f.txt (both changed line 2). +-- `style` picks the merge.conflictStyle: nil for git's default, "diff3" to get the +-- ||||||| base marker as well +---@param style string|nil +local function conflict_repo(style) + local root = diverged_repo() if style then git(root, "-c", "merge.conflictStyle=" .. style, "merge", "feature") else @@ -51,6 +57,38 @@ local function conflict_repo(style) return root end +-- a diverged repo stopped on f.txt's conflict by `op`, each its own way of replaying one +-- side onto the other; "stash" pops a stash over a conflicting commit, which has no --continue +---@param op "merge"|"rebase"|"rebase-apply"|"am"|"cherry-pick"|"revert"|"stash" +local function operation_repo(op) + local root = diverged_repo() + if op == "merge" then + git(root, "merge", "feature") + elseif op == "rebase" then + git_ok(root, "checkout", "-q", "feature") + git(root, "rebase", "main") + elseif op == "rebase-apply" then + git_ok(root, "checkout", "-q", "feature") + git(root, "rebase", "--apply", "main") + elseif op == "am" then + local patch = root .. "/../" .. vim.fn.fnamemodify(root, ":t") .. ".patch" + write(patch, git_ok(root, "format-patch", "-1", "--stdout", "feature")) + git(root, "am", "-3", patch) + elseif op == "cherry-pick" then + git(root, "cherry-pick", "feature") + elseif op == "revert" then + write(root .. "/f.txt", "a\nLATER\nc\n") + git_ok(root, "commit", "-q", "-am", "later") + git(root, "revert", "--no-edit", "HEAD~1") + elseif op == "stash" then + write(root .. "/f.txt", "a\nSTASHED\nc\n") + git_ok(root, "stash", "-q") + git_ok(root, "checkout", "-q", "feature") + git(root, "stash", "pop") + end + return root +end + -- a repo with a single conflict buried in a long file, so the unchanged spans either side -- are large enough to fold (the small repo's block fills the file and folds nothing) local function conflict_repo_big() @@ -573,6 +611,77 @@ local function input(s, side) end end +describe(":Differ mergetool in-progress operation", function() + after_each(function() + if merge.current() then + merge.close() + end + end) + + local cases = { + { "merge", "merge" }, + { "rebase", "rebase" }, + { "rebase-apply", "rebase" }, + { "am", "am" }, + { "cherry-pick", "cherry-pick" }, + { "revert", "revert" }, + } + for _, case in ipairs(cases) do + it(("reads %s as %s"):format(case[1], case[2]), function() + local root = operation_repo(case[1]) + local git_mod = require("differ.git") + assert.are.same({ "f.txt" }, git_mod.conflicted(root)) + assert.are.equal(case[2], git_mod.operation(root)) + end) + end + + it("reads no operation for conflicts a stash pop left", function() + local root = operation_repo("stash") + local git_mod = require("differ.git") + assert.are.same({ "f.txt" }, git_mod.conflicted(root)) + assert.is_nil(git_mod.operation(root)) + end) + + ---@param op string + ---@return string[] messages + local function resolve_last(op) + local root = operation_repo(op) + vim.cmd.edit(root .. "/f.txt") + merge.open({}) + local s = merge.current() + local orig_notify = vim.notify + local messages = {} + vim.notify = function(msg) + messages[#messages + 1] = msg + end + local ok, err = pcall(function() + fire(s.result_buf, "differ: take ours") + vim.api.nvim_set_current_win(s.result_win) + vim.cmd("silent write") + vim.wait(2000, function() + return merge.current() == nil + end) + end) + vim.notify = orig_notify + assert(ok, err) + assert.is_nil(merge.current()) + return messages + end + + it("names the operation's --continue once the last file is written", function() + local messages = resolve_last("rebase") + assert.are.equal( + "differ: all conflicts resolved: git rebase --continue", + messages[#messages] + ) + end) + + it("reports plain done when no operation has a --continue", function() + local messages = resolve_last("stash") + assert.are.equal("differ: all conflicts resolved and staged", messages[#messages]) + end) +end) + describe(":Differ mergetool navigation", function() after_each(function() if merge.current() then From e5f116ad677f2ec0c98d37325db49679e1fc6d34 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:27:19 +0100 Subject: [PATCH 04/25] fix: skip conflicts the merge tool can't open when advancing --- lua/differ/merge/init.lua | 42 ++++++++++---- test/nvim/mergetool_spec.lua | 103 +++++++++++++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 12 deletions(-) diff --git a/lua/differ/merge/init.lua b/lua/differ/merge/init.lua index 70bf4dd..d5169c7 100644 --- a/lua/differ/merge/init.lua +++ b/lua/differ/merge/init.lua @@ -819,10 +819,21 @@ end ---@type fun(root: string, relpath: string, model: differ.MergeModel, layout: "default"|"diff4") local lay_out --- once a file resolves + stages, open the next conflicted file (git's order, the staged file --- already dropped off), reusing the session layout. when none remain, report done, naming --- the in-progress operation's `--continue` when there is one, and close, returning to the --- invoking tab. a next file that fails to build leaves the resolved view up rather than looping +-- conflicted paths with no markers in the worktree (modify/delete, added by one side, binary) +---@param relpaths string[] +local function report_unopenable(relpaths) + notify( + ("%s can't be opened in the merge tool: resolve with git add/rm"):format( + table.concat(relpaths, ", ") + ), + vim.log.levels.WARN + ) +end + +-- once a file resolves + stages, open the next conflicted file that builds (git's order, the +-- staged file already dropped off), reusing the session layout. when none remain, report +-- done, naming the in-progress operation's `--continue` when there is one; when only +-- unbuildable ones remain, name them instead. either way close, returning to the invoking tab ---@param root string ---@param layout "default"|"diff4" local function advance(root, layout) @@ -837,12 +848,15 @@ local function advance(root, layout) end return M.close() end - local relpath = remaining[1] - local model, err = require("differ.merge.model").build(root, relpath, nil) - if not model then - return notify(err or ("could not open " .. relpath), vim.log.levels.WARN) + local build = require("differ.merge.model").build + for _, relpath in ipairs(remaining) do + local model = build(root, relpath, nil) + if model then + return lay_out(root, relpath, model, layout) + end end - lay_out(root, relpath, model, layout) + report_unopenable(remaining) + M.close() end -- lay out the render in a fresh session tab and wire navigation @@ -1173,15 +1187,19 @@ function M.open(opts) local layout = opts.layout or merge_cfg.layout or "default" local function go(relpath) - local model, err = require("differ.merge.model").build(root, relpath, nil) + local model = require("differ.merge.model").build(root, relpath, nil) if not model then - return notify(err or "could not open the merge tool", vim.log.levels.WARN) + return report_unopenable({ relpath }) end lay_out(root, relpath, model, layout) end if opts.path and opts.path ~= "" then - local abs = vim.fn.fnamemodify(opts.path, ":p") + local full = vim.fn.fnamemodify(opts.path, ":p") + -- the directory only: git lists a conflicted symlink by its own path, not its target + local abs = vim.fn.resolve(vim.fn.fnamemodify(full, ":h")) + .. "/" + .. vim.fn.fnamemodify(full, ":t") local rel = (abs:sub(1, #root + 1) == root .. "/") and abs:sub(#root + 2) or opts.path if not vim.tbl_contains(conflicted, rel) then return notify(("%s is not conflicted"):format(opts.path), vim.log.levels.WARN) diff --git a/test/nvim/mergetool_spec.lua b/test/nvim/mergetool_spec.lua index b4f326a..9a5014c 100644 --- a/test/nvim/mergetool_spec.lua +++ b/test/nvim/mergetool_spec.lua @@ -220,6 +220,35 @@ local function conflict_repo_two() return root end +-- a repo where merging `feature` leaves a.txt modify/delete (main edits it, feature deletes +-- it), sorted before a content conflict in each of `files` +---@param files string[] +local function conflict_repo_modify_delete(files) + local root = vim.fn.tempname() + vim.fn.mkdir(root, "p") + git_ok(root, "init", "-q") + write(root .. "/a.txt", "a\n") + for _, f in ipairs(files) do + write(root .. "/" .. f, "a\nb\nc\n") + end + git_ok(root, "add", ".") + git_ok(root, "commit", "-q", "-m", "base") + git_ok(root, "checkout", "-q", "-b", "feature") + git_ok(root, "rm", "-q", "a.txt") + for _, f in ipairs(files) do + write(root .. "/" .. f, "a\nTHEIRS\nc\n") + end + git_ok(root, "commit", "-q", "-am", "theirs") + git_ok(root, "checkout", "-q", "main") + write(root .. "/a.txt", "OURS\n") + for _, f in ipairs(files) do + write(root .. "/" .. f, "a\nOURS\nc\n") + end + git_ok(root, "commit", "-q", "-am", "ours") + git(root, "merge", "feature") + return root +end + local merge_ns = vim.api.nvim_create_namespace("differ.merge") local flash_ns = vim.api.nvim_create_namespace("differ.merge.flash") @@ -570,6 +599,80 @@ describe(":Differ mergetool resolution", function() assert.are.same({}, require("differ.git").conflicted(root)) end) + it("skips a conflict the merge tool can't open for the next one that builds", function() + local root = conflict_repo_modify_delete({ "f.txt", "g.txt" }) + assert.are.same({ "a.txt", "f.txt", "g.txt" }, require("differ.git").conflicted(root)) + vim.cmd.edit(root .. "/f.txt") + merge.open({}) + local s = merge.current() + fire(s.result_buf, "differ: take ours") + vim.api.nvim_set_current_win(s.result_win) + vim.cmd("silent write") + vim.wait(2000, function() + return merge.current() ~= nil and merge.current().path == "g.txt" + end) + assert.are.equal("g.txt", merge.current().path) + end) + + it("names the conflicts it can't open once only those remain, then closes", function() + local root = conflict_repo_modify_delete({ "f.txt" }) + vim.cmd.edit(root .. "/f.txt") + merge.open({}) + local s = merge.current() + _G.notifs = {} + fire(s.result_buf, "differ: take ours") + vim.api.nvim_set_current_win(s.result_win) + vim.cmd("silent write") + vim.wait(2000, function() + return merge.current() == nil + end) + assert.is_nil(merge.current()) + local last = _G.notifs[#_G.notifs] + assert.are.equal( + "differ: a.txt can't be opened in the merge tool: resolve with git add/rm", + last.msg + ) + for _, n in ipairs(_G.notifs) do + assert.is_nil(n.msg:find("--continue", 1, true)) + end + end) + + it("says why when asked to open a conflict it can't", function() + local root = conflict_repo_modify_delete({ "f.txt" }) + vim.cmd.edit(root .. "/f.txt") + _G.notifs = {} + merge.open({ path = root .. "/a.txt" }) + assert.is_nil(merge.current()) + assert.are.equal( + "differ: a.txt can't be opened in the merge tool: resolve with git add/rm", + _G.notifs[1].msg + ) + assert.are.equal(vim.log.levels.WARN, _G.notifs[1].level) + end) + + it("says why when asked to open a conflicted symlink, not its target", function() + local root = conflict_repo_modify_delete({ "f.txt" }) + git_ok(root, "merge", "--abort") + git_ok(root, "checkout", "-q", "feature") + vim.uv.fs_symlink("f.txt", root .. "/link.txt") + git_ok(root, "add", "link.txt") + git_ok(root, "commit", "-q", "-m", "link theirs") + git_ok(root, "checkout", "-q", "main") + vim.uv.fs_symlink("a.txt", root .. "/link.txt") + git_ok(root, "add", "link.txt") + git_ok(root, "commit", "-q", "-m", "link ours") + git(root, "merge", "feature") + assert.is_true(vim.tbl_contains(require("differ.git").conflicted(root), "link.txt")) + vim.cmd.edit(root .. "/f.txt") + _G.notifs = {} + merge.open({ path = root .. "/link.txt" }) + assert.is_nil(merge.current()) + assert.are.equal( + "differ: link.txt can't be opened in the merge tool: resolve with git add/rm", + _G.notifs[1].msg + ) + end) + it("does not stage while conflicts remain on write", function() local root = conflict_repo() vim.cmd.edit(root .. "/f.txt") From c8089c90382119b900e7e2506f4223da97e45764 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:21:50 +0100 Subject: [PATCH 05/25] feat: step between conflicted files with ]f/[f instead of advancing on write --- lua/differ/config.lua | 2 +- lua/differ/git/init.lua | 14 +- lua/differ/merge/init.lua | 315 +++++++++++++++++++++++++++-------- test/nvim/mergetool_spec.lua | 297 +++++++++++++++++++++++++++------ 4 files changed, 500 insertions(+), 128 deletions(-) diff --git a/lua/differ/config.lua b/lua/differ/config.lua index 84cf111..931e39c 100644 --- a/lua/differ/config.lua +++ b/lua/differ/config.lua @@ -107,7 +107,7 @@ M.defaults = { keymaps = { next_hunk = "]c", -- diff, panel, history prev_hunk = "[c", - next_file = "]f", -- diff; panel/history step the selection + next_file = "]f", -- diff, merge; panel/history step the selection prev_file = "[f", first_file = "gg", -- panel/history: jump to the first/last file or commit last_file = "G", diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index d4690dd..5e3862e 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -84,6 +84,14 @@ function M.head_sha(root) return out and chomp(out) or nil end +-- the repo's absolute git dir, or nil outside a repo +---@param root string +---@return string|nil +function M.git_dir(root) + local out = git({ "rev-parse", "--absolute-git-dir" }, root) + return out and chomp(out) or nil +end + -- does a local branch of this name already exist? ---@param root string ---@param ref string @@ -364,11 +372,10 @@ local OPERATION_MARKERS = { ---@param root string ---@return "merge"|"rebase"|"am"|"cherry-pick"|"revert"|nil function M.operation(root) - local dir = git({ "rev-parse", "--absolute-git-dir" }, root) + local dir = M.git_dir(root) if not dir then return nil end - dir = chomp(dir) for _, marker in ipairs(OPERATION_MARKERS) do if vim.uv.fs_stat(dir .. "/" .. marker[1]) then return marker[2] @@ -1689,8 +1696,7 @@ function M.panel(opts) -- editor, a commit) re-sources instantly, not just on focus. worktree panels only: -- rev-pair diffs are against immutable SHAs and never go stale if stageable then - local git_dir = git({ "rev-parse", "--absolute-git-dir" }, root) - git_dir = git_dir and chomp(git_dir) or nil + local git_dir = M.git_dir(root) if git_dir then watcher = watch.new({ git_dir = git_dir, on_change = refresh_external }) end diff --git a/lua/differ/merge/init.lua b/lua/differ/merge/init.lua index d5169c7..841876f 100644 --- a/lua/differ/merge/init.lua +++ b/lua/differ/merge/init.lua @@ -2,9 +2,9 @@ -- top (plus base under the diff4 layout), the result spine full-width below — drive -- conflict navigation (]x/[x), and resolve per conflict (take ours/theirs/both/base/none), -- splicing the chosen slab into the result and stripping the markers. the result column is --- the real worktree file: :w writes it and auto-stages once no markers remain, then advances --- to the next conflicted file; when none remain the session reports done and closes. the user --- can :Differ close any time to stop after the current file. +-- the real worktree file: a write auto-stages it once no markers remain, and the session stays +-- on it. ]f/[f save the result, stage it when marker-free, and step to the next/prev conflicted +-- file; the session stays open until :Differ close. -- -- each input column is a read-only scratch buffer of a whole stage file, so windows use -- native `number` + native syntax; conflict regions are extmark-only in the differ.merge @@ -63,7 +63,14 @@ local INPUT_HL = { ---@field active_index integer|nil -- live index of the conflict under the cursor ---@field labels table -- side -> winbar pane label (OURS (HEAD), ...) ---@field no_ancestor boolean -- add/add: the base pane says so rather than sitting empty ----@field layout "default"|"diff4" -- the chosen layout, reused when advancing to the next file +---@field layout "default"|"diff4" -- the chosen layout, reused when stepping to another file +---@field files string[] -- the conflicted files at session start, in git's order (]f/[f) +---@field file_index integer -- this file's position in files +---@field left integer -- conflicted files still unstaged (for the winbar) +---@field staged boolean -- this file has been staged this session +---@field head string|nil -- HEAD at session start; a move means the operation went on +---@field operation string|nil -- the in-progress operation at session start +---@field watcher differ.git.Watcher|nil -- the git dir watch for changes made outside the session ---@field result_win integer ---@field result_buf integer -- the real worktree file (editable) ---@field bufs integer[] -- the scratch input buffers (deleted on close) @@ -648,8 +655,6 @@ local function resolve_choice(choice) if target then vim.api.nvim_win_set_cursor(session.result_win, { target.result_start, 0 }) on_cursor_moved() - elseif #session.regions == 0 then - notify("all conflicts resolved: :w to save and stage") end end @@ -681,6 +686,16 @@ local function base_label() return label end +---@param n integer +---@param noun string +---@return string +local function plural(n, noun) + if n == 1 then + return "1 " .. noun + end + return ("%d %ss"):format(n, noun) +end + -- winbar text for the merge windows: the result shows the conflict counter, an input -- shows its side label. a `%!` expression reading the window it renders for ---@return string @@ -690,13 +705,20 @@ function M.winbar() end local win = vim.g.statusline_winid if win == session.result_win then + local file = ("RESULT · file %d/%d"):format(session.file_index, #session.files) local remaining = #session.regions + if remaining == 0 and session.staged and session.left == 0 then + return file .. " · staged · all conflicts resolved" + end + if remaining == 0 and session.staged then + return ("%s · staged · %s left"):format(file, plural(session.left, "file")) + end if remaining == 0 then - return "RESULT · all resolved" + return file .. " · all resolved · :w to stage" end local total = session.total local n = (total - remaining) + active_pos() -- the absolute conflict ordinal - return ("RESULT · conflict %d/%d · %d unresolved"):format(n, total, remaining) + return ("%s · conflict %d/%d · %d unresolved"):format(file, n, total, remaining) end local side = session.win_side[win] if side == "base" then @@ -774,6 +796,9 @@ function M.close() if s.aug then pcall(vim.api.nvim_del_augroup_by_id, s.aug) end + if s.watcher then + s.watcher:stop() + end for _, buf in ipairs(s.bufs) do if vim.api.nvim_buf_is_valid(buf) then pcall(vim.api.nvim_buf_delete, buf, { force = true }) @@ -807,6 +832,7 @@ local function show_help() }) end vim.list_extend(rows, { + { pair(km.next_file, km.prev_file), "save, stage if resolved, next / previous file" }, { ":Differ close", "close the merge tool" }, { fmt(km.help), "this help" }, }) @@ -816,7 +842,7 @@ local function show_help() help.show(help.lines(rows), { title = " Differ: merge ", dismiss = dismiss }) end ----@type fun(root: string, relpath: string, model: differ.MergeModel, layout: "default"|"diff4") +---@type fun(root: string, relpath: string, model: differ.MergeModel, layout: "default"|"diff4", files: string[]) local lay_out -- conflicted paths with no markers in the worktree (modify/delete, added by one side, binary) @@ -830,32 +856,185 @@ local function report_unopenable(relpaths) ) end --- once a file resolves + stages, open the next conflicted file that builds (git's order, the --- staged file already dropped off), reusing the session layout. when none remain, report --- done, naming the in-progress operation's `--continue` when there is one; when only --- unbuildable ones remain, name them instead. either way close, returning to the invoking tab +-- with no conflict left to open here, report done (naming the in-progress operation's +-- `--continue` when there is one), or name the conflicts left for git add/rm ---@param root string ----@param layout "default"|"diff4" -local function advance(root, layout) - local git = require("differ.git") - local remaining = git.conflicted(root) - if #remaining == 0 then - local op = git.operation(root) +---@param conflicted string[] +local function report_progress(root, conflicted) + if #conflicted == 0 then + local op = require("differ.git").operation(root) if op then notify(("all conflicts resolved: git %s --continue"):format(op)) else notify("all conflicts resolved and staged") end - return M.close() + return + end + local build = require("differ.merge.model").build + for _, relpath in ipairs(conflicted) do + if build(root, relpath, nil) then + return + end + end + report_unopenable(conflicted) +end + +-- git add the marker-free result. the winbar shows it staged; only the file's first stage +-- reports progress, since an auto-save re-stages it on every later edit +local function stage_result() + local s = assert(session) + local git = require("differ.git") + -- a failed add has already notified; the file stays unstaged so a later write retries + if not git.stage(s.root, s.path) then + return + end + if s.staged then + return + end + s.staged = true + local conflicted = git.conflicted(s.root) + s.left = #conflicted + report_progress(s.root, conflicted) +end + +-- BufWritePost on the result: stage once no markers remain. repaint so a hand-edit's +-- regions stay current +local function on_write() + if not session then + return + end + repaint_result() + -- a format-on-save that didn't honour disable_autoformat reflows the markers; the + -- parser loses the region, so guard the stage on the raw buffer, not the count + if markers_reformatted(vim.api.nvim_buf_get_lines(session.result_buf, 0, -1, false)) then + if not session.autoformat_warned then + session.autoformat_warned = true + notify( + "a formatter changed the conflict markers on save; not staged. " + .. "your format_on_save ignores vim.b.disable_autoformat", + vim.log.levels.WARN + ) + end + return + end + if #session.regions == 0 then + stage_result() + end +end + +-- save the result and stage it once marker-free, as a write does. false holds the session +-- on this file: the save failed, or the result is marker-free but unstaged, which +-- model.build couldn't reopen +---@return boolean +local function settle_result() + local s = assert(session) + if vim.bo[s.result_buf].modified then + local ok, err = pcall(vim.api.nvim_buf_call, s.result_buf, function() + vim.cmd("silent update") + end) + if not ok then + notify(tostring(err), vim.log.levels.ERROR) + return false + end + elseif #s.regions == 0 and not s.staged then + on_write() -- written but never staged, e.g. after a failed add end + return #s.regions > 0 or s.staged +end + +-- the next/prev file in the session's list that is still conflicted and builds, wrapping +-- past the ends. staged files drop out of `conflicted`; unopenable ones fail the build +---@param s differ.MergeSession +---@param conflicted string[] +---@param direction "next"|"prev" +---@return string|nil relpath, differ.MergeModel|nil model, boolean wrapped +local function find_file(s, conflicted, direction) local build = require("differ.merge.model").build - for _, relpath in ipairs(remaining) do - local model = build(root, relpath, nil) - if model then - return lay_out(root, relpath, model, layout) + local n, from = #s.files, s.file_index + local step = 1 + if direction == "prev" then + step = -1 + end + for k = 1, n - 1 do + local i = (from - 1 + k * step) % n + 1 + local relpath = s.files[i] + if vim.tbl_contains(conflicted, relpath) then + local model = build(s.root, relpath, nil) + if model then + return relpath, model, (step == 1 and i < from) or (step == -1 and i > from) + end + end + end + return nil, nil, false +end + +-- ]f/[f: settle the result, then open the next/prev conflicted file in the session layout +---@param direction "next"|"prev" +local function step_file(direction) + local s = live_session() + if not s or not settle_result() then + return + end + local conflicted = require("differ.git").conflicted(s.root) + local relpath, model, wrapped = find_file(s, conflicted, direction) + if not relpath then + local others = vim.tbl_filter(function(p) + return p ~= s.path + end, conflicted) + if #conflicted == 0 then + return notify("no conflicted files left") end + if #others == 0 then + return notify("no other conflicted file") + end + return report_unopenable(others) + end + if wrapped and direction == "next" then + notify("wrapped to the first conflicted file") + elseif wrapped then + notify("wrapped to the last conflicted file") + end + lay_out(s.root, relpath, assert(model), s.layout, s.files) +end + +-- why the session no longer matches git, or nil: HEAD or the operation changed (a +-- --continue, --abort or skip), or this file was resolved by something other than the session +---@param s differ.MergeSession +---@param conflicted string[] +---@return string|nil msg, integer|nil level +local function stale_reason(s, conflicted) + local git = require("differ.git") + if git.head_sha(s.root) ~= s.head or git.operation(s.root) ~= s.operation then + return "git moved on outside the merge tool", vim.log.levels.INFO + end + if s.staged or vim.tbl_contains(conflicted, s.path) then + return nil, nil + end + -- what git staged is the file on disk, which the buffer may not match + local ok, lines = pcall(vim.fn.readfile, s.root .. "/" .. s.path) + if not ok then + lines = {} + end + if #require("differ.git.conflict").parse(lines) > 0 or markers_reformatted(lines) then + return s.path .. " was staged with conflict markers outside the merge tool", + vim.log.levels.WARN end - report_unopenable(remaining) + return s.path .. " was resolved outside the merge tool", vim.log.levels.INFO +end + +-- the git dir changed: refresh the files left, and close once the session is stale +---@param this differ.MergeSession +local function on_git_change(this) + if session ~= this then + return + end + local conflicted = require("differ.git").conflicted(this.root) + this.left = #conflicted + local msg, level = stale_reason(this, conflicted) + if not msg then + return vim.cmd("redrawstatus!") + end + notify(msg, level) M.close() end @@ -864,7 +1043,9 @@ end ---@param relpath string ---@param model differ.MergeModel ---@param layout "default"|"diff4" -function lay_out(root, relpath, model, layout) +---@param files string[] +function lay_out(root, relpath, model, layout, files) + local git = require("differ.git") if session then -- re-open over a live session M.close() end @@ -932,6 +1113,12 @@ function lay_out(root, relpath, model, layout) local km = (cfg.keymaps.merge or require("differ.config").defaults.keymaps) --[[@as differ.KeymapSet]] local bound = {} -- the specs actually bound below, so close drops exactly these + local file_index + for i, f in ipairs(files) do + if f == relpath then + file_index = i + end + end local first = model.regions[1] session = { root = root, @@ -949,6 +1136,12 @@ function lay_out(root, relpath, model, layout) }, no_ancestor = model.no_ancestor or false, layout = layout, + files = files, + file_index = assert(file_index, "merge: " .. relpath .. " is not in the file list"), + left = #git.conflicted(root), + staged = false, + head = git.head_sha(root), + operation = git.operation(root), result_win = result_win, result_buf = result_buf, bufs = bufs, @@ -1025,6 +1218,12 @@ function lay_out(root, relpath, model, layout) rb("choose_none", function() resolve_choice("none") end, "differ: drop the conflict") + rb("next_file", function() + step_file("next") + end, "differ: next conflicted file") + rb("prev_file", function() + step_file("prev") + end, "differ: previous conflicted file") rb("help", show_help, "differ: keymap help") -- the input panes are scratch buffers deleted on close, so these need no unbind for _, buf in ipairs(bufs) do @@ -1034,6 +1233,12 @@ function lay_out(root, relpath, model, layout) bind(buf, km.prev_conflict, function() goto_conflict("prev") end, "differ: previous conflict") + bind(buf, km.next_file, function() + step_file("next") + end, "differ: next conflicted file") + bind(buf, km.prev_file, function() + step_file("prev") + end, "differ: previous conflicted file") bind(buf, km.help, show_help, "differ: keymap help") end -- no close key here: the result buffer is the real file and stays editable, so q is @@ -1041,53 +1246,21 @@ function lay_out(root, relpath, model, layout) local aug = vim.api.nvim_create_augroup("differ.merge." .. result_buf, { clear = true }) session.aug = aug - -- :w writes the real file; once no markers remain it auto-stages (git add = resolve), - -- otherwise it just reports what's left. repaint so a hand-edit's regions stay current + -- a stage, lazygit or a --continue run outside the session changes the index or HEAD + local git_dir = git.git_dir(root) + if git_dir then + local this = session + session.watcher = require("differ.git.watch").new({ + git_dir = git_dir, + on_change = function() + on_git_change(this) + end, + }) + end vim.api.nvim_create_autocmd("BufWritePost", { buffer = result_buf, group = aug, - callback = function() - if not session then - return - end - repaint_result() - -- a format-on-save that didn't honour disable_autoformat reflows the markers; the - -- parser loses the region, so guard the stage on the raw buffer, not the count - local reformatted = - markers_reformatted(vim.api.nvim_buf_get_lines(session.result_buf, 0, -1, false)) - if reformatted then - if not session.autoformat_warned then - session.autoformat_warned = true - notify( - "a formatter changed the conflict markers on save; not staged. " - .. "your format_on_save ignores vim.b.disable_autoformat", - vim.log.levels.WARN - ) - end - elseif #session.regions == 0 then - -- a failed add has already notified; the view stays up so a later :w retries - if not require("differ.git").stage(session.root, session.path) then - return - end - notify(session.path .. " resolved and staged") - -- advance to the next conflicted file (or finish + close) once the write - -- settles; re-opening the session from inside this BufWritePost isn't safe. - -- root/layout are this session's lay_out upvalues. tie the deferred advance to - -- this exact session so a close (or a new session) before the tick cancels it - local this = session - vim.schedule(function() - if session == this then - advance(root, layout) - end - end) - else - local noun = "conflicts" - if #session.regions == 1 then - noun = "conflict" - end - notify(("%d %s still unresolved; not staged"):format(#session.regions, noun)) - end - end, + callback = on_write, }) -- keep the active-conflict emphasis tracking the cursor vim.api.nvim_create_autocmd("CursorMoved", { @@ -1191,7 +1364,7 @@ function M.open(opts) if not model then return report_unopenable({ relpath }) end - lay_out(root, relpath, model, layout) + lay_out(root, relpath, model, layout, conflicted) end if opts.path and opts.path ~= "" then diff --git a/test/nvim/mergetool_spec.lua b/test/nvim/mergetool_spec.lua index 9a5014c..9ef257e 100644 --- a/test/nvim/mergetool_spec.lua +++ b/test/nvim/mergetool_spec.lua @@ -199,7 +199,7 @@ local function conflict_repo_add_add() end -- a repo where merging `feature` conflicts on TWO files (f.txt then g.txt), to exercise --- write-driven advancing through the conflict set +-- ]f/[f stepping through the conflict set local function conflict_repo_two() local root = vim.fn.tempname() vim.fn.mkdir(root, "p") @@ -535,86 +535,77 @@ describe(":Differ mergetool resolution", function() assert.are.same({}, require("differ.git").conflicted(root)) end) - it("advances to the next conflicted file once one is resolved and written", function() + it("stays on the file once a write stages it", function() local root = conflict_repo_two() vim.cmd.edit(root .. "/f.txt") merge.open({}) local s = merge.current() - assert.are.equal("f.txt", s.path) fire(s.result_buf, "differ: take ours") vim.api.nvim_set_current_win(s.result_win) vim.cmd("silent write") - -- f.txt stages immediately; the deferred advance opens the next conflicted file - vim.wait(2000, function() - return merge.current() ~= nil and merge.current().path == "g.txt" + vim.wait(200, function() + return merge.current() ~= s end) - assert.are.equal("g.txt", merge.current().path) + assert.are.equal(s, merge.current()) assert.are.same({ "g.txt" }, require("differ.git").conflicted(root)) end) - it("neither reports nor advances when the stage fails", function() + it("stages and re-stages quietly, leaving progress to the winbar", function() + local root = conflict_repo_two() + vim.cmd.edit(root .. "/f.txt") + merge.open({}) + local s = merge.current() + _G.notifs = {} + fire(s.result_buf, "differ: take ours") + vim.api.nvim_set_current_win(s.result_win) + vim.cmd("silent write") + vim.api.nvim_buf_set_lines(s.result_buf, 0, 0, false, { "extra" }) + vim.cmd("silent write") + assert.are.same({}, _G.notifs) + assert.are.same({ "g.txt" }, require("differ.git").conflicted(root)) + end) + + it("doesn't move on when the stage fails", function() local root = conflict_repo_two() vim.cmd.edit(root .. "/f.txt") merge.open({}) local s = merge.current() local git_mod = require("differ.git") - local stage, orig_notify = git_mod.stage, vim.notify - local messages = {} + local stage = git_mod.stage git_mod.stage = function() return false end - vim.notify = function(msg) - messages[#messages + 1] = msg - end local ok, err = pcall(function() fire(s.result_buf, "differ: take ours") vim.api.nvim_set_current_win(s.result_win) vim.cmd("silent write") - -- give a scheduled advance the chance to run - vim.wait(200, function() - return merge.current() == nil or merge.current().path ~= "f.txt" - end) + -- a marker-free result left unstaged couldn't be reopened, so ]f holds too + fire(s.result_buf, "differ: next conflicted file") end) - git_mod.stage, vim.notify = stage, orig_notify + git_mod.stage = stage assert(ok, err) - for _, msg in ipairs(messages) do - assert.is_nil(msg:find("resolved and staged", 1, true)) - end - assert.are.equal("f.txt", merge.current().path) + assert.are.equal(s, merge.current()) assert.are.same({ "f.txt", "g.txt" }, require("differ.git").conflicted(root)) end) - it("reports done and closes the session once the last conflict is written", function() + it("reports done once the last conflict is written, staying open", function() local root = conflict_repo() vim.cmd.edit(root .. "/f.txt") merge.open({}) local s = merge.current() + _G.notifs = {} fire(s.result_buf, "differ: take ours") vim.api.nvim_set_current_win(s.result_win) vim.cmd("silent write") - vim.wait(2000, function() - return merge.current() == nil - end) - assert.is_nil(merge.current()) + assert.are.equal(s, merge.current()) + assert.are.equal( + "differ: all conflicts resolved: git merge --continue", + _G.notifs[#_G.notifs].msg + ) assert.are.same({}, require("differ.git").conflicted(root)) end) - it("skips a conflict the merge tool can't open for the next one that builds", function() - local root = conflict_repo_modify_delete({ "f.txt", "g.txt" }) - assert.are.same({ "a.txt", "f.txt", "g.txt" }, require("differ.git").conflicted(root)) - vim.cmd.edit(root .. "/f.txt") - merge.open({}) - local s = merge.current() - fire(s.result_buf, "differ: take ours") - vim.api.nvim_set_current_win(s.result_win) - vim.cmd("silent write") - vim.wait(2000, function() - return merge.current() ~= nil and merge.current().path == "g.txt" - end) - assert.are.equal("g.txt", merge.current().path) - end) - - it("names the conflicts it can't open once only those remain, then closes", function() + it("names the conflicts it can't open once only those remain, staying open", function() local root = conflict_repo_modify_delete({ "f.txt" }) vim.cmd.edit(root .. "/f.txt") merge.open({}) @@ -623,10 +614,7 @@ describe(":Differ mergetool resolution", function() fire(s.result_buf, "differ: take ours") vim.api.nvim_set_current_win(s.result_win) vim.cmd("silent write") - vim.wait(2000, function() - return merge.current() == nil - end) - assert.is_nil(merge.current()) + assert.are.equal(s, merge.current()) local last = _G.notifs[#_G.notifs] assert.are.equal( "differ: a.txt can't be opened in the merge tool: resolve with git add/rm", @@ -761,13 +749,10 @@ describe(":Differ mergetool in-progress operation", function() fire(s.result_buf, "differ: take ours") vim.api.nvim_set_current_win(s.result_win) vim.cmd("silent write") - vim.wait(2000, function() - return merge.current() == nil - end) end) vim.notify = orig_notify assert(ok, err) - assert.is_nil(merge.current()) + assert.are.equal(s, merge.current()) return messages end @@ -785,6 +770,214 @@ describe(":Differ mergetool in-progress operation", function() end) end) +describe(":Differ mergetool file stepping", function() + after_each(function() + if merge.current() then + merge.close() + end + end) + + ---@param root string + ---@param relpath string + local function open_at(root, relpath) + vim.cmd.edit(root .. "/" .. relpath) + merge.open({}) + return assert(merge.current()) + end + + it("steps to the next conflicted file with ]f, keeping the layout", function() + local root = conflict_repo_two() + vim.cmd.edit(root .. "/f.txt") + merge.open({ layout = "diff4" }) + fire(merge.current().result_buf, "differ: next conflicted file") + assert.are.equal("g.txt", merge.current().path) + assert.are.equal("diff4", merge.current().layout) + end) + + it("wraps past either end, saying so", function() + local root = conflict_repo_two() + local s = open_at(root, "g.txt") + _G.notifs = {} + fire(s.result_buf, "differ: next conflicted file") + assert.are.equal("f.txt", merge.current().path) + assert.are.equal("differ: wrapped to the first conflicted file", _G.notifs[1].msg) + _G.notifs = {} + fire(merge.current().result_buf, "differ: previous conflicted file") + assert.are.equal("g.txt", merge.current().path) + assert.are.equal("differ: wrapped to the last conflicted file", _G.notifs[1].msg) + end) + + it("steps from an input pane too", function() + local root = conflict_repo_two() + local s = open_at(root, "f.txt") + local ours = vim.api.nvim_win_get_buf(input(s, "ours").win) + fire(ours, "differ: next conflicted file") + assert.are.equal("g.txt", merge.current().path) + end) + + it("saves and stages an unwritten resolved result on the way out", function() + local root = conflict_repo_two() + local s = open_at(root, "f.txt") + fire(s.result_buf, "differ: take ours") + assert.is_true(vim.bo[s.result_buf].modified) + fire(s.result_buf, "differ: next conflicted file") + assert.are.equal("g.txt", merge.current().path) + assert.is_false(vim.bo[s.result_buf].modified) + assert.are.same({ "g.txt" }, require("differ.git").conflicted(root)) + end) + + it("saves without staging a result that still has markers, and moves on", function() + local root = conflict_repo_two() + local s = open_at(root, "f.txt") + vim.api.nvim_buf_set_lines(s.result_buf, 0, 0, false, { "extra" }) + fire(s.result_buf, "differ: next conflicted file") + assert.are.equal("g.txt", merge.current().path) + assert.are.equal("extra", vim.fn.readfile(root .. "/f.txt")[1]) + assert.are.same({ "f.txt", "g.txt" }, require("differ.git").conflicted(root)) + end) + + it("skips staged files and ones it can't open", function() + local root = conflict_repo_modify_delete({ "f.txt", "g.txt", "h.txt" }) + local s = open_at(root, "f.txt") + assert.are.same({ "a.txt", "f.txt", "g.txt", "h.txt" }, s.files) + fire(s.result_buf, "differ: next conflicted file") -- f -> g + local g = merge.current() + fire(g.result_buf, "differ: take ours") + fire(g.result_buf, "differ: next conflicted file") -- g staged -> h + assert.are.equal("h.txt", merge.current().path) + -- forward from h wraps past a.txt (unopenable) to f, back skips g (staged) + fire(merge.current().result_buf, "differ: next conflicted file") + assert.are.equal("f.txt", merge.current().path) + fire(merge.current().result_buf, "differ: previous conflicted file") + assert.are.equal("h.txt", merge.current().path) + end) + + it("names what's left when no other file can be opened", function() + local root = conflict_repo_modify_delete({ "f.txt" }) + local s = open_at(root, "f.txt") + _G.notifs = {} + fire(s.result_buf, "differ: next conflicted file") + assert.are.equal(s, merge.current()) + assert.are.equal( + "differ: a.txt can't be opened in the merge tool: resolve with git add/rm", + _G.notifs[#_G.notifs].msg + ) + end) + + it("says so when no conflicted file is left", function() + local root = conflict_repo() + local s = open_at(root, "f.txt") + fire(s.result_buf, "differ: take ours") + _G.notifs = {} + fire(s.result_buf, "differ: next conflicted file") + assert.are.equal(s, merge.current()) + assert.are.equal("differ: no conflicted files left", _G.notifs[#_G.notifs].msg) + end) + + it("shows file progress in the result winbar", function() + local root = conflict_repo_two() + local s = open_at(root, "f.txt") + vim.g.statusline_winid = s.result_win + assert.are.equal("RESULT · file 1/2 · conflict 1/1 · 1 unresolved", merge.winbar()) + fire(s.result_buf, "differ: take ours") + assert.are.equal("RESULT · file 1/2 · all resolved · :w to stage", merge.winbar()) + vim.api.nvim_set_current_win(s.result_win) + vim.cmd("silent write") + assert.are.equal("RESULT · file 1/2 · staged · 1 file left", merge.winbar()) + fire(s.result_buf, "differ: next conflicted file") + local g = merge.current() + vim.g.statusline_winid = g.result_win + assert.is_truthy(merge.winbar():find("RESULT · file 2/2", 1, true)) + fire(g.result_buf, "differ: take ours") + vim.api.nvim_set_current_win(g.result_win) + vim.cmd("silent write") + assert.are.equal("RESULT · file 2/2 · staged · all conflicts resolved", merge.winbar()) + end) +end) + +describe(":Differ mergetool changes outside the session", function() + after_each(function() + if merge.current() then + merge.close() + end + end) + + ---@param root string + local function open_two(root) + vim.cmd.edit(root .. "/f.txt") + merge.open({}) + return assert(merge.current()) + end + + ---@param msg string + local function wait_closed_with(msg) + vim.wait(3000, function() + return merge.current() == nil + end) + assert.is_nil(merge.current()) + local found = false + for _, n in ipairs(_G.notifs) do + found = found or n.msg == msg + end + assert.is_true(found, msg) + end + + it("closes once the file is resolved and staged outside it", function() + local root = conflict_repo_two() + open_two(root) + _G.notifs = {} + write(root .. "/f.txt", "a\nOURS\nc\n") + git_ok(root, "add", "f.txt") + wait_closed_with("differ: f.txt was resolved outside the merge tool") + end) + + it("warns when an outside add stages the conflict markers", function() + local root = conflict_repo_two() + open_two(root) + _G.notifs = {} + git_ok(root, "add", ".") + wait_closed_with("differ: f.txt was staged with conflict markers outside the merge tool") + end) + + it("refreshes the files left when another file is staged outside it", function() + local root = conflict_repo_two() + local s = open_two(root) + assert.are.equal(2, s.left) + git_ok(root, "add", "g.txt") + vim.wait(3000, function() + return s.left == 1 + end) + assert.are.equal(1, s.left) + assert.are.equal(s, merge.current()) + end) + + it("closes once the operation moves on outside it", function() + local root = conflict_repo() + vim.cmd.edit(root .. "/f.txt") + merge.open({}) + local s = merge.current() + fire(s.result_buf, "differ: take ours") + vim.api.nvim_set_current_win(s.result_win) + vim.cmd("silent write") + _G.notifs = {} + git_ok(root, "commit", "-q", "--no-edit") + wait_closed_with("differ: git moved on outside the merge tool") + end) + + it("stays open through its own stage", function() + local root = conflict_repo_two() + local s = open_two(root) + fire(s.result_buf, "differ: take ours") + vim.api.nvim_set_current_win(s.result_win) + vim.cmd("silent write") + vim.wait(300, function() + return merge.current() ~= s + end) + assert.are.equal(s, merge.current()) + assert.are.equal(1, s.left) + end) +end) + describe(":Differ mergetool navigation", function() after_each(function() if merge.current() then @@ -1206,7 +1399,7 @@ describe(":Differ mergetool diff4 base pane", function() vim.cmd.edit(root .. "/f.txt") merge.open({ layout = "diff4" }) local s = merge.current() - -- resolve the first (auto-advances onto the second), then take base there: the live + -- resolve the first (the cursor moves onto the second), then take base there: the live -- index -> original mapping has shifted, so this proves the lookup follows `order` fire(s.result_buf, "differ: take base") fire(s.result_buf, "differ: take base") From 36dd09ebce3882cef3cf4c48a05bb3f98113c143 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Mon, 12 Oct 2026 10:40:51 +0100 Subject: [PATCH 06/25] feat: keep or delete modify/delete conflicts in the merge tool --- RECIPES.md | 1 + doc/differ-recipes.txt | 3 + lua/differ/git/index.lua | 7 + lua/differ/git/init.lua | 1 + lua/differ/merge/init.lua | 279 +++++++++++++++++----- lua/differ/merge/model.lua | 30 ++- lua/differ/ui/highlights.lua | 3 + test/nvim/mergetool_spec.lua | 419 +++++++++++++++++++++++++++++++-- test/unit/merge_model_spec.lua | 48 +++- 9 files changed, 711 insertions(+), 80 deletions(-) diff --git a/RECIPES.md b/RECIPES.md index 9f85253..c4b4750 100644 --- a/RECIPES.md +++ b/RECIPES.md @@ -129,3 +129,4 @@ Every group is defined with `default = true`, so a `:highlight` of your own wins | `differMergeConflict` | an unresolved block in the result | | `differMergeMarker` | a raw conflict marker left in the buffer | | `differMergeFlash` | the transient flash on lines a take-this produced | +| `differMergeUnstaged` / `differMergeStaged` | the result winbar's index state: still to stage, or staged | diff --git a/doc/differ-recipes.txt b/doc/differ-recipes.txt index 6f7697d..b623f95 100644 --- a/doc/differ-recipes.txt +++ b/doc/differ-recipes.txt @@ -250,6 +250,9 @@ MERGE TOOL *differ-recipes-highlight-groups-merge-tool* differMergeFlash the transient flash on lines a take-this produced + + differMergeUnstaged / the result winbar’s index state: + differMergeStaged still to stage, or staged ----------------------------------------------------------------------- Generated by panvimdoc diff --git a/lua/differ/git/index.lua b/lua/differ/git/index.lua index 434d520..4c1940a 100644 --- a/lua/differ/git/index.lua +++ b/lua/differ/git/index.lua @@ -20,6 +20,13 @@ function M.unstage(root, path) return git_ok({ "reset", "-q", "HEAD", "--", path }, root, "unstage") end +---@param root string +---@param path string +---@return boolean ok +function M.remove(root, path) + return git_ok({ "rm", "-q", "--", path }, root, "delete") +end + ---@param root string ---@return boolean ok function M.stage_all(root) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 5e3862e..abe7958 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -824,6 +824,7 @@ end -- the index ops the panel's file keys and the merge tool drive M.stage, M.unstage = index_ops.stage, index_ops.unstage +M.remove = index_ops.remove M.stage_all, M.unstage_all = index_ops.stage_all, index_ops.unstage_all -- reverse-apply a single-hunk patch to the worktree, atomically. `--unidiff-zero` diff --git a/lua/differ/merge/init.lua b/lua/differ/merge/init.lua index 841876f..d7f234a 100644 --- a/lua/differ/merge/init.lua +++ b/lua/differ/merge/init.lua @@ -31,6 +31,7 @@ local flash_ns = vim.api.nvim_create_namespace("differ.merge.flash") -- block is spliced away, and undo restores both text and mark together. its own namespace, -- since paint_result clears merge_ns on every repaint local anchor_ns = vim.api.nvim_create_namespace("differ.merge.anchor") +local notice_ns = vim.api.nvim_create_namespace("differ.merge.notice") local FOLDTEXT_EXPR = 'v:lua.require("differ.ui.foldtext").render()' @@ -63,11 +64,13 @@ local INPUT_HL = { ---@field active_index integer|nil -- live index of the conflict under the cursor ---@field labels table -- side -> winbar pane label (OURS (HEAD), ...) ---@field no_ancestor boolean -- add/add: the base pane says so rather than sitting empty +---@field deleted "ours"|"theirs"|nil -- modify/delete: the side that deleted the file ---@field layout "default"|"diff4" -- the chosen layout, reused when stepping to another file ---@field files string[] -- the conflicted files at session start, in git's order (]f/[f) ---@field file_index integer -- this file's position in files ---@field left integer -- conflicted files still unstaged (for the winbar) ---@field staged boolean -- this file has been staged this session +---@field removed boolean|nil -- a modify/delete file deleted this session ---@field head string|nil -- HEAD at session start; a move means the operation went on ---@field operation string|nil -- the in-progress operation at session start ---@field watcher differ.git.Watcher|nil -- the git dir watch for changes made outside the session @@ -598,6 +601,9 @@ local function region_at(regions, lnum) return nil end +---@type fun(choice: "ours"|"theirs"|"both"|"base"|"none") +local resolve_deleted + -- resolve the conflict under the cursor by splicing in the chosen slab, then re-derive + -- repaint, flash the produced lines, and advance to the next remaining conflict ---@param choice "ours"|"theirs"|"both"|"base"|"none" @@ -605,6 +611,9 @@ local function resolve_choice(choice) if not live_session() then return end + if session.deleted then + return resolve_deleted(choice) + end local regions = live_regions() if #regions == 0 then return notify("no conflicts remain") @@ -696,6 +705,37 @@ local function plural(n, noun) return ("%d %ss"):format(n, noun) end +-- a winbar segment in `hl`, then back to the winbar's own colours +---@param text string +---@param hl string +---@return string +local function tint(text, hl) + return ("%%#%s#%s%%*"):format(hl, text) +end + +-- the result winbar: file progress, then the conflicts left or this file's index state +---@param s differ.MergeSession +---@return string +local function result_winbar(s) + local file = ("RESULT · file %d/%d"):format(s.file_index, #s.files) + local remaining = #s.regions + if s.deleted and not s.staged then + return ("%s · %s"):format(file, tint("deleted in " .. s.deleted, "differMergeUnstaged")) + end + if remaining == 0 and s.staged then + local done = tint(s.removed and "deleted" or "staged", "differMergeStaged") + if s.left == 0 then + return ("%s · %s · all conflicts resolved"):format(file, done) + end + return ("%s · %s · %s left"):format(file, done, plural(s.left, "file")) + end + if remaining == 0 then + return ("%s · all resolved · %s"):format(file, tint("not staged", "differMergeUnstaged")) + end + local n = (s.total - remaining) + active_pos() -- the absolute conflict ordinal + return ("%s · conflict %d/%d · %d unresolved"):format(file, n, s.total, remaining) +end + -- winbar text for the merge windows: the result shows the conflict counter, an input -- shows its side label. a `%!` expression reading the window it renders for ---@return string @@ -705,20 +745,7 @@ function M.winbar() end local win = vim.g.statusline_winid if win == session.result_win then - local file = ("RESULT · file %d/%d"):format(session.file_index, #session.files) - local remaining = #session.regions - if remaining == 0 and session.staged and session.left == 0 then - return file .. " · staged · all conflicts resolved" - end - if remaining == 0 and session.staged then - return ("%s · staged · %s left"):format(file, plural(session.left, "file")) - end - if remaining == 0 then - return file .. " · all resolved · :w to stage" - end - local total = session.total - local n = (total - remaining) + active_pos() -- the absolute conflict ordinal - return ("%s · conflict %d/%d · %d unresolved"):format(file, n, total, remaining) + return result_winbar(session) end local side = session.win_side[win] if side == "base" then @@ -744,6 +771,20 @@ local function dress(win) set_local(win, "winbar", '%!v:lua.require("differ.merge").winbar()') end +-- an empty pane that stands for a deleted file: one dim virtual line, no line numbers +---@param win integer +---@param buf integer +---@param text string +local function show_notice(win, buf, text) + set_local(win, "number", false) + set_local(win, "cursorline", false) + set_local(win, "signcolumn", "no") + vim.api.nvim_buf_set_extmark(buf, notice_ns, 0, 0, { + virt_text = { { text, "differFiller" } }, + virt_text_pos = "overlay", + }) +end + -- (re)create the native folds for a window from its fold ranges, left open by default -- (the structure stays so zM/zc collapse the unchanged regions on demand) ---@param win integer @@ -766,16 +807,10 @@ local function apply_folds(win, folds) end) end --- end the session: drop the tab + buffers and return to the invoking tab -function M.close() - if not session then - return - end - local s = session - session = nil - restore_timeout() -- net in case the tab teardown didn't fire BufLeave on the result buf - -- the result buffer is the user's real file and outlives the session, so it has to be - -- handed back clean: no session paint, no session keymaps, no session hooks +-- the result buffer is the user's real file and outlives the session, so it has to be +-- handed back clean: no session paint, no session keymaps, no session hooks +---@param s differ.MergeSession +local function release_result(s) if vim.api.nvim_buf_is_valid(s.result_buf) then vim.b[s.result_buf].disable_autoformat = s.saved_autoformat -- give autoformat back if s.saved_markdown_render then @@ -792,7 +827,19 @@ function M.close() -- drop the diagnostics hook; the producers re-lint the now-resolved file on their own if s.diag_aug then pcall(vim.api.nvim_del_augroup_by_id, s.diag_aug) + s.diag_aug = nil + end +end + +-- end the session: drop the tab + buffers and return to the invoking tab +function M.close() + if not session then + return end + local s = session + session = nil + restore_timeout() -- net in case the tab teardown didn't fire BufLeave on the result buf + release_result(s) if s.aug then pcall(vim.api.nvim_del_augroup_by_id, s.aug) end @@ -820,7 +867,14 @@ local function show_help() local help = require("differ.ui.help") local fmt, pair = help.fmt, help.pair local rows = { { pair(km.next_conflict, km.prev_conflict), "next / previous conflict" } } - if vim.api.nvim_get_current_buf() == session.result_buf then + local deleted = session.deleted + local in_result = vim.api.nvim_get_current_buf() == session.result_buf and not session.removed + if in_result and deleted then + vim.list_extend(rows, { + { fmt(km.choose_ours), deleted == "ours" and "delete the file" or "keep the file" }, + { fmt(km.choose_theirs), deleted == "theirs" and "delete the file" or "keep the file" }, + }) + elseif in_result then vim.list_extend(rows, { { pair(km.next_marker, km.prev_marker), "next / previous marker line" }, { fmt(km.choose_ours), "take ours" }, @@ -828,11 +882,11 @@ local function show_help() { fmt(km.choose_base), "take base" }, { fmt(km.choose_all), "take both (ours then theirs)" }, { fmt(km.choose_none), "drop the conflict" }, - { ":w", "write the file (auto-stages once resolved)" }, + { ":w", "save and stage" }, }) end vim.list_extend(rows, { - { pair(km.next_file, km.prev_file), "save, stage if resolved, next / previous file" }, + { pair(km.next_file, km.prev_file), "next / previous file" }, { ":Differ close", "close the merge tool" }, { fmt(km.help), "this help" }, }) @@ -845,7 +899,24 @@ end ---@type fun(root: string, relpath: string, model: differ.MergeModel, layout: "default"|"diff4", files: string[]) local lay_out --- conflicted paths with no markers in the worktree (modify/delete, added by one side, binary) +-- the input pane labels, named by the first conflict's markers. a modify/delete file has +-- none: HEAD is ours under every operation, but theirs has no one ref to name +---@param model differ.MergeModel +---@return table +local function side_labels(model) + local first = model.regions[1] + local labels = { ours = "OURS (HEAD)", base = "BASE", theirs = "THEIRS", result = "RESULT" } + if first then + labels.ours = ("OURS (%s)"):format(first.label_ours or "HEAD") + labels.theirs = ("THEIRS (%s)"):format(first.label_theirs or "MERGE_HEAD") + end + if model.deleted then + labels[model.deleted] = labels[model.deleted] .. " · deleted" + end + return labels +end + +-- conflicted paths the merge tool can't open (added by one side, deleted by both, binary) ---@param relpaths string[] local function report_unopenable(relpaths) notify( @@ -879,22 +950,27 @@ local function report_progress(root, conflicted) report_unopenable(conflicted) end +-- mark this file resolved in the same tick as the index write, so the git-dir watcher +-- doesn't read it as resolved outside the session, and report progress +---@param s differ.MergeSession +local function mark_staged(s) + s.staged = true + local conflicted = require("differ.git").conflicted(s.root) + s.left = #conflicted + report_progress(s.root, conflicted) +end + -- git add the marker-free result. the winbar shows it staged; only the file's first stage -- reports progress, since an auto-save re-stages it on every later edit local function stage_result() local s = assert(session) - local git = require("differ.git") -- a failed add has already notified; the file stays unstaged so a later write retries - if not git.stage(s.root, s.path) then + if not require("differ.git").stage(s.root, s.path) then return end - if s.staged then - return + if not s.staged then + mark_staged(s) end - s.staged = true - local conflicted = git.conflicted(s.root) - s.left = #conflicted - report_progress(s.root, conflicted) end -- BufWritePost on the result: stage once no markers remain. repaint so a hand-edit's @@ -928,6 +1004,10 @@ end ---@return boolean local function settle_result() local s = assert(session) + -- an untouched modify/delete file waits for keep or delete; stepping past isn't a keep + if s.deleted and not s.staged and not vim.bo[s.result_buf].modified then + return true + end if vim.bo[s.result_buf].modified then local ok, err = pcall(vim.api.nvim_buf_call, s.result_buf, function() vim.cmd("silent update") @@ -997,6 +1077,105 @@ local function step_file(direction) lay_out(s.root, relpath, assert(model), s.layout, s.files) end +-- the keys an input pane carries; also bound on the scratch buffer a delete leaves behind +---@param buf integer +local function bind_pane(buf) + local km = assert(session).keymaps + bind(buf, km.next_conflict, function() + goto_conflict("next") + end, "differ: next conflict") + bind(buf, km.prev_conflict, function() + goto_conflict("prev") + end, "differ: previous conflict") + bind(buf, km.next_file, function() + step_file("next") + end, "differ: next conflicted file") + bind(buf, km.prev_file, function() + step_file("prev") + end, "differ: previous conflicted file") + bind(buf, km.help, show_help, "differ: keymap help") +end + +-- keep a modify/delete file: write it as it stands, which stages it like any write +local function keep_file() + local s = assert(session) + local ok, err = pcall(vim.api.nvim_buf_call, s.result_buf, function() + vim.cmd("silent write") + end) + if not ok then + notify(tostring(err), vim.log.levels.ERROR) + end +end + +-- after a delete the file is gone, so the result window shows an empty read-only buffer +-- in place of the real one; a write there would recreate the file +---@param s differ.MergeSession +local function show_deleted(s) + local real = s.result_buf + release_result(s) + local buf = make_buffer("deleted", s.path, {}) + local notice = s.path .. " deletion staged" + -- acwrite, so a :w reaches the BufWriteCmd below rather than failing on the buftype + vim.bo[buf].buftype = "acwrite" + vim.api.nvim_create_autocmd("BufWriteCmd", { + buffer = buf, + group = s.aug, + callback = function() + notify(notice .. ": nothing to write") + end, + }) + s.bufs[#s.bufs + 1] = buf + s.result_buf, s.bound_keys = buf, {} + vim.api.nvim_win_set_buf(s.result_win, buf) + dress(s.result_win) + show_notice(s.result_win, buf, notice) + bind_pane(buf) + -- unloaded, so nvim doesn't report the file missing; a window outside the session keeps it + if #vim.fn.win_findbuf(real) == 0 then + pcall(vim.api.nvim_buf_delete, real, {}) + end +end + +-- delete a modify/delete file with git rm, once confirmed +local function delete_file() + local s = assert(session) + if vim.bo[s.result_buf].modified then + return notify("unsaved changes: drop them with :e! to delete", vim.log.levels.WARN) + end + -- confirm() runs scheduled callbacks while it blocks, so the watcher can close the session + if vim.fn.confirm(("Delete %s?"):format(s.path), "&Yes\n&No", 2) ~= 1 then + return + end + if session ~= s then + return notify("the merge tool closed while the prompt was up; nothing was deleted") + end + if not require("differ.git").remove(s.root, s.path) then + return + end + s.removed = true + mark_staged(s) + show_deleted(s) +end + +-- a modify/delete file: taking the deleting side deletes it, taking the other keeps it +---@param choice "ours"|"theirs"|"both"|"base"|"none" +function resolve_deleted(choice) + local s = assert(session) + if s.staged then + return notify(("%s is already resolved"):format(s.path)) + end + if choice ~= "ours" and choice ~= "theirs" then + return notify( + "a modify/delete conflict is kept or deleted: take ours or theirs", + vim.log.levels.WARN + ) + end + if choice == s.deleted then + return delete_file() + end + keep_file() +end + -- why the session no longer matches git, or nil: HEAD or the operation changed (a -- --continue, --abort or skip), or this file was resolved by something other than the session ---@param s differ.MergeSession @@ -1104,6 +1283,11 @@ function lay_out(root, relpath, model, layout, files) dress(win) set_local(win, "signcolumn", "yes:1") -- room for the ▌ slab sign end + for _, inp in ipairs(inputs) do + if inp.side == model.deleted then + show_notice(inp.win, vim.api.nvim_win_get_buf(inp.win), "deleted in " .. inp.side) + end + end dress(result_win) -- nav + take-this resolution live on the result buffer (the working surface), from @@ -1119,7 +1303,6 @@ function lay_out(root, relpath, model, layout, files) file_index = i end end - local first = model.regions[1] session = { root = root, path = relpath, @@ -1128,13 +1311,9 @@ function lay_out(root, relpath, model, layout, files) base_slabs = {}, total = #model.regions, active_index = nil, - labels = { - ours = ("OURS (%s)"):format((first and first.label_ours) or "HEAD"), - base = "BASE", - theirs = ("THEIRS (%s)"):format((first and first.label_theirs) or "MERGE_HEAD"), - result = "RESULT", - }, + labels = side_labels(model), no_ancestor = model.no_ancestor or false, + deleted = model.deleted, layout = layout, files = files, file_index = assert(file_index, "merge: " .. relpath .. " is not in the file list"), @@ -1227,19 +1406,7 @@ function lay_out(root, relpath, model, layout, files) rb("help", show_help, "differ: keymap help") -- the input panes are scratch buffers deleted on close, so these need no unbind for _, buf in ipairs(bufs) do - bind(buf, km.next_conflict, function() - goto_conflict("next") - end, "differ: next conflict") - bind(buf, km.prev_conflict, function() - goto_conflict("prev") - end, "differ: previous conflict") - bind(buf, km.next_file, function() - step_file("next") - end, "differ: next conflicted file") - bind(buf, km.prev_file, function() - step_file("prev") - end, "differ: previous conflicted file") - bind(buf, km.help, show_help, "differ: keymap help") + bind_pane(buf) end -- no close key here: the result buffer is the real file and stays editable, so q is -- left to native macro recording. `:Differ close` ends the session diff --git a/lua/differ/merge/model.lua b/lua/differ/merge/model.lua index 0b91dd2..f4d8392 100644 --- a/lua/differ/merge/model.lua +++ b/lua/differ/merge/model.lua @@ -18,6 +18,7 @@ local to_lines = require("differ.util.text").to_lines ---@field regions differ.merge.Region[] ---@field head string|nil ---@field no_ancestor boolean -- add/add: no :1: stage, so no base exists to recover or take +---@field deleted "ours"|"theirs"|nil -- modify/delete: the side that deleted the file, no regions local M = {} @@ -150,8 +151,28 @@ local function recover_base(regions, ours_text, base_text, theirs_text) end end +-- the side that deleted a modify/delete file: the ancestor and exactly one side's stage +-- are in the index. git leaves the other side's file on disk with no markers +---@param root string +---@param relpath string +---@return "ours"|"theirs"|nil +local function deleted_side(root, relpath) + local git = require("differ.git") + if not git.has_stage(root, relpath, 1) then + return nil + end + local has_ours = git.has_stage(root, relpath, 2) + if has_ours == git.has_stage(root, relpath, 3) then + return nil + end + if has_ours then + return "theirs" + end + return "ours" +end + -- build a MergeModel for a conflicted `relpath`. returns nil + a reason when the file --- isn't on disk or carries no conflict markers (already resolved / never conflicted) +-- isn't on disk, or carries no conflict markers and isn't a modify/delete conflict ---@param root string ---@param relpath string ---@param head string|nil @@ -163,8 +184,12 @@ function M.build(root, relpath, head) return nil, relpath .. " is not in the working tree" end local regions = conflict.parse(to_lines(result_text)) + local deleted = nil if #regions == 0 then - return nil, relpath .. " has no conflicts to resolve" + deleted = deleted_side(root, relpath) + if not deleted then + return nil, relpath .. " has no conflicts to resolve" + end end local ours_text = git.read_stage(root, relpath, 2) local base_text = git.read_stage(root, relpath, 1) @@ -186,6 +211,7 @@ function M.build(root, relpath, head) regions = regions, head = head, no_ancestor = no_ancestor, + deleted = deleted, } end diff --git a/lua/differ/ui/highlights.lua b/lua/differ/ui/highlights.lua index 2b4a4a5..5bde44a 100644 --- a/lua/differ/ui/highlights.lua +++ b/lua/differ/ui/highlights.lua @@ -126,6 +126,9 @@ local function status_groups(p) -- the gutter glyph on a line its side ends on with no newline after it: the -- changed colour, since that ending is what changed differNoEol = { fg = p.yellow }, + -- the merge result winbar's index state: a file still to stage, and one staged + differMergeUnstaged = { fg = p.orange }, + differMergeStaged = { fg = p.green }, -- the winbar badge on a view that isn't the whole change differViewBadge = { fg = p.green, bold = true, reverse = true }, } diff --git a/test/nvim/mergetool_spec.lua b/test/nvim/mergetool_spec.lua index 9ef257e..b1d6ef7 100644 --- a/test/nvim/mergetool_spec.lua +++ b/test/nvim/mergetool_spec.lua @@ -220,11 +220,19 @@ local function conflict_repo_two() return root end --- a repo where merging `feature` leaves a.txt modify/delete (main edits it, feature deletes --- it), sorted before a content conflict in each of `files` +-- a repo where merging `feature` leaves a.txt modify/delete, sorted before a content +-- conflict in each of `files`. `deleter` is the side that deletes it, theirs by default ---@param files string[] -local function conflict_repo_modify_delete(files) +---@param deleter "ours"|"theirs"|nil +local function conflict_repo_modify_delete(files, deleter) local root = vim.fn.tempname() + local function change_a(side) + if side == (deleter or "theirs") then + git_ok(root, "rm", "-q", "a.txt") + else + write(root .. "/a.txt", side:upper() .. "\n") + end + end vim.fn.mkdir(root, "p") git_ok(root, "init", "-q") write(root .. "/a.txt", "a\n") @@ -234,13 +242,13 @@ local function conflict_repo_modify_delete(files) git_ok(root, "add", ".") git_ok(root, "commit", "-q", "-m", "base") git_ok(root, "checkout", "-q", "-b", "feature") - git_ok(root, "rm", "-q", "a.txt") + change_a("theirs") for _, f in ipairs(files) do write(root .. "/" .. f, "a\nTHEIRS\nc\n") end git_ok(root, "commit", "-q", "-am", "theirs") git_ok(root, "checkout", "-q", "main") - write(root .. "/a.txt", "OURS\n") + change_a("ours") for _, f in ipairs(files) do write(root .. "/" .. f, "a\nOURS\nc\n") end @@ -249,6 +257,42 @@ local function conflict_repo_modify_delete(files) return root end +-- a repo where merging `feature` conflicts on a.bin, a binary file git writes no markers +-- into, sorted before a content conflict in each of `files` +---@param files string[] +local function conflict_repo_binary(files) + local root = vim.fn.tempname() + vim.fn.mkdir(root, "p") + git_ok(root, "init", "-q") + write(root .. "/a.bin", "base\0") + for _, f in ipairs(files) do + write(root .. "/" .. f, "a\nb\nc\n") + end + git_ok(root, "add", ".") + git_ok(root, "commit", "-q", "-m", "base") + git_ok(root, "checkout", "-q", "-b", "feature") + write(root .. "/a.bin", "theirs\0") + for _, f in ipairs(files) do + write(root .. "/" .. f, "a\nTHEIRS\nc\n") + end + git_ok(root, "commit", "-q", "-am", "theirs") + git_ok(root, "checkout", "-q", "main") + write(root .. "/a.bin", "ours\0") + for _, f in ipairs(files) do + write(root .. "/" .. f, "a\nOURS\nc\n") + end + git_ok(root, "commit", "-q", "-am", "ours") + git(root, "merge", "feature") + return root +end + +-- winbar text without its %#Group# / %* highlight items +---@param bar string +---@return string +local function plain(bar) + return (bar:gsub("%%#[^#]*#", ""):gsub("%%%*", "")) +end + local merge_ns = vim.api.nvim_create_namespace("differ.merge") local flash_ns = vim.api.nvim_create_namespace("differ.merge.flash") @@ -606,7 +650,7 @@ describe(":Differ mergetool resolution", function() end) it("names the conflicts it can't open once only those remain, staying open", function() - local root = conflict_repo_modify_delete({ "f.txt" }) + local root = conflict_repo_binary({ "f.txt" }) vim.cmd.edit(root .. "/f.txt") merge.open({}) local s = merge.current() @@ -617,7 +661,7 @@ describe(":Differ mergetool resolution", function() assert.are.equal(s, merge.current()) local last = _G.notifs[#_G.notifs] assert.are.equal( - "differ: a.txt can't be opened in the merge tool: resolve with git add/rm", + "differ: a.bin can't be opened in the merge tool: resolve with git add/rm", last.msg ) for _, n in ipairs(_G.notifs) do @@ -626,13 +670,13 @@ describe(":Differ mergetool resolution", function() end) it("says why when asked to open a conflict it can't", function() - local root = conflict_repo_modify_delete({ "f.txt" }) + local root = conflict_repo_binary({ "f.txt" }) vim.cmd.edit(root .. "/f.txt") _G.notifs = {} - merge.open({ path = root .. "/a.txt" }) + merge.open({ path = root .. "/a.bin" }) assert.is_nil(merge.current()) assert.are.equal( - "differ: a.txt can't be opened in the merge tool: resolve with git add/rm", + "differ: a.bin can't be opened in the merge tool: resolve with git add/rm", _G.notifs[1].msg ) assert.are.equal(vim.log.levels.WARN, _G.notifs[1].level) @@ -837,15 +881,15 @@ describe(":Differ mergetool file stepping", function() end) it("skips staged files and ones it can't open", function() - local root = conflict_repo_modify_delete({ "f.txt", "g.txt", "h.txt" }) + local root = conflict_repo_binary({ "f.txt", "g.txt", "h.txt" }) local s = open_at(root, "f.txt") - assert.are.same({ "a.txt", "f.txt", "g.txt", "h.txt" }, s.files) + assert.are.same({ "a.bin", "f.txt", "g.txt", "h.txt" }, s.files) fire(s.result_buf, "differ: next conflicted file") -- f -> g local g = merge.current() fire(g.result_buf, "differ: take ours") fire(g.result_buf, "differ: next conflicted file") -- g staged -> h assert.are.equal("h.txt", merge.current().path) - -- forward from h wraps past a.txt (unopenable) to f, back skips g (staged) + -- forward from h wraps past a.bin (unopenable) to f, back skips g (staged) fire(merge.current().result_buf, "differ: next conflicted file") assert.are.equal("f.txt", merge.current().path) fire(merge.current().result_buf, "differ: previous conflicted file") @@ -853,13 +897,13 @@ describe(":Differ mergetool file stepping", function() end) it("names what's left when no other file can be opened", function() - local root = conflict_repo_modify_delete({ "f.txt" }) + local root = conflict_repo_binary({ "f.txt" }) local s = open_at(root, "f.txt") _G.notifs = {} fire(s.result_buf, "differ: next conflicted file") assert.are.equal(s, merge.current()) assert.are.equal( - "differ: a.txt can't be opened in the merge tool: resolve with git add/rm", + "differ: a.bin can't be opened in the merge tool: resolve with git add/rm", _G.notifs[#_G.notifs].msg ) end) @@ -878,12 +922,15 @@ describe(":Differ mergetool file stepping", function() local root = conflict_repo_two() local s = open_at(root, "f.txt") vim.g.statusline_winid = s.result_win - assert.are.equal("RESULT · file 1/2 · conflict 1/1 · 1 unresolved", merge.winbar()) + assert.are.equal( + "RESULT · file 1/2 · conflict 1/1 · 1 unresolved", + plain(merge.winbar()) + ) fire(s.result_buf, "differ: take ours") - assert.are.equal("RESULT · file 1/2 · all resolved · :w to stage", merge.winbar()) + assert.are.equal("RESULT · file 1/2 · all resolved · not staged", plain(merge.winbar())) vim.api.nvim_set_current_win(s.result_win) vim.cmd("silent write") - assert.are.equal("RESULT · file 1/2 · staged · 1 file left", merge.winbar()) + assert.are.equal("RESULT · file 1/2 · staged · 1 file left", plain(merge.winbar())) fire(s.result_buf, "differ: next conflicted file") local g = merge.current() vim.g.statusline_winid = g.result_win @@ -891,7 +938,21 @@ describe(":Differ mergetool file stepping", function() fire(g.result_buf, "differ: take ours") vim.api.nvim_set_current_win(g.result_win) vim.cmd("silent write") - assert.are.equal("RESULT · file 2/2 · staged · all conflicts resolved", merge.winbar()) + assert.are.equal( + "RESULT · file 2/2 · staged · all conflicts resolved", + plain(merge.winbar()) + ) + end) + + it("colours the result's index state in the winbar", function() + local root = conflict_repo_two() + local s = open_at(root, "f.txt") + vim.g.statusline_winid = s.result_win + fire(s.result_buf, "differ: take ours") + assert.is_truthy(merge.winbar():find("%#differMergeUnstaged#not staged%*", 1, true)) + vim.api.nvim_set_current_win(s.result_win) + vim.cmd("silent write") + assert.is_truthy(merge.winbar():find("%#differMergeStaged#staged%*", 1, true)) end) end) @@ -1838,3 +1899,323 @@ describe("g? over a buffer the merge tool shares", function() assert.are.equal(merge_desc, help_desc(edit_buf)) -- not stolen back mid-merge end) end) + +describe(":Differ mergetool modify/delete", function() + after_each(function() + if merge.current() then + merge.close() + end + end) + + local function open_at(root, relpath) + merge.open({ path = root .. "/" .. relpath }) + return assert(merge.current()) + end + + -- the text of the dim virtual line on a deleted pane, or nil + local function notice(buf) + local ns = vim.api.nvim_get_namespaces()["differ.merge.notice"] + local marks = vim.api.nvim_buf_get_extmarks(buf, ns, 0, -1, { details = true }) + return marks[1] and marks[1][4].virt_text[1][1] + end + + local function help_text(s) + vim.api.nvim_set_current_win(s.result_win) + fire(s.result_buf, "differ: keymap help") + for _, w in ipairs(vim.api.nvim_list_wins()) do + if vim.api.nvim_win_get_config(w).relative ~= "" then + local lines = vim.api.nvim_buf_get_lines(vim.api.nvim_win_get_buf(w), 0, -1, false) + vim.api.nvim_win_close(w, true) + return table.concat(lines, "\n") + end + end + end + + it("opens a file theirs deleted, the theirs pane empty and labelled", function() + local root = conflict_repo_modify_delete({ "f.txt" }) + vim.cmd.edit(root .. "/f.txt") + local s = open_at(root, "a.txt") + assert.are.equal("theirs", s.deleted) + assert.are.same({ "OURS" }, vim.api.nvim_buf_get_lines(s.result_buf, 0, -1, false)) + local theirs = input(s, "theirs") + assert.are.equal("deleted in theirs", notice(vim.api.nvim_win_get_buf(theirs.win))) + assert.is_false(vim.wo[theirs.win].number) + assert.is_nil(notice(vim.api.nvim_win_get_buf(input(s, "ours").win))) + assert.are.same( + { "" }, + vim.api.nvim_buf_get_lines(vim.api.nvim_win_get_buf(theirs.win), 0, -1, false) + ) + vim.g.statusline_winid = theirs.win + assert.are.equal("THEIRS · deleted", merge.winbar()) + vim.g.statusline_winid = input(s, "ours").win + assert.are.equal("OURS (HEAD)", merge.winbar()) + vim.g.statusline_winid = s.result_win + assert.are.equal("RESULT · file 1/2 · deleted in theirs", plain(merge.winbar())) + end) + + it("labels the ours pane deleted when ours deleted the file", function() + local root = conflict_repo_modify_delete({ "f.txt" }, "ours") + vim.cmd.edit(root .. "/f.txt") + local s = open_at(root, "a.txt") + assert.are.equal("ours", s.deleted) + vim.g.statusline_winid = input(s, "ours").win + assert.are.equal("OURS (HEAD) · deleted", merge.winbar()) + vim.g.statusline_winid = input(s, "theirs").win + assert.are.equal("THEIRS", merge.winbar()) + end) + + it("shows the original in the diff4 base pane", function() + local root = conflict_repo_modify_delete({ "f.txt" }) + vim.cmd.edit(root .. "/f.txt") + merge.open({ path = root .. "/a.txt", layout = "diff4" }) + local base = input(assert(merge.current()), "base") + assert.are.same( + { "a" }, + vim.api.nvim_buf_get_lines(vim.api.nvim_win_get_buf(base.win), 0, -1, false) + ) + end) + + it("lists which take key keeps and which deletes in g?", function() + local root = conflict_repo_modify_delete({ "f.txt" }) + vim.cmd.edit(root .. "/f.txt") + local txt = help_text(open_at(root, "a.txt")) + assert.is_truthy(txt:find("co%s+keep the file")) + assert.is_truthy(txt:find("ct%s+delete the file")) + assert.is_nil(txt:find("take base", 1, true)) + end) + + it("steps onto a modify/delete file with ]f", function() + local root = conflict_repo_modify_delete({ "f.txt" }) + vim.cmd.edit(root .. "/f.txt") + local s = open_at(root, "f.txt") + fire(s.result_buf, "differ: next conflicted file") + assert.are.equal("a.txt", merge.current().path) + end) + + -- run `fn` with vim.fn.confirm answering `choice` (1 yes, 2 no), returning the prompt + local function confirming(choice, fn) + local orig, prompt = vim.fn.confirm, nil + vim.fn.confirm = function(msg) + prompt = msg + return choice + end + local ok, err = pcall(fn) + vim.fn.confirm = orig + assert(ok, err) + return prompt + end + + local function status(root, path) + return vim.trim(git_ok(root, "status", "--porcelain", "--", path)) + end + + it("keeps the file on taking the side that modified it, staying put", function() + local root = conflict_repo_modify_delete({ "f.txt" }) + vim.cmd.edit(root .. "/f.txt") + local s = open_at(root, "a.txt") + _G.notifs = {} + fire(s.result_buf, "differ: take ours") + assert.are.equal(s, merge.current()) + assert.are.equal("", status(root, "a.txt")) -- ours is HEAD's version + assert.are.equal("OURS", vim.fn.readfile(root .. "/a.txt")[1]) + assert.is_true(s.staged) + vim.g.statusline_winid = s.result_win + assert.are.equal("RESULT · file 1/2 · staged · 1 file left", plain(merge.winbar())) + end) + + it("keeps a delete/modify file on taking theirs", function() + local root = conflict_repo_modify_delete({ "f.txt" }, "ours") + vim.cmd.edit(root .. "/f.txt") + local s = open_at(root, "a.txt") + fire(s.result_buf, "differ: take theirs") + assert.are.equal("A a.txt", status(root, "a.txt")) + assert.are.equal("THEIRS", vim.fn.readfile(root .. "/a.txt")[1]) + end) + + it("deletes the file on taking the deleting side, once confirmed", function() + local root = conflict_repo_modify_delete({ "f.txt" }) + vim.cmd.edit(root .. "/f.txt") + local s = open_at(root, "a.txt") + local real = s.result_buf + local prompt = confirming(1, function() + fire(s.result_buf, "differ: take theirs") + end) + assert.are.equal("Delete a.txt?", prompt) + assert.are.equal(s, merge.current()) + assert.are.equal("D a.txt", status(root, "a.txt")) + assert.are.equal(0, vim.fn.filereadable(root .. "/a.txt")) + -- the result window holds an empty scratch buffer, the real one is unloaded + assert.are_not.equal(real, s.result_buf) + assert.are.equal(s.result_buf, vim.api.nvim_win_get_buf(s.result_win)) + assert.is_false(vim.api.nvim_buf_is_loaded(real)) + assert.are.equal("a.txt deletion staged", notice(s.result_buf)) + assert.is_false(vim.wo[s.result_win].number) + vim.g.statusline_winid = s.result_win + assert.are.equal("RESULT · file 1/2 · deleted · 1 file left", plain(merge.winbar())) + end) + + it("deletes a delete/modify file on taking ours", function() + local root = conflict_repo_modify_delete({ "f.txt" }, "ours") + vim.cmd.edit(root .. "/f.txt") + local s = open_at(root, "a.txt") + confirming(1, function() + fire(s.result_buf, "differ: take ours") + end) + assert.are.equal("", status(root, "a.txt")) + assert.are.equal(0, vim.fn.filereadable(root .. "/a.txt")) + end) + + it("says there's nothing to write on a :w after the delete", function() + local root = conflict_repo_modify_delete({ "f.txt" }) + vim.cmd.edit(root .. "/f.txt") + local s = open_at(root, "a.txt") + confirming(1, function() + fire(s.result_buf, "differ: take theirs") + end) + vim.api.nvim_set_current_win(s.result_win) + _G.notifs = {} + vim.cmd("write") + assert.are.equal("differ: a.txt deletion staged: nothing to write", _G.notifs[1].msg) + assert.are.equal(0, vim.fn.filereadable(root .. "/a.txt")) + end) + + it("leaves the file alone when the delete isn't confirmed", function() + local root = conflict_repo_modify_delete({ "f.txt" }) + vim.cmd.edit(root .. "/f.txt") + local s = open_at(root, "a.txt") + confirming(2, function() + fire(s.result_buf, "differ: take theirs") + end) + assert.are.equal("UD a.txt", status(root, "a.txt")) + assert.is_false(s.staged) + end) + + it("refuses to delete over unsaved edits", function() + local root = conflict_repo_modify_delete({ "f.txt" }) + vim.cmd.edit(root .. "/f.txt") + local s = open_at(root, "a.txt") + vim.api.nvim_buf_set_lines(s.result_buf, 0, -1, false, { "edited" }) + _G.notifs = {} + local prompt = confirming(1, function() + fire(s.result_buf, "differ: take theirs") + end) + assert.is_nil(prompt) + assert.are.equal("UD a.txt", status(root, "a.txt")) + assert.are.equal("differ: unsaved changes: drop them with :e! to delete", _G.notifs[1].msg) + end) + + it("stays open after a delete while the git-dir watcher catches up", function() + local root = conflict_repo_modify_delete({ "f.txt" }) + vim.cmd.edit(root .. "/f.txt") + local s = open_at(root, "a.txt") + confirming(1, function() + fire(s.result_buf, "differ: take theirs") + end) + vim.wait(300, function() + return false + end) + assert.are.equal(s, merge.current()) + end) + + it("says base, both and none don't apply", function() + local root = conflict_repo_modify_delete({ "f.txt" }) + vim.cmd.edit(root .. "/f.txt") + local s = open_at(root, "a.txt") + for _, desc in ipairs({ + "differ: take base", + "differ: take both", + "differ: drop the conflict", + }) do + _G.notifs = {} + fire(s.result_buf, desc) + assert.are.equal( + "differ: a modify/delete conflict is kept or deleted: take ours or theirs", + _G.notifs[1].msg + ) + end + assert.are.equal("UD a.txt", status(root, "a.txt")) + end) + + it("steps past an untouched modify/delete file without keeping it", function() + local root = conflict_repo_modify_delete({ "f.txt" }) + vim.cmd.edit(root .. "/f.txt") + local s = open_at(root, "a.txt") + fire(s.result_buf, "differ: next conflicted file") + assert.are.equal("f.txt", merge.current().path) + assert.are.equal("UD a.txt", status(root, "a.txt")) + end) + + it("keeps an edited modify/delete file when stepping past it", function() + local root = conflict_repo_modify_delete({ "f.txt" }) + vim.cmd.edit(root .. "/f.txt") + local s = open_at(root, "a.txt") + vim.api.nvim_buf_set_lines(s.result_buf, 0, -1, false, { "edited" }) + fire(s.result_buf, "differ: next conflicted file") + assert.are.equal("f.txt", merge.current().path) + assert.are.equal("M a.txt", status(root, "a.txt")) + end) + + it("steps on from a deleted file with ]f", function() + local root = conflict_repo_modify_delete({ "f.txt" }) + vim.cmd.edit(root .. "/f.txt") + local s = open_at(root, "a.txt") + confirming(1, function() + fire(s.result_buf, "differ: take theirs") + end) + fire(s.result_buf, "differ: next conflicted file") + assert.are.equal("f.txt", merge.current().path) + end) + + it("fires the done notify when a delete resolves the last conflict", function() + local root = conflict_repo_modify_delete({ "f.txt" }) + vim.cmd.edit(root .. "/f.txt") + local s = open_at(root, "f.txt") + fire(s.result_buf, "differ: take ours") + vim.api.nvim_set_current_win(s.result_win) + vim.cmd("silent write") + fire(s.result_buf, "differ: next conflicted file") + local a = merge.current() + assert.are.equal("a.txt", a.path) + _G.notifs = {} + confirming(1, function() + fire(a.result_buf, "differ: take theirs") + end) + assert.are.equal( + "differ: all conflicts resolved: git merge --continue", + _G.notifs[#_G.notifs].msg + ) + vim.g.statusline_winid = a.result_win + assert.are.equal( + "RESULT · file 1/2 · deleted · all conflicts resolved", + plain(merge.winbar()) + ) + end) + + it("fires the done notify when a keep resolves the last conflict", function() + local root = conflict_repo_modify_delete({ "f.txt" }) + vim.cmd.edit(root .. "/f.txt") + local s = open_at(root, "f.txt") + fire(s.result_buf, "differ: take ours") + vim.api.nvim_set_current_win(s.result_win) + vim.cmd("silent write") + fire(s.result_buf, "differ: next conflicted file") + local a = merge.current() + _G.notifs = {} + fire(a.result_buf, "differ: take ours") + assert.are.equal( + "differ: all conflicts resolved: git merge --continue", + _G.notifs[#_G.notifs].msg + ) + end) + + it("says a resolved modify/delete file is already resolved", function() + local root = conflict_repo_modify_delete({ "f.txt" }) + vim.cmd.edit(root .. "/f.txt") + local s = open_at(root, "a.txt") + fire(s.result_buf, "differ: take ours") + _G.notifs = {} + fire(s.result_buf, "differ: take theirs") + assert.are.equal("differ: a.txt is already resolved", _G.notifs[1].msg) + assert.are.equal(1, vim.fn.filereadable(root .. "/a.txt")) + end) +end) diff --git a/test/unit/merge_model_spec.lua b/test/unit/merge_model_spec.lua index 0fd2350..1e7d957 100644 --- a/test/unit/merge_model_spec.lua +++ b/test/unit/merge_model_spec.lua @@ -110,8 +110,11 @@ local function fake_git(opts) merge_file_diff3 = function() return opts.diff3 end, - -- stage presence, only consulted when the stage read back empty; present by default - has_stage = function() + -- stage presence: present by default, `false` for none, or a per-stage table + has_stage = function(_, _, stage) + if type(opts.has_stage) == "table" then + return opts.has_stage[stage] == true + end return opts.has_stage ~= false end, } @@ -151,13 +154,52 @@ describe("merge.model.build", function() assert.is_string(err) end) - it("reads an absent stage as empty (modify/delete conflict)", function() + it("reads an absent stage as empty", function() package.loaded["differ.git"] = fake_git({ stages = { [2] = "ours\n" } }) local m = model.build("/repo", "a.txt", nil) assert.are.equal("ours\n", m.ours_text) assert.are.equal("", m.base_text) assert.are.equal("", m.theirs_text) end) + + it("builds a modify/delete file with no markers, naming the deleting side", function() + package.loaded["differ.git"] = fake_git({ + worktree = "kept\n", + stages = { [1] = "base\n", [2] = "kept\n" }, + has_stage = { [1] = true, [2] = true }, + }) + local m, err = model.build("/repo", "a.txt", nil) + assert.is_nil(err) + assert.are.equal("theirs", m.deleted) + assert.are.same({}, m.regions) + assert.are.equal("", m.theirs_text) + assert.is_false(m.no_ancestor) + end) + + it("names ours as the deleting side of a delete/modify file", function() + package.loaded["differ.git"] = fake_git({ + worktree = "kept\n", + stages = { [1] = "base\n", [3] = "kept\n" }, + has_stage = { [1] = true, [3] = true }, + }) + assert.are.equal("ours", model.build("/repo", "a.txt", nil).deleted) + end) + + it("refuses a file with no markers that one side added", function() + package.loaded["differ.git"] = fake_git({ + worktree = "added\n", + stages = { [2] = "added\n" }, + has_stage = { [2] = true }, + }) + local m, err = model.build("/repo", "a.txt", nil) + assert.is_nil(m) + assert.is_string(err) + end) + + it("leaves deleted unset on a content conflict", function() + package.loaded["differ.git"] = fake_git() + assert.is_nil(model.build("/repo", "a.txt", nil).deleted) + end) end) describe("merge.model base recovery", function() From 61c544b07da566c6825f6e16223454937f442e2f Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:20:52 +0100 Subject: [PATCH 07/25] feat: continue, skip or abort the in-progress operation with :Differ continue --- lua/differ/command.lua | 12 + lua/differ/git/init.lua | 8 +- lua/differ/git/operation.lua | 105 ++++++++ lua/differ/merge/continue.lua | 207 +++++++++++++++ lua/differ/merge/init.lua | 64 ++++- test/nvim/mergetool_spec.lua | 26 +- test/nvim/operation_spec.lua | 465 ++++++++++++++++++++++++++++++++++ test/unit/command_spec.lua | 6 + 8 files changed, 870 insertions(+), 23 deletions(-) create mode 100644 lua/differ/git/operation.lua create mode 100644 lua/differ/merge/continue.lua create mode 100644 test/nvim/operation_spec.lua diff --git a/lua/differ/command.lua b/lua/differ/command.lua index 5274521..c03b1d5 100644 --- a/lua/differ/command.lua +++ b/lua/differ/command.lua @@ -219,6 +219,12 @@ function M.mergetool(arg) require("differ.merge").open({ path = (arg ~= "" and arg) or nil }) end +-- :Differ continue: continue, skip or abort the in-progress merge, rebase, cherry-pick, +-- revert or am, picked from a list +function M.continue() + require("differ.merge.continue").pick() +end + -- :Differ log [arg]: file history. no arg or an arg naming a readable file → -- single-file history (that file, else the current buffer); `base` is the trunk -- shortcut → branch-range history of `...HEAD`; any other arg is a rev-range. @@ -311,6 +317,7 @@ local SUB = { edit = M.edit, log = M.log, mergetool = M.mergetool, + continue = M.continue, sidecar = M.sidecar, cache = M.cache, } @@ -428,6 +435,11 @@ function M.complete(arglead, cmdline) if idx <= 2 then pool = vim.tbl_keys(SUB) pool[#pool + 1] = "base" -- the trunk diff shortcut isn't a SUB handler + if not require("differ.merge.continue").in_progress() then + pool = vim.tbl_filter(function(c) + return c ~= "continue" + end, pool) + end elseif idx == 3 then pool = VALUES[parts[2]] or {} elseif parts[2] == "pr" then diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index abe7958..77b1c80 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -1077,7 +1077,7 @@ end -- the repo to operate on: the current file's repo if it's a real file, else cwd ---@return string|nil -local function repo_root() +function M.repo_root() local file = vim.api.nvim_buf_get_name(0) local anchor = (file ~= "" and vim.fn.filereadable(file) == 1) and file or vim.fn.getcwd() return M.root(anchor) @@ -1118,7 +1118,7 @@ end -- the base ref the caller suffixes (`...HEAD` for log, `...` for diff), or nil ---@return string|nil function M.resolve_base() - local root = repo_root() + local root = M.repo_root() if not root then return notify("not inside a git repository", vim.log.levels.WARN) end @@ -1188,7 +1188,7 @@ function M.panel(opts) local origin_file = vim.api.nvim_buf_get_name(0) local origin_line, origin_col = unpack(vim.api.nvim_win_get_cursor(0)) - local root = repo_root() + local root = M.repo_root() if not root then return notify("not inside a git repository", vim.log.levels.WARN) end @@ -1974,7 +1974,7 @@ function M.range_history(opts) if not range or range == "" then return notify("range history needs a rev-range (e.g. main...HEAD)", vim.log.levels.WARN) end - local root = repo_root() + local root = M.repo_root() if not root then return notify("not inside a git repository", vim.log.levels.WARN) end diff --git a/lua/differ/git/operation.lua b/lua/differ/git/operation.lua new file mode 100644 index 0000000..e1aee7b --- /dev/null +++ b/lua/differ/git/operation.lua @@ -0,0 +1,105 @@ +-- continue, skip or abort the in-progress operation (`git.operation`) in the background. +-- git never gets a terminal here, so it never gets a real editor either: a plain run +-- commits the message git prepared, an edited one hands git the user's text + +local exec = require("differ.git.exec") + +local M = {} + +-- operations with a --skip +---@type table +M.SKIPPABLE = { rebase = true, am = true, ["cherry-pick"] = true, revert = true } + +-- the message the operation's next commit starts from, or nil when its --continue never +-- opens an editor (am, an apply-backend rebase) +---@param root string +---@param op string +---@return string|nil +function M.prepared_message(root, op) + local dir = require("differ.git").git_dir(root) + if not dir or op == "am" then + return nil + end + local path = dir .. "/MERGE_MSG" + if op == "rebase" then + path = dir .. "/rebase-merge/message" + end + local fd = io.open(path, "rb") + if not fd then + return nil + end + local text = fd:read("*a") + fd:close() + return text +end + +-- spawn git with `editor` as its editor, off the main loop; `on_exit` gets whether it +-- exited 0 and what it printed. detached: with no controlling terminal a prompt (a curses +-- pinentry) fails rather than drawing over nvim +---@param root string +---@param args string[] +---@param editor string +---@param on_exit fun(ok: boolean, output: string) +local function spawn(root, args, editor, on_exit) + local cmd = vim.list_extend(vim.list_extend({}, exec.GIT), args) + local function done(res) + local output = vim.trim((res.stderr or "") .. "\n" .. (res.stdout or "")) + vim.schedule(function() + on_exit(res.code == 0, output) + end) + end + local ok, err = pcall(vim.system, cmd, { + cwd = root, + text = true, + detach = true, + env = { GIT_EDITOR = editor }, + }, done) + if not ok then + done({ code = -1, stderr = tostring(err) }) + end +end + +-- cherry-pick and revert keep the message their sequencer saved (`--no-edit` sticks past +-- `--continue --edit`), so commit by hand, then let a multi-commit run go on +---@param root string +---@param op string +---@param msgfile string +---@param on_exit fun(ok: boolean, output: string) +local function commit_then_continue(root, op, msgfile, on_exit) + spawn(root, { "commit", "-q", "--cleanup=strip", "-F", msgfile }, "true", function(ok, output) + os.remove(msgfile) + local dir = require("differ.git").git_dir(root) + if not ok or not (dir and vim.uv.fs_stat(dir .. "/sequencer")) then + return on_exit(ok, output) + end + spawn(root, { op, "--continue" }, "true", on_exit) + end) +end + +-- run `git --`, never opening an editor. a continue commits git's prepared +-- message, or `opts.message` in its place +---@param root string +---@param op "merge"|"rebase"|"am"|"cherry-pick"|"revert" +---@param action "continue"|"skip"|"abort" +---@param opts { message?: string }|nil +---@param on_exit fun(ok: boolean, output: string) +function M.run(root, op, action, opts, on_exit) + local message = opts and opts.message + if not (message and action == "continue") then + return spawn(root, { op, "--" .. action }, "true", on_exit) + end + local msgfile = vim.fn.tempname() + local fd = assert(io.open(msgfile, "wb")) + fd:write(message) + fd:close() + if op == "cherry-pick" or op == "revert" then + return commit_then_continue(root, op, msgfile, on_exit) + end + -- git runs the editor through the shell with the file to edit appended + spawn(root, { op, "--continue" }, "cp " .. vim.fn.shellescape(msgfile), function(ok, output) + os.remove(msgfile) + on_exit(ok, output) + end) +end + +return M diff --git a/lua/differ/merge/continue.lua b/lua/differ/merge/continue.lua new file mode 100644 index 0000000..ba01ffe --- /dev/null +++ b/lua/differ/merge/continue.lua @@ -0,0 +1,207 @@ +-- :Differ continue: a picker over the in-progress operation's continue, skip and abort, +-- and the scratch buffer an edited commit message is written from + +local exec = require("differ.git.exec") +local git_op = require("differ.git.operation") + +local M = {} + +local notify = exec.notify + +---@param n integer +---@param noun string +---@return string +local function plural(n, noun) + if n == 1 then + return "1 " .. noun + end + return ("%d %ss"):format(n, noun) +end + +-- the repo the command acts on: a merge session's, else the current file's or cwd's +---@return string|nil +local function target_root() + local s = require("differ.merge").current() + if s then + return s.root + end + return require("differ.git").repo_root() +end + +-- say how the run left the repo +---@param root string +---@param op string +---@param action string +---@param ok boolean +---@param output string +local function report(root, op, action, ok, output) + local git = require("differ.git") + local now = git.operation(root) + if ok and not now then + if action == "abort" then + return notify(op .. " aborted") + end + return notify(op .. " finished") + end + local conflicted = git.conflicted(root) + if now and #conflicted > 0 then + return notify(("%s stopped on %s"):format(op, plural(#conflicted, "conflicted file"))) + end + if ok then + return notify(op .. " continued") + end + notify( + exec.with_stderr(("git %s --%s failed"):format(op, action), output), + vim.log.levels.ERROR + ) +end + +---@param root string +---@param op string +---@param action "continue"|"skip"|"abort" +---@param opts { message?: string }|nil +local function run(root, op, action, opts) + local merge = require("differ.merge") + merge.hold() + git_op.run(root, op, action, opts, function(ok, output) + report(root, op, action, ok, output) + merge.after_operation(root) + end) +end + +-- a continue with files still conflicted only gets git's refusal, so refuse here. the +-- merge tool's open result is saved and staged first +---@param root string +---@return boolean +local function resolved(root) + if not require("differ.merge").settle() then + return false + end + local left = #require("differ.git").conflicted(root) + if left == 0 then + return true + end + notify( + ("%s left: resolve them before continuing"):format(plural(left, "conflicted file")), + vim.log.levels.WARN + ) + return false +end + +-- a scratch buffer holding the prepared message; :w continues with what it holds, closing +-- it unwritten cancels +---@param root string +---@param op string +---@param text string +local function edit_message(root, op, text) + local name = ("differ://%s-message"):format(op) + local existing = vim.fn.bufnr(name) + if existing ~= -1 then + local wins = vim.fn.win_findbuf(existing) + if #wins > 0 then + return vim.api.nvim_set_current_win(wins[1]) + end + vim.api.nvim_buf_delete(existing, { force = true }) + end + vim.cmd("botright 12new") + local buf = vim.api.nvim_get_current_buf() + vim.bo[buf].buftype = "acwrite" + vim.bo[buf].bufhidden = "wipe" + vim.bo[buf].swapfile = false + vim.api.nvim_buf_set_name(buf, name) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, vim.split(vim.trim(text), "\n")) + vim.bo[buf].modified = false + vim.bo[buf].filetype = "gitcommit" + vim.wo.winbar = ("%s message · :w continues, close to cancel"):format(op) + vim.api.nvim_create_autocmd("BufWriteCmd", { + buffer = buf, + callback = function() + local message = table.concat(vim.api.nvim_buf_get_lines(buf, 0, -1, false), "\n") + vim.bo[buf].modified = false + vim.schedule(function() + pcall(vim.api.nvim_buf_delete, buf, { force = true }) + end) + if resolved(root) then + run(root, op, "continue", { message = message .. "\n" }) + end + end, + }) +end + +---@class differ.merge.ContinueEntry +---@field label string +---@field run fun() + +---@param root string +---@param op "merge"|"rebase"|"am"|"cherry-pick"|"revert" +---@return differ.merge.ContinueEntry[] +local function entries(root, op) + local list = { + { + label = "continue", + run = function() + if resolved(root) then + run(root, op, "continue") + end + end, + }, + } + local message = git_op.prepared_message(root, op) + if message then + list[#list + 1] = { + label = "continue and edit the message", + run = function() + if resolved(root) then + edit_message(root, op, message) + end + end, + } + end + if git_op.SKIPPABLE[op] then + list[#list + 1] = { + label = "skip", + run = function() + run(root, op, "skip") + end, + } + end + list[#list + 1] = { + label = "abort", + run = function() + local prompt = ("Abort the %s? Resolutions so far are lost."):format(op) + if vim.fn.confirm(prompt, "&Yes\n&No", 2) == 1 then + run(root, op, "abort") + end + end, + } + list[#list + 1] = { label = "cancel", run = function() end } + return list +end + +-- whether the current repo has an operation the picker can act on +---@return boolean +function M.in_progress() + local root = target_root() + return root ~= nil and require("differ.git").operation(root) ~= nil +end + +-- the picker, continue first +function M.pick() + local root = target_root() + local op = root and require("differ.git").operation(root) + if not root or not op then + return notify("no merge, rebase, cherry-pick, revert or am in progress") + end + vim.ui.select(entries(root, op), { + prompt = op .. " options", + format_item = function(entry) + return entry.label + end, + }, function(choice) + if choice then + choice.run() + end + end) +end + +return M diff --git a/lua/differ/merge/init.lua b/lua/differ/merge/init.lua index d7f234a..3d7aea3 100644 --- a/lua/differ/merge/init.lua +++ b/lua/differ/merge/init.lua @@ -74,6 +74,7 @@ local INPUT_HL = { ---@field head string|nil -- HEAD at session start; a move means the operation went on ---@field operation string|nil -- the in-progress operation at session start ---@field watcher differ.git.Watcher|nil -- the git dir watch for changes made outside the session +---@field running boolean|nil -- :Differ continue is running git; its end decides, not the watcher ---@field result_win integer ---@field result_buf integer -- the real worktree file (editable) ---@field bufs integer[] -- the scratch input buffers (deleted on close) @@ -724,6 +725,9 @@ local function result_winbar(s) end if remaining == 0 and s.staged then local done = tint(s.removed and "deleted" or "staged", "differMergeStaged") + if s.left == 0 and s.operation then + return ("%s · %s · all conflicts resolved · :Differ continue"):format(file, done) + end if s.left == 0 then return ("%s · %s · all conflicts resolved"):format(file, done) end @@ -927,15 +931,14 @@ local function report_unopenable(relpaths) ) end --- with no conflict left to open here, report done (naming the in-progress operation's --- `--continue` when there is one), or name the conflicts left for git add/rm +-- with no conflict left to open here, report done (pointing at :Differ continue when an +-- operation is in progress), or name the conflicts left for git add/rm ---@param root string ---@param conflicted string[] local function report_progress(root, conflicted) if #conflicted == 0 then - local op = require("differ.git").operation(root) - if op then - notify(("all conflicts resolved: git %s --continue"):format(op)) + if require("differ.git").operation(root) then + notify("all conflicts resolved: :Differ continue") else notify("all conflicts resolved and staged") end @@ -1061,6 +1064,9 @@ local function step_file(direction) local others = vim.tbl_filter(function(p) return p ~= s.path end, conflicted) + if #conflicted == 0 and s.operation then + return notify("no conflicted files left: :Differ continue") + end if #conflicted == 0 then return notify("no conflicted files left") end @@ -1204,7 +1210,7 @@ end -- the git dir changed: refresh the files left, and close once the session is stale ---@param this differ.MergeSession local function on_git_change(this) - if session ~= this then + if session ~= this or this.running then return end local conflicted = require("differ.git").conflicted(this.root) @@ -1503,6 +1509,52 @@ function lay_out(root, relpath, model, layout, files) on_cursor_moved() end +-- before :Differ continue runs git: save and stage the open result. false when the save +-- failed or a marker-free result stays unstaged +---@return boolean +function M.settle() + if not session then + return true + end + return settle_result() +end + +-- hold the session open while differ runs git, so the git-dir watcher doesn't close it +function M.hold() + if session then + session.running = true + end +end + +-- once differ's own continue, skip or abort ends: open the first conflict it stopped on, +-- else close the session when the operation is over, else keep it with git's new HEAD +---@param root string +function M.after_operation(root) + local git = require("differ.git") + local s = session + if s then + s.running = false + s.head, s.operation = git.head_sha(root), git.operation(root) + end + if not git.operation(root) then + return M.close() + end + local conflicted = git.conflicted(root) + if #conflicted == 0 then + return + end + local build = require("differ.merge.model").build + for _, relpath in ipairs(conflicted) do + local model = build(root, relpath, nil) + if model then + local layout = s and s.layout or require("differ").get_config().merge.layout + return lay_out(root, relpath, model, layout or "default", conflicted) + end + end + report_unopenable(conflicted) + M.close() +end + -- resolve root + the target relpath, then build + open. with no path the current file is -- used when it's conflicted, else the sole conflicted file, else a picker over them. an -- explicit opts.layout wins over the configured merge.layout diff --git a/test/nvim/mergetool_spec.lua b/test/nvim/mergetool_spec.lua index b1d6ef7..af99c41 100644 --- a/test/nvim/mergetool_spec.lua +++ b/test/nvim/mergetool_spec.lua @@ -643,7 +643,7 @@ describe(":Differ mergetool resolution", function() vim.cmd("silent write") assert.are.equal(s, merge.current()) assert.are.equal( - "differ: all conflicts resolved: git merge --continue", + "differ: all conflicts resolved: :Differ continue", _G.notifs[#_G.notifs].msg ) assert.are.same({}, require("differ.git").conflicted(root)) @@ -665,7 +665,7 @@ describe(":Differ mergetool resolution", function() last.msg ) for _, n in ipairs(_G.notifs) do - assert.is_nil(n.msg:find("--continue", 1, true)) + assert.is_nil(n.msg:find(":Differ continue", 1, true)) end end) @@ -800,15 +800,12 @@ describe(":Differ mergetool in-progress operation", function() return messages end - it("names the operation's --continue once the last file is written", function() + it("points at :Differ continue once the last file is written", function() local messages = resolve_last("rebase") - assert.are.equal( - "differ: all conflicts resolved: git rebase --continue", - messages[#messages] - ) + assert.are.equal("differ: all conflicts resolved: :Differ continue", messages[#messages]) end) - it("reports plain done when no operation has a --continue", function() + it("reports plain done when no operation is in progress", function() local messages = resolve_last("stash") assert.are.equal("differ: all conflicts resolved and staged", messages[#messages]) end) @@ -915,7 +912,10 @@ describe(":Differ mergetool file stepping", function() _G.notifs = {} fire(s.result_buf, "differ: next conflicted file") assert.are.equal(s, merge.current()) - assert.are.equal("differ: no conflicted files left", _G.notifs[#_G.notifs].msg) + assert.are.equal( + "differ: no conflicted files left: :Differ continue", + _G.notifs[#_G.notifs].msg + ) end) it("shows file progress in the result winbar", function() @@ -939,7 +939,7 @@ describe(":Differ mergetool file stepping", function() vim.api.nvim_set_current_win(g.result_win) vim.cmd("silent write") assert.are.equal( - "RESULT · file 2/2 · staged · all conflicts resolved", + "RESULT · file 2/2 · staged · all conflicts resolved · :Differ continue", plain(merge.winbar()) ) end) @@ -2181,12 +2181,12 @@ describe(":Differ mergetool modify/delete", function() fire(a.result_buf, "differ: take theirs") end) assert.are.equal( - "differ: all conflicts resolved: git merge --continue", + "differ: all conflicts resolved: :Differ continue", _G.notifs[#_G.notifs].msg ) vim.g.statusline_winid = a.result_win assert.are.equal( - "RESULT · file 1/2 · deleted · all conflicts resolved", + "RESULT · file 1/2 · deleted · all conflicts resolved · :Differ continue", plain(merge.winbar()) ) end) @@ -2203,7 +2203,7 @@ describe(":Differ mergetool modify/delete", function() _G.notifs = {} fire(a.result_buf, "differ: take ours") assert.are.equal( - "differ: all conflicts resolved: git merge --continue", + "differ: all conflicts resolved: :Differ continue", _G.notifs[#_G.notifs].msg ) end) diff --git a/test/nvim/operation_spec.lua b/test/nvim/operation_spec.lua new file mode 100644 index 0000000..c13e558 --- /dev/null +++ b/test/nvim/operation_spec.lua @@ -0,0 +1,465 @@ +-- runs under headless nvim against throwaway repos stopped mid-operation: continue, skip +-- and abort run without a terminal and never open an editor + +local git_src = require("differ.git") +local operation = require("differ.git.operation") +local merge = require("differ.merge") + +local function git(cwd, ...) + local args = { "git", "-c", "init.defaultBranch=main" } + vim.list_extend(args, { ... }) + return vim.system(args, { cwd = cwd, text = true }):wait() +end + +local function git_ok(cwd, ...) + local res = git(cwd, ...) + assert( + res.code == 0, + "git failed: " .. table.concat({ ... }, " ") .. "\n" .. (res.stderr or "") + ) + return res.stdout +end + +local function write(path, content) + local fd = assert(io.open(path, "wb")) + fd:write(content) + fd:close() +end + +-- a repo where `feature` changes f.txt then g.txt and `main` changes both the other way, +-- so replaying feature's two commits stops twice. identity is repo config, since the +-- runner's own commits can't take `-c` flags +local function diverged_repo() + local root = vim.fn.tempname() + vim.fn.mkdir(root, "p") + git_ok(root, "init", "-q") + git_ok(root, "config", "user.email", "t@t") + git_ok(root, "config", "user.name", "t") + git_ok(root, "config", "rerere.enabled", "false") + write(root .. "/f.txt", "a\n") + write(root .. "/g.txt", "a\n") + git_ok(root, "add", ".") + git_ok(root, "commit", "-q", "-m", "base") + git_ok(root, "checkout", "-q", "-b", "feature") + write(root .. "/f.txt", "feature\n") + git_ok(root, "commit", "-q", "-am", "feature f", "--author=f ") + write(root .. "/g.txt", "feature\n") + git_ok(root, "commit", "-q", "-am", "feature g", "--author=f ") + git_ok(root, "checkout", "-q", "main") + write(root .. "/f.txt", "main\n") + write(root .. "/g.txt", "main\n") + git_ok(root, "commit", "-q", "-am", "main") + return root +end + +-- stopped on the first conflict of `op`; revert undoes feature's two commits on top of a +-- third that rewrites both files +---@param op "merge"|"rebase"|"rebase-apply"|"am"|"cherry-pick"|"revert" +local function stopped(op) + local root = diverged_repo() + if op == "merge" then + git(root, "merge", "--no-edit", "feature") -- --no-edit isn't saved for the continue + elseif op == "rebase" then + git_ok(root, "checkout", "-q", "feature") + git(root, "rebase", "main") + elseif op == "rebase-apply" then + git_ok(root, "checkout", "-q", "feature") + git(root, "rebase", "--apply", "main") + elseif op == "am" then + local patch = root .. ".patch" + write(patch, git_ok(root, "format-patch", "-2", "--stdout", "feature")) + git(root, "am", "-3", patch) + elseif op == "cherry-pick" then + git(root, "cherry-pick", "feature~1", "feature") + elseif op == "revert" then + git_ok(root, "checkout", "-q", "feature") + write(root .. "/f.txt", "later\n") + write(root .. "/g.txt", "later\n") + git_ok(root, "commit", "-q", "-am", "later") + git(root, "revert", "--no-edit", "HEAD~2", "HEAD~1") + end + assert(#git_src.conflicted(root) > 0, op .. " didn't stop on a conflict") + return root +end + +local function resolve_all(root) + for _, path in ipairs(git_src.conflicted(root)) do + write(root .. "/" .. path, "resolved\n") + git_ok(root, "add", path) + end +end + +-- run the action and wait for its callback +local function run(root, action, opts) + local result + operation.run(root, assert(git_src.operation(root)), action, opts, function(ok, output) + result = { ok = ok, output = output } + end) + assert( + vim.wait(10000, function() + return result ~= nil + end), + "the run never finished" + ) + return result +end + +local function subject(root) + return vim.trim(git_ok(root, "log", "-1", "--format=%s")) +end + +describe("git operation runner", function() + local saved, marker + + -- an editor that leaves a trace, so a run that opened one shows + before_each(function() + marker = vim.fn.tempname() + local script = vim.fn.tempname() + write(script, ("#!/bin/sh\ntouch %s\n"):format(vim.fn.shellescape(marker))) + vim.fn.setfperm(script, "rwx------") + saved = { vim.env.GIT_EDITOR, vim.env.EDITOR, vim.env.VISUAL } + vim.env.GIT_EDITOR, vim.env.EDITOR, vim.env.VISUAL = script, script, script + end) + + after_each(function() + vim.env.GIT_EDITOR, vim.env.EDITOR, vim.env.VISUAL = saved[1], saved[2], saved[3] + assert.are.equal(0, vim.fn.filereadable(marker), "an editor was opened") + end) + + it("continues a merge with git's prepared message", function() + local root = stopped("merge") + resolve_all(root) + local res = run(root, "continue") + assert.is_true(res.ok, res.output) + assert.is_nil(git_src.operation(root)) + assert.are.equal("Merge branch 'feature'", subject(root)) + assert.are.equal(2, #vim.split(vim.trim(git_ok(root, "log", "-1", "--format=%p")), " ")) + end) + + for _, op in ipairs({ "rebase", "rebase-apply", "am", "cherry-pick", "revert" }) do + it(op .. " continues to its next conflict, then finishes", function() + local root = stopped(op) + resolve_all(root) + local res = run(root, "continue") + assert.is_false(res.ok) + assert.is_not_nil(git_src.operation(root)) + assert.are.same({ "g.txt" }, git_src.conflicted(root)) + resolve_all(root) + res = run(root, "continue") + assert.is_true(res.ok, res.output) + assert.is_nil(git_src.operation(root)) + end) + end + + for _, op in ipairs({ "merge", "rebase", "cherry-pick", "revert" }) do + it(op .. " continues with an edited message", function() + local root = stopped(op) + resolve_all(root) + assert.is_not_nil(operation.prepared_message(root, git_src.operation(root))) + run(root, "continue", { message = "edited subject\n\n# a comment\nbody\n" }) + -- the sequence ops stop again on g.txt, after committing the edited first one + assert.are.equal( + "edited subject\n\nbody", + vim.trim(git_ok(root, "log", "-1", "--format=%B")) + ) + if op ~= "merge" then + assert.are.same({ "g.txt" }, git_src.conflicted(root)) + end + end) + end + + it("keeps the picked commit's author on an edited cherry-pick", function() + local root = stopped("cherry-pick") + resolve_all(root) + run(root, "continue", { message = "edited\n" }) + assert.are.equal( + vim.trim(git_ok(root, "log", "-1", "--format=%ae", "feature~1")), + vim.trim(git_ok(root, "log", "-1", "--format=%ae")) + ) + end) + + it("has no prepared message for am or an apply-backend rebase", function() + assert.is_nil(operation.prepared_message(stopped("am"), "am")) + assert.is_nil(operation.prepared_message(stopped("rebase-apply"), "rebase")) + end) + + it("reports git's output when a continue fails", function() + local root = stopped("merge") + local res = run(root, "continue") + assert.is_false(res.ok) + assert.matches("conflict", res.output) + assert.is_not_nil(git_src.operation(root)) + end) + + it("skips a rebase commit", function() + local root = stopped("rebase") + local res = run(root, "skip") + assert.is_false(res.ok) -- the next commit stops too + assert.are.same({ "g.txt" }, git_src.conflicted(root)) + end) + + it("aborts a merge", function() + local root = stopped("merge") + local head = git_ok(root, "rev-parse", "main") + local res = run(root, "abort") + assert.is_true(res.ok, res.output) + assert.is_nil(git_src.operation(root)) + assert.are.equal(head, git_ok(root, "rev-parse", "HEAD")) + end) +end) + +describe(":Differ continue", function() + local command = require("differ.command") + local saved, notes, shown, chosen + + -- stub the picker, choosing the entry labelled `chosen` (nil chooses nothing) + before_each(function() + saved = { select = vim.ui.select, notify = vim.notify, confirm = vim.fn.confirm } + notes, shown, chosen = {}, nil, nil + vim.notify = function(msg, level) + notes[#notes + 1] = { msg = msg, level = level } + end + vim.ui.select = function(items, opts, on_choice) + shown = { prompt = opts.prompt, labels = vim.tbl_map(opts.format_item, items) } + for _, item in ipairs(items) do + if opts.format_item(item) == chosen then + return on_choice(item) + end + end + on_choice(nil) + end + end) + + after_each(function() + vim.ui.select, vim.notify, vim.fn.confirm = saved.select, saved.notify, saved.confirm + if merge.current() then + merge.close() + end + vim.cmd("silent! %bwipeout!") + end) + + -- the command acts on the current file's repo + local function in_repo(root) + vim.cmd.edit(vim.fn.fnameescape(root .. "/f.txt")) + end + + local function noted(pattern) + for _, n in ipairs(notes) do + if n.msg:find(pattern) then + return n + end + end + end + + local function finished(root) + assert( + vim.wait(10000, function() + return git_src.operation(root) == nil + end), + "the operation never finished" + ) + vim.wait(100) -- the scheduled report + end + + it("says there's nothing in progress", function() + in_repo(diverged_repo()) + command.dispatch({ "continue" }) + assert.is_nil(shown) + assert.is_not_nil(noted("no merge, rebase, cherry%-pick, revert or am in progress")) + end) + + local cases = { + { "merge", { "continue", "continue and edit the message", "abort", "cancel" } }, + { "rebase", { "continue", "continue and edit the message", "skip", "abort", "cancel" } }, + { "am", { "continue", "skip", "abort", "cancel" } }, + { + "cherry-pick", + { "continue", "continue and edit the message", "skip", "abort", "cancel" }, + }, + { "revert", { "continue", "continue and edit the message", "skip", "abort", "cancel" } }, + } + for _, case in ipairs(cases) do + it("offers " .. case[1] .. "'s options", function() + in_repo(stopped(case[1])) + command.dispatch({ "continue" }) + assert.are.equal(case[1] .. " options", shown.prompt) + assert.are.same(case[2], shown.labels) + end) + end + + it("completes only while an operation is in progress", function() + local root = stopped("merge") + in_repo(root) + assert.is_true(vim.tbl_contains(command.complete("", "Differ "), "continue")) + git_ok(root, "merge", "--abort") + assert.is_false(vim.tbl_contains(command.complete("", "Differ "), "continue")) + end) + + it("refuses to continue with files still conflicted", function() + local root = stopped("merge") + in_repo(root) + chosen = "continue" + command.dispatch({ "continue" }) + vim.wait(200) + assert.are.equal(vim.log.levels.WARN, noted("2 conflicted files left").level) + assert.is_not_nil(git_src.operation(root)) + end) + + it("continues a merge", function() + local root = stopped("merge") + resolve_all(root) + in_repo(root) + chosen = "continue" + command.dispatch({ "continue" }) + finished(root) + assert.is_not_nil(noted("merge finished")) + end) + + it("reports a rebase stopping again", function() + local root = stopped("rebase") + resolve_all(root) + in_repo(root) + chosen = "continue" + command.dispatch({ "continue" }) + assert(vim.wait(10000, function() + return noted("rebase stopped on 1 conflicted file") ~= nil + end)) + end) + + it("continues with the message written in the edit buffer", function() + local root = stopped("merge") + resolve_all(root) + in_repo(root) + chosen = "continue and edit the message" + command.dispatch({ "continue" }) + local buf = vim.api.nvim_get_current_buf() + assert.are.equal("gitcommit", vim.bo[buf].filetype) + assert.are.equal("Merge branch 'feature'", vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1]) + vim.api.nvim_buf_set_lines(buf, 0, 1, false, { "edited" }) + vim.cmd("write") + finished(root) + assert.are.equal("edited", subject(root)) + assert.is_false(vim.api.nvim_buf_is_valid(buf)) + end) + + it("leaves the operation alone when the edit buffer closes unwritten", function() + local root = stopped("merge") + resolve_all(root) + in_repo(root) + chosen = "continue and edit the message" + command.dispatch({ "continue" }) + vim.cmd("bwipeout!") + vim.wait(200) + assert.is_not_nil(git_src.operation(root)) + end) + + it("aborts once confirmed, and not otherwise", function() + local root = stopped("merge") + in_repo(root) + chosen = "abort" + vim.fn.confirm = function() + return 2 + end + command.dispatch({ "continue" }) + vim.wait(200) + assert.is_not_nil(git_src.operation(root)) + vim.fn.confirm = function() + return 1 + end + command.dispatch({ "continue" }) + finished(root) + assert.is_not_nil(noted("merge aborted")) + end) + + describe("from the merge tool", function() + -- a session on f.txt with its result resolved but unsaved + local function session_on(root) + in_repo(root) + merge.open({}) + local s = assert(merge.current()) + vim.api.nvim_buf_set_lines(s.result_buf, 0, -1, false, { "resolved" }) + return s + end + + local function outside_closes() + return noted("outside the merge tool") + end + + it("saves and stages the result, then closes once the merge finishes", function() + local root = stopped("merge") + session_on(root) + write(root .. "/g.txt", "resolved\n") + git_ok(root, "add", "g.txt") + chosen = "continue" + command.dispatch({ "continue" }) + finished(root) + assert.is_nil(merge.current()) + assert.is_not_nil(noted("merge finished")) + assert.is_nil(outside_closes()) + end) + + it("reopens on the conflict a rebase stops on next", function() + local root = stopped("rebase") + session_on(root) + chosen = "continue" + command.dispatch({ "continue" }) + assert( + vim.wait(10000, function() + local s = merge.current() + return s ~= nil and s.path == "g.txt" + end), + "never reopened on g.txt" + ) + vim.wait(300) -- a late watcher fire must not close the new session + assert.is_not_nil(merge.current()) + assert.is_nil(outside_closes()) + end) + + it("closes after an abort", function() + local root = stopped("rebase") + session_on(root) + chosen = "abort" + vim.fn.confirm = function() + return 1 + end + command.dispatch({ "continue" }) + finished(root) + assert.is_nil(merge.current()) + assert.is_nil(outside_closes()) + end) + + it("stays open with git's error when a hook refuses the commit", function() + local root = stopped("merge") + local hook = root .. "/.git/hooks/commit-msg" + write(hook, "#!/bin/sh\necho 'hook says no' >&2\nexit 1\n") + vim.fn.setfperm(hook, "rwx------") + local s = session_on(root) + write(root .. "/g.txt", "resolved\n") + git_ok(root, "add", "g.txt") + chosen = "continue" + command.dispatch({ "continue" }) + assert(vim.wait(10000, function() + return noted("hook says no") ~= nil + end)) + assert.are.equal(vim.log.levels.ERROR, noted("hook says no").level) + vim.wait(300) + assert.are.equal(s, merge.current()) + assert.is_not_nil(git_src.operation(root)) + end) + end) + + it("opens the merge tool on new conflicts when run outside it", function() + local root = stopped("rebase") + resolve_all(root) + in_repo(root) + chosen = "continue" + command.dispatch({ "continue" }) + assert( + vim.wait(10000, function() + local s = merge.current() + return s ~= nil and s.path == "g.txt" + end), + "the merge tool never opened on g.txt" + ) + end) +end) diff --git a/test/unit/command_spec.lua b/test/unit/command_spec.lua index fa961de..d49058b 100644 --- a/test/unit/command_spec.lua +++ b/test/unit/command_spec.lua @@ -222,6 +222,11 @@ describe("M.complete (pr tree)", function() before_each(function() package.loaded["differ.view"] = { current = function() end } package.loaded["differ.pr"] = FAKE_PR + package.loaded["differ.merge.continue"] = { + in_progress = function() + return false + end, + } package.loaded["differ.command"] = nil cmd = require("differ.command") end) @@ -230,6 +235,7 @@ describe("M.complete (pr tree)", function() package.loaded["differ.command"] = nil package.loaded["differ.pr"] = nil package.loaded["differ.view"] = nil + package.loaded["differ.merge.continue"] = nil end) it("offers the review actions after `pr review `", function() From 14926c7a6a3effcd59db93d4fc658dbf15df1de1 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:20:20 +0100 Subject: [PATCH 08/25] feat: run signed operations in a floating terminal so pinentry can prompt --- lua/differ/git/operation.lua | 57 +++++++++++++++++++++++++- lua/differ/merge/continue.lua | 7 ++-- test/nvim/operation_spec.lua | 76 +++++++++++++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 4 deletions(-) diff --git a/lua/differ/git/operation.lua b/lua/differ/git/operation.lua index e1aee7b..b4f830f 100644 --- a/lua/differ/git/operation.lua +++ b/lua/differ/git/operation.lua @@ -33,15 +33,70 @@ function M.prepared_message(root, op) return text end +---@param root string +---@return boolean +local function signing(root) + return vim.trim(exec.git({ "config", "--type=bool", "commit.gpgsign" }, root) or "") == "true" +end + +-- run `cmd` in a floating terminal so a signing prompt has a tty to draw on. gpg reads +-- the commit from a pipe, so GPG_TTY has to name the float's pty or pinentry gets none +---@param root string +---@param cmd string[] +---@param editor string +---@param on_exit fun(ok: boolean, output: string) +local function spawn_float(root, cmd, editor, on_exit) + local buf = vim.api.nvim_create_buf(false, true) + local width = math.floor(vim.o.columns * 0.8) + local height = math.floor(vim.o.lines * 0.6) + local win = vim.api.nvim_open_win(buf, true, { + relative = "editor", + width = width, + height = height, + row = math.floor((vim.o.lines - height) / 2), + col = math.floor((vim.o.columns - width) / 2), + style = "minimal", + border = "rounded", + title = " git " .. cmd[#exec.GIT + 1] .. " ", + }) + local argv = vim.list_extend({ "sh", "-c", 'GPG_TTY=$(tty) exec "$@"', "sh" }, cmd) + local job = vim.fn.jobstart(argv, { + term = true, + cwd = root, + env = { GIT_EDITOR = editor }, + on_exit = function(_, code) + vim.schedule(function() + local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + if vim.api.nvim_win_is_valid(win) then + vim.api.nvim_win_close(win, true) + end + pcall(vim.api.nvim_buf_delete, buf, { force = true }) + -- drop the terminal's "[Process exited n]" line + local text = vim.trim(table.concat(lines, "\n")) + local output = text:gsub("%s*%[Process exited %d+%]$", "") + on_exit(code == 0, output) + end) + end, + }) + if job <= 0 then + vim.api.nvim_win_close(win, true) + return on_exit(false, "couldn't start git in a terminal") + end + vim.cmd.startinsert() +end + -- spawn git with `editor` as its editor, off the main loop; `on_exit` gets whether it -- exited 0 and what it printed. detached: with no controlling terminal a prompt (a curses --- pinentry) fails rather than drawing over nvim +-- pinentry) fails rather than drawing over nvim. with signing on it runs in a float ---@param root string ---@param args string[] ---@param editor string ---@param on_exit fun(ok: boolean, output: string) local function spawn(root, args, editor, on_exit) local cmd = vim.list_extend(vim.list_extend({}, exec.GIT), args) + if signing(root) then + return spawn_float(root, cmd, editor, on_exit) + end local function done(res) local output = vim.trim((res.stderr or "") .. "\n" .. (res.stdout or "")) vim.schedule(function() diff --git a/lua/differ/merge/continue.lua b/lua/differ/merge/continue.lua index ba01ffe..fa19b2c 100644 --- a/lua/differ/merge/continue.lua +++ b/lua/differ/merge/continue.lua @@ -118,12 +118,13 @@ local function edit_message(root, op, text) callback = function() local message = table.concat(vim.api.nvim_buf_get_lines(buf, 0, -1, false), "\n") vim.bo[buf].modified = false + -- closing the message window moves focus, so run only once it's gone vim.schedule(function() pcall(vim.api.nvim_buf_delete, buf, { force = true }) + if resolved(root) then + run(root, op, "continue", { message = message .. "\n" }) + end end) - if resolved(root) then - run(root, op, "continue", { message = message .. "\n" }) - end end, }) end diff --git a/test/nvim/operation_spec.lua b/test/nvim/operation_spec.lua index c13e558..98b98d3 100644 --- a/test/nvim/operation_spec.lua +++ b/test/nvim/operation_spec.lua @@ -108,6 +108,29 @@ local function subject(root) return vim.trim(git_ok(root, "log", "-1", "--format=%s")) end +-- signing on, with a gpg.program that records whether GPG_TTY names a terminal, then +-- signs with junk. returns the record's path +local function signing(root) + local record = vim.fn.tempname() + local stub = vim.fn.tempname() + write( + stub, + table.concat({ + "#!/bin/sh", + "cat >/dev/null", + ('if [ -c "$GPG_TTY" ]; then echo tty; else echo none; fi > %s'):format( + vim.fn.shellescape(record) + ), + "printf '\\n[GNUPG:] SIG_CREATED D 22 8 00 0 0\\n' >&2", + "printf -- '-----BEGIN PGP SIGNATURE-----\\nstub\\n-----END PGP SIGNATURE-----\\n'", + }, "\n") + ) + vim.fn.setfperm(stub, "rwx------") + git_ok(root, "config", "commit.gpgsign", "true") + git_ok(root, "config", "gpg.program", stub) + return record +end + describe("git operation runner", function() local saved, marker @@ -206,6 +229,40 @@ describe("git operation runner", function() assert.is_nil(git_src.operation(root)) assert.are.equal(head, git_ok(root, "rev-parse", "HEAD")) end) + + describe("with signing on", function() + it("runs in a floating terminal that gpg can prompt on", function() + local root = stopped("merge") + resolve_all(root) + local record = signing(root) + local wins = #vim.api.nvim_list_wins() + local result + operation.run(root, "merge", "continue", nil, function(ok, output) + result = { ok = ok, output = output } + end) + assert.are.equal(wins + 1, #vim.api.nvim_list_wins()) + assert.are.equal("terminal", vim.bo.buftype) + assert( + vim.wait(10000, function() + return result ~= nil + end), + "the run never finished" + ) + assert.is_true(result.ok, result.output) + assert.are.equal(wins, #vim.api.nvim_list_wins()) + assert.are.same({ "tty" }, vim.fn.readfile(record)) + assert.matches("gpgsig", git_ok(root, "cat-file", "-p", "HEAD")) + end) + + it("hands back the float's output when git fails", function() + local root = stopped("merge") + signing(root) + local res = run(root, "continue") + assert.is_false(res.ok) + assert.matches("conflict", res.output) + assert.not_matches("Process exited", res.output) + end) + end) end) describe(":Differ continue", function() @@ -342,6 +399,25 @@ describe(":Differ continue", function() assert.is_false(vim.api.nvim_buf_is_valid(buf)) end) + it("leaves focus in the signing float after the edit buffer is written", function() + local root = stopped("merge") + resolve_all(root) + signing(root) + in_repo(root) + chosen = "continue and edit the message" + command.dispatch({ "continue" }) + local buf = vim.api.nvim_get_current_buf() + vim.cmd("write") + assert( + vim.wait(1000, function() + return vim.bo.buftype == "terminal" + end), + "focus never reached the float" + ) + assert.is_false(vim.api.nvim_buf_is_valid(buf)) + finished(root) + end) + it("leaves the operation alone when the edit buffer closes unwritten", function() local root = stopped("merge") resolve_all(root) From d25e9676f8169c2c70ed06e46dac0a0b28456c24 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:28:17 +0100 Subject: [PATCH 09/25] feat: name the in-progress operation in the local panel header --- RECIPES.md | 1 + doc/differ-recipes.txt | 5 ++ lua/differ/git/init.lua | 101 +++++++++++++++++++++++++++++++- lua/differ/panel/init.lua | 46 +++++++++++++-- lua/differ/panel/render.lua | 12 +++- lua/differ/ui/highlights.lua | 2 + test/nvim/operation_spec.lua | 74 +++++++++++++++++++++++ test/unit/panel_render_spec.lua | 16 +++++ 8 files changed, 250 insertions(+), 7 deletions(-) diff --git a/RECIPES.md b/RECIPES.md index c4b4750..bc0e216 100644 --- a/RECIPES.md +++ b/RECIPES.md @@ -86,6 +86,7 @@ Every group is defined with `default = true`, so a `:highlight` of your own wins | Group | Applies to | |---|---| | `differPanelHeader` / `differPanelRoot` / `differPanelHelp` | panel chrome (link `Title` / `Directory` / `Comment`) | +| `differPanelOperation` / `differPanelContinue` | the in-progress operation's `(merging)` verb and its `:Differ continue` line in the panel header (link `WarningMsg` / `Special`) | | `differPanelDir` / `differPanelContext` | a tree directory row, and the dimmed `·parent/` trailer in the name listing | | `differPanelAdd` / `differPanelModify` / `differPanelDelete` | the status glyph, by kind | | `differPanelRename` / `differPanelUnmerged` / `differPanelUntracked` | the status glyph, by kind | diff --git a/doc/differ-recipes.txt b/doc/differ-recipes.txt index b623f95..4535cbd 100644 --- a/doc/differ-recipes.txt +++ b/doc/differ-recipes.txt @@ -154,6 +154,11 @@ FILE PANEL *differ-recipes-highlight-groups-file-panel* differPanelHeader / differPanelRoot panel chrome (link Title / / differPanelHelp Directory / Comment) + differPanelOperation / the in-progress operation’s + differPanelContinue (merging) verb and its + :Differ continue line in the panel + header (link WarningMsg / Special) + differPanelDir / differPanelContext a tree directory row, and the dimmed ·parent/ trailer in the name listing diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 77b1c80..6817b5d 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -560,6 +560,100 @@ local function head_branch(root) return (name and name ~= "HEAD") and name or nil end +-- name-rev walks history, which takes hundreds of ms in a large repo, and the panel asks +-- on every refresh while the named commit only changes once per operation step +local commit_names, commit_names_cached = {}, 0 +local COMMIT_NAMES_MAX = 64 + +-- a commit as `git name-rev` names it (`topic`, `main~1`), else its short sha +---@param root string +---@param sha string +---@return string +local function commit_name(root, sha) + local key = root .. "\0" .. sha + if commit_names[key] then + return commit_names[key] + end + local out = git({ "name-rev", "--name-only", "--no-undefined", sha }, root) + local name = out and chomp(out) or sha:sub(1, 7) + if commit_names_cached >= COMMIT_NAMES_MAX then + commit_names, commit_names_cached = {}, 0 + end + commit_names[key], commit_names_cached = name, commit_names_cached + 1 + return name +end + +-- the first line of a git-dir state file, or nil when it isn't there +---@param dir string +---@param file string +---@return string|nil +local function state_line(dir, file) + local data = read_file(dir .. "/" .. file) + return data and data:match("^[^\n]*") or nil +end + +---@param root string +---@param dir string +---@return string +local function rebase_detail(root, dir) + local state = vim.uv.fs_stat(dir .. "/rebase-merge") and "rebase-merge" or "rebase-apply" + local detail = "" + local branch = state_line(dir, state .. "/head-name") + if branch then + detail = " " .. branch:gsub("^refs/heads/", "") + end + local onto = state_line(dir, state .. "/onto") + if onto then + detail = detail .. " onto " .. commit_name(root, onto) + end + return detail +end + +local OPERATION_HEADS = { + merge = { "MERGE_HEAD", "merging" }, + ["cherry-pick"] = { "CHERRY_PICK_HEAD", "cherry-picking" }, + revert = { "REVERT_HEAD", "reverting" }, +} + +---@param root string +---@param dir string +---@param op "merge"|"rebase"|"am"|"cherry-pick"|"revert" +---@return string verb, string detail +local function operation_parts(root, dir, op) + if op == "rebase" then + return "rebasing", rebase_detail(root, dir) + end + local target = head_branch(root) + local into = target and (" → " .. target) or "" + if op == "am" then + local at, last = state_line(dir, "rebase-apply/next"), state_line(dir, "rebase-apply/last") + if not (at and last) then + return "applying patches", into + end + return ("applying patch %s/%s"):format(at, last), into + end + local file, verb = unpack(OPERATION_HEADS[op]) + local sha = state_line(dir, file) + if not sha then + return verb, into + end + return verb, " " .. commit_name(root, sha) .. into +end + +-- the in-progress operation in lazygit's form (`(merging) topic → main`, `(rebasing) topic +-- onto main`), or nil when there's none +---@param root string +---@return string|nil +function M.operation_label(root) + local op = M.operation(root) + local dir = op and M.git_dir(root) + if not (op and dir) then + return nil + end + local verb, detail = operation_parts(root, dir, op) + return ("(%s)%s"):format(verb, detail) +end + -- the "Showing changes for:" footer label: the user's rev spec, or the -- resolved HEAD commit for the default uncommitted view (mirrors diffview) ---@param args string[] @@ -1712,7 +1806,12 @@ function M.panel(opts) panel = Panel.new({ sections = nonempty, root = vim.fn.fnamemodify(root, ":~"), -- ~-relative repo path for the header - footer = footer_label(args, root), + footer = function() + return footer_label(args, root) + end, + operation = stageable and function() + return M.operation_label(root) + end or nil, actions = actions, on_external_change = refresh_external, on_refresh = function() diff --git a/lua/differ/panel/init.lua b/lua/differ/panel/init.lua index d0c81f1..78220c9 100644 --- a/lua/differ/panel/init.lua +++ b/lua/differ/panel/init.lua @@ -98,6 +98,9 @@ local STATUS_HL = { ---@field on_staged fun(paths: table|nil)|nil ---@field root string|nil ---@field footer string|nil +---@field footer_source string|fun(): string|nil +---@field operation fun(): string|nil +---@field operation_label string|nil ---@field actions differ.panel.Actions|nil ---@field keymaps table -- session-supplied buffer maps (e.g. pr viewed nav) the generic panel doesn't own @@ -144,7 +147,10 @@ Panel.__index = Panel -- s / u keep their frozen marks ---@field on_staged? fun(paths: table|nil) ---@field root? string -- repo/worktree path shown in the panel header ----@field footer? string -- rev spec shown under "Showing changes for:" +-- rev spec shown under "Showing changes for:"; a function is re-read on every reload, as +-- is `operation`, the in-progress git operation +---@field footer? string|fun(): string|nil +---@field operation? fun(): string|nil ---@field actions? differ.panel.Actions -- file-level staging hooks (slice C) ---@field keymaps? table -- resolved panel action -> lhs ---@field extra_keymaps? differ.panel.ExtraMap[] -- session maps (pr viewed nav) @@ -189,7 +195,7 @@ function Panel.new(opts) if not pcall(vim.api.nvim_buf_set_name, bufnr, "differ://panel") then pcall(vim.api.nvim_buf_set_name, bufnr, "differ://panel#" .. bufnr) end - return setmetatable({ + local self = setmetatable({ bufnr = bufnr, sections = opts.sections, on_select = opts.on_select, @@ -199,7 +205,8 @@ function Panel.new(opts) on_refresh = opts.on_refresh, on_staged = opts.on_staged, root = opts.root, - footer = opts.footer, + footer_source = opts.footer, + operation = opts.operation or function() end, actions = opts.actions, extra_keymaps = opts.extra_keymaps, on_step = opts.on_step, @@ -219,6 +226,8 @@ function Panel.new(opts) lines = {}, meta = {}, }, Panel) + self:_read_chrome() + return self end -- 1-based line of the first file row, or 1 if there are none @@ -323,7 +332,10 @@ function Panel:render() -- diff --stat totals, painted on the help line (the file count already sits in the -- winbar meter, so the totals show the +A -B the count doesn't repeat) self.add_total, self.del_total = add_total, del_total - local header = self.root and { path = self.root, help = "g?" } or nil + local header ---@type differ.panel.Header|nil + if self.root then + header = { path = self.root, operation = self.operation_label, help = "g?" } + end -- live window width, falling back to the configured width when the window -- isn't open yet (headless construction / tests) local live = self:is_open() and vim.api.nvim_win_get_width(self.winid) or self.width @@ -398,6 +410,22 @@ function Panel:_highlight() 0, { end_col = eol, hl_group = "differPanelRoot" } ) + elseif m.kind == "operation" then + vim.api.nvim_buf_set_extmark( + self.bufnr, + ns, + row, + 0, + { end_col = m.verb_end or eol, hl_group = "differPanelOperation" } + ) + elseif m.kind == "continue" then + vim.api.nvim_buf_set_extmark( + self.bufnr, + ns, + row, + 0, + { end_col = eol, hl_group = "differPanelContinue" } + ) elseif m.kind == "help" then vim.api.nvim_buf_set_extmark( self.bufnr, @@ -524,10 +552,20 @@ function Panel:_highlight() end end +function Panel:_read_chrome() + local footer = self.footer_source + if type(footer) == "function" then + footer = footer() + end + self.footer = footer + self.operation_label = self.operation() +end + -- replace the file-list model and repaint (used on refresh / source change) ---@param sections differ.panel.Section[] function Panel:set_sections(sections) self.sections = sections + self:_read_chrome() self:render() end diff --git a/lua/differ/panel/render.lua b/lua/differ/panel/render.lua index 354d0c1..dacb0c4 100644 --- a/lua/differ/panel/render.lua +++ b/lua/differ/panel/render.lua @@ -11,7 +11,7 @@ local INDENT = " " local FOLD_OPEN, FOLD_CLOSED = "▾", "▸" ---@class differ.panel.LineMeta ----@field kind "root"|"help"|"blank"|"header"|"dir"|"file"|"foothead"|"footrev" +---@field kind "root"|"operation"|"continue"|"help"|"blank"|"header"|"dir"|"file"|"foothead"|"footrev" ---@field entry differ.FileEntry|nil ---@field path string|nil ---@field dir_path string|nil -- dir rows: full repo-relative dir path (prefix re-attached) @@ -32,6 +32,7 @@ local FOLD_OPEN, FOLD_CLOSED = "▾", "▸" ---@field viewed_end integer|nil ---@field prefix_col integer|nil -- header rows: byte col where the dimmed common-prefix subtitle begins ---@field prefix_end integer|nil +---@field verb_end integer|nil -- operation row: byte col just past the parenthesised verb ---@class differ.panel.Block ---@field title string|nil -- section header, nil = no header row @@ -53,10 +54,11 @@ end ---@class differ.panel.Header ---@field path string|nil -- repo/worktree path shown on the top line +---@field operation string|nil -- in-progress git operation, `() `, above the path ---@field help string|nil -- keymap hint (rendered as "Help: ") -- build the panel buffer lines and a parallel metadata list (one per line). an --- optional header (repo path + `Help: g?` + blank) prefixes the sections, +-- optional header (any in-progress operation, repo path, `Help: g?`, blank) prefixes the sections, -- and an optional `footer` rev appends a "Showing changes for:" block. -- `icon_for(path)` (supplied by the runtime layer, nvim-web-devicons, so this -- stays pure) returns a `(glyph, hl_group)` pair painted before each filename. @@ -72,6 +74,12 @@ end function M.lines(blocks, header, icon_for, footer, width) local lines, meta = {}, {} if header then + if header.operation then + lines[#lines + 1] = header.operation + meta[#meta + 1] = { kind = "operation", verb_end = header.operation:find(")", 1, true) } + lines[#lines + 1] = ":Differ continue" + meta[#meta + 1] = { kind = "continue" } + end if header.path then lines[#lines + 1] = header.path meta[#meta + 1] = { kind = "root" } diff --git a/lua/differ/ui/highlights.lua b/lua/differ/ui/highlights.lua index 5bde44a..56d4c67 100644 --- a/lua/differ/ui/highlights.lua +++ b/lua/differ/ui/highlights.lua @@ -30,6 +30,8 @@ local LINKS = { differPanelHeader = { link = "Title" }, differPanelRoot = { link = "Directory" }, differPanelHelp = { link = "Comment" }, + differPanelOperation = { link = "WarningMsg" }, + differPanelContinue = { link = "Special" }, differPanelDir = { link = "Directory" }, -- dimmed "·parent/" trailer after a basename in the name listing differPanelContext = { link = "Comment" }, diff --git a/test/nvim/operation_spec.lua b/test/nvim/operation_spec.lua index 98b98d3..d796e00 100644 --- a/test/nvim/operation_spec.lua +++ b/test/nvim/operation_spec.lua @@ -539,3 +539,77 @@ describe(":Differ continue", function() ) end) end) + +describe("operation label", function() + for _, case in ipairs({ + { "merge", "(merging) feature → main" }, + { "rebase", "(rebasing) feature onto main" }, + { "rebase-apply", "(rebasing) feature onto main" }, + { "am", "(applying patch 1/2) → main" }, + { "cherry-pick", "(cherry-picking) feature~1 → main" }, + { "revert", "(reverting) feature~2 → feature" }, + }) do + it("names a stopped " .. case[1], function() + assert.are.equal(case[2], git_src.operation_label(stopped(case[1]))) + end) + end + + it("is nil with nothing in progress", function() + assert.is_nil(git_src.operation_label(diverged_repo())) + end) + + it("names each commit once", function() + local root = stopped("merge") + assert.are.equal("(merging) feature → main", git_src.operation_label(root)) + git_ok(root, "branch", "-m", "feature", "renamed") + assert.are.equal("(merging) feature → main", git_src.operation_label(root)) + end) +end) + +describe("local panel mid-operation", function() + after_each(function() + local p = require("differ.panel").current() + if p then + p:close() + end + vim.cmd("silent! %bwipeout!") + end) + + local function operation_lines(p) + local lines = {} + for i, m in ipairs(p.meta) do + if m.kind == "operation" or m.kind == "continue" then + lines[#lines + 1] = p.lines[i] + end + end + return lines + end + + -- every conflict staged, plus an untracked file so the panel outlives the operation + local function panel_on(root) + resolve_all(root) + write(root .. "/extra.txt", "extra\n") + vim.cmd.edit(vim.fn.fnameescape(root .. "/f.txt")) + git_src.panel({ rev = {} }) + return assert(require("differ.panel").current()) + end + + for _, action in ipairs({ "continue", "abort" }) do + it("drops the operation lines after a " .. action, function() + local root = stopped("merge") + local p = panel_on(root) + assert.are.same( + { "(merging) feature → main", ":Differ continue" }, + operation_lines(p) + ) + assert.is_true(run(root, action).ok) + assert( + vim.wait(5000, function() + return #operation_lines(p) == 0 + end), + "the operation lines outlived the " .. action + ) + assert.are.equal(vim.trim(git_ok(root, "rev-parse", "HEAD")), p.lines[#p.lines]) + end) + end +end) diff --git a/test/unit/panel_render_spec.lua b/test/unit/panel_render_spec.lua index f0df306..446db90 100644 --- a/test/unit/panel_render_spec.lua +++ b/test/unit/panel_render_spec.lua @@ -19,6 +19,22 @@ describe("panel.render.lines", function() assert.are.equal("header", out.meta[4].kind) end) + it("puts an in-progress operation and its continue line above the root path", function() + local root = tree.build({ entry("a.lua") }) + local out = render.lines( + { { title = "Changes", rows = tree.rows(root, "tree", {}) } }, + { path = "~/repo", operation = "(merging) topic → main", help = "g?" } + ) + assert.are.same( + { "(merging) topic → main", ":Differ continue", "~/repo", "Help: g?" }, + { out.lines[1], out.lines[2], out.lines[3], out.lines[4] } + ) + assert.are.equal("operation", out.meta[1].kind) + assert.are.equal("(merging)", out.lines[1]:sub(1, out.meta[1].verb_end)) + assert.are.equal("continue", out.meta[2].kind) + assert.are.equal("root", out.meta[3].kind) + end) + it("appends a 'Showing changes for:' footer when given a rev", function() local root = tree.build({ entry("a.lua") }) local out = render.lines({ { rows = tree.rows(root, "tree", {}) } }, nil, nil, "main...") From 9c7d8d5779b435e5668d816dc2f1c37142802ea4 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:20:59 +0100 Subject: [PATCH 10/25] fix: harden the continue flow, signing float and modify/delete against the branch review --- lua/differ/git/init.lua | 13 ++- lua/differ/git/operation.lua | 114 ++++++++++++++++------ lua/differ/merge/continue.lua | 21 ++++ lua/differ/merge/init.lua | 28 +++++- test/nvim/mergetool_spec.lua | 36 +++++++ test/nvim/operation_spec.lua | 174 +++++++++++++++++++++++++++++++--- 6 files changed, 346 insertions(+), 40 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 6817b5d..548f13f 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -367,6 +367,10 @@ local OPERATION_MARKERS = { { "REVERT_HEAD", "revert" }, } +-- the verb on sequencer/todo's first line: a cherry-pick or revert range whose stopped +-- commit was committed by hand leaves only sequencer/ +local SEQUENCER_VERBS = { pick = "cherry-pick", p = "cherry-pick", revert = "revert" } + -- the in-progress operation that has a `--continue`; nil for conflicts left by one that -- doesn't (stash pop, checkout -m) ---@param root string @@ -381,7 +385,12 @@ function M.operation(root) return marker[2] end end - return nil + local todo = read_file(dir .. "/sequencer/todo") + local verb = todo and todo:match("^(%S+)") + if not verb then + return nil + end + return SEQUENCER_VERBS[verb] end -- repo-relative paths of untracked files, respecting .gitignore, in git's order @@ -1438,6 +1447,8 @@ function M.panel(opts) local sig = (git({ "rev-parse", "HEAD" }, root) or "") .. "\0" .. (git({ "status", "--porcelain=v1", "-z", "-uall" }, root) or "") + .. "\0" + .. (M.operation(root) or "") if active_entry then sig = sig .. "\0" .. row_signature(active_entry) end diff --git a/lua/differ/git/operation.lua b/lua/differ/git/operation.lua index b4f830f..e2f7170 100644 --- a/lua/differ/git/operation.lua +++ b/lua/differ/git/operation.lua @@ -10,6 +10,18 @@ local M = {} ---@type table M.SKIPPABLE = { rebase = true, am = true, ["cherry-pick"] = true, revert = true } +---@param path string +---@return string|nil +local function read(path) + local fd = io.open(path, "rb") + if not fd then + return nil + end + local text = fd:read("*a") + fd:close() + return text +end + -- the message the operation's next commit starts from, or nil when its --continue never -- opens an editor (am, an apply-backend rebase) ---@param root string @@ -24,23 +36,42 @@ function M.prepared_message(root, op) if op == "rebase" then path = dir .. "/rebase-merge/message" end - local fd = io.open(path, "rb") - if not fd then + return read(path) +end + +-- the sequencer's saved `-S`: `gpg-sign = ` in sequencer/opts, the key empty for a +-- bare -S. nil when the run doesn't sign +---@param dir string +---@return string|nil +local function sequencer_sign(dir) + local opts = read(dir .. "/sequencer/opts") + local key = opts and opts:match("\n%s*gpg%-sign = ([^\n]*)") + if not key then return nil end - local text = fd:read("*a") - fd:close() - return text + return "-S" .. key end +-- whether git's next commit signs: commit.gpgsign, or a rebase or cherry-pick/revert range +-- started with -S ---@param root string ---@return boolean local function signing(root) - return vim.trim(exec.git({ "config", "--type=bool", "commit.gpgsign" }, root) or "") == "true" + if vim.trim(exec.git({ "config", "--type=bool", "commit.gpgsign" }, root) or "") == "true" then + return true + end + local dir = require("differ.git").git_dir(root) + if not dir then + return false + end + return vim.uv.fs_stat(dir .. "/rebase-merge/gpg_sign_opt") ~= nil + or vim.uv.fs_stat(dir .. "/rebase-apply/gpg_sign_opt") ~= nil + or sequencer_sign(dir) ~= nil end -- run `cmd` in a floating terminal so a signing prompt has a tty to draw on. gpg reads --- the commit from a pipe, so GPG_TTY has to name the float's pty or pinentry gets none +-- the commit from a pipe, so GPG_TTY has to name the float's pty or pinentry gets none. +-- hiding or deleting the float stops git ---@param root string ---@param cmd string[] ---@param editor string @@ -66,7 +97,10 @@ local function spawn_float(root, cmd, editor, on_exit) env = { GIT_EDITOR = editor }, on_exit = function(_, code) vim.schedule(function() - local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + local lines = {} + if vim.api.nvim_buf_is_valid(buf) then + lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + end if vim.api.nvim_win_is_valid(win) then vim.api.nvim_win_close(win, true) end @@ -74,6 +108,9 @@ local function spawn_float(root, cmd, editor, on_exit) -- drop the terminal's "[Process exited n]" line local text = vim.trim(table.concat(lines, "\n")) local output = text:gsub("%s*%[Process exited %d+%]$", "") + if output == "" and code ~= 0 then + output = "the terminal was closed" + end on_exit(code == 0, output) end) end, @@ -82,19 +119,27 @@ local function spawn_float(root, cmd, editor, on_exit) vim.api.nvim_win_close(win, true) return on_exit(false, "couldn't start git in a terminal") end + vim.api.nvim_create_autocmd({ "BufHidden", "BufUnload" }, { + buffer = buf, + once = true, + callback = function() + vim.fn.jobstop(job) + end, + }) vim.cmd.startinsert() end -- spawn git with `editor` as its editor, off the main loop; `on_exit` gets whether it -- exited 0 and what it printed. detached: with no controlling terminal a prompt (a curses --- pinentry) fails rather than drawing over nvim. with signing on it runs in a float +-- pinentry) fails rather than drawing over nvim. `float` runs it in a terminal instead ---@param root string ---@param args string[] ---@param editor string +---@param float boolean ---@param on_exit fun(ok: boolean, output: string) -local function spawn(root, args, editor, on_exit) +local function spawn(root, args, editor, float, on_exit) local cmd = vim.list_extend(vim.list_extend({}, exec.GIT), args) - if signing(root) then + if float then return spawn_float(root, cmd, editor, on_exit) end local function done(res) @@ -114,47 +159,62 @@ local function spawn(root, args, editor, on_exit) end end --- cherry-pick and revert keep the message their sequencer saved (`--no-edit` sticks past --- `--continue --edit`), so commit by hand, then let a multi-commit run go on +-- commit `msgfile` by hand, then let a cherry-pick or revert range go on. their sequencer +-- keeps its saved message past `--continue --edit`, and a merge's editor would overwrite +-- what prepare-commit-msg added ---@param root string ---@param op string ---@param msgfile string +---@param float boolean ---@param on_exit fun(ok: boolean, output: string) -local function commit_then_continue(root, op, msgfile, on_exit) - spawn(root, { "commit", "-q", "--cleanup=strip", "-F", msgfile }, "true", function(ok, output) +local function commit_then_continue(root, op, msgfile, float, on_exit) + local dir = require("differ.git").git_dir(root) + local args = { "commit", "-q", "--cleanup=strip", "-F", msgfile } + local sign = dir and sequencer_sign(dir) + if sign then + table.insert(args, 2, sign) + end + spawn(root, args, "true", float, function(ok, output) os.remove(msgfile) - local dir = require("differ.git").git_dir(root) - if not ok or not (dir and vim.uv.fs_stat(dir .. "/sequencer")) then + if not ok or op == "merge" or not (dir and vim.uv.fs_stat(dir .. "/sequencer")) then return on_exit(ok, output) end - spawn(root, { op, "--continue" }, "true", on_exit) + spawn(root, { op, "--continue" }, "true", float, on_exit) end) end -- run `git --`, never opening an editor. a continue commits git's prepared --- message, or `opts.message` in its place +-- message, or `opts.message` in its place. an abort never signs, so it never needs the float ---@param root string ---@param op "merge"|"rebase"|"am"|"cherry-pick"|"revert" ---@param action "continue"|"skip"|"abort" ---@param opts { message?: string }|nil ---@param on_exit fun(ok: boolean, output: string) function M.run(root, op, action, opts, on_exit) + local float = action ~= "abort" and signing(root) local message = opts and opts.message if not (message and action == "continue") then - return spawn(root, { op, "--" .. action }, "true", on_exit) + return spawn(root, { op, "--" .. action }, "true", float, on_exit) end local msgfile = vim.fn.tempname() local fd = assert(io.open(msgfile, "wb")) fd:write(message) fd:close() - if op == "cherry-pick" or op == "revert" then - return commit_then_continue(root, op, msgfile, on_exit) + if op ~= "rebase" then + return commit_then_continue(root, op, msgfile, float, on_exit) end - -- git runs the editor through the shell with the file to edit appended - spawn(root, { op, "--continue" }, "cp " .. vim.fn.shellescape(msgfile), function(ok, output) - os.remove(msgfile) - on_exit(ok, output) - end) + -- a rebase commit made by hand loses the picked commit's author, so git commits it, + -- with the shell running the editor and the file to edit appended + spawn( + root, + { op, "--continue" }, + "cp " .. vim.fn.shellescape(msgfile), + float, + function(ok, output) + os.remove(msgfile) + on_exit(ok, output) + end + ) end return M diff --git a/lua/differ/merge/continue.lua b/lua/differ/merge/continue.lua index fa19b2c..5024757 100644 --- a/lua/differ/merge/continue.lua +++ b/lua/differ/merge/continue.lua @@ -56,14 +56,32 @@ local function report(root, op, action, ok, output) ) end +-- the operation of the run in flight; a second run alongside it only gets git's errors +---@type string|nil +local running + +---@return boolean +local function refuse_while_running() + if not running then + return false + end + notify(("git %s is still running"):format(running), vim.log.levels.WARN) + return true +end + ---@param root string ---@param op string ---@param action "continue"|"skip"|"abort" ---@param opts { message?: string }|nil local function run(root, op, action, opts) + if refuse_while_running() then + return + end local merge = require("differ.merge") merge.hold() + running = op git_op.run(root, op, action, opts, function(ok, output) + running = nil report(root, op, action, ok, output) merge.after_operation(root) end) @@ -188,6 +206,9 @@ end -- the picker, continue first function M.pick() + if refuse_while_running() then + return + end local root = target_root() local op = root and require("differ.git").operation(root) if not root or not op then diff --git a/lua/differ/merge/init.lua b/lua/differ/merge/init.lua index 3d7aea3..83f6e5c 100644 --- a/lua/differ/merge/init.lua +++ b/lua/differ/merge/init.lua @@ -827,6 +827,9 @@ local function release_result(s) for _, spec in ipairs(s.bound_keys) do unbind(s.result_buf, spec) end + if s.aug then + vim.api.nvim_clear_autocmds({ group = s.aug, buffer = s.result_buf }) + end end -- drop the diagnostics hook; the producers re-lint the now-resolved file on their own if s.diag_aug then @@ -835,6 +838,15 @@ local function release_result(s) end end +-- read back what git wrote to the result's file while the session held it. a buffer with +-- unsaved edits gets nvim's changed-on-disk prompt +---@param s differ.MergeSession +local function reload_result(s) + if vim.api.nvim_buf_is_valid(s.result_buf) then + vim.cmd.checktime(s.result_buf) + end +end + -- end the session: drop the tab + buffers and return to the invoking tab function M.close() if not session then @@ -844,6 +856,7 @@ function M.close() session = nil restore_timeout() -- net in case the tab teardown didn't fire BufLeave on the result buf release_result(s) + reload_result(s) if s.aug then pcall(vim.api.nvim_del_augroup_by_id, s.aug) end @@ -1007,6 +1020,8 @@ end ---@return boolean local function settle_result() local s = assert(session) + -- on_write warns about a reformatting formatter once; a later refusal has to say why + local warned = s.autoformat_warned -- an untouched modify/delete file waits for keep or delete; stepping past isn't a keep if s.deleted and not s.staged and not vim.bo[s.result_buf].modified then return true @@ -1022,7 +1037,17 @@ local function settle_result() elseif #s.regions == 0 and not s.staged then on_write() -- written but never staged, e.g. after a failed add end - return #s.regions > 0 or s.staged + if #s.regions > 0 or s.staged then + return true + end + local lines = vim.api.nvim_buf_get_lines(s.result_buf, 0, -1, false) + if warned and markers_reformatted(lines) then + notify( + ("%s isn't staged: a formatter changed its conflict markers"):format(s.path), + vim.log.levels.WARN + ) + end + return false end -- the next/prev file in the session's list that is still conflicted and builds, wrapping @@ -1535,6 +1560,7 @@ function M.after_operation(root) if s then s.running = false s.head, s.operation = git.head_sha(root), git.operation(root) + reload_result(s) end if not git.operation(root) then return M.close() diff --git a/test/nvim/mergetool_spec.lua b/test/nvim/mergetool_spec.lua index af99c41..b22ef08 100644 --- a/test/nvim/mergetool_spec.lua +++ b/test/nvim/mergetool_spec.lua @@ -733,6 +733,27 @@ describe(":Differ mergetool resolution", function() -- the parser reads zero regions, but the file is still conflicted: not staged assert.are.same({ "f.txt" }, require("differ.git").conflicted(root)) end) + + it("says why ]f stays put once a reformatted save was warned about", function() + local root = conflict_repo() + vim.cmd.edit(root .. "/f.txt") + merge.open({}) + local s = merge.current() + local lines = vim.api.nvim_buf_get_lines(s.result_buf, 0, -1, false) + for i, l in ipairs(lines) do + lines[i] = " " .. l + end + vim.api.nvim_buf_set_lines(s.result_buf, 0, -1, false, lines) + vim.api.nvim_set_current_win(s.result_win) + vim.cmd("silent write") + _G.notifs = {} + fire(s.result_buf, "differ: next conflicted file") + assert.are.equal(1, #_G.notifs) + assert.matches( + "f.txt isn't staged: a formatter changed its conflict markers", + _G.notifs[1].msg + ) + end) end) -- the located input slab line for a side (the row sync_inputs centres on) @@ -2065,6 +2086,21 @@ describe(":Differ mergetool modify/delete", function() assert.are.equal(0, vim.fn.filereadable(root .. "/a.txt")) end) + it("doesn't stage a write of the deleted file from a window outside the session", function() + local root = conflict_repo_modify_delete({ "f.txt" }) + vim.cmd.edit(root .. "/a.txt") + local s = open_at(root, "a.txt") + local real = s.result_buf + confirming(1, function() + fire(s.result_buf, "differ: take theirs") + end) + assert.is_true(vim.api.nvim_buf_is_loaded(real)) + vim.api.nvim_buf_call(real, function() + vim.cmd("silent write") + end) + assert.are.equal("", vim.trim(git_ok(root, "ls-files", "--stage", "--", "a.txt"))) + end) + it("says there's nothing to write on a :w after the delete", function() local root = conflict_repo_modify_delete({ "f.txt" }) vim.cmd.edit(root .. "/f.txt") diff --git a/test/nvim/operation_spec.lua b/test/nvim/operation_spec.lua index d796e00..846bcc1 100644 --- a/test/nvim/operation_spec.lua +++ b/test/nvim/operation_spec.lua @@ -108,9 +108,9 @@ local function subject(root) return vim.trim(git_ok(root, "log", "-1", "--format=%s")) end --- signing on, with a gpg.program that records whether GPG_TTY names a terminal, then --- signs with junk. returns the record's path -local function signing(root) +-- a gpg.program that records whether GPG_TTY names a terminal, then signs with junk after +-- `delay` seconds. returns the record's path +local function gpg_stub(root, delay) local record = vim.fn.tempname() local stub = vim.fn.tempname() write( @@ -121,16 +121,22 @@ local function signing(root) ('if [ -c "$GPG_TTY" ]; then echo tty; else echo none; fi > %s'):format( vim.fn.shellescape(record) ), + ("sleep %d"):format(delay or 0), "printf '\\n[GNUPG:] SIG_CREATED D 22 8 00 0 0\\n' >&2", "printf -- '-----BEGIN PGP SIGNATURE-----\\nstub\\n-----END PGP SIGNATURE-----\\n'", }, "\n") ) vim.fn.setfperm(stub, "rwx------") - git_ok(root, "config", "commit.gpgsign", "true") git_ok(root, "config", "gpg.program", stub) return record end +-- signing on through commit.gpgsign. returns the stub's record +local function signing(root, delay) + git_ok(root, "config", "commit.gpgsign", "true") + return gpg_stub(root, delay) +end + describe("git operation runner", function() local saved, marker @@ -191,16 +197,29 @@ describe("git operation runner", function() end) end - it("keeps the picked commit's author on an edited cherry-pick", function() - local root = stopped("cherry-pick") + it("keeps prepare-commit-msg's output in an edited merge message", function() + local root = stopped("merge") resolve_all(root) - run(root, "continue", { message = "edited\n" }) - assert.are.equal( - vim.trim(git_ok(root, "log", "-1", "--format=%ae", "feature~1")), - vim.trim(git_ok(root, "log", "-1", "--format=%ae")) - ) + local hook = root .. "/.git/hooks/prepare-commit-msg" + write(hook, '#!/bin/sh\necho hooked >> "$1"\n') + vim.fn.setfperm(hook, "rwx------") + local res = run(root, "continue", { message = "edited\n" }) + assert.is_true(res.ok, res.output) + assert.is_nil(git_src.operation(root)) + assert.are.equal("edited\nhooked", vim.trim(git_ok(root, "log", "-1", "--format=%B"))) + assert.are.equal(2, #vim.split(vim.trim(git_ok(root, "log", "-1", "--format=%p")), " ")) end) + for _, op in ipairs({ "rebase", "cherry-pick" }) do + it("keeps the picked commit's author on an edited " .. op, function() + local root = stopped(op) + resolve_all(root) + run(root, "continue", { message = "edited\n" }) + assert.are.equal("edited", subject(root)) + assert.are.equal("f@f", vim.trim(git_ok(root, "log", "-1", "--format=%ae"))) + end) + end + it("has no prepared message for am or an apply-backend rebase", function() assert.is_nil(operation.prepared_message(stopped("am"), "am")) assert.is_nil(operation.prepared_message(stopped("rebase-apply"), "rebase")) @@ -262,6 +281,71 @@ describe("git operation runner", function() assert.matches("conflict", res.output) assert.not_matches("Process exited", res.output) end) + + for _, close in ipairs({ "quit", "bdelete!" }) do + it("stops git and still hands back when the float is closed with :" .. close, function() + local root = stopped("merge") + resolve_all(root) + signing(root, 30) + local wins = #vim.api.nvim_list_wins() + local result + operation.run(root, "merge", "continue", nil, function(ok, output) + result = { ok = ok, output = output } + end) + vim.cmd(close) + assert( + vim.wait(5000, function() + return result ~= nil + end), + "the run never handed back" + ) + assert.is_false(result.ok) + assert.are.equal(wins, #vim.api.nvim_list_wins()) + assert.are.equal("merge", git_src.operation(root)) + end) + end + + it("aborts in the background", function() + local root = stopped("merge") + signing(root) + local wins = #vim.api.nvim_list_wins() + local result + operation.run(root, "merge", "abort", nil, function(ok, output) + result = { ok = ok, output = output } + end) + assert.are.equal(wins, #vim.api.nvim_list_wins()) + assert(vim.wait(10000, function() + return result ~= nil + end)) + assert.is_true(result.ok, result.output) + end) + + it("runs a rebase started with -S in the float", function() + local root = diverged_repo() + local record = gpg_stub(root) + git_ok(root, "checkout", "-q", "feature") + git(root, "rebase", "-S", "main") + resolve_all(root) + local result + operation.run(root, "rebase", "continue", nil, function(ok, output) + result = { ok = ok, output = output } + end) + assert.are.equal("terminal", vim.bo.buftype) + assert(vim.wait(10000, function() + return result ~= nil + end)) + assert.are.same({ "tty" }, vim.fn.readfile(record)) + end) + + it("signs an edited commit of a cherry-pick range started with -S", function() + local root = diverged_repo() + gpg_stub(root) + git(root, "cherry-pick", "-S", "feature~1", "feature") + resolve_all(root) + run(root, "continue", { message = "edited\n" }) + assert.are.equal("edited", subject(root)) + assert.matches("gpgsig", git_ok(root, "cat-file", "-p", "HEAD")) + end) end) end) @@ -504,6 +588,28 @@ describe(":Differ continue", function() assert.is_nil(outside_closes()) end) + for _, case in ipairs({ { "merge", "abort" }, { "rebase", "skip" } }) do + it("reads the saved result back after a " .. case[2], function() + local root = stopped(case[1]) + local s = session_on(root) + vim.api.nvim_buf_call(s.result_buf, function() + vim.cmd("silent write") + end) + chosen = case[2] + vim.fn.confirm = function() + return 1 + end + command.dispatch({ "continue" }) + local buf = vim.fn.bufnr(root .. "/f.txt") + assert( + vim.wait(10000, function() + return vim.api.nvim_buf_get_lines(buf, 0, -1, false)[1] == "main" + end), + "the result kept the pre-" .. case[2] .. " text" + ) + end) + end + it("stays open with git's error when a hook refuses the commit", function() local root = stopped("merge") local hook = root .. "/.git/hooks/commit-msg" @@ -524,6 +630,25 @@ describe(":Differ continue", function() end) end) + it("refuses a second run while the first is going", function() + local root = stopped("merge") + resolve_all(root) + local hook = root .. "/.git/hooks/commit-msg" + write(hook, "#!/bin/sh\nsleep 1\n") + vim.fn.setfperm(hook, "rwx------") + in_repo(root) + chosen = "continue" + command.dispatch({ "continue" }) + command.dispatch({ "continue" }) + local refused = noted("still running") + assert.is_not_nil(refused) + assert.are.equal(vim.log.levels.WARN, refused.level) + finished(root) + for _, n in ipairs(notes) do + assert.are_not.equal(vim.log.levels.ERROR, n.level, n.msg) + end + end) + it("opens the merge tool on new conflicts when run outside it", function() local root = stopped("rebase") resolve_all(root) @@ -558,6 +683,20 @@ describe("operation label", function() assert.is_nil(git_src.operation_label(diverged_repo())) end) + -- resolving the stopped commit and committing it by hand drops *_HEAD, not sequencer/ + for _, case in ipairs({ + { "cherry-pick", "(cherry-picking) → main" }, + { "revert", "(reverting) → feature" }, + }) do + it("names a " .. case[1] .. " range left with only sequencer/", function() + local root = stopped(case[1]) + resolve_all(root) + git_ok(root, "commit", "-q", "--no-edit") + assert.are.equal(case[1], git_src.operation(root)) + assert.are.equal(case[2], git_src.operation_label(root)) + end) + end + it("names each commit once", function() local root = stopped("merge") assert.are.equal("(merging) feature → main", git_src.operation_label(root)) @@ -612,4 +751,17 @@ describe("local panel mid-operation", function() assert.are.equal(vim.trim(git_ok(root, "rev-parse", "HEAD")), p.lines[#p.lines]) end) end + + it("drops the operation lines after a rebase --quit", function() + local root = stopped("rebase") + local p = panel_on(root) + assert.are.equal(2, #operation_lines(p)) + git_ok(root, "rebase", "--quit") + assert( + vim.wait(5000, function() + return #operation_lines(p) == 0 + end), + "the operation lines outlived the --quit" + ) + end) end) From deb9e3f56d180dfa92da3fc51211ea503e0a27e4 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:28:20 +0100 Subject: [PATCH 11/25] refactor: cache the git dir, move operation detection and split the merge tool modules --- lua/differ/git/conflict.lua | 20 +++ lua/differ/git/init.lua | 151 ++-------------- lua/differ/git/operation.lua | 152 ++++++++++++++-- lua/differ/merge/continue.lua | 19 +- lua/differ/merge/deleted.lua | 89 ++++++++++ lua/differ/merge/files.lua | 165 ++++++++++++++++++ lua/differ/merge/init.lua | 316 +++------------------------------- lua/differ/panel/init.lua | 77 +++------ lua/differ/pr/checks.lua | 11 +- lua/differ/ui/foldtext.lua | 4 +- lua/differ/ui/help.lua | 9 +- lua/differ/util/date.lua | 2 +- lua/differ/util/text.lua | 11 ++ lua/differ/util/win.lua | 19 ++ test/nvim/operation_spec.lua | 10 +- 15 files changed, 536 insertions(+), 519 deletions(-) create mode 100644 lua/differ/merge/deleted.lua create mode 100644 lua/differ/merge/files.lua diff --git a/lua/differ/git/conflict.lua b/lua/differ/git/conflict.lua index ae76b57..14e01ef 100644 --- a/lua/differ/git/conflict.lua +++ b/lua/differ/git/conflict.lua @@ -66,4 +66,24 @@ function M.parse(lines) return regions end +-- a clean conflict marker sits at column 0; a format-on-save that ignores the buffer's +-- disable_autoformat opt-out reflows the whole region and indents the markers with it. the +-- parser then can't see the region (it matches at column 0), so it would read as resolved +-- and stage a file that still has markers. an indented <<<<<<< / >>>>>>> run is the tell; +-- `<`/`>` only, since a stray indented ======= can be decorative +---@param lines string[] +---@return boolean +function M.reformatted(lines) + for _, line in ipairs(lines) do + local ch = line:match("^%s+([<>])") + if ch then + local run = line:match("^%s+(%" .. ch .. "+)") + if run and #run >= 7 then + return true + end + end + end + return false +end + return M diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 548f13f..3b4e4ed 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -7,6 +7,7 @@ local rev = require("differ.git.rev") local exec = require("differ.git.exec") local index_ops = require("differ.git.index") +local operation_ops = require("differ.git.operation") local GIT = exec.GIT local notify, chomp, reload_buffer = exec.notify, exec.chomp, exec.reload_buffer local git, git_raw, git_ok = exec.git, exec.git_raw, exec.git_ok @@ -84,12 +85,23 @@ function M.head_sha(root) return out and chomp(out) or nil end --- the repo's absolute git dir, or nil outside a repo +---@type table +local git_dirs = {} + +-- the repo's absolute git dir, or nil outside a repo. only found dirs are cached: a +-- directory outside a repo can become one ---@param root string ---@return string|nil function M.git_dir(root) + if git_dirs[root] then + return git_dirs[root] + end local out = git({ "rev-parse", "--absolute-git-dir" }, root) - return out and chomp(out) or nil + if not out then + return nil + end + git_dirs[root] = chomp(out) + return git_dirs[root] end -- does a local branch of this name already exist? @@ -355,43 +367,7 @@ function M.conflicted(root) return out and rev.parse_unmerged(out) or {} end --- git-dir markers of the operations with a `--continue`, checked in order: a rebase --- replaying a merge or a pick also writes MERGE_HEAD/CHERRY_PICK_HEAD, and `git am` shares --- rebase-apply/ -local OPERATION_MARKERS = { - { "rebase-merge", "rebase" }, - { "rebase-apply/applying", "am" }, - { "rebase-apply", "rebase" }, - { "MERGE_HEAD", "merge" }, - { "CHERRY_PICK_HEAD", "cherry-pick" }, - { "REVERT_HEAD", "revert" }, -} - --- the verb on sequencer/todo's first line: a cherry-pick or revert range whose stopped --- commit was committed by hand leaves only sequencer/ -local SEQUENCER_VERBS = { pick = "cherry-pick", p = "cherry-pick", revert = "revert" } - --- the in-progress operation that has a `--continue`; nil for conflicts left by one that --- doesn't (stash pop, checkout -m) ----@param root string ----@return "merge"|"rebase"|"am"|"cherry-pick"|"revert"|nil -function M.operation(root) - local dir = M.git_dir(root) - if not dir then - return nil - end - for _, marker in ipairs(OPERATION_MARKERS) do - if vim.uv.fs_stat(dir .. "/" .. marker[1]) then - return marker[2] - end - end - local todo = read_file(dir .. "/sequencer/todo") - local verb = todo and todo:match("^(%S+)") - if not verb then - return nil - end - return SEQUENCER_VERBS[verb] -end +M.operation, M.operation_label = operation_ops.current, operation_ops.label -- repo-relative paths of untracked files, respecting .gitignore, in git's order ---@param root string @@ -563,105 +539,12 @@ end -- the current branch name (for the buffer statusline), or nil on a detached HEAD ---@param root string ---@return string|nil -local function head_branch(root) +function M.head_branch(root) local out = git({ "rev-parse", "--abbrev-ref", "HEAD" }, root) local name = out and chomp(out) or nil return (name and name ~= "HEAD") and name or nil end - --- name-rev walks history, which takes hundreds of ms in a large repo, and the panel asks --- on every refresh while the named commit only changes once per operation step -local commit_names, commit_names_cached = {}, 0 -local COMMIT_NAMES_MAX = 64 - --- a commit as `git name-rev` names it (`topic`, `main~1`), else its short sha ----@param root string ----@param sha string ----@return string -local function commit_name(root, sha) - local key = root .. "\0" .. sha - if commit_names[key] then - return commit_names[key] - end - local out = git({ "name-rev", "--name-only", "--no-undefined", sha }, root) - local name = out and chomp(out) or sha:sub(1, 7) - if commit_names_cached >= COMMIT_NAMES_MAX then - commit_names, commit_names_cached = {}, 0 - end - commit_names[key], commit_names_cached = name, commit_names_cached + 1 - return name -end - --- the first line of a git-dir state file, or nil when it isn't there ----@param dir string ----@param file string ----@return string|nil -local function state_line(dir, file) - local data = read_file(dir .. "/" .. file) - return data and data:match("^[^\n]*") or nil -end - ----@param root string ----@param dir string ----@return string -local function rebase_detail(root, dir) - local state = vim.uv.fs_stat(dir .. "/rebase-merge") and "rebase-merge" or "rebase-apply" - local detail = "" - local branch = state_line(dir, state .. "/head-name") - if branch then - detail = " " .. branch:gsub("^refs/heads/", "") - end - local onto = state_line(dir, state .. "/onto") - if onto then - detail = detail .. " onto " .. commit_name(root, onto) - end - return detail -end - -local OPERATION_HEADS = { - merge = { "MERGE_HEAD", "merging" }, - ["cherry-pick"] = { "CHERRY_PICK_HEAD", "cherry-picking" }, - revert = { "REVERT_HEAD", "reverting" }, -} - ----@param root string ----@param dir string ----@param op "merge"|"rebase"|"am"|"cherry-pick"|"revert" ----@return string verb, string detail -local function operation_parts(root, dir, op) - if op == "rebase" then - return "rebasing", rebase_detail(root, dir) - end - local target = head_branch(root) - local into = target and (" → " .. target) or "" - if op == "am" then - local at, last = state_line(dir, "rebase-apply/next"), state_line(dir, "rebase-apply/last") - if not (at and last) then - return "applying patches", into - end - return ("applying patch %s/%s"):format(at, last), into - end - local file, verb = unpack(OPERATION_HEADS[op]) - local sha = state_line(dir, file) - if not sha then - return verb, into - end - return verb, " " .. commit_name(root, sha) .. into -end - --- the in-progress operation in lazygit's form (`(merging) topic → main`, `(rebasing) topic --- onto main`), or nil when there's none ----@param root string ----@return string|nil -function M.operation_label(root) - local op = M.operation(root) - local dir = op and M.git_dir(root) - if not (op and dir) then - return nil - end - local verb, detail = operation_parts(root, dir, op) - return ("(%s)%s"):format(verb, detail) -end +local head_branch = M.head_branch -- the "Showing changes for:" footer label: the user's rev spec, or the -- resolved HEAD commit for the default uncommitted view (mirrors diffview) diff --git a/lua/differ/git/operation.lua b/lua/differ/git/operation.lua index e2f7170..278593a 100644 --- a/lua/differ/git/operation.lua +++ b/lua/differ/git/operation.lua @@ -1,4 +1,5 @@ --- continue, skip or abort the in-progress operation (`git.operation`) in the background. +-- the in-progress operation (merge, rebase, am, cherry-pick, revert): detected from the +-- git dir, labelled for the panel, and continued, skipped or aborted in the background. -- git never gets a terminal here, so it never gets a real editor either: a plain run -- commits the message git prepared, an edited one hands git the user's text @@ -22,13 +23,147 @@ local function read(path) return text end +---@param root string +---@return string|nil +local function git_dir(root) + return require("differ.git").git_dir(root) +end + +-- git-dir markers of the operations with a `--continue`, checked in order: a rebase +-- replaying a merge or a pick also writes MERGE_HEAD/CHERRY_PICK_HEAD, and `git am` shares +-- rebase-apply/ +local OPERATION_MARKERS = { + { "rebase-merge", "rebase" }, + { "rebase-apply/applying", "am" }, + { "rebase-apply", "rebase" }, + { "MERGE_HEAD", "merge" }, + { "CHERRY_PICK_HEAD", "cherry-pick" }, + { "REVERT_HEAD", "revert" }, +} + +-- the verb on sequencer/todo's first line: a cherry-pick or revert range whose stopped +-- commit was committed by hand leaves only sequencer/ +local SEQUENCER_VERBS = { pick = "cherry-pick", p = "cherry-pick", revert = "revert" } + +-- the in-progress operation that has a `--continue`; nil for conflicts left by one that +-- doesn't (stash pop, checkout -m) +---@param root string +---@return "merge"|"rebase"|"am"|"cherry-pick"|"revert"|nil +function M.current(root) + local dir = git_dir(root) + if not dir then + return nil + end + for _, marker in ipairs(OPERATION_MARKERS) do + if vim.uv.fs_stat(dir .. "/" .. marker[1]) then + return marker[2] + end + end + local todo = read(dir .. "/sequencer/todo") + local verb = todo and todo:match("^(%S+)") + if not verb then + return nil + end + return SEQUENCER_VERBS[verb] +end + +-- name-rev walks history, which takes hundreds of ms in a large repo, and the panel asks +-- on every refresh while the named commit only changes once per operation step +---@type { key: string, name: string }|nil +local last_name + +-- a commit as `git name-rev` names it (`topic`, `main~1`), else its short sha +---@param root string +---@param sha string +---@return string +local function commit_name(root, sha) + local key = root .. "\0" .. sha + if last_name and last_name.key == key then + return last_name.name + end + local out = exec.git({ "name-rev", "--name-only", "--no-undefined", sha }, root) + last_name = { key = key, name = out and exec.chomp(out) or sha:sub(1, 7) } + return last_name.name +end + +-- the first line of a git-dir state file, or nil when it isn't there +---@param dir string +---@param file string +---@return string|nil +local function state_line(dir, file) + local data = read(dir .. "/" .. file) + return data and data:match("^[^\n]*") or nil +end + +---@param root string +---@param dir string +---@return string +local function rebase_detail(root, dir) + local state = vim.uv.fs_stat(dir .. "/rebase-merge") and "rebase-merge" or "rebase-apply" + local detail = "" + local branch = state_line(dir, state .. "/head-name") + if branch then + detail = " " .. branch:gsub("^refs/heads/", "") + end + local onto = state_line(dir, state .. "/onto") + if onto then + detail = detail .. " onto " .. commit_name(root, onto) + end + return detail +end + +local OPERATION_HEADS = { + merge = { "MERGE_HEAD", "merging" }, + ["cherry-pick"] = { "CHERRY_PICK_HEAD", "cherry-picking" }, + revert = { "REVERT_HEAD", "reverting" }, +} + +---@param root string +---@param dir string +---@param op "merge"|"rebase"|"am"|"cherry-pick"|"revert" +---@return string verb, string detail +local function operation_parts(root, dir, op) + if op == "rebase" then + return "rebasing", rebase_detail(root, dir) + end + local target = require("differ.git").head_branch(root) + local into = target and (" → " .. target) or "" + if op == "am" then + local at, last = state_line(dir, "rebase-apply/next"), state_line(dir, "rebase-apply/last") + if not (at and last) then + return "applying patches", into + end + return ("applying patch %s/%s"):format(at, last), into + end + local file, verb = unpack(OPERATION_HEADS[op]) + local sha = state_line(dir, file) + if not sha then + return verb, into + end + return verb, " " .. commit_name(root, sha) .. into +end + +-- the in-progress operation in lazygit's form (`(merging) topic → main`, `(rebasing) topic +-- onto main`), or nil when there's none +---@param root string +---@return string|nil +function M.label(root) + local op = M.current(root) + if not op then + return nil + end + local dir = assert(git_dir(root)) + local verb, detail = operation_parts(root, dir, op) + return ("(%s)%s"):format(verb, detail) +end + -- the message the operation's next commit starts from, or nil when its --continue never -- opens an editor (am, an apply-backend rebase) ---@param root string ---@param op string ---@return string|nil function M.prepared_message(root, op) - local dir = require("differ.git").git_dir(root) + local dir = git_dir(root) if not dir or op == "am" then return nil end @@ -60,7 +195,7 @@ local function signing(root) if vim.trim(exec.git({ "config", "--type=bool", "commit.gpgsign" }, root) or "") == "true" then return true end - local dir = require("differ.git").git_dir(root) + local dir = git_dir(root) if not dir then return false end @@ -80,14 +215,7 @@ local function spawn_float(root, cmd, editor, on_exit) local buf = vim.api.nvim_create_buf(false, true) local width = math.floor(vim.o.columns * 0.8) local height = math.floor(vim.o.lines * 0.6) - local win = vim.api.nvim_open_win(buf, true, { - relative = "editor", - width = width, - height = height, - row = math.floor((vim.o.lines - height) / 2), - col = math.floor((vim.o.columns - width) / 2), - style = "minimal", - border = "rounded", + local win = require("differ.util.win").open_centred(buf, width, height, { title = " git " .. cmd[#exec.GIT + 1] .. " ", }) local argv = vim.list_extend({ "sh", "-c", 'GPG_TTY=$(tty) exec "$@"', "sh" }, cmd) @@ -168,7 +296,7 @@ end ---@param float boolean ---@param on_exit fun(ok: boolean, output: string) local function commit_then_continue(root, op, msgfile, float, on_exit) - local dir = require("differ.git").git_dir(root) + local dir = git_dir(root) local args = { "commit", "-q", "--cleanup=strip", "-F", msgfile } local sign = dir and sequencer_sign(dir) if sign then diff --git a/lua/differ/merge/continue.lua b/lua/differ/merge/continue.lua index 5024757..26607bd 100644 --- a/lua/differ/merge/continue.lua +++ b/lua/differ/merge/continue.lua @@ -3,21 +3,12 @@ local exec = require("differ.git.exec") local git_op = require("differ.git.operation") +local plural = require("differ.util.text").plural local M = {} local notify = exec.notify ----@param n integer ----@param noun string ----@return string -local function plural(n, noun) - if n == 1 then - return "1 " .. noun - end - return ("%d %ss"):format(n, noun) -end - -- the repo the command acts on: a merge session's, else the current file's or cwd's ---@return string|nil local function target_root() @@ -78,7 +69,6 @@ local function run(root, op, action, opts) return end local merge = require("differ.merge") - merge.hold() running = op git_op.run(root, op, action, opts, function(ok, output) running = nil @@ -193,7 +183,6 @@ local function entries(root, op) end end, } - list[#list + 1] = { label = "cancel", run = function() end } return list end @@ -204,6 +193,12 @@ function M.in_progress() return root ~= nil and require("differ.git").operation(root) ~= nil end +-- whether a run is in flight +---@return boolean +function M.running() + return running ~= nil +end + -- the picker, continue first function M.pick() if refuse_while_running() then diff --git a/lua/differ/merge/deleted.lua b/lua/differ/merge/deleted.lua new file mode 100644 index 0000000..a4aa125 --- /dev/null +++ b/lua/differ/merge/deleted.lua @@ -0,0 +1,89 @@ +-- modify/delete conflicts in the merge tool: taking the deleting side deletes the file with +-- git rm, taking the other keeps it by writing it + +local notify = require("differ.git.exec").notify + +local M = {} + +-- keep the file: write it as it stands, which stages it like any write +---@param s differ.MergeSession +local function keep_file(s) + local ok, err = pcall(vim.api.nvim_buf_call, s.result_buf, function() + vim.cmd("silent write") + end) + if not ok then + notify(tostring(err), vim.log.levels.ERROR) + end +end + +-- after a delete the file is gone, so the result window shows an empty read-only buffer +-- in place of the real one; a write there would recreate the file +---@param s differ.MergeSession +local function show_deleted(s) + local merge = require("differ.merge") + local real = s.result_buf + merge._release_result(s) + local buf = merge._make_buffer("deleted", s.path, {}) + local notice = s.path .. " deletion staged" + -- acwrite, so a :w reaches the BufWriteCmd below rather than failing on the buftype + vim.bo[buf].buftype = "acwrite" + vim.api.nvim_create_autocmd("BufWriteCmd", { + buffer = buf, + group = s.aug, + callback = function() + notify(notice .. ": nothing to write") + end, + }) + s.bufs[#s.bufs + 1] = buf + s.result_buf, s.bound_keys = buf, {} + vim.api.nvim_win_set_buf(s.result_win, buf) + merge._dress(s.result_win) + merge._show_notice(s.result_win, buf, notice) + merge._bind_pane(buf) + -- unloaded, so nvim doesn't report the file missing; a window outside the session keeps it + if #vim.fn.win_findbuf(real) == 0 then + pcall(vim.api.nvim_buf_delete, real, {}) + end +end + +-- delete the file with git rm, once confirmed +---@param s differ.MergeSession +local function delete_file(s) + if vim.bo[s.result_buf].modified then + return notify("unsaved changes: drop them with :e! to delete", vim.log.levels.WARN) + end + -- confirm() runs scheduled callbacks while it blocks, so the watcher can close the session + if vim.fn.confirm(("Delete %s?"):format(s.path), "&Yes\n&No", 2) ~= 1 then + return + end + if require("differ.merge").current() ~= s then + return notify("the merge tool closed while the prompt was up; nothing was deleted") + end + if not require("differ.git").remove(s.root, s.path) then + return + end + s.removed = true + require("differ.merge.files").mark_staged(s) + show_deleted(s) +end + +-- taking the deleting side deletes the file, taking the other keeps it +---@param s differ.MergeSession +---@param choice "ours"|"theirs"|"both"|"base"|"none" +function M.resolve(s, choice) + if s.staged then + return notify(("%s is already resolved"):format(s.path)) + end + if choice ~= "ours" and choice ~= "theirs" then + return notify( + "a modify/delete conflict is kept or deleted: take ours or theirs", + vim.log.levels.WARN + ) + end + if choice == s.deleted then + return delete_file(s) + end + keep_file(s) +end + +return M diff --git a/lua/differ/merge/files.lua b/lua/differ/merge/files.lua new file mode 100644 index 0000000..258ed6c --- /dev/null +++ b/lua/differ/merge/files.lua @@ -0,0 +1,165 @@ +-- the merge tool's conflicted files: progress as they are staged, ]f/[f between them, and +-- closing the session once git moves on without it + +local notify = require("differ.git.exec").notify + +local M = {} + +-- conflicted paths the merge tool can't open (added by one side, deleted by both, binary) +---@param relpaths string[] +function M.report_unopenable(relpaths) + notify( + ("%s can't be opened in the merge tool: resolve with git add/rm"):format( + table.concat(relpaths, ", ") + ), + vim.log.levels.WARN + ) +end + +-- the first conflicted path the merge tool can open, with its model +---@param root string +---@param conflicted string[] +---@return string|nil relpath, differ.MergeModel|nil model +function M.first_openable(root, conflicted) + local build = require("differ.merge.model").build + for _, relpath in ipairs(conflicted) do + local model = build(root, relpath, nil) + if model then + return relpath, model + end + end + return nil, nil +end + +-- with no conflict left to open here, report done (pointing at :Differ continue when an +-- operation is in progress), or name the conflicts left for git add/rm +---@param root string +---@param conflicted string[] +local function report_progress(root, conflicted) + if #conflicted == 0 then + if require("differ.git").operation(root) then + notify("all conflicts resolved: :Differ continue") + else + notify("all conflicts resolved and staged") + end + return + end + if not M.first_openable(root, conflicted) then + M.report_unopenable(conflicted) + end +end + +-- mark this file resolved in the same tick as the index write, so the git-dir watcher +-- doesn't read it as resolved outside the session, and report progress +---@param s differ.MergeSession +function M.mark_staged(s) + s.staged = true + local conflicted = require("differ.git").conflicted(s.root) + s.left = #conflicted + report_progress(s.root, conflicted) +end + +-- the next/prev file in the session's list that is still conflicted and builds, wrapping +-- past the ends. staged files drop out of `conflicted`; unopenable ones fail the build +---@param s differ.MergeSession +---@param conflicted string[] +---@param direction "next"|"prev" +---@return string|nil relpath, differ.MergeModel|nil model, boolean wrapped +local function find_file(s, conflicted, direction) + local build = require("differ.merge.model").build + local n, from = #s.files, s.file_index + local step = 1 + if direction == "prev" then + step = -1 + end + for k = 1, n - 1 do + local i = (from - 1 + k * step) % n + 1 + local relpath = s.files[i] + if vim.tbl_contains(conflicted, relpath) then + local model = build(s.root, relpath, nil) + if model then + return relpath, model, (step == 1 and i < from) or (step == -1 and i > from) + end + end + end + return nil, nil, false +end + +-- ]f/[f: settle the result, then open the next/prev conflicted file in the session layout +---@param direction "next"|"prev" +function M.step(direction) + local merge = require("differ.merge") + local s = merge._live_session() + if not s or not merge.settle() then + return + end + local conflicted = require("differ.git").conflicted(s.root) + local relpath, model, wrapped = find_file(s, conflicted, direction) + if not relpath then + local others = vim.tbl_filter(function(p) + return p ~= s.path + end, conflicted) + if #conflicted == 0 and s.operation then + return notify("no conflicted files left: :Differ continue") + end + if #conflicted == 0 then + return notify("no conflicted files left") + end + if #others == 0 then + return notify("no other conflicted file") + end + return M.report_unopenable(others) + end + if wrapped and direction == "next" then + notify("wrapped to the first conflicted file") + elseif wrapped then + notify("wrapped to the last conflicted file") + end + merge._lay_out(s.root, relpath, assert(model), s.layout, s.files) +end + +-- why the session no longer matches git, or nil: HEAD or the operation changed (a +-- --continue, --abort or skip), or this file was resolved by something other than the session +---@param s differ.MergeSession +---@param conflicted string[] +---@return string|nil msg, integer|nil level +local function stale_reason(s, conflicted) + local git = require("differ.git") + if git.head_sha(s.root) ~= s.head or git.operation(s.root) ~= s.operation then + return "git moved on outside the merge tool", vim.log.levels.INFO + end + if s.staged or vim.tbl_contains(conflicted, s.path) then + return nil, nil + end + -- what git staged is the file on disk, which the buffer may not match + local ok, lines = pcall(vim.fn.readfile, s.root .. "/" .. s.path) + if not ok then + lines = {} + end + local conflict = require("differ.git.conflict") + if #conflict.parse(lines) > 0 or conflict.reformatted(lines) then + return s.path .. " was staged with conflict markers outside the merge tool", + vim.log.levels.WARN + end + return s.path .. " was resolved outside the merge tool", vim.log.levels.INFO +end + +-- the git dir changed: refresh the files left, and close once the session is stale +---@param this differ.MergeSession +function M.on_git_change(this) + local merge = require("differ.merge") + -- while :Differ continue runs git, its end decides, not the watcher + if merge.current() ~= this or require("differ.merge.continue").running() then + return + end + local conflicted = require("differ.git").conflicted(this.root) + this.left = #conflicted + local msg, level = stale_reason(this, conflicted) + if not msg then + return vim.cmd("redrawstatus!") + end + notify(msg, level) + merge.close() +end + +return M diff --git a/lua/differ/merge/init.lua b/lua/differ/merge/init.lua index 83f6e5c..758e433 100644 --- a/lua/differ/merge/init.lua +++ b/lua/differ/merge/init.lua @@ -19,6 +19,9 @@ local set_local = require("differ.util.win").set_local local keymap = require("differ.util.keymap") local tab_util = require("differ.util.tab") +local plural = require("differ.util.text").plural +local merge_files = require("differ.merge.files") +local markers_reformatted = require("differ.git.conflict").reformatted local bind, unbind = keymap.bind, keymap.unbind local M = {} @@ -74,7 +77,6 @@ local INPUT_HL = { ---@field head string|nil -- HEAD at session start; a move means the operation went on ---@field operation string|nil -- the in-progress operation at session start ---@field watcher differ.git.Watcher|nil -- the git dir watch for changes made outside the session ----@field running boolean|nil -- :Differ continue is running git; its end decides, not the watcher ---@field result_win integer ---@field result_buf integer -- the real worktree file (editable) ---@field bufs integer[] -- the scratch input buffers (deleted on close) @@ -139,26 +141,6 @@ local function notify(msg, level) vim.notify("differ: " .. msg, level or vim.log.levels.INFO) end --- a clean conflict marker sits at column 0; a format-on-save that ignores the buffer's --- disable_autoformat opt-out reflows the whole region and indents the markers with it. the --- parser then can't see the region (it matches at column 0), so it would read as resolved --- and stage a file that still has markers. an indented <<<<<<< / >>>>>>> run is the tell; --- `<`/`>` only, since a stray indented ======= can be decorative ----@param lines string[] ----@return boolean -local function markers_reformatted(lines) - for _, line in ipairs(lines) do - local ch = line:match("^%s+([<>])") - if ch then - local run = line:match("^%s+(%" .. ch .. "+)") - if run and #run >= 7 then - return true - end - end - end - return false -end - -- the active session, or nil. exposed so :Differ close can route to it ---@return differ.MergeSession|nil function M.current() @@ -602,18 +584,16 @@ local function region_at(regions, lnum) return nil end ----@type fun(choice: "ours"|"theirs"|"both"|"base"|"none") -local resolve_deleted - -- resolve the conflict under the cursor by splicing in the chosen slab, then re-derive + -- repaint, flash the produced lines, and advance to the next remaining conflict ---@param choice "ours"|"theirs"|"both"|"base"|"none" local function resolve_choice(choice) - if not live_session() then + local s = live_session() + if not s then return end - if session.deleted then - return resolve_deleted(choice) + if s.deleted then + return require("differ.merge.deleted").resolve(s, choice) end local regions = live_regions() if #regions == 0 then @@ -696,16 +676,6 @@ local function base_label() return label end ----@param n integer ----@param noun string ----@return string -local function plural(n, noun) - if n == 1 then - return "1 " .. noun - end - return ("%d %ss"):format(n, noun) -end - -- a winbar segment in `hl`, then back to the winbar's own colours ---@param text string ---@param hl string @@ -913,9 +883,6 @@ local function show_help() help.show(help.lines(rows), { title = " Differ: merge ", dismiss = dismiss }) end ----@type fun(root: string, relpath: string, model: differ.MergeModel, layout: "default"|"diff4", files: string[]) -local lay_out - -- the input pane labels, named by the first conflict's markers. a modify/delete file has -- none: HEAD is ours under every operation, but theirs has no one ref to name ---@param model differ.MergeModel @@ -933,49 +900,6 @@ local function side_labels(model) return labels end --- conflicted paths the merge tool can't open (added by one side, deleted by both, binary) ----@param relpaths string[] -local function report_unopenable(relpaths) - notify( - ("%s can't be opened in the merge tool: resolve with git add/rm"):format( - table.concat(relpaths, ", ") - ), - vim.log.levels.WARN - ) -end - --- with no conflict left to open here, report done (pointing at :Differ continue when an --- operation is in progress), or name the conflicts left for git add/rm ----@param root string ----@param conflicted string[] -local function report_progress(root, conflicted) - if #conflicted == 0 then - if require("differ.git").operation(root) then - notify("all conflicts resolved: :Differ continue") - else - notify("all conflicts resolved and staged") - end - return - end - local build = require("differ.merge.model").build - for _, relpath in ipairs(conflicted) do - if build(root, relpath, nil) then - return - end - end - report_unopenable(conflicted) -end - --- mark this file resolved in the same tick as the index write, so the git-dir watcher --- doesn't read it as resolved outside the session, and report progress ----@param s differ.MergeSession -local function mark_staged(s) - s.staged = true - local conflicted = require("differ.git").conflicted(s.root) - s.left = #conflicted - report_progress(s.root, conflicted) -end - -- git add the marker-free result. the winbar shows it staged; only the file's first stage -- reports progress, since an auto-save re-stages it on every later edit local function stage_result() @@ -985,7 +909,7 @@ local function stage_result() return end if not s.staged then - mark_staged(s) + merge_files.mark_staged(s) end end @@ -1050,64 +974,6 @@ local function settle_result() return false end --- the next/prev file in the session's list that is still conflicted and builds, wrapping --- past the ends. staged files drop out of `conflicted`; unopenable ones fail the build ----@param s differ.MergeSession ----@param conflicted string[] ----@param direction "next"|"prev" ----@return string|nil relpath, differ.MergeModel|nil model, boolean wrapped -local function find_file(s, conflicted, direction) - local build = require("differ.merge.model").build - local n, from = #s.files, s.file_index - local step = 1 - if direction == "prev" then - step = -1 - end - for k = 1, n - 1 do - local i = (from - 1 + k * step) % n + 1 - local relpath = s.files[i] - if vim.tbl_contains(conflicted, relpath) then - local model = build(s.root, relpath, nil) - if model then - return relpath, model, (step == 1 and i < from) or (step == -1 and i > from) - end - end - end - return nil, nil, false -end - --- ]f/[f: settle the result, then open the next/prev conflicted file in the session layout ----@param direction "next"|"prev" -local function step_file(direction) - local s = live_session() - if not s or not settle_result() then - return - end - local conflicted = require("differ.git").conflicted(s.root) - local relpath, model, wrapped = find_file(s, conflicted, direction) - if not relpath then - local others = vim.tbl_filter(function(p) - return p ~= s.path - end, conflicted) - if #conflicted == 0 and s.operation then - return notify("no conflicted files left: :Differ continue") - end - if #conflicted == 0 then - return notify("no conflicted files left") - end - if #others == 0 then - return notify("no other conflicted file") - end - return report_unopenable(others) - end - if wrapped and direction == "next" then - notify("wrapped to the first conflicted file") - elseif wrapped then - notify("wrapped to the last conflicted file") - end - lay_out(s.root, relpath, assert(model), s.layout, s.files) -end - -- the keys an input pane carries; also bound on the scratch buffer a delete leaves behind ---@param buf integer local function bind_pane(buf) @@ -1119,142 +985,21 @@ local function bind_pane(buf) goto_conflict("prev") end, "differ: previous conflict") bind(buf, km.next_file, function() - step_file("next") + merge_files.step("next") end, "differ: next conflicted file") bind(buf, km.prev_file, function() - step_file("prev") + merge_files.step("prev") end, "differ: previous conflicted file") bind(buf, km.help, show_help, "differ: keymap help") end --- keep a modify/delete file: write it as it stands, which stages it like any write -local function keep_file() - local s = assert(session) - local ok, err = pcall(vim.api.nvim_buf_call, s.result_buf, function() - vim.cmd("silent write") - end) - if not ok then - notify(tostring(err), vim.log.levels.ERROR) - end -end - --- after a delete the file is gone, so the result window shows an empty read-only buffer --- in place of the real one; a write there would recreate the file ----@param s differ.MergeSession -local function show_deleted(s) - local real = s.result_buf - release_result(s) - local buf = make_buffer("deleted", s.path, {}) - local notice = s.path .. " deletion staged" - -- acwrite, so a :w reaches the BufWriteCmd below rather than failing on the buftype - vim.bo[buf].buftype = "acwrite" - vim.api.nvim_create_autocmd("BufWriteCmd", { - buffer = buf, - group = s.aug, - callback = function() - notify(notice .. ": nothing to write") - end, - }) - s.bufs[#s.bufs + 1] = buf - s.result_buf, s.bound_keys = buf, {} - vim.api.nvim_win_set_buf(s.result_win, buf) - dress(s.result_win) - show_notice(s.result_win, buf, notice) - bind_pane(buf) - -- unloaded, so nvim doesn't report the file missing; a window outside the session keeps it - if #vim.fn.win_findbuf(real) == 0 then - pcall(vim.api.nvim_buf_delete, real, {}) - end -end - --- delete a modify/delete file with git rm, once confirmed -local function delete_file() - local s = assert(session) - if vim.bo[s.result_buf].modified then - return notify("unsaved changes: drop them with :e! to delete", vim.log.levels.WARN) - end - -- confirm() runs scheduled callbacks while it blocks, so the watcher can close the session - if vim.fn.confirm(("Delete %s?"):format(s.path), "&Yes\n&No", 2) ~= 1 then - return - end - if session ~= s then - return notify("the merge tool closed while the prompt was up; nothing was deleted") - end - if not require("differ.git").remove(s.root, s.path) then - return - end - s.removed = true - mark_staged(s) - show_deleted(s) -end - --- a modify/delete file: taking the deleting side deletes it, taking the other keeps it ----@param choice "ours"|"theirs"|"both"|"base"|"none" -function resolve_deleted(choice) - local s = assert(session) - if s.staged then - return notify(("%s is already resolved"):format(s.path)) - end - if choice ~= "ours" and choice ~= "theirs" then - return notify( - "a modify/delete conflict is kept or deleted: take ours or theirs", - vim.log.levels.WARN - ) - end - if choice == s.deleted then - return delete_file() - end - keep_file() -end - --- why the session no longer matches git, or nil: HEAD or the operation changed (a --- --continue, --abort or skip), or this file was resolved by something other than the session ----@param s differ.MergeSession ----@param conflicted string[] ----@return string|nil msg, integer|nil level -local function stale_reason(s, conflicted) - local git = require("differ.git") - if git.head_sha(s.root) ~= s.head or git.operation(s.root) ~= s.operation then - return "git moved on outside the merge tool", vim.log.levels.INFO - end - if s.staged or vim.tbl_contains(conflicted, s.path) then - return nil, nil - end - -- what git staged is the file on disk, which the buffer may not match - local ok, lines = pcall(vim.fn.readfile, s.root .. "/" .. s.path) - if not ok then - lines = {} - end - if #require("differ.git.conflict").parse(lines) > 0 or markers_reformatted(lines) then - return s.path .. " was staged with conflict markers outside the merge tool", - vim.log.levels.WARN - end - return s.path .. " was resolved outside the merge tool", vim.log.levels.INFO -end - --- the git dir changed: refresh the files left, and close once the session is stale ----@param this differ.MergeSession -local function on_git_change(this) - if session ~= this or this.running then - return - end - local conflicted = require("differ.git").conflicted(this.root) - this.left = #conflicted - local msg, level = stale_reason(this, conflicted) - if not msg then - return vim.cmd("redrawstatus!") - end - notify(msg, level) - M.close() -end - -- lay out the render in a fresh session tab and wire navigation ---@param root string ---@param relpath string ---@param model differ.MergeModel ---@param layout "default"|"diff4" ---@param files string[] -function lay_out(root, relpath, model, layout, files) +local function lay_out(root, relpath, model, layout, files) local git = require("differ.git") if session then -- re-open over a live session M.close() @@ -1429,10 +1174,10 @@ function lay_out(root, relpath, model, layout, files) resolve_choice("none") end, "differ: drop the conflict") rb("next_file", function() - step_file("next") + merge_files.step("next") end, "differ: next conflicted file") rb("prev_file", function() - step_file("prev") + merge_files.step("prev") end, "differ: previous conflicted file") rb("help", show_help, "differ: keymap help") -- the input panes are scratch buffers deleted on close, so these need no unbind @@ -1451,7 +1196,7 @@ function lay_out(root, relpath, model, layout, files) session.watcher = require("differ.git.watch").new({ git_dir = git_dir, on_change = function() - on_git_change(this) + merge_files.on_git_change(this) end, }) end @@ -1544,40 +1289,30 @@ function M.settle() return settle_result() end --- hold the session open while differ runs git, so the git-dir watcher doesn't close it -function M.hold() - if session then - session.running = true - end -end - -- once differ's own continue, skip or abort ends: open the first conflict it stopped on, -- else close the session when the operation is over, else keep it with git's new HEAD ---@param root string function M.after_operation(root) local git = require("differ.git") local s = session + local operation = git.operation(root) if s then - s.running = false - s.head, s.operation = git.head_sha(root), git.operation(root) + s.head, s.operation = git.head_sha(root), operation reload_result(s) end - if not git.operation(root) then + if not operation then return M.close() end local conflicted = git.conflicted(root) if #conflicted == 0 then return end - local build = require("differ.merge.model").build - for _, relpath in ipairs(conflicted) do - local model = build(root, relpath, nil) - if model then - local layout = s and s.layout or require("differ").get_config().merge.layout - return lay_out(root, relpath, model, layout or "default", conflicted) - end + local relpath, model = merge_files.first_openable(root, conflicted) + if relpath then + local layout = s and s.layout or require("differ").get_config().merge.layout + return lay_out(root, relpath, assert(model), layout or "default", conflicted) end - report_unopenable(conflicted) + merge_files.report_unopenable(conflicted) M.close() end @@ -1607,7 +1342,7 @@ function M.open(opts) local function go(relpath) local model = require("differ.merge.model").build(root, relpath, nil) if not model then - return report_unopenable({ relpath }) + return merge_files.report_unopenable({ relpath }) end lay_out(root, relpath, model, layout, conflicted) end @@ -1646,4 +1381,9 @@ function M.open(opts) end) end +-- for merge/files.lua and merge/deleted.lua +M._live_session, M._lay_out, M._bind_pane = live_session, lay_out, bind_pane +M._make_buffer, M._release_result = make_buffer, release_result +M._dress, M._show_notice = dress, show_notice + return M diff --git a/lua/differ/panel/init.lua b/lua/differ/panel/init.lua index 78220c9..e9e0fb8 100644 --- a/lua/differ/panel/init.lua +++ b/lua/differ/panel/init.lua @@ -54,6 +54,16 @@ local STATUS_HL = { ["?"] = "differPanelUntracked", } +-- rows painted from column 0 to the line's end, or to the operation row's verb_end +---@type table +local LINE_HL = { + root = "differPanelRoot", + operation = "differPanelOperation", + continue = "differPanelContinue", + help = "differPanelHelp", + footrev = "differPanelHelp", +} + -- `one` takes the path; `many` the names of the files that lose content ---@class differ.panel.DropPrompt ---@field one string @@ -97,10 +107,8 @@ local STATUS_HL = { ---@field on_refresh fun()|nil ---@field on_staged fun(paths: table|nil)|nil ---@field root string|nil ----@field footer string|nil ----@field footer_source string|fun(): string|nil ----@field operation fun(): string|nil ----@field operation_label string|nil +---@field chrome_sources { footer?: string|(fun(): string|nil), operation?: (fun(): string|nil) } +---@field chrome { footer?: string, operation?: string } -- chrome_sources as last read ---@field actions differ.panel.Actions|nil ---@field keymaps table -- session-supplied buffer maps (e.g. pr viewed nav) the generic panel doesn't own @@ -205,8 +213,8 @@ function Panel.new(opts) on_refresh = opts.on_refresh, on_staged = opts.on_staged, root = opts.root, - footer_source = opts.footer, - operation = opts.operation or function() end, + chrome_sources = { footer = opts.footer, operation = opts.operation }, + chrome = {}, actions = opts.actions, extra_keymaps = opts.extra_keymaps, on_step = opts.on_step, @@ -334,7 +342,7 @@ function Panel:render() self.add_total, self.del_total = add_total, del_total local header ---@type differ.panel.Header|nil if self.root then - header = { path = self.root, operation = self.operation_label, help = "g?" } + header = { path = self.root, operation = self.chrome.operation, help = "g?" } end -- live window width, falling back to the configured width when the window -- isn't open yet (headless construction / tests) @@ -349,7 +357,7 @@ function Panel:render() local horizontal = self.position == "top" or self.position == "bottom" local out, width if horizontal then - out = render.lines(blocks, header, self.icon_for, self.footer, nil) + out = render.lines(blocks, header, self.icon_for, self.chrome.footer, nil) local needed = 0 for i, m in ipairs(out.meta) do if m.kind == "file" then @@ -364,11 +372,11 @@ function Panel:render() -- the untruncated render stands unless the content overflows the window, in -- which case re-render truncated to the live edge if width < needed then - out = render.lines(blocks, header, self.icon_for, self.footer, width) + out = render.lines(blocks, header, self.icon_for, self.chrome.footer, width) end else width = live - out = render.lines(blocks, header, self.icon_for, self.footer, width) + out = render.lines(blocks, header, self.icon_for, self.chrome.footer, width) end self.content_width = width self.lines, self.meta = out.lines, out.meta @@ -402,38 +410,17 @@ function Panel:_highlight() for i, m in ipairs(self.meta) do local row = i - 1 local eol = #self.lines[i] - if m.kind == "root" then - vim.api.nvim_buf_set_extmark( - self.bufnr, - ns, - row, - 0, - { end_col = eol, hl_group = "differPanelRoot" } - ) - elseif m.kind == "operation" then - vim.api.nvim_buf_set_extmark( - self.bufnr, - ns, - row, - 0, - { end_col = m.verb_end or eol, hl_group = "differPanelOperation" } - ) - elseif m.kind == "continue" then + local line_hl = LINE_HL[m.kind] + if line_hl then vim.api.nvim_buf_set_extmark( self.bufnr, ns, row, 0, - { end_col = eol, hl_group = "differPanelContinue" } - ) - elseif m.kind == "help" then - vim.api.nvim_buf_set_extmark( - self.bufnr, - ns, - row, - 0, - { end_col = eol, hl_group = "differPanelHelp" } + { end_col = m.verb_end or eol, hl_group = line_hl } ) + end + if m.kind == "help" then -- the diff --stat totals ride the (otherwise sparse) help line, pinned to -- the same content column as the per-file counts so they stack directly -- above them as a column total. virt_text, not line text, so it can't be @@ -473,14 +460,6 @@ function Panel:_highlight() { end_col = m.prefix_end, hl_group = "differPanelContext" } ) end - elseif m.kind == "footrev" then - vim.api.nvim_buf_set_extmark( - self.bufnr, - ns, - row, - 0, - { end_col = eol, hl_group = "differPanelHelp" } - ) elseif m.kind == "dir" then vim.api.nvim_buf_set_extmark( self.bufnr, @@ -553,12 +532,12 @@ function Panel:_highlight() end function Panel:_read_chrome() - local footer = self.footer_source - if type(footer) == "function" then - footer = footer() + for key, source in pairs(self.chrome_sources) do + if type(source) == "function" then + source = source() + end + self.chrome[key] = source end - self.footer = footer - self.operation_label = self.operation() end -- replace the file-list model and repaint (used on refresh / source change) diff --git a/lua/differ/pr/checks.lua b/lua/differ/pr/checks.lua index 613f760..d0e26fd 100644 --- a/lua/differ/pr/checks.lua +++ b/lua/differ/pr/checks.lua @@ -4,6 +4,7 @@ local client = require("differ.pr.client") local set_wo = require("differ.util.win").set_local +local open_centred = require("differ.util.win").open_centred local M = {} @@ -112,14 +113,8 @@ local function render(checks) local title = (" checks · %s "):format( (rollup ~= nil and rollup ~= "") and rollup or "no rollup" ) - local win = vim.api.nvim_open_win(buf, true, { - relative = "editor", - width = width + 1, - height = math.min(#lines, math.max(1, vim.o.lines - 6)), - row = math.floor((vim.o.lines - #lines) / 2), - col = math.floor((vim.o.columns - width) / 2), - style = "minimal", - border = "rounded", + local height = math.min(#lines, math.max(1, vim.o.lines - 6)) + local win = open_centred(buf, width + 1, height, { title = title, title_pos = "center", }) diff --git a/lua/differ/ui/foldtext.lua b/lua/differ/ui/foldtext.lua index 6aeddf2..c9c5116 100644 --- a/lua/differ/ui/foldtext.lua +++ b/lua/differ/ui/foldtext.lua @@ -7,8 +7,8 @@ local M = {} function M.render() local count = vim.v.foldend - vim.v.foldstart + 1 local first = vim.fn.getline(vim.v.foldstart):gsub("^%s+", "") - local plural = count == 1 and "" or "s" - return ("\u{22ef} %d unchanged line%s: %s"):format(count, plural, first) + local lines = require("differ.util.text").plural(count, "unchanged line") + return ("\u{22ef} %s: %s"):format(lines, first) end return M diff --git a/lua/differ/ui/help.lua b/lua/differ/ui/help.lua index 2d2c1ee..c4d5008 100644 --- a/lua/differ/ui/help.lua +++ b/lua/differ/ui/help.lua @@ -64,14 +64,7 @@ function M.show(lines, opts) vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) vim.bo[buf].modifiable = false vim.bo[buf].bufhidden = "wipe" - local win = vim.api.nvim_open_win(buf, true, { - relative = "editor", - width = width + 1, - height = #lines, - row = math.floor((vim.o.lines - #lines) / 2), - col = math.floor((vim.o.columns - width) / 2), - style = "minimal", - border = "rounded", + local win = require("differ.util.win").open_centred(buf, width + 1, #lines, { title = opts.title or " Differ ", }) local function close() diff --git a/lua/differ/util/date.lua b/lua/differ/util/date.lua index 85a7db4..c338065 100644 --- a/lua/differ/util/date.lua +++ b/lua/differ/util/date.lua @@ -16,7 +16,7 @@ local YEAR = 365 * DAY ---@param unit string ---@return string local function ago(n, unit) - return n .. " " .. unit .. (n == 1 and "" or "s") .. " ago" + return require("differ.util.text").plural(n, unit) .. " ago" end -- a coarse "N ago" for `epoch`, measured against `now` (defaults to the diff --git a/lua/differ/util/text.lua b/lua/differ/util/text.lua index 6be6423..a755fef 100644 --- a/lua/differ/util/text.lua +++ b/lua/differ/util/text.lua @@ -54,6 +54,17 @@ function M.truncate_end(s, max) return s:sub(1, max - 1) .. "…" -- "…" is one display column end +-- `n noun`, with an s on the noun unless n is 1 +---@param n integer +---@param noun string +---@return string +function M.plural(n, noun) + if n == 1 then + return "1 " .. noun + end + return ("%d %ss"):format(n, noun) +end + -- set on a last line with no newline after it, so `c` and `c\n` compare unequal as they -- do in git's diff. a NUL never reaches a line: is_binary text isn't read by line M.NO_EOL = "\0" diff --git a/lua/differ/util/win.lua b/lua/differ/util/win.lua index 293c754..9affd8f 100644 --- a/lua/differ/util/win.lua +++ b/lua/differ/util/win.lua @@ -13,4 +13,23 @@ function M.set_local(win, opt, val) vim.api.nvim_set_option_value(opt, val, { scope = "local", win = win }) end +-- a minimal, rounded float of `width` x `height` in the middle of the editor, focused +---@param buf integer +---@param width integer +---@param height integer +---@param opts table|nil extra win config, e.g. the title +---@return integer win +function M.open_centred(buf, width, height, opts) + local config = vim.tbl_extend("force", { + relative = "editor", + width = width, + height = height, + row = math.floor((vim.o.lines - height) / 2), + col = math.floor((vim.o.columns - width) / 2), + style = "minimal", + border = "rounded", + }, opts or {}) + return vim.api.nvim_open_win(buf, true, config) +end + return M diff --git a/test/nvim/operation_spec.lua b/test/nvim/operation_spec.lua index 846bcc1..858c20f 100644 --- a/test/nvim/operation_spec.lua +++ b/test/nvim/operation_spec.lua @@ -410,14 +410,14 @@ describe(":Differ continue", function() end) local cases = { - { "merge", { "continue", "continue and edit the message", "abort", "cancel" } }, - { "rebase", { "continue", "continue and edit the message", "skip", "abort", "cancel" } }, - { "am", { "continue", "skip", "abort", "cancel" } }, + { "merge", { "continue", "continue and edit the message", "abort" } }, + { "rebase", { "continue", "continue and edit the message", "skip", "abort" } }, + { "am", { "continue", "skip", "abort" } }, { "cherry-pick", - { "continue", "continue and edit the message", "skip", "abort", "cancel" }, + { "continue", "continue and edit the message", "skip", "abort" }, }, - { "revert", { "continue", "continue and edit the message", "skip", "abort", "cancel" } }, + { "revert", { "continue", "continue and edit the message", "skip", "abort" } }, } for _, case in ipairs(cases) do it("offers " .. case[1] .. "'s options", function() From a5290cf96a696211761bbb1af503d4914abd1226 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:59:43 +0100 Subject: [PATCH 12/25] test: cover skip/abort per operation and modify/delete failures, tidy merge specs --- test/nvim/mergetool_spec.lua | 249 +++++++++++++++++++---------------- test/nvim/operation_spec.lua | 137 ++++++++++++++----- 2 files changed, 243 insertions(+), 143 deletions(-) diff --git a/test/nvim/mergetool_spec.lua b/test/nvim/mergetool_spec.lua index b22ef08..f5dc868 100644 --- a/test/nvim/mergetool_spec.lua +++ b/test/nvim/mergetool_spec.lua @@ -536,6 +536,34 @@ local function has_marker(buf) return false end +local function take_ours_and_write(s) + fire(s.result_buf, "differ: take ours") + vim.api.nvim_set_current_win(s.result_win) + vim.cmd("silent write") +end + +-- run `fn`, then wait for the session's git-dir watcher to handle a change +local function after_git_change(s, fn) + local files = require("differ.merge.files") + local on_git_change, seen = files.on_git_change, false + files.on_git_change = function(this) + if this == s then + seen = true + end + return on_git_change(this) + end + local ok, err = pcall(fn) + local handled = false + if ok then + handled = vim.wait(3000, function() + return seen + end) + end + files.on_git_change = on_git_change + assert(ok, err) + assert(handled, "the watcher never handled a change") +end + describe(":Differ mergetool resolution", function() after_each(function() if merge.current() then @@ -573,36 +601,17 @@ describe(":Differ mergetool resolution", function() vim.cmd.edit(root .. "/f.txt") merge.open({}) local s = merge.current() - fire(s.result_buf, "differ: take ours") - vim.api.nvim_set_current_win(s.result_win) - vim.cmd("silent write") + take_ours_and_write(s) assert.are.same({}, require("differ.git").conflicted(root)) end) - it("stays on the file once a write stages it", function() - local root = conflict_repo_two() - vim.cmd.edit(root .. "/f.txt") - merge.open({}) - local s = merge.current() - fire(s.result_buf, "differ: take ours") - vim.api.nvim_set_current_win(s.result_win) - vim.cmd("silent write") - vim.wait(200, function() - return merge.current() ~= s - end) - assert.are.equal(s, merge.current()) - assert.are.same({ "g.txt" }, require("differ.git").conflicted(root)) - end) - it("stages and re-stages quietly, leaving progress to the winbar", function() local root = conflict_repo_two() vim.cmd.edit(root .. "/f.txt") merge.open({}) local s = merge.current() _G.notifs = {} - fire(s.result_buf, "differ: take ours") - vim.api.nvim_set_current_win(s.result_win) - vim.cmd("silent write") + take_ours_and_write(s) vim.api.nvim_buf_set_lines(s.result_buf, 0, 0, false, { "extra" }) vim.cmd("silent write") assert.are.same({}, _G.notifs) @@ -620,9 +629,7 @@ describe(":Differ mergetool resolution", function() return false end local ok, err = pcall(function() - fire(s.result_buf, "differ: take ours") - vim.api.nvim_set_current_win(s.result_win) - vim.cmd("silent write") + take_ours_and_write(s) -- a marker-free result left unstaged couldn't be reopened, so ]f holds too fire(s.result_buf, "differ: next conflicted file") end) @@ -638,9 +645,7 @@ describe(":Differ mergetool resolution", function() merge.open({}) local s = merge.current() _G.notifs = {} - fire(s.result_buf, "differ: take ours") - vim.api.nvim_set_current_win(s.result_win) - vim.cmd("silent write") + take_ours_and_write(s) assert.are.equal(s, merge.current()) assert.are.equal( "differ: all conflicts resolved: :Differ continue", @@ -655,9 +660,7 @@ describe(":Differ mergetool resolution", function() merge.open({}) local s = merge.current() _G.notifs = {} - fire(s.result_buf, "differ: take ours") - vim.api.nvim_set_current_win(s.result_win) - vim.cmd("silent write") + take_ours_and_write(s) assert.are.equal(s, merge.current()) local last = _G.notifs[#_G.notifs] assert.are.equal( @@ -669,6 +672,20 @@ describe(":Differ mergetool resolution", function() end end) + it("closes, naming them, when an operation stops only on conflicts it can't open", function() + local root = conflict_repo_binary({ "f.txt" }) + vim.cmd.edit(root .. "/f.txt") + merge.open({}) + git_ok(root, "add", "f.txt") + _G.notifs = {} + merge.after_operation(root) + assert.is_nil(merge.current()) + assert.are.equal( + "differ: a.bin can't be opened in the merge tool: resolve with git add/rm", + _G.notifs[#_G.notifs].msg + ) + end) + it("says why when asked to open a conflict it can't", function() local root = conflict_repo_binary({ "f.txt" }) vim.cmd.edit(root .. "/f.txt") @@ -799,36 +816,24 @@ describe(":Differ mergetool in-progress operation", function() end) ---@param op string - ---@return string[] messages + ---@return string message the last notify local function resolve_last(op) local root = operation_repo(op) vim.cmd.edit(root .. "/f.txt") merge.open({}) local s = merge.current() - local orig_notify = vim.notify - local messages = {} - vim.notify = function(msg) - messages[#messages + 1] = msg - end - local ok, err = pcall(function() - fire(s.result_buf, "differ: take ours") - vim.api.nvim_set_current_win(s.result_win) - vim.cmd("silent write") - end) - vim.notify = orig_notify - assert(ok, err) + _G.notifs = {} + take_ours_and_write(s) assert.are.equal(s, merge.current()) - return messages + return _G.notifs[#_G.notifs].msg end it("points at :Differ continue once the last file is written", function() - local messages = resolve_last("rebase") - assert.are.equal("differ: all conflicts resolved: :Differ continue", messages[#messages]) + assert.are.equal("differ: all conflicts resolved: :Differ continue", resolve_last("rebase")) end) it("reports plain done when no operation is in progress", function() - local messages = resolve_last("stash") - assert.are.equal("differ: all conflicts resolved and staged", messages[#messages]) + assert.are.equal("differ: all conflicts resolved and staged", resolve_last("stash")) end) end) @@ -956,9 +961,7 @@ describe(":Differ mergetool file stepping", function() local g = merge.current() vim.g.statusline_winid = g.result_win assert.is_truthy(merge.winbar():find("RESULT · file 2/2", 1, true)) - fire(g.result_buf, "differ: take ours") - vim.api.nvim_set_current_win(g.result_win) - vim.cmd("silent write") + take_ours_and_write(g) assert.are.equal( "RESULT · file 2/2 · staged · all conflicts resolved · :Differ continue", plain(merge.winbar()) @@ -1025,9 +1028,8 @@ describe(":Differ mergetool changes outside the session", function() local root = conflict_repo_two() local s = open_two(root) assert.are.equal(2, s.left) - git_ok(root, "add", "g.txt") - vim.wait(3000, function() - return s.left == 1 + after_git_change(s, function() + git_ok(root, "add", "g.txt") end) assert.are.equal(1, s.left) assert.are.equal(s, merge.current()) @@ -1038,9 +1040,7 @@ describe(":Differ mergetool changes outside the session", function() vim.cmd.edit(root .. "/f.txt") merge.open({}) local s = merge.current() - fire(s.result_buf, "differ: take ours") - vim.api.nvim_set_current_win(s.result_win) - vim.cmd("silent write") + take_ours_and_write(s) _G.notifs = {} git_ok(root, "commit", "-q", "--no-edit") wait_closed_with("differ: git moved on outside the merge tool") @@ -1049,14 +1049,12 @@ describe(":Differ mergetool changes outside the session", function() it("stays open through its own stage", function() local root = conflict_repo_two() local s = open_two(root) - fire(s.result_buf, "differ: take ours") - vim.api.nvim_set_current_win(s.result_win) - vim.cmd("silent write") - vim.wait(300, function() - return merge.current() ~= s + after_git_change(s, function() + take_ours_and_write(s) end) assert.are.equal(s, merge.current()) assert.are.equal(1, s.left) + assert.are.same({ "g.txt" }, require("differ.git").conflicted(root)) end) end) @@ -1586,9 +1584,7 @@ describe(":Differ mergetool take-base anchoring", function() -- and through to the index: :w auto-stages once no markers remain, so the wrong -- bytes were committable without another gesture vim.api.nvim_win_set_cursor(s.result_win, { nth_marker_row(s.result_buf, 1), 0 }) - fire(s.result_buf, "differ: take ours") - vim.api.nvim_set_current_win(s.result_win) - vim.cmd("silent write") + take_ours_and_write(s) local staged = git_ok(root, "show", ":f.txt") assert.is_truthy(staged:find("base15", 1, true)) assert.is_nil(staged:find("base5\n", 1, true)) @@ -2126,6 +2122,59 @@ describe(":Differ mergetool modify/delete", function() assert.is_false(s.staged) end) + it("leaves the file conflicted when git rm fails", function() + local root = conflict_repo_modify_delete({ "f.txt" }) + vim.cmd.edit(root .. "/f.txt") + local s = open_at(root, "a.txt") + local real = s.result_buf + local git_mod = require("differ.git") + local remove = git_mod.remove + git_mod.remove = function() + return false + end + local ok, err = pcall(confirming, 1, function() + fire(s.result_buf, "differ: take theirs") + end) + git_mod.remove = remove + assert(ok, err) + assert.are.equal("UD a.txt", status(root, "a.txt")) + assert.is_false(s.staged) + assert.are.equal(real, s.result_buf) + end) + + it("says why when keeping the file can't write it", function() + local root = conflict_repo_modify_delete({ "f.txt" }) + vim.cmd.edit(root .. "/f.txt") + local s = open_at(root, "a.txt") + vim.fn.setfperm(root .. "/a.txt", "r--r--r--") + _G.notifs = {} + fire(s.result_buf, "differ: take ours") + assert.are.equal(vim.log.levels.ERROR, _G.notifs[#_G.notifs].level) + assert.are.equal("UD a.txt", status(root, "a.txt")) + assert.is_false(s.staged) + end) + + it("deletes nothing when the merge tool closed while the prompt was up", function() + local root = conflict_repo_modify_delete({ "f.txt" }) + vim.cmd.edit(root .. "/f.txt") + local s = open_at(root, "a.txt") + _G.notifs = {} + confirming(1, function() + local confirm = vim.fn.confirm + vim.fn.confirm = function(msg) + merge.close() + return confirm(msg) + end + fire(s.result_buf, "differ: take theirs") + end) + assert.are.equal( + "differ: the merge tool closed while the prompt was up; nothing was deleted", + _G.notifs[#_G.notifs].msg + ) + assert.are.equal("UD a.txt", status(root, "a.txt")) + assert.are.equal(1, vim.fn.filereadable(root .. "/a.txt")) + end) + it("refuses to delete over unsaved edits", function() local root = conflict_repo_modify_delete({ "f.txt" }) vim.cmd.edit(root .. "/f.txt") @@ -2144,11 +2193,10 @@ describe(":Differ mergetool modify/delete", function() local root = conflict_repo_modify_delete({ "f.txt" }) vim.cmd.edit(root .. "/f.txt") local s = open_at(root, "a.txt") - confirming(1, function() - fire(s.result_buf, "differ: take theirs") - end) - vim.wait(300, function() - return false + after_git_change(s, function() + confirming(1, function() + fire(s.result_buf, "differ: take theirs") + end) end) assert.are.equal(s, merge.current()) end) @@ -2202,47 +2250,28 @@ describe(":Differ mergetool modify/delete", function() assert.are.equal("f.txt", merge.current().path) end) - it("fires the done notify when a delete resolves the last conflict", function() - local root = conflict_repo_modify_delete({ "f.txt" }) - vim.cmd.edit(root .. "/f.txt") - local s = open_at(root, "f.txt") - fire(s.result_buf, "differ: take ours") - vim.api.nvim_set_current_win(s.result_win) - vim.cmd("silent write") - fire(s.result_buf, "differ: next conflicted file") - local a = merge.current() - assert.are.equal("a.txt", a.path) - _G.notifs = {} - confirming(1, function() - fire(a.result_buf, "differ: take theirs") + for _, case in ipairs({ { "delete", "theirs", "deleted" }, { "keep", "ours", "staged" } }) do + it("fires the done notify when a " .. case[1] .. " resolves the last conflict", function() + local root = conflict_repo_modify_delete({ "f.txt" }) + vim.cmd.edit(root .. "/f.txt") + local s = open_at(root, "f.txt") + take_ours_and_write(s) + fire(s.result_buf, "differ: next conflicted file") + local a = merge.current() + assert.are.equal("a.txt", a.path) + _G.notifs = {} + confirming(1, function() + fire(a.result_buf, "differ: take " .. case[2]) + end) + assert.are.equal( + "differ: all conflicts resolved: :Differ continue", + _G.notifs[#_G.notifs].msg + ) + vim.g.statusline_winid = a.result_win + local bar = "RESULT · file 1/2 · %s · all conflicts resolved · :Differ continue" + assert.are.equal(bar:format(case[3]), plain(merge.winbar())) end) - assert.are.equal( - "differ: all conflicts resolved: :Differ continue", - _G.notifs[#_G.notifs].msg - ) - vim.g.statusline_winid = a.result_win - assert.are.equal( - "RESULT · file 1/2 · deleted · all conflicts resolved · :Differ continue", - plain(merge.winbar()) - ) - end) - - it("fires the done notify when a keep resolves the last conflict", function() - local root = conflict_repo_modify_delete({ "f.txt" }) - vim.cmd.edit(root .. "/f.txt") - local s = open_at(root, "f.txt") - fire(s.result_buf, "differ: take ours") - vim.api.nvim_set_current_win(s.result_win) - vim.cmd("silent write") - fire(s.result_buf, "differ: next conflicted file") - local a = merge.current() - _G.notifs = {} - fire(a.result_buf, "differ: take ours") - assert.are.equal( - "differ: all conflicts resolved: :Differ continue", - _G.notifs[#_G.notifs].msg - ) - end) + end it("says a resolved modify/delete file is already resolved", function() local root = conflict_repo_modify_delete({ "f.txt" }) diff --git a/test/nvim/operation_spec.lua b/test/nvim/operation_spec.lua index 858c20f..e4b2a21 100644 --- a/test/nvim/operation_spec.lua +++ b/test/nvim/operation_spec.lua @@ -53,9 +53,11 @@ local function diverged_repo() end -- stopped on the first conflict of `op`; revert undoes feature's two commits on top of a --- third that rewrites both files +-- third that rewrites both files. `single` picks or reverts only the first commit, which +-- git runs without a sequencer ---@param op "merge"|"rebase"|"rebase-apply"|"am"|"cherry-pick"|"revert" -local function stopped(op) +---@param single boolean|nil +local function stopped(op, single) local root = diverged_repo() if op == "merge" then git(root, "merge", "--no-edit", "feature") -- --no-edit isn't saved for the continue @@ -70,13 +72,21 @@ local function stopped(op) write(patch, git_ok(root, "format-patch", "-2", "--stdout", "feature")) git(root, "am", "-3", patch) elseif op == "cherry-pick" then - git(root, "cherry-pick", "feature~1", "feature") + local picks = { "feature~1", "feature" } + if single then + picks = { "feature~1" } + end + git(root, "cherry-pick", unpack(picks)) elseif op == "revert" then git_ok(root, "checkout", "-q", "feature") write(root .. "/f.txt", "later\n") write(root .. "/g.txt", "later\n") git_ok(root, "commit", "-q", "-am", "later") - git(root, "revert", "--no-edit", "HEAD~2", "HEAD~1") + local reverts = { "HEAD~2", "HEAD~1" } + if single then + reverts = { "HEAD~2" } + end + git(root, "revert", "--no-edit", unpack(reverts)) end assert(#git_src.conflicted(root) > 0, op .. " didn't stop on a conflict") return root @@ -233,19 +243,63 @@ describe("git operation runner", function() assert.is_not_nil(git_src.operation(root)) end) - it("skips a rebase commit", function() - local root = stopped("rebase") - local res = run(root, "skip") - assert.is_false(res.ok) -- the next commit stops too - assert.are.same({ "g.txt" }, git_src.conflicted(root)) - end) + for _, op in ipairs({ "rebase", "am", "cherry-pick", "revert" }) do + it("skips a " .. op .. " commit", function() + local root = stopped(op) + local res = run(root, "skip") + assert.is_false(res.ok) -- the next commit stops too + assert.are.same({ "g.txt" }, git_src.conflicted(root)) + assert.are.equal(op, git_src.operation(root)) + end) + end - it("aborts a merge", function() - local root = stopped("merge") - local head = git_ok(root, "rev-parse", "main") - local res = run(root, "abort") - assert.is_true(res.ok, res.output) - assert.is_nil(git_src.operation(root)) + for _, op in ipairs({ "merge", "rebase", "am", "cherry-pick", "revert" }) do + it("aborts a " .. op, function() + local root = stopped(op) + -- a rebase stops detached on main; aborting goes back to feature + local head = git_ok(root, "rev-parse", "HEAD") + if op == "rebase" then + head = git_ok(root, "rev-parse", "feature") + end + local res = run(root, "abort") + assert.is_true(res.ok, res.output) + assert.is_nil(git_src.operation(root)) + assert.are.equal(head, git_ok(root, "rev-parse", "HEAD")) + assert.are.same({}, git_src.conflicted(root)) + end) + end + + for _, op in ipairs({ "cherry-pick", "revert" }) do + it("continues a single-commit " .. op, function() + local root = stopped(op, true) + assert.is_nil(vim.uv.fs_stat(root .. "/.git/sequencer")) + resolve_all(root) + local res = run(root, "continue") + assert.is_true(res.ok, res.output) + assert.is_nil(git_src.operation(root)) + end) + + it("continues a single-commit " .. op .. " with an edited message", function() + local root = stopped(op, true) + resolve_all(root) + local res = run(root, "continue", { message = "edited\n" }) + assert.is_true(res.ok, res.output) + assert.is_nil(git_src.operation(root)) + assert.are.equal("edited", subject(root)) + end) + end + + it("stops before the --continue when the edited commit fails", function() + local root = stopped("cherry-pick") + resolve_all(root) + local head = git_ok(root, "rev-parse", "HEAD") + local hook = root .. "/.git/hooks/commit-msg" + write(hook, "#!/bin/sh\necho 'hook says no' >&2\nexit 1\n") + vim.fn.setfperm(hook, "rwx------") + local res = run(root, "continue", { message = "edited\n" }) + assert.is_false(res.ok) + assert.matches("hook says no", res.output) + assert.are.equal("cherry-pick", git_src.operation(root)) assert.are.equal(head, git_ok(root, "rev-parse", "HEAD")) end) @@ -351,15 +405,13 @@ end) describe(":Differ continue", function() local command = require("differ.command") - local saved, notes, shown, chosen + local continue = require("differ.merge.continue") + local saved, shown, chosen -- stub the picker, choosing the entry labelled `chosen` (nil chooses nothing) before_each(function() - saved = { select = vim.ui.select, notify = vim.notify, confirm = vim.fn.confirm } - notes, shown, chosen = {}, nil, nil - vim.notify = function(msg, level) - notes[#notes + 1] = { msg = msg, level = level } - end + saved = { select = vim.ui.select, confirm = vim.fn.confirm } + _G.notifs, shown, chosen = {}, nil, nil vim.ui.select = function(items, opts, on_choice) shown = { prompt = opts.prompt, labels = vim.tbl_map(opts.format_item, items) } for _, item in ipairs(items) do @@ -372,7 +424,7 @@ describe(":Differ continue", function() end) after_each(function() - vim.ui.select, vim.notify, vim.fn.confirm = saved.select, saved.notify, saved.confirm + vim.ui.select, vim.fn.confirm = saved.select, saved.confirm if merge.current() then merge.close() end @@ -385,21 +437,21 @@ describe(":Differ continue", function() end local function noted(pattern) - for _, n in ipairs(notes) do + for _, n in ipairs(_G.notifs) do if n.msg:find(pattern) then return n end end end + -- the run is reported in the same callback that clears running() local function finished(root) assert( vim.wait(10000, function() - return git_src.operation(root) == nil + return git_src.operation(root) == nil and not continue.running() end), "the operation never finished" ) - vim.wait(100) -- the scheduled report end it("says there's nothing in progress", function() @@ -441,8 +493,8 @@ describe(":Differ continue", function() in_repo(root) chosen = "continue" command.dispatch({ "continue" }) - vim.wait(200) assert.are.equal(vim.log.levels.WARN, noted("2 conflicted files left").level) + assert.is_false(continue.running()) assert.is_not_nil(git_src.operation(root)) end) @@ -509,7 +561,7 @@ describe(":Differ continue", function() chosen = "continue and edit the message" command.dispatch({ "continue" }) vim.cmd("bwipeout!") - vim.wait(200) + assert.is_false(continue.running()) assert.is_not_nil(git_src.operation(root)) end) @@ -521,7 +573,7 @@ describe(":Differ continue", function() return 2 end command.dispatch({ "continue" }) - vim.wait(200) + assert.is_false(continue.running()) assert.is_not_nil(git_src.operation(root)) vim.fn.confirm = function() return 1 @@ -545,6 +597,24 @@ describe(":Differ continue", function() return noted("outside the merge tool") end + -- touch the git dir and wait for the session's watcher to handle it + local function after_git_change(s, root) + local files = require("differ.merge.files") + local on_git_change, seen = files.on_git_change, false + files.on_git_change = function(this) + if this == s then + seen = true + end + return on_git_change(this) + end + git_ok(root, "config", "differ.test", "touched") + local handled = vim.wait(3000, function() + return seen + end) + files.on_git_change = on_git_change + assert(handled, "the watcher never handled a change") + end + it("saves and stages the result, then closes once the merge finishes", function() local root = stopped("merge") session_on(root) @@ -570,8 +640,9 @@ describe(":Differ continue", function() end), "never reopened on g.txt" ) - vim.wait(300) -- a late watcher fire must not close the new session - assert.is_not_nil(merge.current()) + local s = assert(merge.current()) + after_git_change(s, root) + assert.are.equal(s, merge.current()) assert.is_nil(outside_closes()) end) @@ -624,7 +695,7 @@ describe(":Differ continue", function() return noted("hook says no") ~= nil end)) assert.are.equal(vim.log.levels.ERROR, noted("hook says no").level) - vim.wait(300) + after_git_change(s, root) assert.are.equal(s, merge.current()) assert.is_not_nil(git_src.operation(root)) end) @@ -644,7 +715,7 @@ describe(":Differ continue", function() assert.is_not_nil(refused) assert.are.equal(vim.log.levels.WARN, refused.level) finished(root) - for _, n in ipairs(notes) do + for _, n in ipairs(_G.notifs) do assert.are_not.equal(vim.log.levels.ERROR, n.level, n.msg) end end) From 336b2e8e1b99de11972a473ddd26d68fc382c400 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:00:04 +0100 Subject: [PATCH 13/25] feat: keep the commit message draft when the edit buffer closes unwritten --- lua/differ/merge/continue.lua | 46 +++++++++++++++-- test/nvim/operation_spec.lua | 95 ++++++++++++++++++++++++++++++++--- 2 files changed, 131 insertions(+), 10 deletions(-) diff --git a/lua/differ/merge/continue.lua b/lua/differ/merge/continue.lua index 26607bd..fd3dea3 100644 --- a/lua/differ/merge/continue.lua +++ b/lua/differ/merge/continue.lua @@ -47,6 +47,25 @@ local function report(root, op, action, ok, output) ) end +-- an edited message closed unwritten, one per repo, for the stop it was written on +---@type table +local drafts = {} + +---@param root string +---@param op string +---@return string|nil +local function draft_for(root, op) + local draft = drafts[root] + if not draft then + return nil + end + if draft.op ~= op or draft.head ~= require("differ.git").head_sha(root) then + drafts[root] = nil + return nil + end + return draft.text +end + -- the operation of the run in flight; a second run alongside it only gets git's errors ---@type string|nil local running @@ -72,6 +91,9 @@ local function run(root, op, action, opts) running = op git_op.run(root, op, action, opts, function(ok, output) running = nil + if ok then + drafts[root] = nil + end report(root, op, action, ok, output) merge.after_operation(root) end) @@ -97,7 +119,7 @@ local function resolved(root) end -- a scratch buffer holding the prepared message; :w continues with what it holds, closing --- it unwritten cancels +-- it unwritten keeps its text as the draft ---@param root string ---@param op string ---@param text string @@ -120,11 +142,27 @@ local function edit_message(root, op, text) vim.api.nvim_buf_set_lines(buf, 0, -1, false, vim.split(vim.trim(text), "\n")) vim.bo[buf].modified = false vim.bo[buf].filetype = "gitcommit" - vim.wo.winbar = ("%s message · :w continues, close to cancel"):format(op) + vim.wo.winbar = ("%s message · :w continues, close keeps a draft"):format(op) + local head = require("differ.git").head_sha(root) + local function buffer_text() + return table.concat(vim.api.nvim_buf_get_lines(buf, 0, -1, false), "\n") + end + vim.api.nvim_create_autocmd("BufWipeout", { + buffer = buf, + callback = function() + if not vim.bo[buf].modified then + return + end + drafts[root] = { op = op, head = head, text = buffer_text() } + notify(op .. " message draft kept") + end, + }) vim.api.nvim_create_autocmd("BufWriteCmd", { buffer = buf, callback = function() - local message = table.concat(vim.api.nvim_buf_get_lines(buf, 0, -1, false), "\n") + local message = buffer_text() + -- kept until the continue succeeds, so a refused commit reopens with it + drafts[root] = { op = op, head = head, text = message } vim.bo[buf].modified = false -- closing the message window moves focus, so run only once it's gone vim.schedule(function() @@ -161,7 +199,7 @@ local function entries(root, op) label = "continue and edit the message", run = function() if resolved(root) then - edit_message(root, op, message) + edit_message(root, op, draft_for(root, op) or message) end end, } diff --git a/test/nvim/operation_spec.lua b/test/nvim/operation_spec.lua index e4b2a21..4ef02f1 100644 --- a/test/nvim/operation_spec.lua +++ b/test/nvim/operation_spec.lua @@ -554,15 +554,98 @@ describe(":Differ continue", function() finished(root) end) - it("leaves the operation alone when the edit buffer closes unwritten", function() - local root = stopped("merge") - resolve_all(root) - in_repo(root) + -- the edit buffer for the stopped operation, open on its prefilled message + local function edit_buffer() chosen = "continue and edit the message" command.dispatch({ "continue" }) + local buf = vim.api.nvim_get_current_buf() + assert.are.equal("gitcommit", vim.bo[buf].filetype) + return buf + end + + -- edit the message's subject and close the buffer unwritten + local function draft(text) + local buf = edit_buffer() + vim.api.nvim_buf_set_lines(buf, 0, 1, false, { text }) vim.cmd("bwipeout!") - assert.is_false(continue.running()) - assert.is_not_nil(git_src.operation(root)) + end + + local function first_line(buf) + return vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1] + end + + describe("message draft", function() + it("is kept when the edit buffer closes unwritten, and reopens", function() + local root = stopped("merge") + resolve_all(root) + in_repo(root) + draft("drafted") + assert.is_false(continue.running()) + assert.is_not_nil(git_src.operation(root)) + assert.is_not_nil(noted("merge message draft kept")) + assert.are.equal("drafted", first_line(edit_buffer())) + end) + + it("isn't kept when the edit buffer closes unchanged", function() + local root = stopped("merge") + resolve_all(root) + in_repo(root) + edit_buffer() + vim.cmd("bwipeout!") + assert.is_nil(noted("draft kept")) + end) + + it("is committed by a write, then cleared", function() + local root = stopped("merge") + resolve_all(root) + in_repo(root) + draft("drafted") + edit_buffer() + vim.cmd("write") + finished(root) + assert.are.equal("drafted", subject(root)) + git_ok(root, "reset", "-q", "--hard", "HEAD~1") + git(root, "merge", "--no-edit", "feature") + resolve_all(root) + assert.are.equal("Merge branch 'feature'", first_line(edit_buffer())) + end) + + it("is ignored by a plain continue", function() + local root = stopped("merge") + resolve_all(root) + in_repo(root) + draft("drafted") + chosen = "continue" + command.dispatch({ "continue" }) + finished(root) + assert.are.equal("Merge branch 'feature'", subject(root)) + end) + + it("is cleared by an abort", function() + local root = stopped("merge") + resolve_all(root) + in_repo(root) + draft("drafted") + chosen = "abort" + vim.fn.confirm = function() + return 1 + end + command.dispatch({ "continue" }) + finished(root) + git(root, "merge", "--no-edit", "feature") + resolve_all(root) + assert.are.equal("Merge branch 'feature'", first_line(edit_buffer())) + end) + + it("isn't offered on the next stop of a rebase", function() + local root = stopped("rebase") + resolve_all(root) + in_repo(root) + draft("drafted") + git(root, "-c", "core.editor=true", "rebase", "--continue") + resolve_all(root) + assert.are.equal("feature g", first_line(edit_buffer())) + end) end) it("aborts once confirmed, and not otherwise", function() From c9a7955d58b0ab63cf1065125efbb19df9513ad4 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:17:36 +0100 Subject: [PATCH 14/25] fix: restore the result window's options before the merge tool closes --- lua/differ/merge/init.lua | 26 +++++++++++++++++++++--- test/nvim/mergetool_spec.lua | 38 ++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/lua/differ/merge/init.lua b/lua/differ/merge/init.lua index 758e433..bc0a248 100644 --- a/lua/differ/merge/init.lua +++ b/lua/differ/merge/init.lua @@ -437,12 +437,12 @@ local function without_scrollbind(fn) end each(function(w) saved[w] = vim.wo[w].scrollbind - vim.wo[w].scrollbind = false + set_local(w, "scrollbind", false) end) local ok, err = pcall(fn) each(function(w) if saved[w] ~= nil then - vim.wo[w].scrollbind = saved[w] + set_local(w, "scrollbind", saved[w]) end end) if not ok then @@ -781,10 +781,30 @@ local function apply_folds(win, folds) end) end +-- every window option dress() and apply_folds set, back to its global value. nvim stores +-- a buffer's last window-local options and reapplies them when the buffer is next shown +---@param win integer +local function undress(win) + vim.api.nvim_win_call(win, function() + vim.cmd("silent! normal! zE") + vim.cmd( + "setlocal number< relativenumber< wrap< foldcolumn< foldenable< foldmethod<" + .. " foldtext< signcolumn< cursorline< scrollbind< winbar<" + ) + end) +end + -- the result buffer is the user's real file and outlives the session, so it has to be --- handed back clean: no session paint, no session keymaps, no session hooks +-- handed back clean: no session paint, no session keymaps, no session hooks, no +-- session window options ---@param s differ.MergeSession local function release_result(s) + if + vim.api.nvim_win_is_valid(s.result_win) + and vim.api.nvim_win_get_buf(s.result_win) == s.result_buf + then + undress(s.result_win) + end if vim.api.nvim_buf_is_valid(s.result_buf) then vim.b[s.result_buf].disable_autoformat = s.saved_autoformat -- give autoformat back if s.saved_markdown_render then diff --git a/test/nvim/mergetool_spec.lua b/test/nvim/mergetool_spec.lua index f5dc868..de58f94 100644 --- a/test/nvim/mergetool_spec.lua +++ b/test/nvim/mergetool_spec.lua @@ -296,6 +296,22 @@ end local merge_ns = vim.api.nvim_create_namespace("differ.merge") local flash_ns = vim.api.nvim_create_namespace("differ.merge.flash") +-- open the merge tool on a file no other window shows, from an untracked one beside it +local function open_unshown(root, relpath) + write(root .. "/untracked.txt", "x\n") + vim.cmd.edit(root .. "/untracked.txt") + merge.open({ path = root .. "/" .. relpath }) + return assert(merge.current()) +end + +-- whether the current window carries none of the merge tool's window dressing +local function assert_undressed() + assert.are.equal("", vim.api.nvim_get_option_value("winbar", { scope = "local" })) + for _, opt in ipairs({ "number", "signcolumn", "cursorline", "scrollbind", "foldmethod" }) do + assert.are.equal(vim.go[opt], vim.wo[opt], opt) + end +end + describe(":Differ mergetool", function() after_each(function() if merge.current() then @@ -373,6 +389,14 @@ describe(":Differ mergetool", function() assert.is_nil(merge.current()) end) + it("hands the result buffer back without its window options", function() + local root = conflict_repo() + open_unshown(root, "f.txt") + merge.close() + vim.cmd.edit(root .. "/f.txt") + assert_undressed() + end) + it("leaves q to native macro recording on the editable result buffer", function() local root = conflict_repo() vim.cmd.edit(root .. "/f.txt") @@ -2071,6 +2095,20 @@ describe(":Differ mergetool modify/delete", function() assert.are.equal("RESULT · file 1/2 · deleted · 1 file left", plain(merge.winbar())) end) + it("hands a deleted file's buffer back without its window options", function() + local root = conflict_repo_modify_delete({ "f.txt" }) + vim.cmd.edit(root .. "/a.txt") -- a window outside the session keeps the real buffer + local s = open_at(root, "a.txt") + confirming(1, function() + fire(s.result_buf, "differ: take theirs") + end) + merge.close() + git_ok(root, "checkout", "HEAD", "--", "a.txt") + vim.cmd("new") + vim.cmd.edit(root .. "/a.txt") + assert_undressed() + end) + it("deletes a delete/modify file on taking ours", function() local root = conflict_repo_modify_delete({ "f.txt" }, "ours") vim.cmd.edit(root .. "/f.txt") From f14e5184915cc2524c21c19fe6a795f898ed8ee5 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:10:04 +0100 Subject: [PATCH 15/25] fix: hand the result buffer's own keymaps back when the merge tool closes --- lua/differ/merge/deleted.lua | 2 +- lua/differ/merge/init.lua | 15 +++++------- lua/differ/util/keymap.lua | 47 ++++++++++++++++++++++++++++++++++++ test/nvim/mergetool_spec.lua | 24 ++++++++++++++++++ 4 files changed, 78 insertions(+), 10 deletions(-) diff --git a/lua/differ/merge/deleted.lua b/lua/differ/merge/deleted.lua index a4aa125..a685787 100644 --- a/lua/differ/merge/deleted.lua +++ b/lua/differ/merge/deleted.lua @@ -35,7 +35,7 @@ local function show_deleted(s) end, }) s.bufs[#s.bufs + 1] = buf - s.result_buf, s.bound_keys = buf, {} + s.result_buf, s.held_keys = buf, {} vim.api.nvim_win_set_buf(s.result_win, buf) merge._dress(s.result_win) merge._show_notice(s.result_win, buf, notice) diff --git a/lua/differ/merge/init.lua b/lua/differ/merge/init.lua index bc0a248..1e3d435 100644 --- a/lua/differ/merge/init.lua +++ b/lua/differ/merge/init.lua @@ -22,7 +22,7 @@ local tab_util = require("differ.util.tab") local plural = require("differ.util.text").plural local merge_files = require("differ.merge.files") local markers_reformatted = require("differ.git.conflict").reformatted -local bind, unbind = keymap.bind, keymap.unbind +local bind = keymap.bind local M = {} @@ -91,7 +91,7 @@ local INPUT_HL = { ---@field autoformat_warned boolean|nil -- set once a save is seen to have reformatted the markers ---@field diag_aug integer|nil -- the DiagnosticChanged hook that hushes the result ---@field aug integer|nil -- the session augroup on the result buffer ----@field bound_keys (string|string[]|false)[] -- the lhs specs bound on the result buffer +---@field held_keys differ.keymap.Held[] -- the maps bound on the result buffer, with what they replaced ---@type differ.MergeSession|nil local session = nil @@ -814,9 +814,7 @@ local function release_result(s) for _, ns in ipairs({ merge_ns, flash_ns, anchor_ns }) do vim.api.nvim_buf_clear_namespace(s.result_buf, ns, 0, -1) end - for _, spec in ipairs(s.bound_keys) do - unbind(s.result_buf, spec) - end + keymap.release(s.result_buf, s.held_keys) if s.aug then vim.api.nvim_clear_autocmds({ group = s.aug, buffer = s.result_buf }) end @@ -1092,7 +1090,7 @@ local function lay_out(root, relpath, model, layout, files) local cfg = require("differ").get_config() local km = (cfg.keymaps.merge or require("differ.config").defaults.keymaps) --[[@as differ.KeymapSet]] - local bound = {} -- the specs actually bound below, so close drops exactly these + local held = {} -- the maps bound below, so close hands back exactly these local file_index for i, f in ipairs(files) do if f == relpath then @@ -1125,7 +1123,7 @@ local function lay_out(root, relpath, model, layout, files) return_tab = return_tab, session_tab = session_tab, keymaps = km, -- for the g? cheatsheet - bound_keys = bound, + held_keys = held, diag_aug = suppress_diagnostics(result_buf), -- the markers aren't valid source saved_markdown_render = quiet_markdown_render(result_buf), -- don't conceal the markers saved_diagnostics = vim.diagnostic.is_enabled({ bufnr = result_buf }), @@ -1163,8 +1161,7 @@ local function lay_out(root, relpath, model, layout, files) -- nested empty DiagnosticChanged reaches it before the outer one vim.diagnostic.enable(false, { bufnr = result_buf }) local function rb(action, fn, desc) - bind(result_buf, km[action], fn, desc) - bound[#bound + 1] = km[action] + vim.list_extend(held, keymap.hold(result_buf, km[action], fn, desc)) end rb("next_conflict", function() goto_conflict("next") diff --git a/lua/differ/util/keymap.lua b/lua/differ/util/keymap.lua index 8b85b43..0a7fe91 100644 --- a/lua/differ/util/keymap.lua +++ b/lua/differ/util/keymap.lua @@ -38,6 +38,53 @@ function M.unbind(bufnr, spec, mode) end end +---@class differ.keymap.Held +---@field lhs string +---@field fn fun() +---@field prior table|nil -- the buffer-local maparg() dict bind replaced + +-- bind on a buffer we don't own, keeping each lhs's prior buffer-local map for release +---@param bufnr integer +---@param spec string|string[]|false|nil +---@param fn fun() +---@param desc string +---@return differ.keymap.Held[] +function M.hold(bufnr, spec, fn, desc) + if not spec then + return {} + end + local held = {} + vim.api.nvim_buf_call(bufnr, function() + for _, lhs in ipairs(type(spec) == "table" and spec or { spec }) do + local prior = vim.fn.maparg(lhs, "n", false, true) + held[#held + 1] = { lhs = lhs, fn = fn, prior = prior.buffer == 1 and prior or nil } + end + end) + M.bind(bufnr, spec, fn, desc) + return held +end + +-- the inverse of hold: drop each map still ours and put back the one it replaced. a map +-- set over ours since (an LSP attaching mid-session) stays +---@param bufnr integer +---@param held differ.keymap.Held[] +function M.release(bufnr, held) + if not vim.api.nvim_buf_is_valid(bufnr) then + return + end + vim.api.nvim_buf_call(bufnr, function() + for _, h in ipairs(held) do + local now = vim.fn.maparg(h.lhs, "n", false, true) + if now.buffer == 1 and now.callback == h.fn then + vim.keymap.del("n", h.lhs, { buffer = bufnr }) + if h.prior then + vim.fn.mapset(h.prior) + end + end + end + end) +end + -- true when every lhs in `spec` is mapped buffer-locally on `bufnr` ---@param bufnr integer ---@param spec string|string[]|false|nil diff --git a/test/nvim/mergetool_spec.lua b/test/nvim/mergetool_spec.lua index de58f94..a44e120 100644 --- a/test/nvim/mergetool_spec.lua +++ b/test/nvim/mergetool_spec.lua @@ -397,6 +397,30 @@ describe(":Differ mergetool", function() assert_undressed() end) + it("hands the result buffer's own keymap back", function() + local root = conflict_repo() + vim.cmd.edit(root .. "/f.txt") + local buf = vim.api.nvim_get_current_buf() + vim.keymap.set("n", "ca", "", { buffer = buf, desc = "code action" }) + merge.open() + assert.are.equal("differ: take both", vim.fn.maparg("ca", "n", false, true).desc) + merge.close() + vim.api.nvim_set_current_buf(buf) + assert.are.equal("code action", vim.fn.maparg("ca", "n", false, true).desc) + end) + + it("keeps a keymap set over the merge tool's during the session", function() + local root = conflict_repo() + vim.cmd.edit(root .. "/f.txt") + local buf = vim.api.nvim_get_current_buf() + merge.open() + vim.keymap.set("n", "ca", "", { buffer = buf, desc = "code action" }) + merge.close() + vim.api.nvim_set_current_buf(buf) + assert.are.equal("code action", vim.fn.maparg("ca", "n", false, true).desc) + assert.are.same({}, vim.fn.maparg("ct", "n", false, true)) + end) + it("leaves q to native macro recording on the editable result buffer", function() local root = conflict_repo() vim.cmd.edit(root .. "/f.txt") From 3e72b9c8ba368236def761f27011607b749cc4b7 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:10:04 +0100 Subject: [PATCH 16/25] docs: :Differ continue, merge tool stepping and signing prompts --- CHANGELOG.md | 22 ++++++++++++++++++++++ README.md | 3 ++- TROUBLESHOOTING.md | 4 ++++ doc/differ-troubleshooting.txt | 15 +++++++++++++-- doc/differ.txt | 11 ++++++++--- 5 files changed, 49 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a493d68..7eafd84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,28 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- `:Differ continue` finishes the merge, rebase, cherry-pick, revert or `am` in progress without leaving nvim: continue, edit the message, skip or abort +- The merge tool keeps or deletes a file changed on one side and deleted on the other +- The local panel names the operation in progress and points to `:Differ continue` +- An edited commit message closed without writing is kept as a draft for the next edit + +### Changed + +- The merge tool no longer jumps to the next file on save: `]f`/`[f` step between conflicted files and the winbar shows progress + +### Fixed + +- The merge tool reported a file staged when `git add` failed, and moved on +- Inline diagnostic plugins showed LSP errors over the merge result's conflict markers +- The merge tool stopped at a conflict it couldn't open instead of moving to the next one +- The panel footer kept showing the old `HEAD` after a commit +- A file reopened after the merge tool closed kept its empty winbar row, hidden sign column and scrollbind +- A buffer-local keymap on the merged file was lost after the merge tool closed + +## [0.2.4] — 2026-10-10 + +### Added + - In the merge tool, `g?`, `]x` and `[x` also work from the ours, theirs and base panes ## [0.2.3] — 2026-10-09 diff --git a/README.md b/README.md index 9384c59..246821b 100644 --- a/README.md +++ b/README.md @@ -287,7 +287,8 @@ The lifecycle verbs act on the active session's PR: |---|---| | `:Differ gofile` | Open the real file at the cursor's mapped line and end the session. The `de` key | | `:Differ edit` | Edit the real file in a transient split at the cursor's mapped line, keeping the session; `:w` re-sources the diff. The `df` key | -| `:Differ mergetool [path]` | Open the merge tool on a conflicted file; no argument takes the current file, else the only conflicted one, else a picker | +| `:Differ mergetool [path]` | Open the merge tool on a conflicted file; `]f`/`[f` step between conflicted files | +| `:Differ continue` | Finish the merge, rebase, cherry-pick, revert or `am` in progress. Supports gitconfig `commit.gpgsign` with a floating terminal for the passphrase input | | `:Differ sidecar` | Smoke-check the Go sidecar: start it, round-trip a handshake, and report the binary and protocol version | | `:Differ sidecar stop` | Stop the supervised sidecar; the next PR command starts a fresh one | | `:Differ cache clear` | Flush the sidecar's in-process caches (file blobs and PR review threads) | diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index 6dae6d0..3f74852 100644 --- a/TROUBLESHOOTING.md +++ b/TROUBLESHOOTING.md @@ -67,6 +67,10 @@ The merge result buffer is the real worktree file, so a format-on-save would oth If a formatter reformats the markers anyway, differ notices on save, refuses to stage the file, and warns once that the flag isn't being honoured. +## Signing prompts during `:Differ continue` + +With `commit.gpgsign` on, `:Differ continue` runs git in a floating terminal and points `GPG_TTY` at it, so a terminal pinentry or an `ssh-keygen` passphrase prompt draws there. A key held by gpg-agent's SSH support is the exception: its prompt goes to the terminal the agent started in. A GUI pinentry (pinentry-mac) or a cached passphrase avoids the prompt entirely. + ## render-markdown.nvim in the merge result The merge result disables in-buffer markdown rendering for the session so the conflict markers aren't concealed as block-quotes, restoring it on close. diff --git a/doc/differ-troubleshooting.txt b/doc/differ-troubleshooting.txt index ee6bbc8..d5fef51 100644 --- a/doc/differ-troubleshooting.txt +++ b/doc/differ-troubleshooting.txt @@ -11,7 +11,8 @@ Table of Contents *differ-troubleshooting-table-of-contents* 6. which-key popups over differ buffers|differ-troubleshooting-which-key-popups-over-differ-buffers| 7. Statusline requires under lazy-loading|differ-troubleshooting-statusline-requires-under-lazy-loading| 8. Format-on-save over conflict markers|differ-troubleshooting-format-on-save-over-conflict-markers| -9. render-markdown.nvim in the merge result|differ-troubleshooting-render-markdown.nvim-in-the-merge-result| +9. Signing prompts during :Differ continue|differ-troubleshooting-signing-prompts-during-:differ-continue| +10. render-markdown.nvim in the merge result|differ-troubleshooting-render-markdown.nvim-in-the-merge-result| Working out what’s wrong, and the edge cases in how differ interacts with lazy-loading and other plugins. It lives here rather than in the README so that stays a reference rather than a FAQ. @@ -142,7 +143,17 @@ stage the file, and warns once that the flag isn’t being honoured. ============================================================================== -9. render-markdown.nvim in the merge result*differ-troubleshooting-render-markdown.nvim-in-the-merge-result* +9. Signing prompts during :Differ continue*differ-troubleshooting-signing-prompts-during-:differ-continue* + +With `commit.gpgsign` on, `:Differ continue` runs git in a floating terminal +and points `GPG_TTY` at it, so a terminal pinentry or an `ssh-keygen` +passphrase prompt draws there. A key held by gpg-agent’s SSH support is the +exception: its prompt goes to the terminal the agent started in. A GUI pinentry +(pinentry-mac) or a cached passphrase avoids the prompt entirely. + + +============================================================================== +10. render-markdown.nvim in the merge result*differ-troubleshooting-render-markdown.nvim-in-the-merge-result* The merge result disables in-buffer markdown rendering for the session so the conflict markers aren’t concealed as block-quotes, restoring it on close. diff --git a/doc/differ.txt b/doc/differ.txt index 6be5257..760c216 100644 --- a/doc/differ.txt +++ b/doc/differ.txt @@ -441,9 +441,14 @@ SESSION AND SIDECAR COMMANDS *differ-usage-session-and-sidecar-commands* the diff. The df key :Differ mergetool [path] Open the merge tool on a conflicted - file; no argument takes the current - file, else the only conflicted one, - else a picker + file; ]f/[f step between conflicted + files + + :Differ continue Finish the merge, rebase, + cherry-pick, revert or am in + progress. Supports gitconfig + commit.gpgsign with a floating + terminal for the passphrase input :Differ sidecar Smoke-check the Go sidecar: start it, round-trip a handshake, and From 1ecafe6fabfa8bfad9fd663f30f9d6b75b6b73f7 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:03:10 +0100 Subject: [PATCH 17/25] fix: let git own the commit message on :Differ continue --- README.md | 2 +- TROUBLESHOOTING.md | 4 +- doc/differ-troubleshooting.txt | 12 +- doc/differ.txt | 14 +- lua/differ/git/editor.lua | 88 +++++ lua/differ/git/operation.lua | 199 +++++------ lua/differ/git/shim.lua | 38 +++ lua/differ/merge/continue.lua | 114 ++----- test/nvim/operation_spec.lua | 592 ++++++++++++++++++++------------- 9 files changed, 613 insertions(+), 450 deletions(-) create mode 100644 lua/differ/git/editor.lua create mode 100644 lua/differ/git/shim.lua diff --git a/README.md b/README.md index 246821b..0e0d493 100644 --- a/README.md +++ b/README.md @@ -288,7 +288,7 @@ The lifecycle verbs act on the active session's PR: | `:Differ gofile` | Open the real file at the cursor's mapped line and end the session. The `de` key | | `:Differ edit` | Edit the real file in a transient split at the cursor's mapped line, keeping the session; `:w` re-sources the diff. The `df` key | | `:Differ mergetool [path]` | Open the merge tool on a conflicted file; `]f`/`[f` step between conflicted files | -| `:Differ continue` | Finish the merge, rebase, cherry-pick, revert or `am` in progress. Supports gitconfig `commit.gpgsign` with a floating terminal for the passphrase input | +| `:Differ continue` | Continue, skip or abort the merge, rebase, cherry-pick, revert or `am` in progress. Git runs in a floating terminal, and each commit message it asks for opens there, except the stopped commit's on a plain `continue`. Closing it commits; an emptied message drops that commit and leaves the operation stopped | | `:Differ sidecar` | Smoke-check the Go sidecar: start it, round-trip a handshake, and report the binary and protocol version | | `:Differ sidecar stop` | Stop the supervised sidecar; the next PR command starts a fresh one | | `:Differ cache clear` | Flush the sidecar's in-process caches (file blobs and PR review threads) | diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index 3f74852..822ac41 100644 --- a/TROUBLESHOOTING.md +++ b/TROUBLESHOOTING.md @@ -69,7 +69,9 @@ If a formatter reformats the markers anyway, differ notices on save, refuses to ## Signing prompts during `:Differ continue` -With `commit.gpgsign` on, `:Differ continue` runs git in a floating terminal and points `GPG_TTY` at it, so a terminal pinentry or an `ssh-keygen` passphrase prompt draws there. A key held by gpg-agent's SSH support is the exception: its prompt goes to the terminal the agent started in. A GUI pinentry (pinentry-mac) or a cached passphrase avoids the prompt entirely. +`:Differ continue` runs git in a floating terminal and points `GPG_TTY` at it, so with `commit.gpgsign` on a terminal pinentry or an `ssh-keygen` passphrase prompt draws there. A key held by gpg-agent's SSH support is the exception: its prompt goes to the terminal the agent started in. A GUI pinentry (pinentry-mac) or a cached passphrase avoids the prompt entirely. + +A cherry-pick or revert range started with `-S` leaves the commit it stopped on unsigned after `:Differ continue`, as `git cherry-pick --continue` does on the command line; `commit.gpgsign` signs it. ## render-markdown.nvim in the merge result diff --git a/doc/differ-troubleshooting.txt b/doc/differ-troubleshooting.txt index d5fef51..39f6fbf 100644 --- a/doc/differ-troubleshooting.txt +++ b/doc/differ-troubleshooting.txt @@ -145,12 +145,16 @@ stage the file, and warns once that the flag isn’t being honoured. ============================================================================== 9. Signing prompts during :Differ continue*differ-troubleshooting-signing-prompts-during-:differ-continue* -With `commit.gpgsign` on, `:Differ continue` runs git in a floating terminal -and points `GPG_TTY` at it, so a terminal pinentry or an `ssh-keygen` -passphrase prompt draws there. A key held by gpg-agent’s SSH support is the -exception: its prompt goes to the terminal the agent started in. A GUI pinentry +`:Differ continue` runs git in a floating terminal and points `GPG_TTY` at it, +so with `commit.gpgsign` on a terminal pinentry or an `ssh-keygen` passphrase +prompt draws there. A key held by gpg-agent’s SSH support is the exception: +its prompt goes to the terminal the agent started in. A GUI pinentry (pinentry-mac) or a cached passphrase avoids the prompt entirely. +A cherry-pick or revert range started with `-S` leaves the commit it stopped on +unsigned after `:Differ continue`, as `git cherry-pick --continue` does on the +command line; `commit.gpgsign` signs it. + ============================================================================== 10. render-markdown.nvim in the merge result*differ-troubleshooting-render-markdown.nvim-in-the-merge-result* diff --git a/doc/differ.txt b/doc/differ.txt index 760c216..1c4ee6e 100644 --- a/doc/differ.txt +++ b/doc/differ.txt @@ -444,11 +444,15 @@ SESSION AND SIDECAR COMMANDS *differ-usage-session-and-sidecar-commands* file; ]f/[f step between conflicted files - :Differ continue Finish the merge, rebase, - cherry-pick, revert or am in - progress. Supports gitconfig - commit.gpgsign with a floating - terminal for the passphrase input + :Differ continue Continue, skip or abort the merge, + rebase, cherry-pick, revert or am + in progress. Git runs in a floating + terminal, and each commit message + it asks for opens there, except the + stopped commit’s on a plain + continue. Closing it commits; an + emptied message drops that commit + and leaves the operation stopped :Differ sidecar Smoke-check the Go sidecar: start it, round-trip a handshake, and diff --git a/lua/differ/git/editor.lua b/lua/differ/git/editor.lua new file mode 100644 index 0000000..24f0e7c --- /dev/null +++ b/lua/differ/git/editor.lua @@ -0,0 +1,88 @@ +-- the editor git gets in differ's terminal float: git/shim.lua, which asks this nvim to +-- open the message in the float and waits until the buffer closes. as with any editor, +-- git then reads the file, and an emptied message drops that commit + +local M = {} + +local SHIM = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":p:h") .. "/shim.lua" + +-- the environment git runs with in terminal buffer `float`: the shim as its editor, +-- pointed back at this nvim and that buffer +---@param float integer +---@return table +function M.env(float) + if vim.v.servername == "" then + vim.fn.serverstart() + end + local editor = table.concat({ + vim.fn.shellescape(vim.v.progpath), + "--clean", + "-l", + vim.fn.shellescape(SHIM), + }, " ") + return { GIT_EDITOR = editor, NVIM = vim.v.servername, DIFFER_FLOAT = tostring(float) } +end + +-- the float's window, reopened when nothing shows the terminal +---@param float integer +---@return integer +local function float_win(float) + require("differ.git.operation").show(float) + return vim.fn.bufwinid(float) +end + +-- called by the shim over rpc: show `path` in place of terminal buffer `float` and tell +-- channel `chan` once it closes. returns at once, since the shim's request blocks until +-- it does +---@param path string +---@param chan integer +---@param float integer +function M.open(path, chan, float) + local function closed() + pcall(vim.rpcnotify, chan, "nvim_exec_lua", "_G.differ_closed = true", {}) + end + -- the stopped commit's message, asked for before git moves past the stop + local accept = vim.b[float].differ_accept + vim.b[float].differ_accept = nil + if accept and accept.stop == require("differ.git.operation").stop(accept.root) then + return closed() + end + local existing = vim.fn.bufnr(path) + if existing ~= -1 then + if vim.bo[existing].modified then + require("differ.git.exec").notify( + "unsaved edits to " .. path .. " were discarded", + vim.log.levels.WARN + ) + end + vim.api.nvim_buf_delete(existing, { force = true }) + end + local buf = vim.fn.bufadd(path) + local win = float_win(float) + vim.b[float].differ_message = true + vim.api.nvim_win_set_buf(win, buf) + vim.api.nvim_set_current_win(win) + vim.cmd.stopinsert() + vim.bo[buf].bufhidden = "wipe" + vim.bo[buf].swapfile = false + vim.wo[win].winbar = "commit message" + vim.api.nvim_create_autocmd("BufUnload", { + buffer = buf, + once = true, + callback = function() + vim.schedule(function() + if vim.api.nvim_buf_is_valid(float) then + vim.b[float].differ_message = nil + local back = float_win(float) + vim.wo[back].winbar = "" + vim.api.nvim_win_set_buf(back, float) + vim.api.nvim_set_current_win(back) + vim.cmd.startinsert() + end + closed() + end) + end, + }) +end + +return M diff --git a/lua/differ/git/operation.lua b/lua/differ/git/operation.lua index 278593a..1aa3e87 100644 --- a/lua/differ/git/operation.lua +++ b/lua/differ/git/operation.lua @@ -1,7 +1,6 @@ -- the in-progress operation (merge, rebase, am, cherry-pick, revert): detected from the -- git dir, labelled for the panel, and continued, skipped or aborted in the background. --- git never gets a terminal here, so it never gets a real editor either: a plain run --- commits the message git prepared, an edited one hands git the user's text +-- git's editor is git/editor.lua's shim, which opens each commit message in this nvim local exec = require("differ.git.exec") @@ -157,80 +156,95 @@ function M.label(root) return ("(%s)%s"):format(verb, detail) end --- the message the operation's next commit starts from, or nil when its --continue never --- opens an editor (am, an apply-backend rebase) +-- todo lines whose message git commits without asking, conflicts aside: a pick, and a +-- merge that reuses its message with -C (-c or no flag asks) +local KEEPS_MESSAGE = { "^pick ", "^p ", "^merge %-C ", "^m %-C " } + +---@param line string +---@return boolean +local function keeps_message(line) + for _, pattern in ipairs(KEEPS_MESSAGE) do + if line:match(pattern) then + return true + end + end + return false +end + +-- what identifies the stop git is on: HEAD, plus a rebase's step. nil when the commit it +-- stopped on is one whose message the todo asks to edit (a reword, squash, fixup, or a +-- merge without -C) ---@param root string ----@param op string ---@return string|nil -function M.prepared_message(root, op) +function M.stop(root) + local head = require("differ.git").head_sha(root) or "" local dir = git_dir(root) - if not dir or op == "am" then - return nil + if not dir then + return head end - local path = dir .. "/MERGE_MSG" - if op == "rebase" then - path = dir .. "/rebase-merge/message" + local done = read(dir .. "/rebase-merge/done") + if not done then + return head end - return read(path) -end - --- the sequencer's saved `-S`: `gpg-sign = ` in sequencer/opts, the key empty for a --- bare -S. nil when the run doesn't sign ----@param dir string ----@return string|nil -local function sequencer_sign(dir) - local opts = read(dir .. "/sequencer/opts") - local key = opts and opts:match("\n%s*gpg%-sign = ([^\n]*)") - if not key then + if not keeps_message(vim.trim(done):match("([^\n]*)$")) then return nil end - return "-S" .. key + return head .. " " .. (state_line(dir, "rebase-merge/msgnum") or "") end --- whether git's next commit signs: commit.gpgsign, or a rebase or cherry-pick/revert range --- started with -S ----@param root string ----@return boolean -local function signing(root) - if vim.trim(exec.git({ "config", "--type=bool", "commit.gpgsign" }, root) or "") == "true" then - return true - end - local dir = git_dir(root) - if not dir then - return false +-- the terminal buffers git runs in, and their float titles +---@type table +local floats = {} + +---@param buf integer +---@param title string +---@return integer +local function open_float(buf, title) + local width = math.floor(vim.o.columns * 0.8) + local height = math.floor(vim.o.lines * 0.6) + return require("differ.util.win").open_centred(buf, width, height, { title = title }) +end + +-- reopen the float of terminal buffer `buf` when nothing shows it +---@param buf integer +function M.show(buf) + local title = floats[buf] + if not title or vim.fn.bufwinid(buf) ~= -1 then + return end - return vim.uv.fs_stat(dir .. "/rebase-merge/gpg_sign_opt") ~= nil - or vim.uv.fs_stat(dir .. "/rebase-apply/gpg_sign_opt") ~= nil - or sequencer_sign(dir) ~= nil + open_float(buf, title) end --- run `cmd` in a floating terminal so a signing prompt has a tty to draw on. gpg reads --- the commit from a pipe, so GPG_TTY has to name the float's pty or pinentry gets none. --- hiding or deleting the float stops git +-- run `cmd` in a floating terminal: git edits a cherry-pick or revert message only on a +-- tty, and a signing prompt needs one to draw on. gpg reads the commit from a pipe, so +-- GPG_TTY has to name the float's pty or pinentry gets none. hiding or deleting the float +-- stops git, unless the commit message is showing in its place ---@param root string ---@param cmd string[] ----@param editor string +---@param accept boolean ---@param on_exit fun(ok: boolean, output: string) -local function spawn_float(root, cmd, editor, on_exit) +local function in_float(root, cmd, accept, on_exit) local buf = vim.api.nvim_create_buf(false, true) - local width = math.floor(vim.o.columns * 0.8) - local height = math.floor(vim.o.lines * 0.6) - local win = require("differ.util.win").open_centred(buf, width, height, { - title = " git " .. cmd[#exec.GIT + 1] .. " ", - }) + local stop = accept and M.stop(root) + if stop then + vim.b[buf].differ_accept = { root = root, stop = stop } + end + floats[buf] = " git " .. cmd[#exec.GIT + 1] .. " " + local win = open_float(buf, floats[buf]) local argv = vim.list_extend({ "sh", "-c", 'GPG_TTY=$(tty) exec "$@"', "sh" }, cmd) local job = vim.fn.jobstart(argv, { term = true, cwd = root, - env = { GIT_EDITOR = editor }, + env = require("differ.git.editor").env(buf), on_exit = function(_, code) vim.schedule(function() + floats[buf] = nil local lines = {} if vim.api.nvim_buf_is_valid(buf) then lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) end - if vim.api.nvim_win_is_valid(win) then - vim.api.nvim_win_close(win, true) + for _, shown in ipairs(vim.fn.win_findbuf(buf)) do + vim.api.nvim_win_close(shown, true) end pcall(vim.api.nvim_buf_delete, buf, { force = true }) -- drop the terminal's "[Process exited n]" line @@ -244,32 +258,28 @@ local function spawn_float(root, cmd, editor, on_exit) end, }) if job <= 0 then + floats[buf] = nil vim.api.nvim_win_close(win, true) return on_exit(false, "couldn't start git in a terminal") end vim.api.nvim_create_autocmd({ "BufHidden", "BufUnload" }, { buffer = buf, - once = true, - callback = function() + callback = function(ev) + if ev.event == "BufHidden" and vim.b[buf].differ_message then + return + end vim.fn.jobstop(job) + return true end, }) vim.cmd.startinsert() end --- spawn git with `editor` as its editor, off the main loop; `on_exit` gets whether it --- exited 0 and what it printed. detached: with no controlling terminal a prompt (a curses --- pinentry) fails rather than drawing over nvim. `float` runs it in a terminal instead +-- run `cmd` detached and off the main loop, with no editor: an abort never commits ---@param root string ----@param args string[] ----@param editor string ----@param float boolean +---@param cmd string[] ---@param on_exit fun(ok: boolean, output: string) -local function spawn(root, args, editor, float, on_exit) - local cmd = vim.list_extend(vim.list_extend({}, exec.GIT), args) - if float then - return spawn_float(root, cmd, editor, on_exit) - end +local function in_background(root, cmd, on_exit) local function done(res) local output = vim.trim((res.stderr or "") .. "\n" .. (res.stdout or "")) vim.schedule(function() @@ -280,69 +290,28 @@ local function spawn(root, args, editor, float, on_exit) cwd = root, text = true, detach = true, - env = { GIT_EDITOR = editor }, + env = { GIT_EDITOR = "false" }, }, done) if not ok then done({ code = -1, stderr = tostring(err) }) end end --- commit `msgfile` by hand, then let a cherry-pick or revert range go on. their sequencer --- keeps its saved message past `--continue --edit`, and a merge's editor would overwrite --- what prepare-commit-msg added ----@param root string ----@param op string ----@param msgfile string ----@param float boolean ----@param on_exit fun(ok: boolean, output: string) -local function commit_then_continue(root, op, msgfile, float, on_exit) - local dir = git_dir(root) - local args = { "commit", "-q", "--cleanup=strip", "-F", msgfile } - local sign = dir and sequencer_sign(dir) - if sign then - table.insert(args, 2, sign) - end - spawn(root, args, "true", float, function(ok, output) - os.remove(msgfile) - if not ok or op == "merge" or not (dir and vim.uv.fs_stat(dir .. "/sequencer")) then - return on_exit(ok, output) - end - spawn(root, { op, "--continue" }, "true", float, on_exit) - end) -end - --- run `git --`, never opening an editor. a continue commits git's prepared --- message, or `opts.message` in its place. an abort never signs, so it never needs the float +-- run `git --`; `on_exit` gets whether it exited 0 and what it printed. a +-- continue or skip runs in the float, where git asks for each commit message through +-- git/editor.lua as it would in a terminal, a later reword or squash in a rebase included. +-- `accept` commits the stopped commit's message as it stands, where `M.stop` allows it ---@param root string ---@param op "merge"|"rebase"|"am"|"cherry-pick"|"revert" ---@param action "continue"|"skip"|"abort" ----@param opts { message?: string }|nil ---@param on_exit fun(ok: boolean, output: string) -function M.run(root, op, action, opts, on_exit) - local float = action ~= "abort" and signing(root) - local message = opts and opts.message - if not (message and action == "continue") then - return spawn(root, { op, "--" .. action }, "true", float, on_exit) - end - local msgfile = vim.fn.tempname() - local fd = assert(io.open(msgfile, "wb")) - fd:write(message) - fd:close() - if op ~= "rebase" then - return commit_then_continue(root, op, msgfile, float, on_exit) +---@param accept boolean|nil +function M.run(root, op, action, on_exit, accept) + local cmd = vim.list_extend(vim.list_extend({}, exec.GIT), { op, "--" .. action }) + if action == "abort" then + return in_background(root, cmd, on_exit) end - -- a rebase commit made by hand loses the picked commit's author, so git commits it, - -- with the shell running the editor and the file to edit appended - spawn( - root, - { op, "--continue" }, - "cp " .. vim.fn.shellescape(msgfile), - float, - function(ok, output) - os.remove(msgfile) - on_exit(ok, output) - end - ) + in_float(root, cmd, accept == true, on_exit) end return M diff --git a/lua/differ/git/shim.lua b/lua/differ/git/shim.lua new file mode 100644 index 0000000..260d4ad --- /dev/null +++ b/lua/differ/git/shim.lua @@ -0,0 +1,38 @@ +-- git's editor while differ runs an operation, run as `nvim --clean -l shim.lua `: +-- asks the nvim in $NVIM to open the file, then exits once it says the buffer closed. +-- never required as a module + +local addr = vim.env.NVIM +if not addr or addr == "" then + io.stderr:write("differ: no nvim to edit the message in\n") + os.exit(1) +end +local mode = "pipe" +if addr:match("^[%w.-]+:%d+$") then + mode = "tcp" +end +local ok, chan = pcall(vim.fn.sockconnect, mode, addr, { rpc = true }) +if not ok or chan == 0 then + io.stderr:write(("differ: can't reach nvim at %s\n"):format(addr)) + os.exit(1) +end + +-- set by the parent through nvim_exec_lua once the message buffer closes +_G.differ_closed = false +local own = vim.rpcrequest(chan, "nvim_get_api_info")[1] +vim.rpcrequest( + chan, + "nvim_exec_lua", + "require('differ.git.editor').open(...)", + { vim.fn.fnamemodify(_G.arg[1], ":p"), own, tonumber(vim.env.DIFFER_FLOAT) } +) +while not _G.differ_closed do + vim.wait(1000, function() + return _G.differ_closed + end) + -- the socket closes with the parent, and a request on it then fails + if not _G.differ_closed and not pcall(vim.rpcrequest, chan, "nvim_get_api_info") then + os.exit(1) + end +end +os.exit(0) diff --git a/lua/differ/merge/continue.lua b/lua/differ/merge/continue.lua index fd3dea3..2ee98ec 100644 --- a/lua/differ/merge/continue.lua +++ b/lua/differ/merge/continue.lua @@ -1,5 +1,4 @@ --- :Differ continue: a picker over the in-progress operation's continue, skip and abort, --- and the scratch buffer an edited commit message is written from +-- :Differ continue: a picker over the in-progress operation's continue, skip and abort local exec = require("differ.git.exec") local git_op = require("differ.git.operation") @@ -47,25 +46,6 @@ local function report(root, op, action, ok, output) ) end --- an edited message closed unwritten, one per repo, for the stop it was written on ----@type table -local drafts = {} - ----@param root string ----@param op string ----@return string|nil -local function draft_for(root, op) - local draft = drafts[root] - if not draft then - return nil - end - if draft.op ~= op or draft.head ~= require("differ.git").head_sha(root) then - drafts[root] = nil - return nil - end - return draft.text -end - -- the operation of the run in flight; a second run alongside it only gets git's errors ---@type string|nil local running @@ -82,21 +62,18 @@ end ---@param root string ---@param op string ---@param action "continue"|"skip"|"abort" ----@param opts { message?: string }|nil -local function run(root, op, action, opts) +---@param accept boolean|nil +local function run(root, op, action, accept) if refuse_while_running() then return end local merge = require("differ.merge") running = op - git_op.run(root, op, action, opts, function(ok, output) + git_op.run(root, op, action, function(ok, output) running = nil - if ok then - drafts[root] = nil - end report(root, op, action, ok, output) merge.after_operation(root) - end) + end, accept) end -- a continue with files still conflicted only gets git's refusal, so refuse here. the @@ -118,61 +95,10 @@ local function resolved(root) return false end --- a scratch buffer holding the prepared message; :w continues with what it holds, closing --- it unwritten keeps its text as the draft ----@param root string ----@param op string ----@param text string -local function edit_message(root, op, text) - local name = ("differ://%s-message"):format(op) - local existing = vim.fn.bufnr(name) - if existing ~= -1 then - local wins = vim.fn.win_findbuf(existing) - if #wins > 0 then - return vim.api.nvim_set_current_win(wins[1]) - end - vim.api.nvim_buf_delete(existing, { force = true }) - end - vim.cmd("botright 12new") - local buf = vim.api.nvim_get_current_buf() - vim.bo[buf].buftype = "acwrite" - vim.bo[buf].bufhidden = "wipe" - vim.bo[buf].swapfile = false - vim.api.nvim_buf_set_name(buf, name) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, vim.split(vim.trim(text), "\n")) - vim.bo[buf].modified = false - vim.bo[buf].filetype = "gitcommit" - vim.wo.winbar = ("%s message · :w continues, close keeps a draft"):format(op) - local head = require("differ.git").head_sha(root) - local function buffer_text() - return table.concat(vim.api.nvim_buf_get_lines(buf, 0, -1, false), "\n") - end - vim.api.nvim_create_autocmd("BufWipeout", { - buffer = buf, - callback = function() - if not vim.bo[buf].modified then - return - end - drafts[root] = { op = op, head = head, text = buffer_text() } - notify(op .. " message draft kept") - end, - }) - vim.api.nvim_create_autocmd("BufWriteCmd", { - buffer = buf, - callback = function() - local message = buffer_text() - -- kept until the continue succeeds, so a refused commit reopens with it - drafts[root] = { op = op, head = head, text = message } - vim.bo[buf].modified = false - -- closing the message window moves focus, so run only once it's gone - vim.schedule(function() - pcall(vim.api.nvim_buf_delete, buf, { force = true }) - if resolved(root) then - run(root, op, "continue", { message = message .. "\n" }) - end - end) - end, - }) +---@param prompt string +---@return boolean +local function confirm(prompt) + return vim.fn.confirm(prompt, "&Yes\n&No", 2) == 1 end ---@class differ.merge.ContinueEntry @@ -188,18 +114,18 @@ local function entries(root, op) label = "continue", run = function() if resolved(root) then - run(root, op, "continue") + run(root, op, "continue", true) end end, }, } - local message = git_op.prepared_message(root, op) - if message then + -- git am never asks for a message + if op ~= "am" then list[#list + 1] = { label = "continue and edit the message", run = function() if resolved(root) then - edit_message(root, op, draft_for(root, op) or message) + run(root, op, "continue") end end, } @@ -208,15 +134,16 @@ local function entries(root, op) list[#list + 1] = { label = "skip", run = function() - run(root, op, "skip") + if confirm("Skip the commit git stopped on? Its changes are dropped.") then + run(root, op, "skip") + end end, } end list[#list + 1] = { label = "abort", run = function() - local prompt = ("Abort the %s? Resolutions so far are lost."):format(op) - if vim.fn.confirm(prompt, "&Yes\n&No", 2) == 1 then + if confirm(("Abort the %s? Resolutions so far are lost."):format(op)) then run(root, op, "abort") end end, @@ -253,9 +180,12 @@ function M.pick() return entry.label end, }, function(choice) - if choice then - choice.run() + if not choice then + return end + -- the builtin inputlist calls back mid-prompt, which holds the float's first redraw + -- for seconds + vim.schedule(choice.run) end) end diff --git a/test/nvim/operation_spec.lua b/test/nvim/operation_spec.lua index 4ef02f1..bc31299 100644 --- a/test/nvim/operation_spec.lua +++ b/test/nvim/operation_spec.lua @@ -86,7 +86,7 @@ local function stopped(op, single) if single then reverts = { "HEAD~2" } end - git(root, "revert", "--no-edit", unpack(reverts)) + git(root, "revert", unpack(reverts)) end assert(#git_src.conflicted(root) > 0, op .. " didn't stop on a conflict") return root @@ -99,19 +99,68 @@ local function resolve_all(root) end end --- run the action and wait for its callback -local function run(root, action, opts) +-- start the action; the returned function gives its result once it has finished +local function start(root, action, accept) local result - operation.run(root, assert(git_src.operation(root)), action, opts, function(ok, output) + operation.run(root, assert(git_src.operation(root)), action, function(ok, output) result = { ok = ok, output = output } - end) + end, accept) + return function() + return result + end +end + +local function finished(res) assert( vim.wait(10000, function() - return result ~= nil + return res() ~= nil end), "the run never finished" ) - return result + return res() +end + +-- the commit message buffer git's editor opened, or nil once the run is over without one +local function message_buffer(res) + local buf + vim.wait(10000, function() + for _, b in ipairs(vim.api.nvim_list_bufs()) do + if vim.api.nvim_buf_get_name(b):match("COMMIT_EDITMSG$") then + buf = b + end + end + return buf ~= nil or res() ~= nil + end) + return buf +end + +-- replace the message with `lines` when given, write, and close +local function reply(buf, lines) + if lines then + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + end + vim.api.nvim_buf_call(buf, function() + vim.cmd("silent write") + end) + vim.api.nvim_buf_delete(buf, { force = true }) +end + +-- answer git's next message prompt +local function answer(res, lines) + local buf = message_buffer(res) + assert(buf, "git never asked for a message: " .. vim.inspect(res())) + reply(buf, lines) +end + +-- run the action, answering every message prompt with `lines`, and wait for it +local function run(root, action, lines) + local res = start(root, action) + local buf = message_buffer(res) + while buf do + reply(buf, lines) + buf = message_buffer(res) + end + return finished(res) end local function subject(root) @@ -190,12 +239,60 @@ describe("git operation runner", function() end) end + -- an interactive rebase stopped on feature's first commit (`first`, default pick), with + -- `second` (default pick) then `verb` on later commits that apply cleanly + local function todo_rebase(verb, first, second) + local root = diverged_repo() + git_ok(root, "checkout", "-q", "feature") + write(root .. "/h.txt", "h\n") + git_ok(root, "add", "h.txt") + git_ok(root, "commit", "-q", "-m", "feature h") + write(root .. "/i.txt", "i\n") + git_ok(root, "add", "i.txt") + git_ok(root, "commit", "-q", "-m", "feature i") + local function sha(rev) + return vim.trim(git_ok(root, "rev-parse", rev)) + end + local todo = vim.fn.tempname() + write( + todo, + ("%s %s\n%s %s\n%s %s\n"):format( + first or "pick", + sha("feature~3"), + second or "pick", + sha("feature~1"), + verb, + sha("feature") + ) + ) + local editor = vim.fn.tempname() + write(editor, ('#!/bin/sh\ncp %s "$1"\n'):format(vim.fn.shellescape(todo))) + vim.fn.setfperm(editor, "rwx------") + git(root, "-c", "sequence.editor=" .. editor, "rebase", "-i", "main") + assert.are.same({ "f.txt" }, git_src.conflicted(root)) + return root + end + + for _, case in ipairs({ { "reword", "reworded" }, { "squash", "squashed" } }) do + it("opens the message of a later " .. case[1] .. " in the todo", function() + local root = todo_rebase(case[1]) + resolve_all(root) + local res = start(root, "continue") + answer(res) -- the stop on f.txt + answer(res, { case[2] }) + assert.is_true(finished(res).ok, res().output) + assert.is_nil(git_src.operation(root)) + assert.are.equal(case[2], subject(root)) + end) + end + + local edited = { "edited subject", "", "# a comment", "body" } + for _, op in ipairs({ "merge", "rebase", "cherry-pick", "revert" }) do - it(op .. " continues with an edited message", function() + it(op .. " commits the message written in the editor", function() local root = stopped(op) resolve_all(root) - assert.is_not_nil(operation.prepared_message(root, git_src.operation(root))) - run(root, "continue", { message = "edited subject\n\n# a comment\nbody\n" }) + run(root, "continue", edited) -- the sequence ops stop again on g.txt, after committing the edited first one assert.are.equal( "edited subject\n\nbody", @@ -205,18 +302,159 @@ describe("git operation runner", function() assert.are.same({ "g.txt" }, git_src.conflicted(root)) end end) + + it("leaves a " .. op .. " stopped when the message is emptied", function() + local root = stopped(op) + resolve_all(root) + local head = git_ok(root, "rev-parse", "HEAD") + local res = run(root, "continue", {}) + assert.is_false(res.ok) + assert.matches("empty commit message", res.output) + assert.are.equal(op, git_src.operation(root)) + assert.are.equal(head, git_ok(root, "rev-parse", "HEAD")) + end) + end + + it("commits git's message when the edited message closes unwritten", function() + local root = stopped("merge") + resolve_all(root) + local res = start(root, "continue") + local buf = assert(message_buffer(res), "git never asked for a message") + vim.api.nvim_buf_set_lines(buf, 0, 1, false, { "unwritten" }) + vim.api.nvim_buf_delete(buf, { force = true }) + assert.is_true(finished(res).ok, res().output) + assert.are.equal("Merge branch 'feature'", subject(root)) + end) + + it("commits the stopped commit's message unasked when accepting it", function() + local root = stopped("merge") + resolve_all(root) + local res = start(root, "continue", true) + assert.is_nil(message_buffer(res)) + assert.is_true(finished(res).ok, res().output) + assert.are.equal("Merge branch 'feature'", subject(root)) + end) + + it("still asks for a later reword when accepting the stopped message", function() + local root = todo_rebase("reword") + resolve_all(root) + local res = start(root, "continue", true) + answer(res, { "reworded" }) + assert.is_true(finished(res).ok, res().output) + assert.are.equal("reworded", subject(root)) + assert.are.equal("feature f", vim.trim(git_ok(root, "log", "-1", "--format=%s", "HEAD~2"))) + end) + + for _, case in ipairs({ { "reword", "reworded" }, { "squash", "squashed" } }) do + it("still asks for a " .. case[1] .. " after an edit stop when accepting", function() + local root = todo_rebase(case[1], "pick", "edit") + resolve_all(root) + assert.is_true(finished(start(root, "continue", true)).ok) + assert.are.equal("rebase", git_src.operation(root)) + local res = start(root, "continue", true) + answer(res, { case[2] }) + assert.is_true(finished(res).ok, res().output) + assert.are.equal(case[2], subject(root)) + end) + end + + it("asks for the message of a reword that stopped on a conflict when accepting", function() + local root = todo_rebase("pick", "reword") + resolve_all(root) + local res = start(root, "continue", true) + answer(res, { "reworded" }) + assert.is_true(finished(res).ok, res().output) + assert.are.equal("reworded", vim.trim(git_ok(root, "log", "-1", "--format=%s", "HEAD~2"))) + end) + + -- a --rebase-merges rebase stopped on a conflicting `merge` todo line, `flag` being + -- -C, -c or nil + local function merges_rebase(flag) + local root = vim.fn.tempname() + vim.fn.mkdir(root, "p") + git_ok(root, "init", "-q", "-b", "main") + git_ok(root, "config", "user.email", "t@t") + git_ok(root, "config", "user.name", "t") + git_ok(root, "config", "rerere.enabled", "false") + write(root .. "/f.txt", "a\n") + git_ok(root, "add", ".") + git_ok(root, "commit", "-q", "-m", "base") + git_ok(root, "checkout", "-q", "-b", "side") + write(root .. "/f.txt", "side\n") + git_ok(root, "commit", "-q", "-am", "side") + git_ok(root, "checkout", "-q", "main") + write(root .. "/f.txt", "main\n") + git_ok(root, "commit", "-q", "-am", "main f") + git(root, "merge", "-q", "side") + write(root .. "/f.txt", "merged\n") + git_ok(root, "commit", "-q", "-am", "merge side") + git_ok(root, "checkout", "-q", "-b", "onto", "HEAD~2") + write(root .. "/g.txt", "onto\n") + git_ok(root, "add", "g.txt") + git_ok(root, "commit", "-q", "-m", "onto") + git_ok(root, "checkout", "-q", "main") + local function sha(rev) + return vim.trim(git_ok(root, "rev-parse", rev)) + end + local merge = "merge side-label" + if flag then + merge = ("merge %s %s side-label"):format(flag, sha("main")) + end + local todo = vim.fn.tempname() + write( + todo, + ("label onto\nreset onto\npick %s\nlabel side-label\nreset onto\npick %s\n%s\n"):format( + sha("side"), + sha("main~1"), + merge + ) + ) + local editor = vim.fn.tempname() + write(editor, ('#!/bin/sh\ncp %s "$1"\n'):format(vim.fn.shellescape(todo))) + vim.fn.setfperm(editor, "rwx------") + git( + root, + "-c", + "sequence.editor=" .. editor, + "-c", + "core.editor=true", + "rebase", + "-i", + "--rebase-merges", + "onto" + ) + assert.are.same({ "f.txt" }, git_src.conflicted(root)) + resolve_all(root) + return root + end + + it("accepts a conflicting merge -C's message", function() + local res = start(merges_rebase("-C"), "continue", true) + assert.is_nil(message_buffer(res)) + assert.is_true(finished(res).ok, res().output) + end) + + for _, flag in ipairs({ "-c", false }) do + local name = flag and ("merge " .. flag) or "bare merge" + it("asks for a conflicting " .. name .. "'s message when accepting", function() + local res = start(merges_rebase(flag or nil), "continue", true) + answer(res) + assert.is_true(finished(res).ok, res().output) + end) end - it("keeps prepare-commit-msg's output in an edited merge message", function() + it("shows prepare-commit-msg's output in the message", function() local root = stopped("merge") resolve_all(root) local hook = root .. "/.git/hooks/prepare-commit-msg" write(hook, '#!/bin/sh\necho hooked >> "$1"\n') vim.fn.setfperm(hook, "rwx------") - local res = run(root, "continue", { message = "edited\n" }) - assert.is_true(res.ok, res.output) - assert.is_nil(git_src.operation(root)) - assert.are.equal("edited\nhooked", vim.trim(git_ok(root, "log", "-1", "--format=%B"))) + local res = start(root, "continue") + local buf = assert(message_buffer(res), "git never asked for a message") + assert.is_true(vim.tbl_contains(vim.api.nvim_buf_get_lines(buf, 0, -1, false), "hooked")) + reply(buf) + assert.is_true(finished(res).ok, res().output) + assert.matches("\nhooked$", vim.trim(git_ok(root, "log", "-1", "--format=%B"))) assert.are.equal(2, #vim.split(vim.trim(git_ok(root, "log", "-1", "--format=%p")), " ")) end) @@ -224,17 +462,12 @@ describe("git operation runner", function() it("keeps the picked commit's author on an edited " .. op, function() local root = stopped(op) resolve_all(root) - run(root, "continue", { message = "edited\n" }) + run(root, "continue", { "edited" }) assert.are.equal("edited", subject(root)) assert.are.equal("f@f", vim.trim(git_ok(root, "log", "-1", "--format=%ae"))) end) end - it("has no prepared message for am or an apply-backend rebase", function() - assert.is_nil(operation.prepared_message(stopped("am"), "am")) - assert.is_nil(operation.prepared_message(stopped("rebase-apply"), "rebase")) - end) - it("reports git's output when a continue fails", function() local root = stopped("merge") local res = run(root, "continue") @@ -282,21 +515,21 @@ describe("git operation runner", function() it("continues a single-commit " .. op .. " with an edited message", function() local root = stopped(op, true) resolve_all(root) - local res = run(root, "continue", { message = "edited\n" }) + local res = run(root, "continue", { "edited" }) assert.is_true(res.ok, res.output) assert.is_nil(git_src.operation(root)) assert.are.equal("edited", subject(root)) end) end - it("stops before the --continue when the edited commit fails", function() + it("leaves the operation stopped when a hook refuses the commit", function() local root = stopped("cherry-pick") resolve_all(root) local head = git_ok(root, "rev-parse", "HEAD") local hook = root .. "/.git/hooks/commit-msg" write(hook, "#!/bin/sh\necho 'hook says no' >&2\nexit 1\n") vim.fn.setfperm(hook, "rwx------") - local res = run(root, "continue", { message = "edited\n" }) + local res = run(root, "continue") assert.is_false(res.ok) assert.matches("hook says no", res.output) assert.are.equal("cherry-pick", git_src.operation(root)) @@ -304,29 +537,50 @@ describe("git operation runner", function() end) describe("with signing on", function() + -- wait for the float to show git's terminal again + local function terminal_shown() + assert( + vim.wait(5000, function() + return vim.bo.buftype == "terminal" + end), + "the terminal float never came back" + ) + end + it("runs in a floating terminal that gpg can prompt on", function() local root = stopped("merge") resolve_all(root) local record = signing(root) local wins = #vim.api.nvim_list_wins() - local result - operation.run(root, "merge", "continue", nil, function(ok, output) - result = { ok = ok, output = output } - end) + local res = start(root, "continue") assert.are.equal(wins + 1, #vim.api.nvim_list_wins()) assert.are.equal("terminal", vim.bo.buftype) - assert( - vim.wait(10000, function() - return result ~= nil - end), - "the run never finished" - ) - assert.is_true(result.ok, result.output) + answer(res, { "signed" }) + assert.is_true(finished(res).ok, res().output) assert.are.equal(wins, #vim.api.nvim_list_wins()) assert.are.same({ "tty" }, vim.fn.readfile(record)) assert.matches("gpgsig", git_ok(root, "cat-file", "-p", "HEAD")) + assert.are.equal("signed", subject(root)) end) + for _, close in ipairs({ "wq", "write | bdelete" }) do + it("opens the message in the float and comes back after :" .. close, function() + local root = stopped("merge") + resolve_all(root) + signing(root, 30) + local res = start(root, "continue") + local buf = assert(message_buffer(res), "git never asked for a message") + local win = vim.api.nvim_get_current_win() + assert.are.equal(buf, vim.api.nvim_win_get_buf(win)) + assert.are_not.equal("", vim.api.nvim_win_get_config(win).relative) + vim.cmd("silent " .. close) + terminal_shown() + assert.are_not.equal("", vim.api.nvim_win_get_config(0).relative) + vim.cmd("bdelete!") + assert.is_false(finished(res).ok) + end) + end + it("hands back the float's output when git fails", function() local root = stopped("merge") signing(root) @@ -342,18 +596,11 @@ describe("git operation runner", function() resolve_all(root) signing(root, 30) local wins = #vim.api.nvim_list_wins() - local result - operation.run(root, "merge", "continue", nil, function(ok, output) - result = { ok = ok, output = output } - end) + local res = start(root, "continue") + answer(res) + terminal_shown() vim.cmd(close) - assert( - vim.wait(5000, function() - return result ~= nil - end), - "the run never handed back" - ) - assert.is_false(result.ok) + assert.is_false(finished(res).ok) assert.are.equal(wins, #vim.api.nvim_list_wins()) assert.are.equal("merge", git_src.operation(root)) end) @@ -363,15 +610,9 @@ describe("git operation runner", function() local root = stopped("merge") signing(root) local wins = #vim.api.nvim_list_wins() - local result - operation.run(root, "merge", "abort", nil, function(ok, output) - result = { ok = ok, output = output } - end) + local res = start(root, "abort") assert.are.equal(wins, #vim.api.nvim_list_wins()) - assert(vim.wait(10000, function() - return result ~= nil - end)) - assert.is_true(result.ok, result.output) + assert.is_true(finished(res).ok, res().output) end) it("runs a rebase started with -S in the float", function() @@ -380,26 +621,12 @@ describe("git operation runner", function() git_ok(root, "checkout", "-q", "feature") git(root, "rebase", "-S", "main") resolve_all(root) - local result - operation.run(root, "rebase", "continue", nil, function(ok, output) - result = { ok = ok, output = output } - end) + local res = start(root, "continue") assert.are.equal("terminal", vim.bo.buftype) - assert(vim.wait(10000, function() - return result ~= nil - end)) + answer(res) + finished(res) assert.are.same({ "tty" }, vim.fn.readfile(record)) end) - - it("signs an edited commit of a cherry-pick range started with -S", function() - local root = diverged_repo() - gpg_stub(root) - git(root, "cherry-pick", "-S", "feature~1", "feature") - resolve_all(root) - run(root, "continue", { message = "edited\n" }) - assert.are.equal("edited", subject(root)) - assert.matches("gpgsig", git_ok(root, "cat-file", "-p", "HEAD")) - end) end) end) @@ -408,8 +635,18 @@ describe(":Differ continue", function() local continue = require("differ.merge.continue") local saved, shown, chosen - -- stub the picker, choosing the entry labelled `chosen` (nil chooses nothing) + -- stub the picker, choosing the entry labelled `chosen` (nil chooses nothing), and + -- commit every message git asks for as it stands before_each(function() + vim.api.nvim_create_autocmd("BufWinEnter", { + group = vim.api.nvim_create_augroup("differ_test_messages", {}), + pattern = "*/COMMIT_EDITMSG", + callback = function(ev) + vim.schedule(function() + reply(ev.buf) + end) + end, + }) saved = { select = vim.ui.select, confirm = vim.fn.confirm } _G.notifs, shown, chosen = {}, nil, nil vim.ui.select = function(items, opts, on_choice) @@ -424,6 +661,7 @@ describe(":Differ continue", function() end) after_each(function() + vim.api.nvim_del_augroup_by_name("differ_test_messages") vim.ui.select, vim.fn.confirm = saved.select, saved.confirm if merge.current() then merge.close() @@ -431,6 +669,18 @@ describe(":Differ continue", function() vim.cmd("silent! %bwipeout!") end) + -- :Differ continue, then the picker's choice, which runs on the next loop + local function continue_cmd() + command.dispatch({ "continue" }) + local flushed = false + vim.schedule(function() + flushed = true + end) + vim.wait(1000, function() + return flushed + end) + end + -- the command acts on the current file's repo local function in_repo(root) vim.cmd.edit(vim.fn.fnameescape(root .. "/f.txt")) @@ -456,25 +706,23 @@ describe(":Differ continue", function() it("says there's nothing in progress", function() in_repo(diverged_repo()) - command.dispatch({ "continue" }) + continue_cmd() assert.is_nil(shown) assert.is_not_nil(noted("no merge, rebase, cherry%-pick, revert or am in progress")) end) + local edit = "continue and edit the message" local cases = { - { "merge", { "continue", "continue and edit the message", "abort" } }, - { "rebase", { "continue", "continue and edit the message", "skip", "abort" } }, + { "merge", { "continue", edit, "abort" } }, + { "rebase", { "continue", edit, "skip", "abort" } }, { "am", { "continue", "skip", "abort" } }, - { - "cherry-pick", - { "continue", "continue and edit the message", "skip", "abort" }, - }, - { "revert", { "continue", "continue and edit the message", "skip", "abort" } }, + { "cherry-pick", { "continue", edit, "skip", "abort" } }, + { "revert", { "continue", edit, "skip", "abort" } }, } for _, case in ipairs(cases) do it("offers " .. case[1] .. "'s options", function() in_repo(stopped(case[1])) - command.dispatch({ "continue" }) + continue_cmd() assert.are.equal(case[1] .. " options", shown.prompt) assert.are.same(case[2], shown.labels) end) @@ -492,7 +740,7 @@ describe(":Differ continue", function() local root = stopped("merge") in_repo(root) chosen = "continue" - command.dispatch({ "continue" }) + continue_cmd() assert.are.equal(vim.log.levels.WARN, noted("2 conflicted files left").level) assert.is_false(continue.running()) assert.is_not_nil(git_src.operation(root)) @@ -503,7 +751,7 @@ describe(":Differ continue", function() resolve_all(root) in_repo(root) chosen = "continue" - command.dispatch({ "continue" }) + continue_cmd() finished(root) assert.is_not_nil(noted("merge finished")) end) @@ -513,158 +761,38 @@ describe(":Differ continue", function() resolve_all(root) in_repo(root) chosen = "continue" - command.dispatch({ "continue" }) + continue_cmd() assert(vim.wait(10000, function() return noted("rebase stopped on 1 conflicted file") ~= nil end)) end) - it("continues with the message written in the edit buffer", function() - local root = stopped("merge") - resolve_all(root) - in_repo(root) - chosen = "continue and edit the message" - command.dispatch({ "continue" }) - local buf = vim.api.nvim_get_current_buf() - assert.are.equal("gitcommit", vim.bo[buf].filetype) - assert.are.equal("Merge branch 'feature'", vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1]) - vim.api.nvim_buf_set_lines(buf, 0, 1, false, { "edited" }) - vim.cmd("write") - finished(root) - assert.are.equal("edited", subject(root)) - assert.is_false(vim.api.nvim_buf_is_valid(buf)) - end) - - it("leaves focus in the signing float after the edit buffer is written", function() - local root = stopped("merge") - resolve_all(root) - signing(root) - in_repo(root) - chosen = "continue and edit the message" - command.dispatch({ "continue" }) - local buf = vim.api.nvim_get_current_buf() - vim.cmd("write") - assert( - vim.wait(1000, function() - return vim.bo.buftype == "terminal" - end), - "focus never reached the float" - ) - assert.is_false(vim.api.nvim_buf_is_valid(buf)) - finished(root) - end) - - -- the edit buffer for the stopped operation, open on its prefilled message - local function edit_buffer() - chosen = "continue and edit the message" - command.dispatch({ "continue" }) - local buf = vim.api.nvim_get_current_buf() - assert.are.equal("gitcommit", vim.bo[buf].filetype) - return buf - end - - -- edit the message's subject and close the buffer unwritten - local function draft(text) - local buf = edit_buffer() - vim.api.nvim_buf_set_lines(buf, 0, 1, false, { text }) - vim.cmd("bwipeout!") - end - - local function first_line(buf) - return vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1] - end - - describe("message draft", function() - it("is kept when the edit buffer closes unwritten, and reopens", function() - local root = stopped("merge") - resolve_all(root) + for _, case in ipairs({ + { "abort", "merge", "merge aborted" }, + { "skip", "rebase", "rebase stopped on 1 conflicted file" }, + }) do + it(case[1] .. "s once confirmed, and not otherwise", function() + local root = stopped(case[2]) in_repo(root) - draft("drafted") + local head = git_ok(root, "rev-parse", "HEAD") + local conflicted = git_src.conflicted(root) + chosen = case[1] + vim.fn.confirm = function() + return 2 + end + continue_cmd() assert.is_false(continue.running()) - assert.is_not_nil(git_src.operation(root)) - assert.is_not_nil(noted("merge message draft kept")) - assert.are.equal("drafted", first_line(edit_buffer())) - end) - - it("isn't kept when the edit buffer closes unchanged", function() - local root = stopped("merge") - resolve_all(root) - in_repo(root) - edit_buffer() - vim.cmd("bwipeout!") - assert.is_nil(noted("draft kept")) - end) - - it("is committed by a write, then cleared", function() - local root = stopped("merge") - resolve_all(root) - in_repo(root) - draft("drafted") - edit_buffer() - vim.cmd("write") - finished(root) - assert.are.equal("drafted", subject(root)) - git_ok(root, "reset", "-q", "--hard", "HEAD~1") - git(root, "merge", "--no-edit", "feature") - resolve_all(root) - assert.are.equal("Merge branch 'feature'", first_line(edit_buffer())) - end) - - it("is ignored by a plain continue", function() - local root = stopped("merge") - resolve_all(root) - in_repo(root) - draft("drafted") - chosen = "continue" - command.dispatch({ "continue" }) - finished(root) - assert.are.equal("Merge branch 'feature'", subject(root)) - end) - - it("is cleared by an abort", function() - local root = stopped("merge") - resolve_all(root) - in_repo(root) - draft("drafted") - chosen = "abort" + assert.are.equal(head, git_ok(root, "rev-parse", "HEAD")) + assert.are.same(conflicted, git_src.conflicted(root)) vim.fn.confirm = function() return 1 end - command.dispatch({ "continue" }) - finished(root) - git(root, "merge", "--no-edit", "feature") - resolve_all(root) - assert.are.equal("Merge branch 'feature'", first_line(edit_buffer())) - end) - - it("isn't offered on the next stop of a rebase", function() - local root = stopped("rebase") - resolve_all(root) - in_repo(root) - draft("drafted") - git(root, "-c", "core.editor=true", "rebase", "--continue") - resolve_all(root) - assert.are.equal("feature g", first_line(edit_buffer())) + continue_cmd() + assert(vim.wait(10000, function() + return noted(case[3]) ~= nil + end)) end) - end) - - it("aborts once confirmed, and not otherwise", function() - local root = stopped("merge") - in_repo(root) - chosen = "abort" - vim.fn.confirm = function() - return 2 - end - command.dispatch({ "continue" }) - assert.is_false(continue.running()) - assert.is_not_nil(git_src.operation(root)) - vim.fn.confirm = function() - return 1 - end - command.dispatch({ "continue" }) - finished(root) - assert.is_not_nil(noted("merge aborted")) - end) + end describe("from the merge tool", function() -- a session on f.txt with its result resolved but unsaved @@ -704,7 +832,7 @@ describe(":Differ continue", function() write(root .. "/g.txt", "resolved\n") git_ok(root, "add", "g.txt") chosen = "continue" - command.dispatch({ "continue" }) + continue_cmd() finished(root) assert.is_nil(merge.current()) assert.is_not_nil(noted("merge finished")) @@ -715,7 +843,7 @@ describe(":Differ continue", function() local root = stopped("rebase") session_on(root) chosen = "continue" - command.dispatch({ "continue" }) + continue_cmd() assert( vim.wait(10000, function() local s = merge.current() @@ -736,7 +864,7 @@ describe(":Differ continue", function() vim.fn.confirm = function() return 1 end - command.dispatch({ "continue" }) + continue_cmd() finished(root) assert.is_nil(merge.current()) assert.is_nil(outside_closes()) @@ -753,7 +881,7 @@ describe(":Differ continue", function() vim.fn.confirm = function() return 1 end - command.dispatch({ "continue" }) + continue_cmd() local buf = vim.fn.bufnr(root .. "/f.txt") assert( vim.wait(10000, function() @@ -773,7 +901,7 @@ describe(":Differ continue", function() write(root .. "/g.txt", "resolved\n") git_ok(root, "add", "g.txt") chosen = "continue" - command.dispatch({ "continue" }) + continue_cmd() assert(vim.wait(10000, function() return noted("hook says no") ~= nil end)) @@ -792,8 +920,8 @@ describe(":Differ continue", function() vim.fn.setfperm(hook, "rwx------") in_repo(root) chosen = "continue" - command.dispatch({ "continue" }) - command.dispatch({ "continue" }) + continue_cmd() + continue_cmd() local refused = noted("still running") assert.is_not_nil(refused) assert.are.equal(vim.log.levels.WARN, refused.level) @@ -808,7 +936,7 @@ describe(":Differ continue", function() resolve_all(root) in_repo(root) chosen = "continue" - command.dispatch({ "continue" }) + continue_cmd() assert( vim.wait(10000, function() local s = merge.current() From 8f030241ee13aa9df8a1c8d08c2f8a67106af875 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:24:51 +0100 Subject: [PATCH 18/25] feat: resolve binary conflicts in the merge tool by taking a side --- lua/differ/git/index.lua | 12 ++ lua/differ/git/init.lua | 3 +- lua/differ/git/operation.lua | 43 ++++- lua/differ/merge/binary.lua | 54 ++++++ lua/differ/merge/deleted.lua | 11 +- lua/differ/merge/files.lua | 2 +- lua/differ/merge/init.lua | 68 ++++++- lua/differ/merge/model.lua | 54 +++++- test/nvim/mergetool_spec.lua | 357 +++++++++++++++++++++++++++++------ 9 files changed, 516 insertions(+), 88 deletions(-) create mode 100644 lua/differ/merge/binary.lua diff --git a/lua/differ/git/index.lua b/lua/differ/git/index.lua index 4c1940a..0b1d33f 100644 --- a/lua/differ/git/index.lua +++ b/lua/differ/git/index.lua @@ -27,6 +27,18 @@ function M.remove(root, path) return git_ok({ "rm", "-q", "--", path }, root, "delete") end +-- resolve a conflict to one side's stage: check it out over the worktree file, then stage it +---@param root string +---@param path string +---@param side "ours"|"theirs" +---@return boolean ok +function M.take_side(root, path, side) + if not git_ok({ "checkout", "--" .. side, "--", path }, root, "take " .. side) then + return false + end + return M.stage(root, path) +end + ---@param root string ---@return boolean ok function M.stage_all(root) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 3b4e4ed..aeadbc1 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -368,6 +368,7 @@ function M.conflicted(root) end M.operation, M.operation_label = operation_ops.current, operation_ops.label +M.operation_theirs = operation_ops.theirs_name -- repo-relative paths of untracked files, respecting .gitignore, in git's order ---@param root string @@ -810,7 +811,7 @@ end -- the index ops the panel's file keys and the merge tool drive M.stage, M.unstage = index_ops.stage, index_ops.unstage -M.remove = index_ops.remove +M.remove, M.take_side = index_ops.remove, index_ops.take_side M.stage_all, M.unstage_all = index_ops.stage_all, index_ops.unstage_all -- reverse-apply a single-hunk patch to the worktree, atomically. `--unidiff-zero` diff --git a/lua/differ/git/operation.lua b/lua/differ/git/operation.lua index 1aa3e87..36d61b5 100644 --- a/lua/differ/git/operation.lua +++ b/lua/differ/git/operation.lua @@ -67,9 +67,9 @@ function M.current(root) end -- name-rev walks history, which takes hundreds of ms in a large repo, and the panel asks --- on every refresh while the named commit only changes once per operation step ----@type { key: string, name: string }|nil -local last_name +-- on every refresh while the named commits only change once per operation step +---@type table +local names = {} -- a commit as `git name-rev` names it (`topic`, `main~1`), else its short sha ---@param root string @@ -77,12 +77,11 @@ local last_name ---@return string local function commit_name(root, sha) local key = root .. "\0" .. sha - if last_name and last_name.key == key then - return last_name.name + if not names[key] then + local out = exec.git({ "name-rev", "--name-only", "--no-undefined", sha }, root) + names[key] = out and exec.chomp(out) or sha:sub(1, 7) end - local out = exec.git({ "name-rev", "--name-only", "--no-undefined", sha }, root) - last_name = { key = key, name = out and exec.chomp(out) or sha:sub(1, 7) } - return last_name.name + return names[key] end -- the first line of a git-dir state file, or nil when it isn't there @@ -156,6 +155,34 @@ function M.label(root) return ("(%s)%s"):format(verb, detail) end +-- the commit each operation's theirs side comes from; during a rebase, the one being replayed +local THEIRS_HEADS = { + merge = "MERGE_HEAD", + rebase = "REBASE_HEAD", + ["cherry-pick"] = "CHERRY_PICK_HEAD", + revert = "REVERT_HEAD", +} + +-- the name of the commit a conflict's theirs side comes from, as git's markers label it +-- (a revert's theirs is the reverted commit's parent). nil for am, which applies a patch +---@param root string +---@return string|nil +function M.theirs_name(root) + local op = M.current(root) + local file = op and THEIRS_HEADS[op] + if not file then + return nil + end + local sha = state_line(assert(git_dir(root)), file) + if not sha then + return nil + end + if op == "revert" then + return "parent of " .. commit_name(root, sha) + end + return commit_name(root, sha) +end + -- todo lines whose message git commits without asking, conflicts aside: a pick, and a -- merge that reuses its message with -C (-c or no flag asks) local KEEPS_MESSAGE = { "^pick ", "^p ", "^merge %-C ", "^m %-C " } diff --git a/lua/differ/merge/binary.lua b/lua/differ/merge/binary.lua new file mode 100644 index 0000000..7fdedf5 --- /dev/null +++ b/lua/differ/merge/binary.lua @@ -0,0 +1,54 @@ +-- binary conflicts in the merge tool: git writes no markers into a binary file, so it +-- resolves by checking out one side's stage whole + +local notify = require("differ.git.exec").notify + +local M = {} + +---@param bytes integer +---@return string +local function size(bytes) + if bytes < 1024 then + return bytes .. " B" + end + if bytes < 1024 * 1024 then + return ("%.1f KB"):format(bytes / 1024) + end + return ("%.1f MB"):format(bytes / (1024 * 1024)) +end + +-- the notice an input pane shows for its side's stage +---@param blob differ.merge.Blob +---@return string +function M.describe(blob) + return ("binary · %s · %s"):format(size(blob.size), blob.oid:sub(1, 7)) +end + +-- check out `side`'s stage and stage it; also a binary modify/delete's keep +---@param s differ.MergeSession +---@param side "ours"|"theirs" +function M.take(s, side) + if not require("differ.git").take_side(s.root, s.path, side) then + return + end + require("differ.merge.files").mark_staged(s) + require("differ.merge")._show_notice( + s.result_win, + s.result_buf, + ("took %s, staged"):format(side) + ) +end + +---@param s differ.MergeSession +---@param choice "ours"|"theirs"|"both"|"base"|"none" +function M.resolve(s, choice) + if s.staged then + return notify(("%s is already resolved"):format(s.path)) + end + if choice ~= "ours" and choice ~= "theirs" then + return notify("a binary file can't be merged: take ours or theirs", vim.log.levels.WARN) + end + M.take(s, choice --[[@as "ours"|"theirs"]]) +end + +return M diff --git a/lua/differ/merge/deleted.lua b/lua/differ/merge/deleted.lua index a685787..3e852b1 100644 --- a/lua/differ/merge/deleted.lua +++ b/lua/differ/merge/deleted.lua @@ -5,9 +5,17 @@ local notify = require("differ.git.exec").notify local M = {} --- keep the file: write it as it stands, which stages it like any write +-- keep the file: write it as it stands, which stages it like any write. a binary file is +-- staged from disk instead, since a write from nvim can change its bytes ---@param s differ.MergeSession local function keep_file(s) + if s.binary then + local surviving = "ours" + if s.deleted == "ours" then + surviving = "theirs" + end + return require("differ.merge.binary").take(s, surviving) + end local ok, err = pcall(vim.api.nvim_buf_call, s.result_buf, function() vim.cmd("silent write") end) @@ -27,6 +35,7 @@ local function show_deleted(s) local notice = s.path .. " deletion staged" -- acwrite, so a :w reaches the BufWriteCmd below rather than failing on the buftype vim.bo[buf].buftype = "acwrite" + vim.bo[buf].modified = false -- or stepping off would save it vim.api.nvim_create_autocmd("BufWriteCmd", { buffer = buf, group = s.aug, diff --git a/lua/differ/merge/files.lua b/lua/differ/merge/files.lua index 258ed6c..b1b6356 100644 --- a/lua/differ/merge/files.lua +++ b/lua/differ/merge/files.lua @@ -5,7 +5,7 @@ local notify = require("differ.git.exec").notify local M = {} --- conflicted paths the merge tool can't open (added by one side, deleted by both, binary) +-- conflicted paths the merge tool can't open (added by one side, deleted by both, a symlink) ---@param relpaths string[] function M.report_unopenable(relpaths) notify( diff --git a/lua/differ/merge/init.lua b/lua/differ/merge/init.lua index 1e3d435..1984c24 100644 --- a/lua/differ/merge/init.lua +++ b/lua/differ/merge/init.lua @@ -68,6 +68,7 @@ local INPUT_HL = { ---@field labels table -- side -> winbar pane label (OURS (HEAD), ...) ---@field no_ancestor boolean -- add/add: the base pane says so rather than sitting empty ---@field deleted "ours"|"theirs"|nil -- modify/delete: the side that deleted the file +---@field binary boolean -- binary stages: the result is a scratch notice, not the file ---@field layout "default"|"diff4" -- the chosen layout, reused when stepping to another file ---@field files string[] -- the conflicted files at session start, in git's order (]f/[f) ---@field file_index integer -- this file's position in files @@ -78,8 +79,8 @@ local INPUT_HL = { ---@field operation string|nil -- the in-progress operation at session start ---@field watcher differ.git.Watcher|nil -- the git dir watch for changes made outside the session ---@field result_win integer ----@field result_buf integer -- the real worktree file (editable) ----@field bufs integer[] -- the scratch input buffers (deleted on close) +---@field result_buf integer -- the real worktree file (editable), or a binary's scratch notice +---@field bufs integer[] -- the scratch buffers (deleted on close) ---@field inputs differ.MergeInput[] -- the input panes (for pane sync on navigation) ---@field win_side table -- window id -> side (for the winbar) ---@field return_tab integer @@ -595,6 +596,9 @@ local function resolve_choice(choice) if s.deleted then return require("differ.merge.deleted").resolve(s, choice) end + if s.binary then + return require("differ.merge.binary").resolve(s, choice) + end local regions = live_regions() if #regions == 0 then return notify("no conflicts remain") @@ -693,6 +697,9 @@ local function result_winbar(s) if s.deleted and not s.staged then return ("%s · %s"):format(file, tint("deleted in " .. s.deleted, "differMergeUnstaged")) end + if s.binary and not s.staged then + return ("%s · binary · %s"):format(file, tint("not staged", "differMergeUnstaged")) + end if remaining == 0 and s.staged then local done = tint(s.removed and "deleted" or "staged", "differMergeStaged") if s.left == 0 and s.operation then @@ -745,11 +752,13 @@ local function dress(win) set_local(win, "winbar", '%!v:lua.require("differ.merge").winbar()') end --- an empty pane that stands for a deleted file: one dim virtual line, no line numbers +-- an empty pane that stands for a deleted or binary file: one dim virtual line, no line +-- numbers. a second call replaces the line ---@param win integer ---@param buf integer ---@param text string local function show_notice(win, buf, text) + vim.api.nvim_buf_clear_namespace(buf, notice_ns, 0, -1) set_local(win, "number", false) set_local(win, "cursorline", false) set_local(win, "signcolumn", "no") @@ -879,6 +888,11 @@ local function show_help() { fmt(km.choose_ours), deleted == "ours" and "delete the file" or "keep the file" }, { fmt(km.choose_theirs), deleted == "theirs" and "delete the file" or "keep the file" }, }) + elseif in_result and session.binary then + vim.list_extend(rows, { + { fmt(km.choose_ours), "take ours" }, + { fmt(km.choose_theirs), "take theirs" }, + }) elseif in_result then vim.list_extend(rows, { { pair(km.next_marker, km.prev_marker), "next / previous marker line" }, @@ -901,8 +915,8 @@ local function show_help() help.show(help.lines(rows), { title = " Differ: merge ", dismiss = dismiss }) end --- the input pane labels, named by the first conflict's markers. a modify/delete file has --- none: HEAD is ours under every operation, but theirs has no one ref to name +-- the input pane labels, named by the first conflict's markers. a modify/delete or binary +-- file has none: HEAD is ours under every operation, and theirs is named from git's state ---@param model differ.MergeModel ---@return table local function side_labels(model) @@ -911,6 +925,11 @@ local function side_labels(model) if first then labels.ours = ("OURS (%s)"):format(first.label_ours or "HEAD") labels.theirs = ("THEIRS (%s)"):format(first.label_theirs or "MERGE_HEAD") + else + local theirs = require("differ.git").operation_theirs(model.root) + if theirs then + labels.theirs = ("THEIRS (%s)"):format(theirs) + end end if model.deleted then labels[model.deleted] = labels[model.deleted] .. " · deleted" @@ -964,8 +983,8 @@ local function settle_result() local s = assert(session) -- on_write warns about a reformatting formatter once; a later refusal has to say why local warned = s.autoformat_warned - -- an untouched modify/delete file waits for keep or delete; stepping past isn't a keep - if s.deleted and not s.staged and not vim.bo[s.result_buf].modified then + -- an untouched modify/delete or binary file waits for a take; stepping past isn't one + if (s.deleted or s.binary) and not s.staged and not vim.bo[s.result_buf].modified then return true end if vim.bo[s.result_buf].modified then @@ -1037,12 +1056,21 @@ local function lay_out(root, relpath, model, layout, files) vim.api.nvim_set_current_win(top) local abs = root .. "/" .. relpath - local bufs, result_buf = {}, nil -- bufs: the scratch input buffers (deleted on close) + local bufs, result_buf = {}, nil -- bufs: the scratch buffers (deleted on close) local result_col -- the result render column (for its fold ranges) local inputs = {} -- { side, win, regions, folds } local input_wins = {} for i, col in ipairs(result.columns) do - if i == result.result_index then + if i == result.result_index and model.binary then + -- never the real file: a write from nvim can change a binary's bytes + result_buf = make_buffer("result", relpath, {}) + -- acwrite, so a :w reaches the session's BufWriteCmd rather than failing on the buftype + vim.bo[result_buf].buftype = "acwrite" + vim.bo[result_buf].modified = false -- or stepping off would save it + bufs[#bufs + 1] = result_buf + vim.api.nvim_win_set_buf(result_win, result_buf) + result_col = col + elseif i == result.result_index then -- the result is the REAL worktree file: editable, native syntax/undo/LSP, and -- :w writes it for real (BufWritePost stages it once it's marker-free) vim.api.nvim_win_call(result_win, function() @@ -1078,11 +1106,21 @@ local function lay_out(root, relpath, model, layout, files) set_local(win, "signcolumn", "yes:1") -- room for the ▌ slab sign end for _, inp in ipairs(inputs) do + local buf = vim.api.nvim_win_get_buf(inp.win) if inp.side == model.deleted then - show_notice(inp.win, vim.api.nvim_win_get_buf(inp.win), "deleted in " .. inp.side) + show_notice(inp.win, buf, "deleted in " .. inp.side) + elseif model.binary and model.binary[inp.side] then + show_notice( + inp.win, + buf, + require("differ.merge.binary").describe(model.binary[inp.side]) + ) end end dress(result_win) + if model.binary then + show_notice(result_win, result_buf, "binary file: take ours or theirs") + end -- nav + take-this resolution live on the result buffer (the working surface), from -- the configurable merge keymaps; falls back to the flat defaults when setup @@ -1108,6 +1146,7 @@ local function lay_out(root, relpath, model, layout, files) labels = side_labels(model), no_ancestor = model.no_ancestor or false, deleted = model.deleted, + binary = model.binary ~= nil, layout = layout, files = files, file_index = assert(file_index, "merge: " .. relpath .. " is not in the file list"), @@ -1222,6 +1261,15 @@ local function lay_out(root, relpath, model, layout, files) group = aug, callback = on_write, }) + if model.binary then + vim.api.nvim_create_autocmd("BufWriteCmd", { + buffer = result_buf, + group = aug, + callback = function() + notify(("%s is binary: nothing to write"):format(relpath)) + end, + }) + end -- keep the active-conflict emphasis tracking the cursor vim.api.nvim_create_autocmd("CursorMoved", { buffer = result_buf, diff --git a/lua/differ/merge/model.lua b/lua/differ/merge/model.lua index f4d8392..12a103b 100644 --- a/lua/differ/merge/model.lua +++ b/lua/differ/merge/model.lua @@ -6,6 +6,7 @@ local conflict = require("differ.git.conflict") local find_run = require("differ.util.lines").find_run local find_runs = require("differ.util.lines").find_runs +local is_binary = require("differ.util.text").is_binary local to_lines = require("differ.util.text").to_lines ---@class differ.MergeModel @@ -19,6 +20,11 @@ local to_lines = require("differ.util.text").to_lines ---@field head string|nil ---@field no_ancestor boolean -- add/add: no :1: stage, so no base exists to recover or take ---@field deleted "ours"|"theirs"|nil -- modify/delete: the side that deleted the file, no regions +---@field binary table<"ours"|"base"|"theirs", differ.merge.Blob>|nil -- binary stages: no regions, empty texts + +---@class differ.merge.Blob +---@field oid string +---@field size integer local M = {} @@ -171,8 +177,28 @@ local function deleted_side(root, relpath) return "ours" end +local STAGE_SIDES = { ["1"] = "base", ["2"] = "ours", ["3"] = "theirs" } + +-- each present stage's object id and size, by side +---@param root string +---@param relpath string +---@param texts table<"ours"|"base"|"theirs", string> +---@return table<"ours"|"base"|"theirs", differ.merge.Blob> +local function stage_blobs(root, relpath, texts) + local listed = require("differ.git.exec").git({ "ls-files", "-s", "--", relpath }, root) + local blobs = {} + for oid, stage in (listed or ""):gmatch("%d+ (%x+) (%d)\t") do + local side = STAGE_SIDES[stage] + if side then + blobs[side] = { oid = oid, size = #texts[side] } + end + end + return blobs +end + -- build a MergeModel for a conflicted `relpath`. returns nil + a reason when the file --- isn't on disk, or carries no conflict markers and isn't a modify/delete conflict +-- isn't on disk, or carries no conflict markers and is neither binary nor a +-- modify/delete conflict ---@param root string ---@param relpath string ---@param head string|nil @@ -183,6 +209,27 @@ function M.build(root, relpath, head) if not result_text then return nil, relpath .. " is not in the working tree" end + local ours_text = git.read_stage(root, relpath, 2) + local base_text = git.read_stage(root, relpath, 1) + local theirs_text = git.read_stage(root, relpath, 3) + -- only ambiguous when the stage read back empty, so the extra call sits inside that + local no_ancestor = base_text == "" and not git.has_stage(root, relpath, 1) + if is_binary(ours_text) or is_binary(base_text) or is_binary(theirs_text) then + local texts = { ours = ours_text, base = base_text, theirs = theirs_text } + return { + path = relpath, + root = root, + ours_text = "", + base_text = "", + theirs_text = "", + result_text = "", + regions = {}, + head = head, + no_ancestor = no_ancestor, + deleted = deleted_side(root, relpath), + binary = stage_blobs(root, relpath, texts), + } + end local regions = conflict.parse(to_lines(result_text)) local deleted = nil if #regions == 0 then @@ -191,11 +238,6 @@ function M.build(root, relpath, head) return nil, relpath .. " has no conflicts to resolve" end end - local ours_text = git.read_stage(root, relpath, 2) - local base_text = git.read_stage(root, relpath, 1) - local theirs_text = git.read_stage(root, relpath, 3) - -- only ambiguous when the stage read back empty, so the extra call sits inside that - local no_ancestor = base_text == "" and not git.has_stage(root, relpath, 1) -- with no ancestor the re-merge still yields empty base slabs, which recover would copy -- across as `{}`; that's truthy, so take-base would splice it and silently drop the block if not no_ancestor then diff --git a/test/nvim/mergetool_spec.lua b/test/nvim/mergetool_spec.lua index a44e120..26038b4 100644 --- a/test/nvim/mergetool_spec.lua +++ b/test/nvim/mergetool_spec.lua @@ -257,27 +257,31 @@ local function conflict_repo_modify_delete(files, deleter) return root end --- a repo where merging `feature` conflicts on a.bin, a binary file git writes no markers --- into, sorted before a content conflict in each of `files` +-- a repo where merging `feature` conflicts on a.lnk, a symlink the merge tool can't open +-- (its targets don't exist), sorted before a content conflict in each of `files` ---@param files string[] -local function conflict_repo_binary(files) +local function conflict_repo_symlink(files) local root = vim.fn.tempname() + local function link(target) + vim.fn.delete(root .. "/a.lnk") + vim.uv.fs_symlink(target, root .. "/a.lnk") + end vim.fn.mkdir(root, "p") git_ok(root, "init", "-q") - write(root .. "/a.bin", "base\0") + link("base-target") for _, f in ipairs(files) do write(root .. "/" .. f, "a\nb\nc\n") end git_ok(root, "add", ".") git_ok(root, "commit", "-q", "-m", "base") git_ok(root, "checkout", "-q", "-b", "feature") - write(root .. "/a.bin", "theirs\0") + link("theirs-target") for _, f in ipairs(files) do write(root .. "/" .. f, "a\nTHEIRS\nc\n") end git_ok(root, "commit", "-q", "-am", "theirs") git_ok(root, "checkout", "-q", "main") - write(root .. "/a.bin", "ours\0") + link("ours-target") for _, f in ipairs(files) do write(root .. "/" .. f, "a\nOURS\nc\n") end @@ -286,6 +290,38 @@ local function conflict_repo_binary(files) return root end +-- a repo where merging `feature` conflicts on a.bin, a binary file git writes no markers +-- into, sorted before a content conflict in f.txt. `kind` "add" adds a.bin on both sides +-- (no base), "delete" deletes it on theirs +---@param kind "add"|"delete"|nil +local function conflict_repo_binary(kind) + local root = vim.fn.tempname() + vim.fn.mkdir(root, "p") + git_ok(root, "init", "-q") + if kind ~= "add" then + write(root .. "/a.bin", "base\0") + end + write(root .. "/f.txt", "a\nb\nc\n") + git_ok(root, "add", ".") + git_ok(root, "commit", "-q", "-m", "base") + git_ok(root, "checkout", "-q", "-b", "feature") + if kind == "delete" then + git_ok(root, "rm", "-q", "a.bin") + else + write(root .. "/a.bin", "theirs\0") + end + write(root .. "/f.txt", "a\nTHEIRS\nc\n") + git_ok(root, "add", ".") + git_ok(root, "commit", "-q", "-m", "theirs") + git_ok(root, "checkout", "-q", "main") + write(root .. "/a.bin", "ours\0") + write(root .. "/f.txt", "a\nOURS\nc\n") + git_ok(root, "add", ".") + git_ok(root, "commit", "-q", "-m", "ours") + git(root, "merge", "feature") + return root +end + -- winbar text without its %#Group# / %* highlight items ---@param bar string ---@return string @@ -703,7 +739,7 @@ describe(":Differ mergetool resolution", function() end) it("names the conflicts it can't open once only those remain, staying open", function() - local root = conflict_repo_binary({ "f.txt" }) + local root = conflict_repo_symlink({ "f.txt" }) vim.cmd.edit(root .. "/f.txt") merge.open({}) local s = merge.current() @@ -712,7 +748,7 @@ describe(":Differ mergetool resolution", function() assert.are.equal(s, merge.current()) local last = _G.notifs[#_G.notifs] assert.are.equal( - "differ: a.bin can't be opened in the merge tool: resolve with git add/rm", + "differ: a.lnk can't be opened in the merge tool: resolve with git add/rm", last.msg ) for _, n in ipairs(_G.notifs) do @@ -721,7 +757,7 @@ describe(":Differ mergetool resolution", function() end) it("closes, naming them, when an operation stops only on conflicts it can't open", function() - local root = conflict_repo_binary({ "f.txt" }) + local root = conflict_repo_symlink({ "f.txt" }) vim.cmd.edit(root .. "/f.txt") merge.open({}) git_ok(root, "add", "f.txt") @@ -729,19 +765,19 @@ describe(":Differ mergetool resolution", function() merge.after_operation(root) assert.is_nil(merge.current()) assert.are.equal( - "differ: a.bin can't be opened in the merge tool: resolve with git add/rm", + "differ: a.lnk can't be opened in the merge tool: resolve with git add/rm", _G.notifs[#_G.notifs].msg ) end) it("says why when asked to open a conflict it can't", function() - local root = conflict_repo_binary({ "f.txt" }) + local root = conflict_repo_symlink({ "f.txt" }) vim.cmd.edit(root .. "/f.txt") _G.notifs = {} - merge.open({ path = root .. "/a.bin" }) + merge.open({ path = root .. "/a.lnk" }) assert.is_nil(merge.current()) assert.are.equal( - "differ: a.bin can't be opened in the merge tool: resolve with git add/rm", + "differ: a.lnk can't be opened in the merge tool: resolve with git add/rm", _G.notifs[1].msg ) assert.are.equal(vim.log.levels.WARN, _G.notifs[1].level) @@ -952,15 +988,15 @@ describe(":Differ mergetool file stepping", function() end) it("skips staged files and ones it can't open", function() - local root = conflict_repo_binary({ "f.txt", "g.txt", "h.txt" }) + local root = conflict_repo_symlink({ "f.txt", "g.txt", "h.txt" }) local s = open_at(root, "f.txt") - assert.are.same({ "a.bin", "f.txt", "g.txt", "h.txt" }, s.files) + assert.are.same({ "a.lnk", "f.txt", "g.txt", "h.txt" }, s.files) fire(s.result_buf, "differ: next conflicted file") -- f -> g local g = merge.current() fire(g.result_buf, "differ: take ours") fire(g.result_buf, "differ: next conflicted file") -- g staged -> h assert.are.equal("h.txt", merge.current().path) - -- forward from h wraps past a.bin (unopenable) to f, back skips g (staged) + -- forward from h wraps past a.lnk (unopenable) to f, back skips g (staged) fire(merge.current().result_buf, "differ: next conflicted file") assert.are.equal("f.txt", merge.current().path) fire(merge.current().result_buf, "differ: previous conflicted file") @@ -968,13 +1004,13 @@ describe(":Differ mergetool file stepping", function() end) it("names what's left when no other file can be opened", function() - local root = conflict_repo_binary({ "f.txt" }) + local root = conflict_repo_symlink({ "f.txt" }) local s = open_at(root, "f.txt") _G.notifs = {} fire(s.result_buf, "differ: next conflicted file") assert.are.equal(s, merge.current()) assert.are.equal( - "differ: a.bin can't be opened in the merge tool: resolve with git add/rm", + "differ: a.lnk can't be opened in the merge tool: resolve with git add/rm", _G.notifs[#_G.notifs].msg ) end) @@ -1965,36 +2001,53 @@ describe("g? over a buffer the merge tool shares", function() end) end) -describe(":Differ mergetool modify/delete", function() - after_each(function() - if merge.current() then - merge.close() - end - end) +local function open_at(root, relpath) + merge.open({ path = root .. "/" .. relpath }) + return assert(merge.current()) +end - local function open_at(root, relpath) - merge.open({ path = root .. "/" .. relpath }) - return assert(merge.current()) +-- the text of the dim virtual line on a notice pane, or nil +local function notice(buf) + local ns = vim.api.nvim_get_namespaces()["differ.merge.notice"] + local marks = vim.api.nvim_buf_get_extmarks(buf, ns, 0, -1, { details = true }) + return marks[1] and marks[1][4].virt_text[1][1] +end + +local function help_text(s) + vim.api.nvim_set_current_win(s.result_win) + fire(s.result_buf, "differ: keymap help") + for _, w in ipairs(vim.api.nvim_list_wins()) do + if vim.api.nvim_win_get_config(w).relative ~= "" then + local lines = vim.api.nvim_buf_get_lines(vim.api.nvim_win_get_buf(w), 0, -1, false) + vim.api.nvim_win_close(w, true) + return table.concat(lines, "\n") + end end +end - -- the text of the dim virtual line on a deleted pane, or nil - local function notice(buf) - local ns = vim.api.nvim_get_namespaces()["differ.merge.notice"] - local marks = vim.api.nvim_buf_get_extmarks(buf, ns, 0, -1, { details = true }) - return marks[1] and marks[1][4].virt_text[1][1] +-- run `fn` with vim.fn.confirm answering `choice` (1 yes, 2 no), returning the prompt +local function confirming(choice, fn) + local orig, prompt = vim.fn.confirm, nil + vim.fn.confirm = function(msg) + prompt = msg + return choice end + local ok, err = pcall(fn) + vim.fn.confirm = orig + assert(ok, err) + return prompt +end - local function help_text(s) - vim.api.nvim_set_current_win(s.result_win) - fire(s.result_buf, "differ: keymap help") - for _, w in ipairs(vim.api.nvim_list_wins()) do - if vim.api.nvim_win_get_config(w).relative ~= "" then - local lines = vim.api.nvim_buf_get_lines(vim.api.nvim_win_get_buf(w), 0, -1, false) - vim.api.nvim_win_close(w, true) - return table.concat(lines, "\n") - end +local function status(root, path) + return vim.trim(git_ok(root, "status", "--porcelain", "--", path)) +end + +describe(":Differ mergetool modify/delete", function() + after_each(function() + if merge.current() then + merge.close() end - end + end) it("opens a file theirs deleted, the theirs pane empty and labelled", function() local root = conflict_repo_modify_delete({ "f.txt" }) @@ -2011,7 +2064,7 @@ describe(":Differ mergetool modify/delete", function() vim.api.nvim_buf_get_lines(vim.api.nvim_win_get_buf(theirs.win), 0, -1, false) ) vim.g.statusline_winid = theirs.win - assert.are.equal("THEIRS · deleted", merge.winbar()) + assert.are.equal("THEIRS (feature) · deleted", merge.winbar()) vim.g.statusline_winid = input(s, "ours").win assert.are.equal("OURS (HEAD)", merge.winbar()) vim.g.statusline_winid = s.result_win @@ -2026,7 +2079,7 @@ describe(":Differ mergetool modify/delete", function() vim.g.statusline_winid = input(s, "ours").win assert.are.equal("OURS (HEAD) · deleted", merge.winbar()) vim.g.statusline_winid = input(s, "theirs").win - assert.are.equal("THEIRS", merge.winbar()) + assert.are.equal("THEIRS (feature)", merge.winbar()) end) it("shows the original in the diff4 base pane", function() @@ -2057,23 +2110,6 @@ describe(":Differ mergetool modify/delete", function() assert.are.equal("a.txt", merge.current().path) end) - -- run `fn` with vim.fn.confirm answering `choice` (1 yes, 2 no), returning the prompt - local function confirming(choice, fn) - local orig, prompt = vim.fn.confirm, nil - vim.fn.confirm = function(msg) - prompt = msg - return choice - end - local ok, err = pcall(fn) - vim.fn.confirm = orig - assert(ok, err) - return prompt - end - - local function status(root, path) - return vim.trim(git_ok(root, "status", "--porcelain", "--", path)) - end - it("keeps the file on taking the side that modified it, staying put", function() local root = conflict_repo_modify_delete({ "f.txt" }) vim.cmd.edit(root .. "/f.txt") @@ -2308,8 +2344,10 @@ describe(":Differ mergetool modify/delete", function() confirming(1, function() fire(s.result_buf, "differ: take theirs") end) + _G.notifs = {} fire(s.result_buf, "differ: next conflicted file") assert.are.equal("f.txt", merge.current().path) + assert.are.same({}, _G.notifs) end) for _, case in ipairs({ { "delete", "theirs", "deleted" }, { "keep", "ours", "staged" } }) do @@ -2346,3 +2384,200 @@ describe(":Differ mergetool modify/delete", function() assert.are.equal(1, vim.fn.filereadable(root .. "/a.txt")) end) end) + +describe(":Differ mergetool binary", function() + after_each(function() + if merge.current() then + merge.close() + end + end) + + local function bytes(path) + local fd = assert(io.open(path, "rb")) + local data = fd:read("*a") + fd:close() + return data + end + + local function oid(root, spec) + return vim.trim(git_ok(root, "rev-parse", spec)) + end + + it("opens a binary conflict with notice panes, never the file's bytes", function() + local root = conflict_repo_binary() + vim.cmd.edit(root .. "/f.txt") + local ours, theirs = oid(root, ":2:a.bin"), oid(root, ":3:a.bin") + local s = open_at(root, "a.bin") + assert.is_true(s.binary) + assert.are.equal("acwrite", vim.bo[s.result_buf].buftype) + assert.are.same({ "" }, vim.api.nvim_buf_get_lines(s.result_buf, 0, -1, false)) + assert.are.equal("binary file: take ours or theirs", notice(s.result_buf)) + local ours_buf = vim.api.nvim_win_get_buf(input(s, "ours").win) + local theirs_buf = vim.api.nvim_win_get_buf(input(s, "theirs").win) + assert.are.equal("binary · 5 B · " .. ours:sub(1, 7), notice(ours_buf)) + assert.are.equal("binary · 7 B · " .. theirs:sub(1, 7), notice(theirs_buf)) + assert.are.same({ "" }, vim.api.nvim_buf_get_lines(ours_buf, 0, -1, false)) + vim.g.statusline_winid = s.result_win + assert.are.equal("RESULT · file 1/2 · binary · not staged", plain(merge.winbar())) + end) + + it("names theirs from the merge head, having no markers to name it", function() + local root = conflict_repo_binary() + vim.cmd.edit(root .. "/f.txt") + vim.g.statusline_winid = input(open_at(root, "a.bin"), "theirs").win + assert.are.equal("THEIRS (feature)", merge.winbar()) + end) + + it("names theirs as the commit a rebase is replaying", function() + local root = conflict_repo_binary() + git_ok(root, "merge", "--abort") + git_ok(root, "checkout", "-q", "feature") + git(root, "rebase", "main") + vim.cmd.edit(root .. "/f.txt") + local s = open_at(root, "a.bin") + vim.g.statusline_winid = input(s, "theirs").win + assert.are.equal("THEIRS (feature)", merge.winbar()) + vim.g.statusline_winid = input(s, "ours").win + assert.are.equal("OURS (HEAD)", merge.winbar()) + end) + + for _, case in ipairs({ { "ours", "ours\0" }, { "theirs", "theirs\0" } }) do + it("stages " .. case[1] .. "'s blob on taking " .. case[1] .. ", staying put", function() + local root = conflict_repo_binary() + vim.cmd.edit(root .. "/f.txt") + local want = oid(root, (":%d:a.bin"):format(case[1] == "ours" and 2 or 3)) + local s = open_at(root, "a.bin") + after_git_change(s, function() + fire(s.result_buf, "differ: take " .. case[1]) + end) + assert.are.equal(s, merge.current()) + assert.are.equal(want, oid(root, ":0:a.bin")) + assert.are.equal(case[2], bytes(root .. "/a.bin")) + assert.is_true(s.staged) + assert.are.equal(("took %s, staged"):format(case[1]), notice(s.result_buf)) + vim.g.statusline_winid = s.result_win + assert.are.equal("RESULT · file 1/2 · staged · 1 file left", plain(merge.winbar())) + end) + end + + it("says base, both and none don't apply", function() + local root = conflict_repo_binary() + vim.cmd.edit(root .. "/f.txt") + local s = open_at(root, "a.bin") + for _, desc in ipairs({ + "differ: take base", + "differ: take both", + "differ: drop the conflict", + }) do + _G.notifs = {} + fire(s.result_buf, desc) + assert.are.equal( + "differ: a binary file can't be merged: take ours or theirs", + _G.notifs[1].msg + ) + end + assert.are.equal("UU a.bin", status(root, "a.bin")) + end) + + it("says a taken binary file is already resolved", function() + local root = conflict_repo_binary() + vim.cmd.edit(root .. "/f.txt") + local s = open_at(root, "a.bin") + fire(s.result_buf, "differ: take ours") + _G.notifs = {} + fire(s.result_buf, "differ: take theirs") + assert.are.equal("differ: a.bin is already resolved", _G.notifs[1].msg) + assert.are.equal("ours\0", bytes(root .. "/a.bin")) + end) + + it("writes nothing on :w", function() + local root = conflict_repo_binary() + vim.cmd.edit(root .. "/f.txt") + local s = open_at(root, "a.bin") + vim.api.nvim_set_current_win(s.result_win) + _G.notifs = {} + vim.cmd("silent write") + assert.are.equal("differ: a.bin is binary: nothing to write", _G.notifs[1].msg) + assert.are.equal("UU a.bin", status(root, "a.bin")) + assert.are.equal("ours\0", bytes(root .. "/a.bin")) + end) + + it("steps onto a binary file with ]f, and past it untaken", function() + local root = conflict_repo_binary() + vim.cmd.edit(root .. "/f.txt") + local s = open_at(root, "f.txt") + fire(s.result_buf, "differ: next conflicted file") + local b = merge.current() + assert.are.equal("a.bin", b.path) + fire(b.result_buf, "differ: next conflicted file") + assert.are.equal("f.txt", merge.current().path) + assert.are.equal("UU a.bin", status(root, "a.bin")) + end) + + it("fires the done notify when a take resolves the last conflict", function() + local root = conflict_repo_binary() + vim.cmd.edit(root .. "/f.txt") + local s = open_at(root, "f.txt") + take_ours_and_write(s) + fire(s.result_buf, "differ: next conflicted file") + local b = merge.current() + assert.are.equal("a.bin", b.path) + _G.notifs = {} + fire(b.result_buf, "differ: take theirs") + assert.are.equal( + "differ: all conflicts resolved: :Differ continue", + _G.notifs[#_G.notifs].msg + ) + end) + + it("lists only the take keys in g?", function() + local root = conflict_repo_binary() + vim.cmd.edit(root .. "/f.txt") + local txt = help_text(open_at(root, "a.bin")) + assert.is_truthy(txt:find("co%s+take ours")) + assert.is_truthy(txt:find("ct%s+take theirs")) + assert.is_nil(txt:find("take base", 1, true)) + assert.is_nil(txt:find(":w", 1, true)) + end) + + it("takes a side of a binary add/add, its base pane empty", function() + local root = conflict_repo_binary("add") + vim.cmd.edit(root .. "/f.txt") + merge.open({ path = root .. "/a.bin", layout = "diff4" }) + local s = assert(merge.current()) + local base = input(s, "base") + assert.is_nil(notice(vim.api.nvim_win_get_buf(base.win))) + vim.g.statusline_winid = base.win + assert.are.equal("BASE · no common ancestor", merge.winbar()) + fire(s.result_buf, "differ: take theirs") + assert.are.equal("theirs\0", bytes(root .. "/a.bin")) + assert.is_true(s.staged) + end) + + it("keeps a binary modify/delete file byte for byte", function() + local root = conflict_repo_binary("delete") + vim.cmd.edit(root .. "/f.txt") + local s = open_at(root, "a.bin") + assert.are.equal("theirs", s.deleted) + assert.are.equal("acwrite", vim.bo[s.result_buf].buftype) + assert.are.equal( + "deleted in theirs", + notice(vim.api.nvim_win_get_buf(input(s, "theirs").win)) + ) + fire(s.result_buf, "differ: take ours") + assert.are.equal("", status(root, "a.bin")) + assert.are.equal("ours\0", bytes(root .. "/a.bin")) + assert.are.equal("took ours, staged", notice(s.result_buf)) + end) + + it("deletes a binary modify/delete file on taking the deleting side", function() + local root = conflict_repo_binary("delete") + vim.cmd.edit(root .. "/f.txt") + local s = open_at(root, "a.bin") + confirming(1, function() + fire(s.result_buf, "differ: take theirs") + end) + assert.are.equal("D a.bin", status(root, "a.bin")) + assert.are.equal("a.bin deletion staged", notice(s.result_buf)) + end) +end) From f1f0de32b3c470db1bd068cf66ce5b1cbaa3d65c Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Sat, 17 Oct 2026 12:57:32 +0100 Subject: [PATCH 19/25] fix: name the commit message in the float title instead of a winbar --- lua/differ/git/editor.lua | 4 ++-- lua/differ/git/operation.lua | 23 +++++++++++++++++++++-- test/nvim/operation_spec.lua | 27 +++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/lua/differ/git/editor.lua b/lua/differ/git/editor.lua index 24f0e7c..0eaa16b 100644 --- a/lua/differ/git/editor.lua +++ b/lua/differ/git/editor.lua @@ -65,7 +65,7 @@ function M.open(path, chan, float) vim.cmd.stopinsert() vim.bo[buf].bufhidden = "wipe" vim.bo[buf].swapfile = false - vim.wo[win].winbar = "commit message" + require("differ.git.operation").title_float(win, float, "commit message") vim.api.nvim_create_autocmd("BufUnload", { buffer = buf, once = true, @@ -74,7 +74,7 @@ function M.open(path, chan, float) if vim.api.nvim_buf_is_valid(float) then vim.b[float].differ_message = nil local back = float_win(float) - vim.wo[back].winbar = "" + require("differ.git.operation").title_float(back, float) vim.api.nvim_win_set_buf(back, float) vim.api.nvim_set_current_win(back) vim.cmd.startinsert() diff --git a/lua/differ/git/operation.lua b/lua/differ/git/operation.lua index 36d61b5..1246de2 100644 --- a/lua/differ/git/operation.lua +++ b/lua/differ/git/operation.lua @@ -223,13 +223,32 @@ end ---@type table local floats = {} +---@param title string +---@return string +local function padded(title) + return " " .. title .. " " +end + ---@param buf integer ---@param title string ---@return integer local function open_float(buf, title) local width = math.floor(vim.o.columns * 0.8) local height = math.floor(vim.o.lines * 0.6) - return require("differ.util.win").open_centred(buf, width, height, { title = title }) + return require("differ.util.win").open_centred(buf, width, height, { title = padded(title) }) +end + +-- title float `win` of terminal buffer `buf` for `shown`, a buffer showing in its place, +-- or for the terminal itself when nil +---@param win integer +---@param buf integer +---@param shown string? +function M.title_float(win, buf, shown) + local title = floats[buf] + if shown then + title = title .. " · " .. shown + end + vim.api.nvim_win_set_config(win, { title = padded(title) }) end -- reopen the float of terminal buffer `buf` when nothing shows it @@ -256,7 +275,7 @@ local function in_float(root, cmd, accept, on_exit) if stop then vim.b[buf].differ_accept = { root = root, stop = stop } end - floats[buf] = " git " .. cmd[#exec.GIT + 1] .. " " + floats[buf] = "git " .. cmd[#exec.GIT + 1] local win = open_float(buf, floats[buf]) local argv = vim.list_extend({ "sh", "-c", 'GPG_TTY=$(tty) exec "$@"', "sh" }, cmd) local job = vim.fn.jobstart(argv, { diff --git a/test/nvim/operation_spec.lua b/test/nvim/operation_spec.lua index bc31299..cce68dc 100644 --- a/test/nvim/operation_spec.lua +++ b/test/nvim/operation_spec.lua @@ -326,6 +326,33 @@ describe("git operation runner", function() assert.are.equal("Merge branch 'feature'", subject(root)) end) + it("names the commit message in the float's title while it shows", function() + local root = stopped("merge") + resolve_all(root) + local res = start(root, "continue") + local buf = assert(message_buffer(res), "git never asked for a message") + local win = vim.fn.bufwinid(buf) + assert.are.same( + { { " git merge · commit message " } }, + vim.api.nvim_win_get_config(win).title + ) + assert.are.equal("", vim.wo[win].winbar) + local back + local group = vim.api.nvim_create_augroup("differ_test_title", {}) + vim.api.nvim_create_autocmd("BufWinEnter", { + group = group, + callback = function(args) + if vim.bo[args.buf].buftype == "terminal" then + back = vim.api.nvim_win_get_config(vim.fn.bufwinid(args.buf)).title + end + end, + }) + reply(buf) + assert.is_true(finished(res).ok, res().output) + vim.api.nvim_del_augroup_by_id(group) + assert.are.same({ { " git merge " } }, back) + end) + it("commits the stopped commit's message unasked when accepting it", function() local root = stopped("merge") resolve_all(root) From a4072a302f0cef3c166165062194c3396c9ae0dd Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:23:37 +0100 Subject: [PATCH 20/25] fix: keep the message float's gutter columns off when a plugin sets them --- lua/differ/git/editor.lua | 2 ++ lua/differ/util/win.lua | 11 ++++++++++ test/nvim/operation_spec.lua | 41 ++++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/lua/differ/git/editor.lua b/lua/differ/git/editor.lua index 0eaa16b..58fb9df 100644 --- a/lua/differ/git/editor.lua +++ b/lua/differ/git/editor.lua @@ -61,6 +61,7 @@ function M.open(path, chan, float) local win = float_win(float) vim.b[float].differ_message = true vim.api.nvim_win_set_buf(win, buf) + require("differ.util.win").no_columns(win) vim.api.nvim_set_current_win(win) vim.cmd.stopinsert() vim.bo[buf].bufhidden = "wipe" @@ -76,6 +77,7 @@ function M.open(path, chan, float) local back = float_win(float) require("differ.git.operation").title_float(back, float) vim.api.nvim_win_set_buf(back, float) + require("differ.util.win").no_columns(back) vim.api.nvim_set_current_win(back) vim.cmd.startinsert() end diff --git a/lua/differ/util/win.lua b/lua/differ/util/win.lua index 9affd8f..9111436 100644 --- a/lua/differ/util/win.lua +++ b/lua/differ/util/win.lua @@ -13,6 +13,17 @@ function M.set_local(win, opt, val) vim.api.nvim_set_option_value(opt, val, { scope = "local", win = win }) end +-- the columns `style = "minimal"` turns off, off again on `win`: a buffer swapped into a +-- minimal float gets whatever columns BufWinEnter hooks set for it +---@param win integer +function M.no_columns(win) + M.set_local(win, "number", false) + M.set_local(win, "relativenumber", false) + M.set_local(win, "signcolumn", "no") + M.set_local(win, "foldcolumn", "0") + M.set_local(win, "statuscolumn", "") +end + -- a minimal, rounded float of `width` x `height` in the middle of the editor, focused ---@param buf integer ---@param width integer diff --git a/test/nvim/operation_spec.lua b/test/nvim/operation_spec.lua index cce68dc..e724849 100644 --- a/test/nvim/operation_spec.lua +++ b/test/nvim/operation_spec.lua @@ -353,6 +353,47 @@ describe("git operation runner", function() assert.are.same({ { " git merge " } }, back) end) + it("keeps the float's columns off across the message swap", function() + local saved = { signcolumn = vim.o.signcolumn, statuscolumn = vim.o.statuscolumn } + vim.o.signcolumn, vim.o.statuscolumn = "yes", "%l " + -- a plugin hook setting the columns on every buffer a window shows + local group = vim.api.nvim_create_augroup("differ_test_columns", {}) + vim.api.nvim_create_autocmd("BufWinEnter", { + group = group, + callback = function() + vim.wo.signcolumn, vim.wo.statuscolumn = "yes", "%l " + end, + }) + local columns = {} + local function record(win) + columns[#columns + 1] = { vim.wo[win].signcolumn, vim.wo[win].statuscolumn } + end + vim.api.nvim_create_autocmd("BufWinEnter", { + group = group, + callback = function(args) + if vim.bo[args.buf].buftype == "terminal" then + local win = vim.fn.bufwinid(args.buf) + vim.schedule(function() + if vim.api.nvim_win_is_valid(win) then + record(win) + end + end) + end + end, + }) + local root = stopped("merge") + resolve_all(root) + local res = start(root, "continue") + local buf = assert(message_buffer(res), "git never asked for a message") + record(vim.fn.bufwinid(buf)) + reply(buf) + local ok = finished(res).ok + vim.api.nvim_del_augroup_by_id(group) + vim.o.signcolumn, vim.o.statuscolumn = saved.signcolumn, saved.statuscolumn + assert.is_true(ok, res().output) + assert.are.same({ { "no", "" }, { "no", "" } }, columns) + end) + it("commits the stopped commit's message unasked when accepting it", function() local root = stopped("merge") resolve_all(root) From 4a6418ec6c60d72f4b36e37e9bea96b2d36bf9f6 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:23:37 +0100 Subject: [PATCH 21/25] feat: gC continues the operation in progress once every conflict is resolved --- README.md | 1 + doc/differ.txt | 1 + lua/differ/config.lua | 1 + lua/differ/git/init.lua | 18 ++++++++ lua/differ/merge/continue.lua | 62 ++++++++++++++++++++++++--- lua/differ/merge/files.lua | 18 ++++---- lua/differ/merge/init.lua | 7 ++- lua/differ/panel/init.lua | 3 ++ lua/differ/panel/render.lua | 3 +- test/nvim/mergetool_spec.lua | 30 +++++++++---- test/nvim/operation_spec.lua | 80 +++++++++++++++++++++++++++++++++-- 11 files changed, 195 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 0e0d493..dc33f9a 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,7 @@ require("differ").setup({ help = "g?", close = "dc", -- end the session toggle_panel = "dd", -- hide/show the file panel + commit = "gC", -- commit, or continue the operation in progress -- diffs toggle_layout = "dl", -- flip between stacked / split diff --git a/doc/differ.txt b/doc/differ.txt index 1c4ee6e..ed310b8 100644 --- a/doc/differ.txt +++ b/doc/differ.txt @@ -198,6 +198,7 @@ you’re in doubt, `:Differ sidecar` reports which one is actually running. help = "g?", close = "dc", -- end the session toggle_panel = "dd", -- hide/show the file panel + commit = "gC", -- commit, or continue the operation in progress -- diffs toggle_layout = "dl", -- flip between stacked / split diff --git a/lua/differ/config.lua b/lua/differ/config.lua index 931e39c..e15cebb 100644 --- a/lua/differ/config.lua +++ b/lua/differ/config.lua @@ -164,6 +164,7 @@ M.defaults = { toggle_layout = "dl", -- diff: flip stacked / split toggle_local = "dw", -- diff/panel: a partly staged file's changes since staging, and back commit_preview = "ds", -- panel/diff: list only what a commit would take, HEAD↔index rows + commit = "gC", -- panel/diff/merge: commit, or continue the operation in progress -- merge tool, bound on the result buffer. nav + take-this resolution, -- the result buffer is the real worktree -- file and stays editable, so the whole choose family sits behind diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index aeadbc1..947928d 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -1116,6 +1116,20 @@ function M.resolve_base() return base end +-- the commit key on the panel and diff view of a worktree session +---@param root string +---@param spec string|string[]|false +---@return differ.panel.ExtraMap +local function commit_map(root, spec) + return { + spec = spec, + fn = function() + require("differ.merge.continue").pick_resolved(root) + end, + desc = "continue, skip or abort the operation (once resolved)", + } +end + -- :Differ panel: open the file panel over a git change set (opts.toggle hides/shows -- a live one). selecting a file re-sources the one View in place rather than spawning a new -- one. `opts.rev` is the rev spec; position/listing/height/width pass through to @@ -1304,6 +1318,8 @@ function M.panel(opts) -- hunk-level staging. content edits stage by hunk; anything with no lines to stage -- sets `whole_file`. `apply` stages one hunk, or unstages it with `reverse` local stageable = is_worktree_status(source) + local diff_keys = require("differ").get_config().keymaps.diff + or require("differ.config").defaults.keymaps local active_entry ---@type differ.FileEntry|nil -- the file the view currently shows -- a cheap fingerprint of the diff's inputs: HEAD + porcelain status + the shown @@ -1544,6 +1560,7 @@ function M.panel(opts) commit_preview = stageable and function() set_preview(not preview) end or nil, + extra_keymaps = stageable and { commit_map(root, diff_keys.commit) } or nil, }) end active_entry = entry @@ -1762,6 +1779,7 @@ function M.panel(opts) end, desc = "local view: changes since staging", }, + commit_map(root, panel_keys.commit), } or nil, listing = opts.listing or panel_cfg.listing, position = opts.position or panel_cfg.position, diff --git a/lua/differ/merge/continue.lua b/lua/differ/merge/continue.lua index 2ee98ec..19a97ef 100644 --- a/lua/differ/merge/continue.lua +++ b/lua/differ/merge/continue.lua @@ -76,6 +76,14 @@ local function run(root, op, action, accept) end, accept) end +---@param left integer +local function warn_left(left) + notify( + ("%s left: resolve them before continuing"):format(plural(left, "conflicted file")), + vim.log.levels.WARN + ) +end + -- a continue with files still conflicted only gets git's refusal, so refuse here. the -- merge tool's open result is saved and staged first ---@param root string @@ -88,13 +96,29 @@ local function resolved(root) if left == 0 then return true end - notify( - ("%s left: resolve them before continuing"):format(plural(left, "conflicted file")), - vim.log.levels.WARN - ) + warn_left(left) return false end +-- the conflicted files left, counting the merge tool's open file by its buffer: the index +-- keeps it conflicted until settle() stages it +---@param root string +---@return integer +local function conflicts_left(root) + local conflicted = require("differ.git").conflicted(root) + local s = require("differ.merge").current() + if not s or s.root ~= root then + return #conflicted + end + local left = #vim.tbl_filter(function(p) + return p ~= s.path + end, conflicted) + if #s.regions > 0 or ((s.deleted or s.binary) and not s.staged) then + left = left + 1 + end + return left +end + ---@param prompt string ---@return boolean local function confirm(prompt) @@ -164,12 +188,38 @@ function M.running() return running ~= nil end +-- how a done notify or winbar names the continue step: the bound key, else the command +---@param km table -- a resolved surface keymap table +---@return string +function M.hint(km) + local key = require("differ.ui.help").fmt(km.commit) + if not key then + return ":Differ continue" + end + return key .. " to continue" +end + +-- the commit key: mid-operation, the picker once every conflict is resolved +---@param root string|nil -- the surface's repo, else the command's +function M.pick_resolved(root) + root = root or target_root() + if not root or not require("differ.git").operation(root) then + return notify("nothing to continue") + end + local left = conflicts_left(root) + if left > 0 then + return warn_left(left) + end + M.pick(root) +end + -- the picker, continue first -function M.pick() +---@param root string|nil -- the surface's repo, else the command's +function M.pick(root) if refuse_while_running() then return end - local root = target_root() + root = root or target_root() local op = root and require("differ.git").operation(root) if not root or not op then return notify("no merge, rebase, cherry-pick, revert or am in progress") diff --git a/lua/differ/merge/files.lua b/lua/differ/merge/files.lua index b1b6356..cbbba2b 100644 --- a/lua/differ/merge/files.lua +++ b/lua/differ/merge/files.lua @@ -31,20 +31,20 @@ function M.first_openable(root, conflicted) return nil, nil end --- with no conflict left to open here, report done (pointing at :Differ continue when an +-- with no conflict left to open here, report done (pointing at the continue step when an -- operation is in progress), or name the conflicts left for git add/rm ----@param root string +---@param s differ.MergeSession ---@param conflicted string[] -local function report_progress(root, conflicted) +local function report_progress(s, conflicted) if #conflicted == 0 then - if require("differ.git").operation(root) then - notify("all conflicts resolved: :Differ continue") + if require("differ.git").operation(s.root) then + notify("all conflicts resolved: " .. require("differ.merge.continue").hint(s.keymaps)) else notify("all conflicts resolved and staged") end return end - if not M.first_openable(root, conflicted) then + if not M.first_openable(s.root, conflicted) then M.report_unopenable(conflicted) end end @@ -56,7 +56,7 @@ function M.mark_staged(s) s.staged = true local conflicted = require("differ.git").conflicted(s.root) s.left = #conflicted - report_progress(s.root, conflicted) + report_progress(s, conflicted) end -- the next/prev file in the session's list that is still conflicted and builds, wrapping @@ -100,7 +100,9 @@ function M.step(direction) return p ~= s.path end, conflicted) if #conflicted == 0 and s.operation then - return notify("no conflicted files left: :Differ continue") + return notify( + "no conflicted files left: " .. require("differ.merge.continue").hint(s.keymaps) + ) end if #conflicted == 0 then return notify("no conflicted files left") diff --git a/lua/differ/merge/init.lua b/lua/differ/merge/init.lua index 1984c24..1d4f665 100644 --- a/lua/differ/merge/init.lua +++ b/lua/differ/merge/init.lua @@ -703,7 +703,8 @@ local function result_winbar(s) if remaining == 0 and s.staged then local done = tint(s.removed and "deleted" or "staged", "differMergeStaged") if s.left == 0 and s.operation then - return ("%s · %s · all conflicts resolved · :Differ continue"):format(file, done) + local hint = require("differ.merge.continue").hint(s.keymaps) + return ("%s · %s · all conflicts resolved · %s"):format(file, done, hint) end if s.left == 0 then return ("%s · %s · all conflicts resolved"):format(file, done) @@ -906,6 +907,7 @@ local function show_help() end vim.list_extend(rows, { { pair(km.next_file, km.prev_file), "next / previous file" }, + { fmt(km.commit), "continue, skip or abort (once resolved)" }, { ":Differ close", "close the merge tool" }, { fmt(km.help), "this help" }, }) @@ -1235,6 +1237,9 @@ local function lay_out(root, relpath, model, layout, files) rb("prev_file", function() merge_files.step("prev") end, "differ: previous conflicted file") + rb("commit", function() + require("differ.merge.continue").pick_resolved() + end, "differ: continue, skip or abort the operation") rb("help", show_help, "differ: keymap help") -- the input panes are scratch buffers deleted on close, so these need no unbind for _, buf in ipairs(bufs) do diff --git a/lua/differ/panel/init.lua b/lua/differ/panel/init.lua index e9e0fb8..915efd3 100644 --- a/lua/differ/panel/init.lua +++ b/lua/differ/panel/init.lua @@ -343,6 +343,9 @@ function Panel:render() local header ---@type differ.panel.Header|nil if self.root then header = { path = self.root, operation = self.chrome.operation, help = "g?" } + if header.operation then + header.continue = require("differ.merge.continue").hint(self.keymaps) + end end -- live window width, falling back to the configured width when the window -- isn't open yet (headless construction / tests) diff --git a/lua/differ/panel/render.lua b/lua/differ/panel/render.lua index dacb0c4..bded6cf 100644 --- a/lua/differ/panel/render.lua +++ b/lua/differ/panel/render.lua @@ -55,6 +55,7 @@ end ---@class differ.panel.Header ---@field path string|nil -- repo/worktree path shown on the top line ---@field operation string|nil -- in-progress git operation, `() `, above the path +---@field continue string|nil -- the continue hint under the operation ---@field help string|nil -- keymap hint (rendered as "Help: ") -- build the panel buffer lines and a parallel metadata list (one per line). an @@ -77,7 +78,7 @@ function M.lines(blocks, header, icon_for, footer, width) if header.operation then lines[#lines + 1] = header.operation meta[#meta + 1] = { kind = "operation", verb_end = header.operation:find(")", 1, true) } - lines[#lines + 1] = ":Differ continue" + lines[#lines + 1] = header.continue or ":Differ continue" meta[#meta + 1] = { kind = "continue" } end if header.path then diff --git a/test/nvim/mergetool_spec.lua b/test/nvim/mergetool_spec.lua index 26038b4..d8a32bd 100644 --- a/test/nvim/mergetool_spec.lua +++ b/test/nvim/mergetool_spec.lua @@ -732,7 +732,7 @@ describe(":Differ mergetool resolution", function() take_ours_and_write(s) assert.are.equal(s, merge.current()) assert.are.equal( - "differ: all conflicts resolved: :Differ continue", + "differ: all conflicts resolved: gC to continue", _G.notifs[#_G.notifs].msg ) assert.are.same({}, require("differ.git").conflicted(root)) @@ -752,7 +752,7 @@ describe(":Differ mergetool resolution", function() last.msg ) for _, n in ipairs(_G.notifs) do - assert.is_nil(n.msg:find(":Differ continue", 1, true)) + assert.is_nil(n.msg:find("continue", 1, true)) end end) @@ -912,8 +912,8 @@ describe(":Differ mergetool in-progress operation", function() return _G.notifs[#_G.notifs].msg end - it("points at :Differ continue once the last file is written", function() - assert.are.equal("differ: all conflicts resolved: :Differ continue", resolve_last("rebase")) + it("points at the continue key once the last file is written", function() + assert.are.equal("differ: all conflicts resolved: gC to continue", resolve_last("rebase")) end) it("reports plain done when no operation is in progress", function() @@ -1023,7 +1023,7 @@ describe(":Differ mergetool file stepping", function() fire(s.result_buf, "differ: next conflicted file") assert.are.equal(s, merge.current()) assert.are.equal( - "differ: no conflicted files left: :Differ continue", + "differ: no conflicted files left: gC to continue", _G.notifs[#_G.notifs].msg ) end) @@ -1047,7 +1047,7 @@ describe(":Differ mergetool file stepping", function() assert.is_truthy(merge.winbar():find("RESULT · file 2/2", 1, true)) take_ours_and_write(g) assert.are.equal( - "RESULT · file 2/2 · staged · all conflicts resolved · :Differ continue", + "RESULT · file 2/2 · staged · all conflicts resolved · gC to continue", plain(merge.winbar()) ) end) @@ -2364,11 +2364,11 @@ describe(":Differ mergetool modify/delete", function() fire(a.result_buf, "differ: take " .. case[2]) end) assert.are.equal( - "differ: all conflicts resolved: :Differ continue", + "differ: all conflicts resolved: gC to continue", _G.notifs[#_G.notifs].msg ) vim.g.statusline_winid = a.result_win - local bar = "RESULT · file 1/2 · %s · all conflicts resolved · :Differ continue" + local bar = "RESULT · file 1/2 · %s · all conflicts resolved · gC to continue" assert.are.equal(bar:format(case[3]), plain(merge.winbar())) end) end @@ -2523,9 +2523,21 @@ describe(":Differ mergetool binary", function() local b = merge.current() assert.are.equal("a.bin", b.path) _G.notifs = {} + local select = vim.ui.select + local picked = false + vim.ui.select = function() + picked = true + end + fire(b.result_buf, "differ: continue, skip or abort the operation") + vim.ui.select = select + assert.is_false(picked) + assert.are.equal( + "differ: 1 conflicted file left: resolve them before continuing", + _G.notifs[#_G.notifs].msg + ) fire(b.result_buf, "differ: take theirs") assert.are.equal( - "differ: all conflicts resolved: :Differ continue", + "differ: all conflicts resolved: gC to continue", _G.notifs[#_G.notifs].msg ) end) diff --git a/test/nvim/operation_spec.lua b/test/nvim/operation_spec.lua index e724849..873d254 100644 --- a/test/nvim/operation_spec.lua +++ b/test/nvim/operation_spec.lua @@ -862,6 +862,81 @@ describe(":Differ continue", function() end) end + describe("the commit key", function() + after_each(function() + local p = require("differ.panel").current() + if p then + p:close() + end + end) + + -- gC's buffer-local map on `buf` + local function press(buf) + vim.api.nvim_buf_call(buf, function() + vim.fn.maparg("gC", "n", false, true).callback() + end) + end + + local function blocked(left) + assert.is_nil(shown) + assert.are.equal(vim.log.levels.WARN, noted(left .. " left").level) + end + + local function panel_on(root) + in_repo(root) + git_src.panel({ rev = {}, open_first = true }) + local p = assert(require("differ.panel").current()) + assert(require("differ.view").current()) + return p, vim.api.nvim_win_get_buf(p.origin_win) + end + + it("waits for every conflict on the panel and diff view", function() + local root = stopped("merge") + local p, diff_buf = panel_on(root) + press(p.bufnr) + blocked("2 conflicted files") + press(diff_buf) + blocked("2 conflicted files") + resolve_all(root) + press(p.bufnr) + assert.are.equal("merge options", shown.prompt) + shown = nil + press(diff_buf) + assert.are.equal("merge options", shown.prompt) + end) + + it("says there's nothing to continue with no operation", function() + local root = diverged_repo() + write(root .. "/f.txt", "edited\n") + local p = panel_on(root) + press(p.bufnr) + assert.is_nil(shown) + assert.is_not_nil(noted("nothing to continue")) + end) + + it("counts the merge tool's open file by its buffer", function() + local root = stopped("merge") + in_repo(root) + merge.open({}) + local s = assert(merge.current()) + press(s.result_buf) + blocked("2 conflicted files") + write(root .. "/g.txt", "resolved\n") + git_ok(root, "add", "g.txt") + press(s.result_buf) + blocked("1 conflicted file") + vim.api.nvim_buf_set_lines(s.result_buf, 0, -1, false, { "resolved" }) + vim.api.nvim_exec_autocmds("TextChanged", { buffer = s.result_buf }) + press(s.result_buf) + assert.are.equal("merge options", shown.prompt) + end) + + it("names the key in the hints, or the command when it's disabled", function() + assert.are.equal("gC to continue", continue.hint({ commit = "gC" })) + assert.are.equal(":Differ continue", continue.hint({ commit = false })) + end) + end) + describe("from the merge tool", function() -- a session on f.txt with its result resolved but unsaved local function session_on(root) @@ -1087,10 +1162,7 @@ describe("local panel mid-operation", function() it("drops the operation lines after a " .. action, function() local root = stopped("merge") local p = panel_on(root) - assert.are.same( - { "(merging) feature → main", ":Differ continue" }, - operation_lines(p) - ) + assert.are.same({ "(merging) feature → main", "gC to continue" }, operation_lines(p)) assert.is_true(run(root, action).ok) assert( vim.wait(5000, function() From fdb935f250d9fa796a9c6513f89c31a0734e8fd6 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:42:03 +0100 Subject: [PATCH 22/25] fix: redraw after the skip/abort confirm so the float doesn't stall --- lua/differ/merge/continue.lua | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/lua/differ/merge/continue.lua b/lua/differ/merge/continue.lua index 19a97ef..61f3a87 100644 --- a/lua/differ/merge/continue.lua +++ b/lua/differ/merge/continue.lua @@ -122,7 +122,10 @@ end ---@param prompt string ---@return boolean local function confirm(prompt) - return vim.fn.confirm(prompt, "&Yes\n&No", 2) == 1 + local yes = vim.fn.confirm(prompt, "&Yes\n&No", 2) == 1 + -- the prompt left on screen holds the terminal float's first redraw for seconds + vim.cmd.redraw() + return yes end ---@class differ.merge.ContinueEntry From d5b2d7118928bea79edf950b8f5aced41e48a8ef Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:42:03 +0100 Subject: [PATCH 23/25] feat: gC with conflicts left offers skip and abort --- lua/differ/merge/continue.lua | 79 +++++++++++++++++++---------------- test/nvim/mergetool_spec.lua | 12 ++---- test/nvim/operation_spec.lua | 38 +++++++++++++---- 3 files changed, 78 insertions(+), 51 deletions(-) diff --git a/lua/differ/merge/continue.lua b/lua/differ/merge/continue.lua index 61f3a87..8b8b690 100644 --- a/lua/differ/merge/continue.lua +++ b/lua/differ/merge/continue.lua @@ -76,14 +76,6 @@ local function run(root, op, action, accept) end, accept) end ----@param left integer -local function warn_left(left) - notify( - ("%s left: resolve them before continuing"):format(plural(left, "conflicted file")), - vim.log.levels.WARN - ) -end - -- a continue with files still conflicted only gets git's refusal, so refuse here. the -- merge tool's open result is saved and staged first ---@param root string @@ -96,7 +88,10 @@ local function resolved(root) if left == 0 then return true end - warn_left(left) + notify( + ("%s left: resolve them before continuing"):format(plural(left, "conflicted file")), + vim.log.levels.WARN + ) return false end @@ -134,20 +129,22 @@ end ---@param root string ---@param op "merge"|"rebase"|"am"|"cherry-pick"|"revert" +---@param left integer -- conflicts left: above zero, only skip and abort ---@return differ.merge.ContinueEntry[] -local function entries(root, op) - local list = { - { +local function entries(root, op, left) + local list = {} + if left == 0 then + list[1] = { label = "continue", run = function() if resolved(root) then run(root, op, "continue", true) end end, - }, - } + } + end -- git am never asks for a message - if op ~= "am" then + if left == 0 and op ~= "am" then list[#list + 1] = { label = "continue and edit the message", run = function() @@ -202,18 +199,42 @@ function M.hint(km) return key .. " to continue" end --- the commit key: mid-operation, the picker once every conflict is resolved +---@param root string +---@param op "merge"|"rebase"|"am"|"cherry-pick"|"revert" +---@param left integer +local function show(root, op, left) + local prompt = op .. " options" + if left > 0 then + prompt = ("%s left"):format(plural(left, "conflicted file")) + end + vim.ui.select(entries(root, op, left), { + prompt = prompt, + format_item = function(entry) + return entry.label + end, + }, function(choice) + if not choice then + return + end + -- the builtin inputlist calls back mid-prompt, which holds the float's first redraw + -- for seconds + vim.schedule(choice.run) + end) +end + +-- the commit key: mid-operation, the picker once every conflict is resolved, skip and +-- abort before then ---@param root string|nil -- the surface's repo, else the command's function M.pick_resolved(root) + if refuse_while_running() then + return + end root = root or target_root() - if not root or not require("differ.git").operation(root) then + local op = root and require("differ.git").operation(root) + if not root or not op then return notify("nothing to continue") end - local left = conflicts_left(root) - if left > 0 then - return warn_left(left) - end - M.pick(root) + show(root, op, conflicts_left(root)) end -- the picker, continue first @@ -227,19 +248,7 @@ function M.pick(root) if not root or not op then return notify("no merge, rebase, cherry-pick, revert or am in progress") end - vim.ui.select(entries(root, op), { - prompt = op .. " options", - format_item = function(entry) - return entry.label - end, - }, function(choice) - if not choice then - return - end - -- the builtin inputlist calls back mid-prompt, which holds the float's first redraw - -- for seconds - vim.schedule(choice.run) - end) + show(root, op, 0) end return M diff --git a/test/nvim/mergetool_spec.lua b/test/nvim/mergetool_spec.lua index d8a32bd..39bed2a 100644 --- a/test/nvim/mergetool_spec.lua +++ b/test/nvim/mergetool_spec.lua @@ -2524,17 +2524,13 @@ describe(":Differ mergetool binary", function() assert.are.equal("a.bin", b.path) _G.notifs = {} local select = vim.ui.select - local picked = false - vim.ui.select = function() - picked = true + local prompt + vim.ui.select = function(_, opts) + prompt = opts.prompt end fire(b.result_buf, "differ: continue, skip or abort the operation") vim.ui.select = select - assert.is_false(picked) - assert.are.equal( - "differ: 1 conflicted file left: resolve them before continuing", - _G.notifs[#_G.notifs].msg - ) + assert.are.equal("1 conflicted file left", prompt) fire(b.result_buf, "differ: take theirs") assert.are.equal( "differ: all conflicts resolved: gC to continue", diff --git a/test/nvim/operation_spec.lua b/test/nvim/operation_spec.lua index 873d254..e2cf340 100644 --- a/test/nvim/operation_spec.lua +++ b/test/nvim/operation_spec.lua @@ -877,9 +877,11 @@ describe(":Differ continue", function() end) end - local function blocked(left) - assert.is_nil(shown) - assert.are.equal(vim.log.levels.WARN, noted(left .. " left").level) + -- the menu with conflicts left: skip and abort under the count + local function reduced(left, labels) + assert.are.equal(left .. " left", shown.prompt) + assert.are.same(labels, shown.labels) + shown = nil end local function panel_on(root) @@ -890,13 +892,13 @@ describe(":Differ continue", function() return p, vim.api.nvim_win_get_buf(p.origin_win) end - it("waits for every conflict on the panel and diff view", function() + it("offers only abort on a merge with conflicts left, everything once resolved", function() local root = stopped("merge") local p, diff_buf = panel_on(root) press(p.bufnr) - blocked("2 conflicted files") + reduced("2 conflicted files", { "abort" }) press(diff_buf) - blocked("2 conflicted files") + reduced("2 conflicted files", { "abort" }) resolve_all(root) press(p.bufnr) assert.are.equal("merge options", shown.prompt) @@ -905,6 +907,26 @@ describe(":Differ continue", function() assert.are.equal("merge options", shown.prompt) end) + it("offers skip and abort on a rebase with conflicts left", function() + local root = stopped("rebase") + local p = panel_on(root) + press(p.bufnr) + reduced("1 conflicted file", { "skip", "abort" }) + end) + + it("skips from the reduced menu once confirmed", function() + local root = stopped("rebase") + local p = panel_on(root) + vim.fn.confirm = function() + return 1 + end + chosen = "skip" + press(p.bufnr) + assert(vim.wait(10000, function() + return noted("rebase stopped on 1 conflicted file") ~= nil + end)) + end) + it("says there's nothing to continue with no operation", function() local root = diverged_repo() write(root .. "/f.txt", "edited\n") @@ -920,11 +942,11 @@ describe(":Differ continue", function() merge.open({}) local s = assert(merge.current()) press(s.result_buf) - blocked("2 conflicted files") + reduced("2 conflicted files", { "abort" }) write(root .. "/g.txt", "resolved\n") git_ok(root, "add", "g.txt") press(s.result_buf) - blocked("1 conflicted file") + reduced("1 conflicted file", { "abort" }) vim.api.nvim_buf_set_lines(s.result_buf, 0, -1, false, { "resolved" }) vim.api.nvim_exec_autocmds("TextChanged", { buffer = s.result_buf }) press(s.result_buf) From 6fac7793fa91620c6b7eeb368cb87c0f5ec5b352 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Sun, 18 Oct 2026 10:46:21 +0100 Subject: [PATCH 24/25] docs: changelog and troubleshooting for the continue rework --- CHANGELOG.md | 7 ++++--- TROUBLESHOOTING.md | 2 -- doc/differ-troubleshooting.txt | 4 ---- 3 files changed, 4 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eafd84..9354247 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added -- `:Differ continue` finishes the merge, rebase, cherry-pick, revert or `am` in progress without leaving nvim: continue, edit the message, skip or abort +- `:Differ continue` finishes the merge, rebase, cherry-pick, revert or `am` in progress without leaving nvim: continue, edit the message in a buffer, skip or abort - The merge tool keeps or deletes a file changed on one side and deleted on the other -- The local panel names the operation in progress and points to `:Differ continue` -- An edited commit message closed without writing is kept as a draft for the next edit +- The merge tool resolves a binary conflict by taking ours or theirs +- `gC` in the panel, diff view and merge tool opens the continue menu: skip or abort while conflicts are left, continue once they're resolved +- The local panel names the operation in progress and how to continue it ### Changed diff --git a/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index 822ac41..e8b4db1 100644 --- a/TROUBLESHOOTING.md +++ b/TROUBLESHOOTING.md @@ -71,8 +71,6 @@ If a formatter reformats the markers anyway, differ notices on save, refuses to `:Differ continue` runs git in a floating terminal and points `GPG_TTY` at it, so with `commit.gpgsign` on a terminal pinentry or an `ssh-keygen` passphrase prompt draws there. A key held by gpg-agent's SSH support is the exception: its prompt goes to the terminal the agent started in. A GUI pinentry (pinentry-mac) or a cached passphrase avoids the prompt entirely. -A cherry-pick or revert range started with `-S` leaves the commit it stopped on unsigned after `:Differ continue`, as `git cherry-pick --continue` does on the command line; `commit.gpgsign` signs it. - ## render-markdown.nvim in the merge result The merge result disables in-buffer markdown rendering for the session so the conflict markers aren't concealed as block-quotes, restoring it on close. diff --git a/doc/differ-troubleshooting.txt b/doc/differ-troubleshooting.txt index 39f6fbf..5cfce24 100644 --- a/doc/differ-troubleshooting.txt +++ b/doc/differ-troubleshooting.txt @@ -151,10 +151,6 @@ prompt draws there. A key held by gpg-agent’s SSH support is the exception: its prompt goes to the terminal the agent started in. A GUI pinentry (pinentry-mac) or a cached passphrase avoids the prompt entirely. -A cherry-pick or revert range started with `-S` leaves the commit it stopped on -unsigned after `:Differ continue`, as `git cherry-pick --continue` does on the -command line; `commit.gpgsign` signs it. - ============================================================================== 10. render-markdown.nvim in the merge result*differ-troubleshooting-render-markdown.nvim-in-the-merge-result* From eb327af22e4de3e38ec86c0fea4f37a5e53667cc Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Sun, 18 Oct 2026 23:16:13 +0100 Subject: [PATCH 25/25] fix: commit the message only once it's written, and trim the continue notifies --- README.md | 2 +- doc/differ.txt | 4 +- lua/differ/git/editor.lua | 56 ++++++++++++-- lua/differ/git/operation.lua | 61 ++++++++++++--- lua/differ/git/shim.lua | 8 +- lua/differ/merge/continue.lua | 26 ++++--- lua/differ/merge/deleted.lua | 2 +- lua/differ/merge/files.lua | 25 ++----- test/nvim/mergetool_spec.lua | 54 ++++---------- test/nvim/operation_spec.lua | 135 ++++++++++++++++++++++++++++++---- 10 files changed, 265 insertions(+), 108 deletions(-) diff --git a/README.md b/README.md index dc33f9a..6965a2c 100644 --- a/README.md +++ b/README.md @@ -289,7 +289,7 @@ The lifecycle verbs act on the active session's PR: | `:Differ gofile` | Open the real file at the cursor's mapped line and end the session. The `de` key | | `:Differ edit` | Edit the real file in a transient split at the cursor's mapped line, keeping the session; `:w` re-sources the diff. The `df` key | | `:Differ mergetool [path]` | Open the merge tool on a conflicted file; `]f`/`[f` step between conflicted files | -| `:Differ continue` | Continue, skip or abort the merge, rebase, cherry-pick, revert or `am` in progress. Git runs in a floating terminal, and each commit message it asks for opens there, except the stopped commit's on a plain `continue`. Closing it commits; an emptied message drops that commit and leaves the operation stopped | +| `:Differ continue` | Continue, skip or abort the merge, rebase, cherry-pick, revert or `am` in progress. Git runs in a floating terminal, and each commit message it asks for opens there, except the stopped commit's on a plain `continue` | | `:Differ sidecar` | Smoke-check the Go sidecar: start it, round-trip a handshake, and report the binary and protocol version | | `:Differ sidecar stop` | Stop the supervised sidecar; the next PR command starts a fresh one | | `:Differ cache clear` | Flush the sidecar's in-process caches (file blobs and PR review threads) | diff --git a/doc/differ.txt b/doc/differ.txt index ed310b8..a140b4e 100644 --- a/doc/differ.txt +++ b/doc/differ.txt @@ -451,9 +451,7 @@ SESSION AND SIDECAR COMMANDS *differ-usage-session-and-sidecar-commands* terminal, and each commit message it asks for opens there, except the stopped commit’s on a plain - continue. Closing it commits; an - emptied message drops that commit - and leaves the operation stopped + continue :Differ sidecar Smoke-check the Go sidecar: start it, round-trip a handshake, and diff --git a/lua/differ/git/editor.lua b/lua/differ/git/editor.lua index 58fb9df..9cf3516 100644 --- a/lua/differ/git/editor.lua +++ b/lua/differ/git/editor.lua @@ -1,6 +1,6 @@ -- the editor git gets in differ's terminal float: git/shim.lua, which asks this nvim to --- open the message in the float and waits until the buffer closes. as with any editor, --- git then reads the file, and an emptied message drops that commit +-- open the message in the float and waits until the buffer closes. git commits the file +-- only when it was written: closing it unwritten, or emptying it, drops that commit local M = {} @@ -23,6 +23,33 @@ function M.env(float) return { GIT_EDITOR = editor, NVIM = vim.v.servername, DIFFER_FLOAT = tostring(float) } end +-- what starts a comment line in the repo holding git dir `dir`; "auto" is read as "#" +---@param dir string +---@return string +local function comment_prefix(dir) + for _, key in ipairs({ "core.commentString", "core.commentChar" }) do + local value = require("differ.git.exec").git({ "config", "--get", key }, dir) + value = value and vim.trim(value) + if value and value ~= "" and value ~= "auto" then + return value + end + end + return "#" +end + +-- whether `lines` hold nothing but blank and comment lines +---@param lines string[] +---@param prefix string +---@return boolean +local function emptied(lines, prefix) + for _, line in ipairs(lines) do + if line:match("%S") and not vim.startswith(line, prefix) then + return false + end + end + return true +end + -- the float's window, reopened when nothing shows the terminal ---@param float integer ---@return integer @@ -32,20 +59,23 @@ local function float_win(float) end -- called by the shim over rpc: show `path` in place of terminal buffer `float` and tell --- channel `chan` once it closes. returns at once, since the shim's request blocks until --- it does +-- channel `chan` once it closes, and whether it was written. returns at once, since the +-- shim's request blocks until it does. closing it unwritten, or last written with nothing +-- but comments, sets `differ_cancelled` on `float` ---@param path string ---@param chan integer ---@param float integer function M.open(path, chan, float) - local function closed() - pcall(vim.rpcnotify, chan, "nvim_exec_lua", "_G.differ_closed = true", {}) + ---@param written boolean + local function closed(written) + local lua = ("_G.differ_written = %s; _G.differ_closed = true"):format(written) + pcall(vim.rpcnotify, chan, "nvim_exec_lua", lua, {}) end -- the stopped commit's message, asked for before git moves past the stop local accept = vim.b[float].differ_accept vim.b[float].differ_accept = nil if accept and accept.stop == require("differ.git.operation").stop(accept.root) then - return closed() + return closed(true) end local existing = vim.fn.bufnr(path) if existing ~= -1 then @@ -67,6 +97,15 @@ function M.open(path, chan, float) vim.bo[buf].bufhidden = "wipe" vim.bo[buf].swapfile = false require("differ.git.operation").title_float(win, float, "commit message") + local prefix = comment_prefix(vim.fn.fnamemodify(path, ":h")) + local written, empty = false, false + vim.api.nvim_create_autocmd("BufWritePost", { + buffer = buf, + callback = function() + written = true + empty = emptied(vim.api.nvim_buf_get_lines(buf, 0, -1, false), prefix) + end, + }) vim.api.nvim_create_autocmd("BufUnload", { buffer = buf, once = true, @@ -74,6 +113,7 @@ function M.open(path, chan, float) vim.schedule(function() if vim.api.nvim_buf_is_valid(float) then vim.b[float].differ_message = nil + vim.b[float].differ_cancelled = not written or empty local back = float_win(float) require("differ.git.operation").title_float(back, float) vim.api.nvim_win_set_buf(back, float) @@ -81,7 +121,7 @@ function M.open(path, chan, float) vim.api.nvim_set_current_win(back) vim.cmd.startinsert() end - closed() + closed(written) end) end, }) diff --git a/lua/differ/git/operation.lua b/lua/differ/git/operation.lua index 1246de2..642a5bd 100644 --- a/lua/differ/git/operation.lua +++ b/lua/differ/git/operation.lua @@ -198,6 +198,17 @@ local function keeps_message(line) return false end +-- the todo line a rebase last ran, nil outside one +---@param dir string +---@return string|nil +local function last_done(dir) + local done = read(dir .. "/rebase-merge/done") + if not done then + return nil + end + return vim.trim(done):match("([^\n]*)$") +end + -- what identifies the stop git is on: HEAD, plus a rebase's step. nil when the commit it -- stopped on is one whose message the todo asks to edit (a reword, squash, fixup, or a -- merge without -C) @@ -209,16 +220,29 @@ function M.stop(root) if not dir then return head end - local done = read(dir .. "/rebase-merge/done") - if not done then + local line = last_done(dir) + if not line then return head end - if not keeps_message(vim.trim(done):match("([^\n]*)$")) then + if not keeps_message(line) then return nil end return head .. " " .. (state_line(dir, "rebase-merge/msgnum") or "") end +-- whether a rebase stopped on a reword whose message edit failed: git commits the pick +-- before asking, so a continue keeps the original message +---@param root string +---@return boolean +function M.reword_kept(root) + local dir = git_dir(root) + local line = dir and last_done(dir) + if not line or not (line:match("^reword ") or line:match("^r ")) then + return false + end + return exec.git({ "diff", "--cached", "--quiet" }, root) ~= nil +end + -- the terminal buffers git runs in, and their float titles ---@type table local floats = {} @@ -268,7 +292,7 @@ end ---@param root string ---@param cmd string[] ---@param accept boolean ----@param on_exit fun(ok: boolean, output: string) +---@param on_exit fun(ok: boolean, output: string, cancelled: boolean) local function in_float(root, cmd, accept, on_exit) local buf = vim.api.nvim_create_buf(false, true) local stop = accept and M.stop(root) @@ -285,9 +309,11 @@ local function in_float(root, cmd, accept, on_exit) on_exit = function(_, code) vim.schedule(function() floats[buf] = nil - local lines = {} + local lines, cancelled = {}, false if vim.api.nvim_buf_is_valid(buf) then lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + -- a cleanup that keeps comments commits a comment-only message + cancelled = code ~= 0 and vim.b[buf].differ_cancelled == true end for _, shown in ipairs(vim.fn.win_findbuf(buf)) do vim.api.nvim_win_close(shown, true) @@ -299,14 +325,26 @@ local function in_float(root, cmd, accept, on_exit) if output == "" and code ~= 0 then output = "the terminal was closed" end - on_exit(code == 0, output) + local function finish() + on_exit(code == 0, output, cancelled) + end + -- leaving terminal mode clears the message line, so report after it + if vim.api.nvim_get_mode().mode == "t" and vim.bo.buftype ~= "terminal" then + vim.api.nvim_create_autocmd("ModeChanged", { + pattern = "t:*", + once = true, + callback = vim.schedule_wrap(finish), + }) + return + end + finish() end) end, }) if job <= 0 then floats[buf] = nil vim.api.nvim_win_close(win, true) - return on_exit(false, "couldn't start git in a terminal") + return on_exit(false, "couldn't start git in a terminal", false) end vim.api.nvim_create_autocmd({ "BufHidden", "BufUnload" }, { buffer = buf, @@ -324,12 +362,12 @@ end -- run `cmd` detached and off the main loop, with no editor: an abort never commits ---@param root string ---@param cmd string[] ----@param on_exit fun(ok: boolean, output: string) +---@param on_exit fun(ok: boolean, output: string, cancelled: boolean) local function in_background(root, cmd, on_exit) local function done(res) local output = vim.trim((res.stderr or "") .. "\n" .. (res.stdout or "")) vim.schedule(function() - on_exit(res.code == 0, output) + on_exit(res.code == 0, output, false) end) end local ok, err = pcall(vim.system, cmd, { @@ -343,14 +381,15 @@ local function in_background(root, cmd, on_exit) end end --- run `git --`; `on_exit` gets whether it exited 0 and what it printed. a +-- run `git --`; `on_exit` gets whether it exited 0, what it printed, and +-- whether a commit message closed unwritten stopped it. a -- continue or skip runs in the float, where git asks for each commit message through -- git/editor.lua as it would in a terminal, a later reword or squash in a rebase included. -- `accept` commits the stopped commit's message as it stands, where `M.stop` allows it ---@param root string ---@param op "merge"|"rebase"|"am"|"cherry-pick"|"revert" ---@param action "continue"|"skip"|"abort" ----@param on_exit fun(ok: boolean, output: string) +---@param on_exit fun(ok: boolean, output: string, cancelled: boolean) ---@param accept boolean|nil function M.run(root, op, action, on_exit, accept) local cmd = vim.list_extend(vim.list_extend({}, exec.GIT), { op, "--" .. action }) diff --git a/lua/differ/git/shim.lua b/lua/differ/git/shim.lua index 260d4ad..dc9975f 100644 --- a/lua/differ/git/shim.lua +++ b/lua/differ/git/shim.lua @@ -1,6 +1,6 @@ -- git's editor while differ runs an operation, run as `nvim --clean -l shim.lua `: --- asks the nvim in $NVIM to open the file, then exits once it says the buffer closed. --- never required as a module +-- asks the nvim in $NVIM to open the file, then exits once it says the buffer closed: +-- 0 when it was written, else 1 so git drops that commit. never required as a module local addr = vim.env.NVIM if not addr or addr == "" then @@ -19,6 +19,7 @@ end -- set by the parent through nvim_exec_lua once the message buffer closes _G.differ_closed = false +_G.differ_written = false local own = vim.rpcrequest(chan, "nvim_get_api_info")[1] vim.rpcrequest( chan, @@ -35,4 +36,7 @@ while not _G.differ_closed do os.exit(1) end end +if not _G.differ_written then + os.exit(1) +end os.exit(0) diff --git a/lua/differ/merge/continue.lua b/lua/differ/merge/continue.lua index 8b8b690..946c1a8 100644 --- a/lua/differ/merge/continue.lua +++ b/lua/differ/merge/continue.lua @@ -24,21 +24,25 @@ end ---@param action string ---@param ok boolean ---@param output string -local function report(root, op, action, ok, output) +---@param cancelled boolean +local function report(root, op, action, ok, output, cancelled) local git = require("differ.git") local now = git.operation(root) + if cancelled and now then + -- git committed the reword before asking, so nothing was cancelled + if git_op.reword_kept(root) then + return + end + return notify(("commit cancelled, %s still in progress"):format(op)) + end if ok and not now then if action == "abort" then return notify(op .. " aborted") end return notify(op .. " finished") end - local conflicted = git.conflicted(root) - if now and #conflicted > 0 then - return notify(("%s stopped on %s"):format(op, plural(#conflicted, "conflicted file"))) - end - if ok then - return notify(op .. " continued") + if now and (ok or #git.conflicted(root) > 0) then + return end notify( exec.with_stderr(("git %s --%s failed"):format(op, action), output), @@ -69,9 +73,9 @@ local function run(root, op, action, accept) end local merge = require("differ.merge") running = op - git_op.run(root, op, action, function(ok, output) + git_op.run(root, op, action, function(ok, output, cancelled) running = nil - report(root, op, action, ok, output) + report(root, op, action, ok, output, cancelled) merge.after_operation(root) end, accept) end @@ -188,7 +192,7 @@ function M.running() return running ~= nil end --- how a done notify or winbar names the continue step: the bound key, else the command +-- how a notify, winbar or panel header names the continue step: the bound key, else the command ---@param km table -- a resolved surface keymap table ---@return string function M.hint(km) @@ -232,7 +236,7 @@ function M.pick_resolved(root) root = root or target_root() local op = root and require("differ.git").operation(root) if not root or not op then - return notify("nothing to continue") + return notify("no merge, rebase, cherry-pick, revert or am in progress") end show(root, op, conflicts_left(root)) end diff --git a/lua/differ/merge/deleted.lua b/lua/differ/merge/deleted.lua index 3e852b1..30e8e54 100644 --- a/lua/differ/merge/deleted.lua +++ b/lua/differ/merge/deleted.lua @@ -66,7 +66,7 @@ local function delete_file(s) return end if require("differ.merge").current() ~= s then - return notify("the merge tool closed while the prompt was up; nothing was deleted") + return end if not require("differ.git").remove(s.root, s.path) then return diff --git a/lua/differ/merge/files.lua b/lua/differ/merge/files.lua index cbbba2b..4552177 100644 --- a/lua/differ/merge/files.lua +++ b/lua/differ/merge/files.lua @@ -31,32 +31,17 @@ function M.first_openable(root, conflicted) return nil, nil end --- with no conflict left to open here, report done (pointing at the continue step when an --- operation is in progress), or name the conflicts left for git add/rm ----@param s differ.MergeSession ----@param conflicted string[] -local function report_progress(s, conflicted) - if #conflicted == 0 then - if require("differ.git").operation(s.root) then - notify("all conflicts resolved: " .. require("differ.merge.continue").hint(s.keymaps)) - else - notify("all conflicts resolved and staged") - end - return - end - if not M.first_openable(s.root, conflicted) then - M.report_unopenable(conflicted) - end -end - -- mark this file resolved in the same tick as the index write, so the git-dir watcher --- doesn't read it as resolved outside the session, and report progress +-- doesn't read it as resolved outside the session, and name the conflicts left that only +-- git add/rm can resolve ---@param s differ.MergeSession function M.mark_staged(s) s.staged = true local conflicted = require("differ.git").conflicted(s.root) s.left = #conflicted - report_progress(s, conflicted) + if #conflicted > 0 and not M.first_openable(s.root, conflicted) then + M.report_unopenable(conflicted) + end end -- the next/prev file in the session's list that is still conflicted and builds, wrapping diff --git a/test/nvim/mergetool_spec.lua b/test/nvim/mergetool_spec.lua index 39bed2a..58a9b3b 100644 --- a/test/nvim/mergetool_spec.lua +++ b/test/nvim/mergetool_spec.lua @@ -723,7 +723,7 @@ describe(":Differ mergetool resolution", function() assert.are.same({ "f.txt", "g.txt" }, require("differ.git").conflicted(root)) end) - it("reports done once the last conflict is written, staying open", function() + it("stays open and quiet once the last conflict is written", function() local root = conflict_repo() vim.cmd.edit(root .. "/f.txt") merge.open({}) @@ -731,10 +731,7 @@ describe(":Differ mergetool resolution", function() _G.notifs = {} take_ours_and_write(s) assert.are.equal(s, merge.current()) - assert.are.equal( - "differ: all conflicts resolved: gC to continue", - _G.notifs[#_G.notifs].msg - ) + assert.are.same({}, _G.notifs) assert.are.same({}, require("differ.git").conflicted(root)) end) @@ -899,26 +896,18 @@ describe(":Differ mergetool in-progress operation", function() assert.is_nil(git_mod.operation(root)) end) - ---@param op string - ---@return string message the last notify - local function resolve_last(op) - local root = operation_repo(op) - vim.cmd.edit(root .. "/f.txt") - merge.open({}) - local s = merge.current() - _G.notifs = {} - take_ours_and_write(s) - assert.are.equal(s, merge.current()) - return _G.notifs[#_G.notifs].msg + for _, op in ipairs({ "rebase", "stash" }) do + it("says nothing once the last file of a " .. op .. " is written", function() + local root = operation_repo(op) + vim.cmd.edit(root .. "/f.txt") + merge.open({}) + local s = merge.current() + _G.notifs = {} + take_ours_and_write(s) + assert.are.equal(s, merge.current()) + assert.are.same({}, _G.notifs) + end) end - - it("points at the continue key once the last file is written", function() - assert.are.equal("differ: all conflicts resolved: gC to continue", resolve_last("rebase")) - end) - - it("reports plain done when no operation is in progress", function() - assert.are.equal("differ: all conflicts resolved and staged", resolve_last("stash")) - end) end) describe(":Differ mergetool file stepping", function() @@ -2265,10 +2254,7 @@ describe(":Differ mergetool modify/delete", function() end fire(s.result_buf, "differ: take theirs") end) - assert.are.equal( - "differ: the merge tool closed while the prompt was up; nothing was deleted", - _G.notifs[#_G.notifs].msg - ) + assert.are.same({}, _G.notifs) assert.are.equal("UD a.txt", status(root, "a.txt")) assert.are.equal(1, vim.fn.filereadable(root .. "/a.txt")) end) @@ -2351,7 +2337,7 @@ describe(":Differ mergetool modify/delete", function() end) for _, case in ipairs({ { "delete", "theirs", "deleted" }, { "keep", "ours", "staged" } }) do - it("fires the done notify when a " .. case[1] .. " resolves the last conflict", function() + it("shows done in the winbar after a " .. case[1] .. " of the last conflict", function() local root = conflict_repo_modify_delete({ "f.txt" }) vim.cmd.edit(root .. "/f.txt") local s = open_at(root, "f.txt") @@ -2359,14 +2345,9 @@ describe(":Differ mergetool modify/delete", function() fire(s.result_buf, "differ: next conflicted file") local a = merge.current() assert.are.equal("a.txt", a.path) - _G.notifs = {} confirming(1, function() fire(a.result_buf, "differ: take " .. case[2]) end) - assert.are.equal( - "differ: all conflicts resolved: gC to continue", - _G.notifs[#_G.notifs].msg - ) vim.g.statusline_winid = a.result_win local bar = "RESULT · file 1/2 · %s · all conflicts resolved · gC to continue" assert.are.equal(bar:format(case[3]), plain(merge.winbar())) @@ -2532,10 +2513,7 @@ describe(":Differ mergetool binary", function() vim.ui.select = select assert.are.equal("1 conflicted file left", prompt) fire(b.result_buf, "differ: take theirs") - assert.are.equal( - "differ: all conflicts resolved: gC to continue", - _G.notifs[#_G.notifs].msg - ) + assert.is_true(b.staged) end) it("lists only the take keys in g?", function() diff --git a/test/nvim/operation_spec.lua b/test/nvim/operation_spec.lua index e2cf340..368bb50 100644 --- a/test/nvim/operation_spec.lua +++ b/test/nvim/operation_spec.lua @@ -102,8 +102,8 @@ end -- start the action; the returned function gives its result once it has finished local function start(root, action, accept) local result - operation.run(root, assert(git_src.operation(root)), action, function(ok, output) - result = { ok = ok, output = output } + operation.run(root, assert(git_src.operation(root)), action, function(ok, output, cancelled) + result = { ok = ok, output = output, cancelled = cancelled } end, accept) return function() return result @@ -307,22 +307,98 @@ describe("git operation runner", function() local root = stopped(op) resolve_all(root) local head = git_ok(root, "rev-parse", "HEAD") - local res = run(root, "continue", {}) + local res = run(root, "continue", { "", "# only a comment" }) assert.is_false(res.ok) - assert.matches("empty commit message", res.output) + assert.is_true(res.cancelled) assert.are.equal(op, git_src.operation(root)) assert.are.equal(head, git_ok(root, "rev-parse", "HEAD")) end) end - it("commits git's message when the edited message closes unwritten", function() + -- close git's next message prompt without writing it + local function cancel(res) + local buf = assert(message_buffer(res), "git never asked for a message") + vim.api.nvim_buf_set_lines(buf, 0, 1, false, { "unwritten" }) + vim.api.nvim_buf_delete(buf, { force = true }) + end + + it("leaves the operation stopped when the message closes unwritten", function() + local root = stopped("merge") + resolve_all(root) + local head = git_ok(root, "rev-parse", "HEAD") + local res = start(root, "continue") + cancel(res) + assert.is_false(finished(res).ok) + assert.is_true(res().cancelled) + assert.is_false(operation.reword_kept(root)) + assert.are.equal("merge", git_src.operation(root)) + assert.are.equal(head, git_ok(root, "rev-parse", "HEAD")) + end) + + it("keeps a later reword's original message when it closes unwritten", function() + local root = todo_rebase("reword") + resolve_all(root) + local res = start(root, "continue") + answer(res) -- the stop on f.txt + cancel(res) + assert.is_true(finished(res).cancelled) + assert.are.equal("rebase", git_src.operation(root)) + -- git picks a reword before asking for its message + assert.are.equal("feature i", subject(root)) + assert.is_true(operation.reword_kept(root)) + res = run(root, "continue") + assert.is_true(res.ok, res.output) + assert.are.equal("feature i", subject(root)) + end) + + for _, verb in ipairs({ "squash", "fixup -c" }) do + it("asks again for a later " .. verb .. " closed unwritten", function() + local root = todo_rebase(verb) + resolve_all(root) + local res = start(root, "continue") + answer(res) -- the stop on f.txt + cancel(res) + assert.is_true(finished(res).cancelled) + assert.are.equal("feature h", subject(root)) + assert.is_false(operation.reword_kept(root)) + res = start(root, "continue") + answer(res, { "combined" }) + assert.is_true(finished(res).ok, res().output) + assert.are.equal("combined", subject(root)) + end) + end + + it("commits a comment-only message when the cleanup keeps comments", function() + local root = stopped("merge") + resolve_all(root) + git_ok(root, "config", "commit.cleanup", "verbatim") + local res = run(root, "continue", { "# kept" }) + assert.is_true(res.ok, res.output) + assert.is_false(res.cancelled) + assert.are.equal("# kept", subject(root)) + end) + + it("reads core.commentChar when deciding a message was emptied", function() + local root = stopped("merge") + resolve_all(root) + git_ok(root, "config", "core.commentChar", ";") + local res = run(root, "continue", { "; only a comment" }) + assert.is_true(res.cancelled) + assert.are.equal("merge", git_src.operation(root)) + end) + + it("isn't cancelled when the message is written before closing", function() local root = stopped("merge") resolve_all(root) local res = start(root, "continue") local buf = assert(message_buffer(res), "git never asked for a message") + vim.api.nvim_buf_call(buf, function() + vim.cmd("silent write") + end) vim.api.nvim_buf_set_lines(buf, 0, 1, false, { "unwritten" }) vim.api.nvim_buf_delete(buf, { force = true }) assert.is_true(finished(res).ok, res().output) + assert.is_false(res().cancelled) assert.are.equal("Merge branch 'feature'", subject(root)) end) @@ -824,20 +900,53 @@ describe(":Differ continue", function() assert.is_not_nil(noted("merge finished")) end) - it("reports a rebase stopping again", function() + it("reports a commit message closed unwritten as cancelled", function() + local root = stopped("merge") + resolve_all(root) + in_repo(root) + vim.api.nvim_create_augroup("differ_test_messages", {}) + chosen = "continue and edit the message" + continue_cmd() + local buf + assert(vim.wait(10000, function() + buf = vim.fn.bufnr("COMMIT_EDITMSG$") + return buf ~= -1 + end)) + vim.api.nvim_buf_delete(buf, { force = true }) + assert(vim.wait(10000, function() + return not continue.running() + end)) + local n = assert(noted("commit cancelled, merge still in progress")) + assert.are.equal(vim.log.levels.INFO, n.level) + assert.is_nil(noted("failed")) + end) + + -- the run is over and the rebase stopped on its second conflict + local function stopped_again(root) + return not continue.running() and vim.deep_equal({ "g.txt" }, git_src.conflicted(root)) + end + + it("says nothing when a rebase stops again", function() local root = stopped("rebase") resolve_all(root) in_repo(root) chosen = "continue" continue_cmd() assert(vim.wait(10000, function() - return noted("rebase stopped on 1 conflicted file") ~= nil + return stopped_again(root) end)) + assert.is_nil(noted("rebase")) end) for _, case in ipairs({ - { "abort", "merge", "merge aborted" }, - { "skip", "rebase", "rebase stopped on 1 conflicted file" }, + { + "abort", + "merge", + function() + return noted("merge aborted") ~= nil + end, + }, + { "skip", "rebase", stopped_again }, }) do it(case[1] .. "s once confirmed, and not otherwise", function() local root = stopped(case[2]) @@ -857,7 +966,7 @@ describe(":Differ continue", function() end continue_cmd() assert(vim.wait(10000, function() - return noted(case[3]) ~= nil + return case[3](root) end)) end) end @@ -923,17 +1032,17 @@ describe(":Differ continue", function() chosen = "skip" press(p.bufnr) assert(vim.wait(10000, function() - return noted("rebase stopped on 1 conflicted file") ~= nil + return stopped_again(root) end)) end) - it("says there's nothing to continue with no operation", function() + it("says nothing is in progress with no operation", function() local root = diverged_repo() write(root .. "/f.txt", "edited\n") local p = panel_on(root) press(p.bufnr) assert.is_nil(shown) - assert.is_not_nil(noted("nothing to continue")) + assert.is_not_nil(noted("no merge, rebase, cherry%-pick, revert or am in progress")) end) it("counts the merge tool's open file by its buffer", function()