@kyubiware/commit-mint 0.2.0 → 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/README.md +87 -28
- package/dist/cli.mjs +927 -315
- package/dist/cli.mjs.map +1 -1
- package/package.json +1 -1
package/dist/cli.mjs
CHANGED
|
@@ -1,14 +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";
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import Groq from "groq-sdk";
|
|
12
13
|
import { createHash } from "node:crypto";
|
|
13
14
|
//#region \0rolldown/runtime.js
|
|
14
15
|
var __defProp = Object.defineProperty;
|
|
@@ -25,7 +26,7 @@ var __exportAll = (all, no_symbols) => {
|
|
|
25
26
|
//#region package.json
|
|
26
27
|
var package_default = {
|
|
27
28
|
name: "@kyubiware/commit-mint",
|
|
28
|
-
version: "0.
|
|
29
|
+
version: "0.3.0",
|
|
29
30
|
description: "A commit tool that actually handles hook failures",
|
|
30
31
|
type: "module",
|
|
31
32
|
bin: { "cmint": "./dist/cli.mjs" },
|
|
@@ -96,171 +97,6 @@ function debug(...args) {
|
|
|
96
97
|
console.error(dim(`[debug ${timestamp}]`), ...args);
|
|
97
98
|
}
|
|
98
99
|
//#endregion
|
|
99
|
-
//#region src/services/ai.ts
|
|
100
|
-
const MAX_DIFF_CHARS = 2e4;
|
|
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
|
-
}
|
|
114
|
-
function compressDiff(diff) {
|
|
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) => {
|
|
127
|
-
const match = f.match(/^diff --git a\/(.+) b\/(.+)$/);
|
|
128
|
-
return match && match[1] === match[2] ? `${match[1]} | changed` : "";
|
|
129
|
-
}).filter(Boolean).join("\n")}`;
|
|
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
|
-
}
|
|
161
|
-
function buildSystemPrompt(type) {
|
|
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.";
|
|
163
|
-
if (type && type.trim().length > 0) prompt += `\nYou MUST use type: ${type}`;
|
|
164
|
-
return prompt;
|
|
165
|
-
}
|
|
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");
|
|
172
|
-
}
|
|
173
|
-
function isValidConventionalCommit(message) {
|
|
174
|
-
return CONVENTIONAL_COMMIT_REGEX.test(message);
|
|
175
|
-
}
|
|
176
|
-
function extractContentText(content) {
|
|
177
|
-
if (content == null) return "";
|
|
178
|
-
if (typeof content === "string") return content.trim();
|
|
179
|
-
if (Array.isArray(content)) return content.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => stripThinkTags(part.text)).join("").trim();
|
|
180
|
-
return "";
|
|
181
|
-
}
|
|
182
|
-
async function generateCommitMessage(diff, options) {
|
|
183
|
-
debug("generateCommitMessage: model=%s, type=%s, hint=%s", options.model ?? "default", options.type ?? "none", options.hint ?? "none");
|
|
184
|
-
const timeoutMs = options.timeout ?? 6e4;
|
|
185
|
-
debug("Timeout: %d ms", timeoutMs);
|
|
186
|
-
const client = new Groq({
|
|
187
|
-
apiKey: options.apiKey,
|
|
188
|
-
timeout: timeoutMs
|
|
189
|
-
});
|
|
190
|
-
const compressedDiff = compressDiff(diff);
|
|
191
|
-
const statSummary = buildStatSummary(diff);
|
|
192
|
-
const systemPrompt = buildSystemPrompt(options.type);
|
|
193
|
-
const userPrompt = buildUserPrompt(compressedDiff, options.hint, statSummary);
|
|
194
|
-
debug("Diff: %d chars → compressed to %d chars", diff.length, compressedDiff.length);
|
|
195
|
-
debug("Stat summary:\n%s", statSummary);
|
|
196
|
-
debug("User prompt length: %d chars", userPrompt.length);
|
|
197
|
-
async function callAI(strictSystemPrompt) {
|
|
198
|
-
const callStart = Date.now();
|
|
199
|
-
debug("callAI: %s — model=%s, promptLen=%d, systemLen=%d", !!strictSystemPrompt ? "RETRY (strict)" : "INITIAL", options.model ?? "openai/gpt-oss-20b", userPrompt.length, (strictSystemPrompt ?? systemPrompt).length);
|
|
200
|
-
try {
|
|
201
|
-
const isReasoningModel = /^(o[1-9]|.*gpt-oss.*|.*gpt-5.*)/i.test(options.model ?? "");
|
|
202
|
-
const completion = await client.chat.completions.create({
|
|
203
|
-
messages: [{
|
|
204
|
-
role: "system",
|
|
205
|
-
content: strictSystemPrompt ?? systemPrompt
|
|
206
|
-
}, {
|
|
207
|
-
role: "user",
|
|
208
|
-
content: userPrompt
|
|
209
|
-
}],
|
|
210
|
-
model: options.model ?? "openai/gpt-oss-20b",
|
|
211
|
-
temperature: .3,
|
|
212
|
-
...isReasoningModel ? { max_completion_tokens: 1024 } : { max_tokens: 1024 },
|
|
213
|
-
reasoning_format: "parsed"
|
|
214
|
-
});
|
|
215
|
-
const elapsed = Date.now() - callStart;
|
|
216
|
-
const rawContent = completion.choices[0]?.message?.content;
|
|
217
|
-
const content = extractContentText(typeof rawContent === "string" ? stripThinkTags(rawContent) : rawContent);
|
|
218
|
-
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);
|
|
219
|
-
debug("callAI raw content: %s", content.slice(0, 300) || "(empty)");
|
|
220
|
-
if (!content) {
|
|
221
|
-
const reasoning = completion.choices[0]?.message?.reasoning;
|
|
222
|
-
debug("callAI: content empty, attempting reasoning fallback (reasoningLen=%d)", reasoning?.length ?? 0);
|
|
223
|
-
if (reasoning) {
|
|
224
|
-
const derived = deriveMessageFromReasoning(reasoning);
|
|
225
|
-
if (derived) {
|
|
226
|
-
debug("callAI: derived message from reasoning: %s", derived.slice(0, 100));
|
|
227
|
-
return stripThinkTags(derived);
|
|
228
|
-
}
|
|
229
|
-
debug("callAI: could not derive message from reasoning");
|
|
230
|
-
}
|
|
231
|
-
throw new Error("AI returned an empty commit message");
|
|
232
|
-
}
|
|
233
|
-
return content;
|
|
234
|
-
} catch (error) {
|
|
235
|
-
debug("callAI FAILED after %d ms: %s", Date.now() - callStart, error instanceof Error ? `${error.name}: ${error.message}` : String(error));
|
|
236
|
-
throw error;
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
try {
|
|
240
|
-
const totalStart = Date.now();
|
|
241
|
-
let message = await callAI();
|
|
242
|
-
debug("Validation: message=%s, isValid=%s", message.slice(0, 100), isValidConventionalCommit(message));
|
|
243
|
-
if (!isValidConventionalCommit(message)) {
|
|
244
|
-
debug("Initial message failed conventional commit validation, retrying with strict prompt (elapsed: %d ms)", Date.now() - totalStart);
|
|
245
|
-
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.");
|
|
246
|
-
debug("Retry validation: message=%s, isValid=%s", retryMessage.slice(0, 100), isValidConventionalCommit(retryMessage));
|
|
247
|
-
if (isValidConventionalCommit(retryMessage)) {
|
|
248
|
-
debug("Retry produced valid conventional commit");
|
|
249
|
-
message = retryMessage;
|
|
250
|
-
} else debug("Retry also failed validation, using original message");
|
|
251
|
-
}
|
|
252
|
-
debug("Final message (%d ms total): %s", Date.now() - totalStart, message);
|
|
253
|
-
return message;
|
|
254
|
-
} catch (error) {
|
|
255
|
-
debug("AI error: %s", error instanceof Error ? error.message : String(error));
|
|
256
|
-
if (error instanceof Groq.AuthenticationError) throw new Error("Invalid GROQ_API_KEY. Run: cmint config set GROQ_API_KEY=<key>");
|
|
257
|
-
if (error instanceof Groq.RateLimitError) throw new Error("Rate limited by Groq. Please wait and try again.");
|
|
258
|
-
if (error instanceof Groq.APIConnectionTimeoutError) throw new Error("Request timed out. Check your network or try a smaller diff.");
|
|
259
|
-
if (error instanceof Groq.APIError) throw new Error(`Groq API error: ${error.message}`);
|
|
260
|
-
throw new Error(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`);
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
//#endregion
|
|
264
100
|
//#region src/services/config.ts
|
|
265
101
|
const CONFIG_PATH = join(os.homedir(), ".commit-mint");
|
|
266
102
|
const defaults = {
|
|
@@ -406,10 +242,14 @@ async function getStatusShort() {
|
|
|
406
242
|
async function getChangedFiles() {
|
|
407
243
|
const { stdout } = await execa("git", ["status", "--short"]);
|
|
408
244
|
if (!stdout.trim()) return [];
|
|
409
|
-
const files = stdout.split("\n").filter(Boolean).map((line) =>
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
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
|
+
});
|
|
413
253
|
debug("getChangedFiles:", files.length, "files");
|
|
414
254
|
return files;
|
|
415
255
|
}
|
|
@@ -596,8 +436,33 @@ async function copyToClipboard(content) {
|
|
|
596
436
|
["xsel", ["--clipboard", "--input"]],
|
|
597
437
|
["pbcopy", []]
|
|
598
438
|
]) try {
|
|
599
|
-
await
|
|
600
|
-
|
|
439
|
+
if (await new Promise((resolve) => {
|
|
440
|
+
const child = spawn(cmd, args, { stdio: [
|
|
441
|
+
"pipe",
|
|
442
|
+
"ignore",
|
|
443
|
+
"ignore"
|
|
444
|
+
] });
|
|
445
|
+
let settled = false;
|
|
446
|
+
const done = (result) => {
|
|
447
|
+
if (settled) return;
|
|
448
|
+
settled = true;
|
|
449
|
+
resolve(result);
|
|
450
|
+
};
|
|
451
|
+
child.on("error", () => done(false));
|
|
452
|
+
child.on("exit", (code) => {
|
|
453
|
+
if (code !== 0) done(false);
|
|
454
|
+
});
|
|
455
|
+
child.stdin.write(content, (err) => {
|
|
456
|
+
if (err) {
|
|
457
|
+
done(false);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
child.stdin.end(() => {
|
|
461
|
+
child.unref();
|
|
462
|
+
done(true);
|
|
463
|
+
});
|
|
464
|
+
});
|
|
465
|
+
})) return true;
|
|
601
466
|
} catch {}
|
|
602
467
|
return false;
|
|
603
468
|
}
|
|
@@ -614,9 +479,27 @@ async function showStagingMenu(files) {
|
|
|
614
479
|
default: return dim(status);
|
|
615
480
|
}
|
|
616
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" : ""}`);
|
|
617
495
|
const choice = await p.select({
|
|
618
496
|
message: "Stage files for commit:",
|
|
619
497
|
options: [
|
|
498
|
+
{
|
|
499
|
+
label: "Auto-group into commits",
|
|
500
|
+
value: "autogroup",
|
|
501
|
+
hint: "LLM groups files into logical commits"
|
|
502
|
+
},
|
|
620
503
|
{
|
|
621
504
|
label: "Stage all files",
|
|
622
505
|
value: "all",
|
|
@@ -633,13 +516,14 @@ async function showStagingMenu(files) {
|
|
|
633
516
|
]
|
|
634
517
|
});
|
|
635
518
|
if (p.isCancel(choice) || choice === "cancel") return null;
|
|
519
|
+
if (choice === "autogroup") return "autogroup";
|
|
636
520
|
if (choice === "all") return {
|
|
637
521
|
files: files.map((f) => f.path),
|
|
638
522
|
all: true
|
|
639
523
|
};
|
|
640
524
|
const selected = await p.multiselect({
|
|
641
525
|
message: "Select files to stage:",
|
|
642
|
-
options:
|
|
526
|
+
options: sorted.map((f) => ({
|
|
643
527
|
label: `${statusLabel(f.status)} ${f.path}`,
|
|
644
528
|
value: f.path
|
|
645
529
|
})),
|
|
@@ -653,15 +537,20 @@ async function showStagingMenu(files) {
|
|
|
653
537
|
}
|
|
654
538
|
async function showRecoveryMenu(errors, onRetry, onSkipHooks, onRestage, message, rawStderr) {
|
|
655
539
|
debug("showRecoveryMenu: %d errors", errors.length);
|
|
540
|
+
let clipboardCopied = false;
|
|
541
|
+
let showNote = true;
|
|
656
542
|
while (true) {
|
|
657
|
-
|
|
543
|
+
if (showNote) {
|
|
544
|
+
p.note(errors.map((e) => ` ${red("•")} [${e.tool}] ${e.message}`).join("\n"), red(bold("Pre-commit hook failed")));
|
|
545
|
+
showNote = false;
|
|
546
|
+
}
|
|
658
547
|
const choice = await p.select({
|
|
659
548
|
message: "What do you want to do?",
|
|
660
549
|
options: [
|
|
661
550
|
{
|
|
662
|
-
label: "Copy error report to clipboard",
|
|
551
|
+
label: clipboardCopied ? `${green("✓")} Copy error report to clipboard` : "Copy error report to clipboard",
|
|
663
552
|
value: "clipboard",
|
|
664
|
-
hint: "Paste into another terminal for an AI agent"
|
|
553
|
+
hint: clipboardCopied ? "Copied!" : "Paste into another terminal for an AI agent"
|
|
665
554
|
},
|
|
666
555
|
{
|
|
667
556
|
label: "Skip hooks and commit (--no-verify)",
|
|
@@ -687,26 +576,32 @@ async function showRecoveryMenu(errors, onRetry, onSkipHooks, onRestage, message
|
|
|
687
576
|
if (p.isCancel(choice)) {
|
|
688
577
|
debug("showRecoveryMenu: user cancelled");
|
|
689
578
|
p.outro(yellow("Cancelled. Message cached for --retry."));
|
|
690
|
-
|
|
691
|
-
return;
|
|
579
|
+
return "cancelled";
|
|
692
580
|
}
|
|
693
581
|
debug("showRecoveryMenu: user chose %s", choice);
|
|
694
582
|
switch (choice) {
|
|
695
583
|
case "clipboard":
|
|
696
|
-
if (await copyToClipboard(rawStderr))
|
|
697
|
-
|
|
584
|
+
if (await copyToClipboard(rawStderr)) {
|
|
585
|
+
clipboardCopied = true;
|
|
586
|
+
p.log.step(green("Copied to clipboard."));
|
|
587
|
+
} else p.log.warn(red("No clipboard tool found. Install xclip, wl-copy, or xsel."));
|
|
698
588
|
continue;
|
|
699
589
|
case "skip":
|
|
700
590
|
p.log.info(yellow("Committing with --no-verify..."));
|
|
701
|
-
if (await onSkipHooks(message))
|
|
702
|
-
|
|
703
|
-
|
|
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
|
+
}
|
|
704
598
|
case "restage":
|
|
705
599
|
p.log.info(cyan("Re-staging and retrying..."));
|
|
706
600
|
if (await onRestage()) {
|
|
707
601
|
p.outro(green("Committed successfully."));
|
|
708
|
-
return;
|
|
602
|
+
return "committed";
|
|
709
603
|
}
|
|
604
|
+
showNote = true;
|
|
710
605
|
continue;
|
|
711
606
|
case "edit": {
|
|
712
607
|
const edited = await p.text({
|
|
@@ -716,92 +611,846 @@ async function showRecoveryMenu(errors, onRetry, onSkipHooks, onRestage, message
|
|
|
716
611
|
});
|
|
717
612
|
if (p.isCancel(edited)) {
|
|
718
613
|
p.outro(yellow("Cancelled. Message cached for --retry."));
|
|
719
|
-
|
|
720
|
-
|
|
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";
|
|
721
622
|
}
|
|
722
|
-
if (await onRetry()) p.outro(green("Committed successfully."));
|
|
723
|
-
else p.outro(red("Commit failed again."));
|
|
724
|
-
return;
|
|
725
623
|
}
|
|
726
624
|
case "cancel":
|
|
727
625
|
p.outro(dim("Message cached for --retry."));
|
|
728
|
-
|
|
729
|
-
return;
|
|
626
|
+
return "cancelled";
|
|
730
627
|
}
|
|
731
628
|
}
|
|
732
629
|
}
|
|
733
630
|
//#endregion
|
|
734
|
-
//#region src/
|
|
735
|
-
const
|
|
736
|
-
function
|
|
737
|
-
|
|
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)}`);
|
|
738
639
|
}
|
|
739
|
-
|
|
740
|
-
|
|
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();
|
|
741
643
|
}
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
repoPath
|
|
748
|
-
};
|
|
749
|
-
const path = cachePath(repoPath);
|
|
750
|
-
debug("saveCachedCommit: saving to %s", path);
|
|
751
|
-
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;
|
|
752
649
|
}
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
debug("loadCachedCommit: loading from %s", path);
|
|
756
|
-
try {
|
|
757
|
-
const raw = await readFile(path, "utf8");
|
|
758
|
-
const data = JSON.parse(raw);
|
|
759
|
-
debug("loadCachedCommit: found message from %s", new Date(data.timestamp).toISOString());
|
|
760
|
-
return data;
|
|
761
|
-
} catch {
|
|
762
|
-
debug("loadCachedCommit: no cached commit found");
|
|
763
|
-
return null;
|
|
764
|
-
}
|
|
650
|
+
function stripContextLines(diff) {
|
|
651
|
+
return diff.split("\n").filter((line) => !line.startsWith(" ")).join("\n");
|
|
765
652
|
}
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
if (
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
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 {
|
|
797
1446
|
const errors = parseHookErrors(result.stderr ?? "");
|
|
798
1447
|
debug("Hook errors on retry:", errors.length);
|
|
799
|
-
await showRecoveryMenu(errors, async () => (await attemptCommit(cached.message)).ok, async (msg) => (await attemptCommitNoVerify(msg)).ok, async () => {
|
|
1448
|
+
if (await showRecoveryMenu(errors, async () => (await attemptCommit(cached.message)).ok, async (msg) => (await attemptCommitNoVerify(msg)).ok, async () => {
|
|
800
1449
|
await stageAll();
|
|
801
1450
|
return (await attemptCommit(cached.message)).ok;
|
|
802
|
-
}, cached.message, result.stderr ?? "");
|
|
1451
|
+
}, cached.message, result.stderr ?? "") === "cancelled") process.exit(1);
|
|
1452
|
+
return;
|
|
803
1453
|
}
|
|
804
|
-
return;
|
|
805
1454
|
}
|
|
806
1455
|
intro("commit-mint");
|
|
807
1456
|
const status = await getStatusShort();
|
|
@@ -824,6 +1473,14 @@ async function commitCommand(flags) {
|
|
|
824
1473
|
s.stop("File staged");
|
|
825
1474
|
} else {
|
|
826
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
|
+
}
|
|
827
1484
|
if (!stagingResult) {
|
|
828
1485
|
outro(dim("Cancelled."));
|
|
829
1486
|
return;
|
|
@@ -862,10 +1519,10 @@ async function commitCommand(flags) {
|
|
|
862
1519
|
return;
|
|
863
1520
|
}
|
|
864
1521
|
s.stop("Commit failed.");
|
|
865
|
-
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 () => {
|
|
866
1523
|
await stageAll();
|
|
867
1524
|
return (await attemptCommit(message)).ok;
|
|
868
|
-
}, message, result.stderr ?? "");
|
|
1525
|
+
}, message, result.stderr ?? "") === "cancelled") process.exit(1);
|
|
869
1526
|
return;
|
|
870
1527
|
}
|
|
871
1528
|
debug("Staged files:", diffResult.files);
|
|
@@ -908,43 +1565,12 @@ async function commitCommand(flags) {
|
|
|
908
1565
|
}
|
|
909
1566
|
s.stop("Message generated");
|
|
910
1567
|
}
|
|
911
|
-
const
|
|
912
|
-
|
|
913
|
-
message: `Review commit message:\n\n ${bold(message)}\n`,
|
|
914
|
-
options: [
|
|
915
|
-
{
|
|
916
|
-
label: "Use as-is",
|
|
917
|
-
value: "use"
|
|
918
|
-
},
|
|
919
|
-
{
|
|
920
|
-
label: "Edit",
|
|
921
|
-
value: "edit"
|
|
922
|
-
},
|
|
923
|
-
{
|
|
924
|
-
label: "Cancel",
|
|
925
|
-
value: "cancel"
|
|
926
|
-
}
|
|
927
|
-
]
|
|
928
|
-
});
|
|
929
|
-
if (isCancel(review) || review === "cancel") {
|
|
930
|
-
debug("User cancelled at review step");
|
|
1568
|
+
const reviewed = await reviewCommitMessage(message);
|
|
1569
|
+
if (reviewed === null) {
|
|
931
1570
|
outro(dim("Cancelled."));
|
|
932
1571
|
return;
|
|
933
1572
|
}
|
|
934
|
-
|
|
935
|
-
debug("User chose to edit message");
|
|
936
|
-
const edited = await text({
|
|
937
|
-
message: "Edit commit message:",
|
|
938
|
-
initialValue: message,
|
|
939
|
-
validate: (v) => v.trim() ? void 0 : "Message cannot be empty"
|
|
940
|
-
});
|
|
941
|
-
if (isCancel(edited)) {
|
|
942
|
-
outro(dim("Cancelled."));
|
|
943
|
-
return;
|
|
944
|
-
}
|
|
945
|
-
message = String(edited).trim();
|
|
946
|
-
debug("Edited message:", message);
|
|
947
|
-
}
|
|
1573
|
+
message = reviewed;
|
|
948
1574
|
const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
|
|
949
1575
|
const repoRoot = await getRepoRoot();
|
|
950
1576
|
await saveCachedCommit(repoRoot, message);
|
|
@@ -970,35 +1596,14 @@ async function commitCommand(flags) {
|
|
|
970
1596
|
debug("Commit failed, showing recovery menu");
|
|
971
1597
|
const errors = parseHookErrors(result.stderr ?? "");
|
|
972
1598
|
debug("Parsed hook errors:", errors.length, "errors");
|
|
973
|
-
await showRecoveryMenu(errors, async () => {
|
|
1599
|
+
if (await showRecoveryMenu(errors, async () => {
|
|
974
1600
|
return (await attemptCommit(message)).ok;
|
|
975
1601
|
}, async (msg) => {
|
|
976
1602
|
return (await attemptCommitNoVerify(msg)).ok;
|
|
977
1603
|
}, async () => {
|
|
978
1604
|
await stageAll();
|
|
979
1605
|
return (await attemptCommit(message)).ok;
|
|
980
|
-
}, message, result.stderr ?? "");
|
|
981
|
-
}
|
|
982
|
-
async function generateMessage(diff, hint) {
|
|
983
|
-
const config = await readConfig();
|
|
984
|
-
const apiKey = await getApiKey();
|
|
985
|
-
debug("Generating message with model:", config.model, "type:", config.type);
|
|
986
|
-
return generateCommitMessage(diff, {
|
|
987
|
-
apiKey,
|
|
988
|
-
model: config.model,
|
|
989
|
-
type: config.type,
|
|
990
|
-
timeout: config.timeout ? parseInt(config.timeout, 10) : void 0,
|
|
991
|
-
hint
|
|
992
|
-
});
|
|
993
|
-
}
|
|
994
|
-
function buildExcludedFilesMessage(files) {
|
|
995
|
-
const excludes = getDefaultExcludes();
|
|
996
|
-
const isLockfile = (f) => excludes.some((pattern) => {
|
|
997
|
-
if (pattern.endsWith(".lock") || pattern.endsWith(".json")) return f === pattern || f.endsWith(pattern.replace("*.", "."));
|
|
998
|
-
return false;
|
|
999
|
-
});
|
|
1000
|
-
if (files.every(isLockfile)) return "chore: update lockfile";
|
|
1001
|
-
return "chore: update generated files";
|
|
1606
|
+
}, message, result.stderr ?? "") === "cancelled") process.exit(1);
|
|
1002
1607
|
}
|
|
1003
1608
|
//#endregion
|
|
1004
1609
|
//#region src/commands/config.ts
|
|
@@ -1056,6 +1661,12 @@ cli({
|
|
|
1056
1661
|
description: "Add context hint for AI commit message generation",
|
|
1057
1662
|
alias: "H"
|
|
1058
1663
|
},
|
|
1664
|
+
review: {
|
|
1665
|
+
type: Boolean,
|
|
1666
|
+
description: "Review staged changes with a coding model",
|
|
1667
|
+
alias: "R",
|
|
1668
|
+
default: false
|
|
1669
|
+
},
|
|
1059
1670
|
debug: {
|
|
1060
1671
|
type: Boolean,
|
|
1061
1672
|
description: "Enable debug output",
|
|
@@ -1066,7 +1677,8 @@ cli({
|
|
|
1066
1677
|
commands: [configCommand]
|
|
1067
1678
|
}, (argv) => {
|
|
1068
1679
|
setDebug(argv.flags.debug);
|
|
1069
|
-
|
|
1680
|
+
if (argv.flags.review) reviewCommand();
|
|
1681
|
+
else commitCommand(argv.flags);
|
|
1070
1682
|
});
|
|
1071
1683
|
//#endregion
|
|
1072
1684
|
export {};
|