diff --git a/src/core/output/outputGenerate.ts b/src/core/output/outputGenerate.ts index 50d9a1b17..32caa6666 100644 --- a/src/core/output/outputGenerate.ts +++ b/src/core/output/outputGenerate.ts @@ -258,6 +258,7 @@ export const generateOutput = async ( gitDiffResult: GitDiffResult | undefined = undefined, gitLogResult: GitLogResult | undefined = undefined, filePathsByRoot?: FilesByRoot[], + emptyDirPaths?: string[], deps = { buildOutputGeneratorContext, generateHandlebarOutput, @@ -277,6 +278,7 @@ export const generateOutput = async ( gitDiffResult, gitLogResult, filePathsByRoot, + emptyDirPaths, ); const renderContext = createRenderContext(outputGeneratorContext); @@ -303,6 +305,7 @@ export const buildOutputGeneratorContext = async ( gitDiffResult: GitDiffResult | undefined = undefined, gitLogResult: GitLogResult | undefined = undefined, filePathsByRoot?: FilesByRoot[], + emptyDirPaths?: string[], deps = { listDirectories, listFiles, @@ -356,16 +359,21 @@ export const buildOutputGeneratorContext = async ( ); } } else if (config.output.directoryStructure && config.output.includeEmptyDirectories) { - // Default behavior: include empty directories only - try { - const results = await Promise.all(rootDirs.map((rootDir) => deps.searchFiles(rootDir, config))); - const merged = results.flatMap((r) => r.emptyDirPaths); - directoryPathsForTree = [...new Set(merged)].sort(); - } catch (error) { - throw new RepomixError( - `Failed to search for empty directories: ${error instanceof Error ? error.message : String(error)}`, - error instanceof Error ? { cause: error } : undefined, - ); + // Reuse pre-computed emptyDirPaths from the initial searchFiles call when available, + // avoiding a redundant full directory scan. + if (emptyDirPaths) { + directoryPathsForTree = emptyDirPaths; + } else { + try { + const results = await Promise.all(rootDirs.map((rootDir) => deps.searchFiles(rootDir, config))); + const merged = results.flatMap((r) => r.emptyDirPaths); + directoryPathsForTree = [...new Set(merged)].sort(); + } catch (error) { + throw new RepomixError( + `Failed to search for empty directories: ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? { cause: error } : undefined, + ); + } } } diff --git a/src/core/output/outputSplit.ts b/src/core/output/outputSplit.ts index 3cfe14ec5..be8261f6f 100644 --- a/src/core/output/outputSplit.ts +++ b/src/core/output/outputSplit.ts @@ -101,6 +101,7 @@ const renderGroups = async ( gitDiffResult: GitDiffResult | undefined, gitLogResult: GitLogResult | undefined, filePathsByRoot: FilesByRoot[] | undefined, + emptyDirPaths: string[] | undefined, generateOutput: GenerateOutputFn, ): Promise => { const chunkProcessedFiles = groupsToRender.flatMap((g) => g.processedFiles); @@ -115,6 +116,7 @@ const renderGroups = async ( partIndex === 1 ? gitDiffResult : undefined, partIndex === 1 ? gitLogResult : undefined, filePathsByRoot, + emptyDirPaths, ); }; @@ -128,6 +130,7 @@ export const generateSplitOutputParts = async ({ gitLogResult, progressCallback, filePathsByRoot, + emptyDirPaths, deps, }: { rootDirs: string[]; @@ -139,6 +142,7 @@ export const generateSplitOutputParts = async ({ gitLogResult: GitLogResult | undefined; progressCallback: RepomixProgressCallback; filePathsByRoot?: FilesByRoot[]; + emptyDirPaths?: string[]; deps: { generateOutput: GenerateOutputFn; }; @@ -178,6 +182,7 @@ export const generateSplitOutputParts = async ({ gitDiffResult, gitLogResult, filePathsByRoot, + emptyDirPaths, deps.generateOutput, ); const nextBytes = getUtf8ByteLength(nextContent); @@ -215,6 +220,7 @@ export const generateSplitOutputParts = async ({ gitDiffResult, gitLogResult, filePathsByRoot, + emptyDirPaths, deps.generateOutput, ); const singleGroupBytes = getUtf8ByteLength(singleGroupContent); diff --git a/src/core/packager.ts b/src/core/packager.ts index 935d9789d..00e30c4f0 100644 --- a/src/core/packager.ts +++ b/src/core/packager.ts @@ -70,22 +70,28 @@ export const pack = async ( logMemoryUsage('Pack - Start'); progressCallback('Searching for files...'); - const filePathsByDir = await withMemoryLogging('Search Files', async () => + const searchResultsByDir = await withMemoryLogging('Search Files', async () => Promise.all( - rootDirs.map(async (rootDir) => ({ - rootDir, - filePaths: (await deps.searchFiles(rootDir, config, explicitFiles)).filePaths, - })), + rootDirs.map(async (rootDir) => { + const result = await deps.searchFiles(rootDir, config, explicitFiles); + return { rootDir, filePaths: result.filePaths, emptyDirPaths: result.emptyDirPaths }; + }), ), ); + // Deduplicate and sort empty directory paths for reuse during output generation, + // avoiding a redundant searchFiles call in buildOutputGeneratorContext. + const emptyDirPaths = config.output.includeEmptyDirectories + ? [...new Set(searchResultsByDir.flatMap((r) => r.emptyDirPaths))].sort() + : undefined; + // Sort file paths progressCallback('Sorting files...'); - const allFilePaths = filePathsByDir.flatMap(({ filePaths }) => filePaths); + const allFilePaths = searchResultsByDir.flatMap(({ filePaths }) => filePaths); const sortedFilePaths = deps.sortPaths(allFilePaths); // Regroup sorted file paths by rootDir using Set for O(1) membership checks - const filePathSetByDir = new Map(filePathsByDir.map(({ rootDir, filePaths }) => [rootDir, new Set(filePaths)])); + const filePathSetByDir = new Map(searchResultsByDir.map(({ rootDir, filePaths }) => [rootDir, new Set(filePaths)])); const sortedFilePathsByDir = rootDirs.map((rootDir) => ({ rootDir, filePaths: sortedFilePaths.filter((filePath) => filePathSetByDir.get(rootDir)?.has(filePath) ?? false), @@ -194,6 +200,7 @@ export const pack = async ( gitLogResult, progressCallback, filePathsByRoot, + emptyDirPaths, ); const outputForMetricsPromise = outputPromise.then((r) => r.outputForMetrics); diff --git a/src/core/packager/produceOutput.ts b/src/core/packager/produceOutput.ts index 3a919f409..7bdf5d356 100644 --- a/src/core/packager/produceOutput.ts +++ b/src/core/packager/produceOutput.ts @@ -30,6 +30,7 @@ export const produceOutput = async ( gitLogResult: GitLogResult | undefined, progressCallback: RepomixProgressCallback, filePathsByRoot?: FilesByRoot[], + emptyDirPaths?: string[], overrideDeps: Partial = {}, ): Promise => { const deps = { ...defaultDeps, ...overrideDeps }; @@ -47,6 +48,7 @@ export const produceOutput = async ( gitLogResult, progressCallback, filePathsByRoot, + emptyDirPaths, deps, ); } @@ -60,6 +62,7 @@ export const produceOutput = async ( gitLogResult, progressCallback, filePathsByRoot, + emptyDirPaths, deps, ); }; @@ -74,6 +77,7 @@ const generateAndWriteSplitOutput = async ( gitLogResult: GitLogResult | undefined, progressCallback: RepomixProgressCallback, filePathsByRoot: FilesByRoot[] | undefined, + emptyDirPaths: string[] | undefined, deps: typeof defaultDeps, ): Promise => { const parts = await withMemoryLogging('Generate Split Output', async () => { @@ -87,6 +91,7 @@ const generateAndWriteSplitOutput = async ( gitLogResult, progressCallback, filePathsByRoot, + emptyDirPaths, deps: { generateOutput: deps.generateOutput, }, @@ -125,10 +130,20 @@ const generateAndWriteSingleOutput = async ( gitLogResult: GitLogResult | undefined, progressCallback: RepomixProgressCallback, filePathsByRoot: FilesByRoot[] | undefined, + emptyDirPaths: string[] | undefined, deps: typeof defaultDeps, ): Promise => { const output = await withMemoryLogging('Generate Output', () => - deps.generateOutput(rootDirs, config, processedFiles, allFilePaths, gitDiffResult, gitLogResult, filePathsByRoot), + deps.generateOutput( + rootDirs, + config, + processedFiles, + allFilePaths, + gitDiffResult, + gitLogResult, + filePathsByRoot, + emptyDirPaths, + ), ); progressCallback('Writing output file...'); diff --git a/tests/core/output/diffsInOutput.test.ts b/tests/core/output/diffsInOutput.test.ts index 4b1087a7b..e446605aa 100644 --- a/tests/core/output/diffsInOutput.test.ts +++ b/tests/core/output/diffsInOutput.test.ts @@ -131,6 +131,7 @@ index 123..456 100644 gitDiffResult, undefined, undefined, + undefined, { buildOutputGeneratorContext: mockBuildOutputGeneratorContext, generateHandlebarOutput: mockGenerateHandlebarOutput, @@ -204,6 +205,7 @@ index 123..456 100644 gitDiffResult, undefined, undefined, + undefined, { buildOutputGeneratorContext: mockBuildOutputGeneratorContext, generateHandlebarOutput: mockGenerateHandlebarOutput, diff --git a/tests/core/output/flagFullDirectoryStructure.test.ts b/tests/core/output/flagFullDirectoryStructure.test.ts index b2d70bc88..19c9aa73d 100644 --- a/tests/core/output/flagFullDirectoryStructure.test.ts +++ b/tests/core/output/flagFullDirectoryStructure.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from 'vitest'; +import { describe, expect, test, vi } from 'vitest'; import type { RepomixConfigMerged } from '../../../src/config/configSchema.js'; import type { ProcessedFile } from '../../../src/core/file/fileTypes.js'; import { buildOutputGeneratorContext } from '../../../src/core/output/outputGenerate.js'; @@ -72,6 +72,7 @@ describe('includeFullDirectoryStructure flag', () => { undefined, undefined, undefined, + undefined, deps, ); @@ -105,6 +106,7 @@ describe('includeFullDirectoryStructure flag', () => { undefined, undefined, undefined, + undefined, deps, ); @@ -117,3 +119,106 @@ describe('includeFullDirectoryStructure flag', () => { expect(ctx.treeString).toContain('index.ts'); }); }); + +describe('includeEmptyDirectories with pre-computed emptyDirPaths', () => { + const createEmptyDirConfig = (overrides: Partial = {}): RepomixConfigMerged => ({ + cwd: '/repo', + input: { maxFileSize: 1024 * 1024 }, + output: { + filePath: 'repomix-output.json', + style: 'json', + parsableStyle: false, + headerText: undefined, + instructionFilePath: undefined, + fileSummary: true, + directoryStructure: true, + files: true, + removeComments: false, + removeEmptyLines: false, + compress: false, + topFilesLength: 5, + showLineNumbers: false, + truncateBase64: false, + copyToClipboard: false, + includeEmptyDirectories: true, + includeFullDirectoryStructure: false, + tokenCountTree: false, + git: { + sortByChanges: false, + sortByChangesMaxCommits: 10, + includeDiffs: false, + includeLogs: false, + includeLogsCount: 5, + }, + }, + include: [], + ignore: { + useGitignore: true, + useDotIgnore: true, + useDefaultPatterns: true, + customPatterns: [], + }, + security: { enableSecurityCheck: true }, + tokenCount: { encoding: 'cl100k_base' }, + ...overrides, + }); + + test('uses pre-computed emptyDirPaths and skips searchFiles call', async () => { + const config = createEmptyDirConfig(); + const processedFiles: ProcessedFile[] = [{ path: 'src/index.ts', content: 'export const a = 1;\n' }]; + const allFilePaths = processedFiles.map((f) => f.path); + const preComputedEmptyDirs = ['empty-dir']; + + const deps = { + listDirectories: vi.fn(), + listFiles: vi.fn(), + searchFiles: vi.fn().mockResolvedValue({ filePaths: allFilePaths, emptyDirPaths: ['should-not-use'] }), + }; + + const ctx = await buildOutputGeneratorContext( + ['/repo'], + config, + allFilePaths, + processedFiles, + undefined, + undefined, + undefined, + preComputedEmptyDirs, + deps, + ); + + // searchFiles should NOT be called when emptyDirPaths is provided + expect(deps.searchFiles).not.toHaveBeenCalled(); + // The pre-computed empty dir should appear in the tree + expect(ctx.treeString).toContain('empty-dir'); + }); + + test('falls back to searchFiles when emptyDirPaths is not provided', async () => { + const config = createEmptyDirConfig(); + const processedFiles: ProcessedFile[] = [{ path: 'src/index.ts', content: 'export const a = 1;\n' }]; + const allFilePaths = processedFiles.map((f) => f.path); + + const deps = { + listDirectories: vi.fn(), + listFiles: vi.fn(), + searchFiles: vi.fn().mockResolvedValue({ filePaths: allFilePaths, emptyDirPaths: ['fallback-empty-dir'] }), + }; + + const ctx = await buildOutputGeneratorContext( + ['/repo'], + config, + allFilePaths, + processedFiles, + undefined, + undefined, + undefined, + undefined, + deps, + ); + + // searchFiles SHOULD be called as fallback + expect(deps.searchFiles).toHaveBeenCalledWith('/repo', config); + // The fallback empty dir should appear in the tree + expect(ctx.treeString).toContain('fallback-empty-dir'); + }); +}); diff --git a/tests/core/output/outputGenerate.test.ts b/tests/core/output/outputGenerate.test.ts index 635d89487..47bfd08f7 100644 --- a/tests/core/output/outputGenerate.test.ts +++ b/tests/core/output/outputGenerate.test.ts @@ -58,6 +58,7 @@ describe('outputGenerate', () => { undefined, undefined, undefined, + undefined, mockDeps, ); @@ -70,6 +71,7 @@ describe('outputGenerate', () => { undefined, undefined, undefined, + undefined, ); expect(output).toBe('mock output'); }); diff --git a/tests/core/output/outputGenerateDiffs.test.ts b/tests/core/output/outputGenerateDiffs.test.ts index c0f9b6601..79e989286 100644 --- a/tests/core/output/outputGenerateDiffs.test.ts +++ b/tests/core/output/outputGenerateDiffs.test.ts @@ -88,6 +88,7 @@ describe('Output Generation with Diffs', () => { gitDiffResult, undefined, undefined, + undefined, mockDeps, ); @@ -123,6 +124,7 @@ describe('Output Generation with Diffs', () => { undefined, undefined, undefined, + undefined, mockDeps, ); @@ -158,6 +160,7 @@ describe('Output Generation with Diffs', () => { undefined, undefined, undefined, + undefined, mockDeps, ); @@ -193,6 +196,7 @@ describe('Output Generation with Diffs', () => { undefined, undefined, undefined, + undefined, mockDeps, ); @@ -238,6 +242,7 @@ describe('Output Generation with Diffs', () => { undefined, undefined, undefined, + undefined, mockDeps, ); diff --git a/tests/core/packager.test.ts b/tests/core/packager.test.ts index 3baca2ef2..fea0b83c7 100644 --- a/tests/core/packager.test.ts +++ b/tests/core/packager.test.ts @@ -100,6 +100,7 @@ describe('packager', () => { undefined, progressCallback, [{ rootLabel: 'root', files: mockFilePaths }], + undefined, ); expect(mockDeps.calculateMetrics).toHaveBeenCalledWith( mockProcessedFiles, diff --git a/tests/core/packager/produceOutput.test.ts b/tests/core/packager/produceOutput.test.ts index ac8e324dd..2ea552570 100644 --- a/tests/core/packager/produceOutput.test.ts +++ b/tests/core/packager/produceOutput.test.ts @@ -26,6 +26,7 @@ describe('produceOutput', () => { undefined, progressCallback, undefined, + undefined, mockDeps, ); @@ -37,6 +38,7 @@ describe('produceOutput', () => { undefined, undefined, undefined, + undefined, ); expect(mockDeps.writeOutputToDisk).toHaveBeenCalledWith('generated output', mockConfig); expect(mockDeps.copyToClipboardIfEnabled).toHaveBeenCalledWith('generated output', progressCallback, mockConfig); @@ -61,6 +63,7 @@ describe('produceOutput', () => { gitLogResult as Parameters[5], vi.fn(), undefined, + undefined, mockDeps, ); @@ -72,6 +75,7 @@ describe('produceOutput', () => { gitDiffResult, gitLogResult, undefined, + undefined, ); }); @@ -80,7 +84,18 @@ describe('produceOutput', () => { const mockConfig = createMockConfig(); const progressCallback = vi.fn(); - await produceOutput(['/root'], mockConfig, [], [], undefined, undefined, progressCallback, undefined, mockDeps); + await produceOutput( + ['/root'], + mockConfig, + [], + [], + undefined, + undefined, + progressCallback, + undefined, + undefined, + mockDeps, + ); expect(progressCallback).toHaveBeenCalledWith('Writing output file...'); }); @@ -116,6 +131,7 @@ describe('produceOutput', () => { undefined, progressCallback, undefined, + undefined, mockDeps, ); @@ -134,7 +150,18 @@ describe('produceOutput', () => { }); const progressCallback = vi.fn(); - await produceOutput(['/root'], mockConfig, [], [], undefined, undefined, progressCallback, undefined, mockDeps); + await produceOutput( + ['/root'], + mockConfig, + [], + [], + undefined, + undefined, + progressCallback, + undefined, + undefined, + mockDeps, + ); expect(progressCallback).toHaveBeenCalledWith('Writing output files...'); }); @@ -148,7 +175,7 @@ describe('produceOutput', () => { }, }); - await produceOutput(['/root'], mockConfig, [], [], undefined, undefined, vi.fn(), undefined, mockDeps); + await produceOutput(['/root'], mockConfig, [], [], undefined, undefined, vi.fn(), undefined, undefined, mockDeps); expect(mockDeps.copyToClipboardIfEnabled).not.toHaveBeenCalled(); }); diff --git a/tests/core/packager/splitOutput.test.ts b/tests/core/packager/splitOutput.test.ts index 47754dd79..e4e7dc14b 100644 --- a/tests/core/packager/splitOutput.test.ts +++ b/tests/core/packager/splitOutput.test.ts @@ -70,6 +70,7 @@ describe('packager split output', () => { undefined, expect.any(Function), [{ rootLabel: 'root', files: allFilePaths }], + undefined, ); expect(calculateMetrics).toHaveBeenCalledWith(