@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,281 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * Tests for the git plugin's branch tracker (`./branch-tracker.ts`).
6
+ *
7
+ * These tests exercise the tracker directly via `walk()` from
8
+ * unbash-walker, with small hand-crafted bash scripts. They pin the
9
+ * modifier's semantics independently of the evaluator / predicate
10
+ * wiring. Evaluator-level behaviour is covered by `./integration.test.ts`.
11
+ */
12
+
13
+ import assert from "node:assert/strict";
14
+ import { describe, it } from "node:test";
15
+ import {
16
+ type CommandRef,
17
+ extractAllCommandsFromAST,
18
+ getBasename,
19
+ getCommandArgs,
20
+ parse as parseBash,
21
+ walk,
22
+ } from "@cad0p/unbash-walker";
23
+ import { branchTracker } from "./branch-tracker.ts";
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Harness
27
+ // ---------------------------------------------------------------------------
28
+
29
+ interface WalkedCommand {
30
+ ref: CommandRef;
31
+ text: string;
32
+ branch: string;
33
+ }
34
+
35
+ /**
36
+ * Walk `script` with only the branch tracker and return one entry per
37
+ * extracted command ref with its stringified form and snapshot branch.
38
+ *
39
+ * `initialBranch` seeds the tracker's starting value so tests can pin
40
+ * both "checkout advances" and "other subcommands preserve" shapes.
41
+ */
42
+ function walkBranches(script: string, initialBranch: string): WalkedCommand[] {
43
+ const ast = parseBash(script);
44
+ const refs = extractAllCommandsFromAST(ast, script);
45
+ const result = walk(
46
+ ast,
47
+ { branch: initialBranch },
48
+ { branch: branchTracker },
49
+ refs,
50
+ );
51
+ return refs.map((ref) => {
52
+ const snap = result.get(ref);
53
+ return {
54
+ ref,
55
+ text: `${getBasename(ref)} ${getCommandArgs(ref).join(" ")}`.trim(),
56
+ branch: (snap?.branch as string | undefined) ?? "<missing>",
57
+ };
58
+ });
59
+ }
60
+
61
+ /** Find the recorded branch for the first command whose text starts with `prefix`. */
62
+ function branchOf(walked: WalkedCommand[], prefix: string): string {
63
+ const hit = walked.find((w) => w.text.startsWith(prefix));
64
+ if (!hit) {
65
+ throw new Error(
66
+ `no command starting with "${prefix}" in walked set: ` +
67
+ walked.map((w) => w.text).join(" | "),
68
+ );
69
+ }
70
+ return hit.branch;
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // Sequential subcommand advancement
75
+ // ---------------------------------------------------------------------------
76
+
77
+ describe("branchTracker: checkout / switch advance the branch", () => {
78
+ it("`git checkout feat` advances to feat for subsequent commands", () => {
79
+ const walked = walkBranches("git checkout feat && git status", "main");
80
+ // The checkout ITSELF is recorded with the pre-checkout branch -
81
+ // same convention as cd in cwdTracker: a sequential modifier's
82
+ // effect only propagates to SUBSEQUENT siblings.
83
+ assert.equal(branchOf(walked, "git checkout"), "main");
84
+ assert.equal(branchOf(walked, "git status"), "feat");
85
+ });
86
+
87
+ it("`git switch main` advances to main", () => {
88
+ const walked = walkBranches("git switch main && git status", "feat");
89
+ assert.equal(branchOf(walked, "git status"), "main");
90
+ });
91
+
92
+ it("`git checkout -b new-branch` advances to new-branch", () => {
93
+ const walked = walkBranches(
94
+ "git checkout -b new-branch && git push",
95
+ "main",
96
+ );
97
+ assert.equal(branchOf(walked, "git push"), "new-branch");
98
+ });
99
+
100
+ it("`git switch -c other-branch` advances to other-branch", () => {
101
+ const walked = walkBranches(
102
+ "git switch -c other-branch && git commit",
103
+ "main",
104
+ );
105
+ assert.equal(branchOf(walked, "git commit"), "other-branch");
106
+ });
107
+
108
+ it("chained checkouts - final state wins", () => {
109
+ const walked = walkBranches(
110
+ "git checkout A && git checkout B && git status",
111
+ "main",
112
+ );
113
+ assert.equal(branchOf(walked, "git status"), "B");
114
+ });
115
+ });
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Non-advancing subcommands
119
+ // ---------------------------------------------------------------------------
120
+
121
+ describe("branchTracker: non-checkout subcommands preserve branch", () => {
122
+ it("`git commit` does not change the branch", () => {
123
+ const walked = walkBranches(
124
+ "git status && git commit -m 'x' && git log",
125
+ "feat",
126
+ );
127
+ assert.equal(branchOf(walked, "git status"), "feat");
128
+ assert.equal(branchOf(walked, "git commit"), "feat");
129
+ assert.equal(branchOf(walked, "git log"), "feat");
130
+ });
131
+
132
+ it("`git` with no subcommand does not change the branch", () => {
133
+ const walked = walkBranches("git && git commit", "feat");
134
+ assert.equal(branchOf(walked, "git commit"), "feat");
135
+ });
136
+
137
+ it('`git commit -m "$MSG"` (non-static args) does not collapse to unknown', () => {
138
+ // Regression guard: the modifier must return `current` (not
139
+ // `undefined`) when the subcommand isn't a branch change. A
140
+ // naive `isStaticallyResolvable(args[1])` check on `-m` would
141
+ // work, but a non-static later arg must not poison the state.
142
+ const walked = walkBranches('git commit -m "$MSG" && git push', "feat");
143
+ assert.equal(branchOf(walked, "git push"), "feat");
144
+ });
145
+ });
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // Unknown collapse on dynamic targets
149
+ // ---------------------------------------------------------------------------
150
+
151
+ describe("branchTracker: non-static targets collapse to unknown", () => {
152
+ it("`git checkout $VAR` produces unknown for subsequent commands", () => {
153
+ const walked = walkBranches("git checkout $VAR && git status", "main");
154
+ assert.equal(branchOf(walked, "git status"), "unknown");
155
+ });
156
+
157
+ it("`git checkout -b $NEW` produces unknown", () => {
158
+ const walked = walkBranches("git checkout -b $NEW && git status", "main");
159
+ assert.equal(branchOf(walked, "git status"), "unknown");
160
+ });
161
+
162
+ it('`git switch "$BR"` (double-quoted with expansion) produces unknown', () => {
163
+ const walked = walkBranches('git switch "$BR" && git log', "main");
164
+ assert.equal(branchOf(walked, "git log"), "unknown");
165
+ });
166
+ });
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Subshell isolation
170
+ // ---------------------------------------------------------------------------
171
+
172
+ describe("branchTracker: subshell isolation", () => {
173
+ it("`(git checkout A)` does not escape the subshell", () => {
174
+ const walked = walkBranches("(git checkout A) && git status", "main");
175
+ // `git status` runs after the subshell in the OUTER scope - branch
176
+ // should still be the pre-subshell value.
177
+ assert.equal(branchOf(walked, "git status"), "main");
178
+ });
179
+
180
+ it("subshell body sees the outer branch value", () => {
181
+ const walked = walkBranches(
182
+ "git checkout feat && (git log && git status)",
183
+ "main",
184
+ );
185
+ // Both commands inside the subshell see `feat` (the propagated
186
+ // outer value at the point the subshell starts).
187
+ assert.equal(branchOf(walked, "git log"), "feat");
188
+ assert.equal(branchOf(walked, "git status"), "feat");
189
+ });
190
+ });
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Pre-subcommand flags
194
+ // ---------------------------------------------------------------------------
195
+
196
+ describe("branchTracker: pre-subcommand flags", () => {
197
+ it("`git -C /other checkout feat && git commit` advances branch to feat", () => {
198
+ const walked = walkBranches(
199
+ "git -C /other checkout feat && git commit -m 'x'",
200
+ "main",
201
+ );
202
+ assert.equal(branchOf(walked, "git commit"), "feat");
203
+ });
204
+
205
+ it("`git -c key=val checkout feat` advances (-c consumes next arg)", () => {
206
+ const walked = walkBranches(
207
+ "git -c color.ui=never checkout feat && git commit",
208
+ "main",
209
+ );
210
+ assert.equal(branchOf(walked, "git commit"), "feat");
211
+ });
212
+
213
+ it("`git --no-pager checkout feat` advances (long flag without value)", () => {
214
+ const walked = walkBranches(
215
+ "git --no-pager checkout feat && git commit",
216
+ "main",
217
+ );
218
+ assert.equal(branchOf(walked, "git commit"), "feat");
219
+ });
220
+
221
+ it("`git --git-dir=/g checkout feat` advances (long flag with =value)", () => {
222
+ const walked = walkBranches(
223
+ "git --git-dir=/g checkout feat && git commit",
224
+ "main",
225
+ );
226
+ assert.equal(branchOf(walked, "git commit"), "feat");
227
+ });
228
+
229
+ it("`git -C /other checkout -b new-branch` advances via -b path", () => {
230
+ const walked = walkBranches(
231
+ "git -C /other checkout -b new-branch && git commit",
232
+ "main",
233
+ );
234
+ assert.equal(branchOf(walked, "git commit"), "new-branch");
235
+ });
236
+ });
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // Pathological cases (documented corners)
240
+ // ---------------------------------------------------------------------------
241
+
242
+ describe("branchTracker: pathological cases (documented corners)", () => {
243
+ it("`git checkout --help` leaves branch unchanged", () => {
244
+ const walked = walkBranches("git checkout --help && git status", "main");
245
+ assert.equal(branchOf(walked, "git status"), "main");
246
+ });
247
+
248
+ it("`git checkout .` sets branch to `.` (documented false-positive - path reset)", () => {
249
+ // Real git interprets `.` as "reset path", not a branch. Our tracker
250
+ // treats the first static arg after checkout as the branch name,
251
+ // which is wrong in this corner. Documented accepted false-positive.
252
+ const walked = walkBranches("git checkout . && git status", "main");
253
+ assert.equal(branchOf(walked, "git status"), ".");
254
+ });
255
+
256
+ it("`git checkout -- file.txt` sets branch to `--` (documented false-positive)", () => {
257
+ const walked = walkBranches(
258
+ "git checkout -- file.txt && git status",
259
+ "main",
260
+ );
261
+ assert.equal(branchOf(walked, "git status"), "--");
262
+ });
263
+ });
264
+
265
+ // ---------------------------------------------------------------------------
266
+ // Motivating case: `git checkout A && git commit`
267
+ // ---------------------------------------------------------------------------
268
+
269
+ describe("branchTracker: the ADR's motivating case", () => {
270
+ it("`git checkout main && git commit` reports branch=main on the commit", () => {
271
+ // This is the KEY test. A session-level "current branch" check
272
+ // would see the PRE-checkout branch and miss this case. The
273
+ // walker-backed tracker folds the checkout into the commit's
274
+ // branch snapshot, so a `when.branch: /^main$/` predicate fires.
275
+ const walked = walkBranches(
276
+ "git checkout main && git commit -m 'x'",
277
+ "feat",
278
+ );
279
+ assert.equal(branchOf(walked, "git commit"), "main");
280
+ });
281
+ });
@@ -0,0 +1,187 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Part of pi-steering.
3
+
4
+ /**
5
+ * `branch` tracker for the git plugin.
6
+ *
7
+ * Models the effective git branch AT EACH extracted command within a
8
+ * single `tool_call`. The motivating case is the ADR's
9
+ * `git checkout A && git commit` example: a session-level
10
+ * "current branch" query would miss the mid-command checkout and allow
11
+ * the commit on what looks like a non-protected branch. A walker-backed
12
+ * tracker folds the `git checkout` delta into the branch value seen by
13
+ * the subsequent `git commit`, so a rule gated on
14
+ * `when: { branch: /^main$/ }` correctly fires if the chain ends up on
15
+ * `main`.
16
+ *
17
+ * Semantics:
18
+ * - `git checkout X` - sequential; branch becomes `X` for the rest
19
+ * of this scope.
20
+ * - `git switch X` - sequential; same as above.
21
+ * - `git checkout -b NEW` / `git switch -c NEW` - sequential; branch
22
+ * becomes `NEW`. The `-b` / `-c` token is consumed; the following
23
+ * argument is the new branch name.
24
+ * - Anything else under `git` (including `git commit`, `git status`,
25
+ * `git checkout -- FILE`) leaves the branch unchanged. Returning
26
+ * `current` (not `undefined`) is important: a `git commit` that
27
+ * happens to carry a non-static arg (e.g. `-m "$MSG"`) must NOT
28
+ * collapse branch state to `unknown`.
29
+ * - Non-static branch names (`git checkout $BR`, `git checkout "$BR"`)
30
+ * return `undefined`, which the walker translates to the tracker's
31
+ * `unknown` sentinel. Predicates then apply their `onUnknown`
32
+ * policy (default `"block"` - see the `branch` predicate handler).
33
+ *
34
+ * Subshells: `isolated` - a `(git checkout X)` inside parens cannot
35
+ * change the enclosing shell's branch (real git semantics - the
36
+ * subshell has no effect on the parent's working tree state for this
37
+ * dimension).
38
+ *
39
+ * The tracker distinguishes two "I don't know the branch" states so
40
+ * predicates can apply the correct policy:
41
+ *
42
+ * - `initial: NO_CHECKOUT_IN_CHAIN` - no branch-changing modifier
43
+ * has fired in this ref's scope. The plugin cannot synchronously
44
+ * know the session's current branch at construction time; the
45
+ * `branch` predicate handler shells out via `ctx.exec("git",
46
+ * ["branch", "--show-current"])` to learn it. (A session-start
47
+ * prefetch is a reasonable future optimization but out of scope
48
+ * here.)
49
+ * - `unknown: "unknown"` - a modifier FIRED but couldn't resolve
50
+ * statically (e.g. `git checkout $VAR`). The predicate must NOT
51
+ * shell out in this case: `git branch --show-current` would
52
+ * return the PRE-checkout branch and silently defeat the
53
+ * walker's tracking. Predicates apply their `onUnknown` policy
54
+ * instead (default `"block"` -> fail-closed).
55
+ *
56
+ * The `NO_CHECKOUT_IN_CHAIN` sentinel is chosen to be a string that
57
+ * cannot occur as a real git branch name (git refuses refs containing
58
+ * `:`). Exported so tests and plugin authors can reference it; it
59
+ * should never be constructed ad-hoc.
60
+ *
61
+ * ## Note for plugin authors
62
+ *
63
+ * This tracker is a canonical example of the strict Tracker contract -
64
+ * unresolvable modifier targets return `undefined`, NOT `current`. The
65
+ * built-in `cwdTracker.cd` modifier is a documented Phase-1 exception;
66
+ * do not copy its "return current" shortcut in new trackers.
67
+ */
68
+
69
+ import {
70
+ isStaticallyResolvable,
71
+ type Modifier,
72
+ type Tracker,
73
+ } from "@cad0p/unbash-walker";
74
+
75
+ /**
76
+ * Sentinel value for `branchTracker.initial` - marks "no branch-
77
+ * changing modifier has fired in this ref's scope yet". Distinct
78
+ * from the `"unknown"` sentinel (which marks "a modifier DID fire
79
+ * but couldn't resolve statically"). See the file JSDoc for why the
80
+ * distinction matters to the `branch` predicate's exec fallback
81
+ * decision. The colon is chosen because git rejects it in branch
82
+ * names, so this value cannot collide with a real branch.
83
+ */
84
+ export const NO_CHECKOUT_IN_CHAIN = "pi-steering:no-checkout-in-chain";
85
+
86
+ /** Branch-changing git subcommands. */
87
+ const CHECKOUT_SUBCOMMANDS = new Set(["checkout", "switch"]);
88
+
89
+ /** `git checkout -b NEW` / `git switch -c NEW` - new-branch flags. */
90
+ const NEW_BRANCH_FLAGS = new Set(["-b", "-c"]);
91
+
92
+ /**
93
+ * Sequential modifier that applies to every `git`-basename command.
94
+ *
95
+ * Returns the new branch for `checkout` / `switch` invocations;
96
+ * returns `current` unchanged for every other git subcommand (so
97
+ * `git commit -m "x"` does NOT collapse the branch to `unknown`);
98
+ * returns `undefined` when the target branch name can't be resolved
99
+ * statically.
100
+ *
101
+ * Pre-subcommand flags are skipped before we look at the subcommand
102
+ * token. Git accepts `git -C PATH`, `git -c KEY=VAL`, `git --no-pager`,
103
+ * `git --git-dir=/x`, `git --work-tree=/y`, `git --paginate`, etc.
104
+ * before the subcommand. Without this skip, `git -C /other checkout
105
+ * feat` would look at the `-C` flag as the subcommand, miss the
106
+ * checkout, and silently bypass `no-main-commit`. This mirrors the
107
+ * `applyGitCwd` flag scanner in `unbash-walker/src/trackers/cwd.ts`:
108
+ * only `-C <path>` and `-c <key=value>` consume an additional token;
109
+ * every other flag form (short cluster, long with or without attached
110
+ * value) is a single token.
111
+ */
112
+ const gitBranchModifier: Modifier<string> = {
113
+ scope: "sequential",
114
+ apply: (args, current) => {
115
+ // Scan past pre-subcommand flags to find the real subcommand.
116
+ // Known value-consuming flags: `-C <path>`, `-c <key=val>`.
117
+ // Others (`--no-pager`, `--git-dir=/x`, `--work-tree=/y`,
118
+ // `--paginate`, `-p`, etc.) are single tokens.
119
+ let i = 0;
120
+ while (i < args.length) {
121
+ const tokWord = args[i];
122
+ const tok = tokWord?.value ?? tokWord?.text ?? "";
123
+ if (!tok.startsWith("-")) break; // found the subcommand
124
+ if (tok === "-C" || tok === "-c") {
125
+ i += 2;
126
+ continue;
127
+ }
128
+ i++; // --long-flag, --long=value, or -shortcluster
129
+ }
130
+
131
+ // No subcommand - e.g. bare `git` or `git --help`. Branch unchanged.
132
+ const subcmdWord = args[i];
133
+ if (!subcmdWord) return current;
134
+ const subcmd = subcmdWord.value ?? subcmdWord.text;
135
+ if (!subcmd || !CHECKOUT_SUBCOMMANDS.has(subcmd)) return current;
136
+
137
+ // `git checkout` / `git switch` with no further args: also a
138
+ // help / error case. Leave branch alone.
139
+ const firstArgWord = args[i + 1];
140
+ if (!firstArgWord) return current;
141
+ const firstArg = firstArgWord.value ?? firstArgWord.text;
142
+ if (firstArg === undefined) return current;
143
+
144
+ // `git checkout --help` / `git checkout -h` - help request, not a
145
+ // branch change. Every other unrecognised flag form (e.g. `--`, `.`,
146
+ // `--force`) still flows through the default branch-name path and
147
+ // is accepted as a documented false-positive (see test cases).
148
+ if (firstArg === "--help" || firstArg === "-h") return current;
149
+
150
+ // `git checkout -b NEW` / `git switch -c NEW` - the branch
151
+ // argument is at position i + 2.
152
+ if (NEW_BRANCH_FLAGS.has(firstArg)) {
153
+ const newBranchWord = args[i + 2];
154
+ if (!newBranchWord) return current;
155
+ if (!isStaticallyResolvable(newBranchWord)) return undefined;
156
+ const newBranch = newBranchWord.value ?? newBranchWord.text;
157
+ return newBranch === undefined ? current : newBranch;
158
+ }
159
+
160
+ // Plain `git checkout X` / `git switch X`. Note: `git checkout --
161
+ // FILE` (restore path) is NOT a branch change, but the first arg
162
+ // `--` happens to be statically resolvable AND `!= undefined`.
163
+ // That's a false-positive corner we accept for Phase 4 - agents
164
+ // rarely emit the `--` separator form, and the alternative
165
+ // (hard-coding a `--` check) brittles against other pathspec
166
+ // conventions. Follow-up if it bites.
167
+ if (!isStaticallyResolvable(firstArgWord)) return undefined;
168
+ return firstArg;
169
+ },
170
+ };
171
+
172
+ /**
173
+ * The branch tracker.
174
+ *
175
+ * Registered by the git plugin under `trackers.branch`. Walker-merged
176
+ * with any future plugin wanting to extend branch semantics (though
177
+ * the tracker name `branch` is expected to stay owned by the git
178
+ * plugin - name collisions are a hard error per the plugin-merger).
179
+ */
180
+ export const branchTracker: Tracker<string> = {
181
+ initial: NO_CHECKOUT_IN_CHAIN,
182
+ unknown: "unknown",
183
+ modifiers: {
184
+ git: gitBranchModifier,
185
+ },
186
+ subshellSemantics: "isolated",
187
+ };