feat(pack): .packignore, so a pack root can leave build artefacts out

A pack root is packed wholesale, and that is the right default - a game writes
`pack "assets"` and everything it opens is in the pack. What also goes in is
everything the game does NOT open: the preview renders a model pipeline leaves
beside its meshes, the intermediate a texture bake writes and never reads again,
the .blend the .gltf came out of. Nothing errors, nothing looks wrong, and the
app is simply bigger than the game. The only way out the manifest offered was
naming every file by hand, which is worse - a list that goes stale the day
someone adds a texture.

So `.packignore`, with gitignore's rules, because that is the file everyone
already knows. Anchored and floating patterns, `preview/` for directories only,
`*` and `?` stopping at a separator where `**` crosses one, `[a-z]` classes, `!`
re-includes with the last line winning, a deeper file beating a shallower one,
and no re-including out of an ignored directory.

The semantics are not claimed, they are checked: the implementation was diffed
against git itself over two fixtures - 35 paths, 19 patterns, nested ignore
files, directory negation, `[!0-9]` and `\#` escaping - and `git check-ignore`
and `ludic pack` agree on every path.

Two rules of its own, because a pack is not a working tree. `.packignore` is
never packed (nothing reads one at run time, and --no-ignore does not bring it
back). And it governs the project's own roots only: a package's resources - the
renderer's shaders above all - are added after the gather, so a stray `*.frag`
in a game's ignore file cannot quietly un-ship what it needs to draw anything.

`ludic pack` reports what it left out; `--no-ignore` packs everything so you can
see what a rule is costing. `ludic bundle` gathers through the same path, so the
two agree by construction.
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-11 18:29:32 +03:00
parent 841ae1d442
commit cf814d4a97
6 changed files with 484 additions and 5 deletions

21
changes/packignore.md Normal file
View file

@ -0,0 +1,21 @@
bump: minor
type: feat
**`.packignore`** — keep build artefacts out of the shipped asset pack.
A pack root is packed wholesale, so everything a model or texture pipeline leaves
beside its output ships too: preview renders, bake intermediates, the `.blend` a
`.gltf` came from. Nothing errors and nothing looks wrong — the app is just bigger
than the game. The only way out was naming every file in `package.ludic`, a list
that goes stale the day someone adds a texture.
Put a `.packignore` beside the assets instead. The rules are **gitignore's**, down
to the parts people rely on without thinking about them: patterns anchored by a
slash or floating without one, `preview/` for directories only, `*` and `?` stopping
at a separator where `**` crosses it, `[a-z]` classes, `!` re-includes with the last
line winning, a deeper file beating a shallower one, and no re-including out of an
ignored directory.
`ludic pack` reports what it left out, and `--no-ignore` packs everything so you can
see what a rule costs. `ludic bundle` gathers the same way. `.packignore` itself is
never packed, and the file only governs your own roots — a package's resources, such
as the renderer's shaders, are added afterwards and cannot be excluded by accident.

View file

