From a1238a8b0438e4d6f5de0a16769f11b3405906fb Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:45:29 +0100 Subject: [PATCH 1/7] fix: open zero-hunk entries on a notice naming the reason --- lua/differ/git/init.lua | 76 ++++++++++++++++++++++++++++++----- lua/differ/git/rev.lua | 8 ++++ lua/differ/model/diff.lua | 16 ++++++++ lua/differ/render/split.lua | 9 +++-- lua/differ/render/stacked.lua | 8 ++-- test/nvim/diff_spec.lua | 29 +++++++++++++ test/nvim/git_spec.lua | 68 +++++++++++++++++++++++++++++++ test/unit/rev_spec.lua | 19 +++++++++ test/unit/split_spec.lua | 33 +++++++++++++++ test/unit/stacked_spec.lua | 16 ++++++++ 10 files changed, 266 insertions(+), 16 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index fa63462..eb9e764 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -737,6 +737,58 @@ local function live_status(root, entry) return nil end +-- the path's `diff --raw` line under `args`'s pair, or nil when the pair no longer +-- lists it at all +---@param root string +---@param args string[] +---@param path string +---@return string|nil +local function raw_line(root, args, path) + local full = { "diff", "--raw" } + vim.list_extend(full, args) + vim.list_extend(full, { "--", path }) + local out = git(full, root) + if not out or chomp(out) == "" then + return nil + end + return out +end + +-- the notice a zero-hunk entry opens on, or nil when it's stale (committed, staged +-- away or reverted outside differ) and the caller should re-source instead. staleness +-- is "the listing no longer carries it": `diff` for a tracked path, status for an +-- untracked one, which `diff` never reports. an eol change can't be stale, since the +-- sides still differ +---@param root string +---@param entry differ.FileEntry +---@param model differ.DiffModel +---@param args string[] -- names the entry's pair for `diff --raw` +---@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 + end + local out = raw_line(root, args, entry.path) + if not out then + return nil + end + if (entry.status == "R" or entry.status == "C") and entry.previous_path then + return ("Renamed from %s, content unchanged"):format(entry.previous_path) + end + local old_mode, new_mode = rev.parse_raw_modes(out) + if old_mode and new_mode and old_mode ~= new_mode then + return ("Mode changed %s → %s"):format(old_mode, new_mode) + end + return empty_or_not +end + -- discard a file's changes: untracked or a staged-add drops the file (unstaging -- first if needed); anything tracked in HEAD reverts index + worktree to HEAD. -- destructive, so the panel confirms before calling this. confirm() drains scheduled @@ -933,7 +985,7 @@ function M.panel(opts) -- 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 - local sections, model_for, actions + local sections, model_for, raw_args_for, actions if is_worktree_status(source) then sections = M.status_sections(root) model_for = function(entry) @@ -943,6 +995,11 @@ function M.panel(opts) -- 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 + raw_args_for = function(entry) + return entry.staged and { "--cached" } or {} + end actions = { stage = function(entry) M.stage(root, entry.path) @@ -968,6 +1025,9 @@ function M.panel(opts) model_for = function(entry) return M.model(source, root, entry, branch) end + raw_args_for = function() + return rev.diff_args(source) + end end local nonempty, total = nonempty_sections(sections) @@ -1172,16 +1232,14 @@ function M.panel(opts) local function show_entry(entry, focus_line, focus_col) local model = model_for(entry) if #model.hunks == 0 and not model.binary then - -- a pure rename or copy has identical content on both sides, so it diffs - -- to zero hunks but is still a real change worth opening (the file just - -- moved). a binary file also has no hunks but renders a placeholder, so it - -- opens too. any other zero-hunk entry is stale: committed, staged away, or - -- reverted outside differ - local is_move = (entry.status == "R" or entry.status == "C") - and entry.previous_path ~= nil - if not is_move 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; + -- a stale entry has no notice and tells the caller to re-source + local notice = empty_notice(root, entry, model, raw_args_for(entry)) + if not notice then return false end + model.notice = notice end local staging = stage_for(entry) if view and view:is_open() then diff --git a/lua/differ/git/rev.lua b/lua/differ/git/rev.lua index 9b4f9a7..ace2c2a 100644 --- a/lua/differ/git/rev.lua +++ b/lua/differ/git/rev.lua @@ -109,6 +109,14 @@ function M.diff_args(source) return { o.rev, n.rev } end +-- the old/new file modes off a `git diff --raw` run, whose first line is +-- ": \t" +---@param out string +---@return string|nil old_mode, string|nil new_mode +function M.parse_raw_modes(out) + return out:match("^:(%d+) (%d+) ") +end + -- split a NUL-delimited byte string into fields. pure (avoids vim.split's vim dep) -- and trailing-NUL tolerant, matching git's `-z` framing ---@param s string diff --git a/lua/differ/model/diff.lua b/lua/differ/model/diff.lua index 1490baa..f1d3c6a 100644 --- a/lua/differ/model/diff.lua +++ b/lua/differ/model/diff.lua @@ -19,6 +19,7 @@ ---@field head string|nil -- git branch, for the synthetic buffer's statusline (set by the frontend) ---@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 local text_util = require("differ.util.text") local to_lines = text_util.to_lines @@ -106,6 +107,21 @@ 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) +---@param model differ.DiffModel +---@return "empty"|"eol_added"|"eol_removed"|"identical"|nil +function M.empty_reason(model) + if #model.hunks > 0 or model.binary 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 + -- 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 diff --git a/lua/differ/render/split.lua b/lua/differ/render/split.lua index 14a4e6a..55eed26 100644 --- a/lua/differ/render/split.lua +++ b/lua/differ/render/split.lua @@ -62,10 +62,11 @@ function M.render(model, opts) } end - -- a binary file isn't diffed (it would blow up the word pass); show a placeholder - -- on both sides so the columns stay row-aligned - if model.binary then - push_row(BINARY_NOTICE, { kind = "meta" }, BINARY_NOTICE, { kind = "meta" }) + -- a binary file isn't diffed (it would blow up the word pass), and a zero-hunk + -- entry has no lines to show; both notices go on both sides, keeping the rows aligned + local notice = model.binary and BINARY_NOTICE or model.notice + if notice then + push_row(notice, { kind = "meta" }, notice, { kind = "meta" }) return result() end diff --git a/lua/differ/render/stacked.lua b/lua/differ/render/stacked.lua index e4b060d..4cb8cb5 100644 --- a/lua/differ/render/stacked.lua +++ b/lua/differ/render/stacked.lua @@ -35,11 +35,13 @@ function M.render(model, opts) end end - -- a binary file isn't diffed (it would blow up the word pass); show a placeholder - if model.binary then + -- a binary file isn't diffed (it would blow up the word pass), and a zero-hunk + -- entry has no lines to show; both render as a one-line notice + local notice = model.binary and BINARY_NOTICE or model.notice + if notice then map:push({ kind = "meta" }) return { - columns = { { lines = { BINARY_NOTICE }, map = map, side = "unified", folds = folds } }, + columns = { { lines = { notice }, map = map, side = "unified", folds = folds } }, rows = 1, } end diff --git a/test/nvim/diff_spec.lua b/test/nvim/diff_spec.lua index 9ba6369..863a3b9 100644 --- a/test/nvim/diff_spec.lua +++ b/test/nvim/diff_spec.lua @@ -162,3 +162,32 @@ describe("model.diff.revert_hunk", function() assert.are.equal("/repo", r.root) end) end) + +describe("model.diff.empty_reason", function() + ---@param old string + ---@param new string + local function reason(old, new) + return diff.empty_reason( + diff.build({ path = "x", old_rev = "A", new_rev = "B", old_text = old, new_text = new }) + ) + end + + it("is nil when there are hunks to show", function() + assert.is_nil(reason("a\n", "b\n")) + end) + + it("names an empty file, and identical content apart", function() + assert.are.equal("empty", reason("", "")) + 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")) + end) + + it("is nil for binary content, which has its own placeholder", function() + assert.is_nil(reason("a\0b", "a\0c")) + end) +end) diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index 9485b8c..f1fdbb6 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -3425,3 +3425,71 @@ describe("git session teardown after :q on the diff", function() assert.are.equal(aus_before, viewclose_autocmds()) end) end) + +-- a change that diffs to no lines still opens a diff view (the session's anchor) on +-- a notice naming the reason, rather than refusing the selection and re-notifying +describe(":Differ panel (zero-hunk entries)", function() + local Panel = require("differ.panel") + + local function open_only_entry(root) + git_src.panel({ open_first = true }) + local p = assert(Panel.current()) + vim.api.nvim_set_current_win(p.origin_win) + return p, require("differ.view").current() + end + + it("opens a mode-only change on a notice naming both modes", function() + local root = fresh_repo() + git(root, "config", "core.fileMode", "true") + assert((vim.uv or vim.loop).fs_chmod(root .. "/a.lua", 493)) -- 0755 + vim.cmd.edit(root .. "/a.lua") + + local p, v = open_only_entry(root) + assert.is_not_nil(v) + assert.are.equal("Mode changed 100644 → 100755", v.model.notice) + assert.are.same( + { "Mode changed 100644 → 100755" }, + vim.api.nvim_buf_get_lines(v.columns[1].bufnr, 0, -1, false) + ) + p:close() + end) + + it("opens an empty untracked file on a notice", function() + local root = fresh_repo() + write(root .. "/empty.txt", "") + vim.cmd.edit(root .. "/empty.txt") + + local p, v = open_only_entry(root) + assert.is_not_nil(v) + assert.are.equal("empty.txt", v.model.path) + assert.are.equal("Empty file", v.model.notice) + p:close() + end) + + it("opens a final-newline-only change on a notice", 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) + p:close() + end) + + it("still refuses a stale entry, so the list re-sources instead", function() + local root = fresh_repo() + write(root .. "/a.lua", "local x = 2\nreturn x\n") + vim.cmd.edit(root .. "/a.lua") + git_src.panel({}) + local p = assert(Panel.current()) + + -- commit the change behind the panel's back: its entry now diffs to nothing + git(root, "commit", "-qam", "outside") + -- on_select is what the panel calls on ; false is "stale, re-source" + assert.is_false(p.on_select({ path = "a.lua", status = "M", staged = false })) + if Panel.current() then + p:close() -- the refresh may have emptied the list and ended the session + end + end) +end) diff --git a/test/unit/rev_spec.lua b/test/unit/rev_spec.lua index f4d6ad7..3940ebf 100644 --- a/test/unit/rev_spec.lua +++ b/test/unit/rev_spec.lua @@ -179,3 +179,22 @@ describe("git.rev.valid_ref", function() end end) end) + +describe("git.rev.parse_raw_modes", function() + it("reads both modes off a --raw line", function() + local old, new = rev.parse_raw_modes(":100644 100755 e69de29 e69de29 M\ta.lua\n") + assert.are.equal("100644", old) + assert.are.equal("100755", new) + end) + + it("reads a rename line, whose path half carries two paths", function() + local old, new = + rev.parse_raw_modes(":100644 100644 aaaaaaa aaaaaaa R100\told.lua\tnew.lua\n") + assert.are.equal("100644", old) + assert.are.equal("100644", new) + end) + + it("is nil when the run listed nothing", function() + assert.is_nil(rev.parse_raw_modes("")) + end) +end) diff --git a/test/unit/split_spec.lua b/test/unit/split_spec.lua index 1b667de..8693359 100644 --- a/test/unit/split_spec.lua +++ b/test/unit/split_spec.lua @@ -236,3 +236,36 @@ describe("render.split identical content", function() assert.are.equal(0, r.new_map:len()) end) end) + +describe("render.split notice", function() + -- a zero-hunk entry renders its notice on both sides, like a binary file does + local model = { + path = "x", + old_rev = "A", + new_rev = "B", + old_text = "a\n", + new_text = "a\n", + hunks = {}, + notice = "Mode changed 100644 → 100755", + } + + it("renders the notice as a meta row on both sides", function() + local r = render(model, { context = FULL }) + assert.are.same({ "Mode changed 100644 → 100755" }, r.old_lines) + assert.are.same({ "Mode changed 100644 → 100755" }, r.new_lines) + assert.are.same({ "meta" }, kinds(r.old_map)) + assert.are.same({ "meta" }, kinds(r.new_map)) + end) + + it("leaves a zero-hunk model with no notice empty", function() + local r = render({ + path = "x", + old_rev = "A", + new_rev = "B", + old_text = "a\n", + new_text = "a\n", + hunks = {}, + }, { context = FULL }) + assert.are.same({}, r.old_lines) + end) +end) diff --git a/test/unit/stacked_spec.lua b/test/unit/stacked_spec.lua index 4162036..e2e587e 100644 --- a/test/unit/stacked_spec.lua +++ b/test/unit/stacked_spec.lua @@ -235,3 +235,19 @@ describe("render.stacked identical content", function() assert.are.equal(0, r.map:len()) end) end) + +describe("render.stacked notice", function() + it("renders a zero-hunk entry's notice as a single meta row", function() + local col = render({ + path = "x", + old_rev = "A", + new_rev = "B", + old_text = "a\n", + new_text = "a\n", + hunks = {}, + notice = "Final newline removed", + }, { context = FULL }) + assert.are.same({ "Final newline removed" }, col.lines) + assert.are.equal("meta", col.map.lines[1].kind) + end) +end) From 1bb97775ac89ca0b765c9a7341f5d8fb723c4ca8 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:57:28 +0100 Subject: [PATCH 2/7] fix: name a submodule pointer move rather than calling it empty --- lua/differ/git/init.lua | 18 ++++++++++++++---- test/nvim/git_spec.lua | 26 ++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index eb9e764..a46bfc5 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -23,6 +23,9 @@ local WORKTREE = { kind = "worktree", label = "WORKTREE" } -- so its files list and read as pure adds (history) 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) @@ -755,10 +758,7 @@ local function raw_line(root, args, path) end -- the notice a zero-hunk entry opens on, or nil when it's stale (committed, staged --- away or reverted outside differ) and the caller should re-source instead. staleness --- is "the listing no longer carries it": `diff` for a tracked path, status for an --- untracked one, which `diff` never reports. an eol change can't be stale, since the --- sides still differ +-- away or reverted outside differ) and the caller should re-source instead. ---@param root string ---@param entry differ.FileEntry ---@param model differ.DiffModel @@ -783,6 +783,16 @@ local function empty_notice(root, entry, model, args) return ("Renamed from %s, content unchanged"):format(entry.previous_path) end local old_mode, new_mode = rev.parse_raw_modes(out) + -- a gitlink has no blob behind it (`git show :path` fails), so both sides read + -- empty however the pointer moved. + if old_mode == GITLINK or new_mode == GITLINK then + if old_mode ~= GITLINK then + return "Submodule added" + elseif new_mode ~= GITLINK then + return "Submodule removed" + end + return "Submodule commit changed" + end if old_mode and new_mode and old_mode ~= new_mode then return ("Mode changed %s → %s"):format(old_mode, new_mode) end diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index f1fdbb6..342c531 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -3477,6 +3477,32 @@ describe(":Differ panel (zero-hunk entries)", function() p:close() end) + it("names a moved submodule pointer, which reads empty on both sides", function() + local root = fresh_repo() + -- the submodule's own repo lives outside root, so it adds no untracked entry + local sub = vim.fn.tempname() + vim.fn.mkdir(sub, "p") + git(sub, "init", "-q") + write(sub .. "/f", "one\n") + git(sub, "add", "f") + git(sub, "commit", "-q", "-m", "one") + + git(root, "-c", "protocol.file.allow=always", "submodule", "add", "-q", sub, "mod") + git(root, "commit", "-q", "-m", "add submodule") + write(root .. "/mod/f", "two\n") -- move the pointer: commit inside the submodule + git(root .. "/mod", "commit", "-q", "-am", "two") + vim.cmd.edit(root .. "/a.lua") -- put the session's origin inside this repo + + local p, v = open_only_entry(root) + assert.is_not_nil(v) + assert.are.equal("mod", v.model.path) + -- `git show :mod` fails on a gitlink, so the model has no content either side + assert.are.equal("", v.model.old_text) + assert.are.equal("", v.model.new_text) + assert.are.equal("Submodule commit changed", v.model.notice) + p:close() + end) + it("still refuses a stale entry, so the list re-sources instead", function() local root = fresh_repo() write(root .. "/a.lua", "local x = 2\nreturn x\n") From 6a10e9ea06dae7b1cd00328bb337bb7d92afeb8f Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:31:36 +0100 Subject: [PATCH 3/7] fix: stage whole-file entries instead of stepping to another file --- lua/differ/git/init.lua | 22 ++++++-- lua/differ/view.lua | 51 +++++++++++++----- test/nvim/git_spec.lua | 112 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 169 insertions(+), 16 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index a46bfc5..d761b54 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -1161,8 +1161,9 @@ function M.panel(opts) 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) + local function stage_for(entry, diff) if not stageable then return nil end @@ -1170,7 +1171,7 @@ function M.panel(opts) local function settle() return settle_pair() end - if entry.status == "M" then + if entry.status == "M" and #diff.hunks > 0 then return { initial = entry.staged and "staged" or "unstaged", apply = function(model, hunk, offset, reverse) @@ -1200,9 +1201,23 @@ function M.panel(opts) end return M.stage(root, entry.path) end + -- a modification with no hunks to patch: a mode change, a submodule pointer, a + -- binary file, a change git normalises away. `git add` stages exactly what git + -- would. no revert: `git checkout --` would also throw away content the diff + -- never showed, and the panel's own X still discards behind its confirm + if entry.status == "M" then + return { + initial = entry.staged and "staged" or "unstaged", + 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", + whole_file = true, apply = whole_file_apply, -- the file is the hunk, so reverting it is deleting it; `discard` -- already knows to drop the staged add first @@ -1217,6 +1232,7 @@ function M.panel(opts) if entry.status == "D" then return { initial = entry.staged and "staged" or "unstaged", + whole_file = true, apply = whole_file_apply, -- the mirror: a deletion is restored rather than removed revert = function() @@ -1251,7 +1267,7 @@ function M.panel(opts) end model.notice = notice end - local staging = stage_for(entry) + local staging = stage_for(entry, model) if view and view:is_open() then view:set_source( model, diff --git a/lua/differ/view.lua b/lua/differ/view.lua index 8f9744b..b446b71 100644 --- a/lua/differ/view.lua +++ b/lua/differ/view.lua @@ -80,7 +80,9 @@ local armed_view = nil -- what it will do to the file, the view words the question ---@class differ.view.Staging ---@field initial "staged"|"unstaged" ----@field apply? fun(model: differ.DiffModel, hunk: differ.Hunk, offset: integer, reverse: boolean): boolean +---@field whole_file? boolean -- stages as a unit: the keys act on the file, not the hunk under the cursor +-- `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 revert_label? string -- e.g. "deletes the file" ---@field refresh fun() @@ -169,13 +171,16 @@ function View.new(model, opts) end -- seed per-hunk staged state for the current source: a staged diff (HEAD↔index) --- opens with every hunk staged, an unstaged diff (index↔worktree) with none +-- opens with every hunk staged, an unstaged diff (index↔worktree) with none. a +-- whole-file source seeds its single slot whether or not a hunk backs it, so a +-- staged mode change (which has none) still opens knowing it's staged function View:_init_staged() self.staged_hunks = {} - if self.staging and self.staging.initial == "staged" then - for i = 1, #self.model.hunks do - self.staged_hunks[i] = true - end + if not (self.staging and self.staging.initial == "staged") then + return + end + for i = 1, math.max(#self.model.hunks, self:_whole_file() and 1 or 0) do + self.staged_hunks[i] = true end end @@ -770,7 +775,8 @@ function View:show_help() -- per-file, not session-wide: a pure rename carries no staging capability at all, so -- listing s/u there would advertise keys that refuse if self:_can_stage_hunk() then - rows[#rows + 1] = { pair(km.stage, km.unstage), "stage / unstage hunk" } + local unit = self:_whole_file() and "file" or "hunk" + rows[#rows + 1] = { pair(km.stage, km.unstage), "stage / unstage " .. unit } rows[#rows + 1] = { pair(km.stage_all, km.unstage_all), "stage / unstage all" } end if self.staging and self.staging.revert then @@ -990,13 +996,31 @@ function View:_hunk_index_under_cursor() return line and line.hunk or nil end --- whether the hunk-staging keys act here: the session allows staging and this file's --- capability implements it. gated on `apply` alone, since `revert` gates X on its own +-- whether the staging keys act here: the session allows staging and this file's +-- capability implements it ---@return boolean function View:_can_stage_hunk() return self.can_stage and self.staging ~= nil and self.staging.apply ~= nil end +-- whether this file stages as one unit (e.g. a new/deleted file, a binary one, +-- a change with no lines to show). +---@return boolean +function View:_whole_file() + return (self.staging and self.staging.whole_file) == true +end + +-- the slot the staging keys act on: a whole-file source always uses slot 1, which is +-- its one hunk where it has one and a virtual slot where it doesn't. anything else +-- takes the hunk under the cursor +---@return integer|nil +function View:_target_index() + if self:_whole_file() then + return 1 + end + 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 @@ -1025,7 +1049,7 @@ function View:stage_hunk() if not self:_can_stage_hunk() then return vim.notify("differ: hunk staging isn't available here", vim.log.levels.WARN) end - local idx = self:_hunk_index_under_cursor() + local idx = self:_target_index() if idx and not (self.staged_hunks[idx] or false) then self:_toggle_hunk(true) else @@ -1039,7 +1063,7 @@ function View:unstage_hunk() if not self:_can_stage_hunk() then return vim.notify("differ: hunk staging isn't available here", vim.log.levels.WARN) end - local idx = self:_hunk_index_under_cursor() + local idx = self:_target_index() if idx and (self.staged_hunks[idx] or false) then self:_toggle_hunk(false) else @@ -1118,7 +1142,7 @@ function View:_toggle_hunk(want_staged) if not self:_can_stage_hunk() then return vim.notify("differ: hunk staging isn't available here", vim.log.levels.WARN) end - local idx = self:_hunk_index_under_cursor() + local idx = self:_target_index() if not idx then return vim.notify("differ: no hunk under the cursor", vim.log.levels.WARN) end @@ -1176,7 +1200,8 @@ function View:_toggle_all(want_staged) return false end local changed = false - for i = 1, #self.model.hunks do + -- a whole-file source has one slot to act on, backed by a hunk or not + for i = 1, math.max(#self.model.hunks, self:_whole_file() and 1 or 0) do if self:_apply_hunk(i, want_staged) then changed = true end diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index 342c531..69593ba 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -3519,3 +3519,115 @@ describe(":Differ panel (zero-hunk entries)", function() end end) end) + +-- a whole-file source (a new or deleted file, a binary one, a change with no lines +-- to show) stages as a unit. the keys act on the file rather than on the hunk under +-- the cursor, which is what used to send them stepping off to another file +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+)")) + 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 + return m.entry + end + end + end + + it("stages a mode-only change with s instead of stepping to another file", function() + local root = fresh_repo() + git(root, "config", "core.fileMode", "true") + write(root .. "/b.lua", "other\n") -- a second file, to catch a step away + git(root, "add", "b.lua") + git(root, "commit", "-q", "-m", "two files") + 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) + 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) + assert.are.equal("100644", index_mode(root, "a.lua")) + + v:stage_hunk() + + assert.are.equal("100755", index_mode(root, "a.lua")) -- the mode really staged + assert.is_not_nil(staged_entry(p, "a.lua")) + assert.are.equal("a.lua", view_in_origin(p).model.path) -- never stepped away + p:close() + end) + + it("unstages a staged mode change with u, which needs its seeded staged state", function() + local root = fresh_repo() + git(root, "config", "core.fileMode", "true") + assert((vim.uv or vim.loop).fs_chmod(root .. "/a.lua", 493)) + 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) + assert.are.equal("staged", v.staging.initial) + assert.is_true(v.staged_hunks[1]) -- seeded with no hunk behind it + assert.are.equal("100755", index_mode(root, "a.lua")) + + v:unstage_hunk() + + assert.are.equal("100644", index_mode(root, "a.lua")) + p:close() + end) + + it("stages a new file from the old column's filler rows in split layout", function() + local root = fresh_repo() + write(root .. "/new.lua", "one\ntwo\n") -- untracked: a pure add + 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) + assert.are.equal("new.lua", v.model.path) + v:toggle_layout() -- -> split; the old column is all filler + assert.are.equal(2, #v.columns) + + -- a pure add carries no old lines, so every old-column row is a meta row with + -- no hunk on it: the cursor lookup finds nothing there + vim.api.nvim_set_current_win(v.columns[1].winid) + vim.api.nvim_win_set_cursor(v.columns[1].winid, { 1, 0 }) + assert.is_nil(v:_hunk_index_under_cursor()) + + v:stage_hunk() + + assert.are.equal("one\ntwo\n", git(root, "show", ":new.lua")) + assert.is_not_nil(staged_entry(p, "new.lua")) + p:close() + end) + + it("advertises the file, not the hunk, in the diff help", function() + local root = fresh_repo() + 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) + assert.is_true(v.staging.whole_file) + v:show_help() + local help_buf = vim.api.nvim_win_get_buf(0) + local text = table.concat(vim.api.nvim_buf_get_lines(help_buf, 0, -1, false), "\n") + assert.is_truthy(text:find("stage / unstage file", 1, true)) + assert.is_nil(text:find("stage / unstage hunk", 1, true)) + vim.api.nvim_win_close(0, true) + p:close() + end) +end) From 0678adeede63c9d418f6f8449d2922346be68667 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:48:11 +0100 Subject: [PATCH 4/7] fix: report git's error when a listing fails, not "no changes" --- lua/differ/git/init.lua | 70 ++++++++++++++++++++++++++++------------- test/nvim/git_spec.lua | 68 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 21 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index d761b54..1c3de64 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -374,27 +374,32 @@ function M.is_conflicted(root, relpath) return false end --- list changed files for a resolved source (used by the picker/panel) +-- list changed files for a resolved source (used by the picker/panel). rev.source is +-- pure and can't tell a real ref from a typo, so this is the first call that finds +-- out: git's stderr rides along, else a typo reads as an empty change set ---@param source differ.git.Source ---@param root string ----@return differ.git.ChangedFile[] +---@return differ.git.ChangedFile[] files, string|nil err function M.changed_files(source, root) local args = { "diff", "--name-status", "-z" } vim.list_extend(args, rev.diff_args(source)) - local out = git(args, root) + local out, err = git(args, root) if not out then - return {} + return {}, err end return rev.parse_name_status(out) end --- the commits behind a history request, newest first. an empty list on git --- failure (e.g. the path has no history). pure arg-building/parsing live in git/log.lua +-- the commits behind a history request, newest first, plus git's stderr when the +-- run failed. a path with no history exits clean with no output, so an empty list +-- and no error is "nothing to show" rather than a failure. pure arg-building/parsing +-- live in git/log.lua ---@param root string ---@param opts differ.git.LogOpts ----@return differ.git.Commit[] +---@return differ.git.Commit[] commits, string|nil err function M.log_commits(root, opts) - return log.parse_log(git(log.log_args(opts), root) or "") + local out, err = git(log.log_args(opts), root) + return log.parse_log(out or ""), err end -- the "old" side for a commit's own diff: its first parent, or the empty tree when @@ -415,7 +420,7 @@ end -- to keep only the right side (the branch's own commits), mirroring the dp flow ---@param root string ---@param range string ----@return differ.git.Commit[] +---@return differ.git.Commit[] commits, string|nil err function M.range_commits(root, range) local extra = vim.split(range, "%s+", { trimempty = true }) extra[#extra + 1] = "--no-merges" @@ -435,7 +440,8 @@ function M.commit_files(root, sha) old = parent_or_empty(root, sha), new = { kind = "rev", rev = sha, label = sha:sub(1, 7) }, } - return M.file_entries(source, root) + -- the sha came from git's own log, so there's no revspec here to mistype + return (M.file_entries(source, root)) end -- resolve a source's refs to concrete revs (merge_base -> rev). returns nil if a @@ -538,11 +544,15 @@ end -- disjoint ---@param source differ.git.Source -- resolved ---@param root string ----@return differ.FileEntry[] +---@return differ.FileEntry[] entries, string|nil err function M.file_entries(source, root) + local files, err = M.changed_files(source, root) + if err then + return {}, err -- a failed listing isn't an empty one; the caller reports it + end local counts = numstat(rev.diff_args(source), root) local out = {} - for _, f in ipairs(M.changed_files(source, root)) do + for _, f in ipairs(files) do local c = counts[f.path] or {} out[#out + 1] = { path = f.path, @@ -572,9 +582,10 @@ end -- (X status, HEAD↔index counts) and Unstaged (Y status, index↔worktree counts). -- empty sections are dropped by the caller ---@param root string ----@return differ.panel.Section[] +---@return differ.panel.Section[] sections, string|nil err function M.status_sections(root) - local entries = rev.parse_status(git({ "status", "--porcelain=v1", "-z", "-uall" }, root) or "") + 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 staged, unstaged, untracked = {}, {}, {} @@ -614,11 +625,12 @@ function M.status_sections(root) end end end - return { + local sections = { { title = "Staged", entries = staged }, { title = "Unstaged", entries = unstaged }, { title = "Untracked", entries = untracked }, } + return sections, err end -- file-level staging ops driven from the panel (slice C); each is whole-file @@ -996,8 +1008,9 @@ function M.panel(opts) -- rev-pair list diffs every entry against the one resolved source. `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 = M.status_sections(root) + 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 } @@ -1027,11 +1040,14 @@ function M.panel(opts) M.discard(root, entry) end, reload = function() - return (nonempty_sections(M.status_sections(root))) + local live = M.status_sections(root) + return (nonempty_sections(live)) end, } else - sections = { { title = "Changes", entries = M.file_entries(source, root) } } + local entries + entries, list_err = M.file_entries(source, root) + sections = { { title = "Changes", entries = entries } } model_for = function(entry) return M.model(source, root, entry, branch) end @@ -1042,6 +1058,11 @@ function M.panel(opts) local nonempty, total = nonempty_sections(sections) if total == 0 then + -- an empty list and a failed one look the same from here, and a mistyped + -- revspec only ever lands as the second: report what git said + if list_err then + return notify(chomp(list_err), vim.log.levels.ERROR) + end return notify("no changes for this source") end @@ -1295,7 +1316,8 @@ function M.panel(opts) ---@return differ.FileEntry[] local function entries_for_path(path) local out = {} - for _, sec in ipairs(M.status_sections(root)) do + local live = M.status_sections(root) + for _, sec in ipairs(live) do for _, e in ipairs(sec.entries) do if e.path == path then out[#out + 1] = e @@ -1551,8 +1573,11 @@ function M.history(opts) -- file we're in; a `:Differ log ` has no meaningful origin line local origin = (origin_buf ~= "" and origin_buf == file) and origin_line or nil - local commits = M.log_commits(root, { path = relpath }) + local commits, err = M.log_commits(root, { path = relpath }) if #commits == 0 then + if err then + return notify(chomp(err), vim.log.levels.ERROR) + end return notify("no history for " .. relpath) end local branch = head_branch(root) @@ -1635,8 +1660,11 @@ function M.range_history(opts) if not root then return notify("not inside a git repository", vim.log.levels.WARN) end - local commits = M.range_commits(root, range) + local commits, err = M.range_commits(root, range) if #commits == 0 then + if err then + return notify(chomp(err), vim.log.levels.ERROR) + end return notify("no commits in " .. range) end local branch = head_branch(root) diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index 69593ba..60d324a 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -3631,3 +3631,71 @@ describe(":Differ diff whole-file staging", function() p:close() end) end) + +-- rev.source turns any leftover arg into a rev ref, so a typo reaches git as a real +-- lookup and comes back as a failed listing. reporting it as "no changes" hides that +describe("git listing errors", function() + local Panel = require("differ.panel") + + -- the notification the call under test produced, if any + local function last_notif() + return _G.notifs[#_G.notifs] + end + + it("carries git's stderr off a failed listing instead of an empty list", function() + local root = fresh_repo() + local source = { + old = { kind = "rev", rev = "nosuchref", label = "nosuchref" }, + new = { + kind = "worktree", + label = "WORKTREE", + }, + } + local files, err = git_src.changed_files(source, root) + assert.are.same({}, files) + assert.is_truthy(err) + assert.is_truthy(err:find("nosuchref", 1, true)) + + -- file_entries stops at the failure rather than listing untracked files past it + local entries, ferr = git_src.file_entries(source, root) + assert.are.same({}, entries) + assert.are.equal(err, ferr) + end) + + it("reports a mistyped revspec at ERROR, in git's words", function() + local root = fresh_repo() + write(root .. "/a.lua", "local x = 2\nreturn x\n") -- a real change, so INFO would lie + vim.cmd.edit(root .. "/a.lua") + + _G.notifs = {} + local p = git_src.panel({ rev = { "nosuchref" } }) + assert.is_nil(p) -- no session opened + assert.is_nil(Panel.current()) + local n = last_notif() + assert.are.equal(vim.log.levels.ERROR, n.level) + assert.is_truthy(n.msg:find("nosuchref", 1, true)) + assert.is_nil(n.msg:find("no changes for this source", 1, true)) + end) + + it("still reports a genuinely empty source at INFO", function() + local root = fresh_repo() -- clean worktree: nothing to show, and no failure + vim.cmd.edit(root .. "/a.lua") + + _G.notifs = {} + assert.is_nil(git_src.panel({})) + local n = last_notif() + assert.are.equal("differ: no changes for this source", n.msg) + assert.are.equal(vim.log.levels.INFO, n.level) + end) + + it("reports a mistyped rev-range history at ERROR too", function() + local root = fresh_repo() + vim.cmd.edit(root .. "/a.lua") + + _G.notifs = {} + git_src.range_history({ range = "nosuchref...HEAD" }) + local n = last_notif() + assert.are.equal(vim.log.levels.ERROR, n.level) + assert.is_truthy(n.msg:find("nosuchref", 1, true)) + end) +end) From 0e57925d53c7d1d9a516b8ceda55b6032529d6b6 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:03:29 +0100 Subject: [PATCH 5/7] perf: count untracked lines from disk once, not on every refresh --- lua/differ/git/init.lua | 66 +++++++++++++++++++++++++++++++---------- test/nvim/git_spec.lua | 54 +++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 15 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 1c3de64..7f2c7ed 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -190,6 +190,22 @@ local function resolve_ref(ref, root) return { kind = "rev", rev = chomp(out), label = ref.label } end +-- a file's bytes as they sit on disk, or nil when it isn't readable +---@param abs string +---@return string|nil +local function read_file(abs) + if vim.fn.filereadable(abs) == 0 then + return nil + end + local fd = io.open(abs, "rb") + if not fd then + return nil + end + local data = fd:read("*a") + fd:close() + return data +end + -- worktree bytes as `git add` would store them (the clean filter: eol -- conversion, text attrs, custom filters), so worktree-side diffs compare in -- the repo domain, like `git diff`, and the hunk patches built from the model @@ -253,17 +269,8 @@ end ---@return string|nil function M.read(ref, root, relpath) if ref.kind == "worktree" then - local abs = root .. "/" .. relpath - if vim.fn.filereadable(abs) == 0 then - return nil - end - local fd = io.open(abs, "rb") - if not fd then - return nil - end - local data = fd:read("*a") - fd:close() - return as_staged(root, relpath, data) + 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 @@ -347,18 +354,47 @@ function M.untracked(root) return out and rev.parse_paths(out) or {} end +-- untracked line counts, keyed by repo path and invalidated by the file's own +-- mtime/size. every panel build and every refresh asks for all of them at once, and +-- a file that hasn't moved can't have a different count. wiped wholesale past a cap +-- rather than evicted one by one: the live set is whatever the panel lists, so a +-- table this size is a session that has walked many repos, not a working set +local untracked_counts, untracked_cached = {}, 0 +local UNTRACKED_CACHE_MAX = 4096 + -- an untracked file has no diff to numstat, so every line reads as an addition; -- binary content counts as 0, matching how numstat's `-` markers already read --- binary tracked changes as 0/0 +-- binary tracked changes as 0/0. read raw rather than through the clean filter +-- (M.read): eol conversion can't change how many lines there are, and the filter +-- costs three git processes and a loose blob per `\r`-carrying file ---@param root string ---@param relpath string ---@return integer local function untracked_additions(root, relpath) - local content = M.read(WORKTREE, root, relpath) - if not content or text_util.is_binary(content) then + local abs = root .. "/" .. relpath + local st = (vim.uv or vim.loop).fs_stat(abs) + if not st then return 0 end - return #text_util.to_lines(content) + local key = root .. "\0" .. relpath + local stamp = ("%d.%d:%d"):format(st.mtime.sec, st.mtime.nsec, st.size) + local hit = untracked_counts[key] + if hit and hit.stamp == stamp then + return hit.additions + end + local content = read_file(abs) + local additions = 0 + if content and not text_util.is_binary(content) then + additions = #text_util.to_lines(content) + end + if untracked_cached >= UNTRACKED_CACHE_MAX then + untracked_counts, untracked_cached = {}, 0 + end + if not hit then + untracked_cached = untracked_cached + 1 + end + untracked_counts[key] = { stamp = stamp, additions = additions } + return additions end -- whether `relpath` is currently conflicted diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index 60d324a..d8ba882 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -3699,3 +3699,57 @@ describe("git listing errors", function() assert.is_truthy(n.msg:find("nosuchref", 1, true)) end) end) + +-- the panel counts every untracked file's lines on every build and every refresh. +-- reading them through the clean filter meant three git processes and a loose blob +-- per CRLF-carrying file, every time +describe("git.untracked_additions (cost)", function() + -- the number of loose objects in the repo's object store + local function loose_objects(root) + return #vim.fn.glob(root .. "/.git/objects/??/*", false, true) + end + local function untracked_entry(sections, path) + for _, sec in ipairs(sections) do + for _, e in ipairs(sec.entries) do + if e.path == path and e.status == "?" then + return e + end + end + end + end + + it("counts a CRLF untracked file without writing a blob into .git/objects", function() + local root = fresh_repo() + write(root .. "/crlf.txt", "one\r\ntwo\r\nthree\r\n") + local before = loose_objects(root) + + for _ = 1, 3 do -- a build and two refreshes + local sections = git_src.status_sections(root) + assert.are.equal(3, untracked_entry(sections, "crlf.txt").additions) + end + + assert.are.equal(before, loose_objects(root)) + end) + + it("reuses the count until the file's mtime or size moves", function() + local root = fresh_repo() + local path = root .. "/u.txt" + local uv = vim.uv or vim.loop + write(path, "a\nb\n") -- 4 bytes, two lines + assert(uv.fs_utime(path, 1700000000, 1700000000)) + + local sections = git_src.status_sections(root) + assert.are.equal(2, untracked_entry(sections, "u.txt").additions) + + -- same byte count, same timestamp, different content: a re-read would say 1 + write(path, "abc\n") + assert(uv.fs_utime(path, 1700000000, 1700000000)) + sections = git_src.status_sections(root) + assert.are.equal(2, untracked_entry(sections, "u.txt").additions) -- served from cache + + -- moving the timestamp is what lets the new count through + assert(uv.fs_utime(path, 1700000001, 1700000001)) + sections = git_src.status_sections(root) + assert.are.equal(1, untracked_entry(sections, "u.txt").additions) + end) +end) From 691349d9e16fd8cba34a5b96e8279a546f421642 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:24:21 +0100 Subject: [PATCH 6/7] fix: bound the git fetch, and hand back spawn failures --- lua/differ/git/init.lua | 58 +++++++++++++++++++++++++++++++++++++---- test/nvim/git_spec.lua | 55 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 5 deletions(-) diff --git a/lua/differ/git/init.lua b/lua/differ/git/init.lua index 7f2c7ed..e4b93dd 100644 --- a/lua/differ/git/init.lua +++ b/lua/differ/git/init.lua @@ -32,17 +32,61 @@ 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 + +-- what a network call runs under. git prompts for credentials on the controlling +-- terminal, which under nvim is one nothing can be typed into, so prompting off turns +-- a hang into an error. read per call: the budget is settable +---@return { timeout: integer, env: table } +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 + -- 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) +local function git(args, cwd, opts) local cmd = { "git" } vim.list_extend(cmd, args) - local res = vim.system(cmd, { cwd = cwd, text = true }):wait() + 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 @@ -58,7 +102,10 @@ end local function git_raw(args, cwd) local cmd = { "git" } vim.list_extend(cmd, args) - local res = vim.system(cmd, { cwd = cwd }):wait() + 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 @@ -136,7 +183,8 @@ local function checkout_pull_ref(root, ref, number, fetch_err) if not number then return false, fetch_err end - local _, perr = git({ "fetch", "origin", ("refs/pull/%d/head"):format(number) }, root) + local _, perr = + git({ "fetch", "origin", ("refs/pull/%d/head"):format(number) }, root, fetch_opts()) if perr then return false, fetch_err end @@ -162,7 +210,7 @@ function M.checkout(root, ref, number) if not rev.valid_ref(ref) then return false, ("unsafe branch name, refusing to run git: %s"):format(ref) end - local _, ferr = git({ "fetch", "origin", ref }, root) + local _, ferr = git({ "fetch", "origin", ref }, root, fetch_opts()) if ferr then return checkout_pull_ref(root, ref, number, ferr) end diff --git a/test/nvim/git_spec.lua b/test/nvim/git_spec.lua index d8ba882..41d1b21 100644 --- a/test/nvim/git_spec.lua +++ b/test/nvim/git_spec.lua @@ -3753,3 +3753,58 @@ describe("git.untracked_additions (cost)", function() assert.are.equal(1, untracked_entry(sections, "u.txt").additions) end) end) + +-- a fetch is the one git call that talks to a network, and it ran unbounded: a slow +-- remote froze nvim until git gave up, and a credential prompt never came back +describe("git.checkout (network budget)", function() + -- a `git` earlier on PATH than the real one, running `body` + local function fake_git(body) + local dir = vim.fn.tempname() + vim.fn.mkdir(dir, "p") + local path = dir .. "/git" + write(path, "#!/bin/sh\n" .. body .. "\n") + assert((vim.uv or vim.loop).fs_chmod(path, 493)) + return dir + end + + it("kills a fetch that outlasts its budget, and says so", function() + local root = fresh_repo() + local git_src_mod = require("differ.git") + local budget, path = git_src_mod.fetch_timeout_ms, vim.env.PATH + git_src_mod.fetch_timeout_ms = 300 + vim.env.PATH = fake_git("sleep 30") .. ":" .. path + + local started = (vim.uv or vim.loop).now() + local ok, err = git_src_mod.checkout(root, "feature", nil) + local took = (vim.uv or vim.loop).now() - started + + git_src_mod.fetch_timeout_ms, vim.env.PATH = budget, path + assert.is_false(ok) + assert.is_truthy(err) + assert.is_truthy(err:find("timed out", 1, true)) + assert.is_true(took < 10000) -- killed, not waited out + end) + + it("returns the spawn error instead of raising it", function() + -- a cwd that isn't there fails to spawn the same way a missing git does + local ok, err = require("differ.git").checkout("/no/such/repo", "feature", nil) + assert.is_false(ok) + assert.is_truthy(err) + assert.is_truthy(err:find("ENOENT", 1, true)) + end) + + it("runs the fetch with terminal prompts disabled", function() + local root = fresh_repo() + local marker = vim.fn.tempname() + local path = vim.env.PATH + -- the fake records the env it saw, then fails so checkout stops there + vim.env.PATH = fake_git('printf "%s" "$GIT_TERMINAL_PROMPT" > ' .. marker .. "\nexit 1") + .. ":" + .. path + + require("differ.git").checkout(root, "feature", nil) + + vim.env.PATH = path + assert.are.equal("0", table.concat(vim.fn.readfile(marker), "")) + end) +end) From 3af1ccb53f3e2078487834254297ac8eb94c3f87 Mon Sep 17 00:00:00 2001 From: sean <91675054+undont@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:25:02 +0100 Subject: [PATCH 7/7] docs: changelog for the local-diff fixes --- CHANGELOG.md | 10 ++++++++++ README.md | 20 -------------------- doc/differ.txt | 5 ----- 3 files changed, 10 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cd49bf..f290ed5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Fixed + +- A change git lists that has no lines to show (a `chmod +x`, an empty new file, a trailing-newline-only edit, a rename, a moved submodule pointer) put a row in the panel that did nothing when you opened it, and reported "no changes" again on every try. Those rows now open a diff naming the reason +- `s` and `u` were advertised on files that stage as a unit, then stepped to another file instead of staging it. A binary file, the zero-hunk changes above, and a new file opened from a split's left column now all stage where the keys say they will +- A mistyped revspec reported "no changes for this source" while git actually emitted `fatal: bad revision`. `:Differ` and both `:Differ log` forms now report based on what git said +- The panel read every untracked file from disk on every build and refresh, and one containing a carriage return also left a loose object in `.git/objects` each time. Those counts are now read once and reused until the file's timestamp or size moves +- `:Differ pr checkout` froze nvim for as long as a slow remote took, and hung for good on a credential prompt. The fetch is now bounded and prompts are off, and git failing to start at all reports a message rather than a Lua error + +## [0.1.31] — 2026-08-15 + ### Added - `:checkhealth differ` reports the Neovim floor, git, the options you passed to `setup()`, the sidecar binary and its handshake, whether a GitHub token is available, and anything the sidecar has logged diff --git a/README.md b/README.md index 7979f7e..772009d 100644 --- a/README.md +++ b/README.md @@ -29,8 +29,6 @@ Everything runs through one renderer, so staging a hunk and replying to a review The GitHub side runs in a separate process rather than the editor, so opening a PR or posting a review doesn't block on the API, and results are cached between calls. ---- - ## Features @@ -47,8 +45,6 @@ The GitHub side runs in a separate process rather than the editor, so opening a - Real buffer lines, so search, yank, and motions work as normal - One diff engine (`vim.text.diff()`, histogram) shared by every source ---- - ## Requirements - Neovim 0.12+ (`vim.text.diff` sets the floor; also uses `vim.system` and `vim.uv`) @@ -60,8 +56,6 @@ The GitHub side runs in a separate process rather than the editor, so opening a `:checkhealth differ` reports which of these are satisfied. ---- - ## Installation @@ -105,8 +99,6 @@ Pin a release with `{ src = "https://github.com/undont/differ.nvim", version = " differ's only install step is building the Go sidecar, so point your manager's build / post-update hook at `make go-build`: pckr `run`, vim-plug `do`, or the equivalent. ---- - ## Configuration @@ -202,8 +194,6 @@ require("differ").setup({ }) ``` ---- - ## Usage `:Differ [revspec]` opens the file panel over the changed files for a resolved source, landing on the file you ran it from, or on the first file in the list when that file isn't one of them. The grammar mirrors git: @@ -485,13 +475,3 @@ require("differ").goto_hunk("next", { end, }) ``` - ---- - - - -## Licence - -[MIT](LICENCE) - - diff --git a/doc/differ.txt b/doc/differ.txt index 1794062..733857b 100644 --- a/doc/differ.txt +++ b/doc/differ.txt @@ -30,7 +30,6 @@ Table of Contents *differ-table-of-contents* - Real buffer lines, so search, yank, and motions work as normal - One diff engine (`vim.text.diff()`, histogram) shared by every source ------------------------------------------------------------------------------- ============================================================================== 2. Requirements *differ-requirements* @@ -44,7 +43,6 @@ Table of Contents *differ-table-of-contents* `:checkhealth differ` reports which of these are satisfied. ------------------------------------------------------------------------------- ============================================================================== 3. Configuration *differ-configuration* @@ -140,7 +138,6 @@ Table of Contents *differ-table-of-contents* }) < ------------------------------------------------------------------------------- ============================================================================== 4. Usage *differ-usage* @@ -590,8 +587,6 @@ LUA API *differ-usage-lua-api* }) < ------------------------------------------------------------------------------- - Generated by panvimdoc vim:tw=78:ts=8:noet:ft=help:norl: