434 lines
14 KiB
TypeScript
434 lines
14 KiB
TypeScript
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<string> {
|
|
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<string | null> {
|
|
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<number> {
|
|
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<number> {
|
|
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;
|
|
}
|