import * as readline from "node:readline"; import { isGitRepo, getRepoRoot, getStagedFiles, getUnstagedFiles, getStagedDiff, getRecentCommits, stageFiles, applyFileSelection, commit, } from "../git"; import { selectFiles } from "../selector"; import { BACK, SKIP_WAIT } from "../menu"; import { collectProjectContext } from "../context"; import { buildPrompt, SYSTEM_PROMPT } from "../prompt"; import { generateCommitMessage } from "../ai"; import { copyToClipboard } from "../clipboard"; import { BOLD, GREEN, YELLOW, CYAN, RED, DIM, RESET } from "../terminal"; import { isStdinTTY } from "../tty"; import type { Config, CommitResult, StreamCallbacks } from "../types"; import { loadConfig } from "../config"; import type { ParsedArgs } from "../cli"; function ask(question: string): Promise { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); return new Promise((resolve) => { rl.question(question, (answer) => { rl.close(); resolve(answer.trim()); }); }); } function printCommitResult(result: CommitResult, msg: string) { console.log(`\n ${GREEN()}${BOLD()}✔ Committed successfully!${RESET()}`); const id = result.branch && result.hash ? `${YELLOW()}[${result.branch} ${result.hash}]${RESET()}` : result.hash ? `${YELLOW()}${result.hash}${RESET()}` : ""; console.log(` ${id} ${msg}`); const parts: string[] = []; if (result.files > 0) parts.push(`${YELLOW()}${result.files} file${result.files > 1 ? "s" : ""} changed${RESET()}`); if (result.insertions > 0) parts.push(`${GREEN()}${result.insertions} insertion${result.insertions > 1 ? "s" : ""}(+)${RESET()}`); if (result.deletions > 0) parts.push(`${RED()}${result.deletions} deletion${result.deletions > 1 ? "s" : ""}(-)${RESET()}`); if (parts.length > 0) console.log(` ${parts.join(", ")}`); } function printSelectionResult(result: { staged: string[]; unstaged: string[] }) { const parts: string[] = []; if (result.staged.length > 0) { parts.push(`${GREEN()}staged ${result.staged.length}${RESET()}`); } if (result.unstaged.length > 0) { parts.push(`${YELLOW()}unstaged ${result.unstaged.length}${RESET()}`); } if (parts.length > 0) { console.log(` Updated staging area: ${parts.join(", ")}`); } } async function confirmCommit(message: string): Promise<"y" | "n" | "e"> { console.log(`\n ${BOLD()}Generated commit message:${RESET()}`); console.log(` ${GREEN()}${message}${RESET()}\n`); const answer = await ask(` Use this message? [${GREEN()}Y${RESET()}/n/e] `); const lower = answer.toLowerCase(); if (lower === "n") return "n"; if (lower === "e") return "e"; return "y"; } async function editMessage(current: string): Promise { if (!isStdinTTY()) return null; process.stdout.write(` ${DIM()}Edit message (Enter to confirm, Esc to abort):${RESET()}\n`); const savedRaw = process.stdin.isRaw; process.stdin.setRawMode(true); process.stdin.resume(); let buffer = current; let cursor = current.length; function render() { process.stdout.write("\x1b[2K\r > " + buffer); if (cursor < buffer.length) { process.stdout.write(`\x1b[${buffer.length - cursor}D`); } } process.stdout.write(" > "); process.stdout.write(buffer); return new Promise((resolve) => { let escapeBuf = ""; process.stdin.on("data", (data: Buffer) => { const key = data.toString(); if (key === "\x03") { process.stdin.setRawMode(savedRaw === true); process.stdin.pause(); process.stdin.removeAllListeners("data"); process.stdout.write("\n"); resolve(null); return; } if (key === "\x1b" || key.startsWith("\x1b[")) { escapeBuf = key; if (key.length >= 3) { handleSeq(key); escapeBuf = ""; } return; } if (escapeBuf) { escapeBuf += key; if (/^[A-Za-z~]$/.test(key)) { handleSeq(escapeBuf); escapeBuf = ""; } else if (escapeBuf.length > 8) escapeBuf = ""; return; } if (key === "\r") { process.stdin.setRawMode(savedRaw === true); process.stdin.pause(); process.stdin.removeAllListeners("data"); process.stdout.write("\n"); const result = buffer.trim(); resolve(result || null); return; } if (key === "\x7f") { if (cursor > 0) { buffer = buffer.slice(0, cursor - 1) + buffer.slice(cursor); cursor--; render(); } return; } if (key === "\x01") { if (cursor > 0) { process.stdout.write(`\x1b[${cursor}D`); cursor = 0; } return; } if (key === "\x05") { if (cursor < buffer.length) { process.stdout.write(`\x1b[${buffer.length - cursor}C`); cursor = buffer.length; } return; } if (key === "\x0b") { buffer = buffer.slice(0, cursor); render(); return; } if (key === "\x15") { buffer = buffer.slice(cursor); cursor = 0; render(); return; } if (key.charCodeAt(0) >= 32 && key.charCodeAt(0) < 127) { buffer = buffer.slice(0, cursor) + key + buffer.slice(cursor); cursor++; render(); } }); function handleSeq(seq: string) { if (seq === "\x1b[D" || seq === "\x1bOD") { if (cursor > 0) { cursor--; process.stdout.write("\x1b[D"); } } else if (seq === "\x1b[C" || seq === "\x1bOC") { if (cursor < buffer.length) { cursor++; process.stdout.write("\x1b[C"); } } else if (seq === "\x1b[H" || seq === "\x1b[1~" || seq === "\x1bOH") { if (cursor > 0) { process.stdout.write(`\x1b[${cursor}D`); cursor = 0; } } else if (seq === "\x1b[F" || seq === "\x1b[4~" || seq === "\x1bOF") { if (cursor < buffer.length) { process.stdout.write(`\x1b[${buffer.length - cursor}C`); cursor = buffer.length; } } else if (seq === "\x1b[3~") { if (cursor < buffer.length) { buffer = buffer.slice(0, cursor) + buffer.slice(cursor + 1); render(); } } } }); } export async function handleCommit(args: ParsedArgs): Promise { const autoMode = args.flags["all"] as boolean || args.flags["auto"] as boolean; const dryRun = args.flags["dry-run"] as boolean; const amend = args.flags["amend"] as boolean; const customMessage = args.flags["message"] as string | undefined; const verbose = args.flags["verbose"] as boolean; if (!(await isGitRepo())) { console.error(`\n ${RED()}Error: Not a git repository.${RESET()}\n`); return 1; } // If a custom message is provided, skip AI and commit directly if (customMessage) { const stagedFiles = await getStagedFiles(); const unstagedFiles = await getUnstagedFiles(); if (!amend && stagedFiles.length === 0 && unstagedFiles.length === 0) { console.error(`\n ${RED()}Error: Nothing to commit.${RESET()}\n`); return 1; } if (!amend && autoMode && unstagedFiles.length > 0) { await stageFiles(unstagedFiles.map((f) => f.path)); console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`); } else if (!amend) { if (isStdinTTY()) { const selected = await selectFiles(stagedFiles, unstagedFiles); if (selected === BACK) return SKIP_WAIT as unknown as number; printSelectionResult(await applyFileSelection(stagedFiles, unstagedFiles, selected)); } else if (stagedFiles.length === 0 && unstagedFiles.length > 0) { console.error(`\n ${RED()}Error: No staged changes. Use -a to auto-stage, or stage files manually.${RESET()}\n`); return 1; } } const diff = await getStagedDiff(); if (!diff && !amend) { console.log(` ${DIM()}Nothing to commit.${RESET()}`); return 0; } try { const result = await commit(customMessage); printCommitResult(result, customMessage); return 0; } catch (err) { console.error(`\n ${RED()}Commit failed: ${err instanceof Error ? err.message : err}${RESET()}\n`); return 1; } } // Handle amend if (amend) { return handleAmendCommit(args); } const stagedFiles = await getStagedFiles(); const unstagedFiles = await getUnstagedFiles(); if (stagedFiles.length === 0 && unstagedFiles.length === 0) { console.log(` ${DIM()}Nothing to commit. No staged or unstaged changes.${RESET()}`); return 0; } if (autoMode && unstagedFiles.length > 0) { await stageFiles(unstagedFiles.map((f) => f.path)); console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`); } else { const selected = await selectFiles(stagedFiles, unstagedFiles); if (selected === BACK) return SKIP_WAIT as unknown as number; printSelectionResult(await applyFileSelection(stagedFiles, unstagedFiles, selected)); } const diff = await getStagedDiff(); if (!diff) { console.log(` ${DIM()}Nothing to commit. No staged changes to commit.${RESET()}`); return 0; } const config = await loadConfig(); if (!config.apiKey) { console.error(`\n ${RED()}Error: API key not set. Run ${BOLD()}gai config${RESET()}${RED()} to configure.${RESET()}\n`); return 1; } const MAX_DIFF_SIZE = 15000; const truncatedDiff = diff.length > MAX_DIFF_SIZE ? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)" : diff; const repoRoot = await getRepoRoot(); const projectCtx = await collectProjectContext(repoRoot); const recentCommits = await getRecentCommits(10); const userPrompt = buildPrompt({ readme: projectCtx.readme, packageDescription: projectCtx.packageDescription, structure: projectCtx.structure, recentCommits, diff: truncatedDiff, }); if (verbose) { console.log(` ${DIM()}Model: ${config.model} | API: ${config.apiBase}${RESET()}`); } const tty = isStdinTTY(); if (tty) { console.log("\n Generating commit message..."); } let message: string; try { const callbacks: StreamCallbacks | undefined = tty ? { onToken: (token) => process.stdout.write(token), } : undefined; if (callbacks) process.stdout.write(" "); message = await generateCommitMessage(config, SYSTEM_PROMPT, userPrompt, callbacks); if (callbacks) process.stdout.write("\n"); } catch (err) { console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`); return 1; } if (dryRun) { console.log(`\n ${BOLD()}Generated commit message:${RESET()}`); console.log(` ${GREEN()}${message}${RESET()}`); await copyToClipboard(message); console.log(`\n ${DIM()}(dry-run, message copied to clipboard)${RESET()}`); return 0; } const action = await confirmCommit(message); if (action === "y") { try { const result = await commit(message); printCommitResult(result, message); } catch (err) { console.error(`\n ${RED()}Commit failed: ${err instanceof Error ? err.message : err}${RESET()}\n`); return 1; } } else if (action === "e") { const edited = await editMessage(message); if (edited) { try { const result = await commit(edited); printCommitResult(result, edited); } catch (err) { console.error(`\n ${RED()}Commit failed: ${err instanceof Error ? err.message : err}${RESET()}\n`); return 1; } } else { console.log(" Aborted."); } } else { const copied = await copyToClipboard(message); console.log(copied ? ` Aborted. Message copied to clipboard.` : ` Aborted. Message: ${message}`); } return 0; } async function handleAmendCommit(args: ParsedArgs): Promise { const config = await loadConfig(); if (!config.apiKey) { console.error(`\n ${RED()}Error: API key not set.${RESET()}\n`); return 1; } // Get the diff of the last commit (what would be amended) let diff: string; try { diff = await Bun.$`git diff HEAD~1..HEAD`.quiet().text(); diff = diff.trim(); } catch { // If there's no previous commit (first commit), get staged diff diff = await getStagedDiff(); } if (!diff) { // Try getting the commit message from HEAD try { const lastMsg = await Bun.$`git log -1 --format=%B`.quiet().text(); if (lastMsg.trim()) { console.log(` Last commit message: ${DIM()}${lastMsg.trim()}${RESET()}`); const newMsg = await editMessage(lastMsg.trim()); if (newMsg) { await Bun.spawn(["git", "commit", "--amend", "-m", newMsg], { stdio: ["inherit", "inherit", "inherit"] }); } return 0; } } catch {} console.log(` ${DIM()}No changes to amend.${RESET()}`); return 0; } const repoRoot = await getRepoRoot(); const projectCtx = await collectProjectContext(repoRoot); const recentCommits = await getRecentCommits(10); const MAX_DIFF_SIZE = 15000; const truncatedDiff = diff.length > MAX_DIFF_SIZE ? diff.substring(0, MAX_DIFF_SIZE) + "\n... (truncated)" : diff; const userPrompt = buildPrompt({ readme: projectCtx.readme, packageDescription: projectCtx.packageDescription, structure: projectCtx.structure, recentCommits, diff: truncatedDiff, }); console.log("\n Generating amended commit message..."); let message: string; try { message = await generateCommitMessage(config, SYSTEM_PROMPT, userPrompt); } catch (err) { console.error(`\n ${RED()}AI request failed: ${err instanceof Error ? err.message : err}${RESET()}\n`); return 1; } console.log(`\n ${BOLD()}Generated amended message:${RESET()}`); console.log(` ${GREEN()}${message}${RESET()}\n`); const answer = await ask(` Amend commit with this message? [${GREEN()}Y${RESET()}/n/e] `); const lower = answer.toLowerCase(); if (lower === "n") { console.log(" Aborted."); return 0; } if (lower === "e") { const edited = await editMessage(message); if (!edited) { console.log(" Aborted."); return 0; } message = edited; } try { const proc = Bun.spawn(["git", "commit", "--amend", "-m", message], { stdio: ["inherit", "inherit", "inherit"], }); const exitCode = await proc.exited; if (exitCode !== 0) { throw new Error(`git commit --amend failed (exit code ${exitCode})`); } } catch (err) { console.error(`\n ${RED()}Amend failed: ${err instanceof Error ? err.message : err}${RESET()}\n`); return 1; } return 0; }