@kyubiware/commit-mint 0.4.2 → 0.5.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 +166 -89
- package/dist/cli.mjs +1046 -766
- package/dist/cli.mjs.map +1 -1
- package/package.json +6 -11
package/dist/cli.mjs
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { cli, command } from "cleye";
|
|
3
3
|
import * as p from "@clack/prompts";
|
|
4
|
-
import { intro, isCancel, log,
|
|
4
|
+
import { intro, isCancel, log, outro, spinner } from "@clack/prompts";
|
|
5
5
|
import { bold, cyan, dim, green, red, yellow } from "kolorist";
|
|
6
6
|
import { access, constants, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
7
7
|
import os from "node:os";
|
|
8
|
-
import { join } from "node:path";
|
|
8
|
+
import { extname, join } from "node:path";
|
|
9
9
|
import ini from "ini";
|
|
10
|
-
import { execa } from "execa";
|
|
11
|
-
import { spawn } from "node:child_process";
|
|
12
10
|
import Groq from "groq-sdk";
|
|
11
|
+
import { execa } from "execa";
|
|
13
12
|
import { createHash } from "node:crypto";
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import picomatch from "picomatch";
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
14
16
|
//#region \0rolldown/runtime.js
|
|
15
17
|
var __defProp = Object.defineProperty;
|
|
16
18
|
var __exportAll = (all, no_symbols) => {
|
|
@@ -26,8 +28,8 @@ var __exportAll = (all, no_symbols) => {
|
|
|
26
28
|
//#region package.json
|
|
27
29
|
var package_default = {
|
|
28
30
|
name: "@kyubiware/commit-mint",
|
|
29
|
-
version: "0.
|
|
30
|
-
description: "A commit tool that actually handles hook failures",
|
|
31
|
+
version: "0.5.1",
|
|
32
|
+
description: "🌿 A commit tool that actually handles hook failures",
|
|
31
33
|
type: "module",
|
|
32
34
|
bin: { "cmint": "./dist/cli.mjs" },
|
|
33
35
|
files: ["dist"],
|
|
@@ -45,16 +47,13 @@ var package_default = {
|
|
|
45
47
|
"release:patch": "bash scripts/release.sh patch",
|
|
46
48
|
"release:minor": "bash scripts/release.sh minor",
|
|
47
49
|
"release:major": "bash scripts/release.sh major",
|
|
48
|
-
"prepublishOnly": "npm run build"
|
|
49
|
-
"prepare": "simple-git-hooks"
|
|
50
|
+
"prepublishOnly": "npm run build"
|
|
50
51
|
},
|
|
51
|
-
"simple-git-hooks": { "pre-commit": "npx lint-staged" },
|
|
52
52
|
keywords: [
|
|
53
53
|
"git",
|
|
54
54
|
"commit",
|
|
55
55
|
"hooks",
|
|
56
56
|
"pre-commit",
|
|
57
|
-
"lint-staged",
|
|
58
57
|
"ai",
|
|
59
58
|
"groq",
|
|
60
59
|
"conventional-commits",
|
|
@@ -72,14 +71,14 @@ var package_default = {
|
|
|
72
71
|
"execa": "^9.6.0",
|
|
73
72
|
"groq-sdk": "^0.32.0",
|
|
74
73
|
"ini": "^5.0.0",
|
|
75
|
-
"
|
|
74
|
+
"jiti": "^2.7.0",
|
|
75
|
+
"kolorist": "^1.8.0",
|
|
76
|
+
"picomatch": "^4.0.4"
|
|
76
77
|
},
|
|
77
78
|
devDependencies: {
|
|
78
79
|
"@biomejs/biome": "^2.0.0",
|
|
79
80
|
"@types/ini": "^4.1.1",
|
|
80
81
|
"@vitest/coverage-v8": "^3.2.4",
|
|
81
|
-
"lint-staged": "^17.0.5",
|
|
82
|
-
"simple-git-hooks": "^2.13.1",
|
|
83
82
|
"tsdown": "^0.22.0",
|
|
84
83
|
"tsx": "^4.22.2",
|
|
85
84
|
"typescript": "^5.9.2",
|
|
@@ -98,9 +97,52 @@ function debug(...args) {
|
|
|
98
97
|
console.error(dim(`[debug ${timestamp}]`), ...args);
|
|
99
98
|
}
|
|
100
99
|
//#endregion
|
|
100
|
+
//#region src/services/provider.ts
|
|
101
|
+
const PROVIDER_CONFIGS = {
|
|
102
|
+
groq: {
|
|
103
|
+
baseURL: "https://api.groq.com",
|
|
104
|
+
defaultModel: "openai/gpt-oss-20b"
|
|
105
|
+
},
|
|
106
|
+
cerebras: {
|
|
107
|
+
baseURL: "https://api.cerebras.ai",
|
|
108
|
+
defaultModel: "gpt-oss-120b"
|
|
109
|
+
},
|
|
110
|
+
mistral: {
|
|
111
|
+
baseURL: "https://api.mistral.ai",
|
|
112
|
+
defaultModel: "mistral-small"
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
const ALLOWED_PROVIDERS = Object.keys(PROVIDER_CONFIGS);
|
|
116
|
+
const PROVIDER_ENV_KEYS = {
|
|
117
|
+
groq: "GROQ_API_KEY",
|
|
118
|
+
cerebras: "CEREBRAS_API_KEY",
|
|
119
|
+
mistral: "MISTRAL_API_KEY"
|
|
120
|
+
};
|
|
121
|
+
function formatProviderName(provider) {
|
|
122
|
+
return provider.charAt(0).toUpperCase() + provider.slice(1);
|
|
123
|
+
}
|
|
124
|
+
function isValidProvider(name) {
|
|
125
|
+
return ALLOWED_PROVIDERS.includes(name);
|
|
126
|
+
}
|
|
127
|
+
function createProvider(options) {
|
|
128
|
+
if (!isValidProvider(options.provider)) throw new Error(`Invalid provider "${options.provider}". Allowed values: ${ALLOWED_PROVIDERS.join(", ")}`);
|
|
129
|
+
const providerConfig = PROVIDER_CONFIGS[options.provider];
|
|
130
|
+
const model = options.modelOverride ?? providerConfig.defaultModel;
|
|
131
|
+
const baseURL = options.baseURLOverride ?? providerConfig.baseURL;
|
|
132
|
+
return {
|
|
133
|
+
client: new Groq({
|
|
134
|
+
apiKey: options.apiKey,
|
|
135
|
+
baseURL,
|
|
136
|
+
timeout: options.timeout
|
|
137
|
+
}),
|
|
138
|
+
model
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
//#endregion
|
|
101
142
|
//#region src/services/config.ts
|
|
102
143
|
const CONFIG_PATH = join(os.homedir(), ".commit-mint");
|
|
103
144
|
const defaults = {
|
|
145
|
+
provider: "groq",
|
|
104
146
|
model: "openai/gpt-oss-20b",
|
|
105
147
|
locale: "en",
|
|
106
148
|
"max-length": "100",
|
|
@@ -128,25 +170,29 @@ async function writeConfig(updates) {
|
|
|
128
170
|
Object.assign(existing, updates);
|
|
129
171
|
await writeFile(CONFIG_PATH, ini.stringify(existing), "utf8");
|
|
130
172
|
}
|
|
131
|
-
async function getConfigValue(key) {
|
|
132
|
-
return (await readConfig())[key];
|
|
133
|
-
}
|
|
134
173
|
async function setConfigValue(key, value) {
|
|
135
174
|
await writeConfig({ [key]: value });
|
|
136
175
|
}
|
|
137
|
-
async function
|
|
138
|
-
const
|
|
139
|
-
if (
|
|
140
|
-
|
|
141
|
-
|
|
176
|
+
async function getProviderApiKey(provider) {
|
|
177
|
+
const envVar = PROVIDER_ENV_KEYS[provider];
|
|
178
|
+
if (envVar) {
|
|
179
|
+
const envValue = process.env[envVar];
|
|
180
|
+
if (envValue) {
|
|
181
|
+
debug("getProviderApiKey(%s): found in env", provider);
|
|
182
|
+
return envValue;
|
|
183
|
+
}
|
|
142
184
|
}
|
|
143
185
|
const config = await readConfig();
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
186
|
+
const configKey = PROVIDER_ENV_KEYS[provider];
|
|
187
|
+
if (configKey && config[configKey]) {
|
|
188
|
+
debug("getProviderApiKey(%s): found in config", provider);
|
|
189
|
+
return config[configKey];
|
|
147
190
|
}
|
|
148
|
-
debug("
|
|
149
|
-
throw new Error(
|
|
191
|
+
debug("getProviderApiKey(%s): not found", provider);
|
|
192
|
+
throw new Error(`Please set your ${formatProviderName(provider)} API key via \`cmint config set ${envVar}=<your token>\``);
|
|
193
|
+
}
|
|
194
|
+
function getModelForProvider(config, provider, defaultModel) {
|
|
195
|
+
return config[`model_${provider}`] ?? config.model ?? defaultModel;
|
|
150
196
|
}
|
|
151
197
|
//#endregion
|
|
152
198
|
//#region src/services/hooks.ts
|
|
@@ -521,195 +567,20 @@ async function attemptCommitNoVerify(message, onProgress) {
|
|
|
521
567
|
return attemptCommit(message, ["--no-verify"], onProgress);
|
|
522
568
|
}
|
|
523
569
|
//#endregion
|
|
524
|
-
//#region src/
|
|
525
|
-
|
|
526
|
-
"
|
|
527
|
-
".lintstagedrc.json",
|
|
528
|
-
".lintstagedrc.yaml",
|
|
529
|
-
".lintstagedrc.yml",
|
|
530
|
-
".lintstagedrc.mjs",
|
|
531
|
-
".lintstagedrc.cjs",
|
|
532
|
-
"lint-staged.config.mjs",
|
|
533
|
-
"lint-staged.config.cjs",
|
|
534
|
-
"lint-staged.config.js"
|
|
535
|
-
];
|
|
536
|
-
async function hasLintStagedConfig(repoRoot) {
|
|
537
|
-
debug("hasLintStagedConfig: checking in %s", repoRoot);
|
|
538
|
-
for (const file of CONFIG_FILES) {
|
|
539
|
-
const path = join(repoRoot, file);
|
|
540
|
-
try {
|
|
541
|
-
await access(path, constants.F_OK);
|
|
542
|
-
debug("hasLintStagedConfig: found %s", file);
|
|
543
|
-
return true;
|
|
544
|
-
} catch {}
|
|
545
|
-
}
|
|
546
|
-
const packageJsonPath = join(repoRoot, "package.json");
|
|
547
|
-
try {
|
|
548
|
-
const raw = await readFile(packageJsonPath, "utf8");
|
|
549
|
-
if ("lint-staged" in JSON.parse(raw)) {
|
|
550
|
-
debug("hasLintStagedConfig: found lint-staged in package.json");
|
|
551
|
-
return true;
|
|
552
|
-
}
|
|
553
|
-
} catch {}
|
|
554
|
-
debug("hasLintStagedConfig: no config found");
|
|
555
|
-
return false;
|
|
556
|
-
}
|
|
557
|
-
async function runLintStaged() {
|
|
558
|
-
debug("runLintStaged: starting npx lint-staged");
|
|
559
|
-
const { failed, stdout, stderr } = await execa("npx", ["lint-staged"], { reject: false });
|
|
560
|
-
debug("runLintStaged: finished, failed=%s", failed);
|
|
561
|
-
return {
|
|
562
|
-
ok: !failed,
|
|
563
|
-
stdout,
|
|
564
|
-
stderr
|
|
565
|
-
};
|
|
566
|
-
}
|
|
567
|
-
//#endregion
|
|
568
|
-
//#region src/services/clipboard.ts
|
|
569
|
-
async function copyToClipboard(content) {
|
|
570
|
-
for (const [cmd, args] of [
|
|
571
|
-
["wl-copy", []],
|
|
572
|
-
["xclip", ["-selection", "clipboard"]],
|
|
573
|
-
["xsel", ["--clipboard", "--input"]],
|
|
574
|
-
["pbcopy", []]
|
|
575
|
-
]) try {
|
|
576
|
-
if (await new Promise((resolve) => {
|
|
577
|
-
const child = spawn(cmd, args, { stdio: [
|
|
578
|
-
"pipe",
|
|
579
|
-
"ignore",
|
|
580
|
-
"ignore"
|
|
581
|
-
] });
|
|
582
|
-
let settled = false;
|
|
583
|
-
const done = (result) => {
|
|
584
|
-
if (settled) return;
|
|
585
|
-
settled = true;
|
|
586
|
-
resolve(result);
|
|
587
|
-
};
|
|
588
|
-
child.on("error", () => done(false));
|
|
589
|
-
child.on("exit", (code) => {
|
|
590
|
-
if (code !== 0) done(false);
|
|
591
|
-
});
|
|
592
|
-
child.stdin.write(content, (err) => {
|
|
593
|
-
if (err) {
|
|
594
|
-
done(false);
|
|
595
|
-
return;
|
|
596
|
-
}
|
|
597
|
-
child.stdin.end(() => {
|
|
598
|
-
child.unref();
|
|
599
|
-
done(true);
|
|
600
|
-
});
|
|
601
|
-
});
|
|
602
|
-
})) return true;
|
|
603
|
-
} catch {}
|
|
604
|
-
return false;
|
|
605
|
-
}
|
|
606
|
-
//#endregion
|
|
607
|
-
//#region src/ui/menu.ts
|
|
608
|
-
async function showStagingMenu(files, hasLintStaged) {
|
|
609
|
-
debug("showStagingMenu: %d files", files.length);
|
|
610
|
-
const statusLabel = (status) => {
|
|
611
|
-
switch (status) {
|
|
612
|
-
case "M": return yellow("M");
|
|
613
|
-
case "A": return green("A");
|
|
614
|
-
case "D": return red("D");
|
|
615
|
-
case "?":
|
|
616
|
-
case "??": return cyan("?");
|
|
617
|
-
default: return dim(status);
|
|
618
|
-
}
|
|
619
|
-
};
|
|
620
|
-
const sorted = [...files].sort((a, b) => {
|
|
621
|
-
if (a.staged !== b.staged) return a.staged ? -1 : 1;
|
|
622
|
-
return a.path.localeCompare(b.path);
|
|
623
|
-
});
|
|
624
|
-
const stagedFiles = sorted.filter((f) => f.staged);
|
|
625
|
-
const unstagedFiles = sorted.filter((f) => !f.staged);
|
|
626
|
-
const lines = [];
|
|
627
|
-
if (stagedFiles.length > 0) lines.push(green(bold("Staged:")), ...stagedFiles.map((f) => ` ${statusLabel(f.status)} ${f.path}`));
|
|
628
|
-
if (unstagedFiles.length > 0) {
|
|
629
|
-
if (lines.length > 0) lines.push("");
|
|
630
|
-
lines.push(yellow(bold("Changed:")), ...unstagedFiles.map((f) => ` ${statusLabel(f.status)} ${f.path}`));
|
|
631
|
-
}
|
|
632
|
-
p.note(lines.join("\n"), `${files.length} file${files.length !== 1 ? "s" : ""}`);
|
|
633
|
-
const choice = await p.select({
|
|
634
|
-
message: "Stage files for commit:",
|
|
635
|
-
options: [
|
|
636
|
-
{
|
|
637
|
-
label: "Auto-group into commits",
|
|
638
|
-
value: "autogroup",
|
|
639
|
-
hint: "LLM groups files into logical commits"
|
|
640
|
-
},
|
|
641
|
-
{
|
|
642
|
-
label: "Stage all files",
|
|
643
|
-
value: "all",
|
|
644
|
-
hint: `${files.length} file${files.length !== 1 ? "s" : ""}`
|
|
645
|
-
},
|
|
646
|
-
...hasLintStaged ? [{
|
|
647
|
-
label: "Run lint-staged checks",
|
|
648
|
-
value: "lint-staged",
|
|
649
|
-
hint: "Pre-flight checks on all changed files"
|
|
650
|
-
}] : [],
|
|
651
|
-
{
|
|
652
|
-
label: "Select files...",
|
|
653
|
-
value: "select"
|
|
654
|
-
},
|
|
655
|
-
{
|
|
656
|
-
label: "Cancel",
|
|
657
|
-
value: "cancel"
|
|
658
|
-
}
|
|
659
|
-
]
|
|
660
|
-
});
|
|
661
|
-
if (p.isCancel(choice) || choice === "cancel") return null;
|
|
662
|
-
if (choice === "autogroup") return "autogroup";
|
|
663
|
-
if (choice === "lint-staged") return "lint-staged";
|
|
664
|
-
if (choice === "all") return {
|
|
665
|
-
files: files.map((f) => f.path),
|
|
666
|
-
all: true
|
|
667
|
-
};
|
|
668
|
-
const selected = await p.multiselect({
|
|
669
|
-
message: "Select files to stage:",
|
|
670
|
-
options: sorted.map((f) => ({
|
|
671
|
-
label: `${statusLabel(f.status)} ${f.path}`,
|
|
672
|
-
value: f.path
|
|
673
|
-
})),
|
|
674
|
-
required: true
|
|
675
|
-
});
|
|
676
|
-
if (p.isCancel(selected)) return null;
|
|
677
|
-
return {
|
|
678
|
-
files: selected,
|
|
679
|
-
all: false
|
|
680
|
-
};
|
|
681
|
-
}
|
|
682
|
-
async function showRecoveryMenu(errors, onRetry, onSkipHooks, onRestage, message, rawStderr) {
|
|
683
|
-
debug("showRecoveryMenu: %d errors", errors.length);
|
|
684
|
-
let clipboardCopied = false;
|
|
685
|
-
let showNote = true;
|
|
570
|
+
//#region src/ui/review-message.ts
|
|
571
|
+
async function reviewCommitMessage(message) {
|
|
572
|
+
const { select, text } = await import("@clack/prompts");
|
|
686
573
|
while (true) {
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
showNote = false;
|
|
690
|
-
}
|
|
691
|
-
const choice = await p.select({
|
|
692
|
-
message: "What do you want to do?",
|
|
574
|
+
const review = await select({
|
|
575
|
+
message: `Review commit message:\n\n ${bold(message)}\n`,
|
|
693
576
|
options: [
|
|
694
577
|
{
|
|
695
|
-
label:
|
|
696
|
-
value: "
|
|
697
|
-
hint: clipboardCopied ? "Copied!" : "Paste into another terminal for an AI agent"
|
|
698
|
-
},
|
|
699
|
-
{
|
|
700
|
-
label: "Skip hooks and commit (--no-verify)",
|
|
701
|
-
value: "skip",
|
|
702
|
-
hint: "Commit anyway, fix later"
|
|
703
|
-
},
|
|
704
|
-
{
|
|
705
|
-
label: "Re-stage files and retry",
|
|
706
|
-
value: "restage",
|
|
707
|
-
hint: "Pick up fixes from another terminal"
|
|
578
|
+
label: "Use as-is",
|
|
579
|
+
value: "use"
|
|
708
580
|
},
|
|
709
581
|
{
|
|
710
|
-
label: "Edit
|
|
711
|
-
value: "edit"
|
|
712
|
-
hint: "Modify the message before retrying"
|
|
582
|
+
label: "Edit",
|
|
583
|
+
value: "edit"
|
|
713
584
|
},
|
|
714
585
|
{
|
|
715
586
|
label: "Cancel",
|
|
@@ -717,73 +588,74 @@ async function showRecoveryMenu(errors, onRetry, onSkipHooks, onRestage, message
|
|
|
717
588
|
}
|
|
718
589
|
]
|
|
719
590
|
});
|
|
720
|
-
if (
|
|
721
|
-
debug("
|
|
722
|
-
|
|
723
|
-
return "cancelled";
|
|
591
|
+
if (isCancel(review) || review === "cancel") {
|
|
592
|
+
debug("User cancelled at review step");
|
|
593
|
+
return null;
|
|
724
594
|
}
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
p.outro(red("Commit failed even with --no-verify."));
|
|
740
|
-
return "failed";
|
|
741
|
-
}
|
|
742
|
-
case "restage":
|
|
743
|
-
p.log.info(cyan("Re-staging and retrying..."));
|
|
744
|
-
if (await onRestage()) {
|
|
745
|
-
p.outro(green("Committed successfully."));
|
|
746
|
-
return "committed";
|
|
747
|
-
}
|
|
748
|
-
showNote = true;
|
|
749
|
-
continue;
|
|
750
|
-
case "edit": {
|
|
751
|
-
const edited = await p.text({
|
|
752
|
-
message: "Edit commit message:",
|
|
753
|
-
initialValue: message,
|
|
754
|
-
validate: (v) => v?.trim() ? void 0 : "Message cannot be empty"
|
|
755
|
-
});
|
|
756
|
-
if (p.isCancel(edited)) {
|
|
757
|
-
p.outro(yellow("Cancelled. Message cached for --retry."));
|
|
758
|
-
return "cancelled";
|
|
759
|
-
}
|
|
760
|
-
if (await onRetry()) {
|
|
761
|
-
p.outro(green("Committed successfully."));
|
|
762
|
-
return "committed";
|
|
763
|
-
} else {
|
|
764
|
-
p.outro(red("Commit failed again."));
|
|
765
|
-
return "failed";
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
case "cancel":
|
|
769
|
-
p.outro(dim("Message cached for --retry."));
|
|
770
|
-
return "cancelled";
|
|
595
|
+
if (review === "use") {
|
|
596
|
+
debug("User accepted message");
|
|
597
|
+
return message;
|
|
598
|
+
}
|
|
599
|
+
if (review === "edit") {
|
|
600
|
+
debug("User chose to edit message");
|
|
601
|
+
const edited = await text({
|
|
602
|
+
message: "Edit commit message:",
|
|
603
|
+
initialValue: message,
|
|
604
|
+
validate: (v) => v?.trim() ? void 0 : "Message cannot be empty"
|
|
605
|
+
});
|
|
606
|
+
if (isCancel(edited)) continue;
|
|
607
|
+
message = String(edited).trim();
|
|
608
|
+
debug("Edited message:", message);
|
|
771
609
|
}
|
|
772
610
|
}
|
|
773
611
|
}
|
|
774
612
|
//#endregion
|
|
775
|
-
//#region src/
|
|
776
|
-
const
|
|
777
|
-
function
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
613
|
+
//#region src/utils/cache.ts
|
|
614
|
+
const CACHE_DIR = join(os.homedir(), ".cache", "commit-mint");
|
|
615
|
+
function repoHash(repoPath) {
|
|
616
|
+
return createHash("sha256").update(repoPath).digest("hex").slice(0, 12);
|
|
617
|
+
}
|
|
618
|
+
function cachePath(repoPath) {
|
|
619
|
+
return join(CACHE_DIR, `${repoHash(repoPath)}.json`);
|
|
620
|
+
}
|
|
621
|
+
async function saveCachedCommit(repoPath, message) {
|
|
622
|
+
await mkdir(CACHE_DIR, { recursive: true });
|
|
623
|
+
const data = {
|
|
624
|
+
message,
|
|
625
|
+
timestamp: Date.now(),
|
|
626
|
+
repoPath
|
|
627
|
+
};
|
|
628
|
+
const path = cachePath(repoPath);
|
|
629
|
+
debug("saveCachedCommit: saving to %s", path);
|
|
630
|
+
await writeFile(path, JSON.stringify(data, null, 2), "utf8");
|
|
631
|
+
}
|
|
632
|
+
async function loadCachedCommit(repoPath) {
|
|
633
|
+
const path = cachePath(repoPath);
|
|
634
|
+
debug("loadCachedCommit: loading from %s", path);
|
|
635
|
+
try {
|
|
636
|
+
const raw = await readFile(path, "utf8");
|
|
637
|
+
const data = JSON.parse(raw);
|
|
638
|
+
debug("loadCachedCommit: found message from %s", new Date(data.timestamp).toISOString());
|
|
639
|
+
return data;
|
|
640
|
+
} catch {
|
|
641
|
+
debug("loadCachedCommit: no cached commit found");
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
//#endregion
|
|
646
|
+
//#region src/services/ai.ts
|
|
647
|
+
const MAX_DIFF_CHARS = 2e4;
|
|
648
|
+
function mapGroqError(error, providerLabel) {
|
|
649
|
+
const label = providerLabel ?? "Groq";
|
|
650
|
+
if (error instanceof Groq.AuthenticationError) return /* @__PURE__ */ new Error(`Invalid API key for ${label}. Run: cmint config set ${label.toUpperCase()}_API_KEY=<key>`);
|
|
651
|
+
if (error instanceof Groq.RateLimitError) return /* @__PURE__ */ new Error(`Rate limited by ${label}. Please wait and try again.`);
|
|
652
|
+
if (error instanceof Groq.APIConnectionTimeoutError) return /* @__PURE__ */ new Error("Request timed out. Check your network or try a smaller diff.");
|
|
653
|
+
if (error instanceof Groq.APIError) return /* @__PURE__ */ new Error(`${label} API error: ${error.message}`);
|
|
654
|
+
return /* @__PURE__ */ new Error(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`);
|
|
655
|
+
}
|
|
656
|
+
const CONVENTIONAL_COMMIT_REGEX = /^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.+\))?!?: .+$/;
|
|
657
|
+
function stripThinkTags(text) {
|
|
658
|
+
return text.replace(/<think[\s\S]*?<\/think>/gi, "").trim();
|
|
787
659
|
}
|
|
788
660
|
function deriveMessageFromReasoning(reasoning) {
|
|
789
661
|
const match = reasoning.match(/(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.+\))?!?: .+/i);
|
|
@@ -863,13 +735,16 @@ function extractContentText(content) {
|
|
|
863
735
|
return "";
|
|
864
736
|
}
|
|
865
737
|
async function generateCommitMessage(diff, options) {
|
|
866
|
-
debug("generateCommitMessage: model=%s, type=%s, hint=%s", options.model ?? "default", options.type ?? "none", options.hint ?? "none");
|
|
867
738
|
const timeoutMs = options.timeout ?? 6e4;
|
|
868
739
|
debug("Timeout: %d ms", timeoutMs);
|
|
869
|
-
const client =
|
|
740
|
+
const { client, model } = createProvider({
|
|
741
|
+
provider: options.provider ?? "groq",
|
|
870
742
|
apiKey: options.apiKey,
|
|
871
|
-
|
|
743
|
+
modelOverride: options.model,
|
|
744
|
+
timeout: timeoutMs,
|
|
745
|
+
baseURLOverride: options.proxy
|
|
872
746
|
});
|
|
747
|
+
debug("generateCommitMessage: model=%s, type=%s, hint=%s", model, options.type ?? "none", options.hint ?? "none");
|
|
873
748
|
const compressedDiff = compressDiff(diff);
|
|
874
749
|
const statSummary = buildStatSummary(diff);
|
|
875
750
|
const systemPrompt = buildSystemPrompt(options.type);
|
|
@@ -879,9 +754,9 @@ async function generateCommitMessage(diff, options) {
|
|
|
879
754
|
debug("User prompt length: %d chars", userPrompt.length);
|
|
880
755
|
async function callAI(strictSystemPrompt) {
|
|
881
756
|
const callStart = Date.now();
|
|
882
|
-
debug("callAI: %s — model=%s, promptLen=%d, systemLen=%d", !!strictSystemPrompt ? "RETRY (strict)" : "INITIAL",
|
|
757
|
+
debug("callAI: %s — model=%s, promptLen=%d, systemLen=%d", !!strictSystemPrompt ? "RETRY (strict)" : "INITIAL", model, userPrompt.length, (strictSystemPrompt ?? systemPrompt).length);
|
|
883
758
|
try {
|
|
884
|
-
const isReasoningModel = /^(o[1-9]|.*gpt-oss.*|.*gpt-5.*)/i.test(
|
|
759
|
+
const isReasoningModel = /^(o[1-9]|.*gpt-oss.*|.*gpt-5.*)/i.test(model);
|
|
885
760
|
const completion = await client.chat.completions.create({
|
|
886
761
|
messages: [{
|
|
887
762
|
role: "system",
|
|
@@ -890,7 +765,7 @@ async function generateCommitMessage(diff, options) {
|
|
|
890
765
|
role: "user",
|
|
891
766
|
content: userPrompt
|
|
892
767
|
}],
|
|
893
|
-
model
|
|
768
|
+
model,
|
|
894
769
|
temperature: .3,
|
|
895
770
|
...isReasoningModel ? { max_completion_tokens: 1024 } : { max_tokens: 1024 },
|
|
896
771
|
reasoning_format: "parsed"
|
|
@@ -936,332 +811,205 @@ async function generateCommitMessage(diff, options) {
|
|
|
936
811
|
return message;
|
|
937
812
|
} catch (error) {
|
|
938
813
|
debug("AI error: %s", error instanceof Error ? error.message : String(error));
|
|
939
|
-
throw mapGroqError
|
|
940
|
-
}
|
|
941
|
-
}
|
|
942
|
-
//#endregion
|
|
943
|
-
//#region src/services/review-ai.ts
|
|
944
|
-
function buildReviewSystemPrompt() {
|
|
945
|
-
return [
|
|
946
|
-
"You are an expert code reviewer. Review the following staged git diff.",
|
|
947
|
-
"",
|
|
948
|
-
"Focus on finding:",
|
|
949
|
-
"1. **Bugs** — logic errors, off-by-one, race conditions, null pointer risks",
|
|
950
|
-
"2. **Security issues** — injection, exposure of secrets, missing validation, CSRF, XSS",
|
|
951
|
-
"3. **Performance problems** — unnecessary work, large allocations in hot paths",
|
|
952
|
-
"4. **Code quality** — readability, maintainability, error handling gaps",
|
|
953
|
-
"5. **Edge cases** — missing boundary checks, empty states, error states",
|
|
954
|
-
"",
|
|
955
|
-
"For each issue found, use this format:",
|
|
956
|
-
"- SEVERITY: [critical|major|minor|suggestion]",
|
|
957
|
-
"- LOCATION: <file-path>:<line-number>",
|
|
958
|
-
"- ISSUE: <description>",
|
|
959
|
-
"- FIX: <suggested resolution>",
|
|
960
|
-
"",
|
|
961
|
-
"Separate issues with a blank line.",
|
|
962
|
-
"",
|
|
963
|
-
"If you find NO issues at all, respond with exactly: NO_ISSUES_FOUND",
|
|
964
|
-
"",
|
|
965
|
-
"Be thorough but practical. Only flag real problems — not style preferences or nitpicks."
|
|
966
|
-
].join("\n");
|
|
967
|
-
}
|
|
968
|
-
function buildReviewPrompt(diff, files, statSummary) {
|
|
969
|
-
const parts = [];
|
|
970
|
-
parts.push(`Review the following staged changes (${files.length} files):`);
|
|
971
|
-
parts.push("");
|
|
972
|
-
parts.push(statSummary);
|
|
973
|
-
parts.push("");
|
|
974
|
-
parts.push("```diff");
|
|
975
|
-
parts.push(diff);
|
|
976
|
-
parts.push("```");
|
|
977
|
-
return parts.join("\n");
|
|
978
|
-
}
|
|
979
|
-
async function generateCodeReview(diff, files, options) {
|
|
980
|
-
debug("generateCodeReview: model=%s, files=%d", options.model ?? "default", files.length);
|
|
981
|
-
const timeoutMs = options.timeout ?? 6e4;
|
|
982
|
-
const client = new Groq({
|
|
983
|
-
apiKey: options.apiKey,
|
|
984
|
-
timeout: timeoutMs
|
|
985
|
-
});
|
|
986
|
-
const compressedDiff = compressDiff(diff);
|
|
987
|
-
const statSummary = buildStatSummary(diff);
|
|
988
|
-
const systemPrompt = buildReviewSystemPrompt();
|
|
989
|
-
const userPrompt = buildReviewPrompt(compressedDiff, files, statSummary);
|
|
990
|
-
debug("Code review: %d chars → %d chars, system=%d chars, user=%d chars", diff.length, compressedDiff.length, systemPrompt.length, userPrompt.length);
|
|
991
|
-
try {
|
|
992
|
-
const completion = await client.chat.completions.create({
|
|
993
|
-
messages: [{
|
|
994
|
-
role: "system",
|
|
995
|
-
content: systemPrompt
|
|
996
|
-
}, {
|
|
997
|
-
role: "user",
|
|
998
|
-
content: userPrompt
|
|
999
|
-
}],
|
|
1000
|
-
model: options.model ?? "openai/gpt-oss-20b",
|
|
1001
|
-
temperature: .3,
|
|
1002
|
-
max_tokens: 4096
|
|
1003
|
-
});
|
|
1004
|
-
const rawContent = completion.choices[0]?.message?.content;
|
|
1005
|
-
const content = extractContentText(rawContent);
|
|
1006
|
-
debug("generateCodeReview response: choices=%d, finishReason=%s, contentLen=%d", completion.choices.length, completion.choices[0]?.finish_reason ?? "(none)", content.length);
|
|
1007
|
-
if (!content) {
|
|
1008
|
-
const reasoning = completion.choices[0]?.message?.reasoning;
|
|
1009
|
-
if (reasoning) {
|
|
1010
|
-
const derived = deriveMessageFromReasoning(reasoning);
|
|
1011
|
-
if (derived) {
|
|
1012
|
-
debug("generateCodeReview: derived from reasoning");
|
|
1013
|
-
return derived;
|
|
1014
|
-
}
|
|
1015
|
-
}
|
|
1016
|
-
return "NO_ISSUES_FOUND";
|
|
1017
|
-
}
|
|
1018
|
-
return content;
|
|
1019
|
-
} catch (error) {
|
|
1020
|
-
debug("generateCodeReview error: %s", error instanceof Error ? error.message : String(error));
|
|
1021
|
-
throw mapGroqError$1(error);
|
|
814
|
+
throw mapGroqError(error, options.provider ? formatProviderName(options.provider) : void 0);
|
|
1022
815
|
}
|
|
1023
816
|
}
|
|
1024
817
|
//#endregion
|
|
1025
|
-
//#region src/
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
}, {
|
|
1057
|
-
label: "No",
|
|
1058
|
-
value: "no"
|
|
1059
|
-
}]
|
|
1060
|
-
});
|
|
1061
|
-
if (isCancel(shouldCopy) || shouldCopy === "no") {
|
|
1062
|
-
outro(dim("Done."));
|
|
1063
|
-
return;
|
|
1064
|
-
}
|
|
1065
|
-
if (await copyToClipboard(report)) outro(green("Report copied to clipboard. You can paste it anywhere for fixes."));
|
|
1066
|
-
else outro(red("Failed to copy to clipboard. Install xclip, wl-copy, or xsel."));
|
|
818
|
+
//#region src/services/checks.ts
|
|
819
|
+
/** Config file names, checked in priority order (matches lint-staged naming conventions) */
|
|
820
|
+
const CONFIG_FILES = [
|
|
821
|
+
".cmintrc",
|
|
822
|
+
".cmintrc.json",
|
|
823
|
+
".cmintrc.mjs",
|
|
824
|
+
".cmintrc.mts",
|
|
825
|
+
".cmintrc.js",
|
|
826
|
+
".cmintrc.ts",
|
|
827
|
+
".cmintrc.cjs",
|
|
828
|
+
".cmintrc.cts",
|
|
829
|
+
"cmint.config.mjs",
|
|
830
|
+
"cmint.config.mts",
|
|
831
|
+
"cmint.config.js",
|
|
832
|
+
"cmint.config.ts",
|
|
833
|
+
"cmint.config.cjs",
|
|
834
|
+
"cmint.config.cts"
|
|
835
|
+
];
|
|
836
|
+
/**
|
|
837
|
+
* Detect whether the repo has a cmint config file.
|
|
838
|
+
* Returns the config file path, or null if none found.
|
|
839
|
+
*/
|
|
840
|
+
async function detectConfig(repoRoot) {
|
|
841
|
+
debug("detectConfig: checking for config in %s", repoRoot);
|
|
842
|
+
for (const name of CONFIG_FILES) try {
|
|
843
|
+
await access(join(repoRoot, name), constants.R_OK);
|
|
844
|
+
debug("detectConfig: found %s", name);
|
|
845
|
+
return join(repoRoot, name);
|
|
846
|
+
} catch {}
|
|
847
|
+
debug("detectConfig: no config file found");
|
|
848
|
+
return null;
|
|
1067
849
|
}
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
850
|
+
/**
|
|
851
|
+
* Load and validate the cmint config from a repo root.
|
|
852
|
+
* Throws if the loaded value is missing or not a non-null object.
|
|
853
|
+
*/
|
|
854
|
+
async function loadConfig(repoRoot) {
|
|
855
|
+
const configPath = await detectConfig(repoRoot);
|
|
856
|
+
if (!configPath) throw new Error("No cmint config file found");
|
|
857
|
+
debug("loadConfig: loading %s", configPath);
|
|
858
|
+
const ext = extname(configPath);
|
|
859
|
+
const isJSON = ext === ".json";
|
|
860
|
+
const needsJiti = ext === ".ts" || ext === ".mts" || ext === ".cts" || ext === ".cjs";
|
|
861
|
+
let config;
|
|
862
|
+
if (isJSON) {
|
|
863
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
864
|
+
config = JSON.parse(raw);
|
|
865
|
+
} else if (needsJiti) {
|
|
866
|
+
const { createJiti } = await import("jiti");
|
|
867
|
+
const mod = await createJiti(import.meta.url, {}).import(configPath);
|
|
868
|
+
config = mod.default ?? mod;
|
|
869
|
+
} else config = (await import(configPath)).default;
|
|
870
|
+
if (!config || typeof config !== "object" || Array.isArray(config)) throw new Error("cmint config must export a non-null object with glob→command mappings");
|
|
871
|
+
debug("loadConfig: loaded %d glob patterns", Object.keys(config).length);
|
|
872
|
+
return config;
|
|
1075
873
|
}
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
874
|
+
/**
|
|
875
|
+
* Run a shell command and capture its output.
|
|
876
|
+
* Returns a CheckResult with ok=true on success (exit 0), ok=false on failure.
|
|
877
|
+
* Handles ENOENT (command not found) and timeout errors gracefully.
|
|
878
|
+
*/
|
|
879
|
+
async function runCommand(command, timeout, repoRoot) {
|
|
880
|
+
debug("runCommand: %s (timeout: %dms)", command, timeout);
|
|
881
|
+
const tool = extractToolName(command) ?? command.split(" ")[0];
|
|
1079
882
|
try {
|
|
1080
|
-
const
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
883
|
+
const result = await execa(command, {
|
|
884
|
+
shell: true,
|
|
885
|
+
reject: false,
|
|
886
|
+
timeout,
|
|
887
|
+
all: true,
|
|
888
|
+
preferLocal: true,
|
|
889
|
+
...repoRoot ? { localDir: repoRoot } : {}
|
|
1085
890
|
});
|
|
1086
|
-
|
|
1087
|
-
|
|
891
|
+
const ok = !result.failed;
|
|
892
|
+
debug("runCommand: %s — ok=%s", tool, ok);
|
|
893
|
+
return {
|
|
894
|
+
ok,
|
|
895
|
+
tool,
|
|
896
|
+
command,
|
|
897
|
+
stdout: result.stdout ?? "",
|
|
898
|
+
stderr: result.stderr ?? "",
|
|
899
|
+
files: []
|
|
900
|
+
};
|
|
1088
901
|
} catch (err) {
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
902
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
903
|
+
const isTimedOut = msg.toLowerCase().includes("timed out");
|
|
904
|
+
const isNotFound = msg.toLowerCase().includes("enoent") || msg.toLowerCase().includes("not found");
|
|
905
|
+
debug("runCommand: %s — error: %s", tool, msg);
|
|
906
|
+
return {
|
|
907
|
+
ok: false,
|
|
908
|
+
tool,
|
|
909
|
+
command,
|
|
910
|
+
stdout: "",
|
|
911
|
+
stderr: isTimedOut ? `Check timed out after ${timeout}ms` : isNotFound ? `Command not found: ${tool}` : msg,
|
|
912
|
+
files: []
|
|
913
|
+
};
|
|
1092
914
|
}
|
|
1093
915
|
}
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
"Provide a structured report with severity, location, issue, and fix suggestion for each finding.",
|
|
1112
|
-
"If no issues found, respond with: NO_ISSUES_FOUND"
|
|
1113
|
-
].join("\n");
|
|
1114
|
-
const { stdout } = await import("execa").then((m) => m.execa("opencode", [
|
|
1115
|
-
"run",
|
|
1116
|
-
prompt,
|
|
1117
|
-
"--dir",
|
|
1118
|
-
repoRoot
|
|
1119
|
-
], {
|
|
1120
|
-
timeout: 12e4,
|
|
1121
|
-
reject: false
|
|
1122
|
-
}));
|
|
1123
|
-
s.stop("Review complete");
|
|
1124
|
-
return stdout || "OpenCode review completed but no output captured.";
|
|
1125
|
-
} catch (err) {
|
|
1126
|
-
s.stop(red("OpenCode review failed."));
|
|
1127
|
-
debug("reviewWithOpenCode error:", err instanceof Error ? err.message : String(err));
|
|
1128
|
-
throw new Error(`OpenCode review failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1129
|
-
}
|
|
916
|
+
/**
|
|
917
|
+
* Filter a list of file paths by a picomatch glob pattern.
|
|
918
|
+
* When the pattern contains no `/`, files are matched at any depth (matchBase).
|
|
919
|
+
* Dotfiles are included (dot: true).
|
|
920
|
+
*/
|
|
921
|
+
function matchFiles(pattern, files) {
|
|
922
|
+
if (!pattern) return [];
|
|
923
|
+
const matchBase = !pattern.includes("/");
|
|
924
|
+
const isMatch = picomatch(pattern, {
|
|
925
|
+
dot: true,
|
|
926
|
+
posixSlashes: true,
|
|
927
|
+
strictBrackets: true
|
|
928
|
+
});
|
|
929
|
+
return files.filter((f) => {
|
|
930
|
+
const parts = f.split("/");
|
|
931
|
+
return isMatch(matchBase ? parts[parts.length - 1] : f);
|
|
932
|
+
});
|
|
1130
933
|
}
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
}
|
|
1139
|
-
const opencodeAvailable = await isOpenCodeAvailable();
|
|
1140
|
-
const s = spinner();
|
|
1141
|
-
s.start(opencodeAvailable ? "Running OpenCode review..." : "Running Groq review...");
|
|
1142
|
-
try {
|
|
1143
|
-
const report = opencodeAvailable ? await reviewWithOpenCode(diffResult.diff, diffResult.files) : await reviewWithGroq(diffResult.diff, diffResult.files);
|
|
1144
|
-
s.stop("Review complete");
|
|
1145
|
-
await showReviewResults(report);
|
|
1146
|
-
} catch (err) {
|
|
1147
|
-
s.stop(red("Review failed."));
|
|
1148
|
-
debug("Code review error:", err instanceof Error ? err.message : String(err));
|
|
1149
|
-
outro(red(err instanceof Error ? err.message : String(err)));
|
|
1150
|
-
}
|
|
934
|
+
/**
|
|
935
|
+
* Build a shell command string from a base command and a list of file paths.
|
|
936
|
+
* File paths containing spaces are wrapped in double quotes.
|
|
937
|
+
* If no files are provided, the base command is returned as-is.
|
|
938
|
+
*/
|
|
939
|
+
function buildCommand(command, files) {
|
|
940
|
+
if (files.length === 0) return command;
|
|
941
|
+
return `${command} ${files.map((f) => f.includes(" ") ? `"${f}"` : f).join(" ")}`;
|
|
1151
942
|
}
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
943
|
+
/**
|
|
944
|
+
* Resolve config commands for a glob entry into an array of command strings.
|
|
945
|
+
* Function commands receive matched filenames; string commands are used as-is.
|
|
946
|
+
*/
|
|
947
|
+
function resolveCommands(commands, matchedFiles) {
|
|
948
|
+
if (typeof commands === "function") {
|
|
949
|
+
const resolved = commands(matchedFiles);
|
|
950
|
+
return Array.isArray(resolved) ? resolved : [resolved];
|
|
1157
951
|
}
|
|
1158
|
-
|
|
1159
|
-
const shouldCopy = await clackSelect({
|
|
1160
|
-
message: "Copy review report to clipboard?",
|
|
1161
|
-
options: [{
|
|
1162
|
-
label: "Yes, copy to clipboard",
|
|
1163
|
-
value: "yes"
|
|
1164
|
-
}, {
|
|
1165
|
-
label: "No",
|
|
1166
|
-
value: "no"
|
|
1167
|
-
}]
|
|
1168
|
-
});
|
|
1169
|
-
if (isCancel(shouldCopy) || shouldCopy !== "yes") return;
|
|
1170
|
-
if (await copyToClipboard(report)) log.info(green("Report copied to clipboard."));
|
|
1171
|
-
else log.warn(red("Failed to copy to clipboard."));
|
|
952
|
+
return Array.isArray(commands) ? commands : [commands];
|
|
1172
953
|
}
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
value: "edit"
|
|
1186
|
-
},
|
|
1187
|
-
{
|
|
1188
|
-
label: "Review with OpenCode",
|
|
1189
|
-
value: "review"
|
|
1190
|
-
},
|
|
1191
|
-
{
|
|
1192
|
-
label: "Cancel",
|
|
1193
|
-
value: "cancel"
|
|
1194
|
-
}
|
|
1195
|
-
]
|
|
954
|
+
/**
|
|
955
|
+
* Run resolved commands for a single glob entry, appending results.
|
|
956
|
+
* Returns false if any command fails (for fail-fast signaling).
|
|
957
|
+
*/
|
|
958
|
+
async function runCommandsForGlob(cmds, isFunction, matchedFiles, timeout, results, repoRoot) {
|
|
959
|
+
for (const cmd of cmds) {
|
|
960
|
+
const fullCommand = isFunction ? cmd : buildCommand(cmd, matchedFiles);
|
|
961
|
+
debug("runCommandsForGlob: running '%s'", fullCommand);
|
|
962
|
+
const result = await runCommand(fullCommand, timeout, repoRoot);
|
|
963
|
+
results.push({
|
|
964
|
+
...result,
|
|
965
|
+
files: matchedFiles
|
|
1196
966
|
});
|
|
1197
|
-
if (
|
|
1198
|
-
debug("
|
|
1199
|
-
return
|
|
1200
|
-
}
|
|
1201
|
-
if (review === "use") {
|
|
1202
|
-
debug("User accepted message");
|
|
1203
|
-
return message;
|
|
967
|
+
if (!result.ok) {
|
|
968
|
+
debug("runCommandsForGlob: check failed, stopping (fail-fast)");
|
|
969
|
+
return false;
|
|
1204
970
|
}
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
971
|
+
}
|
|
972
|
+
return true;
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Run all user-defined checks from .cmintrc against staged files.
|
|
976
|
+
* Returns a no-op result when no config exists.
|
|
977
|
+
* Fail-fast: stops on first error.
|
|
978
|
+
*/
|
|
979
|
+
async function runAllChecks(repoRoot, stagedFiles, timeout) {
|
|
980
|
+
debug("runAllChecks: %d staged files, checking for config in %s", stagedFiles.length, repoRoot);
|
|
981
|
+
if (!await detectConfig(repoRoot)) {
|
|
982
|
+
debug("runAllChecks: no config found, skipping checks");
|
|
983
|
+
return {
|
|
984
|
+
ok: true,
|
|
985
|
+
results: []
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
const config = await loadConfig(repoRoot);
|
|
989
|
+
debug("runAllChecks: loaded config with %d patterns", Object.keys(config).length);
|
|
990
|
+
const results = [];
|
|
991
|
+
for (const [glob, commands] of Object.entries(config)) {
|
|
992
|
+
const matchedFiles = matchFiles(glob, stagedFiles);
|
|
993
|
+
const isFunction = typeof commands === "function";
|
|
994
|
+
if (matchedFiles.length === 0) {
|
|
995
|
+
debug("runAllChecks: no files matched pattern '%s'", glob);
|
|
1215
996
|
continue;
|
|
1216
997
|
}
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
998
|
+
debug("runAllChecks: pattern '%s' matched %d files", glob, matchedFiles.length);
|
|
999
|
+
if (!await runCommandsForGlob(resolveCommands(commands, matchedFiles), isFunction, matchedFiles, timeout, results, repoRoot)) return {
|
|
1000
|
+
ok: false,
|
|
1001
|
+
results
|
|
1002
|
+
};
|
|
1221
1003
|
}
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
return createHash("sha256").update(repoPath).digest("hex").slice(0, 12);
|
|
1228
|
-
}
|
|
1229
|
-
function cachePath(repoPath) {
|
|
1230
|
-
return join(CACHE_DIR, `${repoHash(repoPath)}.json`);
|
|
1231
|
-
}
|
|
1232
|
-
async function saveCachedCommit(repoPath, message) {
|
|
1233
|
-
await mkdir(CACHE_DIR, { recursive: true });
|
|
1234
|
-
const data = {
|
|
1235
|
-
message,
|
|
1236
|
-
timestamp: Date.now(),
|
|
1237
|
-
repoPath
|
|
1004
|
+
const ok = results.every((r) => r.ok);
|
|
1005
|
+
debug("runAllChecks: complete — ok=%s, %d results", ok, results.length);
|
|
1006
|
+
return {
|
|
1007
|
+
ok,
|
|
1008
|
+
results
|
|
1238
1009
|
};
|
|
1239
|
-
const path = cachePath(repoPath);
|
|
1240
|
-
debug("saveCachedCommit: saving to %s", path);
|
|
1241
|
-
await writeFile(path, JSON.stringify(data, null, 2), "utf8");
|
|
1242
|
-
}
|
|
1243
|
-
async function loadCachedCommit(repoPath) {
|
|
1244
|
-
const path = cachePath(repoPath);
|
|
1245
|
-
debug("loadCachedCommit: loading from %s", path);
|
|
1246
|
-
try {
|
|
1247
|
-
const raw = await readFile(path, "utf8");
|
|
1248
|
-
const data = JSON.parse(raw);
|
|
1249
|
-
debug("loadCachedCommit: found message from %s", new Date(data.timestamp).toISOString());
|
|
1250
|
-
return data;
|
|
1251
|
-
} catch {
|
|
1252
|
-
debug("loadCachedCommit: no cached commit found");
|
|
1253
|
-
return null;
|
|
1254
|
-
}
|
|
1255
1010
|
}
|
|
1256
1011
|
//#endregion
|
|
1257
1012
|
//#region src/services/grouping.ts
|
|
1258
|
-
function mapGroqError(error) {
|
|
1259
|
-
if (error instanceof Groq.AuthenticationError) return /* @__PURE__ */ new Error("Invalid GROQ_API_KEY. Run: cmint config set GROQ_API_KEY=<key>");
|
|
1260
|
-
if (error instanceof Groq.RateLimitError) return /* @__PURE__ */ new Error("Rate limited by Groq. Please wait and try again.");
|
|
1261
|
-
if (error instanceof Groq.APIConnectionTimeoutError) return /* @__PURE__ */ new Error("Request timed out. Check your network and try again.");
|
|
1262
|
-
if (error instanceof Groq.APIError) return /* @__PURE__ */ new Error(`Groq API error: ${error.message}`);
|
|
1263
|
-
return /* @__PURE__ */ new Error(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`);
|
|
1264
|
-
}
|
|
1265
1013
|
function matchesExcludePattern(filePath, pattern) {
|
|
1266
1014
|
if (pattern === filePath) return true;
|
|
1267
1015
|
if (pattern.endsWith("/**")) {
|
|
@@ -1278,7 +1026,9 @@ function matchesExcludePattern(filePath, pattern) {
|
|
|
1278
1026
|
const LOCKFILE_COMPANIONS = {
|
|
1279
1027
|
"package-lock.json": "package.json",
|
|
1280
1028
|
"pnpm-lock.yaml": "package.json",
|
|
1281
|
-
"yarn.lock": "package.json"
|
|
1029
|
+
"yarn.lock": "package.json",
|
|
1030
|
+
"bun.lock": "package.json",
|
|
1031
|
+
"bun.lockb": "package.json"
|
|
1282
1032
|
};
|
|
1283
1033
|
function filterExcludedFiles(files) {
|
|
1284
1034
|
const patterns = getDefaultExcludes();
|
|
@@ -1352,7 +1102,7 @@ function parseGroupingResponse(content) {
|
|
|
1352
1102
|
});
|
|
1353
1103
|
return rawGroups;
|
|
1354
1104
|
}
|
|
1355
|
-
async function generateGroups(files, apiKey, model, timeout) {
|
|
1105
|
+
async function generateGroups(files, apiKey, model, timeout, provider, proxy) {
|
|
1356
1106
|
debug("generateGroups: %d files, model=%s", files.length, model ?? "default");
|
|
1357
1107
|
const { included, excluded } = filterExcludedFiles(files);
|
|
1358
1108
|
if (included.length === 0) {
|
|
@@ -1367,9 +1117,12 @@ async function generateGroups(files, apiKey, model, timeout) {
|
|
|
1367
1117
|
const userPrompt = buildGroupingUserPrompt(summary);
|
|
1368
1118
|
debug("File summary:\n%s", summary);
|
|
1369
1119
|
debug("User prompt length: %d chars", userPrompt.length);
|
|
1370
|
-
const client =
|
|
1120
|
+
const { client, model: resolvedModel } = createProvider({
|
|
1121
|
+
provider: provider ?? "groq",
|
|
1371
1122
|
apiKey,
|
|
1372
|
-
|
|
1123
|
+
modelOverride: model,
|
|
1124
|
+
timeout: timeout ?? 6e4,
|
|
1125
|
+
baseURLOverride: proxy
|
|
1373
1126
|
});
|
|
1374
1127
|
try {
|
|
1375
1128
|
const completion = await client.chat.completions.create({
|
|
@@ -1380,7 +1133,7 @@ async function generateGroups(files, apiKey, model, timeout) {
|
|
|
1380
1133
|
role: "user",
|
|
1381
1134
|
content: userPrompt
|
|
1382
1135
|
}],
|
|
1383
|
-
model:
|
|
1136
|
+
model: resolvedModel,
|
|
1384
1137
|
temperature: .3,
|
|
1385
1138
|
max_tokens: 2048
|
|
1386
1139
|
});
|
|
@@ -1399,7 +1152,7 @@ async function generateGroups(files, apiKey, model, timeout) {
|
|
|
1399
1152
|
};
|
|
1400
1153
|
} catch (error) {
|
|
1401
1154
|
debug("generateGroups error: %s", error instanceof Error ? error.message : String(error));
|
|
1402
|
-
throw mapGroqError(error);
|
|
1155
|
+
throw mapGroqError(error, provider ? formatProviderName(provider) : void 0);
|
|
1403
1156
|
}
|
|
1404
1157
|
}
|
|
1405
1158
|
function validateGroups(groups, allFiles) {
|
|
@@ -1465,36 +1218,372 @@ async function showGroupingConfirmation(groups, excluded) {
|
|
|
1465
1218
|
function showGroupProgress(current, total, groupName) {
|
|
1466
1219
|
p.log.info(`Commit group ${current} of ${total}: ${cyan(`"${groupName}"`)}`);
|
|
1467
1220
|
}
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1221
|
+
const statusLabel = (status) => {
|
|
1222
|
+
switch (status) {
|
|
1223
|
+
case "M": return yellow("M");
|
|
1224
|
+
case "A": return green("A");
|
|
1225
|
+
case "D": return red("D");
|
|
1226
|
+
case "?":
|
|
1227
|
+
case "??": return cyan("?");
|
|
1228
|
+
default: return dim(status);
|
|
1229
|
+
}
|
|
1230
|
+
};
|
|
1231
|
+
/** Display combined view: files with status indicators grouped by commit group */
|
|
1232
|
+
function showGroupedFiles(groups, changedFiles) {
|
|
1233
|
+
const statusMap = new Map(changedFiles.map((f) => [f.path, f.status]));
|
|
1234
|
+
const lines = [];
|
|
1235
|
+
for (let i = 0; i < groups.length; i++) {
|
|
1236
|
+
const group = groups[i];
|
|
1237
|
+
lines.push(`${bold(group.name)} ${dim("—")} ${group.files.length} file${group.files.length !== 1 ? "s" : ""}`);
|
|
1238
|
+
for (const file of group.files) {
|
|
1239
|
+
const status = statusMap.get(file) ?? "M";
|
|
1240
|
+
lines.push(` ${statusLabel(status)} ${file}`);
|
|
1486
1241
|
}
|
|
1487
|
-
|
|
1488
|
-
debug("API key saved to config");
|
|
1242
|
+
if (i < groups.length - 1) lines.push("");
|
|
1489
1243
|
}
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1244
|
+
p.note(lines.join("\n"), "Commit groups");
|
|
1245
|
+
}
|
|
1246
|
+
//#endregion
|
|
1247
|
+
//#region src/services/clipboard.ts
|
|
1248
|
+
async function copyToClipboard(content) {
|
|
1249
|
+
for (const [cmd, args] of [
|
|
1250
|
+
["wl-copy", []],
|
|
1251
|
+
["xclip", ["-selection", "clipboard"]],
|
|
1252
|
+
["xsel", ["--clipboard", "--input"]],
|
|
1253
|
+
["pbcopy", []]
|
|
1254
|
+
]) try {
|
|
1255
|
+
if (await new Promise((resolve) => {
|
|
1256
|
+
const child = spawn(cmd, args, { stdio: [
|
|
1257
|
+
"pipe",
|
|
1258
|
+
"ignore",
|
|
1259
|
+
"ignore"
|
|
1260
|
+
] });
|
|
1261
|
+
let settled = false;
|
|
1262
|
+
const done = (result) => {
|
|
1263
|
+
if (settled) return;
|
|
1264
|
+
settled = true;
|
|
1265
|
+
resolve(result);
|
|
1266
|
+
};
|
|
1267
|
+
child.on("error", () => done(false));
|
|
1268
|
+
child.on("exit", (code) => {
|
|
1269
|
+
if (code !== 0) done(false);
|
|
1270
|
+
});
|
|
1271
|
+
child.stdin.write(content, (err) => {
|
|
1272
|
+
if (err) {
|
|
1273
|
+
done(false);
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
child.stdin.end(() => {
|
|
1277
|
+
child.unref();
|
|
1278
|
+
done(true);
|
|
1279
|
+
});
|
|
1280
|
+
});
|
|
1281
|
+
})) return true;
|
|
1282
|
+
} catch {}
|
|
1283
|
+
return false;
|
|
1284
|
+
}
|
|
1285
|
+
//#endregion
|
|
1286
|
+
//#region src/ui/menu.ts
|
|
1287
|
+
async function showStagingMenu(files, hasChecks) {
|
|
1288
|
+
debug("showStagingMenu: %d files", files.length);
|
|
1289
|
+
const statusLabel = (status) => {
|
|
1290
|
+
switch (status) {
|
|
1291
|
+
case "M": return yellow("M");
|
|
1292
|
+
case "A": return green("A");
|
|
1293
|
+
case "D": return red("D");
|
|
1294
|
+
case "?":
|
|
1295
|
+
case "??": return cyan("?");
|
|
1296
|
+
default: return dim(status);
|
|
1297
|
+
}
|
|
1298
|
+
};
|
|
1299
|
+
const sorted = [...files].sort((a, b) => {
|
|
1300
|
+
if (a.staged !== b.staged) return a.staged ? -1 : 1;
|
|
1301
|
+
return a.path.localeCompare(b.path);
|
|
1302
|
+
});
|
|
1303
|
+
const stagedFiles = sorted.filter((f) => f.staged);
|
|
1304
|
+
const unstagedFiles = sorted.filter((f) => !f.staged);
|
|
1305
|
+
const lines = [];
|
|
1306
|
+
if (stagedFiles.length > 0) lines.push(green(bold("Staged:")), ...stagedFiles.map((f) => ` ${statusLabel(f.status)} ${f.path}`));
|
|
1307
|
+
if (unstagedFiles.length > 0) {
|
|
1308
|
+
if (lines.length > 0) lines.push("");
|
|
1309
|
+
lines.push(yellow(bold("Changed:")), ...unstagedFiles.map((f) => ` ${statusLabel(f.status)} ${f.path}`));
|
|
1310
|
+
}
|
|
1311
|
+
p.note(lines.join("\n"), `${files.length} file${files.length !== 1 ? "s" : ""}`);
|
|
1312
|
+
const choice = await p.select({
|
|
1313
|
+
message: "Stage files for commit:",
|
|
1314
|
+
options: [
|
|
1315
|
+
{
|
|
1316
|
+
label: "Auto-group into commits",
|
|
1317
|
+
value: "autogroup",
|
|
1318
|
+
hint: "LLM groups files into logical commits"
|
|
1319
|
+
},
|
|
1320
|
+
...stagedFiles.length > 0 ? [{
|
|
1321
|
+
label: "Commit staged files only",
|
|
1322
|
+
value: "staged",
|
|
1323
|
+
hint: `${stagedFiles.length} file${stagedFiles.length !== 1 ? "s" : ""} already staged`
|
|
1324
|
+
}] : [],
|
|
1325
|
+
{
|
|
1326
|
+
label: "Stage all files",
|
|
1327
|
+
value: "all",
|
|
1328
|
+
hint: `${files.length} file${files.length !== 1 ? "s" : ""}`
|
|
1329
|
+
},
|
|
1330
|
+
...hasChecks ? [{
|
|
1331
|
+
label: "Run checks",
|
|
1332
|
+
value: "checks",
|
|
1333
|
+
hint: "Pre-flight checks from cmint config"
|
|
1334
|
+
}] : [],
|
|
1335
|
+
{
|
|
1336
|
+
label: "Select files...",
|
|
1337
|
+
value: "select"
|
|
1338
|
+
},
|
|
1339
|
+
{
|
|
1340
|
+
label: "Cancel",
|
|
1341
|
+
value: "cancel"
|
|
1342
|
+
}
|
|
1343
|
+
]
|
|
1344
|
+
});
|
|
1345
|
+
if (p.isCancel(choice) || choice === "cancel") return null;
|
|
1346
|
+
if (choice === "autogroup") return "autogroup";
|
|
1347
|
+
if (choice === "checks") return "checks";
|
|
1348
|
+
if (choice === "staged") return "staged";
|
|
1349
|
+
if (choice === "all") return {
|
|
1350
|
+
files: files.map((f) => f.path),
|
|
1351
|
+
all: true
|
|
1352
|
+
};
|
|
1353
|
+
const selected = await p.multiselect({
|
|
1354
|
+
message: "Select files to stage:",
|
|
1355
|
+
options: sorted.map((f) => ({
|
|
1356
|
+
label: `${statusLabel(f.status)} ${f.path}`,
|
|
1357
|
+
value: f.path
|
|
1358
|
+
})),
|
|
1359
|
+
required: true
|
|
1360
|
+
});
|
|
1361
|
+
if (p.isCancel(selected)) return null;
|
|
1362
|
+
return {
|
|
1363
|
+
files: selected,
|
|
1364
|
+
all: false
|
|
1365
|
+
};
|
|
1366
|
+
}
|
|
1367
|
+
async function showRecoveryMenu(errors, onRetry, onSkipHooks, onRestage, message, rawStderr) {
|
|
1368
|
+
debug("showRecoveryMenu: %d errors", errors.length);
|
|
1369
|
+
let clipboardCopied = false;
|
|
1370
|
+
let showNote = true;
|
|
1371
|
+
while (true) {
|
|
1372
|
+
if (showNote) {
|
|
1373
|
+
p.note(errors.map((e) => ` ${red("•")} [${e.tool}] ${e.message}`).join("\n"), red(bold("Pre-commit hook failed")));
|
|
1374
|
+
showNote = false;
|
|
1375
|
+
}
|
|
1376
|
+
const choice = await p.select({
|
|
1377
|
+
message: "What do you want to do?",
|
|
1378
|
+
options: [
|
|
1379
|
+
{
|
|
1380
|
+
label: clipboardCopied ? `${green("✓")} Copy error report to clipboard` : "Copy error report to clipboard",
|
|
1381
|
+
value: "clipboard",
|
|
1382
|
+
hint: clipboardCopied ? "Copied!" : "Paste into another terminal for an AI agent"
|
|
1383
|
+
},
|
|
1384
|
+
{
|
|
1385
|
+
label: "View full error output",
|
|
1386
|
+
value: "view",
|
|
1387
|
+
hint: "Show the raw stderr from hooks"
|
|
1388
|
+
},
|
|
1389
|
+
{
|
|
1390
|
+
label: "Skip hooks and commit (--no-verify)",
|
|
1391
|
+
value: "skip",
|
|
1392
|
+
hint: "Commit anyway, fix later"
|
|
1393
|
+
},
|
|
1394
|
+
{
|
|
1395
|
+
label: "Re-stage files and retry",
|
|
1396
|
+
value: "restage",
|
|
1397
|
+
hint: "Pick up fixes from another terminal"
|
|
1398
|
+
},
|
|
1399
|
+
{
|
|
1400
|
+
label: "Edit commit message",
|
|
1401
|
+
value: "edit",
|
|
1402
|
+
hint: "Modify the message before retrying"
|
|
1403
|
+
},
|
|
1404
|
+
{
|
|
1405
|
+
label: "Cancel",
|
|
1406
|
+
value: "cancel"
|
|
1407
|
+
}
|
|
1408
|
+
]
|
|
1409
|
+
});
|
|
1410
|
+
if (p.isCancel(choice)) {
|
|
1411
|
+
debug("showRecoveryMenu: user cancelled");
|
|
1412
|
+
p.outro(yellow("Cancelled. Message cached for --retry."));
|
|
1413
|
+
return "cancelled";
|
|
1414
|
+
}
|
|
1415
|
+
debug("showRecoveryMenu: user chose %s", choice);
|
|
1416
|
+
switch (choice) {
|
|
1417
|
+
case "clipboard":
|
|
1418
|
+
if (await copyToClipboard(rawStderr)) {
|
|
1419
|
+
clipboardCopied = true;
|
|
1420
|
+
p.log.step(green("Copied to clipboard."));
|
|
1421
|
+
} else p.log.warn(red("No clipboard tool found. Install xclip, wl-copy, or xsel."));
|
|
1422
|
+
continue;
|
|
1423
|
+
case "view":
|
|
1424
|
+
p.note(rawStderr.trim() || "(no raw output)", "Full error output");
|
|
1425
|
+
showNote = true;
|
|
1426
|
+
continue;
|
|
1427
|
+
case "skip":
|
|
1428
|
+
p.log.info(yellow("Committing with --no-verify..."));
|
|
1429
|
+
if (await onSkipHooks(message)) {
|
|
1430
|
+
p.outro(green("Committed (hooks skipped)."));
|
|
1431
|
+
return "committed";
|
|
1432
|
+
} else {
|
|
1433
|
+
p.outro(red("Commit failed even with --no-verify."));
|
|
1434
|
+
return "failed";
|
|
1435
|
+
}
|
|
1436
|
+
case "restage":
|
|
1437
|
+
p.log.info(cyan("Re-staging and retrying..."));
|
|
1438
|
+
if (await onRestage()) {
|
|
1439
|
+
p.outro(green("Committed successfully."));
|
|
1440
|
+
return "committed";
|
|
1441
|
+
}
|
|
1442
|
+
showNote = true;
|
|
1443
|
+
continue;
|
|
1444
|
+
case "edit": {
|
|
1445
|
+
const edited = await p.text({
|
|
1446
|
+
message: "Edit commit message:",
|
|
1447
|
+
initialValue: message,
|
|
1448
|
+
validate: (v) => v?.trim() ? void 0 : "Message cannot be empty"
|
|
1449
|
+
});
|
|
1450
|
+
if (p.isCancel(edited)) {
|
|
1451
|
+
p.outro(yellow("Cancelled. Message cached for --retry."));
|
|
1452
|
+
return "cancelled";
|
|
1453
|
+
}
|
|
1454
|
+
if (await onRetry()) {
|
|
1455
|
+
p.outro(green("Committed successfully."));
|
|
1456
|
+
return "committed";
|
|
1457
|
+
} else {
|
|
1458
|
+
p.outro(red("Commit failed again."));
|
|
1459
|
+
return "failed";
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
case "cancel":
|
|
1463
|
+
p.outro(dim("Message cached for --retry."));
|
|
1464
|
+
return "cancelled";
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
async function showCheckFailureMenu(errors, rawStderr) {
|
|
1469
|
+
debug("showCheckFailureMenu: %d errors", errors.length);
|
|
1470
|
+
let clipboardCopied = false;
|
|
1471
|
+
p.note(errors.map((e) => ` ${red("•")} [${e.tool}] ${e.message}`).join("\n"), red("Pre-commit check failed"));
|
|
1472
|
+
while (true) {
|
|
1473
|
+
const choice = await p.select({
|
|
1474
|
+
message: "What do you want to do?",
|
|
1475
|
+
options: [
|
|
1476
|
+
{
|
|
1477
|
+
label: clipboardCopied ? `${green("✓")} Copy error report to clipboard` : "Copy error report to clipboard",
|
|
1478
|
+
value: "copy"
|
|
1479
|
+
},
|
|
1480
|
+
{
|
|
1481
|
+
label: "View full error output",
|
|
1482
|
+
value: "view",
|
|
1483
|
+
hint: "Show the raw stderr from checks"
|
|
1484
|
+
},
|
|
1485
|
+
{
|
|
1486
|
+
label: "Skip checks and commit",
|
|
1487
|
+
value: "skip"
|
|
1488
|
+
},
|
|
1489
|
+
{
|
|
1490
|
+
label: "Cancel",
|
|
1491
|
+
value: "cancel"
|
|
1492
|
+
}
|
|
1493
|
+
]
|
|
1494
|
+
});
|
|
1495
|
+
if (p.isCancel(choice)) {
|
|
1496
|
+
debug("showCheckFailureMenu: user cancelled");
|
|
1497
|
+
return "cancelled";
|
|
1498
|
+
}
|
|
1499
|
+
debug("showCheckFailureMenu: user chose %s", choice);
|
|
1500
|
+
switch (choice) {
|
|
1501
|
+
case "copy":
|
|
1502
|
+
if (await copyToClipboard(rawStderr)) {
|
|
1503
|
+
clipboardCopied = true;
|
|
1504
|
+
p.log.step(green("Copied to clipboard."));
|
|
1505
|
+
} else p.log.warn(red("No clipboard tool found. Install xclip, wl-copy, or xsel."));
|
|
1506
|
+
continue;
|
|
1507
|
+
case "view":
|
|
1508
|
+
p.note(rawStderr.trim() || "(no raw output)", "Full error output");
|
|
1509
|
+
continue;
|
|
1510
|
+
case "skip":
|
|
1511
|
+
p.log.info("Skipping checks and proceeding with commit...");
|
|
1512
|
+
return "skipped";
|
|
1513
|
+
case "cancel":
|
|
1514
|
+
p.outro(dim("Cancelled."));
|
|
1515
|
+
return "cancelled";
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
//#endregion
|
|
1520
|
+
//#region src/commands/auto-group.ts
|
|
1521
|
+
async function runAutoGroupFlow(changedFiles, flags) {
|
|
1522
|
+
const { included, excluded } = filterExcludedFiles(changedFiles);
|
|
1523
|
+
if (excluded.length > 0) {
|
|
1524
|
+
debug("Committing %d excluded files upfront:", excluded.length, excluded);
|
|
1525
|
+
const message = buildExcludedFilesMessage(excluded);
|
|
1526
|
+
await resetStaging();
|
|
1527
|
+
await stageFiles(excluded);
|
|
1528
|
+
const headBefore = await getHead();
|
|
1529
|
+
const commitResult = await attemptCommit(message);
|
|
1530
|
+
const headAfter = await getHead();
|
|
1531
|
+
if (commitResult.ok || headBefore !== headAfter) debug("Excluded files committed:", message);
|
|
1532
|
+
else debug("Excluded files commit failed, continuing without them");
|
|
1533
|
+
}
|
|
1534
|
+
if (included.length === 0) {
|
|
1535
|
+
debug("No included files to group, done");
|
|
1536
|
+
outro(green("Done."));
|
|
1537
|
+
return "committed";
|
|
1538
|
+
}
|
|
1539
|
+
if (!flags.noCheck) {
|
|
1540
|
+
const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
|
|
1541
|
+
const repoRoot = await getRepoRoot();
|
|
1542
|
+
const allFiles = included.filter((f) => f.status !== "D").map((f) => f.path);
|
|
1543
|
+
debug("Running user checks on %d files...", allFiles.length);
|
|
1544
|
+
const ck = spinner();
|
|
1545
|
+
ck.start("Running checks...");
|
|
1546
|
+
const checkResults = await runAllChecks(repoRoot, allFiles, 6e4);
|
|
1547
|
+
debug("Check results: ok=%s, count=%d", checkResults.ok, checkResults.results.length);
|
|
1548
|
+
if (!checkResults.ok) {
|
|
1549
|
+
ck.stop(`${checkResults.results.filter((r) => !r.ok).length} check(s) failed`);
|
|
1550
|
+
const rawStderr = checkResults.results.filter((r) => !r.ok).map((r) => `[${r.tool}] ${r.stderr}`).join("\n");
|
|
1551
|
+
if (await showCheckFailureMenu(parseHookErrors(rawStderr), rawStderr) === "cancelled") return "cancelled";
|
|
1552
|
+
} else {
|
|
1553
|
+
ck.stop("All checks passed");
|
|
1554
|
+
if (checkResults.results.length > 0) log.info(checkResults.results.map((r) => ` ${green("✓")} ${r.tool}`).join("\n"));
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
const config = await readConfig();
|
|
1558
|
+
const resolvedProvider = config.provider ?? "groq";
|
|
1559
|
+
const provider = isValidProvider(resolvedProvider) ? resolvedProvider : "groq";
|
|
1560
|
+
try {
|
|
1561
|
+
await getProviderApiKey(provider);
|
|
1562
|
+
debug("API key found");
|
|
1563
|
+
} catch {
|
|
1564
|
+
debug("No API key found, prompting user");
|
|
1565
|
+
const { text: promptText } = await import("@clack/prompts");
|
|
1566
|
+
const key = await promptText({
|
|
1567
|
+
message: `Enter your ${formatProviderName(provider)} API key:`,
|
|
1568
|
+
placeholder: provider === "groq" ? "gsk_..." : "...",
|
|
1569
|
+
validate: (v) => v?.trim() ? void 0 : "API key is required"
|
|
1570
|
+
});
|
|
1571
|
+
if (isCancel(key)) {
|
|
1572
|
+
outro(dim("Cancelled."));
|
|
1573
|
+
return "cancelled";
|
|
1574
|
+
}
|
|
1575
|
+
const configKey = PROVIDER_ENV_KEYS[provider];
|
|
1576
|
+
await setConfigValue(configKey, String(key).trim());
|
|
1577
|
+
debug("API key saved to config");
|
|
1578
|
+
}
|
|
1579
|
+
const s = spinner();
|
|
1580
|
+
s.start("Analyzing files...");
|
|
1581
|
+
const validatedGroups = validateGroups((await generateGroups(included, await getProviderApiKey(provider), getModelForProvider(config, provider, PROVIDER_CONFIGS[provider].defaultModel), config.timeout ? parseInt(config.timeout, 10) : void 0, provider, config.proxy)).groups, included);
|
|
1582
|
+
s.stop("Files analyzed");
|
|
1583
|
+
showGroupedFiles(validatedGroups, included);
|
|
1584
|
+
if (flags.auto) debug("Auto mode: skipping grouping confirmation");
|
|
1585
|
+
else if (!await showGroupingConfirmation(validatedGroups, excluded)) {
|
|
1586
|
+
outro(dim("Cancelled."));
|
|
1498
1587
|
return "cancelled";
|
|
1499
1588
|
}
|
|
1500
1589
|
for (let i = 0; i < validatedGroups.length; i++) {
|
|
@@ -1558,14 +1647,19 @@ async function runAutoGroupFlow(changedFiles, flags) {
|
|
|
1558
1647
|
}
|
|
1559
1648
|
async function generateMessage(diff, hint) {
|
|
1560
1649
|
const config = await readConfig();
|
|
1561
|
-
const
|
|
1562
|
-
|
|
1650
|
+
const resolvedProvider = config.provider ?? "groq";
|
|
1651
|
+
const provider = isValidProvider(resolvedProvider) ? resolvedProvider : "groq";
|
|
1652
|
+
const apiKey = await getProviderApiKey(provider);
|
|
1653
|
+
const model = getModelForProvider(config, provider, PROVIDER_CONFIGS[provider].defaultModel);
|
|
1654
|
+
debug("Generating message with provider:", provider, "model:", model, "type:", config.type);
|
|
1563
1655
|
return generateCommitMessage(diff, {
|
|
1564
1656
|
apiKey,
|
|
1565
|
-
model
|
|
1657
|
+
model,
|
|
1566
1658
|
type: config.type,
|
|
1567
1659
|
timeout: config.timeout ? parseInt(config.timeout, 10) : void 0,
|
|
1568
|
-
hint
|
|
1660
|
+
hint,
|
|
1661
|
+
provider,
|
|
1662
|
+
proxy: config.proxy
|
|
1569
1663
|
});
|
|
1570
1664
|
}
|
|
1571
1665
|
function buildExcludedFilesMessage(files) {
|
|
@@ -1578,46 +1672,143 @@ function buildExcludedFilesMessage(files) {
|
|
|
1578
1672
|
return "chore: update generated files";
|
|
1579
1673
|
}
|
|
1580
1674
|
//#endregion
|
|
1581
|
-
//#region src/commands/commit.ts
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1675
|
+
//#region src/commands/commit-utils.ts
|
|
1676
|
+
/** Shared recovery menu factory — avoids repeating the same callback set */
|
|
1677
|
+
function makeRecoveryCallbacks(message) {
|
|
1678
|
+
return {
|
|
1679
|
+
retry: async () => (await attemptCommit(message)).ok,
|
|
1680
|
+
skipHooks: async (msg) => (await attemptCommitNoVerify(msg)).ok,
|
|
1681
|
+
restage: async () => {
|
|
1682
|
+
await stageAll();
|
|
1683
|
+
return (await attemptCommit(message)).ok;
|
|
1684
|
+
},
|
|
1685
|
+
message
|
|
1686
|
+
};
|
|
1687
|
+
}
|
|
1688
|
+
/**
|
|
1689
|
+
* Attempt commit with automatic recovery flow.
|
|
1690
|
+
* Handles the attempt → HEAD check → success (tool checks display)
|
|
1691
|
+
* / failure (recovery menu) pattern.
|
|
1692
|
+
* Caller is responsible for starting the spinner and showing the final outro.
|
|
1693
|
+
*/
|
|
1694
|
+
async function commitWithRecovery(message, s, headBefore) {
|
|
1695
|
+
const result = await attemptCommit(message, [], createProgressHandler(s));
|
|
1696
|
+
const headAfter = await getHead();
|
|
1697
|
+
if (result.ok || headBefore !== headAfter) {
|
|
1698
|
+
s.stop("Committed successfully.");
|
|
1699
|
+
const checks = parseToolChecks(result.stderr ?? "");
|
|
1700
|
+
if (checks.length > 0) {
|
|
1701
|
+
const lines = checks.map((c) => ` ${c.ok ? green("✓") : red("✗")} ${c.tool}`);
|
|
1702
|
+
log.info(lines.join("\n"));
|
|
1595
1703
|
}
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1704
|
+
return "committed";
|
|
1705
|
+
}
|
|
1706
|
+
s.stop("Commit failed.");
|
|
1707
|
+
const errors = parseHookErrors(result.stderr ?? "");
|
|
1708
|
+
const cb = makeRecoveryCallbacks(message);
|
|
1709
|
+
if (await showRecoveryMenu(errors, cb.retry, cb.skipHooks, cb.restage, cb.message, result.stderr ?? "") === "cancelled") return "cancelled";
|
|
1710
|
+
return "committed";
|
|
1711
|
+
}
|
|
1712
|
+
//#endregion
|
|
1713
|
+
//#region src/commands/retry.ts
|
|
1714
|
+
/** Handle --retry mode: load cached message and re-attempt commit */
|
|
1715
|
+
async function handleRetry() {
|
|
1716
|
+
debug("Entering retry mode");
|
|
1717
|
+
const cached = await loadCachedCommit(await getRepoRoot());
|
|
1718
|
+
if (!cached) {
|
|
1719
|
+
outro(red("No cached commit message found. Run cmint without --retry first."));
|
|
1720
|
+
process.exit(1);
|
|
1721
|
+
}
|
|
1722
|
+
intro("🌿 commit-mint — retry");
|
|
1723
|
+
const s = spinner();
|
|
1724
|
+
const headBefore = await getHead();
|
|
1725
|
+
s.start("Running pre-commit hooks...");
|
|
1726
|
+
if (await commitWithRecovery(cached.message, s, headBefore) === "committed") outro(green("Committed successfully."));
|
|
1727
|
+
else process.exit(1);
|
|
1728
|
+
}
|
|
1729
|
+
//#endregion
|
|
1730
|
+
//#region src/commands/staging.ts
|
|
1731
|
+
/** Interactive staging loop for multiple changed files */
|
|
1732
|
+
async function handleStaging(changedFiles, flags) {
|
|
1733
|
+
const repoRoot = await getRepoRoot();
|
|
1734
|
+
const checksAvailable = await detectConfig(repoRoot) !== null;
|
|
1735
|
+
debug("checks available:", checksAvailable);
|
|
1736
|
+
let stagingResult = null;
|
|
1737
|
+
let filesToStage = [];
|
|
1738
|
+
let stageAllFlag = false;
|
|
1739
|
+
let skipStaging = false;
|
|
1740
|
+
let currentFiles = changedFiles;
|
|
1741
|
+
while (true) {
|
|
1742
|
+
stagingResult = await showStagingMenu(currentFiles, checksAvailable);
|
|
1743
|
+
if (stagingResult === "autogroup") {
|
|
1744
|
+
if (flags.message) {
|
|
1745
|
+
outro(red("--message flag is not compatible with auto-group mode."));
|
|
1746
|
+
return null;
|
|
1608
1747
|
}
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1748
|
+
if (await runAutoGroupFlow(currentFiles, flags) !== "committed") process.exit(1);
|
|
1749
|
+
return null;
|
|
1750
|
+
}
|
|
1751
|
+
if (stagingResult === "checks") {
|
|
1752
|
+
await stageAll();
|
|
1753
|
+
const ckSpinner = spinner();
|
|
1754
|
+
ckSpinner.start("Running checks...");
|
|
1755
|
+
const ckResult = await runAllChecks(repoRoot, currentFiles.filter((f) => f.status !== "D").map((f) => f.path), 6e4);
|
|
1756
|
+
if (ckResult.ok) {
|
|
1757
|
+
ckSpinner.stop("All checks passed");
|
|
1758
|
+
for (const r of ckResult.results) if (r.stdout.trim()) log.info(dim(r.stdout.trim()));
|
|
1759
|
+
} else {
|
|
1760
|
+
const failed = ckResult.results.filter((r) => !r.ok);
|
|
1761
|
+
ckSpinner.stop(`${failed.length} check${failed.length !== 1 ? "s" : ""} failed`);
|
|
1762
|
+
for (const r of failed) log.info(r.stderr?.trim() || r.stdout?.trim() || `Check failed: ${r.command}`);
|
|
1763
|
+
}
|
|
1764
|
+
currentFiles = await getChangedFiles();
|
|
1765
|
+
continue;
|
|
1766
|
+
}
|
|
1767
|
+
if (stagingResult === "staged") {
|
|
1768
|
+
skipStaging = true;
|
|
1769
|
+
break;
|
|
1770
|
+
}
|
|
1771
|
+
if (!stagingResult) {
|
|
1772
|
+
outro(dim("Cancelled."));
|
|
1773
|
+
return null;
|
|
1618
1774
|
}
|
|
1775
|
+
filesToStage = stagingResult.files;
|
|
1776
|
+
stageAllFlag = stagingResult.all;
|
|
1777
|
+
break;
|
|
1619
1778
|
}
|
|
1620
|
-
|
|
1779
|
+
if (!skipStaging) {
|
|
1780
|
+
const s = spinner();
|
|
1781
|
+
s.start(`Staging ${filesToStage.length} file${filesToStage.length !== 1 ? "s" : ""}...`);
|
|
1782
|
+
if (stageAllFlag) await stageAll();
|
|
1783
|
+
else await stageFiles(filesToStage);
|
|
1784
|
+
s.stop("Files staged");
|
|
1785
|
+
}
|
|
1786
|
+
return {
|
|
1787
|
+
changedFiles: currentFiles,
|
|
1788
|
+
skipStaging
|
|
1789
|
+
};
|
|
1790
|
+
}
|
|
1791
|
+
/** Run user-defined pre-commit checks from cmint config */
|
|
1792
|
+
async function runPreCommitChecks(changedFiles, noCheck) {
|
|
1793
|
+
if (noCheck) return;
|
|
1794
|
+
const checkRoot = await getRepoRoot();
|
|
1795
|
+
const stagedFileList = changedFiles.filter((f) => f.staged && f.status !== "D").map((f) => f.path);
|
|
1796
|
+
if (stagedFileList.length === 0) return;
|
|
1797
|
+
debug("Running user checks on %d staged files...", stagedFileList.length);
|
|
1798
|
+
const checkResults = await runAllChecks(checkRoot, stagedFileList, 6e4);
|
|
1799
|
+
debug("Check results: ok=%s, count=%d", checkResults.ok, checkResults.results.length);
|
|
1800
|
+
if (!checkResults.ok) {
|
|
1801
|
+
const rawStderr = checkResults.results.filter((r) => !r.ok).map((r) => `[${r.tool}] ${r.stderr}`).join("\n");
|
|
1802
|
+
if (await showCheckFailureMenu(parseHookErrors(rawStderr), rawStderr) === "cancelled") process.exit(1);
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
//#endregion
|
|
1806
|
+
//#region src/commands/commit.ts
|
|
1807
|
+
async function commitCommand(flags) {
|
|
1808
|
+
debug("commitCommand called", { flags });
|
|
1809
|
+
await assertGitRepo();
|
|
1810
|
+
if (flags.retry) return handleRetry();
|
|
1811
|
+
intro("🌿 commit-mint");
|
|
1621
1812
|
const status = await getStatusShort();
|
|
1622
1813
|
debug("Git status:", status || "(empty)");
|
|
1623
1814
|
if (!status) {
|
|
@@ -1640,49 +1831,9 @@ async function commitCommand(flags) {
|
|
|
1640
1831
|
await stageFiles([changedFiles[0].path]);
|
|
1641
1832
|
s.stop("File staged");
|
|
1642
1833
|
} else {
|
|
1643
|
-
const
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
let stagingResult = null;
|
|
1647
|
-
let filesToStage = [];
|
|
1648
|
-
let stageAllFlag = false;
|
|
1649
|
-
while (true) {
|
|
1650
|
-
stagingResult = await showStagingMenu(changedFiles, lintStagedAvailable);
|
|
1651
|
-
if (stagingResult === "autogroup") {
|
|
1652
|
-
if (flags.message) {
|
|
1653
|
-
outro(red("--message flag is not compatible with auto-group mode."));
|
|
1654
|
-
return;
|
|
1655
|
-
}
|
|
1656
|
-
if (await runAutoGroupFlow(changedFiles, flags) !== "committed") process.exit(1);
|
|
1657
|
-
return;
|
|
1658
|
-
}
|
|
1659
|
-
if (stagingResult === "lint-staged") {
|
|
1660
|
-
await stageAll();
|
|
1661
|
-
const lsSpinner = spinner();
|
|
1662
|
-
lsSpinner.start("Running lint-staged checks...");
|
|
1663
|
-
const lsResult = await runLintStaged();
|
|
1664
|
-
if (lsResult.ok) {
|
|
1665
|
-
lsSpinner.stop("All lint-staged checks passed");
|
|
1666
|
-
if (lsResult.stdout.trim()) log.info(dim(lsResult.stdout.trim()));
|
|
1667
|
-
} else {
|
|
1668
|
-
lsSpinner.stop("Lint-staged checks failed");
|
|
1669
|
-
log.info(lsResult.stderr?.trim() || lsResult.stdout?.trim() || "Unknown error");
|
|
1670
|
-
}
|
|
1671
|
-
changedFiles = await getChangedFiles();
|
|
1672
|
-
continue;
|
|
1673
|
-
}
|
|
1674
|
-
if (!stagingResult) {
|
|
1675
|
-
outro(dim("Cancelled."));
|
|
1676
|
-
return;
|
|
1677
|
-
}
|
|
1678
|
-
filesToStage = stagingResult.files;
|
|
1679
|
-
stageAllFlag = stagingResult.all;
|
|
1680
|
-
break;
|
|
1681
|
-
}
|
|
1682
|
-
s.start(`Staging ${filesToStage.length} file${filesToStage.length !== 1 ? "s" : ""}...`);
|
|
1683
|
-
if (stageAllFlag) await stageAll();
|
|
1684
|
-
else await stageFiles(filesToStage);
|
|
1685
|
-
s.stop("Files staged");
|
|
1834
|
+
const result = await handleStaging(changedFiles, flags);
|
|
1835
|
+
if (!result) return;
|
|
1836
|
+
changedFiles = result.changedFiles;
|
|
1686
1837
|
}
|
|
1687
1838
|
} catch (err) {
|
|
1688
1839
|
s.stop(red("Staging failed."));
|
|
@@ -1691,6 +1842,8 @@ async function commitCommand(flags) {
|
|
|
1691
1842
|
outro(red(`Failed to stage files: ${msg}`));
|
|
1692
1843
|
process.exit(1);
|
|
1693
1844
|
}
|
|
1845
|
+
changedFiles = await getChangedFiles();
|
|
1846
|
+
await runPreCommitChecks(changedFiles, flags.noCheck);
|
|
1694
1847
|
const diffResult = await getStagedDiff();
|
|
1695
1848
|
if (!diffResult) {
|
|
1696
1849
|
debug("No staged changes found after staging");
|
|
@@ -1701,22 +1854,14 @@ async function commitCommand(flags) {
|
|
|
1701
1854
|
debug("All staged files are excluded:", diffResult.excludedFiles);
|
|
1702
1855
|
const message = buildExcludedFilesMessage(diffResult.excludedFiles);
|
|
1703
1856
|
log.info(diffResult.excludedFiles.map((f) => ` ${f}`).join("\n"));
|
|
1704
|
-
const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
|
|
1705
1857
|
await saveCachedCommit(await getRepoRoot(), message);
|
|
1706
1858
|
s.start("Running pre-commit hooks...");
|
|
1707
|
-
const
|
|
1708
|
-
|
|
1709
|
-
const headAfter = await getHead();
|
|
1710
|
-
if (result.ok || headBefore !== headAfter) {
|
|
1711
|
-
s.stop("Committed successfully.");
|
|
1859
|
+
const result = await commitWithRecovery(message, s, await getHead());
|
|
1860
|
+
if (result === "committed") {
|
|
1712
1861
|
outro(green("Done."));
|
|
1713
1862
|
return;
|
|
1714
1863
|
}
|
|
1715
|
-
|
|
1716
|
-
if (await showRecoveryMenu(parseHookErrors(result.stderr ?? ""), async () => (await attemptCommit(message)).ok, async (msg) => (await attemptCommitNoVerify(msg)).ok, async () => {
|
|
1717
|
-
await stageAll();
|
|
1718
|
-
return (await attemptCommit(message)).ok;
|
|
1719
|
-
}, message, result.stderr ?? "") === "cancelled") process.exit(1);
|
|
1864
|
+
if (result === "cancelled") process.exit(1);
|
|
1720
1865
|
return;
|
|
1721
1866
|
}
|
|
1722
1867
|
debug("Staged files:", diffResult.files);
|
|
@@ -1727,22 +1872,25 @@ async function commitCommand(flags) {
|
|
|
1727
1872
|
debug("Using provided message:", flags.message);
|
|
1728
1873
|
message = flags.message;
|
|
1729
1874
|
} else {
|
|
1875
|
+
const config = await readConfig();
|
|
1876
|
+
const provider = isValidProvider(config.provider ?? "groq") ? config.provider : "groq";
|
|
1730
1877
|
try {
|
|
1731
|
-
await
|
|
1878
|
+
await getProviderApiKey(provider);
|
|
1732
1879
|
debug("API key found");
|
|
1733
1880
|
} catch {
|
|
1734
1881
|
debug("No API key found, prompting user");
|
|
1735
1882
|
const { text: promptText } = await import("@clack/prompts");
|
|
1883
|
+
const configKey = PROVIDER_ENV_KEYS[provider];
|
|
1736
1884
|
const key = await promptText({
|
|
1737
|
-
message:
|
|
1738
|
-
placeholder: "gsk_...",
|
|
1885
|
+
message: `Enter your ${formatProviderName(provider)} API key:`,
|
|
1886
|
+
placeholder: provider === "groq" ? "gsk_..." : "...",
|
|
1739
1887
|
validate: (v) => v?.trim() ? void 0 : "API key is required"
|
|
1740
1888
|
});
|
|
1741
1889
|
if (isCancel(key)) {
|
|
1742
1890
|
outro(dim("Cancelled."));
|
|
1743
1891
|
return;
|
|
1744
1892
|
}
|
|
1745
|
-
await setConfigValue(
|
|
1893
|
+
await setConfigValue(configKey, String(key).trim());
|
|
1746
1894
|
debug("API key saved to config");
|
|
1747
1895
|
}
|
|
1748
1896
|
s.start("Generating commit message...");
|
|
@@ -1765,66 +1913,197 @@ async function commitCommand(flags) {
|
|
|
1765
1913
|
return;
|
|
1766
1914
|
}
|
|
1767
1915
|
message = reviewed;
|
|
1768
|
-
const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
|
|
1769
1916
|
const repoRoot = await getRepoRoot();
|
|
1770
1917
|
await saveCachedCommit(repoRoot, message);
|
|
1771
1918
|
debug("Message cached for repo:", repoRoot);
|
|
1772
1919
|
s.start("Running pre-commit hooks...");
|
|
1773
1920
|
const headBefore = await getHead();
|
|
1774
1921
|
debug("HEAD before commit:", headBefore);
|
|
1775
|
-
const result = await
|
|
1776
|
-
const headAfter = await getHead();
|
|
1777
|
-
debug("HEAD after commit:", headAfter);
|
|
1922
|
+
const result = await commitWithRecovery(message, s, headBefore);
|
|
1778
1923
|
debug("Commit result:", result);
|
|
1779
|
-
if (result
|
|
1780
|
-
s.stop("Committed successfully.");
|
|
1781
|
-
const checks = parseToolChecks(result.stderr ?? "");
|
|
1782
|
-
if (checks.length > 0) {
|
|
1783
|
-
const lines = checks.map((c) => ` ${c.ok ? green("✓") : red("✗")} ${c.tool}`);
|
|
1784
|
-
log.info(lines.join("\n"));
|
|
1785
|
-
}
|
|
1924
|
+
if (result === "committed") {
|
|
1786
1925
|
outro(green("Done."));
|
|
1787
1926
|
return;
|
|
1788
1927
|
}
|
|
1789
|
-
|
|
1790
|
-
debug("Commit failed, showing recovery menu");
|
|
1791
|
-
const errors = parseHookErrors(result.stderr ?? "");
|
|
1792
|
-
debug("Parsed hook errors:", errors.length, "errors");
|
|
1793
|
-
if (await showRecoveryMenu(errors, async () => {
|
|
1794
|
-
return (await attemptCommit(message)).ok;
|
|
1795
|
-
}, async (msg) => {
|
|
1796
|
-
return (await attemptCommitNoVerify(msg)).ok;
|
|
1797
|
-
}, async () => {
|
|
1798
|
-
await stageAll();
|
|
1799
|
-
return (await attemptCommit(message)).ok;
|
|
1800
|
-
}, message, result.stderr ?? "") === "cancelled") process.exit(1);
|
|
1928
|
+
if (result === "cancelled") process.exit(1);
|
|
1801
1929
|
}
|
|
1802
1930
|
//#endregion
|
|
1803
1931
|
//#region src/commands/config.ts
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
}
|
|
1815
|
-
|
|
1932
|
+
function maskKey(key) {
|
|
1933
|
+
if (!key) return dim("not set");
|
|
1934
|
+
if (key.length <= 8) return "****";
|
|
1935
|
+
return `${key.slice(0, 4)}${"*".repeat(Math.min(key.length - 8, 20))}${key.slice(-4)}`;
|
|
1936
|
+
}
|
|
1937
|
+
function buildConfigDisplay(config) {
|
|
1938
|
+
const provider = isValidProvider(config.provider ?? "groq") ? config.provider : "groq";
|
|
1939
|
+
const apiKey = config[PROVIDER_ENV_KEYS[provider]];
|
|
1940
|
+
return [
|
|
1941
|
+
`Provider: ${bold(formatProviderName(provider))}`,
|
|
1942
|
+
`API Key: ${maskKey(apiKey)}`,
|
|
1943
|
+
`Model: ${config.model ?? "(none)"}`,
|
|
1944
|
+
`Locale: ${config.locale ?? "en"}`,
|
|
1945
|
+
`Max Length: ${config["max-length"] ?? "100"}`,
|
|
1946
|
+
`Commit Type: ${config.type || dim("(none)")}`,
|
|
1947
|
+
`Timeout: ${config.timeout ?? "10000"}ms`,
|
|
1948
|
+
`Proxy: ${config.proxy || dim("(none)")}`
|
|
1949
|
+
].join("\n");
|
|
1950
|
+
}
|
|
1951
|
+
function getProvider(config) {
|
|
1952
|
+
return isValidProvider(config.provider ?? "groq") ? config.provider : "groq";
|
|
1953
|
+
}
|
|
1954
|
+
async function promptProvider() {
|
|
1955
|
+
return p.select({
|
|
1956
|
+
message: "Select LLM provider:",
|
|
1957
|
+
options: [
|
|
1958
|
+
{
|
|
1959
|
+
label: "Groq",
|
|
1960
|
+
value: "groq",
|
|
1961
|
+
hint: PROVIDER_CONFIGS.groq.defaultModel
|
|
1962
|
+
},
|
|
1963
|
+
{
|
|
1964
|
+
label: "Cerebras",
|
|
1965
|
+
value: "cerebras",
|
|
1966
|
+
hint: PROVIDER_CONFIGS.cerebras.defaultModel
|
|
1967
|
+
},
|
|
1968
|
+
{
|
|
1969
|
+
label: "Mistral",
|
|
1970
|
+
value: "mistral",
|
|
1971
|
+
hint: PROVIDER_CONFIGS.mistral.defaultModel
|
|
1972
|
+
}
|
|
1973
|
+
]
|
|
1974
|
+
});
|
|
1975
|
+
}
|
|
1976
|
+
async function promptApiKey(provider) {
|
|
1977
|
+
const keyName = PROVIDER_ENV_KEYS[provider];
|
|
1978
|
+
const result = await p.text({
|
|
1979
|
+
message: `${formatProviderName(provider)} API key:`,
|
|
1980
|
+
placeholder: "Paste your API key",
|
|
1981
|
+
validate: (v) => !v?.trim() ? "API key cannot be empty" : void 0
|
|
1982
|
+
});
|
|
1983
|
+
if (p.isCancel(result)) return result;
|
|
1984
|
+
await writeConfig({ [keyName]: result.toString().trim() });
|
|
1985
|
+
debug("config: %s set", keyName);
|
|
1986
|
+
return result;
|
|
1987
|
+
}
|
|
1988
|
+
async function promptTextSetting(label, configKey, currentValue, validate) {
|
|
1989
|
+
const result = await p.text({
|
|
1990
|
+
message: label,
|
|
1991
|
+
placeholder: currentValue ?? "",
|
|
1992
|
+
initialValue: currentValue ?? "",
|
|
1993
|
+
validate
|
|
1994
|
+
});
|
|
1995
|
+
if (p.isCancel(result)) return result;
|
|
1996
|
+
await writeConfig({ [configKey]: result.toString().trim() });
|
|
1997
|
+
debug("config: %s set to %s", configKey, result);
|
|
1998
|
+
return result;
|
|
1999
|
+
}
|
|
2000
|
+
const requireNumber = (v) => {
|
|
2001
|
+
if (!v?.trim()) return "Value cannot be empty";
|
|
2002
|
+
return Number.isNaN(Number(v)) ? "Must be a number" : void 0;
|
|
2003
|
+
};
|
|
2004
|
+
function getSettingHandlers(config) {
|
|
2005
|
+
const provider = getProvider(config);
|
|
2006
|
+
return {
|
|
2007
|
+
provider: async () => {
|
|
2008
|
+
const result = await promptProvider();
|
|
2009
|
+
if (p.isCancel(result)) return result;
|
|
2010
|
+
await writeConfig({ provider: result });
|
|
2011
|
+
debug("config: provider set to %s", result);
|
|
2012
|
+
},
|
|
2013
|
+
apikey: async () => promptApiKey(provider),
|
|
2014
|
+
model: async () => promptTextSetting("Model ID:", "model", config.model),
|
|
2015
|
+
locale: async () => promptTextSetting("Locale (e.g. en, ja, ko):", "locale", config.locale),
|
|
2016
|
+
maxlen: async () => promptTextSetting("Max commit message length:", "max-length", config["max-length"], requireNumber),
|
|
2017
|
+
type: async () => promptTextSetting("Commit type prefix (e.g. conventional):", "type", config.type),
|
|
2018
|
+
timeout: async () => promptTextSetting("Timeout (ms):", "timeout", config.timeout, requireNumber),
|
|
2019
|
+
proxy: async () => promptTextSetting("Proxy URL:", "proxy", config.proxy)
|
|
2020
|
+
};
|
|
2021
|
+
}
|
|
2022
|
+
async function handleEditSetting(setting, config) {
|
|
2023
|
+
const handler = getSettingHandlers(config)[setting];
|
|
2024
|
+
if (!handler) return false;
|
|
2025
|
+
const result = await handler();
|
|
2026
|
+
return !p.isCancel(result);
|
|
2027
|
+
}
|
|
2028
|
+
async function editSettingsLoop(initialConfig) {
|
|
2029
|
+
let config = initialConfig;
|
|
2030
|
+
while (true) {
|
|
2031
|
+
config = await readConfig();
|
|
2032
|
+
const provider = getProvider(config);
|
|
2033
|
+
const setting = await p.select({
|
|
2034
|
+
message: "Select a setting to edit:",
|
|
2035
|
+
options: [
|
|
2036
|
+
{
|
|
2037
|
+
label: `LLM Provider ${dim(`(${formatProviderName(provider)})`)}`,
|
|
2038
|
+
value: "provider"
|
|
2039
|
+
},
|
|
2040
|
+
{
|
|
2041
|
+
label: `API Key ${dim(`(for ${formatProviderName(provider)})`)}`,
|
|
2042
|
+
value: "apikey"
|
|
2043
|
+
},
|
|
2044
|
+
{
|
|
2045
|
+
label: `Model ${dim(`(${config.model ?? "(none)"})`)}`,
|
|
2046
|
+
value: "model"
|
|
2047
|
+
},
|
|
2048
|
+
{
|
|
2049
|
+
label: `Locale ${dim(`(${config.locale ?? "en"})`)}`,
|
|
2050
|
+
value: "locale"
|
|
2051
|
+
},
|
|
2052
|
+
{
|
|
2053
|
+
label: `Max commit length ${dim(`(${config["max-length"] ?? "100"})`)}`,
|
|
2054
|
+
value: "maxlen"
|
|
2055
|
+
},
|
|
2056
|
+
{
|
|
2057
|
+
label: `Commit type prefix ${dim(`(${config.type || "(none)"})`)}`,
|
|
2058
|
+
value: "type"
|
|
2059
|
+
},
|
|
2060
|
+
{
|
|
2061
|
+
label: `Timeout (ms) ${dim(`(${config.timeout ?? "10000"})`)}`,
|
|
2062
|
+
value: "timeout"
|
|
2063
|
+
},
|
|
2064
|
+
{
|
|
2065
|
+
label: `Proxy URL ${dim(`(${config.proxy || "(none)"})`)}`,
|
|
2066
|
+
value: "proxy"
|
|
2067
|
+
},
|
|
2068
|
+
{
|
|
2069
|
+
label: "Done editing",
|
|
2070
|
+
value: "done"
|
|
2071
|
+
}
|
|
2072
|
+
]
|
|
2073
|
+
});
|
|
2074
|
+
if (p.isCancel(setting) || setting === "done") break;
|
|
2075
|
+
if (await handleEditSetting(setting, config)) p.log.success(green("Updated."));
|
|
1816
2076
|
}
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
2077
|
+
}
|
|
2078
|
+
async function configCommand() {
|
|
2079
|
+
debug("configCommand: starting");
|
|
2080
|
+
p.intro(bold("🌿 commit-mint config"));
|
|
2081
|
+
while (true) {
|
|
2082
|
+
const config = await readConfig();
|
|
2083
|
+
p.note(buildConfigDisplay(config), "commit-mint config");
|
|
2084
|
+
const action = await p.select({
|
|
2085
|
+
message: "What would you like to do?",
|
|
2086
|
+
options: [{
|
|
2087
|
+
label: "Edit settings",
|
|
2088
|
+
value: "edit"
|
|
2089
|
+
}, {
|
|
2090
|
+
label: "Done",
|
|
2091
|
+
value: "done"
|
|
2092
|
+
}]
|
|
2093
|
+
});
|
|
2094
|
+
if (p.isCancel(action)) {
|
|
2095
|
+
debug("configCommand: cancelled at main menu");
|
|
2096
|
+
p.outro(dim("Cancelled."));
|
|
2097
|
+
return;
|
|
1821
2098
|
}
|
|
1822
|
-
|
|
1823
|
-
|
|
2099
|
+
if (action === "done") {
|
|
2100
|
+
debug("configCommand: done");
|
|
2101
|
+
p.outro("Config saved.");
|
|
2102
|
+
return;
|
|
2103
|
+
}
|
|
2104
|
+
await editSettingsLoop(config);
|
|
1824
2105
|
}
|
|
1825
|
-
|
|
1826
|
-
process.exit(1);
|
|
1827
|
-
});
|
|
2106
|
+
}
|
|
1828
2107
|
//#endregion
|
|
1829
2108
|
//#region src/cli.ts
|
|
1830
2109
|
const { version } = package_default;
|
|
@@ -1855,24 +2134,25 @@ cli({
|
|
|
1855
2134
|
description: "Add context hint for AI commit message generation",
|
|
1856
2135
|
alias: "H"
|
|
1857
2136
|
},
|
|
1858
|
-
review: {
|
|
1859
|
-
type: Boolean,
|
|
1860
|
-
description: "Review staged changes with a coding model",
|
|
1861
|
-
alias: "R",
|
|
1862
|
-
default: false
|
|
1863
|
-
},
|
|
1864
2137
|
debug: {
|
|
1865
2138
|
type: Boolean,
|
|
1866
2139
|
description: "Enable debug output",
|
|
1867
2140
|
alias: "d",
|
|
1868
2141
|
default: false
|
|
2142
|
+
},
|
|
2143
|
+
noCheck: {
|
|
2144
|
+
type: Boolean,
|
|
2145
|
+
description: "Skip user-defined pre-commit checks",
|
|
2146
|
+
alias: "N",
|
|
2147
|
+
default: false
|
|
1869
2148
|
}
|
|
1870
2149
|
},
|
|
1871
|
-
commands: [
|
|
2150
|
+
commands: [command({ name: "config" }, async () => {
|
|
2151
|
+
await configCommand();
|
|
2152
|
+
})]
|
|
1872
2153
|
}, (argv) => {
|
|
1873
2154
|
setDebug(argv.flags.debug);
|
|
1874
|
-
|
|
1875
|
-
else commitCommand(argv.flags);
|
|
2155
|
+
commitCommand(argv.flags);
|
|
1876
2156
|
});
|
|
1877
2157
|
//#endregion
|
|
1878
2158
|
export {};
|