@kyubiware/commit-mint 0.7.6 → 0.8.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/dist/cli.mjs CHANGED
@@ -29,7 +29,7 @@ var __exportAll = (all, no_symbols) => {
29
29
  //#region package.json
30
30
  var package_default = {
31
31
  name: "@kyubiware/commit-mint",
32
- version: "0.7.6",
32
+ version: "0.8.1",
33
33
  description: "🌿 AI-powered git commit tool — auto-group changed files, generate messages, run pre-commit checks",
34
34
  type: "module",
35
35
  bin: { "cmint": "./dist/cli.mjs" },
@@ -673,7 +673,10 @@ async function runCommand(command, timeout, repoRoot) {
673
673
  timeout,
674
674
  all: true,
675
675
  preferLocal: true,
676
- ...repoRoot ? { localDir: repoRoot } : {}
676
+ ...repoRoot ? {
677
+ localDir: repoRoot,
678
+ cwd: repoRoot
679
+ } : {}
677
680
  });
678
681
  const ok = !result.failed;
679
682
  debug("runCommand: %s — ok=%s", tool, ok);
@@ -924,8 +927,10 @@ var git_exports = /* @__PURE__ */ __exportAll({
924
927
  getHead: () => getHead,
925
928
  getRepoRoot: () => getRepoRoot,
926
929
  getStagedDiff: () => getStagedDiff,
930
+ getStagedFiles: () => getStagedFiles,
927
931
  getStatusShort: () => getStatusShort,
928
932
  resetStaging: () => resetStaging,
933
+ resolveToRepoRoot: () => resolveToRepoRoot,
929
934
  stageAll: () => stageAll,
930
935
  stageFiles: () => stageFiles
931
936
  });
@@ -1026,7 +1031,11 @@ async function getStatusShort() {
1026
1031
  return stdout.trim();
1027
1032
  }
1028
1033
  async function getChangedFiles() {
1029
- const { stdout } = await execa("git", ["status", "--short"]);
1034
+ const { stdout } = await execa("git", [
1035
+ "status",
1036
+ "--short",
1037
+ "--untracked-files=all"
1038
+ ]);
1030
1039
  if (!stdout.trim()) return [];
1031
1040
  const files = stdout.split("\n").filter(Boolean).map((line) => {
1032
1041
  const indexStatus = line[0];
@@ -1039,6 +1048,44 @@ async function getChangedFiles() {
1039
1048
  debug("getChangedFiles:", files.length, "files");
1040
1049
  return files;
1041
1050
  }
1051
+ /**
1052
+ * Return staged file paths relative to the repository root, excluding deletions.
1053
+ *
1054
+ * `git status --short` reports paths relative to the current working directory,
1055
+ * but `.cmintrc` globs are written from the repo root (matching lint-staged
1056
+ * conventions). Use this helper whenever staged paths need to match repo-root
1057
+ * globs. `--diff-filter=d` excludes staged deletions so check commands don't
1058
+ * receive paths whose content no longer exists.
1059
+ */
1060
+ async function getStagedFiles() {
1061
+ const { stdout } = await execa("git", [
1062
+ "diff",
1063
+ "--cached",
1064
+ "--name-only",
1065
+ "--diff-filter=d"
1066
+ ]);
1067
+ const files = stdout.split("\n").map((line) => line.trim()).filter(Boolean);
1068
+ debug("getStagedFiles:", files.length, "files");
1069
+ return files;
1070
+ }
1071
+ /**
1072
+ * Convert cwd-relative file paths to repo-root-relative paths.
1073
+ *
1074
+ * Uses `git rev-parse --show-prefix` to discover the prefix of the current
1075
+ * working directory relative to the repo root (e.g. `"extension/"` when cwd
1076
+ * is `<repo>/extension`, or `""` when at the repo root). Useful when a caller
1077
+ * has cwd-relative paths from `getChangedFiles()` but needs to match them
1078
+ * against repo-root-relative `.cmintrc` globs (e.g. the auto-group flow,
1079
+ * which runs checks BEFORE files are staged — so `getStagedFiles()` can't be
1080
+ * used because the index doesn't yet contain those paths).
1081
+ */
1082
+ async function resolveToRepoRoot(cwdRelativePaths) {
1083
+ if (cwdRelativePaths.length === 0) return [];
1084
+ const { stdout } = await execa("git", ["rev-parse", "--show-prefix"]);
1085
+ const prefix = stdout.trim();
1086
+ if (!prefix) return [...cwdRelativePaths];
1087
+ return cwdRelativePaths.map((p) => `${prefix}${p}`);
1088
+ }
1042
1089
  async function stageFiles(paths) {
1043
1090
  debug("stageFiles:", paths);
1044
1091
  await execa("git", ["add", ...paths]);
@@ -1167,6 +1214,168 @@ function parseGroupingResponse(content) {
1167
1214
  throw new Error("AI response did not contain a JSON array");
1168
1215
  }
1169
1216
  //#endregion
1217
+ //#region src/services/grouping-reunite.ts
1218
+ /**
1219
+ * # Test/source reunification
1220
+ *
1221
+ * AI grouping prompts ask the model to keep a source file and its tests in the
1222
+ * same commit group, but the model frequently ignores that instruction (it
1223
+ * split `git.ts` from `git.test.ts` in the bug report that motivated this
1224
+ * module). The functions here provide a deterministic post-processing pass
1225
+ * that moves misplaced test files back into the group already containing
1226
+ * their source counterpart, regardless of what the model decided.
1227
+ *
1228
+ * Supported layouts (in priority order):
1229
+ * 1. Co-located: `src/foo.ts` ↔ `src/foo.test.ts`
1230
+ * 2. `__tests__/` mirror: `src/foo.ts` ↔ `src/__tests__/foo.test.ts`
1231
+ * 3. `tests/` or `test/` mirror: `src/foo.ts` ↔ `tests/foo.test.ts`
1232
+ *
1233
+ * Reunification runs inside `validateGroups()` (see `grouping.ts`) after
1234
+ * hallucinated-path filtering so only real files participate.
1235
+ */
1236
+ /** Suffixes that mark a file as a test companion of a same-name source. */
1237
+ const TEST_SUFFIXES = [".test", ".spec"];
1238
+ /**
1239
+ * Extensions tried when looking for a source counterpart for a test file. The
1240
+ * test file's own extension is usually the right one, but we also try common
1241
+ * alternates (a `.test.tsx` may back a `.tsx` or a `.ts`).
1242
+ */
1243
+ const SOURCE_EXTENSIONS = [
1244
+ ".ts",
1245
+ ".tsx",
1246
+ ".js",
1247
+ ".jsx",
1248
+ ".mjs",
1249
+ ".cjs",
1250
+ ".mts",
1251
+ ".cts"
1252
+ ];
1253
+ /** Matches a test file by extension regardless of which suffix it uses. */
1254
+ const TEST_FILE_PATTERN = /\.(?:test|spec)\.(?:ts|tsx|js|jsx|mjs|cjs|mts|cts)$/;
1255
+ /** Directory prefixes/segments that mirror source layout for non-co-located tests. */
1256
+ const TEST_DIR_PREFIXES = ["tests/", "test/"];
1257
+ /** Marker segment for co-located `__tests__/` directories. */
1258
+ const TESTS_DIR_SEGMENT = "/__tests__/";
1259
+ function stripTestSuffix(filename) {
1260
+ for (const suffix of TEST_SUFFIXES) {
1261
+ const marker = `${suffix}.`;
1262
+ const idx = filename.lastIndexOf(marker);
1263
+ if (idx > 0) return filename.slice(0, idx);
1264
+ }
1265
+ return null;
1266
+ }
1267
+ function withEachExtension(base) {
1268
+ return SOURCE_EXTENSIONS.map((ext) => `${base}${ext}`);
1269
+ }
1270
+ /** Co-located: `dir/foo.test.ts` → `dir/foo.{ts,tsx,...}` */
1271
+ function colocatedCandidates(testPath) {
1272
+ const base = stripTestSuffix(testPath);
1273
+ return base === null ? [] : withEachExtension(base);
1274
+ }
1275
+ /** `__tests__` mirror: `src/__tests__/foo.test.ts` → `src/foo.{ts,tsx,...}` */
1276
+ function testsDirCandidates(testPath) {
1277
+ const segmentIdx = testPath.indexOf(TESTS_DIR_SEGMENT);
1278
+ if (segmentIdx < 0) return [];
1279
+ const parentDir = testPath.slice(0, segmentIdx);
1280
+ const base = stripTestSuffix(testPath.slice(segmentIdx + 11));
1281
+ if (base === null) return [];
1282
+ return withEachExtension(`${parentDir}/${base}`);
1283
+ }
1284
+ /** `tests/` or `test/` mirror: `tests/services/foo.test.ts` → `src/services/foo.{ts,tsx,...}` */
1285
+ function prefixedDirCandidates(testPath) {
1286
+ for (const prefix of TEST_DIR_PREFIXES) {
1287
+ if (!testPath.startsWith(prefix)) continue;
1288
+ const base = stripTestSuffix(testPath.slice(prefix.length));
1289
+ if (base === null) return [];
1290
+ return withEachExtension(`src/${base}`);
1291
+ }
1292
+ return [];
1293
+ }
1294
+ /**
1295
+ * Compute candidate source paths for a test file in priority order: co-located
1296
+ * first, then `__tests__/` mirror, then `tests/`/`test/` mirror. Each mirror
1297
+ * emits one entry per `SOURCE_EXTENSIONS` candidate so the caller can match
1298
+ * against any of them.
1299
+ */
1300
+ function candidateSourcePaths(testPath) {
1301
+ return [
1302
+ ...colocatedCandidates(testPath),
1303
+ ...testsDirCandidates(testPath),
1304
+ ...prefixedDirCandidates(testPath)
1305
+ ];
1306
+ }
1307
+ /**
1308
+ * Resolve the unambiguous target group for a single test file, or `null` when
1309
+ * no source counterpart exists / matches more than one group (ambiguous). A
1310
+ * test whose candidates span multiple groups (e.g. `foo.ts` and `foo.tsx` in
1311
+ * different groups) is intentionally left alone rather than guessed.
1312
+ */
1313
+ function findTargetGroup(testFile, currentGi, fileToGroup) {
1314
+ const targetGroups = /* @__PURE__ */ new Set();
1315
+ for (const candidate of candidateSourcePaths(testFile)) {
1316
+ const targetGi = fileToGroup.get(candidate);
1317
+ if (targetGi !== void 0 && targetGi !== currentGi) targetGroups.add(targetGi);
1318
+ }
1319
+ return targetGroups.size === 1 ? [...targetGroups][0] : null;
1320
+ }
1321
+ /**
1322
+ * Find the unambiguous target group for each misplaced test file.
1323
+ *
1324
+ * Returns a map of `testFile → targetGroupIndex`. A test is only moved when
1325
+ * its candidate source matches exactly one other group; ambiguous matches are
1326
+ * skipped (see {@link findTargetGroup}).
1327
+ */
1328
+ function findMoves(groups, fileToGroup) {
1329
+ const moves = /* @__PURE__ */ new Map();
1330
+ for (let gi = 0; gi < groups.length; gi++) for (const file of groups[gi].files) {
1331
+ if (moves.has(file) || !TEST_FILE_PATTERN.test(file)) continue;
1332
+ const target = findTargetGroup(file, gi, fileToGroup);
1333
+ if (target !== null) moves.set(file, target);
1334
+ }
1335
+ return moves;
1336
+ }
1337
+ /**
1338
+ * Apply queued moves to a fresh copy of the groups array. Tests are removed
1339
+ * from their original group and appended to the target group. Groups left
1340
+ * empty by moves are dropped.
1341
+ */
1342
+ function applyMoves(groups, moves) {
1343
+ const result = groups.map((g) => ({
1344
+ ...g,
1345
+ files: [...g.files]
1346
+ }));
1347
+ const movedFiles = new Set(moves.keys());
1348
+ const additionsByGroup = /* @__PURE__ */ new Map();
1349
+ for (const [file, toGroup] of moves) {
1350
+ const bucket = additionsByGroup.get(toGroup) ?? [];
1351
+ bucket.push(file);
1352
+ additionsByGroup.set(toGroup, bucket);
1353
+ }
1354
+ for (let gi = 0; gi < result.length; gi++) {
1355
+ if (movedFiles.size > 0) result[gi].files = result[gi].files.filter((f) => !movedFiles.has(f));
1356
+ const additions = additionsByGroup.get(gi);
1357
+ if (!additions) continue;
1358
+ const existing = new Set(result[gi].files);
1359
+ for (const file of additions) if (!existing.has(file)) result[gi].files.push(file);
1360
+ }
1361
+ return result.filter((g) => g.files.length > 0);
1362
+ }
1363
+ /**
1364
+ * Move test files (`.test.*` / `.spec.*`) into the group that already contains
1365
+ * their source counterpart. Returns the same array reference if no moves were
1366
+ * needed; otherwise returns a fresh array of new group objects.
1367
+ *
1368
+ * See module doc for the matching algorithm.
1369
+ */
1370
+ function reuniteTestsWithSources(groups) {
1371
+ if (groups.length === 0) return groups;
1372
+ const fileToGroup = /* @__PURE__ */ new Map();
1373
+ for (let gi = 0; gi < groups.length; gi++) for (const file of groups[gi].files) fileToGroup.set(file, gi);
1374
+ const moves = findMoves(groups, fileToGroup);
1375
+ if (moves.size === 0) return groups;
1376
+ return applyMoves(groups, moves);
1377
+ }
1378
+ //#endregion
1170
1379
  //#region src/services/grouping.ts
1171
1380
  function matchesExcludePattern(filePath, pattern) {
1172
1381
  if (pattern === filePath) return true;
@@ -1227,7 +1436,8 @@ function buildGroupingSystemPrompt() {
1227
1436
  "You are analyzing changed files in a git repository. Group them into logical commits based on what changed and why. Each group should be a coherent unit of work.",
1228
1437
  "",
1229
1438
  "Rules:",
1230
- "- Group by feature, fix, or concern (e.g., 'Frontend refactor', 'API changes', 'Test updates')",
1439
+ "- ALWAYS keep a test file in the same group as the source file it tests. Examples: `foo.test.ts` stays with `foo.ts`; `__tests__/foo.test.ts` stays with `foo.ts` in the parent directory; `tests/foo.test.ts` stays with `src/foo.ts`. Never put source and its tests in separate groups.",
1440
+ "- Group by feature, fix, or concern (e.g., 'Frontend refactor', 'API changes')",
1231
1441
  "- Keep related files together (e.g., a component + its test, a model + its migration)",
1232
1442
  "- Separate documentation changes (*.md files, docs/) from code changes — put docs in their own group",
1233
1443
  "- Do not split a single logical change across multiple groups",
@@ -1255,12 +1465,13 @@ function buildRetryGroupingPrompt() {
1255
1465
  "You MUST split the files into at least 2 groups based on what changed and why.",
1256
1466
  "",
1257
1467
  "Look for these natural split points:",
1258
- "- Source code vs tests",
1259
1468
  "- Different features or modules (e.g., different directories)",
1260
1469
  "- New files vs modified files vs deleted files",
1261
1470
  "- Configuration changes vs code changes",
1262
1471
  "- Documentation vs implementation",
1263
1472
  "",
1473
+ "Do NOT split a source file from its tests — keep `foo.ts` and `foo.test.ts` in the same group.",
1474
+ "",
1264
1475
  "If unsure, err on the side of MORE groups, not fewer.",
1265
1476
  "",
1266
1477
  "Output format: JSON array of objects with keys 'name', 'description', 'files'.",
@@ -1359,16 +1570,18 @@ function validateGroups(groups, allFiles) {
1359
1570
  files: uniqueFiles
1360
1571
  });
1361
1572
  }
1573
+ const reunited = reuniteTestsWithSources(validated);
1574
+ if (reunited !== validated) debug("validateGroups: reunited %d groups after test/source merge", reunited.length);
1362
1575
  const ungrouped = allFiles.filter((f) => !seen.has(f.path));
1363
1576
  if (ungrouped.length > 0) {
1364
1577
  debug("validateGroups: %d ungrouped files added to 'Other changes'", ungrouped.length);
1365
- validated.push({
1578
+ reunited.push({
1366
1579
  name: "Other changes",
1367
1580
  description: "Miscellaneous changes that did not fit into other groups",
1368
1581
  files: ungrouped.map((f) => f.path)
1369
1582
  });
1370
1583
  }
1371
- return validated;
1584
+ return reunited;
1372
1585
  }
1373
1586
  const EXIT_CODES = {
1374
1587
  SUCCESS: 0,
@@ -1416,6 +1629,68 @@ async function loadCachedCommit(repoPath) {
1416
1629
  }
1417
1630
  }
1418
1631
  //#endregion
1632
+ //#region src/ui/grouping.ts
1633
+ async function showGroupingConfirmation(groups, excluded) {
1634
+ debug("showGroupingConfirmation: %d groups, %d excluded", groups.length, excluded.length);
1635
+ const lines = [];
1636
+ for (const group of groups) {
1637
+ lines.push(bold(group.name));
1638
+ lines.push(` ${dim(group.description)}`);
1639
+ lines.push(` ${green(String(group.files.length))} file${group.files.length !== 1 ? "s" : ""}`);
1640
+ for (const file of group.files) lines.push(` ${dim("•")} ${file}`);
1641
+ lines.push("");
1642
+ }
1643
+ if (excluded.length > 0) {
1644
+ lines.push(dim(`Excluded: ${excluded.length} file${excluded.length !== 1 ? "s" : ""}`));
1645
+ for (const file of excluded) lines.push(` ${dim("•")} ${dim(file)}`);
1646
+ }
1647
+ p.note(lines.join("\n"), "Proposed commit groups");
1648
+ const choice = await p.select({
1649
+ message: "Proceed with these groupings?",
1650
+ options: [{
1651
+ label: "Yes, commit all groups",
1652
+ value: "yes"
1653
+ }, {
1654
+ label: "No, cancel",
1655
+ value: "no"
1656
+ }]
1657
+ });
1658
+ if (p.isCancel(choice) || choice === "no") {
1659
+ debug("showGroupingConfirmation: user cancelled");
1660
+ return false;
1661
+ }
1662
+ debug("showGroupingConfirmation: user confirmed");
1663
+ return true;
1664
+ }
1665
+ function showGroupProgress(current, total, groupName) {
1666
+ p.log.info(`Commit group ${current} of ${total}: ${cyan(`"${groupName}"`)}`);
1667
+ }
1668
+ const statusLabel = (status) => {
1669
+ switch (status) {
1670
+ case "M": return yellow("M");
1671
+ case "A": return green("A");
1672
+ case "D": return red("D");
1673
+ case "?":
1674
+ case "??": return cyan("?");
1675
+ default: return dim(status);
1676
+ }
1677
+ };
1678
+ /** Display combined view: files with status indicators grouped by commit group */
1679
+ function showGroupedFiles(groups, changedFiles) {
1680
+ const statusMap = new Map(changedFiles.map((f) => [f.path, f.status]));
1681
+ const lines = [];
1682
+ for (let i = 0; i < groups.length; i++) {
1683
+ const group = groups[i];
1684
+ lines.push(`${bold(group.name)} ${dim("—")} ${group.files.length} file${group.files.length !== 1 ? "s" : ""}`);
1685
+ for (const file of group.files) {
1686
+ const status = statusMap.get(file) ?? "M";
1687
+ lines.push(` ${statusLabel(status)} ${file}`);
1688
+ }
1689
+ if (i < groups.length - 1) lines.push("");
1690
+ }
1691
+ p.note(lines.join("\n"), "Commit groups");
1692
+ }
1693
+ //#endregion
1419
1694
  //#region src/services/clipboard.ts
1420
1695
  /** Milliseconds to wait after stdin closes for quick exit failures. */
1421
1696
  const GRACE_PERIOD_MS = 150;
@@ -1480,286 +1755,18 @@ function tryCopy(cmd, args, content) {
1480
1755
  });
1481
1756
  let exitCode = null;
1482
1757
  child.on("exit", (code) => {
1483
- exitCode = code;
1484
- });
1485
- child.stdin.write(content, (err) => {
1486
- if (err) {
1487
- done(false, "stdin write error");
1488
- return;
1489
- }
1490
- child.stdin.end(() => {
1491
- setTimeout(() => handleGracePeriod(settled, exitCode, stderrChunks, child, done), GRACE_PERIOD_MS);
1492
- });
1493
- });
1494
- });
1495
- }
1496
- //#endregion
1497
- //#region src/ui/check-failure-menu.ts
1498
- const MAX_TSC_DIAGNOSTICS = 3;
1499
- const MAX_ESLINT_DIAGNOSTICS = 3;
1500
- const MAX_TEST_FAILURES = 3;
1501
- const MAX_SUMMARY_LINE_LENGTH = 120;
1502
- const TSC_DIAGNOSTIC = /^(.+?\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs))\((\d+),(\d+)\):\s+error\s+(TS\d+):\s+(.+)$/;
1503
- const ESLINT_ERROR_LINE = /^\s*(\d+):(\d+)\s+(error|warning)\s+(.+)\s{2,}(\S+)\s*$/;
1504
- const TEST_FILE_FAIL = /^\s*FAIL\s+(.+?\.(?:test|spec)\.[^\s>]+)\s*>\s*(.+)$/;
1505
- function formatCheckFailureSummary(errors) {
1506
- if (errors.length === 0) return "No check error details were parsed. View full output for details.";
1507
- return errors.map((error) => formatCheckErrorSummary(error)).join("\n");
1508
- }
1509
- function formatCheckErrorSummary(error) {
1510
- if (error.tool === "tsc") {
1511
- const diagnostics = extractTscDiagnostics(error.raw || error.message);
1512
- if (diagnostics.length > 0) return formatTscSummary(diagnostics);
1513
- }
1514
- if (error.tool === "eslint") {
1515
- const diagnostics = extractEslintDiagnostics(error.raw || error.message);
1516
- if (diagnostics.length > 0) return formatEslintSummary(diagnostics);
1517
- }
1518
- if (error.tool === "vitest" || error.tool === "jest") {
1519
- const failures = extractTestFailures(error.raw || error.message);
1520
- if (failures.length > 0) return formatTestFailureSummary(failures, error.tool);
1521
- }
1522
- const message = firstMeaningfulLine(error.message || error.raw);
1523
- return ` ${red("•")} [${error.tool}] ${truncate(message, MAX_SUMMARY_LINE_LENGTH)}`;
1524
- }
1525
- function extractTscDiagnostics(raw) {
1526
- return raw.split("\n").map((line) => line.trim()).map((line) => {
1527
- const match = TSC_DIAGNOSTIC.exec(line);
1528
- if (!match) return null;
1529
- return {
1530
- file: match[1] ?? "",
1531
- line: match[2] ?? "",
1532
- column: match[3] ?? "",
1533
- code: match[4] ?? "",
1534
- message: match[5] ?? ""
1535
- };
1536
- }).filter((diagnostic) => diagnostic !== null);
1537
- }
1538
- function formatTscSummary(diagnostics) {
1539
- const visible = diagnostics.slice(0, MAX_TSC_DIAGNOSTICS);
1540
- const hidden = diagnostics.length - visible.length;
1541
- const lines = [` ${red("•")} [tsc] ${diagnostics.length} TypeScript error${diagnostics.length !== 1 ? "s" : ""}`, ...visible.map((diagnostic) => `${diagnostic.file}:${diagnostic.line}:${diagnostic.column} — error ${diagnostic.code}: ${truncate(diagnostic.message, MAX_SUMMARY_LINE_LENGTH)}`)];
1542
- if (hidden > 0) lines.push(dim(` +${hidden} more TypeScript error${hidden !== 1 ? "s" : ""}. View full output for details.`));
1543
- return lines.join("\n");
1544
- }
1545
- function extractEslintDiagnostics(raw) {
1546
- const diagnostics = [];
1547
- const lines = raw.split("\n");
1548
- let currentFile = "";
1549
- for (const line of lines) {
1550
- if (!/^\s/.test(line) && line.includes("/") && !ESLINT_ERROR_LINE.test(line)) {
1551
- currentFile = line.trim();
1552
- continue;
1553
- }
1554
- const match = ESLINT_ERROR_LINE.exec(line);
1555
- if (match) diagnostics.push({
1556
- file: currentFile || "unknown",
1557
- line: match[1] ?? "",
1558
- column: match[2] ?? "",
1559
- severity: match[3] ?? "",
1560
- message: (match[4] ?? "").trim(),
1561
- rule: match[5] ?? ""
1562
- });
1563
- }
1564
- return diagnostics;
1565
- }
1566
- function extractTestFailures(raw) {
1567
- const failures = [];
1568
- const seen = /* @__PURE__ */ new Set();
1569
- for (const line of raw.split("\n")) {
1570
- const match = TEST_FILE_FAIL.exec(line);
1571
- if (!match) continue;
1572
- const file = (match[1] ?? "").trim();
1573
- const name = (match[2] ?? "").trim();
1574
- if (!file || !name) continue;
1575
- const key = `${file}\u0000${name}`;
1576
- if (seen.has(key)) continue;
1577
- seen.add(key);
1578
- failures.push({
1579
- file,
1580
- name
1581
- });
1582
- }
1583
- return failures;
1584
- }
1585
- function formatTestFailureSummary(failures, tool) {
1586
- const total = failures.length;
1587
- const visible = failures.slice(0, MAX_TEST_FAILURES);
1588
- const hidden = total - visible.length;
1589
- const fileCount = new Set(failures.map((f) => f.file)).size;
1590
- const testNoun = total === 1 ? "test" : "tests";
1591
- const fileNoun = fileCount === 1 ? "file" : "files";
1592
- const lines = [` ${red("•")} [${tool}] ${total} failed ${testNoun} in ${fileCount} ${fileNoun}`];
1593
- const byFile = /* @__PURE__ */ new Map();
1594
- for (const failure of visible) {
1595
- const names = byFile.get(failure.file) ?? [];
1596
- names.push(failure.name);
1597
- byFile.set(failure.file, names);
1598
- }
1599
- for (const [file, names] of byFile) {
1600
- lines.push(` ${truncate(file, MAX_SUMMARY_LINE_LENGTH)}`);
1601
- for (const name of names) lines.push(` ${red("×")} ${truncate(name, MAX_SUMMARY_LINE_LENGTH)}`);
1602
- }
1603
- if (hidden > 0) lines.push(dim(` +${hidden} more failed ${hidden === 1 ? "test" : "tests"}. View full output for details.`));
1604
- return lines.join("\n");
1605
- }
1606
- function formatEslintSummary(diagnostics) {
1607
- const visible = diagnostics.slice(0, MAX_ESLINT_DIAGNOSTICS);
1608
- const hidden = diagnostics.length - visible.length;
1609
- const count = diagnostics.length;
1610
- const noun = count === 1 ? "problem" : "problems";
1611
- const lines = [` ${red("•")} [eslint] ${count} ESLint ${noun}`, ...visible.map((diagnostic) => `${diagnostic.file}:${diagnostic.line}:${diagnostic.column} ${diagnostic.severity} ${diagnostic.rule} — ${truncate(diagnostic.message, MAX_SUMMARY_LINE_LENGTH)}`)];
1612
- if (hidden > 0) lines.push(dim(` +${hidden} more ESLint ${hidden === 1 ? "problem" : "problems"}. View full output for details.`));
1613
- return lines.join("\n");
1614
- }
1615
- function firstMeaningfulLine(message) {
1616
- return message.split("\n").map((l) => l.trim()).find((l) => l.length > 0 && !l.startsWith(">") && !l.startsWith("ELIFECYCLE")) ?? message;
1617
- }
1618
- function truncate(message, maxLength) {
1619
- const collapsed = message.replace(/\s+/g, " ").trim();
1620
- if (collapsed.length <= maxLength) return collapsed;
1621
- return `${collapsed.slice(0, Math.max(0, maxLength - 1))}…`;
1622
- }
1623
- async function showCheckFailureMenu(errors, rawStderr, onRetry) {
1624
- debug("showCheckFailureMenu: %d errors", errors.length);
1625
- let clipboardCopied = false;
1626
- p.note(formatCheckFailureSummary(errors), red("Pre-commit check failed"));
1627
- while (true) {
1628
- const choice = await p.select({
1629
- message: "What do you want to do?",
1630
- options: [
1631
- {
1632
- label: clipboardCopied ? `${green("✓")} Copy error report to clipboard` : "Copy error report to clipboard",
1633
- value: "copy"
1634
- },
1635
- {
1636
- label: "View full error output",
1637
- value: "view",
1638
- hint: "Show the raw stderr from checks"
1639
- },
1640
- {
1641
- label: "Retry checks",
1642
- value: "retry",
1643
- hint: "Re-run checks after fixing errors"
1644
- },
1645
- {
1646
- label: "Skip checks and commit",
1647
- value: "skip"
1648
- },
1649
- {
1650
- label: "Cancel",
1651
- value: "cancel"
1652
- }
1653
- ]
1654
- });
1655
- if (p.isCancel(choice)) {
1656
- debug("showCheckFailureMenu: user cancelled");
1657
- return "cancelled";
1658
- }
1659
- debug("showCheckFailureMenu: user chose %s", choice);
1660
- switch (choice) {
1661
- case "copy":
1662
- if (await copyToClipboard(rawStderr)) {
1663
- clipboardCopied = true;
1664
- p.log.step(green("Copied to clipboard."));
1665
- } else p.log.warn(red("No clipboard tool found. Install xclip, wl-copy, or xsel."));
1666
- continue;
1667
- case "view":
1668
- p.note(rawStderr.trim() || "(no raw output)", "Full error output");
1669
- continue;
1670
- case "retry":
1671
- if (onRetry) return "retried";
1672
- return "retried";
1673
- case "skip":
1674
- p.log.info("Skipping checks and proceeding with commit...");
1675
- return "skipped";
1676
- case "cancel":
1677
- p.outro(dim("Cancelled."));
1678
- return "cancelled";
1679
- }
1680
- }
1681
- }
1682
- //#endregion
1683
- //#region src/ui/check-summary.ts
1684
- /**
1685
- * Stop a check spinner with a per-tool summary of the check results.
1686
- *
1687
- * - On success: stops with "All checks passed" and prints a `✓ tool` line
1688
- * for each result.
1689
- * - On failure: stops with "N checks failed" (pluralized). Raw error output
1690
- * is intentionally NOT printed here — callers handle failure display
1691
- * (menu, raw print, etc.).
1692
- */
1693
- function stopCheckSpinner(spinner, results) {
1694
- if (results.ok) {
1695
- spinner.stop("All checks passed");
1696
- if (results.results.length > 0) log.info(results.results.map((r) => ` ${green("✓")} ${r.tool}`).join("\n"));
1697
- } else {
1698
- const failed = results.results.filter((r) => !r.ok);
1699
- spinner.stop(`${failed.length} check${failed.length !== 1 ? "s" : ""} failed`);
1700
- }
1701
- }
1702
- //#endregion
1703
- //#region src/ui/grouping.ts
1704
- async function showGroupingConfirmation(groups, excluded) {
1705
- debug("showGroupingConfirmation: %d groups, %d excluded", groups.length, excluded.length);
1706
- const lines = [];
1707
- for (const group of groups) {
1708
- lines.push(bold(group.name));
1709
- lines.push(` ${dim(group.description)}`);
1710
- lines.push(` ${green(String(group.files.length))} file${group.files.length !== 1 ? "s" : ""}`);
1711
- for (const file of group.files) lines.push(` ${dim("•")} ${file}`);
1712
- lines.push("");
1713
- }
1714
- if (excluded.length > 0) {
1715
- lines.push(dim(`Excluded: ${excluded.length} file${excluded.length !== 1 ? "s" : ""}`));
1716
- for (const file of excluded) lines.push(` ${dim("•")} ${dim(file)}`);
1717
- }
1718
- p.note(lines.join("\n"), "Proposed commit groups");
1719
- const choice = await p.select({
1720
- message: "Proceed with these groupings?",
1721
- options: [{
1722
- label: "Yes, commit all groups",
1723
- value: "yes"
1724
- }, {
1725
- label: "No, cancel",
1726
- value: "no"
1727
- }]
1728
- });
1729
- if (p.isCancel(choice) || choice === "no") {
1730
- debug("showGroupingConfirmation: user cancelled");
1731
- return false;
1732
- }
1733
- debug("showGroupingConfirmation: user confirmed");
1734
- return true;
1735
- }
1736
- function showGroupProgress(current, total, groupName) {
1737
- p.log.info(`Commit group ${current} of ${total}: ${cyan(`"${groupName}"`)}`);
1738
- }
1739
- const statusLabel = (status) => {
1740
- switch (status) {
1741
- case "M": return yellow("M");
1742
- case "A": return green("A");
1743
- case "D": return red("D");
1744
- case "?":
1745
- case "??": return cyan("?");
1746
- default: return dim(status);
1747
- }
1748
- };
1749
- /** Display combined view: files with status indicators grouped by commit group */
1750
- function showGroupedFiles(groups, changedFiles) {
1751
- const statusMap = new Map(changedFiles.map((f) => [f.path, f.status]));
1752
- const lines = [];
1753
- for (let i = 0; i < groups.length; i++) {
1754
- const group = groups[i];
1755
- lines.push(`${bold(group.name)} ${dim("—")} ${group.files.length} file${group.files.length !== 1 ? "s" : ""}`);
1756
- for (const file of group.files) {
1757
- const status = statusMap.get(file) ?? "M";
1758
- lines.push(` ${statusLabel(status)} ${file}`);
1759
- }
1760
- if (i < groups.length - 1) lines.push("");
1761
- }
1762
- p.note(lines.join("\n"), "Commit groups");
1758
+ exitCode = code;
1759
+ });
1760
+ child.stdin.write(content, (err) => {
1761
+ if (err) {
1762
+ done(false, "stdin write error");
1763
+ return;
1764
+ }
1765
+ child.stdin.end(() => {
1766
+ setTimeout(() => handleGracePeriod(settled, exitCode, stderrChunks, child, done), GRACE_PERIOD_MS);
1767
+ });
1768
+ });
1769
+ });
1763
1770
  }
