@kyubiware/commit-mint 0.2.1 → 0.3.0

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.
package/dist/cli.mjs CHANGED
@@ -1,15 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import { cli, command } from "cleye";
3
3
  import * as p from "@clack/prompts";
4
- import { intro, isCancel, log, outro, spinner } from "@clack/prompts";
4
+ import { intro, isCancel, log, note, outro, select, spinner } from "@clack/prompts";
5
5
  import { bold, cyan, dim, green, red, yellow } from "kolorist";
6
- import Groq from "groq-sdk";
7
6
  import { mkdir, readFile, writeFile } from "node:fs/promises";
8
7
  import os from "node:os";
9
8
  import { join } from "node:path";
10
9
  import ini from "ini";
11
10
  import { execa } from "execa";
12
11
  import { spawn } from "node:child_process";
12
+ import Groq from "groq-sdk";
13
13
  import { createHash } from "node:crypto";
14
14
  //#region \0rolldown/runtime.js
15
15
  var __defProp = Object.defineProperty;
@@ -26,7 +26,7 @@ var __exportAll = (all, no_symbols) => {
26
26
  //#region package.json
27
27
  var package_default = {
28
28
  name: "@kyubiware/commit-mint",
29
- version: "0.2.1",
29
+ version: "0.3.0",
30
30
  description: "A commit tool that actually handles hook failures",
31
31
  type: "module",
32
32
  bin: { "cmint": "./dist/cli.mjs" },
@@ -97,171 +97,6 @@ function debug(...args) {
97
97
  console.error(dim(`[debug ${timestamp}]`), ...args);
98
98
  }
99
99
  //#endregion
100
- //#region src/services/ai.ts
101
- const MAX_DIFF_CHARS = 2e4;
102
- const CONVENTIONAL_COMMIT_REGEX = /^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.+\))?!?: .+$/;
103
- function stripThinkTags(text) {
104
- return text.replace(/<think[\s\S]*?<\/think>/gi, "").trim();
105
- }
106
- function deriveMessageFromReasoning(reasoning) {
107
- const match = reasoning.match(/(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.+\))?!?: .+/i);
108
- if (match) return match[0].trim();
109
- const first = reasoning.split(/[.!?]/).find((s) => s.trim().length >= 10);
110
- return first ? first.trim() : null;
111
- }
112
- function stripContextLines(diff) {
113
- return diff.split("\n").filter((line) => !line.startsWith(" ")).join("\n");
114
- }
115
- function compressDiff(diff) {
116
- if (diff.length <= MAX_DIFF_CHARS) return diff;
117
- let result = stripContextLines(diff);
118
- if (result.length <= MAX_DIFF_CHARS) return result;
119
- result = result.split(/(?=diff --git)/).filter(Boolean).map((fd) => {
120
- return fd.split(/(?=\n@@)/).map((part, idx) => {
121
- if (idx === 0) return part;
122
- const lines = part.split("\n");
123
- return [lines[0], ...lines.slice(1).filter((l) => l.startsWith("+") || l.startsWith("-")).slice(0, 10)].join("\n");
124
- }).join("");
125
- }).join("");
126
- if (result.length <= MAX_DIFF_CHARS) return result;
127
- return `Summary of changes:\n${(diff.match(/^diff --git a\/(.+) b\/(.+)$/gm) || []).map((f) => {
128
- const match = f.match(/^diff --git a\/(.+) b\/(.+)$/);
129
- return match && match[1] === match[2] ? `${match[1]} | changed` : "";
130
- }).filter(Boolean).join("\n")}`;
131
- }
132
- function buildStatSummary(diff) {
133
- const files = [];
134
- let currentFile = "";
135
- let adds = 0;
136
- let dels = 0;
137
- for (const line of diff.split("\n")) {
138
- const match = line.match(/^diff --git a\/.+ b\/(.+)$/);
139
- if (match) {
140
- if (currentFile) files.push({
141
- name: currentFile,
142
- adds,
143
- dels
144
- });
145
- currentFile = match[1];
146
- adds = 0;
147
- dels = 0;
148
- } else if (line.startsWith("+") && !line.startsWith("+++")) adds++;
149
- else if (line.startsWith("-") && !line.startsWith("---")) dels++;
150
- }
151
- if (currentFile) files.push({
152
- name: currentFile,
153
- adds,
154
- dels
155
- });
156
- const totalAdds = files.reduce((s, f) => s + f.adds, 0);
157
- const totalDels = files.reduce((s, f) => s + f.dels, 0);
158
- const lines = files.map((f) => ` ${f.name} | +${f.adds} -${f.dels}`);
159
- lines.push(` ${files.length} files changed, ${totalAdds} insertions(+), ${totalDels} deletions(-)`);
160
- return lines.join("\n");
161
- }
162
- function buildSystemPrompt(type) {
163
- 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.";
164
- if (type && type.trim().length > 0) prompt += `\nYou MUST use type: ${type}`;
165
- return prompt;
166
- }
167
- function buildUserPrompt(diff, hint, statSummary) {
168
- const parts = [];
169
- if (hint) parts.push(`Context: ${hint}`);
170
- if (statSummary) parts.push(`Change summary:\n${statSummary}`);
171
- parts.push(`Generate a conventional commit for:\n\n${diff}`);
172
- return parts.join("\n\n");
173
- }
174
- function isValidConventionalCommit(message) {
175
- return CONVENTIONAL_COMMIT_REGEX.test(message);
176
- }
177
- function extractContentText(content) {
178
- if (content == null) return "";
179
- if (typeof content === "string") return content.trim();
180
- if (Array.isArray(content)) return content.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => stripThinkTags(part.text)).join("").trim();
181
- return "";
182
- }
183
- async function generateCommitMessage(diff, options) {
184
- debug("generateCommitMessage: model=%s, type=%s, hint=%s", options.model ?? "default", options.type ?? "none", options.hint ?? "none");
185
- const timeoutMs = options.timeout ?? 6e4;
186
- debug("Timeout: %d ms", timeoutMs);
187
- const client = new Groq({
188
- apiKey: options.apiKey,
189
- timeout: timeoutMs
190
- });
191
- const compressedDiff = compressDiff(diff);
192
- const statSummary = buildStatSummary(diff);
193
- const systemPrompt = buildSystemPrompt(options.type);
194
- const userPrompt = buildUserPrompt(compressedDiff, options.hint, statSummary);
195
- debug("Diff: %d chars → compressed to %d chars", diff.length, compressedDiff.length);
196
- debug("Stat summary:\n%s", statSummary);
197
- debug("User prompt length: %d chars", userPrompt.length);
198
- async function callAI(strictSystemPrompt) {
199
- const callStart = Date.now();
200
- debug("callAI: %s — model=%s, promptLen=%d, systemLen=%d", !!strictSystemPrompt ? "RETRY (strict)" : "INITIAL", options.model ?? "openai/gpt-oss-20b", userPrompt.length, (strictSystemPrompt ?? systemPrompt).length);
201
- try {
202
- const isReasoningModel = /^(o[1-9]|.*gpt-oss.*|.*gpt-5.*)/i.test(options.model ?? "");
203
- const completion = await client.chat.completions.create({
204
- messages: [{
205
- role: "system",
206
- content: strictSystemPrompt ?? systemPrompt
207
- }, {
208
- role: "user",
209
- content: userPrompt
210
- }],
211
- model: options.model ?? "openai/gpt-oss-20b",
212
- temperature: .3,
213
- ...isReasoningModel ? { max_completion_tokens: 1024 } : { max_tokens: 1024 },
214
- reasoning_format: "parsed"
215
- });
216
- const elapsed = Date.now() - callStart;
217
- const rawContent = completion.choices[0]?.message?.content;
218
- const content = extractContentText(typeof rawContent === "string" ? stripThinkTags(rawContent) : rawContent);
219
- 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);
220
- debug("callAI raw content: %s", content.slice(0, 300) || "(empty)");
221
- if (!content) {
222
- const reasoning = completion.choices[0]?.message?.reasoning;
223
- debug("callAI: content empty, attempting reasoning fallback (reasoningLen=%d)", reasoning?.length ?? 0);
224
- if (reasoning) {
225
- const derived = deriveMessageFromReasoning(reasoning);
226
- if (derived) {
227
- debug("callAI: derived message from reasoning: %s", derived.slice(0, 100));
228
- return stripThinkTags(derived);
229
- }
230
- debug("callAI: could not derive message from reasoning");
231
- }
232
- throw new Error("AI returned an empty commit message");
233
- }
234
- return content;
235
- } catch (error) {
236
- debug("callAI FAILED after %d ms: %s", Date.now() - callStart, error instanceof Error ? `${error.name}: ${error.message}` : String(error));
237
- throw error;
238
- }
239
- }
240
- try {
241
- const totalStart = Date.now();
242
- let message = await callAI();
243
- debug("Validation: message=%s, isValid=%s", message.slice(0, 100), isValidConventionalCommit(message));
244
- if (!isValidConventionalCommit(message)) {
245
- debug("Initial message failed conventional commit validation, retrying with strict prompt (elapsed: %d ms)", Date.now() - totalStart);
246
- 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.");
247
- debug("Retry validation: message=%s, isValid=%s", retryMessage.slice(0, 100), isValidConventionalCommit(retryMessage));
248
- if (isValidConventionalCommit(retryMessage)) {
249
- debug("Retry produced valid conventional commit");
250
- message = retryMessage;
251
- } else debug("Retry also failed validation, using original message");
252
- }
253
- debug("Final message (%d ms total): %s", Date.now() - totalStart, message);
254
- return message;
255
- } catch (error) {
256
- debug("AI error: %s", error instanceof Error ? error.message : String(error));
257
- if (error instanceof Groq.AuthenticationError) throw new Error("Invalid GROQ_API_KEY. Run: cmint config set GROQ_API_KEY=<key>");
258
- if (error instanceof Groq.RateLimitError) throw new Error("Rate limited by Groq. Please wait and try again.");
259
- if (error instanceof Groq.APIConnectionTimeoutError) throw new Error("Request timed out. Check your network or try a smaller diff.");
260
- if (error instanceof Groq.APIError) throw new Error(`Groq API error: ${error.message}`);
261
- throw new Error(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`);
262
- }
263
- }
264
- //#endregion
265
100
  //#region src/services/config.ts
266
101
  const CONFIG_PATH = join(os.homedir(), ".commit-mint");
267
102
  const defaults = {
@@ -407,10 +242,14 @@ async function getStatusShort() {
407
242
  async function getChangedFiles() {
408
243
  const { stdout } = await execa("git", ["status", "--short"]);
409
244
  if (!stdout.trim()) return [];
410
- const files = stdout.split("\n").filter(Boolean).map((line) => ({
411
- status: line.slice(0, 2).trim(),
412
- path: line.slice(3)
413
- }));
245
+ const files = stdout.split("\n").filter(Boolean).map((line) => {
246
+ const indexStatus = line[0];
247
+ return {
248
+ status: line.slice(0, 2).trim(),
249
+ path: line.slice(3),
250
+ staged: indexStatus !== " " && indexStatus !== "?"
251
+ };
252
+ });
414
253
  debug("getChangedFiles:", files.length, "files");
415
254
  return files;
416
255
  }
@@ -640,9 +479,27 @@ async function showStagingMenu(files) {
640
479
  default: return dim(status);
641
480
  }
642
481
  };
482
+ const sorted = [...files].sort((a, b) => {
483
+ if (a.staged !== b.staged) return a.staged ? -1 : 1;
484
+ return a.path.localeCompare(b.path);
485
+ });
486
+ const stagedFiles = sorted.filter((f) => f.staged);
487
+ const unstagedFiles = sorted.filter((f) => !f.staged);
488
+ const lines = [];
489
+ if (stagedFiles.length > 0) lines.push(green(bold("Staged:")), ...stagedFiles.map((f) => ` ${statusLabel(f.status)} ${f.path}`));
490
+ if (unstagedFiles.length > 0) {
491
+ if (lines.length > 0) lines.push("");
492
+ lines.push(yellow(bold("Changed:")), ...unstagedFiles.map((f) => ` ${statusLabel(f.status)} ${f.path}`));
493
+ }
494
+ p.note(lines.join("\n"), `${files.length} file${files.length !== 1 ? "s" : ""}`);
643
495
  const choice = await p.select({
644
496
  message: "Stage files for commit:",
645
497
  options: [
498
+ {
499
+ label: "Auto-group into commits",
500
+ value: "autogroup",
501
+ hint: "LLM groups files into logical commits"
502
+ },
646
503
  {
647
504
  label: "Stage all files",
648
505
  value: "all",
@@ -659,13 +516,14 @@ async function showStagingMenu(files) {
659
516
  ]
660
517
  });
661
518
  if (p.isCancel(choice) || choice === "cancel") return null;
519
+ if (choice === "autogroup") return "autogroup";
662
520
  if (choice === "all") return {
663
521
  files: files.map((f) => f.path),
664
522
  all: true
665
523
  };
666
524
  const selected = await p.multiselect({
667
525
  message: "Select files to stage:",
668
- options: files.map((f) => ({
526
+ options: sorted.map((f) => ({
669
527
  label: `${statusLabel(f.status)} ${f.path}`,
670
528
  value: f.path
671
529
  })),
@@ -718,8 +576,7 @@ async function showRecoveryMenu(errors, onRetry, onSkipHooks, onRestage, message
718
576
  if (p.isCancel(choice)) {
719
577
  debug("showRecoveryMenu: user cancelled");
720
578
  p.outro(yellow("Cancelled. Message cached for --retry."));
721
- process.exit(1);
722
- return;
579
+ return "cancelled";
723
580
  }
724
581
  debug("showRecoveryMenu: user chose %s", choice);
725
582
  switch (choice) {
@@ -731,14 +588,18 @@ async function showRecoveryMenu(errors, onRetry, onSkipHooks, onRestage, message
731
588
  continue;
732
589
  case "skip":
733
590
  p.log.info(yellow("Committing with --no-verify..."));
734
- if (await onSkipHooks(message)) p.outro(green("Committed (hooks skipped)."));
735
- else p.outro(red("Commit failed even with --no-verify."));
736
- return;
591
+ if (await onSkipHooks(message)) {
592
+ p.outro(green("Committed (hooks skipped)."));
593
+ return "committed";
594
+ } else {
595
+ p.outro(red("Commit failed even with --no-verify."));
596
+ return "failed";
597
+ }
737
598
  case "restage":
738
599
  p.log.info(cyan("Re-staging and retrying..."));
739
600
  if (await onRestage()) {
740
601
  p.outro(green("Committed successfully."));
741
- return;
602
+ return "committed";
742
603
  }
743
604
  showNote = true;
744
605
  continue;
@@ -750,92 +611,846 @@ async function showRecoveryMenu(errors, onRetry, onSkipHooks, onRestage, message
750
611
  });
751
612
  if (p.isCancel(edited)) {
752
613
  p.outro(yellow("Cancelled. Message cached for --retry."));
753
- process.exit(1);
754
- return;
614
+ return "cancelled";
615
+ }
616
+ if (await onRetry()) {
617
+ p.outro(green("Committed successfully."));
618
+ return "committed";
619
+ } else {
620
+ p.outro(red("Commit failed again."));
621
+ return "failed";
755
622
  }
756
- if (await onRetry()) p.outro(green("Committed successfully."));
757
- else p.outro(red("Commit failed again."));
758
- return;
759
623
  }
760
624
  case "cancel":
761
625
  p.outro(dim("Message cached for --retry."));
762
- process.exit(1);
763
- return;
626
+ return "cancelled";
764
627
  }
765
628
  }
766
629
  }
767
630
  //#endregion
768
- //#region src/utils/cache.ts
769
- const CACHE_DIR = join(os.homedir(), ".cache", "commit-mint");
770
- function repoHash(repoPath) {
771
- return createHash("sha256").update(repoPath).digest("hex").slice(0, 12);
631
+ //#region src/services/ai.ts
632
+ const MAX_DIFF_CHARS = 2e4;
633
+ function mapGroqError$1(error) {
634
+ if (error instanceof Groq.AuthenticationError) return /* @__PURE__ */ new Error("Invalid GROQ_API_KEY. Run: cmint config set GROQ_API_KEY=<key>");
635
+ if (error instanceof Groq.RateLimitError) return /* @__PURE__ */ new Error("Rate limited by Groq. Please wait and try again.");
636
+ if (error instanceof Groq.APIConnectionTimeoutError) return /* @__PURE__ */ new Error("Request timed out. Check your network or try a smaller diff.");
637
+ if (error instanceof Groq.APIError) return /* @__PURE__ */ new Error(`Groq API error: ${error.message}`);
638
+ return /* @__PURE__ */ new Error(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`);
772
639
  }
