refactor: extract commit, pr, config into command modules
- commit: add --amend, -m/--message, streaming output, auto-stage flow - pr: add clipboard copy on abort, TTY-safe platform selection - config: add get/set/list subcommands for non-interactive use - All modules use dynamic terminal colors and proper TTY detection
This commit is contained in:
@@ -0,0 +1,418 @@
|
||||
import * as readline from "node:readline";
|
||||
import {
|
||||
isGitRepo,
|
||||
getRepoRoot,
|
||||
getStagedFiles,
|
||||
getUnstagedFiles,
|
||||
getStagedDiff,
|
||||
getRecentCommits,
|
||||
stageFiles,
|
||||
commit,
|
||||
} from "../git";
|
||||
import { selectFiles } from "../selector";
|
||||
import { BACK } 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(", ")}`);
|
||||
}
|
||||
|
||||
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 (stagedFiles.length === 0 && !amend) {
|
||||
if (autoMode && unstagedFiles.length > 0) {
|
||||
await stageFiles(unstagedFiles.map((f) => f.path));
|
||||
console.log(` ${GREEN()}Auto-staged ${unstagedFiles.length} file(s).${RESET()}`);
|
||||
} else if (unstagedFiles.length > 0) {
|
||||
console.error(`\n ${RED()}Error: No staged changes. Use -a to auto-stage, or stage files manually.${RESET()}\n`);
|
||||
return 1;
|
||||
} else {
|
||||
console.error(`\n ${RED()}Error: Nothing to commit.${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 (unstagedFiles.length > 0) {
|
||||
if (autoMode) {
|
||||
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 0;
|
||||
if (selected.length > 0) {
|
||||
await stageFiles(selected);
|
||||
console.log(` ${GREEN()}Staged ${selected.length} file(s).${RESET()}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user