Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5f860fe
fix: keep the merge view open when staging a resolved file fails
undont Oct 10, 2026
2f80c66
fix: keep lsp diagnostics off the merge result's conflict markers
undont Oct 10, 2026
370099e
feat: name the in-progress operation's --continue once every conflict…
undont Oct 10, 2026
e5f116a
fix: skip conflicts the merge tool can't open when advancing
undont Oct 10, 2026
c8089c9
feat: step between conflicted files with ]f/[f instead of advancing o…
undont Oct 10, 2026
36dd09e
feat: keep or delete modify/delete conflicts in the merge tool
undont Oct 12, 2026
61c544b
feat: continue, skip or abort the in-progress operation with :Differ …
undont Oct 12, 2026
14926c7
feat: run signed operations in a floating terminal so pinentry can pr…
undont Oct 12, 2026
d25e967
feat: name the in-progress operation in the local panel header
undont Oct 13, 2026
9c7d8d5
fix: harden the continue flow, signing float and modify/delete agains…
undont Oct 13, 2026
deb9e3f
refactor: cache the git dir, move operation detection and split the m…
undont Oct 13, 2026
a5290cf
test: cover skip/abort per operation and modify/delete failures, tidy…
undont Oct 13, 2026
336b2e8
feat: keep the commit message draft when the edit buffer closes unwri…
undont Oct 13, 2026
c9a7955
fix: restore the result window's options before the merge tool closes
undont Oct 14, 2026
f14e518
fix: hand the result buffer's own keymaps back when the merge tool cl…
undont Oct 14, 2026
3e72b9c
docs: :Differ continue, merge tool stepping and signing prompts
undont Oct 14, 2026
1ecafe6
fix: let git own the commit message on :Differ continue
undont Oct 15, 2026
8f03024
feat: resolve binary conflicts in the merge tool by taking a side
undont Oct 16, 2026
f1f0de3
fix: name the commit message in the float title instead of a winbar
undont Oct 17, 2026
a4072a3
fix: keep the message float's gutter columns off when a plugin sets them
undont Oct 17, 2026
4a6418e
feat: gC continues the operation in progress once every conflict is r…
undont Oct 17, 2026
fdb935f
fix: redraw after the skip/abort confirm so the float doesn't stall
undont Oct 17, 2026
d5b2d71
feat: gC with conflicts left offers skip and abort
undont Oct 17, 2026
6fac779
docs: changelog and troubleshooting for the continue rework
undont Oct 18, 2026
eb327af
fix: commit the message only once it's written, and trim the continue…
undont Oct 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) |
Expand Down
2 changes: 2 additions & 0 deletions RECIPES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 |
4 changes: 4 additions & 0 deletions TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
8 changes: 8 additions & 0 deletions doc/differ-recipes.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <https://github.com/kdheepak/panvimdoc>
Expand Down
15 changes: 13 additions & 2 deletions doc/differ-troubleshooting.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
14 changes: 11 additions & 3 deletions doc/differ.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions lua/differ/command.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<base>...HEAD`; any other arg is a rev-range.
Expand Down Expand Up @@ -311,6 +317,7 @@ local SUB = {
edit = M.edit,
log = M.log,
mergetool = M.mergetool,
continue = M.continue,
sidecar = M.sidecar,
cache = M.cache,
}
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion lua/differ/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 <leader>
Expand Down
20 changes: 20 additions & 0 deletions lua/differ/git/conflict.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
130 changes: 130 additions & 0 deletions lua/differ/git/editor.lua
Original file line number Diff line number Diff line change
@@ -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<string, string>
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
19 changes: 19 additions & 0 deletions lua/differ/git/index.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading