From 68afa4a938c051f977a6e2653d21d364e04a4104 Mon Sep 17 00:00:00 2001 From: neverland Date: Tue, 11 Aug 2026 21:19:40 +0800 Subject: [PATCH] perf(fmt): move gitignore matching to Rust --- crates/rstack-binding/src/lib.rs | 99 ++++- crates/rstack-ignore/src/lib.rs | 388 +++++++++++++++++- packages/rstack/THIRD_PARTY_NOTICES.md | 28 -- packages/rstack/binding.cjs | 1 + packages/rstack/binding.d.cts | 16 + packages/rstack/package.json | 1 - packages/rstack/src/fmt/discoverPaths.ts | 149 +++---- .../rstack/tests/fmt/discoverPaths.test.ts | 26 ++ pnpm-lock.yaml | 12 - pnpm-workspace.yaml | 1 - 10 files changed, 590 insertions(+), 131 deletions(-) diff --git a/crates/rstack-binding/src/lib.rs b/crates/rstack-binding/src/lib.rs index 4a3e70f8..da3a361d 100644 --- a/crates/rstack-binding/src/lib.rs +++ b/crates/rstack-binding/src/lib.rs @@ -2,9 +2,12 @@ use std::path::Path; -use napi::Error; +use napi::{bindgen_prelude::Uint8Array, Error, Status}; use napi_derive::napi; -use rstack_ignore::{IgnoreMatcher as CoreIgnoreMatcher, IgnoreSource as CoreIgnoreSource}; +use rstack_ignore::{ + GitIgnoreMatcher as CoreGitIgnoreMatcher, IgnoreMatcher as CoreIgnoreMatcher, + IgnoreSource as CoreIgnoreSource, +}; /// A Gitignore-compatible pattern source received from JavaScript. #[napi(object, object_to_js = false)] @@ -42,3 +45,95 @@ impl IgnoreMatcher { self.inner.is_ignored(Path::new(&file_path), is_directory) } } + +/// JavaScript-facing hierarchy for repository `.gitignore` files. +#[napi] +pub struct GitIgnoreMatcher { + inner: CoreGitIgnoreMatcher, +} + +impl Default for GitIgnoreMatcher { + fn default() -> Self { + Self { + inner: CoreGitIgnoreMatcher::new(), + } + } +} + +#[napi] +impl GitIgnoreMatcher { + /// Creates an empty matcher whose sources can be added during directory traversal. + #[napi(constructor)] + pub fn new() -> Self { + Self::default() + } + + /// Compiles or replaces rules rooted at a repository-relative POSIX directory. + #[napi] + pub fn add_source(&mut self, relative_root: String, patterns: String) -> napi::Result { + self.inner + .add_source(&relative_root, &patterns) + .map_err(|error| { + Error::from_reason(format!("Failed to compile .gitignore patterns: {error}")) + }) + } + + /// Returns whether one repository-relative POSIX path is ignored. + #[napi] + pub fn is_ignored(&mut self, relative_path: String, is_directory: bool) -> bool { + self.inner.is_ignored(&relative_path, is_directory) + } + + /// Matches one directory's entries in a native call and returns one byte per name. + #[napi] + pub fn is_ignored_batch( + &mut self, + relative_parent: String, + names: Vec, + directory_flags: Uint8Array, + ) -> napi::Result { + if names.len() != directory_flags.len() { + return Err(Error::new( + Status::InvalidArg, + "Name and directory flag counts must match.", + )); + } + + Ok(self + .inner + .is_ignored_batch(&relative_parent, &names, directory_flags.as_ref()) + .into()) + } + + /// Matches up to 32 entries while avoiding per-directory typed-array allocation. + #[napi] + pub fn is_ignored_batch_mask( + &mut self, + relative_parent: String, + names: Vec, + directory_mask: u32, + ) -> napi::Result { + if names.len() > u32::BITS as usize { + return Err(Error::new( + Status::InvalidArg, + "A bit-mask batch cannot contain more than 32 names.", + )); + } + + Ok(self + .inner + .is_ignored_batch_mask(&relative_parent, &names, directory_mask)) + } + + /// Matches a single directory entry without constructing a JavaScript array. + #[napi] + pub fn is_ignored_child( + &mut self, + relative_parent: String, + name: String, + is_directory: bool, + ) -> bool { + self.inner + .is_ignored_child(&relative_parent, &name, is_directory) + } +} diff --git a/crates/rstack-ignore/src/lib.rs b/crates/rstack-ignore/src/lib.rs index 965307ef..c0944109 100644 --- a/crates/rstack-ignore/src/lib.rs +++ b/crates/rstack-ignore/src/lib.rs @@ -7,6 +7,25 @@ use ignore::{ Match, }; +fn compile_patterns(patterns: &str) -> Result { + // Paths are made relative to the source root before matching, so the builder uses a + // synthetic root instead of tying compiled patterns to an absolute path. + let mut builder = GitignoreBuilder::new("."); + // Match the case-insensitive default used by the previous JavaScript matcher. + builder.case_insensitive(true)?; + + // Accept ignore files with CRLF line endings or a UTF-8 byte-order mark. + for line in patterns.split('\n') { + let line = line.strip_suffix('\r').unwrap_or(line); + let line = line.strip_prefix('\u{feff}').unwrap_or(line); + // Gitignore files and the previous JavaScript matcher treat malformed lines as + // nonmatching, while continuing to apply the remaining valid rules. + let _ = builder.add_line(None, line); + } + + builder.build() +} + /// Raw Gitignore patterns anchored to a base directory. pub struct IgnoreSource { root_path: PathBuf, @@ -49,6 +68,248 @@ impl IgnoreMatcher { } } +/// A hierarchy of repository `.gitignore` files keyed by their root-relative directories. +#[derive(Default)] +pub struct GitIgnoreMatcher { + matchers: HashMap, GitIgnoreSourceMatcher>, + // Traversal checks every file's parent, so cache both ignored and included directories. + ignored_directories: HashMap, bool>, +} + +impl GitIgnoreMatcher { + /// Creates an empty hierarchy. Sources can be added as traversal discovers them. + pub fn new() -> Self { + Self::default() + } + + /// Adds or replaces the patterns rooted at a POSIX, repository-relative directory. + /// + /// Returns whether the hierarchy contains any effective rules after the update. + pub fn add_source( + &mut self, + relative_root: &str, + patterns: &str, + ) -> Result { + let relative_root = normalize_relative_path(relative_root); + let matcher = compile_patterns(patterns)?; + + self.invalidate_directory_cache(relative_root); + if matcher.is_empty() { + self.matchers.remove(relative_root); + } else { + self.matchers + .insert(relative_root.into(), GitIgnoreSourceMatcher::new(matcher)); + } + Ok(!self.matchers.is_empty()) + } + + /// Returns whether a repository-relative POSIX path is ignored by its applicable hierarchy. + pub fn is_ignored(&mut self, relative_path: &str, is_directory: bool) -> bool { + let relative_path = normalize_relative_path(relative_path); + if relative_path.is_empty() || self.matchers.is_empty() { + return false; + } + + if is_directory { + return self.is_directory_ignored(relative_path); + } + + // Git cannot re-include a path below an ignored directory, so parent state wins. + parent_directory(relative_path).is_some_and(|parent| self.is_directory_ignored(parent)) + || self.matches(relative_path, false) + } + + /// Matches one directory's child names without crossing the native boundary per entry. + pub fn is_ignored_batch( + &mut self, + relative_parent: &str, + names: &[String], + directory_flags: &[u8], + ) -> Vec { + let relative_parent = normalize_relative_path(relative_parent); + let separator = usize::from(!relative_parent.is_empty()); + let name_capacity = names.iter().map(String::len).max().unwrap_or(0); + let mut relative_path = + String::with_capacity(relative_parent.len() + separator + name_capacity); + let mut ignored = Vec::with_capacity(names.len()); + + for (name, is_directory) in names.iter().zip(directory_flags) { + relative_path.clear(); + if !relative_parent.is_empty() { + relative_path.push_str(relative_parent); + relative_path.push('/'); + } + relative_path.push_str(name); + ignored.push(u8::from( + self.is_ignored(&relative_path, *is_directory != 0), + )); + } + + ignored + } + + /// Matches one child without allocating an intermediate names array. + pub fn is_ignored_child( + &mut self, + relative_parent: &str, + name: &str, + is_directory: bool, + ) -> bool { + let relative_parent = normalize_relative_path(relative_parent); + if relative_parent.is_empty() { + return self.is_ignored(name, is_directory); + } + + let mut relative_path = String::with_capacity(relative_parent.len() + 1 + name.len()); + relative_path.push_str(relative_parent); + relative_path.push('/'); + relative_path.push_str(name); + self.is_ignored(&relative_path, is_directory) + } + + /// Matches up to 32 child names and packs both input types and results into bit masks. + pub fn is_ignored_batch_mask( + &mut self, + relative_parent: &str, + names: &[String], + directory_mask: u32, + ) -> u32 { + debug_assert!(names.len() <= u32::BITS as usize); + + let relative_parent = normalize_relative_path(relative_parent); + let separator = usize::from(!relative_parent.is_empty()); + let name_capacity = names.iter().map(String::len).max().unwrap_or(0); + let mut relative_path = + String::with_capacity(relative_parent.len() + separator + name_capacity); + let mut ignored_mask = 0; + + for (index, name) in names.iter().enumerate() { + relative_path.clear(); + if !relative_parent.is_empty() { + relative_path.push_str(relative_parent); + relative_path.push('/'); + } + relative_path.push_str(name); + if self.is_ignored(&relative_path, directory_mask & (1 << index) != 0) { + ignored_mask |= 1 << index; + } + } + + ignored_mask + } + + fn invalidate_directory_cache(&mut self, relative_root: &str) { + if relative_root.is_empty() { + self.ignored_directories.clear(); + return; + } + + let descendant_prefix = format!("{relative_root}/"); + self.ignored_directories + .retain(|relative_path, _| !relative_path.starts_with(&descendant_prefix)); + } + + fn is_directory_ignored(&mut self, relative_path: &str) -> bool { + let relative_path = relative_path.trim_end_matches('/'); + if relative_path.is_empty() { + return false; + } + + if let Some(ignored) = self.ignored_directories.get(relative_path) { + return *ignored; + } + + let ignored = parent_directory(relative_path) + .is_some_and(|parent| self.is_directory_ignored(parent)) + || self.matches(relative_path, true); + self.ignored_directories + .insert(relative_path.into(), ignored); + ignored + } + + fn matches(&mut self, relative_path: &str, is_directory: bool) -> bool { + // Most repositories only use a root `.gitignore`. Avoid looking up every path segment + // when no nested matcher can override the root result. + if self.matchers.len() == 1 { + if let Some(root_matcher) = self.matchers.get_mut("") { + return root_matcher + .match_path(relative_path, is_directory) + .unwrap_or(false); + } + } + + let mut ignored = false; + let mut matcher_root_end = 0; + let mut path_from_matcher_start = 0; + + for segment in relative_path.split('/') { + let matcher_root = &relative_path[..matcher_root_end]; + if let Some(matcher) = self.matchers.get_mut(matcher_root) { + let path_from_matcher = &relative_path[path_from_matcher_start..]; + if let Some(state) = matcher.match_path(path_from_matcher, is_directory) { + ignored = state; + } + } + + matcher_root_end = path_from_matcher_start + segment.len(); + path_from_matcher_start = (matcher_root_end + 1).min(relative_path.len()); + } + + ignored + } +} + +/// One `.gitignore` source with the directory state needed to reproduce +/// `ignore.test(path)` without re-walking ancestors for every file. +struct GitIgnoreSourceMatcher { + matcher: Gitignore, + directory_states: HashMap, Option>, +} + +impl GitIgnoreSourceMatcher { + fn new(matcher: Gitignore) -> Self { + Self { + matcher, + directory_states: HashMap::new(), + } + } + + fn match_path(&mut self, relative_path: &str, is_directory: bool) -> Option { + if is_directory { + return self.match_directory(relative_path); + } + + match self.matcher.matched(relative_path, false) { + Match::Ignore(_) => Some(true), + Match::Whitelist(_) => Some(false), + Match::None => parent_directory(relative_path) + .is_some_and(|parent| self.is_directory_ignored(parent)) + .then_some(true), + } + } + + fn match_directory(&mut self, relative_path: &str) -> Option { + if let Some(state) = self.directory_states.get(relative_path) { + return *state; + } + + let matched = self.matcher.matched(relative_path, true); + let state = match matched { + Match::Ignore(_) => Some(true), + Match::Whitelist(_) => Some(false), + Match::None => parent_directory(relative_path) + .is_some_and(|parent| self.is_directory_ignored(parent)) + .then_some(true), + }; + self.directory_states.insert(relative_path.into(), state); + state + } + + fn is_directory_ignored(&mut self, relative_path: &str) -> bool { + self.match_directory(relative_path) == Some(true) + } +} + struct SourceMatcher { root_path: PathBuf, matcher: Gitignore, @@ -58,24 +319,9 @@ struct SourceMatcher { impl SourceMatcher { fn new(source: IgnoreSource) -> Result { - // Paths are made relative to the source root before matching, so the builder uses a - // synthetic root instead of tying compiled patterns to an absolute path. - let mut builder = GitignoreBuilder::new("."); - // Match the case-insensitive default used by the previous JavaScript matcher. - builder.case_insensitive(true)?; - - // Accept ignore files with CRLF line endings or a UTF-8 byte-order mark. - for line in source.patterns.split('\n') { - let line = line.strip_suffix('\r').unwrap_or(line); - let line = line.strip_prefix('\u{feff}').unwrap_or(line); - // Gitignore files and the previous JavaScript matcher treat malformed lines as - // nonmatching, while continuing to apply the remaining valid rules. - let _ = builder.add_line(None, line); - } - Ok(Self { root_path: source.root_path, - matcher: builder.build()?, + matcher: compile_patterns(&source.patterns)?, ignored_directories: HashMap::new(), }) } @@ -136,6 +382,15 @@ fn is_ignore_match(matched: Match<&ignore::gitignore::Glob>) -> bool { matches!(matched, Match::Ignore(_)) } +fn normalize_relative_path(path: &str) -> &str { + let path = path.trim_matches('/'); + if path == "." { + "" + } else { + path.strip_prefix("./").unwrap_or(path) + } +} + fn to_posix_path(path: &Path) -> Cow<'_, str> { let path = path.to_string_lossy(); @@ -149,3 +404,104 @@ fn to_posix_path(path: &Path) -> Cow<'_, str> { path } } + +#[cfg(test)] +mod tests { + use super::{GitIgnoreMatcher, IgnoreMatcher, IgnoreSource}; + use std::path::Path; + + #[test] + fn keeps_independent_ignore_sources_isolated() { + let mut matcher = IgnoreMatcher::new([ + IgnoreSource::new("project", "*.js\n!keep.js"), + IgnoreSource::new("project", "keep.js"), + ]) + .unwrap(); + + assert!(matcher.is_ignored(Path::new("project/keep.js"), false)); + assert!(matcher.is_ignored(Path::new("project/drop.js"), false)); + assert!(!matcher.is_ignored(Path::new("project/keep.ts"), false)); + } + + #[test] + fn applies_nested_sources_and_child_negation() { + let mut matcher = GitIgnoreMatcher::new(); + matcher.add_source("", "*.js\ndist/\n").unwrap(); + matcher.add_source("src", "!keep.js\n").unwrap(); + matcher.add_source("dist", "!keep.js\n").unwrap(); + + assert!(!matcher.is_ignored("src/keep.js", false)); + assert!(matcher.is_ignored("src/drop.js", false)); + assert!(matcher.is_ignored("dist", true)); + assert!(matcher.is_ignored("dist/keep.js", false)); + assert!(!matcher.is_ignored("visible.ts", false)); + } + + #[test] + fn does_not_propagate_an_ancestor_unignore_across_sources() { + let mut matcher = GitIgnoreMatcher::new(); + matcher.add_source("", "debug/\n").unwrap(); + matcher.add_source("scripts", "!debug\n").unwrap(); + + assert!(!matcher.is_ignored("scripts/debug", true)); + assert!(matcher.is_ignored("scripts/debug/launch.mjs", false)); + } + + #[test] + fn applies_a_nested_source_without_root_rules() { + let mut matcher = GitIgnoreMatcher::new(); + matcher.add_source("src", "*.js\n").unwrap(); + + assert!(!matcher.is_ignored("root.js", false)); + assert!(matcher.is_ignored("src/drop.js", false)); + assert!(!matcher.is_ignored("src/keep.ts", false)); + } + + #[test] + fn preserves_valid_rules_around_malformed_and_normalized_lines() { + let mut matcher = GitIgnoreMatcher::new(); + matcher + .add_source("", "\u{feff}ignored.js\r\nmalformed\\\n*.snap\n") + .unwrap(); + + assert!(matcher.is_ignored("IGNORED.JS", false)); + assert!(matcher.is_ignored("nested/value.snap", false)); + assert!(!matcher.is_ignored("nested/value.ts", false)); + } + + #[test] + fn invalidates_descendant_directory_cache_when_adding_a_source() { + let mut matcher = GitIgnoreMatcher::new(); + matcher.add_source("", "generated/keep/**\n").unwrap(); + + assert!(matcher.is_ignored("generated/keep/nested", true)); + matcher.add_source("generated/keep", "!nested/\n").unwrap(); + + assert!(!matcher.is_ignored("generated/keep/nested", true)); + } + + #[test] + fn matches_batches_in_input_order() { + let mut matcher = GitIgnoreMatcher::new(); + matcher.add_source("", "*.js\ndist/\n").unwrap(); + let names = vec!["index.js".into(), "index.ts".into(), "dist".into()]; + + assert_eq!( + matcher.is_ignored_batch("nested", &names, &[0, 0, 1]), + vec![1, 0, 1] + ); + assert_eq!( + matcher.is_ignored_batch_mask("nested", &names, 0b100), + 0b101 + ); + assert!(matcher.is_ignored_child("nested", "index.js", false)); + } + + #[test] + fn omits_empty_sources() { + let mut matcher = GitIgnoreMatcher::new(); + + assert!(!matcher.add_source("", "# comment only\n").unwrap()); + assert!(!matcher.is_ignored("index.js", false)); + } +} diff --git a/packages/rstack/THIRD_PARTY_NOTICES.md b/packages/rstack/THIRD_PARTY_NOTICES.md index 470cc525..dbd76f6b 100644 --- a/packages/rstack/THIRD_PARTY_NOTICES.md +++ b/packages/rstack/THIRD_PARTY_NOTICES.md @@ -87,34 +87,6 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -## ignore - -This package includes bundled code from [ignore](https://github.com/kaelzhang/node-ignore). - -License: MIT - -Copyright (c) 2013 Kael Zhang , contributors -http://kael.me/ - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - ## import-meta-resolve This package includes bundled code from diff --git a/packages/rstack/binding.cjs b/packages/rstack/binding.cjs index 57c34102..7cfe958b 100644 --- a/packages/rstack/binding.cjs +++ b/packages/rstack/binding.cjs @@ -700,4 +700,5 @@ if (!nativeBinding) { } module.exports = nativeBinding +module.exports.GitIgnoreMatcher = nativeBinding.GitIgnoreMatcher module.exports.IgnoreMatcher = nativeBinding.IgnoreMatcher diff --git a/packages/rstack/binding.d.cts b/packages/rstack/binding.d.cts index 265f41b6..fdbfacfc 100644 --- a/packages/rstack/binding.d.cts +++ b/packages/rstack/binding.d.cts @@ -1,5 +1,21 @@ /* auto-generated by NAPI-RS */ /* eslint-disable */ +/** JavaScript-facing hierarchy for repository `.gitignore` files. */ +export declare class GitIgnoreMatcher { + /** Creates an empty matcher whose sources can be added during directory traversal. */ + constructor() + /** Compiles or replaces rules rooted at a repository-relative POSIX directory. */ + addSource(relativeRoot: string, patterns: string): boolean + /** Returns whether one repository-relative POSIX path is ignored. */ + isIgnored(relativePath: string, isDirectory: boolean): boolean + /** Matches one directory's entries in a native call and returns one byte per name. */ + isIgnoredBatch(relativeParent: string, names: Array, directoryFlags: Uint8Array): Uint8Array + /** Matches up to 32 entries while avoiding per-directory typed-array allocation. */ + isIgnoredBatchMask(relativeParent: string, names: Array, directoryMask: number): number + /** Matches a single directory entry without constructing a JavaScript array. */ + isIgnoredChild(relativeParent: string, name: string, isDirectory: boolean): boolean +} + /** JavaScript-facing wrapper around the compiled Rust matcher. */ export declare class IgnoreMatcher { /** Compiles all pattern sources once and keeps the result for repeated path checks. */ diff --git a/packages/rstack/package.json b/packages/rstack/package.json index 89782fd7..8af35737 100644 --- a/packages/rstack/package.json +++ b/packages/rstack/package.json @@ -91,7 +91,6 @@ "@types/micromatch": "catalog:", "@types/node": "catalog:", "fast-json-stable-stringify": "catalog:", - "ignore": "catalog:", "import-meta-resolve": "catalog:", "is-binary-path": "catalog:", "lint-staged": "catalog:", diff --git a/packages/rstack/src/fmt/discoverPaths.ts b/packages/rstack/src/fmt/discoverPaths.ts index fba8da05..0850cec0 100644 --- a/packages/rstack/src/fmt/discoverPaths.ts +++ b/packages/rstack/src/fmt/discoverPaths.ts @@ -1,9 +1,10 @@ import { lstat, readFile } from 'node:fs/promises'; import path from 'node:path'; -import ignore from 'ignore'; import isBinaryPath from 'is-binary-path'; import micromatch from 'micromatch'; import readdir, { type Dirent, type DirentLike } from 'tiny-readdir'; +import type { GitIgnoreMatcher as NativeGitIgnoreMatcher } from '../../binding.cjs'; +import { loadNativeBinding } from '../native/index.ts'; import { createRelativePathResolver, toPosixPath, @@ -20,6 +21,9 @@ const defaultIgnoredDirNames = new Set([ 'node_modules', ]); +const gitIgnored = Symbol('gitIgnored'); +type GitIgnoreDirent = Dirent & { [gitIgnored]?: true }; + interface DiscoverFmtPathsOptions { /** Absolute directory used to resolve input paths. */ cwd: string; @@ -81,20 +85,21 @@ const findGitRoot = async (cwd: string): Promise => { } }; -class GitIgnoreMatcher { +/** Loads repository ignore files while Rust owns their compiled matching state. */ +class GitIgnoreFiles { readonly #rootPath: string; readonly #resolveRelativePath: RelativePathResolver; - readonly #matchers = new Map>(); readonly #loads = new Map>(); - readonly #ignoredDirectories = new Map(); + #matcher: NativeGitIgnoreMatcher | undefined; + #hasRules = false; private constructor(rootPath: string) { this.#rootPath = rootPath; this.#resolveRelativePath = createRelativePathResolver(rootPath); } - static async create(cwd: string): Promise { - const matcher = new GitIgnoreMatcher(await findGitRoot(cwd)); + static async create(cwd: string): Promise { + const matcher = new GitIgnoreFiles(await findGitRoot(cwd)); await matcher.loadThrough(cwd); return matcher; } @@ -124,7 +129,7 @@ class GitIgnoreMatcher { } isIgnored(filePath: string, isDirectory: boolean): boolean { - if (this.#matchers.size === 0) { + if (!this.#hasRules) { return false; } @@ -133,15 +138,47 @@ class GitIgnoreMatcher { return false; } - if (isDirectory) { - return this.#isDirectoryIgnored(filePath, relativePath); + return this.#matcher!.isIgnored(toPosixPath(relativePath), isDirectory); + } + + /** Matches one directory's entries in a single native call. */ + matchDirents(parentPath: string, dirents: Dirent[]): boolean | number | Uint8Array | undefined { + if (!this.#hasRules || dirents.length === 0) { + return; } - const parentPath = path.dirname(filePath); - return ( - (parentPath !== this.#rootPath && this.#isDirectoryIgnored(parentPath)) || - this.#matches(relativePath, false) - ); + const relativeParentPath = this.#resolveRelativePath(parentPath); + if (!isRelativePathInside(relativeParentPath)) { + return; + } + + const relativeParent = toPosixPath(relativeParentPath); + + if (dirents.length === 1) { + const dirent = dirents[0]; + return this.#matcher!.isIgnoredChild(relativeParent, dirent.name, dirent.isDirectory()); + } + + const names = new Array(dirents.length); + + if (dirents.length <= 32) { + let directoryMask = 0; + for (let index = 0; index < dirents.length; index++) { + const dirent = dirents[index]; + names[index] = dirent.name; + directoryMask |= Number(dirent.isDirectory()) << index; + } + return this.#matcher!.isIgnoredBatchMask(relativeParent, names, directoryMask >>> 0); + } + + const directoryFlags = new Uint8Array(dirents.length); + for (let index = 0; index < dirents.length; index++) { + const dirent = dirents[index]; + names[index] = dirent.name; + directoryFlags[index] = Number(dirent.isDirectory()); + } + + return this.#matcher!.isIgnoredBatch(relativeParent, names, directoryFlags); } #load(directoryPath: string): Promise { @@ -154,69 +191,18 @@ class GitIgnoreMatcher { const loading = readFile(path.join(directoryPath, '.gitignore'), 'utf8') .then((content) => { const relativePath = toPosixPath(this.#resolveRelativePath(directoryPath)); - this.#matchers.set(relativePath, ignore().add(content)); + this.#matcher ??= new (loadNativeBinding().GitIgnoreMatcher)(); + this.#hasRules = this.#matcher.addSource(relativePath, content); }) .catch(() => undefined); this.#loads.set(directoryPath, loading); return loading; } - - #isDirectoryIgnored(directoryPath: string, relativePath?: string): boolean { - const cached = this.#ignoredDirectories.get(directoryPath); - if (cached !== undefined) { - return cached; - } - - relativePath ??= this.#resolveRelativePath(directoryPath); - - // Git cannot re-include a path below an ignored directory. - const parentPath = path.dirname(directoryPath); - const ignored = - (parentPath !== this.#rootPath && this.#isDirectoryIgnored(parentPath)) || - this.#matches(relativePath, true); - this.#ignoredDirectories.set(directoryPath, ignored); - return ignored; - } - - #matches(relativePath: string, isDirectory: boolean): boolean { - const pathFromRoot = toPosixPath(relativePath); - - // Most repositories only use a root `.gitignore`. Avoid checking every path - // segment when no nested matcher can override its result. - const rootMatcher = this.#matchers.size === 1 ? this.#matchers.get('') : undefined; - if (rootMatcher) { - // `ignore` expects POSIX separators and uses a trailing slash to distinguish directories. - return rootMatcher.test(isDirectory ? `${pathFromRoot}/` : pathFromRoot).ignored; - } - - const segments = pathFromRoot.split('/'); - let directoryPath = ''; - let pathFromMatcher = pathFromRoot; - let ignored = false; - - for (const segment of segments) { - const matcher = this.#matchers.get(directoryPath); - if (matcher) { - const result = matcher.test(isDirectory ? `${pathFromMatcher}/` : pathFromMatcher); - - if (result.ignored) { - ignored = true; - } else if (result.unignored) { - ignored = false; - } - } - - directoryPath = directoryPath ? `${directoryPath}/${segment}` : segment; - pathFromMatcher = pathFromMatcher.slice(segment.length + 1); - } - - return ignored; - } } const createTraversalOptions = ( - gitIgnore: GitIgnoreMatcher, + gitIgnore: GitIgnoreFiles, ignoredDirNames: ReadonlySet, isIncluded?: (filePath: string) => boolean, isIgnored?: (filePath: string, isDirectory: boolean) => boolean, @@ -231,7 +217,9 @@ const createTraversalOptions = ( } if (dirent.isDirectory()) { - return gitIgnore.isIgnored(targetPath, true) || isIgnored?.(targetPath, true) === true; + return ( + (dirent as GitIgnoreDirent)[gitIgnored] === true || isIgnored?.(targetPath, true) === true + ); } if (isIncluded !== undefined && !isIncluded(targetPath)) { @@ -241,7 +229,7 @@ const createTraversalOptions = ( return ( isIgnored?.(targetPath, false) === true || isBinaryPath(targetPath) || - gitIgnore.isIgnored(targetPath, false) + (dirent as GitIgnoreDirent)[gitIgnored] === true ); }, onDirents: async (dirents: Dirent[]) => { @@ -258,6 +246,25 @@ const createTraversalOptions = ( await gitIgnore.load(parentPath); } + const ignored = gitIgnore.matchDirents(parentPath, dirents); + if (typeof ignored === 'boolean') { + if (ignored) { + (dirents[0] as GitIgnoreDirent)[gitIgnored] = true; + } + } else if (typeof ignored === 'number') { + for (let index = 0; index < dirents.length; index++) { + if (ignored & (1 << index)) { + (dirents[index] as GitIgnoreDirent)[gitIgnored] = true; + } + } + } else if (ignored) { + for (let index = 0; index < ignored.length; index++) { + if (ignored[index] === 1) { + (dirents[index] as GitIgnoreDirent)[gitIgnored] = true; + } + } + } + return undefined; }, }; @@ -392,7 +399,7 @@ const discoverFmtPaths = async ({ const traversalRoots = getTraversalRoots(cwd, directoryRoots, globs); if (traversalRoots.length) { - const gitIgnore = await GitIgnoreMatcher.create(cwd); + const gitIgnore = await GitIgnoreFiles.create(cwd); const results = await Promise.all( traversalRoots.map(async (rootPath) => { const stats = await lstatSafe(rootPath); diff --git a/packages/rstack/tests/fmt/discoverPaths.test.ts b/packages/rstack/tests/fmt/discoverPaths.test.ts index 22f88b99..1d4a4416 100644 --- a/packages/rstack/tests/fmt/discoverPaths.test.ts +++ b/packages/rstack/tests/fmt/discoverPaths.test.ts @@ -122,6 +122,18 @@ test('applies nested gitignore rules with child negation', async () => { }); }); +test('does not extend a nested directory negation to its files', async () => { + await withTempProject(async (rootPath) => { + writeProjectFile(rootPath, '.gitignore', 'debug/\n'); + writeProjectFile(rootPath, 'scripts/.gitignore', '!debug\n'); + writeProjectFile(rootPath, 'scripts/debug/launch.mjs'); + + const files = await discoverFmtPaths({ cwd: rootPath, patterns: ['**/*.mjs'] }); + + expect(files).toEqual([]); + }); +}); + test('applies a nested gitignore without a root matcher', async () => { await withTempProject(async (rootPath) => { writeProjectFile(rootPath, 'src/.gitignore', '*.js\n'); @@ -139,6 +151,20 @@ test('applies a nested gitignore without a root matcher', async () => { }); }); +test('keeps valid nested gitignore rules around normalized and malformed lines', async () => { + await withTempProject(async (rootPath) => { + writeProjectFile(rootPath, '.gitignore', '\uFEFF*.js\r\nmalformed\\\r\n'); + writeProjectFile(rootPath, 'src/.gitignore', '!keep.js\r\n'); + writeProjectFile(rootPath, 'src/keep.js'); + writeProjectFile(rootPath, 'src/drop.js'); + writeProjectFile(rootPath, 'visible.ts'); + + const files = await discoverFmtPaths({ cwd: rootPath, patterns: ['**/*.{js,ts}'] }); + + expect(relativePaths(rootPath, files)).toEqual([path.join('src', 'keep.js'), 'visible.ts']); + }); +}); + test('lets explicit files bypass gitignore', async () => { await withTempProject(async (rootPath) => { writeProjectFile(rootPath, '.gitignore', '/generated/\n'); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 11bd8abb..e26f1fa5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -91,9 +91,6 @@ catalogs: heading-case: specifier: ^1.1.5 version: 1.1.5 - ignore: - specifier: 7.0.6 - version: 7.0.6 import-meta-resolve: specifier: 4.2.0 version: 4.2.0 @@ -398,9 +395,6 @@ importers: fast-json-stable-stringify: specifier: 'catalog:' version: 2.1.0 - ignore: - specifier: 'catalog:' - version: 7.0.6 import-meta-resolve: specifier: 'catalog:' version: 4.2.0 @@ -2135,10 +2129,6 @@ packages: resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} - ignore@7.0.6: - resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} - engines: {node: '>= 4'} - immutable@5.1.9: resolution: {integrity: sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==} @@ -4500,8 +4490,6 @@ snapshots: dependencies: safer-buffer: 2.1.2 - ignore@7.0.6: {} - immutable@5.1.9: {} import-meta-resolve@4.2.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c27933cf..c57d25d7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -40,7 +40,6 @@ catalog: 'fast-json-stable-stringify': '2.1.0' 'happy-dom': '^20.11.2' 'heading-case': '^1.1.5' - ignore: 7.0.6 'import-meta-resolve': '4.2.0' is-binary-path: 3.0.0 'lint-staged': '^17.3.0'