@untools/commitgen
Version:
CLI to create generate commit messages
570 lines โข 24.2 kB
JavaScript
#!/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