Skip to content

Commit

Permalink
Merge pull request #180 from iNerdStack/main
Browse files Browse the repository at this point in the history
Implemented option to include empty directory
  • Loading branch information
yamadashy authored Nov 24, 2024
2 parents f0401c7 + be993d8 commit 0f225e7
Show file tree
Hide file tree
Showing 8 changed files with 163 additions and 29 deletions.
3 changes: 2 additions & 1 deletion repomix.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"removeComments": false,
"removeEmptyLines": false,
"topFilesLength": 5,
"showLineNumbers": false
"showLineNumbers": false,
"includeEmptyDirectories": true
},
"include": [],
"ignore": {
Expand Down
2 changes: 2 additions & 0 deletions src/config/configSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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([]),
Expand Down
60 changes: 57 additions & 3 deletions src/core/file/fileSearch.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> => {
export interface FileSearchResult {
filePaths: string[];
emptyDirPaths: string[];
}

const findEmptyDirectories = async (
rootDir: string,
directories: string[],
ignorePatterns: string[],
): Promise<string[]> => {
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<FileSearchResult> => {
// First check directory permissions
const permissionCheck = await checkDirectoryPermissions(rootDir);

Expand Down Expand Up @@ -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) {
Expand Down
43 changes: 26 additions & 17 deletions src/core/file/fileTreeGenerate.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import path from 'node:path';
import nodepath from 'node:path';

interface TreeNode {
name: string;
Expand All @@ -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) => {
Expand Down Expand Up @@ -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();
};
14 changes: 13 additions & 1 deletion src/core/output/outputGenerate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/core/packager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export const pack = async (
): Promise<PackResult> => {
// 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...');
Expand Down
58 changes: 54 additions & 4 deletions tests/core/file/fileSearch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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([]);
});
});
});
10 changes: 8 additions & 2 deletions tests/core/packager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -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' },
Expand Down

0 comments on commit 0f225e7

Please sign in to comment.