@kyubiware/commit-mint 0.4.1 → 0.5.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 +173 -83
- package/dist/cli.mjs +1266 -646
- package/dist/cli.mjs.map +1 -1
- package/package.json +6 -11
package/dist/cli.mjs
CHANGED
|
@@ -5,12 +5,14 @@ import { intro, isCancel, log, note, outro, select, spinner } from "@clack/promp
|
|
|
5
5
|
import { bold, cyan, dim, green, red, yellow } from "kolorist";
|
|
6
6
|
import { access, constants, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
7
7
|
import os from "node:os";
|
|
8
|
-
import { join } from "node:path";
|
|
8
|
+
import { extname, join } from "node:path";
|
|
9
9
|
import ini from "ini";
|
|
10
|
+
import Groq from "groq-sdk";
|
|
10
11
|
import { execa } from "execa";
|
|
11
12
|
import { spawn } from "node:child_process";
|
|
12
|
-
import Groq from "groq-sdk";
|
|
13
13
|
import { createHash } from "node:crypto";
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import picomatch from "picomatch";
|
|
14
16
|
//#region \0rolldown/runtime.js
|
|
15
17
|
var __defProp = Object.defineProperty;
|
|
16
18
|
var __exportAll = (all, no_symbols) => {
|
|
@@ -26,8 +28,8 @@ var __exportAll = (all, no_symbols) => {
|
|
|
26
28
|
//#region package.json
|
|
27
29
|
var package_default = {
|
|
28
30
|
name: "@kyubiware/commit-mint",
|
|
29
|
-
version: "0.
|
|
30
|
-
description: "A commit tool that actually handles hook failures",
|
|
31
|
+
version: "0.5.0",
|
|
32
|
+
description: "🌿 A commit tool that actually handles hook failures",
|
|
31
33
|
type: "module",
|
|
32
34
|
bin: { "cmint": "./dist/cli.mjs" },
|
|
33
35
|
files: ["dist"],
|
|
@@ -45,16 +47,13 @@ var package_default = {
|
|
|
45
47
|
"release:patch": "bash scripts/release.sh patch",
|
|
46
48
|
"release:minor": "bash scripts/release.sh minor",
|
|
47
49
|
"release:major": "bash scripts/release.sh major",
|
|
48
|
-
"prepublishOnly": "npm run build"
|
|
49
|
-
"prepare": "simple-git-hooks"
|
|
50
|
+
"prepublishOnly": "npm run build"
|
|
50
51
|
},
|
|
51
|
-
"simple-git-hooks": { "pre-commit": "npx lint-staged" },
|
|
52
52
|
keywords: [
|
|
53
53
|
"git",
|
|
54
54
|
"commit",
|
|
55
55
|
"hooks",
|
|
56
56
|
"pre-commit",
|
|
57
|
-
"lint-staged",
|
|
58
57
|
"ai",
|
|
59
58
|
"groq",
|
|
60
59
|
"conventional-commits",
|
|
@@ -72,14 +71,14 @@ var package_default = {
|
|
|
72
71
|
"execa": "^9.6.0",
|
|
73
72
|
"groq-sdk": "^0.32.0",
|
|
74
73
|
"ini": "^5.0.0",
|
|
75
|
-
"
|
|
74
|
+
"jiti": "^2.7.0",
|
|
75
|
+
"kolorist": "^1.8.0",
|
|
76
|
+
"picomatch": "^4.0.4"
|
|
76
77
|
},
|
|
77
78
|
devDependencies: {
|
|
78
79
|
"@biomejs/biome": "^2.0.0",
|
|
79
80
|
"@types/ini": "^4.1.1",
|
|
80
81
|
"@vitest/coverage-v8": "^3.2.4",
|
|
81
|
-
"lint-staged": "^17.0.5",
|
|
82
|
-
"simple-git-hooks": "^2.13.1",
|
|
83
82
|
"tsdown": "^0.22.0",
|
|
84
83
|
"tsx": "^4.22.2",
|
|
85
84
|
"typescript": "^5.9.2",
|
|
@@ -98,9 +97,52 @@ function debug(...args) {
|
|
|
98
97
|
console.error(dim(`[debug ${timestamp}]`), ...args);
|
|
99
98
|
}
|
|
100
99
|
//#endregion
|
|
100
|
+
//#region src/services/provider.ts
|
|
101
|
+
const PROVIDER_CONFIGS = {
|
|
102
|
+
groq: {
|
|
103
|
+
baseURL: "https://api.groq.com",
|
|
104
|
+
defaultModel: "openai/gpt-oss-20b"
|
|
105
|
+
},
|
|
106
|
+
cerebras: {
|
|
107
|
+
baseURL: "https://api.cerebras.ai",
|
|
108
|
+
defaultModel: "gpt-oss-120b"
|
|
109
|
+
},
|
|
110
|
+
mistral: {
|
|
111
|
+
baseURL: "https://api.mistral.ai",
|
|
112
|
+
defaultModel: "mistral-small"
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
const ALLOWED_PROVIDERS = Object.keys(PROVIDER_CONFIGS);
|
|
116
|
+
const PROVIDER_ENV_KEYS = {
|
|
117
|
+
groq: "GROQ_API_KEY",
|
|
118
|
+
cerebras: "CEREBRAS_API_KEY",
|
|
119
|
+
mistral: "MISTRAL_API_KEY"
|
|
120
|
+
};
|
|
121
|
+
function formatProviderName(provider) {
|
|
122
|
+
return provider.charAt(0).toUpperCase() + provider.slice(1);
|
|
123
|
+
}
|
|
124
|
+
function isValidProvider(name) {
|
|
125
|
+
return ALLOWED_PROVIDERS.includes(name);
|
|
126
|
+
}
|
|
127
|
+
function createProvider(options) {
|
|
128
|
+
if (!isValidProvider(options.provider)) throw new Error(`Invalid provider "${options.provider}". Allowed values: ${ALLOWED_PROVIDERS.join(", ")}`);
|
|
129
|
+
const providerConfig = PROVIDER_CONFIGS[options.provider];
|
|
130
|
+
const model = options.modelOverride ?? providerConfig.defaultModel;
|
|
131
|
+
const baseURL = options.baseURLOverride ?? providerConfig.baseURL;
|
|
132
|
+
return {
|
|
133
|
+
client: new Groq({
|
|
134
|
+
apiKey: options.apiKey,
|
|
135
|
+
baseURL,
|
|
136
|
+
timeout: options.timeout
|
|
137
|
+
}),
|
|
138
|
+
model
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
//#endregion
|
|
101
142
|
//#region src/services/config.ts
|
|
102
143
|
const CONFIG_PATH = join(os.homedir(), ".commit-mint");
|
|
103
144
|
const defaults = {
|
|
145
|
+
provider: "groq",
|
|
104
146
|
model: "openai/gpt-oss-20b",
|
|
105
147
|
locale: "en",
|
|
106
148
|
"max-length": "100",
|
|
@@ -128,25 +170,250 @@ async function writeConfig(updates) {
|
|
|
128
170
|
Object.assign(existing, updates);
|
|
129
171
|
await writeFile(CONFIG_PATH, ini.stringify(existing), "utf8");
|
|
130
172
|
}
|
|
131
|
-
async function getConfigValue(key) {
|
|
132
|
-
return (await readConfig())[key];
|
|
133
|
-
}
|
|
134
173
|
async function setConfigValue(key, value) {
|
|
135
174
|
await writeConfig({ [key]: value });
|
|
136
175
|
}
|
|
137
|
-
async function
|
|
138
|
-
const
|
|
139
|
-
if (
|
|
140
|
-
|
|
141
|
-
|
|
176
|
+
async function getProviderApiKey(provider) {
|
|
177
|
+
const envVar = PROVIDER_ENV_KEYS[provider];
|
|
178
|
+
if (envVar) {
|
|
179
|
+
const envValue = process.env[envVar];
|
|
180
|
+
if (envValue) {
|
|
181
|
+
debug("getProviderApiKey(%s): found in env", provider);
|
|
182
|
+
return envValue;
|
|
183
|
+
}
|
|
142
184
|
}
|
|
143
185
|
const config = await readConfig();
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
186
|
+
const configKey = PROVIDER_ENV_KEYS[provider];
|
|
187
|
+
if (configKey && config[configKey]) {
|
|
188
|
+
debug("getProviderApiKey(%s): found in config", provider);
|
|
189
|
+
return config[configKey];
|
|
190
|
+
}
|
|
191
|
+
debug("getProviderApiKey(%s): not found", provider);
|
|
192
|
+
throw new Error(`Please set your ${formatProviderName(provider)} API key via \`cmint config set ${envVar}=<your token>\``);
|
|
193
|
+
}
|
|
194
|
+
function getModelForProvider(config, provider, defaultModel) {
|
|
195
|
+
return config[`model_${provider}`] ?? config.model ?? defaultModel;
|
|
196
|
+
}
|
|
197
|
+
//#endregion
|
|
198
|
+
//#region src/services/hooks.ts
|
|
199
|
+
/**
|
|
200
|
+
* Parse git hook error output into structured, human-readable errors.
|
|
201
|
+
* Handles output from lint-staged, biome, eslint, tsc, vitest, jest.
|
|
202
|
+
*/
|
|
203
|
+
function parseHookErrors(stderr) {
|
|
204
|
+
if (!stderr) return [];
|
|
205
|
+
debug("parseHookErrors: stderr length=%d", stderr.length);
|
|
206
|
+
const errors = [];
|
|
207
|
+
if (stderr.includes("lint-staged") || stderr.includes("[FAILED]")) errors.push(...parseLintStagedErrors(stderr));
|
|
208
|
+
if (stderr.includes("biome") || stderr.includes("Biome")) errors.push(...parseBiomeErrors(stderr));
|
|
209
|
+
if (stderr.includes("error TS") || stderr.includes("tsc")) errors.push(...parseTscErrors(stderr));
|
|
210
|
+
if (stderr.includes("vitest") || stderr.includes("jest") || stderr.includes("FAIL") || stderr.includes("test failed")) errors.push(...parseTestErrors(stderr));
|
|
211
|
+
if (stderr.includes("eslint") || stderr.includes("ESLint")) errors.push(...parseEslintErrors(stderr));
|
|
212
|
+
if (errors.length === 0) {
|
|
213
|
+
debug("parseHookErrors: no patterns matched, using raw fallback");
|
|
214
|
+
errors.push({
|
|
215
|
+
tool: "git hooks",
|
|
216
|
+
message: stderr.trim(),
|
|
217
|
+
raw: stderr
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
debug("parseHookErrors: found %d errors", errors.length);
|
|
221
|
+
return errors;
|
|
222
|
+
}
|
|
223
|
+
function parseLintStagedErrors(output) {
|
|
224
|
+
const errors = [];
|
|
225
|
+
for (const match of output.matchAll(/\[FAILED\]\s+(.+?)\s+\[FAILED\]/g)) {
|
|
226
|
+
const task = match[1].trim();
|
|
227
|
+
errors.push({
|
|
228
|
+
tool: "lint-staged",
|
|
229
|
+
message: `Task failed: ${task}`,
|
|
230
|
+
raw: match[0]
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
return errors;
|
|
234
|
+
}
|
|
235
|
+
function parseBiomeErrors(output) {
|
|
236
|
+
const errors = [];
|
|
237
|
+
for (const match of output.matchAll(/^(.+?):(\d+):(\d+)\s+(.+)$/gm)) errors.push({
|
|
238
|
+
tool: "biome",
|
|
239
|
+
message: `${match[1]}:${match[2]}:${match[3]} — ${match[4]}`,
|
|
240
|
+
raw: match[0]
|
|
241
|
+
});
|
|
242
|
+
if (errors.length === 0 && output.includes("biome")) errors.push({
|
|
243
|
+
tool: "biome",
|
|
244
|
+
message: "Biome check failed. See raw output for details.",
|
|
245
|
+
raw: output
|
|
246
|
+
});
|
|
247
|
+
return errors;
|
|
248
|
+
}
|
|
249
|
+
function parseTscErrors(output) {
|
|
250
|
+
const errors = [];
|
|
251
|
+
for (const match of output.matchAll(/^(.+?)\((\d+),(\d+)\):\s+error\s+(TS\d+):\s+(.+)$/gm)) errors.push({
|
|
252
|
+
tool: "tsc",
|
|
253
|
+
message: `${match[1]}:${match[2]}:${match[3]} — ${match[4]}: ${match[5]}`,
|
|
254
|
+
raw: match[0]
|
|
255
|
+
});
|
|
256
|
+
return errors;
|
|
257
|
+
}
|
|
258
|
+
function parseTestErrors(output) {
|
|
259
|
+
const errors = [];
|
|
260
|
+
const match = /FAIL\s+(.+\.(test|spec)\..+)/.exec(output);
|
|
261
|
+
if (match) errors.push({
|
|
262
|
+
tool: output.includes("vitest") ? "vitest" : "jest",
|
|
263
|
+
message: `Test file failed: ${match[1]}`,
|
|
264
|
+
raw: output
|
|
265
|
+
});
|
|
266
|
+
if (errors.length === 0 && (output.includes("vitest") || output.includes("jest"))) errors.push({
|
|
267
|
+
tool: output.includes("vitest") ? "vitest" : "jest",
|
|
268
|
+
message: "Tests failed. See raw output for details.",
|
|
269
|
+
raw: output
|
|
270
|
+
});
|
|
271
|
+
return errors;
|
|
272
|
+
}
|
|
273
|
+
function parseEslintErrors(output) {
|
|
274
|
+
const errors = [];
|
|
275
|
+
const lines = output.split("\n");
|
|
276
|
+
let currentFile = "";
|
|
277
|
+
for (const line of lines) {
|
|
278
|
+
if (!/^\s/.test(line) && line.includes("/")) {
|
|
279
|
+
currentFile = line.trim();
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const match = line.match(/^\s*(\d+):(\d+)\s+(error|warning)\s+(.+)\s{2,}(\S+)\s*$/);
|
|
283
|
+
if (match) {
|
|
284
|
+
const [, lineNum, col, severity, message, rule] = match;
|
|
285
|
+
const file = currentFile || "unknown";
|
|
286
|
+
errors.push({
|
|
287
|
+
tool: "eslint",
|
|
288
|
+
message: `${file}:${lineNum}:${col} ${severity}: ${message} (${rule})`,
|
|
289
|
+
raw: line.trim()
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return errors;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Parse lint-staged/hook stderr output to discover which tools ran
|
|
297
|
+
* and whether they succeeded. Used for clean post-commit summary.
|
|
298
|
+
*/
|
|
299
|
+
function parseToolChecks(stderr) {
|
|
300
|
+
if (!stderr) return [];
|
|
301
|
+
const checks = [];
|
|
302
|
+
for (const match of stderr.matchAll(/\[(COMPLETED|FAILED)\]\s+(.+)/g)) {
|
|
303
|
+
const status = match[1];
|
|
304
|
+
const command = match[2].trim();
|
|
305
|
+
if (isLintStagedMeta(command)) continue;
|
|
306
|
+
const tool = extractToolName(command);
|
|
307
|
+
if (!tool) continue;
|
|
308
|
+
checks.push({
|
|
309
|
+
tool,
|
|
310
|
+
ok: status === "COMPLETED"
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
const seen = /* @__PURE__ */ new Map();
|
|
314
|
+
for (const c of checks) seen.set(c.tool, c);
|
|
315
|
+
return [...seen.values()];
|
|
316
|
+
}
|
|
317
|
+
/** Heuristic: skip lint-staged internal metadata lines */
|
|
318
|
+
function isLintStagedMeta(command) {
|
|
319
|
+
if (/[*{}[\]]/.test(command)) return true;
|
|
320
|
+
if (/\s[-–—]\s(\d+\s)?files?$/.test(command)) return true;
|
|
321
|
+
if (/\s[-–—]\sno\s files$/.test(command)) return true;
|
|
322
|
+
if (/^(Running tasks|Applying modifications|Cleaning up|Backing up|Backed up|Updating Git)/.test(command)) return true;
|
|
323
|
+
if (/\.{3}$/.test(command)) return true;
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
/** Extract a display-friendly tool name from a lint-staged command */
|
|
327
|
+
function extractToolName(command) {
|
|
328
|
+
const unwrapped = unwrapShC(command);
|
|
329
|
+
if (unwrapped !== null) command = unwrapped;
|
|
330
|
+
command = findMeaningfulCommand(command);
|
|
331
|
+
return parseToolFromTokens(command.split(/\s+/));
|
|
332
|
+
}
|
|
333
|
+
/** Map common script names to their underlying tool */
|
|
334
|
+
const SCRIPT_MAP = {
|
|
335
|
+
typecheck: "tsc",
|
|
336
|
+
lint: "eslint",
|
|
337
|
+
format: "prettier"
|
|
338
|
+
};
|
|
339
|
+
/** Package managers that use [run|exec] <script|tool> pattern */
|
|
340
|
+
const PKG_MANAGERS = [
|
|
341
|
+
"npm",
|
|
342
|
+
"yarn",
|
|
343
|
+
"pnpm",
|
|
344
|
+
"bun"
|
|
345
|
+
];
|
|
346
|
+
/** Parse tool name from a tokenized command */
|
|
347
|
+
function parseToolFromTokens(tokens) {
|
|
348
|
+
const first = tokens[0];
|
|
349
|
+
if (first === "sh" || first === "bash" || first === "zsh") return null;
|
|
350
|
+
if (PKG_MANAGERS.includes(first)) return parsePackageManagerTool(tokens);
|
|
351
|
+
if (first === "npx") return tokens[1] ?? null;
|
|
352
|
+
if (first === "uv") return parseUvTool(tokens);
|
|
353
|
+
return first;
|
|
354
|
+
}
|
|
355
|
+
/** Extract tool name from npm/yarn/pnpm/bun commands */
|
|
356
|
+
function parsePackageManagerTool(tokens) {
|
|
357
|
+
const sub = tokens[1];
|
|
358
|
+
if (sub === "exec") return tokens[2] ?? null;
|
|
359
|
+
const script = tokens[sub === "run" ? 2 : 1];
|
|
360
|
+
if (!script) return null;
|
|
361
|
+
return SCRIPT_MAP[script] ?? script;
|
|
362
|
+
}
|
|
363
|
+
/** Extract tool name from uv commands */
|
|
364
|
+
function parseUvTool(tokens) {
|
|
365
|
+
if (tokens[1] === "run") return tokens[2] ?? null;
|
|
366
|
+
if (tokens[1] === "tool" && tokens[2] === "run") return tokens[3] ?? null;
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
/** Unwrap sh -c 'command' or sh -c "command" wrappers */
|
|
370
|
+
function unwrapShC(command) {
|
|
371
|
+
const quoted = command.match(/^(?:sh|bash|zsh)\s+-c\s+(['"])([\s\S]*)\1$/);
|
|
372
|
+
if (quoted) return quoted[2];
|
|
373
|
+
const bare = command.match(/^(?:sh|bash|zsh)\s+-c\s+(\S+)$/);
|
|
374
|
+
if (bare) return bare[1];
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
/** Find the meaningful command in a && chain, skipping cd segments */
|
|
378
|
+
function findMeaningfulCommand(command) {
|
|
379
|
+
const segments = command.split(/\s*&&\s*/).map((s) => s.trim()).filter(Boolean);
|
|
380
|
+
for (const seg of segments) {
|
|
381
|
+
if (/^cd\s/.test(seg)) continue;
|
|
382
|
+
return seg;
|
|
147
383
|
}
|
|
148
|
-
|
|
149
|
-
|
|
384
|
+
return segments[segments.length - 1] || command;
|
|
385
|
+
}
|
|
386
|
+
//#endregion
|
|
387
|
+
//#region src/services/hook-progress.ts
|
|
388
|
+
const ansiRe = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
|
|
389
|
+
function createStderrParser() {
|
|
390
|
+
let buffer = "";
|
|
391
|
+
return (chunk) => {
|
|
392
|
+
buffer += chunk;
|
|
393
|
+
const steps = [];
|
|
394
|
+
const lines = buffer.split("\n");
|
|
395
|
+
buffer = lines.pop() ?? "";
|
|
396
|
+
for (const line of lines) {
|
|
397
|
+
const match = line.replace(ansiRe, "").match(/\[(STARTED|COMPLETED|FAILED)\]\s+(.+)/);
|
|
398
|
+
if (!match) continue;
|
|
399
|
+
const status = match[1].toLowerCase();
|
|
400
|
+
const command = match[2].trim();
|
|
401
|
+
if (isLintStagedMeta(command)) continue;
|
|
402
|
+
const tool = extractToolName(command) ?? command;
|
|
403
|
+
steps.push({
|
|
404
|
+
status,
|
|
405
|
+
command,
|
|
406
|
+
tool
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
return steps;
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
function createProgressHandler(s) {
|
|
413
|
+
return (step) => {
|
|
414
|
+
if (step.status === "started") s.message(step.command);
|
|
415
|
+
else if (step.status === "failed") s.message(step.command);
|
|
416
|
+
};
|
|
150
417
|
}
|
|
151
418
|
//#endregion
|
|
152
419
|
//#region src/services/git.ts
|
|
@@ -263,7 +530,7 @@ async function stageFiles(paths) {
|
|
|
263
530
|
debug("stageFiles:", paths);
|
|
264
531
|
await execa("git", ["add", ...paths]);
|
|
265
532
|
}
|
|
266
|
-
async function attemptCommit(message, extraArgs = []) {
|
|
533
|
+
async function attemptCommit(message, extraArgs = [], onProgress) {
|
|
267
534
|
debug("attemptCommit:", message, extraArgs.length ? extraArgs : "(no extra args)");
|
|
268
535
|
try {
|
|
269
536
|
const subprocess = execa("git", [
|
|
@@ -273,8 +540,11 @@ async function attemptCommit(message, extraArgs = []) {
|
|
|
273
540
|
...extraArgs
|
|
274
541
|
]);
|
|
275
542
|
const stderrChunks = [];
|
|
543
|
+
const parser = onProgress ? createStderrParser() : null;
|
|
276
544
|
subprocess.stderr?.on("data", (chunk) => {
|
|
277
|
-
|
|
545
|
+
const text = chunk.toString();
|
|
546
|
+
stderrChunks.push(text);
|
|
547
|
+
if (parser && onProgress) for (const step of parser(text)) onProgress(step);
|
|
278
548
|
});
|
|
279
549
|
await subprocess;
|
|
280
550
|
debug("attemptCommit: success");
|
|
@@ -292,411 +562,63 @@ async function attemptCommit(message, extraArgs = []) {
|
|
|
292
562
|
};
|
|
293
563
|
}
|
|
294
564
|
}
|
|
295
|
-
async function attemptCommitNoVerify(message) {
|
|
565
|
+
async function attemptCommitNoVerify(message, onProgress) {
|
|
296
566
|
debug("attemptCommitNoVerify:", message);
|
|
297
|
-
return attemptCommit(message, ["--no-verify"]);
|
|
567
|
+
return attemptCommit(message, ["--no-verify"], onProgress);
|
|
298
568
|
}
|
|
299
569
|
//#endregion
|
|
300
|
-
//#region src/services/
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
570
|
+
//#region src/services/clipboard.ts
|
|
571
|
+
async function copyToClipboard(content) {
|
|
572
|
+
for (const [cmd, args] of [
|
|
573
|
+
["wl-copy", []],
|
|
574
|
+
["xclip", ["-selection", "clipboard"]],
|
|
575
|
+
["xsel", ["--clipboard", "--input"]],
|
|
576
|
+
["pbcopy", []]
|
|
577
|
+
]) try {
|
|
578
|
+
if (await new Promise((resolve) => {
|
|
579
|
+
const child = spawn(cmd, args, { stdio: [
|
|
580
|
+
"pipe",
|
|
581
|
+
"ignore",
|
|
582
|
+
"ignore"
|
|
583
|
+
] });
|
|
584
|
+
let settled = false;
|
|
585
|
+
const done = (result) => {
|
|
586
|
+
if (settled) return;
|
|
587
|
+
settled = true;
|
|
588
|
+
resolve(result);
|
|
589
|
+
};
|
|
590
|
+
child.on("error", () => done(false));
|
|
591
|
+
child.on("exit", (code) => {
|
|
592
|
+
if (code !== 0) done(false);
|
|
593
|
+
});
|
|
594
|
+
child.stdin.write(content, (err) => {
|
|
595
|
+
if (err) {
|
|
596
|
+
done(false);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
child.stdin.end(() => {
|
|
600
|
+
child.unref();
|
|
601
|
+
done(true);
|
|
602
|
+
});
|
|
603
|
+
});
|
|
604
|
+
})) return true;
|
|
605
|
+
} catch {}
|
|
606
|
+
return false;
|
|
336
607
|
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
});
|
|
344
|
-
if (
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
raw: output
|
|
348
|
-
});
|
|
349
|
-
return errors;
|
|
608
|
+
//#endregion
|
|
609
|
+
//#region src/services/ai.ts
|
|
610
|
+
const MAX_DIFF_CHARS = 2e4;
|
|
611
|
+
function mapGroqError(error, providerLabel) {
|
|
612
|
+
const label = providerLabel ?? "Groq";
|
|
613
|
+
if (error instanceof Groq.AuthenticationError) return /* @__PURE__ */ new Error(`Invalid API key for ${label}. Run: cmint config set ${label.toUpperCase()}_API_KEY=<key>`);
|
|
614
|
+
if (error instanceof Groq.RateLimitError) return /* @__PURE__ */ new Error(`Rate limited by ${label}. Please wait and try again.`);
|
|
615
|
+
if (error instanceof Groq.APIConnectionTimeoutError) return /* @__PURE__ */ new Error("Request timed out. Check your network or try a smaller diff.");
|
|
616
|
+
if (error instanceof Groq.APIError) return /* @__PURE__ */ new Error(`${label} API error: ${error.message}`);
|
|
617
|
+
return /* @__PURE__ */ new Error(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`);
|
|
350
618
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
tool: "tsc",
|
|
355
|
-
message: `${match[1]}:${match[2]}:${match[3]} — ${match[4]}: ${match[5]}`,
|
|
356
|
-
raw: match[0]
|
|
357
|
-
});
|
|
358
|
-
return errors;
|
|
359
|
-
}
|
|
360
|
-
function parseTestErrors(output) {
|
|
361
|
-
const errors = [];
|
|
362
|
-
const match = /FAIL\s+(.+\.(test|spec)\..+)/.exec(output);
|
|
363
|
-
if (match) errors.push({
|
|
364
|
-
tool: output.includes("vitest") ? "vitest" : "jest",
|
|
365
|
-
message: `Test file failed: ${match[1]}`,
|
|
366
|
-
raw: output
|
|
367
|
-
});
|
|
368
|
-
if (errors.length === 0 && (output.includes("vitest") || output.includes("jest"))) errors.push({
|
|
369
|
-
tool: output.includes("vitest") ? "vitest" : "jest",
|
|
370
|
-
message: "Tests failed. See raw output for details.",
|
|
371
|
-
raw: output
|
|
372
|
-
});
|
|
373
|
-
return errors;
|
|
374
|
-
}
|
|
375
|
-
function parseEslintErrors(output) {
|
|
376
|
-
const errors = [];
|
|
377
|
-
for (const match of output.matchAll(/^\s*\d+:(\d+)\s+(error|warning)\s+(.+?)\s+(.+?)$/gm)) errors.push({
|
|
378
|
-
tool: "eslint",
|
|
379
|
-
message: `${match[2]}: ${match[3]} (${match[4]})`,
|
|
380
|
-
raw: match[0]
|
|
381
|
-
});
|
|
382
|
-
return errors;
|
|
383
|
-
}
|
|
384
|
-
/**
|
|
385
|
-
* Parse lint-staged/hook stderr output to discover which tools ran
|
|
386
|
-
* and whether they succeeded. Used for clean post-commit summary.
|
|
387
|
-
*/
|
|
388
|
-
function parseToolChecks(stderr) {
|
|
389
|
-
if (!stderr) return [];
|
|
390
|
-
const checks = [];
|
|
391
|
-
for (const match of stderr.matchAll(/\[(COMPLETED|FAILED)\]\s+(.+)/g)) {
|
|
392
|
-
const status = match[1];
|
|
393
|
-
const command = match[2].trim();
|
|
394
|
-
if (isLintStagedMeta(command)) continue;
|
|
395
|
-
const tool = extractToolName(command);
|
|
396
|
-
if (!tool) continue;
|
|
397
|
-
checks.push({
|
|
398
|
-
tool,
|
|
399
|
-
ok: status === "COMPLETED"
|
|
400
|
-
});
|
|
401
|
-
}
|
|
402
|
-
const seen = /* @__PURE__ */ new Map();
|
|
403
|
-
for (const c of checks) seen.set(c.tool, c);
|
|
404
|
-
return [...seen.values()];
|
|
405
|
-
}
|
|
406
|
-
/** Heuristic: skip lint-staged internal metadata lines */
|
|
407
|
-
function isLintStagedMeta(command) {
|
|
408
|
-
if (/[*{}[\]]/.test(command)) return true;
|
|
409
|
-
if (/\s[-–—]\s(\d+\s)?files?$/.test(command)) return true;
|
|
410
|
-
if (/\s[-–—]\sno\s files$/.test(command)) return true;
|
|
411
|
-
if (/^(Running tasks|Applying modifications|Cleaning up|Backing up|Backed up|Updating Git)/.test(command)) return true;
|
|
412
|
-
if (/\.{3}$/.test(command)) return true;
|
|
413
|
-
return false;
|
|
414
|
-
}
|
|
415
|
-
/** Extract a display-friendly tool name from a lint-staged command */
|
|
416
|
-
function extractToolName(command) {
|
|
417
|
-
const tokens = command.split(/\s+/);
|
|
418
|
-
const first = tokens[0];
|
|
419
|
-
if ([
|
|
420
|
-
"npm",
|
|
421
|
-
"yarn",
|
|
422
|
-
"pnpm",
|
|
423
|
-
"bun"
|
|
424
|
-
].includes(first)) {
|
|
425
|
-
const script = tokens[tokens[1] === "run" ? 2 : 1];
|
|
426
|
-
if (!script) return null;
|
|
427
|
-
return {
|
|
428
|
-
typecheck: "tsc",
|
|
429
|
-
lint: "eslint",
|
|
430
|
-
format: "prettier"
|
|
431
|
-
}[script] ?? script;
|
|
432
|
-
}
|
|
433
|
-
if (first === "npx") return tokens[1] ?? null;
|
|
434
|
-
return first;
|
|
435
|
-
}
|
|
436
|
-
//#endregion
|
|
437
|
-
//#region src/services/lint-staged.ts
|
|
438
|
-
const CONFIG_FILES = [
|
|
439
|
-
".lintstagedrc",
|
|
440
|
-
".lintstagedrc.json",
|
|
441
|
-
".lintstagedrc.yaml",
|
|
442
|
-
".lintstagedrc.yml",
|
|
443
|
-
".lintstagedrc.mjs",
|
|
444
|
-
".lintstagedrc.cjs",
|
|
445
|
-
"lint-staged.config.mjs",
|
|
446
|
-
"lint-staged.config.cjs",
|
|
447
|
-
"lint-staged.config.js"
|
|
448
|
-
];
|
|
449
|
-
async function hasLintStagedConfig(repoRoot) {
|
|
450
|
-
debug("hasLintStagedConfig: checking in %s", repoRoot);
|
|
451
|
-
for (const file of CONFIG_FILES) {
|
|
452
|
-
const path = join(repoRoot, file);
|
|
453
|
-
try {
|
|
454
|
-
await access(path, constants.F_OK);
|
|
455
|
-
debug("hasLintStagedConfig: found %s", file);
|
|
456
|
-
return true;
|
|
457
|
-
} catch {}
|
|
458
|
-
}
|
|
459
|
-
const packageJsonPath = join(repoRoot, "package.json");
|
|
460
|
-
try {
|
|
461
|
-
const raw = await readFile(packageJsonPath, "utf8");
|
|
462
|
-
if ("lint-staged" in JSON.parse(raw)) {
|
|
463
|
-
debug("hasLintStagedConfig: found lint-staged in package.json");
|
|
464
|
-
return true;
|
|
465
|
-
}
|
|
466
|
-
} catch {}
|
|
467
|
-
debug("hasLintStagedConfig: no config found");
|
|
468
|
-
return false;
|
|
469
|
-
}
|
|
470
|
-
async function runLintStaged() {
|
|
471
|
-
debug("runLintStaged: starting npx lint-staged");
|
|
472
|
-
const { failed, stdout, stderr } = await execa("npx", ["lint-staged"], { reject: false });
|
|
473
|
-
debug("runLintStaged: finished, failed=%s", failed);
|
|
474
|
-
return {
|
|
475
|
-
ok: !failed,
|
|
476
|
-
stdout,
|
|
477
|
-
stderr
|
|
478
|
-
};
|
|
479
|
-
}
|
|
480
|
-
//#endregion
|
|
481
|
-
//#region src/services/clipboard.ts
|
|
482
|
-
async function copyToClipboard(content) {
|
|
483
|
-
for (const [cmd, args] of [
|
|
484
|
-
["wl-copy", []],
|
|
485
|
-
["xclip", ["-selection", "clipboard"]],
|
|
486
|
-
["xsel", ["--clipboard", "--input"]],
|
|
487
|
-
["pbcopy", []]
|
|
488
|
-
]) try {
|
|
489
|
-
if (await new Promise((resolve) => {
|
|
490
|
-
const child = spawn(cmd, args, { stdio: [
|
|
491
|
-
"pipe",
|
|
492
|
-
"ignore",
|
|
493
|
-
"ignore"
|
|
494
|
-
] });
|
|
495
|
-
let settled = false;
|
|
496
|
-
const done = (result) => {
|
|
497
|
-
if (settled) return;
|
|
498
|
-
settled = true;
|
|
499
|
-
resolve(result);
|
|
500
|
-
};
|
|
501
|
-
child.on("error", () => done(false));
|
|
502
|
-
child.on("exit", (code) => {
|
|
503
|
-
if (code !== 0) done(false);
|
|
504
|
-
});
|
|
505
|
-
child.stdin.write(content, (err) => {
|
|
506
|
-
if (err) {
|
|
507
|
-
done(false);
|
|
508
|
-
return;
|
|
509
|
-
}
|
|
510
|
-
child.stdin.end(() => {
|
|
511
|
-
child.unref();
|
|
512
|
-
done(true);
|
|
513
|
-
});
|
|
514
|
-
});
|
|
515
|
-
})) return true;
|
|
516
|
-
} catch {}
|
|
517
|
-
return false;
|
|
518
|
-
}
|
|
519
|
-
//#endregion
|
|
520
|
-
//#region src/ui/menu.ts
|
|
521
|
-
async function showStagingMenu(files, hasLintStaged) {
|
|
522
|
-
debug("showStagingMenu: %d files", files.length);
|
|
523
|
-
const statusLabel = (status) => {
|
|
524
|
-
switch (status) {
|
|
525
|
-
case "M": return yellow("M");
|
|
526
|
-
case "A": return green("A");
|
|
527
|
-
case "D": return red("D");
|
|
528
|
-
case "?":
|
|
529
|
-
case "??": return cyan("?");
|
|
530
|
-
default: return dim(status);
|
|
531
|
-
}
|
|
532
|
-
};
|
|
533
|
-
const sorted = [...files].sort((a, b) => {
|
|
534
|
-
if (a.staged !== b.staged) return a.staged ? -1 : 1;
|
|
535
|
-
return a.path.localeCompare(b.path);
|
|
536
|
-
});
|
|
537
|
-
const stagedFiles = sorted.filter((f) => f.staged);
|
|
538
|
-
const unstagedFiles = sorted.filter((f) => !f.staged);
|
|
539
|
-
const lines = [];
|
|
540
|
-
if (stagedFiles.length > 0) lines.push(green(bold("Staged:")), ...stagedFiles.map((f) => ` ${statusLabel(f.status)} ${f.path}`));
|
|
541
|
-
if (unstagedFiles.length > 0) {
|
|
542
|
-
if (lines.length > 0) lines.push("");
|
|
543
|
-
lines.push(yellow(bold("Changed:")), ...unstagedFiles.map((f) => ` ${statusLabel(f.status)} ${f.path}`));
|
|
544
|
-
}
|
|
545
|
-
p.note(lines.join("\n"), `${files.length} file${files.length !== 1 ? "s" : ""}`);
|
|
546
|
-
const choice = await p.select({
|
|
547
|
-
message: "Stage files for commit:",
|
|
548
|
-
options: [
|
|
549
|
-
{
|
|
550
|
-
label: "Auto-group into commits",
|
|
551
|
-
value: "autogroup",
|
|
552
|
-
hint: "LLM groups files into logical commits"
|
|
553
|
-
},
|
|
554
|
-
{
|
|
555
|
-
label: "Stage all files",
|
|
556
|
-
value: "all",
|
|
557
|
-
hint: `${files.length} file${files.length !== 1 ? "s" : ""}`
|
|
558
|
-
},
|
|
559
|
-
...hasLintStaged ? [{
|
|
560
|
-
label: "Run lint-staged checks",
|
|
561
|
-
value: "lint-staged",
|
|
562
|
-
hint: "Pre-flight checks on all changed files"
|
|
563
|
-
}] : [],
|
|
564
|
-
{
|
|
565
|
-
label: "Select files...",
|
|
566
|
-
value: "select"
|
|
567
|
-
},
|
|
568
|
-
{
|
|
569
|
-
label: "Cancel",
|
|
570
|
-
value: "cancel"
|
|
571
|
-
}
|
|
572
|
-
]
|
|
573
|
-
});
|
|
574
|
-
if (p.isCancel(choice) || choice === "cancel") return null;
|
|
575
|
-
if (choice === "autogroup") return "autogroup";
|
|
576
|
-
if (choice === "lint-staged") return "lint-staged";
|
|
577
|
-
if (choice === "all") return {
|
|
578
|
-
files: files.map((f) => f.path),
|
|
579
|
-
all: true
|
|
580
|
-
};
|
|
581
|
-
const selected = await p.multiselect({
|
|
582
|
-
message: "Select files to stage:",
|
|
583
|
-
options: sorted.map((f) => ({
|
|
584
|
-
label: `${statusLabel(f.status)} ${f.path}`,
|
|
585
|
-
value: f.path
|
|
586
|
-
})),
|
|
587
|
-
required: true
|
|
588
|
-
});
|
|
589
|
-
if (p.isCancel(selected)) return null;
|
|
590
|
-
return {
|
|
591
|
-
files: selected,
|
|
592
|
-
all: false
|
|
593
|
-
};
|
|
594
|
-
}
|
|
595
|
-
async function showRecoveryMenu(errors, onRetry, onSkipHooks, onRestage, message, rawStderr) {
|
|
596
|
-
debug("showRecoveryMenu: %d errors", errors.length);
|
|
597
|
-
let clipboardCopied = false;
|
|
598
|
-
let showNote = true;
|
|
599
|
-
while (true) {
|
|
600
|
-
if (showNote) {
|
|
601
|
-
p.note(errors.map((e) => ` ${red("•")} [${e.tool}] ${e.message}`).join("\n"), red(bold("Pre-commit hook failed")));
|
|
602
|
-
showNote = false;
|
|
603
|
-
}
|
|
604
|
-
const choice = await p.select({
|
|
605
|
-
message: "What do you want to do?",
|
|
606
|
-
options: [
|
|
607
|
-
{
|
|
608
|
-
label: clipboardCopied ? `${green("✓")} Copy error report to clipboard` : "Copy error report to clipboard",
|
|
609
|
-
value: "clipboard",
|
|
610
|
-
hint: clipboardCopied ? "Copied!" : "Paste into another terminal for an AI agent"
|
|
611
|
-
},
|
|
612
|
-
{
|
|
613
|
-
label: "Skip hooks and commit (--no-verify)",
|
|
614
|
-
value: "skip",
|
|
615
|
-
hint: "Commit anyway, fix later"
|
|
616
|
-
},
|
|
617
|
-
{
|
|
618
|
-
label: "Re-stage files and retry",
|
|
619
|
-
value: "restage",
|
|
620
|
-
hint: "Pick up fixes from another terminal"
|
|
621
|
-
},
|
|
622
|
-
{
|
|
623
|
-
label: "Edit commit message",
|
|
624
|
-
value: "edit",
|
|
625
|
-
hint: "Modify the message before retrying"
|
|
626
|
-
},
|
|
627
|
-
{
|
|
628
|
-
label: "Cancel",
|
|
629
|
-
value: "cancel"
|
|
630
|
-
}
|
|
631
|
-
]
|
|
632
|
-
});
|
|
633
|
-
if (p.isCancel(choice)) {
|
|
634
|
-
debug("showRecoveryMenu: user cancelled");
|
|
635
|
-
p.outro(yellow("Cancelled. Message cached for --retry."));
|
|
636
|
-
return "cancelled";
|
|
637
|
-
}
|
|
638
|
-
debug("showRecoveryMenu: user chose %s", choice);
|
|
639
|
-
switch (choice) {
|
|
640
|
-
case "clipboard":
|
|
641
|
-
if (await copyToClipboard(rawStderr)) {
|
|
642
|
-
clipboardCopied = true;
|
|
643
|
-
p.log.step(green("Copied to clipboard."));
|
|
644
|
-
} else p.log.warn(red("No clipboard tool found. Install xclip, wl-copy, or xsel."));
|
|
645
|
-
continue;
|
|
646
|
-
case "skip":
|
|
647
|
-
p.log.info(yellow("Committing with --no-verify..."));
|
|
648
|
-
if (await onSkipHooks(message)) {
|
|
649
|
-
p.outro(green("Committed (hooks skipped)."));
|
|
650
|
-
return "committed";
|
|
651
|
-
} else {
|
|
652
|
-
p.outro(red("Commit failed even with --no-verify."));
|
|
653
|
-
return "failed";
|
|
654
|
-
}
|
|
655
|
-
case "restage":
|
|
656
|
-
p.log.info(cyan("Re-staging and retrying..."));
|
|
657
|
-
if (await onRestage()) {
|
|
658
|
-
p.outro(green("Committed successfully."));
|
|
659
|
-
return "committed";
|
|
660
|
-
}
|
|
661
|
-
showNote = true;
|
|
662
|
-
continue;
|
|
663
|
-
case "edit": {
|
|
664
|
-
const edited = await p.text({
|
|
665
|
-
message: "Edit commit message:",
|
|
666
|
-
initialValue: message,
|
|
667
|
-
validate: (v) => v?.trim() ? void 0 : "Message cannot be empty"
|
|
668
|
-
});
|
|
669
|
-
if (p.isCancel(edited)) {
|
|
670
|
-
p.outro(yellow("Cancelled. Message cached for --retry."));
|
|
671
|
-
return "cancelled";
|
|
672
|
-
}
|
|
673
|
-
if (await onRetry()) {
|
|
674
|
-
p.outro(green("Committed successfully."));
|
|
675
|
-
return "committed";
|
|
676
|
-
} else {
|
|
677
|
-
p.outro(red("Commit failed again."));
|
|
678
|
-
return "failed";
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
case "cancel":
|
|
682
|
-
p.outro(dim("Message cached for --retry."));
|
|
683
|
-
return "cancelled";
|
|
684
|
-
}
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
//#endregion
|
|
688
|
-
//#region src/services/ai.ts
|
|
689
|
-
const MAX_DIFF_CHARS = 2e4;
|
|
690
|
-
function mapGroqError$1(error) {
|
|
691
|
-
if (error instanceof Groq.AuthenticationError) return /* @__PURE__ */ new Error("Invalid GROQ_API_KEY. Run: cmint config set GROQ_API_KEY=<key>");
|
|
692
|
-
if (error instanceof Groq.RateLimitError) return /* @__PURE__ */ new Error("Rate limited by Groq. Please wait and try again.");
|
|
693
|
-
if (error instanceof Groq.APIConnectionTimeoutError) return /* @__PURE__ */ new Error("Request timed out. Check your network or try a smaller diff.");
|
|
694
|
-
if (error instanceof Groq.APIError) return /* @__PURE__ */ new Error(`Groq API error: ${error.message}`);
|
|
695
|
-
return /* @__PURE__ */ new Error(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`);
|
|
696
|
-
}
|
|
697
|
-
const CONVENTIONAL_COMMIT_REGEX = /^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.+\))?!?: .+$/;
|
|
698
|
-
function stripThinkTags(text) {
|
|
699
|
-
return text.replace(/<think[\s\S]*?<\/think>/gi, "").trim();
|
|
619
|
+
const CONVENTIONAL_COMMIT_REGEX = /^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.+\))?!?: .+$/;
|
|
620
|
+
function stripThinkTags(text) {
|
|
621
|
+
return text.replace(/<think[\s\S]*?<\/think>/gi, "").trim();
|
|
700
622
|
}
|
|
701
623
|
function deriveMessageFromReasoning(reasoning) {
|
|
702
624
|
const match = reasoning.match(/(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.+\))?!?: .+/i);
|
|
@@ -776,13 +698,16 @@ function extractContentText(content) {
|
|
|
776
698
|
return "";
|
|
777
699
|
}
|
|
778
700
|
async function generateCommitMessage(diff, options) {
|
|
779
|
-
debug("generateCommitMessage: model=%s, type=%s, hint=%s", options.model ?? "default", options.type ?? "none", options.hint ?? "none");
|
|
780
701
|
const timeoutMs = options.timeout ?? 6e4;
|
|
781
702
|
debug("Timeout: %d ms", timeoutMs);
|
|
782
|
-
const client =
|
|
703
|
+
const { client, model } = createProvider({
|
|
704
|
+
provider: options.provider ?? "groq",
|
|
783
705
|
apiKey: options.apiKey,
|
|
784
|
-
|
|
706
|
+
modelOverride: options.model,
|
|
707
|
+
timeout: timeoutMs,
|
|
708
|
+
baseURLOverride: options.proxy
|
|
785
709
|
});
|
|
710
|
+
debug("generateCommitMessage: model=%s, type=%s, hint=%s", model, options.type ?? "none", options.hint ?? "none");
|
|
786
711
|
const compressedDiff = compressDiff(diff);
|
|
787
712
|
const statSummary = buildStatSummary(diff);
|
|
788
713
|
const systemPrompt = buildSystemPrompt(options.type);
|
|
@@ -792,9 +717,9 @@ async function generateCommitMessage(diff, options) {
|
|
|
792
717
|
debug("User prompt length: %d chars", userPrompt.length);
|
|
793
718
|
async function callAI(strictSystemPrompt) {
|
|
794
719
|
const callStart = Date.now();
|
|
795
|
-
debug("callAI: %s — model=%s, promptLen=%d, systemLen=%d", !!strictSystemPrompt ? "RETRY (strict)" : "INITIAL",
|
|
720
|
+
debug("callAI: %s — model=%s, promptLen=%d, systemLen=%d", !!strictSystemPrompt ? "RETRY (strict)" : "INITIAL", model, userPrompt.length, (strictSystemPrompt ?? systemPrompt).length);
|
|
796
721
|
try {
|
|
797
|
-
const isReasoningModel = /^(o[1-9]|.*gpt-oss.*|.*gpt-5.*)/i.test(
|
|
722
|
+
const isReasoningModel = /^(o[1-9]|.*gpt-oss.*|.*gpt-5.*)/i.test(model);
|
|
798
723
|
const completion = await client.chat.completions.create({
|
|
799
724
|
messages: [{
|
|
800
725
|
role: "system",
|
|
@@ -803,7 +728,7 @@ async function generateCommitMessage(diff, options) {
|
|
|
803
728
|
role: "user",
|
|
804
729
|
content: userPrompt
|
|
805
730
|
}],
|
|
806
|
-
model
|
|
731
|
+
model,
|
|
807
732
|
temperature: .3,
|
|
808
733
|
...isReasoningModel ? { max_completion_tokens: 1024 } : { max_tokens: 1024 },
|
|
809
734
|
reasoning_format: "parsed"
|
|
@@ -849,7 +774,7 @@ async function generateCommitMessage(diff, options) {
|
|
|
849
774
|
return message;
|
|
850
775
|
} catch (error) {
|
|
851
776
|
debug("AI error: %s", error instanceof Error ? error.message : String(error));
|
|
852
|
-
throw mapGroqError
|
|
777
|
+
throw mapGroqError(error, options.provider ? formatProviderName(options.provider) : void 0);
|
|
853
778
|
}
|
|
854
779
|
}
|
|
855
780
|
//#endregion
|
|
@@ -892,9 +817,12 @@ function buildReviewPrompt(diff, files, statSummary) {
|
|
|
892
817
|
async function generateCodeReview(diff, files, options) {
|
|
893
818
|
debug("generateCodeReview: model=%s, files=%d", options.model ?? "default", files.length);
|
|
894
819
|
const timeoutMs = options.timeout ?? 6e4;
|
|
895
|
-
const client =
|
|
820
|
+
const { client, model } = createProvider({
|
|
821
|
+
provider: options.provider ?? "groq",
|
|
896
822
|
apiKey: options.apiKey,
|
|
897
|
-
|
|
823
|
+
modelOverride: options.model,
|
|
824
|
+
timeout: timeoutMs,
|
|
825
|
+
baseURLOverride: options.proxy
|
|
898
826
|
});
|
|
899
827
|
const compressedDiff = compressDiff(diff);
|
|
900
828
|
const statSummary = buildStatSummary(diff);
|
|
@@ -910,7 +838,7 @@ async function generateCodeReview(diff, files, options) {
|
|
|
910
838
|
role: "user",
|
|
911
839
|
content: userPrompt
|
|
912
840
|
}],
|
|
913
|
-
model
|
|
841
|
+
model,
|
|
914
842
|
temperature: .3,
|
|
915
843
|
max_tokens: 4096
|
|
916
844
|
});
|
|
@@ -931,7 +859,7 @@ async function generateCodeReview(diff, files, options) {
|
|
|
931
859
|
return content;
|
|
932
860
|
} catch (error) {
|
|
933
861
|
debug("generateCodeReview error: %s", error instanceof Error ? error.message : String(error));
|
|
934
|
-
throw mapGroqError
|
|
862
|
+
throw mapGroqError(error, options.provider ? formatProviderName(options.provider) : void 0);
|
|
935
863
|
}
|
|
936
864
|
}
|
|
937
865
|
//#endregion
|
|
@@ -952,7 +880,7 @@ async function reviewCommand() {
|
|
|
952
880
|
outro(dim("Staged files are all excluded from review."));
|
|
953
881
|
return;
|
|
954
882
|
}
|
|
955
|
-
intro("commit-mint — code review");
|
|
883
|
+
intro("🌿 commit-mint — code review");
|
|
956
884
|
log.info(diffResult.files.map((f) => ` ${f}`).join("\n"));
|
|
957
885
|
const report = await isOpenCodeAvailable() ? await reviewWithOpenCode(diffResult.diff, diffResult.files) : await reviewWithGroq(diffResult.diff, diffResult.files);
|
|
958
886
|
if (report !== "NO_ISSUES_FOUND" && report.trim().length > 0) {
|
|
@@ -987,14 +915,19 @@ async function isOpenCodeAvailable() {
|
|
|
987
915
|
}
|
|
988
916
|
}
|
|
989
917
|
async function reviewWithGroq(diff, files) {
|
|
918
|
+
const config = await readConfig();
|
|
919
|
+
const provider = isValidProvider(config.provider ?? "groq") ? config.provider : "groq";
|
|
920
|
+
const apiKey = await getProviderApiKey(provider);
|
|
921
|
+
const model = getModelForProvider(config, provider, PROVIDER_CONFIGS[provider].defaultModel);
|
|
990
922
|
const s = spinner();
|
|
991
|
-
s.start(
|
|
923
|
+
s.start(`Reviewing with ${formatProviderName(provider)}...`);
|
|
992
924
|
try {
|
|
993
|
-
const config = await readConfig();
|
|
994
925
|
const report = await generateCodeReview(diff, files, {
|
|
995
|
-
apiKey
|
|
996
|
-
model
|
|
997
|
-
timeout: config.timeout ? Number.parseInt(config.timeout, 10) : void 0
|
|
926
|
+
apiKey,
|
|
927
|
+
model,
|
|
928
|
+
timeout: config.timeout ? Number.parseInt(config.timeout, 10) : void 0,
|
|
929
|
+
provider,
|
|
930
|
+
proxy: config.proxy
|
|
998
931
|
});
|
|
999
932
|
s.stop("Review complete");
|
|
1000
933
|
return report;
|
|
@@ -1050,8 +983,10 @@ async function runCodeReview() {
|
|
|
1050
983
|
return;
|
|
1051
984
|
}
|
|
1052
985
|
const opencodeAvailable = await isOpenCodeAvailable();
|
|
986
|
+
const config = await readConfig();
|
|
987
|
+
const provider = isValidProvider(config.provider ?? "groq") ? config.provider : "groq";
|
|
1053
988
|
const s = spinner();
|
|
1054
|
-
s.start(opencodeAvailable ? "Running OpenCode review..." :
|
|
989
|
+
s.start(opencodeAvailable ? "Running OpenCode review..." : `Running ${formatProviderName(provider)} review...`);
|
|
1055
990
|
try {
|
|
1056
991
|
const report = opencodeAvailable ? await reviewWithOpenCode(diffResult.diff, diffResult.files) : await reviewWithGroq(diffResult.diff, diffResult.files);
|
|
1057
992
|
s.stop("Review complete");
|
|
@@ -1167,14 +1102,201 @@ async function loadCachedCommit(repoPath) {
|
|
|
1167
1102
|
}
|
|
1168
1103
|
}
|
|
1169
1104
|
//#endregion
|
|
1170
|
-
//#region src/services/
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1105
|
+
//#region src/services/checks.ts
|
|
1106
|
+
/** Config file names, checked in priority order (matches lint-staged naming conventions) */
|
|
1107
|
+
const CONFIG_FILES = [
|
|
1108
|
+
".cmintrc",
|
|
1109
|
+
".cmintrc.json",
|
|
1110
|
+
".cmintrc.mjs",
|
|
1111
|
+
".cmintrc.mts",
|
|
1112
|
+
".cmintrc.js",
|
|
1113
|
+
".cmintrc.ts",
|
|
1114
|
+
".cmintrc.cjs",
|
|
1115
|
+
".cmintrc.cts",
|
|
1116
|
+
"cmint.config.mjs",
|
|
1117
|
+
"cmint.config.mts",
|
|
1118
|
+
"cmint.config.js",
|
|
1119
|
+
"cmint.config.ts",
|
|
1120
|
+
"cmint.config.cjs",
|
|
1121
|
+
"cmint.config.cts"
|
|
1122
|
+
];
|
|
1123
|
+
/**
|
|
1124
|
+
* Detect whether the repo has a cmint config file.
|
|
1125
|
+
* Returns the config file path, or null if none found.
|
|
1126
|
+
*/
|
|
1127
|
+
async function detectConfig(repoRoot) {
|
|
1128
|
+
debug("detectConfig: checking for config in %s", repoRoot);
|
|
1129
|
+
for (const name of CONFIG_FILES) try {
|
|
1130
|
+
await access(join(repoRoot, name), constants.R_OK);
|
|
1131
|
+
debug("detectConfig: found %s", name);
|
|
1132
|
+
return join(repoRoot, name);
|
|
1133
|
+
} catch {}
|
|
1134
|
+
debug("detectConfig: no config file found");
|
|
1135
|
+
return null;
|
|
1136
|
+
}
|
|
1137
|
+
/**
|
|
1138
|
+
* Load and validate the cmint config from a repo root.
|
|
1139
|
+
* Throws if the loaded value is missing or not a non-null object.
|
|
1140
|
+
*/
|
|
1141
|
+
async function loadConfig(repoRoot) {
|
|
1142
|
+
const configPath = await detectConfig(repoRoot);
|
|
1143
|
+
if (!configPath) throw new Error("No cmint config file found");
|
|
1144
|
+
debug("loadConfig: loading %s", configPath);
|
|
1145
|
+
const ext = extname(configPath);
|
|
1146
|
+
const isJSON = ext === ".json";
|
|
1147
|
+
const needsJiti = ext === ".ts" || ext === ".mts" || ext === ".cts" || ext === ".cjs";
|
|
1148
|
+
let config;
|
|
1149
|
+
if (isJSON) {
|
|
1150
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
1151
|
+
config = JSON.parse(raw);
|
|
1152
|
+
} else if (needsJiti) {
|
|
1153
|
+
const { createJiti } = await import("jiti");
|
|
1154
|
+
const mod = await createJiti(import.meta.url, {}).import(configPath);
|
|
1155
|
+
config = mod.default ?? mod;
|
|
1156
|
+
} else config = (await import(configPath)).default;
|
|
1157
|
+
if (!config || typeof config !== "object" || Array.isArray(config)) throw new Error("cmint config must export a non-null object with glob→command mappings");
|
|
1158
|
+
debug("loadConfig: loaded %d glob patterns", Object.keys(config).length);
|
|
1159
|
+
return config;
|
|
1160
|
+
}
|
|
1161
|
+
/**
|
|
1162
|
+
* Run a shell command and capture its output.
|
|
1163
|
+
* Returns a CheckResult with ok=true on success (exit 0), ok=false on failure.
|
|
1164
|
+
* Handles ENOENT (command not found) and timeout errors gracefully.
|
|
1165
|
+
*/
|
|
1166
|
+
async function runCommand(command, timeout, repoRoot) {
|
|
1167
|
+
debug("runCommand: %s (timeout: %dms)", command, timeout);
|
|
1168
|
+
const tool = extractToolName(command) ?? command.split(" ")[0];
|
|
1169
|
+
try {
|
|
1170
|
+
const result = await execa(command, {
|
|
1171
|
+
shell: true,
|
|
1172
|
+
reject: false,
|
|
1173
|
+
timeout,
|
|
1174
|
+
all: true,
|
|
1175
|
+
preferLocal: true,
|
|
1176
|
+
...repoRoot ? { localDir: repoRoot } : {}
|
|
1177
|
+
});
|
|
1178
|
+
const ok = !result.failed;
|
|
1179
|
+
debug("runCommand: %s — ok=%s", tool, ok);
|
|
1180
|
+
return {
|
|
1181
|
+
ok,
|
|
1182
|
+
tool,
|
|
1183
|
+
command,
|
|
1184
|
+
stdout: result.stdout ?? "",
|
|
1185
|
+
stderr: result.stderr ?? "",
|
|
1186
|
+
files: []
|
|
1187
|
+
};
|
|
1188
|
+
} catch (err) {
|
|
1189
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1190
|
+
const isTimedOut = msg.toLowerCase().includes("timed out");
|
|
1191
|
+
const isNotFound = msg.toLowerCase().includes("enoent") || msg.toLowerCase().includes("not found");
|
|
1192
|
+
debug("runCommand: %s — error: %s", tool, msg);
|
|
1193
|
+
return {
|
|
1194
|
+
ok: false,
|
|
1195
|
+
tool,
|
|
1196
|
+
command,
|
|
1197
|
+
stdout: "",
|
|
1198
|
+
stderr: isTimedOut ? `Check timed out after ${timeout}ms` : isNotFound ? `Command not found: ${tool}` : msg,
|
|
1199
|
+
files: []
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* Filter a list of file paths by a picomatch glob pattern.
|
|
1205
|
+
* When the pattern contains no `/`, files are matched at any depth (matchBase).
|
|
1206
|
+
* Dotfiles are included (dot: true).
|
|
1207
|
+
*/
|
|
1208
|
+
function matchFiles(pattern, files) {
|
|
1209
|
+
if (!pattern) return [];
|
|
1210
|
+
const matchBase = !pattern.includes("/");
|
|
1211
|
+
const isMatch = picomatch(pattern, {
|
|
1212
|
+
dot: true,
|
|
1213
|
+
posixSlashes: true,
|
|
1214
|
+
strictBrackets: true
|
|
1215
|
+
});
|
|
1216
|
+
return files.filter((f) => {
|
|
1217
|
+
const parts = f.split("/");
|
|
1218
|
+
return isMatch(matchBase ? parts[parts.length - 1] : f);
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
/**
|
|
1222
|
+
* Build a shell command string from a base command and a list of file paths.
|
|
1223
|
+
* File paths containing spaces are wrapped in double quotes.
|
|
1224
|
+
* If no files are provided, the base command is returned as-is.
|
|
1225
|
+
*/
|
|
1226
|
+
function buildCommand(command, files) {
|
|
1227
|
+
if (files.length === 0) return command;
|
|
1228
|
+
return `${command} ${files.map((f) => f.includes(" ") ? `"${f}"` : f).join(" ")}`;
|
|
1229
|
+
}
|
|
1230
|
+
/**
|
|
1231
|
+
* Resolve config commands for a glob entry into an array of command strings.
|
|
1232
|
+
* Function commands receive matched filenames; string commands are used as-is.
|
|
1233
|
+
*/
|
|
1234
|
+
function resolveCommands(commands, matchedFiles) {
|
|
1235
|
+
if (typeof commands === "function") {
|
|
1236
|
+
const resolved = commands(matchedFiles);
|
|
1237
|
+
return Array.isArray(resolved) ? resolved : [resolved];
|
|
1238
|
+
}
|
|
1239
|
+
return Array.isArray(commands) ? commands : [commands];
|
|
1240
|
+
}
|
|
1241
|
+
/**
|
|
1242
|
+
* Run resolved commands for a single glob entry, appending results.
|
|
1243
|
+
* Returns false if any command fails (for fail-fast signaling).
|
|
1244
|
+
*/
|
|
1245
|
+
async function runCommandsForGlob(cmds, isFunction, matchedFiles, timeout, results, repoRoot) {
|
|
1246
|
+
for (const cmd of cmds) {
|
|
1247
|
+
const fullCommand = isFunction ? cmd : buildCommand(cmd, matchedFiles);
|
|
1248
|
+
debug("runCommandsForGlob: running '%s'", fullCommand);
|
|
1249
|
+
const result = await runCommand(fullCommand, timeout, repoRoot);
|
|
1250
|
+
results.push({
|
|
1251
|
+
...result,
|
|
1252
|
+
files: matchedFiles
|
|
1253
|
+
});
|
|
1254
|
+
if (!result.ok) {
|
|
1255
|
+
debug("runCommandsForGlob: check failed, stopping (fail-fast)");
|
|
1256
|
+
return false;
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
return true;
|
|
1260
|
+
}
|
|
1261
|
+
/**
|
|
1262
|
+
* Run all user-defined checks from .cmintrc against staged files.
|
|
1263
|
+
* Returns a no-op result when no config exists.
|
|
1264
|
+
* Fail-fast: stops on first error.
|
|
1265
|
+
*/
|
|
1266
|
+
async function runAllChecks(repoRoot, stagedFiles, timeout) {
|
|
1267
|
+
debug("runAllChecks: %d staged files, checking for config in %s", stagedFiles.length, repoRoot);
|
|
1268
|
+
if (!await detectConfig(repoRoot)) {
|
|
1269
|
+
debug("runAllChecks: no config found, skipping checks");
|
|
1270
|
+
return {
|
|
1271
|
+
ok: true,
|
|
1272
|
+
results: []
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
const config = await loadConfig(repoRoot);
|
|
1276
|
+
debug("runAllChecks: loaded config with %d patterns", Object.keys(config).length);
|
|
1277
|
+
const results = [];
|
|
1278
|
+
for (const [glob, commands] of Object.entries(config)) {
|
|
1279
|
+
const matchedFiles = matchFiles(glob, stagedFiles);
|
|
1280
|
+
const isFunction = typeof commands === "function";
|
|
1281
|
+
if (matchedFiles.length === 0) {
|
|
1282
|
+
debug("runAllChecks: no files matched pattern '%s'", glob);
|
|
1283
|
+
continue;
|
|
1284
|
+
}
|
|
1285
|
+
debug("runAllChecks: pattern '%s' matched %d files", glob, matchedFiles.length);
|
|
1286
|
+
if (!await runCommandsForGlob(resolveCommands(commands, matchedFiles), isFunction, matchedFiles, timeout, results, repoRoot)) return {
|
|
1287
|
+
ok: false,
|
|
1288
|
+
results
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
const ok = results.every((r) => r.ok);
|
|
1292
|
+
debug("runAllChecks: complete — ok=%s, %d results", ok, results.length);
|
|
1293
|
+
return {
|
|
1294
|
+
ok,
|
|
1295
|
+
results
|
|
1296
|
+
};
|
|
1177
1297
|
}
|
|
1298
|
+
//#endregion
|
|
1299
|
+
//#region src/services/grouping.ts
|
|
1178
1300
|
function matchesExcludePattern(filePath, pattern) {
|
|
1179
1301
|
if (pattern === filePath) return true;
|
|
1180
1302
|
if (pattern.endsWith("/**")) {
|
|
@@ -1191,7 +1313,9 @@ function matchesExcludePattern(filePath, pattern) {
|
|
|
1191
1313
|
const LOCKFILE_COMPANIONS = {
|
|
1192
1314
|
"package-lock.json": "package.json",
|
|
1193
1315
|
"pnpm-lock.yaml": "package.json",
|
|
1194
|
-
"yarn.lock": "package.json"
|
|
1316
|
+
"yarn.lock": "package.json",
|
|
1317
|
+
"bun.lock": "package.json",
|
|
1318
|
+
"bun.lockb": "package.json"
|
|
1195
1319
|
};
|
|
1196
1320
|
function filterExcludedFiles(files) {
|
|
1197
1321
|
const patterns = getDefaultExcludes();
|
|
@@ -1265,7 +1389,7 @@ function parseGroupingResponse(content) {
|
|
|
1265
1389
|
});
|
|
1266
1390
|
return rawGroups;
|
|
1267
1391
|
}
|
|
1268
|
-
async function generateGroups(files, apiKey, model, timeout) {
|
|
1392
|
+
async function generateGroups(files, apiKey, model, timeout, provider, proxy) {
|
|
1269
1393
|
debug("generateGroups: %d files, model=%s", files.length, model ?? "default");
|
|
1270
1394
|
const { included, excluded } = filterExcludedFiles(files);
|
|
1271
1395
|
if (included.length === 0) {
|
|
@@ -1280,9 +1404,12 @@ async function generateGroups(files, apiKey, model, timeout) {
|
|
|
1280
1404
|
const userPrompt = buildGroupingUserPrompt(summary);
|
|
1281
1405
|
debug("File summary:\n%s", summary);
|
|
1282
1406
|
debug("User prompt length: %d chars", userPrompt.length);
|
|
1283
|
-
const client =
|
|
1407
|
+
const { client, model: resolvedModel } = createProvider({
|
|
1408
|
+
provider: provider ?? "groq",
|
|
1284
1409
|
apiKey,
|
|
1285
|
-
|
|
1410
|
+
modelOverride: model,
|
|
1411
|
+
timeout: timeout ?? 6e4,
|
|
1412
|
+
baseURLOverride: proxy
|
|
1286
1413
|
});
|
|
1287
1414
|
try {
|
|
1288
1415
|
const completion = await client.chat.completions.create({
|
|
@@ -1293,7 +1420,7 @@ async function generateGroups(files, apiKey, model, timeout) {
|
|
|
1293
1420
|
role: "user",
|
|
1294
1421
|
content: userPrompt
|
|
1295
1422
|
}],
|
|
1296
|
-
model:
|
|
1423
|
+
model: resolvedModel,
|
|
1297
1424
|
temperature: .3,
|
|
1298
1425
|
max_tokens: 2048
|
|
1299
1426
|
});
|
|
@@ -1312,7 +1439,7 @@ async function generateGroups(files, apiKey, model, timeout) {
|
|
|
1312
1439
|
};
|
|
1313
1440
|
} catch (error) {
|
|
1314
1441
|
debug("generateGroups error: %s", error instanceof Error ? error.message : String(error));
|
|
1315
|
-
throw mapGroqError(error);
|
|
1442
|
+
throw mapGroqError(error, provider ? formatProviderName(provider) : void 0);
|
|
1316
1443
|
}
|
|
1317
1444
|
}
|
|
1318
1445
|
function validateGroups(groups, allFiles) {
|
|
@@ -1339,72 +1466,367 @@ function validateGroups(groups, allFiles) {
|
|
|
1339
1466
|
files: ungrouped.map((f) => f.path)
|
|
1340
1467
|
});
|
|
1341
1468
|
}
|
|
1342
|
-
return validated;
|
|
1469
|
+
return validated;
|
|
1470
|
+
}
|
|
1471
|
+
//#endregion
|
|
1472
|
+
//#region src/ui/grouping.ts
|
|
1473
|
+
async function showGroupingConfirmation(groups, excluded) {
|
|
1474
|
+
debug("showGroupingConfirmation: %d groups, %d excluded", groups.length, excluded.length);
|
|
1475
|
+
const lines = [];
|
|
1476
|
+
for (const group of groups) {
|
|
1477
|
+
lines.push(bold(group.name));
|
|
1478
|
+
lines.push(` ${dim(group.description)}`);
|
|
1479
|
+
lines.push(` ${green(String(group.files.length))} file${group.files.length !== 1 ? "s" : ""}`);
|
|
1480
|
+
for (const file of group.files) lines.push(` ${dim("•")} ${file}`);
|
|
1481
|
+
lines.push("");
|
|
1482
|
+
}
|
|
1483
|
+
if (excluded.length > 0) {
|
|
1484
|
+
lines.push(dim(`Excluded: ${excluded.length} file${excluded.length !== 1 ? "s" : ""}`));
|
|
1485
|
+
for (const file of excluded) lines.push(` ${dim("•")} ${dim(file)}`);
|
|
1486
|
+
}
|
|
1487
|
+
p.note(lines.join("\n"), "Proposed commit groups");
|
|
1488
|
+
const choice = await p.select({
|
|
1489
|
+
message: "Proceed with these groupings?",
|
|
1490
|
+
options: [{
|
|
1491
|
+
label: "Yes, commit all groups",
|
|
1492
|
+
value: "yes"
|
|
1493
|
+
}, {
|
|
1494
|
+
label: "No, cancel",
|
|
1495
|
+
value: "no"
|
|
1496
|
+
}]
|
|
1497
|
+
});
|
|
1498
|
+
if (p.isCancel(choice) || choice === "no") {
|
|
1499
|
+
debug("showGroupingConfirmation: user cancelled");
|
|
1500
|
+
return false;
|
|
1501
|
+
}
|
|
1502
|
+
debug("showGroupingConfirmation: user confirmed");
|
|
1503
|
+
return true;
|
|
1504
|
+
}
|
|
1505
|
+
function showGroupProgress(current, total, groupName) {
|
|
1506
|
+
p.log.info(`Commit group ${current} of ${total}: ${cyan(`"${groupName}"`)}`);
|
|
1507
|
+
}
|
|
1508
|
+
const statusLabel = (status) => {
|
|
1509
|
+
switch (status) {
|
|
1510
|
+
case "M": return yellow("M");
|
|
1511
|
+
case "A": return green("A");
|
|
1512
|
+
case "D": return red("D");
|
|
1513
|
+
case "?":
|
|
1514
|
+
case "??": return cyan("?");
|
|
1515
|
+
default: return dim(status);
|
|
1516
|
+
}
|
|
1517
|
+
};
|
|
1518
|
+
/** Display a table of changed files with status indicators */
|
|
1519
|
+
function showChangedFilesTable(files) {
|
|
1520
|
+
if (files.length === 0) return;
|
|
1521
|
+
const lines = files.map((f) => ` ${statusLabel(f.status)} ${f.path}`);
|
|
1522
|
+
p.note(lines.join("\n"), `${files.length} file${files.length !== 1 ? "s" : ""} changed`);
|
|
1523
|
+
}
|
|
1524
|
+
/** Display a compact grouping summary (only shown when >1 group) */
|
|
1525
|
+
function showGroupingSummary(groups) {
|
|
1526
|
+
if (groups.length <= 1) return;
|
|
1527
|
+
const lines = groups.map((g) => `${bold(g.name)} ${dim("—")} ${g.files.length} file${g.files.length !== 1 ? "s" : ""}`);
|
|
1528
|
+
p.note(lines.join("\n"), "Commit groups");
|
|
1529
|
+
}
|
|
1530
|
+
//#endregion
|
|
1531
|
+
//#region src/ui/menu.ts
|
|
1532
|
+
async function showStagingMenu(files, hasChecks) {
|
|
1533
|
+
debug("showStagingMenu: %d files", files.length);
|
|
1534
|
+
const statusLabel = (status) => {
|
|
1535
|
+
switch (status) {
|
|
1536
|
+
case "M": return yellow("M");
|
|
1537
|
+
case "A": return green("A");
|
|
1538
|
+
case "D": return red("D");
|
|
1539
|
+
case "?":
|
|
1540
|
+
case "??": return cyan("?");
|
|
1541
|
+
default: return dim(status);
|
|
1542
|
+
}
|
|
1543
|
+
};
|
|
1544
|
+
const sorted = [...files].sort((a, b) => {
|
|
1545
|
+
if (a.staged !== b.staged) return a.staged ? -1 : 1;
|
|
1546
|
+
return a.path.localeCompare(b.path);
|
|
1547
|
+
});
|
|
1548
|
+
const stagedFiles = sorted.filter((f) => f.staged);
|
|
1549
|
+
const unstagedFiles = sorted.filter((f) => !f.staged);
|
|
1550
|
+
const lines = [];
|
|
1551
|
+
if (stagedFiles.length > 0) lines.push(green(bold("Staged:")), ...stagedFiles.map((f) => ` ${statusLabel(f.status)} ${f.path}`));
|
|
1552
|
+
if (unstagedFiles.length > 0) {
|
|
1553
|
+
if (lines.length > 0) lines.push("");
|
|
1554
|
+
lines.push(yellow(bold("Changed:")), ...unstagedFiles.map((f) => ` ${statusLabel(f.status)} ${f.path}`));
|
|
1555
|
+
}
|
|
1556
|
+
p.note(lines.join("\n"), `${files.length} file${files.length !== 1 ? "s" : ""}`);
|
|
1557
|
+
const choice = await p.select({
|
|
1558
|
+
message: "Stage files for commit:",
|
|
1559
|
+
options: [
|
|
1560
|
+
{
|
|
1561
|
+
label: "Auto-group into commits",
|
|
1562
|
+
value: "autogroup",
|
|
1563
|
+
hint: "LLM groups files into logical commits"
|
|
1564
|
+
},
|
|
1565
|
+
...stagedFiles.length > 0 ? [{
|
|
1566
|
+
label: "Commit staged files only",
|
|
1567
|
+
value: "staged",
|
|
1568
|
+
hint: `${stagedFiles.length} file${stagedFiles.length !== 1 ? "s" : ""} already staged`
|
|
1569
|
+
}] : [],
|
|
1570
|
+
{
|
|
1571
|
+
label: "Stage all files",
|
|
1572
|
+
value: "all",
|
|
1573
|
+
hint: `${files.length} file${files.length !== 1 ? "s" : ""}`
|
|
1574
|
+
},
|
|
1575
|
+
...hasChecks ? [{
|
|
1576
|
+
label: "Run checks",
|
|
1577
|
+
value: "checks",
|
|
1578
|
+
hint: "Pre-flight checks from cmint config"
|
|
1579
|
+
}] : [],
|
|
1580
|
+
{
|
|
1581
|
+
label: "Select files...",
|
|
1582
|
+
value: "select"
|
|
1583
|
+
},
|
|
1584
|
+
{
|
|
1585
|
+
label: "Cancel",
|
|
1586
|
+
value: "cancel"
|
|
1587
|
+
}
|
|
1588
|
+
]
|
|
1589
|
+
});
|
|
1590
|
+
if (p.isCancel(choice) || choice === "cancel") return null;
|
|
1591
|
+
if (choice === "autogroup") return "autogroup";
|
|
1592
|
+
if (choice === "checks") return "checks";
|
|
1593
|
+
if (choice === "staged") return "staged";
|
|
1594
|
+
if (choice === "all") return {
|
|
1595
|
+
files: files.map((f) => f.path),
|
|
1596
|
+
all: true
|
|
1597
|
+
};
|
|
1598
|
+
const selected = await p.multiselect({
|
|
1599
|
+
message: "Select files to stage:",
|
|
1600
|
+
options: sorted.map((f) => ({
|
|
1601
|
+
label: `${statusLabel(f.status)} ${f.path}`,
|
|
1602
|
+
value: f.path
|
|
1603
|
+
})),
|
|
1604
|
+
required: true
|
|
1605
|
+
});
|
|
1606
|
+
if (p.isCancel(selected)) return null;
|
|
1607
|
+
return {
|
|
1608
|
+
files: selected,
|
|
1609
|
+
all: false
|
|
1610
|
+
};
|
|
1611
|
+
}
|
|
1612
|
+
async function showRecoveryMenu(errors, onRetry, onSkipHooks, onRestage, message, rawStderr) {
|
|
1613
|
+
debug("showRecoveryMenu: %d errors", errors.length);
|
|
1614
|
+
let clipboardCopied = false;
|
|
1615
|
+
let showNote = true;
|
|
1616
|
+
while (true) {
|
|
1617
|
+
if (showNote) {
|
|
1618
|
+
p.note(errors.map((e) => ` ${red("•")} [${e.tool}] ${e.message}`).join("\n"), red(bold("Pre-commit hook failed")));
|
|
1619
|
+
showNote = false;
|
|
1620
|
+
}
|
|
1621
|
+
const choice = await p.select({
|
|
1622
|
+
message: "What do you want to do?",
|
|
1623
|
+
options: [
|
|
1624
|
+
{
|
|
1625
|
+
label: clipboardCopied ? `${green("✓")} Copy error report to clipboard` : "Copy error report to clipboard",
|
|
1626
|
+
value: "clipboard",
|
|
1627
|
+
hint: clipboardCopied ? "Copied!" : "Paste into another terminal for an AI agent"
|
|
1628
|
+
},
|
|
1629
|
+
{
|
|
1630
|
+
label: "View full error output",
|
|
1631
|
+
value: "view",
|
|
1632
|
+
hint: "Show the raw stderr from hooks"
|
|
1633
|
+
},
|
|
1634
|
+
{
|
|
1635
|
+
label: "Skip hooks and commit (--no-verify)",
|
|
1636
|
+
value: "skip",
|
|
1637
|
+
hint: "Commit anyway, fix later"
|
|
1638
|
+
},
|
|
1639
|
+
{
|
|
1640
|
+
label: "Re-stage files and retry",
|
|
1641
|
+
value: "restage",
|
|
1642
|
+
hint: "Pick up fixes from another terminal"
|
|
1643
|
+
},
|
|
1644
|
+
{
|
|
1645
|
+
label: "Edit commit message",
|
|
1646
|
+
value: "edit",
|
|
1647
|
+
hint: "Modify the message before retrying"
|
|
1648
|
+
},
|
|
1649
|
+
{
|
|
1650
|
+
label: "Cancel",
|
|
1651
|
+
value: "cancel"
|
|
1652
|
+
}
|
|
1653
|
+
]
|
|
1654
|
+
});
|
|
1655
|
+
if (p.isCancel(choice)) {
|
|
1656
|
+
debug("showRecoveryMenu: user cancelled");
|
|
1657
|
+
p.outro(yellow("Cancelled. Message cached for --retry."));
|
|
1658
|
+
return "cancelled";
|
|
1659
|
+
}
|
|
1660
|
+
debug("showRecoveryMenu: user chose %s", choice);
|
|
1661
|
+
switch (choice) {
|
|
1662
|
+
case "clipboard":
|
|
1663
|
+
if (await copyToClipboard(rawStderr)) {
|
|
1664
|
+
clipboardCopied = true;
|
|
1665
|
+
p.log.step(green("Copied to clipboard."));
|
|
1666
|
+
} else p.log.warn(red("No clipboard tool found. Install xclip, wl-copy, or xsel."));
|
|
1667
|
+
continue;
|
|
1668
|
+
case "view":
|
|
1669
|
+
p.note(rawStderr.trim() || "(no raw output)", "Full error output");
|
|
1670
|
+
showNote = true;
|
|
1671
|
+
continue;
|
|
1672
|
+
case "skip":
|
|
1673
|
+
p.log.info(yellow("Committing with --no-verify..."));
|
|
1674
|
+
if (await onSkipHooks(message)) {
|
|
1675
|
+
p.outro(green("Committed (hooks skipped)."));
|
|
1676
|
+
return "committed";
|
|
1677
|
+
} else {
|
|
1678
|
+
p.outro(red("Commit failed even with --no-verify."));
|
|
1679
|
+
return "failed";
|
|
1680
|
+
}
|
|
1681
|
+
case "restage":
|
|
1682
|
+
p.log.info(cyan("Re-staging and retrying..."));
|
|
1683
|
+
if (await onRestage()) {
|
|
1684
|
+
p.outro(green("Committed successfully."));
|
|
1685
|
+
return "committed";
|
|
1686
|
+
}
|
|
1687
|
+
showNote = true;
|
|
1688
|
+
continue;
|
|
1689
|
+
case "edit": {
|
|
1690
|
+
const edited = await p.text({
|
|
1691
|
+
message: "Edit commit message:",
|
|
1692
|
+
initialValue: message,
|
|
1693
|
+
validate: (v) => v?.trim() ? void 0 : "Message cannot be empty"
|
|
1694
|
+
});
|
|
1695
|
+
if (p.isCancel(edited)) {
|
|
1696
|
+
p.outro(yellow("Cancelled. Message cached for --retry."));
|
|
1697
|
+
return "cancelled";
|
|
1698
|
+
}
|
|
1699
|
+
if (await onRetry()) {
|
|
1700
|
+
p.outro(green("Committed successfully."));
|
|
1701
|
+
return "committed";
|
|
1702
|
+
} else {
|
|
1703
|
+
p.outro(red("Commit failed again."));
|
|
1704
|
+
return "failed";
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
case "cancel":
|
|
1708
|
+
p.outro(dim("Message cached for --retry."));
|
|
1709
|
+
return "cancelled";
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1343
1712
|
}
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1713
|
+
async function showCheckFailureMenu(errors, rawStderr) {
|
|
1714
|
+
debug("showCheckFailureMenu: %d errors", errors.length);
|
|
1715
|
+
let clipboardCopied = false;
|
|
1716
|
+
p.note(errors.map((e) => ` ${red("•")} [${e.tool}] ${e.message}`).join("\n"), red("Pre-commit check failed"));
|
|
1717
|
+
while (true) {
|
|
1718
|
+
const choice = await p.select({
|
|
1719
|
+
message: "What do you want to do?",
|
|
1720
|
+
options: [
|
|
1721
|
+
{
|
|
1722
|
+
label: clipboardCopied ? `${green("✓")} Copy error report to clipboard` : "Copy error report to clipboard",
|
|
1723
|
+
value: "copy"
|
|
1724
|
+
},
|
|
1725
|
+
{
|
|
1726
|
+
label: "View full error output",
|
|
1727
|
+
value: "view",
|
|
1728
|
+
hint: "Show the raw stderr from checks"
|
|
1729
|
+
},
|
|
1730
|
+
{
|
|
1731
|
+
label: "Skip checks and commit",
|
|
1732
|
+
value: "skip"
|
|
1733
|
+
},
|
|
1734
|
+
{
|
|
1735
|
+
label: "Cancel",
|
|
1736
|
+
value: "cancel"
|
|
1737
|
+
}
|
|
1738
|
+
]
|
|
1739
|
+
});
|
|
1740
|
+
if (p.isCancel(choice)) {
|
|
1741
|
+
debug("showCheckFailureMenu: user cancelled");
|
|
1742
|
+
return "cancelled";
|
|
1743
|
+
}
|
|
1744
|
+
debug("showCheckFailureMenu: user chose %s", choice);
|
|
1745
|
+
switch (choice) {
|
|
1746
|
+
case "copy":
|
|
1747
|
+
if (await copyToClipboard(rawStderr)) {
|
|
1748
|
+
clipboardCopied = true;
|
|
1749
|
+
p.log.step(green("Copied to clipboard."));
|
|
1750
|
+
} else p.log.warn(red("No clipboard tool found. Install xclip, wl-copy, or xsel."));
|
|
1751
|
+
continue;
|
|
1752
|
+
case "view":
|
|
1753
|
+
p.note(rawStderr.trim() || "(no raw output)", "Full error output");
|
|
1754
|
+
continue;
|
|
1755
|
+
case "skip":
|
|
1756
|
+
p.log.info("Skipping checks and proceeding with commit...");
|
|
1757
|
+
return "skipped";
|
|
1758
|
+
case "cancel":
|
|
1759
|
+
p.outro(dim("Cancelled."));
|
|
1760
|
+
return "cancelled";
|
|
1761
|
+
}
|
|
1374
1762
|
}
|
|
1375
|
-
debug("showGroupingConfirmation: user confirmed");
|
|
1376
|
-
return true;
|
|
1377
|
-
}
|
|
1378
|
-
function showGroupProgress(current, total, groupName) {
|
|
1379
|
-
p.log.info(`Commit group ${current} of ${total}: ${cyan(`"${groupName}"`)}`);
|
|
1380
1763
|
}
|
|
1381
1764
|
//#endregion
|
|
1382
1765
|
//#region src/commands/auto-group.ts
|
|
1383
1766
|
async function runAutoGroupFlow(changedFiles, flags) {
|
|
1384
1767
|
const { included, excluded } = filterExcludedFiles(changedFiles);
|
|
1768
|
+
if (excluded.length > 0) {
|
|
1769
|
+
debug("Committing %d excluded files upfront:", excluded.length, excluded);
|
|
1770
|
+
const message = buildExcludedFilesMessage(excluded);
|
|
1771
|
+
await resetStaging();
|
|
1772
|
+
await stageFiles(excluded);
|
|
1773
|
+
const headBefore = await getHead();
|
|
1774
|
+
const commitResult = await attemptCommit(message);
|
|
1775
|
+
const headAfter = await getHead();
|
|
1776
|
+
if (commitResult.ok || headBefore !== headAfter) debug("Excluded files committed:", message);
|
|
1777
|
+
else debug("Excluded files commit failed, continuing without them");
|
|
1778
|
+
}
|
|
1779
|
+
if (included.length === 0) {
|
|
1780
|
+
debug("No included files to group, done");
|
|
1781
|
+
outro(green("Done."));
|
|
1782
|
+
return "committed";
|
|
1783
|
+
}
|
|
1784
|
+
if (!flags.noCheck) {
|
|
1785
|
+
const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
|
|
1786
|
+
const repoRoot = await getRepoRoot();
|
|
1787
|
+
const allFiles = included.filter((f) => f.status !== "D").map((f) => f.path);
|
|
1788
|
+
debug("Running user checks on %d files...", allFiles.length);
|
|
1789
|
+
const ck = spinner();
|
|
1790
|
+
ck.start("Running checks...");
|
|
1791
|
+
const checkResults = await runAllChecks(repoRoot, allFiles, 6e4);
|
|
1792
|
+
debug("Check results: ok=%s, count=%d", checkResults.ok, checkResults.results.length);
|
|
1793
|
+
if (!checkResults.ok) {
|
|
1794
|
+
ck.stop(`${checkResults.results.filter((r) => !r.ok).length} check(s) failed`);
|
|
1795
|
+
const rawStderr = checkResults.results.filter((r) => !r.ok).map((r) => `[${r.tool}] ${r.stderr}`).join("\n");
|
|
1796
|
+
if (await showCheckFailureMenu(parseHookErrors(rawStderr), rawStderr) === "cancelled") return "cancelled";
|
|
1797
|
+
} else {
|
|
1798
|
+
ck.stop("All checks passed");
|
|
1799
|
+
if (checkResults.results.length > 0) log.info(checkResults.results.map((r) => ` ${green("✓")} ${r.tool}`).join("\n"));
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
const config = await readConfig();
|
|
1803
|
+
const resolvedProvider = config.provider ?? "groq";
|
|
1804
|
+
const provider = isValidProvider(resolvedProvider) ? resolvedProvider : "groq";
|
|
1385
1805
|
try {
|
|
1386
|
-
await
|
|
1806
|
+
await getProviderApiKey(provider);
|
|
1387
1807
|
debug("API key found");
|
|
1388
1808
|
} catch {
|
|
1389
1809
|
debug("No API key found, prompting user");
|
|
1390
1810
|
const { text: promptText } = await import("@clack/prompts");
|
|
1391
1811
|
const key = await promptText({
|
|
1392
|
-
message:
|
|
1393
|
-
placeholder: "gsk_...",
|
|
1812
|
+
message: `Enter your ${formatProviderName(provider)} API key:`,
|
|
1813
|
+
placeholder: provider === "groq" ? "gsk_..." : "...",
|
|
1394
1814
|
validate: (v) => v?.trim() ? void 0 : "API key is required"
|
|
1395
1815
|
});
|
|
1396
1816
|
if (isCancel(key)) {
|
|
1397
1817
|
outro(dim("Cancelled."));
|
|
1398
1818
|
return "cancelled";
|
|
1399
1819
|
}
|
|
1400
|
-
|
|
1820
|
+
const configKey = PROVIDER_ENV_KEYS[provider];
|
|
1821
|
+
await setConfigValue(configKey, String(key).trim());
|
|
1401
1822
|
debug("API key saved to config");
|
|
1402
1823
|
}
|
|
1403
1824
|
const s = spinner();
|
|
1404
1825
|
s.start("Analyzing files...");
|
|
1405
|
-
const
|
|
1406
|
-
const validatedGroups = validateGroups((await generateGroups(included, await getApiKey(), config.model, config.timeout ? parseInt(config.timeout, 10) : void 0)).groups, included);
|
|
1826
|
+
const validatedGroups = validateGroups((await generateGroups(included, await getProviderApiKey(provider), getModelForProvider(config, provider, PROVIDER_CONFIGS[provider].defaultModel), config.timeout ? parseInt(config.timeout, 10) : void 0, provider, config.proxy)).groups, included);
|
|
1407
1827
|
s.stop("Files analyzed");
|
|
1828
|
+
showChangedFilesTable(included);
|
|
1829
|
+
showGroupingSummary(validatedGroups);
|
|
1408
1830
|
if (flags.auto) debug("Auto mode: skipping grouping confirmation");
|
|
1409
1831
|
else if (!await showGroupingConfirmation(validatedGroups, excluded)) {
|
|
1410
1832
|
outro(dim("Cancelled."));
|
|
@@ -1442,9 +1864,9 @@ async function runAutoGroupFlow(changedFiles, flags) {
|
|
|
1442
1864
|
}
|
|
1443
1865
|
const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
|
|
1444
1866
|
await saveCachedCommit(await getRepoRoot(), message);
|
|
1445
|
-
s.start("
|
|
1867
|
+
s.start("Running pre-commit hooks...");
|
|
1446
1868
|
const headBefore = await getHead();
|
|
1447
|
-
const commitResult = await attemptCommit(message);
|
|
1869
|
+
const commitResult = await attemptCommit(message, [], createProgressHandler(s));
|
|
1448
1870
|
const headAfter = await getHead();
|
|
1449
1871
|
if (commitResult.ok || headBefore !== headAfter) {
|
|
1450
1872
|
s.stop("Committed successfully.");
|
|
@@ -1471,14 +1893,19 @@ async function runAutoGroupFlow(changedFiles, flags) {
|
|
|
1471
1893
|
}
|
|
1472
1894
|
async function generateMessage(diff, hint) {
|
|
1473
1895
|
const config = await readConfig();
|
|
1474
|
-
const
|
|
1475
|
-
|
|
1896
|
+
const resolvedProvider = config.provider ?? "groq";
|
|
1897
|
+
const provider = isValidProvider(resolvedProvider) ? resolvedProvider : "groq";
|
|
1898
|
+
const apiKey = await getProviderApiKey(provider);
|
|
1899
|
+
const model = getModelForProvider(config, provider, PROVIDER_CONFIGS[provider].defaultModel);
|
|
1900
|
+
debug("Generating message with provider:", provider, "model:", model, "type:", config.type);
|
|
1476
1901
|
return generateCommitMessage(diff, {
|
|
1477
1902
|
apiKey,
|
|
1478
|
-
model
|
|
1903
|
+
model,
|
|
1479
1904
|
type: config.type,
|
|
1480
1905
|
timeout: config.timeout ? parseInt(config.timeout, 10) : void 0,
|
|
1481
|
-
hint
|
|
1906
|
+
hint,
|
|
1907
|
+
provider,
|
|
1908
|
+
proxy: config.proxy
|
|
1482
1909
|
});
|
|
1483
1910
|
}
|
|
1484
1911
|
function buildExcludedFilesMessage(files) {
|
|
@@ -1491,46 +1918,143 @@ function buildExcludedFilesMessage(files) {
|
|
|
1491
1918
|
return "chore: update generated files";
|
|
1492
1919
|
}
|
|
1493
1920
|
//#endregion
|
|
1494
|
-
//#region src/commands/commit.ts
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1921
|
+
//#region src/commands/commit-utils.ts
|
|
1922
|
+
/** Shared recovery menu factory — avoids repeating the same callback set */
|
|
1923
|
+
function makeRecoveryCallbacks(message) {
|
|
1924
|
+
return {
|
|
1925
|
+
retry: async () => (await attemptCommit(message)).ok,
|
|
1926
|
+
skipHooks: async (msg) => (await attemptCommitNoVerify(msg)).ok,
|
|
1927
|
+
restage: async () => {
|
|
1928
|
+
await stageAll();
|
|
1929
|
+
return (await attemptCommit(message)).ok;
|
|
1930
|
+
},
|
|
1931
|
+
message
|
|
1932
|
+
};
|
|
1933
|
+
}
|
|
1934
|
+
/**
|
|
1935
|
+
* Attempt commit with automatic recovery flow.
|
|
1936
|
+
* Handles the attempt → HEAD check → success (tool checks display)
|
|
1937
|
+
* / failure (recovery menu) pattern.
|
|
1938
|
+
* Caller is responsible for starting the spinner and showing the final outro.
|
|
1939
|
+
*/
|
|
1940
|
+
async function commitWithRecovery(message, s, headBefore) {
|
|
1941
|
+
const result = await attemptCommit(message, [], createProgressHandler(s));
|
|
1942
|
+
const headAfter = await getHead();
|
|
1943
|
+
if (result.ok || headBefore !== headAfter) {
|
|
1944
|
+
s.stop("Committed successfully.");
|
|
1945
|
+
const checks = parseToolChecks(result.stderr ?? "");
|
|
1946
|
+
if (checks.length > 0) {
|
|
1947
|
+
const lines = checks.map((c) => ` ${c.ok ? green("✓") : red("✗")} ${c.tool}`);
|
|
1948
|
+
log.info(lines.join("\n"));
|
|
1508
1949
|
}
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1950
|
+
return "committed";
|
|
1951
|
+
}
|
|
1952
|
+
s.stop("Commit failed.");
|
|
1953
|
+
const errors = parseHookErrors(result.stderr ?? "");
|
|
1954
|
+
const cb = makeRecoveryCallbacks(message);
|
|
1955
|
+
if (await showRecoveryMenu(errors, cb.retry, cb.skipHooks, cb.restage, cb.message, result.stderr ?? "") === "cancelled") return "cancelled";
|
|
1956
|
+
return "committed";
|
|
1957
|
+
}
|
|
1958
|
+
//#endregion
|
|
1959
|
+
//#region src/commands/retry.ts
|
|
1960
|
+
/** Handle --retry mode: load cached message and re-attempt commit */
|
|
1961
|
+
async function handleRetry() {
|
|
1962
|
+
debug("Entering retry mode");
|
|
1963
|
+
const cached = await loadCachedCommit(await getRepoRoot());
|
|
1964
|
+
if (!cached) {
|
|
1965
|
+
outro(red("No cached commit message found. Run cmint without --retry first."));
|
|
1966
|
+
process.exit(1);
|
|
1967
|
+
}
|
|
1968
|
+
intro("🌿 commit-mint — retry");
|
|
1969
|
+
const s = spinner();
|
|
1970
|
+
const headBefore = await getHead();
|
|
1971
|
+
s.start("Running pre-commit hooks...");
|
|
1972
|
+
if (await commitWithRecovery(cached.message, s, headBefore) === "committed") outro(green("Committed successfully."));
|
|
1973
|
+
else process.exit(1);
|
|
1974
|
+
}
|
|
1975
|
+
//#endregion
|
|
1976
|
+
//#region src/commands/staging.ts
|
|
1977
|
+
/** Interactive staging loop for multiple changed files */
|
|
1978
|
+
async function handleStaging(changedFiles, flags) {
|
|
1979
|
+
const repoRoot = await getRepoRoot();
|
|
1980
|
+
const checksAvailable = await detectConfig(repoRoot) !== null;
|
|
1981
|
+
debug("checks available:", checksAvailable);
|
|
1982
|
+
let stagingResult = null;
|
|
1983
|
+
let filesToStage = [];
|
|
1984
|
+
let stageAllFlag = false;
|
|
1985
|
+
let skipStaging = false;
|
|
1986
|
+
let currentFiles = changedFiles;
|
|
1987
|
+
while (true) {
|
|
1988
|
+
stagingResult = await showStagingMenu(currentFiles, checksAvailable);
|
|
1989
|
+
if (stagingResult === "autogroup") {
|
|
1990
|
+
if (flags.message) {
|
|
1991
|
+
outro(red("--message flag is not compatible with auto-group mode."));
|
|
1992
|
+
return null;
|
|
1521
1993
|
}
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1994
|
+
if (await runAutoGroupFlow(currentFiles, flags) !== "committed") process.exit(1);
|
|
1995
|
+
return null;
|
|
1996
|
+
}
|
|
1997
|
+
if (stagingResult === "checks") {
|
|
1998
|
+
await stageAll();
|
|
1999
|
+
const ckSpinner = spinner();
|
|
2000
|
+
ckSpinner.start("Running checks...");
|
|
2001
|
+
const ckResult = await runAllChecks(repoRoot, currentFiles.filter((f) => f.status !== "D").map((f) => f.path), 6e4);
|
|
2002
|
+
if (ckResult.ok) {
|
|
2003
|
+
ckSpinner.stop("All checks passed");
|
|
2004
|
+
for (const r of ckResult.results) if (r.stdout.trim()) log.info(dim(r.stdout.trim()));
|
|
2005
|
+
} else {
|
|
2006
|
+
const failed = ckResult.results.filter((r) => !r.ok);
|
|
2007
|
+
ckSpinner.stop(`${failed.length} check${failed.length !== 1 ? "s" : ""} failed`);
|
|
2008
|
+
for (const r of failed) log.info(r.stderr?.trim() || r.stdout?.trim() || `Check failed: ${r.command}`);
|
|
2009
|
+
}
|
|
2010
|
+
currentFiles = await getChangedFiles();
|
|
2011
|
+
continue;
|
|
2012
|
+
}
|
|
2013
|
+
if (stagingResult === "staged") {
|
|
2014
|
+
skipStaging = true;
|
|
2015
|
+
break;
|
|
2016
|
+
}
|
|
2017
|
+
if (!stagingResult) {
|
|
2018
|
+
outro(dim("Cancelled."));
|
|
2019
|
+
return null;
|
|
1531
2020
|
}
|
|
2021
|
+
filesToStage = stagingResult.files;
|
|
2022
|
+
stageAllFlag = stagingResult.all;
|
|
2023
|
+
break;
|
|
2024
|
+
}
|
|
2025
|
+
if (!skipStaging) {
|
|
2026
|
+
const s = spinner();
|
|
2027
|
+
s.start(`Staging ${filesToStage.length} file${filesToStage.length !== 1 ? "s" : ""}...`);
|
|
2028
|
+
if (stageAllFlag) await stageAll();
|
|
2029
|
+
else await stageFiles(filesToStage);
|
|
2030
|
+
s.stop("Files staged");
|
|
1532
2031
|
}
|
|
1533
|
-
|
|
2032
|
+
return {
|
|
2033
|
+
changedFiles: currentFiles,
|
|
2034
|
+
skipStaging
|
|
2035
|
+
};
|
|
2036
|
+
}
|
|
2037
|
+
/** Run user-defined pre-commit checks from cmint config */
|
|
2038
|
+
async function runPreCommitChecks(changedFiles, noCheck) {
|
|
2039
|
+
if (noCheck) return;
|
|
2040
|
+
const checkRoot = await getRepoRoot();
|
|
2041
|
+
const stagedFileList = changedFiles.filter((f) => f.staged && f.status !== "D").map((f) => f.path);
|
|
2042
|
+
if (stagedFileList.length === 0) return;
|
|
2043
|
+
debug("Running user checks on %d staged files...", stagedFileList.length);
|
|
2044
|
+
const checkResults = await runAllChecks(checkRoot, stagedFileList, 6e4);
|
|
2045
|
+
debug("Check results: ok=%s, count=%d", checkResults.ok, checkResults.results.length);
|
|
2046
|
+
if (!checkResults.ok) {
|
|
2047
|
+
const rawStderr = checkResults.results.filter((r) => !r.ok).map((r) => `[${r.tool}] ${r.stderr}`).join("\n");
|
|
2048
|
+
if (await showCheckFailureMenu(parseHookErrors(rawStderr), rawStderr) === "cancelled") process.exit(1);
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
//#endregion
|
|
2052
|
+
//#region src/commands/commit.ts
|
|
2053
|
+
async function commitCommand(flags) {
|
|
2054
|
+
debug("commitCommand called", { flags });
|
|
2055
|
+
await assertGitRepo();
|
|
2056
|
+
if (flags.retry) return handleRetry();
|
|
2057
|
+
intro("🌿 commit-mint");
|
|
1534
2058
|
const status = await getStatusShort();
|
|
1535
2059
|
debug("Git status:", status || "(empty)");
|
|
1536
2060
|
if (!status) {
|
|
@@ -1553,49 +2077,9 @@ async function commitCommand(flags) {
|
|
|
1553
2077
|
await stageFiles([changedFiles[0].path]);
|
|
1554
2078
|
s.stop("File staged");
|
|
1555
2079
|
} else {
|
|
1556
|
-
const
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
let stagingResult = null;
|
|
1560
|
-
let filesToStage = [];
|
|
1561
|
-
let stageAllFlag = false;
|
|
1562
|
-
while (true) {
|
|
1563
|
-
stagingResult = await showStagingMenu(changedFiles, lintStagedAvailable);
|
|
1564
|
-
if (stagingResult === "autogroup") {
|
|
1565
|
-
if (flags.message) {
|
|
1566
|
-
outro(red("--message flag is not compatible with auto-group mode."));
|
|
1567
|
-
return;
|
|
1568
|
-
}
|
|
1569
|
-
if (await runAutoGroupFlow(changedFiles, flags) !== "committed") process.exit(1);
|
|
1570
|
-
return;
|
|
1571
|
-
}
|
|
1572
|
-
if (stagingResult === "lint-staged") {
|
|
1573
|
-
await stageAll();
|
|
1574
|
-
const lsSpinner = spinner();
|
|
1575
|
-
lsSpinner.start("Running lint-staged checks...");
|
|
1576
|
-
const lsResult = await runLintStaged();
|
|
1577
|
-
if (lsResult.ok) {
|
|
1578
|
-
lsSpinner.stop("All lint-staged checks passed");
|
|
1579
|
-
if (lsResult.stdout.trim()) log.info(dim(lsResult.stdout.trim()));
|
|
1580
|
-
} else {
|
|
1581
|
-
lsSpinner.stop("Lint-staged checks failed");
|
|
1582
|
-
log.info(lsResult.stderr?.trim() || lsResult.stdout?.trim() || "Unknown error");
|
|
1583
|
-
}
|
|
1584
|
-
changedFiles = await getChangedFiles();
|
|
1585
|
-
continue;
|
|
1586
|
-
}
|
|
1587
|
-
if (!stagingResult) {
|
|
1588
|
-
outro(dim("Cancelled."));
|
|
1589
|
-
return;
|
|
1590
|
-
}
|
|
1591
|
-
filesToStage = stagingResult.files;
|
|
1592
|
-
stageAllFlag = stagingResult.all;
|
|
1593
|
-
break;
|
|
1594
|
-
}
|
|
1595
|
-
s.start(`Staging ${filesToStage.length} file${filesToStage.length !== 1 ? "s" : ""}...`);
|
|
1596
|
-
if (stageAllFlag) await stageAll();
|
|
1597
|
-
else await stageFiles(filesToStage);
|
|
1598
|
-
s.stop("Files staged");
|
|
2080
|
+
const result = await handleStaging(changedFiles, flags);
|
|
2081
|
+
if (!result) return;
|
|
2082
|
+
changedFiles = result.changedFiles;
|
|
1599
2083
|
}
|
|
1600
2084
|
} catch (err) {
|
|
1601
2085
|
s.stop(red("Staging failed."));
|
|
@@ -1604,6 +2088,8 @@ async function commitCommand(flags) {
|
|
|
1604
2088
|
outro(red(`Failed to stage files: ${msg}`));
|
|
1605
2089
|
process.exit(1);
|
|
1606
2090
|
}
|
|
2091
|
+
changedFiles = await getChangedFiles();
|
|
2092
|
+
await runPreCommitChecks(changedFiles, flags.noCheck);
|
|
1607
2093
|
const diffResult = await getStagedDiff();
|
|
1608
2094
|
if (!diffResult) {
|
|
1609
2095
|
debug("No staged changes found after staging");
|
|
@@ -1614,22 +2100,14 @@ async function commitCommand(flags) {
|
|
|
1614
2100
|
debug("All staged files are excluded:", diffResult.excludedFiles);
|
|
1615
2101
|
const message = buildExcludedFilesMessage(diffResult.excludedFiles);
|
|
1616
2102
|
log.info(diffResult.excludedFiles.map((f) => ` ${f}`).join("\n"));
|
|
1617
|
-
const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
|
|
1618
2103
|
await saveCachedCommit(await getRepoRoot(), message);
|
|
1619
|
-
s.start("
|
|
1620
|
-
const
|
|
1621
|
-
|
|
1622
|
-
const headAfter = await getHead();
|
|
1623
|
-
if (result.ok || headBefore !== headAfter) {
|
|
1624
|
-
s.stop("Committed successfully.");
|
|
2104
|
+
s.start("Running pre-commit hooks...");
|
|
2105
|
+
const result = await commitWithRecovery(message, s, await getHead());
|
|
2106
|
+
if (result === "committed") {
|
|
1625
2107
|
outro(green("Done."));
|
|
1626
2108
|
return;
|
|
1627
2109
|
}
|
|
1628
|
-
|
|
1629
|
-
if (await showRecoveryMenu(parseHookErrors(result.stderr ?? ""), async () => (await attemptCommit(message)).ok, async (msg) => (await attemptCommitNoVerify(msg)).ok, async () => {
|
|
1630
|
-
await stageAll();
|
|
1631
|
-
return (await attemptCommit(message)).ok;
|
|
1632
|
-
}, message, result.stderr ?? "") === "cancelled") process.exit(1);
|
|
2110
|
+
if (result === "cancelled") process.exit(1);
|
|
1633
2111
|
return;
|
|
1634
2112
|
}
|
|
1635
2113
|
debug("Staged files:", diffResult.files);
|
|
@@ -1640,22 +2118,25 @@ async function commitCommand(flags) {
|
|
|
1640
2118
|
debug("Using provided message:", flags.message);
|
|
1641
2119
|
message = flags.message;
|
|
1642
2120
|
} else {
|
|
2121
|
+
const config = await readConfig();
|
|
2122
|
+
const provider = isValidProvider(config.provider ?? "groq") ? config.provider : "groq";
|
|
1643
2123
|
try {
|
|
1644
|
-
await
|
|
2124
|
+
await getProviderApiKey(provider);
|
|
1645
2125
|
debug("API key found");
|
|
1646
2126
|
} catch {
|
|
1647
2127
|
debug("No API key found, prompting user");
|
|
1648
2128
|
const { text: promptText } = await import("@clack/prompts");
|
|
2129
|
+
const configKey = PROVIDER_ENV_KEYS[provider];
|
|
1649
2130
|
const key = await promptText({
|
|
1650
|
-
message:
|
|
1651
|
-
placeholder: "gsk_...",
|
|
2131
|
+
message: `Enter your ${formatProviderName(provider)} API key:`,
|
|
2132
|
+
placeholder: provider === "groq" ? "gsk_..." : "...",
|
|
1652
2133
|
validate: (v) => v?.trim() ? void 0 : "API key is required"
|
|
1653
2134
|
});
|
|
1654
2135
|
if (isCancel(key)) {
|
|
1655
2136
|
outro(dim("Cancelled."));
|
|
1656
2137
|
return;
|
|
1657
2138
|
}
|
|
1658
|
-
await setConfigValue(
|
|
2139
|
+
await setConfigValue(configKey, String(key).trim());
|
|
1659
2140
|
debug("API key saved to config");
|
|
1660
2141
|
}
|
|
1661
2142
|
s.start("Generating commit message...");
|
|
@@ -1678,66 +2159,197 @@ async function commitCommand(flags) {
|
|
|
1678
2159
|
return;
|
|
1679
2160
|
}
|
|
1680
2161
|
message = reviewed;
|
|
1681
|
-
const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
|
|
1682
2162
|
const repoRoot = await getRepoRoot();
|
|
1683
2163
|
await saveCachedCommit(repoRoot, message);
|
|
1684
2164
|
debug("Message cached for repo:", repoRoot);
|
|
1685
|
-
s.start("
|
|
2165
|
+
s.start("Running pre-commit hooks...");
|
|
1686
2166
|
const headBefore = await getHead();
|
|
1687
2167
|
debug("HEAD before commit:", headBefore);
|
|
1688
|
-
const result = await
|
|
1689
|
-
const headAfter = await getHead();
|
|
1690
|
-
debug("HEAD after commit:", headAfter);
|
|
2168
|
+
const result = await commitWithRecovery(message, s, headBefore);
|
|
1691
2169
|
debug("Commit result:", result);
|
|
1692
|
-
if (result
|
|
1693
|
-
s.stop("Committed successfully.");
|
|
1694
|
-
const checks = parseToolChecks(result.stderr ?? "");
|
|
1695
|
-
if (checks.length > 0) {
|
|
1696
|
-
const lines = checks.map((c) => ` ${c.ok ? green("✓") : red("✗")} ${c.tool}`);
|
|
1697
|
-
log.info(lines.join("\n"));
|
|
1698
|
-
}
|
|
2170
|
+
if (result === "committed") {
|
|
1699
2171
|
outro(green("Done."));
|
|
1700
2172
|
return;
|
|
1701
2173
|
}
|
|
1702
|
-
|
|
1703
|
-
debug("Commit failed, showing recovery menu");
|
|
1704
|
-
const errors = parseHookErrors(result.stderr ?? "");
|
|
1705
|
-
debug("Parsed hook errors:", errors.length, "errors");
|
|
1706
|
-
if (await showRecoveryMenu(errors, async () => {
|
|
1707
|
-
return (await attemptCommit(message)).ok;
|
|
1708
|
-
}, async (msg) => {
|
|
1709
|
-
return (await attemptCommitNoVerify(msg)).ok;
|
|
1710
|
-
}, async () => {
|
|
1711
|
-
await stageAll();
|
|
1712
|
-
return (await attemptCommit(message)).ok;
|
|
1713
|
-
}, message, result.stderr ?? "") === "cancelled") process.exit(1);
|
|
2174
|
+
if (result === "cancelled") process.exit(1);
|
|
1714
2175
|
}
|
|
1715
2176
|
//#endregion
|
|
1716
2177
|
//#region src/commands/config.ts
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
}
|
|
1728
|
-
|
|
2178
|
+
function maskKey(key) {
|
|
2179
|
+
if (!key) return dim("not set");
|
|
2180
|
+
if (key.length <= 8) return "****";
|
|
2181
|
+
return `${key.slice(0, 4)}${"*".repeat(Math.min(key.length - 8, 20))}${key.slice(-4)}`;
|
|
2182
|
+
}
|
|
2183
|
+
function buildConfigDisplay(config) {
|
|
2184
|
+
const provider = isValidProvider(config.provider ?? "groq") ? config.provider : "groq";
|
|
2185
|
+
const apiKey = config[PROVIDER_ENV_KEYS[provider]];
|
|
2186
|
+
return [
|
|
2187
|
+
`Provider: ${bold(formatProviderName(provider))}`,
|
|
2188
|
+
`API Key: ${maskKey(apiKey)}`,
|
|
2189
|
+
`Model: ${config.model ?? "(none)"}`,
|
|
2190
|
+
`Locale: ${config.locale ?? "en"}`,
|
|
2191
|
+
`Max Length: ${config["max-length"] ?? "100"}`,
|
|
2192
|
+
`Commit Type: ${config.type || dim("(none)")}`,
|
|
2193
|
+
`Timeout: ${config.timeout ?? "10000"}ms`,
|
|
2194
|
+
`Proxy: ${config.proxy || dim("(none)")}`
|
|
2195
|
+
].join("\n");
|
|
2196
|
+
}
|
|
2197
|
+
function getProvider(config) {
|
|
2198
|
+
return isValidProvider(config.provider ?? "groq") ? config.provider : "groq";
|
|
2199
|
+
}
|
|
2200
|
+
async function promptProvider() {
|
|
2201
|
+
return p.select({
|
|
2202
|
+
message: "Select LLM provider:",
|
|
2203
|
+
options: [
|
|
2204
|
+
{
|
|
2205
|
+
label: "Groq",
|
|
2206
|
+
value: "groq",
|
|
2207
|
+
hint: PROVIDER_CONFIGS.groq.defaultModel
|
|
2208
|
+
},
|
|
2209
|
+
{
|
|
2210
|
+
label: "Cerebras",
|
|
2211
|
+
value: "cerebras",
|
|
2212
|
+
hint: PROVIDER_CONFIGS.cerebras.defaultModel
|
|
2213
|
+
},
|
|
2214
|
+
{
|
|
2215
|
+
label: "Mistral",
|
|
2216
|
+
value: "mistral",
|
|
2217
|
+
hint: PROVIDER_CONFIGS.mistral.defaultModel
|
|
2218
|
+
}
|
|
2219
|
+
]
|
|
2220
|
+
});
|
|
2221
|
+
}
|
|
2222
|
+
async function promptApiKey(provider) {
|
|
2223
|
+
const keyName = PROVIDER_ENV_KEYS[provider];
|
|
2224
|
+
const result = await p.text({
|
|
2225
|
+
message: `${formatProviderName(provider)} API key:`,
|
|
2226
|
+
placeholder: "Paste your API key",
|
|
2227
|
+
validate: (v) => !v?.trim() ? "API key cannot be empty" : void 0
|
|
2228
|
+
});
|
|
2229
|
+
if (p.isCancel(result)) return result;
|
|
2230
|
+
await writeConfig({ [keyName]: result.toString().trim() });
|
|
2231
|
+
debug("config: %s set", keyName);
|
|
2232
|
+
return result;
|
|
2233
|
+
}
|
|
2234
|
+
async function promptTextSetting(label, configKey, currentValue, validate) {
|
|
2235
|
+
const result = await p.text({
|
|
2236
|
+
message: label,
|
|
2237
|
+
placeholder: currentValue ?? "",
|
|
2238
|
+
initialValue: currentValue ?? "",
|
|
2239
|
+
validate
|
|
2240
|
+
});
|
|
2241
|
+
if (p.isCancel(result)) return result;
|
|
2242
|
+
await writeConfig({ [configKey]: result.toString().trim() });
|
|
2243
|
+
debug("config: %s set to %s", configKey, result);
|
|
2244
|
+
return result;
|
|
2245
|
+
}
|
|
2246
|
+
const requireNumber = (v) => {
|
|
2247
|
+
if (!v?.trim()) return "Value cannot be empty";
|
|
2248
|
+
return Number.isNaN(Number(v)) ? "Must be a number" : void 0;
|
|
2249
|
+
};
|
|
2250
|
+
function getSettingHandlers(config) {
|
|
2251
|
+
const provider = getProvider(config);
|
|
2252
|
+
return {
|
|
2253
|
+
provider: async () => {
|
|
2254
|
+
const result = await promptProvider();
|
|
2255
|
+
if (p.isCancel(result)) return result;
|
|
2256
|
+
await writeConfig({ provider: result });
|
|
2257
|
+
debug("config: provider set to %s", result);
|
|
2258
|
+
},
|
|
2259
|
+
apikey: async () => promptApiKey(provider),
|
|
2260
|
+
model: async () => promptTextSetting("Model ID:", "model", config.model),
|
|
2261
|
+
locale: async () => promptTextSetting("Locale (e.g. en, ja, ko):", "locale", config.locale),
|
|
2262
|
+
maxlen: async () => promptTextSetting("Max commit message length:", "max-length", config["max-length"], requireNumber),
|
|
2263
|
+
type: async () => promptTextSetting("Commit type prefix (e.g. conventional):", "type", config.type),
|
|
2264
|
+
timeout: async () => promptTextSetting("Timeout (ms):", "timeout", config.timeout, requireNumber),
|
|
2265
|
+
proxy: async () => promptTextSetting("Proxy URL:", "proxy", config.proxy)
|
|
2266
|
+
};
|
|
2267
|
+
}
|
|
2268
|
+
async function handleEditSetting(setting, config) {
|
|
2269
|
+
const handler = getSettingHandlers(config)[setting];
|
|
2270
|
+
if (!handler) return false;
|
|
2271
|
+
const result = await handler();
|
|
2272
|
+
return !p.isCancel(result);
|
|
2273
|
+
}
|
|
2274
|
+
async function editSettingsLoop(initialConfig) {
|
|
2275
|
+
let config = initialConfig;
|
|
2276
|
+
while (true) {
|
|
2277
|
+
config = await readConfig();
|
|
2278
|
+
const provider = getProvider(config);
|
|
2279
|
+
const setting = await p.select({
|
|
2280
|
+
message: "Select a setting to edit:",
|
|
2281
|
+
options: [
|
|
2282
|
+
{
|
|
2283
|
+
label: `LLM Provider ${dim(`(${formatProviderName(provider)})`)}`,
|
|
2284
|
+
value: "provider"
|
|
2285
|
+
},
|
|
2286
|
+
{
|
|
2287
|
+
label: `API Key ${dim(`(for ${formatProviderName(provider)})`)}`,
|
|
2288
|
+
value: "apikey"
|
|
2289
|
+
},
|
|
2290
|
+
{
|
|
2291
|
+
label: `Model ${dim(`(${config.model ?? "(none)"})`)}`,
|
|
2292
|
+
value: "model"
|
|
2293
|
+
},
|
|
2294
|
+
{
|
|
2295
|
+
label: `Locale ${dim(`(${config.locale ?? "en"})`)}`,
|
|
2296
|
+
value: "locale"
|
|
2297
|
+
},
|
|
2298
|
+
{
|
|
2299
|
+
label: `Max commit length ${dim(`(${config["max-length"] ?? "100"})`)}`,
|
|
2300
|
+
value: "maxlen"
|
|
2301
|
+
},
|
|
2302
|
+
{
|
|
2303
|
+
label: `Commit type prefix ${dim(`(${config.type || "(none)"})`)}`,
|
|
2304
|
+
value: "type"
|
|
2305
|
+
},
|
|
2306
|
+
{
|
|
2307
|
+
label: `Timeout (ms) ${dim(`(${config.timeout ?? "10000"})`)}`,
|
|
2308
|
+
value: "timeout"
|
|
2309
|
+
},
|
|
2310
|
+
{
|
|
2311
|
+
label: `Proxy URL ${dim(`(${config.proxy || "(none)"})`)}`,
|
|
2312
|
+
value: "proxy"
|
|
2313
|
+
},
|
|
2314
|
+
{
|
|
2315
|
+
label: "Done editing",
|
|
2316
|
+
value: "done"
|
|
2317
|
+
}
|
|
2318
|
+
]
|
|
2319
|
+
});
|
|
2320
|
+
if (p.isCancel(setting) || setting === "done") break;
|
|
2321
|
+
if (await handleEditSetting(setting, config)) p.log.success(green("Updated."));
|
|
1729
2322
|
}
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
2323
|
+
}
|
|
2324
|
+
async function configCommand() {
|
|
2325
|
+
debug("configCommand: starting");
|
|
2326
|
+
p.intro(bold("🌿 commit-mint config"));
|
|
2327
|
+
while (true) {
|
|
2328
|
+
const config = await readConfig();
|
|
2329
|
+
p.note(buildConfigDisplay(config), "commit-mint config");
|
|
2330
|
+
const action = await p.select({
|
|
2331
|
+
message: "What would you like to do?",
|
|
2332
|
+
options: [{
|
|
2333
|
+
label: "Edit settings",
|
|
2334
|
+
value: "edit"
|
|
2335
|
+
}, {
|
|
2336
|
+
label: "Done",
|
|
2337
|
+
value: "done"
|
|
2338
|
+
}]
|
|
2339
|
+
});
|
|
2340
|
+
if (p.isCancel(action)) {
|
|
2341
|
+
debug("configCommand: cancelled at main menu");
|
|
2342
|
+
p.outro(dim("Cancelled."));
|
|
2343
|
+
return;
|
|
1734
2344
|
}
|
|
1735
|
-
|
|
1736
|
-
|
|
2345
|
+
if (action === "done") {
|
|
2346
|
+
debug("configCommand: done");
|
|
2347
|
+
p.outro("Config saved.");
|
|
2348
|
+
return;
|
|
2349
|
+
}
|
|
2350
|
+
await editSettingsLoop(config);
|
|
1737
2351
|
}
|
|
1738
|
-
|
|
1739
|
-
process.exit(1);
|
|
1740
|
-
});
|
|
2352
|
+
}
|
|
1741
2353
|
//#endregion
|
|
1742
2354
|
//#region src/cli.ts
|
|
1743
2355
|
const { version } = package_default;
|
|
@@ -1779,9 +2391,17 @@ cli({
|
|
|
1779
2391
|
description: "Enable debug output",
|
|
1780
2392
|
alias: "d",
|
|
1781
2393
|
default: false
|
|
2394
|
+
},
|
|
2395
|
+
noCheck: {
|
|
2396
|
+
type: Boolean,
|
|
2397
|
+
description: "Skip user-defined pre-commit checks",
|
|
2398
|
+
alias: "N",
|
|
2399
|
+
default: false
|
|
1782
2400
|
}
|
|
1783
2401
|
},
|
|
1784
|
-
commands: [
|
|
2402
|
+
commands: [command({ name: "config" }, async () => {
|
|
2403
|
+
await configCommand();
|
|
2404
|
+
})]
|
|
1785
2405
|
}, (argv) => {
|
|
1786
2406
|
setDebug(argv.flags.debug);
|
|
1787
2407
|
if (argv.flags.review) reviewCommand();
|