Skip to content

--exclude: use .gitignore glob syntax - #154

Merged
martinus merged 3 commits into
masterfrom
gitignore-exclude
Jul 26, 2026
Merged

--exclude: use .gitignore glob syntax#154
martinus merged 3 commits into
masterfrom
gitignore-exclude

Conversation

@martinus

Copy link
Copy Markdown
Owner

Closes #147.

--exclude node_modules matched nothing, silently. Patterns were fnmatch'd against the whole path and relative ones were resolved against the cwd, so a bare name could only ever match one literal directory — and usually not even that. Every NAS user's first instinct (@eaDir, .snapshots, node_modules) was a no-op, and the tool said nothing.

The syntax

The one git, ripgrep and fd already use:

Pattern Meaning
@eaDir, *.iso no / → matches the name at any depth
/srv/media/cache* leading /absolute, anchored
Steam/temp interior / → matches at any depth
cache/ trailing /directories only
* ? [a-z] ** * stops at /, ** crosses

Negation (!) is deliberately unsupported — last-match-wins ordering is where the complexity and the bugs are, and nobody has asked for it.

Excluding a directory prunes the walk there, so naming a directory also drops everything inside it.

This is a breaking change

Taken deliberately, without syntax versioning, so a pattern only ever has one meaning. Patterns stored in an existing hashfile replay under the new rules — which matters most for scheduled systemd jobs.

Two things blunt the edge:

  • A pattern that matches nothing is now reported, instead of being silent. That is what catches a stored pattern whose meaning narrowed.
  • A malformed pattern is an error before the scan starts, on both the command line and the replay path, rather than a warning the run ignores while scanning a wider tree and exiting 0.

The widening direction (a stored relative pattern that now matches more) can't be caught by a count, but -v already prints Excluding: <path> (matches <pattern>) per file, which shows it directly. Documented as Changed in 1.6.0 in the man page, with an upgrade note in the NAS quick-start where affected users actually look, and in CLAUDE.md for contributors.

Implementation

New src/glob.{c,h}. Patterns compile to PCRE2 fragments joined into one combined GRegex, so matching costs one regex run per path however many patterns there are. Exact absolute paths skip it via a hash lookup. GRegex is PCRE2 and GLib was already linked, so no new dependency.

The set is compiled once in filescan_init() — main thread, before any walker exists — and is read-only afterwards apart from one relaxed _Atomic bool per pattern, so the walkers need no lock. The per-pattern regexes survive only to name the matching pattern and apply the directory-only rule, which runs on paths already being excluded, never on the hot negative path.

