@cad0p/pi-steering 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 (246) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/LICENSE +29 -0
  3. package/README.md +920 -0
  4. package/dist/__test-helpers__.d.ts +95 -0
  5. package/dist/__test-helpers__.d.ts.map +1 -0
  6. package/dist/__test-helpers__.js +174 -0
  7. package/dist/__test-helpers__.js.map +1 -0
  8. package/dist/bin/pi-steering.d.ts +9 -0
  9. package/dist/bin/pi-steering.d.ts.map +1 -0
  10. package/dist/bin/pi-steering.js +506 -0
  11. package/dist/bin/pi-steering.js.map +1 -0
  12. package/dist/compat.d.ts +50 -0
  13. package/dist/compat.d.ts.map +1 -0
  14. package/dist/compat.js +180 -0
  15. package/dist/compat.js.map +1 -0
  16. package/dist/defaults.d.ts +61 -0
  17. package/dist/defaults.d.ts.map +1 -0
  18. package/dist/defaults.js +106 -0
  19. package/dist/defaults.js.map +1 -0
  20. package/dist/define-config.d.ts +235 -0
  21. package/dist/define-config.d.ts.map +1 -0
  22. package/dist/define-config.js +118 -0
  23. package/dist/define-config.js.map +1 -0
  24. package/dist/define-predicate.d.ts +49 -0
  25. package/dist/define-predicate.d.ts.map +1 -0
  26. package/dist/define-predicate.js +35 -0
  27. package/dist/define-predicate.js.map +1 -0
  28. package/dist/evaluator-internals/context.d.ts +186 -0
  29. package/dist/evaluator-internals/context.d.ts.map +1 -0
  30. package/dist/evaluator-internals/context.js +219 -0
  31. package/dist/evaluator-internals/context.js.map +1 -0
  32. package/dist/evaluator-internals/override.d.ts +19 -0
  33. package/dist/evaluator-internals/override.d.ts.map +1 -0
  34. package/dist/evaluator-internals/override.js +79 -0
  35. package/dist/evaluator-internals/override.js.map +1 -0
  36. package/dist/evaluator-internals/predicates.d.ts +152 -0
  37. package/dist/evaluator-internals/predicates.d.ts.map +1 -0
  38. package/dist/evaluator-internals/predicates.js +849 -0
  39. package/dist/evaluator-internals/predicates.js.map +1 -0
  40. package/dist/evaluator-internals/speculative-synthesis.d.ts +102 -0
  41. package/dist/evaluator-internals/speculative-synthesis.d.ts.map +1 -0
  42. package/dist/evaluator-internals/speculative-synthesis.js +101 -0
  43. package/dist/evaluator-internals/speculative-synthesis.js.map +1 -0
  44. package/dist/evaluator.d.ts +101 -0
  45. package/dist/evaluator.d.ts.map +1 -0
  46. package/dist/evaluator.js +714 -0
  47. package/dist/evaluator.js.map +1 -0
  48. package/dist/helpers/walker-unknown-cwd-reason.d.ts +68 -0
  49. package/dist/helpers/walker-unknown-cwd-reason.d.ts.map +1 -0
  50. package/dist/helpers/walker-unknown-cwd-reason.js +73 -0
  51. package/dist/helpers/walker-unknown-cwd-reason.js.map +1 -0
  52. package/dist/index.d.ts +42 -0
  53. package/dist/index.d.ts.map +1 -0
  54. package/dist/index.js +93 -0
  55. package/dist/index.js.map +1 -0
  56. package/dist/internal/drop-unused-observers.d.ts +43 -0
  57. package/dist/internal/drop-unused-observers.d.ts.map +1 -0
  58. package/dist/internal/drop-unused-observers.js +50 -0
  59. package/dist/internal/drop-unused-observers.js.map +1 -0
  60. package/dist/internal/finalize-plugin-state.d.ts +22 -0
  61. package/dist/internal/finalize-plugin-state.d.ts.map +1 -0
  62. package/dist/internal/finalize-plugin-state.js +14 -0
  63. package/dist/internal/finalize-plugin-state.js.map +1 -0
  64. package/dist/internal/merge-observers.d.ts +30 -0
  65. package/dist/internal/merge-observers.d.ts.map +1 -0
  66. package/dist/internal/merge-observers.js +27 -0
  67. package/dist/internal/merge-observers.js.map +1 -0
  68. package/dist/internal/pattern-utils.d.ts +33 -0
  69. package/dist/internal/pattern-utils.d.ts.map +1 -0
  70. package/dist/internal/pattern-utils.js +20 -0
  71. package/dist/internal/pattern-utils.js.map +1 -0
  72. package/dist/internal/ref-text.d.ts +11 -0
  73. package/dist/internal/ref-text.d.ts.map +1 -0
  74. package/dist/internal/ref-text.js +15 -0
  75. package/dist/internal/ref-text.js.map +1 -0
  76. package/dist/internal/session-runtime.d.ts +44 -0
  77. package/dist/internal/session-runtime.d.ts.map +1 -0
  78. package/dist/internal/session-runtime.js +143 -0
  79. package/dist/internal/session-runtime.js.map +1 -0
  80. package/dist/internal/watch-matcher.d.ts +56 -0
  81. package/dist/internal/watch-matcher.d.ts.map +1 -0
  82. package/dist/internal/watch-matcher.js +179 -0
  83. package/dist/internal/watch-matcher.js.map +1 -0
  84. package/dist/loader.d.ts +97 -0
  85. package/dist/loader.d.ts.map +1 -0
  86. package/dist/loader.js +527 -0
  87. package/dist/loader.js.map +1 -0
  88. package/dist/observer-dispatcher.d.ts +69 -0
  89. package/dist/observer-dispatcher.d.ts.map +1 -0
  90. package/dist/observer-dispatcher.js +184 -0
  91. package/dist/observer-dispatcher.js.map +1 -0
  92. package/dist/plugin-merger.d.ts +175 -0
  93. package/dist/plugin-merger.d.ts.map +1 -0
  94. package/dist/plugin-merger.js +450 -0
  95. package/dist/plugin-merger.js.map +1 -0
  96. package/dist/plugins/git/branch-tracker.d.ts +85 -0
  97. package/dist/plugins/git/branch-tracker.d.ts.map +1 -0
  98. package/dist/plugins/git/branch-tracker.js +181 -0
  99. package/dist/plugins/git/branch-tracker.js.map +1 -0
  100. package/dist/plugins/git/cwd-extensions.d.ts +10 -0
  101. package/dist/plugins/git/cwd-extensions.d.ts.map +1 -0
  102. package/dist/plugins/git/cwd-extensions.js +155 -0
  103. package/dist/plugins/git/cwd-extensions.js.map +1 -0
  104. package/dist/plugins/git/git-ops.d.ts +89 -0
  105. package/dist/plugins/git/git-ops.d.ts.map +1 -0
  106. package/dist/plugins/git/git-ops.js +107 -0
  107. package/dist/plugins/git/git-ops.js.map +1 -0
  108. package/dist/plugins/git/index.d.ts +225 -0
  109. package/dist/plugins/git/index.d.ts.map +1 -0
  110. package/dist/plugins/git/index.js +52 -0
  111. package/dist/plugins/git/index.js.map +1 -0
  112. package/dist/plugins/git/predicates.d.ts +312 -0
  113. package/dist/plugins/git/predicates.d.ts.map +1 -0
  114. package/dist/plugins/git/predicates.js +580 -0
  115. package/dist/plugins/git/predicates.js.map +1 -0
  116. package/dist/plugins/git/rules.d.ts +209 -0
  117. package/dist/plugins/git/rules.d.ts.map +1 -0
  118. package/dist/plugins/git/rules.js +267 -0
  119. package/dist/plugins/git/rules.js.map +1 -0
  120. package/dist/schema.d.ts +1799 -0
  121. package/dist/schema.d.ts.map +1 -0
  122. package/dist/schema.js +4 -0
  123. package/dist/schema.js.map +1 -0
  124. package/dist/testing/index.d.ts +621 -0
  125. package/dist/testing/index.d.ts.map +1 -0
  126. package/dist/testing/index.js +747 -0
  127. package/dist/testing/index.js.map +1 -0
  128. package/examples/README.md +83 -0
  129. package/examples/combined-git-discipline/README.md +52 -0
  130. package/examples/combined-git-discipline/node_modules/.bin/pi-steering +43 -0
  131. package/examples/combined-git-discipline/package.json +18 -0
  132. package/examples/combined-git-discipline/steering.json +27 -0
  133. package/examples/combined-git-discipline/steering.test.ts +42 -0
  134. package/examples/combined-git-discipline/steering.ts +64 -0
  135. package/examples/combined-git-discipline/tsconfig.json +7 -0
  136. package/examples/draft-prs-only/README.md +42 -0
  137. package/examples/draft-prs-only/node_modules/.bin/pi-steering +43 -0
  138. package/examples/draft-prs-only/package.json +18 -0
  139. package/examples/draft-prs-only/steering.json +12 -0
  140. package/examples/draft-prs-only/steering.test.ts +48 -0
  141. package/examples/draft-prs-only/steering.ts +36 -0
  142. package/examples/draft-prs-only/tsconfig.json +7 -0
  143. package/examples/dynamic-reason-runtime-cwd/README.md +148 -0
  144. package/examples/dynamic-reason-runtime-cwd/node_modules/.bin/pi-steering +43 -0
  145. package/examples/dynamic-reason-runtime-cwd/package.json +18 -0
  146. package/examples/dynamic-reason-runtime-cwd/steering.test.ts +130 -0
  147. package/examples/dynamic-reason-runtime-cwd/steering.ts +82 -0
  148. package/examples/dynamic-reason-runtime-cwd/tsconfig.json +7 -0
  149. package/examples/force-push-strict/README.md +75 -0
  150. package/examples/force-push-strict/node_modules/.bin/pi-steering +43 -0
  151. package/examples/force-push-strict/package.json +18 -0
  152. package/examples/force-push-strict/steering.json +12 -0
  153. package/examples/force-push-strict/steering.test.ts +51 -0
  154. package/examples/force-push-strict/steering.ts +54 -0
  155. package/examples/force-push-strict/tsconfig.json +7 -0
  156. package/examples/no-amend/README.md +52 -0
  157. package/examples/no-amend/node_modules/.bin/pi-steering +43 -0
  158. package/examples/no-amend/package.json +18 -0
  159. package/examples/no-amend/steering.cwd-scoped.json +12 -0
  160. package/examples/no-amend/steering.json +11 -0
  161. package/examples/no-amend/steering.test.ts +49 -0
  162. package/examples/no-amend/steering.ts +38 -0
  163. package/examples/no-amend/tsconfig.json +7 -0
  164. package/examples/work-item-plugin/README.md +103 -0
  165. package/examples/work-item-plugin/node_modules/.bin/pi-steering +43 -0
  166. package/examples/work-item-plugin/package.json +19 -0
  167. package/examples/work-item-plugin/src/index.test.ts +168 -0
  168. package/examples/work-item-plugin/src/index.ts +122 -0
  169. package/examples/work-item-plugin/src/observers/npm-test-tracker.test.ts +95 -0
  170. package/examples/work-item-plugin/src/observers/npm-test-tracker.ts +88 -0
  171. package/examples/work-item-plugin/src/observers/retest-required-tracker.test.ts +81 -0
  172. package/examples/work-item-plugin/src/observers/retest-required-tracker.ts +81 -0
  173. package/examples/work-item-plugin/src/predicates/work-item-format.test.ts +157 -0
  174. package/examples/work-item-plugin/src/predicates/work-item-format.ts +99 -0
  175. package/examples/work-item-plugin/src/rules/commit-description-check.test.ts +107 -0
  176. package/examples/work-item-plugin/src/rules/commit-description-check.ts +83 -0
  177. package/examples/work-item-plugin/src/rules/commit-requires-work-item.test.ts +79 -0
  178. package/examples/work-item-plugin/src/rules/commit-requires-work-item.ts +56 -0
  179. package/examples/work-item-plugin/src/rules/push-requires-tests.test.ts +245 -0
  180. package/examples/work-item-plugin/src/rules/push-requires-tests.ts +62 -0
  181. package/examples/work-item-plugin/tsconfig.json +8 -0
  182. package/package.json +88 -0
  183. package/skills/steering-authoring/SKILL.md +102 -0
  184. package/src/INVARIANTS.md +116 -0
  185. package/src/__test-helpers__.ts +243 -0
  186. package/src/bin/pi-steering.test.ts +920 -0
  187. package/src/bin/pi-steering.ts +584 -0
  188. package/src/compat.test.ts +431 -0
  189. package/src/compat.ts +282 -0
  190. package/src/defaults.test.ts +585 -0
  191. package/src/defaults.ts +148 -0
  192. package/src/define-config.test.ts +888 -0
  193. package/src/define-config.ts +375 -0
  194. package/src/define-predicate.test.ts +70 -0
  195. package/src/define-predicate.ts +57 -0
  196. package/src/evaluator-internals/context.ts +290 -0
  197. package/src/evaluator-internals/override.test.ts +99 -0
  198. package/src/evaluator-internals/override.ts +83 -0
  199. package/src/evaluator-internals/predicates.ts +1037 -0
  200. package/src/evaluator-internals/speculative-synthesis.test.ts +411 -0
  201. package/src/evaluator-internals/speculative-synthesis.ts +199 -0
  202. package/src/evaluator.test.ts +6296 -0
  203. package/src/evaluator.ts +1106 -0
  204. package/src/factory-time-load.test.ts +630 -0
  205. package/src/helpers/walker-unknown-cwd-reason.test.ts +73 -0
  206. package/src/helpers/walker-unknown-cwd-reason.ts +83 -0
  207. package/src/index.test.ts +836 -0
  208. package/src/index.ts +229 -0
  209. package/src/internal/drop-unused-observers.test.ts +141 -0
  210. package/src/internal/drop-unused-observers.ts +81 -0
  211. package/src/internal/finalize-plugin-state.ts +43 -0
  212. package/src/internal/merge-observers.ts +51 -0
  213. package/src/internal/pattern-utils.test.ts +52 -0
  214. package/src/internal/pattern-utils.ts +38 -0
  215. package/src/internal/ref-text.test.ts +54 -0
  216. package/src/internal/ref-text.ts +17 -0
  217. package/src/internal/session-runtime.test.ts +629 -0
  218. package/src/internal/session-runtime.ts +201 -0
  219. package/src/internal/watch-matcher.test.ts +326 -0
  220. package/src/internal/watch-matcher.ts +200 -0
  221. package/src/jsdoc-propagation.test.ts +277 -0
  222. package/src/loader.test.ts +1098 -0
  223. package/src/loader.ts +581 -0
  224. package/src/not-block-onunknown.test.ts +554 -0
  225. package/src/observer-dispatcher.test.ts +939 -0
  226. package/src/observer-dispatcher.ts +290 -0
  227. package/src/per-predicate-typing.test.ts +361 -0
  228. package/src/plugin-merger.test.ts +790 -0
  229. package/src/plugin-merger.ts +598 -0
  230. package/src/plugins/git/README.md +485 -0
  231. package/src/plugins/git/branch-tracker.test.ts +281 -0
  232. package/src/plugins/git/branch-tracker.ts +187 -0
  233. package/src/plugins/git/cwd-extensions.test.ts +230 -0
  234. package/src/plugins/git/cwd-extensions.ts +160 -0
  235. package/src/plugins/git/git-ops.test.ts +397 -0
  236. package/src/plugins/git/git-ops.ts +173 -0
  237. package/src/plugins/git/index.ts +253 -0
  238. package/src/plugins/git/integration.test.ts +705 -0
  239. package/src/plugins/git/predicates.test.ts +1401 -0
  240. package/src/plugins/git/predicates.ts +693 -0
  241. package/src/plugins/git/rules.test.ts +1001 -0
  242. package/src/plugins/git/rules.ts +307 -0
  243. package/src/schema.test.ts +259 -0
  244. package/src/schema.ts +1963 -0
  245. package/src/testing/index.test.ts +1696 -0
  246. package/src/testing/index.ts +1472 -0
