Skip to content

Comparison & synthesis ​

What the twenty surveyed systems agree on, where they split, and which of their answers survive contact with a language that must compile to both a directory-traversal plan and an in-memory predicate.

Last reviewed: September 20, 2026


At a glance ​

SystemCompositionOperatorsPrecedenceComplementKind namespaceAnchoringPrefix closurePlans a walk
jj filesetsset algebra| & ~ + prefix ~&,~ > |yeshyphenated word + :root-anchoreddefaultno
jj revsetsset algebra + graph| & ~ + 4 graph levels7 levelsyeshyphenated word + :n/an/ano
Mercurial filesetsset algebra| & - + or and not !and,- (5) > or (4)yeslowercase word + : (16)root/cwd-anchoredpath: yesno (cost model)
Sapling matchersset algebraAPI combinatorsn/anoclosed enum of hg'sexplicit per kinda plan verdictyes
watchmangenerate then filterallof/anyof/not (JSON)n/a (nesting)yes*JSON term namesexplicit per termparameterised depthyes
bazel queryset algebra^ + - / intersect union exceptflat, left-assocnonone (functions)n/an/ano
Bazel glob()ordered rulesnonen/anononepackage-anchorednoprefetch
Ant <fileset>two-layer hybrid<and>/<or>/<not>/<none>/<majority>XML nestingyesXML element per primarybase-dir anchoredtrailing /no
Gradle PatternSettwo-layer hybridintersect/union/negatemethod chainingyesSpec closuresbase-dir anchoredtrailing /no (lazy tree)
git pathspecordered rules:! / :^ onlyn/anosymbol signature / word setdir-prefix, * crosses /dir matches subtreeyes (per arg)
sparse-checkout conecanonical dir setnone (directories only)n/anononeroot-anchored dirsonly closureyes (hashed)
.gitignoreordered rules!positionalper-rulenonefloat unless /dir → pruneyes
rsync filtersordered rules+/- per linepositionalper-rulerule-letter prefixessuffix, / anchorsdir/***yes
the ignore crateordered rules! in globs7 numbered stepsper-rulefile-type tablegitignoredir → pruneyes
nixpkgs lib.filesetset algebrafunctionshost languagenon/a (no patterns)absolute pathsdir = subtreelazy cutoff
fzfimplicit conjunctionsigils + || > implicit ANDper-termnonen/a (strings)n/ano
fffimplicit conjunction! prefix onlyn/aper-termword + :, incl. g: st:gitignore (downstream of a guess)nono
findexplicit + implicit AND! -a -o , + ( )! > -a > -o > ,yes-flag primariescaller's choicen/amanual -prune
fdordered rules! in globs, --and flagn/aper-globnonebase namenoyes
ripgrepordered rules! in globspositional (later wins)per-globnonegitignorenoyes

* watchman's not is safe because a generator has already bounded the universe — see Generators versus predicates.


Per-dimension synthesis ​

1. Composition model — set algebra wins, and the reason is intersection ​

Every system whose job is to answer a question (jj, Mercurial, Bazel query, nixpkgs) is a set algebra. Every system whose job is to configure a default (.gitignore, rsync, .npmignore, CODEOWNERS, glob(), rg -g) is an ordered rule list. The split is not historical; it follows from whether intersection is ever needed.

Ordered rules cannot express intersection at all, and all four of the ordered-rule subjects have accreted a workaround for the one place they needed it: npm's files-plus-subdirectory-.npmignore override, CODEOWNERS' removal of ! to keep the fold total, rsync's per-directory merge files, and Bazel's glob(include, exclude) pair.

Two further models appear once the survey includes systems built for scale:

  • Generate then filter (watchman). A generator bounds the candidate set, an algebra filters it. This is the only model in the field that makes the Plan/Predicate split part of the user-facing API, and it is the one that licenses a complement — see Generators versus predicates.
  • Two-layer hybrid (Ant and Gradle). Ordered include/exclude patterns for paths, a genuine boolean algebra one level up over predicates. Ant's manual states the seam exactly — "this makes a FileSet equivalent to an <and> selector container" — and Gradle names it with PatternSet.intersect(), whose addToAntBuilder emits an Ant <and> node. The lineage's answer to "ordered rules cannot intersect" is: don't fix the rules, put the algebra above them.

And one model is the absence of a language. git sparse-checkout's cone mode takes a list of directory names and nothing else, because the general pattern language did not scale — the field's only recorded retreat.

2. Operator surface — symbols, and the precedence is not optional ​

  • Symbols only, &/~ tighter than | — jj (both languages).
  • Symbols plus words, same precedence shape — Mercurial, Bazel query, find.
  • Flat precedence — Bazel query alone, and its own parser comment (// All operators are left-associative and of equal precedence.) is the clearest statement of the cost: a + b ^ c parses as (a + b) ^ c and reads as the opposite.
  • Inverted precedence — fzf, forced by implicit conjunction.
  • No precedence at all — watchman (JSON nesting), Ant (XML nesting), Gradle and Sapling (API calls), nixpkgs (host language). Every system that gave up on a textual syntax also gave up on this entire design question, which is the honest accounting of what a syntax costs.

The ignore crate is the limiting case in the other direction: with no operators at all, it still needs seven numbered precedence steps, with two orthogonal axes inside step two, to specify what its walker does.

Word operators are a genuine readability win in a config file and a genuine cost in a language whose operands are filenames: Mercurial has to say that an identifier must be quoted "if it matches one of the predefined predicates".

3. Kind namespace — a prefix, never a bare keyword ​

Unanimous among the systems that have non-path primaries. The shapes differ in extensibility:

ShapeExampleNew selectorNew modifier
word + :glob:new prefixnew prefix (combinatorial)
hyphenated word + :root-prefix-glob:new prefixnew suffix (combinatorial)
symbol signature:!new symbolnew symbol
word set in parentheses:(glob,icase)new wordnew word (additive)

git's long form is the only shape where modifiers compose instead of multiplying, and jj's root-prefix-glob-i: is the visible cost of not having it. watchman reached the same shape independently in a different notation — a trailing flag dictionary, ["match", "*.txt", "basename", {"includedotfiles": true}] — which makes it two occurrences of the additive idea against three of the combinatorial one.

Sapling shows the other axis: its kind namespace is Mercurial's, promoted to a closed Rust enum with the anchoring rule documented per variant. That buys exhaustive matching and no unknown-kind path at runtime, and costs the ability to add a selector without a source change — the exact opposite trade from reserving a shape.

4. Anchoring — the field splits by context, not by taste ​

ContextRuleSystems
A query someone typesanchoredjj, Mercurial glob:, git pathspec, Bazel
An ignore filefloat unless slashed.gitignore, .hgignore, .npmignore
A search tool's filterfloat unless slashedripgrep -g
A search tool's patternbase namefd, watchman match (default)
A build fileanchored to base dirAnt, Gradle
A system built for scaleexplicit per termwatchman, Sapling

Mercurial ships both rules in one tool and documents the split explicitly: glob: is rooted at the current directory, while ".hgignore are not rooted". That is the evidence that anchoring is a property of the context, not of the language, and it is why a language serving both a picker and a build system has a real decision to make rather than an obvious one.

The last row should give a designer pause. The two subjects here that had to make file selection work at Meta scale both refuse to infer the anchoring scope from a pattern's shape: watchman takes it as an argument (["match", "dir/*.txt", "wholename"]), Sapling as a separate kind (glob: versus relglob:, path: versus relpath:, re: versus relre:). Inference is what the tools people type into interactively choose; explicitness is what the tools that must be right at scale choose. A language serving both needs the inference and a one-character override in each direction — see the recommendations O1.

Prefix closure is where the field is most useful and most divided. jj makes it the default pattern kind; Mercurial's path: has it; rsync spells it dir/***; Ant has spelled it as a trailing / since 2000; ripgrep refuses it and documents the refusal in --help. The tools that refuse it are the ones that have to explain themselves.

Sapling supplies a fourth answer, and it is the best one: closure need not be a property of the pattern at all. DirectoryMatch::Everything is a verdict the matcher returns about a directory during the walk, so a subtree is admitted wholesale without any kind having to say so.

5. Lexis — a positive charset, and one honest escape ​

Both VCS languages define the bare-token charset positively — jj's XID_CONTINUE | "+" | "-" | "." | "@" | "_" | "*" | "?" | "[" | "]" | "/" | "\\", Mercurial's [.*{}[]?/\_a-zA-Z0-9\x80-\xff]. Neither defines it as "everything except the operators". The difference matters for evolution: with a positive charset, reserving a new operator character is a no-op for every query that parses today; with a negative one, it silently reinterprets existing input.

On escaping, the field offers one convention and two anti-patterns:

  • Convention — \ quotes the next character (.gitignore, jj, Mercurial).
  • Anti-pattern 1 — \x is the identity for unreserved x (.gitignore: "\a matches a, even though there is no need for escaping there"), which permanently freezes the reserved set.
  • Anti-pattern 2 — a context-dependent escape (rsync: \ escapes "only … if at least one wildcard character is present in the match pattern"), which cannot be lexed in one pass.

And the domain constraint every one of them lives under, from find(1): file names "can contain any character except \0 and /".

6. Evaluation & pruning — the divide that decides everything else ​

UniverseSystemsConsequence
A materialised manifest or indexjj, Mercurial, fff, Bazel querycomplement is cheap; no plan is needed
An index the system itself maintainswatchmancomplement is cheap within a generator's bound
A directory tree that must be walked.gitignore, rsync, fd, rg, git pathspec, nixpkgs, Ant/Gradle, the ignore crate, Saplingprune/accept verdicts; complement is ruinous

Everything in this survey that permits a unary complement evaluates against an already-enumerated universe. Everything that walks a tree either omits complement (nixpkgs, glob(), ripgrep globs) or accepts that negation is per-rule and that an excluded directory is simply never opened (.gitignore: "Git doesn't list excluded directories for performance reasons").

Three subjects turn this into engineering rather than a caveat.

Sapling makes the plan a typed method. matches_directory returns Everything / Nothing / ShouldTraverse, with the soundness property documented on the top element — "ShouldTraverse is a value that is always valid. It does not provide additional information." Its combinators then write the lattice joins out: one Nothing short-circuits an intersection, Everything ∩ Everything = Everything preserves closure, and the empty intersection is Nothing, because Everything there would mean walking the whole repository. DifferenceMatcher even defers evaluating its exclude operand, "since in some cases we can avoid executing it entirely … [it] may inspect a manifest or the treestate".

git sparse-checkout supplies the counterfactual. Non-cone mode is the unrestricted pattern language, and git measured what that costs — "O(N*M) pattern matches, where N is the number of patterns and M is the number of paths in the index" — named the only mitigation, "limiting the number of patterns via specifying leading directory name or glob", concluded that users cannot be relied on to supply one, and replaced the whole thing with a canonical directory set over which "Git will use faster hash-based algorithms to compute inclusion". The sentence to pin above any fileset IR is the last one: "The excessive flexibility made other extensions essentially impractical. --sparse-index is likely impossible in non-cone mode." An unprunable language does not merely run slowly — it forecloses later work.

Mercurial is the only subject that optimises the expression tree with a cost model — WEIGHT_CHECK_FILENAME = 0.5 against WEIGHT_READ_CONTENTS = 30 — commuting intersections so the cheap predicate runs first, folding x and not y into minus, and coalescing a union of patterns into one compiled matcher.

Together they are the three layers a fileset compiler wants: a typed prune verdict (Sapling), a language restricted enough that the verdict is always computable (sparse-checkout), and a cost model to order what remains (Mercurial).


Consensus standard ​

A file-selection language written today, on the evidence of this survey, has:

  1. Explicit infix set algebra — |, &, difference, parentheses.
  2. & and difference binding tighter than |, left-associative.
  3. Symbolic operators, with word forms only if the language is primarily read rather than typed.
  4. A kind: prefix namespace, so no bare word is ever a keyword, with modifiers as an additive set rather than concatenated names.
  5. A positive bare-token charset and a \ escape that errors rather than silently identifies.
  6. Prefix closure by default — a pattern naming a directory means its subtree.
  7. Complement only if the universe is already enumerated — or, following watchman, only inside a generator that has bounded it.
  8. Emptiness treated as a defect (Bazel's allow_empty = False), and missing literal paths treated as errors (nixpkgs' strict existence check).
  9. A typed, three-valued directory verdict whose top element is documented as always sound (Sapling's ShouldTraverse, the ignore crate's Match::None) — two independent arrivals at the same shape.
  10. A language restricted enough that the verdict is always computable.git sparse-checkout is the field's proof by counterexample.

Delta table — where sparkles stands today ​

CapabilityField standardsparkles todayGap
Set algebra over file selectionsjj, hg, nixpkgsnone — include/exclude string lists (glob_walk.d)the whole language
Grouping / nestinguniversalnonethe whole language
Explicit operators| & ~implicit: include wins over exclude, positionallythe whole language
kind: namespaceword + :ext: path: seg: glob: status:/git:/st:/g: (fuzzy/query.d)retire the 1–2 char aliases; fix the shape
Bare-token classificationpositive charset + kindsper-token heuristics (dispatchPositive) with != / !!foo carve-outs (PKQ3)replace with a delimited region
Anchoring.gitignore float-unless-slashedglobAny: base name or whole path, via Phobos globMatch where * crosses /one rule instead of a double match
Prefix closurejj default, hg path:ad-hoc: a string-prefix test in explorer.d re-admits directories for build/*.logmake it a pattern-level rule
Literal-path-prefix analysisgit pathspec (per arg)the same explorer.d string test, which fails for **/x/*.loga real lattice over the expression
Statically empty detectionBazel, dynamicallynonenew, and cheaper than Bazel's
Cost-model reorderingMercurial onlynonewanted for git: and content predicates
Generator / predicate splitwatchman (API), Sapling (trait)planned — the IR's two consumersname it; it is the whole design
Three-valued directory verdictSapling, the ignore cratedir_walk.d's hook returns bool from enterDira boolean cannot say "admit the subtree"
Explicit anchoring overridewatchman (argument), Sapling (kind)none — globAny infers, and in two directions at oncekeep the inference, add /p and **/p
Errors as values, borrowed spansnone (all allocate)FuzzyError { code, offset, context }, @safe pure nothrow @nogcahead of the field; keep it
{a,b} alternationrg extension, hg, our glob enginesupported by sparkles.fuzzy.globrules out {…} as a region delimiter

