@kyubiware/commit-mint 0.6.0 → 0.6.2
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 +193 -136
- package/dist/cli.mjs +909 -544
- package/dist/cli.mjs.map +1 -1
- package/package.json +2 -2
package/dist/cli.mjs
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { cli, command } from "cleye";
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import { bold, cyan, dim, green, red, yellow } from "kolorist";
|
|
6
|
-
import { access, constants, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import Groq from "groq-sdk";
|
|
4
|
+
import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
5
|
import os from "node:os";
|
|
8
6
|
import { extname, join } from "node:path";
|
|
9
|
-
import
|
|
10
|
-
import
|
|
7
|
+
import { bold, cyan, dim, green, red, yellow } from "kolorist";
|
|
8
|
+
import { access, constants, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
11
9
|
import { execa } from "execa";
|
|
12
|
-
import { createHash } from "node:crypto";
|
|
13
|
-
import { readFileSync } from "node:fs";
|
|
14
10
|
import picomatch from "picomatch";
|
|
11
|
+
import ini from "ini";
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import * as p from "@clack/prompts";
|
|
14
|
+
import { intro, isCancel, log, outro, spinner } from "@clack/prompts";
|
|
15
15
|
import { spawn } from "node:child_process";
|
|
16
16
|
//#region \0rolldown/runtime.js
|
|
17
17
|
var __defProp = Object.defineProperty;
|
|
@@ -28,7 +28,7 @@ var __exportAll = (all, no_symbols) => {
|
|
|
28
28
|
//#region package.json
|
|
29
29
|
var package_default = {
|
|
30
30
|
name: "@kyubiware/commit-mint",
|
|
31
|
-
version: "0.6.
|
|
31
|
+
version: "0.6.2",
|
|
32
32
|
description: "🌿 A commit tool that actually handles hook failures",
|
|
33
33
|
type: "module",
|
|
34
34
|
bin: { "cmint": "./dist/cli.mjs" },
|
|
@@ -39,7 +39,7 @@ var package_default = {
|
|
|
39
39
|
"dev:auto": "tsx src/cli.ts -a",
|
|
40
40
|
"dev:debug": "tsx src/cli.ts --debug",
|
|
41
41
|
"lint": "biome check .",
|
|
42
|
-
"lint:fix": "biome check --
|
|
42
|
+
"lint:fix": "biome check --write --unsafe .",
|
|
43
43
|
"typecheck": "tsc --noEmit",
|
|
44
44
|
"test": "vitest run",
|
|
45
45
|
"test:coverage": "vitest run --coverage",
|
|
@@ -89,13 +89,30 @@ var package_default = {
|
|
|
89
89
|
//#endregion
|
|
90
90
|
//#region src/utils/debug.ts
|
|
91
91
|
let enabled = false;
|
|
92
|
+
let dirEnsured = false;
|
|
93
|
+
let sessionWritten = false;
|
|
94
|
+
let logFile = join(os.homedir(), ".cache", "commit-mint", "debug.log");
|
|
95
|
+
const LOG_DIR = join(os.homedir(), ".cache", "commit-mint");
|
|
96
|
+
function ensureLogDir() {
|
|
97
|
+
if (dirEnsured) return;
|
|
98
|
+
mkdirSync(LOG_DIR, { recursive: true });
|
|
99
|
+
dirEnsured = true;
|
|
100
|
+
}
|
|
92
101
|
function setDebug(value) {
|
|
93
102
|
enabled = value;
|
|
94
103
|
}
|
|
104
|
+
function writeSessionHeader() {
|
|
105
|
+
if (sessionWritten) return;
|
|
106
|
+
ensureLogDir();
|
|
107
|
+
writeFileSync(logFile, `--- session ${(/* @__PURE__ */ new Date()).toISOString()} ---\n`, "utf8");
|
|
108
|
+
sessionWritten = true;
|
|
109
|
+
}
|
|
95
110
|
function debug(...args) {
|
|
111
|
+
const prefix = `[debug ${(/* @__PURE__ */ new Date()).toISOString().slice(11, 23)}]`;
|
|
112
|
+
ensureLogDir();
|
|
113
|
+
appendFileSync(logFile, `${prefix} ${args.map(String).join(" ")}\n`, "utf8");
|
|
96
114
|
if (!enabled) return;
|
|
97
|
-
|
|
98
|
-
console.error(dim(`[debug ${timestamp}]`), ...args);
|
|
115
|
+
console.error(dim(prefix), ...args);
|
|
99
116
|
}
|
|
100
117
|
//#endregion
|
|
101
118
|
//#region src/services/provider.ts
|
|
@@ -175,69 +192,178 @@ function createProvider(options) {
|
|
|
175
192
|
};
|
|
176
193
|
}
|
|
177
194
|
//#endregion
|
|
178
|
-
//#region src/services/
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
"
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
};
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
195
|
+
//#region src/services/ai.ts
|
|
196
|
+
const MAX_DIFF_CHARS = 2e4;
|
|
197
|
+
function mapGroqError(error, providerLabel) {
|
|
198
|
+
const label = providerLabel ?? "Groq";
|
|
199
|
+
if (error instanceof Groq.AuthenticationError) return /* @__PURE__ */ new Error(`Invalid API key for ${label}. Run: cmint config set ${label.toUpperCase()}_API_KEY=<key>`);
|
|
200
|
+
if (error instanceof Groq.RateLimitError) return /* @__PURE__ */ new Error(`Rate limited by ${label}. Please wait and try again.`);
|
|
201
|
+
if (error instanceof Groq.APIConnectionTimeoutError) return /* @__PURE__ */ new Error("Request timed out. Check your network or try a smaller diff.");
|
|
202
|
+
if (error instanceof Groq.APIError) return /* @__PURE__ */ new Error(`${label} API error: ${error.message}`);
|
|
203
|
+
if (error instanceof Error && /^4\d{2}\s/.test(error.message)) return /* @__PURE__ */ new Error(`${label} API error: ${error.message}`);
|
|
204
|
+
return /* @__PURE__ */ new Error(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`);
|
|
205
|
+
}
|
|
206
|
+
const CONVENTIONAL_COMMIT_REGEX = /^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.+\))?!?: .+$/;
|
|
207
|
+
function stripThinkTags(text) {
|
|
208
|
+
return text.replace(/<think[\s\S]*?<\/think>/gi, "").trim();
|
|
209
|
+
}
|
|
210
|
+
function deriveMessageFromReasoning(reasoning) {
|
|
211
|
+
const match = reasoning.match(/(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.+\))?!?: .+/i);
|
|
212
|
+
if (match) return match[0].trim();
|
|
213
|
+
const first = reasoning.split(/[.!?]/).find((s) => s.trim().length >= 10);
|
|
214
|
+
return first ? first.trim() : null;
|
|
215
|
+
}
|
|
216
|
+
function stripContextLines(diff) {
|
|
217
|
+
return diff.split("\n").filter((line) => !line.startsWith(" ")).join("\n");
|
|
218
|
+
}
|
|
219
|
+
function compressDiff(diff) {
|
|
220
|
+
if (diff.length <= MAX_DIFF_CHARS) return diff;
|
|
221
|
+
let result = stripContextLines(diff);
|
|
222
|
+
if (result.length <= MAX_DIFF_CHARS) return result;
|
|
223
|
+
result = result.split(/(?=diff --git)/).filter(Boolean).map((fd) => {
|
|
224
|
+
return fd.split(/(?=\n@@)/).map((part, idx) => {
|
|
225
|
+
if (idx === 0) return part;
|
|
226
|
+
const lines = part.split("\n");
|
|
227
|
+
return [lines[0], ...lines.slice(1).filter((l) => l.startsWith("+") || l.startsWith("-")).slice(0, 10)].join("\n");
|
|
228
|
+
}).join("");
|
|
229
|
+
}).join("");
|
|
230
|
+
if (result.length <= MAX_DIFF_CHARS) return result;
|
|
231
|
+
return `Summary of changes:\n${(diff.match(/^diff --git a\/(.+) b\/(.+)$/gm) || []).map((f) => {
|
|
232
|
+
const match = f.match(/^diff --git a\/(.+) b\/(.+)$/);
|
|
233
|
+
return match && match[1] === match[2] ? `${match[1]} | changed` : "";
|
|
234
|
+
}).filter(Boolean).join("\n")}`;
|
|
235
|
+
}
|
|
236
|
+
function buildStatSummary(diff) {
|
|
237
|
+
const files = [];
|
|
238
|
+
let currentFile = "";
|
|
239
|
+
let adds = 0;
|
|
240
|
+
let dels = 0;
|
|
241
|
+
for (const line of diff.split("\n")) {
|
|
242
|
+
const match = line.match(/^diff --git a\/.+ b\/(.+)$/);
|
|
243
|
+
if (match) {
|
|
244
|
+
if (currentFile) files.push({
|
|
245
|
+
name: currentFile,
|
|
246
|
+
adds,
|
|
247
|
+
dels
|
|
248
|
+
});
|
|
249
|
+
currentFile = match[1];
|
|
250
|
+
adds = 0;
|
|
251
|
+
dels = 0;
|
|
252
|
+
} else if (line.startsWith("+") && !line.startsWith("+++")) adds++;
|
|
253
|
+
else if (line.startsWith("-") && !line.startsWith("---")) dels++;
|
|
202
254
|
}
|
|
255
|
+
if (currentFile) files.push({
|
|
256
|
+
name: currentFile,
|
|
257
|
+
adds,
|
|
258
|
+
dels
|
|
259
|
+
});
|
|
260
|
+
const totalAdds = files.reduce((s, f) => s + f.adds, 0);
|
|
261
|
+
const totalDels = files.reduce((s, f) => s + f.dels, 0);
|
|
262
|
+
const lines = files.map((f) => ` ${f.name} | +${f.adds} -${f.dels}`);
|
|
263
|
+
lines.push(` ${files.length} files changed, ${totalAdds} insertions(+), ${totalDels} deletions(-)`);
|
|
264
|
+
return lines.join("\n");
|
|
203
265
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
266
|
+
function buildSystemPrompt(type) {
|
|
267
|
+
let prompt = "You are a commit message generator. Follow the Conventional Commits specification.\nValid types: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test.\nFormat: type(scope): description\nUse imperative mood, lowercase, no trailing period.\nOutput ONLY the commit message, no markdown fences, no explanation.";
|
|
268
|
+
if (type && type.trim().length > 0) prompt += `\nYou MUST use type: ${type}`;
|
|
269
|
+
return prompt;
|
|
208
270
|
}
|
|
209
|
-
|
|
210
|
-
|
|
271
|
+
function buildUserPrompt(diff, hint, statSummary) {
|
|
272
|
+
const parts = [];
|
|
273
|
+
if (hint) parts.push(`Context: ${hint}`);
|
|
274
|
+
if (statSummary) parts.push(`Change summary:\n${statSummary}`);
|
|
275
|
+
parts.push(`Generate a conventional commit for:\n\n${diff}`);
|
|
276
|
+
return parts.join("\n\n");
|
|
211
277
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
278
|
+
function isValidConventionalCommit(message) {
|
|
279
|
+
return CONVENTIONAL_COMMIT_REGEX.test(message);
|
|
280
|
+
}
|
|
281
|
+
function extractContentText(content) {
|
|
282
|
+
if (content == null) return "";
|
|
283
|
+
if (typeof content === "string") return content.trim();
|
|
284
|
+
if (Array.isArray(content)) return content.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => stripThinkTags(part.text)).join("").trim();
|
|
285
|
+
return "";
|
|
286
|
+
}
|
|
287
|
+
async function generateCommitMessage(diff, options) {
|
|
288
|
+
const timeoutMs = options.timeout ?? 6e4;
|
|
289
|
+
debug("Timeout: %d ms", timeoutMs);
|
|
290
|
+
const { client, model } = createProvider({
|
|
291
|
+
provider: options.provider ?? "groq",
|
|
292
|
+
apiKey: options.apiKey,
|
|
293
|
+
modelOverride: options.model,
|
|
294
|
+
timeout: timeoutMs,
|
|
295
|
+
baseURLOverride: options.proxy
|
|
296
|
+
});
|
|
297
|
+
debug("generateCommitMessage: model=%s, type=%s, hint=%s", model, options.type ?? "none", options.hint ?? "none");
|
|
298
|
+
const compressedDiff = compressDiff(diff);
|
|
299
|
+
const statSummary = buildStatSummary(diff);
|
|
300
|
+
const systemPrompt = buildSystemPrompt(options.type);
|
|
301
|
+
const userPrompt = buildUserPrompt(compressedDiff, options.hint, statSummary);
|
|
302
|
+
debug("Diff: %d chars → compressed to %d chars", diff.length, compressedDiff.length);
|
|
303
|
+
debug("Stat summary:\n%s", statSummary);
|
|
304
|
+
debug("User prompt length: %d chars", userPrompt.length);
|
|
305
|
+
async function callAI(strictSystemPrompt) {
|
|
306
|
+
const callStart = Date.now();
|
|
307
|
+
debug("callAI: %s — model=%s, promptLen=%d, systemLen=%d", !!strictSystemPrompt ? "RETRY (strict)" : "INITIAL", model, userPrompt.length, (strictSystemPrompt ?? systemPrompt).length);
|
|
308
|
+
try {
|
|
309
|
+
const isReasoningModel = /^(o[1-9]|.*gpt-oss.*|.*gpt-5.*)/i.test(model);
|
|
310
|
+
const isGroq = (options.provider ?? "groq") === "groq";
|
|
311
|
+
const completion = await client.chat.completions.create({
|
|
312
|
+
messages: [{
|
|
313
|
+
role: "system",
|
|
314
|
+
content: strictSystemPrompt ?? systemPrompt
|
|
315
|
+
}, {
|
|
316
|
+
role: "user",
|
|
317
|
+
content: userPrompt
|
|
318
|
+
}],
|
|
319
|
+
model,
|
|
320
|
+
temperature: .3,
|
|
321
|
+
...isReasoningModel ? { max_completion_tokens: 1024 } : { max_tokens: 1024 },
|
|
322
|
+
...isGroq && isReasoningModel ? { reasoning_format: "parsed" } : {}
|
|
323
|
+
});
|
|
324
|
+
const elapsed = Date.now() - callStart;
|
|
325
|
+
const rawContent = completion.choices[0]?.message?.content;
|
|
326
|
+
const content = extractContentText(typeof rawContent === "string" ? stripThinkTags(rawContent) : rawContent);
|
|
327
|
+
debug("callAI response (%d ms): choices=%d, finishReason=%s, contentLen=%d, rawType=%s", elapsed, completion.choices.length, completion.choices[0]?.finish_reason ?? "(none)", content.length, typeof rawContent);
|
|
328
|
+
debug("callAI raw content: %s", content.slice(0, 300) || "(empty)");
|
|
329
|
+
if (!content) {
|
|
330
|
+
const reasoning = completion.choices[0]?.message?.reasoning;
|
|
331
|
+
debug("callAI: content empty, attempting reasoning fallback (reasoningLen=%d)", reasoning?.length ?? 0);
|
|
332
|
+
if (reasoning) {
|
|
333
|
+
const derived = deriveMessageFromReasoning(reasoning);
|
|
334
|
+
if (derived) {
|
|
335
|
+
debug("callAI: derived message from reasoning: %s", derived.slice(0, 100));
|
|
336
|
+
return stripThinkTags(derived);
|
|
337
|
+
}
|
|
338
|
+
debug("callAI: could not derive message from reasoning");
|
|
339
|
+
}
|
|
340
|
+
throw new Error("AI returned an empty commit message");
|
|
341
|
+
}
|
|
342
|
+
return content;
|
|
343
|
+
} catch (error) {
|
|
344
|
+
debug("callAI FAILED after %d ms: %s", Date.now() - callStart, error instanceof Error ? `${error.name}: ${error.message}` : String(error));
|
|
345
|
+
throw error;
|
|
219
346
|
}
|
|
220
347
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
debug("
|
|
225
|
-
|
|
348
|
+
try {
|
|
349
|
+
const totalStart = Date.now();
|
|
350
|
+
let message = await callAI();
|
|
351
|
+
debug("Validation: message=%s, isValid=%s", message.slice(0, 100), isValidConventionalCommit(message));
|
|
352
|
+
if (!isValidConventionalCommit(message)) {
|
|
353
|
+
debug("Initial message failed conventional commit validation, retrying with strict prompt (elapsed: %d ms)", Date.now() - totalStart);
|
|
354
|
+
const retryMessage = await callAI("You MUST output ONLY a valid conventional commit message. Format: type(scope): description. If you output anything else your response will be rejected.\nValid types: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test.");
|
|
355
|
+
debug("Retry validation: message=%s, isValid=%s", retryMessage.slice(0, 100), isValidConventionalCommit(retryMessage));
|
|
356
|
+
if (isValidConventionalCommit(retryMessage)) {
|
|
357
|
+
debug("Retry produced valid conventional commit");
|
|
358
|
+
message = retryMessage;
|
|
359
|
+
} else debug("Retry also failed validation, using original message");
|
|
360
|
+
}
|
|
361
|
+
debug("Final message (%d ms total): %s", Date.now() - totalStart, message);
|
|
362
|
+
return message;
|
|
363
|
+
} catch (error) {
|
|
364
|
+
debug("AI error: %s", error instanceof Error ? error.message : String(error));
|
|
365
|
+
throw mapGroqError(error, options.provider ? formatProviderName(options.provider) : void 0);
|
|
226
366
|
}
|
|
227
|
-
debug("getProviderApiKey(%s): not found", provider);
|
|
228
|
-
throw new Error(`Please set your ${formatProviderName(provider)} API key via \`cmint config set ${envVar}=<your token>\``);
|
|
229
|
-
}
|
|
230
|
-
/** Check if a model name is the default for a provider OTHER than the given one. */
|
|
231
|
-
function isOtherProviderDefault(model, provider) {
|
|
232
|
-
for (const [name, config] of Object.entries(PROVIDER_CONFIGS)) if (name !== provider && config.defaultModel === model) return true;
|
|
233
|
-
return false;
|
|
234
|
-
}
|
|
235
|
-
function getModelForProvider(config, provider, defaultModel) {
|
|
236
|
-
const providerModel = config[`model_${provider}`];
|
|
237
|
-
if (providerModel) return providerModel;
|
|
238
|
-
const globalModel = config.model;
|
|
239
|
-
if (globalModel && !isOtherProviderDefault(globalModel, provider)) return globalModel;
|
|
240
|
-
return defaultModel;
|
|
241
367
|
}
|
|
242
368
|
//#endregion
|
|
243
369
|
//#region src/services/hooks.ts
|
|
@@ -473,439 +599,6 @@ function findMeaningfulCommand(command) {
|
|
|
473
599
|
return segments[segments.length - 1] || command;
|
|
474
600
|
}
|
|
475
601
|
//#endregion
|
|
476
|
-
//#region src/services/hook-progress.ts
|
|
477
|
-
const ansiRe = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
|
|
478
|
-
function createStderrParser() {
|
|
479
|
-
let buffer = "";
|
|
480
|
-
return (chunk) => {
|
|
481
|
-
buffer += chunk;
|
|
482
|
-
const steps = [];
|
|
483
|
-
const lines = buffer.split("\n");
|
|
484
|
-
buffer = lines.pop() ?? "";
|
|
485
|
-
for (const line of lines) {
|
|
486
|
-
const match = line.replace(ansiRe, "").match(/\[(STARTED|COMPLETED|FAILED)\]\s+(.+)/);
|
|
487
|
-
if (!match) continue;
|
|
488
|
-
const status = match[1].toLowerCase();
|
|
489
|
-
const command = match[2].trim();
|
|
490
|
-
if (isLintStagedMeta(command)) continue;
|
|
491
|
-
const tool = extractToolName(command) ?? command;
|
|
492
|
-
steps.push({
|
|
493
|
-
status,
|
|
494
|
-
command,
|
|
495
|
-
tool
|
|
496
|
-
});
|
|
497
|
-
}
|
|
498
|
-
return steps;
|
|
499
|
-
};
|
|
500
|
-
}
|
|
501
|
-
function createProgressHandler(s) {
|
|
502
|
-
return (step) => {
|
|
503
|
-
if (step.status === "started") s.message(step.command);
|
|
504
|
-
else if (step.status === "failed") s.message(step.command);
|
|
505
|
-
};
|
|
506
|
-
}
|
|
507
|
-
//#endregion
|
|
508
|
-
//#region src/services/git.ts
|
|
509
|
-
var git_exports = /* @__PURE__ */ __exportAll({
|
|
510
|
-
KnownError: () => KnownError,
|
|
511
|
-
assertGitRepo: () => assertGitRepo,
|
|
512
|
-
attemptCommit: () => attemptCommit,
|
|
513
|
-
attemptCommitNoVerify: () => attemptCommitNoVerify,
|
|
514
|
-
getChangedFiles: () => getChangedFiles,
|
|
515
|
-
getDefaultExcludes: () => getDefaultExcludes,
|
|
516
|
-
getHead: () => getHead,
|
|
517
|
-
getRepoRoot: () => getRepoRoot,
|
|
518
|
-
getStagedDiff: () => getStagedDiff,
|
|
519
|
-
getStatusShort: () => getStatusShort,
|
|
520
|
-
resetStaging: () => resetStaging,
|
|
521
|
-
stageAll: () => stageAll,
|
|
522
|
-
stageFiles: () => stageFiles
|
|
523
|
-
});
|
|
524
|
-
var KnownError = class extends Error {};
|
|
525
|
-
async function assertGitRepo() {
|
|
526
|
-
debug("assertGitRepo");
|
|
527
|
-
const { failed } = await execa("git", ["rev-parse", "--show-toplevel"], { reject: false });
|
|
528
|
-
if (failed) throw new KnownError("The current directory must be a Git repository!");
|
|
529
|
-
}
|
|
530
|
-
async function getRepoRoot() {
|
|
531
|
-
const { stdout } = await execa("git", ["rev-parse", "--show-toplevel"]);
|
|
532
|
-
debug("getRepoRoot:", stdout.trim());
|
|
533
|
-
return stdout.trim();
|
|
534
|
-
}
|
|
535
|
-
const DEFAULT_EXCLUDES = [
|
|
536
|
-
"package-lock.json",
|
|
537
|
-
"node_modules/**",
|
|
538
|
-
"dist/**",
|
|
539
|
-
"build/**",
|
|
540
|
-
".next/**",
|
|
541
|
-
"coverage/**",
|
|
542
|
-
"*.log",
|
|
543
|
-
"*.min.js",
|
|
544
|
-
"*.min.css",
|
|
545
|
-
"*.lock",
|
|
546
|
-
".DS_Store"
|
|
547
|
-
];
|
|
548
|
-
function getDefaultExcludes() {
|
|
549
|
-
return [...DEFAULT_EXCLUDES];
|
|
550
|
-
}
|
|
551
|
-
async function getStagedDiff(exclude) {
|
|
552
|
-
const excludeArgs = (exclude ?? []).map((e) => `:(exclude)${e}`);
|
|
553
|
-
const defaultExcludeArgs = DEFAULT_EXCLUDES.map((e) => `:(exclude)${e}`);
|
|
554
|
-
const { stdout: allFiles } = await execa("git", [
|
|
555
|
-
"diff",
|
|
556
|
-
"--cached",
|
|
557
|
-
"--name-only"
|
|
558
|
-
]);
|
|
559
|
-
if (!allFiles) {
|
|
560
|
-
debug("getStagedDiff: no staged files");
|
|
561
|
-
return null;
|
|
562
|
-
}
|
|
563
|
-
const { stdout: files } = await execa("git", [
|
|
564
|
-
"diff",
|
|
565
|
-
"--cached",
|
|
566
|
-
"--name-only",
|
|
567
|
-
...defaultExcludeArgs,
|
|
568
|
-
...excludeArgs
|
|
569
|
-
]);
|
|
570
|
-
if (!files) {
|
|
571
|
-
const excludedFiles = allFiles.split("\n").filter(Boolean);
|
|
572
|
-
debug("getStagedDiff: all files excluded:", excludedFiles);
|
|
573
|
-
return { excludedFiles };
|
|
574
|
-
}
|
|
575
|
-
const { stdout: diff } = await execa("git", [
|
|
576
|
-
"diff",
|
|
577
|
-
"--cached",
|
|
578
|
-
"--diff-algorithm=minimal",
|
|
579
|
-
...defaultExcludeArgs,
|
|
580
|
-
...excludeArgs
|
|
581
|
-
]);
|
|
582
|
-
debug("getStagedDiff:", files.split("\n").filter(Boolean).length, "files,", diff.length, "chars");
|
|
583
|
-
return {
|
|
584
|
-
files: files.split("\n").filter(Boolean),
|
|
585
|
-
diff
|
|
586
|
-
};
|
|
587
|
-
}
|
|
588
|
-
async function stageAll() {
|
|
589
|
-
debug("stageAll: git add -A");
|
|
590
|
-
await execa("git", ["add", "-A"]);
|
|
591
|
-
}
|
|
592
|
-
async function resetStaging() {
|
|
593
|
-
debug("resetStaging: git reset HEAD");
|
|
594
|
-
await execa("git", ["reset", "HEAD"]);
|
|
595
|
-
}
|
|
596
|
-
async function getHead() {
|
|
597
|
-
const { stdout } = await execa("git", ["rev-parse", "HEAD"]);
|
|
598
|
-
return stdout.trim();
|
|
599
|
-
}
|
|
600
|
-
async function getStatusShort() {
|
|
601
|
-
const { stdout } = await execa("git", ["status", "--short"]);
|
|
602
|
-
return stdout.trim();
|
|
603
|
-
}
|
|
604
|
-
async function getChangedFiles() {
|
|
605
|
-
const { stdout } = await execa("git", ["status", "--short"]);
|
|
606
|
-
if (!stdout.trim()) return [];
|
|
607
|
-
const files = stdout.split("\n").filter(Boolean).map((line) => {
|
|
608
|
-
const indexStatus = line[0];
|
|
609
|
-
return {
|
|
610
|
-
status: line.slice(0, 2).trim(),
|
|
611
|
-
path: line.slice(3),
|
|
612
|
-
staged: indexStatus !== " " && indexStatus !== "?"
|
|
613
|
-
};
|
|
614
|
-
});
|
|
615
|
-
debug("getChangedFiles:", files.length, "files");
|
|
616
|
-
return files;
|
|
617
|
-
}
|
|
618
|
-
async function stageFiles(paths) {
|
|
619
|
-
debug("stageFiles:", paths);
|
|
620
|
-
await execa("git", ["add", ...paths]);
|
|
621
|
-
}
|
|
622
|
-
async function attemptCommit(message, extraArgs = [], onProgress) {
|
|
623
|
-
debug("attemptCommit:", message, extraArgs.length ? extraArgs : "(no extra args)");
|
|
624
|
-
try {
|
|
625
|
-
const subprocess = execa("git", [
|
|
626
|
-
"commit",
|
|
627
|
-
"-m",
|
|
628
|
-
message,
|
|
629
|
-
...extraArgs
|
|
630
|
-
]);
|
|
631
|
-
const stderrChunks = [];
|
|
632
|
-
const parser = onProgress ? createStderrParser() : null;
|
|
633
|
-
subprocess.stderr?.on("data", (chunk) => {
|
|
634
|
-
const text = chunk.toString();
|
|
635
|
-
stderrChunks.push(text);
|
|
636
|
-
if (parser && onProgress) for (const step of parser(text)) onProgress(step);
|
|
637
|
-
});
|
|
638
|
-
await subprocess;
|
|
639
|
-
debug("attemptCommit: success");
|
|
640
|
-
return {
|
|
641
|
-
ok: true,
|
|
642
|
-
stderr: stderrChunks.join("")
|
|
643
|
-
};
|
|
644
|
-
} catch (error) {
|
|
645
|
-
const e = error;
|
|
646
|
-
debug("attemptCommit: failed —", e.message?.slice(0, 200));
|
|
647
|
-
return {
|
|
648
|
-
ok: false,
|
|
649
|
-
error: e.message,
|
|
650
|
-
stderr: typeof e.stderr === "string" ? e.stderr : ""
|
|
651
|
-
};
|
|
652
|
-
}
|
|
653
|
-
}
|
|
654
|
-
async function attemptCommitNoVerify(message, onProgress) {
|
|
655
|
-
debug("attemptCommitNoVerify:", message);
|
|
656
|
-
return attemptCommit(message, ["--no-verify"], onProgress);
|
|
657
|
-
}
|
|
658
|
-
//#endregion
|
|
659
|
-
//#region src/ui/review-message.ts
|
|
660
|
-
async function reviewCommitMessage(message) {
|
|
661
|
-
const { select, text } = await import("@clack/prompts");
|
|
662
|
-
while (true) {
|
|
663
|
-
const review = await select({
|
|
664
|
-
message: `Review commit message:\n\n ${bold(message)}\n`,
|
|
665
|
-
options: [
|
|
666
|
-
{
|
|
667
|
-
label: "Use as-is",
|
|
668
|
-
value: "use"
|
|
669
|
-
},
|
|
670
|
-
{
|
|
671
|
-
label: "Edit",
|
|
672
|
-
value: "edit"
|
|
673
|
-
},
|
|
674
|
-
{
|
|
675
|
-
label: "Cancel",
|
|
676
|
-
value: "cancel"
|
|
677
|
-
}
|
|
678
|
-
]
|
|
679
|
-
});
|
|
680
|
-
if (isCancel(review) || review === "cancel") {
|
|
681
|
-
debug("User cancelled at review step");
|
|
682
|
-
return null;
|
|
683
|
-
}
|
|
684
|
-
if (review === "use") {
|
|
685
|
-
debug("User accepted message");
|
|
686
|
-
return message;
|
|
687
|
-
}
|
|
688
|
-
if (review === "edit") {
|
|
689
|
-
debug("User chose to edit message");
|
|
690
|
-
const edited = await text({
|
|
691
|
-
message: "Edit commit message:",
|
|
692
|
-
initialValue: message,
|
|
693
|
-
validate: (v) => v?.trim() ? void 0 : "Message cannot be empty"
|
|
694
|
-
});
|
|
695
|
-
if (isCancel(edited)) continue;
|
|
696
|
-
message = String(edited).trim();
|
|
697
|
-
debug("Edited message:", message);
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
|
-
//#endregion
|
|
702
|
-
//#region src/utils/cache.ts
|
|
703
|
-
const CACHE_DIR = join(os.homedir(), ".cache", "commit-mint");
|
|
704
|
-
function repoHash(repoPath) {
|
|
705
|
-
return createHash("sha256").update(repoPath).digest("hex").slice(0, 12);
|
|
706
|
-
}
|
|
707
|
-
function cachePath(repoPath) {
|
|
708
|
-
return join(CACHE_DIR, `${repoHash(repoPath)}.json`);
|
|
709
|
-
}
|
|
710
|
-
async function saveCachedCommit(repoPath, message) {
|
|
711
|
-
await mkdir(CACHE_DIR, { recursive: true });
|
|
712
|
-
const data = {
|
|
713
|
-
message,
|
|
714
|
-
timestamp: Date.now(),
|
|
715
|
-
repoPath
|
|
716
|
-
};
|
|
717
|
-
const path = cachePath(repoPath);
|
|
718
|
-
debug("saveCachedCommit: saving to %s", path);
|
|
719
|
-
await writeFile(path, JSON.stringify(data, null, 2), "utf8");
|
|
720
|
-
}
|
|
721
|
-
async function loadCachedCommit(repoPath) {
|
|
722
|
-
const path = cachePath(repoPath);
|
|
723
|
-
debug("loadCachedCommit: loading from %s", path);
|
|
724
|
-
try {
|
|
725
|
-
const raw = await readFile(path, "utf8");
|
|
726
|
-
const data = JSON.parse(raw);
|
|
727
|
-
debug("loadCachedCommit: found message from %s", new Date(data.timestamp).toISOString());
|
|
728
|
-
return data;
|
|
729
|
-
} catch {
|
|
730
|
-
debug("loadCachedCommit: no cached commit found");
|
|
731
|
-
return null;
|
|
732
|
-
}
|
|
733
|
-
}
|
|
734
|
-
//#endregion
|
|
735
|
-
//#region src/services/ai.ts
|
|
736
|
-
const MAX_DIFF_CHARS = 2e4;
|
|
737
|
-
function mapGroqError(error, providerLabel) {
|
|
738
|
-
const label = providerLabel ?? "Groq";
|
|
739
|
-
if (error instanceof Groq.AuthenticationError) return /* @__PURE__ */ new Error(`Invalid API key for ${label}. Run: cmint config set ${label.toUpperCase()}_API_KEY=<key>`);
|
|
740
|
-
if (error instanceof Groq.RateLimitError) return /* @__PURE__ */ new Error(`Rate limited by ${label}. Please wait and try again.`);
|
|
741
|
-
if (error instanceof Groq.APIConnectionTimeoutError) return /* @__PURE__ */ new Error("Request timed out. Check your network or try a smaller diff.");
|
|
742
|
-
if (error instanceof Groq.APIError) return /* @__PURE__ */ new Error(`${label} API error: ${error.message}`);
|
|
743
|
-
if (error instanceof Error && /^4\d{2}\s/.test(error.message)) return /* @__PURE__ */ new Error(`${label} API error: ${error.message}`);
|
|
744
|
-
return /* @__PURE__ */ new Error(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`);
|
|
745
|
-
}
|
|
746
|
-
const CONVENTIONAL_COMMIT_REGEX = /^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.+\))?!?: .+$/;
|
|
747
|
-
function stripThinkTags(text) {
|
|
748
|
-
return text.replace(/<think[\s\S]*?<\/think>/gi, "").trim();
|
|
749
|
-
}
|
|
750
|
-
function deriveMessageFromReasoning(reasoning) {
|
|
751
|
-
const match = reasoning.match(/(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.+\))?!?: .+/i);
|
|
752
|
-
if (match) return match[0].trim();
|
|
753
|
-
const first = reasoning.split(/[.!?]/).find((s) => s.trim().length >= 10);
|
|
754
|
-
return first ? first.trim() : null;
|
|
755
|
-
}
|
|
756
|
-
function stripContextLines(diff) {
|
|
757
|
-
return diff.split("\n").filter((line) => !line.startsWith(" ")).join("\n");
|
|
758
|
-
}
|
|
759
|
-
function compressDiff(diff) {
|
|
760
|
-
if (diff.length <= MAX_DIFF_CHARS) return diff;
|
|
761
|
-
let result = stripContextLines(diff);
|
|
762
|
-
if (result.length <= MAX_DIFF_CHARS) return result;
|
|
763
|
-
result = result.split(/(?=diff --git)/).filter(Boolean).map((fd) => {
|
|
764
|
-
return fd.split(/(?=\n@@)/).map((part, idx) => {
|
|
765
|
-
if (idx === 0) return part;
|
|
766
|
-
const lines = part.split("\n");
|
|
767
|
-
return [lines[0], ...lines.slice(1).filter((l) => l.startsWith("+") || l.startsWith("-")).slice(0, 10)].join("\n");
|
|
768
|
-
}).join("");
|
|
769
|
-
}).join("");
|
|
770
|
-
if (result.length <= MAX_DIFF_CHARS) return result;
|
|
771
|
-
return `Summary of changes:\n${(diff.match(/^diff --git a\/(.+) b\/(.+)$/gm) || []).map((f) => {
|
|
772
|
-
const match = f.match(/^diff --git a\/(.+) b\/(.+)$/);
|
|
773
|
-
return match && match[1] === match[2] ? `${match[1]} | changed` : "";
|
|
774
|
-
}).filter(Boolean).join("\n")}`;
|
|
775
|
-
}
|
|
776
|
-
function buildStatSummary(diff) {
|
|
777
|
-
const files = [];
|
|
778
|
-
let currentFile = "";
|
|
779
|
-
let adds = 0;
|
|
780
|
-
let dels = 0;
|
|
781
|
-
for (const line of diff.split("\n")) {
|
|
782
|
-
const match = line.match(/^diff --git a\/.+ b\/(.+)$/);
|
|
783
|
-
if (match) {
|
|
784
|
-
if (currentFile) files.push({
|
|
785
|
-
name: currentFile,
|
|
786
|
-
adds,
|
|
787
|
-
dels
|
|
788
|
-
});
|
|
789
|
-
currentFile = match[1];
|
|
790
|
-
adds = 0;
|
|
791
|
-
dels = 0;
|
|
792
|
-
} else if (line.startsWith("+") && !line.startsWith("+++")) adds++;
|
|
793
|
-
else if (line.startsWith("-") && !line.startsWith("---")) dels++;
|
|
794
|
-
}
|
|
795
|
-
if (currentFile) files.push({
|
|
796
|
-
name: currentFile,
|
|
797
|
-
adds,
|
|
798
|
-
dels
|
|
799
|
-
});
|
|
800
|
-
const totalAdds = files.reduce((s, f) => s + f.adds, 0);
|
|
801
|
-
const totalDels = files.reduce((s, f) => s + f.dels, 0);
|
|
802
|
-
const lines = files.map((f) => ` ${f.name} | +${f.adds} -${f.dels}`);
|
|
803
|
-
lines.push(` ${files.length} files changed, ${totalAdds} insertions(+), ${totalDels} deletions(-)`);
|
|
804
|
-
return lines.join("\n");
|
|
805
|
-
}
|
|
806
|
-
function buildSystemPrompt(type) {
|
|
807
|
-
let prompt = "You are a commit message generator. Follow the Conventional Commits specification.\nValid types: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test.\nFormat: type(scope): description\nUse imperative mood, lowercase, no trailing period.\nOutput ONLY the commit message, no markdown fences, no explanation.";
|
|
808
|
-
if (type && type.trim().length > 0) prompt += `\nYou MUST use type: ${type}`;
|
|
809
|
-
return prompt;
|
|
810
|
-
}
|
|
811
|
-
function buildUserPrompt(diff, hint, statSummary) {
|
|
812
|
-
const parts = [];
|
|
813
|
-
if (hint) parts.push(`Context: ${hint}`);
|
|
814
|
-
if (statSummary) parts.push(`Change summary:\n${statSummary}`);
|
|
815
|
-
parts.push(`Generate a conventional commit for:\n\n${diff}`);
|
|
816
|
-
return parts.join("\n\n");
|
|
817
|
-
}
|
|
818
|
-
function isValidConventionalCommit(message) {
|
|
819
|
-
return CONVENTIONAL_COMMIT_REGEX.test(message);
|
|
820
|
-
}
|
|
821
|
-
function extractContentText(content) {
|
|
822
|
-
if (content == null) return "";
|
|
823
|
-
if (typeof content === "string") return content.trim();
|
|
824
|
-
if (Array.isArray(content)) return content.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => stripThinkTags(part.text)).join("").trim();
|
|
825
|
-
return "";
|
|
826
|
-
}
|
|
827
|
-
async function generateCommitMessage(diff, options) {
|
|
828
|
-
const timeoutMs = options.timeout ?? 6e4;
|
|
829
|
-
debug("Timeout: %d ms", timeoutMs);
|
|
830
|
-
const { client, model } = createProvider({
|
|
831
|
-
provider: options.provider ?? "groq",
|
|
832
|
-
apiKey: options.apiKey,
|
|
833
|
-
modelOverride: options.model,
|
|
834
|
-
timeout: timeoutMs,
|
|
835
|
-
baseURLOverride: options.proxy
|
|
836
|
-
});
|
|
837
|
-
debug("generateCommitMessage: model=%s, type=%s, hint=%s", model, options.type ?? "none", options.hint ?? "none");
|
|
838
|
-
const compressedDiff = compressDiff(diff);
|
|
839
|
-
const statSummary = buildStatSummary(diff);
|
|
840
|
-
const systemPrompt = buildSystemPrompt(options.type);
|
|
841
|
-
const userPrompt = buildUserPrompt(compressedDiff, options.hint, statSummary);
|
|
842
|
-
debug("Diff: %d chars → compressed to %d chars", diff.length, compressedDiff.length);
|
|
843
|
-
debug("Stat summary:\n%s", statSummary);
|
|
844
|
-
debug("User prompt length: %d chars", userPrompt.length);
|
|
845
|
-
async function callAI(strictSystemPrompt) {
|
|
846
|
-
const callStart = Date.now();
|
|
847
|
-
debug("callAI: %s — model=%s, promptLen=%d, systemLen=%d", !!strictSystemPrompt ? "RETRY (strict)" : "INITIAL", model, userPrompt.length, (strictSystemPrompt ?? systemPrompt).length);
|
|
848
|
-
try {
|
|
849
|
-
const isReasoningModel = /^(o[1-9]|.*gpt-oss.*|.*gpt-5.*)/i.test(model);
|
|
850
|
-
const isGroq = (options.provider ?? "groq") === "groq";
|
|
851
|
-
const completion = await client.chat.completions.create({
|
|
852
|
-
messages: [{
|
|
853
|
-
role: "system",
|
|
854
|
-
content: strictSystemPrompt ?? systemPrompt
|
|
855
|
-
}, {
|
|
856
|
-
role: "user",
|
|
857
|
-
content: userPrompt
|
|
858
|
-
}],
|
|
859
|
-
model,
|
|
860
|
-
temperature: .3,
|
|
861
|
-
...isReasoningModel ? { max_completion_tokens: 1024 } : { max_tokens: 1024 },
|
|
862
|
-
...isGroq && isReasoningModel ? { reasoning_format: "parsed" } : {}
|
|
863
|
-
});
|
|
864
|
-
const elapsed = Date.now() - callStart;
|
|
865
|
-
const rawContent = completion.choices[0]?.message?.content;
|
|
866
|
-
const content = extractContentText(typeof rawContent === "string" ? stripThinkTags(rawContent) : rawContent);
|
|
867
|
-
debug("callAI response (%d ms): choices=%d, finishReason=%s, contentLen=%d, rawType=%s", elapsed, completion.choices.length, completion.choices[0]?.finish_reason ?? "(none)", content.length, typeof rawContent);
|
|
868
|
-
debug("callAI raw content: %s", content.slice(0, 300) || "(empty)");
|
|
869
|
-
if (!content) {
|
|
870
|
-
const reasoning = completion.choices[0]?.message?.reasoning;
|
|
871
|
-
debug("callAI: content empty, attempting reasoning fallback (reasoningLen=%d)", reasoning?.length ?? 0);
|
|
872
|
-
if (reasoning) {
|
|
873
|
-
const derived = deriveMessageFromReasoning(reasoning);
|
|
874
|
-
if (derived) {
|
|
875
|
-
debug("callAI: derived message from reasoning: %s", derived.slice(0, 100));
|
|
876
|
-
return stripThinkTags(derived);
|
|
877
|
-
}
|
|
878
|
-
debug("callAI: could not derive message from reasoning");
|
|
879
|
-
}
|
|
880
|
-
throw new Error("AI returned an empty commit message");
|
|
881
|
-
}
|
|
882
|
-
return content;
|
|
883
|
-
} catch (error) {
|
|
884
|
-
debug("callAI FAILED after %d ms: %s", Date.now() - callStart, error instanceof Error ? `${error.name}: ${error.message}` : String(error));
|
|
885
|
-
throw error;
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
try {
|
|
889
|
-
const totalStart = Date.now();
|
|
890
|
-
let message = await callAI();
|
|
891
|
-
debug("Validation: message=%s, isValid=%s", message.slice(0, 100), isValidConventionalCommit(message));
|
|
892
|
-
if (!isValidConventionalCommit(message)) {
|
|
893
|
-
debug("Initial message failed conventional commit validation, retrying with strict prompt (elapsed: %d ms)", Date.now() - totalStart);
|
|
894
|
-
const retryMessage = await callAI("You MUST output ONLY a valid conventional commit message. Format: type(scope): description. If you output anything else your response will be rejected.\nValid types: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test.");
|
|
895
|
-
debug("Retry validation: message=%s, isValid=%s", retryMessage.slice(0, 100), isValidConventionalCommit(retryMessage));
|
|
896
|
-
if (isValidConventionalCommit(retryMessage)) {
|
|
897
|
-
debug("Retry produced valid conventional commit");
|
|
898
|
-
message = retryMessage;
|
|
899
|
-
} else debug("Retry also failed validation, using original message");
|
|
900
|
-
}
|
|
901
|
-
debug("Final message (%d ms total): %s", Date.now() - totalStart, message);
|
|
902
|
-
return message;
|
|
903
|
-
} catch (error) {
|
|
904
|
-
debug("AI error: %s", error instanceof Error ? error.message : String(error));
|
|
905
|
-
throw mapGroqError(error, options.provider ? formatProviderName(options.provider) : void 0);
|
|
906
|
-
}
|
|
907
|
-
}
|
|
908
|
-
//#endregion
|
|
909
602
|
//#region src/services/checks.ts
|
|
910
603
|
/** Config file names, checked in priority order (matches lint-staged naming conventions) */
|
|
911
604
|
const CONFIG_FILES = [
|
|
@@ -1094,30 +787,278 @@ async function runAllChecks(repoRoot, stagedFiles, timeout) {
|
|
|
1094
787
|
debug("runAllChecks: no config found, skipping checks");
|
|
1095
788
|
return {
|
|
1096
789
|
ok: true,
|
|
1097
|
-
results: []
|
|
790
|
+
results: []
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
const config = await loadConfig(repoRoot);
|
|
794
|
+
debug("runAllChecks: loaded config with %d patterns", Object.keys(config).length);
|
|
795
|
+
const results = [];
|
|
796
|
+
for (const [glob, commands] of Object.entries(config)) {
|
|
797
|
+
const matchedFiles = matchFiles(glob, stagedFiles);
|
|
798
|
+
if (matchedFiles.length === 0) {
|
|
799
|
+
debug("runAllChecks: no files matched pattern '%s'", glob);
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
debug("runAllChecks: pattern '%s' matched %d files", glob, matchedFiles.length);
|
|
803
|
+
if (!await runCommandsForGlob(resolveCommands(commands, matchedFiles), matchedFiles, timeout, results, repoRoot)) return {
|
|
804
|
+
ok: false,
|
|
805
|
+
results
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
const ok = results.every((r) => r.ok);
|
|
809
|
+
debug("runAllChecks: complete — ok=%s, %d results", ok, results.length);
|
|
810
|
+
return {
|
|
811
|
+
ok,
|
|
812
|
+
results
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
//#endregion
|
|
816
|
+
//#region src/services/config.ts
|
|
817
|
+
const CONFIG_PATH = join(os.homedir(), ".commit-mint");
|
|
818
|
+
const defaults = {
|
|
819
|
+
provider: "groq",
|
|
820
|
+
model: "openai/gpt-oss-20b",
|
|
821
|
+
locale: "en",
|
|
822
|
+
"max-length": "100",
|
|
823
|
+
type: "",
|
|
824
|
+
timeout: "10000"
|
|
825
|
+
};
|
|
826
|
+
async function readConfig() {
|
|
827
|
+
debug("readConfig: loading from %s", CONFIG_PATH);
|
|
828
|
+
try {
|
|
829
|
+
const raw = await readFile(CONFIG_PATH, "utf8");
|
|
830
|
+
const parsed = ini.parse(raw);
|
|
831
|
+
const merged = {
|
|
832
|
+
...defaults,
|
|
833
|
+
...parsed
|
|
834
|
+
};
|
|
835
|
+
debug("readConfig: loaded keys: %s", Object.keys(merged).join(", "));
|
|
836
|
+
return merged;
|
|
837
|
+
} catch {
|
|
838
|
+
debug("readConfig: no config file, using defaults");
|
|
839
|
+
return { ...defaults };
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
async function writeConfig(updates) {
|
|
843
|
+
const existing = await readConfig();
|
|
844
|
+
Object.assign(existing, updates);
|
|
845
|
+
await writeFile(CONFIG_PATH, ini.stringify(existing), "utf8");
|
|
846
|
+
}
|
|
847
|
+
async function setConfigValue(key, value) {
|
|
848
|
+
await writeConfig({ [key]: value });
|
|
849
|
+
}
|
|
850
|
+
async function getProviderApiKey(provider) {
|
|
851
|
+
const envVar = PROVIDER_ENV_KEYS[provider];
|
|
852
|
+
if (envVar) {
|
|
853
|
+
const envValue = process.env[envVar];
|
|
854
|
+
if (envValue) {
|
|
855
|
+
debug("getProviderApiKey(%s): found in env", provider);
|
|
856
|
+
return envValue;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
const config = await readConfig();
|
|
860
|
+
const configKey = PROVIDER_ENV_KEYS[provider];
|
|
861
|
+
if (configKey && config[configKey]) {
|
|
862
|
+
debug("getProviderApiKey(%s): found in config", provider);
|
|
863
|
+
return config[configKey];
|
|
864
|
+
}
|
|
865
|
+
debug("getProviderApiKey(%s): not found", provider);
|
|
866
|
+
throw new Error(`Please set your ${formatProviderName(provider)} API key via \`cmint config set ${envVar}=<your token>\``);
|
|
867
|
+
}
|
|
868
|
+
/** Check if a model name is the default for a provider OTHER than the given one. */
|
|
869
|
+
function isOtherProviderDefault(model, provider) {
|
|
870
|
+
for (const [name, config] of Object.entries(PROVIDER_CONFIGS)) if (name !== provider && config.defaultModel === model) return true;
|
|
871
|
+
return false;
|
|
872
|
+
}
|
|
873
|
+
function getModelForProvider(config, provider, defaultModel) {
|
|
874
|
+
const providerModel = config[`model_${provider}`];
|
|
875
|
+
if (providerModel) return providerModel;
|
|
876
|
+
const globalModel = config.model;
|
|
877
|
+
if (globalModel && !isOtherProviderDefault(globalModel, provider)) return globalModel;
|
|
878
|
+
return defaultModel;
|
|
879
|
+
}
|
|
880
|
+
//#endregion
|
|
881
|
+
//#region src/services/hook-progress.ts
|
|
882
|
+
const ansiRe = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
|
|
883
|
+
function createStderrParser() {
|
|
884
|
+
let buffer = "";
|
|
885
|
+
return (chunk) => {
|
|
886
|
+
buffer += chunk;
|
|
887
|
+
const steps = [];
|
|
888
|
+
const lines = buffer.split("\n");
|
|
889
|
+
buffer = lines.pop() ?? "";
|
|
890
|
+
for (const line of lines) {
|
|
891
|
+
const match = line.replace(ansiRe, "").match(/\[(STARTED|COMPLETED|FAILED)\]\s+(.+)/);
|
|
892
|
+
if (!match) continue;
|
|
893
|
+
const status = match[1].toLowerCase();
|
|
894
|
+
const command = match[2].trim();
|
|
895
|
+
if (isLintStagedMeta(command)) continue;
|
|
896
|
+
const tool = extractToolName(command) ?? command;
|
|
897
|
+
steps.push({
|
|
898
|
+
status,
|
|
899
|
+
command,
|
|
900
|
+
tool
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
return steps;
|
|
904
|
+
};
|
|
905
|
+
}
|
|
906
|
+
function createProgressHandler(s) {
|
|
907
|
+
return (step) => {
|
|
908
|
+
if (step.status === "started") s.message(step.command);
|
|
909
|
+
else if (step.status === "failed") s.message(step.command);
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
//#endregion
|
|
913
|
+
//#region src/services/git.ts
|
|
914
|
+
var git_exports = /* @__PURE__ */ __exportAll({
|
|
915
|
+
KnownError: () => KnownError,
|
|
916
|
+
assertGitRepo: () => assertGitRepo,
|
|
917
|
+
attemptCommit: () => attemptCommit,
|
|
918
|
+
attemptCommitNoVerify: () => attemptCommitNoVerify,
|
|
919
|
+
getChangedFiles: () => getChangedFiles,
|
|
920
|
+
getDefaultExcludes: () => getDefaultExcludes,
|
|
921
|
+
getHead: () => getHead,
|
|
922
|
+
getRepoRoot: () => getRepoRoot,
|
|
923
|
+
getStagedDiff: () => getStagedDiff,
|
|
924
|
+
getStatusShort: () => getStatusShort,
|
|
925
|
+
resetStaging: () => resetStaging,
|
|
926
|
+
stageAll: () => stageAll,
|
|
927
|
+
stageFiles: () => stageFiles
|
|
928
|
+
});
|
|
929
|
+
var KnownError = class extends Error {};
|
|
930
|
+
async function assertGitRepo() {
|
|
931
|
+
debug("assertGitRepo");
|
|
932
|
+
const { failed } = await execa("git", ["rev-parse", "--show-toplevel"], { reject: false });
|
|
933
|
+
if (failed) throw new KnownError("The current directory must be a Git repository!");
|
|
934
|
+
}
|
|
935
|
+
async function getRepoRoot() {
|
|
936
|
+
const { stdout } = await execa("git", ["rev-parse", "--show-toplevel"]);
|
|
937
|
+
debug("getRepoRoot:", stdout.trim());
|
|
938
|
+
return stdout.trim();
|
|
939
|
+
}
|
|
940
|
+
const DEFAULT_EXCLUDES = [
|
|
941
|
+
"package-lock.json",
|
|
942
|
+
"node_modules/**",
|
|
943
|
+
"dist/**",
|
|
944
|
+
"build/**",
|
|
945
|
+
".next/**",
|
|
946
|
+
"coverage/**",
|
|
947
|
+
"*.log",
|
|
948
|
+
"*.min.js",
|
|
949
|
+
"*.min.css",
|
|
950
|
+
"*.lock",
|
|
951
|
+
".DS_Store"
|
|
952
|
+
];
|
|
953
|
+
function getDefaultExcludes() {
|
|
954
|
+
return [...DEFAULT_EXCLUDES];
|
|
955
|
+
}
|
|
956
|
+
async function getStagedDiff(exclude) {
|
|
957
|
+
const excludeArgs = (exclude ?? []).map((e) => `:(exclude)${e}`);
|
|
958
|
+
const defaultExcludeArgs = DEFAULT_EXCLUDES.map((e) => `:(exclude)${e}`);
|
|
959
|
+
const { stdout: allFiles } = await execa("git", [
|
|
960
|
+
"diff",
|
|
961
|
+
"--cached",
|
|
962
|
+
"--name-only"
|
|
963
|
+
]);
|
|
964
|
+
if (!allFiles) {
|
|
965
|
+
debug("getStagedDiff: no staged files");
|
|
966
|
+
return null;
|
|
967
|
+
}
|
|
968
|
+
const { stdout: files } = await execa("git", [
|
|
969
|
+
"diff",
|
|
970
|
+
"--cached",
|
|
971
|
+
"--name-only",
|
|
972
|
+
...defaultExcludeArgs,
|
|
973
|
+
...excludeArgs
|
|
974
|
+
]);
|
|
975
|
+
if (!files) {
|
|
976
|
+
const excludedFiles = allFiles.split("\n").filter(Boolean);
|
|
977
|
+
debug("getStagedDiff: all files excluded:", excludedFiles);
|
|
978
|
+
return { excludedFiles };
|
|
979
|
+
}
|
|
980
|
+
const { stdout: diff } = await execa("git", [
|
|
981
|
+
"diff",
|
|
982
|
+
"--cached",
|
|
983
|
+
"--diff-algorithm=minimal",
|
|
984
|
+
...defaultExcludeArgs,
|
|
985
|
+
...excludeArgs
|
|
986
|
+
]);
|
|
987
|
+
debug("getStagedDiff:", files.split("\n").filter(Boolean).length, "files,", diff.length, "chars");
|
|
988
|
+
return {
|
|
989
|
+
files: files.split("\n").filter(Boolean),
|
|
990
|
+
diff
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
async function stageAll() {
|
|
994
|
+
debug("stageAll: git add -A");
|
|
995
|
+
await execa("git", ["add", "-A"]);
|
|
996
|
+
}
|
|
997
|
+
async function resetStaging() {
|
|
998
|
+
debug("resetStaging: git reset HEAD");
|
|
999
|
+
await execa("git", ["reset", "HEAD"]);
|
|
1000
|
+
}
|
|
1001
|
+
async function getHead() {
|
|
1002
|
+
const { stdout } = await execa("git", ["rev-parse", "HEAD"]);
|
|
1003
|
+
return stdout.trim();
|
|
1004
|
+
}
|
|
1005
|
+
async function getStatusShort() {
|
|
1006
|
+
const { stdout } = await execa("git", ["status", "--short"]);
|
|
1007
|
+
return stdout.trim();
|
|
1008
|
+
}
|
|
1009
|
+
async function getChangedFiles() {
|
|
1010
|
+
const { stdout } = await execa("git", ["status", "--short"]);
|
|
1011
|
+
if (!stdout.trim()) return [];
|
|
1012
|
+
const files = stdout.split("\n").filter(Boolean).map((line) => {
|
|
1013
|
+
const indexStatus = line[0];
|
|
1014
|
+
return {
|
|
1015
|
+
status: line.slice(0, 2).trim(),
|
|
1016
|
+
path: line.slice(3),
|
|
1017
|
+
staged: indexStatus !== " " && indexStatus !== "?"
|
|
1018
|
+
};
|
|
1019
|
+
});
|
|
1020
|
+
debug("getChangedFiles:", files.length, "files");
|
|
1021
|
+
return files;
|
|
1022
|
+
}
|
|
1023
|
+
async function stageFiles(paths) {
|
|
1024
|
+
debug("stageFiles:", paths);
|
|
1025
|
+
await execa("git", ["add", ...paths]);
|
|
1026
|
+
}
|
|
1027
|
+
async function attemptCommit(message, extraArgs = [], onProgress) {
|
|
1028
|
+
debug("attemptCommit:", message, extraArgs.length ? extraArgs : "(no extra args)");
|
|
1029
|
+
try {
|
|
1030
|
+
const subprocess = execa("git", [
|
|
1031
|
+
"commit",
|
|
1032
|
+
"-m",
|
|
1033
|
+
message,
|
|
1034
|
+
...extraArgs
|
|
1035
|
+
]);
|
|
1036
|
+
const stderrChunks = [];
|
|
1037
|
+
const parser = onProgress ? createStderrParser() : null;
|
|
1038
|
+
subprocess.stderr?.on("data", (chunk) => {
|
|
1039
|
+
const text = chunk.toString();
|
|
1040
|
+
stderrChunks.push(text);
|
|
1041
|
+
if (parser && onProgress) for (const step of parser(text)) onProgress(step);
|
|
1042
|
+
});
|
|
1043
|
+
await subprocess;
|
|
1044
|
+
debug("attemptCommit: success");
|
|
1045
|
+
return {
|
|
1046
|
+
ok: true,
|
|
1047
|
+
stderr: stderrChunks.join("")
|
|
1098
1048
|
};
|
|
1099
|
-
}
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
for (const [glob, commands] of Object.entries(config)) {
|
|
1104
|
-
const matchedFiles = matchFiles(glob, stagedFiles);
|
|
1105
|
-
if (matchedFiles.length === 0) {
|
|
1106
|
-
debug("runAllChecks: no files matched pattern '%s'", glob);
|
|
1107
|
-
continue;
|
|
1108
|
-
}
|
|
1109
|
-
debug("runAllChecks: pattern '%s' matched %d files", glob, matchedFiles.length);
|
|
1110
|
-
if (!await runCommandsForGlob(resolveCommands(commands, matchedFiles), matchedFiles, timeout, results, repoRoot)) return {
|
|
1049
|
+
} catch (error) {
|
|
1050
|
+
const e = error;
|
|
1051
|
+
debug("attemptCommit: failed —", e.message?.slice(0, 200));
|
|
1052
|
+
return {
|
|
1111
1053
|
ok: false,
|
|
1112
|
-
|
|
1054
|
+
error: e.message,
|
|
1055
|
+
stderr: typeof e.stderr === "string" ? e.stderr : ""
|
|
1113
1056
|
};
|
|
1114
1057
|
}
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
results
|
|
1120
|
-
};
|
|
1058
|
+
}
|
|
1059
|
+
async function attemptCommitNoVerify(message, onProgress) {
|
|
1060
|
+
debug("attemptCommitNoVerify:", message);
|
|
1061
|
+
return attemptCommit(message, ["--no-verify"], onProgress);
|
|
1121
1062
|
}
|
|
1122
1063
|
//#endregion
|
|
1123
1064
|
//#region src/services/grouping.ts
|
|
@@ -1201,8 +1142,36 @@ function buildGroupingUserPrompt(summary) {
|
|
|
1201
1142
|
summary
|
|
1202
1143
|
].join("\n");
|
|
1203
1144
|
}
|
|
1145
|
+
function buildRetryGroupingPrompt() {
|
|
1146
|
+
return [
|
|
1147
|
+
"PREVIOUS ATTEMPT FAILED: You grouped all files into a single group.",
|
|
1148
|
+
"",
|
|
1149
|
+
"You MUST split the files into at least 2 groups based on what changed and why.",
|
|
1150
|
+
"",
|
|
1151
|
+
"Look for these natural split points:",
|
|
1152
|
+
"- Source code vs tests",
|
|
1153
|
+
"- Different features or modules (e.g., different directories)",
|
|
1154
|
+
"- New files vs modified files vs deleted files",
|
|
1155
|
+
"- Configuration changes vs code changes",
|
|
1156
|
+
"- Documentation vs implementation",
|
|
1157
|
+
"",
|
|
1158
|
+
"If unsure, err on the side of MORE groups, not fewer.",
|
|
1159
|
+
"",
|
|
1160
|
+
"Output format: JSON array of objects with keys 'name', 'description', 'files'.",
|
|
1161
|
+
"name: short label (3-5 words)",
|
|
1162
|
+
"description: 1-2 sentences explaining what this group changes",
|
|
1163
|
+
"files: array of exact file paths from the input",
|
|
1164
|
+
"",
|
|
1165
|
+
"Output ONLY valid JSON. No markdown fences, no explanation."
|
|
1166
|
+
].join("\n");
|
|
1167
|
+
}
|
|
1204
1168
|
function parseGroupingResponse(content) {
|
|
1205
|
-
|
|
1169
|
+
let cleaned = content.replace(/<think[\s\S]*?<\/think>/gi, "").trim();
|
|
1170
|
+
cleaned = cleaned.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "").trim();
|
|
1171
|
+
const start = cleaned.indexOf("[");
|
|
1172
|
+
const end = cleaned.lastIndexOf("]");
|
|
1173
|
+
if (start === -1 || end === -1 || end <= start) throw new Error("AI response did not contain a JSON array");
|
|
1174
|
+
const jsonText = cleaned.slice(start, end + 1);
|
|
1206
1175
|
const parsed = JSON.parse(jsonText);
|
|
1207
1176
|
if (!Array.isArray(parsed)) throw new Error("AI response was not a JSON array");
|
|
1208
1177
|
const rawGroups = [];
|
|
@@ -1236,27 +1205,17 @@ async function generateGroups(files, apiKey, model, timeout, provider, proxy) {
|
|
|
1236
1205
|
baseURLOverride: proxy
|
|
1237
1206
|
});
|
|
1238
1207
|
try {
|
|
1239
|
-
|
|
1240
|
-
messages: [{
|
|
1241
|
-
role: "system",
|
|
1242
|
-
content: systemPrompt
|
|
1243
|
-
}, {
|
|
1244
|
-
role: "user",
|
|
1245
|
-
content: userPrompt
|
|
1246
|
-
}],
|
|
1247
|
-
model: resolvedModel,
|
|
1248
|
-
temperature: .3,
|
|
1249
|
-
max_tokens: 2048
|
|
1250
|
-
});
|
|
1251
|
-
const rawContent = completion.choices[0]?.message?.content;
|
|
1252
|
-
const content = typeof rawContent === "string" ? rawContent.trim() : "";
|
|
1253
|
-
debug("generateGroups response: choices=%d, finishReason=%s, contentLen=%d", completion.choices.length, completion.choices[0]?.finish_reason ?? "(none)", content.length);
|
|
1254
|
-
debug("generateGroups raw content: %s", content.slice(0, 500) || "(empty)");
|
|
1255
|
-
if (!content) throw new Error("AI returned an empty grouping response");
|
|
1256
|
-
const rawGroups = parseGroupingResponse(content);
|
|
1208
|
+
let rawGroups = await callGroupingAI(client, resolvedModel, systemPrompt, userPrompt);
|
|
1257
1209
|
debug("generateGroups: parsed %d raw groups", rawGroups.length);
|
|
1258
|
-
|
|
1210
|
+
let validated = validateGroups(rawGroups, included);
|
|
1259
1211
|
debug("generateGroups: %d validated groups", validated.length);
|
|
1212
|
+
if (isLowQualityGrouping(validated, included)) {
|
|
1213
|
+
debug("generateGroups: low quality result, retrying with stricter prompt");
|
|
1214
|
+
rawGroups = await callGroupingAI(client, resolvedModel, buildRetryGroupingPrompt(), userPrompt);
|
|
1215
|
+
debug("generateGroups retry: parsed %d raw groups", rawGroups.length);
|
|
1216
|
+
validated = validateGroups(rawGroups, included);
|
|
1217
|
+
debug("generateGroups retry: %d validated groups", validated.length);
|
|
1218
|
+
}
|
|
1260
1219
|
return {
|
|
1261
1220
|
groups: validated,
|
|
1262
1221
|
excluded
|
|
@@ -1266,6 +1225,33 @@ async function generateGroups(files, apiKey, model, timeout, provider, proxy) {
|
|
|
1266
1225
|
throw mapGroqError(error, provider ? formatProviderName(provider) : void 0);
|
|
1267
1226
|
}
|
|
1268
1227
|
}
|
|
1228
|
+
async function callGroupingAI(client, model, systemPrompt, userPrompt) {
|
|
1229
|
+
const completion = await client.chat.completions.create({
|
|
1230
|
+
messages: [{
|
|
1231
|
+
role: "system",
|
|
1232
|
+
content: systemPrompt
|
|
1233
|
+
}, {
|
|
1234
|
+
role: "user",
|
|
1235
|
+
content: userPrompt
|
|
1236
|
+
}],
|
|
1237
|
+
model,
|
|
1238
|
+
temperature: .3,
|
|
1239
|
+
max_tokens: 2048
|
|
1240
|
+
});
|
|
1241
|
+
const rawContent = completion.choices[0]?.message?.content;
|
|
1242
|
+
const content = typeof rawContent === "string" ? rawContent.trim() : "";
|
|
1243
|
+
debug("callGroupingAI response: choices=%d, finishReason=%s, contentLen=%d", completion.choices.length, completion.choices[0]?.finish_reason ?? "(none)", content.length);
|
|
1244
|
+
debug("callGroupingAI raw content: %s", content.slice(0, 500) || "(empty)");
|
|
1245
|
+
if (!content) throw new Error("AI returned an empty grouping response");
|
|
1246
|
+
return parseGroupingResponse(content);
|
|
1247
|
+
}
|
|
1248
|
+
/** Minimum file count where a single-group result is considered low quality */
|
|
1249
|
+
const MIN_FILES_FOR_QUALITY_CHECK = 5;
|
|
1250
|
+
function isLowQualityGrouping(groups, allFiles) {
|
|
1251
|
+
if (groups.length === 0) return false;
|
|
1252
|
+
if (allFiles.length < MIN_FILES_FOR_QUALITY_CHECK) return false;
|
|
1253
|
+
return groups.length === 1;
|
|
1254
|
+
}
|
|
1269
1255
|
function validateGroups(groups, allFiles) {
|
|
1270
1256
|
const validPaths = new Set(allFiles.map((f) => f.path));
|
|
1271
1257
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -1294,6 +1280,51 @@ function validateGroups(groups, allFiles) {
|
|
|
1294
1280
|
}
|
|
1295
1281
|
return validated;
|
|
1296
1282
|
}
|
|
1283
|
+
const EXIT_CODES = {
|
|
1284
|
+
SUCCESS: 0,
|
|
1285
|
+
GENERIC: 1,
|
|
1286
|
+
NO_CHANGES: 2,
|
|
1287
|
+
GIT: 3,
|
|
1288
|
+
AI: 4,
|
|
1289
|
+
CHECK: 5,
|
|
1290
|
+
HOOK: 6
|
|
1291
|
+
};
|
|
1292
|
+
function writeAgentResult(result) {
|
|
1293
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
1294
|
+
}
|
|
1295
|
+
//#endregion
|
|
1296
|
+
//#region src/utils/cache.ts
|
|
1297
|
+
const CACHE_DIR = join(os.homedir(), ".cache", "commit-mint");
|
|
1298
|
+
function repoHash(repoPath) {
|
|
1299
|
+
return createHash("sha256").update(repoPath).digest("hex").slice(0, 12);
|
|
1300
|
+
}
|
|
1301
|
+
function cachePath(repoPath) {
|
|
1302
|
+
return join(CACHE_DIR, `${repoHash(repoPath)}.json`);
|
|
1303
|
+
}
|
|
1304
|
+
async function saveCachedCommit(repoPath, message) {
|
|
1305
|
+
await mkdir(CACHE_DIR, { recursive: true });
|
|
1306
|
+
const data = {
|
|
1307
|
+
message,
|
|
1308
|
+
timestamp: Date.now(),
|
|
1309
|
+
repoPath
|
|
1310
|
+
};
|
|
1311
|
+
const path = cachePath(repoPath);
|
|
1312
|
+
debug("saveCachedCommit: saving to %s", path);
|
|
1313
|
+
await writeFile(path, JSON.stringify(data, null, 2), "utf8");
|
|
1314
|
+
}
|
|
1315
|
+
async function loadCachedCommit(repoPath) {
|
|
1316
|
+
const path = cachePath(repoPath);
|
|
1317
|
+
debug("loadCachedCommit: loading from %s", path);
|
|
1318
|
+
try {
|
|
1319
|
+
const raw = await readFile(path, "utf8");
|
|
1320
|
+
const data = JSON.parse(raw);
|
|
1321
|
+
debug("loadCachedCommit: found message from %s", new Date(data.timestamp).toISOString());
|
|
1322
|
+
return data;
|
|
1323
|
+
} catch {
|
|
1324
|
+
debug("loadCachedCommit: no cached commit found");
|
|
1325
|
+
return null;
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1297
1328
|
//#endregion
|
|
1298
1329
|
//#region src/services/clipboard.ts
|
|
1299
1330
|
/** Milliseconds to wait after stdin closes for quick exit failures. */
|
|
@@ -1678,6 +1709,49 @@ async function showRecoveryMenu(errors, onRetry, onSkipHooks, onRestage, message
|
|
|
1678
1709
|
}
|
|
1679
1710
|
}
|
|
1680
1711
|
//#endregion
|
|
1712
|
+
//#region src/ui/review-message.ts
|
|
1713
|
+
async function reviewCommitMessage(message) {
|
|
1714
|
+
const { select, text } = await import("@clack/prompts");
|
|
1715
|
+
while (true) {
|
|
1716
|
+
const review = await select({
|
|
1717
|
+
message: `Review commit message:\n\n ${bold(message)}\n`,
|
|
1718
|
+
options: [
|
|
1719
|
+
{
|
|
1720
|
+
label: "Use as-is",
|
|
1721
|
+
value: "use"
|
|
1722
|
+
},
|
|
1723
|
+
{
|
|
1724
|
+
label: "Edit",
|
|
1725
|
+
value: "edit"
|
|
1726
|
+
},
|
|
1727
|
+
{
|
|
1728
|
+
label: "Cancel",
|
|
1729
|
+
value: "cancel"
|
|
1730
|
+
}
|
|
1731
|
+
]
|
|
1732
|
+
});
|
|
1733
|
+
if (isCancel(review) || review === "cancel") {
|
|
1734
|
+
debug("User cancelled at review step");
|
|
1735
|
+
return null;
|
|
1736
|
+
}
|
|
1737
|
+
if (review === "use") {
|
|
1738
|
+
debug("User accepted message");
|
|
1739
|
+
return message;
|
|
1740
|
+
}
|
|
1741
|
+
if (review === "edit") {
|
|
1742
|
+
debug("User chose to edit message");
|
|
1743
|
+
const edited = await text({
|
|
1744
|
+
message: "Edit commit message:",
|
|
1745
|
+
initialValue: message,
|
|
1746
|
+
validate: (v) => v?.trim() ? void 0 : "Message cannot be empty"
|
|
1747
|
+
});
|
|
1748
|
+
if (isCancel(edited)) continue;
|
|
1749
|
+
message = String(edited).trim();
|
|
1750
|
+
debug("Edited message:", message);
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
//#endregion
|
|
1681
1755
|
//#region src/commands/auto-group.ts
|
|
1682
1756
|
async function runAutoGroupFlow(changedFiles, flags) {
|
|
1683
1757
|
const { included, excluded } = filterExcludedFiles(changedFiles);
|
|
@@ -1855,6 +1929,245 @@ function buildExcludedFilesMessage(files) {
|
|
|
1855
1929
|
return "chore: update generated files";
|
|
1856
1930
|
}
|
|
1857
1931
|
//#endregion
|
|
1932
|
+
//#region src/commands/agent.ts
|
|
1933
|
+
/**
|
|
1934
|
+
* Wrapper around getHead() that returns "" on fresh repos with no commits.
|
|
1935
|
+
* `git rev-parse HEAD` fails with exit 128 on a brand-new repo, which would
|
|
1936
|
+
* crash the agent flow before the first commit can be made.
|
|
1937
|
+
*/
|
|
1938
|
+
async function safeGetHead() {
|
|
1939
|
+
try {
|
|
1940
|
+
return await getHead();
|
|
1941
|
+
} catch {
|
|
1942
|
+
return "";
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
/**
|
|
1946
|
+
* Headless agent command — orchestrates the entire commit flow without any TUI
|
|
1947
|
+
* interaction. Emits structured JSON results to stdout, one per line. Returns
|
|
1948
|
+
* control to the caller with `process.exitCode` set to one of the 7 documented
|
|
1949
|
+
* exit codes (0=success, 1=generic, 2=no_changes, 3=git, 4=ai, 5=check, 6=hook).
|
|
1950
|
+
*/
|
|
1951
|
+
async function agentCommand(flags) {
|
|
1952
|
+
debug("agentCommand called", { flags });
|
|
1953
|
+
if (flags.retry) {
|
|
1954
|
+
process.exitCode = EXIT_CODES.GENERIC;
|
|
1955
|
+
writeAgentResult({
|
|
1956
|
+
status: "failure",
|
|
1957
|
+
commits: [],
|
|
1958
|
+
errors: ["--agent is not compatible with --retry"]
|
|
1959
|
+
});
|
|
1960
|
+
return;
|
|
1961
|
+
}
|
|
1962
|
+
try {
|
|
1963
|
+
await assertGitRepo();
|
|
1964
|
+
} catch (err) {
|
|
1965
|
+
process.exitCode = EXIT_CODES.GIT;
|
|
1966
|
+
writeAgentResult({
|
|
1967
|
+
status: "failure",
|
|
1968
|
+
commits: [],
|
|
1969
|
+
errors: [err instanceof Error ? err.message : String(err)]
|
|
1970
|
+
});
|
|
1971
|
+
return;
|
|
1972
|
+
}
|
|
1973
|
+
const status = await getStatusShort();
|
|
1974
|
+
debug("Git status:", status || "(empty)");
|
|
1975
|
+
if (!status) {
|
|
1976
|
+
process.exitCode = EXIT_CODES.NO_CHANGES;
|
|
1977
|
+
writeAgentResult({
|
|
1978
|
+
status: "no_changes",
|
|
1979
|
+
commits: []
|
|
1980
|
+
});
|
|
1981
|
+
return;
|
|
1982
|
+
}
|
|
1983
|
+
const changedFiles = await getChangedFiles();
|
|
1984
|
+
debug("Changed files:", changedFiles.length);
|
|
1985
|
+
await stageFiles(changedFiles.map((f) => f.path));
|
|
1986
|
+
const diffResult = await getStagedDiff();
|
|
1987
|
+
if (!diffResult) {
|
|
1988
|
+
process.exitCode = EXIT_CODES.NO_CHANGES;
|
|
1989
|
+
writeAgentResult({
|
|
1990
|
+
status: "no_changes",
|
|
1991
|
+
commits: []
|
|
1992
|
+
});
|
|
1993
|
+
return;
|
|
1994
|
+
}
|
|
1995
|
+
if ("excludedFiles" in diffResult) {
|
|
1996
|
+
debug("All staged files are excluded:", diffResult.excludedFiles);
|
|
1997
|
+
const message = buildExcludedFilesMessage(diffResult.excludedFiles);
|
|
1998
|
+
const headBefore = await safeGetHead();
|
|
1999
|
+
const result = await attemptCommit(message);
|
|
2000
|
+
const headAfter = await safeGetHead();
|
|
2001
|
+
if (result.ok || headBefore !== headAfter) {
|
|
2002
|
+
process.exitCode = EXIT_CODES.SUCCESS;
|
|
2003
|
+
writeAgentResult({
|
|
2004
|
+
status: "success",
|
|
2005
|
+
commits: [{
|
|
2006
|
+
message,
|
|
2007
|
+
hash: headAfter,
|
|
2008
|
+
files: diffResult.excludedFiles
|
|
2009
|
+
}]
|
|
2010
|
+
});
|
|
2011
|
+
} else {
|
|
2012
|
+
process.exitCode = EXIT_CODES.HOOK;
|
|
2013
|
+
writeAgentResult({
|
|
2014
|
+
status: "failure",
|
|
2015
|
+
commits: [],
|
|
2016
|
+
errors: parseHookErrors(result.stderr ?? "").map((e) => `[${e.tool}] ${e.message}`)
|
|
2017
|
+
});
|
|
2018
|
+
}
|
|
2019
|
+
return;
|
|
2020
|
+
}
|
|
2021
|
+
if (flags.message) {
|
|
2022
|
+
debug("Using provided message:", flags.message);
|
|
2023
|
+
const headBefore = await safeGetHead();
|
|
2024
|
+
const result = await attemptCommit(flags.message);
|
|
2025
|
+
const headAfter = await safeGetHead();
|
|
2026
|
+
if (result.ok || headBefore !== headAfter) {
|
|
2027
|
+
process.exitCode = EXIT_CODES.SUCCESS;
|
|
2028
|
+
writeAgentResult({
|
|
2029
|
+
status: "success",
|
|
2030
|
+
commits: [{
|
|
2031
|
+
message: flags.message,
|
|
2032
|
+
hash: headAfter,
|
|
2033
|
+
files: diffResult.files
|
|
2034
|
+
}]
|
|
2035
|
+
});
|
|
2036
|
+
} else {
|
|
2037
|
+
process.exitCode = EXIT_CODES.HOOK;
|
|
2038
|
+
writeAgentResult({
|
|
2039
|
+
status: "failure",
|
|
2040
|
+
commits: [],
|
|
2041
|
+
errors: parseHookErrors(result.stderr ?? "").map((e) => `[${e.tool}] ${e.message}`)
|
|
2042
|
+
});
|
|
2043
|
+
}
|
|
2044
|
+
return;
|
|
2045
|
+
}
|
|
2046
|
+
if (!flags.noCheck) {
|
|
2047
|
+
const repoRoot = await getRepoRoot();
|
|
2048
|
+
if (await detectConfig(repoRoot)) {
|
|
2049
|
+
debug("Running user checks on changed files...");
|
|
2050
|
+
const checkResults = await runAllChecks(repoRoot, changedFiles.filter((f) => f.status !== "D").map((f) => f.path), 6e4);
|
|
2051
|
+
if (!checkResults.ok) {
|
|
2052
|
+
const errorMessages = checkResults.results.filter((r) => !r.ok).map((r) => `[${r.tool}]\n${r.stdout}\n${r.stderr}`.trim()).filter(Boolean);
|
|
2053
|
+
const parsed = parseCheckErrors(errorMessages.join("\n\n"));
|
|
2054
|
+
const errors = parsed.length > 0 ? parsed.map((e) => `[${e.tool}] ${e.message}`) : errorMessages;
|
|
2055
|
+
process.exitCode = EXIT_CODES.CHECK;
|
|
2056
|
+
writeAgentResult({
|
|
2057
|
+
status: "failure",
|
|
2058
|
+
commits: [],
|
|
2059
|
+
errors
|
|
2060
|
+
});
|
|
2061
|
+
return;
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
2065
|
+
const { included, excluded } = filterExcludedFiles(changedFiles);
|
|
2066
|
+
debug("Auto-group: %d included, %d excluded", included.length, excluded.length);
|
|
2067
|
+
if (excluded.length > 0) {
|
|
2068
|
+
const message = buildExcludedFilesMessage(excluded);
|
|
2069
|
+
debug("Committing %d excluded files:", excluded.length, excluded);
|
|
2070
|
+
await resetStaging();
|
|
2071
|
+
await stageFiles(excluded);
|
|
2072
|
+
const headBefore = await safeGetHead();
|
|
2073
|
+
const result = await attemptCommit(message);
|
|
2074
|
+
const headAfter = await safeGetHead();
|
|
2075
|
+
if (!result.ok && headBefore === headAfter) debug("Excluded files commit failed, continuing without them");
|
|
2076
|
+
}
|
|
2077
|
+
if (included.length === 0) {
|
|
2078
|
+
process.exitCode = EXIT_CODES.SUCCESS;
|
|
2079
|
+
writeAgentResult({
|
|
2080
|
+
status: "success",
|
|
2081
|
+
commits: []
|
|
2082
|
+
});
|
|
2083
|
+
return;
|
|
2084
|
+
}
|
|
2085
|
+
const config = await readConfig();
|
|
2086
|
+
const provider = isValidProvider(config.provider ?? "groq") ? config.provider : "groq";
|
|
2087
|
+
let apiKey;
|
|
2088
|
+
try {
|
|
2089
|
+
apiKey = await getProviderApiKey(provider);
|
|
2090
|
+
} catch {
|
|
2091
|
+
process.exitCode = EXIT_CODES.AI;
|
|
2092
|
+
writeAgentResult({
|
|
2093
|
+
status: "failure",
|
|
2094
|
+
commits: [],
|
|
2095
|
+
errors: [`No API key found for ${provider}. Set ${PROVIDER_ENV_KEYS[provider]} env var or run 'cmint config'`]
|
|
2096
|
+
});
|
|
2097
|
+
return;
|
|
2098
|
+
}
|
|
2099
|
+
const model = getModelForProvider(config, provider, PROVIDER_CONFIGS[provider].defaultModel);
|
|
2100
|
+
const timeout = config.timeout ? parseInt(config.timeout, 10) : void 0;
|
|
2101
|
+
let groups;
|
|
2102
|
+
try {
|
|
2103
|
+
groups = validateGroups((await generateGroups(included, apiKey, model, timeout, provider, config.proxy)).groups, included);
|
|
2104
|
+
} catch (err) {
|
|
2105
|
+
process.exitCode = EXIT_CODES.AI;
|
|
2106
|
+
writeAgentResult({
|
|
2107
|
+
status: "failure",
|
|
2108
|
+
commits: [],
|
|
2109
|
+
errors: [err instanceof Error ? err.message : String(err)]
|
|
2110
|
+
});
|
|
2111
|
+
return;
|
|
2112
|
+
}
|
|
2113
|
+
const commits = [];
|
|
2114
|
+
for (const group of groups) {
|
|
2115
|
+
debug("Processing group %d/%d: %s", commits.length + 1, groups.length, group.name);
|
|
2116
|
+
await resetStaging();
|
|
2117
|
+
await stageFiles(group.files);
|
|
2118
|
+
const groupDiff = await getStagedDiff();
|
|
2119
|
+
if (!groupDiff || "excludedFiles" in groupDiff) {
|
|
2120
|
+
debug(`Skipping group "${group.name}" — no diff`);
|
|
2121
|
+
continue;
|
|
2122
|
+
}
|
|
2123
|
+
let message;
|
|
2124
|
+
try {
|
|
2125
|
+
message = await generateCommitMessage(groupDiff.diff, {
|
|
2126
|
+
apiKey,
|
|
2127
|
+
model,
|
|
2128
|
+
type: config.type,
|
|
2129
|
+
timeout,
|
|
2130
|
+
hint: flags.hint,
|
|
2131
|
+
provider,
|
|
2132
|
+
proxy: config.proxy
|
|
2133
|
+
});
|
|
2134
|
+
} catch (err) {
|
|
2135
|
+
process.exitCode = EXIT_CODES.AI;
|
|
2136
|
+
writeAgentResult({
|
|
2137
|
+
status: "failure",
|
|
2138
|
+
commits,
|
|
2139
|
+
errors: [err instanceof Error ? err.message : String(err)]
|
|
2140
|
+
});
|
|
2141
|
+
return;
|
|
2142
|
+
}
|
|
2143
|
+
await saveCachedCommit(await getRepoRoot(), message);
|
|
2144
|
+
const headBefore = await safeGetHead();
|
|
2145
|
+
const result = await attemptCommit(message);
|
|
2146
|
+
const headAfter = await safeGetHead();
|
|
2147
|
+
if (result.ok || headBefore !== headAfter) {
|
|
2148
|
+
commits.push({
|
|
2149
|
+
message,
|
|
2150
|
+
hash: headAfter,
|
|
2151
|
+
files: group.files,
|
|
2152
|
+
groupName: group.name
|
|
2153
|
+
});
|
|
2154
|
+
continue;
|
|
2155
|
+
}
|
|
2156
|
+
process.exitCode = EXIT_CODES.HOOK;
|
|
2157
|
+
writeAgentResult({
|
|
2158
|
+
status: "failure",
|
|
2159
|
+
commits,
|
|
2160
|
+
errors: parseHookErrors(result.stderr ?? "").map((e) => `[${e.tool}] ${e.message}`)
|
|
2161
|
+
});
|
|
2162
|
+
return;
|
|
2163
|
+
}
|
|
2164
|
+
process.exitCode = EXIT_CODES.SUCCESS;
|
|
2165
|
+
writeAgentResult({
|
|
2166
|
+
status: "success",
|
|
2167
|
+
commits
|
|
2168
|
+
});
|
|
2169
|
+
}
|
|
2170
|
+
//#endregion
|
|
1858
2171
|
//#region src/commands/commit-utils.ts
|
|
1859
2172
|
/** Shared recovery menu factory — avoids repeating the same callback set */
|
|
1860
2173
|
function makeRecoveryCallbacks(message) {
|
|
@@ -2265,7 +2578,8 @@ async function runPreCommitChecks(changedFiles, noCheck) {
|
|
|
2265
2578
|
});
|
|
2266
2579
|
if (menuResult === "cancelled") process.exit(1);
|
|
2267
2580
|
if (menuResult === "retried") {
|
|
2268
|
-
debug("Re-running checks after retry...");
|
|
2581
|
+
debug("Re-staging files and re-running checks after retry...");
|
|
2582
|
+
await stageAll();
|
|
2269
2583
|
const ckSpinner = spinner();
|
|
2270
2584
|
ckSpinner.start("Running checks...");
|
|
2271
2585
|
checkResults = await runAllChecks(checkRoot, stagedFileList, 6e4);
|
|
@@ -2612,6 +2926,40 @@ async function configCommand() {
|
|
|
2612
2926
|
}
|
|
2613
2927
|
}
|
|
2614
2928
|
//#endregion
|
|
2929
|
+
//#region src/commands/logs.ts
|
|
2930
|
+
const LOG_PATH = join(os.homedir(), ".cache", "commit-mint", "debug.log");
|
|
2931
|
+
const SESSION_SEPARATOR = /^--- session .+ ---$/;
|
|
2932
|
+
async function logsCommand(flags) {
|
|
2933
|
+
let content;
|
|
2934
|
+
try {
|
|
2935
|
+
content = await readFile(LOG_PATH, "utf8");
|
|
2936
|
+
} catch (err) {
|
|
2937
|
+
if (err.code === "ENOENT") {
|
|
2938
|
+
console.error("No debug logs found. Run cmint with any command first.");
|
|
2939
|
+
process.exit(1);
|
|
2940
|
+
}
|
|
2941
|
+
throw err;
|
|
2942
|
+
}
|
|
2943
|
+
if (content.trim() === "") {
|
|
2944
|
+
console.error("No debug logs found. Run cmint with any command first.");
|
|
2945
|
+
process.exit(1);
|
|
2946
|
+
}
|
|
2947
|
+
const allLines = content.split("\n");
|
|
2948
|
+
let lastSessionIndex = -1;
|
|
2949
|
+
for (let i = allLines.length - 1; i >= 0; i--) if (SESSION_SEPARATOR.test(allLines[i])) {
|
|
2950
|
+
lastSessionIndex = i;
|
|
2951
|
+
break;
|
|
2952
|
+
}
|
|
2953
|
+
const sessionLines = lastSessionIndex === -1 ? allLines : allLines.slice(lastSessionIndex + 1);
|
|
2954
|
+
const filtered = sessionLines.filter((line) => line.length > 0 || sessionLines.indexOf(line) === 0);
|
|
2955
|
+
if (filtered.length === 0) {
|
|
2956
|
+
console.error("No debug logs found. Run cmint with any command first.");
|
|
2957
|
+
process.exit(1);
|
|
2958
|
+
}
|
|
2959
|
+
const lines = flags.lines !== void 0 && flags.lines > 0 ? filtered.slice(-flags.lines) : filtered;
|
|
2960
|
+
for (const line of lines) console.log(line);
|
|
2961
|
+
}
|
|
2962
|
+
//#endregion
|
|
2615
2963
|
//#region src/cli.ts
|
|
2616
2964
|
const { version } = package_default;
|
|
2617
2965
|
cli({
|
|
@@ -2652,14 +3000,31 @@ cli({
|
|
|
2652
3000
|
description: "Skip user-defined pre-commit checks",
|
|
2653
3001
|
alias: "N",
|
|
2654
3002
|
default: false
|
|
3003
|
+
},
|
|
3004
|
+
agent: {
|
|
3005
|
+
type: Boolean,
|
|
3006
|
+
description: "AI agent mode: non-interactive auto-group with JSON output",
|
|
3007
|
+
default: false
|
|
2655
3008
|
}
|
|
2656
3009
|
},
|
|
2657
|
-
commands: [command({
|
|
3010
|
+
commands: [command({
|
|
3011
|
+
name: "logs",
|
|
3012
|
+
description: "Show debug logs from the last cmint run",
|
|
3013
|
+
flags: { lines: {
|
|
3014
|
+
type: Number,
|
|
3015
|
+
description: "Number of lines to show from the end",
|
|
3016
|
+
alias: "n"
|
|
3017
|
+
} }
|
|
3018
|
+
}, async (argv) => {
|
|
3019
|
+
await logsCommand(argv.flags);
|
|
3020
|
+
}), command({ name: "config" }, async () => {
|
|
2658
3021
|
await configCommand();
|
|
2659
3022
|
})]
|
|
2660
3023
|
}, (argv) => {
|
|
3024
|
+
writeSessionHeader();
|
|
2661
3025
|
setDebug(argv.flags.debug);
|
|
2662
|
-
|
|
3026
|
+
if (argv.flags.agent) agentCommand(argv.flags);
|
|
3027
|
+
else commitCommand(argv.flags);
|
|
2663
3028
|
});
|
|
2664
3029
|
//#endregion
|
|
2665
3030
|
export {};
|