UNPKG

@untools/commitgen

Version:
570 lines โ€ข 24.2 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 }); // ./src/index.ts const child_process_1 = require("child_process"); const chalk_1 = __importDefault(require("chalk")); const commander_1 = require("commander"); const inquirer_1 = __importDefault(require("inquirer")); const config_1 = require("./config"); const configure_1 = require("./commands/configure"); const providers_1 = require("./providers"); const commit_history_1 = require("./utils/commit-history"); const multi_commit_1 = require("./utils/multi-commit"); const issue_tracker_1 = require("./utils/issue-tracker"); class CommitGen { constructor() { this.historyAnalyzer = new commit_history_1.CommitHistoryAnalyzer(); this.multiCommitAnalyzer = new multi_commit_1.MultiCommitAnalyzer(); this.issueTracker = new issue_tracker_1.IssueTrackerIntegration(); } exec(cmd) { try { return (0, child_process_1.execSync)(cmd, { encoding: "utf8", stdio: ["pipe", "pipe", "ignore"], }).trim(); } catch (error) { return ""; } } isGitRepo() { return this.exec("git rev-parse --git-dir") !== ""; } getStagedChanges() { return this.exec("git diff --cached --stat"); } getStagedDiff() { return this.exec("git diff --cached"); } getTrackedChanges() { return this.exec("git diff --stat"); } analyzeChanges() { const staged = this.getStagedChanges(); const unstaged = this.getTrackedChanges(); const diff = this.getStagedDiff(); const files = []; if (staged) { const lines = staged.split("\n"); lines.forEach((line) => { const match = line.match(/^\s*(.+?)\s+\|/); if (match) files.push(match[1]); }); } const additions = (diff.match(/^\+(?!\+)/gm) || []).length; const deletions = (diff.match(/^-(?!-)/gm) || []).length; return { filesChanged: files, additions, deletions, hasStaged: staged !== "", hasUnstaged: unstaged !== "", diff, }; } formatCommitMessage(msg) { let result = `${msg.type}`; if (msg.scope) result += `(${msg.scope})`; if (msg.breaking) result += "!"; result += `: ${msg.subject}`; if (msg.body) result += `\n\n${msg.body}`; if (msg.breaking) result += `\n\nBREAKING CHANGE: Major version update required`; return result; } displayAnalysis(analysis) { console.log(chalk_1.default.cyan.bold("\n๐Ÿ“Š Analysis:")); console.log(chalk_1.default.gray(` Files changed: ${chalk_1.default.white(analysis.filesChanged.length)}`)); console.log(chalk_1.default.gray(` Additions: ${chalk_1.default.green(`+${analysis.additions}`)}`)); console.log(chalk_1.default.gray(` Deletions: ${chalk_1.default.red(`-${analysis.deletions}`)}`)); console.log(chalk_1.default.cyan.bold("\n๐Ÿ“ Changed files:")); analysis.filesChanged.slice(0, 10).forEach((f) => { const ext = f.split(".").pop(); const icon = this.getFileIcon(ext || ""); console.log(chalk_1.default.gray(` ${icon} ${f}`)); }); if (analysis.filesChanged.length > 10) { console.log(chalk_1.default.gray(` ... and ${analysis.filesChanged.length - 10} more files`)); } } getFileIcon(ext) { const icons = { ts: "๐Ÿ“˜", js: "๐Ÿ“’", tsx: "โš›๏ธ", jsx: "โš›๏ธ", json: "๐Ÿ“‹", md: "๐Ÿ“", css: "๐ŸŽจ", scss: "๐ŸŽจ", html: "๐ŸŒ", test: "๐Ÿงช", spec: "๐Ÿงช", }; return icons[ext] || "๐Ÿ“„"; } hasEnvironmentApiKey(provider) { switch (provider) { case "vercel-google": return !!process.env.GOOGLE_GENERATIVE_AI_API_KEY; case "vercel-openai": return !!process.env.OPENAI_API_KEY; case "groq": return !!process.env.GROQ_API_KEY; case "openai": return !!process.env.OPENAI_API_KEY; case "google": return !!process.env.GOOGLE_GENERATIVE_AI_API_KEY; default: return false; } } combineCommitMessages(messages) { const types = messages.map((m) => m.type); const typeCount = types.reduce((acc, t) => { acc[t] = (acc[t] || 0) + 1; return acc; }, {}); const mostCommonType = Object.entries(typeCount).sort((a, b) => b[1] - a[1])[0][0]; const scopes = messages.map((m) => m.scope).filter(Boolean); const uniqueScopes = [...new Set(scopes)]; const scope = uniqueScopes.length > 0 ? uniqueScopes.slice(0, 2).join(", ") : undefined; const subjects = messages.map((m) => m.subject); const combinedSubject = subjects.join("; "); const bodies = messages.map((m) => m.body).filter(Boolean); const combinedBody = bodies.length > 0 ? bodies.join("\n\n") : undefined; const hasBreaking = messages.some((m) => m.breaking); return { type: mostCommonType, scope, subject: combinedSubject, body: combinedBody, breaking: hasBreaking, }; } async run(options) { console.log(chalk_1.default.bold.cyan("\n๐Ÿš€ CommitGen") + chalk_1.default.gray(" - AI-Powered Commit Message Generator\n")); if (!this.isGitRepo()) { console.error(chalk_1.default.red("โŒ Error: Not a git repository")); process.exit(1); } const analysis = this.analyzeChanges(); if (!analysis.hasStaged) { console.log(chalk_1.default.yellow("โš ๏ธ No staged changes found.")); if (analysis.hasUnstaged) { console.log(chalk_1.default.blue("๐Ÿ’ก You have unstaged changes. Stage them with:") + chalk_1.default.gray(" git add <files>")); } process.exit(0); } // Check for issue tracking integration let issueRef = null; if (options.linkIssues !== false) { issueRef = this.issueTracker.extractIssueFromBranch(); if (issueRef) { console.log(chalk_1.default.cyan(`\n${this.issueTracker.getIssueDisplay(issueRef)} detected`)); } } // Check if multi-commit mode should be suggested if (options.multiCommit !== false && this.multiCommitAnalyzer.shouldSplit(analysis)) { const { useMultiCommit } = await inquirer_1.default.prompt([ { type: "confirm", name: "useMultiCommit", message: chalk_1.default.yellow("๐Ÿ”„ Multiple concerns detected. Split into separate commits?"), default: true, }, ]); if (useMultiCommit) { return this.runMultiCommit(analysis, options); } } this.displayAnalysis(analysis); // Load commit history pattern for personalization let historyPattern = null; if (options.learnFromHistory !== false) { historyPattern = await this.historyAnalyzer.getCommitPattern(); if (historyPattern) { console.log(chalk_1.default.cyan("\n๐Ÿ“œ Personalizing based on your commit history")); } } let suggestions = []; let usingFallback = false; if (options.useAi) { try { const configManager = new config_1.ConfigManager(); let providerConfig = configManager.getProviderConfig(); if (!providerConfig.apiKey && !this.hasEnvironmentApiKey(providerConfig.provider)) { console.log(chalk_1.default.yellow("\nโš ๏ธ API key not found for the selected provider.")); const { shouldConfigure } = await inquirer_1.default.prompt([ { type: "confirm", name: "shouldConfigure", message: "Would you like to configure your API key now?", default: true, }, ]); if (shouldConfigure) { await (0, configure_1.configureCommand)(); providerConfig = configManager.getProviderConfig(); } else { console.log(chalk_1.default.gray("Falling back to rule-based suggestions...\n")); suggestions = this.getFallbackSuggestions(analysis); usingFallback = true; } } if (!usingFallback) { console.log(chalk_1.default.blue(`\n๐Ÿค– Generating commit messages using ${providerConfig.provider}...\n`)); const provider = (0, providers_1.createProvider)(providerConfig); suggestions = await provider.generateCommitMessage(analysis); if (!suggestions || suggestions.length === 0) { throw new Error("No suggestions generated"); } // Personalize suggestions based on history if (historyPattern) { suggestions = suggestions.map((msg) => this.historyAnalyzer.personalizeCommitMessage(msg, historyPattern)); } // Adjust type based on issue if available if (issueRef) { suggestions = suggestions.map((msg) => ({ ...msg, type: this.issueTracker.suggestTypeFromIssue(issueRef, msg.type), })); } } } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; console.warn(chalk_1.default.yellow(`โš ๏ธ AI generation failed: ${errorMessage}`)); if (error instanceof Error && error.message.includes("API key")) { const { shouldReconfigure } = await inquirer_1.default.prompt([ { type: "confirm", name: "shouldReconfigure", message: "Would you like to reconfigure your API key?", default: true, }, ]); if (shouldReconfigure) { await (0, configure_1.configureCommand)(); console.log(chalk_1.default.blue("\n๐Ÿ”„ Please run the command again with your new configuration.")); return; } } console.log(chalk_1.default.gray("Falling back to rule-based suggestions...\n")); suggestions = this.getFallbackSuggestions(analysis); usingFallback = true; } } else { console.log(chalk_1.default.gray("\n๐Ÿ“ Using rule-based suggestions (AI disabled)\n")); suggestions = this.getFallbackSuggestions(analysis); usingFallback = true; } await this.commitInteractive(suggestions, analysis, issueRef, options); } async runMultiCommit(analysis, options) { const groups = this.multiCommitAnalyzer.groupFiles(analysis); console.log(chalk_1.default.cyan.bold(`\n๐Ÿ”„ Splitting into ${groups.length} commits:\n`)); groups.forEach((group, i) => { console.log(chalk_1.default.gray(`${i + 1}. ${group.reason}`)); console.log(chalk_1.default.gray(` Files: ${group.files.slice(0, 3).join(", ")}${group.files.length > 3 ? "..." : ""}`)); }); const { proceed } = await inquirer_1.default.prompt([ { type: "confirm", name: "proceed", message: "Proceed with multi-commit?", default: true, }, ]); if (!proceed) { console.log(chalk_1.default.yellow("\nCancelled. Falling back to single commit mode.")); return this.run({ ...options, multiCommit: false }); } for (let i = 0; i < groups.length; i++) { const group = groups[i]; console.log(chalk_1.default.cyan.bold(`\n๐Ÿ“ Commit ${i + 1}/${groups.length}: ${group.reason}`)); // Generate suggestions for this group let suggestions = [group.suggestedMessage]; if (options.useAi) { try { const configManager = new config_1.ConfigManager(); const providerConfig = configManager.getProviderConfig(); if (providerConfig.apiKey || this.hasEnvironmentApiKey(providerConfig.provider)) { const provider = (0, providers_1.createProvider)(providerConfig); suggestions = await provider.generateCommitMessage(group.analysis); } } catch (error) { console.log(chalk_1.default.gray("Using suggested message for this commit")); } } await this.commitInteractive(suggestions, group.analysis, null, options, group.files); } console.log(chalk_1.default.green.bold("\nโœ… All commits completed!")); } async commitInteractive(suggestions, analysis, issueRef, options, specificFiles) { console.log(chalk_1.default.cyan.bold("๐Ÿ’ก Suggested commit messages:\n")); const choices = suggestions.map((s, i) => { const formatted = this.formatCommitMessage(s); const preview = formatted.split("\n")[0]; return { name: `${chalk_1.default.gray(`${i + 1}.`)} ${preview}`, value: i, short: preview, }; }); choices.push({ name: chalk_1.default.magenta("๐Ÿ”— Combine all suggestions"), value: -1, short: "Combined", }); choices.push({ name: chalk_1.default.gray("โœ๏ธ Write custom message"), value: -2, short: "Custom message", }); const { selectedIndex } = await inquirer_1.default.prompt([ { type: "list", name: "selectedIndex", message: "Choose a commit message:", choices, pageSize: 10, }, ]); let commitMessage; if (selectedIndex === -1) { const combined = this.combineCommitMessages(suggestions); const combinedFormatted = this.formatCommitMessage(combined); console.log(chalk_1.default.cyan("\n๐Ÿ“ฆ Combined message:")); console.log(chalk_1.default.white(combinedFormatted)); const { action } = await inquirer_1.default.prompt([ { type: "list", name: "action", message: "What would you like to do?", choices: [ { name: "โœ… Use this combined message", value: "use" }, { name: "โœ๏ธ Edit this message", value: "edit" }, { name: "๐Ÿ”™ Go back to suggestions", value: "back" }, ], }, ]); if (action === "back") { return this.commitInteractive(suggestions, analysis, issueRef, options, specificFiles); } else if (action === "edit") { const { edited } = await inquirer_1.default.prompt([ { type: "input", name: "edited", message: "Edit commit message:", default: combinedFormatted, validate: (input) => { if (!input.trim()) return "Commit message cannot be empty"; return true; }, }, ]); commitMessage = edited; } else { commitMessage = combinedFormatted; } } else if (selectedIndex === -2) { const { customMessage } = await inquirer_1.default.prompt([ { type: "input", name: "customMessage", message: "Enter your commit message:", validate: (input) => { if (!input.trim()) return "Commit message cannot be empty"; return true; }, }, ]); commitMessage = customMessage; } else { let selected = suggestions[selectedIndex]; // Add issue reference if available if (issueRef && options.linkIssues !== false) { selected = this.issueTracker.appendIssueToCommit(selected, issueRef); } const formatted = this.formatCommitMessage(selected); const { action } = await inquirer_1.default.prompt([ { type: "list", name: "action", message: "What would you like to do?", choices: [ { name: "โœ… Use this message", value: "use" }, { name: "โœ๏ธ Edit this message", value: "edit" }, { name: "๐Ÿ”™ Choose a different message", value: "back" }, ], }, ]); if (action === "back") { return this.commitInteractive(suggestions, analysis, issueRef, options, specificFiles); } else if (action === "edit") { const { edited } = await inquirer_1.default.prompt([ { type: "input", name: "edited", message: "Edit commit message:", default: formatted, validate: (input) => { if (!input.trim()) return "Commit message cannot be empty"; return true; }, }, ]); commitMessage = edited; } else { commitMessage = formatted; } } if (!commitMessage.trim()) { console.log(chalk_1.default.red("\nโŒ Commit cancelled - empty message")); return; } try { let commitCmd = specificFiles ? `git commit ${specificFiles .map((f) => `"${f}"`) .join(" ")} -m "${commitMessage.replace(/"/g, '\\"')}"` : `git commit -m "${commitMessage.replace(/"/g, '\\"')}"`; if (options.noverify) { commitCmd += " --no-verify"; } this.exec(commitCmd); console.log(chalk_1.default.green("\nโœ… Commit successful!")); if (options.push && !specificFiles) { console.log(chalk_1.default.blue("\n๐Ÿ“ค Pushing to remote...")); const currentBranch = this.exec("git branch --show-current"); this.exec(`git push origin ${currentBranch}`); console.log(chalk_1.default.green("โœ… Pushed successfully!")); } } catch (error) { console.error(chalk_1.default.red("โŒ Commit failed:"), error); process.exit(1); } } getFallbackSuggestions(analysis) { const { filesChanged, additions, deletions } = analysis; const suggestions = []; const hasTests = filesChanged.some((f) => f.includes("test") || f.includes("spec") || f.includes("__tests__")); const hasDocs = filesChanged.some((f) => f.includes("README") || f.includes(".md")); const hasConfig = filesChanged.some((f) => f.includes("config") || f.includes(".json") || f.includes("package.json")); if (additions > deletions * 2 && additions > 20) { suggestions.push({ type: "feat", subject: `add new feature`, }); } if (deletions > additions * 2 && deletions > 20) { suggestions.push({ type: "refactor", subject: `remove unused code`, }); } if (hasTests) { suggestions.push({ type: "test", subject: `add tests`, }); } if (hasDocs) { suggestions.push({ type: "docs", subject: "update documentation", }); } if (hasConfig) { suggestions.push({ type: "chore", subject: "update configuration", }); } if (suggestions.length === 0) { suggestions.push({ type: "feat", subject: `add feature`, }, { type: "fix", subject: `fix issue`, }, { type: "refactor", subject: `refactor code`, }); } return suggestions.slice(0, 5); } } // CLI setup const program = new commander_1.Command(); program .name("commitgen") .description("AI-powered commit message generator for Git") .version("0.2.2") .option("-p, --push", "Push changes after committing") .option("-n, --noverify", "Skip git hooks (--no-verify)") .option("--use-ai", "Use AI generation (default: enabled)") .option("--no-use-ai", "Disable AI generation, use rule-based suggestions only") .option("-m, --multi-commit", "Enable multi-commit mode for atomic commits") .option("--no-multi-commit", "Disable multi-commit mode") .option("--no-history", "Disable commit history learning") .option("--no-issues", "Disable issue tracker integration") .action(async (options) => { const commitGen = new CommitGen(); // Default useAi to true if not explicitly set if (options.useAi === undefined) { options.useAi = true; } await commitGen.run(options); }); program .command("config") .description("Configure AI provider and settings") .action(configure_1.configureCommand); program .command("show-config") .description("Show current configuration") .action(() => { const configManager = new config_1.ConfigManager(); const config = configManager.getProviderConfig(); console.log(chalk_1.default.cyan.bold("\nโš™๏ธ Current Configuration\n")); console.log(chalk_1.default.gray(`Provider: ${chalk_1.default.white(config.provider)}`)); console.log(chalk_1.default.gray(`Model: ${chalk_1.default.white(config.model || "default")}`)); console.log(chalk_1.default.gray(`API Key: ${config.apiKey ? chalk_1.default.green("configured") : chalk_1.default.red("not set")}`)); if (!config.apiKey) { console.log(chalk_1.default.yellow("\n๐Ÿ’ก Run 'commitgen config' to set up your API key")); } }); program.parse(); //# sourceMappingURL=index.js.map