#!/usr/bin/env node /** * Git Commit Automation Script * * Automatically generates commit messages using OpenRouter AI (inception/mercury-coder) * based on staged changes. Supports message editing and optional pushing. * * Usage: * 1. Stage your changes: git add * 2. Run: pnpm commit * 3. Review/edit the generated message * 4. Approve and optionally push */ import { execSync, spawnSync } from 'child_process'; import { createInterface } from 'readline'; import { readFileSync, writeFileSync, unlinkSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import { tmpdir } from 'os'; // Get current directory for ES modules const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Load environment variables from .env file function loadEnv() { try { const envPath = join(__dirname, '.env'); const envContent = readFileSync(envPath, 'utf-8'); const lines = envContent.split('\n'); for (const line of lines) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const [key, ...valueParts] = trimmed.split('='); const value = valueParts.join('=').trim(); if (key && value) { process.env[key.trim()] = value.replace(/^["']|["']$/g, ''); } } } catch (error) { console.error(`${colors.red}❌ Failed to load .env file${colors.reset}`); console.error(`${colors.yellow}💡 Create a .env file in src/utils/ with:${colors.reset}`); console.error(` ${colors.dim}OPENROUTER_API_KEY=your_api_key_here${colors.reset}\n`); process.exit(1); } } // Configuration const OPENROUTER_API_URL = 'https://openrouter.ai/api/v1'; const MODEL_NAME = 'inception/mercury-coder'; // Color codes for terminal output const colors = { reset: '\x1b[0m', bright: '\x1b[1m', dim: '\x1b[2m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', cyan: '\x1b[36m', }; /** * Execute a git command and return the output */ function git(command, silent = false) { try { return execSync(`git ${command}`, { encoding: 'utf-8', stdio: silent ? 'pipe' : ['pipe', 'pipe', 'pipe'] }).trim(); } catch (error) { if (!silent) { console.error(`${colors.red}❌ Git command failed: ${command}${colors.reset}`); console.error(error.message); } return null; } } /** * Check if there are staged changes */ function checkStagedChanges() { const stagedFiles = git('diff --staged --name-only', true); return stagedFiles && stagedFiles.length > 0; } /** * Get git context for AI commit message generation */ function getGitContext() { console.log(`${colors.cyan}🔍 Gathering git context...${colors.reset}`); const status = git('status --short'); const diff = git('diff --staged'); const stagedFiles = git('diff --staged --name-only'); return { status, diff, stagedFiles }; } /** * Call OpenRouter API to generate commit message */ async function generateCommitMessage(context) { console.log(`${colors.cyan}🤖 Generating commit message with OpenRouter...${colors.reset}`); const systemPrompt = `You are a helpful assistant that generates concise, clear git commit messages. Generate commit messages following these guidelines: - Use imperative mood (e.g., "Add", "Fix", "Update", "Refactor") - Keep it concise but descriptive - First line should be a short summary (50-72 characters) - If needed, add a blank line and then bullet points for details - Focus on WHAT changed and WHY, not HOW Generate ONLY the commit message, nothing else. Do not include any explanations or meta-commentary.`; const userPrompt = `Based on the following git changes, generate a commit message: Staged files: ${context.stagedFiles} Git status: ${context.status} Git diff: ${context.diff.slice(0, 8000)}${context.diff.length > 8000 ? '\n... (diff truncated)' : ''}`; try { const apiKey = process.env.OPENROUTER_API_KEY; if (!apiKey) { throw new Error('OPENROUTER_API_KEY not found in environment variables'); } const response = await fetch(`${OPENROUTER_API_URL}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}`, 'HTTP-Referer': 'https://github.com/yourusername/git-commit-automation', 'X-Title': 'Git Commit Automation', }, body: JSON.stringify({ model: MODEL_NAME, messages: [ { role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt } ], }), }); if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(`OpenRouter API error: ${response.status} ${response.statusText}\n${JSON.stringify(errorData, null, 2)}`); } const data = await response.json(); if (!data.choices || !data.choices[0] || !data.choices[0].message) { throw new Error('Unexpected API response format'); } return data.choices[0].message.content.trim(); } catch (error) { console.error(`${colors.red}❌ Failed to generate commit message${colors.reset}`); console.error(error.message); // Check for common errors if (error.message.includes('OPENROUTER_API_KEY not found')) { console.log(`\n${colors.yellow}💡 Make sure you have a .env file in src/utils/ with:${colors.reset}`); console.log(` ${colors.dim}OPENROUTER_API_KEY=your_api_key_here${colors.reset}`); console.log(`\n${colors.yellow}💡 Get your API key from:${colors.reset}`); console.log(` ${colors.dim}https://openrouter.ai/keys${colors.reset}`); } else if (error.message.includes('ECONNREFUSED') || error.message.includes('fetch failed')) { console.log(`\n${colors.yellow}💡 Check your internet connection${colors.reset}`); } else if (error.message.includes('401')) { console.log(`\n${colors.yellow}💡 Invalid API key. Check your OPENROUTER_API_KEY in .env${colors.reset}`); } process.exit(1); } } /** * Create readline interface for user input */ function createReadlineInterface() { return createInterface({ input: process.stdin, output: process.stdout, }); } /** * Ask user a question and get input */ function question(rl, query) { return new Promise((resolve) => { rl.question(query, resolve); }); } /** * Open neovim to edit the commit message */ function editInNeovim(message) { // Create a temporary file for editing const tempFile = join(tmpdir(), `git-commit-${Date.now()}.txt`); try { // Write the current message to the temp file writeFileSync(tempFile, message, 'utf-8'); console.log(`\n${colors.cyan}✏️ Opening neovim to edit commit message...${colors.reset}`); // Open neovim with the temp file const result = spawnSync('nvim', [tempFile], { stdio: 'inherit', shell: false }); if (result.error) { throw new Error(`Failed to open neovim: ${result.error.message}`); } // Read the edited content const editedMessage = readFileSync(tempFile, 'utf-8').trim(); // Clean up temp file unlinkSync(tempFile); return editedMessage; } catch (error) { // Clean up temp file if it exists try { unlinkSync(tempFile); } catch {} console.error(`${colors.red}❌ Failed to edit in neovim${colors.reset}`); console.error(error.message); if (error.message.includes('Failed to open neovim')) { console.log(`\n${colors.yellow}💡 Make sure neovim is installed:${colors.reset}`); console.log(` ${colors.dim}# Arch Linux${colors.reset}`); console.log(` ${colors.dim}sudo pacman -S neovim${colors.reset}`); } // Return the original message if editing fails return message; } } /** * Display the commit message and get user approval */ async function getUserApproval(message, rl) { console.log(`\n${colors.bright}${colors.green}📝 Generated commit message:${colors.reset}`); console.log(`${colors.dim}${'─'.repeat(60)}${colors.reset}`); console.log(message); console.log(`${colors.dim}${'─'.repeat(60)}${colors.reset}\n`); while (true) { const answer = await question( rl, `${colors.yellow}[A]ccept / [E]dit / [C]ancel?${colors.reset} ` ); const choice = answer.trim().toLowerCase(); if (choice === 'a' || choice === 'accept') { return { approved: true, message }; } else if (choice === 'e' || choice === 'edit') { // Close readline to give full control to neovim rl.pause(); // Open neovim for editing const editedMessage = editInNeovim(message); // Resume readline rl.resume(); // Show the edited message and ask for approval again return getUserApproval(editedMessage, rl); } else if (choice === 'c' || choice === 'cancel') { return { approved: false, message: null }; } else { console.log(`${colors.red}Invalid option. Please enter A, E, or C.${colors.reset}`); } } } /** * Create the commit with the approved message */ function createCommit(message) { console.log(`\n${colors.cyan}📦 Creating commit...${colors.reset}`); try { // Use a temporary file for the commit message to handle multi-line messages execSync(`git commit -F -`, { input: message, encoding: 'utf-8', stdio: ['pipe', 'inherit', 'inherit'] }); console.log(`${colors.green}✅ Commit created successfully!${colors.reset}`); return true; } catch (error) { console.error(`${colors.red}❌ Failed to create commit${colors.reset}`); console.error(error.message); return false; } } /** * Ask if user wants to push to remote */ async function askToPush(rl) { const answer = await question( rl, `\n${colors.yellow}Push to remote? [y/N]${colors.reset} ` ); return answer.trim().toLowerCase() === 'y' || answer.trim().toLowerCase() === 'yes'; } /** * Push to remote repository */ function pushToRemote() { console.log(`${colors.cyan}🚀 Pushing to remote...${colors.reset}`); try { // Get current branch const branch = git('rev-parse --abbrev-ref HEAD'); execSync(`git push origin ${branch}`, { encoding: 'utf-8', stdio: 'inherit' }); console.log(`${colors.green}✅ Pushed successfully!${colors.reset}`); return true; } catch (error) { console.error(`${colors.red}❌ Failed to push${colors.reset}`); console.error(error.message); return false; } } /** * Show help message */ function showHelp() { console.log(` ${colors.bright}Git Commit Automation Script${colors.reset} ${colors.dim}Generates commit messages using OpenRouter AI${colors.reset} ${colors.bright}Usage:${colors.reset} 1. Stage your changes: ${colors.cyan}git add ${colors.reset} 2. Run this script: ${colors.cyan}pnpm commit${colors.reset} 3. Review the AI-generated commit message 4. Choose to accept, edit, or cancel 5. Optionally push to remote ${colors.bright}Requirements:${colors.reset} - OpenRouter API key in .env file - Create ${colors.dim}src/utils/.env${colors.reset} with: ${colors.dim}OPENROUTER_API_KEY=your_api_key_here${colors.reset} - Get your key from: ${colors.dim}https://openrouter.ai/keys${colors.reset} ${colors.bright}Options:${colors.reset} --help, -h Show this help message `); } /** * Main function */ async function main() { // Check for help flag const args = process.argv.slice(2); if (args.includes('--help') || args.includes('-h')) { showHelp(); process.exit(0); } // Load environment variables loadEnv(); console.log(`${colors.bright}${colors.blue}🚀 Git Commit Automation${colors.reset}\n`); // Check if we're in a git repository if (!git('rev-parse --git-dir', true)) { console.error(`${colors.red}❌ Not a git repository${colors.reset}`); process.exit(1); } // Check for staged changes if (!checkStagedChanges()) { console.error(`${colors.red}❌ No staged changes found${colors.reset}`); console.log(`\n${colors.yellow}💡 Stage your changes first:${colors.reset}`); console.log(` ${colors.dim}git add ${colors.reset}\n`); process.exit(1); } // Get git context const context = getGitContext(); // Generate commit message using OpenRouter const generatedMessage = await generateCommitMessage(context); // Get user approval const rl = createReadlineInterface(); const { approved, message } = await getUserApproval(generatedMessage, rl); if (!approved) { console.log(`\n${colors.yellow}⏭️ Commit cancelled${colors.reset}`); rl.close(); process.exit(0); } // Create the commit const commitSuccess = createCommit(message); if (!commitSuccess) { rl.close(); process.exit(1); } // Ask to push const shouldPush = await askToPush(rl); rl.close(); if (shouldPush) { pushToRemote(); } console.log(`\n${colors.green}✨ Done!${colors.reset}\n`); } // Run the script main().catch((error) => { console.error(`${colors.red}❌ Unexpected error:${colors.reset}`, error); process.exit(1); });