@pknx/waterfall-cli 0.1.0

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.
Files changed (106) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +62 -0
  3. package/bin/waterfall.mjs +14 -0
  4. package/lib/cli/agent/agent-message.ts +71 -0
  5. package/lib/cli/agent/agent-translators.ts +145 -0
  6. package/lib/cli/agent/backend-invoke.ts +133 -0
  7. package/lib/cli/agent/backends.ts +100 -0
  8. package/lib/cli/agent/global-prompts.ts +55 -0
  9. package/lib/cli/commands/bug-start.ts +115 -0
  10. package/lib/cli/commands/comment-add.ts +47 -0
  11. package/lib/cli/commands/cr-all.ts +18 -0
  12. package/lib/cli/commands/cr-finish.ts +176 -0
  13. package/lib/cli/commands/cr-start.ts +105 -0
  14. package/lib/cli/commands/cr-to-rq.ts +18 -0
  15. package/lib/cli/commands/export-pdf.ts +193 -0
  16. package/lib/cli/commands/horizontal/horizontal.ts +232 -0
  17. package/lib/cli/commands/horizontal-create.ts +34 -0
  18. package/lib/cli/commands/horizontal-update.ts +32 -0
  19. package/lib/cli/commands/join-hint.ts +4 -0
  20. package/lib/cli/commands/registry.ts +59 -0
  21. package/lib/cli/commands/resolve-operator-hint.ts +120 -0
  22. package/lib/cli/commands/rq-all.ts +18 -0
  23. package/lib/cli/commands/rq-to-uc.ts +18 -0
  24. package/lib/cli/commands/story-close.ts +124 -0
  25. package/lib/cli/commands/sync-work-items.ts +59 -0
  26. package/lib/cli/commands/sys-start.ts +96 -0
  27. package/lib/cli/commands/test-all.ts +18 -0
  28. package/lib/cli/commands/test-to-story.ts +18 -0
  29. package/lib/cli/commands/types.ts +33 -0
  30. package/lib/cli/commands/uc-all.ts +18 -0
  31. package/lib/cli/commands/uc-to-story.ts +18 -0
  32. package/lib/cli/commands/uc-to-test.ts +18 -0
  33. package/lib/cli/comments/item-comments.ts +285 -0
  34. package/lib/cli/config/dot-waterfall.ts +404 -0
  35. package/lib/cli/config/global-cli.ts +21 -0
  36. package/lib/cli/config/sync-work-item-config.ts +34 -0
  37. package/lib/cli/core/cli-help-spec.ts +833 -0
  38. package/lib/cli/core/cli-log.ts +124 -0
  39. package/lib/cli/core/exec-file.ts +8 -0
  40. package/lib/cli/core/prompt-map.ts +64 -0
  41. package/lib/cli/core/slug.ts +44 -0
  42. package/lib/cli/entry.ts +4 -0
  43. package/lib/cli/export/collect-md.ts +41 -0
  44. package/lib/cli/export/export-items.ts +104 -0
  45. package/lib/cli/export/export-pdf-path.ts +88 -0
  46. package/lib/cli/export/merge-md.ts +37 -0
  47. package/lib/cli/export/mermaid-run.ts +104 -0
  48. package/lib/cli/export/pandoc-pdf.ts +90 -0
  49. package/lib/cli/export/pdf-bundled-worker.mjs +73 -0
  50. package/lib/cli/export/pdf-bundled.ts +36 -0
  51. package/lib/cli/git/cr-agent-context.ts +62 -0
  52. package/lib/cli/git/git-branch-guards.ts +60 -0
  53. package/lib/cli/git/git-cli-mock.ts +191 -0
  54. package/lib/cli/git/git-cli.ts +24 -0
  55. package/lib/cli/main.ts +434 -0
  56. package/lib/cli/paths.ts +9 -0
  57. package/lib/cli/project/pom-json.ts +55 -0
  58. package/lib/cli/spec/spec-init.ts +216 -0
  59. package/lib/cli/spec/spec-root.ts +93 -0
  60. package/lib/cli/sync/apply-remote-comments.ts +87 -0
  61. package/lib/cli/sync/attachment-category.ts +43 -0
  62. package/lib/cli/sync/diff-work-items.ts +113 -0
  63. package/lib/cli/sync/materialize-remote-bugs.ts +66 -0
  64. package/lib/cli/sync/provider-types.ts +43 -0
  65. package/lib/cli/sync/providers/direct-provider.ts +27 -0
  66. package/lib/cli/sync/providers/jira-provider.ts +34 -0
  67. package/lib/cli/sync/providers/registry.ts +26 -0
  68. package/lib/cli/sync/run-sync-work-items.ts +202 -0
  69. package/lib/cli/sync/spec-work-items.ts +226 -0
  70. package/lib/cli/sync/sync-hint-json.ts +163 -0
  71. package/lib/cli/sync/work-item-meta.ts +117 -0
  72. package/lib/cli/work-items/infer-bug-sys.ts +147 -0
  73. package/lib/cli/work-items/remote-bug-import-scaffold.ts +32 -0
  74. package/lib/cli/work-items/write-bug-to-spec.ts +158 -0
  75. package/package.json +54 -0
  76. package/prompts/commands/bug-start.md +46 -0
  77. package/prompts/commands/cr-finish.md +44 -0
  78. package/prompts/commands/cr-start.md +65 -0
  79. package/prompts/commands/cr-to-rq.md +62 -0
  80. package/prompts/commands/horizontal-create.md +27 -0
  81. package/prompts/commands/horizontal-update.md +39 -0
  82. package/prompts/commands/rq-to-uc.md +62 -0
  83. package/prompts/commands/story-close-all.md +34 -0
  84. package/prompts/commands/story-close.md +44 -0
  85. package/prompts/commands/sync-bugs-refine-imports.md +33 -0
  86. package/prompts/commands/sys-start.md +63 -0
  87. package/prompts/commands/test-to-story.md +64 -0
  88. package/prompts/commands/uc-to-story.md +85 -0
  89. package/prompts/commands/uc-to-test.md +58 -0
  90. package/prompts/global/before-changing-spec.md +62 -0
  91. package/prompts/global/content-requirements-vs-use-cases.md +116 -0
  92. package/prompts/global/cursor-overview.md +31 -0
  93. package/prompts/global/git-usage.md +46 -0
  94. package/prompts/global/horizontal-structure.md +75 -0
  95. package/prompts/global/workflows-index.md +59 -0
  96. package/prompts/items/bug-document-structure.md +23 -0
  97. package/prompts/items/cr-document-structure.md +45 -0
  98. package/prompts/items/rq-theme-document-structure.md +36 -0
  99. package/prompts/items/story-document-structure.md +49 -0
  100. package/prompts/items/sys-document-structure.md +36 -0
  101. package/prompts/items/tst-document-structure.md +55 -0
  102. package/prompts/items/uc-document-structure.md +38 -0
  103. package/spec-template/README.md +11 -0
  104. package/spec-template/full/doc/spec-structure.md +16 -0
  105. package/spec-template/full/prompts/before-changing-spec.md +7 -0
  106. package/spec-template/full/prompts/workflows.md +25 -0
