Repository navigation
bug: interrupted plugin update leaves repo dirty; update and restore then refuse forever with no in-UI recovery #2191
Description
bug: dirty plugin trees make update AND restore refuse forever with no in-UI recovery
- I have read all the lazy.nvim docs
- I have updated the plugin to the latest version before submitting this issue
- I have searched the existing issues of lazy.nvim
- I have searched the existing issues of plugins related to this issue
Neovim version (nvim -v): NVIM v0.12.5, lazy.nvim 11.17.5 (85c7ff3, latest release), git 2.55.0
Operating system/version: Arch Linux
Describe the bug
Once a plugin working tree under lazy's root is dirty, every
:Lazy update fails for that plugin with:
You have local changes in
...:
Please remove them to update.
You can also pressxto remove the plugin and thenIto install it again.
:Lazy restore fails identically, so there is no in-UI recovery path —
the only offers are hand-editing git state in a terminal or x+I
(full re-clone). Re-running update can never heal it, by construction:
M.updatepipeline runsgit.statusbeforegit.checkout
(lua/lazy/manage/init.lua:111-117).git.statusrunsgit ls-files -d -mand callsself:error(...)on any
output (lua/lazy/manage/task/git.lua:238-273).- The runner stops the plugin's remaining tasks on error
(lua/lazy/manage/runner.lua:100-101, "don't continue tasks if there are errors"),
sogit.checkoutnever runs while the tree is dirty. M.restoreisM.updatewithlockfile=true
(lua/lazy/manage/init.lua:141-144) — same pipeline, same gate.
Real-world case: nvim-lspconfig with 11 UU unmerged files
(4 workflows, doc/configs.md/.txt, lsp/terraformls.lua, 4
lua/lspconfig/types/*) — residue of an interrupted sync, later turned into
a conflict by a stash-pop across the update; update has refused ever since.
Two dirt flavors, one refusal, different recoveries
- Flavor A — plain modified (e.g. interrupted
git checkout --progress --recurse-submodules <sha>mid-update: partial
content, HEAD unmoved). Recovery:git checkout -- .— which lazy never
offers. (Even bypassing the gate wouldn't help otherwise: plain
git checkout <sha>on such a tree fails with "would be overwritten".) - Flavor B — unmerged (UU) (e.g. stash pushed before an update, popped
after it moved the same files: conflict, index holds stages 1-3, stash
entry kept, no MERGE_HEAD). The status gate fires identically
(ls-files -d -mlists the file once per stage). Butgit checkout -- .
fails witherror: path '<f>' is unmerged. Recovery:
git reset --hard <target>(verified to clear index + worktree with no
MERGE_HEAD present).
Related prior art (checked, distinct): #1099 shows the same message but at
checkout time ("checkout failed ... would be overwritten"), i.e. dirt
appearing without the status gate catching it — not the refusal loop, the
shared restore gate, or the missing recovery described here. #1220 (skip-list
for the git check) and #1771 (local/dev overrides) are adjacent but different.
Steps To Reproduce
- Save the repro below as
repro.lua, runnvim -u repro.lua
(it builds a localfile://fixture remote with commits A→B→C, no network
needed beyond the lazy bootstrap). :Lazy! install repro-fixture(installs at pinned B).- Flavor A:
git -C $D show <SHA_A>:b.txt > $D/b.txt(same forc.txt;
D= plugin dir, SHAs are printed) → partial content, HEAD unmoved. :Lazy update→ refusal; repeat → identical;:Lazy restore→ identical.git -C $D checkout -- ., then:Lazy update→ completes.- Flavor B:
echo local-work > $D/b.txt && git -C $D stash push -- b.txt,
thengit -C $D checkout <SHA_C> && git -C $D stash pop
→CONFLICT,UU b.txt, stash kept. :Lazy update→ identical refusal;git -C $D checkout -- .→ fails
(is unmerged).git -C $D reset --hard HEAD, then:Lazy update→ completes.
Verified end-to-end headlessly by driving the real task runner
(require("lazy.manage").install/update/restore({wait=true}), the same
pipeline the UI uses): 17/17 checks pass — refusal on both flavors,
checkout -- . recovers A but fails on B, reset --hard recovers B.
Expected Behavior
Either of:
- on the status error, offer an in-place recovery action alongside the
currentx+Iadvice (git checkout -- .for plain-modified,
git reset --hardfor unmerged) and retry — i.e. make update able to heal
what its own interrupted runs (and stash-pop helpers) leave behind, or - make restore (which conceptually returns the plugin to a known-good commit)
restore the worktree first instead of sharing the refusal gate.
At minimum, the error message could name the lightweight recovery
(checkout -- ., or reset --hard for unmerged paths) instead of only
full reinstall.
Repro
-- (contents of repro.lua — see attached file)
vim.env.LAZY_STDPATH = ".repro"
load(vim.fn.system("curl -s https://raw.githubusercontent.com/folke/lazy.nvim/main/bootstrap.lua"))()
local root = vim.fn.fnamemodify(vim.env.LAZY_STDPATH, ":p")
local remote = root .. "fixture-remote"
local function G(args, cwd)
local cmd = { "git", "-c", "user.name=repro", "-c", "user.email=repro@repro" }
vim.list_extend(cmd, args)
local o = vim.system(cmd, { cwd = cwd or remote, text = true }):wait()
assert(o.code == 0, table.concat(args, " ") .. " -> " .. (o.stderr or ""))
return (vim.trim(o.stdout))
end
local function write(path, text)
local f = assert(io.open(path, "w"))
f:write(text)
f:close()
end
vim.fn.delete(remote, "rf")
vim.fn.mkdir(remote, "p")
G({ "init", "-b", "main", "." })
write(remote .. "/a.txt", "v1\n")
write(remote .. "/b.txt", "v1\n")
write(remote .. "/c.txt", "v1\n")
G({ "add", "-A" })
G({ "commit", "-qm", "A" })
local sha_a = G({ "rev-parse", "HEAD" })
write(remote .. "/b.txt", "v2 with more content\n")
write(remote .. "/c.txt", "v2 with more content\n")
write(remote .. "/d.txt", "brand new file\n")
G({ "add", "-A" })
G({ "commit", "-qm", "B" })
local sha_b = G({ "rev-parse", "HEAD" })
write(remote .. "/b.txt", "v3 conflicting content\n")
G({ "add", "-A" })
G({ "commit", "-qm", "C" })
local sha_c = G({ "rev-parse", "HEAD" })
require("lazy.minit").repro({
-- minit.repro concatenates the default tokyonight spec (minit.lua M.extend);
-- this keeps the repro offline
install = { missing = false },
spec = {
{ url = "file://" .. remote, name = "repro-fixture", commit = sha_b },
},
})
print("Fixture ready. SHA_A=" .. sha_a .. " SHA_B=" .. sha_b .. " SHA_C=" .. sha_c)
-- then STEPS 1-8 above
Activity