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.
| 9018b1f | 1 | /** |
| 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 | ||
| 22 | import { spawnSync } from "node:child_process"; | |
| 23 | import { writeFileSync, mkdtempSync, readFileSync } from "node:fs"; | |
| 24 | import { tmpdir } from "node:os"; | |
| 25 | import { join } from "node:path"; | |
| 26 | ||
| 27 | export interface CommitMessage { | |
| 28 | subject: string; | |
| 29 | body: string; | |
| 30 | } | |
| 31 | ||
| 32 | export interface CommitConfig { | |
| 33 | host: string; | |
| 34 | token?: string; | |
| 35 | } | |
| 36 | ||
| 37 | // ──────────────────────────── arg parsing ──────────────────────────── | |
| 38 | ||
| 39 | export 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 | ||
| 47 | export 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 | ||
| 72 | function 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 | ||
| 89 | export 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 | ||
| 104 | export 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 | ||
| 140 | export 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 | ||
| 151 | async 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 | ||
| 164 | export 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 | ||
| 190 | export 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 | */ | |
| 206 | export 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 | ||
| 289 | export 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 | ||
| 297 | export 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 | } |