@kyubiware/commit-mint 0.2.1 → 0.3.1
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 +987 -314
- package/dist/cli.mjs.map +1 -1
- package/package.json +2 -2
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
|
|
7
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
6
|
+
import { access, constants, 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.
|
|
29
|
+
version: "0.3.1",
|
|
30
30
|
description: "A commit tool that actually handles hook failures",
|
|
31
31
|
type: "module",
|
|
32
32
|
bin: { "cmint": "./dist/cli.mjs" },
|
|
@@ -66,7 +66,7 @@ var package_default = {
|
|
|
66
66
|
"url": "https://github.com/kyubiware/commit-mint.git"
|
|
67
67
|
},
|
|
68
68
|
dependencies: {
|
|
69
|
-
"@clack/prompts": "^
|
|
69
|
+
"@clack/prompts": "^1.4.0",
|
|
70
70
|
"cleye": "^1.3.4",
|
|
71
71
|
"execa": "^9.6.0",
|
|
72
72
|
"groq-sdk": "^0.32.0",
|
|
@@ -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 = {
|
|
@@ -325,6 +160,7 @@ var git_exports = /* @__PURE__ */ __exportAll({
|
|
|
325
160
|
getRepoRoot: () => getRepoRoot,
|
|
326
161
|
getStagedDiff: () => getStagedDiff,
|
|
327
162
|
getStatusShort: () => getStatusShort,
|
|
163
|
+
resetStaging: () => resetStaging,
|
|
328
164
|
stageAll: () => stageAll,
|
|
329
165
|
stageFiles: () => stageFiles
|
|
330
166
|
});
|
|
@@ -396,6 +232,10 @@ async function stageAll() {
|
|
|
396
232
|
debug("stageAll: git add -A");
|
|
397
233
|
await execa("git", ["add", "-A"]);
|
|
398
234
|
}
|
|
235
|
+
async function resetStaging() {
|
|
236
|
+
debug("resetStaging: git reset HEAD");
|
|
237
|
+
await execa("git", ["reset", "HEAD"]);
|
|
238
|
+
}
|
|
399
239
|
async function getHead() {
|
|
400
240
|
const { stdout } = await execa("git", ["rev-parse", "HEAD"]);
|
|
401
241
|
return stdout.trim();
|
|
@@ -407,10 +247,14 @@ async function getStatusShort() {
|
|
|
407
247
|
async function getChangedFiles() {
|
|
408
248
|
const { stdout } = await execa("git", ["status", "--short"]);
|
|
409
249
|
if (!stdout.trim()) return [];
|
|
410
|
-
const files = stdout.split("\n").filter(Boolean).map((line) =>
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
250
|
+
const files = stdout.split("\n").filter(Boolean).map((line) => {
|
|
251
|
+
const indexStatus = line[0];
|
|
252
|
+
return {
|
|
253
|
+
status: line.slice(0, 2).trim(),
|
|
254
|
+
path: line.slice(3),
|
|
255
|
+
staged: indexStatus !== " " && indexStatus !== "?"
|
|
256
|
+
};
|
|
257
|
+
});
|
|
414
258
|
debug("getChangedFiles:", files.length, "files");
|
|
415
259
|
return files;
|
|
416
260
|
}
|
|
@@ -589,6 +433,50 @@ function extractToolName(command) {
|
|
|
589
433
|
return first;
|
|
590
434
|
}
|
|
591
435
|
//#endregion
|
|
436
|
+
//#region src/services/lint-staged.ts
|
|
437
|
+
const CONFIG_FILES = [
|
|
438
|
+
".lintstagedrc",
|
|
439
|
+
".lintstagedrc.json",
|
|
440
|
+
".lintstagedrc.yaml",
|
|
441
|
+
".lintstagedrc.yml",
|
|
442
|
+
".lintstagedrc.mjs",
|
|
443
|
+
".lintstagedrc.cjs",
|
|
444
|
+
"lint-staged.config.mjs",
|
|
445
|
+
"lint-staged.config.cjs",
|
|
446
|
+
"lint-staged.config.js"
|
|
447
|
+
];
|
|
448
|
+
async function hasLintStagedConfig(repoRoot) {
|
|
449
|
+
debug("hasLintStagedConfig: checking in %s", repoRoot);
|
|
450
|
+
for (const file of CONFIG_FILES) {
|
|
451
|
+
const path = join(repoRoot, file);
|
|
452
|
+
try {
|
|
453
|
+
await access(path, constants.F_OK);
|
|
454
|
+
debug("hasLintStagedConfig: found %s", file);
|
|
455
|
+
return true;
|
|
456
|
+
} catch {}
|
|
457
|
+
}
|
|
458
|
+
const packageJsonPath = join(repoRoot, "package.json");
|
|
459
|
+
try {
|
|
460
|
+
const raw = await readFile(packageJsonPath, "utf8");
|
|
461
|
+
if ("lint-staged" in JSON.parse(raw)) {
|
|
462
|
+
debug("hasLintStagedConfig: found lint-staged in package.json");
|
|
463
|
+
return true;
|
|
464
|
+
}
|
|
465
|
+
} catch {}
|
|
466
|
+
debug("hasLintStagedConfig: no config found");
|
|
467
|
+
return false;
|
|
468
|
+
}
|
|
469
|
+
async function runLintStaged() {
|
|
470
|
+
debug("runLintStaged: starting npx lint-staged");
|
|
471
|
+
const { failed, stdout, stderr } = await execa("npx", ["lint-staged"], { reject: false });
|
|
472
|
+
debug("runLintStaged: finished, failed=%s", failed);
|
|
473
|
+
return {
|
|
474
|
+
ok: !failed,
|
|
475
|
+
stdout,
|
|
476
|
+
stderr
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
//#endregion
|
|
592
480
|
//#region src/services/clipboard.ts
|
|
593
481
|
async function copyToClipboard(content) {
|
|
594
482
|
for (const [cmd, args] of [
|
|
@@ -629,7 +517,7 @@ async function copyToClipboard(content) {
|
|
|
629
517
|
}
|
|
630
518
|
//#endregion
|
|
631
519
|
//#region src/ui/menu.ts
|
|
632
|
-
async function showStagingMenu(files) {
|
|
520
|
+
async function showStagingMenu(files, hasLintStaged) {
|
|
633
521
|
debug("showStagingMenu: %d files", files.length);
|
|
634
522
|
const statusLabel = (status) => {
|
|
635
523
|
switch (status) {
|
|
@@ -640,14 +528,37 @@ async function showStagingMenu(files) {
|
|
|
640
528
|
default: return dim(status);
|
|
641
529
|
}
|
|
642
530
|
};
|
|
531
|
+
const sorted = [...files].sort((a, b) => {
|
|
532
|
+
if (a.staged !== b.staged) return a.staged ? -1 : 1;
|
|
533
|
+
return a.path.localeCompare(b.path);
|
|
534
|
+
});
|
|
535
|
+
const stagedFiles = sorted.filter((f) => f.staged);
|
|
536
|
+
const unstagedFiles = sorted.filter((f) => !f.staged);
|
|
537
|
+
const lines = [];
|
|
538
|
+
if (stagedFiles.length > 0) lines.push(green(bold("Staged:")), ...stagedFiles.map((f) => ` ${statusLabel(f.status)} ${f.path}`));
|
|
539
|
+
if (unstagedFiles.length > 0) {
|
|
540
|
+
if (lines.length > 0) lines.push("");
|
|
541
|
+
lines.push(yellow(bold("Changed:")), ...unstagedFiles.map((f) => ` ${statusLabel(f.status)} ${f.path}`));
|
|
542
|
+
}
|
|
543
|
+
p.note(lines.join("\n"), `${files.length} file${files.length !== 1 ? "s" : ""}`);
|
|
643
544
|
const choice = await p.select({
|
|
644
545
|
message: "Stage files for commit:",
|
|
645
546
|
options: [
|
|
547
|
+
{
|
|
548
|
+
label: "Auto-group into commits",
|
|
549
|
+
value: "autogroup",
|
|
550
|
+
hint: "LLM groups files into logical commits"
|
|
551
|
+
},
|
|
646
552
|
{
|
|
647
553
|
label: "Stage all files",
|
|
648
554
|
value: "all",
|
|
649
555
|
hint: `${files.length} file${files.length !== 1 ? "s" : ""}`
|
|
650
556
|
},
|
|
557
|
+
...hasLintStaged ? [{
|
|
558
|
+
label: "Run lint-staged checks",
|
|
559
|
+
value: "lint-staged",
|
|
560
|
+
hint: "Pre-flight checks on all changed files"
|
|
561
|
+
}] : [],
|
|
651
562
|
{
|
|
652
563
|
label: "Select files...",
|
|
653
564
|
value: "select"
|
|
@@ -659,13 +570,15 @@ async function showStagingMenu(files) {
|
|
|
659
570
|
]
|
|
660
571
|
});
|
|
661
572
|
if (p.isCancel(choice) || choice === "cancel") return null;
|
|
573
|
+
if (choice === "autogroup") return "autogroup";
|
|
574
|
+
if (choice === "lint-staged") return "lint-staged";
|
|
662
575
|
if (choice === "all") return {
|
|
663
576
|
files: files.map((f) => f.path),
|
|
664
577
|
all: true
|
|
665
578
|
};
|
|
666
579
|
const selected = await p.multiselect({
|
|
667
580
|
message: "Select files to stage:",
|
|
668
|
-
options:
|
|
581
|
+
options: sorted.map((f) => ({
|
|
669
582
|
label: `${statusLabel(f.status)} ${f.path}`,
|
|
670
583
|
value: f.path
|
|
671
584
|
})),
|
|
@@ -718,8 +631,7 @@ async function showRecoveryMenu(errors, onRetry, onSkipHooks, onRestage, message
|
|
|
718
631
|
if (p.isCancel(choice)) {
|
|
719
632
|
debug("showRecoveryMenu: user cancelled");
|
|
720
633
|
p.outro(yellow("Cancelled. Message cached for --retry."));
|
|
721
|
-
|
|
722
|
-
return;
|
|
634
|
+
return "cancelled";
|
|
723
635
|
}
|
|
724
636
|
debug("showRecoveryMenu: user chose %s", choice);
|
|
725
637
|
switch (choice) {
|
|
@@ -731,14 +643,18 @@ async function showRecoveryMenu(errors, onRetry, onSkipHooks, onRestage, message
|
|
|
731
643
|
continue;
|
|
732
644
|
case "skip":
|
|
733
645
|
p.log.info(yellow("Committing with --no-verify..."));
|
|
734
|
-
if (await onSkipHooks(message))
|
|
735
|
-
|
|
736
|
-
|
|
646
|
+
if (await onSkipHooks(message)) {
|
|
647
|
+
p.outro(green("Committed (hooks skipped)."));
|
|
648
|
+
return "committed";
|
|
649
|
+
} else {
|
|
650
|
+
p.outro(red("Commit failed even with --no-verify."));
|
|
651
|
+
return "failed";
|
|
652
|
+
}
|
|
737
653
|
case "restage":
|
|
738
654
|
p.log.info(cyan("Re-staging and retrying..."));
|
|
739
655
|
if (await onRestage()) {
|
|
740
656
|
p.outro(green("Committed successfully."));
|
|
741
|
-
return;
|
|
657
|
+
return "committed";
|
|
742
658
|
}
|
|
743
659
|
showNote = true;
|
|
744
660
|
continue;
|
|
@@ -746,81 +662,849 @@ async function showRecoveryMenu(errors, onRetry, onSkipHooks, onRestage, message
|
|
|
746
662
|
const edited = await p.text({
|
|
747
663
|
message: "Edit commit message:",
|
|
748
664
|
initialValue: message,
|
|
749
|
-
validate: (v) => v
|
|
665
|
+
validate: (v) => v?.trim() ? void 0 : "Message cannot be empty"
|
|
750
666
|
});
|
|
751
667
|
if (p.isCancel(edited)) {
|
|
752
668
|
p.outro(yellow("Cancelled. Message cached for --retry."));
|
|
753
|
-
|
|
754
|
-
|
|
669
|
+
return "cancelled";
|
|
670
|
+
}
|
|
671
|
+
if (await onRetry()) {
|
|
672
|
+
p.outro(green("Committed successfully."));
|
|
673
|
+
return "committed";
|
|
674
|
+
} else {
|
|
675
|
+
p.outro(red("Commit failed again."));
|
|
676
|
+
return "failed";
|
|
755
677
|
}
|
|
756
|
-
if (await onRetry()) p.outro(green("Committed successfully."));
|
|
757
|
-
else p.outro(red("Commit failed again."));
|
|
758
|
-
return;
|
|
759
678
|
}
|
|
760
679
|
case "cancel":
|
|
761
680
|
p.outro(dim("Message cached for --retry."));
|
|
762
|
-
|
|
763
|
-
return;
|
|
681
|
+
return "cancelled";
|
|
764
682
|
}
|
|
765
683
|
}
|
|
766
684
|
}
|
|
767
685
|
//#endregion
|
|
768
|
-
//#region src/
|
|
769
|
-
const
|
|
770
|
-
function
|
|
771
|
-
|
|
686
|
+
//#region src/services/ai.ts
|
|
687
|
+
const MAX_DIFF_CHARS = 2e4;
|
|
688
|
+
function mapGroqError$1(error) {
|
|
689
|
+
if (error instanceof Groq.AuthenticationError) return /* @__PURE__ */ new Error("Invalid GROQ_API_KEY. Run: cmint config set GROQ_API_KEY=<key>");
|
|
690
|
+
if (error instanceof Groq.RateLimitError) return /* @__PURE__ */ new Error("Rate limited by Groq. Please wait and try again.");
|
|
691
|
+
if (error instanceof Groq.APIConnectionTimeoutError) return /* @__PURE__ */ new Error("Request timed out. Check your network or try a smaller diff.");
|
|
692
|
+
if (error instanceof Groq.APIError) return /* @__PURE__ */ new Error(`Groq API error: ${error.message}`);
|
|
693
|
+
return /* @__PURE__ */ new Error(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`);
|
|
772
694
|
}
|
|
773
|
-
|
|
774
|
-
|
|
695
|
+
const CONVENTIONAL_COMMIT_REGEX = /^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.+\))?!?: .+$/;
|
|
696
|
+
function stripThinkTags(text) {
|
|
697
|
+
return text.replace(/<think[\s\S]*?<\/think>/gi, "").trim();
|
|
775
698
|
}
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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");
|
|
699
|
+
function deriveMessageFromReasoning(reasoning) {
|
|
700
|
+
const match = reasoning.match(/(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.+\))?!?: .+/i);
|
|
701
|
+
if (match) return match[0].trim();
|
|
702
|
+
const first = reasoning.split(/[.!?]/).find((s) => s.trim().length >= 10);
|
|
703
|
+
return first ? first.trim() : null;
|
|
786
704
|
}
|
|
787
|
-
|
|
788
|
-
|
|
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
|
-
}
|
|
705
|
+
function stripContextLines(diff) {
|
|
706
|
+
return diff.split("\n").filter((line) => !line.startsWith(" ")).join("\n");
|
|
799
707
|
}
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
708
|
+
function compressDiff(diff) {
|
|
709
|
+
if (diff.length <= MAX_DIFF_CHARS) return diff;
|
|
710
|
+
let result = stripContextLines(diff);
|
|
711
|
+
if (result.length <= MAX_DIFF_CHARS) return result;
|
|
712
|
+
result = result.split(/(?=diff --git)/).filter(Boolean).map((fd) => {
|
|
713
|
+
return fd.split(/(?=\n@@)/).map((part, idx) => {
|
|
714
|
+
if (idx === 0) return part;
|
|
715
|
+
const lines = part.split("\n");
|
|
716
|
+
return [lines[0], ...lines.slice(1).filter((l) => l.startsWith("+") || l.startsWith("-")).slice(0, 10)].join("\n");
|
|
717
|
+
}).join("");
|
|
718
|
+
}).join("");
|
|
719
|
+
if (result.length <= MAX_DIFF_CHARS) return result;
|
|
720
|
+
return `Summary of changes:\n${(diff.match(/^diff --git a\/(.+) b\/(.+)$/gm) || []).map((f) => {
|
|
721
|
+
const match = f.match(/^diff --git a\/(.+) b\/(.+)$/);
|
|
722
|
+
return match && match[1] === match[2] ? `${match[1]} | changed` : "";
|
|
723
|
+
}).filter(Boolean).join("\n")}`;
|
|
724
|
+
}
|
|
725
|
+
function buildStatSummary(diff) {
|
|
726
|
+
const files = [];
|
|
727
|
+
let currentFile = "";
|
|
728
|
+
let adds = 0;
|
|
729
|
+
let dels = 0;
|
|
730
|
+
for (const line of diff.split("\n")) {
|
|
731
|
+
const match = line.match(/^diff --git a\/.+ b\/(.+)$/);
|
|
732
|
+
if (match) {
|
|
733
|
+
if (currentFile) files.push({
|
|
734
|
+
name: currentFile,
|
|
735
|
+
adds,
|
|
736
|
+
dels
|
|
737
|
+
});
|
|
738
|
+
currentFile = match[1];
|
|
739
|
+
adds = 0;
|
|
740
|
+
dels = 0;
|
|
741
|
+
} else if (line.startsWith("+") && !line.startsWith("+++")) adds++;
|
|
742
|
+
else if (line.startsWith("-") && !line.startsWith("---")) dels++;
|
|
743
|
+
}
|
|
744
|
+
if (currentFile) files.push({
|
|
745
|
+
name: currentFile,
|
|
746
|
+
adds,
|
|
747
|
+
dels
|
|
748
|
+
});
|
|
749
|
+
const totalAdds = files.reduce((s, f) => s + f.adds, 0);
|
|
750
|
+
const totalDels = files.reduce((s, f) => s + f.dels, 0);
|
|
751
|
+
const lines = files.map((f) => ` ${f.name} | +${f.adds} -${f.dels}`);
|
|
752
|
+
lines.push(` ${files.length} files changed, ${totalAdds} insertions(+), ${totalDels} deletions(-)`);
|
|
753
|
+
return lines.join("\n");
|
|
754
|
+
}
|
|
755
|
+
function buildSystemPrompt(type) {
|
|
756
|
+
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.";
|
|
757
|
+
if (type && type.trim().length > 0) prompt += `\nYou MUST use type: ${type}`;
|
|
758
|
+
return prompt;
|
|
759
|
+
}
|
|
760
|
+
function buildUserPrompt(diff, hint, statSummary) {
|
|
761
|
+
const parts = [];
|
|
762
|
+
if (hint) parts.push(`Context: ${hint}`);
|
|
763
|
+
if (statSummary) parts.push(`Change summary:\n${statSummary}`);
|
|
764
|
+
parts.push(`Generate a conventional commit for:\n\n${diff}`);
|
|
765
|
+
return parts.join("\n\n");
|
|
766
|
+
}
|
|
767
|
+
function isValidConventionalCommit(message) {
|
|
768
|
+
return CONVENTIONAL_COMMIT_REGEX.test(message);
|
|
769
|
+
}
|
|
770
|
+
function extractContentText(content) {
|
|
771
|
+
if (content == null) return "";
|
|
772
|
+
if (typeof content === "string") return content.trim();
|
|
773
|
+
if (Array.isArray(content)) return content.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => stripThinkTags(part.text)).join("").trim();
|
|
774
|
+
return "";
|
|
775
|
+
}
|
|
776
|
+
async function generateCommitMessage(diff, options) {
|
|
777
|
+
debug("generateCommitMessage: model=%s, type=%s, hint=%s", options.model ?? "default", options.type ?? "none", options.hint ?? "none");
|
|
778
|
+
const timeoutMs = options.timeout ?? 6e4;
|
|
779
|
+
debug("Timeout: %d ms", timeoutMs);
|
|
780
|
+
const client = new Groq({
|
|
781
|
+
apiKey: options.apiKey,
|
|
782
|
+
timeout: timeoutMs
|
|
783
|
+
});
|
|
784
|
+
const compressedDiff = compressDiff(diff);
|
|
785
|
+
const statSummary = buildStatSummary(diff);
|
|
786
|
+
const systemPrompt = buildSystemPrompt(options.type);
|
|
787
|
+
const userPrompt = buildUserPrompt(compressedDiff, options.hint, statSummary);
|
|
788
|
+
debug("Diff: %d chars → compressed to %d chars", diff.length, compressedDiff.length);
|
|
789
|
+
debug("Stat summary:\n%s", statSummary);
|
|
790
|
+
debug("User prompt length: %d chars", userPrompt.length);
|
|
791
|
+
async function callAI(strictSystemPrompt) {
|
|
792
|
+
const callStart = Date.now();
|
|
793
|
+
debug("callAI: %s — model=%s, promptLen=%d, systemLen=%d", !!strictSystemPrompt ? "RETRY (strict)" : "INITIAL", options.model ?? "openai/gpt-oss-20b", userPrompt.length, (strictSystemPrompt ?? systemPrompt).length);
|
|
794
|
+
try {
|
|
795
|
+
const isReasoningModel = /^(o[1-9]|.*gpt-oss.*|.*gpt-5.*)/i.test(options.model ?? "");
|
|
796
|
+
const completion = await client.chat.completions.create({
|
|
797
|
+
messages: [{
|
|
798
|
+
role: "system",
|
|
799
|
+
content: strictSystemPrompt ?? systemPrompt
|
|
800
|
+
}, {
|
|
801
|
+
role: "user",
|
|
802
|
+
content: userPrompt
|
|
803
|
+
}],
|
|
804
|
+
model: options.model ?? "openai/gpt-oss-20b",
|
|
805
|
+
temperature: .3,
|
|
806
|
+
...isReasoningModel ? { max_completion_tokens: 1024 } : { max_tokens: 1024 },
|
|
807
|
+
reasoning_format: "parsed"
|
|
808
|
+
});
|
|
809
|
+
const elapsed = Date.now() - callStart;
|
|
810
|
+
const rawContent = completion.choices[0]?.message?.content;
|
|
811
|
+
const content = extractContentText(typeof rawContent === "string" ? stripThinkTags(rawContent) : rawContent);
|
|
812
|
+
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);
|
|
813
|
+
debug("callAI raw content: %s", content.slice(0, 300) || "(empty)");
|
|
814
|
+
if (!content) {
|
|
815
|
+
const reasoning = completion.choices[0]?.message?.reasoning;
|
|
816
|
+
debug("callAI: content empty, attempting reasoning fallback (reasoningLen=%d)", reasoning?.length ?? 0);
|
|
817
|
+
if (reasoning) {
|
|
818
|
+
const derived = deriveMessageFromReasoning(reasoning);
|
|
819
|
+
if (derived) {
|
|
820
|
+
debug("callAI: derived message from reasoning: %s", derived.slice(0, 100));
|
|
821
|
+
return stripThinkTags(derived);
|
|
822
|
+
}
|
|
823
|
+
debug("callAI: could not derive message from reasoning");
|
|
824
|
+
}
|
|
825
|
+
throw new Error("AI returned an empty commit message");
|
|
826
|
+
}
|
|
827
|
+
return content;
|
|
828
|
+
} catch (error) {
|
|
829
|
+
debug("callAI FAILED after %d ms: %s", Date.now() - callStart, error instanceof Error ? `${error.name}: ${error.message}` : String(error));
|
|
830
|
+
throw error;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
try {
|
|
834
|
+
const totalStart = Date.now();
|
|
835
|
+
let message = await callAI();
|
|
836
|
+
debug("Validation: message=%s, isValid=%s", message.slice(0, 100), isValidConventionalCommit(message));
|
|
837
|
+
if (!isValidConventionalCommit(message)) {
|
|
838
|
+
debug("Initial message failed conventional commit validation, retrying with strict prompt (elapsed: %d ms)", Date.now() - totalStart);
|
|
839
|
+
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.");
|
|
840
|
+
debug("Retry validation: message=%s, isValid=%s", retryMessage.slice(0, 100), isValidConventionalCommit(retryMessage));
|
|
841
|
+
if (isValidConventionalCommit(retryMessage)) {
|
|
842
|
+
debug("Retry produced valid conventional commit");
|
|
843
|
+
message = retryMessage;
|
|
844
|
+
} else debug("Retry also failed validation, using original message");
|
|
845
|
+
}
|
|
846
|
+
debug("Final message (%d ms total): %s", Date.now() - totalStart, message);
|
|
847
|
+
return message;
|
|
848
|
+
} catch (error) {
|
|
849
|
+
debug("AI error: %s", error instanceof Error ? error.message : String(error));
|
|
850
|
+
throw mapGroqError$1(error);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
//#endregion
|
|
854
|
+
//#region src/services/review-ai.ts
|
|
855
|
+
function buildReviewSystemPrompt() {
|
|
856
|
+
return [
|
|
857
|
+
"You are an expert code reviewer. Review the following staged git diff.",
|
|
858
|
+
"",
|
|
859
|
+
"Focus on finding:",
|
|
860
|
+
"1. **Bugs** — logic errors, off-by-one, race conditions, null pointer risks",
|
|
861
|
+
"2. **Security issues** — injection, exposure of secrets, missing validation, CSRF, XSS",
|
|
862
|
+
"3. **Performance problems** — unnecessary work, large allocations in hot paths",
|
|
863
|
+
"4. **Code quality** — readability, maintainability, error handling gaps",
|
|
864
|
+
"5. **Edge cases** — missing boundary checks, empty states, error states",
|
|
865
|
+
"",
|
|
866
|
+
"For each issue found, use this format:",
|
|
867
|
+
"- SEVERITY: [critical|major|minor|suggestion]",
|
|
868
|
+
"- LOCATION: <file-path>:<line-number>",
|
|
869
|
+
"- ISSUE: <description>",
|
|
870
|
+
"- FIX: <suggested resolution>",
|
|
871
|
+
"",
|
|
872
|
+
"Separate issues with a blank line.",
|
|
873
|
+
"",
|
|
874
|
+
"If you find NO issues at all, respond with exactly: NO_ISSUES_FOUND",
|
|
875
|
+
"",
|
|
876
|
+
"Be thorough but practical. Only flag real problems — not style preferences or nitpicks."
|
|
877
|
+
].join("\n");
|
|
878
|
+
}
|
|
879
|
+
function buildReviewPrompt(diff, files, statSummary) {
|
|
880
|
+
const parts = [];
|
|
881
|
+
parts.push(`Review the following staged changes (${files.length} files):`);
|
|
882
|
+
parts.push("");
|
|
883
|
+
parts.push(statSummary);
|
|
884
|
+
parts.push("");
|
|
885
|
+
parts.push("```diff");
|
|
886
|
+
parts.push(diff);
|
|
887
|
+
parts.push("```");
|
|
888
|
+
return parts.join("\n");
|
|
889
|
+
}
|
|
890
|
+
async function generateCodeReview(diff, files, options) {
|
|
891
|
+
debug("generateCodeReview: model=%s, files=%d", options.model ?? "default", files.length);
|
|
892
|
+
const timeoutMs = options.timeout ?? 6e4;
|
|
893
|
+
const client = new Groq({
|
|
894
|
+
apiKey: options.apiKey,
|
|
895
|
+
timeout: timeoutMs
|
|
896
|
+
});
|
|
897
|
+
const compressedDiff = compressDiff(diff);
|
|
898
|
+
const statSummary = buildStatSummary(diff);
|
|
899
|
+
const systemPrompt = buildReviewSystemPrompt();
|
|
900
|
+
const userPrompt = buildReviewPrompt(compressedDiff, files, statSummary);
|
|
901
|
+
debug("Code review: %d chars → %d chars, system=%d chars, user=%d chars", diff.length, compressedDiff.length, systemPrompt.length, userPrompt.length);
|
|
902
|
+
try {
|
|
903
|
+
const completion = await client.chat.completions.create({
|
|
904
|
+
messages: [{
|
|
905
|
+
role: "system",
|
|
906
|
+
content: systemPrompt
|
|
907
|
+
}, {
|
|
908
|
+
role: "user",
|
|
909
|
+
content: userPrompt
|
|
910
|
+
}],
|
|
911
|
+
model: options.model ?? "openai/gpt-oss-20b",
|
|
912
|
+
temperature: .3,
|
|
913
|
+
max_tokens: 4096
|
|
914
|
+
});
|
|
915
|
+
const rawContent = completion.choices[0]?.message?.content;
|
|
916
|
+
const content = extractContentText(rawContent);
|
|
917
|
+
debug("generateCodeReview response: choices=%d, finishReason=%s, contentLen=%d", completion.choices.length, completion.choices[0]?.finish_reason ?? "(none)", content.length);
|
|
918
|
+
if (!content) {
|
|
919
|
+
const reasoning = completion.choices[0]?.message?.reasoning;
|
|
920
|
+
if (reasoning) {
|
|
921
|
+
const derived = deriveMessageFromReasoning(reasoning);
|
|
922
|
+
if (derived) {
|
|
923
|
+
debug("generateCodeReview: derived from reasoning");
|
|
924
|
+
return derived;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
return "NO_ISSUES_FOUND";
|
|
928
|
+
}
|
|
929
|
+
return content;
|
|
930
|
+
} catch (error) {
|
|
931
|
+
debug("generateCodeReview error: %s", error instanceof Error ? error.message : String(error));
|
|
932
|
+
throw mapGroqError$1(error);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
//#endregion
|
|
936
|
+
//#region src/commands/review.ts
|
|
937
|
+
async function reviewCommand() {
|
|
938
|
+
debug("reviewCommand called");
|
|
939
|
+
await assertGitRepo();
|
|
940
|
+
const s = spinner();
|
|
941
|
+
s.start("Staging all changes...");
|
|
942
|
+
await stageAll();
|
|
943
|
+
s.stop("Changes staged");
|
|
944
|
+
const diffResult = await getStagedDiff();
|
|
945
|
+
if (!diffResult) {
|
|
946
|
+
outro(dim("No changes to review."));
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
if ("excludedFiles" in diffResult) {
|
|
950
|
+
outro(dim("Staged files are all excluded from review."));
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
intro("commit-mint — code review");
|
|
954
|
+
log.info(diffResult.files.map((f) => ` ${f}`).join("\n"));
|
|
955
|
+
const report = await isOpenCodeAvailable() ? await reviewWithOpenCode(diffResult.diff, diffResult.files) : await reviewWithGroq(diffResult.diff, diffResult.files);
|
|
956
|
+
if (report !== "NO_ISSUES_FOUND" && report.trim().length > 0) {
|
|
957
|
+
note(report, red(bold("Review findings")));
|
|
958
|
+
await offerClipboardCopy(report);
|
|
959
|
+
} else outro(green("No issues found. Looks good!"));
|
|
960
|
+
}
|
|
961
|
+
async function offerClipboardCopy(report) {
|
|
962
|
+
const shouldCopy = await select({
|
|
963
|
+
message: "Copy review report to clipboard?",
|
|
964
|
+
options: [{
|
|
965
|
+
label: "Yes, copy to clipboard",
|
|
966
|
+
value: "yes"
|
|
967
|
+
}, {
|
|
968
|
+
label: "No",
|
|
969
|
+
value: "no"
|
|
970
|
+
}]
|
|
971
|
+
});
|
|
972
|
+
if (isCancel(shouldCopy) || shouldCopy === "no") {
|
|
973
|
+
outro(dim("Done."));
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
if (await copyToClipboard(report)) outro(green("Report copied to clipboard. You can paste it anywhere for fixes."));
|
|
977
|
+
else outro(red("Failed to copy to clipboard. Install xclip, wl-copy, or xsel."));
|
|
978
|
+
}
|
|
979
|
+
async function isOpenCodeAvailable() {
|
|
980
|
+
try {
|
|
981
|
+
const { exitCode } = await import("execa").then((m) => m.execa("which", ["opencode"], { reject: false }));
|
|
982
|
+
return exitCode === 0;
|
|
983
|
+
} catch {
|
|
984
|
+
return false;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
async function reviewWithGroq(diff, files) {
|
|
988
|
+
const s = spinner();
|
|
989
|
+
s.start("Reviewing with Groq...");
|
|
990
|
+
try {
|
|
991
|
+
const config = await readConfig();
|
|
992
|
+
const report = await generateCodeReview(diff, files, {
|
|
993
|
+
apiKey: await getApiKey(),
|
|
994
|
+
model: config.model,
|
|
995
|
+
timeout: config.timeout ? Number.parseInt(config.timeout, 10) : void 0
|
|
996
|
+
});
|
|
997
|
+
s.stop("Review complete");
|
|
998
|
+
return report;
|
|
999
|
+
} catch (err) {
|
|
1000
|
+
s.stop(red("Review failed."));
|
|
1001
|
+
debug("reviewWithGroq error:", err instanceof Error ? err.message : String(err));
|
|
1002
|
+
throw err;
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
async function reviewWithOpenCode(diff, files) {
|
|
1006
|
+
const s = spinner();
|
|
1007
|
+
s.start("Running OpenCode review...");
|
|
1008
|
+
try {
|
|
1009
|
+
const repoRoot = await getRepoRoot();
|
|
1010
|
+
const prompt = [
|
|
1011
|
+
"Review the staged changes in this git repository.",
|
|
1012
|
+
"Analyze the code diff for bugs, security issues, performance problems,",
|
|
1013
|
+
"code quality issues, and missing edge cases.",
|
|
1014
|
+
"",
|
|
1015
|
+
`Files changed (${files.length}):`,
|
|
1016
|
+
...files.map((f) => ` - ${f}`),
|
|
1017
|
+
"",
|
|
1018
|
+
"```diff",
|
|
1019
|
+
diff.slice(0, 15e3),
|
|
1020
|
+
"```",
|
|
1021
|
+
"",
|
|
1022
|
+
"Provide a structured report with severity, location, issue, and fix suggestion for each finding.",
|
|
1023
|
+
"If no issues found, respond with: NO_ISSUES_FOUND"
|
|
1024
|
+
].join("\n");
|
|
1025
|
+
const { stdout } = await import("execa").then((m) => m.execa("opencode", [
|
|
1026
|
+
"run",
|
|
1027
|
+
prompt,
|
|
1028
|
+
"--dir",
|
|
1029
|
+
repoRoot
|
|
1030
|
+
], {
|
|
1031
|
+
timeout: 12e4,
|
|
1032
|
+
reject: false
|
|
1033
|
+
}));
|
|
1034
|
+
s.stop("Review complete");
|
|
1035
|
+
return stdout || "OpenCode review completed but no output captured.";
|
|
1036
|
+
} catch (err) {
|
|
1037
|
+
s.stop(red("OpenCode review failed."));
|
|
1038
|
+
debug("reviewWithOpenCode error:", err instanceof Error ? err.message : String(err));
|
|
1039
|
+
throw new Error(`OpenCode review failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
//#endregion
|
|
1043
|
+
//#region src/ui/review-message.ts
|
|
1044
|
+
async function runCodeReview() {
|
|
1045
|
+
const diffResult = await getStagedDiff();
|
|
1046
|
+
if (!diffResult || "excludedFiles" in diffResult) {
|
|
1047
|
+
outro(dim("No staged changes to review."));
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
const opencodeAvailable = await isOpenCodeAvailable();
|
|
1051
|
+
const s = spinner();
|
|
1052
|
+
s.start(opencodeAvailable ? "Running OpenCode review..." : "Running Groq review...");
|
|
1053
|
+
try {
|
|
1054
|
+
const report = opencodeAvailable ? await reviewWithOpenCode(diffResult.diff, diffResult.files) : await reviewWithGroq(diffResult.diff, diffResult.files);
|
|
1055
|
+
s.stop("Review complete");
|
|
1056
|
+
await showReviewResults(report);
|
|
1057
|
+
} catch (err) {
|
|
1058
|
+
s.stop(red("Review failed."));
|
|
1059
|
+
debug("Code review error:", err instanceof Error ? err.message : String(err));
|
|
1060
|
+
outro(red(err instanceof Error ? err.message : String(err)));
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
async function showReviewResults(report) {
|
|
1064
|
+
const { note: clackNote, select: clackSelect } = await import("@clack/prompts");
|
|
1065
|
+
if (!(report !== "NO_ISSUES_FOUND" && report.trim().length > 0)) {
|
|
1066
|
+
log.info(green("No issues found."));
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
clackNote(report, red(bold("Review findings")));
|
|
1070
|
+
const shouldCopy = await clackSelect({
|
|
1071
|
+
message: "Copy review report to clipboard?",
|
|
1072
|
+
options: [{
|
|
1073
|
+
label: "Yes, copy to clipboard",
|
|
1074
|
+
value: "yes"
|
|
1075
|
+
}, {
|
|
1076
|
+
label: "No",
|
|
1077
|
+
value: "no"
|
|
1078
|
+
}]
|
|
1079
|
+
});
|
|
1080
|
+
if (isCancel(shouldCopy) || shouldCopy !== "yes") return;
|
|
1081
|
+
if (await copyToClipboard(report)) log.info(green("Report copied to clipboard."));
|
|
1082
|
+
else log.warn(red("Failed to copy to clipboard."));
|
|
1083
|
+
}
|
|
1084
|
+
async function reviewCommitMessage(message) {
|
|
1085
|
+
const { select, text } = await import("@clack/prompts");
|
|
1086
|
+
while (true) {
|
|
1087
|
+
const review = await select({
|
|
1088
|
+
message: `Review commit message:\n\n ${bold(message)}\n`,
|
|
1089
|
+
options: [
|
|
1090
|
+
{
|
|
1091
|
+
label: "Use as-is",
|
|
1092
|
+
value: "use"
|
|
1093
|
+
},
|
|
1094
|
+
{
|
|
1095
|
+
label: "Edit",
|
|
1096
|
+
value: "edit"
|
|
1097
|
+
},
|
|
1098
|
+
{
|
|
1099
|
+
label: "Review with OpenCode",
|
|
1100
|
+
value: "review"
|
|
1101
|
+
},
|
|
1102
|
+
{
|
|
1103
|
+
label: "Cancel",
|
|
1104
|
+
value: "cancel"
|
|
1105
|
+
}
|
|
1106
|
+
]
|
|
1107
|
+
});
|
|
1108
|
+
if (isCancel(review) || review === "cancel") {
|
|
1109
|
+
debug("User cancelled at review step");
|
|
1110
|
+
return null;
|
|
1111
|
+
}
|
|
1112
|
+
if (review === "use") {
|
|
1113
|
+
debug("User accepted message");
|
|
1114
|
+
return message;
|
|
1115
|
+
}
|
|
1116
|
+
if (review === "edit") {
|
|
1117
|
+
debug("User chose to edit message");
|
|
1118
|
+
const edited = await text({
|
|
1119
|
+
message: "Edit commit message:",
|
|
1120
|
+
initialValue: message,
|
|
1121
|
+
validate: (v) => v?.trim() ? void 0 : "Message cannot be empty"
|
|
1122
|
+
});
|
|
1123
|
+
if (isCancel(edited)) continue;
|
|
1124
|
+
message = String(edited).trim();
|
|
1125
|
+
debug("Edited message:", message);
|
|
1126
|
+
continue;
|
|
1127
|
+
}
|
|
1128
|
+
if (review === "review") {
|
|
1129
|
+
debug("User chose to review code");
|
|
1130
|
+
await runCodeReview();
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
//#endregion
|
|
1135
|
+
//#region src/utils/cache.ts
|
|
1136
|
+
const CACHE_DIR = join(os.homedir(), ".cache", "commit-mint");
|
|
1137
|
+
function repoHash(repoPath) {
|
|
1138
|
+
return createHash("sha256").update(repoPath).digest("hex").slice(0, 12);
|
|
1139
|
+
}
|
|
1140
|
+
function cachePath(repoPath) {
|
|
1141
|
+
return join(CACHE_DIR, `${repoHash(repoPath)}.json`);
|
|
1142
|
+
}
|
|
1143
|
+
async function saveCachedCommit(repoPath, message) {
|
|
1144
|
+
await mkdir(CACHE_DIR, { recursive: true });
|
|
1145
|
+
const data = {
|
|
1146
|
+
message,
|
|
1147
|
+
timestamp: Date.now(),
|
|
1148
|
+
repoPath
|
|
1149
|
+
};
|
|
1150
|
+
const path = cachePath(repoPath);
|
|
1151
|
+
debug("saveCachedCommit: saving to %s", path);
|
|
1152
|
+
await writeFile(path, JSON.stringify(data, null, 2), "utf8");
|
|
1153
|
+
}
|
|
1154
|
+
async function loadCachedCommit(repoPath) {
|
|
1155
|
+
const path = cachePath(repoPath);
|
|
1156
|
+
debug("loadCachedCommit: loading from %s", path);
|
|
1157
|
+
try {
|
|
1158
|
+
const raw = await readFile(path, "utf8");
|
|
1159
|
+
const data = JSON.parse(raw);
|
|
1160
|
+
debug("loadCachedCommit: found message from %s", new Date(data.timestamp).toISOString());
|
|
1161
|
+
return data;
|
|
1162
|
+
} catch {
|
|
1163
|
+
debug("loadCachedCommit: no cached commit found");
|
|
1164
|
+
return null;
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
//#endregion
|
|
1168
|
+
//#region src/services/grouping.ts
|
|
1169
|
+
function mapGroqError(error) {
|
|
1170
|
+
if (error instanceof Groq.AuthenticationError) return /* @__PURE__ */ new Error("Invalid GROQ_API_KEY. Run: cmint config set GROQ_API_KEY=<key>");
|
|
1171
|
+
if (error instanceof Groq.RateLimitError) return /* @__PURE__ */ new Error("Rate limited by Groq. Please wait and try again.");
|
|
1172
|
+
if (error instanceof Groq.APIConnectionTimeoutError) return /* @__PURE__ */ new Error("Request timed out. Check your network and try again.");
|
|
1173
|
+
if (error instanceof Groq.APIError) return /* @__PURE__ */ new Error(`Groq API error: ${error.message}`);
|
|
1174
|
+
return /* @__PURE__ */ new Error(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`);
|
|
1175
|
+
}
|
|
1176
|
+
function matchesExcludePattern(filePath, pattern) {
|
|
1177
|
+
if (pattern === filePath) return true;
|
|
1178
|
+
if (pattern.endsWith("/**")) {
|
|
1179
|
+
const prefix = pattern.slice(0, -3);
|
|
1180
|
+
return filePath === prefix || filePath.startsWith(`${prefix}/`);
|
|
1181
|
+
}
|
|
1182
|
+
if (pattern.startsWith("*.")) {
|
|
1183
|
+
const suffix = pattern.slice(1);
|
|
1184
|
+
return filePath.endsWith(suffix);
|
|
1185
|
+
}
|
|
1186
|
+
return false;
|
|
1187
|
+
}
|
|
1188
|
+
/** Lockfiles that should be kept when their companion manifest is present */
|
|
1189
|
+
const LOCKFILE_COMPANIONS = {
|
|
1190
|
+
"package-lock.json": "package.json",
|
|
1191
|
+
"pnpm-lock.yaml": "package.json",
|
|
1192
|
+
"yarn.lock": "package.json"
|
|
1193
|
+
};
|
|
1194
|
+
function filterExcludedFiles(files) {
|
|
1195
|
+
const patterns = getDefaultExcludes();
|
|
1196
|
+
const included = [];
|
|
1197
|
+
const excluded = [];
|
|
1198
|
+
const filePaths = new Set(files.map((f) => f.path));
|
|
1199
|
+
for (const file of files) if (patterns.some((pattern) => matchesExcludePattern(file.path, pattern))) excluded.push(file);
|
|
1200
|
+
else included.push(file);
|
|
1201
|
+
const stillExcluded = [];
|
|
1202
|
+
for (const file of excluded) {
|
|
1203
|
+
const companion = LOCKFILE_COMPANIONS[file.path];
|
|
1204
|
+
if (companion && filePaths.has(companion)) included.push(file);
|
|
1205
|
+
else stillExcluded.push(file.path);
|
|
1206
|
+
}
|
|
1207
|
+
debug("filterExcludedFiles: %d included, %d excluded", included.length, stillExcluded.length);
|
|
1208
|
+
return {
|
|
1209
|
+
included,
|
|
1210
|
+
excluded: stillExcluded
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
function statusIndicator(status) {
|
|
1214
|
+
switch (status) {
|
|
1215
|
+
case "M": return "modified";
|
|
1216
|
+
case "A": return "added";
|
|
1217
|
+
case "D": return "deleted";
|
|
1218
|
+
case "R": return "renamed";
|
|
1219
|
+
case "C": return "copied";
|
|
1220
|
+
case "?": return "untracked";
|
|
1221
|
+
default: return "changed";
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
function buildFileSummary(files) {
|
|
1225
|
+
return files.map((f) => `${f.path} (${statusIndicator(f.status)})`).join("\n");
|
|
1226
|
+
}
|
|
1227
|
+
function buildGroupingSystemPrompt() {
|
|
1228
|
+
return [
|
|
1229
|
+
"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.",
|
|
1230
|
+
"",
|
|
1231
|
+
"Rules:",
|
|
1232
|
+
"- Group by feature, fix, or concern (e.g., 'Frontend refactor', 'API changes', 'Test updates')",
|
|
1233
|
+
"- Keep related files together (e.g., a component + its test, a model + its migration)",
|
|
1234
|
+
"- Do not split a single logical change across multiple groups",
|
|
1235
|
+
"- If a file does not clearly belong to any group, include it anyway — do not omit files",
|
|
1236
|
+
"",
|
|
1237
|
+
"Output format: JSON array of objects with keys 'name', 'description', 'files'.",
|
|
1238
|
+
"name: short label (3-5 words)",
|
|
1239
|
+
"description: 1-2 sentences explaining what this group changes",
|
|
1240
|
+
"files: array of exact file paths from the input",
|
|
1241
|
+
"",
|
|
1242
|
+
"Output ONLY valid JSON. No markdown fences, no explanation."
|
|
1243
|
+
].join("\n");
|
|
1244
|
+
}
|
|
1245
|
+
function buildGroupingUserPrompt(summary) {
|
|
1246
|
+
return [
|
|
1247
|
+
"Group the following changed files into logical commits:",
|
|
1248
|
+
"",
|
|
1249
|
+
summary
|
|
1250
|
+
].join("\n");
|
|
1251
|
+
}
|
|
1252
|
+
function parseGroupingResponse(content) {
|
|
1253
|
+
const jsonText = content.replace(/^```json\s*/, "").replace(/\s*```$/, "").trim();
|
|
1254
|
+
const parsed = JSON.parse(jsonText);
|
|
1255
|
+
if (!Array.isArray(parsed)) throw new Error("AI response was not a JSON array");
|
|
1256
|
+
const rawGroups = [];
|
|
1257
|
+
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({
|
|
1258
|
+
name: String(item.name),
|
|
1259
|
+
description: String(item.description),
|
|
1260
|
+
files: item.files.filter((f) => typeof f === "string")
|
|
1261
|
+
});
|
|
1262
|
+
return rawGroups;
|
|
1263
|
+
}
|
|
1264
|
+
async function generateGroups(files, apiKey, model, timeout) {
|
|
1265
|
+
debug("generateGroups: %d files, model=%s", files.length, model ?? "default");
|
|
1266
|
+
const { included, excluded } = filterExcludedFiles(files);
|
|
1267
|
+
if (included.length === 0) {
|
|
1268
|
+
debug("generateGroups: no files to group after exclusion");
|
|
1269
|
+
return {
|
|
1270
|
+
groups: [],
|
|
1271
|
+
excluded
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
const summary = buildFileSummary(included);
|
|
1275
|
+
const systemPrompt = buildGroupingSystemPrompt();
|
|
1276
|
+
const userPrompt = buildGroupingUserPrompt(summary);
|
|
1277
|
+
debug("File summary:\n%s", summary);
|
|
1278
|
+
debug("User prompt length: %d chars", userPrompt.length);
|
|
1279
|
+
const client = new Groq({
|
|
1280
|
+
apiKey,
|
|
1281
|
+
timeout: timeout ?? 6e4
|
|
1282
|
+
});
|
|
1283
|
+
try {
|
|
1284
|
+
const completion = await client.chat.completions.create({
|
|
1285
|
+
messages: [{
|
|
1286
|
+
role: "system",
|
|
1287
|
+
content: systemPrompt
|
|
1288
|
+
}, {
|
|
1289
|
+
role: "user",
|
|
1290
|
+
content: userPrompt
|
|
1291
|
+
}],
|
|
1292
|
+
model: model ?? "openai/gpt-oss-20b",
|
|
1293
|
+
temperature: .3,
|
|
1294
|
+
max_tokens: 2048
|
|
1295
|
+
});
|
|
1296
|
+
const rawContent = completion.choices[0]?.message?.content;
|
|
1297
|
+
const content = typeof rawContent === "string" ? rawContent.trim() : "";
|
|
1298
|
+
debug("generateGroups response: choices=%d, finishReason=%s, contentLen=%d", completion.choices.length, completion.choices[0]?.finish_reason ?? "(none)", content.length);
|
|
1299
|
+
debug("generateGroups raw content: %s", content.slice(0, 500) || "(empty)");
|
|
1300
|
+
if (!content) throw new Error("AI returned an empty grouping response");
|
|
1301
|
+
const rawGroups = parseGroupingResponse(content);
|
|
1302
|
+
debug("generateGroups: parsed %d raw groups", rawGroups.length);
|
|
1303
|
+
const validated = validateGroups(rawGroups, included);
|
|
1304
|
+
debug("generateGroups: %d validated groups", validated.length);
|
|
1305
|
+
return {
|
|
1306
|
+
groups: validated,
|
|
1307
|
+
excluded
|
|
1308
|
+
};
|
|
1309
|
+
} catch (error) {
|
|
1310
|
+
debug("generateGroups error: %s", error instanceof Error ? error.message : String(error));
|
|
1311
|
+
throw mapGroqError(error);
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
function validateGroups(groups, allFiles) {
|
|
1315
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1316
|
+
const validated = [];
|
|
1317
|
+
for (const group of groups) {
|
|
1318
|
+
const uniqueFiles = group.files.filter((f) => {
|
|
1319
|
+
if (seen.has(f)) return false;
|
|
1320
|
+
seen.add(f);
|
|
1321
|
+
return true;
|
|
1322
|
+
});
|
|
1323
|
+
if (uniqueFiles.length > 0) validated.push({
|
|
1324
|
+
name: group.name,
|
|
1325
|
+
description: group.description,
|
|
1326
|
+
files: uniqueFiles
|
|
1327
|
+
});
|
|
1328
|
+
}
|
|
1329
|
+
const ungrouped = allFiles.filter((f) => !seen.has(f.path));
|
|
1330
|
+
if (ungrouped.length > 0) {
|
|
1331
|
+
debug("validateGroups: %d ungrouped files added to 'Other changes'", ungrouped.length);
|
|
1332
|
+
validated.push({
|
|
1333
|
+
name: "Other changes",
|
|
1334
|
+
description: "Miscellaneous changes that did not fit into other groups",
|
|
1335
|
+
files: ungrouped.map((f) => f.path)
|
|
1336
|
+
});
|
|
1337
|
+
}
|
|
1338
|
+
return validated;
|
|
1339
|
+
}
|
|
1340
|
+
//#endregion
|
|
1341
|
+
//#region src/ui/grouping.ts
|
|
1342
|
+
async function showGroupingConfirmation(groups, excluded) {
|
|
1343
|
+
debug("showGroupingConfirmation: %d groups, %d excluded", groups.length, excluded.length);
|
|
1344
|
+
const lines = [];
|
|
1345
|
+
for (const group of groups) {
|
|
1346
|
+
lines.push(bold(group.name));
|
|
1347
|
+
lines.push(` ${dim(group.description)}`);
|
|
1348
|
+
lines.push(` ${green(String(group.files.length))} file${group.files.length !== 1 ? "s" : ""}`);
|
|
1349
|
+
for (const file of group.files) lines.push(` ${dim("•")} ${file}`);
|
|
1350
|
+
lines.push("");
|
|
1351
|
+
}
|
|
1352
|
+
if (excluded.length > 0) {
|
|
1353
|
+
lines.push(dim(`Excluded: ${excluded.length} file${excluded.length !== 1 ? "s" : ""}`));
|
|
1354
|
+
for (const file of excluded) lines.push(` ${dim("•")} ${dim(file)}`);
|
|
1355
|
+
}
|
|
1356
|
+
p.note(lines.join("\n"), "Proposed commit groups");
|
|
1357
|
+
const choice = await p.select({
|
|
1358
|
+
message: "Proceed with these groupings?",
|
|
1359
|
+
options: [{
|
|
1360
|
+
label: "Yes, commit all groups",
|
|
1361
|
+
value: "yes"
|
|
1362
|
+
}, {
|
|
1363
|
+
label: "No, cancel",
|
|
1364
|
+
value: "no"
|
|
1365
|
+
}]
|
|
1366
|
+
});
|
|
1367
|
+
if (p.isCancel(choice) || choice === "no") {
|
|
1368
|
+
debug("showGroupingConfirmation: user cancelled");
|
|
1369
|
+
return false;
|
|
1370
|
+
}
|
|
1371
|
+
debug("showGroupingConfirmation: user confirmed");
|
|
1372
|
+
return true;
|
|
1373
|
+
}
|
|
1374
|
+
function showGroupProgress(current, total, groupName) {
|
|
1375
|
+
p.log.info(`Commit group ${current} of ${total}: ${cyan(`"${groupName}"`)}`);
|
|
1376
|
+
}
|
|
1377
|
+
//#endregion
|
|
1378
|
+
//#region src/commands/auto-group.ts
|
|
1379
|
+
async function runAutoGroupFlow(changedFiles, flags) {
|
|
1380
|
+
const { included, excluded } = filterExcludedFiles(changedFiles);
|
|
1381
|
+
try {
|
|
1382
|
+
await getApiKey();
|
|
1383
|
+
debug("API key found");
|
|
1384
|
+
} catch {
|
|
1385
|
+
debug("No API key found, prompting user");
|
|
1386
|
+
const { text: promptText } = await import("@clack/prompts");
|
|
1387
|
+
const key = await promptText({
|
|
1388
|
+
message: "Enter your Groq API key:",
|
|
1389
|
+
placeholder: "gsk_...",
|
|
1390
|
+
validate: (v) => v?.trim() ? void 0 : "API key is required"
|
|
1391
|
+
});
|
|
1392
|
+
if (isCancel(key)) {
|
|
1393
|
+
outro(dim("Cancelled."));
|
|
1394
|
+
return "cancelled";
|
|
1395
|
+
}
|
|
1396
|
+
await setConfigValue("GROQ_API_KEY", String(key).trim());
|
|
1397
|
+
debug("API key saved to config");
|
|
1398
|
+
}
|
|
1399
|
+
const s = spinner();
|
|
1400
|
+
s.start("Analyzing files...");
|
|
1401
|
+
const config = await readConfig();
|
|
1402
|
+
const validatedGroups = validateGroups((await generateGroups(included, await getApiKey(), config.model, config.timeout ? parseInt(config.timeout, 10) : void 0)).groups, included);
|
|
1403
|
+
s.stop("Files analyzed");
|
|
1404
|
+
if (!await showGroupingConfirmation(validatedGroups, excluded)) {
|
|
1405
|
+
outro(dim("Cancelled."));
|
|
1406
|
+
return "cancelled";
|
|
1407
|
+
}
|
|
1408
|
+
for (let i = 0; i < validatedGroups.length; i++) {
|
|
1409
|
+
const group = validatedGroups[i];
|
|
1410
|
+
showGroupProgress(i + 1, validatedGroups.length, group.name);
|
|
1411
|
+
await resetStaging();
|
|
1412
|
+
await stageFiles(group.files);
|
|
1413
|
+
const diffResult = await getStagedDiff();
|
|
1414
|
+
if (!diffResult || "excludedFiles" in diffResult) {
|
|
1415
|
+
log.warn(red(`No changes found for group "${group.name}" — skipping.`));
|
|
1416
|
+
continue;
|
|
1417
|
+
}
|
|
1418
|
+
s.start("Generating commit message...");
|
|
1419
|
+
let message;
|
|
1420
|
+
try {
|
|
1421
|
+
message = await generateMessage(diffResult.diff, flags.hint);
|
|
1422
|
+
} catch (err) {
|
|
1423
|
+
s.stop(red("Failed to generate message."));
|
|
1424
|
+
outro(red(err instanceof Error ? err.message : String(err)));
|
|
1425
|
+
return "cancelled";
|
|
1426
|
+
}
|
|
1427
|
+
s.stop("Message generated");
|
|
1428
|
+
const reviewed = await reviewCommitMessage(message);
|
|
1429
|
+
if (reviewed === null) {
|
|
1430
|
+
outro(dim("Cancelled."));
|
|
1431
|
+
return "cancelled";
|
|
1432
|
+
}
|
|
1433
|
+
message = reviewed;
|
|
1434
|
+
const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
|
|
1435
|
+
await saveCachedCommit(await getRepoRoot(), message);
|
|
1436
|
+
s.start("Committing...");
|
|
1437
|
+
const headBefore = await getHead();
|
|
1438
|
+
const commitResult = await attemptCommit(message);
|
|
1439
|
+
const headAfter = await getHead();
|
|
1440
|
+
if (commitResult.ok || headBefore !== headAfter) {
|
|
1441
|
+
s.stop("Committed successfully.");
|
|
1442
|
+
const checks = parseToolChecks(commitResult.stderr ?? "");
|
|
1443
|
+
if (checks.length > 0) {
|
|
1444
|
+
const lines = checks.map((c) => ` ${c.ok ? green("✓") : red("✗")} ${c.tool}`);
|
|
1445
|
+
log.info(lines.join("\n"));
|
|
1446
|
+
}
|
|
1447
|
+
continue;
|
|
1448
|
+
}
|
|
1449
|
+
s.stop("Commit failed.");
|
|
1450
|
+
const recoveryResult = await showRecoveryMenu(parseHookErrors(commitResult.stderr ?? ""), async () => (await attemptCommit(message)).ok, async (msg) => (await attemptCommitNoVerify(msg)).ok, async () => {
|
|
1451
|
+
await stageFiles(group.files);
|
|
1452
|
+
return (await attemptCommit(message)).ok;
|
|
1453
|
+
}, message, commitResult.stderr ?? "");
|
|
1454
|
+
if (recoveryResult === "committed") {
|
|
1455
|
+
if (i < validatedGroups.length - 1) continue;
|
|
1456
|
+
return "committed";
|
|
1457
|
+
}
|
|
1458
|
+
return recoveryResult;
|
|
1459
|
+
}
|
|
1460
|
+
outro(green("All groups committed."));
|
|
1461
|
+
return "committed";
|
|
1462
|
+
}
|
|
1463
|
+
async function generateMessage(diff, hint) {
|
|
1464
|
+
const config = await readConfig();
|
|
1465
|
+
const apiKey = await getApiKey();
|
|
1466
|
+
debug("Generating message with model:", config.model, "type:", config.type);
|
|
1467
|
+
return generateCommitMessage(diff, {
|
|
1468
|
+
apiKey,
|
|
1469
|
+
model: config.model,
|
|
1470
|
+
type: config.type,
|
|
1471
|
+
timeout: config.timeout ? parseInt(config.timeout, 10) : void 0,
|
|
1472
|
+
hint
|
|
1473
|
+
});
|
|
1474
|
+
}
|
|
1475
|
+
function buildExcludedFilesMessage(files) {
|
|
1476
|
+
const excludes = getDefaultExcludes();
|
|
1477
|
+
const isLockfile = (f) => excludes.some((pattern) => {
|
|
1478
|
+
if (pattern.endsWith(".lock") || pattern.endsWith(".json")) return f === pattern || f.endsWith(pattern.replace("*.", "."));
|
|
1479
|
+
return false;
|
|
1480
|
+
});
|
|
1481
|
+
if (files.every(isLockfile)) return "chore: update lockfile";
|
|
1482
|
+
return "chore: update generated files";
|
|
1483
|
+
}
|
|
1484
|
+
//#endregion
|
|
1485
|
+
//#region src/commands/commit.ts
|
|
1486
|
+
async function commitCommand(flags) {
|
|
1487
|
+
debug("commitCommand called", { flags });
|
|
1488
|
+
await assertGitRepo();
|
|
1489
|
+
if (flags.retry) {
|
|
1490
|
+
debug("Entering retry mode");
|
|
1491
|
+
const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
|
|
1492
|
+
const repoRoot = await getRepoRoot();
|
|
1493
|
+
debug("Repo root:", repoRoot);
|
|
1494
|
+
const cached = await loadCachedCommit(repoRoot);
|
|
1495
|
+
if (!cached) {
|
|
1496
|
+
debug("No cached commit found");
|
|
1497
|
+
outro(red("No cached commit message found. Run cmint without --retry first."));
|
|
1498
|
+
process.exit(1);
|
|
1499
|
+
}
|
|
1500
|
+
debug("Loaded cached message:", cached.message);
|
|
1501
|
+
intro("commit-mint — retry");
|
|
1502
|
+
const s = spinner();
|
|
1503
|
+
s.start("Retrying commit...");
|
|
1504
|
+
const result = await attemptCommit(cached.message);
|
|
1505
|
+
s.stop("Attempted commit");
|
|
1506
|
+
debug("Retry commit result:", result);
|
|
1507
|
+
if (result.ok) {
|
|
824
1508
|
const checks = parseToolChecks(result.stderr ?? "");
|
|
825
1509
|
if (checks.length > 0) {
|
|
826
1510
|
const lines = checks.map((c) => ` ${c.ok ? green("✓") : red("✗")} ${c.tool}`);
|
|
@@ -830,12 +1514,12 @@ async function commitCommand(flags) {
|
|
|
830
1514
|
} else {
|
|
831
1515
|
const errors = parseHookErrors(result.stderr ?? "");
|
|
832
1516
|
debug("Hook errors on retry:", errors.length);
|
|
833
|
-
await showRecoveryMenu(errors, async () => (await attemptCommit(cached.message)).ok, async (msg) => (await attemptCommitNoVerify(msg)).ok, async () => {
|
|
1517
|
+
if (await showRecoveryMenu(errors, async () => (await attemptCommit(cached.message)).ok, async (msg) => (await attemptCommitNoVerify(msg)).ok, async () => {
|
|
834
1518
|
await stageAll();
|
|
835
1519
|
return (await attemptCommit(cached.message)).ok;
|
|
836
|
-
}, cached.message, result.stderr ?? "");
|
|
1520
|
+
}, cached.message, result.stderr ?? "") === "cancelled") process.exit(1);
|
|
1521
|
+
return;
|
|
837
1522
|
}
|
|
838
|
-
return;
|
|
839
1523
|
}
|
|
840
1524
|
intro("commit-mint");
|
|
841
1525
|
const status = await getStatusShort();
|
|
@@ -844,7 +1528,7 @@ async function commitCommand(flags) {
|
|
|
844
1528
|
outro(dim("Nothing to commit."));
|
|
845
1529
|
return;
|
|
846
1530
|
}
|
|
847
|
-
|
|
1531
|
+
let changedFiles = await getChangedFiles();
|
|
848
1532
|
debug("Changed files:", changedFiles.length);
|
|
849
1533
|
const s = spinner();
|
|
850
1534
|
try {
|
|
@@ -857,14 +1541,48 @@ async function commitCommand(flags) {
|
|
|
857
1541
|
await stageFiles([changedFiles[0].path]);
|
|
858
1542
|
s.stop("File staged");
|
|
859
1543
|
} else {
|
|
860
|
-
const
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
1544
|
+
const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
|
|
1545
|
+
const lintStagedAvailable = await hasLintStagedConfig(await getRepoRoot());
|
|
1546
|
+
debug("lint-staged available:", lintStagedAvailable);
|
|
1547
|
+
let stagingResult = null;
|
|
1548
|
+
let filesToStage = [];
|
|
1549
|
+
let stageAllFlag = false;
|
|
1550
|
+
while (true) {
|
|
1551
|
+
stagingResult = await showStagingMenu(changedFiles, lintStagedAvailable);
|
|
1552
|
+
if (stagingResult === "autogroup") {
|
|
1553
|
+
if (flags.message) {
|
|
1554
|
+
outro(red("--message flag is not compatible with auto-group mode."));
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1557
|
+
if (await runAutoGroupFlow(changedFiles, flags) !== "committed") process.exit(1);
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
if (stagingResult === "lint-staged") {
|
|
1561
|
+
await stageAll();
|
|
1562
|
+
const lsSpinner = spinner();
|
|
1563
|
+
lsSpinner.start("Running lint-staged checks...");
|
|
1564
|
+
const lsResult = await runLintStaged();
|
|
1565
|
+
if (lsResult.ok) {
|
|
1566
|
+
lsSpinner.stop("All lint-staged checks passed");
|
|
1567
|
+
if (lsResult.stdout.trim()) log.info(dim(lsResult.stdout.trim()));
|
|
1568
|
+
} else {
|
|
1569
|
+
lsSpinner.stop("Lint-staged checks failed");
|
|
1570
|
+
log.info(lsResult.stderr?.trim() || lsResult.stdout?.trim() || "Unknown error");
|
|
1571
|
+
}
|
|
1572
|
+
changedFiles = await getChangedFiles();
|
|
1573
|
+
continue;
|
|
1574
|
+
}
|
|
1575
|
+
if (!stagingResult) {
|
|
1576
|
+
outro(dim("Cancelled."));
|
|
1577
|
+
return;
|
|
1578
|
+
}
|
|
1579
|
+
filesToStage = stagingResult.files;
|
|
1580
|
+
stageAllFlag = stagingResult.all;
|
|
1581
|
+
break;
|
|
864
1582
|
}
|
|
865
|
-
s.start(`Staging ${
|
|
866
|
-
if (
|
|
867
|
-
else await stageFiles(
|
|
1583
|
+
s.start(`Staging ${filesToStage.length} file${filesToStage.length !== 1 ? "s" : ""}...`);
|
|
1584
|
+
if (stageAllFlag) await stageAll();
|
|
1585
|
+
else await stageFiles(filesToStage);
|
|
868
1586
|
s.stop("Files staged");
|
|
869
1587
|
}
|
|
870
1588
|
} catch (err) {
|
|
@@ -896,10 +1614,10 @@ async function commitCommand(flags) {
|
|
|
896
1614
|
return;
|
|
897
1615
|
}
|
|
898
1616
|
s.stop("Commit failed.");
|
|
899
|
-
await showRecoveryMenu(parseHookErrors(result.stderr ?? ""), async () => (await attemptCommit(message)).ok, async (msg) => (await attemptCommitNoVerify(msg)).ok, async () => {
|
|
1617
|
+
if (await showRecoveryMenu(parseHookErrors(result.stderr ?? ""), async () => (await attemptCommit(message)).ok, async (msg) => (await attemptCommitNoVerify(msg)).ok, async () => {
|
|
900
1618
|
await stageAll();
|
|
901
1619
|
return (await attemptCommit(message)).ok;
|
|
902
|
-
}, message, result.stderr ?? "");
|
|
1620
|
+
}, message, result.stderr ?? "") === "cancelled") process.exit(1);
|
|
903
1621
|
return;
|
|
904
1622
|
}
|
|
905
1623
|
debug("Staged files:", diffResult.files);
|
|
@@ -919,7 +1637,7 @@ async function commitCommand(flags) {
|
|
|
919
1637
|
const key = await promptText({
|
|
920
1638
|
message: "Enter your Groq API key:",
|
|
921
1639
|
placeholder: "gsk_...",
|
|
922
|
-
validate: (v) => v
|
|
1640
|
+
validate: (v) => v?.trim() ? void 0 : "API key is required"
|
|
923
1641
|
});
|
|
924
1642
|
if (isCancel(key)) {
|
|
925
1643
|
outro(dim("Cancelled."));
|
|
@@ -942,43 +1660,12 @@ async function commitCommand(flags) {
|
|
|
942
1660
|
}
|
|
943
1661
|
s.stop("Message generated");
|
|
944
1662
|
}
|
|
945
|
-
const
|
|
946
|
-
|
|
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");
|
|
1663
|
+
const reviewed = await reviewCommitMessage(message);
|
|
1664
|
+
if (reviewed === null) {
|
|
965
1665
|
outro(dim("Cancelled."));
|
|
966
1666
|
return;
|
|
967
1667
|
}
|
|
968
|
-
|
|
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
|
-
}
|
|
1668
|
+
message = reviewed;
|
|
982
1669
|
const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
|
|
983
1670
|
const repoRoot = await getRepoRoot();
|
|
984
1671
|
await saveCachedCommit(repoRoot, message);
|
|
@@ -1004,35 +1691,14 @@ async function commitCommand(flags) {
|
|
|
1004
1691
|
debug("Commit failed, showing recovery menu");
|
|
1005
1692
|
const errors = parseHookErrors(result.stderr ?? "");
|
|
1006
1693
|
debug("Parsed hook errors:", errors.length, "errors");
|
|
1007
|
-
await showRecoveryMenu(errors, async () => {
|
|
1694
|
+
if (await showRecoveryMenu(errors, async () => {
|
|
1008
1695
|
return (await attemptCommit(message)).ok;
|
|
1009
1696
|
}, async (msg) => {
|
|
1010
1697
|
return (await attemptCommitNoVerify(msg)).ok;
|
|
1011
1698
|
}, async () => {
|
|
1012
1699
|
await stageAll();
|
|
1013
1700
|
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";
|
|
1701
|
+
}, message, result.stderr ?? "") === "cancelled") process.exit(1);
|
|
1036
1702
|
}
|
|
1037
1703
|
//#endregion
|
|
1038
1704
|
//#region src/commands/config.ts
|
|
@@ -1090,6 +1756,12 @@ cli({
|
|
|
1090
1756
|
description: "Add context hint for AI commit message generation",
|
|
1091
1757
|
alias: "H"
|
|
1092
1758
|
},
|
|
1759
|
+
review: {
|
|
1760
|
+
type: Boolean,
|
|
1761
|
+
description: "Review staged changes with a coding model",
|
|
1762
|
+
alias: "R",
|
|
1763
|
+
default: false
|
|
1764
|
+
},
|
|
1093
1765
|
debug: {
|
|
1094
1766
|
type: Boolean,
|
|
1095
1767
|
description: "Enable debug output",
|
|
@@ -1100,7 +1772,8 @@ cli({
|
|
|
1100
1772
|
commands: [configCommand]
|
|
1101
1773
|
}, (argv) => {
|
|
1102
1774
|
setDebug(argv.flags.debug);
|
|
1103
|
-
|
|
1775
|
+
if (argv.flags.review) reviewCommand();
|
|
1776
|
+
else commitCommand(argv.flags);
|
|
1104
1777
|
});
|
|
1105
1778
|
//#endregion
|
|
1106
1779
|
export {};
|