Skip to content

bug: interrupted plugin update leaves repo dirty; update and restore then refuse forever with no in-UI recovery #2191

Open

Description

@HaleTom

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 press x to remove the plugin and then I to 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:

  1. M.update pipeline runs git.status before git.checkout
    (lua/lazy/manage/init.lua:111-117).
  2. git.status runs git ls-files -d -m and calls self:error(...) on any
    output (lua/lazy/manage/task/git.lua:238-273).
  3. 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"),
    so git.checkout never runs while the tree is dirty.
  4. M.restore is M.update with lockfile=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 -m lists the file once per stage). But git checkout -- .
    fails with error: 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

  1. Save the repro below as repro.lua, run nvim -u repro.lua
    (it builds a local file:// fixture remote with commits A→B→C, no network
    needed beyond the lazy bootstrap).
  2. :Lazy! install repro-fixture (installs at pinned B).
  3. Flavor A: git -C $D show <SHA_A>:b.txt > $D/b.txt (same for c.txt;
    D = plugin dir, SHAs are printed) → partial content, HEAD unmoved.
  4. :Lazy update → refusal; repeat → identical; :Lazy restore → identical.
  5. git -C $D checkout -- ., then :Lazy update → completes.
  6. Flavor B: echo local-work > $D/b.txt && git -C $D stash push -- b.txt,
    then git -C $D checkout <SHA_C> && git -C $D stash pop
    → CONFLICT, UU b.txt, stash kept.
  7. :Lazy update → identical refusal; git -C $D checkout -- . → fails
    (is unmerged).
  8. 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
    current x+I advice (git checkout -- . for plain-modified,
    git reset --hard for 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions