fix(oxfmt): do not re-include a file below an excluded directory with a negated pattern (#27237)

## Summary

A negated pattern no longer re-includes a file whose parent directory is
excluded, for explicit paths, `--stdin-filepath` and the language
server. This is what the directory walk, Git and Prettier already do.

With `.prettierignore`:

```
vendor/
!vendor/keep.ts
```

`oxfmt .` skips `vendor/keep.ts` (the walk prunes `vendor/`), but `oxfmt
vendor/keep.ts`, `--stdin-filepath vendor/keep.ts` and format-on-save
formatted it. The explicit-path checks call
`Gitignore::matched_path_or_any_parents()`, which matches the path
itself first: `!vendor/keep.ts` is a whitelist match and the parent
directory is never consulted. The same applied to `ignorePatterns` in
`.oxfmtrc.json`.

Git documents the rule: "It is not possible to re-include a file if a
parent directory of that file is excluded."

## Measured behaviour

Formatted = the file is formatted, ignored = it is skipped. Git column
is `git check-ignore` with the same patterns as `.gitignore`. Prettier
is 3.9.9 (the version pinned in `apps/oxfmt/package.json`).

| Patterns (`.prettierignore`) | File | git | CLI walk | CLI explicit /
stdin / LSP, before | CLI explicit / stdin / LSP, after | Prettier `.` /
explicit |
| --- | --- | --- | --- | --- | --- | --- |
| A: `vendor/`, `!vendor/keep.ts` | `vendor/keep.ts` | ignored | ignored
| formatted | ignored | ignored / ignored |
| B: `vendor/*`, `!vendor/keep.ts` | `vendor/keep.ts` | not ignored |
formatted | formatted | formatted | formatted / formatted |
| C: `*.gen.ts`, `!keep.gen.ts` | `keep.gen.ts` | not ignored |
formatted | formatted | formatted | formatted / formatted |

Only variant A changes. The "before" and "after" cells for explicit
path, stdin and LSP were observed with the test suite below (the A cases
fail without the fix).

Beyond these three variants, the fixed build was compared with Prettier
3.9.9 on nine pattern sets: the three above, `vendor` (no slash),
`/vendor/` (anchored), `vendor/**`, `**/vendor/` with the file in
`pkg/vendor/`, a nested re-include (`vendor/` + `!vendor/sub/` +
`!vendor/sub/keep.ts`), and a negation of the directory itself
(`vendor/` + `!vendor/`). Each set was run with the patterns in
`.prettierignore`, in `--ignore-path`, and (for oxfmt) in
`ignorePatterns`, for the walk, an explicit path and `--stdin-filepath`.
The fixed oxfmt matches Prettier in every cell. Before the fix, oxfmt
differed for `vendor/`, `vendor`, `/vendor/`, `**/vendor/` and the
nested re-include on explicit paths and stdin; with `ignorePatterns`,
the nested re-include also leaked into the walk (`vendor/sub/*` was
formatted by `oxfmt .`), which the fix closes as well.

## The rule and where it is applied

`matches_with_ancestors()` in `apps/oxfmt/src/core/global_ignore.rs`
returns true if the matcher ignores the path, or ignores one of its
parent directories below the matcher's root (each checked as a
directory). A whitelist match on the path itself does not override an
excluded parent. Paths outside the matcher's root are not ignored, as
before.

It is used by:
- `is_ignored(.., check_ancestors: true)`: explicit CLI paths, stdin,
LSP (`.prettierignore`, `--ignore-path`, `!` excludes)
- `ConfigResolver::is_path_ignored`: `ignorePatterns` in the config file

Calls with `check_ancestors: false` (the walk) are unchanged. Each
matcher is still evaluated independently, so nested ignore files and the
per-matcher semantics are unchanged.

## Behaviour change

