Ordered-rule filters: .gitignore, rsync, .npmignore, CODEOWNERS
Four systems that solve file selection with a list of rules evaluated in order rather than an expression. They are grouped because their limits are identical and structural: an ordered rule list is a fold over verdicts, and a fold over verdicts cannot express intersection.
| Subjects | .gitignore, rsync filter rules, .npmignore + package.json files, GitHub CODEOWNERS |
| Surveyed revisions | git f78ce2f7 · rsync 8b8de523 · npm docs v11 · GitHub docs |
| Category | Ordered include/exclude rule lists |
| Composition model | Ordered rules |
| Grammar | One pattern per line; no operators |
Overview
What they solve
"Everything under here except these" — the overwhelmingly common case, stated in the cheapest possible notation, in a file a non-programmer can edit.
Design philosophy
Explicitly not a language. .gitignore's entire specification is a pattern format plus a precedence rule:
When deciding whether to ignore a path, Git normally checks
gitignorepatterns from multiple sources, with the following order of precedence, from highest to lowest (within one level of precedence, the last matching pattern decides the outcome)
Analysis
1. Composition model
Last-match-wins over a flat list, with ! re-including. CODEOWNERS is the same fold with the negation removed:
There are some syntax rules for gitignore files that do not work in CODEOWNERS files: … Using
!to negate a pattern doesn't work. Using[ ]to define a character range doesn't work.
npm inverts the polarity instead of adding an operator — files is an allowlist "similar syntax to .gitignore, but reversed" — and then has to bolt on both a subdirectory override rule and a list of files that are included "regardless of settings" (package.json reference). The proliferation of special cases is what happens when a model without intersection meets a requirement that needs one.
2. Operator surface & precedence
One prefix, !, and positional precedence. There is no grouping, so the only way to express "A and B" is to compute it by hand into the rule order.
3. Primary vocabulary & the kind namespace
None. Every line is a path pattern — which is the property this survey's settled design adopts for bare tokens, and the reason it needs a kind: namespace to recover everything else.
4. Anchoring & glob semantics
.gitignore's rule is the one most users know:
If there is a separator at the beginning or middle (or both) of the pattern, then the pattern is relative to the directory level of the particular
gitignorefile itself. Otherwise the pattern may also match at any level below thegitignorelevel.
with the equivalence spelled out:
A leading
**followed by a slash means match in all directories. For example,**/foomatches file or directoryfooanywhere, the same as patternfoo.
So "float" is exactly "prepend **/", not a second matching mode.
rsync is a third rule, and a subtly different one — suffix-anchored rather than floating:
If a pattern contains a
/(not counting a trailing slash) or a "**" (which can match a slash), then the pattern is matched against the full pathname, including any leading directories within the transfer. If the pattern doesn't contain a (non-trailing)/or a "**", then it is matched only against the final component of the filename or pathname. For example,foomeans that the final path component must be "foo" whilefoo/barwould match the last 2 elements of the path.
Note the divergences from .gitignore: a slash-free pattern matches only the final component (not any segment), a slashed pattern matches a suffix (not the root-relative whole), and ** alone is enough to promote a pattern to full-path matching. rsync also adds dir/*** as "the directory and everything in it" — the prefix-closure case, spelled as a third asterisk.
5. Lexis: quoting, escaping, reserved characters
.gitignore is the most permissive and the most forgiving:
A backslash (
\) can be used to escape any character. E.g.,\*matches a literal asterisk (and\amatchesa, even though there is no need for escaping there). As withfnmatch(3), a backslash at the end of a pattern is an invalid pattern that never matches.
Two rules worth naming, because a query language should copy one and refuse the other:
\xis the identity for every unreservedx. Forgiving today, load-bearing forever: once\ameansa,acan never become a metacharacter without silently changing existing patterns.- A trailing backslash silently never matches. A malformed pattern that produces no diagnostic is the worst of both worlds;
sparkles.fuzzy.globalready rejects it withinvalidEscape/ "trailing glob escape".
rsync's escaping rule is the field's cautionary tale:
a backslash can be used to escape a wildcard character, but it is only interpreted as an escape character if at least one wildcard character is present in the match pattern. For instance, the pattern "
foo\bar" matches that single backslash literally, while the pattern "foo\bar*" would need to be changed to "foo\\bar*".
A lexical rule that depends on a property of the whole token cannot be implemented in one pass, cannot be explained in one sentence, and changes the meaning of a prefix when a suffix is edited.
6. Evaluation strategy & pruning
.gitignore's pruning behaviour is the single most important paragraph in this page, because it is the model our Plan consumer implements:
It is not possible to re-include a file if a parent directory of that file is excluded. Git doesn't list excluded directories for performance reasons, so any patterns on contained files have no effect, no matter where they are defined.
That is a leak of the traversal plan into the language's semantics: the rule list is not evaluated as a predicate over paths, it is evaluated as a sequence of prune/accept verdicts over a walk, and the two disagree. An expression language that compiles to both a plan and a predicate must keep them in agreement, which means it cannot adopt a construct whose meaning depends on whether a directory was opened.
Strengths
- Trivially learnable; the format is one pattern per line.
.gitignore's float rule matches how people think about file extensions (*.omeans "anywhere").- Pruning falls out of the model for free — an excluded directory is simply not descended.
Weaknesses
- No intersection, ever. Every one of the four has accreted a workaround (npm's
files+ subdirectory override, CODEOWNERS' removal of!, rsync's per-directory merge files). - Three mutually incompatible anchoring rules across
.gitignore, rsync and git's own pathspec. .gitignore's prune/predicate divergence is silent.- rsync's context-dependent backslash.
Key design decisions and trade-offs
| Decision | Rationale | Trade-off |
|---|---|---|
| Last matching rule wins | One pass, no parser, editable by anyone | Intersection is unreachable |
Float unless the pattern contains / | *.o means what a person means | A slash-free pattern has no literal prefix, so it cannot prune |
\x is the identity for unreserved x | Forgiving to typists | Permanently blocks reserving x later |
| Excluded directories are not descended | Performance | Re-inclusion is silently impossible — the language and plan diverge |
| rsync: backslash escapes only if a wildcard is present | Backwards compatibility with literal Windows-ish names | Not lexable in one pass; suffix edits change prefix meaning |
Sources
Documentation/gitignore.adoc— precedence, anchoring,**, escaping, and the non-descent rule (all quoted above).rsync.1.md— the "INCLUDE/EXCLUDE PATTERN RULES" section (quoted above).package.jsonreference — thefilesallowlist and.npmignoreoverride rules (quoted above).- About code owners — the gitignore syntax CODEOWNERS drops (quoted above).