Skip to content

Commit 5fe9881

Browse files
authored
perf(fmt): reuse compiled gitignore rules and release traversal state (#590)
1 parent a462d97 commit 5fe9881

5 files changed

Lines changed: 190 additions & 49 deletions

File tree

‎crates/rstack-binding/src/lib.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,12 @@ impl GitIgnoreMatcher {
137137
Self::default()
138138
}
139139

140+
/// Releases compiled rules and matching state after directory traversal.
141+
#[napi]
142+
pub fn clear(&mut self) {
143+
self.inner.clear();
144+
}
145+
140146
/// Compiles or replaces rules rooted at a repository-relative POSIX directory.
141147
#[napi]
142148
pub fn add_source(&mut self, relative_root: String, patterns: String) -> napi::Result<bool> {

‎crates/rstack-ignore/src/lib.rs‎

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#![deny(clippy::all)]
22

3-
use std::{borrow::Cow, collections::HashMap, path::Path, path::PathBuf};
3+
use std::{borrow::Cow, collections::HashMap, path::Path, path::PathBuf, sync::Arc};
44

55
use ignore::{
66
gitignore::{Gitignore, GitignoreBuilder},
@@ -138,6 +138,7 @@ impl IgnoreMatcher {
138138
/// A hierarchy of repository `.gitignore` files keyed by their root-relative directories.
139139
#[derive(Default)]
140140
pub struct GitIgnoreMatcher {
141+
compiled_patterns: HashMap<Box<str>, Arc<Gitignore>>,
141142
matchers: HashMap<Box<str>, GitIgnoreSourceMatcher>,
142143
// Traversal checks every file's parent, so cache both ignored and included directories.
143144
ignored_directories: HashMap<Box<str>, bool>,
@@ -149,6 +150,11 @@ impl GitIgnoreMatcher {
149150
Self::default()
150151
}
151152

153+
/// Releases compiled rules and matching state after directory traversal.
154+
pub fn clear(&mut self) {
155+
*self = Self::default();
156+
}
157+
152158
/// Adds or replaces the patterns rooted at a POSIX, repository-relative directory.
153159
///
154160
/// Returns whether the hierarchy contains any effective rules after the update.
@@ -158,7 +164,15 @@ impl GitIgnoreMatcher {
158164
patterns: &str,
159165
) -> Result<bool, ignore::Error> {
160166
let relative_root = normalize_relative_path(relative_root);
161-
let matcher = compile_patterns(patterns)?;
167+
// Compiled rules use relative paths, so identical contents can share them across roots.
168+
let matcher = if let Some(matcher) = self.compiled_patterns.get(patterns) {
169+
Arc::clone(matcher)
170+
} else {
171+
let matcher = Arc::new(compile_patterns(patterns)?);
172+
self.compiled_patterns
173+
.insert(patterns.into(), Arc::clone(&matcher));
174+
matcher
175+
};
162176

163177
self.invalidate_directory_cache(relative_root);
164178
if matcher.is_empty() {
@@ -329,12 +343,12 @@ impl GitIgnoreMatcher {
329343
/// One `.gitignore` source with the directory state needed to reproduce
330344
/// `ignore.test(path)` without re-walking ancestors for every file.
331345
struct GitIgnoreSourceMatcher {
332-
matcher: Gitignore,
346+
matcher: Arc<Gitignore>,
333347
directory_states: HashMap<Box<str>, Option<bool>>,
334348
}
335349

336350
impl GitIgnoreSourceMatcher {
337-
fn new(matcher: Gitignore) -> Self {
351+
fn new(matcher: Arc<Gitignore>) -> Self {
338352
Self {
339353
matcher,
340354
directory_states: HashMap::new(),
@@ -550,6 +564,31 @@ mod tests {
550564
use super::{GitIgnoreMatcher, IgnoreMatcher, IgnoreSource};
551565
use std::path::Path;
552566

567+
#[test]
568+
fn identical_gitignore_contents_keep_independent_roots() {
569+
let mut matcher = GitIgnoreMatcher::new();
570+
let patterns = "/dist/\n";
571+
matcher.add_source("a", patterns).unwrap();
572+
matcher.add_source("b", patterns).unwrap();
573+
for root in ["a", "b"] {
574+
assert!(matcher.is_ignored(&format!("{root}/dist/index.js"), false));
575+
assert!(!matcher.is_ignored(&format!("{root}/nested/dist/index.js"), false));
576+
}
577+
matcher.add_source("a", "").unwrap();
578+
assert!(!matcher.is_ignored("a/dist/index.js", false));
579+
assert!(matcher.is_ignored("b/dist/index.js", false));
580+
assert!(!matcher.is_ignored("dist/index.js", false));
581+
}
582+
583+
#[test]
584+
fn clearing_gitignore_removes_rules_and_directory_state() {
585+
let mut matcher = GitIgnoreMatcher::new();
586+
matcher.add_source("", "dist/\n").unwrap();
587+
assert!(matcher.is_ignored("dist/index.js", false));
588+
matcher.clear();
589+
assert!(!matcher.is_ignored("dist/index.js", false));
590+
}
591+
553592
#[test]
554593
fn keeps_independent_ignore_sources_isolated() {
555594
let mut matcher = IgnoreMatcher::new([

‎packages/rstack/binding.d.cts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export declare const __napiBindingTarget: 'native' | 'wasm32-wasi' | 'wasm32-was
1313
export declare class GitIgnoreMatcher {
1414
/** Creates an empty matcher whose sources can be added during directory traversal. */
1515
constructor()
16+
/** Releases compiled rules and matching state after directory traversal. */
17+
clear(): void
1618
/** Compiles or replaces rules rooted at a repository-relative POSIX directory. */
1719
addSource(relativeRoot: string, patterns: string): boolean
1820
/** Returns whether one repository-relative POSIX path is ignored. */

‎packages/rstack/src/fmt/discoverPaths.ts‎

Lines changed: 71 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,25 @@ class GitIgnoreFiles {
115115

116116
static async create(cwd: string): Promise<GitIgnoreFiles> {
117117
const matcher = new GitIgnoreFiles(await findGitRoot(cwd));
118-
await matcher.loadThrough(cwd);
119-
return matcher;
118+
try {
119+
await matcher.loadThrough(cwd);
120+
return matcher;
121+
} catch (error) {
122+
await matcher.waitForLoads();
123+
matcher.dispose();
124+
throw error;
125+
}
126+
}
127+
128+
async waitForLoads(): Promise<void> {
129+
// A failed operation may leave other ignore files still loading.
130+
await Promise.allSettled(this.#loads.values());
131+
}
132+
133+
dispose(): void {
134+
this.#matcher?.clear();
135+
this.#matcher = undefined;
136+
this.#loads.clear();
120137
}
121138

122139
async loadThrough(directoryPath: string): Promise<void> {
@@ -642,53 +659,62 @@ const discoverFmtPaths = async ({
642659

643660
if (traversalRoots.length) {
644661
const gitIgnore = await GitIgnoreFiles.create(cwd);
645-
const results = await Promise.all(
646-
traversalRoots.map(async (rootPath) => {
647-
const stats = await lstatSafe(rootPath);
648-
if (!stats?.isDirectory()) {
649-
return [];
650-
}
662+
const traversals = traversalRoots.map(async (rootPath) => {
663+
const stats = await lstatSafe(rootPath);
664+
if (!stats?.isDirectory()) {
665+
return [];
666+
}
651667

652-
await gitIgnore.loadThrough(rootPath);
653-
if (
654-
gitIgnore.isIgnored(rootPath, true) ||
655-
isIgnored?.(rootPath, true) === true
656-
) {
657-
return [];
658-
}
668+
await gitIgnore.loadThrough(rootPath);
669+
if (
670+
gitIgnore.isIgnored(rootPath, true) ||
671+
isIgnored?.(rootPath, true) === true
672+
) {
673+
return [];
674+
}
659675

660-
const includesAll = directoryRoots.some((directoryPath) =>
661-
isPathInside(directoryPath, rootPath),
662-
);
663-
const isIncluded = includesAll
664-
? undefined
665-
: (filePath: string): boolean => {
666-
if (
667-
directoryRoots.some((directoryPath) =>
668-
isPathInside(directoryPath, filePath),
669-
)
670-
) {
671-
return true;
672-
}
673-
674-
const relativePath = toPosixPath(resolveRelativePath(filePath));
675-
return globMatchers.some((matches) => matches(relativePath));
676-
};
677-
678-
return discoverDirectoryFiles(
679-
rootPath,
680-
gitIgnore,
681-
ignoredDirNames,
682-
isIncluded,
683-
isIgnored,
684-
);
685-
}),
686-
);
676+
const includesAll = directoryRoots.some((directoryPath) =>
677+
isPathInside(directoryPath, rootPath),
678+
);
679+
const isIncluded = includesAll
680+
? undefined
681+
: (filePath: string): boolean => {
682+
if (
683+
directoryRoots.some((directoryPath) =>
684+
isPathInside(directoryPath, filePath),
685+
)
686+
) {
687+
return true;
688+
}
689+
690+
const relativePath = toPosixPath(resolveRelativePath(filePath));
691+
return globMatchers.some((matches) => matches(relativePath));
692+
};
693+
694+
return discoverDirectoryFiles(
695+
rootPath,
696+
gitIgnore,
697+
ignoredDirNames,
698+
isIncluded,
699+
isIgnored,
700+
);
701+
});
687702

688-
for (const files of results) {
689-
for (const filePath of files) {
690-
candidates.add(filePath);
703+
try {
704+
const results = await Promise.all(traversals);
705+
706+
for (const files of results) {
707+
for (const filePath of files) {
708+
candidates.add(filePath);
709+
}
691710
}
711+
} catch (error) {
712+
// A rejected traversal must not release rules still used by another root.
713+
await Promise.allSettled(traversals);
714+
await gitIgnore.waitForLoads();
715+
throw error;
716+
} finally {
717+
gitIgnore.dispose();
692718
}
693719
}
694720

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import path from 'node:path';
2+
import { setImmediate } from 'node:timers/promises';
3+
import { afterEach, expect, rs, test } from 'rstack/test';
4+
import { discoverFmtPaths } from '../../src/fmt/discoverPaths.ts';
5+
import { loadNativeBinding } from '../../src/native/index.ts';
6+
import { withTempProject, writeProjectFile } from './helpers.ts';
7+
8+
const mocks = rs.hoisted(() => ({ readdir: rs.fn() }));
9+
rs.mock('tiny-readdir', () => ({ default: mocks.readdir }));
10+
11+
afterEach(() => {
12+
rs.restoreAllMocks();
13+
mocks.readdir.mockReset();
14+
});
15+
16+
test('waits for pending traversals before releasing rules after a failure', async () => {
17+
await withTempProject(async (rootPath) => {
18+
writeProjectFile(rootPath, '.gitignore', '*.log\n');
19+
const firstFile = writeProjectFile(rootPath, 'first/index.ts');
20+
const secondFile = writeProjectFile(rootPath, 'second/index.ts');
21+
const pending = Promise.withResolvers<{ files: string[] }>();
22+
const started = Promise.withResolvers<void>();
23+
const error = new Error('Traversal failed');
24+
const clear = rs.spyOn(
25+
loadNativeBinding().GitIgnoreMatcher.prototype,
26+
'clear',
27+
);
28+
29+
mocks.readdir.mockImplementation((directory: string) => {
30+
if (directory === path.dirname(firstFile)) {
31+
return Promise.reject(error);
32+
}
33+
started.resolve();
34+
return pending.promise;
35+
});
36+
37+
const discovery = discoverFmtPaths({
38+
cwd: rootPath,
39+
patterns: ['first', 'second'],
40+
});
41+
const completion = expect(discovery).rejects.toBe(error);
42+
try {
43+
await started.promise;
44+
// Let a sibling rejection propagate while this traversal is still pending.
45+
await setImmediate();
46+
expect(clear).not.toHaveBeenCalled();
47+
} finally {
48+
pending.resolve({ files: [secondFile] });
49+
await completion;
50+
}
51+
expect(clear).toHaveBeenCalledTimes(1);
52+
});
53+
});
54+
55+
test('releases rules when initialization fails', async () => {
56+
await withTempProject(async (rootPath) => {
57+
writeProjectFile(rootPath, '.gitignore', '*.log\n');
58+
const prototype = loadNativeBinding().GitIgnoreMatcher.prototype;
59+
const error = new Error('Invalid ignore source');
60+
rs.spyOn(prototype, 'addSource').mockImplementation(() => {
61+
throw error;
62+
});
63+
const clear = rs.spyOn(prototype, 'clear');
64+
65+
await expect(discoverFmtPaths({ cwd: rootPath })).rejects.toBe(error);
66+
expect(clear).toHaveBeenCalledTimes(1);
67+
});
68+
});

0 commit comments

Comments
 (0)