@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,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
|
+
}
|