@kyubiware/commit-mint 0.1.0 → 0.1.5
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/dist/cli.mjs +478 -186
- package/dist/cli.mjs.map +1 -1
- package/package.json +65 -54
package/dist/cli.mjs
CHANGED
|
@@ -5,8 +5,8 @@ import { intro, isCancel, log, outro, spinner } from "@clack/prompts";
|
|
|
5
5
|
import { bold, cyan, dim, green, red, yellow } from "kolorist";
|
|
6
6
|
import Groq from "groq-sdk";
|
|
7
7
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
8
|
-
import { join } from "node:path";
|
|
9
8
|
import os from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
10
|
import ini from "ini";
|
|
11
11
|
import { execa } from "execa";
|
|
12
12
|
import { createHash } from "node:crypto";
|
|
@@ -25,7 +25,7 @@ var __exportAll = (all, no_symbols) => {
|
|
|
25
25
|
//#region package.json
|
|
26
26
|
var package_default = {
|
|
27
27
|
name: "@kyubiware/commit-mint",
|
|
28
|
-
version: "0.1.
|
|
28
|
+
version: "0.1.5",
|
|
29
29
|
description: "A commit tool that actually handles hook failures",
|
|
30
30
|
type: "module",
|
|
31
31
|
bin: { "cmint": "./dist/cli.mjs" },
|
|
@@ -33,14 +33,20 @@ var package_default = {
|
|
|
33
33
|
scripts: {
|
|
34
34
|
"build": "tsdown src/cli.ts --format esm --dts --clean",
|
|
35
35
|
"dev": "tsx src/cli.ts",
|
|
36
|
+
"dev:debug": "tsx src/cli.ts --debug",
|
|
36
37
|
"lint": "biome check .",
|
|
37
38
|
"lint:fix": "biome check --fix .",
|
|
38
39
|
"typecheck": "tsc --noEmit",
|
|
39
40
|
"test": "vitest run",
|
|
40
41
|
"test:coverage": "vitest run --coverage",
|
|
41
42
|
"test:watch": "vitest --watch",
|
|
42
|
-
"
|
|
43
|
+
"release:patch": "bash scripts/release.sh patch",
|
|
44
|
+
"release:minor": "bash scripts/release.sh minor",
|
|
45
|
+
"release:major": "bash scripts/release.sh major",
|
|
46
|
+
"prepublishOnly": "npm run build",
|
|
47
|
+
"prepare": "simple-git-hooks"
|
|
43
48
|
},
|
|
49
|
+
"simple-git-hooks": { "pre-commit": "npx lint-staged" },
|
|
44
50
|
keywords: [
|
|
45
51
|
"git",
|
|
46
52
|
"commit",
|
|
@@ -68,7 +74,10 @@ var package_default = {
|
|
|
68
74
|
},
|
|
69
75
|
devDependencies: {
|
|
70
76
|
"@biomejs/biome": "^2.0.0",
|
|
77
|
+
"@types/ini": "^4.1.1",
|
|
71
78
|
"@vitest/coverage-v8": "^3.2.4",
|
|
79
|
+
"lint-staged": "^17.0.5",
|
|
80
|
+
"simple-git-hooks": "^2.13.1",
|
|
72
81
|
"tsdown": "^0.22.0",
|
|
73
82
|
"tsx": "^4.22.2",
|
|
74
83
|
"typescript": "^5.9.2",
|
|
@@ -76,37 +85,90 @@ var package_default = {
|
|
|
76
85
|
}
|
|
77
86
|
};
|
|
78
87
|
//#endregion
|
|
88
|
+
//#region src/utils/debug.ts
|
|
89
|
+
let enabled = false;
|
|
90
|
+
function setDebug(value) {
|
|
91
|
+
enabled = value;
|
|
92
|
+
}
|
|
93
|
+
function debug(...args) {
|
|
94
|
+
if (!enabled) return;
|
|
95
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().slice(11, 23);
|
|
96
|
+
console.error(dim(`[debug ${timestamp}]`), ...args);
|
|
97
|
+
}
|
|
98
|
+
//#endregion
|
|
79
99
|
//#region src/services/ai.ts
|
|
100
|
+
const MAX_DIFF_CHARS = 2e4;
|
|
80
101
|
const CONVENTIONAL_COMMIT_REGEX = /^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.+\))?!?: .+$/;
|
|
102
|
+
function stripThinkTags(text) {
|
|
103
|
+
return text.replace(/<think[\s\S]*?<\/think>/gi, "").trim();
|
|
104
|
+
}
|
|
105
|
+
function deriveMessageFromReasoning(reasoning) {
|
|
106
|
+
const match = reasoning.match(/(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.+\))?!?: .+/i);
|
|
107
|
+
if (match) return match[0].trim();
|
|
108
|
+
const first = reasoning.split(/[.!?]/).find((s) => s.trim().length >= 10);
|
|
109
|
+
return first ? first.trim() : null;
|
|
110
|
+
}
|
|
111
|
+
function stripContextLines(diff) {
|
|
112
|
+
return diff.split("\n").filter((line) => !line.startsWith(" ")).join("\n");
|
|
113
|
+
}
|
|
81
114
|
function compressDiff(diff) {
|
|
82
|
-
if (diff.length <=
|
|
83
|
-
let result = diff
|
|
84
|
-
if (result.length <=
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
longest.diff = longest.parts.join("");
|
|
95
|
-
result = files.map((f) => f.diff).join("");
|
|
96
|
-
}
|
|
97
|
-
if (result.length <= 4e4) return result;
|
|
98
|
-
return `Summary of changes:\n${(result.match(/^diff --git a\/(.+) b\/(.+)$/gm) || []).map((f) => {
|
|
115
|
+
if (diff.length <= MAX_DIFF_CHARS) return diff;
|
|
116
|
+
let result = stripContextLines(diff);
|
|
117
|
+
if (result.length <= MAX_DIFF_CHARS) return result;
|
|
118
|
+
result = result.split(/(?=diff --git)/).filter(Boolean).map((fd) => {
|
|
119
|
+
return fd.split(/(?=\n@@)/).map((part, idx) => {
|
|
120
|
+
if (idx === 0) return part;
|
|
121
|
+
const lines = part.split("\n");
|
|
122
|
+
return [lines[0], ...lines.slice(1).filter((l) => l.startsWith("+") || l.startsWith("-")).slice(0, 10)].join("\n");
|
|
123
|
+
}).join("");
|
|
124
|
+
}).join("");
|
|
125
|
+
if (result.length <= MAX_DIFF_CHARS) return result;
|
|
126
|
+
return `Summary of changes:\n${(diff.match(/^diff --git a\/(.+) b\/(.+)$/gm) || []).map((f) => {
|
|
99
127
|
const match = f.match(/^diff --git a\/(.+) b\/(.+)$/);
|
|
100
128
|
return match && match[1] === match[2] ? `${match[1]} | changed` : "";
|
|
101
129
|
}).filter(Boolean).join("\n")}`;
|
|
102
130
|
}
|
|
131
|
+
function buildStatSummary(diff) {
|
|
132
|
+
const files = [];
|
|
133
|
+
let currentFile = "";
|
|
134
|
+
let adds = 0;
|
|
135
|
+
let dels = 0;
|
|
136
|
+
for (const line of diff.split("\n")) {
|
|
137
|
+
const match = line.match(/^diff --git a\/.+ b\/(.+)$/);
|
|
138
|
+
if (match) {
|
|
139
|
+
if (currentFile) files.push({
|
|
140
|
+
name: currentFile,
|
|
141
|
+
adds,
|
|
142
|
+
dels
|
|
143
|
+
});
|
|
144
|
+
currentFile = match[1];
|
|
145
|
+
adds = 0;
|
|
146
|
+
dels = 0;
|
|
147
|
+
} else if (line.startsWith("+") && !line.startsWith("+++")) adds++;
|
|
148
|
+
else if (line.startsWith("-") && !line.startsWith("---")) dels++;
|
|
149
|
+
}
|
|
150
|
+
if (currentFile) files.push({
|
|
151
|
+
name: currentFile,
|
|
152
|
+
adds,
|
|
153
|
+
dels
|
|
154
|
+
});
|
|
155
|
+
const totalAdds = files.reduce((s, f) => s + f.adds, 0);
|
|
156
|
+
const totalDels = files.reduce((s, f) => s + f.dels, 0);
|
|
157
|
+
const lines = files.map((f) => ` ${f.name} | +${f.adds} -${f.dels}`);
|
|
158
|
+
lines.push(` ${files.length} files changed, ${totalAdds} insertions(+), ${totalDels} deletions(-)`);
|
|
159
|
+
return lines.join("\n");
|
|
160
|
+
}
|
|
103
161
|
function buildSystemPrompt(type) {
|
|
104
162
|
let prompt = "You are a commit message generator. Follow the Conventional Commits specification.\nValid types: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test.\nFormat: type(scope): description\nUse imperative mood, lowercase, no trailing period.\nOutput ONLY the commit message, no markdown fences, no explanation.";
|
|
105
163
|
if (type && type.trim().length > 0) prompt += `\nYou MUST use type: ${type}`;
|
|
106
164
|
return prompt;
|
|
107
165
|
}
|
|
108
|
-
function buildUserPrompt(diff, hint) {
|
|
109
|
-
|
|
166
|
+
function buildUserPrompt(diff, hint, statSummary) {
|
|
167
|
+
const parts = [];
|
|
168
|
+
if (hint) parts.push(`Context: ${hint}`);
|
|
169
|
+
if (statSummary) parts.push(`Change summary:\n${statSummary}`);
|
|
170
|
+
parts.push(`Generate a conventional commit for:\n\n${diff}`);
|
|
171
|
+
return parts.join("\n\n");
|
|
110
172
|
}
|
|
111
173
|
function isValidConventionalCommit(message) {
|
|
112
174
|
return CONVENTIONAL_COMMIT_REGEX.test(message);
|
|
@@ -115,36 +177,87 @@ function enforceMaxLength(message, maxLength) {
|
|
|
115
177
|
if (!maxLength || message.length <= maxLength) return message;
|
|
116
178
|
return `${message.slice(0, maxLength - 3)}...`;
|
|
117
179
|
}
|
|
180
|
+
function extractContentText(content) {
|
|
181
|
+
if (content == null) return "";
|
|
182
|
+
if (typeof content === "string") return content.trim();
|
|
183
|
+
if (Array.isArray(content)) return content.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => stripThinkTags(part.text)).join("").trim();
|
|
184
|
+
return "";
|
|
185
|
+
}
|
|
118
186
|
async function generateCommitMessage(diff, options) {
|
|
187
|
+
debug("generateCommitMessage: model=%s, maxLength=%s, type=%s, hint=%s", options.model ?? "default", options.maxLength ?? "default", options.type ?? "none", options.hint ?? "none");
|
|
188
|
+
const timeoutMs = options.timeout ?? 6e4;
|
|
189
|
+
debug("Timeout: %d ms", timeoutMs);
|
|
119
190
|
const client = new Groq({
|
|
120
191
|
apiKey: options.apiKey,
|
|
121
|
-
timeout:
|
|
192
|
+
timeout: timeoutMs
|
|
122
193
|
});
|
|
123
194
|
const compressedDiff = compressDiff(diff);
|
|
195
|
+
const statSummary = buildStatSummary(diff);
|
|
124
196
|
const systemPrompt = buildSystemPrompt(options.type);
|
|
125
|
-
const userPrompt = buildUserPrompt(compressedDiff, options.hint);
|
|
197
|
+
const userPrompt = buildUserPrompt(compressedDiff, options.hint, statSummary);
|
|
198
|
+
debug("Diff: %d chars → compressed to %d chars", diff.length, compressedDiff.length);
|
|
199
|
+
debug("Stat summary:\n%s", statSummary);
|
|
200
|
+
debug("User prompt length: %d chars", userPrompt.length);
|
|
126
201
|
async function callAI(strictSystemPrompt) {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
202
|
+
const callStart = Date.now();
|
|
203
|
+
debug("callAI: %s — model=%s, promptLen=%d, systemLen=%d", !!strictSystemPrompt ? "RETRY (strict)" : "INITIAL", options.model ?? "openai/gpt-oss-20b", userPrompt.length, (strictSystemPrompt ?? systemPrompt).length);
|
|
204
|
+
try {
|
|
205
|
+
const isReasoningModel = /^(o[1-9]|.*gpt-oss.*|.*gpt-5.*)/i.test(options.model ?? "");
|
|
206
|
+
const completion = await client.chat.completions.create({
|
|
207
|
+
messages: [{
|
|
208
|
+
role: "system",
|
|
209
|
+
content: strictSystemPrompt ?? systemPrompt
|
|
210
|
+
}, {
|
|
211
|
+
role: "user",
|
|
212
|
+
content: userPrompt
|
|
213
|
+
}],
|
|
214
|
+
model: options.model ?? "openai/gpt-oss-20b",
|
|
215
|
+
temperature: .3,
|
|
216
|
+
...isReasoningModel ? { max_completion_tokens: 1024 } : { max_tokens: 1024 },
|
|
217
|
+
reasoning_format: "parsed"
|
|
218
|
+
});
|
|
219
|
+
const elapsed = Date.now() - callStart;
|
|
220
|
+
const rawContent = completion.choices[0]?.message?.content;
|
|
221
|
+
const content = extractContentText(typeof rawContent === "string" ? stripThinkTags(rawContent) : rawContent);
|
|
222
|
+
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);
|
|
223
|
+
debug("callAI raw content: %s", content.slice(0, 300) || "(empty)");
|
|
224
|
+
if (!content) {
|
|
225
|
+
const reasoning = completion.choices[0]?.message?.reasoning;
|
|
226
|
+
debug("callAI: content empty, attempting reasoning fallback (reasoningLen=%d)", reasoning?.length ?? 0);
|
|
227
|
+
if (reasoning) {
|
|
228
|
+
const derived = deriveMessageFromReasoning(reasoning);
|
|
229
|
+
if (derived) {
|
|
230
|
+
debug("callAI: derived message from reasoning: %s", derived.slice(0, 100));
|
|
231
|
+
return stripThinkTags(derived);
|
|
232
|
+
}
|
|
233
|
+
debug("callAI: could not derive message from reasoning");
|
|
234
|
+
}
|
|
235
|
+
throw new Error("AI returned an empty commit message");
|
|
236
|
+
}
|
|
237
|
+
return content;
|
|
238
|
+
} catch (error) {
|
|
239
|
+
debug("callAI FAILED after %d ms: %s", Date.now() - callStart, error instanceof Error ? `${error.name}: ${error.message}` : String(error));
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
139
242
|
}
|
|
140
243
|
try {
|
|
244
|
+
const totalStart = Date.now();
|
|
141
245
|
let message = await callAI();
|
|
246
|
+
debug("Validation: message=%s, isValid=%s", message.slice(0, 100), isValidConventionalCommit(message));
|
|
142
247
|
if (!isValidConventionalCommit(message)) {
|
|
248
|
+
debug("Initial message failed conventional commit validation, retrying with strict prompt (elapsed: %d ms)", Date.now() - totalStart);
|
|
143
249
|
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.");
|
|
144
|
-
|
|
250
|
+
debug("Retry validation: message=%s, isValid=%s", retryMessage.slice(0, 100), isValidConventionalCommit(retryMessage));
|
|
251
|
+
if (isValidConventionalCommit(retryMessage)) {
|
|
252
|
+
debug("Retry produced valid conventional commit");
|
|
253
|
+
message = retryMessage;
|
|
254
|
+
} else debug("Retry also failed validation, using original message");
|
|
145
255
|
}
|
|
146
|
-
|
|
256
|
+
const result = enforceMaxLength(message, options.maxLength);
|
|
257
|
+
debug("Final message (%d ms total): %s", Date.now() - totalStart, result);
|
|
258
|
+
return result;
|
|
147
259
|
} catch (error) {
|
|
260
|
+
debug("AI error: %s", error instanceof Error ? error.message : String(error));
|
|
148
261
|
if (error instanceof Groq.AuthenticationError) throw new Error("Invalid GROQ_API_KEY. Run: cmint config set GROQ_API_KEY=<key>");
|
|
149
262
|
if (error instanceof Groq.RateLimitError) throw new Error("Rate limited by Groq. Please wait and try again.");
|
|
150
263
|
if (error instanceof Groq.APIConnectionTimeoutError) throw new Error("Request timed out. Check your network or try a smaller diff.");
|
|
@@ -163,14 +276,18 @@ const defaults = {
|
|
|
163
276
|
timeout: "10000"
|
|
164
277
|
};
|
|
165
278
|
async function readConfig() {
|
|
279
|
+
debug("readConfig: loading from %s", CONFIG_PATH);
|
|
166
280
|
try {
|
|
167
281
|
const raw = await readFile(CONFIG_PATH, "utf8");
|
|
168
282
|
const parsed = ini.parse(raw);
|
|
169
|
-
|
|
283
|
+
const merged = {
|
|
170
284
|
...defaults,
|
|
171
285
|
...parsed
|
|
172
286
|
};
|
|
287
|
+
debug("readConfig: loaded keys: %s", Object.keys(merged).join(", "));
|
|
288
|
+
return merged;
|
|
173
289
|
} catch {
|
|
290
|
+
debug("readConfig: no config file, using defaults");
|
|
174
291
|
return { ...defaults };
|
|
175
292
|
}
|
|
176
293
|
}
|
|
@@ -187,9 +304,16 @@ async function setConfigValue(key, value) {
|
|
|
187
304
|
}
|
|
188
305
|
async function getApiKey() {
|
|
189
306
|
const envKey = process.env.GROQ_API_KEY;
|
|
190
|
-
if (envKey)
|
|
307
|
+
if (envKey) {
|
|
308
|
+
debug("getApiKey: found in env");
|
|
309
|
+
return envKey;
|
|
310
|
+
}
|
|
191
311
|
const config = await readConfig();
|
|
192
|
-
if (config.GROQ_API_KEY)
|
|
312
|
+
if (config.GROQ_API_KEY) {
|
|
313
|
+
debug("getApiKey: found in config");
|
|
314
|
+
return config.GROQ_API_KEY;
|
|
315
|
+
}
|
|
316
|
+
debug("getApiKey: not found");
|
|
193
317
|
throw new Error("Please set your Groq API key via `cmint config set GROQ_API_KEY=<your token>`");
|
|
194
318
|
}
|
|
195
319
|
//#endregion
|
|
@@ -199,6 +323,7 @@ var git_exports = /* @__PURE__ */ __exportAll({
|
|
|
199
323
|
assertGitRepo: () => assertGitRepo,
|
|
200
324
|
attemptCommit: () => attemptCommit,
|
|
201
325
|
attemptCommitNoVerify: () => attemptCommitNoVerify,
|
|
326
|
+
getDefaultExcludes: () => getDefaultExcludes,
|
|
202
327
|
getHead: () => getHead,
|
|
203
328
|
getRepoRoot: () => getRepoRoot,
|
|
204
329
|
getStagedDiff: () => getStagedDiff,
|
|
@@ -207,49 +332,70 @@ var git_exports = /* @__PURE__ */ __exportAll({
|
|
|
207
332
|
});
|
|
208
333
|
var KnownError = class extends Error {};
|
|
209
334
|
async function assertGitRepo() {
|
|
335
|
+
debug("assertGitRepo");
|
|
210
336
|
const { failed } = await execa("git", ["rev-parse", "--show-toplevel"], { reject: false });
|
|
211
337
|
if (failed) throw new KnownError("The current directory must be a Git repository!");
|
|
212
338
|
}
|
|
213
339
|
async function getRepoRoot() {
|
|
214
340
|
const { stdout } = await execa("git", ["rev-parse", "--show-toplevel"]);
|
|
341
|
+
debug("getRepoRoot:", stdout.trim());
|
|
215
342
|
return stdout.trim();
|
|
216
343
|
}
|
|
344
|
+
const DEFAULT_EXCLUDES = [
|
|
345
|
+
"package-lock.json",
|
|
346
|
+
"node_modules/**",
|
|
347
|
+
"dist/**",
|
|
348
|
+
"build/**",
|
|
349
|
+
".next/**",
|
|
350
|
+
"coverage/**",
|
|
351
|
+
"*.log",
|
|
352
|
+
"*.min.js",
|
|
353
|
+
"*.min.css",
|
|
354
|
+
"*.lock",
|
|
355
|
+
".DS_Store"
|
|
356
|
+
];
|
|
357
|
+
function getDefaultExcludes() {
|
|
358
|
+
return [...DEFAULT_EXCLUDES];
|
|
359
|
+
}
|
|
217
360
|
async function getStagedDiff(exclude) {
|
|
218
361
|
const excludeArgs = (exclude ?? []).map((e) => `:(exclude)${e}`);
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
"
|
|
222
|
-
"
|
|
223
|
-
"
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
"
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
"*.lock",
|
|
230
|
-
".DS_Store"
|
|
231
|
-
].map((e) => `:(exclude)${e}`);
|
|
362
|
+
const defaultExcludeArgs = DEFAULT_EXCLUDES.map((e) => `:(exclude)${e}`);
|
|
363
|
+
const { stdout: allFiles } = await execa("git", [
|
|
364
|
+
"diff",
|
|
365
|
+
"--cached",
|
|
366
|
+
"--name-only"
|
|
367
|
+
]);
|
|
368
|
+
if (!allFiles) {
|
|
369
|
+
debug("getStagedDiff: no staged files");
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
232
372
|
const { stdout: files } = await execa("git", [
|
|
233
373
|
"diff",
|
|
234
374
|
"--cached",
|
|
235
375
|
"--name-only",
|
|
236
|
-
...
|
|
376
|
+
...defaultExcludeArgs,
|
|
237
377
|
...excludeArgs
|
|
238
378
|
]);
|
|
239
|
-
if (!files)
|
|
379
|
+
if (!files) {
|
|
380
|
+
const excludedFiles = allFiles.split("\n").filter(Boolean);
|
|
381
|
+
debug("getStagedDiff: all files excluded:", excludedFiles);
|
|
382
|
+
return { excludedFiles };
|
|
383
|
+
}
|
|
240
384
|
const { stdout: diff } = await execa("git", [
|
|
241
385
|
"diff",
|
|
242
386
|
"--cached",
|
|
243
387
|
"--diff-algorithm=minimal",
|
|
244
|
-
...
|
|
388
|
+
...defaultExcludeArgs,
|
|
245
389
|
...excludeArgs
|
|
246
390
|
]);
|
|
391
|
+
debug("getStagedDiff:", files.split("\n").filter(Boolean).length, "files,", diff.length, "chars");
|
|
247
392
|
return {
|
|
248
393
|
files: files.split("\n").filter(Boolean),
|
|
249
394
|
diff
|
|
250
395
|
};
|
|
251
396
|
}
|
|
252
397
|
async function stageAll() {
|
|
398
|
+
debug("stageAll: git add -A");
|
|
253
399
|
await execa("git", ["add", "-A"]);
|
|
254
400
|
}
|
|
255
401
|
async function getHead() {
|
|
@@ -261,24 +407,36 @@ async function getStatusShort() {
|
|
|
261
407
|
return stdout.trim();
|
|
262
408
|
}
|
|
263
409
|
async function attemptCommit(message, extraArgs = []) {
|
|
410
|
+
debug("attemptCommit:", message, extraArgs.length ? extraArgs : "(no extra args)");
|
|
264
411
|
try {
|
|
265
|
-
|
|
412
|
+
const subprocess = execa("git", [
|
|
266
413
|
"commit",
|
|
267
414
|
"-m",
|
|
268
415
|
message,
|
|
269
416
|
...extraArgs
|
|
270
417
|
]);
|
|
271
|
-
|
|
418
|
+
const stderrChunks = [];
|
|
419
|
+
subprocess.stderr?.on("data", (chunk) => {
|
|
420
|
+
stderrChunks.push(chunk.toString());
|
|
421
|
+
});
|
|
422
|
+
await subprocess;
|
|
423
|
+
debug("attemptCommit: success");
|
|
424
|
+
return {
|
|
425
|
+
ok: true,
|
|
426
|
+
stderr: stderrChunks.join("")
|
|
427
|
+
};
|
|
272
428
|
} catch (error) {
|
|
273
429
|
const e = error;
|
|
430
|
+
debug("attemptCommit: failed —", e.message?.slice(0, 200));
|
|
274
431
|
return {
|
|
275
432
|
ok: false,
|
|
276
433
|
error: e.message,
|
|
277
|
-
stderr: e.stderr
|
|
434
|
+
stderr: typeof e.stderr === "string" ? e.stderr : ""
|
|
278
435
|
};
|
|
279
436
|
}
|
|
280
437
|
}
|
|
281
438
|
async function attemptCommitNoVerify(message) {
|
|
439
|
+
debug("attemptCommitNoVerify:", message);
|
|
282
440
|
return attemptCommit(message, ["--no-verify"]);
|
|
283
441
|
}
|
|
284
442
|
//#endregion
|
|
@@ -289,25 +447,27 @@ async function attemptCommitNoVerify(message) {
|
|
|
289
447
|
*/
|
|
290
448
|
function parseHookErrors(stderr) {
|
|
291
449
|
if (!stderr) return [];
|
|
450
|
+
debug("parseHookErrors: stderr length=%d", stderr.length);
|
|
292
451
|
const errors = [];
|
|
293
|
-
stderr.split("\n");
|
|
294
452
|
if (stderr.includes("lint-staged") || stderr.includes("[FAILED]")) errors.push(...parseLintStagedErrors(stderr));
|
|
295
453
|
if (stderr.includes("biome") || stderr.includes("Biome")) errors.push(...parseBiomeErrors(stderr));
|
|
296
454
|
if (stderr.includes("error TS") || stderr.includes("tsc")) errors.push(...parseTscErrors(stderr));
|
|
297
455
|
if (stderr.includes("vitest") || stderr.includes("jest") || stderr.includes("FAIL") || stderr.includes("test failed")) errors.push(...parseTestErrors(stderr));
|
|
298
456
|
if (stderr.includes("eslint") || stderr.includes("ESLint")) errors.push(...parseEslintErrors(stderr));
|
|
299
|
-
if (errors.length === 0)
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
457
|
+
if (errors.length === 0) {
|
|
458
|
+
debug("parseHookErrors: no patterns matched, using raw fallback");
|
|
459
|
+
errors.push({
|
|
460
|
+
tool: "git hooks",
|
|
461
|
+
message: stderr.trim(),
|
|
462
|
+
raw: stderr
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
debug("parseHookErrors: found %d errors", errors.length);
|
|
304
466
|
return errors;
|
|
305
467
|
}
|
|
306
468
|
function parseLintStagedErrors(output) {
|
|
307
469
|
const errors = [];
|
|
308
|
-
const
|
|
309
|
-
let match;
|
|
310
|
-
while ((match = taskPattern.exec(output)) !== null) {
|
|
470
|
+
for (const match of output.matchAll(/\[FAILED\]\s+(.+?)\s+\[FAILED\]/g)) {
|
|
311
471
|
const task = match[1].trim();
|
|
312
472
|
errors.push({
|
|
313
473
|
tool: "lint-staged",
|
|
@@ -319,9 +479,7 @@ function parseLintStagedErrors(output) {
|
|
|
319
479
|
}
|
|
320
480
|
function parseBiomeErrors(output) {
|
|
321
481
|
const errors = [];
|
|
322
|
-
const
|
|
323
|
-
let match;
|
|
324
|
-
while ((match = biomePattern.exec(output)) !== null) errors.push({
|
|
482
|
+
for (const match of output.matchAll(/^(.+?):(\d+):(\d+)\s+(.+)$/gm)) errors.push({
|
|
325
483
|
tool: "biome",
|
|
326
484
|
message: `${match[1]}:${match[2]}:${match[3]} — ${match[4]}`,
|
|
327
485
|
raw: match[0]
|
|
@@ -335,9 +493,7 @@ function parseBiomeErrors(output) {
|
|
|
335
493
|
}
|
|
336
494
|
function parseTscErrors(output) {
|
|
337
495
|
const errors = [];
|
|
338
|
-
const
|
|
339
|
-
let match;
|
|
340
|
-
while ((match = tscPattern.exec(output)) !== null) errors.push({
|
|
496
|
+
for (const match of output.matchAll(/^(.+?)\((\d+),(\d+)\):\s+error\s+(TS\d+):\s+(.+)$/gm)) errors.push({
|
|
341
497
|
tool: "tsc",
|
|
342
498
|
message: `${match[1]}:${match[2]}:${match[3]} — ${match[4]}: ${match[5]}`,
|
|
343
499
|
raw: match[0]
|
|
@@ -361,116 +517,158 @@ function parseTestErrors(output) {
|
|
|
361
517
|
}
|
|
362
518
|
function parseEslintErrors(output) {
|
|
363
519
|
const errors = [];
|
|
364
|
-
const
|
|
365
|
-
let match;
|
|
366
|
-
while ((match = eslintPattern.exec(output)) !== null) errors.push({
|
|
520
|
+
for (const match of output.matchAll(/^\s*\d+:(\d+)\s+(error|warning)\s+(.+?)\s+(.+?)$/gm)) errors.push({
|
|
367
521
|
tool: "eslint",
|
|
368
522
|
message: `${match[2]}: ${match[3]} (${match[4]})`,
|
|
369
523
|
raw: match[0]
|
|
370
524
|
});
|
|
371
525
|
return errors;
|
|
372
526
|
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
527
|
+
/**
|
|
528
|
+
* Parse lint-staged/hook stderr output to discover which tools ran
|
|
529
|
+
* and whether they succeeded. Used for clean post-commit summary.
|
|
530
|
+
*/
|
|
531
|
+
function parseToolChecks(stderr) {
|
|
532
|
+
if (!stderr) return [];
|
|
533
|
+
const checks = [];
|
|
534
|
+
for (const match of stderr.matchAll(/\[(COMPLETED|FAILED)\]\s+(.+)/g)) {
|
|
535
|
+
const status = match[1];
|
|
536
|
+
const command = match[2].trim();
|
|
537
|
+
if (isLintStagedMeta(command)) continue;
|
|
538
|
+
const tool = extractToolName(command);
|
|
539
|
+
if (!tool) continue;
|
|
540
|
+
checks.push({
|
|
541
|
+
tool,
|
|
542
|
+
ok: status === "COMPLETED"
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
const seen = /* @__PURE__ */ new Map();
|
|
546
|
+
for (const c of checks) seen.set(c.tool, c);
|
|
547
|
+
return [...seen.values()];
|
|
548
|
+
}
|
|
549
|
+
/** Heuristic: skip lint-staged internal metadata lines */
|
|
550
|
+
function isLintStagedMeta(command) {
|
|
551
|
+
if (/[*{}[\]]/.test(command)) return true;
|
|
552
|
+
if (/\s[-–—]\s(\d+\s)?files?$/.test(command)) return true;
|
|
553
|
+
if (/\s[-–—]\sno\s files$/.test(command)) return true;
|
|
554
|
+
if (/^(Running tasks|Applying modifications|Cleaning up|Backing up|Backed up|Updating Git)/.test(command)) return true;
|
|
555
|
+
if (/\.{3}$/.test(command)) return true;
|
|
556
|
+
return false;
|
|
557
|
+
}
|
|
558
|
+
/** Extract a display-friendly tool name from a lint-staged command */
|
|
559
|
+
function extractToolName(command) {
|
|
560
|
+
const tokens = command.split(/\s+/);
|
|
561
|
+
const first = tokens[0];
|
|
562
|
+
if ([
|
|
563
|
+
"npm",
|
|
564
|
+
"yarn",
|
|
565
|
+
"pnpm",
|
|
566
|
+
"bun"
|
|
567
|
+
].includes(first)) {
|
|
568
|
+
const script = tokens[tokens[1] === "run" ? 2 : 1];
|
|
569
|
+
if (!script) return null;
|
|
570
|
+
return {
|
|
571
|
+
typecheck: "tsc",
|
|
572
|
+
lint: "eslint",
|
|
573
|
+
format: "prettier"
|
|
574
|
+
}[script] ?? script;
|
|
575
|
+
}
|
|
576
|
+
if (first === "npx") return tokens[1] ?? null;
|
|
577
|
+
return first;
|
|
376
578
|
}
|
|
377
579
|
//#endregion
|
|
378
580
|
//#region src/services/clipboard.ts
|
|
379
581
|
async function copyToClipboard(content) {
|
|
380
|
-
for (const [cmd,
|
|
381
|
-
["wl-copy"],
|
|
382
|
-
[
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
"clipboard"
|
|
386
|
-
],
|
|
387
|
-
[
|
|
388
|
-
"xsel",
|
|
389
|
-
"--clipboard",
|
|
390
|
-
"--input"
|
|
391
|
-
],
|
|
392
|
-
["pbcopy"]
|
|
582
|
+
for (const [cmd, args] of [
|
|
583
|
+
["wl-copy", []],
|
|
584
|
+
["xclip", ["-selection", "clipboard"]],
|
|
585
|
+
["xsel", ["--clipboard", "--input"]],
|
|
586
|
+
["pbcopy", []]
|
|
393
587
|
]) try {
|
|
394
|
-
|
|
395
|
-
if (!stdout) continue;
|
|
396
|
-
await execa(cmd, args.length > 0 ? args : [], { input: content });
|
|
588
|
+
await execa(cmd, args, { input: content });
|
|
397
589
|
return true;
|
|
398
|
-
} catch {
|
|
399
|
-
continue;
|
|
400
|
-
}
|
|
590
|
+
} catch {}
|
|
401
591
|
return false;
|
|
402
592
|
}
|
|
403
593
|
//#endregion
|
|
404
594
|
//#region src/ui/menu.ts
|
|
405
|
-
async function showRecoveryMenu(errors, onRetry, onSkipHooks, onRestage, message) {
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
595
|
+
async function showRecoveryMenu(errors, onRetry, onSkipHooks, onRestage, message, rawStderr) {
|
|
596
|
+
debug("showRecoveryMenu: %d errors", errors.length);
|
|
597
|
+
while (true) {
|
|
598
|
+
p.note(errors.map((e) => ` ${red("•")} [${e.tool}] ${e.message}`).join("\n"), red(bold("Pre-commit hook failed")));
|
|
599
|
+
const choice = await p.select({
|
|
600
|
+
message: "What do you want to do?",
|
|
601
|
+
options: [
|
|
602
|
+
{
|
|
603
|
+
label: "Copy error report to clipboard",
|
|
604
|
+
value: "clipboard",
|
|
605
|
+
hint: "Paste into another terminal for an AI agent"
|
|
606
|
+
},
|
|
607
|
+
{
|
|
608
|
+
label: "Skip hooks and commit (--no-verify)",
|
|
609
|
+
value: "skip",
|
|
610
|
+
hint: "Commit anyway, fix later"
|
|
611
|
+
},
|
|
612
|
+
{
|
|
613
|
+
label: "Re-stage files and retry",
|
|
614
|
+
value: "restage",
|
|
615
|
+
hint: "Pick up fixes from another terminal"
|
|
616
|
+
},
|
|
617
|
+
{
|
|
618
|
+
label: "Edit commit message",
|
|
619
|
+
value: "edit",
|
|
620
|
+
hint: "Modify the message before retrying"
|
|
621
|
+
},
|
|
622
|
+
{
|
|
623
|
+
label: "Cancel",
|
|
624
|
+
value: "cancel"
|
|
625
|
+
}
|
|
626
|
+
]
|
|
627
|
+
});
|
|
628
|
+
if (p.isCancel(choice)) {
|
|
629
|
+
debug("showRecoveryMenu: user cancelled");
|
|
630
|
+
p.outro(yellow("Cancelled. Message cached for --retry."));
|
|
631
|
+
process.exit(1);
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
debug("showRecoveryMenu: user chose %s", choice);
|
|
635
|
+
switch (choice) {
|
|
636
|
+
case "clipboard":
|
|
637
|
+
if (await copyToClipboard(rawStderr)) p.log.step(green("Errors copied"));
|
|
638
|
+
else p.log.warn(red("No clipboard tool found. Install xclip, wl-copy, or xsel."));
|
|
639
|
+
continue;
|
|
640
|
+
case "skip":
|
|
641
|
+
p.log.info(yellow("Committing with --no-verify..."));
|
|
642
|
+
if (await onSkipHooks(message)) p.outro(green("Committed (hooks skipped)."));
|
|
643
|
+
else p.outro(red("Commit failed even with --no-verify."));
|
|
644
|
+
return;
|
|
645
|
+
case "restage":
|
|
646
|
+
p.log.info(cyan("Re-staging and retrying..."));
|
|
647
|
+
if (await onRestage()) {
|
|
648
|
+
p.outro(green("Committed successfully."));
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
continue;
|
|
652
|
+
case "edit": {
|
|
653
|
+
const edited = await p.text({
|
|
654
|
+
message: "Edit commit message:",
|
|
655
|
+
initialValue: message,
|
|
656
|
+
validate: (v) => v.trim() ? void 0 : "Message cannot be empty"
|
|
657
|
+
});
|
|
658
|
+
if (p.isCancel(edited)) {
|
|
659
|
+
p.outro(yellow("Cancelled. Message cached for --retry."));
|
|
660
|
+
process.exit(1);
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
if (await onRetry()) p.outro(green("Committed successfully."));
|
|
664
|
+
else p.outro(red("Commit failed again."));
|
|
665
|
+
return;
|
|
433
666
|
}
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
if (p.isCancel(choice)) {
|
|
437
|
-
p.outro(yellow("Cancelled. Message cached for --retry."));
|
|
438
|
-
process.exit(1);
|
|
439
|
-
}
|
|
440
|
-
switch (choice) {
|
|
441
|
-
case "clipboard":
|
|
442
|
-
if (await copyToClipboard(formatErrorReport(errors))) p.outro(green("Error report copied to clipboard."));
|
|
443
|
-
else p.outro(red("No clipboard tool found. Install xclip, wl-copy, or xsel."));
|
|
444
|
-
p.log.info(dim("Fix the errors, then run: cmint --retry"));
|
|
445
|
-
process.exit(0);
|
|
446
|
-
break;
|
|
447
|
-
case "skip":
|
|
448
|
-
p.log.info(yellow("Committing with --no-verify..."));
|
|
449
|
-
if (await onSkipHooks(message)) p.outro(green("Committed (hooks skipped)."));
|
|
450
|
-
else p.outro(red("Commit failed even with --no-verify."));
|
|
451
|
-
break;
|
|
452
|
-
case "restage":
|
|
453
|
-
p.log.info(cyan("Re-staging and retrying..."));
|
|
454
|
-
if (await onRestage()) p.outro(green("Committed successfully."));
|
|
455
|
-
else await showRecoveryMenu(errors, onRetry, onSkipHooks, onRestage, message);
|
|
456
|
-
break;
|
|
457
|
-
case "edit": {
|
|
458
|
-
const edited = await p.text({
|
|
459
|
-
message: "Edit commit message:",
|
|
460
|
-
initialValue: message,
|
|
461
|
-
validate: (v) => v.trim() ? void 0 : "Message cannot be empty"
|
|
462
|
-
});
|
|
463
|
-
if (p.isCancel(edited)) {
|
|
464
|
-
p.outro(yellow("Cancelled. Message cached for --retry."));
|
|
667
|
+
case "cancel":
|
|
668
|
+
p.outro(dim("Message cached for --retry."));
|
|
465
669
|
process.exit(1);
|
|
466
|
-
|
|
467
|
-
if (await onRetry()) p.outro(green("Committed successfully."));
|
|
468
|
-
else p.outro(red("Commit failed again."));
|
|
469
|
-
break;
|
|
670
|
+
return;
|
|
470
671
|
}
|
|
471
|
-
case "cancel":
|
|
472
|
-
p.outro(dim("Message cached for --retry."));
|
|
473
|
-
process.exit(1);
|
|
474
672
|
}
|
|
475
673
|
}
|
|
476
674
|
//#endregion
|
|
@@ -489,41 +687,67 @@ async function saveCachedCommit(repoPath, message) {
|
|
|
489
687
|
timestamp: Date.now(),
|
|
490
688
|
repoPath
|
|
491
689
|
};
|
|
492
|
-
|
|
690
|
+
const path = cachePath(repoPath);
|
|
691
|
+
debug("saveCachedCommit: saving to %s", path);
|
|
692
|
+
await writeFile(path, JSON.stringify(data, null, 2), "utf8");
|
|
493
693
|
}
|
|
494
694
|
async function loadCachedCommit(repoPath) {
|
|
695
|
+
const path = cachePath(repoPath);
|
|
696
|
+
debug("loadCachedCommit: loading from %s", path);
|
|
495
697
|
try {
|
|
496
|
-
const raw = await readFile(
|
|
497
|
-
|
|
698
|
+
const raw = await readFile(path, "utf8");
|
|
699
|
+
const data = JSON.parse(raw);
|
|
700
|
+
debug("loadCachedCommit: found message from %s", new Date(data.timestamp).toISOString());
|
|
701
|
+
return data;
|
|
498
702
|
} catch {
|
|
703
|
+
debug("loadCachedCommit: no cached commit found");
|
|
499
704
|
return null;
|
|
500
705
|
}
|
|
501
706
|
}
|
|
502
707
|
//#endregion
|
|
503
708
|
//#region src/commands/commit.ts
|
|
504
709
|
async function commitCommand(flags) {
|
|
710
|
+
debug("commitCommand called", { flags });
|
|
505
711
|
await assertGitRepo();
|
|
506
712
|
if (flags.retry) {
|
|
713
|
+
debug("Entering retry mode");
|
|
507
714
|
const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
|
|
508
|
-
const
|
|
715
|
+
const repoRoot = await getRepoRoot();
|
|
716
|
+
debug("Repo root:", repoRoot);
|
|
717
|
+
const cached = await loadCachedCommit(repoRoot);
|
|
509
718
|
if (!cached) {
|
|
719
|
+
debug("No cached commit found");
|
|
510
720
|
outro(red("No cached commit message found. Run cmint without --retry first."));
|
|
511
721
|
process.exit(1);
|
|
512
722
|
}
|
|
723
|
+
debug("Loaded cached message:", cached.message);
|
|
513
724
|
intro("commit-mint — retry");
|
|
514
725
|
const s = spinner();
|
|
515
726
|
s.start("Retrying commit...");
|
|
516
727
|
const result = await attemptCommit(cached.message);
|
|
517
728
|
s.stop("Attempted commit");
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
729
|
+
debug("Retry commit result:", result);
|
|
730
|
+
if (result.ok) {
|
|
731
|
+
const checks = parseToolChecks(result.stderr ?? "");
|
|
732
|
+
if (checks.length > 0) {
|
|
733
|
+
const lines = checks.map((c) => ` ${c.ok ? green("✓") : red("✗")} ${c.tool}`);
|
|
734
|
+
log.info(lines.join("\n"));
|
|
735
|
+
}
|
|
736
|
+
outro(green("Committed successfully."));
|
|
737
|
+
} else {
|
|
738
|
+
const errors = parseHookErrors(result.stderr ?? "");
|
|
739
|
+
debug("Hook errors on retry:", errors.length);
|
|
740
|
+
await showRecoveryMenu(errors, async () => (await attemptCommit(cached.message)).ok, async (msg) => (await attemptCommitNoVerify(msg)).ok, async () => {
|
|
741
|
+
await stageAll();
|
|
742
|
+
return (await attemptCommit(cached.message)).ok;
|
|
743
|
+
}, cached.message, result.stderr ?? "");
|
|
744
|
+
}
|
|
523
745
|
return;
|
|
524
746
|
}
|
|
525
747
|
intro("commit-mint");
|
|
526
|
-
|
|
748
|
+
const status = await getStatusShort();
|
|
749
|
+
debug("Git status:", status || "(empty)");
|
|
750
|
+
if (!status) {
|
|
527
751
|
outro(dim("Nothing to commit."));
|
|
528
752
|
return;
|
|
529
753
|
}
|
|
@@ -531,18 +755,47 @@ async function commitCommand(flags) {
|
|
|
531
755
|
s.start("Staging all changes...");
|
|
532
756
|
await stageAll();
|
|
533
757
|
s.stop("Changes staged");
|
|
534
|
-
const
|
|
535
|
-
if (!
|
|
758
|
+
const diffResult = await getStagedDiff();
|
|
759
|
+
if (!diffResult) {
|
|
760
|
+
debug("No staged changes found after staging");
|
|
536
761
|
outro(red("No staged changes found."));
|
|
537
762
|
process.exit(1);
|
|
538
763
|
}
|
|
539
|
-
|
|
764
|
+
if ("excludedFiles" in diffResult) {
|
|
765
|
+
debug("All staged files are excluded:", diffResult.excludedFiles);
|
|
766
|
+
const message = buildExcludedFilesMessage(diffResult.excludedFiles);
|
|
767
|
+
log.info(diffResult.excludedFiles.map((f) => ` ${f}`).join("\n"));
|
|
768
|
+
const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
|
|
769
|
+
await saveCachedCommit(await getRepoRoot(), message);
|
|
770
|
+
s.start("Committing...");
|
|
771
|
+
const headBefore = await getHead();
|
|
772
|
+
const result = await attemptCommit(message);
|
|
773
|
+
const headAfter = await getHead();
|
|
774
|
+
if (result.ok || headBefore !== headAfter) {
|
|
775
|
+
s.stop("Committed successfully.");
|
|
776
|
+
outro(green("Done."));
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
s.stop("Commit failed.");
|
|
780
|
+
await showRecoveryMenu(parseHookErrors(result.stderr ?? ""), async () => (await attemptCommit(message)).ok, async (msg) => (await attemptCommitNoVerify(msg)).ok, async () => {
|
|
781
|
+
await stageAll();
|
|
782
|
+
return (await attemptCommit(message)).ok;
|
|
783
|
+
}, message, result.stderr ?? "");
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
debug("Staged files:", diffResult.files);
|
|
787
|
+
debug("Diff length:", diffResult.diff.length, "chars");
|
|
788
|
+
log.info(diffResult.files.map((f) => ` ${f}`).join("\n"));
|
|
540
789
|
let message;
|
|
541
|
-
if (flags.message)
|
|
542
|
-
|
|
790
|
+
if (flags.message) {
|
|
791
|
+
debug("Using provided message:", flags.message);
|
|
792
|
+
message = flags.message;
|
|
793
|
+
} else {
|
|
543
794
|
try {
|
|
544
795
|
await getApiKey();
|
|
796
|
+
debug("API key found");
|
|
545
797
|
} catch {
|
|
798
|
+
debug("No API key found, prompting user");
|
|
546
799
|
const { text: promptText } = await import("@clack/prompts");
|
|
547
800
|
const key = await promptText({
|
|
548
801
|
message: "Enter your Groq API key:",
|
|
@@ -554,12 +807,17 @@ async function commitCommand(flags) {
|
|
|
554
807
|
return;
|
|
555
808
|
}
|
|
556
809
|
await setConfigValue("GROQ_API_KEY", String(key).trim());
|
|
810
|
+
debug("API key saved to config");
|
|
557
811
|
}
|
|
558
812
|
s.start("Generating commit message...");
|
|
559
813
|
try {
|
|
560
|
-
|
|
814
|
+
const genStart = Date.now();
|
|
815
|
+
message = await generateMessage(diffResult.diff, flags.hint);
|
|
816
|
+
debug("generateMessage took %d ms", Date.now() - genStart);
|
|
817
|
+
debug("Generated message:", message);
|
|
561
818
|
} catch (err) {
|
|
562
819
|
s.stop(red("Failed to generate message."));
|
|
820
|
+
debug("Message generation failed:", err instanceof Error ? err.message : String(err));
|
|
563
821
|
outro(red(err instanceof Error ? err.message : String(err)));
|
|
564
822
|
return;
|
|
565
823
|
}
|
|
@@ -584,10 +842,12 @@ async function commitCommand(flags) {
|
|
|
584
842
|
]
|
|
585
843
|
});
|
|
586
844
|
if (isCancel(review) || review === "cancel") {
|
|
845
|
+
debug("User cancelled at review step");
|
|
587
846
|
outro(dim("Cancelled."));
|
|
588
847
|
return;
|
|
589
848
|
}
|
|
590
849
|
if (review === "edit") {
|
|
850
|
+
debug("User chose to edit message");
|
|
591
851
|
const edited = await text({
|
|
592
852
|
message: "Edit commit message:",
|
|
593
853
|
initialValue: message,
|
|
@@ -598,32 +858,48 @@ async function commitCommand(flags) {
|
|
|
598
858
|
return;
|
|
599
859
|
}
|
|
600
860
|
message = String(edited).trim();
|
|
861
|
+
debug("Edited message:", message);
|
|
601
862
|
}
|
|
602
863
|
const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
|
|
603
|
-
|
|
864
|
+
const repoRoot = await getRepoRoot();
|
|
865
|
+
await saveCachedCommit(repoRoot, message);
|
|
866
|
+
debug("Message cached for repo:", repoRoot);
|
|
604
867
|
s.start("Committing...");
|
|
605
868
|
const headBefore = await getHead();
|
|
869
|
+
debug("HEAD before commit:", headBefore);
|
|
606
870
|
const result = await attemptCommit(message);
|
|
607
871
|
const headAfter = await getHead();
|
|
872
|
+
debug("HEAD after commit:", headAfter);
|
|
873
|
+
debug("Commit result:", result);
|
|
608
874
|
if (result.ok || headBefore !== headAfter) {
|
|
609
875
|
s.stop("Committed successfully.");
|
|
876
|
+
const checks = parseToolChecks(result.stderr ?? "");
|
|
877
|
+
if (checks.length > 0) {
|
|
878
|
+
const lines = checks.map((c) => ` ${c.ok ? green("✓") : red("✗")} ${c.tool}`);
|
|
879
|
+
log.info(lines.join("\n"));
|
|
880
|
+
}
|
|
610
881
|
outro(green("Done."));
|
|
611
882
|
return;
|
|
612
883
|
}
|
|
613
884
|
s.stop("Commit failed.");
|
|
614
|
-
|
|
885
|
+
debug("Commit failed, showing recovery menu");
|
|
886
|
+
const errors = parseHookErrors(result.stderr ?? "");
|
|
887
|
+
debug("Parsed hook errors:", errors.length, "errors");
|
|
888
|
+
await showRecoveryMenu(errors, async () => {
|
|
615
889
|
return (await attemptCommit(message)).ok;
|
|
616
890
|
}, async (msg) => {
|
|
617
891
|
return (await attemptCommitNoVerify(msg)).ok;
|
|
618
892
|
}, async () => {
|
|
619
893
|
await stageAll();
|
|
620
894
|
return (await attemptCommit(message)).ok;
|
|
621
|
-
}, message);
|
|
895
|
+
}, message, result.stderr ?? "");
|
|
622
896
|
}
|
|
623
897
|
async function generateMessage(diff, hint) {
|
|
624
898
|
const config = await readConfig();
|
|
899
|
+
const apiKey = await getApiKey();
|
|
900
|
+
debug("Generating message with model:", config.model, "max-length:", config["max-length"], "type:", config.type);
|
|
625
901
|
return generateCommitMessage(diff, {
|
|
626
|
-
apiKey
|
|
902
|
+
apiKey,
|
|
627
903
|
model: config.model,
|
|
628
904
|
maxLength: config["max-length"] ? parseInt(config["max-length"], 10) : void 0,
|
|
629
905
|
type: config.type,
|
|
@@ -631,6 +907,15 @@ async function generateMessage(diff, hint) {
|
|
|
631
907
|
hint
|
|
632
908
|
});
|
|
633
909
|
}
|
|
910
|
+
function buildExcludedFilesMessage(files) {
|
|
911
|
+
const excludes = getDefaultExcludes();
|
|
912
|
+
const isLockfile = (f) => excludes.some((pattern) => {
|
|
913
|
+
if (pattern.endsWith(".lock") || pattern.endsWith(".json")) return f === pattern || f.endsWith(pattern.replace("*.", "."));
|
|
914
|
+
return false;
|
|
915
|
+
});
|
|
916
|
+
if (files.every(isLockfile)) return "chore: update lockfile";
|
|
917
|
+
return "chore: update generated files";
|
|
918
|
+
}
|
|
634
919
|
//#endregion
|
|
635
920
|
//#region src/commands/config.ts
|
|
636
921
|
const configCommand = command({
|
|
@@ -686,10 +971,17 @@ cli({
|
|
|
686
971
|
type: String,
|
|
687
972
|
description: "Add context hint for AI commit message generation",
|
|
688
973
|
alias: "H"
|
|
974
|
+
},
|
|
975
|
+
debug: {
|
|
976
|
+
type: Boolean,
|
|
977
|
+
description: "Enable debug output",
|
|
978
|
+
alias: "d",
|
|
979
|
+
default: false
|
|
689
980
|
}
|
|
690
981
|
},
|
|
691
982
|
commands: [configCommand]
|
|
692
983
|
}, (argv) => {
|
|
984
|
+
setDebug(argv.flags.debug);
|
|
693
985
|
commitCommand(argv.flags);
|
|
694
986
|
});
|
|
695
987
|
//#endregion
|