Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5a260ad25 | |||
| c06ac229af | |||
| 3716131e56 | |||
| 3c73a83602 | |||
| 460dc2c5c8 |
@@ -1,15 +1,15 @@
|
||||
# arti-pack
|
||||
|
||||
A Neovim plugin manager that installs plugins from an artifact repository
|
||||
instead of git. It downloads tagged upstream source archives over HTTP and
|
||||
unpacks them into Neovim's native package path — see `:h packages`.
|
||||
instead of git. It downloads upstream source archives over HTTP — a tag for
|
||||
anything that publishes one, a branch for anything that does not — and unpacks
|
||||
them into Neovim's native package path — see `:h packages`.
|
||||
|
||||
It works with any HTTP artifact repository that serves an upstream tag archive
|
||||
at a stable URL — a JFrog Artifactory remote GitHub repository, or any caching
|
||||
proxy with the same path shape. Nothing is cloned, no git binary is needed, and
|
||||
the editor never has to reach GitHub: every plugin arrives as a cached,
|
||||
immutable, versioned artifact from the same store your builds already pull
|
||||
from.
|
||||
It works with any HTTP artifact repository that serves upstream tag and branch
|
||||
archives at a stable URL — a JFrog Artifactory remote GitHub repository, or any
|
||||
caching proxy with the same path shape. Nothing is cloned, no git binary is
|
||||
needed, and the editor never has to reach GitHub: every plugin arrives as a
|
||||
cached artifact from the same store your builds already pull from.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -55,6 +55,7 @@ require('arti-pack').setup({
|
||||
{ 'folke/which-key.nvim', tag = 'v3.13.3', opt = true },
|
||||
{ 'folke/tokyonight.nvim', tag = 'v4.10.0', as = 'tokyonight' },
|
||||
{ 'tpope/vim-fugitive', tag = 'v3.7', ext = 'tar.gz' },
|
||||
{ 'hrsh7th/cmp-path', branch = 'main' },
|
||||
},
|
||||
})
|
||||
```
|
||||
@@ -72,20 +73,30 @@ whitespace is trimmed and anything else is rejected. Until you set it,
|
||||
`:ArtiPackClean` and `:ArtiPackList` never resolve a URL, so they work without
|
||||
it, as do plugins that carry an explicit `url`.
|
||||
|
||||
Every plugin needs an explicit `tag` — that is the point. Plugins install to
|
||||
Every plugin names an explicit ref — either a `tag` or a `branch`, never both
|
||||
and never neither. Plugins install to
|
||||
`stdpath('data')/site/pack/arti-pack/start/<name>`, or `.../opt/<name>` when
|
||||
`opt = true`.
|
||||
|
||||
### Spec fields
|
||||
|
||||
| Field | Default | Meaning |
|
||||
| ------ | ---------- | ---------------------------------------------------- |
|
||||
| `[1]` | *required* | `owner/repo` |
|
||||
| `tag` | *required* | Upstream tag, used verbatim in the archive path |
|
||||
| `opt` | `false` | Install under `opt/` for `:packadd` instead of `start/` |
|
||||
| `as` | repo name | Directory name to install as |
|
||||
| `ext` | `'zip'` | `zip` or `tar.gz` |
|
||||
| `url` | — | Full archive URL, bypassing the registry (no whitespace) |
|
||||
| Field | Default | Meaning |
|
||||
| -------- | ---------- | ---------------------------------------------------- |
|
||||
| `[1]` | *required* | `owner/repo` |
|
||||
| `tag` | *one of* | Upstream tag, used verbatim in the archive path |
|
||||
| `branch` | *one of* | Upstream branch to track, used verbatim in the archive path |
|
||||
| `opt` | `false` | Install under `opt/` for `:packadd` instead of `start/` |
|
||||
| `as` | repo name | Directory name to install as |
|
||||
| `ext` | `'zip'` | `zip` or `tar.gz` |
|
||||
| `url` | — | Full archive URL, bypassing the registry (no whitespace) |
|
||||
|
||||
`tag` and `branch` are mutually exclusive: a spec setting both, or neither, is
|
||||
rejected with an error naming the plugin and nothing is installed for it. Both
|
||||
are one path segment of the archive URL, so both are trimmed of surrounding
|
||||
whitespace and rejected if they are `.` or `..`, or contain inner whitespace,
|
||||
`/`, `\`, or any of `#`, `?`, `%`, `[`, `]`, `{`, `}` — each of those would
|
||||
make curl fetch something other than the ref you named. A branch whose name
|
||||
contains `/` therefore needs an explicit `url`.
|
||||
|
||||
### Options
|
||||
|
||||
@@ -98,32 +109,70 @@ Every plugin needs an explicit `tag` — that is the point. Plugins install to
|
||||
|
||||
### Commands
|
||||
|
||||
| Command | Effect |
|
||||
| ---------------- | ---------------------------------------------------------- |
|
||||
| `:ArtiPackSync` | Install missing plugins and update ones whose tag changed |
|
||||
| `:ArtiPackSync!` | Reinstall everything, ignoring recorded state |
|
||||
| `:ArtiPackClean` | Remove installed plugins no longer in the spec |
|
||||
| `:ArtiPackList` | Show every configured plugin and its state |
|
||||
| Command | Effect |
|
||||
| ----------------- | ---------------------------------------------------------- |
|
||||
| `:ArtiPackSync` | Install missing plugins and apply changed refs |
|
||||
| `:ArtiPackSync!` | Reinstall everything, ignoring recorded state |
|
||||
| `:ArtiPackUpdate` | Refetch every branch-tracking plugin |
|
||||
| `:ArtiPackClean` | Remove installed plugins no longer in the spec |
|
||||
| `:ArtiPackList` | Show every configured plugin and its state |
|
||||
|
||||
## Tracking a branch
|
||||
|
||||
Plenty of upstreams publish no tags at all, so a `branch` spec installs from the
|
||||
branch archive instead. A branch is mutable, and that changes when arti-pack
|
||||
fetches:
|
||||
|
||||
- `:ArtiPackSync` installs a branch plugin that is missing, that moved between
|
||||
`start` and `opt`, or whose `branch` you changed — and otherwise leaves it
|
||||
alone. It says how many branch plugins it did not recheck. Startup cost and
|
||||
the load on your artifact store stay the same as a tag-only setup, and your
|
||||
editor does not change under you on a restart.
|
||||
- `:ArtiPackUpdate` refetches every branch plugin, whether or not upstream
|
||||
moved. This is the only command that goes looking for new branch content.
|
||||
- `:ArtiPackSync!` still reinstalls everything.
|
||||
|
||||
Branch archives are never served from the local archive cache, so an update
|
||||
always sees what the store has now.
|
||||
|
||||
The lockfile records the branch name, a digest of the archive that was actually
|
||||
installed, and when it was fetched — never a version, because a branch is not
|
||||
one. `:ArtiPackList` reports that instead of guessing:
|
||||
|
||||
```
|
||||
hrsh7th/cmp-path @main start tracking branch main, fetched 2026-09-08 10:14 (7db8f64a)
|
||||
nvim-lua/plenary.nvim v0.1.4 start installed
|
||||
```
|
||||
|
||||
The digest is a content identity, not a file checksum: Neovim's `sha256()`
|
||||
refuses binary input, so it hashes the archive's base64 form. It will not match
|
||||
`sha256sum <archive>`.
|
||||
|
||||
A branch plugin is never reported as up to date: nothing on disk can know
|
||||
whether upstream has moved. After an update, an unchanged digest is reported as
|
||||
`unchanged` rather than as an update that changed nothing.
|
||||
|
||||
## How it resolves an archive
|
||||
|
||||
```
|
||||
<registry>/<owner>/<repo>/archive/refs/tags/<tag>.<ext>
|
||||
<registry>/<owner>/<repo>/archive/refs/heads/<branch>.<ext>
|
||||
```
|
||||
|
||||
which for the example above is
|
||||
which for the examples above are
|
||||
|
||||
```
|
||||
https://artifacts.example.com/artifactory/github-remote/nvim-lua/plenary.nvim/archive/refs/tags/v0.1.4.zip
|
||||
https://artifacts.example.com/artifactory/github-remote/hrsh7th/cmp-path/archive/refs/heads/main.zip
|
||||
```
|
||||
|
||||
That is GitHub's own tag-archive path shape, so a remote repository proxying
|
||||
That is GitHub's own archive path shape, so a remote repository proxying
|
||||
`github.com` resolves it unchanged.
|
||||
|
||||
Archives are cached under `stdpath('cache')/arti-pack`, checked for a real zip
|
||||
or gzip magic header before being trusted, and unpacked into a staging
|
||||
directory. Tag archives wrap their contents in a single `<repo>-<version>`
|
||||
directory, which is stripped on the way in. Installed tags are recorded in
|
||||
directory. Source archives wrap their contents in a single `<repo>-<ref>`
|
||||
directory, which is stripped on the way in. Installs are recorded in
|
||||
`stdpath('data')/arti-pack/lock.json`, so a sync with nothing to do costs
|
||||
nothing. Helptags are regenerated per plugin after install.
|
||||
|
||||
|
||||
+72
-20
@@ -6,19 +6,21 @@ CONTENTS *arti-pack-contents*
|
||||
1. Introduction ......................... |arti-pack-introduction|
|
||||
2. Setup ................................ |arti-pack-setup|
|
||||
3. Plugin specs ......................... |arti-pack-specs|
|
||||
4. Commands ............................. |arti-pack-commands|
|
||||
5. Layout ............................... |arti-pack-layout|
|
||||
4. Branch tracking ...................... |arti-pack-branches|
|
||||
5. Commands ............................. |arti-pack-commands|
|
||||
6. Layout ............................... |arti-pack-layout|
|
||||
|
||||
==============================================================================
|
||||
1. INTRODUCTION *arti-pack-introduction*
|
||||
|
||||
arti-pack installs plugins from tagged upstream source archives served over
|
||||
HTTP by an artifact repository, and unpacks them into Neovim's native package
|
||||
path. See |packages| for how Neovim loads what arti-pack writes.
|
||||
arti-pack installs plugins from upstream source archives served over HTTP by an
|
||||
artifact repository — a tag for anything that publishes one, a branch for
|
||||
anything that does not — and unpacks them into Neovim's native package path.
|
||||
See |packages| for how Neovim loads what arti-pack writes.
|
||||
|
||||
Any artifact repository that serves an upstream tag archive at a stable URL
|
||||
works — a JFrog Artifactory remote GitHub repository, or any caching proxy
|
||||
with the same path shape. Nothing is cloned and the editor never reaches
|
||||
Any artifact repository that serves upstream tag and branch archives at a
|
||||
stable URL works — a JFrog Artifactory remote GitHub repository, or any caching
|
||||
proxy with the same path shape. Nothing is cloned and the editor never reaches
|
||||
GitHub itself.
|
||||
|
||||
Requires curl. Zip and tar.gz archives are unpacked in pure Lua, so no
|
||||
@@ -35,6 +37,7 @@ entry's CRC32 is checked before it lands on disk.
|
||||
plugins = {
|
||||
{ 'nvim-lua/plenary.nvim', tag = 'v0.1.4' },
|
||||
{ 'folke/which-key.nvim', tag = 'v3.13.3', opt = true },
|
||||
{ 'hrsh7th/cmp-path', branch = 'main' },
|
||||
},
|
||||
})
|
||||
<
|
||||
@@ -59,23 +62,72 @@ Options:
|
||||
|
||||
Each entry is a table whose first element is "owner/repo".
|
||||
|
||||
tag Required. Upstream tag, used verbatim in the archive path.
|
||||
opt Install under opt/ for |:packadd| instead of start/. Default false.
|
||||
as Directory name to install as. Defaults to the repo name.
|
||||
ext "zip" or "tar.gz". Default "zip".
|
||||
url Full archive URL, bypassing the registry. Trimmed; it may not
|
||||
contain whitespace.
|
||||
tag Upstream tag, used verbatim in the archive path.
|
||||
branch Upstream branch to track, used verbatim in the archive path.
|
||||
See |arti-pack-branches|.
|
||||
opt Install under opt/ for |:packadd| instead of start/. Default false.
|
||||
as Directory name to install as. Defaults to the repo name.
|
||||
ext "zip" or "tar.gz". Default "zip".
|
||||
url Full archive URL, bypassing the registry. Trimmed; it may not
|
||||
contain whitespace.
|
||||
|
||||
Exactly one of "tag" and "branch" is required. A spec that sets both, or
|
||||
neither, is rejected with an error naming the plugin, and nothing is installed
|
||||
for it. Both are one path segment of the archive URL, so both are trimmed of
|
||||
surrounding whitespace and rejected if they are "." or "..", or contain inner
|
||||
whitespace, "/", "\", or any of "#", "?", "%", "[", "]", "{", "}" — each of
|
||||
those would make curl fetch something other than the ref you named. A branch
|
||||
name containing "/" therefore needs an explicit "url".
|
||||
|
||||
The archive URL is built as: >
|
||||
<registry>/<owner>/<repo>/archive/refs/tags/<tag>.<ext>
|
||||
<registry>/<owner>/<repo>/archive/refs/heads/<branch>.<ext>
|
||||
<
|
||||
==============================================================================
|
||||
4. COMMANDS *arti-pack-commands*
|
||||
4. BRANCH TRACKING *arti-pack-branches*
|
||||
|
||||
Plenty of upstreams publish no tags at all, so a "branch" spec installs from
|
||||
the branch archive instead. A tag is immutable; a branch is not, so a branch
|
||||
plugin is refetched only when asked for:
|
||||
|
||||
|:ArtiPackSync| installs a branch plugin that is missing, that moved
|
||||
between start/ and opt/, or whose "branch" changed, and
|
||||
otherwise leaves it alone. It reports how many branch
|
||||
plugins it did not recheck. Startup cost and load on the
|
||||
artifact store stay as they are for tags, and the editor
|
||||
does not change under you on a restart.
|
||||
|:ArtiPackUpdate| refetches every branch plugin, whether or not upstream
|
||||
moved. Nothing else goes looking for new branch content.
|
||||
|:ArtiPackSync|! still reinstalls everything.
|
||||
|
||||
Branch archives are never served from the local archive cache, so an update
|
||||
always sees what the store has now.
|
||||
|
||||
The lockfile records the branch name, a digest of the archive that was actually
|
||||
installed, and when it was fetched — never a version, since a branch is not
|
||||
one. |:ArtiPackList| reports that rather than guessing: >
|
||||
hrsh7th/cmp-path @main start tracking branch main, fetched
|
||||
2026-09-08 10:14 (7db8f64a)
|
||||
<
|
||||
The digest is a content identity, not a file checksum: Neovim's sha256()
|
||||
refuses binary input, so it hashes the archive's base64 form. It will not
|
||||
match sha256sum on the archive.
|
||||
|
||||
A branch plugin is never reported as up to date, because nothing on disk can
|
||||
know whether upstream has moved. An update that brings back the same digest is
|
||||
reported as unchanged, not as an update.
|
||||
|
||||
==============================================================================
|
||||
5. COMMANDS *arti-pack-commands*
|
||||
|
||||
*:ArtiPackSync*
|
||||
:ArtiPackSync Install missing plugins and update ones whose tag changed.
|
||||
:ArtiPackSync Install missing plugins and apply changed refs.
|
||||
:ArtiPackSync! Reinstall every configured plugin, ignoring recorded state.
|
||||
|
||||
*:ArtiPackUpdate*
|
||||
:ArtiPackUpdate Refetch every branch-tracking plugin. See
|
||||
|arti-pack-branches|.
|
||||
|
||||
*:ArtiPackClean*
|
||||
:ArtiPackClean Remove installed plugins that are no longer in the spec.
|
||||
|
||||
@@ -83,16 +135,16 @@ The archive URL is built as: >
|
||||
:ArtiPackList Show every configured plugin and its state.
|
||||
|
||||
==============================================================================
|
||||
5. LAYOUT *arti-pack-layout*
|
||||
6. LAYOUT *arti-pack-layout*
|
||||
|
||||
stdpath('data')/site/pack/arti-pack/start/<name> loaded at startup
|
||||
stdpath('data')/site/pack/arti-pack/opt/<name> loaded by |:packadd|
|
||||
stdpath('data')/arti-pack/lock.json recorded tags
|
||||
stdpath('data')/arti-pack/lock.json recorded installs
|
||||
stdpath('cache')/arti-pack downloaded archives
|
||||
|
||||
Downloads are checked for a zip or gzip magic header before being trusted, and
|
||||
the single "<repo>-<version>" directory that tag archives wrap their contents
|
||||
in is stripped on the way in. A plugin that fails is reported and skipped; the
|
||||
the single "<repo>-<ref>" directory that source archives wrap their contents in
|
||||
is stripped on the way in. A plugin that fails is reported and skipped; the
|
||||
rest of the run continues.
|
||||
|
||||
vim:tw=78:ts=8:noet:ft=help:norl:
|
||||
|
||||
@@ -60,6 +60,23 @@ function M.read_head(path, bytes)
|
||||
return head
|
||||
end
|
||||
|
||||
-- Identifies what was actually fetched. A branch name says nothing about
|
||||
-- content, so the digest is what makes a branch install reportable. Archive
|
||||
-- bytes reach sha256() as a Blob, which it refuses, so they are hashed in their
|
||||
-- base64 form: an identity for the content, not the file's sha256sum.
|
||||
function M.digest(path)
|
||||
local fd = io.open(path, 'rb')
|
||||
if not fd then
|
||||
return nil
|
||||
end
|
||||
local data = fd:read('*a')
|
||||
fd:close()
|
||||
if not data then
|
||||
return nil
|
||||
end
|
||||
return vim.fn.sha256(vim.base64.encode(data))
|
||||
end
|
||||
|
||||
function M.verify_archive(path, ext)
|
||||
local stat = uv.fs_stat(path)
|
||||
if not stat or stat.size == 0 then
|
||||
|
||||
+58
-10
@@ -59,7 +59,7 @@ local function installed_dir(ctx, name, opt)
|
||||
end
|
||||
|
||||
local function finalise(ctx, results)
|
||||
local counts = { installed = 0, updated = 0, failed = 0 }
|
||||
local counts = { installed = 0, updated = 0, unchanged = 0, failed = 0 }
|
||||
|
||||
for _, res in ipairs(results) do
|
||||
counts[res.status] = (counts[res.status] or 0) + 1
|
||||
@@ -85,7 +85,7 @@ local function finalise(ctx, results)
|
||||
end
|
||||
|
||||
local parts = {}
|
||||
for _, key in ipairs({ 'installed', 'updated', 'failed' }) do
|
||||
for _, key in ipairs({ 'installed', 'updated', 'unchanged', 'failed' }) do
|
||||
if counts[key] > 0 then
|
||||
table.insert(parts, string.format('%d %s', counts[key], key))
|
||||
end
|
||||
@@ -98,6 +98,25 @@ local function finalise(ctx, results)
|
||||
end
|
||||
end
|
||||
|
||||
-- A branch install is only as fresh as the last fetch, and a plain sync does not
|
||||
-- fetch it again, so an idle run says what it did not check instead of claiming
|
||||
-- everything is current.
|
||||
local function idle_message(specs)
|
||||
local branches = 0
|
||||
for _, spec in ipairs(specs) do
|
||||
if spec.branch then
|
||||
branches = branches + 1
|
||||
end
|
||||
end
|
||||
if branches == 0 then
|
||||
return 'everything up to date'
|
||||
end
|
||||
return string.format(
|
||||
'nothing to install; %d branch plugin(s) not rechecked, :ArtiPackUpdate refetches them',
|
||||
branches
|
||||
)
|
||||
end
|
||||
|
||||
function M.sync(opts)
|
||||
opts = opts or {}
|
||||
if running then
|
||||
@@ -112,14 +131,18 @@ function M.sync(opts)
|
||||
local ctx = context()
|
||||
local pending = {}
|
||||
for _, spec in ipairs(specs) do
|
||||
local current = ctx.lock:is_current(spec) and fs.exists(installed_dir(ctx, spec.name, spec.opt))
|
||||
if opts.force or not current then
|
||||
local wanted = ctx.lock:needs_install(spec, {
|
||||
force = opts.force,
|
||||
update = opts.update,
|
||||
present = fs.exists(installed_dir(ctx, spec.name, spec.opt)),
|
||||
})
|
||||
if wanted then
|
||||
table.insert(pending, spec)
|
||||
end
|
||||
end
|
||||
|
||||
if #pending == 0 then
|
||||
return notify('everything up to date')
|
||||
return notify(opts.update and 'no branch-tracking plugins to update' or idle_message(specs))
|
||||
end
|
||||
|
||||
local reg_err = registry_error(pending, ctx.registry)
|
||||
@@ -135,6 +158,11 @@ function M.sync(opts)
|
||||
end)
|
||||
end
|
||||
|
||||
-- Branch archives are mutable, so nothing refetches them implicitly.
|
||||
function M.update()
|
||||
return M.sync({ update = true })
|
||||
end
|
||||
|
||||
function M.clean()
|
||||
local specs, errors = spec_mod.parse_all(M.config.plugins)
|
||||
for _, err in ipairs(errors) do
|
||||
@@ -179,6 +207,24 @@ function M.clean()
|
||||
end
|
||||
end
|
||||
|
||||
local function locked_ref(entry)
|
||||
if entry.branch then
|
||||
return 'branch ' .. entry.branch
|
||||
end
|
||||
return tostring(entry.tag)
|
||||
end
|
||||
|
||||
local function fetched(entry)
|
||||
if type(entry.installed_at) ~= 'number' then
|
||||
return 'at an unknown time'
|
||||
end
|
||||
local stamp = os.date('%Y-%m-%d %H:%M', entry.installed_at)
|
||||
if type(entry.digest) == 'string' then
|
||||
return string.format('%s (%s)', stamp, entry.digest:sub(1, 8))
|
||||
end
|
||||
return stamp
|
||||
end
|
||||
|
||||
function M.list()
|
||||
local specs, errors = spec_mod.parse_all(M.config.plugins)
|
||||
local ctx = context()
|
||||
@@ -190,16 +236,18 @@ function M.list()
|
||||
if not entry then
|
||||
state = 'missing'
|
||||
elseif not ctx.lock:is_current(spec) then
|
||||
state = 'outdated (' .. tostring(entry.tag) .. ')'
|
||||
state = 'outdated (' .. locked_ref(entry) .. ')'
|
||||
elseif not fs.exists(installed_dir(ctx, spec.name, spec.opt)) then
|
||||
state = 'gone from disk'
|
||||
elseif spec.branch then
|
||||
-- A branch may have moved upstream since this was fetched and nothing here
|
||||
-- can know, so report what was installed and when, never "installed".
|
||||
state = string.format('tracking branch %s, fetched %s', spec.branch, fetched(entry))
|
||||
else
|
||||
state = 'installed'
|
||||
end
|
||||
table.insert(
|
||||
lines,
|
||||
string.format('%-32s %-14s %-5s %s', spec.source, spec.tag, spec.opt and 'opt' or 'start', state)
|
||||
)
|
||||
local ref = spec.branch and ('@' .. spec.branch) or spec.tag
|
||||
table.insert(lines, string.format('%-32s %-14s %-5s %s', spec.source, ref, spec.opt and 'opt' or 'start', state))
|
||||
end
|
||||
|
||||
for _, name in ipairs(ctx.lock:names()) do
|
||||
|
||||
@@ -4,7 +4,14 @@ local spec_mod = require('arti-pack.spec')
|
||||
local M = {}
|
||||
|
||||
local function result(spec, status, err)
|
||||
return { name = spec.name, source = spec.source, tag = spec.tag, status = status, err = err }
|
||||
return {
|
||||
name = spec.name,
|
||||
source = spec.source,
|
||||
tag = spec.tag,
|
||||
branch = spec.branch,
|
||||
status = status,
|
||||
err = err,
|
||||
}
|
||||
end
|
||||
|
||||
local function target_dir(ctx, spec)
|
||||
@@ -29,6 +36,7 @@ end
|
||||
local function place(ctx, spec, stage, done)
|
||||
local root = fs.archive_root(stage)
|
||||
local target = target_dir(ctx, spec)
|
||||
local had_target = fs.exists(target)
|
||||
|
||||
fs.mkdirp(vim.fn.fnamemodify(target, ':h'))
|
||||
fs.rm_rf(target)
|
||||
@@ -43,6 +51,8 @@ local function place(ctx, spec, stage, done)
|
||||
local previous = ctx.lock:get(spec.name)
|
||||
ctx.lock:set(spec.name, {
|
||||
tag = spec.tag,
|
||||
branch = spec.branch,
|
||||
digest = spec.digest,
|
||||
opt = spec.opt,
|
||||
source = spec.source,
|
||||
ext = spec.ext,
|
||||
@@ -50,10 +60,22 @@ local function place(ctx, spec, stage, done)
|
||||
installed_at = os.time(),
|
||||
})
|
||||
|
||||
done(result(spec, previous and 'updated' or 'installed'))
|
||||
-- Refetching a branch usually lands the same bytes; saying so is more useful
|
||||
-- than reporting an update that changed nothing.
|
||||
local status = 'installed'
|
||||
if previous then
|
||||
local same = had_target and spec.digest ~= nil and previous.digest == spec.digest
|
||||
status = same and 'unchanged' or 'updated'
|
||||
end
|
||||
|
||||
done(result(spec, status))
|
||||
end
|
||||
|
||||
local function extract(ctx, spec, archive, done)
|
||||
if spec.branch then
|
||||
spec.digest = fs.digest(archive)
|
||||
end
|
||||
|
||||
local stage = fs.join(ctx.pack_dir, '.stage', spec.name)
|
||||
fs.rm_rf(stage)
|
||||
fs.mkdirp(stage)
|
||||
@@ -75,7 +97,9 @@ local function download(ctx, spec, archive, done)
|
||||
end
|
||||
spec.resolved_url = url
|
||||
|
||||
if fs.exists(archive) and fs.verify_archive(archive, spec.ext) then
|
||||
-- Only a tag archive is safe to serve from cache: the bytes behind a branch
|
||||
-- archive change, so a cached one would make every update a no-op.
|
||||
if not spec.branch and fs.exists(archive) and fs.verify_archive(archive, spec.ext) then
|
||||
return extract(ctx, spec, archive, done)
|
||||
end
|
||||
|
||||
@@ -92,6 +116,10 @@ local function download(ctx, spec, archive, done)
|
||||
tostring(ctx.timeout),
|
||||
'-o',
|
||||
tmp,
|
||||
-- curl reads a leading-dash positional as an option, so a spec url of
|
||||
-- "--stderr=/path" or "--config=/path" would run as a flag instead of
|
||||
-- being fetched. "--" ends option parsing.
|
||||
'--',
|
||||
url,
|
||||
}
|
||||
|
||||
@@ -120,14 +148,16 @@ local function download(ctx, spec, archive, done)
|
||||
end
|
||||
|
||||
function M.install(ctx, spec, done)
|
||||
local archive = fs.join(ctx.cache_dir, string.format('%s-%s.%s', spec.name, spec.tag, spec.ext))
|
||||
local slot = spec.branch and string.format('%s-heads-%s', spec.name, spec.branch)
|
||||
or string.format('%s-%s', spec.name, spec.tag)
|
||||
local archive = fs.join(ctx.cache_dir, string.format('%s.%s', slot, spec.ext))
|
||||
local ok, err = pcall(download, ctx, spec, archive, done)
|
||||
if not ok then
|
||||
done(result(spec, 'failed', tostring(err)))
|
||||
end
|
||||
end
|
||||
|
||||
-- Failures are per plugin: one bad tag must not abandon the rest of the run.
|
||||
-- Failures are per plugin: one bad ref must not abandon the rest of the run.
|
||||
function M.run(ctx, specs, on_done)
|
||||
local results, started, finished = {}, 0, 0
|
||||
local limit = math.max(1, ctx.concurrency or 4)
|
||||
|
||||
+25
-2
@@ -74,14 +74,37 @@ function M:names()
|
||||
return names
|
||||
end
|
||||
|
||||
-- A plugin is current when the recorded tag and load style both still match the
|
||||
-- A plugin is current when the recorded ref and load style both still match the
|
||||
-- spec; a flipped opt flag has to move the directory, so it counts as changed.
|
||||
-- For a branch this answers "are we tracking the ref you asked for", not "is the
|
||||
-- installed content still what that branch points at": a branch moves upstream
|
||||
-- and nothing on disk can tell us that it has.
|
||||
function M:is_current(spec)
|
||||
local entry = self.entries[spec.name]
|
||||
if not entry then
|
||||
return false
|
||||
end
|
||||
return entry.tag == spec.tag and (entry.opt == true) == (spec.opt == true)
|
||||
if (entry.opt == true) ~= (spec.opt == true) then
|
||||
return false
|
||||
end
|
||||
if spec.branch then
|
||||
return entry.branch == spec.branch
|
||||
end
|
||||
return entry.tag == spec.tag and entry.branch == nil
|
||||
end
|
||||
|
||||
-- The whole update policy, in one place. A tag is immutable, so a matching entry
|
||||
-- with its directory still on disk needs no work. A branch is mutable, so it is
|
||||
-- refetched only when asked for: a forced sync, or an update run.
|
||||
function M:needs_install(spec, opts)
|
||||
opts = opts or {}
|
||||
if opts.force then
|
||||
return true
|
||||
end
|
||||
if not (self:is_current(spec) and opts.present) then
|
||||
return true
|
||||
end
|
||||
return opts.update == true and spec.branch ~= nil
|
||||
end
|
||||
|
||||
return M
|
||||
|
||||
+46
-12
@@ -9,8 +9,20 @@ local function trim(s)
|
||||
return (s:gsub('^%s+', ''):gsub('%s+$', ''))
|
||||
end
|
||||
|
||||
-- vim.system hands argv to the OS as C strings, so a NUL truncates the value
|
||||
-- before curl sees it: the URL fetched would not be the one validated, locked
|
||||
-- and shown. Lua's %s class does not cover it, so it needs its own check.
|
||||
local function has_nul(s)
|
||||
return s:find('\0', 1, true) ~= nil
|
||||
end
|
||||
|
||||
-- A ref is spliced into the archive URL verbatim, so a character that is
|
||||
-- significant to a URL makes the fetched path differ from the validated one:
|
||||
-- "#" truncates at the fragment, "?" starts a query, "%" is decoded by the
|
||||
-- server ("%2F" reopens the traversal "/" already blocks), and curl expands
|
||||
-- "[]" and "{}" as globs into other URLs entirely.
|
||||
local function bad_name(name)
|
||||
return name == '' or name == '.' or name == '..' or name:find('[/\\%s]') ~= nil
|
||||
return name == '' or name == '.' or name == '..' or name:find('[/\\%s#?%%%[%]{}]') ~= nil
|
||||
end
|
||||
|
||||
function M.parse(entry)
|
||||
@@ -32,13 +44,23 @@ function M.parse(entry)
|
||||
return nil, string.format('invalid source %q, expected "owner/repo"', source)
|
||||
end
|
||||
|
||||
local tag = entry.tag
|
||||
if type(tag) ~= 'string' or trim(tag) == '' then
|
||||
return nil, string.format('%s is missing a tag', source)
|
||||
if entry.tag ~= nil and entry.branch ~= nil then
|
||||
return nil, string.format('%s sets both a tag and a branch, pick one', source)
|
||||
end
|
||||
tag = trim(tag)
|
||||
if tag:find('[/\\%s]') then
|
||||
return nil, string.format('%s has an invalid tag %q', source, tag)
|
||||
|
||||
-- A ref is one path segment of the archive URL, so the same rules that keep a
|
||||
-- directory name from escaping its parent apply to it.
|
||||
local kind = entry.branch ~= nil and 'branch' or 'tag'
|
||||
local ref = entry.branch ~= nil and entry.branch or entry.tag
|
||||
if type(ref) ~= 'string' or trim(ref) == '' then
|
||||
return nil, string.format('%s is missing a tag or a branch', source)
|
||||
end
|
||||
ref = trim(ref)
|
||||
if has_nul(ref) then
|
||||
return nil, string.format('%s has a NUL byte in its %s %s', source, kind, vim.inspect(ref))
|
||||
end
|
||||
if bad_name(ref) then
|
||||
return nil, string.format('%s has an invalid %s %q', source, kind, ref)
|
||||
end
|
||||
|
||||
local ext = entry.ext or 'zip'
|
||||
@@ -47,6 +69,9 @@ function M.parse(entry)
|
||||
end
|
||||
|
||||
local name = entry.as or repo
|
||||
if type(name) == 'string' and has_nul(name) then
|
||||
return nil, string.format('%s has a NUL byte in its directory name %s', source, vim.inspect(name))
|
||||
end
|
||||
if type(name) ~= 'string' or bad_name(name) then
|
||||
return nil, string.format('%s resolves to an unusable directory name %q', source, tostring(name))
|
||||
end
|
||||
@@ -57,6 +82,9 @@ function M.parse(entry)
|
||||
return nil, string.format('%s has a non-string url', source)
|
||||
end
|
||||
url = trim(url)
|
||||
if has_nul(url) then
|
||||
return nil, string.format('%s has a NUL byte in its url %s', source, vim.inspect(url))
|
||||
end
|
||||
if url == '' or url:find('%s') then
|
||||
return nil, string.format('%s has an invalid url %q', source, entry.url)
|
||||
end
|
||||
@@ -67,7 +95,9 @@ function M.parse(entry)
|
||||
owner = owner,
|
||||
repo = repo,
|
||||
name = name,
|
||||
tag = tag,
|
||||
tag = kind == 'tag' and ref or nil,
|
||||
branch = kind == 'branch' and ref or nil,
|
||||
ref = ref,
|
||||
ext = ext,
|
||||
opt = entry.opt == true,
|
||||
url = url,
|
||||
@@ -87,14 +117,18 @@ function M.check_registry(registry)
|
||||
end
|
||||
-- Whitespace left inside the URL would be baked into every download.
|
||||
local base = trim(registry)
|
||||
if has_nul(base) then
|
||||
return nil, string.format('registry %s has a NUL byte in it; %s', vim.inspect(registry), SETUP_HINT)
|
||||
end
|
||||
if not base:match('^https?://[^%s/]') or base:find('%s') then
|
||||
return nil, bad
|
||||
end
|
||||
return (base:gsub('/+$', ''))
|
||||
end
|
||||
|
||||
-- Tagged source archives live at the same path shape on every GitHub-alike, so
|
||||
-- the registry is just the proxy prefix in front of "<owner>/<repo>".
|
||||
-- Tag and branch source archives live at the same path shape on every
|
||||
-- GitHub-alike, so the registry is just the proxy prefix in front of
|
||||
-- "<owner>/<repo>".
|
||||
function M.url(spec, registry)
|
||||
if spec.url then
|
||||
return spec.url
|
||||
@@ -109,8 +143,8 @@ function M.url(spec, registry)
|
||||
spec.repo,
|
||||
'archive',
|
||||
'refs',
|
||||
'tags',
|
||||
spec.tag .. '.' .. spec.ext,
|
||||
spec.branch and 'heads' or 'tags',
|
||||
spec.ref .. '.' .. spec.ext,
|
||||
}, '/')
|
||||
end
|
||||
|
||||
|
||||
@@ -9,7 +9,11 @@ end
|
||||
|
||||
vim.api.nvim_create_user_command('ArtiPackSync', function(cmd)
|
||||
arti().sync({ force = cmd.bang })
|
||||
end, { bang = true, desc = 'Install missing arti-pack plugins and update changed tags' })
|
||||
end, { bang = true, desc = 'Install missing arti-pack plugins and apply changed refs' })
|
||||
|
||||
vim.api.nvim_create_user_command('ArtiPackUpdate', function()
|
||||
arti().update()
|
||||
end, { desc = 'Refetch every branch-tracking plugin' })
|
||||
|
||||
vim.api.nvim_create_user_command('ArtiPackClean', function()
|
||||
arti().clean()
|
||||
|
||||
@@ -74,6 +74,27 @@ t.describe('fs.verify_archive', function()
|
||||
end)
|
||||
end)
|
||||
|
||||
t.describe('fs.digest', function()
|
||||
t.it('identifies file contents, not the file name', function()
|
||||
local dir = tmpdir()
|
||||
local a = write(dir .. '/a.zip', 'PK\3\4payload')
|
||||
local b = write(dir .. '/b.zip', 'PK\3\4payload')
|
||||
t.eq(fs.digest(a), fs.digest(b))
|
||||
t.eq(64, #fs.digest(a), 'a sha256 hex digest')
|
||||
end)
|
||||
|
||||
t.it('changes when the bytes change', function()
|
||||
local dir = tmpdir()
|
||||
local before = fs.digest(write(dir .. '/a.zip', 'PK\3\4one'))
|
||||
local after = fs.digest(write(dir .. '/a.zip', 'PK\3\4two'))
|
||||
t.truthy(before ~= after, 'a refetched archive with new contents digests differently')
|
||||
end)
|
||||
|
||||
t.it('returns nothing for a missing file', function()
|
||||
t.eq(nil, fs.digest(tmpdir() .. '/absent.zip'))
|
||||
end)
|
||||
end)
|
||||
|
||||
t.describe('fs.extract', function()
|
||||
t.it('reports a missing archive instead of raising', function()
|
||||
local ok, err = fs.extract(tmpdir() .. '/absent.zip', tmpdir(), 'zip')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
local t = require('tests.harness')
|
||||
local fs = require('arti-pack.fs')
|
||||
local Lock = require('arti-pack.lock')
|
||||
local zipfile = require('tests.zipfile')
|
||||
local arti = require('arti-pack')
|
||||
|
||||
local function tmpdir()
|
||||
@@ -87,11 +88,21 @@ local function install_fake(name, sub, tag)
|
||||
record(name, tag, sub == 'opt')
|
||||
end
|
||||
|
||||
local function install_branch_fake(name, branch, at)
|
||||
place_dir(name, 'start')
|
||||
local l = lock()
|
||||
l:set(name, { branch = branch, opt = false, digest = string.rep('b', 64), installed_at = at })
|
||||
assert(l:save())
|
||||
end
|
||||
|
||||
-- sync finishes asynchronously; its closing notification is the only signal.
|
||||
local function is_terminal(msg)
|
||||
return msg:find('everything up to date', 1, true) ~= nil
|
||||
or msg:find('nothing to install', 1, true) ~= nil
|
||||
or msg:find('no branch-tracking plugins', 1, true) ~= nil
|
||||
or msg:find('%d+ installed') ~= nil
|
||||
or msg:find('%d+ updated') ~= nil
|
||||
or msg:find('%d+ unchanged') ~= nil
|
||||
or msg:find('%d+ failed') ~= nil
|
||||
end
|
||||
|
||||
@@ -115,6 +126,12 @@ local function sync(opts)
|
||||
drain(before)
|
||||
end
|
||||
|
||||
local function update()
|
||||
local before = #env.notices
|
||||
arti.update()
|
||||
drain(before)
|
||||
end
|
||||
|
||||
local function build_archive(ext, repo, version)
|
||||
local work = tmpdir()
|
||||
local root = string.format('%s-%s', repo, version)
|
||||
@@ -353,6 +370,89 @@ t.describe('arti-pack.sync', function()
|
||||
end)
|
||||
end)
|
||||
|
||||
-- Branch fixtures are built in Lua, so these run with no zip or tar around.
|
||||
t.describe('arti-pack branch tracking', function()
|
||||
local function branch_entry(source, branch, archive)
|
||||
return { source, branch = branch, url = 'file://' .. archive }
|
||||
end
|
||||
|
||||
local function setup_branch(archive)
|
||||
arti.setup({ auto_sync = false, plugins = { branch_entry('hrsh7th/cmp-path', 'main', archive) } })
|
||||
end
|
||||
|
||||
t.it('installs a branch plugin and records the branch it tracks', function()
|
||||
use_env()
|
||||
local archive = zipfile.write(tmpdir() .. '/cmp-path-main.zip', 'cmp-path-main', 'first')
|
||||
setup_branch(archive)
|
||||
sync()
|
||||
|
||||
t.truthy(fs.exists(env.pack_dir .. '/start/cmp-path/lua/marker.lua'))
|
||||
t.eq('main', lock():get('cmp-path').branch)
|
||||
t.eq(nil, lock():get('cmp-path').tag)
|
||||
end)
|
||||
|
||||
t.it('does not refetch a branch on an ordinary sync, and says so', function()
|
||||
use_env()
|
||||
local path = tmpdir() .. '/cmp-path-main.zip'
|
||||
setup_branch(zipfile.write(path, 'cmp-path-main', 'first'))
|
||||
sync()
|
||||
|
||||
zipfile.write(path, 'cmp-path-main', 'second')
|
||||
env.notices = {}
|
||||
sync()
|
||||
|
||||
t.matches('nothing to install', last_notice())
|
||||
t.matches('1 branch plugin%(s%) not rechecked', last_notice())
|
||||
t.matches('first', table.concat(vim.fn.readfile(env.pack_dir .. '/start/cmp-path/lua/marker.lua'), '\n'))
|
||||
end)
|
||||
|
||||
t.it('refetches a moved branch on update', function()
|
||||
use_env()
|
||||
local path = tmpdir() .. '/cmp-path-main.zip'
|
||||
setup_branch(zipfile.write(path, 'cmp-path-main', 'first'))
|
||||
sync()
|
||||
|
||||
zipfile.write(path, 'cmp-path-main', 'second')
|
||||
env.notices = {}
|
||||
update()
|
||||
|
||||
t.matches('1 updated', last_notice())
|
||||
t.matches('second', table.concat(vim.fn.readfile(env.pack_dir .. '/start/cmp-path/lua/marker.lua'), '\n'))
|
||||
end)
|
||||
|
||||
t.it('reports an update that changed nothing as unchanged', function()
|
||||
use_env()
|
||||
local archive = zipfile.write(tmpdir() .. '/cmp-path-main.zip', 'cmp-path-main', 'first')
|
||||
setup_branch(archive)
|
||||
sync()
|
||||
|
||||
env.notices = {}
|
||||
update()
|
||||
t.matches('1 unchanged', last_notice())
|
||||
end)
|
||||
|
||||
t.it('has nothing to update when every plugin is pinned to a tag', function()
|
||||
use_env()
|
||||
arti.setup({ auto_sync = false, plugins = { { 'owner/here.nvim', tag = 'v1' } } })
|
||||
install_fake('here.nvim', 'start', 'v1')
|
||||
|
||||
env.notices = {}
|
||||
update()
|
||||
t.matches('no branch%-tracking plugins to update', last_notice())
|
||||
end)
|
||||
|
||||
t.it('installs a branch plugin whose directory disappeared', function()
|
||||
use_env()
|
||||
local archive = zipfile.write(tmpdir() .. '/cmp-path-main.zip', 'cmp-path-main', 'first')
|
||||
setup_branch(archive)
|
||||
sync()
|
||||
fs.rm_rf(env.pack_dir .. '/start/cmp-path')
|
||||
|
||||
sync()
|
||||
t.truthy(fs.exists(env.pack_dir .. '/start/cmp-path/lua/marker.lua'))
|
||||
end)
|
||||
end)
|
||||
|
||||
t.describe('arti-pack.clean', function()
|
||||
t.it('removes the directories and lock entries that are no longer configured', function()
|
||||
use_env()
|
||||
@@ -419,6 +519,39 @@ t.describe('arti-pack.clean', function()
|
||||
t.matches('missing a tag', notices(), 'the parse error is reported')
|
||||
t.matches('clean aborted', last_notice())
|
||||
end)
|
||||
|
||||
t.it('keeps a plugin that is configured to track a branch', function()
|
||||
use_env()
|
||||
arti.setup({ auto_sync = false, plugins = { { 'hrsh7th/cmp-path', branch = 'main' } } })
|
||||
install_branch_fake('cmp-path', 'main', 1757000000)
|
||||
install_fake('drop.nvim', 'start')
|
||||
|
||||
arti.clean()
|
||||
|
||||
t.truthy(fs.exists(env.pack_dir .. '/start/cmp-path/marker.lua'), 'a branch plugin is still configured')
|
||||
t.falsy(fs.exists(env.pack_dir .. '/start/drop.nvim'))
|
||||
t.eq({ 'cmp-path' }, lock():names())
|
||||
end)
|
||||
|
||||
t.it('deletes nothing when a branch spec is in error', function()
|
||||
use_env()
|
||||
arti.setup({
|
||||
auto_sync = false,
|
||||
plugins = {
|
||||
{ 'hrsh7th/cmp-path', branch = 'main' },
|
||||
{ 'owner/broken.nvim', tag = 'v1', branch = 'main' },
|
||||
},
|
||||
})
|
||||
install_branch_fake('cmp-path', 'main', 1757000000)
|
||||
install_fake('orphan.nvim', 'start')
|
||||
|
||||
arti.clean()
|
||||
|
||||
t.truthy(fs.exists(env.pack_dir .. '/start/cmp-path'))
|
||||
t.truthy(fs.exists(env.pack_dir .. '/start/orphan.nvim'), 'nothing is deleted from a partial spec set')
|
||||
t.matches('sets both a tag and a branch', notices())
|
||||
t.matches('clean aborted', last_notice())
|
||||
end)
|
||||
end)
|
||||
|
||||
t.describe('arti-pack.list', function()
|
||||
@@ -463,6 +596,37 @@ t.describe('arti-pack.list', function()
|
||||
t.eq(5, #lines)
|
||||
end)
|
||||
|
||||
t.it('never claims a branch plugin is up to date', function()
|
||||
use_env()
|
||||
arti.setup({ auto_sync = false, plugins = { { 'hrsh7th/cmp-path', branch = 'main' } } })
|
||||
install_branch_fake('cmp-path', 'main', 1757000000)
|
||||
|
||||
local line = arti.list()[1]
|
||||
|
||||
t.matches('@main', line, 'the branch is shown in place of a version')
|
||||
t.matches('tracking branch main, fetched 20', line)
|
||||
t.matches('%(bbbbbbbb%)$', line, 'the fetched archive digest is shown')
|
||||
t.falsy(line:find('installed', 1, true), 'a branch install is never reported as simply installed')
|
||||
end)
|
||||
|
||||
t.it('reports a branch plugin whose lock entry tracks another branch', function()
|
||||
use_env()
|
||||
arti.setup({ auto_sync = false, plugins = { { 'hrsh7th/cmp-path', branch = 'master' } } })
|
||||
install_branch_fake('cmp-path', 'main', 1757000000)
|
||||
|
||||
t.matches('outdated %(branch main%)$', arti.list()[1])
|
||||
end)
|
||||
|
||||
t.it('reports a branch plugin that is missing from disk', function()
|
||||
use_env()
|
||||
arti.setup({ auto_sync = false, plugins = { { 'hrsh7th/cmp-path', branch = 'main' } } })
|
||||
local l = lock()
|
||||
l:set('cmp-path', { branch = 'main', opt = false, installed_at = 1757000000 })
|
||||
assert(l:save())
|
||||
|
||||
t.matches('gone from disk$', arti.list()[1])
|
||||
end)
|
||||
|
||||
t.it('lists spec errors alongside the plugins', function()
|
||||
use_env()
|
||||
arti.setup({ auto_sync = false, plugins = { { 'owner/ok.nvim', tag = 'v1' }, 'bogus' } })
|
||||
|
||||
@@ -3,6 +3,7 @@ local fs = require('arti-pack.fs')
|
||||
local installer = require('arti-pack.installer')
|
||||
local Lock = require('arti-pack.lock')
|
||||
local spec_mod = require('arti-pack.spec')
|
||||
local zipfile = require('tests.zipfile')
|
||||
|
||||
local function tmpdir()
|
||||
local dir = vim.fn.tempname()
|
||||
@@ -74,6 +75,103 @@ local function spec_for(source, tag, archive, extra)
|
||||
return assert(spec_mod.parse(entry))
|
||||
end
|
||||
|
||||
-- Branch fixtures are built in Lua, so these run with no zip or tar around.
|
||||
t.describe('installer.run with a branch spec', function()
|
||||
local function branch_spec(source, branch, archive)
|
||||
return assert(spec_mod.parse({ source, branch = branch, url = 'file://' .. archive }))
|
||||
end
|
||||
|
||||
t.it('installs a branch archive and records the branch, not a tag', function()
|
||||
local ctx = new_ctx()
|
||||
local archive = zipfile.write(tmpdir() .. '/cmp-path-main.zip', 'cmp-path-main', 'first')
|
||||
local results = run(ctx, { branch_spec('hrsh7th/cmp-path', 'main', archive) })
|
||||
|
||||
t.eq('installed', results[1].status, results[1].err)
|
||||
t.truthy(fs.exists(ctx.pack_dir .. '/start/cmp-path/lua/marker.lua'), 'wrapper directory is stripped')
|
||||
|
||||
local entry = ctx.lock:get('cmp-path')
|
||||
t.eq('main', entry.branch)
|
||||
t.eq(nil, entry.tag, 'a branch is not recorded as a version')
|
||||
t.eq(64, #entry.digest, 'the fetched archive is identified by digest')
|
||||
t.truthy(type(entry.installed_at) == 'number', 'the fetch time is recorded')
|
||||
end)
|
||||
|
||||
t.it('reports a refetch that brought back the same bytes as unchanged', function()
|
||||
local ctx = new_ctx()
|
||||
local archive = zipfile.write(tmpdir() .. '/cmp-path-main.zip', 'cmp-path-main', 'first')
|
||||
run(ctx, { branch_spec('hrsh7th/cmp-path', 'main', archive) })
|
||||
local before = ctx.lock:get('cmp-path').digest
|
||||
|
||||
local results = run(ctx, { branch_spec('hrsh7th/cmp-path', 'main', archive) })
|
||||
t.eq('unchanged', results[1].status, results[1].err)
|
||||
t.eq(before, ctx.lock:get('cmp-path').digest)
|
||||
end)
|
||||
|
||||
t.it('reports a moved branch as an update and replaces the tree', function()
|
||||
local ctx = new_ctx()
|
||||
local path = tmpdir() .. '/cmp-path-main.zip'
|
||||
run(ctx, { branch_spec('hrsh7th/cmp-path', 'main', zipfile.write(path, 'cmp-path-main', 'first')) })
|
||||
local before = ctx.lock:get('cmp-path').digest
|
||||
write(ctx.pack_dir .. '/start/cmp-path/leftover.txt', 'stale')
|
||||
|
||||
zipfile.write(path, 'cmp-path-main', 'second')
|
||||
local results = run(ctx, { branch_spec('hrsh7th/cmp-path', 'main', path) })
|
||||
t.eq('updated', results[1].status, results[1].err)
|
||||
t.falsy(fs.exists(ctx.pack_dir .. '/start/cmp-path/leftover.txt'), 'stale files are cleared')
|
||||
t.truthy(before ~= ctx.lock:get('cmp-path').digest, 'the recorded digest follows the branch')
|
||||
end)
|
||||
|
||||
t.it('never serves a branch from the archive cache', function()
|
||||
local ctx = new_ctx()
|
||||
local archive = zipfile.write(tmpdir() .. '/cmp-path-main.zip', 'cmp-path-main', 'first')
|
||||
run(ctx, { branch_spec('hrsh7th/cmp-path', 'main', archive) })
|
||||
|
||||
-- With the source gone a cached archive would silently succeed, which is
|
||||
-- exactly what would make an update a no-op.
|
||||
vim.fn.delete(archive)
|
||||
local results = run(ctx, { branch_spec('hrsh7th/cmp-path', 'main', archive) })
|
||||
t.eq('failed', results[1].status)
|
||||
t.matches('download failed', results[1].err)
|
||||
end)
|
||||
end)
|
||||
|
||||
-- curl reads a leading-dash positional as an option, so a url that looks like a
|
||||
-- flag must reach it as a url or not at all. These fixtures are built in Lua,
|
||||
-- so they run with no zip or tar around.
|
||||
t.describe('installer.run with a url that looks like a curl option', function()
|
||||
t.it('does not let a url write a file of its choosing', function()
|
||||
local ctx = new_ctx()
|
||||
local victim = tmpdir() .. '/written-by-curl'
|
||||
local s = assert(spec_mod.parse({ 'owner/sneaky', tag = 'v1', url = '--stderr=' .. victim }))
|
||||
local results = run(ctx, { s })
|
||||
|
||||
t.eq('failed', results[1].status)
|
||||
t.falsy(fs.exists(victim), 'the url ran as a curl option and created a file')
|
||||
end)
|
||||
|
||||
t.it('does not let a url pull curl options in from a config file', function()
|
||||
local ctx = new_ctx()
|
||||
local archive = zipfile.write(tmpdir() .. '/sneaky-v1.zip', 'sneaky-v1', 'first')
|
||||
local conf = write(tmpdir() .. '/curl.conf', string.format('url = "file://%s"\n', archive))
|
||||
local s = assert(spec_mod.parse({ 'owner/sneaky', tag = 'v1', url = '--config=' .. conf }))
|
||||
local results = run(ctx, { s })
|
||||
|
||||
t.eq('failed', results[1].status, 'curl fetched a url the spec never named')
|
||||
t.falsy(fs.exists(ctx.pack_dir .. '/start/sneaky'), 'nothing was installed')
|
||||
t.falsy(ctx.lock:get('sneaky'), 'nothing was recorded')
|
||||
end)
|
||||
|
||||
t.it('still installs from a plain file:// url', function()
|
||||
local ctx = new_ctx()
|
||||
local archive = zipfile.write(tmpdir() .. '/honest-v1.zip', 'honest-v1', 'first')
|
||||
local s = assert(spec_mod.parse({ 'owner/honest', tag = 'v1', url = 'file://' .. archive }))
|
||||
local results = run(ctx, { s })
|
||||
|
||||
t.eq('installed', results[1].status, results[1].err)
|
||||
t.truthy(fs.exists(ctx.pack_dir .. '/start/honest/lua/marker.lua'))
|
||||
end)
|
||||
end)
|
||||
|
||||
t.describe('installer.run', function()
|
||||
if not have('zip') or not have('tar') then
|
||||
t.it('SKIPPED: zip/tar unavailable to build fixtures', function() end)
|
||||
|
||||
@@ -25,6 +25,23 @@ t.describe('lock', function()
|
||||
t.eq({ tag = 'v0.1.4', opt = false }, reloaded:get('plenary.nvim'))
|
||||
end)
|
||||
|
||||
t.it('round-trips a branch entry with what identifies the fetched content', function()
|
||||
local path = tmpdir() .. '/lock.json'
|
||||
local entry = {
|
||||
branch = 'main',
|
||||
digest = string.rep('a', 64),
|
||||
installed_at = 1757000000,
|
||||
opt = false,
|
||||
source = 'hrsh7th/cmp-path',
|
||||
ext = 'zip',
|
||||
}
|
||||
local lock = Lock.new(path)
|
||||
lock:set('cmp-path', entry)
|
||||
t.truthy(lock:save())
|
||||
|
||||
t.eq(entry, Lock.new(path):load():get('cmp-path'))
|
||||
end)
|
||||
|
||||
t.it('starts empty when the state file does not exist', function()
|
||||
local lock = Lock.new(tmpdir() .. '/absent.json'):load()
|
||||
t.eq({}, lock:names())
|
||||
@@ -101,4 +118,67 @@ t.describe('lock.is_current', function()
|
||||
local s = assert(spec.parse({ 'owner/repo', tag = 'v1', opt = true }))
|
||||
t.falsy(make({ tag = 'v1', opt = false }):is_current(s))
|
||||
end)
|
||||
|
||||
t.it('is true when the tracked branch still matches', function()
|
||||
local s = assert(spec.parse({ 'owner/repo', branch = 'main' }))
|
||||
t.truthy(make({ branch = 'main', opt = false }):is_current(s))
|
||||
end)
|
||||
|
||||
t.it('is false when the spec switched to another branch', function()
|
||||
local s = assert(spec.parse({ 'owner/repo', branch = 'master' }))
|
||||
t.falsy(make({ branch = 'main', opt = false }):is_current(s))
|
||||
end)
|
||||
|
||||
t.it('is false when a spec swaps between a tag and a branch', function()
|
||||
local tagged = assert(spec.parse({ 'owner/repo', tag = 'v1' }))
|
||||
local branched = assert(spec.parse({ 'owner/repo', branch = 'main' }))
|
||||
t.falsy(make({ branch = 'main', opt = false }):is_current(tagged))
|
||||
t.falsy(make({ tag = 'v1', opt = false }):is_current(branched))
|
||||
end)
|
||||
end)
|
||||
|
||||
t.describe('lock.needs_install', function()
|
||||
local function make(entry)
|
||||
local lock = Lock.new(tmpdir() .. '/lock.json')
|
||||
if entry then
|
||||
lock:set('repo', entry)
|
||||
end
|
||||
return lock
|
||||
end
|
||||
|
||||
local tagged = assert(spec.parse({ 'owner/repo', tag = 'v1' }))
|
||||
local branched = assert(spec.parse({ 'owner/repo', branch = 'main' }))
|
||||
|
||||
t.it('installs anything that is missing from the lockfile', function()
|
||||
t.truthy(make(nil):needs_install(tagged, { present = false }))
|
||||
t.truthy(make(nil):needs_install(branched, { present = false }))
|
||||
end)
|
||||
|
||||
t.it('installs anything whose directory is gone', function()
|
||||
t.truthy(make({ tag = 'v1' }):needs_install(tagged, { present = false }))
|
||||
t.truthy(make({ branch = 'main' }):needs_install(branched, { present = false }))
|
||||
end)
|
||||
|
||||
t.it('skips an installed tag, which cannot have moved', function()
|
||||
t.falsy(make({ tag = 'v1' }):needs_install(tagged, { present = true }))
|
||||
t.falsy(make({ tag = 'v1' }):needs_install(tagged, { present = true, update = true }))
|
||||
end)
|
||||
|
||||
t.it('leaves an installed branch alone on a plain sync', function()
|
||||
t.falsy(make({ branch = 'main' }):needs_install(branched, { present = true }))
|
||||
end)
|
||||
|
||||
t.it('refetches an installed branch on an update run', function()
|
||||
t.truthy(make({ branch = 'main' }):needs_install(branched, { present = true, update = true }))
|
||||
end)
|
||||
|
||||
t.it('refetches everything when forced', function()
|
||||
t.truthy(make({ tag = 'v1' }):needs_install(tagged, { present = true, force = true }))
|
||||
t.truthy(make({ branch = 'main' }):needs_install(branched, { present = true, force = true }))
|
||||
end)
|
||||
|
||||
t.it('installs a branch whose name changed even without an update run', function()
|
||||
local other = assert(spec.parse({ 'owner/repo', branch = 'master' }))
|
||||
t.truthy(make({ branch = 'main' }):needs_install(other, { present = true }))
|
||||
end)
|
||||
end)
|
||||
|
||||
@@ -2,6 +2,25 @@ local t = require('tests.harness')
|
||||
local spec = require('arti-pack.spec')
|
||||
|
||||
local REGISTRY = 'https://artifacts.example.com/artifactory/github-remote'
|
||||
local NUL = string.char(0)
|
||||
|
||||
-- A NUL truncates the value in argv, so anything that reaches curl must be
|
||||
-- rejected before it can diverge from what was validated and locked.
|
||||
local NULS = { 'mid' .. NUL .. 'dle', 'trailing' .. NUL, NUL }
|
||||
|
||||
-- Each of these makes curl fetch something other than the ref it names: "#"
|
||||
-- drops the rest of the path, "?" turns it into a query, "%" is decoded by the
|
||||
-- server, and "[]"/"{}" are curl globs that fan out into other URLs.
|
||||
local URL_SIGNIFICANT = {
|
||||
'v1#frag',
|
||||
'v1?q=1',
|
||||
'v1%2e%2e',
|
||||
'v1%2Fetc',
|
||||
'v1[1-3]',
|
||||
'v1{a,b}',
|
||||
'v1]x',
|
||||
'v1}x',
|
||||
}
|
||||
|
||||
t.describe('spec.parse', function()
|
||||
t.it('parses a full table spec', function()
|
||||
@@ -35,6 +54,55 @@ t.describe('spec.parse', function()
|
||||
t.matches('missing a tag', err)
|
||||
end)
|
||||
|
||||
t.it('parses a branch spec', function()
|
||||
local s = assert(spec.parse({ 'hrsh7th/cmp-path', branch = 'main' }))
|
||||
t.eq('main', s.branch)
|
||||
t.eq('main', s.ref)
|
||||
t.eq(nil, s.tag, 'a branch spec carries no tag')
|
||||
t.eq('cmp-path', s.name)
|
||||
end)
|
||||
|
||||
t.it('rejects a spec that sets both a tag and a branch', function()
|
||||
local s, err = spec.parse({ 'owner/repo', tag = 'v1', branch = 'main' })
|
||||
t.falsy(s)
|
||||
t.matches('sets both a tag and a branch', err)
|
||||
end)
|
||||
|
||||
t.it('rejects a spec with neither a tag nor a branch', function()
|
||||
local s, err = spec.parse({ 'owner/repo' })
|
||||
t.falsy(s)
|
||||
t.matches('missing a tag or a branch', err)
|
||||
end)
|
||||
|
||||
t.it('rejects a branch that would reshape the archive path', function()
|
||||
for _, bad in ipairs({ '..', '.', 'feature/foo', '../../etc', 'a\\b', 'ma in' }) do
|
||||
local s, err = spec.parse({ 'owner/repo', branch = bad })
|
||||
t.falsy(s, 'rejects ' .. vim.inspect(bad))
|
||||
t.matches('invalid branch', err)
|
||||
end
|
||||
end)
|
||||
|
||||
t.it('rejects an empty branch', function()
|
||||
for _, bad in ipairs({ '', ' ', 42 }) do
|
||||
local s, err = spec.parse({ 'owner/repo', branch = bad })
|
||||
t.falsy(s, 'rejects ' .. vim.inspect(bad))
|
||||
t.matches('missing a tag or a branch', err)
|
||||
end
|
||||
end)
|
||||
|
||||
t.it('rejects a tag that would reshape the archive path', function()
|
||||
for _, bad in ipairs({ '..', '.', 'a\\b' }) do
|
||||
local s, err = spec.parse({ 'owner/repo', tag = bad })
|
||||
t.falsy(s, 'rejects ' .. vim.inspect(bad))
|
||||
t.matches('invalid tag', err)
|
||||
end
|
||||
end)
|
||||
|
||||
t.it('trims surrounding whitespace from a branch', function()
|
||||
local s = assert(spec.parse({ 'owner/repo', branch = ' main \n' }))
|
||||
t.eq('main', s.branch)
|
||||
end)
|
||||
|
||||
t.it('rejects a source without an owner', function()
|
||||
local s, err = spec.parse({ 'plenary.nvim', tag = 'v1' })
|
||||
t.falsy(s)
|
||||
@@ -82,6 +150,73 @@ t.describe('spec.parse', function()
|
||||
t.eq('https://example.test/a.zip', s.url)
|
||||
end)
|
||||
|
||||
t.it('rejects a tag carrying a NUL byte', function()
|
||||
for _, bad in ipairs(NULS) do
|
||||
local s, err = spec.parse({ 'owner/repo', tag = bad })
|
||||
t.falsy(s, 'rejects ' .. vim.inspect(bad))
|
||||
t.matches('NUL byte in its tag', err)
|
||||
end
|
||||
end)
|
||||
|
||||
t.it('rejects a branch carrying a NUL byte', function()
|
||||
for _, bad in ipairs(NULS) do
|
||||
local s, err = spec.parse({ 'owner/repo', branch = bad })
|
||||
t.falsy(s, 'rejects ' .. vim.inspect(bad))
|
||||
t.matches('NUL byte in its branch', err)
|
||||
end
|
||||
end)
|
||||
|
||||
t.it('rejects an "as" name carrying a NUL byte', function()
|
||||
for _, bad in ipairs(NULS) do
|
||||
local s, err = spec.parse({ 'owner/repo', tag = 'v1', as = bad })
|
||||
t.falsy(s, 'rejects ' .. vim.inspect(bad))
|
||||
t.matches('NUL byte in its directory name', err)
|
||||
end
|
||||
end)
|
||||
|
||||
t.it('rejects a url carrying a NUL byte', function()
|
||||
local urls = { 'https://example.test/a' .. NUL .. 'b.zip', 'https://example.test/a.zip' .. NUL, NUL }
|
||||
for _, bad in ipairs(urls) do
|
||||
local s, err = spec.parse({ 'owner/repo', tag = 'v1', url = bad })
|
||||
t.falsy(s, 'rejects ' .. vim.inspect(bad))
|
||||
t.matches('NUL byte in its url', err)
|
||||
end
|
||||
end)
|
||||
|
||||
t.it('rejects a tag that would change the fetched url', function()
|
||||
for _, bad in ipairs(URL_SIGNIFICANT) do
|
||||
local s, err = spec.parse({ 'owner/repo', tag = bad })
|
||||
t.falsy(s, 'rejects ' .. vim.inspect(bad))
|
||||
t.matches('invalid tag', err)
|
||||
end
|
||||
end)
|
||||
|
||||
t.it('rejects a branch that would change the fetched url', function()
|
||||
for _, bad in ipairs(URL_SIGNIFICANT) do
|
||||
local s, err = spec.parse({ 'owner/repo', branch = bad })
|
||||
t.falsy(s, 'rejects ' .. vim.inspect(bad))
|
||||
t.matches('invalid branch', err)
|
||||
end
|
||||
end)
|
||||
|
||||
-- curl sends these through the path byte for byte, so they still name the ref
|
||||
-- they were validated as. Rejecting them would lock out real upstream tags.
|
||||
t.it('keeps accepting ref characters that reach the server unchanged', function()
|
||||
for _, good in ipairs({ 'v1+2', 'v1@host', 'v1:2', 'release_1.2', 'v1~rc1', 'v1,2', 'v1;x', 'v1&x' }) do
|
||||
local s = spec.parse({ 'owner/repo', tag = good })
|
||||
t.truthy(s, 'accepts ' .. vim.inspect(good))
|
||||
t.eq(REGISTRY .. '/owner/repo/archive/refs/tags/' .. good .. '.zip', spec.url(s, REGISTRY))
|
||||
end
|
||||
end)
|
||||
|
||||
t.it('rejects a source carrying a NUL byte', function()
|
||||
for _, bad in ipairs(NULS) do
|
||||
local s, err = spec.parse({ 'owner/' .. bad, tag = 'v1' })
|
||||
t.falsy(s, 'rejects ' .. vim.inspect(bad))
|
||||
t.matches('expected "owner/repo"', err)
|
||||
end
|
||||
end)
|
||||
|
||||
t.it('rejects a url with whitespace inside it', function()
|
||||
for _, bad in ipairs({ 'https://example.test/a b.zip', 'https://example\n.test/a.zip', ' ' }) do
|
||||
local s, err = spec.parse({ 'owner/repo', tag = 'v1', url = bad })
|
||||
@@ -121,6 +256,26 @@ t.describe('spec.url', function()
|
||||
end
|
||||
end)
|
||||
|
||||
t.it('builds a branch archive url', function()
|
||||
local s = assert(spec.parse({ 'hrsh7th/cmp-path', branch = 'main' }))
|
||||
t.eq(REGISTRY .. '/hrsh7th/cmp-path/archive/refs/heads/main.zip', spec.url(s, REGISTRY))
|
||||
end)
|
||||
|
||||
t.it('builds a branch archive url for tar.gz', function()
|
||||
local s = assert(spec.parse({ 'hrsh7th/vim-vsnip', branch = 'master', ext = 'tar.gz' }))
|
||||
t.eq(REGISTRY .. '/hrsh7th/vim-vsnip/archive/refs/heads/master.tar.gz', spec.url(s, REGISTRY))
|
||||
end)
|
||||
|
||||
t.it('does not double up on a trailing registry slash for a branch', function()
|
||||
local s = assert(spec.parse({ 'owner/repo', branch = 'main' }))
|
||||
t.eq(REGISTRY .. '/owner/repo/archive/refs/heads/main.zip', spec.url(s, REGISTRY .. '///'))
|
||||
end)
|
||||
|
||||
t.it('lets an explicit url bypass the registry for a branch', function()
|
||||
local s = assert(spec.parse({ 'owner/repo', branch = 'main', url = 'https://example.test/main.zip' }))
|
||||
t.eq('https://example.test/main.zip', spec.url(s, REGISTRY))
|
||||
end)
|
||||
|
||||
t.it('passes an explicit url straight through', function()
|
||||
local s = assert(spec.parse({ 'owner/repo', tag = 'v1', url = 'https://example.test/a.zip' }))
|
||||
t.eq('https://example.test/a.zip', spec.url(s, REGISTRY))
|
||||
@@ -173,6 +328,15 @@ t.describe('spec.check_registry', function()
|
||||
end
|
||||
end)
|
||||
|
||||
t.it('rejects a registry carrying a NUL byte', function()
|
||||
local bads = { 'https://artifacts.example.com/g' .. NUL .. 'h', REGISTRY .. NUL, NUL }
|
||||
for _, bad in ipairs(bads) do
|
||||
local base, err = spec.check_registry(bad)
|
||||
t.falsy(base, 'rejects ' .. vim.inspect(bad))
|
||||
t.matches('NUL byte', err)
|
||||
end
|
||||
end)
|
||||
|
||||
t.it('rejects a whitespace-only registry', function()
|
||||
for _, bad in ipairs({ ' ', ' ', '\n', ' \t\n ' }) do
|
||||
local base, err = spec.check_registry(bad)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
local zzlib = require('arti-pack.vendor.zzlib')
|
||||
|
||||
local M = {}
|
||||
|
||||
local function le(n, bytes)
|
||||
local out = {}
|
||||
for _ = 1, bytes do
|
||||
table.insert(out, string.char(n % 256))
|
||||
n = math.floor(n / 256)
|
||||
end
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
-- A stored-entry zip built here rather than shelled out to the zip binary, so
|
||||
-- tests that need a real archive run wherever nvim does.
|
||||
function M.bytes(entries)
|
||||
local locals, central, offset = {}, {}, 0
|
||||
for _, e in ipairs(entries) do
|
||||
local data = e.data or ''
|
||||
local crc = zzlib.crc32(data)
|
||||
local head = 'PK\3\4'
|
||||
.. le(20, 2)
|
||||
.. le(0, 2)
|
||||
.. le(0, 2)
|
||||
.. le(0, 4)
|
||||
.. le(crc, 4)
|
||||
.. le(#data, 4)
|
||||
.. le(#data, 4)
|
||||
.. le(#e.name, 2)
|
||||
.. le(0, 2)
|
||||
.. e.name
|
||||
table.insert(locals, head .. data)
|
||||
table.insert(
|
||||
central,
|
||||
'PK\1\2'
|
||||
.. le(3 * 256 + 20, 2)
|
||||
.. le(20, 2)
|
||||
.. le(0, 2)
|
||||
.. le(0, 2)
|
||||
.. le(0, 4)
|
||||
.. le(crc, 4)
|
||||
.. le(#data, 4)
|
||||
.. le(#data, 4)
|
||||
.. le(#e.name, 2)
|
||||
.. le(0, 2)
|
||||
.. le(0, 2)
|
||||
.. le(0, 2)
|
||||
.. le(0, 2)
|
||||
.. le(0, 4)
|
||||
.. le(offset, 4)
|
||||
.. e.name
|
||||
)
|
||||
offset = offset + #head + #data
|
||||
end
|
||||
local body = table.concat(locals)
|
||||
local cd = table.concat(central)
|
||||
local eocd = 'PK\5\6'
|
||||
.. le(0, 2)
|
||||
.. le(0, 2)
|
||||
.. le(#entries, 2)
|
||||
.. le(#entries, 2)
|
||||
.. le(#cd, 4)
|
||||
.. le(#body, 4)
|
||||
.. le(0, 2)
|
||||
return body .. cd .. eocd
|
||||
end
|
||||
|
||||
-- Mirrors an upstream source archive: everything under one "<repo>-<ref>" root.
|
||||
function M.write(path, root, marker)
|
||||
local fd = assert(io.open(path, 'wb'))
|
||||
fd:write(M.bytes({ { name = root .. '/lua/marker.lua', data = 'return "' .. marker .. '"' } }))
|
||||
fd:close()
|
||||
return path
|
||||
end
|
||||
|
||||
return M
|
||||
Reference in New Issue
Block a user