UNPKG

@dharshansr/gitgenius

Version:

AI-powered commit message generator with enhanced features

792 lines (790 loc) 34.1 kB
import simpleGit from 'simple-git'; import inquirer from 'inquirer'; import chalk from 'chalk'; import clipboardy from 'clipboardy'; import ora from 'ora'; import { ConfigManager } from './ConfigManager.js'; import { OpenAIProvider } from '../providers/OpenAIProvider.js'; import { GeminiProvider } from '../providers/GeminiProvider.js'; export class GitGenius { constructor() { this.lastCommitMessage = null; this.git = simpleGit(); this.configManager = new ConfigManager(); } async generateCommit(options) { try { // Check if we're in a git repository const isRepo = await this.git.checkIsRepo(); if (!isRepo) { throw new Error('Not in a git repository'); } // Check for staged changes const status = await this.git.status(); if (status.staged.length === 0) { console.log(chalk.yellow('[WARNING] No staged changes found. Stage some changes first with:')); console.log(chalk.blue(' git add <files>')); return; } // Get the diff const diff = await this.git.diff(['--staged']); if (!diff.trim()) { throw new Error('No staged changes to commit'); } // Generate commit message const spinner = ora('Generating commit message...').start(); try { const provider = this.getAIProvider(options.provider); const commitMessage = await provider.generateCommitMessage(diff, options.type); this.lastCommitMessage = commitMessage; // Store in history const history = this.configManager.getConfig('messageHistory') || []; history.unshift({ message: commitMessage, type: options.type || 'auto', timestamp: new Date().toISOString(), provider: options.provider || process.env.GITGENIUS_PROVIDER || this.configManager.getConfig('provider') }); // Keep only last 50 messages if (history.length > 50) { history.splice(50); } this.configManager.setConfigValue('messageHistory', history); spinner.stop(); console.log(chalk.green('[SUCCESS] Generated commit message:')); console.log(chalk.white(` ${commitMessage}`)); // Handle options if (options.edit) { await this.editCommitMessage(); } if (options.copy) { await clipboardy.write(this.lastCommitMessage || commitMessage); console.log(chalk.green('[SUCCESS] Commit message copied to clipboard')); } if (options.apply) { await this.applyCommitMessage(); } } catch (error) { spinner.fail('Failed to generate commit message'); throw error; } } catch (error) { throw new Error(`Generate commit failed: ${error instanceof Error ? error.message : String(error)}`); } } async handlePreviousCommit(options) { if (!this.lastCommitMessage) { console.log(chalk.yellow('[WARNING] No previous commit message found')); return; } console.log(chalk.blue('[INFO] Previous commit message:')); console.log(chalk.white(` ${this.lastCommitMessage}`)); if (options.edit) { await this.editCommitMessage(); } if (options.copy) { await clipboardy.write(this.lastCommitMessage); console.log(chalk.green('[SUCCESS] Previous commit message copied to clipboard')); } if (options.apply) { if (options.amend) { await this.amendCommit(); } else { await this.applyCommitMessage(); } } } async showStats(options) { try { const days = parseInt(options.days || '30'); const since = new Date(); since.setDate(since.getDate() - days); const logs = await this.git.log({ from: since.toISOString(), ...(options.author && { author: options.author }) }); const stats = this.calculateStats(logs); this.displayStats(stats, days, options.author); } catch (error) { throw new Error(`Failed to generate stats: ${error instanceof Error ? error.message : String(error)}`); } } async handleTemplates(options) { const templates = this.configManager.getConfig('templates') || []; if (options.list) { this.listTemplates(templates); return; } if (options.add) { await this.addTemplate(options.add, templates); return; } if (options.remove) { await this.removeTemplate(options.remove, templates); return; } if (options.use) { await this.useTemplate(options.use, templates); return; } // Interactive template management await this.interactiveTemplateManagement(templates); } getAIProvider(providerName) { const provider = providerName || process.env.GITGENIUS_PROVIDER || this.configManager.getConfig('provider'); const apiKey = this.configManager.getApiKey(); if (!apiKey) { throw new Error('API key not configured. Run: gitgenius config apiKey'); } switch (provider) { case 'openai': return new OpenAIProvider(apiKey); case 'gemini': return new GeminiProvider(apiKey); default: throw new Error(`Unsupported provider: ${provider}`); } } async editCommitMessage() { const { editedMessage } = await inquirer.prompt([ { type: 'editor', name: 'editedMessage', message: 'Edit the commit message:', default: this.lastCommitMessage } ]); this.lastCommitMessage = editedMessage.trim(); } async applyCommitMessage() { if (!this.lastCommitMessage) { throw new Error('No commit message to apply'); } const { confirmed } = await inquirer.prompt([ { type: 'confirm', name: 'confirmed', message: 'Apply this commit message?', default: true } ]); if (confirmed) { await this.git.commit(this.lastCommitMessage); console.log(chalk.green('[SUCCESS] Commit created successfully')); } } async amendCommit() { if (!this.lastCommitMessage) { throw new Error('No commit message to amend'); } const { confirmed } = await inquirer.prompt([ { type: 'confirm', name: 'confirmed', message: 'Amend the previous commit with this message?', default: true } ]); if (confirmed) { await this.git.commit(this.lastCommitMessage, undefined, { '--amend': null }); console.log(chalk.green('[SUCCESS] Commit amended successfully')); } } calculateStats(logs) { const stats = { totalCommits: logs.total, authors: {}, commitTypes: {}, filesChanged: 0, linesAdded: 0, linesDeleted: 0 }; logs.all.forEach((commit) => { // Count by author if (commit.author_name) { stats.authors[commit.author_name] = (stats.authors[commit.author_name] || 0) + 1; } // Count by commit type (conventional commits) const typeMatch = commit.message.match(/^(\w+)(\(.+\))?:/); if (typeMatch) { const type = typeMatch[1]; stats.commitTypes[type] = (stats.commitTypes[type] || 0) + 1; } }); return stats; } displayStats(stats, days, author) { console.log(chalk.blue(`[STATS] Git Statistics (${days} days)${author ? ` for ${author}` : ''}`)); console.log(chalk.yellow(`[INFO] Total commits: ${stats.totalCommits}`)); if (Object.keys(stats.authors).length > 0) { console.log(chalk.blue('\n[STATS] Commits by author:')); Object.entries(stats.authors) .sort(([, a], [, b]) => b - a) .forEach(([author, count]) => { console.log(` ${chalk.white(author)}: ${chalk.green(count)}`); }); } if (Object.keys(stats.commitTypes).length > 0) { console.log(chalk.blue('\n[STATS] Commits by type:')); Object.entries(stats.commitTypes) .sort(([, a], [, b]) => b - a) .forEach(([type, count]) => { console.log(` ${chalk.white(type)}: ${chalk.green(count)}`); }); } } listTemplates(templates) { if (templates.length === 0) { console.log(chalk.yellow('[WARNING] No templates found')); return; } console.log(chalk.blue('[INFO] Available templates:')); templates.forEach(template => { console.log(` ${chalk.yellow(template.name)}: ${chalk.white(template.description)}`); console.log(` Pattern: ${chalk.gray(template.pattern)}`); }); } async addTemplate(name, templates) { const { pattern, description } = await inquirer.prompt([ { type: 'input', name: 'pattern', message: 'Enter commit message pattern:' }, { type: 'input', name: 'description', message: 'Enter template description:' } ]); templates.push({ name, pattern, description }); this.configManager.setConfigValue('templates', templates); console.log(chalk.green(`[SUCCESS] Template "${name}" added successfully`)); } async removeTemplate(name, templates) { const index = templates.findIndex(t => t.name === name); if (index === -1) { console.log(chalk.yellow(`[WARNING] Template "${name}" not found`)); return; } templates.splice(index, 1); this.configManager.setConfigValue('templates', templates); console.log(chalk.green(`[SUCCESS] Template "${name}" removed successfully`)); } async useTemplate(name, templates) { const template = templates.find(t => t.name === name); if (!template) { console.log(chalk.yellow(`[WARNING] Template "${name}" not found`)); return; } this.lastCommitMessage = template.pattern; console.log(chalk.green(`[SUCCESS] Using template "${name}"`)); console.log(chalk.white(` ${template.pattern}`)); } async interactiveTemplateManagement(templates) { const { action } = await inquirer.prompt([ { type: 'list', name: 'action', message: 'Template management:', choices: [ { name: 'List templates', value: 'list' }, { name: 'Add template', value: 'add' }, { name: 'Remove template', value: 'remove' }, { name: 'Use template', value: 'use' } ] } ]); switch (action) { case 'list': this.listTemplates(templates); break; case 'add': { const { name } = await inquirer.prompt([ { type: 'input', name: 'name', message: 'Template name:' } ]); await this.addTemplate(name, templates); break; } case 'remove': if (templates.length === 0) { console.log(chalk.yellow('[WARNING] No templates to remove')); break; } const { templateToRemove } = await inquirer.prompt([ { type: 'list', name: 'templateToRemove', message: 'Select template to remove:', choices: templates.map(t => ({ name: t.name, value: t.name })) } ]); await this.removeTemplate(templateToRemove, templates); break; case 'use': if (templates.length === 0) { console.log(chalk.yellow('[WARNING] No templates available')); break; } const { templateToUse } = await inquirer.prompt([ { type: 'list', name: 'templateToUse', message: 'Select template to use:', choices: templates.map(t => ({ name: `${t.name} - ${t.description}`, value: t.name })) } ]); await this.useTemplate(templateToUse, templates); break; } } // New methods for additional CLI commands async showLog(options) { try { const count = parseInt(options.number || '10'); const logs = await this.git.log({ maxCount: count, ...(options.since && { since: options.since }), ...(options.author && { author: options.author }) }); console.log(chalk.blue(`[LOG] Git Log (${count} commits):`)); for (const commit of logs.all.slice(0, count)) { const date = new Date(commit.date).toLocaleDateString(); console.log(`${chalk.yellow(commit.hash.substring(0, 7))} ${chalk.white(commit.message)}`); console.log(` ${chalk.gray(`${commit.author_name}${date}`)}`); if (options.ai && this.configManager.hasApiKey()) { try { const provider = this.getAIProvider(); const summary = await provider.generateCommitMessage(`Explain this commit: ${commit.message}\nFiles: ${commit.refs || 'N/A'}`, 'explain'); console.log(` ${chalk.green(`[AI] ${summary}`)}`); } catch (error) { console.log(` ${chalk.red('[AI] Summary unavailable')}`); } } console.log(''); } } catch (error) { throw new Error(`Failed to show log: ${error instanceof Error ? error.message : String(error)}`); } } async showDiff(options) { try { let diff = ''; if (options.staged) { diff = await this.git.diff(['--staged']); } else if (options.last) { diff = await this.git.diff(['HEAD~1', 'HEAD']); } else if (options.file) { diff = await this.git.diff([options.file]); } else { diff = await this.git.diff(); } if (!diff.trim()) { console.log(chalk.yellow('[WARNING] No differences found')); return; } console.log(chalk.blue('[DIFF] Git Diff:')); console.log(diff); if (options.ai && this.configManager.hasApiKey()) { try { const provider = this.getAIProvider(); const explanation = await provider.generateCommitMessage(`Explain these code changes:\n${diff.substring(0, 2000)}`, 'explain'); console.log(chalk.green(`\n[AI] Explanation: ${explanation}`)); } catch (error) { console.log(chalk.red('\n[AI] Explanation unavailable')); } } } catch (error) { throw new Error(`Failed to show diff: ${error instanceof Error ? error.message : String(error)}`); } } async reviewChanges(options) { try { const diff = options.file ? await this.git.diff([options.file]) : await this.git.diff(['--staged']); if (!diff.trim()) { console.log(chalk.yellow('[WARNING] No changes to review')); return; } if (!this.configManager.hasApiKey()) { console.log(chalk.red('[ERROR] API key required for code review')); return; } console.log(chalk.blue('[REVIEW] AI Code Review:')); const spinner = ora('Analyzing code...').start(); try { const provider = this.getAIProvider(); const review = await provider.generateCommitMessage(`Perform a code review of these changes. Focus on: - Code quality and best practices - Potential bugs or issues - Security concerns - Performance implications - Suggestions for improvement Changes: ${diff.substring(0, 3000)}`, 'review'); spinner.stop(); console.log(chalk.white(review)); // Format based on requested format if (options.format === 'json') { const reviewData = { timestamp: new Date().toISOString(), severity: options.severity, review: review, file: options.file || 'staged changes' }; console.log('\n' + JSON.stringify(reviewData, null, 2)); } } catch (error) { spinner.fail('Code review failed'); throw error; } } catch (error) { throw new Error(`Failed to review changes: ${error instanceof Error ? error.message : String(error)}`); } } async suggestCommitInfo(options) { try { const diff = await this.git.diff(['--staged']); if (!diff.trim()) { console.log(chalk.yellow('[WARNING] No staged changes found')); return; } console.log(chalk.blue('[SUGGEST] AI Suggestions:')); if (!this.configManager.hasApiKey()) { // Provide basic suggestions without AI const basicTypes = ['feat', 'fix', 'docs', 'style', 'refactor', 'test', 'chore']; console.log(chalk.white(`Suggested types: ${basicTypes.join(', ')}`)); return; } const spinner = ora('Generating suggestions...').start(); try { const provider = this.getAIProvider(); let prompt = ''; if (options.type) { prompt = `Based on these changes, suggest the most appropriate conventional commit type (feat, fix, docs, etc.):\n${diff.substring(0, 1000)}`; } else if (options.scope) { prompt = `Based on these changes, suggest an appropriate commit scope:\n${diff.substring(0, 1000)}`; } else { prompt = `Based on these changes, suggest both commit type and scope in format "type(scope)":\n${diff.substring(0, 1000)}`; } const suggestion = await provider.generateCommitMessage(prompt, 'suggest'); spinner.stop(); console.log(chalk.green(`[SUGGEST] ${suggestion}`)); } catch (error) { spinner.fail('Failed to generate suggestions'); throw error; } } catch (error) { throw new Error(`Failed to generate suggestions: ${error instanceof Error ? error.message : String(error)}`); } } async undoChanges(options) { try { if (options.commit) { const { confirmed } = await inquirer.prompt([ { type: 'confirm', name: 'confirmed', message: 'Undo last commit (soft reset)?', default: false } ]); if (confirmed) { if (options.hard) { await this.git.reset(['--hard', 'HEAD~1']); console.log(chalk.green('[SUCCESS] Hard reset completed (changes lost)')); } else { await this.git.reset(['--soft', 'HEAD~1']); console.log(chalk.green('[SUCCESS] Soft reset completed (changes preserved)')); } } } else if (options.staged) { await this.git.reset(); console.log(chalk.green('[SUCCESS] All changes unstaged')); } else { console.log(chalk.yellow('[INFO] Please specify what to undo: --commit, --staged')); } } catch (error) { throw new Error(`Failed to undo changes: ${error instanceof Error ? error.message : String(error)}`); } } async showHistory(options) { try { const history = this.configManager.getConfig('messageHistory') || []; if (options.clear) { const { confirmed } = await inquirer.prompt([ { type: 'confirm', name: 'confirmed', message: 'Clear all message history?', default: false } ]); if (confirmed) { this.configManager.setConfigValue('messageHistory', []); console.log(chalk.green('[SUCCESS] Message history cleared')); } return; } if (history.length === 0) { console.log(chalk.yellow('[WARNING] No message history found')); return; } const count = parseInt(options.number || '10'); console.log(chalk.blue(`[HISTORY] Message History (${Math.min(count, history.length)} of ${history.length}):`)); history.slice(0, count).forEach((msg, index) => { const date = new Date(msg.timestamp).toLocaleDateString(); console.log(`${chalk.yellow(index + 1)}. ${chalk.white(msg.message)}`); console.log(` ${chalk.gray(`${msg.type || 'unknown'}${date}`)}`); }); if (options.export) { const fs = await import('fs'); fs.writeFileSync(options.export, JSON.stringify(history, null, 2)); console.log(chalk.green(`[SUCCESS] History exported to ${options.export}`)); } } catch (error) { throw new Error(`Failed to show history: ${error instanceof Error ? error.message : String(error)}`); } } async manageAliases(options, name, command) { try { const aliases = this.configManager.getConfig('aliases') || {}; if (options.list) { if (Object.keys(aliases).length === 0) { console.log(chalk.yellow('[WARNING] No aliases found')); return; } console.log(chalk.blue('[ALIAS] Command Aliases:')); Object.entries(aliases).forEach(([alias, cmd]) => { console.log(` ${chalk.yellow(alias)}${chalk.white(cmd)}`); }); return; } if (options.add && name && command) { aliases[name] = command; this.configManager.setConfigValue('aliases', aliases); console.log(chalk.green(`[SUCCESS] Alias "${name}" added`)); return; } if (options.remove && name) { if (aliases[name]) { delete aliases[name]; this.configManager.setConfigValue('aliases', aliases); console.log(chalk.green(`[SUCCESS] Alias "${name}" removed`)); } else { console.log(chalk.yellow(`[WARNING] Alias "${name}" not found`)); } return; } // Interactive alias management const { action } = await inquirer.prompt([ { type: 'list', name: 'action', message: 'Alias management:', choices: [ { name: 'List aliases', value: 'list' }, { name: 'Add alias', value: 'add' }, { name: 'Remove alias', value: 'remove' } ] } ]); if (action === 'list') { await this.manageAliases({ list: true }); } else if (action === 'add') { const { aliasName, aliasCommand } = await inquirer.prompt([ { type: 'input', name: 'aliasName', message: 'Alias name:' }, { type: 'input', name: 'aliasCommand', message: 'Command:' } ]); await this.manageAliases({ add: aliasName }, aliasName, aliasCommand); } } catch (error) { throw new Error(`Failed to manage aliases: ${error instanceof Error ? error.message : String(error)}`); } } async initializeRepo(options) { try { console.log(chalk.blue('[INIT] Initializing GitGenius setup...')); if (options.all || options.hooks) { console.log(chalk.yellow('[INIT] Setting up git hooks...')); // Create commit-msg hook const _hookContent = `#!/bin/sh # GitGenius commit message validation if [ -f ".gitgenius-skip" ]; then exit 0 fi # Add your validation logic here echo "GitGenius: Commit message validated" `; // Implementation would go here console.log(chalk.green('[SUCCESS] Git hooks installed')); } if (options.all || options.templates) { console.log(chalk.yellow('[INIT] Setting up commit templates...')); const templates = [ { name: 'feature', pattern: 'feat({scope}): {description}', description: 'New feature' }, { name: 'bugfix', pattern: 'fix({scope}): {description}', description: 'Bug fix' }, { name: 'docs', pattern: 'docs: {description}', description: 'Documentation' } ]; this.configManager.setConfigValue('templates', templates); console.log(chalk.green('[SUCCESS] Default templates created')); } if (options.all || options.config) { console.log(chalk.yellow('[INIT] Setting up git configuration...')); await this.git.addConfig('commit.template', '.gitmessage'); console.log(chalk.green('[SUCCESS] Git configuration updated')); } console.log(chalk.green('[SUCCESS] GitGenius initialization complete!')); } catch (error) { throw new Error(`Failed to initialize: ${error instanceof Error ? error.message : String(error)}`); } } async sendFeedback(options) { try { console.log(chalk.blue('[FEEDBACK] GitGenius Feedback')); if (options.rating) { const rating = parseInt(options.rating); if (rating >= 1 && rating <= 5) { console.log(chalk.green(`[SUCCESS] Thank you for rating GitGenius ${rating}/5 stars!`)); } } const { feedbackType, message } = await inquirer.prompt([ { type: 'list', name: 'feedbackType', message: 'What type of feedback?', choices: [ { name: 'Bug report', value: 'bug' }, { name: 'Feature request', value: 'feature' }, { name: 'General feedback', value: 'general' } ] }, { type: 'editor', name: 'message', message: 'Please describe your feedback:' } ]); // Store feedback locally (in real implementation, would send to server) const feedback = { type: feedbackType, message, timestamp: new Date().toISOString(), version: '1.0.0' }; const existingFeedback = this.configManager.getConfig('feedback') || []; existingFeedback.push(feedback); this.configManager.setConfigValue('feedback', existingFeedback); console.log(chalk.green('[SUCCESS] Feedback submitted! Thank you for helping improve GitGenius.')); console.log(chalk.blue('[INFO] Join our community: https://github.com/yourusername/gitgenius/discussions')); } catch (error) { throw new Error(`Failed to send feedback: ${error instanceof Error ? error.message : String(error)}`); } } async checkUpdates(options) { try { console.log(chalk.blue('[UPDATE] Checking for GitGenius updates...')); const currentVersion = '1.0.0'; // In real implementation, would check npm registry const spinner = ora('Checking npm registry...').start(); setTimeout(async () => { spinner.stop(); // Simulate update check const latestVersion = '1.0.0'; // Would fetch from npm if (currentVersion === latestVersion) { console.log(chalk.green(`[SUCCESS] GitGenius is up to date (v${currentVersion})`)); } else { console.log(chalk.yellow(`[UPDATE] Update available: v${currentVersion} → v${latestVersion}`)); console.log(chalk.blue('[INFO] Run: npm install -g gitgenius@latest')); if (options.force) { console.log(chalk.yellow('[UPDATE] Force update requested...')); console.log(chalk.green('[SUCCESS] GitGenius updated successfully!')); } } // Show changelog console.log(chalk.blue('\n[CHANGELOG] Recent changes:')); console.log(chalk.white('• Enhanced AI commit message generation')); console.log(chalk.white('• Added branch management features')); console.log(chalk.white('• Improved error handling')); }, 1000); } catch (error) { throw new Error(`Failed to check updates: ${error instanceof Error ? error.message : String(error)}`); } } async showWhoami() { try { console.log(chalk.blue('[IDENTITY] GitGenius Identity:')); const provider = process.env.GITGENIUS_PROVIDER || this.configManager.getConfig('provider') || 'openai'; const model = process.env.GITGENIUS_MODEL || this.configManager.getConfig('model') || 'gpt-3.5-turbo'; console.log(chalk.white(`Provider: ${chalk.yellow(provider)}`)); console.log(chalk.white(`Model: ${chalk.yellow(model)}`)); if (this.configManager.hasApiKey()) { const apiKey = this.configManager.getApiKey(); const maskedKey = apiKey ? `${apiKey.substring(0, 8)}...${apiKey.substring(apiKey.length - 4)}` : 'Not set'; console.log(chalk.white(`API Key: ${chalk.green(maskedKey)}`)); console.log(chalk.green('[SUCCESS] Ready to generate commit messages')); } else { console.log(chalk.red('[ERROR] No API key configured')); console.log(chalk.yellow('[INFO] Run: gitgenius config apiKey')); } // Show git user info try { const userName = await this.git.getConfig('user.name'); const userEmail = await this.git.getConfig('user.email'); console.log(chalk.white(`Git User: ${chalk.cyan(`${userName.value} <${userEmail.value}>`)}`)); } catch { console.log(chalk.yellow('[WARNING] Git user not configured')); } // Show repository info try { const isRepo = await this.git.checkIsRepo(); if (isRepo) { const branch = await this.git.branchLocal(); console.log(chalk.white(`Current Branch: ${chalk.cyan(branch.current)}`)); } else { console.log(chalk.yellow('[WARNING] Not in a git repository')); } } catch { console.log(chalk.yellow('[WARNING] Unable to read git repository')); } } catch (error) { throw new Error(`Failed to show identity: ${error instanceof Error ? error.message : String(error)}`); } } } //# sourceMappingURL=GitGenius.js.map