Skip to content

Commit cf1bc97

Browse files
committed
fix(fmt): propagate native gitignore errors
1 parent c209281 commit cf1bc97

2 files changed

Lines changed: 107 additions & 34 deletions

File tree

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

Lines changed: 67 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -188,13 +188,14 @@ class GitIgnoreFiles {
188188
}
189189

190190
// Ignore files may disappear or become unreadable during traversal.
191-
const loading = readFile(path.join(directoryPath, '.gitignore'), 'utf8')
192-
.then((content) => {
191+
const loading = readFile(path.join(directoryPath, '.gitignore'), 'utf8').then(
192+
(content) => {
193193
const relativePath = toPosixPath(this.#resolveRelativePath(directoryPath));
194194
this.#matcher ??= new (loadNativeBinding().GitIgnoreMatcher)();
195195
this.#hasRules = this.#matcher.addSource(relativePath, content);
196-
})
197-
.catch(() => undefined);
196+
},
197+
() => undefined,
198+
);
198199

199200
this.#loads.set(directoryPath, loading);
200201
return loading;
@@ -204,11 +205,14 @@ class GitIgnoreFiles {
204205
const createTraversalOptions = (
205206
gitIgnore: GitIgnoreFiles,
206207
ignoredDirNames: ReadonlySet<string>,
208+
signal: { aborted: boolean },
209+
onError: (error: unknown) => void,
207210
isIncluded?: (filePath: string) => boolean,
208211
isIgnored?: (filePath: string, isDirectory: boolean) => boolean,
209212
) => {
210213
return {
211214
followSymlinks: false,
215+
signal,
212216
ignore: (targetPath: string, targetContext: DirentLike) => {
213217
// With symlink following disabled, tiny-readdir always provides a Dirent here.
214218
const dirent = targetContext as Dirent;
@@ -233,43 +237,78 @@ const createTraversalOptions = (
233237
);
234238
},
235239
onDirents: async (dirents: Dirent[]) => {
236-
const parentPath = getDirentParentPath(dirents[0]);
237-
let hasGitIgnore = false;
240+
try {
241+
const parentPath = getDirentParentPath(dirents[0]);
242+
let hasGitIgnore = false;
238243

239-
for (const dirent of dirents) {
240-
if (dirent.name === '.gitignore') {
241-
hasGitIgnore = true;
244+
for (const dirent of dirents) {
245+
if (dirent.name === '.gitignore') {
246+
hasGitIgnore = true;
247+
}
242248
}
243-
}
244249

245-
if (hasGitIgnore) {
246-
await gitIgnore.load(parentPath);
247-
}
248-
249-
const ignored = gitIgnore.matchDirents(parentPath, dirents);
250-
if (typeof ignored === 'boolean') {
251-
if (ignored) {
252-
(dirents[0] as GitIgnoreDirent)[gitIgnored] = true;
250+
if (hasGitIgnore) {
251+
await gitIgnore.load(parentPath);
253252
}
254-
} else if (typeof ignored === 'number') {
255-
for (let index = 0; index < dirents.length; index++) {
256-
if (ignored & (1 << index)) {
257-
(dirents[index] as GitIgnoreDirent)[gitIgnored] = true;
253+
254+
const ignored = gitIgnore.matchDirents(parentPath, dirents);
255+
if (typeof ignored === 'boolean') {
256+
if (ignored) {
257+
(dirents[0] as GitIgnoreDirent)[gitIgnored] = true;
258258
}
259-
}
260-
} else if (ignored) {
261-
for (let index = 0; index < ignored.length; index++) {
262-
if (ignored[index] === 1) {
263-
(dirents[index] as GitIgnoreDirent)[gitIgnored] = true;
259+
} else if (typeof ignored === 'number') {
260+
for (let index = 0; index < dirents.length; index++) {
261+
if (ignored & (1 << index)) {
262+
(dirents[index] as GitIgnoreDirent)[gitIgnored] = true;
263+
}
264+
}
265+
} else if (ignored) {
266+
for (let index = 0; index < ignored.length; index++) {
267+
if (ignored[index] === 1) {
268+
(dirents[index] as GitIgnoreDirent)[gitIgnored] = true;
269+
}
264270
}
265271
}
272+
} catch (error) {
273+
onError(error);
266274
}
267275

268276
return undefined;
269277
},
270278
};
271279
};
272280

281+
const discoverDirectoryFiles = async (
282+
rootPath: string,
283+
gitIgnore: GitIgnoreFiles,
284+
ignoredDirNames: ReadonlySet<string>,
285+
isIncluded?: (filePath: string) => boolean,
286+
isIgnored?: (filePath: string, isDirectory: boolean) => boolean,
287+
): Promise<string[]> => {
288+
let failed = false;
289+
let failure: unknown;
290+
const signal = { aborted: false };
291+
const onError = (error: unknown): void => {
292+
if (!failed) {
293+
failed = true;
294+
failure = error;
295+
}
296+
signal.aborted = true;
297+
};
298+
299+
const result = await readdir(
300+
rootPath,
301+
createTraversalOptions(gitIgnore, ignoredDirNames, signal, onError, isIncluded, isIgnored),
302+
);
303+
304+
// tiny-readdir only handles fulfilled onDirents promises, so rethrow after its counter settles.
305+
if (failed) {
306+
throw failure;
307+
}
308+
309+
return result.files;
310+
};
311+
273312
const normalizeGlob = (cwd: string, pattern: string): string => {
274313
const relativePattern = path.isAbsolute(pattern) ? path.relative(cwd, pattern) : pattern;
275314
return toPosixPath(relativePattern);
@@ -426,12 +465,7 @@ const discoverFmtPaths = async ({
426465
return globMatchers.some((matches) => matches(relativePath));
427466
};
428467

429-
return (
430-
await readdir(
431-
rootPath,
432-
createTraversalOptions(gitIgnore, ignoredDirNames, isIncluded, isIgnored),
433-
)
434-
).files;
468+
return discoverDirectoryFiles(rootPath, gitIgnore, ignoredDirNames, isIncluded, isIgnored);
435469
}),
436470
);
437471

‎packages/rstack/tests/fmt/discoverPaths.test.ts‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { symlinkSync } from 'node:fs';
22
import path from 'node:path';
3-
import { expect, test } from 'rstack/test';
3+
import { expect, rs, test } from 'rstack/test';
44
import { discoverFmtPaths } from '../../src/fmt/discoverPaths.ts';
5+
import { loadNativeBinding } from '../../src/native/index.ts';
56
import { withTempProject, writeProjectFile } from './helpers.ts';
67

78
const relativePaths = (rootPath: string, files: string[]): string[] =>
@@ -165,6 +166,44 @@ test('keeps valid nested gitignore rules around normalized and malformed lines',
165166
});
166167
});
167168

169+
test.sequential('propagates errors while loading a nested gitignore', async () => {
170+
await withTempProject(async (rootPath) => {
171+
writeProjectFile(rootPath, 'src/.gitignore', '*.js\n');
172+
writeProjectFile(rootPath, 'src/index.js');
173+
const nativeError = new Error('Failed to add nested gitignore source');
174+
const addSource = rs
175+
.spyOn(loadNativeBinding().GitIgnoreMatcher.prototype, 'addSource')
176+
.mockImplementation(() => {
177+
throw nativeError;
178+
});
179+
180+
try {
181+
await expect(discoverFmtPaths({ cwd: rootPath })).rejects.toBe(nativeError);
182+
} finally {
183+
addSource.mockRestore();
184+
}
185+
});
186+
});
187+
188+
test.sequential('propagates errors from batched native gitignore matching', async () => {
189+
await withTempProject(async (rootPath) => {
190+
writeProjectFile(rootPath, '.gitignore', '*.js\n');
191+
writeProjectFile(rootPath, 'index.js');
192+
const nativeError = new Error('Failed to match gitignore entries');
193+
const matchBatch = rs
194+
.spyOn(loadNativeBinding().GitIgnoreMatcher.prototype, 'isIgnoredBatchMask')
195+
.mockImplementation(() => {
196+
throw nativeError;
197+
});
198+
199+
try {
200+
await expect(discoverFmtPaths({ cwd: rootPath })).rejects.toBe(nativeError);
201+
} finally {
202+
matchBatch.mockRestore();
203+
}
204+
});
205+
});
206+
168207
test('lets explicit files bypass gitignore', async () => {
169208
await withTempProject(async (rootPath) => {
170209
writeProjectFile(rootPath, '.gitignore', '/generated/\n');

0 commit comments

Comments
 (0)