Skip to content

Concepts & vocabulary ​

The terms the rest of this tree uses, each defined once and grounded in a real system. A file-selection language is small enough that most of its design space is covered by five axes; this page names them.

Last reviewed: September 20, 2026


Composition model ​

How two selections combine. The field has five answers, and they are not interchangeable.

ModelShapeCan express intersection?Examples
Set algebraAn expression tree over union / intersection / difference with groupingyesjj filesets, Mercurial filesets, bazel query, nixpkgs, Sapling
Ordered rulesA list of patterns, each include or exclude; last (or first) match decidesno.gitignore, rsync, CODEOWNERS, ripgrep -g, the ignore crate
Generate then filterA generator bounds the candidate set; an algebra filters what it producedyes, within one generatorwatchman
Two-layer hybridOrdered rules for paths, a boolean algebra one level up over predicatesyes, in the upper layerAnt <fileset>, Gradle PatternSet
Implicit conjunctionJuxtaposed terms are ANDed; a prefix sigil negates one termyes, but only over one axisfzf, fff, find (with explicit -o)

The distinction that matters for a build system is that an ordered-rule list cannot express intersection. a then !b is a ∖ b, and the rules compose by overwriting each other's verdicts, not by meeting. There is no ordering of include/exclude lines that means "files that are both under src/ and tracked by git" unless one of the two predicates is folded into the traversal itself. bazel's glob(include, exclude) is the ordered-rule model wearing a function's clothes: two lists, one subtracted from the other, and no third operator.

Generators versus predicates ​

The distinction watchman makes its API out of, and the one a fileset IR with two consumers needs a name for.

A generator bounds the candidate set: it can be evaluated without being handed a candidate, because it enumerates. A predicate can only test a candidate it is given. Watchman's five generators (since, suffix, glob, path, all) and its expression terms (match, dirname, type, size, …) are exactly this split, and the documentation teaches users to exploit it by hand: {"suffix": "c"} plus a dirname term is faster than {"glob": ["**/*.c"]} for the same set.

Two properties follow, and both are used throughout this tree:

  • Only a generator can prune. A predicate answers about one path, and by the time it is asked the walk is already committed. This is why the literal-path-prefix lattice is an analysis over the generating parts of an expression.
  • Complement is safe inside a generator's bound and ruinous outside it.["not", ["match", "*.c"]] means "of the candidates this generator produced, the non-C ones". The same ~x with no bounding generator means "every path on the disk that is not x".

Sapling expresses the same split differently: one Matcher trait with two methods — matches_directory, the generator side, which may prune a whole subtree, and matches_file, the predicate side.

Anchoring ​

Whether a pattern is positioned relative to a root, or free to match at any depth. Four distinct rules are in production use:

RuleA slash-free pattern (foo)A pattern with a slash (a/b)Systems
Root-anchoredonly the root entry fooonly a/b from the rootMercurial glob:/rootglob:, jj, git
Float-unless-slashedany segment, at any depth (**/foo)anchored to the root.gitignore, ripgrep -g
Suffix-anchoredthe final component onlythe trailing components (a/b matches x/a/b)rsync filter rules
Base-name by defaultthe final component onlynothing, unless --full-pathfd, watchman match (default scope)
Explicit per termwhatever the term's scope argument sayswhatever the term's scope argument sayswatchman (basename/wholename), Sapling (glob:/relglob:)

Two further sub-questions ride along:

  • Does * cross a separator? Under fnmatch(3) without FNM_PATHNAME it does; under FNM_PATHNAME (and in every .gitignore-family matcher) it does not, and ** is introduced to recover the crossing case. git's pathspec keeps the crossing behaviour by default and offers :(glob) to turn it off — the only mainstream system that defaults to crossing.
  • Prefix closure. Does a pattern that matches a directory admit everything under it? jj says yes and makes it the default pattern kind (prefix-glob:); Mercurial path: says yes; ripgrep -g says no, and documents the resulting footgun in its own --help.

