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
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,21 @@ When using `--stdin`, the specified files are effectively added to the include p
> [!NOTE]
> When using `--stdin`, file paths can be relative or absolute, and Repomix will automatically handle path resolution and deduplication.

To include git logs in the output:

```bash
# Include git logs with default count (50 commits)
repomix --include-logs

# Include git logs with specific commit count
repomix --include-logs --include-logs-count 10

# Combine with diffs for comprehensive git context
repomix --include-logs --include-diffs
```

The git logs include commit dates, messages, and file paths for each commit, providing valuable context for AI analysis of code evolution and development patterns.

To compress the output:

```bash
Expand Down Expand Up @@ -537,6 +552,8 @@ Instruction
- `--include-empty-directories`: Include empty directories in the output
- `--no-git-sort-by-changes`: Disable sorting files by git change count (enabled by default)
- `--include-diffs`: Include git diffs in the output (includes both work tree and staged changes separately)
- `--include-logs`: Include git logs in the output (includes commit history with dates, messages, and file paths)
- `--include-logs-count <count>`: Number of git log commits to include (default: 50)

#### File Selection Options
- `--include <patterns>`: List of include patterns (comma-separated)
Expand Down Expand Up @@ -932,6 +949,8 @@ Here's an explanation of the configuration options:
| `output.git.sortByChanges` | Whether to sort files by git change count (files with more changes appear at the bottom) | `true` |
| `output.git.sortByChangesMaxCommits` | Maximum number of commits to analyze for git changes | `100` |
| `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` |
| `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.useDefaultPatterns` | Whether to use default ignore patterns | `true` |
Expand Down Expand Up @@ -972,7 +991,9 @@ Example configuration:
"git": {
"sortByChanges": true,
"sortByChangesMaxCommits": 100,
"includeDiffs": false
"includeDiffs": false,
"includeLogs": false,
"includeLogsCount": 50
}
},
"include": ["**/*"],
Expand Down
4 changes: 3 additions & 1 deletion repomix.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@
"git": {
"sortByChanges": true,
"sortByChangesMaxCommits": 100,
"includeDiffs": true
"includeDiffs": true,
"includeLogs": true,
"includeLogsCount": 50
}
},
"include": [],
Expand Down
14 changes: 14 additions & 0 deletions src/cli/actions/defaultAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,20 @@ export const buildCliConfig = (options: CliOptions): RepomixConfigCli => {
};
}

// Configure git logs inclusion and count - consolidating related git log options
if (options.includeLogs || options.includeLogsCount !== undefined) {
const gitLogConfig = {
...cliConfig.output?.git,
...(options.includeLogs && { includeLogs: true }),
...(options.includeLogsCount !== undefined && { includeLogsCount: options.includeLogsCount }),
};

cliConfig.output = {
...cliConfig.output,
git: gitLogConfig,
};
}

if (options.tokenCountTree !== undefined) {
cliConfig.output = {
...cliConfig.output,
Expand Down
40 changes: 27 additions & 13 deletions src/cli/cliReport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,13 @@ export const reportResults = (cwd: string, packResult: PackResult, config: Repom
logger.log('');
}

reportSecurityCheck(cwd, packResult.suspiciousFilesResults, packResult.suspiciousGitDiffResults, config);
reportSecurityCheck(
cwd,
packResult.suspiciousFilesResults,
packResult.suspiciousGitDiffResults,
packResult.suspiciousGitLogResults,
config,
);
logger.log('');

reportSummary(packResult, config);
Expand Down Expand Up @@ -75,6 +81,7 @@ export const reportSecurityCheck = (
rootDir: string,
suspiciousFilesResults: SuspiciousFileResult[],
suspiciousGitDiffResults: SuspiciousFileResult[],
suspiciousGitLogResults: SuspiciousFileResult[],
config: RepomixConfigMerged,
) => {
if (!config.security.enableSecurityCheck) {
Expand All @@ -100,19 +107,26 @@ export const reportSecurityCheck = (
logger.log(pc.yellow('Please review these files for potential sensitive information.'));
}

// Report results for git diffs
if (suspiciousGitDiffResults.length > 0) {
logger.log('');
logger.log(pc.yellow(`${suspiciousGitDiffResults.length} security issue(s) found in Git diffs:`));
suspiciousGitDiffResults.forEach((suspiciousResult, index) => {
logger.log(`${pc.white(`${index + 1}.`)} ${pc.white(suspiciousResult.filePath)}`);
const issueCount = suspiciousResult.messages.length;
const issueText = issueCount === 1 ? 'security issue' : 'security issues';
logger.log(pc.dim(` - ${issueCount} ${issueText} detected`));
});
logger.log(pc.yellow('\nNote: Git diffs with security issues are still included in the output.'));
logger.log(pc.yellow('Please review the diffs before sharing the output.'));
// Report git-related security issues
reportSuspiciousGitContent('Git diffs', suspiciousGitDiffResults);
reportSuspiciousGitContent('Git logs', suspiciousGitLogResults);
};

const reportSuspiciousGitContent = (title: string, results: SuspiciousFileResult[]) => {
if (results.length === 0) {
return;
}

logger.log('');
logger.log(pc.yellow(`${results.length} security issue(s) found in ${title}:`));
results.forEach((suspiciousResult, index) => {
logger.log(`${pc.white(`${index + 1}.`)} ${pc.white(suspiciousResult.filePath)}`);
const issueCount = suspiciousResult.messages.length;
const issueText = issueCount === 1 ? 'security issue' : 'security issues';
logger.log(pc.dim(` - ${issueCount} ${issueText} detected`));
});
logger.log(pc.yellow(`\nNote: ${title} with security issues are still included in the output.`));
logger.log(pc.yellow(`Please review the ${title.toLowerCase()} before sharing the output.`));
};

export const reportTopFiles = (
Expand Down
5 changes: 5 additions & 0 deletions src/cli/cliRun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ export const run = async () => {
'--include-diffs',
'include git diffs in the output (includes both work tree and staged changes separately)',
)
.option(
'--include-logs',
'include git logs in the output (includes commit history with dates, messages, and file paths)',
)
.option('--include-logs-count <count>', 'number of git log commits to include (default: 50)', Number.parseInt)
// File Selection Options
.optionsGroup('File Selection Options')
.option('--include <patterns>', 'list of include patterns (comma-separated)')
Expand Down
2 changes: 2 additions & 0 deletions src/cli/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export interface CliOptions extends OptionValues {
includeEmptyDirectories?: boolean;
gitSortByChanges?: boolean;
includeDiffs?: boolean;
includeLogs?: boolean;
includeLogsCount?: number;

// Filter Options
include?: string;
Expand Down
4 changes: 4 additions & 0 deletions src/config/configSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ export const repomixConfigBaseSchema = z.object({
sortByChanges: z.boolean().optional(),
sortByChangesMaxCommits: z.number().optional(),
includeDiffs: z.boolean().optional(),
includeLogs: z.boolean().optional(),
includeLogsCount: z.number().optional(),
})
.optional(),
})
Expand Down Expand Up @@ -103,6 +105,8 @@ export const repomixConfigDefaultSchema = z.object({
sortByChanges: z.boolean().default(true),
sortByChangesMaxCommits: z.number().int().min(1).default(100),
includeDiffs: z.boolean().default(false),
includeLogs: z.boolean().default(false),
includeLogsCount: z.number().int().min(1).default(50),
})
.default({}),
})
Expand Down
27 changes: 27 additions & 0 deletions src/core/git/gitCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,33 @@ export const execGitShallowClone = async (
await fs.rm(path.join(directory, '.git'), { recursive: true, force: true });
};

export const execGitLog = async (
directory: string,
maxCommits: number,
gitSeparator: string,
deps = {
execFileAsync,
},
): Promise<string> => {
try {
const result = await deps.execFileAsync('git', [
'-C',
directory,
'log',
`--pretty=format:${gitSeparator}%ad|%s`,
'--date=iso',
'--name-only',
'-n',
maxCommits.toString(),
]);

return result.stdout || '';
} catch (error) {
logger.trace('Failed to execute git log:', (error as Error).message);
throw error;
}
};

/**
* Validates a Git URL for security and format
* @throws {RepomixError} If the URL is invalid or contains potentially dangerous parameters
Expand Down
114 changes: 114 additions & 0 deletions src/core/git/gitLogHandle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import type { RepomixConfigMerged } from '../../config/configSchema.js';
import { RepomixError } from '../../shared/errorHandle.js';
import { logger } from '../../shared/logger.js';
import { execGitLog } from './gitCommand.js';
import { isGitRepository } from './gitRepositoryHandle.js';

// Null character used as record separator in git log output for robust parsing
// This ensures commits are split correctly even when commit messages contain newlines
export const GIT_LOG_RECORD_SEPARATOR = '\x00';

// Git format string for null character separator
// Git expects %x00 format in pretty format strings
export const GIT_LOG_FORMAT_SEPARATOR = '%x00';

export interface GitLogCommit {
date: string;
message: string;
files: string[];
}

export interface GitLogResult {
logContent: string;
commits: GitLogCommit[];
}

const parseGitLog = (rawLogOutput: string, recordSeparator = GIT_LOG_RECORD_SEPARATOR): GitLogCommit[] => {
if (!rawLogOutput.trim()) {
return [];
}

const commits: GitLogCommit[] = [];
// Split by record separator used in git log output
// This is more robust than splitting by double newlines, as commit messages may contain newlines
const logEntries = rawLogOutput.split(recordSeparator).filter(Boolean);

for (const entry of logEntries) {
// Split on both \n and \r\n to handle different line ending formats across platforms
const lines = entry.split(/\r?\n/).filter((line) => line.trim() !== '');
if (lines.length === 0) continue;

// First line contains date and message separated by |
const firstLine = lines[0];
const separatorIndex = firstLine.indexOf('|');
if (separatorIndex === -1) continue;

const date = firstLine.substring(0, separatorIndex);
const message = firstLine.substring(separatorIndex + 1);

// Remaining lines are file paths
const files = lines.slice(1).filter((line) => line.trim() !== '');

commits.push({
date,
message,
files,
});
}

return commits;
};

export const getGitLog = async (
directory: string,
maxCommits: number,
deps = {
execGitLog,
isGitRepository,
},
): Promise<string> => {
if (!(await deps.isGitRepository(directory))) {
logger.trace(`Directory ${directory} is not a git repository`);
return '';
}

try {
return await deps.execGitLog(directory, maxCommits, GIT_LOG_FORMAT_SEPARATOR);
} catch (error) {
logger.trace('Failed to get git log:', (error as Error).message);
throw error;
}
};

export const getGitLogs = async (
rootDirs: string[],
config: RepomixConfigMerged,
deps = {
getGitLog,
},
): Promise<GitLogResult | undefined> => {
// Get git logs if enabled
let gitLogResult: GitLogResult | undefined;

if (config.output.git?.includeLogs) {
try {
// Use the first directory as the git repository root
// Usually this would be the root of the project
const gitRoot = rootDirs[0] || config.cwd;
const maxCommits = config.output.git?.includeLogsCount || 50;
const logContent = await deps.getGitLog(gitRoot, maxCommits);

// Parse the raw log content into structured commits
const commits = parseGitLog(logContent);

gitLogResult = {
logContent,
commits,
};
} catch (error) {
throw new RepomixError(`Failed to get git logs: ${(error as Error).message}`, { cause: error });
}
}

return gitLogResult;
};
60 changes: 60 additions & 0 deletions src/core/metrics/calculateGitLogMetrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { RepomixConfigMerged } from '../../config/configSchema.js';
import { logger } from '../../shared/logger.js';
import { initTaskRunner } from '../../shared/processConcurrency.js';
import type { GitLogResult } from '../git/gitLogHandle.js';
import type { GitLogMetricsTask } from './workers/gitLogMetricsWorker.js';

/**
* Calculate token count for git logs if included
*/
export const calculateGitLogMetrics = async (
config: RepomixConfigMerged,
gitLogResult: GitLogResult | undefined,
deps = {
initTaskRunner,
},
): Promise<{ gitLogTokenCount: number }> => {
// Return zero token count if git logs are disabled or no result
if (!config.output.git?.includeLogs || !gitLogResult) {
return {
gitLogTokenCount: 0,
};
}

// Return zero token count if no git log content
if (!gitLogResult.logContent) {
return {
gitLogTokenCount: 0,
};
}

const taskRunner = deps.initTaskRunner<GitLogMetricsTask, number>(
1, // Single task for git log calculation
new URL('./workers/gitLogMetricsWorker.js', import.meta.url).href,
);

try {
const startTime = process.hrtime.bigint();
logger.trace('Starting git log token calculation using worker');

const result = await taskRunner.run({
content: gitLogResult.logContent,
encoding: config.tokenCount.encoding,
});

const endTime = process.hrtime.bigint();
const duration = Number(endTime - startTime) / 1e6;
logger.trace(`Git log token calculation completed in ${duration.toFixed(2)}ms`);

return {
gitLogTokenCount: result,
};
} catch (error) {
logger.error('Failed to calculate git log metrics:', error);
return {
gitLogTokenCount: 0,
};
} finally {
await taskRunner.cleanup();
}
};
Loading
Loading