@kyubiware/commit-mint 0.6.2 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.mjs +149 -66
- package/dist/cli.mjs.map +1 -1
- package/package.json +1 -1
package/dist/cli.mjs
CHANGED
|
@@ -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.6.
|
|
31
|
+
version: "0.6.4",
|
|
32
32
|
description: "🌿 A commit tool that actually handles hook failures",
|
|
33
33
|
type: "module",
|
|
34
34
|
bin: { "cmint": "./dist/cli.mjs" },
|
|
@@ -995,12 +995,28 @@ async function stageAll() {
|
|
|
995
995
|
await execa("git", ["add", "-A"]);
|
|
996
996
|
}
|
|
997
997
|
async function resetStaging() {
|
|
998
|
-
|
|
999
|
-
|
|
998
|
+
try {
|
|
999
|
+
debug("resetStaging: git reset HEAD");
|
|
1000
|
+
await execa("git", ["reset", "HEAD"]);
|
|
1001
|
+
} catch {
|
|
1002
|
+
debug("resetStaging: HEAD missing, falling back to git rm --cached");
|
|
1003
|
+
await execa("git", [
|
|
1004
|
+
"rm",
|
|
1005
|
+
"-r",
|
|
1006
|
+
"--cached",
|
|
1007
|
+
"--quiet",
|
|
1008
|
+
"."
|
|
1009
|
+
]);
|
|
1010
|
+
}
|
|
1000
1011
|
}
|
|
1001
1012
|
async function getHead() {
|
|
1002
|
-
|
|
1003
|
-
|
|
1013
|
+
try {
|
|
1014
|
+
const { stdout } = await execa("git", ["rev-parse", "HEAD"]);
|
|
1015
|
+
return stdout.trim();
|
|
1016
|
+
} catch {
|
|
1017
|
+
debug("getHead: HEAD does not exist (fresh repo)");
|
|
1018
|
+
return null;
|
|
1019
|
+
}
|
|
1004
1020
|
}
|
|
1005
1021
|
async function getStatusShort() {
|
|
1006
1022
|
const { stdout } = await execa("git", ["status", "--short"]);
|
|
@@ -1061,6 +1077,93 @@ async function attemptCommitNoVerify(message, onProgress) {
|
|
|
1061
1077
|
return attemptCommit(message, ["--no-verify"], onProgress);
|
|
1062
1078
|
}
|
|
1063
1079
|
//#endregion
|
|
1080
|
+
//#region src/services/grouping-parser.ts
|
|
1081
|
+
/** Coerce a parsed value into a CommitGroup, or null if it doesn't match the shape. */
|
|
1082
|
+
function coerceGroup(item) {
|
|
1083
|
+
if (typeof item === "object" && item !== null && "name" in item && "description" in item && "files" in item && Array.isArray(item.files)) {
|
|
1084
|
+
const obj = item;
|
|
1085
|
+
return {
|
|
1086
|
+
name: String(obj.name),
|
|
1087
|
+
description: String(obj.description),
|
|
1088
|
+
files: obj.files.filter((f) => typeof f === "string")
|
|
1089
|
+
};
|
|
1090
|
+
}
|
|
1091
|
+
return null;
|
|
1092
|
+
}
|
|
1093
|
+
/** Return the index of the closing quote of a string starting at text[start] === '"'. */
|
|
1094
|
+
function skipString(text, start) {
|
|
1095
|
+
let i = start + 1;
|
|
1096
|
+
while (i < text.length) {
|
|
1097
|
+
const ch = text[i];
|
|
1098
|
+
if (ch === "\\") {
|
|
1099
|
+
i += 2;
|
|
1100
|
+
continue;
|
|
1101
|
+
}
|
|
1102
|
+
if (ch === "\"") return i;
|
|
1103
|
+
i++;
|
|
1104
|
+
}
|
|
1105
|
+
return i;
|
|
1106
|
+
}
|
|
1107
|
+
function openBrace(state, index) {
|
|
1108
|
+
if (state.depth === 0) state.start = index;
|
|
1109
|
+
state.depth++;
|
|
1110
|
+
}
|
|
1111
|
+
function pushParsedObject(objects, candidate) {
|
|
1112
|
+
try {
|
|
1113
|
+
objects.push(JSON.parse(candidate));
|
|
1114
|
+
} catch {}
|
|
1115
|
+
}
|
|
1116
|
+
function closeBrace(state, text, index) {
|
|
1117
|
+
if (state.depth === 0) return;
|
|
1118
|
+
state.depth--;
|
|
1119
|
+
if (state.depth === 0 && state.start !== -1) {
|
|
1120
|
+
pushParsedObject(state.objects, text.slice(state.start, index + 1));
|
|
1121
|
+
state.start = -1;
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
/**
|
|
1125
|
+
* Scan for top-level `{...}` objects, respecting string literals and escapes.
|
|
1126
|
+
* Used to recover groups when the model emits a single object or concatenated
|
|
1127
|
+
* objects instead of the requested JSON array.
|
|
1128
|
+
*/
|
|
1129
|
+
function extractTopLevelObjects(text) {
|
|
1130
|
+
const state = {
|
|
1131
|
+
objects: [],
|
|
1132
|
+
depth: 0,
|
|
1133
|
+
start: -1
|
|
1134
|
+
};
|
|
1135
|
+
for (let i = 0; i < text.length; i++) {
|
|
1136
|
+
const ch = text[i];
|
|
1137
|
+
if (ch === "\"") {
|
|
1138
|
+
i = skipString(text, i);
|
|
1139
|
+
continue;
|
|
1140
|
+
}
|
|
1141
|
+
if (ch === "{") {
|
|
1142
|
+
openBrace(state, i);
|
|
1143
|
+
continue;
|
|
1144
|
+
}
|
|
1145
|
+
if (ch === "}") closeBrace(state, text, i);
|
|
1146
|
+
}
|
|
1147
|
+
return state.objects;
|
|
1148
|
+
}
|
|
1149
|
+
function parseGroupingResponse(content) {
|
|
1150
|
+
let cleaned = content.replace(/<think[\s\S]*?<\/think>/gi, "").trim();
|
|
1151
|
+
cleaned = cleaned.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "").trim();
|
|
1152
|
+
const start = cleaned.indexOf("[");
|
|
1153
|
+
const end = cleaned.lastIndexOf("]");
|
|
1154
|
+
if (start !== -1 && end !== -1 && end > start) try {
|
|
1155
|
+
const parsed = JSON.parse(cleaned.slice(start, end + 1));
|
|
1156
|
+
if (Array.isArray(parsed)) {
|
|
1157
|
+
if (parsed.length === 0) return [];
|
|
1158
|
+
const groups = parsed.map(coerceGroup).filter((g) => g !== null);
|
|
1159
|
+
if (groups.length > 0) return groups;
|
|
1160
|
+
}
|
|
1161
|
+
} catch {}
|
|
1162
|
+
const groups = extractTopLevelObjects(cleaned).map(coerceGroup).filter((g) => g !== null);
|
|
1163
|
+
if (groups.length > 0) return groups;
|
|
1164
|
+
throw new Error("AI response did not contain a JSON array");
|
|
1165
|
+
}
|
|
1166
|
+
//#endregion
|
|
1064
1167
|
//#region src/services/grouping.ts
|
|
1065
1168
|
function matchesExcludePattern(filePath, pattern) {
|
|
1066
1169
|
if (pattern === filePath) return true;
|
|
@@ -1165,23 +1268,6 @@ function buildRetryGroupingPrompt() {
|
|
|
1165
1268
|
"Output ONLY valid JSON. No markdown fences, no explanation."
|
|
1166
1269
|
].join("\n");
|
|
1167
1270
|
}
|
|
1168
|
-
function parseGroupingResponse(content) {
|
|
1169
|
-
let cleaned = content.replace(/<think[\s\S]*?<\/think>/gi, "").trim();
|
|
1170
|
-
cleaned = cleaned.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "").trim();
|
|
1171
|
-
const start = cleaned.indexOf("[");
|
|
1172
|
-
const end = cleaned.lastIndexOf("]");
|
|
1173
|
-
if (start === -1 || end === -1 || end <= start) throw new Error("AI response did not contain a JSON array");
|
|
1174
|
-
const jsonText = cleaned.slice(start, end + 1);
|
|
1175
|
-
const parsed = JSON.parse(jsonText);
|
|
1176
|
-
if (!Array.isArray(parsed)) throw new Error("AI response was not a JSON array");
|
|
1177
|
-
const rawGroups = [];
|
|
1178
|
-
for (const item of parsed) if (typeof item === "object" && item !== null && "name" in item && "description" in item && "files" in item && Array.isArray(item.files)) rawGroups.push({
|
|
1179
|
-
name: String(item.name),
|
|
1180
|
-
description: String(item.description),
|
|
1181
|
-
files: item.files.filter((f) => typeof f === "string")
|
|
1182
|
-
});
|
|
1183
|
-
return rawGroups;
|
|
1184
|
-
}
|
|
1185
1271
|
async function generateGroups(files, apiKey, model, timeout, provider, proxy) {
|
|
1186
1272
|
debug("generateGroups: %d files, model=%s", files.length, model ?? "default");
|
|
1187
1273
|
const { included, excluded } = filterExcludedFiles(files);
|
|
@@ -1209,7 +1295,7 @@ async function generateGroups(files, apiKey, model, timeout, provider, proxy) {
|
|
|
1209
1295
|
debug("generateGroups: parsed %d raw groups", rawGroups.length);
|
|
1210
1296
|
let validated = validateGroups(rawGroups, included);
|
|
1211
1297
|
debug("generateGroups: %d validated groups", validated.length);
|
|
1212
|
-
if (isLowQualityGrouping(
|
|
1298
|
+
if (isLowQualityGrouping(rawGroups, included)) {
|
|
1213
1299
|
debug("generateGroups: low quality result, retrying with stricter prompt");
|
|
1214
1300
|
rawGroups = await callGroupingAI(client, resolvedModel, buildRetryGroupingPrompt(), userPrompt);
|
|
1215
1301
|
debug("generateGroups retry: parsed %d raw groups", rawGroups.length);
|
|
@@ -1248,7 +1334,8 @@ async function callGroupingAI(client, model, systemPrompt, userPrompt) {
|
|
|
1248
1334
|
/** Minimum file count where a single-group result is considered low quality */
|
|
1249
1335
|
const MIN_FILES_FOR_QUALITY_CHECK = 5;
|
|
1250
1336
|
function isLowQualityGrouping(groups, allFiles) {
|
|
1251
|
-
if (
|
|
1337
|
+
if (allFiles.length === 0) return false;
|
|
1338
|
+
if (groups.length === 0) return true;
|
|
1252
1339
|
if (allFiles.length < MIN_FILES_FOR_QUALITY_CHECK) return false;
|
|
1253
1340
|
return groups.length === 1;
|
|
1254
1341
|
}
|
|
@@ -1544,6 +1631,26 @@ async function showCheckFailureMenu(errors, rawStderr, onRetry) {
|
|
|
1544
1631
|
}
|
|
1545
1632
|
}
|
|
1546
1633
|
//#endregion
|
|
1634
|
+
//#region src/ui/check-summary.ts
|
|
1635
|
+
/**
|
|
1636
|
+
* Stop a check spinner with a per-tool summary of the check results.
|
|
1637
|
+
*
|
|
1638
|
+
* - On success: stops with "All checks passed" and prints a `✓ tool` line
|
|
1639
|
+
* for each result.
|
|
1640
|
+
* - On failure: stops with "N checks failed" (pluralized). Raw error output
|
|
1641
|
+
* is intentionally NOT printed here — callers handle failure display
|
|
1642
|
+
* (menu, raw print, etc.).
|
|
1643
|
+
*/
|
|
1644
|
+
function stopCheckSpinner(spinner, results) {
|
|
1645
|
+
if (results.ok) {
|
|
1646
|
+
spinner.stop("All checks passed");
|
|
1647
|
+
if (results.results.length > 0) log.info(results.results.map((r) => ` ${green("✓")} ${r.tool}`).join("\n"));
|
|
1648
|
+
} else {
|
|
1649
|
+
const failed = results.results.filter((r) => !r.ok);
|
|
1650
|
+
spinner.stop(`${failed.length} check${failed.length !== 1 ? "s" : ""} failed`);
|
|
1651
|
+
}
|
|
1652
|
+
}
|
|
1653
|
+
//#endregion
|
|
1547
1654
|
//#region src/ui/grouping.ts
|
|
1548
1655
|
async function showGroupingConfirmation(groups, excluded) {
|
|
1549
1656
|
debug("showGroupingConfirmation: %d groups, %d excluded", groups.length, excluded.length);
|
|
@@ -1805,10 +1912,7 @@ async function runAutoGroupFlow(changedFiles, flags) {
|
|
|
1805
1912
|
}
|
|
1806
1913
|
break;
|
|
1807
1914
|
}
|
|
1808
|
-
if (checkResults.ok)
|
|
1809
|
-
ck.stop("All checks passed");
|
|
1810
|
-
if (checkResults.results.length > 0) log.info(checkResults.results.map((r) => ` ${green("✓")} ${r.tool}`).join("\n"));
|
|
1811
|
-
}
|
|
1915
|
+
if (checkResults.ok) stopCheckSpinner(ck, checkResults);
|
|
1812
1916
|
}
|
|
1813
1917
|
}
|
|
1814
1918
|
const config = await readConfig();
|
|
@@ -1931,18 +2035,6 @@ function buildExcludedFilesMessage(files) {
|
|
|
1931
2035
|
//#endregion
|
|
1932
2036
|
//#region src/commands/agent.ts
|
|
1933
2037
|
/**
|
|
1934
|
-
* Wrapper around getHead() that returns "" on fresh repos with no commits.
|
|
1935
|
-
* `git rev-parse HEAD` fails with exit 128 on a brand-new repo, which would
|
|
1936
|
-
* crash the agent flow before the first commit can be made.
|
|
1937
|
-
*/
|
|
1938
|
-
async function safeGetHead() {
|
|
1939
|
-
try {
|
|
1940
|
-
return await getHead();
|
|
1941
|
-
} catch {
|
|
1942
|
-
return "";
|
|
1943
|
-
}
|
|
1944
|
-
}
|
|
1945
|
-
/**
|
|
1946
2038
|
* Headless agent command — orchestrates the entire commit flow without any TUI
|
|
1947
2039
|
* interaction. Emits structured JSON results to stdout, one per line. Returns
|
|
1948
2040
|
* control to the caller with `process.exitCode` set to one of the 7 documented
|
|
@@ -1995,16 +2087,16 @@ async function agentCommand(flags) {
|
|
|
1995
2087
|
if ("excludedFiles" in diffResult) {
|
|
1996
2088
|
debug("All staged files are excluded:", diffResult.excludedFiles);
|
|
1997
2089
|
const message = buildExcludedFilesMessage(diffResult.excludedFiles);
|
|
1998
|
-
const headBefore = await
|
|
2090
|
+
const headBefore = await getHead();
|
|
1999
2091
|
const result = await attemptCommit(message);
|
|
2000
|
-
const headAfter = await
|
|
2092
|
+
const headAfter = await getHead();
|
|
2001
2093
|
if (result.ok || headBefore !== headAfter) {
|
|
2002
2094
|
process.exitCode = EXIT_CODES.SUCCESS;
|
|
2003
2095
|
writeAgentResult({
|
|
2004
2096
|
status: "success",
|
|
2005
2097
|
commits: [{
|
|
2006
2098
|
message,
|
|
2007
|
-
hash: headAfter,
|
|
2099
|
+
hash: headAfter ?? "",
|
|
2008
2100
|
files: diffResult.excludedFiles
|
|
2009
2101
|
}]
|
|
2010
2102
|
});
|
|
@@ -2020,16 +2112,16 @@ async function agentCommand(flags) {
|
|
|
2020
2112
|
}
|
|
2021
2113
|
if (flags.message) {
|
|
2022
2114
|
debug("Using provided message:", flags.message);
|
|
2023
|
-
const headBefore = await
|
|
2115
|
+
const headBefore = await getHead();
|
|
2024
2116
|
const result = await attemptCommit(flags.message);
|
|
2025
|
-
const headAfter = await
|
|
2117
|
+
const headAfter = await getHead();
|
|
2026
2118
|
if (result.ok || headBefore !== headAfter) {
|
|
2027
2119
|
process.exitCode = EXIT_CODES.SUCCESS;
|
|
2028
2120
|
writeAgentResult({
|
|
2029
2121
|
status: "success",
|
|
2030
2122
|
commits: [{
|
|
2031
2123
|
message: flags.message,
|
|
2032
|
-
hash: headAfter,
|
|
2124
|
+
hash: headAfter ?? "",
|
|
2033
2125
|
files: diffResult.files
|
|
2034
2126
|
}]
|
|
2035
2127
|
});
|
|
@@ -2069,9 +2161,9 @@ async function agentCommand(flags) {
|
|
|
2069
2161
|
debug("Committing %d excluded files:", excluded.length, excluded);
|
|
2070
2162
|
await resetStaging();
|
|
2071
2163
|
await stageFiles(excluded);
|
|
2072
|
-
const headBefore = await
|
|
2164
|
+
const headBefore = await getHead();
|
|
2073
2165
|
const result = await attemptCommit(message);
|
|
2074
|
-
const headAfter = await
|
|
2166
|
+
const headAfter = await getHead();
|
|
2075
2167
|
if (!result.ok && headBefore === headAfter) debug("Excluded files commit failed, continuing without them");
|
|
2076
2168
|
}
|
|
2077
2169
|
if (included.length === 0) {
|
|
@@ -2141,13 +2233,13 @@ async function agentCommand(flags) {
|
|
|
2141
2233
|
return;
|
|
2142
2234
|
}
|
|
2143
2235
|
await saveCachedCommit(await getRepoRoot(), message);
|
|
2144
|
-
const headBefore = await
|
|
2236
|
+
const headBefore = await getHead();
|
|
2145
2237
|
const result = await attemptCommit(message);
|
|
2146
|
-
const headAfter = await
|
|
2238
|
+
const headAfter = await getHead();
|
|
2147
2239
|
if (result.ok || headBefore !== headAfter) {
|
|
2148
2240
|
commits.push({
|
|
2149
2241
|
message,
|
|
2150
|
-
hash: headAfter,
|
|
2242
|
+
hash: headAfter ?? "",
|
|
2151
2243
|
files: group.files,
|
|
2152
2244
|
groupName: group.name
|
|
2153
2245
|
});
|
|
@@ -2526,14 +2618,8 @@ async function handleStaging(changedFiles, flags) {
|
|
|
2526
2618
|
const ckSpinner = spinner();
|
|
2527
2619
|
ckSpinner.start("Running checks...");
|
|
2528
2620
|
const ckResult = await runAllChecks(repoRoot, allFiles, 6e4);
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
for (const r of ckResult.results) if (r.stdout.trim()) log.info(dim(r.stdout.trim()));
|
|
2532
|
-
} else {
|
|
2533
|
-
const failed = ckResult.results.filter((r) => !r.ok);
|
|
2534
|
-
ckSpinner.stop(`${failed.length} check${failed.length !== 1 ? "s" : ""} failed`);
|
|
2535
|
-
for (const r of failed) log.info(r.stderr?.trim() || r.stdout?.trim() || `Check failed: ${r.command}`);
|
|
2536
|
-
}
|
|
2621
|
+
stopCheckSpinner(ckSpinner, ckResult);
|
|
2622
|
+
if (!ckResult.ok) for (const r of ckResult.results.filter((r) => !r.ok)) log.info(r.stderr?.trim() || r.stdout?.trim() || `Check failed: ${r.command}`);
|
|
2537
2623
|
}
|
|
2538
2624
|
currentFiles = await getChangedFiles();
|
|
2539
2625
|
continue;
|
|
@@ -2569,7 +2655,10 @@ async function runPreCommitChecks(changedFiles, noCheck) {
|
|
|
2569
2655
|
const stagedFileList = changedFiles.filter((f) => f.staged && f.status !== "D").map((f) => f.path);
|
|
2570
2656
|
if (stagedFileList.length === 0) return;
|
|
2571
2657
|
debug("Running user checks on %d staged files...", stagedFileList.length);
|
|
2658
|
+
const ckSpinner = spinner();
|
|
2659
|
+
ckSpinner.start("Running checks...");
|
|
2572
2660
|
let checkResults = await runAllChecks(checkRoot, stagedFileList, 6e4);
|
|
2661
|
+
stopCheckSpinner(ckSpinner, checkResults);
|
|
2573
2662
|
debug("Check results: ok=%s, count=%d", checkResults.ok, checkResults.results.length);
|
|
2574
2663
|
while (!checkResults.ok) {
|
|
2575
2664
|
const rawOutput = checkResults.results.filter((r) => !r.ok).map((r) => `[${r.tool}]\n${r.stdout}\n${r.stderr}`.trim()).join("\n\n");
|
|
@@ -2584,13 +2673,7 @@ async function runPreCommitChecks(changedFiles, noCheck) {
|
|
|
2584
2673
|
ckSpinner.start("Running checks...");
|
|
2585
2674
|
checkResults = await runAllChecks(checkRoot, stagedFileList, 6e4);
|
|
2586
2675
|
debug("Retry check results: ok=%s, count=%d", checkResults.ok, checkResults.results.length);
|
|
2587
|
-
|
|
2588
|
-
ckSpinner.stop("All checks passed");
|
|
2589
|
-
for (const r of checkResults.results) if (r.stdout.trim()) log.info(dim(r.stdout.trim()));
|
|
2590
|
-
} else {
|
|
2591
|
-
const retryFailed = checkResults.results.filter((r) => !r.ok);
|
|
2592
|
-
ckSpinner.stop(`${retryFailed.length} check${retryFailed.length !== 1 ? "s" : ""} failed`);
|
|
2593
|
-
}
|
|
2676
|
+
stopCheckSpinner(ckSpinner, checkResults);
|
|
2594
2677
|
continue;
|
|
2595
2678
|
}
|
|
2596
2679
|
break;
|