From 7853cde29a08aa43bcaecc9848a8b43248bf8d66 Mon Sep 17 00:00:00 2001 From: Evgeni Chasnovski Date: Sun, 7 Sep 2025 19:59:31 +0300 Subject: feat(pack): vim.pack.get() gets VCS info #35631 Problem: Force resolve `spec.version` overrides the information about whether a user supplied `version` or not. Knowing it might be useful in some use cases (like comparing to previously set `spec` to detect if it has changed). Solution: Do not resolve `spec.version`. This also improves speed when triggering events and calling `get()`. - Place default branch first when listing all branches. - Use correct terminology in `get_hash` helper. - Do not return `{ '' }` if there are no tags. Problem: There is no way to get more information about installed plugins, like current revision or default branch (necessary if resolving default `spec.version` manually). As computing Git data migth take some time, also allow `get()` to limit output to only necessary set of plugins. Solution: - introduce arguments to `get(names, opts)`, which follows other `vim.pack` functions. Plugin extra info is returned by default and should be opt-out via `opts.info = false`. - Examples: - Get current revision: `get({ 'plug-name' })[1].rev` - Get default branch: `get({ 'plug_name' })[1].branches[1]` - `update()` and `del()` act on plugins in the same order their names are supplied. This is less surprising. - default `opts.info` to `true` since this simplifies logic for the common user, while still leaving the door open for a faster `get()` if needed. --- runtime/lua/vim/pack.lua | 127 +++++++++++++++++++++++++++++------------------ 1 file changed, 78 insertions(+), 49 deletions(-) (limited to 'runtime/lua/vim/pack.lua') diff --git a/runtime/lua/vim/pack.lua b/runtime/lua/vim/pack.lua index ae999e95e7..de3a64c325 100644 --- a/runtime/lua/vim/pack.lua +++ b/runtime/lua/vim/pack.lua @@ -68,9 +68,8 @@ ---you run |vim.pack.update()|. --- ---Freeze plugin from being updated: ----- Update 'init.lua' for plugin to have `version` set to current commit hash. ----You can get it by running `vim.pack.update({ 'plugin-name' })` and yanking ----the word describing current state (looks like `abc12345`). +---- 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`). ---- |:restart|. --- ---Unfreeze plugin to start receiving updates: @@ -148,13 +147,13 @@ local function git_clone(url, path) end --- @async ---- @param rev string +--- @param ref string --- @param cwd string --- @return string -local function git_get_hash(rev, cwd) - -- Using `rev-list -1` shows a commit of revision, while `rev-parse` shows - -- hash of revision. Those are different for annotated tags. - return git_cmd({ 'rev-list', '-1', '--abbrev-commit', rev }, cwd) +local function git_get_hash(ref, cwd) + -- Using `rev-list -1` shows a commit of reference, while `rev-parse` shows + -- hash of reference. Those are different for annotated tags. + return git_cmd({ 'rev-list', '-1', '--abbrev-commit', ref }, cwd) end --- @async @@ -169,11 +168,14 @@ end --- @param cwd string --- @return string[] local function git_get_branches(cwd) + local def_branch = git_get_default_branch(cwd) local cmd = { 'branch', '--remote', '--list', '--format=%(refname:short)', '--', 'origin/**' } local stdout = git_cmd(cmd, cwd) local res = {} --- @type string[] for l in vim.gsplit(stdout, '\n') do - res[#res + 1] = l:match('^origin/(.+)$') + local branch = l:match('^origin/(.+)$') + local pos = branch == def_branch and 1 or (#res + 1) + table.insert(res, pos, branch) end return res end @@ -182,8 +184,8 @@ end --- @param cwd string --- @return string[] local function git_get_tags(cwd) - local cmd = { 'tag', '--list', '--sort=-v:refname' } - return vim.split(git_cmd(cmd, cwd), '\n') + local tags = git_cmd({ 'tag', '--list', '--sort=-v:refname' }, cwd) + return tags == '' and {} or vim.split(tags, '\n') end -- Plugin operations ---------------------------------------------------------- @@ -323,34 +325,22 @@ local function normalize_plugs(plugs) return res end ---- @param names string[]? +--- @param names? string[] --- @return vim.pack.Plug[] local function plug_list_from_names(names) - local all_plugins = M.get() + local p_data_list = M.get(names, { info = false }) local plug_dir = get_plug_dir() local plugs = {} --- @type vim.pack.Plug[] - local used_names = {} --- @type table - -- Preserve plugin order; might be important during checkout or event trigger - for _, p_data in ipairs(all_plugins) do + 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): Consider changing this if/when there is lockfile. - --- @cast names string[] - if (not names and p_data.active) or vim.tbl_contains(names or {}, p_data.spec.name) then + -- 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) - used_names[p_data.spec.name] = true end end - if vim.islist(names) and #plugs ~= #names then - --- @param n string - local unused = vim.tbl_filter(function(n) - return not used_names[n] - end, names) - error('The following plugins are not installed: ' .. table.concat(unused, ', ')) - end - return plugs end @@ -358,13 +348,7 @@ end --- @param event_name 'PackChangedPre'|'PackChanged' --- @param kind 'install'|'update'|'delete' local function trigger_event(p, event_name, kind) - local spec = vim.deepcopy(p.spec) - -- Infer default branch for fuller `event-data` (if possible) - -- Doing it only on event trigger level allows keeping `spec` close to what - -- user supplied without performance issues during startup. - spec.version = spec.version or (uv.fs_stat(p.path) and git_get_default_branch(p.path)) - - local data = { kind = kind, spec = spec, path = p.path } + local data = { kind = kind, spec = vim.deepcopy(p.spec), path = p.path } api.nvim_exec_autocmds(event_name, { pattern = p.path, data = data }) end @@ -463,7 +447,7 @@ end --- @param p vim.pack.Plug local function resolve_version(p) local function list_in_line(name, list) - return #list == 0 and '' or ('\n' .. name .. ': ' .. table.concat(list, ', ')) + return ('\n%s: %s'):format(name, table.concat(list, ', ')) end -- Resolve only once @@ -987,13 +971,44 @@ end --- @inlinedoc --- @class vim.pack.PlugData ---- @field spec vim.pack.SpecResolved A |vim.pack.Spec| with defaults made explicit. ---- @field path string Plugin's path on disk. --- @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 spec vim.pack.SpecResolved A |vim.pack.Spec| with resolved `name`. +--- @field tags? string[] Available Git tags. Missing if `info=false`. + +--- @class vim.pack.keyset.get +--- @inlinedoc +--- @field info boolean Whether to include extra plugin info. Default `true`. ---- Get data about all plugins managed by |vim.pack| +--- @param p_data_list vim.pack.PlugData[] +local function add_p_data_info(p_data_list) + local funs = {} --- @type (async fun())[] + for i, p_data in ipairs(p_data_list) do + local path = p_data.path + --- @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 + --- @async + local function joined_f() + async.join(n_threads, funs) + end + async.run(joined_f):wait() +end + +--- Gets |vim.pack| plugin info, optionally filtered by `names`. +--- @param names? string[] List of plugin names. Default: all plugins managed by |vim.pack|. +--- @param opts? vim.pack.keyset.get --- @return vim.pack.PlugData[] -function M.get() +function M.get(names, opts) + vim.validate('names', names, vim.islist, true, 'list') + opts = vim.tbl_extend('force', { info = true }, opts or {}) + -- Process active plugins in order they were added. Take into account that -- there might be "holes" after `vim.pack.del()`. local active = {} --- @type table @@ -1001,11 +1016,12 @@ function M.get() active[p_active.id] = p_active.plug end - --- @type vim.pack.PlugData[] - local res = {} + local res = {} --- @type vim.pack.PlugData[] + local used_names = {} --- @type table for i = 1, n_active_plugins do - if active[i] then + 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 end end @@ -1015,20 +1031,33 @@ function M.get() 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) - if t == 'directory' and not active_plugins[path] then + 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 end + end + async.run(do_get):wait() - -- Make default `version` explicit - for _, p_data in ipairs(res) do - if not p_data.spec.version then - p_data.spec.version = git_get_default_branch(p_data.path) + if names ~= nil then + -- Align result with input + local names_order = {} --- @type table + for i, n in ipairs(names) do + if not used_names[n] then + error(('Plugin `%s` is not installed'):format(tostring(n))) end + names_order[n] = i end + table.sort(res, function(a, b) + return names_order[a.spec.name] < names_order[b.spec.name] + end) + end + + if opts.info then + add_p_data_info(res) end - async.run(do_get):wait() return res end -- cgit