@kyubiware/commit-mint 0.1.0 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/cli.mjs +478 -186
  2. package/dist/cli.mjs.map +1 -1
  3. package/package.json +65 -54
package/dist/cli.mjs CHANGED
@@ -5,8 +5,8 @@ import { intro, isCancel, log, outro, spinner } from "@clack/prompts";
5
5
  import { bold, cyan, dim, green, red, yellow } from "kolorist";
6
6
  import Groq from "groq-sdk";
7
7
  import { mkdir, readFile, writeFile } from "node:fs/promises";
8
- import { join } from "node:path";
9
8
  import os from "node:os";
9
+ import { join } from "node:path";
10
10
  import ini from "ini";
11
11
  import { execa } from "execa";
12
12
  import { createHash } from "node:crypto";
@@ -25,7 +25,7 @@ var __exportAll = (all, no_symbols) => {
25
25
  //#region package.json
26
26
  var package_default = {
27
27
  name: "@kyubiware/commit-mint",
28
- version: "0.1.0",
28
+ version: "0.1.5",
29
29
  description: "A commit tool that actually handles hook failures",
30
30
  type: "module",
31
31
  bin: { "cmint": "./dist/cli.mjs" },
@@ -33,14 +33,20 @@ var package_default = {
33
33
  scripts: {
34
34
  "build": "tsdown src/cli.ts --format esm --dts --clean",
35
35
  "dev": "tsx src/cli.ts",
36
+ "dev:debug": "tsx src/cli.ts --debug",
36
37
  "lint": "biome check .",
37
38
  "lint:fix": "biome check --fix .",
38
39
  "typecheck": "tsc --noEmit",
39
40
  "test": "vitest run",
40
41
  "test:coverage": "vitest run --coverage",
41
42
  "test:watch": "vitest --watch",
42
- "prepublishOnly": "npm run build"
43
+ "release:patch": "bash scripts/release.sh patch",
44
+ "release:minor": "bash scripts/release.sh minor",
45
+ "release:major": "bash scripts/release.sh major",
46
+ "prepublishOnly": "npm run build",
47
+ "prepare": "simple-git-hooks"
43
48
  },
49
+ "simple-git-hooks": { "pre-commit": "npx lint-staged" },
44
50
  keywords: [
45
51
  "git",
46
52
  "commit",
@@ -68,7 +74,10 @@ var package_default = {
68
74
  },
69
75
  devDependencies: {
70
76
  "@biomejs/biome": "^2.0.0",
77
+ "@types/ini": "^4.1.1",
71
78
  "@vitest/coverage-v8": "^3.2.4",
79
+ "lint-staged": "^17.0.5",
80
+ "simple-git-hooks": "^2.13.1",
72
81
  "tsdown": "^0.22.0",
73
82
  "tsx": "^4.22.2",
74
83
  "typescript": "^5.9.2",
@@ -76,37 +85,90 @@ var package_default = {
76
85
  }
77
86
  };
78
87
  //#endregion
88
+ //#region src/utils/debug.ts
89
+ let enabled = false;
90
+ function setDebug(value) {
91
+ enabled = value;
92
+ }
93
+ function debug(...args) {
94
+ if (!enabled) return;
95
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().slice(11, 23);
96
+ console.error(dim(`[debug ${timestamp}]`), ...args);
97
+ }
98
+ //#endregion
79
99
  //#region src/services/ai.ts
100
+ const MAX_DIFF_CHARS = 2e4;
80
101
  const CONVENTIONAL_COMMIT_REGEX = /^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.+\))?!?: .+$/;
102
+ function stripThinkTags(text) {
103
+ return text.replace(/<think[\s\S]*?<\/think>/gi, "").trim();
104
+ }
105
+ function deriveMessageFromReasoning(reasoning) {
106
+ const match = reasoning.match(/(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.+\))?!?: .+/i);
107
+ if (match) return match[0].trim();
108
+ const first = reasoning.split(/[.!?]/).find((s) => s.trim().length >= 10);
109
+ return first ? first.trim() : null;
110
+ }
111
+ function stripContextLines(diff) {
112
+ return diff.split("\n").filter((line) => !line.startsWith(" ")).join("\n");
113
+ }
81
114
  function compressDiff(diff) {
82
- if (diff.length <= 4e4) return diff;
83
- let result = diff.split("\n").map((line) => line.length > 256 ? `${line.slice(0, 256)}...` : line).join("\n");
84
- if (result.length <= 4e4) return result;
85
- const files = result.split(/(?=diff --git)/).filter(Boolean).map((fd) => ({
86
- diff: fd,
87
- parts: fd.split(/(?=\n@@)/)
88
- }));
89
- while (result.length > 4e4) {
90
- files.sort((a, b) => b.parts.length - a.parts.length);
91
- const longest = files[0];
92
- if (!longest || longest.parts.length <= 1) break;
93
- longest.parts.pop();
94
- longest.diff = longest.parts.join("");
95
- result = files.map((f) => f.diff).join("");
96
- }
97
- if (result.length <= 4e4) return result;
98
- return `Summary of changes:\n${(result.match(/^diff --git a\/(.+) b\/(.+)$/gm) || []).map((f) => {
115
+ if (diff.length <= MAX_DIFF_CHARS) return diff;
116
+ let result = stripContextLines(diff);
117
+ if (result.length <= MAX_DIFF_CHARS) return result;
118
+ result = result.split(/(?=diff --git)/).filter(Boolean).map((fd) => {
119
+ return fd.split(/(?=\n@@)/).map((part, idx) => {
120
+ if (idx === 0) return part;
121
+ const lines = part.split("\n");
122
+ return [lines[0], ...lines.slice(1).filter((l) => l.startsWith("+") || l.startsWith("-")).slice(0, 10)].join("\n");
123
+ }).join("");
124
+ }).join("");
125
+ if (result.length <= MAX_DIFF_CHARS) return result;
126
+ return `Summary of changes:\n${(diff.match(/^diff --git a\/(.+) b\/(.+)$/gm) || []).map((f) => {
99
127
  const match = f.match(/^diff --git a\/(.+) b\/(.+)$/);
100
128
  return match && match[1] === match[2] ? `${match[1]} | changed` : "";
101
129
  }).filter(Boolean).join("\n")}`;
102
130
  }
131
+ function buildStatSummary(diff) {
132
+ const files = [];
133
+ let currentFile = "";
134
+ let adds = 0;
135
+ let dels = 0;
136
+ for (const line of diff.split("\n")) {
137
+ const match = line.match(/^diff --git a\/.+ b\/(.+)$/);
138
+ if (match) {
139
+ if (currentFile) files.push({
140
+ name: currentFile,
141
+ adds,
142
+ dels
143
+ });
144
+ currentFile = match[1];
145
+ adds = 0;
146
+ dels = 0;
147
+ } else if (line.startsWith("+") && !line.startsWith("+++")) adds++;
148
+ else if (line.startsWith("-") && !line.startsWith("---")) dels++;
149
+ }
150
+ if (currentFile) files.push({
151
+ name: currentFile,
152
+ adds,
153
+ dels
154
+ });
155
+ const totalAdds = files.reduce((s, f) => s + f.adds, 0);
156
+ const totalDels = files.reduce((s, f) => s + f.dels, 0);
157
+ const lines = files.map((f) => ` ${f.name} | +${f.adds} -${f.dels}`);
158
+ lines.push(` ${files.length} files changed, ${totalAdds} insertions(+), ${totalDels} deletions(-)`);
159
+ return lines.join("\n");
160
+ }
103
161
  function buildSystemPrompt(type) {
104
162
  let prompt = "You are a commit message generator. Follow the Conventional Commits specification.\nValid types: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test.\nFormat: type(scope): description\nUse imperative mood, lowercase, no trailing period.\nOutput ONLY the commit message, no markdown fences, no explanation.";
105
163
  if (type && type.trim().length > 0) prompt += `\nYou MUST use type: ${type}`;
106
164
  return prompt;
107
165
  }
108
- function buildUserPrompt(diff, hint) {
109
- return `${hint ? `Context: ${hint}\n\n` : ""}Generate a conventional commit for:\n\n${diff}`;
166
+ function buildUserPrompt(diff, hint, statSummary) {
167
+ const parts = [];
168
+ if (hint) parts.push(`Context: ${hint}`);
169
+ if (statSummary) parts.push(`Change summary:\n${statSummary}`);
170
+ parts.push(`Generate a conventional commit for:\n\n${diff}`);
171
+ return parts.join("\n\n");
110
172
  }
111
173
  function isValidConventionalCommit(message) {
112
174
  return CONVENTIONAL_COMMIT_REGEX.test(message);
@@ -115,36 +177,87 @@ function enforceMaxLength(message, maxLength) {
115
177
  if (!maxLength || message.length <= maxLength) return message;
116
178
  return `${message.slice(0, maxLength - 3)}...`;
117
179
  }
180
+ function extractContentText(content) {
181
+ if (content == null) return "";
182
+ if (typeof content === "string") return content.trim();
183
+ if (Array.isArray(content)) return content.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => stripThinkTags(part.text)).join("").trim();
184
+ return "";
185
+ }
118
186
  async function generateCommitMessage(diff, options) {
187
+ debug("generateCommitMessage: model=%s, maxLength=%s, type=%s, hint=%s", options.model ?? "default", options.maxLength ?? "default", options.type ?? "none", options.hint ?? "none");
188
+ const timeoutMs = options.timeout ?? 6e4;
189
+ debug("Timeout: %d ms", timeoutMs);
119
190
  const client = new Groq({
120
191
  apiKey: options.apiKey,
121
- timeout: options.timeout ?? 6e4
192
+ timeout: timeoutMs
122
193
  });
123
194
  const compressedDiff = compressDiff(diff);
195
+ const statSummary = buildStatSummary(diff);
124
196
  const systemPrompt = buildSystemPrompt(options.type);
125
- const userPrompt = buildUserPrompt(compressedDiff, options.hint);
197
+ const userPrompt = buildUserPrompt(compressedDiff, options.hint, statSummary);
198
+ debug("Diff: %d chars → compressed to %d chars", diff.length, compressedDiff.length);
199
+ debug("Stat summary:\n%s", statSummary);
200
+ debug("User prompt length: %d chars", userPrompt.length);
126
201
  async function callAI(strictSystemPrompt) {
127
- return ((await client.chat.completions.create({
128
- messages: [{
129
- role: "system",
130
- content: strictSystemPrompt ?? systemPrompt
131
- }, {
132
- role: "user",
133
- content: userPrompt
134
- }],
135
- model: options.model ?? "openai/gpt-oss-20b",
136
- temperature: .3,
137
- max_tokens: 300
138
- })).choices[0]?.message?.content)?.trim() ?? "";
202
+ const callStart = Date.now();
203
+ debug("callAI: %s — model=%s, promptLen=%d, systemLen=%d", !!strictSystemPrompt ? "RETRY (strict)" : "INITIAL", options.model ?? "openai/gpt-oss-20b", userPrompt.length, (strictSystemPrompt ?? systemPrompt).length);
204
+ try {
205
+ const isReasoningModel = /^(o[1-9]|.*gpt-oss.*|.*gpt-5.*)/i.test(options.model ?? "");
206
+ const completion = await client.chat.completions.create({
207
+ messages: [{
208
+ role: "system",
209
+ content: strictSystemPrompt ?? systemPrompt
210
+ }, {
211
+ role: "user",
212
+ content: userPrompt
213
+ }],
214
+ model: options.model ?? "openai/gpt-oss-20b",
215
+ temperature: .3,
216
+ ...isReasoningModel ? { max_completion_tokens: 1024 } : { max_tokens: 1024 },
217
+ reasoning_format: "parsed"
218
+ });
219
+ const elapsed = Date.now() - callStart;
220
+ const rawContent = completion.choices[0]?.message?.content;
221
+ const content = extractContentText(typeof rawContent === "string" ? stripThinkTags(rawContent) : rawContent);
222
+ debug("callAI response (%d ms): choices=%d, finishReason=%s, contentLen=%d, rawType=%s", elapsed, completion.choices.length, completion.choices[0]?.finish_reason ?? "(none)", content.length, typeof rawContent);
223
+ debug("callAI raw content: %s", content.slice(0, 300) || "(empty)");
224
+ if (!content) {
225
+ const reasoning = completion.choices[0]?.message?.reasoning;
226
+ debug("callAI: content empty, attempting reasoning fallback (reasoningLen=%d)", reasoning?.length ?? 0);
227
+ if (reasoning) {
228
+ const derived = deriveMessageFromReasoning(reasoning);
229
+ if (derived) {
230
+ debug("callAI: derived message from reasoning: %s", derived.slice(0, 100));
231
+ return stripThinkTags(derived);
232
+ }
233
+ debug("callAI: could not derive message from reasoning");
234
+ }
235
+ throw new Error("AI returned an empty commit message");
236
+ }
237
+ return content;
238
+ } catch (error) {
239
+ debug("callAI FAILED after %d ms: %s", Date.now() - callStart, error instanceof Error ? `${error.name}: ${error.message}` : String(error));
240
+ throw error;
241
+ }
139
242
  }
140
243
  try {
244
+ const totalStart = Date.now();
141
245
  let message = await callAI();
246
+ debug("Validation: message=%s, isValid=%s", message.slice(0, 100), isValidConventionalCommit(message));
142
247
  if (!isValidConventionalCommit(message)) {
248
+ debug("Initial message failed conventional commit validation, retrying with strict prompt (elapsed: %d ms)", Date.now() - totalStart);
143
249
  const retryMessage = await callAI("You MUST output ONLY a valid conventional commit message. Format: type(scope): description. If you output anything else your response will be rejected.\nValid types: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test.");
144
- if (isValidConventionalCommit(retryMessage)) message = retryMessage;
250
+ debug("Retry validation: message=%s, isValid=%s", retryMessage.slice(0, 100), isValidConventionalCommit(retryMessage));
251
+ if (isValidConventionalCommit(retryMessage)) {
252
+ debug("Retry produced valid conventional commit");
253
+ message = retryMessage;
254
+ } else debug("Retry also failed validation, using original message");
145
255
  }
146
- return enforceMaxLength(message, options.maxLength);
256
+ const result = enforceMaxLength(message, options.maxLength);
257
+ debug("Final message (%d ms total): %s", Date.now() - totalStart, result);
258
+ return result;
147
259
  } catch (error) {
260
+ debug("AI error: %s", error instanceof Error ? error.message : String(error));
148
261
  if (error instanceof Groq.AuthenticationError) throw new Error("Invalid GROQ_API_KEY. Run: cmint config set GROQ_API_KEY=<key>");
149
262
  if (error instanceof Groq.RateLimitError) throw new Error("Rate limited by Groq. Please wait and try again.");
150
263
  if (error instanceof Groq.APIConnectionTimeoutError) throw new Error("Request timed out. Check your network or try a smaller diff.");
@@ -163,14 +276,18 @@ const defaults = {
163
276
  timeout: "10000"
164
277
  };
165
278
  async function readConfig() {
279
+ debug("readConfig: loading from %s", CONFIG_PATH);
166
280
  try {
167
281
  const raw = await readFile(CONFIG_PATH, "utf8");
168
282
  const parsed = ini.parse(raw);
169
- return {
283
+ const merged = {
170
284
  ...defaults,
171
285
  ...parsed
172
286
  };
287
+ debug("readConfig: loaded keys: %s", Object.keys(merged).join(", "));
288
+ return merged;
173
289
  } catch {
290
+ debug("readConfig: no config file, using defaults");
174
291
  return { ...defaults };
175
292
  }
176
293
  }
@@ -187,9 +304,16 @@ async function setConfigValue(key, value) {
187
304
  }
188
305
  async function getApiKey() {
189
306
  const envKey = process.env.GROQ_API_KEY;
190
- if (envKey) return envKey;
307
+ if (envKey) {
308
+ debug("getApiKey: found in env");
309
+ return envKey;
310
+ }
191
311
  const config = await readConfig();
192
- if (config.GROQ_API_KEY) return config.GROQ_API_KEY;
312
+ if (config.GROQ_API_KEY) {
313
+ debug("getApiKey: found in config");
314
+ return config.GROQ_API_KEY;
315
+ }
316
+ debug("getApiKey: not found");
193
317
  throw new Error("Please set your Groq API key via `cmint config set GROQ_API_KEY=<your token>`");
194
318
  }
195
319
  //#endregion
@@ -199,6 +323,7 @@ var git_exports = /* @__PURE__ */ __exportAll({
199
323
  assertGitRepo: () => assertGitRepo,
200
324
  attemptCommit: () => attemptCommit,
201
325
  attemptCommitNoVerify: () => attemptCommitNoVerify,
326
+ getDefaultExcludes: () => getDefaultExcludes,
202
327
  getHead: () => getHead,
203
328
  getRepoRoot: () => getRepoRoot,
204
329
  getStagedDiff: () => getStagedDiff,
@@ -207,49 +332,70 @@ var git_exports = /* @__PURE__ */ __exportAll({
207
332
  });
208
333
  var KnownError = class extends Error {};
209
334
  async function assertGitRepo() {
335
+ debug("assertGitRepo");
210
336
  const { failed } = await execa("git", ["rev-parse", "--show-toplevel"], { reject: false });
211
337
  if (failed) throw new KnownError("The current directory must be a Git repository!");
212
338
  }
213
339
  async function getRepoRoot() {
214
340
  const { stdout } = await execa("git", ["rev-parse", "--show-toplevel"]);
341
+ debug("getRepoRoot:", stdout.trim());
215
342
  return stdout.trim();
216
343
  }
344
+ const DEFAULT_EXCLUDES = [
345
+ "package-lock.json",
346
+ "node_modules/**",
347
+ "dist/**",
348
+ "build/**",
349
+ ".next/**",
350
+ "coverage/**",
351
+ "*.log",
352
+ "*.min.js",
353
+ "*.min.css",
354
+ "*.lock",
355
+ ".DS_Store"
356
+ ];
357
+ function getDefaultExcludes() {
358
+ return [...DEFAULT_EXCLUDES];
359
+ }
217
360
  async function getStagedDiff(exclude) {
218
361
  const excludeArgs = (exclude ?? []).map((e) => `:(exclude)${e}`);
219
- const defaultExcludes = [
220
- "package-lock.json",
221
- "node_modules/**",
222
- "dist/**",
223
- "build/**",
224
- ".next/**",
225
- "coverage/**",
226
- "*.log",
227
- "*.min.js",
228
- "*.min.css",
229
- "*.lock",
230
- ".DS_Store"
231
- ].map((e) => `:(exclude)${e}`);
362
+ const defaultExcludeArgs = DEFAULT_EXCLUDES.map((e) => `:(exclude)${e}`);
363
+ const { stdout: allFiles } = await execa("git", [
364
+ "diff",
365
+ "--cached",
366
+ "--name-only"
367
+ ]);
368
+ if (!allFiles) {
369
+ debug("getStagedDiff: no staged files");
370
+ return null;
371
+ }
232
372
  const { stdout: files } = await execa("git", [
233
373
  "diff",
234
374
  "--cached",
235
375
  "--name-only",
236
- ...defaultExcludes,
376
+ ...defaultExcludeArgs,
237
377
  ...excludeArgs
238
378
  ]);
239
- if (!files) return null;
379
+ if (!files) {
380
+ const excludedFiles = allFiles.split("\n").filter(Boolean);
381
+ debug("getStagedDiff: all files excluded:", excludedFiles);
382
+ return { excludedFiles };
383
+ }
240
384
  const { stdout: diff } = await execa("git", [
241
385
  "diff",
242
386
  "--cached",
243
387
  "--diff-algorithm=minimal",
244
- ...defaultExcludes,
388
+ ...defaultExcludeArgs,
245
389
  ...excludeArgs
246
390
  ]);
391
+ debug("getStagedDiff:", files.split("\n").filter(Boolean).length, "files,", diff.length, "chars");
247
392
  return {
248
393
  files: files.split("\n").filter(Boolean),
249
394
  diff
250
395
  };
251
396
  }
252
397
  async function stageAll() {
398
+ debug("stageAll: git add -A");
253
399
  await execa("git", ["add", "-A"]);
254
400
  }
255
401
  async function getHead() {
@@ -261,24 +407,36 @@ async function getStatusShort() {
261
407
  return stdout.trim();
262
408
  }
263
409
  async function attemptCommit(message, extraArgs = []) {
410
+ debug("attemptCommit:", message, extraArgs.length ? extraArgs : "(no extra args)");
264
411
  try {
265
- await execa("git", [
412
+ const subprocess = execa("git", [
266
413
  "commit",
267
414
  "-m",
268
415
  message,
269
416
  ...extraArgs
270
417
  ]);
271
- return { ok: true };
418
+ const stderrChunks = [];
419
+ subprocess.stderr?.on("data", (chunk) => {
420
+ stderrChunks.push(chunk.toString());
421
+ });
422
+ await subprocess;
423
+ debug("attemptCommit: success");
424
+ return {
425
+ ok: true,
426
+ stderr: stderrChunks.join("")
427
+ };
272
428
  } catch (error) {
273
429
  const e = error;
430
+ debug("attemptCommit: failed —", e.message?.slice(0, 200));
274
431
  return {
275
432
  ok: false,
276
433
  error: e.message,
277
- stderr: e.stderr ?? ""
434
+ stderr: typeof e.stderr === "string" ? e.stderr : ""
278
435
  };
279
436
  }
280
437
  }
281
438
  async function attemptCommitNoVerify(message) {
439
+ debug("attemptCommitNoVerify:", message);
282
440
  return attemptCommit(message, ["--no-verify"]);
283
441
  }
284
442
  //#endregion
@@ -289,25 +447,27 @@ async function attemptCommitNoVerify(message) {
289
447
  */
290
448
  function parseHookErrors(stderr) {
291
449
  if (!stderr) return [];
450
+ debug("parseHookErrors: stderr length=%d", stderr.length);
292
451
  const errors = [];
293
- stderr.split("\n");
294
452
  if (stderr.includes("lint-staged") || stderr.includes("[FAILED]")) errors.push(...parseLintStagedErrors(stderr));
295
453
  if (stderr.includes("biome") || stderr.includes("Biome")) errors.push(...parseBiomeErrors(stderr));
296
454
  if (stderr.includes("error TS") || stderr.includes("tsc")) errors.push(...parseTscErrors(stderr));
297
455
  if (stderr.includes("vitest") || stderr.includes("jest") || stderr.includes("FAIL") || stderr.includes("test failed")) errors.push(...parseTestErrors(stderr));
298
456
  if (stderr.includes("eslint") || stderr.includes("ESLint")) errors.push(...parseEslintErrors(stderr));
299
- if (errors.length === 0) errors.push({
300
- tool: "git hooks",
301
- message: stderr.trim(),
302
- raw: stderr
303
- });
457
+ if (errors.length === 0) {
458
+ debug("parseHookErrors: no patterns matched, using raw fallback");
459
+ errors.push({
460
+ tool: "git hooks",
461
+ message: stderr.trim(),
462
+ raw: stderr
463
+ });
464
+ }
465
+ debug("parseHookErrors: found %d errors", errors.length);
304
466
  return errors;
305
467
  }
306
468
  function parseLintStagedErrors(output) {
307
469
  const errors = [];
308
- const taskPattern = /\[FAILED\]\s+(.+?)\s+\[FAILED\]/g;
309
- let match;
310
- while ((match = taskPattern.exec(output)) !== null) {
470
+ for (const match of output.matchAll(/\[FAILED\]\s+(.+?)\s+\[FAILED\]/g)) {
311
471
  const task = match[1].trim();
312
472
  errors.push({
313
473
  tool: "lint-staged",
@@ -319,9 +479,7 @@ function parseLintStagedErrors(output) {
319
479
  }
320
480
  function parseBiomeErrors(output) {
321
481
  const errors = [];
322
- const biomePattern = /^(.+?):(\d+):(\d+)\s+(.+)$/gm;
323
- let match;
324
- while ((match = biomePattern.exec(output)) !== null) errors.push({
482
+ for (const match of output.matchAll(/^(.+?):(\d+):(\d+)\s+(.+)$/gm)) errors.push({
325
483
  tool: "biome",
326
484
  message: `${match[1]}:${match[2]}:${match[3]} — ${match[4]}`,
327
485
  raw: match[0]
@@ -335,9 +493,7 @@ function parseBiomeErrors(output) {
335
493
  }
336
494
  function parseTscErrors(output) {
337
495
  const errors = [];
338
- const tscPattern = /^(.+?)\((\d+),(\d+)\):\s+error\s+(TS\d+):\s+(.+)$/gm;
339
- let match;
340
- while ((match = tscPattern.exec(output)) !== null) errors.push({
496
+ for (const match of output.matchAll(/^(.+?)\((\d+),(\d+)\):\s+error\s+(TS\d+):\s+(.+)$/gm)) errors.push({
341
497
  tool: "tsc",
342
498
  message: `${match[1]}:${match[2]}:${match[3]} — ${match[4]}: ${match[5]}`,
343
499
  raw: match[0]
@@ -361,116 +517,158 @@ function parseTestErrors(output) {
361
517
  }
362
518
  function parseEslintErrors(output) {
363
519
  const errors = [];
364
- const eslintPattern = /^\s*\d+:(\d+)\s+(error|warning)\s+(.+?)\s+(.+?)$/gm;
365
- let match;
366
- while ((match = eslintPattern.exec(output)) !== null) errors.push({
520
+ for (const match of output.matchAll(/^\s*\d+:(\d+)\s+(error|warning)\s+(.+?)\s+(.+?)$/gm)) errors.push({
367
521
  tool: "eslint",
368
522
  message: `${match[2]}: ${match[3]} (${match[4]})`,
369
523
  raw: match[0]
370
524
  });
371
525
  return errors;
372
526
  }
373
- function formatErrorReport(errors) {
374
- if (errors.length === 0) return "";
375
- return errors.map((e) => `[${e.tool}]\n${e.message}`).join("\n\n");
527
+ /**
528
+ * Parse lint-staged/hook stderr output to discover which tools ran
529
+ * and whether they succeeded. Used for clean post-commit summary.
530
+ */
531
+ function parseToolChecks(stderr) {
532
+ if (!stderr) return [];
533
+ const checks = [];
534
+ for (const match of stderr.matchAll(/\[(COMPLETED|FAILED)\]\s+(.+)/g)) {
535
+ const status = match[1];
536
+ const command = match[2].trim();
537
+ if (isLintStagedMeta(command)) continue;
538
+ const tool = extractToolName(command);
539
+ if (!tool) continue;
540
+ checks.push({
541
+ tool,
542
+ ok: status === "COMPLETED"
543
+ });
544
+ }
545
+ const seen = /* @__PURE__ */ new Map();
546
+ for (const c of checks) seen.set(c.tool, c);
547
+ return [...seen.values()];
548
+ }
549
+ /** Heuristic: skip lint-staged internal metadata lines */
550
+ function isLintStagedMeta(command) {
551
+ if (/[*{}[\]]/.test(command)) return true;
552
+ if (/\s[-–—]\s(\d+\s)?files?$/.test(command)) return true;
553
+ if (/\s[-–—]\sno\s files$/.test(command)) return true;
554
+ if (/^(Running tasks|Applying modifications|Cleaning up|Backing up|Backed up|Updating Git)/.test(command)) return true;
555
+ if (/\.{3}$/.test(command)) return true;
556
+ return false;
557
+ }
558
+ /** Extract a display-friendly tool name from a lint-staged command */
559
+ function extractToolName(command) {
560
+ const tokens = command.split(/\s+/);
561
+ const first = tokens[0];
562
+ if ([
563
+ "npm",
564
+ "yarn",
565
+ "pnpm",
566
+ "bun"
567
+ ].includes(first)) {
568
+ const script = tokens[tokens[1] === "run" ? 2 : 1];
569
+ if (!script) return null;
570
+ return {
571
+ typecheck: "tsc",
572
+ lint: "eslint",
573
+ format: "prettier"
574
+ }[script] ?? script;
575
+ }
576
+ if (first === "npx") return tokens[1] ?? null;
577
+ return first;
376
578
  }
377
579
  //#endregion
378
580
  //#region src/services/clipboard.ts
379
581
  async function copyToClipboard(content) {
380
- for (const [cmd, ...args] of [
381
- ["wl-copy"],
382
- [
383
- "xclip",
384
- "-selection",
385
- "clipboard"
386
- ],
387
- [
388
- "xsel",
389
- "--clipboard",
390
- "--input"
391
- ],
392
- ["pbcopy"]
582
+ for (const [cmd, args] of [
583
+ ["wl-copy", []],
584
+ ["xclip", ["-selection", "clipboard"]],
585
+ ["xsel", ["--clipboard", "--input"]],
586
+ ["pbcopy", []]
393
587
  ]) try {
394
- const { stdout } = await execa("which", [cmd], { reject: false });
395
- if (!stdout) continue;
396
- await execa(cmd, args.length > 0 ? args : [], { input: content });
588
+ await execa(cmd, args, { input: content });
397
589
  return true;
398
- } catch {
399
- continue;
400
- }
590
+ } catch {}
401
591
  return false;
402
592
  }
403
593
  //#endregion
404
594
  //#region src/ui/menu.ts
405
- async function showRecoveryMenu(errors, onRetry, onSkipHooks, onRestage, message) {
406
- p.note(errors.map((e) => ` ${red("•")} [${e.tool}] ${e.message}`).join("\n"), red(bold("Pre-commit hook failed")));
407
- const choice = await p.select({
408
- message: "What do you want to do?",
409
- options: [
410
- {
411
- label: "Copy error report to clipboard",
412
- value: "clipboard",
413
- hint: "Paste into another terminal for an AI agent"
414
- },
415
- {
416
- label: "Skip hooks and commit (--no-verify)",
417
- value: "skip",
418
- hint: "Commit anyway, fix later"
419
- },
420
- {
421
- label: "Re-stage files and retry",
422
- value: "restage",
423
- hint: "Pick up fixes from another terminal"
424
- },
425
- {
426
- label: "Edit commit message",
427
- value: "edit",
428
- hint: "Modify the message before retrying"
429
- },
430
- {
431
- label: "Cancel",
432
- value: "cancel"
595
+ async function showRecoveryMenu(errors, onRetry, onSkipHooks, onRestage, message, rawStderr) {
596
+ debug("showRecoveryMenu: %d errors", errors.length);
597
+ while (true) {
598
+ p.note(errors.map((e) => ` ${red("•")} [${e.tool}] ${e.message}`).join("\n"), red(bold("Pre-commit hook failed")));
599
+ const choice = await p.select({
600
+ message: "What do you want to do?",
601
+ options: [
602
+ {
603
+ label: "Copy error report to clipboard",
604
+ value: "clipboard",
605
+ hint: "Paste into another terminal for an AI agent"
606
+ },
607
+ {
608
+ label: "Skip hooks and commit (--no-verify)",
609
+ value: "skip",
610
+ hint: "Commit anyway, fix later"
611
+ },
612
+ {
613
+ label: "Re-stage files and retry",
614
+ value: "restage",
615
+ hint: "Pick up fixes from another terminal"
616
+ },
617
+ {
618
+ label: "Edit commit message",
619
+ value: "edit",
620
+ hint: "Modify the message before retrying"
621
+ },
622
+ {
623
+ label: "Cancel",
624
+ value: "cancel"
625
+ }
626
+ ]
627
+ });
628
+ if (p.isCancel(choice)) {
629
+ debug("showRecoveryMenu: user cancelled");
630
+ p.outro(yellow("Cancelled. Message cached for --retry."));
631
+ process.exit(1);
632
+ return;
633
+ }
634
+ debug("showRecoveryMenu: user chose %s", choice);
635
+ switch (choice) {
636
+ case "clipboard":
637
+ if (await copyToClipboard(rawStderr)) p.log.step(green("Errors copied"));
638
+ else p.log.warn(red("No clipboard tool found. Install xclip, wl-copy, or xsel."));
639
+ continue;
640
+ case "skip":
641
+ p.log.info(yellow("Committing with --no-verify..."));
642
+ if (await onSkipHooks(message)) p.outro(green("Committed (hooks skipped)."));
643
+ else p.outro(red("Commit failed even with --no-verify."));
644
+ return;
645
+ case "restage":
646
+ p.log.info(cyan("Re-staging and retrying..."));
647
+ if (await onRestage()) {
648
+ p.outro(green("Committed successfully."));
649
+ return;
650
+ }
651
+ continue;
652
+ case "edit": {
653
+ const edited = await p.text({
654
+ message: "Edit commit message:",
655
+ initialValue: message,
656
+ validate: (v) => v.trim() ? void 0 : "Message cannot be empty"
657
+ });
658
+ if (p.isCancel(edited)) {
659
+ p.outro(yellow("Cancelled. Message cached for --retry."));
660
+ process.exit(1);
661
+ return;
662
+ }
663
+ if (await onRetry()) p.outro(green("Committed successfully."));
664
+ else p.outro(red("Commit failed again."));
665
+ return;
433
666
  }
434
- ]
435
- });
436
- if (p.isCancel(choice)) {
437
- p.outro(yellow("Cancelled. Message cached for --retry."));
438
- process.exit(1);
439
- }
440
- switch (choice) {
441
- case "clipboard":
442
- if (await copyToClipboard(formatErrorReport(errors))) p.outro(green("Error report copied to clipboard."));
443
- else p.outro(red("No clipboard tool found. Install xclip, wl-copy, or xsel."));
444
- p.log.info(dim("Fix the errors, then run: cmint --retry"));
445
- process.exit(0);
446
- break;
447
- case "skip":
448
- p.log.info(yellow("Committing with --no-verify..."));
449
- if (await onSkipHooks(message)) p.outro(green("Committed (hooks skipped)."));
450
- else p.outro(red("Commit failed even with --no-verify."));
451
- break;
452
- case "restage":
453
- p.log.info(cyan("Re-staging and retrying..."));
454
- if (await onRestage()) p.outro(green("Committed successfully."));
455
- else await showRecoveryMenu(errors, onRetry, onSkipHooks, onRestage, message);
456
- break;
457
- case "edit": {
458
- const edited = await p.text({
459
- message: "Edit commit message:",
460
- initialValue: message,
461
- validate: (v) => v.trim() ? void 0 : "Message cannot be empty"
462
- });
463
- if (p.isCancel(edited)) {
464
- p.outro(yellow("Cancelled. Message cached for --retry."));
667
+ case "cancel":
668
+ p.outro(dim("Message cached for --retry."));
465
669
  process.exit(1);
466
- }
467
- if (await onRetry()) p.outro(green("Committed successfully."));
468
- else p.outro(red("Commit failed again."));
469
- break;
670
+ return;
470
671
  }
471
- case "cancel":
472
- p.outro(dim("Message cached for --retry."));
473
- process.exit(1);
474
672
  }
475
673
  }
476
674
  //#endregion
@@ -489,41 +687,67 @@ async function saveCachedCommit(repoPath, message) {
489
687
  timestamp: Date.now(),
490
688
  repoPath
491
689
  };
492
- await writeFile(cachePath(repoPath), JSON.stringify(data, null, 2), "utf8");
690
+ const path = cachePath(repoPath);
691
+ debug("saveCachedCommit: saving to %s", path);
692
+ await writeFile(path, JSON.stringify(data, null, 2), "utf8");
493
693
  }
494
694
  async function loadCachedCommit(repoPath) {
695
+ const path = cachePath(repoPath);
696
+ debug("loadCachedCommit: loading from %s", path);
495
697
  try {
496
- const raw = await readFile(cachePath(repoPath), "utf8");
497
- return JSON.parse(raw);
698
+ const raw = await readFile(path, "utf8");
699
+ const data = JSON.parse(raw);
700
+ debug("loadCachedCommit: found message from %s", new Date(data.timestamp).toISOString());
701
+ return data;
498
702
  } catch {
703
+ debug("loadCachedCommit: no cached commit found");
499
704
  return null;
500
705
  }
501
706
  }
502
707
  //#endregion
503
708
  //#region src/commands/commit.ts
504
709
  async function commitCommand(flags) {
710
+ debug("commitCommand called", { flags });
505
711
  await assertGitRepo();
506
712
  if (flags.retry) {
713
+ debug("Entering retry mode");
507
714
  const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
508
- const cached = await loadCachedCommit(await getRepoRoot());
715
+ const repoRoot = await getRepoRoot();
716
+ debug("Repo root:", repoRoot);
717
+ const cached = await loadCachedCommit(repoRoot);
509
718
  if (!cached) {
719
+ debug("No cached commit found");
510
720
  outro(red("No cached commit message found. Run cmint without --retry first."));
511
721
  process.exit(1);
512
722
  }
723
+ debug("Loaded cached message:", cached.message);
513
724
  intro("commit-mint — retry");
514
725
  const s = spinner();
515
726
  s.start("Retrying commit...");
516
727
  const result = await attemptCommit(cached.message);
517
728
  s.stop("Attempted commit");
518
- if (result.ok) outro(green("Committed successfully."));
519
- else await showRecoveryMenu(parseHookErrors(result.stderr ?? ""), async () => (await attemptCommit(cached.message)).ok, async (msg) => (await attemptCommitNoVerify(msg)).ok, async () => {
520
- await stageAll();
521
- return (await attemptCommit(cached.message)).ok;
522
- }, cached.message);
729
+ debug("Retry commit result:", result);
730
+ if (result.ok) {
731
+ const checks = parseToolChecks(result.stderr ?? "");
732
+ if (checks.length > 0) {
733
+ const lines = checks.map((c) => ` ${c.ok ? green("✓") : red("✗")} ${c.tool}`);
734
+ log.info(lines.join("\n"));
735
+ }
736
+ outro(green("Committed successfully."));
737
+ } else {
738
+ const errors = parseHookErrors(result.stderr ?? "");
739
+ debug("Hook errors on retry:", errors.length);
740
+ await showRecoveryMenu(errors, async () => (await attemptCommit(cached.message)).ok, async (msg) => (await attemptCommitNoVerify(msg)).ok, async () => {
741
+ await stageAll();
742
+ return (await attemptCommit(cached.message)).ok;
743
+ }, cached.message, result.stderr ?? "");
744
+ }
523
745
  return;
524
746
  }
525
747
  intro("commit-mint");
526
- if (!await getStatusShort()) {
748
+ const status = await getStatusShort();
749
+ debug("Git status:", status || "(empty)");
750
+ if (!status) {
527
751
  outro(dim("Nothing to commit."));
528
752
  return;
529
753
  }
@@ -531,18 +755,47 @@ async function commitCommand(flags) {
531
755
  s.start("Staging all changes...");
532
756
  await stageAll();
533
757
  s.stop("Changes staged");
534
- const diff = await getStagedDiff();
535
- if (!diff) {
758
+ const diffResult = await getStagedDiff();
759
+ if (!diffResult) {
760
+ debug("No staged changes found after staging");
536
761
  outro(red("No staged changes found."));
537
762
  process.exit(1);
538
763
  }
539
- log.info(diff.files.map((f) => ` ${f}`).join("\n"));
764
+ if ("excludedFiles" in diffResult) {
765
+ debug("All staged files are excluded:", diffResult.excludedFiles);
766
+ const message = buildExcludedFilesMessage(diffResult.excludedFiles);
767
+ log.info(diffResult.excludedFiles.map((f) => ` ${f}`).join("\n"));
768
+ const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
769
+ await saveCachedCommit(await getRepoRoot(), message);
770
+ s.start("Committing...");
771
+ const headBefore = await getHead();
772
+ const result = await attemptCommit(message);
773
+ const headAfter = await getHead();
774
+ if (result.ok || headBefore !== headAfter) {
775
+ s.stop("Committed successfully.");
776
+ outro(green("Done."));
777
+ return;
778
+ }
779
+ s.stop("Commit failed.");
780
+ await showRecoveryMenu(parseHookErrors(result.stderr ?? ""), async () => (await attemptCommit(message)).ok, async (msg) => (await attemptCommitNoVerify(msg)).ok, async () => {
781
+ await stageAll();
782
+ return (await attemptCommit(message)).ok;
783
+ }, message, result.stderr ?? "");
784
+ return;
785
+ }
786
+ debug("Staged files:", diffResult.files);
787
+ debug("Diff length:", diffResult.diff.length, "chars");
788
+ log.info(diffResult.files.map((f) => ` ${f}`).join("\n"));
540
789
  let message;
541
- if (flags.message) message = flags.message;
542
- else {
790
+ if (flags.message) {
791
+ debug("Using provided message:", flags.message);
792
+ message = flags.message;
793
+ } else {
543
794
  try {
544
795
  await getApiKey();
796
+ debug("API key found");
545
797
  } catch {
798
+ debug("No API key found, prompting user");
546
799
  const { text: promptText } = await import("@clack/prompts");
547
800
  const key = await promptText({
548
801
  message: "Enter your Groq API key:",
@@ -554,12 +807,17 @@ async function commitCommand(flags) {
554
807
  return;
555
808
  }
556
809
  await setConfigValue("GROQ_API_KEY", String(key).trim());
810
+ debug("API key saved to config");
557
811
  }
558
812
  s.start("Generating commit message...");
559
813
  try {
560
- message = await generateMessage(diff.diff, flags.hint);
814
+ const genStart = Date.now();
815
+ message = await generateMessage(diffResult.diff, flags.hint);
816
+ debug("generateMessage took %d ms", Date.now() - genStart);
817
+ debug("Generated message:", message);
561
818
  } catch (err) {
562
819
  s.stop(red("Failed to generate message."));
820
+ debug("Message generation failed:", err instanceof Error ? err.message : String(err));
563
821
  outro(red(err instanceof Error ? err.message : String(err)));
564
822
  return;
565
823
  }
@@ -584,10 +842,12 @@ async function commitCommand(flags) {
584
842
  ]
585
843
  });
586
844
  if (isCancel(review) || review === "cancel") {
845
+ debug("User cancelled at review step");
587
846
  outro(dim("Cancelled."));
588
847
  return;
589
848
  }
590
849
  if (review === "edit") {
850
+ debug("User chose to edit message");
591
851
  const edited = await text({
592
852
  message: "Edit commit message:",
593
853
  initialValue: message,
@@ -598,32 +858,48 @@ async function commitCommand(flags) {
598
858
  return;
599
859
  }
600
860
  message = String(edited).trim();
861
+ debug("Edited message:", message);
601
862
  }
602
863
  const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
603
- await saveCachedCommit(await getRepoRoot(), message);
864
+ const repoRoot = await getRepoRoot();
865
+ await saveCachedCommit(repoRoot, message);
866
+ debug("Message cached for repo:", repoRoot);
604
867
  s.start("Committing...");
605
868
  const headBefore = await getHead();
869
+ debug("HEAD before commit:", headBefore);
606
870
  const result = await attemptCommit(message);
607
871
  const headAfter = await getHead();
872
+ debug("HEAD after commit:", headAfter);
873
+ debug("Commit result:", result);
608
874
  if (result.ok || headBefore !== headAfter) {
609
875
  s.stop("Committed successfully.");
876
+ const checks = parseToolChecks(result.stderr ?? "");
877
+ if (checks.length > 0) {
878
+ const lines = checks.map((c) => ` ${c.ok ? green("✓") : red("✗")} ${c.tool}`);
879
+ log.info(lines.join("\n"));
880
+ }
610
881
  outro(green("Done."));
611
882
  return;
612
883
  }
613
884
  s.stop("Commit failed.");
614
- await showRecoveryMenu(parseHookErrors(result.stderr ?? ""), async () => {
885
+ debug("Commit failed, showing recovery menu");
886
+ const errors = parseHookErrors(result.stderr ?? "");
887
+ debug("Parsed hook errors:", errors.length, "errors");
888
+ await showRecoveryMenu(errors, async () => {
615
889
  return (await attemptCommit(message)).ok;
616
890
  }, async (msg) => {
617
891
  return (await attemptCommitNoVerify(msg)).ok;
618
892
  }, async () => {
619
893
  await stageAll();
620
894
  return (await attemptCommit(message)).ok;
621
- }, message);
895
+ }, message, result.stderr ?? "");
622
896
  }
623
897
  async function generateMessage(diff, hint) {
624
898
  const config = await readConfig();
899
+ const apiKey = await getApiKey();
900
+ debug("Generating message with model:", config.model, "max-length:", config["max-length"], "type:", config.type);
625
901
  return generateCommitMessage(diff, {
626
- apiKey: await getApiKey(),
902
+ apiKey,
627
903
  model: config.model,
628
904
  maxLength: config["max-length"] ? parseInt(config["max-length"], 10) : void 0,
629
905
  type: config.type,
@@ -631,6 +907,15 @@ async function generateMessage(diff, hint) {
631
907
  hint
632
908
  });
633
909
  }
910
+ function buildExcludedFilesMessage(files) {
911
+ const excludes = getDefaultExcludes();
912
+ const isLockfile = (f) => excludes.some((pattern) => {
913
+ if (pattern.endsWith(".lock") || pattern.endsWith(".json")) return f === pattern || f.endsWith(pattern.replace("*.", "."));
914
+ return false;
915
+ });
916
+ if (files.every(isLockfile)) return "chore: update lockfile";
917
+ return "chore: update generated files";
918
+ }
634
919
  //#endregion
635
920
  //#region src/commands/config.ts
636
921
  const configCommand = command({
@@ -686,10 +971,17 @@ cli({
686
971
  type: String,
687
972
  description: "Add context hint for AI commit message generation",
688
973
  alias: "H"
974
+ },
975
+ debug: {
976
+ type: Boolean,
977
+ description: "Enable debug output",
978
+ alias: "d",
979
+ default: false
689
980
  }
690
981
  },
691
982
  commands: [configCommand]
692
983
  }, (argv) => {
984
+ setDebug(argv.flags.debug);
693
985
  commitCommand(argv.flags);
694
986
  });
695
987
  //#endregion