@kyubiware/commit-mint 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.mjs +559 -183
- 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.
|
|
28
|
+
version: "0.2.0",
|
|
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,75 +85,174 @@ 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);
|
|
113
175
|
}
|
|
114
|
-
function
|
|
115
|
-
if (
|
|
116
|
-
|
|
176
|
+
function extractContentText(content) {
|
|
177
|
+
if (content == null) return "";
|
|
178
|
+
if (typeof content === "string") return content.trim();
|
|
179
|
+
if (Array.isArray(content)) return content.filter((part) => part.type === "text" && typeof part.text === "string").map((part) => stripThinkTags(part.text)).join("").trim();
|
|
180
|
+
return "";
|
|
117
181
|
}
|
|
118
182
|
async function generateCommitMessage(diff, options) {
|
|
183
|
+
debug("generateCommitMessage: model=%s, type=%s, hint=%s", options.model ?? "default", options.type ?? "none", options.hint ?? "none");
|
|
184
|
+
const timeoutMs = options.timeout ?? 6e4;
|
|
185
|
+
debug("Timeout: %d ms", timeoutMs);
|
|
119
186
|
const client = new Groq({
|
|
120
187
|
apiKey: options.apiKey,
|
|
121
|
-
timeout:
|
|
188
|
+
timeout: timeoutMs
|
|
122
189
|
});
|
|
123
190
|
const compressedDiff = compressDiff(diff);
|
|
191
|
+
const statSummary = buildStatSummary(diff);
|
|
124
192
|
const systemPrompt = buildSystemPrompt(options.type);
|
|
125
|
-
const userPrompt = buildUserPrompt(compressedDiff, options.hint);
|
|
193
|
+
const userPrompt = buildUserPrompt(compressedDiff, options.hint, statSummary);
|
|
194
|
+
debug("Diff: %d chars → compressed to %d chars", diff.length, compressedDiff.length);
|
|
195
|
+
debug("Stat summary:\n%s", statSummary);
|
|
196
|
+
debug("User prompt length: %d chars", userPrompt.length);
|
|
126
197
|
async function callAI(strictSystemPrompt) {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
198
|
+
const callStart = Date.now();
|
|
199
|
+
debug("callAI: %s — model=%s, promptLen=%d, systemLen=%d", !!strictSystemPrompt ? "RETRY (strict)" : "INITIAL", options.model ?? "openai/gpt-oss-20b", userPrompt.length, (strictSystemPrompt ?? systemPrompt).length);
|
|
200
|
+
try {
|
|
201
|
+
const isReasoningModel = /^(o[1-9]|.*gpt-oss.*|.*gpt-5.*)/i.test(options.model ?? "");
|
|
202
|
+
const completion = await client.chat.completions.create({
|
|
203
|
+
messages: [{
|
|
204
|
+
role: "system",
|
|
205
|
+
content: strictSystemPrompt ?? systemPrompt
|
|
206
|
+
}, {
|
|
207
|
+
role: "user",
|
|
208
|
+
content: userPrompt
|
|
209
|
+
}],
|
|
210
|
+
model: options.model ?? "openai/gpt-oss-20b",
|
|
211
|
+
temperature: .3,
|
|
212
|
+
...isReasoningModel ? { max_completion_tokens: 1024 } : { max_tokens: 1024 },
|
|
213
|
+
reasoning_format: "parsed"
|
|
214
|
+
});
|
|
215
|
+
const elapsed = Date.now() - callStart;
|
|
216
|
+
const rawContent = completion.choices[0]?.message?.content;
|
|
217
|
+
const content = extractContentText(typeof rawContent === "string" ? stripThinkTags(rawContent) : rawContent);
|
|
218
|
+
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);
|
|
219
|
+
debug("callAI raw content: %s", content.slice(0, 300) || "(empty)");
|
|
220
|
+
if (!content) {
|
|
221
|
+
const reasoning = completion.choices[0]?.message?.reasoning;
|
|
222
|
+
debug("callAI: content empty, attempting reasoning fallback (reasoningLen=%d)", reasoning?.length ?? 0);
|
|
223
|
+
if (reasoning) {
|
|
224
|
+
const derived = deriveMessageFromReasoning(reasoning);
|
|
225
|
+
if (derived) {
|
|
226
|
+
debug("callAI: derived message from reasoning: %s", derived.slice(0, 100));
|
|
227
|
+
return stripThinkTags(derived);
|
|
228
|
+
}
|
|
229
|
+
debug("callAI: could not derive message from reasoning");
|
|
230
|
+
}
|
|
231
|
+
throw new Error("AI returned an empty commit message");
|
|
232
|
+
}
|
|
233
|
+
return content;
|
|
234
|
+
} catch (error) {
|
|
235
|
+
debug("callAI FAILED after %d ms: %s", Date.now() - callStart, error instanceof Error ? `${error.name}: ${error.message}` : String(error));
|
|
236
|
+
throw error;
|
|
237
|
+
}
|
|
139
238
|
}
|
|
140
239
|
try {
|
|
240
|
+
const totalStart = Date.now();
|
|
141
241
|
let message = await callAI();
|
|
242
|
+
debug("Validation: message=%s, isValid=%s", message.slice(0, 100), isValidConventionalCommit(message));
|
|
142
243
|
if (!isValidConventionalCommit(message)) {
|
|
244
|
+
debug("Initial message failed conventional commit validation, retrying with strict prompt (elapsed: %d ms)", Date.now() - totalStart);
|
|
143
245
|
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
|
-
|
|
246
|
+
debug("Retry validation: message=%s, isValid=%s", retryMessage.slice(0, 100), isValidConventionalCommit(retryMessage));
|
|
247
|
+
if (isValidConventionalCommit(retryMessage)) {
|
|
248
|
+
debug("Retry produced valid conventional commit");
|
|
249
|
+
message = retryMessage;
|
|
250
|
+
} else debug("Retry also failed validation, using original message");
|
|
145
251
|
}
|
|
146
|
-
|
|
252
|
+
debug("Final message (%d ms total): %s", Date.now() - totalStart, message);
|
|
253
|
+
return message;
|
|
147
254
|
} catch (error) {
|
|
255
|
+
debug("AI error: %s", error instanceof Error ? error.message : String(error));
|
|
148
256
|
if (error instanceof Groq.AuthenticationError) throw new Error("Invalid GROQ_API_KEY. Run: cmint config set GROQ_API_KEY=<key>");
|
|
149
257
|
if (error instanceof Groq.RateLimitError) throw new Error("Rate limited by Groq. Please wait and try again.");
|
|
150
258
|
if (error instanceof Groq.APIConnectionTimeoutError) throw new Error("Request timed out. Check your network or try a smaller diff.");
|
|
@@ -163,14 +271,18 @@ const defaults = {
|
|
|
163
271
|
timeout: "10000"
|
|
164
272
|
};
|
|
165
273
|
async function readConfig() {
|
|
274
|
+
debug("readConfig: loading from %s", CONFIG_PATH);
|
|
166
275
|
try {
|
|
167
276
|
const raw = await readFile(CONFIG_PATH, "utf8");
|
|
168
277
|
const parsed = ini.parse(raw);
|
|
169
|
-
|
|
278
|
+
const merged = {
|
|
170
279
|
...defaults,
|
|
171
280
|
...parsed
|
|
172
281
|
};
|
|
282
|
+
debug("readConfig: loaded keys: %s", Object.keys(merged).join(", "));
|
|
283
|
+
return merged;
|
|
173
284
|
} catch {
|
|
285
|
+
debug("readConfig: no config file, using defaults");
|
|
174
286
|
return { ...defaults };
|
|
175
287
|
}
|
|
176
288
|
}
|
|
@@ -187,9 +299,16 @@ async function setConfigValue(key, value) {
|
|
|
187
299
|
}
|
|
188
300
|
async function getApiKey() {
|
|
189
301
|
const envKey = process.env.GROQ_API_KEY;
|
|
190
|
-
if (envKey)
|
|
302
|
+
if (envKey) {
|
|
303
|
+
debug("getApiKey: found in env");
|
|
304
|
+
return envKey;
|
|
305
|
+
}
|
|
191
306
|
const config = await readConfig();
|
|
192
|
-
if (config.GROQ_API_KEY)
|
|
307
|
+
if (config.GROQ_API_KEY) {
|
|
308
|
+
debug("getApiKey: found in config");
|
|
309
|
+
return config.GROQ_API_KEY;
|
|
310
|
+
}
|
|
311
|
+
debug("getApiKey: not found");
|
|
193
312
|
throw new Error("Please set your Groq API key via `cmint config set GROQ_API_KEY=<your token>`");
|
|
194
313
|
}
|
|
195
314
|
//#endregion
|
|
@@ -199,57 +318,81 @@ var git_exports = /* @__PURE__ */ __exportAll({
|
|
|
199
318
|
assertGitRepo: () => assertGitRepo,
|
|
200
319
|
attemptCommit: () => attemptCommit,
|
|
201
320
|
attemptCommitNoVerify: () => attemptCommitNoVerify,
|
|
321
|
+
getChangedFiles: () => getChangedFiles,
|
|
322
|
+
getDefaultExcludes: () => getDefaultExcludes,
|
|
202
323
|
getHead: () => getHead,
|
|
203
324
|
getRepoRoot: () => getRepoRoot,
|
|
204
325
|
getStagedDiff: () => getStagedDiff,
|
|
205
326
|
getStatusShort: () => getStatusShort,
|
|
206
|
-
stageAll: () => stageAll
|
|
327
|
+
stageAll: () => stageAll,
|
|
328
|
+
stageFiles: () => stageFiles
|
|
207
329
|
});
|
|
208
330
|
var KnownError = class extends Error {};
|
|
209
331
|
async function assertGitRepo() {
|
|
332
|
+
debug("assertGitRepo");
|
|
210
333
|
const { failed } = await execa("git", ["rev-parse", "--show-toplevel"], { reject: false });
|
|
211
334
|
if (failed) throw new KnownError("The current directory must be a Git repository!");
|
|
212
335
|
}
|
|
213
336
|
async function getRepoRoot() {
|
|
214
337
|
const { stdout } = await execa("git", ["rev-parse", "--show-toplevel"]);
|
|
338
|
+
debug("getRepoRoot:", stdout.trim());
|
|
215
339
|
return stdout.trim();
|
|
216
340
|
}
|
|
341
|
+
const DEFAULT_EXCLUDES = [
|
|
342
|
+
"package-lock.json",
|
|
343
|
+
"node_modules/**",
|
|
344
|
+
"dist/**",
|
|
345
|
+
"build/**",
|
|
346
|
+
".next/**",
|
|
347
|
+
"coverage/**",
|
|
348
|
+
"*.log",
|
|
349
|
+
"*.min.js",
|
|
350
|
+
"*.min.css",
|
|
351
|
+
"*.lock",
|
|
352
|
+
".DS_Store"
|
|
353
|
+
];
|
|
354
|
+
function getDefaultExcludes() {
|
|
355
|
+
return [...DEFAULT_EXCLUDES];
|
|
356
|
+
}
|
|
217
357
|
async function getStagedDiff(exclude) {
|
|
218
358
|
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}`);
|
|
359
|
+
const defaultExcludeArgs = DEFAULT_EXCLUDES.map((e) => `:(exclude)${e}`);
|
|
360
|
+
const { stdout: allFiles } = await execa("git", [
|
|
361
|
+
"diff",
|
|
362
|
+
"--cached",
|
|
363
|
+
"--name-only"
|
|
364
|
+
]);
|
|
365
|
+
if (!allFiles) {
|
|
366
|
+
debug("getStagedDiff: no staged files");
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
232
369
|
const { stdout: files } = await execa("git", [
|
|
233
370
|
"diff",
|
|
234
371
|
"--cached",
|
|
235
372
|
"--name-only",
|
|
236
|
-
...
|
|
373
|
+
...defaultExcludeArgs,
|
|
237
374
|
...excludeArgs
|
|
238
375
|
]);
|
|
239
|
-
if (!files)
|
|
376
|
+
if (!files) {
|
|
377
|
+
const excludedFiles = allFiles.split("\n").filter(Boolean);
|
|
378
|
+
debug("getStagedDiff: all files excluded:", excludedFiles);
|
|
379
|
+
return { excludedFiles };
|
|
380
|
+
}
|
|
240
381
|
const { stdout: diff } = await execa("git", [
|
|
241
382
|
"diff",
|
|
242
383
|
"--cached",
|
|
243
384
|
"--diff-algorithm=minimal",
|
|
244
|
-
...
|
|
385
|
+
...defaultExcludeArgs,
|
|
245
386
|
...excludeArgs
|
|
246
387
|
]);
|
|
388
|
+
debug("getStagedDiff:", files.split("\n").filter(Boolean).length, "files,", diff.length, "chars");
|
|
247
389
|
return {
|
|
248
390
|
files: files.split("\n").filter(Boolean),
|
|
249
391
|
diff
|
|
250
392
|
};
|
|
251
393
|
}
|
|
252
394
|
async function stageAll() {
|
|
395
|
+
debug("stageAll: git add -A");
|
|
253
396
|
await execa("git", ["add", "-A"]);
|
|
254
397
|
}
|
|
255
398
|
async function getHead() {
|
|
@@ -260,25 +403,51 @@ async function getStatusShort() {
|
|
|
260
403
|
const { stdout } = await execa("git", ["status", "--short"]);
|
|
261
404
|
return stdout.trim();
|
|
262
405
|
}
|
|
406
|
+
async function getChangedFiles() {
|
|
407
|
+
const { stdout } = await execa("git", ["status", "--short"]);
|
|
408
|
+
if (!stdout.trim()) return [];
|
|
409
|
+
const files = stdout.split("\n").filter(Boolean).map((line) => ({
|
|
410
|
+
status: line.slice(0, 2).trim(),
|
|
411
|
+
path: line.slice(3)
|
|
412
|
+
}));
|
|
413
|
+
debug("getChangedFiles:", files.length, "files");
|
|
414
|
+
return files;
|
|
415
|
+
}
|
|
416
|
+
async function stageFiles(paths) {
|
|
417
|
+
debug("stageFiles:", paths);
|
|
418
|
+
await execa("git", ["add", ...paths]);
|
|
419
|
+
}
|
|
263
420
|
async function attemptCommit(message, extraArgs = []) {
|
|
421
|
+
debug("attemptCommit:", message, extraArgs.length ? extraArgs : "(no extra args)");
|
|
264
422
|
try {
|
|
265
|
-
|
|
423
|
+
const subprocess = execa("git", [
|
|
266
424
|
"commit",
|
|
267
425
|
"-m",
|
|
268
426
|
message,
|
|
269
427
|
...extraArgs
|
|
270
428
|
]);
|
|
271
|
-
|
|
429
|
+
const stderrChunks = [];
|
|
430
|
+
subprocess.stderr?.on("data", (chunk) => {
|
|
431
|
+
stderrChunks.push(chunk.toString());
|
|
432
|
+
});
|
|
433
|
+
await subprocess;
|
|
434
|
+
debug("attemptCommit: success");
|
|
435
|
+
return {
|
|
436
|
+
ok: true,
|
|
437
|
+
stderr: stderrChunks.join("")
|
|
438
|
+
};
|
|
272
439
|
} catch (error) {
|
|
273
440
|
const e = error;
|
|
441
|
+
debug("attemptCommit: failed —", e.message?.slice(0, 200));
|
|
274
442
|
return {
|
|
275
443
|
ok: false,
|
|
276
444
|
error: e.message,
|
|
277
|
-
stderr: e.stderr
|
|
445
|
+
stderr: typeof e.stderr === "string" ? e.stderr : ""
|
|
278
446
|
};
|
|
279
447
|
}
|
|
280
448
|
}
|
|
281
449
|
async function attemptCommitNoVerify(message) {
|
|
450
|
+
debug("attemptCommitNoVerify:", message);
|
|
282
451
|
return attemptCommit(message, ["--no-verify"]);
|
|
283
452
|
}
|
|
284
453
|
//#endregion
|
|
@@ -289,25 +458,27 @@ async function attemptCommitNoVerify(message) {
|
|
|
289
458
|
*/
|
|
290
459
|
function parseHookErrors(stderr) {
|
|
291
460
|
if (!stderr) return [];
|
|
461
|
+
debug("parseHookErrors: stderr length=%d", stderr.length);
|
|
292
462
|
const errors = [];
|
|
293
|
-
stderr.split("\n");
|
|
294
463
|
if (stderr.includes("lint-staged") || stderr.includes("[FAILED]")) errors.push(...parseLintStagedErrors(stderr));
|
|
295
464
|
if (stderr.includes("biome") || stderr.includes("Biome")) errors.push(...parseBiomeErrors(stderr));
|
|
296
465
|
if (stderr.includes("error TS") || stderr.includes("tsc")) errors.push(...parseTscErrors(stderr));
|
|
297
466
|
if (stderr.includes("vitest") || stderr.includes("jest") || stderr.includes("FAIL") || stderr.includes("test failed")) errors.push(...parseTestErrors(stderr));
|
|
298
467
|
if (stderr.includes("eslint") || stderr.includes("ESLint")) errors.push(...parseEslintErrors(stderr));
|
|
299
|
-
if (errors.length === 0)
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
468
|
+
if (errors.length === 0) {
|
|
469
|
+
debug("parseHookErrors: no patterns matched, using raw fallback");
|
|
470
|
+
errors.push({
|
|
471
|
+
tool: "git hooks",
|
|
472
|
+
message: stderr.trim(),
|
|
473
|
+
raw: stderr
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
debug("parseHookErrors: found %d errors", errors.length);
|
|
304
477
|
return errors;
|
|
305
478
|
}
|
|
306
479
|
function parseLintStagedErrors(output) {
|
|
307
480
|
const errors = [];
|
|
308
|
-
const
|
|
309
|
-
let match;
|
|
310
|
-
while ((match = taskPattern.exec(output)) !== null) {
|
|
481
|
+
for (const match of output.matchAll(/\[FAILED\]\s+(.+?)\s+\[FAILED\]/g)) {
|
|
311
482
|
const task = match[1].trim();
|
|
312
483
|
errors.push({
|
|
313
484
|
tool: "lint-staged",
|
|
@@ -319,9 +490,7 @@ function parseLintStagedErrors(output) {
|
|
|
319
490
|
}
|
|
320
491
|
function parseBiomeErrors(output) {
|
|
321
492
|
const errors = [];
|
|
322
|
-
const
|
|
323
|
-
let match;
|
|
324
|
-
while ((match = biomePattern.exec(output)) !== null) errors.push({
|
|
493
|
+
for (const match of output.matchAll(/^(.+?):(\d+):(\d+)\s+(.+)$/gm)) errors.push({
|
|
325
494
|
tool: "biome",
|
|
326
495
|
message: `${match[1]}:${match[2]}:${match[3]} — ${match[4]}`,
|
|
327
496
|
raw: match[0]
|
|
@@ -335,9 +504,7 @@ function parseBiomeErrors(output) {
|
|
|
335
504
|
}
|
|
336
505
|
function parseTscErrors(output) {
|
|
337
506
|
const errors = [];
|
|
338
|
-
const
|
|
339
|
-
let match;
|
|
340
|
-
while ((match = tscPattern.exec(output)) !== null) errors.push({
|
|
507
|
+
for (const match of output.matchAll(/^(.+?)\((\d+),(\d+)\):\s+error\s+(TS\d+):\s+(.+)$/gm)) errors.push({
|
|
341
508
|
tool: "tsc",
|
|
342
509
|
message: `${match[1]}:${match[2]}:${match[3]} — ${match[4]}: ${match[5]}`,
|
|
343
510
|
raw: match[0]
|
|
@@ -361,71 +528,103 @@ function parseTestErrors(output) {
|
|
|
361
528
|
}
|
|
362
529
|
function parseEslintErrors(output) {
|
|
363
530
|
const errors = [];
|
|
364
|
-
const
|
|
365
|
-
let match;
|
|
366
|
-
while ((match = eslintPattern.exec(output)) !== null) errors.push({
|
|
531
|
+
for (const match of output.matchAll(/^\s*\d+:(\d+)\s+(error|warning)\s+(.+?)\s+(.+?)$/gm)) errors.push({
|
|
367
532
|
tool: "eslint",
|
|
368
533
|
message: `${match[2]}: ${match[3]} (${match[4]})`,
|
|
369
534
|
raw: match[0]
|
|
370
535
|
});
|
|
371
536
|
return errors;
|
|
372
537
|
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
538
|
+
/**
|
|
539
|
+
* Parse lint-staged/hook stderr output to discover which tools ran
|
|
540
|
+
* and whether they succeeded. Used for clean post-commit summary.
|
|
541
|
+
*/
|
|
542
|
+
function parseToolChecks(stderr) {
|
|
543
|
+
if (!stderr) return [];
|
|
544
|
+
const checks = [];
|
|
545
|
+
for (const match of stderr.matchAll(/\[(COMPLETED|FAILED)\]\s+(.+)/g)) {
|
|
546
|
+
const status = match[1];
|
|
547
|
+
const command = match[2].trim();
|
|
548
|
+
if (isLintStagedMeta(command)) continue;
|
|
549
|
+
const tool = extractToolName(command);
|
|
550
|
+
if (!tool) continue;
|
|
551
|
+
checks.push({
|
|
552
|
+
tool,
|
|
553
|
+
ok: status === "COMPLETED"
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
const seen = /* @__PURE__ */ new Map();
|
|
557
|
+
for (const c of checks) seen.set(c.tool, c);
|
|
558
|
+
return [...seen.values()];
|
|
559
|
+
}
|
|
560
|
+
/** Heuristic: skip lint-staged internal metadata lines */
|
|
561
|
+
function isLintStagedMeta(command) {
|
|
562
|
+
if (/[*{}[\]]/.test(command)) return true;
|
|
563
|
+
if (/\s[-–—]\s(\d+\s)?files?$/.test(command)) return true;
|
|
564
|
+
if (/\s[-–—]\sno\s files$/.test(command)) return true;
|
|
565
|
+
if (/^(Running tasks|Applying modifications|Cleaning up|Backing up|Backed up|Updating Git)/.test(command)) return true;
|
|
566
|
+
if (/\.{3}$/.test(command)) return true;
|
|
567
|
+
return false;
|
|
568
|
+
}
|
|
569
|
+
/** Extract a display-friendly tool name from a lint-staged command */
|
|
570
|
+
function extractToolName(command) {
|
|
571
|
+
const tokens = command.split(/\s+/);
|
|
572
|
+
const first = tokens[0];
|
|
573
|
+
if ([
|
|
574
|
+
"npm",
|
|
575
|
+
"yarn",
|
|
576
|
+
"pnpm",
|
|
577
|
+
"bun"
|
|
578
|
+
].includes(first)) {
|
|
579
|
+
const script = tokens[tokens[1] === "run" ? 2 : 1];
|
|
580
|
+
if (!script) return null;
|
|
581
|
+
return {
|
|
582
|
+
typecheck: "tsc",
|
|
583
|
+
lint: "eslint",
|
|
584
|
+
format: "prettier"
|
|
585
|
+
}[script] ?? script;
|
|
586
|
+
}
|
|
587
|
+
if (first === "npx") return tokens[1] ?? null;
|
|
588
|
+
return first;
|
|
376
589
|
}
|
|
377
590
|
//#endregion
|
|
378
591
|
//#region src/services/clipboard.ts
|
|
379
592
|
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"]
|
|
593
|
+
for (const [cmd, args] of [
|
|
594
|
+
["wl-copy", []],
|
|
595
|
+
["xclip", ["-selection", "clipboard"]],
|
|
596
|
+
["xsel", ["--clipboard", "--input"]],
|
|
597
|
+
["pbcopy", []]
|
|
393
598
|
]) try {
|
|
394
|
-
|
|
395
|
-
if (!stdout) continue;
|
|
396
|
-
await execa(cmd, args.length > 0 ? args : [], { input: content });
|
|
599
|
+
await execa(cmd, args, { input: content });
|
|
397
600
|
return true;
|
|
398
|
-
} catch {
|
|
399
|
-
continue;
|
|
400
|
-
}
|
|
601
|
+
} catch {}
|
|
401
602
|
return false;
|
|
402
603
|
}
|
|
403
604
|
//#endregion
|
|
404
605
|
//#region src/ui/menu.ts
|
|
405
|
-
async function
|
|
406
|
-
|
|
606
|
+
async function showStagingMenu(files) {
|
|
607
|
+
debug("showStagingMenu: %d files", files.length);
|
|
608
|
+
const statusLabel = (status) => {
|
|
609
|
+
switch (status) {
|
|
610
|
+
case "M": return yellow("M");
|
|
611
|
+
case "A": return green("A");
|
|
612
|
+
case "D": return red("D");
|
|
613
|
+
case "?": return dim("?");
|
|
614
|
+
default: return dim(status);
|
|
615
|
+
}
|
|
616
|
+
};
|
|
407
617
|
const choice = await p.select({
|
|
408
|
-
message: "
|
|
618
|
+
message: "Stage files for commit:",
|
|
409
619
|
options: [
|
|
410
620
|
{
|
|
411
|
-
label: "
|
|
412
|
-
value: "
|
|
413
|
-
hint:
|
|
621
|
+
label: "Stage all files",
|
|
622
|
+
value: "all",
|
|
623
|
+
hint: `${files.length} file${files.length !== 1 ? "s" : ""}`
|
|
414
624
|
},
|
|
415
625
|
{
|
|
416
|
-
label: "
|
|
417
|
-
value: "
|
|
418
|
-
hint: "Commit anyway, fix later"
|
|
419
|
-
},
|
|
420
|
-
{
|
|
421
|
-
label: "Re-stage files and retry",
|
|
422
|
-
value: "restage",
|
|
423
|
-
hint: "Pick up fixes from another terminal"
|
|
424
|
-
},
|
|
425
|
-
{
|
|
426
|
-
label: "Edit commit message",
|
|
427
|
-
value: "edit",
|
|
428
|
-
hint: "Modify the message before retrying"
|
|
626
|
+
label: "Select files...",
|
|
627
|
+
value: "select"
|
|
429
628
|
},
|
|
430
629
|
{
|
|
431
630
|
label: "Cancel",
|
|
@@ -433,44 +632,102 @@ async function showRecoveryMenu(errors, onRetry, onSkipHooks, onRestage, message
|
|
|
433
632
|
}
|
|
434
633
|
]
|
|
435
634
|
});
|
|
436
|
-
if (p.isCancel(choice))
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
635
|
+
if (p.isCancel(choice) || choice === "cancel") return null;
|
|
636
|
+
if (choice === "all") return {
|
|
637
|
+
files: files.map((f) => f.path),
|
|
638
|
+
all: true
|
|
639
|
+
};
|
|
640
|
+
const selected = await p.multiselect({
|
|
641
|
+
message: "Select files to stage:",
|
|
642
|
+
options: files.map((f) => ({
|
|
643
|
+
label: `${statusLabel(f.status)} ${f.path}`,
|
|
644
|
+
value: f.path
|
|
645
|
+
})),
|
|
646
|
+
required: true
|
|
647
|
+
});
|
|
648
|
+
if (p.isCancel(selected)) return null;
|
|
649
|
+
return {
|
|
650
|
+
files: selected,
|
|
651
|
+
all: false
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
async function showRecoveryMenu(errors, onRetry, onSkipHooks, onRestage, message, rawStderr) {
|
|
655
|
+
debug("showRecoveryMenu: %d errors", errors.length);
|
|
656
|
+
while (true) {
|
|
657
|
+
p.note(errors.map((e) => ` ${red("•")} [${e.tool}] ${e.message}`).join("\n"), red(bold("Pre-commit hook failed")));
|
|
658
|
+
const choice = await p.select({
|
|
659
|
+
message: "What do you want to do?",
|
|
660
|
+
options: [
|
|
661
|
+
{
|
|
662
|
+
label: "Copy error report to clipboard",
|
|
663
|
+
value: "clipboard",
|
|
664
|
+
hint: "Paste into another terminal for an AI agent"
|
|
665
|
+
},
|
|
666
|
+
{
|
|
667
|
+
label: "Skip hooks and commit (--no-verify)",
|
|
668
|
+
value: "skip",
|
|
669
|
+
hint: "Commit anyway, fix later"
|
|
670
|
+
},
|
|
671
|
+
{
|
|
672
|
+
label: "Re-stage files and retry",
|
|
673
|
+
value: "restage",
|
|
674
|
+
hint: "Pick up fixes from another terminal"
|
|
675
|
+
},
|
|
676
|
+
{
|
|
677
|
+
label: "Edit commit message",
|
|
678
|
+
value: "edit",
|
|
679
|
+
hint: "Modify the message before retrying"
|
|
680
|
+
},
|
|
681
|
+
{
|
|
682
|
+
label: "Cancel",
|
|
683
|
+
value: "cancel"
|
|
684
|
+
}
|
|
685
|
+
]
|
|
686
|
+
});
|
|
687
|
+
if (p.isCancel(choice)) {
|
|
688
|
+
debug("showRecoveryMenu: user cancelled");
|
|
689
|
+
p.outro(yellow("Cancelled. Message cached for --retry."));
|
|
690
|
+
process.exit(1);
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
debug("showRecoveryMenu: user chose %s", choice);
|
|
694
|
+
switch (choice) {
|
|
695
|
+
case "clipboard":
|
|
696
|
+
if (await copyToClipboard(rawStderr)) p.log.step(green("Errors copied"));
|
|
697
|
+
else p.log.warn(red("No clipboard tool found. Install xclip, wl-copy, or xsel."));
|
|
698
|
+
continue;
|
|
699
|
+
case "skip":
|
|
700
|
+
p.log.info(yellow("Committing with --no-verify..."));
|
|
701
|
+
if (await onSkipHooks(message)) p.outro(green("Committed (hooks skipped)."));
|
|
702
|
+
else p.outro(red("Commit failed even with --no-verify."));
|
|
703
|
+
return;
|
|
704
|
+
case "restage":
|
|
705
|
+
p.log.info(cyan("Re-staging and retrying..."));
|
|
706
|
+
if (await onRestage()) {
|
|
707
|
+
p.outro(green("Committed successfully."));
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
continue;
|
|
711
|
+
case "edit": {
|
|
712
|
+
const edited = await p.text({
|
|
713
|
+
message: "Edit commit message:",
|
|
714
|
+
initialValue: message,
|
|
715
|
+
validate: (v) => v.trim() ? void 0 : "Message cannot be empty"
|
|
716
|
+
});
|
|
717
|
+
if (p.isCancel(edited)) {
|
|
718
|
+
p.outro(yellow("Cancelled. Message cached for --retry."));
|
|
719
|
+
process.exit(1);
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
if (await onRetry()) p.outro(green("Committed successfully."));
|
|
723
|
+
else p.outro(red("Commit failed again."));
|
|
724
|
+
return;
|
|
466
725
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
726
|
+
case "cancel":
|
|
727
|
+
p.outro(dim("Message cached for --retry."));
|
|
728
|
+
process.exit(1);
|
|
729
|
+
return;
|
|
470
730
|
}
|
|
471
|
-
case "cancel":
|
|
472
|
-
p.outro(dim("Message cached for --retry."));
|
|
473
|
-
process.exit(1);
|
|
474
731
|
}
|
|
475
732
|
}
|
|
476
733
|
//#endregion
|
|
@@ -489,60 +746,141 @@ async function saveCachedCommit(repoPath, message) {
|
|
|
489
746
|
timestamp: Date.now(),
|
|
490
747
|
repoPath
|
|
491
748
|
};
|
|
492
|
-
|
|
749
|
+
const path = cachePath(repoPath);
|
|
750
|
+
debug("saveCachedCommit: saving to %s", path);
|
|
751
|
+
await writeFile(path, JSON.stringify(data, null, 2), "utf8");
|
|
493
752
|
}
|
|
494
753
|
async function loadCachedCommit(repoPath) {
|
|
754
|
+
const path = cachePath(repoPath);
|
|
755
|
+
debug("loadCachedCommit: loading from %s", path);
|
|
495
756
|
try {
|
|
496
|
-
const raw = await readFile(
|
|
497
|
-
|
|
757
|
+
const raw = await readFile(path, "utf8");
|
|
758
|
+
const data = JSON.parse(raw);
|
|
759
|
+
debug("loadCachedCommit: found message from %s", new Date(data.timestamp).toISOString());
|
|
760
|
+
return data;
|
|
498
761
|
} catch {
|
|
762
|
+
debug("loadCachedCommit: no cached commit found");
|
|
499
763
|
return null;
|
|
500
764
|
}
|
|
501
765
|
}
|
|
502
766
|
//#endregion
|
|
503
767
|
//#region src/commands/commit.ts
|
|
504
768
|
async function commitCommand(flags) {
|
|
769
|
+
debug("commitCommand called", { flags });
|
|
505
770
|
await assertGitRepo();
|
|
506
771
|
if (flags.retry) {
|
|
772
|
+
debug("Entering retry mode");
|
|
507
773
|
const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
|
|
508
|
-
const
|
|
774
|
+
const repoRoot = await getRepoRoot();
|
|
775
|
+
debug("Repo root:", repoRoot);
|
|
776
|
+
const cached = await loadCachedCommit(repoRoot);
|
|
509
777
|
if (!cached) {
|
|
778
|
+
debug("No cached commit found");
|
|
510
779
|
outro(red("No cached commit message found. Run cmint without --retry first."));
|
|
511
780
|
process.exit(1);
|
|
512
781
|
}
|
|
782
|
+
debug("Loaded cached message:", cached.message);
|
|
513
783
|
intro("commit-mint — retry");
|
|
514
784
|
const s = spinner();
|
|
515
785
|
s.start("Retrying commit...");
|
|
516
786
|
const result = await attemptCommit(cached.message);
|
|
517
787
|
s.stop("Attempted commit");
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
788
|
+
debug("Retry commit result:", result);
|
|
789
|
+
if (result.ok) {
|
|
790
|
+
const checks = parseToolChecks(result.stderr ?? "");
|
|
791
|
+
if (checks.length > 0) {
|
|
792
|
+
const lines = checks.map((c) => ` ${c.ok ? green("✓") : red("✗")} ${c.tool}`);
|
|
793
|
+
log.info(lines.join("\n"));
|
|
794
|
+
}
|
|
795
|
+
outro(green("Committed successfully."));
|
|
796
|
+
} else {
|
|
797
|
+
const errors = parseHookErrors(result.stderr ?? "");
|
|
798
|
+
debug("Hook errors on retry:", errors.length);
|
|
799
|
+
await showRecoveryMenu(errors, async () => (await attemptCommit(cached.message)).ok, async (msg) => (await attemptCommitNoVerify(msg)).ok, async () => {
|
|
800
|
+
await stageAll();
|
|
801
|
+
return (await attemptCommit(cached.message)).ok;
|
|
802
|
+
}, cached.message, result.stderr ?? "");
|
|
803
|
+
}
|
|
523
804
|
return;
|
|
524
805
|
}
|
|
525
806
|
intro("commit-mint");
|
|
526
|
-
|
|
807
|
+
const status = await getStatusShort();
|
|
808
|
+
debug("Git status:", status || "(empty)");
|
|
809
|
+
if (!status) {
|
|
527
810
|
outro(dim("Nothing to commit."));
|
|
528
811
|
return;
|
|
529
812
|
}
|
|
813
|
+
const changedFiles = await getChangedFiles();
|
|
814
|
+
debug("Changed files:", changedFiles.length);
|
|
530
815
|
const s = spinner();
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
816
|
+
try {
|
|
817
|
+
if (flags.all) {
|
|
818
|
+
s.start("Staging all changes...");
|
|
819
|
+
await stageAll();
|
|
820
|
+
s.stop("Changes staged");
|
|
821
|
+
} else if (changedFiles.length === 1) {
|
|
822
|
+
s.start(`Staging ${changedFiles[0].path}...`);
|
|
823
|
+
await stageFiles([changedFiles[0].path]);
|
|
824
|
+
s.stop("File staged");
|
|
825
|
+
} else {
|
|
826
|
+
const stagingResult = await showStagingMenu(changedFiles);
|
|
827
|
+
if (!stagingResult) {
|
|
828
|
+
outro(dim("Cancelled."));
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
s.start(`Staging ${stagingResult.files.length} file${stagingResult.files.length !== 1 ? "s" : ""}...`);
|
|
832
|
+
if (stagingResult.all) await stageAll();
|
|
833
|
+
else await stageFiles(stagingResult.files);
|
|
834
|
+
s.stop("Files staged");
|
|
835
|
+
}
|
|
836
|
+
} catch (err) {
|
|
837
|
+
s.stop(red("Staging failed."));
|
|
838
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
839
|
+
debug("Staging error:", msg);
|
|
840
|
+
outro(red(`Failed to stage files: ${msg}`));
|
|
841
|
+
process.exit(1);
|
|
842
|
+
}
|
|
843
|
+
const diffResult = await getStagedDiff();
|
|
844
|
+
if (!diffResult) {
|
|
845
|
+
debug("No staged changes found after staging");
|
|
536
846
|
outro(red("No staged changes found."));
|
|
537
847
|
process.exit(1);
|
|
538
848
|
}
|
|
539
|
-
|
|
849
|
+
if ("excludedFiles" in diffResult) {
|
|
850
|
+
debug("All staged files are excluded:", diffResult.excludedFiles);
|
|
851
|
+
const message = buildExcludedFilesMessage(diffResult.excludedFiles);
|
|
852
|
+
log.info(diffResult.excludedFiles.map((f) => ` ${f}`).join("\n"));
|
|
853
|
+
const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
|
|
854
|
+
await saveCachedCommit(await getRepoRoot(), message);
|
|
855
|
+
s.start("Committing...");
|
|
856
|
+
const headBefore = await getHead();
|
|
857
|
+
const result = await attemptCommit(message);
|
|
858
|
+
const headAfter = await getHead();
|
|
859
|
+
if (result.ok || headBefore !== headAfter) {
|
|
860
|
+
s.stop("Committed successfully.");
|
|
861
|
+
outro(green("Done."));
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
s.stop("Commit failed.");
|
|
865
|
+
await showRecoveryMenu(parseHookErrors(result.stderr ?? ""), async () => (await attemptCommit(message)).ok, async (msg) => (await attemptCommitNoVerify(msg)).ok, async () => {
|
|
866
|
+
await stageAll();
|
|
867
|
+
return (await attemptCommit(message)).ok;
|
|
868
|
+
}, message, result.stderr ?? "");
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
debug("Staged files:", diffResult.files);
|
|
872
|
+
debug("Diff length:", diffResult.diff.length, "chars");
|
|
873
|
+
log.info(diffResult.files.map((f) => ` ${f}`).join("\n"));
|
|
540
874
|
let message;
|
|
541
|
-
if (flags.message)
|
|
542
|
-
|
|
875
|
+
if (flags.message) {
|
|
876
|
+
debug("Using provided message:", flags.message);
|
|
877
|
+
message = flags.message;
|
|
878
|
+
} else {
|
|
543
879
|
try {
|
|
544
880
|
await getApiKey();
|
|
881
|
+
debug("API key found");
|
|
545
882
|
} catch {
|
|
883
|
+
debug("No API key found, prompting user");
|
|
546
884
|
const { text: promptText } = await import("@clack/prompts");
|
|
547
885
|
const key = await promptText({
|
|
548
886
|
message: "Enter your Groq API key:",
|
|
@@ -554,12 +892,17 @@ async function commitCommand(flags) {
|
|
|
554
892
|
return;
|
|
555
893
|
}
|
|
556
894
|
await setConfigValue("GROQ_API_KEY", String(key).trim());
|
|
895
|
+
debug("API key saved to config");
|
|
557
896
|
}
|
|
558
897
|
s.start("Generating commit message...");
|
|
559
898
|
try {
|
|
560
|
-
|
|
899
|
+
const genStart = Date.now();
|
|
900
|
+
message = await generateMessage(diffResult.diff, flags.hint);
|
|
901
|
+
debug("generateMessage took %d ms", Date.now() - genStart);
|
|
902
|
+
debug("Generated message:", message);
|
|
561
903
|
} catch (err) {
|
|
562
904
|
s.stop(red("Failed to generate message."));
|
|
905
|
+
debug("Message generation failed:", err instanceof Error ? err.message : String(err));
|
|
563
906
|
outro(red(err instanceof Error ? err.message : String(err)));
|
|
564
907
|
return;
|
|
565
908
|
}
|
|
@@ -584,10 +927,12 @@ async function commitCommand(flags) {
|
|
|
584
927
|
]
|
|
585
928
|
});
|
|
586
929
|
if (isCancel(review) || review === "cancel") {
|
|
930
|
+
debug("User cancelled at review step");
|
|
587
931
|
outro(dim("Cancelled."));
|
|
588
932
|
return;
|
|
589
933
|
}
|
|
590
934
|
if (review === "edit") {
|
|
935
|
+
debug("User chose to edit message");
|
|
591
936
|
const edited = await text({
|
|
592
937
|
message: "Edit commit message:",
|
|
593
938
|
initialValue: message,
|
|
@@ -598,39 +943,63 @@ async function commitCommand(flags) {
|
|
|
598
943
|
return;
|
|
599
944
|
}
|
|
600
945
|
message = String(edited).trim();
|
|
946
|
+
debug("Edited message:", message);
|
|
601
947
|
}
|
|
602
948
|
const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
|
|
603
|
-
|
|
949
|
+
const repoRoot = await getRepoRoot();
|
|
950
|
+
await saveCachedCommit(repoRoot, message);
|
|
951
|
+
debug("Message cached for repo:", repoRoot);
|
|
604
952
|
s.start("Committing...");
|
|
605
953
|
const headBefore = await getHead();
|
|
954
|
+
debug("HEAD before commit:", headBefore);
|
|
606
955
|
const result = await attemptCommit(message);
|
|
607
956
|
const headAfter = await getHead();
|
|
957
|
+
debug("HEAD after commit:", headAfter);
|
|
958
|
+
debug("Commit result:", result);
|
|
608
959
|
if (result.ok || headBefore !== headAfter) {
|
|
609
960
|
s.stop("Committed successfully.");
|
|
961
|
+
const checks = parseToolChecks(result.stderr ?? "");
|
|
962
|
+
if (checks.length > 0) {
|
|
963
|
+
const lines = checks.map((c) => ` ${c.ok ? green("✓") : red("✗")} ${c.tool}`);
|
|
964
|
+
log.info(lines.join("\n"));
|
|
965
|
+
}
|
|
610
966
|
outro(green("Done."));
|
|
611
967
|
return;
|
|
612
968
|
}
|
|
613
969
|
s.stop("Commit failed.");
|
|
614
|
-
|
|
970
|
+
debug("Commit failed, showing recovery menu");
|
|
971
|
+
const errors = parseHookErrors(result.stderr ?? "");
|
|
972
|
+
debug("Parsed hook errors:", errors.length, "errors");
|
|
973
|
+
await showRecoveryMenu(errors, async () => {
|
|
615
974
|
return (await attemptCommit(message)).ok;
|
|
616
975
|
}, async (msg) => {
|
|
617
976
|
return (await attemptCommitNoVerify(msg)).ok;
|
|
618
977
|
}, async () => {
|
|
619
978
|
await stageAll();
|
|
620
979
|
return (await attemptCommit(message)).ok;
|
|
621
|
-
}, message);
|
|
980
|
+
}, message, result.stderr ?? "");
|
|
622
981
|
}
|
|
623
982
|
async function generateMessage(diff, hint) {
|
|
624
983
|
const config = await readConfig();
|
|
984
|
+
const apiKey = await getApiKey();
|
|
985
|
+
debug("Generating message with model:", config.model, "type:", config.type);
|
|
625
986
|
return generateCommitMessage(diff, {
|
|
626
|
-
apiKey
|
|
987
|
+
apiKey,
|
|
627
988
|
model: config.model,
|
|
628
|
-
maxLength: config["max-length"] ? parseInt(config["max-length"], 10) : void 0,
|
|
629
989
|
type: config.type,
|
|
630
990
|
timeout: config.timeout ? parseInt(config.timeout, 10) : void 0,
|
|
631
991
|
hint
|
|
632
992
|
});
|
|
633
993
|
}
|
|
994
|
+
function buildExcludedFilesMessage(files) {
|
|
995
|
+
const excludes = getDefaultExcludes();
|
|
996
|
+
const isLockfile = (f) => excludes.some((pattern) => {
|
|
997
|
+
if (pattern.endsWith(".lock") || pattern.endsWith(".json")) return f === pattern || f.endsWith(pattern.replace("*.", "."));
|
|
998
|
+
return false;
|
|
999
|
+
});
|
|
1000
|
+
if (files.every(isLockfile)) return "chore: update lockfile";
|
|
1001
|
+
return "chore: update generated files";
|
|
1002
|
+
}
|
|
634
1003
|
//#endregion
|
|
635
1004
|
//#region src/commands/config.ts
|
|
636
1005
|
const configCommand = command({
|
|
@@ -686,10 +1055,17 @@ cli({
|
|
|
686
1055
|
type: String,
|
|
687
1056
|
description: "Add context hint for AI commit message generation",
|
|
688
1057
|
alias: "H"
|
|
1058
|
+
},
|
|
1059
|
+
debug: {
|
|
1060
|
+
type: Boolean,
|
|
1061
|
+
description: "Enable debug output",
|
|
1062
|
+
alias: "d",
|
|
1063
|
+
default: false
|
|
689
1064
|
}
|
|
690
1065
|
},
|
|
691
1066
|
commands: [configCommand]
|
|
692
1067
|
}, (argv) => {
|
|
1068
|
+
setDebug(argv.flags.debug);
|
|
693
1069
|
commitCommand(argv.flags);
|
|
694
1070
|
});
|
|
695
1071
|
//#endregion
|