diff --git a/repomix.config.json b/repomix.config.json index 6f31287..e868ec9 100644 --- a/repomix.config.json +++ b/repomix.config.json @@ -7,7 +7,8 @@ "removeComments": false, "removeEmptyLines": false, "topFilesLength": 5, - "showLineNumbers": false + "showLineNumbers": false, + "includeEmptyDirectories": true }, "include": [], "ignore": { diff --git a/src/config/configSchema.ts b/src/config/configSchema.ts index 428b10f..6243fba 100644 --- a/src/config/configSchema.ts +++ b/src/config/configSchema.ts @@ -24,6 +24,7 @@ export const repomixConfigBaseSchema = z.object({ topFilesLength: z.number().optional(), showLineNumbers: z.boolean().optional(), copyToClipboard: z.boolean().optional(), + includeEmptyDirectories: z.boolean().optional(), }) .optional(), include: z.array(z.string()).optional(), @@ -54,6 +55,7 @@ export const repomixConfigDefaultSchema = z.object({ topFilesLength: z.number().int().min(0).default(5), showLineNumbers: z.boolean().default(false), copyToClipboard: z.boolean().default(false), + includeEmptyDirectories: z.boolean().optional(), }) .default({}), include: z.array(z.string()).default([]), diff --git a/src/core/file/fileSearch.ts b/src/core/file/fileSearch.ts index 6ae8a25..8efe5b9 100644 --- a/src/core/file/fileSearch.ts +++ b/src/core/file/fileSearch.ts @@ -1,11 +1,48 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; import { globby } from 'globby'; +import { minimatch } from 'minimatch'; import type { RepomixConfigMerged } from '../../config/configSchema.js'; import { defaultIgnoreList } from '../../config/defaultIgnore.js'; import { logger } from '../../shared/logger.js'; import { sortPaths } from './filePathSort.js'; import { PermissionError, checkDirectoryPermissions } from './permissionCheck.js'; -export const searchFiles = async (rootDir: string, config: RepomixConfigMerged): Promise => { +export interface FileSearchResult { + filePaths: string[]; + emptyDirPaths: string[]; +} + +const findEmptyDirectories = async ( + rootDir: string, + directories: string[], + ignorePatterns: string[], +): Promise => { + const emptyDirs: string[] = []; + + for (const dir of directories) { + const fullPath = path.join(rootDir, dir); + try { + const entries = await fs.readdir(fullPath); + const hasVisibleContents = entries.some((entry) => !entry.startsWith('.')); + + if (!hasVisibleContents) { + // This checks if the directory itself matches any ignore patterns + const shouldIgnore = ignorePatterns.some((pattern) => minimatch(dir, pattern) || minimatch(`${dir}/`, pattern)); + + if (!shouldIgnore) { + emptyDirs.push(dir); + } + } + } catch (error) { + logger.debug(`Error checking directory ${dir}:`, error); + } + } + + return emptyDirs; +}; + +export const searchFiles = async (rootDir: string, config: RepomixConfigMerged): Promise => { // First check directory permissions const permissionCheck = await checkDirectoryPermissions(rootDir); @@ -47,10 +84,27 @@ export const searchFiles = async (rootDir: string, config: RepomixConfigMerged): throw error; }); + let emptyDirPaths: string[] = []; + if (config.output.includeEmptyDirectories) { + const directories = await globby(includePatterns, { + cwd: rootDir, + ignore: [...ignorePatterns], + ignoreFiles: [...ignoreFilePatterns], + onlyDirectories: true, + absolute: false, + dot: true, + followSymbolicLinks: false, + }); + + emptyDirPaths = await findEmptyDirectories(rootDir, directories, ignorePatterns); + } + logger.trace(`Filtered ${filePaths.length} files`); - const sortedPaths = sortPaths(filePaths); - return sortedPaths; + return { + filePaths: sortPaths(filePaths), + emptyDirPaths: sortPaths(emptyDirPaths), + }; } catch (error: unknown) { // Re-throw PermissionError as is if (error instanceof PermissionError) { diff --git a/src/core/file/fileTreeGenerate.ts b/src/core/file/fileTreeGenerate.ts index b20c462..a0f07dc 100644 --- a/src/core/file/fileTreeGenerate.ts +++ b/src/core/file/fileTreeGenerate.ts @@ -1,4 +1,4 @@ -import path from 'node:path'; +import nodepath from 'node:path'; interface TreeNode { name: string; @@ -8,28 +8,37 @@ interface TreeNode { const createTreeNode = (name: string, isDirectory: boolean): TreeNode => ({ name, children: [], isDirectory }); -export const generateFileTree = (files: string[]): TreeNode => { +export const generateFileTree = (files: string[], emptyDirPaths: string[] = []): TreeNode => { const root: TreeNode = createTreeNode('root', true); for (const file of files) { - const parts = file.split(path.sep); - let currentNode = root; + addPathToTree(root, file, false); + } + + // Add empty directories + for (const dir of emptyDirPaths) { + addPathToTree(root, dir, true); + } - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; - const isLastPart = i === parts.length - 1; - let child = currentNode.children.find((c) => c.name === part); + return root; +}; - if (!child) { - child = createTreeNode(part, !isLastPart); - currentNode.children.push(child); - } +const addPathToTree = (root: TreeNode, path: string, isDirectory: boolean): void => { + const parts = path.split(nodepath.sep); + let currentNode = root; - currentNode = child; + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const isLastPart = i === parts.length - 1; + let child = currentNode.children.find((c) => c.name === part); + + if (!child) { + child = createTreeNode(part, !isLastPart || isDirectory); + currentNode.children.push(child); } - } - return root; + currentNode = child; + } }; const sortTreeNodes = (node: TreeNode) => { @@ -59,7 +68,7 @@ export const treeToString = (node: TreeNode, prefix = ''): string => { return result; }; -export const generateTreeString = (files: string[]): string => { - const tree = generateFileTree(files); +export const generateTreeString = (files: string[], emptyDirPaths: string[] = []): string => { + const tree = generateFileTree(files, emptyDirPaths); return treeToString(tree).trim(); }; diff --git a/src/core/output/outputGenerate.ts b/src/core/output/outputGenerate.ts index 9064024..36ae6e1 100644 --- a/src/core/output/outputGenerate.ts +++ b/src/core/output/outputGenerate.ts @@ -3,6 +3,7 @@ import path from 'node:path'; import Handlebars from 'handlebars'; import type { RepomixConfigMerged } from '../../config/configSchema.js'; import { RepomixError } from '../../shared/errorHandle.js'; +import { searchFiles } from '../file/fileSearch.js'; import { generateTreeString } from '../file/fileTreeGenerate.js'; import type { ProcessedFile } from '../file/fileTypes.js'; import type { OutputGeneratorContext } from './outputGeneratorTypes.js'; @@ -78,9 +79,20 @@ export const buildOutputGeneratorContext = async ( } } + let emptyDirPaths: string[] = []; + if (config.output.includeEmptyDirectories) { + try { + const searchResult = await searchFiles(rootDir, config); + emptyDirPaths = searchResult.emptyDirPaths; + } catch (error) { + if (error instanceof Error) { + throw new RepomixError(`Failed to search for empty directories: ${error.message}`); + } + } + } return { generationDate: new Date().toISOString(), - treeString: generateTreeString(allFilePaths), + treeString: generateTreeString(allFilePaths, emptyDirPaths), processedFiles, config, instruction: repositoryInstruction, diff --git a/src/core/packager.ts b/src/core/packager.ts index c3492cd..fcad58b 100644 --- a/src/core/packager.ts +++ b/src/core/packager.ts @@ -38,7 +38,7 @@ export const pack = async ( ): Promise => { // Get all file paths considering the config progressCallback('Searching for files...'); - const filePaths = await deps.searchFiles(rootDir, config); + const { filePaths } = await deps.searchFiles(rootDir, config); // Collect raw files progressCallback('Collecting files...'); diff --git a/tests/core/file/fileSearch.test.ts b/tests/core/file/fileSearch.test.ts index 892bfe7..65b9e22 100644 --- a/tests/core/file/fileSearch.test.ts +++ b/tests/core/file/fileSearch.test.ts @@ -46,6 +46,54 @@ describe('fileSearch', () => { const filePatterns = await getIgnoreFilePatterns(mockConfig); 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 (_, options) => { + if (options?.onlyDirectories) { + return mockEmptyDirs; + } + 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()); + }); + + 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 (_, options) => { + if (options?.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', () => { @@ -194,8 +242,9 @@ node_modules }); const result = await searchFiles('/mock/root', mockConfig); - expect(result).toEqual(['root/another/file3.js', 'root/subdir/file2.js', 'root/file1.js']); - expect(result).not.toContain('root/subdir/ignored.js'); + 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('should not apply .gitignore when useGitignore is false', async () => { @@ -219,8 +268,9 @@ node_modules const result = await searchFiles('/mock/root', mockConfig); - expect(result).toEqual(mockFileStructure); - expect(result).toContain('root/subdir/ignored.js'); + expect(result.filePaths).toEqual(mockFileStructure); + expect(result.filePaths).toContain('root/subdir/ignored.js'); + expect(result.emptyDirPaths).toEqual([]); }); }); }); diff --git a/tests/core/packager.test.ts b/tests/core/packager.test.ts index 57b059b..b11589c 100644 --- a/tests/core/packager.test.ts +++ b/tests/core/packager.test.ts @@ -34,7 +34,10 @@ describe('packager', () => { vi.resetAllMocks(); const file2Path = path.join('dir1', 'file2.txt'); mockDeps = { - searchFiles: vi.fn().mockResolvedValue(['file1.txt', file2Path]), + searchFiles: vi.fn().mockResolvedValue({ + filePaths: ['file1.txt', file2Path], + emptyDirPaths: [], + }), collectFiles: vi.fn().mockResolvedValue([ { path: 'file1.txt', content: 'raw content 1' }, { path: file2Path, content: 'raw content 2' }, @@ -111,7 +114,10 @@ describe('packager', () => { const mockConfig = createMockConfig(); const suspiciousFile = 'suspicious.txt'; const file2Path = path.join('dir1', 'file2.txt'); - vi.mocked(mockDeps.searchFiles).mockResolvedValue(['file1.txt', file2Path, suspiciousFile]); + vi.mocked(mockDeps.searchFiles).mockResolvedValue({ + emptyDirPaths: [], + filePaths: ['file1.txt', file2Path, suspiciousFile], + }); vi.mocked(mockDeps.collectFiles).mockResolvedValue([ { path: 'file1.txt', content: 'raw content 1' }, { path: file2Path, content: 'raw content 2' },