@kyubiware/commit-mint 0.5.0 → 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 +1 -14
- package/dist/cli.mjs +129 -382
- package/dist/cli.mjs.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -68,7 +68,6 @@ On first run, you'll be prompted for a `GROQ_API_KEY` if it's not set in `~/.com
|
|
|
68
68
|
- **Hook failures handled in-flow.** When pre-commit hooks fail, you get a parsed error summary and a menu to copy, skip, retry, or edit. No raw stderr dumps.
|
|
69
69
|
- **Built-in pre-commit checks.** Define checks in a cmint config file and run them before AI generation. A failing check never wastes an API call.
|
|
70
70
|
- **Message caching on failure.** A failed commit caches its message. Fix the error, run `cmint --retry`, and pick up exactly where you left off.
|
|
71
|
-
- **Review before you commit.** Every message can be accepted, edited, or reviewed with OpenCode before it hits the repo.
|
|
72
71
|
|
|
73
72
|
## Auto mode (`-a`)
|
|
74
73
|
|
|
@@ -111,9 +110,6 @@ cmint -H "refactoring auth module"
|
|
|
111
110
|
# Retry last failed commit (uses cached message)
|
|
112
111
|
cmint -r
|
|
113
112
|
|
|
114
|
-
# Review staged changes with AI
|
|
115
|
-
cmint -R
|
|
116
|
-
|
|
117
113
|
# Skip pre-commit checks
|
|
118
114
|
cmint -n
|
|
119
115
|
|
|
@@ -132,7 +128,6 @@ cmint config set model openai/gpt-oss-20b
|
|
|
132
128
|
| `-m, --message` | Provide a commit message directly (skip AI) |
|
|
133
129
|
| `-H, --hint` | Add context hint for AI message generation |
|
|
134
130
|
| `-r, --retry` | Retry the last failed commit |
|
|
135
|
-
| `-R, --review` | Review staged changes with a coding model |
|
|
136
131
|
| `-n, --noCheck` | Skip pre-commit checks |
|
|
137
132
|
| `-d, --debug` | Enable debug output |
|
|
138
133
|
| `-h, --help` | Show help |
|
|
@@ -144,7 +139,6 @@ Before every commit, choose what to do with the generated message:
|
|
|
144
139
|
|
|
145
140
|
- **Use as-is** — accept the AI-generated message
|
|
146
141
|
- **Edit** — modify the message in a prompt
|
|
147
|
-
- **Review with OpenCode** — run a code review on staged changes before committing
|
|
148
142
|
- **Cancel** — exit (message is cached for `--retry`)
|
|
149
143
|
|
|
150
144
|
## Recovery menu
|
|
@@ -236,10 +230,6 @@ When a check fails, you get a menu with three options:
|
|
|
236
230
|
|
|
237
231
|
Pass `--noCheck` or `-n` to skip checks entirely.
|
|
238
232
|
|
|
239
|
-
## Code review
|
|
240
|
-
|
|
241
|
-
Run `cmint --review` or `cmint -R` to review staged changes without committing. commit-mint checks for [OpenCode](https://github.com/opencode-ai/opencode) first, then falls back to Groq API. The review covers bugs, security issues, performance problems, and edge cases.
|
|
242
|
-
|
|
243
233
|
## Configuration
|
|
244
234
|
|
|
245
235
|
Stored in `~/.commit-mint` (INI format):
|
|
@@ -278,7 +268,6 @@ cmint --help
|
|
|
278
268
|
--auto, -a Auto-group files into commits, accept all messages (default: false)
|
|
279
269
|
--message, -m Provide a commit message directly (skip AI generation)
|
|
280
270
|
--hint, -H Add context hint for AI commit message generation
|
|
281
|
-
--review, -R Review staged changes with a coding model (default: false)
|
|
282
271
|
--noCheck, -n Skip pre-commit checks (default: false)
|
|
283
272
|
--debug, -d Enable debug output (default: false)
|
|
284
273
|
--help, -h Show help
|
|
@@ -301,13 +290,11 @@ commit-mint/
|
|
|
301
290
|
│ ├── commands/
|
|
302
291
|
│ │ ├── commit.ts # Main commit flow orchestrator
|
|
303
292
|
│ │ ├── auto-group.ts # Auto-group multi-commit flow
|
|
304
|
-
│ │ ├── review.ts # Code review command
|
|
305
293
|
│ │ └── config.ts # Config get/set subcommand
|
|
306
294
|
│ ├── services/
|
|
307
295
|
│ │ ├── git.ts # Git operations (stage, commit, diff, HEAD)
|
|
308
296
|
│ │ ├── ai.ts # Groq AI commit message generation (3-tier diff compression)
|
|
309
297
|
│ │ ├── grouping.ts # AI-powered file grouping into logical commits
|
|
310
|
-
│ │ ├── review-ai.ts # AI code review via Groq
|
|
311
298
|
│ │ ├── hooks.ts # Hook error parser (lint-staged, biome, tsc, etc.)
|
|
312
299
|
│ │ ├── checks.ts # Pre-commit checks via cmint config files (glob matching, command execution)
|
|
313
300
|
│ │ ├── config.ts # INI config read/write at ~/.commit-mint
|
|
@@ -315,7 +302,7 @@ commit-mint/
|
|
|
315
302
|
│ ├── ui/
|
|
316
303
|
│ │ ├── menu.ts # Interactive recovery TUI + staging menu
|
|
317
304
|
│ │ ├── grouping.ts # Grouping confirmation UI
|
|
318
|
-
│ │ └── review-message.ts # Message review step (use/edit/
|
|
305
|
+
│ │ └── review-message.ts # Message review step (use/edit/cancel)
|
|
319
306
|
│ └── utils/
|
|
320
307
|
│ ├── cache.ts # Commit message persistence at ~/.cache/commit-mint/
|
|
321
308
|
│ └── debug.ts # Timestamped debug logging to stderr
|
package/dist/cli.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
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";
|
|
@@ -9,10 +9,10 @@ import { extname, join } from "node:path";
|
|
|
9
9
|
import ini from "ini";
|
|
10
10
|
import Groq from "groq-sdk";
|
|
11
11
|
import { execa } from "execa";
|
|
12
|
-
import { spawn } from "node:child_process";
|
|
13
12
|
import { createHash } from "node:crypto";
|
|
14
13
|
import { readFileSync } from "node:fs";
|
|
15
14
|
import picomatch from "picomatch";
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
16
16
|
//#region \0rolldown/runtime.js
|
|
17
17
|
var __defProp = Object.defineProperty;
|
|
18
18
|
var __exportAll = (all, no_symbols) => {
|
|
@@ -28,7 +28,7 @@ var __exportAll = (all, no_symbols) => {
|
|
|
28
28
|
//#region package.json
|
|
29
29
|
var package_default = {
|
|
30
30
|
name: "@kyubiware/commit-mint",
|
|
31
|
-
version: "0.5.
|
|
31
|
+
version: "0.5.1",
|
|
32
32
|
description: "🌿 A commit tool that actually handles hook failures",
|
|
33
33
|
type: "module",
|
|
34
34
|
bin: { "cmint": "./dist/cli.mjs" },
|
|
@@ -567,43 +567,80 @@ async function attemptCommitNoVerify(message, onProgress) {
|
|
|
567
567
|
return attemptCommit(message, ["--no-verify"], onProgress);
|
|
568
568
|
}
|
|
569
569
|
//#endregion
|
|
570
|
-
//#region src/
|
|
571
|
-
async function
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
resolve(result);
|
|
589
|
-
};
|
|
590
|
-
child.on("error", () => done(false));
|
|
591
|
-
child.on("exit", (code) => {
|
|
592
|
-
if (code !== 0) done(false);
|
|
593
|
-
});
|
|
594
|
-
child.stdin.write(content, (err) => {
|
|
595
|
-
if (err) {
|
|
596
|
-
done(false);
|
|
597
|
-
return;
|
|
570
|
+
//#region src/ui/review-message.ts
|
|
571
|
+
async function reviewCommitMessage(message) {
|
|
572
|
+
const { select, text } = await import("@clack/prompts");
|
|
573
|
+
while (true) {
|
|
574
|
+
const review = await select({
|
|
575
|
+
message: `Review commit message:\n\n ${bold(message)}\n`,
|
|
576
|
+
options: [
|
|
577
|
+
{
|
|
578
|
+
label: "Use as-is",
|
|
579
|
+
value: "use"
|
|
580
|
+
},
|
|
581
|
+
{
|
|
582
|
+
label: "Edit",
|
|
583
|
+
value: "edit"
|
|
584
|
+
},
|
|
585
|
+
{
|
|
586
|
+
label: "Cancel",
|
|
587
|
+
value: "cancel"
|
|
598
588
|
}
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
589
|
+
]
|
|
590
|
+
});
|
|
591
|
+
if (isCancel(review) || review === "cancel") {
|
|
592
|
+
debug("User cancelled at review step");
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
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"
|
|
603
605
|
});
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
606
|
+
if (isCancel(edited)) continue;
|
|
607
|
+
message = String(edited).trim();
|
|
608
|
+
debug("Edited message:", message);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
//#endregion
|
|
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
|
+
}
|
|
607
644
|
}
|
|
608
645
|
//#endregion
|
|
609
646
|
//#region src/services/ai.ts
|
|
@@ -778,330 +815,6 @@ async function generateCommitMessage(diff, options) {
|
|
|
778
815
|
}
|
|
779
816
|
}
|
|
780
817
|
//#endregion
|
|
781
|
-
//#region src/services/review-ai.ts
|
|
782
|
-
function buildReviewSystemPrompt() {
|
|
783
|
-
return [
|
|
784
|
-
"You are an expert code reviewer. Review the following staged git diff.",
|
|
785
|
-
"",
|
|
786
|
-
"Focus on finding:",
|
|
787
|
-
"1. **Bugs** — logic errors, off-by-one, race conditions, null pointer risks",
|
|
788
|
-
"2. **Security issues** — injection, exposure of secrets, missing validation, CSRF, XSS",
|
|
789
|
-
"3. **Performance problems** — unnecessary work, large allocations in hot paths",
|
|
790
|
-
"4. **Code quality** — readability, maintainability, error handling gaps",
|
|
791
|
-
"5. **Edge cases** — missing boundary checks, empty states, error states",
|
|
792
|
-
"",
|
|
793
|
-
"For each issue found, use this format:",
|
|
794
|
-
"- SEVERITY: [critical|major|minor|suggestion]",
|
|
795
|
-
"- LOCATION: <file-path>:<line-number>",
|
|
796
|
-
"- ISSUE: <description>",
|
|
797
|
-
"- FIX: <suggested resolution>",
|
|
798
|
-
"",
|
|
799
|
-
"Separate issues with a blank line.",
|
|
800
|
-
"",
|
|
801
|
-
"If you find NO issues at all, respond with exactly: NO_ISSUES_FOUND",
|
|
802
|
-
"",
|
|
803
|
-
"Be thorough but practical. Only flag real problems — not style preferences or nitpicks."
|
|
804
|
-
].join("\n");
|
|
805
|
-
}
|
|
806
|
-
function buildReviewPrompt(diff, files, statSummary) {
|
|
807
|
-
const parts = [];
|
|
808
|
-
parts.push(`Review the following staged changes (${files.length} files):`);
|
|
809
|
-
parts.push("");
|
|
810
|
-
parts.push(statSummary);
|
|
811
|
-
parts.push("");
|
|
812
|
-
parts.push("```diff");
|
|
813
|
-
parts.push(diff);
|
|
814
|
-
parts.push("```");
|
|
815
|
-
return parts.join("\n");
|
|
816
|
-
}
|
|
817
|
-
async function generateCodeReview(diff, files, options) {
|
|
818
|
-
debug("generateCodeReview: model=%s, files=%d", options.model ?? "default", files.length);
|
|
819
|
-
const timeoutMs = options.timeout ?? 6e4;
|
|
820
|
-
const { client, model } = createProvider({
|
|
821
|
-
provider: options.provider ?? "groq",
|
|
822
|
-
apiKey: options.apiKey,
|
|
823
|
-
modelOverride: options.model,
|
|
824
|
-
timeout: timeoutMs,
|
|
825
|
-
baseURLOverride: options.proxy
|
|
826
|
-
});
|
|
827
|
-
const compressedDiff = compressDiff(diff);
|
|
828
|
-
const statSummary = buildStatSummary(diff);
|
|
829
|
-
const systemPrompt = buildReviewSystemPrompt();
|
|
830
|
-
const userPrompt = buildReviewPrompt(compressedDiff, files, statSummary);
|
|
831
|
-
debug("Code review: %d chars → %d chars, system=%d chars, user=%d chars", diff.length, compressedDiff.length, systemPrompt.length, userPrompt.length);
|
|
832
|
-
try {
|
|
833
|
-
const completion = await client.chat.completions.create({
|
|
834
|
-
messages: [{
|
|
835
|
-
role: "system",
|
|
836
|
-
content: systemPrompt
|
|
837
|
-
}, {
|
|
838
|
-
role: "user",
|
|
839
|
-
content: userPrompt
|
|
840
|
-
}],
|
|
841
|
-
model,
|
|
842
|
-
temperature: .3,
|
|
843
|
-
max_tokens: 4096
|
|
844
|
-
});
|
|
845
|
-
const rawContent = completion.choices[0]?.message?.content;
|
|
846
|
-
const content = extractContentText(rawContent);
|
|
847
|
-
debug("generateCodeReview response: choices=%d, finishReason=%s, contentLen=%d", completion.choices.length, completion.choices[0]?.finish_reason ?? "(none)", content.length);
|
|
848
|
-
if (!content) {
|
|
849
|
-
const reasoning = completion.choices[0]?.message?.reasoning;
|
|
850
|
-
if (reasoning) {
|
|
851
|
-
const derived = deriveMessageFromReasoning(reasoning);
|
|
852
|
-
if (derived) {
|
|
853
|
-
debug("generateCodeReview: derived from reasoning");
|
|
854
|
-
return derived;
|
|
855
|
-
}
|
|
856
|
-
}
|
|
857
|
-
return "NO_ISSUES_FOUND";
|
|
858
|
-
}
|
|
859
|
-
return content;
|
|
860
|
-
} catch (error) {
|
|
861
|
-
debug("generateCodeReview error: %s", error instanceof Error ? error.message : String(error));
|
|
862
|
-
throw mapGroqError(error, options.provider ? formatProviderName(options.provider) : void 0);
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
|
-
//#endregion
|
|
866
|
-
//#region src/commands/review.ts
|
|
867
|
-
async function reviewCommand() {
|
|
868
|
-
debug("reviewCommand called");
|
|
869
|
-
await assertGitRepo();
|
|
870
|
-
const s = spinner();
|
|
871
|
-
s.start("Staging all changes...");
|
|
872
|
-
await stageAll();
|
|
873
|
-
s.stop("Changes staged");
|
|
874
|
-
const diffResult = await getStagedDiff();
|
|
875
|
-
if (!diffResult) {
|
|
876
|
-
outro(dim("No changes to review."));
|
|
877
|
-
return;
|
|
878
|
-
}
|
|
879
|
-
if ("excludedFiles" in diffResult) {
|
|
880
|
-
outro(dim("Staged files are all excluded from review."));
|
|
881
|
-
return;
|
|
882
|
-
}
|
|
883
|
-
intro("🌿 commit-mint — code review");
|
|
884
|
-
log.info(diffResult.files.map((f) => ` ${f}`).join("\n"));
|
|
885
|
-
const report = await isOpenCodeAvailable() ? await reviewWithOpenCode(diffResult.diff, diffResult.files) : await reviewWithGroq(diffResult.diff, diffResult.files);
|
|
886
|
-
if (report !== "NO_ISSUES_FOUND" && report.trim().length > 0) {
|
|
887
|
-
note(report, red(bold("Review findings")));
|
|
888
|
-
await offerClipboardCopy(report);
|
|
889
|
-
} else outro(green("No issues found. Looks good!"));
|
|
890
|
-
}
|
|
891
|
-
async function offerClipboardCopy(report) {
|
|
892
|
-
const shouldCopy = await select({
|
|
893
|
-
message: "Copy review report to clipboard?",
|
|
894
|
-
options: [{
|
|
895
|
-
label: "Yes, copy to clipboard",
|
|
896
|
-
value: "yes"
|
|
897
|
-
}, {
|
|
898
|
-
label: "No",
|
|
899
|
-
value: "no"
|
|
900
|
-
}]
|
|
901
|
-
});
|
|
902
|
-
if (isCancel(shouldCopy) || shouldCopy === "no") {
|
|
903
|
-
outro(dim("Done."));
|
|
904
|
-
return;
|
|
905
|
-
}
|
|
906
|
-
if (await copyToClipboard(report)) outro(green("Report copied to clipboard. You can paste it anywhere for fixes."));
|
|
907
|
-
else outro(red("Failed to copy to clipboard. Install xclip, wl-copy, or xsel."));
|
|
908
|
-
}
|
|
909
|
-
async function isOpenCodeAvailable() {
|
|
910
|
-
try {
|
|
911
|
-
const { exitCode } = await import("execa").then((m) => m.execa("which", ["opencode"], { reject: false }));
|
|
912
|
-
return exitCode === 0;
|
|
913
|
-
} catch {
|
|
914
|
-
return false;
|
|
915
|
-
}
|
|
916
|
-
}
|
|
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);
|
|
922
|
-
const s = spinner();
|
|
923
|
-
s.start(`Reviewing with ${formatProviderName(provider)}...`);
|
|
924
|
-
try {
|
|
925
|
-
const report = await generateCodeReview(diff, files, {
|
|
926
|
-
apiKey,
|
|
927
|
-
model,
|
|
928
|
-
timeout: config.timeout ? Number.parseInt(config.timeout, 10) : void 0,
|
|
929
|
-
provider,
|
|
930
|
-
proxy: config.proxy
|
|
931
|
-
});
|
|
932
|
-
s.stop("Review complete");
|
|
933
|
-
return report;
|
|
934
|
-
} catch (err) {
|
|
935
|
-
s.stop(red("Review failed."));
|
|
936
|
-
debug("reviewWithGroq error:", err instanceof Error ? err.message : String(err));
|
|
937
|
-
throw err;
|
|
938
|
-
}
|
|
939
|
-
}
|
|
940
|
-
async function reviewWithOpenCode(diff, files) {
|
|
941
|
-
const s = spinner();
|
|
942
|
-
s.start("Running OpenCode review...");
|
|
943
|
-
try {
|
|
944
|
-
const repoRoot = await getRepoRoot();
|
|
945
|
-
const prompt = [
|
|
946
|
-
"Review the staged changes in this git repository.",
|
|
947
|
-
"Analyze the code diff for bugs, security issues, performance problems,",
|
|
948
|
-
"code quality issues, and missing edge cases.",
|
|
949
|
-
"",
|
|
950
|
-
`Files changed (${files.length}):`,
|
|
951
|
-
...files.map((f) => ` - ${f}`),
|
|
952
|
-
"",
|
|
953
|
-
"```diff",
|
|
954
|
-
diff.slice(0, 15e3),
|
|
955
|
-
"```",
|
|
956
|
-
"",
|
|
957
|
-
"Provide a structured report with severity, location, issue, and fix suggestion for each finding.",
|
|
958
|
-
"If no issues found, respond with: NO_ISSUES_FOUND"
|
|
959
|
-
].join("\n");
|
|
960
|
-
const { stdout } = await import("execa").then((m) => m.execa("opencode", [
|
|
961
|
-
"run",
|
|
962
|
-
prompt,
|
|
963
|
-
"--dir",
|
|
964
|
-
repoRoot
|
|
965
|
-
], {
|
|
966
|
-
timeout: 12e4,
|
|
967
|
-
reject: false
|
|
968
|
-
}));
|
|
969
|
-
s.stop("Review complete");
|
|
970
|
-
return stdout || "OpenCode review completed but no output captured.";
|
|
971
|
-
} catch (err) {
|
|
972
|
-
s.stop(red("OpenCode review failed."));
|
|
973
|
-
debug("reviewWithOpenCode error:", err instanceof Error ? err.message : String(err));
|
|
974
|
-
throw new Error(`OpenCode review failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
975
|
-
}
|
|
976
|
-
}
|
|
977
|
-
//#endregion
|
|
978
|
-
//#region src/ui/review-message.ts
|
|
979
|
-
async function runCodeReview() {
|
|
980
|
-
const diffResult = await getStagedDiff();
|
|
981
|
-
if (!diffResult || "excludedFiles" in diffResult) {
|
|
982
|
-
outro(dim("No staged changes to review."));
|
|
983
|
-
return;
|
|
984
|
-
}
|
|
985
|
-
const opencodeAvailable = await isOpenCodeAvailable();
|
|
986
|
-
const config = await readConfig();
|
|
987
|
-
const provider = isValidProvider(config.provider ?? "groq") ? config.provider : "groq";
|
|
988
|
-
const s = spinner();
|
|
989
|
-
s.start(opencodeAvailable ? "Running OpenCode review..." : `Running ${formatProviderName(provider)} review...`);
|
|
990
|
-
try {
|
|
991
|
-
const report = opencodeAvailable ? await reviewWithOpenCode(diffResult.diff, diffResult.files) : await reviewWithGroq(diffResult.diff, diffResult.files);
|
|
992
|
-
s.stop("Review complete");
|
|
993
|
-
await showReviewResults(report);
|
|
994
|
-
} catch (err) {
|
|
995
|
-
s.stop(red("Review failed."));
|
|
996
|
-
debug("Code review error:", err instanceof Error ? err.message : String(err));
|
|
997
|
-
outro(red(err instanceof Error ? err.message : String(err)));
|
|
998
|
-
}
|
|
999
|
-
}
|
|
1000
|
-
async function showReviewResults(report) {
|
|
1001
|
-
const { note: clackNote, select: clackSelect } = await import("@clack/prompts");
|
|
1002
|
-
if (!(report !== "NO_ISSUES_FOUND" && report.trim().length > 0)) {
|
|
1003
|
-
log.info(green("No issues found."));
|
|
1004
|
-
return;
|
|
1005
|
-
}
|
|
1006
|
-
clackNote(report, red(bold("Review findings")));
|
|
1007
|
-
const shouldCopy = await clackSelect({
|
|
1008
|
-
message: "Copy review report to clipboard?",
|
|
1009
|
-
options: [{
|
|
1010
|
-
label: "Yes, copy to clipboard",
|
|
1011
|
-
value: "yes"
|
|
1012
|
-
}, {
|
|
1013
|
-
label: "No",
|
|
1014
|
-
value: "no"
|
|
1015
|
-
}]
|
|
1016
|
-
});
|
|
1017
|
-
if (isCancel(shouldCopy) || shouldCopy !== "yes") return;
|
|
1018
|
-
if (await copyToClipboard(report)) log.info(green("Report copied to clipboard."));
|
|
1019
|
-
else log.warn(red("Failed to copy to clipboard."));
|
|
1020
|
-
}
|
|
1021
|
-
async function reviewCommitMessage(message) {
|
|
1022
|
-
const { select, text } = await import("@clack/prompts");
|
|
1023
|
-
while (true) {
|
|
1024
|
-
const review = await select({
|
|
1025
|
-
message: `Review commit message:\n\n ${bold(message)}\n`,
|
|
1026
|
-
options: [
|
|
1027
|
-
{
|
|
1028
|
-
label: "Use as-is",
|
|
1029
|
-
value: "use"
|
|
1030
|
-
},
|
|
1031
|
-
{
|
|
1032
|
-
label: "Edit",
|
|
1033
|
-
value: "edit"
|
|
1034
|
-
},
|
|
1035
|
-
{
|
|
1036
|
-
label: "Review with OpenCode",
|
|
1037
|
-
value: "review"
|
|
1038
|
-
},
|
|
1039
|
-
{
|
|
1040
|
-
label: "Cancel",
|
|
1041
|
-
value: "cancel"
|
|
1042
|
-
}
|
|
1043
|
-
]
|
|
1044
|
-
});
|
|
1045
|
-
if (isCancel(review) || review === "cancel") {
|
|
1046
|
-
debug("User cancelled at review step");
|
|
1047
|
-
return null;
|
|
1048
|
-
}
|
|
1049
|
-
if (review === "use") {
|
|
1050
|
-
debug("User accepted message");
|
|
1051
|
-
return message;
|
|
1052
|
-
}
|
|
1053
|
-
if (review === "edit") {
|
|
1054
|
-
debug("User chose to edit message");
|
|
1055
|
-
const edited = await text({
|
|
1056
|
-
message: "Edit commit message:",
|
|
1057
|
-
initialValue: message,
|
|
1058
|
-
validate: (v) => v?.trim() ? void 0 : "Message cannot be empty"
|
|
1059
|
-
});
|
|
1060
|
-
if (isCancel(edited)) continue;
|
|
1061
|
-
message = String(edited).trim();
|
|
1062
|
-
debug("Edited message:", message);
|
|
1063
|
-
continue;
|
|
1064
|
-
}
|
|
1065
|
-
if (review === "review") {
|
|
1066
|
-
debug("User chose to review code");
|
|
1067
|
-
await runCodeReview();
|
|
1068
|
-
}
|
|
1069
|
-
}
|
|
1070
|
-
}
|
|
1071
|
-
//#endregion
|
|
1072
|
-
//#region src/utils/cache.ts
|
|
1073
|
-
const CACHE_DIR = join(os.homedir(), ".cache", "commit-mint");
|
|
1074
|
-
function repoHash(repoPath) {
|
|
1075
|
-
return createHash("sha256").update(repoPath).digest("hex").slice(0, 12);
|
|
1076
|
-
}
|
|
1077
|
-
function cachePath(repoPath) {
|
|
1078
|
-
return join(CACHE_DIR, `${repoHash(repoPath)}.json`);
|
|
1079
|
-
}
|
|
1080
|
-
async function saveCachedCommit(repoPath, message) {
|
|
1081
|
-
await mkdir(CACHE_DIR, { recursive: true });
|
|
1082
|
-
const data = {
|
|
1083
|
-
message,
|
|
1084
|
-
timestamp: Date.now(),
|
|
1085
|
-
repoPath
|
|
1086
|
-
};
|
|
1087
|
-
const path = cachePath(repoPath);
|
|
1088
|
-
debug("saveCachedCommit: saving to %s", path);
|
|
1089
|
-
await writeFile(path, JSON.stringify(data, null, 2), "utf8");
|
|
1090
|
-
}
|
|
1091
|
-
async function loadCachedCommit(repoPath) {
|
|
1092
|
-
const path = cachePath(repoPath);
|
|
1093
|
-
debug("loadCachedCommit: loading from %s", path);
|
|
1094
|
-
try {
|
|
1095
|
-
const raw = await readFile(path, "utf8");
|
|
1096
|
-
const data = JSON.parse(raw);
|
|
1097
|
-
debug("loadCachedCommit: found message from %s", new Date(data.timestamp).toISOString());
|
|
1098
|
-
return data;
|
|
1099
|
-
} catch {
|
|
1100
|
-
debug("loadCachedCommit: no cached commit found");
|
|
1101
|
-
return null;
|
|
1102
|
-
}
|
|
1103
|
-
}
|
|
1104
|
-
//#endregion
|
|
1105
818
|
//#region src/services/checks.ts
|
|
1106
819
|
/** Config file names, checked in priority order (matches lint-staged naming conventions) */
|
|
1107
820
|
const CONFIG_FILES = [
|
|
@@ -1515,19 +1228,61 @@ const statusLabel = (status) => {
|
|
|
1515
1228
|
default: return dim(status);
|
|
1516
1229
|
}
|
|
1517
1230
|
};
|
|
1518
|
-
/** Display
|
|
1519
|
-
function
|
|
1520
|
-
|
|
1521
|
-
const lines =
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
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}`);
|
|
1241
|
+
}
|
|
1242
|
+
if (i < groups.length - 1) lines.push("");
|
|
1243
|
+
}
|
|
1528
1244
|
p.note(lines.join("\n"), "Commit groups");
|
|
1529
1245
|
}
|
|
1530
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
|
|
1531
1286
|
//#region src/ui/menu.ts
|
|
1532
1287
|
async function showStagingMenu(files, hasChecks) {
|
|
1533
1288
|
debug("showStagingMenu: %d files", files.length);
|
|
@@ -1825,8 +1580,7 @@ async function runAutoGroupFlow(changedFiles, flags) {
|
|
|
1825
1580
|
s.start("Analyzing files...");
|
|
1826
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);
|
|
1827
1582
|
s.stop("Files analyzed");
|
|
1828
|
-
|
|
1829
|
-
showGroupingSummary(validatedGroups);
|
|
1583
|
+
showGroupedFiles(validatedGroups, included);
|
|
1830
1584
|
if (flags.auto) debug("Auto mode: skipping grouping confirmation");
|
|
1831
1585
|
else if (!await showGroupingConfirmation(validatedGroups, excluded)) {
|
|
1832
1586
|
outro(dim("Cancelled."));
|
|
@@ -2380,12 +2134,6 @@ cli({
|
|
|
2380
2134
|
description: "Add context hint for AI commit message generation",
|
|
2381
2135
|
alias: "H"
|
|
2382
2136
|
},
|
|
2383
|
-
review: {
|
|
2384
|
-
type: Boolean,
|
|
2385
|
-
description: "Review staged changes with a coding model",
|
|
2386
|
-
alias: "R",
|
|
2387
|
-
default: false
|
|
2388
|
-
},
|
|
2389
2137
|
debug: {
|
|
2390
2138
|
type: Boolean,
|
|
2391
2139
|
description: "Enable debug output",
|
|
@@ -2404,8 +2152,7 @@ cli({
|
|
|
2404
2152
|
})]
|
|
2405
2153
|
}, (argv) => {
|
|
2406
2154
|
setDebug(argv.flags.debug);
|
|
2407
|
-
|
|
2408
|
-
else commitCommand(argv.flags);
|
|
2155
|
+
commitCommand(argv.flags);
|
|
2409
2156
|
});
|
|
2410
2157
|
//#endregion
|
|
2411
2158
|
export {};
|