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.
| 9018b1f | 1 | /** |
| 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 | ||
| 21 | import { | |
| 22 | getAnthropic, | |
| 23 | MODEL_HAIKU, | |
| b271465 | 24 | MODEL_SONNET, |
| 9018b1f | 25 | extractText, |
| 26 | parseJsonResponse, | |
| 27 | isAiAvailable, | |
| 28 | } from "./ai-client"; | |
| 29 | ||
| 30 | export type CommitStyle = "conventional" | "plain"; | |
| 31 | ||
| 32 | export interface CommitMessage { | |
| 33 | subject: string; | |
| 34 | body: string; | |
| 35 | } | |
| 36 | ||
| 37 | export interface GenerateOptions { | |
| 38 | style?: CommitStyle; | |
| 39 | } | |
| 40 | ||
| 41 | /** Max bytes of diff we send to the model. Past this we truncate. */ | |
| 42 | export const DIFF_BYTE_CAP = 50_000; | |
| 43 | const TRUNCATE_MARKER = "\n... (more)"; | |
| 44 | ||
| 45 | /** Conventional-commits types we accept in the subject. */ | |
| 46 | const 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 | ||
| 60 | type 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 | */ | |
| 66 | export 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 | */ | |
| 84 | export 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 | */ | |
| 170 | export 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 | */ | |
| 218 | export 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 | ||
| 228 | Rules: | |
| 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 | ||
| 236 | Respond ONLY with JSON in this exact shape: | |
| 237 | {"subject": "...", "body": "..."} | |
| 238 | ||
| 239 | If the change is trivial enough that no body is needed, return body as "". | |
| 240 | ||
| 241 | Diff: | |
| 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 | */ | |
| 254 | export 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({ | |
| a5705cf | 277 | model: MODEL_SONNET, |
| 9018b1f | 278 | max_tokens: 512, |
| 279 | messages: [ | |
| 280 | { | |
| 281 | role: "user", | |
| 282 | content: buildPrompt(truncated, style), | |
| 283 | }, | |
| 284 | ], | |
| 285 | }); | |
| 1d4ff60 | 286 | try { |
| 287 | const { recordAiCost, extractUsage } = await import("./ai-cost-tracker"); | |
| 288 | const usage = extractUsage(message); | |
| 289 | await recordAiCost({ | |
| a5705cf | 290 | model: MODEL_SONNET, |
| 1d4ff60 | 291 | inputTokens: usage.input, |
| 292 | outputTokens: usage.output, | |
| 293 | category: "other", | |
| 294 | sourceKind: "commit_message", | |
| 295 | }); | |
| 296 | } catch { | |
| 297 | /* swallow — best-effort */ | |
| 298 | } | |
| 9018b1f | 299 | 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. */ | |
| 311 | export const __test = { | |
| 312 | truncateDiff, | |
| 313 | heuristicMessage, | |
| 314 | parseModelOutput, | |
| 315 | buildPrompt, | |
| 316 | CONVENTIONAL_TYPES, | |
| 317 | }; |