The kind namespace ​

Every language in this survey that admits non-path primaries reaches them through a prefix rather than a bare keyword, for the same reason: bare keywords would collide with directory names. The prefixes differ only in shape.

SystemShapeExamplesExtensibility mechanism
Mercuriallowercase alphanumeric + :path: glob: re: rootglob: listfile0: set:new prefix
jjhyphenated identifier + :cwd-prefix-glob: root-file: glob-i:new prefix, plus a -i modifier suffix
git pathspec: + symbol signature, or :(word,word):! :/ :(glob,icase)a comma-separated word list
hue todaylowercase word + :, plus 1–2 char aliasesext: seg: git: st: g:new prefix

git's long form is the most extensible shape in the field: modifiers compose as a set rather than multiplying kind names, which is what jj's root-prefix-glob-i: is heading toward.

Delimiting a language inside another input ​

A picker prompt, a .gitignore line and a command line all carry text that is mostly not an expression. Three answers exist:

  • A prefix that switches the whole remainder. Mercurial's set: turns a pattern argument into a fileset expression; the rest of the argument is the expression.
  • A separator token. git's -- ("the rest is paths"), reused by ripgrep, fd and every picker that forwards flags.
  • A parse-failure fallback. jj tries the argument as an expression and, on failure, retries it as a bare path pattern (program_or_bare_string). This removes the delimiter entirely at the cost of a query whose meaning changes when a typo makes it stop parsing.
  • Restrict the language instead. git sparse-checkout's cone mode accepts only directory names, which dissolves the question — a directory name cannot be confused with an expression, so it needs no delimiter.
  • A heuristic per token. fff (and hue today) classify each whitespace-separated token as constraint-or-text by inspecting it. This is the model the recommendations replace, and its cost is visible in fff's own source as a stack of carve-outs.

Literal-path-prefix lattice ​

The static analysis that turns an expression into a traversal plan: for each sub-expression, the longest root-relative path prefix that every member must start with, with a top element ⊤ meaning "unknown — could be anywhere".

prefix(literal "src/app.d")  = "src/app.d"
prefix(glob "src/**/*.d")    = "src/"
prefix(glob "**/*.d")        = ⊤
prefix(a ∪ b)                = longest common prefix of prefix(a), prefix(b)
prefix(a ∩ b)                = the longer of the two, if one extends the other;
                               otherwise the intersection is statically empty
prefix(a ∖ b)                = prefix(a)          (b can only remove)
prefix(¬a)                   = ⊤                  (always)

Its runtime dual is Sapling's DirectoryMatch, which answers the same question per directory during the walk rather than per sub-expression before it: Everything (admit the subtree and stop asking), Nothing (prune), ShouldTraverse (the ⊤ — "always valid … does not provide additional information"). A planner computes the static lattice, a matcher answers the dynamic one, and the two must agree.

Two consequences drive the recommendations:

  • Complement destroys pruning. ¬a has no literal prefix, so any expression containing one must start its walk at the root. This is why jj and Mercurial, which match against an already-materialised manifest, can afford ~x and not x, and why a language that compiles to a directory walk cannot.
  • A disjoint intersection is statically empty. src/** & libs/** has two prefixes neither of which extends the other, so the result is provably the empty set before a single readdir. Bazel treats the dynamic version of this as an error (allow_empty = False is the default); the static version is strictly cheaper to detect.

Identity versus matching ​

Whether two paths are the same file is a question about bytes. Whether a pattern matches a path is a question about policy — case folding, Unicode normalisation, separator flavour. Conflating them is how a content-addressed build system ends up with two store entries for one file. Every system here keeps its case-insensitivity in the pattern (glob-i:, :(icase), ripgrep --glob-case-insensitive) and never in the path.

Sources ​

Per-subject citations live in the deep-dives; this page's claims are each restated with a primary source there.