@kyubiware/commit-mint 0.8.0 → 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.8.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 ? { 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
  });
@@ -1043,6 +1048,44 @@ async function getChangedFiles() {
1043
1048
  debug("getChangedFiles:", files.length, "files");
1044
1049
  return files;
1045
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
+ }
1046
1089
  async function stageFiles(paths) {
1047
1090
  debug("stageFiles:", paths);
1048
1091
  await execa("git", ["add", ...paths]);
@@ -1586,6 +1629,68 @@ async function loadCachedCommit(repoPath) {
1586
1629
  }
1587
1630
  }
1588
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
1589
1694
  //#region src/services/clipboard.ts
1590
1695
  /** Milliseconds to wait after stdin closes for quick exit failures. */
1591
1696
  const GRACE_PERIOD_MS = 150;
@@ -1650,286 +1755,18 @@ function tryCopy(cmd, args, content) {
1650
1755
  });
1651
1756
  let exitCode = null;
1652
1757
  child.on("exit", (code) => {
1653
- exitCode = code;
1654
- });
1655
- child.stdin.write(content, (err) => {
1656
- if (err) {
1657
- done(false, "stdin write error");
1658
- return;
1659
- }
1660
- child.stdin.end(() => {
1661
- setTimeout(() => handleGracePeriod(settled, exitCode, stderrChunks, child, done), GRACE_PERIOD_MS);
1662
- });
1663
- });
1664
- });
1665
- }
1666
- //#endregion
1667
- //#region src/ui/check-failure-menu.ts
1668
- const MAX_TSC_DIAGNOSTICS = 3;
1669
- const MAX_ESLINT_DIAGNOSTICS = 3;
1670
- const MAX_TEST_FAILURES = 3;
1671
- const MAX_SUMMARY_LINE_LENGTH = 120;
1672
- const TSC_DIAGNOSTIC = /^(.+?\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs))\((\d+),(\d+)\):\s+error\s+(TS\d+):\s+(.+)$/;
1673
- const ESLINT_ERROR_LINE = /^\s*(\d+):(\d+)\s+(error|warning)\s+(.+)\s{2,}(\S+)\s*$/;
1674
- const TEST_FILE_FAIL = /^\s*FAIL\s+(.+?\.(?:test|spec)\.[^\s>]+)\s*>\s*(.+)$/;
1675
- function formatCheckFailureSummary(errors) {
1676
- if (errors.length === 0) return "No check error details were parsed. View full output for details.";
1677
- return errors.map((error) => formatCheckErrorSummary(error)).join("\n");
1678
- }
1679
- function formatCheckErrorSummary(error) {
1680
- if (error.tool === "tsc") {
1681
- const diagnostics = extractTscDiagnostics(error.raw || error.message);
1682
- if (diagnostics.length > 0) return formatTscSummary(diagnostics);
1683
- }
1684
- if (error.tool === "eslint") {
1685
- const diagnostics = extractEslintDiagnostics(error.raw || error.message);
1686
- if (diagnostics.length > 0) return formatEslintSummary(diagnostics);
1687
- }
1688
- if (error.tool === "vitest" || error.tool === "jest") {
1689
- const failures = extractTestFailures(error.raw || error.message);
1690
- if (failures.length > 0) return formatTestFailureSummary(failures, error.tool);
1691
- }
1692
- const message = firstMeaningfulLine(error.message || error.raw);
1693
- return ` ${red("•")} [${error.tool}] ${truncate(message, MAX_SUMMARY_LINE_LENGTH)}`;
1694
- }
1695
- function extractTscDiagnostics(raw) {
1696
- return raw.split("\n").map((line) => line.trim()).map((line) => {
1697
- const match = TSC_DIAGNOSTIC.exec(line);
1698
- if (!match) return null;
1699
- return {
1700
- file: match[1] ?? "",
1701
- line: match[2] ?? "",
1702
- column: match[3] ?? "",
1703
- code: match[4] ?? "",
1704
- message: match[5] ?? ""
1705
- };
1706
- }).filter((diagnostic) => diagnostic !== null);
1707
- }
1708
- function formatTscSummary(diagnostics) {
1709
- const visible = diagnostics.slice(0, MAX_TSC_DIAGNOSTICS);
1710
- const hidden = diagnostics.length - visible.length;
1711
- 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)}`)];
1712
- if (hidden > 0) lines.push(dim(` +${hidden} more TypeScript error${hidden !== 1 ? "s" : ""}. View full output for details.`));
1713
- return lines.join("\n");
1714
- }
1715
- function extractEslintDiagnostics(raw) {
1716
- const diagnostics = [];
1717
- const lines = raw.split("\n");
1718
- let currentFile = "";
1719
- for (const line of lines) {
1720
- if (!/^\s/.test(line) && line.includes("/") && !ESLINT_ERROR_LINE.test(line)) {
1721
- currentFile = line.trim();
1722
- continue;
1723
- }
1724
- const match = ESLINT_ERROR_LINE.exec(line);
1725
- if (match) diagnostics.push({
1726
- file: currentFile || "unknown",
1727
- line: match[1] ?? "",
1728
- column: match[2] ?? "",
1729
- severity: match[3] ?? "",
1730
- message: (match[4] ?? "").trim(),
1731
- rule: match[5] ?? ""
1732
- });
1733
- }
1734
- return diagnostics;
1735
- }
1736
- function extractTestFailures(raw) {
1737
- const failures = [];
1738
- const seen = /* @__PURE__ */ new Set();
1739
- for (const line of raw.split("\n")) {
1740
- const match = TEST_FILE_FAIL.exec(line);
1741
- if (!match) continue;
1742
- const file = (match[1] ?? "").trim();
1743
- const name = (match[2] ?? "").trim();
1744
- if (!file || !name) continue;
1745
- const key = `${file}\u0000${name}`;
1746
- if (seen.has(key)) continue;
1747
- seen.add(key);
1748
- failures.push({
1749
- file,
1750
- name
1751
- });
1752
- }
1753
- return failures;
1754
- }
1755
- function formatTestFailureSummary(failures, tool) {
1756
- const total = failures.length;
1757
- const visible = failures.slice(0, MAX_TEST_FAILURES);
1758
- const hidden = total - visible.length;
1759
- const fileCount = new Set(failures.map((f) => f.file)).size;
1760
- const testNoun = total === 1 ? "test" : "tests";
1761
- const fileNoun = fileCount === 1 ? "file" : "files";
1762
- const lines = [` ${red("•")} [${tool}] ${total} failed ${testNoun} in ${fileCount} ${fileNoun}`];
1763
- const byFile = /* @__PURE__ */ new Map();
1764
- for (const failure of visible) {
1765
- const names = byFile.get(failure.file) ?? [];
1766
- names.push(failure.name);
1767
- byFile.set(failure.file, names);
1768
- }
1769
- for (const [file, names] of byFile) {
1770
- lines.push(` ${truncate(file, MAX_SUMMARY_LINE_LENGTH)}`);
1771
- for (const name of names) lines.push(` ${red("×")} ${truncate(name, MAX_SUMMARY_LINE_LENGTH)}`);
1772
- }
1773
- if (hidden > 0) lines.push(dim(` +${hidden} more failed ${hidden === 1 ? "test" : "tests"}. View full output for details.`));
1774
- return lines.join("\n");
1775
- }
1776
- function formatEslintSummary(diagnostics) {
1777
- const visible = diagnostics.slice(0, MAX_ESLINT_DIAGNOSTICS);
1778
- const hidden = diagnostics.length - visible.length;
1779
- const count = diagnostics.length;
1780
- const noun = count === 1 ? "problem" : "problems";
1781
- 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)}`)];
1782
- if (hidden > 0) lines.push(dim(` +${hidden} more ESLint ${hidden === 1 ? "problem" : "problems"}. View full output for details.`));
1783
- return lines.join("\n");
1784
- }
1785
- function firstMeaningfulLine(message) {
1786
- return message.split("\n").map((l) => l.trim()).find((l) => l.length > 0 && !l.startsWith(">") && !l.startsWith("ELIFECYCLE")) ?? message;
1787
- }
1788
- function truncate(message, maxLength) {
1789
- const collapsed = message.replace(/\s+/g, " ").trim();
1790
- if (collapsed.length <= maxLength) return collapsed;
1791
- return `${collapsed.slice(0, Math.max(0, maxLength - 1))}…`;
1792
- }
1793
- async function showCheckFailureMenu(errors, rawStderr, onRetry) {
1794
- debug("showCheckFailureMenu: %d errors", errors.length);
1795
- let clipboardCopied = false;
1796
- p.note(formatCheckFailureSummary(errors), red("Pre-commit check failed"));
1797
- while (true) {
1798
- const choice = await p.select({
1799
- message: "What do you want to do?",
1800
- options: [
1801
- {
1802
- label: clipboardCopied ? `${green("✓")} Copy error report to clipboard` : "Copy error report to clipboard",
1803
- value: "copy"
1804
- },
1805
- {
1806
- label: "View full error output",
1807
- value: "view",
1808
- hint: "Show the raw stderr from checks"
1809
- },
1810
- {
1811
- label: "Retry checks",
1812
- value: "retry",
1813
- hint: "Re-run checks after fixing errors"
1814
- },
1815
- {
1816
- label: "Skip checks and commit",
1817
- value: "skip"
1818
- },
1819
- {
1820
- label: "Cancel",
1821
- value: "cancel"
1822
- }
1823
- ]
1824
- });
1825
- if (p.isCancel(choice)) {
1826
- debug("showCheckFailureMenu: user cancelled");
1827
- return "cancelled";
1828
- }
1829
- debug("showCheckFailureMenu: user chose %s", choice);
1830
- switch (choice) {
1831
- case "copy":
1832
- if (await copyToClipboard(rawStderr)) {
1833
- clipboardCopied = true;
1834
- p.log.step(green("Copied to clipboard."));
1835
- } else p.log.warn(red("No clipboard tool found. Install xclip, wl-copy, or xsel."));
1836
- continue;
1837
- case "view":
1838
- p.note(rawStderr.trim() || "(no raw output)", "Full error output");
1839
- continue;
1840
- case "retry":
1841
- if (onRetry) return "retried";
1842
- return "retried";
1843
- case "skip":
1844
- p.log.info("Skipping checks and proceeding with commit...");
1845
- return "skipped";
1846
- case "cancel":
1847
- p.outro(dim("Cancelled."));
1848
- return "cancelled";
1849
- }
1850
- }
1851
- }
1852
- //#endregion
1853
- //#region src/ui/check-summary.ts
1854
- /**
1855
- * Stop a check spinner with a per-tool summary of the check results.
1856
- *
1857
- * - On success: stops with "All checks passed" and prints a `✓ tool` line
1858
- * for each result.
1859
- * - On failure: stops with "N checks failed" (pluralized). Raw error output
1860
- * is intentionally NOT printed here — callers handle failure display
1861
- * (menu, raw print, etc.).
1862
- */
1863
- function stopCheckSpinner(spinner, results) {
1864
- if (results.ok) {
1865
- spinner.stop("All checks passed");
1866
- if (results.results.length > 0) log.info(results.results.map((r) => ` ${green("✓")} ${r.tool}`).join("\n"));
1867
- } else {
1868
- const failed = results.results.filter((r) => !r.ok);
1869
- spinner.stop(`${failed.length} check${failed.length !== 1 ? "s" : ""} failed`);
1870
- }
1871
- }
1872
- //#endregion
1873
- //#region src/ui/grouping.ts
1874
- async function showGroupingConfirmation(groups, excluded) {
1875
- debug("showGroupingConfirmation: %d groups, %d excluded", groups.length, excluded.length);
1876
- const lines = [];
1877
- for (const group of groups) {
1878
- lines.push(bold(group.name));
1879
- lines.push(` ${dim(group.description)}`);
1880
- lines.push(` ${green(String(group.files.length))} file${group.files.length !== 1 ? "s" : ""}`);
1881
- for (const file of group.files) lines.push(` ${dim("•")} ${file}`);
1882
- lines.push("");
1883
- }
1884
- if (excluded.length > 0) {
1885
- lines.push(dim(`Excluded: ${excluded.length} file${excluded.length !== 1 ? "s" : ""}`));
1886
- for (const file of excluded) lines.push(` ${dim("•")} ${dim(file)}`);
1887
- }
1888
- p.note(lines.join("\n"), "Proposed commit groups");
1889
- const choice = await p.select({
1890
- message: "Proceed with these groupings?",
1891
- options: [{
1892
- label: "Yes, commit all groups",
1893
- value: "yes"
1894
- }, {
1895
- label: "No, cancel",
1896
- value: "no"
1897
- }]
1898
- });
1899
- if (p.isCancel(choice) || choice === "no") {
1900
- debug("showGroupingConfirmation: user cancelled");
1901
- return false;
1902
- }
1903
- debug("showGroupingConfirmation: user confirmed");
1904
- return true;
1905
- }
1906
- function showGroupProgress(current, total, groupName) {
1907
- p.log.info(`Commit group ${current} of ${total}: ${cyan(`"${groupName}"`)}`);
1908
- }
1909
- const statusLabel = (status) => {
1910
- switch (status) {
1911
- case "M": return yellow("M");
1912
- case "A": return green("A");
1913
- case "D": return red("D");
1914
- case "?":
1915
- case "??": return cyan("?");
1916
- default: return dim(status);
1917
- }
1918
- };
1919
- /** Display combined view: files with status indicators grouped by commit group */
1920
- function showGroupedFiles(groups, changedFiles) {
1921
- const statusMap = new Map(changedFiles.map((f) => [f.path, f.status]));
1922
- const lines = [];
1923
- for (let i = 0; i < groups.length; i++) {
1924
- const group = groups[i];
1925
- lines.push(`${bold(group.name)} ${dim("—")} ${group.files.length} file${group.files.length !== 1 ? "s" : ""}`);
1926
- for (const file of group.files) {
1927
- const status = statusMap.get(file) ?? "M";
1928
- lines.push(` ${statusLabel(status)} ${file}`);
1929
- }
1930
- if (i < groups.length - 1) lines.push("");
1931
- }
1932
- 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
+ });
1933
1770
  }
