diff --git a/src/core/file/fileSearch.ts b/src/core/file/fileSearch.ts index e1391f367..65b4af30c 100644 --- a/src/core/file/fileSearch.ts +++ b/src/core/file/fileSearch.ts @@ -173,48 +173,127 @@ export const searchFiles = async ( } // Start with configured include patterns - let includePatterns = config.include.map((pattern) => escapeGlobPattern(pattern)); + const includePatterns = config.include.map((pattern) => escapeGlobPattern(pattern)); + + // In stdin mode (explicitFiles provided), bypass globby for explicit files to avoid hang + // when .gitignore is both an ignore rules source and a match target + let filePaths: string[] = []; - // If explicit files are provided, add them to include patterns if (explicitFiles) { - const relativePaths = explicitFiles.map((filePath) => { - const relativePath = path.relative(rootDir, filePath); - // Escape the path to handle special characters - return escapeGlobPattern(relativePath); - }); - includePatterns = [...includePatterns, ...relativePaths]; - } + logger.debug('Stdin mode: processing explicit files separately from globby'); + + // In stdin mode, we need to manually read .gitignore files since we're not using globby's ignoreFiles + const allIgnorePatterns = [...adjustedIgnorePatterns]; + + if (config.ignore.useGitignore) { + // Read .gitignore from root directory + const gitignorePath = path.join(rootDir, '.gitignore'); + try { + const gitignoreContent = await fs.readFile(gitignorePath, 'utf8'); + const gitignorePatterns = parseIgnoreContent(gitignoreContent); + allIgnorePatterns.push(...gitignorePatterns); + logger.trace('Loaded .gitignore patterns:', gitignorePatterns); + } catch (error) { + // .gitignore might not exist, which is fine + logger.trace( + 'No .gitignore found or could not read:', + error instanceof Error ? error.message : String(error), + ); + } - // If no include patterns at all, default to all files - if (includePatterns.length === 0) { - includePatterns = ['**/*']; - } + // Read .repomixignore from root directory + const repomixignorePath = path.join(rootDir, '.repomixignore'); + try { + const repomixignoreContent = await fs.readFile(repomixignorePath, 'utf8'); + const repomixignorePatterns = parseIgnoreContent(repomixignoreContent); + allIgnorePatterns.push(...repomixignorePatterns); + logger.trace('Loaded .repomixignore patterns:', repomixignorePatterns); + } catch (error) { + // .repomixignore might not exist, which is fine + logger.trace( + 'No .repomixignore found or could not read:', + error instanceof Error ? error.message : String(error), + ); + } + } - logger.trace('Include patterns with explicit files:', includePatterns); - - const filePaths = await globby(includePatterns, { - cwd: rootDir, - ignore: [...adjustedIgnorePatterns], - ignoreFiles: [...ignoreFilePatterns], - onlyFiles: true, - absolute: false, - dot: true, - followSymbolicLinks: false, - }).catch((error: unknown) => { - // Handle EPERM errors specifically - const code = (error as NodeJS.ErrnoException | { code?: string })?.code; - if (code === 'EPERM' || code === 'EACCES') { - throw new PermissionError( - `Permission denied while scanning directory. Please check folder access permissions for your terminal app. path: ${rootDir}`, - rootDir, + // 1. Run globby only for includePatterns (if any) + const globbyPatterns = includePatterns.length > 0 ? includePatterns : []; + const globbyResults = + globbyPatterns.length > 0 + ? await globby(globbyPatterns, { + cwd: rootDir, + ignore: [...adjustedIgnorePatterns], + ignoreFiles: [...ignoreFilePatterns], + onlyFiles: true, + absolute: false, + dot: true, + followSymbolicLinks: false, + }).catch((error: unknown) => { + const code = (error as NodeJS.ErrnoException | { code?: string })?.code; + if (code === 'EPERM' || code === 'EACCES') { + throw new PermissionError( + `Permission denied while scanning directory. Please check folder access permissions for your terminal app. path: ${rootDir}`, + rootDir, + ); + } + throw error; + }) + : []; + + logger.trace('Globby results for includePatterns:', globbyResults); + + // 2. Convert explicit files to relative paths + const relativePaths = explicitFiles.map((filePath) => path.relative(rootDir, filePath)); + + logger.trace('Explicit files (relative):', relativePaths); + + // 3. Filter explicit files using ignore patterns (manually with minimatch) + const filteredExplicitFiles = relativePaths.filter((filePath) => { + // Check if file matches any ignore pattern + const shouldIgnore = allIgnorePatterns.some( + (pattern) => minimatch(filePath, pattern) || minimatch(`${filePath}/`, pattern), ); - } - throw error; - }); + return !shouldIgnore; + }); + + logger.trace('Filtered explicit files:', filteredExplicitFiles); + + // 4. Merge globby results and filtered explicit files, removing duplicates + filePaths = [...new Set([...globbyResults, ...filteredExplicitFiles])]; + + logger.trace('Merged file paths:', filePaths); + } else { + // Normal mode: use globby with all patterns + const patterns = includePatterns.length > 0 ? includePatterns : ['**/*']; + + logger.trace('Include patterns:', patterns); + + filePaths = await globby(patterns, { + cwd: rootDir, + ignore: [...adjustedIgnorePatterns], + ignoreFiles: [...ignoreFilePatterns], + onlyFiles: true, + absolute: false, + dot: true, + followSymbolicLinks: false, + }).catch((error: unknown) => { + const code = (error as NodeJS.ErrnoException | { code?: string })?.code; + if (code === 'EPERM' || code === 'EACCES') { + throw new PermissionError( + `Permission denied while scanning directory. Please check folder access permissions for your terminal app. path: ${rootDir}`, + rootDir, + ); + } + throw error; + }); + } let emptyDirPaths: string[] = []; - if (config.output.includeEmptyDirectories) { - const directories = await globby(includePatterns, { + if (config.output.includeEmptyDirectories && !explicitFiles) { + // Note: empty directory detection is only supported in normal mode, not in stdin mode + const patterns = includePatterns.length > 0 ? includePatterns : ['**/*']; + const directories = await globby(patterns, { cwd: rootDir, ignore: [...adjustedIgnorePatterns], ignoreFiles: [...ignoreFilePatterns], diff --git a/tests/core/file/fileSearch.test.ts b/tests/core/file/fileSearch.test.ts index 91918b96a..ff7471eee 100644 --- a/tests/core/file/fileSearch.test.ts +++ b/tests/core/file/fileSearch.test.ts @@ -582,12 +582,23 @@ node_modules '/test/src/file3.ts', ]; - // Mock globby to return the expected filtered files - vi.mocked(globby).mockResolvedValue(['src/file1.ts', 'src/file3.ts']); + // In new logic: globby is called with include patterns, and explicit files are filtered manually + // Globby returns files matching **/*.ts from the repo + vi.mocked(globby).mockResolvedValue(['src/other.ts']); const result = await searchFiles('/test', mockConfig, explicitFiles); - expect(result.filePaths).toEqual(['src/file1.ts', 'src/file3.ts']); + // Result should include: + // - src/other.ts (from globby) + // - src/file1.ts (from explicit files, passes ignore check) + // - src/file3.ts (from explicit files, passes ignore check) + // Excluded: + // - src/file1.test.ts (matched by **/*.test.ts ignore pattern) + // - src/file2.js (doesn't match *.ts pattern, but in stdin mode explicit files don't need to match include patterns) + expect(result.filePaths).toEqual( + expect.arrayContaining(['src/file1.ts', 'src/file2.js', 'src/file3.ts', 'src/other.ts']), + ); + expect(result.filePaths).not.toContain('src/file1.test.ts'); expect(result.emptyDirPaths).toEqual([]); }); @@ -603,13 +614,79 @@ node_modules const explicitFiles = ['/test/src/main.ts', '/test/tests/unit.test.ts', '/test/lib/utils.ts']; - // Mock globby to return the expected filtered files - vi.mocked(globby).mockResolvedValue(['src/main.ts', 'lib/utils.ts']); - + // In new logic: no include patterns, so globby is not called + // Explicit files are filtered manually const result = await searchFiles('/test', mockConfig, explicitFiles); + // Globby should not be called when includePatterns is empty + expect(globby).not.toHaveBeenCalled(); + + // Result should include files not matching ignore pattern expect(result.filePaths).toEqual(['lib/utils.ts', 'src/main.ts']); expect(result.emptyDirPaths).toEqual([]); }); + + test('should apply .gitignore rules to explicit files in stdin mode', async () => { + const mockConfig = createMockConfig({ + include: [], + ignore: { + useGitignore: true, + useDefaultPatterns: false, + customPatterns: ['ignored.txt'], + }, + }); + + const explicitFiles = ['/test/.gitignore', '/test/file1.ts', '/test/ignored.txt', '/test/file2.ts']; + + const result = await searchFiles('/test', mockConfig, explicitFiles); + + // .gitignore rules should be applied via minimatch + expect(result.filePaths).toEqual(expect.arrayContaining(['.gitignore', 'file1.ts', 'file2.ts'])); + expect(result.filePaths).not.toContain('ignored.txt'); + }); + + test('should merge globby results and explicit files in stdin mode', async () => { + const mockConfig = createMockConfig({ + include: ['src/**/*.ts'], + ignore: { + useGitignore: false, + useDefaultPatterns: false, + customPatterns: [], + }, + }); + + const explicitFiles = ['/test/README.md', '/test/src/duplicate.ts']; + + // Globby returns files matching src/**/*.ts + vi.mocked(globby).mockResolvedValue(['src/duplicate.ts', 'src/other.ts']); + + const result = await searchFiles('/test', mockConfig, explicitFiles); + + // Should include both globby results and explicit files, with duplicates removed + expect(result.filePaths).toEqual(expect.arrayContaining(['README.md', 'src/duplicate.ts', 'src/other.ts'])); + expect(result.filePaths).toHaveLength(3); // No duplicates + }); + + test('should not process empty directories in stdin mode', async () => { + const mockConfig = createMockConfig({ + include: [], + ignore: { + useGitignore: false, + useDefaultPatterns: false, + customPatterns: [], + }, + output: { + includeEmptyDirectories: true, + }, + }); + + const explicitFiles = ['/test/file1.ts', '/test/file2.ts']; + + const result = await searchFiles('/test', mockConfig, explicitFiles); + + // Empty directories should not be processed in stdin mode + expect(result.filePaths).toEqual(['file1.ts', 'file2.ts']); + expect(result.emptyDirPaths).toEqual([]); + }); }); });