From fb462fb0bcd868b999b520853bc95b6ec15dc72c Mon Sep 17 00:00:00 2001 From: Yamada Dev Date: Tue, 4 Feb 2025 01:29:31 +0900 Subject: [PATCH] feat(output): Add git metrics to output --- repomix.config.json | 6 +- src/config/configSchema.ts | 10 + src/core/file/gitMetrics.ts | 131 ++++++++++++ src/core/output/outputGenerate.ts | 54 ++++- src/core/output/outputGeneratorTypes.ts | 9 + src/core/output/outputStyles/markdownStyle.ts | 14 ++ src/core/output/outputStyles/plainStyle.ts | 18 ++ src/core/output/outputStyles/xmlStyle.ts | 15 ++ src/core/packager.ts | 29 ++- tests/core/file/gitMetrics.test.ts | 189 ++++++++++++++++++ 10 files changed, 466 insertions(+), 9 deletions(-) create mode 100644 src/core/file/gitMetrics.ts create mode 100644 tests/core/file/gitMetrics.test.ts diff --git a/repomix.config.json b/repomix.config.json index 5de3fa7b..d0003a34 100644 --- a/repomix.config.json +++ b/repomix.config.json @@ -10,7 +10,11 @@ "removeEmptyLines": false, "topFilesLength": 5, "showLineNumbers": false, - "includeEmptyDirectories": true + "includeEmptyDirectories": true, + "gitMetrics": { + "enable": true, + "maxCommits": 100 + } }, "include": [], "ignore": { diff --git a/src/config/configSchema.ts b/src/config/configSchema.ts index db5fe1fa..761d6340 100644 --- a/src/config/configSchema.ts +++ b/src/config/configSchema.ts @@ -12,6 +12,11 @@ export const defaultFilePathMap: Record = { 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 @@ -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(), @@ -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([]), diff --git a/src/core/file/gitMetrics.ts b/src/core/file/gitMetrics.ts new file mode 100644 index 00000000..c2fbb9ee --- /dev/null +++ b/src/core/file/gitMetrics.ts @@ -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 => { + 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 => { + 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 => { + 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>((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', + }; + } +}; diff --git a/src/core/output/outputGenerate.ts b/src/core/output/outputGenerate.ts index 79b28ed7..4b82eae7 100644 --- a/src/core/output/outputGenerate.ts +++ b/src/core/output/outputGenerate.ts @@ -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, @@ -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): string => { const maxBackticks = files .flatMap((file) => file.content.match(/`+/g) ?? []) @@ -28,7 +28,7 @@ const calculateMarkdownDelimiter = (files: ReadonlyArray): 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( @@ -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, }; }; @@ -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) { @@ -88,23 +108,34 @@ const generateParsableXmlOutput = async (renderContext: RenderContext): Promise< }; const generateHandlebarOutput = async (config: RepomixConfigMerged, renderContext: RenderContext): Promise => { + // 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'}`); @@ -116,8 +147,15 @@ export const generateOutput = async ( config: RepomixConfigMerged, processedFiles: ProcessedFile[], allFilePaths: string[], + gitMetrics?: GitMetricsResult, ): Promise => { - 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); @@ -136,6 +174,7 @@ export const buildOutputGeneratorContext = async ( config: RepomixConfigMerged, allFilePaths: string[], processedFiles: ProcessedFile[], + gitMetrics?: GitMetricsResult, ): Promise => { let repositoryInstruction = ''; @@ -166,5 +205,6 @@ export const buildOutputGeneratorContext = async ( processedFiles, config, instruction: repositoryInstruction, + gitMetrics, }; }; diff --git a/src/core/output/outputGeneratorTypes.ts b/src/core/output/outputGeneratorTypes.ts index ce082b56..07343bac 100644 --- a/src/core/output/outputGeneratorTypes.ts +++ b/src/core/output/outputGeneratorTypes.ts @@ -1,5 +1,6 @@ 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; @@ -7,6 +8,7 @@ export interface OutputGeneratorContext { processedFiles: ProcessedFile[]; config: RepomixConfigMerged; instruction: string; + gitMetrics?: GitMetricsResult; } export interface RenderContext { @@ -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; + }>; + }; } diff --git a/src/core/output/outputStyles/markdownStyle.ts b/src/core/output/outputStyles/markdownStyle.ts index 1155b96d..9023a8f2 100644 --- a/src/core/output/outputStyles/markdownStyle.ts +++ b/src/core/output/outputStyles/markdownStyle.ts @@ -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}}`; +}; diff --git a/src/core/output/outputStyles/plainStyle.ts b/src/core/output/outputStyles/plainStyle.ts index eedd5c01..33f5ca5a 100644 --- a/src/core/output/outputStyles/plainStyle.ts +++ b/src/core/output/outputStyles/plainStyle.ts @@ -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}}`; +}; diff --git a/src/core/output/outputStyles/xmlStyle.ts b/src/core/output/outputStyles/xmlStyle.ts index c6e38fd0..ed280185 100644 --- a/src/core/output/outputStyles/xmlStyle.ts +++ b/src/core/output/outputStyles/xmlStyle.ts @@ -61,3 +61,18 @@ This section contains the contents of the repository's files. {{/if}} `; }; + +export const getGitMetricsXmlTemplate = () => { + return /* xml */ `{{#if gitMetrics}} + + + Total Commits Analyzed: {{gitMetrics.totalCommits}} + + + {{#each gitMetrics.mostChangedFiles}} + {{addOne @index}}. {{this.path}} ({{this.changes}} changes) + {{/each}} + + +{{/if}}`; +}; diff --git a/src/core/packager.ts b/src/core/packager.ts index ce929b8d..73eff8f1 100644 --- a/src/core/packager.ts +++ b/src/core/packager.ts @@ -1,8 +1,10 @@ import type { RepomixConfigMerged } from '../config/configSchema.js'; +import { logger } from '../shared/logger.js'; import type { RepomixProgressCallback } from '../shared/types.js'; import { collectFiles } from './file/fileCollect.js'; import { processFiles } from './file/fileProcess.js'; import { searchFiles } from './file/fileSearch.js'; +import { calculateGitMetrics } from './file/gitMetrics.js'; import { calculateMetrics } from './metrics/calculateMetrics.js'; import { generateOutput } from './output/outputGenerate.js'; import { copyToClipboardIfEnabled } from './packager/copyToClipboardIfEnabled.js'; @@ -17,6 +19,13 @@ export interface PackResult { fileCharCounts: Record; fileTokenCounts: Record; suspiciousFilesResults: SuspiciousFileResult[]; + gitMetrics?: { + totalCommits: number; + mostChangedFiles: Array<{ + path: string; + changes: number; + }>; + }; } export const pack = async ( @@ -32,6 +41,7 @@ export const pack = async ( writeOutputToDisk, copyToClipboardIfEnabled, calculateMetrics, + calculateGitMetrics, }, ): Promise => { progressCallback('Searching for files...'); @@ -51,7 +61,17 @@ export const pack = async ( const processedFiles = await deps.processFiles(safeRawFiles, config, progressCallback); progressCallback('Generating output...'); - const output = await deps.generateOutput(rootDir, config, processedFiles, safeFilePaths); + let gitMetrics; + if (config.output.gitMetrics?.enabled) { + progressCallback('Calculating git metrics...'); + gitMetrics = await deps.calculateGitMetrics(rootDir, config.output.gitMetrics.maxCommits); + if (gitMetrics.error) { + logger.warn(`Git metrics calculation skipped: ${gitMetrics.error}`); + gitMetrics = undefined; + } + } + + const output = await deps.generateOutput(rootDir, config, processedFiles, safeFilePaths, gitMetrics); progressCallback('Writing output file...'); await deps.writeOutputToDisk(output, config); @@ -63,5 +83,12 @@ export const pack = async ( return { ...metrics, suspiciousFilesResults, + gitMetrics: + gitMetrics && !gitMetrics.error + ? { + totalCommits: gitMetrics.totalCommits, + mostChangedFiles: gitMetrics.mostChangedFiles, + } + : undefined, }; }; diff --git a/tests/core/file/gitMetrics.test.ts b/tests/core/file/gitMetrics.test.ts new file mode 100644 index 00000000..a412845d --- /dev/null +++ b/tests/core/file/gitMetrics.test.ts @@ -0,0 +1,189 @@ +// tests/core/file/gitMetrics.test.ts (continued) + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { calculateGitMetrics, isGitInstalled, isGitRepository } from '../../../src/core/file/gitMetrics.js'; +import { logger } from '../../../src/shared/logger.js'; + +vi.mock('../../../src/shared/logger'); + +describe('gitMetrics', () => { + // ... (previous test cases) + + describe('calculateGitMetrics', () => { + const mockGitLogOutput = ` +file1.ts +file2.ts +file1.ts +file3.ts +file2.ts +file1.ts + `.trim(); + + it('should calculate metrics correctly', async () => { + const mockExecFileAsync = vi + .fn() + .mockResolvedValueOnce({ stdout: mockGitLogOutput, stderr: '' }) // git log + .mockResolvedValueOnce({ stdout: '6', stderr: '' }); // commit count + + const result = await calculateGitMetrics('/test/dir', 100, { + execFileAsync: mockExecFileAsync, + isGitInstalled: async () => true, + isGitRepository: async () => true, + }); + + expect(result).toEqual({ + totalCommits: 6, + mostChangedFiles: [ + { path: 'file1.ts', changes: 3 }, + { path: 'file2.ts', changes: 2 }, + { path: 'file3.ts', changes: 1 }, + ], + }); + + expect(mockExecFileAsync).toHaveBeenCalledWith('git', [ + '-C', + '/test/dir', + 'log', + '--name-only', + '--pretty=format:', + '-n', + '100', + ]); + }); + + it('should return error when git is not installed', async () => { + const result = await calculateGitMetrics('/test/dir', 100, { + execFileAsync: vi.fn(), + isGitInstalled: async () => false, + isGitRepository: async () => true, + }); + + expect(result).toEqual({ + totalCommits: 0, + mostChangedFiles: [], + error: 'Git is not installed', + }); + }); + + it('should return error for non-git repository', async () => { + const result = await calculateGitMetrics('/test/dir', 100, { + execFileAsync: vi.fn(), + isGitInstalled: async () => true, + isGitRepository: async () => false, + }); + + expect(result).toEqual({ + totalCommits: 0, + mostChangedFiles: [], + error: 'Not a Git repository', + }); + }); + + it('should handle git command errors', async () => { + const mockError = new Error('Git command failed'); + const mockExecFileAsync = vi.fn().mockRejectedValue(mockError); + + const result = await calculateGitMetrics('/test/dir', 100, { + execFileAsync: mockExecFileAsync, + isGitInstalled: async () => true, + isGitRepository: async () => true, + }); + + expect(result).toEqual({ + totalCommits: 0, + mostChangedFiles: [], + error: 'Failed to calculate git metrics', + }); + expect(logger.error).toHaveBeenCalledWith('Error calculating git metrics:', mockError); + }); + + it('should respect maxCommits parameter', async () => { + const mockExecFileAsync = vi + .fn() + .mockResolvedValueOnce({ stdout: mockGitLogOutput, stderr: '' }) + .mockResolvedValueOnce({ stdout: '3', stderr: '' }); + + const result = await calculateGitMetrics('/test/dir', 3, { + execFileAsync: mockExecFileAsync, + isGitInstalled: async () => true, + isGitRepository: async () => true, + }); + + expect(result.totalCommits).toBe(3); + expect(mockExecFileAsync).toHaveBeenCalledWith('git', expect.arrayContaining(['-n', '3'])); + }); + + it('should limit to top 5 most changed files', async () => { + const mockLongOutput = ` +file1.ts +file2.ts +file3.ts +file4.ts +file5.ts +file6.ts +file7.ts +file1.ts +file2.ts +file3.ts +file4.ts +file5.ts +file1.ts + `.trim(); + + const mockExecFileAsync = vi + .fn() + .mockResolvedValueOnce({ stdout: mockLongOutput, stderr: '' }) + .mockResolvedValueOnce({ stdout: '13', stderr: '' }); + + const result = await calculateGitMetrics('/test/dir', 100, { + execFileAsync: mockExecFileAsync, + isGitInstalled: async () => true, + isGitRepository: async () => true, + }); + + expect(result.mostChangedFiles).toHaveLength(5); + expect(result.mostChangedFiles[0].path).toBe('file1.ts'); + expect(result.mostChangedFiles[0].changes).toBe(3); + }); + + it('should handle empty git repository', async () => { + const mockExecFileAsync = vi + .fn() + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + .mockResolvedValueOnce({ stdout: '0', stderr: '' }); + + const result = await calculateGitMetrics('/test/dir', 100, { + execFileAsync: mockExecFileAsync, + isGitInstalled: async () => true, + isGitRepository: async () => true, + }); + + expect(result).toEqual({ + totalCommits: 0, + mostChangedFiles: [], + }); + }); + + it('should handle malformed git log output', async () => { + const mockExecFileAsync = vi + .fn() + .mockResolvedValueOnce({ stdout: 'malformed\noutput\n', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'invalid', stderr: '' }); + + const result = await calculateGitMetrics('/test/dir', 100, { + execFileAsync: mockExecFileAsync, + isGitInstalled: async () => true, + isGitRepository: async () => true, + }); + + expect(result).toEqual({ + totalCommits: 0, + mostChangedFiles: [ + { path: 'malformed', changes: 1 }, + { path: 'output', changes: 1 }, + ], + error: 'Failed to calculate git metrics', + }); + }); + }); +});