The hashfile and its -wal/-shm sidecars register via add_exclude_path() and match literally, so a * or [ in the hashfile's own path cannot become a wildcard.

Performance

is_excluded() runs once per directory entry on every walker, so this was measured rather than assumed. With PCRE2's JIT enabled (G_REGEX_OPTIMIZE), 4096 realistic paths against 3 patterns:

ns/path
old: 3× strcmp + 3× fnmatch 138
new, without JIT 3694
new, as shipped 304

~2.2× the absolute cost of the old code, but O(1) in pattern count rather than O(n) — which is the trade the design is for. The JIT flag is not optional here; without it this would have been a 12× regression on a hot path. (It is also well under the statx/btrfs-btree cost that dominates the walk.)

Testing

9 unit tests over the matching rules, 10 integration tests over observable behaviour — pruning, the S_ISDIR plumbing that the unit tests can't reach, the warning, and the malformed-pattern exit code.

scripts/verify.sh passes: build with warnings-as-failure, 126 tests, valgrind scan+dedupe+replay smoke.

A /simplify pass is the second commit: it found the missing JIT flag, a plain (non-atomic) shared counter written from the walker threads, and the replay path ignoring pattern errors. See that commit message for the full list.

🤖 Generated with Claude Code

martinus and others added 3 commits July 26, 2026 13:22
`--exclude node_modules` matched nothing, silently. Patterns were fnmatch'd
against the whole path and relative ones were resolved against the cwd, so a
bare name could only ever match one literal directory -- and usually not even
that. Every NAS user's first instinct (@eadir, .snapshots, node_modules) was a
no-op, and the tool said nothing (#147).

Adopt the syntax users already have in their fingers, the one git, ripgrep and
fd use:

  - no '/'          -> matches the name at any depth   (@eadir, *.iso)
  - leading '/'     -> absolute, anchored              (/srv/media/cache*)
  - interior '/'    -> matches at any depth            (Steam/temp)
  - trailing '/'    -> directories only                (cache/)
  - '*' stops at '/', '**' crosses, '?' and [a-z] as usual

Negation ('!') is deliberately unsupported: last-match-wins ordering is where
the complexity and the bugs are, and nobody has asked for it.

This is a behaviour change, taken deliberately without syntax versioning so
there is only ever one meaning for a pattern. Patterns stored in an existing
hashfile replay under the new rules. Two things blunt the edge: a pattern that
matches nothing is now reported instead of being silent, and a malformed one is
an error before the scan starts rather than a warning the run ignores.

New src/glob.{c,h}. Patterns compile to PCRE2 fragments joined into one
combined GRegex, so matching costs one regex run per path however many patterns
there are; exact absolute paths skip it via a hash lookup. GRegex is PCRE2 and
GLib is already linked, so this adds no dependency. The set is compiled once in
filescan_init() -- main thread, before any walker exists -- and is read-only
after, so the walkers need no lock. The per-pattern regexes survive only to
attribute a hit for the -v message, which runs on paths already being skipped,
never on the hot negative path.

The hashfile and its -wal/-shm sidecars now register via add_exclude_path(),
matched literally, so a '*' or '[' in the hashfile's own path cannot become a
wildcard and drop unrelated files.

Also fixes a silent failure found while testing: a rejected --exclude printed an
error and carried on, scanning a wider tree than asked for and still exiting 0.

9 unit tests over the matching rules, 10 integration tests over the observable
behaviour. scripts/verify.sh passes (126 tests, valgrind smoke).

Co-Authored-By: Claude <noreply@anthropic.com>
Review pass over the --exclude change. One finding was a real regression, the
rest are structural.

Turn on PCRE2's JIT (G_REGEX_OPTIMIZE) at both g_regex_new() sites. Without it
the new matcher was ~12x slower per path than the fnmatch() code it replaces --
measured in a harness over 4096 realistic paths with 3 patterns: 138 ns/path
old, 3694 ns as written, 304 ns with the flag. That is per directory entry on
every walker, so on a million-file tree it is seconds of CPU. The flag is not
deprecated in any GLib we target (it predates 2.14) and compiles clean under
the project's warning set.

Structural cuts, all behaviour-preserving:

  - One combined regex, not two. The dir-only alternation earned nothing:
    attribute() already applies dir_only and is the only thing that can name
    the matching pattern, so directories were paying a second regex run on the
    hot negative path for a filter that ran anyway.
  - Drop `bool compiled` (written in three places, read nowhere) and
    `glob_pat.literal` (always a second copy of ->pattern; the hash table now
    keys on ->pattern, and ->re == NULL is the single discriminator).
  - Escape via g_regex_escape_string() instead of a hand-kept metacharacter
    list that could drift from PCRE2's.
  - append_class() rolls back with g_string_truncate() instead of building a
    throwaway GString per character class.
  - g_clear_pointer(), memchr(), g_str_has_suffix() where the codebase already
    uses them; fold the trailing-'/' test into is_plain_path().
  - glob_set_add_literal() and add_exclude_path() return void -- they had no
    failure path, and every caller ignored the int.
  - Delete glob_set_empty(): no production caller.
  - Drop the unreachable `err ? err : "..."` and `which ? which : "?"`
    fallbacks; every failure path sets them.
  - Drop <bsd/sys/queue.h> from file_scan.c, dead since the SLIST went.

Two correctness-adjacent fixes the review surfaced:

  - gp->matches++ was a plain shared increment from the walker threads, which
    contradicted the "read-only after compile, so walkers need no lock" claim
    in glob.h, CLAUDE.md and the commit message. Only its zero/non-zero was
    ever read, so it becomes a relaxed _Atomic bool written once on first hit:
    no race, and no contended write per excluded path.
  - The replay path ignored add_exclude_pattern()'s return, so a stored pattern
    that no longer parses would scan a wider tree and still exit 0 -- on the
    unattended systemd path, where nobody would see it. It now fails like the
    command-line path does.

Also move the "matched nothing" warning out of filescan_free() into
scan_files(), gated on the walk having succeeded: as teardown it also fired
after a failed scan, where every pattern trivially matched nothing and the
noise buried the real error.

Tests: gs_hit() now aborts on a pattern that fails to compile instead of
returning false, which had been letting every negative assertion pass
vacuously. Seven integration cases share an assert_kept() helper. Added the
pre-1.6.0 upgrade note to the NAS guide, where the people with patterns
already stored in a hashfile actually look.

scripts/verify.sh passes (126 tests, valgrind smoke).

Co-Authored-By: Claude <noreply@anthropic.com>
The valgrind CI job failed on every --exclude test with "conditional jump or
move depends on uninitialised value(s)" in unnamed frames -- PCRE2's
JIT-generated code, which matches a word at a time and so reads past the
string's terminator. The reads are inside their allocations and harmless, but
the branches are on undefined bytes, and both buffers were genuinely
under-initialised:

  - process_dir()'s `child` was malloc'd once per directory, and each entry
    only ever writes up to its own terminator, so the tail stayed undefined for
    the life of the buffer. calloc it: once per directory, not per entry.
  - scan_file()'s `char path[PATH_MAX]` is a 4 KiB stack buffer that realpath()
    fills only as far as the terminator. Zero it; once per scan root.

Fixing the buffers rather than suppressing the JIT frames: a suppression on
`???` addresses would have to match unnamed frames, which is both fragile and
exactly the kind of blanket that hides a real error later.

verify.sh's smoke never exercised this because it does not pass --exclude, so
no regex is ever compiled -- only `make integration-valgrind` reaches it. Now
clean: 126 tests under memcheck, "valgrind: no findings".

Co-Authored-By: Claude <noreply@anthropic.com>
@martinus
martinus merged commit 6df3955 into master Jul 26, 2026
8 checks passed
@martinus
martinus deleted the gitignore-exclude branch July 26, 2026 13:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

--exclude patterns that match nothing are accepted silently

1 participant