From 5b2fdf7e31305813160f4f29542c90998ef27b27 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:44:19 +0100 Subject: [PATCH 01/93] fix: count and step a two-row file once in the panel --- lua/differ/panel/init.lua | 63 +++++++++++++++++++++++++------------ lua/differ/panel/render.lua | 3 +- lua/differ/ui/winbar.lua | 7 +++-- test/nvim/panel_spec.lua | 54 +++++++++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 24 deletions(-) diff --git a/lua/differ/panel/init.lua b/lua/differ/panel/init.lua index 87e627c..6a0f833 100644 --- a/lua/differ/panel/init.lua +++ b/lua/differ/panel/init.lua @@ -100,7 +100,7 @@ local STATUS_HL = { ---@field content_width integer|nil -- column the list + pinned counts occupy; capped under window width for top/bottom ---@field lines string[] ---@field meta differ.panel.LineMeta[] ----@field file_total integer|nil -- total files in the change set (fold-independent) +---@field file_total integer|nil -- distinct paths in the change set (fold-independent) ---@field add_total integer|nil -- total additions across the change set (diff --stat, on the help line) ---@field del_total integer|nil -- total deletions across the change set ---@field augroup integer|nil -- autocmd group for the external-change refresh @@ -218,7 +218,7 @@ end -- move the cursor to `path`'s file row if it's currently rendered, returning whether -- it was found; lets :Differ open on the current file rather than the first. --- a path can hold two rows (an "MM" file lists under Staged and Unstaged), and the +-- a path can hold two rows (an "RM" file lists under Staged and Unstaged), and the -- Staged one comes first; `prefer_unstaged` takes the unstaged row instead, falling -- back to the staged one when that's the only pair ---@param path string -- repo-relative @@ -279,15 +279,23 @@ function Panel:render() local blocks = {} -- absolute file numbering across the whole change set, in display order and -- independent of which dirs are folded, so the section counts + winbar meter - -- stay accurate when collapsed (entry -> 1-based index) - local abs_of, total = {}, 0 - -- diff --stat totals, summed over every file (fold-independent, like the count) + -- stay accurate when collapsed (entry -> 1-based index). a path is counted once + -- however many rows carry it: a file changed on both sides (an "RM", renamed in the + -- index and edited again) lists under Staged and Unstaged both, and each row takes + -- the number of distinct paths at or before it, so the second row repeats the + -- first's number rather than adding one and the meter never steps backwards + local abs_of, seen, total = {}, {}, 0 + -- diff --stat totals, summed over every row (fold-independent, like the count): + -- a two-row file's pairs change different lines, so both counts stand local add_total, del_total = 0, 0 for bi, sec in ipairs(self.sections) do local root, strip = self:_section_root(sec) for _, row in ipairs(tree.rows(root, "tree", {})) do -- fully expanded if row.kind == "file" then - total = total + 1 + if not seen[row.entry.path] then + seen[row.entry.path] = true + total = total + 1 + end abs_of[row.entry] = total add_total = add_total + (row.entry.additions or 0) del_total = del_total + (row.entry.deletions or 0) @@ -344,9 +352,18 @@ function Panel:render() end self.content_width = width self.lines, self.meta = out.lines, out.meta - for _, m in ipairs(self.meta) do + -- a path listed twice (a two-row file's Staged and Unstaged rows) marks all but its + -- last row superseded, so ]f / [f stop once per file and land on the pair with + -- work left. the review flow steps by staged state and still sees every row + local held = {} + for i, m in ipairs(self.meta) do if m.kind == "file" then m.file_index = abs_of[m.entry] + local prev = held[m.entry.path] + if prev then + self.meta[prev].superseded = true + end + held[m.entry.path] = i end end -- rows shuffle on every rebuild (a fold toggle, a listing swap, an entry leaving @@ -831,21 +848,28 @@ end -- the next/prev file row from `lnum`. wraps past the ends by default so ]f / [f -- stepping is cyclic (you often open mid-list); `wrap == false` bounds it instead, -- returning nil at the first/last file so the staging review flow stops at the ends. --- nil too when there are no file rows at all. the second return reports whether --- reaching it actually crossed an end, so goto_file can notify on a wrap +-- `distinct` skips a row whose path is listed again further down, so a step visits a +-- two-row file once, at its Unstaged row; the review flow leaves it off and walks pairs. +-- nil when there are no file rows at all. the second return reports whether reaching +-- it actually crossed an end, so goto_file can notify on a wrap ---@param lnum integer ---@param direction "next"|"prev" ---@param wrap? boolean -- default true; false stops at the list ends +---@param distinct? boolean -- default false; true stops once per path ---@return integer|nil row, boolean wrapped -function Panel:_file_row(lnum, direction, wrap) +function Panel:_file_row(lnum, direction, wrap, distinct) local n = #self.meta if n == 0 then return nil, false end + local function lands(i) + local m = self.meta[i] + return m ~= nil and m.kind == "file" and not (distinct and m.superseded) + end local step = direction == "prev" and -1 or 1 local i = lnum + step while i >= 1 and i <= n do - if self.meta[i] and self.meta[i].kind == "file" then + if lands(i) then return i, false end i = i + step @@ -855,8 +879,7 @@ function Panel:_file_row(lnum, direction, wrap) end for k = 1, n do local j = ((lnum - 1 + step * k) % n) + 1 - local m = self.meta[j] - if m and m.kind == "file" then + if lands(j) then return j, true end end @@ -891,12 +914,12 @@ function Panel:open_nearest(keep_focus) return true end --- ]f / [f: move to the next/prev file row and open it (lockstep file stepping). --- wraps at the ends by default (notifying, since it's otherwise not obvious you --- cycled back rather than simply moved); `wrap == false` (the staging review flow) --- stops at them instead. `keep_focus` is threaded to `_open` so in-view stepping --- stays in the diff window. returns whether a file was actually opened (false at a --- no-wrap list end) +-- ]f / [f: move to the next/prev file and open it (lockstep file stepping), stopping +-- once per path rather than once per row. wraps at the ends by default (notifying, +-- since it's otherwise not obvious you cycled back rather than simply moved); +-- `wrap == false` (the staging review flow) stops at them instead. `keep_focus` is +-- threaded to `_open` so in-view stepping stays in the diff window. returns whether a +-- file was actually opened (false at a no-wrap list end) ---@param direction "next"|"prev" ---@param keep_focus boolean|nil ---@param wrap? boolean -- default true; false stops at the list ends @@ -907,7 +930,7 @@ function Panel:goto_file(direction, keep_focus, wrap) local from = self:is_open() and vim.api.nvim_win_get_cursor(self.winid)[1] or self.selected_row or self:_first_file_line() - local i, wrapped = self:_file_row(from, direction, wrap) + local i, wrapped = self:_file_row(from, direction, wrap, true) if not i then return false end diff --git a/lua/differ/panel/render.lua b/lua/differ/panel/render.lua index 354d0c1..7234766 100644 --- a/lua/differ/panel/render.lua +++ b/lua/differ/panel/render.lua @@ -20,7 +20,8 @@ local FOLD_OPEN, FOLD_CLOSED = "▾", "▸" ---@field collapsed boolean|nil ---@field title string|nil -- header rows: the section title ---@field depth integer|nil -- tree depth (dir + file rows), for parent lookup ----@field file_index integer|nil -- 1-based position among all files (fold-independent); stamped by the panel +---@field file_index integer|nil -- 1-based position among distinct paths (fold-independent); stamped by the panel +---@field superseded boolean|nil -- file rows: a later row lists the same path, so ]f / [f skip this one ---@field status_col integer|nil -- byte col of the status letter (file rows) ---@field name_col integer|nil -- byte col where the name starts ---@field icon_col integer|nil -- byte range of the devicon glyph (when shown) diff --git a/lua/differ/ui/winbar.lua b/lua/differ/ui/winbar.lua index f24fced..d6328a2 100644 --- a/lua/differ/ui/winbar.lua +++ b/lua/differ/ui/winbar.lua @@ -90,9 +90,10 @@ function M.panel() if not (panel and panel.winid == win) then return "" end - -- total = every file in the change set (fold-independent), not just the rows - -- currently visible; idx = the fold-independent number of the file at/before the - -- cursor, so the meter stays accurate when dirs are collapsed + -- total = every distinct path in the change set (fold-independent), not just the + -- rows currently visible; idx = the fold-independent number of the file at/before + -- the cursor, so the meter stays accurate when dirs are collapsed. a file listed + -- under two sections holds two rows and one number, so the total counts it once local total = panel.file_total or 0 if total == 0 then return "" diff --git a/test/nvim/panel_spec.lua b/test/nvim/panel_spec.lua index 97e06f4..4a13296 100644 --- a/test/nvim/panel_spec.lua +++ b/test/nvim/panel_spec.lua @@ -268,6 +268,60 @@ describe("panel navigation", function() p:close() end) + it("counts an MM file's two rows as one file", function() + -- b.lua is staged and edited again, so it lists under both sections. the meter + -- counts paths, and both its rows take the same number, so it never steps back + local p = panel(nil, { + sections = { + { title = "Staged", entries = { fe("b.lua") } }, + { title = "Unstaged", entries = { fe("a.lua"), fe("b.lua") } }, + }, + }) + p:open() + assert.are.equal(2, p.file_total) + local idx = {} + for _, m in ipairs(p.meta) do + if m.kind == "file" then + idx[#idx + 1] = { m.entry.path, m.file_index } + end + end + assert.are.same({ { "b.lua", 1 }, { "a.lua", 2 }, { "b.lua", 2 } }, idx) + p:close() + end) + + it("]f / [f step past an MM file's staged row to its unstaged one", function() + -- b.lua lists under both sections; a step visits it once, at the Unstaged row, + -- so the wrap comes back to a.lua rather than to the staged half of b.lua + local function se(path, staged) + return { path = path, status = "M", additions = 1, deletions = 0, staged = staged } + end + local p = panel(nil, { + sections = { + { title = "Staged", entries = { se("b.lua", true) } }, + { + title = "Unstaged", + entries = { se("a.lua", false), se("b.lua", false), se("c.lua", false) }, + }, + }, + }) + p:open() + for i, m in ipairs(p.meta) do + if m.kind == "file" and m.entry.staged then + vim.api.nvim_win_set_cursor(p.winid, { i, 0 }) + end + end + local walk = {} + for _ = 1, 4 do + p:goto_file("next") + walk[#walk + 1] = cursor_path(p) + end + assert.are.same({ "a.lua", "b.lua", "c.lua", "a.lua" }, walk) + assert.is_false(p.meta[vim.api.nvim_win_get_cursor(p.winid)[1]].entry.staged) + p:goto_file("prev") -- back past b.lua's unstaged row, not into the staged one + assert.are.equal("c.lua", cursor_path(p)) + p:close() + end) + it("paints the diff --stat totals as virt_text on the help line", function() local p = panel({ fe("a.lua", "M", 31, 1), fe("b.lua", "M", 39, 2) }, { root = "/repo" }) p:open() From d7dc72e641668182906258e69926658791b2af55 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:44:23 +0100 Subject: [PATCH 02/93] feat: classify which lines of a HEAD-to-worktree diff the index holds --- lua/differ/union/marks.lua | 144 +++++++++++++++++++++++++++++++++ test/unit/union_marks_spec.lua | 108 +++++++++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 lua/differ/union/marks.lua create mode 100644 test/unit/union_marks_spec.lua diff --git a/lua/differ/union/marks.lua b/lua/differ/union/marks.lua new file mode 100644 index 0000000..fde75a3 --- /dev/null +++ b/lua/differ/union/marks.lua @@ -0,0 +1,144 @@ +-- which lines of a HEAD↔worktree diff the index already holds. +-- each real pair shares one side with the union (HEAD↔index shares HEAD, index↔worktree +-- shares the worktree), so a displayed line is classified by whichever pair shares its +-- side and the two never have to be composed. pure lua, no nvim API + +local M = {} + +---@alias differ.union.State "staged"|"unstaged"|"partial" + +---@class differ.union.Marks +---@field old table -- HEAD lnum -> the index already dropped this line +---@field new table -- worktree lnum -> the index already holds this line +---@field hunks differ.union.State[] -- union hunk index -> rolled-up state + +-- the lines a hunk list covers on one side, as a set +---@param hunks differ.Hunk[] +---@param side "old"|"new" +---@return table +local function covered(hunks, side) + local out = {} + local start_key, count_key = side .. "_start", side .. "_count" + for _, h in ipairs(hunks) do + for l = h[start_key], h[start_key] + h[count_key] - 1 do + out[l] = true + end + end + return out +end + +-- classify every line of a HEAD↔worktree diff as staged or not, and roll each hunk up +-- to a tri-state. a HEAD line the union deletes is staged when HEAD↔index deletes it +-- too (the index has already dropped it); a worktree line the union adds is staged +-- unless index↔worktree adds it (anything that pair leaves as context is in the index +-- already). pure: no git, no nvim +---@param union differ.Hunk[] -- HEAD↔worktree +---@param cached differ.Hunk[] -- HEAD↔index +---@param unstaged differ.Hunk[] -- index↔worktree +---@return differ.union.Marks +function M.classify(union, cached, unstaged) + local dropped = covered(cached, "old") + local fresh = covered(unstaged, "new") + local marks = { old = {}, new = {}, hunks = {} } + for i, h in ipairs(union) do + local staged, live = 0, 0 + for l = h.old_start, h.old_start + h.old_count - 1 do + marks.old[l] = dropped[l] or false + if marks.old[l] then + staged = staged + 1 + else + live = live + 1 + end + end + for l = h.new_start, h.new_start + h.new_count - 1 do + marks.new[l] = not fresh[l] + if marks.new[l] then + staged = staged + 1 + else + live = live + 1 + end + end + if live == 0 then + marks.hunks[i] = "staged" + elseif staged == 0 then + marks.hunks[i] = "unstaged" + else + marks.hunks[i] = "partial" + end + end + return marks +end + +-- every line the hunks delete on `side`, as a multiset of content +---@param hunks differ.Hunk[] +---@param side "old"|"new" +---@return table +local function line_bag(hunks, side) + local out = {} + for _, h in ipairs(hunks) do + for _, l in ipairs(h[side .. "_lines"] or {}) do + out[l] = (out[l] or 0) + 1 + end + end + return out +end + +-- whether a HEAD↔worktree diff shows all of a file's half-staged content. it cannot +-- show index content that differs from HEAD and worktree both: stage a change and then +-- put the worktree back, or let the two diffs align a run of repeated lines +-- differently, and the change is real but off-screen. three ways that happens, so three +-- checks; a false sends the file back to the two-pair view, where nothing is hidden +---@param union differ.Hunk[] +---@param cached differ.Hunk[] +---@param unstaged differ.Hunk[] +---@return boolean complete, string|nil reason +function M.complete(union, cached, unstaged) + if #union == 0 and (#cached > 0 or #unstaged > 0) then + return false, "the index differs from HEAD and the worktree matches it" + end + local shows_new = covered(union, "new") + for _, h in ipairs(unstaged) do + for l = h.new_start, h.new_start + h.new_count - 1 do + if not shows_new[l] then + return false, "unstaged content sits outside every hunk" + end + end + end + local shows_old = covered(union, "old") + for _, h in ipairs(cached) do + for l = h.old_start, h.old_start + h.old_count - 1 do + if not shows_old[l] then + return false, "staged content sits outside every hunk" + end + end + end + -- a pure deletion has no worktree line to check a range against, so it goes by + -- content: the index dropped these lines, and the union shows them only if HEAD + -- held them too. lines the index itself added and the worktree then dropped never + -- reach either side of a HEAD↔worktree diff + local dropped = line_bag(union, "old") + for _, h in ipairs(unstaged) do + if h.new_count == 0 then + for _, l in ipairs(h.old_lines or {}) do + if (dropped[l] or 0) == 0 then + return false, "the index holds a line neither HEAD nor the worktree has" + end + dropped[l] = dropped[l] - 1 + end + end + end + return true, nil +end + +-- how many hunks sit in each state, for the summary line +---@param marks differ.union.Marks +---@return table +function M.tally(marks) + local out = { staged = 0, unstaged = 0, partial = 0 } + for _, state in ipairs(marks.hunks) do + out[state] = out[state] + 1 + end + return out +end + +return M diff --git a/test/unit/union_marks_spec.lua b/test/unit/union_marks_spec.lua new file mode 100644 index 0000000..e1be3e5 --- /dev/null +++ b/test/unit/union_marks_spec.lua @@ -0,0 +1,108 @@ +local marks = require("differ.union.marks") + +-- classify only reads the four line-range fields, so the hunks here carry just those +local function h(old_start, old_count, new_start, new_count) + return { + old_start = old_start, + old_count = old_count, + new_start = new_start, + new_count = new_count, + } +end + +describe("union marks", function() + it("calls every hunk unstaged when the index holds nothing", function() + local m = marks.classify( + { h(2, 1, 2, 1), h(9, 1, 9, 1) }, + {}, + { h(2, 1, 2, 1), h(9, 1, 9, 1) } + ) + assert.are.same({ "unstaged", "unstaged" }, m.hunks) + assert.is_false(m.old[2]) + assert.is_false(m.new[2]) + end) + + it("calls every hunk staged when the worktree matches the index", function() + local m = marks.classify({ h(2, 1, 2, 1) }, { h(2, 1, 2, 1) }, {}) + assert.are.same({ "staged" }, m.hunks) + assert.is_true(m.old[2]) + assert.is_true(m.new[2]) + end) + + -- one file, one hunk staged and another not: each rolls up on its own, so a file + -- half in the index still reads hunk by hunk rather than as one blurred state + it("rolls each hunk up separately", function() + local union = { h(1, 1, 1, 1), h(10, 1, 10, 1) } + local m = marks.classify(union, { h(1, 1, 1, 1) }, { h(10, 1, 10, 1) }) + assert.are.same({ "staged", "unstaged" }, m.hunks) + end) + + -- the ticket's repro: a hunk staged, then two more edits inside the region it + -- covers, so HEAD↔worktree collapses all three into one hunk. that hunk is partial, + -- and the per-line marks still say which line of it the index already has + it("marks a hunk partial when later edits land inside a staged one", function() + local m = marks.classify({ h(3, 3, 3, 3) }, { h(3, 1, 3, 1) }, { h(4, 2, 4, 2) }) + assert.are.same({ "partial" }, m.hunks) + assert.are.same({ [3] = true, [4] = false, [5] = false }, m.old) + assert.are.same({ [3] = true, [4] = false, [5] = false }, m.new) + end) + + -- a hunk carrying content, for the checks that compare lines rather than ranges + local function hl(old_start, old_lines, new_start, new_lines) + return { + old_start = old_start, + old_count = #old_lines, + old_lines = old_lines, + new_start = new_start, + new_count = #new_lines, + new_lines = new_lines, + } + end + + describe("completeness", function() + it("passes a file whose half-staged content is all on screen", function() + assert.is_true(marks.complete({ h(3, 3, 3, 3) }, { h(3, 1, 3, 1) }, { h(4, 2, 4, 2) })) + end) + + -- stage an edit, then put the worktree back: git still calls the file MM, both + -- pairs hold a change, and HEAD↔worktree is empty + it("catches an edit staged and then undone in the worktree", function() + local ok, why = marks.complete({}, { h(2, 1, 2, 1) }, { h(2, 1, 2, 1) }) + assert.is_false(ok) + assert.are.equal("the index differs from HEAD and the worktree matches it", why) + end) + + it("catches unstaged content no hunk covers", function() + local ok, why = marks.complete({ h(3, 1, 3, 1) }, {}, { h(9, 1, 9, 1) }) + assert.is_false(ok) + assert.are.equal("unstaged content sits outside every hunk", why) + end) + + it("catches staged content no hunk covers", function() + local ok, why = marks.complete({ h(3, 1, 3, 1) }, { h(9, 1, 9, 1) }, {}) + assert.is_false(ok) + assert.are.equal("staged content sits outside every hunk", why) + end) + + -- a line the index added and the worktree then dropped reaches neither side of a + -- HEAD↔worktree diff, and a pure deletion has no worktree range to test + it("catches a line only the index holds", function() + local union = { hl(3, { "a" }, 3, { "b" }) } + local unstaged = { hl(7, { "ghost" }, 7, {}) } + local ok, why = marks.complete(union, {}, unstaged) + assert.is_false(ok) + assert.are.equal("the index holds a line neither HEAD nor the worktree has", why) + end) + + it("allows a deletion of a line HEAD held too", function() + local union = { hl(7, { "gone" }, 7, {}) } + local unstaged = { hl(7, { "gone" }, 7, {}) } + assert.is_true(marks.complete(union, {}, unstaged)) + end) + end) + + it("tallies the hunk states", function() + local m = { hunks = { "staged", "partial", "unstaged", "unstaged" } } + assert.are.same({ staged = 1, partial = 1, unstaged = 2 }, marks.tally(m)) + end) +end) From b68fb060a642ea47165d1d7380b9210a39a506b3 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:44:23 +0100 Subject: [PATCH 03/93] feat: build the text a partial stage should produce --- lua/differ/model/apply.lua | 168 +++++++++++++++++++++++++++++++++ test/unit/model_apply_spec.lua | 89 +++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 lua/differ/model/apply.lua create mode 100644 test/unit/model_apply_spec.lua diff --git a/lua/differ/model/apply.lua b/lua/differ/model/apply.lua new file mode 100644 index 0000000..7b5f4f2 --- /dev/null +++ b/lua/differ/model/apply.lua @@ -0,0 +1,168 @@ +-- the old side's text with only part of a diff applied, for staging a selection of +-- lines rather than a whole hunk. the caller re-diffs the result against the real file +-- and patches from that, so a selection never hand-builds a patch body: a diff of two +-- real texts always applies, where a hand-built body can place an added line on the +-- wrong side of a context line and land a silently wrong index. +-- +-- a hunk carries two blocks and no correspondence between them, so a partial hunk is +-- only splittable where the mapping is unambiguous: an insertion, a deletion, or a +-- replacement of equal length. anything else is refused rather than guessed at. +-- pure lua, no vim API + +local to_lines = require("differ.util.text").to_lines + +local M = {} + +---@class differ.model.Selection +---@field old table|nil -- old-side line numbers picked +---@field new table|nil -- new-side line numbers picked + +---@param hunk differ.Hunk +---@param sel differ.model.Selection +---@return integer picked, integer total +local function tally(hunk, sel) + local picked, total = 0, hunk.old_count + hunk.new_count + for k = 0, hunk.old_count - 1 do + if sel.old[hunk.old_start + k] then + picked = picked + 1 + end + end + for k = 0, hunk.new_count - 1 do + if sel.new[hunk.new_start + k] then + picked = picked + 1 + end + end + return picked, total +end + +-- what one hunk's block becomes. the second return says whether its last line came +-- from the new side, which decides the file's terminator when the hunk sits at the end +---@param hunk differ.Hunk +---@param sel differ.model.Selection +---@return string[]|nil lines, boolean|string tail_from_new -- a reason in place of the flag on refusal +local function region(hunk, sel) + local picked, total = tally(hunk, sel) + if picked == 0 then + return hunk.old_lines, false + end + if picked == total then + return hunk.new_lines, true + end + local out = {} + if hunk.old_count == 0 then + for k = 1, hunk.new_count do + if sel.new[hunk.new_start + k - 1] then + out[#out + 1] = hunk.new_lines[k] + end + end + return out, true + end + if hunk.new_count == 0 then + for k = 1, hunk.old_count do + if not sel.old[hunk.old_start + k - 1] then + out[#out + 1] = hunk.old_lines[k] + end + end + return out, false + end + if hunk.old_count ~= hunk.new_count then + local why = "a hunk replacing %d lines with %d can't be split" + return nil, why:format(hunk.old_count, hunk.new_count) + end + -- equal blocks pair up by position, which is how a line edited in place reads on + -- screen. either side of a pair being picked takes the new line, so a selection + -- dragged over one column alone still means the change on that row + local from_new = false + for k = 1, hunk.old_count do + local take = sel.old[hunk.old_start + k - 1] or sel.new[hunk.new_start + k - 1] + out[#out + 1] = take and hunk.new_lines[k] or hunk.old_lines[k] + from_new = take or false + end + return out, from_new +end + +---@param text string +---@return boolean +local function terminated(text) + return text == "" or text:sub(-1) == "\n" +end + +-- `model`'s old-side text with the lines `sel` names applied, or nil and a reason when +-- the selection lands inside a hunk whose blocks don't pair up. an empty selection +-- returns the old text, a full one the new text +---@param model differ.DiffModel +---@param sel differ.model.Selection|nil +---@return string|nil text, string|nil reason +function M.partial(model, sel) + sel = { old = (sel and sel.old) or {}, new = (sel and sel.new) or {} } + local old = to_lines(model.old_text) + local out, tail_new, hi = {}, false, 1 + + -- a hunk with no old lines sits after the line `old_start` names, so it is emitted + -- once that line has been, not in place of one + ---@param after integer + ---@return string|nil reason + local function insertions_after(after) + while true do + local h = model.hunks[hi] + if not (h and h.old_count == 0 and h.old_start == after) then + return nil + end + local lines, flag = region(h, sel) + if not lines then + return flag --[[@as string]] + end + for _, l in ipairs(lines) do + out[#out + 1] = l + end + if #lines > 0 then + tail_new = flag --[[@as boolean]] + end + hi = hi + 1 + end + end + + local reason = insertions_after(0) + if reason then + return nil, reason + end + local i = 1 + while i <= #old do + local h = model.hunks[hi] + if h and h.old_count > 0 and h.old_start == i then + local lines, flag = region(h, sel) + if not lines then + return nil, flag --[[@as string]] + end + for _, l in ipairs(lines) do + out[#out + 1] = l + end + if #lines > 0 then + tail_new = flag --[[@as boolean]] + end + i = i + h.old_count + hi = hi + 1 + else + out[#out + 1] = old[i] + tail_new = false + i = i + 1 + end + reason = insertions_after(i - 1) + if reason then + return nil, reason + end + end + + if #out == 0 then + return "" + end + local keep_nl + if tail_new then + keep_nl = terminated(model.new_text) + else + keep_nl = terminated(model.old_text) + end + return table.concat(out, "\n") .. (keep_nl and "\n" or "") +end + +return M diff --git a/test/unit/model_apply_spec.lua b/test/unit/model_apply_spec.lua new file mode 100644 index 0000000..e3dcc4f --- /dev/null +++ b/test/unit/model_apply_spec.lua @@ -0,0 +1,89 @@ +local apply = require("differ.model.apply") + +-- a model carrying only what partial() reads: the two texts and the hunks +local function model(old_text, new_text, hunks) + return { + path = "f", + old_rev = "A", + new_rev = "B", + old_text = old_text, + new_text = new_text, + hunks = hunks, + } +end + +local function h(old_start, old_lines, new_start, new_lines) + return { + old_start = old_start, + old_count = #old_lines, + old_lines = old_lines, + new_start = new_start, + new_count = #new_lines, + new_lines = new_lines, + } +end + +local function set(...) + local out = {} + for _, n in ipairs({ ... }) do + out[n] = true + end + return out +end + +describe("partial apply", function() + -- index 1,2,3x,4,5 -> worktree 1,2,3x,4y,5y; stage only 4y + local edit = + model("1\n2\n3x\n4\n5\n", "1\n2\n3x\n4y\n5y\n", { h(4, { "4", "5" }, 4, { "4y", "5y" }) }) + + it("takes one line of a replacement and leaves the rest", function() + assert.are.equal("1\n2\n3x\n4y\n5\n", apply.partial(edit, { new = set(4) })) + end) + + it("reads a pick on either side of a pair as that row", function() + assert.are.equal( + apply.partial(edit, { new = set(4) }), + apply.partial(edit, { old = set(4) }) + ) + end) + + it("returns the old text for an empty selection", function() + assert.are.equal(edit.old_text, apply.partial(edit, nil)) + end) + + it("returns the new text when everything is picked", function() + assert.are.equal(edit.new_text, apply.partial(edit, { old = set(4, 5), new = set(4, 5) })) + end) + + it("takes only the picked lines of an insertion", function() + -- a,b -> a,x,y,b: the hunk sits after old line 1 + local m = model("a\nb\n", "a\nx\ny\nb\n", { h(1, {}, 2, { "x", "y" }) }) + assert.are.equal("a\ny\nb\n", apply.partial(m, { new = set(3) })) + end) + + it("drops only the picked lines of a deletion", function() + -- a,b,c,d -> a,d: the hunk removes old lines 2 and 3 + local m = model("a\nb\nc\nd\n", "a\nd\n", { h(2, { "b", "c" }, 1, {}) }) + assert.are.equal("a\nc\nd\n", apply.partial(m, { old = set(2) })) + end) + + it("inserts at the top of the file", function() + local m = model("a\n", "x\na\n", { h(0, {}, 1, { "x" }) }) + assert.are.equal("x\na\n", apply.partial(m, { new = set(1) })) + end) + + -- the blocks carry no correspondence, so there is no honest way to say which of + -- three removed lines the one added line replaces + it("refuses a replacement whose blocks differ in length", function() + local m = model("a\nb\nc\n", "x\n", { h(1, { "a", "b", "c" }, 1, { "x" }) }) + local text, why = apply.partial(m, { new = set(1) }) + assert.is_nil(text) + assert.are.equal("a hunk replacing 3 lines with 1 can't be split", why) + end) + + it("keeps an unterminated old tail the selection doesn't reach", function() + local m = model("a\nb", "a\nB", { h(2, { "b" }, 2, { "B" }) }) + assert.are.equal("a\nb", apply.partial(m, nil)) + assert.are.equal("a\nB", apply.partial(m, { new = set(2) })) + end) +end) From d69b160d25be4756f58a8393d5df58ec81118571 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:44:36 +0100 Subject: [PATCH 04/93] feat: show and stage an MM file as one HEAD-to-worktree diff --- lua/differ/git/init.lua | 237 ++++++++++++++++++++++++++++++++++++-- lua/differ/panel/tree.lua | 1 + lua/differ/ui/winbar.lua | 11 +- lua/differ/view.lua | 73 ++++++++++-- test/nvim/git_spec.lua | 235 +++++++++++++++++++++++++++++++------ 5 files changed, 499 insertions(+), 58 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index bd7ed61..8e607dd 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -657,12 +657,53 @@ function M.file_entries(source, root) return out end --- working-tree status as panel sections: Staged / Unstaged / Untracked +-- the three models a half-staged file needs: HEAD↔worktree to show, and the two real +-- pairs to derive its marks from. built from blobs rather than `git diff` so all three +-- come off one read of each side +---@param root string +---@param path string +---@return differ.DiffModel union, differ.DiffModel cached, differ.DiffModel unstaged +function M.union_models(root, path) + local build = require("differ.model.diff").build + local head = M.read(HEAD, root, path) or "" + local index = M.read(INDEX, root, path) or "" + local work = M.read(WORKTREE, root, path) or "" + local function pair(old_rev, new_rev, old_text, new_text) + return build({ + path = path, + old_rev = old_rev, + new_rev = new_rev, + old_text = old_text, + new_text = new_text, + root = root, + }) + end + return pair("HEAD", "WORKTREE", head, work), + pair("HEAD", "INDEX", head, index), + pair("INDEX", "WORKTREE", index, work) +end + +-- whether a file modified in both the index and the worktree can be shown as one +-- HEAD↔worktree diff. it can't when half-staged content would fall outside that diff +-- entirely, and then the file keeps its two rows, where nothing is hidden +---@param root string +---@param path string +---@return boolean +local function unifiable(root, path) + local union, cached, unstaged = M.union_models(root, path) + if union.binary then + return false + end + return (require("differ.union.marks").complete(union.hunks, cached.hunks, unstaged.hunks)) +end + +-- working-tree status as panel sections: Staged / Partial / Unstaged / Untracked -- (slice B). git status compares HEAD/index/worktree, so it only models the -- default HEAD-vs-worktree source; rev-pair sources use file_entries instead. --- a file edited in both index and worktree (e.g. "MM") appears in both Staged --- (X status, HEAD↔index counts) and Unstaged (Y status, index↔worktree counts). --- empty sections are dropped by the caller +-- a file modified in both the index and the worktree takes one Partial row diffing +-- HEAD↔worktree, so the whole change reads at once; any other combination of two +-- statuses (an "RM", an "AM") keeps a row per pair, as does an "MM" the union diff +-- cannot show whole. empty sections are dropped by the caller ---@param root string ---@return differ.panel.Section[] sections, string|nil err function M.status_sections(root) @@ -670,9 +711,22 @@ function M.status_sections(root) local entries = rev.parse_status(out or "") local staged_counts = numstat({ "--cached" }, root) local unstaged_counts = numstat({}, root) - local staged, unstaged, untracked = {}, {}, {} + local head_counts = numstat({ "HEAD" }, root) + local staged, partial, unstaged, untracked = {}, {}, {}, {} for _, s in ipairs(entries) do - if s.x == "?" then + if s.x == "M" and s.y == "M" and unifiable(root, s.path) then + -- one row, counted against HEAD: that is the diff the row opens, and the + -- section already says the file is only part staged + local c = head_counts[s.path] or {} + partial[#partial + 1] = { + path = s.path, + status = "M", + additions = c.additions or 0, + deletions = c.deletions or 0, + staged = false, + partial = true, + } + elseif s.x == "?" then untracked[#untracked + 1] = { path = s.path, status = "?", @@ -709,6 +763,7 @@ function M.status_sections(root) end local sections = { { title = "Staged", entries = staged }, + { title = "Partial", entries = partial }, { title = "Unstaged", entries = unstaged }, { title = "Untracked", entries = untracked }, } @@ -735,6 +790,34 @@ local function git_ok(args, cwd, what) return true end +-- point `path`'s index entry at `text`, keeping its mode. the caller has already +-- worked out the exact content the index should hold, so this writes a blob and moves +-- the entry rather than applying a patch: no header to compute, no offset to carry, and +-- nothing that can half-apply. `text` must already be in index domain (M.read runs the +-- worktree side through the clean filter), so the blob is stored verbatim +---@param root string +---@param path string +---@param text string +---@return boolean ok +local function write_index(root, path, text) + local listed = git({ "ls-files", "-s", "--", path }, root) + local mode = listed and listed:match("^(%d+)%s") + if not mode then + notify(("%s has no index entry to update"):format(path), vim.log.levels.ERROR) + return false + end + local hashed = vim.system({ "git", "hash-object", "-w", "--stdin" }, { + cwd = root, + stdin = text, + }):wait() + if hashed.code ~= 0 or not hashed.stdout then + notify(("staging %s failed: %s"):format(path, hashed.stderr or ""), vim.log.levels.ERROR) + return false + end + local spec = ("%s,%s,%s"):format(mode, chomp(hashed.stdout), path) + return git_ok({ "update-index", "--cacheinfo", spec }, root, "staging " .. path) +end + ---@param root string ---@param path string ---@return boolean ok @@ -862,7 +945,15 @@ local function live_status(root, entry) local out = git(args, root) for _, s in ipairs(rev.parse_status(out or "")) do if s.path == entry.path then - return s.x == "?" and "?" or (entry.staged and s.x or s.y) + if s.x == "?" then + return "?" + end + -- a partial row stands for both halves, so it survives as long as either + -- side still has a change; its own status is the modification it shows + if entry.partial then + return (s.x ~= " " or s.y ~= " ") and "M" or nil + end + return entry.staged and s.x or s.y end end return nil @@ -1137,8 +1228,14 @@ function M.panel(opts) if is_worktree_status(source) then sections, list_err = M.status_sections(root) model_for = function(entry) - local s = entry.staged and { old = HEAD, new = INDEX } - or { old = INDEX, new = WORKTREE } + local s + if entry.partial then + s = { old = HEAD, new = WORKTREE } + elseif entry.staged then + s = { old = HEAD, new = INDEX } + else + s = { old = INDEX, new = WORKTREE } + end -- re-read HEAD per build so a branch switch under an open panel updates -- the synthetic buffer's statusline label, not just the diff content return M.model(s, root, entry, head_branch(root)) @@ -1146,6 +1243,9 @@ function M.panel(opts) -- the entry's own pair as `diff` args: staged is HEAD↔index, unstaged -- index↔worktree raw_args_for = function(entry) + if entry.partial then + return { "HEAD" } + end return entry.staged and { "--cached" } or {} end actions = { @@ -1300,6 +1400,117 @@ function M.panel(opts) return true end + -- a hunk's extent on one side, widened to a single line where it has none, so a + -- pure insertion or deletion still overlaps the region it sits in + ---@param h differ.Hunk + ---@param side "old"|"new" + ---@return integer start, integer stop -- [start, stop) + local function extent(h, side) + local at, n = h[side .. "_start"], h[side .. "_count"] + if n == 0 then + return at, at + 1 + end + return at, at + n + end + + -- every line of the hunks in `hunks` that do (or don't) meet `[a, b)` on `side`. + -- whole hunks either way, so the partial apply never has to split one: at hunk + -- granularity the unit staged is already a hunk of the pair being patched + ---@param hunks differ.Hunk[] + ---@param side "old"|"new" + ---@param a integer + ---@param b integer + ---@param want boolean -- true picks the hunks that meet the region, false the rest + ---@return differ.model.Selection + local function select_hunks(hunks, side, a, b, want) + local sel = { old = {}, new = {} } + for _, h in ipairs(hunks) do + local hs, he = extent(h, side) + if (hs < b and he > a) == want then + for k = 0, h.old_count - 1 do + sel.old[h.old_start + k] = true + end + for k = 0, h.new_count - 1 do + sel.new[h.new_start + k] = true + end + end + end + return sel + end + + -- staging for a file shown as one HEAD↔worktree diff. both directions work out the + -- content the index should hold and write it whole, rather than patching: staging a + -- union hunk means the index takes the worktree's version of the lines it covers, + -- and unstaging means the index keeps every staged change except those. the two real + -- pairs are re-read per op, so nothing is frozen and no offset is carried + ---@param entry differ.FileEntry + ---@return differ.view.Staging + local function partial_staging(entry) + local marks = require("differ.union.marks") + local apply_text = require("differ.model.apply").partial + -- the view holds this table, so a re-classify writes through it rather than + -- replacing it. it keys on the union hunks read back each time rather than the + -- ones this source opened with: staging leaves HEAD and the worktree alone, so + -- those agree, but a revert rewrites the worktree and takes a hunk out, and the + -- marks have to follow the shape the view is left rendering + local live = {} + local function reclassify() + local union, cached, unstaged = M.union_models(root, entry.path) + local fresh = marks.classify(union.hunks, cached.hunks, unstaged.hunks) + live.old, live.new, live.hunks = fresh.old, fresh.new, fresh.hunks + return cached, unstaged + end + reclassify() + return { + initial = "unstaged", + marks = live, + refresh = refresh_panel, + apply = function(_, hunk, _, reverse) + local cached, unstaged = reclassify() + local from, sel + if reverse then + local a, b = extent(hunk, "old") + from, sel = cached, select_hunks(cached.hunks, "old", a, b, false) + else + local a, b = extent(hunk, "new") + from, sel = unstaged, select_hunks(unstaged.hunks, "new", a, b, true) + end + local text, why = apply_text(from, sel) + if not text then + notify(("this hunk can't be staged: %s"):format(why), vim.log.levels.WARN) + return false + end + if not write_index(root, entry.path, text) then + return false + end + reclassify() -- the index moved; the marks describe where it is now + return true + end, + -- X throws the hunk away on both sides at once: the index gives up whatever + -- of it it holds and the worktree gives up the rest. index first, so a + -- worktree copy that won't take the patch leaves the file merely unstaged + -- rather than holding a change the index no longer has. the worktree half + -- goes through a patch, not a write: the model's new side was read through + -- the clean filter, so writing it back would push that conversion to disk + revert = function(model, hunk) + local cached = select(2, M.union_models(root, entry.path)) + local a, b = extent(hunk, "old") + local text = apply_text(cached, select_hunks(cached.hunks, "old", a, b, false)) + if text and not write_index(root, entry.path, text) then + return false + end + local p = patch.hunk(model.path, hunk, model.old_text, model.new_text, 0, "new") + local ok, err = M.apply_patch(root, p, true, "worktree") + if not ok then + notify(("hunk revert failed: %s"):format(err or ""), vim.log.levels.ERROR) + end + reload_buffer(root, entry.path) + reclassify() + return ok + end, + } + end + ---@param entry differ.FileEntry ---@param diff differ.DiffModel -- the entry's built model; only its hunk count is read ---@return differ.view.Staging|nil @@ -1307,6 +1518,9 @@ function M.panel(opts) if not stageable then return nil end + if entry.partial then + return partial_staging(entry) + end -- settle_pair is assigned below stage_for, so the lookup has to defer to call time local function settle() return settle_pair() @@ -1656,8 +1870,9 @@ function M.panel(opts) -- land on the file (and line) :Differ was run from when it's in the change -- set, else the first unstaged file (skipping the Staged section); leave the -- cursor in the diff, not the panel. - -- a file changed on both pairs ("MM") takes its unstaged row: origin_line is a - -- worktree line, and index↔worktree is the only pair in that coordinate space + -- a two-row file takes its unstaged row: origin_line is a worktree line, and + -- index↔worktree is the only pair of that one in the same coordinate space (a + -- one-row "MM" needs no preference, its union pair already ends at the worktree) local on_origin = origin_rel and panel:focus_file(origin_rel, true) if not on_origin then panel:focus_first_unstaged() diff --git a/lua/differ/panel/tree.lua b/lua/differ/panel/tree.lua index 6ca39ad..aad95db 100644 --- a/lua/differ/panel/tree.lua +++ b/lua/differ/panel/tree.lua @@ -11,6 +11,7 @@ local M = {} ---@field additions integer ---@field deletions integer ---@field staged boolean|nil -- which local section it belongs to +---@field partial boolean|nil -- one row for a file half in the index; diffs HEAD↔worktree ---@field previous_path string|nil -- renames/copies ---@field viewed boolean|nil -- PR only diff --git a/lua/differ/ui/winbar.lua b/lua/differ/ui/winbar.lua index d6328a2..8561d25 100644 --- a/lua/differ/ui/winbar.lua +++ b/lua/differ/ui/winbar.lua @@ -70,9 +70,18 @@ function M.diff() -- while reviewing (not just in the compose window) local draft = require("differ.pr").review_status(buf) local badge = draft and ("%#differReviewDraft#● " .. draft .. "%* ") or "" - return (" %s %%=%s%s hunk %d/%d "):format( + -- a union source's hunk can hold lines the index has and lines it doesn't. the dim + -- shade already says which lines those are; this says the hunk as a whole is mixed, + -- so s and u both still have something to do on it. staged and unstaged say nothing + -- here, the shading being the whole story for them + local mixed = "" + if k > 0 and view.staging and view.staging.marks and view:_hunk_state(k) == "partial" then + mixed = "%#differPanelContext#partial%* " + end + return (" %s %%=%s%s%s hunk %d/%d "):format( esc(vim.fn.fnamemodify(view.model.path, ":t")), badge, + mixed, HUNK_MARK, k, total diff --git a/lua/differ/view.lua b/lua/differ/view.lua index fd066ab..5eb34d3 100644 --- a/lua/differ/view.lua +++ b/lua/differ/view.lua @@ -90,6 +90,7 @@ local armed_view = nil ---@field revert_label? string -- e.g. "deletes the file" ---@field refresh fun() ---@field settle? fun(): boolean -- post-op: re-target the view when this pair emptied +---@field marks? differ.union.Marks -- union source: which lines the index already holds ---@class differ.View ---@field columns differ.ViewColumn[] @@ -179,9 +180,18 @@ function View.new(model, opts) end -- seed staged state for the current source: a staged diff (HEAD↔index) opens with --- everything staged, an unstaged diff (index↔worktree) with nothing +-- everything staged, an unstaged diff (index↔worktree) with nothing. a union source +-- (HEAD↔worktree) carries neither, so its hunks read their state off the marks, where +-- a hunk counts as staged only when the index already holds every line of it function View:_init_staged() self.staged_hunks = {} + local marks = self.staging and self.staging.marks + if marks then + for i, state in ipairs(marks.hunks) do + self.staged_hunks[i] = state == "staged" + end + return + end if not (self.staging and self.staging.initial == "staged") then return end @@ -342,7 +352,7 @@ function View:_paint_staged() vim.api.nvim_buf_clear_namespace(col.bufnr, staged_ns, 0, -1) local staged_lines = {} for i, line in ipairs(col.map.lines) do - if line.hunk and self.staged_hunks[self:_slot(line.hunk)] then + if self:_line_staged(line) then local row = i - 1 -- char-level fill with hl_eol, not line_hl_group: a line_hl_group covers -- the text but loses the past-EOL tail to the diff bg's own hl_eol (it @@ -432,10 +442,7 @@ function View:_paint_cursorline() -- a staged line is recoloured above the live word spans (210/215); lift the cursor -- tint over that so the focused line still lights up in its kind. on a normal line -- stay under the word spans (200) so changed words show through under the cursor - local staged = self.can_stage - and line - and line.hunk - and self.staged_hunks[self:_slot(line.hunk)] + local staged = self.can_stage and line ~= nil and self:_line_staged(line) vim.api.nvim_buf_set_extmark(col.bufnr, cursor_ns, row, 0, { end_row = row + 1, end_col = 0, @@ -1134,6 +1141,42 @@ function View:_slot(h) return h end +-- whether a rail line's content is already in the index. a union source knows this per +-- line, so a part-staged hunk paints as the lines the index holds rather than all or +-- nothing; every other source only knows it per hunk +---@param line differ.RailLine +---@return boolean +function View:_line_staged(line) + if not line.hunk then + return false + end + local marks = self.staging and self.staging.marks + if not marks then + return self.staged_hunks[self:_slot(line.hunk)] or false + end + if line.kind == "old" then + return marks.old[line.old] or false + end + if line.kind == "new" then + return marks.new[line.new] or false + end + return false +end + +-- a hunk's staged state. only a union source has a middle: a hunk git merged out of a +-- change staged and another made after it holds some lines the index has and some it +-- doesn't, and s and u both have work to do on it +---@param idx integer +---@return "staged"|"partial"|"unstaged" +function View:_hunk_state(idx) + local marks = self.staging and self.staging.marks + local state = marks and marks.hunks[idx] + if state then + return state + end + return self.staged_hunks[idx] and "staged" or "unstaged" +end + -- the slot the staging keys act on: slot 1 for a whole-file source, backed by a hunk -- or not; anything else takes the hunk under the cursor ---@return integer|nil @@ -1173,7 +1216,7 @@ function View:stage_hunk() return vim.notify("differ: hunk staging isn't available here", vim.log.levels.WARN) end local idx = self:_target_index() - if idx and not (self.staged_hunks[idx] or false) then + if idx and self:_hunk_state(idx) ~= "staged" then self:_toggle_hunk(true) else self:_step_review("next") @@ -1187,7 +1230,7 @@ function View:unstage_hunk() return vim.notify("differ: hunk staging isn't available here", vim.log.levels.WARN) end local idx = self:_target_index() - if idx and (self.staged_hunks[idx] or false) then + if idx and self:_hunk_state(idx) ~= "unstaged" then self:_toggle_hunk(false) else self:_step_review("prev") @@ -1269,13 +1312,19 @@ function View:_toggle_hunk(want_staged) if not idx then return vim.notify("differ: no hunk under the cursor", vim.log.levels.WARN) end - if (self.staged_hunks[idx] or false) == want_staged then + -- a partial hunk is neither, and both keys have something to do on it: s takes the + -- lines the index is missing, u gives back the ones it holds + local state = self:_hunk_state(idx) + if state == (want_staged and "staged" or "unstaged") then return vim.notify( "differ: hunk already " .. (want_staged and "staged" or "unstaged"), vim.log.levels.INFO ) end if self:_apply_hunk(idx, want_staged) then + if self.staging.marks then + self:_init_staged() -- the op re-read the pairs; take the hunk states off them + end self.staging.refresh() -- that may have taken this pair's last hunk, in which case the session re-sources -- the view onto the file's surviving pair and there's nothing here left to paint @@ -1434,8 +1483,10 @@ function View:revert_hunk() return vim.notify("differ: no hunk under the cursor", vim.log.levels.WARN) end -- on an unstaged diff a marked hunk is already in the index, so reverting only the - -- worktree copy would leave the two differing the opposite way round - if self.model.new_rev == "WORKTREE" and self.staged_hunks[idx] then + -- worktree copy would leave the two differing the opposite way round. a union source + -- reverts both sides in one go, so it has no such half state to fall into + local union = self.staging.marks ~= nil + if self.model.new_rev == "WORKTREE" and not union and self.staged_hunks[idx] then return vim.notify( "differ: hunk is staged; unstage it (u) before reverting", vim.log.levels.WARN diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index 0233c9c..7f9a47b 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -1021,10 +1021,12 @@ describe(":Differ panel", function() it("diffs a staged entry HEAD↔index and an unstaged entry index↔worktree", function() local root = fresh_repo() - -- stage one version of a.lua, then edit further in the worktree - write(root .. "/a.lua", "local x = 2\nreturn x\n") + write(root .. "/b.lua", "one\n") + git(root, "add", "b.lua") + git(root, "commit", "-q", "-m", "b") + write(root .. "/a.lua", "local x = 2\nreturn x\n") -- staged outright git(root, "add", "a.lua") - write(root .. "/a.lua", "local x = 3\nreturn x\n") + write(root .. "/b.lua", "two\n") -- unstaged only vim.cmd.edit(root .. "/a.lua") git_src.panel({}) @@ -1035,17 +1037,46 @@ describe(":Differ panel", function() vim.api.nvim_set_current_win(p.origin_win) return require("differ.view").current() end - -- a.lua is "MM": it appears in both the Staged and Unstaged sections vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "a.lua", true), 0 }) p:select() local v = view_in_origin() assert.are.equal(V1, v.model.old_text) -- HEAD assert.are.equal("local x = 2\nreturn x\n", v.model.new_text) -- index - vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "a.lua", false), 0 }) + vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "b.lua", false), 0 }) p:select() v = view_in_origin() - assert.are.equal("local x = 2\nreturn x\n", v.model.old_text) -- index + assert.are.equal("one\n", v.model.old_text) -- index + assert.are.equal("two\n", v.model.new_text) -- worktree + p:close() + end) + + -- a file modified in the index and again in the worktree takes one row, not two, + -- and that row diffs HEAD↔worktree so the whole change reads at once + it("gives an MM file one Partial row diffing HEAD↔worktree", function() + local root = fresh_repo() + write(root .. "/a.lua", "local x = 2\nreturn x\n") + git(root, "add", "a.lua") + write(root .. "/a.lua", "local x = 3\nreturn x\n") + vim.cmd.edit(root .. "/a.lua") + + git_src.panel({}) + local p = Panel.current() + assert.is_nil(file_line(p, "a.lua", true)) -- no Staged row + local rows = 0 + for _, m in ipairs(p.meta) do + if m.kind == "file" and m.entry.path == "a.lua" then + rows = rows + 1 + assert.is_true(m.entry.partial) + end + end + assert.are.equal(1, rows) + + vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "a.lua", false), 0 }) + p:select() + vim.api.nvim_set_current_win(p.origin_win) + local v = require("differ.view").current() + assert.are.equal(V1, v.model.old_text) -- HEAD assert.are.equal("local x = 3\nreturn x\n", v.model.new_text) -- worktree p:close() end) @@ -1358,6 +1389,20 @@ describe(":Differ diff hunk staging", function() local function worktree(root, path) return table.concat(vim.fn.readfile(root .. "/" .. path), "\n") .. "\n" end + local function committed(root, path) + return git(root, "show", "HEAD:" .. path) + end + -- an "RM": a.lua renamed to b.lua and edited in the index, then edited again in the + -- worktree. the union view only takes "MM", so this is the shape that still lists a + -- row per pair, which is what the pair-following flow needs to be exercised against + ---@return string path -- the new name, which both rows carry + local function rename_and_edit(root, staged_text, work_text) + git(root, "mv", "a.lua", "b.lua") + write(root .. "/b.lua", staged_text) + git(root, "add", "b.lua") + write(root .. "/b.lua", work_text) + return "b.lua" + end -- the buffer line hunk `n` starts on, in the view's primary column local function hunk_line(v, n) for lnum, line in ipairs(v.columns[1].map.lines) do @@ -1673,10 +1718,10 @@ describe(":Differ diff hunk staging", function() p:close() end) - -- an "MM" file lists under Staged and Unstaged, and Staged renders first. the origin - -- line is a worktree line, so it only means anything against the index↔worktree pair: - -- landing on the staged row reads it in index coordinates and snaps to a stray hunk - it("opens the unstaged side of an MM file, holding the exact origin line", function() + -- an "MM" file takes one row diffing HEAD↔worktree. the origin line is a worktree + -- line, and the union pair's new side is the worktree, so it lands exactly rather + -- than being read in index coordinates and snapping to a stray hunk + it("opens an MM file on its union pair, holding the exact origin line", function() local root = fresh_repo() write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") git(root, "commit", "-q", "-am", "ten lines") @@ -1691,9 +1736,10 @@ describe(":Differ diff hunk staging", function() local p = Panel.current() local v = view_in_origin(p) - -- the index↔worktree pair, not HEAD↔index (whose hunk sits at line 1) - assert.are.equal(indexed(root, "a.lua"), v.model.old_text) + -- HEAD↔worktree, carrying both the staged hunk at line 1 and the unstaged one + assert.are.equal(committed(root, "a.lua"), v.model.old_text) assert.are.equal(worktree(root, "a.lua"), v.model.new_text) + assert.are.equal(2, #v.model.hunks) -- line 10 is a changed line there, so it's held exactly (column included) -- rather than falling back to the nearest hunk @@ -1773,11 +1819,13 @@ describe(":Differ diff hunk staging", function() local root = fresh_repo() write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") git(root, "commit", "-q", "-am", "ten lines") - write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") -- staged: a hunk at line 1 - git(root, "add", "a.lua") - write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10x\n") -- unstaged: a hunk at line 10 + local path = rename_and_edit( + root, + "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10\n", -- staged: a hunk at line 1 + "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10x\n" -- unstaged: a hunk at line 10 + ) - vim.cmd.edit(root .. "/a.lua") + vim.cmd.edit(root .. "/" .. path) vim.api.nvim_win_set_cursor(0, { 10, 2 }) git_src.panel({ rev = {}, open_first = true }) @@ -1787,7 +1835,17 @@ describe(":Differ diff hunk staging", function() assert.are.equal(1, #v.model.hunks) -- just the line-10 change vim.api.nvim_set_current_win(p.origin_win) - v:stage_hunk() + local real_notify, said = vim.notify, {} + vim.notify = function(msg) + said[#said + 1] = msg + end + local ok, err = pcall(v.stage_hunk, v) + vim.notify = real_notify + assert.is_true(ok, tostring(err)) + + -- silently: this fires at the end of every file staged hunk by hunk, so a + -- message here would land on each one + assert.are.same({}, said) -- the staged pair, carrying both edits, with every hunk marked staged assert.are.equal("staged", v.staging.initial) @@ -1803,32 +1861,139 @@ describe(":Differ diff hunk staging", function() p:close() end) - it("keeps the folds the user opened when staging follows to the staged side", function() + -- an "MM" file diffs HEAD↔worktree, so staging moves the marks and leaves the diff + -- alone: no pair to empty, nothing to follow, and s/u round-trip on the same buffer + it("stages and unstages an MM file's hunks without swapping the diff", function() local root = fresh_repo() - local lines = {} - for i = 1, 20 do - lines[i] = tostring(i) - end - write(root .. "/a.lua", table.concat(lines, "\n") .. "\n") - git(root, "commit", "-q", "-am", "20 lines") - lines[1] = "1x" - write(root .. "/a.lua", table.concat(lines, "\n") .. "\n") - git(root, "add", "a.lua") -- staged: a hunk at line 1 - lines[20] = "20x" - write(root .. "/a.lua", table.concat(lines, "\n") .. "\n") -- unstaged: line 20 + write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "commit", "-q", "-am", "ten lines") + write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "add", "a.lua") + write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10x\n") + vim.cmd.edit(root .. "/a.lua") + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + local v = view_in_origin(p) + + local buffer = vim.api.nvim_buf_get_lines(v.columns[#v.columns].bufnr, 0, -1, false) + assert.are.equal(2, #v.model.hunks) -- both edits, in one diff + assert.are.equal("staged", v:_hunk_state(1)) + assert.are.equal("unstaged", v:_hunk_state(2)) + + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 2), 0 }) + v:stage_hunk() + assert.are.equal("staged", v:_hunk_state(2)) + assert.are.equal(worktree(root, "a.lua"), indexed(root, "a.lua")) -- all of it staged + -- the buffer never moved: only the marks did + assert.are.same(buffer, vim.api.nvim_buf_get_lines(col.bufnr, 0, -1, false)) + + v:unstage_hunk() + assert.are.equal("unstaged", v:_hunk_state(2)) + assert.are.equal("staged", v:_hunk_state(1)) -- the other hunk is untouched + assert.are.same(buffer, vim.api.nvim_buf_get_lines(col.bufnr, 0, -1, false)) + p:close() + end) + + -- a change staged and then edited again within a few lines merges into one + -- HEAD↔worktree hunk holding both. it reads as partial, and per line the marks still + -- say which of it the index has + it("marks a merged hunk partial and stages the rest with s", function() + local root = fresh_repo() + write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "commit", "-q", "-am", "ten lines") + write(root .. "/a.lua", "1\n2\n3x\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "add", "a.lua") + write(root .. "/a.lua", "1\n2\n3x\n4y\n5y\n6\n7\n8\n9\n10\n") + + vim.cmd.edit(root .. "/a.lua") git_src.panel({ rev = {}, open_first = true }) local p = Panel.current() local v = view_in_origin(p) - v:set_context(1) - open_fold_at(v, "10") - vim.api.nvim_set_current_win(p.origin_win) - vim.api.nvim_win_set_cursor(0, { row_at(v, "20x"), 0 }) + assert.are.equal(1, #v.model.hunks) + assert.are.equal("partial", v:_hunk_state(1)) + -- the staged half is line 3 on each side; the later edits are not + local col = v.columns[#v.columns] + local dimmed = {} + for _, line in ipairs(col.map.lines) do + if v:_line_staged(line) then + dimmed[#dimmed + 1] = line.kind == "old" and line.old or line.new + end + end + assert.are.same({ 3, 3 }, dimmed) + + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) v:stage_hunk() - assert.are.equal("staged", v.staging.initial) - assert.are.equal(-1, fold_closed_at(v, "10")) + assert.are.equal("staged", v:_hunk_state(1)) + assert.are.equal(worktree(root, "a.lua"), indexed(root, "a.lua")) + p:close() + end) + + -- the shading says which lines the index holds; the winbar says the hunk as a whole + -- is mixed, which is what tells you s and u both still have work on it + it("names a partial hunk in the diff winbar", function() + local winbar = require("differ.ui.winbar") + local root = fresh_repo() + write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "commit", "-q", "-am", "ten lines") + write(root .. "/a.lua", "1\n2\n3x\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "add", "a.lua") + write(root .. "/a.lua", "1\n2\n3x\n4y\n5y\n6\n7\n8\n9\n10\n") + + vim.cmd.edit(root .. "/a.lua") + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + local v = view_in_origin(p) + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + + vim.g.statusline_winid = col.winid + assert.is_truthy(winbar.diff():find("partial", 1, true)) + v:stage_hunk() -- nothing mixed about it now + assert.is_nil(winbar.diff():find("partial", 1, true)) + vim.g.statusline_winid = nil + p:close() + end) + + -- on a two-row file X refuses a staged hunk, since reverting only the worktree copy + -- would leave index and worktree differing the opposite way round. a union row has + -- both sides in one diff, so it takes them both and the refusal doesn't apply + it("X on a staged union hunk drops it from the index and the worktree", function() + local root = fresh_repo() + write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "commit", "-q", "-am", "ten lines") + write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "add", "a.lua") + write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10x\n") + + vim.cmd.edit(root .. "/a.lua") + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + local v = view_in_origin(p) + assert.are.equal("staged", v:_hunk_state(1)) + + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + local real_confirm = vim.fn.confirm + vim.fn.confirm = function() + return 1 + end + local ok, err = pcall(v.revert_hunk, v) + vim.fn.confirm = real_confirm + assert.is_true(ok, tostring(err)) + + -- the line-1 change is gone from both sides; the line-10 one is untouched + assert.are.equal(committed(root, "a.lua"), indexed(root, "a.lua")) + assert.are.equal("1\n2\n3\n4\n5\n6\n7\n8\n9\n10x\n", worktree(root, "a.lua")) + assert.are.equal(1, #v.model.hunks) + assert.are.equal("unstaged", v:_hunk_state(1)) -- the marks followed the splice p:close() end) From 107e973f462fdd79acff8be76154a13c1c095341 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:31:10 +0100 Subject: [PATCH 05/93] refactor: move the union marks under model --- lua/differ/git/init.lua | 4 ++-- lua/differ/model/apply.lua | 14 ++++++-------- lua/differ/{union => model}/marks.lua | 12 ++++++------ lua/differ/view.lua | 2 +- .../{union_marks_spec.lua => model_marks_spec.lua} | 2 +- 5 files changed, 16 insertions(+), 18 deletions(-) rename lua/differ/{union => model}/marks.lua (94%) rename test/unit/{union_marks_spec.lua => model_marks_spec.lua} (99%) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 8e607dd..57ac6c2 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -694,7 +694,7 @@ local function unifiable(root, path) if union.binary then return false end - return (require("differ.union.marks").complete(union.hunks, cached.hunks, unstaged.hunks)) + return (require("differ.model.marks").complete(union.hunks, cached.hunks, unstaged.hunks)) end -- working-tree status as panel sections: Staged / Partial / Unstaged / Untracked @@ -1446,7 +1446,7 @@ function M.panel(opts) ---@param entry differ.FileEntry ---@return differ.view.Staging local function partial_staging(entry) - local marks = require("differ.union.marks") + local marks = require("differ.model.marks") local apply_text = require("differ.model.apply").partial -- the view holds this table, so a re-classify writes through it rather than -- replacing it. it keys on the union hunks read back each time rather than the diff --git a/lua/differ/model/apply.lua b/lua/differ/model/apply.lua index 7b5f4f2..590a49c 100644 --- a/lua/differ/model/apply.lua +++ b/lua/differ/model/apply.lua @@ -1,12 +1,10 @@ --- the old side's text with only part of a diff applied, for staging a selection of --- lines rather than a whole hunk. the caller re-diffs the result against the real file --- and patches from that, so a selection never hand-builds a patch body: a diff of two --- real texts always applies, where a hand-built body can place an added line on the --- wrong side of a context line and land a silently wrong index. +-- the old side's text with part of a diff applied: the text the index should hold once +-- a selection of hunks is staged, written whole rather than patched in. -- --- a hunk carries two blocks and no correspondence between them, so a partial hunk is --- only splittable where the mapping is unambiguous: an insertion, a deletion, or a --- replacement of equal length. anything else is refused rather than guessed at. +-- a hunk carries two blocks and no correspondence between them, so a hunk picked only +-- in part is splittable where the mapping is unambiguous: an insertion, a deletion, or +-- a replacement of equal length. anything else is refused rather than guessed at. +-- staging picks whole hunks; the split is for selections narrower than one. -- pure lua, no vim API local to_lines = require("differ.util.text").to_lines diff --git a/lua/differ/union/marks.lua b/lua/differ/model/marks.lua similarity index 94% rename from lua/differ/union/marks.lua rename to lua/differ/model/marks.lua index fde75a3..9bc6c2b 100644 --- a/lua/differ/union/marks.lua +++ b/lua/differ/model/marks.lua @@ -5,12 +5,12 @@ local M = {} ----@alias differ.union.State "staged"|"unstaged"|"partial" +---@alias differ.model.HunkState "staged"|"unstaged"|"partial" ----@class differ.union.Marks +---@class differ.model.Marks ---@field old table -- HEAD lnum -> the index already dropped this line ---@field new table -- worktree lnum -> the index already holds this line ----@field hunks differ.union.State[] -- union hunk index -> rolled-up state +---@field hunks differ.model.HunkState[] -- union hunk index -> rolled-up state -- the lines a hunk list covers on one side, as a set ---@param hunks differ.Hunk[] @@ -35,7 +35,7 @@ end ---@param union differ.Hunk[] -- HEAD↔worktree ---@param cached differ.Hunk[] -- HEAD↔index ---@param unstaged differ.Hunk[] -- index↔worktree ----@return differ.union.Marks +---@return differ.model.Marks function M.classify(union, cached, unstaged) local dropped = covered(cached, "old") local fresh = covered(unstaged, "new") @@ -131,8 +131,8 @@ function M.complete(union, cached, unstaged) end -- how many hunks sit in each state, for the summary line ----@param marks differ.union.Marks ----@return table +---@param marks differ.model.Marks +---@return table function M.tally(marks) local out = { staged = 0, unstaged = 0, partial = 0 } for _, state in ipairs(marks.hunks) do diff --git a/lua/differ/view.lua b/lua/differ/view.lua index 5eb34d3..568b156 100644 --- a/lua/differ/view.lua +++ b/lua/differ/view.lua @@ -90,7 +90,7 @@ local armed_view = nil ---@field revert_label? string -- e.g. "deletes the file" ---@field refresh fun() ---@field settle? fun(): boolean -- post-op: re-target the view when this pair emptied ----@field marks? differ.union.Marks -- union source: which lines the index already holds +---@field marks? differ.model.Marks -- union source: which lines the index already holds ---@class differ.View ---@field columns differ.ViewColumn[] diff --git a/test/unit/union_marks_spec.lua b/test/unit/model_marks_spec.lua similarity index 99% rename from test/unit/union_marks_spec.lua rename to test/unit/model_marks_spec.lua index e1be3e5..edbbc98 100644 --- a/test/unit/union_marks_spec.lua +++ b/test/unit/model_marks_spec.lua @@ -1,4 +1,4 @@ -local marks = require("differ.union.marks") +local marks = require("differ.model.marks") -- classify only reads the four line-range fields, so the hunks here carry just those local function h(old_start, old_count, new_start, new_count) From 034de7e010ea5411d0c097357187a75ae4f46725 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:31:14 +0100 Subject: [PATCH 06/93] fix: catch staged lines the HEAD-to-worktree diff hides --- lua/differ/model/marks.lua | 73 +++++++++++++++++++++++----------- test/nvim/git_spec.lua | 4 +- test/unit/model_marks_spec.lua | 42 +++++++++++++++++-- 3 files changed, 89 insertions(+), 30 deletions(-) diff --git a/lua/differ/model/marks.lua b/lua/differ/model/marks.lua index 9bc6c2b..438ed8b 100644 --- a/lua/differ/model/marks.lua +++ b/lua/differ/model/marks.lua @@ -69,25 +69,27 @@ function M.classify(union, cached, unstaged) return marks end --- every line the hunks delete on `side`, as a multiset of content ----@param hunks differ.Hunk[] ----@param side "old"|"new" ----@return table -local function line_bag(hunks, side) - local out = {} - for _, h in ipairs(hunks) do - for _, l in ipairs(h[side .. "_lines"] or {}) do - out[l] = (out[l] or 0) + 1 +-- the HEAD line an index line sits at, for a line HEAD↔index leaves alone +---@param cached differ.Hunk[] -- HEAD↔index +---@param lnum integer -- index line +---@return integer +local function head_line(cached, lnum) + local shift = 0 + for _, h in ipairs(cached) do + local last = h.new_count == 0 and h.new_start or h.new_start + h.new_count - 1 + if last >= lnum then + break end + shift = shift + h.new_count - h.old_count end - return out + return lnum - shift end -- whether a HEAD↔worktree diff shows all of a file's half-staged content. it cannot -- show index content that differs from HEAD and worktree both: stage a change and then --- put the worktree back, or let the two diffs align a run of repeated lines --- differently, and the change is real but off-screen. three ways that happens, so three --- checks; a false sends the file back to the two-pair view, where nothing is hidden +-- put the worktree back or edit it again, or let the two diffs align a run of repeated +-- lines differently, and the change is real but off-screen. a false sends the file +-- back to the two-pair view, where nothing is hidden ---@param union differ.Hunk[] ---@param cached differ.Hunk[] ---@param unstaged differ.Hunk[] @@ -112,20 +114,43 @@ function M.complete(union, cached, unstaged) end end end - -- a pure deletion has no worktree line to check a range against, so it goes by - -- content: the index dropped these lines, and the union shows them only if HEAD - -- held them too. lines the index itself added and the worktree then dropped never - -- reach either side of a HEAD↔worktree diff - local dropped = line_bag(union, "old") + -- an index line the worktree replaces shows only as the HEAD line it still is: one + -- the index added (or rewrote) reaches neither side, and one whose HEAD line the + -- union leaves as context is shown as committed + local added = covered(cached, "new") for _, h in ipairs(unstaged) do - if h.new_count == 0 then - for _, l in ipairs(h.old_lines or {}) do - if (dropped[l] or 0) == 0 then - return false, "the index holds a line neither HEAD nor the worktree has" - end - dropped[l] = dropped[l] - 1 + for l = h.old_start, h.old_start + h.old_count - 1 do + if added[l] then + return false, "the index holds a line neither HEAD nor the worktree has" + end + if not shows_old[head_line(cached, l)] then + return false, "a line the worktree replaced sits outside every hunk" + end + end + end + -- the view implies an index of HEAD's context plus the marked lines. HEAD's length + -- cancels out of comparing that with HEAD↔index's net change, so the counts check + -- from hunks alone; a mismatch is a line the two pairs align differently + local shown, net = 0, 0 + local m = M.classify(union, cached, unstaged) + for _, h in ipairs(union) do + shown = shown - h.old_count + for l = h.old_start, h.old_start + h.old_count - 1 do + if not m.old[l] then + shown = shown + 1 end end + for l = h.new_start, h.new_start + h.new_count - 1 do + if m.new[l] then + shown = shown + 1 + end + end + end + for _, h in ipairs(cached) do + net = net + h.new_count - h.old_count + end + if shown ~= net then + return false, "the marked lines don't add up to the index" end return true, nil end diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index 7f9a47b..623dbbc 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -1057,7 +1057,7 @@ describe(":Differ panel", function() local root = fresh_repo() write(root .. "/a.lua", "local x = 2\nreturn x\n") git(root, "add", "a.lua") - write(root .. "/a.lua", "local x = 3\nreturn x\n") + write(root .. "/a.lua", "local x = 2\nreturn x + 1\n") vim.cmd.edit(root .. "/a.lua") git_src.panel({}) @@ -1077,7 +1077,7 @@ describe(":Differ panel", function() vim.api.nvim_set_current_win(p.origin_win) local v = require("differ.view").current() assert.are.equal(V1, v.model.old_text) -- HEAD - assert.are.equal("local x = 3\nreturn x\n", v.model.new_text) -- worktree + assert.are.equal("local x = 2\nreturn x + 1\n", v.model.new_text) -- worktree p:close() end) end) diff --git a/test/unit/model_marks_spec.lua b/test/unit/model_marks_spec.lua index edbbc98..ac1084d 100644 --- a/test/unit/model_marks_spec.lua +++ b/test/unit/model_marks_spec.lua @@ -84,16 +84,50 @@ describe("union marks", function() assert.are.equal("staged content sits outside every hunk", why) end) - -- a line the index added and the worktree then dropped reaches neither side of a - -- HEAD↔worktree diff, and a pure deletion has no worktree range to test + -- HEAD x,y; index x,ghost,y; worktree X,y. the line the index added and the + -- worktree then dropped reaches neither side of a HEAD↔worktree diff it("catches a line only the index holds", function() - local union = { hl(3, { "a" }, 3, { "b" }) } - local unstaged = { hl(7, { "ghost" }, 7, {}) } + local union = { hl(1, { "x" }, 1, { "X" }) } + local cached = { hl(1, {}, 2, { "ghost" }) } + local unstaged = { hl(1, { "x", "ghost" }, 1, { "X" }) } + local ok, why = marks.complete(union, cached, unstaged) + assert.is_false(ok) + assert.are.equal("the index holds a line neither HEAD nor the worktree has", why) + end) + + -- the pair diffs can align repeated lines differently from the union: here the + -- worktree drops a HEAD line the union still shows as context + it("catches a replaced line the union shows as context", function() + local union = { h(1, 1, 1, 1) } + local unstaged = { h(5, 1, 4, 0) } local ok, why = marks.complete(union, {}, unstaged) assert.is_false(ok) + assert.are.equal("a line the worktree replaced sits outside every hunk", why) + end) + + -- stage an edit, then edit the same line again: the index's version is replaced + -- in the worktree, so it reaches neither side of a HEAD↔worktree diff + it("catches a staged line edited again in the worktree", function() + local union = { hl(1, { "foo" }, 1, { "FOOD" }) } + local cached = { hl(1, { "foo" }, 1, { "FOO" }) } + local unstaged = { hl(1, { "FOO" }, 1, { "FOOD" }) } + local ok, why = marks.complete(union, cached, unstaged) + assert.is_false(ok) assert.are.equal("the index holds a line neither HEAD nor the worktree has", why) end) + -- HEAD a,a,a,b; index a,a,c,b; worktree b,a,c,b. every range check passes, but + -- the marks keep two HEAD a's and hold the worktree's a too: five index lines + -- where there are four + it("catches marks that count a line twice", function() + local union = { hl(1, { "a", "a", "a" }, 1, { "b", "a", "c" }) } + local cached = { hl(3, { "a" }, 3, { "c" }) } + local unstaged = { hl(1, { "a" }, 1, { "b" }) } + local ok, why = marks.complete(union, cached, unstaged) + assert.is_false(ok) + assert.are.equal("the marked lines don't add up to the index", why) + end) + it("allows a deletion of a line HEAD held too", function() local union = { hl(7, { "gone" }, 7, {}) } local unstaged = { hl(7, { "gone" }, 7, {}) } From 69c637f8ed54c5437dc7fea7dba518d0d712b146 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:48:04 +0100 Subject: [PATCH 07/93] fix: keep one per-line staged state and let u reach partial hunks --- lua/differ/git/init.lua | 2 +- lua/differ/model/marks.lua | 76 ++++++++-------- lua/differ/panel/init.lua | 14 ++- lua/differ/ui/winbar.lua | 2 +- lua/differ/view.lua | 141 ++++++++++++++++++------------ test/nvim/git_spec.lua | 154 ++++++++++++++++++++++++--------- test/unit/model_marks_spec.lua | 35 ++++---- 7 files changed, 268 insertions(+), 156 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 57ac6c2..8cbc990 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -1457,7 +1457,7 @@ function M.panel(opts) local function reclassify() local union, cached, unstaged = M.union_models(root, entry.path) local fresh = marks.classify(union.hunks, cached.hunks, unstaged.hunks) - live.old, live.new, live.hunks = fresh.old, fresh.new, fresh.hunks + live.old, live.new = fresh.old, fresh.new return cached, unstaged end reclassify() diff --git a/lua/differ/model/marks.lua b/lua/differ/model/marks.lua index 438ed8b..cf286e2 100644 --- a/lua/differ/model/marks.lua +++ b/lua/differ/model/marks.lua @@ -8,9 +8,9 @@ local M = {} ---@alias differ.model.HunkState "staged"|"unstaged"|"partial" ---@class differ.model.Marks ----@field old table -- HEAD lnum -> the index already dropped this line ----@field new table -- worktree lnum -> the index already holds this line ----@field hunks differ.model.HunkState[] -- union hunk index -> rolled-up state +---@field old table -- old lnum -> the index already dropped this line +---@field new table -- new lnum -> the index already holds this line +---@field whole boolean|nil -- a whole-file source: the file is staged as one unit -- the lines a hunk list covers on one side, as a set ---@param hunks differ.Hunk[] @@ -27,11 +27,10 @@ local function covered(hunks, side) return out end --- classify every line of a HEAD↔worktree diff as staged or not, and roll each hunk up --- to a tri-state. a HEAD line the union deletes is staged when HEAD↔index deletes it --- too (the index has already dropped it); a worktree line the union adds is staged --- unless index↔worktree adds it (anything that pair leaves as context is in the index --- already). pure: no git, no nvim +-- classify every line of a HEAD↔worktree diff as staged or not. a HEAD line the union +-- deletes is staged when HEAD↔index deletes it too (the index has already dropped it); +-- a worktree line the union adds is staged unless index↔worktree adds it (anything +-- that pair leaves as context is in the index already) ---@param union differ.Hunk[] -- HEAD↔worktree ---@param cached differ.Hunk[] -- HEAD↔index ---@param unstaged differ.Hunk[] -- index↔worktree @@ -39,34 +38,46 @@ end function M.classify(union, cached, unstaged) local dropped = covered(cached, "old") local fresh = covered(unstaged, "new") - local marks = { old = {}, new = {}, hunks = {} } - for i, h in ipairs(union) do - local staged, live = 0, 0 + local marks = { old = {}, new = {} } + for _, h in ipairs(union) do for l = h.old_start, h.old_start + h.old_count - 1 do marks.old[l] = dropped[l] or false - if marks.old[l] then - staged = staged + 1 - else - live = live + 1 - end end for l = h.new_start, h.new_start + h.new_count - 1 do marks.new[l] = not fresh[l] - if marks.new[l] then - staged = staged + 1 - else - live = live + 1 - end end - if live == 0 then - marks.hunks[i] = "staged" - elseif staged == 0 then - marks.hunks[i] = "unstaged" + end + return marks +end + +-- a hunk's state, rolled up from its lines: staged when the index holds all of it, +-- unstaged when it holds none, partial in between +---@param marks differ.model.Marks +---@param h differ.Hunk +---@return differ.model.HunkState +function M.state(marks, h) + local staged, live = 0, 0 + for l = h.old_start, h.old_start + h.old_count - 1 do + if marks.old[l] then + staged = staged + 1 else - marks.hunks[i] = "partial" + live = live + 1 end end - return marks + for l = h.new_start, h.new_start + h.new_count - 1 do + if marks.new[l] then + staged = staged + 1 + else + live = live + 1 + end + end + if live == 0 then + return "staged" + end + if staged == 0 then + return "unstaged" + end + return "partial" end -- the HEAD line an index line sits at, for a line HEAD↔index leaves alone @@ -155,15 +166,4 @@ function M.complete(union, cached, unstaged) return true, nil end --- how many hunks sit in each state, for the summary line ----@param marks differ.model.Marks ----@return table -function M.tally(marks) - local out = { staged = 0, unstaged = 0, partial = 0 } - for _, state in ipairs(marks.hunks) do - out[state] = out[state] + 1 - end - return out -end - return M diff --git a/lua/differ/panel/init.lua b/lua/differ/panel/init.lua index 6a0f833..ee1c3f8 100644 --- a/lua/differ/panel/init.lua +++ b/lua/differ/panel/init.lua @@ -983,6 +983,18 @@ function Panel:focus_first_unstaged() self:focus_first_changed() end +-- whether a row has hunks left to stage (`staged` false) or unstage (true). a Partial +-- row holds both +---@param e differ.FileEntry +---@param staged boolean +---@return boolean +local function has_review_work(e, staged) + if e.partial then + return true + end + return (e.staged or false) == staged +end + -- the review flow's file step: the nearest row in `direction` with something left to do, -- wrapping past the list ends. the open file is skipped, since re-opening it would -- re-source its frozen diff and drop the staging marks @@ -1002,7 +1014,7 @@ function Panel:step_review(direction, staged, keep_focus) end wrapped = wrapped or crossed local e = self.meta[next_row].entry - if e and (e.staged or false) == staged and entry_key(e) ~= self.selected_key then + if e and has_review_work(e, staged) and entry_key(e) ~= self.selected_key then self.selected_row = next_row if self:is_open() then vim.api.nvim_win_set_cursor(self.winid, { next_row, 0 }) diff --git a/lua/differ/ui/winbar.lua b/lua/differ/ui/winbar.lua index 8561d25..7612428 100644 --- a/lua/differ/ui/winbar.lua +++ b/lua/differ/ui/winbar.lua @@ -75,7 +75,7 @@ function M.diff() -- so s and u both still have something to do on it. staged and unstaged say nothing -- here, the shading being the whole story for them local mixed = "" - if k > 0 and view.staging and view.staging.marks and view:_hunk_state(k) == "partial" then + if k > 0 and view:_hunk_state(k) == "partial" then mixed = "%#differPanelContext#partial%* " end return (" %s %%=%s%s%s hunk %d/%d "):format( diff --git a/lua/differ/view.lua b/lua/differ/view.lua index 568b156..19b61b4 100644 --- a/lua/differ/view.lua +++ b/lua/differ/view.lua @@ -104,8 +104,8 @@ local armed_view = nil ---@field keymaps table ---@field can_stage boolean -- session-level: bind s/u (worktree-status panels) ---@field staging differ.view.Staging|nil -- per-source capability (nil off-side) ----@field staged_hunks table -- hunk index -> staged, for marking ---@field fold_memory table -- diff_key -> its open folds when last left +---@field marks differ.model.Marks -- which lines the index holds; hunk state rolls up from it ---@field on_edit_unstage fun(path: string)|nil -- frontend hook: unstage + re-source for edit-in-review ---@field extra_keymaps differ.panel.ExtraMap[]|nil -- session-supplied buffer maps (pr unviewed nav) ---@field on_rerender fun()|nil -- session hook after a re-render, to re-apply overlays (pr threads) @@ -168,8 +168,8 @@ function View.new(model, opts) on_rerender = opts.on_rerender, on_cursor = opts.on_cursor, on_repurpose = opts.on_repurpose, - staged_hunks = {}, fold_memory = {}, + marks = { old = {}, new = {} }, id = next_id(), _suppress_close = false, _closing = false, @@ -179,24 +179,37 @@ function View.new(model, opts) return self end --- seed staged state for the current source: a staged diff (HEAD↔index) opens with --- everything staged, an unstaged diff (index↔worktree) with nothing. a union source --- (HEAD↔worktree) carries neither, so its hunks read their state off the marks, where --- a hunk counts as staged only when the index already holds every line of it +-- seed staged state for the current source. a union source (HEAD↔worktree) hands over +-- its marks and keeps them current itself; otherwise a staged diff (HEAD↔index) opens +-- with every line staged and an unstaged diff (index↔worktree) with none function View:_init_staged() - self.staged_hunks = {} - local marks = self.staging and self.staging.marks - if marks then - for i, state in ipairs(marks.hunks) do - self.staged_hunks[i] = state == "staged" - end + local staging = self.staging + if staging and staging.marks then + self.marks = staging.marks return end - if not (self.staging and self.staging.initial == "staged") then + self.marks = { old = {}, new = {} } + if not (staging and staging.initial == "staged") then return end - for i = 1, self:_slot_count() do - self.staged_hunks[i] = true + if self:_whole_file() then + self.marks.whole = true + return + end + for _, h in ipairs(self.model.hunks) do + self:_mark_hunk(h, true) + end +end + +-- set every line of `h` staged or not +---@param h differ.Hunk +---@param staged boolean +function View:_mark_hunk(h, staged) + for l = h.old_start, h.old_start + h.old_count - 1 do + self.marks.old[l] = staged + end + for l = h.new_start, h.new_start + h.new_count - 1 do + self.marks.new[l] = staged end end @@ -460,7 +473,7 @@ end function View:_stage_offset(idx) local off = 0 for j = 1, idx - 1 do - if self.staged_hunks[j] then + if self:_hunk_state(j) == "staged" then local h = self.model.hunks[j] off = off + (h.new_count - h.old_count) end @@ -968,12 +981,14 @@ function View:goto_hunk(direction, opts) end -- a hunk filter for the review scans: `staged` picks the side, so false matches every --- hunk still to stage and true every one still to unstage +-- hunk with something left to stage and true every one with something left to unstage. +-- a partial hunk matches both ---@param staged boolean ---@return fun(hunk: integer): boolean function View:_review_filter(staged) + local done = staged and "unstaged" or "staged" return function(hunk) - return (self.staged_hunks[self:_slot(hunk)] or false) == staged + return self:_hunk_state(self:_slot(hunk)) ~= done end end @@ -1141,40 +1156,39 @@ function View:_slot(h) return h end --- whether a rail line's content is already in the index. a union source knows this per --- line, so a part-staged hunk paints as the lines the index holds rather than all or --- nothing; every other source only knows it per hunk +-- whether a rail line's content is already in the index ---@param line differ.RailLine ---@return boolean function View:_line_staged(line) if not line.hunk then return false end - local marks = self.staging and self.staging.marks - if not marks then - return self.staged_hunks[self:_slot(line.hunk)] or false + if self:_whole_file() then + return self.marks.whole or false end if line.kind == "old" then - return marks.old[line.old] or false + return self.marks.old[line.old] or false end if line.kind == "new" then - return marks.new[line.new] or false + return self.marks.new[line.new] or false end return false end --- a hunk's staged state. only a union source has a middle: a hunk git merged out of a +-- a slot's staged state. only a union source has a middle: a hunk git merged out of a -- change staged and another made after it holds some lines the index has and some it -- doesn't, and s and u both have work to do on it ---@param idx integer ----@return "staged"|"partial"|"unstaged" +---@return differ.model.HunkState function View:_hunk_state(idx) - local marks = self.staging and self.staging.marks - local state = marks and marks.hunks[idx] - if state then - return state + if self:_whole_file() then + return self.marks.whole and "staged" or "unstaged" end - return self.staged_hunks[idx] and "staged" or "unstaged" + local h = self.model.hunks[idx] + if not h then + return "unstaged" + end + return require("differ.model.marks").state(self.marks, h) end -- the slot the staging keys act on: slot 1 for a whole-file source, backed by a hunk @@ -1195,16 +1209,20 @@ end ---@return boolean function View:_apply_hunk(idx, want_staged) local apply = self.staging and self.staging.apply - if not apply or (self.staged_hunks[idx] or false) == want_staged then + if not apply or self:_hunk_state(idx) == (want_staged and "staged" or "unstaged") then return false end local offset = self:_stage_offset(idx) -- reverse unstages: we patch away a change currently in the index - if apply(self.model, self.model.hunks[idx], offset, not want_staged) then - self.staged_hunks[idx] = want_staged - return true + if not apply(self.model, self.model.hunks[idx], offset, not want_staged) then + return false end - return false + if self:_whole_file() then + self.marks.whole = want_staged + elseif not self.staging.marks then -- a union source has re-read its own + self:_mark_hunk(self.model.hunks[idx], want_staged) + end + return true end -- s: stage the hunk under the cursor, or advance if there's nothing to stage here. @@ -1322,9 +1340,6 @@ function View:_toggle_hunk(want_staged) ) end if self:_apply_hunk(idx, want_staged) then - if self.staging.marks then - self:_init_staged() -- the op re-read the pairs; take the hunk states off them - end self.staging.refresh() -- that may have taken this pair's last hunk, in which case the session re-sources -- the view onto the file's surviving pair and there's nothing here left to paint @@ -1397,7 +1412,7 @@ end function View:_unstage_offset(idx) local off = 0 for j = 1, idx - 1 do - if not self.staged_hunks[j] then + if self:_hunk_state(j) ~= "staged" then local h = self.model.hunks[j] off = off + (h.old_count - h.new_count) end @@ -1405,25 +1420,37 @@ function View:_unstage_offset(idx) return off end --- shift per-hunk staged marks down past a hunk that has just left the model. the --- rebuilt list normally loses exactly the reverted hunk and keeps the rest in order; --- if it didn't, the marks can't be mapped, so reseed them from the source instead of --- carrying wrong ones ----@param removed integer +-- carry the marks past a hunk that has just left the model. a revert rebuilds only the +-- new side, so old lines keep their numbers and new lines past the hunk shift by what +-- it took out. the rebuilt list normally loses exactly the reverted hunk; if it didn't, +-- the marks can't be mapped, so reseed them from the source instead of carrying wrong +-- ones. a union source re-reads its own marks, so there is nothing to carry +---@param h differ.Hunk -- the reverted hunk ---@param before integer -- hunk count before the revert -function View:_rekey_staged(removed, before) +function View:_rekey_staged(h, before) + if self.staging.marks then + return + end if #self.model.hunks ~= before - 1 then return self:_init_staged() end - local out = {} - for i, staged in pairs(self.staged_hunks) do - if i < removed then - out[i] = staged - elseif i > removed then - out[i - 1] = staged + local old, new = {}, {} + for l, staged in pairs(self.marks.old) do + if l < h.old_start or l >= h.old_start + h.old_count then + old[l] = staged + end + end + -- a zero-count hunk sits after new_start rather than covering it + local first = h.new_count > 0 and h.new_start or h.new_start + 1 + local last = first + h.new_count - 1 + for l, staged in pairs(self.marks.new) do + if l < first then + new[l] = staged + elseif l > last then + new[l + h.old_count - h.new_count] = staged end end - self.staged_hunks = out + self.marks = { old = old, new = new, whole = self.marks.whole } end -- the new-side line to land on once `h` is reverted: the cursor's own line, shifted by @@ -1486,7 +1513,7 @@ function View:revert_hunk() -- worktree copy would leave the two differing the opposite way round. a union source -- reverts both sides in one go, so it has no such half state to fall into local union = self.staging.marks ~= nil - if self.model.new_rev == "WORKTREE" and not union and self.staged_hunks[idx] then + if self.model.new_rev == "WORKTREE" and not union and self:_hunk_state(idx) ~= "unstaged" then return vim.notify( "differ: hunk is staged; unstage it (u) before reverting", vim.log.levels.WARN @@ -1539,7 +1566,7 @@ function View:revert_hunk() local opened = self:_opened_folds() -- before rerender replaces col.folds self.model = require("differ.model.diff").revert_hunk(self.model, idx) - self:_rekey_staged(idx, before) + self:_rekey_staged(hunk, before) self:rerender({ layout = self.layout, context = self.context, deep_diff = self.deep_diff }) self:_apply_folds(opened) -- stay where the reverted hunk was rather than being pulled to the next one diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index 623dbbc..4998c77 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -1443,8 +1443,8 @@ describe(":Differ diff hunk staging", function() -- the diff didn't vanish or re-source: same hunks, same buffer, hunk 1 marked assert.are.equal(2, #v.model.hunks) assert.are.same(before, vim.api.nvim_buf_get_lines(v.columns[1].bufnr, 0, -1, false)) - assert.is_true(v.staged_hunks[1]) - assert.is_nil(v.staged_hunks[2]) + assert.are.equal("staged", v:_hunk_state(1)) + assert.are.equal("unstaged", v:_hunk_state(2)) p:close() end) @@ -1468,7 +1468,7 @@ describe(":Differ diff hunk staging", function() assert.are.equal("a.lua", v.model.path) vim.api.nvim_win_set_cursor(p.origin_win, { 1, 0 }) v:stage_hunk() -- mark hunk 1 in place, leaving hunk 2 unstaged - assert.is_true(v.staged_hunks[1]) + assert.are.equal("staged", v:_hunk_state(1)) -- stage a *different* file whole, from the sidebar vim.api.nvim_set_current_win(p.winid) @@ -1481,8 +1481,8 @@ describe(":Differ diff hunk staging", function() p.on_external_change() assert.are.equal(2, #v.model.hunks) -- still frozen on the same two hunks - assert.is_true(v.staged_hunks[1]) -- and hunk 1 is still marked staged - assert.is_nil(v.staged_hunks[2]) + assert.are.equal("staged", v:_hunk_state(1)) -- and hunk 1 is still marked staged + assert.are.equal("unstaged", v:_hunk_state(2)) p:close() end) @@ -1496,17 +1496,17 @@ describe(":Differ diff hunk staging", function() local p = Panel.current() local v = view_in_origin(p) assert.are.equal("staged", v.staging.initial) -- a staged (HEAD↔index) diff - assert.is_true(v.staged_hunks[1]) -- opens marked staged + assert.are.equal("staged", v:_hunk_state(1)) -- opens marked staged assert.are.equal("local x = 2\nreturn x\n", indexed(root, "a.lua")) vim.api.nvim_win_set_cursor(p.origin_win, { 1, 0 }) v:unstage_hunk() assert.are.equal(V1, indexed(root, "a.lua")) -- index reverted to HEAD - assert.is_false(v.staged_hunks[1]) -- now marked unstaged, still visible + assert.are.equal("unstaged", v:_hunk_state(1)) -- now marked unstaged, still visible v:stage_hunk() -- u then s on the same hunk: the mark toggles back assert.are.equal("local x = 2\nreturn x\n", indexed(root, "a.lua")) - assert.is_true(v.staged_hunks[1]) + assert.are.equal("staged", v:_hunk_state(1)) p:close() end) @@ -1522,7 +1522,7 @@ describe(":Differ diff hunk staging", function() local v = view_in_origin(p) vim.api.nvim_win_set_cursor(p.origin_win, { 1, 0 }) v:stage_hunk() - assert.is_true(v.staged_hunks[1]) + assert.are.equal("staged", v:_hunk_state(1)) -- s/u on a hunk already in the target state re-enter the review flow instead -- (stage_hunk/unstage_hunk step past it), so _toggle_hunk itself is called @@ -1531,15 +1531,15 @@ describe(":Differ diff hunk staging", function() v:_toggle_hunk(true) assert.are.equal("differ: hunk already staged", _G.notifs[1].msg) assert.are.equal(vim.log.levels.INFO, _G.notifs[1].level) - assert.is_true(v.staged_hunks[1]) -- state untouched + assert.are.equal("staged", v:_hunk_state(1)) -- state untouched v:_toggle_hunk(false) -- actually unstage it, so the mirror check below is real - assert.is_false(v.staged_hunks[1]) + assert.are.equal("unstaged", v:_hunk_state(1)) _G.notifs = {} v:_toggle_hunk(false) assert.are.equal("differ: hunk already unstaged", _G.notifs[1].msg) assert.are.equal(vim.log.levels.INFO, _G.notifs[1].level) - assert.is_false(v.staged_hunks[1]) -- state untouched + assert.are.equal("unstaged", v:_hunk_state(1)) -- state untouched p:close() end) @@ -1558,10 +1558,10 @@ describe(":Differ diff hunk staging", function() vim.api.nvim_win_set_cursor(p.origin_win, { 1, 0 }) v:stage_hunk() assert.are.equal("alpha\nbeta\n", indexed(root, "new.lua")) -- whole file staged - assert.is_true(v.staged_hunks[1]) + assert.are.equal("staged", v:_hunk_state(1)) v:unstage_hunk() -- u back: leaves the index, untracked again - assert.is_false(v.staged_hunks[1]) + assert.are.equal("unstaged", v:_hunk_state(1)) local porc = vim.system( { "git", "status", "--porcelain", "--", "new.lua" }, { cwd = root, text = true } @@ -1850,8 +1850,8 @@ describe(":Differ diff hunk staging", function() -- the staged pair, carrying both edits, with every hunk marked staged assert.are.equal("staged", v.staging.initial) assert.are.equal(2, #v.model.hunks) -- the line-1 and line-10 changes together - assert.is_true(v.staged_hunks[1]) - assert.is_true(v.staged_hunks[2]) + assert.are.equal("staged", v:_hunk_state(1)) + assert.are.equal("staged", v:_hunk_state(2)) -- and the cursor held line 10 rather than snapping to the first hunk local col = v.columns[#v.columns] @@ -1934,6 +1934,76 @@ describe(":Differ diff hunk staging", function() p:close() end) + it("unstages a partial hunk with u", function() + local root = fresh_repo() + write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "commit", "-q", "-am", "ten lines") + write(root .. "/a.lua", "1\n2\n3x\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "add", "a.lua") + write(root .. "/a.lua", "1\n2\n3x\n4y\n5y\n6\n7\n8\n9\n10\n") + + vim.cmd.edit(root .. "/a.lua") + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + local v = view_in_origin(p) + assert.are.equal("partial", v:_hunk_state(1)) + + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + v:unstage_hunk() + assert.are.equal("unstaged", v:_hunk_state(1)) + assert.are.equal(committed(root, "a.lua"), indexed(root, "a.lua")) + p:close() + end) + + -- a partial hunk still holds something to unstage, so the backward walk stops on it + it("walks u back onto a partial hunk", function() + local root = fresh_repo() + write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "commit", "-q", "-am", "ten lines") + write(root .. "/a.lua", "1\n2\n3x\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "add", "a.lua") + write(root .. "/a.lua", "1\n2\n3x\n4y\n5y\n6\n7\n8\n9\n10x\n") + + vim.cmd.edit(root .. "/a.lua") + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + local v = view_in_origin(p) + assert.are.equal("partial", v:_hunk_state(1)) + assert.are.equal("unstaged", v:_hunk_state(2)) + + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 2), 0 }) + v:unstage_hunk() -- nothing staged here, so it steps back + assert.are.equal(hunk_line(v, 1), vim.api.nvim_win_get_cursor(col.winid)[1]) + p:close() + end) + + -- a Partial row holds work in both directions, so the file step backwards (U with + -- nothing staged here) lands on it rather than passing it by + it("steps the unstage walk onto a Partial row", function() + local root = fresh_repo() + write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") + write(root .. "/b.lua", "one\n") + git(root, "add", "b.lua") + git(root, "commit", "-q", "-am", "ten lines and b") + write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "add", "a.lua") + write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10x\n") + write(root .. "/b.lua", "two\n") -- unstaged only + + vim.cmd.edit(root .. "/b.lua") + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + assert.are.equal("b.lua", view_in_origin(p).model.path) + + assert.is_true(p:step_review("prev", true, true)) + assert.are.equal("a.lua", view_in_origin(p).model.path) + p:close() + end) + -- the shading says which lines the index holds; the winbar says the hunk as a whole -- is mixed, which is what tells you s and u both still have work on it it("names a partial hunk in the diff winbar", function() @@ -2017,10 +2087,10 @@ describe(":Differ diff hunk staging", function() assert.are.equal(V1, indexed(root, "a.lua")) -- back to HEAD assert.are.equal("staged", v.staging.initial) assert.are.equal("a.lua", v.model.path) - assert.is_false(v.staged_hunks[1]) + assert.are.equal("unstaged", v:_hunk_state(1)) v:stage_hunk() -- and s puts it back in place, on the same pair - assert.is_true(v.staged_hunks[1]) + assert.are.equal("staged", v:_hunk_state(1)) assert.are.equal(worktree(root, "a.lua"), indexed(root, "a.lua")) p:close() end) @@ -2115,8 +2185,8 @@ describe(":Differ diff hunk staging", function() vim.api.nvim_set_current_win(p.origin_win) vim.api.nvim_win_set_cursor(p.origin_win, { hunk_line(v, 2), 0 }) v:stage_hunk() - assert.is_true(v.staged_hunks[2]) - assert.is_falsy(v.staged_hunks[1]) + assert.are.equal("staged", v:_hunk_state(2)) + assert.are.equal("unstaged", v:_hunk_state(1)) -- B1 after line 6, where the hunk actually is; not after line 8, where the -- worktree's own numbering would put it @@ -2145,14 +2215,14 @@ describe(":Differ diff hunk staging", function() for _, n in ipairs({ 1, 2 }) do vim.api.nvim_win_set_cursor(p.origin_win, { hunk_line(v, n), 0 }) v:stage_hunk() - assert.is_true(v.staged_hunks[n]) + assert.are.equal("staged", v:_hunk_state(n)) end assert.are.equal("1\nINS1\nINS2\n2\n3\n4\n7\n8\n9\n10\n", indexed(root, "a.lua")) -- put the deletion back: lines 5 and 6 belong after 4, not earlier vim.api.nvim_win_set_cursor(p.origin_win, { hunk_line(v, 2), 0 }) v:unstage_hunk() - assert.is_false(v.staged_hunks[2]) + assert.are.equal("unstaged", v:_hunk_state(2)) assert.are.equal("1\nINS1\nINS2\n2\n3\n4\n5\n6\n7\n8\n9\n10\n", indexed(root, "a.lua")) p:close() end) @@ -2374,7 +2444,7 @@ describe(":Differ diff hunk staging", function() vim.api.nvim_set_current_win(p.origin_win) vim.api.nvim_win_set_cursor(p.origin_win, { 1, 0 }) v:stage_hunk() - assert.is_true(v.staged_hunks[1]) + assert.are.equal("staged", v:_hunk_state(1)) assert.are.equal("WORKTREE", v.model.new_rev) -- still the unstaged pair confirming(1, function() -- would say yes, but it never gets asked @@ -2732,7 +2802,7 @@ describe(":Differ diff hunk staging", function() v:stage_hunk() os.remove(root .. "/.git/index.lock") -- dropped before the asserts, not after - assert.is_falsy(v.staged_hunks[1]) -- no mark for something git didn't stage + assert.are.equal("unstaged", v:_hunk_state(1)) -- no mark for something git didn't stage assert.are.equal("", git(root, "ls-files", "--", "z.lua")) -- and it really didn't assert.is_truthy( (_G.notifs[1] and _G.notifs[1].msg or ""):find("stage failed", 1, true), @@ -2796,8 +2866,8 @@ describe(":Differ diff hunk staging", function() end) assert.are.equal(2, #v.model.hunks) - assert.is_true(v.staged_hunks[1]) -- still the 1 -> 1x hunk - assert.is_nil(v.staged_hunks[2]) -- what was hunk 3, unstaged, renumbered down + assert.are.equal("staged", v:_hunk_state(1)) -- still the 1 -> 1x hunk + assert.are.equal("unstaged", v:_hunk_state(2)) -- what was hunk 3, unstaged, renumbered down assert.are.same({ "9x" }, v.model.hunks[2].new_lines) p:close() end) @@ -3009,9 +3079,9 @@ describe(":Differ diff hunk staging", function() v:stage_hunk() -- advance to hunk 3 assert.are.equal(hunk_line(v, 3), vim.api.nvim_win_get_cursor(p.origin_win)[1]) v:stage_hunk() -- stage hunk 3 - assert.is_true(v.staged_hunks[2]) - assert.is_true(v.staged_hunks[3]) - assert.is_falsy(v.staged_hunks[1]) + assert.are.equal("staged", v:_hunk_state(2)) + assert.are.equal("staged", v:_hunk_state(3)) + assert.are.equal("unstaged", v:_hunk_state(1)) -- at the bottom of the file: back round to the hunk left behind, not off to -- another file, and without re-sourcing the frozen diff @@ -3068,7 +3138,7 @@ describe(":Differ diff hunk staging", function() -- and it stages there, finishing the file v:stage_hunk() - assert.is_true(v.staged_hunks[1]) + assert.are.equal("staged", v:_hunk_state(1)) assert.are.equal(worktree(root, "a.lua"), indexed(root, "a.lua")) p:close() end) @@ -3094,13 +3164,13 @@ describe(":Differ diff hunk staging", function() v:stage_hunk() -- stage hunk 1 v:stage_hunk() -- advance to hunk 2 v:stage_hunk() -- stage hunk 2 - assert.is_true(v.staged_hunks[1]) - assert.is_true(v.staged_hunks[2]) - assert.is_falsy(v.staged_hunks[3]) + assert.are.equal("staged", v:_hunk_state(1)) + assert.are.equal("staged", v:_hunk_state(2)) + assert.are.equal("unstaged", v:_hunk_state(3)) vim.api.nvim_win_set_cursor(p.origin_win, { hunk_line(v, 1), 0 }) v:unstage_hunk() -- unstage hunk 1, in place - assert.is_false(v.staged_hunks[1]) + assert.are.equal("unstaged", v:_hunk_state(1)) _G.notifs = {} v:unstage_hunk() -- nothing staged behind it: round to hunk 2 @@ -3132,14 +3202,14 @@ describe(":Differ diff hunk staging", function() assert.are.equal(2, vim.api.nvim_win_get_cursor(p.origin_win)[1]) -- on hunk 1 v:stage_hunk() -- stage hunk 1; cursor stays put, marked - assert.is_true(v.staged_hunks[1]) + assert.are.equal("staged", v:_hunk_state(1)) assert.are.equal(2, vim.api.nvim_win_get_cursor(p.origin_win)[1]) v:stage_hunk() -- second s: advance to hunk 2 (buffer line 9) assert.are.equal(9, vim.api.nvim_win_get_cursor(p.origin_win)[1]) v:stage_hunk() -- stage hunk 2 - assert.is_true(v.staged_hunks[2]) + assert.are.equal("staged", v:_hunk_state(2)) v:stage_hunk() -- second s on the last hunk: step to the next file assert.are.equal("z.lua", v.model.path) @@ -3165,7 +3235,7 @@ describe(":Differ diff hunk staging", function() assert.are.equal("z.lua", v.model.path) v:unstage_hunk() -- unstage z.lua's hunk; cursor stays, now unstaged - assert.is_false(v.staged_hunks[1]) + assert.are.equal("unstaged", v:_hunk_state(1)) v:unstage_hunk() -- second u: no earlier hunk -> step back to a.lua's last hunk assert.are.equal("a.lua", v.model.path) @@ -3188,13 +3258,13 @@ describe(":Differ diff hunk staging", function() v:stage_all() -- both hunks into the index in one go assert.are.equal("a\nINS1\nINS2\nb\nc\nD\n", indexed(root, "a.lua")) - assert.is_true(v.staged_hunks[1]) - assert.is_true(v.staged_hunks[2]) + assert.are.equal("staged", v:_hunk_state(1)) + assert.are.equal("staged", v:_hunk_state(2)) v:unstage_all() -- back out of the index entirely assert.are.equal("a\nb\nc\nd\n", indexed(root, "a.lua")) -- == HEAD - assert.is_false(v.staged_hunks[1]) - assert.is_false(v.staged_hunks[2]) + assert.are.equal("unstaged", v:_hunk_state(1)) + assert.are.equal("unstaged", v:_hunk_state(2)) p:close() end) @@ -3936,7 +4006,7 @@ describe(":Differ diff whole-file staging", function() local p = Panel.current() local v = view_in_origin(p) assert.are.equal("staged", v.staging.initial) - assert.is_true(v.staged_hunks[1]) -- seeded with no hunk behind it + assert.are.equal("staged", v:_hunk_state(1)) -- seeded with no hunk behind it assert.are.equal("100755", index_mode(root, "a.lua")) v:unstage_hunk() diff --git a/test/unit/model_marks_spec.lua b/test/unit/model_marks_spec.lua index ac1084d..7bd369f 100644 --- a/test/unit/model_marks_spec.lua +++ b/test/unit/model_marks_spec.lua @@ -10,21 +10,28 @@ local function h(old_start, old_count, new_start, new_count) } end +-- each union hunk's rolled-up state +local function states(m, union) + local out = {} + for i, u in ipairs(union) do + out[i] = marks.state(m, u) + end + return out +end + describe("union marks", function() it("calls every hunk unstaged when the index holds nothing", function() - local m = marks.classify( - { h(2, 1, 2, 1), h(9, 1, 9, 1) }, - {}, - { h(2, 1, 2, 1), h(9, 1, 9, 1) } - ) - assert.are.same({ "unstaged", "unstaged" }, m.hunks) + local union = { h(2, 1, 2, 1), h(9, 1, 9, 1) } + local m = marks.classify(union, {}, { h(2, 1, 2, 1), h(9, 1, 9, 1) }) + assert.are.same({ "unstaged", "unstaged" }, states(m, union)) assert.is_false(m.old[2]) assert.is_false(m.new[2]) end) it("calls every hunk staged when the worktree matches the index", function() - local m = marks.classify({ h(2, 1, 2, 1) }, { h(2, 1, 2, 1) }, {}) - assert.are.same({ "staged" }, m.hunks) + local union = { h(2, 1, 2, 1) } + local m = marks.classify(union, { h(2, 1, 2, 1) }, {}) + assert.are.same({ "staged" }, states(m, union)) assert.is_true(m.old[2]) assert.is_true(m.new[2]) end) @@ -34,15 +41,16 @@ describe("union marks", function() it("rolls each hunk up separately", function() local union = { h(1, 1, 1, 1), h(10, 1, 10, 1) } local m = marks.classify(union, { h(1, 1, 1, 1) }, { h(10, 1, 10, 1) }) - assert.are.same({ "staged", "unstaged" }, m.hunks) + assert.are.same({ "staged", "unstaged" }, states(m, union)) end) -- the ticket's repro: a hunk staged, then two more edits inside the region it -- covers, so HEAD↔worktree collapses all three into one hunk. that hunk is partial, -- and the per-line marks still say which line of it the index already has it("marks a hunk partial when later edits land inside a staged one", function() - local m = marks.classify({ h(3, 3, 3, 3) }, { h(3, 1, 3, 1) }, { h(4, 2, 4, 2) }) - assert.are.same({ "partial" }, m.hunks) + local union = { h(3, 3, 3, 3) } + local m = marks.classify(union, { h(3, 1, 3, 1) }, { h(4, 2, 4, 2) }) + assert.are.same({ "partial" }, states(m, union)) assert.are.same({ [3] = true, [4] = false, [5] = false }, m.old) assert.are.same({ [3] = true, [4] = false, [5] = false }, m.new) end) @@ -134,9 +142,4 @@ describe("union marks", function() assert.is_true(marks.complete(union, {}, unstaged)) end) end) - - it("tallies the hunk states", function() - local m = { hunks = { "staged", "partial", "unstaged", "unstaged" } } - assert.are.same({ staged = 1, partial = 1, unstaged = 2 }, marks.tally(m)) - end) end) From 8733756bb885020138fda8d5150e42aa79a927ae Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:26:23 +0100 Subject: [PATCH 08/93] feat: give every changed path one row diffing HEAD-to-worktree --- lua/differ/git/init.lua | 652 ++++++++++++++++-------------------- lua/differ/init.lua | 2 - lua/differ/model/diff.lua | 1 + lua/differ/model/marks.lua | 6 +- lua/differ/panel/init.lua | 106 +++--- lua/differ/panel/render.lua | 3 +- lua/differ/panel/tree.lua | 5 +- lua/differ/ui/winbar.lua | 15 +- lua/differ/view.lua | 165 ++------- test/nvim/git_spec.lua | 404 ++++++++++++++-------- test/nvim/panel_spec.lua | 62 +--- test/nvim/view_spec.lua | 72 ---- 12 files changed, 650 insertions(+), 843 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 8cbc990..79dfcae 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -657,17 +657,16 @@ function M.file_entries(source, root) return out end --- the three models a half-staged file needs: HEAD↔worktree to show, and the two real --- pairs to derive its marks from. built from blobs rather than `git diff` so all three --- come off one read of each side +-- a file's three diffs over texts already read: HEAD↔worktree to show, and the two +-- real pairs its marks come from ---@param root string ---@param path string +---@param head string +---@param index string +---@param work string ---@return differ.DiffModel union, differ.DiffModel cached, differ.DiffModel unstaged -function M.union_models(root, path) +local function union_pairs(root, path, head, index, work) local build = require("differ.model.diff").build - local head = M.read(HEAD, root, path) or "" - local index = M.read(INDEX, root, path) or "" - local work = M.read(WORKTREE, root, path) or "" local function pair(old_rev, new_rev, old_text, new_text) return build({ path = path, @@ -683,89 +682,102 @@ function M.union_models(root, path) pair("INDEX", "WORKTREE", index, work) end --- whether a file modified in both the index and the worktree can be shown as one --- HEAD↔worktree diff. it can't when half-staged content would fall outside that diff --- entirely, and then the file keeps its two rows, where nothing is hidden +-- a row's three diffs, read fresh. HEAD is read at a rename's old path, and so is the +-- index for a rename only the worktree has made (` R`), whose entry at the new path is +-- an empty intent-to-add placeholder ---@param root string ----@param path string ----@return boolean -local function unifiable(root, path) - local union, cached, unstaged = M.union_models(root, path) - if union.binary then - return false +---@param entry differ.FileEntry +---@return differ.DiffModel union, differ.DiffModel cached, differ.DiffModel unstaged +function M.union_models(root, entry) + local prev = entry.previous_path + local at_head = prev or entry.path + local at_index = entry.path + if entry.y == "R" and prev then + at_index = prev + end + return union_pairs( + root, + entry.path, + M.read(HEAD, root, at_head) or "", + M.read(INDEX, root, at_index) or "", + M.read(WORKTREE, root, entry.path) or "" + ) +end + +-- a porcelain entry's section and the letter its row shows. the section comes from +-- which of the index (X) and the worktree (Y) hold a change; the letter is the change +-- the row's HEAD↔worktree diff shows +---@param s { x: string, y: string } +---@return string section, string status +local function row_of(s) + if s.x == "?" then + return "Untracked", "?" + end + if s.x == "U" or s.y == "U" or (s.x == s.y and (s.x == "A" or s.x == "D")) then + return "Conflicts", "U" end - return (require("differ.model.marks").complete(union.hunks, cached.hunks, unstaged.hunks)) + if s.y == " " then + return "Staged", s.x + end + if s.x == " " then + return "Unstaged", s.y + end + if s.y == "D" and s.x ~= "A" then + return "Partial", "D" + end + return "Partial", s.x end -- working-tree status as panel sections: Staged / Partial / Unstaged / Untracked -- (slice B). git status compares HEAD/index/worktree, so it only models the -- default HEAD-vs-worktree source; rev-pair sources use file_entries instead. --- a file modified in both the index and the worktree takes one Partial row diffing --- HEAD↔worktree, so the whole change reads at once; any other combination of two --- statuses (an "RM", an "AM") keeps a row per pair, as does an "MM" the union diff --- cannot show whole. empty sections are dropped by the caller +-- every changed path takes one row, counted against HEAD since that is the diff it +-- opens. a staged deletion counts against the index: porcelain lists a file removed +-- from the index but still on disk twice (`D ` and `??`), and its one row is the +-- deletion, marked `kept`. empty sections are dropped by the caller ---@param root string ---@return differ.panel.Section[] sections, string|nil err function M.status_sections(root) local out, err = git({ "status", "--porcelain=v1", "-z", "-uall" }, root) local entries = rev.parse_status(out or "") local staged_counts = numstat({ "--cached" }, root) - local unstaged_counts = numstat({}, root) local head_counts = numstat({ "HEAD" }, root) - local staged, partial, unstaged, untracked = {}, {}, {}, {} + local removed, untracked = {}, {} for _, s in ipairs(entries) do - if s.x == "M" and s.y == "M" and unifiable(root, s.path) then - -- one row, counted against HEAD: that is the diff the row opens, and the - -- section already says the file is only part staged - local c = head_counts[s.path] or {} - partial[#partial + 1] = { - path = s.path, - status = "M", - additions = c.additions or 0, - deletions = c.deletions or 0, - staged = false, - partial = true, - } + if s.x == "D" then + removed[s.path] = true elseif s.x == "?" then - untracked[#untracked + 1] = { + untracked[s.path] = true + end + end + local rows = { Conflicts = {}, Staged = {}, Partial = {}, Unstaged = {}, Untracked = {} } + for _, s in ipairs(entries) do + if not (s.x == "?" and removed[s.path]) then + local section, status = row_of(s) + local c = (s.x == "D" and staged_counts or head_counts)[s.path] or {} + local additions = c.additions or 0 + if status == "?" then + additions = untracked_additions(root, s.path) + end + local list = rows[section] + list[#list + 1] = { path = s.path, - status = "?", - additions = untracked_additions(root, s.path), - deletions = 0, - staged = false, + status = status, + additions = additions, + deletions = c.deletions or 0, + x = s.x, + y = s.y, + previous_path = s.previous_path, + kept = (s.x == "D" and untracked[s.path]) or nil, } - else - -- previous_path only belongs to whichever pair carries the rename: - -- an "RM" file is renamed HEAD↔index but plain-modified index↔worktree - if s.x ~= " " then - local c = staged_counts[s.path] or {} - staged[#staged + 1] = { - path = s.path, - status = s.x, - additions = c.additions or 0, - deletions = c.deletions or 0, - staged = true, - previous_path = (s.x == "R" or s.x == "C") and s.previous_path or nil, - } - end - if s.y ~= " " then - local c = unstaged_counts[s.path] or {} - unstaged[#unstaged + 1] = { - path = s.path, - status = s.y, - additions = c.additions or 0, - deletions = c.deletions or 0, - staged = false, - previous_path = (s.y == "R" or s.y == "C") and s.previous_path or nil, - } - end end end local sections = { - { title = "Staged", entries = staged }, - { title = "Partial", entries = partial }, - { title = "Unstaged", entries = unstaged }, - { title = "Untracked", entries = untracked }, + { title = "Conflicts", entries = rows.Conflicts }, + { title = "Staged", entries = rows.Staged }, + { title = "Partial", entries = rows.Partial }, + { title = "Unstaged", entries = rows.Unstaged }, + { title = "Untracked", entries = rows.Untracked }, } return sections, err end @@ -944,31 +956,27 @@ local function live_status(root, entry) end local out = git(args, root) for _, s in ipairs(rev.parse_status(out or "")) do - if s.path == entry.path then - if s.x == "?" then - return "?" - end - -- a partial row stands for both halves, so it survives as long as either - -- side still has a change; its own status is the modification it shows - if entry.partial then - return (s.x ~= " " or s.y ~= " ") and "M" or nil - end - return entry.staged and s.x or s.y + -- a kept deletion's copy on disk is its own `??` line, and not this row's status + if s.path == entry.path and not (s.x == "?" and entry.kept) then + return select(2, row_of(s)) end end return nil end --- the path's `diff --raw` line under `args`'s pair, or nil when the pair no longer --- lists it at all +-- the entry's `diff --raw` line under `args`'s pair, or nil when the pair no longer +-- lists it at all. git pairs a rename only with both paths in the pathspec ---@param root string ---@param args string[] ----@param path string +---@param entry differ.FileEntry ---@return string|nil -local function raw_line(root, args, path) +local function raw_line(root, args, entry) local full = { "diff", "--raw" } vim.list_extend(full, args) - vim.list_extend(full, { "--", path }) + vim.list_extend(full, { "--", entry.path }) + if entry.previous_path then + full[#full + 1] = entry.previous_path + end local out = git(full, root) if not out or chomp(out) == "" then return nil @@ -994,8 +1002,13 @@ local function empty_notice(root, entry, model, args) if entry.status == "?" then return live_status(root, entry) == entry.status and empty_or_not or nil end - local out = raw_line(root, args, entry.path) + local out = raw_line(root, args, entry) if not out then + -- a row changed on both sides whose worktree is back to HEAD: the staged change + -- is real, it just has no HEAD↔worktree diff to show + if entry.x ~= " " and entry.y ~= " " and raw_line(root, { "--cached" }, entry) then + return "Staged changes the worktree has put back" + end return nil end if (entry.status == "R" or entry.status == "C") and entry.previous_path then @@ -1035,6 +1048,12 @@ function M.discard(root, entry) ) return false end + -- restoring a staged deletion from HEAD would overwrite the copy still on disk + if entry.kept then + local msg = "discard skipped: %s is on disk untracked; u tracks it again" + notify(msg:format(entry.path), vim.log.levels.WARN) + return false + end local abs = root .. "/" .. entry.path local ok if entry.status == "?" then @@ -1219,34 +1238,24 @@ function M.panel(opts) end local branch = head_branch(root) -- once per source, for the buffer statuslines - -- model_for picks the (old, new) pair per entry: working-tree sections diff by - -- the entry's staged flag (staged = HEAD↔index, else index↔worktree), while a - -- rev-pair list diffs every entry against the one resolved source. `actions` - -- (file-level staging) is only meaningful for the worktree-status source + -- model_for picks the (old, new) pair per entry: a working-tree row diffs + -- HEAD↔worktree, which staging never moves, while a rev-pair list diffs every entry + -- against the one resolved source. a staged deletion diffs HEAD↔index, since its + -- worktree copy is a row of its own. `actions` (file-level staging) is only + -- meaningful for the worktree-status source local sections, model_for, raw_args_for, actions local list_err ---@type string|nil -- git's own words when the listing failed if is_worktree_status(source) then sections, list_err = M.status_sections(root) model_for = function(entry) - local s - if entry.partial then - s = { old = HEAD, new = WORKTREE } - elseif entry.staged then - s = { old = HEAD, new = INDEX } - else - s = { old = INDEX, new = WORKTREE } - end + local s = { old = HEAD, new = entry.x == "D" and INDEX or WORKTREE } -- re-read HEAD per build so a branch switch under an open panel updates -- the synthetic buffer's statusline label, not just the diff content return M.model(s, root, entry, head_branch(root)) end - -- the entry's own pair as `diff` args: staged is HEAD↔index, unstaged - -- index↔worktree + -- the entry's own pair as `diff` args raw_args_for = function(entry) - if entry.partial then - return { "HEAD" } - end - return entry.staged and { "--cached" } or {} + return entry.x == "D" and { "--cached" } or { "HEAD" } end actions = { stage = function(entry) @@ -1294,12 +1303,10 @@ function M.panel(opts) local view ---@type differ.View|nil -- the single diff view the panel drives local panel ---@type differ.Panel|nil -- forward ref so staging can refresh it local watcher ---@type differ.git.Watcher|nil -- fs watcher, set for worktree panels - local on_edit_unstage ---@type fun(path: string)|nil -- assigned below; passed to the view - local settle_pair ---@type fun(): boolean -- assigned below; the staging follow hook + local retarget_view ---@type fun(outside: boolean): boolean -- assigned below - -- hunk-level staging. content edits patch by hunk; anything with no lines to patch - -- sets `whole_file`. the view keeps its diff frozen, so `apply` patches one hunk: - -- forward stages, `reverse` unstages, `offset` shifts past the hunks staged before it + -- 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 active_entry ---@type differ.FileEntry|nil -- the file the view currently shows @@ -1339,57 +1346,19 @@ function M.panel(opts) record_state() end end - -- throw one hunk away rather than move it between index and worktree. an unstaged - -- hunk exists only in the worktree, so a single reverse apply is the whole job. a - -- staged one is in the index and, unless it was edited since, the worktree too, so - -- it takes both: index first, because a worktree copy that won't take the patch - -- then leaves the file merely unstaged (a state the panel already models) instead - -- of holding a change the index no longer has. returns whether the model's new - -- side moved, which is what tells the caller to re-source - ---@param model differ.DiffModel - ---@param hunk differ.Hunk - ---@param offset integer -- index-side only; see differ.view.Staging - ---@return boolean - local function revert_hunk(model, hunk, offset) - -- a revert patches whichever file the model's new side was read from (the - -- worktree, or the index on a staged diff), so it's in new-side coordinates - ---@param off integer - local function reverse_patch(off) - return patch.hunk(model.path, hunk, model.old_text, model.new_text, off, "new") - end - if model.new_rev ~= INDEX.label then - local ok, err = M.apply_patch(root, reverse_patch(0), true, "worktree") - if not ok then - notify(("hunk revert failed: %s"):format(err or ""), vim.log.levels.ERROR) - end - reload_buffer(root, model.path) - return ok - end - local ok, err = M.apply_patch(root, reverse_patch(offset), true, "index") - if not ok then - notify(("hunk revert failed: %s"):format(err or ""), vim.log.levels.ERROR) - return false - end - -- unstaging shifts the index but never the worktree, so the worktree's copy - -- still sits where the model says and this half needs no offset - if not M.apply_patch(root, reverse_patch(0), true, "worktree") then - local msg = - "%s: reverted from the index; the worktree copy changed since, so it stays unstaged" - notify(msg:format(model.path), vim.log.levels.WARN) - end - reload_buffer(root, model.path) - return true -- the index half landed either way - end - - -- bring a deleted file back. which side it comes from is fixed by which diff is on - -- screen: a staged deletion is recorded in the index, so only HEAD still has the - -- content; an unstaged one is still in the index, and that's what its diff compares - -- against, so restoring from HEAD instead would silently drop edits staged before - -- the delete + -- bring a deleted file back. a staged deletion is recorded in the index, so only + -- HEAD still has the content; an unstaged one is still in the index, so restoring + -- from HEAD instead would silently drop edits staged before the delete. a staged + -- deletion whose file is still on disk would have that copy overwritten, so it refuses ---@param entry differ.FileEntry ---@return boolean local function restore_deleted(entry) - local cmd = entry.staged and { "checkout", "HEAD", "--", entry.path } + if entry.kept then + local msg = "%s is on disk untracked; u tracks it again" + notify(msg:format(entry.path), vim.log.levels.WARN) + return false + end + local cmd = entry.x == "D" and { "checkout", "HEAD", "--", entry.path } or { "checkout", "--", entry.path } local _, err = git(cmd, root) if err then @@ -1438,77 +1407,150 @@ function M.panel(opts) return sel end - -- staging for a file shown as one HEAD↔worktree diff. both directions work out the - -- content the index should hold and write it whole, rather than patching: staging a - -- union hunk means the index takes the worktree's version of the lines it covers, - -- and unstaging means the index keeps every staged change except those. the two real - -- pairs are re-read per op, so nothing is frozen and no offset is carried + -- whether the index holds a mode change the HEAD↔worktree diff can't show: staged, + -- then put back in the worktree + ---@param entry differ.FileEntry + ---@return boolean + local function mode_hidden(entry) + local cached = raw_line(root, { "--cached" }, entry) + local unstaged = raw_line(root, {}, entry) + if not (cached and unstaged) then + return false + end + local head_mode, index_mode = rev.parse_raw_modes(cached) + local _, work_mode = rev.parse_raw_modes(unstaged) + return index_mode ~= head_mode and index_mode ~= work_mode + end + + -- staging for a row shown as HEAD↔worktree. both directions work out the content + -- the index should hold and write it whole rather than patching: staging a hunk + -- means the index takes the worktree's version of the lines it covers, and + -- unstaging means the index keeps every staged change except those. each op reads + -- the pairs once and re-marks from the text it wrote ---@param entry differ.FileEntry ---@return differ.view.Staging - local function partial_staging(entry) + local function union_staging(entry) local marks = require("differ.model.marks") local apply_text = require("differ.model.apply").partial - -- the view holds this table, so a re-classify writes through it rather than - -- replacing it. it keys on the union hunks read back each time rather than the - -- ones this source opened with: staging leaves HEAD and the worktree alone, so - -- those agree, but a revert rewrites the worktree and takes a hunk out, and the - -- marks have to follow the shape the view is left rendering - local live = {} - local function reclassify() - local union, cached, unstaged = M.union_models(root, entry.path) + local mode_why ---@type string|nil + if entry.x ~= " " and entry.y ~= " " and mode_hidden(entry) then + mode_why = "the index holds a mode change" + end + ---@type differ.view.Staging + local staging = { marks = { old = {}, new = {} }, refresh = refresh_panel } + + -- the view keeps staging.marks, so a re-mark writes through it + ---@param union differ.DiffModel + ---@param cached differ.DiffModel + ---@param unstaged differ.DiffModel + local function remark(union, cached, unstaged) local fresh = marks.classify(union.hunks, cached.hunks, unstaged.hunks) - live.old, live.new = fresh.old, fresh.new - return cached, unstaged + staging.marks.old, staging.marks.new = fresh.old, fresh.new + local complete, why = marks.complete(union.hunks, cached.hunks, unstaged.hunks) + if mode_why then + staging.hidden = mode_why + elseif complete then + staging.hidden = nil + else + staging.hidden = why + end end - reclassify() - return { - initial = "unstaged", - marks = live, - refresh = refresh_panel, - apply = function(_, hunk, _, reverse) - local cached, unstaged = reclassify() - local from, sel - if reverse then - local a, b = extent(hunk, "old") - from, sel = cached, select_hunks(cached.hunks, "old", a, b, false) - else - local a, b = extent(hunk, "new") - from, sel = unstaged, select_hunks(unstaged.hunks, "new", a, b, true) - end - local text, why = apply_text(from, sel) - if not text then - notify(("this hunk can't be staged: %s"):format(why), vim.log.levels.WARN) - return false - end - if not write_index(root, entry.path, text) then + + -- write `text` as the staged content. an add left with nothing staged leaves the + -- index, and a rename only the worktree has made is staged along with its + -- content; both change the row's status, so the view re-sources onto it + ---@param text string + ---@return boolean ok + local function put_index(text) + if entry.x == "A" and text == "" then + if not M.unstage(root, entry.path) then return false end - reclassify() -- the index moved; the marks describe where it is now + vim.schedule(function() + retarget_view(false) + end) return true - end, - -- X throws the hunk away on both sides at once: the index gives up whatever - -- of it it holds and the worktree gives up the rest. index first, so a - -- worktree copy that won't take the patch leaves the file merely unstaged - -- rather than holding a change the index no longer has. the worktree half - -- goes through a patch, not a write: the model's new side was read through - -- the clean filter, so writing it back would push that conversion to disk - revert = function(model, hunk) - local cached = select(2, M.union_models(root, entry.path)) - local a, b = extent(hunk, "old") - local text = apply_text(cached, select_hunks(cached.hunks, "old", a, b, false)) - if text and not write_index(root, entry.path, text) then + end + if not write_index(root, entry.path, text) then + return false + end + if entry.y == "R" and entry.previous_path then + local drop = { "update-index", "--force-remove", "--", entry.previous_path } + if not git_ok(drop, root, "staging " .. entry.path) then return false end - local p = patch.hunk(model.path, hunk, model.old_text, model.new_text, 0, "new") - local ok, err = M.apply_patch(root, p, true, "worktree") - if not ok then - notify(("hunk revert failed: %s"):format(err or ""), vim.log.levels.ERROR) - end - reload_buffer(root, entry.path) - reclassify() - return ok - end, - } + vim.schedule(function() + retarget_view(false) + end) + end + return true + end + + remark(M.union_models(root, entry)) + staging.apply = function(_, hunk, reverse) + if not hunk then + return false + end + local union, cached, unstaged = M.union_models(root, entry) + local from, sel + if reverse then + local a, b = extent(hunk, "old") + from, sel = cached, select_hunks(cached.hunks, "old", a, b, false) + else + local a, b = extent(hunk, "new") + from, sel = unstaged, select_hunks(unstaged.hunks, "new", a, b, true) + end + local text, why = apply_text(from, sel) + if not text then + notify(("this hunk can't be staged: %s"):format(why), vim.log.levels.WARN) + return false + end + if not put_index(text) then + return false + end + remark(union_pairs(root, entry.path, union.old_text, text, union.new_text)) + return true + end + -- X throws the hunk away on both sides at once: the index gives up whatever of + -- it it holds and the worktree gives up the rest. index first, so a worktree copy + -- that won't take the patch leaves the file merely unstaged rather than holding a + -- change the index no longer has. the worktree half goes through a patch, not a + -- write: the model's new side was read through the clean filter, so writing it + -- back would push that conversion to disk + staging.revert = function(model, idx) + local union, cached = M.union_models(root, entry) + local hunk = model.hunks[idx] + local a, b = extent(hunk, "old") + local text = apply_text(cached, select_hunks(cached.hunks, "old", a, b, false)) + if text and text ~= cached.new_text and not put_index(text) then + return false + end + local p = patch.hunk(model.path, hunk, model.old_text, model.new_text, 0, "new") + local ok, err = M.apply_patch(root, p, true, "worktree") + reload_buffer(root, entry.path) + if not ok then + notify(("hunk revert failed: %s"):format(err or ""), vim.log.levels.ERROR) + remark(M.union_models(root, entry)) + return false + end + local work = require("differ.model.diff").revert_hunk(model, idx).new_text + remark(union_pairs(root, entry.path, union.old_text, text or cached.new_text, work)) + return true + end + return staging + end + + -- a whole-file row's staged state, from its status letters + ---@param entry differ.FileEntry + ---@return differ.model.HunkState + local function row_state(entry) + if entry.x == " " or entry.x == "?" then + return "unstaged" + end + if entry.y == " " then + return "staged" + end + return "partial" end ---@param entry differ.FileEntry @@ -1518,65 +1560,39 @@ function M.panel(opts) if not stageable then return nil end - if entry.partial then - return partial_staging(entry) - end - -- settle_pair is assigned below stage_for, so the lookup has to defer to call time - local function settle() - return settle_pair() - end -- git records no rename, only the old path gone from the index and the new one - -- present, so a hunk patches an ordinary blob and the move rides along untouched. + -- present, so a hunk stages an ordinary blob and the move rides along untouched. -- the move is whole-file and belongs to the panel row's keys - local hunk_level = entry.status == "M" or entry.status == "R" or entry.status == "C" - if hunk_level and #diff.hunks > 0 then - return { - initial = entry.staged and "staged" or "unstaged", - apply = function(model, hunk, offset, reverse) - -- both directions patch the index, where the hunk sits at its - -- old-side start shifted past the hunks staged before it; `reverse` - -- only picks which way git reads the same patch - local p = - patch.hunk(model.path, hunk, model.old_text, model.new_text, offset, "old") - local ok, err = M.apply_patch(root, p, reverse) - if not ok then - local op = reverse and "unstage" or "stage" - notify(("hunk %s failed: %s"):format(op, err or ""), vim.log.levels.ERROR) - end - return ok - end, - revert = revert_hunk, - refresh = refresh_panel, - settle = settle, - } - end - -- shared by every whole-file status below. `git add` reads the file off disk, so - -- on a staged pair it also stages whatever the file's unstaged row holds - local function whole_file_apply(model, _, _, reverse) - if reverse then - return set_staged(root, entry, false) - end - local ok = set_staged(root, entry, true) - if ok and entry.staged and M.read(WORKTREE, root, entry.path) ~= model.new_text then - local msg = "%s is fully staged now, including the edits you had left unstaged" - notify(msg:format(entry.path), vim.log.levels.WARN) + local content = entry.status == "M" or entry.status == "R" or entry.status == "C" + local added = entry.status == "A" and entry.x == "A" + if (content or added) and entry.x ~= "D" and #diff.hunks > 0 then + local staging = union_staging(entry) + if added then + -- throwing away an add is deleting the file, not reverting a hunk of it + staging.revert = function() + return M.discard(root, entry) + end + staging.revert_label = "deletes the file" end - return ok + return staging + end + -- shared by every whole-file status below + local function whole_file_apply(_, _, reverse) + return set_staged(root, entry, not reverse) end - -- a modification with no hunks: a mode change, a submodule pointer, a binary - -- file, a change git normalises away - if entry.status == "M" then + -- no lines to stage: a mode change, a submodule pointer, a binary file, a change + -- git normalises away, a rename or copy with no content change + if content then return { - initial = entry.staged and "staged" or "unstaged", + initial = row_state(entry), whole_file = true, apply = whole_file_apply, refresh = refresh_panel, - settle = settle, } end if entry.status == "?" or entry.status == "A" then return { - initial = entry.staged and "staged" or "unstaged", + initial = row_state(entry), whole_file = true, apply = whole_file_apply, -- `discard` drops the staged add before removing the file @@ -1585,12 +1601,11 @@ function M.panel(opts) end, revert_label = "deletes the file", refresh = refresh_panel, - settle = settle, } end if entry.status == "D" then return { - initial = entry.staged and "staged" or "unstaged", + initial = row_state(entry), whole_file = true, apply = whole_file_apply, revert = function() @@ -1598,17 +1613,6 @@ function M.panel(opts) end, revert_label = "restores the file", refresh = refresh_panel, - settle = settle, - } - end - -- a rename or copy with no content to patch: the move is all there is - if entry.status == "R" or entry.status == "C" then - return { - initial = entry.staged and "staged" or "unstaged", - whole_file = true, - apply = whole_file_apply, - refresh = refresh_panel, - settle = settle, } end return nil @@ -1632,6 +1636,11 @@ function M.panel(opts) end model.notice = notice end + if entry.status == "U" then + model.banner = "conflicted: resolve with :Differ mergetool" + elseif entry.kept then + model.banner = "still on disk, untracked: u tracks it again" + end local staging = stage_for(entry, model) if view and view:is_open() then view:set_source(model, staging, source_opts) @@ -1639,7 +1648,6 @@ function M.panel(opts) view = require("differ").diff_model(model, { staging = staging, can_stage = stageable, - on_edit_unstage = on_edit_unstage, }) end active_entry = entry @@ -1650,8 +1658,8 @@ function M.panel(opts) return true end - -- the current changed-file entries for a path: a file can have a staged pair and - -- an unstaged pair, and an external op (lazygit) can empty the pair you were on + -- the current changed-file entries for a path: an external op (lazygit) can move it + -- to another section or out of the change set ---@param path string ---@return differ.FileEntry[] local function entries_for_path(path) @@ -1667,87 +1675,22 @@ function M.panel(opts) return out end - -- after staging from an unstaged diff: when that took the file's last unstaged hunk - -- there's nothing left to review on this pair, so follow it to the staged one rather - -- than leave the diff on a pair git no longer has. only this direction follows: - -- unstaging the last staged hunk stays put, since that's what lets s re-stage it in - -- place, and swapping there would ping-pong the view on every u/s. returns whether - -- the view was re-targeted, so the caller knows its repaint is moot - ---@return boolean - settle_pair = function() - if not (panel and panel:is_alive() and view and view:is_open() and active_entry) then - return false - end - if active_entry.staged then - return false -- on the staged pair: u/s round-trips in place - end - local survivor ---@type differ.FileEntry|nil - for _, e in ipairs(entries_for_path(active_entry.path)) do - if not e.staged then - return false -- unstaged changes remain; stay frozen on them - end - survivor = survivor or e - end - if not survivor then - return false -- the file left the change set; revert's own path handles that - end - -- hold the position the stage happened at. this is a pair swap, not a file - -- switch, so landing on the first hunk would lose the reviewer's place; the - -- unstaged pair just emptied, so index and worktree agree and the line carries - local focus_line, focus_col = view:cursor_new_line() - if - not show_entry( - survivor, - { focus_line = focus_line, focus_col = focus_col, keep_folds = true } - ) - then - return false - end - panel:mark_selected(survivor) - return true - end - - -- edit-in-review on a staged diff (flow C): unstage the whole file so the - -- staged change returns to the worktree, then re-source the diff to the file's now- - -- unstaged view so the edit lands somewhere the diff reflects. driven explicitly by - -- the view (not the watcher, whose re-source is suppressed by the staging signature) - ---@param path string - on_edit_unstage = function(path) - M.unstage(root, path) - refresh_panel() -- the file moves to the Unstaged section; records last_sig - for _, e in ipairs(entries_for_path(path)) do - if not e.staged then - show_entry(e, { keep_folds = true }) -- switch the diff to index↔worktree - return - end - end - end - - -- re-source the view onto the shown file's current changes, preferring the pair it - -- was on, then the other pair, then the nearest surviving change. `outside` announces - -- a landing on a different change + -- re-source the view onto the shown file's current row, else the nearest surviving + -- change. `outside` announces a landing on a different change ---@param outside boolean ---@return boolean sourced - local function retarget_view(outside) + retarget_view = function(outside) if not (view and view:is_open() and active_entry) then return false end -- hold the cursor near where it was rather than snapping to the top local focus_line, focus_col = view:cursor_new_line() - local candidates = entries_for_path(active_entry.path) - local pick = candidates[1] - for _, e in ipairs(candidates) do - if e.staged == active_entry.staged then - pick = e - break - end - end + local pick = entries_for_path(active_entry.path)[1] local was = active_entry local source_opts = { focus_line = focus_line, focus_col = focus_col, keep_folds = true } if pick and show_entry(pick, source_opts) then - if outside and (pick.staged ~= was.staged or pick.status ~= was.status) then - local msg = "%s changed outside differ; showing its %s change instead" - notify(msg:format(pick.path, pick.staged and "staged" or "unstaged")) + if outside and pick.status ~= was.status then + notify(("%s changed outside differ"):format(pick.path)) end return true end @@ -1868,12 +1811,9 @@ function M.panel(opts) panel.return_tab = return_tab if opts.open_first then -- land on the file (and line) :Differ was run from when it's in the change - -- set, else the first unstaged file (skipping the Staged section); leave the - -- cursor in the diff, not the panel. - -- a two-row file takes its unstaged row: origin_line is a worktree line, and - -- index↔worktree is the only pair of that one in the same coordinate space (a - -- one-row "MM" needs no preference, its union pair already ends at the worktree) - local on_origin = origin_rel and panel:focus_file(origin_rel, true) + -- set, else the first file with unstaged work (skipping the Staged section); + -- leave the cursor in the diff, not the panel + local on_origin = origin_rel and panel:focus_file(origin_rel) if not on_origin then panel:focus_first_unstaged() end diff --git a/lua/differ/init.lua b/lua/differ/init.lua index 342e3f7..184a6cb 100644 --- a/lua/differ/init.lua +++ b/lua/differ/init.lua @@ -80,7 +80,6 @@ end ---@field context? number|"full" ---@field staging? differ.view.Staging ---@field can_stage? boolean ----@field on_edit_unstage? fun(path: string) ---@field extra_keymaps? differ.panel.ExtraMap[] ---@field on_rerender? fun() ---@field on_cursor? fun() @@ -104,7 +103,6 @@ function M.diff_model(model, opts) keymaps = cfg.keymaps.diff --[[@as table]], staging = opts.staging, can_stage = opts.can_stage, - on_edit_unstage = opts.on_edit_unstage, extra_keymaps = opts.extra_keymaps, on_rerender = opts.on_rerender, on_cursor = opts.on_cursor, diff --git a/lua/differ/model/diff.lua b/lua/differ/model/diff.lua index f1d3c6a..a08423f 100644 --- a/lua/differ/model/diff.lua +++ b/lua/differ/model/diff.lua @@ -20,6 +20,7 @@ ---@field root string|nil -- repo root (absolute), so jump-to-file can resolve the real file (set by the frontend) ---@field binary boolean|nil -- either side is binary: no hunks, renderers show a placeholder ---@field notice string|nil -- why a zero-hunk diff has nothing to show; rendered in place of the diff +---@field banner string|nil -- a note the winbar carries over the diff local text_util = require("differ.util.text") local to_lines = text_util.to_lines diff --git a/lua/differ/model/marks.lua b/lua/differ/model/marks.lua index cf286e2..e100a66 100644 --- a/lua/differ/model/marks.lua +++ b/lua/differ/model/marks.lua @@ -10,7 +10,7 @@ local M = {} ---@class differ.model.Marks ---@field old table -- old lnum -> the index already dropped this line ---@field new table -- new lnum -> the index already holds this line ----@field whole boolean|nil -- a whole-file source: the file is staged as one unit +---@field whole differ.model.HunkState|nil -- a whole-file source: the file's state as one unit -- the lines a hunk list covers on one side, as a set ---@param hunks differ.Hunk[] @@ -99,8 +99,8 @@ end -- whether a HEAD↔worktree diff shows all of a file's half-staged content. it cannot -- show index content that differs from HEAD and worktree both: stage a change and then -- put the worktree back or edit it again, or let the two diffs align a run of repeated --- lines differently, and the change is real but off-screen. a false sends the file --- back to the two-pair view, where nothing is hidden +-- lines differently, and the change is real but off-screen. a false puts a notice on +-- the view ---@param union differ.Hunk[] ---@param cached differ.Hunk[] ---@param unstaged differ.Hunk[] diff --git a/lua/differ/panel/init.lua b/lua/differ/panel/init.lua index ee1c3f8..74527c0 100644 --- a/lua/differ/panel/init.lua +++ b/lua/differ/panel/init.lua @@ -18,16 +18,14 @@ local CTRL_U = vim.api.nvim_replace_termcodes("", true, false, true) ---@type differ.Panel|nil -- the live panel, for runtime API (Panel.current()) local current = nil --- a file entry's identity across a list rebuild. the path alone isn't enough: a file --- changed on both pairs holds a Staged and an Unstaged row at once, so the pair is --- part of what makes the row that row +-- a file entry's identity across a list rebuild: every path holds one row ---@param entry differ.FileEntry|nil ---@return string|nil local function entry_key(entry) if not entry then return nil end - return (entry.staged and "s\0" or "u\0") .. entry.path + return entry.path end -- a `(glyph, hl)` provider backed by nvim-web-devicons, or nil when it's absent @@ -100,7 +98,7 @@ local STATUS_HL = { ---@field content_width integer|nil -- column the list + pinned counts occupy; capped under window width for top/bottom ---@field lines string[] ---@field meta differ.panel.LineMeta[] ----@field file_total integer|nil -- distinct paths in the change set (fold-independent) +---@field file_total integer|nil -- total files in the change set (fold-independent) ---@field add_total integer|nil -- total additions across the change set (diff --stat, on the help line) ---@field del_total integer|nil -- total deletions across the change set ---@field augroup integer|nil -- autocmd group for the external-change refresh @@ -217,25 +215,17 @@ function Panel:focus_first_changed() end -- move the cursor to `path`'s file row if it's currently rendered, returning whether --- it was found; lets :Differ open on the current file rather than the first. --- a path can hold two rows (an "RM" file lists under Staged and Unstaged), and the --- Staged one comes first; `prefer_unstaged` takes the unstaged row instead, falling --- back to the staged one when that's the only pair +-- it was found; lets :Differ open on the current file rather than the first ---@param path string -- repo-relative ----@param prefer_unstaged? boolean ---@return boolean -function Panel:focus_file(path, prefer_unstaged) - local first, pick +function Panel:focus_file(path) + local pick for i, m in ipairs(self.meta) do if m.kind == "file" and m.entry.path == path then - first = first or i - if not (prefer_unstaged and m.entry.staged) then - pick = i - break - end + pick = i + break end end - pick = pick or first if not pick then return false end @@ -279,23 +269,15 @@ function Panel:render() local blocks = {} -- absolute file numbering across the whole change set, in display order and -- independent of which dirs are folded, so the section counts + winbar meter - -- stay accurate when collapsed (entry -> 1-based index). a path is counted once - -- however many rows carry it: a file changed on both sides (an "RM", renamed in the - -- index and edited again) lists under Staged and Unstaged both, and each row takes - -- the number of distinct paths at or before it, so the second row repeats the - -- first's number rather than adding one and the meter never steps backwards - local abs_of, seen, total = {}, {}, 0 - -- diff --stat totals, summed over every row (fold-independent, like the count): - -- a two-row file's pairs change different lines, so both counts stand + -- stay accurate when collapsed (entry -> 1-based index) + local abs_of, total = {}, 0 + -- diff --stat totals, summed over every file (fold-independent, like the count) local add_total, del_total = 0, 0 for bi, sec in ipairs(self.sections) do local root, strip = self:_section_root(sec) for _, row in ipairs(tree.rows(root, "tree", {})) do -- fully expanded if row.kind == "file" then - if not seen[row.entry.path] then - seen[row.entry.path] = true - total = total + 1 - end + total = total + 1 abs_of[row.entry] = total add_total = add_total + (row.entry.additions or 0) del_total = del_total + (row.entry.deletions or 0) @@ -352,18 +334,9 @@ function Panel:render() end self.content_width = width self.lines, self.meta = out.lines, out.meta - -- a path listed twice (a two-row file's Staged and Unstaged rows) marks all but its - -- last row superseded, so ]f / [f stop once per file and land on the pair with - -- work left. the review flow steps by staged state and still sees every row - local held = {} - for i, m in ipairs(self.meta) do + for _, m in ipairs(self.meta) do if m.kind == "file" then m.file_index = abs_of[m.entry] - local prev = held[m.entry.path] - if prev then - self.meta[prev].superseded = true - end - held[m.entry.path] = i end end -- rows shuffle on every rebuild (a fold toggle, a listing swap, an entry leaving @@ -848,28 +821,21 @@ end -- the next/prev file row from `lnum`. wraps past the ends by default so ]f / [f -- stepping is cyclic (you often open mid-list); `wrap == false` bounds it instead, -- returning nil at the first/last file so the staging review flow stops at the ends. --- `distinct` skips a row whose path is listed again further down, so a step visits a --- two-row file once, at its Unstaged row; the review flow leaves it off and walks pairs. --- nil when there are no file rows at all. the second return reports whether reaching --- it actually crossed an end, so goto_file can notify on a wrap +-- nil too when there are no file rows at all. the second return reports whether +-- reaching it actually crossed an end, so goto_file can notify on a wrap ---@param lnum integer ---@param direction "next"|"prev" ---@param wrap? boolean -- default true; false stops at the list ends ----@param distinct? boolean -- default false; true stops once per path ---@return integer|nil row, boolean wrapped -function Panel:_file_row(lnum, direction, wrap, distinct) +function Panel:_file_row(lnum, direction, wrap) local n = #self.meta if n == 0 then return nil, false end - local function lands(i) - local m = self.meta[i] - return m ~= nil and m.kind == "file" and not (distinct and m.superseded) - end local step = direction == "prev" and -1 or 1 local i = lnum + step while i >= 1 and i <= n do - if lands(i) then + if self.meta[i] and self.meta[i].kind == "file" then return i, false end i = i + step @@ -879,7 +845,8 @@ function Panel:_file_row(lnum, direction, wrap, distinct) end for k = 1, n do local j = ((lnum - 1 + step * k) % n) + 1 - if lands(j) then + local m = self.meta[j] + if m and m.kind == "file" then return j, true end end @@ -914,12 +881,12 @@ function Panel:open_nearest(keep_focus) return true end --- ]f / [f: move to the next/prev file and open it (lockstep file stepping), stopping --- once per path rather than once per row. wraps at the ends by default (notifying, --- since it's otherwise not obvious you cycled back rather than simply moved); --- `wrap == false` (the staging review flow) stops at them instead. `keep_focus` is --- threaded to `_open` so in-view stepping stays in the diff window. returns whether a --- file was actually opened (false at a no-wrap list end) +-- ]f / [f: move to the next/prev file row and open it (lockstep file stepping). +-- wraps at the ends by default (notifying, since it's otherwise not obvious you +-- cycled back rather than simply moved); `wrap == false` (the staging review flow) +-- stops at them instead. `keep_focus` is threaded to `_open` so in-view stepping +-- stays in the diff window. returns whether a file was actually opened (false at a +-- no-wrap list end) ---@param direction "next"|"prev" ---@param keep_focus boolean|nil ---@param wrap? boolean -- default true; false stops at the list ends @@ -930,7 +897,7 @@ function Panel:goto_file(direction, keep_focus, wrap) local from = self:is_open() and vim.api.nvim_win_get_cursor(self.winid)[1] or self.selected_row or self:_first_file_line() - local i, wrapped = self:_file_row(from, direction, wrap, true) + local i, wrapped = self:_file_row(from, direction, wrap) if not i then return false end @@ -965,6 +932,14 @@ function Panel:_edge_file_row(edge) return row end +-- whether a row has something left to stage: anything but a Staged row, and every row +-- of a source without staging +---@param e differ.FileEntry +---@return boolean +local function has_unstaged(e) + return e.y ~= " " +end + -- move the cursor to the first unstaged file row, skipping the Staged section so -- :Differ lands on the first thing left to review, falling back to the first -- first file when everything is staged. mirrors the per-file _first_review_line, which @@ -974,7 +949,7 @@ function Panel:focus_first_unstaged() local i = self:_file_row(0, "next", false) while i do local e = self.meta[i].entry - if e and not e.staged then + if e and has_unstaged(e) then pcall(vim.api.nvim_win_set_cursor, self.winid, { i, 0 }) return end @@ -984,15 +959,18 @@ function Panel:focus_first_unstaged() end -- whether a row has hunks left to stage (`staged` false) or unstage (true). a Partial --- row holds both +-- row holds both; a conflict holds neither, since the merge tool resolves it ---@param e differ.FileEntry ---@param staged boolean ---@return boolean local function has_review_work(e, staged) - if e.partial then - return true + if e.status == "U" then + return false + end + if staged then + return e.x ~= nil and e.x ~= " " and e.x ~= "?" end - return (e.staged or false) == staged + return has_unstaged(e) end -- the review flow's file step: the nearest row in `direction` with something left to do, diff --git a/lua/differ/panel/render.lua b/lua/differ/panel/render.lua index 7234766..354d0c1 100644 --- a/lua/differ/panel/render.lua +++ b/lua/differ/panel/render.lua @@ -20,8 +20,7 @@ local FOLD_OPEN, FOLD_CLOSED = "▾", "▸" ---@field collapsed boolean|nil ---@field title string|nil -- header rows: the section title ---@field depth integer|nil -- tree depth (dir + file rows), for parent lookup ----@field file_index integer|nil -- 1-based position among distinct paths (fold-independent); stamped by the panel ----@field superseded boolean|nil -- file rows: a later row lists the same path, so ]f / [f skip this one +---@field file_index integer|nil -- 1-based position among all files (fold-independent); stamped by the panel ---@field status_col integer|nil -- byte col of the status letter (file rows) ---@field name_col integer|nil -- byte col where the name starts ---@field icon_col integer|nil -- byte range of the devicon glyph (when shown) diff --git a/lua/differ/panel/tree.lua b/lua/differ/panel/tree.lua index aad95db..a0a3cc4 100644 --- a/lua/differ/panel/tree.lua +++ b/lua/differ/panel/tree.lua @@ -10,8 +10,9 @@ local M = {} ---@field status "A"|"M"|"D"|"R"|"C"|"U"|"?" ---@field additions integer ---@field deletions integer ----@field staged boolean|nil -- which local section it belongs to ----@field partial boolean|nil -- one row for a file half in the index; diffs HEAD↔worktree +---@field x string|nil -- worktree panels: git's index status letter +---@field y string|nil -- worktree panels: git's worktree status letter +---@field kept boolean|nil -- a staged deletion whose file is still on disk, untracked ---@field previous_path string|nil -- renames/copies ---@field viewed boolean|nil -- PR only diff --git a/lua/differ/ui/winbar.lua b/lua/differ/ui/winbar.lua index 7612428..aca1190 100644 --- a/lua/differ/ui/winbar.lua +++ b/lua/differ/ui/winbar.lua @@ -78,9 +78,15 @@ function M.diff() if k > 0 and view:_hunk_state(k) == "partial" then mixed = "%#differPanelContext#partial%* " end - return (" %s %%=%s%s%s hunk %d/%d "):format( + local note = view.model.banner + if view.staging and view.staging.hidden then + note = "staged content hidden" + end + local noted = note and ("%#WarningMsg#" .. esc(note) .. "%* ") or "" + return (" %s %%=%s%s%s%s hunk %d/%d "):format( esc(vim.fn.fnamemodify(view.model.path, ":t")), badge, + noted, mixed, HUNK_MARK, k, @@ -99,10 +105,9 @@ function M.panel() if not (panel and panel.winid == win) then return "" end - -- total = every distinct path in the change set (fold-independent), not just the - -- rows currently visible; idx = the fold-independent number of the file at/before - -- the cursor, so the meter stays accurate when dirs are collapsed. a file listed - -- under two sections holds two rows and one number, so the total counts it once + -- total = every file in the change set (fold-independent), not just the rows + -- currently visible; idx = the fold-independent number of the file at/before the + -- cursor, so the meter stays accurate when dirs are collapsed local total = panel.file_total or 0 if total == 0 then return "" diff --git a/lua/differ/view.lua b/lua/differ/view.lua index 19b61b4..c8fa9af 100644 --- a/lua/differ/view.lua +++ b/lua/differ/view.lua @@ -80,17 +80,17 @@ local armed_view = nil -- whole content is one hunk, where reverting isn't a partial act: the frontend knows -- what it will do to the file, the view words the question ---@class differ.view.Staging ----@field initial "staged"|"unstaged" +---@field initial? differ.model.HunkState -- a source without marks: where it starts -- `whole_file` stages as a unit: the keys act on the file, and its diff shares one -- staged-state slot ---@field whole_file? boolean -- `apply` takes a nil hunk on a whole-file source, which ignores it ----@field apply? fun(model: differ.DiffModel, hunk: differ.Hunk|nil, offset: integer, reverse: boolean): boolean ----@field revert? fun(model: differ.DiffModel, hunk: differ.Hunk, offset: integer): boolean +---@field apply? fun(model: differ.DiffModel, hunk: differ.Hunk|nil, reverse: boolean): boolean +---@field revert? fun(model: differ.DiffModel, idx: integer): boolean ---@field revert_label? string -- e.g. "deletes the file" ---@field refresh fun() ----@field settle? fun(): boolean -- post-op: re-target the view when this pair emptied ----@field marks? differ.model.Marks -- union source: which lines the index already holds +---@field marks? differ.model.Marks -- union source: which lines the index holds, kept current by the source +---@field hidden? string -- union source: why some staged content isn't on screen ---@class differ.View ---@field columns differ.ViewColumn[] @@ -106,7 +106,6 @@ local armed_view = nil ---@field staging differ.view.Staging|nil -- per-source capability (nil off-side) ---@field fold_memory table -- diff_key -> its open folds when last left ---@field marks differ.model.Marks -- which lines the index holds; hunk state rolls up from it ----@field on_edit_unstage fun(path: string)|nil -- frontend hook: unstage + re-source for edit-in-review ---@field extra_keymaps differ.panel.ExtraMap[]|nil -- session-supplied buffer maps (pr unviewed nav) ---@field on_rerender fun()|nil -- session hook after a re-render, to re-apply overlays (pr threads) ---@field on_cursor fun()|nil -- session hook on cursor move in a diff window (pr thread cursor-expand) @@ -132,7 +131,6 @@ View.__index = View ---@field keymaps? table ---@field staging? differ.view.Staging ---@field can_stage? boolean ----@field on_edit_unstage? fun(path: string) ---@field extra_keymaps? differ.panel.ExtraMap[] ---@field on_rerender? fun() ---@field on_cursor? fun() @@ -162,7 +160,6 @@ function View.new(model, opts) ), can_stage = opts.can_stage or false, staging = opts.staging, - on_edit_unstage = opts.on_edit_unstage, -- session-supplied maps the generic diff surface doesn't own (pr unviewed nav) extra_keymaps = opts.extra_keymaps, on_rerender = opts.on_rerender, @@ -180,8 +177,7 @@ function View.new(model, opts) end -- seed staged state for the current source. a union source (HEAD↔worktree) hands over --- its marks and keeps them current itself; otherwise a staged diff (HEAD↔index) opens --- with every line staged and an unstaged diff (index↔worktree) with none +-- its marks and keeps them current itself; any other starts from `initial` function View:_init_staged() local staging = self.staging if staging and staging.marks then @@ -189,11 +185,14 @@ function View:_init_staged() return end self.marks = { old = {}, new = {} } - if not (staging and staging.initial == "staged") then + if not staging then return end if self:_whole_file() then - self.marks.whole = true + self.marks.whole = staging.initial + return + end + if staging.initial ~= "staged" then return end for _, h in ipairs(self.model.hunks) do @@ -465,22 +464,6 @@ function View:_paint_cursorline() }) end --- the net line-count delta of the staged hunks before `idx`: the frozen view's --- line numbers are from open time, but git applies against the live index, where --- each already-staged earlier hunk has shifted positions by its added/removed lines ----@param idx integer ----@return integer -function View:_stage_offset(idx) - local off = 0 - for j = 1, idx - 1 do - if self:_hunk_state(j) == "staged" then - local h = self.model.hunks[j] - off = off + (h.new_count - h.old_count) - end - end - return off -end - -- open folds by the rail lines at their ends, with the texts those line numbers refer to ---@class differ.view.OpenedFolds ---@field old_text? string @@ -526,7 +509,7 @@ end ---@param opened differ.view.OpenedFolds function View:_apply_folds(opened) local side = anchor_side(opened, self.model) - for _, col in ipairs(self.columns) do + for ci, col in ipairs(self.columns) do local win = col.winid if win and vim.api.nvim_win_is_valid(win) then set_wo(win, "foldmethod", "manual") @@ -1164,7 +1147,7 @@ function View:_line_staged(line) return false end if self:_whole_file() then - return self.marks.whole or false + return self.marks.whole == "staged" end if line.kind == "old" then return self.marks.old[line.old] or false @@ -1182,7 +1165,7 @@ end ---@return differ.model.HunkState function View:_hunk_state(idx) if self:_whole_file() then - return self.marks.whole and "staged" or "unstaged" + return self.marks.whole or "unstaged" end local h = self.model.hunks[idx] if not h then @@ -1201,24 +1184,24 @@ function View:_target_index() return self:_hunk_index_under_cursor() end --- patch hunk `idx` to `want_staged` in the index from the frozen hunk model (never --- buffer text), shifted past the hunks staged before it, and mark it. no panel --- refresh / repaint, so callers can batch. returns whether it changed +-- move hunk `idx` to `want_staged` in the index from the hunk model (never buffer +-- text), and mark it. no panel refresh / repaint, so callers can batch. returns +-- whether it changed ---@param idx integer ---@param want_staged boolean ---@return boolean function View:_apply_hunk(idx, want_staged) local apply = self.staging and self.staging.apply - if not apply or self:_hunk_state(idx) == (want_staged and "staged" or "unstaged") then + local want = want_staged and "staged" or "unstaged" + if not apply or self:_hunk_state(idx) == want then return false end - local offset = self:_stage_offset(idx) - -- reverse unstages: we patch away a change currently in the index - if not apply(self.model, self.model.hunks[idx], offset, not want_staged) then + -- reverse unstages: the change leaves the index + if not apply(self.model, self.model.hunks[idx], not want_staged) then return false end if self:_whole_file() then - self.marks.whole = want_staged + self.marks.whole = want elseif not self.staging.marks then -- a union source has re-read its own self:_mark_hunk(self.model.hunks[idx], want_staged) end @@ -1341,11 +1324,6 @@ function View:_toggle_hunk(want_staged) end if self:_apply_hunk(idx, want_staged) then self.staging.refresh() - -- that may have taken this pair's last hunk, in which case the session re-sources - -- the view onto the file's surviving pair and there's nothing here left to paint - if self.staging.settle and self.staging.settle() then - return - end self:_paint_staged() self:_paint_cursorline() -- re-lift the cursor tint above the fresh staged fill end @@ -1375,10 +1353,9 @@ function View:unstage_all() end end --- stage / unstage every hunk. forward order keeps the running offset correct --- as the index shifts under each apply; the panel refreshes once after the batch. --- returns whether anything changed (false when already wholly in the target state), --- so S/U can fall through to file stepping +-- stage / unstage every hunk; the panel refreshes once after the batch. returns +-- whether anything changed (false when already wholly in the target state), so S/U +-- can fall through to file stepping ---@param want_staged boolean ---@return boolean changed function View:_toggle_all(want_staged) @@ -1394,65 +1371,12 @@ function View:_toggle_all(want_staged) end if changed then self.staging.refresh() - -- S/U empty a pair outright, so this is where the follow usually fires - if not (self.staging.settle and self.staging.settle()) then - self:_paint_staged() - self:_paint_cursorline() -- re-lift the cursor tint above the fresh staged fill - end + self:_paint_staged() + self:_paint_cursorline() -- re-lift the cursor tint above the fresh staged fill end return changed end --- the index-side shift for a revert on a staged diff: unstaging a hunk swaps its new --- lines back for its old ones, so every hunk after it sits that many lines off the --- frozen model. only the index moves under an unstage, so this never applies to the --- worktree half of a revert ----@param idx integer ----@return integer -function View:_unstage_offset(idx) - local off = 0 - for j = 1, idx - 1 do - if self:_hunk_state(j) ~= "staged" then - local h = self.model.hunks[j] - off = off + (h.old_count - h.new_count) - end - end - return off -end - --- carry the marks past a hunk that has just left the model. a revert rebuilds only the --- new side, so old lines keep their numbers and new lines past the hunk shift by what --- it took out. the rebuilt list normally loses exactly the reverted hunk; if it didn't, --- the marks can't be mapped, so reseed them from the source instead of carrying wrong --- ones. a union source re-reads its own marks, so there is nothing to carry ----@param h differ.Hunk -- the reverted hunk ----@param before integer -- hunk count before the revert -function View:_rekey_staged(h, before) - if self.staging.marks then - return - end - if #self.model.hunks ~= before - 1 then - return self:_init_staged() - end - local old, new = {}, {} - for l, staged in pairs(self.marks.old) do - if l < h.old_start or l >= h.old_start + h.old_count then - old[l] = staged - end - end - -- a zero-count hunk sits after new_start rather than covering it - local first = h.new_count > 0 and h.new_start or h.new_start + 1 - local last = first + h.new_count - 1 - for l, staged in pairs(self.marks.new) do - if l < first then - new[l] = staged - elseif l > last then - new[l + h.old_count - h.new_count] = staged - end - end - self.marks = { old = old, new = new, whole = self.marks.whole } -end - -- the new-side line to land on once `h` is reverted: the cursor's own line, shifted by -- whatever the revert added or removed above it. a cursor inside the reverted region -- has no line of its own to return to, so it lands at the region's start @@ -1509,17 +1433,6 @@ function View:revert_hunk() if not idx then return vim.notify("differ: no hunk under the cursor", vim.log.levels.WARN) end - -- on an unstaged diff a marked hunk is already in the index, so reverting only the - -- worktree copy would leave the two differing the opposite way round. a union source - -- reverts both sides in one go, so it has no such half state to fall into - local union = self.staging.marks ~= nil - if self.model.new_rev == "WORKTREE" and not union and self:_hunk_state(idx) ~= "unstaged" then - return vim.notify( - "differ: hunk is staged; unstage it (u) before reverting", - vim.log.levels.WARN - ) - end - -- a whole-file revert isn't partial, so it says so rather than counting hunks local label = self.staging.revert_label local prompt = label and ("Revert all of %s? This %s."):format(self.model.path, label) @@ -1539,14 +1452,12 @@ function View:revert_hunk() ) end - local offset = self.model.new_rev == "INDEX" and self:_unstage_offset(idx) or 0 -- a whole-file revert, or one taking the file's last hunk, leaves no diff at all local emptied = label ~= nil or #self.model.hunks == 1 local hunk = self.model.hunks[idx] -- captured before the model is replaced - local before = #self.model.hunks local focus = self:cursor_new_line() - if not revert(self.model, hunk, offset) then + if not revert(self.model, idx) then return end @@ -1566,7 +1477,6 @@ function View:revert_hunk() local opened = self:_opened_folds() -- before rerender replaces col.folds self.model = require("differ.model.diff").revert_hunk(self.model, idx) - self:_rekey_staged(hunk, before) self:rerender({ layout = self.layout, context = self.context, deep_diff = self.deep_diff }) self:_apply_folds(opened) -- stay where the reverted hunk was rather than being pulled to the next one @@ -1658,10 +1568,8 @@ end -- jump_to_file this never tears down the diff: you edit, `:w`, and the worktree -- watcher re-sources the diff in place (cursor held near its hunk). the projection -- buffer and line map are untouched (invariant 2); you edit the real file's own --- buffer, so LSP / treesitter / undo all work natively. a staged diff (index↔ side) --- can't be edited in place (you can't edit the index), so the file is unstaged first: --- the staged change returns to the worktree and the watcher re-sources to the now- --- unstaged diff, where the edit shows. git-correct; re-stage (s) when done +-- buffer, so LSP / treesitter / undo all work natively. a file not on disk (a +-- deletion) has nothing to edit, and _edit_target says so function View:edit_file() if not self:_editable_source() then return vim.notify( @@ -1673,19 +1581,6 @@ function View:edit_file() if not t then return end - - -- staged diff: unstage the file and re-source to its unstaged index↔worktree view - -- so the edit lands on a diff that reflects it. driven explicitly (the watcher's - -- re-source is suppressed by the staging signature); falls back to an in-place - -- unstage if no frontend hook is wired - if self.model.new_rev == "INDEX" then - if self.on_edit_unstage then - self.on_edit_unstage(self.model.path) - elseif self:_can_stage_hunk() then - self:_toggle_all(false) - end - end - self:_open_edit_window(t.abs, t.target, t.tcol, t.anchor_win) end diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index 4998c77..fa5acdc 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -510,9 +510,56 @@ describe("git.status_sections", function() end end assert.are.same({ - { path = "multi.lua", status = "?", additions = 3, deletions = 0, staged = false }, + { path = "multi.lua", status = "?", additions = 3, deletions = 0, x = "?", y = "?" }, }, untracked) end) + + -- every row as { section, path, status, kept } + local function rows_of(root) + local out = {} + for _, sec in ipairs(git_src.status_sections(root)) do + for _, e in ipairs(sec.entries) do + out[#out + 1] = { sec.title, e.path, e.status, e.kept or false } + end + end + return out + end + + -- `git rm --cached` leaves the file on disk, so porcelain lists the path twice + it("folds a kept deletion's copy on disk into its one Staged row", function() + local root = fresh_repo() + git(root, "rm", "-q", "--cached", "a.lua") + assert.are.same({ { "Staged", "a.lua", "D", true } }, rows_of(root)) + end) + + it("refuses to discard a kept deletion over its copy on disk", function() + local root = fresh_repo() + git(root, "rm", "-q", "--cached", "a.lua") + write(root .. "/a.lua", "mine\n") + local entry + for _, sec in ipairs(git_src.status_sections(root)) do + entry = entry or sec.entries[1] + end + assert.is_false(git_src.discard(root, entry)) + assert.are.equal("mine\n", table.concat(vim.fn.readfile(root .. "/a.lua"), "\n") .. "\n") + end) + + it("lists a conflict once, in a Conflicts section ahead of the rest", function() + local root = fresh_repo() + git(root, "checkout", "-q", "-b", "side") + write(root .. "/a.lua", "side\n") + git(root, "commit", "-q", "-am", "side") + git(root, "checkout", "-q", "main") + write(root .. "/a.lua", "main\n") + git(root, "commit", "-q", "-am", "main") + vim.system({ "git", "merge", "-q", "side" }, { cwd = root }):wait() -- conflicts + write(root .. "/b.lua", "b\n") + + assert.are.same({ + { "Conflicts", "a.lua", "U", false }, + { "Untracked", "b.lua", "?", false }, + }, rows_of(root)) + end) end) describe("git.open_file", function() @@ -545,7 +592,7 @@ describe(":Differ panel", function() if m.kind == "file" and m.entry.path == path - and (staged == nil or m.entry.staged == staged) + and (staged == nil or (m.entry.y == " ") == staged) then return i end @@ -1067,7 +1114,7 @@ describe(":Differ panel", function() for _, m in ipairs(p.meta) do if m.kind == "file" and m.entry.path == "a.lua" then rows = rows + 1 - assert.is_true(m.entry.partial) + assert.are.equal("MM", m.entry.x .. m.entry.y) end end assert.are.equal(1, rows) @@ -1092,7 +1139,7 @@ describe(":Differ panel staging (slice C)", function() if m.kind == "file" and m.entry.path == path - and (staged == nil or m.entry.staged == staged) + and (staged == nil or (m.entry.y == " ") == staged) then return m.entry end @@ -1103,7 +1150,7 @@ describe(":Differ panel staging (slice C)", function() if m.kind == "file" and m.entry.path == path - and (staged == nil or m.entry.staged == staged) + and (staged == nil or (m.entry.y == " ") == staged) then return i end @@ -1393,9 +1440,8 @@ describe(":Differ diff hunk staging", function() return git(root, "show", "HEAD:" .. path) end -- an "RM": a.lua renamed to b.lua and edited in the index, then edited again in the - -- worktree. the union view only takes "MM", so this is the shape that still lists a - -- row per pair, which is what the pair-following flow needs to be exercised against - ---@return string path -- the new name, which both rows carry + -- worktree + ---@return string path -- the new name local function rename_and_edit(root, staged_text, work_text) git(root, "mv", "a.lua", "b.lua") write(root .. "/b.lua", staged_text) @@ -1429,7 +1475,7 @@ describe(":Differ diff hunk staging", function() git_src.panel({ rev = {}, open_first = true }) local p = Panel.current() local v = view_in_origin(p) - assert.are.equal("unstaged", v.staging.initial) -- index↔worktree opens unstaged + assert.are.equal("unstaged", v:_hunk_state(1)) -- index↔worktree opens unstaged assert.are.equal(2, #v.model.hunks) -- two distinct hunks (lines 1 and 8) local before = vim.api.nvim_buf_get_lines(v.columns[1].bufnr, 0, -1, false) @@ -1495,7 +1541,7 @@ describe(":Differ diff hunk staging", function() git_src.panel({ rev = {}, open_first = true }) local p = Panel.current() local v = view_in_origin(p) - assert.are.equal("staged", v.staging.initial) -- a staged (HEAD↔index) diff + assert.are.equal("staged", v:_hunk_state(1)) -- a staged (HEAD↔index) diff assert.are.equal("staged", v:_hunk_state(1)) -- opens marked staged assert.are.equal("local x = 2\nreturn x\n", indexed(root, "a.lua")) @@ -1552,7 +1598,7 @@ describe(":Differ diff hunk staging", function() local p = Panel.current() local v = view_in_origin(p) assert.is_not_nil(v.staging) -- a new file now offers (whole-file) staging - assert.are.equal("unstaged", v.staging.initial) + assert.are.equal("unstaged", v:_hunk_state(1)) assert.are.equal(1, #v.model.hunks) -- empty<->content is a single hunk vim.api.nvim_win_set_cursor(p.origin_win, { 1, 0 }) @@ -1595,25 +1641,20 @@ describe(":Differ diff hunk staging", function() p:close() end) - -- unstaging a whole-file change splits it off the pair the review was on, and the - -- re-source then has only the other pair to land on. `s` there acts on that entry - -- instead, so the swap can't be silent - it("says when an external change re-sources the diff onto the other pair", function() + -- a change made outside differ can turn the shown file into a different kind of + -- change, and the keys then act on that one instead, so the swap can't be silent + it("says when an external change re-sources the diff onto another status", function() local root = fresh_repo() - write(root .. "/new.lua", "one\ntwo\n") - git(root, "add", "new.lua") -- a staged add: the only pair this file has - vim.cmd.edit(root .. "/new.lua") + write(root .. "/a.lua", "local x = 2\nreturn x\n") + vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {} }) + git_src.panel({ rev = {}, open_first = true }) local p = Panel.current() - assert.is_true(p:focus_file("new.lua")) - p:select(true) - local v = view_in_origin(p) - assert.are.equal("INDEX", v.model.new_rev) -- HEAD↔index, the staged pair - v:unstage_hunk() -- the staged pair is gone; the file is untracked now + assert.are.equal("M", p:current_entry().status) _G.notifs = {} - write(root .. "/other.lua", "unrelated\n") -- something else moves the signature + os.remove(root .. "/a.lua") -- a modification becomes a deletion + write(root .. "/other.lua", "unrelated\n") git(root, "add", "other.lua") p:reload() @@ -1624,7 +1665,7 @@ describe(":Differ diff hunk staging", function() end end assert.is_truthy(msg) - assert.is_truthy(msg:find("new.lua", 1, true)) + assert.is_truthy(msg:find("a.lua", 1, true)) p:close() end) @@ -1648,9 +1689,8 @@ describe(":Differ diff hunk staging", function() p:close() end) - -- the panel's own staging keys move the pair the diff was built from, so the window - -- has to follow. the diff window's own s/u keep their frozen model instead, which is - -- what lets the staged marks survive a stage + -- the panel's own staging keys move the index under the open diff, so the window + -- re-sources to pick up the marks it("re-sources the open diff when the panel's own keys stage the file", function() local root = fresh_repo() write(root .. "/a.lua", "local x = 2\nreturn x\n") @@ -1658,12 +1698,14 @@ describe(":Differ diff hunk staging", function() git_src.panel({ rev = {}, open_first = true }) local p = Panel.current() - assert.are.equal("WORKTREE", view_in_origin(p).model.new_rev) -- the unstaged pair + assert.are.equal("unstaged", view_in_origin(p):_hunk_state(1)) _G.notifs = {} p:stage_op("stage_all") -- S on the panel row, not in the diff - assert.are.equal("INDEX", view_in_origin(p).model.new_rev) -- followed to HEAD↔index + local v = view_in_origin(p) + assert.are.equal("WORKTREE", v.model.new_rev) -- still HEAD↔worktree + assert.are.equal("staged", v:_hunk_state(1)) -- differ's own doing, so it isn't reported the way an outside change is for _, n in ipairs(_G.notifs) do assert.is_nil(tostring(n.msg):find("changed outside differ", 1, true)) @@ -1751,9 +1793,9 @@ describe(":Differ diff hunk staging", function() p:close() end) - -- preferring the unstaged row only means preferring it: a file staged outright has - -- no unstaged side to land on, so the staged row is the one it has - it("falls back to the staged row for a fully staged origin file", function() + -- a file staged outright has a worktree equal to its index, so its HEAD↔worktree + -- diff is the staged change, every hunk marked + it("opens a fully staged origin file with its hunks marked staged", function() local root = fresh_repo() write(root .. "/a.lua", "local x = 2\nreturn x\n") git(root, "add", "a.lua") -- staged, with nothing left in the worktree @@ -1763,14 +1805,14 @@ describe(":Differ diff hunk staging", function() local p = Panel.current() local v = view_in_origin(p) assert.are.equal("a.lua", v.model.path) - assert.are.equal("staged", v.staging.initial) - assert.are.equal(V1, v.model.old_text) -- HEAD↔index, the only pair it has + assert.are.equal("staged", v:_hunk_state(1)) + assert.are.equal(V1, v.model.old_text) assert.are.equal(indexed(root, "a.lua"), v.model.new_text) p:close() end) - -- staging a file's last unstaged hunk drops its Unstaged row, so every row below - -- slides up one. restoring the panel cursor by line number then lands it on a + -- staging a file's last unstaged hunk moves its row from Partial to Staged, so the + -- rows shift. restoring the panel cursor by line number then lands it on a -- different file, and ]f / [f step from there it("keeps the panel cursor on its file when the row leaves the section", function() local root = fresh_repo() @@ -1789,10 +1831,11 @@ describe(":Differ diff hunk staging", function() git_src.panel({ rev = {}, open_first = true }) local p = Panel.current() local v = view_in_origin(p) - assert.are.equal("unstaged", v.staging.initial) -- a.lua's index↔worktree side + assert.are.equal("staged", v:_hunk_state(1)) + assert.are.equal("unstaged", v:_hunk_state(2)) local before = p:current_entry() assert.are.equal("a.lua", before.path) - assert.is_falsy(before.staged) + assert.are.equal("M", before.y) -- the open landed on the line-8 hunk already; stage it, emptying the unstaged side vim.api.nvim_set_current_win(p.origin_win) @@ -1805,17 +1848,15 @@ describe(":Differ diff hunk staging", function() -- than staying on the line z.lua just slid into local after = p:current_entry() assert.are.equal("a.lua", after.path) - assert.is_true(after.staged) + assert.are.equal(" ", after.y) -- and the selection ]f / [f step from tracks it too assert.are.equal("a.lua", p.meta[p.selected_row].entry.path) p:close() end) - -- staging a file's last unstaged hunk leaves the index↔worktree pair with nothing in - -- it, so the view follows the file to its staged pair rather than sitting on a diff - -- git no longer has. the reviewer's line comes along: it's a side swap, not a file - -- switch, and the two sides agree on line numbers now the unstaged one is empty - it("follows to the staged side when the last unstaged hunk is staged", function() + -- staging a file's last unstaged hunk moves only the marks: HEAD↔worktree doesn't + -- change under staging, so the diff and the reviewer's line stay put + it("stays on the same diff when the last unstaged hunk is staged", function() local root = fresh_repo() write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") git(root, "commit", "-q", "-am", "ten lines") @@ -1831,8 +1872,10 @@ describe(":Differ diff hunk staging", function() git_src.panel({ rev = {}, open_first = true }) local p = Panel.current() local v = view_in_origin(p) - assert.are.equal("unstaged", v.staging.initial) - assert.are.equal(1, #v.model.hunks) -- just the line-10 change + assert.are.equal(2, #v.model.hunks) -- the staged line-1 change and the line-10 one + assert.are.equal("staged", v:_hunk_state(1)) + assert.are.equal("unstaged", v:_hunk_state(2)) + local model = v.model vim.api.nvim_set_current_win(p.origin_win) local real_notify, said = vim.notify, {} @@ -1843,13 +1886,10 @@ describe(":Differ diff hunk staging", function() vim.notify = real_notify assert.is_true(ok, tostring(err)) - -- silently: this fires at the end of every file staged hunk by hunk, so a - -- message here would land on each one assert.are.same({}, said) - -- the staged pair, carrying both edits, with every hunk marked staged - assert.are.equal("staged", v.staging.initial) - assert.are.equal(2, #v.model.hunks) -- the line-1 and line-10 changes together + -- the same diff, now with every hunk marked staged + assert.are.equal(model, v.model) assert.are.equal("staged", v:_hunk_state(1)) assert.are.equal("staged", v:_hunk_state(2)) @@ -2004,6 +2044,113 @@ describe(":Differ diff hunk staging", function() p:close() end) + -- the winbar text over the diff column of `v` + local function winbar_of(v) + vim.g.statusline_winid = v.columns[#v.columns].winid + local text = require("differ.ui.winbar").diff() + vim.g.statusline_winid = nil + return text + end + + -- the staged line edited again: the index's version reaches neither side of the diff + it("says in the winbar when staged content isn't on screen", function() + local root = fresh_repo() + write(root .. "/a.lua", "local x = 2\nreturn x\n") + git(root, "add", "a.lua") + write(root .. "/a.lua", "local x = 3\nreturn x\n") + vim.cmd.edit(root .. "/a.lua") + + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + local text = winbar_of(view_in_origin(p)) + p:close() + assert.is_truthy(text:find("staged content hidden", 1, true)) + end) + + it("says in the winbar when a staged mode change isn't on screen", function() + local root = fresh_repo() + vim.fn.setfperm(root .. "/a.lua", "rwxr-xr-x") + git(root, "add", "a.lua") -- the index holds +x + write(root .. "/a.lua", "local x = 2\nreturn x\n") + vim.fn.setfperm(root .. "/a.lua", "rw-r--r--") -- the worktree is back to -x + vim.cmd.edit(root .. "/a.lua") + + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + local text = winbar_of(view_in_origin(p)) + p:close() + assert.is_truthy(text:find("staged content hidden", 1, true)) + end) + + it("names a kept deletion's copy on disk in the winbar", function() + local root = fresh_repo() + git(root, "rm", "-q", "--cached", "a.lua") + write(root .. "/b.lua", "b\n") + vim.cmd.edit(root .. "/b.lua") + + git_src.panel({ rev = {} }) + local p = Panel.current() + assert.is_true(p:focus_file("a.lua")) + p:select(true) + local v = view_in_origin(p) + local new_rev, text = v.model.new_rev, winbar_of(v) + p:close() + assert.are.equal("INDEX", new_rev) -- the removal, not the copy on disk + assert.is_truthy(text:find("still on disk, untracked", 1, true)) + end) + + -- git reset on an added file drops it from the index, and u taking the last staged + -- line of one does the same rather than staging an empty file + it("drops an added file from the index once nothing of it is staged", function() + local root = fresh_repo() + write(root .. "/new.lua", "one\ntwo\n") + git(root, "add", "new.lua") + write(root .. "/new.lua", "one\ntwo\nthree\n") -- AM + vim.cmd.edit(root .. "/new.lua") + + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + local v = view_in_origin(p) + local state = v:_hunk_state(1) + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + v:unstage_hunk() + p:close() + assert.are.equal("partial", state) + assert.are.equal("?? new.lua\n", git(root, "status", "--porcelain=v1", "--", "new.lua")) + end) + + -- a rename only the worktree has made (`git add -N`) keeps the old path in the + -- index, so staging content from it has to stage the move too + it("stages a worktree-only rename along with the content staged from it", function() + local root = fresh_repo() + local lines = {} + for n = 1, 10 do + lines[n] = "line " .. n + end + write(root .. "/a.lua", table.concat(lines, "\n") .. "\n") + git(root, "commit", "-q", "-am", "ten lines") + os.rename(root .. "/a.lua", root .. "/b.lua") + lines[1], lines[10] = "line 1 edited", "line 10 edited" + write(root .. "/b.lua", table.concat(lines, "\n") .. "\n") + git(root, "add", "-N", "b.lua") -- " R a.lua -> b.lua" + vim.cmd.edit(root .. "/b.lua") + + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + local v = view_in_origin(p) + local hunks = #v.model.hunks + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + v:stage_hunk() + p:close() + assert.are.equal(2, hunks) -- read against HEAD's a.lua, not a whole-file add + local status = git(root, "status", "--porcelain=v1") + assert.are.equal("RM a.lua -> b.lua\n", status) + end) + -- the shading says which lines the index holds; the winbar says the hunk as a whole -- is mixed, which is what tells you s and u both still have work on it it("names a partial hunk in the diff winbar", function() @@ -2067,9 +2214,9 @@ describe(":Differ diff hunk staging", function() p:close() end) - -- only the unstaged side follows. emptying the staged one stays put, which is what - -- lets s put the hunk straight back where it was rather than ping-ponging the view - it("stays on the staged pair when its last staged hunk is unstaged", function() + -- unstaging a fully staged file's last hunk leaves the diff where it is, which is + -- what lets s put the hunk straight back + it("unstages and re-stages a fully staged file in place", function() local root = fresh_repo() write(root .. "/a.lua", "local x = 2\nreturn x\n") git(root, "add", "a.lua") -- staged outright: the diff opens on HEAD↔index @@ -2078,18 +2225,17 @@ describe(":Differ diff hunk staging", function() git_src.panel({ rev = {}, open_first = true }) local p = Panel.current() local v = view_in_origin(p) - assert.are.equal("staged", v.staging.initial) + assert.are.equal("staged", v:_hunk_state(1)) assert.are.equal(1, #v.model.hunks) vim.api.nvim_set_current_win(p.origin_win) vim.api.nvim_win_set_cursor(p.origin_win, { hunk_line(v, 1), 0 }) - v:unstage_hunk() -- the staged side is empty now, but the view holds + v:unstage_hunk() assert.are.equal(V1, indexed(root, "a.lua")) -- back to HEAD - assert.are.equal("staged", v.staging.initial) assert.are.equal("a.lua", v.model.path) assert.are.equal("unstaged", v:_hunk_state(1)) - v:stage_hunk() -- and s puts it back in place, on the same pair + v:stage_hunk() -- and s puts it back in place assert.are.equal("staged", v:_hunk_state(1)) assert.are.equal(worktree(root, "a.lua"), indexed(root, "a.lua")) p:close() @@ -2116,7 +2262,7 @@ describe(":Differ diff hunk staging", function() p:close() end) - it("follows a file to its staged side when staged wholesale outside differ", function() + it("re-marks a file staged wholesale outside differ", function() local root = fresh_repo() write(root .. "/a.lua", "local x = 2\nreturn x\n") -- a.lua modified, unstaged vim.cmd.edit(root .. "/a.lua") @@ -2124,17 +2270,17 @@ describe(":Differ diff hunk staging", function() git_src.panel({ rev = {}, open_first = true }) local p = Panel.current() local v = view_in_origin(p) - assert.are.equal("unstaged", v.staging.initial) -- viewing the unstaged side + assert.are.equal("unstaged", v:_hunk_state(1)) git(root, "add", "a.lua") -- stage the whole file in "lazygit" vim.api.nvim_exec_autocmds("FocusGained", { group = p.augroup }) vim.wait(200, function() - return v.staging.initial == "staged" + return v:_hunk_state(1) == "staged" end) - -- the diff followed the file to its staged side rather than going blank - assert.are.equal("staged", v.staging.initial) - assert.are.equal("local x = 2\nreturn x\n", v.model.new_text) -- HEAD↔index + -- the same diff, re-read with the hunk now marked staged + assert.are.equal("staged", v:_hunk_state(1)) + assert.are.equal("local x = 2\nreturn x\n", v.model.new_text) p:close() end) @@ -2233,7 +2379,7 @@ describe(":Differ diff hunk staging", function() if m.kind == "file" and m.entry.path == path - and (staged == nil or m.entry.staged == staged) + and (staged == nil or (m.entry.y == " ") == staged) then return i end @@ -2410,7 +2556,7 @@ describe(":Differ diff hunk staging", function() git_src.panel({ rev = {}, open_first = true }) local p = Panel.current() local v = view_in_origin(p) - assert.are.equal("staged", v.staging.initial) + assert.are.equal("staged", v:_hunk_state(1)) assert.are.equal(2, #v.model.hunks) vim.api.nvim_set_current_win(p.origin_win) @@ -2426,14 +2572,12 @@ describe(":Differ diff hunk staging", function() p:close() end) - -- reverting only the worktree copy of a hunk already pushed to the index would - -- leave the two differing the other way round, so the key refuses instead - it("refuses to revert a hunk staged during the session", function() + -- the diff has both sides of a hunk staged during the session, so X takes it out of + -- the index and the worktree together rather than leaving them differing + it("reverts a hunk staged during the session from both sides", function() local root = fresh_repo() write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n") git(root, "commit", "-q", "-am", "8 lines") - -- two hunks, so staging one leaves the unstaged pair live and the view frozen on - -- it: a marked hunk on a worktree diff is exactly the state the guard is for write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8x\n") vim.cmd.edit(root .. "/a.lua") @@ -2445,13 +2589,14 @@ describe(":Differ diff hunk staging", function() vim.api.nvim_win_set_cursor(p.origin_win, { 1, 0 }) v:stage_hunk() assert.are.equal("staged", v:_hunk_state(1)) - assert.are.equal("WORKTREE", v.model.new_rev) -- still the unstaged pair - confirming(1, function() -- would say yes, but it never gets asked + confirming(1, function() v:revert_hunk() end) - assert.are.equal("1x\n2\n3\n4\n5\n6\n7\n8x\n", worktree(root, "a.lua")) - assert.are.equal(2, #v.model.hunks) + assert.are.equal(committed(root, "a.lua"), indexed(root, "a.lua")) + assert.are.equal("1\n2\n3\n4\n5\n6\n7\n8x\n", worktree(root, "a.lua")) + assert.are.equal(1, #v.model.hunks) + assert.are.equal("unstaged", v:_hunk_state(1)) p:close() end) @@ -2773,7 +2918,7 @@ describe(":Differ diff hunk staging", function() v:stage_hunk() -- the removal is in the index, and the view followed the file to its staged side assert.are.equal("", git(root, "ls-files", "--", "b.lua")) - assert.are.equal("staged", v.staging.initial) + assert.are.equal("staged", v:_hunk_state(1)) assert.are.equal(0, vim.fn.filereadable(root .. "/b.lua")) -- still gone from disk vim.api.nvim_set_current_win(p.origin_win) @@ -2906,7 +3051,7 @@ describe(":Differ diff hunk staging", function() p:close() end) - it("df on a staged diff unstages the file and re-sources to its worktree view", function() + it("df on a fully staged file edits the worktree and leaves the index alone", function() local root = fresh_repo() write(root .. "/a.lua", "1\n2\n3\n") git(root, "commit", "-q", "-am", "base") @@ -2917,13 +3062,34 @@ describe(":Differ diff hunk staging", function() git_src.panel({ rev = {}, open_first = true }) local p = Panel.current() local v = view_in_origin(p) - assert.are.equal("INDEX", v.model.new_rev) -- opens on the staged HEAD<->index diff + assert.are.equal("WORKTREE", v.model.new_rev) - v:edit_file() -- flow C: unstage + re-source to index<->worktree - assert.are.equal("WORKTREE", v.model.new_rev) -- the diff now reflects the worktree - assert.is_truthy(v.edit_win) -- and the editable window opened + v:edit_file() + assert.is_truthy(v.edit_win) local staged = git(root, "diff", "--cached", "--name-only") or "" - assert.is_nil(staged:find("a.lua", 1, true)) -- a.lua is no longer staged + assert.is_truthy(staged:find("a.lua", 1, true)) -- still staged + p:close() + end) + + -- a file deleted in the worktree has nothing on disk to open + it("df on a worktree deletion refuses rather than opening a missing file", function() + local root = fresh_repo() + write(root .. "/a.lua", "local x = 2\nreturn x\n") + git(root, "add", "a.lua") + os.remove(root .. "/a.lua") -- MD: an edit staged, the file then deleted + write(root .. "/b.lua", "b\n") + vim.cmd.edit(root .. "/b.lua") + + git_src.panel({ rev = {} }) + local p = Panel.current() + assert.is_true(p:focus_file("a.lua")) + p:select(true) + local v = view_in_origin(p) + + _G.notifs = {} + v:edit_file() + assert.is_nil(v.edit_win) + assert.is_truthy(_G.notifs[#_G.notifs].msg:find("a.lua is not on disk", 1, true)) p:close() end) @@ -3050,7 +3216,7 @@ describe(":Differ diff hunk staging", function() assert.is_true(p:step_review("prev", true, true)) assert.are.equal("a.lua", v.model.path) - assert.are.equal("staged", v.staging.initial) + assert.are.equal("staged", v:_hunk_state(1)) p:close() end) @@ -3097,13 +3263,13 @@ describe(":Differ diff hunk staging", function() v:stage_hunk() -- stage it: z.lua is done, and follows to its staged side assert.are.equal(worktree(root, "z.lua"), indexed(root, "z.lua")) - assert.are.equal("staged", v.staging.initial) + assert.are.equal("staged", v:_hunk_state(1)) -- only now does the review leave, for the file it never opened vim.api.nvim_set_current_win(p.origin_win) v:stage_hunk() assert.are.equal("a.lua", v.model.path) - assert.are.equal("unstaged", v.staging.initial) + assert.are.equal("unstaged", v:_hunk_state(1)) p:close() end) @@ -3314,7 +3480,7 @@ describe(":Differ diff hunk staging", function() v:stage_all() -- nothing left here: round the end and past the staged a.lua assert.are.equal("b.lua", v.model.path) - assert.are.equal("unstaged", v.staging.initial) + assert.are.equal("unstaged", v:_hunk_state(1)) p:close() end) @@ -3338,7 +3504,7 @@ describe(":Differ diff hunk staging", function() v:unstage_all() -- nothing staged here: back to a.lua, on its last staged hunk assert.are.equal("a.lua", v.model.path) - assert.are.equal("staged", v.staging.initial) + assert.are.equal("staged", v:_hunk_state(1)) assert.is_not_nil(v.columns[1].map.lines[vim.api.nvim_win_get_cursor(p.origin_win)[1]].hunk) p:close() end) @@ -3964,7 +4130,7 @@ describe(":Differ diff whole-file staging", function() end local function staged_entry(p, path) for _, m in ipairs(p.meta) do - if m.kind == "file" and m.entry.path == path and m.entry.staged then + if m.kind == "file" and m.entry.path == path and m.entry.y == " " then return m.entry end end @@ -4005,7 +4171,7 @@ describe(":Differ diff whole-file staging", function() git_src.panel({ rev = {}, open_first = true }) local p = Panel.current() local v = view_in_origin(p) - assert.are.equal("staged", v.staging.initial) + assert.are.equal("staged", v:_hunk_state(1)) assert.are.equal("staged", v:_hunk_state(1)) -- seeded with no hunk behind it assert.are.equal("100755", index_mode(root, "a.lua")) @@ -4058,60 +4224,6 @@ describe(":Differ diff whole-file staging", function() vim.api.nvim_win_close(0, true) p:close() end) - - -- a file with both a staged and an unstaged row: the staged row's diff is HEAD↔index - -- and shows nothing of what the unstaged row holds, but staging reads the file off - -- disk. `u` then `s` on the staged row is what reaches it, since `s` on a row already - -- staged steps to the next file rather than staging - local function round_trip_staged(root, path) - vim.cmd.edit(root .. "/" .. path) - git_src.panel({ rev = {} }) - local p = Panel.current() - assert.is_true(p:focus_file(path)) -- the staged row: it comes first - p:select(true) - local v = view_in_origin(p) - assert.is_true(v.staging.whole_file) - assert.are.equal("staged", v.staging.initial) - _G.notifs = {} - v:unstage_hunk() - view_in_origin(p):stage_hunk() - return p - end - - local function absorb_warning() - for _, n in ipairs(_G.notifs) do - if tostring(n.msg):find("including the edits you had left unstaged", 1, true) then - return n - end - end - return nil - end - - it("warns when staging a whole file sweeps in the unstaged row too", function() - local root = fresh_repo() - write(root .. "/new.lua", "one\ntwo\n") - git(root, "add", "new.lua") -- a staged add - write(root .. "/new.lua", "one\ntwo\nthree WIP\n") -- edited after, left unstaged - - local p = round_trip_staged(root, "new.lua") - - -- the staged diff never showed the WIP line, and it is in the index now - assert.are.equal("one\ntwo\nthree WIP\n", git(root, "show", ":new.lua")) - assert.are.equal(vim.log.levels.WARN, assert(absorb_warning()).level) - p:close() - end) - - it("stays quiet when the file on disk is what the diff staged", function() - local root = fresh_repo() - write(root .. "/new.lua", "one\ntwo\n") - git(root, "add", "new.lua") -- staged, and the worktree matches the index - - local p = round_trip_staged(root, "new.lua") - - assert.are.equal("one\ntwo\n", git(root, "show", ":new.lua")) - assert.is_nil(absorb_warning()) - p:close() - end) end) -- a rename is the one whole-file status that can show several hunks, and the one that @@ -4226,7 +4338,7 @@ describe(":Differ diff rename staging", function() local v = open_staged(p, "new/big.lua") assert.are.equal(3, #v.model.hunks) - assert.are.equal("staged", v.staging.initial) + assert.are.equal("staged", v:_hunk_state(1)) assert.are.same({ true, true, true }, painted_hunks(v)) p:close() end) diff --git a/test/nvim/panel_spec.lua b/test/nvim/panel_spec.lua index 4a13296..f08f1c4 100644 --- a/test/nvim/panel_spec.lua +++ b/test/nvim/panel_spec.lua @@ -164,7 +164,11 @@ describe("panel navigation", function() it("focus_first_unstaged lands on the first unstaged file, skipping Staged", function() local function se(path, staged) - return { path = path, status = "M", additions = 1, deletions = 0, staged = staged } + local x, y = "M", " " + if not staged then + x, y = " ", "M" + end + return { path = path, status = "M", additions = 1, deletions = 0, x = x, y = y } end local p, picked = panel({}, { sections = { @@ -181,7 +185,7 @@ describe("panel navigation", function() it("focus_first_unstaged falls back to the first file when all are staged", function() local function se(path) - return { path = path, status = "M", additions = 1, deletions = 0, staged = true } + return { path = path, status = "M", additions = 1, deletions = 0, x = "M", y = " " } end local p, picked = panel({}, { sections = { { title = "Staged", entries = { se("a.lua"), se("b.lua") } } }, @@ -268,60 +272,6 @@ describe("panel navigation", function() p:close() end) - it("counts an MM file's two rows as one file", function() - -- b.lua is staged and edited again, so it lists under both sections. the meter - -- counts paths, and both its rows take the same number, so it never steps back - local p = panel(nil, { - sections = { - { title = "Staged", entries = { fe("b.lua") } }, - { title = "Unstaged", entries = { fe("a.lua"), fe("b.lua") } }, - }, - }) - p:open() - assert.are.equal(2, p.file_total) - local idx = {} - for _, m in ipairs(p.meta) do - if m.kind == "file" then - idx[#idx + 1] = { m.entry.path, m.file_index } - end - end - assert.are.same({ { "b.lua", 1 }, { "a.lua", 2 }, { "b.lua", 2 } }, idx) - p:close() - end) - - it("]f / [f step past an MM file's staged row to its unstaged one", function() - -- b.lua lists under both sections; a step visits it once, at the Unstaged row, - -- so the wrap comes back to a.lua rather than to the staged half of b.lua - local function se(path, staged) - return { path = path, status = "M", additions = 1, deletions = 0, staged = staged } - end - local p = panel(nil, { - sections = { - { title = "Staged", entries = { se("b.lua", true) } }, - { - title = "Unstaged", - entries = { se("a.lua", false), se("b.lua", false), se("c.lua", false) }, - }, - }, - }) - p:open() - for i, m in ipairs(p.meta) do - if m.kind == "file" and m.entry.staged then - vim.api.nvim_win_set_cursor(p.winid, { i, 0 }) - end - end - local walk = {} - for _ = 1, 4 do - p:goto_file("next") - walk[#walk + 1] = cursor_path(p) - end - assert.are.same({ "a.lua", "b.lua", "c.lua", "a.lua" }, walk) - assert.is_false(p.meta[vim.api.nvim_win_get_cursor(p.winid)[1]].entry.staged) - p:goto_file("prev") -- back past b.lua's unstaged row, not into the staged one - assert.are.equal("c.lua", cursor_path(p)) - p:close() - end) - it("paints the diff --stat totals as virt_text on the help line", function() local p = panel({ fe("a.lua", "M", 31, 1), fe("b.lua", "M", 39, 2) }, { root = "/repo" }) p:open() diff --git a/test/nvim/view_spec.lua b/test/nvim/view_spec.lua index 2d82cfb..8da3c3c 100644 --- a/test/nvim/view_spec.lua +++ b/test/nvim/view_spec.lua @@ -1136,78 +1136,6 @@ describe("view edit-in-review", function() v:close() end) - it("drives the unstage hook for a staged source (flow C)", function() - vim.cmd("silent! only") - local dir = vim.fn.tempname() - write(dir, "f.txt", "a\nB\nc\n") - local hook_path, applied - local m = diff.build({ - path = "f.txt", - old_rev = "HEAD", - new_rev = "INDEX", - old_text = "a\nb\nc\n", - new_text = "a\nB\nc\n", - root = dir, - }) - local v = View.new(m, { - layout = "stacked", - context = math.huge, - deep_diff = { enabled = true }, - can_stage = true, - staging = { - initial = "staged", - apply = function() - applied = true - return true - end, - refresh = function() end, - }, - on_edit_unstage = function(path) - hook_path = path - end, - }) - v:open() - v:edit_file() - assert.are.equal("f.txt", hook_path) -- the frontend hook ran with the path - assert.is_nil(applied) -- the hook supersedes the in-place unstage - assert.is_truthy(v.edit_win) -- and the editor opened - v:close() - end) - - it("falls back to an in-place unstage when no hook is wired", function() - vim.cmd("silent! only") - local dir = vim.fn.tempname() - write(dir, "f.txt", "a\nB\nc\n") - local reversed = {} - local m = diff.build({ - path = "f.txt", - old_rev = "HEAD", - new_rev = "INDEX", - old_text = "a\nb\nc\n", - new_text = "a\nB\nc\n", - root = dir, - }) - local v = View.new(m, { - layout = "stacked", - context = math.huge, - deep_diff = { enabled = true }, - can_stage = true, - staging = { - initial = "staged", - apply = function(_, _, _, reverse) - reversed[#reversed + 1] = reverse - return true - end, - refresh = function() end, - }, - }) - v:open() - v:edit_file() - assert.are.same({ true }, reversed) -- the hunk was unstaged (reverse=true) - assert.is_truthy(v.edit_win) - v:close() - end) - it("also edits a staged (index) source", function() vim.cmd("silent! only") local dir = vim.fn.tempname() From 3d8ef4b617fdecad1514afeec8b151df5b191255 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:25:35 +0100 Subject: [PATCH 09/93] feat: add the dw local view and a ! gutter mark for hidden staged content --- README.md | 1 + doc/differ.txt | 1 + lua/differ/config.lua | 1 + lua/differ/git/init.lua | 158 ++++++++++++++++++++++++++------- lua/differ/model/marks.lua | 132 +++++++++++++++++++++++++++ lua/differ/ui/highlights.lua | 4 + lua/differ/ui/statuscolumn.lua | 22 ++++- lua/differ/ui/winbar.lua | 5 +- lua/differ/view.lua | 36 ++++++++ test/nvim/git_spec.lua | 102 ++++++++++++++++++++- test/unit/model_marks_spec.lua | 57 ++++++++++++ 11 files changed, 482 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 80a3129..55048b6 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,7 @@ require("differ").setup({ -- diffs toggle_layout = "dl", -- flip between stacked / split + toggle_local = "dw", -- a partly staged file: its changes since staging and back more_context = "d=", less_context = "d-", stage = "s", unstage = "u", -- stage/unstage[all] work on hunks/files (diff buffer/panel) stage_all = "S", unstage_all = "U", diff --git a/doc/differ.txt b/doc/differ.txt index 0e3d5c2..673c75f 100644 --- a/doc/differ.txt +++ b/doc/differ.txt @@ -200,6 +200,7 @@ you’re in doubt, `:Differ sidecar` reports which one is actually running. -- diffs toggle_layout = "dl", -- flip between stacked / split + toggle_local = "dw", -- a partly staged file: its changes since staging and back more_context = "d=", less_context = "d-", stage = "s", unstage = "u", -- stage/unstage[all] work on hunks/files (diff buffer/panel) stage_all = "S", unstage_all = "U", diff --git a/lua/differ/config.lua b/lua/differ/config.lua index 9147258..f1d94f5 100644 --- a/lua/differ/config.lua +++ b/lua/differ/config.lua @@ -162,6 +162,7 @@ M.defaults = { close = "dc", -- diff/panel/history: end the session (merge, pr or local) toggle_panel = "dd", -- diff/panel: hide/show the file panel sidebar toggle_layout = "dl", -- diff: flip stacked / split + toggle_local = "dw", -- diff: a partly staged file's changes since staging, and back -- merge tool, bound on the result buffer. nav + take-this resolution, -- the result buffer is the real worktree -- file and stays editable, so the whole choose family sits behind diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 79dfcae..1648687 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -1407,6 +1407,37 @@ function M.panel(opts) return sel end + -- write `text` as `entry`'s staged content. an add left with nothing staged leaves + -- the index, and a rename only the worktree has made is staged along with its + -- content; both change the row's status, so the view re-sources onto it + ---@param entry differ.FileEntry + ---@param text string + ---@return boolean ok + local function put_index(entry, text) + if entry.x == "A" and text == "" then + if not M.unstage(root, entry.path) then + return false + end + vim.schedule(function() + retarget_view(false) + end) + return true + end + if not write_index(root, entry.path, text) then + return false + end + if entry.y == "R" and entry.previous_path then + local drop = { "update-index", "--force-remove", "--", entry.previous_path } + if not git_ok(drop, root, "staging " .. entry.path) then + return false + end + vim.schedule(function() + retarget_view(false) + end) + end + return true + end + -- whether the index holds a mode change the HEAD↔worktree diff can't show: staged, -- then put back in the worktree ---@param entry differ.FileEntry @@ -1447,45 +1478,18 @@ function M.panel(opts) local fresh = marks.classify(union.hunks, cached.hunks, unstaged.hunks) staging.marks.old, staging.marks.new = fresh.old, fresh.new local complete, why = marks.complete(union.hunks, cached.hunks, unstaged.hunks) + staging.hidden_in = nil if mode_why then staging.hidden = mode_why elseif complete then staging.hidden = nil else staging.hidden = why + local head = require("differ.util.text").to_lines(union.old_text) + staging.hidden_in = marks.hidden_in(head, union.hunks, cached.hunks, fresh) end end - -- write `text` as the staged content. an add left with nothing staged leaves the - -- index, and a rename only the worktree has made is staged along with its - -- content; both change the row's status, so the view re-sources onto it - ---@param text string - ---@return boolean ok - local function put_index(text) - if entry.x == "A" and text == "" then - if not M.unstage(root, entry.path) then - return false - end - vim.schedule(function() - retarget_view(false) - end) - return true - end - if not write_index(root, entry.path, text) then - return false - end - if entry.y == "R" and entry.previous_path then - local drop = { "update-index", "--force-remove", "--", entry.previous_path } - if not git_ok(drop, root, "staging " .. entry.path) then - return false - end - vim.schedule(function() - retarget_view(false) - end) - end - return true - end - remark(M.union_models(root, entry)) staging.apply = function(_, hunk, reverse) if not hunk then @@ -1505,7 +1509,7 @@ function M.panel(opts) notify(("this hunk can't be staged: %s"):format(why), vim.log.levels.WARN) return false end - if not put_index(text) then + if not put_index(entry, text) then return false end remark(union_pairs(root, entry.path, union.old_text, text, union.new_text)) @@ -1522,7 +1526,7 @@ function M.panel(opts) local hunk = model.hunks[idx] local a, b = extent(hunk, "old") local text = apply_text(cached, select_hunks(cached.hunks, "old", a, b, false)) - if text and text ~= cached.new_text and not put_index(text) then + if text and text ~= cached.new_text and not put_index(entry, text) then return false end local p = patch.hunk(model.path, hunk, model.old_text, model.new_text, 0, "new") @@ -1618,6 +1622,91 @@ function M.panel(opts) return nil end + -- a row with a staged change and more on top of it: the one kind with a local view + ---@param entry differ.FileEntry + ---@return boolean + local function partly_staged(entry) + if entry.status == "U" or entry.x == "?" or entry.y == "D" then + return false + end + return entry.x ~= " " and entry.y ~= " " + end + + -- staging frozen at the index the view opened on: s and u rewrite the index as the + -- model's old side plus the hunks marked staged, so a marked hunk stays on screen + -- and the opposite key puts it back + ---@param entry differ.FileEntry + ---@param model differ.DiffModel -- read once, with at least one hunk + ---@param staged boolean -- every hunk's opening state + ---@return differ.view.Staging + local function frozen_staging(entry, model, staged) + local apply_text = require("differ.model.apply").partial + local marks = { old = {}, new = {} } + ---@param h differ.Hunk + ---@param on boolean + local function mark(h, on) + for l = h.old_start, h.old_start + h.old_count - 1 do + marks.old[l] = on + end + for l = h.new_start, h.new_start + h.new_count - 1 do + marks.new[l] = on + end + end + for _, h in ipairs(model.hunks) do + mark(h, staged) + end + return { + marks = marks, + refresh = refresh_panel, + apply = function(_, hunk, reverse) + if not hunk then + return false + end + mark(hunk, not reverse) + -- whole hunks only, so the rebuild never has to split one + local text = apply_text(model, marks) + if text and put_index(entry, text) then + return true + end + mark(hunk, reverse) + return false + end, + } + end + + -- swap the open view onto `entry`'s local view, index↔worktree. back goes through + -- retarget_view, which re-reads the row, since staging here can move it to another + -- section + ---@param entry differ.FileEntry + local function show_local(entry) + if not (view and view:is_open()) then + return + end + local focus_line, focus_col = view:cursor_new_line() + -- a rename only the worktree has made leaves the index at the old path + local at_index = entry.y == "R" and entry.previous_path or nil + local file = { path = entry.path, status = entry.status, previous_path = at_index } + local model = M.model({ old = INDEX, new = WORKTREE }, root, file, head_branch(root)) + local staging ---@type differ.view.Staging + if #model.hunks > 0 then + staging = frozen_staging(entry, model, false) + local _, cached = M.union_models(root, entry) + staging.hidden_in = require("differ.model.marks").restaged(model.hunks, cached.hunks) + else + if not model.binary then + model.notice = empty_notice(root, entry, model, {}) or "No local content change" + end + staging = { refresh = refresh_panel } + end + local function back() + retarget_view(false) + end + staging.badge, staging.toggle_local, staging.leave = "LOCAL", back, back + local focus = focus_line and { focus_line = focus_line, focus_col = focus_col } or nil + view:set_source(model, staging, focus) + last_sig = git_signature() + end + -- (re)source the diff view from an entry's current git state. false when the -- entry has no diff anymore (committed / fully staged / reverted outside differ). -- `source_opts` goes to View:set_source when the view is open @@ -1642,6 +1731,11 @@ function M.panel(opts) model.banner = "still on disk, untracked: u tracks it again" end local staging = stage_for(entry, model) + if staging and partly_staged(entry) then + staging.toggle_local = function() + show_local(entry) + end + end if view and view:is_open() then view:set_source(model, staging, source_opts) else diff --git a/lua/differ/model/marks.lua b/lua/differ/model/marks.lua index e100a66..c939d67 100644 --- a/lua/differ/model/marks.lua +++ b/lua/differ/model/marks.lua @@ -80,6 +80,138 @@ function M.state(marks, h) return "partial" end +-- a hunk's real lines on one side, [start, stop). a zero-count hunk has none +---@param h differ.Hunk +---@param side "old"|"new" +---@return integer start, integer stop +local function span(h, side) + local at, n = h[side .. "_start"], h[side .. "_count"] + return at, at + n +end + +-- a hunk's extent on the old side, widened to a line where it has none, so a pure +-- insertion still meets the change it sits in +---@param h differ.Hunk +---@return integer start, integer stop -- [start, stop) +local function reach(h) + local at, n = h.old_start, h.old_count + if n == 0 then + return at, at + 1 + end + return at, at + n +end + +-- the HEAD↔index hunks that touch a HEAD↔worktree hunk +---@param h differ.Hunk -- HEAD↔worktree +---@param cached differ.Hunk[] -- HEAD↔index +---@return differ.Hunk[] +local function touching(h, cached) + local a, b = reach(h) + local out = {} + for _, c in ipairs(cached) do + local ca, cb = reach(c) + if ca < b and cb > a then + out[#out + 1] = c + end + end + return out +end + +---@param lines string[] +---@return string +local function bag(lines) + table.sort(lines) + return table.concat(lines, "\n") +end + +-- the HEAD↔worktree hunks whose lines, read as the marks say, don't hold what the index +-- does there, and whether any staged change touches no hunk at all. by content, so a +-- hunk whose index lines differ only in order isn't counted +---@param head string[] -- HEAD's lines +---@param union differ.Hunk[] -- HEAD↔worktree +---@param cached differ.Hunk[] -- HEAD↔index +---@param marks differ.model.Marks +---@return integer[] hunks, boolean outside +function M.hidden_in(head, union, cached, marks) + local out, touched = {}, {} + for i, h in ipairs(union) do + local in_union, in_cached, region = {}, {}, {} + local ua, ub = span(h, "old") + for l = ua, ub - 1 do + in_union[l], region[l] = true, true + end + local actual = {} + for _, c in ipairs(touching(h, cached)) do + touched[c] = true + local ca, cb = span(c, "old") + for l = ca, cb - 1 do + in_cached[l], region[l] = true, true + end + for _, line in ipairs(c.new_lines or {}) do + actual[#actual + 1] = line + end + end + local implied = {} + for l in pairs(region) do + if not in_cached[l] then + actual[#actual + 1] = head[l] + end + if not in_union[l] or not marks.old[l] then + implied[#implied + 1] = head[l] + end + end + local na = h.new_start + for k, line in ipairs(h.new_lines or {}) do + if marks.new[na + k - 1] then + implied[#implied + 1] = line + end + end + if bag(actual) ~= bag(implied) then + out[#out + 1] = i + end + end + local outside = false + for _, c in ipairs(cached) do + outside = outside or not touched[c] + end + return out, outside +end + +-- whether index↔worktree hunk `h` puts back lines HEAD↔index hunk `c` deleted outright +---@param h differ.Hunk -- index↔worktree +---@param c differ.Hunk -- HEAD↔index +---@return boolean +local function puts_back(h, c) + if not (h.old_count == 0 and c.new_count == 0 and h.old_start == c.new_start) then + return false + end + return table.concat(h.new_lines or {}, "\n") == table.concat(c.old_lines or {}, "\n") +end + +-- the index↔worktree hunks that change staged content again: one that rewrites or drops +-- a line the index added, or puts back lines the index deleted. what they undo is +-- staged content the HEAD↔worktree diff can't show +---@param unstaged differ.Hunk[] -- index↔worktree +---@param cached differ.Hunk[] -- HEAD↔index +---@return integer[] +function M.restaged(unstaged, cached) + local added = covered(cached, "new") + local out = {} + for i, h in ipairs(unstaged) do + local hit = false + for l = h.old_start, h.old_start + h.old_count - 1 do + hit = hit or added[l] == true + end + for _, c in ipairs(cached) do + hit = hit or puts_back(h, c) + end + if hit then + out[#out + 1] = i + end + end + return out +end + -- the HEAD line an index line sits at, for a line HEAD↔index leaves alone ---@param cached differ.Hunk[] -- HEAD↔index ---@param lnum integer -- index line diff --git a/lua/differ/ui/highlights.lua b/lua/differ/ui/highlights.lua index ac4cf6f..e5d9293 100644 --- a/lua/differ/ui/highlights.lua +++ b/lua/differ/ui/highlights.lua @@ -121,6 +121,10 @@ local function status_groups(p) differPanelCountDelete = { fg = p.red }, -- the staged-hunk gutter glyph: green for "in the index" differStagedSign = { fg = p.green }, + -- a hunk touching staged content the whole change can't show, in the staged cell + differHiddenSign = { fg = p.orange, bold = true }, + -- the winbar badge on a view that isn't the whole change + differViewBadge = { fg = p.green, bold = true, reverse = true }, } end diff --git a/lua/differ/ui/statuscolumn.lua b/lua/differ/ui/statuscolumn.lua index d31bc21..c795df8 100644 --- a/lua/differ/ui/statuscolumn.lua +++ b/lua/differ/ui/statuscolumn.lua @@ -4,6 +4,7 @@ local M = {} local STAGED_GLYPH = "▌" +local HIDDEN_GLYPH = "!" ---@type table -- bufnr -> pre-formatted rail string per lnum local rails = {} @@ -11,6 +12,9 @@ local rails = {} ---@type table> -- bufnr -> set of staged lnums local staged = {} +---@type table> -- bufnr -> lnums opening a hunk marked `!` +local hidden = {} + -- pre-format the gutter string for every line of a column from its map. pure, so -- the statuscolumn callback only does an O(1) index per redraw. a unified -- column shows both rails (old left / new right); a side column shows only its @@ -64,11 +68,19 @@ function M.set_staged(bufnr, lines) staged[bufnr] = lines end +-- register the lines that open a hunk marked `!`; they take the staged cell's place +---@param bufnr integer +---@param lines table|nil +function M.set_hidden(bufnr, lines) + hidden[bufnr] = lines +end + -- drop a buffer's cached rail strings ---@param bufnr integer function M.clear(bufnr) rails[bufnr] = nil staged[bufnr] = nil + hidden[bufnr] = nil end -- statuscolumn callback: return the rail string for the current line, prefixed by @@ -87,10 +99,14 @@ function M.render() end local s = cache[vim.v.lnum] or "" local sset = buf and staged[buf] - if sset then - return (sset[vim.v.lnum] and ("%#differStagedSign#" .. STAGED_GLYPH .. "%*") or " ") .. s + if not sset then + return s + end + local hset = hidden[buf] + if hset and hset[vim.v.lnum] then + return "%#differHiddenSign#" .. HIDDEN_GLYPH .. "%*" .. s end - return s + return (sset[vim.v.lnum] and ("%#differStagedSign#" .. STAGED_GLYPH .. "%*") or " ") .. s end return M diff --git a/lua/differ/ui/winbar.lua b/lua/differ/ui/winbar.lua index aca1190..f03ebae 100644 --- a/lua/differ/ui/winbar.lua +++ b/lua/differ/ui/winbar.lua @@ -83,7 +83,10 @@ function M.diff() note = "staged content hidden" end local noted = note and ("%#WarningMsg#" .. esc(note) .. "%* ") or "" - return (" %s %%=%s%s%s%s hunk %d/%d "):format( + local badge_text = view.staging and view.staging.badge + local tag = badge_text and ("%#differViewBadge# " .. esc(badge_text) .. " %* ") or "" + return (" %s%s %%=%s%s%s%s hunk %d/%d "):format( + tag, esc(vim.fn.fnamemodify(view.model.path, ":t")), badge, noted, diff --git a/lua/differ/view.lua b/lua/differ/view.lua index c8fa9af..f082ddc 100644 --- a/lua/differ/view.lua +++ b/lua/differ/view.lua @@ -91,6 +91,10 @@ local armed_view = nil ---@field refresh fun() ---@field marks? differ.model.Marks -- union source: which lines the index holds, kept current by the source ---@field hidden? string -- union source: why some staged content isn't on screen +---@field hidden_in? integer[] -- the hunks marked `!`: they touch staged content the whole change can't show +---@field toggle_local? fun() -- a partly staged file: swap between the whole change and its local view +---@field leave? fun() -- a frozen view: back to the whole change, whose new side is the file to edit +---@field badge? string -- winbar tag naming a view that isn't the whole change ---@class differ.View ---@field columns differ.ViewColumn[] @@ -360,7 +364,19 @@ function View:_paint_staged() if not self.can_stage then return end + local hidden = {} + for _, h in ipairs(self.staging and self.staging.hidden_in or {}) do + hidden[h] = true + end for _, col in ipairs(self.columns) do + local opening, seen = {}, {} + for i, line in ipairs(col.map.lines) do + local h = line.hunk + if h and hidden[h] and not seen[h] then + seen[h], opening[i] = true, true + end + end + statuscolumn.set_hidden(col.bufnr, opening) vim.api.nvim_buf_clear_namespace(col.bufnr, staged_ns, 0, -1) local staged_lines = {} for i, line in ipairs(col.map.lines) do @@ -647,6 +663,16 @@ function View:toggle_layout() self:set_layout(self.layout == "stacked" and "split" or "stacked") end +-- dw: swap a partly staged file between its whole change (HEAD↔worktree) and what +-- changed since staging (index↔worktree) +function View:toggle_local() + local toggle = self.staging and self.staging.toggle_local + if not toggle then + return vim.notify("differ: only a partly staged file has a local view", vim.log.levels.INFO) + end + toggle() +end + -- set the per-view context line count (math.huge = whole file). same column -- count, so no relayout, content/map/gutter/highlights refresh in place ---@param n number @@ -803,6 +829,9 @@ function View:_setup_window(winid, bufnr) bind(bufnr, km.unstage_all, function() self:unstage_all() end, "differ: unstage all hunks") + bind(bufnr, km.toggle_local, function() + self:toggle_local() + end, "differ: toggle the local view") -- hunk-level here vs the panel's file-level discard, and destructive either -- way, so it confirms rather than acting straight off the key bind(bufnr, km.discard, function() @@ -889,6 +918,10 @@ function View:show_help() if self.staging and self.staging.revert then rows[#rows + 1] = { fmt(km.discard), "revert hunk (confirm)" } end + if self.staging and self.staging.toggle_local then + local what = self.staging.badge and "back to the whole change" or "changes since staging" + rows[#rows + 1] = { fmt(km.toggle_local), what } + end for _, m in ipairs(self.extra_keymaps or {}) do rows[#rows + 1] = { fmt(m.spec), m.desc } end @@ -1577,6 +1610,9 @@ function View:edit_file() vim.log.levels.WARN ) end + if self.staging and self.staging.leave then + self.staging.leave() + end local t = self:_edit_target() if not t then return diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index fa5acdc..77b8381 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -2052,6 +2052,13 @@ describe(":Differ diff hunk staging", function() return text end + -- the gutter drawn beside buffer line `lnum` of the diff column of `v` + local function gutter_of(v, lnum) + local win = v.columns[#v.columns].winid + local fmt = vim.wo[win].statuscolumn + return vim.api.nvim_eval_statusline(fmt, { winid = win, use_statuscol_lnum = lnum }).str + end + -- the staged line edited again: the index's version reaches neither side of the diff it("says in the winbar when staged content isn't on screen", function() local root = fresh_repo() @@ -2062,9 +2069,14 @@ describe(":Differ diff hunk staging", function() git_src.panel({ rev = {}, open_first = true }) local p = Panel.current() - local text = winbar_of(view_in_origin(p)) + local v = view_in_origin(p) + local text, gutter = winbar_of(v), gutter_of(v, hunk_line(v, 1)) + v:toggle_local() + local local_gutter = gutter_of(v, hunk_line(v, 1)) p:close() assert.is_truthy(text:find("staged content hidden", 1, true)) + assert.is_truthy(gutter:find("!", 1, true)) -- the hunk it's hidden in + assert.is_truthy(local_gutter:find("!", 1, true)) -- the local hunk that rewrote it end) it("says in the winbar when a staged mode change isn't on screen", function() @@ -2151,6 +2163,94 @@ describe(":Differ diff hunk staging", function() assert.are.equal("RM a.lua -> b.lua\n", status) end) + -- two staged edits and a third on top in the worktree, far enough apart to diff as + -- three hunks + local function partly_staged_repo() + local root = fresh_repo() + write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n") + git(root, "commit", "-q", "-am", "twelve lines") + write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6x\n7\n8\n9\n10\n11\n12\n") + git(root, "add", "a.lua") + write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6x\n7\n8\n9\n10\n11\n12x\n") + vim.cmd.edit(root .. "/a.lua") + return root + end + + it("dw opens a partly staged file's local view and goes back", function() + partly_staged_repo() + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + local v = view_in_origin(p) + local whole = #v.model.hunks + v:toggle_local() + local old_rev, new_rev = v.model.old_rev, v.model.new_rev + local hunks, state, text = #v.model.hunks, v:_hunk_state(1), winbar_of(v) + local gutter = gutter_of(v, hunk_line(v, 1)) + v:toggle_local() + local back_rev, back_hunks, back_text = v.model.old_rev, #v.model.hunks, winbar_of(v) + p:close() + assert.are.equal(3, whole) + assert.are.same({ "INDEX", "WORKTREE" }, { old_rev, new_rev }) + assert.are.equal(1, hunks) + assert.are.equal("unstaged", state) + assert.is_truthy(text:find(" LOCAL ", 1, true)) + assert.is_nil(gutter:find("!", 1, true)) -- 12x touches nothing staged + assert.are.equal("HEAD", back_rev) + assert.are.equal(3, back_hunks) + assert.is_nil(back_text:find("LOCAL", 1, true)) + end) + + -- the view stays frozen and the index is rebuilt from the one it opened on, so a + -- hunk staged here stays on screen and u puts it back + it("stages and unstages in the local view against the index it opened on", function() + local root = partly_staged_repo() + local before = indexed(root, "a.lua") + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + local v = view_in_origin(p) + v:toggle_local() + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + v:stage_hunk() + local staged, state, rev_after = indexed(root, "a.lua"), v:_hunk_state(1), v.model.old_rev + v:unstage_hunk() + local unstaged = indexed(root, "a.lua") + p:close() + assert.are.equal(worktree(root, "a.lua"), staged) + assert.are.equal("staged", state) + assert.are.equal("INDEX", rev_after) -- still the local view + assert.are.equal(before, unstaged) + end) + + it("df from the local view edits the worktree file from the whole change", function() + partly_staged_repo() + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + local v = view_in_origin(p) + v:toggle_local() + v:edit_file() + local rev_after, win = v.model.old_rev, v.edit_win + p:close() + assert.are.equal("HEAD", rev_after) + assert.is_truthy(win) + end) + + it("dw says so on a file with no local view", function() + local root = fresh_repo() + write(root .. "/a.lua", "local x = 2\nreturn x\n") + vim.cmd.edit(root .. "/a.lua") + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + local v = view_in_origin(p) + _G.notifs = {} + v:toggle_local() + local rev_after, msg = v.model.old_rev, _G.notifs[#_G.notifs].msg + p:close() + assert.are.equal("HEAD", rev_after) + assert.are.equal("differ: only a partly staged file has a local view", msg) + end) + -- the shading says which lines the index holds; the winbar says the hunk as a whole -- is mixed, which is what tells you s and u both still have work on it it("names a partial hunk in the diff winbar", function() diff --git a/test/unit/model_marks_spec.lua b/test/unit/model_marks_spec.lua index 7bd369f..0059e21 100644 --- a/test/unit/model_marks_spec.lua +++ b/test/unit/model_marks_spec.lua @@ -142,4 +142,61 @@ describe("union marks", function() assert.is_true(marks.complete(union, {}, unstaged)) end) end) + + describe("locating hidden content", function() + local head = { "1", "2", "3", "4", "5" } + + it("names the hunk whose staged line was edited again", function() + local union = { hl(1, { "foo" }, 1, { "FOOD" }) } + local cached = { hl(1, { "foo" }, 1, { "FOO" }) } + local unstaged = { hl(1, { "FOO" }, 1, { "FOOD" }) } + local m = marks.classify(union, cached, unstaged) + local where, outside = marks.hidden_in({ "foo" }, union, cached, m) + assert.are.same({ 1 }, where) + assert.is_false(outside) + end) + + -- HEAD 1..5, line 3 staged as 3x, then 4 edited on top in the worktree + it("names nothing when the view holds what the index does", function() + local union = { hl(3, { "3", "4" }, 3, { "3x", "4y" }) } + local cached = { hl(3, { "3" }, 3, { "3x" }) } + local unstaged = { hl(4, { "4" }, 4, { "4y" }) } + local m = marks.classify(union, cached, unstaged) + local where, outside = marks.hidden_in(head, union, cached, m) + assert.are.same({}, where) + assert.is_false(outside) + end) + + -- line 5 staged as 5x and then put back in the worktree, beside an edit at 1 + it("reports a staged change that touches no hunk", function() + local union = { hl(1, { "1" }, 1, { "1y" }) } + local cached = { hl(5, { "5" }, 5, { "5x" }) } + local unstaged = { hl(1, { "1" }, 1, { "1y" }), hl(5, { "5x" }, 5, { "5" }) } + local m = marks.classify(union, cached, unstaged) + local where, outside = marks.hidden_in(head, union, cached, m) + assert.are.same({}, where) + assert.is_true(outside) + end) + end) + + describe("local hunks over staged content", function() + it("names a local hunk rewriting a line the index added", function() + local cached = { hl(1, { "foo" }, 1, { "FOO" }) } + local unstaged = { hl(1, { "FOO" }, 1, { "FOOD" }) } + assert.are.same({ 1 }, marks.restaged(unstaged, cached)) + end) + + it("names a local hunk putting back a line the index deleted", function() + local cached = { hl(3, { "3" }, 2, {}) } + local unstaged = { hl(2, {}, 3, { "3" }) } + assert.are.same({ 1 }, marks.restaged(unstaged, cached)) + end) + + it("leaves out local hunks on lines the index left alone", function() + local cached = { hl(3, { "3" }, 3, { "3x" }) } + local beside = hl(3, {}, 4, { "new" }) -- an insertion after the staged line + local apart = hl(5, { "5" }, 6, { "5y" }) + assert.are.same({}, marks.restaged({ beside, apart }, cached)) + end) + end) end) From 7c756b6fd68c1d22e46f6fc0bc2a9e17b02f2c55 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:15:22 +0100 Subject: [PATCH 10/93] feat: tally staged and partial hunks in the diff winbar --- lua/differ/ui/winbar.lua | 59 ++++++++++++++++++++++++++++++---------- test/nvim/git_spec.lua | 36 ++++++++++++++++-------- 2 files changed, 69 insertions(+), 26 deletions(-) diff --git a/lua/differ/ui/winbar.lua b/lua/differ/ui/winbar.lua index f03ebae..cac6940 100644 --- a/lua/differ/ui/winbar.lua +++ b/lua/differ/ui/winbar.lua @@ -70,33 +70,64 @@ function M.diff() -- while reviewing (not just in the compose window) local draft = require("differ.pr").review_status(buf) local badge = draft and ("%#differReviewDraft#● " .. draft .. "%* ") or "" - -- a union source's hunk can hold lines the index has and lines it doesn't. the dim - -- shade already says which lines those are; this says the hunk as a whole is mixed, - -- so s and u both still have something to do on it. staged and unstaged say nothing - -- here, the shading being the whole story for them - local mixed = "" - if k > 0 and view:_hunk_state(k) == "partial" then - mixed = "%#differPanelContext#partial%* " - end - local note = view.model.banner - if view.staging and view.staging.hidden then - note = "staged content hidden" - end + local note = M.hidden_note(view) or view.model.banner local noted = note and ("%#WarningMsg#" .. esc(note) .. "%* ") or "" local badge_text = view.staging and view.staging.badge local tag = badge_text and ("%#differViewBadge# " .. esc(badge_text) .. " %* ") or "" - return (" %s%s %%=%s%s%s%s hunk %d/%d "):format( + return (" %s%s %%=%s%s%s %shunk %d/%d "):format( tag, esc(vim.fn.fnamemodify(view.model.path, ":t")), badge, noted, - mixed, HUNK_MARK, + M.tally(view), k, total ) end +-- "3 staged · 1 partial · " from the hunks' staged states, zero counts left out. +-- empty off a hunk-staging view +---@param view differ.View +---@return string +function M.tally(view) + if not view:_can_stage_hunk() or view:_whole_file() then + return "" + end + local counts = { staged = 0, partial = 0 } + for i = 1, #view.model.hunks do + local state = view:_hunk_state(i) + if counts[state] then + counts[state] = counts[state] + 1 + end + end + local out = "" + for _, state in ipairs({ "staged", "partial" }) do + if counts[state] > 0 then + out = out .. ("%d %s · "):format(counts[state], state) + end + end + return out +end + +-- the note on staged content the view can't show: how many hunks it sits in, else that +-- it sits outside them, plus the key to the local view where there is one +---@param view differ.View +---@return string|nil +function M.hidden_note(view) + local staging = view.staging + if not (staging and staging.hidden) then + return nil + end + local where = staging.hidden_in or {} + local text = #where > 0 and ("%d hidden"):format(#where) or "staged content hidden" + local key = staging.toggle_local and require("differ.ui.help").fmt(view.keymaps.toggle_local) + if not key then + return text + end + return ("%s: %s shows it"):format(text, key) +end + -- panel winbar: a bar plus "file K/N" for the cursor's position in the file list ---@return string function M.panel() diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index 77b8381..e16ef57 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -2074,7 +2074,7 @@ describe(":Differ diff hunk staging", function() v:toggle_local() local local_gutter = gutter_of(v, hunk_line(v, 1)) p:close() - assert.is_truthy(text:find("staged content hidden", 1, true)) + assert.is_truthy(text:find("1 hidden: dw shows it", 1, true)) assert.is_truthy(gutter:find("!", 1, true)) -- the hunk it's hidden in assert.is_truthy(local_gutter:find("!", 1, true)) -- the local hunk that rewrote it end) @@ -2091,7 +2091,7 @@ describe(":Differ diff hunk staging", function() local p = Panel.current() local text = winbar_of(view_in_origin(p)) p:close() - assert.is_truthy(text:find("staged content hidden", 1, true)) + assert.is_truthy(text:find("staged content hidden: dw shows it", 1, true)) end) it("names a kept deletion's copy on disk in the winbar", function() @@ -2251,31 +2251,43 @@ describe(":Differ diff hunk staging", function() assert.are.equal("differ: only a partly staged file has a local view", msg) end) - -- the shading says which lines the index holds; the winbar says the hunk as a whole - -- is mixed, which is what tells you s and u both still have work on it - it("names a partial hunk in the diff winbar", function() - local winbar = require("differ.ui.winbar") + -- the winbar counts the hunks the index holds whole and in part; zero counts drop out + it("tallies staged and partial hunks in the diff winbar", function() local root = fresh_repo() write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") git(root, "commit", "-q", "-am", "ten lines") write(root .. "/a.lua", "1\n2\n3x\n4\n5\n6\n7\n8\n9\n10\n") git(root, "add", "a.lua") write(root .. "/a.lua", "1\n2\n3x\n4y\n5y\n6\n7\n8\n9\n10\n") - vim.cmd.edit(root .. "/a.lua") + git_src.panel({ rev = {}, open_first = true }) local p = Panel.current() local v = view_in_origin(p) local col = v.columns[#v.columns] vim.api.nvim_set_current_win(col.winid) vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + local mixed = winbar_of(v) + v:stage_hunk() + local whole = winbar_of(v) + v:unstage_hunk() + local none = winbar_of(v) + p:close() + assert.is_truthy(mixed:find(" 1 partial · hunk 1/1 ", 1, true)) + assert.is_truthy(whole:find(" 1 staged · hunk 1/1 ", 1, true)) + assert.is_truthy(none:find(" hunk 1/1 ", 1, true)) + assert.is_nil(none:find("staged", 1, true)) + assert.is_nil(none:find("partial", 1, true)) + end) - vim.g.statusline_winid = col.winid - assert.is_truthy(winbar.diff():find("partial", 1, true)) - v:stage_hunk() -- nothing mixed about it now - assert.is_nil(winbar.diff():find("partial", 1, true)) - vim.g.statusline_winid = nil + it("tallies staged hunks beside unstaged ones", function() + partly_staged_repo() + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + local text = winbar_of(view_in_origin(p)) p:close() + assert.is_truthy(text:find(" 2 staged · hunk %d/3 ")) + assert.is_nil(text:find("partial", 1, true)) end) -- on a two-row file X refuses a staged hunk, since reverting only the worktree copy From ecd62fd117dbf334a16c43376cb1ddbdb6bf5be8 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:49:32 +0100 Subject: [PATCH 11/93] fix: keep the winbar tally current and hide it once every hunk is staged --- lua/differ/ui/winbar.lua | 5 ++++- lua/differ/view.lua | 17 +++++++++++------ test/nvim/git_spec.lua | 31 ++++++++++++++++++++++++++++++- 3 files changed, 45 insertions(+), 8 deletions(-) diff --git a/lua/differ/ui/winbar.lua b/lua/differ/ui/winbar.lua index cac6940..ade9204 100644 --- a/lua/differ/ui/winbar.lua +++ b/lua/differ/ui/winbar.lua @@ -87,7 +87,7 @@ function M.diff() end -- "3 staged · 1 partial · " from the hunks' staged states, zero counts left out. --- empty off a hunk-staging view +-- empty once every hunk is staged, and off a hunk-staging view ---@param view differ.View ---@return string function M.tally(view) @@ -101,6 +101,9 @@ function M.tally(view) counts[state] = counts[state] + 1 end end + if counts.staged == #view.model.hunks then + return "" + end local out = "" for _, state in ipairs({ "staged", "partial" }) do if counts[state] > 0 then diff --git a/lua/differ/view.lua b/lua/differ/view.lua index f082ddc..bab7632 100644 --- a/lua/differ/view.lua +++ b/lua/differ/view.lua @@ -1356,12 +1356,19 @@ function View:_toggle_hunk(want_staged) ) end if self:_apply_hunk(idx, want_staged) then - self.staging.refresh() - self:_paint_staged() - self:_paint_cursorline() -- re-lift the cursor tint above the fresh staged fill + self:_after_staging() end end +-- repaint after a staging op: the panel counts, the staged shade, and the winbar +-- tally, which a `%!` winbar only redraws on its own when the cursor moves +function View:_after_staging() + self.staging.refresh() + self:_paint_staged() + self:_paint_cursorline() -- re-lift the cursor tint above the fresh staged fill + vim.cmd.redrawstatus({ bang = true }) +end + -- S: stage every hunk in the file, or, when they're all staged already (nothing left to -- do here), move on to the next file with something to stage. the file-level echo of s -- advancing past the last hunk, minus its in-file wrap: S leaves no hunk behind to come @@ -1403,9 +1410,7 @@ function View:_toggle_all(want_staged) end end if changed then - self.staging.refresh() - self:_paint_staged() - self:_paint_cursorline() -- re-lift the cursor tint above the fresh staged fill + self:_after_staging() end return changed end diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index e16ef57..4166ecd 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -2274,12 +2274,41 @@ describe(":Differ diff hunk staging", function() local none = winbar_of(v) p:close() assert.is_truthy(mixed:find(" 1 partial · hunk 1/1 ", 1, true)) - assert.is_truthy(whole:find(" 1 staged · hunk 1/1 ", 1, true)) + assert.is_truthy(whole:find(" hunk 1/1 ", 1, true)) + assert.is_nil(whole:find("staged ·", 1, true)) -- every hunk staged: no tally assert.is_truthy(none:find(" hunk 1/1 ", 1, true)) assert.is_nil(none:find("staged", 1, true)) assert.is_nil(none:find("partial", 1, true)) end) + -- the winbar as drawn on the screen grid rather than re-evaluated, so a stale one shows + local function drawn_winbar(v) + local win = v.columns[#v.columns].winid + vim.cmd("redraw") + local row, col = unpack(vim.api.nvim_win_get_position(win)) + local chars = {} + for c = col + 1, col + vim.api.nvim_win_get_width(win) do + chars[#chars + 1] = vim.fn.screenstring(row + 1, c) + end + return table.concat(chars) + end + + it("redraws the winbar tally as soon as a hunk is unstaged", function() + partly_staged_repo() + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + local v = view_in_origin(p) + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + local before = drawn_winbar(v) + v:unstage_hunk() + local after = drawn_winbar(v) + p:close() + assert.is_truthy(before:find("2 staged", 1, true)) + assert.is_truthy(after:find("1 staged", 1, true)) + end) + it("tallies staged hunks beside unstaged ones", function() partly_staged_repo() git_src.panel({ rev = {}, open_first = true }) From 148b035ebd532e8c285120629a1c317cf98923e3 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:49:46 +0100 Subject: [PATCH 12/93] feat: preview the commit from the panel on gs --- README.md | 1 + doc/differ.txt | 1 + lua/differ/config.lua | 1 + lua/differ/git/init.lua | 146 ++++++++++++++++++++++++++++++-- lua/differ/panel/init.lua | 16 ++-- lua/differ/view.lua | 5 +- test/nvim/git_spec.lua | 171 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 329 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 55048b6..8ef0b47 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,7 @@ require("differ").setup({ goto_file = "de", -- open the real file and close the session; in a pr it opens -- the real file in a new tab toggle_listing = "i", -- tree/flat list + commit_preview = "gs", -- panel: only the staged changes, each as HEAD↔index, and back close_node = "c", close_all = "C", open_all = "O", diff --git a/doc/differ.txt b/doc/differ.txt index 673c75f..e9138cf 100644 --- a/doc/differ.txt +++ b/doc/differ.txt @@ -209,6 +209,7 @@ you’re in doubt, `:Differ sidecar` reports which one is actually running. goto_file = "de", -- open the real file and close the session; in a pr it opens -- the real file in a new tab toggle_listing = "i", -- tree/flat list + commit_preview = "gs", -- panel: only the staged changes, each as HEAD↔index, and back close_node = "c", close_all = "C", open_all = "O", diff --git a/lua/differ/config.lua b/lua/differ/config.lua index f1d94f5..c0e4516 100644 --- a/lua/differ/config.lua +++ b/lua/differ/config.lua @@ -142,6 +142,7 @@ M.defaults = { details = "K", -- history: float the full commit message (subject + body) help = "g?", -- panel, history toggle_listing = "i", -- panel: toggle tree / name + commit_preview = "gs", -- panel: list only what a commit would take, each row HEAD↔index close_node = "c", -- panel: collapse the dir under the cursor; history: the commit close_all = "C", -- panel/history: collapse every dir / commit open_all = "O", -- panel/history: expand every dir / commit diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 1648687..ce63b4e 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -782,6 +782,40 @@ function M.status_sections(root) return sections, err end +-- the rows a commit would take, as one section: every path the index changes, counted +-- and lettered against HEAD. conflicts and untracked files hold nothing to commit +---@param root string +---@return differ.panel.Section[] sections, string|nil err +function M.staged_sections(root) + local out, err = git({ "status", "--porcelain=v1", "-z", "-uall" }, root) + local entries = rev.parse_status(out or "") + local counts = numstat({ "--cached" }, root) + local untracked = {} + for _, s in ipairs(entries) do + if s.x == "?" then + untracked[s.path] = true + end + end + local rows = {} + for _, s in ipairs(entries) do + local section = row_of(s) + if section ~= "Untracked" and section ~= "Conflicts" and s.x ~= " " then + local c = counts[s.path] or {} + rows[#rows + 1] = { + path = s.path, + status = s.x, + additions = c.additions or 0, + deletions = c.deletions or 0, + x = s.x, + y = s.y, + previous_path = s.previous_path, + kept = (s.x == "D" and untracked[s.path]) or nil, + } + end + end + return { { title = "Staged changes", entries = rows } }, err +end + -- file-level staging ops driven from the panel (slice C); each is whole-file -- and operates on the repo root. hunk-level staging stays in the diff view @@ -1243,28 +1277,37 @@ function M.panel(opts) -- against the one resolved source. a staged deletion diffs HEAD↔index, since its -- worktree copy is a row of its own. `actions` (file-level staging) is only -- meaningful for the worktree-status source + local preview = false -- gs: the panel lists only what a commit would take local sections, model_for, raw_args_for, actions local list_err ---@type string|nil -- git's own words when the listing failed if is_worktree_status(source) then sections, list_err = M.status_sections(root) model_for = function(entry) - local s = { old = HEAD, new = entry.x == "D" and INDEX or WORKTREE } + local s = { old = HEAD, new = (preview or entry.x == "D") and INDEX or WORKTREE } -- re-read HEAD per build so a branch switch under an open panel updates -- the synthetic buffer's statusline label, not just the diff content return M.model(s, root, entry, head_branch(root)) end -- the entry's own pair as `diff` args raw_args_for = function(entry) - return entry.x == "D" and { "--cached" } or { "HEAD" } + return (preview or entry.x == "D") and { "--cached" } or { "HEAD" } end actions = { stage = function(entry) + if preview then + notify("the commit preview only unstages: gs goes back", vim.log.levels.WARN) + return false + end set_staged(root, entry, true) end, unstage = function(entry) set_staged(root, entry, false) end, stage_all = function() + if preview then + notify("the commit preview only unstages: gs goes back", vim.log.levels.WARN) + return false + end M.stage_all(root) end, unstage_all = function() @@ -1274,7 +1317,7 @@ function M.panel(opts) M.discard(root, entry) end, reload = function() - local live = M.status_sections(root) + local live = preview and M.staged_sections(root) or M.status_sections(root) return (nonempty_sections(live)) end, } @@ -1304,6 +1347,7 @@ function M.panel(opts) local panel ---@type differ.Panel|nil -- forward ref so staging can refresh it local watcher ---@type differ.git.Watcher|nil -- fs watcher, set for worktree panels local retarget_view ---@type fun(outside: boolean): boolean -- assigned below + local set_preview ---@type fun(on: boolean) -- assigned below -- 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` @@ -1674,6 +1718,58 @@ function M.panel(opts) } end + -- whole-file staging frozen at the index the view opened on: u resets the row's + -- paths to HEAD, s puts back the entries the index held then + ---@param entry differ.FileEntry + ---@return differ.view.Staging + local function snapshot_staging(entry) + local paths = entry_paths(entry) + local held = {} ---@type table -- path -> update-index cacheinfo + local listed = git(vim.list_extend({ "ls-files", "-s", "-z", "--" }, paths), root) or "" + for mode, sha, path in listed:gmatch("(%d+) (%x+) %d+\t([^%z]+)") do + held[path] = ("%s,%s,%s"):format(mode, sha, path) + end + return { + initial = "staged", + whole_file = true, + refresh = refresh_panel, + apply = function(_, _, reverse) + if reverse then + return set_staged(root, entry, false) + end + local ok = true + for _, path in ipairs(paths) do + local cmd = { "update-index", "--force-remove", "--", path } + if held[path] then + cmd = { "update-index", "--add", "--cacheinfo", held[path] } + end + ok = git_ok(cmd, root, "staging " .. path) and ok + end + return ok + end, + } + end + + -- staging for a commit-preview row, HEAD↔index. an add or a deletion is one unit + -- whose index entry comes and goes, so it stages as a file + ---@param entry differ.FileEntry + ---@param model differ.DiffModel -- HEAD↔index + ---@return differ.view.Staging + local function preview_staging(entry, model) + local staging + if #model.hunks > 0 and entry.x ~= "A" and entry.x ~= "D" then + staging = frozen_staging(entry, model, true) + else + staging = snapshot_staging(entry) + end + staging.badge = "STAGED" + staging.no_local = "the commit preview has no local view: gs goes back" + staging.leave = function() + set_preview(false) + end + return staging + end + -- swap the open view onto `entry`'s local view, index↔worktree. back goes through -- retarget_view, which re-reads the row, since staging here can move it to another -- section @@ -1730,8 +1826,13 @@ function M.panel(opts) elseif entry.kept then model.banner = "still on disk, untracked: u tracks it again" end - local staging = stage_for(entry, model) - if staging and partly_staged(entry) then + local staging ---@type differ.view.Staging|nil + if preview then + staging = preview_staging(entry, model) + else + staging = stage_for(entry, model) + end + if staging and not preview and partly_staged(entry) then staging.toggle_local = function() show_local(entry) end @@ -1758,7 +1859,7 @@ function M.panel(opts) ---@return differ.FileEntry[] local function entries_for_path(path) local out = {} - local live = M.status_sections(root) + local live = preview and M.staged_sections(root) or M.status_sections(root) for _, sec in ipairs(live) do for _, e in ipairs(sec.entries) do if e.path == path then @@ -1799,6 +1900,26 @@ function M.panel(opts) return false end + -- gs: flip the panel between every change and the commit preview, then reopen the + -- file on screen in the new listing, else the nearest one + set_preview = function(on) + if on then + local _, staged_total = nonempty_sections((M.staged_sections(root))) + if staged_total == 0 then + return notify("nothing staged to preview") + end + end + preview = on + local path = active_entry and active_entry.path + panel:refresh() + if not panel:is_alive() then + return + end + if not (path and panel:goto_path(path, true)) then + panel:open_nearest(true) + end + end + -- after an external git change (lazygit, a tmux-pane commit, `:!git`): refresh the -- list, then re-source the open diff so it reflects the new state too rather than -- staying frozen. gated on the signature so unrelated terminal events are no-ops @@ -1841,6 +1962,7 @@ function M.panel(opts) -- defaults, else Panel.new's own hardcoded fallbacks local cfg = require("differ").get_config() local panel_cfg = cfg.panel or {} + local panel_keys = cfg.keymaps.panel or require("differ.config").defaults.keymaps local return_tab, session_tab = open_session_tab() panel = Panel.new({ sections = nonempty, @@ -1857,6 +1979,15 @@ function M.panel(opts) end end, keymaps = cfg.keymaps.panel --[[@as differ.KeymapSet]], + extra_keymaps = stageable and { + { + spec = panel_keys.commit_preview, + fn = function() + set_preview(not preview) + end, + desc = "commit preview: staged changes only", + }, + } or nil, listing = opts.listing or panel_cfg.listing, position = opts.position or panel_cfg.position, height = opts.height or panel_cfg.height, @@ -1883,6 +2014,9 @@ function M.panel(opts) -- empty sidebar next to a diff of a file that's now clean. only the worktree -- source can reach this; a rev-pair list never reloads on_empty = function() + if preview then + return set_preview(false) -- everything unstaged: back to the whole list + end notify("no changes left") local back = panel and panel.return_tab if panel then diff --git a/lua/differ/panel/init.lua b/lua/differ/panel/init.lua index 74527c0..4a4acd3 100644 --- a/lua/differ/panel/init.lua +++ b/lua/differ/panel/init.lua @@ -61,9 +61,9 @@ local STATUS_HL = { -- file-level staging hooks (slice C); supplied by the local frontend for the -- working-tree source, nil otherwise (rev-pair lists aren't stageable) ---@class differ.panel.Actions ----@field stage fun(entry: differ.FileEntry) +---@field stage fun(entry: differ.FileEntry): boolean|nil -- false: refused, nothing moved ---@field unstage fun(entry: differ.FileEntry) ----@field stage_all fun() +---@field stage_all fun(): boolean|nil -- false: refused, nothing moved ---@field unstage_all fun() ---@field discard fun(entry: differ.FileEntry) ---@field reload fun(): differ.panel.Section[] -- recompute sections after an op @@ -760,7 +760,9 @@ function Panel:stage_op(op) end local paths ---@type table|nil -- nil: the op moved every file if op == "stage_all" or op == "unstage_all" then - self.actions[op]() + if self.actions[op]() == false then + return + end else local entries = self:_op_targets() if #entries == 0 then @@ -768,8 +770,12 @@ function Panel:stage_op(op) end paths = {} for _, e in ipairs(entries) do - self.actions[op](e) - paths[e.path] = true + if self.actions[op](e) ~= false then + paths[e.path] = true + end + end + if next(paths) == nil then + return end end self:_after_stage_op(paths) diff --git a/lua/differ/view.lua b/lua/differ/view.lua index bab7632..d0cdec3 100644 --- a/lua/differ/view.lua +++ b/lua/differ/view.lua @@ -93,6 +93,7 @@ local armed_view = nil ---@field hidden? string -- union source: why some staged content isn't on screen ---@field hidden_in? integer[] -- the hunks marked `!`: they touch staged content the whole change can't show ---@field toggle_local? fun() -- a partly staged file: swap between the whole change and its local view +---@field no_local? string -- why dw does nothing here, in place of the default ---@field leave? fun() -- a frozen view: back to the whole change, whose new side is the file to edit ---@field badge? string -- winbar tag naming a view that isn't the whole change @@ -668,7 +669,9 @@ end function View:toggle_local() local toggle = self.staging and self.staging.toggle_local if not toggle then - return vim.notify("differ: only a partly staged file has a local view", vim.log.levels.INFO) + local why = self.staging and self.staging.no_local + or "only a partly staged file has a local view" + return vim.notify("differ: " .. why, vim.log.levels.INFO) end toggle() end diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index 4166ecd..4451e4f 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -2251,6 +2251,177 @@ describe(":Differ diff hunk staging", function() assert.are.equal("differ: only a partly staged file has a local view", msg) end) + -- gs, from the panel + local function toggle_preview(p) + p.extra_keymaps[1].fn() + end + local function titles(p) + local out = {} + for _, sec in ipairs(p.sections) do + out[#out + 1] = sec.title + end + return out + end + local function paths(p) + local out = {} + for _, sec in ipairs(p.sections) do + for _, e in ipairs(sec.entries) do + out[#out + 1] = e.path + end + end + table.sort(out) + return out + end + -- the partly staged a.lua plus a staged add c.lua (edited again) and an unstaged b.lua + local function preview_repo() + local root = partly_staged_repo() + write(root .. "/b.lua", "b\n") + git(root, "add", "b.lua") + git(root, "commit", "-q", "-m", "b", "--", "b.lua") -- a.lua's staged edits stay staged + write(root .. "/b.lua", "b2\n") + write(root .. "/c.lua", "one\n") + git(root, "add", "c.lua") + write(root .. "/c.lua", "one\ntwo\n") + return root + end + + it("gs lists only the staged changes, each diffing HEAD-to-index, and back", function() + preview_repo() + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + local before = titles(p) + toggle_preview(p) + local v = view_in_origin(p) + local in_titles, in_paths = titles(p), paths(p) + local path, revs = v.model.path, { v.model.old_rev, v.model.new_rev } + local state, text = v:_hunk_state(1), winbar_of(v) + toggle_preview(p) + local out_titles, out_rev = titles(p), v.model.new_rev + p:close() + assert.are.same({ "Staged changes" }, in_titles) + assert.are.same({ "a.lua", "c.lua" }, in_paths) + assert.are.equal("a.lua", path) -- the file on screen stays + assert.are.same({ "HEAD", "INDEX" }, revs) + assert.are.equal("staged", state) + assert.is_truthy(text:find(" STAGED ", 1, true)) + assert.is_truthy(text:find(" hunk 1/2 ", 1, true)) + assert.is_nil(text:find("staged ·", 1, true)) -- every hunk staged: no tally + assert.are.same(before, out_titles) + assert.are.equal("WORKTREE", out_rev) + end) + + -- frozen like the local view: the index is rebuilt from HEAD plus the hunks still + -- marked staged, so hunks come out in any order and go straight back + it("unstages in the commit preview in any order, and stages back in place", function() + local root = preview_repo() + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + toggle_preview(p) + local v = view_in_origin(p) + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 2), 0 }) + v:unstage_hunk() + local second_out = indexed(root, "a.lua") + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + v:unstage_hunk() + local both_out = indexed(root, "a.lua") + v:stage_hunk() + local first_back = indexed(root, "a.lua") + local hunks, rev_after = #v.model.hunks, v.model.new_rev + p:close() + assert.are.equal("1x\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n", second_out) + assert.are.equal(committed(root, "a.lua"), both_out) + assert.are.equal("1x\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n", first_back) + assert.are.equal(2, hunks) + assert.are.equal("INDEX", rev_after) + end) + + -- an add is one unit whose index entry goes and comes back: s restores what was + -- staged, not the worktree's copy + it("unstages an add in the commit preview and stages back what was staged", function() + local root = preview_repo() + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + toggle_preview(p) + assert.is_true(p:goto_path("c.lua", true)) + local v = view_in_origin(p) + v:unstage_hunk() + local status = git(root, "status", "--porcelain=v1", "--", "c.lua") + v:stage_hunk() + local back = indexed(root, "c.lua") + p:close() + assert.are.equal("?? c.lua\n", status) + assert.are.equal("one\n", back) + end) + + it("refuses s in the commit preview panel", function() + local root = preview_repo() + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + toggle_preview(p) + local before = indexed(root, "a.lua") + _G.notifs = {} + p:stage_op("stage_all") + local msg, after = _G.notifs[#_G.notifs].msg, indexed(root, "a.lua") + p:close() + assert.are.equal("differ: the commit preview only unstages: gs goes back", msg) + assert.are.equal(before, after) + end) + + it("returns to the whole list once the commit preview is unstaged", function() + preview_repo() + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + toggle_preview(p) + p:stage_op("unstage_all") + local alive, got = p:is_alive(), titles(p) + local rev_after = view_in_origin(p).model.new_rev + p:close() + assert.is_true(alive) + assert.are.same({ "Unstaged", "Untracked" }, got) + assert.are.equal("WORKTREE", rev_after) + end) + + it("df from the commit preview edits the file from the whole list", function() + preview_repo() + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + toggle_preview(p) + local v = view_in_origin(p) + v:edit_file() + local got, rev_after, win = titles(p), v.model.new_rev, v.edit_win + p:close() + assert.are.same({ "Partial", "Unstaged" }, got) + assert.are.equal("WORKTREE", rev_after) + assert.is_truthy(win) + end) + + it("dw and gs say why they do nothing", function() + preview_repo() + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + toggle_preview(p) + local v = view_in_origin(p) + _G.notifs = {} + v:toggle_local() + local dw_msg = _G.notifs[#_G.notifs].msg + p:close() + + local root = fresh_repo() + write(root .. "/a.lua", "local x = 2\nreturn x\n") + vim.cmd.edit(root .. "/a.lua") + git_src.panel({ rev = {}, open_first = true }) + p = Panel.current() + _G.notifs = {} + toggle_preview(p) + local gs_msg, got = _G.notifs[#_G.notifs].msg, titles(p) + p:close() + assert.are.equal("differ: the commit preview has no local view: gs goes back", dw_msg) + assert.are.equal("differ: nothing staged to preview", gs_msg) + assert.are.same({ "Unstaged" }, got) + end) + -- the winbar counts the hunks the index holds whole and in part; zero counts drop out it("tallies staged and partial hunks in the diff winbar", function() local root = fresh_repo() From 5709512d996449e2939e194b8584c7a6b0018b7c Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:37:19 +0100 Subject: [PATCH 13/93] feat: let u drop and X revert hunks in the dw local view --- lua/differ/git/init.lua | 70 ++++++++++++++++++++- lua/differ/view.lua | 20 +++++- test/nvim/git_spec.lua | 134 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 221 insertions(+), 3 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index ce63b4e..dbaab3e 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -1764,17 +1764,76 @@ function M.panel(opts) end staging.badge = "STAGED" staging.no_local = "the commit preview has no local view: gs goes back" + staging.revert_refusal = function() + return "X doesn't revert in the commit preview: gs goes back" + end staging.leave = function() set_preview(false) end return staging end + local show_local ---@type fun(entry: differ.FileEntry) + + -- u on a `!` hunk in the local view: the index takes HEAD's lines under it, dropping + -- the staged change the worktree undid, then the view re-reads against that index + ---@param entry differ.FileEntry + ---@param hunk differ.Hunk -- index↔worktree + ---@return boolean + local function drop_hidden(entry, hunk) + local _, cached = M.union_models(root, entry) + local a, b = extent(hunk, "old") + local sel = select_hunks(cached.hunks, "new", a, b, false) + local text, why = require("differ.model.apply").partial(cached, sel) + if not text then + notify(("this hunk can't be unstaged: %s"):format(why), vim.log.levels.WARN) + return false + end + if not put_index(entry, text) then + return false + end + refresh_panel() + show_local(entry) + return true + end + + -- X in the local view: the worktree hunk goes back to the index's version. a hunk + -- marked staged leaves the index first, so neither side keeps it; then the view + -- re-reads, since the frozen rebuild's worktree side has moved + ---@param entry differ.FileEntry + ---@param model differ.DiffModel -- index↔worktree + ---@param staging differ.view.Staging + ---@param idx integer + ---@return boolean + local function revert_local(entry, model, staging, idx) + local hunk = model.hunks[idx] + local marked = require("differ.model.marks").state(staging.marks, hunk) == "staged" + if marked and not (staging.apply and staging.apply(model, hunk, true)) then + return false + end + local p = patch.hunk(model.path, hunk, model.old_text, model.new_text, 0, "new") + local ok, err = M.apply_patch(root, p, true, "worktree") + reload_buffer(root, entry.path) + if not ok then + notify(("hunk revert failed: %s"):format(err or ""), vim.log.levels.ERROR) + return false + end + -- a last hunk leaves no local view, and the view hands over to the panel itself + if #model.hunks > 1 then + vim.schedule(function() + if view and view.staging == staging then + show_local(entry) + end + end) + end + return true + end + -- swap the open view onto `entry`'s local view, index↔worktree. back goes through -- retarget_view, which re-reads the row, since staging here can move it to another -- section ---@param entry differ.FileEntry - local function show_local(entry) + show_local = function(entry) if not (view and view:is_open()) then return end @@ -1787,7 +1846,14 @@ function M.panel(opts) if #model.hunks > 0 then staging = frozen_staging(entry, model, false) local _, cached = M.union_models(root, entry) - staging.hidden_in = require("differ.model.marks").restaged(model.hunks, cached.hunks) + local hidden_in = require("differ.model.marks").restaged(model.hunks, cached.hunks) + staging.hidden_in = hidden_in + staging.unstage_hidden = function(idx) + return drop_hidden(entry, model.hunks[idx]) + end + staging.revert = function(m, idx) + return revert_local(entry, m, staging, idx) + end else if not model.binary then model.notice = empty_notice(root, entry, model, {}) or "No local content change" diff --git a/lua/differ/view.lua b/lua/differ/view.lua index d0cdec3..601e7e0 100644 --- a/lua/differ/view.lua +++ b/lua/differ/view.lua @@ -96,6 +96,8 @@ local armed_view = nil ---@field no_local? string -- why dw does nothing here, in place of the default ---@field leave? fun() -- a frozen view: back to the whole change, whose new side is the file to edit ---@field badge? string -- winbar tag naming a view that isn't the whole change +---@field unstage_hidden? fun(idx: integer): boolean -- u on a `!` hunk: drop the staged change it undoes +---@field revert_refusal? fun(idx: integer): string|nil -- why X won't revert hunk `idx`, asked before the prompt ---@class differ.View ---@field columns differ.ViewColumn[] @@ -1269,11 +1271,23 @@ function View:unstage_hunk() local idx = self:_target_index() if idx and self:_hunk_state(idx) ~= "unstaged" then self:_toggle_hunk(false) + elseif idx and self:_drops_hidden(idx) then + self.staging.unstage_hidden(idx) else self:_step_review("prev") end end +-- whether u on unmarked hunk `idx` drops staged content the whole change can't show +---@param idx integer +---@return boolean +function View:_drops_hidden(idx) + if not self.staging.unstage_hidden then + return false + end + return vim.tbl_contains(self.staging.hidden_in or {}, idx) +end + -- hand the file-level half of the review walk to the panel: the next file with work -- left on the `staged` pair, cycling past the list ends. not View:step_file, which also -- serves ]f / [f and file history; the review flow needs a worktree-status panel (it's @@ -1466,11 +1480,15 @@ function View:revert_hunk() if not (self.can_stage and self.staging) then return vim.notify("differ: hunk revert isn't available here", vim.log.levels.WARN) end + local idx = self:_hunk_index_under_cursor() + local refusal = idx and self.staging.revert_refusal and self.staging.revert_refusal(idx) + if refusal then + return vim.notify("differ: " .. refusal, vim.log.levels.WARN) + end local revert = self.staging.revert if not revert then return vim.notify("differ: this hunk can't be reverted", vim.log.levels.WARN) end - local idx = self:_hunk_index_under_cursor() if not idx then return vim.notify("differ: no hunk under the cursor", vim.log.levels.WARN) end diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index 4451e4f..13a445f 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -2251,6 +2251,122 @@ describe(":Differ diff hunk staging", function() assert.are.equal("differ: only a partly staged file has a local view", msg) end) + -- HEAD's twelve numbered lines with `edits` (line -> text) applied + local function twelve(edits) + local out = {} + for n = 1, 12 do + out[n] = edits[n] or tostring(n) + end + return table.concat(out, "\n") .. "\n" + end + -- line 1 staged as 1x, then the worktree set to `work` + local function staged_then(work) + local root = fresh_repo() + write(root .. "/a.lua", twelve({})) + git(root, "commit", "-q", "-am", "twelve lines") + write(root .. "/a.lua", twelve({ "1x" })) + git(root, "add", "a.lua") + write(root .. "/a.lua", work) + vim.cmd.edit(root .. "/a.lua") + return root + end + -- the local view of the file :Differ opened, cursor on hunk `n` + local function local_view_at(n) + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + local v = view_in_origin(p) + v:toggle_local() + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, n), 0 }) + return p, v + end + + -- 1x staged and then put back to 1 in the worktree: the local hunk undoing it is `!` + it("u on a ! hunk in the local view drops the staged change it undoes", function() + local root = staged_then(twelve({ [12] = "12x" })) + local p, v = local_view_at(1) + local marked = v.staging.hidden_in + v:unstage_hunk() + local index, hunks, rev_after = indexed(root, "a.lua"), #v.model.hunks, v.model.old_rev + local after = v.staging.hidden_in + p:close() + assert.are.same({ 1 }, marked) + assert.are.equal(committed(root, "a.lua"), index) + assert.are.equal(1, hunks) -- only 12x is left to stage + assert.are.equal("INDEX", rev_after) -- still the local view + assert.are.same({}, after) + end) + + -- 1x staged and then edited to 1y: u gives the index HEAD's line, where s gives 1y + it("u on a ! hunk takes HEAD's lines, not the worktree's", function() + local root = staged_then(twelve({ "1y", [12] = "12x" })) + local p, v = local_view_at(1) + v:unstage_hunk() + local index = indexed(root, "a.lua") + p:close() + assert.are.equal(committed(root, "a.lua"), index) + end) + + -- where u drops the staged 1x, X keeps it: the put-back goes and the file holds 1x again + it("X on a ! hunk in the local view puts the staged line back into the file", function() + local root = staged_then(twelve({ [12] = "12x" })) + local p, v = local_view_at(1) + local opened = v.staging + local orig = vim.fn.confirm + vim.fn.confirm = function() + return 1 + end + v:revert_hunk() + vim.fn.confirm = orig + vim.wait(500, function() -- the local view re-reads on a schedule + return v.staging ~= opened + end) + local work, index = worktree(root, "a.lua"), indexed(root, "a.lua") + local hidden = v.staging.hidden_in + p:close() + assert.are.equal(twelve({ "1x", [12] = "12x" }), work) + assert.are.equal(twelve({ "1x" }), index) + assert.are.same({}, hidden) + end) + + it("X in the local view puts the worktree hunk back to the index's version", function() + local root = staged_then(twelve({ "1x", [6] = "6y", [12] = "12y" })) + local p, v = local_view_at(1) + local opened = v.staging + local orig = vim.fn.confirm + vim.fn.confirm = function() + return 1 + end + v:revert_hunk() + vim.fn.confirm = orig + vim.wait(500, function() -- the local view re-reads on a schedule + return v.staging ~= opened + end) + local work, index, hunks = worktree(root, "a.lua"), indexed(root, "a.lua"), #v.model.hunks + p:close() + assert.are.equal(twelve({ "1x", [12] = "12y" }), work) + assert.are.equal(twelve({ "1x" }), index) + assert.are.equal(1, hunks) + end) + + -- s put 6y in the index; throwing the hunk away takes it out of both + it("X on a hunk staged in the local view drops it from the index too", function() + local root = staged_then(twelve({ "1x", [6] = "6y", [12] = "12y" })) + local p, v = local_view_at(1) + v:stage_hunk() + local orig = vim.fn.confirm + vim.fn.confirm = function() + return 1 + end + v:revert_hunk() + vim.fn.confirm = orig + local work, index = worktree(root, "a.lua"), indexed(root, "a.lua") + p:close() + assert.are.equal(twelve({ "1x", [12] = "12y" }), work) + assert.are.equal(twelve({ "1x" }), index) + end) + -- gs, from the panel local function toggle_preview(p) p.extra_keymaps[1].fn() @@ -2397,6 +2513,24 @@ describe(":Differ diff hunk staging", function() assert.is_truthy(win) end) + it("X refuses in the commit preview", function() + local root = preview_repo() + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + toggle_preview(p) + local v = view_in_origin(p) + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + local before = indexed(root, "a.lua") + _G.notifs = {} + v:revert_hunk() + local msg, after = _G.notifs[#_G.notifs].msg, indexed(root, "a.lua") + p:close() + assert.are.equal("differ: X doesn't revert in the commit preview: gs goes back", msg) + assert.are.equal(before, after) + end) + it("dw and gs say why they do nothing", function() preview_repo() git_src.panel({ rev = {}, open_first = true }) From dabb0e2f77406188cec69a2b5c8e651d9c8321f9 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Fri, 11 Sep 2026 09:46:54 +0100 Subject: [PATCH 14/93] feat: revert staged hunks with X in the gs commit preview --- lua/differ/git/init.lua | 69 ++++++++++++++++++++++++++++++++++++--- lua/differ/view.lua | 7 +--- test/nvim/git_spec.lua | 71 ++++++++++++++++++++++++++++++++++++++--- 3 files changed, 132 insertions(+), 15 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index dbaab3e..9bb4dd6 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -931,8 +931,9 @@ end ---@param text string -- the unified diff to apply ---@param reverse boolean ---@param target? "index"|"worktree" -- default "index" +---@param check? boolean -- only test that it applies, writing nothing ---@return boolean ok, string|nil err -function M.apply_patch(root, text, reverse, target) +function M.apply_patch(root, text, reverse, target, check) local cmd = { "git", "apply", "--unidiff-zero", "--whitespace=nowarn" } if (target or "index") == "index" then cmd[#cmd + 1] = "--cached" @@ -940,6 +941,9 @@ function M.apply_patch(root, text, reverse, target) if reverse then cmd[#cmd + 1] = "--reverse" end + if check then + cmd[#cmd + 1] = "--check" + end cmd[#cmd + 1] = "-" local res = vim.system(cmd, { cwd = root, stdin = text, text = true }):wait() if res.code ~= 0 then @@ -1750,6 +1754,62 @@ function M.panel(opts) } end + -- X in the commit preview: the staged hunk leaves the index and the file both. the + -- file half is the same hunk reverse-applied, tried first, so a file that changed + -- those lines again refuses before the index moves; then the preview re-reads + ---@param entry differ.FileEntry + ---@param model differ.DiffModel -- HEAD↔index + ---@param staging differ.view.Staging + ---@param idx integer + ---@return boolean + local function revert_staged(entry, model, staging, idx) + local hunk = model.hunks[idx] + local p = patch.hunk(model.path, hunk, model.old_text, model.new_text, 0, "new") + if not M.apply_patch(root, p, true, "worktree", true) then + local msg = "the file has changed these lines since they were staged: nothing reverted" + notify(msg, vim.log.levels.WARN) + return false + end + local marked = require("differ.model.marks").state(staging.marks, hunk) == "staged" + if marked and not (staging.apply and staging.apply(model, hunk, true)) then + return false + end + local ok, err = M.apply_patch(root, p, true, "worktree") + reload_buffer(root, entry.path) + if not ok then + notify(("hunk revert failed: %s"):format(err or ""), vim.log.levels.ERROR) + return false + end + -- a last hunk leaves the row, and the view hands over to the panel itself + if #model.hunks > 1 then + vim.schedule(function() + if view and view.staging == staging then + retarget_view(false) + end + end) + end + return true + end + + -- X on a whole-file commit-preview row, and what it does to the file + ---@param entry differ.FileEntry + ---@return fun(): boolean revert, string label + local function whole_file_revert(entry) + local label = "puts it back as HEAD has it" + local revert = function() + return M.discard(root, entry) + end + if entry.x == "D" then + label = "restores the file" + revert = function() + return restore_deleted(entry) + end + elseif entry.x == "A" then + label = "deletes the file" + end + return revert, label + end + -- staging for a commit-preview row, HEAD↔index. an add or a deletion is one unit -- whose index entry comes and goes, so it stages as a file ---@param entry differ.FileEntry @@ -1759,14 +1819,15 @@ function M.panel(opts) local staging if #model.hunks > 0 and entry.x ~= "A" and entry.x ~= "D" then staging = frozen_staging(entry, model, true) + staging.revert = function(m, idx) + return revert_staged(entry, m, staging, idx) + end else staging = snapshot_staging(entry) + staging.revert, staging.revert_label = whole_file_revert(entry) end staging.badge = "STAGED" staging.no_local = "the commit preview has no local view: gs goes back" - staging.revert_refusal = function() - return "X doesn't revert in the commit preview: gs goes back" - end staging.leave = function() set_preview(false) end diff --git a/lua/differ/view.lua b/lua/differ/view.lua index 601e7e0..6f8f3f3 100644 --- a/lua/differ/view.lua +++ b/lua/differ/view.lua @@ -97,7 +97,6 @@ local armed_view = nil ---@field leave? fun() -- a frozen view: back to the whole change, whose new side is the file to edit ---@field badge? string -- winbar tag naming a view that isn't the whole change ---@field unstage_hidden? fun(idx: integer): boolean -- u on a `!` hunk: drop the staged change it undoes ----@field revert_refusal? fun(idx: integer): string|nil -- why X won't revert hunk `idx`, asked before the prompt ---@class differ.View ---@field columns differ.ViewColumn[] @@ -1480,15 +1479,11 @@ function View:revert_hunk() if not (self.can_stage and self.staging) then return vim.notify("differ: hunk revert isn't available here", vim.log.levels.WARN) end - local idx = self:_hunk_index_under_cursor() - local refusal = idx and self.staging.revert_refusal and self.staging.revert_refusal(idx) - if refusal then - return vim.notify("differ: " .. refusal, vim.log.levels.WARN) - end local revert = self.staging.revert if not revert then return vim.notify("differ: this hunk can't be reverted", vim.log.levels.WARN) end + local idx = self:_hunk_index_under_cursor() if not idx then return vim.notify("differ: no hunk under the cursor", vim.log.levels.WARN) end diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index 13a445f..75d624f 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -2513,7 +2513,21 @@ describe(":Differ diff hunk staging", function() assert.is_truthy(win) end) - it("X refuses in the commit preview", function() + -- confirm X's prompt, then wait out the re-read the preview schedules + local function revert_confirmed(v) + local opened = v.staging + local orig = vim.fn.confirm + vim.fn.confirm = function() + return 1 + end + v:revert_hunk() + vim.fn.confirm = orig + vim.wait(500, function() + return v.staging ~= opened + end) + end + + it("X in the commit preview drops a staged hunk from the index and the file", function() local root = preview_repo() git_src.panel({ rev = {}, open_first = true }) local p = Panel.current() @@ -2521,14 +2535,61 @@ describe(":Differ diff hunk staging", function() local v = view_in_origin(p) local col = v.columns[#v.columns] vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 2), 0 }) + revert_confirmed(v) + local index, work = indexed(root, "a.lua"), worktree(root, "a.lua") + local hunks, rev_after = #v.model.hunks, v.model.new_rev + p:close() + assert.are.equal(twelve({ "1x" }), index) + assert.are.equal(twelve({ "1x", [12] = "12x" }), work) + assert.are.equal(1, hunks) + assert.are.equal("INDEX", rev_after) -- still the preview + end) + + -- 1x staged and then edited to 1y: taking 1x out of the file would lose 1y + it("X in the commit preview refuses a hunk the file has changed since staging", function() + local root = staged_then(twelve({ "1y", [12] = "12x" })) + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + toggle_preview(p) + local v = view_in_origin(p) + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) - local before = indexed(root, "a.lua") _G.notifs = {} + local orig = vim.fn.confirm + vim.fn.confirm = function() + return 1 + end v:revert_hunk() - local msg, after = _G.notifs[#_G.notifs].msg, indexed(root, "a.lua") + vim.fn.confirm = orig + local msg = _G.notifs[#_G.notifs].msg + local index, work = indexed(root, "a.lua"), worktree(root, "a.lua") p:close() - assert.are.equal("differ: X doesn't revert in the commit preview: gs goes back", msg) - assert.are.equal(before, after) + local want = "differ: the file has changed these lines since they were staged: " + assert.are.equal(want .. "nothing reverted", msg) + assert.are.equal(twelve({ "1x" }), index) + assert.are.equal(twelve({ "1y", [12] = "12x" }), work) + end) + + it("X in the commit preview deletes a staged add", function() + local root = preview_repo() + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + toggle_preview(p) + assert.is_true(p:goto_path("c.lua", true)) + local v = view_in_origin(p) + local orig = vim.fn.confirm + vim.fn.confirm = function() + return 1 + end + v:revert_hunk() + vim.fn.confirm = orig + local on_disk = vim.fn.filereadable(root .. "/c.lua") + local status = git(root, "status", "--porcelain=v1", "--", "c.lua") + p:close() + assert.are.equal(0, on_disk) + assert.are.equal("", status) end) it("dw and gs say why they do nothing", function() From 4e916ea20b3b06d1eec9bd4b5893fbb7addab0d4 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:19:48 +0100 Subject: [PATCH 15/93] refactor: stage through one whole-hunk splice --- lua/differ/git/init.lua | 60 ++++------- lua/differ/model/apply.lua | 177 +++++---------------------------- lua/differ/model/diff.lua | 43 ++------ test/unit/model_apply_spec.lua | 84 +++++++++------- 4 files changed, 104 insertions(+), 260 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 9bb4dd6..2c881c7 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -1430,29 +1430,20 @@ function M.panel(opts) return at, at + n end - -- every line of the hunks in `hunks` that do (or don't) meet `[a, b)` on `side`. - -- whole hunks either way, so the partial apply never has to split one: at hunk - -- granularity the unit staged is already a hunk of the pair being patched + -- the indices of the hunks in `hunks` that do (or don't) meet `[a, b)` on `side` ---@param hunks differ.Hunk[] ---@param side "old"|"new" ---@param a integer ---@param b integer ---@param want boolean -- true picks the hunks that meet the region, false the rest - ---@return differ.model.Selection + ---@return table local function select_hunks(hunks, side, a, b, want) - local sel = { old = {}, new = {} } - for _, h in ipairs(hunks) do + local picked = {} + for i, h in ipairs(hunks) do local hs, he = extent(h, side) - if (hs < b and he > a) == want then - for k = 0, h.old_count - 1 do - sel.old[h.old_start + k] = true - end - for k = 0, h.new_count - 1 do - sel.new[h.new_start + k] = true - end - end + picked[i] = (hs < b and he > a) == want end - return sel + return picked end -- write `text` as `entry`'s staged content. an add left with nothing staged leaves @@ -1510,7 +1501,7 @@ function M.panel(opts) ---@return differ.view.Staging local function union_staging(entry) local marks = require("differ.model.marks") - local apply_text = require("differ.model.apply").partial + local splice = require("differ.model.apply").splice local mode_why ---@type string|nil if entry.x ~= " " and entry.y ~= " " and mode_hidden(entry) then mode_why = "the index holds a mode change" @@ -1544,18 +1535,13 @@ function M.panel(opts) return false end local union, cached, unstaged = M.union_models(root, entry) - local from, sel + local text if reverse then local a, b = extent(hunk, "old") - from, sel = cached, select_hunks(cached.hunks, "old", a, b, false) + text = splice(cached, select_hunks(cached.hunks, "old", a, b, false)) else local a, b = extent(hunk, "new") - from, sel = unstaged, select_hunks(unstaged.hunks, "new", a, b, true) - end - local text, why = apply_text(from, sel) - if not text then - notify(("this hunk can't be staged: %s"):format(why), vim.log.levels.WARN) - return false + text = splice(unstaged, select_hunks(unstaged.hunks, "new", a, b, true)) end if not put_index(entry, text) then return false @@ -1573,8 +1559,8 @@ function M.panel(opts) local union, cached = M.union_models(root, entry) local hunk = model.hunks[idx] local a, b = extent(hunk, "old") - local text = apply_text(cached, select_hunks(cached.hunks, "old", a, b, false)) - if text and text ~= cached.new_text and not put_index(entry, text) then + local text = splice(cached, select_hunks(cached.hunks, "old", a, b, false)) + if text ~= cached.new_text and not put_index(entry, text) then return false end local p = patch.hunk(model.path, hunk, model.old_text, model.new_text, 0, "new") @@ -1586,7 +1572,7 @@ function M.panel(opts) return false end local work = require("differ.model.diff").revert_hunk(model, idx).new_text - remark(union_pairs(root, entry.path, union.old_text, text or cached.new_text, work)) + remark(union_pairs(root, entry.path, union.old_text, text, work)) return true end return staging @@ -1688,7 +1674,8 @@ function M.panel(opts) ---@param staged boolean -- every hunk's opening state ---@return differ.view.Staging local function frozen_staging(entry, model, staged) - local apply_text = require("differ.model.apply").partial + local splice = require("differ.model.apply").splice + local state = require("differ.model.marks").state local marks = { old = {}, new = {} } ---@param h differ.Hunk ---@param on boolean @@ -1711,9 +1698,11 @@ function M.panel(opts) return false end mark(hunk, not reverse) - -- whole hunks only, so the rebuild never has to split one - local text = apply_text(model, marks) - if text and put_index(entry, text) then + local picked = {} + for i, h in ipairs(model.hunks) do + picked[i] = state(marks, h) == "staged" + end + if put_index(entry, splice(model, picked)) then return true end mark(hunk, reverse) @@ -1844,13 +1833,8 @@ function M.panel(opts) local function drop_hidden(entry, hunk) local _, cached = M.union_models(root, entry) local a, b = extent(hunk, "old") - local sel = select_hunks(cached.hunks, "new", a, b, false) - local text, why = require("differ.model.apply").partial(cached, sel) - if not text then - notify(("this hunk can't be unstaged: %s"):format(why), vim.log.levels.WARN) - return false - end - if not put_index(entry, text) then + local picked = select_hunks(cached.hunks, "new", a, b, false) + if not put_index(entry, require("differ.model.apply").splice(cached, picked)) then return false end refresh_panel() diff --git a/lua/differ/model/apply.lua b/lua/differ/model/apply.lua index 590a49c..71ae2e7 100644 --- a/lua/differ/model/apply.lua +++ b/lua/differ/model/apply.lua @@ -1,166 +1,43 @@ --- the old side's text with part of a diff applied: the text the index should hold once --- a selection of hunks is staged, written whole rather than patched in. --- --- a hunk carries two blocks and no correspondence between them, so a hunk picked only --- in part is splittable where the mapping is unambiguous: an insertion, a deletion, or --- a replacement of equal length. anything else is refused rather than guessed at. --- staging picks whole hunks; the split is for selections narrower than one. --- pure lua, no vim API +-- the text a diff's two sides splice into: each hunk takes its new block when picked +-- and its old block otherwise, with the unchanged lines between read from one side. +-- staging writes the result whole, so it only ever picks whole hunks; pure lua, no vim API local to_lines = require("differ.util.text").to_lines local M = {} ----@class differ.model.Selection ----@field old table|nil -- old-side line numbers picked ----@field new table|nil -- new-side line numbers picked - ----@param hunk differ.Hunk ----@param sel differ.model.Selection ----@return integer picked, integer total -local function tally(hunk, sel) - local picked, total = 0, hunk.old_count + hunk.new_count - for k = 0, hunk.old_count - 1 do - if sel.old[hunk.old_start + k] then - picked = picked + 1 - end - end - for k = 0, hunk.new_count - 1 do - if sel.new[hunk.new_start + k] then - picked = picked + 1 - end - end - return picked, total -end - --- what one hunk's block becomes. the second return says whether its last line came --- from the new side, which decides the file's terminator when the hunk sits at the end ----@param hunk differ.Hunk ----@param sel differ.model.Selection ----@return string[]|nil lines, boolean|string tail_from_new -- a reason in place of the flag on refusal -local function region(hunk, sel) - local picked, total = tally(hunk, sel) - if picked == 0 then - return hunk.old_lines, false - end - if picked == total then - return hunk.new_lines, true - end - local out = {} - if hunk.old_count == 0 then - for k = 1, hunk.new_count do - if sel.new[hunk.new_start + k - 1] then - out[#out + 1] = hunk.new_lines[k] - end - end - return out, true - end - if hunk.new_count == 0 then - for k = 1, hunk.old_count do - if not sel.old[hunk.old_start + k - 1] then - out[#out + 1] = hunk.old_lines[k] - end - end - return out, false - end - if hunk.old_count ~= hunk.new_count then - local why = "a hunk replacing %d lines with %d can't be split" - return nil, why:format(hunk.old_count, hunk.new_count) - end - -- equal blocks pair up by position, which is how a line edited in place reads on - -- screen. either side of a pair being picked takes the new line, so a selection - -- dragged over one column alone still means the change on that row - local from_new = false - for k = 1, hunk.old_count do - local take = sel.old[hunk.old_start + k - 1] or sel.new[hunk.new_start + k - 1] - out[#out + 1] = take and hunk.new_lines[k] or hunk.old_lines[k] - from_new = take or false - end - return out, from_new -end - ----@param text string ----@return boolean -local function terminated(text) - return text == "" or text:sub(-1) == "\n" -end - --- `model`'s old-side text with the lines `sel` names applied, or nil and a reason when --- the selection lands inside a hunk whose blocks don't pair up. an empty selection --- returns the old text, a full one the new text ---@param model differ.DiffModel ----@param sel differ.model.Selection|nil ----@return string|nil text, string|nil reason -function M.partial(model, sel) - sel = { old = (sel and sel.old) or {}, new = (sel and sel.new) or {} } - local old = to_lines(model.old_text) - local out, tail_new, hi = {}, false, 1 - - -- a hunk with no old lines sits after the line `old_start` names, so it is emitted - -- once that line has been, not in place of one - ---@param after integer - ---@return string|nil reason - local function insertions_after(after) - while true do - local h = model.hunks[hi] - if not (h and h.old_count == 0 and h.old_start == after) then - return nil - end - local lines, flag = region(h, sel) - if not lines then - return flag --[[@as string]] - end - for _, l in ipairs(lines) do - out[#out + 1] = l - end - if #lines > 0 then - tail_new = flag --[[@as boolean]] - end - hi = hi + 1 +---@param picked table -- hunk index -> take its new block +---@param base? "old"|"new" -- the side the unchanged lines and their ending come from; default "old" +---@return string +function M.splice(model, picked, base) + base = base or "old" + local lines = to_lines(base == "new" and model.new_text or model.old_text) + local out, next_line, tail = {}, 1, base + for i, h in ipairs(model.hunks) do + local start, count = h[base .. "_start"], h[base .. "_count"] + -- a hunk with no lines on `base` sits after the line `start` names + local first = count > 0 and start or start + 1 + for l = next_line, first - 1 do + out[#out + 1] = lines[l] end - end - - local reason = insertions_after(0) - if reason then - return nil, reason - end - local i = 1 - while i <= #old do - local h = model.hunks[hi] - if h and h.old_count > 0 and h.old_start == i then - local lines, flag = region(h, sel) - if not lines then - return nil, flag --[[@as string]] - end - for _, l in ipairs(lines) do - out[#out + 1] = l - end - if #lines > 0 then - tail_new = flag --[[@as boolean]] - end - i = i + h.old_count - hi = hi + 1 - else - out[#out + 1] = old[i] - tail_new = false - i = i + 1 + local side = picked[i] and "new" or "old" + for _, line in ipairs(h[side .. "_lines"]) do + out[#out + 1] = line end - reason = insertions_after(i - 1) - if reason then - return nil, reason + next_line = first + count + if next_line > #lines then + tail = side -- nothing follows the hunk, so the file ends as its side does end end - + for l = next_line, #lines do + out[#out + 1] = lines[l] + end if #out == 0 then return "" end - local keep_nl - if tail_new then - keep_nl = terminated(model.new_text) - else - keep_nl = terminated(model.old_text) - end - return table.concat(out, "\n") .. (keep_nl and "\n" or "") + local ending = tail == "new" and model.new_text or model.old_text + return table.concat(out, "\n") .. (ending:sub(-1) == "\n" and "\n" or "") end return M diff --git a/lua/differ/model/diff.lua b/lua/differ/model/diff.lua index a08423f..3412707 100644 --- a/lua/differ/model/diff.lua +++ b/lua/differ/model/diff.lua @@ -123,39 +123,6 @@ function M.empty_reason(model) return model.old_text == "" and "empty" or "identical" end --- the new side's text with hunk `idx` undone: its old lines put back where its new --- lines sit. only the new side ever moves, in both diff directions (a revert rewrites --- the worktree on an index↔worktree diff, the index on a head↔index one), so there's --- one case here rather than two ----@param model differ.DiffModel ----@param h differ.Hunk ----@return string -local function reverted_text(model, h) - local lines = to_lines(model.new_text) - -- a zero-count hunk occupies nothing on the new side, and `new_start` names the - -- line it sits *after*, so the restored lines go in after it rather than over it - local at = h.new_count > 0 and h.new_start or h.new_start + 1 - local out = {} - for i = 1, at - 1 do - out[#out + 1] = lines[i] - end - vim.list_extend(out, h.old_lines) - for i = at + h.new_count, #lines do - out[#out + 1] = lines[i] - end - - -- whichever side supplies the result's last line supplies its terminator too: - -- a hunk reaching the end of the new side leaves no tail behind it, so the old - -- side's ending wins. getting this wrong would re-diff as a phantom eof hunk - local last = h.new_count > 0 and (h.new_start + h.new_count - 1) or h.new_start - local source = last == #lines and model.old_text or model.new_text - local text = table.concat(out, "\n") - if #out > 0 and source:sub(-1) == "\n" then - text = text .. "\n" - end - return text -end - -- a model with hunk `idx` reverted on the new side. rebuilt from the spliced text -- rather than patched in place, so the hunk list can never drift from the text it -- describes; callers hold the result as their new frozen model. the rebuild @@ -165,13 +132,19 @@ end ---@param idx integer ---@return differ.DiffModel function M.revert_hunk(model, idx) - local h = assert(model.hunks[idx], "revert_hunk: no hunk at index " .. tostring(idx)) + assert(model.hunks[idx], "revert_hunk: no hunk at index " .. tostring(idx)) + -- only the new side moves: the worktree on an index↔worktree diff, the index on a + -- HEAD↔index one + local picked = {} + for i in ipairs(model.hunks) do + picked[i] = i ~= idx + end return M.build({ path = model.path, old_rev = model.old_rev, new_rev = model.new_rev, old_text = model.old_text, - new_text = reverted_text(model, h), + new_text = require("differ.model.apply").splice(model, picked, "new"), head = model.head, root = model.root, }) diff --git a/test/unit/model_apply_spec.lua b/test/unit/model_apply_spec.lua index e3dcc4f..c19729b 100644 --- a/test/unit/model_apply_spec.lua +++ b/test/unit/model_apply_spec.lua @@ -1,6 +1,6 @@ local apply = require("differ.model.apply") --- a model carrying only what partial() reads: the two texts and the hunks +-- a model carrying only what splice() reads: the two texts and the hunks local function model(old_text, new_text, hunks) return { path = "f", @@ -31,59 +31,69 @@ local function set(...) return out end -describe("partial apply", function() - -- index 1,2,3x,4,5 -> worktree 1,2,3x,4y,5y; stage only 4y - local edit = - model("1\n2\n3x\n4\n5\n", "1\n2\n3x\n4y\n5y\n", { h(4, { "4", "5" }, 4, { "4y", "5y" }) }) +describe("splice", function() + -- 1,2,3,4,5 -> 1,2x,3,4y,5y + local two = model( + "1\n2\n3\n4\n5\n", + "1\n2x\n3\n4y\n5y\n", + { h(2, { "2" }, 2, { "2x" }), h(4, { "4", "5" }, 4, { "4y", "5y" }) } + ) - it("takes one line of a replacement and leaves the rest", function() - assert.are.equal("1\n2\n3x\n4y\n5\n", apply.partial(edit, { new = set(4) })) + it("takes the picked hunk and leaves the other", function() + assert.are.equal("1\n2\n3\n4y\n5y\n", apply.splice(two, set(2))) end) - it("reads a pick on either side of a pair as that row", function() - assert.are.equal( - apply.partial(edit, { new = set(4) }), - apply.partial(edit, { old = set(4) }) - ) - end) - - it("returns the old text for an empty selection", function() - assert.are.equal(edit.old_text, apply.partial(edit, nil)) + it("returns the old text when nothing is picked", function() + assert.are.equal(two.old_text, apply.splice(two, {})) end) it("returns the new text when everything is picked", function() - assert.are.equal(edit.new_text, apply.partial(edit, { old = set(4, 5), new = set(4, 5) })) + assert.are.equal(two.new_text, apply.splice(two, set(1, 2))) end) - it("takes only the picked lines of an insertion", function() - -- a,b -> a,x,y,b: the hunk sits after old line 1 - local m = model("a\nb\n", "a\nx\ny\nb\n", { h(1, {}, 2, { "x", "y" }) }) - assert.are.equal("a\ny\nb\n", apply.partial(m, { new = set(3) })) + it("reads the unchanged lines from the new side", function() + assert.are.equal("1\n2\n3\n4y\n5y\n", apply.splice(two, set(2), "new")) end) - it("drops only the picked lines of a deletion", function() - -- a,b,c,d -> a,d: the hunk removes old lines 2 and 3 - local m = model("a\nb\nc\nd\n", "a\nd\n", { h(2, { "b", "c" }, 1, {}) }) - assert.are.equal("a\nc\nd\n", apply.partial(m, { old = set(2) })) + it("places an insertion after the line it follows", function() + local m = model("a\nb\n", "a\nx\ny\nb\n", { h(1, {}, 2, { "x", "y" }) }) + assert.are.equal(m.new_text, apply.splice(m, set(1))) end) it("inserts at the top of the file", function() local m = model("a\n", "x\na\n", { h(0, {}, 1, { "x" }) }) - assert.are.equal("x\na\n", apply.partial(m, { new = set(1) })) + assert.are.equal("x\na\n", apply.splice(m, set(1))) end) - -- the blocks carry no correspondence, so there is no honest way to say which of - -- three removed lines the one added line replaces - it("refuses a replacement whose blocks differ in length", function() - local m = model("a\nb\nc\n", "x\n", { h(1, { "a", "b", "c" }, 1, { "x" }) }) - local text, why = apply.partial(m, { new = set(1) }) - assert.is_nil(text) - assert.are.equal("a hunk replacing 3 lines with 1 can't be split", why) + it("puts back a deletion from the new side", function() + local m = model("a\nb\nc\n", "a\nc\n", { h(2, { "b" }, 1, {}) }) + assert.are.equal(m.old_text, apply.splice(m, {}, "new")) end) - it("keeps an unterminated old tail the selection doesn't reach", function() - local m = model("a\nb", "a\nB", { h(2, { "b" }, 2, { "B" }) }) - assert.are.equal("a\nb", apply.partial(m, nil)) - assert.are.equal("a\nB", apply.partial(m, { new = set(2) })) + describe("the final newline", function() + it("keeps an unterminated old tail no hunk reaches", function() + local m = model("a\nb\nc", "A\nb\nc\n", { h(1, { "a" }, 1, { "A" }) }) + assert.are.equal("A\nb\nc", apply.splice(m, set(1))) + end) + + it("takes the new side's ending when a picked hunk reaches eof", function() + local m = model("a\nb", "a\nB", { h(2, { "b" }, 2, { "B" }) }) + assert.are.equal("a\nB", apply.splice(m, set(1))) + end) + + it("ends terminated after deleting an unterminated last line", function() + local m = model("a\nb", "a\n", { h(2, { "b" }, 1, {}) }) + assert.are.equal("a\n", apply.splice(m, set(1))) + end) + + it("ends unterminated after deleting a last line when the new side does", function() + local m = model("a\nb\n", "a", { h(2, { "b" }, 1, {}) }) + assert.are.equal("a", apply.splice(m, set(1))) + end) + + it("empties the file when every line goes", function() + local m = model("a\nb\n", "", { h(1, { "a", "b" }, 0, {}) }) + assert.are.equal("", apply.splice(m, set(1))) + end) end) end) From 290df17238a34293dac4b792a1e181141ef429bf Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:29:09 +0100 Subject: [PATCH 16/93] fix: reach an insertion staged at the edge of a hunk --- lua/differ/git/init.lua | 53 +++++++++---------------- lua/differ/model/apply.lua | 10 ++--- lua/differ/model/diff.lua | 6 +-- lua/differ/model/marks.lua | 33 ++++++++++------ test/nvim/git_spec.lua | 71 ++++++++++++++++++++++++++++++++++ test/unit/model_apply_spec.lua | 8 ++-- test/unit/model_marks_spec.lua | 25 ++++++++++++ 7 files changed, 147 insertions(+), 59 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 2c881c7..5d9658f 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -1417,33 +1417,20 @@ function M.panel(opts) return true end - -- a hunk's extent on one side, widened to a single line where it has none, so a - -- pure insertion or deletion still overlaps the region it sits in - ---@param h differ.Hunk - ---@param side "old"|"new" - ---@return integer start, integer stop -- [start, stop) - local function extent(h, side) - local at, n = h[side .. "_start"], h[side .. "_count"] - if n == 0 then - return at, at + 1 - end - return at, at + n - end - - -- the indices of the hunks in `hunks` that do (or don't) meet `[a, b)` on `side` + -- the indices of the hunks in `hunks` that do (or don't) meet `anchor` ---@param hunks differ.Hunk[] - ---@param side "old"|"new" - ---@param a integer - ---@param b integer - ---@param want boolean -- true picks the hunks that meet the region, false the rest + ---@param side "old"|"new" -- the side of `hunks` compared + ---@param anchor differ.Hunk + ---@param anchor_side "old"|"new" + ---@param want boolean -- true picks the hunks that meet it, false the rest ---@return table - local function select_hunks(hunks, side, a, b, want) - local picked = {} + local function select_hunks(hunks, side, anchor, anchor_side, want) + local meets = require("differ.model.marks").meets + local applied = {} for i, h in ipairs(hunks) do - local hs, he = extent(h, side) - picked[i] = (hs < b and he > a) == want + applied[i] = meets(h, side, anchor, anchor_side) == want end - return picked + return applied end -- write `text` as `entry`'s staged content. an add left with nothing staged leaves @@ -1537,11 +1524,9 @@ function M.panel(opts) local union, cached, unstaged = M.union_models(root, entry) local text if reverse then - local a, b = extent(hunk, "old") - text = splice(cached, select_hunks(cached.hunks, "old", a, b, false)) + text = splice(cached, select_hunks(cached.hunks, "old", hunk, "old", false)) else - local a, b = extent(hunk, "new") - text = splice(unstaged, select_hunks(unstaged.hunks, "new", a, b, true)) + text = splice(unstaged, select_hunks(unstaged.hunks, "new", hunk, "new", true)) end if not put_index(entry, text) then return false @@ -1558,8 +1543,7 @@ function M.panel(opts) staging.revert = function(model, idx) local union, cached = M.union_models(root, entry) local hunk = model.hunks[idx] - local a, b = extent(hunk, "old") - local text = splice(cached, select_hunks(cached.hunks, "old", a, b, false)) + local text = splice(cached, select_hunks(cached.hunks, "old", hunk, "old", false)) if text ~= cached.new_text and not put_index(entry, text) then return false end @@ -1698,11 +1682,11 @@ function M.panel(opts) return false end mark(hunk, not reverse) - local picked = {} + local applied = {} for i, h in ipairs(model.hunks) do - picked[i] = state(marks, h) == "staged" + applied[i] = state(marks, h) == "staged" end - if put_index(entry, splice(model, picked)) then + if put_index(entry, splice(model, applied)) then return true end mark(hunk, reverse) @@ -1832,9 +1816,8 @@ function M.panel(opts) ---@return boolean local function drop_hidden(entry, hunk) local _, cached = M.union_models(root, entry) - local a, b = extent(hunk, "old") - local picked = select_hunks(cached.hunks, "new", a, b, false) - if not put_index(entry, require("differ.model.apply").splice(cached, picked)) then + local applied = select_hunks(cached.hunks, "new", hunk, "old", false) + if not put_index(entry, require("differ.model.apply").splice(cached, applied)) then return false end refresh_panel() diff --git a/lua/differ/model/apply.lua b/lua/differ/model/apply.lua index 71ae2e7..b29ae1d 100644 --- a/lua/differ/model/apply.lua +++ b/lua/differ/model/apply.lua @@ -1,16 +1,16 @@ --- the text a diff's two sides splice into: each hunk takes its new block when picked +-- the text a diff's two sides splice into: each hunk takes its new block when applied -- and its old block otherwise, with the unchanged lines between read from one side. --- staging writes the result whole, so it only ever picks whole hunks; pure lua, no vim API +-- staging writes the result whole, so it only ever applies whole hunks; pure lua, no vim API local to_lines = require("differ.util.text").to_lines local M = {} ---@param model differ.DiffModel ----@param picked table -- hunk index -> take its new block +---@param applied table -- hunk index -> take its new block ---@param base? "old"|"new" -- the side the unchanged lines and their ending come from; default "old" ---@return string -function M.splice(model, picked, base) +function M.splice(model, applied, base) base = base or "old" local lines = to_lines(base == "new" and model.new_text or model.old_text) local out, next_line, tail = {}, 1, base @@ -21,7 +21,7 @@ function M.splice(model, picked, base) for l = next_line, first - 1 do out[#out + 1] = lines[l] end - local side = picked[i] and "new" or "old" + local side = applied[i] and "new" or "old" for _, line in ipairs(h[side .. "_lines"]) do out[#out + 1] = line end diff --git a/lua/differ/model/diff.lua b/lua/differ/model/diff.lua index 3412707..6d66f26 100644 --- a/lua/differ/model/diff.lua +++ b/lua/differ/model/diff.lua @@ -135,16 +135,16 @@ function M.revert_hunk(model, idx) assert(model.hunks[idx], "revert_hunk: no hunk at index " .. tostring(idx)) -- only the new side moves: the worktree on an index↔worktree diff, the index on a -- HEAD↔index one - local picked = {} + local applied = {} for i in ipairs(model.hunks) do - picked[i] = i ~= idx + applied[i] = i ~= idx end return M.build({ path = model.path, old_rev = model.old_rev, new_rev = model.new_rev, old_text = model.old_text, - new_text = require("differ.model.apply").splice(model, picked, "new"), + new_text = require("differ.model.apply").splice(model, applied, "new"), head = model.head, root = model.root, }) diff --git a/lua/differ/model/marks.lua b/lua/differ/model/marks.lua index c939d67..260411b 100644 --- a/lua/differ/model/marks.lua +++ b/lua/differ/model/marks.lua @@ -89,16 +89,27 @@ local function span(h, side) return at, at + n end --- a hunk's extent on the old side, widened to a line where it has none, so a pure --- insertion still meets the change it sits in ----@param h differ.Hunk ----@return integer start, integer stop -- [start, stop) -local function reach(h) - local at, n = h.old_start, h.old_count - if n == 0 then - return at, at + 1 +-- whether two hunks meet on the sides given: lines in common, or an insertion that +-- sits inside the other's lines or at either edge of them. an insertion's start names +-- the line it follows +---@param a differ.Hunk +---@param a_side "old"|"new" +---@param b differ.Hunk +---@param b_side "old"|"new" +---@return boolean +function M.meets(a, a_side, b, b_side) + local as, an = a[a_side .. "_start"], a[a_side .. "_count"] + local bs, bn = b[b_side .. "_start"], b[b_side .. "_count"] + if an > 0 and bn > 0 then + return as < bs + bn and bs < as + an end - return at, at + n + if an == 0 and bn == 0 then + return as == bs + end + if an == 0 then + return as >= bs - 1 and as <= bs + bn - 1 + end + return bs >= as - 1 and bs <= as + an - 1 end -- the HEAD↔index hunks that touch a HEAD↔worktree hunk @@ -106,11 +117,9 @@ end ---@param cached differ.Hunk[] -- HEAD↔index ---@return differ.Hunk[] local function touching(h, cached) - local a, b = reach(h) local out = {} for _, c in ipairs(cached) do - local ca, cb = reach(c) - if ca < b and cb > a then + if M.meets(h, "old", c, "old") then out[#out + 1] = c end end diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index 75d624f..848f5ca 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -1997,6 +1997,77 @@ describe(":Differ diff hunk staging", function() p:close() end) + -- a staged insertion sits after line 2, and the edit below it starts at line 3: the + -- one hunk they merge into holds both, so u and X reach the insertion too + ---@param root string + ---@return differ.Panel, differ.View + local function insert_then_edit_below(root) + write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "commit", "-q", "-am", "ten lines") + write(root .. "/a.lua", "1\n2\nN\n3\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "add", "a.lua") + write(root .. "/a.lua", "1\n2\nN\n3x\n4\n5\n6\n7\n8\n9\n10\n") + vim.cmd.edit(root .. "/a.lua") + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + local v = view_in_origin(p) + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + return p, v + end + + it("unstages an insertion staged just above the hunk's edit", function() + local root = fresh_repo() + local p, v = insert_then_edit_below(root) + assert.are.equal("partial", v:_hunk_state(1)) + v:unstage_hunk() + local state, index = v:_hunk_state(1), indexed(root, "a.lua") + p:close() + assert.are.equal("unstaged", state) + assert.are.equal(committed(root, "a.lua"), index) + end) + + it("reverts an insertion staged just above the hunk's edit from both sides", function() + local root = fresh_repo() + local p, v = insert_then_edit_below(root) + local orig = vim.fn.confirm + vim.fn.confirm = function() + return 1 + end + v:revert_hunk() + vim.fn.confirm = orig + local work, index = worktree(root, "a.lua"), indexed(root, "a.lua") + p:close() + assert.are.equal(committed(root, "a.lua"), work) + assert.are.equal(committed(root, "a.lua"), index) + end) + + -- the worktree deletes line 3, just above the staged 4x: index↔worktree holds the + -- deletion as an insertion point that sits before the union hunk's first line + it("stages a deletion just above a staged edit", function() + local root = fresh_repo() + write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "commit", "-q", "-am", "ten lines") + write(root .. "/a.lua", "1\n2\n3\n4x\n5\n6\n7\n8\n9\n10\n") + git(root, "add", "a.lua") + write(root .. "/a.lua", "1\n2\n4x\n5\n6\n7\n8\n9\n10\n") + + vim.cmd.edit(root .. "/a.lua") + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + local v = view_in_origin(p) + assert.are.equal("partial", v:_hunk_state(1)) + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + v:stage_hunk() + local state, index = v:_hunk_state(1), indexed(root, "a.lua") + p:close() + assert.are.equal("staged", state) + assert.are.equal(worktree(root, "a.lua"), index) + end) + -- a partial hunk still holds something to unstage, so the backward walk stops on it it("walks u back onto a partial hunk", function() local root = fresh_repo() diff --git a/test/unit/model_apply_spec.lua b/test/unit/model_apply_spec.lua index c19729b..02e92e4 100644 --- a/test/unit/model_apply_spec.lua +++ b/test/unit/model_apply_spec.lua @@ -39,15 +39,15 @@ describe("splice", function() { h(2, { "2" }, 2, { "2x" }), h(4, { "4", "5" }, 4, { "4y", "5y" }) } ) - it("takes the picked hunk and leaves the other", function() + it("takes the applied hunk and leaves the other", function() assert.are.equal("1\n2\n3\n4y\n5y\n", apply.splice(two, set(2))) end) - it("returns the old text when nothing is picked", function() + it("returns the old text when nothing is applied", function() assert.are.equal(two.old_text, apply.splice(two, {})) end) - it("returns the new text when everything is picked", function() + it("returns the new text when everything is applied", function() assert.are.equal(two.new_text, apply.splice(two, set(1, 2))) end) @@ -76,7 +76,7 @@ describe("splice", function() assert.are.equal("A\nb\nc", apply.splice(m, set(1))) end) - it("takes the new side's ending when a picked hunk reaches eof", function() + it("takes the new side's ending when an applied hunk reaches eof", function() local m = model("a\nb", "a\nB", { h(2, { "b" }, 2, { "B" }) }) assert.are.equal("a\nB", apply.splice(m, set(1))) end) diff --git a/test/unit/model_marks_spec.lua b/test/unit/model_marks_spec.lua index 0059e21..dd9f39d 100644 --- a/test/unit/model_marks_spec.lua +++ b/test/unit/model_marks_spec.lua @@ -199,4 +199,29 @@ describe("union marks", function() assert.are.same({}, marks.restaged({ beside, apart }, cached)) end) end) + + describe("meets", function() + it("meets on shared lines and not on lines apart", function() + assert.is_true(marks.meets(h(3, 2, 3, 2), "old", h(4, 1, 4, 1), "old")) + assert.is_false(marks.meets(h(3, 1, 3, 1), "old", h(4, 1, 4, 1), "old")) + end) + + it("meets an insertion at either edge of its lines", function() + local lines = h(3, 1, 3, 2) -- HEAD line 3 + assert.is_true(marks.meets(h(2, 0, 3, 1), "old", lines, "old")) -- after line 2 + assert.is_true(marks.meets(lines, "old", h(3, 0, 4, 1), "old")) -- after line 3 + assert.is_false(marks.meets(h(1, 0, 2, 1), "old", lines, "old")) + assert.is_false(marks.meets(h(4, 0, 5, 1), "old", lines, "old")) + end) + + it("meets two insertions only at the same place", function() + assert.is_true(marks.meets(h(2, 0, 3, 1), "old", h(2, 0, 3, 2), "old")) + assert.is_false(marks.meets(h(2, 0, 3, 1), "old", h(3, 0, 4, 1), "old")) + end) + + it("compares the sides it's given", function() + -- index↔worktree deletes index line 3; HEAD↔index changes it + assert.is_true(marks.meets(h(3, 1, 2, 0), "old", h(3, 1, 3, 1), "new")) + end) + end) end) From cb2ae2cacc9fdfb43374af7169eff77c5f55d9dd Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:53:59 +0100 Subject: [PATCH 17/93] fix: refuse a hunk op that would reach the next hunk --- lua/differ/git/init.lua | 34 ++++++++++++++++++++++--- lua/differ/model/marks.lua | 21 ++++++++++++++++ test/nvim/git_spec.lua | 46 ++++++++++++++++++++++++++++++++++ test/unit/model_marks_spec.lua | 16 ++++++++++++ 4 files changed, 113 insertions(+), 4 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 5d9658f..e07e394 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -1516,18 +1516,41 @@ function M.panel(opts) end end + -- a pair hunk that also reaches another union hunk holds index-only content + -- between the two, and taking it whole would carry the op into that hunk + ---@param union differ.DiffModel + ---@param pair differ.DiffModel -- index↔worktree on the new side, HEAD↔index on the old + ---@param side "old"|"new" + ---@param hunk differ.Hunk + ---@return boolean + local function shared(union, pair, side, hunk) + local other = marks.shared_with(union.hunks, hunk, pair.hunks, side) + if not other then + return false + end + local what, where = "staged", "gs" + if side == "new" then + what, where = "unstaged", "dw" + end + local msg = "this hunk and hunk %d share one %s change: %s takes it whole" + notify(msg:format(other, what, where), vim.log.levels.WARN) + return true + end + remark(M.union_models(root, entry)) staging.apply = function(_, hunk, reverse) if not hunk then return false end local union, cached, unstaged = M.union_models(root, entry) - local text + local from, side = unstaged, "new" if reverse then - text = splice(cached, select_hunks(cached.hunks, "old", hunk, "old", false)) - else - text = splice(unstaged, select_hunks(unstaged.hunks, "new", hunk, "new", true)) + from, side = cached, "old" + end + if shared(union, from, side, hunk) then + return false end + local text = splice(from, select_hunks(from.hunks, side, hunk, side, not reverse)) if not put_index(entry, text) then return false end @@ -1543,6 +1566,9 @@ function M.panel(opts) staging.revert = function(model, idx) local union, cached = M.union_models(root, entry) local hunk = model.hunks[idx] + if shared(union, cached, "old", hunk) then + return false + end local text = splice(cached, select_hunks(cached.hunks, "old", hunk, "old", false)) if text ~= cached.new_text and not put_index(entry, text) then return false diff --git a/lua/differ/model/marks.lua b/lua/differ/model/marks.lua index 260411b..8b4019d 100644 --- a/lua/differ/model/marks.lua +++ b/lua/differ/model/marks.lua @@ -112,6 +112,27 @@ function M.meets(a, a_side, b, b_side) return bs >= as - 1 and bs <= as + an - 1 end +-- another union hunk that a pair hunk meeting `anchor` also meets, or nil. staging +-- takes pair hunks whole, so one of these would carry the op into that hunk too. +-- union hunks never meet each other, so the one `anchor` meets is itself +---@param union differ.Hunk[] +---@param anchor differ.Hunk +---@param pair differ.Hunk[] +---@param side "old"|"new" -- the side union and pair share +---@return integer|nil +function M.shared_with(union, anchor, pair, side) + for _, p in ipairs(pair) do + if M.meets(p, side, anchor, side) then + for j, u in ipairs(union) do + if M.meets(p, side, u, side) and not M.meets(u, side, anchor, side) then + return j + end + end + end + end + return nil +end + -- the HEAD↔index hunks that touch a HEAD↔worktree hunk ---@param h differ.Hunk -- HEAD↔worktree ---@param cached differ.Hunk[] -- HEAD↔index diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index 848f5ca..4fe29f1 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -2068,6 +2068,52 @@ describe(":Differ diff hunk staging", function() assert.are.equal(worktree(root, "a.lua"), index) end) + -- the index rewrote 2..6 as A..E and the worktree put 3 and 4 back: both pairs hold + -- one hunk across what shows as two, and B C sit only in the index + ---@param root string + ---@return differ.Panel, differ.View + local function shared_change(root) + write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n") + git(root, "commit", "-q", "-am", "seven lines") + write(root .. "/a.lua", "1\nA\nB\nC\nD\nE\n7\n") + git(root, "add", "a.lua") + write(root .. "/a.lua", "1\nA2\n3\n4\nD2\nE2\n7\n") + vim.cmd.edit(root .. "/a.lua") + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + local v = view_in_origin(p) + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + _G.notifs = {} + return p, v + end + + for _, case in ipairs({ + { key = "s", op = "stage_hunk", what = "unstaged change: dw" }, + { key = "u", op = "unstage_hunk", what = "staged change: gs" }, + { key = "X", op = "revert_hunk", what = "staged change: gs" }, + }) do + it(("refuses %s on a hunk sharing a change with the next"):format(case.key), function() + local root = fresh_repo() + local p, v = shared_change(root) + local before = { worktree(root, "a.lua"), indexed(root, "a.lua") } + local hunks = #v.model.hunks + local orig = vim.fn.confirm + vim.fn.confirm = function() + return 1 + end + v[case.op](v) + vim.fn.confirm = orig + local after = { worktree(root, "a.lua"), indexed(root, "a.lua") } + local said = (_G.notifs[#_G.notifs] or {}).msg or "" + p:close() + assert.are.equal(2, hunks) + assert.are.same(before, after) + assert.is_truthy(said:find("this hunk and hunk 2 share one " .. case.what, 1, true)) + end) + end + -- a partial hunk still holds something to unstage, so the backward walk stops on it it("walks u back onto a partial hunk", function() local root = fresh_repo() diff --git a/test/unit/model_marks_spec.lua b/test/unit/model_marks_spec.lua index dd9f39d..69549b2 100644 --- a/test/unit/model_marks_spec.lua +++ b/test/unit/model_marks_spec.lua @@ -219,6 +219,22 @@ describe("union marks", function() assert.is_false(marks.meets(h(2, 0, 3, 1), "old", h(3, 0, 4, 1), "old")) end) + -- HEAD 1..7, index 1 A B C D E 7, worktree 1 A2 3 4 D2 E2 7: one pair hunk each + -- way spans both union hunks, through the B C only the index holds + it("names the other union hunk a pair hunk reaches", function() + local union = { h(2, 1, 2, 1), h(5, 2, 5, 2) } + local pair = { h(2, 5, 2, 5) } + assert.are.equal(2, marks.shared_with(union, union[1], pair, "new")) + assert.are.equal(1, marks.shared_with(union, union[2], pair, "old")) + end) + + it("names no hunk when each pair hunk stays in one", function() + local union = { h(2, 1, 2, 1), h(5, 2, 5, 2) } + local pair = { h(2, 1, 2, 1), h(5, 1, 5, 1) } + assert.is_nil(marks.shared_with(union, union[1], pair, "new")) + assert.is_nil(marks.shared_with(union, union[2], pair, "new")) + end) + it("compares the sides it's given", function() -- index↔worktree deletes index line 3; HEAD↔index changes it assert.is_true(marks.meets(h(3, 1, 2, 0), "old", h(3, 1, 3, 1), "new")) From 9bb947f5fc2632d193c9fd9456e60a93ba5c26ec Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:04:53 +0100 Subject: [PATCH 18/93] fix: revert a staged hunk at the file's own line numbers --- lua/differ/git/init.lua | 83 ++++++++++++++-------------------- lua/differ/model/marks.lua | 30 ++++++++---- test/nvim/git_spec.lua | 61 ++++++++++++++++++++++++- test/unit/model_marks_spec.lua | 15 ++++++ 4 files changed, 128 insertions(+), 61 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index e07e394..6d58df4 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -1558,22 +1558,25 @@ function M.panel(opts) return true end -- X throws the hunk away on both sides at once: the index gives up whatever of - -- it it holds and the worktree gives up the rest. index first, so a worktree copy - -- that won't take the patch leaves the file merely unstaged rather than holding a - -- change the index no longer has. the worktree half goes through a patch, not a - -- write: the model's new side was read through the clean filter, so writing it - -- back would push that conversion to disk + -- it it holds and the worktree gives up the rest. the worktree half is checked + -- first, so a file that changed those lines refuses before the index moves. it + -- goes through a patch, not a write: the model's new side was read through the + -- clean filter, so writing it back would push that conversion to disk staging.revert = function(model, idx) local union, cached = M.union_models(root, entry) local hunk = model.hunks[idx] if shared(union, cached, "old", hunk) then return false end + local p = patch.hunk(model.path, hunk, model.old_text, model.new_text, 0, "new") + if not M.apply_patch(root, p, true, "worktree", true) then + notify("the file has changed these lines: nothing reverted", vim.log.levels.WARN) + return false + end local text = splice(cached, select_hunks(cached.hunks, "old", hunk, "old", false)) if text ~= cached.new_text and not put_index(entry, text) then return false end - local p = patch.hunk(model.path, hunk, model.old_text, model.new_text, 0, "new") local ok, err = M.apply_patch(root, p, true, "worktree") reload_buffer(root, entry.path) if not ok then @@ -1753,20 +1756,23 @@ function M.panel(opts) } end - -- X in the commit preview: the staged hunk leaves the index and the file both. the - -- file half is the same hunk reverse-applied, tried first, so a file that changed - -- those lines again refuses before the index moves; then the preview re-reads + -- X in a frozen view (the commit preview, the local view): the hunk leaves the index + -- if marked staged, and the file takes its old lines back. the file half is checked + -- first, so a file that changed those lines refuses before the index moves; then + -- `reopen` re-reads the view. a last hunk leaves nothing to reopen, and the view + -- hands over to the panel itself ---@param entry differ.FileEntry - ---@param model differ.DiffModel -- HEAD↔index + ---@param model differ.DiffModel ---@param staging differ.view.Staging ---@param idx integer + ---@param offset integer -- the model's new-side lines to the file's, see patch.hunk + ---@param reopen fun() ---@return boolean - local function revert_staged(entry, model, staging, idx) + local function revert_frozen(entry, model, staging, idx, offset, reopen) local hunk = model.hunks[idx] - local p = patch.hunk(model.path, hunk, model.old_text, model.new_text, 0, "new") + local p = patch.hunk(model.path, hunk, model.old_text, model.new_text, offset, "new") if not M.apply_patch(root, p, true, "worktree", true) then - local msg = "the file has changed these lines since they were staged: nothing reverted" - notify(msg, vim.log.levels.WARN) + notify("the file has changed these lines: nothing reverted", vim.log.levels.WARN) return false end local marked = require("differ.model.marks").state(staging.marks, hunk) == "staged" @@ -1779,11 +1785,10 @@ function M.panel(opts) notify(("hunk revert failed: %s"):format(err or ""), vim.log.levels.ERROR) return false end - -- a last hunk leaves the row, and the view hands over to the panel itself if #model.hunks > 1 then vim.schedule(function() if view and view.staging == staging then - retarget_view(false) + reopen() end end) end @@ -1818,8 +1823,16 @@ function M.panel(opts) local staging if #model.hunks > 0 and entry.x ~= "A" and entry.x ~= "D" then staging = frozen_staging(entry, model, true) + -- the model's new side is the index, so its lines move by whatever the + -- worktree has added or dropped above them since staging.revert = function(m, idx) - return revert_staged(entry, m, staging, idx) + local h = m.hunks[idx] + local _, _, unstaged = M.union_models(root, entry) + local at = h.new_count > 0 and h.new_start or h.new_start + 1 + local offset = require("differ.model.marks").shift(unstaged.hunks, at, "old") + return revert_frozen(entry, m, staging, idx, offset, function() + retarget_view(false) + end) end else staging = snapshot_staging(entry) @@ -1851,38 +1864,6 @@ function M.panel(opts) return true end - -- X in the local view: the worktree hunk goes back to the index's version. a hunk - -- marked staged leaves the index first, so neither side keeps it; then the view - -- re-reads, since the frozen rebuild's worktree side has moved - ---@param entry differ.FileEntry - ---@param model differ.DiffModel -- index↔worktree - ---@param staging differ.view.Staging - ---@param idx integer - ---@return boolean - local function revert_local(entry, model, staging, idx) - local hunk = model.hunks[idx] - local marked = require("differ.model.marks").state(staging.marks, hunk) == "staged" - if marked and not (staging.apply and staging.apply(model, hunk, true)) then - return false - end - local p = patch.hunk(model.path, hunk, model.old_text, model.new_text, 0, "new") - local ok, err = M.apply_patch(root, p, true, "worktree") - reload_buffer(root, entry.path) - if not ok then - notify(("hunk revert failed: %s"):format(err or ""), vim.log.levels.ERROR) - return false - end - -- a last hunk leaves no local view, and the view hands over to the panel itself - if #model.hunks > 1 then - vim.schedule(function() - if view and view.staging == staging then - show_local(entry) - end - end) - end - return true - end - -- swap the open view onto `entry`'s local view, index↔worktree. back goes through -- retarget_view, which re-reads the row, since staging here can move it to another -- section @@ -1906,7 +1887,9 @@ function M.panel(opts) return drop_hidden(entry, model.hunks[idx]) end staging.revert = function(m, idx) - return revert_local(entry, m, staging, idx) + return revert_frozen(entry, m, staging, idx, 0, function() + show_local(entry) + end) end else if not model.binary then diff --git a/lua/differ/model/marks.lua b/lua/differ/model/marks.lua index 8b4019d..d868cff 100644 --- a/lua/differ/model/marks.lua +++ b/lua/differ/model/marks.lua @@ -242,20 +242,32 @@ function M.restaged(unstaged, cached) return out end --- the HEAD line an index line sits at, for a line HEAD↔index leaves alone ----@param cached differ.Hunk[] -- HEAD↔index ----@param lnum integer -- index line +-- how far line `lnum` on side `from` moves on the other side: the net lines the hunks +-- ending before it add there +---@param hunks differ.Hunk[] +---@param lnum integer +---@param from "old"|"new" ---@return integer -local function head_line(cached, lnum) - local shift = 0 - for _, h in ipairs(cached) do - local last = h.new_count == 0 and h.new_start or h.new_start + h.new_count - 1 +function M.shift(hunks, lnum, from) + local to = from == "old" and "new" or "old" + local out = 0 + for _, h in ipairs(hunks) do + local start, count = h[from .. "_start"], h[from .. "_count"] + local last = count == 0 and start or start + count - 1 if last >= lnum then break end - shift = shift + h.new_count - h.old_count + out = out + h[to .. "_count"] - count end - return lnum - shift + return out +end + +-- the HEAD line an index line sits at, for a line HEAD↔index leaves alone +---@param cached differ.Hunk[] -- HEAD↔index +---@param lnum integer -- index line +---@return integer +local function head_line(cached, lnum) + return lnum + M.shift(cached, lnum, "new") end -- whether a HEAD↔worktree diff shows all of a file's half-staged content. it cannot diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index 4fe29f1..f73da6d 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -2114,6 +2114,39 @@ describe(":Differ diff hunk staging", function() end) end + -- the file is edited under the open view: X can't take the hunk out of it, so the + -- index keeps its half too rather than dropping it alone + it("X leaves the index alone when the file won't take the revert", function() + local root = fresh_repo() + write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "commit", "-q", "-am", "ten lines") + write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "add", "a.lua") + write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10x\n") + vim.cmd.edit(root .. "/a.lua") + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + local v = view_in_origin(p) + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + local edited = "1z\n2\n3\n4\n5\n6\n7\n8\n9\n10x\n" + write(root .. "/a.lua", edited) + _G.notifs = {} + local orig = vim.fn.confirm + vim.fn.confirm = function() + return 1 + end + v:revert_hunk() + vim.fn.confirm = orig + local said = (_G.notifs[#_G.notifs] or {}).msg + local index, work = indexed(root, "a.lua"), worktree(root, "a.lua") + p:close() + assert.are.equal("differ: the file has changed these lines: nothing reverted", said) + assert.are.equal("1x\n2\n3\n4\n5\n6\n7\n8\n9\n10\n", index) + assert.are.equal(edited, work) + end) + -- a partial hunk still holds something to unstage, so the backward walk stops on it it("walks u back onto a partial hunk", function() local root = fresh_repo() @@ -2683,12 +2716,36 @@ describe(":Differ diff hunk staging", function() local msg = _G.notifs[#_G.notifs].msg local index, work = indexed(root, "a.lua"), worktree(root, "a.lua") p:close() - local want = "differ: the file has changed these lines since they were staged: " - assert.are.equal(want .. "nothing reverted", msg) + assert.are.equal("differ: the file has changed these lines: nothing reverted", msg) assert.are.equal(twelve({ "1x" }), index) assert.are.equal(twelve({ "1y", [12] = "12x" }), work) end) + -- the index drops l6 l7 and the worktree does too, with three lines added on top: + -- the deletion's index line number is three short of where it sits in the file + it("X in the commit preview puts a staged deletion back where the file has it", function() + local root = fresh_repo() + local head = "l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\n" + write(root .. "/a.lua", head) + git(root, "commit", "-q", "-am", "ten lines") + write(root .. "/a.lua", "l1\nl2\nl3\nl4\nl5\nl8\nl9\nl10\n") + git(root, "add", "a.lua") + write(root .. "/a.lua", "n1\nn2\nn3\nl1\nl2\nl3\nl4\nl5\nl8\nl9\nl10\n") + vim.cmd.edit(root .. "/a.lua") + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + toggle_preview(p) + local v = view_in_origin(p) + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + revert_confirmed(v) + local index, work = indexed(root, "a.lua"), worktree(root, "a.lua") + p:close() + assert.are.equal(head, index) + assert.are.equal("n1\nn2\nn3\n" .. head, work) + end) + it("X in the commit preview deletes a staged add", function() local root = preview_repo() git_src.panel({ rev = {}, open_first = true }) diff --git a/test/unit/model_marks_spec.lua b/test/unit/model_marks_spec.lua index 69549b2..e932323 100644 --- a/test/unit/model_marks_spec.lua +++ b/test/unit/model_marks_spec.lua @@ -240,4 +240,19 @@ describe("union marks", function() assert.is_true(marks.meets(h(3, 1, 2, 0), "old", h(3, 1, 3, 1), "new")) end) end) + + describe("shift", function() + -- three lines inserted at the top, and line 5 replaced by two + local hunks = { h(0, 0, 1, 3), h(5, 1, 8, 2) } + + it("moves a line by the hunks that end before it", function() + assert.are.equal(3, marks.shift(hunks, 1, "old")) + assert.are.equal(4, marks.shift(hunks, 6, "old")) + assert.are.equal(-3, marks.shift(hunks, 4, "new")) + end) + + it("leaves out a hunk that starts on the line", function() + assert.are.equal(3, marks.shift(hunks, 5, "old")) + end) + end) end) From eb1f4c0cc937e3183c78d34d6b027783b7e3dff8 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:10:35 +0100 Subject: [PATCH 19/93] fix: refuse frozen staging over an index changed outside differ --- lua/differ/git/init.lua | 28 ++++++++++++++++++++++----- test/nvim/git_spec.lua | 42 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 6d58df4..a9284dd 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -1681,14 +1681,21 @@ function M.panel(opts) -- staging frozen at the index the view opened on: s and u rewrite the index as the -- model's old side plus the hunks marked staged, so a marked hunk stays on screen - -- and the opposite key puts it back + -- and the opposite key puts it back. an index written outside differ since then + -- would be overwritten by that rebuild, so s and u refuse and `reopen` re-reads ---@param entry differ.FileEntry ---@param model differ.DiffModel -- read once, with at least one hunk ---@param staged boolean -- every hunk's opening state + ---@param reopen fun() ---@return differ.view.Staging - local function frozen_staging(entry, model, staged) + local function frozen_staging(entry, model, staged, reopen) local splice = require("differ.model.apply").splice local state = require("differ.model.marks").state + -- the commit preview opens staged with the index as its new side; the local view + -- opens unstaged with it as its old side + local held = staged and model.new_text or model.old_text + -- a rename only the worktree has made leaves the index at the old path + local at_index = entry.y == "R" and entry.previous_path or entry.path local marks = { old = {}, new = {} } ---@param h differ.Hunk ---@param on boolean @@ -1710,12 +1717,19 @@ function M.panel(opts) if not hunk then return false end + if (M.read(INDEX, root, at_index) or "") ~= held then + notify("the index changed outside differ: re-reading", vim.log.levels.WARN) + vim.schedule(reopen) + return false + end mark(hunk, not reverse) local applied = {} for i, h in ipairs(model.hunks) do applied[i] = state(marks, h) == "staged" end - if put_index(entry, splice(model, applied)) then + local text = splice(model, applied) + if put_index(entry, text) then + held, at_index = text, entry.path -- put_index stages a rename at the new path return true end mark(hunk, reverse) @@ -1822,7 +1836,9 @@ function M.panel(opts) local function preview_staging(entry, model) local staging if #model.hunks > 0 and entry.x ~= "A" and entry.x ~= "D" then - staging = frozen_staging(entry, model, true) + staging = frozen_staging(entry, model, true, function() + retarget_view(false) + end) -- the model's new side is the index, so its lines move by whatever the -- worktree has added or dropped above them since staging.revert = function(m, idx) @@ -1879,7 +1895,9 @@ function M.panel(opts) local model = M.model({ old = INDEX, new = WORKTREE }, root, file, head_branch(root)) local staging ---@type differ.view.Staging if #model.hunks > 0 then - staging = frozen_staging(entry, model, false) + staging = frozen_staging(entry, model, false, function() + show_local(entry) + end) local _, cached = M.union_models(root, entry) local hidden_in = require("differ.model.marks").restaged(model.hunks, cached.hunks) staging.hidden_in = hidden_in diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index f73da6d..fadfdc4 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -2420,6 +2420,14 @@ describe(":Differ diff hunk staging", function() vim.cmd.edit(root .. "/a.lua") return root end + -- stage `content` as a.lua from outside differ, leaving the worktree file alone + local function stage_behind(root, content) + local blob = vim.fn.tempname() + write(blob, content) + local sha = vim.trim(git(root, "hash-object", "-w", blob)) + os.remove(blob) + git(root, "update-index", "--cacheinfo", "100644," .. sha .. ",a.lua") + end -- the local view of the file :Differ opened, cursor on hunk `n` local function local_view_at(n) git_src.panel({ rev = {}, open_first = true }) @@ -2480,6 +2488,21 @@ describe(":Differ diff hunk staging", function() assert.are.same({}, hidden) end) + -- the rebuild starts from the index the view opened on, so a write made outside + -- differ since then would be lost to it + it("s in the local view refuses an index staged from outside since it opened", function() + local root = staged_then(twelve({ "1x", [12] = "12x" })) + local p, v = local_view_at(1) + stage_behind(root, twelve({ "1x", "2x" })) + _G.notifs = {} + v:stage_hunk() + local said = (_G.notifs[#_G.notifs] or {}).msg + local index = indexed(root, "a.lua") + p:close() + assert.are.equal("differ: the index changed outside differ: re-reading", said) + assert.are.equal(twelve({ "1x", "2x" }), index) + end) + it("X in the local view puts the worktree hunk back to the index's version", function() local root = staged_then(twelve({ "1x", [6] = "6y", [12] = "12y" })) local p, v = local_view_at(1) @@ -2746,6 +2769,25 @@ describe(":Differ diff hunk staging", function() assert.are.equal("n1\nn2\nn3\n" .. head, work) end) + it("u in the commit preview refuses an index staged from outside since it opened", function() + local root = staged_then(twelve({ "1x", [12] = "12x" })) + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + toggle_preview(p) + local v = view_in_origin(p) + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + stage_behind(root, twelve({ "1x", "2x" })) + _G.notifs = {} + v:unstage_hunk() + local said = (_G.notifs[#_G.notifs] or {}).msg + local index = indexed(root, "a.lua") + p:close() + assert.are.equal("differ: the index changed outside differ: re-reading", said) + assert.are.equal(twelve({ "1x", "2x" }), index) + end) + it("X in the commit preview deletes a staged add", function() local root = preview_repo() git_src.panel({ rev = {}, open_first = true }) From 93df02ca9a8f3b36183a4c83999cf82a8430bd59 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:17:48 +0100 Subject: [PATCH 20/93] fix: pass git literal paths and read symlinks as their target --- lua/differ/git/init.lua | 15 ++++++++-- test/nvim/git_spec.lua | 64 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index a9284dd..849d401 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -82,6 +82,9 @@ local function run(cmd, opts) return obj:wait() end +-- every path differ hands git is a real file name, never a glob +local GIT = { "git", "--literal-pathspecs" } + -- run git in `cwd`. returns stdout on success, or nil + stderr on failure. -- `text = true` normalises `\r\n` to `\n` in stdout, which is right for plumbing -- output (status/numstat/rev-parse/name-status/...) but would corrupt file @@ -91,7 +94,7 @@ end ---@param opts? { timeout?: integer, env?: table } ---@return string|nil stdout, string|nil stderr local function git(args, cwd, opts) - local cmd = { "git" } + local cmd = vim.list_extend({}, GIT) vim.list_extend(cmd, args) opts = opts or {} local res, err = run(cmd, { @@ -125,7 +128,7 @@ end ---@param cwd string ---@return string|nil stdout, string|nil stderr local function git_raw(args, cwd) - local cmd = { "git" } + local cmd = vim.list_extend({}, GIT) vim.list_extend(cmd, args) local res, err = run(cmd, { cwd = cwd }) if not res then @@ -240,6 +243,11 @@ end ---@param abs string ---@return string|nil local function read_file(abs) + local uv = vim.uv or vim.loop + local st = uv.fs_lstat(abs) + if st and st.type == "link" then + return uv.fs_readlink(abs) -- git stores a symlink as its target path + end if vim.fn.filereadable(abs) == 0 then return nil end @@ -290,7 +298,8 @@ local function as_staged(root, relpath, data) dst:close() end local env = { GIT_INDEX_FILE = tmp } - local add = vim.system({ "git", "add", "--", relpath }, { cwd = root, env = env }):wait() + local add_cmd = vim.list_extend(vim.list_extend({}, GIT), { "add", "--", relpath }) + local add = vim.system(add_cmd, { cwd = root, env = env }):wait() if add.code ~= 0 then os.remove(tmp) return data diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index fadfdc4..d326044 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -75,6 +75,40 @@ describe("git.read / changed_files", function() assert.are.equal("a\r\nb\r\nc\r\n", git_src.read(head, root, "f.txt")) assert.are.equal("a\r\nB\r\nc\r\n", git_src.read(index, root, "f.txt")) end) + + -- git stores a symlink as the path it points at, not the contents behind it + it("reads a worktree symlink as its target path", function() + local root = fresh_repo() + write(root .. "/b.lua", "local y = 1\n") + vim.uv.fs_symlink("a.lua", root .. "/l") + git(root, "add", "b.lua", "l") + git(root, "commit", "-q", "-m", "link") + os.remove(root .. "/l") + vim.uv.fs_symlink("b.lua", root .. "/l") + + local wt = { kind = "worktree", label = "WORKTREE" } + local index = { kind = "index", label = "INDEX" } + assert.are.equal("a.lua", git_src.read(index, root, "l")) + assert.are.equal("b.lua", git_src.read(wt, root, "l")) + end) +end) + +-- a file name with glob characters, next to a file its glob matches +describe("git pathspecs", function() + it("unstages only the file named, not the files its glob matches", function() + local root = fresh_repo() + write(root .. "/a1.txt", "one\n") + write(root .. "/a[1].txt", "bracket\n") + git(root, "add", "a1.txt", "a[1].txt") + git(root, "commit", "-q", "-m", "two") + write(root .. "/a1.txt", "one staged\n") + write(root .. "/a[1].txt", "bracket staged\n") + git(root, "add", "--", "a1.txt", ":(literal)a[1].txt") + + assert.is_true(git_src.unstage(root, "a[1].txt")) + local staged = git(root, "diff", "--cached", "--name-only") + assert.are.equal("a1.txt\n", staged) + end) end) describe("git.read_stage", function() @@ -2147,6 +2181,36 @@ describe(":Differ diff hunk staging", function() assert.are.equal(edited, work) end) + -- the glob a[1].txt matches the executable a1.txt, which sorts first + it("stages a hunk of a glob-named file keeping its own mode", function() + local root = fresh_repo() + local ten = "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n" + write(root .. "/a1.txt", "one\n") + write(root .. "/a[1].txt", ten) + git(root, "add", "a1.txt", "a[1].txt") + git(root, "update-index", "--chmod=+x", "a1.txt") + git(root, "commit", "-q", "-m", "two") + write(root .. "/a[1].txt", "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10\n") + git(root, "add", "--", ":(literal)a[1].txt") + write(root .. "/a[1].txt", "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10x\n") + + vim.cmd.edit(vim.fn.fnameescape(root .. "/a[1].txt")) + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + local v = view_in_origin(p) + if v.model.path ~= "a[1].txt" then + p:close() + error("opened " .. v.model.path .. ", not the bracketed file") + end + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 2), 0 }) + v:stage_hunk() + p:close() + local listed = git(root, "--literal-pathspecs", "ls-files", "-s", "--", "a[1].txt") + assert.are.equal("100644", listed:match("^(%d+)")) + end) + -- a partial hunk still holds something to unstage, so the backward walk stops on it it("walks u back onto a partial hunk", function() local root = fresh_repo() From 20430d6640f0125e2458ab871fef8d90ff6c6178 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:39:10 +0100 Subject: [PATCH 21/93] refactor: share the status row builder and drop dead staging paths --- lua/differ/git/init.lua | 137 ++++++++++++++++----------------- lua/differ/model/marks.lua | 14 +--- lua/differ/view.lua | 64 +++------------ test/unit/model_marks_spec.lua | 19 +---- 4 files changed, 84 insertions(+), 150 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 849d401..31f60a6 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -737,6 +737,41 @@ local function row_of(s) return "Partial", s.x end +-- the worktree's porcelain entries, and the set of paths it lists as untracked +---@param root string +---@return differ.git.StatusEntry[] entries, table untracked, string|nil err +local function porcelain(root) + local out, err = git({ "status", "--porcelain=v1", "-z", "-uall" }, root) + local entries = rev.parse_status(out or "") + local untracked = {} + for _, s in ipairs(entries) do + if s.x == "?" then + untracked[s.path] = true + end + end + return entries, untracked, err +end + +-- a panel row for porcelain entry `s`, lettered `status` and counted from `counts` +---@param s differ.git.StatusEntry +---@param status string +---@param counts table +---@param untracked table +---@return differ.FileEntry +local function status_row(s, status, counts, untracked) + local c = counts[s.path] or {} + return { + path = s.path, + status = status, + additions = c.additions or 0, + deletions = c.deletions or 0, + x = s.x, + y = s.y, + previous_path = s.previous_path, + kept = (s.x == "D" and untracked[s.path]) or nil, + } +end + -- working-tree status as panel sections: Staged / Partial / Unstaged / Untracked -- (slice B). git status compares HEAD/index/worktree, so it only models the -- default HEAD-vs-worktree source; rev-pair sources use file_entries instead. @@ -747,38 +782,26 @@ end ---@param root string ---@return differ.panel.Section[] sections, string|nil err function M.status_sections(root) - local out, err = git({ "status", "--porcelain=v1", "-z", "-uall" }, root) - local entries = rev.parse_status(out or "") + local entries, untracked, err = porcelain(root) local staged_counts = numstat({ "--cached" }, root) local head_counts = numstat({ "HEAD" }, root) - local removed, untracked = {}, {} + local removed = {} for _, s in ipairs(entries) do if s.x == "D" then removed[s.path] = true - elseif s.x == "?" then - untracked[s.path] = true end end local rows = { Conflicts = {}, Staged = {}, Partial = {}, Unstaged = {}, Untracked = {} } for _, s in ipairs(entries) do if not (s.x == "?" and removed[s.path]) then local section, status = row_of(s) - local c = (s.x == "D" and staged_counts or head_counts)[s.path] or {} - local additions = c.additions or 0 + local counts = s.x == "D" and staged_counts or head_counts + local row = status_row(s, status, counts, untracked) if status == "?" then - additions = untracked_additions(root, s.path) + row.additions = untracked_additions(root, s.path) end local list = rows[section] - list[#list + 1] = { - path = s.path, - status = status, - additions = additions, - deletions = c.deletions or 0, - x = s.x, - y = s.y, - previous_path = s.previous_path, - kept = (s.x == "D" and untracked[s.path]) or nil, - } + list[#list + 1] = row end end local sections = { @@ -796,30 +819,13 @@ end ---@param root string ---@return differ.panel.Section[] sections, string|nil err function M.staged_sections(root) - local out, err = git({ "status", "--porcelain=v1", "-z", "-uall" }, root) - local entries = rev.parse_status(out or "") + local entries, untracked, err = porcelain(root) local counts = numstat({ "--cached" }, root) - local untracked = {} - for _, s in ipairs(entries) do - if s.x == "?" then - untracked[s.path] = true - end - end local rows = {} for _, s in ipairs(entries) do local section = row_of(s) if section ~= "Untracked" and section ~= "Conflicts" and s.x ~= " " then - local c = counts[s.path] or {} - rows[#rows + 1] = { - path = s.path, - status = s.x, - additions = c.additions or 0, - deletions = c.deletions or 0, - x = s.x, - y = s.y, - previous_path = s.previous_path, - kept = (s.x == "D" and untracked[s.path]) or nil, - } + rows[#rows + 1] = status_row(s, s.x, counts, untracked) end end return { { title = "Staged changes", entries = rows } }, err @@ -1636,44 +1642,35 @@ function M.panel(opts) end return staging end - -- shared by every whole-file status below - local function whole_file_apply(_, _, reverse) - return set_staged(root, entry, not reverse) - end - -- no lines to stage: a mode change, a submodule pointer, a binary file, a change - -- git normalises away, a rename or copy with no content change + -- whole-file from here: no lines to stage (a mode change, a submodule pointer, a + -- binary file, a change git normalises away, a bare rename or copy), or a file + -- added, untracked or deleted as one unit + ---@type differ.view.Staging + local staging = { + initial = row_state(entry), + whole_file = true, + apply = function(_, _, reverse) + return set_staged(root, entry, not reverse) + end, + refresh = refresh_panel, + } if content then - return { - initial = row_state(entry), - whole_file = true, - apply = whole_file_apply, - refresh = refresh_panel, - } + return staging end if entry.status == "?" or entry.status == "A" then - return { - initial = row_state(entry), - whole_file = true, - apply = whole_file_apply, - -- `discard` drops the staged add before removing the file - revert = function() - return M.discard(root, entry) - end, - revert_label = "deletes the file", - refresh = refresh_panel, - } + -- `discard` drops the staged add before removing the file + staging.revert = function() + return M.discard(root, entry) + end + staging.revert_label = "deletes the file" + return staging end if entry.status == "D" then - return { - initial = row_state(entry), - whole_file = true, - apply = whole_file_apply, - revert = function() - return restore_deleted(entry) - end, - revert_label = "restores the file", - refresh = refresh_panel, - } + staging.revert = function() + return restore_deleted(entry) + end + staging.revert_label = "restores the file" + return staging end return nil end diff --git a/lua/differ/model/marks.lua b/lua/differ/model/marks.lua index d868cff..668b54b 100644 --- a/lua/differ/model/marks.lua +++ b/lua/differ/model/marks.lua @@ -155,15 +155,14 @@ local function bag(lines) end -- the HEAD↔worktree hunks whose lines, read as the marks say, don't hold what the index --- does there, and whether any staged change touches no hunk at all. by content, so a --- hunk whose index lines differ only in order isn't counted +-- does there. by content, so a hunk whose index lines differ only in order isn't counted ---@param head string[] -- HEAD's lines ---@param union differ.Hunk[] -- HEAD↔worktree ---@param cached differ.Hunk[] -- HEAD↔index ---@param marks differ.model.Marks ----@return integer[] hunks, boolean outside +---@return integer[] function M.hidden_in(head, union, cached, marks) - local out, touched = {}, {} + local out = {} for i, h in ipairs(union) do local in_union, in_cached, region = {}, {}, {} local ua, ub = span(h, "old") @@ -172,7 +171,6 @@ function M.hidden_in(head, union, cached, marks) end local actual = {} for _, c in ipairs(touching(h, cached)) do - touched[c] = true local ca, cb = span(c, "old") for l = ca, cb - 1 do in_cached[l], region[l] = true, true @@ -200,11 +198,7 @@ function M.hidden_in(head, union, cached, marks) out[#out + 1] = i end end - local outside = false - for _, c in ipairs(cached) do - outside = outside or not touched[c] - end - return out, outside + return out end -- whether index↔worktree hunk `h` puts back lines HEAD↔index hunk `c` deleted outright diff --git a/lua/differ/view.lua b/lua/differ/view.lua index 6f8f3f3..fe976d2 100644 --- a/lua/differ/view.lua +++ b/lua/differ/view.lua @@ -62,25 +62,17 @@ local armed_view = nil ---@field side differ.ColumnSide ---@field folds? differ.FoldRange[] --- the hunk-staging capability the git frontend supplies per source. the --- view keeps its diff frozen and marks staged hunks in place rather than re-reading --- git, so it tracks per-hunk state and calls `apply` to patch one hunk: `reverse` --- false stages, true unstages, `offset` shifts past already-staged hunks before it. --- `initial` is every hunk's opening state (an unstaged diff opens unstaged, a staged --- one opens staged). `apply` patches one hunk and returns ok; `refresh` repaints the --- panel counts and is called once after a single toggle or a whole S/U batch +-- the hunk-staging capability the git frontend supplies per source. the view keeps its +-- diff frozen and reads staged state from `marks`, which the source keeps current; +-- `apply` stages one hunk (`reverse` unstages it) and returns ok, and `refresh` +-- repaints the panel counts once after a single toggle or a whole S/U batch. -- `apply` and `revert` are independently optional, and each one's absence gates its own --- keys rather than the whole capability. the git frontend supplies both wherever it --- stages at all: a deleted file stages wholesale like a new one, and reverts by --- restoring the file rather than removing it. --- `revert` throws a hunk away instead of moving it between index and worktree, and its --- absence is what gates the key off a source that can't do it. `offset` shifts its --- index-side apply past hunks unstaged before it; its worktree apply needs none, since --- a revert re-sources the model. `revert_label` names the consequence for a file whose +-- keys rather than the whole capability. `revert` throws a hunk away instead of moving +-- it between index and worktree. `revert_label` names the consequence for a file whose -- whole content is one hunk, where reverting isn't a partial act: the frontend knows -- what it will do to the file, the view words the question ---@class differ.view.Staging ----@field initial? differ.model.HunkState -- a source without marks: where it starts +---@field initial? differ.model.HunkState -- a whole-file source: where it starts -- `whole_file` stages as a unit: the keys act on the file, and its diff shares one -- staged-state slot ---@field whole_file? boolean @@ -182,8 +174,8 @@ function View.new(model, opts) return self end --- seed staged state for the current source. a union source (HEAD↔worktree) hands over --- its marks and keeps them current itself; any other starts from `initial` +-- seed staged state for the current source. a hunk-level source hands over its marks +-- and keeps them current itself; a whole-file one starts from `initial` function View:_init_staged() local staging = self.staging if staging and staging.marks then @@ -191,30 +183,8 @@ function View:_init_staged() return end self.marks = { old = {}, new = {} } - if not staging then - return - end - if self:_whole_file() then + if staging and self:_whole_file() then self.marks.whole = staging.initial - return - end - if staging.initial ~= "staged" then - return - end - for _, h in ipairs(self.model.hunks) do - self:_mark_hunk(h, true) - end -end - --- set every line of `h` staged or not ----@param h differ.Hunk ----@param staged boolean -function View:_mark_hunk(h, staged) - for l = h.old_start, h.old_start + h.old_count - 1 do - self.marks.old[l] = staged - end - for l = h.new_start, h.new_start + h.new_count - 1 do - self.marks.new[l] = staged end end @@ -1008,7 +978,7 @@ end function View:_review_filter(staged) local done = staged and "unstaged" or "staged" return function(hunk) - return self:_hunk_state(self:_slot(hunk)) ~= done + return self:_hunk_state(hunk) ~= done end end @@ -1166,16 +1136,6 @@ function View:_slot_count() return #self.model.hunks end --- the slot holding hunk `h`'s staged state ----@param h integer ----@return integer -function View:_slot(h) - if self:_whole_file() then - return 1 - end - return h -end - -- whether a rail line's content is already in the index ---@param line differ.RailLine ---@return boolean @@ -1239,8 +1199,6 @@ function View:_apply_hunk(idx, want_staged) end if self:_whole_file() then self.marks.whole = want - elseif not self.staging.marks then -- a union source has re-read its own - self:_mark_hunk(self.model.hunks[idx], want_staged) end return true end diff --git a/test/unit/model_marks_spec.lua b/test/unit/model_marks_spec.lua index e932323..9af02a4 100644 --- a/test/unit/model_marks_spec.lua +++ b/test/unit/model_marks_spec.lua @@ -151,9 +151,7 @@ describe("union marks", function() local cached = { hl(1, { "foo" }, 1, { "FOO" }) } local unstaged = { hl(1, { "FOO" }, 1, { "FOOD" }) } local m = marks.classify(union, cached, unstaged) - local where, outside = marks.hidden_in({ "foo" }, union, cached, m) - assert.are.same({ 1 }, where) - assert.is_false(outside) + assert.are.same({ 1 }, marks.hidden_in({ "foo" }, union, cached, m)) end) -- HEAD 1..5, line 3 staged as 3x, then 4 edited on top in the worktree @@ -162,20 +160,7 @@ describe("union marks", function() local cached = { hl(3, { "3" }, 3, { "3x" }) } local unstaged = { hl(4, { "4" }, 4, { "4y" }) } local m = marks.classify(union, cached, unstaged) - local where, outside = marks.hidden_in(head, union, cached, m) - assert.are.same({}, where) - assert.is_false(outside) - end) - - -- line 5 staged as 5x and then put back in the worktree, beside an edit at 1 - it("reports a staged change that touches no hunk", function() - local union = { hl(1, { "1" }, 1, { "1y" }) } - local cached = { hl(5, { "5" }, 5, { "5x" }) } - local unstaged = { hl(1, { "1" }, 1, { "1y" }), hl(5, { "5x" }, 5, { "5" }) } - local m = marks.classify(union, cached, unstaged) - local where, outside = marks.hidden_in(head, union, cached, m) - assert.are.same({}, where) - assert.is_true(outside) + assert.are.same({}, marks.hidden_in(head, union, cached, m)) end) end) From 89f4b4e75edb440e2f48276d782b793060803290 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:02:54 +0100 Subject: [PATCH 22/93] test: fail a spec that opens a panel on the suite's own repo --- test/nvim/git_spec.lua | 82 +++++++++++--------------------------- test/nvim/minimal_init.lua | 17 ++++++++ 2 files changed, 40 insertions(+), 59 deletions(-) diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index d326044..12c83ea 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -36,6 +36,27 @@ local function fresh_repo() return root end +-- the panel row for `path`; `staged` narrows to a row with nothing unstaged (true) or +-- with something unstaged (false) +local function file_line(p, path, staged) + for i, m in ipairs(p.meta) do + if + m.kind == "file" + and m.entry.path == path + and (staged == nil or (m.entry.y == " ") == staged) + then + return i + end + end +end + +-- the diff view the panel drives. View.current keys off the focused buffer, and +-- panel keys leave focus on the panel, so it's looked up from the origin window +local function view_in_origin(p) + vim.api.nvim_set_current_win(p.origin_win) + return require("differ.view").current() +end + describe("git.read / changed_files", function() it("reads the committed version and the worktree version", function() local root = fresh_repo() @@ -621,17 +642,6 @@ describe(":Differ panel", function() -- 1-based line of the file row for `path` (optionally pinned to a staged/unstaged -- section), located via the panel's meta so tests don't hardcode header offsets - local function file_line(p, path, staged) - for i, m in ipairs(p.meta) do - if - m.kind == "file" - and m.entry.path == path - and (staged == nil or (m.entry.y == " ") == staged) - then - return i - end - end - end -- the View driven by the panel, read without moving focus (which the focus-steal -- assertions below depend on): it lives in the origin window @@ -1112,21 +1122,15 @@ describe(":Differ panel", function() git_src.panel({}) local p = Panel.current() - -- p:select returns focus to the panel, so look the View up via the origin - -- window's buffer (View.current keys off the focused buffer) - local function view_in_origin() - vim.api.nvim_set_current_win(p.origin_win) - return require("differ.view").current() - end vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "a.lua", true), 0 }) p:select() - local v = view_in_origin() + local v = view_in_origin(p) assert.are.equal(V1, v.model.old_text) -- HEAD assert.are.equal("local x = 2\nreturn x\n", v.model.new_text) -- index vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "b.lua", false), 0 }) p:select() - v = view_in_origin() + v = view_in_origin(p) assert.are.equal("one\n", v.model.old_text) -- index assert.are.equal("two\n", v.model.new_text) -- worktree p:close() @@ -1179,17 +1183,6 @@ describe(":Differ panel staging (slice C)", function() end end end - local function file_line(p, path, staged) - for i, m in ipairs(p.meta) do - if - m.kind == "file" - and m.entry.path == path - and (staged == nil or (m.entry.y == " ") == staged) - then - return i - end - end - end local function keymaps(p) local lhs = {} for _, m in ipairs(vim.api.nvim_buf_get_keymap(p.bufnr, "n")) do @@ -1440,11 +1433,6 @@ end) describe(":Differ diff hunk staging", function() local Panel = require("differ.panel") - -- p:select returns focus to the panel, so the View lives in the origin window - local function view_in_origin(p) - vim.api.nvim_set_current_win(p.origin_win) - return require("differ.view").current() - end -- the first buffer row of the diff showing `text` local function row_at(v, text) for row, l in ipairs(vim.api.nvim_buf_get_lines(v.columns[1].bufnr, 0, -1, false)) do @@ -3161,17 +3149,6 @@ describe(":Differ diff hunk staging", function() end) -- the panel row for `path`, optionally pinned to its staged or unstaged side - local function file_line(p, path, staged) - for i, m in ipairs(p.meta) do - if - m.kind == "file" - and m.entry.path == path - and (staged == nil or (m.entry.y == " ") == staged) - then - return i - end - end - end -- answer the revert confirm with `choice` for the duration of `fn` local function confirming(choice, fn) @@ -4576,10 +4553,6 @@ describe(":Differ (open_first)", function() local Panel = require("differ.panel") -- p:select returns focus to the panel, so the View lives in the origin window - local function view_in_origin(p) - vim.api.nvim_set_current_win(p.origin_win) - return require("differ.view").current() - end it("opens the panel and the first file's diff (DiffviewOpen-style)", function() local root = fresh_repo() @@ -4907,10 +4880,6 @@ end) describe(":Differ diff whole-file staging", function() local Panel = require("differ.panel") - local function view_in_origin(p) - vim.api.nvim_set_current_win(p.origin_win) - return require("differ.view").current() - end -- the mode git records for `path` in the index local function index_mode(root, path) return (git(root, "ls-files", "--stage", "--", path):match("^(%d+)")) @@ -5018,11 +4987,6 @@ end) describe(":Differ diff rename staging", function() local Panel = require("differ.panel") - local function view_in_origin(p) - vim.api.nvim_set_current_win(p.origin_win) - return require("differ.view").current() - end - -- a 60-line file committed at old/big.lua, then staged-renamed to new/big.lua with -- `edits` (a list of line numbers) changed on the way, so the rename carries that -- many hunks. 60 lines keeps a few edits well above git's 50% similarity threshold, diff --git a/test/nvim/minimal_init.lua b/test/nvim/minimal_init.lua index 062b92a..35cc1b2 100644 --- a/test/nvim/minimal_init.lua +++ b/test/nvim/minimal_init.lua @@ -5,3 +5,20 @@ _G.notifs = {} vim.notify = function(msg, level) _G.notifs[#_G.notifs + 1] = { msg = msg, level = level } end + +-- a panel stages and reverts in the repo it opened on: the current file's repo, else +-- cwd's. a spec that opens one without a file of its throwaway repo focused lands on +-- the repo the suite runs from, so that fails here before any key can write to it +local suite_root = vim.fn.systemlist({ "git", "rev-parse", "--show-toplevel" })[1] +local suite_label = vim.fn.fnamemodify(suite_root, ":~") -- the form Panel.root holds +local git = require("differ.git") +local panel = git.panel +git.panel = function(opts) + local out = panel(opts) + local p = require("differ.panel").current() + if p and p.root == suite_label then + p:close() + error("a spec opened a panel on the repo the suite runs from") + end + return out +end From 5ff7d035da7b6be6caf07c30bf3fa6e809e6a5b9 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:28:25 +0100 Subject: [PATCH 23/93] feat: read which whole hunks an index splices into HEAD --- lua/differ/model/marks.lua | 63 ++++++++++++++++++++++++++++++++++ test/unit/model_marks_spec.lua | 42 +++++++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/lua/differ/model/marks.lua b/lua/differ/model/marks.lua index 668b54b..767a4f2 100644 --- a/lua/differ/model/marks.lua +++ b/lua/differ/model/marks.lua @@ -80,6 +80,69 @@ function M.state(marks, h) return "partial" end +---@param lines string[] +---@param at integer +---@param block string[] +---@return boolean +local function holds(lines, at, block) + for k, line in ipairs(block) do + if lines[at + k - 1] ~= line then + return false + end + end + return true +end + +-- the union hunks the index takes, when it is HEAD with whole union hunks spliced in; +-- nil when it isn't. an unchanged stretch that fits either side of a hunk is settled +-- by the lines after it +---@param union differ.Hunk[] -- HEAD↔worktree +---@param head string[] -- HEAD's lines +---@param index string[] -- the index's lines +---@return table|nil +function M.spliced(union, head, index) + local taken = {} + local stuck = {} ---@type table -- "hunk:index line" with no way through + ---@param i integer -- the next union hunk + ---@param from integer -- the next HEAD line + ---@param at integer -- the next index line + ---@return boolean + local function walk(i, from, at) + local key = i .. ":" .. at + if stuck[key] then + return false + end + local h = union[i] + local stop = #head + 1 + if h then + stop = h.old_count > 0 and h.old_start or h.old_start + 1 + end + for l = from, stop - 1 do + if index[at] ~= head[l] then + stuck[key] = true + return false + end + at = at + 1 + end + if not h then + return at == #index + 1 + end + for _, take in ipairs({ true, false }) do + local block = take and h.new_lines or h.old_lines + if holds(index, at, block) and walk(i + 1, stop + h.old_count, at + #block) then + taken[i] = take + return true + end + end + stuck[key] = true + return false + end + if not walk(1, 1, 1) then + return nil + end + return taken +end + -- a hunk's real lines on one side, [start, stop). a zero-count hunk has none ---@param h differ.Hunk ---@param side "old"|"new" diff --git a/test/unit/model_marks_spec.lua b/test/unit/model_marks_spec.lua index 9af02a4..fb6184f 100644 --- a/test/unit/model_marks_spec.lua +++ b/test/unit/model_marks_spec.lua @@ -226,6 +226,48 @@ describe("union marks", function() end) end) + describe("spliced", function() + -- HEAD a b c d, worktree a B c D e: b and d rewritten, e added at the end + local head = { "a", "b", "c", "d" } + local union = { hl(2, { "b" }, 2, { "B" }), hl(4, { "d" }, 4, { "D", "e" }) } + + it("takes no hunk when the index is HEAD", function() + assert.are.same({ false, false }, marks.spliced(union, head, head)) + end) + + it("takes every hunk when the index is the worktree", function() + local index = { "a", "B", "c", "D", "e" } + assert.are.same({ true, true }, marks.spliced(union, head, index)) + end) + + it("names the hunks taken", function() + local index = { "a", "b", "c", "D", "e" } + assert.are.same({ false, true }, marks.spliced(union, head, index)) + end) + + it("gives nil for part of a hunk", function() + assert.is_nil(marks.spliced(union, head, { "a", "b", "c", "D" })) + end) + + it("gives nil for a line neither side has", function() + assert.is_nil(marks.spliced(union, head, { "a", "X", "c", "d" })) + assert.is_nil(marks.spliced(union, head, { "a", "b", "c", "d", "z" })) + end) + + -- HEAD a x c, worktree a x x c: the inserted x also fits HEAD's own x + it("settles a hunk its unchanged lines repeat by the lines after it", function() + local repeat_head = { "a", "x", "c" } + local insert = { hl(1, {}, 2, { "x" }) } + assert.are.same({ false }, marks.spliced(insert, repeat_head, repeat_head)) + assert.are.same({ true }, marks.spliced(insert, repeat_head, { "a", "x", "x", "c" })) + end) + + it("places an insertion at the top of the file", function() + local top = { hl(0, {}, 1, { "z" }) } + assert.are.same({ true }, marks.spliced(top, head, { "z", "a", "b", "c", "d" })) + end) + end) + describe("shift", function() -- three lines inserted at the top, and line 5 replaced by two local hunks = { h(0, 0, 1, 3), h(5, 1, 8, 2) } From be092163e6f7548c865b0011150ec52899ff51c4 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Mon, 14 Sep 2026 10:41:25 +0100 Subject: [PATCH 24/93] fix: stage and mark by whole hunk when the index is one --- lua/differ/git/init.lua | 84 ++++++++++++++++++++++++++++------ lua/differ/model/marks.lua | 17 +++++++ test/unit/model_marks_spec.lua | 7 +++ 3 files changed, 95 insertions(+), 13 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 31f60a6..64d5670 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -1516,17 +1516,25 @@ function M.panel(opts) ---@param cached differ.DiffModel ---@param unstaged differ.DiffModel local function remark(union, cached, unstaged) + local to_lines = require("differ.util.text").to_lines + local head = to_lines(union.old_text) + local taken = marks.spliced(union.hunks, head, to_lines(cached.new_text)) + staging.hidden_in = nil + if taken then + local held = marks.of_hunks(union.hunks, taken) + staging.marks.old, staging.marks.new = held.old, held.new + staging.hidden = mode_why + return + end local fresh = marks.classify(union.hunks, cached.hunks, unstaged.hunks) staging.marks.old, staging.marks.new = fresh.old, fresh.new local complete, why = marks.complete(union.hunks, cached.hunks, unstaged.hunks) - staging.hidden_in = nil if mode_why then staging.hidden = mode_why elseif complete then staging.hidden = nil else staging.hidden = why - local head = require("differ.util.text").to_lines(union.old_text) staging.hidden_in = marks.hidden_in(head, union.hunks, cached.hunks, fresh) end end @@ -1552,21 +1560,71 @@ function M.panel(opts) return true end - remark(M.union_models(root, entry)) - staging.apply = function(_, hunk, reverse) - if not hunk then - return false + ---@param a differ.Hunk + ---@param b differ.Hunk + ---@return boolean + local function same_range(a, b) + return a.old_start == b.old_start + and a.old_count == b.old_count + and a.new_start == b.new_start + and a.new_count == b.new_count + end + + -- the index with the union hunks it holds whole, and `hunk` taken or given back. + -- nil unless the index is exactly HEAD with whole union hunks spliced in + ---@param union differ.DiffModel + ---@param cached differ.DiffModel + ---@param hunk differ.Hunk + ---@param take boolean + ---@return string|nil + local function respliced(union, cached, hunk, take) + local to_lines = require("differ.util.text").to_lines + local head, index = to_lines(union.old_text), to_lines(cached.new_text) + local taken = marks.spliced(union.hunks, head, index) + if not taken or splice(union, taken) ~= cached.new_text then + return nil + end + for i, u in ipairs(union.hunks) do + if same_range(u, hunk) then + taken[i] = take + return splice(union, taken) + end + end + return nil + end + + -- the text the index takes when `hunk` is staged (`take`) or unstaged, or nil + -- when the op would reach another hunk. an index of whole union hunks moves by + -- union hunk; otherwise by the pair hunks meeting it on the side that pair shares + ---@param union differ.DiffModel + ---@param cached differ.DiffModel + ---@param unstaged differ.DiffModel + ---@param hunk differ.Hunk + ---@param take boolean + ---@return string|nil + local function next_index(union, cached, unstaged, hunk, take) + local text = respliced(union, cached, hunk, take) + if text then + return text end - local union, cached, unstaged = M.union_models(root, entry) local from, side = unstaged, "new" - if reverse then + if not take then from, side = cached, "old" end if shared(union, from, side, hunk) then + return nil + end + return splice(from, select_hunks(from.hunks, side, hunk, side, take)) + end + + remark(M.union_models(root, entry)) + staging.apply = function(_, hunk, reverse) + if not hunk then return false end - local text = splice(from, select_hunks(from.hunks, side, hunk, side, not reverse)) - if not put_index(entry, text) then + local union, cached, unstaged = M.union_models(root, entry) + local text = next_index(union, cached, unstaged, hunk, not reverse) + if not text or not put_index(entry, text) then return false end remark(union_pairs(root, entry.path, union.old_text, text, union.new_text)) @@ -1578,9 +1636,10 @@ function M.panel(opts) -- goes through a patch, not a write: the model's new side was read through the -- clean filter, so writing it back would push that conversion to disk staging.revert = function(model, idx) - local union, cached = M.union_models(root, entry) + local union, cached, unstaged = M.union_models(root, entry) local hunk = model.hunks[idx] - if shared(union, cached, "old", hunk) then + local text = next_index(union, cached, unstaged, hunk, false) + if not text then return false end local p = patch.hunk(model.path, hunk, model.old_text, model.new_text, 0, "new") @@ -1588,7 +1647,6 @@ function M.panel(opts) notify("the file has changed these lines: nothing reverted", vim.log.levels.WARN) return false end - local text = splice(cached, select_hunks(cached.hunks, "old", hunk, "old", false)) if text ~= cached.new_text and not put_index(entry, text) then return false end diff --git a/lua/differ/model/marks.lua b/lua/differ/model/marks.lua index 767a4f2..b81227a 100644 --- a/lua/differ/model/marks.lua +++ b/lua/differ/model/marks.lua @@ -143,6 +143,23 @@ function M.spliced(union, head, index) return taken end +-- marks for union hunks each held whole or not at all +---@param union differ.Hunk[] +---@param taken table -- hunk index -> the index holds it +---@return differ.model.Marks +function M.of_hunks(union, taken) + local out = { old = {}, new = {} } + for i, h in ipairs(union) do + for l = h.old_start, h.old_start + h.old_count - 1 do + out.old[l] = taken[i] + end + for l = h.new_start, h.new_start + h.new_count - 1 do + out.new[l] = taken[i] + end + end + return out +end + -- a hunk's real lines on one side, [start, stop). a zero-count hunk has none ---@param h differ.Hunk ---@param side "old"|"new" diff --git a/test/unit/model_marks_spec.lua b/test/unit/model_marks_spec.lua index fb6184f..c589f74 100644 --- a/test/unit/model_marks_spec.lua +++ b/test/unit/model_marks_spec.lua @@ -266,6 +266,13 @@ describe("union marks", function() local top = { hl(0, {}, 1, { "z" }) } assert.are.same({ true }, marks.spliced(top, head, { "z", "a", "b", "c", "d" })) end) + + it("marks each hunk's lines as the hunk is held", function() + local m = marks.of_hunks(union, { false, true }) + assert.are.same({ "unstaged", "staged" }, states(m, union)) + assert.are.same({ [2] = false, [4] = true }, m.old) + assert.are.same({ [2] = false, [4] = true, [5] = true }, m.new) + end) end) describe("shift", function() From 59b104b08f622788b5cf2d90b5fd3ca1f54d0655 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:07:53 +0100 Subject: [PATCH 25/93] test: pin hunk staging where the index diff pairs lines apart --- test/nvim/git_spec.lua | 193 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index 12c83ea..a496ee8 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -1960,6 +1960,199 @@ describe(":Differ diff hunk staging", function() p:close() end) + local ACME_HEAD = [==[ +package main + +import ( + "context" + "fmt" + "net/http" + "net/http/httptrace" + "time" +) + +func main() { + now := time.Now() + fmt.Printf("starting up: %v\n", now.Format(time.UnixDate)) + + ctx := context.TODO() + + for i := range 5 { + startTime := time.Now() + c := http.Client{} + trace := httptrace.ClientTrace{ + DNSStart: func(di httptrace.DNSStartInfo) { + fmt.Printf("dns lookup started at %v with host: %s\n", time.Now().Format(time.UnixDate), di.Host) + }, + TLSHandshakeStart: func() { + fmt.Printf("tls handshake started at %v\n", time.Since(startTime)) + }, + ConnectDone: func(network, addr string, err error) { + if err != nil { + fmt.Printf("failed to connect: %s\n", err.Error()) + return + } + fmt.Printf("connected to network at address: %s\n", addr) + }, + } + traceCtx := httptrace.WithClientTrace(ctx, &trace) + req, err := http.NewRequestWithContext(traceCtx, http.MethodGet, "https://acme.example/download?bytes=42", nil) + if err != nil { + panic(err) + } + + _, err = c.Do(req) + if err != nil { + panic(err) + } + fmt.Printf("request %d took %v\n", i, time.Since(startTime)) + } + + fmt.Printf("total time lapsed: %s\n", time.Since(now).String()) +} +]==] + + local ACME_WORK = [==[ +package main + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptrace" + "slices" + "time" +) + +func main() { + now := time.Now() + times := []time.Duration{} + ctx := context.TODO() + url := "https://acme.example/download?bytes=1337" + + fmt.Printf("starting up: %v\n", now.Format(time.UnixDate)) + + for i := range 5 { + reqTime := ping(ctx, url, i) + fmt.Printf("request %d took %v\n", i, reqTime) + times = append(times, reqTime) + } + + slices.Sort(times) + + fmt.Printf("total time lapsed: %s\nmedian request: %v\n", time.Since(now).String(), median(times)) +} + +func ping(ctx context.Context, url string, iteration int) time.Duration { + now := time.Now() + c := http.Client{} + trace := httptrace.ClientTrace{ + DNSStart: func(di httptrace.DNSStartInfo) { + fmt.Printf("dns lookup started at %v with host: %s\n", time.Now().Format(time.UnixDate), di.Host) + }, + TLSHandshakeStart: func() { + fmt.Printf("tls handshake started at %v\n", time.Now().Format(time.UnixDate)) + }, + ConnectDone: func(network, addr string, err error) { + if err != nil { + fmt.Printf("failed to connect: %s\n", err.Error()) + return + } + fmt.Printf("connected to network at address: %s\n", addr) + }, + GotFirstResponseByte: func() { + fmt.Printf("response %d took %s to get first response byte\n", iteration, time.Since(now).String()) + }, + } + traceCtx := httptrace.WithClientTrace(ctx, &trace) + req, err := http.NewRequestWithContext(traceCtx, http.MethodGet, url, nil) + if err != nil { + panic(err) + } + + resp, err := c.Do(req) + if err != nil { + panic(err) + } + + io.Copy(io.Discard, resp.Body) + + defer resp.Body.Close() + + return time.Since(now) +} + +func median(input []time.Duration) time.Duration { + middle := len(input) / 2 + if len(input)%2 == 1 { + return input[middle] + } + + return (input[middle-1] + input[middle]) / 2 +} + +func getDownloadSpeed() { + // throughput = bytes / (t_end − t_first_byte) +} +]==] + + -- with hunks 1-4 staged, HEAD↔index keeps `ctx` in place and moves the Printf, where + -- HEAD↔worktree keeps the Printf and moves `ctx`: the index still holds whole hunks + it("stages and unstages hunk by hunk where HEAD↔index pairs lines differently", function() + local root = fresh_repo() + write(root .. "/a.go", ACME_HEAD) + git(root, "add", "a.go") + git(root, "commit", "-q", "-m", "ping") + write(root .. "/a.go", ACME_WORK) + + vim.cmd.edit(root .. "/a.go") + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + local v = view_in_origin(p) + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + local function press(n, op) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, n), 0 }) + v[op](v) + end + local function states() + local out = {} + for i = 1, #v.model.hunks do + out[i] = v:_hunk_state(i) + end + return out + end + + local count = #v.model.hunks + for n = 1, 4 do + press(n, "stage_hunk") + end + local staged, hidden, index = states(), v.staging.hidden_in, indexed(root, "a.go") + press(4, "unstage_hunk") + local after4 = states() + press(3, "unstage_hunk") + local after3, index3 = states(), indexed(root, "a.go") + press(2, "unstage_hunk") + press(1, "unstage_hunk") + local final = indexed(root, "a.go") + p:close() + + local h, w = vim.split(ACME_HEAD, "\n"), vim.split(ACME_WORK, "\n") + local function joined(top, rest) + return table.concat(vim.list_extend(top, rest), "\n") + end + local S, U = "staged", "unstaged" + assert.are.equal(6, count) + assert.are.same({ S, S, S, S, U, U }, staged) + assert.is_nil(hidden) + assert.are.equal(joined(vim.list_slice(w, 1, 20), vim.list_slice(h, 17)), index) + assert.are.same({ S, S, S, U, U, U }, after4) + assert.are.same({ S, S, U, U, U, U }, after3) + assert.are.equal(joined(vim.list_slice(w, 1, 14), vim.list_slice(h, 13)), index3) + assert.are.equal(ACME_HEAD, final) + end) + -- a change staged and then edited again within a few lines merges into one -- HEAD↔worktree hunk holding both. it reads as partial, and per line the marks still -- say which of it the index has From 2e4233bfb7654adbd221207a593e5cb63453c788 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:07:53 +0100 Subject: [PATCH 26/93] fix: stage partial hunks by the index's own lines --- lua/differ/git/init.lua | 88 ++++++++++------- lua/differ/model/apply.lua | 63 ++++++++++++ lua/differ/model/marks.lua | 169 ++++++++++++++++++++++++++------- test/nvim/git_spec.lua | 62 ++++++++++++ test/unit/model_apply_spec.lua | 32 +++++++ test/unit/model_marks_spec.lua | 75 +++++++++------ 6 files changed, 391 insertions(+), 98 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 64d5670..99d246b 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -1504,6 +1504,7 @@ function M.panel(opts) local function union_staging(entry) local marks = require("differ.model.marks") local splice = require("differ.model.apply").splice + local join = require("differ.model.apply").join local mode_why ---@type string|nil if entry.x ~= " " and entry.y ~= " " and mode_hidden(entry) then mode_why = "the index holds a mode change" @@ -1511,19 +1512,41 @@ function M.panel(opts) ---@type differ.view.Staging local staging = { marks = { old = {}, new = {} }, refresh = refresh_panel } - -- the view keeps staging.marks, so a re-mark writes through it + -- the index's lines at each union hunk; nil unless the index holds every unchanged + -- line between them and rejoins to exactly its own text + ---@param union differ.DiffModel + ---@param cached differ.DiffModel + ---@return string[][]|nil + local function index_blocks(union, cached) + local to_lines = require("differ.util.text").to_lines + local index = cached.new_text + local blocks = marks.blocks(union.hunks, to_lines(union.old_text), to_lines(index)) + if not blocks or join(union, blocks, index) ~= index then + return nil + end + return blocks + end + + -- the view keeps staging.marks, so a re-mark writes through it. marks come from + -- the index's own lines at each hunk, and from the two pairs only when the index + -- changes a line between hunks ---@param union differ.DiffModel ---@param cached differ.DiffModel ---@param unstaged differ.DiffModel local function remark(union, cached, unstaged) - local to_lines = require("differ.util.text").to_lines - local head = to_lines(union.old_text) - local taken = marks.spliced(union.hunks, head, to_lines(cached.new_text)) staging.hidden_in = nil - if taken then - local held = marks.of_hunks(union.hunks, taken) + local blocks = index_blocks(union, cached) + local held, hidden_in = nil, {} ---@type differ.model.Marks|nil, integer[] + if blocks then + held, hidden_in = marks.of_blocks(union.hunks, blocks) + end + if held then staging.marks.old, staging.marks.new = held.old, held.new staging.hidden = mode_why + if not mode_why and #hidden_in > 0 then + staging.hidden = "the index holds a line neither HEAD nor the worktree has" + staging.hidden_in = hidden_in + end return end local fresh = marks.classify(union.hunks, cached.hunks, unstaged.hunks) @@ -1535,6 +1558,7 @@ function M.panel(opts) staging.hidden = nil else staging.hidden = why + local head = require("differ.util.text").to_lines(union.old_text) staging.hidden_in = marks.hidden_in(head, union.hunks, cached.hunks, fresh) end end @@ -1560,42 +1584,28 @@ function M.panel(opts) return true end - ---@param a differ.Hunk - ---@param b differ.Hunk - ---@return boolean - local function same_range(a, b) - return a.old_start == b.old_start - and a.old_count == b.old_count - and a.new_start == b.new_start - and a.new_count == b.new_count - end - - -- the index with the union hunks it holds whole, and `hunk` taken or given back. - -- nil unless the index is exactly HEAD with whole union hunks spliced in + -- where `hunk` sits in a fresh read of the union, by its line ranges ---@param union differ.DiffModel - ---@param cached differ.DiffModel ---@param hunk differ.Hunk - ---@param take boolean - ---@return string|nil - local function respliced(union, cached, hunk, take) - local to_lines = require("differ.util.text").to_lines - local head, index = to_lines(union.old_text), to_lines(cached.new_text) - local taken = marks.spliced(union.hunks, head, index) - if not taken or splice(union, taken) ~= cached.new_text then - return nil - end + ---@return integer|nil + local function hunk_index(union, hunk) for i, u in ipairs(union.hunks) do - if same_range(u, hunk) then - taken[i] = take - return splice(union, taken) + if + u.old_start == hunk.old_start + and u.old_count == hunk.old_count + and u.new_start == hunk.new_start + and u.new_count == hunk.new_count + then + return i end end return nil end -- the text the index takes when `hunk` is staged (`take`) or unstaged, or nil - -- when the op would reach another hunk. an index of whole union hunks moves by - -- union hunk; otherwise by the pair hunks meeting it on the side that pair shares + -- when the op would reach another hunk. the index's lines at the hunk become the + -- hunk's new or old lines; an index that changes a line between hunks moves by + -- the pair hunks meeting it on the side that pair shares ---@param union differ.DiffModel ---@param cached differ.DiffModel ---@param unstaged differ.DiffModel @@ -1603,9 +1613,15 @@ function M.panel(opts) ---@param take boolean ---@return string|nil local function next_index(union, cached, unstaged, hunk, take) - local text = respliced(union, cached, hunk, take) - if text then - return text + local blocks = index_blocks(union, cached) + local i = hunk_index(union, hunk) + if blocks and i then + local lines = union.hunks[i].old_lines + if take then + lines = union.hunks[i].new_lines + end + blocks[i] = lines + return join(union, blocks, cached.new_text) end local from, side = unstaged, "new" if not take then diff --git a/lua/differ/model/apply.lua b/lua/differ/model/apply.lua index b29ae1d..d13062b 100644 --- a/lua/differ/model/apply.lua +++ b/lua/differ/model/apply.lua @@ -40,4 +40,67 @@ function M.splice(model, applied, base) return table.concat(out, "\n") .. (ending:sub(-1) == "\n" and "\n" or "") end +---@param a string[] +---@param b string[] +---@return boolean +local function same_lines(a, b) + if #a ~= #b then + return false + end + for i, line in ipairs(a) do + if b[i] ~= line then + return false + end + end + return true +end + +-- the text a file ends as when hunk `h`, holding `block`, reaches its end: a side's +-- text when the block is that side's lines, else `text` +---@param model differ.DiffModel +---@param h differ.Hunk +---@param block string[] +---@param text string +---@return string +local function ending_at(model, h, block, text) + if same_lines(block, h.new_lines) then + return model.new_text + end + if same_lines(block, h.old_lines) then + return model.old_text + end + return text +end + +-- the model's unchanged old lines with `blocks` at its hunks. the file ends as `text` +-- does, unless the last hunk reaches the end holding one side's lines +---@param model differ.DiffModel +---@param blocks string[][] -- hunk index -> the lines at that hunk +---@param text string -- the text the ending comes from otherwise +---@return string +function M.join(model, blocks, text) + local lines = to_lines(model.old_text) + local out, next_line, ending = {}, 1, text + for i, h in ipairs(model.hunks) do + local first = h.old_count > 0 and h.old_start or h.old_start + 1 + for l = next_line, first - 1 do + out[#out + 1] = lines[l] + end + for _, line in ipairs(blocks[i]) do + out[#out + 1] = line + end + next_line = first + h.old_count + if next_line > #lines then + ending = ending_at(model, h, blocks[i], text) + end + end + for l = next_line, #lines do + out[#out + 1] = lines[l] + end + if #out == 0 then + return "" + end + return table.concat(out, "\n") .. (ending:sub(-1) == "\n" and "\n" or "") +end + return M diff --git a/lua/differ/model/marks.lua b/lua/differ/model/marks.lua index b81227a..63a9beb 100644 --- a/lua/differ/model/marks.lua +++ b/lua/differ/model/marks.lua @@ -80,30 +80,45 @@ function M.state(marks, h) return "partial" end ----@param lines string[] ----@param at integer ----@param block string[] ----@return boolean -local function holds(lines, at, block) - for k, line in ipairs(block) do - if lines[at + k - 1] ~= line then - return false +-- the HEAD line a union hunk's lines start at, and the first HEAD line after them +---@param h differ.Hunk +---@return integer first, integer after +local function old_bounds(h) + local first = h.old_count > 0 and h.old_start or h.old_start + 1 + return first, first + h.old_count +end + +-- the block lengths to try for hunk `h` with `left` index lines to go: its own two +-- sides first, then the rest shortest first +---@param h differ.Hunk +---@param left integer +---@return integer[] +local function block_lengths(h, left) + local out, seen = {}, {} + for _, n in ipairs({ h.new_count, h.old_count }) do + if n <= left and not seen[n] then + out[#out + 1], seen[n] = n, true + end + end + for n = 0, left do + if not seen[n] then + out[#out + 1] = n end end - return true + return out end --- the union hunks the index takes, when it is HEAD with whole union hunks spliced in; --- nil when it isn't. an unchanged stretch that fits either side of a hunk is settled --- by the lines after it +-- the index's lines at each union hunk, when the index holds every unchanged line +-- between them; nil when it doesn't. where unchanged lines repeat, a hunk's block +-- ends at the first length that lets the rest of the index through ---@param union differ.Hunk[] -- HEAD↔worktree ---@param head string[] -- HEAD's lines ---@param index string[] -- the index's lines ----@return table|nil -function M.spliced(union, head, index) - local taken = {} +---@return string[][]|nil +function M.blocks(union, head, index) + local out = {} local stuck = {} ---@type table -- "hunk:index line" with no way through - ---@param i integer -- the next union hunk + ---@param i integer -- the next union hunk ---@param from integer -- the next HEAD line ---@param at integer -- the next index line ---@return boolean @@ -113,11 +128,11 @@ function M.spliced(union, head, index) return false end local h = union[i] - local stop = #head + 1 + local first, after = #head + 1, #head + 1 if h then - stop = h.old_count > 0 and h.old_start or h.old_start + 1 + first, after = old_bounds(h) end - for l = from, stop - 1 do + for l = from, first - 1 do if index[at] ~= head[l] then stuck[key] = true return false @@ -127,10 +142,15 @@ function M.spliced(union, head, index) if not h then return at == #index + 1 end - for _, take in ipairs({ true, false }) do - local block = take and h.new_lines or h.old_lines - if holds(index, at, block) and walk(i + 1, stop + h.old_count, at + #block) then - taken[i] = take + for _, n in ipairs(block_lengths(h, #index - at + 1)) do + -- the unchanged line after the block, when there is one, has to come next + local fits = after > #head or index[at + n] == head[after] + if fits and walk(i + 1, after, at + n) then + local block = {} + for l = at, at + n - 1 do + block[#block + 1] = index[l] + end + out[i] = block return true end end @@ -140,24 +160,105 @@ function M.spliced(union, head, index) if not walk(1, 1, 1) then return nil end - return taken + return out +end + +-- LCS grid size past which a hunk's block isn't compared line by line +local MAX_CELLS = 1e6 + +-- which lines of `a` and `b` a longest common subsequence pairs up; nil past MAX_CELLS +---@param a string[] +---@param b string[] +---@return table|nil in_a, table|nil in_b +local function common(a, b) + local n, m = #a, #b + if n * m > MAX_CELLS then + return nil, nil + end + local dp = {} -- dp[i][j] = LCS length of a[i..] and b[j..] + for i = 1, n + 1 do + dp[i] = { [m + 1] = 0 } + end + for j = 1, m + 1 do + dp[n + 1][j] = 0 + end + for i = n, 1, -1 do + for j = m, 1, -1 do + if a[i] == b[j] then + dp[i][j] = dp[i + 1][j + 1] + 1 + else + dp[i][j] = math.max(dp[i + 1][j], dp[i][j + 1]) + end + end + end + local in_a, in_b = {}, {} + local i, j = 1, 1 + while i <= n and j <= m do + if a[i] == b[j] then + in_a[i], in_b[j] = true, true + i, j = i + 1, j + 1 + elseif dp[i + 1][j] >= dp[i][j + 1] then + i = i + 1 + else + j = j + 1 + end + end + return in_a, in_b end --- marks for union hunks each held whole or not at all +-- mark one hunk's lines from its index block. a block line is HEAD's where it pairs +-- with an old line, else the worktree's where it pairs with a new one +---@param h differ.Hunk +---@param block string[] +---@param marks differ.model.Marks +---@return boolean compared -- false for a hunk too big to compare +---@return boolean hidden -- a block line neither side has +local function mark_block(h, block, marks) + local kept, used = common(h.old_lines, block) + if not (kept and used) then + return false, false + end + local rest = {} + for k, line in ipairs(block) do + if not used[k] then + rest[#rest + 1] = line + end + end + local held, placed = common(h.new_lines, rest) + if not (held and placed) then + return false, false + end + for k = 1, h.old_count do + marks.old[h.old_start + k - 1] = not kept[k] + end + local paired = 0 + for k = 1, h.new_count do + marks.new[h.new_start + k - 1] = held[k] == true + if held[k] then + paired = paired + 1 + end + end + return true, paired < #rest +end + +-- marks for the union hunks from the index's block at each, and the hunks whose block +-- holds a line neither side has. nil when a hunk is too big to compare ---@param union differ.Hunk[] ----@param taken table -- hunk index -> the index holds it ----@return differ.model.Marks -function M.of_hunks(union, taken) - local out = { old = {}, new = {} } +---@param blocks string[][] +---@return differ.model.Marks|nil marks, integer[] hidden +function M.of_blocks(union, blocks) + local marks = { old = {}, new = {} } + local hidden = {} for i, h in ipairs(union) do - for l = h.old_start, h.old_start + h.old_count - 1 do - out.old[l] = taken[i] + local compared, extra = mark_block(h, blocks[i], marks) + if not compared then + return nil, {} end - for l = h.new_start, h.new_start + h.new_count - 1 do - out.new[l] = taken[i] + if extra then + hidden[#hidden + 1] = i end end - return out + return marks, hidden end -- a hunk's real lines on one side, [start, stop). a zero-count hunk has none diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index a496ee8..1187753 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -2153,6 +2153,68 @@ func getDownloadSpeed() { assert.are.equal(ACME_HEAD, final) end) + -- point `path`'s index entry at `content` without touching the worktree + local function index_as(root, path, content) + local blob = root .. "/.blob" + write(blob, content) + local sha = vim.trim(git(root, "hash-object", "-w", blob)) + os.remove(blob) + git(root, "update-index", "--cacheinfo", "100644," .. sha .. "," .. path) + end + -- a repo with a.lua committed as `head`, staged as `index` and on disk as `work`, and + -- the diff :Differ opens on it + local function staged_as(head, index, work) + local root = fresh_repo() + write(root .. "/a.lua", head) + git(root, "commit", "-q", "-am", "head") + index_as(root, "a.lua", index) + write(root .. "/a.lua", work) + vim.cmd.edit(root .. "/a.lua") + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + return root, p, view_in_origin(p) + end + local function lines_of(t) + return table.concat(t, "\n") .. "\n" + end + + -- hunk 3 staged without its `url` line and hunk 4 staged whole: u on 3 leaves 4 alone + it("unstages a partly staged hunk without reaching the whole one beside it", function() + local h, w = vim.split(ACME_HEAD, "\n"), vim.split(ACME_WORK, "\n") + local function joined(...) + local out = {} + for _, part in ipairs({ ... }) do + vim.list_extend(out, part) + end + return table.concat(out, "\n") + end + local index = + joined(vim.list_slice(w, 1, 16), vim.list_slice(w, 18, 20), vim.list_slice(h, 17)) + local root, p, v = staged_as(ACME_HEAD, index, ACME_WORK) + local function states() + local out = {} + for i = 1, #v.model.hunks do + out[i] = v:_hunk_state(i) + end + return out + end + local before, hidden = states(), v.staging.hidden_in + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 3), 0 }) + v:unstage_hunk() + local after, written = states(), indexed(root, "a.lua") + p:close() + + local S, U, P = "staged", "unstaged", "partial" + assert.are.same({ S, S, P, S, U, U }, before) + assert.is_nil(hidden) + assert.are.same({ S, S, U, S, U, U }, after) + local want = + joined(vim.list_slice(w, 1, 14), vim.list_slice(h, 13, 14), vim.list_slice(h, 17)) + assert.are.equal(want, written) + end) + -- a change staged and then edited again within a few lines merges into one -- HEAD↔worktree hunk holding both. it reads as partial, and per line the marks still -- say which of it the index has diff --git a/test/unit/model_apply_spec.lua b/test/unit/model_apply_spec.lua index 02e92e4..cb473cf 100644 --- a/test/unit/model_apply_spec.lua +++ b/test/unit/model_apply_spec.lua @@ -97,3 +97,35 @@ describe("splice", function() end) end) end) + +describe("join", function() + -- 1,2,3,4,5 -> 1,2x,3,4y,5y + local two = model( + "1\n2\n3\n4\n5\n", + "1\n2x\n3\n4y\n5y\n", + { h(2, { "2" }, 2, { "2x" }), h(4, { "4", "5" }, 4, { "4y", "5y" }) } + ) + + it("rejoins each side's own blocks to that side's text", function() + assert.are.equal(two.old_text, apply.join(two, { { "2" }, { "4", "5" } }, two.old_text)) + assert.are.equal(two.new_text, apply.join(two, { { "2x" }, { "4y", "5y" } }, two.new_text)) + end) + + it("places any lines at a hunk between the unchanged ones", function() + local text = apply.join(two, { { "2", "z" }, {} }, two.old_text) + assert.are.equal("1\n2\nz\n3\n", text) + end) + + describe("the final newline", function() + it("ends as the text given when no hunk reaches eof", function() + local m = model("a\nb\nc", "A\nb\nc\n", { h(1, { "a" }, 1, { "A" }) }) + assert.are.equal("A\nb\nc\n", apply.join(m, { { "A" } }, "A\nb\nc\n")) + end) + + it("ends as a side does when the last hunk reaches eof holding its lines", function() + local m = model("a\nb", "a\nB\n", { h(2, { "b" }, 2, { "B" }) }) + assert.are.equal("a\nB\n", apply.join(m, { { "B" } }, "a\nb")) + assert.are.equal("a\nb", apply.join(m, { { "b" } }, "a\nB\n")) + end) + end) +end) diff --git a/test/unit/model_marks_spec.lua b/test/unit/model_marks_spec.lua index c589f74..0ae951a 100644 --- a/test/unit/model_marks_spec.lua +++ b/test/unit/model_marks_spec.lua @@ -226,52 +226,71 @@ describe("union marks", function() end) end) - describe("spliced", function() + describe("blocks", function() -- HEAD a b c d, worktree a B c D e: b and d rewritten, e added at the end local head = { "a", "b", "c", "d" } local union = { hl(2, { "b" }, 2, { "B" }), hl(4, { "d" }, 4, { "D", "e" }) } - it("takes no hunk when the index is HEAD", function() - assert.are.same({ false, false }, marks.spliced(union, head, head)) + it("reads HEAD's lines at every hunk of an index that is HEAD", function() + assert.are.same({ { "b" }, { "d" } }, marks.blocks(union, head, head)) end) - it("takes every hunk when the index is the worktree", function() - local index = { "a", "B", "c", "D", "e" } - assert.are.same({ true, true }, marks.spliced(union, head, index)) + it("reads whatever the index holds at a hunk", function() + local index = { "a", "B", "c", "D" } + assert.are.same({ { "B" }, { "D" } }, marks.blocks(union, head, index)) + index = { "a", "b", "X", "Y", "c", "d" } + assert.are.same({ { "b", "X", "Y" }, { "d" } }, marks.blocks(union, head, index)) end) - it("names the hunks taken", function() - local index = { "a", "b", "c", "D", "e" } - assert.are.same({ false, true }, marks.spliced(union, head, index)) + it("gives nil when the index changes a line between hunks", function() + assert.is_nil(marks.blocks(union, head, { "a", "b", "C", "d" })) end) - it("gives nil for part of a hunk", function() - assert.is_nil(marks.spliced(union, head, { "a", "b", "c", "D" })) + -- HEAD a x c, worktree a x x c: the inserted x also fits HEAD's own x + it("settles a block its unchanged lines repeat by the lines after it", function() + local repeat_head = { "a", "x", "c" } + local insert = { hl(1, {}, 2, { "x" }) } + assert.are.same({ {} }, marks.blocks(insert, repeat_head, repeat_head)) + local index = { "a", "x", "x", "c" } + assert.are.same({ { "x" } }, marks.blocks(insert, repeat_head, index)) end) - it("gives nil for a line neither side has", function() - assert.is_nil(marks.spliced(union, head, { "a", "X", "c", "d" })) - assert.is_nil(marks.spliced(union, head, { "a", "b", "c", "d", "z" })) + it("gives the last hunk the rest of the index", function() + local index = { "a", "b", "c", "D", "e", "f" } + assert.are.same({ { "b" }, { "D", "e", "f" } }, marks.blocks(union, head, index)) end) + end) - -- HEAD a x c, worktree a x x c: the inserted x also fits HEAD's own x - it("settles a hunk its unchanged lines repeat by the lines after it", function() - local repeat_head = { "a", "x", "c" } - local insert = { hl(1, {}, 2, { "x" }) } - assert.are.same({ false }, marks.spliced(insert, repeat_head, repeat_head)) - assert.are.same({ true }, marks.spliced(insert, repeat_head, { "a", "x", "x", "c" })) + describe("of_blocks", function() + -- HEAD 1 a b 4, worktree 1 A B C 4: one hunk rewriting two lines as three + local union = { hl(2, { "a", "b" }, 2, { "A", "B", "C" }) } + + it("reads a block of HEAD's lines as unstaged and the worktree's as staged", function() + local m = marks.of_blocks(union, { { "a", "b" } }) + assert.are.same({ "unstaged" }, states(m, union)) + m = marks.of_blocks(union, { { "A", "B", "C" } }) + assert.are.same({ "staged" }, states(m, union)) + end) + + it("marks each line of a block that holds part of the hunk", function() + local m, hidden = marks.of_blocks(union, { { "a", "B" } }) + assert.are.same({ "partial" }, states(m, union)) + assert.are.same({ [2] = false, [3] = true }, m.old) + assert.are.same({ [2] = false, [3] = true, [4] = false }, m.new) + assert.are.same({}, hidden) end) - it("places an insertion at the top of the file", function() - local top = { hl(0, {}, 1, { "z" }) } - assert.are.same({ true }, marks.spliced(top, head, { "z", "a", "b", "c", "d" })) + it("names the hunk whose block holds a line neither side has", function() + local _, hidden = marks.of_blocks(union, { { "a", "Z" } }) + assert.are.same({ 1 }, hidden) end) - it("marks each hunk's lines as the hunk is held", function() - local m = marks.of_hunks(union, { false, true }) - assert.are.same({ "unstaged", "staged" }, states(m, union)) - assert.are.same({ [2] = false, [4] = true }, m.old) - assert.are.same({ [2] = false, [4] = true, [5] = true }, m.new) + -- HEAD x a, worktree a x: a block of HEAD's lines pairs with the old side first + it("reads a moved line from HEAD before the worktree", function() + local moved = { hl(1, { "x", "a" }, 1, { "a", "x" }) } + local m, hidden = marks.of_blocks(moved, { { "x", "a" } }) + assert.are.same({ "unstaged" }, states(m, moved)) + assert.are.same({}, hidden) end) end) From 6e9d3d462ee51e55966b400c0375611a4a4afe25 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:07:53 +0100 Subject: [PATCH 27/93] fix: stage and unstage the whole file at once on S and U --- lua/differ/git/init.lua | 14 +++++++++++++ lua/differ/view.lua | 17 +++++++++------ test/nvim/git_spec.lua | 46 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 6 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 99d246b..3de754e 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -1677,6 +1677,20 @@ function M.panel(opts) remark(union_pairs(root, entry.path, union.old_text, text, work)) return true end + -- S and U: the index takes the worktree's or HEAD's text whole, staged content no + -- hunk shows included. the entry's mode is left as it is + staging.set_all = function(staged) + local union, cached = M.union_models(root, entry) + local text = union.old_text + if staged then + text = union.new_text + end + if text == cached.new_text or not put_index(entry, text) then + return false + end + remark(union_pairs(root, entry.path, union.old_text, text, union.new_text)) + return true + end return staging end diff --git a/lua/differ/view.lua b/lua/differ/view.lua index fe976d2..eafd0dc 100644 --- a/lua/differ/view.lua +++ b/lua/differ/view.lua @@ -89,6 +89,7 @@ local armed_view = nil ---@field leave? fun() -- a frozen view: back to the whole change, whose new side is the file to edit ---@field badge? string -- winbar tag naming a view that isn't the whole change ---@field unstage_hidden? fun(idx: integer): boolean -- u on a `!` hunk: drop the staged change it undoes +---@field set_all? fun(staged: boolean): boolean -- S / U on the whole file at once; whether the index moved ---@class differ.View ---@field columns differ.ViewColumn[] @@ -1367,9 +1368,9 @@ function View:unstage_all() end end --- stage / unstage every hunk; the panel refreshes once after the batch. returns --- whether anything changed (false when already wholly in the target state), so S/U --- can fall through to file stepping +-- stage / unstage every hunk, through the source's whole-file op when it has one; the +-- panel refreshes once after the batch. returns whether anything changed (false when +-- already wholly in the target state), so S/U can fall through to file stepping ---@param want_staged boolean ---@return boolean changed function View:_toggle_all(want_staged) @@ -1378,9 +1379,13 @@ function View:_toggle_all(want_staged) return false end local changed = false - for i = 1, self:_slot_count() do - if self:_apply_hunk(i, want_staged) then - changed = true + if self.staging.set_all then + changed = self.staging.set_all(want_staged) + else + for i = 1, self:_slot_count() do + if self:_apply_hunk(i, want_staged) then + changed = true + end end end if changed then diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index 1187753..684e685 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -2215,6 +2215,52 @@ func getDownloadSpeed() { assert.are.equal(want, written) end) + -- HEAD 1..7, index 1 A B C D E 7, worktree 1 A2 3 4 D2 E2 7: the index's B C sit + -- between the two hunks, where neither u nor s on one hunk can reach them + local JOINED = { + head = lines_of({ "1", "2", "3", "4", "5", "6", "7" }), + index = lines_of({ "1", "A", "B", "C", "D", "E", "7" }), + work = lines_of({ "1", "A2", "3", "4", "D2", "E2", "7" }), + } + + it("U gives the index HEAD's text where hunk by hunk can't", function() + local root, p, v = staged_as(JOINED.head, JOINED.index, JOINED.work) + v:unstage_all() + local written, hidden = indexed(root, "a.lua"), v.staging.hidden + p:close() + assert.are.equal(JOINED.head, written) + assert.is_nil(hidden) + end) + + it("S gives the index the worktree's text where hunk by hunk can't", function() + local root, p, v = staged_as(JOINED.head, JOINED.index, JOINED.work) + v:stage_all() + local written, hidden = indexed(root, "a.lua"), v.staging.hidden + p:close() + assert.are.equal(JOINED.work, written) + assert.is_nil(hidden) + end) + + -- 5x staged and then put back to 5 on disk: no hunk shows it, U still takes it out + it("U drops staged content no hunk shows", function() + local ten = {} + for i = 1, 10 do + ten[i] = tostring(i) + end + local head = lines_of(ten) + ten[5] = "5x" + local index = lines_of(ten) + ten[5], ten[1] = "5", "1x" + local root, p, v = staged_as(head, index, lines_of(ten)) + local hidden_before = v.staging.hidden + v:unstage_all() + local written, hidden = indexed(root, "a.lua"), v.staging.hidden + p:close() + assert.is_not_nil(hidden_before) + assert.are.equal(head, written) + assert.is_nil(hidden) + end) + -- a change staged and then edited again within a few lines merges into one -- HEAD↔worktree hunk holding both. it reads as partial, and per line the marks still -- say which of it the index has From 9895adf53951cbe74bd306690da70c0c14b6fe44 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Mon, 14 Sep 2026 15:07:53 +0100 Subject: [PATCH 28/93] fix: point a shared staged change at dw --- lua/differ/git/init.lua | 8 ++++---- test/nvim/git_spec.lua | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 3de754e..2e0e8e7 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -1575,12 +1575,12 @@ function M.panel(opts) if not other then return false end - local what, where = "staged", "gs" + local msg = "this hunk's staged change also covers hunk %d: " + .. "u on the ! hunk in dw, or in gs, unstages it whole" if side == "new" then - what, where = "unstaged", "dw" + msg = "this hunk's unstaged change also covers hunk %d: s in dw stages it whole" end - local msg = "this hunk and hunk %d share one %s change: %s takes it whole" - notify(msg:format(other, what, where), vim.log.levels.WARN) + notify(msg:format(other), vim.log.levels.WARN) return true end diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index 684e685..04c1fe8 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -2413,9 +2413,9 @@ func getDownloadSpeed() { end for _, case in ipairs({ - { key = "s", op = "stage_hunk", what = "unstaged change: dw" }, - { key = "u", op = "unstage_hunk", what = "staged change: gs" }, - { key = "X", op = "revert_hunk", what = "staged change: gs" }, + { key = "s", op = "stage_hunk", what = "unstaged change also covers hunk 2: s in dw" }, + { key = "u", op = "unstage_hunk", what = "staged change also covers hunk 2: u on the !" }, + { key = "X", op = "revert_hunk", what = "staged change also covers hunk 2: u on the !" }, }) do it(("refuses %s on a hunk sharing a change with the next"):format(case.key), function() local root = fresh_repo() @@ -2433,7 +2433,7 @@ func getDownloadSpeed() { p:close() assert.are.equal(2, hunks) assert.are.same(before, after) - assert.is_truthy(said:find("this hunk and hunk 2 share one " .. case.what, 1, true)) + assert.is_truthy(said:find("this hunk's " .. case.what, 1, true)) end) end From 099b2573446d47cf417b98092577f9381141dc49 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:46:04 +0100 Subject: [PATCH 29/93] fix: refuse staging on a diff drawn before the file changed --- lua/differ/git/init.lua | 30 ++++++++++++++++++++++++-- lua/differ/view.lua | 5 +++-- test/nvim/git_spec.lua | 48 +++++++++++++++++++++++++++++++++++++++-- 3 files changed, 77 insertions(+), 6 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 2e0e8e7..2847186 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -1633,12 +1633,32 @@ function M.panel(opts) return splice(from, select_hunks(from.hunks, side, hunk, side, take)) end + -- whether the diff still shows the file as it is. one drawn before the file or + -- HEAD moved would place an op by stale line numbers, so it refuses and re-reads + ---@param model differ.DiffModel -- the view's + ---@param union differ.DiffModel -- read fresh + ---@return boolean + local function drawn_current(model, union) + if model.old_text == union.old_text and model.new_text == union.new_text then + return true + end + notify("the file changed since the diff was drawn: re-reading", vim.log.levels.WARN) + vim.schedule(function() + refresh_panel() + retarget_view(false) + end) + return false + end + remark(M.union_models(root, entry)) - staging.apply = function(_, hunk, reverse) + staging.apply = function(model, hunk, reverse) if not hunk then return false end local union, cached, unstaged = M.union_models(root, entry) + if not drawn_current(model, union) then + return false + end local text = next_index(union, cached, unstaged, hunk, not reverse) if not text or not put_index(entry, text) then return false @@ -1653,6 +1673,9 @@ function M.panel(opts) -- clean filter, so writing it back would push that conversion to disk staging.revert = function(model, idx) local union, cached, unstaged = M.union_models(root, entry) + if not drawn_current(model, union) then + return false + end local hunk = model.hunks[idx] local text = next_index(union, cached, unstaged, hunk, false) if not text then @@ -1679,8 +1702,11 @@ function M.panel(opts) end -- S and U: the index takes the worktree's or HEAD's text whole, staged content no -- hunk shows included. the entry's mode is left as it is - staging.set_all = function(staged) + staging.set_all = function(model, staged) local union, cached = M.union_models(root, entry) + if not drawn_current(model, union) then + return false + end local text = union.old_text if staged then text = union.new_text diff --git a/lua/differ/view.lua b/lua/differ/view.lua index eafd0dc..73c9540 100644 --- a/lua/differ/view.lua +++ b/lua/differ/view.lua @@ -89,7 +89,8 @@ local armed_view = nil ---@field leave? fun() -- a frozen view: back to the whole change, whose new side is the file to edit ---@field badge? string -- winbar tag naming a view that isn't the whole change ---@field unstage_hidden? fun(idx: integer): boolean -- u on a `!` hunk: drop the staged change it undoes ----@field set_all? fun(staged: boolean): boolean -- S / U on the whole file at once; whether the index moved +-- `set_all` runs S / U on the whole file at once, and says whether the index moved +---@field set_all? fun(model: differ.DiffModel, staged: boolean): boolean ---@class differ.View ---@field columns differ.ViewColumn[] @@ -1380,7 +1381,7 @@ function View:_toggle_all(want_staged) end local changed = false if self.staging.set_all then - changed = self.staging.set_all(want_staged) + changed = self.staging.set_all(self.model, want_staged) else for i = 1, self:_slot_count() do if self:_apply_hunk(i, want_staged) then diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index 04c1fe8..ee4c1cc 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -2261,6 +2261,50 @@ func getDownloadSpeed() { assert.is_nil(hidden) end) + -- the file moves on disk after the diff is drawn and before the watcher re-reads it + for _, case in ipairs({ + { + what = "an edit inside a hunk", + edit = function(t) + t[10] = "10z" + end, + }, + { + what = "a line above every hunk", + edit = function(t) + table.insert(t, 1, "0") + end, + }, + }) do + it(("refuses s on a diff drawn before %s, then re-reads"):format(case.what), function() + local t = {} + for i = 1, 12 do + t[i] = tostring(i) + end + local head = lines_of(t) + t[3], t[10] = "3x", "10x" + local root, p, v = staged_as(head, head, lines_of(t)) + local drawn = v.model + case.edit(t) + write(root .. "/a.lua", lines_of(t)) + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 2), 0 }) + _G.notifs = {} + v:stage_hunk() + local written = indexed(root, "a.lua") + local said = (_G.notifs[#_G.notifs] or {}).msg or "" + vim.wait(1000, function() + return v.model ~= drawn + end, 20) + local reread = v.model.new_text + p:close() + assert.are.equal(head, written) + assert.is_truthy(said:find("changed since the diff was drawn", 1, true)) + assert.are.equal(lines_of(t), reread) + end) + end + -- a change staged and then edited again within a few lines merges into one -- HEAD↔worktree hunk holding both. it reads as partial, and per line the marks still -- say which of it the index has @@ -2437,7 +2481,7 @@ func getDownloadSpeed() { end) end - -- the file is edited under the open view: X can't take the hunk out of it, so the + -- the file is edited under the open view: X refuses before either side moves, so the -- index keeps its half too rather than dropping it alone it("X leaves the index alone when the file won't take the revert", function() local root = fresh_repo() @@ -2465,7 +2509,7 @@ func getDownloadSpeed() { local said = (_G.notifs[#_G.notifs] or {}).msg local index, work = indexed(root, "a.lua"), worktree(root, "a.lua") p:close() - assert.are.equal("differ: the file has changed these lines: nothing reverted", said) + assert.are.equal("differ: the file changed since the diff was drawn: re-reading", said) assert.are.equal("1x\n2\n3\n4\n5\n6\n7\n8\n9\n10\n", index) assert.are.equal(edited, work) end) From 42058c56e3cc501e82cca844c26f0b8172649a98 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:23:33 +0100 Subject: [PATCH 30/93] fix: cut the index by whole hunks first when repeated lines fit two ways --- lua/differ/model/marks.lua | 157 ++++++++++++++++++++++++--------- test/nvim/git_spec.lua | 36 ++++++++ test/unit/model_marks_spec.lua | 22 +++++ 3 files changed, 172 insertions(+), 43 deletions(-) diff --git a/lua/differ/model/marks.lua b/lua/differ/model/marks.lua index 63a9beb..5b9b401 100644 --- a/lua/differ/model/marks.lua +++ b/lua/differ/model/marks.lua @@ -88,44 +88,63 @@ local function old_bounds(h) return first, first + h.old_count end --- the block lengths to try for hunk `h` with `left` index lines to go: its own two --- sides first, then the rest shortest first ----@param h differ.Hunk ----@param left integer ----@return integer[] -local function block_lengths(h, left) - local out, seen = {}, {} - for _, n in ipairs({ h.new_count, h.old_count }) do - if n <= left and not seen[n] then - out[#out + 1], seen[n] = n, true +-- whether the index holds `lines` from line `at` +---@param index string[] +---@param at integer +---@param lines string[] +---@return boolean +local function holds(index, at, lines) + for k, line in ipairs(lines) do + if index[at + k - 1] ~= line then + return false end end - for n = 0, left do - if not seen[n] then - out[#out + 1] = n - end + return true +end + +---@alias differ.model.BlockLengths fun(h: differ.Hunk, index: string[], at: integer): integer[] + +-- lengths for a block that is exactly one of the hunk's sides +---@type differ.model.BlockLengths +local function whole_lengths(h, index, at) + local out = {} + if holds(index, at, h.new_lines) then + out[#out + 1] = h.new_count + end + if holds(index, at, h.old_lines) and h.old_count ~= out[1] then + out[#out + 1] = h.old_count end return out end --- the index's lines at each union hunk, when the index holds every unchanged line --- between them; nil when it doesn't. where unchanged lines repeat, a hunk's block --- ends at the first length that lets the rest of the index through ----@param union differ.Hunk[] -- HEAD↔worktree ----@param head string[] -- HEAD's lines ----@param index string[] -- the index's lines ----@return string[][]|nil -function M.blocks(union, head, index) +-- every length the rest of the index allows, shortest first +---@type differ.model.BlockLengths +local function any_lengths(_, index, at) local out = {} - local stuck = {} ---@type table -- "hunk:index line" with no way through + for n = 0, #index - at + 1 do + out[#out + 1] = n + end + return out +end + +-- the ways to cut the index into a block per union hunk, with HEAD's unchanged lines +-- between them: the first cut, and how many there are (0, 1, or 2 for two or more) +---@param union differ.Hunk[] +---@param head string[] +---@param index string[] +---@param lengths differ.model.BlockLengths +---@return string[][] cut, integer ways +local function cut(union, head, index, lengths) + local ways = {} ---@type table -- "hunk:index line" -> ways through + local picks = {} ---@type table -- "hunk:index line" -> first block's {at, n} ---@param i integer -- the next union hunk ---@param from integer -- the next HEAD line ---@param at integer -- the next index line - ---@return boolean - local function walk(i, from, at) + ---@return integer + local function count(i, from, at) local key = i .. ":" .. at - if stuck[key] then - return false + if ways[key] then + return ways[key] end local h = union[i] local first, after = #head + 1, #head + 1 @@ -134,33 +153,72 @@ function M.blocks(union, head, index) end for l = from, first - 1 do if index[at] ~= head[l] then - stuck[key] = true - return false + ways[key] = 0 + return 0 end at = at + 1 end if not h then - return at == #index + 1 + ways[key] = 0 + if at == #index + 1 then + ways[key] = 1 + end + return ways[key] end - for _, n in ipairs(block_lengths(h, #index - at + 1)) do + local total = 0 + for _, n in ipairs(lengths(h, index, at)) do -- the unchanged line after the block, when there is one, has to come next local fits = after > #head or index[at + n] == head[after] - if fits and walk(i + 1, after, at + n) then - local block = {} - for l = at, at + n - 1 do - block[#block + 1] = index[l] - end - out[i] = block - return true + local through = 0 + if fits then + through = count(i + 1, after, at + n) + end + if through > 0 and total == 0 then + picks[key] = { at, n } + end + total = total + through + if total >= 2 then + break end end - stuck[key] = true - return false + ways[key] = math.min(total, 2) + return ways[key] end - if not walk(1, 1, 1) then - return nil + local total = count(1, 1, 1) + local out, at = {}, 1 + if total == 0 then + return out, 0 end - return out + for i in ipairs(union) do + local pick = picks[i .. ":" .. at] + local block = {} + for l = pick[1], pick[1] + pick[2] - 1 do + block[#block + 1] = index[l] + end + out[i] = block + at = pick[1] + pick[2] + end + return out, total +end + +-- the index's lines at each union hunk, when the index holds every unchanged line +-- between them. a cut where every block is one of its hunk's sides wins; nil when the +-- index can't be cut, or can be cut more than one way +---@param union differ.Hunk[] -- HEAD↔worktree +---@param head string[] -- HEAD's lines +---@param index string[] -- the index's lines +---@return string[][]|nil +function M.blocks(union, head, index) + for _, lengths in ipairs({ whole_lengths, any_lengths }) do + local found, ways = cut(union, head, index, lengths) + if ways == 1 then + return found + end + if ways > 1 then + return nil + end + end + return nil end -- LCS grid size past which a hunk's block isn't compared line by line @@ -214,6 +272,19 @@ end ---@return boolean compared -- false for a hunk too big to compare ---@return boolean hidden -- a block line neither side has local function mark_block(h, block, marks) + -- a block that is one whole side is that side, even where the two sides share a line + for _, staged in ipairs({ true, false }) do + local side = staged and h.new_lines or h.old_lines + if #block == #side and holds(block, 1, side) then + for l = h.old_start, h.old_start + h.old_count - 1 do + marks.old[l] = staged + end + for l = h.new_start, h.new_start + h.new_count - 1 do + marks.new[l] = staged + end + return true, false + end + end local kept, used = common(h.old_lines, block) if not (kept and used) then return false, false diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index ee4c1cc..78df123 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -2178,6 +2178,42 @@ func getDownloadSpeed() { return table.concat(t, "\n") .. "\n" end + -- HEAD a b _ _ c d, worktree a b B _ d: the blank beside hunk 1 also fits hunk 2 + it("stages and reverts by hunk where a blank line fits either hunk", function() + local head = lines_of({ "a", "b", "", "", "c", "d" }) + local root, p, v = staged_as(head, head, lines_of({ "a", "b", "B", "", "d" })) + local function states() + local out = {} + for i = 1, #v.model.hunks do + out[i] = v:_hunk_state(i) + end + return out + end + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + local before, hidden = states(), v.staging.hidden + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + v:stage_hunk() + local staged, index = states(), indexed(root, "a.lua") + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + v:unstage_hunk() + local unstaged = indexed(root, "a.lua") + local reverted = v.staging.revert(v.model, 1) + local after, work = + indexed(root, "a.lua"), table.concat(vim.fn.readfile(root .. "/a.lua"), "\n") + p:close() + + local S, U = "staged", "unstaged" + assert.are.same({ U, U }, before) + assert.is_nil(hidden) + assert.are.same({ S, U }, staged) + assert.are.equal(lines_of({ "a", "b", "B", "", "", "c", "d" }), index) + assert.are.equal(head, unstaged) + assert.is_true(reverted) + assert.are.equal(head, after) + assert.are.equal("a\nb\n\nd", work) + end) + -- hunk 3 staged without its `url` line and hunk 4 staged whole: u on 3 leaves 4 alone it("unstages a partly staged hunk without reaching the whole one beside it", function() local h, w = vim.split(ACME_HEAD, "\n"), vim.split(ACME_WORK, "\n") diff --git a/test/unit/model_marks_spec.lua b/test/unit/model_marks_spec.lua index 0ae951a..b4dcdc1 100644 --- a/test/unit/model_marks_spec.lua +++ b/test/unit/model_marks_spec.lua @@ -255,6 +255,18 @@ describe("union marks", function() assert.are.same({ { "x" } }, marks.blocks(insert, repeat_head, index)) end) + -- HEAD a b _ _ c d, worktree a b B _ d: a blank either side of hunk 1's insertion, + -- so the index's first blank fits hunk 1's block or the context after it + it("reads a block by the hunk's own lines where a repeated line fits two ways", function() + local blank = { "a", "b", "", "", "c", "d" } + local around = { hl(2, {}, 3, { "B" }), hl(4, { "", "c" }, 4, {}) } + assert.are.same({ {}, { "", "c" } }, marks.blocks(around, blank, blank)) + local index = { "a", "b", "B", "", "", "c", "d" } + assert.are.same({ { "B" }, { "", "c" } }, marks.blocks(around, blank, index)) + index = { "a", "b", "", "d" } + assert.are.same({ {}, {} }, marks.blocks(around, blank, index)) + end) + it("gives the last hunk the rest of the index", function() local index = { "a", "b", "c", "D", "e", "f" } assert.are.same({ { "b" }, { "D", "e", "f" } }, marks.blocks(union, head, index)) @@ -285,6 +297,16 @@ describe("union marks", function() assert.are.same({ 1 }, hidden) end) + -- HEAD a a, worktree a z: the two sides share an a + it("reads a block that is one whole side as that side", function() + local shared = { hl(1, { "a", "a" }, 1, { "a", "z" }) } + assert.are.same({ "staged" }, states(marks.of_blocks(shared, { { "a", "z" } }), shared)) + assert.are.same( + { "unstaged" }, + states(marks.of_blocks(shared, { { "a", "a" } }), shared) + ) + end) + -- HEAD x a, worktree a x: a block of HEAD's lines pairs with the old side first it("reads a moved line from HEAD before the worktree", function() local moved = { hl(1, { "x", "a" }, 1, { "a", "x" }) } From 381641d6be5c4ec04152b20d54743e157943912c Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:30:32 +0100 Subject: [PATCH 31/93] fix: place u on a ! hunk by the index the local view has written --- lua/differ/git/init.lua | 35 +++++++++++++++++++++++++++++------ test/nvim/git_spec.lua | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 2847186..f15871b 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -1985,14 +1985,37 @@ function M.panel(opts) local show_local ---@type fun(entry: differ.FileEntry) -- u on a `!` hunk in the local view: the index takes HEAD's lines under it, dropping - -- the staged change the worktree undid, then the view re-reads against that index + -- the staged change the worktree undid, then the view re-reads against that index. + -- the hunk's lines are the index's at open, moved by whatever s has staged since ---@param entry differ.FileEntry - ---@param hunk differ.Hunk -- index↔worktree + ---@param model differ.DiffModel -- index↔worktree, as the view opened on it + ---@param staging differ.view.Staging + ---@param idx integer ---@return boolean - local function drop_hidden(entry, hunk) + local function drop_hidden(entry, model, staging, idx) + local marks = require("differ.model.marks") + local splice = require("differ.model.apply").splice + local applied, moved = {}, {} ---@type table, differ.Hunk[] + for i, h in ipairs(model.hunks) do + applied[i] = marks.state(staging.marks, h) == "staged" + if applied[i] then + moved[#moved + 1] = h + end + end local _, cached = M.union_models(root, entry) - local applied = select_hunks(cached.hunks, "new", hunk, "old", false) - if not put_index(entry, require("differ.model.apply").splice(cached, applied)) then + if cached.new_text ~= splice(model, applied) then + notify("the index changed outside differ: re-reading", vim.log.levels.WARN) + vim.schedule(function() + show_local(entry) + end) + return false + end + local hunk = model.hunks[idx] + local placed = vim.tbl_extend("force", hunk, { + old_start = hunk.old_start + marks.shift(moved, hunk.old_start, "old"), + }) + local keep = select_hunks(cached.hunks, "new", placed, "old", false) + if not put_index(entry, splice(cached, keep)) then return false end refresh_panel() @@ -2022,7 +2045,7 @@ function M.panel(opts) local hidden_in = require("differ.model.marks").restaged(model.hunks, cached.hunks) staging.hidden_in = hidden_in staging.unstage_hidden = function(idx) - return drop_hidden(entry, model.hunks[idx]) + return drop_hidden(entry, model, staging, idx) end staging.revert = function(m, idx) return revert_frozen(entry, m, staging, idx, 0, function() diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index 78df123..ede26df 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -2936,6 +2936,38 @@ func getDownloadSpeed() { assert.are.equal(twelve({ "1x", "2x" }), index) end) + it("u on a ! hunk refuses an index staged from outside since the view opened", function() + local root = staged_then(twelve({ [12] = "12x" })) + local p, v = local_view_at(1) + stage_behind(root, twelve({ "1x", "2x" })) + _G.notifs = {} + v:unstage_hunk() + local said = (_G.notifs[#_G.notifs] or {}).msg + local index = indexed(root, "a.lua") + p:close() + assert.are.equal("differ: the index changed outside differ: re-reading", said) + assert.are.equal(twelve({ "1x", "2x" }), index) + end) + + -- 5x staged and put back on disk, 0 added above it: s on the 0 moves the 5x down a line + it("u on a ! hunk finds its line below a hunk s staged in the same view", function() + local root = fresh_repo() + write(root .. "/a.lua", twelve({})) + git(root, "commit", "-q", "-am", "twelve lines") + stage_behind(root, twelve({ [5] = "5x" })) + write(root .. "/a.lua", "0\n" .. twelve({ [12] = "12x" })) + vim.cmd.edit(root .. "/a.lua") + local p, v = local_view_at(1) + local marked = v.staging.hidden_in + v:stage_hunk() + vim.api.nvim_win_set_cursor(0, { hunk_line(v, 2), 0 }) + v:unstage_hunk() + local index = indexed(root, "a.lua") + p:close() + assert.are.same({ 2 }, marked) + assert.are.equal("0\n" .. twelve({}), index) + end) + it("X in the local view puts the worktree hunk back to the index's version", function() local root = staged_then(twelve({ "1x", [6] = "6y", [12] = "12y" })) local p, v = local_view_at(1) From f7ae6314c3b582f190e5ec3fe248d25b1c83d7fb Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:01:43 +0100 Subject: [PATCH 32/93] fix: re-mark the diff after an outside stage that leaves the row unchanged --- lua/differ/git/init.lua | 13 ++++++++----- test/nvim/git_spec.lua | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index f15871b..0cfa8bd 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -1373,11 +1373,11 @@ function M.panel(opts) local stageable = is_worktree_status(source) 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 index - -- side) + the shown file's mtime/size (the worktree side). external refreshes act - -- only when it moved, so a stray event doesn't re-source over an in-progress - -- in-differ staging session, and a differ stage records it so the index write it - -- caused doesn't read as outside. content-aware so a worktree edit counts too + -- a cheap fingerprint of the diff's inputs: HEAD + porcelain status + the shown + -- file's index blobs (the index side) + its mtime/size (the worktree side). external + -- refreshes act only when it moved, so a stray event doesn't re-source over an + -- in-progress in-differ staging session, and a differ stage records it so the index + -- write it caused doesn't read as outside local function git_signature() if not stageable then return "" @@ -1386,6 +1386,9 @@ function M.panel(opts) .. "\0" .. (git({ "status", "--porcelain=v1", "-z", "-uall" }, root) or "") if active_entry then + -- a partial stage that leaves the row MM moves only the blob + local blobs = { "ls-files", "-s", "--", active_entry.path, active_entry.previous_path } + sig = sig .. "\0" .. (git(blobs, root) or "") local st = (vim.uv or vim.loop).fs_stat(root .. "/" .. active_entry.path) if st and st.mtime then sig = sig .. "\0" .. st.mtime.sec .. "." .. st.mtime.nsec .. ":" .. st.size diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index ede26df..192be07 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -2968,6 +2968,27 @@ func getDownloadSpeed() { assert.are.equal("0\n" .. twelve({}), index) end) + -- 1x staged, 6y and 12x not: staging 6y from outside leaves the row MM + it("re-marks a partial stage from outside that leaves the row as it was", function() + local root, p, v = + staged_as(twelve({}), twelve({ "1x" }), twelve({ "1x", [6] = "6y", [12] = "12x" })) + local function states() + local out = {} + for i = 1, #v.model.hunks do + out[i] = v:_hunk_state(i) + end + return out + end + local before = states() + stage_behind(root, twelve({ "1x", [6] = "6y" })) + p.on_external_change() + local after = states() + p:close() + local S, U = "staged", "unstaged" + assert.are.same({ S, U, U }, before) + assert.are.same({ S, S, U }, after) + end) + it("X in the local view puts the worktree hunk back to the index's version", function() local root = staged_then(twelve({ "1x", [6] = "6y", [12] = "12y" })) local p, v = local_view_at(1) From cd36588bf65993d53d8c956ba155248ea5d155c2 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:12:46 +0100 Subject: [PATCH 33/93] fix: read the index as stage 0 and refuse an op when it can't be read --- lua/differ/git/init.lua | 59 ++++++++++++++++++++++++++++++++--------- test/nvim/git_spec.lua | 39 +++++++++++++++++++++++++++ 2 files changed, 85 insertions(+), 13 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 0cfa8bd..cdab26e 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -305,7 +305,7 @@ local function as_staged(root, relpath, data) return data end -- raw read, no text=true: that would strip the CRs git chose to keep - local show = vim.system({ "git", "show", ":" .. relpath }, { cwd = root, env = env }):wait() + local show = vim.system({ "git", "show", ":0:" .. relpath }, { cwd = root, env = env }):wait() os.remove(tmp) if show.code ~= 0 or not show.stdout then return data @@ -327,8 +327,9 @@ function M.read(ref, root, relpath) local data = read_file(root .. "/" .. relpath) return data and as_staged(root, relpath, data) or nil end - -- index (stage 0) is `:path`; a rev is `:path` - local spec = (ref.kind == "index" and ":" or (ref.rev .. ":")) .. relpath + -- a rev is `:path`; the index is `:0:path`, since `:path` reads a leading + -- `1:` as a stage number + local spec = (ref.kind == "index" and ":0:" or (ref.rev .. ":")) .. relpath return git_raw({ "show", spec }, root) -- nil if the path is absent in that tree end @@ -691,12 +692,12 @@ local function union_pairs(root, path, head, index, work) pair("INDEX", "WORKTREE", index, work) end --- a row's three diffs, read fresh. HEAD is read at a rename's old path, and so is the --- index for a rename only the worktree has made (` R`), whose entry at the new path is --- an empty intent-to-add placeholder +-- a row's three diffs, read fresh, or nil when the index can't be read. HEAD is read at +-- a rename's old path, and so is the index for a rename only the worktree has made +-- (` R`), whose entry at the new path is an empty intent-to-add placeholder ---@param root string ---@param entry differ.FileEntry ----@return differ.DiffModel union, differ.DiffModel cached, differ.DiffModel unstaged +---@return differ.DiffModel|nil union, differ.DiffModel|nil cached, differ.DiffModel|nil unstaged function M.union_models(root, entry) local prev = entry.previous_path local at_head = prev or entry.path @@ -704,11 +705,15 @@ function M.union_models(root, entry) if entry.y == "R" and prev then at_index = prev end + local index = M.read(INDEX, root, at_index) + if not index then + return nil + end return union_pairs( root, entry.path, M.read(HEAD, root, at_head) or "", - M.read(INDEX, root, at_index) or "", + index, M.read(WORKTREE, root, entry.path) or "" ) end @@ -1497,6 +1502,14 @@ function M.panel(opts) return index_mode ~= head_mode and index_mode ~= work_mode end + -- an op built on a failed index read would write the file's index from nothing + ---@param entry differ.FileEntry + ---@return false + local function index_unreadable(entry) + notify(("couldn't read %s from the index"):format(entry.path), vim.log.levels.WARN) + return false + end + -- staging for a row shown as HEAD↔worktree. both directions work out the content -- the index should hold and write it whole rather than patching: staging a hunk -- means the index takes the worktree's version of the lines it covers, and @@ -1533,10 +1546,13 @@ function M.panel(opts) -- the view keeps staging.marks, so a re-mark writes through it. marks come from -- the index's own lines at each hunk, and from the two pairs only when the index -- changes a line between hunks - ---@param union differ.DiffModel - ---@param cached differ.DiffModel - ---@param unstaged differ.DiffModel + ---@param union differ.DiffModel|nil -- nil keeps the marks there are + ---@param cached differ.DiffModel|nil + ---@param unstaged differ.DiffModel|nil local function remark(union, cached, unstaged) + if not (union and cached and unstaged) then + return + end staging.hidden_in = nil local blocks = index_blocks(union, cached) local held, hidden_in = nil, {} ---@type differ.model.Marks|nil, integer[] @@ -1659,6 +1675,9 @@ function M.panel(opts) return false end local union, cached, unstaged = M.union_models(root, entry) + if not (union and cached and unstaged) then + return index_unreadable(entry) + end if not drawn_current(model, union) then return false end @@ -1676,6 +1695,9 @@ function M.panel(opts) -- clean filter, so writing it back would push that conversion to disk staging.revert = function(model, idx) local union, cached, unstaged = M.union_models(root, entry) + if not (union and cached and unstaged) then + return index_unreadable(entry) + end if not drawn_current(model, union) then return false end @@ -1707,6 +1729,9 @@ function M.panel(opts) -- hunk shows included. the entry's mode is left as it is staging.set_all = function(model, staged) local union, cached = M.union_models(root, entry) + if not (union and cached) then + return index_unreadable(entry) + end if not drawn_current(model, union) then return false end @@ -1967,6 +1992,9 @@ function M.panel(opts) staging.revert = function(m, idx) local h = m.hunks[idx] local _, _, unstaged = M.union_models(root, entry) + if not unstaged then + return index_unreadable(entry) + end local at = h.new_count > 0 and h.new_start or h.new_start + 1 local offset = require("differ.model.marks").shift(unstaged.hunks, at, "old") return revert_frozen(entry, m, staging, idx, offset, function() @@ -2006,6 +2034,9 @@ function M.panel(opts) end end local _, cached = M.union_models(root, entry) + if not cached then + return index_unreadable(entry) + end if cached.new_text ~= splice(model, applied) then notify("the index changed outside differ: re-reading", vim.log.levels.WARN) vim.schedule(function() @@ -2045,8 +2076,10 @@ function M.panel(opts) show_local(entry) end) local _, cached = M.union_models(root, entry) - local hidden_in = require("differ.model.marks").restaged(model.hunks, cached.hunks) - staging.hidden_in = hidden_in + if cached then + staging.hidden_in = + require("differ.model.marks").restaged(model.hunks, cached.hunks) + end staging.unstage_hidden = function(idx) return drop_hidden(entry, model, staging, idx) end diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index 192be07..5b9ebf6 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -2989,6 +2989,45 @@ func getDownloadSpeed() { assert.are.same({ S, S, U }, after) end) + -- git reads `:1:x.txt` as conflict stage 1 of x.txt + it("reads the index of a file whose name starts with a stage number", function() + local root = fresh_repo() + write(root .. "/1:x.txt", twelve({})) + git(root, "add", "1:x.txt") + git(root, "commit", "-q", "-m", "twelve lines") + index_as(root, "1:x.txt", twelve({ "1x" })) + write(root .. "/1:x.txt", twelve({ "1x", [12] = "12x" })) + vim.cmd.edit(vim.fn.fnameescape(root .. "/1:x.txt")) + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + local v = view_in_origin(p) + local states = { v:_hunk_state(1), v:_hunk_state(2) } + p:close() + assert.are.same({ "staged", "unstaged" }, states) + end) + + it("refuses s when the index can't be read", function() + local root, p, v = staged_as(twelve({}), twelve({ "1x" }), twelve({ "1x", [12] = "12x" })) + local read = git_src.read + git_src.read = function(ref, ...) + if ref.kind == "index" then + return nil + end + return read(ref, ...) + end + _G.notifs = {} + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 2), 0 }) + v:stage_hunk() + git_src.read = read + local said = (_G.notifs[#_G.notifs] or {}).msg + local index = indexed(root, "a.lua") + p:close() + assert.are.equal("differ: couldn't read a.lua from the index", said) + assert.are.equal(twelve({ "1x" }), index) + end) + it("X in the local view puts the worktree hunk back to the index's version", function() local root = staged_then(twelve({ "1x", [6] = "6y", [12] = "12y" })) local p, v = local_view_at(1) From 6a44cd172ff09f67a2cc573a9feb028752a6ba90 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:29:24 +0100 Subject: [PATCH 34/93] fix: diff a final newline change as the last line's hunk --- lua/differ/git/init.lua | 15 ++++------ lua/differ/model/apply.lua | 43 +++++---------------------- lua/differ/model/diff.lua | 23 ++++++--------- lua/differ/model/marks.lua | 42 ++++++++++++++++++++++----- lua/differ/util/text.lua | 11 ------- test/nvim/diff_spec.lua | 25 ++++++++++++---- test/nvim/git_spec.lua | 53 ++++++++++++++++++++++++++++++++-- test/unit/model_apply_spec.lua | 14 +++++++-- test/unit/model_marks_spec.lua | 49 ++++++++++++++++++++++++------- 9 files changed, 176 insertions(+), 99 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index cdab26e..3780efe 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -1051,11 +1051,6 @@ end ---@return string|nil local function empty_notice(root, entry, model, args) local reason = require("differ.model.diff").empty_reason(model) - if reason == "eol_added" then - return "Final newline added" - elseif reason == "eol_removed" then - return "Final newline removed" - end local empty_or_not = reason == "empty" and "Empty file" or "No content change" if entry.status == "?" then return live_status(root, entry) == entry.status and empty_or_not or nil @@ -1557,7 +1552,7 @@ function M.panel(opts) local blocks = index_blocks(union, cached) local held, hidden_in = nil, {} ---@type differ.model.Marks|nil, integer[] if blocks then - held, hidden_in = marks.of_blocks(union.hunks, blocks) + held, hidden_in = marks.of_blocks(union, blocks, cached.new_text) end if held then staging.marks.old, staging.marks.new = held.old, held.new @@ -1635,12 +1630,12 @@ function M.panel(opts) local blocks = index_blocks(union, cached) local i = hunk_index(union, hunk) if blocks and i then - local lines = union.hunks[i].old_lines + local side = "old" if take then - lines = union.hunks[i].new_lines + side = "new" end - blocks[i] = lines - return join(union, blocks, cached.new_text) + blocks[i] = union.hunks[i][side .. "_lines"] + return join(union, blocks, cached.new_text, { [i] = side }) end local from, side = unstaged, "new" if not take then diff --git a/lua/differ/model/apply.lua b/lua/differ/model/apply.lua index d13062b..fdac831 100644 --- a/lua/differ/model/apply.lua +++ b/lua/differ/model/apply.lua @@ -40,45 +40,16 @@ function M.splice(model, applied, base) return table.concat(out, "\n") .. (ending:sub(-1) == "\n" and "\n" or "") end ----@param a string[] ----@param b string[] ----@return boolean -local function same_lines(a, b) - if #a ~= #b then - return false - end - for i, line in ipairs(a) do - if b[i] ~= line then - return false - end - end - return true -end - --- the text a file ends as when hunk `h`, holding `block`, reaches its end: a side's --- text when the block is that side's lines, else `text` ----@param model differ.DiffModel ----@param h differ.Hunk ----@param block string[] ----@param text string ----@return string -local function ending_at(model, h, block, text) - if same_lines(block, h.new_lines) then - return model.new_text - end - if same_lines(block, h.old_lines) then - return model.old_text - end - return text -end - -- the model's unchanged old lines with `blocks` at its hunks. the file ends as `text` --- does, unless the last hunk reaches the end holding one side's lines +-- does, unless the hunk reaching the end is named in `sides`: then as that side does. +-- a hunk's two sides can be the same lines ending differently, so its lines can't say ---@param model differ.DiffModel ---@param blocks string[][] -- hunk index -> the lines at that hunk ---@param text string -- the text the ending comes from otherwise +---@param sides? table -- hunk index -> the side its block is ---@return string -function M.join(model, blocks, text) +function M.join(model, blocks, text, sides) + sides = sides or {} local lines = to_lines(model.old_text) local out, next_line, ending = {}, 1, text for i, h in ipairs(model.hunks) do @@ -90,8 +61,8 @@ function M.join(model, blocks, text) out[#out + 1] = line end next_line = first + h.old_count - if next_line > #lines then - ending = ending_at(model, h, blocks[i], text) + if next_line > #lines and sides[i] then + ending = model[sides[i] .. "_text"] end end for l = next_line, #lines do diff --git a/lua/differ/model/diff.lua b/lua/differ/model/diff.lua index 6d66f26..029e7ea 100644 --- a/lua/differ/model/diff.lua +++ b/lua/differ/model/diff.lua @@ -24,7 +24,6 @@ local text_util = require("differ.util.text") local to_lines = text_util.to_lines -local ensure_trailing_nl = text_util.ensure_trailing_nl local is_binary = text_util.is_binary local M = {} @@ -76,11 +75,11 @@ function M.build(opts) local old_lines = to_lines(opts.old_text) local new_lines = to_lines(opts.new_text) - local raw = - vim.text.diff(ensure_trailing_nl(opts.old_text), ensure_trailing_nl(opts.new_text), { - result_type = "indices", - algorithm = "histogram", - }) + -- a last line with and without its newline differ, as in git + local raw = vim.text.diff(opts.old_text, opts.new_text, { + result_type = "indices", + algorithm = "histogram", + }) ---@cast raw integer[][] local hunks = {} @@ -108,18 +107,14 @@ function M.build(opts) } end --- why a zero-hunk model has nothing to show, from its two texts alone. build --- ensures a trailing newline on both sides, so that's the only way two differing --- texts reach zero hunks. nil when only the frontend knows (a mode change, a rename) +-- why a zero-hunk model has nothing to show, from its two texts alone. nil when only +-- the frontend knows (a mode change, a rename) ---@param model differ.DiffModel ----@return "empty"|"eol_added"|"eol_removed"|"identical"|nil +---@return "empty"|"identical"|nil function M.empty_reason(model) - if #model.hunks > 0 or model.binary then + if #model.hunks > 0 or model.binary or model.old_text ~= model.new_text then return nil end - if model.old_text ~= model.new_text then - return model.new_text:sub(-1) == "\n" and "eol_added" or "eol_removed" - end return model.old_text == "" and "empty" or "identical" end diff --git a/lua/differ/model/marks.lua b/lua/differ/model/marks.lua index 5b9b401..1651037 100644 --- a/lua/differ/model/marks.lua +++ b/lua/differ/model/marks.lua @@ -3,6 +3,8 @@ -- shares the worktree), so a displayed line is classified by whichever pair shares its -- side and the two never have to be composed. pure lua, no nvim API +local to_lines = require("differ.util.text").to_lines + local M = {} ---@alias differ.model.HunkState "staged"|"unstaged"|"partial" @@ -264,18 +266,26 @@ local function common(a, b) return in_a, in_b end +---@class differ.model.Endings -- whether each text ends in a newline +---@field old boolean +---@field new boolean +---@field index boolean + -- mark one hunk's lines from its index block. a block line is HEAD's where it pairs --- with an old line, else the worktree's where it pairs with a new one +-- with an old line, else the worktree's where it pairs with a new one. a hunk reaching +-- the end of the file takes `ends` too: its last line and ending count as one ---@param h differ.Hunk ---@param block string[] ---@param marks differ.model.Marks +---@param ends? differ.model.Endings ---@return boolean compared -- false for a hunk too big to compare ---@return boolean hidden -- a block line neither side has -local function mark_block(h, block, marks) +local function mark_block(h, block, marks, ends) -- a block that is one whole side is that side, even where the two sides share a line for _, staged in ipairs({ true, false }) do local side = staged and h.new_lines or h.old_lines - if #block == #side and holds(block, 1, side) then + local ending_fits = not ends or ends[staged and "new" or "old"] == ends.index + if ending_fits and #block == #side and holds(block, 1, side) then for l = h.old_start, h.old_start + h.old_count - 1 do marks.old[l] = staged end @@ -309,19 +319,37 @@ local function mark_block(h, block, marks) paired = paired + 1 end end + if ends and ends.index ~= ends.new and h.new_count > 0 then + marks.new[h.new_start + h.new_count - 1] = false + end + if ends and ends.index ~= ends.old and h.old_count > 0 then + marks.old[h.old_start + h.old_count - 1] = true + end return true, paired < #rest end -- marks for the union hunks from the index's block at each, and the hunks whose block -- holds a line neither side has. nil when a hunk is too big to compare ----@param union differ.Hunk[] +---@param union differ.DiffModel -- HEAD↔worktree ---@param blocks string[][] +---@param index string -- the index's text, for the ending of a hunk that reaches it ---@return differ.model.Marks|nil marks, integer[] hidden -function M.of_blocks(union, blocks) +function M.of_blocks(union, blocks, index) local marks = { old = {}, new = {} } local hidden = {} - for i, h in ipairs(union) do - local compared, extra = mark_block(h, blocks[i], marks) + local last = #to_lines(union.old_text) + local ends = { + old = union.old_text:sub(-1) == "\n", + new = union.new_text:sub(-1) == "\n", + index = index:sub(-1) == "\n", + } + for i, h in ipairs(union.hunks) do + local _, after = old_bounds(h) + local at_end = nil ---@type differ.model.Endings|nil + if after > last then + at_end = ends + end + local compared, extra = mark_block(h, blocks[i], marks, at_end) if not compared then return nil, {} end diff --git a/lua/differ/util/text.lua b/lua/differ/util/text.lua index 18accc8..9321f1d 100644 --- a/lua/differ/util/text.lua +++ b/lua/differ/util/text.lua @@ -54,15 +54,4 @@ function M.truncate_end(s, max) return s:sub(1, max - 1) .. "…" -- "…" is one display column end --- vim.text.diff is line-oriented; an unterminated final line reads as changed, --- so normalise to newline-terminated before diffing ----@param text string ----@return string -function M.ensure_trailing_nl(text) - if text == "" or text:sub(-1) == "\n" then - return text - end - return text .. "\n" -end - return M diff --git a/test/nvim/diff_spec.lua b/test/nvim/diff_spec.lua index 863a3b9..35a4338 100644 --- a/test/nvim/diff_spec.lua +++ b/test/nvim/diff_spec.lua @@ -26,7 +26,8 @@ describe("model.diff.build", function() assert.are.same({ "B" }, m.hunks[1].new_lines) end) - it("handles add-only and missing trailing newline", function() + -- as in git: the unterminated `a` gives way to a terminated one + it("counts an unterminated last line as changed when lines follow it", function() local m = diff.build({ path = "x.lua", old_rev = "HEAD", @@ -35,6 +36,21 @@ describe("model.diff.build", function() new_text = "a\nb", }) assert.are.equal(1, #m.hunks) + assert.are.same({ "a" }, m.hunks[1].old_lines) + assert.are.same({ "a", "b" }, m.hunks[1].new_lines) + end) + + it("counts a final newline alone as a change to the last line", function() + local m = diff.build({ + path = "x.lua", + old_rev = "HEAD", + new_rev = "WORKTREE", + old_text = "a\nb", + new_text = "a\nb\n", + }) + assert.are.equal(1, #m.hunks) + assert.are.equal(2, m.hunks[1].old_start) + assert.are.same({ "b" }, m.hunks[1].old_lines) assert.are.same({ "b" }, m.hunks[1].new_lines) end) @@ -181,10 +197,9 @@ describe("model.diff.empty_reason", function() assert.are.equal("identical", reason("a\n", "a\n")) end) - it("names a final newline in either direction", function() - -- build ensures a trailing newline, so these reach zero hunks - assert.are.equal("eol_added", reason("a", "a\n")) - assert.are.equal("eol_removed", reason("a\n", "a")) + it("is nil for a final newline change, which is a hunk on the last line", function() + assert.is_nil(reason("a", "a\n")) + assert.is_nil(reason("a\n", "a")) end) it("is nil for binary content, which has its own placeholder", function() diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index 5b9ebf6..e0047de 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -2178,6 +2178,51 @@ func getDownloadSpeed() { return table.concat(t, "\n") .. "\n" end + -- HEAD a b c unterminated, worktree A b c with its final newline: the ending is + -- hunk 2, on line c + it("stages and unstages a final newline as the last line's hunk", function() + local head = "a\nb\nc" + local root, p, v = staged_as(head, head, "A\nb\nc\n") + local function states() + local out = {} + for i = 1, #v.model.hunks do + out[i] = v:_hunk_state(i) + end + return out + end + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + local function press(n, op) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, n), 0 }) + v[op](v) + return states(), indexed(root, "a.lua") + end + local before = states() + local s1, i1 = press(1, "stage_hunk") + local s2, i2 = press(2, "stage_hunk") + local u2, i3 = press(2, "unstage_hunk") + local u1, i4 = press(1, "unstage_hunk") + local reverted = v.staging.revert(v.model, 2) + local fd = assert(io.open(root .. "/a.lua", "rb")) -- worktree() always adds a newline + local work, index = fd:read("*a"), indexed(root, "a.lua") + fd:close() + p:close() + + local S, U = "staged", "unstaged" + assert.are.same({ U, U }, before) + assert.are.same({ S, U }, s1) + assert.are.equal("A\nb\nc", i1) + assert.are.same({ S, S }, s2) + assert.are.equal("A\nb\nc\n", i2) + assert.are.same({ S, U }, u2) + assert.are.equal("A\nb\nc", i3) + assert.are.same({ U, U }, u1) + assert.are.equal(head, i4) + assert.is_true(reverted) + assert.are.equal("A\nb\nc", work) + assert.are.equal(head, index) + end) + -- HEAD a b _ _ c d, worktree a b B _ d: the blank beside hunk 1 also fits hunk 2 it("stages and reverts by hunk where a blank line fits either hunk", function() local head = lines_of({ "a", "b", "", "", "c", "d" }) @@ -5293,15 +5338,17 @@ describe(":Differ panel (zero-hunk entries)", function() p:close() end) - it("opens a final-newline-only change on a notice", function() + it("opens a final-newline-only change on its last line", function() local root = fresh_repo() write(root .. "/a.lua", V1:sub(1, -2)) -- same content, no trailing newline vim.cmd.edit(root .. "/a.lua") local p, v = open_only_entry(root) - assert.is_not_nil(v) - assert.are.equal("Final newline removed", v.model.notice) + local hunks, notice = v.model.hunks, v.model.notice p:close() + assert.are.equal(1, #hunks) + assert.are.same(hunks[1].old_lines, hunks[1].new_lines) + assert.is_nil(notice) end) it("names a moved submodule pointer, which reads empty on both sides", function() diff --git a/test/unit/model_apply_spec.lua b/test/unit/model_apply_spec.lua index cb473cf..b98f520 100644 --- a/test/unit/model_apply_spec.lua +++ b/test/unit/model_apply_spec.lua @@ -122,10 +122,18 @@ describe("join", function() assert.are.equal("A\nb\nc\n", apply.join(m, { { "A" } }, "A\nb\nc\n")) end) - it("ends as a side does when the last hunk reaches eof holding its lines", function() + it("ends as the side named for the hunk that reaches eof", function() local m = model("a\nb", "a\nB\n", { h(2, { "b" }, 2, { "B" }) }) - assert.are.equal("a\nB\n", apply.join(m, { { "B" } }, "a\nb")) - assert.are.equal("a\nb", apply.join(m, { { "b" } }, "a\nB\n")) + assert.are.equal("a\nB\n", apply.join(m, { { "B" } }, "a\nb", { "new" })) + assert.are.equal("a\nb", apply.join(m, { { "b" } }, "a\nB\n", { "old" })) + end) + + -- b and b⏎: both sides hold the same line, so only the side named says which + it("ends as the side named where both sides hold the same lines", function() + local m = model("a\nb", "a\nb\n", { h(2, { "b" }, 2, { "b" }) }) + assert.are.equal("a\nb\n", apply.join(m, { { "b" } }, "a\nb", { "new" })) + assert.are.equal("a\nb", apply.join(m, { { "b" } }, "a\nb\n", { "old" })) + assert.are.equal("a\nb", apply.join(m, { { "b" } }, "a\nb")) end) end) end) diff --git a/test/unit/model_marks_spec.lua b/test/unit/model_marks_spec.lua index b4dcdc1..620ada8 100644 --- a/test/unit/model_marks_spec.lua +++ b/test/unit/model_marks_spec.lua @@ -274,18 +274,23 @@ describe("union marks", function() end) describe("of_blocks", function() + -- texts that end alike, so no hunk's ending counts + local function of_blocks(union, blocks) + return marks.of_blocks({ hunks = union, old_text = "", new_text = "" }, blocks, "") + end + -- HEAD 1 a b 4, worktree 1 A B C 4: one hunk rewriting two lines as three local union = { hl(2, { "a", "b" }, 2, { "A", "B", "C" }) } it("reads a block of HEAD's lines as unstaged and the worktree's as staged", function() - local m = marks.of_blocks(union, { { "a", "b" } }) + local m = of_blocks(union, { { "a", "b" } }) assert.are.same({ "unstaged" }, states(m, union)) - m = marks.of_blocks(union, { { "A", "B", "C" } }) + m = of_blocks(union, { { "A", "B", "C" } }) assert.are.same({ "staged" }, states(m, union)) end) it("marks each line of a block that holds part of the hunk", function() - local m, hidden = marks.of_blocks(union, { { "a", "B" } }) + local m, hidden = of_blocks(union, { { "a", "B" } }) assert.are.same({ "partial" }, states(m, union)) assert.are.same({ [2] = false, [3] = true }, m.old) assert.are.same({ [2] = false, [3] = true, [4] = false }, m.new) @@ -293,29 +298,53 @@ describe("union marks", function() end) it("names the hunk whose block holds a line neither side has", function() - local _, hidden = marks.of_blocks(union, { { "a", "Z" } }) + local _, hidden = of_blocks(union, { { "a", "Z" } }) assert.are.same({ 1 }, hidden) end) -- HEAD a a, worktree a z: the two sides share an a it("reads a block that is one whole side as that side", function() local shared = { hl(1, { "a", "a" }, 1, { "a", "z" }) } - assert.are.same({ "staged" }, states(marks.of_blocks(shared, { { "a", "z" } }), shared)) - assert.are.same( - { "unstaged" }, - states(marks.of_blocks(shared, { { "a", "a" } }), shared) - ) + assert.are.same({ "staged" }, states(of_blocks(shared, { { "a", "z" } }), shared)) + assert.are.same({ "unstaged" }, states(of_blocks(shared, { { "a", "a" } }), shared)) end) -- HEAD x a, worktree a x: a block of HEAD's lines pairs with the old side first it("reads a moved line from HEAD before the worktree", function() local moved = { hl(1, { "x", "a" }, 1, { "a", "x" }) } - local m, hidden = marks.of_blocks(moved, { { "x", "a" } }) + local m, hidden = of_blocks(moved, { { "x", "a" } }) assert.are.same({ "unstaged" }, states(m, moved)) assert.are.same({}, hidden) end) end) + describe("of_blocks at the end of the file", function() + -- HEAD a b unterminated, worktree a b⏎: hunk 2 holds b on both sides + local same = { + hunks = { hl(2, { "b" }, 2, { "b" }) }, + old_text = "a\nb", + new_text = "a\nb\n", + } + + it("reads the index's ending to say which side a same-line block is", function() + local unstaged = marks.of_blocks(same, { { "b" } }, "a\nb") + local staged = marks.of_blocks(same, { { "b" } }, "a\nb\n") + assert.are.same({ "unstaged" }, states(unstaged, same.hunks)) + assert.are.same({ "staged" }, states(staged, same.hunks)) + end) + + -- HEAD a b unterminated, worktree a B⏎: the index holds B without the newline + it("marks the hunk partial when the index holds its line but not its ending", function() + local rewrite = { + hunks = { hl(2, { "b" }, 2, { "B" }) }, + old_text = "a\nb", + new_text = "a\nB\n", + } + local m = marks.of_blocks(rewrite, { { "B" } }, "a\nB") + assert.are.same({ "partial" }, states(m, rewrite.hunks)) + end) + end) + describe("shift", function() -- three lines inserted at the top, and line 5 replaced by two local hunks = { h(0, 0, 1, 3), h(5, 1, 8, 2) } From d2aefb54d63dc5623676df3b3d7880854b47248a Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:17:25 +0100 Subject: [PATCH 35/93] refactor: read a last line and its ending as one line when staging --- lua/differ/git/init.lua | 32 +++++++------- lua/differ/model/apply.lua | 63 ++++++++++++++++++++-------- lua/differ/model/marks.lua | 42 ++++--------------- lua/differ/util/text.lua | 37 +++++++++++++++++ test/unit/model_apply_spec.lua | 48 +++++++++++++-------- test/unit/model_marks_spec.lua | 76 +++++++++++++++++++--------------- 6 files changed, 181 insertions(+), 117 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 3780efe..04cf016 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -1523,19 +1523,22 @@ function M.panel(opts) ---@type differ.view.Staging local staging = { marks = { old = {}, new = {} }, refresh = refresh_panel } - -- the index's lines at each union hunk; nil unless the index holds every unchanged - -- line between them and rejoins to exactly its own text + -- the index's lines at each union hunk, and the union with a last line and its + -- ending read as one (apply.ended). the blocks are nil unless the index holds + -- every unchanged line between them and rejoins to exactly its own text ---@param union differ.DiffModel ---@param cached differ.DiffModel - ---@return string[][]|nil + ---@return string[][]|nil blocks, differ.DiffModel ended local function index_blocks(union, cached) - local to_lines = require("differ.util.text").to_lines + local ended_lines = require("differ.util.text").ended_lines + local ended = require("differ.model.apply").ended(union) local index = cached.new_text - local blocks = marks.blocks(union.hunks, to_lines(union.old_text), to_lines(index)) - if not blocks or join(union, blocks, index) ~= index then - return nil + local blocks = + marks.blocks(ended.hunks, ended_lines(union.old_text), ended_lines(index)) + if not blocks or join(ended, blocks) ~= index then + return nil, ended end - return blocks + return blocks, ended end -- the view keeps staging.marks, so a re-mark writes through it. marks come from @@ -1549,10 +1552,10 @@ function M.panel(opts) return end staging.hidden_in = nil - local blocks = index_blocks(union, cached) + local blocks, ended = index_blocks(union, cached) local held, hidden_in = nil, {} ---@type differ.model.Marks|nil, integer[] if blocks then - held, hidden_in = marks.of_blocks(union, blocks, cached.new_text) + held, hidden_in = marks.of_blocks(ended.hunks, blocks) end if held then staging.marks.old, staging.marks.new = held.old, held.new @@ -1627,15 +1630,14 @@ function M.panel(opts) ---@param take boolean ---@return string|nil local function next_index(union, cached, unstaged, hunk, take) - local blocks = index_blocks(union, cached) + local blocks, ended = index_blocks(union, cached) local i = hunk_index(union, hunk) if blocks and i then - local side = "old" + blocks[i] = ended.hunks[i].old_lines if take then - side = "new" + blocks[i] = ended.hunks[i].new_lines end - blocks[i] = union.hunks[i][side .. "_lines"] - return join(union, blocks, cached.new_text, { [i] = side }) + return join(ended, blocks) end local from, side = unstaged, "new" if not take then diff --git a/lua/differ/model/apply.lua b/lua/differ/model/apply.lua index fdac831..dac38e3 100644 --- a/lua/differ/model/apply.lua +++ b/lua/differ/model/apply.lua @@ -2,7 +2,8 @@ -- and its old block otherwise, with the unchanged lines between read from one side. -- staging writes the result whole, so it only ever applies whole hunks; pure lua, no vim API -local to_lines = require("differ.util.text").to_lines +local text = require("differ.util.text") +local to_lines = text.to_lines local M = {} @@ -40,18 +41,52 @@ function M.splice(model, applied, base) return table.concat(out, "\n") .. (ending:sub(-1) == "\n" and "\n" or "") end --- the model's unchanged old lines with `blocks` at its hunks. the file ends as `text` --- does, unless the hunk reaching the end is named in `sides`: then as that side does. --- a hunk's two sides can be the same lines ending differently, so its lines can't say +---@param lines string[] +---@param start integer +---@param count integer +---@return string[] +local function slice(lines, start, count) + local out = {} + for l = start, start + count - 1 do + out[#out + 1] = lines[l] + end + return out +end + +-- `model` with its hunks' lines as text.ended_lines reads each side, so a hunk reaching +-- an unterminated end holds that side's last line with NO_EOL on it +---@param model differ.DiffModel +---@return differ.DiffModel +function M.ended(model) + local old, new = text.ended_lines(model.old_text), text.ended_lines(model.new_text) + local hunks = {} + for i, h in ipairs(model.hunks) do + hunks[i] = { + old_start = h.old_start, + old_count = h.old_count, + old_lines = slice(old, h.old_start, h.old_count), + new_start = h.new_start, + new_count = h.new_count, + new_lines = slice(new, h.new_start, h.new_count), + } + end + local out = {} + for k, v in pairs(model) do + out[k] = v + end + out.hunks = hunks + return out +end + +-- the model's unchanged old lines with `blocks` at its hunks. every line is an ended +-- line (M.ended's side lines, or marks.blocks over ended lines), so the file ends +-- without a newline exactly when its last line carries NO_EOL ---@param model differ.DiffModel ---@param blocks string[][] -- hunk index -> the lines at that hunk ----@param text string -- the text the ending comes from otherwise ----@param sides? table -- hunk index -> the side its block is ---@return string -function M.join(model, blocks, text, sides) - sides = sides or {} - local lines = to_lines(model.old_text) - local out, next_line, ending = {}, 1, text +function M.join(model, blocks) + local lines = text.ended_lines(model.old_text) + local out, next_line = {}, 1 for i, h in ipairs(model.hunks) do local first = h.old_count > 0 and h.old_start or h.old_start + 1 for l = next_line, first - 1 do @@ -61,17 +96,11 @@ function M.join(model, blocks, text, sides) out[#out + 1] = line end next_line = first + h.old_count - if next_line > #lines and sides[i] then - ending = model[sides[i] .. "_text"] - end end for l = next_line, #lines do out[#out + 1] = lines[l] end - if #out == 0 then - return "" - end - return table.concat(out, "\n") .. (ending:sub(-1) == "\n" and "\n" or "") + return text.ended_text(out) end return M diff --git a/lua/differ/model/marks.lua b/lua/differ/model/marks.lua index 1651037..5b9b401 100644 --- a/lua/differ/model/marks.lua +++ b/lua/differ/model/marks.lua @@ -3,8 +3,6 @@ -- shares the worktree), so a displayed line is classified by whichever pair shares its -- side and the two never have to be composed. pure lua, no nvim API -local to_lines = require("differ.util.text").to_lines - local M = {} ---@alias differ.model.HunkState "staged"|"unstaged"|"partial" @@ -266,26 +264,18 @@ local function common(a, b) return in_a, in_b end ----@class differ.model.Endings -- whether each text ends in a newline ----@field old boolean ----@field new boolean ----@field index boolean - -- mark one hunk's lines from its index block. a block line is HEAD's where it pairs --- with an old line, else the worktree's where it pairs with a new one. a hunk reaching --- the end of the file takes `ends` too: its last line and ending count as one +-- with an old line, else the worktree's where it pairs with a new one ---@param h differ.Hunk ---@param block string[] ---@param marks differ.model.Marks ----@param ends? differ.model.Endings ---@return boolean compared -- false for a hunk too big to compare ---@return boolean hidden -- a block line neither side has -local function mark_block(h, block, marks, ends) +local function mark_block(h, block, marks) -- a block that is one whole side is that side, even where the two sides share a line for _, staged in ipairs({ true, false }) do local side = staged and h.new_lines or h.old_lines - local ending_fits = not ends or ends[staged and "new" or "old"] == ends.index - if ending_fits and #block == #side and holds(block, 1, side) then + if #block == #side and holds(block, 1, side) then for l = h.old_start, h.old_start + h.old_count - 1 do marks.old[l] = staged end @@ -319,37 +309,19 @@ local function mark_block(h, block, marks, ends) paired = paired + 1 end end - if ends and ends.index ~= ends.new and h.new_count > 0 then - marks.new[h.new_start + h.new_count - 1] = false - end - if ends and ends.index ~= ends.old and h.old_count > 0 then - marks.old[h.old_start + h.old_count - 1] = true - end return true, paired < #rest end -- marks for the union hunks from the index's block at each, and the hunks whose block -- holds a line neither side has. nil when a hunk is too big to compare ----@param union differ.DiffModel -- HEAD↔worktree +---@param union differ.Hunk[] ---@param blocks string[][] ----@param index string -- the index's text, for the ending of a hunk that reaches it ---@return differ.model.Marks|nil marks, integer[] hidden -function M.of_blocks(union, blocks, index) +function M.of_blocks(union, blocks) local marks = { old = {}, new = {} } local hidden = {} - local last = #to_lines(union.old_text) - local ends = { - old = union.old_text:sub(-1) == "\n", - new = union.new_text:sub(-1) == "\n", - index = index:sub(-1) == "\n", - } - for i, h in ipairs(union.hunks) do - local _, after = old_bounds(h) - local at_end = nil ---@type differ.model.Endings|nil - if after > last then - at_end = ends - end - local compared, extra = mark_block(h, blocks[i], marks, at_end) + for i, h in ipairs(union) do + local compared, extra = mark_block(h, blocks[i], marks) if not compared then return nil, {} end diff --git a/lua/differ/util/text.lua b/lua/differ/util/text.lua index 9321f1d..6be6423 100644 --- a/lua/differ/util/text.lua +++ b/lua/differ/util/text.lua @@ -54,4 +54,41 @@ function M.truncate_end(s, max) return s:sub(1, max - 1) .. "…" -- "…" is one display column 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" + +-- `text`'s lines, the last carrying NO_EOL when the text doesn't end in a newline +---@param text string +---@return string[] +function M.ended_lines(text) + local lines = M.to_lines(text) + if #lines > 0 and text:sub(-1) ~= "\n" then + lines[#lines] = lines[#lines] .. M.NO_EOL + end + return lines +end + +-- the text ended_lines reads `lines` from: a newline after each line but one that +-- carries NO_EOL +---@param lines string[] +---@return string +function M.ended_text(lines) + if #lines == 0 then + return "" + end + local out = {} + for i, line in ipairs(lines) do + if line:sub(-1) == M.NO_EOL then + line = line:sub(1, -2) + end + out[i] = line + end + local text = table.concat(out, "\n") + if lines[#lines]:sub(-1) == M.NO_EOL then + return text + end + return text .. "\n" +end + return M diff --git a/test/unit/model_apply_spec.lua b/test/unit/model_apply_spec.lua index b98f520..02369d2 100644 --- a/test/unit/model_apply_spec.lua +++ b/test/unit/model_apply_spec.lua @@ -107,33 +107,47 @@ describe("join", function() ) it("rejoins each side's own blocks to that side's text", function() - assert.are.equal(two.old_text, apply.join(two, { { "2" }, { "4", "5" } }, two.old_text)) - assert.are.equal(two.new_text, apply.join(two, { { "2x" }, { "4y", "5y" } }, two.new_text)) + assert.are.equal(two.old_text, apply.join(two, { { "2" }, { "4", "5" } })) + assert.are.equal(two.new_text, apply.join(two, { { "2x" }, { "4y", "5y" } })) end) it("places any lines at a hunk between the unchanged ones", function() - local text = apply.join(two, { { "2", "z" }, {} }, two.old_text) - assert.are.equal("1\n2\nz\n3\n", text) + assert.are.equal("1\n2\nz\n3\n", apply.join(two, { { "2", "z" }, {} })) end) describe("the final newline", function() - it("ends as the text given when no hunk reaches eof", function() - local m = model("a\nb\nc", "A\nb\nc\n", { h(1, { "a" }, 1, { "A" }) }) - assert.are.equal("A\nb\nc\n", apply.join(m, { { "A" } }, "A\nb\nc\n")) + it("ends as HEAD's last line does when no hunk reaches it", function() + local m = apply.ended(model("a\nb\nc", "A\nb\nc", { h(1, { "a" }, 1, { "A" }) })) + assert.are.equal("A\nb\nc", apply.join(m, { m.hunks[1].new_lines })) end) - it("ends as the side named for the hunk that reaches eof", function() - local m = model("a\nb", "a\nB\n", { h(2, { "b" }, 2, { "B" }) }) - assert.are.equal("a\nB\n", apply.join(m, { { "B" } }, "a\nb", { "new" })) - assert.are.equal("a\nb", apply.join(m, { { "b" } }, "a\nB\n", { "old" })) + it("ends as the side a block at the end holds", function() + local m = apply.ended(model("a\nb", "a\nB\n", { h(2, { "b" }, 2, { "B" }) })) + assert.are.equal("a\nB\n", apply.join(m, { m.hunks[1].new_lines })) + assert.are.equal("a\nb", apply.join(m, { m.hunks[1].old_lines })) end) - -- b and b⏎: both sides hold the same line, so only the side named says which - it("ends as the side named where both sides hold the same lines", function() - local m = model("a\nb", "a\nb\n", { h(2, { "b" }, 2, { "b" }) }) - assert.are.equal("a\nb\n", apply.join(m, { { "b" } }, "a\nb", { "new" })) - assert.are.equal("a\nb", apply.join(m, { { "b" } }, "a\nb\n", { "old" })) - assert.are.equal("a\nb", apply.join(m, { { "b" } }, "a\nb")) + -- "a\nb" and "a\nb\n": the two sides differ only in the ending + it("tells apart two sides that hold the same line", function() + local m = apply.ended(model("a\nb", "a\nb\n", { h(2, { "b" }, 2, { "b" }) })) + assert.are.equal("a\nb\n", apply.join(m, { m.hunks[1].new_lines })) + assert.are.equal("a\nb", apply.join(m, { m.hunks[1].old_lines })) end) end) end) + +describe("ended", function() + local NO_EOL = require("differ.util.text").NO_EOL + + it("marks the last line of an unterminated side in the hunk that holds it", function() + local m = apply.ended(model("a\nb", "a\nb\n", { h(2, { "b" }, 2, { "b" }) })) + assert.are.same({ "b" .. NO_EOL }, m.hunks[1].old_lines) + assert.are.same({ "b" }, m.hunks[1].new_lines) + end) + + it("leaves a hunk alone that doesn't reach the end", function() + local m = apply.ended(model("a\nb", "A\nb", { h(1, { "a" }, 1, { "A" }) })) + assert.are.same({ "a" }, m.hunks[1].old_lines) + assert.are.same({ "A" }, m.hunks[1].new_lines) + end) +end) diff --git a/test/unit/model_marks_spec.lua b/test/unit/model_marks_spec.lua index 620ada8..40c95ba 100644 --- a/test/unit/model_marks_spec.lua +++ b/test/unit/model_marks_spec.lua @@ -274,23 +274,18 @@ describe("union marks", function() end) describe("of_blocks", function() - -- texts that end alike, so no hunk's ending counts - local function of_blocks(union, blocks) - return marks.of_blocks({ hunks = union, old_text = "", new_text = "" }, blocks, "") - end - -- HEAD 1 a b 4, worktree 1 A B C 4: one hunk rewriting two lines as three local union = { hl(2, { "a", "b" }, 2, { "A", "B", "C" }) } it("reads a block of HEAD's lines as unstaged and the worktree's as staged", function() - local m = of_blocks(union, { { "a", "b" } }) + local m = marks.of_blocks(union, { { "a", "b" } }) assert.are.same({ "unstaged" }, states(m, union)) - m = of_blocks(union, { { "A", "B", "C" } }) + m = marks.of_blocks(union, { { "A", "B", "C" } }) assert.are.same({ "staged" }, states(m, union)) end) it("marks each line of a block that holds part of the hunk", function() - local m, hidden = of_blocks(union, { { "a", "B" } }) + local m, hidden = marks.of_blocks(union, { { "a", "B" } }) assert.are.same({ "partial" }, states(m, union)) assert.are.same({ [2] = false, [3] = true }, m.old) assert.are.same({ [2] = false, [3] = true, [4] = false }, m.new) @@ -298,50 +293,65 @@ describe("union marks", function() end) it("names the hunk whose block holds a line neither side has", function() - local _, hidden = of_blocks(union, { { "a", "Z" } }) + local _, hidden = marks.of_blocks(union, { { "a", "Z" } }) assert.are.same({ 1 }, hidden) end) -- HEAD a a, worktree a z: the two sides share an a it("reads a block that is one whole side as that side", function() local shared = { hl(1, { "a", "a" }, 1, { "a", "z" }) } - assert.are.same({ "staged" }, states(of_blocks(shared, { { "a", "z" } }), shared)) - assert.are.same({ "unstaged" }, states(of_blocks(shared, { { "a", "a" } }), shared)) + assert.are.same({ "staged" }, states(marks.of_blocks(shared, { { "a", "z" } }), shared)) + assert.are.same( + { "unstaged" }, + states(marks.of_blocks(shared, { { "a", "a" } }), shared) + ) end) -- HEAD x a, worktree a x: a block of HEAD's lines pairs with the old side first it("reads a moved line from HEAD before the worktree", function() local moved = { hl(1, { "x", "a" }, 1, { "a", "x" }) } - local m, hidden = of_blocks(moved, { { "x", "a" } }) + local m, hidden = marks.of_blocks(moved, { { "x", "a" } }) assert.are.same({ "unstaged" }, states(m, moved)) assert.are.same({}, hidden) end) end) - describe("of_blocks at the end of the file", function() - -- HEAD a b unterminated, worktree a b⏎: hunk 2 holds b on both sides - local same = { - hunks = { hl(2, { "b" }, 2, { "b" }) }, - old_text = "a\nb", - new_text = "a\nb\n", - } - - it("reads the index's ending to say which side a same-line block is", function() - local unstaged = marks.of_blocks(same, { { "b" } }, "a\nb") - local staged = marks.of_blocks(same, { { "b" } }, "a\nb\n") - assert.are.same({ "unstaged" }, states(unstaged, same.hunks)) - assert.are.same({ "staged" }, states(staged, same.hunks)) + describe("a last line without its newline", function() + local text = require("differ.util.text") + local NO = text.NO_EOL + local S, U, P = "staged", "unstaged", "partial" + + -- HEAD "a\nb", worktree "a\nb\n": one hunk, b on both sides + it("tells a line from the same line with its newline", function() + local union = { hl(2, { "b" .. NO }, 2, { "b" }) } + local head = text.ended_lines("a\nb") + local unstaged = marks.blocks(union, head, text.ended_lines("a\nb")) + local staged = marks.blocks(union, head, text.ended_lines("a\nb\n")) + assert.are.same({ U }, states(marks.of_blocks(union, unstaged), union)) + assert.are.same({ S }, states(marks.of_blocks(union, staged), union)) + end) + + -- HEAD "a\nb", worktree "a\nB\n": the index holds B without the newline + it("reads a last line the index holds without its newline as neither side's", function() + local union = { hl(2, { "b" .. NO }, 2, { "B" }) } + local blocks = marks.blocks(union, text.ended_lines("a\nb"), text.ended_lines("a\nB")) + local m, hidden = marks.of_blocks(union, blocks) + assert.are.same({ P }, states(m, union)) + assert.are.same({ 1 }, hidden) end) - -- HEAD a b unterminated, worktree a B⏎: the index holds B without the newline - it("marks the hunk partial when the index holds its line but not its ending", function() - local rewrite = { - hunks = { hl(2, { "b" }, 2, { "B" }) }, - old_text = "a\nb", - new_text = "a\nB\n", + -- HEAD "a\nx\nc\nb\na\na\n", worktree "a\nc\nay\nz4\na\na": the index's last `a` + -- has no newline, so it's the worktree's added line, not HEAD's last one + it("cuts an unterminated last line into the hunk that ends the worktree", function() + local union = { + hl(2, { "x" }, 1, {}), + hl(4, { "b", "a" }, 3, { "ay", "z4" }), + hl(6, {}, 6, { "a" .. NO }), } - local m = marks.of_blocks(rewrite, { { "B" } }, "a\nB") - assert.are.same({ "partial" }, states(m, rewrite.hunks)) + local head = text.ended_lines("a\nx\nc\nb\na\na\n") + local blocks = marks.blocks(union, head, text.ended_lines("a\nx\nc\nb\na\na")) + assert.are.same({ { "x" }, { "b" }, { "a" .. NO } }, blocks) + assert.are.same({ U, P, S }, states(marks.of_blocks(union, blocks), union)) end) end) From 4a96e76b05a3fe2a24b0c97ea0401d2f0da61540 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:42:03 +0100 Subject: [PATCH 36/93] feat: note a changed last line that has no newline after it --- lua/differ/render/init.lua | 26 +++++++++++++- lua/differ/render/linemap.lua | 1 + lua/differ/ui/highlights.lua | 2 ++ lua/differ/ui/paint.lua | 7 ++++ test/nvim/paint_spec.lua | 31 ++++++++++++++++ test/unit/render_spec.lua | 66 +++++++++++++++++++++++++++++++++++ 6 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 test/nvim/paint_spec.lua create mode 100644 test/unit/render_spec.lua diff --git a/lua/differ/render/init.lua b/lua/differ/render/init.lua index 27ddf68..bc452db 100644 --- a/lua/differ/render/init.lua +++ b/lua/differ/render/init.lua @@ -34,6 +34,28 @@ local RENDERERS = { split = "differ.render.split", } +-- flag the changed line a side ends on without a newline, the line git follows with +-- `\ No newline at end of file` +---@param model differ.DiffModel +---@param result differ.RenderResult +local function flag_no_eol(model, result) + local to_lines = require("differ.util.text").to_lines + local last = {} ---@type table + for _, side in ipairs({ "old", "new" }) do + local text = model[side .. "_text"] + if text ~= "" and text:sub(-1) ~= "\n" then + last[side] = #to_lines(text) + end + end + for _, col in ipairs(result.columns) do + for _, line in ipairs(col.map.lines) do + if last[line.kind] and line[line.kind] == last[line.kind] then + line.no_eol = true + end + end + end +end + -- render a model under the given layout ---@param model differ.DiffModel ---@param opts { layout: differ.Layout, context: number, deep_diff: table } @@ -45,7 +67,9 @@ function M.render(model, opts) end ---@type differ.Renderer local renderer = require(mod).render - return renderer(model, opts) + local result = renderer(model, opts) + flag_no_eol(model, result) + return result end return M diff --git a/lua/differ/render/linemap.lua b/lua/differ/render/linemap.lua index 27632ee..acd7af5 100644 --- a/lua/differ/render/linemap.lua +++ b/lua/differ/render/linemap.lua @@ -13,6 +13,7 @@ ---@field new integer|nil ---@field hunk integer|nil -- index into DiffModel.hunks ---@field spans differ.SubSpan[]|nil -- word-level changed regions (old/new only) +---@field no_eol boolean|nil -- a side's last line, with no newline after it ---@class differ.LineMap ---@field lines differ.RailLine[] -- indexed by buffer lnum (1-based) diff --git a/lua/differ/ui/highlights.lua b/lua/differ/ui/highlights.lua index e5d9293..bb8735f 100644 --- a/lua/differ/ui/highlights.lua +++ b/lua/differ/ui/highlights.lua @@ -23,6 +23,8 @@ local LINKS = { -- a dashed fill marks the side that has no line here, the native-vimdiff look, so -- the gap reads as "absent on this side" rather than an empty void. dim like NonText differFiller = { link = "NonText" }, + -- the note after a changed last line with no newline after it + differNoEol = { link = "NonText" }, -- neutral fallback for a staged line with no add/delete kind; kinded staged lines -- use the dimmed deep-diff groups (differStagedLine{Add,Delete}/Word*) instead differStagedLine = { link = "CursorLine" }, diff --git a/lua/differ/ui/paint.lua b/lua/differ/ui/paint.lua index ec69987..71aa00c 100644 --- a/lua/differ/ui/paint.lua +++ b/lua/differ/ui/paint.lua @@ -42,6 +42,13 @@ function M.apply(bufnr, ns, column) priority = 100, }) end + if line.no_eol then + vim.api.nvim_buf_set_extmark(bufnr, ns, row, 0, { + virt_text = { { " no newline at end of file", "differNoEol" } }, + virt_text_pos = "eol", + priority = 100, + }) + end local word_hl = WORD_HL[line.kind] if word_hl and line.spans then for _, span in ipairs(line.spans) do diff --git a/test/nvim/paint_spec.lua b/test/nvim/paint_spec.lua new file mode 100644 index 0000000..637087c --- /dev/null +++ b/test/nvim/paint_spec.lua @@ -0,0 +1,31 @@ +local paint = require("differ.ui.paint") +local render = require("differ.render") +local diff = require("differ.model.diff") + +describe("paint no_eol", function() + it("notes a changed last line with no newline after it", function() + local model = diff.build({ + path = "x", + old_rev = "A", + new_rev = "B", + old_text = "a\nb", + new_text = "a\nb\n", + }) + local col = render.render(model, { layout = "stacked", context = math.huge }).columns[1] + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, col.lines) + local ns = vim.api.nvim_create_namespace("differ_paint_spec") + paint.apply(buf, ns, col) + + local notes = {} + for _, mark in ipairs(vim.api.nvim_buf_get_extmarks(buf, ns, 0, -1, { details = true })) do + local chunks = mark[4].virt_text + if chunks and mark[4].virt_text_pos == "eol" then + notes[#notes + 1] = { row = mark[2], text = vim.trim(chunks[1][1]) } + end + end + vim.api.nvim_buf_delete(buf, { force = true }) + -- rows: a (context), b (old, unterminated), b (new) + assert.are.same({ { row = 1, text = "no newline at end of file" } }, notes) + end) +end) diff --git a/test/unit/render_spec.lua b/test/unit/render_spec.lua new file mode 100644 index 0000000..a6f4e35 --- /dev/null +++ b/test/unit/render_spec.lua @@ -0,0 +1,66 @@ +local render = require("differ.render") + +-- HEAD "a\nb", worktree "a\nb\n": one hunk, b on both sides +local model = { + path = "x", + old_rev = "A", + new_rev = "B", + old_text = "a\nb", + new_text = "a\nb\n", + hunks = { + { + old_start = 2, + old_count = 1, + new_start = 2, + new_count = 1, + old_lines = { "b" }, + new_lines = { "b" }, + }, + }, +} + +-- each flagged row as "kind:lnum" +local function flagged(column) + local out = {} + for _, line in ipairs(column.map.lines) do + if line.no_eol then + out[#out + 1] = line.kind .. ":" .. (line[line.kind] or "") + end + end + return out +end + +describe("render no_eol", function() + it("flags the old side's last line in stacked", function() + local r = render.render(model, { layout = "stacked", context = math.huge }) + assert.are.same({ "old:2" }, flagged(r.columns[1])) + end) + + it("flags the old column's last line in split", function() + local r = render.render(model, { layout = "split", context = math.huge }) + assert.are.same({ "old:2" }, flagged(r.columns[1])) + assert.are.same({}, flagged(r.columns[2])) + end) + + it("leaves an unterminated line alone when no hunk shows it", function() + local context = { + path = "x", + old_rev = "A", + new_rev = "B", + old_text = "a\nb", + new_text = "A\nb", + hunks = { + { + old_start = 1, + old_count = 1, + new_start = 1, + new_count = 1, + old_lines = { "a" }, + new_lines = { "A" }, + }, + }, + } + local r = render.render(context, { layout = "stacked", context = math.huge }) + assert.are.same({}, flagged(r.columns[1])) + end) +end) From 089f35ba7c4ad902fc76bafb0be408dd87c1ef51 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:53:12 +0100 Subject: [PATCH 37/93] refactor: move the staging decisions into model/stage.lua --- lua/differ/git/init.lua | 110 +++++---------------------------- lua/differ/model/stage.lua | 94 ++++++++++++++++++++++++++++ test/unit/model_stage_spec.lua | 95 ++++++++++++++++++++++++++++ 3 files changed, 204 insertions(+), 95 deletions(-) create mode 100644 lua/differ/model/stage.lua create mode 100644 test/unit/model_stage_spec.lua diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 04cf016..d848ec9 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -1435,22 +1435,6 @@ function M.panel(opts) return true end - -- the indices of the hunks in `hunks` that do (or don't) meet `anchor` - ---@param hunks differ.Hunk[] - ---@param side "old"|"new" -- the side of `hunks` compared - ---@param anchor differ.Hunk - ---@param anchor_side "old"|"new" - ---@param want boolean -- true picks the hunks that meet it, false the rest - ---@return table - local function select_hunks(hunks, side, anchor, anchor_side, want) - local meets = require("differ.model.marks").meets - local applied = {} - for i, h in ipairs(hunks) do - applied[i] = meets(h, side, anchor, anchor_side) == want - end - return applied - end - -- write `text` as `entry`'s staged content. an add left with nothing staged leaves -- the index, and a rename only the worktree has made is staged along with its -- content; both change the row's status, so the view re-sources onto it @@ -1514,8 +1498,7 @@ function M.panel(opts) ---@return differ.view.Staging local function union_staging(entry) local marks = require("differ.model.marks") - local splice = require("differ.model.apply").splice - local join = require("differ.model.apply").join + local stage = require("differ.model.stage") local mode_why ---@type string|nil if entry.x ~= " " and entry.y ~= " " and mode_hidden(entry) then mode_why = "the index holds a mode change" @@ -1523,24 +1506,6 @@ function M.panel(opts) ---@type differ.view.Staging local staging = { marks = { old = {}, new = {} }, refresh = refresh_panel } - -- the index's lines at each union hunk, and the union with a last line and its - -- ending read as one (apply.ended). the blocks are nil unless the index holds - -- every unchanged line between them and rejoins to exactly its own text - ---@param union differ.DiffModel - ---@param cached differ.DiffModel - ---@return string[][]|nil blocks, differ.DiffModel ended - local function index_blocks(union, cached) - local ended_lines = require("differ.util.text").ended_lines - local ended = require("differ.model.apply").ended(union) - local index = cached.new_text - local blocks = - marks.blocks(ended.hunks, ended_lines(union.old_text), ended_lines(index)) - if not blocks or join(ended, blocks) ~= index then - return nil, ended - end - return blocks, ended - end - -- the view keeps staging.marks, so a re-mark writes through it. marks come from -- the index's own lines at each hunk, and from the two pairs only when the index -- changes a line between hunks @@ -1552,7 +1517,7 @@ function M.panel(opts) return end staging.hidden_in = nil - local blocks, ended = index_blocks(union, cached) + local blocks, ended = stage.index_blocks(union, cached.new_text) local held, hidden_in = nil, {} ---@type differ.model.Marks|nil, integer[] if blocks then held, hidden_in = marks.of_blocks(ended.hunks, blocks) @@ -1580,49 +1545,9 @@ function M.panel(opts) end end - -- a pair hunk that also reaches another union hunk holds index-only content - -- between the two, and taking it whole would carry the op into that hunk - ---@param union differ.DiffModel - ---@param pair differ.DiffModel -- index↔worktree on the new side, HEAD↔index on the old - ---@param side "old"|"new" - ---@param hunk differ.Hunk - ---@return boolean - local function shared(union, pair, side, hunk) - local other = marks.shared_with(union.hunks, hunk, pair.hunks, side) - if not other then - return false - end - local msg = "this hunk's staged change also covers hunk %d: " - .. "u on the ! hunk in dw, or in gs, unstages it whole" - if side == "new" then - msg = "this hunk's unstaged change also covers hunk %d: s in dw stages it whole" - end - notify(msg:format(other), vim.log.levels.WARN) - return true - end - - -- where `hunk` sits in a fresh read of the union, by its line ranges - ---@param union differ.DiffModel - ---@param hunk differ.Hunk - ---@return integer|nil - local function hunk_index(union, hunk) - for i, u in ipairs(union.hunks) do - if - u.old_start == hunk.old_start - and u.old_count == hunk.old_count - and u.new_start == hunk.new_start - and u.new_count == hunk.new_count - then - return i - end - end - return nil - end - -- the text the index takes when `hunk` is staged (`take`) or unstaged, or nil - -- when the op would reach another hunk. the index's lines at the hunk become the - -- hunk's new or old lines; an index that changes a line between hunks moves by - -- the pair hunks meeting it on the side that pair shares + -- when the pair hunk carrying this one's change covers another union hunk too, + -- which the other view's key takes whole ---@param union differ.DiffModel ---@param cached differ.DiffModel ---@param unstaged differ.DiffModel @@ -1630,23 +1555,17 @@ function M.panel(opts) ---@param take boolean ---@return string|nil local function next_index(union, cached, unstaged, hunk, take) - local blocks, ended = index_blocks(union, cached) - local i = hunk_index(union, hunk) - if blocks and i then - blocks[i] = ended.hunks[i].old_lines - if take then - blocks[i] = ended.hunks[i].new_lines - end - return join(ended, blocks) - end - local from, side = unstaged, "new" - if not take then - from, side = cached, "old" + local text, reaches = stage.next_index(union, cached, unstaged, hunk, take) + if not reaches then + return text end - if shared(union, from, side, hunk) then - return nil + local msg = "this hunk's staged change also covers hunk %d: " + .. "u on the ! hunk in dw, or in gs, unstages it whole" + if take then + msg = "this hunk's unstaged change also covers hunk %d: s in dw stages it whole" end - return splice(from, select_hunks(from.hunks, side, hunk, side, take)) + notify(msg:format(reaches), vim.log.levels.WARN) + return nil end -- whether the diff still shows the file as it is. one drawn before the file or @@ -2022,6 +1941,7 @@ function M.panel(opts) ---@return boolean local function drop_hidden(entry, model, staging, idx) local marks = require("differ.model.marks") + local stage = require("differ.model.stage") local splice = require("differ.model.apply").splice local applied, moved = {}, {} ---@type table, differ.Hunk[] for i, h in ipairs(model.hunks) do @@ -2045,7 +1965,7 @@ function M.panel(opts) local placed = vim.tbl_extend("force", hunk, { old_start = hunk.old_start + marks.shift(moved, hunk.old_start, "old"), }) - local keep = select_hunks(cached.hunks, "new", placed, "old", false) + local keep = stage.select_hunks(cached.hunks, "new", placed, "old", false) if not put_index(entry, splice(cached, keep)) then return false end diff --git a/lua/differ/model/stage.lua b/lua/differ/model/stage.lua new file mode 100644 index 0000000..6bda7fd --- /dev/null +++ b/lua/differ/model/stage.lua @@ -0,0 +1,94 @@ +-- what the index should hold after a hunk of a HEAD↔worktree diff is staged or +-- unstaged. the index's lines at each hunk come from marks.blocks where the index holds +-- every unchanged line between them; otherwise the op moves whole pair hunks. +-- pure lua, no vim API + +local apply = require("differ.model.apply") +local marks = require("differ.model.marks") +local text_util = require("differ.util.text") + +local M = {} + +-- the index's lines at each union hunk, and the union with a last line and its ending +-- read as one (apply.ended). the blocks are nil unless the index holds every unchanged +-- line between the hunks and rejoins to exactly its own text +---@param union differ.DiffModel -- HEAD↔worktree +---@param index string -- the index's text +---@return string[][]|nil blocks, differ.DiffModel ended +function M.index_blocks(union, index) + local ended = apply.ended(union) + local head_lines, index_lines = + text_util.ended_lines(union.old_text), text_util.ended_lines(index) + local blocks = marks.blocks(ended.hunks, head_lines, index_lines) + if not blocks or apply.join(ended, blocks) ~= index then + return nil, ended + end + return blocks, ended +end + +-- the hunks that meet `anchor` on the sides given, or the rest of them +---@param hunks differ.Hunk[] +---@param side "old"|"new" -- the side of `hunks` compared +---@param anchor differ.Hunk +---@param anchor_side "old"|"new" +---@param want boolean -- true picks the hunks that meet it, false the rest +---@return table +function M.select_hunks(hunks, side, anchor, anchor_side, want) + local applied = {} + for i, h in ipairs(hunks) do + applied[i] = marks.meets(h, side, anchor, anchor_side) == want + end + return applied +end + +-- where `hunk` sits in a fresh read of the union, by its line ranges +---@param union differ.DiffModel +---@param hunk differ.Hunk +---@return integer|nil +function M.hunk_index(union, hunk) + for i, u in ipairs(union.hunks) do + if + u.old_start == hunk.old_start + and u.old_count == hunk.old_count + and u.new_start == hunk.new_start + and u.new_count == hunk.new_count + then + return i + end + end + return nil +end + +-- the text the index takes when `hunk` is staged (`take`) or unstaged. the index's lines +-- at the hunk become the hunk's new or old lines; an index that changes a line between +-- hunks moves by the pair hunks meeting it on the side that pair shares. `reaches` names +-- another union hunk such a pair hunk covers too, and then there is no text: staging by +-- hunk can't take one without the other +---@param union differ.DiffModel -- HEAD↔worktree +---@param cached differ.DiffModel -- HEAD↔index +---@param unstaged differ.DiffModel -- index↔worktree +---@param hunk differ.Hunk +---@param take boolean +---@return string|nil text, integer|nil reaches +function M.next_index(union, cached, unstaged, hunk, take) + local blocks, ended = M.index_blocks(union, cached.new_text) + local i = M.hunk_index(union, hunk) + if blocks and i then + blocks[i] = ended.hunks[i].old_lines + if take then + blocks[i] = ended.hunks[i].new_lines + end + return apply.join(ended, blocks) + end + local from, side = unstaged, "new" + if not take then + from, side = cached, "old" + end + local reaches = marks.shared_with(union.hunks, hunk, from.hunks, side) + if reaches then + return nil, reaches + end + return apply.splice(from, M.select_hunks(from.hunks, side, hunk, side, take)) +end + +return M diff --git a/test/unit/model_stage_spec.lua b/test/unit/model_stage_spec.lua new file mode 100644 index 0000000..5df5732 --- /dev/null +++ b/test/unit/model_stage_spec.lua @@ -0,0 +1,95 @@ +local stage = require("differ.model.stage") + +local function model(old_text, new_text, hunks) + return { + path = "f", + old_rev = "A", + new_rev = "B", + old_text = old_text, + new_text = new_text, + hunks = hunks, + } +end + +local function h(old_start, old_lines, new_start, new_lines) + return { + old_start = old_start, + old_count = #old_lines, + old_lines = old_lines, + new_start = new_start, + new_count = #new_lines, + new_lines = new_lines, + } +end + +-- HEAD 1..5, worktree 1 2x 3 4 5y: one hunk on line 2 and one on the last line +local HEAD = "1\n2\n3\n4\n5\n" +local WORK = "1\n2x\n3\n4\n5y\n" +local function pairs_of(index) + local hunks = { h(2, { "2" }, 2, { "2x" }), h(5, { "5" }, 5, { "5y" }) } + return model(HEAD, WORK, hunks), model(HEAD, index, {}), model(index, WORK, hunks) +end + +describe("index_blocks", function() + it("reads each hunk's lines from an index that is HEAD", function() + local union = (pairs_of(HEAD)) + local blocks = stage.index_blocks(union, HEAD) + assert.are.same({ { "2" }, { "5" } }, blocks) + end) + + it("gives nil when the index changes a line between the hunks", function() + local union = (pairs_of(HEAD)) + assert.is_nil(stage.index_blocks(union, "1\n2\n3x\n4\n5\n")) + end) +end) + +describe("next_index", function() + it("stages a hunk by giving the index the worktree's lines", function() + local union, cached, unstaged = pairs_of(HEAD) + local text = stage.next_index(union, cached, unstaged, union.hunks[1], true) + assert.are.equal("1\n2x\n3\n4\n5\n", text) + end) + + it("unstages a hunk by giving the index HEAD's lines", function() + local index = "1\n2x\n3\n4\n5\n" + local union, cached, unstaged = pairs_of(index) + cached.hunks = { h(2, { "2" }, 2, { "2x" }) } + unstaged.hunks = { h(5, { "5" }, 5, { "5y" }) } + local text = stage.next_index(union, cached, unstaged, union.hunks[1], false) + assert.are.equal(HEAD, text) + end) + + -- HEAD "a\nb", worktree "a\nb\n": the hunk is the ending alone + it("takes the ending with the hunk that reaches the end of the file", function() + local hunks = { h(2, { "b" }, 2, { "b" }) } + local union = model("a\nb", "a\nb\n", hunks) + local cached = model("a\nb", "a\nb", {}) + local unstaged = model("a\nb", "a\nb\n", hunks) + assert.are.equal("a\nb\n", stage.next_index(union, cached, unstaged, union.hunks[1], true)) + end) + + -- HEAD 1..7, index 1 A B C D E 7, worktree 1 A2 3 4 D2 E2 7: the index's B and C + -- sit between the two union hunks, so one HEAD↔index hunk covers both + it("names the other hunk a pair hunk would carry the op into", function() + local head = "1\n2\n3\n4\n5\n6\n7\n" + local index = "1\nA\nB\nC\nD\nE\n7\n" + local work = "1\nA2\n3\n4\nD2\nE2\n7\n" + local union = model(head, work, { + h(2, { "2" }, 2, { "A2" }), + h(5, { "5", "6" }, 5, { "D2", "E2" }), + }) + local cached = model( + head, + index, + { h(2, { "2", "3", "4", "5", "6" }, 2, { "A", "B", "C", "D", "E" }) } + ) + local unstaged = model( + index, + work, + { h(2, { "A", "B", "C", "D", "E" }, 2, { "A2", "3", "4", "D2", "E2" }) } + ) + local text, reaches = stage.next_index(union, cached, unstaged, union.hunks[1], false) + assert.is_nil(text) + assert.are.equal(2, reaches) + end) +end) From 78834575d45278526f241ba89e060a95107f432e Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:08:25 +0100 Subject: [PATCH 38/93] refactor: write the revert sequence, whole-file revert and index path once each --- lua/differ/git/init.lua | 173 +++++++++++++++++++++------------------- 1 file changed, 91 insertions(+), 82 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index d848ec9..42ddf6b 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -692,20 +692,25 @@ local function union_pairs(root, path, head, index, work) pair("INDEX", "WORKTREE", index, work) end +-- the path an entry's index entry sits at: a rename only the worktree has made (` R`) +-- leaves it at the old path, the new one holding an empty intent-to-add placeholder +---@param entry differ.FileEntry +---@return string +local function index_path(entry) + if entry.y == "R" and entry.previous_path then + return entry.previous_path + end + return entry.path +end + -- a row's three diffs, read fresh, or nil when the index can't be read. HEAD is read at --- a rename's old path, and so is the index for a rename only the worktree has made --- (` R`), whose entry at the new path is an empty intent-to-add placeholder +-- a rename's old path ---@param root string ---@param entry differ.FileEntry ---@return differ.DiffModel|nil union, differ.DiffModel|nil cached, differ.DiffModel|nil unstaged function M.union_models(root, entry) - local prev = entry.previous_path - local at_head = prev or entry.path - local at_index = entry.path - if entry.y == "R" and prev then - at_index = prev - end - local index = M.read(INDEX, root, at_index) + local at_head = entry.previous_path or entry.path + local index = M.read(INDEX, root, index_path(entry)) if not index then return nil end @@ -1489,6 +1494,35 @@ function M.panel(opts) return false end + -- X on a hunk: the file gives those lines back, and `stage_index` moves the index's + -- half of the change. the file is checked first, so a file that changed those lines + -- refuses before the index moves, and a false from `stage_index` stops it there too. + -- it goes through a patch, not a write: the model's new side was read through the + -- clean filter, so writing it back would push that conversion to disk + ---@param entry differ.FileEntry + ---@param model differ.DiffModel + ---@param hunk differ.Hunk + ---@param offset integer -- the model's new-side lines to the file's, see patch.hunk + ---@param stage_index fun(): boolean + ---@return boolean + local function revert_worktree(entry, model, hunk, offset, stage_index) + local p = patch.hunk(model.path, hunk, model.old_text, model.new_text, offset, "new") + if not M.apply_patch(root, p, true, "worktree", true) then + notify("the file has changed these lines: nothing reverted", vim.log.levels.WARN) + return false + end + if not stage_index() then + return false + end + local ok, err = M.apply_patch(root, p, true, "worktree") + reload_buffer(root, entry.path) + if not ok then + notify(("hunk revert failed: %s"):format(err or ""), vim.log.levels.ERROR) + return false + end + return true + end + -- staging for a row shown as HEAD↔worktree. both directions work out the content -- the index should hold and write it whole rather than patching: staging a hunk -- means the index takes the worktree's version of the lines it covers, and @@ -1605,10 +1639,7 @@ function M.panel(opts) return true end -- X throws the hunk away on both sides at once: the index gives up whatever of - -- it it holds and the worktree gives up the rest. the worktree half is checked - -- first, so a file that changed those lines refuses before the index moves. it - -- goes through a patch, not a write: the model's new side was read through the - -- clean filter, so writing it back would push that conversion to disk + -- it it holds and the worktree gives up the rest staging.revert = function(model, idx) local union, cached, unstaged = M.union_models(root, entry) if not (union and cached and unstaged) then @@ -1622,18 +1653,10 @@ function M.panel(opts) if not text then return false end - local p = patch.hunk(model.path, hunk, model.old_text, model.new_text, 0, "new") - if not M.apply_patch(root, p, true, "worktree", true) then - notify("the file has changed these lines: nothing reverted", vim.log.levels.WARN) - return false - end - if text ~= cached.new_text and not put_index(entry, text) then - return false - end - local ok, err = M.apply_patch(root, p, true, "worktree") - reload_buffer(root, entry.path) + local ok = revert_worktree(entry, model, hunk, 0, function() + return text == cached.new_text or put_index(entry, text) + end) if not ok then - notify(("hunk revert failed: %s"):format(err or ""), vim.log.levels.ERROR) remark(M.union_models(root, entry)) return false end @@ -1664,6 +1687,27 @@ function M.panel(opts) return staging end + -- X on a whole-file row, and what it does to the file. `letter` owns the change the + -- row shows: its status for a worktree row, the index's (x) in the commit preview + ---@param entry differ.FileEntry + ---@param letter string + ---@return fun(): boolean revert, string label + local function whole_file_revert(entry, letter) + if letter == "D" then + return function() + return restore_deleted(entry) + end, + "restores the file" + end + local discard = function() + return M.discard(root, entry) + end + if letter == "A" or letter == "?" then + return discard, "deletes the file" + end + return discard, "puts it back as HEAD has it" + end + -- a whole-file row's staged state, from its status letters ---@param entry differ.FileEntry ---@return differ.model.HunkState @@ -1693,10 +1737,7 @@ function M.panel(opts) local staging = union_staging(entry) if added then -- throwing away an add is deleting the file, not reverting a hunk of it - staging.revert = function() - return M.discard(root, entry) - end - staging.revert_label = "deletes the file" + staging.revert, staging.revert_label = whole_file_revert(entry, "A") end return staging end @@ -1715,19 +1756,9 @@ function M.panel(opts) if content then return staging end - if entry.status == "?" or entry.status == "A" then - -- `discard` drops the staged add before removing the file - staging.revert = function() - return M.discard(root, entry) - end - staging.revert_label = "deletes the file" - return staging - end - if entry.status == "D" then - staging.revert = function() - return restore_deleted(entry) - end - staging.revert_label = "restores the file" + -- an add's discard drops the staged entry before removing the file + if entry.status == "?" or entry.status == "A" or entry.status == "D" then + staging.revert, staging.revert_label = whole_file_revert(entry, entry.status) return staging end return nil @@ -1758,8 +1789,7 @@ function M.panel(opts) -- the commit preview opens staged with the index as its new side; the local view -- opens unstaged with it as its old side local held = staged and model.new_text or model.old_text - -- a rename only the worktree has made leaves the index at the old path - local at_index = entry.y == "R" and entry.previous_path or entry.path + local at_index = index_path(entry) local marks = { old = {}, new = {} } ---@param h differ.Hunk ---@param on boolean @@ -1835,10 +1865,8 @@ function M.panel(opts) end -- X in a frozen view (the commit preview, the local view): the hunk leaves the index - -- if marked staged, and the file takes its old lines back. the file half is checked - -- first, so a file that changed those lines refuses before the index moves; then - -- `reopen` re-reads the view. a last hunk leaves nothing to reopen, and the view - -- hands over to the panel itself + -- if marked staged, and the file takes its old lines back, then `reopen` re-reads the + -- view. a last hunk leaves nothing to reopen, and the view hands over to the panel ---@param entry differ.FileEntry ---@param model differ.DiffModel ---@param staging differ.view.Staging @@ -1848,19 +1876,16 @@ function M.panel(opts) ---@return boolean local function revert_frozen(entry, model, staging, idx, offset, reopen) local hunk = model.hunks[idx] - local p = patch.hunk(model.path, hunk, model.old_text, model.new_text, offset, "new") - if not M.apply_patch(root, p, true, "worktree", true) then - notify("the file has changed these lines: nothing reverted", vim.log.levels.WARN) - return false - end - local marked = require("differ.model.marks").state(staging.marks, hunk) == "staged" - if marked and not (staging.apply and staging.apply(model, hunk, true)) then - return false - end - local ok, err = M.apply_patch(root, p, true, "worktree") - reload_buffer(root, entry.path) + local ok = revert_worktree(entry, model, hunk, offset, function() + if require("differ.model.marks").state(staging.marks, hunk) ~= "staged" then + return true + end + if not staging.apply then + return false + end + return staging.apply(model, hunk, true) + end) if not ok then - notify(("hunk revert failed: %s"):format(err or ""), vim.log.levels.ERROR) return false end if #model.hunks > 1 then @@ -1873,25 +1898,6 @@ function M.panel(opts) return true end - -- X on a whole-file commit-preview row, and what it does to the file - ---@param entry differ.FileEntry - ---@return fun(): boolean revert, string label - local function whole_file_revert(entry) - local label = "puts it back as HEAD has it" - local revert = function() - return M.discard(root, entry) - end - if entry.x == "D" then - label = "restores the file" - revert = function() - return restore_deleted(entry) - end - elseif entry.x == "A" then - label = "deletes the file" - end - return revert, label - end - -- staging for a commit-preview row, HEAD↔index. an add or a deletion is one unit -- whose index entry comes and goes, so it stages as a file ---@param entry differ.FileEntry @@ -1919,7 +1925,7 @@ function M.panel(opts) end else staging = snapshot_staging(entry) - staging.revert, staging.revert_label = whole_file_revert(entry) + staging.revert, staging.revert_label = whole_file_revert(entry, entry.x) end staging.badge = "STAGED" staging.no_local = "the commit preview has no local view: gs goes back" @@ -1983,9 +1989,12 @@ function M.panel(opts) return end local focus_line, focus_col = view:cursor_new_line() - -- a rename only the worktree has made leaves the index at the old path - local at_index = entry.y == "R" and entry.previous_path or nil - local file = { path = entry.path, status = entry.status, previous_path = at_index } + local at_index = index_path(entry) + local prev = nil ---@type string|nil + if at_index ~= entry.path then + prev = at_index + end + local file = { path = entry.path, status = entry.status, previous_path = prev } local model = M.model({ old = INDEX, new = WORKTREE }, root, file, head_branch(root)) local staging ---@type differ.view.Staging if #model.hunks > 0 then From 454f602048110a00b2adefc99ccce04c24a7ab7b Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:54:50 +0100 Subject: [PATCH 39/93] refactor: read staged state through a view tally and an entry's review field --- lua/differ/git/init.lua | 33 +++++++++++++++++++++++++++------ lua/differ/panel/init.lua | 8 ++++---- lua/differ/panel/tree.lua | 3 +++ lua/differ/ui/winbar.lua | 13 ++----------- lua/differ/view.lua | 17 +++++++++++++++++ test/nvim/git_spec.lua | 10 +++++++++- test/nvim/panel_spec.lua | 24 ++++++++++++++++++++---- test/unit/winbar_spec.lua | 31 +++++++++++++++++++++++++++++++ 8 files changed, 113 insertions(+), 26 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 42ddf6b..320ed26 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -762,6 +762,25 @@ local function porcelain(root) return entries, untracked, err end +-- what a row leaves to review, from the two status letters: a conflict is the merge +-- tool's, and the panel's review walk steps past it +---@param x string -- index side +---@param y string -- worktree side +---@param status string +---@return differ.panel.Review +local function review_state(x, y, status) + if status == "U" then + return "conflict" + end + if x == " " or x == "?" then + return "unstaged" + end + if y == " " then + return "staged" + end + return "partial" +end + -- a panel row for porcelain entry `s`, lettered `status` and counted from `counts` ---@param s differ.git.StatusEntry ---@param status string @@ -773,6 +792,7 @@ local function status_row(s, status, counts, untracked) return { path = s.path, status = status, + review = review_state(s.x, s.y, status), additions = c.additions or 0, deletions = c.deletions or 0, x = s.x, @@ -1708,17 +1728,18 @@ function M.panel(opts) return discard, "puts it back as HEAD has it" end - -- a whole-file row's staged state, from its status letters + -- a whole-file row's staged state. a conflict holds both sides at once, which the + -- view has no third state for ---@param entry differ.FileEntry ---@return differ.model.HunkState local function row_state(entry) - if entry.x == " " or entry.x == "?" then - return "unstaged" - end - if entry.y == " " then + if entry.review == "staged" then return "staged" end - return "partial" + if entry.review == "partial" or entry.review == "conflict" then + return "partial" + end + return "unstaged" end ---@param entry differ.FileEntry diff --git a/lua/differ/panel/init.lua b/lua/differ/panel/init.lua index 4a4acd3..1d8f564 100644 --- a/lua/differ/panel/init.lua +++ b/lua/differ/panel/init.lua @@ -939,11 +939,11 @@ function Panel:_edge_file_row(edge) end -- whether a row has something left to stage: anything but a Staged row, and every row --- of a source without staging +-- of a source without staging, whose entries carry no review state ---@param e differ.FileEntry ---@return boolean local function has_unstaged(e) - return e.y ~= " " + return e.review ~= "staged" end -- move the cursor to the first unstaged file row, skipping the Staged section so @@ -970,11 +970,11 @@ end ---@param staged boolean ---@return boolean local function has_review_work(e, staged) - if e.status == "U" then + if e.review == "conflict" then return false end if staged then - return e.x ~= nil and e.x ~= " " and e.x ~= "?" + return e.review == "staged" or e.review == "partial" end return has_unstaged(e) end diff --git a/lua/differ/panel/tree.lua b/lua/differ/panel/tree.lua index a0a3cc4..9910bb1 100644 --- a/lua/differ/panel/tree.lua +++ b/lua/differ/panel/tree.lua @@ -5,6 +5,8 @@ local M = {} +---@alias differ.panel.Review "staged"|"partial"|"unstaged"|"conflict" + ---@class differ.FileEntry ---@field path string ---@field status "A"|"M"|"D"|"R"|"C"|"U"|"?" @@ -12,6 +14,7 @@ local M = {} ---@field deletions integer ---@field x string|nil -- worktree panels: git's index status letter ---@field y string|nil -- worktree panels: git's worktree status letter +---@field review differ.panel.Review|nil -- what the row leaves to review; nil off a staging source ---@field kept boolean|nil -- a staged deletion whose file is still on disk, untracked ---@field previous_path string|nil -- renames/copies ---@field viewed boolean|nil -- PR only diff --git a/lua/differ/ui/winbar.lua b/lua/differ/ui/winbar.lua index ade9204..5e77682 100644 --- a/lua/differ/ui/winbar.lua +++ b/lua/differ/ui/winbar.lua @@ -91,17 +91,8 @@ end ---@param view differ.View ---@return string function M.tally(view) - if not view:_can_stage_hunk() or view:_whole_file() then - return "" - end - local counts = { staged = 0, partial = 0 } - for i = 1, #view.model.hunks do - local state = view:_hunk_state(i) - if counts[state] then - counts[state] = counts[state] + 1 - end - end - if counts.staged == #view.model.hunks then + local counts = view:hunk_tally() + if not counts or counts.staged == counts.total then return "" end local out = "" diff --git a/lua/differ/view.lua b/lua/differ/view.lua index 73c9540..fe327b1 100644 --- a/lua/differ/view.lua +++ b/lua/differ/view.lua @@ -1173,6 +1173,23 @@ function View:_hunk_state(idx) return require("differ.model.marks").state(self.marks, h) end +-- how many of the file's hunks the index holds, and how many it holds part of. nil off +-- a hunk-staging source, which has nothing to tally +---@return { staged: integer, partial: integer, total: integer }|nil +function View:hunk_tally() + if not self:_can_stage_hunk() or self:_whole_file() then + return nil + end + local counts = { staged = 0, partial = 0, total = #self.model.hunks } + for i = 1, counts.total do + local state = self:_hunk_state(i) + if state == "staged" or state == "partial" then + counts[state] = counts[state] + 1 + end + end + return counts +end + -- the slot the staging keys act on: slot 1 for a whole-file source, backed by a hunk -- or not; anything else takes the hunk under the cursor ---@return integer|nil diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index e0047de..2b65f8f 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -565,7 +565,15 @@ describe("git.status_sections", function() end end assert.are.same({ - { path = "multi.lua", status = "?", additions = 3, deletions = 0, x = "?", y = "?" }, + { + path = "multi.lua", + status = "?", + additions = 3, + deletions = 0, + x = "?", + y = "?", + review = "unstaged", + }, }, untracked) end) diff --git a/test/nvim/panel_spec.lua b/test/nvim/panel_spec.lua index f08f1c4..f1ce2a4 100644 --- a/test/nvim/panel_spec.lua +++ b/test/nvim/panel_spec.lua @@ -164,11 +164,19 @@ describe("panel navigation", function() it("focus_first_unstaged lands on the first unstaged file, skipping Staged", function() local function se(path, staged) - local x, y = "M", " " + local x, y, review = "M", " ", "staged" if not staged then - x, y = " ", "M" + x, y, review = " ", "M", "unstaged" end - return { path = path, status = "M", additions = 1, deletions = 0, x = x, y = y } + return { + path = path, + status = "M", + additions = 1, + deletions = 0, + x = x, + y = y, + review = review, + } end local p, picked = panel({}, { sections = { @@ -185,7 +193,15 @@ describe("panel navigation", function() it("focus_first_unstaged falls back to the first file when all are staged", function() local function se(path) - return { path = path, status = "M", additions = 1, deletions = 0, x = "M", y = " " } + return { + path = path, + status = "M", + additions = 1, + deletions = 0, + x = "M", + y = " ", + review = "staged", + } end local p, picked = panel({}, { sections = { { title = "Staged", entries = { se("a.lua"), se("b.lua") } } }, diff --git a/test/unit/winbar_spec.lua b/test/unit/winbar_spec.lua index 4d44817..425615c 100644 --- a/test/unit/winbar_spec.lua +++ b/test/unit/winbar_spec.lua @@ -62,6 +62,37 @@ describe("winbar.hunk_at", function() end) end) +describe("winbar.tally", function() + -- a view is only read through hunk_tally here + local function view(states) + return { + hunk_tally = function() + local counts = { staged = 0, partial = 0, total = #states } + for _, state in ipairs(states) do + if state ~= "unstaged" then + counts[state] = counts[state] + 1 + end + end + return counts + end, + } + end + + it("counts staged and partial hunks, leaving zero counts out", function() + assert.are.equal("1 staged · 1 partial · ", winbar.tally(view({ "staged", "partial" }))) + assert.are.equal("1 partial · ", winbar.tally(view({ "partial", "unstaged" }))) + assert.are.equal("", winbar.tally(view({ "unstaged" }))) + end) + + it("is empty once every hunk is staged", function() + assert.are.equal("", winbar.tally(view({ "staged", "staged" }))) + end) + + it("is empty off a hunk-staging source", function() + assert.are.equal("", winbar.tally({ hunk_tally = function() end })) + end) +end) + describe("winbar.hunk_at with no hunks", function() it("is always 0", function() local map = stacked.render({ From 44cd7e1cd73fdc710dcd30145208b31dc3f498ef Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:11:18 +0100 Subject: [PATCH 40/93] refactor: drop the unread hidden reasons and the dead pairs field --- lua/differ/git/init.lua | 20 +++++++++++--------- lua/differ/model/diff.lua | 1 - lua/differ/model/marks.lua | 22 ++++++++++++---------- lua/differ/view.lua | 4 ++-- test/unit/model_marks_spec.lua | 28 +++++++--------------------- 5 files changed, 32 insertions(+), 43 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 320ed26..3ccbdf6 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -1553,9 +1553,9 @@ function M.panel(opts) local function union_staging(entry) local marks = require("differ.model.marks") local stage = require("differ.model.stage") - local mode_why ---@type string|nil + local mode_change = false if entry.x ~= " " and entry.y ~= " " and mode_hidden(entry) then - mode_why = "the index holds a mode change" + mode_change = true -- the index holds a mode change, which no hunk shows end ---@type differ.view.Staging local staging = { marks = { old = {}, new = {} }, refresh = refresh_panel } @@ -1578,22 +1578,24 @@ function M.panel(opts) end if held then staging.marks.old, staging.marks.new = held.old, held.new - staging.hidden = mode_why - if not mode_why and #hidden_in > 0 then - staging.hidden = "the index holds a line neither HEAD nor the worktree has" + staging.hidden = nil + if mode_change then + staging.hidden = true + elseif #hidden_in > 0 then + staging.hidden = true staging.hidden_in = hidden_in end return end local fresh = marks.classify(union.hunks, cached.hunks, unstaged.hunks) staging.marks.old, staging.marks.new = fresh.old, fresh.new - local complete, why = marks.complete(union.hunks, cached.hunks, unstaged.hunks) - if mode_why then - staging.hidden = mode_why + local complete = marks.complete(union.hunks, cached.hunks, unstaged.hunks) + if mode_change then + staging.hidden = true elseif complete then staging.hidden = nil else - staging.hidden = why + staging.hidden = true local head = require("differ.util.text").to_lines(union.old_text) staging.hidden_in = marks.hidden_in(head, union.hunks, cached.hunks, fresh) end diff --git a/lua/differ/model/diff.lua b/lua/differ/model/diff.lua index 029e7ea..5a77289 100644 --- a/lua/differ/model/diff.lua +++ b/lua/differ/model/diff.lua @@ -7,7 +7,6 @@ ---@field new_count integer ---@field old_lines string[] ---@field new_lines string[] ----@field pairs differ.LinePair[]|nil ---@class differ.DiffModel ---@field path string diff --git a/lua/differ/model/marks.lua b/lua/differ/model/marks.lua index 5b9b401..357c061 100644 --- a/lua/differ/model/marks.lua +++ b/lua/differ/model/marks.lua @@ -519,21 +519,23 @@ end -- whether a HEAD↔worktree diff shows all of a file's half-staged content. it cannot -- show index content that differs from HEAD and worktree both: stage a change and then -- put the worktree back or edit it again, or let the two diffs align a run of repeated --- lines differently, and the change is real but off-screen. a false puts a notice on --- the view +-- lines differently, and the change is real but off-screen. a false puts the note on +-- the winbar, which says how many hunks hold some of it rather than why ---@param union differ.Hunk[] ---@param cached differ.Hunk[] ---@param unstaged differ.Hunk[] ----@return boolean complete, string|nil reason +---@return boolean function M.complete(union, cached, unstaged) + -- the index differs from HEAD and the worktree matches it if #union == 0 and (#cached > 0 or #unstaged > 0) then - return false, "the index differs from HEAD and the worktree matches it" + return false end + -- unstaged or staged content sitting outside every hunk local shows_new = covered(union, "new") for _, h in ipairs(unstaged) do for l = h.new_start, h.new_start + h.new_count - 1 do if not shows_new[l] then - return false, "unstaged content sits outside every hunk" + return false end end end @@ -541,7 +543,7 @@ function M.complete(union, cached, unstaged) for _, h in ipairs(cached) do for l = h.old_start, h.old_start + h.old_count - 1 do if not shows_old[l] then - return false, "staged content sits outside every hunk" + return false end end end @@ -552,10 +554,10 @@ function M.complete(union, cached, unstaged) for _, h in ipairs(unstaged) do for l = h.old_start, h.old_start + h.old_count - 1 do if added[l] then - return false, "the index holds a line neither HEAD nor the worktree has" + return false -- a line only the index holds end if not shows_old[head_line(cached, l)] then - return false, "a line the worktree replaced sits outside every hunk" + return false -- a line the worktree replaced, outside every hunk end end end @@ -581,9 +583,9 @@ function M.complete(union, cached, unstaged) net = net + h.new_count - h.old_count end if shown ~= net then - return false, "the marked lines don't add up to the index" + return false -- the marked lines don't add up to the index end - return true, nil + return true end return M diff --git a/lua/differ/view.lua b/lua/differ/view.lua index fe327b1..01d1c22 100644 --- a/lua/differ/view.lua +++ b/lua/differ/view.lua @@ -82,7 +82,7 @@ local armed_view = nil ---@field revert_label? string -- e.g. "deletes the file" ---@field refresh fun() ---@field marks? differ.model.Marks -- union source: which lines the index holds, kept current by the source ----@field hidden? string -- union source: why some staged content isn't on screen +---@field hidden? boolean -- union source: some staged content isn't on screen ---@field hidden_in? integer[] -- the hunks marked `!`: they touch staged content the whole change can't show ---@field toggle_local? fun() -- a partly staged file: swap between the whole change and its local view ---@field no_local? string -- why dw does nothing here, in place of the default @@ -499,7 +499,7 @@ end ---@param opened differ.view.OpenedFolds function View:_apply_folds(opened) local side = anchor_side(opened, self.model) - for ci, col in ipairs(self.columns) do + for _, col in ipairs(self.columns) do local win = col.winid if win and vim.api.nvim_win_is_valid(win) then set_wo(win, "foldmethod", "manual") diff --git a/test/unit/model_marks_spec.lua b/test/unit/model_marks_spec.lua index 40c95ba..92387be 100644 --- a/test/unit/model_marks_spec.lua +++ b/test/unit/model_marks_spec.lua @@ -75,21 +75,15 @@ describe("union marks", function() -- stage an edit, then put the worktree back: git still calls the file MM, both -- pairs hold a change, and HEAD↔worktree is empty it("catches an edit staged and then undone in the worktree", function() - local ok, why = marks.complete({}, { h(2, 1, 2, 1) }, { h(2, 1, 2, 1) }) - assert.is_false(ok) - assert.are.equal("the index differs from HEAD and the worktree matches it", why) + assert.is_false(marks.complete({}, { h(2, 1, 2, 1) }, { h(2, 1, 2, 1) })) end) it("catches unstaged content no hunk covers", function() - local ok, why = marks.complete({ h(3, 1, 3, 1) }, {}, { h(9, 1, 9, 1) }) - assert.is_false(ok) - assert.are.equal("unstaged content sits outside every hunk", why) + assert.is_false(marks.complete({ h(3, 1, 3, 1) }, {}, { h(9, 1, 9, 1) })) end) it("catches staged content no hunk covers", function() - local ok, why = marks.complete({ h(3, 1, 3, 1) }, { h(9, 1, 9, 1) }, {}) - assert.is_false(ok) - assert.are.equal("staged content sits outside every hunk", why) + assert.is_false(marks.complete({ h(3, 1, 3, 1) }, { h(9, 1, 9, 1) }, {})) end) -- HEAD x,y; index x,ghost,y; worktree X,y. the line the index added and the @@ -98,9 +92,7 @@ describe("union marks", function() local union = { hl(1, { "x" }, 1, { "X" }) } local cached = { hl(1, {}, 2, { "ghost" }) } local unstaged = { hl(1, { "x", "ghost" }, 1, { "X" }) } - local ok, why = marks.complete(union, cached, unstaged) - assert.is_false(ok) - assert.are.equal("the index holds a line neither HEAD nor the worktree has", why) + assert.is_false(marks.complete(union, cached, unstaged)) end) -- the pair diffs can align repeated lines differently from the union: here the @@ -108,9 +100,7 @@ describe("union marks", function() it("catches a replaced line the union shows as context", function() local union = { h(1, 1, 1, 1) } local unstaged = { h(5, 1, 4, 0) } - local ok, why = marks.complete(union, {}, unstaged) - assert.is_false(ok) - assert.are.equal("a line the worktree replaced sits outside every hunk", why) + assert.is_false(marks.complete(union, {}, unstaged)) end) -- stage an edit, then edit the same line again: the index's version is replaced @@ -119,9 +109,7 @@ describe("union marks", function() local union = { hl(1, { "foo" }, 1, { "FOOD" }) } local cached = { hl(1, { "foo" }, 1, { "FOO" }) } local unstaged = { hl(1, { "FOO" }, 1, { "FOOD" }) } - local ok, why = marks.complete(union, cached, unstaged) - assert.is_false(ok) - assert.are.equal("the index holds a line neither HEAD nor the worktree has", why) + assert.is_false(marks.complete(union, cached, unstaged)) end) -- HEAD a,a,a,b; index a,a,c,b; worktree b,a,c,b. every range check passes, but @@ -131,9 +119,7 @@ describe("union marks", function() local union = { hl(1, { "a", "a", "a" }, 1, { "b", "a", "c" }) } local cached = { hl(3, { "a" }, 3, { "c" }) } local unstaged = { hl(1, { "a" }, 1, { "b" }) } - local ok, why = marks.complete(union, cached, unstaged) - assert.is_false(ok) - assert.are.equal("the marked lines don't add up to the index", why) + assert.is_false(marks.complete(union, cached, unstaged)) end) it("allows a deletion of a line HEAD held too", function() From acb1b5d79407afd2d789ad64c1090344a30b097d Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:17:14 +0100 Subject: [PATCH 41/93] refactor: split git exec, index and staging out of the panel session --- lua/differ/git/exec.lua | 116 ++++++ lua/differ/git/index.lua | 124 ++++++ lua/differ/git/init.lua | 815 ++----------------------------------- lua/differ/git/staging.lua | 576 ++++++++++++++++++++++++++ 4 files changed, 854 insertions(+), 777 deletions(-) create mode 100644 lua/differ/git/exec.lua create mode 100644 lua/differ/git/index.lua create mode 100644 lua/differ/git/staging.lua diff --git a/lua/differ/git/exec.lua b/lua/differ/git/exec.lua new file mode 100644 index 0000000..8107367 --- /dev/null +++ b/lua/differ/git/exec.lua @@ -0,0 +1,116 @@ +-- spawning git, and telling the user (or a stale buffer) about it. every git module +-- runs through here, so the argv prefix and the message shape sit in one place + +local M = {} + +-- every path differ hands git is a real file name, never a glob +M.GIT = { "git", "--literal-pathspecs" } + +---@param msg string +---@param level integer|nil +function M.notify(msg, level) + vim.notify("differ: " .. msg, level or vim.log.levels.INFO) +end + +-- spawn git and wait. vim.system raises when the process can't start, so that comes +-- back as `err`; a nil res with no err is wait() answering nothing at all +---@param cmd string[] +---@param opts table +---@return { code: integer, stdout: string|nil, stderr: string|nil }|nil res, string|nil err +local function run(cmd, opts) + local ok, obj = pcall(vim.system, cmd, opts) + if not ok then + return nil, tostring(obj) + end + return obj:wait() +end + +-- run git in `cwd`: stdout on success, nil + stderr on failure. `text = true` +-- normalises `\r\n`, which is right for plumbing output but would corrupt file content +-- read via `git show`; content reads use git_raw instead +---@param args string[] +---@param cwd string +---@param opts? { timeout?: integer, env?: table } +---@return string|nil stdout, string|nil stderr +function M.git(args, cwd, opts) + local cmd = vim.list_extend({}, M.GIT) + vim.list_extend(cmd, args) + opts = opts or {} + local res, err = run(cmd, { + cwd = cwd, + text = true, + timeout = opts.timeout, + env = opts.env, + }) + if err then + return nil, err -- never started + end + -- a killed process exits 124 with an empty stderr, or answers nothing at all when a + -- child outlives it holding the pipes. both are the budget, and neither says so + if opts.timeout and (not res or res.code == 124) then + return nil, ("git %s timed out after %ds"):format(args[1], opts.timeout / 1000) + end + if not res then + return nil, "git exited without a result" + end + if res.code ~= 0 then + return nil, res.stderr + end + return res.stdout +end + +-- like `git`, but byte-true stdout (CRLF kept): for content reads (`git show` on the +-- index, a rev, a conflict stage), never for plumbing output +---@param args string[] +---@param cwd string +---@return string|nil stdout, string|nil stderr +function M.git_raw(args, cwd) + local cmd = vim.list_extend({}, M.GIT) + vim.list_extend(cmd, args) + local res, err = run(cmd, { cwd = cwd }) + if not res then + return nil, err + end + if res.code ~= 0 then + return nil, res.stderr + end + return res.stdout +end + +-- run git and report a failure where the user can see it. the wrapper owns the message, +-- so a caller branching on the boolean alone can't swallow one +---@param args string[] +---@param cwd string +---@param what string -- names the operation in the message +---@return boolean ok +function M.git_ok(args, cwd, what) + local out, err = M.git(args, cwd) -- nil out is exactly a non-zero exit; stderr can be empty + if not out then + M.notify(("%s failed: %s"):format(what, err or ""), vim.log.levels.ERROR) + return false + end + return true +end + +-- strip trailing whitespace from a git output line (e.g. rev-parse, show, log) +---@param s string +---@return string +function M.chomp(s) + return (s:gsub("%s+$", "")) +end + +-- a buffer on a file differ just rewrote keeps the old content until something checks, +-- and a window switch doesn't. checktime leaves unsaved edits alone, so fire it always +---@param root string +---@param relpath string +function M.reload_buffer(root, relpath) + local buf = require("differ.util.buf").find(root .. "/" .. relpath) + if buf and vim.api.nvim_buf_is_loaded(buf) then + -- silent!: the file may be gone entirely (a discarded untracked file) + pcall(vim.api.nvim_buf_call, buf, function() + vim.cmd("silent! checktime") + end) + end +end + +return M diff --git a/lua/differ/git/index.lua b/lua/differ/git/index.lua new file mode 100644 index 0000000..a6d58ed --- /dev/null +++ b/lua/differ/git/index.lua @@ -0,0 +1,124 @@ +-- moving content in and out of the index, and the paths one row's ops act on. the +-- whole-file keys and the diff view's hunk staging both write through here + +local exec = require("differ.git.exec") +local git, git_ok, notify = exec.git, exec.git_ok, exec.notify + +local M = {} + +---@param root string +---@param path string +---@return boolean ok +function M.stage(root, path) + return git_ok({ "add", "--", path }, root, "stage") +end + +---@param root string +---@param path string +---@return boolean ok +function M.unstage(root, path) + return git_ok({ "reset", "-q", "HEAD", "--", path }, root, "unstage") +end + +---@param root string +---@return boolean ok +function M.stage_all(root) + return git_ok({ "add", "-A" }, root, "stage all") +end + +---@param root string +---@return boolean ok +function M.unstage_all(root) + return git_ok({ "reset", "-q", "HEAD" }, root, "unstage all") +end + +-- point `path`'s index entry at `text`, keeping its mode: a blob write and an entry +-- move, so there's no header to compute and nothing that can half-apply. `text` must +-- already be in index domain (M.read runs the worktree side through the clean filter) +---@param root string +---@param path string +---@param text string +---@return boolean ok +function M.write_index(root, path, text) + local listed = git({ "ls-files", "-s", "--", path }, root) + local mode = listed and listed:match("^(%d+)%s") + if not mode then + notify(("%s has no index entry to update"):format(path), vim.log.levels.ERROR) + return false + end + local hashed = vim.system({ "git", "hash-object", "-w", "--stdin" }, { + cwd = root, + stdin = text, + }):wait() + if hashed.code ~= 0 or not hashed.stdout then + notify(("staging %s failed: %s"):format(path, hashed.stderr or ""), vim.log.levels.ERROR) + return false + end + local spec = ("%s,%s,%s"):format(mode, exec.chomp(hashed.stdout), path) + return git_ok({ "update-index", "--cacheinfo", spec }, root, "staging " .. path) +end + +-- the paths one entry's staging ops act on: a rename owns both ends of the move, a copy +-- only its new path +---@param entry differ.FileEntry +---@return string[] +function M.entry_paths(entry) + if entry.status == "R" and entry.previous_path then + return { entry.path, entry.previous_path } + end + return { entry.path } +end + +-- move every path an entry owns into or out of the index. the panel's file keys and the +-- diff view's whole-file staging share it +---@param root string +---@param entry differ.FileEntry +---@param staged boolean +---@return boolean ok +function M.set_staged(root, entry, staged) + local ok = true + for _, p in ipairs(M.entry_paths(entry)) do + if staged then + ok = M.stage(root, p) and ok + else + ok = M.unstage(root, p) and ok + end + end + return ok +end + +-- the path an entry's index entry sits at: a rename only the worktree has made (` R`) +-- leaves it at the old path, the new one holding an empty intent-to-add placeholder +---@param entry differ.FileEntry +---@return string +function M.index_path(entry) + if entry.y == "R" and entry.previous_path then + return entry.previous_path + end + return entry.path +end + +-- bring a deleted file back: from HEAD for a staged deletion, which only HEAD still +-- has, else from the index, which holds any edits staged before the delete. a staged +-- deletion whose file is on disk would lose that copy, so it refuses +---@param root string +---@param entry differ.FileEntry +---@return boolean +function M.restore_deleted(root, entry) + if entry.kept then + local msg = "%s is on disk untracked; u tracks it again" + notify(msg:format(entry.path), vim.log.levels.WARN) + return false + end + local cmd = entry.x == "D" and { "checkout", "HEAD", "--", entry.path } + or { "checkout", "--", entry.path } + local _, err = git(cmd, root) + if err then + notify(("restore failed: %s"):format(err), vim.log.levels.ERROR) + return false + end + exec.reload_buffer(root, entry.path) + return true +end + +return M diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 3ccbdf6..324831f 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -5,7 +5,12 @@ -- pure parsing/grammar lives in git/rev.lua; this module only does I/O + wiring local rev = require("differ.git.rev") -local patch = require("differ.git.patch") +local exec = require("differ.git.exec") +local index_ops = require("differ.git.index") +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 +local set_staged, index_path = index_ops.set_staged, index_ops.index_path local log = require("differ.git.log") local watch = require("differ.git.watch") local text_util = require("differ.util.text") @@ -28,12 +33,6 @@ local EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" -- git's mode for a submodule entry (a commit pointer) local GITLINK = "160000" ----@param msg string ----@param level integer|nil -local function notify(msg, level) - vim.notify("differ: " .. msg, level or vim.log.levels.INFO) -end - -- how long a network git call may block the editor; `wait()` has no budget of its own M.fetch_timeout_ms = 20000 @@ -68,83 +67,6 @@ local function fetch_opts() return { timeout = M.fetch_timeout_ms, env = { GIT_TERMINAL_PROMPT = "0" } } end --- spawn git and wait. vim.system raises when the process can't start (no git on PATH, --- a cwd that's gone), so the raise comes back as `err`. a nil res with no err is --- wait() answering nothing at all; only the caller knows if a budget explains it ----@param cmd string[] ----@param opts table ----@return { code: integer, stdout: string|nil, stderr: string|nil }|nil res, string|nil err -local function run(cmd, opts) - local ok, obj = pcall(vim.system, cmd, opts) - if not ok then - return nil, tostring(obj) - end - return obj:wait() -end - --- every path differ hands git is a real file name, never a glob -local GIT = { "git", "--literal-pathspecs" } - --- run git in `cwd`. returns stdout on success, or nil + stderr on failure. --- `text = true` normalises `\r\n` to `\n` in stdout, which is right for plumbing --- output (status/numstat/rev-parse/name-status/...) but would corrupt file --- content read via `git show`; content reads use git_raw instead ----@param args string[] ----@param cwd string ----@param opts? { timeout?: integer, env?: table } ----@return string|nil stdout, string|nil stderr -local function git(args, cwd, opts) - local cmd = vim.list_extend({}, GIT) - vim.list_extend(cmd, args) - opts = opts or {} - local res, err = run(cmd, { - cwd = cwd, - text = true, - timeout = opts.timeout, - env = opts.env, - }) - if err then - return nil, err -- never started - end - -- a killed process exits 124 with an empty stderr, or answers nothing at all when - -- a child outlives it holding the pipes (a fetch's transport helper does). both are - -- the budget, and neither says so itself - if opts.timeout and (not res or res.code == 124) then - return nil, ("git %s timed out after %ds"):format(args[1], opts.timeout / 1000) - end - if not res then - return nil, "git exited without a result" - end - if res.code ~= 0 then - return nil, res.stderr - end - return res.stdout -end - --- like `git`, but without `text = true`: stdout comes back byte-true (CRLF kept --- intact) instead of newline-normalised. use for content-bearing reads (`git --- show` on the index/a rev/a conflict stage), never for plumbing output ----@param args string[] ----@param cwd string ----@return string|nil stdout, string|nil stderr -local function git_raw(args, cwd) - local cmd = vim.list_extend({}, GIT) - vim.list_extend(cmd, args) - local res, err = run(cmd, { cwd = cwd }) - if not res then - return nil, err - end - if res.code ~= 0 then - return nil, res.stderr - end - return res.stdout -end - --- strip trailing whitespace from a git output line (e.g. rev-parse, show, log) -local function chomp(s) - return (s:gsub("%s+$", "")) -end - -- repo root containing `path` (a file or directory), or nil if not in a repo ---@param path string ---@return string|nil @@ -692,17 +614,6 @@ local function union_pairs(root, path, head, index, work) pair("INDEX", "WORKTREE", index, work) end --- the path an entry's index entry sits at: a rename only the worktree has made (` R`) --- leaves it at the old path, the new one holding an empty intent-to-add placeholder ----@param entry differ.FileEntry ----@return string -local function index_path(entry) - if entry.y == "R" and entry.previous_path then - return entry.previous_path - end - return entry.path -end - -- a row's three diffs, read fresh, or nil when the index can't be read. HEAD is read at -- a rename's old path ---@param root string @@ -864,105 +775,9 @@ end -- file-level staging ops driven from the panel (slice C); each is whole-file -- and operates on the repo root. hunk-level staging stays in the diff view --- run git and report a failure where the user can see it, returning whether it landed. --- these wrappers own their own message so callers only ever branch on the boolean: a --- swallowed failure would leave the panel and the staged marks describing a state git --- never reached ----@param args string[] ----@param cwd string ----@param what string -- names the operation in the message ----@return boolean ok -local function git_ok(args, cwd, what) - local out, err = git(args, cwd) -- nil out is exactly a non-zero exit; stderr can be empty - if not out then - notify(("%s failed: %s"):format(what, err or ""), vim.log.levels.ERROR) - return false - end - return true -end - --- point `path`'s index entry at `text`, keeping its mode. the caller has already --- worked out the exact content the index should hold, so this writes a blob and moves --- the entry rather than applying a patch: no header to compute, no offset to carry, and --- nothing that can half-apply. `text` must already be in index domain (M.read runs the --- worktree side through the clean filter), so the blob is stored verbatim ----@param root string ----@param path string ----@param text string ----@return boolean ok -local function write_index(root, path, text) - local listed = git({ "ls-files", "-s", "--", path }, root) - local mode = listed and listed:match("^(%d+)%s") - if not mode then - notify(("%s has no index entry to update"):format(path), vim.log.levels.ERROR) - return false - end - local hashed = vim.system({ "git", "hash-object", "-w", "--stdin" }, { - cwd = root, - stdin = text, - }):wait() - if hashed.code ~= 0 or not hashed.stdout then - notify(("staging %s failed: %s"):format(path, hashed.stderr or ""), vim.log.levels.ERROR) - return false - end - local spec = ("%s,%s,%s"):format(mode, chomp(hashed.stdout), path) - return git_ok({ "update-index", "--cacheinfo", spec }, root, "staging " .. path) -end - ----@param root string ----@param path string ----@return boolean ok -function M.stage(root, path) - return git_ok({ "add", "--", path }, root, "stage") -end - ----@param root string ----@param path string ----@return boolean ok -function M.unstage(root, path) - return git_ok({ "reset", "-q", "HEAD", "--", path }, root, "unstage") -end - ----@param root string ----@return boolean ok -function M.stage_all(root) - return git_ok({ "add", "-A" }, root, "stage all") -end - ----@param root string ----@return boolean ok -function M.unstage_all(root) - return git_ok({ "reset", "-q", "HEAD" }, root, "unstage all") -end - --- the paths one entry's staging ops act on: a rename owns both ends of the move, a copy --- only its new path ----@param entry differ.FileEntry ----@return string[] -local function entry_paths(entry) - if entry.status == "R" and entry.previous_path then - return { entry.path, entry.previous_path } - end - return { entry.path } -end - --- move every path an entry owns into or out of the index. the panel's file keys and the --- diff view's whole-file staging share it ----@param root string ----@param entry differ.FileEntry ----@param staged boolean ----@return boolean ok -local function set_staged(root, entry, staged) - local ok = true - for _, p in ipairs(entry_paths(entry)) do - if staged then - ok = M.stage(root, p) and ok - else - ok = M.unstage(root, p) and ok - end - end - return ok -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.stage_all, M.unstage_all = index_ops.stage_all, index_ops.unstage_all -- apply a single-hunk patch, atomically. `--unidiff-zero` because the patch carries -- no context (built straight from the hunk model); `--reverse` undoes rather than @@ -997,22 +812,6 @@ function M.apply_patch(root, text, reverse, target, check) return true end --- an open buffer on a file differ just rewrote keeps showing the old content until --- something checks: a window switch doesn't, so a revert or discard would sit next to --- a stale window. checktime reloads it, and leaves a buffer with unsaved edits alone --- (nvim warns rather than clobbering), so this is safe to fire unconditionally ----@param root string ----@param relpath string -local function reload_buffer(root, relpath) - local buf = require("differ.util.buf").find(root .. "/" .. relpath) - if buf and vim.api.nvim_buf_is_loaded(buf) then - -- silent!: the file may be gone entirely (a discarded untracked file) - pcall(vim.api.nvim_buf_call, buf, function() - vim.cmd("silent! checktime") - end) - end -end - -- git_ok's counterpart for the one discard path that isn't a git call ---@param abs string ---@return boolean ok @@ -1437,572 +1236,28 @@ function M.panel(opts) record_state() end end - -- bring a deleted file back. a staged deletion is recorded in the index, so only - -- HEAD still has the content; an unstaged one is still in the index, so restoring - -- from HEAD instead would silently drop edits staged before the delete. a staged - -- deletion whose file is still on disk would have that copy overwritten, so it refuses - ---@param entry differ.FileEntry - ---@return boolean - local function restore_deleted(entry) - if entry.kept then - local msg = "%s is on disk untracked; u tracks it again" - notify(msg:format(entry.path), vim.log.levels.WARN) - return false - end - local cmd = entry.x == "D" and { "checkout", "HEAD", "--", entry.path } - or { "checkout", "--", entry.path } - local _, err = git(cmd, root) - if err then - notify(("restore failed: %s"):format(err), vim.log.levels.ERROR) - return false - end - reload_buffer(root, entry.path) - return true - end - - -- write `text` as `entry`'s staged content. an add left with nothing staged leaves - -- the index, and a rename only the worktree has made is staged along with its - -- content; both change the row's status, so the view re-sources onto it - ---@param entry differ.FileEntry - ---@param text string - ---@return boolean ok - local function put_index(entry, text) - if entry.x == "A" and text == "" then - if not M.unstage(root, entry.path) then - return false - end - vim.schedule(function() - retarget_view(false) - end) - return true - end - if not write_index(root, entry.path, text) then - return false - end - if entry.y == "R" and entry.previous_path then - local drop = { "update-index", "--force-remove", "--", entry.previous_path } - if not git_ok(drop, root, "staging " .. entry.path) then - return false - end - vim.schedule(function() - retarget_view(false) - end) - end - return true - end - - -- whether the index holds a mode change the HEAD↔worktree diff can't show: staged, - -- then put back in the worktree - ---@param entry differ.FileEntry - ---@return boolean - local function mode_hidden(entry) - local cached = raw_line(root, { "--cached" }, entry) - local unstaged = raw_line(root, {}, entry) - if not (cached and unstaged) then - return false - end - local head_mode, index_mode = rev.parse_raw_modes(cached) - local _, work_mode = rev.parse_raw_modes(unstaged) - return index_mode ~= head_mode and index_mode ~= work_mode - end - - -- an op built on a failed index read would write the file's index from nothing - ---@param entry differ.FileEntry - ---@return false - local function index_unreadable(entry) - notify(("couldn't read %s from the index"):format(entry.path), vim.log.levels.WARN) - return false - end - - -- X on a hunk: the file gives those lines back, and `stage_index` moves the index's - -- half of the change. the file is checked first, so a file that changed those lines - -- refuses before the index moves, and a false from `stage_index` stops it there too. - -- it goes through a patch, not a write: the model's new side was read through the - -- clean filter, so writing it back would push that conversion to disk - ---@param entry differ.FileEntry - ---@param model differ.DiffModel - ---@param hunk differ.Hunk - ---@param offset integer -- the model's new-side lines to the file's, see patch.hunk - ---@param stage_index fun(): boolean - ---@return boolean - local function revert_worktree(entry, model, hunk, offset, stage_index) - local p = patch.hunk(model.path, hunk, model.old_text, model.new_text, offset, "new") - if not M.apply_patch(root, p, true, "worktree", true) then - notify("the file has changed these lines: nothing reverted", vim.log.levels.WARN) - return false - end - if not stage_index() then - return false - end - local ok, err = M.apply_patch(root, p, true, "worktree") - reload_buffer(root, entry.path) - if not ok then - notify(("hunk revert failed: %s"):format(err or ""), vim.log.levels.ERROR) - return false - end - return true - end - - -- staging for a row shown as HEAD↔worktree. both directions work out the content - -- the index should hold and write it whole rather than patching: staging a hunk - -- means the index takes the worktree's version of the lines it covers, and - -- unstaging means the index keeps every staged change except those. each op reads - -- the pairs once and re-marks from the text it wrote - ---@param entry differ.FileEntry - ---@return differ.view.Staging - local function union_staging(entry) - local marks = require("differ.model.marks") - local stage = require("differ.model.stage") - local mode_change = false - if entry.x ~= " " and entry.y ~= " " and mode_hidden(entry) then - mode_change = true -- the index holds a mode change, which no hunk shows - end - ---@type differ.view.Staging - local staging = { marks = { old = {}, new = {} }, refresh = refresh_panel } - - -- the view keeps staging.marks, so a re-mark writes through it. marks come from - -- the index's own lines at each hunk, and from the two pairs only when the index - -- changes a line between hunks - ---@param union differ.DiffModel|nil -- nil keeps the marks there are - ---@param cached differ.DiffModel|nil - ---@param unstaged differ.DiffModel|nil - local function remark(union, cached, unstaged) - if not (union and cached and unstaged) then - return - end - staging.hidden_in = nil - local blocks, ended = stage.index_blocks(union, cached.new_text) - local held, hidden_in = nil, {} ---@type differ.model.Marks|nil, integer[] - if blocks then - held, hidden_in = marks.of_blocks(ended.hunks, blocks) - end - if held then - staging.marks.old, staging.marks.new = held.old, held.new - staging.hidden = nil - if mode_change then - staging.hidden = true - elseif #hidden_in > 0 then - staging.hidden = true - staging.hidden_in = hidden_in - end - return - end - local fresh = marks.classify(union.hunks, cached.hunks, unstaged.hunks) - staging.marks.old, staging.marks.new = fresh.old, fresh.new - local complete = marks.complete(union.hunks, cached.hunks, unstaged.hunks) - if mode_change then - staging.hidden = true - elseif complete then - staging.hidden = nil - else - staging.hidden = true - local head = require("differ.util.text").to_lines(union.old_text) - staging.hidden_in = marks.hidden_in(head, union.hunks, cached.hunks, fresh) - end - end - - -- the text the index takes when `hunk` is staged (`take`) or unstaged, or nil - -- when the pair hunk carrying this one's change covers another union hunk too, - -- which the other view's key takes whole - ---@param union differ.DiffModel - ---@param cached differ.DiffModel - ---@param unstaged differ.DiffModel - ---@param hunk differ.Hunk - ---@param take boolean - ---@return string|nil - local function next_index(union, cached, unstaged, hunk, take) - local text, reaches = stage.next_index(union, cached, unstaged, hunk, take) - if not reaches then - return text - end - local msg = "this hunk's staged change also covers hunk %d: " - .. "u on the ! hunk in dw, or in gs, unstages it whole" - if take then - msg = "this hunk's unstaged change also covers hunk %d: s in dw stages it whole" - end - notify(msg:format(reaches), vim.log.levels.WARN) - return nil - end - -- whether the diff still shows the file as it is. one drawn before the file or - -- HEAD moved would place an op by stale line numbers, so it refuses and re-reads - ---@param model differ.DiffModel -- the view's - ---@param union differ.DiffModel -- read fresh - ---@return boolean - local function drawn_current(model, union) - if model.old_text == union.old_text and model.new_text == union.new_text then - return true - end - notify("the file changed since the diff was drawn: re-reading", vim.log.levels.WARN) - vim.schedule(function() - refresh_panel() - retarget_view(false) - end) - return false - end - - remark(M.union_models(root, entry)) - staging.apply = function(model, hunk, reverse) - if not hunk then - return false - end - local union, cached, unstaged = M.union_models(root, entry) - if not (union and cached and unstaged) then - return index_unreadable(entry) - end - if not drawn_current(model, union) then - return false - end - local text = next_index(union, cached, unstaged, hunk, not reverse) - if not text or not put_index(entry, text) then - return false - end - remark(union_pairs(root, entry.path, union.old_text, text, union.new_text)) - return true - end - -- X throws the hunk away on both sides at once: the index gives up whatever of - -- it it holds and the worktree gives up the rest - staging.revert = function(model, idx) - local union, cached, unstaged = M.union_models(root, entry) - if not (union and cached and unstaged) then - return index_unreadable(entry) - end - if not drawn_current(model, union) then - return false - end - local hunk = model.hunks[idx] - local text = next_index(union, cached, unstaged, hunk, false) - if not text then - return false - end - local ok = revert_worktree(entry, model, hunk, 0, function() - return text == cached.new_text or put_index(entry, text) - end) - if not ok then - remark(M.union_models(root, entry)) - return false - end - local work = require("differ.model.diff").revert_hunk(model, idx).new_text - remark(union_pairs(root, entry.path, union.old_text, text, work)) - return true - end - -- S and U: the index takes the worktree's or HEAD's text whole, staged content no - -- hunk shows included. the entry's mode is left as it is - staging.set_all = function(model, staged) - local union, cached = M.union_models(root, entry) - if not (union and cached) then - return index_unreadable(entry) - end - if not drawn_current(model, union) then - return false - end - local text = union.old_text - if staged then - text = union.new_text - end - if text == cached.new_text or not put_index(entry, text) then - return false - end - remark(union_pairs(root, entry.path, union.old_text, text, union.new_text)) - return true - end - return staging - end - - -- X on a whole-file row, and what it does to the file. `letter` owns the change the - -- row shows: its status for a worktree row, the index's (x) in the commit preview - ---@param entry differ.FileEntry - ---@param letter string - ---@return fun(): boolean revert, string label - local function whole_file_revert(entry, letter) - if letter == "D" then - return function() - return restore_deleted(entry) - end, - "restores the file" - end - local discard = function() - return M.discard(root, entry) - end - if letter == "A" or letter == "?" then - return discard, "deletes the file" - end - return discard, "puts it back as HEAD has it" - end - - -- a whole-file row's staged state. a conflict holds both sides at once, which the - -- view has no third state for - ---@param entry differ.FileEntry - ---@return differ.model.HunkState - local function row_state(entry) - if entry.review == "staged" then - return "staged" - end - if entry.review == "partial" or entry.review == "conflict" then - return "partial" - end - return "unstaged" - end - - ---@param entry differ.FileEntry - ---@param diff differ.DiffModel -- the entry's built model; only its hunk count is read - ---@return differ.view.Staging|nil - local function stage_for(entry, diff) - if not stageable then - return nil - end - -- git records no rename, only the old path gone from the index and the new one - -- present, so a hunk stages an ordinary blob and the move rides along untouched. - -- the move is whole-file and belongs to the panel row's keys - local content = entry.status == "M" or entry.status == "R" or entry.status == "C" - local added = entry.status == "A" and entry.x == "A" - if (content or added) and entry.x ~= "D" and #diff.hunks > 0 then - local staging = union_staging(entry) - if added then - -- throwing away an add is deleting the file, not reverting a hunk of it - staging.revert, staging.revert_label = whole_file_revert(entry, "A") - end - return staging - end - -- whole-file from here: no lines to stage (a mode change, a submodule pointer, a - -- binary file, a change git normalises away, a bare rename or copy), or a file - -- added, untracked or deleted as one unit - ---@type differ.view.Staging - local staging = { - initial = row_state(entry), - whole_file = true, - apply = function(_, _, reverse) - return set_staged(root, entry, not reverse) - end, - refresh = refresh_panel, - } - if content then - return staging - end - -- an add's discard drops the staged entry before removing the file - if entry.status == "?" or entry.status == "A" or entry.status == "D" then - staging.revert, staging.revert_label = whole_file_revert(entry, entry.status) - return staging - end - return nil - end - - -- a row with a staged change and more on top of it: the one kind with a local view - ---@param entry differ.FileEntry - ---@return boolean - local function partly_staged(entry) - if entry.status == "U" or entry.x == "?" or entry.y == "D" then - return false - end - return entry.x ~= " " and entry.y ~= " " - end - - -- staging frozen at the index the view opened on: s and u rewrite the index as the - -- model's old side plus the hunks marked staged, so a marked hunk stays on screen - -- and the opposite key puts it back. an index written outside differ since then - -- would be overwritten by that rebuild, so s and u refuse and `reopen` re-reads - ---@param entry differ.FileEntry - ---@param model differ.DiffModel -- read once, with at least one hunk - ---@param staged boolean -- every hunk's opening state - ---@param reopen fun() - ---@return differ.view.Staging - local function frozen_staging(entry, model, staged, reopen) - local splice = require("differ.model.apply").splice - local state = require("differ.model.marks").state - -- the commit preview opens staged with the index as its new side; the local view - -- opens unstaged with it as its old side - local held = staged and model.new_text or model.old_text - local at_index = index_path(entry) - local marks = { old = {}, new = {} } - ---@param h differ.Hunk - ---@param on boolean - local function mark(h, on) - for l = h.old_start, h.old_start + h.old_count - 1 do - marks.old[l] = on - end - for l = h.new_start, h.new_start + h.new_count - 1 do - marks.new[l] = on - end - end - for _, h in ipairs(model.hunks) do - mark(h, staged) - end - return { - marks = marks, - refresh = refresh_panel, - apply = function(_, hunk, reverse) - if not hunk then - return false - end - if (M.read(INDEX, root, at_index) or "") ~= held then - notify("the index changed outside differ: re-reading", vim.log.levels.WARN) - vim.schedule(reopen) - return false - end - mark(hunk, not reverse) - local applied = {} - for i, h in ipairs(model.hunks) do - applied[i] = state(marks, h) == "staged" - end - local text = splice(model, applied) - if put_index(entry, text) then - held, at_index = text, entry.path -- put_index stages a rename at the new path - return true - end - mark(hunk, reverse) - return false - end, - } - end - - -- whole-file staging frozen at the index the view opened on: u resets the row's - -- paths to HEAD, s puts back the entries the index held then - ---@param entry differ.FileEntry - ---@return differ.view.Staging - local function snapshot_staging(entry) - local paths = entry_paths(entry) - local held = {} ---@type table -- path -> update-index cacheinfo - local listed = git(vim.list_extend({ "ls-files", "-s", "-z", "--" }, paths), root) or "" - for mode, sha, path in listed:gmatch("(%d+) (%x+) %d+\t([^%z]+)") do - held[path] = ("%s,%s,%s"):format(mode, sha, path) - end - return { - initial = "staged", - whole_file = true, - refresh = refresh_panel, - apply = function(_, _, reverse) - if reverse then - return set_staged(root, entry, false) - end - local ok = true - for _, path in ipairs(paths) do - local cmd = { "update-index", "--force-remove", "--", path } - if held[path] then - cmd = { "update-index", "--add", "--cacheinfo", held[path] } - end - ok = git_ok(cmd, root, "staging " .. path) and ok - end - return ok - end, - } - end - - -- X in a frozen view (the commit preview, the local view): the hunk leaves the index - -- if marked staged, and the file takes its old lines back, then `reopen` re-reads the - -- view. a last hunk leaves nothing to reopen, and the view hands over to the panel - ---@param entry differ.FileEntry - ---@param model differ.DiffModel - ---@param staging differ.view.Staging - ---@param idx integer - ---@param offset integer -- the model's new-side lines to the file's, see patch.hunk - ---@param reopen fun() - ---@return boolean - local function revert_frozen(entry, model, staging, idx, offset, reopen) - local hunk = model.hunks[idx] - local ok = revert_worktree(entry, model, hunk, offset, function() - if require("differ.model.marks").state(staging.marks, hunk) ~= "staged" then - return true - end - if not staging.apply then - return false - end - return staging.apply(model, hunk, true) - end) - if not ok then - return false - end - if #model.hunks > 1 then - vim.schedule(function() - if view and view.staging == staging then - reopen() - end - end) - end - return true - end - - -- staging for a commit-preview row, HEAD↔index. an add or a deletion is one unit - -- whose index entry comes and goes, so it stages as a file - ---@param entry differ.FileEntry - ---@param model differ.DiffModel -- HEAD↔index - ---@return differ.view.Staging - local function preview_staging(entry, model) - local staging - if #model.hunks > 0 and entry.x ~= "A" and entry.x ~= "D" then - staging = frozen_staging(entry, model, true, function() - retarget_view(false) - end) - -- the model's new side is the index, so its lines move by whatever the - -- worktree has added or dropped above them since - staging.revert = function(m, idx) - local h = m.hunks[idx] - local _, _, unstaged = M.union_models(root, entry) - if not unstaged then - return index_unreadable(entry) - end - local at = h.new_count > 0 and h.new_start or h.new_start + 1 - local offset = require("differ.model.marks").shift(unstaged.hunks, at, "old") - return revert_frozen(entry, m, staging, idx, offset, function() - retarget_view(false) - end) - end - else - staging = snapshot_staging(entry) - staging.revert, staging.revert_label = whole_file_revert(entry, entry.x) - end - staging.badge = "STAGED" - staging.no_local = "the commit preview has no local view: gs goes back" - staging.leave = function() + -- the row-level staging ops. they read and write git, and call back here to reload + -- the list and re-source the view + local staging_ops = require("differ.git.staging").new({ + root = root, + stageable = stageable, + refresh = function() + refresh_panel() + end, + retarget = function(outside) + retarget_view(outside) + end, + preview_off = function() set_preview(false) - end - return staging - end + end, + current_view = function() + return view + end, + }) local show_local ---@type fun(entry: differ.FileEntry) - -- u on a `!` hunk in the local view: the index takes HEAD's lines under it, dropping - -- the staged change the worktree undid, then the view re-reads against that index. - -- the hunk's lines are the index's at open, moved by whatever s has staged since - ---@param entry differ.FileEntry - ---@param model differ.DiffModel -- index↔worktree, as the view opened on it - ---@param staging differ.view.Staging - ---@param idx integer - ---@return boolean - local function drop_hidden(entry, model, staging, idx) - local marks = require("differ.model.marks") - local stage = require("differ.model.stage") - local splice = require("differ.model.apply").splice - local applied, moved = {}, {} ---@type table, differ.Hunk[] - for i, h in ipairs(model.hunks) do - applied[i] = marks.state(staging.marks, h) == "staged" - if applied[i] then - moved[#moved + 1] = h - end - end - local _, cached = M.union_models(root, entry) - if not cached then - return index_unreadable(entry) - end - if cached.new_text ~= splice(model, applied) then - notify("the index changed outside differ: re-reading", vim.log.levels.WARN) - vim.schedule(function() - show_local(entry) - end) - return false - end - local hunk = model.hunks[idx] - local placed = vim.tbl_extend("force", hunk, { - old_start = hunk.old_start + marks.shift(moved, hunk.old_start, "old"), - }) - local keep = stage.select_hunks(cached.hunks, "new", placed, "old", false) - if not put_index(entry, splice(cached, keep)) then - return false - end - refresh_panel() - show_local(entry) - return true - end - -- swap the open view onto `entry`'s local view, index↔worktree. back goes through -- retarget_view, which re-reads the row, since staging here can move it to another -- section @@ -2021,7 +1276,7 @@ function M.panel(opts) local model = M.model({ old = INDEX, new = WORKTREE }, root, file, head_branch(root)) local staging ---@type differ.view.Staging if #model.hunks > 0 then - staging = frozen_staging(entry, model, false, function() + staging = staging_ops.frozen(entry, model, false, function() show_local(entry) end) local _, cached = M.union_models(root, entry) @@ -2030,10 +1285,12 @@ function M.panel(opts) require("differ.model.marks").restaged(model.hunks, cached.hunks) end staging.unstage_hidden = function(idx) - return drop_hidden(entry, model, staging, idx) + return staging_ops.drop_hidden(entry, model, staging, idx, function() + show_local(entry) + end) end staging.revert = function(m, idx) - return revert_frozen(entry, m, staging, idx, 0, function() + return staging_ops.revert_frozen(entry, m, staging, idx, 0, function() show_local(entry) end) end @@ -2077,11 +1334,11 @@ function M.panel(opts) end local staging ---@type differ.view.Staging|nil if preview then - staging = preview_staging(entry, model) + staging = staging_ops.preview(entry, model) else - staging = stage_for(entry, model) + staging = staging_ops.for_entry(entry, model) end - if staging and not preview and partly_staged(entry) then + if staging and not preview and staging_ops.partly_staged(entry) then staging.toggle_local = function() show_local(entry) end @@ -2545,4 +1802,8 @@ function M.close() notify("no differ view open") end +-- what git/staging.lua reads back: the pair models it re-marks from, a row's raw diff +-- line, and the index ref +M.raw_line, M.union_pairs, M.INDEX = raw_line, union_pairs, INDEX + return M diff --git a/lua/differ/git/staging.lua b/lua/differ/git/staging.lua new file mode 100644 index 0000000..481f783 --- /dev/null +++ b/lua/differ/git/staging.lua @@ -0,0 +1,576 @@ +-- staging for one panel session's rows: the diff view's s/u/S/U/X, the commit preview's +-- frozen keys and the local view's. every op works out the content the index should +-- hold and writes it whole. the session passes in its root and its callbacks + +local rev = require("differ.git.rev") +local exec = require("differ.git.exec") +local index_ops = require("differ.git.index") +local patch = require("differ.git.patch") +local notify, git, git_ok = exec.notify, exec.git, exec.git_ok +local reload_buffer = exec.reload_buffer +local write_index, entry_paths = index_ops.write_index, index_ops.entry_paths +local set_staged, index_path = index_ops.set_staged, index_ops.index_path + +local M = {} + +---@class differ.git.StagingCtx +---@field root string +---@field stageable boolean -- a worktree source: rev-pair rows don't stage +---@field refresh fun() -- reload the panel's list +---@field retarget fun(outside: boolean) -- re-source the view onto the row's state now +---@field preview_off fun() -- leave the commit preview +---@field current_view fun(): differ.View|nil + +---@param ctx differ.git.StagingCtx +---@return table +function M.new(ctx) + local gitmod = require("differ.git") + local root, stageable = ctx.root, ctx.stageable + local refresh_panel, retarget_view = ctx.refresh, ctx.retarget + + -- write `text` as `entry`'s staged content. an add left with nothing staged leaves + -- the index, and a worktree rename stages with its content; both move the row + ---@param entry differ.FileEntry + ---@param text string + ---@return boolean ok + local function put_index(entry, text) + if entry.x == "A" and text == "" then + if not gitmod.unstage(root, entry.path) then + return false + end + vim.schedule(function() + retarget_view(false) + end) + return true + end + if not write_index(root, entry.path, text) then + return false + end + if entry.y == "R" and entry.previous_path then + local drop = { "update-index", "--force-remove", "--", entry.previous_path } + if not git_ok(drop, root, "staging " .. entry.path) then + return false + end + vim.schedule(function() + retarget_view(false) + end) + end + return true + end + + -- whether the index holds a mode change the HEAD↔worktree diff can't show: staged, + -- then put back in the worktree + ---@param entry differ.FileEntry + ---@return boolean + local function mode_hidden(entry) + local cached = gitmod.raw_line(root, { "--cached" }, entry) + local unstaged = gitmod.raw_line(root, {}, entry) + if not (cached and unstaged) then + return false + end + local head_mode, index_mode = rev.parse_raw_modes(cached) + local _, work_mode = rev.parse_raw_modes(unstaged) + return index_mode ~= head_mode and index_mode ~= work_mode + end + + -- an op built on a failed index read would write the file's index from nothing + ---@param entry differ.FileEntry + ---@return false + local function index_unreadable(entry) + notify(("couldn't read %s from the index"):format(entry.path), vim.log.levels.WARN) + return false + end + + -- X on a hunk: the file gives those lines back, and `stage_index` moves the index's + -- half. the file is checked first, so one that changed those lines refuses before the + -- index moves. it patches rather than writes: the new side came through the clean + -- filter, and writing it back would push that conversion to disk + ---@param entry differ.FileEntry + ---@param model differ.DiffModel + ---@param hunk differ.Hunk + ---@param offset integer -- the model's new-side lines to the file's, see patch.hunk + ---@param stage_index fun(): boolean + ---@return boolean + local function revert_worktree(entry, model, hunk, offset, stage_index) + local p = patch.hunk(model.path, hunk, model.old_text, model.new_text, offset, "new") + if not gitmod.apply_patch(root, p, true, "worktree", true) then + notify("the file has changed these lines: nothing reverted", vim.log.levels.WARN) + return false + end + if not stage_index() then + return false + end + local ok, err = gitmod.apply_patch(root, p, true, "worktree") + reload_buffer(root, entry.path) + if not ok then + notify(("hunk revert failed: %s"):format(err or ""), vim.log.levels.ERROR) + return false + end + return true + end + + -- staging for a row shown as HEAD↔worktree. staging a hunk gives the index the + -- worktree's version of its lines, unstaging keeps every staged change except those; + -- each op reads the pairs once and re-marks from the text it wrote + ---@param entry differ.FileEntry + ---@return differ.view.Staging + local function union_staging(entry) + local marks = require("differ.model.marks") + local stage = require("differ.model.stage") + local mode_change = false + if entry.x ~= " " and entry.y ~= " " and mode_hidden(entry) then + mode_change = true -- the index holds a mode change, which no hunk shows + end + ---@type differ.view.Staging + local staging = { marks = { old = {}, new = {} }, refresh = refresh_panel } + + -- the view keeps staging.marks, so a re-mark writes through it. marks come from + -- the index's own lines at each hunk, and from the two pairs only when the index + -- changes a line between hunks + ---@param union differ.DiffModel|nil -- nil keeps the marks there are + ---@param cached differ.DiffModel|nil + ---@param unstaged differ.DiffModel|nil + local function remark(union, cached, unstaged) + if not (union and cached and unstaged) then + return + end + staging.hidden_in = nil + local blocks, ended = stage.index_blocks(union, cached.new_text) + local held, hidden_in = nil, {} ---@type differ.model.Marks|nil, integer[] + if blocks then + held, hidden_in = marks.of_blocks(ended.hunks, blocks) + end + if held then + staging.marks.old, staging.marks.new = held.old, held.new + staging.hidden = nil + if mode_change then + staging.hidden = true + elseif #hidden_in > 0 then + staging.hidden = true + staging.hidden_in = hidden_in + end + return + end + local fresh = marks.classify(union.hunks, cached.hunks, unstaged.hunks) + staging.marks.old, staging.marks.new = fresh.old, fresh.new + local complete = marks.complete(union.hunks, cached.hunks, unstaged.hunks) + if mode_change then + staging.hidden = true + elseif complete then + staging.hidden = nil + else + staging.hidden = true + local head = require("differ.util.text").to_lines(union.old_text) + staging.hidden_in = marks.hidden_in(head, union.hunks, cached.hunks, fresh) + end + end + + -- the text the index takes when `hunk` is staged (`take`) or unstaged, or nil + -- when the pair hunk carrying its change covers another union hunk too + ---@param union differ.DiffModel + ---@param cached differ.DiffModel + ---@param unstaged differ.DiffModel + ---@param hunk differ.Hunk + ---@param take boolean + ---@return string|nil + local function next_index(union, cached, unstaged, hunk, take) + local text, reaches = stage.next_index(union, cached, unstaged, hunk, take) + if not reaches then + return text + end + local msg = "this hunk's staged change also covers hunk %d: " + .. "u on the ! hunk in dw, or in gs, unstages it whole" + if take then + msg = "this hunk's unstaged change also covers hunk %d: s in dw stages it whole" + end + notify(msg:format(reaches), vim.log.levels.WARN) + return nil + end + + -- whether the diff still shows the file as it is. one drawn before the file or + -- HEAD moved would place an op by stale line numbers, so it refuses and re-reads + ---@param model differ.DiffModel -- the view's + ---@param union differ.DiffModel -- read fresh + ---@return boolean + local function drawn_current(model, union) + if model.old_text == union.old_text and model.new_text == union.new_text then + return true + end + notify("the file changed since the diff was drawn: re-reading", vim.log.levels.WARN) + vim.schedule(function() + refresh_panel() + retarget_view(false) + end) + return false + end + + remark(gitmod.union_models(root, entry)) + staging.apply = function(model, hunk, reverse) + if not hunk then + return false + end + local union, cached, unstaged = gitmod.union_models(root, entry) + if not (union and cached and unstaged) then + return index_unreadable(entry) + end + if not drawn_current(model, union) then + return false + end + local text = next_index(union, cached, unstaged, hunk, not reverse) + if not text or not put_index(entry, text) then + return false + end + remark(gitmod.union_pairs(root, entry.path, union.old_text, text, union.new_text)) + return true + end + -- X throws the hunk away on both sides at once: the index gives up whatever of + -- it it holds and the worktree gives up the rest + staging.revert = function(model, idx) + local union, cached, unstaged = gitmod.union_models(root, entry) + if not (union and cached and unstaged) then + return index_unreadable(entry) + end + if not drawn_current(model, union) then + return false + end + local hunk = model.hunks[idx] + local text = next_index(union, cached, unstaged, hunk, false) + if not text then + return false + end + local ok = revert_worktree(entry, model, hunk, 0, function() + return text == cached.new_text or put_index(entry, text) + end) + if not ok then + remark(gitmod.union_models(root, entry)) + return false + end + local work = require("differ.model.diff").revert_hunk(model, idx).new_text + remark(gitmod.union_pairs(root, entry.path, union.old_text, text, work)) + return true + end + -- S and U: the index takes the worktree's or HEAD's text whole, staged content no + -- hunk shows included. the entry's mode is left as it is + staging.set_all = function(model, staged) + local union, cached = gitmod.union_models(root, entry) + if not (union and cached) then + return index_unreadable(entry) + end + if not drawn_current(model, union) then + return false + end + local text = union.old_text + if staged then + text = union.new_text + end + if text == cached.new_text or not put_index(entry, text) then + return false + end + remark(gitmod.union_pairs(root, entry.path, union.old_text, text, union.new_text)) + return true + end + return staging + end + + -- X on a whole-file row, and what it does to the file. `letter` owns the change the + -- row shows: its status for a worktree row, the index's (x) in the commit preview + ---@param entry differ.FileEntry + ---@param letter string + ---@return fun(): boolean revert, string label + local function whole_file_revert(entry, letter) + if letter == "D" then + return function() + return index_ops.restore_deleted(root, entry) + end, + "restores the file" + end + local discard = function() + return gitmod.discard(root, entry) + end + if letter == "A" or letter == "?" then + return discard, "deletes the file" + end + return discard, "puts it back as HEAD has it" + end + + -- a whole-file row's staged state. a conflict holds both sides at once, which the + -- view has no third state for + ---@param entry differ.FileEntry + ---@return differ.model.HunkState + local function row_state(entry) + if entry.review == "staged" then + return "staged" + end + if entry.review == "partial" or entry.review == "conflict" then + return "partial" + end + return "unstaged" + end + + ---@param entry differ.FileEntry + ---@param diff differ.DiffModel -- the entry's built model; only its hunk count is read + ---@return differ.view.Staging|nil + local function stage_for(entry, diff) + if not stageable then + return nil + end + -- git records no rename, only the old path gone from the index and the new one + -- present, so a hunk stages an ordinary blob and the move rides along untouched. + -- the move is whole-file and belongs to the panel row's keys + local content = entry.status == "M" or entry.status == "R" or entry.status == "C" + local added = entry.status == "A" and entry.x == "A" + if (content or added) and entry.x ~= "D" and #diff.hunks > 0 then + local staging = union_staging(entry) + if added then + -- throwing away an add is deleting the file, not reverting a hunk of it + staging.revert, staging.revert_label = whole_file_revert(entry, "A") + end + return staging + end + -- whole-file from here: nothing to stage by line (a mode change, a submodule, a + -- binary file, a bare rename), or a file added, untracked or deleted as one unit + ---@type differ.view.Staging + local staging = { + initial = row_state(entry), + whole_file = true, + apply = function(_, _, reverse) + return set_staged(root, entry, not reverse) + end, + refresh = refresh_panel, + } + if content then + return staging + end + -- an add's discard drops the staged entry before removing the file + if entry.status == "?" or entry.status == "A" or entry.status == "D" then + staging.revert, staging.revert_label = whole_file_revert(entry, entry.status) + return staging + end + return nil + end + + -- a row with a staged change and more on top of it: the one kind with a local view + ---@param entry differ.FileEntry + ---@return boolean + local function partly_staged(entry) + if entry.status == "U" or entry.x == "?" or entry.y == "D" then + return false + end + return entry.x ~= " " and entry.y ~= " " + end + + -- staging frozen at the index the view opened on: s and u rebuild it from the model's + -- old side plus the hunks marked staged, so a marked hunk stays on screen. an index + -- written outside differ since then would be lost to that rebuild, so they refuse + ---@param entry differ.FileEntry + ---@param model differ.DiffModel -- read once, with at least one hunk + ---@param staged boolean -- every hunk's opening state + ---@param reopen fun() + ---@return differ.view.Staging + local function frozen_staging(entry, model, staged, reopen) + local splice = require("differ.model.apply").splice + local state = require("differ.model.marks").state + -- the commit preview opens staged with the index as its new side; the local view + -- opens unstaged with it as its old side + local held = staged and model.new_text or model.old_text + local at_index = index_path(entry) + local marks = { old = {}, new = {} } + ---@param h differ.Hunk + ---@param on boolean + local function mark(h, on) + for l = h.old_start, h.old_start + h.old_count - 1 do + marks.old[l] = on + end + for l = h.new_start, h.new_start + h.new_count - 1 do + marks.new[l] = on + end + end + for _, h in ipairs(model.hunks) do + mark(h, staged) + end + return { + marks = marks, + refresh = refresh_panel, + apply = function(_, hunk, reverse) + if not hunk then + return false + end + if (gitmod.read(gitmod.INDEX, root, at_index) or "") ~= held then + notify("the index changed outside differ: re-reading", vim.log.levels.WARN) + vim.schedule(reopen) + return false + end + mark(hunk, not reverse) + local applied = {} + for i, h in ipairs(model.hunks) do + applied[i] = state(marks, h) == "staged" + end + local text = splice(model, applied) + if put_index(entry, text) then + held, at_index = text, entry.path -- put_index stages a rename at the new path + return true + end + mark(hunk, reverse) + return false + end, + } + end + + -- whole-file staging frozen at the index the view opened on: u resets the row's + -- paths to HEAD, s puts back the entries the index held then + ---@param entry differ.FileEntry + ---@return differ.view.Staging + local function snapshot_staging(entry) + local paths = entry_paths(entry) + local held = {} ---@type table -- path -> update-index cacheinfo + local listed = git(vim.list_extend({ "ls-files", "-s", "-z", "--" }, paths), root) or "" + for mode, sha, path in listed:gmatch("(%d+) (%x+) %d+\t([^%z]+)") do + held[path] = ("%s,%s,%s"):format(mode, sha, path) + end + return { + initial = "staged", + whole_file = true, + refresh = refresh_panel, + apply = function(_, _, reverse) + if reverse then + return set_staged(root, entry, false) + end + local ok = true + for _, path in ipairs(paths) do + local cmd = { "update-index", "--force-remove", "--", path } + if held[path] then + cmd = { "update-index", "--add", "--cacheinfo", held[path] } + end + ok = git_ok(cmd, root, "staging " .. path) and ok + end + return ok + end, + } + end + + -- X in a frozen view (the commit preview, the local view): the hunk leaves the index + -- if marked staged, and the file takes its old lines back, then `reopen` re-reads the + -- view. a last hunk leaves nothing to reopen, and the view hands over to the panel + ---@param entry differ.FileEntry + ---@param model differ.DiffModel + ---@param staging differ.view.Staging + ---@param idx integer + ---@param offset integer -- the model's new-side lines to the file's, see patch.hunk + ---@param reopen fun() + ---@return boolean + local function revert_frozen(entry, model, staging, idx, offset, reopen) + local hunk = model.hunks[idx] + local ok = revert_worktree(entry, model, hunk, offset, function() + if require("differ.model.marks").state(staging.marks, hunk) ~= "staged" then + return true + end + if not staging.apply then + return false + end + return staging.apply(model, hunk, true) + end) + if not ok then + return false + end + if #model.hunks > 1 then + vim.schedule(function() + local current = ctx.current_view() + if current and current.staging == staging then + reopen() + end + end) + end + return true + end + + -- staging for a commit-preview row, HEAD↔index. an add or a deletion is one unit + -- whose index entry comes and goes, so it stages as a file + ---@param entry differ.FileEntry + ---@param model differ.DiffModel -- HEAD↔index + ---@return differ.view.Staging + local function preview_staging(entry, model) + local staging + if #model.hunks > 0 and entry.x ~= "A" and entry.x ~= "D" then + staging = frozen_staging(entry, model, true, function() + retarget_view(false) + end) + -- the model's new side is the index, so its lines move by whatever the + -- worktree has added or dropped above them since + staging.revert = function(m, idx) + local h = m.hunks[idx] + local _, _, unstaged = gitmod.union_models(root, entry) + if not unstaged then + return index_unreadable(entry) + end + local at = h.new_count > 0 and h.new_start or h.new_start + 1 + local offset = require("differ.model.marks").shift(unstaged.hunks, at, "old") + return revert_frozen(entry, m, staging, idx, offset, function() + retarget_view(false) + end) + end + else + staging = snapshot_staging(entry) + staging.revert, staging.revert_label = whole_file_revert(entry, entry.x) + end + staging.badge = "STAGED" + staging.no_local = "the commit preview has no local view: gs goes back" + staging.leave = function() + ctx.preview_off() + end + return staging + end + + -- u on a `!` hunk in the local view: the index takes HEAD's lines there, dropping the + -- staged change the worktree undid. its lines are the index's at open, moved by + -- whatever s has staged since + ---@param entry differ.FileEntry + ---@param model differ.DiffModel -- index↔worktree, as the view opened on it + ---@param staging differ.view.Staging + ---@param idx integer + ---@return boolean + local function drop_hidden(entry, model, staging, idx, reopen) + local marks = require("differ.model.marks") + local stage = require("differ.model.stage") + local splice = require("differ.model.apply").splice + local applied, moved = {}, {} ---@type table, differ.Hunk[] + for i, h in ipairs(model.hunks) do + applied[i] = marks.state(staging.marks, h) == "staged" + if applied[i] then + moved[#moved + 1] = h + end + end + local _, cached = gitmod.union_models(root, entry) + if not cached then + return index_unreadable(entry) + end + if cached.new_text ~= splice(model, applied) then + notify("the index changed outside differ: re-reading", vim.log.levels.WARN) + vim.schedule(function() + reopen() + end) + return false + end + local hunk = model.hunks[idx] + local placed = vim.tbl_extend("force", hunk, { + old_start = hunk.old_start + marks.shift(moved, hunk.old_start, "old"), + }) + local keep = stage.select_hunks(cached.hunks, "new", placed, "old", false) + if not put_index(entry, splice(cached, keep)) then + return false + end + refresh_panel() + reopen() + return true + end + + return { + for_entry = stage_for, + preview = preview_staging, + frozen = frozen_staging, + revert_frozen = revert_frozen, + drop_hidden = drop_hidden, + partly_staged = partly_staged, + } +end + +return M From 6b892d813bce5dd9ab90cd3363ee7f9e98b65987 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:31:46 +0100 Subject: [PATCH 42/93] feat: flip the commit preview from the diff as well as the panel --- lua/differ/config.lua | 2 +- lua/differ/git/init.lua | 3 +++ lua/differ/git/staging.lua | 2 +- lua/differ/init.lua | 2 ++ lua/differ/view.lua | 25 +++++++++++++++++++++++++ test/nvim/git_spec.lua | 22 ++++++++++++++++++++++ 6 files changed, 54 insertions(+), 2 deletions(-) diff --git a/lua/differ/config.lua b/lua/differ/config.lua index c0e4516..4b56596 100644 --- a/lua/differ/config.lua +++ b/lua/differ/config.lua @@ -142,7 +142,7 @@ M.defaults = { details = "K", -- history: float the full commit message (subject + body) help = "g?", -- panel, history toggle_listing = "i", -- panel: toggle tree / name - commit_preview = "gs", -- panel: list only what a commit would take, each row HEAD↔index + commit_preview = "gs", -- panel/diff: list only what a commit would take, HEAD↔index rows close_node = "c", -- panel: collapse the dir under the cursor; history: the commit close_all = "C", -- panel/history: collapse every dir / commit open_all = "O", -- panel/history: expand every dir / commit diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 324831f..ed11354 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -1349,6 +1349,9 @@ function M.panel(opts) view = require("differ").diff_model(model, { staging = staging, can_stage = stageable, + commit_preview = stageable and function() + set_preview(not preview) + end or nil, }) end active_entry = entry diff --git a/lua/differ/git/staging.lua b/lua/differ/git/staging.lua index 481f783..bc02b12 100644 --- a/lua/differ/git/staging.lua +++ b/lua/differ/git/staging.lua @@ -513,7 +513,7 @@ function M.new(ctx) staging.revert, staging.revert_label = whole_file_revert(entry, entry.x) end staging.badge = "STAGED" - staging.no_local = "the commit preview has no local view: gs goes back" + staging.no_local = "the commit preview has no local view: goes back" staging.leave = function() ctx.preview_off() end diff --git a/lua/differ/init.lua b/lua/differ/init.lua index 184a6cb..bd26235 100644 --- a/lua/differ/init.lua +++ b/lua/differ/init.lua @@ -80,6 +80,7 @@ end ---@field context? number|"full" ---@field staging? differ.view.Staging ---@field can_stage? boolean +---@field commit_preview? fun() ---@field extra_keymaps? differ.panel.ExtraMap[] ---@field on_rerender? fun() ---@field on_cursor? fun() @@ -103,6 +104,7 @@ function M.diff_model(model, opts) keymaps = cfg.keymaps.diff --[[@as table]], staging = opts.staging, can_stage = opts.can_stage, + commit_preview = opts.commit_preview, extra_keymaps = opts.extra_keymaps, on_rerender = opts.on_rerender, on_cursor = opts.on_cursor, diff --git a/lua/differ/view.lua b/lua/differ/view.lua index 01d1c22..d96cbac 100644 --- a/lua/differ/view.lua +++ b/lua/differ/view.lua @@ -104,6 +104,7 @@ local armed_view = nil ---@field keymaps table ---@field can_stage boolean -- session-level: bind s/u (worktree-status panels) ---@field staging differ.view.Staging|nil -- per-source capability (nil off-side) +---@field commit_preview fun()|nil -- session-level: gs flips the panel's listing ---@field fold_memory table -- diff_key -> its open folds when last left ---@field marks differ.model.Marks -- which lines the index holds; hunk state rolls up from it ---@field extra_keymaps differ.panel.ExtraMap[]|nil -- session-supplied buffer maps (pr unviewed nav) @@ -131,6 +132,7 @@ View.__index = View ---@field keymaps? table ---@field staging? differ.view.Staging ---@field can_stage? boolean +---@field commit_preview? fun() -- session-level: gs flips the panel's listing ---@field extra_keymaps? differ.panel.ExtraMap[] ---@field on_rerender? fun() ---@field on_cursor? fun() @@ -160,6 +162,8 @@ function View.new(model, opts) ), can_stage = opts.can_stage or false, staging = opts.staging, + -- the session's own listing flip, so gs reads the same either side of the diff + commit_preview = opts.commit_preview, -- session-supplied maps the generic diff surface doesn't own (pr unviewed nav) extra_keymaps = opts.extra_keymaps, on_rerender = opts.on_rerender, @@ -639,6 +643,15 @@ end -- dw: swap a partly staged file between its whole change (HEAD↔worktree) and what -- changed since staging (index↔worktree) +-- gs: the same flip the panel's key does. the session owns the listing, so the view +-- only asks for it +function View:toggle_commit_preview() + if not self.commit_preview then + return vim.notify("differ: no commit preview here", vim.log.levels.INFO) + end + self.commit_preview() +end + function View:toggle_local() local toggle = self.staging and self.staging.toggle_local if not toggle then @@ -808,6 +821,11 @@ function View:_setup_window(winid, bufnr) bind(bufnr, km.toggle_local, function() self:toggle_local() end, "differ: toggle the local view") + if self.commit_preview then + bind(bufnr, km.commit_preview, function() + self:toggle_commit_preview() + end, "differ: toggle the commit preview") + end -- hunk-level here vs the panel's file-level discard, and destructive either -- way, so it confirms rather than acting straight off the key bind(bufnr, km.discard, function() @@ -898,6 +916,13 @@ function View:show_help() local what = self.staging.badge and "back to the whole change" or "changes since staging" rows[#rows + 1] = { fmt(km.toggle_local), what } end + if self.commit_preview then + local what = "commit preview: staged changes only" + if self.staging and self.staging.badge == "STAGED" then + what = "back to every change" + end + rows[#rows + 1] = { fmt(km.commit_preview), what } + end for _, m in ipairs(self.extra_keymaps or {}) do rows[#rows + 1] = { fmt(m.spec), m.desc } end diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index 2b65f8f..074f9c4 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -3152,6 +3152,28 @@ func getDownloadSpeed() { return root end + it("gs in the diff flips the listing the same way the panel's does", function() + preview_repo() + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + local v = view_in_origin(p) + local before = titles(p) + local bound = false + for _, map in ipairs(vim.api.nvim_buf_get_keymap(v.columns[#v.columns].bufnr, "n")) do + bound = bound or map.lhs == "gs" + end + v:toggle_commit_preview() + local in_titles, in_badge = titles(p), view_in_origin(p).staging.badge + view_in_origin(p):toggle_commit_preview() + local out_titles, out_badge = titles(p), view_in_origin(p).staging.badge + p:close() + assert.is_true(bound) + assert.are.same({ "Staged changes" }, in_titles) + assert.are.equal("STAGED", in_badge) + assert.are.same(before, out_titles) + assert.is_nil(out_badge) + end) + it("gs lists only the staged changes, each diffing HEAD-to-index, and back", function() preview_repo() git_src.panel({ rev = {}, open_first = true }) From 2be13694d9a1d8918f66ad67a6a0b00bdf7bc0cf Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:05:19 +0100 Subject: [PATCH 43/93] feat: open a row's local view from the panel with dw --- lua/differ/git/init.lua | 29 +++++++++++++++++++++++ test/nvim/git_spec.lua | 52 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index ed11354..90dfd13 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -1496,6 +1496,35 @@ function M.panel(opts) end, desc = "commit preview: staged changes only", }, + -- the diff's own dw, driven from the row under the cursor: it opens the row + -- first where the view is showing another one + { + spec = panel_keys.toggle_local, + fn = function() + local entry = panel and panel:current_entry() + if not entry then + return + end + if preview then + return notify("the commit preview has no local view: gs goes back") + end + if not staging_ops.partly_staged(entry) then + return notify("only a partly staged file has a local view") + end + local on_row = active_entry ~= nil and active_entry.path == entry.path + local local_view = view ~= nil + and view.staging ~= nil + and view.staging.badge == "LOCAL" + if on_row and local_view then + return retarget_view(false) -- the same key takes it back + end + if not on_row and not show_entry(entry) then + return + end + show_local(entry) + end, + desc = "local view: changes since staging", + }, } or nil, listing = opts.listing or panel_cfg.listing, position = opts.position or panel_cfg.position, diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index 074f9c4..8e36266 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -3152,6 +3152,58 @@ func getDownloadSpeed() { return root end + -- dw, from the panel: extra_keymaps[2], where [1] is gs + local function toggle_local_key(p) + p.extra_keymaps[2].fn() + end + + it("dw in the panel opens the row's local view, and takes it back", function() + local root = partly_staged_repo() + vim.cmd.edit(root .. "/a.lua") + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + toggle_local_key(p) + local v = view_in_origin(p) + local revs, badge = { v.model.old_rev, v.model.new_rev }, v.staging.badge + toggle_local_key(p) + v = view_in_origin(p) + local back = { v.model.old_rev, v.model.new_rev } + p:close() + assert.are.same({ "INDEX", "WORKTREE" }, revs) + assert.are.equal("LOCAL", badge) + assert.are.same({ "HEAD", "WORKTREE" }, back) + end) + + it("dw in the panel refuses in the commit preview", function() + preview_repo() + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + toggle_preview(p) + _G.notifs = {} + toggle_local_key(p) + local said, rev_after = (_G.notifs[#_G.notifs] or {}).msg, view_in_origin(p).model.new_rev + p:close() + assert.are.equal("differ: the commit preview has no local view: gs goes back", said) + assert.are.equal("INDEX", rev_after) + end) + + it("dw in the panel refuses a row with nothing staged", function() + local root = fresh_repo() + write(root .. "/a.lua", "one\ntwo\n") + git(root, "add", "a.lua") + git(root, "commit", "-q", "-m", "one") + write(root .. "/a.lua", "one\ntwo2\n") + vim.cmd.edit(root .. "/a.lua") + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + _G.notifs = {} + toggle_local_key(p) + local said, rev_after = (_G.notifs[#_G.notifs] or {}).msg, view_in_origin(p).model.old_rev + p:close() + assert.are.equal("differ: only a partly staged file has a local view", said) + assert.are.equal("HEAD", rev_after) + end) + it("gs in the diff flips the listing the same way the panel's does", function() preview_repo() git_src.panel({ rev = {}, open_first = true }) From cb686d1d85384c6e4a15ee8f79bd4be916615215 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:05:19 +0100 Subject: [PATCH 44/93] fix: draw a staged change the worktree put back as HEAD-to-index hunks --- lua/differ/git/init.lua | 30 ++++++++++++++++++++++++++++++ lua/differ/git/staging.lua | 19 +++++++++++++++---- test/nvim/git_spec.lua | 26 ++++++++++++++++++++++++++ 3 files changed, 71 insertions(+), 4 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 90dfd13..879eb0e 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -1309,6 +1309,26 @@ function M.panel(opts) last_sig = git_signature() end + -- a row whose staged change the worktree has put back: HEAD↔worktree is empty, so + -- the row draws HEAD↔index instead, where the change it still has is + ---@param entry differ.FileEntry + ---@return differ.DiffModel|nil + local function staged_model(entry) + if preview or not staging_ops.partly_staged(entry) then + return nil + end + local file = { + path = entry.path, + status = entry.status, + previous_path = entry.previous_path, + } + local model = M.model({ old = HEAD, new = INDEX }, root, file, head_branch(root)) + if #model.hunks == 0 then + return nil + end + return model + end + -- (re)source the diff view from an entry's current git state. false when the -- entry has no diff anymore (committed / fully staged / reverted outside differ). -- `source_opts` goes to View:set_source when the view is open @@ -1317,6 +1337,14 @@ function M.panel(opts) ---@return boolean shown local function show_entry(entry, source_opts) local model = model_for(entry) + local staged_only = false + if #model.hunks == 0 and not model.binary then + local staged = staged_model(entry) + if staged then + model = staged + staged_only = true + end + end if #model.hunks == 0 and not model.binary then -- a real change with no lines to show (a mode change, a rename, a final -- newline, an empty new file) opens on a notice, like a binary file does; @@ -1335,6 +1363,8 @@ function M.panel(opts) local staging ---@type differ.view.Staging|nil if preview then staging = staging_ops.preview(entry, model) + elseif staged_only then + staging = staging_ops.staged_only(entry, model) else staging = staging_ops.for_entry(entry, model) end diff --git a/lua/differ/git/staging.lua b/lua/differ/git/staging.lua index bc02b12..9963ac7 100644 --- a/lua/differ/git/staging.lua +++ b/lua/differ/git/staging.lua @@ -483,12 +483,13 @@ function M.new(ctx) return true end - -- staging for a commit-preview row, HEAD↔index. an add or a deletion is one unit - -- whose index entry comes and goes, so it stages as a file + -- staging for a row drawn HEAD↔index, in the commit preview or where that is the + -- only diff the row has. an add or a deletion is one unit whose index entry comes + -- and goes, so it stages as a file ---@param entry differ.FileEntry ---@param model differ.DiffModel -- HEAD↔index ---@return differ.view.Staging - local function preview_staging(entry, model) + local function staged_staging(entry, model) local staging if #model.hunks > 0 and entry.x ~= "A" and entry.x ~= "D" then staging = frozen_staging(entry, model, true, function() @@ -513,7 +514,16 @@ function M.new(ctx) staging.revert, staging.revert_label = whole_file_revert(entry, entry.x) end staging.badge = "STAGED" - staging.no_local = "the commit preview has no local view: goes back" + return staging + end + + -- staging for a commit-preview row: the HEAD↔index keys, plus the way back out + ---@param entry differ.FileEntry + ---@param model differ.DiffModel -- HEAD↔index + ---@return differ.view.Staging + local function preview_staging(entry, model) + local staging = staged_staging(entry, model) + staging.no_local = "the commit preview has no local view: gs goes back" staging.leave = function() ctx.preview_off() end @@ -566,6 +576,7 @@ function M.new(ctx) return { for_entry = stage_for, preview = preview_staging, + staged_only = staged_staging, frozen = frozen_staging, revert_frozen = revert_frozen, drop_hidden = drop_hidden, diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index 8e36266..935f5cc 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -2926,6 +2926,32 @@ func getDownloadSpeed() { return p, v end + -- 1x staged and then put back on disk: HEAD↔worktree is empty, so the row draws the + -- staged change it still has rather than a notice + it("opens a staged change the worktree put back on its HEAD-to-index hunks", function() + local root = staged_then(twelve({})) + git_src.panel({ rev = {}, open_first = true }) + local p = Panel.current() + local v = view_in_origin(p) + local revs = { v.model.old_rev, v.model.new_rev } + local hunks, notice, badge = #v.model.hunks, v.model.notice, v.staging.badge + local state = v:_hunk_state(1) + local col = v.columns[#v.columns] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + v:unstage_hunk() + local index = indexed(root, "a.lua") + if Panel.current() then + p:close() -- unstaging the last staged change empties the list + end + assert.are.same({ "HEAD", "INDEX" }, revs) + assert.are.equal(1, hunks) + assert.is_nil(notice) + assert.are.equal("STAGED", badge) + assert.are.equal("staged", state) + assert.are.equal(committed(root, "a.lua"), index) + end) + -- 1x staged and then put back to 1 in the worktree: the local hunk undoing it is `!` it("u on a ! hunk in the local view drops the staged change it undoes", function() local root = staged_then(twelve({ [12] = "12x" })) From 65252eea7ebd9963a8792c28f0c9e6ae7e5f430d Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:05:19 +0100 Subject: [PATCH 45/93] fix: confirm before u drops staged content no other side holds --- lua/differ/view.lua | 24 +++++++++++++++++++++++- test/nvim/git_spec.lua | 39 +++++++++++++++++++++++++++++++++++---- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/lua/differ/view.lua b/lua/differ/view.lua index d96cbac..6dc47f3 100644 --- a/lua/differ/view.lua +++ b/lua/differ/view.lua @@ -1273,12 +1273,34 @@ function View:unstage_hunk() if idx and self:_hunk_state(idx) ~= "unstaged" then self:_toggle_hunk(false) elseif idx and self:_drops_hidden(idx) then - self.staging.unstage_hidden(idx) + self:_drop_hidden(idx) else self:_step_review("prev") end end +-- u on a `!` hunk drops staged content no other side holds, so it confirms like X does. +-- the prompt drains scheduled callbacks, so the model is checked again after it +---@param idx integer +function View:_drop_hidden(idx) + local prompt = ("Drop the staged change under hunk %d/%d in %s? Nothing else holds it."):format( + idx, + #self.model.hunks, + self.model.path + ) + local asked_on = self.model + if vim.fn.confirm(prompt, "&Yes\n&No", 2) ~= 1 then + return + end + if self.model ~= asked_on then + return vim.notify( + "differ: the diff changed while the prompt was up; nothing was dropped", + vim.log.levels.WARN + ) + end + self.staging.unstage_hidden(idx) +end + -- whether u on unmarked hunk `idx` drops staged content the whole change can't show ---@param idx integer ---@return boolean diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index 935f5cc..00445c7 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -2914,6 +2914,17 @@ func getDownloadSpeed() { os.remove(blob) git(root, "update-index", "--cacheinfo", "100644," .. sha .. ",a.lua") end + -- u on a `!` hunk confirms, like X: 1 answers yes, 2 no + local function dropping(choice, fn) + local orig = vim.fn.confirm + vim.fn.confirm = function() + return choice + end + local ok, err = pcall(fn) + vim.fn.confirm = orig + assert(ok, err) + end + -- the local view of the file :Differ opened, cursor on hunk `n` local function local_view_at(n) git_src.panel({ rev = {}, open_first = true }) @@ -2957,7 +2968,9 @@ func getDownloadSpeed() { local root = staged_then(twelve({ [12] = "12x" })) local p, v = local_view_at(1) local marked = v.staging.hidden_in - v:unstage_hunk() + dropping(1, function() + v:unstage_hunk() + end) local index, hunks, rev_after = indexed(root, "a.lua"), #v.model.hunks, v.model.old_rev local after = v.staging.hidden_in p:close() @@ -2972,12 +2985,26 @@ func getDownloadSpeed() { it("u on a ! hunk takes HEAD's lines, not the worktree's", function() local root = staged_then(twelve({ "1y", [12] = "12x" })) local p, v = local_view_at(1) - v:unstage_hunk() + dropping(1, function() + v:unstage_hunk() + end) local index = indexed(root, "a.lua") p:close() assert.are.equal(committed(root, "a.lua"), index) end) + -- it drops staged content no other side holds, so a no leaves the index alone + it("u on a ! hunk drops nothing when the confirm is declined", function() + local root = staged_then(twelve({ [12] = "12x" })) + local p, v = local_view_at(1) + dropping(2, function() + v:unstage_hunk() + end) + local index = indexed(root, "a.lua") + p:close() + assert.are.equal(twelve({ "1x" }), index) + end) + -- where u drops the staged 1x, X keeps it: the put-back goes and the file holds 1x again it("X on a ! hunk in the local view puts the staged line back into the file", function() local root = staged_then(twelve({ [12] = "12x" })) @@ -3020,7 +3047,9 @@ func getDownloadSpeed() { local p, v = local_view_at(1) stage_behind(root, twelve({ "1x", "2x" })) _G.notifs = {} - v:unstage_hunk() + dropping(1, function() + v:unstage_hunk() + end) local said = (_G.notifs[#_G.notifs] or {}).msg local index = indexed(root, "a.lua") p:close() @@ -3040,7 +3069,9 @@ func getDownloadSpeed() { local marked = v.staging.hidden_in v:stage_hunk() vim.api.nvim_win_set_cursor(0, { hunk_line(v, 2), 0 }) - v:unstage_hunk() + dropping(1, function() + v:unstage_hunk() + end) local index = indexed(root, "a.lua") p:close() assert.are.same({ 2 }, marked) From 2a89e75228b1d87f76558f234474e872d24cca3f Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:26:38 +0100 Subject: [PATCH 46/93] refactor: narrow apply_patch to the worktree revert it is --- lua/differ/git/init.lua | 29 ++++------- lua/differ/git/staging.lua | 4 +- test/nvim/git_spec.lua | 100 ++++++++++++++++--------------------- 3 files changed, 55 insertions(+), 78 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 879eb0e..3442b51 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -779,28 +779,19 @@ end M.stage, M.unstage = index_ops.stage, index_ops.unstage M.stage_all, M.unstage_all = index_ops.stage_all, index_ops.unstage_all --- apply a single-hunk patch, atomically. `--unidiff-zero` because the patch carries --- no context (built straight from the hunk model); `--reverse` undoes rather than --- applies. `target` picks the side written: the index for hunk staging, the worktree --- for hunk revert. never `--index` (both at once), which checks the whole path is in --- sync and so refuses on any file that has changes on the other side, hunk-unrelated --- ones included; a revert that must reach both composes two calls instead. git apply --- is atomic, so a non-applying patch fails cleanly with stderr rather than --- half-writing. returns ok + git's stderr on failure +-- reverse-apply a single-hunk patch to the worktree, atomically. `--unidiff-zero` +-- because the patch carries no context (built straight from the hunk model). never +-- `--index` (index and worktree at once), which checks the whole path is in sync and so +-- refuses on any file that has changes on the other side, hunk-unrelated ones included; +-- a revert that must reach both writes the index separately. git apply is atomic, so a +-- non-applying patch fails cleanly with stderr rather than half-writing. the index is +-- never patched: staging works out the content it should hold and writes it whole ---@param root string ----@param text string -- the unified diff to apply ----@param reverse boolean ----@param target? "index"|"worktree" -- default "index" +---@param text string -- the unified diff to reverse ---@param check? boolean -- only test that it applies, writing nothing ---@return boolean ok, string|nil err -function M.apply_patch(root, text, reverse, target, check) - local cmd = { "git", "apply", "--unidiff-zero", "--whitespace=nowarn" } - if (target or "index") == "index" then - cmd[#cmd + 1] = "--cached" - end - if reverse then - cmd[#cmd + 1] = "--reverse" - end +function M.revert_patch(root, text, check) + local cmd = { "git", "apply", "--unidiff-zero", "--whitespace=nowarn", "--reverse" } if check then cmd[#cmd + 1] = "--check" end diff --git a/lua/differ/git/staging.lua b/lua/differ/git/staging.lua index 9963ac7..ab5dade 100644 --- a/lua/differ/git/staging.lua +++ b/lua/differ/git/staging.lua @@ -93,14 +93,14 @@ function M.new(ctx) ---@return boolean local function revert_worktree(entry, model, hunk, offset, stage_index) local p = patch.hunk(model.path, hunk, model.old_text, model.new_text, offset, "new") - if not gitmod.apply_patch(root, p, true, "worktree", true) then + if not gitmod.revert_patch(root, p, true) then notify("the file has changed these lines: nothing reverted", vim.log.levels.WARN) return false end if not stage_index() then return false end - local ok, err = gitmod.apply_patch(root, p, true, "worktree") + local ok, err = gitmod.revert_patch(root, p) reload_buffer(root, entry.path) if not ok then notify(("hunk revert failed: %s"):format(err or ""), vim.log.levels.ERROR) diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index 00445c7..f289ffe 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -340,26 +340,25 @@ describe("git.read (worktree clean filter)", function() assert.are.equal("one\r\ntwo\r\n", git_src.read(wt, root, "c.txt")) end) - it("hunk staging stores the same blob a git add would (no CRLF leak)", function() + it("staging stores the same blob a git add would (no CRLF leak)", function() local root = fresh_repo() git(root, "config", "core.autocrlf", "input") - write(root .. "/a.lua", "local x = 1\r\nNEW LINE\r\nreturn x\r\n") + write(root .. "/c.txt", "one\r\ntwo\r\n") + git(root, "add", "c.txt") + git(root, "commit", "-q", "-m", "crlf") + write(root .. "/c.txt", "one\r\nNEW LINE\r\ntwo\r\n") - local source = { - old = { kind = "index", label = "INDEX" }, - new = { kind = "worktree", label = "WORKTREE" }, - } - local model = git_src.model(source, root, { path = "a.lua" }) - assert.are.equal(1, #model.hunks) -- the insert only, no phantom eol hunks - local patch = require("differ.git.patch") - local p = patch.hunk("a.lua", model.hunks[1], model.old_text, model.new_text, 0, false) - assert.is_true(git_src.apply_patch(root, p, false)) + vim.cmd.edit(root .. "/c.txt") + git_src.panel({ rev = {}, open_first = true }) + local p = require("differ.panel").current() + view_in_origin(p):stage_all() + p:close() -- the staged blob is byte-identical to what `git add` would have written - assert.are.equal("local x = 1\nNEW LINE\nreturn x\n", raw_indexed(root, "a.lua")) + assert.are.equal("one\nNEW LINE\ntwo\n", raw_indexed(root, "c.txt")) end) end) -describe("git.apply_patch (target)", function() +describe("git.revert_patch", function() local patch = require("differ.git.patch") local INDEX = { kind = "index", label = "INDEX" } local WT = { kind = "worktree", label = "WORKTREE" } @@ -384,33 +383,24 @@ describe("git.apply_patch (target)", function() return git_src.model({ old = INDEX, new = WT }, root, { path = "f.txt" }) end + -- the revert patches the file, so its hunk sits at the model's new-side lines ---@param model differ.DiffModel ---@param idx integer - ---@param reverse boolean - local function hunk_patch(model, idx, reverse) - return patch.hunk("f.txt", model.hunks[idx], model.old_text, model.new_text, 0, reverse) + local function hunk_patch(model, idx) + return patch.hunk("f.txt", model.hunks[idx], model.old_text, model.new_text, 0, "new") end - it("reverse-applies to the worktree and leaves the index alone", function() + it("reverses one hunk in the worktree and leaves the index alone", function() local root = two_hunk_repo() local model = unstaged_model(root) assert.are.equal(2, #model.hunks) - assert.is_true(git_src.apply_patch(root, hunk_patch(model, 2, true), true, "worktree")) + assert.is_true(git_src.revert_patch(root, hunk_patch(model, 2))) -- only the second edit is undone; the first survives, as does the index assert.are.equal("a\nB\nc\nd\ne\n", git_src.read(WT, root, "f.txt")) assert.are.equal(BASE, git_src.read(INDEX, root, "f.txt")) end) - it("defaults to the index and leaves the worktree alone", function() - local root = two_hunk_repo() - local model = unstaged_model(root) - - assert.is_true(git_src.apply_patch(root, hunk_patch(model, 1, false), false)) - assert.are.equal("a\nB\nc\nd\ne\n", git_src.read(INDEX, root, "f.txt")) - assert.are.equal(EDITED, git_src.read(WT, root, "f.txt")) - end) - -- the revert-on-a-staged-hunk path composes two calls, and the worktree one is -- located against content the user may have edited since; it must fail whole it("fails without writing when the worktree no longer holds the hunk's content", function() @@ -418,7 +408,7 @@ describe("git.apply_patch (target)", function() local model = unstaged_model(root) write(root .. "/f.txt", "a\nB\nc\nZZZ\ne\n") -- the D line was rewritten since - local ok, err = git_src.apply_patch(root, hunk_patch(model, 2, true), true, "worktree") + local ok, err = git_src.revert_patch(root, hunk_patch(model, 2)) assert.is_false(ok) assert.is_not_nil(err) assert.are.equal("a\nB\nc\nZZZ\ne\n", git_src.read(WT, root, "f.txt")) @@ -431,27 +421,28 @@ describe("git.apply_patch (target)", function() local model = unstaged_model(root) write(root .. "/f.txt", "x1\nx2\nx3\na\nB\nc\nD\ne\n") -- D moves from line 4 to 7 - assert.is_true(git_src.apply_patch(root, hunk_patch(model, 2, true), true, "worktree")) + assert.is_true(git_src.revert_patch(root, hunk_patch(model, 2))) assert.are.equal("x1\nx2\nx3\na\nB\nc\nd\ne\n", git_src.read(WT, root, "f.txt")) end) end) -- the missing-final-newline bug was never that the patch text looked wrong: it looked --- plausible, git apply reported success, and the index silently gained a joined line. +-- plausible, git apply reported success, and the file silently gained a joined line. -- the unit fixtures pin the text; only real git can pin the bytes it writes, and under -- `--unidiff-zero` a side with no lines leaves git trusting the header rather than --- failing loudly. every shape here round-trips, so an over-eager widening is caught --- too: unstaging has to land back on the original blob byte-for-byte -describe("git hunk staging round-trip (real git apply, EOF terminators)", function() +-- failing loudly. an over-eager widening is caught too: the revert has to land back on +-- the original file byte-for-byte +describe("git hunk revert (real git apply, EOF terminators)", function() local patch = require("differ.git.patch") local INDEX = { kind = "index", label = "INDEX" } local WT = { kind = "worktree", label = "WORKTREE" } - -- raw stdout, no text=true: that collapses the very terminators under test - local function indexed(root, path) - local res = vim.system({ "git", "cat-file", "blob", ":" .. path }, { cwd = root }):wait() - assert(res.code == 0, res.stderr) - return res.stdout + -- raw bytes, no readfile: that collapses the very terminators under test + local function on_disk(root, path) + local fd = assert(io.open(root .. "/" .. path, "rb")) + local data = fd:read("*a") + fd:close() + return data end -- committed, so the index starts equal to HEAD, with the worktree carrying the edit @@ -466,40 +457,35 @@ describe("git hunk staging round-trip (real git apply, EOF terminators)", functi return root end - -- stage the hunk, then unstage the same patch, checking the index blob at each - -- step. one hunk, so no offset: `reverse` only picks which way git reads it, which - -- is exactly what the staging keys do - local function round_trip(base, edited) + -- X's own path: reverse the hunk against the file, which has to put back the base + -- bytes exactly. one hunk, so no offset + local function revert_back(base, edited) local root = repo_with(base, edited) local model = git_src.model({ old = INDEX, new = WT }, root, { path = "f.txt" }) assert.are.equal(1, #model.hunks) - local p = patch.hunk("f.txt", model.hunks[1], model.old_text, model.new_text, 0, "old") - - local ok, err = git_src.apply_patch(root, p, false) - assert.is_true(ok, "stage failed: " .. tostring(err) .. "\n" .. p) - assert.are.equal(edited, indexed(root, "f.txt")) + local p = patch.hunk("f.txt", model.hunks[1], model.old_text, model.new_text, 0, "new") - ok, err = git_src.apply_patch(root, p, true) - assert.is_true(ok, "unstage failed: " .. tostring(err) .. "\n" .. p) - assert.are.equal(base, indexed(root, "f.txt")) + local ok, err = git_src.revert_patch(root, p) + assert.is_true(ok, "revert failed: " .. tostring(err) .. "\n" .. p) + assert.are.equal(base, on_disk(root, "f.txt")) end - it("appends past an unterminated last line, giving it its newline", function() - round_trip("a\nb", "a\nb\nc\n") + it("takes back an append that gave the last line its newline", function() + revert_back("a\nb", "a\nb\nc\n") end) - it("deletes the tail, leaving the new last line unterminated", function() - round_trip("a\nb\nc\n", "a\nb") + it("puts back a deleted tail, with the newline it had", function() + revert_back("a\nb\nc\n", "a\nb") end) - it("appends unterminated onto unterminated", function() - round_trip("a\nb", "a\nb\nc") + it("takes back an unterminated append onto an unterminated file", function() + revert_back("a\nb", "a\nb\nc") end) -- the control: an unterminated file whose hunk stops short of EOF must not widen, -- and must not quietly acquire a trailing newline on the way through it("leaves an unterminated file unterminated when the hunk is mid-file", function() - round_trip("a\nb", "a\nX\nb") + revert_back("a\nb", "a\nX\nb") end) end) From 29ae63351548ab3271ceeef99aede8cad0626e59 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Wed, 16 Sep 2026 19:19:26 +0100 Subject: [PATCH 47/93] test: open the panel and answer confirms through shared helpers --- test/nvim/git_spec.lua | 613 ++++++++++++----------------------------- 1 file changed, 183 insertions(+), 430 deletions(-) diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index f289ffe..f0be3cb 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -57,6 +57,24 @@ local function view_in_origin(p) return require("differ.view").current() end +-- the panel session on the repo just built, and the diff it opens on the origin file +local function open_panel() + git_src.panel({ rev = {}, open_first = true }) + local p = assert(require("differ.panel").current()) + return p, view_in_origin(p) +end + +-- run `fn` with vim.fn.confirm answering `choice`: 1 yes, 2 no +local function confirming(choice, fn) + local orig = vim.fn.confirm + vim.fn.confirm = function() + return choice + end + local ok, err = pcall(fn) + vim.fn.confirm = orig + assert(ok, err) +end + describe("git.read / changed_files", function() it("reads the committed version and the worktree version", function() local root = fresh_repo() @@ -734,8 +752,7 @@ describe(":Differ panel", function() write(root .. "/z.lua", "z1x\nz2\n") -- z.lua modified, unstaged vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() local winbar = require("differ.ui.winbar") vim.g.statusline_winid = p.winid vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "a.lua"), 0 }) @@ -932,8 +949,7 @@ describe(":Differ panel", function() write(root .. "/keep.lua", "kept\n") -- a survivor the diff should land on vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() assert.are.equal("a.lua", view_at(p).model.path) git(root, "checkout", "HEAD", "--", "a.lua") -- only the shown file goes clean @@ -957,8 +973,7 @@ describe(":Differ panel", function() write(root .. "/a.lua", "local x = 2\nreturn x\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() assert.are.equal(1, #view_at(p).model.hunks) write(root .. "/a.lua", "local x = 2\nreturn x\nlocal y = 3\n") -- edited outside @@ -1245,12 +1260,9 @@ describe(":Differ panel staging (slice C)", function() local p = Panel.current() vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "a.lua"), 0 }) - local orig = vim.fn.confirm - vim.fn.confirm = function() - return 1 - end - p:discard() - vim.fn.confirm = orig + confirming(1, function() + p:discard() + end) assert.is_nil(entry_of(p, "a.lua")) -- no longer a change assert.are.equal(V1, table.concat(vim.fn.readfile(root .. "/a.lua"), "\n") .. "\n") @@ -1271,12 +1283,9 @@ describe(":Differ panel staging (slice C)", function() local p = Panel.current() vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "u.lua"), 0 }) - local orig = vim.fn.confirm - vim.fn.confirm = function() - return 1 - end - p:discard() - vim.fn.confirm = orig + confirming(1, function() + p:discard() + end) assert.are.equal(0, vim.fn.filereadable(root .. "/u.lua")) p:close() @@ -1370,12 +1379,9 @@ describe(":Differ panel staging (slice C)", function() local p = Panel.current() vim.api.nvim_win_set_cursor(p.winid, { dir_line(p, "src"), 0 }) - local orig = vim.fn.confirm - vim.fn.confirm = function() - return 1 - end - p:discard() - vim.fn.confirm = orig + confirming(1, function() + p:discard() + end) assert.are.equal(0, vim.fn.filereadable(root .. "/src/a.lua")) -- both deleted assert.are.equal(0, vim.fn.filereadable(root .. "/src/b.lua")) @@ -1488,9 +1494,7 @@ describe(":Differ diff hunk staging", function() write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8x\n") -- two far-apart edits vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("unstaged", v:_hunk_state(1)) -- index↔worktree opens unstaged assert.are.equal(2, #v.model.hunks) -- two distinct hunks (lines 1 and 8) local before = vim.api.nvim_buf_get_lines(v.columns[1].bufnr, 0, -1, false) @@ -1524,9 +1528,7 @@ describe(":Differ diff hunk staging", function() write(root .. "/z.lua", "z1x\nz2\n") -- a second file to stage from the panel vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("a.lua", v.model.path) vim.api.nvim_win_set_cursor(p.origin_win, { 1, 0 }) v:stage_hunk() -- mark hunk 1 in place, leaving hunk 2 unstaged @@ -1554,9 +1556,7 @@ describe(":Differ diff hunk staging", function() git(root, "add", "a.lua") -- whole change staged vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("staged", v:_hunk_state(1)) -- a staged (HEAD↔index) diff assert.are.equal("staged", v:_hunk_state(1)) -- opens marked staged assert.are.equal("local x = 2\nreturn x\n", indexed(root, "a.lua")) @@ -1579,9 +1579,7 @@ describe(":Differ diff hunk staging", function() write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8x\n") -- two far-apart edits vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() vim.api.nvim_win_set_cursor(p.origin_win, { 1, 0 }) v:stage_hunk() assert.are.equal("staged", v:_hunk_state(1)) @@ -1610,9 +1608,7 @@ describe(":Differ diff hunk staging", function() write(root .. "/new.lua", "alpha\nbeta\n") -- untracked, the only change vim.cmd.edit(root .. "/new.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.is_not_nil(v.staging) -- a new file now offers (whole-file) staging assert.are.equal("unstaged", v:_hunk_state(1)) assert.are.equal(1, #v.model.hunks) -- empty<->content is a single hunk @@ -1638,9 +1634,7 @@ describe(":Differ diff hunk staging", function() write(root .. "/a.lua", "local x = 2\nreturn x\n") -- a.lua modified vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("local x = 2\nreturn x\n", v.model.new_text) -- outside differ: edit the viewed file further, and change git state (stage a @@ -1664,8 +1658,7 @@ describe(":Differ diff hunk staging", function() write(root .. "/a.lua", "local x = 2\nreturn x\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() assert.are.equal("M", p:current_entry().status) _G.notifs = {} @@ -1690,8 +1683,7 @@ describe(":Differ diff hunk staging", function() write(root .. "/a.lua", "local x = 2\nreturn x\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() _G.notifs = {} write(root .. "/a.lua", "local x = 99\nreturn x\n") -- same file, same pair @@ -1712,8 +1704,7 @@ describe(":Differ diff hunk staging", function() write(root .. "/a.lua", "local x = 2\nreturn x\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() assert.are.equal("unstaged", view_in_origin(p):_hunk_state(1)) _G.notifs = {} @@ -1763,9 +1754,7 @@ describe(":Differ diff hunk staging", function() vim.cmd.edit(root .. "/a.lua") vim.api.nvim_win_set_cursor(0, { 10, 0 }) -- near the second hunk - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() -- opened a.lua (the current file) though m.lua sorts first in the list assert.are.equal("1x\n2\n3\n4\n5\n6\n7\n8\n9\n10x\n", v.model.new_text) @@ -1790,9 +1779,7 @@ describe(":Differ diff hunk staging", function() vim.cmd.edit(root .. "/a.lua") vim.api.nvim_win_set_cursor(0, { 10, 2 }) -- on the unstaged change - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() -- HEAD↔worktree, carrying both the staged hunk at line 1 and the unstaged one assert.are.equal(committed(root, "a.lua"), v.model.old_text) @@ -1817,9 +1804,7 @@ describe(":Differ diff hunk staging", function() git(root, "add", "a.lua") -- staged, with nothing left in the worktree vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("a.lua", v.model.path) assert.are.equal("staged", v:_hunk_state(1)) assert.are.equal(V1, v.model.old_text) @@ -1844,9 +1829,7 @@ describe(":Differ diff hunk staging", function() vim.cmd.edit(root .. "/a.lua") vim.api.nvim_win_set_cursor(0, { 8, 0 }) - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("staged", v:_hunk_state(1)) assert.are.equal("unstaged", v:_hunk_state(2)) local before = p:current_entry() @@ -1885,9 +1868,7 @@ describe(":Differ diff hunk staging", function() vim.cmd.edit(root .. "/" .. path) vim.api.nvim_win_set_cursor(0, { 10, 2 }) - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal(2, #v.model.hunks) -- the staged line-1 change and the line-10 one assert.are.equal("staged", v:_hunk_state(1)) assert.are.equal("unstaged", v:_hunk_state(2)) @@ -1928,9 +1909,7 @@ describe(":Differ diff hunk staging", function() write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10x\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() local buffer = vim.api.nvim_buf_get_lines(v.columns[#v.columns].bufnr, 0, -1, false) assert.are.equal(2, #v.model.hunks) -- both edits, in one diff @@ -2101,9 +2080,7 @@ func getDownloadSpeed() { write(root .. "/a.go", ACME_WORK) vim.cmd.edit(root .. "/a.go") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() local col = v.columns[#v.columns] vim.api.nvim_set_current_win(col.winid) local function press(n, op) @@ -2164,8 +2141,7 @@ func getDownloadSpeed() { index_as(root, "a.lua", index) write(root .. "/a.lua", work) vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() return root, p, view_in_origin(p) end local function lines_of(t) @@ -2392,9 +2368,7 @@ func getDownloadSpeed() { write(root .. "/a.lua", "1\n2\n3x\n4y\n5y\n6\n7\n8\n9\n10\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal(1, #v.model.hunks) assert.are.equal("partial", v:_hunk_state(1)) @@ -2425,9 +2399,7 @@ func getDownloadSpeed() { write(root .. "/a.lua", "1\n2\n3x\n4y\n5y\n6\n7\n8\n9\n10\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("partial", v:_hunk_state(1)) local col = v.columns[#v.columns] @@ -2450,9 +2422,7 @@ func getDownloadSpeed() { git(root, "add", "a.lua") write(root .. "/a.lua", "1\n2\nN\n3x\n4\n5\n6\n7\n8\n9\n10\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() local col = v.columns[#v.columns] vim.api.nvim_set_current_win(col.winid) vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) @@ -2473,12 +2443,9 @@ func getDownloadSpeed() { it("reverts an insertion staged just above the hunk's edit from both sides", function() local root = fresh_repo() local p, v = insert_then_edit_below(root) - local orig = vim.fn.confirm - vim.fn.confirm = function() - return 1 - end - v:revert_hunk() - vim.fn.confirm = orig + confirming(1, function() + v:revert_hunk() + end) local work, index = worktree(root, "a.lua"), indexed(root, "a.lua") p:close() assert.are.equal(committed(root, "a.lua"), work) @@ -2496,9 +2463,7 @@ func getDownloadSpeed() { write(root .. "/a.lua", "1\n2\n4x\n5\n6\n7\n8\n9\n10\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("partial", v:_hunk_state(1)) local col = v.columns[#v.columns] vim.api.nvim_set_current_win(col.winid) @@ -2521,9 +2486,7 @@ func getDownloadSpeed() { git(root, "add", "a.lua") write(root .. "/a.lua", "1\nA2\n3\n4\nD2\nE2\n7\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() local col = v.columns[#v.columns] vim.api.nvim_set_current_win(col.winid) vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) @@ -2541,12 +2504,9 @@ func getDownloadSpeed() { local p, v = shared_change(root) local before = { worktree(root, "a.lua"), indexed(root, "a.lua") } local hunks = #v.model.hunks - local orig = vim.fn.confirm - vim.fn.confirm = function() - return 1 - end - v[case.op](v) - vim.fn.confirm = orig + confirming(1, function() + v[case.op](v) + end) local after = { worktree(root, "a.lua"), indexed(root, "a.lua") } local said = (_G.notifs[#_G.notifs] or {}).msg or "" p:close() @@ -2566,21 +2526,16 @@ func getDownloadSpeed() { git(root, "add", "a.lua") write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10x\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() local col = v.columns[#v.columns] vim.api.nvim_set_current_win(col.winid) vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) local edited = "1z\n2\n3\n4\n5\n6\n7\n8\n9\n10x\n" write(root .. "/a.lua", edited) _G.notifs = {} - local orig = vim.fn.confirm - vim.fn.confirm = function() - return 1 - end - v:revert_hunk() - vim.fn.confirm = orig + confirming(1, function() + v:revert_hunk() + end) local said = (_G.notifs[#_G.notifs] or {}).msg local index, work = indexed(root, "a.lua"), worktree(root, "a.lua") p:close() @@ -2603,9 +2558,7 @@ func getDownloadSpeed() { write(root .. "/a[1].txt", "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10x\n") vim.cmd.edit(vim.fn.fnameescape(root .. "/a[1].txt")) - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() if v.model.path ~= "a[1].txt" then p:close() error("opened " .. v.model.path .. ", not the bracketed file") @@ -2629,9 +2582,7 @@ func getDownloadSpeed() { write(root .. "/a.lua", "1\n2\n3x\n4y\n5y\n6\n7\n8\n9\n10x\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("partial", v:_hunk_state(1)) assert.are.equal("unstaged", v:_hunk_state(2)) @@ -2657,8 +2608,7 @@ func getDownloadSpeed() { write(root .. "/b.lua", "two\n") -- unstaged only vim.cmd.edit(root .. "/b.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() assert.are.equal("b.lua", view_in_origin(p).model.path) assert.is_true(p:step_review("prev", true, true)) @@ -2689,9 +2639,7 @@ func getDownloadSpeed() { write(root .. "/a.lua", "local x = 3\nreturn x\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() local text, gutter = winbar_of(v), gutter_of(v, hunk_line(v, 1)) v:toggle_local() local local_gutter = gutter_of(v, hunk_line(v, 1)) @@ -2709,8 +2657,7 @@ func getDownloadSpeed() { vim.fn.setfperm(root .. "/a.lua", "rw-r--r--") -- the worktree is back to -x vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() local text = winbar_of(view_in_origin(p)) p:close() assert.is_truthy(text:find("staged content hidden: dw shows it", 1, true)) @@ -2742,9 +2689,7 @@ func getDownloadSpeed() { write(root .. "/new.lua", "one\ntwo\nthree\n") -- AM vim.cmd.edit(root .. "/new.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() local state = v:_hunk_state(1) local col = v.columns[#v.columns] vim.api.nvim_set_current_win(col.winid) @@ -2771,9 +2716,7 @@ func getDownloadSpeed() { git(root, "add", "-N", "b.lua") -- " R a.lua -> b.lua" vim.cmd.edit(root .. "/b.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() local hunks = #v.model.hunks local col = v.columns[#v.columns] vim.api.nvim_set_current_win(col.winid) @@ -2800,9 +2743,7 @@ func getDownloadSpeed() { it("dw opens a partly staged file's local view and goes back", function() partly_staged_repo() - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() local whole = #v.model.hunks v:toggle_local() local old_rev, new_rev = v.model.old_rev, v.model.new_rev @@ -2827,9 +2768,7 @@ func getDownloadSpeed() { it("stages and unstages in the local view against the index it opened on", function() local root = partly_staged_repo() local before = indexed(root, "a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() v:toggle_local() local col = v.columns[#v.columns] vim.api.nvim_set_current_win(col.winid) @@ -2847,9 +2786,7 @@ func getDownloadSpeed() { it("df from the local view edits the worktree file from the whole change", function() partly_staged_repo() - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() v:toggle_local() v:edit_file() local rev_after, win = v.model.old_rev, v.edit_win @@ -2862,9 +2799,7 @@ func getDownloadSpeed() { local root = fresh_repo() write(root .. "/a.lua", "local x = 2\nreturn x\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() _G.notifs = {} v:toggle_local() local rev_after, msg = v.model.old_rev, _G.notifs[#_G.notifs].msg @@ -2900,22 +2835,9 @@ func getDownloadSpeed() { os.remove(blob) git(root, "update-index", "--cacheinfo", "100644," .. sha .. ",a.lua") end - -- u on a `!` hunk confirms, like X: 1 answers yes, 2 no - local function dropping(choice, fn) - local orig = vim.fn.confirm - vim.fn.confirm = function() - return choice - end - local ok, err = pcall(fn) - vim.fn.confirm = orig - assert(ok, err) - end - -- the local view of the file :Differ opened, cursor on hunk `n` local function local_view_at(n) - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() v:toggle_local() local col = v.columns[#v.columns] vim.api.nvim_set_current_win(col.winid) @@ -2927,9 +2849,7 @@ func getDownloadSpeed() { -- staged change it still has rather than a notice it("opens a staged change the worktree put back on its HEAD-to-index hunks", function() local root = staged_then(twelve({})) - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() local revs = { v.model.old_rev, v.model.new_rev } local hunks, notice, badge = #v.model.hunks, v.model.notice, v.staging.badge local state = v:_hunk_state(1) @@ -2954,7 +2874,7 @@ func getDownloadSpeed() { local root = staged_then(twelve({ [12] = "12x" })) local p, v = local_view_at(1) local marked = v.staging.hidden_in - dropping(1, function() + confirming(1, function() v:unstage_hunk() end) local index, hunks, rev_after = indexed(root, "a.lua"), #v.model.hunks, v.model.old_rev @@ -2971,7 +2891,7 @@ func getDownloadSpeed() { it("u on a ! hunk takes HEAD's lines, not the worktree's", function() local root = staged_then(twelve({ "1y", [12] = "12x" })) local p, v = local_view_at(1) - dropping(1, function() + confirming(1, function() v:unstage_hunk() end) local index = indexed(root, "a.lua") @@ -2983,7 +2903,7 @@ func getDownloadSpeed() { it("u on a ! hunk drops nothing when the confirm is declined", function() local root = staged_then(twelve({ [12] = "12x" })) local p, v = local_view_at(1) - dropping(2, function() + confirming(2, function() v:unstage_hunk() end) local index = indexed(root, "a.lua") @@ -2996,12 +2916,9 @@ func getDownloadSpeed() { local root = staged_then(twelve({ [12] = "12x" })) local p, v = local_view_at(1) local opened = v.staging - local orig = vim.fn.confirm - vim.fn.confirm = function() - return 1 - end - v:revert_hunk() - vim.fn.confirm = orig + confirming(1, function() + v:revert_hunk() + end) vim.wait(500, function() -- the local view re-reads on a schedule return v.staging ~= opened end) @@ -3033,7 +2950,7 @@ func getDownloadSpeed() { local p, v = local_view_at(1) stage_behind(root, twelve({ "1x", "2x" })) _G.notifs = {} - dropping(1, function() + confirming(1, function() v:unstage_hunk() end) local said = (_G.notifs[#_G.notifs] or {}).msg @@ -3055,7 +2972,7 @@ func getDownloadSpeed() { local marked = v.staging.hidden_in v:stage_hunk() vim.api.nvim_win_set_cursor(0, { hunk_line(v, 2), 0 }) - dropping(1, function() + confirming(1, function() v:unstage_hunk() end) local index = indexed(root, "a.lua") @@ -3094,9 +3011,7 @@ func getDownloadSpeed() { index_as(root, "1:x.txt", twelve({ "1x" })) write(root .. "/1:x.txt", twelve({ "1x", [12] = "12x" })) vim.cmd.edit(vim.fn.fnameescape(root .. "/1:x.txt")) - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() local states = { v:_hunk_state(1), v:_hunk_state(2) } p:close() assert.are.same({ "staged", "unstaged" }, states) @@ -3128,12 +3043,9 @@ func getDownloadSpeed() { local root = staged_then(twelve({ "1x", [6] = "6y", [12] = "12y" })) local p, v = local_view_at(1) local opened = v.staging - local orig = vim.fn.confirm - vim.fn.confirm = function() - return 1 - end - v:revert_hunk() - vim.fn.confirm = orig + confirming(1, function() + v:revert_hunk() + end) vim.wait(500, function() -- the local view re-reads on a schedule return v.staging ~= opened end) @@ -3149,12 +3061,9 @@ func getDownloadSpeed() { local root = staged_then(twelve({ "1x", [6] = "6y", [12] = "12y" })) local p, v = local_view_at(1) v:stage_hunk() - local orig = vim.fn.confirm - vim.fn.confirm = function() - return 1 - end - v:revert_hunk() - vim.fn.confirm = orig + confirming(1, function() + v:revert_hunk() + end) local work, index = worktree(root, "a.lua"), indexed(root, "a.lua") p:close() assert.are.equal(twelve({ "1x", [12] = "12y" }), work) @@ -3203,8 +3112,7 @@ func getDownloadSpeed() { it("dw in the panel opens the row's local view, and takes it back", function() local root = partly_staged_repo() vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() toggle_local_key(p) local v = view_in_origin(p) local revs, badge = { v.model.old_rev, v.model.new_rev }, v.staging.badge @@ -3219,8 +3127,7 @@ func getDownloadSpeed() { it("dw in the panel refuses in the commit preview", function() preview_repo() - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() toggle_preview(p) _G.notifs = {} toggle_local_key(p) @@ -3237,8 +3144,7 @@ func getDownloadSpeed() { git(root, "commit", "-q", "-m", "one") write(root .. "/a.lua", "one\ntwo2\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() _G.notifs = {} toggle_local_key(p) local said, rev_after = (_G.notifs[#_G.notifs] or {}).msg, view_in_origin(p).model.old_rev @@ -3249,9 +3155,7 @@ func getDownloadSpeed() { it("gs in the diff flips the listing the same way the panel's does", function() preview_repo() - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() local before = titles(p) local bound = false for _, map in ipairs(vim.api.nvim_buf_get_keymap(v.columns[#v.columns].bufnr, "n")) do @@ -3271,8 +3175,7 @@ func getDownloadSpeed() { it("gs lists only the staged changes, each diffing HEAD-to-index, and back", function() preview_repo() - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() local before = titles(p) toggle_preview(p) local v = view_in_origin(p) @@ -3298,8 +3201,7 @@ func getDownloadSpeed() { -- marked staged, so hunks come out in any order and go straight back it("unstages in the commit preview in any order, and stages back in place", function() local root = preview_repo() - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() toggle_preview(p) local v = view_in_origin(p) local col = v.columns[#v.columns] @@ -3325,8 +3227,7 @@ func getDownloadSpeed() { -- staged, not the worktree's copy it("unstages an add in the commit preview and stages back what was staged", function() local root = preview_repo() - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() toggle_preview(p) assert.is_true(p:goto_path("c.lua", true)) local v = view_in_origin(p) @@ -3341,8 +3242,7 @@ func getDownloadSpeed() { it("refuses s in the commit preview panel", function() local root = preview_repo() - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() toggle_preview(p) local before = indexed(root, "a.lua") _G.notifs = {} @@ -3355,8 +3255,7 @@ func getDownloadSpeed() { it("returns to the whole list once the commit preview is unstaged", function() preview_repo() - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() toggle_preview(p) p:stage_op("unstage_all") local alive, got = p:is_alive(), titles(p) @@ -3369,8 +3268,7 @@ func getDownloadSpeed() { it("df from the commit preview edits the file from the whole list", function() preview_repo() - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() toggle_preview(p) local v = view_in_origin(p) v:edit_file() @@ -3384,12 +3282,9 @@ func getDownloadSpeed() { -- confirm X's prompt, then wait out the re-read the preview schedules local function revert_confirmed(v) local opened = v.staging - local orig = vim.fn.confirm - vim.fn.confirm = function() - return 1 - end - v:revert_hunk() - vim.fn.confirm = orig + confirming(1, function() + v:revert_hunk() + end) vim.wait(500, function() return v.staging ~= opened end) @@ -3397,8 +3292,7 @@ func getDownloadSpeed() { it("X in the commit preview drops a staged hunk from the index and the file", function() local root = preview_repo() - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() toggle_preview(p) local v = view_in_origin(p) local col = v.columns[#v.columns] @@ -3417,20 +3311,16 @@ func getDownloadSpeed() { -- 1x staged and then edited to 1y: taking 1x out of the file would lose 1y it("X in the commit preview refuses a hunk the file has changed since staging", function() local root = staged_then(twelve({ "1y", [12] = "12x" })) - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() toggle_preview(p) local v = view_in_origin(p) local col = v.columns[#v.columns] vim.api.nvim_set_current_win(col.winid) vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) _G.notifs = {} - local orig = vim.fn.confirm - vim.fn.confirm = function() - return 1 - end - v:revert_hunk() - vim.fn.confirm = orig + confirming(1, function() + v:revert_hunk() + end) local msg = _G.notifs[#_G.notifs].msg local index, work = indexed(root, "a.lua"), worktree(root, "a.lua") p:close() @@ -3450,8 +3340,7 @@ func getDownloadSpeed() { git(root, "add", "a.lua") write(root .. "/a.lua", "n1\nn2\nn3\nl1\nl2\nl3\nl4\nl5\nl8\nl9\nl10\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() toggle_preview(p) local v = view_in_origin(p) local col = v.columns[#v.columns] @@ -3466,8 +3355,7 @@ func getDownloadSpeed() { it("u in the commit preview refuses an index staged from outside since it opened", function() local root = staged_then(twelve({ "1x", [12] = "12x" })) - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() toggle_preview(p) local v = view_in_origin(p) local col = v.columns[#v.columns] @@ -3485,17 +3373,13 @@ func getDownloadSpeed() { it("X in the commit preview deletes a staged add", function() local root = preview_repo() - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() toggle_preview(p) assert.is_true(p:goto_path("c.lua", true)) local v = view_in_origin(p) - local orig = vim.fn.confirm - vim.fn.confirm = function() - return 1 - end - v:revert_hunk() - vim.fn.confirm = orig + confirming(1, function() + v:revert_hunk() + end) local on_disk = vim.fn.filereadable(root .. "/c.lua") local status = git(root, "status", "--porcelain=v1", "--", "c.lua") p:close() @@ -3505,8 +3389,7 @@ func getDownloadSpeed() { it("dw and gs say why they do nothing", function() preview_repo() - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() toggle_preview(p) local v = view_in_origin(p) _G.notifs = {} @@ -3538,9 +3421,7 @@ func getDownloadSpeed() { write(root .. "/a.lua", "1\n2\n3x\n4y\n5y\n6\n7\n8\n9\n10\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() local col = v.columns[#v.columns] vim.api.nvim_set_current_win(col.winid) vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) @@ -3572,9 +3453,7 @@ func getDownloadSpeed() { it("redraws the winbar tally as soon as a hunk is unstaged", function() partly_staged_repo() - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() local col = v.columns[#v.columns] vim.api.nvim_set_current_win(col.winid) vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) @@ -3588,8 +3467,7 @@ func getDownloadSpeed() { it("tallies staged hunks beside unstaged ones", function() partly_staged_repo() - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() local text = winbar_of(view_in_origin(p)) p:close() assert.is_truthy(text:find(" 2 staged · hunk %d/3 ")) @@ -3608,21 +3486,15 @@ func getDownloadSpeed() { write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10x\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("staged", v:_hunk_state(1)) local col = v.columns[#v.columns] vim.api.nvim_set_current_win(col.winid) vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) - local real_confirm = vim.fn.confirm - vim.fn.confirm = function() - return 1 - end - local ok, err = pcall(v.revert_hunk, v) - vim.fn.confirm = real_confirm - assert.is_true(ok, tostring(err)) + confirming(1, function() + v:revert_hunk() + end) -- the line-1 change is gone from both sides; the line-10 one is untouched assert.are.equal(committed(root, "a.lua"), indexed(root, "a.lua")) @@ -3640,9 +3512,7 @@ func getDownloadSpeed() { git(root, "add", "a.lua") -- staged outright: the diff opens on HEAD↔index vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("staged", v:_hunk_state(1)) assert.are.equal(1, #v.model.hunks) @@ -3664,9 +3534,7 @@ func getDownloadSpeed() { write(root .. "/a.lua", "local x = 2\nreturn x\n") -- a.lua modified vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() -- edit only the worktree (status stays " M"): the content-aware signature still -- moves, so the diff re-sources where the old HEAD+status signature would miss it @@ -3685,9 +3553,7 @@ func getDownloadSpeed() { write(root .. "/a.lua", "local x = 2\nreturn x\n") -- a.lua modified, unstaged vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("unstaged", v:_hunk_state(1)) git(root, "add", "a.lua") -- stage the whole file in "lazygit" @@ -3710,9 +3576,7 @@ func getDownloadSpeed() { write(root .. "/a.lua", "a\nINS1\nINS2\nb\nc\nD\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal(2, #v.model.hunks) vim.api.nvim_set_current_win(p.origin_win) @@ -3741,9 +3605,7 @@ func getDownloadSpeed() { write(root .. "/a.lua", "1\nA1\nA2\n2\n3\n4\n5\n6\nB1\n7\n8\n9\n10\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal(2, #v.model.hunks) vim.api.nvim_set_current_win(p.origin_win) @@ -3770,9 +3632,7 @@ func getDownloadSpeed() { write(root .. "/a.lua", "1\nINS1\nINS2\n2\n3\n4\n7\n8\n9\nZ\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal(3, #v.model.hunks) vim.api.nvim_set_current_win(p.origin_win) @@ -3794,16 +3654,6 @@ func getDownloadSpeed() { -- the panel row for `path`, optionally pinned to its staged or unstaged side -- answer the revert confirm with `choice` for the duration of `fn` - local function confirming(choice, fn) - local orig = vim.fn.confirm - vim.fn.confirm = function() - return choice - end - local ok, err = pcall(fn) - vim.fn.confirm = orig - assert(ok, err) - end - it("reverts one hunk from the worktree, leaving the others and the index", function() local root = fresh_repo() write(root .. "/a.lua", "1\n2\n3\n4\n5\n6\n7\n8\n") @@ -3811,9 +3661,7 @@ func getDownloadSpeed() { write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8x\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal(2, #v.model.hunks) vim.api.nvim_set_current_win(p.origin_win) @@ -3836,9 +3684,7 @@ func getDownloadSpeed() { write(root .. "/a.lua", "local x = 2\nreturn x\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() vim.api.nvim_set_current_win(p.origin_win) vim.api.nvim_win_set_cursor(p.origin_win, { 1, 0 }) @@ -3867,9 +3713,7 @@ func getDownloadSpeed() { write(root .. "/a.lua", table.concat(edited, "\n") .. "\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal(3, #v.model.hunks) local asked_on = v.model @@ -3918,9 +3762,7 @@ func getDownloadSpeed() { write(root .. "/z.lua", "local z = 99\nreturn z\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("a.lua", v.model.path) vim.api.nvim_set_current_win(p.origin_win) @@ -3960,9 +3802,7 @@ func getDownloadSpeed() { git(root, "add", "a.lua") -- both edits staged; the worktree matches the index vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("staged", v:_hunk_state(1)) assert.are.equal(2, #v.model.hunks) @@ -3988,9 +3828,7 @@ func getDownloadSpeed() { write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8x\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() vim.api.nvim_set_current_win(p.origin_win) vim.api.nvim_win_set_cursor(p.origin_win, { 1, 0 }) @@ -4014,9 +3852,7 @@ func getDownloadSpeed() { write(root .. "/new.lua", "fresh\n") -- untracked vim.cmd.edit(root .. "/new.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("deletes the file", v.staging.revert_label) vim.api.nvim_set_current_win(p.origin_win) @@ -4044,9 +3880,7 @@ func getDownloadSpeed() { git(root, "add", "new.lua") -- staged add: "A" vim.cmd.edit(root .. "/new.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() vim.api.nvim_set_current_win(p.origin_win) vim.api.nvim_win_set_cursor(p.origin_win, { 1, 0 }) @@ -4070,9 +3904,7 @@ func getDownloadSpeed() { write(root .. "/gone.lua", "temporary\n") -- untracked, about to go vim.cmd.edit(root .. "/gone.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("gone.lua", v.model.path) vim.api.nvim_set_current_win(p.origin_win) @@ -4099,9 +3931,7 @@ func getDownloadSpeed() { write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12x\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal(2, #v.model.hunks) vim.api.nvim_set_current_win(p.origin_win) @@ -4130,9 +3960,7 @@ func getDownloadSpeed() { write(root .. "/a.lua", "local x = 2\nreturn x\n") -- a.lua has one hunk vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("a.lua", v.model.path) assert.are.equal(1, #v.model.hunks) @@ -4156,9 +3984,7 @@ func getDownloadSpeed() { write(root .. "/a.lua", "local x = 2\nreturn x\n") -- the only change in the repo vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal(1, #v.model.hunks) vim.api.nvim_set_current_win(p.origin_win) @@ -4182,8 +4008,7 @@ func getDownloadSpeed() { vim.cmd.edit(root .. "/a.lua") local invoked_from = vim.api.nvim_get_current_tabpage() - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() assert.is_false(invoked_from == vim.api.nvim_get_current_tabpage()) -- own tab local v = view_in_origin(p) @@ -4203,9 +4028,7 @@ func getDownloadSpeed() { write(root .. "/a.lua", "local x = 2\nreturn x\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.is_true(v:is_open()) git(root, "commit", "-q", "-am", "committed elsewhere") @@ -4224,9 +4047,7 @@ func getDownloadSpeed() { write(root .. "/b.lua", "kept\n") -- a second change, untracked vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("a.lua", v.model.path) vim.api.nvim_set_current_win(p.origin_win) @@ -4252,8 +4073,7 @@ func getDownloadSpeed() { os.remove(root .. "/b.lua") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "b.lua", false), 0 }) p:select() local v = view_in_origin(p) @@ -4279,8 +4099,7 @@ func getDownloadSpeed() { git(root, "rm", "-q", "b.lua") -- deletion staged vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "b.lua", true), 0 }) p:select() local v = view_in_origin(p) @@ -4307,8 +4126,7 @@ func getDownloadSpeed() { os.remove(root .. "/b.lua") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "b.lua", false), 0 }) p:select() local v = view_in_origin(p) @@ -4342,9 +4160,7 @@ func getDownloadSpeed() { write(root .. "/z.lua", "z1\nz2\n") -- untracked: staged wholesale, not by hunk vim.cmd.edit(root .. "/z.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("z.lua", v.model.path) _G.notifs = {} @@ -4371,9 +4187,7 @@ func getDownloadSpeed() { vim.cmd.edit(root .. "/a.lua") -- the real file is open in a buffer local filebuf = vim.api.nvim_get_current_buf() - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() vim.api.nvim_set_current_win(p.origin_win) vim.api.nvim_win_set_cursor(p.origin_win, { 1, 0 }) confirming(1, function() @@ -4394,9 +4208,7 @@ func getDownloadSpeed() { write(root .. "/a.lua", "1x\n2\n3\n4\n5x\n6\n7\n8\n9x\n") -- three hunks vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal(3, #v.model.hunks) -- stage the first, then revert the second: the mark has to follow hunk 1 @@ -4431,9 +4243,7 @@ func getDownloadSpeed() { write(root .. "/a.lua", "1\n2\n3\nX\n5\n6\n") -- only line 4 changes vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() local cur = vim.api.nvim_win_get_cursor(p.origin_win)[1] assert.is_not_nil(v.columns[1].map.lines[cur].hunk) -- on a hunk, not context assert.are.equal(v:_first_review_line(v.columns[1]), cur) @@ -4447,9 +4257,7 @@ func getDownloadSpeed() { write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8x\n") -- hunks at lines 1 and 8 vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal(1, v:_first_review_line(v.columns[1])) -- both unstaged -> hunk 1 vim.api.nvim_win_set_cursor(p.origin_win, { 1, 0 }) @@ -4466,9 +4274,7 @@ func getDownloadSpeed() { git(root, "add", "a.lua") -- and stage it (fully staged: worktree == index) vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("WORKTREE", v.model.new_rev) v:edit_file() @@ -4513,9 +4319,7 @@ func getDownloadSpeed() { git(root, "add", "a.lua") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() v:set_context(1) assert.are_not.equal(-1, fold_closed_at(v, "10")) open_fold_at(v, "10") @@ -4538,9 +4342,7 @@ func getDownloadSpeed() { write(root .. "/z.lua", "z1x\nz2\n") vim.cmd.edit(root .. "/z.lua") -- open on the last file in the list - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("z.lua", v.model.path) _G.notifs = {} @@ -4569,9 +4371,7 @@ func getDownloadSpeed() { write(root .. "/z.lua", "z1x\nz2\n") -- z.lua: unstaged vim.cmd.edit(root .. "/z.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("z.lua", v.model.path) -- the staged a.lua row renders first, so the wrap meets it before b.lua assert.is_true(file_line(p, "a.lua", true) < file_line(p, "b.lua", false)) @@ -4593,9 +4393,7 @@ func getDownloadSpeed() { write(root .. "/z.lua", "z1x\nz2\n") -- the only unstaged file vim.cmd.edit(root .. "/z.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("z.lua", v.model.path) local hunks_before = #v.model.hunks @@ -4616,9 +4414,7 @@ func getDownloadSpeed() { write(root .. "/z.lua", "z1x\nz2\n") -- unstaged vim.cmd.edit(root .. "/z.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("z.lua", v.model.path) assert.is_true(p:step_review("prev", true, true)) @@ -4639,9 +4435,7 @@ func getDownloadSpeed() { write(root .. "/z.lua", "1x\n2\n3\n4\n5x\n6\n7\n8\n9x\n") -- z.lua: three hunks vim.cmd.edit(root .. "/z.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("z.lua", v.model.path) assert.are.equal(3, #v.model.hunks) @@ -4688,9 +4482,7 @@ func getDownloadSpeed() { write(root .. "/a.lua", "1x\n2\n3\n4\n5x\n6\n7\n8\n9x\n") -- the only file, three hunks vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal(3, #v.model.hunks) vim.api.nvim_set_current_win(p.origin_win) @@ -4725,9 +4517,7 @@ func getDownloadSpeed() { write(root .. "/a.lua", "1x\n2\n3\n4\n5x\n6\n7\n8\n9x\n") -- the only file, three hunks vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal(3, #v.model.hunks) -- stage 1 and 2, leaving 3 alone: the unstaged side survives, so the view stays @@ -4766,9 +4556,7 @@ func getDownloadSpeed() { write(root .. "/z.lua", "z1x\nz2\n") -- z.lua: one hunk vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("a.lua", v.model.path) -- opened from new-file line 1 (the "1" -> "1x" change), so it lands on that -- line's new-side row (row 2, under the deleted old "1"), not the hunk's top @@ -4801,9 +4589,7 @@ func getDownloadSpeed() { git(root, "add", "a.lua", "z.lua") -- stage both: a staged (HEAD↔index) review vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() v:step_file("next") -- move forward to z.lua (the last file) assert.are.equal("z.lua", v.model.path) @@ -4824,9 +4610,7 @@ func getDownloadSpeed() { write(root .. "/a.lua", "a\nINS1\nINS2\nb\nc\nD\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal(2, #v.model.hunks) v:stage_all() -- both hunks into the index in one go @@ -4851,9 +4635,7 @@ func getDownloadSpeed() { write(root .. "/z.lua", "z1x\nz2\n") -- z.lua: one hunk vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("a.lua", v.model.path) v:stage_all() -- a.lua now fully staged; we stay put @@ -4877,9 +4659,7 @@ func getDownloadSpeed() { write(root .. "/z.lua", "z1x\nz2\n") -- z.lua: unstaged, last in the list vim.cmd.edit(root .. "/z.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("z.lua", v.model.path) v:stage_all() -- z.lua fully staged; the view follows to its staged side @@ -4904,9 +4684,7 @@ func getDownloadSpeed() { write(root .. "/z.lua", "z1x\nz2\n") -- z.lua: one hunk, unstaged vim.cmd.edit(root .. "/z.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("z.lua", v.model.path) v:unstage_all() -- nothing staged here: back to a.lua, on its last staged hunk @@ -4926,9 +4704,7 @@ func getDownloadSpeed() { write(root .. "/z.lua", "z1x\nz2\n") -- z.lua: one hunk, left unstaged vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("a.lua", v.model.path) -- u / U at the first file with nothing staged: stay put, no wrap to the last @@ -4965,9 +4741,7 @@ func getDownloadSpeed() { write(root .. "/z.lua", "z1x\nz2\n") -- z.lua: one hunk vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("a.lua", v.model.path) v:goto_hunk("next") -- past a.lua's only (last) hunk: flow into z.lua @@ -5071,9 +4845,7 @@ func getDownloadSpeed() { write(root .. "/z.lua", table.concat(changed, "\n") .. "\n") -- z.lua: two hunks vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("a.lua", v.model.path) v:goto_hunk("next") -- past a.lua's only hunk: flow into z.lua @@ -5092,8 +4864,7 @@ func getDownloadSpeed() { local root = fresh_repo() write(root .. "/a.lua", "local x = 2\nreturn x\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() local lhs = keymaps(view_in_origin(p).columns[1].bufnr) for _, k in ipairs({ "s", "u", "S", "U", "X" }) do assert.is_true(lhs[k]) @@ -5118,8 +4889,7 @@ func getDownloadSpeed() { vim.cmd.edit(root .. "/a.lua") -- default `:Differ` is HEAD↔worktree (uncommitted): df is bound - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() assert.is_true(keymaps(view_in_origin(p).columns[1].bufnr)["df"]) p:close() @@ -5144,8 +4914,7 @@ describe(":Differ panel <-> diff wiring", function() local root = fresh_repo() write(root .. "/a.lua", "local x = 2\nreturn x\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() assert.are.equal(p.origin_win, vim.api.nvim_get_current_win()) -- in the diff assert.are_not.equal(p.winid, vim.api.nvim_get_current_win()) p:close() @@ -5157,8 +4926,7 @@ describe(":Differ panel <-> diff wiring", function() git(root, "commit", "-q", "-am", "8 lines") write(root .. "/a.lua", "1x\n2\n3\n4\n5\n6\n7\n8x\n") -- two hunks vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() local lhs = {} for _, m in ipairs(vim.api.nvim_buf_get_keymap(p.bufnr, "n")) do @@ -5179,8 +4947,7 @@ describe(":Differ panel <-> diff wiring", function() local root = fresh_repo() write(root .. "/a.lua", "local x = 2\nreturn x\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() vim.api.nvim_set_current_win(p.winid) -- focus the panel assert.is_not_nil(require("differ").active_view()) @@ -5202,8 +4969,7 @@ describe(":Differ (open_first)", function() write(root .. "/a.lua", "local x = 2\nreturn x\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() assert.is_not_nil(p) local v = view_in_origin(p) assert.is_not_nil(v) @@ -5223,8 +4989,7 @@ describe(":Differ (open_first)", function() write(root .. "/a.lua", "local x = 2\nreturn x\n") -- one changed line vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() local buf = vim.api.nvim_win_get_buf(p.origin_win) local dict = vim.b[buf].gitsigns_status_dict assert.is_not_nil(dict) @@ -5262,8 +5027,7 @@ describe(":Differ (open_first)", function() git(root, "add", "z.lua") -- staged -> two files in the set vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() vim.api.nvim_set_current_win(p.origin_win) -- emulate cursor in the diff window local v = require("differ.view").current() local first = v.model.path @@ -5283,8 +5047,7 @@ describe(":Differ (open_first)", function() git(root, "add", "z.lua") -- staged (sorts first) vim.cmd.edit(root .. "/a.lua") -- open on a.lua, the last file - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() + local p = open_panel() vim.api.nvim_set_current_win(p.origin_win) local v = require("differ.view").current() assert.are.equal("a.lua", v.model.path) @@ -5308,9 +5071,7 @@ describe(":Differ (open_first)", function() write(root .. "/a.lua", "local x = 2\nreturn x\n") vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.is_true(p:is_open()) assert.is_true(v:is_open()) @@ -5546,9 +5307,7 @@ describe(":Differ diff whole-file staging", function() assert((vim.uv or vim.loop).fs_chmod(root .. "/a.lua", 493)) vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("a.lua", v.model.path) assert.are.equal(0, #v.model.hunks) -- nothing to put a cursor on assert.is_true(v.staging.whole_file) @@ -5569,9 +5328,7 @@ describe(":Differ diff whole-file staging", function() git(root, "add", "a.lua") -- stage the mode change: the only entry is staged vim.cmd.edit(root .. "/a.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("staged", v:_hunk_state(1)) assert.are.equal("staged", v:_hunk_state(1)) -- seeded with no hunk behind it assert.are.equal("100755", index_mode(root, "a.lua")) @@ -5588,9 +5345,7 @@ describe(":Differ diff whole-file staging", function() write(root .. "/b.lua", "other\n") -- a second entry, to catch a step away vim.cmd.edit(root .. "/new.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.are.equal("new.lua", v.model.path) v:toggle_layout() -- -> split; the old column is all filler assert.are.equal(2, #v.columns) @@ -5613,9 +5368,7 @@ describe(":Differ diff whole-file staging", function() write(root .. "/new.lua", "one\n") vim.cmd.edit(root .. "/new.lua") - git_src.panel({ rev = {}, open_first = true }) - local p = Panel.current() - local v = view_in_origin(p) + local p, v = open_panel() assert.is_true(v.staging.whole_file) v:show_help() local help_buf = vim.api.nvim_win_get_buf(0) From cab85dfba5aeb42f85432d067bc35a2fa3463ab1 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Wed, 16 Sep 2026 19:28:26 +0100 Subject: [PATCH 48/93] test: close a leaked panel between staging specs and cover the untested branches --- test/nvim/git_spec.lua | 9 +++++++++ test/nvim/panel_spec.lua | 23 +++++++++++++++++++++++ test/unit/model_apply_spec.lua | 12 ++++++++++++ test/unit/model_marks_spec.lua | 15 +++++++++++++++ 4 files changed, 59 insertions(+) diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index f0be3cb..cc9da89 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -1433,6 +1433,15 @@ end) describe(":Differ diff hunk staging", function() local Panel = require("differ.panel") + -- a spec that aborts before its p:close() would otherwise leak its panel into every + -- spec after it, and the count would name them all rather than the one that broke + after_each(function() + local open = Panel.current() + if open then + open:close() + end + end) + -- the first buffer row of the diff showing `text` local function row_at(v, text) for row, l in ipairs(vim.api.nvim_buf_get_lines(v.columns[1].bufnr, 0, -1, false)) do diff --git a/test/nvim/panel_spec.lua b/test/nvim/panel_spec.lua index f1ce2a4..b71615a 100644 --- a/test/nvim/panel_spec.lua +++ b/test/nvim/panel_spec.lua @@ -525,6 +525,29 @@ describe("panel selection identity", function() } end + -- a refused file op moved nothing, so the list mustn't reload or the hook fire: the + -- commit preview refuses s this way + it("does nothing after a file op the action refused", function() + local staged, reloaded + local actions = acts(function() + reloaded = true + end) + actions.stage = function() + return false + end + local p = panel({ fe("a.lua") }, { + actions = actions, + on_staged = function(paths) + staged = paths + end, + }) + p:open() + p:stage_op("stage") + p:close() + assert.is_nil(staged) + assert.is_nil(reloaded) + end) + -- name mode reorders the list (b.lua rises above a.lua as the tree dir collapses -- away), so restoring the cursor by line number lands it on the other file it("holds the cursor on its file across a listing toggle", function() diff --git a/test/unit/model_apply_spec.lua b/test/unit/model_apply_spec.lua index 02369d2..5a98b7d 100644 --- a/test/unit/model_apply_spec.lua +++ b/test/unit/model_apply_spec.lua @@ -127,6 +127,18 @@ describe("join", function() assert.are.equal("a\nb", apply.join(m, { m.hunks[1].old_lines })) end) + it("ends as the inserted block does when it reaches eof", function() + local terminated = apply.ended(model("a\nb\n", "a\nb\nc\n", { h(2, {}, 3, { "c" }) })) + assert.are.equal("a\nb\nc\n", apply.join(terminated, { terminated.hunks[1].new_lines })) + local bare = apply.ended(model("a\nb\n", "a\nb\nc", { h(2, {}, 3, { "c" }) })) + assert.are.equal("a\nb\nc", apply.join(bare, { bare.hunks[1].new_lines })) + end) + + it("empties the file when every line goes", function() + local m = apply.ended(model("a\n", "", { h(1, { "a" }, 1, {}) })) + assert.are.equal("", apply.join(m, { {} })) + end) + -- "a\nb" and "a\nb\n": the two sides differ only in the ending it("tells apart two sides that hold the same line", function() local m = apply.ended(model("a\nb", "a\nb\n", { h(2, { "b" }, 2, { "b" }) })) diff --git a/test/unit/model_marks_spec.lua b/test/unit/model_marks_spec.lua index 92387be..299ca6f 100644 --- a/test/unit/model_marks_spec.lua +++ b/test/unit/model_marks_spec.lua @@ -302,6 +302,21 @@ describe("union marks", function() end) end) + -- past MAX_CELLS the block isn't compared line by line, and an uncompared hunk takes + -- the whole file to the pair diffs rather than marking half of it + it("gives no marks for a hunk too big to compare", function() + local old_lines, block = {}, {} + for i = 1, 1001 do + old_lines[i] = "line " .. i + block[i] = "line " .. i + end + block[1] = "changed" -- so the block isn't one whole side, which needs no compare + local union = { hl(1, old_lines, 1, { "one" }) } + local m, hidden = marks.of_blocks(union, { block }) + assert.is_nil(m) + assert.are.same({}, hidden) + end) + describe("a last line without its newline", function() local text = require("differ.util.text") local NO = text.NO_EOL From 16fb38f1fd288c445451238f0756d357133b356e Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:27:00 +0100 Subject: [PATCH 49/93] feat: leave the local view once its last hunk is staged --- lua/differ/git/init.lua | 18 ++++++++++++++++++ test/nvim/git_spec.lua | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 3442b51..80b142e 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -1285,6 +1285,24 @@ function M.panel(opts) show_local(entry) end) end + -- staging the last hunk empties "changes since staging", so there's nothing + -- left for this view to show and it hands back to the whole change + local stage_hunk = staging.apply + staging.apply = function(m, hunk, reverse) + if not (stage_hunk and stage_hunk(m, hunk, reverse)) then + return false + end + local state = require("differ.model.marks").state + for _, h in ipairs(model.hunks) do + if state(staging.marks, h) ~= "staged" then + return true + end + end + vim.schedule(function() + retarget_view(false) + end) + return true + end else if not model.binary then model.notice = empty_notice(root, entry, model, {}) or "No local content change" diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index cc9da89..ca21d26 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -2878,6 +2878,24 @@ func getDownloadSpeed() { assert.are.equal(committed(root, "a.lua"), index) end) + -- 1x staged, 12x not: staging the one local hunk leaves nothing since staging to show + it("s on the last hunk in the local view hands back to the whole change", function() + local root = staged_then(twelve({ "1x", [12] = "12x" })) + local p, v = local_view_at(1) + local hunks = #v.model.hunks + v:stage_hunk() + vim.wait(500, function() -- the hand-back runs on a schedule + return v.staging.badge ~= "LOCAL" + end) + local revs, badge = { v.model.old_rev, v.model.new_rev }, v.staging.badge + local index = indexed(root, "a.lua") + p:close() + assert.are.equal(1, hunks) + assert.are.same({ "HEAD", "WORKTREE" }, revs) + assert.is_nil(badge) + assert.are.equal(twelve({ "1x", [12] = "12x" }), index) + end) + -- 1x staged and then put back to 1 in the worktree: the local hunk undoing it is `!` it("u on a ! hunk in the local view drops the staged change it undoes", function() local root = staged_then(twelve({ [12] = "12x" })) From a50cf7f4ef3b122586715828a6b418760747587b Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:11:47 +0100 Subject: [PATCH 50/93] docs: name both surfaces for the dw and gs keys --- README.md | 4 ++-- doc/differ.txt | 4 ++-- lua/differ/config.lua | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 8ef0b47..52754d3 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ require("differ").setup({ -- diffs toggle_layout = "dl", -- flip between stacked / split - toggle_local = "dw", -- a partly staged file: its changes since staging and back + toggle_local = "dw", -- diff/panel: a partly staged file's changes since staging, and back more_context = "d=", less_context = "d-", stage = "s", unstage = "u", -- stage/unstage[all] work on hunks/files (diff buffer/panel) stage_all = "S", unstage_all = "U", @@ -174,7 +174,7 @@ require("differ").setup({ goto_file = "de", -- open the real file and close the session; in a pr it opens -- the real file in a new tab toggle_listing = "i", -- tree/flat list - commit_preview = "gs", -- panel: only the staged changes, each as HEAD↔index, and back + commit_preview = "gs", -- panel/diff: only the staged changes, each as HEAD↔index, and back close_node = "c", close_all = "C", open_all = "O", diff --git a/doc/differ.txt b/doc/differ.txt index e9138cf..ed32750 100644 --- a/doc/differ.txt +++ b/doc/differ.txt @@ -200,7 +200,7 @@ you’re in doubt, `:Differ sidecar` reports which one is actually running. -- diffs toggle_layout = "dl", -- flip between stacked / split - toggle_local = "dw", -- a partly staged file: its changes since staging and back + toggle_local = "dw", -- diff/panel: a partly staged file's changes since staging, and back more_context = "d=", less_context = "d-", stage = "s", unstage = "u", -- stage/unstage[all] work on hunks/files (diff buffer/panel) stage_all = "S", unstage_all = "U", @@ -209,7 +209,7 @@ you’re in doubt, `:Differ sidecar` reports which one is actually running. goto_file = "de", -- open the real file and close the session; in a pr it opens -- the real file in a new tab toggle_listing = "i", -- tree/flat list - commit_preview = "gs", -- panel: only the staged changes, each as HEAD↔index, and back + commit_preview = "gs", -- panel/diff: only the staged changes, each as HEAD↔index, and back close_node = "c", close_all = "C", open_all = "O", diff --git a/lua/differ/config.lua b/lua/differ/config.lua index 4b56596..5d1cbfe 100644 --- a/lua/differ/config.lua +++ b/lua/differ/config.lua @@ -163,7 +163,7 @@ M.defaults = { close = "dc", -- diff/panel/history: end the session (merge, pr or local) toggle_panel = "dd", -- diff/panel: hide/show the file panel sidebar toggle_layout = "dl", -- diff: flip stacked / split - toggle_local = "dw", -- diff: a partly staged file's changes since staging, and back + toggle_local = "dw", -- diff/panel: a partly staged file's changes since staging, and back -- 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 From 53bd24fcab47a539667758717d4b785c6ca47201 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:11:48 +0100 Subject: [PATCH 51/93] fix: discard a commit preview row by the letter the preview gives it --- lua/differ/git/init.lua | 13 +++++++++---- test/nvim/git_spec.lua | 25 +++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 80b142e..4daa399 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -820,8 +820,9 @@ end -- the file the entry came from is the side to re-read ---@param root string ---@param entry differ.FileEntry +---@param preview? boolean -- the entry is lettered by its index side, as the commit preview lists it ---@return string|nil status -local function live_status(root, entry) +local function live_status(root, entry, preview) local args = { "status", "--porcelain=v1", "-z", "-uall", "--", entry.path } -- git pairs a rename only with both paths in the pathspec; with one it reports an add if entry.previous_path then @@ -831,6 +832,9 @@ local function live_status(root, entry) for _, s in ipairs(rev.parse_status(out or "")) do -- a kept deletion's copy on disk is its own `??` line, and not this row's status if s.path == entry.path and not (s.x == "?" and entry.kept) then + if preview then + return s.x + end return select(2, row_of(s)) end end @@ -907,9 +911,10 @@ end -- no longer matches refuses rather than picking by the stale one ---@param root string ---@param entry differ.FileEntry +---@param preview? boolean -- the entry comes from the commit preview's listing ---@return boolean ok -function M.discard(root, entry) - if live_status(root, entry) ~= entry.status then +function M.discard(root, entry, preview) + if live_status(root, entry, preview) ~= entry.status then notify( ("discard skipped: %s changed since the prompt"):format(entry.path), vim.log.levels.WARN @@ -1148,7 +1153,7 @@ function M.panel(opts) M.unstage_all(root) end, discard = function(entry) - M.discard(root, entry) + M.discard(root, entry, preview) end, reload = function() local live = preview and M.staged_sections(root) or M.status_sections(root) diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index ca21d26..4d05ee3 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -3280,6 +3280,31 @@ func getDownloadSpeed() { assert.are.equal(before, after) end) + -- an MD row is lettered D in the whole list but M in the preview + it("discards an edit staged and then deleted from the commit preview panel", function() + local root = preview_repo() + os.remove(root .. "/a.lua") + vim.cmd.edit(root .. "/c.lua") -- a buffer on a file still on disk resolves the repo + local p = open_panel() + toggle_preview(p) + vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "a.lua"), 0 }) + _G.notifs = {} + confirming(1, function() + p:discard() + end) + local skipped = false + for _, n in ipairs(_G.notifs) do + skipped = skipped or n.msg:find("discard skipped", 1, true) ~= nil + end + local status = git(root, "status", "--porcelain=v1", "--", "a.lua") + local on_disk = vim.fn.filereadable(root .. "/a.lua") == 1 + local work = on_disk and worktree(root, "a.lua") or nil + p:close() + assert.is_false(skipped) + assert.are.equal("", status) + assert.are.equal(committed(root, "a.lua"), work) + end) + it("returns to the whole list once the commit preview is unstaged", function() preview_repo() local p = open_panel() From 4f78dc4cd7d18ebf6671efc0178928e9f8d8a12b Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:11:48 +0100 Subject: [PATCH 52/93] fix: discard an add, copy or rename whose new path is gone from disk --- lua/differ/git/init.lua | 15 ++++++++--- test/nvim/git_spec.lua | 58 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 4daa399..dbf2355 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -808,7 +808,8 @@ end ---@return boolean ok local function remove_ok(abs) local ok, err = os.remove(abs) - if not ok then + -- a file already gone from disk is as discarded as one removed here + if not ok and (vim.uv or vim.loop).fs_lstat(abs) then notify(("discard failed: %s"):format(err or ""), vim.log.levels.ERROR) return false end @@ -928,15 +929,21 @@ function M.discard(root, entry, preview) return false end local abs = root .. "/" .. entry.path + -- an add, copy or rename the worktree then deleted is lettered D in the whole list, + -- but its new path is unknown to HEAD, so it discards by what the index holds + local kind = entry.status + if entry.x == "A" or entry.x == "C" or entry.x == "R" then + kind = entry.x + end local ok - if entry.status == "?" then + if kind == "?" then ok = remove_ok(abs) - elseif entry.status == "A" or entry.status == "C" then + elseif kind == "A" or kind == "C" then -- unstage the add before dropping the file, and don't drop it if that failed: -- the index would keep an add for a file no longer on disk. a copy's source is -- untouched, so its new path is an add ok = git_ok({ "reset", "-q", "HEAD", "--", entry.path }, root, "discard") and remove_ok(abs) - elseif entry.status == "R" and entry.previous_path then + elseif kind == "R" and entry.previous_path then -- undoing a move: the old path comes back from HEAD, the new one goes like an add ok = git_ok({ "checkout", "HEAD", "--", entry.previous_path }, root, "discard") and git_ok({ "reset", "-q", "HEAD", "--", entry.path }, root, "discard") diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index 4d05ee3..f979884 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -3305,6 +3305,64 @@ func getDownloadSpeed() { assert.are.equal(committed(root, "a.lua"), work) end) + -- an RD: a.lua renamed to b.lua in the index, then b.lua deleted from disk. the row + -- is lettered D in the whole list and R in the preview + local function renamed_then_deleted() + local root = fresh_repo() + write(root .. "/z.lua", "z\n") + git(root, "add", "z.lua") + git(root, "commit", "-q", "-m", "z") + git(root, "mv", "a.lua", "b.lua") + os.remove(root .. "/b.lua") + vim.cmd.edit(root .. "/z.lua") + return root + end + ---@param root string + ---@param preview boolean + ---@return { complaints: string[], status: string, a: string|nil, b_on_disk: boolean } + local function discard_renamed_then_deleted(root, preview) + local p = open_panel() + if preview then + toggle_preview(p) + end + vim.api.nvim_win_set_cursor(p.winid, { file_line(p, "b.lua"), 0 }) + _G.notifs = {} + confirming(1, function() + p:discard() + end) + local out = { complaints = {} } + for _, n in ipairs(_G.notifs) do + if n.msg:find("discard", 1, true) then + out.complaints[#out.complaints + 1] = n.msg + end + end + out.status = git(root, "status", "--porcelain=v1") + if vim.fn.filereadable(root .. "/a.lua") == 1 then + out.a = worktree(root, "a.lua") + end + out.b_on_disk = vim.fn.filereadable(root .. "/b.lua") == 1 + p:close() + return out + end + + it("discards a staged rename whose new path was deleted, from the whole list", function() + local root = renamed_then_deleted() + local got = discard_renamed_then_deleted(root, false) + assert.are.equal("", got.status) + assert.are.equal(committed(root, "a.lua"), got.a) + assert.is_false(got.b_on_disk) + assert.are.same({}, got.complaints) + end) + + it("discards a staged rename whose new path was deleted, from the commit preview", function() + local root = renamed_then_deleted() + local got = discard_renamed_then_deleted(root, true) + assert.are.equal("", got.status) + assert.are.equal(committed(root, "a.lua"), got.a) + assert.is_false(got.b_on_disk) + assert.are.same({}, got.complaints) + end) + it("returns to the whole list once the commit preview is unstaged", function() preview_repo() local p = open_panel() From 19c598218fd4b08c2142ae192fd2832210dad7c0 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:11:48 +0100 Subject: [PATCH 53/93] fix: stop cutting an index into hunk blocks past a step limit --- lua/differ/model/marks.lua | 53 ++++++++++++++++++++++++++++------ test/unit/model_marks_spec.lua | 17 +++++++++++ 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/lua/differ/model/marks.lua b/lua/differ/model/marks.lua index 357c061..bf9075e 100644 --- a/lua/differ/model/marks.lua +++ b/lua/differ/model/marks.lua @@ -102,7 +102,11 @@ local function holds(index, at, lines) return true end ----@alias differ.model.BlockLengths fun(h: differ.Hunk, index: string[], at: integer): integer[] +-- the block lengths worth trying for hunk `h` at index line `at`. `next_line` is the +-- HEAD line the block has to be followed by (nil at the end of HEAD), and `where` maps +-- each index line to the lines it sits at, ascending +---@alias differ.model.BlockLengths +---| fun(h: differ.Hunk, index: string[], at: integer, next_line?: string, where: table): integer[] -- lengths for a block that is exactly one of the hunk's sides ---@type differ.model.BlockLengths @@ -117,18 +121,37 @@ local function whole_lengths(h, index, at) return out end --- every length the rest of the index allows, shortest first +-- every length that ends where `next_line` sits, shortest first, or the rest of the +-- index when nothing follows ---@type differ.model.BlockLengths -local function any_lengths(_, index, at) +local function any_lengths(_, index, at, next_line, where) + if next_line == nil then + return { #index - at + 1 } + end + local lines = where[next_line] or {} + local lo, hi = 1, #lines + 1 -- the first entry at or after `at` + while lo < hi do + local mid = math.floor((lo + hi) / 2) + if lines[mid] < at then + lo = mid + 1 + else + hi = mid + end + end local out = {} - for n = 0, #index - at + 1 do - out[#out + 1] = n + for k = lo, #lines do + out[#out + 1] = lines[k] - at end return out end +-- lengths tried and lines compared past which a cut is reported as ambiguous. a file of +-- mostly repeated lines leaves any_lengths a length to try at nearly every line +local MAX_STEPS = 1e6 + -- the ways to cut the index into a block per union hunk, with HEAD's unchanged lines --- between them: the first cut, and how many there are (0, 1, or 2 for two or more) +-- between them: the cut when there is only one, and how many there are (0, 1, or 2 for +-- two or more, or for a search past MAX_STEPS) ---@param union differ.Hunk[] ---@param head string[] ---@param index string[] @@ -137,6 +160,12 @@ end local function cut(union, head, index, lengths) local ways = {} ---@type table -- "hunk:index line" -> ways through local picks = {} ---@type table -- "hunk:index line" -> first block's {at, n} + local where = {} ---@type table + for l, line in ipairs(index) do + where[line] = where[line] or {} + table.insert(where[line], l) + end + local steps = 0 ---@param i integer -- the next union hunk ---@param from integer -- the next HEAD line ---@param at integer -- the next index line @@ -152,6 +181,7 @@ local function cut(union, head, index, lengths) first, after = old_bounds(h) end for l = from, first - 1 do + steps = steps + 1 if index[at] ~= head[l] then ways[key] = 0 return 0 @@ -166,7 +196,12 @@ local function cut(union, head, index, lengths) return ways[key] end local total = 0 - for _, n in ipairs(lengths(h, index, at)) do + local tried = lengths(h, index, at, head[after], where) + steps = steps + #tried + if steps > MAX_STEPS then + return 2 + end + for _, n in ipairs(tried) do -- the unchanged line after the block, when there is one, has to come next local fits = after > #head or index[at + n] == head[after] local through = 0 @@ -186,8 +221,8 @@ local function cut(union, head, index, lengths) end local total = count(1, 1, 1) local out, at = {}, 1 - if total == 0 then - return out, 0 + if total ~= 1 then + return out, total -- a cut that isn't the only one is never read end for i in ipairs(union) do local pick = picks[i .. ":" .. at] diff --git a/test/unit/model_marks_spec.lua b/test/unit/model_marks_spec.lua index 299ca6f..85999a7 100644 --- a/test/unit/model_marks_spec.lua +++ b/test/unit/model_marks_spec.lua @@ -257,6 +257,23 @@ describe("union marks", function() local index = { "a", "b", "c", "D", "e", "f" } assert.are.same({ { "b" }, { "D", "e", "f" } }, marks.blocks(union, head, index)) end) + + -- a blank file with a line rewritten as two every 25 lines, and the index holding + -- one of the two: nearly every blank is a place the block could end + it("gives nil rather than search a mostly blank file for long", function() + local blank_head, blank_index, spread = {}, {}, {} + for l = 1, 2000 do + local line = "" + if l % 25 == 0 then + line = "old" .. l + local n = #spread + spread[n + 1] = hl(l, { line }, l + n, { "x" .. l, "y" .. l }) + blank_index[l] = "x" .. l + end + blank_head[l], blank_index[l] = line, blank_index[l] or line + end + assert.is_nil(marks.blocks(spread, blank_head, blank_index)) + end) end) describe("of_blocks", function() From 476be865f824d60b26431a8dd95598492242dc1a Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:11:48 +0100 Subject: [PATCH 54/93] test: pin a partial block's shared line as HEAD's --- test/unit/model_marks_spec.lua | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/unit/model_marks_spec.lua b/test/unit/model_marks_spec.lua index 85999a7..184d69e 100644 --- a/test/unit/model_marks_spec.lua +++ b/test/unit/model_marks_spec.lua @@ -317,6 +317,17 @@ describe("union marks", function() assert.are.same({ "unstaged" }, states(m, moved)) assert.are.same({}, hidden) end) + + -- HEAD x a, worktree a y: a block of a alone is x dropped from HEAD, or the + -- worktree's a with y still to come. both describe the same index + it("reads a partial block's shared line as HEAD's", function() + local shared = { hl(1, { "x", "a" }, 1, { "a", "y" }) } + local m, hidden = marks.of_blocks(shared, { { "a" } }) + assert.are.same({ "partial" }, states(m, shared)) + assert.are.same({ [1] = true, [2] = false }, m.old) + assert.are.same({ [1] = false, [2] = false }, m.new) + assert.are.same({}, hidden) + end) end) -- past MAX_CELLS the block isn't compared line by line, and an uncompared hunk takes From bebbdfe273dd7baa0649d4129953a7ebc5fac281 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:11:48 +0100 Subject: [PATCH 55/93] test: return the prompt from confirming and drop under_confirm --- test/nvim/git_spec.lua | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index f979884..d4c8ddc 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -64,15 +64,19 @@ local function open_panel() return p, view_in_origin(p) end --- run `fn` with vim.fn.confirm answering `choice`: 1 yes, 2 no +-- run `fn` with vim.fn.confirm answering `choice` (1 yes, 2 no), returning the prompt +-- it was shown +---@return string|nil prompt local function confirming(choice, fn) - local orig = vim.fn.confirm - vim.fn.confirm = function() + 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 describe("git.read / changed_files", function() @@ -5658,19 +5662,6 @@ describe(":Differ diff rename staging", function() p:close() end) - -- run `fn` with confirm answering `answer`, returning the prompt it was shown - local function under_confirm(answer, fn) - local orig, prompt = vim.fn.confirm, nil - vim.fn.confirm = function(msg) - prompt = msg - return answer - end - local okay, err = pcall(fn) - vim.fn.confirm = orig - assert(okay, err) - return prompt - end - it("undoes a rename with X, restoring the old path and dropping the new", function() local root = renamed_repo({ 5, 30 }) vim.cmd.edit(root .. "/new/big.lua") @@ -5679,7 +5670,7 @@ describe(":Differ diff rename staging", function() local p = Panel.current() assert.is_true(p:focus_file("new/big.lua")) - under_confirm(1, function() + confirming(1, function() p:discard() end) @@ -5698,7 +5689,7 @@ describe(":Differ diff rename staging", function() local p = Panel.current() assert.is_true(p:focus_file("new/big.lua")) - local prompt = under_confirm(2, function() -- answer No: nothing is discarded + local prompt = confirming(2, function() -- answer No: nothing is discarded p:discard() end) From c55059318921cd4f5a578c2d9004790cb3c04a3f Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:57:43 +0100 Subject: [PATCH 56/93] fix: unstage both ends of a staged rename whose new path is gone --- lua/differ/git/index.lua | 4 ++-- test/nvim/git_spec.lua | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/lua/differ/git/index.lua b/lua/differ/git/index.lua index a6d58ed..f80b898 100644 --- a/lua/differ/git/index.lua +++ b/lua/differ/git/index.lua @@ -59,11 +59,11 @@ function M.write_index(root, path, text) end -- the paths one entry's staging ops act on: a rename owns both ends of the move, a copy --- only its new path +-- only its new path. a staged rename the worktree then deleted is lettered D ---@param entry differ.FileEntry ---@return string[] function M.entry_paths(entry) - if entry.status == "R" and entry.previous_path then + if (entry.status == "R" or entry.x == "R") and entry.previous_path then return { entry.path, entry.previous_path } end return { entry.path } diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index d4c8ddc..dade468 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -3358,6 +3358,16 @@ func getDownloadSpeed() { assert.are.same({}, got.complaints) end) + it("unstages both ends of a staged rename whose new path was deleted", function() + local root = renamed_then_deleted() + local p = open_panel() + assert.is_true(p:goto_path("b.lua", true)) + view_in_origin(p):unstage_hunk() + local status = git(root, "status", "--porcelain=v1") + p:close() + assert.are.equal(" D a.lua\n", status) + end) + it("discards a staged rename whose new path was deleted, from the commit preview", function() local root = renamed_then_deleted() local got = discard_renamed_then_deleted(root, true) From 37fd281e4a550fcb065de6dbe9af166b9d7be69b Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:57:43 +0100 Subject: [PATCH 57/93] fix: draw a staged add whose file was deleted as HEAD-to-index --- lua/differ/git/init.lua | 11 ++++++++--- test/nvim/git_spec.lua | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index dbf2355..259dda0 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -1330,12 +1330,17 @@ function M.panel(opts) last_sig = git_signature() end - -- a row whose staged change the worktree has put back: HEAD↔worktree is empty, so - -- the row draws HEAD↔index instead, where the change it still has is + -- a row whose staged change the worktree has put back, or a staged add whose file is + -- gone: HEAD↔worktree is empty, so the row draws HEAD↔index instead, where the + -- change it still has is ---@param entry differ.FileEntry ---@return differ.DiffModel|nil local function staged_model(entry) - if preview or not staging_ops.partly_staged(entry) then + if preview then + return nil + end + local gone = entry.x == "A" and entry.y == "D" + if not (gone or staging_ops.partly_staged(entry)) then return nil end local file = { diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index dade468..7a2b268 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -3368,6 +3368,27 @@ func getDownloadSpeed() { assert.are.equal(" D a.lua\n", status) end) + -- an AD: HEAD and the worktree both lack c.lua, so only the index shows the add + it("draws a staged add whose file was deleted as HEAD-to-index", function() + local root = fresh_repo() + write(root .. "/c.lua", "one\n") + git(root, "add", "c.lua") + os.remove(root .. "/c.lua") + vim.cmd.edit(root .. "/a.lua") + local p = open_panel() + assert.is_true(p:goto_path("c.lua", true)) + local v = view_in_origin(p) + local revs, hunks, badge = + { v.model.old_rev, v.model.new_rev }, #v.model.hunks, v.staging.badge + v:unstage_hunk() + local status = git(root, "status", "--porcelain=v1") + p:close() + assert.are.same({ "HEAD", "INDEX" }, revs) + assert.are.equal(1, hunks) + assert.are.equal("STAGED", badge) + assert.are.equal("", status) + end) + it("discards a staged rename whose new path was deleted, from the commit preview", function() local root = renamed_then_deleted() local got = discard_renamed_then_deleted(root, true) From bd2aa64fa0684bfa6be5e1ea00b8023310a02f1a Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:57:43 +0100 Subject: [PATCH 58/93] fix: take the mode of the side S and U stage --- lua/differ/git/index.lua | 15 +++++++++------ lua/differ/git/staging.lua | 38 +++++++++++++++++++++++++++++-------- test/nvim/git_spec.lua | 39 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 14 deletions(-) diff --git a/lua/differ/git/index.lua b/lua/differ/git/index.lua index f80b898..abe7a1f 100644 --- a/lua/differ/git/index.lua +++ b/lua/differ/git/index.lua @@ -32,20 +32,23 @@ function M.unstage_all(root) return git_ok({ "reset", "-q", "HEAD" }, root, "unstage all") end --- point `path`'s index entry at `text`, keeping its mode: a blob write and an entry --- move, so there's no header to compute and nothing that can half-apply. `text` must --- already be in index domain (M.read runs the worktree side through the clean filter) +-- point `path`'s index entry at `text`: a blob write and an entry move, so there's no +-- header to compute and nothing that can half-apply. `text` must already be in index +-- domain (M.read runs the worktree side through the clean filter). the entry keeps the +-- mode it has unless `mode` names another ---@param root string ---@param path string ---@param text string +---@param mode? string ---@return boolean ok -function M.write_index(root, path, text) +function M.write_index(root, path, text, mode) local listed = git({ "ls-files", "-s", "--", path }, root) - local mode = listed and listed:match("^(%d+)%s") - if not mode then + local held = listed and listed:match("^(%d+)%s") + if not held then notify(("%s has no index entry to update"):format(path), vim.log.levels.ERROR) return false end + mode = mode or held local hashed = vim.system({ "git", "hash-object", "-w", "--stdin" }, { cwd = root, stdin = text, diff --git a/lua/differ/git/staging.lua b/lua/differ/git/staging.lua index ab5dade..67bbb9d 100644 --- a/lua/differ/git/staging.lua +++ b/lua/differ/git/staging.lua @@ -32,8 +32,9 @@ function M.new(ctx) -- the index, and a worktree rename stages with its content; both move the row ---@param entry differ.FileEntry ---@param text string + ---@param mode? string -- the mode the entry takes; nil keeps the one it has ---@return boolean ok - local function put_index(entry, text) + local function put_index(entry, text, mode) if entry.x == "A" and text == "" then if not gitmod.unstage(root, entry.path) then return false @@ -43,7 +44,7 @@ function M.new(ctx) end) return true end - if not write_index(root, entry.path, text) then + if not write_index(root, entry.path, text, mode) then return false end if entry.y == "R" and entry.previous_path then @@ -58,6 +59,25 @@ function M.new(ctx) return true end + -- the mode of the side S (the worktree) or U (HEAD) takes, or nil when the index is + -- already there. a `diff --raw` line reads `: ...` + ---@param entry differ.FileEntry + ---@param staged boolean + ---@return string|nil + local function side_mode(entry, staged) + local line = gitmod.raw_line(root, staged and {} or { "--cached" }, entry) + if not line then + return nil + end + local index_mode, work_mode = rev.parse_raw_modes(line) + local want = staged and work_mode or index_mode + local held = staged and index_mode or work_mode + if not want or want == held or want:match("^0+$") then + return nil + end + return want + end + -- whether the index holds a mode change the HEAD↔worktree diff can't show: staged, -- then put back in the worktree ---@param entry differ.FileEntry @@ -249,8 +269,9 @@ function M.new(ctx) remark(gitmod.union_pairs(root, entry.path, union.old_text, text, work)) return true end - -- S and U: the index takes the worktree's or HEAD's text whole, staged content no - -- hunk shows included. the entry's mode is left as it is + -- S and U: the index takes the worktree's or HEAD's side of the file whole, + -- staged content no hunk shows and the mode included. the move a rename made + -- stays staged either way; the panel row's keys own that staging.set_all = function(model, staged) local union, cached = gitmod.union_models(root, entry) if not (union and cached) then @@ -259,11 +280,12 @@ function M.new(ctx) if not drawn_current(model, union) then return false end - local text = union.old_text - if staged then - text = union.new_text + local text = staged and union.new_text or union.old_text + local mode = side_mode(entry, staged) + if text == cached.new_text and not mode then + return false end - if text == cached.new_text or not put_index(entry, text) then + if not put_index(entry, text, mode) then return false end remark(gitmod.union_pairs(root, entry.path, union.old_text, text, union.new_text)) diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index 7a2b268..ff571ac 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -2305,6 +2305,45 @@ func getDownloadSpeed() { assert.is_nil(hidden) end) + -- +x staged over HEAD's 644: no hunk shows the mode, and S and U still land the + -- index on the mode of the side they take + local function with_staged_mode(work_exec) + local root, p, v = staged_as(JOINED.head, JOINED.index, JOINED.work) + local sha = git(root, "rev-parse", ":0:a.lua"):gsub("%s+$", "") + git(root, "update-index", "--cacheinfo", "100755," .. sha .. ",a.lua") + if work_exec then + vim.fn.system({ "chmod", "+x", root .. "/a.lua" }) + end + return root, p, v + end + local function index_mode(root) + return git(root, "ls-files", "-s", "--", "a.lua"):match("^(%d+)") + end + + it("S takes the worktree's mode", function() + local root, p, v = with_staged_mode(false) + v:stage_all() + local mode = index_mode(root) + p:close() + assert.are.equal("100644", mode) + end) + + it("S keeps a mode the worktree has too", function() + local root, p, v = with_staged_mode(true) + v:stage_all() + local mode = index_mode(root) + p:close() + assert.are.equal("100755", mode) + end) + + it("U takes HEAD's mode", function() + local root, p, v = with_staged_mode(true) + v:unstage_all() + local mode = index_mode(root) + p:close() + assert.are.equal("100644", mode) + end) + -- 5x staged and then put back to 5 on disk: no hunk shows it, U still takes it out it("U drops staged content no hunk shows", function() local ten = {} From 54cba47aba7a99ec8cb2bc060193f434f8e518f4 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:22:29 +0100 Subject: [PATCH 59/93] fix: badge the HEAD-to-index fallback INDEX and shorten the winbar notes --- lua/differ/git/init.lua | 4 ++-- lua/differ/git/staging.lua | 3 ++- lua/differ/ui/winbar.lua | 8 ++++---- lua/differ/view.lua | 3 ++- test/nvim/git_spec.lua | 10 +++++----- 5 files changed, 15 insertions(+), 13 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 259dda0..2127a0e 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -1382,9 +1382,9 @@ function M.panel(opts) model.notice = notice end if entry.status == "U" then - model.banner = "conflicted: resolve with :Differ mergetool" + model.banner = "conflicted (:Differ mergetool)" elseif entry.kept then - model.banner = "still on disk, untracked: u tracks it again" + model.banner = "still on disk (u)" end local staging ---@type differ.view.Staging|nil if preview then diff --git a/lua/differ/git/staging.lua b/lua/differ/git/staging.lua index 67bbb9d..b8229e7 100644 --- a/lua/differ/git/staging.lua +++ b/lua/differ/git/staging.lua @@ -535,7 +535,7 @@ function M.new(ctx) staging = snapshot_staging(entry) staging.revert, staging.revert_label = whole_file_revert(entry, entry.x) end - staging.badge = "STAGED" + staging.badge = "INDEX" return staging end @@ -545,6 +545,7 @@ function M.new(ctx) ---@return differ.view.Staging local function preview_staging(entry, model) local staging = staged_staging(entry, model) + staging.badge = "STAGED" staging.no_local = "the commit preview has no local view: gs goes back" staging.leave = function() ctx.preview_off() diff --git a/lua/differ/ui/winbar.lua b/lua/differ/ui/winbar.lua index 5e77682..fea8ab1 100644 --- a/lua/differ/ui/winbar.lua +++ b/lua/differ/ui/winbar.lua @@ -104,8 +104,8 @@ function M.tally(view) return out end --- the note on staged content the view can't show: how many hunks it sits in, else that --- it sits outside them, plus the key to the local view where there is one +-- the note on staged content the view can't show: how many hunks it sits in, and the +-- key to the local view where there is one ---@param view differ.View ---@return string|nil function M.hidden_note(view) @@ -114,12 +114,12 @@ function M.hidden_note(view) return nil end local where = staging.hidden_in or {} - local text = #where > 0 and ("%d hidden"):format(#where) or "staged content hidden" + local text = #where > 0 and ("%d hidden"):format(#where) or "hidden" local key = staging.toggle_local and require("differ.ui.help").fmt(view.keymaps.toggle_local) if not key then return text end - return ("%s: %s shows it"):format(text, key) + return ("%s (%s)"):format(text, key) end -- panel winbar: a bar plus "file K/N" for the cursor's position in the file list diff --git a/lua/differ/view.lua b/lua/differ/view.lua index 6dc47f3..d5eb612 100644 --- a/lua/differ/view.lua +++ b/lua/differ/view.lua @@ -913,7 +913,8 @@ function View:show_help() rows[#rows + 1] = { fmt(km.discard), "revert hunk (confirm)" } end if self.staging and self.staging.toggle_local then - local what = self.staging.badge and "back to the whole change" or "changes since staging" + local what = self.staging.badge == "LOCAL" and "back to the whole change" + or "changes since staging" rows[#rows + 1] = { fmt(km.toggle_local), what } end if self.commit_preview then diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index ff571ac..c465f22 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -2696,7 +2696,7 @@ func getDownloadSpeed() { v:toggle_local() local local_gutter = gutter_of(v, hunk_line(v, 1)) p:close() - assert.is_truthy(text:find("1 hidden: dw shows it", 1, true)) + assert.is_truthy(text:find("1 hidden (dw)", 1, true)) assert.is_truthy(gutter:find("!", 1, true)) -- the hunk it's hidden in assert.is_truthy(local_gutter:find("!", 1, true)) -- the local hunk that rewrote it end) @@ -2712,7 +2712,7 @@ func getDownloadSpeed() { local p = open_panel() local text = winbar_of(view_in_origin(p)) p:close() - assert.is_truthy(text:find("staged content hidden: dw shows it", 1, true)) + assert.is_truthy(text:find("hidden (dw)", 1, true)) end) it("names a kept deletion's copy on disk in the winbar", function() @@ -2729,7 +2729,7 @@ func getDownloadSpeed() { local new_rev, text = v.model.new_rev, winbar_of(v) p:close() assert.are.equal("INDEX", new_rev) -- the removal, not the copy on disk - assert.is_truthy(text:find("still on disk, untracked", 1, true)) + assert.is_truthy(text:find("still on disk (u)", 1, true)) end) -- git reset on an added file drops it from the index, and u taking the last staged @@ -2916,7 +2916,7 @@ func getDownloadSpeed() { assert.are.same({ "HEAD", "INDEX" }, revs) assert.are.equal(1, hunks) assert.is_nil(notice) - assert.are.equal("STAGED", badge) + assert.are.equal("INDEX", badge) assert.are.equal("staged", state) assert.are.equal(committed(root, "a.lua"), index) end) @@ -3424,7 +3424,7 @@ func getDownloadSpeed() { p:close() assert.are.same({ "HEAD", "INDEX" }, revs) assert.are.equal(1, hunks) - assert.are.equal("STAGED", badge) + assert.are.equal("INDEX", badge) assert.are.equal("", status) end) From 7b3294efd06cddcdb9c45e25d9a0f1c9d04d9a6e Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:28:50 +0100 Subject: [PATCH 60/93] fix: describe a kept deletion's copy on disk instead of pointing at u --- lua/differ/git/index.lua | 2 +- lua/differ/git/init.lua | 4 ++-- test/nvim/git_spec.lua | 4 +++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lua/differ/git/index.lua b/lua/differ/git/index.lua index abe7a1f..ca4f113 100644 --- a/lua/differ/git/index.lua +++ b/lua/differ/git/index.lua @@ -109,7 +109,7 @@ end ---@return boolean function M.restore_deleted(root, entry) if entry.kept then - local msg = "%s is on disk untracked; u tracks it again" + local msg = "X would overwrite the untracked copy of %s on disk" notify(msg:format(entry.path), vim.log.levels.WARN) return false end diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 2127a0e..844991c 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -924,7 +924,7 @@ function M.discard(root, entry, preview) end -- restoring a staged deletion from HEAD would overwrite the copy still on disk if entry.kept then - local msg = "discard skipped: %s is on disk untracked; u tracks it again" + local msg = "discard skipped: X would overwrite the untracked copy of %s on disk" notify(msg:format(entry.path), vim.log.levels.WARN) return false end @@ -1384,7 +1384,7 @@ function M.panel(opts) if entry.status == "U" then model.banner = "conflicted (:Differ mergetool)" elseif entry.kept then - model.banner = "still on disk (u)" + model.banner = "untracked copy on disk" end local staging ---@type differ.view.Staging|nil if preview then diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index c465f22..d7a397e 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -611,7 +611,9 @@ describe("git.status_sections", function() for _, sec in ipairs(git_src.status_sections(root)) do entry = entry or sec.entries[1] end + _G.notifs = {} assert.is_false(git_src.discard(root, entry)) + assert.is_truthy(_G.notifs[#_G.notifs].msg:find("untracked copy of a.lua", 1, true)) assert.are.equal("mine\n", table.concat(vim.fn.readfile(root .. "/a.lua"), "\n") .. "\n") end) @@ -2729,7 +2731,7 @@ func getDownloadSpeed() { local new_rev, text = v.model.new_rev, winbar_of(v) p:close() assert.are.equal("INDEX", new_rev) -- the removal, not the copy on disk - assert.is_truthy(text:find("still on disk (u)", 1, true)) + assert.is_truthy(text:find("untracked copy on disk", 1, true)) end) -- git reset on an added file drops it from the index, and u taking the last staged From 1ff97c1f7a5f820c0b7b8f1bdf1211727debfcc6 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:17:21 +0100 Subject: [PATCH 61/93] fix: stage a staged rename's old path without git add refusing it --- lua/differ/git/index.lua | 11 ++++++++--- test/nvim/git_spec.lua | 22 ++++++++++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/lua/differ/git/index.lua b/lua/differ/git/index.lua index ca4f113..054598f 100644 --- a/lua/differ/git/index.lua +++ b/lua/differ/git/index.lua @@ -81,10 +81,15 @@ end function M.set_staged(root, entry, staged) local ok = true for _, p in ipairs(M.entry_paths(entry)) do - if staged then - ok = M.stage(root, p) and ok - else + if not staged then ok = M.unstage(root, p) and ok + elseif p == entry.previous_path then + -- a staged move's old path is in neither the index nor the worktree, which + -- `git add` refuses as a pathspec that matches nothing + local drop = { "rm", "-q", "--cached", "--ignore-unmatch", "--", p } + ok = git_ok(drop, root, "stage") and ok + else + ok = M.stage(root, p) and ok end end return ok diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index d7a397e..17084d7 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -5718,6 +5718,28 @@ describe(":Differ diff rename staging", function() p:close() end) + -- an RM: the old path is in neither the index nor the worktree, so git add refuses it + it("stages the rest of a staged rename from the panel row without an error", function() + local root = renamed_repo({ 5 }) + local text = table.concat(vim.fn.readfile(root .. "/new/big.lua"), "\n") .. "\n" + write(root .. "/new/big.lua", (text:gsub("line 30\n", "line 30 unstaged\n"))) + vim.cmd.edit(root .. "/new/big.lua") + + git_src.panel({ rev = {} }) + local p = Panel.current() + assert.is_true(p:focus_file("new/big.lua")) + _G.notifs = {} + p:stage_op("stage") + local failed = false + for _, n in ipairs(_G.notifs) do + failed = failed or n.msg:find("failed", 1, true) ~= nil + end + local status = git(root, "status", "--porcelain=v1") + p:close() + assert.is_false(failed) + assert.are.equal("R old/big.lua -> new/big.lua\n", status) + end) + it("unstages both of a rename's paths from the panel row, as the diff keys do", function() local root = renamed_repo({ 5 }) vim.cmd.edit(root .. "/new/big.lua") From 6512a2c9f7bba50d910e8ded5871e3161bb49047 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:59:36 +0100 Subject: [PATCH 62/93] fix: walk s and u through the list once and end where nothing is left --- lua/differ/git/init.lua | 55 ++++++++++++-- lua/differ/panel/init.lua | 152 ++++++++++++++++++++++++++++---------- lua/differ/view.lua | 46 ++++++++++-- test/nvim/git_spec.lua | 71 ++++++++++++++++++ 4 files changed, 271 insertions(+), 53 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 844991c..aef76f8 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -1205,6 +1205,19 @@ function M.panel(opts) -- refreshes act only when it moved, so a stray event doesn't re-source over an -- in-progress in-differ staging session, and a differ stage records it so the index -- write it caused doesn't read as outside + -- one row's part of it: its index blobs, and its file's mtime and size. a partial + -- stage that leaves the row MM moves only the blob + ---@param entry differ.FileEntry + ---@return string + local function row_signature(entry) + local blobs = { "ls-files", "-s", "--", entry.path, entry.previous_path } + local sig = git(blobs, root) or "" + local st = (vim.uv or vim.loop).fs_stat(root .. "/" .. entry.path) + if st and st.mtime then + sig = sig .. "\0" .. st.mtime.sec .. "." .. st.mtime.nsec .. ":" .. st.size + end + return sig + end local function git_signature() if not stageable then return "" @@ -1213,16 +1226,43 @@ function M.panel(opts) .. "\0" .. (git({ "status", "--porcelain=v1", "-z", "-uall" }, root) or "") if active_entry then - -- a partial stage that leaves the row MM moves only the blob - local blobs = { "ls-files", "-s", "--", active_entry.path, active_entry.previous_path } - sig = sig .. "\0" .. (git(blobs, root) or "") - local st = (vim.uv or vim.loop).fs_stat(root .. "/" .. active_entry.path) - if st and st.mtime then - sig = sig .. "\0" .. st.mtime.sec .. "." .. st.mtime.nsec .. ":" .. st.size - end + sig = sig .. "\0" .. row_signature(active_entry) end return sig end + + -- rows the review walk stepped off with nothing left for its key, by walk (false: s) + -- then path, each with HEAD and the row's signature as they were. any change to + -- either puts the row back in the walk + local passed = { [false] = {}, [true] = {} } ---@type table> + ---@param entry differ.FileEntry + ---@return string + local function passed_key(entry) + return (git({ "rev-parse", "HEAD" }, root) or "") .. "\0" .. row_signature(entry) + end + ---@type differ.panel.ReviewHooks + local review_hooks = { + accept = function(entry, staged) + -- the preview and the local view aren't the whole change: what they show done + -- says nothing about the row + local badge = view and view.staging and view.staging.badge + if preview or badge == "LOCAL" then + return + end + passed[staged][entry.path] = passed_key(entry) + end, + accepted = function(entry, staged) + local was = passed[staged][entry.path] + if not was then + return false + end + if was == passed_key(entry) then + return true + end + passed[staged][entry.path] = nil + return false + end, + } local last_sig = git_signature() -- record the state the list now reflects, so the next external event doesn't read an -- in-differ op as an outside change and re-source over the in-place staged marks. @@ -1544,6 +1584,7 @@ function M.panel(opts) end end, keymaps = cfg.keymaps.panel --[[@as differ.KeymapSet]], + review = stageable and review_hooks or nil, extra_keymaps = stageable and { { spec = panel_keys.commit_preview, diff --git a/lua/differ/panel/init.lua b/lua/differ/panel/init.lua index 1d8f564..ae362ad 100644 --- a/lua/differ/panel/init.lua +++ b/lua/differ/panel/init.lua @@ -91,6 +91,8 @@ local STATUS_HL = { ---@field extra_keymaps differ.panel.ExtraMap[]|nil -- fired after a ]f/[f step, for an optional session hook (the pr forward-auto-mark) ---@field on_step fun(direction: "next"|"prev", left: differ.FileEntry|nil, new: differ.FileEntry)|nil +---@field review differ.panel.ReviewHooks|nil +---@field walk_order string[]|nil -- the file rows' keys, in list order, when the open file opened ---@field icon_for nil|fun(path: string): string|nil, string|nil ---@field position string ---@field height integer @@ -133,6 +135,7 @@ Panel.__index = Panel ---@field keymaps? table -- resolved panel action -> lhs ---@field extra_keymaps? differ.panel.ExtraMap[] -- session maps (pr viewed nav) ---@field on_step? fun(direction: "next"|"prev", left: differ.FileEntry|nil, new: differ.FileEntry) +---@field review? differ.panel.ReviewHooks -- the staging walk's memory of rows passed as they are ---@field icons? boolean -- filetype devicons (default true when available) ---@field listing? "tree"|"name" ---@field position? "bottom"|"top"|"left"|"right" @@ -140,6 +143,12 @@ Panel.__index = Panel ---@field width? integer ---@field progress? boolean -- file-position meter in the panel winbar (default on) +-- rows the review walk has stepped off with nothing left for its key, remembered by the +-- session until the file or its index entry changes. `staged` is the walk: false for s +---@class differ.panel.ReviewHooks +---@field accept fun(entry: differ.FileEntry, staged: boolean) +---@field accepted fun(entry: differ.FileEntry, staged: boolean): boolean + ---@class differ.panel.ExtraMap ---@field spec string|string[]|false -- resolved lhs (a keymaps value) ---@field fn fun() @@ -178,6 +187,7 @@ function Panel.new(opts) actions = opts.actions, extra_keymaps = opts.extra_keymaps, on_step = opts.on_step, + review = opts.review, keymaps = vim.tbl_extend( "force", require("differ.config").defaults.keymaps, @@ -647,6 +657,7 @@ function Panel:_open(entry, keep_focus) -- every selection path sets selected_row then lands here, so this is the one -- place the opened file's identity has to be recorded for render to re-derive it self.selected_key = entry_key(entry) + self.walk_order = self:_file_keys() end if not keep_focus and self.winid and vim.api.nvim_win_is_valid(self.winid) then vim.api.nvim_set_current_win(self.winid) @@ -979,34 +990,111 @@ local function has_review_work(e, staged) return has_unstaged(e) end --- the review flow's file step: the nearest row in `direction` with something left to do, --- wrapping past the list ends. the open file is skipped, since re-opening it would +-- every file row's key, in list order +---@return string[] +function Panel:_file_keys() + local out = {} + for _, m in ipairs(self.meta) do + if m.kind == "file" then + out[#out + 1] = entry_key(m.entry) + end + end + return out +end + +-- the keys the walk tries from the open file, in `direction`: the rest of the list as +-- it stood when the file opened, then round from the other end, then any file listed +-- since. staging moves a finished file up into Staged, and stepping from where it +-- landed would restart the walk at the top. the second value is where the wrap starts +---@param direction "next"|"prev" +---@return string[] keys, integer wrap_at +function Panel:_walk_keys(direction) + local order = self.walk_order or self:_file_keys() + local at = 0 + for i, key in ipairs(order) do + if key == self.selected_key then + at = i + end + end + local out, seen = {}, {} + local function add(key) + if not seen[key] then + seen[key] = true + out[#out + 1] = key + end + end + local n, step = #order, direction == "prev" and -1 or 1 + local ahead = direction == "prev" and at - 1 or n - at + for k = 1, n do + add(order[((at - 1 + step * k) % n) + 1]) + end + local wrap_at = ahead + 1 + for _, key in ipairs(self:_file_keys()) do + add(key) + end + return out, wrap_at +end + +-- whether the walk still has something for its key on `e`: its status says so, and +-- the walk hasn't already stepped off it with nothing left +---@param e differ.FileEntry +---@param staged boolean +---@return boolean +function Panel:_walk_wants(e, staged) + if not has_review_work(e, staged) then + return false + end + return not (self.review and self.review.accepted(e, staged)) +end + +-- the open file has nothing left for the walk's key: remember it as passed +---@param staged boolean +function Panel:accept_review(staged) + local row = self:_row_of_key(self.selected_key) + local e = row and self.meta[row].entry + if e and self.review and has_review_work(e, staged) then + self.review.accept(e, staged) + end +end + +-- how many listed rows the walk has passed with work its key couldn't take +---@param staged boolean +---@return integer +function Panel:review_leftover(staged) + local n = 0 + for _, m in ipairs(self.meta) do + if + m.kind == "file" + and has_review_work(m.entry, staged) + and not self:_walk_wants(m.entry, staged) + then + n = n + 1 + end + end + return n +end + +-- the review flow's file step: the next file in `direction` with something left to do, +-- in the order _walk_keys gives. the open file is skipped, since re-opening it would -- re-source its frozen diff and drop the staging marks ---@param direction "next"|"prev" ---@param staged boolean -- the pair hunted: false for a file with hunks left to stage ---@param keep_focus boolean|nil ---@return boolean moved function Panel:step_review(direction, staged, keep_focus) - -- from the selection rather than the panel cursor: this flow is driven from the - -- diff window, so where the review sits beats where the sidebar is parked - local from = self.selected_row or self:_first_file_line() - local row, wrapped = from, false - for _ = 1, #self.meta do - local next_row, crossed = self:_file_row(row, direction, true) - if not next_row or next_row == from then - break -- the list holds no file rows, or we're back where we started - end - wrapped = wrapped or crossed - local e = self.meta[next_row].entry - if e and has_review_work(e, staged) and entry_key(e) ~= self.selected_key then - self.selected_row = next_row + local keys, wrap_at = self:_walk_keys(direction) + for i, key in ipairs(keys) do + local row = self:_row_of_key(key) + local e = row and self.meta[row].entry + if e and key ~= self.selected_key and self:_walk_wants(e, staged) then + self.selected_row = row if self:is_open() then - vim.api.nvim_win_set_cursor(self.winid, { next_row, 0 }) + vim.api.nvim_win_set_cursor(self.winid, { row, 0 }) end if self:_open(e, keep_focus) then -- the review flow's own wrap notice: `goto_file`'s would claim the first -- file, and this landed on the first one with anything left to do - if wrapped then + if i >= wrap_at then vim.notify( direction == "next" and "differ: wrapped to the first file left to stage" or "differ: wrapped to the last file left to unstage", @@ -1015,14 +1103,8 @@ function Panel:step_review(direction, staged, keep_focus) end return true end - -- the entry was stale, and opening it refreshed the list out from under the - -- walk: the rows behind us name different files now, so start again from the - -- selection, which a failed open left on the file we came from. the stale - -- entry is gone from the rebuilt list, so each retry has one less to meet - from = self.selected_row or self:_first_file_line() - row = from - else - row = next_row + -- the entry was stale, and opening it refreshed the list: the keys still + -- name files, so the walk goes on through them, skipping any that left end end return false @@ -1472,29 +1554,21 @@ function Panel:is_alive() return vim.api.nvim_buf_is_valid(self.bufnr) end --- the meta row holding `key`, preferring its own pair and falling back to the same --- path's other pair: staging a file's last unstaged hunk moves its row from Unstaged --- to Staged, and the cursor should follow the file rather than sit on whatever row --- slid into its place. nil when the path left the list entirely +-- the meta row holding `key`: staging can move a file's row to another section, and +-- the cursor should follow the file rather than sit on whatever row slid into its +-- place. nil when the path left the list entirely ---@param key string|nil ---@return integer|nil function Panel:_row_of_key(key) if not key then return nil end - local path = key:sub(3) - local same_path for i, m in ipairs(self.meta) do - if m.kind == "file" then - local k = entry_key(m.entry) - if k == key then - return i - elseif not same_path and m.entry.path == path then - same_path = i - end + if m.kind == "file" and entry_key(m.entry) == key then + return i end end - return same_path + return nil end -- the cursor's restorable position: the file entry under it (by identity, so it diff --git a/lua/differ/view.lua b/lua/differ/view.lua index d5eb612..ac9458d 100644 --- a/lua/differ/view.lua +++ b/lua/differ/view.lua @@ -1321,7 +1321,42 @@ end ---@return boolean moved function View:_step_review_file(direction, staged) local panel = require("differ.panel").current() - return panel ~= nil and panel:step_review(direction, staged, true) + if not panel then + return false + end + if self:_review_done(staged) then + panel:accept_review(staged) + end + return panel:step_review(direction, staged, true) +end + +-- whether no hunk here has anything left for the walk's key: stepping off leaves the +-- rest of the file as it is shown +---@param staged boolean +---@return boolean +function View:_review_done(staged) + local want = self:_review_filter(staged) + for i = 1, self:_slot_count() do + if want(i) then + return false + end + end + return true +end + +-- the walk's end: nothing left for the key, plus how many files it passed with changes +-- only the local view shows +---@param staged boolean +function View:_walk_done(staged) + local msg = staged and "differ: nothing left to unstage" or "differ: nothing left to stage" + local panel = require("differ.panel").current() + local left = panel and panel:review_leftover(staged) or 0 + if left > 0 then + local files = left == 1 and "1 file differs" or ("%d files differ"):format(left) + local key = require("differ.ui.help").fmt(self.keymaps.toggle_local) + msg = ("%s (%s locally: %s)"):format(msg, files, key) + end + vim.notify(msg, vim.log.levels.INFO) end -- the second tap of s / u: move to the next hunk that still has work left on it, @@ -1369,10 +1404,7 @@ function View:_step_review(direction) return end - vim.notify( - forward and "differ: no more hunks to stage" or "differ: no more hunks to unstage", - vim.log.levels.INFO - ) + self:_walk_done(staged) end -- toggle the staged state of the hunk under the cursor, marking it in place @@ -1417,7 +1449,7 @@ end function View:stage_all() if not self:_toggle_all(true) and self:_can_stage_hunk() then if not self:_step_review_file("next", false) then - vim.notify("differ: no more files to stage", vim.log.levels.INFO) + self:_walk_done(false) end end end @@ -1429,7 +1461,7 @@ function View:unstage_all() if self:_step_review_file("prev", true) then self:_focus_last_hunk() -- only when a previous file actually opened else - vim.notify("differ: no more files to unstage", vim.log.levels.INFO) + self:_walk_done(true) end end end diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index 17084d7..c28a6ba 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -3430,6 +3430,77 @@ func getDownloadSpeed() { assert.are.equal("", status) end) + -- the s walk shows a row it can't stage once, then counts it at the end rather than + -- landing back on it: c.lua was added and then deleted, which only u can act on + it("walks s onto a row it can't stage once, then ends there", function() + local root = fresh_repo() + write(root .. "/c.lua", "one\n") + git(root, "add", "c.lua") + os.remove(root .. "/c.lua") + write(root .. "/a.lua", "changed\n") + vim.cmd.edit(root .. "/a.lua") + local p, v = open_panel() + local landed = {} + for _ = 1, 5 do + view_in_origin(p):stage_hunk() + landed[#landed + 1] = view_in_origin(p).model.path + end + local said = _G.notifs[#_G.notifs].msg + p:close() + assert.is_not_nil(v) + assert.are.same({ "a.lua", "c.lua", "c.lua", "c.lua", "c.lua" }, landed) + assert.are.equal("differ: nothing left to stage (1 file differs locally: dw)", said) + end) + + -- b.lua staged then put back on disk: passed once, and back in the walk once it changes + it("puts a passed row back in the walk when its file changes", function() + local root = fresh_repo() + write(root .. "/b.lua", "one\n") + git(root, "add", "b.lua") + git(root, "commit", "-q", "-m", "b") + write(root .. "/b.lua", "two\n") + git(root, "add", "b.lua") + write(root .. "/b.lua", "one\n") + write(root .. "/a.lua", "changed\n") + vim.cmd.edit(root .. "/a.lua") + local p = open_panel() + view_in_origin(p):stage_hunk() -- a.lua staged + view_in_origin(p):stage_hunk() -- onto b.lua + view_in_origin(p):stage_hunk() -- b.lua passed: nothing left + write(root .. "/b.lua", "three\n") + p:refresh() + assert.is_true(p:goto_path("a.lua", true)) + view_in_origin(p):stage_hunk() -- a.lua is done: the walk looks again + local landed = view_in_origin(p).model.path + p:close() + assert.are.equal("b.lua", landed) + end) + + -- a.lua and c.lua half staged, d.lua not at all: finishing c.lua moves it up into + -- Staged, and the walk goes on to d.lua below it rather than back up to a.lua + it("walks on from where a finished file sat, not from where it moved", function() + local root = fresh_repo() + for _, name in ipairs({ "c.lua", "d.lua" }) do + write(root .. "/" .. name, "one\n") + end + git(root, "add", "c.lua", "d.lua") + git(root, "commit", "-q", "-m", "c d") + write(root .. "/a.lua", "staged\n") + write(root .. "/c.lua", "staged\n") + git(root, "add", "a.lua", "c.lua") + write(root .. "/a.lua", "staged\nand more\n") + write(root .. "/c.lua", "staged\nand more\n") + write(root .. "/d.lua", "two\n") + vim.cmd.edit(root .. "/c.lua") + local p = open_panel() + assert.are.equal("c.lua", view_in_origin(p).model.path) + view_in_origin(p):stage_hunk() -- c.lua's rest: the file moves to Staged + view_in_origin(p):stage_hunk() -- on to the next file + local landed = view_in_origin(p).model.path + p:close() + assert.are.equal("d.lua", landed) + end) + it("discards a staged rename whose new path was deleted, from the commit preview", function() local root = renamed_then_deleted() local got = discard_renamed_then_deleted(root, true) From 137aaf7d0bbb7218396a7e848f32f8123f6d08a7 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Sun, 20 Sep 2026 16:48:26 +0100 Subject: [PATCH 63/93] fix: refuse a hunk no pair can move on its own instead of rewriting the index --- lua/differ/git/staging.lua | 49 +++++++++++++++--------- lua/differ/model/stage.lua | 51 +++++++++++++++++-------- lua/differ/view.lua | 43 +++++++++++++++++---- test/nvim/git_spec.lua | 70 +++++++++++++++++++++++++++++++++- test/unit/model_stage_spec.lua | 24 ++++++++++++ 5 files changed, 194 insertions(+), 43 deletions(-) diff --git a/lua/differ/git/staging.lua b/lua/differ/git/staging.lua index b8229e7..ed79f4d 100644 --- a/lua/differ/git/staging.lua +++ b/lua/differ/git/staging.lua @@ -185,26 +185,28 @@ function M.new(ctx) end end - -- the text the index takes when `hunk` is staged (`take`) or unstaged, or nil - -- when the pair hunk carrying its change covers another union hunk too - ---@param union differ.DiffModel - ---@param cached differ.DiffModel - ---@param unstaged differ.DiffModel - ---@param hunk differ.Hunk + -- why `hunk` can't move on its own, either way naming the view where its change + -- is a hunk of its own and can be taken whole. `reaches` names another union + -- hunk a pair hunk would carry the op into; without one the two pairs line this + -- hunk's change up under a different one, leaving nothing here to write ---@param take boolean - ---@return string|nil - local function next_index(union, cached, unstaged, hunk, take) - local text, reaches = stage.next_index(union, cached, unstaged, hunk, take) + ---@param reaches integer|nil + ---@return string + local function refusal(take, reaches) if not reaches then - return text + if take then + return "this hunk's unstaged change sits under another hunk: " + .. "s in dw stages it whole" + end + return "this hunk's staged change sits under another hunk: " + .. "u in gs unstages it whole" end local msg = "this hunk's staged change also covers hunk %d: " .. "u on the ! hunk in dw, or in gs, unstages it whole" if take then msg = "this hunk's unstaged change also covers hunk %d: s in dw stages it whole" end - notify(msg:format(reaches), vim.log.levels.WARN) - return nil + return msg:format(reaches) end -- whether the diff still shows the file as it is. one drawn before the file or @@ -236,8 +238,15 @@ function M.new(ctx) if not drawn_current(model, union) then return false end - local text = next_index(union, cached, unstaged, hunk, not reverse) - if not text or not put_index(entry, text) then + local take = not reverse + local text, reaches = stage.next_index(union, cached, unstaged, hunk, take) + if not text then + -- neither refusal is a failure: the key moves on, and the message says + -- where this hunk's change can be taken whole + notify(refusal(take, reaches), vim.log.levels.WARN) + return false, true + end + if not put_index(entry, text) then return false end remark(gitmod.union_pairs(root, entry.path, union.old_text, text, union.new_text)) @@ -254,19 +263,23 @@ function M.new(ctx) return false end local hunk = model.hunks[idx] - local text = next_index(union, cached, unstaged, hunk, false) - if not text then + local text, reaches = stage.next_index(union, cached, unstaged, hunk, false) + if reaches then + notify(refusal(false, reaches), vim.log.levels.WARN) return false end + -- a hunk the index holds nothing of on its own leaves the index where it is, + -- and the worktree still has the whole change to give back + local index_text = text or cached.new_text local ok = revert_worktree(entry, model, hunk, 0, function() - return text == cached.new_text or put_index(entry, text) + return index_text == cached.new_text or put_index(entry, index_text) end) if not ok then remark(gitmod.union_models(root, entry)) return false end local work = require("differ.model.diff").revert_hunk(model, idx).new_text - remark(gitmod.union_pairs(root, entry.path, union.old_text, text, work)) + remark(gitmod.union_pairs(root, entry.path, union.old_text, index_text, work)) return true end -- S and U: the index takes the worktree's or HEAD's side of the file whole, diff --git a/lua/differ/model/stage.lua b/lua/differ/model/stage.lua index 6bda7fd..8f27c87 100644 --- a/lua/differ/model/stage.lua +++ b/lua/differ/model/stage.lua @@ -59,11 +59,32 @@ function M.hunk_index(union, hunk) return nil end +-- the index's lines at each union hunk with `hunk`'s own replaced by the side the op +-- takes, or nil where the index can't be cut into blocks +---@param union differ.DiffModel +---@param cached differ.DiffModel +---@param hunk differ.Hunk +---@param take boolean +---@return string|nil +local function by_blocks(union, cached, hunk, take) + local blocks, ended = M.index_blocks(union, cached.new_text) + local i = M.hunk_index(union, hunk) + if not (blocks and i) then + return nil + end + blocks[i] = ended.hunks[i].old_lines + if take then + blocks[i] = ended.hunks[i].new_lines + end + return apply.join(ended, blocks) +end + -- the text the index takes when `hunk` is staged (`take`) or unstaged. the index's lines -- at the hunk become the hunk's new or old lines; an index that changes a line between -- hunks moves by the pair hunks meeting it on the side that pair shares. `reaches` names -- another union hunk such a pair hunk covers too, and then there is no text: staging by --- hunk can't take one without the other +-- hunk can't take one without the other. no text and no `reaches` is a hunk the pair the +-- op reads for lines up under a different union hunk, leaving the index where it is ---@param union differ.DiffModel -- HEAD↔worktree ---@param cached differ.DiffModel -- HEAD↔index ---@param unstaged differ.DiffModel -- index↔worktree @@ -71,24 +92,22 @@ end ---@param take boolean ---@return string|nil text, integer|nil reaches function M.next_index(union, cached, unstaged, hunk, take) - local blocks, ended = M.index_blocks(union, cached.new_text) - local i = M.hunk_index(union, hunk) - if blocks and i then - blocks[i] = ended.hunks[i].old_lines - if take then - blocks[i] = ended.hunks[i].new_lines + local text = by_blocks(union, cached, hunk, take) + if not text then + local from, side = unstaged, "new" + if not take then + from, side = cached, "old" end - return apply.join(ended, blocks) - end - local from, side = unstaged, "new" - if not take then - from, side = cached, "old" + local reaches = marks.shared_with(union.hunks, hunk, from.hunks, side) + if reaches then + return nil, reaches + end + text = apply.splice(from, M.select_hunks(from.hunks, side, hunk, side, take)) end - local reaches = marks.shared_with(union.hunks, hunk, from.hunks, side) - if reaches then - return nil, reaches + if text == cached.new_text then + return nil -- the op has nothing of this hunk to move on its own end - return apply.splice(from, M.select_hunks(from.hunks, side, hunk, side, take)) + return text end return M diff --git a/lua/differ/view.lua b/lua/differ/view.lua index ac9458d..6cefb44 100644 --- a/lua/differ/view.lua +++ b/lua/differ/view.lua @@ -77,7 +77,9 @@ local armed_view = nil -- staged-state slot ---@field whole_file? boolean -- `apply` takes a nil hunk on a whole-file source, which ignores it ----@field apply? fun(model: differ.DiffModel, hunk: differ.Hunk|nil, reverse: boolean): boolean +-- the second return says the op refused because this hunk can't move on its own, as +-- against a failure: the source has named the view that can take it whole +---@field apply? fun(model: differ.DiffModel, hunk: differ.Hunk|nil, reverse: boolean): boolean, boolean|nil ---@field revert? fun(model: differ.DiffModel, idx: integer): boolean ---@field revert_label? string -- e.g. "deletes the file" ---@field refresh fun() @@ -107,6 +109,7 @@ local armed_view = nil ---@field commit_preview fun()|nil -- session-level: gs flips the panel's listing ---@field fold_memory table -- diff_key -> its open folds when last left ---@field marks differ.model.Marks -- which lines the index holds; hunk state rolls up from it +---@field stuck table> -- hunks a key refused to move, by the state it wanted ---@field extra_keymaps differ.panel.ExtraMap[]|nil -- session-supplied buffer maps (pr unviewed nav) ---@field on_rerender fun()|nil -- session hook after a re-render, to re-apply overlays (pr threads) ---@field on_cursor fun()|nil -- session hook on cursor move in a diff window (pr thread cursor-expand) @@ -171,6 +174,7 @@ function View.new(model, opts) on_repurpose = opts.on_repurpose, fold_memory = {}, marks = { old = {}, new = {} }, + stuck = { staged = {}, unstaged = {} }, id = next_id(), _suppress_close = false, _closing = false, @@ -183,6 +187,7 @@ end -- seed staged state for the current source. a hunk-level source hands over its marks -- and keeps them current itself; a whole-file one starts from `initial` function View:_init_staged() + self:_forget_stuck() local staging = self.staging if staging and staging.marks then self.marks = staging.marks @@ -194,6 +199,12 @@ function View:_init_staged() end end +-- drop the record of hunks a key has refused to move. any op moves what the index +-- holds, so what was unreachable before it may not be after +function View:_forget_stuck() + self.stuck = { staged = {}, unstaged = {} } +end + -- a stable, file-shaped buffer name so the statusline/winbar shows the file path -- instead of `[Scratch]`. the `differ://` scheme keeps it distinct from the -- real file (a bare relative name would resolve to the same absolute path and @@ -1000,12 +1011,17 @@ end -- a hunk filter for the review scans: `staged` picks the side, so false matches every -- hunk with something left to stage and true every one with something left to unstage. --- a partial hunk matches both +-- a partial hunk matches both; one the key has already refused to move matches neither, +-- so the walk reads the file as done rather than circling the hunk it can't take ---@param staged boolean ---@return fun(hunk: integer): boolean function View:_review_filter(staged) local done = staged and "unstaged" or "staged" + local refused = self.stuck[done] return function(hunk) + if refused[hunk] then + return false + end return self:_hunk_state(hunk) ~= done end end @@ -1228,10 +1244,11 @@ end -- move hunk `idx` to `want_staged` in the index from the hunk model (never buffer -- text), and mark it. no panel refresh / repaint, so callers can batch. returns --- whether it changed +-- whether it changed, and whether the source refused because this hunk can't move on +-- its own (as against a failure, which leaves the cursor where it is) ---@param idx integer ---@param want_staged boolean ----@return boolean +---@return boolean changed, boolean|nil stuck function View:_apply_hunk(idx, want_staged) local apply = self.staging and self.staging.apply local want = want_staged and "staged" or "unstaged" @@ -1239,8 +1256,9 @@ function View:_apply_hunk(idx, want_staged) return false end -- reverse unstages: the change leaves the index - if not apply(self.model, self.model.hunks[idx], not want_staged) then - return false + local changed, stuck = apply(self.model, self.model.hunks[idx], not want_staged) + if not changed then + return false, stuck end if self:_whole_file() then self.marks.whole = want @@ -1428,14 +1446,22 @@ function View:_toggle_hunk(want_staged) vim.log.levels.INFO ) end - if self:_apply_hunk(idx, want_staged) then - self:_after_staging() + local changed, stuck = self:_apply_hunk(idx, want_staged) + if changed then + return self:_after_staging() + end + -- the source has said where this hunk's change can be taken whole, so carry the + -- walk on rather than parking on a hunk this key can never move + if stuck then + self.stuck[want_staged and "staged" or "unstaged"][idx] = true + self:_step_review(want_staged and "next" or "prev") end end -- repaint after a staging op: the panel counts, the staged shade, and the winbar -- tally, which a `%!` winbar only redraws on its own when the cursor moves function View:_after_staging() + self:_forget_stuck() self.staging.refresh() self:_paint_staged() self:_paint_cursorline() -- re-lift the cursor tint above the fresh staged fill @@ -1591,6 +1617,7 @@ function View:revert_hunk() end local opened = self:_opened_folds() -- before rerender replaces col.folds + self:_forget_stuck() -- the hunks renumber around the one that just left self.model = require("differ.model.diff").revert_hunk(self.model, idx) self:rerender({ layout = self.layout, context = self.context, deep_diff = self.deep_diff }) self:_apply_folds(opened) diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index c28a6ba..d37bd3b 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -2562,7 +2562,9 @@ func getDownloadSpeed() { v[case.op](v) end) local after = { worktree(root, "a.lua"), indexed(root, "a.lua") } - local said = (_G.notifs[#_G.notifs] or {}).msg or "" + -- the first thing said, since s and u go on to step the walk and may + -- announce a wrap after the refusal + local said = (_G.notifs[1] or {}).msg or "" p:close() assert.are.equal(2, hunks) assert.are.same(before, after) @@ -2570,6 +2572,18 @@ func getDownloadSpeed() { end) end + -- the same refusal as above: s can't take hunk 1 without hunk 2, so it says where + -- both can be taken and carries the walk on rather than parking on hunk 1 + it("steps s off a hunk sharing a change with the next", function() + local root = fresh_repo() + local p, v = shared_change(root) + assert.are.equal(1, v:_hunk_index_under_cursor()) + v:stage_hunk() + assert.are_not.equal(1, v:_hunk_index_under_cursor()) + assert.is_true(v.stuck.staged[1]) + p:close() + end) + -- the file is edited under the open view: X refuses before either side moves, so the -- index keeps its half too rather than dropping it alone it("X leaves the index alone when the file won't take the revert", function() @@ -5162,6 +5176,60 @@ func getDownloadSpeed() { assert.is_false(v2:_editable_source()) p2:close() end) + + -- HEAD's "x x" pair loses one line in the worktree, which the union shows under the + -- first hunk: the second hunk reads partial, but neither pair holds anything of it + -- on its own, so s has no text to write and has to say where the change can be taken + local function stuck_repo() + local root = fresh_repo() + write(root .. "/a.lua", "1\n2\nx\nx\n5\n") + git(root, "commit", "-q", "-am", "five") + write(root .. "/a.lua", "1\nx\nx\n5x\n") + git(root, "add", "a.lua") + write(root .. "/a.lua", "1\n2x\nx\n5x\n") + vim.cmd.edit(root .. "/a.lua") + return root + end + + it("refuses a hunk neither pair holds alone rather than rewriting the index", function() + local root = stuck_repo() + local p, v = open_panel() + local before = indexed(root, "a.lua") + local stuck + for i = 1, #v.model.hunks do + if v:_hunk_state(i) == "partial" then + stuck = i + end + end + assert.is_not_nil(stuck) + local changed, refused = v:_apply_hunk(stuck, true) + assert.is_false(changed) + assert.is_true(refused) + assert.are.equal(before, indexed(root, "a.lua")) -- no identical blob written + local said = _G.notifs[#_G.notifs].msg + assert.is_truthy(said:find("sits under another hunk", 1, true)) + assert.is_truthy(said:find("dw", 1, true)) + p:close() + end) + + it("steps s off a hunk it refused instead of parking on it", function() + local root = stuck_repo() + local p, v = open_panel() + local stuck + for i = 1, #v.model.hunks do + if v:_hunk_state(i) == "partial" then + stuck = i + end + end + vim.api.nvim_win_set_cursor(v.columns[1].winid, { hunk_line(v, stuck), 0 }) + v:stage_hunk() -- refuses, and the walk carries on past it + assert.are_not.equal(stuck, v:_hunk_index_under_cursor()) + assert.is_true(v.stuck.staged[stuck]) + -- the hunk it wrapped to does move, and takes the refused one with it + v:stage_hunk() + assert.are.equal(indexed(root, "a.lua"), worktree(root, "a.lua")) + p:close() + end) end) describe(":Differ panel <-> diff wiring", function() diff --git a/test/unit/model_stage_spec.lua b/test/unit/model_stage_spec.lua index 5df5732..5146816 100644 --- a/test/unit/model_stage_spec.lua +++ b/test/unit/model_stage_spec.lua @@ -92,4 +92,28 @@ describe("next_index", function() assert.is_nil(text) assert.are.equal(2, reaches) end) + + -- HEAD 1 2 x x 5, index 1 x x 5x, worktree 1 2x x 5x: the index drops a line the + -- union shows under hunk 1, so the pair hunks leave hunk 2 nothing of its own. the + -- op has to say so rather than hand back the index's own text + it("gives no text and no reaches for a hunk neither pair sits at", function() + local head = "1\n2\nx\nx\n5\n" + local index = "1\nx\nx\n5x\n" + local work = "1\n2x\nx\n5x\n" + local union = model(head, work, { + h(2, { "2" }, 2, { "2x" }), + h(4, { "x", "5" }, 4, { "5x" }), + }) + local cached = model(head, index, { h(2, { "2" }, 2, {}), h(5, { "5" }, 4, { "5x" }) }) + local unstaged = model(index, work, { h(2, {}, 2, { "2x" }) }) + local text, reaches = stage.next_index(union, cached, unstaged, union.hunks[2], true) + assert.is_nil(text) + assert.is_nil(reaches) + end) + + it("never hands back the text the index already holds", function() + local union, cached, unstaged = pairs_of(HEAD) + -- both hunks are unstaged, so unstaging one has nothing to write + assert.is_nil(stage.next_index(union, cached, unstaged, union.hunks[1], false)) + end) end) From 8364aa48555e01eff09c8679a4f6c2859d0a1b96 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Sun, 20 Sep 2026 16:51:05 +0100 Subject: [PATCH 64/93] fix: let X reach the rows it is offered on, and refuse before the prompt --- lua/differ/git/init.lua | 2 +- lua/differ/git/staging.lua | 42 +++++++++++----- lua/differ/view.lua | 8 +++- test/nvim/git_spec.lua | 98 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 135 insertions(+), 15 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index aef76f8..f9b3f72 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -1430,7 +1430,7 @@ function M.panel(opts) if preview then staging = staging_ops.preview(entry, model) elseif staged_only then - staging = staging_ops.staged_only(entry, model) + staging = staging_ops.staged_only(entry, model, false) else staging = staging_ops.for_entry(entry, model) end diff --git a/lua/differ/git/staging.lua b/lua/differ/git/staging.lua index ed79f4d..7a58b3a 100644 --- a/lua/differ/git/staging.lua +++ b/lua/differ/git/staging.lua @@ -13,6 +13,11 @@ local set_staged, index_path = index_ops.set_staged, index_ops.index_path local M = {} +-- the whole-file rows X acts on: a file that came, went, or changed kind altogether. +-- `T` is a path swapped for a symlink or the other way round, which has no hunk to take +---@type table +local REVERTABLE_WHOLE = { ["?"] = true, A = true, D = true, T = true } + ---@class differ.git.StagingCtx ---@field root string ---@field stageable boolean -- a worktree source: rev-pair rows don't stage @@ -308,19 +313,26 @@ function M.new(ctx) end -- X on a whole-file row, and what it does to the file. `letter` owns the change the - -- row shows: its status for a worktree row, the index's (x) in the commit preview + -- row shows: its status for a worktree row, the index's (x) in the commit preview. + -- a row X can't act on has no revert and says why instead, so the key refuses + -- before the prompt rather than after it ---@param entry differ.FileEntry ---@param letter string - ---@return fun(): boolean revert, string label - local function whole_file_revert(entry, letter) + ---@param preview boolean -- the entry is lettered by its index side + ---@return (fun(): boolean)|nil revert, string|nil label, string|nil no_revert + local function whole_file_revert(entry, letter, preview) if letter == "D" then + if entry.kept then + local why = "X would overwrite the untracked copy of %s on disk" + return nil, nil, why:format(entry.path) + end return function() return index_ops.restore_deleted(root, entry) end, "restores the file" end local discard = function() - return gitmod.discard(root, entry) + return gitmod.discard(root, entry, preview) end if letter == "A" or letter == "?" then return discard, "deletes the file" @@ -358,12 +370,14 @@ function M.new(ctx) local staging = union_staging(entry) if added then -- throwing away an add is deleting the file, not reverting a hunk of it - staging.revert, staging.revert_label = whole_file_revert(entry, "A") + staging.revert, staging.revert_label, staging.no_revert = + whole_file_revert(entry, "A", false) end return staging end -- whole-file from here: nothing to stage by line (a mode change, a submodule, a - -- binary file, a bare rename), or a file added, untracked or deleted as one unit + -- binary file, a bare rename, a file swapped for a symlink), or a file added, + -- untracked or deleted as one unit ---@type differ.view.Staging local staging = { initial = row_state(entry), @@ -376,9 +390,11 @@ function M.new(ctx) if content then return staging end - -- an add's discard drops the staged entry before removing the file - if entry.status == "?" or entry.status == "A" or entry.status == "D" then - staging.revert, staging.revert_label = whole_file_revert(entry, entry.status) + -- an add's discard drops the staged entry before removing the file; a + -- typechange's puts HEAD's own kind of file back over the one on disk + if REVERTABLE_WHOLE[entry.status] then + staging.revert, staging.revert_label, staging.no_revert = + whole_file_revert(entry, entry.status, false) return staging end return nil @@ -523,8 +539,9 @@ function M.new(ctx) -- and goes, so it stages as a file ---@param entry differ.FileEntry ---@param model differ.DiffModel -- HEAD↔index + ---@param preview boolean -- the row comes from the commit preview's listing ---@return differ.view.Staging - local function staged_staging(entry, model) + local function staged_staging(entry, model, preview) local staging if #model.hunks > 0 and entry.x ~= "A" and entry.x ~= "D" then staging = frozen_staging(entry, model, true, function() @@ -546,7 +563,8 @@ function M.new(ctx) end else staging = snapshot_staging(entry) - staging.revert, staging.revert_label = whole_file_revert(entry, entry.x) + staging.revert, staging.revert_label, staging.no_revert = + whole_file_revert(entry, entry.x, preview) end staging.badge = "INDEX" return staging @@ -557,7 +575,7 @@ function M.new(ctx) ---@param model differ.DiffModel -- HEAD↔index ---@return differ.view.Staging local function preview_staging(entry, model) - local staging = staged_staging(entry, model) + local staging = staged_staging(entry, model, true) staging.badge = "STAGED" staging.no_local = "the commit preview has no local view: gs goes back" staging.leave = function() diff --git a/lua/differ/view.lua b/lua/differ/view.lua index 6cefb44..7f406e8 100644 --- a/lua/differ/view.lua +++ b/lua/differ/view.lua @@ -82,6 +82,7 @@ local armed_view = nil ---@field apply? fun(model: differ.DiffModel, hunk: differ.Hunk|nil, reverse: boolean): boolean, boolean|nil ---@field revert? fun(model: differ.DiffModel, idx: integer): boolean ---@field revert_label? string -- e.g. "deletes the file" +---@field no_revert? string -- why X does nothing here, in place of the default refusal ---@field refresh fun() ---@field marks? differ.model.Marks -- union source: which lines the index holds, kept current by the source ---@field hidden? boolean -- union source: some staged content isn't on screen @@ -1568,9 +1569,12 @@ function View:revert_hunk() end local revert = self.staging.revert if not revert then - return vim.notify("differ: this hunk can't be reverted", vim.log.levels.WARN) + local why = self.staging.no_revert or "this hunk can't be reverted" + return vim.notify("differ: " .. why, vim.log.levels.WARN) end - local idx = self:_hunk_index_under_cursor() + -- a whole-file source reverts as one unit, so it takes slot 1 even where the diff + -- draws a notice rather than hunks (a mode change, a bare rename, a submodule) + local idx = self:_target_index() if not idx then return vim.notify("differ: no hunk under the cursor", vim.log.levels.WARN) end diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index d37bd3b..8596f37 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -5703,6 +5703,104 @@ describe(":Differ diff whole-file staging", function() vim.api.nvim_win_close(0, true) p:close() end) + + -- the commit preview draws a staged mode change as a notice, so there's no hunk + -- under the cursor for X to find, and the whole-file slot is what it acts on + it("reverts a zero-hunk commit-preview row with X", function() + local root = fresh_repo() + write(root .. "/b.lua", "one\n") + git(root, "add", "b.lua") + git(root, "commit", "-q", "-m", "b") + vim.fn.system({ "chmod", "+x", root .. "/b.lua" }) + git(root, "add", "b.lua") + vim.cmd.edit(root .. "/b.lua") + + local p, v = open_panel() + v:toggle_commit_preview() + v = view_in_origin(p) + assert.are.equal("STAGED", v.staging.badge) + assert.are.equal(0, #v.model.hunks) + confirming(1, function() + v:revert_hunk() + end) + local status = git(root, "status", "--porcelain=v1") + p:close() + assert.are.equal("", status) + assert.are.equal("100644", index_mode(root, "b.lua")) + end) + + -- the preview letters a row by its index side, so discard has to be told which + -- listing the entry came from or it reads the row as changed under the prompt + it("reverts a staged rename whose file is gone from the commit preview", function() + local root = fresh_repo() + write(root .. "/b.lua", "one\ntwo\n") + git(root, "add", "b.lua") + git(root, "commit", "-q", "-m", "b") + git(root, "mv", "b.lua", "c.lua") + os.remove(root .. "/c.lua") + vim.cmd.edit(root .. "/a.lua") + + local p = open_panel() + assert.is_true(Panel.current():goto_path("c.lua", true)) + local v = view_in_origin(p) + v:toggle_commit_preview() + v = view_in_origin(p) + assert.are.equal(0, #v.model.hunks) + confirming(1, function() + v:revert_hunk() + end) + local status = git(root, "status", "--porcelain=v1") + p:close() + assert.are.equal("", status) + end) + + -- a staged deletion whose file is still on disk untracked: X can't put HEAD's copy + -- back over it, and says so instead of asking first + it("refuses X on a kept deletion without prompting", function() + local root = fresh_repo() + write(root .. "/b.lua", "one\n") + git(root, "add", "b.lua") + git(root, "commit", "-q", "-m", "b") + git(root, "rm", "-q", "--cached", "b.lua") + vim.cmd.edit(root .. "/a.lua") + + local p = open_panel() + assert.is_true(Panel.current():goto_path("b.lua", true)) + local v = view_in_origin(p) + assert.is_nil(v.staging.revert) + local prompt = confirming(1, function() + v:revert_hunk() + end) + local said = _G.notifs[#_G.notifs].msg + p:close() + assert.is_nil(prompt) + assert.is_truthy(said:find("would overwrite the untracked copy", 1, true)) + end) + + -- a file swapped for a symlink has one content hunk but no line-level change to + -- take, so it stages as a file and X puts HEAD's own kind of file back + it("stages and reverts a typechange row as a whole file", function() + local root = fresh_repo() + write(root .. "/b.lua", "a plain file\n") + git(root, "add", "b.lua") + git(root, "commit", "-q", "-m", "b") + os.remove(root .. "/b.lua") + vim.fn.system({ "ln", "-s", "a.lua", root .. "/b.lua" }) + vim.cmd.edit(root .. "/a.lua") + + local p = open_panel() + assert.is_true(Panel.current():goto_path("b.lua", true)) + local v = view_in_origin(p) + assert.is_true(v.staging.whole_file) + v:stage_hunk() + assert.are.equal("120000", index_mode(root, "b.lua")) + confirming(1, function() + v:revert_hunk() + end) + local status = git(root, "status", "--porcelain=v1") + p:close() + assert.are.equal("", status) + end) end) -- a rename is the one whole-file status that can show several hunks, and the one that From ad7b109951eecc3f4460d9c24af15dd687f9b7b0 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Sun, 20 Sep 2026 16:53:49 +0100 Subject: [PATCH 65/93] fix: end the walk with a count only of rows its local view can answer --- lua/differ/git/init.lua | 18 +++++++++++++- lua/differ/panel/init.lua | 24 +++++++++++++++--- test/nvim/git_spec.lua | 52 ++++++++++++++++++++++++++++++++++++--- test/nvim/panel_spec.lua | 25 +++++++++++++++++++ 4 files changed, 111 insertions(+), 8 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index f9b3f72..7635f3d 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -1194,6 +1194,7 @@ function M.panel(opts) local watcher ---@type differ.git.Watcher|nil -- fs watcher, set for worktree panels local retarget_view ---@type fun(outside: boolean): boolean -- assigned below local set_preview ---@type fun(on: boolean) -- assigned below + local staging_ops ---@type table -- assigned below; the review hooks read it at call time -- 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` @@ -1262,6 +1263,21 @@ function M.panel(opts) passed[staged][entry.path] = nil return false end, + has_local = function(entry) + return staging_ops.partly_staged(entry) + end, + -- the paths index↔worktree changes by line, in one read for the whole list: a + -- row left with only a staged mode change counts 0/0 there, and one left with + -- only a move isn't listed at all, so dw opens empty on both + local_paths = function() + local out = {} + for path, c in pairs(numstat({}, root)) do + if (c.additions or 0) > 0 or (c.deletions or 0) > 0 then + out[path] = true + end + end + return out + end, } local last_sig = git_signature() -- record the state the list now reflects, so the next external event doesn't read an @@ -1282,7 +1298,7 @@ function M.panel(opts) -- the row-level staging ops. they read and write git, and call back here to reload -- the list and re-source the view - local staging_ops = require("differ.git.staging").new({ + staging_ops = require("differ.git.staging").new({ root = root, stageable = stageable, refresh = function() diff --git a/lua/differ/panel/init.lua b/lua/differ/panel/init.lua index ae362ad..8e85794 100644 --- a/lua/differ/panel/init.lua +++ b/lua/differ/panel/init.lua @@ -148,6 +148,8 @@ Panel.__index = Panel ---@class differ.panel.ReviewHooks ---@field accept fun(entry: differ.FileEntry, staged: boolean) ---@field accepted fun(entry: differ.FileEntry, staged: boolean): boolean +---@field has_local fun(entry: differ.FileEntry): boolean -- the row has a local view to send it to +---@field local_paths fun(): table -- the paths whose local view has lines to show ---@class differ.panel.ExtraMap ---@field spec string|string[]|false -- resolved lhs (a keymaps value) @@ -1024,15 +1026,20 @@ function Panel:_walk_keys(direction) end end local n, step = #order, direction == "prev" and -1 or 1 - local ahead = direction == "prev" and at - 1 or n - at for k = 1, n do add(order[((at - 1 + step * k) % n) + 1]) end - local wrap_at = ahead + 1 + -- where the walk comes round the end of the remembered order. the open row isn't in + -- it when `at` is 0, and then there is no place in it to have come round from, so + -- the wrap sits past every key and nothing the walk reaches claims one + local wrap_at + if at > 0 then + wrap_at = (direction == "prev" and at - 1 or n - at) + 1 + end for _, key in ipairs(self:_file_keys()) do add(key) end - return out, wrap_at + return out, wrap_at or #out + 1 end -- whether the walk still has something for its key on `e`: its status says so, and @@ -1057,16 +1064,25 @@ function Panel:accept_review(staged) end end --- how many listed rows the walk has passed with work its key couldn't take +-- how many listed rows the walk has passed with work its key couldn't take and a local +-- view with something to show for it. a row whose local view would open empty is left +-- out: the count is read as where to go next, and a row left with only a staged mode +-- change or a move has nothing for dw to answer with ---@param staged boolean ---@return integer function Panel:review_leftover(staged) + if not self.review then + return 0 + end + local shows = self.review.local_paths() local n = 0 for _, m in ipairs(self.meta) do if m.kind == "file" and has_review_work(m.entry, staged) and not self:_walk_wants(m.entry, staged) + and self.review.has_local(m.entry) + and shows[m.entry.path] then n = n + 1 end diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index 8596f37..67c0408 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -3444,8 +3444,9 @@ func getDownloadSpeed() { assert.are.equal("", status) end) - -- the s walk shows a row it can't stage once, then counts it at the end rather than - -- landing back on it: c.lua was added and then deleted, which only u can act on + -- the s walk shows a row it can't stage once, then ends there rather than landing + -- back on it: c.lua was added and then deleted, which only u can act on. it has no + -- local view either, so the end names no file to go and look at it("walks s onto a row it can't stage once, then ends there", function() local root = fresh_repo() write(root .. "/c.lua", "one\n") @@ -3463,7 +3464,7 @@ func getDownloadSpeed() { p:close() assert.is_not_nil(v) assert.are.same({ "a.lua", "c.lua", "c.lua", "c.lua", "c.lua" }, landed) - assert.are.equal("differ: nothing left to stage (1 file differs locally: dw)", said) + assert.are.equal("differ: nothing left to stage", said) end) -- b.lua staged then put back on disk: passed once, and back in the walk once it changes @@ -5230,6 +5231,51 @@ func getDownloadSpeed() { assert.are.equal(indexed(root, "a.lua"), worktree(root, "a.lua")) p:close() end) + + it("counts a leftover row with a local view in the walk's last word", function() + local root = fresh_repo() + -- b.lua: staged, then the worktree puts it back, so only dw can reach the change + write(root .. "/b.lua", "one\n") + git(root, "add", "b.lua") + git(root, "commit", "-q", "-m", "b") + write(root .. "/b.lua", "two\n") + git(root, "add", "b.lua") + write(root .. "/b.lua", "one\n") + write(root .. "/a.lua", "changed\n") + vim.cmd.edit(root .. "/a.lua") + local p = open_panel() + for _ = 1, 6 do + view_in_origin(p):stage_hunk() + end + local said = _G.notifs[#_G.notifs].msg + p:close() + assert.are.equal("differ: nothing left to stage (1 file differs locally: dw)", said) + end) + + -- b.lua's content is staged and its index entry carries a mode the worktree doesn't: + -- the walk can't take that, and dw opens on nothing there, so the last word leaves + -- it out rather than sending anyone to it + it("leaves a row whose local view has nothing to show out of the walk's last word", function() + local root = fresh_repo() + write(root .. "/b.lua", "one\n") + git(root, "add", "b.lua") + git(root, "commit", "-q", "-m", "b") + write(root .. "/b.lua", "two\n") + git(root, "add", "b.lua") + local sha = git(root, "rev-parse", ":0:b.lua"):gsub("%s+$", "") + git(root, "update-index", "--cacheinfo", "100755," .. sha .. ",b.lua") + write(root .. "/a.lua", "changed\n") + vim.cmd.edit(root .. "/a.lua") + local p = open_panel() + for _ = 1, 6 do + view_in_origin(p):stage_hunk() + end + local said = _G.notifs[#_G.notifs].msg + local status = git(root, "status", "--porcelain=v1", "--", "b.lua") + p:close() + assert.are.equal("MM b.lua\n", status) -- still a leftover, just not one dw answers + assert.are.equal("differ: nothing left to stage", said) + end) end) describe(":Differ panel <-> diff wiring", function() diff --git a/test/nvim/panel_spec.lua b/test/nvim/panel_spec.lua index b71615a..b5dc157 100644 --- a/test/nvim/panel_spec.lua +++ b/test/nvim/panel_spec.lua @@ -671,6 +671,31 @@ describe("panel selection identity", function() p:close() end) + -- the walk follows the list order the open file opened on. a row that isn't in it + -- has nowhere in it to have come round from, so nothing it reaches is a wrap + it("claims no wrap stepping back from a row the remembered order doesn't hold", function() + vim.cmd("silent! only") + local function staged_row(path) + return { path = path, status = "M", additions = 1, deletions = 0, review = "staged" } + end + local p = Panel.new({ + sections = { + { title = "Staged", entries = { staged_row("a.lua"), staged_row("z.lua") } }, + }, + on_select = function() + return true + end, + }) + p:open() + p:goto_path("z.lua") + p.walk_order = { "gone.lua" } -- the order was taken before this row was listed + local before = #_G.notifs + assert.is_true(p:step_review("prev", true, true)) + local said = #_G.notifs > before and _G.notifs[#_G.notifs].msg or "" + p:close() + assert.is_nil(said:find("wrapped", 1, true)) + end) + it("step_review reports the walk over when every candidate is stale", function() vim.cmd("silent! only") local p = Panel.new({ From dac0749ef2306431d0c7348f595e5318b98ee7e9 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:08:29 +0100 Subject: [PATCH 66/93] refactor: mark a missing final newline in the gutter, not after the line --- lua/differ/ui/highlights.lua | 5 +++-- lua/differ/ui/paint.lua | 7 ------- lua/differ/ui/statuscolumn.lua | 36 +++++++++++++++++++++++++++++----- lua/differ/view.lua | 1 + test/nvim/git_spec.lua | 19 ++++++++++++++++++ test/nvim/paint_spec.lua | 31 ----------------------------- 6 files changed, 54 insertions(+), 45 deletions(-) delete mode 100644 test/nvim/paint_spec.lua diff --git a/lua/differ/ui/highlights.lua b/lua/differ/ui/highlights.lua index bb8735f..69cd8c2 100644 --- a/lua/differ/ui/highlights.lua +++ b/lua/differ/ui/highlights.lua @@ -23,8 +23,6 @@ local LINKS = { -- a dashed fill marks the side that has no line here, the native-vimdiff look, so -- the gap reads as "absent on this side" rather than an empty void. dim like NonText differFiller = { link = "NonText" }, - -- the note after a changed last line with no newline after it - differNoEol = { link = "NonText" }, -- neutral fallback for a staged line with no add/delete kind; kinded staged lines -- use the dimmed deep-diff groups (differStagedLine{Add,Delete}/Word*) instead differStagedLine = { link = "CursorLine" }, @@ -125,6 +123,9 @@ local function status_groups(p) differStagedSign = { fg = p.green }, -- a hunk touching staged content the whole change can't show, in the staged cell differHiddenSign = { fg = p.orange, bold = true }, + -- 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 winbar badge on a view that isn't the whole change differViewBadge = { fg = p.green, bold = true, reverse = true }, } diff --git a/lua/differ/ui/paint.lua b/lua/differ/ui/paint.lua index 71aa00c..ec69987 100644 --- a/lua/differ/ui/paint.lua +++ b/lua/differ/ui/paint.lua @@ -42,13 +42,6 @@ function M.apply(bufnr, ns, column) priority = 100, }) end - if line.no_eol then - vim.api.nvim_buf_set_extmark(bufnr, ns, row, 0, { - virt_text = { { " no newline at end of file", "differNoEol" } }, - virt_text_pos = "eol", - priority = 100, - }) - end local word_hl = WORD_HL[line.kind] if word_hl and line.spans then for _, span in ipairs(line.spans) do diff --git a/lua/differ/ui/statuscolumn.lua b/lua/differ/ui/statuscolumn.lua index c795df8..146331f 100644 --- a/lua/differ/ui/statuscolumn.lua +++ b/lua/differ/ui/statuscolumn.lua @@ -5,6 +5,7 @@ local M = {} local STAGED_GLYPH = "▌" local HIDDEN_GLYPH = "!" +local NO_EOL_GLYPH = "¬" ---@type table -- bufnr -> pre-formatted rail string per lnum local rails = {} @@ -15,6 +16,9 @@ local staged = {} ---@type table> -- bufnr -> lnums opening a hunk marked `!` local hidden = {} +---@type table> -- bufnr -> lnums ending a side with no newline +local no_eol = {} + -- pre-format the gutter string for every line of a column from its map. pure, so -- the statuscolumn callback only does an O(1) index per redraw. a unified -- column shows both rails (old left / new right); a side column shows only its @@ -75,16 +79,34 @@ function M.set_hidden(bufnr, lines) hidden[bufnr] = lines end +-- register the lines that end their side without a newline after them, from the map +-- the column was rendered from. nil where none do, so the cell costs nothing +---@param bufnr integer +---@param map differ.LineMap +function M.set_no_eol(bufnr, map) + local lines + for i, l in ipairs(map.lines) do + if l.no_eol then + lines = lines or {} + lines[i] = true + end + end + no_eol[bufnr] = lines +end + -- drop a buffer's cached rail strings ---@param bufnr integer function M.clear(bufnr) rails[bufnr] = nil staged[bufnr] = nil hidden[bufnr] = nil + no_eol[bufnr] = nil end --- statuscolumn callback: return the rail string for the current line, prefixed by --- the staged-gutter cell when this is a staging-capable view +-- statuscolumn callback: return the rail string for the current line, prefixed by the +-- sign cell where the view has one. a line can carry only one sign: `!` names staged +-- content the diff can't show, `¬` a line its side ends on without a newline, and `▌` +-- a line the index holds, which the line's own shading says as well ---@return string function M.render() -- virtual lines (our thread overlay's virt_lines) reuse the anchor's lnum; without @@ -98,15 +120,19 @@ function M.render() return "" end local s = cache[vim.v.lnum] or "" - local sset = buf and staged[buf] - if not sset then + local sset, eset = buf and staged[buf], buf and no_eol[buf] + if not (sset or eset) then return s end local hset = hidden[buf] if hset and hset[vim.v.lnum] then return "%#differHiddenSign#" .. HIDDEN_GLYPH .. "%*" .. s end - return (sset[vim.v.lnum] and ("%#differStagedSign#" .. STAGED_GLYPH .. "%*") or " ") .. s + if eset and eset[vim.v.lnum] then + return "%#differNoEol#" .. NO_EOL_GLYPH .. "%*" .. s + end + return (sset and sset[vim.v.lnum] and ("%#differStagedSign#" .. STAGED_GLYPH .. "%*") or " ") + .. s end return M diff --git a/lua/differ/view.lua b/lua/differ/view.lua index 7f406e8..6c1e55f 100644 --- a/lua/differ/view.lua +++ b/lua/differ/view.lua @@ -320,6 +320,7 @@ function View:rerender(opts) paint.apply(bufnr, ns, col) syntax.apply(bufnr, col, self.model) statuscolumn.set(bufnr, statuscolumn.format(col)) + statuscolumn.set_no_eol(bufnr, col.map) self.columns[i] = { bufnr = bufnr, winid = existing and existing.winid or nil, diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index 67c0408..3246922 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -2699,6 +2699,25 @@ func getDownloadSpeed() { return vim.api.nvim_eval_statusline(fmt, { winid = win, use_statuscol_lnum = lnum }).str end + -- git's `\ No newline at end of file`, as a gutter glyph beside the line it belongs to + it("marks a line its side ends on without a newline in the gutter", function() + local root = fresh_repo() + write(root .. "/a.lua", "one\ntwo\n") + git(root, "commit", "-q", "-am", "two lines") + write(root .. "/a.lua", "one\ntwo") + vim.cmd.edit(root .. "/a.lua") + local p, v = open_panel() + local marked, plain = {}, gutter_of(v, 1) + for lnum, line in ipairs(v.columns[#v.columns].map.lines) do + if gutter_of(v, lnum):find("¬", 1, true) then + marked[#marked + 1] = line.kind + end + end + p:close() + assert.are.same({ "new" }, marked) -- only the side that lost the newline + assert.is_nil(plain:find("¬", 1, true)) + end) + -- the staged line edited again: the index's version reaches neither side of the diff it("says in the winbar when staged content isn't on screen", function() local root = fresh_repo() diff --git a/test/nvim/paint_spec.lua b/test/nvim/paint_spec.lua deleted file mode 100644 index 637087c..0000000 --- a/test/nvim/paint_spec.lua +++ /dev/null @@ -1,31 +0,0 @@ -local paint = require("differ.ui.paint") -local render = require("differ.render") -local diff = require("differ.model.diff") - -describe("paint no_eol", function() - it("notes a changed last line with no newline after it", function() - local model = diff.build({ - path = "x", - old_rev = "A", - new_rev = "B", - old_text = "a\nb", - new_text = "a\nb\n", - }) - local col = render.render(model, { layout = "stacked", context = math.huge }).columns[1] - local buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_lines(buf, 0, -1, false, col.lines) - local ns = vim.api.nvim_create_namespace("differ_paint_spec") - paint.apply(buf, ns, col) - - local notes = {} - for _, mark in ipairs(vim.api.nvim_buf_get_extmarks(buf, ns, 0, -1, { details = true })) do - local chunks = mark[4].virt_text - if chunks and mark[4].virt_text_pos == "eol" then - notes[#notes + 1] = { row = mark[2], text = vim.trim(chunks[1][1]) } - end - end - vim.api.nvim_buf_delete(buf, { force = true }) - -- rows: a (context), b (old, unterminated), b (new) - assert.are.same({ { row = 1, text = "no newline at end of file" } }, notes) - end) -end) From b4044ba56b1aa3d65486c75ddaea50d2f3470262 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:08:29 +0100 Subject: [PATCH 67/93] docs: close off 0.1.45 and describe staging on one row --- CHANGELOG.md | 23 +++++++++++++++++++++++ README.md | 6 ++++++ RECIPES.md | 2 ++ doc/differ-recipes.txt | 7 +++++++ doc/differ.txt | 22 ++++++++++++++++++++++ 5 files changed, 60 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c97810..e377c3e 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 +- `dw` in the diff or the panel shows a partly staged file's changes since staging, works as a toggle +- `gs` in the panel or the diff previews the commit: only the staged changes, each diffed against `HEAD`, also works as a toggle +- `s` and `u` act on a hunk the index holds only part of, and the diff winbar tallies staged and partial hunks + +### Changed + +- A file changed in both the index and the worktree lists once now, under a new `Partial` section, and its diff shows the whole change with the staged lines shaded. +- Staged content that can't be shown in the standard diff is marked `!`, openable by `dw` +- `X` on a hunk discards it from the index and the file together, instead of asking you to unstage it first +- `s` and `u` walk the change set once and end with what is left, rather than looping back into files they can't act on + +### Fixed + +- A change to a file's final newline showed only a notice. It now shows as a hunk on the last line, with a `¬` in the gutter on whichever side ends without one +- A file whose name holds glob characters, or starts with a digit and a colon, could stage or diff the wrong content, and a symlink diffed the content it points at +- Staging a renamed file from the panel reported "stage failed" although the rename was staged +- Discarding a staged rename or add whose file had been deleted from disk failed +- A file swapped for a symlink refused every staging key in the diff + +## [0.1.45] — 2026-09-12 + +### Added + - `context` accepts `"full"` in config, matching `:Differ context full`, and it is now the default (`math.huge` still works) - A fold you open in the diff view stays open for the rest of the session, including when you come back to its file diff --git a/README.md b/README.md index 52754d3..6d377fa 100644 --- a/README.md +++ b/README.md @@ -226,6 +226,12 @@ The highlight groups differ defines are listed in [recipes](RECIPES.md#highlight Sources with worktree on the new side (`:Differ`, `:Differ `, `:Differ ...`) also list untracked files. `git diff` can't see them whatever refs you pass it, so differ unions in `git ls-files --others --exclude-standard` to bring them in with the other changes. +### Staging + +Every changed path takes only one row. Its diff spans `HEAD` to the worktree, with the lines already in the index shaded, and each row can either be `Conflicts`, `Staged`, `Unstaged`, `Partial` (not fully staged file) or `Untracked`. In the diff, `s` and `u` stage and unstage a hunk respectively, with repeated presses walking through/back your hunks; `X` discards a hunk on both sides (effectively hard resetting the hunk). Similarly in the panel, `s` and `u` stage and unstage entire rows/files (works on sections, directories and individual rows). `S` and `U` are available in both contexts as stage/unstage-all. + +Certain changes can't be displayed in that span, e.g. a change staged and then put back on disk, or a staged and then further modified hunk. Those hunks carry a `!` in the gutter and the winbar says how many exist in the current file. `dw` opens a view to show what changed since staging, where they show as hunks of their own. `gs` opens a view of **only** the files with staged changes, each diffed against the index, i.e. what is about to be committed, where `u` takes one back out. + ### Runtime controls | Command | Effect | diff --git a/RECIPES.md b/RECIPES.md index 18e7c82..9f85253 100644 --- a/RECIPES.md +++ b/RECIPES.md @@ -78,6 +78,8 @@ Every group is defined with `default = true`, so a `:highlight` of your own wins | `differStagedWordAdd` / `differStagedWordDelete` | the word patch inside a staged line | | `differStagedLine` | a staged line with no add/delete kind (links `CursorLine`) | | `differStagedSign` | the staged-hunk gutter glyph | +| `differHiddenSign` | the `!` gutter glyph on a hunk holding staged content the diff can't show | +| `differNoEol` | the `¬` gutter glyph on a line its side ends on with no newline | ### File panel diff --git a/doc/differ-recipes.txt b/doc/differ-recipes.txt index bdd414e..6f7697d 100644 --- a/doc/differ-recipes.txt +++ b/doc/differ-recipes.txt @@ -137,6 +137,13 @@ STAGING *differ-recipes-highlight-groups-staging* kind (links CursorLine) differStagedSign the staged-hunk gutter glyph + + differHiddenSign the ! gutter glyph on a hunk + holding staged content the diff + can’t show + + differNoEol the ¬ gutter glyph on a line its + side ends on with no newline ----------------------------------------------------------------------- FILE PANEL *differ-recipes-highlight-groups-file-panel* diff --git a/doc/differ.txt b/doc/differ.txt index ed32750..ee31572 100644 --- a/doc/differ.txt +++ b/doc/differ.txt @@ -12,6 +12,7 @@ Table of Contents *differ-table-of-contents* - Other managers |differ-installation-other-managers| 4. Configuration |differ-configuration| 5. Usage |differ-usage| + - Staging |differ-usage-staging| - Runtime controls |differ-usage-runtime-controls| - PR review |differ-usage-pr-review| - Session and sidecar commands |differ-usage-session-and-sidecar-commands| @@ -288,6 +289,27 @@ you pass it, so differ unions in `git ls-files --others --exclude-standard` to bring them in with the other changes. +STAGING *differ-usage-staging* + +Every changed path takes only one row. Its diff spans `HEAD` to the worktree, +with the lines already in the index shaded, and each row can either be +`Conflicts`, `Staged`, `Unstaged`, `Partial` (not fully staged file) or +`Untracked`. In the diff, `s` and `u` stage and unstage a hunk respectively, +with repeated presses walking through/back your hunks; `X` discards a hunk on +both sides (effectively hard resetting the hunk). Similarly in the panel, `s` +and `u` stage and unstage entire rows/files (works on sections, directories and +individual rows). `S` and `U` are available in both contexts as +stage/unstage-all. + +Certain changes can’t be displayed in that span, e.g. a change staged and +then put back on disk, or a staged and then further modified hunk. Those hunks +carry a `!` in the gutter and the winbar says how many exist in the current +file. `dw` opens a view to show what changed since staging, where they show as +hunks of their own. `gs` opens a view of **only** the files with staged +changes, each diffed against the index, i.e. what is about to be committed, +where `u` takes one back out. + + RUNTIME CONTROLS *differ-usage-runtime-controls* ------------------------------------------------------------------------------ From 5a58e7069e6ddc4e0cf2ee358fa1bd4a75fda0ac Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:58:26 +0100 Subject: [PATCH 68/93] fix: offer dw as soon as the first s leaves a row partly staged --- lua/differ/git/init.lua | 39 ++++++++++++++++++++++++++++++++++++++- test/nvim/git_spec.lua | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 7635f3d..d174210 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -1521,6 +1521,40 @@ function M.panel(opts) return false end + -- the refreshed list's row for `path`, or nil once it has left the change set + ---@param path string + ---@return differ.FileEntry|nil + local function listed(path) + for _, sec in ipairs(panel and panel.sections or {}) do + for _, e in ipairs(sec.entries) do + if e.path == path then + return e + end + end + end + return nil + end + + -- keep the open row's dw in step with the list: the first s can turn an Unstaged row + -- Partial, and the last a Partial row Staged, without the diff re-sourcing. the + -- LOCAL, INDEX and STAGED views keep the keys they opened with + local function sync_local() + local staging = view and view:is_open() and view.staging + if preview or not (staging and active_entry) or staging.badge then + return + end + local live = listed(active_entry.path) + if not live then + return + end + staging.toggle_local = nil + if staging_ops.partly_staged(live) then + staging.toggle_local = function() + show_local(live) + end + end + end + -- gs: flip the panel between every change and the commit preview, then reopen the -- file on screen in the new listing, else the nearest one set_preview = function(on) @@ -1591,7 +1625,10 @@ function M.panel(opts) footer = footer_label(args, root), actions = actions, on_external_change = refresh_external, - on_refresh = record_state, + on_refresh = function() + sync_local() + record_state() + end, -- the panel's keys move the pair the diff was built from, so the window follows. -- only for the file on screen: another row's diff keeps its frozen marks on_staged = function(paths) diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index 3246922..d9a5445 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -3242,6 +3242,40 @@ func getDownloadSpeed() { assert.are.equal("INDEX", rev_after) end) + -- the first s turns a.lua Partial and the last makes it Staged, with no re-source + -- between: dw follows the row rather than the state the diff opened on + it("offers dw once the first s leaves a row partly staged, and not after the last", function() + local root = fresh_repo() + local ten = {} + for i = 1, 10 do + ten[i] = tostring(i) + end + write(root .. "/a.lua", table.concat(ten, "\n") .. "\n") + git(root, "commit", "-q", "-am", "ten") + ten[2], ten[9] = "2x", "9x" + write(root .. "/a.lua", table.concat(ten, "\n") .. "\n") + vim.cmd.edit(root .. "/a.lua") + local p, v = open_panel() + local col = v.columns[1] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 1), 0 }) + v:stage_hunk() + v:toggle_local() + local partial_badge = view_in_origin(p).staging.badge + view_in_origin(p):toggle_local() -- back to the whole change + v = view_in_origin(p) + col = v.columns[1] + vim.api.nvim_set_current_win(col.winid) + vim.api.nvim_win_set_cursor(col.winid, { hunk_line(v, 2), 0 }) + v:stage_hunk() + _G.notifs = {} + v:toggle_local() + local said = (_G.notifs[#_G.notifs] or {}).msg + p:close() + assert.are.equal("LOCAL", partial_badge) + assert.are.equal("differ: only a partly staged file has a local view", said) + end) + it("dw in the panel refuses a row with nothing staged", function() local root = fresh_repo() write(root .. "/a.lua", "one\ntwo\n") From 3e0a31cd6b31cf590ac455410c627f600f89f1e8 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Wed, 23 Sep 2026 20:24:35 +0100 Subject: [PATCH 69/93] test: pin the identity on the conflict spec's merge so a bare runner conflicts --- test/nvim/git_spec.lua | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index d9a5445..72ca8b8 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -625,7 +625,12 @@ describe("git.status_sections", function() git(root, "checkout", "-q", "main") write(root .. "/a.lua", "main\n") git(root, "commit", "-q", "-am", "main") - vim.system({ "git", "merge", "-q", "side" }, { cwd = root }):wait() -- conflicts + -- conflicts, so the asserting helper can't run it; the identity is pinned inline + -- or a runner with no global gitconfig aborts the merge before it conflicts + vim.system( + { "git", "-c", "user.email=t@t", "-c", "user.name=t", "merge", "-q", "side" }, + { cwd = root } + ):wait() write(root .. "/b.lua", "b\n") assert.are.same({ From 571e5c0e6a2e1ed506b6cf609e11f9bd1658687a Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:08:34 +0100 Subject: [PATCH 70/93] docs: re-record the demo on the one-row staging --- .demo/demo.gif | Bin 2524058 -> 2477608 bytes .demo/demo.mp4 | Bin 1453942 -> 1432075 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/.demo/demo.gif b/.demo/demo.gif index 6290607a3cd1baed7f7c4b217ef5404b6603cf13..16fff8984c0dab58695346d789e4a429ee5e53b0 100644 GIT binary patch delta 2256852 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