diff --git a/.gitignore b/.gitignore index 39a8773ae..cc7e3c610 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ node_modules/ # Build output lib/ website/client/.vitepress/dist/ +website/server/dist/ # Logs *.log diff --git a/package-lock.json b/package-lock.json index 015043501..e6ca310c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,7 @@ "fast-xml-parser": "^5.3.1", "fflate": "^0.8.2", "git-url-parse": "^16.1.0", - "globby": "^15.0.0", + "globby": "^16.0.0", "handlebars": "^4.7.8", "iconv-lite": "^0.7.0", "istextorbinary": "^9.5.0", @@ -3137,18 +3137,30 @@ } }, "node_modules/globby": { - "version": "15.0.0", - "resolved": "https://registry.npmjs.org/globby/-/globby-15.0.0.tgz", - "integrity": "sha512-oB4vkQGqlMl682wL1IlWd02tXCbquGWM4voPEI85QmNKCaw8zGTm1f1rubFgkg3Eli2PtKlFgrnmUqasbQWlkw==", + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-16.0.0.tgz", + "integrity": "sha512-ejy4TJFga99yW6Q0uhM3pFawKWZmtZzZD/v/GwI5+9bCV5Ew+D2pSND6W7fUes5UykqSsJkUfxFVdRh7Q1+P3Q==", "license": "MIT", "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "fast-glob": "^3.3.3", "ignore": "^7.0.5", - "path-type": "^6.0.0", + "is-path-inside": "^4.0.0", "slash": "^5.1.0", - "unicorn-magic": "^0.3.0" + "unicorn-magic": "^0.4.0" + }, + "engines": { + "node": ">=20" }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby/node_modules/unicorn-magic": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.4.0.tgz", + "integrity": "sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==", + "license": "MIT", "engines": { "node": ">=20" }, @@ -3391,6 +3403,18 @@ "node": ">=0.12.0" } }, + "node_modules/is-path-inside": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-4.0.0.tgz", + "integrity": "sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -4154,6 +4178,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" diff --git a/package.json b/package.json index 990d79e2e..099fa3381 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "dependencies": { "@clack/prompts": "^0.11.0", "@modelcontextprotocol/sdk": "^1.21.0", + "@repomix/tree-sitter-wasms": "^0.1.14", "@secretlint/core": "^11.2.5", "@secretlint/secretlint-rule-preset-recommend": "^11.2.5", "clipboardy": "^5.0.0", @@ -87,7 +88,7 @@ "fast-xml-parser": "^5.3.1", "fflate": "^0.8.2", "git-url-parse": "^16.1.0", - "globby": "^15.0.0", + "globby": "^16.0.0", "handlebars": "^4.7.8", "iconv-lite": "^0.7.0", "istextorbinary": "^9.5.0", @@ -100,7 +101,6 @@ "strip-comments": "^2.0.1", "tiktoken": "^1.0.22", "tinypool": "^2.0.0", - "@repomix/tree-sitter-wasms": "^0.1.14", "web-tree-sitter": "^0.25.10", "zod": "^4.1.12" }, diff --git a/src/core/file/fileSearch.ts b/src/core/file/fileSearch.ts index 8a83550e7..492af837e 100644 --- a/src/core/file/fileSearch.ts +++ b/src/core/file/fileSearch.ts @@ -1,7 +1,7 @@ import type { Stats } from 'node:fs'; import fs from 'node:fs/promises'; import path from 'node:path'; -import { globby } from 'globby'; +import { type Options as GlobbyOptions, globby } from 'globby'; import { minimatch } from 'minimatch'; import type { RepomixConfigMerged } from '../../config/configSchema.js'; import { defaultIgnoreList } from '../../config/defaultIgnore.js'; @@ -146,32 +146,11 @@ export const searchFiles = async ( } try { - const [ignorePatterns, ignoreFilePatterns] = await Promise.all([ - getIgnorePatterns(rootDir, config), - getIgnoreFilePatterns(config), - ]); + const { adjustedIgnorePatterns, ignoreFilePatterns } = await prepareIgnoreContext(rootDir, config); - // Normalize ignore patterns to handle trailing slashes consistently - const normalizedIgnorePatterns = ignorePatterns.map(normalizeGlobPattern); - - logger.trace('Ignore patterns:', normalizedIgnorePatterns); + logger.trace('Ignore patterns:', adjustedIgnorePatterns); logger.trace('Ignore file patterns:', ignoreFilePatterns); - // Check if .git is a worktree reference - const gitPath = path.join(rootDir, '.git'); - const isWorktree = await isGitWorktreeRef(gitPath); - - // Modify ignore patterns for git worktree - const adjustedIgnorePatterns = [...normalizedIgnorePatterns]; - if (isWorktree) { - // Remove '.git/**' pattern and add '.git' to ignore the reference file - const gitIndex = adjustedIgnorePatterns.indexOf('.git/**'); - if (gitIndex !== -1) { - adjustedIgnorePatterns.splice(gitIndex, 1); - adjustedIgnorePatterns.push('.git'); - } - } - // Start with configured include patterns let includePatterns = config.include.map((pattern) => escapeGlobPattern(pattern)); @@ -211,13 +190,8 @@ export const searchFiles = async ( const globbyStartTime = Date.now(); const filePaths = await globby(includePatterns, { - cwd: rootDir, - ignore: [...adjustedIgnorePatterns], - ignoreFiles: [...ignoreFilePatterns], + ...createBaseGlobbyOptions(rootDir, config, adjustedIgnorePatterns, 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; @@ -239,13 +213,8 @@ export const searchFiles = async ( const emptyDirStartTime = Date.now(); const directories = await globby(includePatterns, { - cwd: rootDir, - ignore: [...adjustedIgnorePatterns], - ignoreFiles: [...ignoreFilePatterns], + ...createBaseGlobbyOptions(rootDir, config, adjustedIgnorePatterns, ignoreFilePatterns), onlyDirectories: true, - absolute: false, - dot: true, - followSymbolicLinks: false, }); const emptyDirElapsedTime = Date.now() - emptyDirStartTime; @@ -292,6 +261,63 @@ export const parseIgnoreContent = (content: string): string[] => { }, []); }; +/** + * Prepares ignore context including patterns and file patterns with git worktree handling. + * This logic is shared across searchFiles, listDirectories, and listFiles. + * + * @param rootDir The root directory to search + * @param config The merged configuration + * @returns Object containing adjusted ignore patterns and ignore file patterns + */ +const prepareIgnoreContext = async ( + rootDir: string, + config: RepomixConfigMerged, +): Promise<{ adjustedIgnorePatterns: string[]; ignoreFilePatterns: string[] }> => { + const [ignorePatterns, ignoreFilePatterns] = await Promise.all([ + getIgnorePatterns(rootDir, config), + getIgnoreFilePatterns(config), + ]); + + // Normalize ignore patterns to handle trailing slashes consistently + const normalizedIgnorePatterns = ignorePatterns.map(normalizeGlobPattern); + + // Check if .git is a worktree reference + const gitPath = path.join(rootDir, '.git'); + const isWorktree = await isGitWorktreeRef(gitPath); + + // Modify ignore patterns for git worktree + const adjustedIgnorePatterns = [...normalizedIgnorePatterns]; + if (isWorktree) { + // Remove '.git/**' pattern and add '.git' to ignore the reference file + const gitIndex = adjustedIgnorePatterns.indexOf('.git/**'); + if (gitIndex !== -1) { + adjustedIgnorePatterns.splice(gitIndex, 1); + adjustedIgnorePatterns.push('.git'); + } + } + + return { adjustedIgnorePatterns, ignoreFilePatterns }; +}; + +/** + * Creates base globby options with common ignore patterns. + * Returns options that can be extended with specific settings like onlyFiles or onlyDirectories. + */ +const createBaseGlobbyOptions = ( + rootDir: string, + config: RepomixConfigMerged, + ignorePatterns: string[], + ignoreFilePatterns: string[], +): Omit => ({ + cwd: rootDir, + ignore: ignorePatterns, + gitignore: config.ignore.useGitignore, + ignoreFiles: ignoreFilePatterns, + absolute: false, + dot: true, + followSymbolicLinks: false, +}); + export const getIgnoreFilePatterns = async (config: RepomixConfigMerged): Promise => { const ignoreFilePatterns: string[] = []; @@ -301,10 +327,9 @@ export const getIgnoreFilePatterns = async (config: RepomixConfigMerged): Promis // // Multiple ignore files in the same directory (.gitignore, .ignore, .repomixignore) // are all merged together. The order in this array does not affect priority. - - if (config.ignore.useGitignore) { - ignoreFilePatterns.push('**/.gitignore'); - } + // + // .gitignore files are handled by globby's gitignore option (not ignoreFiles) + // to properly respect parent directory .gitignore files, matching Git's behavior. if (config.ignore.useDotIgnore) { ignoreFilePatterns.push('**/.ignore'); @@ -373,37 +398,11 @@ export const getIgnorePatterns = async (rootDir: string, config: RepomixConfigMe * @returns Array of directory paths relative to rootDir */ export const listDirectories = async (rootDir: string, config: RepomixConfigMerged): Promise => { - const [ignorePatterns, ignoreFilePatterns] = await Promise.all([ - getIgnorePatterns(rootDir, config), - getIgnoreFilePatterns(config), - ]); - - // Normalize ignore patterns to handle trailing slashes consistently - const normalizedIgnorePatterns = ignorePatterns.map(normalizeGlobPattern); - - // Check if .git is a worktree reference - const gitPath = path.join(rootDir, '.git'); - const isWorktree = await isGitWorktreeRef(gitPath); - - // Modify ignore patterns for git worktree - const adjustedIgnorePatterns = [...normalizedIgnorePatterns]; - if (isWorktree) { - // Remove '.git/**' pattern and add '.git' to ignore the reference file - const gitIndex = adjustedIgnorePatterns.indexOf('.git/**'); - if (gitIndex !== -1) { - adjustedIgnorePatterns.splice(gitIndex, 1); - adjustedIgnorePatterns.push('.git'); - } - } + const { adjustedIgnorePatterns, ignoreFilePatterns } = await prepareIgnoreContext(rootDir, config); const directories = await globby(['**/*'], { - cwd: rootDir, + ...createBaseGlobbyOptions(rootDir, config, adjustedIgnorePatterns, ignoreFilePatterns), onlyDirectories: true, - absolute: false, - dot: true, - followSymbolicLinks: false, - ignore: [...adjustedIgnorePatterns], - ignoreFiles: [...ignoreFilePatterns], }); return sortPaths(directories); @@ -418,37 +417,11 @@ export const listDirectories = async (rootDir: string, config: RepomixConfigMerg * @returns Array of file paths relative to rootDir */ export const listFiles = async (rootDir: string, config: RepomixConfigMerged): Promise => { - const [ignorePatterns, ignoreFilePatterns] = await Promise.all([ - getIgnorePatterns(rootDir, config), - getIgnoreFilePatterns(config), - ]); - - // Normalize ignore patterns to handle trailing slashes consistently - const normalizedIgnorePatterns = ignorePatterns.map(normalizeGlobPattern); - - // Check if .git is a worktree reference - const gitPath = path.join(rootDir, '.git'); - const isWorktree = await isGitWorktreeRef(gitPath); - - // Modify ignore patterns for git worktree - const adjustedIgnorePatterns = [...normalizedIgnorePatterns]; - if (isWorktree) { - // Remove '.git/**' pattern and add '.git' to ignore the reference file - const gitIndex = adjustedIgnorePatterns.indexOf('.git/**'); - if (gitIndex !== -1) { - adjustedIgnorePatterns.splice(gitIndex, 1); - adjustedIgnorePatterns.push('.git'); - } - } + const { adjustedIgnorePatterns, ignoreFilePatterns } = await prepareIgnoreContext(rootDir, config); const files = await globby(['**/*'], { - cwd: rootDir, + ...createBaseGlobbyOptions(rootDir, config, adjustedIgnorePatterns, ignoreFilePatterns), onlyFiles: true, - absolute: false, - dot: true, - followSymbolicLinks: false, - ignore: [...adjustedIgnorePatterns], - ignoreFiles: [...ignoreFilePatterns], }); return sortPaths(files); diff --git a/tests/core/file/fileSearch.test.ts b/tests/core/file/fileSearch.test.ts index 505b2110f..8e3e123bf 100644 --- a/tests/core/file/fileSearch.test.ts +++ b/tests/core/file/fileSearch.test.ts @@ -9,6 +9,8 @@ import { escapeGlobPattern, getIgnoreFilePatterns, getIgnorePatterns, + listDirectories, + listFiles, normalizeGlobPattern, parseIgnoreContent, searchFiles, @@ -53,7 +55,7 @@ describe('fileSearch', () => { }); describe('getIgnoreFilePaths', () => { - test('should return correct paths when .gitignore, .ignore and .repomixignore exist', async () => { + 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: { @@ -64,7 +66,8 @@ describe('fileSearch', () => { }, }); const filePatterns = await getIgnoreFilePatterns(mockConfig); - expect(filePatterns).toEqual(['**/.gitignore', '**/.ignore', '**/.repomixignore']); + // .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 () => { @@ -92,7 +95,8 @@ describe('fileSearch', () => { }, }); const filePatterns = await getIgnoreFilePatterns(mockConfig); - expect(filePatterns).toEqual(['**/.gitignore', '**/.repomixignore']); + // .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 () => { @@ -280,7 +284,8 @@ node_modules expect.objectContaining({ cwd: '/mock/root', ignore: expect.arrayContaining(['*.custom']), - ignoreFiles: expect.arrayContaining(['**/.gitignore', '**/.repomixignore']), + gitignore: true, + ignoreFiles: expect.arrayContaining(['**/.repomixignore']), onlyFiles: true, absolute: false, dot: true, @@ -335,6 +340,187 @@ node_modules 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 option was passed to globby + expect(globby).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + gitignore: true, + }), + ); + }); + + 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 option includes .ignore + expect(globby).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + ignoreFiles: expect.arrayContaining(['**/.ignore']), + }), + ); + }); + + 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 option includes .repomixignore + expect(globby).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + ignoreFiles: expect.arrayContaining(['**/.repomixignore']), + }), + ); + }); + test('should not apply .gitignore when useGitignore is false', async () => { const mockConfig = createMockConfig({ include: ['**/*.js'], @@ -409,6 +595,95 @@ node_modules 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 option was passed (enables parent .gitignore handling) + expect(globby).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + gitignore: true, + }), + ); + }); + test('should handle regular git repository correctly', async () => { // Mock .git as a directory vi.mocked(fs.stat) @@ -628,4 +903,120 @@ node_modules 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 twice (files + directories if includeEmptyDirectories is true) + // listDirectories calls globby once + // listFiles calls globby once + 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: true, + ignoreFiles: expect.arrayContaining(['**/.repomixignore']), + absolute: false, + dot: true, + followSymbolicLinks: false, + }); + + // Each call should have either onlyFiles or onlyDirectories, but not both + if (options) { + const hasOnlyFiles = 'onlyFiles' in options && options.onlyFiles === true; + const hasOnlyDirectories = 'onlyDirectories' in options && options.onlyDirectories === true; + expect(hasOnlyFiles || hasOnlyDirectories).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)); + } + }); + }); });