Skip to content
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
77 changes: 77 additions & 0 deletions src/core/file/fileTreeGenerate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,80 @@ export const generateTreeStringWithLineCounts = (
const tree = generateFileTree(files, emptyDirPaths);
return treeToStringWithLineCounts(tree, lineCounts).trim();
};

/**
* Represents files grouped by their root directory.
*/
export interface FilesByRoot {
rootLabel: string;
files: string[];
}

/**
* Internal helper function to generate multi-root tree sections.
* Extracts common logic used by both generateTreeStringWithRoots and generateTreeStringWithRootsAndLineCounts.
*
* Note: Empty directories (emptyDirPaths) are not included in multi-root output.
* This is because emptyDirPaths would need to be filtered per-root to avoid cross-root
* contamination, which would require additional complexity. For most use cases,
* empty directories are less important in multi-root scenarios.
*/
const generateMultiRootSections = (
filesByRoot: FilesByRoot[],
treeToStringFn: (tree: TreeNode, prefix: string) => string,
): string => {
const sections: string[] = [];

for (const { rootLabel, files } of filesByRoot) {
if (files.length === 0) {
continue;
}

const tree = generateFileTree(files);
const treeContent = treeToStringFn(tree, ' ');
sections.push(`[${rootLabel}]/\n${treeContent}`);
}

return sections.join('\n').trim();
};

/**
* Generates a tree string with root directory labels when multiple roots are provided.
* For single root, returns the standard flat tree.
* For multiple roots, each section is labeled with [rootLabel]/.
*
* @param filesByRoot Array of root directories with their files
* @param emptyDirPaths Optional paths to empty directories
*/
export const generateTreeStringWithRoots = (filesByRoot: FilesByRoot[], emptyDirPaths: string[] = []): string => {
// Single root: use existing behavior without labels
if (filesByRoot.length === 1) {
return generateTreeString(filesByRoot[0].files, emptyDirPaths);
}

// Multiple roots: generate labeled sections
return generateMultiRootSections(filesByRoot, (tree, prefix) => treeToString(tree, prefix));
};

