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

ai-commit-message.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.

ai-commit-message.tsBlame316 lines · 1 contributor
9018b1fClaude1/**
2 * AI-generated commit messages.
3 *
4 * Powers the `gluecron commit` CLI and the `POST /api/v2/ai/commit-message`
5 * endpoint. Given a unified diff (typically `git diff --cached`), returns a
6 * { subject, body } pair.
7 *
8 * - Conventional Commits style by default: `feat(scope): subject` etc.
9 * - The body explains WHY (not what — the diff already says what).
10 * - Falls back to a deterministic heuristic when ANTHROPIC_API_KEY is
11 * missing, the model errors out, or the diff is empty. The CLI must
12 * never block a developer on a Claude outage.
13 * - Diff is capped at ~50KB before being sent to the model — past that
14 * the signal-to-noise ratio drops and we'd just be burning tokens.
15 *
16 * Tests live in src/__tests__/ai-commit-message.test.ts. The module
17 * exports a `__test` bag for the parser + truncation helpers so the
18 * unit tests don't have to call the network path.
19 */
20
21import {
22 getAnthropic,
23 MODEL_HAIKU,
24 extractText,
25 parseJsonResponse,
26 isAiAvailable,
27} from "./ai-client";
28
29export type CommitStyle = "conventional" | "plain";
30
31export interface CommitMessage {
32 subject: string;
33 body: string;
34}
35
36export interface GenerateOptions {
37 style?: CommitStyle;
38}
39
40/** Max bytes of diff we send to the model. Past this we truncate. */
41export const DIFF_BYTE_CAP = 50_000;
42const TRUNCATE_MARKER = "\n... (more)";
43
44/** Conventional-commits types we accept in the subject. */
45const CONVENTIONAL_TYPES = [
46 "feat",
47 "fix",
48 "docs",
49 "style",
50 "refactor",
51 "perf",
52 "test",
53 "build",
54 "ci",
55 "chore",
56 "revert",
57] as const;
58
59type ConventionalType = (typeof CONVENTIONAL_TYPES)[number];
60
61/**
62 * Truncate a diff to DIFF_BYTE_CAP bytes, appending a marker so the model
63 * (and any human reader) knows the input was cut.
64 */
65export function truncateDiff(diff: string, cap = DIFF_BYTE_CAP): string {
66 if (diff.length <= cap) return diff;
67 return diff.slice(0, cap - TRUNCATE_MARKER.length) + TRUNCATE_MARKER;
68}
69
70/**
71 * Deterministic fallback: scan filenames + counts and emit a plausible
72 * conventional-commit. Never blocks on a network call, never throws.
73 *
74 * Heuristics:
75 * - "test" files → test
76 * - "doc"/"readme"/".md" only → docs
77 * - new files dominate → feat
78 * - otherwise → chore
79 *
80 * Subject ≤ 72 chars; body lists the top-3 touched paths so the human
81 * still has signal even when Claude is unavailable.
82 */
83export function heuristicMessage(
84 diff: string,
85 style: CommitStyle = "conventional"
86): CommitMessage {
87 const trimmed = diff.trim();
88 if (!trimmed) {
89 return {
90 subject: style === "conventional" ? "chore: update" : "Update",
91 body: "",
92 };
93 }
94
95 // Pull file paths out of standard unified-diff headers.
96 // We look at `+++ b/<path>` to favour the *new* side of renames.
97 const paths: string[] = [];
98 let added = 0;
99 let removed = 0;
100 let newFiles = 0;
101 for (const line of trimmed.split("\n")) {
102 if (line.startsWith("+++ b/")) {
103 const p = line.slice("+++ b/".length).trim();
104 if (p && p !== "/dev/null") paths.push(p);
105 } else if (line.startsWith("new file mode")) {
106 newFiles++;
107 } else if (line.startsWith("+") && !line.startsWith("+++")) {
108 added++;
109 } else if (line.startsWith("-") && !line.startsWith("---")) {
110 removed++;
111 }
112 }
113
114 const onlyDocs =
115 paths.length > 0 &&
116 paths.every((p) => /\.(md|mdx|rst|txt)$/i.test(p) || /readme/i.test(p));
117 const onlyTests =
118 paths.length > 0 && paths.every((p) => /(^|\/)(__tests__|tests?)\//.test(p) || /\.test\.[tj]sx?$/.test(p));
119 const allNew = newFiles > 0 && newFiles === paths.length;
120
121 let type: ConventionalType = "chore";
122 if (onlyDocs) type = "docs";
123 else if (onlyTests) type = "test";
124 else if (allNew) type = "feat";
125 else if (removed > added * 2) type = "refactor";
126 else type = "chore";
127
128 // Scope = top-level directory if all paths share one.
129 const tops = new Set(paths.map((p) => p.split("/")[0]).filter(Boolean));
130 const scope = tops.size === 1 ? [...tops][0] : "";
131
132 const fileWord = paths.length === 1 ? "file" : "files";
133 const subjectRaw =
134 paths.length === 0
135 ? "update files"
136 : `update ${paths.length} ${fileWord}`;
137
138 const subject =
139 style === "conventional"
140 ? `${type}${scope ? `(${scope})` : ""}: ${subjectRaw}`
141 : subjectRaw.charAt(0).toUpperCase() + subjectRaw.slice(1);
142
143 const cappedSubject =
144 subject.length > 72 ? subject.slice(0, 69) + "..." : subject;
145
146 const topPaths = paths.slice(0, 3);
147 const body =
148 paths.length === 0
149 ? ""
150 : `Touched files:\n${topPaths.map((p) => `- ${p}`).join("\n")}` +
151 (paths.length > topPaths.length
152 ? `\n- ...and ${paths.length - topPaths.length} more`
153 : "");
154
155 return { subject: cappedSubject, body };
156}
157
158/**
159 * Coerce arbitrary model output into a clean { subject, body } shape.
160 *
161 * Accepts (in order):
162 * 1. A JSON object with `subject` / `body` fields.
163 * 2. A code-fenced block whose first line looks like a commit subject.
164 * 3. Raw text — first line as subject, the rest as body.
165 *
166 * Always strips backticks, trims, and caps the subject at 72 chars.
167 * Exported for unit tests.
168 */
169export function parseModelOutput(
170 text: string,
171 style: CommitStyle = "conventional"
172): CommitMessage {
173 const cleaned = text.replace(/^```[a-zA-Z]*\n|\n```$/g, "").trim();
174
175 // Try JSON first.
176 const parsed = parseJsonResponse<{ subject?: string; body?: string }>(
177 cleaned
178 );
179 let subject = "";
180 let body = "";
181 if (parsed && typeof parsed.subject === "string") {
182 subject = parsed.subject.trim();
183 body = typeof parsed.body === "string" ? parsed.body.trim() : "";
184 } else {
185 // Fall back to "first line = subject, rest = body" parsing.
186 const lines = cleaned.split("\n");
187 subject = (lines[0] || "").trim();
188 body = lines.slice(1).join("\n").trim();
189 // Drop a single blank line between subject + body if present.
190 if (body.startsWith("\n")) body = body.slice(1);
191 }
192
193 // Strip surrounding quotes / backticks the model sometimes adds.
194 subject = subject.replace(/^["'`]+|["'`]+$/g, "").trim();
195 if (subject.length > 72) {
196 subject = subject.slice(0, 69) + "...";
197 }
198 if (!subject) {
199 return { subject: style === "conventional" ? "chore: update" : "Update", body: "" };
200 }
201
202 // If conventional style was requested but the model returned a bare
203 // sentence, lightly normalise it. We do NOT try to re-classify — that
204 // would be guessing — but we do prefix `chore:` so commitlint stays happy.
205 if (style === "conventional" && !/^[a-z]+(\([^)]+\))?(!?):/.test(subject)) {
206 subject = `chore: ${subject.charAt(0).toLowerCase()}${subject.slice(1)}`;
207 if (subject.length > 72) subject = subject.slice(0, 69) + "...";
208 }
209
210 return { subject, body };
211}
212
213/**
214 * Build the prompt sent to Claude. Pulled out so the test suite can
215 * sanity-check it without spinning up the API client.
216 */
217export function buildPrompt(diff: string, style: CommitStyle): string {
218 const styleInstruction =
219 style === "conventional"
220 ? `Use Conventional Commits style — the subject MUST start with one of: ${CONVENTIONAL_TYPES.join(", ")}, optionally followed by (scope), then ": ", then a lowercase imperative summary. Example: "feat(auth): add passkey login".`
221 : `Write a plain-English subject in imperative mood (e.g. "Add passkey login"). No type prefix.`;
222
223 return `You are writing a git commit message for the following staged diff.
224
225${styleInstruction}
226
227Rules:
228- Subject MUST be 72 characters or fewer.
229- Subject is one line. No trailing period.
230- Body explains WHY the change was made when it is non-obvious. Skip the body for trivial changes.
231- Body lines wrap at ~72 chars. Use blank lines between paragraphs.
232- Do NOT describe what every file does — the diff already shows that. Focus on intent.
233- Do NOT include code fences, markdown, or extra commentary.
234
235Respond ONLY with JSON in this exact shape:
236{"subject": "...", "body": "..."}
237
238If the change is trivial enough that no body is needed, return body as "".
239
240Diff:
241\`\`\`diff
242${diff}
243\`\`\``;
244}
245
246/**
247 * Generate a commit message for the given diff.
248 *
249 * Never throws — on any error (no API key, model timeout, parse failure)
250 * we fall back to the deterministic heuristic so the caller can always
251 * present a draft to the developer.
252 */
253export async function generateCommitMessage(
254 diff: string,
255 opts: GenerateOptions = {}
256): Promise<CommitMessage> {
257 const style: CommitStyle = opts.style === "plain" ? "plain" : "conventional";
258 const trimmed = diff.trim();
259
260 if (!trimmed) {
261 return {
262 subject: style === "conventional" ? "chore: empty commit" : "Empty commit",
263 body: "",
264 };
265 }
266
267 if (!isAiAvailable()) {
268 return heuristicMessage(trimmed, style);
269 }
270
271 const truncated = truncateDiff(trimmed);
272
273 try {
274 const client = getAnthropic();
275 const message = await client.messages.create({
a5705cfClaude276 model: MODEL_SONNET,
9018b1fClaude277 max_tokens: 512,
278 messages: [
279 {
280 role: "user",
281 content: buildPrompt(truncated, style),
282 },
283 ],
284 });
1d4ff60Claude285 try {
286 const { recordAiCost, extractUsage } = await import("./ai-cost-tracker");
287 const usage = extractUsage(message);
288 await recordAiCost({
a5705cfClaude289 model: MODEL_SONNET,
1d4ff60Claude290 inputTokens: usage.input,
291 outputTokens: usage.output,
292 category: "other",
293 sourceKind: "commit_message",
294 });
295 } catch {
296 /* swallow — best-effort */
297 }
9018b1fClaude298 const text = extractText(message);
299 if (!text.trim()) {
300 return heuristicMessage(trimmed, style);
301 }
302 return parseModelOutput(text, style);
303 } catch {
304 // Network/API failure → never block the developer; degrade.
305 return heuristicMessage(trimmed, style);
306 }
307}
308
309/** Test-only exports — not part of the public API. */
310export const __test = {
311 truncateDiff,
312 heuristicMessage,
313 parseModelOutput,
314 buildPrompt,
315 CONVENTIONAL_TYPES,
316};