From d7db552394ead8ad9b365c60a650a425d50c5adf Mon Sep 17 00:00:00 2001 From: Evgeni Chasnovski Date: Sat, 4 Oct 2025 16:13:32 +0300 Subject: [PATCH 1/5] feat(pack): add initial lockfile tracking Problem: Some use cases require or benefit from persistent on disk storage of plugin data (a.k.a. "lockfile"): 1. Allow `update()` to act on not-yet-active plugins. Currently if `add()` is not yet called, then plugin's version is unknown and `update()` can't decide where to look for changes. 2. Efficiently know plugin's dependencies without having to read 'pkg.json' files on every load for every plugin. This is for the future, after there is `packspec` support (or other declaration of dependencies on plugin's side). 3. Allow initial install to check out the exact latest "working" state for a reproducible setup. Currently it pulls the latest available `version.` 4. Ensure that all declared plugins are installed, even if lazy loaded. So that later `add()` does not trigger auto-install (when there might be no Internet connection, for example) and there is no issues with knowing which plugins are used in the config (so even never loaded rare plugins are still installed and can be updated). 5. Allow `add()` to detect if plugin's spec has changed between Nvim sessions and act accordingly. I.e. either set new `src` as origin or enforce `version.` This is not critical and can be done during `update()`, but it might be nice to have. Solution: Add lockfile in JSON format that tracks (adds, updtes, removes) necessary data for described use cases. Here are the required data that enables each point: 1. `name` -> `version` map. 2. `name` -> `dependencies` map. 3. `name` -> `rev` map. Probably also requires `name` -> `src` map to ensure that commit comes from correct origin. 4. `name` -> `src` map. It would be good to also track the order, but that might be too many complications and redundant together with point 2. 5. Map from `name` to all relevant spec fields. I.e. `name` -> `src` and `name` -> `version` for now. Storing data might be too much, but can be discussed, of course. This commit only adds lockfile tracking without implementing actual use cases. It is stored in user's config directory and is suggested to be tracked via version control. Example of a lockfile: ```json { # Extra nesting to more future proof. "plugins": { "plug-a": { "ref": "abcdef1" "src": "https://github.com/user/plug-a", # No `version` means it was `nil` (infer default branch later) }, "plug-b": { "dependencies": ["plugin-a", "plug-c"], "src": "https://github.com/user/plug-b", "ref": "bcdefg2", # Enclose string `version` in quotes "version": "'dev'" }, "plug-c": { "src": "https://github.com/user/plug-c", "ref": "cdefgh3", # Store `vim.version.Range` via its `tostring()` output "version": ">=0.0.0", } } } ``` --- runtime/doc/pack.txt | 12 ++- runtime/lua/vim/pack.lua | 111 ++++++++++++++++++++++-- test/functional/plugin/pack_spec.lua | 121 +++++++++++++++++++++++++++ 3 files changed, 235 insertions(+), 9 deletions(-) diff --git a/runtime/doc/pack.txt b/runtime/doc/pack.txt index e80d3639ff..018d3867e9 100644 --- a/runtime/doc/pack.txt +++ b/runtime/doc/pack.txt @@ -221,6 +221,12 @@ Uses Git to manage plugins and requires present `git` executable of at least version 2.36. Target plugins should be Git repositories with versions as named tags following semver convention `v..`. +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. Should not be +edited by hand or deleted. + Example workflows ~ Basic install and management: @@ -260,11 +266,13 @@ Basic install and management: show confirmation buffer in a separate tabpage. • Review changes. To confirm all updates execute |:write|. To discard updates execute |:quit|. + • (Optionally) |:restart| to start using code from updated plugins. Switch plugin's version: • Update 'init.lua' for plugin to have desired `version`. Let's say, plugin named 'plugin1' has changed to `vim.version.range('*')`. -• |:restart|. The plugin's actual state on disk is not yet changed. +• |:restart|. The plugin's actual state on disk is not yet changed. Only + plugin's `version` in |vim.pack-lockfile| is updated. • Execute `vim.pack.update({ 'plugin1' })`. • Review changes and either confirm or discard them. If discarded, revert any changes in 'init.lua' as well or you will be prompted again next time you @@ -272,7 +280,7 @@ Switch plugin's version: Freeze plugin from being updated: • Update 'init.lua' for plugin to have `version` set to current revision. Get - it with `:=vim.pack.get({ 'plug-name' })[1].rev` (looks like `abc12345`). + it from |vim.pack-lockfile| (plugin's field `rev`; looks like `abc12345`). • |:restart|. Unfreeze plugin to start receiving updates: diff --git a/runtime/lua/vim/pack.lua b/runtime/lua/vim/pack.lua index 40f1786ac5..fc889f6780 100644 --- a/runtime/lua/vim/pack.lua +++ b/runtime/lua/vim/pack.lua @@ -14,6 +14,12 @@ ---least version 2.36. Target plugins should be Git repositories with versions ---as named tags following semver convention `v..`. --- +---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. +---Should not be edited by hand or deleted. +--- ---Example workflows ~ --- ---Basic install and management: @@ -57,11 +63,13 @@ --- show confirmation buffer in a separate tabpage. --- - Review changes. To confirm all updates execute |:write|. --- To discard updates execute |:quit|. +--- - (Optionally) |:restart| to start using code from updated plugins. --- ---Switch plugin's version: ---- Update 'init.lua' for plugin to have desired `version`. Let's say, plugin ---named 'plugin1' has changed to `vim.version.range('*')`. ---- |:restart|. The plugin's actual state on disk is not yet changed. +--- Only plugin's `version` in |vim.pack-lockfile| is updated. ---- Execute `vim.pack.update({ 'plugin1' })`. ---- Review changes and either confirm or discard them. If discarded, revert ---any changes in 'init.lua' as well or you will be prompted again next time @@ -69,7 +77,7 @@ --- ---Freeze plugin from being updated: ---- Update 'init.lua' for plugin to have `version` set to current revision. ----Get it with `:=vim.pack.get({ 'plug-name' })[1].rev` (looks like `abc12345`). +---Get it from |vim.pack-lockfile| (plugin's field `rev`; looks like `abc12345`). ---- |:restart|. --- ---Unfreeze plugin to start receiving updates: @@ -190,13 +198,72 @@ local function git_get_tags(cwd) return tags == '' and {} or vim.split(tags, '\n') end --- Plugin operations ---------------------------------------------------------- +-- 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 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[] --- @param level ('DEBUG'|'TRACE'|'INFO'|'WARN'|'ERROR')? local function notify(msg, level) @@ -532,6 +599,8 @@ local function checkout(p, timestamp, skip_same_sha) git_cmd({ 'checkout', '--quiet', p.info.sha_target }, p.path) + plugin_lock.plugins[p.spec.name].rev = p.info.sha_target + trigger_event(p, 'PackChanged', 'update') -- (Re)Generate help tags according to the current help files. @@ -561,6 +630,8 @@ local function install_list(plug_list, confirm) git_clone(p.spec.src, p.path) p.info.installed = true + plugin_lock.plugins[p.spec.name].src = p.spec.src + -- Do not skip checkout even if HEAD and target have same commit hash to -- have new repo in expected detached HEAD state and generated help files. checkout(p, timestamp, false) @@ -698,17 +769,34 @@ function M.add(specs, opts) end plugs = normalize_plugs(plugs) - -- Install - --- @param p vim.pack.Plug - local plugs_to_install = vim.tbl_filter(function(p) - return not p.info.installed - end, plugs) + -- Pre-process + lock_read() + local plugs_to_install = {} --- @type vim.pack.Plug[] + local needs_lock_write = false + for _, p in ipairs(plugs) do + -- TODO(echasnovski): check that lock's `src` is the same as in spec. + -- If not - cleanly reclone (delete directory and mark as not installed). + local p_lock = plugin_lock.plugins[p.spec.name] or {} + needs_lock_write = needs_lock_write or p_lock.version ~= p.spec.version + p_lock.version = p.spec.version + plugin_lock.plugins[p.spec.name] = p_lock + if not p.info.installed then + plugs_to_install[#plugs_to_install + 1] = p + needs_lock_write = true + end + end + + -- Install if #plugs_to_install > 0 then git_ensure_exec() install_list(plugs_to_install, opts.confirm) end + if needs_lock_write then + lock_write() + end + -- Register and load those actually on disk while collecting errors -- Delay showing all errors to have "good" plugins added first local errors = {} --- @type string[] @@ -899,6 +987,7 @@ function M.update(names, opts) return end git_ensure_exec() + lock_read() -- Perform update local timestamp = get_timestamp() @@ -925,6 +1014,7 @@ function M.update(names, opts) run_list(plug_list, do_update, progress_title) if opts.force then + lock_write() feedback_log(plug_list) return end @@ -950,6 +1040,7 @@ function M.update(names, opts) end run_list(plugs_to_checkout, do_checkout, 'Applying updates') + lock_write() feedback_log(plugs_to_checkout) end) end @@ -967,6 +1058,8 @@ function M.del(names) return end + lock_read() + for _, p in ipairs(plug_list) do trigger_event(p, 'PackChangedPre', 'delete') @@ -974,8 +1067,12 @@ function M.del(names) active_plugins[p.path] = nil notify(("Removed plugin '%s'"):format(p.spec.name), 'INFO') + plugin_lock.plugins[p.spec.name] = nil + trigger_event(p, 'PackChanged', 'delete') end + + lock_write() end --- @inlinedoc diff --git a/test/functional/plugin/pack_spec.lua b/test/functional/plugin/pack_spec.lua index fc7415c602..41f7c69cbc 100644 --- a/test/functional/plugin/pack_spec.lua +++ b/test/functional/plugin/pack_spec.lua @@ -310,6 +310,14 @@ local function is_jit() return exec_lua('return package.loaded.jit ~= nil') end +local function get_lock_path() + return vim.fs.joinpath(fn.stdpath('config'), 'nvim-pack-lock.json') +end + +local function get_lock_tbl() + return vim.json.decode(fn.readblob(get_lock_path())) +end + -- Tests ====================================================================== describe('vim.pack', function() @@ -326,6 +334,7 @@ describe('vim.pack', function() after_each(function() vim.fs.rm(pack_get_dir(), { force = true, recursive = true }) + vim.fs.rm(get_lock_path(), { force = true }) end) teardown(function() @@ -413,6 +422,80 @@ describe('vim.pack', function() eq('plugindirs main', exec_lua('return require("plugindirs")')) end) + it('creates lockfile', function() + local helptags_rev = git_get_hash('HEAD', 'helptags') + exec_lua(function() + vim.pack.add({ + { src = repos_src.basic, version = 'some-tag' }, + { src = repos_src.defbranch, version = 'main' }, + { src = repos_src.helptags, version = helptags_rev }, + { src = repos_src.plugindirs }, + { src = repos_src.semver, version = vim.version.range('*') }, + }) + end) + + local basic_rev = git_get_hash('some-tag', 'basic') + local defbranch_rev = git_get_hash('main', 'defbranch') + local plugindirs_rev = git_get_hash('HEAD', 'plugindirs') + local semver_rev = git_get_hash('v1.0.0', 'semver') + + -- Should properly format as indented JSON + local ref_lockfile_lines = { + '{', + ' "plugins": {', + ' "basic": {', + ' "rev": "' .. basic_rev .. '",', + ' "src": "' .. repos_src.basic .. '",', + -- Branch, tag, and commit should be serialized like `'value'` to be + -- distinguishable from version ranges + ' "version": "\'some-tag\'"', + ' },', + ' "defbranch": {', + ' "rev": "' .. defbranch_rev .. '",', + ' "src": "' .. repos_src.defbranch .. '",', + ' "version": "\'main\'"', + ' },', + ' "helptags": {', + ' "rev": "' .. helptags_rev .. '",', + ' "src": "' .. repos_src.helptags .. '",', + ' "version": "\'' .. helptags_rev .. '\'"', + ' },', + ' "plugindirs": {', + ' "rev": "' .. plugindirs_rev .. '",', + ' "src": "' .. repos_src.plugindirs .. '"', + -- Absent `version` should be missing and not autoresolved + ' },', + ' "semver": {', + ' "rev": "' .. semver_rev .. '",', + ' "src": "' .. repos_src.semver .. '",', + ' "version": ">=0.0.0"', + ' }', + ' }', + '}', + } + eq(ref_lockfile_lines, fn.readfile(get_lock_path())) + end) + + it('updates lockfile', function() + exec_lua(function() + vim.pack.add({ repos_src.basic }) + end) + local ref_lockfile = { + plugins = { + basic = { rev = git_get_hash('main', 'basic'), src = repos_src.basic }, + }, + } + eq(ref_lockfile, get_lock_tbl()) + + n.clear() + exec_lua(function() + vim.pack.add({ { src = repos_src.basic, version = 'main' } }) + end) + + ref_lockfile.plugins.basic.version = "'main'" + eq(ref_lockfile, get_lock_tbl()) + end) + it('installs at proper version', function() local out = exec_lua(function() vim.pack.add({ @@ -1087,6 +1170,20 @@ describe('vim.pack', function() local confirm_text = table.concat(api.nvim_buf_get_lines(0, 0, -1, false), '\n') matches('Available newer versions:\n• v1%.0%.0\n• v0%.4\n• 0%.3%.1$', confirm_text) end) + + it('updates lockfile', function() + exec_lua(function() + vim.pack.add({ repos_src.fetch }) + end) + local ref_fetch_lock = { rev = hashes.fetch_head, src = repos_src.fetch } + eq(ref_fetch_lock, get_lock_tbl().plugins.fetch) + + exec_lua('vim.pack.update()') + n.exec('write') + + ref_fetch_lock.rev = git_get_hash('HEAD', 'fetch') + eq(ref_fetch_lock, get_lock_tbl().plugins.fetch) + end) end) it('works with not active plugins', function() @@ -1138,6 +1235,9 @@ describe('vim.pack', function() '', } eq(ref_log_lines, vim.list_slice(log_lines, 2)) + + -- Should update lockfile + eq(git_get_hash('HEAD', 'fetch'), get_lock_tbl().plugins.fetch.rev) end) it('shows progress report', function() @@ -1350,6 +1450,10 @@ describe('vim.pack', function() eq(true, pack_exists('basic')) eq(true, pack_exists('plugindirs')) + local locked_plugins = vim.tbl_keys(get_lock_tbl().plugins) + table.sort(locked_plugins) + eq({ 'basic', 'plugindirs' }, locked_plugins) + watch_events({ 'PackChangedPre', 'PackChanged' }) n.exec('messages clear') @@ -1371,6 +1475,23 @@ describe('vim.pack', function() eq(3, find_in_log(log, 'PackChangedPre', 'delete', 'plugindirs', nil)) eq(4, find_in_log(log, 'PackChanged', 'delete', 'plugindirs', nil)) eq(4, #log) + + -- Should update lockfile + eq({ plugins = {} }, get_lock_tbl()) + end) + + it('works without prior `add()`', function() + exec_lua(function() + vim.pack.add({ repos_src.basic }) + end) + n.clear() + + eq(true, pack_exists('basic')) + exec_lua(function() + vim.pack.del({ 'basic' }) + end) + eq(false, pack_exists('basic')) + eq({ plugins = {} }, get_lock_tbl()) end) it('validates input', function() From 2c0b70e5598d0fd888a524ea955d48b91ceccd29 Mon Sep 17 00:00:00 2001 From: Evgeni Chasnovski Date: Sat, 4 Oct 2025 16:13:36 +0300 Subject: [PATCH 2/5] fix(pack)!: use lockfile in `get()` for data about non-active plugins Problem: `get()` doesn't return `spec.version` about not-yet-active plugins (because there was no way to know that without `add()`). Solution: Use lockfile data to set `spec.version` of non-active plugins. --- runtime/lua/vim/pack.lua | 22 +++++------- test/functional/plugin/pack_spec.lua | 54 +++++++++++++++++++++------- 2 files changed, 51 insertions(+), 25 deletions(-) diff --git a/runtime/lua/vim/pack.lua b/runtime/lua/vim/pack.lua index fc889f6780..fb6ff2958f 100644 --- a/runtime/lua/vim/pack.lua +++ b/runtime/lua/vim/pack.lua @@ -1122,6 +1122,7 @@ function M.get(names, opts) active[p_active.id] = p_active.plug end + lock_read() local res = {} --- @type vim.pack.PlugData[] local used_names = {} --- @type table for i = 1, n_active_plugins do @@ -1131,21 +1132,16 @@ function M.get(names, opts) end end - --- @async - local function do_get() - -- Process not active plugins - local plug_dir = get_plug_dir() - for n, t in vim.fs.dir(plug_dir, { depth = 1 }) do - local path = vim.fs.joinpath(plug_dir, n) - local is_in_names = not names or vim.tbl_contains(names, n) - if t == 'directory' and not active_plugins[path] and is_in_names then - local spec = { name = n, src = git_cmd({ 'remote', 'get-url', 'origin' }, path) } - res[#res + 1] = { spec = spec, path = path, active = false } - used_names[n] = true - end + local plug_dir = get_plug_dir() + for name, l_data in vim.spairs(plugin_lock.plugins) do + local path = vim.fs.joinpath(plug_dir, name) + local is_in_names = not names or vim.tbl_contains(names, name) + if not active_plugins[path] and is_in_names then + local spec = { name = name, src = l_data.src, version = l_data.version } + res[#res + 1] = { spec = spec, path = path, active = false } + used_names[name] = true end end - async.run(do_get):wait() if names ~= nil then -- Align result with input diff --git a/test/functional/plugin/pack_spec.lua b/test/functional/plugin/pack_spec.lua index 41f7c69cbc..700182c22a 100644 --- a/test/functional/plugin/pack_spec.lua +++ b/test/functional/plugin/pack_spec.lua @@ -135,6 +135,12 @@ end function repos_setup.plugindirs() init_test_repo('plugindirs') + -- Add semver tag + repo_write_file('plugindirs', 'lua/plugindirs.lua', 'return "plugindirs v0.0.1"') + git_add_commit('Add version v0.0.1', 'plugindirs') + git_cmd({ 'tag', 'v0.0.1' }, 'plugindirs') + + -- Add various 'plugin/' files repo_write_file('plugindirs', 'lua/plugindirs.lua', 'return "plugindirs main"') repo_write_file('plugindirs', 'plugin/dirs.lua', 'vim.g._plugin = true') repo_write_file('plugindirs', 'plugin/dirs_log.lua', '_G.DL = _G.DL or {}; DL[#DL+1] = "p"') @@ -1367,41 +1373,65 @@ describe('vim.pack', function() return res end + local make_plugindirs_data = function(active, info) + local spec = + { name = 'plugindirs', src = repos_src.plugindirs, version = vim.version.range('*') } + local path = pack_get_plug_path('plugindirs') + local rev = git_get_hash('v0.0.1', 'plugindirs') + local res = { active = active, path = path, spec = spec, rev = rev } + if info then + res.branches = { 'main' } + res.tags = { 'v0.0.1' } + end + return res + end + it('returns list with necessary data', function() - local basic_data, defbranch_data + local basic_data, defbranch_data, plugindirs_data -- Should work just after installation exec_lua(function() - vim.pack.add({ repos_src.defbranch, { src = repos_src.basic, version = 'feat-branch' } }) + vim.pack.add({ + repos_src.defbranch, + { src = repos_src.basic, version = 'feat-branch' }, + { src = repos_src.plugindirs, version = vim.version.range('*') }, + }) end) defbranch_data = make_defbranch_data(true, true) basic_data = make_basic_data(true, true) + plugindirs_data = make_plugindirs_data(true, true) -- Should preserve order in which plugins were `vim.pack.add()`ed - eq({ defbranch_data, basic_data }, exec_lua('return vim.pack.get()')) + eq({ defbranch_data, basic_data, plugindirs_data }, exec_lua('return vim.pack.get()')) -- Should also list non-active plugins n.clear() exec_lua(function() - vim.pack.add({ { src = repos_src.basic, version = 'feat-branch' } }) + vim.pack.add({ repos_src.defbranch }) end) - defbranch_data = make_defbranch_data(false, true) - basic_data = make_basic_data(true, true) - -- Should first list active, then non-active - eq({ basic_data, defbranch_data }, exec_lua('return vim.pack.get()')) + defbranch_data = make_defbranch_data(true, true) + basic_data = make_basic_data(false, true) + plugindirs_data = make_plugindirs_data(false, true) + -- Should first list active, then non-active (including their latest + -- set `version` which is inferred from lockfile) + eq({ defbranch_data, basic_data, plugindirs_data }, exec_lua('return vim.pack.get()')) -- Should respect `names` for both active and not active plugins eq({ basic_data }, exec_lua('return vim.pack.get({ "basic" })')) eq({ defbranch_data }, exec_lua('return vim.pack.get({ "defbranch" })')) - eq({ defbranch_data, basic_data }, exec_lua('return vim.pack.get({ "defbranch", "basic" })')) + eq({ basic_data, defbranch_data }, exec_lua('return vim.pack.get({ "basic", "defbranch" })')) local bad_get_cmd = 'return vim.pack.get({ "ccc", "basic", "aaa" })' matches('Plugin `ccc` is not installed', pcall_err(exec_lua, bad_get_cmd)) -- Should respect `opts.info` - defbranch_data = make_defbranch_data(false, false) - basic_data = make_basic_data(true, false) - eq({ basic_data, defbranch_data }, exec_lua('return vim.pack.get(nil, { info = false })')) + defbranch_data = make_defbranch_data(true, false) + basic_data = make_basic_data(false, false) + plugindirs_data = make_plugindirs_data(false, false) + eq( + { defbranch_data, basic_data, plugindirs_data }, + exec_lua('return vim.pack.get(nil, { info = false })') + ) eq({ basic_data }, exec_lua('return vim.pack.get({ "basic" }, { info = false })')) eq({ defbranch_data }, exec_lua('return vim.pack.get({ "defbranch" }, { info = false })')) end) From a31f63f661a0c05bdec678facfc4a5fdc4630435 Mon Sep 17 00:00:00 2001 From: Evgeni Chasnovski Date: Sat, 4 Oct 2025 16:13:38 +0300 Subject: [PATCH 3/5] feat(pack)!: update `get()` to return revision regardless of `opts.info` Problem: The revision data is returned behind `opts.info` flag because it required extra Git calls. With lockfile it is not the case. Solution: Use lockfile to always set `rev` field in output of `get()`. --- runtime/doc/pack.txt | 2 +- runtime/lua/vim/pack.lua | 12 +++++++----- test/functional/plugin/pack_spec.lua | 13 ++++++------- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/runtime/doc/pack.txt b/runtime/doc/pack.txt index 018d3867e9..5221c6a213 100644 --- a/runtime/doc/pack.txt +++ b/runtime/doc/pack.txt @@ -379,7 +379,7 @@ get({names}, {opts}) *vim.pack.get()* • {branches}? (`string[]`) Available Git branches (first is default). Missing if `info=false`. • {path} (`string`) Plugin's path on disk. - • {rev}? (`string`) Current Git revision. Missing if `info=false`. + • {rev} (`string`) Current Git revision. • {spec} (`vim.pack.SpecResolved`) A |vim.pack.Spec| with resolved `name`. • {tags}? (`string[]`) Available Git tags. Missing if `info=false`. diff --git a/runtime/lua/vim/pack.lua b/runtime/lua/vim/pack.lua index fb6ff2958f..fe68bb3a02 100644 --- a/runtime/lua/vim/pack.lua +++ b/runtime/lua/vim/pack.lua @@ -1080,7 +1080,7 @@ end --- @field active boolean Whether plugin was added via |vim.pack.add()| to current session. --- @field branches? string[] Available Git branches (first is default). Missing if `info=false`. --- @field path string Plugin's path on disk. ---- @field rev? string Current Git revision. Missing if `info=false`. +--- @field rev string Current Git revision. --- @field spec vim.pack.SpecResolved A |vim.pack.Spec| with resolved `name`. --- @field tags? string[] Available Git tags. Missing if `info=false`. @@ -1096,7 +1096,6 @@ local function add_p_data_info(p_data_list) --- @async funs[i] = function() p_data.branches = git_get_branches(path) - p_data.rev = git_get_hash('HEAD', path) p_data.tags = git_get_tags(path) end end @@ -1127,8 +1126,11 @@ function M.get(names, opts) local used_names = {} --- @type table for i = 1, n_active_plugins do if active[i] and (not names or vim.tbl_contains(names, active[i].spec.name)) then - res[#res + 1] = { spec = vim.deepcopy(active[i].spec), path = active[i].path, active = true } - used_names[active[i].spec.name] = true + local name = active[i].spec.name + local spec = vim.deepcopy(active[i].spec) + local rev = (plugin_lock.plugins[name] or {}).rev + res[#res + 1] = { spec = spec, path = active[i].path, rev = rev, active = true } + used_names[name] = true end end @@ -1138,7 +1140,7 @@ function M.get(names, opts) local is_in_names = not names or vim.tbl_contains(names, name) if not active_plugins[path] and is_in_names then local spec = { name = name, src = l_data.src, version = l_data.version } - res[#res + 1] = { spec = spec, path = path, active = false } + res[#res + 1] = { spec = spec, path = path, rev = l_data.rev, active = false } used_names[name] = true end end diff --git a/test/functional/plugin/pack_spec.lua b/test/functional/plugin/pack_spec.lua index 700182c22a..3f893a8324 100644 --- a/test/functional/plugin/pack_spec.lua +++ b/test/functional/plugin/pack_spec.lua @@ -686,9 +686,8 @@ describe('vim.pack', function() eq({}, n.exec_lua('return { vim.g._plugin, vim.g._after_plugin }')) -- Plugins should still be marked as "active", since they were added - plugindirs_data.active = true - basic_data.active = true - eq({ plugindirs_data, basic_data }, exec_lua('return vim.pack.get(nil, { info = false })')) + eq(true, exec_lua('return vim.pack.get({ "plugindirs" })[1].active')) + eq(true, exec_lua('return vim.pack.get({ "basic" })[1].active')) end -- Works on initial install @@ -1352,10 +1351,10 @@ describe('vim.pack', function() local make_basic_data = function(active, info) local spec = { name = 'basic', src = repos_src.basic, version = 'feat-branch' } local path = pack_get_plug_path('basic') - local res = { active = active, path = path, spec = spec } + local rev = git_get_hash('feat-branch', 'basic') + local res = { active = active, path = path, spec = spec, rev = rev } if info then res.branches = { 'main', 'feat-branch' } - res.rev = git_get_hash('feat-branch', 'basic') res.tags = { 'some-tag' } end return res @@ -1364,10 +1363,10 @@ describe('vim.pack', function() local make_defbranch_data = function(active, info) local spec = { name = 'defbranch', src = repos_src.defbranch } local path = pack_get_plug_path('defbranch') - local res = { active = active, path = path, spec = spec } + local rev = git_get_hash('dev', 'defbranch') + local res = { active = active, path = path, spec = spec, rev = rev } if info then res.branches = { 'dev', 'main' } - res.rev = git_get_hash('dev', 'defbranch') res.tags = {} end return res From cfbc03a9549017984a2ca9c139553aac1c016a42 Mon Sep 17 00:00:00 2001 From: Evgeni Chasnovski Date: Sat, 4 Oct 2025 16:13:39 +0300 Subject: [PATCH 4/5] feat(pack)!: make `update()` include not active plugins by default Problem: Running `update()` by default doesn't include not active plugins, because there was no way to get relevant `version` to get updates from. This might be a problem in presence of lazy loaded plugins, i.e. ones that can be "not *yet* active" but still needed to be updated. Solution: Include not active plugins by default since their `version` is tracked via lockfile. --- runtime/doc/pack.txt | 3 +-- runtime/lua/vim/pack.lua | 11 ++--------- test/functional/plugin/pack_spec.lua | 29 +++++++++++++++++----------- 3 files changed, 21 insertions(+), 22 deletions(-) diff --git a/runtime/doc/pack.txt b/runtime/doc/pack.txt index 5221c6a213..6cf225aec6 100644 --- a/runtime/doc/pack.txt +++ b/runtime/doc/pack.txt @@ -410,8 +410,7 @@ update({names}, {opts}) *vim.pack.update()* Parameters: ~ • {names} (`string[]?`) List of plugin names to update. Must be managed by |vim.pack|, not necessarily already added to current - session. Default: names of all plugins added to current - session via |vim.pack.add()|. + session. Default: names of all plugins managed by |vim.pack|. • {opts} (`table?`) A table with the following fields: • {force}? (`boolean`) Whether to skip confirmation and make updates immediately. Default `false`. diff --git a/runtime/lua/vim/pack.lua b/runtime/lua/vim/pack.lua index fe68bb3a02..268f43e063 100644 --- a/runtime/lua/vim/pack.lua +++ b/runtime/lua/vim/pack.lua @@ -401,15 +401,8 @@ local function plug_list_from_names(names) local plug_dir = get_plug_dir() local plugs = {} --- @type vim.pack.Plug[] for _, p_data in ipairs(p_data_list) do - -- NOTE: By default include only active plugins (and not all on disk). Using - -- not active plugins might lead to a confusion as default `version` and - -- user's desired one might mismatch. - -- TODO(echasnovski): Change this when there is lockfile. - if names ~= nil or p_data.active then - plugs[#plugs + 1] = new_plug(p_data.spec, plug_dir) - end + plugs[#plugs + 1] = new_plug(p_data.spec, plug_dir) end - return plugs end @@ -975,7 +968,7 @@ end --- --- @param names? string[] List of plugin names to update. Must be managed --- by |vim.pack|, not necessarily already added to current session. ---- Default: names of all plugins added to current session via |vim.pack.add()|. +--- Default: names of all plugins managed by |vim.pack|. --- @param opts? vim.pack.keyset.update function M.update(names, opts) vim.validate('names', names, vim.islist, true, 'list') diff --git a/test/functional/plugin/pack_spec.lua b/test/functional/plugin/pack_spec.lua index 3f893a8324..7440d2b9ca 100644 --- a/test/functional/plugin/pack_spec.lua +++ b/test/functional/plugin/pack_spec.lua @@ -847,7 +847,7 @@ describe('vim.pack', function() -- Install initial versions of tested plugins exec_lua(function() vim.pack.add({ - repos_src.fetch, + { src = repos_src.fetch, version = 'main' }, { src = repos_src.semver, version = 'v0.3.0' }, repos_src.defbranch, }) @@ -865,6 +865,11 @@ describe('vim.pack', function() repo_write_file('fetch', 'lua/fetch.lua', 'return "fetch new 2"') git_add_commit('Commit to be added 2', 'fetch') + + -- Make `dev` default remote branch to check that `version` is respected + git_cmd({ 'checkout', '-b', 'dev' }, 'fetch') + repo_write_file('fetch', 'lua/fetch.lua', 'return "fetch dev"') + git_add_commit('Commit from default `dev` branch', 'fetch') end) after_each(function() @@ -942,8 +947,8 @@ describe('vim.pack', function() local screen screen = Screen.new(85, 35) - hashes.fetch_new = git_get_hash('HEAD', 'fetch') - hashes.fetch_new_prev = git_get_hash('HEAD~', 'fetch') + hashes.fetch_new = git_get_hash('main', 'fetch') + hashes.fetch_new_prev = git_get_hash('main~', 'fetch') hashes.semver_head = git_get_hash('v0.3.0', 'semver') local tab_name = 'n' .. (t.is_os('win') and ':' or '') .. '//2/confirm-update' @@ -1186,15 +1191,16 @@ describe('vim.pack', function() exec_lua('vim.pack.update()') n.exec('write') - ref_fetch_lock.rev = git_get_hash('HEAD', 'fetch') + ref_fetch_lock.rev = git_get_hash('main', 'fetch') eq(ref_fetch_lock, get_lock_tbl().plugins.fetch) end) end) it('works with not active plugins', function() + -- No plugins are added, but they are installed in `before_each()` exec_lua(function() - -- No plugins are added, but they are installed in `before_each()` - vim.pack.update({ 'fetch' }) + -- By default should also include not active plugins + vim.pack.update() end) eq({ 'return "fetch main"' }, fn.readfile(fetch_lua_file)) n.exec('write') @@ -1218,8 +1224,8 @@ describe('vim.pack', function() eq('', api.nvim_get_option_value('filetype', {})) -- Write to log file - hashes.fetch_new = git_get_hash('HEAD', 'fetch') - hashes.fetch_new_prev = git_get_hash('HEAD~', 'fetch') + hashes.fetch_new = git_get_hash('main', 'fetch') + hashes.fetch_new_prev = git_get_hash('main~', 'fetch') local log_path = vim.fs.joinpath(fn.stdpath('log'), 'nvim-pack.log') local log_lines = fn.readfile(log_path) @@ -1242,18 +1248,19 @@ describe('vim.pack', function() eq(ref_log_lines, vim.list_slice(log_lines, 2)) -- Should update lockfile - eq(git_get_hash('HEAD', 'fetch'), get_lock_tbl().plugins.fetch.rev) + eq(hashes.fetch_new, get_lock_tbl().plugins.fetch.rev) end) it('shows progress report', function() track_nvim_echo() exec_lua(function() vim.pack.add({ repos_src.fetch, repos_src.defbranch }) + -- Should also include updates from not active plugins vim.pack.update() end) -- During initial download - validate_progress_report('Downloading updates', { 'fetch', 'defbranch' }) + validate_progress_report('Downloading updates', { 'fetch', 'defbranch', 'semver' }) exec_lua('_G.echo_log = {}') -- During application (only for plugins that have updates) @@ -1270,7 +1277,7 @@ describe('vim.pack', function() vim.pack.add({ repos_src.fetch, repos_src.defbranch }) vim.pack.update(nil, { force = true }) end) - validate_progress_report('Updating', { 'fetch', 'defbranch' }) + validate_progress_report('Updating', { 'fetch', 'defbranch', 'semver' }) end) it('triggers relevant events', function() From dc8235c48c8be65659f75461d8cb185ab1941d0f Mon Sep 17 00:00:00 2001 From: Evgeni Chasnovski Date: Sat, 4 Oct 2025 16:15:54 +0300 Subject: [PATCH 5/5] feat(pack): prefer using revision from lockfile during install Problem: Installing plugin always pulls latest `version` changes (usually from the default branch or "latest version tag"). It is more robust to prefer initial installation to use the latest recorded (i.e. "working") revision. Solution: Prefer using revision from the lockfile (if present) during install. The extra `update()` will pull the latest changes. --- runtime/doc/pack.txt | 8 +++--- runtime/lua/vim/pack.lua | 9 +++++-- test/functional/plugin/pack_spec.lua | 39 ++++++++++++++++++++++++++++ 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/runtime/doc/pack.txt b/runtime/doc/pack.txt index 6cf225aec6..557f1bc762 100644 --- a/runtime/doc/pack.txt +++ b/runtime/doc/pack.txt @@ -224,8 +224,9 @@ tags following semver convention `v..`. 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. Should not be -edited by hand or deleted. +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. Example workflows ~ @@ -260,7 +261,8 @@ Basic install and management: plugin1 = require('plugin1') < • Restart Nvim (for example, with |:restart|). Plugins that were not yet - installed will be available on disk in target state after `add()` call. + installed will be available on disk after `add()` call. Their revision is + taken from |vim.pack-lockfile| (if present) or inferred from the `version`. • To update all plugins with new changes: • Execute |vim.pack.update()|. This will download updates from source and show confirmation buffer in a separate tabpage. diff --git a/runtime/lua/vim/pack.lua b/runtime/lua/vim/pack.lua index 268f43e063..6790350bc3 100644 --- a/runtime/lua/vim/pack.lua +++ b/runtime/lua/vim/pack.lua @@ -18,7 +18,8 @@ ---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. ----Should not be edited by hand or deleted. +---In this case initial install prefers revision from the lockfile instead of +---inferring from `version`. Should not be edited by hand or deleted. --- ---Example workflows ~ --- @@ -56,7 +57,8 @@ ---``` --- ---- Restart Nvim (for example, with |:restart|). Plugins that were not yet ----installed will be available on disk in target state after `add()` call. +---installed will be available on disk after `add()` call. Their revision is +---taken from |vim.pack-lockfile| (if present) or inferred from the `version`. --- ---- To update all plugins with new changes: --- - Execute |vim.pack.update()|. This will download updates from source and @@ -625,6 +627,9 @@ local function install_list(plug_list, confirm) plugin_lock.plugins[p.spec.name].src = p.spec.src + -- Prefer revision from the lockfile instead of using `version` + p.info.sha_target = (plugin_lock.plugins[p.spec.name] or {}).rev + -- Do not skip checkout even if HEAD and target have same commit hash to -- have new repo in expected detached HEAD state and generated help files. checkout(p, timestamp, false) diff --git a/test/functional/plugin/pack_spec.lua b/test/functional/plugin/pack_spec.lua index 7440d2b9ca..4ea9a0eca7 100644 --- a/test/functional/plugin/pack_spec.lua +++ b/test/functional/plugin/pack_spec.lua @@ -341,6 +341,8 @@ describe('vim.pack', function() after_each(function() vim.fs.rm(pack_get_dir(), { force = true, recursive = true }) vim.fs.rm(get_lock_path(), { force = true }) + local log_path = vim.fs.joinpath(fn.stdpath('log'), 'nvim-pack.log') + pcall(vim.fs.rm, log_path, { force = true }) end) teardown(function() @@ -502,6 +504,43 @@ describe('vim.pack', function() eq(ref_lockfile, get_lock_tbl()) end) + it('uses lockfile revision during install', function() + exec_lua(function() + vim.pack.add({ { src = repos_src.basic, version = 'feat-branch' } }) + end) + + -- Mock clean initial install, but with lockfile present + 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 ref_lockfile = { + plugins = { + basic = { rev = basic_rev, src = repos_src.basic, version = "'feat-branch'" }, + }, + } + eq(ref_lockfile, get_lock_tbl()) + + exec_lua(function() + -- Should use revision from lockfile (pointing at latest 'feat-branch' + -- commit) and not use latest `main` commit + vim.pack.add({ { src = repos_src.basic, version = 'main' } }) + end) + local basic_lua_file = vim.fs.joinpath(pack_get_plug_path('basic'), 'lua', 'basic.lua') + eq({ 'return "basic feat-branch"' }, fn.readfile(basic_lua_file)) + + -- Running `update()` should still update to use `main` + exec_lua(function() + vim.pack.update(nil, { force = true }) + end) + eq({ 'return "basic main"' }, fn.readfile(basic_lua_file)) + + ref_lockfile.plugins.basic.rev = git_get_hash('main', 'basic') + ref_lockfile.plugins.basic.version = "'main'" + eq(ref_lockfile, get_lock_tbl()) + end) + it('installs at proper version', function() local out = exec_lua(function() vim.pack.add({