Install plugins from a branch #7

Merged
benvin merged 3 commits from benvin/branch-specs into main 2026-09-12 00:45:00 +10:00
14 changed files with 847 additions and 76 deletions
+74 -27
View File
@@ -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,28 @@ 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 contain inner whitespace, `/`, `\`, or are `.`
or `..`. A branch whose name contains `/` therefore needs an explicit `url`.
### Options
@@ -98,32 +107,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.
+71 -20
View File
@@ -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,71 @@ 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 contain inner whitespace, "/" or
"\", or are "." or "..". 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 +134,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:
+17
View File
@@ -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
View File
@@ -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
+31 -5
View File
@@ -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
@@ -120,14 +144,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
View File
@@ -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
+40 -11
View File
@@ -9,6 +9,13 @@ 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
local function bad_name(name)
return name == '' or name == '.' or name == '..' or name:find('[/\\%s]') ~= nil
end
@@ -32,13 +39,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 +64,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 +77,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 +90,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 +112,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 +138,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
+5 -1
View File
@@ -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()
+21
View File
@@ -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')
+164
View File
@@ -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' } })
+61
View File
@@ -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,66 @@ 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)
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)
+80
View File
@@ -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)
+124
View File
@@ -2,6 +2,11 @@ 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 }
t.describe('spec.parse', function()
t.it('parses a full table spec', function()
@@ -35,6 +40,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 +136,47 @@ 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 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 +216,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 +288,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)
+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