diff --git a/packages/rstack/src/fmt/discoverPaths.ts b/packages/rstack/src/fmt/discoverPaths.ts index 896aba0..f0e16f4 100644 --- a/packages/rstack/src/fmt/discoverPaths.ts +++ b/packages/rstack/src/fmt/discoverPaths.ts @@ -11,6 +11,8 @@ interface DiscoverFmtPathsOptions { /** Absolute directory used to resolve input paths. */ cwd: string; patterns?: string[]; + /** Returns whether a scanned directory can be pruned before traversal. */ + isDirectoryIgnored?: (directoryPath: string) => boolean; } const isErrnoException = (error: unknown): error is NodeJS.ErrnoException => @@ -201,6 +203,7 @@ class GitIgnoreMatcher { const createTraversalOptions = ( gitIgnore: GitIgnoreMatcher, isIncluded?: (filePath: string) => boolean, + isDirectoryIgnored?: (directoryPath: string) => boolean, ) => { // tiny-readdir passes only a path to `ignore`, so retain the dirent type briefly. const directories = new Set(); @@ -214,7 +217,7 @@ const createTraversalOptions = ( } if (isDirectory) { - return gitIgnore.isIgnored(targetPath, true); + return gitIgnore.isIgnored(targetPath, true) || isDirectoryIgnored?.(targetPath) === true; } return ( @@ -343,6 +346,7 @@ const getTraversalRoots = (cwd: string, directories: string[], globs: string[]): const discoverFmtPaths = async ({ cwd, patterns: inputPatterns, + isDirectoryIgnored, }: DiscoverFmtPathsOptions): Promise => { const patterns = inputPatterns?.length ? inputPatterns : ['.']; const { @@ -366,7 +370,7 @@ const discoverFmtPaths = async ({ } await gitIgnore.loadThrough(rootPath); - if (gitIgnore.isIgnored(rootPath, true)) { + if (gitIgnore.isIgnored(rootPath, true) || isDirectoryIgnored?.(rootPath) === true) { return []; } @@ -384,7 +388,9 @@ const discoverFmtPaths = async ({ return globMatchers.some((matches) => matches(relativePath)); }; - return (await readdir(rootPath, createTraversalOptions(gitIgnore, isIncluded))).files; + return ( + await readdir(rootPath, createTraversalOptions(gitIgnore, isIncluded, isDirectoryIgnored)) + ).files; }), ); diff --git a/packages/rstack/src/fmt/discovery.ts b/packages/rstack/src/fmt/discovery.ts index 77d992d..47dc157 100644 --- a/packages/rstack/src/fmt/discovery.ts +++ b/packages/rstack/src/fmt/discovery.ts @@ -8,17 +8,19 @@ const createFileRequest = (filePath: string, config: ResolvedFmtConfig): FmtFile options: resolveFmtOptions(filePath, config), }); -/** Discovers worker-ready files without reading Prettier config files or `.prettierignore`. */ +/** Discovers worker-ready files without automatically reading Prettier config or ignore files. */ const discoverFmtFiles = async ({ cwd, patterns, ignorePaths, config, }: DiscoverFmtFilesOptions): Promise => { - const [candidates, isIgnored] = await Promise.all([ - discoverFmtPaths({ cwd, patterns }), - createIgnoreMatcher({ config, cwd, ignorePaths }), - ]); + const isIgnored = await createIgnoreMatcher({ config, cwd, ignorePaths }); + const candidates = await discoverFmtPaths({ + cwd, + patterns, + isDirectoryIgnored: (directoryPath) => isIgnored(directoryPath, true), + }); if (candidates.length === 0) { return []; } diff --git a/packages/rstack/src/fmt/ignore.ts b/packages/rstack/src/fmt/ignore.ts index 21d5860..0813098 100644 --- a/packages/rstack/src/fmt/ignore.ts +++ b/packages/rstack/src/fmt/ignore.ts @@ -11,7 +11,7 @@ import type { ResolvedFmtConfig } from './types.ts'; */ const defaultIgnorePatterns = ['package-lock.json', 'pnpm-lock.yaml']; -type IgnoreMatcher = (filePath: string) => boolean; +type IgnoreMatcher = (filePath: string, isDirectory?: boolean) => boolean; interface CreateIgnoreMatcherOptions { config: ResolvedFmtConfig; @@ -23,7 +23,8 @@ interface CreateIgnoreMatcherOptions { const createPatternMatcher = (rootPath: string, patterns: string): IgnoreMatcher => { const matches = fastIgnore(patterns); - return (filePath) => matches(path.relative(rootPath, filePath)); + return (filePath, isDirectory = false) => + matches(path.relative(rootPath, filePath), { isDirectory }); }; const loadIgnoreMatcher = async (cwd: string, ignorePath: string): Promise => { @@ -55,8 +56,9 @@ const createIgnoreMatcher = async ({ ignorePaths.map((ignorePath) => loadIgnoreMatcher(cwd, ignorePath)), ); - return (filePath) => - configMatcher(filePath) || ignoreMatchers.some((matches) => matches(filePath)); + return (filePath, isDirectory = false) => + configMatcher(filePath, isDirectory) || + ignoreMatchers.some((matches) => matches(filePath, isDirectory)); }; export { createIgnoreMatcher }; diff --git a/packages/rstack/tests/fmt/discoverPaths.test.ts b/packages/rstack/tests/fmt/discoverPaths.test.ts index 7de16a3..a712f61 100644 --- a/packages/rstack/tests/fmt/discoverPaths.test.ts +++ b/packages/rstack/tests/fmt/discoverPaths.test.ts @@ -112,6 +112,31 @@ test('lets explicit files bypass gitignore', async () => { }); }); +test('prunes directories with an external ignore matcher', async () => { + await withTempProject(async (rootPath) => { + writeProjectFile(rootPath, 'generated/nested/output.ts'); + writeProjectFile(rootPath, 'src/index.ts'); + const checkedDirectories: string[] = []; + const generatedPath = path.join(rootPath, 'generated'); + const isDirectoryIgnored = (directoryPath: string): boolean => { + checkedDirectories.push(path.relative(rootPath, directoryPath)); + return directoryPath === generatedPath; + }; + + const files = await discoverFmtPaths({ cwd: rootPath, isDirectoryIgnored }); + const ignoredRoot = await discoverFmtPaths({ + cwd: rootPath, + patterns: ['generated'], + isDirectoryIgnored, + }); + + expect(relativePaths(rootPath, files)).toEqual([path.join('src', 'index.ts')]); + expect(ignoredRoot).toEqual([]); + expect(checkedDirectories).toContain('generated'); + expect(checkedDirectories).not.toContain(path.join('generated', 'nested')); + }); +}); + test.runIf(process.platform !== 'win32')('does not follow file or directory symlinks', async () => { await withTempProject(async (rootPath) => { const targetPath = writeProjectFile(rootPath, 'target/index.ts'); diff --git a/packages/rstack/tests/fmt/discovery.test.ts b/packages/rstack/tests/fmt/discovery.test.ts index bc98e69..9ee6443 100644 --- a/packages/rstack/tests/fmt/discovery.test.ts +++ b/packages/rstack/tests/fmt/discovery.test.ts @@ -47,6 +47,27 @@ test('applies config ignore patterns outside the config root', async () => { }); }); +test('keeps files re-included by a CLI ignore file during directory traversal', async () => { + await withTempProject(async (rootPath) => { + writeProjectFile(rootPath, '.prettierignore', 'generated/*\n!generated/keep.ts\n'); + writeProjectFile(rootPath, 'generated/drop.ts'); + writeProjectFile(rootPath, 'generated/keep.ts'); + writeProjectFile(rootPath, 'src/index.ts'); + + const files = await discoverFmtFiles({ + cwd: rootPath, + patterns: ['**/*.ts'], + ignorePaths: ['.prettierignore'], + config: normalizeFmtConfig(undefined, rootPath), + }); + + expect(relativePaths(rootPath, files)).toEqual([ + path.join('generated', 'keep.ts'), + path.join('src', 'index.ts'), + ]); + }); +}); + test('defers parser inference to workers and preserves an explicit parser', async () => { await withTempProject(async (rootPath) => { writeProjectFile(rootPath, 'index.js'); diff --git a/packages/rstack/tests/fmt/ignore.test.ts b/packages/rstack/tests/fmt/ignore.test.ts index c88de37..192ff6a 100644 --- a/packages/rstack/tests/fmt/ignore.test.ts +++ b/packages/rstack/tests/fmt/ignore.test.ts @@ -29,6 +29,15 @@ test('matches gitignore patterns relative to the config root', async () => { expect(isIgnored(path.join(rootPath, 'src/index.js'))).toBe(false); }); +test('distinguishes directory-only patterns from files', async () => { + const isIgnored = await createMatcher(['dist/']); + const directoryPath = path.join(rootPath, 'dist'); + + expect(isIgnored(directoryPath)).toBe(false); + expect(isIgnored(directoryPath, true)).toBe(true); + expect(isIgnored(path.join(directoryPath, 'index.js'))).toBe(true); +}); + test('applies negated patterns in declaration order', async () => { const isIgnored = await createMatcher(['*.js', '!src/keep.js']); const isIgnoredAgain = await createMatcher(['*.js', '!src/keep.js', 'src/keep.js']);