@ -84,6 +84,59 @@ ludic pack --list build/maroon-lake.lpak # what is in it
ludic pack --verify build/maroon-lake.lpak # re-hash every entry
```
### Keeping things out: `.packignore`
A pack root goes in wholesale, which is the right default and the reason a game only
has to write `pack "assets"`. It also means everything the game does *not* open ships
with it — the preview renders a model pipeline leaves beside its meshes, the
intermediate a texture bake writes and never reads again, the `.blend` the `.gltf` came
out of. Nothing errors and nothing looks wrong; the app is just bigger than the game.
Put a `.packignore` beside the assets and those stop shipping:
```
# what the builders leave behind
preview/ # a directory, at any depth
*.blend # a name, at any depth
/scratch # only at this file's own level
**/tmp # any depth, said explicitly
textures/*_intermediate.png
!textures/keep_intermediate.png # ...except this one
```
The rules are **gitignore's**, because that is the file everyone already knows:
- Blank lines and `#` comments are skipped; write `\#` or `\!` for a literal leader.
- A pattern with **no** slash matches a name at any depth. A pattern **with** one is
anchored to the directory holding the `.packignore`.
- A trailing `/` means directory-only: `preview/` never matches a *file* called
`preview`.
- `*` and `?` stop at a `/`; `**` crosses one. `a/**/b` matches `a/b` too.
- `[abc]`, `[a-z]` and `[!abc]` are character classes.
- `!` re-includes, and within one file the **last** matching line wins.
- A `.packignore` deeper in the tree beats a shallower one — a kit can keep something
the project excludes everywhere else.
- A file under an ignored **directory** cannot be re-included, exactly as in git.
Ignore the files rather than the directory when that is what you meant.
Two rules of its own, because a pack is not a working tree:
- **`.packignore` is never packed.** Nothing reads one at run time, and `--no-ignore`
does not bring it back either.
- **It applies to your roots, not to a package's.** The resources a package contributes
to a bundle — the renderer's shaders above all — are added after your roots are
gathered, so a stray `*.frag` in a game's ignore file cannot quietly un-ship them.
`ludic pack` says what it left out, and `--no-ignore` packs everything so you can see
what a rule is costing:
```bash
ludic pack # packed 216 files, 358377 KiB, 164 skipped by .packignore -> ...
ludic pack --no-ignore # packed 380 files, 390820 KiB -> ...
```
`ludic bundle` gathers assets the same way, so the two agree by construction.
### More than one pack
`Contents/Resources/packs.index` is a plain list, and the order is load-bearing:

View file

@ -25,6 +25,7 @@ program Ludic {
import "project.ludic"
import "pkg.ludic"
import "assets.ludic"
import "packignore.ludic"
import "pack.ludic"
import "bundle.ludic"
@ -52,7 +53,8 @@ program Ludic {
print(" link-flags print the clang flags to link this project's prebuilt module dylibs")
print("")
print("shipping:")
print(" pack [--out FILE] [dirs...] pack the assets into build/<name>.lpak")
print(" pack [--out FILE] [dirs...] pack the assets into build/<name>.lpak (honouring .packignore)")
print(" pack --no-ignore ... pack everything, ignoring .packignore")
print(" pack --list FILE print what is in a pack")
print(" pack --verify FILE re-hash every entry against the table")
print(" bundle [file] build a macOS .app: icon, metadata, assets, splash")

View file

@ -79,6 +79,11 @@ function read_blob(path: pointer) -> pointer {
property PackList { names: []pointer, paths: []pointer }
# how many files `.packignore` kept out of the last gather, and whether to read one
# at all (`ludic pack --no-ignore` turns it off to see what a rule is costing)
var pk_skipped: int = 0
var pk_use_ignore: bool = true
function pack_list_new() -> PackList {
let l = new PackList
l.names = new []pointer
@ -99,13 +104,20 @@ function pack_add(l: PackList, name: pointer, path: pointer) -> void {
# `base` is the directory find runs in and `prefix` what the results are stored
# under: with both empty the name is the path, which is the ordinary case.
function pack_gather_into(l: PackList, base: pointer, root: pointer, prefix: pointer) -> void {
pack_gather_filtered(l, base, root, prefix, null)
}
# the same, with a `.packignore` set applied to the paths as they are gathered
function pack_gather_filtered(l: PackList, base: pointer, root: pointer, prefix: pointer, ig: PackIgnore) -> void {
var cd = ""
if base != "" { cd = `cd {base} && ` }
# -L follows symlinks. An asset root is very often a link to a shared or
# fetched tree - `ludic assets` writes one, and a game with two checkouts
# sharing a texture set will have several - and a plain `find` walks straight
# past them, packing nothing while reporting success.
let listing = capture(`{cd}find -L {root} -type f ! -name '.DS_Store' 2>/dev/null | LC_ALL=C sort`)
# `.packignore` is a build-time control file - nothing reads one at run time - so it
# is dropped here rather than by the rules it carries, and `--no-ignore` does not
# bring it back.
let listing = capture(`{cd}find -L {root} -type f ! -name '.DS_Store' ! -name '.packignore' 2>/dev/null | LC_ALL=C sort`)
let n = slen(listing)
var i = 0
while i < n {
@ -113,6 +125,7 @@ function pack_gather_into(l: PackList, base: pointer, root: pointer, prefix: poi
i = i + slen(line) + 1
let rel = s_trim(line)
if slen(rel) == 0 { continue }
if ig != null and pi_ignored(ig, rel) { pk_skipped += 1; continue }
var name = rel
if prefix != "" { name = `{prefix}/{rel}` }
var path = rel
@ -124,18 +137,29 @@ function pack_gather_into(l: PackList, base: pointer, root: pointer, prefix: poi
# the project's own asset roots, stored under the names the game already uses
function pack_gather_all(roots: []pointer) -> PackList {
let l = pack_list_new()
pk_skipped = 0
# `.packignore` (packignore.ludic): the project's own roots only. A package's
# resources are added after this and are not the game's to exclude.
var ig: PackIgnore = null
if pk_use_ignore {
ig = pi_new()
pi_load_dir(ig, "")
var r = 0
while r < len(roots) { pi_load_root(ig, roots[r]); r += 1 }
if pi_count(ig) == 0 { ig = null }
}
var i = 0
while i < len(roots) {
if not file_exists(roots[i]) {
err(`ludic pack: no such directory: {roots[i]}\n`)
} else {
let before = len(l.names)
pack_gather_into(l, "", roots[i], "")
pack_gather_filtered(l, "", roots[i], "", ig)
# A declared root that contributes nothing is almost always a mistake -
# a typo, or a link into a tree that was never fetched - and the failure
# it causes is a game that starts and then behaves as though half its
# assets do not exist. Say so here rather than at the player.
if len(l.names) == before { err(`ludic pack: warning: {roots[i]} is empty, nothing packed from it\n`) }
if len(l.names) == before and pk_skipped == 0 { err(`ludic pack: warning: {roots[i]} is empty, nothing packed from it\n`) }
}
i += 1
}
@ -269,7 +293,9 @@ function pack_write(out_path: pointer, l: PackList) -> bool {
}
file_close(f)
print(`packed {string(n)} files, {string(total / 1024)} KiB -> {out_path}`)
var note = ""
if pk_skipped > 0 { note = `, {string(pk_skipped)} skipped by .packignore` }
print(`packed {string(n)} files, {string(total / 1024)} KiB{note} -> {out_path}`)
return true
}
@ -423,6 +449,7 @@ function cmd_pack() -> int {
if a == "--list" { return cmd_pack_list(argn(i + 1, "")) }
if a == "--verify" { return cmd_pack_verify(argn(i + 1, "")) }
if a == "--out" { out_path = argn(i + 1, ""); i += 1 }
if a == "--no-ignore" { pk_use_ignore = false }
i += 1
}
let m = read_root_manifest()

View file

@ -0,0 +1,311 @@
# packignore.ludic — `.packignore`: what the shipped asset pack leaves out.
#
# A pack root is packed WHOLESALE, and that is the right default: a game says
# `pack "assets"` and everything the game opens is in the pack. What it also puts in
# the pack is everything the game does NOT open — the preview renders a model builder
# writes beside its meshes, the intermediate a texture bake leaves behind, the `.blend`
# the `.gltf` came out of. None of it errors, none of it is visible, and all of it
# ships. The alternative the manifest offered was naming every file one by one, which
# is worse: it is a list that goes stale the day someone adds a texture.
#
# So the ignore file, and deliberately the one everybody already knows:
#
# # a comment
# preview/ # a directory, anywhere under here
# *.blend # by extension, at any depth
# /scratch # only at the root of this ignore file's directory
# **/tmp # any depth, said explicitly
# !keep/important.png # re-include something an earlier line excluded
#
# The semantics are gitignore's, and the ones that matter are the ones people rely on
# without thinking about them:
#
# - Blank lines and `#` comments are skipped. A literal `#` or `!` at the start of a
# pattern is written `\#` / `\!`.
# - A pattern with NO slash matches a name at ANY depth. `*.blend` catches
# `a/b/c.blend`.
# - A pattern WITH a slash is anchored to the directory holding the `.packignore`.
# `/scratch` is that directory's own `scratch`; `doc/tmp` is `doc/tmp` under it.
# - A trailing slash means "directory only": `preview/` ignores the directory and
# everything under it, and never a FILE called `preview`.
# - `*` and `?` do not cross a `/`; `**` does. `**/tmp` is any `tmp`, `a/**/b` is `b`
# under `a` at any depth, `a/b` included.
# - `[abc]`, `[a-z]` and `[!abc]` are character classes.
# - `!` re-includes. Within one file the LAST matching line decides.
# - A `.packignore` deeper in the tree beats a shallower one for the paths it covers.
# - A file under an ignored DIRECTORY cannot be re-included, exactly as in git: once
# `preview/` is out, `!preview/keep.png` does not bring it back. Ignore the files
# rather than the directory if that is what you meant.
#
# Two rules of its own, both because this is a pack and not a working tree:
#
# - `.packignore` files are never packed - nothing reads one at run time. That is
# done in the gather (`pack.ludic`), not by a rule here, so `--no-ignore` does not
# bring them back either.
# - It applies to the PROJECT's own roots. The resources a package contributes to a
# bundle - the renderer's shaders, say - are that package's business and are added
# after this runs, so a stray `*.frag` in a game's ignore file cannot silently
# un-ship the shaders it needs to draw anything.
const PI_UNKNOWN: int = -1
const PI_INCLUDE: int = 0
const PI_IGNORE: int = 1
property PackIgnore {
base: []pointer, # directory the rule was read in, relative to the pack cwd ("" = root)
depth: []int, # segments in `base`; a deeper file wins over a shallower one
pat: []pointer, # the pattern, with `!`, a leading `/` and a trailing `/` stripped
negate: []int,
dironly: []int,
anchored: []int
}
function pi_new() -> PackIgnore {
let g = new PackIgnore
g.base = new []pointer
g.depth = new []int
g.pat = new []pointer
g.negate = new []int
g.dironly = new []int
g.anchored = new []int
return g
}
function pi_count(g: PackIgnore) -> int { return len(g.pat) }
# how many segments deep a directory is; "" is 0
function pi_depth_of(dir: pointer) -> int {
if dir == "" { return 0 }
var n = 1
var i = 0
let e = slen(dir)
while i < e {
if dir[i] == '/' { n += 1 }
i += 1
}
return n
}
# ---- reading one file -------------------------------------------------------
# Parse `{dir}/.packignore` into rules tagged with `dir`. Missing file: nothing.
function pi_load_dir(g: PackIgnore, dir: pointer) -> void {
var path = ".packignore"
if dir != "" { path = `{dir}/.packignore` }
if not file_exists(path) { return }
let text = read_file(path)
if text == null { return }
let n = slen(text)
let d = pi_depth_of(dir)
var i = 0
while i < n {
let line = line_at(text, i)
i = i + slen(line) + 1
pi_add_rule(g, dir, d, s_trim(line))
}
}
function pi_add_rule(g: PackIgnore, dir: pointer, depth: int, raw: pointer) -> void {
var p = raw
if slen(p) == 0 { return }
if p[0] == '#' { return }
var neg = 0
if p[0] == '!' { neg = 1; p = sslice(p, 1, slen(p)) }
# an escaped leading marker is a literal one
if slen(p) > 1 and p[0] == '\\' and (p[1] == '#' or p[1] == '!') { p = sslice(p, 1, slen(p)) }
if slen(p) == 0 { return }
var dironly = 0
while slen(p) > 0 and p[slen(p) - 1] == '/' { dironly = 1; p = sslice(p, 0, slen(p) - 1) }
if slen(p) == 0 { return }
# `**/x` is the explicit spelling of "x at any depth", so it is NOT anchored
var anchored = 1
if s_starts(p, "**/") { p = sslice(p, 3, slen(p)); anchored = 0 }
else if p[0] == '/' { p = sslice(p, 1, slen(p)) }
else if not s_contains(p, "/") { anchored = 0 }
if slen(p) == 0 { return }
push(g.base, dir)
push(g.depth, depth)
push(g.pat, p)
push(g.negate, neg)
push(g.dironly, dironly)
push(g.anchored, anchored)
}
# Every `.packignore` that can bear on `root`: the one at the pack's working
# directory, then one per directory inside it. Call once per pack, for each root.
function pi_load_root(g: PackIgnore, root: pointer) -> void {
let listing = capture(`find -L {root} -name .packignore -type f 2>/dev/null | LC_ALL=C sort`)
let n = slen(listing)
var i = 0
while i < n {
let line = line_at(listing, i)
i = i + slen(line) + 1
let rel = s_trim(line)
if slen(rel) == 0 { continue }
pi_load_dir(g, pi_dirname(rel))
}
}
# the directory part of a path, "" when there is none
function pi_dirname(path: pointer) -> pointer {
var cut = -1
var i = 0
let e = slen(path)
while i < e {
if path[i] == '/' { cut = i }
i += 1
}
if cut < 0 { return "" }
return sslice(path, 0, cut)
}
function pi_basename(path: pointer) -> pointer {
var cut = -1
var i = 0
let e = slen(path)
while i < e {
if path[i] == '/' { cut = i }
i += 1
}
if cut < 0 { return path }
return sslice(path, cut + 1, e)
}
# ---- matching ---------------------------------------------------------------
# One glob against one string. `*` and `?` stop at a `/`; `**` does not.
function pi_glob(pat: pointer, txt: pointer) -> bool {
return pi_glob_at(pat, 0, txt, 0)
}
function pi_glob_at(p: pointer, pi: int, t: pointer, ti: int) -> bool {
var i = pi
var j = ti
while true {
if p[i] == 0 { return t[j] == 0 }
# `**` — crosses separators
if p[i] == '*' and p[i + 1] == '*' {
var rest = i + 2
if p[rest] == '/' {
# `a/**/b` also matches `a/b`: the zero-directory case
if pi_glob_at(p, rest + 1, t, j) { return true }
var k = j
while t[k] != 0 {
if t[k] == '/' { if pi_glob_at(p, rest + 1, t, k + 1) { return true } }
k += 1
}
return false
}
var k2 = j
if pi_glob_at(p, rest, t, k2) { return true }
while t[k2] != 0 {
k2 += 1
if pi_glob_at(p, rest, t, k2) { return true }
}
return false
}
if p[i] == '*' {
var k = j
if pi_glob_at(p, i + 1, t, k) { return true }
while t[k] != 0 and t[k] != '/' {
k += 1
if pi_glob_at(p, i + 1, t, k) { return true }
}
return false
}
if p[i] == '?' {
if t[j] == 0 or t[j] == '/' { return false }
i += 1; j += 1; continue
}
if p[i] == '[' {
if t[j] == 0 or t[j] == '/' { return false }
let after = pi_class(p, i, t[j] & 255)
if after < 0 { return false }
i = after; j += 1; continue
}
if p[i] == '\\' and p[i + 1] != 0 {
if t[j] != p[i + 1] { return false }
i += 2; j += 1; continue
}
if t[j] != p[i] { return false }
if p[i] == 0 { return true }
i += 1; j += 1
}
return false
}
# A `[...]` class at p[at]. Returns the index just past the `]` on a match, or -1.
# A `]` straight after the `[` or the `!` is a literal one, as in a shell.
function pi_class(p: pointer, at: int, c: int) -> int {
var i = at + 1
var neg = false
if p[i] == '!' or p[i] == '^' { neg = true; i += 1 }
var hit = false
var first = true
while p[i] != 0 and (p[i] != ']' or first) {
first = false
var lo = p[i] & 255
if p[i] == '\\' and p[i + 1] != 0 { i += 1; lo = p[i] & 255 }
if p[i + 1] == '-' and p[i + 2] != 0 and p[i + 2] != ']' {
let hi = p[i + 2] & 255
if c >= lo and c <= hi { hit = true }
i += 3
} else {
if c == lo { hit = true }
i += 1
}
}
if p[i] != ']' { return -1 } # unterminated: not a class at all
if neg { hit = not hit }
if not hit { return -1 }
return i + 1
}
# Does one rule bear on `path`, and does it match?
function pi_rule_hits(g: PackIgnore, r: int, path: pointer, is_dir: bool) -> bool {
if g.dironly[r] == 1 and not is_dir { return false }
let base = g.base[r]
var sub = path
if base != "" {
if not s_starts(path, `{base}/`) { return false }
sub = sslice(path, slen(base) + 1, slen(path))
}
if g.anchored[r] == 1 { return pi_glob(g.pat[r], sub) }
return pi_glob(g.pat[r], pi_basename(sub))
}
# The decision for one path: the DEEPEST `.packignore` with something to say wins,
# and within one file the LAST line that matches.
function pi_decide(g: PackIgnore, path: pointer, is_dir: bool) -> int {
var best = -1
var out = PI_UNKNOWN
var r = 0
let n = len(g.pat)
while r < n {
if pi_rule_hits(g, r, path, is_dir) and g.depth[r] >= best {
best = g.depth[r]
if g.negate[r] == 1 { out = PI_INCLUDE } else { out = PI_IGNORE }
}
r += 1
}
return out
}
# Is this file kept out of the pack?
#
# An ignored DIRECTORY takes everything under it and a `!` cannot bring any of it back
# - git's rule, and the one that makes `preview/` mean what people expect. So the
# ancestors are tested first, shallowest down, and the file itself only if they all
# passed.
function pi_ignored(g: PackIgnore, path: pointer) -> bool {
if len(g.pat) == 0 { return false }
var i = 0
let e = slen(path)
while i < e {
if path[i] == '/' {
if pi_decide(g, sslice(path, 0, i), true) == PI_IGNORE { return true }
}
i += 1
}
return pi_decide(g, path, false) == PI_IGNORE
}