/**
* Generates a tree string with root directory labels and line counts.
* For single root, returns the standard flat tree with line counts.
* For multiple roots, each section is labeled with [rootLabel]/.
*
* @param filesByRoot Array of root directories with their files
* @param lineCounts Map of file paths to line counts
* @param emptyDirPaths Optional paths to empty directories
*/
export const generateTreeStringWithRootsAndLineCounts = (
filesByRoot: FilesByRoot[],
lineCounts: Record<string, number>,
emptyDirPaths: string[] = [],
): string => {
// Single root: use existing behavior without labels
if (filesByRoot.length === 1) {
return generateTreeStringWithLineCounts(filesByRoot[0].files, lineCounts, emptyDirPaths);
}

// Multiple roots: generate labeled sections
return generateMultiRootSections(filesByRoot, (tree, prefix) => treeToStringWithLineCounts(tree, lineCounts, prefix));
};
17 changes: 15 additions & 2 deletions src/core/output/outputGenerate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import Handlebars from 'handlebars';
import type { RepomixConfigMerged } from '../../config/configSchema.js';
import { RepomixError } from '../../shared/errorHandle.js';
import { type FileSearchResult, listDirectories, listFiles, searchFiles } from '../file/fileSearch.js';
import { generateTreeString } from '../file/fileTreeGenerate.js';
import { type FilesByRoot, generateTreeString, generateTreeStringWithRoots } from '../file/fileTreeGenerate.js';
import type { ProcessedFile } from '../file/fileTypes.js';
import type { GitDiffResult } from '../git/gitDiffHandle.js';
import type { GitLogResult } from '../git/gitLogHandle.js';
Expand Down Expand Up @@ -257,6 +257,7 @@ export const generateOutput = async (
allFilePaths: string[],
gitDiffResult: GitDiffResult | undefined = undefined,
gitLogResult: GitLogResult | undefined = undefined,
filePathsByRoot?: FilesByRoot[],
deps = {
buildOutputGeneratorContext,
generateHandlebarOutput,
Expand All @@ -275,6 +276,7 @@ export const generateOutput = async (
sortedProcessedFiles,
gitDiffResult,
gitLogResult,
filePathsByRoot,
);
const renderContext = createRenderContext(outputGeneratorContext);

Expand All @@ -300,6 +302,7 @@ export const buildOutputGeneratorContext = async (
processedFiles: ProcessedFile[],
gitDiffResult: GitDiffResult | undefined = undefined,
gitLogResult: GitLogResult | undefined = undefined,
filePathsByRoot?: FilesByRoot[],
deps = {
listDirectories,
listFiles,
Expand Down Expand Up @@ -371,9 +374,19 @@ export const buildOutputGeneratorContext = async (
}
}

// Generate tree string - use multi-root format if filePathsByRoot is provided
// generateTreeStringWithRoots handles single root case internally
let treeString: string;
if (filePathsByRoot) {
treeString = generateTreeStringWithRoots(filePathsByRoot, directoryPathsForTree);
} else {
// Fallback for when root info is not available
treeString = generateTreeString(filePathsForTree, directoryPathsForTree);
}

return {
generationDate: new Date().toISOString(),
treeString: generateTreeString(filePathsForTree, directoryPathsForTree),
treeString,
processedFiles,
config,
instruction: repositoryInstruction,
Expand Down
7 changes: 7 additions & 0 deletions src/core/output/outputSplit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import pc from 'picocolors';
import type { RepomixConfigMerged } from '../../config/configSchema.js';
import { RepomixError } from '../../shared/errorHandle.js';
import type { RepomixProgressCallback } from '../../shared/types.js';
import type { FilesByRoot } from '../file/fileTreeGenerate.js';
import type { ProcessedFile } from '../file/fileTypes.js';
import type { GitDiffResult } from '../git/gitDiffHandle.js';
import type { GitLogResult } from '../git/gitLogHandle.js';
Expand Down Expand Up @@ -99,6 +100,7 @@ const renderGroups = async (
baseConfig: RepomixConfigMerged,
gitDiffResult: GitDiffResult | undefined,
gitLogResult: GitLogResult | undefined,
filePathsByRoot: FilesByRoot[] | undefined,
generateOutput: GenerateOutputFn,
): Promise<string> => {
const chunkProcessedFiles = groupsToRender.flatMap((g) => g.processedFiles);
Expand All @@ -112,6 +114,7 @@ const renderGroups = async (
chunkAllFilePaths,
partIndex === 1 ? gitDiffResult : undefined,
partIndex === 1 ? gitLogResult : undefined,
filePathsByRoot,
);
};

Expand All @@ -124,6 +127,7 @@ export const generateSplitOutputParts = async ({
gitDiffResult,
gitLogResult,
progressCallback,
filePathsByRoot,
deps,
}: {
rootDirs: string[];
Expand All @@ -134,6 +138,7 @@ export const generateSplitOutputParts = async ({
gitDiffResult: GitDiffResult | undefined;
gitLogResult: GitLogResult | undefined;
progressCallback: RepomixProgressCallback;
filePathsByRoot?: FilesByRoot[];
deps: {
generateOutput: GenerateOutputFn;
};
Expand Down Expand Up @@ -172,6 +177,7 @@ export const generateSplitOutputParts = async ({
baseConfig,
gitDiffResult,
gitLogResult,
filePathsByRoot,
deps.generateOutput,
);
const nextBytes = getUtf8ByteLength(nextContent);
Expand Down Expand Up @@ -208,6 +214,7 @@ export const generateSplitOutputParts = async ({
baseConfig,
gitDiffResult,
gitLogResult,
filePathsByRoot,
deps.generateOutput,
);
const singleGroupBytes = getUtf8ByteLength(singleGroupContent);
Expand Down
11 changes: 11 additions & 0 deletions src/core/packager.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import path from 'node:path';
import type { RepomixConfigMerged } from '../config/configSchema.js';
import { logMemoryUsage, withMemoryLogging } from '../shared/memoryUtils.js';
import type { RepomixProgressCallback } from '../shared/types.js';
import { collectFiles, type SkippedFileInfo } from './file/fileCollect.js';
import { sortPaths } from './file/filePathSort.js';
import { processFiles } from './file/fileProcess.js';
import { searchFiles } from './file/fileSearch.js';
import type { FilesByRoot } from './file/fileTreeGenerate.js';
import type { ProcessedFile } from './file/fileTypes.js';
import { getGitDiffs } from './git/gitDiffHandle.js';
import { getGitLogs } from './git/gitLogHandle.js';
Expand Down Expand Up @@ -147,6 +149,14 @@ export const pack = async (
return result;
}

// Build filePathsByRoot for multi-root tree generation
// Use directory basename as the label for each root
// Fallback to rootDir if basename is empty (e.g., filesystem root "/")
const filePathsByRoot: FilesByRoot[] = sortedFilePathsByDir.map(({ rootDir, filePaths }) => ({
rootLabel: path.basename(rootDir) || rootDir,
files: filePaths,
}));

// Generate and write output (handles both single and split output)
const { outputFiles, outputForMetrics } = await deps.produceOutput(
rootDirs,
Expand All @@ -156,6 +166,7 @@ export const pack = async (
gitDiffResult,
gitLogResult,
progressCallback,
filePathsByRoot,
);

const metrics = await withMemoryLogging('Calculate Metrics', () =>
Expand Down
9 changes: 8 additions & 1 deletion src/core/packager/produceOutput.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { RepomixConfigMerged } from '../../config/configSchema.js';
import { withMemoryLogging } from '../../shared/memoryUtils.js';
import type { RepomixProgressCallback } from '../../shared/types.js';
import type { FilesByRoot } from '../file/fileTreeGenerate.js';
import type { ProcessedFile } from '../file/fileTypes.js';
import type { GitDiffResult } from '../git/gitDiffHandle.js';
import type { GitLogResult } from '../git/gitLogHandle.js';
Expand Down Expand Up @@ -28,6 +29,7 @@ export const produceOutput = async (
gitDiffResult: GitDiffResult | undefined,
gitLogResult: GitLogResult | undefined,
progressCallback: RepomixProgressCallback,
filePathsByRoot?: FilesByRoot[],
overrideDeps: Partial<typeof defaultDeps> = {},
): Promise<ProduceOutputResult> => {
const deps = { ...defaultDeps, ...overrideDeps };
Expand All @@ -44,6 +46,7 @@ export const produceOutput = async (
gitDiffResult,
gitLogResult,
progressCallback,
filePathsByRoot,
deps,
);
}
Expand All @@ -56,6 +59,7 @@ export const produceOutput = async (
gitDiffResult,
gitLogResult,
progressCallback,
filePathsByRoot,
deps,
);
};
Expand All @@ -69,6 +73,7 @@ const generateAndWriteSplitOutput = async (
gitDiffResult: GitDiffResult | undefined,
gitLogResult: GitLogResult | undefined,
progressCallback: RepomixProgressCallback,
filePathsByRoot: FilesByRoot[] | undefined,
deps: typeof defaultDeps,
): Promise<ProduceOutputResult> => {
const parts = await withMemoryLogging('Generate Split Output', async () => {
Expand All @@ -81,6 +86,7 @@ const generateAndWriteSplitOutput = async (
gitDiffResult,
gitLogResult,
progressCallback,
filePathsByRoot,
deps: {
generateOutput: deps.generateOutput,
},
Expand Down Expand Up @@ -117,10 +123,11 @@ const generateAndWriteSingleOutput = async (
gitDiffResult: GitDiffResult | undefined,
gitLogResult: GitLogResult | undefined,
progressCallback: RepomixProgressCallback,
filePathsByRoot: FilesByRoot[] | undefined,
deps: typeof defaultDeps,
): Promise<ProduceOutputResult> => {
const output = await withMemoryLogging('Generate Output', () =>
deps.generateOutput(rootDirs, config, processedFiles, allFilePaths, gitDiffResult, gitLogResult),
deps.generateOutput(rootDirs, config, processedFiles, allFilePaths, gitDiffResult, gitLogResult, filePathsByRoot),
);

progressCallback('Writing output file...');
Expand Down
90 changes: 90 additions & 0 deletions tests/core/file/fileTreeGenerate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, expect, test } from 'vitest';
import {
type FilesByRoot,
generateTreeString,
generateTreeStringWithRoots,
} from '../../../src/core/file/fileTreeGenerate.js';

describe('fileTreeGenerate', () => {
describe('generateTreeString', () => {
test('generates a flat tree for single directory files', () => {
const files = ['file1.txt', 'file2.txt', 'subdir/nested.txt'];
const result = generateTreeString(files);

expect(result).toContain('file1.txt');
expect(result).toContain('file2.txt');
expect(result).toContain('subdir/');
expect(result).toContain('nested.txt');
});
});

describe('generateTreeStringWithRoots', () => {
test('returns standard flat tree for single root', () => {
const filesByRoot: FilesByRoot[] = [{ rootLabel: 'project', files: ['file1.txt', 'file2.txt'] }];

const result = generateTreeStringWithRoots(filesByRoot);

// Should not have root label for single root
expect(result).not.toContain('[project]');
expect(result).toContain('file1.txt');
expect(result).toContain('file2.txt');
});

test('generates labeled sections for multiple roots', () => {
const filesByRoot: FilesByRoot[] = [
{ rootLabel: 'cli', files: ['cliRun.ts', 'types.ts'] },
{ rootLabel: 'config', files: ['configLoad.ts', 'configSchema.ts'] },
];

const result = generateTreeStringWithRoots(filesByRoot);

// Should have root labels
expect(result).toContain('[cli]/');
expect(result).toContain('[config]/');

// Should have files under each label
expect(result).toContain('cliRun.ts');
expect(result).toContain('types.ts');
expect(result).toContain('configLoad.ts');
expect(result).toContain('configSchema.ts');
});

test('generates labeled sections with nested directories', () => {
const filesByRoot: FilesByRoot[] = [
{ rootLabel: 'src', files: ['index.ts', 'utils/helper.ts', 'utils/format.ts'] },
{ rootLabel: 'tests', files: ['index.test.ts'] },
];

const result = generateTreeStringWithRoots(filesByRoot);

expect(result).toContain('[src]/');
expect(result).toContain('[tests]/');
expect(result).toContain('utils/');
expect(result).toContain('helper.ts');
});

test('skips empty root sections', () => {
const filesByRoot: FilesByRoot[] = [
{ rootLabel: 'cli', files: ['cliRun.ts'] },
{ rootLabel: 'empty', files: [] },
{ rootLabel: 'config', files: ['configLoad.ts'] },
];

const result = generateTreeStringWithRoots(filesByRoot);

expect(result).toContain('[cli]/');
expect(result).not.toContain('[empty]/');
expect(result).toContain('[config]/');
});

test('handles single root falling back to standard behavior', () => {
const filesByRoot: FilesByRoot[] = [{ rootLabel: 'project', files: ['a.txt', 'b/c.txt'] }];

const singleRootResult = generateTreeStringWithRoots(filesByRoot);
const standardResult = generateTreeString(['a.txt', 'b/c.txt']);

// Should be identical
expect(singleRootResult).toBe(standardResult);
});
});
});
Loading
Loading