1934
1771
  //#endregion
1935
1772
  //#region src/ui/recovery-menu.ts
@@ -2109,6 +1946,259 @@ async function reviewCommitMessage(message, options) {
2109
1946
  }
2110
1947
  }
2111
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
2112
2202
  //#region src/commands/auto-group.ts
2113
2203
  async function runAutoGroupFlow(changedFiles, flags) {
2114
2204
  const { included, excluded } = filterExcludedFiles(changedFiles);
@@ -2137,33 +2227,7 @@ async function runAutoGroupFlow(changedFiles, flags) {
2137
2227
  }
2138
2228
  if (!flags.noCheck) {
2139
2229
  const { getRepoRoot } = await Promise.resolve().then(() => git_exports);
2140
- const repoRoot = await getRepoRoot();
2141
- const allFiles = included.filter((f) => f.status !== "D").map((f) => f.path);
2142
- if (await detectConfig(repoRoot)) {
2143
- debug("Running user checks on %d files...", allFiles.length);
2144
- const ck = spinner();
2145
- ck.start("Running checks...");
2146
- let checkResults = await runAllChecks(repoRoot, allFiles, 6e4);
2147
- debug("Check results: ok=%s, count=%d", checkResults.ok, checkResults.results.length);
2148
- while (!checkResults.ok) {
2149
- const failed = checkResults.results.filter((r) => !r.ok);
2150
- ck.stop(`${failed.length} check(s) failed`);
2151
- const rawOutput = failed.map((r) => `[${r.tool}]\n${r.stdout}\n${r.stderr}`.trim()).join("\n\n");
2152
- const menuResult = await showCheckFailureMenu(parseCheckErrors(rawOutput), rawOutput, async () => {
2153
- return (await runAllChecks(repoRoot, allFiles, 6e4)).ok;
2154
- });
2155
- if (menuResult === "cancelled") return "cancelled";
2156
- if (menuResult === "retried") {
2157
- debug("Re-running checks after retry...");
2158
- ck.start("Running checks...");
2159
- checkResults = await runAllChecks(repoRoot, allFiles, 6e4);
2160
- debug("Retry check results: ok=%s, count=%d", checkResults.ok, checkResults.results.length);
2161
- continue;
2162
- }
2163
- break;
2164
- }
2165
- if (checkResults.ok) stopCheckSpinner(ck, checkResults);
2166
- }
2230
+ if (await runCheckPhaseInteractive(await getRepoRoot(), await resolveToRepoRoot(included.filter((f) => f.status !== "D").map((f) => f.path)), 6e4) === "cancelled") return "cancelled";
2167
2231
  }
2168
2232
  const config = await readConfig();
2169
2233
  const resolvedProvider = config.provider ?? "groq";
@@ -2401,7 +2465,7 @@ async function agentCommand(flags) {
2401
2465
  const repoRoot = await getRepoRoot();
2402
2466
  if (await detectConfig(repoRoot)) {
2403
2467
  debug("Running user checks on changed files...");
2404
- 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);
2405
2469
  if (!checkResults.ok) {
2406
2470
  const errorMessages = checkResults.results.filter((r) => !r.ok).map((r) => `[${r.tool}]\n${r.stdout}\n${r.stderr}`.trim()).filter(Boolean);
2407
2471
  const parsed = parseCheckErrors(errorMessages.join("\n\n"));
@@ -3206,7 +3270,7 @@ async function handleStaging(changedFiles, flags) {
3206
3270
  }
3207
3271
  if (stagingResult === "checks") {
3208
3272
  await stageAll();
3209
- const allFiles = currentFiles.filter((f) => f.status !== "D").map((f) => f.path);
3273
+ const allFiles = await getStagedFiles();
3210
3274
  if (await detectConfig(repoRoot)) {
3211
3275
  const ckSpinner = spinner();
3212
3276
  ckSpinner.start("Running checks...");
@@ -3245,33 +3309,13 @@ async function handleStaging(changedFiles, flags) {
3245
3309
  async function runPreCommitChecks(changedFiles, noCheck) {
3246
3310
  if (noCheck) return;
3247
3311
  const checkRoot = await getRepoRoot();
3248
- const stagedFileList = changedFiles.filter((f) => f.staged && f.status !== "D").map((f) => f.path);
3312
+ const stagedFileList = await getStagedFiles();
3249
3313
  if (stagedFileList.length === 0) return;
3250
- debug("Running user checks on %d staged files...", stagedFileList.length);
3251
- const ckSpinner = spinner();
3252
- ckSpinner.start("Running checks...");
3253
- let checkResults = await runAllChecks(checkRoot, stagedFileList, 6e4);
3254
- stopCheckSpinner(ckSpinner, checkResults);
3255
- debug("Check results: ok=%s, count=%d", checkResults.ok, checkResults.results.length);
3256
- while (!checkResults.ok) {
3257
- const rawOutput = checkResults.results.filter((r) => !r.ok).map((r) => `[${r.tool}]\n${r.stdout}\n${r.stderr}`.trim()).join("\n\n");
3258
- const menuResult = await showCheckFailureMenu(parseCheckErrors(rawOutput), rawOutput, async () => {
3259
- return (await runAllChecks(checkRoot, stagedFileList, 6e4)).ok;
3260
- });
3261
- if (menuResult === "cancelled") process.exit(1);
3262
- if (menuResult === "retried") {
3263
- debug("Re-staging files and re-running checks after retry...");
3264
- await stageAll();
3265
- const ckSpinner = spinner();
3266
- ckSpinner.start("Running checks...");
3267
- checkResults = await runAllChecks(checkRoot, stagedFileList, 6e4);
3268
- debug("Retry check results: ok=%s, count=%d", checkResults.ok, checkResults.results.length);
3269
- stopCheckSpinner(ckSpinner, checkResults);
3270
- continue;
3271
- }
3272
- break;
3273
- }
3274
- 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));
3275
3319
  }
3276
3320
  /**
3277
3321
  * Re-stage staged files whose working-tree content diverged from the index after checks ran.