1764
1771
  //#endregion
1765
1772
  //#region src/ui/recovery-menu.ts
@@ -1939,6 +1946,259 @@ async function reviewCommitMessage(message, options) {
1939
1946
  }
1940
1947
  }
1941
1948
  //#endregion
1949
+ //#region src/ui/check-failure-menu.ts
1950
+ const MAX_TSC_DIAGNOSTICS = 3;
1951
+ const MAX_ESLINT_DIAGNOSTICS = 3;
1952
+ const MAX_TEST_FAILURES = 3;
1953
+ const MAX_SUMMARY_LINE_LENGTH = 120;
1954
+ const TSC_DIAGNOSTIC = /^(.+?\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs))\((\d+),(\d+)\):\s+error\s+(TS\d+):\s+(.+)$/;
1955
+ const ESLINT_ERROR_LINE = /^\s*(\d+):(\d+)\s+(error|warning)\s+(.+)\s{2,}(\S+)\s*$/;
1956
+ const TEST_FILE_FAIL = /^\s*FAIL\s+(.+?\.(?:test|spec)\.[^\s>]+)\s*>\s*(.+)$/;
1957
+ function formatCheckFailureSummary(errors) {
1958
+ if (errors.length === 0) return "No check error details were parsed. View full output for details.";
1959
+ return errors.map((error) => formatCheckErrorSummary(error)).join("\n");
1960
+ }
1961
+ function formatCheckErrorSummary(error) {
1962
+ if (error.tool === "tsc") {
1963
+ const diagnostics = extractTscDiagnostics(error.raw || error.message);
1964
+ if (diagnostics.length > 0) return formatTscSummary(diagnostics);
1965
+ }
1966
+ if (error.tool === "eslint") {
1967
+ const diagnostics = extractEslintDiagnostics(error.raw || error.message);
1968
+ if (diagnostics.length > 0) return formatEslintSummary(diagnostics);
1969
+ }
1970
+ if (error.tool === "vitest" || error.tool === "jest") {
1971
+ const failures = extractTestFailures(error.raw || error.message);
1972
+ if (failures.length > 0) return formatTestFailureSummary(failures, error.tool);
1973
+ }
1974
+ const message = firstMeaningfulLine(error.message || error.raw);
1975
+ return ` ${red("•")} [${error.tool}] ${truncate(message, MAX_SUMMARY_LINE_LENGTH)}`;
1976
+ }
1977
+ function extractTscDiagnostics(raw) {
1978
+ return raw.split("\n").map((line) => line.trim()).map((line) => {
1979
+ const match = TSC_DIAGNOSTIC.exec(line);
1980
+ if (!match) return null;
1981
+ return {
1982
+ file: match[1] ?? "",
1983
+ line: match[2] ?? "",
1984
+ column: match[3] ?? "",
1985
+ code: match[4] ?? "",
1986
+ message: match[5] ?? ""
1987
+ };
1988
+ }).filter((diagnostic) => diagnostic !== null);
1989
+ }
1990
+ function formatTscSummary(diagnostics) {
1991
+ const visible = diagnostics.slice(0, MAX_TSC_DIAGNOSTICS);
1992
+ const hidden = diagnostics.length - visible.length;
1993
+ const lines = [` ${red("•")} [tsc] ${diagnostics.length} TypeScript error${diagnostics.length !== 1 ? "s" : ""}`, ...visible.map((diagnostic) => `${diagnostic.file}:${diagnostic.line}:${diagnostic.column} — error ${diagnostic.code}: ${truncate(diagnostic.message, MAX_SUMMARY_LINE_LENGTH)}`)];
1994
+ if (hidden > 0) lines.push(dim(` +${hidden} more TypeScript error${hidden !== 1 ? "s" : ""}. View full output for details.`));
1995
+ return lines.join("\n");
1996
+ }
1997
+ function extractEslintDiagnostics(raw) {
1998
+ const diagnostics = [];
1999
+ const lines = raw.split("\n");
2000
+ let currentFile = "";
2001
+ for (const line of lines) {
2002
+ if (!/^\s/.test(line) && line.includes("/") && !ESLINT_ERROR_LINE.test(line)) {
2003
+ currentFile = line.trim();
2004
+ continue;
2005
+ }
2006
+ const match = ESLINT_ERROR_LINE.exec(line);
2007
+ if (match) diagnostics.push({
2008
+ file: currentFile || "unknown",
2009
+ line: match[1] ?? "",
2010
+ column: match[2] ?? "",
2011
+ severity: match[3] ?? "",
2012
+ message: (match[4] ?? "").trim(),
2013
+ rule: match[5] ?? ""
2014
+ });
2015
+ }
2016
+ return diagnostics;
2017
+ }
2018
+ function extractTestFailures(raw) {
2019
+ const failures = [];
2020
+ const seen = /* @__PURE__ */ new Set();
2021
+ for (const line of raw.split("\n")) {
2022
+ const match = TEST_FILE_FAIL.exec(line);
2023
+ if (!match) continue;
2024
+ const file = (match[1] ?? "").trim();
2025
+ const name = (match[2] ?? "").trim();
2026
+ if (!file || !name) continue;
2027
+ const key = `${file}\u0000${name}`;
2028
+ if (seen.has(key)) continue;
2029
+ seen.add(key);
2030
+ failures.push({
2031
+ file,
2032
+ name
2033
+ });
2034
+ }
2035
+ return failures;
2036
+ }
2037
+ function formatTestFailureSummary(failures, tool) {
2038
+ const total = failures.length;
2039
+ const visible = failures.slice(0, MAX_TEST_FAILURES);
2040
+ const hidden = total - visible.length;
2041
+ const fileCount = new Set(failures.map((f) => f.file)).size;
2042
+ const testNoun = total === 1 ? "test" : "tests";
2043
+ const fileNoun = fileCount === 1 ? "file" : "files";
2044
+ const lines = [` ${red("•")} [${tool}] ${total} failed ${testNoun} in ${fileCount} ${fileNoun}`];
2045
+ const byFile = /* @__PURE__ */ new Map();
2046
+ for (const failure of visible) {
2047
+ const names = byFile.get(failure.file) ?? [];
2048
+ names.push(failure.name);
2049
+ byFile.set(failure.file, names);
2050
+ }
2051
+ for (const [file, names] of byFile) {
2052
+ lines.push(` ${truncate(file, MAX_SUMMARY_LINE_LENGTH)}`);
2053
+ for (const name of names) lines.push(` ${red("×")} ${truncate(name, MAX_SUMMARY_LINE_LENGTH)}`);
2054
+ }
2055
+ if (hidden > 0) lines.push(dim(` +${hidden} more failed ${hidden === 1 ? "test" : "tests"}. View full output for details.`));
2056
+ return lines.join("\n");
2057
+ }
2058
+ function formatEslintSummary(diagnostics) {
2059
+ const visible = diagnostics.slice(0, MAX_ESLINT_DIAGNOSTICS);
2060
+ const hidden = diagnostics.length - visible.length;
2061
+ const count = diagnostics.length;
2062
+ const noun = count === 1 ? "problem" : "problems";
2063
+ const lines = [` ${red("•")} [eslint] ${count} ESLint ${noun}`, ...visible.map((diagnostic) => `${diagnostic.file}:${diagnostic.line}:${diagnostic.column} ${diagnostic.severity} ${diagnostic.rule} — ${truncate(diagnostic.message, MAX_SUMMARY_LINE_LENGTH)}`)];
2064
+ if (hidden > 0) lines.push(dim(` +${hidden} more ESLint ${hidden === 1 ? "problem" : "problems"}. View full output for details.`));
2065
+ return lines.join("\n");
2066
+ }
2067
+ function firstMeaningfulLine(message) {
2068
+ return message.split("\n").map((l) => l.trim()).find((l) => l.length > 0 && !l.startsWith(">") && !l.startsWith("ELIFECYCLE")) ?? message;
2069
+ }
2070
+ function truncate(message, maxLength) {
2071
+ const collapsed = message.replace(/\s+/g, " ").trim();
2072
+ if (collapsed.length <= maxLength) return collapsed;
2073
+ return `${collapsed.slice(0, Math.max(0, maxLength - 1))}…`;
2074
+ }
2075
+ async function showCheckFailureMenu(errors, rawStderr, onRetry) {
2076
+ debug("showCheckFailureMenu: %d errors", errors.length);
2077
+ let clipboardCopied = false;
2078
+ p.note(formatCheckFailureSummary(errors), red("Pre-commit check failed"));
2079
+ while (true) {
2080
+ const choice = await p.select({
2081
+ message: "What do you want to do?",
2082
+ options: [
2083
+ {
2084
+ label: clipboardCopied ? `${green("✓")} Copy error report to clipboard` : "Copy error report to clipboard",
2085
+ value: "copy"
2086
+ },
2087
+ {
2088
+ label: "View full error output",
2089
+ value: "view",
2090
+ hint: "Show the raw stderr from checks"
2091
+ },
2092
+ {
2093
+ label: "Retry checks",
2094
+ value: "retry",
2095
+ hint: "Re-run checks after fixing errors"
2096
+ },
2097
+ {
2098
+ label: "Skip checks and commit",
2099
+ value: "skip"
2100
+ },
2101
+ {
2102
+ label: "Cancel",
2103
+ value: "cancel"
2104
+ }
2105
+ ]
2106
+ });
2107
+ if (p.isCancel(choice)) {
2108
+ debug("showCheckFailureMenu: user cancelled");
2109
+ return "cancelled";
2110
+ }
2111
+ debug("showCheckFailureMenu: user chose %s", choice);
2112
+ switch (choice) {
2113
+ case "copy":
2114
+ if (await copyToClipboard(rawStderr)) {
2115
+ clipboardCopied = true;
2116
+ p.log.step(green("Copied to clipboard."));
2117
+ } else p.log.warn(red("No clipboard tool found. Install xclip, wl-copy, or xsel."));
2118
+ continue;
2119
+ case "view":
2120
+ p.note(rawStderr.trim() || "(no raw output)", "Full error output");
2121
+ continue;
2122
+ case "retry":
2123
+ if (onRetry) return "retried";
2124
+ return "retried";
2125
+ case "skip":
2126
+ p.log.info("Skipping checks and proceeding with commit...");
2127
+ return "skipped";
2128
+ case "cancel":
2129
+ p.outro(dim("Cancelled."));
2130
+ return "cancelled";
2131
+ }
2132
+ }
2133
+ }
2134
+ //#endregion
2135
+ //#region src/ui/check-summary.ts
2136
+ /**
2137
+ * Stop a check spinner with a per-tool summary of the check results.
2138
+ *
2139
+ * - On success: stops with "All checks passed" and prints a `✓ tool` line
2140
+ * for each result.
2141
+ * - On failure: stops with "N checks failed" (pluralized). Raw error output
2142
+ * is intentionally NOT printed here — callers handle failure display
2143
+ * (menu, raw print, etc.).
2144
+ */
2145
+ function stopCheckSpinner(spinner, results) {
2146
+ if (results.ok) {
2147
+ spinner.stop("All checks passed");
2148
+ if (results.results.length > 0) log.info(results.results.map((r) => ` ${green("✓")} ${r.tool}`).join("\n"));
2149
+ } else {
2150
+ const failed = results.results.filter((r) => !r.ok);
2151
+ spinner.stop(`${failed.length} check${failed.length !== 1 ? "s" : ""} failed`);
2152
+ }
2153
+ }
2154
+ //#endregion
2155
+ //#region src/commands/check-phase.ts
2156
+ /**
2157
+ * Run user-defined pre-commit checks with an interactive failure menu.
2158
+ *
2159
+ * Single entry point for the check-execution pipeline shared by `runPreCommitChecks`
2160
+ * (post-staging, normal commit flow) and `runAutoGroupFlow` (pre-staging, auto-group
2161
+ * flow). Encapsulates: detectConfig guard → spinner → runAllChecks → retry loop with
2162
+ * `showCheckFailureMenu`.
2163
+ *
2164
+ * Caller responsibilities:
2165
+ * - Skip when `noCheck` is set (caller's policy).
2166
+ * - Skip when there are no files to check (caller has the file list context).
2167
+ * - Derive `files` in **repo-root-relative** form so they match `.cmintrc` globs.
2168
+ * Post-staging callers should use `getStagedFiles()`; pre-staging callers should
2169
+ * use `resolveToRepoRoot()` since the index doesn't yet contain those paths.
2170
+ *
2171
+ * Returns the outcome so the caller can decide how to handle cancellation
2172
+ * (`process.exit(1)` in the commit flow, `return "cancelled"` in auto-group).
2173
+ */
2174
+ async function runCheckPhaseInteractive(repoRoot, files, timeout, onRetry) {
2175
+ if (!await detectConfig(repoRoot)) return "passed";
2176
+ debug("Running user checks on %d files...", files.length);
2177
+ const ck = spinner();
2178
+ ck.start("Running checks...");
2179
+ let checkResults = await runAllChecks(repoRoot, files, timeout);
2180
+ stopCheckSpinner(ck, checkResults);
2181
+ debug("Check results: ok=%s, count=%d", checkResults.ok, checkResults.results.length);
2182
+ while (!checkResults.ok) {
2183
+ const rawOutput = checkResults.results.filter((r) => !r.ok).map((r) => `[${r.tool}]\n${r.stdout}\n${r.stderr}`.trim()).join("\n\n");
2184
+ const menuResult = await showCheckFailureMenu(parseCheckErrors(rawOutput), rawOutput, async () => {
2185
+ return (await runAllChecks(repoRoot, files, timeout)).ok;
2186
+ });
2187
+ if (menuResult === "cancelled") return "cancelled";
2188
+ if (menuResult === "retried") {
2189
+ debug("Re-running checks after retry...");
2190
+ if (onRetry) await onRetry();
2191
+ ck.start("Running checks...");
2192
+ checkResults = await runAllChecks(repoRoot, files, timeout);
2193
+ stopCheckSpinner(ck, checkResults);
2194
+ debug("Retry check results: ok=%s, count=%d", checkResults.ok, checkResults.results.length);
2195
+ continue;
2196
+ }
2197
+ break;
2198
+ }
2199
+ return checkResults.ok ? "passed" : "skipped";
2200
+ }
2201
+ //#endregion
1942
2202
  //#region src/commands/auto-group.ts
