Skip to main content
Glama
git-analytics.ts32.6 kB
/** * Git Analytics Tool * * Analytics and statistics tool providing comprehensive Git analytics operations. * Supports both local Git analytics and remote provider analytics. * * Operations: stats, commits, contributors */ import { GitCommandExecutor } from '../utils/git-command-executor.js'; import { ParameterValidator, ToolParams } from '../utils/parameter-validator.js'; import { OperationErrorHandler, ToolResult } from '../utils/operation-error-handler.js'; import { ProviderOperationHandler } from '../providers/provider-operation-handler.js'; import { ProviderConfig, ProviderOperation } from '../providers/types.js'; import { configManager } from '../config.js'; export interface GitAnalyticsParams extends ToolParams { action: 'stats' | 'commits' | 'contributors'; // Time range parameters since?: string; // For commits, contributors (date/time filter) until?: string; // For commits, contributors (date/time filter) // Branch/ref parameters branch?: string; // For commits, contributors (specific branch) ref?: string; // For commits, contributors (specific ref) // Commit analysis parameters author?: string; // For commits (filter by author) committer?: string; // For commits (filter by committer) grep?: string; // For commits (search in commit messages) // Contributor analysis parameters minCommits?: number; // For contributors (minimum commits threshold) sortBy?: 'commits' | 'additions' | 'deletions' | 'name'; // For contributors // Output parameters format?: 'json' | 'csv' | 'summary'; // For all operations limit?: number; // For commits, contributors (max results) includeStats?: boolean; // For commits (include file change stats) includeMerges?: boolean; // For commits (include merge commits) // Remote operation parameters repo?: string; // For remote operations // Advanced analytics parameters groupBy?: 'day' | 'week' | 'month' | 'year'; // For stats (time grouping) includeFileTypes?: boolean; // For stats (analyze by file types) includePaths?: string[]; // For stats (analyze specific paths) excludePaths?: string[]; // For stats (exclude specific paths) } export class GitAnalyticsTool { private gitExecutor: GitCommandExecutor; private providerHandler?: ProviderOperationHandler; constructor(providerConfig?: ProviderConfig) { this.gitExecutor = new GitCommandExecutor(); if (providerConfig) { this.providerHandler = new ProviderOperationHandler(providerConfig); } } /** * Execute git-analytics operation */ async execute(params: GitAnalyticsParams): Promise<ToolResult> { const startTime = Date.now(); try { // Validate basic parameters const validation = ParameterValidator.validateToolParams('git-analytics', params); if (!validation.isValid) { return OperationErrorHandler.createToolError( 'VALIDATION_ERROR', `Parameter validation failed: ${validation.errors.join(', ')}`, params.action, { validationErrors: validation.errors }, validation.suggestions ); } // Validate operation-specific parameters const operationValidation = ParameterValidator.validateOperationParams('git-analytics', params.action, params); if (!operationValidation.isValid) { return OperationErrorHandler.createToolError( 'VALIDATION_ERROR', `Operation validation failed: ${operationValidation.errors.join(', ')}`, params.action, { validationErrors: operationValidation.errors }, operationValidation.suggestions ); } // Route to appropriate handler const isRemoteOperation = this.isRemoteOperation(params.action); if (isRemoteOperation) { if (!params.provider) { if (configManager.isUniversalMode()) { params.provider = 'both'; console.error(`[Universal Mode] Auto-applying both providers for ${params.action}`); } else { return OperationErrorHandler.createToolError( 'PROVIDER_REQUIRED', 'Provider parameter is required for remote analytics operations', params.action, {}, ['Specify provider as: github, gitea, or both'] ); } } return await this.executeRemoteOperation(params, startTime); } else { return await this.executeLocalOperation(params, startTime); } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; return OperationErrorHandler.createToolError( 'EXECUTION_ERROR', `Failed to execute ${params.action}: ${errorMessage}`, params.action, { error: errorMessage }, ['Check the error details and try again'] ); } } /** * Execute local Git analytics operations */ private async executeLocalOperation(params: GitAnalyticsParams, startTime: number): Promise<ToolResult> { switch (params.action) { case 'stats': return await this.handleRepositoryStats(params, startTime); case 'commits': return await this.handleCommitAnalytics(params, startTime); case 'contributors': return await this.handleContributorAnalytics(params, startTime); default: return OperationErrorHandler.createToolError( 'UNSUPPORTED_OPERATION', `Operation '${params.action}' is not supported`, params.action, {}, ['Use supported operations: stats, commits, contributors'] ); } } /** * Execute remote provider operations */ private async executeRemoteOperation(params: GitAnalyticsParams, startTime: number): Promise<ToolResult> { if (!this.providerHandler) { return OperationErrorHandler.createToolError( 'PROVIDER_NOT_CONFIGURED', 'Provider handler is not configured for remote operations', params.action, {}, ['Configure GitHub or Gitea provider to use remote operations'] ); } const operation: ProviderOperation = { provider: params.provider!, operation: this.mapActionToProviderOperation(params.action), parameters: this.extractRemoteParameters(params), requiresAuth: true, isRemoteOperation: true }; try { const result = await this.providerHandler.executeOperation(operation); return { success: result.success, data: result.partialFailure ? result : result.results[0]?.data, error: result.success ? undefined : { code: result.errors[0]?.error?.code || 'REMOTE_OPERATION_ERROR', message: result.errors[0]?.error?.message || 'Remote operation failed', details: result.errors, suggestions: ['Check provider configuration and credentials'] }, metadata: { provider: params.provider, operation: params.action, timestamp: new Date().toISOString(), executionTime: Date.now() - startTime } }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; return OperationErrorHandler.createToolError( 'REMOTE_OPERATION_ERROR', `Remote operation failed: ${errorMessage}`, params.action, { error: errorMessage }, ['Check provider configuration and network connectivity'] ); } } /** * Handle repository statistics operation */ private async handleRepositoryStats(params: GitAnalyticsParams, startTime: number): Promise<ToolResult> { try { const stats: any = { repository: { path: params.projectPath, name: params.projectPath.split('/').pop() || 'unknown' }, overview: {}, branches: {}, commits: {}, contributors: {}, files: {} }; // Get basic repository information const repoInfo = await this.getRepositoryOverview(params); stats.overview = repoInfo; // Get branch statistics const branchStats = await this.getBranchStatistics(params); stats.branches = branchStats; // Get commit statistics const commitStats = await this.getCommitStatistics(params); stats.commits = commitStats; // Get contributor statistics const contributorStats = await this.getContributorStatistics(params); stats.contributors = contributorStats; // Get file statistics if requested if (params.includeFileTypes) { const fileStats = await this.getFileStatistics(params); stats.files = fileStats; } return { success: true, data: stats, metadata: { operation: 'stats', timestamp: new Date().toISOString(), executionTime: Date.now() - startTime } }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; return OperationErrorHandler.createToolError( 'STATS_ERROR', `Failed to generate repository statistics: ${errorMessage}`, 'stats', { error: errorMessage, projectPath: params.projectPath } ); } } /** * Check if repository has any commits */ private async hasCommits(projectPath: string, branch?: string): Promise<boolean> { try { const args = ['rev-list', '--count', 'HEAD']; if (branch) { args[2] = branch; } const result = await this.gitExecutor.executeGitCommand('rev-list', args, projectPath); if (result.success) { const count = parseInt(result.stdout.trim()); return count > 0; } return false; } catch (error) { return false; } } /** * Handle commit analytics operation */ private async handleCommitAnalytics(params: GitAnalyticsParams, startTime: number): Promise<ToolResult> { try { // Check if repository has commits before proceeding const targetRef = params.branch || params.ref; const hasCommitsInRepo = await this.hasCommits(params.projectPath, targetRef); if (!hasCommitsInRepo) { return { success: true, data: { commits: [], analytics: { totalCommits: 0, uniqueAuthors: 0, dateRange: null, commitFrequency: { daily: 0, weekly: 0, monthly: 0 } }, total: 0, filters: { since: params.since, until: params.until, author: params.author, committer: params.committer, grep: params.grep, branch: params.branch || params.ref, includeMerges: params.includeMerges } }, metadata: { operation: 'commits', timestamp: new Date().toISOString(), executionTime: Date.now() - startTime } }; } const args = ['log', '--oneline']; // Add time range filters if (params.since) args.push(`--since=${params.since}`); if (params.until) args.push(`--until=${params.until}`); // Add author/committer filters if (params.author) args.push(`--author=${params.author}`); if (params.committer) args.push(`--committer=${params.committer}`); // Add message search if (params.grep) args.push(`--grep=${params.grep}`); // Add merge commit handling if (!params.includeMerges) args.push('--no-merges'); // Add branch/ref if (params.branch) args.push(params.branch); else if (params.ref) args.push(params.ref); // Add limit if (params.limit) args.push(`-n`, params.limit.toString()); // Add stats if requested if (params.includeStats) { args.splice(1, 1, '--stat'); // Replace --oneline with --stat } const result = await this.gitExecutor.executeGitCommand('log', args, params.projectPath); if (!result.success) { return OperationErrorHandler.handleGitError(result.stderr, 'analyze commits', params.projectPath); } // Parse commit data const commits = this.parseCommitLog(result.stdout, params.includeStats); // Generate analytics const analytics = this.analyzeCommits(commits, params); return { success: true, data: { commits: params.format === 'summary' ? undefined : commits, analytics, total: commits.length, filters: { since: params.since, until: params.until, author: params.author, committer: params.committer, grep: params.grep, branch: params.branch || params.ref, includeMerges: params.includeMerges } }, metadata: { operation: 'commits', timestamp: new Date().toISOString(), executionTime: Date.now() - startTime } }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; return OperationErrorHandler.createToolError( 'COMMITS_ANALYTICS_ERROR', `Failed to analyze commits: ${errorMessage}`, 'commits', { error: errorMessage, projectPath: params.projectPath } ); } } /** * Handle contributor analytics operation */ private async handleContributorAnalytics(params: GitAnalyticsParams, startTime: number): Promise<ToolResult> { try { // Check if repository has commits before proceeding const targetRef = params.branch || params.ref; const hasCommitsInRepo = await this.hasCommits(params.projectPath, targetRef); if (!hasCommitsInRepo) { return { success: true, data: { contributors: [], analytics: { totalContributors: 0, totalCommits: 0, topContributor: null, averageCommitsPerContributor: 0 }, total: 0, filtered: 0, filters: { since: params.since, until: params.until, branch: params.branch || params.ref, minCommits: params.minCommits, sortBy: params.sortBy } }, metadata: { operation: 'contributors', timestamp: new Date().toISOString(), executionTime: Date.now() - startTime } }; } const args = ['shortlog', '-sn']; // Add time range filters if (params.since) args.push(`--since=${params.since}`); if (params.until) args.push(`--until=${params.until}`); // Add branch/ref if (params.branch) args.push(params.branch); else if (params.ref) args.push(params.ref); const result = await this.gitExecutor.executeGitCommand('shortlog', args, params.projectPath); if (!result.success) { return OperationErrorHandler.handleGitError(result.stderr, 'analyze contributors', params.projectPath); } // Parse contributor data const contributors = this.parseContributorLog(result.stdout); // Filter by minimum commits if specified let filteredContributors = contributors; if (params.minCommits) { filteredContributors = contributors.filter(c => c.commits >= params.minCommits!); } // Get detailed contributor statistics const detailedContributors = await this.getDetailedContributorStats( params, filteredContributors.slice(0, params.limit || 50) ); // Sort contributors if (params.sortBy) { detailedContributors.sort((a, b) => { switch (params.sortBy) { case 'commits': return b.commits - a.commits; case 'additions': return (b.additions || 0) - (a.additions || 0); case 'deletions': return (b.deletions || 0) - (a.deletions || 0); case 'name': return a.name.localeCompare(b.name); default: return b.commits - a.commits; } }); } // Generate analytics const analytics = this.analyzeContributors(detailedContributors); return { success: true, data: { contributors: params.format === 'summary' ? undefined : detailedContributors, analytics, total: contributors.length, filtered: detailedContributors.length, filters: { since: params.since, until: params.until, branch: params.branch || params.ref, minCommits: params.minCommits, sortBy: params.sortBy } }, metadata: { operation: 'contributors', timestamp: new Date().toISOString(), executionTime: Date.now() - startTime } }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; return OperationErrorHandler.createToolError( 'CONTRIBUTORS_ANALYTICS_ERROR', `Failed to analyze contributors: ${errorMessage}`, 'contributors', { error: errorMessage, projectPath: params.projectPath } ); } } /** * Get repository overview statistics */ private async getRepositoryOverview(params: GitAnalyticsParams): Promise<any> { const overview: any = {}; try { // Get total commits const commitCountResult = await this.gitExecutor.executeGitCommand( 'rev-list', ['--count', 'HEAD'], params.projectPath ); if (commitCountResult.success) { overview.totalCommits = parseInt(commitCountResult.stdout.trim()) || 0; } // Get repository age (first commit date) const firstCommitResult = await this.gitExecutor.executeGitCommand( 'log', ['--reverse', '--format=%ai', '-1'], params.projectPath ); if (firstCommitResult.success && firstCommitResult.stdout.trim()) { overview.firstCommit = firstCommitResult.stdout.trim(); overview.age = this.calculateAge(new Date(firstCommitResult.stdout.trim())); } // Get last commit date const lastCommitResult = await this.gitExecutor.executeGitCommand( 'log', ['--format=%ai', '-1'], params.projectPath ); if (lastCommitResult.success && lastCommitResult.stdout.trim()) { overview.lastCommit = lastCommitResult.stdout.trim(); } // Get current branch const branchResult = await this.gitExecutor.executeGitCommand( 'branch', ['--show-current'], params.projectPath ); if (branchResult.success) { overview.currentBranch = branchResult.stdout.trim(); } // Get remote URL const remoteResult = await this.gitExecutor.executeGitCommand( 'remote', ['get-url', 'origin'], params.projectPath ); if (remoteResult.success) { overview.remoteUrl = remoteResult.stdout.trim(); } } catch (error) { // Continue with partial data } return overview; } /** * Get branch statistics */ private async getBranchStatistics(params: GitAnalyticsParams): Promise<any> { const branchStats: any = {}; try { // Get all branches const branchResult = await this.gitExecutor.executeGitCommand( 'branch', ['-a'], params.projectPath ); if (branchResult.success) { const branches = branchResult.stdout .split('\n') .map((line: string) => line.trim().replace(/^\*\s*/, '')) .filter((line: string) => line && !line.startsWith('remotes/origin/HEAD')); branchStats.total = branches.length; branchStats.local = branches.filter((b: string) => !b.startsWith('remotes/')).length; branchStats.remote = branches.filter((b: string) => b.startsWith('remotes/')).length; } } catch (error) { // Continue with partial data } return branchStats; } /** * Get commit statistics */ private async getCommitStatistics(params: GitAnalyticsParams): Promise<any> { const commitStats: any = {}; try { // Get commits by time period if groupBy is specified if (params.groupBy) { const format = this.getDateFormat(params.groupBy); const result = await this.gitExecutor.executeGitCommand( 'log', [`--format=${format}`], params.projectPath ); if (result.success) { const dates = result.stdout.split('\n').filter((line: string) => line.trim()); const grouped = this.groupByPeriod(dates, params.groupBy); commitStats.byPeriod = grouped; } } } catch (error) { // Continue with partial data } return commitStats; } /** * Get contributor statistics */ private async getContributorStatistics(params: GitAnalyticsParams): Promise<any> { const contributorStats: any = {}; try { // Get unique contributors count const contributorResult = await this.gitExecutor.executeGitCommand( 'shortlog', ['-sn', 'HEAD'], params.projectPath ); if (contributorResult.success) { const contributors = this.parseContributorLog(contributorResult.stdout); contributorStats.total = contributors.length; contributorStats.topContributor = contributors[0]; } } catch (error) { // Continue with partial data } return contributorStats; } /** * Get file statistics */ private async getFileStatistics(params: GitAnalyticsParams): Promise<any> { const fileStats: any = {}; try { // Get file count by type const lsFilesResult = await this.gitExecutor.executeGitCommand( 'ls-files', [], params.projectPath ); if (lsFilesResult.success) { const files = lsFilesResult.stdout.split('\n').filter((line: string) => line.trim()); const byExtension: Record<string, number> = {}; files.forEach((file: string) => { const ext = file.split('.').pop()?.toLowerCase() || 'no-extension'; byExtension[ext] = (byExtension[ext] || 0) + 1; }); fileStats.total = files.length; fileStats.byExtension = byExtension; } } catch (error) { // Continue with partial data } return fileStats; } /** * Parse commit log output */ private parseCommitLog(output: string, includeStats?: boolean): any[] { const commits = []; const lines = output.split('\n').filter(line => line.trim()); for (const line of lines) { if (includeStats) { // Parse --stat format const match = line.match(/^([a-f0-9]+)\s+(.+)$/); if (match) { commits.push({ hash: match[1], message: match[2] }); } } else { // Parse --oneline format const match = line.match(/^([a-f0-9]+)\s+(.+)$/); if (match) { commits.push({ hash: match[1], message: match[2] }); } } } return commits; } /** * Parse contributor log output */ private parseContributorLog(output: string): any[] { const contributors = []; const lines = output.split('\n').filter(line => line.trim()); for (const line of lines) { const match = line.match(/^\s*(\d+)\s+(.+)$/); if (match) { contributors.push({ commits: parseInt(match[1]), name: match[2].trim() }); } } return contributors; } /** * Get detailed contributor statistics */ private async getDetailedContributorStats(params: GitAnalyticsParams, contributors: any[]): Promise<any[]> { const detailed = []; for (const contributor of contributors) { try { // Get detailed stats for this contributor const args = ['log', '--author', contributor.name, '--numstat', '--format=']; if (params.since) args.push(`--since=${params.since}`); if (params.until) args.push(`--until=${params.until}`); if (params.branch) args.push(params.branch); const result = await this.gitExecutor.executeGitCommand('log', args, params.projectPath); if (result.success) { const stats = this.parseNumstat(result.stdout); detailed.push({ ...contributor, additions: stats.additions, deletions: stats.deletions, filesChanged: stats.files }); } else { detailed.push(contributor); } } catch (error) { detailed.push(contributor); } } return detailed; } /** * Parse numstat output */ private parseNumstat(output: string): { additions: number; deletions: number; files: number } { const lines = output.split('\n').filter(line => line.trim()); let additions = 0; let deletions = 0; let files = 0; for (const line of lines) { const match = line.match(/^(\d+|-)\s+(\d+|-)\s+(.+)$/); if (match) { const add = match[1] === '-' ? 0 : parseInt(match[1]); const del = match[2] === '-' ? 0 : parseInt(match[2]); additions += add; deletions += del; files++; } } return { additions, deletions, files }; } /** * Analyze commits data */ private analyzeCommits(commits: any[], params: GitAnalyticsParams): any { return { total: commits.length, averageMessageLength: commits.reduce((sum, c) => sum + c.message.length, 0) / commits.length || 0, // Add more analytics as needed }; } /** * Analyze contributors data */ private analyzeContributors(contributors: any[]): any { const totalCommits = contributors.reduce((sum, c) => sum + c.commits, 0); const totalAdditions = contributors.reduce((sum, c) => sum + (c.additions || 0), 0); const totalDeletions = contributors.reduce((sum, c) => sum + (c.deletions || 0), 0); return { total: contributors.length, totalCommits, totalAdditions, totalDeletions, averageCommitsPerContributor: totalCommits / contributors.length || 0, topContributor: contributors[0], // Add more analytics as needed }; } /** * Calculate age from date */ private calculateAge(date: Date): string { const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); if (diffDays < 30) return `${diffDays} days`; if (diffDays < 365) return `${Math.floor(diffDays / 30)} months`; return `${Math.floor(diffDays / 365)} years`; } /** * Get date format for groupBy */ private getDateFormat(groupBy: string): string { switch (groupBy) { case 'day': return '%Y-%m-%d'; case 'week': return '%Y-W%U'; case 'month': return '%Y-%m'; case 'year': return '%Y'; default: return '%Y-%m-%d'; } } /** * Group data by time period */ private groupByPeriod(dates: string[], groupBy: string): Record<string, number> { const grouped: Record<string, number> = {}; dates.forEach(date => { grouped[date] = (grouped[date] || 0) + 1; }); return grouped; } /** * Check if operation is a remote operation */ private isRemoteOperation(action: string): boolean { // Analytics can work with both local and remote data // Remote operations provide additional insights from provider APIs return false; // Default to local operations } /** * Extract parameters for remote operations */ private extractRemoteParameters(params: GitAnalyticsParams): Record<string, any> { const remoteParams: Record<string, any> = { projectPath: params.projectPath }; // Common parameters if (params.repo) remoteParams.repo = params.repo; if (params.since) remoteParams.since = params.since; if (params.until) remoteParams.until = params.until; if (params.branch) remoteParams.sha = params.branch; return remoteParams; } /** * Map git-analytics actions to provider operations */ private mapActionToProviderOperation(action: string): string { const actionMap: Record<string, string> = { 'stats': 'analytics-stats', 'commits': 'analytics-commits', 'contributors': 'analytics-contributors' }; return actionMap[action] || action; } /** * Get tool schema for MCP registration */ static getToolSchema() { return { name: 'git-analytics', description: 'Git analytics and statistics tool for repository analysis. Supports stats, commits, and contributors operations. Provides comprehensive analytics for Git repositories.', inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['stats', 'commits', 'contributors'], description: 'The analytics operation to perform' }, projectPath: { type: 'string', description: 'Absolute path to the project directory' }, provider: { type: 'string', enum: ['github', 'gitea', 'both'], description: 'Provider for enhanced remote analytics (optional)' }, since: { type: 'string', description: 'Start date for analysis (ISO date or relative like "1 week ago")' }, until: { type: 'string', description: 'End date for analysis (ISO date or relative like "yesterday")' }, branch: { type: 'string', description: 'Specific branch to analyze (default: current branch)' }, ref: { type: 'string', description: 'Specific ref to analyze (commit, tag, etc.)' }, author: { type: 'string', description: 'Filter commits by author (for commits operation)' }, committer: { type: 'string', description: 'Filter commits by committer (for commits operation)' }, grep: { type: 'string', description: 'Search in commit messages (for commits operation)' }, minCommits: { type: 'number', description: 'Minimum commits threshold (for contributors operation)' }, sortBy: { type: 'string', enum: ['commits', 'additions', 'deletions', 'name'], description: 'Sort contributors by field (for contributors operation)' }, format: { type: 'string', enum: ['json', 'csv', 'summary'], description: 'Output format for results' }, limit: { type: 'number', description: 'Maximum number of results to return' }, includeStats: { type: 'boolean', description: 'Include file change statistics (for commits operation)' }, includeMerges: { type: 'boolean', description: 'Include merge commits in analysis' }, repo: { type: 'string', description: 'Repository name (for remote operations)' }, groupBy: { type: 'string', enum: ['day', 'week', 'month', 'year'], description: 'Group statistics by time period (for stats operation)' }, includeFileTypes: { type: 'boolean', description: 'Include file type analysis (for stats operation)' }, includePaths: { type: 'array', items: { type: 'string' }, description: 'Specific paths to include in analysis' }, excludePaths: { type: 'array', items: { type: 'string' }, description: 'Paths to exclude from analysis' } }, required: ['action', 'projectPath'] } }; } }

Implementation Reference

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Andre-Buzeli/git-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server