Skip to content
Closed
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
149 changes: 114 additions & 35 deletions src/core/file/fileSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +192 to +210
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Normalize patterns loaded from ignore files

Lines like build/ or tmp/ are extremely common in .gitignore, but when we feed them straight into minimatch they no longer behave like git (they won't exclude build/index.js). We already normalize config-derived patterns; we need to apply the same normalizeGlobPattern to patterns pulled from .gitignore / .repomixignore before pushing them into allIgnorePatterns, otherwise stdin-mode files slip through those ignores.

-          const gitignorePatterns = parseIgnoreContent(gitignoreContent);
+          const gitignorePatterns = parseIgnoreContent(gitignoreContent).map(normalizeGlobPattern);
           allIgnorePatterns.push(...gitignorePatterns);
...
-          const repomixignorePatterns = parseIgnoreContent(repomixignoreContent);
+          const repomixignorePatterns = parseIgnoreContent(repomixignoreContent).map(normalizeGlobPattern);
           allIgnorePatterns.push(...repomixignorePatterns);
🤖 Prompt for AI Agents
In src/core/file/fileSearch.ts around lines 192 to 210, the patterns read from
.gitignore and .repomixignore must be normalized before being added to
allIgnorePatterns; map the arrays returned by parseIgnoreContent through the
existing normalizeGlobPattern function (ensuring normalizeGlobPattern is
imported/available) and push the normalized results into allIgnorePatterns, and
update the logger.trace calls to log the normalized patterns so ignores behave
like git (e.g., "build/" will also match build/index.js).

} 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),
Comment on lines +188 to +215
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The logic for reading and parsing .gitignore and .repomixignore is duplicated. To improve maintainability and reduce code repetition, you can extract this logic into a helper function.

      if (config.ignore.useGitignore) {
        const readIgnoreFile = async (fileName: string) => {
          try {
            const content = await fs.readFile(path.join(rootDir, fileName), 'utf8');
            const patterns = parseIgnoreContent(content);
            allIgnorePatterns.push(...patterns);
            logger.trace(`Loaded ${fileName} patterns:`, patterns);
          } catch (error) {
            // Ignore file might not exist, which is fine
            logger.trace(
              `No ${fileName} found or could not read:`,
              error instanceof Error ? error.message : String(error),
            );
          }
        };

        await readIgnoreFile('.gitignore');
        await readIgnoreFile('.repomixignore');
      }

);
}
}
Comment on lines +188 to +218
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Always load .repomixignore in stdin mode

In stdin mode we now skip .repomixignore whenever useGitignore is false, but normal mode still respects it because getIgnoreFilePatterns always includes .repomixignore. This regresses documented behaviour: disabling gitignore handling should not disable the Repomix-specific ignore file. Please load .repomixignore independently of the gitignore flag so explicit files keep honoring those rules.

-      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),
-          );
-        }
-
-        // 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),
-          );
-        }
-      }
+      const loadIgnoreFile = async (fileName: string, enabled: boolean) => {
+        if (!enabled) return;
+        const filePath = path.join(rootDir, fileName);
+        try {
+          const content = await fs.readFile(filePath, 'utf8');
+          const patterns = parseIgnoreContent(content).map(normalizeGlobPattern);
+          allIgnorePatterns.push(...patterns);
+          logger.trace(`Loaded ${fileName} patterns:`, patterns);
+        } catch (error) {
+          logger.trace(
+            `No ${fileName} found or could not read:`,
+            error instanceof Error ? error.message : String(error),
+          );
+        }
+      };
+
+      await loadIgnoreFile('.gitignore', config.ignore.useGitignore);
+      await loadIgnoreFile('.repomixignore', true);

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/core/file/fileSearch.ts around lines 188 to 218, the code currently only
reads .repomixignore when config.ignore.useGitignore is true, which causes
.repomixignore to be skipped in stdin mode when gitignore handling is disabled;
move or duplicate the .repomixignore read/parse/log block so it runs
unconditionally (outside or after the if (config.ignore.useGitignore) block) and
keep the same try/catch behavior and logger.trace messages to handle a missing
file gracefully.


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 : ['**/*'];
Comment on lines +220 to +268
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Normalize explicit paths to POSIX separators before merging

