Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

commit.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

commit.tsBlame319 lines · 1 contributor
9018b1fClaude1/**
2 * `gluecron commit` — wrap `git commit` with an AI-drafted message.
3 *
4 * Flow:
5 * 1. If `-m` is provided, just shell out to git commit verbatim.
6 * 2. Otherwise:
7 * a. Stage all if `-a` was passed.
8 * b. Read `git diff --cached`.
9 * c. POST to `/api/v2/ai/commit-message`.
10 * d. Print the proposed subject + body and prompt
11 * [y]es / [e]dit / [n]o.
12 * e. yes → git commit -m <subject> -m <body>
13 * edit → drop into $EDITOR with the message pre-filled,
14 * then `git commit -F <tmp>` after the editor exits.
15 * no → abort.
16 *
17 * The CLI is dependency-free Bun; we shell out to `git` via Bun.spawnSync.
18 * All exported helpers are pure functions so the test suite can exercise
19 * them without a real git repository.
20 */
21
22import { spawnSync } from "node:child_process";
23import { writeFileSync, mkdtempSync, readFileSync } from "node:fs";
24import { tmpdir } from "node:os";
25import { join } from "node:path";
26
27export interface CommitMessage {
28 subject: string;
29 body: string;
30}
31
32export interface CommitConfig {
33 host: string;
34 token?: string;
35}
36
37// ──────────────────────────── arg parsing ────────────────────────────
38
39export interface CommitArgs {
40 stageAll: boolean;
41 message?: string;
42 bodyExtra?: string;
43 style: "conventional" | "plain";
44 yes: boolean; // --yes / -y → skip the prompt
45}
46
47export function parseCommitArgs(argv: string[]): CommitArgs {
48 const args: CommitArgs = {
49 stageAll: false,
50 style: "conventional",
51 yes: false,
52 };
53 for (let i = 0; i < argv.length; i++) {
54 const a = argv[i];
55 if (a === "-a" || a === "--all") {
56 args.stageAll = true;
57 } else if (a === "-m" || a === "--message") {
58 args.message = argv[++i];
59 } else if (a === "-y" || a === "--yes") {
60 args.yes = true;
61 } else if (a === "--plain") {
62 args.style = "plain";
63 } else if (a === "--conventional") {
64 args.style = "conventional";
65 }
66 }
67 return args;
68}
69
70// ──────────────────────────── git wrappers ────────────────────────────
71
72function git(
73 argv: string[],
74 opts: { input?: string; cwd?: string } = {}
75): { stdout: string; stderr: string; exitCode: number } {
76 const r = spawnSync("git", argv, {
77 cwd: opts.cwd ?? process.cwd(),
78 input: opts.input,
79 encoding: "utf8",
80 stdio: ["pipe", "pipe", "pipe"],
81 });
82 return {
83 stdout: r.stdout || "",
84 stderr: r.stderr || "",
85 exitCode: r.status ?? 1,
86 };
87}
88
89export function readStagedDiff(stageAll: boolean): string {
90 // -a in `git commit` stages tracked changes before committing. We
91 // emulate that by passing `--cached` after a `git add -u` (tracked
92 // only — `git add -A` would sweep up untracked, which is NOT what
93 // `git commit -a` does).
94 if (stageAll) {
95 git(["add", "-u"]);
96 }
97 const r = git(["diff", "--cached"]);
98 if (r.exitCode !== 0) return "";
99 return r.stdout;
100}
101
102// ──────────────────────────── API call ────────────────────────────
103
104export async function requestCommitMessage(
105 cfg: CommitConfig,
106 diff: string,
107 style: "conventional" | "plain",
108 fetchImpl: typeof fetch = fetch
109): Promise<CommitMessage> {
110 const url = cfg.host.replace(/\/+$/, "") + "/api/v2/ai/commit-message";
111 const headers: Record<string, string> = {
112 "content-type": "application/json",
113 accept: "application/json",
114 };
115 if (cfg.token) headers.authorization = `Bearer ${cfg.token}`;
116
117 const res = await fetchImpl(url, {
118 method: "POST",
119 headers,
120 body: JSON.stringify({ diff, style }),
121 });
122 const text = await res.text();
123 let json: { subject?: string; body?: string; error?: string };
124 try {
125 json = JSON.parse(text);
126 } catch {
127 throw new Error(`Server returned non-JSON [${res.status}]: ${text.slice(0, 200)}`);
128 }
129 if (!res.ok) {
130 throw new Error(json?.error || `Server returned ${res.status}`);
131 }
132 return {
133 subject: typeof json.subject === "string" ? json.subject : "",
134 body: typeof json.body === "string" ? json.body : "",
135 };
136}
137
138// ──────────────────────────── prompt + editor ────────────────────────────
139
140export function formatProposedMessage(m: CommitMessage): string {
141 let out = `\nProposed commit message:\n\n ${m.subject}\n`;
142 if (m.body.trim()) {
143 out += "\n";
144 for (const line of m.body.split("\n")) {
145 out += ` ${line}\n`;
146 }
147 }
148 return out;
149}
150
151async function readChar(prompt: string): Promise<string> {
152 process.stdout.write(prompt);
153 return new Promise((resolve) => {
154 const onData = (chunk: Buffer | string) => {
155 process.stdin.removeListener("data", onData);
156 process.stdin.pause();
157 resolve(String(chunk).trim().toLowerCase());
158 };
159 process.stdin.resume();
160 process.stdin.once("data", onData);
161 });
162}
163
164export function editInEditor(m: CommitMessage): CommitMessage {
165 const editor = process.env.EDITOR || process.env.VISUAL || "vi";
166 const dir = mkdtempSync(join(tmpdir(), "gluecron-commit-"));
167 const file = join(dir, "COMMIT_EDITMSG");
168 const seed =
169 m.subject +
170 (m.body.trim() ? `\n\n${m.body}\n` : "\n") +
171 "\n# Edit the commit message above. Lines starting with '#' are ignored.\n";
172 writeFileSync(file, seed, "utf8");
173 // Bun has no synchronous spawn with TTY inheritance — node:child_process
174 // gives it to us cleanly. We block until the editor returns.
175 spawnSync(editor, [file], { stdio: "inherit" });
176 const raw = readFileSync(file, "utf8");
177 const lines = raw
178 .split("\n")
179 .filter((l) => !l.startsWith("#"))
180 .join("\n")
181 .trim();
182 if (!lines) return m;
183 const subject = (lines.split("\n")[0] || "").trim();
184 const body = lines.split("\n").slice(1).join("\n").trim();
185 return { subject, body };
186}
187
188// ──────────────────────────── main command ────────────────────────────
189
190export interface CommitDeps {
191 out: (msg: string) => void;
192 readChar?: (prompt: string) => Promise<string>;
193 fetchImpl?: typeof fetch;
194 editorEdit?: (m: CommitMessage) => CommitMessage;
195 // Test-only override — lets us bypass the network without a fake fetch.
196 generate?: (diff: string, style: "conventional" | "plain") => Promise<CommitMessage>;
197}
198
199/**
200 * Run `gluecron commit`. Returns the exit code.
201 *
202 * Pulled out from the dispatcher so the CLI test suite can drive it
203 * with synthetic deps and assert on behaviour without spawning git or
204 * hitting the network.
205 */
206export async function runCommit(
207 argv: string[],
208 cfg: CommitConfig,
209 deps: CommitDeps
210): Promise<number> {
211 const args = parseCommitArgs(argv);
212
213 // Fast path: -m provided → straight pass-through to git.
214 if (args.message) {
215 const gitArgs = ["commit"];
216 if (args.stageAll) gitArgs.push("-a");
217 gitArgs.push("-m", args.message);
218 if (args.bodyExtra) gitArgs.push("-m", args.bodyExtra);
219 const r = spawnSync("git", gitArgs, { stdio: "inherit" });
220 return r.status ?? 1;
221 }
222
223 // Pull the staged diff (after auto-adding tracked changes if -a).
224 const diff = readStagedDiff(args.stageAll);
225 if (!diff.trim()) {
226 deps.out(
227 "error: nothing to commit. Stage changes with `git add` (or pass -a)."
228 );
229 return 1;
230 }
231
232 // Ask the API for a message — or use the supplied generator (tests).
233 let proposed: CommitMessage;
234 try {
235 proposed = deps.generate
236 ? await deps.generate(diff, args.style)
237 : await requestCommitMessage(cfg, diff, args.style, deps.fetchImpl);
238 } catch (err) {
239 deps.out(`error: ${(err as Error).message}`);
240 return 1;
241 }
242
243 if (!proposed.subject.trim()) {
244 deps.out("error: AI returned an empty subject. Try again or pass -m.");
245 return 1;
246 }
247
248 deps.out(formatProposedMessage(proposed));
249
250 // Skip prompt when --yes was passed (CI / scripted commits).
251 let choice: string;
252 if (args.yes) {
253 choice = "y";
254 } else {
255 const rc = deps.readChar ?? readChar;
256 choice = await rc("Commit with this message? [y/e/n]: ");
257 }
258
259 if (choice === "n" || choice === "no") {
260 deps.out("Aborted.");
261 return 1;
262 }
263
264 let final = proposed;
265 if (choice === "e" || choice === "edit") {
266 final = (deps.editorEdit ?? editInEditor)(proposed);
267 if (!final.subject.trim()) {
268 deps.out("Aborted — empty subject after edit.");
269 return 1;
270 }
271 }
272
273 // Commit. We use two `-m` arguments so git formats the body the usual
274 // way (separated by a blank line from the subject).
275 const commitArgs = ["commit", "-m", final.subject];
276 if (final.body.trim()) {
277 commitArgs.push("-m", final.body);
278 }
279 const r = spawnSync("git", commitArgs, { stdio: "inherit" });
280 return r.status ?? 1;
281}
282
283// ──────────────────────────── `gluecron ai commit-msg` ────────────────────────────
284//
285// Used by the prepare-commit-msg hook. Reads diff from stdin (or runs
286// `git diff --cached`), asks the API for a message, prints just
287// `<subject>\n\n<body>` on stdout. No prompts, no edit flow.
288
289export interface AiCommitMsgDeps {
290 out: (msg: string) => void;
291 fetchImpl?: typeof fetch;
292 generate?: (diff: string, style: "conventional" | "plain") => Promise<CommitMessage>;
293 // Allow tests to supply an explicit diff (so we don't have to mock stdin).
294 diff?: string;
295}
296
297export async function runAiCommitMsg(
298 cfg: CommitConfig,
299 deps: AiCommitMsgDeps
300): Promise<number> {
301 const diff = deps.diff ?? readStagedDiff(false);
302 if (!diff.trim()) {
303 return 1; // hook will fall back to git's default empty message.
304 }
305 try {
306 const msg = deps.generate
307 ? await deps.generate(diff, "conventional")
308 : await requestCommitMessage(cfg, diff, "conventional", deps.fetchImpl);
309 if (!msg.subject.trim()) return 1;
310 if (msg.body.trim()) {
311 deps.out(`${msg.subject}\n\n${msg.body}`);
312 } else {
313 deps.out(msg.subject);
314 }
315 return 0;
316 } catch {
317 return 1;
318 }
319}