Install plugins from a branch
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/lint Pipeline was successful

Many upstream plugins publish no tags at all, so a tag-only installer cannot
manage them.

- Add a branch spec field, mutually exclusive with tag and validated the same way
- Build archive/refs/heads/<branch> URLs for branch specs
- Refetch a branch only on :ArtiPackUpdate or a forced sync, never from cache
- Record the branch, archive digest and fetch time in the lockfile
- Report a branch plugin as tracked and fetched, never as up to date
This commit is contained in:
2026-09-12 00:08:18 +10:00
parent f1b454a4eb
commit 460dc2c5c8
14 changed files with 765 additions and 76 deletions
+70 -27
View File
@@ -1,15 +1,15 @@
# arti-pack # arti-pack
A Neovim plugin manager that installs plugins from an artifact repository A Neovim plugin manager that installs plugins from an artifact repository
instead of git. It downloads tagged upstream source archives over HTTP and instead of git. It downloads upstream source archives over HTTP — a tag for
unpacks them into Neovim's native package path — see `:h packages`. 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 It works with any HTTP artifact repository that serves upstream tag and branch
at a stable URL — a JFrog Artifactory remote GitHub repository, or any caching archives at a stable URL — a JFrog Artifactory remote GitHub repository, or any
proxy with the same path shape. Nothing is cloned, no git binary is needed, and caching proxy with the same path shape. Nothing is cloned, no git binary is
the editor never has to reach GitHub: every plugin arrives as a cached, needed, and the editor never has to reach GitHub: every plugin arrives as a
immutable, versioned artifact from the same store your builds already pull cached artifact from the same store your builds already pull from.
from.
## Requirements ## Requirements
@@ -55,6 +55,7 @@ require('arti-pack').setup({
{ 'folke/which-key.nvim', tag = 'v3.13.3', opt = true }, { 'folke/which-key.nvim', tag = 'v3.13.3', opt = true },
{ 'folke/tokyonight.nvim', tag = 'v4.10.0', as = 'tokyonight' }, { 'folke/tokyonight.nvim', tag = 'v4.10.0', as = 'tokyonight' },
{ 'tpope/vim-fugitive', tag = 'v3.7', ext = 'tar.gz' }, { 'tpope/vim-fugitive', tag = 'v3.7', ext = 'tar.gz' },
{ 'hrsh7th/cmp-path', branch = 'main' },
}, },
}) })
``` ```
@@ -72,20 +73,28 @@ whitespace is trimmed and anything else is rejected. Until you set it,
`:ArtiPackClean` and `:ArtiPackList` never resolve a URL, so they work without `:ArtiPackClean` and `:ArtiPackList` never resolve a URL, so they work without
it, as do plugins that carry an explicit `url`. 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 `stdpath('data')/site/pack/arti-pack/start/<name>`, or `.../opt/<name>` when
`opt = true`. `opt = true`.
### Spec fields ### Spec fields
| Field | Default | Meaning | | Field | Default | Meaning |
| ------ | ---------- | ---------------------------------------------------- | | -------- | ---------- | ---------------------------------------------------- |
| `[1]` | *required* | `owner/repo` | | `[1]` | *required* | `owner/repo` |
| `tag` | *required* | Upstream tag, used verbatim in the archive path | | `tag` | *one of* | Upstream tag, used verbatim in the archive path |
| `opt` | `false` | Install under `opt/` for `:packadd` instead of `start/` | | `branch` | *one of* | Upstream branch to track, used verbatim in the archive path |
| `as` | repo name | Directory name to install as | | `opt` | `false` | Install under `opt/` for `:packadd` instead of `start/` |
| `ext` | `'zip'` | `zip` or `tar.gz` | | `as` | repo name | Directory name to install as |
| `url` | — | Full archive URL, bypassing the registry (no whitespace) | | `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 contain inner whitespace, `/`, `\`, or are `.`
or `..`. A branch whose name contains `/` therefore needs an explicit `url`.
### Options ### Options
@@ -98,32 +107,66 @@ Every plugin needs an explicit `tag` — that is the point. Plugins install to
### Commands ### Commands
| Command | Effect | | Command | Effect |
| ---------------- | ---------------------------------------------------------- | | ----------------- | ---------------------------------------------------------- |
| `:ArtiPackSync` | Install missing plugins and update ones whose tag changed | | `:ArtiPackSync` | Install missing plugins and apply changed refs |
| `:ArtiPackSync!` | Reinstall everything, ignoring recorded state | | `:ArtiPackSync!` | Reinstall everything, ignoring recorded state |
| `:ArtiPackClean` | Remove installed plugins no longer in the spec | | `:ArtiPackUpdate` | Refetch every branch-tracking plugin |
| `:ArtiPackList` | Show every configured plugin and its state | | `: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
```
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 ## How it resolves an archive
``` ```
<registry>/<owner>/<repo>/archive/refs/tags/<tag>.<ext> <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/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. `github.com` resolves it unchanged.
Archives are cached under `stdpath('cache')/arti-pack`, checked for a real zip 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 or gzip magic header before being trusted, and unpacked into a staging
directory. Tag archives wrap their contents in a single `<repo>-<version>` directory. Source archives wrap their contents in a single `<repo>-<ref>`
directory, which is stripped on the way in. Installed tags are recorded in 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 `stdpath('data')/arti-pack/lock.json`, so a sync with nothing to do costs
nothing. Helptags are regenerated per plugin after install. nothing. Helptags are regenerated per plugin after install.
+67 -20
View File
@@ -6,19 +6,21 @@ CONTENTS *arti-pack-contents*
1. Introduction ......................... |arti-pack-introduction| 1. Introduction ......................... |arti-pack-introduction|
2. Setup ................................ |arti-pack-setup| 2. Setup ................................ |arti-pack-setup|
3. Plugin specs ......................... |arti-pack-specs| 3. Plugin specs ......................... |arti-pack-specs|
4. Commands ............................. |arti-pack-commands| 4. Branch tracking ...................... |arti-pack-branches|
5. Layout ............................... |arti-pack-layout| 5. Commands ............................. |arti-pack-commands|
6. Layout ............................... |arti-pack-layout|
============================================================================== ==============================================================================
1. INTRODUCTION *arti-pack-introduction* 1. INTRODUCTION *arti-pack-introduction*
arti-pack installs plugins from tagged upstream source archives served over arti-pack installs plugins from upstream source archives served over HTTP by an
HTTP by an artifact repository, and unpacks them into Neovim's native package artifact repository — a tag for anything that publishes one, a branch for
path. See |packages| for how Neovim loads what arti-pack writes. 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 Any artifact repository that serves upstream tag and branch archives at a
works — a JFrog Artifactory remote GitHub repository, or any caching proxy stable URL works — a JFrog Artifactory remote GitHub repository, or any caching
with the same path shape. Nothing is cloned and the editor never reaches proxy with the same path shape. Nothing is cloned and the editor never reaches
GitHub itself. GitHub itself.
Requires curl. Zip and tar.gz archives are unpacked in pure Lua, so no 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 = { plugins = {
{ 'nvim-lua/plenary.nvim', tag = 'v0.1.4' }, { 'nvim-lua/plenary.nvim', tag = 'v0.1.4' },
{ 'folke/which-key.nvim', tag = 'v3.13.3', opt = true }, { 'folke/which-key.nvim', tag = 'v3.13.3', opt = true },
{ 'hrsh7th/cmp-path', branch = 'main' },
}, },
}) })
< <
@@ -59,23 +62,67 @@ Options:
Each entry is a table whose first element is "owner/repo". Each entry is a table whose first element is "owner/repo".
tag Required. Upstream tag, used verbatim in the archive path. tag Upstream tag, used verbatim in the archive path.
opt Install under opt/ for |:packadd| instead of start/. Default false. branch Upstream branch to track, used verbatim in the archive path.
as Directory name to install as. Defaults to the repo name. See |arti-pack-branches|.
ext "zip" or "tar.gz". Default "zip". opt Install under opt/ for |:packadd| instead of start/. Default false.
url Full archive URL, bypassing the registry. Trimmed; it may not as Directory name to install as. Defaults to the repo name.
contain whitespace. 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 contain inner whitespace, "/" or
"\", or are "." or "..". A branch name containing "/" therefore needs an
explicit "url".
The archive URL is built as: > The archive URL is built as: >
<registry>/<owner>/<repo>/archive/refs/tags/<tag>.<ext> <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)
<
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*
: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. :ArtiPackSync! Reinstall every configured plugin, ignoring recorded state.
*:ArtiPackUpdate*
:ArtiPackUpdate Refetch every branch-tracking plugin. See
|arti-pack-branches|.
*:ArtiPackClean* *:ArtiPackClean*
:ArtiPackClean Remove installed plugins that are no longer in the spec. :ArtiPackClean Remove installed plugins that are no longer in the spec.
@@ -83,16 +130,16 @@ The archive URL is built as: >
:ArtiPackList Show every configured plugin and its state. :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/start/<name> loaded at startup
stdpath('data')/site/pack/arti-pack/opt/<name> loaded by |:packadd| 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 stdpath('cache')/arti-pack downloaded archives
Downloads are checked for a zip or gzip magic header before being trusted, and 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 the single "<repo>-<ref>" directory that source archives wrap their contents in
in is stripped on the way in. A plugin that fails is reported and skipped; the is stripped on the way in. A plugin that fails is reported and skipped; the
rest of the run continues. rest of the run continues.
vim:tw=78:ts=8:noet:ft=help:norl: vim:tw=78:ts=8:noet:ft=help:norl:
+17
View File
@@ -60,6 +60,23 @@ function M.read_head(path, bytes)
return head return head
end 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) function M.verify_archive(path, ext)
local stat = uv.fs_stat(path) local stat = uv.fs_stat(path)
if not stat or stat.size == 0 then if not stat or stat.size == 0 then
+58 -10
View File
@@ -59,7 +59,7 @@ local function installed_dir(ctx, name, opt)
end end
local function finalise(ctx, results) 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 for _, res in ipairs(results) do
counts[res.status] = (counts[res.status] or 0) + 1 counts[res.status] = (counts[res.status] or 0) + 1
@@ -85,7 +85,7 @@ local function finalise(ctx, results)
end end
local parts = {} local parts = {}
for _, key in ipairs({ 'installed', 'updated', 'failed' }) do for _, key in ipairs({ 'installed', 'updated', 'unchanged', 'failed' }) do
if counts[key] > 0 then if counts[key] > 0 then
table.insert(parts, string.format('%d %s', counts[key], key)) table.insert(parts, string.format('%d %s', counts[key], key))
end end
@@ -98,6 +98,25 @@ local function finalise(ctx, results)
end end
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) function M.sync(opts)
opts = opts or {} opts = opts or {}
if running then if running then
@@ -112,14 +131,18 @@ function M.sync(opts)
local ctx = context() local ctx = context()
local pending = {} local pending = {}
for _, spec in ipairs(specs) do for _, spec in ipairs(specs) do
local current = ctx.lock:is_current(spec) and fs.exists(installed_dir(ctx, spec.name, spec.opt)) local wanted = ctx.lock:needs_install(spec, {
if opts.force or not current then force = opts.force,
update = opts.update,
present = fs.exists(installed_dir(ctx, spec.name, spec.opt)),
})
if wanted then
table.insert(pending, spec) table.insert(pending, spec)
end end
end end
if #pending == 0 then 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 end
local reg_err = registry_error(pending, ctx.registry) local reg_err = registry_error(pending, ctx.registry)
@@ -135,6 +158,11 @@ function M.sync(opts)
end) end)
end end
-- Branch archives are mutable, so nothing refetches them implicitly.
function M.update()
return M.sync({ update = true })
end
function M.clean() function M.clean()
local specs, errors = spec_mod.parse_all(M.config.plugins) local specs, errors = spec_mod.parse_all(M.config.plugins)
for _, err in ipairs(errors) do for _, err in ipairs(errors) do
@@ -179,6 +207,24 @@ function M.clean()
end end
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() function M.list()
local specs, errors = spec_mod.parse_all(M.config.plugins) local specs, errors = spec_mod.parse_all(M.config.plugins)
local ctx = context() local ctx = context()
@@ -190,16 +236,18 @@ function M.list()
if not entry then if not entry then
state = 'missing' state = 'missing'
elseif not ctx.lock:is_current(spec) then 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 elseif not fs.exists(installed_dir(ctx, spec.name, spec.opt)) then
state = 'gone from disk' 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 else
state = 'installed' state = 'installed'
end end
table.insert( local ref = spec.branch and ('@' .. spec.branch) or spec.tag
lines, table.insert(lines, string.format('%-32s %-14s %-5s %s', spec.source, ref, spec.opt and 'opt' or 'start', state))
string.format('%-32s %-14s %-5s %s', spec.source, spec.tag, spec.opt and 'opt' or 'start', state)
)
end end
for _, name in ipairs(ctx.lock:names()) do for _, name in ipairs(ctx.lock:names()) do
+31 -5
View File
@@ -4,7 +4,14 @@ local spec_mod = require('arti-pack.spec')
local M = {} local M = {}
local function result(spec, status, err) 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 end
local function target_dir(ctx, spec) local function target_dir(ctx, spec)
@@ -29,6 +36,7 @@ end
local function place(ctx, spec, stage, done) local function place(ctx, spec, stage, done)
local root = fs.archive_root(stage) local root = fs.archive_root(stage)
local target = target_dir(ctx, spec) local target = target_dir(ctx, spec)
local had_target = fs.exists(target)
fs.mkdirp(vim.fn.fnamemodify(target, ':h')) fs.mkdirp(vim.fn.fnamemodify(target, ':h'))
fs.rm_rf(target) fs.rm_rf(target)
@@ -43,6 +51,8 @@ local function place(ctx, spec, stage, done)
local previous = ctx.lock:get(spec.name) local previous = ctx.lock:get(spec.name)
ctx.lock:set(spec.name, { ctx.lock:set(spec.name, {
tag = spec.tag, tag = spec.tag,
branch = spec.branch,
digest = spec.digest,
opt = spec.opt, opt = spec.opt,
source = spec.source, source = spec.source,
ext = spec.ext, ext = spec.ext,
@@ -50,10 +60,22 @@ local function place(ctx, spec, stage, done)
installed_at = os.time(), 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 end
local function extract(ctx, spec, archive, done) 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) local stage = fs.join(ctx.pack_dir, '.stage', spec.name)
fs.rm_rf(stage) fs.rm_rf(stage)
fs.mkdirp(stage) fs.mkdirp(stage)
@@ -75,7 +97,9 @@ local function download(ctx, spec, archive, done)
end end
spec.resolved_url = url 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) return extract(ctx, spec, archive, done)
end end
@@ -120,14 +144,16 @@ local function download(ctx, spec, archive, done)
end end
function M.install(ctx, spec, done) 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) local ok, err = pcall(download, ctx, spec, archive, done)
if not ok then if not ok then
done(result(spec, 'failed', tostring(err))) done(result(spec, 'failed', tostring(err)))
end end
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) function M.run(ctx, specs, on_done)
local results, started, finished = {}, 0, 0 local results, started, finished = {}, 0, 0
local limit = math.max(1, ctx.concurrency or 4) local limit = math.max(1, ctx.concurrency or 4)
+25 -2
View File
@@ -74,14 +74,37 @@ function M:names()
return names return names
end 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. -- 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) function M:is_current(spec)
local entry = self.entries[spec.name] local entry = self.entries[spec.name]
if not entry then if not entry then
return false return false
end 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 end
return M return M
+21 -11
View File
@@ -32,13 +32,20 @@ function M.parse(entry)
return nil, string.format('invalid source %q, expected "owner/repo"', source) return nil, string.format('invalid source %q, expected "owner/repo"', source)
end end
local tag = entry.tag if entry.tag ~= nil and entry.branch ~= nil then
if type(tag) ~= 'string' or trim(tag) == '' then return nil, string.format('%s sets both a tag and a branch, pick one', source)
return nil, string.format('%s is missing a tag', source)
end end
tag = trim(tag)
if tag:find('[/\\%s]') then -- A ref is one path segment of the archive URL, so the same rules that keep a
return nil, string.format('%s has an invalid tag %q', source, tag) -- 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 bad_name(ref) then
return nil, string.format('%s has an invalid %s %q', source, kind, ref)
end end
local ext = entry.ext or 'zip' local ext = entry.ext or 'zip'
@@ -67,7 +74,9 @@ function M.parse(entry)
owner = owner, owner = owner,
repo = repo, repo = repo,
name = name, name = name,
tag = tag, tag = kind == 'tag' and ref or nil,
branch = kind == 'branch' and ref or nil,
ref = ref,
ext = ext, ext = ext,
opt = entry.opt == true, opt = entry.opt == true,
url = url, url = url,
@@ -93,8 +102,9 @@ function M.check_registry(registry)
return (base:gsub('/+$', '')) return (base:gsub('/+$', ''))
end end
-- Tagged source archives live at the same path shape on every GitHub-alike, so -- Tag and branch source archives live at the same path shape on every
-- the registry is just the proxy prefix in front of "<owner>/<repo>". -- GitHub-alike, so the registry is just the proxy prefix in front of
-- "<owner>/<repo>".
function M.url(spec, registry) function M.url(spec, registry)
if spec.url then if spec.url then
return spec.url return spec.url
@@ -109,8 +119,8 @@ function M.url(spec, registry)
spec.repo, spec.repo,
'archive', 'archive',
'refs', 'refs',
'tags', spec.branch and 'heads' or 'tags',
spec.tag .. '.' .. spec.ext, spec.ref .. '.' .. spec.ext,
}, '/') }, '/')
end end
+5 -1
View File
@@ -9,7 +9,11 @@ end
vim.api.nvim_create_user_command('ArtiPackSync', function(cmd) vim.api.nvim_create_user_command('ArtiPackSync', function(cmd)
arti().sync({ force = cmd.bang }) 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() vim.api.nvim_create_user_command('ArtiPackClean', function()
arti().clean() arti().clean()
+21
View File
@@ -74,6 +74,27 @@ t.describe('fs.verify_archive', function()
end) end)
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.describe('fs.extract', function()
t.it('reports a missing archive instead of raising', function() t.it('reports a missing archive instead of raising', function()
local ok, err = fs.extract(tmpdir() .. '/absent.zip', tmpdir(), 'zip') local ok, err = fs.extract(tmpdir() .. '/absent.zip', tmpdir(), 'zip')
+164
View File
@@ -1,6 +1,7 @@
local t = require('tests.harness') local t = require('tests.harness')
local fs = require('arti-pack.fs') local fs = require('arti-pack.fs')
local Lock = require('arti-pack.lock') local Lock = require('arti-pack.lock')
local zipfile = require('tests.zipfile')
local arti = require('arti-pack') local arti = require('arti-pack')
local function tmpdir() local function tmpdir()
@@ -87,11 +88,21 @@ local function install_fake(name, sub, tag)
record(name, tag, sub == 'opt') record(name, tag, sub == 'opt')
end 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. -- sync finishes asynchronously; its closing notification is the only signal.
local function is_terminal(msg) local function is_terminal(msg)
return msg:find('everything up to date', 1, true) ~= nil 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+ installed') ~= nil
or msg:find('%d+ updated') ~= nil or msg:find('%d+ updated') ~= nil
or msg:find('%d+ unchanged') ~= nil
or msg:find('%d+ failed') ~= nil or msg:find('%d+ failed') ~= nil
end end
@@ -115,6 +126,12 @@ local function sync(opts)
drain(before) drain(before)
end end
local function update()
local before = #env.notices
arti.update()
drain(before)
end
local function build_archive(ext, repo, version) local function build_archive(ext, repo, version)
local work = tmpdir() local work = tmpdir()
local root = string.format('%s-%s', repo, version) local root = string.format('%s-%s', repo, version)
@@ -353,6 +370,89 @@ t.describe('arti-pack.sync', function()
end) end)
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.describe('arti-pack.clean', function()
t.it('removes the directories and lock entries that are no longer configured', function() t.it('removes the directories and lock entries that are no longer configured', function()
use_env() 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('missing a tag', notices(), 'the parse error is reported')
t.matches('clean aborted', last_notice()) t.matches('clean aborted', last_notice())
end) 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) end)
t.describe('arti-pack.list', function() t.describe('arti-pack.list', function()
@@ -463,6 +596,37 @@ t.describe('arti-pack.list', function()
t.eq(5, #lines) t.eq(5, #lines)
end) 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() t.it('lists spec errors alongside the plugins', function()
use_env() use_env()
arti.setup({ auto_sync = false, plugins = { { 'owner/ok.nvim', tag = 'v1' }, 'bogus' } }) arti.setup({ auto_sync = false, plugins = { { 'owner/ok.nvim', tag = 'v1' }, 'bogus' } })
+61
View File
@@ -3,6 +3,7 @@ local fs = require('arti-pack.fs')
local installer = require('arti-pack.installer') local installer = require('arti-pack.installer')
local Lock = require('arti-pack.lock') local Lock = require('arti-pack.lock')
local spec_mod = require('arti-pack.spec') local spec_mod = require('arti-pack.spec')
local zipfile = require('tests.zipfile')
local function tmpdir() local function tmpdir()
local dir = vim.fn.tempname() local dir = vim.fn.tempname()
@@ -74,6 +75,66 @@ local function spec_for(source, tag, archive, extra)
return assert(spec_mod.parse(entry)) return assert(spec_mod.parse(entry))
end 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)
t.describe('installer.run', function() t.describe('installer.run', function()
if not have('zip') or not have('tar') then if not have('zip') or not have('tar') then
t.it('SKIPPED: zip/tar unavailable to build fixtures', function() end) t.it('SKIPPED: zip/tar unavailable to build fixtures', function() end)
+80
View File
@@ -25,6 +25,23 @@ t.describe('lock', function()
t.eq({ tag = 'v0.1.4', opt = false }, reloaded:get('plenary.nvim')) t.eq({ tag = 'v0.1.4', opt = false }, reloaded:get('plenary.nvim'))
end) 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() t.it('starts empty when the state file does not exist', function()
local lock = Lock.new(tmpdir() .. '/absent.json'):load() local lock = Lock.new(tmpdir() .. '/absent.json'):load()
t.eq({}, lock:names()) 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 })) local s = assert(spec.parse({ 'owner/repo', tag = 'v1', opt = true }))
t.falsy(make({ tag = 'v1', opt = false }):is_current(s)) t.falsy(make({ tag = 'v1', opt = false }):is_current(s))
end) 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) end)
+69
View File
@@ -35,6 +35,55 @@ t.describe('spec.parse', function()
t.matches('missing a tag', err) t.matches('missing a tag', err)
end) 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() t.it('rejects a source without an owner', function()
local s, err = spec.parse({ 'plenary.nvim', tag = 'v1' }) local s, err = spec.parse({ 'plenary.nvim', tag = 'v1' })
t.falsy(s) t.falsy(s)
@@ -121,6 +170,26 @@ t.describe('spec.url', function()
end end
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() t.it('passes an explicit url straight through', function()
local s = assert(spec.parse({ 'owner/repo', tag = 'v1', url = 'https://example.test/a.zip' })) 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)) t.eq('https://example.test/a.zip', spec.url(s, REGISTRY))
+76
View File
@@ -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