View file

@ -157,6 +157,70 @@ function pack_probe_src() -> pointer {
return s
}
# `.packignore` — the pack's exclude file, with gitignore's semantics. The feature it
# exists for is that a pack root is packed wholesale: the preview renders, bake
# intermediates and .blend files a model pipeline leaves beside its meshes all shipped,
# and the only alternative the manifest offered was naming every file by hand.
#
# The patterns below are one of each kind that people actually rely on, and the assert
# is the exact set that comes out - both halves matter, because a rule that excludes too
# much is as bad as one that excludes nothing and is just as quiet.
function packignore_case() -> void {
let lbl = "ludic pack -> .packignore excludes, with gitignore's rules"
let work = `{tmp_dir()}/packign`
let root = capture_line("pwd")
run(`rm -rf {work}`)
run(`mkdir -p {work}/assets/kit/preview {work}/assets/deep/a/tmp {work}/assets/scratch {work}/assets/sub/scratch {work}/assets/keep`)
write_file(`{work}/assets/kit/preview/x.png`, "x") # preview/ - a directory, any depth
write_file(`{work}/assets/kit/model.blend`, "x") # *.blend - a name, any depth
write_file(`{work}/assets/kit/hiker.png`, "x") # kept
write_file(`{work}/assets/deep/a/tmp/junk.txt`, "x") # **/tmp
write_file(`{work}/assets/deep/a/keep.txt`, "x") # kept
write_file(`{work}/assets/scratch/s.txt`, "x") # /assets/scratch - anchored
write_file(`{work}/assets/sub/scratch/keep.txt`, "x") # kept: the anchor does not reach here
write_file(`{work}/assets/keep/other.png`, "x") # assets/keep/*.png
write_file(`{work}/assets/keep/important.png`, "x") # kept: re-included by !
write_file(`{work}/assets/preview`, "x") # kept: a FILE, and the rule says directory
var ig = "# what nobody opens" + nl()
ig = ig + "preview/" + nl() + "*.blend" + nl() + "/assets/scratch" + nl() + "**/tmp" + nl()
ig = ig + "assets/keep/*.png" + nl() + "!assets/keep/important.png" + nl()
write_file(`{work}/.packignore`, ig)
if not shq(`cd {work} && {root}/bin/ludic pack --out o.lpak assets > pack.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/pack.out`)); return
}
# cut, not awk: `--list` prints "<size>\t<name>" and awk's braces collide with string
# interpolation. capture_line joins the lines with spaces, which is the comparison.
let got = capture_line(`cd {work} && {root}/bin/ludic pack --list o.lpak | tail -n +2 | cut -f2 | LC_ALL=C sort`)
let want = "assets/deep/a/keep.txt assets/keep/important.png assets/kit/hiker.png assets/preview assets/sub/scratch/keep.txt"
if got != want {
bad2(lbl, `packed [{got}], wanted [{want}]`); return
}
# a deeper .packignore wins over a shallower one, which is what makes a kit able to
# keep something the project excludes everywhere else
write_file(`{work}/assets/kit/.packignore`, "!*.blend" + nl())
if not shq(`cd {work} && {root}/bin/ludic pack --out o2.lpak assets > pack2.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/pack2.out`)); return
}
if not shq(`cd {work} && {root}/bin/ludic pack --list o2.lpak | grep -q 'assets/kit/model.blend'`) {
bad2(lbl, "a nested !*.blend did not win over the root rule"); return
}
# and an ignore file is never itself an asset, whatever the rules say
if shq(`cd {work} && {root}/bin/ludic pack --list o2.lpak | grep -q packignore`) {
bad2(lbl, ".packignore was packed"); return
}
# --no-ignore is the escape hatch: everything, minus the control files
if not shq(`cd {work} && {root}/bin/ludic pack --no-ignore --out o3.lpak assets > pack3.out 2>&1`) {
bad2(lbl, capture_line(`tail -1 {work}/pack3.out`)); return
}
let n3 = capture_line(`cd {work} && {root}/bin/ludic pack --list o3.lpak | tail -n +2 | wc -l | tr -d ' '`)
if n3 != "10" {
bad2(lbl, `--no-ignore packed {n3} files, wanted 10`); return
}
ok(lbl)
}
# `ludic bundle` has to produce something macOS actually accepts as an
# application, not a directory that looks like one: a plist that plutil parses,
# an executable where Info.plist says it is, an .icns, and a signature. The
@ -608,6 +672,7 @@ function cmd_dev_test() -> int {
install_layout_case()
scaffold_names_case()
pack_roundtrip_case()
packignore_case()
bundle_case()
# install.sh is what the landing page tells people to pipe into sh, and it is