@@ -0,0 +1,397 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * Tests for the git-ops helpers (`./git-ops.ts`).
6
+ *
7
+ * Each helper is a thin `ctx.exec` wrapper that collapses all failure
8
+ * modes (non-zero exit, spawn error, thrown exception) to `null`. We
9
+ * pin the exact command-and-args shape each helper emits, the `cwd`
10
+ * routing (default → `ctx.cwd`; explicit arg wins), and the
11
+ * null-on-failure contract.
12
+ *
13
+ * Predicate-layer concerns (`onUnknown`, pattern matching, walker-state
14
+ * short-circuits) live in `./predicates.test.ts` and are deliberately
15
+ * not re-tested here.
16
+ */
17
+
18
+ import assert from "node:assert/strict";
19
+ import { describe, it } from "node:test";
20
+ import type { ExecResult, PredicateContext } from "../../index.ts";
21
+ import {
22
+ getBranch,
23
+ getCommitsAhead,
24
+ getRemoteUrl,
25
+ getStagedChanges,
26
+ getUpstream,
27
+ getWorkingTreeClean,
28
+ } from "./git-ops.ts";
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Context builder
32
+ // ---------------------------------------------------------------------------
33
+
34
+ interface ExecCall {
35
+ cmd: string;
36
+ args: string[];
37
+ cwd?: string | undefined;
38
+ }
39
+
40
+ function makeCtx(
41
+ responses: ReadonlyArray<{
42
+ match: (cmd: string, args: string[]) => boolean;
43
+ result?: ExecResult;
44
+ throwError?: Error;
45
+ }>,
46
+ opts?: { cwd?: string },
47
+ ): { ctx: PredicateContext; execCalls: ExecCall[] } {
48
+ const execCalls: ExecCall[] = [];
49
+ const ctx: PredicateContext = {
50
+ cwd: opts?.cwd ?? "/repo",
51
+ tool: "bash",
52
+ input: { tool: "bash", command: "" },
53
+ agentLoopIndex: 0,
54
+ exec: async (cmd, args, execOpts) => {
55
+ execCalls.push({ cmd, args: [...args], cwd: execOpts?.cwd });
56
+ for (const entry of responses) {
57
+ if (entry.match(cmd, args)) {
58
+ if (entry.throwError) throw entry.throwError;
59
+ if (entry.result) return entry.result;
60
+ }
61
+ }
62
+ throw new Error(`unexpected exec call: ${cmd} ${args.join(" ")}`);
63
+ },
64
+ appendEntry: () => {},
65
+ findEntries: () => [],
66
+ walkerState: { cwd: opts?.cwd ?? "/repo", env: new Map() },
67
+ };
68
+ return { ctx, execCalls };
69
+ }
70
+
71
+ const OK = (stdout: string): ExecResult => ({
72
+ stdout,
73
+ stderr: "",
74
+ exitCode: 0,
75
+ });
76
+ const EXIT = (exitCode: number, stdout = "", stderr = ""): ExecResult => ({
77
+ stdout,
78
+ stderr,
79
+ exitCode,
80
+ });
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // getBranch
84
+ // ---------------------------------------------------------------------------
85
+
86
+ describe("getBranch", () => {
87
+ it("returns trimmed stdout on exit 0", async () => {
88
+ const { ctx, execCalls } = makeCtx([
89
+ {
90
+ match: (c, a) => c === "git" && a[0] === "branch",
91
+ result: OK("feature/foo\n"),
92
+ },
93
+ ]);
94
+ const out = await getBranch(ctx);
95
+ assert.equal(out, "feature/foo");
96
+ assert.deepEqual(execCalls[0], {
97
+ cmd: "git",
98
+ args: ["branch", "--show-current"],
99
+ cwd: "/repo",
100
+ });
101
+ });
102
+
103
+ it("returns null on non-zero exit", async () => {
104
+ const { ctx } = makeCtx([{ match: () => true, result: EXIT(128) }]);
105
+ assert.equal(await getBranch(ctx), null);
106
+ });
107
+
108
+ it("returns null on empty stdout (detached HEAD)", async () => {
109
+ const { ctx } = makeCtx([{ match: () => true, result: OK("\n") }]);
110
+ assert.equal(await getBranch(ctx), null);
111
+ });
112
+
113
+ it("returns null when exec throws", async () => {
114
+ const { ctx } = makeCtx([
115
+ { match: () => true, throwError: new Error("spawn ENOENT") },
116
+ ]);
117
+ assert.equal(await getBranch(ctx), null);
118
+ });
119
+
120
+ it("does NOT consult walkerState.branch (predicate-layer concern)", async () => {
121
+ // Helper always shells out. Walker-state shortcut is the
122
+ // predicate's responsibility. See git-ops.ts file header.
123
+ const { ctx, execCalls } = makeCtx([
124
+ { match: () => true, result: OK("on-disk") },
125
+ ]);
126
+ ctx.walkerState = { cwd: "/repo", env: new Map(), branch: "walker-says" };
127
+ const out = await getBranch(ctx);
128
+ assert.equal(out, "on-disk");
129
+ assert.equal(execCalls.length, 1);
130
+ });
131
+ });
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // getUpstream
135
+ // ---------------------------------------------------------------------------
136
+
137
+ describe("getUpstream", () => {
138
+ it("returns the upstream name on exit 0", async () => {
139
+ const { ctx, execCalls } = makeCtx([
140
+ { match: () => true, result: OK("origin/main\n") },
141
+ ]);
142
+ assert.equal(await getUpstream(ctx), "origin/main");
143
+ assert.deepEqual(execCalls[0]?.args, [
144
+ "rev-parse",
145
+ "--abbrev-ref",
146
+ "@{upstream}",
147
+ ]);
148
+ });
149
+
150
+ it("returns null when no upstream configured (non-zero exit)", async () => {
151
+ const { ctx } = makeCtx([{ match: () => true, result: EXIT(128) }]);
152
+ assert.equal(await getUpstream(ctx), null);
153
+ });
154
+ });
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // getCommitsAhead
158
+ // ---------------------------------------------------------------------------
159
+
160
+ describe("getCommitsAhead", () => {
161
+ it("parses rev-list count on exit 0", async () => {
162
+ const { ctx, execCalls } = makeCtx([
163
+ { match: () => true, result: OK("3\n") },
164
+ ]);
165
+ assert.equal(await getCommitsAhead(ctx), 3);
166
+ assert.deepEqual(execCalls[0]?.args, [
167
+ "rev-list",
168
+ "--count",
169
+ "@{upstream}..HEAD",
170
+ ]);
171
+ });
172
+
173
+ it("accepts a custom wrt reference", async () => {
174
+ const { ctx, execCalls } = makeCtx([
175
+ { match: () => true, result: OK("1") },
176
+ ]);
177
+ await getCommitsAhead(ctx, "origin/main");
178
+ assert.deepEqual(execCalls[0]?.args, [
179
+ "rev-list",
180
+ "--count",
181
+ "origin/main..HEAD",
182
+ ]);
183
+ });
184
+
185
+ it("returns null when rev-list fails (e.g. wrt not resolvable)", async () => {
186
+ const { ctx } = makeCtx([{ match: () => true, result: EXIT(128) }]);
187
+ assert.equal(await getCommitsAhead(ctx, "nonexistent/ref"), null);
188
+ });
189
+
190
+ it("returns null on non-numeric stdout (corrupt output)", async () => {
191
+ const { ctx } = makeCtx([{ match: () => true, result: OK("NaN-ish\n") }]);
192
+ assert.equal(await getCommitsAhead(ctx), null);
193
+ });
194
+ });
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // getStagedChanges
198
+ // ---------------------------------------------------------------------------
199
+
200
+ describe("getStagedChanges", () => {
201
+ it("returns false when `git diff --cached --quiet` exits 0 (clean index)", async () => {
202
+ const { ctx, execCalls } = makeCtx([
203
+ { match: () => true, result: EXIT(0) },
204
+ ]);
205
+ assert.equal(await getStagedChanges(ctx), false);
206
+ assert.deepEqual(execCalls[0]?.args, ["diff", "--cached", "--quiet"]);
207
+ });
208
+
209
+ it("returns true when exit 1 (staged changes present)", async () => {
210
+ const { ctx } = makeCtx([{ match: () => true, result: EXIT(1) }]);
211
+ assert.equal(await getStagedChanges(ctx), true);
212
+ });
213
+
214
+ it("returns null on unexpected exit codes", async () => {
215
+ const { ctx } = makeCtx([{ match: () => true, result: EXIT(128) }]);
216
+ assert.equal(await getStagedChanges(ctx), null);
217
+ });
218
+
219
+ it("returns null when exec throws", async () => {
220
+ const { ctx } = makeCtx([
221
+ { match: () => true, throwError: new Error("boom") },
222
+ ]);
223
+ assert.equal(await getStagedChanges(ctx), null);
224
+ });
225
+ });
226
+
227
+ // ---------------------------------------------------------------------------
228
+ // getWorkingTreeClean
229
+ // ---------------------------------------------------------------------------
230
+
231
+ describe("getWorkingTreeClean", () => {
232
+ it("returns true on empty stdout (clean tree)", async () => {
233
+ const { ctx, execCalls } = makeCtx([{ match: () => true, result: OK("") }]);
234
+ assert.equal(await getWorkingTreeClean(ctx), true);
235
+ assert.deepEqual(execCalls[0]?.args, ["status", "--porcelain"]);
236
+ });
237
+
238
+ it("returns false when stdout is non-empty (dirty tree)", async () => {
239
+ const { ctx } = makeCtx([
240
+ { match: () => true, result: OK(" M README.md\n") },
241
+ ]);
242
+ assert.equal(await getWorkingTreeClean(ctx), false);
243
+ });
244
+
245
+ it("returns null on non-zero exit", async () => {
246
+ const { ctx } = makeCtx([{ match: () => true, result: EXIT(128) }]);
247
+ assert.equal(await getWorkingTreeClean(ctx), null);
248
+ });
249
+ });
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // getRemoteUrl
253
+ // ---------------------------------------------------------------------------
254
+
255
+ describe("getRemoteUrl", () => {
256
+ it("returns the origin URL on exit 0", async () => {
257
+ const { ctx, execCalls } = makeCtx([
258
+ {
259
+ match: () => true,
260
+ result: OK("git@github.com:org/repo.git\n"),
261
+ },
262
+ ]);
263
+ assert.equal(await getRemoteUrl(ctx), "git@github.com:org/repo.git");
264
+ assert.deepEqual(execCalls[0]?.args, [
265
+ "config",
266
+ "--get",
267
+ "remote.origin.url",
268
+ ]);
269
+ });
270
+
271
+ it("returns null when no origin configured", async () => {
272
+ const { ctx } = makeCtx([{ match: () => true, result: EXIT(1) }]);
273
+ assert.equal(await getRemoteUrl(ctx), null);
274
+ });
275
+
276
+ it("returns null on empty stdout", async () => {
277
+ const { ctx } = makeCtx([{ match: () => true, result: OK("\n") }]);
278
+ assert.equal(await getRemoteUrl(ctx), null);
279
+ });
280
+ });
281
+
282
+ // ---------------------------------------------------------------------------
283
+ // Shared cwd-routing contract (parameterized across all helpers)
284
+ // ---------------------------------------------------------------------------
285
+
286
+ /**
287
+ * Every helper accepts an optional trailing `cwd` override. When omitted,
288
+ * it routes to `ctx.cwd`; when passed, it routes to the explicit value.
289
+ * These two axes used to live as ad-hoc tests inside each helper's
290
+ * describe block; consolidating here guarantees the contract is uniform
291
+ * and prevents one helper's routing from drifting silently.
292
+ */
293
+ const cwdRoutingCases: ReadonlyArray<{
294
+ name: string;
295
+ fixture: ExecResult;
296
+ call: (ctx: PredicateContext, cwd?: string) => Promise<unknown>;
297
+ }> = [
298
+ {
299
+ name: "getBranch",
300
+ fixture: OK("main"),
301
+ call: (c, cwd) => getBranch(c, cwd),
302
+ },
303
+ {
304
+ name: "getUpstream",
305
+ fixture: OK("origin/main"),
306
+ call: (c, cwd) => getUpstream(c, cwd),
307
+ },
308
+ {
309
+ name: "getCommitsAhead",
310
+ fixture: OK("0"),
311
+ call: (c, cwd) => getCommitsAhead(c, "origin/main", cwd),
312
+ },
313
+ {
314
+ name: "getStagedChanges",
315
+ fixture: EXIT(0),
316
+ call: (c, cwd) => getStagedChanges(c, cwd),
317
+ },
318
+ {
319
+ name: "getWorkingTreeClean",
320
+ fixture: OK(""),
321
+ call: (c, cwd) => getWorkingTreeClean(c, cwd),
322
+ },
323
+ {
324
+ name: "getRemoteUrl",
325
+ fixture: OK("git@github.com:org/repo.git"),
326
+ call: (c, cwd) => getRemoteUrl(c, cwd),
327
+ },
328
+ ];
329
+
330
+ describe("cwd routing (shared across all helpers)", () => {
331
+ for (const { name, fixture, call } of cwdRoutingCases) {
332
+ it(`${name}: routes to ctx.cwd by default (omitted cwd arg)`, async () => {
333
+ const { ctx, execCalls } = makeCtx([
334
+ { match: () => true, result: fixture },
335
+ ]);
336
+ await call(ctx);
337
+ assert.equal(execCalls[0]?.cwd, "/repo");
338
+ });
339
+
340
+ it(`${name}: routes to an explicit cwd override when given`, async () => {
341
+ const { ctx, execCalls } = makeCtx([
342
+ { match: () => true, result: fixture },
343
+ ]);
344
+ await call(ctx, "/other");
345
+ assert.equal(execCalls[0]?.cwd, "/other");
346
+ });
347
+ }
348
+ });
349
+
350
+ // ---------------------------------------------------------------------------
351
+ // Downstream-composition smoke test
352
+ // ---------------------------------------------------------------------------
353
+
354
+ describe("downstream-composition: iterating per-directory", () => {
355
+ it("per-dir iteration works as a `cr --all`-style multi-package scan", async () => {
356
+ // This is the motivating use case for exporting the helpers:
357
+ // downstream plugins (e.g. RDS) query git state per subpackage
358
+ // dir without re-implementing shell invocations. Pin the shape
359
+ // so future refactors don't accidentally break the contract.
360
+ const perDirUpstreams = new Map<string, string>([
361
+ ["/ws/pkg-a", "origin/main"],
362
+ ["/ws/pkg-b", "origin/feature-x"],
363
+ ]);
364
+ const { ctx, execCalls } = makeCtx([
365
+ {
366
+ match: (c, a) => c === "git" && a[0] === "rev-parse",
367
+ result: OK(""), // overridden below per-cwd
368
+ },
369
+ ]);
370
+ // Replace exec with a cwd-aware stub.
371
+ ctx.exec = async (cmd, args, opts) => {
372
+ execCalls.push({ cmd, args: [...args], cwd: opts?.cwd });
373
+ if (cmd === "git" && args[0] === "rev-parse") {
374
+ const u = perDirUpstreams.get(opts?.cwd ?? "");
375
+ if (u !== undefined)
376
+ return { stdout: `${u}\n`, stderr: "", exitCode: 0 };
377
+ }
378
+ return { stdout: "", stderr: "", exitCode: 128 };
379
+ };
380
+
381
+ const results: Array<{ dir: string; upstream: string | null }> = [];
382
+ for (const dir of ["/ws/pkg-a", "/ws/pkg-b", "/ws/no-git"]) {
383
+ results.push({ dir, upstream: await getUpstream(ctx, dir) });
384
+ }
385
+
386
+ assert.deepEqual(results, [
387
+ { dir: "/ws/pkg-a", upstream: "origin/main" },
388
+ { dir: "/ws/pkg-b", upstream: "origin/feature-x" },
389
+ { dir: "/ws/no-git", upstream: null },
390
+ ]);
391
+ // Three calls, one per dir, with the right cwd.
392
+ assert.equal(execCalls.length, 3);
393
+ assert.equal(execCalls[0]?.cwd, "/ws/pkg-a");
394
+ assert.equal(execCalls[1]?.cwd, "/ws/pkg-b");
395
+ assert.equal(execCalls[2]?.cwd, "/ws/no-git");
396
+ });
397
+ });
@@ -0,0 +1,173 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * Raw git query helpers for the git plugin — reusable functions that
6
+ * shell out to git and return trimmed raw data (or `null` on any
7
+ * failure). Exposed so downstream plugins can compose per-directory
8
+ * scans without re-implementing the shell invocations, and so the
9
+ * predicates in this plugin stay thin pattern-matching wrappers
10
+ * around the helpers.
11
+ *
12
+ * Each helper:
13
+ *
14
+ * - takes `(ctx, cwd?)`; `cwd` defaults to `ctx.cwd`, letting
15
+ * callers iterate over per-subpackage git dirs (common in
16
+ * multi-package workspace contexts like `cr --all`).
17
+ * - returns the raw data on success, `null` on any failure
18
+ * (non-zero exit, spawn error, exception thrown inside
19
+ * `ctx.exec`). Callers layer their own "what does null mean"
20
+ * policy — predicates apply `onUnknown`; custom logic can
21
+ * inspect the null directly.
22
+ * - relies on the evaluator's per-tool_call `exec` memoization
23
+ * (`(cmd, args, cwd)` tuple) so multiple helpers reading the
24
+ * same repo state don't re-fork git.
25
+ *
26
+ * ## Consumer note
27
+ *
28
+ * The helpers return **raw query results** and are INTENTIONALLY free
29
+ * of the `onUnknown` / pattern-matching layer. That layer lives in
30
+ * `predicates.ts`. If you want predicate-style semantics, call the
31
+ * matching `when.*` handler (also re-exported from the plugin index)
32
+ * rather than the helper.
33
+ *
34
+ * ## Branch caveat
35
+ *
36
+ * {@link getBranch} intentionally DOES NOT consult `walkerState.branch`
37
+ * — the walker-state short-circuit is a predicate-layer concern (see
38
+ * the `branch` predicate's JSDoc for the three-way tracker
39
+ * discrimination). Downstream plugins iterating per-directory usually
40
+ * care about the on-disk branch at each directory, not the
41
+ * walker-tracked branch of the current bash chain at `ctx.cwd`; the
42
+ * helper therefore always shells out.
43
+ */
44
+
45
+ import type { PredicateContext } from "../../schema.ts";
46
+
47
+ /**
48
+ * Run a git command in the given cwd and return its trimmed stdout on
49
+ * exit 0, or `null` on any failure. Thin wrapper used by every helper
50
+ * here so failure modes (non-zero exit, spawn error, exception)
51
+ * collapse to a uniform `null`.
52
+ */
53
+ async function tryGit(
54
+ ctx: PredicateContext,
55
+ args: readonly string[],
56
+ cwd?: string,
57
+ ): Promise<string | null> {
58
+ try {
59
+ const opts = cwd !== undefined ? { cwd } : { cwd: ctx.cwd };
60
+ const res = await ctx.exec("git", [...args], opts);
61
+ if (res.exitCode !== 0) return null;
62
+ return res.stdout.trim();
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * `git branch --show-current` at `cwd` (default: `ctx.cwd`). Returns
70
+ * the current branch name, or `null` when the command fails OR stdout
71
+ * is empty (detached HEAD).
72
+ *
73
+ * Does NOT consult `ctx.walkerState.branch` — see file header
74
+ * "Branch caveat" for why. Predicates that need the walker-state
75
+ * short-circuit should use the `branch` predicate handler instead.
76
+ */
77
+ export async function getBranch(
78
+ ctx: PredicateContext,
79
+ cwd?: string,
80
+ ): Promise<string | null> {
81
+ const out = await tryGit(ctx, ["branch", "--show-current"], cwd);
82
+ if (out === null || out.length === 0) return null;
83
+ return out;
84
+ }
85
+
86
+ /**
87
+ * `git rev-parse --abbrev-ref @{upstream}` at `cwd` (default:
88
+ * `ctx.cwd`). Returns the tracking branch name (e.g. `origin/main`),
89
+ * or `null` when no upstream is configured or the command fails.
90
+ */
91
+ export async function getUpstream(
92
+ ctx: PredicateContext,
93
+ cwd?: string,
94
+ ): Promise<string | null> {
95
+ const out = await tryGit(
96
+ ctx,
97
+ ["rev-parse", "--abbrev-ref", "@{upstream}"],
98
+ cwd,
99
+ );
100
+ if (out === null || out.length === 0) return null;
101
+ return out;
102
+ }
103
+
104
+ /**
105
+ * `git rev-list --count <wrt>..HEAD` at `cwd` (default: `ctx.cwd`).
106
+ * Returns the number of commits HEAD is ahead of `wrt`, or `null`
107
+ * when the command fails (e.g. `wrt` doesn't resolve, detached HEAD,
108
+ * not a repo).
109
+ *
110
+ * `wrt` defaults to `@{upstream}`. Pass a specific ref like
111
+ * `"origin/main"` when the upstream configuration isn't guaranteed.
112
+ */
113
+ export async function getCommitsAhead(
114
+ ctx: PredicateContext,
115
+ wrt: string = "@{upstream}",
116
+ cwd?: string,
117
+ ): Promise<number | null> {
118
+ const out = await tryGit(ctx, ["rev-list", "--count", `${wrt}..HEAD`], cwd);
119
+ if (out === null) return null;
120
+ const count = Number.parseInt(out, 10);
121
+ return Number.isFinite(count) ? count : null;
122
+ }
123
+
124
+ /**
125
+ * `git diff --cached --quiet` at `cwd` (default: `ctx.cwd`). Exit
126
+ * code discrimination:
127
+ *
128
+ * - `0` → no staged changes → returns `false`
129
+ * - `1` → staged changes exist → returns `true`
130
+ * - anything else (spawn error, weird exit) → returns `null`
131
+ */
132
+ export async function getStagedChanges(
133
+ ctx: PredicateContext,
134
+ cwd?: string,
135
+ ): Promise<boolean | null> {
136
+ try {
137
+ const opts = cwd !== undefined ? { cwd } : { cwd: ctx.cwd };
138
+ const res = await ctx.exec("git", ["diff", "--cached", "--quiet"], opts);
139
+ if (res.exitCode === 0) return false;
140
+ if (res.exitCode === 1) return true;
141
+ return null;
142
+ } catch {
143
+ return null;
144
+ }
145
+ }
146
+
147
+ /**
148
+ * `git status --porcelain` at `cwd` (default: `ctx.cwd`). Returns
149
+ * `true` when the working tree is clean (empty output), `false`
150
+ * when dirty, `null` on any command failure.
151
+ */
152
+ export async function getWorkingTreeClean(
153
+ ctx: PredicateContext,
154
+ cwd?: string,
155
+ ): Promise<boolean | null> {
156
+ const out = await tryGit(ctx, ["status", "--porcelain"], cwd);
157
+ if (out === null) return null;
158
+ return out.length === 0;
159
+ }
160
+
161
+ /**
162
+ * `git config --get remote.origin.url` at `cwd` (default: `ctx.cwd`).
163
+ * Returns the origin URL string, or `null` when no origin is
164
+ * configured or the command fails.
165
+ */
166
+ export async function getRemoteUrl(
167
+ ctx: PredicateContext,
168
+ cwd?: string,
169
+ ): Promise<string | null> {
170
+ const out = await tryGit(ctx, ["config", "--get", "remote.origin.url"], cwd);
171
+ if (out === null || out.length === 0) return null;
172
+ return out;
173
+ }