diff --git a/README.md b/README.md index e1a3c412..87447b0b 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,9 @@ Have a problem or idea? Make an [issue](https://github.com/wbthomason/packer.nvi 4. [Performing plugin management operations](#performing-plugin-management-operations) 5. [Extending packer](#extending-packer) 6. [Compiling Lazy-Loaders](#compiling-lazy-loaders) - 7. [User autocommands](#user-autocommands) - 8. [Using a floating window](#using-a-floating-window) + 7. [Lockfile](#lockfile) + 8. [User autocommands](#user-autocommands) + 9. [Using a floating window](#using-a-floating-window) 6. [Profiling](#profiling) 7. [Debugging](#debugging) 8. [Compatibility and known issues](#compatibility-and-known-issues) @@ -43,6 +44,7 @@ Have a problem or idea? Make an [issue](https://github.com/wbthomason/packer.nvi - Uses jobs for async installation - Support for `git` tags, branches, revisions, submodules - Support for local plugins +- Lockfile for keeping plugins in sync between systems ## Requirements - You need to be running **Neovim v0.5.0+** @@ -160,31 +162,23 @@ end) `packer` provides the following commands after you've run and configured `packer` with `require('packer').startup(...)`: -``` --- You must run this or `PackerSync` whenever you make changes to your plugin configuration --- Regenerate compiled loader file -:PackerCompile - --- Remove any disabled or unused plugins -:PackerClean - --- Clean, then install missing plugins -:PackerInstall - --- Clean, then update and install plugins --- supports the `--preview` flag as an optional first argument to preview updates -:PackerUpdate - --- Perform `PackerUpdate` and then `PackerCompile` --- supports the `--preview` flag as an optional first argument to preview updates -:PackerSync - --- Show list of installed plugins -:PackerStatus - --- Loads opt plugin immediately -:PackerLoad completion-nvim ale -``` +- `:PackerCompile` Regenerate compiled loader file + - You must run this or `PackerSync` whenever you make changes to your plugin configuration +- `:PackerClean` Remove any disabled or unused plugins +- `:PackerInstall` Clean, then install missing plugins. Supports optional arguments + - `--nolockfile`: Do not apply lockfile if enabled + - `--lockfile=/path/lockfile.lua`: Override lockfile used +- `:PackerUpdate` Clean, then update and install plugins. Supports optional arguments + - `--preview`: Preview updates before applying + - `--nolockfile`: Do not apply lockfile if enabled + - `--lockfile=/path/lockfile.lua`: Override lockfile used +- `:PackerSync` Perform `PackerUpdate` and then `PackerCompile`. Supports optional arguments + - `--preview`: Preview updates before applying + - `--nolockfile`: Do not apply lockfile if enabled + - `--lockfile=/path/lockfile.lua`: Override lockfile used +- `:PackerLoad` Loads opt plugin immediately +- `:PackerLockfile` Updates lockfile from installed plugins. Supports optional arguments + - `--path=/path/lockfile.lua`: Override lockfile output You can configure Neovim to automatically run `:PackerCompile` whenever `plugins.lua` is updated with [an autocommand](https://neovim.io/doc/user/autocmd.html#:autocmd): @@ -339,6 +333,11 @@ default configuration values (and structure of the configuration table) are: prompt_revert = 'r', } }, + lockfile = { + enable = false, -- Should packer apply lockfile to `installer` and `updater`? + path = util.join_paths(stdpath 'config', 'lockfile.lua'), -- Default file location for lockfile + regen_on_update = false, -- Should packer update the lockfile after upgrading plugins? + }, luarocks = { python_cmd = 'python' -- Set the python command to use for running hererocks }, @@ -396,12 +395,12 @@ use { ft = string or list, -- Specifies filetypes which load this plugin. keys = string or list, -- Specifies maps which load this plugin. See "Keybindings". event = string or list, -- Specifies autocommand events which load this plugin. - fn = string or list -- Specifies functions which load this plugin. + fn = string or list, -- Specifies functions which load this plugin. cond = string, function, or list of strings/functions, -- Specifies a conditional test to load this plugin - module = string or list -- Specifies Lua module names for require. When requiring a string which starts + module = string or list, -- Specifies Lua module names for require. When requiring a string which starts -- with one of these module names, the plugin will be loaded. - module_pattern = string/list -- Specifies Lua pattern of Lua module names for require. When - -- requiring a string which matches one of these patterns, the plugin will be loaded. + module_pattern = string/list -- Specifies Lua pattern of Lua module names for require. When requiring a + -- string which matches one of these patterns, the plugin will be loaded. } ``` @@ -520,12 +519,20 @@ below, `plugins` is an optional table of plugin names; if not provided, the defa plugins": - `packer.install(plugins)`: Install the specified plugins if they are not already installed +- `packer.install(opts, plugins)`: First argument can be a table of optional args + - `nolockfile`: `boolean` Should the command use the lockfile + - `lockfile`: `string` Override the default lockfile path to be used - `packer.update(plugins)`: Update the specified plugins, installing any that are missing -- `packer.update(opts, plugins)`: First argument can be a table specifying options, such as `{preview_updates = true}` to preview potential changes before updating (same as `PackerUpdate --preview`). +- `packer.update(opts, plugins)`: First argument can be a table specifying options + - `preview`: `boolean` Preview potential change before updating + - `nolockfile`: `boolean` Should the command use the lockfile + - `lockfile`: `string` Override the default lockfile path to be used - `packer.clean()`: Remove any disabled or no longer managed plugins - `packer.sync(plugins)`: Perform a `clean` followed by an `update`. - `packer.sync(opts, plugins)`: Can take same optional options as `update`. - `packer.compile(path)`: Compile lazy-loader code and save to `path`. +- `packer.lockfile(opts)`: Updates lockfile based on currently installed plugins + - `path`: `string` Override lockfile output path - `packer.snapshot(snapshot_name, ...)`: Creates a snapshot file that will live under `config.snapshot_path/`. If `snapshot_name` is an absolute path, then that will be the location where the snapshot will be taken. Optionally, a list of plugins name can be provided to selectively choose the plugins to snapshot. - `packer.rollback(snapshot_name, ...)`: Rollback plugins status a snapshot file that will live under `config.snapshot_path/`. If `snapshot_name` is an absolute path, then that will be the location where the snapshot will be taken. Optionally, a list of plugins name can be provided to selectively choose which plugins to revert. - `packer.delete(snapshot_name)`: Deletes a snapshot file under `config.snapshot_path/`. If `snapshot_name` is an absolute path, then that will be the location where the snapshot will be deleted. @@ -560,6 +567,47 @@ config/setup functions to bytecode, which has this limitation. Additionally, if functions are given for these keys, the functions will be passed the plugin name and information table as arguments. +### Lockfile + +`packer` can provide a `lockfile` to help manage plugin updates. This is useful for users that store their +configuration in some sort of source repository. Committing the lockfile will ensure that `packer` will +`install` and `update` plugins to known working commits for their configuration. + +Enabling lockfile support will change the default behavior of `packer.install()`, `packer.update()`, and +`packer.sync()`. If the lockfile contains a plugin, `packer` will update to the specified commit instead +of the latest changes. If the plugin is not found in the lockfile, `packer` will fetch the latest changes. +The plugin value `commit` will take precedence over any lockfile value. + +If you want to update your local plugins to the latest changes, call `:PackerUpdate` or `:PackerSync` with +the `--nolockfile` argument. This will ignore the lockfile and update your plugins to the latest changes. + +Some example commands: + +```vim +" Generate the lockfile to lockfile.path, as defined in packer's config +PackerLockfile + +" Generating a lockfile to some other path +PackerLockfile --path="/some/other/path.lua" + +" Update plugins to the state defined in lockfile +PackerUpdate + +" Updating without applying lockfile +PackerUpdate --nolockfile + +" Updating a specific plugin without applying lockfile +PackerUpdate --nolockfile plenary.nvim + +" Updating plugins and applying a specific lockfile +PackerUpdate --lockfile="/some/other/path.lua" + +" Updating a specific plugin with a specific lockfile +PackerUpdate --lockfile="/some/other/path.lua" plenary.nvim +``` + +The same options that apply to `PackerUpdate` also apply to `PackerInstall` and `PackerSync`. + ### User autocommands `packer` runs most of its operations asyncronously. If you would like to implement automations that require knowing when the operations are complete, you can use the following `User` autocmds (see @@ -567,6 +615,7 @@ require knowing when the operations are complete, you can use the following `Use - `PackerComplete`: Fires after install, update, clean, and sync asynchronous operations finish. - `PackerCompileDone`: Fires after compiling (see [the section on compilation](#compiling-lazy-loaders)) +- `PackerLockfileDone`: Fires after lockfile generation (see [the section on lockfile](#lockfile)) ### Using a floating window You can configure Packer to use a floating window for command outputs by passing a utility diff --git a/doc/packer.txt b/doc/packer.txt index f0df8444..2e154ecc 100644 --- a/doc/packer.txt +++ b/doc/packer.txt @@ -44,6 +44,7 @@ FEATURES *packer-intro-features* - Uses jobs for async installation - Support for `git` tags, branches, revisions, submodules - Support for local plugins +- Lockfile for keeping plugins in sync between systems - Support for saving/restoring snapshots for plugin versions (`git` only) ============================================================================== @@ -117,27 +118,39 @@ configuration. Regenerate compiled loader file. `PackerInstall` *packer-commands-install* Clean, then install missing plugins. +Optional arguments: + `--nolockfile` Do not apply lockfile if enabled + `--lockfile=/path/lockfile.lua` Override lockfile used `PackerUpdate` *packer-commands-update* Clean, then update and install plugins. -Supports the `--preview` flag as an optional first argument to preview -updates. +Optional arguments: + `--preview` Preview updates + `--nolockfile` Do not apply lockfile if enabled + `--lockfile=/path/lockfile.lua` Override lockfile used `PackerSync` *packer-commands-sync* Perform `PackerUpdate` and then `PackerCompile`. -Supports the `--preview` flag as an optional first argument to preview -updates. +Optional arguments: + `--preview` Preview updates + `--nolockfile` Do not apply lockfile if enabled + `--lockfile=/path/lockfile.lua` Override lockfile used + +`PackerLockfile` *packer-commands-lockfile* +Updates lockfile from installed plugins. +Optional arguments: + `--path=/path/lockfile.lua` Override lockfile output `PackerLoad` *packer-commands-load* Loads opt plugin immediately -`PackerSnapshot` *packer-commands-snapshot* +`PackerSnapshot` *packer-commands-snapshot* Snapshots your plugins to a file -`PackerSnapshotDelete` *packer-commands-delete* +`PackerSnapshotDelete` *packer-commands-delete* Deletes a snapshot -`PackerSnapshotRollback` *packer-commands-rollback* +`PackerSnapshotRollback` *packer-commands-rollback* Rolls back plugins' commit specified by the snapshot ============================================================================== USAGE *packer-usage* @@ -190,6 +203,7 @@ commands work well for this purpose: > command! -nargs=* -complete=customlist,v:lua.require'packer'.plugin_complete PackerInstall lua require('packer').install() command! -nargs=* -complete=customlist,v:lua.require'packer'.plugin_complete PackerUpdate lua require('packer').update() command! -nargs=* -complete=customlist,v:lua.require'packer'.plugin_complete PackerSync lua require('packer').sync() + command! -nargs=* -complete=customlist,v:lua.require'packer.lockfile'.completion PackerLockfile lua require('packer').lockfile() command! PackerClean packadd packer.nvim | lua require('plugins').clean() command! PackerCompile packadd packer.nvim | lua require('plugins').compile('~/.config/nvim/plugin/packer_load.vim') command! -bang -nargs=+ -complete=customlist,v:lua.require'packer'.loader_complete PackerLoad lua require('packer').loader(, '') @@ -231,6 +245,11 @@ default values: > clone_timeout = 60, -- Timeout, in seconds, for git clones default_url_format = 'https://github.com/%s' -- Lua format string used for "aaa/bbb" style plugins }, + lockfile = { + enable = false, -- Should packer apply lockfile to `installer` and `updater`? + path = util.join_paths(stdpath 'config', 'lockfile.lua'), -- Default file location for lockfile + regen_on_update = false, -- Should packer update the lockfile after upgrading plugins? + }, log = { level = 'warn' }, -- The default print log level. One of: "trace", "debug", "info", "warn", "error", "fatal". display = { non_interactive = false, -- If true, disable display windows for all operations @@ -490,6 +509,51 @@ They can be configured by changing the value of `config.display.keybindings` (see |packer-configuration|). Setting it to `false` will disable all keybindings. Setting any of its keys to `false` will disable the corresponding keybinding. +LOCKFILE *packer-lockfile* +`packer` can provide a `lockfile` to help manage plugin updates. This is +useful for users that store their configuration in some sort of source +repository. Committing the lockfile will ensure that `packer` will `install` +and `update` plugins to known working commits for their configuration. + +Lockfile is enabled by setting the |packer-configuration| option +`lockfile.enable = true` . Enabling lockfile support will change the default +behavior of |packer.install()|, |packer.update()|, and |packer.sync()|. If the +lockfile contains a plugin, `packer` will update to the specified commit +instead of the latest changes. If the plugin is not found in the lockfile, +`packer` will fetch the latest changes. The plugin value `commit` will take +precedence over any lockfile value. + +If you want to update your local plugins to the latest changes, call +`:PackerUpdate` or `:PackerSync` with the `--nolockfile` argument. +This will ignore the lockfile and update your plugins to the latest changes. + +Some example commands: + +> + " Generate the lockfile to lockfile.path defined in packer's config + PackerLockfile + + " Generating a lockfile to some other path + PackerLockfile --path="/some/other/path.lua" + + " Update plugins to the state defined in lockfile + PackerUpdate + + " Updating without applying lockfile + PackerUpdate --nolockfile + + " Updating a specific plugin without applying lockfile + PackerUpdate --nolockfile plenary.nvim + + " Updating plugins and applying a specific lockfile + PackerUpdate --lockfile="/some/other/path.lua" + + " Updating a specific plugin with a specific lockfile + PackerUpdate --lockfile="/some/other/path.lua" plenary.nvim +< + +The same options that apply to `PackerUpdate` also apply to `PackerInstall` and `PackerSync`. + USER AUTOCMDS *packer-user-autocmds* `packer` runs most of its operations asyncronously. If you would like to implement automations that require knowing when the operations are complete, @@ -499,15 +563,16 @@ use): `PackerComplete` Fires after install, update, clean, and sync asynchronous operations finish. `PackerCompileDone` Fires after compiling (see |packer-lazy-load|) +`PackerLockfileDone` Fires after lockfile (see |packer-lockfile|) ============================================================================== API *packer-api* -clean() *packer.clean()* +clean() *packer.clean()* `clean` scans for and removes all disabled or no longer managed plugins. It is invoked without arguments. -compile() *packer.compile()* +compile() *packer.compile()* `compile` builds lazy-loader code from your plugin specification and saves it to either `config.compile_path` if it is invoked with no argument, or to the path it is invoked with if it is given a single argument. This path should end @@ -515,12 +580,12 @@ in `.vim` and be on your |runtimepath| in order for lazy-loading to work. You **must** call `compile` to update lazy-loaders after your configuration changes. -init() *packer.init()* +init(user_config) *packer.init()* Initializes `packer`; must be called before any calls to any other `packer` function. Takes an optional table of configuration values as described in |packer-configuration|. -install() *packer.install()* +install(...) *packer.install()* `install` installs any missing plugins, runs post-update hooks, and updates rplugins (|remote-plugin|) and helptags. @@ -528,26 +593,54 @@ It can be invoked with no arguments or with a list of plugin names to install. These plugin names must already be managed by `packer` via a call to |packer.use()|. -reset() *packer.reset()* +Additionally, `install` can take an optional table as the first option. This +table can contain the following keys: + +- `lockfile`: `string` Override the default lockfile path to be used +- `nolockfile`: `boolean` If value is `true` the install command will not + enable the |packer-lockfile| + +Some examples: +> + -- Install only select plugins + packer.install("telescope.nvim", "plenary.nvim") + + -- Install plugins without applying the lockfile + packer.install({ nolockfile = true }) + + -- Install only select plugins without applying the lockfile + packer.install({ nolockfile = true }, "telescope.nvim", "plenary.nvim"}) +< + +lockfile(...) *packer.lockfile()* +`lockfile` updates `packer`'s lockfile (|packer-lockfile|) at `lockfile.path` based +on the currently installed plugins. + +Additionally, `lockfile` can take an optional table. This table can contain +the following keys: + +- `path`: `string` Override lockfile output path + +reset() *packer.reset()* `reset` empties the set of managed plugins. Called with no arguments; used to ensure plugin specifications are reinitialized if the specification file is reloaded. Called by |packer.startup()| or manually before calling |packer.use()|. -set_handler() *packer.set_handler()* +set_handler() *packer.set_handler()* `set_handler` allows custom extension of `packer`. See |packer-extending| for details. -startup() *packer.startup()* +startup() *packer.startup()* `startup` is a convenience function for simple setup. See |packer-startup| for details. -sync() *packer.sync()* +sync() *packer.sync()* `sync` runs |packer.clean()| followed by |packer.update()|. Supports options as the first argument, see |packer.update()|. -update() *packer.update()* +update(...) *packer.update()* `update` installs any missing plugins, updates all installed plugins, runs post-update hooks, and updates rplugins (|remote-plugin|) and helptags. @@ -555,23 +648,44 @@ It can be invoked with no arguments or with a list of plugin names to update. These plugin names must already be managed by `packer` via a call to |packer.use()|. -Additionally, the first argument can be a table specifying options, -such as `update({preview_updates = true}, ...)` to preview potential changes before updating -(same as `PackerUpdate --preview`). +Additionally, `update` can take an optional table as the first option. This +table can contain the following keys: -snapshot(snapshot_name, ...) *packer.snapshot()* -`snapshot` takes the rev of all the installed plugins and serializes them into a Lua table which will be saved under `config.snapshot_path` (which is the directory that will hold all the snapshots files) as `config.snapshot_path/` or an absolute path provided by the users. -Optionally plugins name can be specified so that only those plugins will be -snapshotted. -Snapshot files can be loaded manually via `dofile` which will return a table with the plugins name as keys the commit short hash as value. +- `preview`: `boolean` Preview potential change before updating +- `lockfile`: `string` Override the default lockfile path to be used +- `nolockfile`: `boolean` If value is `true` the install command will not + enable the |packer-lockfile| + +Some examples: +> + -- Update only select plugins + packer.update("telescope.nvim", "plenary.nvim") + + -- Update plugins without applying the lockfile + packer.update({ nolockfile = true }) + + -- Update only select plugins without applying the lockfile + packer.update({ nolockfile = true }, "telescope.nvim", "plenary.nvim"}) +< -delete(snapshot_name) *packer.delete()* +snapshot(snapshot_name, ...) *packer.snapshot()* +`snapshot` takes the rev of all the installed plugins and serializes them into +a Lua table which will be saved under `config.snapshot_path` (which is the +directory that will hold all the snapshots files) as +`config.snapshot_path/` or an absolute path provided by the +users. Optionally plugins name can be specified so that only those plugins +will be snapshotted. Snapshot files can be loaded manually via `dofile` which +will return a table with the plugins name as keys the commit short hash as +value. + +delete(snapshot_name) *packer.delete()* `delete` deletes a snapshot given the name or the absolute path. -rollback(snapshot_name, ...) *packer.rollback()* -`rollback` reverts all plugins or only the specified as extra arguments to the commit specified in the snapshot file +rollback(snapshot_name, ...) *packer.rollback()* +`rollback` reverts all plugins or only the specified as extra arguments to the +commit specified in the snapshot file -use() *packer.use()* +use() *packer.use()* `use` allows you to add one or more plugins to the managed set. It can be invoked as follows: - With a single plugin location string, e.g. `use ` @@ -611,8 +725,10 @@ invoked as follows: } - With a list of plugins specified in either of the above two forms -For the *cmd* option, the command may be a full command, or an autocommand pattern. If the command contains any -non-alphanumeric characters, it is assumed to be a pattern, and instead of creating a stub command, it creates -a CmdUndefined autocmd to load the plugin when a command that matches the pattern is invoked. +For the *cmd* option, the command may be a full command, or an autocommand +pattern. If the command contains any non-alphanumeric characters, it is +assumed to be a pattern, and instead of creating a stub command, it creates a +CmdUndefined autocmd to load the plugin when a command that matches the +pattern is invoked. vim:tw=78:ts=2:ft=help:norl: diff --git a/lua/packer.lua b/lua/packer.lua index 10fa335d..8bae267a 100644 --- a/lua/packer.lua +++ b/lua/packer.lua @@ -29,17 +29,19 @@ local config_defaults = { subcommands = { update = 'pull --ff-only --progress --rebase=false', update_head = 'merge FETCH_HEAD', - install = 'clone --depth %i --no-single-branch --progress', - fetch = 'fetch --depth 999999 --progress', + install = 'clone --no-single-branch --progress', + fetch = 'fetch --progress', checkout = 'checkout %s --', update_branch = 'merge --ff-only @{u}', current_branch = 'rev-parse --abbrev-ref HEAD', diff = 'log --color=never --pretty=format:FMT --no-show-signature %s...%s', diff_fmt = '%%h %%s (%%cr)', + commit_count = 'rev-list --count %s..%s', git_diff_fmt = 'show --no-color --pretty=medium %s', get_rev = 'rev-parse --short HEAD', get_header = 'log --color=never --pretty=format:FMT --no-show-signature HEAD -n 1', get_bodies = 'log --color=never --pretty=format:"===COMMIT_START===%h%n%s===BODY_START===%b" --no-show-signature HEAD@{1}...HEAD', + get_date = 'show -s --format="%ct"', get_fetch_bodies = 'log --color=never --pretty=format:"===COMMIT_START===%h%n%s===BODY_START===%b" --no-show-signature HEAD...FETCH_HEAD', submodules = 'submodule update --init --recursive --progress', revert = 'reset --hard HEAD@{1}', @@ -76,6 +78,11 @@ local config_defaults = { retry = 'R', }, }, + lockfile = { + enable = false, + path = join_paths(stdpath 'config', 'lockfile.lua'), + regen_on_update = false, + }, luarocks = { python_cmd = 'python' }, log = { level = 'warn' }, profile = { enable = false }, @@ -105,6 +112,12 @@ local configurable_modules = { snapshot = false, } +local install_update_complete_opt_args = { + '--preview', + '--nolockfile', + '--lockfile=', +} + local function require_and_configure(module_name) local fully_qualified_name = 'packer.' .. module_name local module = require(fully_qualified_name) @@ -117,6 +130,56 @@ local function require_and_configure(module_name) return module end +---Filter options, flags and positional arguments from a variadic input. +---The first argument in the variadic argument may be a table that +--- options, and flags will be appended to. +--- +--- Optional arguments are defined prefixed by `--`. There are two types: +--- +--- - `Options` +--- - Defined by containing `=` where the lhs is the name and rhs the value. +--- - Example `--path=/some/path` -> { path = '/some/path' } +--- - `Flags` +--- - Defined by just their name. Their value is always set to true +--- - Example `--nolockfile` -> { nolockfile = true } +--- +--- - `Positional` arguments dont contain the optional prefix `--`. +--- @return table, table First table is optional arguments second is positional +local filter_opts_from_pos_args = function(...) + local args = { ... } + local opts = {} + local pos_args = {} + + local unquote = function(str) + return str:gsub('"', ''):gsub("'", '') + end + + if not vim.tbl_isempty(args) then + local first = args[1] + if type(first) == 'table' then + opts = first + table.remove(args, 1) + end + + for _, e in ipairs(args) do + if type(e) == 'string' and e ~= '' then + if e:sub(1, 2) == '--' then + local x = string.find(e, '=', 1, true) + if x then + opts[string.sub(e, 3, x - 1)] = unquote(string.sub(e, x + 1)) + else + opts[string.sub(e, 3)] = true + end + else + table.insert(pos_args, e) + end + end + end + end + + return opts, pos_args +end + --- Initialize packer -- Forwards user configuration to sub-modules, resets the set of managed plugins, and ensures that -- the necessary package directories exist @@ -155,6 +218,7 @@ packer.make_commands = function() vim.cmd [[command! -nargs=* -complete=customlist,v:lua.require'packer'.plugin_complete PackerInstall lua require('packer').install()]] vim.cmd [[command! -nargs=* -complete=customlist,v:lua.require'packer'.plugin_complete PackerUpdate lua require('packer').update()]] vim.cmd [[command! -nargs=* -complete=customlist,v:lua.require'packer'.plugin_complete PackerSync lua require('packer').sync()]] + vim.cmd [[command! -nargs=* -complete=customlist,v:lua.require'packer.lockfile'.completion PackerLockfile lua require('packer').lockfile()]] vim.cmd [[command! PackerClean lua require('packer').clean()]] vim.cmd [[command! -nargs=* PackerCompile lua require('packer').compile()]] vim.cmd [[command! PackerStatus lua require('packer').status()]] @@ -367,6 +431,13 @@ packer.on_compile_done = function() log.debug 'packer.compile: Complete' end +packer.on_lockfile_done = function() + local log = require_and_configure 'log' + + vim.cmd [[doautocmd User PackerLockfileDone]] + log.debug 'packer.lockfile: Complete' +end + --- Clean operation: -- Finds plugins present in the `packer` package but not in the managed set packer.clean = function(results) @@ -407,10 +478,19 @@ packer.install = function(...) local display = require_and_configure 'display' manage_all_plugins() + + local opts, pos_args = filter_opts_from_pos_args(...) + if config.lockfile.enable then + local lockfile = require_and_configure 'lockfile' + lockfile.is_updating = opts.nolockfile or false + lockfile.load(opts.lockfile or config.lockfile.path) + end + local install_plugins - if ... then - install_plugins = { ... } + if #pos_args > 0 then + install_plugins = pos_args end + async(function() local fs_state = await(plugin_utils.get_fs_state(plugins)) if not install_plugins then @@ -462,27 +542,6 @@ packer.install = function(...) end)() end --- Filter out options specified as the first argument to update or sync --- returns the options table and the plugin names -local filter_opts_from_plugins = function(...) - local args = { ... } - local opts = {} - if not vim.tbl_isempty(args) then - local first = args[1] - if type(first) == 'table' then - table.remove(args, 1) - opts = first - elseif first == '--preview' then - table.remove(args, 1) - opts = { preview_updates = true } - end - end - if opts.preview_updates == nil and config.preview_updates then - opts.preview_updates = true - end - return opts, util.nonempty_or(args, vim.tbl_keys(plugins)) -end - --- Update operation: -- Takes an optional list of plugin names as an argument. If no list is given, operates on all -- managed plugins. @@ -504,7 +563,15 @@ packer.update = function(...) manage_all_plugins() - local opts, update_plugins = filter_opts_from_plugins(...) + local opts, pos_args = filter_opts_from_pos_args(...) + local update_plugins = util.nonempty_or(pos_args, vim.tbl_keys(plugins)) + opts.preview_updates = opts.preview or config.preview_updates + if config.lockfile.enable then + local lockfile = require_and_configure 'lockfile' + lockfile.is_updating = opts.nolockfile or false + lockfile.load(opts.lockfile or config.lockfile.path) + end + async(function() local start_time = vim.fn.reltime() local results = {} @@ -557,6 +624,14 @@ packer.update = function(...) plugin_utils.update_rplugins() local delta = string.gsub(vim.fn.reltimestr(vim.fn.reltime(start_time)), ' ', '') display_win:final_results(results, delta, opts) + + if config.lockfile.enable then + local lockfile = require_and_configure 'lockfile' + if lockfile.is_updating and config.lockfile.regen_on_update then + await(lockfile.update(plugins, opts.lockfile or config.lockfile.path)) + end + end + packer.on_complete() end)() end @@ -584,7 +659,15 @@ packer.sync = function(...) manage_all_plugins() - local opts, sync_plugins = filter_opts_from_plugins(...) + local opts, pos_args = filter_opts_from_pos_args(...) + local sync_plugins = util.nonempty_or(pos_args, vim.tbl_keys(plugins)) + opts.preview_updates = opts.preview or config.preview_updates + if config.lockfile.enable then + local lockfile = require_and_configure 'lockfile' + lockfile.is_updating = opts.nolockfile or false + lockfile.load(opts.lockfile or config.lockfile.path) + end + async(function() local start_time = vim.fn.reltime() local results = {} @@ -650,14 +733,60 @@ packer.sync = function(...) plugin_utils.update_rplugins() local delta = string.gsub(vim.fn.reltimestr(vim.fn.reltime(start_time)), ' ', '') display_win:final_results(results, delta, opts) + + if config.lockfile.enable then + local lockfile = require_and_configure 'lockfile' + if lockfile.is_updating and config.lockfile.regen_on_update then + await(lockfile.update(plugins, opts.lockfile or config.lockfile.path)) + end + end + packer.on_complete() end)() end +--- Update lockfile with current plugin status +packer.lockfile = function(...) + local a = require 'packer.async' + local async = a.sync + local await = a.wait + local log = require 'packer.log' + local lockfile = require_and_configure 'lockfile' + + manage_all_plugins() + + local opts, _ = filter_opts_from_pos_args(...) + local lockfile_path = opts.path or config.lockfile.path + + async(function() + await(lockfile.update(plugins, lockfile_path)) + :map_ok(function(ok) + await(a.main) + if next(ok.failed) then + log.error('Could not update lockfile ' .. vim.inspect(ok.failed)) + else + log.info(ok.message) + end + end) + :map_err(function(err) + await(a.main) + log.error(err) + end) + + packer.on_lockfile_done() + end)() +end + packer.status = function() local async = require('packer.async').sync local display = require_and_configure 'display' local log = require_and_configure 'log' + + if config.lockfile.enable then + local lockfile = require_and_configure 'lockfile' + lockfile.load(config.lockfile.path) + end + manage_all_plugins() async(function() local display_win = display.open(config.display.open_fn or config.display.open_cmd) @@ -843,6 +972,15 @@ end -- Completion user plugins -- Intended to provide completion for PackerUpdate/Sync/Install command packer.plugin_complete = function(lead, _, _) + if vim.startswith(lead, '--lockfile=') then + return require('packer.util').path_complete(lead) + end + if vim.startswith(lead, '-') then + return vim.tbl_filter(function(name) + return vim.startswith(name, lead) + end, install_update_complete_opt_args) + end + local completion_list = vim.tbl_filter(function(name) return vim.startswith(name, lead) end, vim.tbl_keys(_G.packer_plugins)) diff --git a/lua/packer/display.lua b/lua/packer/display.lua index 5164b001..35baf519 100644 --- a/lua/packer/display.lua +++ b/lua/packer/display.lua @@ -203,14 +203,10 @@ local function prompt_user(headline, body, callback) end local make_update_msg = function(symbol, status, plugin_name, plugin) - return fmt( - ' %s %s %s: %s..%s', - symbol, - status, - plugin_name, - plugin.revs[1], - plugin.revs[2] - ) + local ahead, behind = plugin.ahead_behind[1], plugin.ahead_behind[2] + local msg = ahead > 0 and 'ahead' or 'behind' + local count = ahead > 0 and ahead or behind + return fmt(' %s %s %s: %s..%s (%s %s)', symbol, status, plugin_name, plugin.revs[1], plugin.revs[2], count, msg) end local display = {} @@ -371,6 +367,8 @@ local display_mt = { self:setup_status_syntax() self:update_headline_message(fmt('Total plugins: %d', vim.tbl_count(plugins))) + local lockfile = require 'packer.lockfile' + local plugs = {} local lines = {} @@ -394,9 +392,16 @@ local display_mt = { end, details) vim.list_extend(config_lines, { fmt('%s%s: ', padding, key), unpack(details) }) end - plugs[plug_name] = { lines = config_lines, displayed = false } end end + + local lockfile_info = lockfile.get(plug_name) + if lockfile_info.commit then + vim.list_extend(config_lines, { fmt('%slockfile: %s', padding, lockfile_info.commit) }) + end + + plugs[plug_name] = { lines = config_lines, displayed = false } + vim.list_extend(lines, header_lines) end table.sort(lines) @@ -494,10 +499,7 @@ local display_mt = { if result.ok then if self:has_changes(plugin) then table.insert(item_order, plugin_name) - table.insert( - message, - make_update_msg(config.done_sym, status_msg, plugin_name, plugin) - ) + table.insert(message, make_update_msg(config.done_sym, status_msg, plugin_name, plugin)) else actual_update = false table.insert(message, fmt(' %s %s is already up to date', config.done_sym, plugin_name)) @@ -768,11 +770,7 @@ local display_mt = { status_msg = 'Can update' symbol = config.done_sym end - self:set_lines( - start_idx, - start_idx + 1, - {make_update_msg(symbol, status_msg, plugin_name, plugin_data)} - ) + self:set_lines(start_idx, start_idx + 1, { make_update_msg(symbol, status_msg, plugin_name, plugin_data) }) -- NOTE we need to reset the mark self.marks[plugin_name].start = set_extmark(self.buf, self.ns, nil, start_idx, 0) end, @@ -789,7 +787,7 @@ local display_mt = { end end if #plugins > 0 then - require('packer').update({pull_head = true, preview_updates = false}, unpack(plugins)) + require('packer').update({ pull_head = true, preview_updates = false }, unpack(plugins)) else log.warn 'No plugins selected!' end diff --git a/lua/packer/lockfile.lua b/lua/packer/lockfile.lua new file mode 100644 index 00000000..6ef59d0b --- /dev/null +++ b/lua/packer/lockfile.lua @@ -0,0 +1,129 @@ +local a = require 'packer.async' +local log = require 'packer.log' +local plugin_utils = require 'packer.plugin_utils' +local result = require 'packer.result' +local async = a.sync +local await = a.wait +local fmt = string.format + +local config = nil +local data = {} + +local function cfg(_config) + config = _config.lockfile +end + +local lockfile = { + cfg = cfg, + is_updating = false, +} + +local opt_args = { + '--path=', +} + +local function dofile_wrap(file) + return dofile(file) +end + +local function collect_commits(plugins) + local completed = {} + local failed = {} + local opt, start = plugin_utils.list_installed_plugins() + local installed = vim.tbl_extend('error', start, opt) + + plugins = vim.tbl_filter(function(plugin) + if installed[plugin.install_path] then -- this plugin is installed + return plugin + end + end, plugins) + + return async(function() + for _, plugin in pairs(plugins) do + local name = plugin.short_name + if plugin.type == plugin_utils.local_plugin_type then + -- If a local plugin exists in the current lockfile data then use that to keep conistant. + -- Note: Since local plugins are ignored by the lockfile it will not try and change the local repo. + if data[name] then + completed[name] = data[name] + end + else + local rev = await(plugin.get_rev()) + local date = await(plugin.get_date()) + if rev.err then + failed[name] = fmt("Getting rev for '%s' failed because of error '%s'", name, vim.inspect(rev.err)) + elseif date.err then + failed[name] = fmt("Getting date for '%s' failed because of error '%s'", name, vim.inspect(date.err)) + else + completed[name] = { commit = rev.ok, date = date.ok } + end + end + end + + return result.ok { failed = failed, completed = completed } + end) +end + +lockfile.completion = function(lead, _, _) + if vim.startswith(lead, '--path=') then + return require('packer.util').path_complete(lead) + end + + if vim.startswith(lead, '-') then + return vim.tbl_filter(function(name) + return vim.startswith(name, lead) + end, opt_args) + end +end + +lockfile.load = function(path) + path = path or config.path + if vim.loop.fs_stat(path) == nil then + log.warn(fmt("Lockfile: '%s' not found. Run `PackerLockfile` to generate", path)) + return + end + + local ok, res = pcall(dofile_wrap, path) + if not ok then + log.error(fmt("Failed loading '%s' lockfile: '%s'", path, res)) + else + data = res + end +end + +lockfile.get = function(name) + return data[name] or {} +end + +lockfile.update = function(plugins, path) + local lines = {} + return async(function() + local commits = await(collect_commits(plugins)) + + for name, commit in pairs(commits.ok.completed) do + lines[#lines + 1] = fmt([[ ["%s"] = { commit = "%s", date = %s },]], name, commit.commit, commit.date) + end + + -- Lines are sorted so that the diff will only contain changes not random re-ordering + table.sort(lines) + table.insert(lines, '}') + table.insert(lines, 1, 'return {') + table.insert(lines, 1, '-- Automatically generated by packer.nvim') + + await(a.main) + local status, res = pcall(function() + return vim.fn.writefile(lines, path) == 0 + end) + + if status and res then + return result.ok { + message = fmt('Lockfile written to %s', path), + failed = commits.ok.failed, + } + else + return result.err { message = fmt("Error on creating lockfile '%s': '%s'", path, res) } + end + end) +end + +return lockfile diff --git a/lua/packer/plugin_types/git.lua b/lua/packer/plugin_types/git.lua index 011efe6f..448961a7 100644 --- a/lua/packer/plugin_types/git.lua +++ b/lua/packer/plugin_types/git.lua @@ -3,6 +3,7 @@ local jobs = require 'packer.jobs' local a = require 'packer.async' local result = require 'packer.result' local log = require 'packer.log' +local lockfile = require 'packer.lockfile' local await = a.wait local async = a.sync local fmt = string.format @@ -81,9 +82,18 @@ git.cfg = function(_config) config.base_dir = _config.package_root config.default_base_dir = util.join_paths(config.base_dir, _config.plugin_package) config.exec_cmd = config.cmd .. ' ' + config.is_lockfile = _config.lockfile.enable ensure_git_env() end +---Get lockfile info if lockfile should be applied +---@param plugin table @ plugin being applied +---@return table @ either table with lockfile info or an empty table +local function get_lockfile_info(plugin) + local use_lockfile = config.is_lockfile and not lockfile.is_updating + return use_lockfile and lockfile.get(plugin.short_name) or {} +end + ---Resets a git repo `dest` to `commit` ---@param dest string @ path to the local git repo ---@param commit string @ commit hash @@ -147,14 +157,15 @@ local handle_checkouts = function(plugin, dest, disp, opts) end) end - if plugin.commit then + local commit = plugin.commit or get_lockfile_info(plugin).commit + if commit then if disp ~= nil then - disp:task_update(plugin_name, fmt('checking out %s...', plugin.commit)) + disp:task_update(plugin_name, fmt('checking out %s...', commit)) end - r:and_then(await, jobs.run(config.exec_cmd .. fmt(config.subcommands.checkout, plugin.commit), job_opts)) + r:and_then(await, jobs.run(config.exec_cmd .. fmt(config.subcommands.checkout, commit), job_opts)) :map_err(function(err) return { - msg = fmt('Error checking out commit %s for %s', plugin.commit, plugin_name), + msg = fmt('Error checking out commit %s for %s', commit, plugin_name), data = err, output = output, } @@ -207,20 +218,48 @@ local split_messages = function(messages) return lines end +local get_date = function(plugin) + local plugin_name = util.get_plugin_full_name(plugin) + + local rev_cmd = config.exec_cmd .. config.subcommands.get_date + + return async(function() + local rev = await(jobs.run(rev_cmd, { cwd = plugin.install_path, options = { env = git.job_env }, capture_output = true })) + :map_ok(function(ok) + local _, r = next(ok.output.data.stdout) + return r + end) + :map_err(function(err) + local _, msg = fmt('%s: %s', plugin_name, next(err.output.data.stderr)) + return msg + end) + + return rev + end) +end + +local get_depth = function(plugin) + if config.is_lockfile then + local info = lockfile.get(plugin.short_name) + return info.date and fmt(' --shallow-since="%s"', info.date) or ' --depth=999999' + else + local depth = plugin.commit and 999999 or config.depth + return fmt(' --depth=%s', depth) + end +end + git.setup = function(plugin) + local depth_opt = get_depth(plugin) local plugin_name = util.get_plugin_full_name(plugin) local install_to = plugin.install_path - local install_cmd = - vim.split(config.exec_cmd .. fmt(config.subcommands.install, plugin.commit and 999999 or config.depth), '%s+') + local install_cmd = vim.split(config.exec_cmd .. config.subcommands.install .. depth_opt, '%s+') local submodule_cmd = config.exec_cmd .. config.subcommands.submodules local rev_cmd = config.exec_cmd .. config.subcommands.get_rev - local update_cmd = config.exec_cmd .. config.subcommands.update + local update_head_cmd = config.exec_cmd .. config.subcommands.update_head - local fetch_cmd = config.exec_cmd .. config.subcommands.fetch - if plugin.commit or plugin.tag then - update_cmd = fetch_cmd - end + local fetch_cmd = vim.split(config.exec_cmd .. config.subcommands.fetch .. depth_opt, '%s+') + local pull_cmd = vim.split(config.exec_cmd .. config.subcommands.update .. depth_opt, '%s+') local branch_cmd = config.exec_cmd .. config.subcommands.current_branch local current_commit_cmd = vim.split(config.exec_cmd .. config.subcommands.get_header, '%s+') @@ -258,12 +297,13 @@ git.setup = function(plugin) installer_opts.cwd = install_to r:and_then(await, jobs.run(submodule_cmd, installer_opts)) - if plugin.commit then - disp:task_update(plugin_name, fmt('checking out %s...', plugin.commit)) - r:and_then(await, jobs.run(config.exec_cmd .. fmt(config.subcommands.checkout, plugin.commit), installer_opts)) + local commit = plugin.commit or get_lockfile_info(plugin).commit + if commit then + disp:task_update(plugin_name, fmt('checking out %s...', commit)) + r:and_then(await, jobs.run(config.exec_cmd .. fmt(config.subcommands.checkout, commit), installer_opts)) :map_err(function(err) return { - msg = fmt('Error checking out commit %s for %s', plugin.commit, plugin_name), + msg = fmt('Error checking out commit %s for %s', commit, plugin_name), data = { err, output }, } end) @@ -302,7 +342,7 @@ git.setup = function(plugin) plugin.updater = function(disp, opts) return async(function() - local update_info = { err = {}, revs = {}, output = {}, messages = {} } + local update_info = { err = {}, revs = {}, output = {}, messages = {}, ahead_behind = {} } local function exit_ok(r) if #update_info.err > 0 or r.exit_code ~= 0 then return result.err(r) @@ -380,7 +420,10 @@ git.setup = function(plugin) options = { env = git.job_env }, } - if needs_checkout then + local commit = plugin.commit or get_lockfile_info(plugin).commit + local update_cmd = commit and fetch_cmd or pull_cmd + + if needs_checkout or commit then r:and_then(await, jobs.run(config.exec_cmd .. config.subcommands.fetch, update_opts)) r:and_then(await, handle_checkouts(plugin, install_to, disp, opts)) local function merge_output(res) @@ -454,6 +497,36 @@ git.setup = function(plugin) local commit_headers_onread = jobs.logging_callback(update_info.err, update_info.messages) local commit_headers_callbacks = { stdout = commit_headers_onread, stderr = commit_headers_onread } + local ahead_behind_onread = jobs.logging_callback(update_info.err, update_info.ahead_behind) + local ahead_behind_callbacks = { stdout = ahead_behind_onread, stderr = ahead_behind_onread } + local ahead_behind_cmd = config.exec_cmd .. config.subcommands.commit_count + + local ahead_cmd = fmt(ahead_behind_cmd, update_info.revs[1], update_info.revs[2]) + local behind_cmd = fmt(ahead_behind_cmd, update_info.revs[2], update_info.revs[1]) + + r:and_then( + await, + jobs.run(ahead_cmd, { + success_test = exit_ok, + capture_output = ahead_behind_callbacks, + cwd = install_to, + options = { env = git.job_env }, + }) + ) + + r:and_then( + await, + jobs.run(behind_cmd, { + success_test = exit_ok, + capture_output = ahead_behind_callbacks, + cwd = install_to, + options = { env = git.job_env }, + }) + ) + + update_info.ahead_behind[1] = tonumber(update_info.ahead_behind[1]) + update_info.ahead_behind[2] = tonumber(update_info.ahead_behind[2]) + local diff_cmd = string.format(config.subcommands.diff, update_info.revs[1], update_info.revs[2]) local commit_headers_cmd = vim.split(config.exec_cmd .. diff_cmd, '%s+') for i, arg in ipairs(commit_headers_cmd) do @@ -475,6 +548,7 @@ git.setup = function(plugin) if r.ok then plugin.messages = update_info.messages plugin.revs = update_info.revs + plugin.ahead_behind = update_info.ahead_behind end if config.mark_breaking_changes then @@ -560,6 +634,12 @@ git.setup = function(plugin) plugin.get_rev = function() return get_rev(plugin) end + + ---Returns HEAD's date + ---@return string + plugin.get_date = function() + return get_date(plugin) + end end return git diff --git a/lua/packer/util.lua b/lua/packer/util.lua index a582b014..f9500501 100644 --- a/lua/packer/util.lua +++ b/lua/packer/util.lua @@ -64,6 +64,44 @@ util.join_paths = function(...) return table.concat({ ... }, separator) end +util.path_complete = function(lead) + local split = vim.split(lead, '=') + local command, path = split[1] .. '=', split[2] + if #path == 0 then + path = '.' + end + path = vim.fs.normalize(path) + + local completion_list = {} + local is_dir = vim.fn.isdirectory(path) == 1 + local dirpath = is_dir and path or vim.fs.dirname(path) + local filepath = not is_dir and vim.fs.basename(path) + + local sep = util.get_separator() + local dir = vim.loop.fs_opendir(dirpath) + + local function join(d, f) + return d:sub(#d, #d) == sep and d .. f or d .. sep .. f + end + + local res = vim.loop.fs_readdir(dir) + while res ~= nil do + for _, entry in ipairs(res) do + if filepath then + if vim.startswith(entry.name, filepath) then + completion_list[#completion_list + 1] = command .. join(dirpath, entry.name) + end + else + completion_list[#completion_list + 1] = command .. join(dirpath, entry.name) + end + end + res = vim.loop.fs_readdir(dir) + end + + vim.loop.fs_closedir(dir) + return completion_list +end + util.get_plugin_short_name = function(plugin) local path = vim.fn.expand(plugin[1]) local name_segments = vim.split(path, util.get_separator()) diff --git a/tests/snapshot_spec.lua b/tests/snapshot_spec.lua index 573b60d8..b7ad001c 100644 --- a/tests/snapshot_spec.lua +++ b/tests/snapshot_spec.lua @@ -69,6 +69,11 @@ local config = { prompt_border = 'double', keybindings = { quit = 'q', toggle_info = '', diff = 'd', prompt_revert = 'r' }, }, + lockfile = { + enable = false, + path = join_paths(stdpath 'config', 'lockfile.lua'), + regen_on_update = false, + }, luarocks = { python_cmd = 'python' }, log = { level = 'trace' }, profile = { enable = false },