@cwdpk/cocommit
Version:
AI-powered git commit message generator CLI tool (CoCommit)
663 lines • 26.9 kB
JavaScript
;
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