Skip to content

Escape globs built from paths and fix them at the project root - #149

Open
dduugg wants to merge 4 commits into
mainfrom
escape-path-globs
Open

dduugg wants to merge 4 commits into
mainfrom
escape-path-globs

Conversation

@dduugg

@dduugg dduugg commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Globs built from package, .codeowner, annotated-file, team-file and vendored-gem paths now escape glob metacharacters. A package or .codeowner at the project root now gets **/**, written to CODEOWNERS as /**/**. With the issue's project, for-file, for-file --from-codeowners and validate now agree on all eight files, and crosscheck-owners passes.

Fixes #146.

Why

These globs are matched by validate and written to CODEOWNERS, where for-file --from-codeowners, validate <files> and the gem's CodeOwnership.for_file match them again with fast_glob. The names went in unescaped:

  • packs/[ab] claimed packs/a.
  • {legacy} meant legacy.
  • In validate, a top-level !legacy was a negated glob that gave its team every file outside legacy.
  • At the root, the glob was /**/** (CODEOWNERS //**/**), which matches nothing.

Changes

  • escaper::escape_glob replaces escape_brackets. It puts a \ before \ * ? [ ] { } !, one character at a time. Letters are never escaped, since fast_glob reads \b, \n, \r, \t as control characters.

  • escaper::directory_glob gives <escaped dir>/**/**, or **/** for the root.

  • Where they're used:

    • the package mappers (entries, matchers, and the glob shown in Source::Package)
    • the .codeowner mapper
    • annotated-file entries
    • team-file entries
    • vendored-gem entries and matchers
    • the package glob for-file shows

    User-written owned_globs are untouched.

  • Fixes at the root, now that a root .codeowner matches:

    • Source::len counts components, so the root ranks below app/.codeowner. Before, they tied, and validate picked whichever the parallel walk found first.
    • The root's source displays as `.codeowner` rather than `/.codeowner`.
    • An invalid .codeowner under it says it inherits "from the project root" rather than from an empty name.
    • A test pins that a root package encloses nested packages in validate, as any outer package does.
  • OwnerLookup (from Add optionally_owned_globs to permit files with no owner #135) no longer special-cases .codeowner files naming unknown teams with a path-prefix match. The directory mapper's own matchers are now correct, so it uses them. This doesn't change behavior. An empty .codeowner still counts as naming an owner, and a new test pins that.

  • A team now counts once in validate, whichever way it's named (separate commit). validate groups a file's owners by the team name each matcher reports. The team-glob mapper reported the GitHub team, and the .codeowner mapper the text as written, while every other mapper reports team.name. So one team reaching a file two ways counted as two owners.

    This already happens on main: frontend/apps/public/index.tsx in the javascript_only_project fixture is reported with owners @DesignTeam and Design. With the root fixed, it would also have failed any repo with a root .codeowner or package and an owned glob of the same team. Both mappers now report team.name, and keep an unknown name as written. Happy to split this into its own PR if you'd rather.

Release notes

  • Repos with glob metacharacters in such paths, or a .codeowner or package at the root, get a different CODEOWNERS. validate reports it out of date until they run generate. Escaped names also sort differently (/app/\{old\} now comes before /app/old).
  • A root .codeowner or package now owns files in validate and CODEOWNERS, as for-file already reported. So a file that another mechanism gives a different team is reported as having more than one owner. With a root package, that includes annotations or .codeowner files naming a nested package's own team, since validate folds nested packages into the outer one.
  • validate no longer reports one team reached two ways as two owners, and multiple-owner reports name a team glob's team by its name.
  • Some descriptions change in for-file, in validate messages and in the gem's for_file reasons:
    • a root .codeowner shows as `.codeowner`
    • a root package's glob shows as **/**
    • a package path with metacharacters shows its escaped glob

How GitHub reads \-escaped characters and a /**/** line isn't verified here. \[ has been emitted since #37. codeowners-rs and the gem match these lines correctly.

Test plan

  • cargo fmt --all -- --check, cargo clippy --all-targets --all-features -- -D warnings and cargo test pass (281 passed, the 3 existing ignores).
  • New tests/path_globs_test.rs (11 tests, temp repos via the tests/common helpers):
    • the issue's project: the expected CODEOWNERS lines, for-file and --from-codeowners for all eight files, exact validate and validate <files> output, and crosscheck-owners success
    • a top-level !legacy
    • a root .codeowner, and a root package.yml
    • a root .codeowner overridden by app/.codeowner
    • a root package with a nested one
    • one team reached two ways
    • the "inheriting its owner from the project root" message
  • Unit tests:
    • escaper: each metacharacter, a backslash escaped once, letters untouched, and for 11 tricky names, matching their own directory and not the siblings the unescaped glob matches
    • each mapper, with braces, brackets, !, * and the root
    • the overrider's root ranking, in both orders
    • nearest_package_owner's display glob, Ruby and JS, including a root package.json
  • Mutation-checked: reverting each mapper's escaping, the root glob, Source::len, the display fixes and the team-name keying fails at least one test.
  • Generated CODEOWNERS and for-file output are byte-identical to main's for every fixture under tests/fixtures. validate differs only where the same-team false positives are gone.
  • Manually reran the issue's reproduction, including the root project, with main's binary (reproduces) and this branch's (fixed).

Follow-ups (not in this PR)

  • Paths with spaces: entries keep the space, and the CODEOWNERS lookup splits each line at the first space. With a root .codeowner, such a file now falls through to the root's team in --from-codeowners.
  • When the checkout path contains glob metacharacters, team files aren't found, because load_teams and Parser::absolute_team_files_globs join the unescaped root (pre-existing).
  • An enclosing package.json (now including a root one) overrides nested package.yml lines in CODEOWNERS, while for-file picks the nearest package (pre-existing for non-root nesting; validate reports it).
  • tests/path_globs_test.rs has its own codeowners helper. Normalize the paths validate, gv and for-file are given #148 moves the equivalent one into tests/common, so whichever lands second can share it.

Each package and `.codeowner` becomes a `<directory>/**/**` glob and
each annotated file its own path. `validate` matches files against
them, and they're written to CODEOWNERS, where
`for-file --from-codeowners`, `validate <files>` and code_ownership's
`for_file` match them again. The package mappers escaped nothing and
the `.codeowner` and annotation mappers escaped only `[` and `]`, so
`packs/[ab]` claimed `packs/a`, `{legacy}` meant `legacy`, and in
`validate` a top-level `!legacy` was a negated glob that gave its team
every file outside `legacy`. A package or `.codeowner` at the project
root got `/**/**`, which matches no project-relative path, and the
CODEOWNERS line `//**/**`.

One escaper now puts a backslash before `\`, `*`, `?`, `[`, `]`, `{`,
`}` and `!` in every glob built from a path, team files and vendored
gems included. It leaves letters alone, since fast_glob reads `\b`,
`\n`, `\r` and `\t` as control characters. The project root's glob is
`**/**`, which CODEOWNERS gets as `/**/**`, and `for-file` describes a
package by the glob `validate` matches.

With the root matching, a file in `app/` matches both a root
`.codeowner` and `app/.codeowner`, and `validate` kept whichever came
first from the parallel walk, since `Source::len` counted separators
and gave both 0. It now counts components, which ranks the root below
every directory. The root's `.codeowner` is shown as `.codeowner`
rather than `/.codeowner`, and a `.codeowner` naming an invalid team
below it is reported as inheriting its owner from the project root
rather than from an empty name. When `validate` drops nested packages,
a root package encloses every other one, as for any nested package; a
test now pins that.

Repos with such names, or with a `.codeowner` or package at the root,
get a different CODEOWNERS, and `validate` reports it out of date until
they regenerate it. A root `.codeowner` or package now also owns files
in `validate` and CODEOWNERS, as `for-file` already reported, so a file
that another mechanism gives a different team is reported as having
more than one owner.

Fixes #146.
`validate <files>` excuses an optionally owned file only if nothing
names an owner for it. Its owner lookup matched the directory of a
`.codeowner` naming a team that doesn't exist by path prefix, because
the directory mapper's glob missed one at the project root and read
braces as alternatives. Since the previous commit that glob matches
just the directory, and the mapper builds a matcher for every
`.codeowner` whatever team it names, so the lookup now uses those
matchers. An empty `.codeowner`, whose owner is "", still names an
owner there, and a test now pins that.

A package naming a team that doesn't exist is still matched by path
prefix, since the package mappers leave it out.

Refs #146.
`validate` counts each distinct team name its matchers report for a
file as an owner. The team glob mapper reported a team's GitHub team,
and the `.codeowner` mapper the name as written, which can also be the
GitHub team, while the other mappers report the team's name. So a file
that one team reached two such ways got two owners, though `for-file`
and CODEOWNERS give it one. On main that happens wherever a team's
owned globs cover a file the team also owns another way, as for
`frontend/apps/public/index.tsx` in the javascript_only_project
fixture, or where a `.codeowner` names its team by GitHub team and
another mapper names it by name.

Now that the root's glob matches every file, a repo that passes today
would also fail wherever a root `.codeowner` or package meets an owned
glob of the same team, or a root `.codeowner` naming the team by GitHub
team meets an annotation or package naming it by name. Both mappers now
report the team's name, and keep a name that matches no team as
written. A multiple-owner report names a team glob's team by name too.

Refs #146.
Escaping only brackets in a team file's path, as the `.codeowner` and
annotation mappers did before, passed every test, though the CODEOWNERS
lookup would then read the line for `config/teams/{dev}.yml` as
`config/teams/dev.yml`. Nor did any test cover how `for-file` describes
a `package.json` at the project root, the JavaScript side of
`nearest_package_owner`. Add both cases.

Refs #146.
@dduugg
dduugg requested a review from a team as a code owner November 3, 2026 01:18
@github-project-automation github-project-automation Bot moved this to Triage in Modularity Nov 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Triage

Development

Successfully merging this pull request may close these issues.

Package, .codeowner and annotation globs aren't escaped, and break at the project root

1 participant