Skip to content

glob: treat a ** that ends a brace branch as a globstar in match() - #39009

Open
robobun wants to merge 1 commit into
mainfrom
farm/dbf79c0e/glob-globstar-ends-brace-branch
Open

robobun wants to merge 1 commit into
mainfrom
farm/dbf79c0e/glob-globstar-ends-brace-branch

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • A ** that is the last thing in a brace branch behaves like a single-segment * in Glob.match() (and in the other users of bun_glob::match: bun test path filters, --filter workspace globs, bundler allowUnresolved patterns):
    new Bun.Glob("a/{**,b}").match("a/x")                           // true
    new Bun.Glob("a/{**,b}").match("a/x/y")                         // false, expected true
    new Bun.Glob("src/{**,lib}/*.ts").match("src/x/y/a.ts")         // false, expected true
    new Bun.Glob("test/{foo/**,bar}/baz").match("test/foo/x/y/baz") // false, expected true
    new Bun.Glob("{**/x,y}").match("a/b/x")                         // true (control: `**` followed by `/` inside a branch works)
  • Cause: the * arm of glob_match_impl (src/glob/matcher.rs:240 on main) promotes ** to a globstar only when the byte right after it is / or the end of the whole pattern. When the ** ends a branch that byte is the group's , or }, so it takes the plain * path even though, per the rule documented on match and per what brace expansion of the pattern gives (a/** or a/b), it is a whole segment.

