neovim

Neovim text editor
git clone https://git.dasho.dev/neovim.git
Log | Files | Refs | README

commit 5df1112e5f2112ce4f3b1fcf9ca78383fe5c9fee
parent 2767eac320ef3f422236cbc8ce2228b02e11bd31
Author: Justin M. Keyes <justinkz@gmail.com>
Date:   Mon, 17 Nov 2025 09:55:11 -0800

Merge #36338 vim.pack: lockfile synchronization


Diffstat:
Mruntime/doc/lua-plugin.txt | 2+-
Mruntime/doc/pack.txt | 11+++++++----
Mruntime/lua/vim/pack.lua | 281+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Mruntime/lua/vim/pack/_lsp.lua | 2+-
Mtest/functional/plugin/pack_spec.lua | 299+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
5 files changed, 473 insertions(+), 122 deletions(-)

diff --git a/runtime/doc/lua-plugin.txt b/runtime/doc/lua-plugin.txt @@ -283,7 +283,7 @@ mappings to a specific action by invoking `vim.lsp.buf.code_action()` with the Example: See `runtime/lua/vim/pack/_lsp.lua` for how vim.pack defines an in-process LSP server to provide interactive features in its -`nvim://pack-confirm` buffer. +`nvim-pack://confirm` buffer. ============================================================================== Troubleshooting *lua-plugin-troubleshooting* diff --git a/runtime/doc/pack.txt b/runtime/doc/pack.txt @@ -224,9 +224,11 @@ semver convention `v<major>.<minor>.<patch>`. The latest state of all managed plugins is stored inside a *vim.pack-lockfile* located at `$XDG_CONFIG_HOME/nvim/nvim-pack-lock.json`. It is a JSON file that is used to persistently track data about plugins. For a more robust config -treat lockfile like its part: put under version control, etc. In this case -initial install prefers revision from the lockfile instead of inferring from -`version`. Should not be edited by hand or deleted. +treat lockfile like its part: put under version control, etc. In this case all +plugins from the lockfile will be installed at once and at lockfile's revision +(instead of inferring from `version`). Should not be edited by hand. Corrupted +data for installed plugins is repaired (including after deleting whole file), +but `version` fields will be missing for not yet added plugins. Example workflows ~ @@ -375,7 +377,8 @@ add({specs}, {opts}) *vim.pack.add()* nothing. • If doesn't exist, install it by downloading from `src` into `name` subdirectory (via partial blobless `git clone`) and update revision to - match `version` (via `git checkout`). + match `version` (via `git checkout`). Plugin will not be on disk if + any step resulted in an error. • For each plugin execute |:packadd| (or customizable `load` function) making it reachable by Nvim. diff --git a/runtime/lua/vim/pack.lua b/runtime/lua/vim/pack.lua @@ -18,8 +18,11 @@ ---located at `$XDG_CONFIG_HOME/nvim/nvim-pack-lock.json`. It is a JSON file that ---is used to persistently track data about plugins. ---For a more robust config treat lockfile like its part: put under version control, etc. ----In this case initial install prefers revision from the lockfile instead of ----inferring from `version`. Should not be edited by hand or deleted. +---In this case all plugins from the lockfile will be installed at once and +---at lockfile's revision (instead of inferring from `version`). +---Should not be edited by hand. Corrupted data for installed plugins is repaired +---(including after deleting whole file), but `version` fields will be missing +---for not yet added plugins. --- ---Example workflows ~ --- @@ -152,6 +155,26 @@ local async = require('vim._async') local M = {} +--- @class (private) vim.pack.LockData +--- @field rev string Latest recorded revision. +--- @field src string Plugin source. +--- @field version? string|vim.VersionRange Plugin `version`, as supplied in `spec`. + +--- @class (private) vim.pack.Lock +--- @field plugins table<string, vim.pack.LockData> Map from plugin name to its lock data. + +--- @type vim.pack.Lock +local plugin_lock + +--- @return string +local function get_plug_dir() + return vim.fs.joinpath(vim.fn.stdpath('data'), 'site', 'pack', 'core', 'opt') +end + +local function lock_get_path() + return vim.fs.joinpath(vim.fn.stdpath('config'), 'nvim-pack-lock.json') +end + -- Git ------------------------------------------------------------------------ --- @async @@ -251,70 +274,6 @@ local function git_get_tags(cwd) return tags == '' and {} or vim.split(tags, '\n') end --- Lockfile ------------------------------------------------------------------- - ---- @return string -local function get_plug_dir() - return vim.fs.joinpath(vim.fn.stdpath('data'), 'site', 'pack', 'core', 'opt') -end - ---- @class (private) vim.pack.LockData ---- @field rev string Latest recorded revision. ---- @field src string Plugin source. ---- @field version? string|vim.VersionRange Plugin `version`, as supplied in `spec`. - ---- @class (private) vim.pack.Lock ---- @field plugins table<string, vim.pack.LockData> Map from plugin name to its lock data. - ---- @type vim.pack.Lock -local plugin_lock - -local function lock_get_path() - return vim.fs.joinpath(vim.fn.stdpath('config'), 'nvim-pack-lock.json') -end - -local function lock_read() - if plugin_lock then - return - end - local fd = uv.fs_open(lock_get_path(), 'r', 438) - if not fd then - plugin_lock = { plugins = {} } - return - end - local stat = assert(uv.fs_fstat(fd)) - local data = assert(uv.fs_read(fd, stat.size, 0)) - assert(uv.fs_close(fd)) - plugin_lock = vim.json.decode(data) --- @type vim.pack.Lock - - -- Deserialize `version` - for _, l_data in pairs(plugin_lock.plugins) do - local version = l_data.version - if type(version) == 'string' then - l_data.version = version:match("^'(.+)'$") or vim.version.range(version) - end - end -end - -local function lock_write() - -- Serialize `version` - local lock = vim.deepcopy(plugin_lock) - for _, l_data in pairs(lock.plugins) do - local version = l_data.version - if version then - l_data.version = type(version) == 'string' and ("'%s'"):format(version) or tostring(version) - end - end - - local path = lock_get_path() - vim.fn.mkdir(vim.fs.dirname(path), 'p') - local fd = assert(uv.fs_open(path, 'w', 438)) - - local data = vim.json.encode(lock, { indent = ' ', sort_keys = true }) - assert(uv.fs_write(fd, data)) - assert(uv.fs_close(fd)) -end - -- Plugin operations ---------------------------------------------------------- --- @param msg string|string[] @@ -401,7 +360,7 @@ end local function new_plug(spec, plug_dir) local spec_resolved = normalize_spec(spec) local path = vim.fs.joinpath(plug_dir or get_plug_dir(), spec_resolved.name) - local info = { err = '', installed = uv.fs_stat(path) ~= nil } + local info = { err = '', installed = plugin_lock.plugins[spec_resolved.name] ~= nil } return { spec = spec_resolved, path = path, info = info } end @@ -545,16 +504,24 @@ local function confirm_install(plug_list) return true end - local src = {} --- @type string[] - for _, p in ipairs(plug_list) do - src[#src + 1] = p.spec.src + -- Gather pretty aligned list of plugins to install + local name_width, name_max_width = {}, 0 --- @type integer[], integer + for i, p in ipairs(plug_list) do + name_width[i] = api.nvim_strwidth(p.spec.name) + name_max_width = math.max(name_max_width, name_width[i]) + end + local lines = {} --- @type string[] + for i, p in ipairs(plug_list) do + local pad = (' '):rep(name_max_width - name_width[i] + 1) + lines[i] = ('%s%sfrom %s'):format(p.spec.name, pad, p.spec.src) end - local src_text = table.concat(src, '\n') - local confirm_msg = ('These plugins will be installed:\n\n%s\n'):format(src_text) - local res = vim.fn.confirm(confirm_msg, 'Proceed? &Yes\n&No\n&Always', 1, 'Question') - confirm_all = res == 3 + + local text = table.concat(lines, '\n') + local confirm_msg = ('These plugins will be installed:\n\n%s\n'):format(text) + local choice = vim.fn.confirm(confirm_msg, 'Proceed? &Yes\n&No\n&Always', 1, 'Question') + confirm_all = choice == 3 vim.cmd.redraw() - return res ~= 2 + return choice ~= 2 end --- @param tags string[] @@ -662,14 +629,6 @@ end --- @param plug_list vim.pack.Plug[] local function install_list(plug_list, confirm) - -- Get user confirmation to install plugins - if confirm and not confirm_install(plug_list) then - for _, p in ipairs(plug_list) do - p.info.err = 'Installation was not confirmed' - end - return - end - local timestamp = get_timestamp() --- @async --- @param p vim.pack.Plug @@ -677,7 +636,6 @@ local function install_list(plug_list, confirm) trigger_event(p, 'PackChangedPre', 'install') git_clone(p.spec.src, p.path) - p.info.installed = true plugin_lock.plugins[p.spec.name].src = p.spec.src @@ -685,10 +643,23 @@ local function install_list(plug_list, confirm) p.info.sha_target = (plugin_lock.plugins[p.spec.name] or {}).rev checkout(p, timestamp) + p.info.installed = true trigger_event(p, 'PackChanged', 'install') end - run_list(plug_list, do_install, 'Installing plugins') + + -- Install possibly after user confirmation + if not confirm or confirm_install(plug_list) then + run_list(plug_list, do_install, 'Installing plugins') + end + + -- Ensure that not fully installed plugins are absent on disk and in lockfile + for _, p in ipairs(plug_list) do + if not (p.info.installed and uv.fs_stat(p.path) ~= nil) then + plugin_lock.plugins[p.spec.name] = nil + vim.fs.rm(p.path, { recursive = true, force = true }) + end + end end --- @async @@ -773,6 +744,133 @@ local function pack_add(plug, load) end end +local function lock_write() + -- Serialize `version` + local lock = vim.deepcopy(plugin_lock) + for _, l_data in pairs(lock.plugins) do + local version = l_data.version + if version then + l_data.version = type(version) == 'string' and ("'%s'"):format(version) or tostring(version) + end + end + + local path = lock_get_path() + vim.fn.mkdir(vim.fs.dirname(path), 'p') + local fd = assert(uv.fs_open(path, 'w', 438)) + + local data = vim.json.encode(lock, { indent = ' ', sort_keys = true }) + assert(uv.fs_write(fd, data)) + assert(uv.fs_close(fd)) +end + +--- @param names string[] +local function lock_repair(names, plug_dir) + --- @async + local function f() + for _, name in ipairs(names) do + local path = vim.fs.joinpath(plug_dir, name) + -- Try reusing existing table to preserve maybe present `version` + local data = plugin_lock.plugins[name] or {} + data.rev = git_get_hash('HEAD', path) + data.src = git_cmd({ 'remote', 'get-url', 'origin' }, path) + plugin_lock.plugins[name] = data + end + end + async.run(f):wait() +end + +--- Sync lockfile data and installed plugins: +--- - Install plugins that have proper lockfile data but are not on disk. +--- - Repair corrupted lock data for installed plugins. +--- - Remove unrepairable corrupted lock data and plugins. +local function lock_sync(confirm) + if type(plugin_lock.plugins) ~= 'table' then + plugin_lock.plugins = {} + end + + -- Compute installed plugins + -- NOTE: The directory traversal is done on every startup, but it is very fast. + -- Also, single `vim.fs.dir()` scales better than on demand `uv.fs_stat()` checks. + local plug_dir = get_plug_dir() + local installed = {} --- @type table<string,string> + for name, fs_type in vim.fs.dir(plug_dir) do + installed[name] = fs_type + plugin_lock.plugins[name] = plugin_lock.plugins[name] or {} + end + + -- Traverse once optimizing for "regular startup" (no repair, no install) + local to_install = {} --- @type vim.pack.Plug[] + local to_repair = {} --- @type string[] + local to_remove = {} --- @type string[] + for name, data in pairs(plugin_lock.plugins) do + if type(data) ~= 'table' then + data = {} ---@diagnostic disable-line: missing-fields + plugin_lock.plugins[name] = data + end + + -- Deserialize `version` + local version = data.version + if type(version) == 'string' then + data.version = version:match("^'(.+)'$") or vim.version.range(version) + end + + -- Synchronize + local is_bad_lock = type(data.rev) ~= 'string' or type(data.src) ~= 'string' + local is_bad_plugin = installed[name] and installed[name] ~= 'directory' + if is_bad_lock or is_bad_plugin then + local t = installed[name] == 'directory' and to_repair or to_remove + t[#t + 1] = name + elseif not installed[name] then + local spec = { src = data.src, name = name, version = data.version } + to_install[#to_install + 1] = new_plug(spec, plug_dir) + end + end + + -- Perform actions if needed + if #to_install > 0 then + table.sort(to_install, function(a, b) + return a.spec.name < b.spec.name + end) + install_list(to_install, confirm) + lock_write() + end + + if #to_repair > 0 then + lock_repair(to_repair, plug_dir) + table.sort(to_repair) + notify('Repaired corrupted lock data for plugins: ' .. table.concat(to_repair, ', '), 'WARN') + lock_write() + end + + if #to_remove > 0 then + for _, name in ipairs(to_remove) do + plugin_lock.plugins[name] = nil + vim.fs.rm(vim.fs.joinpath(plug_dir, name), { recursive = true, force = true }) + end + table.sort(to_remove) + notify('Removed corrupted lock data for plugins: ' .. table.concat(to_remove, ', '), 'WARN') + lock_write() + end +end + +local function lock_read(confirm) + if plugin_lock then + return + end + + local fd = uv.fs_open(lock_get_path(), 'r', 438) + if fd then + local stat = assert(uv.fs_fstat(fd)) + local data = assert(uv.fs_read(fd, stat.size, 0)) + assert(uv.fs_close(fd)) + plugin_lock = vim.json.decode(data) + else + plugin_lock = { plugins = {} } + end + + lock_sync(vim.F.if_nil(confirm, true)) +end + --- @class vim.pack.keyset.add --- @inlinedoc --- Load `plugin/` files and `ftdetect/` scripts. If `false`, works like `:packadd!`. @@ -789,7 +887,8 @@ end --- immediately to clean install from the new source. Otherwise do nothing. --- - If doesn't exist, install it by downloading from `src` into `name` --- subdirectory (via partial blobless `git clone`) and update revision ---- to match `version` (via `git checkout`). +--- to match `version` (via `git checkout`). Plugin will not be on disk if +--- any step resulted in an error. --- - For each plugin execute |:packadd| (or customizable `load` function) making --- it reachable by Nvim. --- @@ -810,6 +909,8 @@ function M.add(specs, opts) opts = vim.tbl_extend('force', { load = vim.v.vim_did_init == 1, confirm = true }, opts or {}) vim.validate('opts', opts, 'table') + lock_read(opts.confirm) + local plug_dir = get_plug_dir() local plugs = {} --- @type vim.pack.Plug[] for i = 1, #specs do @@ -818,7 +919,6 @@ function M.add(specs, opts) plugs = normalize_plugs(plugs) -- Pre-process - lock_read() local plugs_to_install = {} --- @type vim.pack.Plug[] local needs_lock_write = false for _, p in ipairs(plugs) do @@ -845,11 +945,6 @@ function M.add(specs, opts) if #plugs_to_install > 0 then git_ensure_exec() install_list(plugs_to_install, opts.confirm) - for _, p in ipairs(plugs_to_install) do - if not p.info.installed then - plugin_lock.plugins[p.spec.name] = nil - end - end end if needs_lock_write then @@ -962,7 +1057,7 @@ end local function show_confirm_buf(lines, on_finish) -- Show buffer in a separate tabpage local bufnr = api.nvim_create_buf(true, true) - api.nvim_buf_set_name(bufnr, 'nvim://pack-confirm#' .. bufnr) + api.nvim_buf_set_name(bufnr, 'nvim-pack://confirm#' .. bufnr) api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) vim.cmd.sbuffer({ bufnr, mods = { tab = vim.fn.tabpagenr() } }) local tab_id = api.nvim_get_current_tabpage() diff --git a/runtime/lua/vim/pack/_lsp.lua b/runtime/lua/vim/pack/_lsp.lua @@ -20,7 +20,7 @@ function methods.shutdown(_, callback) end local get_confirm_bufnr = function(uri) - return tonumber(uri:match('^nvim://pack%-confirm#(%d+)$')) + return tonumber(uri:match('^nvim%-pack://confirm#(%d+)$')) end local group_header_pattern = '^# (%S+)' diff --git a/test/functional/plugin/pack_spec.lua b/test/functional/plugin/pack_spec.lua @@ -327,6 +327,7 @@ local function get_lock_path() return vim.fs.joinpath(fn.stdpath('config'), 'nvim-pack-lock.json') end +--- @return {plugins:table<string, {rev:string, src:string, version?:string}>} local function get_lock_tbl() return vim.json.decode(fn.readblob(get_lock_path())) end @@ -387,19 +388,47 @@ describe('vim.pack', function() end) it('asks for installation confirmation', function() - -- Do not confirm installation to see what happens + -- Do not confirm installation to see what happens (should not error) mock_confirm(2) - local err = pcall_err(exec_lua, function() - vim.pack.add({ repos_src.basic }) + exec_lua(function() + vim.pack.add({ repos_src.basic, { src = repos_src.defbranch, name = 'other-name' } }) end) + eq(false, pack_exists('basic')) + eq(false, pack_exists('defbranch')) + eq({ plugins = {} }, get_lock_tbl()) - matches('`basic`:\nInstallation was not confirmed', err) - eq(false, exec_lua('return pcall(require, "basic")')) + local confirm_msg_lines = ([[ + These plugins will be installed: - local confirm_msg = 'These plugins will be installed:\n\n' .. repos_src.basic .. '\n' - local ref_log = { { confirm_msg, 'Proceed? &Yes\n&No\n&Always', 1, 'Question' } } + basic from %s + other-name from %s]]):format(repos_src.basic, repos_src.defbranch) + local confirm_msg = vim.trim(vim.text.indent(0, confirm_msg_lines)) + local ref_log = { { confirm_msg .. '\n', 'Proceed? &Yes\n&No\n&Always', 1, 'Question' } } eq(ref_log, exec_lua('return _G.confirm_log')) + + -- Should remove lock data if not confirmed during lockfile sync + n.clear() + exec_lua(function() + vim.pack.add({ repos_src.basic }) + end) + eq(true, pack_exists('basic')) + eq('table', type(get_lock_tbl().plugins.basic)) + + vim.fs.rm(pack_get_dir(), { force = true, recursive = true }) + n.clear() + mock_confirm(2) + + exec_lua(function() + vim.pack.add({ repos_src.basic }) + end) + eq(false, pack_exists('basic')) + eq({ plugins = {} }, get_lock_tbl()) + + -- Should ask for confirm twice: during lockfile sync and inside + -- `vim.pack.add()` (i.e. not confirming during lockfile sync has + -- an immediate effect on whether a plugin is installed or not) + eq(2, exec_lua('return #_G.confirm_log')) end) it('respects `opts.confirm`', function() @@ -409,7 +438,20 @@ describe('vim.pack', function() end) eq(0, exec_lua('return #_G.confirm_log')) - eq('basic main', exec_lua('return require("basic")')) + eq(true, pack_exists('basic')) + + -- Should also respect `confirm` when installing during lockfile sync + vim.fs.rm(pack_get_dir(), { force = true, recursive = true }) + eq('table', type(get_lock_tbl().plugins.basic)) + + n.clear() + mock_confirm(1) + + exec_lua(function() + vim.pack.add({}, { confirm = false }) + end) + eq(0, exec_lua('return #_G.confirm_log')) + eq(true, pack_exists('basic')) end) it('can always confirm in current session', function() @@ -522,24 +564,29 @@ describe('vim.pack', function() eq(ref_lockfile, get_lock_tbl()) end) - it('uses lockfile revision during install', function() + it('uses lockfile during install', function() exec_lua(function() - vim.pack.add({ { src = repos_src.basic, version = 'feat-branch' } }) + vim.pack.add({ + { src = repos_src.basic, version = 'feat-branch' }, + repos_src.defbranch, + }) end) -- Mock clean initial install, but with lockfile present + vim.fs.rm(pack_get_dir(), { force = true, recursive = true }) n.clear() - local basic_plug_path = vim.fs.joinpath(pack_get_dir(), 'basic') - vim.fs.rm(basic_plug_path, { force = true, recursive = true }) local basic_rev = git_get_hash('feat-branch', 'basic') + local defbranch_rev = git_get_hash('HEAD', 'defbranch') local ref_lockfile = { plugins = { basic = { rev = basic_rev, src = repos_src.basic, version = "'feat-branch'" }, + defbranch = { rev = defbranch_rev, src = repos_src.defbranch }, }, } eq(ref_lockfile, get_lock_tbl()) + mock_confirm(1) exec_lua(function() -- Should use revision from lockfile (pointing at latest 'feat-branch' -- commit) and not use latest `main` commit @@ -548,9 +595,17 @@ describe('vim.pack', function() local basic_lua_file = vim.fs.joinpath(pack_get_plug_path('basic'), 'lua', 'basic.lua') eq('return "basic feat-branch"', fn.readblob(basic_lua_file)) + local confirm_log = exec_lua('return _G.confirm_log') + eq(1, #confirm_log) + matches('basic.*defbranch', confirm_log[1][1]) + + -- Should install `defbranch` (as it is in lockfile), but not load it + eq(true, pack_exists('defbranch')) + eq(false, exec_lua('return pcall(require, "defbranch")')) + -- Running `update()` should still update to use `main` exec_lua(function() - vim.pack.update(nil, { force = true }) + vim.pack.update({ 'basic' }, { force = true }) end) eq('return "basic main"', fn.readblob(basic_lua_file)) @@ -571,10 +626,9 @@ describe('vim.pack', function() local pluginerr_hash = git_get_hash('main', 'pluginerr') local ref_lockfile = { - -- Should be no entry for `repo_not_exist` + -- Should be no entry for `repo_not_exist` and `basic` as they did not + -- fully install plugins = { - -- No `rev` because there was no relevant checkout - basic = { src = repos_src.basic, version = "'not-exist'" }, -- Error during sourcing 'plugin/' should not affect lockfile pluginerr = { rev = pluginerr_hash, src = repos_src.pluginerr, version = "'main'" }, }, @@ -582,6 +636,133 @@ describe('vim.pack', function() eq(ref_lockfile, get_lock_tbl()) end) + it('regenerates manually deleted lockfile', function() + exec_lua(function() + vim.pack.add({ + { src = repos_src.basic, name = 'other', version = 'feat-branch' }, + repos_src.defbranch, + }) + end) + local lock_path = get_lock_path() + eq(true, vim.uv.fs_stat(lock_path) ~= nil) + + local basic_rev = git_get_hash('feat-branch', 'basic') + local plugindirs_rev = git_get_hash('dev', 'defbranch') + + -- Should try its best to regenerate lockfile based on installed plugins + fn.delete(get_lock_path()) + n.clear() + exec_lua(function() + vim.pack.add({}) + end) + local ref_lockfile = { + plugins = { + -- No `version = 'feat-branch'` as there is no way to get that info + -- (lockfile was the only source of that on disk) + other = { rev = basic_rev, src = repos_src.basic }, + defbranch = { rev = plugindirs_rev, src = repos_src.defbranch }, + }, + } + eq(ref_lockfile, get_lock_tbl()) + + local ref_messages = 'vim.pack: Repaired corrupted lock data for plugins: defbranch, other' + eq(ref_messages, n.exec_capture('messages')) + + -- Calling `add()` with `version` should still add it to lockfile + exec_lua(function() + vim.pack.add({ { src = repos_src.basic, name = 'other', version = 'feat-branch' } }) + end) + eq("'feat-branch'", get_lock_tbl().plugins.other.version) + end) + + it('repairs corrupted lock data for installed plugins', function() + exec_lua(function() + vim.pack.add({ + -- Should preserve present `version` + { src = repos_src.basic, version = 'feat-branch' }, + repos_src.defbranch, + repos_src.semver, + repos_src.helptags, + }) + end) + + local lock_tbl = get_lock_tbl() + local ref_lock_tbl = vim.deepcopy(lock_tbl) + local assert = function() + exec_lua('vim.pack.add({})') + eq(ref_lock_tbl, get_lock_tbl()) + eq(true, pack_exists('basic')) + eq(true, pack_exists('defbranch')) + eq(true, pack_exists('semver')) + eq(true, pack_exists('helptags')) + end + + -- Missing lock data required field + lock_tbl.plugins.basic.rev = nil + -- Wrong lock data field type + lock_tbl.plugins.defbranch.src = 1 ---@diagnostic disable-line: assign-type-mismatch + -- Wrong lock data type + lock_tbl.plugins.semver = 1 ---@diagnostic disable-line: assign-type-mismatch + + local lockfile_text = vim.json.encode(lock_tbl, { indent = ' ', sort_keys = true }) + fn.writefile(vim.split(lockfile_text, '\n'), get_lock_path()) + + n.clear() + assert() + + local ref_messages = + 'vim.pack: Repaired corrupted lock data for plugins: basic, defbranch, semver' + eq(ref_messages, n.exec_capture('messages')) + + -- Should work even for badly corrupted lockfile + lockfile_text = vim.json.encode({ plugins = 1 }, { indent = ' ', sort_keys = true }) + fn.writefile(vim.split(lockfile_text, '\n'), get_lock_path()) + + n.clear() + -- Can not preserve `version` if it was deleted from the lockfile + ref_lock_tbl.plugins.basic.version = nil + assert() + end) + + it('removes unrepairable corrupted data and plugins', function() + exec_lua(function() + vim.pack.add({ repos_src.basic, repos_src.defbranch, repos_src.semver, repos_src.helptags }) + end) + + local lock_tbl = get_lock_tbl() + local ref_lock_tbl = vim.deepcopy(lock_tbl) + + -- Corrupted data for missing plugin + vim.fs.rm(pack_get_plug_path('basic'), { recursive = true, force = true }) + lock_tbl.plugins.basic.rev = nil + + -- Good data for corrupted plugin + local defbranch_path = pack_get_plug_path('defbranch') + vim.fs.rm(defbranch_path, { recursive = true, force = true }) + fn.writefile({ 'File and not directory' }, defbranch_path) + + -- Corrupted data for corrupted plugin + local semver_path = pack_get_plug_path('semver') + vim.fs.rm(semver_path, { recursive = true, force = true }) + fn.writefile({ 'File and not directory' }, semver_path) + lock_tbl.plugins.semver.rev = 1 ---@diagnostic disable-line: assign-type-mismatch + + local lockfile_text = vim.json.encode(lock_tbl, { indent = ' ', sort_keys = true }) + fn.writefile(vim.split(lockfile_text, '\n'), get_lock_path()) + + n.clear() + exec_lua('vim.pack.add({})') + ref_lock_tbl.plugins.basic = nil + ref_lock_tbl.plugins.defbranch = nil + ref_lock_tbl.plugins.semver = nil + eq(ref_lock_tbl, get_lock_tbl()) + + eq(false, pack_exists('basic')) + eq(false, pack_exists('defbranch')) + eq(false, pack_exists('semver')) + eq(true, pack_exists('helptags')) + end) + it('installs at proper version', function() local out = exec_lua(function() vim.pack.add({ @@ -601,14 +782,12 @@ describe('vim.pack', function() eq(false, vim.tbl_contains(rtp, after_dir)) end) - it('does not checkout on bad `version`', function() + it('does not install on bad `version`', function() local err = pcall_err(exec_lua, function() vim.pack.add({ { src = repos_src.basic, version = 'not-exist' } }) end) matches('`not%-exist` is not a branch/tag/commit', err) - local plug_path = pack_get_plug_path('basic') - local entries = vim.iter(vim.fs.dir(plug_path)):totable() - eq({ { '.git', 'directory' } }, entries) + eq(false, pack_exists('basic')) end) it('allows changing `src` of installed plugin', function() @@ -1041,7 +1220,7 @@ describe('vim.pack', function() local confirm_bufnr = api.nvim_get_current_buf() local confirm_winnr = api.nvim_get_current_win() local confirm_tabpage = api.nvim_get_current_tabpage() - eq(api.nvim_buf_get_name(0), 'nvim://pack-confirm#' .. confirm_bufnr) + eq(api.nvim_buf_get_name(0), 'nvim-pack://confirm#' .. confirm_bufnr) -- Adjust lines for a more robust screenshot testing local fetch_src = repos_src.fetch @@ -1091,10 +1270,10 @@ describe('vim.pack', function() short_hashes.fetch_new_prev = git_get_short_hash('main~', 'fetch') hashes.semver_head = git_get_hash('v0.3.0', 'semver') - local tab_name = 'n' .. (t.is_os('win') and ':' or '') .. '//pack-confirm#2' + local tab_name = 'n' .. (t.is_os('win') and ':' or '') .. '//confirm#2' local screen_lines = { - ('{24: [No Name] }{5: %s }{2:%s }{24:X}|'):format( + ('{24: [No Name] }{5: %s }{2:%s }{24:X}|'):format( tab_name, t.is_os('win') and '' or ' ' ), @@ -1624,6 +1803,32 @@ describe('vim.pack', function() eq(ref_environ, fn.environ()) end) + it('works with out of sync lockfile', function() + -- Should first autoinstall missing plugin (with confirmation) + vim.fs.rm(pack_get_plug_path('fetch'), { force = true, recursive = true }) + n.clear() + mock_confirm(1) + exec_lua(function() + vim.pack.update(nil, { force = true }) + end) + eq(1, exec_lua('return #_G.confirm_log')) + -- - Should checkout `version='main'` as it says in the lockfile + eq('return "fetch new 2"', fn.readblob(fetch_lua_file)) + + -- Should regenerate absent lockfile (from present plugins) + vim.fs.rm(get_lock_path()) + n.clear() + exec_lua(function() + vim.pack.update(nil, { force = true }) + end) + local lock_plugins = get_lock_tbl().plugins + eq(3, vim.tbl_count(lock_plugins)) + -- - Should checkout default branch since `version='main'` info is lost + -- after lockfile is deleted. + eq(nil, lock_plugins.fetch.version) + eq('return "fetch dev"', fn.readblob(fetch_lua_file)) + end) + it('validates input', function() local function assert(err_pat, input) local function update_input() @@ -1773,6 +1978,29 @@ describe('vim.pack', function() local basic_data = make_basic_data(true, true) eq({ { defbranch_data, basic_data }, { basic_data } }, exec_lua('return _G.get_log')) end) + + it('works with out of sync lockfile', function() + exec_lua(function() + vim.pack.add({ repos_src.basic, repos_src.defbranch }) + end) + eq(2, vim.tbl_count(get_lock_tbl().plugins)) + local basic_lua_file = vim.fs.joinpath(pack_get_plug_path('basic'), 'lua', 'basic.lua') + + -- Should first autoinstall missing plugin (with confirmation) + vim.fs.rm(pack_get_plug_path('basic'), { force = true, recursive = true }) + n.clear() + mock_confirm(1) + eq(2, exec_lua('return #vim.pack.get()')) + + eq(1, exec_lua('return #_G.confirm_log')) + eq('return "basic main"', fn.readblob(basic_lua_file)) + + -- Should regenerate absent lockfile (from present plugins) + vim.fs.rm(get_lock_path()) + n.clear() + eq(2, exec_lua('return #vim.pack.get()')) + eq(2, vim.tbl_count(get_lock_tbl().plugins)) + end) end) describe('del()', function() @@ -1828,6 +2056,31 @@ describe('vim.pack', function() eq({ plugins = {} }, get_lock_tbl()) end) + it('works with out of sync lockfile', function() + exec_lua(function() + vim.pack.add({ repos_src.basic, repos_src.defbranch, repos_src.plugindirs }) + end) + eq(3, vim.tbl_count(get_lock_tbl().plugins)) + + -- Should first autoinstall missing plugin (with confirmation) + vim.fs.rm(pack_get_plug_path('basic'), { force = true, recursive = true }) + n.clear() + mock_confirm(1) + exec_lua('vim.pack.del({ "defbranch" })') + + eq(1, exec_lua('return #_G.confirm_log')) + eq(true, pack_exists('basic')) + eq(false, pack_exists('defbranch')) + eq(true, pack_exists('plugindirs')) + + -- Should regenerate absent lockfile (from present plugins) + vim.fs.rm(get_lock_path()) + n.clear() + exec_lua('vim.pack.del({ "basic" })') + eq(1, exec_lua('return #vim.pack.get()')) + eq({ 'plugindirs' }, vim.tbl_keys(get_lock_tbl().plugins)) + end) + it('validates input', function() local function assert(err_pat, input) local function del_input()