/** * @file commit-message.js * @description Suggests a commit message for a lane's STAGED changes with the * user's own `claude` CLI: `claude -p` on the staged diff, sent on stdin, with * no tools, no hooks/plugins/CLAUDE.md (`--safe-mode`) and no transcript on * disk (`--no-session-persistence`), so the call neither acts on the repo nor * shows up as a session in the dashboard. Read-only on git; the diff is scoped * to the lane's cwd and never carries a secret file (`isSecretOnDisk`). * @author Nguyễn Ngọc Trí Vĩ */ const os = require("node:os"); const { spawn } = require("node:child_process"); const worktree = require("./worktree"); const aiTools = require("./ai-tools"); const { isSecretOnDisk } = require("./git-diff"); const git = (dir, args, opts) => worktree.git(dir, args, { literal: true, ...opts }); /** Diff characters sent to the model; the rest is cut and marked. */ const MAX_DIFF_CHARS = 60_000; /** A `claude -p` that has not answered by then is killed. */ const TIMEOUT_MS = 120_000; const PROMPT = "Write a git commit message for the staged diff given on stdin. " + "Use the conventional-commits form `type(scope): subject` (imperative, subject at most 72 characters), " + "matching the style of the recent subjects listed there. Add a short body after a blank line only " + "when the change needs explaining. Output ONLY the commit message — no code fences, no commentary."; function bad(code, message) { return Object.assign(new Error(message), { code }); } /** The text the model reads: recent subjects, the staged file list, the diff. */ async function buildInput(dir) { const names = (await git(dir, ["diff", "--cached", "--name-only", "--relative", "-z"])).stdout .split("\0") .filter(Boolean); const paths = []; for (const p of names) if (!(await isSecretOnDisk(dir, p))) paths.push(p); if (!paths.length) throw bad("ENOSTAGED", "nothing is staged in this workspace"); let diff; try { diff = ( await git( dir, ["diff", "--cached", "--no-color", "--no-ext-diff", "--relative", "--", ...paths], { maxBuffer: MAX_DIFF_CHARS * 4 + 64 * 1024 } ) ).stdout; } catch (err) { // An oversized diff: git's partial output is still the useful head. if (err.code !== "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" || !err.git?.stdout) throw err; diff = String(err.git.stdout); } if (diff.length > MAX_DIFF_CHARS) diff = `${diff.slice(0, MAX_DIFF_CHARS)}\n[diff truncated]\n`; const log = await git(dir, ["log", "-10", "--format=%s"]) .then((r) => r.stdout.trim()) .catch(() => ""); // no commit yet return [ log ? `Recent commit subjects:\n${log}` : "", `Staged files:\n${paths.join("\n")}`, `Staged diff:\n${diff}`, ] .filter(Boolean) .join("\n\n"); } /** Run `bin` with `args`, `input` on stdin; resolve its stdout. */ function run(bin, args, input) { return new Promise((resolve, reject) => { const child = spawn(bin, args, { cwd: os.tmpdir(), stdio: ["pipe", "pipe", "pipe"] }); let out = ""; let errOut = ""; const timer = setTimeout(() => child.kill("SIGKILL"), TIMEOUT_MS); child.stdout.on("data", (d) => (out += d)); child.stderr.on("data", (d) => (errOut += d)); child.on("error", (err) => { clearTimeout(timer); reject(bad("EAI", err.message)); }); child.on("close", (code, signal) => { clearTimeout(timer); if (code === 0) return resolve(out); const why = signal ? "timed out" : (errOut || out).trim().slice(-500) || `exit ${code}`; reject(bad("EAI", `claude failed: ${why}`)); }); child.stdin.on("error", () => {}); // a CLI that exits early closes stdin child.stdin.end(input); }); } /** * A suggested message for what `dir` has staged. * @param {string} dir the lane's (or session worktree's) cwd * @param {{bin?: string}} [opts] `bin` overrides the detected `claude` (tests) * @returns {Promise} */ async function suggestCommitMessage(dir, { bin } = {}) { const input = await buildInput(dir); const claude = bin || aiTools.detect("claude", { fresh: true }).path; if (!claude) throw bad("ENOTOOL", "the claude CLI was not found on PATH"); const out = await run( claude, ["-p", "--safe-mode", "--no-session-persistence", "--tools", "", "--model", "haiku", PROMPT], input ); const message = out .trim() .replace(/^```[a-z]*\n?|\n?```$/g, "") .trim(); if (!message) throw bad("EAI", "claude returned an empty message"); return message; } module.exports = { suggestCommitMessage, buildInput };