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; userSuggestion?: 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", ""); let prompt: string; if (options.userSuggestion) { prompt = `The user suggested: "${options.userSuggestion}" Improve this commit message to be concise and follow Conventional Commit format. Format: (): Max 72 characters for the subject line. Types: feat, fix, refactor, docs, style, test, chore, perf, ci, build, revert Here are the git changes: ${diff} Only output the improved commit message, nothing else.`; } else { 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", "--pure", "--format", "default", "-m", "opencode/deepseek-v4-flash-free", "--variant", "minimal", ]; if (model) { args.push("--model", model); } const proc = spawn("opencode", args, { stdio: ["pipe", "pipe", "pipe"], shell: false, cwd: repoRoot || undefined, env: { ...process.env, OPENCODE_SERVER_PASSWORD: undefined, OPENCODE_SERVER_USERNAME: undefined, OPENCODE_CLIENT: undefined, OPENCODE_HOST: undefined, OPENCODE_PORT: undefined, OPENCODE_SKIP_START: undefined, OPENCODE_BINARY: 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; }