Skip to content

Commit

Permalink
feat(output): Add git metrics to output
Browse files Browse the repository at this point in the history
  • Loading branch information
yamadashy committed Feb 3, 2025
1 parent 836abcd commit 34d1dee
Show file tree
Hide file tree
Showing 10 changed files with 465 additions and 9 deletions.
5 changes: 4 additions & 1 deletion repomix.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
"removeEmptyLines": false,
"topFilesLength": 5,
"showLineNumbers": false,
"includeEmptyDirectories": true
"includeEmptyDirectories": true,
"gitMetrics": {
"enable": true
}
},
"include": [],
"ignore": {
Expand Down
10 changes: 10 additions & 0 deletions src/config/configSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ export const defaultFilePathMap: Record<RepomixOutputStyle, string> = {
xml: 'repomix-output.xml',
} as const;

export const repomixGitMetricsSchema = z.object({
enabled: z.boolean().default(false),
maxCommits: z.number().int().min(1).default(100),
});

// Base config schema
export const repomixConfigBaseSchema = z.object({
output: z
Expand All @@ -29,6 +34,7 @@ export const repomixConfigBaseSchema = z.object({
showLineNumbers: z.boolean().optional(),
copyToClipboard: z.boolean().optional(),
includeEmptyDirectories: z.boolean().optional(),
gitMetrics: repomixGitMetricsSchema.optional(),
})
.optional(),
include: z.array(z.string()).optional(),
Expand Down Expand Up @@ -68,6 +74,10 @@ export const repomixConfigDefaultSchema = z.object({
showLineNumbers: z.boolean().default(false),
copyToClipboard: z.boolean().default(false),
includeEmptyDirectories: z.boolean().optional(),
gitMetrics: repomixGitMetricsSchema.default({
enabled: false,
maxCommits: 100,
}),
})
.default({}),
include: z.array(z.string()).default([]),
Expand Down
131 changes: 131 additions & 0 deletions src/core/file/gitMetrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { logger } from '../../shared/logger.js';

const execFileAsync = promisify(execFile);

export interface GitFileMetric {
path: string;
changes: number;
}

export interface GitMetricsResult {
totalCommits: number;
mostChangedFiles: GitFileMetric[];
error?: string;
}

// Check if git is installed
export const isGitInstalled = async (
deps = {
execFileAsync,
},
): Promise<boolean> => {
try {
const result = await deps.execFileAsync('git', ['--version']);
return !result.stderr;
} catch (error) {
logger.debug('Git is not installed:', (error as Error).message);
return false;
}
};

// Check if directory is a git repository
export const isGitRepository = async (
directory: string,
deps = {
execFileAsync,
},
): Promise<boolean> => {
try {
await deps.execFileAsync('git', ['-C', directory, 'rev-parse', '--git-dir']);
return true;
} catch {
return false;
}
};

// Get file change count from git log
export const calculateGitMetrics = async (
rootDir: string,
maxCommits: number,
deps = {
execFileAsync,
isGitInstalled,
isGitRepository,
},
): Promise<GitMetricsResult> => {
try {
// Check if git is installed
if (!(await deps.isGitInstalled())) {
return {
totalCommits: 0,
mostChangedFiles: [],
error: 'Git is not installed',
};
}

// Check if directory is a git repository
if (!(await deps.isGitRepository(rootDir))) {
return {
totalCommits: 0,
mostChangedFiles: [],
error: 'Not a Git repository',
};
}

// Get file changes from git log
const { stdout } = await deps.execFileAsync('git', [
'-C',
rootDir,
'log',
'--name-only',
'--pretty=format:',
`-n ${maxCommits}`,
]);

// Process the output
const files = stdout
.split('\n')
.filter(Boolean)
.reduce<Record<string, number>>((acc, file) => {
acc[file] = (acc[file] || 0) + 1;
return acc;
}, {});

// Convert to array and sort
const sortedFiles = Object.entries(files)
.map(
([path, changes]): GitFileMetric => ({
path,
changes,
}),
)
.sort((a, b) => b.changes - a.changes)
.slice(0, 5); // Get top 5 most changed files

// Get total number of commits
const { stdout: commitCountStr } = await deps.execFileAsync('git', [
'-C',
rootDir,
'rev-list',
'--count',
'HEAD',
`-n ${maxCommits}`,
]);

const totalCommits = Math.min(Number.parseInt(commitCountStr.trim(), 10), maxCommits);

return {
totalCommits,
mostChangedFiles: sortedFiles,
};
} catch (error) {
logger.error('Error calculating git metrics:', error);
return {
totalCommits: 0,
mostChangedFiles: [],
error: 'Failed to calculate git metrics',
};
}
};
54 changes: 47 additions & 7 deletions src/core/output/outputGenerate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ 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 { GitMetricsResult } from '../file/gitMetrics.js';
import type { OutputGeneratorContext, RenderContext } from './outputGeneratorTypes.js';
import {
generateHeader,
Expand All @@ -15,10 +16,9 @@ import {
generateSummaryPurpose,
generateSummaryUsageGuidelines,
} from './outputStyleDecorate.js';
import { getMarkdownTemplate } from './outputStyles/markdownStyle.js';
import { getPlainTemplate } from './outputStyles/plainStyle.js';
import { getXmlTemplate } from './outputStyles/xmlStyle.js';

import { getGitMetricsMarkdownTemplate, getMarkdownTemplate } from './outputStyles/markdownStyle.js';
import { getGitMetricsPlainTemplate, getPlainTemplate } from './outputStyles/plainStyle.js';
import { getGitMetricsXmlTemplate, getXmlTemplate } from './outputStyles/xmlStyle.js';
const calculateMarkdownDelimiter = (files: ReadonlyArray<ProcessedFile>): string => {
const maxBackticks = files
.flatMap((file) => file.content.match(/`+/g) ?? [])
Expand All @@ -28,7 +28,7 @@ const calculateMarkdownDelimiter = (files: ReadonlyArray<ProcessedFile>): string

const createRenderContext = (outputGeneratorContext: OutputGeneratorContext): RenderContext => {
return {
generationHeader: generateHeader(outputGeneratorContext.config, outputGeneratorContext.generationDate), // configを追加
generationHeader: generateHeader(outputGeneratorContext.config, outputGeneratorContext.generationDate),
summaryPurpose: generateSummaryPurpose(),
summaryFileFormat: generateSummaryFileFormat(),
summaryUsageGuidelines: generateSummaryUsageGuidelines(
Expand All @@ -44,6 +44,13 @@ const createRenderContext = (outputGeneratorContext: OutputGeneratorContext): Re
directoryStructureEnabled: outputGeneratorContext.config.output.directoryStructure,
escapeFileContent: outputGeneratorContext.config.output.parsableStyle,
markdownCodeBlockDelimiter: calculateMarkdownDelimiter(outputGeneratorContext.processedFiles),
gitMetrics:
outputGeneratorContext.gitMetrics && !outputGeneratorContext.gitMetrics.error
? {
totalCommits: outputGeneratorContext.gitMetrics.totalCommits,
mostChangedFiles: outputGeneratorContext.gitMetrics.mostChangedFiles,
}
: undefined,
};
};

Expand Down Expand Up @@ -75,9 +82,22 @@ const generateParsableXmlOutput = async (renderContext: RenderContext): Promise<
'@_path': file.path,
})),
},
git_metrics: renderContext.gitMetrics
? {
summary: {
'#text': `Total Commits Analyzed: ${renderContext.gitMetrics.totalCommits}`,
},
content: {
'#text': renderContext.gitMetrics.mostChangedFiles
.map((file, index) => `${index + 1}. ${file.path} (${file.changes} changes)`)
.join('\n'),
},
}
: undefined,
instruction: renderContext.instruction ? renderContext.instruction : undefined,
},
};

try {
return xmlBuilder.build(xmlDocument);
} catch (error) {
Expand All @@ -88,23 +108,34 @@ const generateParsableXmlOutput = async (renderContext: RenderContext): Promise<
};

const generateHandlebarOutput = async (config: RepomixConfigMerged, renderContext: RenderContext): Promise<string> => {
// Add helper for incrementing index
Handlebars.registerHelper('addOne', (value) => Number.parseInt(value) + 1);

let template: string;
let gitMetricsTemplate = '';

switch (config.output.style) {
case 'xml':
template = getXmlTemplate();
gitMetricsTemplate = getGitMetricsXmlTemplate();
break;
case 'markdown':
template = getMarkdownTemplate();
gitMetricsTemplate = getGitMetricsMarkdownTemplate();
break;
case 'plain':
template = getPlainTemplate();
gitMetricsTemplate = getGitMetricsPlainTemplate();
break;
default:
throw new RepomixError(`Unknown output style: ${config.output.style}`);
}

// Combine templates
const combinedTemplate = `${template}\n${gitMetricsTemplate}`;

try {
const compiledTemplate = Handlebars.compile(template);
const compiledTemplate = Handlebars.compile(combinedTemplate);
return `${compiledTemplate(renderContext).trim()}\n`;
} catch (error) {
throw new RepomixError(`Failed to compile template: ${error instanceof Error ? error.message : 'Unknown error'}`);
Expand All @@ -116,8 +147,15 @@ export const generateOutput = async (
config: RepomixConfigMerged,
processedFiles: ProcessedFile[],
allFilePaths: string[],
gitMetrics?: GitMetricsResult,
): Promise<string> => {
const outputGeneratorContext = await buildOutputGeneratorContext(rootDir, config, allFilePaths, processedFiles);
const outputGeneratorContext = await buildOutputGeneratorContext(
rootDir,
config,
allFilePaths,
processedFiles,
gitMetrics,
);
const renderContext = createRenderContext(outputGeneratorContext);

if (!config.output.parsableStyle) return generateHandlebarOutput(config, renderContext);
Expand All @@ -136,6 +174,7 @@ export const buildOutputGeneratorContext = async (
config: RepomixConfigMerged,
allFilePaths: string[],
processedFiles: ProcessedFile[],
gitMetrics?: GitMetricsResult,
): Promise<OutputGeneratorContext> => {
let repositoryInstruction = '';

Expand Down Expand Up @@ -166,5 +205,6 @@ export const buildOutputGeneratorContext = async (
processedFiles,
config,
instruction: repositoryInstruction,
gitMetrics,
};
};
9 changes: 9 additions & 0 deletions src/core/output/outputGeneratorTypes.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import type { RepomixConfigMerged } from '../../config/configSchema.js';
import type { ProcessedFile } from '../file/fileTypes.js';
import type { GitMetricsResult } from '../file/gitMetrics.js';

export interface OutputGeneratorContext {
generationDate: string;
treeString: string;
processedFiles: ProcessedFile[];
config: RepomixConfigMerged;
instruction: string;
gitMetrics?: GitMetricsResult;
}

export interface RenderContext {
Expand All @@ -23,4 +25,11 @@ export interface RenderContext {
readonly directoryStructureEnabled: boolean;
readonly escapeFileContent: boolean;
readonly markdownCodeBlockDelimiter: string;
readonly gitMetrics?: {
readonly totalCommits: number;
readonly mostChangedFiles: ReadonlyArray<{
readonly path: string;
readonly changes: number;
}>;
};
}
14 changes: 14 additions & 0 deletions src/core/output/outputStyles/markdownStyle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,17 @@ Handlebars.registerHelper('getFileExtension', (filePath) => {
return '';
}
});

export const getGitMetricsMarkdownTemplate = () => {
return /* md */ `{{#if gitMetrics}}
# Git Metrics
## Summary
Total Commits Analyzed: {{gitMetrics.totalCommits}}
## Most Changed Files
{{#each gitMetrics.mostChangedFiles}}
{{addOne @index}}. {{this.path}} ({{this.changes}} changes)
{{/each}}
{{/if}}`;
};
18 changes: 18 additions & 0 deletions src/core/output/outputStyles/plainStyle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,21 @@ End of Codebase
${PLAIN_LONG_SEPARATOR}
`;
};

export const getGitMetricsPlainTemplate = () => {
return `{{#if gitMetrics}}
================
Git Metrics
================
Summary:
--------
Total Commits Analyzed: {{gitMetrics.totalCommits}}
Most Changed Files:
------------------
{{#each gitMetrics.mostChangedFiles}}
{{addOne @index}}. {{this.path}} ({{this.changes}} changes)
{{/each}}
{{/if}}`;
};
15 changes: 15 additions & 0 deletions src/core/output/outputStyles/xmlStyle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,18 @@ This section contains the contents of the repository's files.
{{/if}}
`;
};

export const getGitMetricsXmlTemplate = () => {
return /* xml */ `{{#if gitMetrics}}
<git_metrics>
<summary>
Total Commits Analyzed: {{gitMetrics.totalCommits}}
</summary>
<content>
{{#each gitMetrics.mostChangedFiles}}
{{addOne @index}}. {{this.path}} ({{this.changes}} changes)
{{/each}}
</content>
</git_metrics>
{{/if}}`;
};
Loading

0 comments on commit 34d1dee

Please sign in to comment.