Files
repomix-mirror/tests/core/file/fileSearch.test.ts
2026-05-09 01:07:48 +00:00

1092 lines
39 KiB
TypeScript

import type { Stats } from 'node:fs';
import * as fs from 'node:fs/promises';
import path from 'node:path';
import process from 'node:process';
import { globby } from 'globby';
import { minimatch } from 'minimatch';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import {
escapeGlobPattern,
getIgnoreFilePatterns,
getIgnorePatterns,
listDirectories,
listFiles,
normalizeGlobPattern,
parseIgnoreContent,
searchFiles,
} from '../../../src/core/file/fileSearch.js';
import { checkDirectoryPermissions, PermissionError } from '../../../src/core/file/permissionCheck.js';
import { RepomixError } from '../../../src/shared/errorHandle.js';
import { createMockConfig, isWindows } from '../../testing/testUtils.js';
vi.mock('fs/promises');
vi.mock('globby', () => ({
globby: vi.fn(),
}));
vi.mock('../../../src/core/file/permissionCheck.js', () => ({
checkDirectoryPermissions: vi.fn(),
PermissionError: class extends Error {
constructor(
message: string,
public readonly path: string,
public readonly code?: string,
) {
super(message);
this.name = 'PermissionError';
}
},
}));
describe('fileSearch', () => {
beforeEach(() => {
vi.resetAllMocks();
// Default mock for fs.stat to assume directory exists and is a directory
vi.mocked(fs.stat).mockResolvedValue({
isDirectory: () => true,
isFile: () => false,
} as Stats);
// Default mock for checkDirectoryPermissions
vi.mocked(checkDirectoryPermissions).mockResolvedValue({
hasAllPermission: true,
details: { read: true, write: true, execute: true },
});
// Default mock for globby
vi.mocked(globby).mockResolvedValue([]);
// Default mock for fs.readdir (used by collectIgnoreFilePatterns prescan)
vi.mocked(
fs.readdir as (path: string, opts: object) => Promise<import('node:fs').Dirent<string>[]>,
).mockResolvedValue([]);
});
describe('getIgnoreFilePaths', () => {
test('should return correct paths when .ignore and .repomixignore are enabled (.gitignore handled by gitignore option)', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
const mockConfig = createMockConfig({
ignore: {
useGitignore: true,
useDotIgnore: true,
useDefaultPatterns: true,
customPatterns: [],
},
});
const filePatterns = await getIgnoreFilePatterns(mockConfig);
// .gitignore is not included because it's handled by globby's gitignore option
expect(filePatterns).toEqual(['**/.ignore', '**/.repomixignore']);
});
test('should not include .gitignore when useGitignore is false', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
const mockConfig = createMockConfig({
ignore: {
useGitignore: false,
useDotIgnore: true,
useDefaultPatterns: true,
customPatterns: [],
},
});
const filePatterns = await getIgnoreFilePatterns(mockConfig);
expect(filePatterns).toEqual(['**/.ignore', '**/.repomixignore']);
});
test('should not include .ignore when useDotIgnore is false', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
const mockConfig = createMockConfig({
ignore: {
useGitignore: true,
useDotIgnore: false,
useDefaultPatterns: true,
customPatterns: [],
},
});
const filePatterns = await getIgnoreFilePatterns(mockConfig);
// .gitignore is not included because it's handled by globby's gitignore option
expect(filePatterns).toEqual(['**/.repomixignore']);
});
test('should handle empty directories when enabled', async () => {
const mockConfig = createMockConfig({
output: {
includeEmptyDirectories: true,
},
});
const mockFilePaths = ['src/file1.js', 'src/file2.js'];
const mockEmptyDirs = ['src/empty', 'empty-root'];
vi.mocked(globby).mockImplementation(async (_: unknown, options: unknown) => {
const opts = options as Record<string, unknown>;
// New single-call path: files and directories are returned together in objectMode
if (opts?.objectMode) {
const fileEntries = mockFilePaths.map((p) => ({
path: p,
name: p.split('/').pop() ?? p,
dirent: { isFile: () => true, isDirectory: () => false } as unknown,
}));
const dirEntries = mockEmptyDirs.map((p) => ({
path: p,
name: p.split('/').pop() ?? p,
dirent: { isFile: () => false, isDirectory: () => true } as unknown,
}));
return [...fileEntries, ...dirEntries] as never;
}
return mockFilePaths;
});
vi.mocked(fs.readdir).mockResolvedValue([]);
const result = await searchFiles('/mock/root', mockConfig);
expect(result.filePaths).toEqual(mockFilePaths);
expect(result.emptyDirPaths.sort()).toEqual(mockEmptyDirs.sort());
// One globby call (objectMode) returns files+directories together.
expect(globby).toHaveBeenCalledTimes(1);
const callOptions = vi.mocked(globby).mock.calls[0][1] as Record<string, unknown>;
expect(callOptions.objectMode).toBe(true);
expect(callOptions.onlyFiles).toBe(false);
});
test('should not collect empty directories when disabled', async () => {
const mockConfig = createMockConfig({
output: {
includeEmptyDirectories: false,
},
});
const mockFilePaths = ['src/file1.js', 'src/file2.js'];
vi.mocked(globby).mockImplementation(async (_: unknown, options: unknown) => {
if ((options as Record<string, unknown>)?.onlyDirectories) {
throw new Error('Should not search for directories when disabled');
}
return mockFilePaths;
});
const result = await searchFiles('/mock/root', mockConfig);
expect(result.filePaths).toEqual(mockFilePaths);
expect(result.emptyDirPaths).toEqual([]);
expect(globby).toHaveBeenCalledTimes(1);
});
});
describe('getIgnorePatterns', () => {
test('should return default patterns when useDefaultPatterns is true', async () => {
const mockConfig = createMockConfig({
ignore: {
useGitignore: true,
useDefaultPatterns: true,
customPatterns: [],
},
});
const patterns = await getIgnorePatterns(process.cwd(), mockConfig);
expect(patterns.length).toBeGreaterThan(0);
expect(patterns).toContain('**/node_modules/**');
});
test('should include custom patterns', async () => {
const mockConfig = createMockConfig({
ignore: {
useGitignore: true,
useDefaultPatterns: false,
customPatterns: ['*.custom', 'temp/'],
},
});
const patterns = await getIgnorePatterns(process.cwd(), mockConfig);
expect(patterns).toEqual(['repomix-output.xml', '*.custom', 'temp/']);
});
test('should combine default and custom patterns', async () => {
const mockConfig = createMockConfig({
ignore: {
useGitignore: true,
useDefaultPatterns: true,
customPatterns: ['*.custom', 'temp/'],
},
});
const patterns = await getIgnorePatterns(process.cwd(), mockConfig);
expect(patterns).toContain('**/node_modules/**');
expect(patterns).toContain('*.custom');
expect(patterns).toContain('temp/');
});
test('should include patterns from .git/info/exclude when useGitignore is true', async () => {
const mockConfig = createMockConfig({
ignore: {
useGitignore: true,
useDefaultPatterns: false,
customPatterns: [],
},
});
const mockExcludeContent = `
# Test exclude file
*.ignored
temp-files/
`;
vi.mocked(fs.readFile).mockImplementation(async (filePath) => {
// Use path.join to create platform-specific path for testing
const excludePath = path.join('.git', 'info', 'exclude');
if (filePath.toString().endsWith(excludePath)) {
return mockExcludeContent;
}
return '';
});
const patterns = await getIgnorePatterns('/mock/root', mockConfig);
// Only test for the exclude file patterns
expect(patterns).toContain('*.ignored');
expect(patterns).toContain('temp-files/');
});
});
describe('parseIgnoreContent', () => {
test('should correctly parse ignore content', () => {
const content = `
# Comment
node_modules
*.log
.DS_Store
`;
const patterns = parseIgnoreContent(content);
expect(patterns).toEqual(['node_modules', '*.log', '.DS_Store']);
});
test('should handle mixed line endings', () => {
const content = 'node_modules\n*.log\r\n.DS_Store\r';
const patterns = parseIgnoreContent(content);
expect(patterns).toEqual(['node_modules', '*.log', '.DS_Store']);
});
});
describe('filterFiles', () => {
beforeEach(() => {
vi.resetAllMocks();
// Re-establish default mocks after reset
vi.mocked(fs.stat).mockResolvedValue({
isDirectory: () => true,
isFile: () => false,
} as Stats);
vi.mocked(checkDirectoryPermissions).mockResolvedValue({
hasAllPermission: true,
details: { read: true, write: true, execute: true },
});
// Default mock for fs.readdir (used by collectIgnoreFilePatterns prescan)
vi.mocked(
fs.readdir as (path: string, opts: object) => Promise<import('node:fs').Dirent<string>[]>,
).mockResolvedValue([]);
});
test('should call globby with correct parameters', async () => {
const mockConfig = createMockConfig({
include: ['**/*.js'],
ignore: {
useGitignore: true,
useDefaultPatterns: false,
customPatterns: ['*.custom'],
},
});
vi.mocked(globby).mockResolvedValue(['file1.js', 'file2.js']);
vi.mocked(fs.access).mockResolvedValue(undefined);
await searchFiles('/mock/root', mockConfig);
expect(globby).toHaveBeenCalledWith(
['**/*.js'],
expect.objectContaining({
cwd: '/mock/root',
ignore: expect.arrayContaining(['*.custom']),
gitignore: false,
ignoreFiles: [],
onlyFiles: true,
absolute: false,
dot: true,
followSymbolicLinks: false,
}),
);
});
test.runIf(!isWindows)('Honor .gitignore files in subdirectories', async () => {
const mockConfig = createMockConfig({
include: ['**/*.js'],
ignore: {
useGitignore: true,
useDefaultPatterns: false,
customPatterns: [],
},
});
const mockFileStructure = [
'root/file1.js',
'root/subdir/file2.js',
'root/subdir/ignored.js',
'root/another/file3.js',
];
const mockGitignoreContent = {
'/mock/root/.gitignore': '*.log',
'/mock/root/subdir/.gitignore': 'ignored.js',
};
vi.mocked(globby).mockImplementation(async () => {
// Simulate filtering files based on .gitignore
return mockFileStructure.filter((file) => {
const relativePath = file.replace('root/', '');
const dir = path.dirname(relativePath);
const gitignorePath = path.join('/mock/root', dir, '.gitignore');
const gitignoreContent = mockGitignoreContent[gitignorePath as keyof typeof mockGitignoreContent];
if (gitignoreContent && minimatch(path.basename(file), gitignoreContent)) {
return false;
}
return true;
});
});
vi.mocked(fs.readFile).mockImplementation(async (filePath) => {
return mockGitignoreContent[filePath as keyof typeof mockGitignoreContent] || '';
});
const result = await searchFiles('/mock/root', mockConfig);
expect(result.filePaths).toEqual(['root/another/file3.js', 'root/subdir/file2.js', 'root/file1.js']);
expect(result.filePaths).not.toContain('root/subdir/ignored.js');
expect(result.emptyDirPaths).toEqual([]);
});
test.runIf(!isWindows)('should respect parent directory .gitignore patterns (v16 behavior)', async () => {
// This test verifies globby v16's key improvement: respecting parent directory .gitignore files.
// In v15, only .gitignore files in the cwd and below were checked.
// In v16, .gitignore files in parent directories (up to the git root) are also respected,
// matching Git's standard behavior. This makes Repomix's file filtering align with Git's expectations.
const mockConfig = createMockConfig({
include: ['**/*.js'],
ignore: {
useGitignore: true,
useDefaultPatterns: false,
customPatterns: [],
},
});
// Simulate parent .gitignore pattern applying to subdirectory files
const mockFileStructure = [
'root/file1.js',
'root/subdir/file2.js',
'root/subdir/nested/file3.js',
// 'root/subdir/nested/ignored-by-parent.js' - filtered by parent .gitignore
];
const mockGitignoreContent = {
'/mock/root/.gitignore': 'ignored-by-parent.js',
};
vi.mocked(globby).mockImplementation(async () => {
// Simulate globby v16 behavior: parent .gitignore patterns apply to all subdirectories
return mockFileStructure.filter((file) => {
const basename = path.basename(file);
const parentGitignore = mockGitignoreContent['/mock/root/.gitignore'];
if (minimatch(basename, parentGitignore)) {
return false;
}
return true;
});
});
vi.mocked(fs.readFile).mockImplementation(async (filePath) => {
return mockGitignoreContent[filePath as keyof typeof mockGitignoreContent] || '';
});
const result = await searchFiles('/mock/root', mockConfig);
// Verify parent .gitignore pattern filtered out the file
expect(result.filePaths).toHaveLength(3);
expect(result.filePaths).toContain('root/file1.js');
expect(result.filePaths).toContain('root/subdir/file2.js');
expect(result.filePaths).toContain('root/subdir/nested/file3.js');
expect(result.filePaths).not.toContain('root/subdir/nested/ignored-by-parent.js');
expect(result.emptyDirPaths).toEqual([]);
// Verify gitignore is disabled (patterns pre-collected by prescan into ignore array)
expect(globby).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
gitignore: false,
}),
);
});
test.runIf(!isWindows)('should respect parent directory .ignore patterns', async () => {
// This test verifies that .ignore files in parent directories are respected,
// similar to .gitignore behavior in v16.
const mockConfig = createMockConfig({
include: ['**/*.js'],
ignore: {
useGitignore: false,
useDotIgnore: true,
useDefaultPatterns: false,
customPatterns: [],
},
});
// Simulate parent .ignore pattern applying to subdirectory files
const mockFileStructure = [
'root/file1.js',
'root/subdir/file2.js',
'root/subdir/nested/file3.js',
// 'root/subdir/nested/ignored-by-parent.js' - filtered by parent .ignore
];
const mockIgnoreContent = {
'/mock/root/.ignore': 'ignored-by-parent.js',
};
vi.mocked(globby).mockImplementation(async () => {
// Simulate parent .ignore patterns applying to all subdirectories
return mockFileStructure.filter((file) => {
const basename = path.basename(file);
const parentIgnore = mockIgnoreContent['/mock/root/.ignore'];
if (minimatch(basename, parentIgnore)) {
return false;
}
return true;
});
});
vi.mocked(fs.readFile).mockImplementation(async (filePath) => {
return mockIgnoreContent[filePath as keyof typeof mockIgnoreContent] || '';
});
const result = await searchFiles('/mock/root', mockConfig);
// Verify parent .ignore pattern filtered out the file
expect(result.filePaths).toHaveLength(3);
expect(result.filePaths).toContain('root/file1.js');
expect(result.filePaths).toContain('root/subdir/file2.js');
expect(result.filePaths).toContain('root/subdir/nested/file3.js');
expect(result.filePaths).not.toContain('root/subdir/nested/ignored-by-parent.js');
expect(result.emptyDirPaths).toEqual([]);
// Verify ignoreFiles is disabled (patterns pre-collected by prescan into ignore array)
expect(globby).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
ignoreFiles: [],
}),
);
});
test.runIf(!isWindows)('should respect parent directory .repomixignore patterns', async () => {
// This test verifies that .repomixignore files in parent directories are respected.
// .repomixignore is always enabled by default.
const mockConfig = createMockConfig({
include: ['**/*.js'],
ignore: {
useGitignore: false,
useDotIgnore: false,
useDefaultPatterns: false,
customPatterns: [],
},
});
// Simulate parent .repomixignore pattern applying to subdirectory files
const mockFileStructure = [
'root/file1.js',
'root/subdir/file2.js',
'root/subdir/nested/file3.js',
// 'root/subdir/nested/ignored-by-repomix.js' - filtered by parent .repomixignore
];
const mockIgnoreContent = {
'/mock/root/.repomixignore': 'ignored-by-repomix.js',
};
vi.mocked(globby).mockImplementation(async () => {
// Simulate parent .repomixignore patterns applying to all subdirectories
return mockFileStructure.filter((file) => {
const basename = path.basename(file);
const parentIgnore = mockIgnoreContent['/mock/root/.repomixignore'];
if (minimatch(basename, parentIgnore)) {
return false;
}
return true;
});
});
vi.mocked(fs.readFile).mockImplementation(async (filePath) => {
return mockIgnoreContent[filePath as keyof typeof mockIgnoreContent] || '';
});
const result = await searchFiles('/mock/root', mockConfig);
// Verify parent .repomixignore pattern filtered out the file
expect(result.filePaths).toHaveLength(3);
expect(result.filePaths).toContain('root/file1.js');
expect(result.filePaths).toContain('root/subdir/file2.js');
expect(result.filePaths).toContain('root/subdir/nested/file3.js');
expect(result.filePaths).not.toContain('root/subdir/nested/ignored-by-repomix.js');
expect(result.emptyDirPaths).toEqual([]);
// Verify ignoreFiles is disabled (patterns pre-collected by prescan into ignore array)
expect(globby).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
ignoreFiles: [],
}),
);
});
test('should not apply .gitignore when useGitignore is false', async () => {
const mockConfig = createMockConfig({
include: ['**/*.js'],
ignore: {
useGitignore: false,
useDefaultPatterns: false,
customPatterns: [],
},
});
const sep = path.sep;
const mockFileStructure = [
`root${sep}file1.js`,
`root${sep}another${sep}file3.js`,
`root${sep}subdir${sep}file2.js`,
`root${sep}subdir${sep}ignored.js`,
];
vi.mocked(globby).mockResolvedValue(mockFileStructure);
const result = await searchFiles('/mock/root', mockConfig);
expect(result.filePaths).toEqual([
`root${sep}another${sep}file3.js`,
`root${sep}subdir${sep}file2.js`,
`root${sep}subdir${sep}ignored.js`,
`root${sep}file1.js`,
]);
expect(result.filePaths).toContain(`root${sep}subdir${sep}ignored.js`);
expect(result.emptyDirPaths).toEqual([]);
});
test('should handle git worktree correctly', async () => {
// Mock .git file content for worktree
const gitWorktreeContent = 'gitdir: /path/to/main/repo/.git/worktrees/feature-branch';
// Mock fs.stat - first call for rootDir, subsequent calls for .git file
vi.mocked(fs.stat)
.mockResolvedValueOnce({
isDirectory: () => true,
isFile: () => false,
} as Stats)
.mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
} as Stats);
vi.mocked(fs.readFile).mockResolvedValue(gitWorktreeContent);
// Override checkDirectoryPermissions mock for this test
vi.mocked(checkDirectoryPermissions).mockResolvedValue({
hasAllPermission: true,
details: { read: true, write: true, execute: true },
});
// Mock globby to return some test files
vi.mocked(globby).mockResolvedValue(['file1.js', 'file2.js']);
const mockConfig = createMockConfig({
ignore: {
useGitignore: true,
useDefaultPatterns: true,
customPatterns: [],
},
});
const result = await searchFiles('/test/dir', mockConfig);
// Check that globby was called with correct ignore patterns
const executeGlobbyCall = vi.mocked(globby).mock.calls[0];
const ignorePatterns = executeGlobbyCall[1]?.ignore as string[];
// Verify .git file (not directory) is in ignore patterns
expect(ignorePatterns).toContain('.git');
// Verify .git/** is not in ignore patterns
expect(ignorePatterns).not.toContain('.git/**');
// Verify the files were returned correctly
expect(result.filePaths).toEqual(['file1.js', 'file2.js']);
});
test.runIf(!isWindows)('should handle git worktree with parent .gitignore correctly', async () => {
// This test verifies that git worktree environments correctly handle parent directory .gitignore files.
// It combines worktree detection with parent .gitignore pattern application.
// Mock .git file content for worktree
const gitWorktreeContent = 'gitdir: /path/to/main/repo/.git/worktrees/feature-branch';
// Mock fs.stat - first call for rootDir, subsequent calls for .git file
vi.mocked(fs.stat)
.mockResolvedValueOnce({
isDirectory: () => true,
isFile: () => false,
} as Stats)
.mockResolvedValue({
isFile: () => true,
isDirectory: () => false,
} as Stats);
// Override checkDirectoryPermissions mock for this test
vi.mocked(checkDirectoryPermissions).mockResolvedValue({
hasAllPermission: true,
details: { read: true, write: true, execute: true },
});
// Simulate parent .gitignore pattern in worktree environment
const mockFileStructure = [
'file1.js',
'file2.js',
'subdir/file3.js',
// 'subdir/ignored-in-worktree.js' - filtered by parent .gitignore
];
const mockGitignoreContent = {
'/test/worktree/.gitignore': 'ignored-in-worktree.js',
};
// Mock globby to return filtered file structure
const filteredFiles = mockFileStructure.filter((file) => {
const basename = path.basename(file);
const parentGitignore = mockGitignoreContent['/test/worktree/.gitignore'];
if (minimatch(basename, parentGitignore)) {
return false;
}
return true;
});
vi.mocked(globby).mockResolvedValue(filteredFiles);
vi.mocked(fs.readFile).mockImplementation(async (filePath) => {
// Return worktree content for .git file, gitignore content for .gitignore
if ((filePath as string).endsWith('.git')) {
return gitWorktreeContent;
}
return mockGitignoreContent[filePath as keyof typeof mockGitignoreContent] || '';
});
const mockConfig = createMockConfig({
include: ['**/*.js'],
ignore: {
useGitignore: true,
useDefaultPatterns: true, // Enable default patterns to trigger worktree detection
customPatterns: [],
},
});
const result = await searchFiles('/test/worktree', mockConfig);
// Verify parent .gitignore pattern filtered out the file in worktree
expect(result.filePaths).toHaveLength(3);
expect(result.filePaths).toContain('file1.js');
expect(result.filePaths).toContain('file2.js');
expect(result.filePaths).toContain('subdir/file3.js');
expect(result.filePaths).not.toContain('subdir/ignored-in-worktree.js');
// Verify .git file (not directory) is in ignore patterns (worktree-specific behavior)
// When .git is a worktree reference file, it should be ignored as a file, not as .git/**
const executeGlobbyCall = vi.mocked(globby).mock.calls[0];
const ignorePatterns = executeGlobbyCall[1]?.ignore as string[];
expect(ignorePatterns).toContain('.git');
expect(ignorePatterns).not.toContain('.git/**');
// Verify gitignore is disabled (patterns pre-collected by prescan into ignore array)
expect(globby).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
gitignore: false,
}),
);
});
test('should handle regular git repository correctly', async () => {
// Mock .git as a directory
vi.mocked(fs.stat)
.mockResolvedValueOnce({
isDirectory: () => true,
isFile: () => false,
} as Stats)
.mockResolvedValue({
isFile: () => false,
isDirectory: () => true,
} as Stats);
// Override checkDirectoryPermissions mock for this test
vi.mocked(checkDirectoryPermissions).mockResolvedValue({
hasAllPermission: true,
details: { read: true, write: true, execute: true },
});
// Mock globby to return some test files
vi.mocked(globby).mockResolvedValue(['file1.js', 'file2.js']);
const mockConfig = createMockConfig({
ignore: {
useGitignore: true,
useDefaultPatterns: true,
customPatterns: [],
},
});
const result = await searchFiles('/test/dir', mockConfig);
// Check that globby was called with correct ignore patterns
const executeGlobbyCall = vi.mocked(globby).mock.calls[0];
const ignorePatterns = executeGlobbyCall[1]?.ignore as string[];
// Verify .git/** is in ignore patterns for regular git repos
expect(ignorePatterns).toContain('.git/**');
// Verify just .git is not in ignore patterns
expect(ignorePatterns).not.toContain('.git');
// Verify the files were returned correctly
expect(result.filePaths).toEqual(['file1.js', 'file2.js']);
});
describe('globby error handling', () => {
// Errors thrown by globby flow through handleGlobbyError → outer catch.
// We exercise both layers explicitly so refactors can't silently lose
// PermissionError translation or the friendly RepomixError wrapper.
test('translates EPERM into PermissionError', async () => {
const epermError = Object.assign(new Error('boom'), { code: 'EPERM' });
vi.mocked(globby).mockRejectedValue(epermError);
await expect(searchFiles('/mock/root', createMockConfig({}))).rejects.toBeInstanceOf(PermissionError);
});
test('translates EACCES into PermissionError', async () => {
const eaccesError = Object.assign(new Error('boom'), { code: 'EACCES' });
vi.mocked(globby).mockRejectedValue(eaccesError);
await expect(searchFiles('/mock/root', createMockConfig({}))).rejects.toBeInstanceOf(PermissionError);
});
test('wraps generic Error from globby with directory context', async () => {
vi.mocked(globby).mockRejectedValue(new Error('disk read failed'));
await expect(searchFiles('/mock/root', createMockConfig({}))).rejects.toThrow(
/Failed to filter files in directory \/mock\/root\. Reason: disk read failed/,
);
});
test('throws a generic message for non-Error throws', async () => {
vi.mocked(globby).mockRejectedValue({ unexpected: 'shape' });
await expect(searchFiles('/mock/root', createMockConfig({}))).rejects.toThrow(
'An unexpected error occurred while filtering files.',
);
});
});
});
describe('escapeGlobPattern', () => {
test('should escape parentheses in pattern', () => {
const pattern = 'src/(categories)/**/*.ts';
expect(escapeGlobPattern(pattern)).toBe('src/\\(categories\\)/**/*.ts');
});
test('should handle nested brackets', () => {
const pattern = 'src/(auth)/([id])/**/*.ts';
expect(escapeGlobPattern(pattern)).toBe('src/\\(auth\\)/\\(\\[id\\]\\)/**/*.ts');
});
test('should handle empty string', () => {
expect(escapeGlobPattern('')).toBe('');
});
test('should not modify patterns without special characters', () => {
const pattern = 'src/components/**/*.ts';
expect(escapeGlobPattern(pattern)).toBe(pattern);
});
test('should handle multiple occurrences of the same bracket type', () => {
const pattern = 'src/(auth)/(settings)/**/*.ts';
expect(escapeGlobPattern(pattern)).toBe('src/\\(auth\\)/\\(settings\\)/**/*.ts');
});
});
test('should escape backslashes in pattern', () => {
const pattern = 'src\\temp\\(categories)';
expect(escapeGlobPattern(pattern)).toBe('src\\\\temp\\\\\\(categories\\)');
});
test('should handle patterns with already escaped special characters', () => {
const pattern = 'src\\\\(categories)';
expect(escapeGlobPattern(pattern)).toBe('src\\\\\\\\\\(categories\\)');
});
describe('normalizeGlobPattern', () => {
test('should remove trailing slash from simple directory pattern', () => {
expect(normalizeGlobPattern('bin/')).toBe('bin');
});
test('should remove trailing slash from nested directory pattern', () => {
expect(normalizeGlobPattern('src/components/')).toBe('src/components');
});
test('should preserve patterns without trailing slash', () => {
expect(normalizeGlobPattern('bin')).toBe('bin');
});
test('should preserve patterns ending with **/', () => {
expect(normalizeGlobPattern('src/**/')).toBe('src/**/');
});
test('should preserve patterns with file extensions', () => {
expect(normalizeGlobPattern('*.ts')).toBe('*.ts');
});
test('should handle patterns with special characters', () => {
expect(normalizeGlobPattern('src/(components)/')).toBe('src/(components)');
});
test('should convert **/folder pattern to **/folder/** for consistency', () => {
expect(normalizeGlobPattern('**/bin')).toBe('**/bin/**');
});
test('should convert **/nested/folder pattern to **/nested/folder/**', () => {
expect(normalizeGlobPattern('**/nested/folder')).toBe('**/nested/folder/**');
});
test('should not convert patterns that already have /**', () => {
expect(normalizeGlobPattern('**/folder/**')).toBe('**/folder/**');
});
test('should not convert patterns that already have /**/*', () => {
expect(normalizeGlobPattern('**/folder/**/*')).toBe('**/folder/**/*');
});
});
describe('searchFiles path validation', () => {
test('should throw error when target path does not exist', async () => {
const error = new Error('ENOENT') as Error & { code: string };
error.code = 'ENOENT';
vi.mocked(fs.stat).mockRejectedValue(error);
const mockConfig = createMockConfig();
await expect(searchFiles('/nonexistent/path', mockConfig)).rejects.toThrow(RepomixError);
await expect(searchFiles('/nonexistent/path', mockConfig)).rejects.toThrow(
'Target path does not exist: /nonexistent/path',
);
});
test('should throw PermissionError when access is denied', async () => {
const error = new Error('EPERM') as Error & { code: string };
error.code = 'EPERM';
vi.mocked(fs.stat).mockRejectedValue(error);
const mockConfig = createMockConfig();
await expect(searchFiles('/forbidden/path', mockConfig)).rejects.toThrow(PermissionError);
});
test('should throw error when target path is a file, not a directory', async () => {
vi.mocked(fs.stat).mockResolvedValue({
isDirectory: () => false,
isFile: () => true,
} as Stats);
const mockConfig = createMockConfig();
await expect(searchFiles('/path/to/file.txt', mockConfig)).rejects.toThrow(RepomixError);
await expect(searchFiles('/path/to/file.txt', mockConfig)).rejects.toThrow(
'Target path is not a directory: /path/to/file.txt. Please specify a directory path, not a file path.',
);
});
test('should succeed when target path is a valid directory', async () => {
vi.mocked(globby).mockResolvedValue(['test.js']);
const mockConfig = createMockConfig();
const result = await searchFiles('/valid/directory', mockConfig);
expect(result.filePaths).toEqual(['test.js']);
expect(result.emptyDirPaths).toEqual([]);
});
test('should filter explicit files based on include and ignore patterns', async () => {
const mockConfig = createMockConfig({
include: ['**/*.ts'],
ignore: {
useGitignore: false,
useDefaultPatterns: false,
customPatterns: ['**/*.test.ts'],
},
});
const explicitFiles = [
'/test/src/file1.ts',
'/test/src/file1.test.ts',
'/test/src/file2.js',
'/test/src/file3.ts',
];
// Mock globby to return the expected filtered files
vi.mocked(globby).mockResolvedValue(['src/file1.ts', 'src/file3.ts']);
const result = await searchFiles('/test', mockConfig, explicitFiles);
expect(result.filePaths).toEqual(['src/file1.ts', 'src/file3.ts']);
expect(result.emptyDirPaths).toEqual([]);
});
test('should handle explicit files with ignore patterns only', async () => {
const mockConfig = createMockConfig({
include: [],
ignore: {
useGitignore: false,
useDefaultPatterns: false,
customPatterns: ['tests/**'],
},
});
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']);
const result = await searchFiles('/test', mockConfig, explicitFiles);
expect(result.filePaths).toEqual(['lib/utils.ts', 'src/main.ts']);
expect(result.emptyDirPaths).toEqual([]);
});
});
describe('createBaseGlobbyOptions consistency', () => {
test('should use consistent base options across all globby calls', async () => {
const mockConfig = createMockConfig({
include: ['**/*.ts'],
ignore: {
useGitignore: true,
useDefaultPatterns: false,
customPatterns: ['*.test.ts'],
},
});
vi.mocked(globby).mockResolvedValue(['file1.ts', 'file2.ts']);
// Call all functions that use globby
await searchFiles('/test/root', mockConfig);
await listDirectories('/test/root', mockConfig);
await listFiles('/test/root', mockConfig);
// searchFiles calls globby once: `onlyFiles: true` when `includeEmptyDirectories` is
// disabled (the case here), or `onlyFiles: false, objectMode: true` when enabled to
// return files and directories in a single traversal.
// listDirectories calls globby once (onlyDirectories: true)
// listFiles calls globby once (onlyFiles: true)
const calls = vi.mocked(globby).mock.calls;
// Verify all calls have consistent base options
for (const call of calls) {
const options = call[1];
// In our implementation globby is always called with an options object.
// Guard here to satisfy the type-checker and avoid undefined access.
expect(options).toBeDefined();
if (!options) continue;
expect(options).toMatchObject({
cwd: '/test/root',
gitignore: false,
ignoreFiles: [],
absolute: false,
dot: true,
followSymbolicLinks: false,
});
// A call must target a recognised entry kind: onlyFiles, onlyDirectories,
// or objectMode (the combined files+directories path). A call may not
// set both onlyFiles and onlyDirectories.
const hasOnlyFiles = 'onlyFiles' in options && options.onlyFiles === true;
const hasOnlyDirectories = 'onlyDirectories' in options && options.onlyDirectories === true;
const hasObjectMode = 'objectMode' in options && options.objectMode === true;
expect(hasOnlyFiles || hasOnlyDirectories || hasObjectMode).toBe(true);
expect(hasOnlyFiles && hasOnlyDirectories).toBe(false);
}
});
test('should respect gitignore config consistently across all functions', async () => {
const mockConfigWithoutGitignore = createMockConfig({
ignore: {
useGitignore: false,
useDefaultPatterns: false,
customPatterns: [],
},
});
vi.mocked(globby).mockResolvedValue([]);
// Call all functions
await searchFiles('/test/root', mockConfigWithoutGitignore);
await listDirectories('/test/root', mockConfigWithoutGitignore);
await listFiles('/test/root', mockConfigWithoutGitignore);
// Verify all calls have gitignore: false
const calls = vi.mocked(globby).mock.calls;
for (const call of calls) {
const options = call[1];
// In our implementation globby is always called with an options object.
// Guard here to satisfy the type-checker and avoid undefined access.
expect(options).toBeDefined();
if (!options) continue;
expect(options).toMatchObject({
gitignore: false,
});
}
});
test('should apply custom ignore patterns consistently across all functions', async () => {
const customPatterns = ['*.custom', 'temp/**'];
const mockConfig = createMockConfig({
ignore: {
useGitignore: true,
useDefaultPatterns: false,
customPatterns,
},
});
vi.mocked(globby).mockResolvedValue([]);
// Call all functions
await searchFiles('/test/root', mockConfig);
await listDirectories('/test/root', mockConfig);
await listFiles('/test/root', mockConfig);
// Verify all calls include custom patterns in ignore array
const calls = vi.mocked(globby).mock.calls;
for (const call of calls) {
const options = call[1];
// In our implementation globby is always called with an options object.
// Guard here to satisfy the type-checker and avoid undefined access.
expect(options).toBeDefined();
if (!options) continue;
const ignorePatterns = options.ignore as string[];
expect(ignorePatterns).toEqual(expect.arrayContaining(customPatterns));
}
});
});
});