Skip to content

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 revisionsgit f78ce2f7 · rsync 8b8de523 · npm docs v11 · GitHub docs
CategoryOrdered include/exclude rule lists
Composition modelOrdered rules
GrammarOne 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 gitignore patterns 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)

— Documentation/gitignore.adoc

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.

— About code owners

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 gitignore file itself. Otherwise the pattern may also match at any level below the gitignore level.

— Documentation/gitignore.adoc

with the equivalence spelled out:

A leading ** followed by a slash means match in all directories. For example, **/foo matches file or directory foo anywhere, the same as pattern foo.

— Documentation/gitignore.adoc

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, foo means that the final path component must be "foo" while foo/bar would match the last 2 elements of the path.

— rsync.1.md

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 \a matches a, even though there is no need for escaping there). As with fnmatch(3), a backslash at the end of a pattern is an invalid pattern that never matches.

— Documentation/gitignore.adoc

Two rules worth naming, because a query language should copy one and refuse the other:

  • \x is the identity for every unreserved x. Forgiving today, load-bearing forever: once \a means a, a can 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.glob already rejects it with invalidEscape / "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*".

— rsync.1.md

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.

— Documentation/gitignore.adoc

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 (*.o means "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 ​

DecisionRationaleTrade-off
Last matching rule winsOne pass, no parser, editable by anyoneIntersection is unreachable
Float unless the pattern contains /*.o means what a person meansA slash-free pattern has no literal prefix, so it cannot prune
\x is the identity for unreserved xForgiving to typistsPermanently blocks reserving x later
Excluded directories are not descendedPerformanceRe-inclusion is silently impossible — the language and plan diverge
rsync: backslash escapes only if a wildcard is presentBackwards compatibility with literal Windows-ish namesNot lexable in one pass; suffix edits change prefix meaning

Sources ​