UNPKG

@cwdpk/cocommit

Version:

AI-powered git commit message generator CLI tool (CoCommit)

663 lines 26.9 kB
#!/usr/bin/env node "use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const anthropic_1 = require("@ai-sdk/anthropic"); const google_1 = require("@ai-sdk/google"); const openai_1 = require("@ai-sdk/openai"); const ai_1 = require("ai"); const chalk_1 = __importDefault(require("chalk")); const child_process_1 = require("child_process"); const commander_1 = require("commander"); const dotenv_1 = __importDefault(require("dotenv")); const inquirer_1 = __importDefault(require("inquirer")); const ora_1 = __importDefault(require("ora")); const util_1 = require("util"); const zod_1 = require("zod"); const config_1 = require("./ai/config"); const utils_1 = require("./ai/utils"); // Load environment variables dotenv_1.default.config(); const execAsync = (0, util_1.promisify)(child_process_1.exec); var GenMode; (function (GenMode) { GenMode["Commit"] = "commit"; GenMode["Branch"] = "branch"; })(GenMode || (GenMode = {})); // Zod schema for commit message validation const CommitMessageSchema = zod_1.z.object({ type: zod_1.z.enum([ "feat", "fix", "docs", "style", "refactor", "perf", "test", "build", "ci", "chore", "revert", ]), scope: zod_1.z.string().optional().nullable(), description: zod_1.z.string().min(1), body: zod_1.z.string().min(1), breaking: zod_1.z.boolean().optional().default(false), }); const BranchNameSchema = zod_1.z.object({ branchName: zod_1.z.string().min(1), }); class AIGitCommit { constructor() { this.spinner = (0, ora_1.default)(); this.program = new commander_1.Command(); this.setupCommands(); } setupCommands() { this.program .name("cocommit") .description("AI-powered git commit message generator") .version("1.0.0"); this.program .command("commit") .alias("c") .description("Generate AI commit message and commit changes") .option("-m, --message <message>", "Custom commit message (skips AI generation)") .option("-a, --add-all", "Add all changed files before committing") .option("--dry-run", "Show what would be committed without actually committing") .option("--no-verify", "Skip git hooks") .action(this.handleCommit.bind(this)); this.program .command("branch") .alias("b") .command("new") .alias("n") .option("-n, --name", "Custom branch name (skips AI generation)") .option("-a, --add-all", "Add all changed files before committing") .description("Create a new branch") .action(this.handleGenerateBranchName.bind(this)); this.program .command("config") .description("Configure AI settings") .action(this.handleConfig.bind(this)); this.program .command("status") .alias("s") .description("Show git status with AI insights") .action(this.handleStatus.bind(this)); } async handleCommit(options) { try { this.spinner.start("Checking git repository..."); // Check if we're in a git repository await this.checkGitRepository(); // Get git config const gitConfig = await this.getGitConfig(); this.spinner.succeed("Git repository validated"); // Get changed files this.spinner.start("Analyzing changed files..."); const changedFiles = await this.getChangedFiles(options.addAll); if (changedFiles.length === 0) { this.spinner.warn("No changes detected"); console.log(chalk_1.default.yellow("No files to commit. Use -a flag to add all changes.")); return; } this.spinner.succeed(`Found ${changedFiles.length} changed file(s)`); // Generate commit message let commitMessage; if (options.message) { commitMessage = options.message; console.log(chalk_1.default.blue("Using provided commit message:"), commitMessage); } else { this.spinner.start("Generating AI commit message..."); const aiCommitMessage = await this.generateAIContent(changedFiles, gitConfig, GenMode.Commit); this.spinner.succeed("AI commit message generated"); commitMessage = this.formatCommitMessage(aiCommitMessage); console.log(chalk_1.default.green("\nGenerated commit message:")); console.log(chalk_1.default.cyan(commitMessage)); // Ask for confirmation const { confirmed } = await inquirer_1.default.prompt([ { type: "confirm", name: "confirmed", message: "Do you want to commit with this message?", default: true, }, ]); if (!confirmed) { const { customMessage } = await inquirer_1.default.prompt([ { type: "input", name: "customMessage", message: "Enter your custom commit message:", validate: (input) => input.trim().length > 0 || "Commit message cannot be empty", }, ]); commitMessage = customMessage; } } // Perform commit if (options.dryRun) { console.log(chalk_1.default.yellow("Dry run - would commit:")); console.log(chalk_1.default.cyan(commitMessage)); return; } await this.performCommit(commitMessage, options); console.log(chalk_1.default.green("✨ Successfully committed changes!")); } catch (error) { this.spinner.fail("Failed to commit"); console.error(chalk_1.default.red("Error:"), error instanceof Error ? error.message : error); process.exit(1); } } async handleGenerateBranchName(options) { try { this.spinner.start("Checking git repository..."); // Check if we're in a git repository await this.checkGitRepository(); // Get git config const gitConfig = await this.getGitConfig(); this.spinner.succeed("Git repository validated"); // Get changed files this.spinner.start("Analyzing changed files..."); const changedFiles = await this.getChangedFiles(options.addAll); if (changedFiles.length === 0) { this.spinner.warn("No changes detected"); console.log(chalk_1.default.yellow("No files to commit. Use -a flag to add all changes.")); return; } this.spinner.succeed(`Found ${changedFiles.length} changed file(s)`); // Generate new branch name let branchName; if (options.message) { branchName = options.message; console.log(chalk_1.default.blue("Using provided branch name:"), branchName); } else { this.spinner.start("Generating AI branch name..."); const aiContent = await this.generateAIContent(changedFiles, gitConfig, GenMode.Branch); this.spinner.succeed("New branch name using AI is generated ✨"); const branchNameOutput = aiContent; console.log(chalk_1.default.green("\nGenerated branch name:")); console.log(chalk_1.default.cyan(branchNameOutput.branchName)); branchName = branchNameOutput.branchName; // Ask for confirmation const { confirmed } = await inquirer_1.default.prompt([ { type: "confirm", name: "confirmed", message: "Do you want to proceed with this branch name?", default: true, }, ]); if (!confirmed) { const { customBranchName } = await inquirer_1.default.prompt([ { type: "input", name: "customBranchName", message: "Enter your custom branch name:", validate: (input) => input.trim().length > 0 || "Branch name cannot be empty", }, ]); branchName = customBranchName; } } // Perform commit if (options.dryRun) { console.log(chalk_1.default.yellow("Dry run - would commit:")); console.log(chalk_1.default.cyan(branchName)); return; } await this.createANewBranch(branchName, options); console.log(chalk_1.default.green("✨ Successfully checked out to new branch!")); } catch (error) { this.spinner.fail("Failed to checkout to new branch"); console.error(chalk_1.default.red("Error:"), error instanceof Error ? error.message : error); process.exit(1); } } async handleConfig() { console.log(chalk_1.default.blue("AI Git Commit Configuration")); const { provider } = await inquirer_1.default.prompt([ { type: "list", name: "provider", message: "Select AI provider:", choices: [ { name: "OpenAI (GPT-4o, GPT-4o-mini)", value: "openai" }, { name: "Anthropic (Claude)", value: "anthropic" }, { name: "Google (Gemini)", value: "google" }, ], default: "openai", }, ]); let model; let apiKeyPrompt; switch (provider) { case "openai": const { openaiModel } = await inquirer_1.default.prompt([ { type: "list", name: "openaiModel", message: "Select OpenAI model:", choices: [ { name: "GPT-4o-mini (Recommended - Fast & Cost-effective)", value: "gpt-4o-mini", }, { name: "GPT-4o (Most Capable)", value: "gpt-4o" }, { name: "GPT-3.5 Turbo (Budget)", value: "gpt-3.5-turbo" }, ], default: "gpt-4o-mini", }, ]); model = openaiModel; apiKeyPrompt = "Enter your OpenAI API key:"; break; case "anthropic": const { anthropicModel } = await inquirer_1.default.prompt([ { type: "list", name: "anthropicModel", message: "Select Anthropic model:", choices: [ { name: "Claude 3.5 Sonnet (Recommended)", value: "claude-3-5-sonnet-20241022", }, { name: "Claude 3.5 Haiku (Fast)", value: "claude-3-5-haiku-20241022", }, { name: "Claude 3 Opus (Most Capable)", value: "claude-3-opus-20240229", }, ], default: "claude-3-5-sonnet-20241022", }, ]); model = anthropicModel; apiKeyPrompt = "Enter your Anthropic API key:"; break; case "google": const { googleModel } = await inquirer_1.default.prompt([ { type: "list", name: "googleModel", message: "Select Google model:", choices: [ { name: "Gemini 1.5 Pro (Recommended)", value: "gemini-1.5-pro" }, { name: "Gemini 1.5 Flash (Fast)", value: "gemini-1.5-flash" }, ], default: "gemini-1.5-pro", }, ]); model = googleModel; apiKeyPrompt = "Enter your Google AI API key:"; break; default: throw new Error("Invalid provider selected"); } const { apiKey } = await inquirer_1.default.prompt([ { type: "password", name: "apiKey", message: apiKeyPrompt, mask: "*", validate: (input) => input.trim().length > 0 || "API key cannot be empty", }, ]); // Save configuration to user config file const config = { provider, model, apiKey }; await (0, config_1.saveAIConfig)(config); console.log(chalk_1.default.green(`✅ Configuration saved to ${(0, config_1.getConfigFilePath)()}`)); console.log(chalk_1.default.cyan(`Provider: ${provider}`)); console.log(chalk_1.default.cyan(`Model: ${model}`)); } async getAIConfig() { const config = await (0, config_1.loadAIConfig)(); if (!config) { throw new Error(`AI provider not configured. Run: cocommit config`); } return config; } async handleStatus() { try { const gitConfig = await this.getGitConfig(); const changedFiles = await this.getChangedFiles(false); console.log(chalk_1.default.blue("Git Status with AI Insights")); console.log(chalk_1.default.gray("─".repeat(50))); console.log(chalk_1.default.cyan("Branch:"), gitConfig.branch); console.log(chalk_1.default.cyan("User:"), `${gitConfig.user.name} <${gitConfig.user.email}>`); if (gitConfig.remote.origin) { console.log(chalk_1.default.cyan("Remote:"), gitConfig.remote.origin); } console.log(chalk_1.default.cyan("Changed files:"), changedFiles.length); if (changedFiles.length > 0) { console.log(chalk_1.default.yellow("\nFiles to be committed:")); changedFiles.forEach((file) => { const statusColor = file.status === "A" ? chalk_1.default.green : file.status === "M" ? chalk_1.default.yellow : file.status === "D" ? chalk_1.default.red : chalk_1.default.white; console.log(` ${statusColor(file.status)} ${file.filename}`); }); } } catch (error) { console.error(chalk_1.default.red("Error:"), error instanceof Error ? error.message : error); } } async checkGitRepository() { try { await execAsync("git rev-parse --git-dir"); } catch (error) { throw new Error("Not a git repository"); } } async getGitConfig() { try { const [userName, userEmail, branch, remoteOrigin] = await Promise.all([ execAsync("git config user.name").catch(() => ({ stdout: "" })), execAsync("git config user.email").catch(() => ({ stdout: "" })), execAsync("git branch --show-current"), execAsync("git config remote.origin.url").catch(() => ({ stdout: "" })), ]); return { user: { name: userName.stdout.trim(), email: userEmail.stdout.trim(), }, remote: { origin: remoteOrigin.stdout.trim(), }, branch: branch.stdout.trim(), }; } catch (error) { throw new Error("Failed to get git configuration"); } } async getChangedFiles(addAll = false) { try { let statusOutput; if (addAll) { await execAsync("git add ."); // Get staged files after adding all const { stdout } = await execAsync("git diff --name-only --cached"); statusOutput = stdout; } else { // Get currently staged files const { stdout } = await execAsync("git diff --name-only --cached"); statusOutput = stdout; } const files = []; const lines = statusOutput .trim() .split("\n") .filter((line) => line.length > 0); for (const filename of lines) { let status = "M"; // Check if file is newly added or deleted const { stdout: diffStatus } = await execAsync(`git diff --cached --name-status -- "${filename}"`); if (diffStatus.startsWith("A")) status = "A"; else if (diffStatus.startsWith("D")) status = "D"; let diff = ""; try { if (status !== "D") { const { stdout: diffOutput } = await execAsync(`git diff --cached "${filename}"`); diff = diffOutput; } } catch (error) { // Ignore diff errors } files.push({ filename, status, diff, }); } return files; } catch (error) { throw new Error("Failed to get changed files"); } } async generateAIContent(changedFiles, gitConfig, genMode) { // Create context for AI const context = this.createChangeContext(changedFiles, gitConfig); // Simulate AI call (replace with actual AI service) const aiResponse = await this.callAI(context, genMode); return aiResponse; } createChangeContext(changedFiles, gitConfig) { let context = `Repository: ${gitConfig.remote.origin || "local"}\n`; context += `Branch: ${gitConfig.branch}\n`; context += `Changed files: ${changedFiles.length}\n\n`; context += "File changes:\n"; changedFiles.forEach((file) => { context += `\n${file.status} ${file.filename}\n`; if (file.diff) { // Truncate diff to avoid too much context const diffLines = file.diff.split("\n").slice(0, 20); context += diffLines.join("\n") + "\n"; if (file.diff.split("\n").length > 20) { context += "... (truncated)\n"; } } }); return context; } getAIProvider(config) { const aiProvider = (0, ai_1.experimental_customProvider)({ languageModels: { openai: (0, openai_1.createOpenAI)({ apiKey: config.apiKey }).languageModel(config.model), anthropic: (0, anthropic_1.createAnthropic)({ apiKey: config.apiKey }).languageModel(config.model), google: (0, google_1.createGoogleGenerativeAI)({ apiKey: config.apiKey, }).languageModel(config.model), }, }); switch (config.provider) { case "openai": return aiProvider.languageModel("openai"); case "anthropic": return aiProvider.languageModel("anthropic"); case "google": return aiProvider.languageModel("google"); default: throw new Error("Unsupported AI provider"); } } async callAI(context, genMode) { try { const config = await this.getAIConfig(); const model = this.getAIProvider(config); const result = await (0, ai_1.generateObject)({ model, ...this.getPromptConfig(genMode, context), }); return result.object; } catch (error) { console.error(chalk_1.default.yellow("AI generation failed, falling back to basic analysis...")); if (error instanceof Error) { console.error(chalk_1.default.gray(`Error: ${error.message}`)); } // Fallback to basic analysis if AI fails return this.generateFallbackCommitMessage(context); } } generateFallbackCommitMessage(context) { // Fallback logic when AI fails const hasNewFiles = context.includes("\nA "); const hasModifiedFiles = context.includes("\nM "); const hasDeletedFiles = context.includes("\nD "); const hasPackageJson = context.includes("package.json"); const hasTests = context.includes("test") || context.includes("spec"); const hasDocumentation = context.includes("README") || context.includes(".md"); const hasConfigFiles = context.includes(".env") || context.includes("config"); const hasCIFiles = context.includes(".github") || context.includes(".yml") || context.includes(".yaml"); let type = "chore"; let description = "update files"; let scope = null; if (hasCIFiles) { type = "ci"; description = "update CI configuration"; } else if (hasTests && hasNewFiles) { type = "test"; description = "add new tests"; } else if (hasTests) { type = "test"; description = "update tests"; } else if (hasDocumentation) { type = "docs"; description = "update documentation"; } else if (hasPackageJson) { type = "build"; description = "update dependencies"; } else if (hasConfigFiles) { type = "chore"; description = "update configuration"; scope = "config"; } else if (hasNewFiles) { type = "feat"; description = "add new functionality"; } else if (hasDeletedFiles && hasModifiedFiles) { type = "refactor"; description = "refactor code structure"; } else if (hasDeletedFiles) { type = "refactor"; description = "remove unused code"; } else if (hasModifiedFiles) { type = "fix"; description = "improve code implementation"; } return { type, scope, description, body: null, breaking: false, }; } formatCommitMessage(commitMsg) { let message = `${commitMsg.type}`; if (commitMsg.scope) { message += `(${commitMsg.scope})`; } if (commitMsg.breaking) { message += "!"; } message += `: ${commitMsg.description}`; if (commitMsg.body) { message += `\n\n${commitMsg.body}`; } return message; } async performCommit(message, options) { try { let commitCommand = `git commit -m "${message}"`; if (options.noVerify) { commitCommand += " --no-verify"; } await execAsync(commitCommand); } catch (error) { throw new Error("Failed to commit changes"); } } async createANewBranch(branchName, options) { try { let commitCommand = `git checkout -b "${branchName}"`; await execAsync(commitCommand); } catch (error) { throw new Error(`Failed to make a branch: ${error instanceof Error ? error.message : error}`); } } getSystemPrompt(genMode) { if (genMode === GenMode.Commit) { return utils_1.COMMIT_SYSTEM_PROMPT; } else if (genMode === GenMode.Branch) { return utils_1.BRANCH_SYSTEM_PROMPT; } else { return utils_1.COMMIT_SYSTEM_PROMPT; } } getZodSchema(genMode) { if (genMode === GenMode.Commit) { return CommitMessageSchema; } else if (genMode === GenMode.Branch) { return BranchNameSchema; } else { return CommitMessageSchema; } } getPromptWithContext(genMode, context) { if (genMode === GenMode.Commit) { return `Analyze the following code changes and generate a conventional commit message:\n\n${context}`; } else if (genMode === GenMode.Branch) { return `Analyze the following code changes and generate a new conventional branch name for code changes:\n\n${context}`; } else { return `Analyze the following code changes and generate a conventional commit message:\n\n${context}`; } } getPromptConfig(genMode, context) { return { schema: this.getZodSchema(genMode), system: this.getSystemPrompt(genMode), prompt: this.getPromptWithContext(genMode, context), }; } async run() { // Block all commands except config if config is missing const args = process.argv.slice(2); if (args[0] !== "config") { const aiConfig = await (0, config_1.loadAIConfig)(); if (!aiConfig) { console.log(chalk_1.default.red("AI provider not configured. Please run: cocommit config")); process.exit(1); } } this.program.parse(); } } // Run the CLI if (require.main === module) { const cli = new AIGitCommit(); cli.run(); } exports.default = AIGitCommit; //# sourceMappingURL=cli.js.map