@kyubiware/commit-mint 0.5.6 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +305 -141
- package/dist/cli.mjs +1303 -695
- package/dist/cli.mjs.map +1 -1
- package/package.json +3 -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.
|
|
31
|
+
version: "0.6.1",
|
|
32
32
|
description: "🌿 A commit tool that actually handles hook failures",
|
|
33
33
|
type: "module",
|
|
34
34
|
bin: { "cmint": "./dist/cli.mjs" },
|
|
@@ -47,7 +47,8 @@ var package_default = {
|
|
|
47
47
|
"release:patch": "bash scripts/release.sh patch",
|
|
48
48
|
"release:minor": "bash scripts/release.sh minor",
|
|
49
49
|
"release:major": "bash scripts/release.sh major",
|
|
50
|
-
"prepublishOnly": "npm run build"
|
|
50
|
+
"prepublishOnly": "npm run build",
|
|
51
|
+
"publish:cmint": "cd packages/cmint && npm publish"
|
|
51
52
|
},
|
|
52
53
|
keywords: [
|
|
53
54
|
"git",
|
|
@@ -88,13 +89,30 @@ var package_default = {
|
|
|
88
89
|
//#endregion
|
|
89
90
|
//#region src/utils/debug.ts
|
|
90
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
|
+
}
|
|
91
101
|
function setDebug(value) {
|
|
92
102
|
enabled = value;
|
|
93
103
|
}
|
|
104
|
+
function writeSessionHeader() {
|
|
105
|
+
if (sessionWritten) return;
|
|
106
|
+
ensureLogDir();
|
|
107
|
+
writeFileSync(logFile, `--- session ${(/* @__PURE__ */ new Date()).toISOString()} ---\n`, "utf8");
|
|
108
|
+
sessionWritten = true;
|
|
109
|
+
}
|
|
94
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");
|
|
95
114
|
if (!enabled) return;
|
|
96
|
-
|
|
97
|
-
console.error(dim(`[debug ${timestamp}]`), ...args);
|
|
115
|
+
console.error(dim(prefix), ...args);
|
|
98
116
|
}
|
|
99
117
|
//#endregion
|
|
100
118
|
//#region src/services/provider.ts
|
|
@@ -174,69 +192,178 @@ function createProvider(options) {
|
|
|
174
192
|
};
|
|
175
193
|
}
|
|
176
194
|
//#endregion
|
|
177
|
-
//#region src/services/
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
"
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
};
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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++;
|
|
201
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");
|
|
202
265
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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;
|
|
207
270
|
}
|
|
208
|
-
|
|
209
|
-
|
|
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");
|
|
210
277
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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;
|
|
218
346
|
}
|
|
219
347
|
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
debug("
|
|
224
|
-
|
|
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);
|
|
225
366
|
}
|
|
226
|
-
debug("getProviderApiKey(%s): not found", provider);
|
|
227
|
-
throw new Error(`Please set your ${formatProviderName(provider)} API key via \`cmint config set ${envVar}=<your token>\``);
|
|
228
|
-
}
|
|
229
|
-
/** Check if a model name is the default for a provider OTHER than the given one. */
|
|
230
|
-
function isOtherProviderDefault(model, provider) {
|
|
231
|
-
for (const [name, config] of Object.entries(PROVIDER_CONFIGS)) if (name !== provider && config.defaultModel === model) return true;
|
|
232
|
-
return false;
|
|
233
|
-
}
|
|
234
|
-
function getModelForProvider(config, provider, defaultModel) {
|
|
235
|
-
const providerModel = config[`model_${provider}`];
|
|
236
|
-
if (providerModel) return providerModel;
|
|
237
|
-
const globalModel = config.model;
|
|
238
|
-
if (globalModel && !isOtherProviderDefault(globalModel, provider)) return globalModel;
|
|
239
|
-
return defaultModel;
|
|
240
367
|
}
|
|
241
368
|
//#endregion
|
|
242
369
|
//#region src/services/hooks.ts
|
|
@@ -472,651 +599,466 @@ function findMeaningfulCommand(command) {
|
|
|
472
599
|
return segments[segments.length - 1] || command;
|
|
473
600
|
}
|
|
474
601
|
//#endregion
|
|
475
|
-
//#region src/services/
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
status,
|
|
493
|
-
command,
|
|
494
|
-
tool
|
|
495
|
-
});
|
|
496
|
-
}
|
|
497
|
-
return steps;
|
|
498
|
-
};
|
|
499
|
-
}
|
|
500
|
-
function createProgressHandler(s) {
|
|
501
|
-
return (step) => {
|
|
502
|
-
if (step.status === "started") s.message(step.command);
|
|
503
|
-
else if (step.status === "failed") s.message(step.command);
|
|
504
|
-
};
|
|
505
|
-
}
|
|
506
|
-
//#endregion
|
|
507
|
-
//#region src/services/git.ts
|
|
508
|
-
var git_exports = /* @__PURE__ */ __exportAll({
|
|
509
|
-
KnownError: () => KnownError,
|
|
510
|
-
assertGitRepo: () => assertGitRepo,
|
|
511
|
-
attemptCommit: () => attemptCommit,
|
|
512
|
-
attemptCommitNoVerify: () => attemptCommitNoVerify,
|
|
513
|
-
getChangedFiles: () => getChangedFiles,
|
|
514
|
-
getDefaultExcludes: () => getDefaultExcludes,
|
|
515
|
-
getHead: () => getHead,
|
|
516
|
-
getRepoRoot: () => getRepoRoot,
|
|
517
|
-
getStagedDiff: () => getStagedDiff,
|
|
518
|
-
getStatusShort: () => getStatusShort,
|
|
519
|
-
resetStaging: () => resetStaging,
|
|
520
|
-
stageAll: () => stageAll,
|
|
521
|
-
stageFiles: () => stageFiles
|
|
522
|
-
});
|
|
523
|
-
var KnownError = class extends Error {};
|
|
524
|
-
async function assertGitRepo() {
|
|
525
|
-
debug("assertGitRepo");
|
|
526
|
-
const { failed } = await execa("git", ["rev-parse", "--show-toplevel"], { reject: false });
|
|
527
|
-
if (failed) throw new KnownError("The current directory must be a Git repository!");
|
|
528
|
-
}
|
|
529
|
-
async function getRepoRoot() {
|
|
530
|
-
const { stdout } = await execa("git", ["rev-parse", "--show-toplevel"]);
|
|
531
|
-
debug("getRepoRoot:", stdout.trim());
|
|
532
|
-
return stdout.trim();
|
|
533
|
-
}
|
|
534
|
-
const DEFAULT_EXCLUDES = [
|
|
535
|
-
"package-lock.json",
|
|
536
|
-
"node_modules/**",
|
|
537
|
-
"dist/**",
|
|
538
|
-
"build/**",
|
|
539
|
-
".next/**",
|
|
540
|
-
"coverage/**",
|
|
541
|
-
"*.log",
|
|
542
|
-
"*.min.js",
|
|
543
|
-
"*.min.css",
|
|
544
|
-
"*.lock",
|
|
545
|
-
".DS_Store"
|
|
602
|
+
//#region src/services/checks.ts
|
|
603
|
+
/** Config file names, checked in priority order (matches lint-staged naming conventions) */
|
|
604
|
+
const CONFIG_FILES = [
|
|
605
|
+
".cmintrc",
|
|
606
|
+
".cmintrc.json",
|
|
607
|
+
".cmintrc.mjs",
|
|
608
|
+
".cmintrc.mts",
|
|
609
|
+
".cmintrc.js",
|
|
610
|
+
".cmintrc.ts",
|
|
611
|
+
".cmintrc.cjs",
|
|
612
|
+
".cmintrc.cts",
|
|
613
|
+
"cmint.config.mjs",
|
|
614
|
+
"cmint.config.mts",
|
|
615
|
+
"cmint.config.js",
|
|
616
|
+
"cmint.config.ts",
|
|
617
|
+
"cmint.config.cjs",
|
|
618
|
+
"cmint.config.cts"
|
|
546
619
|
];
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
const
|
|
554
|
-
|
|
555
|
-
"
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
return null;
|
|
561
|
-
}
|
|
562
|
-
const { stdout: files } = await execa("git", [
|
|
563
|
-
"diff",
|
|
564
|
-
"--cached",
|
|
565
|
-
"--name-only",
|
|
566
|
-
...defaultExcludeArgs,
|
|
567
|
-
...excludeArgs
|
|
568
|
-
]);
|
|
569
|
-
if (!files) {
|
|
570
|
-
const excludedFiles = allFiles.split("\n").filter(Boolean);
|
|
571
|
-
debug("getStagedDiff: all files excluded:", excludedFiles);
|
|
572
|
-
return { excludedFiles };
|
|
573
|
-
}
|
|
574
|
-
const { stdout: diff } = await execa("git", [
|
|
575
|
-
"diff",
|
|
576
|
-
"--cached",
|
|
577
|
-
"--diff-algorithm=minimal",
|
|
578
|
-
...defaultExcludeArgs,
|
|
579
|
-
...excludeArgs
|
|
580
|
-
]);
|
|
581
|
-
debug("getStagedDiff:", files.split("\n").filter(Boolean).length, "files,", diff.length, "chars");
|
|
582
|
-
return {
|
|
583
|
-
files: files.split("\n").filter(Boolean),
|
|
584
|
-
diff
|
|
585
|
-
};
|
|
586
|
-
}
|
|
587
|
-
async function stageAll() {
|
|
588
|
-
debug("stageAll: git add -A");
|
|
589
|
-
await execa("git", ["add", "-A"]);
|
|
590
|
-
}
|
|
591
|
-
async function resetStaging() {
|
|
592
|
-
debug("resetStaging: git reset HEAD");
|
|
593
|
-
await execa("git", ["reset", "HEAD"]);
|
|
594
|
-
}
|
|
595
|
-
async function getHead() {
|
|
596
|
-
const { stdout } = await execa("git", ["rev-parse", "HEAD"]);
|
|
597
|
-
return stdout.trim();
|
|
598
|
-
}
|
|
599
|
-
async function getStatusShort() {
|
|
600
|
-
const { stdout } = await execa("git", ["status", "--short"]);
|
|
601
|
-
return stdout.trim();
|
|
602
|
-
}
|
|
603
|
-
async function getChangedFiles() {
|
|
604
|
-
const { stdout } = await execa("git", ["status", "--short"]);
|
|
605
|
-
if (!stdout.trim()) return [];
|
|
606
|
-
const files = stdout.split("\n").filter(Boolean).map((line) => {
|
|
607
|
-
const indexStatus = line[0];
|
|
608
|
-
return {
|
|
609
|
-
status: line.slice(0, 2).trim(),
|
|
610
|
-
path: line.slice(3),
|
|
611
|
-
staged: indexStatus !== " " && indexStatus !== "?"
|
|
612
|
-
};
|
|
613
|
-
});
|
|
614
|
-
debug("getChangedFiles:", files.length, "files");
|
|
615
|
-
return files;
|
|
620
|
+
/**
|
|
621
|
+
* Detect whether the repo has a cmint config file.
|
|
622
|
+
* Returns the config file path, or null if none found.
|
|
623
|
+
*/
|
|
624
|
+
async function detectConfig(repoRoot) {
|
|
625
|
+
debug("detectConfig: checking for config in %s", repoRoot);
|
|
626
|
+
for (const name of CONFIG_FILES) try {
|
|
627
|
+
await access(join(repoRoot, name), constants.R_OK);
|
|
628
|
+
debug("detectConfig: found %s", name);
|
|
629
|
+
return join(repoRoot, name);
|
|
630
|
+
} catch {}
|
|
631
|
+
debug("detectConfig: no config file found");
|
|
632
|
+
return null;
|
|
616
633
|
}
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
634
|
+
/**
|
|
635
|
+
* Load and validate the cmint config from a repo root.
|
|
636
|
+
* Throws if the loaded value is missing or not a non-null object.
|
|
637
|
+
*/
|
|
638
|
+
async function loadConfig(repoRoot) {
|
|
639
|
+
const configPath = await detectConfig(repoRoot);
|
|
640
|
+
if (!configPath) throw new Error("No cmint config file found");
|
|
641
|
+
debug("loadConfig: loading %s", configPath);
|
|
642
|
+
const ext = extname(configPath);
|
|
643
|
+
const isJSON = ext === ".json";
|
|
644
|
+
const needsJiti = ext === ".ts" || ext === ".mts" || ext === ".cts" || ext === ".cjs";
|
|
645
|
+
let config;
|
|
646
|
+
if (isJSON) {
|
|
647
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
648
|
+
config = JSON.parse(raw);
|
|
649
|
+
} else if (needsJiti) {
|
|
650
|
+
const { createJiti } = await import("jiti");
|
|
651
|
+
const mod = await createJiti(import.meta.url, {}).import(configPath);
|
|
652
|
+
config = mod.default ?? mod;
|
|
653
|
+
} else config = (await import(configPath)).default;
|
|
654
|
+
if (!config || typeof config !== "object" || Array.isArray(config)) throw new Error("cmint config must export a non-null object with glob→command mappings");
|
|
655
|
+
debug("loadConfig: loaded %d glob patterns", Object.keys(config).length);
|
|
656
|
+
return config;
|
|
620
657
|
}
|
|
621
|
-
|
|
622
|
-
|
|
658
|
+
/**
|
|
659
|
+
* Run a shell command and capture its output.
|
|
660
|
+
* Returns a CheckResult with ok=true on success (exit 0), ok=false on failure.
|
|
661
|
+
* Handles ENOENT (command not found) and timeout errors gracefully.
|
|
662
|
+
*/
|
|
663
|
+
async function runCommand(command, timeout, repoRoot) {
|
|
664
|
+
debug("runCommand: %s (timeout: %dms)", command, timeout);
|
|
665
|
+
const tool = extractToolName(command) ?? command.split(" ")[0];
|
|
623
666
|
try {
|
|
624
|
-
const
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
const parser = onProgress ? createStderrParser() : null;
|
|
632
|
-
subprocess.stderr?.on("data", (chunk) => {
|
|
633
|
-
const text = chunk.toString();
|
|
634
|
-
stderrChunks.push(text);
|
|
635
|
-
if (parser && onProgress) for (const step of parser(text)) onProgress(step);
|
|
667
|
+
const result = await execa(command, {
|
|
668
|
+
shell: true,
|
|
669
|
+
reject: false,
|
|
670
|
+
timeout,
|
|
671
|
+
all: true,
|
|
672
|
+
preferLocal: true,
|
|
673
|
+
...repoRoot ? { localDir: repoRoot } : {}
|
|
636
674
|
});
|
|
637
|
-
|
|
638
|
-
debug("
|
|
675
|
+
const ok = !result.failed;
|
|
676
|
+
debug("runCommand: %s — ok=%s", tool, ok);
|
|
639
677
|
return {
|
|
640
|
-
ok
|
|
641
|
-
|
|
678
|
+
ok,
|
|
679
|
+
tool,
|
|
680
|
+
command,
|
|
681
|
+
stdout: result.stdout ?? "",
|
|
682
|
+
stderr: result.stderr ?? "",
|
|
683
|
+
files: []
|
|
642
684
|
};
|
|
643
|
-
} catch (
|
|
644
|
-
const
|
|
645
|
-
|
|
685
|
+
} catch (err) {
|
|
686
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
687
|
+
const isTimedOut = msg.toLowerCase().includes("timed out");
|
|
688
|
+
const isNotFound = msg.toLowerCase().includes("enoent") || msg.toLowerCase().includes("not found");
|
|
689
|
+
debug("runCommand: %s — error: %s", tool, msg);
|
|
646
690
|
return {
|
|
647
691
|
ok: false,
|
|
648
|
-
|
|
649
|
-
|
|
692
|
+
tool,
|
|
693
|
+
command,
|
|
694
|
+
stdout: "",
|
|
695
|
+
stderr: isTimedOut ? `Check timed out after ${timeout}ms` : isNotFound ? `Command not found: ${tool}` : msg,
|
|
696
|
+
files: []
|
|
650
697
|
};
|
|
651
698
|
}
|
|
652
699
|
}
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
700
|
+
/**
|
|
701
|
+
* Filter a list of file paths by a picomatch glob pattern.
|
|
702
|
+
* When the pattern contains no `/`, files are matched at any depth (matchBase).
|
|
703
|
+
* Dotfiles are included (dot: true).
|
|
704
|
+
*/
|
|
705
|
+
function matchFiles(pattern, files) {
|
|
706
|
+
if (!pattern) return [];
|
|
707
|
+
const matchBase = !pattern.includes("/");
|
|
708
|
+
const isMatch = picomatch(pattern, {
|
|
709
|
+
dot: true,
|
|
710
|
+
posixSlashes: true,
|
|
711
|
+
strictBrackets: true
|
|
712
|
+
});
|
|
713
|
+
return files.filter((f) => {
|
|
714
|
+
const parts = f.split("/");
|
|
715
|
+
return isMatch(matchBase ? parts[parts.length - 1] : f);
|
|
716
|
+
});
|
|
656
717
|
}
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
718
|
+
/**
|
|
719
|
+
* Build a shell command string from a base command and a list of file paths.
|
|
720
|
+
* File paths containing spaces are wrapped in double quotes.
|
|
721
|
+
* If no files are provided, the base command is returned as-is.
|
|
722
|
+
*/
|
|
723
|
+
function buildCommand(command, files) {
|
|
724
|
+
if (files.length === 0) return command;
|
|
725
|
+
return `${command} ${files.map((f) => f.includes(" ") ? `"${f}"` : f).join(" ")}`;
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Call a function command with matched files and normalize the result to ResolvedCommand[].
|
|
729
|
+
*/
|
|
730
|
+
function resolveFunction(fn, matchedFiles) {
|
|
731
|
+
const resolved = fn(matchedFiles);
|
|
732
|
+
return (Array.isArray(resolved) ? resolved : [resolved]).map((command) => ({
|
|
733
|
+
command,
|
|
734
|
+
fromFunction: true
|
|
735
|
+
}));
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Resolve config commands for a glob entry into an array of resolved commands.
|
|
739
|
+
* Function commands are called with matched filenames; string commands are kept as-is.
|
|
740
|
+
* Each resolved entry tracks whether it came from a function (for file-append behavior).
|
|
741
|
+
*/
|
|
742
|
+
function resolveCommands(commands, matchedFiles) {
|
|
743
|
+
if (typeof commands === "function") return resolveFunction(commands, matchedFiles);
|
|
744
|
+
if (Array.isArray(commands)) {
|
|
745
|
+
const result = [];
|
|
746
|
+
for (const cmd of commands) if (typeof cmd === "function") result.push(...resolveFunction(cmd, matchedFiles));
|
|
747
|
+
else result.push({
|
|
748
|
+
command: cmd,
|
|
749
|
+
fromFunction: false
|
|
678
750
|
});
|
|
679
|
-
|
|
680
|
-
debug("User cancelled at review step");
|
|
681
|
-
return null;
|
|
682
|
-
}
|
|
683
|
-
if (review === "use") {
|
|
684
|
-
debug("User accepted message");
|
|
685
|
-
return message;
|
|
686
|
-
}
|
|
687
|
-
if (review === "edit") {
|
|
688
|
-
debug("User chose to edit message");
|
|
689
|
-
const edited = await text({
|
|
690
|
-
message: "Edit commit message:",
|
|
691
|
-
initialValue: message,
|
|
692
|
-
validate: (v) => v?.trim() ? void 0 : "Message cannot be empty"
|
|
693
|
-
});
|
|
694
|
-
if (isCancel(edited)) continue;
|
|
695
|
-
message = String(edited).trim();
|
|
696
|
-
debug("Edited message:", message);
|
|
697
|
-
}
|
|
751
|
+
return result;
|
|
698
752
|
}
|
|
753
|
+
return [{
|
|
754
|
+
command: commands,
|
|
755
|
+
fromFunction: false
|
|
756
|
+
}];
|
|
699
757
|
}
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
758
|
+
/**
|
|
759
|
+
* Run resolved commands for a single glob entry, appending results.
|
|
760
|
+
* Function-originated commands run as-is; string commands get matched files appended.
|
|
761
|
+
* Returns false if any command fails (for fail-fast signaling).
|
|
762
|
+
*/
|
|
763
|
+
async function runCommandsForGlob(cmds, matchedFiles, timeout, results, repoRoot) {
|
|
764
|
+
for (const { command, fromFunction } of cmds) {
|
|
765
|
+
const fullCommand = fromFunction ? command : buildCommand(command, matchedFiles);
|
|
766
|
+
debug("runCommandsForGlob: running '%s'", fullCommand);
|
|
767
|
+
const result = await runCommand(fullCommand, timeout, repoRoot);
|
|
768
|
+
results.push({
|
|
769
|
+
...result,
|
|
770
|
+
files: matchedFiles
|
|
771
|
+
});
|
|
772
|
+
if (!result.ok) {
|
|
773
|
+
debug("runCommandsForGlob: check failed, stopping (fail-fast)");
|
|
774
|
+
return false;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
return true;
|
|
708
778
|
}
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
779
|
+
/**
|
|
780
|
+
* Run all user-defined checks from .cmintrc against staged files.
|
|
781
|
+
* Returns a no-op result when no config exists.
|
|
782
|
+
* Fail-fast: stops on first error.
|
|
783
|
+
*/
|
|
784
|
+
async function runAllChecks(repoRoot, stagedFiles, timeout) {
|
|
785
|
+
debug("runAllChecks: %d staged files, checking for config in %s", stagedFiles.length, repoRoot);
|
|
786
|
+
if (!await detectConfig(repoRoot)) {
|
|
787
|
+
debug("runAllChecks: no config found, skipping checks");
|
|
788
|
+
return {
|
|
789
|
+
ok: true,
|
|
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
|
|
715
813
|
};
|
|
716
|
-
const path = cachePath(repoPath);
|
|
717
|
-
debug("saveCachedCommit: saving to %s", path);
|
|
718
|
-
await writeFile(path, JSON.stringify(data, null, 2), "utf8");
|
|
719
814
|
}
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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);
|
|
723
828
|
try {
|
|
724
|
-
const raw = await readFile(
|
|
725
|
-
const
|
|
726
|
-
|
|
727
|
-
|
|
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;
|
|
728
837
|
} catch {
|
|
729
|
-
debug("
|
|
730
|
-
return
|
|
838
|
+
debug("readConfig: no config file, using defaults");
|
|
839
|
+
return { ...defaults };
|
|
731
840
|
}
|
|
732
841
|
}
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
const label = providerLabel ?? "Groq";
|
|
738
|
-
if (error instanceof Groq.AuthenticationError) return /* @__PURE__ */ new Error(`Invalid API key for ${label}. Run: cmint config set ${label.toUpperCase()}_API_KEY=<key>`);
|
|
739
|
-
if (error instanceof Groq.RateLimitError) return /* @__PURE__ */ new Error(`Rate limited by ${label}. Please wait and try again.`);
|
|
740
|
-
if (error instanceof Groq.APIConnectionTimeoutError) return /* @__PURE__ */ new Error("Request timed out. Check your network or try a smaller diff.");
|
|
741
|
-
if (error instanceof Groq.APIError) return /* @__PURE__ */ new Error(`${label} API error: ${error.message}`);
|
|
742
|
-
if (error instanceof Error && /^4\d{2}\s/.test(error.message)) return /* @__PURE__ */ new Error(`${label} API error: ${error.message}`);
|
|
743
|
-
return /* @__PURE__ */ new Error(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`);
|
|
842
|
+
async function writeConfig(updates) {
|
|
843
|
+
const existing = await readConfig();
|
|
844
|
+
Object.assign(existing, updates);
|
|
845
|
+
await writeFile(CONFIG_PATH, ini.stringify(existing), "utf8");
|
|
744
846
|
}
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
return text.replace(/<think[\s\S]*?<\/think>/gi, "").trim();
|
|
847
|
+
async function setConfigValue(key, value) {
|
|
848
|
+
await writeConfig({ [key]: value });
|
|
748
849
|
}
|
|
749
|
-
function
|
|
750
|
-
const
|
|
751
|
-
if (
|
|
752
|
-
|
|
753
|
-
|
|
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>\``);
|
|
754
867
|
}
|
|
755
|
-
|
|
756
|
-
|
|
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;
|
|
757
872
|
}
|
|
758
|
-
function
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
if (idx === 0) return part;
|
|
765
|
-
const lines = part.split("\n");
|
|
766
|
-
return [lines[0], ...lines.slice(1).filter((l) => l.startsWith("+") || l.startsWith("-")).slice(0, 10)].join("\n");
|
|
767
|
-
}).join("");
|
|
768
|
-
}).join("");
|
|
769
|
-
if (result.length <= MAX_DIFF_CHARS) return result;
|
|
770
|
-
return `Summary of changes:\n${(diff.match(/^diff --git a\/(.+) b\/(.+)$/gm) || []).map((f) => {
|
|
771
|
-
const match = f.match(/^diff --git a\/(.+) b\/(.+)$/);
|
|
772
|
-
return match && match[1] === match[2] ? `${match[1]} | changed` : "";
|
|
773
|
-
}).filter(Boolean).join("\n")}`;
|
|
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;
|
|
774
879
|
}
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
let
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
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
|
|
787
901
|
});
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
} else if (line.startsWith("+") && !line.startsWith("+++")) adds++;
|
|
792
|
-
else if (line.startsWith("-") && !line.startsWith("---")) dels++;
|
|
793
|
-
}
|
|
794
|
-
if (currentFile) files.push({
|
|
795
|
-
name: currentFile,
|
|
796
|
-
adds,
|
|
797
|
-
dels
|
|
798
|
-
});
|
|
799
|
-
const totalAdds = files.reduce((s, f) => s + f.adds, 0);
|
|
800
|
-
const totalDels = files.reduce((s, f) => s + f.dels, 0);
|
|
801
|
-
const lines = files.map((f) => ` ${f.name} | +${f.adds} -${f.dels}`);
|
|
802
|
-
lines.push(` ${files.length} files changed, ${totalAdds} insertions(+), ${totalDels} deletions(-)`);
|
|
803
|
-
return lines.join("\n");
|
|
902
|
+
}
|
|
903
|
+
return steps;
|
|
904
|
+
};
|
|
804
905
|
}
|
|
805
|
-
function
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
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
|
+
};
|
|
809
911
|
}
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
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!");
|
|
816
934
|
}
|
|
817
|
-
function
|
|
818
|
-
|
|
935
|
+
async function getRepoRoot() {
|
|
936
|
+
const { stdout } = await execa("git", ["rev-parse", "--show-toplevel"]);
|
|
937
|
+
debug("getRepoRoot:", stdout.trim());
|
|
938
|
+
return stdout.trim();
|
|
819
939
|
}
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
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];
|
|
825
955
|
}
|
|
826
|
-
async function
|
|
827
|
-
const
|
|
828
|
-
|
|
829
|
-
const {
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
const compressedDiff = compressDiff(diff);
|
|
838
|
-
const statSummary = buildStatSummary(diff);
|
|
839
|
-
const systemPrompt = buildSystemPrompt(options.type);
|
|
840
|
-
const userPrompt = buildUserPrompt(compressedDiff, options.hint, statSummary);
|
|
841
|
-
debug("Diff: %d chars → compressed to %d chars", diff.length, compressedDiff.length);
|
|
842
|
-
debug("Stat summary:\n%s", statSummary);
|
|
843
|
-
debug("User prompt length: %d chars", userPrompt.length);
|
|
844
|
-
async function callAI(strictSystemPrompt) {
|
|
845
|
-
const callStart = Date.now();
|
|
846
|
-
debug("callAI: %s — model=%s, promptLen=%d, systemLen=%d", !!strictSystemPrompt ? "RETRY (strict)" : "INITIAL", model, userPrompt.length, (strictSystemPrompt ?? systemPrompt).length);
|
|
847
|
-
try {
|
|
848
|
-
const isReasoningModel = /^(o[1-9]|.*gpt-oss.*|.*gpt-5.*)/i.test(model);
|
|
849
|
-
const isGroq = (options.provider ?? "groq") === "groq";
|
|
850
|
-
const completion = await client.chat.completions.create({
|
|
851
|
-
messages: [{
|
|
852
|
-
role: "system",
|
|
853
|
-
content: strictSystemPrompt ?? systemPrompt
|
|
854
|
-
}, {
|
|
855
|
-
role: "user",
|
|
856
|
-
content: userPrompt
|
|
857
|
-
}],
|
|
858
|
-
model,
|
|
859
|
-
temperature: .3,
|
|
860
|
-
...isReasoningModel ? { max_completion_tokens: 1024 } : { max_tokens: 1024 },
|
|
861
|
-
...isGroq && isReasoningModel ? { reasoning_format: "parsed" } : {}
|
|
862
|
-
});
|
|
863
|
-
const elapsed = Date.now() - callStart;
|
|
864
|
-
const rawContent = completion.choices[0]?.message?.content;
|
|
865
|
-
const content = extractContentText(typeof rawContent === "string" ? stripThinkTags(rawContent) : rawContent);
|
|
866
|
-
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);
|
|
867
|
-
debug("callAI raw content: %s", content.slice(0, 300) || "(empty)");
|
|
868
|
-
if (!content) {
|
|
869
|
-
const reasoning = completion.choices[0]?.message?.reasoning;
|
|
870
|
-
debug("callAI: content empty, attempting reasoning fallback (reasoningLen=%d)", reasoning?.length ?? 0);
|
|
871
|
-
if (reasoning) {
|
|
872
|
-
const derived = deriveMessageFromReasoning(reasoning);
|
|
873
|
-
if (derived) {
|
|
874
|
-
debug("callAI: derived message from reasoning: %s", derived.slice(0, 100));
|
|
875
|
-
return stripThinkTags(derived);
|
|
876
|
-
}
|
|
877
|
-
debug("callAI: could not derive message from reasoning");
|
|
878
|
-
}
|
|
879
|
-
throw new Error("AI returned an empty commit message");
|
|
880
|
-
}
|
|
881
|
-
return content;
|
|
882
|
-
} catch (error) {
|
|
883
|
-
debug("callAI FAILED after %d ms: %s", Date.now() - callStart, error instanceof Error ? `${error.name}: ${error.message}` : String(error));
|
|
884
|
-
throw error;
|
|
885
|
-
}
|
|
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;
|
|
886
967
|
}
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
} else debug("Retry also failed validation, using original message");
|
|
899
|
-
}
|
|
900
|
-
debug("Final message (%d ms total): %s", Date.now() - totalStart, message);
|
|
901
|
-
return message;
|
|
902
|
-
} catch (error) {
|
|
903
|
-
debug("AI error: %s", error instanceof Error ? error.message : String(error));
|
|
904
|
-
throw mapGroqError(error, options.provider ? formatProviderName(options.provider) : void 0);
|
|
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 };
|
|
905
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
|
+
};
|
|
906
992
|
}
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
"
|
|
913
|
-
"
|
|
914
|
-
".cmintrc.mts",
|
|
915
|
-
".cmintrc.js",
|
|
916
|
-
".cmintrc.ts",
|
|
917
|
-
".cmintrc.cjs",
|
|
918
|
-
".cmintrc.cts",
|
|
919
|
-
"cmint.config.mjs",
|
|
920
|
-
"cmint.config.mts",
|
|
921
|
-
"cmint.config.js",
|
|
922
|
-
"cmint.config.ts",
|
|
923
|
-
"cmint.config.cjs",
|
|
924
|
-
"cmint.config.cts"
|
|
925
|
-
];
|
|
926
|
-
/**
|
|
927
|
-
* Detect whether the repo has a cmint config file.
|
|
928
|
-
* Returns the config file path, or null if none found.
|
|
929
|
-
*/
|
|
930
|
-
async function detectConfig(repoRoot) {
|
|
931
|
-
debug("detectConfig: checking for config in %s", repoRoot);
|
|
932
|
-
for (const name of CONFIG_FILES) try {
|
|
933
|
-
await access(join(repoRoot, name), constants.R_OK);
|
|
934
|
-
debug("detectConfig: found %s", name);
|
|
935
|
-
return join(repoRoot, name);
|
|
936
|
-
} catch {}
|
|
937
|
-
debug("detectConfig: no config file found");
|
|
938
|
-
return null;
|
|
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"]);
|
|
939
1000
|
}
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
*/
|
|
944
|
-
async function loadConfig(repoRoot) {
|
|
945
|
-
const configPath = await detectConfig(repoRoot);
|
|
946
|
-
if (!configPath) throw new Error("No cmint config file found");
|
|
947
|
-
debug("loadConfig: loading %s", configPath);
|
|
948
|
-
const ext = extname(configPath);
|
|
949
|
-
const isJSON = ext === ".json";
|
|
950
|
-
const needsJiti = ext === ".ts" || ext === ".mts" || ext === ".cts" || ext === ".cjs";
|
|
951
|
-
let config;
|
|
952
|
-
if (isJSON) {
|
|
953
|
-
const raw = readFileSync(configPath, "utf-8");
|
|
954
|
-
config = JSON.parse(raw);
|
|
955
|
-
} else if (needsJiti) {
|
|
956
|
-
const { createJiti } = await import("jiti");
|
|
957
|
-
const mod = await createJiti(import.meta.url, {}).import(configPath);
|
|
958
|
-
config = mod.default ?? mod;
|
|
959
|
-
} else config = (await import(configPath)).default;
|
|
960
|
-
if (!config || typeof config !== "object" || Array.isArray(config)) throw new Error("cmint config must export a non-null object with glob→command mappings");
|
|
961
|
-
debug("loadConfig: loaded %d glob patterns", Object.keys(config).length);
|
|
962
|
-
return config;
|
|
1001
|
+
async function getHead() {
|
|
1002
|
+
const { stdout } = await execa("git", ["rev-parse", "HEAD"]);
|
|
1003
|
+
return stdout.trim();
|
|
963
1004
|
}
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
const
|
|
972
|
-
|
|
973
|
-
const result = await execa(command, {
|
|
974
|
-
shell: true,
|
|
975
|
-
reject: false,
|
|
976
|
-
timeout,
|
|
977
|
-
all: true,
|
|
978
|
-
preferLocal: true,
|
|
979
|
-
...repoRoot ? { localDir: repoRoot } : {}
|
|
980
|
-
});
|
|
981
|
-
const ok = !result.failed;
|
|
982
|
-
debug("runCommand: %s — ok=%s", tool, ok);
|
|
983
|
-
return {
|
|
984
|
-
ok,
|
|
985
|
-
tool,
|
|
986
|
-
command,
|
|
987
|
-
stdout: result.stdout ?? "",
|
|
988
|
-
stderr: result.stderr ?? "",
|
|
989
|
-
files: []
|
|
990
|
-
};
|
|
991
|
-
} catch (err) {
|
|
992
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
993
|
-
const isTimedOut = msg.toLowerCase().includes("timed out");
|
|
994
|
-
const isNotFound = msg.toLowerCase().includes("enoent") || msg.toLowerCase().includes("not found");
|
|
995
|
-
debug("runCommand: %s — error: %s", tool, msg);
|
|
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];
|
|
996
1014
|
return {
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
stdout: "",
|
|
1001
|
-
stderr: isTimedOut ? `Check timed out after ${timeout}ms` : isNotFound ? `Command not found: ${tool}` : msg,
|
|
1002
|
-
files: []
|
|
1015
|
+
status: line.slice(0, 2).trim(),
|
|
1016
|
+
path: line.slice(3),
|
|
1017
|
+
staged: indexStatus !== " " && indexStatus !== "?"
|
|
1003
1018
|
};
|
|
1004
|
-
}
|
|
1005
|
-
}
|
|
1006
|
-
/**
|
|
1007
|
-
* Filter a list of file paths by a picomatch glob pattern.
|
|
1008
|
-
* When the pattern contains no `/`, files are matched at any depth (matchBase).
|
|
1009
|
-
* Dotfiles are included (dot: true).
|
|
1010
|
-
*/
|
|
1011
|
-
function matchFiles(pattern, files) {
|
|
1012
|
-
if (!pattern) return [];
|
|
1013
|
-
const matchBase = !pattern.includes("/");
|
|
1014
|
-
const isMatch = picomatch(pattern, {
|
|
1015
|
-
dot: true,
|
|
1016
|
-
posixSlashes: true,
|
|
1017
|
-
strictBrackets: true
|
|
1018
|
-
});
|
|
1019
|
-
return files.filter((f) => {
|
|
1020
|
-
const parts = f.split("/");
|
|
1021
|
-
return isMatch(matchBase ? parts[parts.length - 1] : f);
|
|
1022
1019
|
});
|
|
1020
|
+
debug("getChangedFiles:", files.length, "files");
|
|
1021
|
+
return files;
|
|
1023
1022
|
}
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
* If no files are provided, the base command is returned as-is.
|
|
1028
|
-
*/
|
|
1029
|
-
function buildCommand(command, files) {
|
|
1030
|
-
if (files.length === 0) return command;
|
|
1031
|
-
return `${command} ${files.map((f) => f.includes(" ") ? `"${f}"` : f).join(" ")}`;
|
|
1032
|
-
}
|
|
1033
|
-
/**
|
|
1034
|
-
* Call a function command with matched files and normalize the result to ResolvedCommand[].
|
|
1035
|
-
*/
|
|
1036
|
-
function resolveFunction(fn, matchedFiles) {
|
|
1037
|
-
const resolved = fn(matchedFiles);
|
|
1038
|
-
return (Array.isArray(resolved) ? resolved : [resolved]).map((command) => ({
|
|
1039
|
-
command,
|
|
1040
|
-
fromFunction: true
|
|
1041
|
-
}));
|
|
1042
|
-
}
|
|
1043
|
-
/**
|
|
1044
|
-
* Resolve config commands for a glob entry into an array of resolved commands.
|
|
1045
|
-
* Function commands are called with matched filenames; string commands are kept as-is.
|
|
1046
|
-
* Each resolved entry tracks whether it came from a function (for file-append behavior).
|
|
1047
|
-
*/
|
|
1048
|
-
function resolveCommands(commands, matchedFiles) {
|
|
1049
|
-
if (typeof commands === "function") return resolveFunction(commands, matchedFiles);
|
|
1050
|
-
if (Array.isArray(commands)) {
|
|
1051
|
-
const result = [];
|
|
1052
|
-
for (const cmd of commands) if (typeof cmd === "function") result.push(...resolveFunction(cmd, matchedFiles));
|
|
1053
|
-
else result.push({
|
|
1054
|
-
command: cmd,
|
|
1055
|
-
fromFunction: false
|
|
1056
|
-
});
|
|
1057
|
-
return result;
|
|
1058
|
-
}
|
|
1059
|
-
return [{
|
|
1060
|
-
command: commands,
|
|
1061
|
-
fromFunction: false
|
|
1062
|
-
}];
|
|
1023
|
+
async function stageFiles(paths) {
|
|
1024
|
+
debug("stageFiles:", paths);
|
|
1025
|
+
await execa("git", ["add", ...paths]);
|
|
1063
1026
|
}
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
const
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
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);
|
|
1077
1042
|
});
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
return false;
|
|
1081
|
-
}
|
|
1082
|
-
}
|
|
1083
|
-
return true;
|
|
1084
|
-
}
|
|
1085
|
-
/**
|
|
1086
|
-
* Run all user-defined checks from .cmintrc against staged files.
|
|
1087
|
-
* Returns a no-op result when no config exists.
|
|
1088
|
-
* Fail-fast: stops on first error.
|
|
1089
|
-
*/
|
|
1090
|
-
async function runAllChecks(repoRoot, stagedFiles, timeout) {
|
|
1091
|
-
debug("runAllChecks: %d staged files, checking for config in %s", stagedFiles.length, repoRoot);
|
|
1092
|
-
if (!await detectConfig(repoRoot)) {
|
|
1093
|
-
debug("runAllChecks: no config found, skipping checks");
|
|
1043
|
+
await subprocess;
|
|
1044
|
+
debug("attemptCommit: success");
|
|
1094
1045
|
return {
|
|
1095
1046
|
ok: true,
|
|
1096
|
-
|
|
1047
|
+
stderr: stderrChunks.join("")
|
|
1097
1048
|
};
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
for (const [glob, commands] of Object.entries(config)) {
|
|
1103
|
-
const matchedFiles = matchFiles(glob, stagedFiles);
|
|
1104
|
-
if (matchedFiles.length === 0) {
|
|
1105
|
-
debug("runAllChecks: no files matched pattern '%s'", glob);
|
|
1106
|
-
continue;
|
|
1107
|
-
}
|
|
1108
|
-
debug("runAllChecks: pattern '%s' matched %d files", glob, matchedFiles.length);
|
|
1109
|
-
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 {
|
|
1110
1053
|
ok: false,
|
|
1111
|
-
|
|
1054
|
+
error: e.message,
|
|
1055
|
+
stderr: typeof e.stderr === "string" ? e.stderr : ""
|
|
1112
1056
|
};
|
|
1113
1057
|
}
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
results
|
|
1119
|
-
};
|
|
1058
|
+
}
|
|
1059
|
+
async function attemptCommitNoVerify(message, onProgress) {
|
|
1060
|
+
debug("attemptCommitNoVerify:", message);
|
|
1061
|
+
return attemptCommit(message, ["--no-verify"], onProgress);
|
|
1120
1062
|
}
|
|
1121
1063
|
//#endregion
|
|
1122
1064
|
//#region src/services/grouping.ts
|
|
@@ -1200,8 +1142,36 @@ function buildGroupingUserPrompt(summary) {
|
|
|
1200
1142
|
summary
|
|
1201
1143
|
].join("\n");
|
|
1202
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
|
+
}
|
|
1203
1168
|
function parseGroupingResponse(content) {
|
|
1204
|
-
|
|
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);
|
|
1205
1175
|
const parsed = JSON.parse(jsonText);
|
|
1206
1176
|
if (!Array.isArray(parsed)) throw new Error("AI response was not a JSON array");
|
|
1207
1177
|
const rawGroups = [];
|
|
@@ -1235,27 +1205,17 @@ async function generateGroups(files, apiKey, model, timeout, provider, proxy) {
|
|
|
1235
1205
|
baseURLOverride: proxy
|
|
1236
1206
|
});
|
|
1237
1207
|
try {
|
|
1238
|
-
|
|
1239
|
-
messages: [{
|
|
1240
|
-
role: "system",
|
|
1241
|
-
content: systemPrompt
|
|
1242
|
-
}, {
|
|
1243
|
-
role: "user",
|
|
1244
|
-
content: userPrompt
|
|
1245
|
-
}],
|
|
1246
|
-
model: resolvedModel,
|
|
1247
|
-
temperature: .3,
|
|
1248
|
-
max_tokens: 2048
|
|
1249
|
-
});
|
|
1250
|
-
const rawContent = completion.choices[0]?.message?.content;
|
|
1251
|
-
const content = typeof rawContent === "string" ? rawContent.trim() : "";
|
|
1252
|
-
debug("generateGroups response: choices=%d, finishReason=%s, contentLen=%d", completion.choices.length, completion.choices[0]?.finish_reason ?? "(none)", content.length);
|
|
1253
|
-
debug("generateGroups raw content: %s", content.slice(0, 500) || "(empty)");
|
|
1254
|
-
if (!content) throw new Error("AI returned an empty grouping response");
|
|
1255
|
-
const rawGroups = parseGroupingResponse(content);
|
|
1208
|
+
let rawGroups = await callGroupingAI(client, resolvedModel, systemPrompt, userPrompt);
|
|
1256
1209
|
debug("generateGroups: parsed %d raw groups", rawGroups.length);
|
|
1257
|
-
|
|
1210
|
+
let validated = validateGroups(rawGroups, included);
|
|
1258
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
|
+
}
|
|
1259
1219
|
return {
|
|
1260
1220
|
groups: validated,
|
|
1261
1221
|
excluded
|
|
@@ -1265,6 +1225,33 @@ async function generateGroups(files, apiKey, model, timeout, provider, proxy) {
|
|
|
1265
1225
|
throw mapGroqError(error, provider ? formatProviderName(provider) : void 0);
|
|
1266
1226
|
}
|
|
1267
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
|
+
}
|
|
1268
1255
|
function validateGroups(groups, allFiles) {
|
|
1269
1256
|
const validPaths = new Set(allFiles.map((f) => f.path));
|
|
1270
1257
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -1291,7 +1278,52 @@ function validateGroups(groups, allFiles) {
|
|
|
1291
1278
|
files: ungrouped.map((f) => f.path)
|
|
1292
1279
|
});
|
|
1293
1280
|
}
|
|
1294
|
-
return validated;
|
|
1281
|
+
return validated;
|
|
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
|
+
}
|
|
1295
1327
|
}
|
|
1296
1328
|
//#endregion
|
|
1297
1329
|
//#region src/services/clipboard.ts
|
|
@@ -1374,8 +1406,10 @@ function tryCopy(cmd, args, content) {
|
|
|
1374
1406
|
//#endregion
|
|
1375
1407
|
//#region src/ui/check-failure-menu.ts
|
|
1376
1408
|
const MAX_TSC_DIAGNOSTICS = 3;
|
|
1409
|
+
const MAX_ESLINT_DIAGNOSTICS = 3;
|
|
1377
1410
|
const MAX_SUMMARY_LINE_LENGTH = 120;
|
|
1378
1411
|
const TSC_DIAGNOSTIC = /^(.+?\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs))\((\d+),(\d+)\):\s+error\s+(TS\d+):\s+(.+)$/;
|
|
1412
|
+
const ESLINT_ERROR_LINE = /^\s*(\d+):(\d+)\s+(error|warning)\s+(.+)\s{2,}(\S+)\s*$/;
|
|
1379
1413
|
function formatCheckFailureSummary(errors) {
|
|
1380
1414
|
if (errors.length === 0) return "No check error details were parsed. View full output for details.";
|
|
1381
1415
|
return errors.map((error) => formatCheckErrorSummary(error)).join("\n");
|
|
@@ -1385,6 +1419,10 @@ function formatCheckErrorSummary(error) {
|
|
|
1385
1419
|
const diagnostics = extractTscDiagnostics(error.raw || error.message);
|
|
1386
1420
|
if (diagnostics.length > 0) return formatTscSummary(diagnostics);
|
|
1387
1421
|
}
|
|
1422
|
+
if (error.tool === "eslint") {
|
|
1423
|
+
const diagnostics = extractEslintDiagnostics(error.raw || error.message);
|
|
1424
|
+
if (diagnostics.length > 0) return formatEslintSummary(diagnostics);
|
|
1425
|
+
}
|
|
1388
1426
|
const message = firstMeaningfulLine(error.message || error.raw);
|
|
1389
1427
|
return ` ${red("•")} [${error.tool}] ${truncate(message, MAX_SUMMARY_LINE_LENGTH)}`;
|
|
1390
1428
|
}
|
|
@@ -1408,6 +1446,36 @@ function formatTscSummary(diagnostics) {
|
|
|
1408
1446
|
if (hidden > 0) lines.push(dim(` +${hidden} more TypeScript error${hidden !== 1 ? "s" : ""}. View full output for details.`));
|
|
1409
1447
|
return lines.join("\n");
|
|
1410
1448
|
}
|
|
1449
|
+
function extractEslintDiagnostics(raw) {
|
|
1450
|
+
const diagnostics = [];
|
|
1451
|
+
const lines = raw.split("\n");
|
|
1452
|
+
let currentFile = "";
|
|
1453
|
+
for (const line of lines) {
|
|
1454
|
+
if (!/^\s/.test(line) && line.includes("/") && !ESLINT_ERROR_LINE.test(line)) {
|
|
1455
|
+
currentFile = line.trim();
|
|
1456
|
+
continue;
|
|
1457
|
+
}
|
|
1458
|
+
const match = ESLINT_ERROR_LINE.exec(line);
|
|
1459
|
+
if (match) diagnostics.push({
|
|
1460
|
+
file: currentFile || "unknown",
|
|
1461
|
+
line: match[1] ?? "",
|
|
1462
|
+
column: match[2] ?? "",
|
|
1463
|
+
severity: match[3] ?? "",
|
|
1464
|
+
message: (match[4] ?? "").trim(),
|
|
1465
|
+
rule: match[5] ?? ""
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1468
|
+
return diagnostics;
|
|
1469
|
+
}
|
|
1470
|
+
function formatEslintSummary(diagnostics) {
|
|
1471
|
+
const visible = diagnostics.slice(0, MAX_ESLINT_DIAGNOSTICS);
|
|
1472
|
+
const hidden = diagnostics.length - visible.length;
|
|
1473
|
+
const count = diagnostics.length;
|
|
1474
|
+
const noun = count === 1 ? "problem" : "problems";
|
|
1475
|
+
const lines = [` ${red("•")} [eslint] ${count} ESLint ${noun}`, ...visible.map((diagnostic) => `${diagnostic.file}:${diagnostic.line}:${diagnostic.column} ${diagnostic.severity} ${diagnostic.rule} — ${truncate(diagnostic.message, MAX_SUMMARY_LINE_LENGTH)}`)];
|
|
1476
|
+
if (hidden > 0) lines.push(dim(` +${hidden} more ESLint ${hidden === 1 ? "problem" : "problems"}. View full output for details.`));
|
|
1477
|
+
return lines.join("\n");
|
|
1478
|
+
}
|
|
1411
1479
|
function firstMeaningfulLine(message) {
|
|
1412
1480
|
return message.split("\n").map((l) => l.trim()).find((l) => l.length > 0 && !l.startsWith(">") && !l.startsWith("ELIFECYCLE")) ?? message;
|
|
1413
1481
|
}
|
|
@@ -1641,6 +1709,49 @@ async function showRecoveryMenu(errors, onRetry, onSkipHooks, onRestage, message
|
|
|
1641
1709
|
}
|
|
1642
1710
|
}
|
|
1643
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
|
|
1644
1755
|
//#region src/commands/auto-group.ts
|
|
1645
1756
|
async function runAutoGroupFlow(changedFiles, flags) {
|
|
1646
1757
|
const { included, excluded } = filterExcludedFiles(changedFiles);
|
|
@@ -1818,6 +1929,245 @@ function buildExcludedFilesMessage(files) {
|
|
|
1818
1929
|
return "chore: update generated files";
|
|
1819
1930
|
}
|
|
1820
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
|
|
1821
2171
|
//#region src/commands/commit-utils.ts
|
|
1822
2172
|
/** Shared recovery menu factory — avoids repeating the same callback set */
|
|
1823
2173
|
function makeRecoveryCallbacks(message) {
|
|
@@ -1873,6 +2223,199 @@ async function handleRetry() {
|
|
|
1873
2223
|
else process.exit(1);
|
|
1874
2224
|
}
|
|
1875
2225
|
//#endregion
|
|
2226
|
+
//#region src/commands/setup.ts
|
|
2227
|
+
/** Marker files for each tool. First match wins per tool. */
|
|
2228
|
+
const TOOL_MARKERS = {
|
|
2229
|
+
biome: ["biome.json", "biome.jsonc"],
|
|
2230
|
+
eslint: [
|
|
2231
|
+
"eslint.config.js",
|
|
2232
|
+
"eslint.config.mjs",
|
|
2233
|
+
"eslint.config.ts",
|
|
2234
|
+
"eslint.config.cjs",
|
|
2235
|
+
".eslintrc.js",
|
|
2236
|
+
".eslintrc.cjs",
|
|
2237
|
+
".eslintrc.json",
|
|
2238
|
+
".eslintrc.yml",
|
|
2239
|
+
".eslintrc.yaml",
|
|
2240
|
+
".eslintrc"
|
|
2241
|
+
],
|
|
2242
|
+
typescript: ["tsconfig.json"],
|
|
2243
|
+
vitest: [
|
|
2244
|
+
"vitest.config.js",
|
|
2245
|
+
"vitest.config.mts",
|
|
2246
|
+
"vitest.config.ts",
|
|
2247
|
+
"vitest.config.mjs"
|
|
2248
|
+
]
|
|
2249
|
+
};
|
|
2250
|
+
/** Indent for generated config — matches biome.json `indentStyle: "tab"`. */
|
|
2251
|
+
const TAB = " ";
|
|
2252
|
+
async function exists(path) {
|
|
2253
|
+
try {
|
|
2254
|
+
await access(path, constants.R_OK);
|
|
2255
|
+
return true;
|
|
2256
|
+
} catch {
|
|
2257
|
+
return false;
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
/**
|
|
2261
|
+
* Scan a directory for marker files that indicate which tools the project uses.
|
|
2262
|
+
* Returns a map of tool name to detected status. Order within each tool's list
|
|
2263
|
+
* is priority order (first match wins).
|
|
2264
|
+
*/
|
|
2265
|
+
async function detectTools(cwd) {
|
|
2266
|
+
const result = {
|
|
2267
|
+
biome: false,
|
|
2268
|
+
eslint: false,
|
|
2269
|
+
typescript: false,
|
|
2270
|
+
vitest: false
|
|
2271
|
+
};
|
|
2272
|
+
for (const [tool, files] of Object.entries(TOOL_MARKERS)) for (const file of files) if (await exists(join(cwd, file))) {
|
|
2273
|
+
result[tool] = true;
|
|
2274
|
+
debug("setup: detected %s via %s", tool, file);
|
|
2275
|
+
break;
|
|
2276
|
+
}
|
|
2277
|
+
debug("setup: detection result %o", result);
|
|
2278
|
+
return result;
|
|
2279
|
+
}
|
|
2280
|
+
/**
|
|
2281
|
+
* Build the string content of a .cmintrc file from a detection result.
|
|
2282
|
+
* Returns tabs-indented TS/JS object literal with trailing commas. Biome is
|
|
2283
|
+
* preferred when both biome and eslint are present — overlapping globs would
|
|
2284
|
+
* cause both tools to run on the same files, which is wasteful and noisy.
|
|
2285
|
+
*/
|
|
2286
|
+
function buildCmintrcContent(tools) {
|
|
2287
|
+
const entries = [];
|
|
2288
|
+
if (tools.biome || tools.eslint) {
|
|
2289
|
+
const cmd = tools.biome ? "biome check --write --no-errors-on-unmatched --error-on-warnings" : "eslint --fix";
|
|
2290
|
+
const ext = tools.biome ? "{js,ts,json}" : "{js,ts}";
|
|
2291
|
+
entries.push(`${TAB}"*.${ext}": "${cmd}",`);
|
|
2292
|
+
}
|
|
2293
|
+
const tsChecks = [];
|
|
2294
|
+
if (tools.typescript) tsChecks.push("tsc --noEmit");
|
|
2295
|
+
if (tools.vitest) tsChecks.push("vitest run --passWithNoTests");
|
|
2296
|
+
if (tsChecks.length > 0) {
|
|
2297
|
+
const body = tsChecks.map((c) => `"${c}"`).join(", ");
|
|
2298
|
+
const fn = tsChecks.length === 1 ? `() => ${body}` : `() => [${body}]`;
|
|
2299
|
+
entries.push(`${TAB}"*.ts": ${fn},`);
|
|
2300
|
+
}
|
|
2301
|
+
if (entries.length === 0) return `export default {\n};\n`;
|
|
2302
|
+
return `export default {\n${entries.join("\n")}\n};\n`;
|
|
2303
|
+
}
|
|
2304
|
+
/** Choose the file extension based on whether the project uses TypeScript. */
|
|
2305
|
+
function pickFileName(tools) {
|
|
2306
|
+
return tools.typescript ? ".cmintrc.ts" : ".cmintrc";
|
|
2307
|
+
}
|
|
2308
|
+
function formatDetection(tools) {
|
|
2309
|
+
return Object.entries(tools).map(([tool, found]) => ` ${found ? green("✓") : dim("✗")} ${tool}`).join("\n");
|
|
2310
|
+
}
|
|
2311
|
+
/**
|
|
2312
|
+
* Interactive setup for `.cmintrc`. Detects biome/eslint/typescript/vitest in
|
|
2313
|
+
* the given directory, previews the generated config, and writes the file
|
|
2314
|
+
* after confirmation. Refuses to overwrite without explicit consent. Defaults
|
|
2315
|
+
* to `process.cwd()` when called from the `cmint config` menu; the preflight
|
|
2316
|
+
* caller passes the repo root explicitly.
|
|
2317
|
+
*/
|
|
2318
|
+
async function setupCmintrcCommand(cwd = process.cwd()) {
|
|
2319
|
+
debug("setupCmintrcCommand: starting in %s", cwd);
|
|
2320
|
+
const tools = await detectTools(cwd);
|
|
2321
|
+
p.log.info(`Detected tools in ${bold(cwd)}:`);
|
|
2322
|
+
p.log.message(formatDetection(tools));
|
|
2323
|
+
if (!Object.values(tools).some(Boolean)) p.log.warn("No recognized tools found. Writing an empty config to fill in manually.");
|
|
2324
|
+
else if (tools.biome && tools.eslint) p.log.warn(yellow("Both biome and eslint detected — using biome (remove this line to switch)."));
|
|
2325
|
+
const fileName = pickFileName(tools);
|
|
2326
|
+
const filePath = join(cwd, fileName);
|
|
2327
|
+
if (await exists(filePath)) {
|
|
2328
|
+
const overwrite = await p.confirm({ message: `${fileName} already exists. Overwrite?` });
|
|
2329
|
+
if (p.isCancel(overwrite) || !overwrite) {
|
|
2330
|
+
p.log.info(dim("Cancelled — existing file left untouched."));
|
|
2331
|
+
return;
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
const content = buildCmintrcContent(tools);
|
|
2335
|
+
p.log.info(dim(`\nPreview of ${fileName}:`));
|
|
2336
|
+
p.log.message(dim(content));
|
|
2337
|
+
const confirm = await p.confirm({ message: `Write ${fileName}?` });
|
|
2338
|
+
if (p.isCancel(confirm) || !confirm) {
|
|
2339
|
+
p.log.info(dim("Cancelled."));
|
|
2340
|
+
return;
|
|
2341
|
+
}
|
|
2342
|
+
await writeFile(filePath, content, "utf-8");
|
|
2343
|
+
debug("setupCmintrcCommand: wrote %s", filePath);
|
|
2344
|
+
p.log.success(green(`Wrote ${fileName}`));
|
|
2345
|
+
}
|
|
2346
|
+
/** Project-local marker file that suppresses the preflight prompt forever. */
|
|
2347
|
+
const SKIP_SETUP_MARKER = ".cmint-skip-setup";
|
|
2348
|
+
/** True if at least one of biome/eslint/typescript/vitest is present. */
|
|
2349
|
+
function isAutoConfigurable(tools) {
|
|
2350
|
+
return Object.values(tools).some(Boolean);
|
|
2351
|
+
}
|
|
2352
|
+
/** True if the skip-setup marker exists in `cwd`. */
|
|
2353
|
+
async function hasSkipSetupMarker(cwd) {
|
|
2354
|
+
return exists(join(cwd, SKIP_SETUP_MARKER));
|
|
2355
|
+
}
|
|
2356
|
+
/** Write the skip-setup marker to `cwd`. The file is empty by design. */
|
|
2357
|
+
async function writeSkipSetupMarker(cwd) {
|
|
2358
|
+
const filePath = join(cwd, SKIP_SETUP_MARKER);
|
|
2359
|
+
await writeFile(filePath, "", "utf-8");
|
|
2360
|
+
debug("preflight: wrote skip-setup marker to %s", filePath);
|
|
2361
|
+
}
|
|
2362
|
+
/**
|
|
2363
|
+
* One-shot prompt run at the start of `cmint`. Skips silently if the user
|
|
2364
|
+
* already has a `.cmintrc` or has previously opted out (`.cmint-skip-setup`).
|
|
2365
|
+
* If the project is auto-configurable, asks the user whether to run setup
|
|
2366
|
+
* now. Choices: `yes` runs the standard setup flow; `no` proceeds without
|
|
2367
|
+
* setup and re-prompts next time; `never` writes a marker to suppress the
|
|
2368
|
+
* prompt for this project forever.
|
|
2369
|
+
*/
|
|
2370
|
+
async function runPreflightSetupPrompt(cwd) {
|
|
2371
|
+
debug("preflight: checking %s", cwd);
|
|
2372
|
+
if (await hasSkipSetupMarker(cwd)) {
|
|
2373
|
+
debug("preflight: skip-setup marker present, skipping prompt");
|
|
2374
|
+
return;
|
|
2375
|
+
}
|
|
2376
|
+
const existingConfig = await detectConfig(cwd);
|
|
2377
|
+
if (existingConfig) {
|
|
2378
|
+
debug("preflight: .cmintrc present at %s, skipping prompt", existingConfig);
|
|
2379
|
+
return;
|
|
2380
|
+
}
|
|
2381
|
+
if (!isAutoConfigurable(await detectTools(cwd))) {
|
|
2382
|
+
debug("preflight: project not auto-configurable, skipping prompt");
|
|
2383
|
+
return;
|
|
2384
|
+
}
|
|
2385
|
+
const choice = await p.select({
|
|
2386
|
+
message: "No .cmintrc found. Run setup to create one from detected tools?",
|
|
2387
|
+
options: [
|
|
2388
|
+
{
|
|
2389
|
+
label: "Yes, set up .cmintrc",
|
|
2390
|
+
value: "yes"
|
|
2391
|
+
},
|
|
2392
|
+
{
|
|
2393
|
+
label: "No, skip for now",
|
|
2394
|
+
value: "no"
|
|
2395
|
+
},
|
|
2396
|
+
{
|
|
2397
|
+
label: "No, don't ask again",
|
|
2398
|
+
value: "never"
|
|
2399
|
+
}
|
|
2400
|
+
]
|
|
2401
|
+
});
|
|
2402
|
+
if (p.isCancel(choice)) {
|
|
2403
|
+
debug("preflight: user cancelled prompt");
|
|
2404
|
+
return;
|
|
2405
|
+
}
|
|
2406
|
+
if (choice === "never") {
|
|
2407
|
+
await writeSkipSetupMarker(cwd);
|
|
2408
|
+
p.log.info(dim(`Won't ask again. Delete ${SKIP_SETUP_MARKER} to re-enable.`));
|
|
2409
|
+
return;
|
|
2410
|
+
}
|
|
2411
|
+
if (choice === "no") {
|
|
2412
|
+
p.log.info(dim("Skipping .cmintrc setup."));
|
|
2413
|
+
return;
|
|
2414
|
+
}
|
|
2415
|
+
debug("preflight: user chose yes, running setup");
|
|
2416
|
+
await setupCmintrcCommand(cwd);
|
|
2417
|
+
}
|
|
2418
|
+
//#endregion
|
|
1876
2419
|
//#region src/ui/staging-menu.ts
|
|
1877
2420
|
async function showStagingMenu(files, hasChecks) {
|
|
1878
2421
|
debug("showStagingMenu: %d files", files.length);
|
|
@@ -2035,7 +2578,8 @@ async function runPreCommitChecks(changedFiles, noCheck) {
|
|
|
2035
2578
|
});
|
|
2036
2579
|
if (menuResult === "cancelled") process.exit(1);
|
|
2037
2580
|
if (menuResult === "retried") {
|
|
2038
|
-
debug("Re-running checks after retry...");
|
|
2581
|
+
debug("Re-staging files and re-running checks after retry...");
|
|
2582
|
+
await stageAll();
|
|
2039
2583
|
const ckSpinner = spinner();
|
|
2040
2584
|
ckSpinner.start("Running checks...");
|
|
2041
2585
|
checkResults = await runAllChecks(checkRoot, stagedFileList, 6e4);
|
|
@@ -2058,6 +2602,8 @@ async function commitCommand(flags) {
|
|
|
2058
2602
|
debug("commitCommand called", { flags });
|
|
2059
2603
|
await assertGitRepo();
|
|
2060
2604
|
if (flags.retry) return handleRetry();
|
|
2605
|
+
const repoRoot = await getRepoRoot();
|
|
2606
|
+
await runPreflightSetupPrompt(repoRoot);
|
|
2061
2607
|
intro("🌿 commit-mint");
|
|
2062
2608
|
const status = await getStatusShort();
|
|
2063
2609
|
debug("Git status:", status || "(empty)");
|
|
@@ -2104,7 +2650,7 @@ async function commitCommand(flags) {
|
|
|
2104
2650
|
debug("All staged files are excluded:", diffResult.excludedFiles);
|
|
2105
2651
|
const message = buildExcludedFilesMessage(diffResult.excludedFiles);
|
|
2106
2652
|
log.info(diffResult.excludedFiles.map((f) => ` ${f}`).join("\n"));
|
|
2107
|
-
await saveCachedCommit(
|
|
2653
|
+
await saveCachedCommit(repoRoot, message);
|
|
2108
2654
|
s.start("Running pre-commit hooks...");
|
|
2109
2655
|
const result = await commitWithRecovery(message, s, await getHead());
|
|
2110
2656
|
if (result === "committed") {
|
|
@@ -2163,7 +2709,6 @@ async function commitCommand(flags) {
|
|
|
2163
2709
|
return;
|
|
2164
2710
|
}
|
|
2165
2711
|
message = reviewed;
|
|
2166
|
-
const repoRoot = await getRepoRoot();
|
|
2167
2712
|
await saveCachedCommit(repoRoot, message);
|
|
2168
2713
|
debug("Message cached for repo:", repoRoot);
|
|
2169
2714
|
s.start("Running pre-commit hooks...");
|
|
@@ -2347,13 +2892,20 @@ async function configCommand() {
|
|
|
2347
2892
|
p.note(buildConfigDisplay(config), "commit-mint config");
|
|
2348
2893
|
const action = await p.select({
|
|
2349
2894
|
message: "What would you like to do?",
|
|
2350
|
-
options: [
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2895
|
+
options: [
|
|
2896
|
+
{
|
|
2897
|
+
label: "Edit settings",
|
|
2898
|
+
value: "edit"
|
|
2899
|
+
},
|
|
2900
|
+
{
|
|
2901
|
+
label: "Setup .cmintrc",
|
|
2902
|
+
value: "setup"
|
|
2903
|
+
},
|
|
2904
|
+
{
|
|
2905
|
+
label: "Done",
|
|
2906
|
+
value: "done"
|
|
2907
|
+
}
|
|
2908
|
+
]
|
|
2357
2909
|
});
|
|
2358
2910
|
if (p.isCancel(action)) {
|
|
2359
2911
|
debug("configCommand: cancelled at main menu");
|
|
@@ -2365,10 +2917,49 @@ async function configCommand() {
|
|
|
2365
2917
|
p.outro("Config saved.");
|
|
2366
2918
|
return;
|
|
2367
2919
|
}
|
|
2920
|
+
if (action === "setup") {
|
|
2921
|
+
debug("configCommand: starting .cmintrc setup");
|
|
2922
|
+
await setupCmintrcCommand();
|
|
2923
|
+
continue;
|
|
2924
|
+
}
|
|
2368
2925
|
await editSettingsLoop(config);
|
|
2369
2926
|
}
|
|
2370
2927
|
}
|
|
2371
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
|
|
2372
2963
|
//#region src/cli.ts
|
|
2373
2964
|
const { version } = package_default;
|
|
2374
2965
|
cli({
|
|
@@ -2409,14 +3000,31 @@ cli({
|
|
|
2409
3000
|
description: "Skip user-defined pre-commit checks",
|
|
2410
3001
|
alias: "N",
|
|
2411
3002
|
default: false
|
|
3003
|
+
},
|
|
3004
|
+
agent: {
|
|
3005
|
+
type: Boolean,
|
|
3006
|
+
description: "AI agent mode: non-interactive auto-group with JSON output",
|
|
3007
|
+
default: false
|
|
2412
3008
|
}
|
|
2413
3009
|
},
|
|
2414
|
-
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 () => {
|
|
2415
3021
|
await configCommand();
|
|
2416
3022
|
})]
|
|
2417
3023
|
}, (argv) => {
|
|
3024
|
+
writeSessionHeader();
|
|
2418
3025
|
setDebug(argv.flags.debug);
|
|
2419
|
-
|
|
3026
|
+
if (argv.flags.agent) agentCommand(argv.flags);
|
|
3027
|
+
else commitCommand(argv.flags);
|
|
2420
3028
|
});
|
|
2421
3029
|
//#endregion
|
|
2422
3030
|
export {};
|