From 0653e7a338e888d7b98143b8aa85865d843eefb7 Mon Sep 17 00:00:00 2001 From: Evgeni Chasnovski Date: Wed, 8 Jul 2026 19:23:28 +0300 Subject: [PATCH] feat(pack): 'packlockfile' option #40562 Problem: No way to configure the lockfile location. Solution: Add 'packlockfile' option. --- runtime/doc/news.txt | 1 + runtime/doc/options.txt | 7 ++ runtime/doc/pack.txt | 18 ++--- runtime/lua/vim/_meta/options.gen.lua | 9 +++ runtime/lua/vim/pack.lua | 16 ++--- runtime/lua/vim/pack/health.lua | 17 ++++- runtime/scripts/optwin.lua | 1 + src/nvim/option_vars.h | 1 + src/nvim/options.lua | 16 +++++ test/functional/plugin/pack_spec.lua | 95 +++++++++++++++++++++++++++ 10 files changed, 160 insertions(+), 21 deletions(-) diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 05a5beb599..6d78679cff 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -293,6 +293,7 @@ OPTIONS The same applies to 'rulerformat', 'statuscolumn', 'tabline', 'winbar', 'titlestring', and 'iconstring'. • 'winpinned' prevents window from closing unless specifically targeted. +• 'packlockfile' sets the path used for |vim.pack-lockfile|. PERFORMANCE diff --git a/runtime/doc/options.txt b/runtime/doc/options.txt index d9be554830..d3a1923d6f 100644 --- a/runtime/doc/options.txt +++ b/runtime/doc/options.txt @@ -4784,6 +4784,13 @@ A jump table for the options with a short description can be found at |Q_op|. the name of a function, a |lambda| or a |Funcref|. See |option-value-function| for more information. + *'packlockfile'* *'plf'* +'packlockfile' 'plf' string (default "$XDG_CONFIG_HOME/nvim/nvim-pack-lock.json") + global + Disallowed in |modeline|. |no-modeline-option| + Path of |vim.pack-lockfile|. Must be set before the first usage of any + |vim.pack| function. Environment variables are expanded |:set_env|. + *'packpath'* *'pp'* 'packpath' 'pp' string (default see 'runtimepath') global diff --git a/runtime/doc/pack.txt b/runtime/doc/pack.txt index fb355d88b5..ac065c1a1f 100644 --- a/runtime/doc/pack.txt +++ b/runtime/doc/pack.txt @@ -224,15 +224,15 @@ semver convention `v..` (with or without `v` prefix). Like `v1.2.0` or `1.2.0`, but not `1.2` or `v1`. 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 all -plugins from the lockfile will be installed at once (in alphabetical order) -and at lockfile's revision (instead of inferring from `version`). This is done -on the very first `vim.pack` function call to ensure that lockfile is aligned -with what is actually on the disk. Lockfile 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. +located at |'packlockfile'|. 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 all plugins from the +lockfile will be installed at once (in alphabetical order) and at lockfile's +revision (instead of inferring from `version`). This is done on the very first +`vim.pack` function call to ensure that lockfile is aligned with what is +actually on the disk. Lockfile 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. *vim.pack-examples* diff --git a/runtime/lua/vim/_meta/options.gen.lua b/runtime/lua/vim/_meta/options.gen.lua index fd06ffe365..e7296cea5d 100644 --- a/runtime/lua/vim/_meta/options.gen.lua +++ b/runtime/lua/vim/_meta/options.gen.lua @@ -4888,6 +4888,15 @@ vim.o.opfunc = vim.o.operatorfunc vim.go.operatorfunc = vim.o.operatorfunc vim.go.opfunc = vim.go.operatorfunc +--- Path of `vim.pack-lockfile`. Must be set before the first usage of any +--- `vim.pack` function. Environment variables are expanded `:set_env`. +--- +--- @type string +vim.o.packlockfile = "$XDG_CONFIG_HOME/nvim/nvim-pack-lock.json" +vim.o.plf = vim.o.packlockfile +vim.go.packlockfile = vim.o.packlockfile +vim.go.plf = vim.go.packlockfile + --- Directories used to find packages. --- See `packages` and `packages-runtimepath`. --- Environment variables are expanded `:set_env`. diff --git a/runtime/lua/vim/pack.lua b/runtime/lua/vim/pack.lua index ad414680a8..291ea0e147 100644 --- a/runtime/lua/vim/pack.lua +++ b/runtime/lua/vim/pack.lua @@ -16,8 +16,8 @@ ---Like `v1.2.0` or `1.2.0`, but not `1.2` or `v1`. --- ---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. +---located at |'packlockfile'|. +---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 all plugins from the lockfile will be installed at once (in alphabetical order) and ---at lockfile's revision (instead of inferring from `version`). This is done on the very first @@ -258,10 +258,6 @@ 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 @@ -871,9 +867,8 @@ local function lock_write() end end - local path = lock_get_path() - vim.fn.mkdir(vim.fs.dirname(path), 'p') - local fd = assert(uv.fs_open(path, 'w', 438)) + vim.fn.mkdir(vim.fs.dirname(M._plugin_lock_path), 'p') + local fd = assert(uv.fs_open(M._plugin_lock_path, 'w', 438)) local data = vim.json.encode(lock, { indent = ' ', sort_keys = true }) assert(uv.fs_write(fd, data .. '\n')) @@ -998,7 +993,8 @@ local function lock_read(confirm, specs) return end - local fd = uv.fs_open(lock_get_path(), 'r', 438) + M._plugin_lock_path = vim.go.packlockfile + local fd = uv.fs_open(M._plugin_lock_path, 'r', 438) if fd then local stat = assert(uv.fs_fstat(fd)) local data = assert(uv.fs_read(fd, stat.size, 0)) diff --git a/runtime/lua/vim/pack/health.lua b/runtime/lua/vim/pack/health.lua index dff67ffffd..5045c58c8f 100644 --- a/runtime/lua/vim/pack/health.lua +++ b/runtime/lua/vim/pack/health.lua @@ -3,7 +3,7 @@ local M = {} local health = vim.health local function get_lockfile_path() - return vim.fs.joinpath(vim.fn.stdpath('config'), 'nvim-pack-lock.json') + return vim.pack._plugin_lock_path or vim.go.packlockfile end local function get_plug_dir() @@ -159,7 +159,8 @@ end local function check_lockfile() health.start('vim.pack: lockfile') - local can_read, text = pcall(vim.fn.readblob, get_lockfile_path()) + local path = get_lockfile_path() + local can_read, text = pcall(vim.fn.readblob, path) if not can_read then health.error('Could not read lockfile. Delete it and restart Nvim.') return @@ -177,6 +178,18 @@ local function check_lockfile() end local is_good = true + if path ~= vim.go.packlockfile then + health.warn( + "Lockfile path is not the same as 'packlockfile' option value. " + .. "Set 'packlockfile' before the first usage of `vim.pack` function." + ) + is_good = false + end + if path ~= vim.fs.abspath(path) then + health.warn('Lockfile path is not absolute. Make sure that this is intentional.') + is_good = false + end + --- @cast data { plugins: table } for plug_name, lock_data in pairs(data.plugins) do is_good = check_plugin_lock_data(plug_name, lock_data) and is_good diff --git a/runtime/scripts/optwin.lua b/runtime/scripts/optwin.lua index 940a41164c..198a79e879 100644 --- a/runtime/scripts/optwin.lua +++ b/runtime/scripts/optwin.lua @@ -456,6 +456,7 @@ local options_list = { { 'termpastefilter', N_ 'characters removed when pasting into terminal window' }, { 'scrollback', N_ 'number of lines kept beyond the visible screen in terminal buffer' }, { 'ttyfast', N_ 'assume terminal responds quickly, enabling more features' }, + { 'packlockfile', N_ 'sets the path used for vim.pack lockfile' }, }, } diff --git a/src/nvim/option_vars.h b/src/nvim/option_vars.h index 2f5ad4b3d6..0db1f34ea0 100644 --- a/src/nvim/option_vars.h +++ b/src/nvim/option_vars.h @@ -467,6 +467,7 @@ EXTERN int p_ari; ///< 'allowrevins' EXTERN int p_ri; ///< 'revins' EXTERN int p_ru; ///< 'ruler' EXTERN char *p_ruf; ///< 'rulerformat' +EXTERN char *p_plf; ///< 'packlockfile' EXTERN char *p_pp; ///< 'packpath' EXTERN char *p_qftf; ///< 'quickfixtextfunc' EXTERN char *p_rtp; ///< 'runtimepath' diff --git a/src/nvim/options.lua b/src/nvim/options.lua index ab6d99f1ce..903567d1f0 100644 --- a/src/nvim/options.lua +++ b/src/nvim/options.lua @@ -6418,6 +6418,22 @@ local options = { type = 'string', varname = 'p_opfunc', }, + { + abbreviation = 'plf', + defaults = '$XDG_CONFIG_HOME/nvim/nvim-pack-lock.json', + deny_duplicates = true, + desc = [=[ + Path of |vim.pack-lockfile|. Must be set before the first usage of any + |vim.pack| function. Environment variables are expanded |:set_env|. + ]=], + expand = true, + full_name = 'packlockfile', + scope = { 'global' }, + secure = true, + short_desc = N_('path of vim.pack lockfile'), + type = 'string', + varname = 'p_plf', + }, { abbreviation = 'pp', cb = 'did_set_runtimepackpath', diff --git a/test/functional/plugin/pack_spec.lua b/test/functional/plugin/pack_spec.lua index 24fd938970..cf3f365b92 100644 --- a/test/functional/plugin/pack_spec.lua +++ b/test/functional/plugin/pack_spec.lua @@ -385,6 +385,10 @@ local function get_lock_path() return vim.fs.joinpath(fn.stdpath('config'), 'nvim-pack-lock.json') end +local function get_other_lock_path() + return vim.fs.joinpath(fn.stdpath('state'), 'test-pack-lock') +end + --- @return {plugins:table} local function get_lock_tbl() return vim.json.decode(fn.readblob(get_lock_path())) @@ -407,6 +411,7 @@ describe('vim.pack', function() after_each(function() local pack_dir = pack_get_dir() local lock_path = get_lock_path() + local other_lock_path = get_other_lock_path() local log_path = vim.fs.joinpath(fn.stdpath('log'), 'nvim-pack.log') -- Wait for neovim to close before removing directories so it can release @@ -416,6 +421,7 @@ describe('vim.pack', function() n.rmdir(pack_dir) pcall(vim.fs.rm, lock_path, { force = true }) + pcall(vim.fs.rm, other_lock_path, { force = true }) pcall(vim.fs.rm, log_path, { force = true }) end) @@ -775,6 +781,29 @@ describe('vim.pack', function() assert() end) + it("respects 'packlockfile'", function() + local other_lock_path = get_other_lock_path() + api.nvim_set_option_value('packlockfile', other_lock_path, { scope = 'global' }) + 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, vim.json.decode(fn.readblob(other_lock_path))) + eq(false, vim.uv.fs_stat(get_lock_path()) ~= nil) + + n.clear() + api.nvim_set_option_value('packlockfile', other_lock_path, { scope = 'global' }) + exec_lua(function() + vim.pack.add({ { src = repos_src.basic, version = 'feat-branch' } }) + end) + ref_lockfile.plugins.basic.version = "'feat-branch'" + eq(ref_lockfile, vim.json.decode(fn.readblob(other_lock_path))) + end) + it('removes unrepairable corrupted data and plugins', function() vim_pack_add({ repos_src.basic, repos_src.defbranch, repos_src.semver, repos_src.helptags }) @@ -2039,6 +2068,37 @@ describe('vim.pack', function() pack_assert_content('fetch', 'return "fetch dev"') end) + it("respects 'packlockfile'", function() + n.rmdir(pack_get_dir()) + pcall(vim.fs.rm, get_lock_path(), { force = true }) + + n.clear() + local other_lock_path = get_other_lock_path() + api.nvim_set_option_value('packlockfile', other_lock_path, { scope = 'global' }) + + exec_lua(function() + vim.pack.add({ repos_src.basic }) + end) + + eq(true, vim.uv.fs_stat(other_lock_path) ~= nil) + eq(false, vim.uv.fs_stat(get_lock_path()) ~= nil) + + n.clear() + api.nvim_set_option_value('packlockfile', other_lock_path, { scope = 'global' }) + + exec_lua(function() + vim.pack.add({ { src = repos_src.basic, version = 'feat-branch' } }) + vim.pack.update({ 'basic' }, { force = true }) + end) + pack_assert_content('basic', 'return "basic feat-branch"') + local basic_lock = { + rev = git_get_hash('feat-branch', 'basic'), + src = repos_src.basic, + version = "'feat-branch'", + } + eq({ plugins = { basic = basic_lock } }, vim.json.decode(fn.readblob(other_lock_path))) + end) + it('validates input', function() local function assert(err_pat, input) local function update_input() @@ -2287,6 +2347,23 @@ describe('vim.pack', function() eq(2, exec_lua('return #vim.pack.get()')) eq(2, vim.tbl_count(get_lock_tbl().plugins)) end) + + it("respects 'packlockfile'", function() + n.rmdir(pack_get_dir()) + pcall(vim.fs.rm, get_lock_path(), { force = true }) + + n.clear() + local other_lock_path = get_other_lock_path() + api.nvim_set_option_value('packlockfile', other_lock_path, { scope = 'global' }) + + exec_lua(function() + vim.pack.add({ { src = repos_src.basic, version = 'feat-branch' } }) + end) + + local basic_data = make_basic_data(true, true) + eq({ basic_data }, exec_lua('return vim.pack.get()')) + eq(false, vim.uv.fs_stat(get_lock_path()) ~= nil) + end) end) describe('del()', function() @@ -2396,6 +2473,24 @@ describe('vim.pack', function() eq({ 'plugindirs' }, vim.tbl_keys(get_lock_tbl().plugins)) end) + it("respects 'packlockfile'", function() + n.rmdir(pack_get_dir()) + pcall(vim.fs.rm, get_lock_path(), { force = true }) + + n.clear() + local other_lock_path = vim.fs.joinpath(fn.stdpath('data'), 'test-pack-lock') + api.nvim_set_option_value('packlockfile', other_lock_path, { scope = 'global' }) + + exec_lua(function() + vim.pack.add({ repos_src.basic }) + end) + + exec_lua('vim.pack.del({ "basic" }, { force = true })') + eq({ plugins = {} }, vim.json.decode(fn.readblob(other_lock_path))) + eq(false, pack_exists('basic')) + eq(false, vim.uv.fs_stat(get_lock_path()) ~= nil) + end) + it('validates input', function() local function assert(err_pat, input) local function del_input()