Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(output): Add git metrics to output #334

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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
6 changes: 5 additions & 1 deletion repomix.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@
"removeEmptyLines": false,
"topFilesLength": 5,
"showLineNumbers": false,
"includeEmptyDirectories": true
"includeEmptyDirectories": true,
"gitMetrics": {
"enable": true,
"maxCommits": 100
}
},
"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',
};
}
yamadashy marked this conversation as resolved.
Show resolved Hide resolved
};
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}}`;
};
yamadashy marked this conversation as resolved.
Show resolved Hide resolved
Loading
Loading