Skip to content

Commit 42e8422

Browse files
pfitzsebclaude
andauthored
feat(bundler): support gitignore-style negation in .juliabundleignore (#130) (#161)
`.juliabundleignore` now supports `.gitignore`-style negation. Patterns are evaluated in file order and the last matching pattern wins: a plain pattern excludes a path, and a `!`-prefixed pattern re-includes a path an earlier pattern excluded. Comment (`#`) and blank lines are now skipped, and `\!` / `\#` escape a literal leading `!` / `#`. This lets users ignore everything and then add back only what they need, e.g.: * !programs/ !programs/* !Project.toml !Manifest.toml Matching switches from an unordered set with an "excluded if any pattern matches" test to an ordered rule list with last-match-wins semantics. With no `!` rules the result is identical to the previous behavior, so existing `.juliabundleignore` files are unaffected. Note: because `Tar.create` prunes excluded directories, re-including a path under a directory requires re-including the parent directory first (the same limitation git has); this is documented. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d63b523 commit 42e8422

4 files changed

Lines changed: 172 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
88

99
* The `expose` argument of `submit_job` now also accepts a `JuliaHub.JobRemoteAccess` object, which additionally allows configuring who can access the exposed port (`JuliaHub.JobAccessMode`) and a fixed DNS prefix for the job. Ports can now also be exposed on package application jobs. ([#154], [#158])
1010
* `JuliaHub.PackageJob` now supports overriding the job image (`image`) and selecting which revision of the application to launch (`revision`, see `JuliaHub.PackageAppRevision`). ([#154])
11+
* `.juliabundleignore` now supports `.gitignore`-style negation: a pattern starting with `!` re-includes paths excluded by an earlier pattern, with last-matching-pattern-wins semantics. Comment (`#`) and blank lines are also now skipped. ([#130])
1112

1213
### Fixed
1314

@@ -242,6 +243,7 @@ Initial package release.
242243
[#117]: https://github.com/JuliaComputing/JuliaHub.jl/issues/117
243244
[#124]: https://github.com/JuliaComputing/JuliaHub.jl/issues/124
244245
[#127]: https://github.com/JuliaComputing/JuliaHub.jl/issues/127
246+
[#130]: https://github.com/JuliaComputing/JuliaHub.jl/issues/130
245247
[#132]: https://github.com/JuliaComputing/JuliaHub.jl/issues/132
246248
[#133]: https://github.com/JuliaComputing/JuliaHub.jl/issues/133
247249
[#149]: https://github.com/JuliaComputing/JuliaHub.jl/issues/149

docs/src/reference/job-submission.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,33 @@ This `.juliabundleignore` will ignore:
150150

151151
You can also have additional `.juliabundleignore` files in subdirectories and they will only apply to those directories and their subdirectories.
152152

153+
#### Negating patterns (`!`)
154+
155+
Like `.gitignore`, a pattern starting with `!` re-includes files that an earlier
156+
pattern excluded. Patterns are applied top to bottom and the **last matching
157+
pattern wins**. This lets you ignore everything and then add back only what you
158+
need:
159+
160+
```
161+
*
162+
!programs/
163+
!programs/*
164+
!Project.toml
165+
!Manifest.toml
166+
```
167+
168+
This bundles only the `programs/` directory (and everything under it) plus the
169+
top-level `Project.toml` and `Manifest.toml`, and ignores everything else.
170+
171+
!!! note "Re-include the parent directory first"
172+
You cannot re-include a file whose parent directory is still excluded — the
173+
bundler skips excluded directories entirely and never descends into them (the
174+
same limitation `git` has). Re-include the directory itself (e.g. `!programs/`)
175+
before, or together with, its contents (`!programs/*`).
176+
177+
Lines beginning with `#` are treated as comments, and blank lines are ignored.
178+
Use `\!` or `\#` to match a file whose name literally begins with `!` or `#`.
179+
153180
### Specifying the job image
154181

155182
JuliaHub batch jobs can run in various container images.

src/PackageBundler/utils.jl

Lines changed: 45 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -73,17 +73,35 @@ function is_subpath(dir, subpath)
7373
return startswith(norm_subpath, norm_dir)
7474
end
7575

76+
# Parse one line of a `.juliabundleignore` file into an ordered rule, mirroring
77+
# `.gitignore` syntax. Returns `nothing` for blank lines and `#` comments. A
78+
# leading `!` marks a negated (re-include) rule; a leading `\#` or `\!` escapes a
79+
# literal `#`/`!`. The remainder is compiled with `Glob.FilenameMatch`, so glob
80+
# and trailing-slash directory patterns behave exactly as before.
81+
function _parse_bundleignore_line(line)
82+
s = strip(line)
83+
(isempty(s) || startswith(s, '#')) && return nothing
84+
negated = false
85+
if startswith(s, '!')
86+
negated = true
87+
s = chop(s; head=1, tail=0)
88+
elseif startswith(s, "\\!") || startswith(s, "\\#")
89+
s = chop(s; head=1, tail=0)
90+
end
91+
return (; negated, pattern=Glob.FilenameMatch(String(s)))
92+
end
93+
7694
function get_bundleignore(file, top)
7795
dir = dirname(file)
78-
patterns = Set{Any}()
96+
rules = @NamedTuple{negated::Bool, pattern::Glob.FilenameMatch}[]
7997
try
8098
while true
8199
if isfile(joinpath(dir, ".juliabundleignore"))
82-
union!(
83-
patterns,
84-
Glob.FilenameMatch.(strip.(readlines(joinpath(dir, ".juliabundleignore")))),
85-
)
86-
return patterns, dir
100+
for line in readlines(joinpath(dir, ".juliabundleignore"))
101+
rule = _parse_bundleignore_line(line)
102+
rule !== nothing && push!(rules, rule)
103+
end
104+
return rules, dir
87105
end
88106
if dir == dirname(dir) || dir == top
89107
break
@@ -93,7 +111,7 @@ function get_bundleignore(file, top)
93111
catch err
94112
@warn "Internal error" exception = (err, catch_backtrace())
95113
end
96-
return patterns, top
114+
return rules, top
97115
end
98116

99117
"""
@@ -139,18 +157,26 @@ function path_filterer(top)
139157
return false
140158
end
141159

142-
patterns, ignorepath = get_bundleignore(path, top)
143-
144-
rpath = relpath(path, ignorepath)
145-
146-
return !(
147-
any(p -> occursin(p, sanitize_windows_path(rpath)), patterns) ||
148-
# directories specifically can be excluded by patterns that end with a
149-
# path separator, and to match them in case `path` does not have that
150-
# path separator appended, we append it ourselves before matching
151-
isdir(path) &&
152-
any(p -> occursin(p, sanitize_windows_path(joinpath(rpath, ""))), patterns)
153-
)
160+
rules, ignorepath = get_bundleignore(path, top)
161+
162+
rel = relpath(path, ignorepath)
163+
rpath = sanitize_windows_path(rel)
164+
# directories can be matched by patterns ending in a path separator, so we
165+
# also test the path with a trailing separator appended.
166+
rpath_dir = sanitize_windows_path(joinpath(rel, ""))
167+
isdirp = isdir(path)
168+
169+
# gitignore semantics: evaluate rules in file order; the last rule that
170+
# matches decides. A plain (non-negated) rule that matches excludes the
171+
# path; a negated (`!`) rule that matches re-includes it. Paths matched by
172+
# no rule are kept.
173+
included = true
174+
for rule in rules
175+
if occursin(rule.pattern, rpath) || (isdirp && occursin(rule.pattern, rpath_dir))
176+
included = rule.negated
177+
end
178+
end
179+
return included
154180
end
155181
end
156182

test/packagebundler.jl

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,104 @@ end
267267
end
268268
end
269269

270+
@testset "path_filterer negation" begin
271+
# gitignore-style: ignore everything, then re-include specific paths.
272+
root = mktempdir()
273+
write(
274+
joinpath(root, ".juliabundleignore"),
275+
"""
276+
*
277+
!programs/
278+
!programs/*
279+
!Project.toml
280+
!Manifest.toml
281+
""",
282+
)
283+
mkpath(joinpath(root, "programs", "appA"))
284+
touch(joinpath(root, "programs", "appA", "main.jl"))
285+
touch(joinpath(root, "programs", "loose.txt"))
286+
mkpath(joinpath(root, "secret"))
287+
touch(joinpath(root, "secret", "data.csv"))
288+
touch(joinpath(root, "Project.toml"))
289+
touch(joinpath(root, "Manifest.toml"))
290+
touch(joinpath(root, "README.md"))
291+
pred = JuliaHub._PackageBundler.path_filterer(root)
292+
293+
# Re-included: parent dir (so Tar descends), its subdir, deep file, loose file.
294+
@test pred(joinpath(root, "programs"))
295+
@test pred(joinpath(root, "programs", "appA"))
296+
@test pred(joinpath(root, "programs", "appA", "main.jl"))
297+
@test pred(joinpath(root, "programs", "loose.txt"))
298+
@test pred(joinpath(root, "Project.toml"))
299+
@test pred(joinpath(root, "Manifest.toml"))
300+
# Still excluded: unlisted dir and file.
301+
@test !pred(joinpath(root, "secret"))
302+
@test !pred(joinpath(root, "README.md"))
303+
304+
# Last-matching rule wins (order matters).
305+
root2 = mktempdir()
306+
write(
307+
joinpath(root2, ".juliabundleignore"),
308+
"""
309+
*.log
310+
!keep*.log
311+
keep-tmp.log
312+
""",
313+
)
314+
touch(joinpath(root2, "app.log"))
315+
touch(joinpath(root2, "keep-final.log"))
316+
touch(joinpath(root2, "keep-tmp.log"))
317+
pred2 = JuliaHub._PackageBundler.path_filterer(root2)
318+
@test !pred2(joinpath(root2, "app.log")) # ignored by *.log
319+
@test pred2(joinpath(root2, "keep-final.log")) # re-included by !keep*.log
320+
@test !pred2(joinpath(root2, "keep-tmp.log")) # re-ignored by later keep-tmp.log
321+
322+
# Comments and blank lines are inert.
323+
root3 = mktempdir()
324+
write(joinpath(root3, ".juliabundleignore"), "# just a comment\n\n*.tmp\n")
325+
touch(joinpath(root3, "a.tmp"))
326+
touch(joinpath(root3, "b.jl"))
327+
pred3 = JuliaHub._PackageBundler.path_filterer(root3)
328+
@test !pred3(joinpath(root3, "a.tmp"))
329+
@test pred3(joinpath(root3, "b.jl"))
330+
end
331+
332+
@testset "bundle negation (Tar)" begin
333+
root = mktempdir()
334+
write(
335+
joinpath(root, ".juliabundleignore"),
336+
"""
337+
*
338+
!programs/
339+
!programs/*
340+
!Project.toml
341+
!Manifest.toml
342+
""",
343+
)
344+
mkpath(joinpath(root, "programs", "appA"))
345+
write(joinpath(root, "programs", "appA", "main.jl"), "println(1)")
346+
write(joinpath(root, "programs", "notes.txt"), "hi")
347+
mkpath(joinpath(root, "secret"))
348+
write(joinpath(root, "secret", "data.csv"), "x,y")
349+
write(joinpath(root, "Project.toml"), "name = \"X\"\n")
350+
write(joinpath(root, "Manifest.toml"), "")
351+
write(joinpath(root, "README.md"), "readme")
352+
353+
tb = tempname()
354+
Tar.create(JuliaHub._PackageBundler.path_filterer(root), root, tb)
355+
ex = mktempdir()
356+
Tar.extract(tb, ex)
357+
358+
# Non-empty and contains exactly the re-included content.
359+
@test isfile(joinpath(ex, "programs", "appA", "main.jl"))
360+
@test isfile(joinpath(ex, "programs", "notes.txt"))
361+
@test isfile(joinpath(ex, "Project.toml"))
362+
@test isfile(joinpath(ex, "Manifest.toml"))
363+
# Excluded paths must not be present.
364+
@test !ispath(joinpath(ex, "secret"))
365+
@test !isfile(joinpath(ex, "README.md"))
366+
end
367+
270368
@testset "cp_skip_dangling_symlinks" begin
271369
mktempdir() do src
272370
# Regular file

0 commit comments

Comments
 (0)