@@ -0,0 +1,90 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+
5
+ export function buildNativePandocArgs(
6
+ markdownBasename: string,
7
+ pdfOutAbsolute: string,
8
+ ): string[] {
9
+ const extra = process.env.WATERFALL_PANDOC_ARGS?.trim();
10
+ /** XeLaTeX handles Unicode (e.g. ≥); default pdflatex often fails on those. */
11
+ const engine =
12
+ process.env.WATERFALL_PANDOC_PDF_ENGINE?.trim() || "xelatex";
13
+ const args = [
14
+ markdownBasename,
15
+ "-o",
16
+ pdfOutAbsolute,
17
+ "--toc",
18
+ "--toc-depth=4",
19
+ "--pdf-engine",
20
+ engine,
21
+ ];
22
+ if (extra) {
23
+ args.push(...extra.split(/\s+/).filter(Boolean));
24
+ }
25
+ return args;
26
+ }
27
+
28
+ /**
29
+ * Executable used when `use_native_pandoc=true`.
30
+ * Order: `WATERFALL_PANDOC` → `pandoc` on PATH → common Homebrew paths (macOS).
31
+ */
32
+ export function resolvePandocExecutable(): string {
33
+ const fromEnv = process.env.WATERFALL_PANDOC?.trim();
34
+ if (fromEnv) {
35
+ if (!fs.existsSync(fromEnv)) {
36
+ throw new Error(
37
+ `WATERFALL_PANDOC is set but file not found: ${fromEnv}`,
38
+ );
39
+ }
40
+ return fromEnv;
41
+ }
42
+
43
+ try {
44
+ execFileSync("pandoc", ["--version"], { stdio: "ignore" });
45
+ return "pandoc";
46
+ } catch {
47
+ /* not on PATH */
48
+ }
49
+
50
+ const brew = process.env.HOMEBREW_PREFIX?.trim();
51
+ const candidates = [
52
+ "/opt/homebrew/bin/pandoc",
53
+ "/usr/local/bin/pandoc",
54
+ ...(brew ? [path.join(brew, "bin", "pandoc")] : []),
55
+ ];
56
+ for (const c of candidates) {
57
+ if (c && fs.existsSync(c)) return c;
58
+ }
59
+
60
+ throw new Error(
61
+ "pandoc not found. Install: https://pandoc.org/installing.html (macOS: brew install pandoc). " +
62
+ "Or set WATERFALL_PANDOC to the binary, or ensure /opt/homebrew/bin or /usr/local/bin is on PATH.",
63
+ );
64
+ }
65
+
66
+ /**
67
+ * Run system `pandoc` on `markdownBasename` inside `workDir` so relative `![](*.png)` resolve.
68
+ * Used when `use_native_pandoc=true` in `.waterfall` / `~/.waterfall` (LaTeX-quality PDF if pandoc + engine are installed).
69
+ * @param pandocExecutable optional; default {@link resolvePandocExecutable}
70
+ */
71
+ export function runNativePandocPdf(
72
+ workDir: string,
73
+ markdownBasename: string,
74
+ pdfOutAbsolute: string,
75
+ pandocExecutable?: string,
76
+ ): void {
77
+ const bin = pandocExecutable ?? resolvePandocExecutable();
78
+ const args = buildNativePandocArgs(markdownBasename, pdfOutAbsolute);
79
+ try {
80
+ execFileSync(bin, args, { cwd: workDir, stdio: "inherit" });
81
+ } catch (e) {
82
+ const err = e as NodeJS.ErrnoException;
83
+ if (err.code === "ENOENT") {
84
+ throw new Error(
85
+ `pandoc executable missing: ${bin}. Install Pandoc or set WATERFALL_PANDOC.`,
86
+ );
87
+ }
88
+ throw e;
89
+ }
90
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * ESM worker: pandoc-wasm (markdown → HTML) + puppeteer (HTML → PDF).
3
+ * node lib/cli/export/pdf-bundled-worker.mjs <workDir> <mdBasename> <pdfOutAbsolute>
4
+ */
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import process from "node:process";
8
+ import { convert } from "pandoc-wasm";
9
+ import puppeteer from "puppeteer";
10
+
11
+ const workDir = process.argv[2];
12
+ const mdName = process.argv[3];
13
+ const pdfOut = process.argv[4];
14
+
15
+ if (!workDir || !mdName || !pdfOut) {
16
+ process.stderr.write(
17
+ "pdf-bundled-worker: expected argv: workDir markdownBasename pdfOutAbsolute\n",
18
+ );
19
+ process.exit(1);
20
+ }
21
+
22
+ async function main() {
23
+ const mdPath = path.join(workDir, mdName);
24
+ const md = fs.readFileSync(mdPath, "utf8");
25
+ const files = {};
26
+ for (const name of fs.readdirSync(workDir)) {
27
+ if (!name.endsWith(".png")) continue;
28
+ files[name] = new Blob([fs.readFileSync(path.join(workDir, name))]);
29
+ }
30
+
31
+ const result = await convert(
32
+ {
33
+ from: "markdown",
34
+ to: "html",
35
+ standalone: true,
36
+ "embed-resources": true,
37
+ "table-of-contents": true,
38
+ "toc-depth": 4,
39
+ },
40
+ md,
41
+ files,
42
+ );
43
+ if (result.stderr?.trim()) {
44
+ process.stderr.write(result.stderr);
45
+ }
46
+ const html = result.stdout;
47
+ if (!html?.trim()) {
48
+ process.stderr.write("pandoc-wasm produced empty HTML\n");
49
+ process.exit(1);
50
+ }
51
+
52
+ const browser = await puppeteer.launch({
53
+ headless: true,
54
+ args: ["--no-sandbox", "--disable-setuid-sandbox"],
55
+ });
56
+ try {
57
+ const page = await browser.newPage();
58
+ await page.setContent(html, { waitUntil: "networkidle0" });
59
+ await page.pdf({
60
+ path: pdfOut,
61
+ format: "A4",
62
+ printBackground: true,
63
+ margin: { top: "18mm", bottom: "18mm", left: "16mm", right: "16mm" },
64
+ });
65
+ } finally {
66
+ await browser.close();
67
+ }
68
+ }
69
+
70
+ main().catch((e) => {
71
+ process.stderr.write(`${e instanceof Error ? e.message : String(e)}\n`);
72
+ process.exit(1);
73
+ });
@@ -0,0 +1,36 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import path from "node:path";
3
+ import process from "node:process";
4
+ import { WATERFALL_CLI_PACKAGE_ROOT } from "../paths";
5
+
6
+ export function bundledPdfWorkerScriptPath(): string {
7
+ return path.join(
8
+ WATERFALL_CLI_PACKAGE_ROOT,
9
+ "lib/cli/export/pdf-bundled-worker.mjs",
10
+ );
11
+ }
12
+
13
+ /**
14
+ * Runs {@link ./pdf-bundled-worker.mjs} in a subprocess so the main CLI stays synchronous.
15
+ * Uses pandoc-wasm + bundled Chromium (puppeteer).
16
+ */
17
+ export function runBundledMarkdownToPdf(
18
+ workDir: string,
19
+ markdownBasename: string,
20
+ pdfOutAbsolute: string,
21
+ ): void {
22
+ const worker = bundledPdfWorkerScriptPath();
23
+ const r = spawnSync(
24
+ process.execPath,
25
+ [worker, workDir, markdownBasename, pdfOutAbsolute],
26
+ {
27
+ stdio: "inherit",
28
+ cwd: process.cwd(),
29
+ env: process.env,
30
+ },
31
+ );
32
+ if (r.error) throw r.error;
33
+ if (r.status !== 0) {
34
+ throw new Error(`Bundled PDF generation failed (exit ${r.status})`);
35
+ }
36
+ }
@@ -0,0 +1,62 @@
1
+ import { CR_FEATURE_BRANCH_RE, getCurrentGitBranch, specRootHasGit } from "./git-branch-guards";
2
+ import { gitExecSync } from "./git-cli";
3
+
4
+ /**
5
+ * Markdown block listing paths changed on the current branch vs `develop` (`develop...HEAD`).
6
+ * Empty string when not on a change-request branch, no git, or diff fails.
7
+ */
8
+ export function formatCrBranchChangedFilesBlock(specRoot: string): string {
9
+ if (!specRootHasGit(specRoot)) {
10
+ return "";
11
+ }
12
+ const branch = getCurrentGitBranch(specRoot);
13
+ if (!CR_FEATURE_BRANCH_RE.test(branch)) {
14
+ return "";
15
+ }
16
+ try {
17
+ const out = gitExecSync(specRoot, ["diff", "develop...HEAD", "--name-only"], {
18
+ encoding: "utf8",
19
+ stdio: ["ignore", "pipe", "pipe"],
20
+ }).toString();
21
+ const lines = out
22
+ .split("\n")
23
+ .map((l) => l.trim())
24
+ .filter(Boolean);
25
+ const body = lines.length
26
+ ? lines.map((p) => `- ${p}`).join("\n")
27
+ : "No paths differ from develop in this range (develop...HEAD).";
28
+ return [
29
+ "CR branch — paths changed vs develop (develop...HEAD)",
30
+ "",
31
+ "The Waterfall CLI attached this list so you do not need to run git for scope discovery.",
32
+ "",
33
+ body,
34
+ "",
35
+ "---",
36
+ "",
37
+ ].join("\n");
38
+ } catch {
39
+ return [
40
+ "CR branch — paths changed vs develop",
41
+ "",
42
+ "Could not run git diff develop...HEAD --name-only (missing develop branch or not a git repo?).",
43
+ "",
44
+ "---",
45
+ "",
46
+ ].join("\n");
47
+ }
48
+ }
49
+
50
+ /**
51
+ * One-line / log-friendly hint: operator text then CR paths block.
52
+ * The agent wire payload uses separate `user` segments (see `buildWaterfallAgentMessage`);
53
+ * this concatenation is for dry-run traces and `describeBackendInvocation` only.
54
+ */
55
+ export function augmentAgentHintWithCrFiles(specRoot: string, hint: string): string {
56
+ const block = formatCrBranchChangedFilesBlock(specRoot);
57
+ if (!block) {
58
+ return hint;
59
+ }
60
+ const t = hint.trim();
61
+ return t.length > 0 ? `${t}\n\n${block}` : block;
62
+ }
@@ -0,0 +1,60 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { gitExecSync } from "./git-cli";
4
+
5
+ /** Matches `feature/CR-001-slug`, `feature/CR-012`, etc. */
6
+ export const CR_FEATURE_BRANCH_RE = /^feature\/CR-\d/;
7
+
8
+ export function specRootHasGit(specRoot: string): boolean {
9
+ return fs.existsSync(path.join(specRoot, ".git"));
10
+ }
11
+
12
+ export function getCurrentGitBranch(specRoot: string): string {
13
+ return gitExecSync(specRoot, ["rev-parse", "--abbrev-ref", "HEAD"], {
14
+ encoding: "utf8",
15
+ stdio: ["ignore", "pipe", "pipe"],
16
+ })
17
+ .toString()
18
+ .trim();
19
+ }
20
+
21
+ /** `waterfall cr start`, `waterfall story close`, `waterfall comment add`, … — normative: only on `develop`. */
22
+ export function assertBranchDevelop(specRoot: string, commandLabel: string): void {
23
+ if (!specRootHasGit(specRoot)) {
24
+ throw new Error(
25
+ `${commandLabel}: spec root must be a git repository (.git missing). Run git init or use a clone.`,
26
+ );
27
+ }
28
+ const b = getCurrentGitBranch(specRoot);
29
+ if (b !== "develop") {
30
+ throw new Error(
31
+ `${commandLabel}: must be on branch "develop" (current: "${b}"). Checkout develop first.`,
32
+ );
33
+ }
34
+ }
35
+
36
+ /** `waterfall cr start` — normative: start new CR work only from `develop`. */
37
+ export function assertBranchForCrStart(specRoot: string): void {
38
+ assertBranchDevelop(specRoot, "cr start");
39
+ }
40
+
41
+ /**
42
+ * Change-request–scoped work: lifecycle prompts, sys start, horizontal create/update, cr finish — normative:
43
+ * on a branch matching `feature/CR-<NNN>-…` (inside an active change request).
44
+ */
45
+ export function assertBranchForCrScopedCommand(
46
+ specRoot: string,
47
+ commandLabel: string,
48
+ ): void {
49
+ if (!specRootHasGit(specRoot)) {
50
+ throw new Error(
51
+ `${commandLabel}: spec root must be a git repository (.git missing).`,
52
+ );
53
+ }
54
+ const b = getCurrentGitBranch(specRoot);
55
+ if (!CR_FEATURE_BRANCH_RE.test(b)) {
56
+ throw new Error(
57
+ `${commandLabel}: must be on a change-request branch matching feature/CR-<NNN>-… (current: "${b}"). Run "waterfall cr start …" from develop first.`,
58
+ );
59
+ }
60
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * In-process git simulation for `VITEST=true` (sandboxes often block the real `git` binary).
3
+ */
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+
7
+ type MockRepo = {
8
+ branch: string;
9
+ commits: { branch: string; msg: string }[];
10
+ };
11
+
12
+ const repos = new Map<string, MockRepo>();
13
+
14
+ function dirKey(cwd: string | URL): string {
15
+ return path.resolve(String(cwd));
16
+ }
17
+
18
+ function getRepo(cwd: string | URL): MockRepo {
19
+ const key = dirKey(cwd);
20
+ let r = repos.get(key);
21
+ if (!r) {
22
+ r = { branch: "develop", commits: [] };
23
+ repos.set(key, r);
24
+ }
25
+ return r;
26
+ }
27
+
28
+ function returnOut(
29
+ s: string,
30
+ options: { encoding?: string; stdio?: string | string[] } | undefined,
31
+ ): string | Buffer {
32
+ if (options?.encoding === "utf8" || options?.encoding === "utf-8") {
33
+ return s;
34
+ }
35
+ if (options?.stdio === "inherit") {
36
+ return "" as unknown as Buffer;
37
+ }
38
+ return Buffer.from(s);
39
+ }
40
+
41
+ function parseLogLimit(argv: string[]): number {
42
+ const hit = argv.find((a) => /^-\d+$/.test(a));
43
+ if (hit) return parseInt(hit.slice(1), 10);
44
+ return 5;
45
+ }
46
+
47
+ export function mockGitExecSync(
48
+ cwd: string | URL,
49
+ argv: string[],
50
+ options?: { encoding?: BufferEncoding; stdio?: "inherit" | ["ignore", "pipe", "pipe"] },
51
+ ): string | Buffer {
52
+ const r = getRepo(cwd);
53
+
54
+ if (argv[0] === "init") {
55
+ let branch = "develop";
56
+ const bi = argv.indexOf("-b");
57
+ if (bi >= 0 && argv[bi + 1]) branch = argv[bi + 1]!;
58
+ const base = dirKey(cwd);
59
+ repos.set(base, { branch, commits: [] });
60
+ const gitDir = path.join(base, ".git");
61
+ fs.mkdirSync(path.join(gitDir, "objects", "pack"), { recursive: true });
62
+ fs.mkdirSync(path.join(gitDir, "refs", "heads"), { recursive: true });
63
+ /* HEAD only — some sandboxes reject `.git/config` writes even from Node. */
64
+ fs.writeFileSync(path.join(gitDir, "HEAD"), `ref: refs/heads/${branch}\n`);
65
+ return returnOut("", options);
66
+ }
67
+
68
+ if (argv[0] === "config") {
69
+ return returnOut("", options);
70
+ }
71
+
72
+ if (argv[0] === "checkout") {
73
+ if (argv[1] === "-b") {
74
+ r.branch = argv[2]!;
75
+ } else {
76
+ r.branch = argv[1]!;
77
+ }
78
+ return returnOut("", options);
79
+ }
80
+
81
+ if (argv[0] === "pull") {
82
+ return returnOut("", options);
83
+ }
84
+
85
+ if (argv[0] === "mv") {
86
+ const from = argv[1]!;
87
+ const to = argv[2]!;
88
+ const base = dirKey(cwd);
89
+ fs.renameSync(path.join(base, from), path.join(base, to));
90
+ return returnOut("", options);
91
+ }
92
+
93
+ if (argv[0] === "add") {
94
+ return returnOut("", options);
95
+ }
96
+
97
+ if (argv[0] === "commit") {
98
+ const mi = argv.indexOf("-m");
99
+ const msg = mi >= 0 ? argv[mi + 1]! : "commit";
100
+ r.commits.push({ branch: r.branch, msg });
101
+ return returnOut("", options);
102
+ }
103
+
104
+ if (argv[0] === "rev-parse" && argv.includes("--short=12") && argv[argv.length - 1] === "HEAD") {
105
+ return returnOut("abc123deadef\n", options);
106
+ }
107
+
108
+ if (
109
+ argv[0] === "tag" &&
110
+ argv.includes("--points-at") &&
111
+ argv.includes("HEAD")
112
+ ) {
113
+ return returnOut("", options);
114
+ }
115
+
116
+ if (argv[0] === "rev-parse" && argv[1] === "--abbrev-ref" && argv[2] === "HEAD") {
117
+ return returnOut(`${r.branch}\n`, options);
118
+ }
119
+
120
+ if (argv[0] === "diff" && argv[1] === "--cached" && argv[2] === "--quiet") {
121
+ return returnOut("", options);
122
+ }
123
+
124
+ if (argv[0] === "status" && argv[1] === "--porcelain") {
125
+ return returnOut("", options);
126
+ }
127
+
128
+ if (argv[0] === "rev-list" && argv[1] === "--count" && argv[2] === "develop..HEAD") {
129
+ if (!r.branch.startsWith("feature/")) {
130
+ return returnOut("0\n", options);
131
+ }
132
+ const n = r.commits.filter((c) => c.branch === r.branch).length;
133
+ return returnOut(`${n}\n`, options);
134
+ }
135
+
136
+ if (argv[0] === "diff" && argv[1] === "develop...HEAD") {
137
+ if (argv.includes("--name-only")) {
138
+ if (!r.branch.startsWith("feature/")) {
139
+ return returnOut("", options);
140
+ }
141
+ const n = r.commits.filter((c) => c.branch === r.branch).length;
142
+ if (n === 0) return returnOut("", options);
143
+ return returnOut(
144
+ "changerequests/CR-001-stub/CR-001.md\ntechnical/SYS-001-x/agent/STORY-001-y/STORY-001.md\n",
145
+ options,
146
+ );
147
+ }
148
+ if (!r.branch.startsWith("feature/")) {
149
+ return returnOut("", options);
150
+ }
151
+ const n = r.commits.filter((c) => c.branch === r.branch).length;
152
+ if (n === 0) return returnOut("", options);
153
+ return returnOut("diff --git a/changerequests/x b/changerequests/x\n", options);
154
+ }
155
+
156
+ if (argv[0] === "merge") {
157
+ const source = argv[1]!;
158
+ const featureCommits = r.commits.filter((c) => c.branch === source);
159
+ for (const c of featureCommits) {
160
+ r.commits.push({ branch: "develop", msg: c.msg });
161
+ }
162
+ const mi = argv.indexOf("-m");
163
+ const mergeMsg = mi >= 0 ? argv[mi + 1]! : "merge";
164
+ if (argv.includes("--no-ff") || featureCommits.length > 0) {
165
+ r.commits.push({ branch: "develop", msg: mergeMsg });
166
+ }
167
+ r.branch = "develop";
168
+ return returnOut("", options);
169
+ }
170
+
171
+ if (argv[0] === "log") {
172
+ if (argv.includes("--oneline")) {
173
+ const branchArg = argv.find((a, i) => i >= 1 && !a.startsWith("-"));
174
+ const b = branchArg ?? r.branch;
175
+ const list = r.commits.filter((c) => c.branch === b);
176
+ const n = parseLogLimit(argv);
177
+ const slice = list.slice(-n).reverse();
178
+ const lines = slice.map(
179
+ (c, i) => `${(0x09abcf + i).toString(16).padStart(7, "0")} ${c.msg}`,
180
+ );
181
+ return returnOut(lines.join("\n") + (lines.length ? "\n" : ""), options);
182
+ }
183
+ if (argv.some((a) => a.startsWith("--pretty"))) {
184
+ const list = r.commits.filter((c) => c.branch === r.branch);
185
+ const last = list[list.length - 1];
186
+ return returnOut(last ? `${last.msg}\n` : "\n", options);
187
+ }
188
+ }
189
+
190
+ throw new Error(`git-cli-mock: unsupported: git ${argv.join(" ")}`);
191
+ }
@@ -0,0 +1,24 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { mockGitExecSync } from "./git-cli-mock";
3
+
4
+ /**
5
+ * Run `git` in `cwd`. Under Vitest, uses an in-process mock so unit tests do not rely
6
+ * on the real git binary (often blocked from writing `.git/` in sandboxes).
7
+ */
8
+ export function gitExecSync(
9
+ cwd: string | URL,
10
+ args: string[],
11
+ options?: {
12
+ stdio?: "inherit" | ["ignore", "pipe", "pipe"];
13
+ encoding?: BufferEncoding;
14
+ },
15
+ ): string | Buffer {
16
+ if (process.env.VITEST === "true") {
17
+ return mockGitExecSync(cwd, args, options);
18
+ }
19
+ const wd = typeof cwd === "string" ? cwd : String(cwd);
20
+ return execFileSync("git", args, {
21
+ cwd: wd,
22
+ ...options,
23
+ } as Parameters<typeof execFileSync>[2]);
24
+ }