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