1943
2203
  async function runAutoGroupFlow(changedFiles, flags) {
1944
2204
  const { included, excluded } = filterExcludedFiles(changedFiles);
@@ -1967,33 +2227,7 @@ async function runAutoGroupFlow(changedFiles, flags) {
1967
2227
  }
1968
2228
  if (!flags.noCheck) {
1969
2229
  const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
1970
- const repoRoot = await getRepoRoot();
1971
- const allFiles = included.filter((f) => f.status !== "D").map((f) => f.path);
1972
- if (await detectConfig(repoRoot)) {
1973
- debug("Running user checks on %d files...", allFiles.length);
1974
- const ck = spinner();
1975
- ck.start("Running checks...");
1976
- let checkResults = await runAllChecks(repoRoot, allFiles, 6e4);
1977
- debug("Check results: ok=%s, count=%d", checkResults.ok, checkResults.results.length);
1978
- while (!checkResults.ok) {
1979
- const failed = checkResults.results.filter((r) => !r.ok);
1980
- ck.stop(`${failed.length} check(s) failed`);
1981
- const rawOutput = failed.map((r) => `[${r.tool}]\n${r.stdout}\n${r.stderr}`.trim()).join("\n\n");
1982
- const menuResult = await showCheckFailureMenu(parseCheckErrors(rawOutput), rawOutput, async () => {
1983
- return (await runAllChecks(repoRoot, allFiles, 6e4)).ok;
1984
- });
1985
- if (menuResult === "cancelled") return "cancelled";
1986
- if (menuResult === "retried") {
1987
- debug("Re-running checks after retry...");
1988
- ck.start("Running checks...");
1989
- checkResults = await runAllChecks(repoRoot, allFiles, 6e4);
1990
- debug("Retry check results: ok=%s, count=%d", checkResults.ok, checkResults.results.length);
1991
- continue;
1992
- }
1993
- break;
1994
- }
1995
- if (checkResults.ok) stopCheckSpinner(ck, checkResults);
1996
- }
2230
+ if (await runCheckPhaseInteractive(await getRepoRoot(), await resolveToRepoRoot(included.filter((f) => f.status !== "D").map((f) => f.path)), 6e4) === "cancelled") return "cancelled";
1997
2231
  }
1998
2232
  const config = await readConfig();
1999
2233
  const resolvedProvider = config.provider ?? "groq";
@@ -2231,7 +2465,7 @@ async function agentCommand(flags) {
2231
2465
  const repoRoot = await getRepoRoot();
2232
2466
  if (await detectConfig(repoRoot)) {
2233
2467
  debug("Running user checks on changed files...");
2234
- const checkResults = await runAllChecks(repoRoot, changedFiles.filter((f) => f.status !== "D").map((f) => f.path), 6e4);
2468
+ const checkResults = await runAllChecks(repoRoot, await resolveToRepoRoot(changedFiles.filter((f) => f.status !== "D").map((f) => f.path)), 6e4);
2235
2469
  if (!checkResults.ok) {
2236
2470
  const errorMessages = checkResults.results.filter((r) => !r.ok).map((r) => `[${r.tool}]\n${r.stdout}\n${r.stderr}`.trim()).filter(Boolean);
2237
2471
  const parsed = parseCheckErrors(errorMessages.join("\n\n"));
@@ -3036,7 +3270,7 @@ async function handleStaging(changedFiles, flags) {
3036
3270
  }
3037
3271
  if (stagingResult === "checks") {
3038
3272
  await stageAll();
3039
- const allFiles = currentFiles.filter((f) => f.status !== "D").map((f) => f.path);
3273
+ const allFiles = await getStagedFiles();
3040
3274
  if (await detectConfig(repoRoot)) {
3041
3275
  const ckSpinner = spinner();
3042
3276
  ckSpinner.start("Running checks...");
@@ -3075,33 +3309,13 @@ async function handleStaging(changedFiles, flags) {
3075
3309
  async function runPreCommitChecks(changedFiles, noCheck) {
3076
3310
  if (noCheck) return;
3077
3311
  const checkRoot = await getRepoRoot();
3078
- const stagedFileList = changedFiles.filter((f) => f.staged && f.status !== "D").map((f) => f.path);
3312
+ const stagedFileList = await getStagedFiles();
3079
3313
  if (stagedFileList.length === 0) return;
3080
- debug("Running user checks on %d staged files...", stagedFileList.length);
3081
- const ckSpinner = spinner();
3082
- ckSpinner.start("Running checks...");
3083
- let checkResults = await runAllChecks(checkRoot, stagedFileList, 6e4);
3084
- stopCheckSpinner(ckSpinner, checkResults);
3085
- debug("Check results: ok=%s, count=%d", checkResults.ok, checkResults.results.length);
3086
- while (!checkResults.ok) {
3087
- const rawOutput = checkResults.results.filter((r) => !r.ok).map((r) => `[${r.tool}]\n${r.stdout}\n${r.stderr}`.trim()).join("\n\n");
3088
- const menuResult = await showCheckFailureMenu(parseCheckErrors(rawOutput), rawOutput, async () => {
3089
- return (await runAllChecks(checkRoot, stagedFileList, 6e4)).ok;
3090
- });
3091
- if (menuResult === "cancelled") process.exit(1);
3092
- if (menuResult === "retried") {
3093
- debug("Re-staging files and re-running checks after retry...");
3094
- await stageAll();
3095
- const ckSpinner = spinner();
3096
- ckSpinner.start("Running checks...");
3097
- checkResults = await runAllChecks(checkRoot, stagedFileList, 6e4);
3098
- debug("Retry check results: ok=%s, count=%d", checkResults.ok, checkResults.results.length);
3099
- stopCheckSpinner(ckSpinner, checkResults);
3100
- continue;
3101
- }
3102
- break;
3103
- }
3104
- await restageFormatterModifications(stagedFileList);
3314
+ if (await runCheckPhaseInteractive(checkRoot, stagedFileList, 6e4, async () => {
3315
+ debug("Re-staging files before retry...");
3316
+ await stageAll();
3317
+ }) === "cancelled") process.exit(1);
3318
+ await restageFormatterModifications(changedFiles.filter((f) => f.staged && f.status !== "D").map((f) => f.path));
3105
3319
  }
3106
3320
  /**
3107
3321
  * Re-stage staged files whose working-tree content diverged from the index after checks ran.