neovim

Neovim text editor
git clone https://git.dasho.dev/neovim.git
Log | Files | Refs | README

pack.lua (51731B)


      1 --- @brief
      2 ---
      3 --- Install, update, and delete external plugins. WARNING: It is still considered
      4 --- experimental, yet should be stable enough for daily use.
      5 ---
      6 ---Manages plugins only in a dedicated [vim.pack-directory]() (see |packages|):
      7 ---`$XDG_DATA_HOME/nvim/site/pack/core/opt`. `$XDG_DATA_HOME/nvim/site` needs to
      8 ---be part of 'packpath'. It usually is, but might not be in cases like |--clean| or
      9 ---setting |$XDG_DATA_HOME| during startup.
     10 ---Plugin's subdirectory name matches plugin's name in specification.
     11 ---It is assumed that all plugins in the directory are managed exclusively by `vim.pack`.
     12 ---
     13 ---Uses Git to manage plugins and requires present `git` executable.
     14 ---Target plugins should be Git repositories with versions as named tags
     15 ---following semver convention `v<major>.<minor>.<patch>`.
     16 ---
     17 ---The latest state of all managed plugins is stored inside a [vim.pack-lockfile]()
     18 ---located at `$XDG_CONFIG_HOME/nvim/nvim-pack-lock.json`. It is a JSON file that
     19 ---is used to persistently track data about plugins.
     20 ---For a more robust config treat lockfile like its part: put under version control, etc.
     21 ---In this case all plugins from the lockfile will be installed at once and at lockfile's revision
     22 ---(instead of inferring from `version`). This is done on the very first `vim.pack` function call
     23 ---to ensure that lockfile is aligned with what is actually on the disk.
     24 ---Lockfile should not be edited by hand. Corrupted data for installed plugins is repaired
     25 ---(including after deleting whole file), but `version` fields will be missing
     26 ---for not yet added plugins.
     27 ---
     28 ---[vim.pack-examples]()
     29 ---
     30 ---Basic install and management ~
     31 ---
     32 ---- Add |vim.pack.add()| call(s) to 'init.lua':
     33 ---```lua
     34 ---
     35 ---vim.pack.add({
     36 ---   -- Install "plugin1" and use default branch (usually `main` or `master`)
     37 ---   'https://github.com/user/plugin1',
     38 ---
     39 ---   -- Same as above, but using a table (allows setting other options)
     40 ---   { src = 'https://github.com/user/plugin1' },
     41 ---
     42 ---   -- Specify plugin's name (here the plugin will be called "plugin2"
     43 ---   -- instead of "generic-name")
     44 ---   { src = 'https://github.com/user/generic-name', name = 'plugin2' },
     45 ---
     46 ---   -- Specify version to follow during install and update
     47 ---   {
     48 ---     src = 'https://github.com/user/plugin3',
     49 ---     -- Version constraint, see |vim.version.range()|
     50 ---     version = vim.version.range('1.0'),
     51 ---   },
     52 ---   {
     53 ---     src = 'https://github.com/user/plugin4',
     54 ---     -- Git branch, tag, or commit hash
     55 ---     version = 'main',
     56 ---   },
     57 ---})
     58 ---
     59 ----- Plugin's code can be used directly after `add()`
     60 ---plugin1 = require('plugin1')
     61 ---```
     62 ---
     63 ---- Restart Nvim (for example, with |:restart|). Plugins that were not yet
     64 ---installed will be available on disk after `add()` call. Their revision is
     65 ---taken from |vim.pack-lockfile| (if present) or inferred from the `version`.
     66 ---
     67 ---- To update all plugins with new changes:
     68 ---    - Execute |vim.pack.update()|. This will download updates from source and
     69 ---      show confirmation buffer in a separate tabpage.
     70 ---    - Review changes. To confirm all updates execute |:write|.
     71 ---      To discard updates execute |:quit|.
     72 ---    - (Optionally) |:restart| to start using code from updated plugins.
     73 ---
     74 ---Use shorter source ~
     75 ---
     76 --- Create custom Lua helpers:
     77 ---
     78 ---```lua
     79 ---
     80 ---local gh = function(x) return 'https://github.com/' .. x end
     81 ---local cb = function(x) return 'https://codeberg.org/' .. x end
     82 ---vim.pack.add({ gh('user/plugin1'), cb('user/plugin2') })
     83 ---```
     84 ---
     85 ---Another approach is to utilize Git's `insteadOf` configuration:
     86 ---- `git config --global url."https://github.com/".insteadOf "gh:"`
     87 ---- `git config --global url."https://codeberg.org/".insteadOf "cb:"`
     88 ---- In 'init.lua': `vim.pack.add({ 'gh:user/plugin1', 'cb:user/plugin2' })`.
     89 ---  These sources will be used verbatim in |vim.pack-lockfile|, so reusing
     90 ---  the config on different machine will require the same Git configuration.
     91 ---
     92 ---Explore installed plugins ~
     93 ---
     94 ---- `vim.pack.update(nil, { offline = true })`
     95 ---- Navigate between plugins with `[[` and `]]`. List them with `gO`
     96 ---  (|vim.lsp.buf.document_symbol()|).
     97 ---
     98 ---Switch plugin's version and/or source ~
     99 ---
    100 ---- Update 'init.lua' for plugin to have desired `version` and/or `src`.
    101 ---  Let's say, the switch is for plugin named 'plugin1'.
    102 ---- |:restart|. The plugin's state on disk (revision and/or tracked source)
    103 ---  is not yet changed. Only plugin's `version` in |vim.pack-lockfile| is updated.
    104 ---- Execute `vim.pack.update({ 'plugin1' })`. The plugin's source is updated.
    105 ---  If only switching version, use `{ offline = true }` option table.
    106 ---- Review changes and either confirm or discard them. If discarded, revert
    107 ---  `version` change in 'init.lua' as well or you will be prompted again next time
    108 ---  you run |vim.pack.update()|.
    109 ---
    110 ---Freeze plugin from being updated ~
    111 ---
    112 ---- Update 'init.lua' for plugin to have `version` set to current revision.
    113 ---Get it from |vim.pack-lockfile| (plugin's field `rev`; looks like `abc12345`).
    114 ---- |:restart|.
    115 ---
    116 ---Unfreeze plugin to start receiving updates ~
    117 ---
    118 ---- Update 'init.lua' for plugin to have `version` set to whichever version
    119 ---you want it to be updated.
    120 ---- |:restart|.
    121 ---
    122 ---Revert plugin after an update ~
    123 ---
    124 ---- Revert the |vim.pack-lockfile| to the state before the update:
    125 ---    - If Git tracked: `git checkout HEAD -- nvim-pack-lock.json`
    126 ---    - If not tracked: examine log file ("nvim-pack.log" at "log" |stdpath()|),
    127 ---      locate the revisions before the latest update, and (carefully) adjust
    128 ---      current lockfile to have those revisions.
    129 ---- |:restart|.
    130 ---- `vim.pack.update({ 'plugin' }, { offline = true, target = 'lockfile' })`.
    131 ---  Read and confirm.
    132 ---
    133 ---Synchronize config across machines ~
    134 ---
    135 ---- On main machine:
    136 ---     - Add |vim.pack-lockfile| to VCS.
    137 ---     - Push to the remote server.
    138 ---- On secondary machine:
    139 ---     - Pull from the server.
    140 ---     - |:restart|. New plugins (not present locally, but present in the lockfile)
    141 ---       are installed at proper revision.
    142 ---     - `vim.pack.update(nil, { target = 'lockfile' })`. Read and confirm.
    143 ---     - Manually delete outdated plugins (present locally, but were not present
    144 ---       in the lockfile prior to restart) with `vim.pack.del( { 'plugin' })`.
    145 ---       They can be located by examining the VCS difference of the lockfile
    146 ---       (`git diff -- nvim-pack-lock.json` for Git).
    147 ---
    148 ---Remove plugins from disk ~
    149 ---
    150 ---- Remove plugin specs from |vim.pack.add()| calls in 'init.lua' or they will be
    151 ---  reinstalled later.
    152 ---- |:restart|.
    153 ---- Use |vim.pack.del()| with a list of plugin names to remove. Use |vim.pack.get()|
    154 ---  to get all non-active plugins:
    155 ---```lua
    156 ---vim.iter(vim.pack.get())
    157 ---  :filter(function(x) return not x.active end)
    158 ---  :map(function(x) return x.spec.name end)
    159 ---  :totable()
    160 ---```
    161 ---
    162 ---[vim.pack-events]()
    163 ---
    164 ---Performing actions via `vim.pack` functions can trigger these events:
    165 ---- [PackChangedPre]() - before trying to change plugin's state.
    166 ---- [PackChanged]() - after plugin's state has changed.
    167 ---
    168 ---Each event populates the following |event-data| fields:
    169 ---- `active` - whether plugin was added via |vim.pack.add()| to current session.
    170 ---- `kind` - one of "install" (install on disk; before loading),
    171 ---  "update" (update already installed plugin; might be not loaded),
    172 ---  "delete" (delete from disk).
    173 ---- `spec` - plugin's specification with defaults made explicit.
    174 ---- `path` - full path to plugin's directory.
    175 ---
    176 --- These events can be used to execute plugin hooks. For example:
    177 ---```lua
    178 ---local hooks = function(ev)
    179 ---   -- Use available |event-data|
    180 ---   local name, kind = ev.data.spec.name, ev.data.kind
    181 ---
    182 ---   -- Run build script after plugin's code has changed
    183 ---   if name == 'plug-1' and (kind == 'install' or kind == 'update') then
    184 ---     -- Append `:wait()` if you need synchronous execution
    185 ---     vim.system({ 'make' }, { cwd = ev.data.path })
    186 ---   end
    187 ---
    188 ---   -- If action relies on code from the plugin (like user command or
    189 ---   -- Lua code), make sure to explicitly load it first
    190 ---   if name == 'plug-2' and kind == 'update' then
    191 ---     if not ev.data.active then
    192 ---       vim.cmd.packadd('plug-2')
    193 ---     end
    194 ---     vim.cmd('PlugTwoUpdate')
    195 ---     require('plug2').after_update()
    196 ---   end
    197 ---end
    198 ---
    199 ----- If hooks need to run on install, run this before `vim.pack.add()`
    200 ----- To act on install from lockfile, run before very first `vim.pack.add()`
    201 ---vim.api.nvim_create_autocmd('PackChanged', { callback = hooks })
    202 ---```
    203 
    204 local api = vim.api
    205 local uv = vim.uv
    206 local async = require('vim._async')
    207 
    208 local M = {}
    209 
    210 --- @class (private) vim.pack.LockData
    211 --- @field rev string Latest recorded revision.
    212 --- @field src string Plugin source.
    213 --- @field version? string|vim.VersionRange Plugin `version`, as supplied in `spec`.
    214 
    215 --- @class (private) vim.pack.Lock
    216 --- @field plugins table<string, vim.pack.LockData> Map from plugin name to its lock data.
    217 
    218 --- @type vim.pack.Lock
    219 local plugin_lock
    220 
    221 --- @return string
    222 local function get_plug_dir()
    223  return vim.fs.joinpath(vim.fn.stdpath('data'), 'site', 'pack', 'core', 'opt')
    224 end
    225 
    226 local function lock_get_path()
    227  return vim.fs.joinpath(vim.fn.stdpath('config'), 'nvim-pack-lock.json')
    228 end
    229 
    230 -- Git ------------------------------------------------------------------------
    231 
    232 --- @async
    233 --- @param cmd string[]
    234 --- @param cwd? string
    235 --- @return string
    236 local function git_cmd(cmd, cwd)
    237  -- Use '-c gc.auto=0' to disable `stderr` "Auto packing..." messages
    238  cmd = vim.list_extend({ 'git', '-c', 'gc.auto=0' }, cmd)
    239  local env = vim.fn.environ() --- @type table<string,string>
    240  env.GIT_DIR, env.GIT_WORK_TREE = nil, nil
    241  local sys_opts = { cwd = cwd, text = true, env = env, clear_env = true }
    242  local out = async.await(3, vim.system, cmd, sys_opts) --- @type vim.SystemCompleted
    243  async.await(1, vim.schedule)
    244  if out.code ~= 0 then
    245    error(out.stderr)
    246  end
    247  local stdout, stderr = assert(out.stdout), assert(out.stderr)
    248  if stderr ~= '' then
    249    vim.schedule(function()
    250      vim.notify(stderr:gsub('\n+$', ''), vim.log.levels.WARN)
    251    end)
    252  end
    253  return (stdout:gsub('\n+$', ''))
    254 end
    255 
    256 local git_version = vim.version.parse('1')
    257 
    258 local function git_ensure_exec()
    259  local ok, sys = pcall(vim.system, { 'git', 'version' })
    260  if not ok then
    261    error('No `git` executable')
    262  end
    263  git_version = vim.version.parse(sys:wait().stdout)
    264 end
    265 
    266 --- @async
    267 --- @param url string
    268 --- @param path string
    269 local function git_clone(url, path)
    270  local cmd = { 'clone', '--quiet', '--no-checkout' }
    271 
    272  if vim.startswith(url, 'file://') then
    273    cmd[#cmd + 1] = '--no-hardlinks'
    274  elseif git_version >= vim.version.parse('2.27') then
    275    cmd[#cmd + 1] = '--filter=blob:none'
    276  end
    277 
    278  vim.list_extend(cmd, { '--origin', 'origin', url, path })
    279  git_cmd(cmd, uv.cwd())
    280 end
    281 
    282 --- @async
    283 --- @param ref string
    284 --- @param cwd string
    285 --- @return string
    286 local function git_get_hash(ref, cwd)
    287  -- Using `rev-list -1` shows a commit of reference, while `rev-parse` shows
    288  -- hash of reference. Those are different for annotated tags.
    289  return git_cmd({ 'rev-list', '-1', ref }, cwd)
    290 end
    291 
    292 --- @async
    293 --- @param cwd string
    294 --- @return string
    295 local function git_get_default_branch(cwd)
    296  local res = git_cmd({ 'rev-parse', '--abbrev-ref', 'origin/HEAD' }, cwd)
    297  return (res:gsub('^origin/', ''))
    298 end
    299 
    300 --- @async
    301 --- @param cwd string
    302 --- @return string[]
    303 local function git_get_branches(cwd)
    304  local def_branch = git_get_default_branch(cwd)
    305  local cmd = { 'branch', '--remote', '--list', '--format=%(refname:short)', '--', 'origin/**' }
    306  local stdout = git_cmd(cmd, cwd)
    307  local res = {} --- @type string[]
    308  for l in vim.gsplit(stdout, '\n') do
    309    local branch = l:match('^origin/(.+)$')
    310    local pos = branch == def_branch and 1 or (#res + 1)
    311    table.insert(res, pos, branch)
    312  end
    313  return res
    314 end
    315 
    316 --- @async
    317 --- @param cwd string
    318 --- @return string[]
    319 local function git_get_tags(cwd)
    320  local tags = git_cmd({ 'tag', '--list', '--sort=-v:refname' }, cwd)
    321  return tags == '' and {} or vim.split(tags, '\n')
    322 end
    323 
    324 -- Plugin operations ----------------------------------------------------------
    325 
    326 --- @param msg string|string[]
    327 --- @param level ('DEBUG'|'TRACE'|'INFO'|'WARN'|'ERROR')?
    328 local function notify(msg, level)
    329  msg = type(msg) == 'table' and table.concat(msg, '\n') or msg
    330  vim.notify('vim.pack: ' .. msg, vim.log.levels[level or 'INFO'])
    331  vim.cmd.redraw()
    332 end
    333 
    334 --- @param x string|vim.VersionRange
    335 --- @return boolean
    336 local function is_version(x)
    337  return type(x) == 'string' or (type(x) == 'table' and pcall(x.has, x, '1'))
    338 end
    339 
    340 --- @param x string
    341 --- @return boolean
    342 local function is_semver(x)
    343  return vim.version.parse(x) ~= nil
    344 end
    345 
    346 local function is_nonempty_string(x)
    347  return type(x) == 'string' and x ~= ''
    348 end
    349 
    350 --- @return string
    351 local function get_timestamp()
    352  return vim.fn.strftime('%Y-%m-%d %H:%M:%S')
    353 end
    354 
    355 --- @class vim.pack.Spec
    356 ---
    357 --- URI from which to install and pull updates. Any format supported by `git clone` is allowed.
    358 --- @field src string
    359 ---
    360 --- Name of plugin. Will be used as directory name. Default: `src` repository name.
    361 --- @field name? string
    362 ---
    363 --- Version to use for install and updates. Can be:
    364 --- - `nil` (no value, default) to use repository's default branch (usually `main` or `master`).
    365 --- - String to use specific branch, tag, or commit hash.
    366 --- - Output of |vim.version.range()| to install the greatest/last semver tag
    367 ---   inside the version constraint.
    368 --- @field version? string|vim.VersionRange
    369 ---
    370 --- @field data? any Arbitrary data associated with a plugin.
    371 
    372 --- @alias vim.pack.SpecResolved { src: string, name: string, version: nil|string|vim.VersionRange, data: any|nil }
    373 
    374 --- @param spec string|vim.pack.Spec
    375 --- @return vim.pack.SpecResolved
    376 local function normalize_spec(spec)
    377  spec = type(spec) == 'string' and { src = spec } or spec
    378  vim.validate('spec', spec, 'table')
    379  vim.validate('spec.src', spec.src, is_nonempty_string, false, 'non-empty string')
    380  local name = spec.name or spec.src:gsub('%.git$', '')
    381  name = (type(name) == 'string' and name or ''):match('[^/]+$') or ''
    382  vim.validate('spec.name', name, is_nonempty_string, true, 'non-empty string')
    383  vim.validate('spec.version', spec.version, is_version, true, 'string or vim.VersionRange')
    384  return { src = spec.src, name = name, version = spec.version, data = spec.data }
    385 end
    386 
    387 --- @class (private) vim.pack.PlugInfo
    388 --- @field err string The latest error when working on plugin. If non-empty,
    389 ---   all further actions should not be done (including triggering events).
    390 --- @field installed? boolean Whether plugin was successfully installed.
    391 --- @field version_str? string `spec.version` with resolved version range.
    392 --- @field version_ref? string Resolved version as Git reference (if different
    393 ---   from `version_str`).
    394 --- @field sha_head? string Git hash of HEAD.
    395 --- @field sha_target? string Git hash of `version_ref`.
    396 --- @field update_details? string Details about the update:: changelog if HEAD
    397 ---   and target are different, available newer tags otherwise.
    398 
    399 --- @class (private) vim.pack.Plug
    400 --- @field spec vim.pack.SpecResolved
    401 --- @field path string
    402 --- @field info vim.pack.PlugInfo Gathered information about plugin.
    403 
    404 --- @param spec string|vim.pack.Spec
    405 --- @param plug_dir string?
    406 --- @return vim.pack.Plug
    407 local function new_plug(spec, plug_dir)
    408  local spec_resolved = normalize_spec(spec)
    409  local path = vim.fs.joinpath(plug_dir or get_plug_dir(), spec_resolved.name)
    410  local info = { err = '', installed = plugin_lock.plugins[spec_resolved.name] ~= nil }
    411  return { spec = spec_resolved, path = path, info = info }
    412 end
    413 
    414 --- Normalize plug array: gather non-conflicting data from duplicated entries.
    415 --- @param plugs vim.pack.Plug[]
    416 --- @return vim.pack.Plug[]
    417 local function normalize_plugs(plugs)
    418  --- @type table<string, { plug: vim.pack.Plug, id: integer }>
    419  local plug_map = {}
    420  local n = 0
    421  for _, p in ipairs(plugs) do
    422    -- Collect
    423    if not plug_map[p.path] then
    424      n = n + 1
    425      plug_map[p.path] = { plug = p, id = n }
    426    end
    427    local p_data = plug_map[p.path]
    428    -- TODO(echasnovski): if both versions are `vim.VersionRange`, collect as
    429    -- their intersection. Needs `vim.version.intersect`.
    430    p_data.plug.spec.version = vim.F.if_nil(p_data.plug.spec.version, p.spec.version)
    431 
    432    -- Ensure no conflicts
    433    local spec_ref = p_data.plug.spec
    434    local spec = p.spec
    435    if spec_ref.src ~= spec.src then
    436      local src_1 = tostring(spec_ref.src)
    437      local src_2 = tostring(spec.src)
    438      error(('Conflicting `src` for `%s`:\n%s\n%s'):format(spec.name, src_1, src_2))
    439    end
    440    if spec_ref.version ~= spec.version then
    441      local ver_1 = tostring(spec_ref.version)
    442      local ver_2 = tostring(spec.version)
    443      error(('Conflicting `version` for `%s`:\n%s\n%s'):format(spec.name, ver_1, ver_2))
    444    end
    445  end
    446 
    447  --- @type vim.pack.Plug[]
    448  local res = {}
    449  for _, p_data in pairs(plug_map) do
    450    res[p_data.id] = p_data.plug
    451  end
    452  assert(#res == n)
    453  return res
    454 end
    455 
    456 --- @param names? string[]
    457 --- @return vim.pack.Plug[]
    458 local function plug_list_from_names(names)
    459  local p_data_list = M.get(names, { info = false })
    460  local plug_dir = get_plug_dir()
    461  local plugs = {} --- @type vim.pack.Plug[]
    462  for _, p_data in ipairs(p_data_list) do
    463    plugs[#plugs + 1] = new_plug(p_data.spec, plug_dir)
    464  end
    465  return plugs
    466 end
    467 
    468 --- Map from plugin path to its data.
    469 --- Use map and not array to avoid linear lookup during startup.
    470 --- @type table<string, { plug: vim.pack.Plug, id: integer }?>
    471 local active_plugins = {}
    472 local n_active_plugins = 0
    473 
    474 --- @param p vim.pack.Plug
    475 --- @param event_name 'PackChangedPre'|'PackChanged'
    476 --- @param kind 'install'|'update'|'delete'
    477 local function trigger_event(p, event_name, kind)
    478  local active = active_plugins[p.path] ~= nil
    479  local data = { active = active, kind = kind, spec = vim.deepcopy(p.spec), path = p.path }
    480  api.nvim_exec_autocmds(event_name, { pattern = p.path, data = data })
    481 end
    482 
    483 --- @param action string
    484 --- @return fun(kind: 'begin'|'report'|'end', percent: integer, fmt: string, ...:any): nil
    485 local function new_progress_report(action)
    486  local progress = { kind = 'progress', title = 'vim.pack' }
    487  local headless = #api.nvim_list_uis() == 0
    488 
    489  return vim.schedule_wrap(function(kind, percent, fmt, ...)
    490    progress.status = kind == 'end' and 'success' or 'running'
    491    progress.percent = percent
    492    local msg = ('%s %s'):format(action, fmt:format(...))
    493    progress.id = api.nvim_echo({ { msg } }, kind ~= 'report', progress)
    494    -- Force redraw to show installation progress during startup
    495    -- TODO: redraw! not needed with ui2.
    496    if not headless then
    497      vim.cmd.redraw({ bang = true })
    498    end
    499  end)
    500 end
    501 
    502 local n_threads = 2 * #(uv.cpu_info() or { {} })
    503 local copcall = package.loaded.jit and pcall or require('coxpcall').pcall
    504 
    505 --- Execute function in parallel for each non-errored plugin in the list
    506 --- @param plug_list vim.pack.Plug[]
    507 --- @param f async fun(p: vim.pack.Plug)
    508 --- @param progress_action string
    509 local function run_list(plug_list, f, progress_action)
    510  local report_progress = new_progress_report(progress_action)
    511 
    512  -- Construct array of functions to execute in parallel
    513  local n_finished = 0
    514  local funs = {} --- @type (async fun())[]
    515  for _, p in ipairs(plug_list) do
    516    -- Run only for plugins which didn't error before
    517    if p.info.err == '' then
    518      --- @async
    519      funs[#funs + 1] = function()
    520        local ok, err = copcall(f, p) --[[@as string]]
    521        if not ok then
    522          p.info.err = err --- @as string
    523        end
    524 
    525        -- Show progress
    526        n_finished = n_finished + 1
    527        local percent = math.floor(100 * n_finished / #funs)
    528        report_progress('report', percent, '(%d/%d) - %s', n_finished, #funs, p.spec.name)
    529      end
    530    end
    531  end
    532 
    533  if #funs == 0 then
    534    return
    535  end
    536 
    537  -- Run async in parallel but wait for all to finish/timeout
    538  report_progress('begin', 0, '(0/%d)', #funs)
    539 
    540  --- @async
    541  local function joined_f()
    542    async.join(n_threads, funs)
    543  end
    544  async.run(joined_f):wait()
    545 
    546  report_progress('end', 100, '(%d/%d)', #funs, #funs)
    547 end
    548 
    549 local confirm_all = false
    550 
    551 --- @param plug_list vim.pack.Plug[]
    552 --- @return boolean
    553 local function confirm_install(plug_list)
    554  if confirm_all then
    555    return true
    556  end
    557 
    558  -- Gather pretty aligned list of plugins to install
    559  local name_width, name_max_width = {}, 0 --- @type integer[], integer
    560  for i, p in ipairs(plug_list) do
    561    name_width[i] = api.nvim_strwidth(p.spec.name)
    562    name_max_width = math.max(name_max_width, name_width[i])
    563  end
    564  local lines = {} --- @type string[]
    565  for i, p in ipairs(plug_list) do
    566    local pad = (' '):rep(name_max_width - name_width[i] + 1)
    567    lines[i] = ('%s%sfrom %s'):format(p.spec.name, pad, p.spec.src)
    568  end
    569 
    570  local text = table.concat(lines, '\n')
    571  local confirm_msg = ('These plugins will be installed:\n\n%s\n'):format(text)
    572  local choice = vim.fn.confirm(confirm_msg, 'Proceed? &Yes\n&No\n&Always', 1, 'Question')
    573  confirm_all = choice == 3
    574  vim.cmd.redraw()
    575  return choice ~= 2
    576 end
    577 
    578 --- @param tags string[]
    579 --- @param version_range vim.VersionRange
    580 local function get_last_semver_tag(tags, version_range)
    581  local last_tag, last_ver_tag --- @type string, vim.Version
    582  for _, tag in ipairs(tags) do
    583    local ver_tag = vim.version.parse(tag)
    584    if ver_tag then
    585      if version_range:has(ver_tag) and (not last_ver_tag or ver_tag > last_ver_tag) then
    586        last_tag, last_ver_tag = tag, ver_tag
    587      end
    588    end
    589  end
    590  return last_tag
    591 end
    592 
    593 --- @async
    594 --- @param p vim.pack.Plug
    595 local function resolve_version(p)
    596  local function list_in_line(name, list)
    597    return ('\n%s: %s'):format(name, table.concat(list, ', '))
    598  end
    599 
    600  -- Resolve only once
    601  if p.info.version_str then
    602    return
    603  end
    604  local version = p.spec.version
    605 
    606  -- Default branch
    607  if not version then
    608    p.info.version_str = git_get_default_branch(p.path)
    609    p.info.version_ref = 'origin/' .. p.info.version_str
    610    return
    611  end
    612 
    613  -- Non-version-range like version: branch, tag, or commit hash
    614  local branches = git_get_branches(p.path)
    615  local tags = git_get_tags(p.path)
    616  if type(version) == 'string' then
    617    local is_branch = vim.tbl_contains(branches, version)
    618    local is_tag_or_hash = copcall(git_get_hash, version, p.path)
    619    if not (is_branch or is_tag_or_hash) then
    620      local err = ('`%s` is not a branch/tag/commit. Available:'):format(version)
    621        .. list_in_line('Tags', tags)
    622        .. list_in_line('Branches', branches)
    623      error(err)
    624    end
    625 
    626    p.info.version_str = version
    627    p.info.version_ref = (is_branch and 'origin/' or '') .. version
    628    return
    629  end
    630  --- @cast version vim.VersionRange
    631 
    632  -- Choose the greatest/last version among all matching semver tags
    633  p.info.version_str = get_last_semver_tag(tags, version)
    634  if p.info.version_str == nil then
    635    local semver_tags = vim.tbl_filter(is_semver, tags)
    636    table.sort(semver_tags, vim.version.gt)
    637    local err = 'No versions fit constraint. Relax it or switch to branch. Available:'
    638      .. list_in_line('Versions', semver_tags)
    639      .. list_in_line('Branches', branches)
    640    error(err)
    641  end
    642 end
    643 
    644 --- @async
    645 --- @param p vim.pack.Plug
    646 local function infer_revisions(p)
    647  p.info.sha_head = p.info.sha_head or git_get_hash('HEAD', p.path)
    648 
    649  resolve_version(p)
    650  local target_ref = p.info.version_ref or p.info.version_str --[[@as string]]
    651  p.info.sha_target = p.info.sha_target or git_get_hash(target_ref, p.path)
    652 end
    653 
    654 --- Keep repos in detached HEAD state. Infer commit from resolved version.
    655 --- No local branches are created, branches from "origin" remote are used directly.
    656 --- @async
    657 --- @param p vim.pack.Plug
    658 --- @param timestamp string
    659 --- @param skip_stash? boolean
    660 local function checkout(p, timestamp, skip_stash)
    661  infer_revisions(p)
    662 
    663  if not skip_stash then
    664    local stash_cmd = { 'stash', '--quiet' }
    665    if git_version > vim.version.parse('2.13') then
    666      stash_cmd[#stash_cmd + 1] = '--message'
    667      stash_cmd[#stash_cmd + 1] = ('vim.pack: %s Stash before checkout'):format(timestamp)
    668    end
    669    git_cmd(stash_cmd, p.path)
    670  end
    671 
    672  git_cmd({ 'checkout', '--quiet', p.info.sha_target }, p.path)
    673 
    674  local submodule_cmd = { 'submodule', 'update', '--init', '--recursive' }
    675  if git_version >= vim.version.parse('2.36') then
    676    submodule_cmd[#submodule_cmd + 1] = '--filter=blob:none'
    677  end
    678  git_cmd(submodule_cmd, p.path)
    679 
    680  plugin_lock.plugins[p.spec.name].rev = p.info.sha_target
    681 
    682  -- (Re)Generate help tags according to the current help files.
    683  -- Also use `pcall()` because `:helptags` errors if there is no 'doc/'
    684  -- directory or if it is empty.
    685  local doc_dir = vim.fs.joinpath(p.path, 'doc')
    686  vim.fn.delete(vim.fs.joinpath(doc_dir, 'tags'))
    687  copcall(vim.cmd.helptags, { doc_dir, magic = { file = false } })
    688 end
    689 
    690 --- @param plug_list vim.pack.Plug[]
    691 local function install_list(plug_list, confirm)
    692  local timestamp = get_timestamp()
    693  --- @async
    694  --- @param p vim.pack.Plug
    695  local function do_install(p)
    696    trigger_event(p, 'PackChangedPre', 'install')
    697 
    698    git_clone(p.spec.src, p.path)
    699 
    700    plugin_lock.plugins[p.spec.name].src = p.spec.src
    701 
    702    -- Prefer revision from the lockfile instead of using `version`
    703    p.info.sha_target = (plugin_lock.plugins[p.spec.name] or {}).rev
    704 
    705    checkout(p, timestamp, true)
    706    p.info.installed = true
    707 
    708    trigger_event(p, 'PackChanged', 'install')
    709  end
    710 
    711  -- Install possibly after user confirmation
    712  if not confirm or confirm_install(plug_list) then
    713    run_list(plug_list, do_install, 'Installing plugins')
    714  end
    715 
    716  -- Ensure that not fully installed plugins are absent on disk and in lockfile
    717  for _, p in ipairs(plug_list) do
    718    if not (p.info.installed and uv.fs_stat(p.path) ~= nil) then
    719      plugin_lock.plugins[p.spec.name] = nil
    720      vim.fs.rm(p.path, { recursive = true, force = true })
    721    end
    722  end
    723 end
    724 
    725 --- @async
    726 --- @param p vim.pack.Plug
    727 local function infer_update_details(p)
    728  p.info.update_details = ''
    729  infer_revisions(p)
    730  local sha_head = assert(p.info.sha_head)
    731  local sha_target = assert(p.info.sha_target)
    732 
    733  -- Try showing log of changes (if any)
    734  if sha_head ~= sha_target then
    735    local range = sha_head .. '...' .. sha_target
    736    local format = '--pretty=format:%m %h │ %s%d'
    737    -- Show only tags near commits (not `origin/main`, etc.)
    738    local decorate = '--decorate-refs=refs/tags'
    739    -- `--topo-order` makes showing divergent branches nicer, but by itself
    740    -- doesn't ensure that reverted ("left", shown with `<`) and added
    741    -- ("right", shown with `>`) commits have fixed order.
    742    local l = git_cmd({ 'log', format, '--topo-order', '--left-only', decorate, range }, p.path)
    743    local r = git_cmd({ 'log', format, '--topo-order', '--right-only', decorate, range }, p.path)
    744    p.info.update_details = l == '' and r or (r == '' and l or (l .. '\n' .. r))
    745    return
    746  end
    747 
    748  -- Suggest newer semver tags (i.e. greater than greatest past semver tag)
    749  local all_semver_tags = vim.tbl_filter(is_semver, git_get_tags(p.path))
    750  if #all_semver_tags == 0 then
    751    return
    752  end
    753 
    754  local older_tags = ''
    755  if git_version >= vim.version.parse('2.13') then
    756    older_tags = git_cmd({ 'tag', '--list', '--no-contains', sha_head }, p.path)
    757  end
    758  local cur_tags = git_cmd({ 'tag', '--list', '--points-at', sha_head }, p.path)
    759  local past_tags = vim.split(older_tags, '\n')
    760  vim.list_extend(past_tags, vim.split(cur_tags, '\n'))
    761 
    762  local any_version = vim.version.range('*') --[[@as vim.VersionRange]]
    763  local last_version = get_last_semver_tag(past_tags, any_version)
    764 
    765  local newer_semver_tags = vim.tbl_filter(function(x) --- @param x string
    766    return vim.version.gt(x, last_version)
    767  end, all_semver_tags)
    768 
    769  table.sort(newer_semver_tags, vim.version.gt)
    770  p.info.update_details = table.concat(newer_semver_tags, '\n')
    771 end
    772 
    773 --- @param plug vim.pack.Plug
    774 --- @param load boolean|fun(plug_data: {spec: vim.pack.Spec, path: string})
    775 local function pack_add(plug, load)
    776  -- Add plugin only once, i.e. no overriding of spec. This allows users to put
    777  -- plugin first to fully control its spec.
    778  if active_plugins[plug.path] then
    779    return
    780  end
    781 
    782  n_active_plugins = n_active_plugins + 1
    783  active_plugins[plug.path] = { plug = plug, id = n_active_plugins }
    784 
    785  if vim.is_callable(load) then
    786    load({ spec = vim.deepcopy(plug.spec), path = plug.path })
    787    return
    788  end
    789 
    790  -- NOTE: The `:packadd` specifically seems to not handle spaces in dir name
    791  vim.cmd.packadd({ vim.fn.escape(plug.spec.name, ' '), bang = not load, magic = { file = false } })
    792 
    793  -- The `:packadd` only sources plain 'plugin/' files. Execute 'after/' scripts
    794  -- if not during startup (when they will be sourced later, even if
    795  -- `vim.pack.add` is inside user's 'plugin/')
    796  -- See https://github.com/vim/vim/issues/15584
    797  -- Deliberately do so after executing all currently known 'plugin/' files.
    798  if vim.v.vim_did_enter == 1 and load then
    799    local after_paths = vim.fn.glob(plug.path .. '/after/plugin/**/*.{vim,lua}', false, true)
    800    --- @param path string
    801    vim.tbl_map(function(path)
    802      vim.cmd.source({ path, magic = { file = false } })
    803    end, after_paths)
    804  end
    805 end
    806 
    807 local function lock_write()
    808  -- Serialize `version`
    809  local lock = vim.deepcopy(plugin_lock)
    810  for _, l_data in pairs(lock.plugins) do
    811    local version = l_data.version
    812    if version then
    813      l_data.version = type(version) == 'string' and ("'%s'"):format(version) or tostring(version)
    814    end
    815  end
    816 
    817  local path = lock_get_path()
    818  vim.fn.mkdir(vim.fs.dirname(path), 'p')
    819  local fd = assert(uv.fs_open(path, 'w', 438))
    820 
    821  local data = vim.json.encode(lock, { indent = '  ', sort_keys = true })
    822  assert(uv.fs_write(fd, data))
    823  assert(uv.fs_close(fd))
    824 end
    825 
    826 --- @param names string[]
    827 local function lock_repair(names, plug_dir)
    828  --- @async
    829  local function f()
    830    for _, name in ipairs(names) do
    831      local path = vim.fs.joinpath(plug_dir, name)
    832      -- Try reusing existing table to preserve maybe present `version`
    833      local data = plugin_lock.plugins[name] or {}
    834      data.rev = git_get_hash('HEAD', path)
    835      data.src = git_cmd({ 'remote', 'get-url', 'origin' }, path)
    836      plugin_lock.plugins[name] = data
    837    end
    838  end
    839  async.run(f):wait()
    840 end
    841 
    842 --- Sync lockfile data and installed plugins:
    843 --- - Install plugins that have proper lockfile data but are not on disk.
    844 --- - Repair corrupted lock data for installed plugins.
    845 --- - Remove unrepairable corrupted lock data and plugins.
    846 local function lock_sync(confirm)
    847  if type(plugin_lock.plugins) ~= 'table' then
    848    plugin_lock.plugins = {}
    849  end
    850 
    851  -- Compute installed plugins
    852  local plug_dir = get_plug_dir()
    853  if vim.uv.fs_stat(plug_dir) == nil then
    854    vim.fn.mkdir(plug_dir, 'p')
    855  end
    856 
    857  -- NOTE: The directory traversal is done on every startup, but it is very fast.
    858  -- Also, single `vim.fs.dir()` scales better than on demand `uv.fs_stat()` checks.
    859  local installed = {} --- @type table<string,string>
    860  for name, fs_type in vim.fs.dir(plug_dir) do
    861    installed[name] = fs_type
    862    plugin_lock.plugins[name] = plugin_lock.plugins[name] or {}
    863  end
    864 
    865  -- Traverse once optimizing for "regular startup" (no repair, no install)
    866  local to_install = {} --- @type vim.pack.Plug[]
    867  local to_repair = {} --- @type string[]
    868  local to_remove = {} --- @type string[]
    869  for name, data in pairs(plugin_lock.plugins) do
    870    if type(data) ~= 'table' then
    871      data = {} ---@diagnostic disable-line: missing-fields
    872      plugin_lock.plugins[name] = data
    873    end
    874 
    875    -- Deserialize `version`
    876    local version = data.version
    877    if type(version) == 'string' then
    878      data.version = version:match("^'(.+)'$") or vim.version.range(version)
    879    end
    880 
    881    -- Synchronize
    882    local is_bad_lock = type(data.rev) ~= 'string' or type(data.src) ~= 'string'
    883    local is_bad_plugin = installed[name] and installed[name] ~= 'directory'
    884    if is_bad_lock or is_bad_plugin then
    885      local t = installed[name] == 'directory' and to_repair or to_remove
    886      t[#t + 1] = name
    887    elseif not installed[name] then
    888      local spec = { src = data.src, name = name, version = data.version }
    889      to_install[#to_install + 1] = new_plug(spec, plug_dir)
    890    end
    891  end
    892 
    893  -- Perform actions if needed
    894  if #to_install > 0 then
    895    table.sort(to_install, function(a, b)
    896      return a.spec.name < b.spec.name
    897    end)
    898    install_list(to_install, confirm)
    899    lock_write()
    900  end
    901 
    902  if #to_repair > 0 then
    903    lock_repair(to_repair, plug_dir)
    904    table.sort(to_repair)
    905    notify('Repaired corrupted lock data for plugins: ' .. table.concat(to_repair, ', '), 'WARN')
    906    lock_write()
    907  end
    908 
    909  if #to_remove > 0 then
    910    for _, name in ipairs(to_remove) do
    911      plugin_lock.plugins[name] = nil
    912      vim.fs.rm(vim.fs.joinpath(plug_dir, name), { recursive = true, force = true })
    913    end
    914    table.sort(to_remove)
    915    notify('Removed corrupted lock data for plugins: ' .. table.concat(to_remove, ', '), 'WARN')
    916    lock_write()
    917  end
    918 end
    919 
    920 local function lock_read(confirm)
    921  if plugin_lock then
    922    return
    923  end
    924 
    925  local fd = uv.fs_open(lock_get_path(), 'r', 438)
    926  if fd then
    927    local stat = assert(uv.fs_fstat(fd))
    928    local data = assert(uv.fs_read(fd, stat.size, 0))
    929    assert(uv.fs_close(fd))
    930    plugin_lock = vim.json.decode(data)
    931  else
    932    plugin_lock = { plugins = {} }
    933  end
    934 
    935  lock_sync(vim.F.if_nil(confirm, true))
    936 end
    937 
    938 --- @class vim.pack.keyset.add
    939 --- @inlinedoc
    940 --- Load `plugin/` files and `ftdetect/` scripts. If `false`, works like `:packadd!`.
    941 --- If function, called with plugin data and is fully responsible for loading plugin.
    942 --- Default `false` during |init.lua| sourcing and `true` afterwards.
    943 --- @field load? boolean|fun(plug_data: {spec: vim.pack.Spec, path: string})
    944 ---
    945 --- @field confirm? boolean Whether to ask user to confirm initial install. Default `true`.
    946 
    947 --- Add plugin to current session
    948 ---
    949 --- - For each specification check that plugin exists on disk in |vim.pack-directory|:
    950 ---     - If exists, check if its `src` is the same as input. If not - delete
    951 ---       immediately to clean install from the new source. Otherwise do nothing.
    952 ---     - If doesn't exist, install it by downloading from `src` into `name`
    953 ---       subdirectory (via partial blobless `git clone`) and update revision
    954 ---       to match `version` (via `git checkout`). Plugin will not be on disk if
    955 ---       any step resulted in an error.
    956 --- - For each plugin execute |:packadd| (or customizable `load` function) making
    957 ---   it reachable by Nvim.
    958 ---
    959 --- Notes:
    960 --- - Installation is done in parallel, but waits for all to finish before
    961 ---   continuing next code execution.
    962 --- - If plugin is already present on disk, there are no checks about its current revision.
    963 ---   The specified `version` can be not the one actually present on disk.
    964 ---   Execute |vim.pack.update()| to synchronize.
    965 --- - Adding plugin second and more times during single session does nothing:
    966 ---   only the data from the first adding is registered.
    967 ---
    968 --- @param specs (string|vim.pack.Spec)[] List of plugin specifications. String item
    969 --- is treated as `src`.
    970 --- @param opts? vim.pack.keyset.add
    971 function M.add(specs, opts)
    972  vim.validate('specs', specs, vim.islist, false, 'list')
    973  opts = vim.tbl_extend('force', { load = vim.v.vim_did_init == 1, confirm = true }, opts or {})
    974  vim.validate('opts', opts, 'table')
    975 
    976  lock_read(opts.confirm)
    977 
    978  local plug_dir = get_plug_dir()
    979  local plugs = {} --- @type vim.pack.Plug[]
    980  for i = 1, #specs do
    981    plugs[i] = new_plug(specs[i], plug_dir)
    982  end
    983  plugs = normalize_plugs(plugs)
    984 
    985  -- Pre-process
    986  local plugs_to_install = {} --- @type vim.pack.Plug[]
    987  local needs_lock_write = false
    988  for _, p in ipairs(plugs) do
    989    -- Detect `version` change
    990    local p_lock = plugin_lock.plugins[p.spec.name] or {}
    991    needs_lock_write = needs_lock_write or p_lock.version ~= p.spec.version
    992    p_lock.version = p.spec.version
    993    plugin_lock.plugins[p.spec.name] = p_lock
    994 
    995    -- Register for install
    996    if not p.info.installed then
    997      plugs_to_install[#plugs_to_install + 1] = p
    998      needs_lock_write = true
    999    end
   1000  end
   1001 
   1002  -- Install
   1003  if #plugs_to_install > 0 then
   1004    git_ensure_exec()
   1005    install_list(plugs_to_install, opts.confirm)
   1006  end
   1007 
   1008  if needs_lock_write then
   1009    lock_write()
   1010  end
   1011 
   1012  -- Register and load those actually on disk while collecting errors
   1013  -- Delay showing all errors to have "good" plugins added first
   1014  local errors = {} --- @type string[]
   1015  for _, p in ipairs(plugs) do
   1016    if p.info.installed then
   1017      local ok, err = pcall(pack_add, p, opts.load) --[[@as string]]
   1018      if not ok then
   1019        p.info.err = err
   1020      end
   1021    end
   1022    if p.info.err ~= '' then
   1023      errors[#errors + 1] = ('`%s`:\n%s'):format(p.spec.name, p.info.err)
   1024    end
   1025  end
   1026 
   1027  if #errors > 0 then
   1028    local error_str = table.concat(errors, '\n\n')
   1029    error(('vim.pack:\n\n%s'):format(error_str))
   1030  end
   1031 end
   1032 
   1033 --- @param p vim.pack.Plug
   1034 --- @return string
   1035 local function compute_feedback_lines_single(p)
   1036  local active_suffix = active_plugins[p.path] ~= nil and '' or ' (not active)'
   1037  if p.info.err ~= '' then
   1038    return ('## %s%s\n\n %s'):format(p.spec.name, active_suffix, p.info.err:gsub('\n', '\n  '))
   1039  end
   1040 
   1041  local parts = { ('## %s%s\n'):format(p.spec.name, active_suffix) }
   1042  local version_suffix = p.info.version_str == '' and '' or (' (%s)'):format(p.info.version_str)
   1043 
   1044  if p.info.sha_head == p.info.sha_target then
   1045    parts[#parts + 1] = table.concat({
   1046      'Path:     ' .. p.path,
   1047      'Source:   ' .. p.spec.src,
   1048      'Revision: ' .. p.info.sha_target .. version_suffix,
   1049    }, '\n')
   1050 
   1051    if p.info.update_details ~= '' then
   1052      local details = p.info.update_details:gsub('\n', '\n• ')
   1053      parts[#parts + 1] = '\n\nAvailable newer versions:\n• ' .. details
   1054    end
   1055  else
   1056    parts[#parts + 1] = table.concat({
   1057      'Path:            ' .. p.path,
   1058      'Source:          ' .. p.spec.src,
   1059      'Revision before: ' .. p.info.sha_head,
   1060      'Revision after:  ' .. p.info.sha_target .. version_suffix,
   1061      '',
   1062      'Pending updates:',
   1063      p.info.update_details,
   1064    }, '\n')
   1065  end
   1066 
   1067  return table.concat(parts, '')
   1068 end
   1069 
   1070 --- @param plug_list vim.pack.Plug[]
   1071 --- @param skip_same_sha boolean
   1072 --- @return string[]
   1073 local function compute_feedback_lines(plug_list, skip_same_sha)
   1074  -- Construct plugin line groups for better report
   1075  local report_err, report_update, report_same = {}, {}, {}
   1076  for _, p in ipairs(plug_list) do
   1077    --- @type string[]
   1078    local group_arr = p.info.err ~= '' and report_err
   1079      or (p.info.sha_head ~= p.info.sha_target and report_update or report_same)
   1080    group_arr[#group_arr + 1] = compute_feedback_lines_single(p)
   1081  end
   1082 
   1083  local lines = {}
   1084  --- @param header string
   1085  --- @param arr string[]
   1086  local function append_report(header, arr)
   1087    if #arr == 0 then
   1088      return
   1089    end
   1090    header = header .. ' ' .. string.rep('─', 79 - header:len())
   1091    table.insert(lines, header)
   1092    vim.list_extend(lines, arr)
   1093  end
   1094  append_report('# Error', report_err)
   1095  append_report('# Update', report_update)
   1096  if not skip_same_sha then
   1097    append_report('# Same', report_same)
   1098  end
   1099 
   1100  return vim.split(table.concat(lines, '\n\n'), '\n')
   1101 end
   1102 
   1103 --- @param plug_list vim.pack.Plug[]
   1104 local function feedback_log(plug_list)
   1105  local lines = { ('========== Update %s =========='):format(get_timestamp()) }
   1106  vim.list_extend(lines, compute_feedback_lines(plug_list, true))
   1107  lines[#lines + 1] = ''
   1108 
   1109  local log_path = vim.fn.stdpath('log') .. '/nvim-pack.log'
   1110  vim.fn.mkdir(vim.fs.dirname(log_path), 'p')
   1111  vim.fn.writefile(lines, log_path, 'a')
   1112 end
   1113 
   1114 --- @param lines string[]
   1115 --- @param on_finish fun(bufnr: integer)
   1116 local function show_confirm_buf(lines, on_finish)
   1117  -- Show buffer in a separate tabpage
   1118  local bufnr = api.nvim_create_buf(true, true)
   1119  api.nvim_buf_set_name(bufnr, 'nvim-pack://confirm#' .. bufnr)
   1120  api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
   1121  vim.cmd.sbuffer({ bufnr, mods = { tab = vim.fn.tabpagenr() } })
   1122  local win_id = api.nvim_get_current_win()
   1123 
   1124  local delete_buffer = vim.schedule_wrap(function()
   1125    pcall(api.nvim_win_close, win_id, true)
   1126    pcall(api.nvim_buf_delete, bufnr, { force = true })
   1127    vim.cmd.redraw()
   1128  end)
   1129 
   1130  -- Define action on accepting confirm
   1131  local function finish()
   1132    on_finish(bufnr)
   1133    delete_buffer()
   1134  end
   1135  -- - Use `nested` to allow other events (useful for statuslines)
   1136  api.nvim_create_autocmd('BufWriteCmd', { buffer = bufnr, nested = true, callback = finish })
   1137 
   1138  -- Define action to cancel confirm
   1139  --- @type integer
   1140  local cancel_au_id
   1141  local function on_cancel(data)
   1142    if tonumber(data.match) ~= win_id then
   1143      return
   1144    end
   1145    pcall(api.nvim_del_autocmd, cancel_au_id)
   1146    delete_buffer()
   1147  end
   1148  cancel_au_id = api.nvim_create_autocmd('WinClosed', { nested = true, callback = on_cancel })
   1149 
   1150  -- Set buffer-local options last (so that user autocmmands could override)
   1151  vim.bo[bufnr].modified = false
   1152  vim.bo[bufnr].modifiable = false
   1153  vim.bo[bufnr].buftype = 'acwrite'
   1154  vim.bo[bufnr].filetype = 'nvim-pack'
   1155 
   1156  -- Attach in-process LSP for more capabilities
   1157  vim.lsp.buf_attach_client(bufnr, require('vim.pack._lsp').client_id)
   1158 end
   1159 
   1160 --- Get map of plugin names that need update based on confirmation buffer
   1161 --- content: all plugin sections present in "# Update" section.
   1162 --- @param bufnr integer
   1163 --- @return table<string,boolean>
   1164 local function get_update_map(bufnr)
   1165  local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
   1166  --- @type table<string,boolean>, boolean
   1167  local res, is_in_update = {}, false
   1168  for _, l in ipairs(lines) do
   1169    local name = l:match('^## (.+)$')
   1170    if name and is_in_update then
   1171      res[name:gsub(' %(not active%)$', '')] = true
   1172    end
   1173 
   1174    local group = l:match('^# (%S+)')
   1175    if group then
   1176      is_in_update = group == 'Update'
   1177    end
   1178  end
   1179  return res
   1180 end
   1181 
   1182 --- @class vim.pack.keyset.update
   1183 --- @inlinedoc
   1184 --- @field force? boolean Whether to skip confirmation and make updates immediately. Default `false`.
   1185 ---
   1186 --- @field offline? boolean Whether to skip downloading new updates. Default: `false`.
   1187 ---
   1188 --- How to compute a new plugin revision. One of:
   1189 ---     - "version" (default) - use latest revision matching `version` from plugin specification.
   1190 ---     - "lockfile" - use revision from the lockfile. Useful for reverting or performing controlled
   1191 ---       update.
   1192 --- @field target? string
   1193 
   1194 --- Update plugins
   1195 ---
   1196 --- - Download new changes from source.
   1197 --- - Infer update info (current/target revisions, changelog, etc.).
   1198 --- - Depending on `force`:
   1199 ---     - If `false`, show confirmation buffer. It lists data about all set to
   1200 ---       update plugins. Pending changes starting with `>` will be applied while
   1201 ---       the ones starting with `<` will be reverted.
   1202 ---       It has dedicated buffer-local mappings:
   1203 ---       - |]]| and |[[| to navigate through plugin sections.
   1204 ---
   1205 ---       Some features are provided  via LSP:
   1206 ---         - 'textDocument/documentSymbol' (`gO` via |lsp-defaults|
   1207 ---           or |vim.lsp.buf.document_symbol()|) - show structure of the buffer.
   1208 ---         - 'textDocument/hover' (`K` via |lsp-defaults| or |vim.lsp.buf.hover()|) -
   1209 ---           show more information at cursor. Like details of particular pending
   1210 ---           change or newer tag.
   1211 ---         - 'textDocument/codeAction' (`gra` via |lsp-defaults| or |vim.lsp.buf.code_action()|) -
   1212 ---           show code actions available for "plugin at cursor".
   1213 ---           Like "delete" (if plugin is not active), "update" or "skip updating"
   1214 ---           (if there are pending updates).
   1215 ---
   1216 ---       Execute |:write| to confirm update, execute |:quit| to discard the update.
   1217 ---     - If `true`, make updates right away.
   1218 ---
   1219 --- Notes:
   1220 --- - Every actual update is logged in "nvim-pack.log" file inside "log" |stdpath()|.
   1221 --- - It doesn't update source's default branch if it has changed (like from `master` to `main`).
   1222 ---   To have `version = nil` point to a new default branch, re-install the plugin
   1223 ---   (|vim.pack.del()| + |vim.pack.add()|).
   1224 ---
   1225 --- @param names? string[] List of plugin names to update. Must be managed
   1226 --- by |vim.pack|, not necessarily already added to current session.
   1227 --- Default: names of all plugins managed by |vim.pack|.
   1228 --- @param opts? vim.pack.keyset.update
   1229 function M.update(names, opts)
   1230  vim.validate('names', names, vim.islist, true, 'list')
   1231  opts = vim.tbl_extend('force', { force = false, offline = false, target = 'version' }, opts or {})
   1232 
   1233  local plug_list = plug_list_from_names(names)
   1234  if #plug_list == 0 then
   1235    notify('Nothing to update', 'WARN')
   1236    return
   1237  end
   1238  git_ensure_exec()
   1239  lock_read()
   1240 
   1241  -- Perform update
   1242  local timestamp = get_timestamp()
   1243  local needs_lock_write = opts.force --- @type boolean
   1244 
   1245  --- @async
   1246  --- @param p vim.pack.Plug
   1247  local function do_update(p)
   1248    local l_data = plugin_lock.plugins[p.spec.name]
   1249    -- Ensure proper `origin` if needed
   1250    if l_data.src ~= p.spec.src then
   1251      git_cmd({ 'remote', 'set-url', 'origin', p.spec.src }, p.path)
   1252      plugin_lock.plugins[p.spec.name].src = p.spec.src
   1253      needs_lock_write = true
   1254    end
   1255 
   1256    -- Fetch
   1257    if not opts.offline then
   1258      -- Using '--tags --force' means conflicting tags will be synced with remote
   1259      local args = { 'fetch', '--quiet', '--tags', '--force', '--recurse-submodules=yes', 'origin' }
   1260      git_cmd(args, p.path)
   1261    end
   1262 
   1263    -- Compute change info: changelog if any, new tags if nothing to update
   1264    if opts.target == 'lockfile' then
   1265      p.info.version_str = '*lockfile*'
   1266      p.info.sha_target = l_data.rev
   1267    end
   1268    infer_update_details(p)
   1269 
   1270    -- Checkout immediately if no need to confirm
   1271    if opts.force and p.info.sha_head ~= p.info.sha_target then
   1272      trigger_event(p, 'PackChangedPre', 'update')
   1273      checkout(p, timestamp)
   1274      trigger_event(p, 'PackChanged', 'update')
   1275    end
   1276  end
   1277  local progress_title = opts.force and (opts.offline and 'Applying updates' or 'Updating')
   1278    or (opts.offline and 'Computing updates' or 'Downloading updates')
   1279  run_list(plug_list, do_update, progress_title)
   1280 
   1281  if needs_lock_write then
   1282    lock_write()
   1283  end
   1284 
   1285  if opts.force then
   1286    feedback_log(plug_list)
   1287    return
   1288  end
   1289 
   1290  -- Show report in new buffer in separate tabpage
   1291  local lines = compute_feedback_lines(plug_list, false)
   1292  show_confirm_buf(lines, function(bufnr)
   1293    local to_update = get_update_map(bufnr)
   1294    if not next(to_update) then
   1295      notify('Nothing to update', 'WARN')
   1296      return
   1297    end
   1298 
   1299    --- @param p vim.pack.Plug
   1300    local plugs_to_checkout = vim.tbl_filter(function(p)
   1301      return to_update[p.spec.name]
   1302    end, plug_list)
   1303 
   1304    local timestamp2 = get_timestamp()
   1305    --- @async
   1306    --- @param p vim.pack.Plug
   1307    local function do_checkout(p)
   1308      trigger_event(p, 'PackChangedPre', 'update')
   1309      checkout(p, timestamp2)
   1310      trigger_event(p, 'PackChanged', 'update')
   1311    end
   1312    run_list(plugs_to_checkout, do_checkout, 'Applying updates')
   1313 
   1314    lock_write()
   1315    feedback_log(plugs_to_checkout)
   1316  end)
   1317 end
   1318 
   1319 --- @class vim.pack.keyset.del
   1320 --- @inlinedoc
   1321 --- @field force? boolean Whether to allow deleting an active plugin. Default `false`.
   1322 
   1323 --- Remove plugins from disk
   1324 ---
   1325 --- @param names string[] List of plugin names to remove from disk. Must be managed
   1326 --- by |vim.pack|, not necessarily already added to current session.
   1327 --- @param opts? vim.pack.keyset.del
   1328 function M.del(names, opts)
   1329  vim.validate('names', names, vim.islist, false, 'list')
   1330  opts = vim.tbl_extend('force', { force = false }, opts or {})
   1331 
   1332  local plug_list = plug_list_from_names(names)
   1333  if #plug_list == 0 then
   1334    notify('Nothing to remove', 'WARN')
   1335    return
   1336  end
   1337 
   1338  lock_read()
   1339 
   1340  local fail_to_delete = {} --- @type string[]
   1341  for _, p in ipairs(plug_list) do
   1342    if not active_plugins[p.path] or opts.force then
   1343      trigger_event(p, 'PackChangedPre', 'delete')
   1344 
   1345      vim.fs.rm(p.path, { recursive = true, force = true })
   1346      active_plugins[p.path] = nil
   1347      notify(("Removed plugin '%s'"):format(p.spec.name), 'INFO')
   1348 
   1349      plugin_lock.plugins[p.spec.name] = nil
   1350 
   1351      trigger_event(p, 'PackChanged', 'delete')
   1352    else
   1353      fail_to_delete[#fail_to_delete + 1] = p.spec.name
   1354    end
   1355  end
   1356 
   1357  lock_write()
   1358 
   1359  if #fail_to_delete > 0 then
   1360    local plugs = table.concat(fail_to_delete, ', ')
   1361    local msg = ('Some plugins are active and were not deleted: %s.'):format(plugs)
   1362      .. ' Remove them from init.lua, restart, and try again.'
   1363    error(msg)
   1364  end
   1365 end
   1366 
   1367 --- @inlinedoc
   1368 --- @class vim.pack.PlugData
   1369 --- @field active boolean Whether plugin was added via |vim.pack.add()| to current session.
   1370 --- @field branches? string[] Available Git branches (first is default). Missing if `info=false`.
   1371 --- @field path string Plugin's path on disk.
   1372 --- @field rev string Current Git revision.
   1373 --- @field spec vim.pack.SpecResolved A |vim.pack.Spec| with resolved `name`.
   1374 --- @field tags? string[] Available Git tags. Missing if `info=false`.
   1375 
   1376 --- @class vim.pack.keyset.get
   1377 --- @inlinedoc
   1378 --- @field info boolean Whether to include extra plugin info. Default `true`.
   1379 
   1380 --- @param p_data_list vim.pack.PlugData[]
   1381 local function add_p_data_info(p_data_list)
   1382  local funs = {} --- @type (async fun())[]
   1383  for i, p_data in ipairs(p_data_list) do
   1384    local path = p_data.path
   1385    --- @async
   1386    funs[i] = function()
   1387      p_data.branches = git_get_branches(path)
   1388      p_data.tags = git_get_tags(path)
   1389    end
   1390  end
   1391  --- @async
   1392  local function joined_f()
   1393    async.join(n_threads, funs)
   1394  end
   1395  async.run(joined_f):wait()
   1396 end
   1397 
   1398 --- Gets |vim.pack| plugin info, optionally filtered by `names`.
   1399 --- @param names? string[] List of plugin names. Default: all plugins managed by |vim.pack|.
   1400 --- @param opts? vim.pack.keyset.get
   1401 --- @return vim.pack.PlugData[]
   1402 function M.get(names, opts)
   1403  vim.validate('names', names, vim.islist, true, 'list')
   1404  opts = vim.tbl_extend('force', { info = true }, opts or {})
   1405 
   1406  -- Process active plugins in order they were added. Take into account that
   1407  -- there might be "holes" after `vim.pack.del()`.
   1408  local active = {} --- @type table<integer,vim.pack.Plug?>
   1409  for _, p_active in pairs(active_plugins) do
   1410    active[p_active.id] = p_active.plug
   1411  end
   1412 
   1413  lock_read()
   1414  local res = {} --- @type vim.pack.PlugData[]
   1415  local used_names = {} --- @type table<string,boolean>
   1416  for i = 1, n_active_plugins do
   1417    if active[i] and (not names or vim.tbl_contains(names, active[i].spec.name)) then
   1418      local name = active[i].spec.name
   1419      local spec = vim.deepcopy(active[i].spec)
   1420      local rev = (plugin_lock.plugins[name] or {}).rev
   1421      res[#res + 1] = { spec = spec, path = active[i].path, rev = rev, active = true }
   1422      used_names[name] = true
   1423    end
   1424  end
   1425 
   1426  local plug_dir = get_plug_dir()
   1427  for name, l_data in vim.spairs(plugin_lock.plugins) do
   1428    local path = vim.fs.joinpath(plug_dir, name)
   1429    local is_in_names = not names or vim.tbl_contains(names, name)
   1430    if not active_plugins[path] and is_in_names then
   1431      local spec = { name = name, src = l_data.src, version = l_data.version }
   1432      res[#res + 1] = { spec = spec, path = path, rev = l_data.rev, active = false }
   1433      used_names[name] = true
   1434    end
   1435  end
   1436 
   1437  if names ~= nil then
   1438    -- Align result with input
   1439    local names_order = {} --- @type table<string,integer>
   1440    for i, n in ipairs(names) do
   1441      if not used_names[n] then
   1442        error(('Plugin `%s` is not installed'):format(tostring(n)))
   1443      end
   1444      names_order[n] = i
   1445    end
   1446    table.sort(res, function(a, b)
   1447      return names_order[a.spec.name] < names_order[b.spec.name]
   1448    end)
   1449  end
   1450 
   1451  if opts.info then
   1452    add_p_data_info(res)
   1453  end
   1454 
   1455  return res
   1456 end
   1457 
   1458 return M