diff --git a/CHANGELOG.md b/CHANGELOG.md index a493d68..9354247 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,29 @@ 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 in a buffer, skip or abort +- The merge tool keeps or deletes a file changed on one side and deleted on the other +- 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 + +- 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..6965a2c 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 @@ -287,7 +288,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` | 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/RECIPES.md b/RECIPES.md index 9f85253..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 | @@ -129,3 +130,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/TROUBLESHOOTING.md b/TROUBLESHOOTING.md index 6dae6d0..e8b4db1 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` + +`: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. + ## 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-recipes.txt b/doc/differ-recipes.txt index 6f7697d..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 @@ -250,6 +255,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/doc/differ-troubleshooting.txt b/doc/differ-troubleshooting.txt index ee6bbc8..5cfce24 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* + +`: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. + + +============================================================================== +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..a140b4e 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 @@ -441,9 +442,16 @@ 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 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 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/config.lua b/lua/differ/config.lua index 84cf111..e15cebb 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", @@ -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/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/editor.lua b/lua/differ/git/editor.lua new file mode 100644 index 0000000..9cf3516 --- /dev/null +++ b/lua/differ/git/editor.lua @@ -0,0 +1,130 @@ +-- 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. git commits the file +-- only when it was written: closing it unwritten, or emptying it, 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 + +-- 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 +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, 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) + ---@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(true) + 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) + require("differ.util.win").no_columns(win) + vim.api.nvim_set_current_win(win) + vim.cmd.stopinsert() + 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, + callback = function() + 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) + require("differ.util.win").no_columns(back) + vim.api.nvim_set_current_win(back) + vim.cmd.startinsert() + end + closed(written) + end) + end, + }) +end + +return M diff --git a/lua/differ/git/index.lua b/lua/differ/git/index.lua index 434d520..0b1d33f 100644 --- a/lua/differ/git/index.lua +++ b/lua/differ/git/index.lua @@ -20,6 +20,25 @@ 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 + +-- 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 e17d15a..947928d 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,6 +85,25 @@ function M.head_sha(root) return out and chomp(out) or nil end +---@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) + 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? ---@param root string ---@param ref string @@ -347,6 +367,9 @@ function M.conflicted(root) return out and rev.parse_unmerged(out) or {} 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 ---@return string[] @@ -517,11 +540,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 +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) @@ -787,6 +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, 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` @@ -1039,7 +1064,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) @@ -1080,7 +1105,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 @@ -1091,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 @@ -1150,7 +1189,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 @@ -1279,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 @@ -1306,6 +1347,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 @@ -1517,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 @@ -1659,8 +1703,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 @@ -1675,7 +1718,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() @@ -1731,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, @@ -1937,7 +1986,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..642a5bd --- /dev/null +++ b/lua/differ/git/operation.lua @@ -0,0 +1,402 @@ +-- 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's editor is git/editor.lua's shim, which opens each commit message in this nvim + +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 } + +---@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 + +---@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 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 +---@param sha string +---@return string +local function commit_name(root, sha) + local key = root .. "\0" .. sha + 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 + return names[key] +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 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 " } + +---@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 + +-- 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) +---@param root string +---@return string|nil +function M.stop(root) + local head = require("differ.git").head_sha(root) or "" + local dir = git_dir(root) + if not dir then + return head + end + local line = last_done(dir) + if not line then + return head + end + 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 = {} + +---@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 = 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 +---@param buf integer +function M.show(buf) + local title = floats[buf] + if not title or vim.fn.bufwinid(buf) ~= -1 then + return + end + open_float(buf, title) +end + +-- 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 accept boolean +---@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) + 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 = require("differ.git.editor").env(buf), + on_exit = function(_, code) + vim.schedule(function() + floats[buf] = nil + 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) + 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+%]$", "") + if output == "" and code ~= 0 then + output = "the terminal was closed" + end + 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", false) + end + vim.api.nvim_create_autocmd({ "BufHidden", "BufUnload" }, { + buffer = buf, + 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 + +-- 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, 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, false) + end) + end + local ok, err = pcall(vim.system, cmd, { + cwd = root, + text = true, + detach = true, + env = { GIT_EDITOR = "false" }, + }, done) + if not ok then + done({ code = -1, stderr = tostring(err) }) + end +end + +-- 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, 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 }) + if action == "abort" then + return in_background(root, cmd, on_exit) + 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..dc9975f --- /dev/null +++ b/lua/differ/git/shim.lua @@ -0,0 +1,42 @@ +-- 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: +-- 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 + 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 +_G.differ_written = 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 +if not _G.differ_written then + os.exit(1) +end +os.exit(0) 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/continue.lua b/lua/differ/merge/continue.lua new file mode 100644 index 0000000..946c1a8 --- /dev/null +++ b/lua/differ/merge/continue.lua @@ -0,0 +1,258 @@ +-- :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") +local plural = require("differ.util.text").plural + +local M = {} + +local notify = exec.notify + +-- 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 +---@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 + if now and (ok or #git.conflicted(root) > 0) then + return + end + notify( + exec.with_stderr(("git %s --%s failed"):format(op, action), output), + vim.log.levels.ERROR + ) +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 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, function(ok, output, cancelled) + running = nil + report(root, op, action, ok, output, cancelled) + merge.after_operation(root) + end, accept) +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 + +-- 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) + 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 +---@field label string +---@field run fun() + +---@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, 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 left == 0 and op ~= "am" then + list[#list + 1] = { + label = "continue and edit the message", + run = function() + if resolved(root) then + run(root, op, "continue") + end + end, + } + end + if git_op.SKIPPABLE[op] then + list[#list + 1] = { + label = "skip", + run = function() + 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() + if confirm(("Abort the %s? Resolutions so far are lost."):format(op)) then + run(root, op, "abort") + end + 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 + +-- whether a run is in flight +---@return boolean +function M.running() + return running ~= nil +end + +-- 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) + local key = require("differ.ui.help").fmt(km.commit) + if not key then + return ":Differ continue" + end + return key .. " to continue" +end + +---@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() + 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 + show(root, op, conflicts_left(root)) +end + +-- the picker, continue first +---@param root string|nil -- the surface's repo, else the command's +function M.pick(root) + if refuse_while_running() then + return + end + 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") + end + show(root, op, 0) +end + +return M diff --git a/lua/differ/merge/deleted.lua b/lua/differ/merge/deleted.lua new file mode 100644 index 0000000..30e8e54 --- /dev/null +++ b/lua/differ/merge/deleted.lua @@ -0,0 +1,98 @@ +-- 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. 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) + 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.bo[buf].modified = false -- or stepping off would save it + 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.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) + 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 + 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..4552177 --- /dev/null +++ b/lua/differ/merge/files.lua @@ -0,0 +1,152 @@ +-- 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, a symlink) +---@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 + +-- 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 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 + 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 +-- 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: " .. require("differ.merge.continue").hint(s.keymaps) + ) + 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 4c5ee7f..1d4f665 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 @@ -19,7 +19,10 @@ local set_local = require("differ.util.win").set_local local keymap = require("differ.util.keymap") local tab_util = require("differ.util.tab") -local bind, unbind = keymap.bind, keymap.unbind +local plural = require("differ.util.text").plural +local merge_files = require("differ.merge.files") +local markers_reformatted = require("differ.git.conflict").reformatted +local bind = keymap.bind local M = {} @@ -31,6 +34,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,10 +67,20 @@ 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 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 +---@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 ---@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 @@ -74,10 +88,11 @@ 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 ----@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 @@ -127,26 +142,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() @@ -170,10 +165,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) @@ -443,12 +438,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 @@ -594,9 +589,16 @@ end -- 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 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") @@ -647,8 +649,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 @@ -680,6 +680,44 @@ local function base_label() return label 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 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 + 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) + 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 @@ -689,13 +727,7 @@ function M.winbar() end local win = vim.g.statusline_winid if win == session.result_win then - local remaining = #session.regions - if remaining == 0 then - return "RESULT · all resolved" - 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 result_winbar(session) end local side = session.win_side[win] if side == "base" then @@ -721,6 +753,22 @@ local function dress(win) set_local(win, "winbar", '%!v:lua.require("differ.merge").winbar()') end +-- 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") + 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 @@ -743,35 +791,76 @@ 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 +-- 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, 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 - 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 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 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 - for _, spec in ipairs(s.bound_keys) do - unbind(s.result_buf, spec) + 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 end -- 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 + +-- 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 + 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) + reload_result(s) 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 }) @@ -793,7 +882,19 @@ 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 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" }, { fmt(km.choose_ours), "take ours" }, @@ -801,10 +902,12 @@ 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), "next / previous file" }, + { fmt(km.commit), "continue, skip or abort (once resolved)" }, { ":Differ close", "close the merge tool" }, { fmt(km.help), "this help" }, }) @@ -814,28 +917,119 @@ 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") -local lay_out +-- 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) + 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") + 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" + end + return labels +end --- 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 ----@param root string ----@param layout "default"|"diff4" -local function advance(root, layout) - local remaining = require("differ.git").conflicted(root) - if #remaining == 0 then - notify("all conflicts resolved and staged") - return M.close() +-- 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) + -- a failed add has already notified; the file stays unstaged so a later write retries + if not require("differ.git").stage(s.root, s.path) then + return 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) + if not s.staged then + merge_files.mark_staged(s) end - lay_out(root, relpath, model, layout) +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) + -- on_write warns about a reformatting formatter once; a later refusal has to say why + local warned = s.autoformat_warned + -- 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 + 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 + 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 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() + merge_files.step("next") + end, "differ: next conflicted file") + bind(buf, km.prev_file, function() + merge_files.step("prev") + end, "differ: previous conflicted file") + bind(buf, km.help, show_help, "differ: keymap help") end -- lay out the render in a fresh session tab and wire navigation @@ -843,7 +1037,9 @@ end ---@param relpath string ---@param model differ.MergeModel ---@param layout "default"|"diff4" -function lay_out(root, relpath, model, layout) +---@param files string[] +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() end @@ -862,12 +1058,21 @@ function lay_out(root, relpath, model, layout) 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() @@ -902,7 +1107,22 @@ function lay_out(root, relpath, model, layout) dress(win) 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, 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 @@ -910,8 +1130,13 @@ function lay_out(root, relpath, model, layout) 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 first = model.regions[1] + 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 + file_index = i + end + end session = { root = root, path = relpath, @@ -920,14 +1145,17 @@ function lay_out(root, relpath, model, layout) 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, + binary = model.binary ~= nil, 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, @@ -936,9 +1164,10 @@ function lay_out(root, relpath, model, layout) 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 }), } -- one anchor per original conflict, spanning its block so a splice retires it for _, r in ipairs(model.regions) do @@ -969,9 +1198,11 @@ 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] + vim.list_extend(held, keymap.hold(result_buf, km[action], fn, desc)) end rb("next_conflict", function() goto_conflict("next") @@ -1000,67 +1231,50 @@ function lay_out(root, relpath, model, layout) rb("choose_none", function() resolve_choice("none") end, "differ: drop the conflict") + rb("next_file", function() + merge_files.step("next") + end, "differ: next conflicted file") + 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 - 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.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 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() + merge_files.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 - require("differ.git").stage(session.root, session.path) - 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, }) + 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, @@ -1135,6 +1349,43 @@ function lay_out(root, relpath, model, layout) 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 + +-- 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.head, s.operation = git.head_sha(root), operation + reload_result(s) + end + if not operation then + return M.close() + end + local conflicted = git.conflicted(root) + if #conflicted == 0 then + return + 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 + merge_files.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 @@ -1159,15 +1410,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 merge_files.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 - 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) @@ -1196,4 +1451,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/merge/model.lua b/lua/differ/merge/model.lua index 0b91dd2..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 @@ -18,6 +19,12 @@ 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 +---@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 = {} @@ -150,8 +157,48 @@ 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 + +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 (already resolved / never conflicted) +-- 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 @@ -162,15 +209,35 @@ function M.build(root, relpath, head) if not result_text then return nil, relpath .. " is not in the working tree" end - local regions = conflict.parse(to_lines(result_text)) - if #regions == 0 then - return nil, relpath .. " has no conflicts to resolve" - 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 + deleted = deleted_side(root, relpath) + if not deleted then + return nil, relpath .. " has no conflicts to resolve" + end + end -- 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 @@ -186,6 +253,7 @@ function M.build(root, relpath, head) regions = regions, head = head, no_ancestor = no_ancestor, + deleted = deleted, } end diff --git a/lua/differ/panel/init.lua b/lua/differ/panel/init.lua index d0c81f1..915efd3 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,7 +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 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 @@ -144,7 +155,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 +203,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 +213,8 @@ function Panel.new(opts) on_refresh = opts.on_refresh, on_staged = opts.on_staged, root = opts.root, - footer = opts.footer, + chrome_sources = { footer = opts.footer, operation = opts.operation }, + chrome = {}, actions = opts.actions, extra_keymaps = opts.extra_keymaps, on_step = opts.on_step, @@ -219,6 +234,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 +340,13 @@ 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.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) local live = self:is_open() and vim.api.nvim_win_get_width(self.winid) or self.width @@ -337,7 +360,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 @@ -352,11 +375,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 @@ -390,22 +413,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 == "help" 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 = "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 @@ -445,14 +463,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, @@ -524,10 +534,20 @@ function Panel:_highlight() end end +function Panel:_read_chrome() + for key, source in pairs(self.chrome_sources) do + if type(source) == "function" then + source = source() + end + self.chrome[key] = source + end +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..bded6cf 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,12 @@ 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 --- 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 +75,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] = header.continue or ":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/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/ui/highlights.lua b/lua/differ/ui/highlights.lua index 2b4a4a5..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" }, @@ -126,6 +128,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/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/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/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..9111436 100644 --- a/lua/differ/util/win.lua +++ b/lua/differ/util/win.lua @@ -13,4 +13,34 @@ 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 +---@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/mergetool_spec.lua b/test/nvim/mergetool_spec.lua index 726d9d8..58a9b3b 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() @@ -161,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") @@ -182,9 +220,134 @@ local function conflict_repo_two() return root end +-- 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[] +---@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") + 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") + 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") + change_a("ours") + 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 + +-- 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_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") + 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") + 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") + link("ours-target") + 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 + +-- 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 +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") +-- 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 @@ -262,6 +425,38 @@ 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("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") @@ -382,6 +577,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 @@ -404,6 +620,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 @@ -441,44 +685,124 @@ 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("advances to the next conflicted file once one is resolved and written", 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() - assert.are.equal("f.txt", s.path) - fire(s.result_buf, "differ: take ours") - vim.api.nvim_set_current_win(s.result_win) + _G.notifs = {} + take_ours_and_write(s) + vim.api.nvim_buf_set_lines(s.result_buf, 0, 0, false, { "extra" }) 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" - end) - assert.are.equal("g.txt", merge.current().path) + assert.are.same({}, _G.notifs) assert.are.same({ "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() + 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() - 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 + local git_mod = require("differ.git") + local stage = git_mod.stage + git_mod.stage = function() + return false + end + local ok, err = pcall(function() + 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) - assert.is_nil(merge.current()) + git_mod.stage = stage + assert(ok, err) + assert.are.equal(s, merge.current()) + assert.are.same({ "f.txt", "g.txt" }, require("differ.git").conflicted(root)) + end) + + it("stays open and quiet once the last conflict is written", function() + local root = conflict_repo() + 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) assert.are.same({}, require("differ.git").conflicted(root)) end) + it("names the conflicts it can't open once only those remain, staying open", function() + local root = conflict_repo_symlink({ "f.txt" }) + 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()) + local last = _G.notifs[#_G.notifs] + assert.are.equal( + "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 + assert.is_nil(n.msg:find("continue", 1, true)) + end + end) + + it("closes, naming them, when an operation stops only on conflicts it can't open", function() + local root = conflict_repo_symlink({ "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.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_symlink({ "f.txt" }) + vim.cmd.edit(root .. "/f.txt") + _G.notifs = {} + merge.open({ path = root .. "/a.lnk" }) + assert.is_nil(merge.current()) + assert.are.equal( + "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) + 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") @@ -507,6 +831,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) @@ -520,6 +865,272 @@ 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) + + 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 +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_symlink({ "f.txt", "g.txt", "h.txt" }) + local s = open_at(root, "f.txt") + 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.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") + 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_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.lnk 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: gC to continue", + _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", + plain(merge.winbar()) + ) + fire(s.result_buf, "differ: take ours") + 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", plain(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)) + take_ours_and_write(g) + assert.are.equal( + "RESULT · file 2/2 · staged · all conflicts resolved · gC to continue", + 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) + +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) + after_git_change(s, function() + git_ok(root, "add", "g.txt") + 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() + 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") + end) + + it("stays open through its own stage", function() + local root = conflict_repo_two() + local s = open_two(root) + 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) + describe(":Differ mergetool navigation", function() after_each(function() if merge.current() then @@ -941,7 +1552,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") @@ -1046,9 +1657,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)) @@ -1380,3 +1989,581 @@ 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) + +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 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 + +-- 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 + +describe(":Differ mergetool modify/delete", function() + after_each(function() + if merge.current() then + merge.close() + 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 (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 + 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 (feature)", 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) + + 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("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") + 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("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") + 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("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.same({}, _G.notifs) + 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") + 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") + after_git_change(s, function() + confirming(1, function() + fire(s.result_buf, "differ: take theirs") + end) + 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) + _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 + 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") + take_ours_and_write(s) + fire(s.result_buf, "differ: next conflicted file") + local a = merge.current() + assert.are.equal("a.txt", a.path) + confirming(1, function() + fire(a.result_buf, "differ: take " .. case[2]) + end) + 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())) + end) + 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) + +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 = {} + local select = vim.ui.select + 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.are.equal("1 conflicted file left", prompt) + fire(b.result_buf, "differ: take theirs") + assert.is_true(b.staged) + 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) diff --git a/test/nvim/operation_spec.lua b/test/nvim/operation_spec.lua new file mode 100644 index 0000000..368bb50 --- /dev/null +++ b/test/nvim/operation_spec.lua @@ -0,0 +1,1320 @@ +-- 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. `single` picks or reverts only the first commit, which +-- git runs without a sequencer +---@param op "merge"|"rebase"|"rebase-apply"|"am"|"cherry-pick"|"revert" +---@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 + 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 + 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") + local reverts = { "HEAD~2", "HEAD~1" } + if single then + reverts = { "HEAD~2" } + end + git(root, "revert", unpack(reverts)) + 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 + +-- 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, cancelled) + result = { ok = ok, output = output, cancelled = cancelled } + end, accept) + return function() + return result + end +end + +local function finished(res) + assert( + vim.wait(10000, function() + return res() ~= nil + end), + "the run never finished" + ) + 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) + return vim.trim(git_ok(root, "log", "-1", "--format=%s")) +end + +-- 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( + 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) + ), + ("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", "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 + + -- 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 + + -- 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 .. " commits the message written in the editor", function() + local root = stopped(op) + resolve_all(root) + 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", + vim.trim(git_ok(root, "log", "-1", "--format=%B")) + ) + if op ~= "merge" then + 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", { "", "# only a comment" }) + assert.is_false(res.ok) + 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 + + -- 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) + + 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("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) + 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("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 = 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) + + 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", { "edited" }) + assert.are.equal("edited", subject(root)) + assert.are.equal("f@f", vim.trim(git_ok(root, "log", "-1", "--format=%ae"))) + end) + 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) + + 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 + + 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", { "edited" }) + assert.is_true(res.ok, res.output) + assert.is_nil(git_src.operation(root)) + assert.are.equal("edited", subject(root)) + end) + end + + 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") + 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) + + 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 res = start(root, "continue") + assert.are.equal(wins + 1, #vim.api.nvim_list_wins()) + assert.are.equal("terminal", vim.bo.buftype) + 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) + local res = run(root, "continue") + assert.is_false(res.ok) + 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 res = start(root, "continue") + answer(res) + terminal_shown() + vim.cmd(close) + assert.is_false(finished(res).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 res = start(root, "abort") + assert.are.equal(wins, #vim.api.nvim_list_wins()) + assert.is_true(finished(res).ok, res().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 res = start(root, "continue") + assert.are.equal("terminal", vim.bo.buftype) + answer(res) + finished(res) + assert.are.same({ "tty" }, vim.fn.readfile(record)) + end) + end) +end) + +describe(":Differ continue", function() + local command = require("differ.command") + local continue = require("differ.merge.continue") + local saved, shown, chosen + + -- 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) + 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.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() + end + 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")) + end + + local function noted(pattern) + 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 and not continue.running() + end), + "the operation never finished" + ) + end + + it("says there's nothing in progress", function() + in_repo(diverged_repo()) + 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", edit, "abort" } }, + { "rebase", { "continue", edit, "skip", "abort" } }, + { "am", { "continue", "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])) + continue_cmd() + 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" + 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)) + end) + + it("continues a merge", function() + local root = stopped("merge") + resolve_all(root) + in_repo(root) + chosen = "continue" + continue_cmd() + finished(root) + assert.is_not_nil(noted("merge finished")) + end) + + 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 stopped_again(root) + end)) + assert.is_nil(noted("rebase")) + end) + + for _, case in ipairs({ + { + "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]) + in_repo(root) + 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.are.equal(head, git_ok(root, "rev-parse", "HEAD")) + assert.are.same(conflicted, git_src.conflicted(root)) + vim.fn.confirm = function() + return 1 + end + continue_cmd() + assert(vim.wait(10000, function() + return case[3](root) + end)) + 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 + + -- 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) + 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("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) + reduced("2 conflicted files", { "abort" }) + press(diff_buf) + reduced("2 conflicted files", { "abort" }) + 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("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 stopped_again(root) + end)) + end) + + 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("no merge, rebase, cherry%-pick, revert or am in progress")) + 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) + reduced("2 conflicted files", { "abort" }) + write(root .. "/g.txt", "resolved\n") + git_ok(root, "add", "g.txt") + press(s.result_buf) + 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) + 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) + 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 + + -- 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) + write(root .. "/g.txt", "resolved\n") + git_ok(root, "add", "g.txt") + chosen = "continue" + continue_cmd() + 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" + continue_cmd() + assert( + vim.wait(10000, function() + local s = merge.current() + return s ~= nil and s.path == "g.txt" + end), + "never reopened on g.txt" + ) + local s = assert(merge.current()) + after_git_change(s, root) + assert.are.equal(s, 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 + continue_cmd() + finished(root) + assert.is_nil(merge.current()) + 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 + continue_cmd() + 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" + 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" + continue_cmd() + assert(vim.wait(10000, function() + return noted("hook says no") ~= nil + end)) + assert.are.equal(vim.log.levels.ERROR, noted("hook says no").level) + after_git_change(s, root) + assert.are.equal(s, merge.current()) + assert.is_not_nil(git_src.operation(root)) + 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" + continue_cmd() + continue_cmd() + 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(_G.notifs) 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) + in_repo(root) + chosen = "continue" + continue_cmd() + 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) + +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) + + -- 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)) + 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", "gC to 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 + + 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) 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() 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() 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...")