Fix

  • Before deciding, compute where the pattern continues after the ** (globstar_continuation): while the next byte is the ,/} of a group we are inside, step past that group's } with the existing skip_branch, i.e. land exactly where matching would resume anyway. The / / end-of-pattern test, the existing trailing-/* special case, and the globstar bookkeeping (skip_to_separator) are then all applied at that position, and matching continues from there.
  • Why this is correct: a brace group means "one of these branches", so a/{**,b}/c has to match the union of a/**/c and a/b/c. For the ** branch the text that follows the ** in the expansion is whatever follows the group, which is the position this change looks at. Using skip_branch for the lookahead means the decision agrees with how the ,/} would have been consumed one iteration later (literal ,/} outside a group, unterminated groups, nested and sequential groups all behave as they do today); the only new behaviour is that the ** keeps globstar semantics when that position is / or the end of the pattern. Backtracking is unchanged: the saved wildcard still points at the ** with the inside-the-branch brace_depth, so re-entering it exits the group again the same way.
  • Unchanged on purpose: a group followed by anything else (a/{**,b}x is a/**x, not a whole segment) still demotes the ** to *; patterns without braces never enter the new loop (brace_depth is 0), so they are byte-for-byte the same decision as before. scan() is unaffected as well: it matches per path component, so a component like {**,b} never spans a / (glob: expand brace groups that span path separators when scanning #32599 is what makes scan() expand such groups; this change is what makes match() agree with it).
  • Tests: new `**` that ends a brace branch is a globstar block in test/js/bun/glob/match.test.ts, a table of 24 patterns / 125 assertions each annotated with the brace-free patterns it must be equivalent to: trailing and non-trailing groups, the ** in the first / last / an empty branch, nested groups (a/{c,{**,d}}/e), a following group that backtracks into the globstar (a/{**,b}/{c,d}), two such groups in one pattern, a ** before the group, negation, plus the boundaries that must stay as they are (a/{**,b}x, a/{x**,b}, literal , outside a group, a/{**,b}/* vs a/x/). Every expectation was cross-checked against the union of the pattern's brace expansions evaluated by the released bun. 18 of the 24 rows fail on bun 1.4.0 (the test also fails on a debug build of main), all pass with this change.
  • Other runs: test/js/bun/glob/{match,scan,stress,proto,path-length}.test.ts pass with the debug build (the only failures seen were the fixture braces test and the scan tests that walk test/node_modules, which time out identically on an unmodified debug build on this loaded box; braces has been reported separately). Interleaved A/B of the fixture workload (13 patterns x 7895 paths) on debug builds with and without the change: 3832 ms vs 3872 ms best-of-6, i.e. noise; with no brace group open the helper stops at its brace_depth check.
  • Related, not overlapping: glob: judge a ** by its own surroundings, not by the brace branch being matched #39004 and glob: stop a non-segment ** from swallowing the ** segments after it in match() #38996 both change the other half of the whole-segment rule (what may precede the **) in the same arm and leave the /-or-end lookahead as is, so this is independent of both; whichever lands later needs a mechanical rebase of this hunk.

Background

  • Matcher structure: glob_match_impl walks pattern and path together. On { it calls match_brace, which tries each branch by recursing into glob_match_impl at the branch's start with a Brace frame (open index, branch index, close index) pushed on brace_stack; when a branch's text is exhausted the ,/} arm calls skip_branch, which jumps past the enclosing group's } and decrements brace_depth, so the rest of the pattern after the group is matched by the same recursive call.
  • Globstar bookkeeping: for a globstar the arm records a backtrack point (wildcard, pointing at the **) and calls skip_to_separator, which makes each later backtrack hand one more path segment to the **. is_end_invalid is the existing name for "there is more pattern after this **": it selects between the **/rest form (skip the /, keep matching rest) and the trailing-** form (only a fully consumed path is a match). With this change both forms are driven by the position after the group rather than the byte after the **.
  • The glob.len() - idx == 2 && "/*" block above the promotion is a pre-existing special case that keeps **/* from matching a path whose last segment is empty ("", "x/"); it is moved to the same position so {**,b}/* behaves like **/* for those paths (covered by the a/{**,b}/* row).
Differential check against brace expansion (not part of the PR)

Generated 39,412 distinct (braced pattern, path) pairs from the alphabet a b c * ** a* *b ? / { , } with up to three levels of nesting and compared match() of the braced pattern with the union of match() over its brace-free expansions on the same binary (expansions contain no braces, so they are unaffected by this change).

  • Patterns whose groups are whole path segments ({ preceded by /, ,, { or the pattern start; } followed by /, ,, } or the end): 1043 disagreements on the released build, 33 with this change. The remaining 33 are all instances of the pre-existing **/*-vs-empty-last-segment special case applied inside a group (**/{*,x} against ""), which disagree the same way on the released build.
  • Disagreements that do not involve a ** directly before ,/} are the same 297 before and after (byte-identical list), i.e. nothing outside the targeted construct changed.
  • Groups glued to other text (a{**,b}, {a,b}{**,c}): the ** opens a brace branch and is therefore treated as segment-initial by the existing (and, in glob: judge a ** by its own surroundings, not by the brace branch being matched #39004/glob: stop a non-segment ** from swallowing the ** segments after it in match() #38996, retained) rule, so with this change it is a globstar there too. That matches how bun already treats a{**/y,b} and how picomatch compiles a{**,b}; textual expansion (a**) would say otherwise. Not asserted either way in the tests.

The `**` arm of glob_match_impl only promoted `**` to a globstar when the
byte after it was `/` or the end of the pattern. When the `**` is the last
thing in a brace branch that byte is the group's `,` or `}`, so the `**`
fell back to single-segment `*` semantics: `a/{**,b}` matched `a/x` but
not `a/x/y`, and `test/{foo/**,bar}/baz` rejected `test/foo/x/y/baz`.

Look at where the pattern actually continues instead: past the `}` of the
group (and of any enclosing groups the `**` also ends), which is exactly
where skip_branch would resume. If that position is `/` or the end of the
pattern, the `**` is a whole segment, and matching continues from there,
so `a/{**,b}/c` now means the same as `a/**/c` or `a/b/c`, including the
zero-segment `a/c`. A group followed by anything else (`a/{**,b}x`, i.e.
`a/**x`) still demotes the `**` to `*`, and a `,`/`}` outside any group is
still a literal. The trailing `/*` special case is evaluated at the same
continuation point so `{**,b}/*` agrees with `**/*`.
@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 6 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 845c5743-2642-4460-aca5-3f8d3c2961b7

📥 Commits

Reviewing files that changed from the base of the PR and between 732491c and 1f389bb.

📒 Files selected for processing (2)
  • src/glob/matcher.rs
  • test/js/bun/glob/match.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 15, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: fix and tests pushed, waiting on CI.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. The fix is well-reasoned and the test coverage is thorough, but since it changes globstar/brace-group interaction in a matcher used by bun test filters, --filter, and the bundler — and overlaps the same arm as #39004/#38996 — a human look to confirm the intended semantics would still be worthwhile.

What was reviewed:

  • Traced globstar_continuation through trailing, nested, and non-segment-terminating groups; confirmed brace_depth == 0 short-circuits so brace-free patterns are byte-identical to before.
  • Checked that state.wildcard is captured before the group is exited, so backtracking re-enters the ** at the in-branch depth and replays the same skip.
  • Verified the segment-initial check still reads the pre-skip state.glob_index (what precedes **) while only the follow check uses rest.
  • Confirmed the relocated **/*-vs-empty-final-segment special case and skip_branch are read-only on brace_stack (mutates only the copied rest).
Extended reasoning...

Overview

This PR fixes Bun.Glob.match() so that a ** ending a brace branch (e.g. a/{**,b}/c) is treated as a globstar rather than being demoted to a single-segment *. The change is ~15 lines in the * arm of glob_match_impl plus an 8-line helper globstar_continuation in src/glob/matcher.rs, a doc-comment update, and a 24-pattern / 125-assertion test table in test/js/bun/glob/match.test.ts.

The mechanism: before deciding whether the ** is a whole segment, walk past any enclosing ,/} via the existing skip_branch (on a copy of state) and evaluate the /-or-end-of-pattern test at that position. When the test passes, commit the copy's glob_index/brace_depth so matching resumes after the group; the saved wildcard still points at the ** with the inside-branch depth, so backtracking is unchanged.

Security risks

None. Input is untrusted glob text but the change only reads bytes via glob.get() / bounds-checked indexing that were already reachable, and skip_branch is bounded by brace_stack (max depth 10). The brace_depth > 0 guard means the new loop iterates at most 10 times. No allocation, no new arithmetic on lengths.

Level of scrutiny

Medium-high. The diff is small and the fix is at the correct layer, but the glob matcher is on the path of bun test filters, workspace --filter, and bundler allowUnresolved, and the interaction of backtracking state (wildcard/globstar), brace_depth, and the brace stack is subtle enough that a second pair of eyes on the semantics is warranted. The PR description also flags two open PRs (#39004, #38996) touching the same arm that will need a mechanical rebase — a maintainer should coordinate landing order.

Other factors

  • The PR description includes differential fuzzing (39k pattern/path pairs) showing disagreements outside the targeted construct are byte-identical before and after, and an A/B perf run showing the change is noise for brace-free patterns.
  • Test coverage is strong: trailing/mid-pattern groups, nested groups where ** closes multiple levels, sequential groups that backtrack into the globstar, negation, the **/* empty-final-segment edge, and the negative boundaries ({**,b}x, {x**,b}, literal , outside a group) that must stay demoted.
  • I verified the helper mutates only a copy of State (*state is Copy) and reads brace_stack immutably, so a failed globstar check leaves state untouched and the ,/} arm handles the branch exit as before.
  • No prior human review on the timeline; only a rate-limited CodeRabbit stub.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant