import { exec, spawn, ExecException } from "child_process"; import * as vscode from "vscode"; import { getGitDiff, getRepositoryRoot } from "./gitService"; import { output } from "./extension"; export interface GenerateOptions { model?: string; } let opencodeAvailableCache: boolean | null = null; const DEFAULT_PROMPT = `You are a helpful assistant that generates git commit messages. Generate a concise Conventional Commit message (max 72 characters for the subject line). Format: (): Types: feat, fix, refactor, docs, style, test, chore, perf, ci, build, revert Only output the commit message, nothing else.`; export async function isOpenCodeAvailable(): Promise { if (opencodeAvailableCache !== null) { return opencodeAvailableCache; } return new Promise((resolve) => { exec("which opencode", (error: ExecException | null) => { opencodeAvailableCache = !error; resolve(opencodeAvailableCache); }); }); } export async function generateCommitMessage( options: GenerateOptions = {}, ): Promise { const opencodeAvailable = await isOpenCodeAvailable(); if (!opencodeAvailable) { throw new Error( "OpenCode is not installed. Please install it from https://opencode.ai", ); } const [diff, repoRoot] = await Promise.all([ getGitDiff(), getRepositoryRoot(), ]); const config = vscode.workspace.getConfiguration("aiCommitExt"); const model = options.model || config.get("model", ""); const prompt = `${DEFAULT_PROMPT} Here are the git changes: ${diff} Generate a concise Conventional Commit message for these changes:`; const log = `[${Date.now()}]\r\n${prompt}`; output.appendLine(log); return new Promise((resolve, reject) => { const args: string[] = [ "run", "--format", "default", "--variant", "minimal", ]; if (model) { args.push("--model", model); } const proc = spawn("opencode", args, { stdio: ["pipe", "pipe", "pipe"], shell: false, cwd: repoRoot || undefined, }); let stdout = ""; let stderr = ""; proc.stdout.on("data", (data: Buffer) => { stdout += data.toString(); }); proc.stderr.on("data", (data: Buffer) => { stderr += data.toString(); }); proc.stdin?.write(prompt + "\n", (err) => { if (err) { new Error(`OpenCode write error with ${err}`); } proc.stdin?.end(); }); proc.on("close", (code: number | null) => { if (code !== 0) { reject( new Error(`OpenCode exited with code ${code}: ${stderr}`), ); return; } const message = parseCommitMessage(stdout); resolve(message); }); proc.on("error", (err: Error) => { reject(new Error(`Failed to run opencode: ${err.message}`)); }); setTimeout(() => { proc.kill(); reject(new Error("OpenCode timed out")); }, 120000); }); } function parseCommitMessage(output: string): string { const lines = output.split("\n").filter((line) => line.trim()); for (const line of lines) { const trimmed = line.trim(); if ( trimmed.includes(":") && (trimmed.startsWith("feat") || trimmed.startsWith("fix") || trimmed.startsWith("refactor") || trimmed.startsWith("docs") || trimmed.startsWith("style") || trimmed.startsWith("test") || trimmed.startsWith("chore") || trimmed.startsWith("perf") || trimmed.startsWith("ci") || trimmed.startsWith("build") || trimmed.startsWith("revert")) ) { return trimmed.substring(0, 200); } } const cleaned = output.trim().split("\n")[0]?.substring(0, 200) || "chore: generated commit message"; return cleaned; }