From d8e4777f1bc150105a40bf57c6f6ae3ae9714267 Mon Sep 17 00:00:00 2001 From: Pablo Kebees Date: Fri, 17 Jul 2026 11:22:02 +0200 Subject: [PATCH] fix(diff): invalidate stage buffers when a newly added file is unstaged Unstaging a newly added file (AM -> ??) removes its path from the index entirely, unlike unstaging a tracked file which resets the stage-0 blob back to the HEAD blob. Three stacked defects left the open diff view permanently out of sync with no recovery short of closing the tab: 1. GitAdapter:file_blob_hash() used fail_on_empty, so the legitimate "path has no blob at this rev" answer (empty output, exit 0) burned retries, spammed the log on every refresh, and returned nil. 2. FileEntry:validate_stage_buffers() gated disposal on a non-nil new hash, so the stale stage buffer survived every refresh, including explicit ones via refresh_files. 3. The NOOP branch of DiffView:update_files() copies status onto a kept entry, but each side's nulled flag is fixed at construction and derives from the status: the M -> ? transition must flip the a-side to nulled. The kept entry retained a live a-side pointing at a gone blob, producing either the stale buffer or a failed buffer reload (E5560 fast-event crash chain), depending on whether blob_hash was captured at buffer creation. Fix: treat empty rev-parse output as a real "no blob" answer, dispose stage buffers when the blob hash goes away, and force entry replacement whenever a side's nulled state would change so the a-side of a now- untracked file renders as a proper null buffer. --- lua/diffview/scene/file_entry.lua | 6 +++++- lua/diffview/scene/views/diff/diff_view.lua | 19 +++++++++++++++++++ lua/diffview/vcs/adapters/git/init.lua | 8 ++++++-- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/lua/diffview/scene/file_entry.lua b/lua/diffview/scene/file_entry.lua index 88f3426b..027f5a2a 100644 --- a/lua/diffview/scene/file_entry.lua +++ b/lua/diffview/scene/file_entry.lua @@ -192,9 +192,13 @@ function FileEntry:validate_stage_buffers(stat) local is_modified = vim.bo[f.bufnr].modified if f.blob_hash then + -- `new_hash` is nil when the path no longer has a stage-0 blob at + -- all (e.g. a newly added file that was unstaged, leaving it + -- untracked). That is a content change like any other: the buffer + -- still shows the old staged blob and must be invalidated. local new_hash = self.adapter:file_blob_hash(f.path) - if new_hash and new_hash ~= f.blob_hash then + if new_hash ~= f.blob_hash then if is_modified then utils.warn( ( diff --git a/lua/diffview/scene/views/diff/diff_view.lua b/lua/diffview/scene/views/diff/diff_view.lua index 5eac5b83..2f9f65d3 100644 --- a/lua/diffview/scene/views/diff/diff_view.lua +++ b/lua/diffview/scene/views/diff/diff_view.lua @@ -828,6 +828,25 @@ local update_files_impl = debounce.debounce_trailing( ) end + -- A status change can flip a side's nulled state even when the + -- revs are identical (e.g. unstaging a newly added file: the + -- working entry goes "M" -> "?" and its a-side no longer has a + -- stage-0 blob). The NOOP path only copies `status` onto the old + -- entry; the Files' `nulled` flags are fixed at construction, so + -- the old a-side would keep showing — and try to reload — a blob + -- that no longer exists. Replace the entry instead. + if not replace_noop then + for _, sym in ipairs({ "a", "b", "c", "d" }) do + local of = old_file.layout:get_file_for(sym) + local nf = new_file.layout:get_file_for(sym) + + if (of and of.nulled or false) ~= (nf and nf.nulled or false) then + replace_noop = true + break + end + end + end + if replace_noop then if self.panel.cur_file == old_file then self.panel:set_cur_file(new_file) diff --git a/lua/diffview/vcs/adapters/git/init.lua b/lua/diffview/vcs/adapters/git/init.lua index ac410e99..24709464 100644 --- a/lua/diffview/vcs/adapters/git/init.lua +++ b/lua/diffview/vcs/adapters/git/init.lua @@ -1843,6 +1843,11 @@ end ---@param rev_arg string? ---@return string? function GitAdapter:file_blob_hash(path, rev_arg) + -- Empty output with exit code 0 is a real outcome, not a failure: the path + -- has no blob at that rev (e.g. a newly added file that was unstaged no + -- longer exists in the index at all). `fail_on_empty` would burn retries + -- and spam the log for it; instead let the empty result through and map it + -- to nil so callers can distinguish "no blob" from a blob hash. local out, code = self:exec_sync({ "rev-parse", "--revs-only", @@ -1850,10 +1855,9 @@ function GitAdapter:file_blob_hash(path, rev_arg) }, { cwd = self.ctx.toplevel, retry = 2, - fail_on_empty = true, }) - if code ~= 0 then + if code ~= 0 or not out[1] or vim.trim(out[1]) == "" then return end