On Windows, path.relative returns backslash-separated paths, so filteredExplicitFiles ends up with values like src\file1.ts while globbyResults uses src/file1.ts. This breaks deduplication and causes ignore checks to misbehave, which is exactly what the Windows CI failures are showing (mixed separators and duplicate entries). Convert both the globby output and the explicit-path relatives to POSIX separators before combining them so we return a consistent, de-duped list everywhere.

-      const globbyResults =
-        globbyPatterns.length > 0
-          ? await globby(globbyPatterns, {
+      const toPosix = (value: string) => value.replace(/\\/g, '/');
+      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;
                 })
-          : [];
+            ).map(toPosix)
+          : [];
...
-      const relativePaths = explicitFiles.map((filePath) => path.relative(rootDir, filePath));
+      const relativePaths = explicitFiles.map((filePath) => toPosix(path.relative(rootDir, filePath)));
🤖 Prompt for AI Agents
In src/core/file/fileSearch.ts around lines 220 to 268, explicit file paths
produced by path.relative are using Windows backslashes which mismatch globby's
POSIX-style results and break deduplication and ignore checks; normalize both
the globbyResults and the relativePaths to POSIX separators before running
minimatch, filtering, and merging. Update the code to map globbyResults and
explicitFiles -> relativePaths through a function that replaces backslashes with
'/' (or use path.posix) so both sets use forward-slash separators, then run the
ignore filtering and dedupe on those normalized values and continue with the
rest of the logic.


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,
);
Comment on lines +269 to +286
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The globby call and its associated error handling logic are duplicated here and in the if (explicitFiles) block (lines 219-239). This repetition can be avoided by creating a reusable helper function for running globby with the common options and error handling. This would make the code cleaner and easier to maintain.

}
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],
Expand Down
89 changes: 83 additions & 6 deletions tests/core/file/fileSearch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -582,12 +582,23 @@
'/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(

Check failure on line 598 in tests/core/file/fileSearch.test.ts

View workflow job for this annotation

GitHub Actions / Test with Bun (windows-latest, latest)

tests/core/file/fileSearch.test.ts > fileSearch > searchFiles path validation > should filter explicit files based on include and ignore patterns

AssertionError: expected [ Array(4) ] to deeply equal ArrayContaining{…} - Expected + Received - ArrayContaining [ - "src/file1.ts", - "src/file2.js", - "src/file3.ts", + [ + "src\\file1.ts", + "src\\file2.js", + "src\\file3.ts", "src/other.ts", ] ❯ tests/core/file/fileSearch.test.ts:598:32
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([]);
});

Expand All @@ -603,13 +614,79 @@

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']);

Check failure on line 625 in tests/core/file/fileSearch.test.ts

View workflow job for this annotation

GitHub Actions / Test with Bun (windows-latest, latest)

tests/core/file/fileSearch.test.ts > fileSearch > searchFiles path validation > should handle explicit files with ignore patterns only

AssertionError: expected [ 'lib\utils.ts', 'src\main.ts' ] to deeply equal [ 'lib/utils.ts', 'src/main.ts' ] - Expected + Received [ - "lib/utils.ts", - "src/main.ts", + "lib\\utils.ts", + "src\\main.ts", ] ❯ tests/core/file/fileSearch.test.ts:625:32
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

Check failure on line 667 in tests/core/file/fileSearch.test.ts

View workflow job for this annotation

GitHub Actions / Test with Bun (windows-latest, latest)

tests/core/file/fileSearch.test.ts > fileSearch > searchFiles path validation > should merge globby results and explicit files in stdin mode

AssertionError: expected [ 'src\duplicate.ts', …(3) ] to have a length of 3 but got 4 - Expected + Received - 3 + 4 ❯ tests/core/file/fileSearch.test.ts:667:32
});

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([]);
});
});
});
Loading