@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.
- package/CHANGELOG.md +36 -0
- package/LICENSE +29 -0
- package/README.md +920 -0
- package/dist/__test-helpers__.d.ts +95 -0
- package/dist/__test-helpers__.d.ts.map +1 -0
- package/dist/__test-helpers__.js +174 -0
- package/dist/__test-helpers__.js.map +1 -0
- package/dist/bin/pi-steering.d.ts +9 -0
- package/dist/bin/pi-steering.d.ts.map +1 -0
- package/dist/bin/pi-steering.js +506 -0
- package/dist/bin/pi-steering.js.map +1 -0
- package/dist/compat.d.ts +50 -0
- package/dist/compat.d.ts.map +1 -0
- package/dist/compat.js +180 -0
- package/dist/compat.js.map +1 -0
- package/dist/defaults.d.ts +61 -0
- package/dist/defaults.d.ts.map +1 -0
- package/dist/defaults.js +106 -0
- package/dist/defaults.js.map +1 -0
- package/dist/define-config.d.ts +235 -0
- package/dist/define-config.d.ts.map +1 -0
- package/dist/define-config.js +118 -0
- package/dist/define-config.js.map +1 -0
- package/dist/define-predicate.d.ts +49 -0
- package/dist/define-predicate.d.ts.map +1 -0
- package/dist/define-predicate.js +35 -0
- package/dist/define-predicate.js.map +1 -0
- package/dist/evaluator-internals/context.d.ts +186 -0
- package/dist/evaluator-internals/context.d.ts.map +1 -0
- package/dist/evaluator-internals/context.js +219 -0
- package/dist/evaluator-internals/context.js.map +1 -0
- package/dist/evaluator-internals/override.d.ts +19 -0
- package/dist/evaluator-internals/override.d.ts.map +1 -0
- package/dist/evaluator-internals/override.js +79 -0
- package/dist/evaluator-internals/override.js.map +1 -0
- package/dist/evaluator-internals/predicates.d.ts +152 -0
- package/dist/evaluator-internals/predicates.d.ts.map +1 -0
- package/dist/evaluator-internals/predicates.js +849 -0
- package/dist/evaluator-internals/predicates.js.map +1 -0
- package/dist/evaluator-internals/speculative-synthesis.d.ts +102 -0
- package/dist/evaluator-internals/speculative-synthesis.d.ts.map +1 -0
- package/dist/evaluator-internals/speculative-synthesis.js +101 -0
- package/dist/evaluator-internals/speculative-synthesis.js.map +1 -0
- package/dist/evaluator.d.ts +101 -0
- package/dist/evaluator.d.ts.map +1 -0
- package/dist/evaluator.js +714 -0
- package/dist/evaluator.js.map +1 -0
- package/dist/helpers/walker-unknown-cwd-reason.d.ts +68 -0
- package/dist/helpers/walker-unknown-cwd-reason.d.ts.map +1 -0
- package/dist/helpers/walker-unknown-cwd-reason.js +73 -0
- package/dist/helpers/walker-unknown-cwd-reason.js.map +1 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +93 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/drop-unused-observers.d.ts +43 -0
- package/dist/internal/drop-unused-observers.d.ts.map +1 -0
- package/dist/internal/drop-unused-observers.js +50 -0
- package/dist/internal/drop-unused-observers.js.map +1 -0
- package/dist/internal/finalize-plugin-state.d.ts +22 -0
- package/dist/internal/finalize-plugin-state.d.ts.map +1 -0
- package/dist/internal/finalize-plugin-state.js +14 -0
- package/dist/internal/finalize-plugin-state.js.map +1 -0
- package/dist/internal/merge-observers.d.ts +30 -0
- package/dist/internal/merge-observers.d.ts.map +1 -0
- package/dist/internal/merge-observers.js +27 -0
- package/dist/internal/merge-observers.js.map +1 -0
- package/dist/internal/pattern-utils.d.ts +33 -0
- package/dist/internal/pattern-utils.d.ts.map +1 -0
- package/dist/internal/pattern-utils.js +20 -0
- package/dist/internal/pattern-utils.js.map +1 -0
- package/dist/internal/ref-text.d.ts +11 -0
- package/dist/internal/ref-text.d.ts.map +1 -0
- package/dist/internal/ref-text.js +15 -0
- package/dist/internal/ref-text.js.map +1 -0
- package/dist/internal/session-runtime.d.ts +44 -0
- package/dist/internal/session-runtime.d.ts.map +1 -0
- package/dist/internal/session-runtime.js +143 -0
- package/dist/internal/session-runtime.js.map +1 -0
- package/dist/internal/watch-matcher.d.ts +56 -0
- package/dist/internal/watch-matcher.d.ts.map +1 -0
- package/dist/internal/watch-matcher.js +179 -0
- package/dist/internal/watch-matcher.js.map +1 -0
- package/dist/loader.d.ts +97 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +527 -0
- package/dist/loader.js.map +1 -0
- package/dist/observer-dispatcher.d.ts +69 -0
- package/dist/observer-dispatcher.d.ts.map +1 -0
- package/dist/observer-dispatcher.js +184 -0
- package/dist/observer-dispatcher.js.map +1 -0
- package/dist/plugin-merger.d.ts +175 -0
- package/dist/plugin-merger.d.ts.map +1 -0
- package/dist/plugin-merger.js +450 -0
- package/dist/plugin-merger.js.map +1 -0
- package/dist/plugins/git/branch-tracker.d.ts +85 -0
- package/dist/plugins/git/branch-tracker.d.ts.map +1 -0
- package/dist/plugins/git/branch-tracker.js +181 -0
- package/dist/plugins/git/branch-tracker.js.map +1 -0
- package/dist/plugins/git/cwd-extensions.d.ts +10 -0
- package/dist/plugins/git/cwd-extensions.d.ts.map +1 -0
- package/dist/plugins/git/cwd-extensions.js +155 -0
- package/dist/plugins/git/cwd-extensions.js.map +1 -0
- package/dist/plugins/git/git-ops.d.ts +89 -0
- package/dist/plugins/git/git-ops.d.ts.map +1 -0
- package/dist/plugins/git/git-ops.js +107 -0
- package/dist/plugins/git/git-ops.js.map +1 -0
- package/dist/plugins/git/index.d.ts +225 -0
- package/dist/plugins/git/index.d.ts.map +1 -0
- package/dist/plugins/git/index.js +52 -0
- package/dist/plugins/git/index.js.map +1 -0
- package/dist/plugins/git/predicates.d.ts +312 -0
- package/dist/plugins/git/predicates.d.ts.map +1 -0
- package/dist/plugins/git/predicates.js +580 -0
- package/dist/plugins/git/predicates.js.map +1 -0
- package/dist/plugins/git/rules.d.ts +209 -0
- package/dist/plugins/git/rules.d.ts.map +1 -0
- package/dist/plugins/git/rules.js +267 -0
- package/dist/plugins/git/rules.js.map +1 -0
- package/dist/schema.d.ts +1799 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +4 -0
- package/dist/schema.js.map +1 -0
- package/dist/testing/index.d.ts +621 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +747 -0
- package/dist/testing/index.js.map +1 -0
- package/examples/README.md +83 -0
- package/examples/combined-git-discipline/README.md +52 -0
- package/examples/combined-git-discipline/node_modules/.bin/pi-steering +43 -0
- package/examples/combined-git-discipline/package.json +18 -0
- package/examples/combined-git-discipline/steering.json +27 -0
- package/examples/combined-git-discipline/steering.test.ts +42 -0
- package/examples/combined-git-discipline/steering.ts +64 -0
- package/examples/combined-git-discipline/tsconfig.json +7 -0
- package/examples/draft-prs-only/README.md +42 -0
- package/examples/draft-prs-only/node_modules/.bin/pi-steering +43 -0
- package/examples/draft-prs-only/package.json +18 -0
- package/examples/draft-prs-only/steering.json +12 -0
- package/examples/draft-prs-only/steering.test.ts +48 -0
- package/examples/draft-prs-only/steering.ts +36 -0
- package/examples/draft-prs-only/tsconfig.json +7 -0
- package/examples/dynamic-reason-runtime-cwd/README.md +148 -0
- package/examples/dynamic-reason-runtime-cwd/node_modules/.bin/pi-steering +43 -0
- package/examples/dynamic-reason-runtime-cwd/package.json +18 -0
- package/examples/dynamic-reason-runtime-cwd/steering.test.ts +130 -0
- package/examples/dynamic-reason-runtime-cwd/steering.ts +82 -0
- package/examples/dynamic-reason-runtime-cwd/tsconfig.json +7 -0
- package/examples/force-push-strict/README.md +75 -0
- package/examples/force-push-strict/node_modules/.bin/pi-steering +43 -0
- package/examples/force-push-strict/package.json +18 -0
- package/examples/force-push-strict/steering.json +12 -0
- package/examples/force-push-strict/steering.test.ts +51 -0
- package/examples/force-push-strict/steering.ts +54 -0
- package/examples/force-push-strict/tsconfig.json +7 -0
- package/examples/no-amend/README.md +52 -0
- package/examples/no-amend/node_modules/.bin/pi-steering +43 -0
- package/examples/no-amend/package.json +18 -0
- package/examples/no-amend/steering.cwd-scoped.json +12 -0
- package/examples/no-amend/steering.json +11 -0
- package/examples/no-amend/steering.test.ts +49 -0
- package/examples/no-amend/steering.ts +38 -0
- package/examples/no-amend/tsconfig.json +7 -0
- package/examples/work-item-plugin/README.md +103 -0
- package/examples/work-item-plugin/node_modules/.bin/pi-steering +43 -0
- package/examples/work-item-plugin/package.json +19 -0
- package/examples/work-item-plugin/src/index.test.ts +168 -0
- package/examples/work-item-plugin/src/index.ts +122 -0
- package/examples/work-item-plugin/src/observers/npm-test-tracker.test.ts +95 -0
- package/examples/work-item-plugin/src/observers/npm-test-tracker.ts +88 -0
- package/examples/work-item-plugin/src/observers/retest-required-tracker.test.ts +81 -0
- package/examples/work-item-plugin/src/observers/retest-required-tracker.ts +81 -0
- package/examples/work-item-plugin/src/predicates/work-item-format.test.ts +157 -0
- package/examples/work-item-plugin/src/predicates/work-item-format.ts +99 -0
- package/examples/work-item-plugin/src/rules/commit-description-check.test.ts +107 -0
- package/examples/work-item-plugin/src/rules/commit-description-check.ts +83 -0
- package/examples/work-item-plugin/src/rules/commit-requires-work-item.test.ts +79 -0
- package/examples/work-item-plugin/src/rules/commit-requires-work-item.ts +56 -0
- package/examples/work-item-plugin/src/rules/push-requires-tests.test.ts +245 -0
- package/examples/work-item-plugin/src/rules/push-requires-tests.ts +62 -0
- package/examples/work-item-plugin/tsconfig.json +8 -0
- package/package.json +88 -0
- package/skills/steering-authoring/SKILL.md +102 -0
- package/src/INVARIANTS.md +116 -0
- package/src/__test-helpers__.ts +243 -0
- package/src/bin/pi-steering.test.ts +920 -0
- package/src/bin/pi-steering.ts +584 -0
- package/src/compat.test.ts +431 -0
- package/src/compat.ts +282 -0
- package/src/defaults.test.ts +585 -0
- package/src/defaults.ts +148 -0
- package/src/define-config.test.ts +888 -0
- package/src/define-config.ts +375 -0
- package/src/define-predicate.test.ts +70 -0
- package/src/define-predicate.ts +57 -0
- package/src/evaluator-internals/context.ts +290 -0
- package/src/evaluator-internals/override.test.ts +99 -0
- package/src/evaluator-internals/override.ts +83 -0
- package/src/evaluator-internals/predicates.ts +1037 -0
- package/src/evaluator-internals/speculative-synthesis.test.ts +411 -0
- package/src/evaluator-internals/speculative-synthesis.ts +199 -0
- package/src/evaluator.test.ts +6296 -0
- package/src/evaluator.ts +1106 -0
- package/src/factory-time-load.test.ts +630 -0
- package/src/helpers/walker-unknown-cwd-reason.test.ts +73 -0
- package/src/helpers/walker-unknown-cwd-reason.ts +83 -0
- package/src/index.test.ts +836 -0
- package/src/index.ts +229 -0
- package/src/internal/drop-unused-observers.test.ts +141 -0
- package/src/internal/drop-unused-observers.ts +81 -0
- package/src/internal/finalize-plugin-state.ts +43 -0
- package/src/internal/merge-observers.ts +51 -0
- package/src/internal/pattern-utils.test.ts +52 -0
- package/src/internal/pattern-utils.ts +38 -0
- package/src/internal/ref-text.test.ts +54 -0
- package/src/internal/ref-text.ts +17 -0
- package/src/internal/session-runtime.test.ts +629 -0
- package/src/internal/session-runtime.ts +201 -0
- package/src/internal/watch-matcher.test.ts +326 -0
- package/src/internal/watch-matcher.ts +200 -0
- package/src/jsdoc-propagation.test.ts +277 -0
- package/src/loader.test.ts +1098 -0
- package/src/loader.ts +581 -0
- package/src/not-block-onunknown.test.ts +554 -0
- package/src/observer-dispatcher.test.ts +939 -0
- package/src/observer-dispatcher.ts +290 -0
- package/src/per-predicate-typing.test.ts +361 -0
- package/src/plugin-merger.test.ts +790 -0
- package/src/plugin-merger.ts +598 -0
- package/src/plugins/git/README.md +485 -0
- package/src/plugins/git/branch-tracker.test.ts +281 -0
- package/src/plugins/git/branch-tracker.ts +187 -0
- package/src/plugins/git/cwd-extensions.test.ts +230 -0
- package/src/plugins/git/cwd-extensions.ts +160 -0
- package/src/plugins/git/git-ops.test.ts +397 -0
- package/src/plugins/git/git-ops.ts +173 -0
- package/src/plugins/git/index.ts +253 -0
- package/src/plugins/git/integration.test.ts +705 -0
- package/src/plugins/git/predicates.test.ts +1401 -0
- package/src/plugins/git/predicates.ts +693 -0
- package/src/plugins/git/rules.test.ts +1001 -0
- package/src/plugins/git/rules.ts +307 -0
- package/src/schema.test.ts +259 -0
- package/src/schema.ts +1963 -0
- package/src/testing/index.test.ts +1696 -0
- 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
|
+
};
|