Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 67 additions & 33 deletions packages/rstack/src/fmt/discoverPaths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,13 +188,14 @@ class GitIgnoreFiles {
}

// Ignore files may disappear or become unreadable during traversal.
const loading = readFile(path.join(directoryPath, '.gitignore'), 'utf8')
.then((content) => {
const loading = readFile(path.join(directoryPath, '.gitignore'), 'utf8').then(
(content) => {
const relativePath = toPosixPath(this.#resolveRelativePath(directoryPath));
this.#matcher ??= new (loadNativeBinding().GitIgnoreMatcher)();
this.#hasRules = this.#matcher.addSource(relativePath, content);
})
.catch(() => undefined);
},
() => undefined,
);

this.#loads.set(directoryPath, loading);
return loading;
Expand All @@ -204,11 +205,14 @@ class GitIgnoreFiles {
const createTraversalOptions = (
gitIgnore: GitIgnoreFiles,
ignoredDirNames: ReadonlySet<string>,
signal: { aborted: boolean },
onError: (error: unknown) => void,
isIncluded?: (filePath: string) => boolean,
isIgnored?: (filePath: string, isDirectory: boolean) => boolean,
) => {
return {
followSymlinks: false,
signal,
ignore: (targetPath: string, targetContext: DirentLike) => {
// With symlink following disabled, tiny-readdir always provides a Dirent here.
const dirent = targetContext as Dirent;
Expand All @@ -233,43 +237,78 @@ const createTraversalOptions = (
);
},
onDirents: async (dirents: Dirent[]) => {
const parentPath = getDirentParentPath(dirents[0]);
let hasGitIgnore = false;
try {
const parentPath = getDirentParentPath(dirents[0]);
let hasGitIgnore = false;

for (const dirent of dirents) {
if (dirent.name === '.gitignore') {
hasGitIgnore = true;
for (const dirent of dirents) {
if (dirent.name === '.gitignore') {
hasGitIgnore = true;
}
}
}

if (hasGitIgnore) {
await gitIgnore.load(parentPath);
}

const ignored = gitIgnore.matchDirents(parentPath, dirents);
if (typeof ignored === 'boolean') {
if (ignored) {
(dirents[0] as GitIgnoreDirent)[gitIgnored] = true;
if (hasGitIgnore) {
await gitIgnore.load(parentPath);
}
} else if (typeof ignored === 'number') {
for (let index = 0; index < dirents.length; index++) {
if (ignored & (1 << index)) {
(dirents[index] as GitIgnoreDirent)[gitIgnored] = true;

const ignored = gitIgnore.matchDirents(parentPath, dirents);
if (typeof ignored === 'boolean') {
if (ignored) {
(dirents[0] 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;
} 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;
}
}
}
} catch (error) {
onError(error);
}

return undefined;
},
};
};

const discoverDirectoryFiles = async (
rootPath: string,
gitIgnore: GitIgnoreFiles,
ignoredDirNames: ReadonlySet<string>,
isIncluded?: (filePath: string) => boolean,
isIgnored?: (filePath: string, isDirectory: boolean) => boolean,
): Promise<string[]> => {
let failed = false;
let failure: unknown;
const signal = { aborted: false };
const onError = (error: unknown): void => {
if (!failed) {
failed = true;
failure = error;
}
signal.aborted = true;
};

const result = await readdir(
rootPath,
createTraversalOptions(gitIgnore, ignoredDirNames, signal, onError, isIncluded, isIgnored),
);

// tiny-readdir only handles fulfilled onDirents promises, so rethrow after its counter settles.
if (failed) {
throw failure;
}

return result.files;
};

const normalizeGlob = (cwd: string, pattern: string): string => {
const relativePattern = path.isAbsolute(pattern) ? path.relative(cwd, pattern) : pattern;
return toPosixPath(relativePattern);
Expand Down Expand Up @@ -426,12 +465,7 @@ const discoverFmtPaths = async ({
return globMatchers.some((matches) => matches(relativePath));
};

return (
await readdir(
rootPath,
createTraversalOptions(gitIgnore, ignoredDirNames, isIncluded, isIgnored),
)
).files;
return discoverDirectoryFiles(rootPath, gitIgnore, ignoredDirNames, isIncluded, isIgnored);
}),
);

Expand Down
22 changes: 21 additions & 1 deletion packages/rstack/tests/fmt/discoverPaths.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { symlinkSync } from 'node:fs';
import path from 'node:path';
import { expect, test } from 'rstack/test';
import { expect, rs, test } from 'rstack/test';
import { discoverFmtPaths } from '../../src/fmt/discoverPaths.ts';
import * as nativeBinding from '../../src/native/index.ts';
import { withTempProject, writeProjectFile } from './helpers.ts';

const relativePaths = (rootPath: string, files: string[]): string[] =>
Expand Down Expand Up @@ -165,6 +166,25 @@ test('keeps valid nested gitignore rules around normalized and malformed lines',
});
});

test('propagates native binding errors while loading a nested gitignore', async () => {
await withTempProject(async (rootPath) => {
writeProjectFile(rootPath, 'src/.gitignore', '*.js\n');
writeProjectFile(rootPath, 'src/index.js');
const nativeError = new Error('Failed to load native binding');
const loadNativeBinding = rs
.spyOn(nativeBinding, 'loadNativeBinding')
.mockImplementation(() => {
throw nativeError;
});

try {
await expect(discoverFmtPaths({ cwd: rootPath })).rejects.toBe(nativeError);
} finally {
loadNativeBinding.mockRestore();
}
});
});

test('lets explicit files bypass gitignore', async () => {
await withTempProject(async (rootPath) => {
writeProjectFile(rootPath, '.gitignore', '/generated/\n');
Expand Down