Skip to main content
Glama
git-history.ts31.1 kB
/** * Git History Tool * * Comprehensive change tracking tool that records all repository modifications * with detailed timestamps, storing history locally in JSON and syncing to remote providers. * * Operations: log, track, sync, export, auto */ import { GitCommandExecutor, GitCommandResult } 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'; import * as fs from 'fs'; import * as path from 'path'; export interface GitHistoryParams extends ToolParams { action: 'log' | 'track' | 'sync' | 'export' | 'auto'; // Log parameters limit?: number; // Number of entries to show (default: 50) since?: string; // Start date (ISO 8601) until?: string; // End date (ISO 8601) author?: string; // Filter by author filePath?: string; // Filter by file path branch?: string; // Filter by branch format?: 'json' | 'markdown'; // Output format for log // Track parameters message: string; // Change description timestamp?: string; // Custom timestamp (ISO 8601) files?: string[]; // Files affected by this change additions?: number; // Lines added deletions?: number; // Lines deleted // Sync parameters provider?: 'github' | 'gitea' | 'both'; syncMethod?: 'file' | 'api'; // File commit or API upload repo?: string; // Repository name for remote sync // Export parameters outputPath?: string; // Export file path includeDiffs?: boolean; // Include full diffs in export // Auto parameters enabled?: boolean; // Enable/disable auto-tracking } export interface HistoryEntry { id: string; timestamp: string; commitHash?: string; author: string; authorEmail: string; message: string; filesChanged: FileChange[]; additions: number; deletions: number; branch: string; tags?: string[]; diff?: string; synced?: boolean; manual?: boolean; } export interface FileChange { path: string; status: 'added' | 'modified' | 'deleted' | 'renamed'; additions: number; deletions: number; } export interface HistoryConfig { autoTracking: boolean; lastCommitHash?: string; lastSyncTimestamp?: string; } export class GitHistoryTool { private gitExecutor: GitCommandExecutor; private providerHandler?: ProviderOperationHandler; private historyPath: string; private configPath: string; constructor(providerConfig?: ProviderConfig) { this.gitExecutor = new GitCommandExecutor(); this.historyPath = path.join('.git-history', 'history.json'); this.configPath = path.join('.git-history', 'config.json'); if (providerConfig) { this.providerHandler = new ProviderOperationHandler(providerConfig); } this.ensureHistoryDirectory(); } /** * Execute git-history operation */ async execute(params: GitHistoryParams): Promise<ToolResult> { const startTime = Date.now(); try { // Validate basic parameters const validation = ParameterValidator.validateToolParams('git-history', 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 = this.validateOperationParams(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 switch (params.action) { case 'log': return await this.handleLog(params, startTime); case 'track': return await this.handleTrack(params, startTime); case 'sync': return await this.handleSync(params, startTime); case 'export': return await this.handleExport(params, startTime); case 'auto': return await this.handleAuto(params, startTime); default: return OperationErrorHandler.createToolError( 'UNSUPPORTED_OPERATION', `Unsupported operation: ${params.action}`, params.action, { supportedOperations: ['log', 'track', 'sync', 'export', 'auto'] }, ['Use one of the supported operations: log, track, sync, export, auto'] ); } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; return OperationErrorHandler.createToolError( 'EXECUTION_ERROR', `Failed to execute git-history operation: ${errorMessage}`, params.action, { error: errorMessage } ); } } /** * Validate operation-specific parameters */ private validateOperationParams(params: GitHistoryParams): { isValid: boolean; errors: string[]; suggestions: string[] } { const errors: string[] = []; const suggestions: string[] = []; switch (params.action) { case 'track': if (!params.message) { errors.push('Message is required for track operation'); suggestions.push('Provide a message describing the change'); } break; case 'sync': if (!this.providerHandler && params.syncMethod === 'api') { errors.push('Provider configuration required for API sync'); suggestions.push('Configure GitHub/Gitea provider or use file sync method'); } break; case 'export': if (params.outputPath && !path.isAbsolute(params.outputPath)) { errors.push('Output path must be absolute'); suggestions.push('Provide an absolute path for export file'); } break; } return { isValid: errors.length === 0, errors, suggestions }; } /** * Handle log operation - View history entries */ private async handleLog(params: GitHistoryParams, startTime: number): Promise<ToolResult> { try { const history = await this.loadHistory(); let entries = [...history.entries]; // Apply filters if (params.since) { const sinceDate = new Date(params.since); entries = entries.filter(entry => new Date(entry.timestamp) >= sinceDate); } if (params.until) { const untilDate = new Date(params.until); entries = entries.filter(entry => new Date(entry.timestamp) <= untilDate); } if (params.author) { entries = entries.filter(entry => entry.author.toLowerCase().includes(params.author!.toLowerCase()) || entry.authorEmail.toLowerCase().includes(params.author!.toLowerCase()) ); } if (params.filePath) { entries = entries.filter(entry => entry.filesChanged.some(file => file.path.includes(params.filePath!)) ); } if (params.branch) { entries = entries.filter(entry => entry.branch === params.branch); } // Sort by timestamp (newest first) entries.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); // Apply limit const limit = params.limit || 50; entries = entries.slice(0, limit); // Format output const format = params.format || 'json'; let formattedData: any; if (format === 'markdown') { formattedData = this.formatHistoryAsMarkdown(entries); } else { formattedData = { totalEntries: history.entries.length, filteredEntries: entries.length, entries: entries }; } return { success: true, data: { ...formattedData, filters: { since: params.since, until: params.until, author: params.author, filePath: params.filePath, branch: params.branch, limit: limit } }, metadata: { operation: params.action, executionTime: Date.now() - startTime, timestamp: new Date().toISOString() } }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; return OperationErrorHandler.createToolError( 'LOG_FAILED', `Failed to retrieve history: ${errorMessage}`, params.action, { error: errorMessage } ); } } /** * Handle track operation - Manually record a change */ private async handleTrack(params: GitHistoryParams, startTime: number): Promise<ToolResult> { try { const currentBranch = await this.getCurrentBranch(params.projectPath); const timestamp = params.timestamp || new Date().toISOString(); const entry: HistoryEntry = { id: `manual-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, timestamp, author: await this.getProviderUserName() || 'Unknown', authorEmail: await this.getGitUserEmail() || '', message: params.message, filesChanged: params.files ? params.files.map(file => ({ path: file, status: 'modified' as const, additions: params.additions || 0, deletions: params.deletions || 0 })) : [], additions: params.additions || 0, deletions: params.deletions || 0, branch: currentBranch || 'main', manual: true, synced: false }; await this.addHistoryEntry(entry); return { success: true, data: { entry: entry, message: 'Change tracked successfully' }, metadata: { operation: params.action, executionTime: Date.now() - startTime, timestamp: new Date().toISOString() } }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; return OperationErrorHandler.createToolError( 'TRACK_FAILED', `Failed to track change: ${errorMessage}`, params.action, { error: errorMessage } ); } } /** * Handle sync operation - Sync local history to remote */ private async handleSync(params: GitHistoryParams, startTime: number): Promise<ToolResult> { try { const history = await this.loadHistory(); const unsyncedEntries = history.entries.filter(entry => !entry.synced); if (unsyncedEntries.length === 0) { return { success: true, data: { message: 'All entries are already synced', totalEntries: history.entries.length, syncedEntries: history.entries.length - unsyncedEntries.length, unsyncedEntries: 0 }, metadata: { operation: params.action, executionTime: Date.now() - startTime, timestamp: new Date().toISOString() } }; } const syncMethod = params.syncMethod || 'file'; let syncResult: any = {}; if (syncMethod === 'file') { syncResult = await this.syncViaFile(params, unsyncedEntries); } else if (syncMethod === 'api') { syncResult = await this.syncViaApi(params, unsyncedEntries); } else { return OperationErrorHandler.createToolError( 'INVALID_SYNC_METHOD', `Invalid sync method: ${syncMethod}`, params.action, { supportedMethods: ['file', 'api'] }, ['Use "file" to commit history.json or "api" to use provider API'] ); } // Mark entries as synced const updatedHistory = await this.loadHistory(); updatedHistory.entries.forEach(entry => { if (!entry.synced && unsyncedEntries.some(u => u.id === entry.id)) { entry.synced = true; } }); await this.saveHistory(updatedHistory); return { success: true, data: { ...syncResult, syncedEntries: unsyncedEntries.length, totalEntries: history.entries.length, syncMethod }, metadata: { operation: params.action, executionTime: Date.now() - startTime, timestamp: new Date().toISOString() } }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; return OperationErrorHandler.createToolError( 'SYNC_FAILED', `Failed to sync history: ${errorMessage}`, params.action, { error: errorMessage } ); } } /** * Handle export operation - Export history to file */ private async handleExport(params: GitHistoryParams, startTime: number): Promise<ToolResult> { try { const history = await this.loadHistory(); const outputPath = params.outputPath || path.join(params.projectPath, 'HISTORY.json'); let exportData: any; if (path.extname(outputPath).toLowerCase() === '.md') { // Export as Markdown exportData = this.formatHistoryAsMarkdown(history.entries); } else { // Export as JSON exportData = { exportedAt: new Date().toISOString(), totalEntries: history.entries.length, includeDiffs: params.includeDiffs || false, entries: params.includeDiffs ? history.entries : history.entries.map(entry => { const { diff, ...entryWithoutDiff } = entry; return entryWithoutDiff; }) }; } // Write to file const outputDir = path.dirname(outputPath); if (!fs.existsSync(outputDir)) { fs.mkdirSync(outputDir, { recursive: true }); } if (typeof exportData === 'string') { fs.writeFileSync(outputPath, exportData, 'utf8'); } else { fs.writeFileSync(outputPath, JSON.stringify(exportData, null, 2), 'utf8'); } return { success: true, data: { exportedPath: outputPath, format: path.extname(outputPath).toLowerCase() === '.md' ? 'markdown' : 'json', totalEntries: history.entries.length, includeDiffs: params.includeDiffs || false }, metadata: { operation: params.action, executionTime: Date.now() - startTime, timestamp: new Date().toISOString() } }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; return OperationErrorHandler.createToolError( 'EXPORT_FAILED', `Failed to export history: ${errorMessage}`, params.action, { error: errorMessage } ); } } /** * Handle auto operation - Enable/disable automatic tracking */ private async handleAuto(params: GitHistoryParams, startTime: number): Promise<ToolResult> { try { const config = await this.loadConfig(); const wasEnabled = config.autoTracking; if (params.enabled !== undefined) { config.autoTracking = params.enabled; await this.saveConfig(config); if (params.enabled && !wasEnabled) { // Enable auto-tracking - sync current commits await this.syncRecentCommits(); } } return { success: true, data: { autoTrackingEnabled: config.autoTracking, message: `Auto-tracking ${config.autoTracking ? 'enabled' : 'disabled'}` }, metadata: { operation: params.action, executionTime: Date.now() - startTime, timestamp: new Date().toISOString() } }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; return OperationErrorHandler.createToolError( 'AUTO_CONFIG_FAILED', `Failed to configure auto-tracking: ${errorMessage}`, params.action, { error: errorMessage } ); } } /** * Sync via file commit */ private async syncViaFile(params: GitHistoryParams, entries: HistoryEntry[]): Promise<any> { try { const historyFile = path.join(params.projectPath, 'HISTORY.json'); // Write history to repository const historyData = { lastUpdated: new Date().toISOString(), entries: entries }; fs.writeFileSync(historyFile, JSON.stringify(historyData, null, 2), 'utf8'); // Commit the file const addResult = await this.gitExecutor.executeGitCommand('add', ['HISTORY.json'], params.projectPath); if (!addResult.success) { throw new Error(`Failed to add HISTORY.json: ${addResult.stderr}`); } const commitResult = await this.gitExecutor.executeGitCommand( 'commit', ['-m', `Update history: ${entries.length} new entries`], params.projectPath ); if (!commitResult.success && !commitResult.stderr.includes('nothing to commit')) { throw new Error(`Failed to commit HISTORY.json: ${commitResult.stderr}`); } return { method: 'file', committed: commitResult.success, message: 'History synced via file commit' }; } catch (error) { throw new Error(`File sync failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Sync via provider API */ private async syncViaApi(params: GitHistoryParams, entries: HistoryEntry[]): Promise<any> { if (!this.providerHandler) { throw new Error('Provider handler not configured'); } try { const provider = params.provider || 'github'; const repo = params.repo || await this.getRepoName(); // Create a summary entry for the API const summary = { period: `${entries[0]?.timestamp} to ${entries[entries.length - 1]?.timestamp}`, totalEntries: entries.length, authors: [...new Set(entries.map(e => e.author))], filesChanged: entries.reduce((sum, e) => sum + e.filesChanged.length, 0), totalAdditions: entries.reduce((sum, e) => sum + e.additions, 0), totalDeletions: entries.reduce((sum, e) => sum + e.deletions, 0), entries: entries.slice(0, 10) // Include first 10 entries in detail }; const operation: ProviderOperation = { provider, operation: 'create', parameters: { repo, title: `History Update: ${entries.length} changes`, body: JSON.stringify(summary, null, 2), type: 'issue' // Create as issue for tracking }, requiresAuth: true, isRemoteOperation: true }; const result = await this.providerHandler.executeOperation(operation); return { method: 'api', provider, success: result.success, message: result.success ? 'History synced via API' : 'API sync failed' }; } catch (error) { throw new Error(`API sync failed: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Format history as Markdown */ private formatHistoryAsMarkdown(entries: HistoryEntry[]): string { let markdown = '# Repository History\n\n'; markdown += `Generated on: ${new Date().toISOString()}\n\n`; markdown += `Total entries: ${entries.length}\n\n`; for (const entry of entries) { markdown += `## ${entry.message}\n\n`; markdown += `- **Date:** ${new Date(entry.timestamp).toLocaleString()}\n`; markdown += `- **Author:** ${entry.author} <${entry.authorEmail}>\n`; markdown += `- **Branch:** ${entry.branch}\n`; markdown += `- **Commit:** ${entry.commitHash || 'Manual entry'}\n`; markdown += `- **Changes:** +${entry.additions} -${entry.deletions}\n`; if (entry.filesChanged.length > 0) { markdown += `- **Files changed:**\n`; for (const file of entry.filesChanged) { markdown += ` - ${file.status.toUpperCase()}: ${file.path} (+${file.additions} -${file.deletions})\n`; } } if (entry.tags && entry.tags.length > 0) { markdown += `- **Tags:** ${entry.tags.join(', ')}\n`; } markdown += '\n---\n\n'; } return markdown; } /** * Load history from file */ private async loadHistory(): Promise<{ entries: HistoryEntry[] }> { try { if (fs.existsSync(this.historyPath)) { const data = fs.readFileSync(this.historyPath, 'utf8'); return JSON.parse(data); } } catch (error) { console.warn('Failed to load history file:', error); } return { entries: [] }; } /** * Save history to file */ private async saveHistory(history: { entries: HistoryEntry[] }): Promise<void> { try { fs.writeFileSync(this.historyPath, JSON.stringify(history, null, 2), 'utf8'); } catch (error) { throw new Error(`Failed to save history: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Add entry to history */ private async addHistoryEntry(entry: HistoryEntry): Promise<void> { const history = await this.loadHistory(); history.entries.push(entry); await this.saveHistory(history); } /** * Load configuration */ private async loadConfig(): Promise<HistoryConfig> { try { if (fs.existsSync(this.configPath)) { const data = fs.readFileSync(this.configPath, 'utf8'); return JSON.parse(data); } } catch (error) { console.warn('Failed to load config file:', error); } return { autoTracking: false }; } /** * Save configuration */ private async saveConfig(config: HistoryConfig): Promise<void> { try { fs.writeFileSync(this.configPath, JSON.stringify(config, null, 2), 'utf8'); } catch (error) { throw new Error(`Failed to save config: ${error instanceof Error ? error.message : 'Unknown error'}`); } } /** * Sync recent commits to history */ private async syncRecentCommits(): Promise<void> { try { const config = await this.loadConfig(); const lastHash = config.lastCommitHash; // Get commits since last sync const logArgs = ['--oneline', '--numstat', '--format=format:%H|%an|%ae|%ad|%s']; if (lastHash) { logArgs.push(`${lastHash}..HEAD`); } const logResult = await this.gitExecutor.executeGitCommand('log', logArgs); if (!logResult.success) { console.warn('Failed to get git log:', logResult.stderr); return; } const lines = logResult.stdout.split('\n').filter(line => line.trim()); let currentEntry: Partial<HistoryEntry> | null = null; for (const line of lines) { if (line.includes('|')) { // Commit header line if (currentEntry) { await this.addHistoryEntry(currentEntry as HistoryEntry); } const [hash, gitAuthor, email, date, ...messageParts] = line.split('|'); const message = messageParts.join('|'); currentEntry = { id: `commit-${hash}`, timestamp: new Date(date).toISOString(), commitHash: hash, author: await this.getProviderUserName() || gitAuthor, // Use provider username, fallback to git author authorEmail: email, message, filesChanged: [], additions: 0, deletions: 0, branch: await this.getCurrentBranch() || 'main', synced: false }; } else if (line.trim() && currentEntry) { // File change line (from --numstat) const parts = line.trim().split('\t'); if (parts.length >= 3) { const additions = parseInt(parts[0]) || 0; const deletions = parseInt(parts[1]) || 0; const filePath = parts[2]; currentEntry.filesChanged!.push({ path: filePath, status: additions === 0 && deletions === 0 ? 'modified' : (additions > 0 ? 'added' : 'deleted'), additions, deletions }); currentEntry.additions! += additions; currentEntry.deletions! += deletions; } } } // Add the last entry if (currentEntry) { await this.addHistoryEntry(currentEntry as HistoryEntry); } // Update last commit hash const latestHash = await this.getLatestCommitHash(); if (latestHash) { config.lastCommitHash = latestHash; config.lastSyncTimestamp = new Date().toISOString(); await this.saveConfig(config); } } catch (error) { console.warn('Failed to sync recent commits:', error); } } /** * Get current branch */ private async getCurrentBranch(projectPath?: string): Promise<string | null> { try { const result = await this.gitExecutor.executeGitCommand('branch', ['--show-current'], projectPath); return result.success ? result.stdout.trim() : null; } catch { return null; } } /** * Get provider username (GitHub or Gitea username from env) */ private async getProviderUserName(): Promise<string | null> { // Try GitHub username first const githubUser = process.env.GITHUB_USERNAME; if (githubUser) return githubUser; // Try Gitea username const giteaUser = process.env.GITEA_USERNAME; if (giteaUser) return giteaUser; // Fallback to git config user name try { const result = await this.gitExecutor.executeGitCommand('config', ['user.name']); return result.success ? result.stdout.trim() : null; } catch { return null; } } /** * Get git user email */ private async getGitUserEmail(): Promise<string | null> { try { const result = await this.gitExecutor.executeGitCommand('config', ['user.email']); return result.success ? result.stdout.trim() : null; } catch { return null; } } /** * Get latest commit hash */ private async getLatestCommitHash(): Promise<string | null> { try { const result = await this.gitExecutor.executeGitCommand('rev-parse', ['HEAD']); return result.success ? result.stdout.trim() : null; } catch { return null; } } /** * Get repository name from git config */ private async getRepoName(): Promise<string | null> { try { const result = await this.gitExecutor.executeGitCommand('config', ['--get', 'remote.origin.url']); if (result.success) { const url = result.stdout.trim(); // Extract repo name from URL (supports GitHub and Gitea formats) const match = url.match(/\/([^\/]+?)(?:\.git)?$/); return match ? match[1] : null; } } catch { // Ignore errors } return null; } /** * Ensure history directory exists */ private ensureHistoryDirectory(): void { try { const dir = path.dirname(this.historyPath); if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); } } catch (error) { console.warn('Failed to create history directory:', error); } } /** * Get tool schema for MCP registration */ static getToolSchema() { return { name: 'git-history', description: 'Comprehensive change tracking tool that records all repository modifications with detailed timestamps, storing history locally in JSON and syncing to remote providers.', inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['log', 'track', 'sync', 'export', 'auto'], description: 'The git-history operation to perform' }, projectPath: { type: 'string', description: 'Absolute path to the project directory' }, limit: { type: 'number', description: 'Number of entries to show (default: 50)', minimum: 1, maximum: 1000 }, since: { type: 'string', description: 'Start date filter (ISO 8601 format)' }, until: { type: 'string', description: 'End date filter (ISO 8601 format)' }, author: { type: 'string', description: 'Filter by author name or email' }, filePath: { type: 'string', description: 'Filter by file path' }, branch: { type: 'string', description: 'Filter by branch name' }, format: { type: 'string', enum: ['json', 'markdown'], description: 'Output format for log operation' }, message: { type: 'string', description: 'Change description (required for track operation)' }, timestamp: { type: 'string', description: 'Custom timestamp for track operation (ISO 8601 format)' }, files: { type: 'array', items: { type: 'string' }, description: 'Files affected by the change (for track operation)' }, additions: { type: 'number', description: 'Lines added (for track operation)', minimum: 0 }, deletions: { type: 'number', description: 'Lines deleted (for track operation)', minimum: 0 }, provider: { type: 'string', enum: ['github', 'gitea', 'both'], description: 'Provider for remote sync operations' }, syncMethod: { type: 'string', enum: ['file', 'api'], description: 'Sync method: file (commit to repo) or api (use provider API)' }, repo: { type: 'string', description: 'Repository name for remote sync' }, outputPath: { type: 'string', description: 'Export file path (absolute path required)' }, includeDiffs: { type: 'boolean', description: 'Include full diffs in export (default: false)' }, enabled: { type: 'boolean', description: 'Enable/disable auto-tracking (for auto operation)' } }, required: ['action', 'projectPath'] } }; } }

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