Skip to content
Open
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,7 @@ Instruction
- `--parsable-style`: Escape special characters to ensure valid XML/Markdown (needed when output contains code that breaks formatting)
- `--compress`: Extract essential code structure (classes, functions, interfaces) using Tree-sitter parsing
- `--output-show-line-numbers`: Prefix each line with its line number in the output
- `--output-show-git-blame`: Show git blame information (author, date) for each line
- `--no-file-summary`: Omit the file summary section from output
- `--no-directory-structure`: Omit the directory tree visualization from output
- `--no-files`: Generate metadata only without file contents (useful for repository analysis)
Expand Down Expand Up @@ -1302,6 +1303,7 @@ Here's an explanation of the configuration options:
| `output.git.includeDiffs` | Whether to include git diffs in the output (includes both work tree and staged changes separately) | `false` |
| `output.git.includeLogs` | Whether to include git logs in the output (includes commit history with dates, messages, and file paths) | `false` |
| `output.git.includeLogsCount` | Number of git log commits to include | `50` |
| `output.git.showBlame` | Whether to include git blame information for each line | `false` |
| `include` | Patterns of files to include (using [glob patterns](https://github.com/mrmlnc/fast-glob?tab=readme-ov-file#pattern-syntax)) | `[]` |
| `ignore.useGitignore` | Whether to use patterns from the project's `.gitignore` file | `true` |
| `ignore.useDotIgnore` | Whether to use patterns from the project's `.ignore` file | `true` |
Expand Down
28 changes: 28 additions & 0 deletions src/cli/actions/defaultAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,23 @@ export const runDefaultAction = async (
const config: RepomixConfigMerged = mergeConfigs(cwd, fileConfig, cliConfig);
logger.trace('Merged config:', config);


if (config.output.git?.showBlame) {
const incompatibleOptions: string[] = [];
if (config.output.compress) incompatibleOptions.push('compress');
if (config.output.removeComments) incompatibleOptions.push('removeComments');
if (config.output.removeEmptyLines) incompatibleOptions.push('removeEmptyLines');

if (incompatibleOptions.length > 0) {
logger.warn(
`Git blame is enabled. The following options will be ignored for files with blame info: ${incompatibleOptions.join(', ')}.`,
);
logger.warn(
'This is because git blame modifies the file content structure, making it incompatible with these processing steps.',
);
}
}

// Validate conflicting options
validateConflictingOptions(config);

Expand All @@ -67,6 +84,7 @@ export const runDefaultAction = async (
if (!cliOptions.skillDir) {
const promptResult = await promptSkillLocation(cliOptions.skillName, cwd);
cliOptions.skillDir = promptResult.skillDir;

}
}

Expand Down Expand Up @@ -308,6 +326,16 @@ export const buildCliConfig = (options: CliOptions): RepomixConfigCli => {
};
}

if (options.outputShowGitBlame) {
cliConfig.output = {
...cliConfig.output,
git: {
...cliConfig.output?.git,
showBlame: true,
},
};
}

if (options.tokenCountTree !== undefined) {
cliConfig.output = {
...cliConfig.output,
Expand Down
1 change: 1 addition & 0 deletions src/cli/cliRun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ export const run = async () => {
return Number(v);
},
)
.option('--output-show-git-blame', 'Show git blame information in the output')
// File Selection Options
.optionsGroup('File Selection Options')
.option(
Expand Down
1 change: 1 addition & 0 deletions src/cli/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export interface CliOptions extends OptionValues {
includeDiffs?: boolean;
includeLogs?: boolean;
includeLogsCount?: number;
outputShowGitBlame?: boolean;

// Filter Options
include?: string;
Expand Down
2 changes: 2 additions & 0 deletions src/config/configSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export const repomixConfigBaseSchema = z.object({
sortByChanges: z.boolean().optional(),
sortByChangesMaxCommits: z.number().optional(),
includeDiffs: z.boolean().optional(),
showBlame: z.boolean().optional(),
includeLogs: z.boolean().optional(),
includeLogsCount: z.number().optional(),
})
Expand Down Expand Up @@ -108,6 +109,7 @@ export const repomixConfigDefaultSchema = z.object({
sortByChangesMaxCommits: z.number().int().min(1).default(100),
includeDiffs: z.boolean().default(false),
includeLogs: z.boolean().default(false),
showBlame: z.boolean().default(false),
includeLogsCount: z.number().int().min(1).default(50),
}),
}),
Expand Down
18 changes: 15 additions & 3 deletions src/core/file/fileProcessContent.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { RepomixConfigMerged } from '../../config/configSchema.js';
import { logger } from '../../shared/logger.js';
import { getGitBlame } from '../git/gitBlameHandle.js';
import { parseFile } from '../treeSitter/parseFile.js';
import { getFileManipulator } from './fileManipulate.js';
import type { RawFile } from './fileTypes.js';
Expand All @@ -22,24 +23,35 @@ export const processContent = async (rawFile: RawFile, config: RepomixConfigMerg
const processStartAt = process.hrtime.bigint();
let processedContent = rawFile.content;
const manipulator = getFileManipulator(rawFile.path);
let isBlameApplied = false;

logger.trace(`Processing file: ${rawFile.path}`);
if (config.output.git?.showBlame) {
const blame = await getGitBlame(config.cwd, rawFile.path);
if (blame) {
processedContent = blame;
isBlameApplied = true;
}
}

if (config.output.truncateBase64) {
processedContent = truncateBase64Content(processedContent);
}

if (manipulator && config.output.removeComments) {
// Skip comment removal if blame is applied, as the content structure is modified
if (manipulator && config.output.removeComments && !isBlameApplied) {
processedContent = manipulator.removeComments(processedContent);
}

if (config.output.removeEmptyLines && manipulator) {
// Skip empty line removal if blame is applied, as lines are no longer empty (they have blame info)
if (config.output.removeEmptyLines && manipulator && !isBlameApplied) {
processedContent = manipulator.removeEmptyLines(processedContent);
}

processedContent = processedContent.trim();

if (config.output.compress) {
// Skip compression if blame is applied, as it breaks the syntax required for parsing
if (config.output.compress && !isBlameApplied) {
try {
const parsedContent = await parseFile(processedContent, rawFile.path, config);
if (parsedContent === undefined) {
Expand Down
79 changes: 79 additions & 0 deletions src/core/git/gitBlameHandle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { logger } from '../../shared/logger.js';
import { execGitBlame } from './gitCommand.js';
import { isGitRepository } from './gitRepositoryHandle.js';

/**
* Formats the output of 'git blame --porcelain' into readable annotated lines.
* @param blameOutput - Raw output from 'git blame --porcelain'
* @returns Formatted string with each line annotated by author and date.
*/
function formatGitBlame(blameOutput: string): string {
const lines = blameOutput.split('\n');
const formattedLines: string[] = [];
let currentAuthor = 'N/A';
let currentDate = 'N/A';

for (const line of lines) {
if (!line) continue;

if (/^[a-f0-9]{40}/.test(line)) {
continue;
}

if (line.startsWith('author ')) {
currentAuthor = line.substring('author '.length);
continue;
}
if (line.startsWith('author-time ')) {
const timestamp = parseInt(line.substring('author-time '.length), 10);
if (!Number.isNaN(timestamp)) {
currentDate = new Date(timestamp * 1000).toISOString().split('T')[0];
}
continue;
}

if (line.startsWith('\t')) {
const codeLine = line.substring(1);
const formattedLine = codeLine
? `[${currentAuthor} ${currentDate}] ${codeLine}`
: `[${currentAuthor} ${currentDate}]`;
formattedLines.push(formattedLine);
}
}

return formattedLines.join('\n');
}

/**
* Retrieves and formats git blame information for a file
* @param directory - The repository directory
* @param filePath - Path to the file
* @param deps - Dependencies
* @returns Formatted blame string or null if failed/skipped
*/
export const getGitBlame = async (
directory: string,
filePath: string,
deps = {
execGitBlame,
isGitRepository,
},
): Promise<string | null> => {
if (!(await deps.isGitRepository(directory))) {
logger.trace(`Directory ${directory} is not a git repository, skipping git blame`);
return null;
}

try {
const blameOutput = await deps.execGitBlame(directory, filePath);

if (!blameOutput) {
return null;
}

return formatGitBlame(blameOutput);
} catch (error) {
logger.trace(`Failed to get git blame for ${filePath}:`, (error as Error).message);
return null;
}
};
24 changes: 24 additions & 0 deletions src/core/git/gitCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,3 +202,27 @@ export const validateGitUrl = (url: string): void => {
throw new RepomixError(`Invalid repository URL. Please provide a valid URL: ${redactedUrl}`);
}
};

/**
* Executes git blame for a specific file
* @param directory - The repository directory
* @param filePath - Path to the file to blame
* @param deps - Dependencies
* @returns The raw git blame output
*/
export const execGitBlame = async (
directory: string,
filePath: string,
deps = {
execFileAsync,
},
): Promise<string> => {
try {
const result = await deps.execFileAsync('git', ['-C', directory, 'blame', '--porcelain', '-w', filePath]);

return result.stdout || '';
} catch (error) {
logger.trace(`Failed to run git blame on ${filePath} in ${directory}:`, (error as Error).message);
return '';
}
};
65 changes: 65 additions & 0 deletions tests/cli/actions/defaultAction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import * as fileStdin from '../../../src/core/file/fileStdin.js';
import * as packageJsonParser from '../../../src/core/file/packageJsonParse.js';
import * as packager from '../../../src/core/packager.js';

import { logger } from '../../../src/shared/logger.js';
import * as processConcurrency from '../../../src/shared/processConcurrency.js';
import { createMockConfig } from '../../testing/testUtils.js';

Expand Down Expand Up @@ -139,6 +140,61 @@ describe('defaultAction', () => {
vi.resetAllMocks();
});

it('should log a warning when showBlame is true and incompatible options are enabled', async () => {
const warnSpy = vi.spyOn(logger, 'warn');

vi.mocked(configLoader.mergeConfigs).mockReturnValue(
createMockConfig({
cwd: process.cwd(),
input: {
maxFileSize: 50 * 1024 * 1024,
},
output: {
filePath: 'output.txt',
style: 'plain',
parsableStyle: false,
fileSummary: true,
directoryStructure: true,
topFilesLength: 5,
showLineNumbers: false,
removeComments: true, // Incompatible option
removeEmptyLines: true, // Incompatible option
compress: true, // Incompatible option
copyToClipboard: false,
stdout: false,
git: {
sortByChanges: true,
sortByChangesMaxCommits: 100,
includeDiffs: false,
showBlame: true, // Enabled blame
},
files: true,
},
ignore: {
useGitignore: true,
useDefaultPatterns: true,
customPatterns: [],
},
include: [],
security: {
enableSecurityCheck: true,
},
tokenCount: {
encoding: 'o200k_base',
},
}),
);

await runDefaultAction(['.'], process.cwd(), {});

expect(warnSpy).toHaveBeenCalledWith(
'Git blame is enabled. The following options will be ignored for files with blame info: compress, removeComments, removeEmptyLines.',
);
expect(warnSpy).toHaveBeenCalledWith(
'This is because git blame modifies the file content structure, making it incompatible with these processing steps.',
);
});

it('should run the default command successfully', async () => {
const options: CliOptions = {
output: 'custom-output.txt',
Expand Down Expand Up @@ -305,6 +361,15 @@ describe('defaultAction', () => {
expect(config.ignore?.useDefaultPatterns).toBe(false);
});


it('should set showBlame to true when outputShowGitBlame option is provided', () => {
const options = {
outputShowGitBlame: true,
};
const config = buildCliConfig(options);
expect(config.output?.git?.showBlame).toBe(true);
});

it('should handle --skill-generate with string name', () => {
const options: CliOptions = {
skillGenerate: 'my-skill',
Expand Down
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 @@ -59,6 +59,7 @@ describe('defaultActionWorker', () => {
includeDiffs: false,
includeLogs: false,
includeLogsCount: 50,
showBlame: false,
},
},
include: ['**/*'],
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 @@ -119,6 +119,7 @@ describe('configSchema', () => {
sortByChanges: true,
sortByChangesMaxCommits: 100,
includeDiffs: false,
showBlame: false,
includeLogs: false,
includeLogsCount: 50,
},
Expand Down Expand Up @@ -224,6 +225,7 @@ describe('configSchema', () => {
sortByChanges: true,
sortByChangesMaxCommits: 100,
includeDiffs: false,
showBlame: false,
includeLogs: false,
includeLogsCount: 50,
},
Expand Down
Loading