diff --git a/changes/packignore.md b/changes/packignore.md new file mode 100644 index 00000000..25c05771 --- /dev/null +++ b/changes/packignore.md @@ -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. diff --git a/docs/SHIPPING.md b/docs/SHIPPING.md index ab946da2..08a2bac7 100644 --- a/docs/SHIPPING.md +++ b/docs/SHIPPING.md @@ -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: diff --git a/tools/ludic-cli/main.ludic b/tools/ludic-cli/main.ludic index fd8a30b6..e8ac03b7 100644 --- a/tools/ludic-cli/main.ludic +++ b/tools/ludic-cli/main.ludic @@ -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/.lpak") + print(" pack [--out FILE] [dirs...] pack the assets into build/.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") diff --git a/tools/ludic-cli/pack.ludic b/tools/ludic-cli/pack.ludic index e4bc06f6..3a8df001 100644 --- a/tools/ludic-cli/pack.ludic +++ b/tools/ludic-cli/pack.ludic @@ -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() diff --git a/tools/ludic-cli/packignore.ludic b/tools/ludic-cli/packignore.ludic new file mode 100644 index 00000000..cc32752c --- /dev/null +++ b/tools/ludic-cli/packignore.ludic @@ -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 +} diff --git a/tools/ludic-cli/test.ludic b/tools/ludic-cli/test.ludic index 4f8d9427..460b0dbc 100644 --- a/tools/ludic-cli/test.ludic +++ b/tools/ludic-cli/test.ludic @@ -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 "\t" 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