@kyubiware/commit-mint 0.4.2 → 0.5.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/README.md +173 -83
- package/dist/cli.mjs +989 -456
- package/dist/cli.mjs.map +1 -1
- package/package.json +6 -11
package/dist/cli.mjs
CHANGED
|
@@ -5,12 +5,14 @@ import { intro, isCancel, log, note, outro, select, spinner } from "@clack/promp
|
|
|
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 Groq from "groq-sdk";
|
|
10
11
|
import { execa } from "execa";
|
|
11
12
|
import { spawn } from "node:child_process";
|
|
12
|
-
import Groq from "groq-sdk";
|
|
13
13
|
import { createHash } from "node:crypto";
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import picomatch from "picomatch";
|
|
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.0",
|
|
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,50 +567,6 @@ async function attemptCommitNoVerify(message, onProgress) {
|
|
|
521
567
|
return attemptCommit(message, ["--no-verify"], onProgress);
|
|
522
568
|
}
|
|
523
569
|
//#endregion
|
|
524
|
-
//#region src/services/lint-staged.ts
|
|
525
|
-
const CONFIG_FILES = [
|
|
526
|
-
".lintstagedrc",
|
|
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
570
|
//#region src/services/clipboard.ts
|
|
569
571
|
async function copyToClipboard(content) {
|
|
570
572
|
for (const [cmd, args] of [
|
|
@@ -604,181 +606,14 @@ async function copyToClipboard(content) {
|
|
|
604
606
|
return false;
|
|
605
607
|
}
|
|
606
608
|
//#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;
|
|
686
|
-
while (true) {
|
|
687
|
-
if (showNote) {
|
|
688
|
-
p.note(errors.map((e) => ` ${red("•")} [${e.tool}] ${e.message}`).join("\n"), red(bold("Pre-commit hook failed")));
|
|
689
|
-
showNote = false;
|
|
690
|
-
}
|
|
691
|
-
const choice = await p.select({
|
|
692
|
-
message: "What do you want to do?",
|
|
693
|
-
options: [
|
|
694
|
-
{
|
|
695
|
-
label: clipboardCopied ? `${green("✓")} Copy error report to clipboard` : "Copy error report to clipboard",
|
|
696
|
-
value: "clipboard",
|
|
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"
|
|
708
|
-
},
|
|
709
|
-
{
|
|
710
|
-
label: "Edit commit message",
|
|
711
|
-
value: "edit",
|
|
712
|
-
hint: "Modify the message before retrying"
|
|
713
|
-
},
|
|
714
|
-
{
|
|
715
|
-
label: "Cancel",
|
|
716
|
-
value: "cancel"
|
|
717
|
-
}
|
|
718
|
-
]
|
|
719
|
-
});
|
|
720
|
-
if (p.isCancel(choice)) {
|
|
721
|
-
debug("showRecoveryMenu: user cancelled");
|
|
722
|
-
p.outro(yellow("Cancelled. Message cached for --retry."));
|
|
723
|
-
return "cancelled";
|
|
724
|
-
}
|
|
725
|
-
debug("showRecoveryMenu: user chose %s", choice);
|
|
726
|
-
switch (choice) {
|
|
727
|
-
case "clipboard":
|
|
728
|
-
if (await copyToClipboard(rawStderr)) {
|
|
729
|
-
clipboardCopied = true;
|
|
730
|
-
p.log.step(green("Copied to clipboard."));
|
|
731
|
-
} else p.log.warn(red("No clipboard tool found. Install xclip, wl-copy, or xsel."));
|
|
732
|
-
continue;
|
|
733
|
-
case "skip":
|
|
734
|
-
p.log.info(yellow("Committing with --no-verify..."));
|
|
735
|
-
if (await onSkipHooks(message)) {
|
|
736
|
-
p.outro(green("Committed (hooks skipped)."));
|
|
737
|
-
return "committed";
|
|
738
|
-
} else {
|
|
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";
|
|
771
|
-
}
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
//#endregion
|
|
775
609
|
//#region src/services/ai.ts
|
|
776
610
|
const MAX_DIFF_CHARS = 2e4;
|
|
777
|
-
function mapGroqError
|
|
778
|
-
|
|
779
|
-
if (error instanceof Groq.
|
|
611
|
+
function mapGroqError(error, providerLabel) {
|
|
612
|
+
const label = providerLabel ?? "Groq";
|
|
613
|
+
if (error instanceof Groq.AuthenticationError) return /* @__PURE__ */ new Error(`Invalid API key for ${label}. Run: cmint config set ${label.toUpperCase()}_API_KEY=<key>`);
|
|
614
|
+
if (error instanceof Groq.RateLimitError) return /* @__PURE__ */ new Error(`Rate limited by ${label}. Please wait and try again.`);
|
|
780
615
|
if (error instanceof Groq.APIConnectionTimeoutError) return /* @__PURE__ */ new Error("Request timed out. Check your network or try a smaller diff.");
|
|
781
|
-
if (error instanceof Groq.APIError) return /* @__PURE__ */ new Error(
|
|
616
|
+
if (error instanceof Groq.APIError) return /* @__PURE__ */ new Error(`${label} API error: ${error.message}`);
|
|
782
617
|
return /* @__PURE__ */ new Error(`Unexpected error: ${error instanceof Error ? error.message : String(error)}`);
|
|
783
618
|
}
|
|
784
619
|
const CONVENTIONAL_COMMIT_REGEX = /^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\(.+\))?!?: .+$/;
|
|
@@ -863,13 +698,16 @@ function extractContentText(content) {
|
|
|
863
698
|
return "";
|
|
864
699
|
}
|
|
865
700
|
async function generateCommitMessage(diff, options) {
|
|
866
|
-
debug("generateCommitMessage: model=%s, type=%s, hint=%s", options.model ?? "default", options.type ?? "none", options.hint ?? "none");
|
|
867
701
|
const timeoutMs = options.timeout ?? 6e4;
|
|
868
702
|
debug("Timeout: %d ms", timeoutMs);
|
|
869
|
-
const client =
|
|
703
|
+
const { client, model } = createProvider({
|
|
704
|
+
provider: options.provider ?? "groq",
|
|
870
705
|
apiKey: options.apiKey,
|
|
871
|
-
|
|
706
|
+
modelOverride: options.model,
|
|
707
|
+
timeout: timeoutMs,
|
|
708
|
+
baseURLOverride: options.proxy
|
|
872
709
|
});
|
|
710
|
+
debug("generateCommitMessage: model=%s, type=%s, hint=%s", model, options.type ?? "none", options.hint ?? "none");
|
|
873
711
|
const compressedDiff = compressDiff(diff);
|
|
874
712
|
const statSummary = buildStatSummary(diff);
|
|
875
713
|
const systemPrompt = buildSystemPrompt(options.type);
|
|
@@ -879,9 +717,9 @@ async function generateCommitMessage(diff, options) {
|
|
|
879
717
|
debug("User prompt length: %d chars", userPrompt.length);
|
|
880
718
|
async function callAI(strictSystemPrompt) {
|
|
881
719
|
const callStart = Date.now();
|
|
882
|
-
debug("callAI: %s — model=%s, promptLen=%d, systemLen=%d", !!strictSystemPrompt ? "RETRY (strict)" : "INITIAL",
|
|
720
|
+
debug("callAI: %s — model=%s, promptLen=%d, systemLen=%d", !!strictSystemPrompt ? "RETRY (strict)" : "INITIAL", model, userPrompt.length, (strictSystemPrompt ?? systemPrompt).length);
|
|
883
721
|
try {
|
|
884
|
-
const isReasoningModel = /^(o[1-9]|.*gpt-oss.*|.*gpt-5.*)/i.test(
|
|
722
|
+
const isReasoningModel = /^(o[1-9]|.*gpt-oss.*|.*gpt-5.*)/i.test(model);
|
|
885
723
|
const completion = await client.chat.completions.create({
|
|
886
724
|
messages: [{
|
|
887
725
|
role: "system",
|
|
@@ -890,7 +728,7 @@ async function generateCommitMessage(diff, options) {
|
|
|
890
728
|
role: "user",
|
|
891
729
|
content: userPrompt
|
|
892
730
|
}],
|
|
893
|
-
model
|
|
731
|
+
model,
|
|
894
732
|
temperature: .3,
|
|
895
733
|
...isReasoningModel ? { max_completion_tokens: 1024 } : { max_tokens: 1024 },
|
|
896
734
|
reasoning_format: "parsed"
|
|
@@ -936,7 +774,7 @@ async function generateCommitMessage(diff, options) {
|
|
|
936
774
|
return message;
|
|
937
775
|
} catch (error) {
|
|
938
776
|
debug("AI error: %s", error instanceof Error ? error.message : String(error));
|
|
939
|
-
throw mapGroqError
|
|
777
|
+
throw mapGroqError(error, options.provider ? formatProviderName(options.provider) : void 0);
|
|
940
778
|
}
|
|
941
779
|
}
|
|
942
780
|
//#endregion
|
|
@@ -979,9 +817,12 @@ function buildReviewPrompt(diff, files, statSummary) {
|
|
|
979
817
|
async function generateCodeReview(diff, files, options) {
|
|
980
818
|
debug("generateCodeReview: model=%s, files=%d", options.model ?? "default", files.length);
|
|
981
819
|
const timeoutMs = options.timeout ?? 6e4;
|
|
982
|
-
const client =
|
|
820
|
+
const { client, model } = createProvider({
|
|
821
|
+
provider: options.provider ?? "groq",
|
|
983
822
|
apiKey: options.apiKey,
|
|
984
|
-
|
|
823
|
+
modelOverride: options.model,
|
|
824
|
+
timeout: timeoutMs,
|
|
825
|
+
baseURLOverride: options.proxy
|
|
985
826
|
});
|
|
986
827
|
const compressedDiff = compressDiff(diff);
|
|
987
828
|
const statSummary = buildStatSummary(diff);
|
|
@@ -997,7 +838,7 @@ async function generateCodeReview(diff, files, options) {
|
|
|
997
838
|
role: "user",
|
|
998
839
|
content: userPrompt
|
|
999
840
|
}],
|
|
1000
|
-
model
|
|
841
|
+
model,
|
|
1001
842
|
temperature: .3,
|
|
1002
843
|
max_tokens: 4096
|
|
1003
844
|
});
|
|
@@ -1018,7 +859,7 @@ async function generateCodeReview(diff, files, options) {
|
|
|
1018
859
|
return content;
|
|
1019
860
|
} catch (error) {
|
|
1020
861
|
debug("generateCodeReview error: %s", error instanceof Error ? error.message : String(error));
|
|
1021
|
-
throw mapGroqError
|
|
862
|
+
throw mapGroqError(error, options.provider ? formatProviderName(options.provider) : void 0);
|
|
1022
863
|
}
|
|
1023
864
|
}
|
|
1024
865
|
//#endregion
|
|
@@ -1039,7 +880,7 @@ async function reviewCommand() {
|
|
|
1039
880
|
outro(dim("Staged files are all excluded from review."));
|
|
1040
881
|
return;
|
|
1041
882
|
}
|
|
1042
|
-
intro("commit-mint — code review");
|
|
883
|
+
intro("🌿 commit-mint — code review");
|
|
1043
884
|
log.info(diffResult.files.map((f) => ` ${f}`).join("\n"));
|
|
1044
885
|
const report = await isOpenCodeAvailable() ? await reviewWithOpenCode(diffResult.diff, diffResult.files) : await reviewWithGroq(diffResult.diff, diffResult.files);
|
|
1045
886
|
if (report !== "NO_ISSUES_FOUND" && report.trim().length > 0) {
|
|
@@ -1074,14 +915,19 @@ async function isOpenCodeAvailable() {
|
|
|
1074
915
|
}
|
|
1075
916
|
}
|
|
1076
917
|
async function reviewWithGroq(diff, files) {
|
|
918
|
+
const config = await readConfig();
|
|
919
|
+
const provider = isValidProvider(config.provider ?? "groq") ? config.provider : "groq";
|
|
920
|
+
const apiKey = await getProviderApiKey(provider);
|
|
921
|
+
const model = getModelForProvider(config, provider, PROVIDER_CONFIGS[provider].defaultModel);
|
|
1077
922
|
const s = spinner();
|
|
1078
|
-
s.start(
|
|
923
|
+
s.start(`Reviewing with ${formatProviderName(provider)}...`);
|
|
1079
924
|
try {
|
|
1080
|
-
const config = await readConfig();
|
|
1081
925
|
const report = await generateCodeReview(diff, files, {
|
|
1082
|
-
apiKey
|
|
1083
|
-
model
|
|
1084
|
-
timeout: config.timeout ? Number.parseInt(config.timeout, 10) : void 0
|
|
926
|
+
apiKey,
|
|
927
|
+
model,
|
|
928
|
+
timeout: config.timeout ? Number.parseInt(config.timeout, 10) : void 0,
|
|
929
|
+
provider,
|
|
930
|
+
proxy: config.proxy
|
|
1085
931
|
});
|
|
1086
932
|
s.stop("Review complete");
|
|
1087
933
|
return report;
|
|
@@ -1137,8 +983,10 @@ async function runCodeReview() {
|
|
|
1137
983
|
return;
|
|
1138
984
|
}
|
|
1139
985
|
const opencodeAvailable = await isOpenCodeAvailable();
|
|
986
|
+
const config = await readConfig();
|
|
987
|
+
const provider = isValidProvider(config.provider ?? "groq") ? config.provider : "groq";
|
|
1140
988
|
const s = spinner();
|
|
1141
|
-
s.start(opencodeAvailable ? "Running OpenCode review..." :
|
|
989
|
+
s.start(opencodeAvailable ? "Running OpenCode review..." : `Running ${formatProviderName(provider)} review...`);
|
|
1142
990
|
try {
|
|
1143
991
|
const report = opencodeAvailable ? await reviewWithOpenCode(diffResult.diff, diffResult.files) : await reviewWithGroq(diffResult.diff, diffResult.files);
|
|
1144
992
|
s.stop("Review complete");
|
|
@@ -1254,54 +1102,243 @@ async function loadCachedCommit(repoPath) {
|
|
|
1254
1102
|
}
|
|
1255
1103
|
}
|
|
1256
1104
|
//#endregion
|
|
1257
|
-
//#region src/services/
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1105
|
+
//#region src/services/checks.ts
|
|
1106
|
+
/** Config file names, checked in priority order (matches lint-staged naming conventions) */
|
|
1107
|
+
const CONFIG_FILES = [
|
|
1108
|
+
".cmintrc",
|
|
1109
|
+
".cmintrc.json",
|
|
1110
|
+
".cmintrc.mjs",
|
|
1111
|
+
".cmintrc.mts",
|
|
1112
|
+
".cmintrc.js",
|
|
1113
|
+
".cmintrc.ts",
|
|
1114
|
+
".cmintrc.cjs",
|
|
1115
|
+
".cmintrc.cts",
|
|
1116
|
+
"cmint.config.mjs",
|
|
1117
|
+
"cmint.config.mts",
|
|
1118
|
+
"cmint.config.js",
|
|
1119
|
+
"cmint.config.ts",
|
|
1120
|
+
"cmint.config.cjs",
|
|
1121
|
+
"cmint.config.cts"
|
|
1122
|
+
];
|
|
1123
|
+
/**
|
|
1124
|
+
* Detect whether the repo has a cmint config file.
|
|
1125
|
+
* Returns the config file path, or null if none found.
|
|
1126
|
+
*/
|
|
1127
|
+
async function detectConfig(repoRoot) {
|
|
1128
|
+
debug("detectConfig: checking for config in %s", repoRoot);
|
|
1129
|
+
for (const name of CONFIG_FILES) try {
|
|
1130
|
+
await access(join(repoRoot, name), constants.R_OK);
|
|
1131
|
+
debug("detectConfig: found %s", name);
|
|
1132
|
+
return join(repoRoot, name);
|
|
1133
|
+
} catch {}
|
|
1134
|
+
debug("detectConfig: no config file found");
|
|
1135
|
+
return null;
|
|
1264
1136
|
}
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
if (
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1137
|
+
/**
|
|
1138
|
+
* Load and validate the cmint config from a repo root.
|
|
1139
|
+
* Throws if the loaded value is missing or not a non-null object.
|
|
1140
|
+
*/
|
|
1141
|
+
async function loadConfig(repoRoot) {
|
|
1142
|
+
const configPath = await detectConfig(repoRoot);
|
|
1143
|
+
if (!configPath) throw new Error("No cmint config file found");
|
|
1144
|
+
debug("loadConfig: loading %s", configPath);
|
|
1145
|
+
const ext = extname(configPath);
|
|
1146
|
+
const isJSON = ext === ".json";
|
|
1147
|
+
const needsJiti = ext === ".ts" || ext === ".mts" || ext === ".cts" || ext === ".cjs";
|
|
1148
|
+
let config;
|
|
1149
|
+
if (isJSON) {
|
|
1150
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
1151
|
+
config = JSON.parse(raw);
|
|
1152
|
+
} else if (needsJiti) {
|
|
1153
|
+
const { createJiti } = await import("jiti");
|
|
1154
|
+
const mod = await createJiti(import.meta.url, {}).import(configPath);
|
|
1155
|
+
config = mod.default ?? mod;
|
|
1156
|
+
} else config = (await import(configPath)).default;
|
|
1157
|
+
if (!config || typeof config !== "object" || Array.isArray(config)) throw new Error("cmint config must export a non-null object with glob→command mappings");
|
|
1158
|
+
debug("loadConfig: loaded %d glob patterns", Object.keys(config).length);
|
|
1159
|
+
return config;
|
|
1276
1160
|
}
|
|
1277
|
-
/**
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
const
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1161
|
+
/**
|
|
1162
|
+
* Run a shell command and capture its output.
|
|
1163
|
+
* Returns a CheckResult with ok=true on success (exit 0), ok=false on failure.
|
|
1164
|
+
* Handles ENOENT (command not found) and timeout errors gracefully.
|
|
1165
|
+
*/
|
|
1166
|
+
async function runCommand(command, timeout, repoRoot) {
|
|
1167
|
+
debug("runCommand: %s (timeout: %dms)", command, timeout);
|
|
1168
|
+
const tool = extractToolName(command) ?? command.split(" ")[0];
|
|
1169
|
+
try {
|
|
1170
|
+
const result = await execa(command, {
|
|
1171
|
+
shell: true,
|
|
1172
|
+
reject: false,
|
|
1173
|
+
timeout,
|
|
1174
|
+
all: true,
|
|
1175
|
+
preferLocal: true,
|
|
1176
|
+
...repoRoot ? { localDir: repoRoot } : {}
|
|
1177
|
+
});
|
|
1178
|
+
const ok = !result.failed;
|
|
1179
|
+
debug("runCommand: %s — ok=%s", tool, ok);
|
|
1180
|
+
return {
|
|
1181
|
+
ok,
|
|
1182
|
+
tool,
|
|
1183
|
+
command,
|
|
1184
|
+
stdout: result.stdout ?? "",
|
|
1185
|
+
stderr: result.stderr ?? "",
|
|
1186
|
+
files: []
|
|
1187
|
+
};
|
|
1188
|
+
} catch (err) {
|
|
1189
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1190
|
+
const isTimedOut = msg.toLowerCase().includes("timed out");
|
|
1191
|
+
const isNotFound = msg.toLowerCase().includes("enoent") || msg.toLowerCase().includes("not found");
|
|
1192
|
+
debug("runCommand: %s — error: %s", tool, msg);
|
|
1193
|
+
return {
|
|
1194
|
+
ok: false,
|
|
1195
|
+
tool,
|
|
1196
|
+
command,
|
|
1197
|
+
stdout: "",
|
|
1198
|
+
stderr: isTimedOut ? `Check timed out after ${timeout}ms` : isNotFound ? `Command not found: ${tool}` : msg,
|
|
1199
|
+
files: []
|
|
1200
|
+
};
|
|
1295
1201
|
}
|
|
1296
|
-
debug("filterExcludedFiles: %d included, %d excluded", included.length, stillExcluded.length);
|
|
1297
|
-
return {
|
|
1298
|
-
included,
|
|
1299
|
-
excluded: stillExcluded
|
|
1300
|
-
};
|
|
1301
1202
|
}
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1203
|
+
/**
|
|
1204
|
+
* Filter a list of file paths by a picomatch glob pattern.
|
|
1205
|
+
* When the pattern contains no `/`, files are matched at any depth (matchBase).
|
|
1206
|
+
* Dotfiles are included (dot: true).
|
|
1207
|
+
*/
|
|
1208
|
+
function matchFiles(pattern, files) {
|
|
1209
|
+
if (!pattern) return [];
|
|
1210
|
+
const matchBase = !pattern.includes("/");
|
|
1211
|
+
const isMatch = picomatch(pattern, {
|
|
1212
|
+
dot: true,
|
|
1213
|
+
posixSlashes: true,
|
|
1214
|
+
strictBrackets: true
|
|
1215
|
+
});
|
|
1216
|
+
return files.filter((f) => {
|
|
1217
|
+
const parts = f.split("/");
|
|
1218
|
+
return isMatch(matchBase ? parts[parts.length - 1] : f);
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
/**
|
|
1222
|
+
* Build a shell command string from a base command and a list of file paths.
|
|
1223
|
+
* File paths containing spaces are wrapped in double quotes.
|
|
1224
|
+
* If no files are provided, the base command is returned as-is.
|
|
1225
|
+
*/
|
|
1226
|
+
function buildCommand(command, files) {
|
|
1227
|
+
if (files.length === 0) return command;
|
|
1228
|
+
return `${command} ${files.map((f) => f.includes(" ") ? `"${f}"` : f).join(" ")}`;
|
|
1229
|
+
}
|
|
1230
|
+
/**
|
|
1231
|
+
* Resolve config commands for a glob entry into an array of command strings.
|
|
1232
|
+
* Function commands receive matched filenames; string commands are used as-is.
|
|
1233
|
+
*/
|
|
1234
|
+
function resolveCommands(commands, matchedFiles) {
|
|
1235
|
+
if (typeof commands === "function") {
|
|
1236
|
+
const resolved = commands(matchedFiles);
|
|
1237
|
+
return Array.isArray(resolved) ? resolved : [resolved];
|
|
1238
|
+
}
|
|
1239
|
+
return Array.isArray(commands) ? commands : [commands];
|
|
1240
|
+
}
|
|
1241
|
+
/**
|
|
1242
|
+
* Run resolved commands for a single glob entry, appending results.
|
|
1243
|
+
* Returns false if any command fails (for fail-fast signaling).
|
|
1244
|
+
*/
|
|
1245
|
+
async function runCommandsForGlob(cmds, isFunction, matchedFiles, timeout, results, repoRoot) {
|
|
1246
|
+
for (const cmd of cmds) {
|
|
1247
|
+
const fullCommand = isFunction ? cmd : buildCommand(cmd, matchedFiles);
|
|
1248
|
+
debug("runCommandsForGlob: running '%s'", fullCommand);
|
|
1249
|
+
const result = await runCommand(fullCommand, timeout, repoRoot);
|
|
1250
|
+
results.push({
|
|
1251
|
+
...result,
|
|
1252
|
+
files: matchedFiles
|
|
1253
|
+
});
|
|
1254
|
+
if (!result.ok) {
|
|
1255
|
+
debug("runCommandsForGlob: check failed, stopping (fail-fast)");
|
|
1256
|
+
return false;
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
return true;
|
|
1260
|
+
}
|
|
1261
|
+
/**
|
|
1262
|
+
* Run all user-defined checks from .cmintrc against staged files.
|
|
1263
|
+
* Returns a no-op result when no config exists.
|
|
1264
|
+
* Fail-fast: stops on first error.
|
|
1265
|
+
*/
|
|
1266
|
+
async function runAllChecks(repoRoot, stagedFiles, timeout) {
|
|
1267
|
+
debug("runAllChecks: %d staged files, checking for config in %s", stagedFiles.length, repoRoot);
|
|
1268
|
+
if (!await detectConfig(repoRoot)) {
|
|
1269
|
+
debug("runAllChecks: no config found, skipping checks");
|
|
1270
|
+
return {
|
|
1271
|
+
ok: true,
|
|
1272
|
+
results: []
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
const config = await loadConfig(repoRoot);
|
|
1276
|
+
debug("runAllChecks: loaded config with %d patterns", Object.keys(config).length);
|
|
1277
|
+
const results = [];
|
|
1278
|
+
for (const [glob, commands] of Object.entries(config)) {
|
|
1279
|
+
const matchedFiles = matchFiles(glob, stagedFiles);
|
|
1280
|
+
const isFunction = typeof commands === "function";
|
|
1281
|
+
if (matchedFiles.length === 0) {
|
|
1282
|
+
debug("runAllChecks: no files matched pattern '%s'", glob);
|
|
1283
|
+
continue;
|
|
1284
|
+
}
|
|
1285
|
+
debug("runAllChecks: pattern '%s' matched %d files", glob, matchedFiles.length);
|
|
1286
|
+
if (!await runCommandsForGlob(resolveCommands(commands, matchedFiles), isFunction, matchedFiles, timeout, results, repoRoot)) return {
|
|
1287
|
+
ok: false,
|
|
1288
|
+
results
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
const ok = results.every((r) => r.ok);
|
|
1292
|
+
debug("runAllChecks: complete — ok=%s, %d results", ok, results.length);
|
|
1293
|
+
return {
|
|
1294
|
+
ok,
|
|
1295
|
+
results
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
//#endregion
|
|
1299
|
+
//#region src/services/grouping.ts
|
|
1300
|
+
function matchesExcludePattern(filePath, pattern) {
|
|
1301
|
+
if (pattern === filePath) return true;
|
|
1302
|
+
if (pattern.endsWith("/**")) {
|
|
1303
|
+
const prefix = pattern.slice(0, -3);
|
|
1304
|
+
return filePath === prefix || filePath.startsWith(`${prefix}/`);
|
|
1305
|
+
}
|
|
1306
|
+
if (pattern.startsWith("*.")) {
|
|
1307
|
+
const suffix = pattern.slice(1);
|
|
1308
|
+
return filePath.endsWith(suffix);
|
|
1309
|
+
}
|
|
1310
|
+
return false;
|
|
1311
|
+
}
|
|
1312
|
+
/** Lockfiles that should be kept when their companion manifest is present */
|
|
1313
|
+
const LOCKFILE_COMPANIONS = {
|
|
1314
|
+
"package-lock.json": "package.json",
|
|
1315
|
+
"pnpm-lock.yaml": "package.json",
|
|
1316
|
+
"yarn.lock": "package.json",
|
|
1317
|
+
"bun.lock": "package.json",
|
|
1318
|
+
"bun.lockb": "package.json"
|
|
1319
|
+
};
|
|
1320
|
+
function filterExcludedFiles(files) {
|
|
1321
|
+
const patterns = getDefaultExcludes();
|
|
1322
|
+
const included = [];
|
|
1323
|
+
const excluded = [];
|
|
1324
|
+
const filePaths = new Set(files.map((f) => f.path));
|
|
1325
|
+
for (const file of files) if (patterns.some((pattern) => matchesExcludePattern(file.path, pattern))) excluded.push(file);
|
|
1326
|
+
else included.push(file);
|
|
1327
|
+
const stillExcluded = [];
|
|
1328
|
+
for (const file of excluded) {
|
|
1329
|
+
const companion = LOCKFILE_COMPANIONS[file.path];
|
|
1330
|
+
if (companion && filePaths.has(companion)) included.push(file);
|
|
1331
|
+
else stillExcluded.push(file.path);
|
|
1332
|
+
}
|
|
1333
|
+
debug("filterExcludedFiles: %d included, %d excluded", included.length, stillExcluded.length);
|
|
1334
|
+
return {
|
|
1335
|
+
included,
|
|
1336
|
+
excluded: stillExcluded
|
|
1337
|
+
};
|
|
1338
|
+
}
|
|
1339
|
+
function statusIndicator(status) {
|
|
1340
|
+
switch (status) {
|
|
1341
|
+
case "M": return "modified";
|
|
1305
1342
|
case "A": return "added";
|
|
1306
1343
|
case "D": return "deleted";
|
|
1307
1344
|
case "R": return "renamed";
|
|
@@ -1352,7 +1389,7 @@ function parseGroupingResponse(content) {
|
|
|
1352
1389
|
});
|
|
1353
1390
|
return rawGroups;
|
|
1354
1391
|
}
|
|
1355
|
-
async function generateGroups(files, apiKey, model, timeout) {
|
|
1392
|
+
async function generateGroups(files, apiKey, model, timeout, provider, proxy) {
|
|
1356
1393
|
debug("generateGroups: %d files, model=%s", files.length, model ?? "default");
|
|
1357
1394
|
const { included, excluded } = filterExcludedFiles(files);
|
|
1358
1395
|
if (included.length === 0) {
|
|
@@ -1367,9 +1404,12 @@ async function generateGroups(files, apiKey, model, timeout) {
|
|
|
1367
1404
|
const userPrompt = buildGroupingUserPrompt(summary);
|
|
1368
1405
|
debug("File summary:\n%s", summary);
|
|
1369
1406
|
debug("User prompt length: %d chars", userPrompt.length);
|
|
1370
|
-
const client =
|
|
1407
|
+
const { client, model: resolvedModel } = createProvider({
|
|
1408
|
+
provider: provider ?? "groq",
|
|
1371
1409
|
apiKey,
|
|
1372
|
-
|
|
1410
|
+
modelOverride: model,
|
|
1411
|
+
timeout: timeout ?? 6e4,
|
|
1412
|
+
baseURLOverride: proxy
|
|
1373
1413
|
});
|
|
1374
1414
|
try {
|
|
1375
1415
|
const completion = await client.chat.completions.create({
|
|
@@ -1380,7 +1420,7 @@ async function generateGroups(files, apiKey, model, timeout) {
|
|
|
1380
1420
|
role: "user",
|
|
1381
1421
|
content: userPrompt
|
|
1382
1422
|
}],
|
|
1383
|
-
model:
|
|
1423
|
+
model: resolvedModel,
|
|
1384
1424
|
temperature: .3,
|
|
1385
1425
|
max_tokens: 2048
|
|
1386
1426
|
});
|
|
@@ -1399,7 +1439,7 @@ async function generateGroups(files, apiKey, model, timeout) {
|
|
|
1399
1439
|
};
|
|
1400
1440
|
} catch (error) {
|
|
1401
1441
|
debug("generateGroups error: %s", error instanceof Error ? error.message : String(error));
|
|
1402
|
-
throw mapGroqError(error);
|
|
1442
|
+
throw mapGroqError(error, provider ? formatProviderName(provider) : void 0);
|
|
1403
1443
|
}
|
|
1404
1444
|
}
|
|
1405
1445
|
function validateGroups(groups, allFiles) {
|
|
@@ -1465,33 +1505,328 @@ async function showGroupingConfirmation(groups, excluded) {
|
|
|
1465
1505
|
function showGroupProgress(current, total, groupName) {
|
|
1466
1506
|
p.log.info(`Commit group ${current} of ${total}: ${cyan(`"${groupName}"`)}`);
|
|
1467
1507
|
}
|
|
1508
|
+
const statusLabel = (status) => {
|
|
1509
|
+
switch (status) {
|
|
1510
|
+
case "M": return yellow("M");
|
|
1511
|
+
case "A": return green("A");
|
|
1512
|
+
case "D": return red("D");
|
|
1513
|
+
case "?":
|
|
1514
|
+
case "??": return cyan("?");
|
|
1515
|
+
default: return dim(status);
|
|
1516
|
+
}
|
|
1517
|
+
};
|
|
1518
|
+
/** Display a table of changed files with status indicators */
|
|
1519
|
+
function showChangedFilesTable(files) {
|
|
1520
|
+
if (files.length === 0) return;
|
|
1521
|
+
const lines = files.map((f) => ` ${statusLabel(f.status)} ${f.path}`);
|
|
1522
|
+
p.note(lines.join("\n"), `${files.length} file${files.length !== 1 ? "s" : ""} changed`);
|
|
1523
|
+
}
|
|
1524
|
+
/** Display a compact grouping summary (only shown when >1 group) */
|
|
1525
|
+
function showGroupingSummary(groups) {
|
|
1526
|
+
if (groups.length <= 1) return;
|
|
1527
|
+
const lines = groups.map((g) => `${bold(g.name)} ${dim("—")} ${g.files.length} file${g.files.length !== 1 ? "s" : ""}`);
|
|
1528
|
+
p.note(lines.join("\n"), "Commit groups");
|
|
1529
|
+
}
|
|
1530
|
+
//#endregion
|
|
1531
|
+
//#region src/ui/menu.ts
|
|
1532
|
+
async function showStagingMenu(files, hasChecks) {
|
|
1533
|
+
debug("showStagingMenu: %d files", files.length);
|
|
1534
|
+
const statusLabel = (status) => {
|
|
1535
|
+
switch (status) {
|
|
1536
|
+
case "M": return yellow("M");
|
|
1537
|
+
case "A": return green("A");
|
|
1538
|
+
case "D": return red("D");
|
|
1539
|
+
case "?":
|
|
1540
|
+
case "??": return cyan("?");
|
|
1541
|
+
default: return dim(status);
|
|
1542
|
+
}
|
|
1543
|
+
};
|
|
1544
|
+
const sorted = [...files].sort((a, b) => {
|
|
1545
|
+
if (a.staged !== b.staged) return a.staged ? -1 : 1;
|
|
1546
|
+
return a.path.localeCompare(b.path);
|
|
1547
|
+
});
|
|
1548
|
+
const stagedFiles = sorted.filter((f) => f.staged);
|
|
1549
|
+
const unstagedFiles = sorted.filter((f) => !f.staged);
|
|
1550
|
+
const lines = [];
|
|
1551
|
+
if (stagedFiles.length > 0) lines.push(green(bold("Staged:")), ...stagedFiles.map((f) => ` ${statusLabel(f.status)} ${f.path}`));
|
|
1552
|
+
if (unstagedFiles.length > 0) {
|
|
1553
|
+
if (lines.length > 0) lines.push("");
|
|
1554
|
+
lines.push(yellow(bold("Changed:")), ...unstagedFiles.map((f) => ` ${statusLabel(f.status)} ${f.path}`));
|
|
1555
|
+
}
|
|
1556
|
+
p.note(lines.join("\n"), `${files.length} file${files.length !== 1 ? "s" : ""}`);
|
|
1557
|
+
const choice = await p.select({
|
|
1558
|
+
message: "Stage files for commit:",
|
|
1559
|
+
options: [
|
|
1560
|
+
{
|
|
1561
|
+
label: "Auto-group into commits",
|
|
1562
|
+
value: "autogroup",
|
|
1563
|
+
hint: "LLM groups files into logical commits"
|
|
1564
|
+
},
|
|
1565
|
+
...stagedFiles.length > 0 ? [{
|
|
1566
|
+
label: "Commit staged files only",
|
|
1567
|
+
value: "staged",
|
|
1568
|
+
hint: `${stagedFiles.length} file${stagedFiles.length !== 1 ? "s" : ""} already staged`
|
|
1569
|
+
}] : [],
|
|
1570
|
+
{
|
|
1571
|
+
label: "Stage all files",
|
|
1572
|
+
value: "all",
|
|
1573
|
+
hint: `${files.length} file${files.length !== 1 ? "s" : ""}`
|
|
1574
|
+
},
|
|
1575
|
+
...hasChecks ? [{
|
|
1576
|
+
label: "Run checks",
|
|
1577
|
+
value: "checks",
|
|
1578
|
+
hint: "Pre-flight checks from cmint config"
|
|
1579
|
+
}] : [],
|
|
1580
|
+
{
|
|
1581
|
+
label: "Select files...",
|
|
1582
|
+
value: "select"
|
|
1583
|
+
},
|
|
1584
|
+
{
|
|
1585
|
+
label: "Cancel",
|
|
1586
|
+
value: "cancel"
|
|
1587
|
+
}
|
|
1588
|
+
]
|
|
1589
|
+
});
|
|
1590
|
+
if (p.isCancel(choice) || choice === "cancel") return null;
|
|
1591
|
+
if (choice === "autogroup") return "autogroup";
|
|
1592
|
+
if (choice === "checks") return "checks";
|
|
1593
|
+
if (choice === "staged") return "staged";
|
|
1594
|
+
if (choice === "all") return {
|
|
1595
|
+
files: files.map((f) => f.path),
|
|
1596
|
+
all: true
|
|
1597
|
+
};
|
|
1598
|
+
const selected = await p.multiselect({
|
|
1599
|
+
message: "Select files to stage:",
|
|
1600
|
+
options: sorted.map((f) => ({
|
|
1601
|
+
label: `${statusLabel(f.status)} ${f.path}`,
|
|
1602
|
+
value: f.path
|
|
1603
|
+
})),
|
|
1604
|
+
required: true
|
|
1605
|
+
});
|
|
1606
|
+
if (p.isCancel(selected)) return null;
|
|
1607
|
+
return {
|
|
1608
|
+
files: selected,
|
|
1609
|
+
all: false
|
|
1610
|
+
};
|
|
1611
|
+
}
|
|
1612
|
+
async function showRecoveryMenu(errors, onRetry, onSkipHooks, onRestage, message, rawStderr) {
|
|
1613
|
+
debug("showRecoveryMenu: %d errors", errors.length);
|
|
1614
|
+
let clipboardCopied = false;
|
|
1615
|
+
let showNote = true;
|
|
1616
|
+
while (true) {
|
|
1617
|
+
if (showNote) {
|
|
1618
|
+
p.note(errors.map((e) => ` ${red("•")} [${e.tool}] ${e.message}`).join("\n"), red(bold("Pre-commit hook failed")));
|
|
1619
|
+
showNote = false;
|
|
1620
|
+
}
|
|
1621
|
+
const choice = await p.select({
|
|
1622
|
+
message: "What do you want to do?",
|
|
1623
|
+
options: [
|
|
1624
|
+
{
|
|
1625
|
+
label: clipboardCopied ? `${green("✓")} Copy error report to clipboard` : "Copy error report to clipboard",
|
|
1626
|
+
value: "clipboard",
|
|
1627
|
+
hint: clipboardCopied ? "Copied!" : "Paste into another terminal for an AI agent"
|
|
1628
|
+
},
|
|
1629
|
+
{
|
|
1630
|
+
label: "View full error output",
|
|
1631
|
+
value: "view",
|
|
1632
|
+
hint: "Show the raw stderr from hooks"
|
|
1633
|
+
},
|
|
1634
|
+
{
|
|
1635
|
+
label: "Skip hooks and commit (--no-verify)",
|
|
1636
|
+
value: "skip",
|
|
1637
|
+
hint: "Commit anyway, fix later"
|
|
1638
|
+
},
|
|
1639
|
+
{
|
|
1640
|
+
label: "Re-stage files and retry",
|
|
1641
|
+
value: "restage",
|
|
1642
|
+
hint: "Pick up fixes from another terminal"
|
|
1643
|
+
},
|
|
1644
|
+
{
|
|
1645
|
+
label: "Edit commit message",
|
|
1646
|
+
value: "edit",
|
|
1647
|
+
hint: "Modify the message before retrying"
|
|
1648
|
+
},
|
|
1649
|
+
{
|
|
1650
|
+
label: "Cancel",
|
|
1651
|
+
value: "cancel"
|
|
1652
|
+
}
|
|
1653
|
+
]
|
|
1654
|
+
});
|
|
1655
|
+
if (p.isCancel(choice)) {
|
|
1656
|
+
debug("showRecoveryMenu: user cancelled");
|
|
1657
|
+
p.outro(yellow("Cancelled. Message cached for --retry."));
|
|
1658
|
+
return "cancelled";
|
|
1659
|
+
}
|
|
1660
|
+
debug("showRecoveryMenu: user chose %s", choice);
|
|
1661
|
+
switch (choice) {
|
|
1662
|
+
case "clipboard":
|
|
1663
|
+
if (await copyToClipboard(rawStderr)) {
|
|
1664
|
+
clipboardCopied = true;
|
|
1665
|
+
p.log.step(green("Copied to clipboard."));
|
|
1666
|
+
} else p.log.warn(red("No clipboard tool found. Install xclip, wl-copy, or xsel."));
|
|
1667
|
+
continue;
|
|
1668
|
+
case "view":
|
|
1669
|
+
p.note(rawStderr.trim() || "(no raw output)", "Full error output");
|
|
1670
|
+
showNote = true;
|
|
1671
|
+
continue;
|
|
1672
|
+
case "skip":
|
|
1673
|
+
p.log.info(yellow("Committing with --no-verify..."));
|
|
1674
|
+
if (await onSkipHooks(message)) {
|
|
1675
|
+
p.outro(green("Committed (hooks skipped)."));
|
|
1676
|
+
return "committed";
|
|
1677
|
+
} else {
|
|
1678
|
+
p.outro(red("Commit failed even with --no-verify."));
|
|
1679
|
+
return "failed";
|
|
1680
|
+
}
|
|
1681
|
+
case "restage":
|
|
1682
|
+
p.log.info(cyan("Re-staging and retrying..."));
|
|
1683
|
+
if (await onRestage()) {
|
|
1684
|
+
p.outro(green("Committed successfully."));
|
|
1685
|
+
return "committed";
|
|
1686
|
+
}
|
|
1687
|
+
showNote = true;
|
|
1688
|
+
continue;
|
|
1689
|
+
case "edit": {
|
|
1690
|
+
const edited = await p.text({
|
|
1691
|
+
message: "Edit commit message:",
|
|
1692
|
+
initialValue: message,
|
|
1693
|
+
validate: (v) => v?.trim() ? void 0 : "Message cannot be empty"
|
|
1694
|
+
});
|
|
1695
|
+
if (p.isCancel(edited)) {
|
|
1696
|
+
p.outro(yellow("Cancelled. Message cached for --retry."));
|
|
1697
|
+
return "cancelled";
|
|
1698
|
+
}
|
|
1699
|
+
if (await onRetry()) {
|
|
1700
|
+
p.outro(green("Committed successfully."));
|
|
1701
|
+
return "committed";
|
|
1702
|
+
} else {
|
|
1703
|
+
p.outro(red("Commit failed again."));
|
|
1704
|
+
return "failed";
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
case "cancel":
|
|
1708
|
+
p.outro(dim("Message cached for --retry."));
|
|
1709
|
+
return "cancelled";
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
async function showCheckFailureMenu(errors, rawStderr) {
|
|
1714
|
+
debug("showCheckFailureMenu: %d errors", errors.length);
|
|
1715
|
+
let clipboardCopied = false;
|
|
1716
|
+
p.note(errors.map((e) => ` ${red("•")} [${e.tool}] ${e.message}`).join("\n"), red("Pre-commit check failed"));
|
|
1717
|
+
while (true) {
|
|
1718
|
+
const choice = await p.select({
|
|
1719
|
+
message: "What do you want to do?",
|
|
1720
|
+
options: [
|
|
1721
|
+
{
|
|
1722
|
+
label: clipboardCopied ? `${green("✓")} Copy error report to clipboard` : "Copy error report to clipboard",
|
|
1723
|
+
value: "copy"
|
|
1724
|
+
},
|
|
1725
|
+
{
|
|
1726
|
+
label: "View full error output",
|
|
1727
|
+
value: "view",
|
|
1728
|
+
hint: "Show the raw stderr from checks"
|
|
1729
|
+
},
|
|
1730
|
+
{
|
|
1731
|
+
label: "Skip checks and commit",
|
|
1732
|
+
value: "skip"
|
|
1733
|
+
},
|
|
1734
|
+
{
|
|
1735
|
+
label: "Cancel",
|
|
1736
|
+
value: "cancel"
|
|
1737
|
+
}
|
|
1738
|
+
]
|
|
1739
|
+
});
|
|
1740
|
+
if (p.isCancel(choice)) {
|
|
1741
|
+
debug("showCheckFailureMenu: user cancelled");
|
|
1742
|
+
return "cancelled";
|
|
1743
|
+
}
|
|
1744
|
+
debug("showCheckFailureMenu: user chose %s", choice);
|
|
1745
|
+
switch (choice) {
|
|
1746
|
+
case "copy":
|
|
1747
|
+
if (await copyToClipboard(rawStderr)) {
|
|
1748
|
+
clipboardCopied = true;
|
|
1749
|
+
p.log.step(green("Copied to clipboard."));
|
|
1750
|
+
} else p.log.warn(red("No clipboard tool found. Install xclip, wl-copy, or xsel."));
|
|
1751
|
+
continue;
|
|
1752
|
+
case "view":
|
|
1753
|
+
p.note(rawStderr.trim() || "(no raw output)", "Full error output");
|
|
1754
|
+
continue;
|
|
1755
|
+
case "skip":
|
|
1756
|
+
p.log.info("Skipping checks and proceeding with commit...");
|
|
1757
|
+
return "skipped";
|
|
1758
|
+
case "cancel":
|
|
1759
|
+
p.outro(dim("Cancelled."));
|
|
1760
|
+
return "cancelled";
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1468
1764
|
//#endregion
|
|
1469
1765
|
//#region src/commands/auto-group.ts
|
|
1470
1766
|
async function runAutoGroupFlow(changedFiles, flags) {
|
|
1471
1767
|
const { included, excluded } = filterExcludedFiles(changedFiles);
|
|
1768
|
+
if (excluded.length > 0) {
|
|
1769
|
+
debug("Committing %d excluded files upfront:", excluded.length, excluded);
|
|
1770
|
+
const message = buildExcludedFilesMessage(excluded);
|
|
1771
|
+
await resetStaging();
|
|
1772
|
+
await stageFiles(excluded);
|
|
1773
|
+
const headBefore = await getHead();
|
|
1774
|
+
const commitResult = await attemptCommit(message);
|
|
1775
|
+
const headAfter = await getHead();
|
|
1776
|
+
if (commitResult.ok || headBefore !== headAfter) debug("Excluded files committed:", message);
|
|
1777
|
+
else debug("Excluded files commit failed, continuing without them");
|
|
1778
|
+
}
|
|
1779
|
+
if (included.length === 0) {
|
|
1780
|
+
debug("No included files to group, done");
|
|
1781
|
+
outro(green("Done."));
|
|
1782
|
+
return "committed";
|
|
1783
|
+
}
|
|
1784
|
+
if (!flags.noCheck) {
|
|
1785
|
+
const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
|
|
1786
|
+
const repoRoot = await getRepoRoot();
|
|
1787
|
+
const allFiles = included.filter((f) => f.status !== "D").map((f) => f.path);
|
|
1788
|
+
debug("Running user checks on %d files...", allFiles.length);
|
|
1789
|
+
const ck = spinner();
|
|
1790
|
+
ck.start("Running checks...");
|
|
1791
|
+
const checkResults = await runAllChecks(repoRoot, allFiles, 6e4);
|
|
1792
|
+
debug("Check results: ok=%s, count=%d", checkResults.ok, checkResults.results.length);
|
|
1793
|
+
if (!checkResults.ok) {
|
|
1794
|
+
ck.stop(`${checkResults.results.filter((r) => !r.ok).length} check(s) failed`);
|
|
1795
|
+
const rawStderr = checkResults.results.filter((r) => !r.ok).map((r) => `[${r.tool}] ${r.stderr}`).join("\n");
|
|
1796
|
+
if (await showCheckFailureMenu(parseHookErrors(rawStderr), rawStderr) === "cancelled") return "cancelled";
|
|
1797
|
+
} else {
|
|
1798
|
+
ck.stop("All checks passed");
|
|
1799
|
+
if (checkResults.results.length > 0) log.info(checkResults.results.map((r) => ` ${green("✓")} ${r.tool}`).join("\n"));
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
const config = await readConfig();
|
|
1803
|
+
const resolvedProvider = config.provider ?? "groq";
|
|
1804
|
+
const provider = isValidProvider(resolvedProvider) ? resolvedProvider : "groq";
|
|
1472
1805
|
try {
|
|
1473
|
-
await
|
|
1806
|
+
await getProviderApiKey(provider);
|
|
1474
1807
|
debug("API key found");
|
|
1475
1808
|
} catch {
|
|
1476
1809
|
debug("No API key found, prompting user");
|
|
1477
1810
|
const { text: promptText } = await import("@clack/prompts");
|
|
1478
1811
|
const key = await promptText({
|
|
1479
|
-
message:
|
|
1480
|
-
placeholder: "gsk_...",
|
|
1812
|
+
message: `Enter your ${formatProviderName(provider)} API key:`,
|
|
1813
|
+
placeholder: provider === "groq" ? "gsk_..." : "...",
|
|
1481
1814
|
validate: (v) => v?.trim() ? void 0 : "API key is required"
|
|
1482
1815
|
});
|
|
1483
1816
|
if (isCancel(key)) {
|
|
1484
1817
|
outro(dim("Cancelled."));
|
|
1485
1818
|
return "cancelled";
|
|
1486
1819
|
}
|
|
1487
|
-
|
|
1820
|
+
const configKey = PROVIDER_ENV_KEYS[provider];
|
|
1821
|
+
await setConfigValue(configKey, String(key).trim());
|
|
1488
1822
|
debug("API key saved to config");
|
|
1489
1823
|
}
|
|
1490
1824
|
const s = spinner();
|
|
1491
1825
|
s.start("Analyzing files...");
|
|
1492
|
-
const
|
|
1493
|
-
const validatedGroups = validateGroups((await generateGroups(included, await getApiKey(), config.model, config.timeout ? parseInt(config.timeout, 10) : void 0)).groups, included);
|
|
1826
|
+
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);
|
|
1494
1827
|
s.stop("Files analyzed");
|
|
1828
|
+
showChangedFilesTable(included);
|
|
1829
|
+
showGroupingSummary(validatedGroups);
|
|
1495
1830
|
if (flags.auto) debug("Auto mode: skipping grouping confirmation");
|
|
1496
1831
|
else if (!await showGroupingConfirmation(validatedGroups, excluded)) {
|
|
1497
1832
|
outro(dim("Cancelled."));
|
|
@@ -1558,14 +1893,19 @@ async function runAutoGroupFlow(changedFiles, flags) {
|
|
|
1558
1893
|
}
|
|
1559
1894
|
async function generateMessage(diff, hint) {
|
|
1560
1895
|
const config = await readConfig();
|
|
1561
|
-
const
|
|
1562
|
-
|
|
1896
|
+
const resolvedProvider = config.provider ?? "groq";
|
|
1897
|
+
const provider = isValidProvider(resolvedProvider) ? resolvedProvider : "groq";
|
|
1898
|
+
const apiKey = await getProviderApiKey(provider);
|
|
1899
|
+
const model = getModelForProvider(config, provider, PROVIDER_CONFIGS[provider].defaultModel);
|
|
1900
|
+
debug("Generating message with provider:", provider, "model:", model, "type:", config.type);
|
|
1563
1901
|
return generateCommitMessage(diff, {
|
|
1564
1902
|
apiKey,
|
|
1565
|
-
model
|
|
1903
|
+
model,
|
|
1566
1904
|
type: config.type,
|
|
1567
1905
|
timeout: config.timeout ? parseInt(config.timeout, 10) : void 0,
|
|
1568
|
-
hint
|
|
1906
|
+
hint,
|
|
1907
|
+
provider,
|
|
1908
|
+
proxy: config.proxy
|
|
1569
1909
|
});
|
|
1570
1910
|
}
|
|
1571
1911
|
function buildExcludedFilesMessage(files) {
|
|
@@ -1578,46 +1918,143 @@ function buildExcludedFilesMessage(files) {
|
|
|
1578
1918
|
return "chore: update generated files";
|
|
1579
1919
|
}
|
|
1580
1920
|
//#endregion
|
|
1581
|
-
//#region src/commands/commit.ts
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1921
|
+
//#region src/commands/commit-utils.ts
|
|
1922
|
+
/** Shared recovery menu factory — avoids repeating the same callback set */
|
|
1923
|
+
function makeRecoveryCallbacks(message) {
|
|
1924
|
+
return {
|
|
1925
|
+
retry: async () => (await attemptCommit(message)).ok,
|
|
1926
|
+
skipHooks: async (msg) => (await attemptCommitNoVerify(msg)).ok,
|
|
1927
|
+
restage: async () => {
|
|
1928
|
+
await stageAll();
|
|
1929
|
+
return (await attemptCommit(message)).ok;
|
|
1930
|
+
},
|
|
1931
|
+
message
|
|
1932
|
+
};
|
|
1933
|
+
}
|
|
1934
|
+
/**
|
|
1935
|
+
* Attempt commit with automatic recovery flow.
|
|
1936
|
+
* Handles the attempt → HEAD check → success (tool checks display)
|
|
1937
|
+
* / failure (recovery menu) pattern.
|
|
1938
|
+
* Caller is responsible for starting the spinner and showing the final outro.
|
|
1939
|
+
*/
|
|
1940
|
+
async function commitWithRecovery(message, s, headBefore) {
|
|
1941
|
+
const result = await attemptCommit(message, [], createProgressHandler(s));
|
|
1942
|
+
const headAfter = await getHead();
|
|
1943
|
+
if (result.ok || headBefore !== headAfter) {
|
|
1944
|
+
s.stop("Committed successfully.");
|
|
1945
|
+
const checks = parseToolChecks(result.stderr ?? "");
|
|
1946
|
+
if (checks.length > 0) {
|
|
1947
|
+
const lines = checks.map((c) => ` ${c.ok ? green("✓") : red("✗")} ${c.tool}`);
|
|
1948
|
+
log.info(lines.join("\n"));
|
|
1595
1949
|
}
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1950
|
+
return "committed";
|
|
1951
|
+
}
|
|
1952
|
+
s.stop("Commit failed.");
|
|
1953
|
+
const errors = parseHookErrors(result.stderr ?? "");
|
|
1954
|
+
const cb = makeRecoveryCallbacks(message);
|
|
1955
|
+
if (await showRecoveryMenu(errors, cb.retry, cb.skipHooks, cb.restage, cb.message, result.stderr ?? "") === "cancelled") return "cancelled";
|
|
1956
|
+
return "committed";
|
|
1957
|
+
}
|
|
1958
|
+
//#endregion
|
|
1959
|
+
//#region src/commands/retry.ts
|
|
1960
|
+
/** Handle --retry mode: load cached message and re-attempt commit */
|
|
1961
|
+
async function handleRetry() {
|
|
1962
|
+
debug("Entering retry mode");
|
|
1963
|
+
const cached = await loadCachedCommit(await getRepoRoot());
|
|
1964
|
+
if (!cached) {
|
|
1965
|
+
outro(red("No cached commit message found. Run cmint without --retry first."));
|
|
1966
|
+
process.exit(1);
|
|
1967
|
+
}
|
|
1968
|
+
intro("🌿 commit-mint — retry");
|
|
1969
|
+
const s = spinner();
|
|
1970
|
+
const headBefore = await getHead();
|
|
1971
|
+
s.start("Running pre-commit hooks...");
|
|
1972
|
+
if (await commitWithRecovery(cached.message, s, headBefore) === "committed") outro(green("Committed successfully."));
|
|
1973
|
+
else process.exit(1);
|
|
1974
|
+
}
|
|
1975
|
+
//#endregion
|
|
1976
|
+
//#region src/commands/staging.ts
|
|
1977
|
+
/** Interactive staging loop for multiple changed files */
|
|
1978
|
+
async function handleStaging(changedFiles, flags) {
|
|
1979
|
+
const repoRoot = await getRepoRoot();
|
|
1980
|
+
const checksAvailable = await detectConfig(repoRoot) !== null;
|
|
1981
|
+
debug("checks available:", checksAvailable);
|
|
1982
|
+
let stagingResult = null;
|
|
1983
|
+
let filesToStage = [];
|
|
1984
|
+
let stageAllFlag = false;
|
|
1985
|
+
let skipStaging = false;
|
|
1986
|
+
let currentFiles = changedFiles;
|
|
1987
|
+
while (true) {
|
|
1988
|
+
stagingResult = await showStagingMenu(currentFiles, checksAvailable);
|
|
1989
|
+
if (stagingResult === "autogroup") {
|
|
1990
|
+
if (flags.message) {
|
|
1991
|
+
outro(red("--message flag is not compatible with auto-group mode."));
|
|
1992
|
+
return null;
|
|
1608
1993
|
}
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1994
|
+
if (await runAutoGroupFlow(currentFiles, flags) !== "committed") process.exit(1);
|
|
1995
|
+
return null;
|
|
1996
|
+
}
|
|
1997
|
+
if (stagingResult === "checks") {
|
|
1998
|
+
await stageAll();
|
|
1999
|
+
const ckSpinner = spinner();
|
|
2000
|
+
ckSpinner.start("Running checks...");
|
|
2001
|
+
const ckResult = await runAllChecks(repoRoot, currentFiles.filter((f) => f.status !== "D").map((f) => f.path), 6e4);
|
|
2002
|
+
if (ckResult.ok) {
|
|
2003
|
+
ckSpinner.stop("All checks passed");
|
|
2004
|
+
for (const r of ckResult.results) if (r.stdout.trim()) log.info(dim(r.stdout.trim()));
|
|
2005
|
+
} else {
|
|
2006
|
+
const failed = ckResult.results.filter((r) => !r.ok);
|
|
2007
|
+
ckSpinner.stop(`${failed.length} check${failed.length !== 1 ? "s" : ""} failed`);
|
|
2008
|
+
for (const r of failed) log.info(r.stderr?.trim() || r.stdout?.trim() || `Check failed: ${r.command}`);
|
|
2009
|
+
}
|
|
2010
|
+
currentFiles = await getChangedFiles();
|
|
2011
|
+
continue;
|
|
1618
2012
|
}
|
|
2013
|
+
if (stagingResult === "staged") {
|
|
2014
|
+
skipStaging = true;
|
|
2015
|
+
break;
|
|
2016
|
+
}
|
|
2017
|
+
if (!stagingResult) {
|
|
2018
|
+
outro(dim("Cancelled."));
|
|
2019
|
+
return null;
|
|
2020
|
+
}
|
|
2021
|
+
filesToStage = stagingResult.files;
|
|
2022
|
+
stageAllFlag = stagingResult.all;
|
|
2023
|
+
break;
|
|
2024
|
+
}
|
|
2025
|
+
if (!skipStaging) {
|
|
2026
|
+
const s = spinner();
|
|
2027
|
+
s.start(`Staging ${filesToStage.length} file${filesToStage.length !== 1 ? "s" : ""}...`);
|
|
2028
|
+
if (stageAllFlag) await stageAll();
|
|
2029
|
+
else await stageFiles(filesToStage);
|
|
2030
|
+
s.stop("Files staged");
|
|
1619
2031
|
}
|
|
1620
|
-
|
|
2032
|
+
return {
|
|
2033
|
+
changedFiles: currentFiles,
|
|
2034
|
+
skipStaging
|
|
2035
|
+
};
|
|
2036
|
+
}
|
|
2037
|
+
/** Run user-defined pre-commit checks from cmint config */
|
|
2038
|
+
async function runPreCommitChecks(changedFiles, noCheck) {
|
|
2039
|
+
if (noCheck) return;
|
|
2040
|
+
const checkRoot = await getRepoRoot();
|
|
2041
|
+
const stagedFileList = changedFiles.filter((f) => f.staged && f.status !== "D").map((f) => f.path);
|
|
2042
|
+
if (stagedFileList.length === 0) return;
|
|
2043
|
+
debug("Running user checks on %d staged files...", stagedFileList.length);
|
|
2044
|
+
const checkResults = await runAllChecks(checkRoot, stagedFileList, 6e4);
|
|
2045
|
+
debug("Check results: ok=%s, count=%d", checkResults.ok, checkResults.results.length);
|
|
2046
|
+
if (!checkResults.ok) {
|
|
2047
|
+
const rawStderr = checkResults.results.filter((r) => !r.ok).map((r) => `[${r.tool}] ${r.stderr}`).join("\n");
|
|
2048
|
+
if (await showCheckFailureMenu(parseHookErrors(rawStderr), rawStderr) === "cancelled") process.exit(1);
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
//#endregion
|
|
2052
|
+
//#region src/commands/commit.ts
|
|
2053
|
+
async function commitCommand(flags) {
|
|
2054
|
+
debug("commitCommand called", { flags });
|
|
2055
|
+
await assertGitRepo();
|
|
2056
|
+
if (flags.retry) return handleRetry();
|
|
2057
|
+
intro("🌿 commit-mint");
|
|
1621
2058
|
const status = await getStatusShort();
|
|
1622
2059
|
debug("Git status:", status || "(empty)");
|
|
1623
2060
|
if (!status) {
|
|
@@ -1640,49 +2077,9 @@ async function commitCommand(flags) {
|
|
|
1640
2077
|
await stageFiles([changedFiles[0].path]);
|
|
1641
2078
|
s.stop("File staged");
|
|
1642
2079
|
} 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");
|
|
2080
|
+
const result = await handleStaging(changedFiles, flags);
|
|
2081
|
+
if (!result) return;
|
|
2082
|
+
changedFiles = result.changedFiles;
|
|
1686
2083
|
}
|
|
1687
2084
|
} catch (err) {
|
|
1688
2085
|
s.stop(red("Staging failed."));
|
|
@@ -1691,6 +2088,8 @@ async function commitCommand(flags) {
|
|
|
1691
2088
|
outro(red(`Failed to stage files: ${msg}`));
|
|
1692
2089
|
process.exit(1);
|
|
1693
2090
|
}
|
|
2091
|
+
changedFiles = await getChangedFiles();
|
|
2092
|
+
await runPreCommitChecks(changedFiles, flags.noCheck);
|
|
1694
2093
|
const diffResult = await getStagedDiff();
|
|
1695
2094
|
if (!diffResult) {
|
|
1696
2095
|
debug("No staged changes found after staging");
|
|
@@ -1701,22 +2100,14 @@ async function commitCommand(flags) {
|
|
|
1701
2100
|
debug("All staged files are excluded:", diffResult.excludedFiles);
|
|
1702
2101
|
const message = buildExcludedFilesMessage(diffResult.excludedFiles);
|
|
1703
2102
|
log.info(diffResult.excludedFiles.map((f) => ` ${f}`).join("\n"));
|
|
1704
|
-
const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
|
|
1705
2103
|
await saveCachedCommit(await getRepoRoot(), message);
|
|
1706
2104
|
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.");
|
|
2105
|
+
const result = await commitWithRecovery(message, s, await getHead());
|
|
2106
|
+
if (result === "committed") {
|
|
1712
2107
|
outro(green("Done."));
|
|
1713
2108
|
return;
|
|
1714
2109
|
}
|
|
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);
|
|
2110
|
+
if (result === "cancelled") process.exit(1);
|
|
1720
2111
|
return;
|
|
1721
2112
|
}
|
|
1722
2113
|
debug("Staged files:", diffResult.files);
|
|
@@ -1727,22 +2118,25 @@ async function commitCommand(flags) {
|
|
|
1727
2118
|
debug("Using provided message:", flags.message);
|
|
1728
2119
|
message = flags.message;
|
|
1729
2120
|
} else {
|
|
2121
|
+
const config = await readConfig();
|
|
2122
|
+
const provider = isValidProvider(config.provider ?? "groq") ? config.provider : "groq";
|
|
1730
2123
|
try {
|
|
1731
|
-
await
|
|
2124
|
+
await getProviderApiKey(provider);
|
|
1732
2125
|
debug("API key found");
|
|
1733
2126
|
} catch {
|
|
1734
2127
|
debug("No API key found, prompting user");
|
|
1735
2128
|
const { text: promptText } = await import("@clack/prompts");
|
|
2129
|
+
const configKey = PROVIDER_ENV_KEYS[provider];
|
|
1736
2130
|
const key = await promptText({
|
|
1737
|
-
message:
|
|
1738
|
-
placeholder: "gsk_...",
|
|
2131
|
+
message: `Enter your ${formatProviderName(provider)} API key:`,
|
|
2132
|
+
placeholder: provider === "groq" ? "gsk_..." : "...",
|
|
1739
2133
|
validate: (v) => v?.trim() ? void 0 : "API key is required"
|
|
1740
2134
|
});
|
|
1741
2135
|
if (isCancel(key)) {
|
|
1742
2136
|
outro(dim("Cancelled."));
|
|
1743
2137
|
return;
|
|
1744
2138
|
}
|
|
1745
|
-
await setConfigValue(
|
|
2139
|
+
await setConfigValue(configKey, String(key).trim());
|
|
1746
2140
|
debug("API key saved to config");
|
|
1747
2141
|
}
|
|
1748
2142
|
s.start("Generating commit message...");
|
|
@@ -1765,66 +2159,197 @@ async function commitCommand(flags) {
|
|
|
1765
2159
|
return;
|
|
1766
2160
|
}
|
|
1767
2161
|
message = reviewed;
|
|
1768
|
-
const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
|
|
1769
2162
|
const repoRoot = await getRepoRoot();
|
|
1770
2163
|
await saveCachedCommit(repoRoot, message);
|
|
1771
2164
|
debug("Message cached for repo:", repoRoot);
|
|
1772
2165
|
s.start("Running pre-commit hooks...");
|
|
1773
2166
|
const headBefore = await getHead();
|
|
1774
2167
|
debug("HEAD before commit:", headBefore);
|
|
1775
|
-
const result = await
|
|
1776
|
-
const headAfter = await getHead();
|
|
1777
|
-
debug("HEAD after commit:", headAfter);
|
|
2168
|
+
const result = await commitWithRecovery(message, s, headBefore);
|
|
1778
2169
|
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
|
-
}
|
|
2170
|
+
if (result === "committed") {
|
|
1786
2171
|
outro(green("Done."));
|
|
1787
2172
|
return;
|
|
1788
2173
|
}
|
|
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);
|
|
2174
|
+
if (result === "cancelled") process.exit(1);
|
|
1801
2175
|
}
|
|
1802
2176
|
//#endregion
|
|
1803
2177
|
//#region src/commands/config.ts
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
}
|
|
1815
|
-
|
|
2178
|
+
function maskKey(key) {
|
|
2179
|
+
if (!key) return dim("not set");
|
|
2180
|
+
if (key.length <= 8) return "****";
|
|
2181
|
+
return `${key.slice(0, 4)}${"*".repeat(Math.min(key.length - 8, 20))}${key.slice(-4)}`;
|
|
2182
|
+
}
|
|
2183
|
+
function buildConfigDisplay(config) {
|
|
2184
|
+
const provider = isValidProvider(config.provider ?? "groq") ? config.provider : "groq";
|
|
2185
|
+
const apiKey = config[PROVIDER_ENV_KEYS[provider]];
|
|
2186
|
+
return [
|
|
2187
|
+
`Provider: ${bold(formatProviderName(provider))}`,
|
|
2188
|
+
`API Key: ${maskKey(apiKey)}`,
|
|
2189
|
+
`Model: ${config.model ?? "(none)"}`,
|
|
2190
|
+
`Locale: ${config.locale ?? "en"}`,
|
|
2191
|
+
`Max Length: ${config["max-length"] ?? "100"}`,
|
|
2192
|
+
`Commit Type: ${config.type || dim("(none)")}`,
|
|
2193
|
+
`Timeout: ${config.timeout ?? "10000"}ms`,
|
|
2194
|
+
`Proxy: ${config.proxy || dim("(none)")}`
|
|
2195
|
+
].join("\n");
|
|
2196
|
+
}
|
|
2197
|
+
function getProvider(config) {
|
|
2198
|
+
return isValidProvider(config.provider ?? "groq") ? config.provider : "groq";
|
|
2199
|
+
}
|
|
2200
|
+
async function promptProvider() {
|
|
2201
|
+
return p.select({
|
|
2202
|
+
message: "Select LLM provider:",
|
|
2203
|
+
options: [
|
|
2204
|
+
{
|
|
2205
|
+
label: "Groq",
|
|
2206
|
+
value: "groq",
|
|
2207
|
+
hint: PROVIDER_CONFIGS.groq.defaultModel
|
|
2208
|
+
},
|
|
2209
|
+
{
|
|
2210
|
+
label: "Cerebras",
|
|
2211
|
+
value: "cerebras",
|
|
2212
|
+
hint: PROVIDER_CONFIGS.cerebras.defaultModel
|
|
2213
|
+
},
|
|
2214
|
+
{
|
|
2215
|
+
label: "Mistral",
|
|
2216
|
+
value: "mistral",
|
|
2217
|
+
hint: PROVIDER_CONFIGS.mistral.defaultModel
|
|
2218
|
+
}
|
|
2219
|
+
]
|
|
2220
|
+
});
|
|
2221
|
+
}
|
|
2222
|
+
async function promptApiKey(provider) {
|
|
2223
|
+
const keyName = PROVIDER_ENV_KEYS[provider];
|
|
2224
|
+
const result = await p.text({
|
|
2225
|
+
message: `${formatProviderName(provider)} API key:`,
|
|
2226
|
+
placeholder: "Paste your API key",
|
|
2227
|
+
validate: (v) => !v?.trim() ? "API key cannot be empty" : void 0
|
|
2228
|
+
});
|
|
2229
|
+
if (p.isCancel(result)) return result;
|
|
2230
|
+
await writeConfig({ [keyName]: result.toString().trim() });
|
|
2231
|
+
debug("config: %s set", keyName);
|
|
2232
|
+
return result;
|
|
2233
|
+
}
|
|
2234
|
+
async function promptTextSetting(label, configKey, currentValue, validate) {
|
|
2235
|
+
const result = await p.text({
|
|
2236
|
+
message: label,
|
|
2237
|
+
placeholder: currentValue ?? "",
|
|
2238
|
+
initialValue: currentValue ?? "",
|
|
2239
|
+
validate
|
|
2240
|
+
});
|
|
2241
|
+
if (p.isCancel(result)) return result;
|
|
2242
|
+
await writeConfig({ [configKey]: result.toString().trim() });
|
|
2243
|
+
debug("config: %s set to %s", configKey, result);
|
|
2244
|
+
return result;
|
|
2245
|
+
}
|
|
2246
|
+
const requireNumber = (v) => {
|
|
2247
|
+
if (!v?.trim()) return "Value cannot be empty";
|
|
2248
|
+
return Number.isNaN(Number(v)) ? "Must be a number" : void 0;
|
|
2249
|
+
};
|
|
2250
|
+
function getSettingHandlers(config) {
|
|
2251
|
+
const provider = getProvider(config);
|
|
2252
|
+
return {
|
|
2253
|
+
provider: async () => {
|
|
2254
|
+
const result = await promptProvider();
|
|
2255
|
+
if (p.isCancel(result)) return result;
|
|
2256
|
+
await writeConfig({ provider: result });
|
|
2257
|
+
debug("config: provider set to %s", result);
|
|
2258
|
+
},
|
|
2259
|
+
apikey: async () => promptApiKey(provider),
|
|
2260
|
+
model: async () => promptTextSetting("Model ID:", "model", config.model),
|
|
2261
|
+
locale: async () => promptTextSetting("Locale (e.g. en, ja, ko):", "locale", config.locale),
|
|
2262
|
+
maxlen: async () => promptTextSetting("Max commit message length:", "max-length", config["max-length"], requireNumber),
|
|
2263
|
+
type: async () => promptTextSetting("Commit type prefix (e.g. conventional):", "type", config.type),
|
|
2264
|
+
timeout: async () => promptTextSetting("Timeout (ms):", "timeout", config.timeout, requireNumber),
|
|
2265
|
+
proxy: async () => promptTextSetting("Proxy URL:", "proxy", config.proxy)
|
|
2266
|
+
};
|
|
2267
|
+
}
|
|
2268
|
+
async function handleEditSetting(setting, config) {
|
|
2269
|
+
const handler = getSettingHandlers(config)[setting];
|
|
2270
|
+
if (!handler) return false;
|
|
2271
|
+
const result = await handler();
|
|
2272
|
+
return !p.isCancel(result);
|
|
2273
|
+
}
|
|
2274
|
+
async function editSettingsLoop(initialConfig) {
|
|
2275
|
+
let config = initialConfig;
|
|
2276
|
+
while (true) {
|
|
2277
|
+
config = await readConfig();
|
|
2278
|
+
const provider = getProvider(config);
|
|
2279
|
+
const setting = await p.select({
|
|
2280
|
+
message: "Select a setting to edit:",
|
|
2281
|
+
options: [
|
|
2282
|
+
{
|
|
2283
|
+
label: `LLM Provider ${dim(`(${formatProviderName(provider)})`)}`,
|
|
2284
|
+
value: "provider"
|
|
2285
|
+
},
|
|
2286
|
+
{
|
|
2287
|
+
label: `API Key ${dim(`(for ${formatProviderName(provider)})`)}`,
|
|
2288
|
+
value: "apikey"
|
|
2289
|
+
},
|
|
2290
|
+
{
|
|
2291
|
+
label: `Model ${dim(`(${config.model ?? "(none)"})`)}`,
|
|
2292
|
+
value: "model"
|
|
2293
|
+
},
|
|
2294
|
+
{
|
|
2295
|
+
label: `Locale ${dim(`(${config.locale ?? "en"})`)}`,
|
|
2296
|
+
value: "locale"
|
|
2297
|
+
},
|
|
2298
|
+
{
|
|
2299
|
+
label: `Max commit length ${dim(`(${config["max-length"] ?? "100"})`)}`,
|
|
2300
|
+
value: "maxlen"
|
|
2301
|
+
},
|
|
2302
|
+
{
|
|
2303
|
+
label: `Commit type prefix ${dim(`(${config.type || "(none)"})`)}`,
|
|
2304
|
+
value: "type"
|
|
2305
|
+
},
|
|
2306
|
+
{
|
|
2307
|
+
label: `Timeout (ms) ${dim(`(${config.timeout ?? "10000"})`)}`,
|
|
2308
|
+
value: "timeout"
|
|
2309
|
+
},
|
|
2310
|
+
{
|
|
2311
|
+
label: `Proxy URL ${dim(`(${config.proxy || "(none)"})`)}`,
|
|
2312
|
+
value: "proxy"
|
|
2313
|
+
},
|
|
2314
|
+
{
|
|
2315
|
+
label: "Done editing",
|
|
2316
|
+
value: "done"
|
|
2317
|
+
}
|
|
2318
|
+
]
|
|
2319
|
+
});
|
|
2320
|
+
if (p.isCancel(setting) || setting === "done") break;
|
|
2321
|
+
if (await handleEditSetting(setting, config)) p.log.success(green("Updated."));
|
|
1816
2322
|
}
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
2323
|
+
}
|
|
2324
|
+
async function configCommand() {
|
|
2325
|
+
debug("configCommand: starting");
|
|
2326
|
+
p.intro(bold("🌿 commit-mint config"));
|
|
2327
|
+
while (true) {
|
|
2328
|
+
const config = await readConfig();
|
|
2329
|
+
p.note(buildConfigDisplay(config), "commit-mint config");
|
|
2330
|
+
const action = await p.select({
|
|
2331
|
+
message: "What would you like to do?",
|
|
2332
|
+
options: [{
|
|
2333
|
+
label: "Edit settings",
|
|
2334
|
+
value: "edit"
|
|
2335
|
+
}, {
|
|
2336
|
+
label: "Done",
|
|
2337
|
+
value: "done"
|
|
2338
|
+
}]
|
|
2339
|
+
});
|
|
2340
|
+
if (p.isCancel(action)) {
|
|
2341
|
+
debug("configCommand: cancelled at main menu");
|
|
2342
|
+
p.outro(dim("Cancelled."));
|
|
2343
|
+
return;
|
|
1821
2344
|
}
|
|
1822
|
-
|
|
1823
|
-
|
|
2345
|
+
if (action === "done") {
|
|
2346
|
+
debug("configCommand: done");
|
|
2347
|
+
p.outro("Config saved.");
|
|
2348
|
+
return;
|
|
2349
|
+
}
|
|
2350
|
+
await editSettingsLoop(config);
|
|
1824
2351
|
}
|
|
1825
|
-
|
|
1826
|
-
process.exit(1);
|
|
1827
|
-
});
|
|
2352
|
+
}
|
|
1828
2353
|
//#endregion
|
|
1829
2354
|
//#region src/cli.ts
|
|
1830
2355
|
const { version } = package_default;
|
|
@@ -1866,9 +2391,17 @@ cli({
|
|
|
1866
2391
|
description: "Enable debug output",
|
|
1867
2392
|
alias: "d",
|
|
1868
2393
|
default: false
|
|
2394
|
+
},
|
|
2395
|
+
noCheck: {
|
|
2396
|
+
type: Boolean,
|
|
2397
|
+
description: "Skip user-defined pre-commit checks",
|
|
2398
|
+
alias: "N",
|
|
2399
|
+
default: false
|
|
1869
2400
|
}
|
|
1870
2401
|
},
|
|
1871
|
-
commands: [
|
|
2402
|
+
commands: [command({ name: "config" }, async () => {
|
|
2403
|
+
await configCommand();
|
|
2404
|
+
})]
|
|
1872
2405
|
}, (argv) => {
|
|
1873
2406
|
setDebug(argv.flags.debug);
|
|
1874
2407
|
if (argv.flags.review) reviewCommand();
|