@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 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.2",
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
- debug("resetStaging: git reset HEAD");
999
- await execa("git", ["reset", "HEAD"]);
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
- const { stdout } = await execa("git", ["rev-parse", "HEAD"]);
1003
- return stdout.trim();
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(validated, included)) {
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 (groups.length === 0) return false;
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 safeGetHead();
2090
+ const headBefore = await getHead();
1999
2091
  const result = await attemptCommit(message);
2000
- const headAfter = await safeGetHead();
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 safeGetHead();
2115
+ const headBefore = await getHead();
2024
2116
  const result = await attemptCommit(flags.message);
2025
- const headAfter = await safeGetHead();
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 safeGetHead();
2164
+ const headBefore = await getHead();
2073
2165
  const result = await attemptCommit(message);
2074
- const headAfter = await safeGetHead();
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 safeGetHead();
2236
+ const headBefore = await getHead();
2145
2237
  const result = await attemptCommit(message);
2146
- const headAfter = await safeGetHead();
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
- if (ckResult.ok) {
2530
- ckSpinner.stop("All checks passed");
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
- if (checkResults.ok) {
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;