773
- function cachePath(repoPath) {
774
- return join(CACHE_DIR, `${repoHash(repoPath)}.json`);
640
+ const CONVENTIONAL_COMMIT_REGEX = /^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.+\))?!?: .+$/;
641
+ function stripThinkTags(text) {
642
+ return text.replace(/<think[\s\S]*?<\/think>/gi, "").trim();
775
643
  }
776
- async function saveCachedCommit(repoPath, message) {
777
- await mkdir(CACHE_DIR, { recursive: true });
778
- const data = {
779
- message,
780
- timestamp: Date.now(),
781
- repoPath
782
- };
783
- const path = cachePath(repoPath);
784
- debug("saveCachedCommit: saving to %s", path);
785
- await writeFile(path, JSON.stringify(data, null, 2), "utf8");
644
+ function deriveMessageFromReasoning(reasoning) {
645
+ const match = reasoning.match(/(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.+\))?!?: .+/i);
646
+ if (match) return match[0].trim();
647
+ const first = reasoning.split(/[.!?]/).find((s) => s.trim().length >= 10);
648
+ return first ? first.trim() : null;
786
649
  }
787
- async function loadCachedCommit(repoPath) {
788
- const path = cachePath(repoPath);
789
- debug("loadCachedCommit: loading from %s", path);
790
- try {
791
- const raw = await readFile(path, "utf8");
792
- const data = JSON.parse(raw);
793
- debug("loadCachedCommit: found message from %s", new Date(data.timestamp).toISOString());
794
- return data;
795
- } catch {
796
- debug("loadCachedCommit: no cached commit found");
797
- return null;
798
- }
650
+ function stripContextLines(diff) {
651
+ return diff.split("\n").filter((line) => !line.startsWith(" ")).join("\n");
799
652
  }
800
- //#endregion
801
- //#region src/commands/commit.ts
802
- async function commitCommand(flags) {
803
- debug("commitCommand called", { flags });
804
- await assertGitRepo();
805
- if (flags.retry) {
806
- debug("Entering retry mode");
807
- const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
808
- const repoRoot = await getRepoRoot();
809
- debug("Repo root:", repoRoot);
810
- const cached = await loadCachedCommit(repoRoot);
811
- if (!cached) {
812
- debug("No cached commit found");
813
- outro(red("No cached commit message found. Run cmint without --retry first."));
814
- process.exit(1);
815
- }
816
- debug("Loaded cached message:", cached.message);
817
- intro("commit-mint — retry");
818
- const s = spinner();
819
- s.start("Retrying commit...");
820
- const result = await attemptCommit(cached.message);
821
- s.stop("Attempted commit");
822
- debug("Retry commit result:", result);
823
- if (result.ok) {
824
- const checks = parseToolChecks(result.stderr ?? "");
825
- if (checks.length > 0) {
826
- const lines = checks.map((c) => ` ${c.ok ? green("✓") : red("✗")} ${c.tool}`);
827
- log.info(lines.join("\n"));
828
- }
829
- outro(green("Committed successfully."));
830
- } else {
831
- const errors = parseHookErrors(result.stderr ?? "");
832
- debug("Hook errors on retry:", errors.length);
833
- await showRecoveryMenu(errors, async () => (await attemptCommit(cached.message)).ok, async (msg) => (await attemptCommitNoVerify(msg)).ok, async () => {
834
- await stageAll();
835
- return (await attemptCommit(cached.message)).ok;
836
- }, cached.message, result.stderr ?? "");
837
- }
838
- return;
653
+ function compressDiff(diff) {
654
+ if (diff.length <= MAX_DIFF_CHARS) return diff;
655
+ let result = stripContextLines(diff);
656
+ if (result.length <= MAX_DIFF_CHARS) return result;
657
+ result = result.split(/(?=diff --git)/).filter(Boolean).map((fd) => {
658
+ return fd.split(/(?=\n@@)/).map((part, idx) => {
659
+ if (idx === 0) return part;
660
+ const lines = part.split("\n");
661
+ return [lines[0], ...lines.slice(1).filter((l) => l.startsWith("+") || l.startsWith("-")).slice(0, 10)].join("\n");
662
+ }).join("");
663
+ }).join("");
664
+ if (result.length <= MAX_DIFF_CHARS) return result;
665
+ return `Summary of changes:\n${(diff.match(/^diff --git a\/(.+) b\/(.+)$/gm) || []).map((f) => {
666
+ const match = f.match(/^diff --git a\/(.+) b\/(.+)$/);
667
+ return match && match[1] === match[2] ? `${match[1]} | changed` : "";
668
+ }).filter(Boolean).join("\n")}`;
669
+ }
670
+ function buildStatSummary(diff) {
671
+ const files = [];
672
+ let currentFile = "";
673
+ let adds = 0;
674
+ let dels = 0;
675
+ for (const line of diff.split("\n")) {
676
+ const match = line.match(/^diff --git a\/.+ b\/(.+)$/);
677
+ if (match) {
678
+ if (currentFile) files.push({
679
+ name: currentFile,
680
+ adds,
681
+ dels
682
+ });
683
+ currentFile = match[1];
684
+ adds = 0;
685
+ dels = 0;
686
+ } else if (line.startsWith("+") && !line.startsWith("+++")) adds++;
687
+ else if (line.startsWith("-") && !line.startsWith("---")) dels++;
688
+ }
689
+ if (currentFile) files.push({
690
+ name: currentFile,
691
+ adds,
692
+ dels
693
+ });
694
+ const totalAdds = files.reduce((s, f) => s + f.adds, 0);
695
+ const totalDels = files.reduce((s, f) => s + f.dels, 0);
696
+ const lines = files.map((f) => ` ${f.name} | +${f.adds} -${f.dels}`);
697
+ lines.push(` ${files.length} files changed, ${totalAdds} insertions(+), ${totalDels} deletions(-)`);
698
+ return lines.join("\n");
699
+ }
700
+ function buildSystemPrompt(type) {
701
+ 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.";
702
+ if (type && type.trim().length > 0) prompt += `\nYou MUST use type: ${type}`;
703
+ return prompt;
704
+ }
705
+ function buildUserPrompt(diff, hint, statSummary) {
706
+ const parts = [];
707
+ if (hint) parts.push(`Context: ${hint}`);
708
+ if (statSummary) parts.push(`Change summary:\n${statSummary}`);
709
+ parts.push(`Generate a conventional commit for:\n\n${diff}`);
710
+ return parts.join("\n\n");
711
+ }
712
+ function isValidConventionalCommit(message) {
713
+ return CONVENTIONAL_COMMIT_REGEX.test(message);
714
+ }
715
+ function extractContentText(content) {
716
+ if (content == null) return "";
717
+ if (typeof content === "string") return content.trim();
718
+ if (Array.isArray(content)) return content.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => stripThinkTags(part.text)).join("").trim();
719
+ return "";
720
+ }
721
+ async function generateCommitMessage(diff, options) {
722
+ debug("generateCommitMessage: model=%s, type=%s, hint=%s", options.model ?? "default", options.type ?? "none", options.hint ?? "none");
723
+ const timeoutMs = options.timeout ?? 6e4;
724
+ debug("Timeout: %d ms", timeoutMs);
725
+ const client = new Groq({
726
+ apiKey: options.apiKey,
727
+ timeout: timeoutMs
728
+ });
729
+ const compressedDiff = compressDiff(diff);
730
+ const statSummary = buildStatSummary(diff);
731
+ const systemPrompt = buildSystemPrompt(options.type);
732
+ const userPrompt = buildUserPrompt(compressedDiff, options.hint, statSummary);
733
+ debug("Diff: %d chars → compressed to %d chars", diff.length, compressedDiff.length);
734
+ debug("Stat summary:\n%s", statSummary);
735
+ debug("User prompt length: %d chars", userPrompt.length);
736
+ async function callAI(strictSystemPrompt) {
737
+ const callStart = Date.now();
738
+ debug("callAI: %s — model=%s, promptLen=%d, systemLen=%d", !!strictSystemPrompt ? "RETRY (strict)" : "INITIAL", options.model ?? "openai/gpt-oss-20b", userPrompt.length, (strictSystemPrompt ?? systemPrompt).length);
739
+ try {
740
+ const isReasoningModel = /^(o[1-9]|.*gpt-oss.*|.*gpt-5.*)/i.test(options.model ?? "");
741
+ const completion = await client.chat.completions.create({
742
+ messages: [{
743
+ role: "system",
744
+ content: strictSystemPrompt ?? systemPrompt
745
+ }, {
746
+ role: "user",
747
+ content: userPrompt
748
+ }],
749
+ model: options.model ?? "openai/gpt-oss-20b",
750
+ temperature: .3,
751
+ ...isReasoningModel ? { max_completion_tokens: 1024 } : { max_tokens: 1024 },
752
+ reasoning_format: "parsed"
753
+ });
754
+ const elapsed = Date.now() - callStart;
755
+ const rawContent = completion.choices[0]?.message?.content;
756
+ const content = extractContentText(typeof rawContent === "string" ? stripThinkTags(rawContent) : rawContent);
757
+ 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);
758
+ debug("callAI raw content: %s", content.slice(0, 300) || "(empty)");
759
+ if (!content) {
760
+ const reasoning = completion.choices[0]?.message?.reasoning;
761
+ debug("callAI: content empty, attempting reasoning fallback (reasoningLen=%d)", reasoning?.length ?? 0);
762
+ if (reasoning) {
763
+ const derived = deriveMessageFromReasoning(reasoning);
764
+ if (derived) {
765
+ debug("callAI: derived message from reasoning: %s", derived.slice(0, 100));
766
+ return stripThinkTags(derived);
767
+ }
768
+ debug("callAI: could not derive message from reasoning");
769
+ }
770
+ throw new Error("AI returned an empty commit message");
771
+ }
772
+ return content;
773
+ } catch (error) {
774
+ debug("callAI FAILED after %d ms: %s", Date.now() - callStart, error instanceof Error ? `${error.name}: ${error.message}` : String(error));
775
+ throw error;
776
+ }
777
+ }
778
+ try {
779
+ const totalStart = Date.now();
780
+ let message = await callAI();
781
+ debug("Validation: message=%s, isValid=%s", message.slice(0, 100), isValidConventionalCommit(message));
782
+ if (!isValidConventionalCommit(message)) {
783
+ debug("Initial message failed conventional commit validation, retrying with strict prompt (elapsed: %d ms)", Date.now() - totalStart);
784
+ 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.");
785
+ debug("Retry validation: message=%s, isValid=%s", retryMessage.slice(0, 100), isValidConventionalCommit(retryMessage));
786
+ if (isValidConventionalCommit(retryMessage)) {
787
+ debug("Retry produced valid conventional commit");
788
+ message = retryMessage;
789
+ } else debug("Retry also failed validation, using original message");
790
+ }
791
+ debug("Final message (%d ms total): %s", Date.now() - totalStart, message);
792
+ return message;
793
+ } catch (error) {
794
+ debug("AI error: %s", error instanceof Error ? error.message : String(error));
795
+ throw mapGroqError$1(error);
796
+ }
797
+ }
798
+ //#endregion
799
+ //#region src/services/review-ai.ts
800
+ function buildReviewSystemPrompt() {
801
+ return [
802
+ "You are an expert code reviewer. Review the following staged git diff.",
803
+ "",
804
+ "Focus on finding:",
805
+ "1. **Bugs** — logic errors, off-by-one, race conditions, null pointer risks",
806
+ "2. **Security issues** — injection, exposure of secrets, missing validation, CSRF, XSS",
807
+ "3. **Performance problems** — unnecessary work, large allocations in hot paths",
808
+ "4. **Code quality** — readability, maintainability, error handling gaps",
809
+ "5. **Edge cases** — missing boundary checks, empty states, error states",
810
+ "",
811
+ "For each issue found, use this format:",
812
+ "- SEVERITY: [critical|major|minor|suggestion]",
813
+ "- LOCATION: <file-path>:<line-number>",
814
+ "- ISSUE: <description>",
815
+ "- FIX: <suggested resolution>",
816
+ "",
817
+ "Separate issues with a blank line.",
818
+ "",
819
+ "If you find NO issues at all, respond with exactly: NO_ISSUES_FOUND",
820
+ "",
821
+ "Be thorough but practical. Only flag real problems — not style preferences or nitpicks."
822
+ ].join("\n");
823
+ }
824
+ function buildReviewPrompt(diff, files, statSummary) {
825
+ const parts = [];
826
+ parts.push(`Review the following staged changes (${files.length} files):`);
827
+ parts.push("");
828
+ parts.push(statSummary);
829
+ parts.push("");
830
+ parts.push("```diff");
831
+ parts.push(diff);
832
+ parts.push("```");
833
+ return parts.join("\n");
834
+ }
835
+ async function generateCodeReview(diff, files, options) {
836
+ debug("generateCodeReview: model=%s, files=%d", options.model ?? "default", files.length);
837
+ const timeoutMs = options.timeout ?? 6e4;
838
+ const client = new Groq({
839
+ apiKey: options.apiKey,
840
+ timeout: timeoutMs
841
+ });
842
+ const compressedDiff = compressDiff(diff);
843
+ const statSummary = buildStatSummary(diff);
844
+ const systemPrompt = buildReviewSystemPrompt();
845
+ const userPrompt = buildReviewPrompt(compressedDiff, files, statSummary);
846
+ debug("Code review: %d chars → %d chars, system=%d chars, user=%d chars", diff.length, compressedDiff.length, systemPrompt.length, userPrompt.length);
847
+ try {
848
+ const completion = await client.chat.completions.create({
849
+ messages: [{
850
+ role: "system",
851
+ content: systemPrompt
852
+ }, {
853
+ role: "user",
854
+ content: userPrompt
855
+ }],
856
+ model: options.model ?? "openai/gpt-oss-20b",
857
+ temperature: .3,
858
+ max_tokens: 4096
859
+ });
860
+ const rawContent = completion.choices[0]?.message?.content;
861
+ const content = extractContentText(rawContent);
862
+ debug("generateCodeReview response: choices=%d, finishReason=%s, contentLen=%d", completion.choices.length, completion.choices[0]?.finish_reason ?? "(none)", content.length);
863
+ if (!content) {
864
+ const reasoning = completion.choices[0]?.message?.reasoning;
865
+ if (reasoning) {
866
+ const derived = deriveMessageFromReasoning(reasoning);
867
+ if (derived) {
868
+ debug("generateCodeReview: derived from reasoning");
869
+ return derived;
870
+ }
871
+ }
872
+ return "NO_ISSUES_FOUND";
873
+ }
874
+ return content;
875
+ } catch (error) {
876
+ debug("generateCodeReview error: %s", error instanceof Error ? error.message : String(error));
877
+ throw mapGroqError$1(error);
878
+ }
879
+ }
880
+ //#endregion
881
+ //#region src/commands/review.ts
882
+ async function reviewCommand() {
883
+ debug("reviewCommand called");
884
+ await assertGitRepo();
885
+ const s = spinner();
886
+ s.start("Staging all changes...");
887
+ await stageAll();
888
+ s.stop("Changes staged");
889
+ const diffResult = await getStagedDiff();
890
+ if (!diffResult) {
891
+ outro(dim("No changes to review."));
892
+ return;
893
+ }
894
+ if ("excludedFiles" in diffResult) {
895
+ outro(dim("Staged files are all excluded from review."));
896
+ return;
897
+ }
898
+ intro("commit-mint — code review");
899
+ log.info(diffResult.files.map((f) => ` ${f}`).join("\n"));
900
+ const report = await isOpenCodeAvailable() ? await reviewWithOpenCode(diffResult.diff, diffResult.files) : await reviewWithGroq(diffResult.diff, diffResult.files);
901
+ if (report !== "NO_ISSUES_FOUND" && report.trim().length > 0) {
902
+ note(report, red(bold("Review findings")));
903
+ await offerClipboardCopy(report);
904
+ } else outro(green("No issues found. Looks good!"));
905
+ }
906
+ async function offerClipboardCopy(report) {
907
+ const shouldCopy = await select({
908
+ message: "Copy review report to clipboard?",
909
+ options: [{
910
+ label: "Yes, copy to clipboard",
911
+ value: "yes"
912
+ }, {
913
+ label: "No",
914
+ value: "no"
915
+ }]
916
+ });
917
+ if (isCancel(shouldCopy) || shouldCopy === "no") {
918
+ outro(dim("Done."));
919
+ return;
920
+ }
921
+ if (await copyToClipboard(report)) outro(green("Report copied to clipboard. You can paste it anywhere for fixes."));
922
+ else outro(red("Failed to copy to clipboard. Install xclip, wl-copy, or xsel."));
923
+ }
924
+ async function isOpenCodeAvailable() {
925
+ try {
926
+ const { exitCode } = await import("execa").then((m) => m.execa("which", ["opencode"], { reject: false }));
927
+ return exitCode === 0;
928
+ } catch {
929
+ return false;
930
+ }
931
+ }
932
+ async function reviewWithGroq(diff, files) {
933
+ const s = spinner();
934
+ s.start("Reviewing with Groq...");
935
+ try {
936
+ const config = await readConfig();
937
+ const report = await generateCodeReview(diff, files, {
938
+ apiKey: await getApiKey(),
939
+ model: config.model,
940
+ timeout: config.timeout ? Number.parseInt(config.timeout, 10) : void 0
941
+ });
942
+ s.stop("Review complete");
943
+ return report;
944
+ } catch (err) {
945
+ s.stop(red("Review failed."));
946
+ debug("reviewWithGroq error:", err instanceof Error ? err.message : String(err));
947
+ throw err;
948
+ }
949
+ }
950
+ async function reviewWithOpenCode(diff, files) {
951
+ const s = spinner();
952
+ s.start("Running OpenCode review...");
953
+ try {
954
+ const repoRoot = await getRepoRoot();
955
+ const prompt = [
956
+ "Review the staged changes in this git repository.",
957
+ "Analyze the code diff for bugs, security issues, performance problems,",
958
+ "code quality issues, and missing edge cases.",
959
+ "",
960
+ `Files changed (${files.length}):`,
961
+ ...files.map((f) => ` - ${f}`),
962
+ "",
963
+ "```diff",
964
+ diff.slice(0, 15e3),
965
+ "```",
966
+ "",
967
+ "Provide a structured report with severity, location, issue, and fix suggestion for each finding.",
968
+ "If no issues found, respond with: NO_ISSUES_FOUND"
969
+ ].join("\n");
970
+ const { stdout } = await import("execa").then((m) => m.execa("opencode", [
971
+ "run",
972
+ prompt,
973
+ "--dir",
974
+ repoRoot
975
+ ], {
976
+ timeout: 12e4,
977
+ reject: false
978
+ }));
979
+ s.stop("Review complete");
980
+ return stdout || "OpenCode review completed but no output captured.";
981
+ } catch (err) {
982
+ s.stop(red("OpenCode review failed."));
983
+ debug("reviewWithOpenCode error:", err instanceof Error ? err.message : String(err));
984
+ throw new Error(`OpenCode review failed: ${err instanceof Error ? err.message : String(err)}`);
985
+ }
986
+ }
987
+ //#endregion
988
+ //#region src/ui/review-message.ts
989
+ async function runCodeReview() {
990
+ const diffResult = await getStagedDiff();
991
+ if (!diffResult || "excludedFiles" in diffResult) {
992
+ outro(dim("No staged changes to review."));
993
+ return;
994
+ }
995
+ const opencodeAvailable = await isOpenCodeAvailable();
996
+ const s = spinner();
997
+ s.start(opencodeAvailable ? "Running OpenCode review..." : "Running Groq review...");
998
+ try {
999
+ const report = opencodeAvailable ? await reviewWithOpenCode(diffResult.diff, diffResult.files) : await reviewWithGroq(diffResult.diff, diffResult.files);
1000
+ s.stop("Review complete");
1001
+ await showReviewResults(report);
1002
+ } catch (err) {
1003
+ s.stop(red("Review failed."));
1004
+ debug("Code review error:", err instanceof Error ? err.message : String(err));
1005
+ outro(red(err instanceof Error ? err.message : String(err)));
1006
+ }
1007
+ }
1008
+ async function showReviewResults(report) {
1009
+ const { note: clackNote, select: clackSelect } = await import("@clack/prompts");
1010
+ if (!(report !== "NO_ISSUES_FOUND" && report.trim().length > 0)) {
1011
+ log.info(green("No issues found."));
1012
+ return;
1013
+ }
1014
+ clackNote(report, red(bold("Review findings")));
1015
+ const shouldCopy = await clackSelect({
1016
+ message: "Copy review report to clipboard?",
1017
+ options: [{
1018
+ label: "Yes, copy to clipboard",
1019
+ value: "yes"
1020
+ }, {
1021
+ label: "No",
1022
+ value: "no"
1023
+ }]
1024
+ });
1025
+ if (isCancel(shouldCopy) || shouldCopy !== "yes") return;
1026
+ if (await copyToClipboard(report)) log.info(green("Report copied to clipboard."));
1027
+ else log.warn(red("Failed to copy to clipboard."));
1028
+ }
1029
+ async function reviewCommitMessage(message) {
1030
+ const { select, text } = await import("@clack/prompts");
1031
+ while (true) {
1032
+ const review = await select({
1033
+ message: `Review commit message:\n\n ${bold(message)}\n`,
1034
+ options: [
1035
+ {
1036
+ label: "Use as-is",
1037
+ value: "use"
1038
+ },
1039
+ {
1040
+ label: "Edit",
1041
+ value: "edit"
1042
+ },
1043
+ {
1044
+ label: "Review with OpenCode",
1045
+ value: "review"
1046
+ },
1047
+ {
1048
+ label: "Cancel",
1049
+ value: "cancel"
1050
+ }
1051
+ ]
1052
+ });
1053
+ if (isCancel(review) || review === "cancel") {
1054
+ debug("User cancelled at review step");
1055
+ return null;
1056
+ }
1057
+ if (review === "use") {
1058
+ debug("User accepted message");
1059
+ return message;
1060
+ }
1061
+ if (review === "edit") {
1062
+ debug("User chose to edit message");
1063
+ const edited = await text({
1064
+ message: "Edit commit message:",
1065
+ initialValue: message,
1066
+ validate: (v) => v.trim() ? void 0 : "Message cannot be empty"
1067
+ });
1068
+ if (isCancel(edited)) continue;
1069
+ message = String(edited).trim();
1070
+ debug("Edited message:", message);
1071
+ continue;
1072
+ }
1073
+ if (review === "review") {
1074
+ debug("User chose to review code");
1075
+ await runCodeReview();
1076
+ }
1077
+ }
1078
+ }
1079
+ //#endregion
1080
+ //#region src/utils/cache.ts
1081
+ const CACHE_DIR = join(os.homedir(), ".cache", "commit-mint");
1082
+ function repoHash(repoPath) {
1083
+ return createHash("sha256").update(repoPath).digest("hex").slice(0, 12);
1084
+ }
1085
+ function cachePath(repoPath) {
1086
+ return join(CACHE_DIR, `${repoHash(repoPath)}.json`);
1087
+ }
1088
+ async function saveCachedCommit(repoPath, message) {
1089
+ await mkdir(CACHE_DIR, { recursive: true });
1090
+ const data = {
1091
+ message,
1092
+ timestamp: Date.now(),
1093
+ repoPath
1094
+ };
1095
+ const path = cachePath(repoPath);
1096
+ debug("saveCachedCommit: saving to %s", path);
1097
+ await writeFile(path, JSON.stringify(data, null, 2), "utf8");
1098
+ }
1099
+ async function loadCachedCommit(repoPath) {
1100
+ const path = cachePath(repoPath);
1101
+ debug("loadCachedCommit: loading from %s", path);
1102
+ try {
1103
+ const raw = await readFile(path, "utf8");
1104
+ const data = JSON.parse(raw);
1105
+ debug("loadCachedCommit: found message from %s", new Date(data.timestamp).toISOString());
1106
+ return data;
1107
+ } catch {
1108
+ debug("loadCachedCommit: no cached commit found");
1109
+ return null;
1110
+ }
1111
+ }
1112
+ //#endregion
1113
+ //#region src/services/grouping.ts
1114
+ function mapGroqError(error) {
1115
+ if (error instanceof Groq.AuthenticationError) return /* @__PURE__ */ new Error("Invalid GROQ_API_KEY. Run: cmint config set GROQ_API_KEY=<key>");
1116
+ if (error instanceof Groq.RateLimitError) return /* @__PURE__ */ new Error("Rate limited by Groq. Please wait and try again.");
1117
+ if (error instanceof Groq.APIConnectionTimeoutError) return /* @__PURE__ */ new Error("Request timed out. Check your network and try again.");
1118
+ if (error instanceof Groq.APIError) return /* @__PURE__ */ new Error(`Groq API error: ${error.message}`);
1119
+ return /* @__PURE__ */ new Error(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`);
1120
+ }
1121
+ function matchesExcludePattern(filePath, pattern) {
1122
+ if (pattern === filePath) return true;
1123
+ if (pattern.endsWith("/**")) {
1124
+ const prefix = pattern.slice(0, -3);
1125
+ return filePath === prefix || filePath.startsWith(`${prefix}/`);
1126
+ }
1127
+ if (pattern.startsWith("*.")) {
1128
+ const suffix = pattern.slice(1);
1129
+ return filePath.endsWith(suffix);
1130
+ }
1131
+ return false;
1132
+ }
1133
+ function filterExcludedFiles(files) {
1134
+ const patterns = getDefaultExcludes();
1135
+ const included = [];
1136
+ const excluded = [];
1137
+ for (const file of files) if (patterns.some((pattern) => matchesExcludePattern(file.path, pattern))) excluded.push(file.path);
1138
+ else included.push(file);
1139
+ debug("filterExcludedFiles: %d included, %d excluded", included.length, excluded.length);
1140
+ return {
1141
+ included,
1142
+ excluded
1143
+ };
1144
+ }
1145
+ function statusIndicator(status) {
1146
+ switch (status) {
1147
+ case "M": return "modified";
1148
+ case "A": return "added";
1149
+ case "D": return "deleted";
1150
+ case "R": return "renamed";
1151
+ case "C": return "copied";
1152
+ case "?": return "untracked";
1153
+ default: return "changed";
1154
+ }
1155
+ }
1156
+ function buildFileSummary(files) {
1157
+ return files.map((f) => `${f.path} (${statusIndicator(f.status)})`).join("\n");
1158
+ }
1159
+ function buildGroupingSystemPrompt() {
1160
+ return [
1161
+ "You are analyzing changed files in a git repository. Group them into logical commits based on what changed and why. Each group should be a coherent unit of work.",
1162
+ "",
1163
+ "Rules:",
1164
+ "- Group by feature, fix, or concern (e.g., 'Frontend refactor', 'API changes', 'Test updates')",
1165
+ "- Keep related files together (e.g., a component + its test, a model + its migration)",
1166
+ "- Do not split a single logical change across multiple groups",
1167
+ "- If a file does not clearly belong to any group, include it anyway — do not omit files",
1168
+ "",
1169
+ "Output format: JSON array of objects with keys 'name', 'description', 'files'.",
1170
+ "name: short label (3-5 words)",
1171
+ "description: 1-2 sentences explaining what this group changes",
1172
+ "files: array of exact file paths from the input",
1173
+ "",
1174
+ "Output ONLY valid JSON. No markdown fences, no explanation."
1175
+ ].join("\n");
1176
+ }
1177
+ function buildGroupingUserPrompt(summary) {
1178
+ return [
1179
+ "Group the following changed files into logical commits:",
1180
+ "",
1181
+ summary
1182
+ ].join("\n");
1183
+ }
1184
+ function parseGroupingResponse(content) {
1185
+ const jsonText = content.replace(/^```json\s*/, "").replace(/\s*```$/, "").trim();
1186
+ const parsed = JSON.parse(jsonText);
1187
+ if (!Array.isArray(parsed)) throw new Error("AI response was not a JSON array");
1188
+ const rawGroups = [];
1189
+ for (const item of parsed) if (typeof item === "object" && item !== null && "name" in item && "description" in item && "files" in item && Array.isArray(item.files)) rawGroups.push({
1190
+ name: String(item.name),
1191
+ description: String(item.description),
1192
+ files: item.files.filter((f) => typeof f === "string")
1193
+ });
1194
+ return rawGroups;
1195
+ }
1196
+ async function generateGroups(files, apiKey, model, timeout) {
1197
+ debug("generateGroups: %d files, model=%s", files.length, model ?? "default");
1198
+ const { included, excluded } = filterExcludedFiles(files);
1199
+ if (included.length === 0) {
1200
+ debug("generateGroups: no files to group after exclusion");
1201
+ return {
1202
+ groups: [],
1203
+ excluded
1204
+ };
1205
+ }
1206
+ const summary = buildFileSummary(included);
1207
+ const systemPrompt = buildGroupingSystemPrompt();
1208
+ const userPrompt = buildGroupingUserPrompt(summary);
1209
+ debug("File summary:\n%s", summary);
1210
+ debug("User prompt length: %d chars", userPrompt.length);
1211
+ const client = new Groq({
1212
+ apiKey,
1213
+ timeout: timeout ?? 6e4
1214
+ });
1215
+ try {
1216
+ const completion = await client.chat.completions.create({
1217
+ messages: [{
1218
+ role: "system",
1219
+ content: systemPrompt
1220
+ }, {
1221
+ role: "user",
1222
+ content: userPrompt
1223
+ }],
1224
+ model: model ?? "openai/gpt-oss-20b",
1225
+ temperature: .3,
1226
+ max_tokens: 2048
1227
+ });
1228
+ const rawContent = completion.choices[0]?.message?.content;
1229
+ const content = typeof rawContent === "string" ? rawContent.trim() : "";
1230
+ debug("generateGroups response: choices=%d, finishReason=%s, contentLen=%d", completion.choices.length, completion.choices[0]?.finish_reason ?? "(none)", content.length);
1231
+ debug("generateGroups raw content: %s", content.slice(0, 500) || "(empty)");
1232
+ if (!content) throw new Error("AI returned an empty grouping response");
1233
+ const rawGroups = parseGroupingResponse(content);
1234
+ debug("generateGroups: parsed %d raw groups", rawGroups.length);
1235
+ const validated = validateGroups(rawGroups, included);
1236
+ debug("generateGroups: %d validated groups", validated.length);
1237
+ return {
1238
+ groups: validated,
1239
+ excluded
1240
+ };
1241
+ } catch (error) {
1242
+ debug("generateGroups error: %s", error instanceof Error ? error.message : String(error));
1243
+ throw mapGroqError(error);
1244
+ }
1245
+ }
1246
+ function validateGroups(groups, allFiles) {
1247
+ const seen = /* @__PURE__ */ new Set();
1248
+ const validated = [];
1249
+ for (const group of groups) {
1250
+ const uniqueFiles = group.files.filter((f) => {
1251
+ if (seen.has(f)) return false;
1252
+ seen.add(f);
1253
+ return true;
1254
+ });
1255
+ if (uniqueFiles.length > 0) validated.push({
1256
+ name: group.name,
1257
+ description: group.description,
1258
+ files: uniqueFiles
1259
+ });
1260
+ }
1261
+ const ungrouped = allFiles.filter((f) => !seen.has(f.path));
1262
+ if (ungrouped.length > 0) {
1263
+ debug("validateGroups: %d ungrouped files added to 'Other changes'", ungrouped.length);
1264
+ validated.push({
1265
+ name: "Other changes",
1266
+ description: "Miscellaneous changes that did not fit into other groups",
1267
+ files: ungrouped.map((f) => f.path)
1268
+ });
1269
+ }
1270
+ return validated;
1271
+ }
1272
+ //#endregion
1273
+ //#region src/ui/grouping.ts
1274
+ async function showGroupingConfirmation(groups, excluded) {
1275
+ debug("showGroupingConfirmation: %d groups, %d excluded", groups.length, excluded.length);
1276
+ const lines = [];
1277
+ for (const group of groups) {
1278
+ lines.push(bold(group.name));
1279
+ lines.push(` ${dim(group.description)}`);
1280
+ lines.push(` ${green(String(group.files.length))} file${group.files.length !== 1 ? "s" : ""}`);
1281
+ for (const file of group.files) lines.push(` ${dim("•")} ${file}`);
1282
+ lines.push("");
1283
+ }
1284
+ if (excluded.length > 0) {
1285
+ lines.push(dim(`Excluded: ${excluded.length} file${excluded.length !== 1 ? "s" : ""}`));
1286
+ for (const file of excluded) lines.push(` ${dim("•")} ${dim(file)}`);
1287
+ }
1288
+ p.note(lines.join("\n"), "Proposed commit groups");
1289
+ const choice = await p.select({
1290
+ message: "Proceed with these groupings?",
1291
+ options: [{
1292
+ label: "Yes, commit all groups",
1293
+ value: "yes"
1294
+ }, {
1295
+ label: "No, cancel",
1296
+ value: "no"
1297
+ }]
1298
+ });
1299
+ if (p.isCancel(choice) || choice === "no") {
1300
+ debug("showGroupingConfirmation: user cancelled");
1301
+ return false;
1302
+ }
1303
+ debug("showGroupingConfirmation: user confirmed");
1304
+ return true;
1305
+ }
1306
+ function showGroupProgress(current, total, groupName) {
1307
+ p.log.info(`Commit group ${current} of ${total}: ${cyan(`"${groupName}"`)}`);
1308
+ }
1309
+ //#endregion
1310
+ //#region src/commands/auto-group.ts
1311
+ async function runAutoGroupFlow(changedFiles, flags) {
1312
+ const { included, excluded } = filterExcludedFiles(changedFiles);
1313
+ try {
1314
+ await getApiKey();
1315
+ debug("API key found");
1316
+ } catch {
1317
+ debug("No API key found, prompting user");
1318
+ const { text: promptText } = await import("@clack/prompts");
1319
+ const key = await promptText({
1320
+ message: "Enter your Groq API key:",
1321
+ placeholder: "gsk_...",
1322
+ validate: (v) => v.trim() ? void 0 : "API key is required"
1323
+ });
1324
+ if (isCancel(key)) {
1325
+ outro(dim("Cancelled."));
1326
+ return "cancelled";
1327
+ }
1328
+ await setConfigValue("GROQ_API_KEY", String(key).trim());
1329
+ debug("API key saved to config");
1330
+ }
1331
+ const s = spinner();
1332
+ s.start("Analyzing files...");
1333
+ const config = await readConfig();
1334
+ const validatedGroups = validateGroups((await generateGroups(included, await getApiKey(), config.model, config.timeout ? parseInt(config.timeout, 10) : void 0)).groups, included);
1335
+ s.stop("Files analyzed");
1336
+ if (!await showGroupingConfirmation(validatedGroups, excluded)) {
1337
+ outro(dim("Cancelled."));
1338
+ return "cancelled";
1339
+ }
1340
+ for (let i = 0; i < validatedGroups.length; i++) {
1341
+ const group = validatedGroups[i];
1342
+ showGroupProgress(i + 1, validatedGroups.length, group.name);
1343
+ await stageFiles(group.files);
1344
+ const diffResult = await getStagedDiff();
1345
+ if (!diffResult || "excludedFiles" in diffResult) {
1346
+ log.warn(red(`No changes found for group "${group.name}" — skipping.`));
1347
+ continue;
1348
+ }
1349
+ s.start("Generating commit message...");
1350
+ let message;
1351
+ try {
1352
+ message = await generateMessage(diffResult.diff, flags.hint);
1353
+ } catch (err) {
1354
+ s.stop(red("Failed to generate message."));
1355
+ outro(red(err instanceof Error ? err.message : String(err)));
1356
+ return "cancelled";
1357
+ }
1358
+ s.stop("Message generated");
1359
+ const reviewed = await reviewCommitMessage(message);
1360
+ if (reviewed === null) {
1361
+ outro(dim("Cancelled."));
1362
+ return "cancelled";
1363
+ }
1364
+ message = reviewed;
1365
+ const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
1366
+ await saveCachedCommit(await getRepoRoot(), message);
1367
+ s.start("Committing...");
1368
+ const headBefore = await getHead();
1369
+ const commitResult = await attemptCommit(message);
1370
+ const headAfter = await getHead();
1371
+ if (commitResult.ok || headBefore !== headAfter) {
1372
+ s.stop("Committed successfully.");
1373
+ const checks = parseToolChecks(commitResult.stderr ?? "");
1374
+ if (checks.length > 0) {
1375
+ const lines = checks.map((c) => ` ${c.ok ? green("✓") : red("✗")} ${c.tool}`);
1376
+ log.info(lines.join("\n"));
1377
+ }
1378
+ continue;
1379
+ }
1380
+ s.stop("Commit failed.");
1381
+ const recoveryResult = await showRecoveryMenu(parseHookErrors(commitResult.stderr ?? ""), async () => (await attemptCommit(message)).ok, async (msg) => (await attemptCommitNoVerify(msg)).ok, async () => {
1382
+ await stageFiles(group.files);
1383
+ return (await attemptCommit(message)).ok;
1384
+ }, message, commitResult.stderr ?? "");
1385
+ if (recoveryResult === "committed") {
1386
+ if (i < validatedGroups.length - 1) continue;
1387
+ return "committed";
1388
+ }
1389
+ return recoveryResult;
1390
+ }
1391
+ outro(green("All groups committed."));
1392
+ return "committed";
1393
+ }
1394
+ async function generateMessage(diff, hint) {
1395
+ const config = await readConfig();
1396
+ const apiKey = await getApiKey();
1397
+ debug("Generating message with model:", config.model, "type:", config.type);
1398
+ return generateCommitMessage(diff, {
1399
+ apiKey,
1400
+ model: config.model,
1401
+ type: config.type,
1402
+ timeout: config.timeout ? parseInt(config.timeout, 10) : void 0,
1403
+ hint
1404
+ });
1405
+ }
1406
+ function buildExcludedFilesMessage(files) {
1407
+ const excludes = getDefaultExcludes();
1408
+ const isLockfile = (f) => excludes.some((pattern) => {
1409
+ if (pattern.endsWith(".lock") || pattern.endsWith(".json")) return f === pattern || f.endsWith(pattern.replace("*.", "."));
1410
+ return false;
1411
+ });
1412
+ if (files.every(isLockfile)) return "chore: update lockfile";
1413
+ return "chore: update generated files";
1414
+ }
1415
+ //#endregion
1416
+ //#region src/commands/commit.ts
1417
+ async function commitCommand(flags) {
1418
+ debug("commitCommand called", { flags });
1419
+ await assertGitRepo();
1420
+ if (flags.retry) {
1421
+ debug("Entering retry mode");
1422
+ const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
1423
+ const repoRoot = await getRepoRoot();
1424
+ debug("Repo root:", repoRoot);
1425
+ const cached = await loadCachedCommit(repoRoot);
1426
+ if (!cached) {
1427
+ debug("No cached commit found");
1428
+ outro(red("No cached commit message found. Run cmint without --retry first."));
1429
+ process.exit(1);
1430
+ }
1431
+ debug("Loaded cached message:", cached.message);
1432
+ intro("commit-mint — retry");
1433
+ const s = spinner();
1434
+ s.start("Retrying commit...");
1435
+ const result = await attemptCommit(cached.message);
1436
+ s.stop("Attempted commit");
1437
+ debug("Retry commit result:", result);
1438
+ if (result.ok) {
1439
+ const checks = parseToolChecks(result.stderr ?? "");
1440
+ if (checks.length > 0) {
1441
+ const lines = checks.map((c) => ` ${c.ok ? green("✓") : red("✗")} ${c.tool}`);
1442
+ log.info(lines.join("\n"));
1443
+ }
1444
+ outro(green("Committed successfully."));
1445
+ } else {
1446
+ const errors = parseHookErrors(result.stderr ?? "");
1447
+ debug("Hook errors on retry:", errors.length);
1448
+ if (await showRecoveryMenu(errors, async () => (await attemptCommit(cached.message)).ok, async (msg) => (await attemptCommitNoVerify(msg)).ok, async () => {
1449
+ await stageAll();
1450
+ return (await attemptCommit(cached.message)).ok;
1451
+ }, cached.message, result.stderr ?? "") === "cancelled") process.exit(1);
1452
+ return;
1453
+ }
839
1454
  }
840
1455
  intro("commit-mint");
841
1456
  const status = await getStatusShort();
@@ -858,6 +1473,14 @@ async function commitCommand(flags) {
858
1473
  s.stop("File staged");
859
1474
  } else {
860
1475
  const stagingResult = await showStagingMenu(changedFiles);
1476
+ if (stagingResult === "autogroup") {
1477
+ if (flags.message) {
1478
+ outro(red("--message flag is not compatible with auto-group mode."));
1479
+ return;
1480
+ }
1481
+ if (await runAutoGroupFlow(changedFiles, flags) !== "committed") process.exit(1);
1482
+ return;
1483
+ }
861
1484
  if (!stagingResult) {
862
1485
  outro(dim("Cancelled."));
863
1486
  return;
@@ -896,10 +1519,10 @@ async function commitCommand(flags) {
896
1519
  return;
897
1520
  }
898
1521
  s.stop("Commit failed.");
899
- await showRecoveryMenu(parseHookErrors(result.stderr ?? ""), async () => (await attemptCommit(message)).ok, async (msg) => (await attemptCommitNoVerify(msg)).ok, async () => {
1522
+ if (await showRecoveryMenu(parseHookErrors(result.stderr ?? ""), async () => (await attemptCommit(message)).ok, async (msg) => (await attemptCommitNoVerify(msg)).ok, async () => {
900
1523
  await stageAll();
901
1524
  return (await attemptCommit(message)).ok;
902
- }, message, result.stderr ?? "");
1525
+ }, message, result.stderr ?? "") === "cancelled") process.exit(1);
903
1526
  return;
904
1527
  }
905
1528
  debug("Staged files:", diffResult.files);
@@ -942,43 +1565,12 @@ async function commitCommand(flags) {
942
1565
  }
943
1566
  s.stop("Message generated");
944
1567
  }
945
- const { select, text } = await import("@clack/prompts");
946
- const review = await select({
947
- message: `Review commit message:\n\n ${bold(message)}\n`,
948
- options: [
949
- {
950
- label: "Use as-is",
951
- value: "use"
952
- },
953
- {
954
- label: "Edit",
955
- value: "edit"
956
- },
957
- {
958
- label: "Cancel",
959
- value: "cancel"
960
- }
961
- ]
962
- });
963
- if (isCancel(review) || review === "cancel") {
964
- debug("User cancelled at review step");
1568
+ const reviewed = await reviewCommitMessage(message);
1569
+ if (reviewed === null) {
965
1570
  outro(dim("Cancelled."));
966
1571
  return;
967
1572
  }
968
- if (review === "edit") {
969
- debug("User chose to edit message");
970
- const edited = await text({
971
- message: "Edit commit message:",
972
- initialValue: message,
973
- validate: (v) => v.trim() ? void 0 : "Message cannot be empty"
974
- });
975
- if (isCancel(edited)) {
976
- outro(dim("Cancelled."));
977
- return;
978
- }
979
- message = String(edited).trim();
980
- debug("Edited message:", message);
981
- }
1573
+ message = reviewed;
982
1574
  const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
983
1575
  const repoRoot = await getRepoRoot();
984
1576
  await saveCachedCommit(repoRoot, message);
@@ -1004,35 +1596,14 @@ async function commitCommand(flags) {
1004
1596
  debug("Commit failed, showing recovery menu");
1005
1597
  const errors = parseHookErrors(result.stderr ?? "");
1006
1598
  debug("Parsed hook errors:", errors.length, "errors");
1007
- await showRecoveryMenu(errors, async () => {
1599
+ if (await showRecoveryMenu(errors, async () => {
1008
1600
  return (await attemptCommit(message)).ok;
1009
1601
  }, async (msg) => {
1010
1602
  return (await attemptCommitNoVerify(msg)).ok;
1011
1603
  }, async () => {
1012
1604
  await stageAll();
1013
1605
  return (await attemptCommit(message)).ok;
1014
- }, message, result.stderr ?? "");
1015
- }
1016
- async function generateMessage(diff, hint) {
1017
- const config = await readConfig();
1018
- const apiKey = await getApiKey();
1019
- debug("Generating message with model:", config.model, "type:", config.type);
1020
- return generateCommitMessage(diff, {
1021
- apiKey,
1022
- model: config.model,
1023
- type: config.type,
1024
- timeout: config.timeout ? parseInt(config.timeout, 10) : void 0,
1025
- hint
1026
- });
1027
- }
1028
- function buildExcludedFilesMessage(files) {
1029
- const excludes = getDefaultExcludes();
1030
- const isLockfile = (f) => excludes.some((pattern) => {
1031
- if (pattern.endsWith(".lock") || pattern.endsWith(".json")) return f === pattern || f.endsWith(pattern.replace("*.", "."));
1032
- return false;
1033
- });
1034
- if (files.every(isLockfile)) return "chore: update lockfile";
1035
- return "chore: update generated files";
1606
+ }, message, result.stderr ?? "") === "cancelled") process.exit(1);
1036
1607
  }
1037
1608
  //#endregion
1038
1609
  //#region src/commands/config.ts
@@ -1090,6 +1661,12 @@ cli({
1090
1661
  description: "Add context hint for AI commit message generation",
1091
1662
  alias: "H"
1092
1663
  },
1664
+ review: {
1665
+ type: Boolean,
1666
+ description: "Review staged changes with a coding model",
1667
+ alias: "R",
1668
+ default: false
1669
+ },
1093
1670
  debug: {
1094
1671
  type: Boolean,
1095
1672
  description: "Enable debug output",
@@ -1100,7 +1677,8 @@ cli({
1100
1677
  commands: [configCommand]
1101
1678
  }, (argv) => {
1102
1679
  setDebug(argv.flags.debug);
1103
- commitCommand(argv.flags);
1680
+ if (argv.flags.review) reviewCommand();
1681
+ else commitCommand(argv.flags);
1104
1682
  });
1105
1683
  //#endregion
1106
1684
  export {};