Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implemented option to include empty directory #180

Merged
merged 4 commits into from
Nov 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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(),
iNerdStack marked this conversation as resolved.
Show resolved Hide resolved
})
.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
iNerdStack marked this conversation as resolved.
Show resolved Hide resolved
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);
}

Check warning on line 39 in src/core/file/fileSearch.ts

View check run for this annotation

Codecov / codecov/patch

src/core/file/fileSearch.ts#L38-L39

Added lines #L38 - L39 were not covered by tests
}

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 @@
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 @@

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);
}

Check warning on line 21 in src/core/file/fileTreeGenerate.ts

View check run for this annotation

Codecov / codecov/patch

src/core/file/fileTreeGenerate.ts#L20-L21

Added lines #L20 - L21 were not covered by tests

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 @@
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 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 @@
}
}

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}`);
}
}
}

Check warning on line 92 in src/core/output/outputGenerate.ts

View check run for this annotation

Codecov / codecov/patch

src/core/output/outputGenerate.ts#L84-L92

Added lines #L84 - L92 were not covered by tests
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);
iNerdStack marked this conversation as resolved.
Show resolved Hide resolved

// 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: [],
}),
iNerdStack marked this conversation as resolved.
Show resolved Hide resolved
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
Loading