feat(pack): 'packlockfile' option #40562

Problem: No way to configure the lockfile location.

Solution: Add 'packlockfile' option.
This commit is contained in:
Evgeni Chasnovski
2026-07-08 19:23:28 +03:00
committed by GitHub
parent ef130902cf
commit 0653e7a338
10 changed files with 160 additions and 21 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -224,15 +224,15 @@ semver convention `v<major>.<minor>.<patch>` (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*

View File

@@ -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`.

View File

@@ -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))

View File

@@ -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<string,table> }
for plug_name, lock_data in pairs(data.plugins) do
is_good = check_plugin_lock_data(plug_name, lock_data) and is_good

View File

@@ -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' },
},
}

View File

@@ -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'

View File

@@ -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',

View File

@@ -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<string, {rev:string, src:string, version?:string}>}
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()