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