@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/README.md +25 -25
- package/dist/cli.mjs +556 -342
- package/dist/cli.mjs.map +1 -1
- package/package.json +1 -1
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.
|
|
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 ? {
|
|
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", [
|
|
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
|
-
"-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
3312
|
+
const stagedFileList = await getStagedFiles();
|
|
3079
3313
|
if (stagedFileList.length === 0) return;
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
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.
|