oxfmt is 0.x. The old behaviour dates from the explicit-path ignore
(#17732) and the language server's `.prettierignore` support. The change
is narrow: a negation of a file inside a directory excluded with `dir/`
(or by a parent-directory pattern) no longer re-includes it. Negations
that are not below an excluded directory (`vendor/*` with
`!vendor/keep.ts`, `*.gen.ts` with `!keep.gen.ts`) keep working
everywhere.

## Tests

- CLI fixture `apps/oxfmt/test/cli/ignore_excluded_parent/`: variants A,
B, C with `.prettierignore` (walk, explicit path, `--stdin-filepath`),
plus variant A as `ignorePatterns` in `.oxfmtrc.json`
- LSP (`apps/oxfmt/test/lsp/format/`): `vendor/keep.ts` with `vendor/` +
`!vendor/keep.ts` produces no edits; `vendor/*` + `!vendor/keep.ts`
still formats; existing `keep.gen.ts` case covers C
- Reverting the fix makes the variant A explicit-path, stdin and LSP
cases fail
- `cargo test -p oxfmt`, `cargo clippy -p oxfmt -- -D warnings`, `cargo
fmt --check`, `typos`, `cargo shear` pass. In the JS suite, 6 CLI tests
(`force_color/1`, `force_color/5`, `ignore_symlink/0`, `vite_config/2`,
`vite_plus/2`, `vite_plus/3`) fail in my environment with and without
this change.

AI disclosure: implemented with Claude Code under my review; every
change was tested and reviewed by me.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-authored-by: leaysgur <6259812+leaysgur@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-01 05:20:56 +00:00
1 parent 6e9fa306b1
commit 2606c60fe5
13 files changed
+168 -24

No files matched your search

+2 -5
View File
@@ -36,6 +36,7 @@ use self::{
use super::options::to_oxc_formatter;
use super::{
FormatStrategy,
global_ignore::matches_with_ancestors,
options::{ValidatedOptions, validate},
oxfmtrc::{FormatConfig, Oxfmtrc},
support::FileKind,
@@ -256,11 +257,7 @@ impl ConfigResolver {
/// Returns `true` if the given path should be ignored by this config's `ignorePatterns`.
pub fn is_path_ignored(&self, path: &Path, is_dir: bool) -> bool {
self.ignore_glob.as_ref().is_some_and(|glob| {
// `matched_path_or_any_parents()` panics if path is not under the glob's root.
path.starts_with(glob.path())
&& glob.matched_path_or_any_parents(path, is_dir).is_ignore()
})
self.ignore_glob.as_ref().is_some_and(|glob| matches_with_ancestors(glob, path, is_dir))
}
/// Create a resolver, handling both JSON/JSONC and JS/TS config files.
+23 -15
View File
@@ -76,7 +76,8 @@ pub fn build_global_ignore_matchers(
/// Check if a path should be ignored by any of the matchers.
/// A path is ignored if any matcher says it's ignored (and not whitelisted in that same matcher).
///
/// When `check_ancestors: true`, also checks if any parent directory is ignored.
/// When `check_ancestors: true`, a path below a parent directory ignored by a matcher is ignored too,
/// even if the same matcher whitelists the path itself, see [`matches_with_ancestors`].
/// This is more expensive, but necessary when paths (to be ignored) are passed directly via CLI arguments.
/// For normal walking, walk is done in a top-down manner, so only the current path needs to be checked.
pub fn is_ignored(
@@ -85,20 +86,27 @@ pub fn is_ignored(
is_dir: bool,
check_ancestors: bool,
) -> bool {
for matcher in matchers {
let matched = if check_ancestors {
// `matched_path_or_any_parents()` panics if path is not under matcher's root.
// Skip this matcher if the path is outside its scope.
if !path.starts_with(matcher.path()) {
continue;
}
matcher.matched_path_or_any_parents(path, is_dir)
matchers.iter().any(|matcher| {
if check_ancestors {
matches_with_ancestors(matcher, path, is_dir)
} else {
matcher.matched(path, is_dir)
};
if matched.is_ignore() && !matched.is_whitelist() {
return true;
matcher.matched(path, is_dir).is_ignore()
}
}
false
})
}
/// Check if a path is ignored by the matcher, or by one of its parent directories below the matcher's root.
///
/// Like Git, a negated pattern cannot re-include a path if one of its parent directories is excluded.
/// This gives the same result as a top-down walk, which does not descend into an ignored directory.
/// A path outside the matcher's root is never ignored.
pub fn matches_with_ancestors(matcher: &Gitignore, path: &Path, is_dir: bool) -> bool {
let root = matcher.path();
path.starts_with(root)
&& (matcher.matched(path, is_dir).is_ignore()
|| path
.ancestors()
.skip(1)
.take_while(|ancestor| *ancestor != root && ancestor.starts_with(root))
.any(|ancestor| matcher.matched(ancestor, true).is_ignore()))
}
@@ -0,0 +1,48 @@
# Input
## Command
```
oxfmt --check --no-error-on-unmatched-pattern not-formatted/bad.js
```
## File tree
```
- fixtures/
- basic/ <CWD>
- .oxfmtrc.json
- fmtrc.jsonc
- not-formatted/
- bad.js
- err.js
- nested_cwd/
- .oxfmtrc.json
- generated/
- error.html
- src/
- test.js
- sub/
- generated/
- error.html
- src/
- test.js
```
# Result
## Exit code
0
## stdout
```
Checking formatting...
Finished in <time> on 0 files using 1 threads.
```
## stderr
```
No files found matching the given patterns.
```
## File changes
No changes
@@ -1,3 +1,3 @@
{
"ignorePatterns": ["not-formatted/"]
"ignorePatterns": ["not-formatted/", "!not-formatted/bad.js"]
}
@@ -14,5 +14,9 @@
{
"cwd": "nested_cwd/sub",
"args": ["--check", "--config", "../.oxfmtrc.json", "."]
},
{
"cwd": "basic",
"args": ["--check", "--no-error-on-unmatched-pattern", "not-formatted/bad.js"]
}
]
@@ -0,0 +1,35 @@
# Input
## Command
```
oxfmt --ignore-path ignore2 --stdin-filepath should_format/ok.js
```
## File tree
```
- fixtures/ <CWD>
- ignore1
- ignore2
- should_format/
- ok.js
- should_ignore/
- err.js
```
# Result
## Exit code
0
## stdout
```
const a = 1;
```
## stderr
```
```
## File changes
No changes
@@ -12,5 +12,9 @@
]
},
{ "args": ["--check", "--ignore-path", "ignore2"] },
{ "args": ["--check", "--ignore-path", "ignore2", "should_format/ok.js"] }
{ "args": ["--check", "--ignore-path", "ignore2", "should_format/ok.js"] },
{
"args": ["--ignore-path", "ignore2", "--stdin-filepath", "should_format/ok.js"],
"stdin": "should_format/ok.js"
}
]
@@ -0,0 +1,44 @@
# Input
## Command
```
oxfmt --check --no-error-on-unmatched-pattern not-formatted/bad.ts
```
## File tree
```
- fixtures/ <CWD>
- .prettierignore
- custom.ignore
- formatted/
- good.js
- gitignore.txt
- good.ts
- ignored/
- should-be-ignored.js
- should-be-ignored.ts
- not-formatted/
- .prettierignore
- bad.js
- bad.ts
```
# Result
## Exit code
0
## stdout
```
Checking formatting...
Finished in <time> on 0 files using 1 threads.
```
## stderr
```
No files found matching the given patterns.
```
## File changes
No changes
@@ -1 +1,2 @@
not-formatted/
!not-formatted/bad.ts
@@ -2,5 +2,6 @@
{ "args": ["--check"] },
{ "args": ["--check", "--ignore-path", "gitignore.txt"] },
{ "args": ["--check", "--ignore-path", "gitignore.txt", "--ignore-path", ".prettierignore"] },
{ "args": ["--check", "--ignore-path", "nonexistent.ignore"] }
{ "args": ["--check", "--ignore-path", "nonexistent.ignore"] },
{ "args": ["--check", "--no-error-on-unmatched-pattern", "not-formatted/bad.ts"] }
]
@@ -1,3 +1,3 @@
{
"ignorePatterns": ["ignored/"]
"ignorePatterns": ["ignored/", "!ignored/test.ts"]
}
@@ -1 +1,2 @@
prettierignored/
!prettierignored/test.ts
@@ -1,3 +1,4 @@
vendor/
*.gen.ts
!keep.gen.ts
!vendor/a.ts