Skip to content
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,7 @@ Instruction
- `--header-text <text>`: Custom text to include at the beginning of the output
- `--instruction-file-path <path>`: Path to file containing custom instructions to include in output
- `--include-empty-directories`: Include folders with no files in directory structure
- `--include-full-directory-structure`: Show complete directory tree in output, including files not matched by --include patterns
- `--no-git-sort-by-changes`: Don't sort files by git change frequency (default: most changed files first)
- `--include-diffs`: Add git diff section showing working tree and staged changes
- `--include-logs`: Add git commit history with messages and changed files
Expand Down
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
"$schema": "https://biomejs.dev/schemas/2.2.6/schema.json",
"files": {
"includes": [
"bin/**",
Expand Down
7 changes: 7 additions & 0 deletions src/cli/actions/defaultAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,13 @@ export const buildCliConfig = (options: CliOptions): RepomixConfigCli => {
};
}

if (options.includeFullDirectoryStructure) {
cliConfig.output = {
...cliConfig.output,
includeFullDirectoryStructure: options.includeFullDirectoryStructure,
};
}

// Only apply gitSortByChanges setting if explicitly set to false
if (options.gitSortByChanges === false) {
cliConfig.output = {
Expand Down
4 changes: 4 additions & 0 deletions src/cli/cliRun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ export const run = async () => {
.option('--header-text <text>', 'Custom text to include at the beginning of the output')
.option('--instruction-file-path <path>', 'Path to file containing custom instructions to include in output')
.option('--include-empty-directories', 'Include folders with no files in directory structure')
.option(
'--include-full-directory-structure',
'Show entire repository tree in the Directory Structure section, even when using --include patterns',
)
.option(
'--no-git-sort-by-changes',
"Don't sort files by git change frequency (default: most changed files first)",
Expand Down
1 change: 1 addition & 0 deletions src/cli/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface CliOptions extends OptionValues {
headerText?: string;
instructionFilePath?: string;
includeEmptyDirectories?: boolean;
includeFullDirectoryStructure?: boolean;
gitSortByChanges?: boolean;
includeDiffs?: boolean;
includeLogs?: boolean;
Expand Down
2 changes: 2 additions & 0 deletions src/config/configSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export const repomixConfigBaseSchema = z.object({
truncateBase64: z.boolean().optional(),
copyToClipboard: z.boolean().optional(),
includeEmptyDirectories: z.boolean().optional(),
includeFullDirectoryStructure: z.boolean().optional(),
tokenCountTree: z.union([z.boolean(), z.number(), z.string()]).optional(),
git: z
.object({
Expand Down Expand Up @@ -100,6 +101,7 @@ export const repomixConfigDefaultSchema = z.object({
truncateBase64: z.boolean().default(false),
copyToClipboard: z.boolean().default(false),
includeEmptyDirectories: z.boolean().optional(),
includeFullDirectoryStructure: z.boolean().default(false),
tokenCountTree: z.union([z.boolean(), z.number(), z.string()]).default(false),
git: z
.object({
Expand Down
92 changes: 91 additions & 1 deletion src/core/file/fileSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,8 +304,8 @@ export const getIgnorePatterns = async (rootDir: string, config: RepomixConfigMe

// Add patterns from .git/info/exclude if useGitignore is enabled
if (config.ignore.useGitignore) {
// Read .git/info/exclude file
const excludeFilePath = path.join(rootDir, '.git', 'info', 'exclude');

try {
const excludeFileContent = await fs.readFile(excludeFilePath, 'utf8');
const excludePatterns = parseIgnoreContent(excludeFileContent);
Expand All @@ -321,3 +321,93 @@ export const getIgnorePatterns = async (rootDir: string, config: RepomixConfigMe

return Array.from(ignorePatterns);
};

/**
* Lists all directories in the given root directory, respecting ignore patterns.
* This function does not apply include patterns - it returns the full directory set subject to ignore rules.
*
* @param rootDir The root directory to scan
* @param config The merged configuration
* @returns Array of directory paths relative to rootDir
*/
export const listDirectories = async (rootDir: string, config: RepomixConfigMerged): Promise<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');
}
}

const directories = await globby(['**/*'], {
cwd: rootDir,
onlyDirectories: true,
absolute: false,
dot: true,
followSymbolicLinks: false,
ignore: [...adjustedIgnorePatterns],
ignoreFiles: [...ignoreFilePatterns],
});

return sortPaths(directories);
};

/**
* Lists all files in the given root directory, respecting ignore patterns.
* This function does not apply include patterns - it returns the full file set subject to ignore rules.
*
* @param rootDir The root directory to scan
* @param config The merged configuration
* @returns Array of file paths relative to rootDir
*/
export const listFiles = async (rootDir: string, config: RepomixConfigMerged): Promise<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');
}
}

const files = await globby(['**/*'], {
cwd: rootDir,
onlyFiles: true,
absolute: false,
dot: true,
followSymbolicLinks: false,
ignore: [...adjustedIgnorePatterns],
ignoreFiles: [...ignoreFilePatterns],
});

return sortPaths(files);
};
57 changes: 49 additions & 8 deletions src/core/output/outputGenerate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { XMLBuilder } from 'fast-xml-parser';
import Handlebars from 'handlebars';
import type { RepomixConfigMerged } from '../../config/configSchema.js';
import { RepomixError } from '../../shared/errorHandle.js';
import { type FileSearchResult, searchFiles } from '../file/fileSearch.js';
import { type FileSearchResult, listDirectories, listFiles, searchFiles } from '../file/fileSearch.js';
import { generateTreeString } from '../file/fileTreeGenerate.js';
import type { ProcessedFile } from '../file/fileTypes.js';
import type { GitDiffResult } from '../git/gitDiffHandle.js';
Expand Down Expand Up @@ -268,6 +268,11 @@ export const buildOutputGeneratorContext = async (
processedFiles: ProcessedFile[],
gitDiffResult: GitDiffResult | undefined = undefined,
gitLogResult: GitLogResult | undefined = undefined,
deps = {
listDirectories,
listFiles,
searchFiles,
},
): Promise<OutputGeneratorContext> => {
let repositoryInstruction = '';

Expand All @@ -280,27 +285,63 @@ export const buildOutputGeneratorContext = async (
}
}

let emptyDirPaths: string[] = [];
if (config.output.includeEmptyDirectories) {
// Determine if full-tree mode applies (only when directory structure is rendered)
const shouldUseFullTree =
config.output.directoryStructure === true &&
!!config.output.includeFullDirectoryStructure &&
(config.include?.length ?? 0) > 0;

// Paths to include in the directory tree visualization
let directoryPathsForTree: string[] = [];
let filePathsForTree: string[] = allFilePaths;

if (shouldUseFullTree) {
try {
emptyDirPaths = (await Promise.all(rootDirs.map((rootDir) => searchFiles(rootDir, config)))).reduce(
// Collect all directories and all files from all roots
const [allDirectoriesByRoot, allFilesByRoot] = await Promise.all([
Promise.all(rootDirs.map((rootDir) => deps.listDirectories(rootDir, config))),
Promise.all(rootDirs.map((rootDir) => deps.listFiles(rootDir, config))),
]);

// Merge, deduplicate, and sort for deterministic output
const allDirectories = Array.from(new Set(allDirectoriesByRoot.flat())).sort();
const allRepoFiles = Array.from(new Set(allFilesByRoot.flat()));

// Merge in any files that weren't part of the included files so they appear in the tree
const includedSet = new Set(allFilePaths);
const additionalFiles = allRepoFiles.filter((p) => !includedSet.has(p));

directoryPathsForTree = allDirectories;
filePathsForTree = Array.from(new Set([...allFilePaths, ...additionalFiles]));
} catch (error) {
throw new RepomixError(
`Failed to build full directory structure: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? { cause: error } : undefined,
);
}
} else if (config.output.directoryStructure && config.output.includeEmptyDirectories) {
// Default behavior: include empty directories only
try {
const merged = (await Promise.all(rootDirs.map((rootDir) => deps.searchFiles(rootDir, config)))).reduce(
(acc: FileSearchResult, curr: FileSearchResult) =>
({
filePaths: [...acc.filePaths, ...curr.filePaths],
emptyDirPaths: [...acc.emptyDirPaths, ...curr.emptyDirPaths],
}) as FileSearchResult,
{ filePaths: [], emptyDirPaths: [] },
).emptyDirPaths;
directoryPathsForTree = [...new Set(merged)].sort();
} catch (error) {
if (error instanceof Error) {
throw new RepomixError(`Failed to search for empty directories: ${error.message}`);
}
throw new RepomixError(
`Failed to search for empty directories: ${error instanceof Error ? error.message : String(error)}`,
error instanceof Error ? { cause: error } : undefined,
);
}
}

return {
generationDate: new Date().toISOString(),
treeString: generateTreeString(allFilePaths, emptyDirPaths),
treeString: generateTreeString(filePathsForTree, directoryPathsForTree),
processedFiles,
config,
instruction: repositoryInstruction,
Expand Down
6 changes: 6 additions & 0 deletions src/core/security/workers/secretlint.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
declare module '@secretlint/secretlint-rule-preset-recommend' {
import type { SecretLintRulePresetCreator } from '@secretlint/types';

// Re-export as a preset creator without importing runtime Secretlint modules.
export const creator: SecretLintRulePresetCreator;
}
1 change: 1 addition & 0 deletions tests/cli/actions/workers/defaultActionWorker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ describe('defaultActionWorker', () => {
truncateBase64: false,
copyToClipboard: false,
includeEmptyDirectories: false,
includeFullDirectoryStructure: false,
tokenCountTree: false,
git: {
sortByChanges: true,
Expand Down
2 changes: 2 additions & 0 deletions tests/config/configSchema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ describe('configSchema', () => {
showLineNumbers: false,
truncateBase64: true,
copyToClipboard: true,
includeFullDirectoryStructure: false,
tokenCountTree: '100',
git: {
sortByChanges: true,
Expand Down Expand Up @@ -211,6 +212,7 @@ describe('configSchema', () => {
showLineNumbers: true,
truncateBase64: true,
copyToClipboard: false,
includeFullDirectoryStructure: false,
tokenCountTree: false,
git: {
sortByChanges: true,
Expand Down
1 change: 1 addition & 0 deletions tests/core/metrics/calculateGitDiffMetrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ describe('calculateGitDiffMetrics', () => {
truncateBase64: false,
copyToClipboard: false,
includeEmptyDirectories: false,
includeFullDirectoryStructure: false,
tokenCountTree: false,
git: {
sortByChanges: true,
Expand Down
1 change: 1 addition & 0 deletions tests/core/metrics/calculateGitLogMetrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ describe('calculateGitLogMetrics', () => {
truncateBase64: false,
copyToClipboard: false,
includeEmptyDirectories: false,
includeFullDirectoryStructure: false,
tokenCountTree: false,
git: {
sortByChanges: true,
Expand Down
Loading
Loading