The two rows at the bottom are worth stating plainly: our error model is already better than every surveyed subject's (all of which allocate strings and several of which throw), and our glob engine already implements brace alternation, which constrains the delimiter choice before the language exists.


Architectural trade-offs ​

  • Complement versus pruning. They cannot both be had in an unbounded expression. watchman shows the seam: a complement under a generator that has already bounded the universe is cheap, and only a free-floating one is ruinous. Choosing the walk therefore means choosing no free ~x, with the compensation that every expression has a computable start set.
  • Flexibility versus future optimisations. The cost of an unprunable language is not only slowness — git sparse-checkout found that it made --sparse-index "likely impossible". Restrictions adopted late cost a deprecation.
  • Delimiter versus heuristics. A region marker costs one token and buys the removal of fff's entire carve-out chain. The cost is that a fileset region is a mode, and modes need a visible affordance in the prompt.
  • Anchoring versus muscle memory. Anchoring is more predictable; floating is what every user's ripgrep and .gitignore have trained them on. The .gitignore rule is the one compromise the field has actually converged on, because it anchors precisely the patterns that can prune.
  • Word operators versus filenames. Every reserved word is a directory name someone has. Symbols cost nothing because the input is quoted anyway.

Sources ​

Every claim on this page is restated with a primary-source citation in the deep-dive it comes from: jj filesets, jj revsets, Mercurial, Bazel, git pathspec, ordered-rule filters, nixpkgs, implicit-conjunction pickers, shell tools, watchman, git sparse-checkout, the ignore crate, Ant & Gradle and Sapling.