pi-gauntlet 5.10.2 → 5.12.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 +8 -0
- package/README.md +7 -3
- package/bin/gauntlet-performance.mjs +283 -0
- package/bin/gauntlet-performance.test.mjs +350 -0
- package/bin/gauntlet-telemetry-salvage.mjs +52 -10
- package/bin/gauntlet-telemetry-salvage.test.mjs +104 -2
- package/extensions/lib/checkout.test.ts +65 -2
- package/extensions/lib/checkout.ts +54 -14
- package/extensions/lib/gauntlet-settings-loader.test.ts +13 -0
- package/extensions/lib/gauntlet-settings-loader.ts +6 -5
- package/extensions/telemetry.test.ts +22 -1
- package/extensions/telemetry.ts +25 -10
- package/package.json +3 -2
- package/skills/finishing-a-development-branch/SKILL.md +1 -1
- package/skills/gatekeep-pr/SKILL.md +3 -2
- package/skills/gauntlet-performance/SKILL.md +43 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
4
|
+
import { join, dirname } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
const CLI = join(dirname(fileURLToPath(import.meta.url)), "gauntlet-performance.mjs");
|
|
10
|
+
const TDIR = ".pi/gauntlet/telemetry/doc/specs";
|
|
11
|
+
const EMPTY_AGENT = mkdtempSync(join(tmpdir(), "gp-empty-agent-"));
|
|
12
|
+
process.on("exit", () => rmSync(EMPTY_AGENT, { recursive: true, force: true }));
|
|
13
|
+
|
|
14
|
+
const write = (root, rel, text) => {
|
|
15
|
+
mkdirSync(join(root, dirname(rel)), { recursive: true });
|
|
16
|
+
writeFileSync(join(root, rel), text);
|
|
17
|
+
};
|
|
18
|
+
const gitRepo = (prefix = "gp-") => {
|
|
19
|
+
const root = mkdtempSync(join(tmpdir(), prefix));
|
|
20
|
+
spawnSync("git", ["init", "-q"], { cwd: root });
|
|
21
|
+
return root;
|
|
22
|
+
};
|
|
23
|
+
const invoke = (cwd, args = [], env = {}) => {
|
|
24
|
+
const r = spawnSync(process.execPath, [CLI, ...args], {
|
|
25
|
+
cwd, encoding: "utf8", env: { ...process.env, PI_CODING_AGENT_DIR: EMPTY_AGENT, ...env },
|
|
26
|
+
});
|
|
27
|
+
return { status: r.status, stdout: r.stdout, stderr: r.stderr.trim(), lines: r.stdout.split("\n").filter(Boolean) };
|
|
28
|
+
};
|
|
29
|
+
const json = (cwd, args = []) => JSON.parse(invoke(cwd, ["--json", ...args]).stdout);
|
|
30
|
+
const cleanup = (t, ...roots) => t.after(() => roots.forEach((r) => rmSync(r, { recursive: true, force: true })));
|
|
31
|
+
|
|
32
|
+
const phase = (extra) => `{ started_at: 2026-09-01T00:00:00Z, completed_at: 2026-09-01T00:10:00Z, duration_s: 600, ${extra} }`;
|
|
33
|
+
const tokens = (input, output, cacheRead, cacheWrite, cost) =>
|
|
34
|
+
`tokens: { input: ${input}, output: ${output}, cache_read: ${cacheRead}, cache_write: ${cacheWrite}, cost: ${cost} }`;
|
|
35
|
+
|
|
36
|
+
// Complete run: cache-dominated tokens, an unphased block that must be ignored, two models.
|
|
37
|
+
const COMPLETE = `schema: 1
|
|
38
|
+
spec: doc/specs/a.md
|
|
39
|
+
run_id: aaaaaaaa-0000-0000-0000-000000000001
|
|
40
|
+
status: shipped
|
|
41
|
+
created_at: 2026-09-01T00:00:00Z
|
|
42
|
+
shipped_at: 2026-09-01T01:00:00Z
|
|
43
|
+
versions: { pi-gauntlet: 5.9.1 }
|
|
44
|
+
derived:
|
|
45
|
+
duration_s: 3600
|
|
46
|
+
phases:
|
|
47
|
+
brainstorm: ${phase(`model: m/alpha, ${tokens(100, 10, 5000, 200, 1.5)}`)}
|
|
48
|
+
plan: ${phase(`model: m/alpha, ${tokens(50, 5, 1000, 100, 0.5)}`)}
|
|
49
|
+
implement: ${phase(`model: m/beta, ${tokens(10, 1, 100, 10, 0.25)}`)}
|
|
50
|
+
verify: ${phase(`model: m/beta`)}
|
|
51
|
+
ship: ${phase(`model: m/beta`)}
|
|
52
|
+
unphased: { ${tokens(99999, 99999, 99999, 99999, 999)} }
|
|
53
|
+
personas:
|
|
54
|
+
worker: { dispatches: 3, models: [m/beta] }
|
|
55
|
+
spec-council-member: { dispatches: 4, models: [m/alpha] }
|
|
56
|
+
reviews:
|
|
57
|
+
spec-reviewer: { dispatches: 1, nonzero_exit: 0, findings: { blocker: 1, major: 2, minor: 3 } }
|
|
58
|
+
code-reviewer: { dispatches: 1, nonzero_exit: 0 }
|
|
59
|
+
conformance_loops: 2
|
|
60
|
+
conformance_open_gaps: 0
|
|
61
|
+
gates: { spec_rounds: 1, plan_rounds: 1, fix_round_grants: 1, task_reopens: 0, ship_option: squash }
|
|
62
|
+
tests: { command: npm test, result: pass }
|
|
63
|
+
accumulators: {}
|
|
64
|
+
events: []
|
|
65
|
+
`;
|
|
66
|
+
// Truncated run: stamped shipped by salvage, stale duration_s, no ship phase, zero gates.
|
|
67
|
+
const TRUNCATED = `schema: 1
|
|
68
|
+
spec: doc/specs/b.md
|
|
69
|
+
run_id: bbbbbbbb-0000-0000-0000-000000000002
|
|
70
|
+
status: shipped
|
|
71
|
+
created_at: 2026-09-02T00:00:00Z
|
|
72
|
+
shipped_at: 2026-09-02T02:00:00Z
|
|
73
|
+
versions: { pi-gauntlet: 5.9.1 }
|
|
74
|
+
derived:
|
|
75
|
+
duration_s: 2501
|
|
76
|
+
phases:
|
|
77
|
+
brainstorm: ${phase(`model: m/alpha, ${tokens(1, 1, 1, 1, 0.01)}`)}
|
|
78
|
+
plan: { started_at: 2026-09-02T00:10:00Z, model: m/alpha }
|
|
79
|
+
personas: {}
|
|
80
|
+
conformance_loops: 0
|
|
81
|
+
gates: { spec_rounds: 0, plan_rounds: 0, fix_round_grants: 0, task_reopens: 0 }
|
|
82
|
+
accumulators: {}
|
|
83
|
+
events: []
|
|
84
|
+
`;
|
|
85
|
+
const IN_PROGRESS = `schema: 1
|
|
86
|
+
spec: doc/specs/c.md
|
|
87
|
+
run_id: cccccccc-0000-0000-0000-000000000003
|
|
88
|
+
status: in_progress
|
|
89
|
+
created_at: 2026-09-03T00:00:00Z
|
|
90
|
+
versions: { pi-gauntlet: 5.11.0 }
|
|
91
|
+
derived:
|
|
92
|
+
duration_s: 10
|
|
93
|
+
phases: { brainstorm: { started_at: 2026-09-03T00:00:00Z } }
|
|
94
|
+
personas: {}
|
|
95
|
+
conformance_loops: 0
|
|
96
|
+
gates: { spec_rounds: 0, plan_rounds: 0, fix_round_grants: 0, task_reopens: 0 }
|
|
97
|
+
accumulators: {}
|
|
98
|
+
events: []
|
|
99
|
+
`;
|
|
100
|
+
// Second complete run on 5.9.1 with different numbers: makes p50 over an even count.
|
|
101
|
+
const COMPLETE_2 = COMPLETE
|
|
102
|
+
.replace("run_id: aaaaaaaa-0000-0000-0000-000000000001", "run_id: dddddddd-0000-0000-0000-000000000004")
|
|
103
|
+
.replace("spec: doc/specs/a.md", "spec: doc/specs/d.md")
|
|
104
|
+
.replace("created_at: 2026-09-01T00:00:00Z", "created_at: 2026-09-04T00:00:00Z")
|
|
105
|
+
.replace("duration_s: 3600", "duration_s: 7200")
|
|
106
|
+
.replace("fix_round_grants: 1", "fix_round_grants: 3")
|
|
107
|
+
.replace(/reviews:[\s\S]*?conformance_loops/, "conformance_loops");
|
|
108
|
+
const OLD_VERSION = COMPLETE
|
|
109
|
+
.replace("run_id: aaaaaaaa-0000-0000-0000-000000000001", "run_id: eeeeeeee-0000-0000-0000-000000000005")
|
|
110
|
+
.replace("spec: doc/specs/a.md", "spec: doc/specs/e.md")
|
|
111
|
+
.replace("pi-gauntlet: 5.9.1", "pi-gauntlet: 5.10.0");
|
|
112
|
+
const BAD_GATES = IN_PROGRESS
|
|
113
|
+
.replace("run_id: cccccccc-0000-0000-0000-000000000003", "run_id: ffffffff-0000-0000-0000-000000000006")
|
|
114
|
+
.replace("spec: doc/specs/c.md", "spec: doc/specs/f.md")
|
|
115
|
+
.replace(/gates: \{[^}]*\}/, 'gates: "nope"');
|
|
116
|
+
const EMPTY_REVIEWS = IN_PROGRESS
|
|
117
|
+
.replace("run_id: cccccccc-0000-0000-0000-000000000003", "run_id: gggggggg-0000-0000-0000-000000000007")
|
|
118
|
+
.replace("spec: doc/specs/c.md", "spec: doc/specs/g.md")
|
|
119
|
+
.replace("created_at: 2026-09-03T00:00:00Z", "created_at: 2026-09-05T00:00:00Z")
|
|
120
|
+
.replace(" conformance_loops: 0", " reviews: {}\n conformance_loops: 0");
|
|
121
|
+
const REVIEW_WITHOUT_FINDINGS = IN_PROGRESS
|
|
122
|
+
.replace("run_id: cccccccc-0000-0000-0000-000000000003", "run_id: hhhhhhhh-0000-0000-0000-000000000008")
|
|
123
|
+
.replace("spec: doc/specs/c.md", "spec: doc/specs/h.md")
|
|
124
|
+
.replace(" conformance_loops: 0", " reviews: { code-reviewer: { dispatches: 1, nonzero_exit: 0 } }\n conformance_loops: 0");
|
|
125
|
+
|
|
126
|
+
const corpus = (root) => {
|
|
127
|
+
write(root, `${TDIR}/a.yaml`, COMPLETE);
|
|
128
|
+
write(root, `${TDIR}/b.yaml`, TRUNCATED);
|
|
129
|
+
write(root, `${TDIR}/c.yaml`, IN_PROGRESS);
|
|
130
|
+
write(root, `${TDIR}/d.yaml`, COMPLETE_2);
|
|
131
|
+
write(root, `${TDIR}/e.yaml`, OLD_VERSION);
|
|
132
|
+
write(root, `${TDIR}/f.yaml`, BAD_GATES);
|
|
133
|
+
write(root, `${TDIR}/present-empty-reviews.yaml`, EMPTY_REVIEWS);
|
|
134
|
+
write(root, `${TDIR}/broken.yaml`, "schema: 1\nspec: [\n");
|
|
135
|
+
write(root, `${TDIR}/v2.yaml`, "schema: 2\nspec: doc/specs/z.md\nrun_id: z\n");
|
|
136
|
+
write(root, `${TDIR}/norec.yaml`, "schema: 1\nspec: doc/specs/z.md\n");
|
|
137
|
+
return root;
|
|
138
|
+
};
|
|
139
|
+
const byId = (runs, prefix) => runs.find((r) => r.run_id.startsWith(prefix));
|
|
140
|
+
|
|
141
|
+
test("per-run fields: typed reads, unphased excluded, truncated wall null, findings null vs 0/0/0", (t) => {
|
|
142
|
+
const root = corpus(gitRepo()); cleanup(t, root);
|
|
143
|
+
const d = json(root);
|
|
144
|
+
assert.deepEqual(d.corpus, { [root.split("/").pop()]: 7 });
|
|
145
|
+
assert.equal(d.since, null);
|
|
146
|
+
const a = byId(d.runs, "aaaaaaaa");
|
|
147
|
+
assert.equal(a.repo, root.split("/").pop());
|
|
148
|
+
assert.equal(a.spec, "a");
|
|
149
|
+
assert.equal(a.version, "5.9.1");
|
|
150
|
+
assert.equal(a.status, "shipped");
|
|
151
|
+
assert.equal(a.truncated, false);
|
|
152
|
+
assert.equal(a.wall_s, 3600);
|
|
153
|
+
assert.deepEqual(a.phase_min, { brainstorm: 10, plan: 10, implement: 10, verify: 10, ship: 10 });
|
|
154
|
+
assert.equal(a.tokens, 100 + 10 + 5000 + 200 + 50 + 5 + 1000 + 100 + 10 + 1 + 100 + 10);
|
|
155
|
+
assert.equal(a.cost, 2.25);
|
|
156
|
+
assert.deepEqual(a.models, ["m/alpha", "m/beta"]);
|
|
157
|
+
assert.equal(a.dispatches, 7);
|
|
158
|
+
assert.equal(a.grants, 1);
|
|
159
|
+
assert.equal(a.reopens, 0);
|
|
160
|
+
assert.equal(a.spec_rounds, 1);
|
|
161
|
+
assert.equal(a.plan_rounds, 1);
|
|
162
|
+
assert.equal(a.loops, 2);
|
|
163
|
+
assert.equal(a.open_gaps, 0);
|
|
164
|
+
assert.deepEqual(a.findings, { blocker: 1, major: 2, minor: 3 });
|
|
165
|
+
assert.equal(a.council, 4);
|
|
166
|
+
assert.equal(a.ship_option, "squash");
|
|
167
|
+
assert.equal(a.tests, "pass");
|
|
168
|
+
const b = byId(d.runs, "bbbbbbbb");
|
|
169
|
+
assert.equal(b.truncated, true);
|
|
170
|
+
assert.equal(b.wall_s, null);
|
|
171
|
+
assert.deepEqual(b.phase_min, { brainstorm: 10, plan: null, implement: null, verify: null, ship: null });
|
|
172
|
+
assert.equal(b.tokens, 4);
|
|
173
|
+
assert.equal(b.dispatches, null);
|
|
174
|
+
assert.equal(b.findings, null);
|
|
175
|
+
assert.equal(b.council, null);
|
|
176
|
+
assert.equal(b.ship_option, null);
|
|
177
|
+
assert.equal(b.tests, null);
|
|
178
|
+
const dd = byId(d.runs, "dddddddd");
|
|
179
|
+
assert.equal(dd.findings, null);
|
|
180
|
+
const f = byId(d.runs, "ffffffff");
|
|
181
|
+
assert.equal(f.grants, null);
|
|
182
|
+
assert.equal(f.status, "in_progress");
|
|
183
|
+
const emptyReviews = byId(d.runs, "gggggggg");
|
|
184
|
+
assert.deepEqual(emptyReviews.findings, { blocker: 0, major: 0, minor: 0 });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("review entries without findings contribute zero counters", (t) => {
|
|
188
|
+
const root = gitRepo(); cleanup(t, root);
|
|
189
|
+
write(root, `${TDIR}/review-without-findings.yaml`, REVIEW_WITHOUT_FINDINGS);
|
|
190
|
+
const d = json(root);
|
|
191
|
+
assert.deepEqual(d.runs[0].findings, { blocker: 0, major: 0, minor: 0 });
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("malformed findings counters remain null in JSON and render as dashes in text", (t) => {
|
|
195
|
+
const root = gitRepo(); cleanup(t, root);
|
|
196
|
+
const malformed = COMPLETE.replace(
|
|
197
|
+
"findings: { blocker: 1, major: 2, minor: 3 }",
|
|
198
|
+
'findings: { blocker: "bad", major: 2, minor: 3 }',
|
|
199
|
+
);
|
|
200
|
+
write(root, `${TDIR}/malformed-findings.yaml`, malformed);
|
|
201
|
+
const d = json(root);
|
|
202
|
+
assert.deepEqual(d.runs[0].findings, { blocker: null, major: 2, minor: 3 });
|
|
203
|
+
const row = invoke(root).lines.find((line) => line.startsWith("aaaaaaaa"));
|
|
204
|
+
assert.match(row, /\s-\/2\/3\s+4$/);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("malformed reviewer entries and findings objects contribute null counters", (t) => {
|
|
208
|
+
const root = gitRepo(); cleanup(t, root);
|
|
209
|
+
const malformedFindings = COMPLETE.replace(
|
|
210
|
+
"findings: { blocker: 1, major: 2, minor: 3 }",
|
|
211
|
+
'findings: "bad"',
|
|
212
|
+
);
|
|
213
|
+
const malformedReviewer = COMPLETE.replace(
|
|
214
|
+
"spec-reviewer: { dispatches: 1, nonzero_exit: 0, findings: { blocker: 1, major: 2, minor: 3 } }",
|
|
215
|
+
'spec-reviewer: "bad"',
|
|
216
|
+
);
|
|
217
|
+
write(root, `${TDIR}/malformed-findings-object.yaml`, malformedFindings);
|
|
218
|
+
write(root, `${TDIR}/malformed-reviewer.yaml`, malformedReviewer.replace(
|
|
219
|
+
"run_id: aaaaaaaa-0000-0000-0000-000000000001",
|
|
220
|
+
"run_id: 22222222-0000-0000-0000-000000000012",
|
|
221
|
+
));
|
|
222
|
+
const d = json(root);
|
|
223
|
+
assert.deepEqual(byId(d.runs, "aaaaaaaa").findings, { blocker: null, major: null, minor: null });
|
|
224
|
+
assert.deepEqual(byId(d.runs, "22222222").findings, { blocker: null, major: null, minor: null });
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("phase minutes remain unrounded in JSON", (t) => {
|
|
228
|
+
const root = gitRepo(); cleanup(t, root);
|
|
229
|
+
write(root, `${TDIR}/phase-61.yaml`, COMPLETE.replace("duration_s: 600", "duration_s: 61"));
|
|
230
|
+
const d = json(root);
|
|
231
|
+
assert.equal(d.runs[0].phase_min.brainstorm, 61 / 60);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("aggregates: only shipped non-truncated rows feed p50/max; even-count p50 is the mean; models tally; unknown group", (t) => {
|
|
235
|
+
const root = corpus(gitRepo()); cleanup(t, root);
|
|
236
|
+
write(root, `${TDIR}/g.yaml`, COMPLETE.replace("run_id: aaaaaaaa-0000-0000-0000-000000000001", "run_id: 99999999-0000-0000-0000-000000000009").replace("versions: { pi-gauntlet: 5.9.1 }", "versions: {}"));
|
|
237
|
+
const d = json(root);
|
|
238
|
+
assert.deepEqual(d.by_version.map((g) => g.version), ["5.9.1", "5.10.0", "5.11.0", "unknown"]);
|
|
239
|
+
const v591 = d.by_version[0];
|
|
240
|
+
assert.equal(v591.n, 3);
|
|
241
|
+
assert.equal(v591.shipped, 2);
|
|
242
|
+
assert.equal(v591.truncated, 1);
|
|
243
|
+
assert.deepEqual(v591.wall_s, { p50: 5400, max: 7200 });
|
|
244
|
+
assert.deepEqual(v591.grants, { p50: 2, max: 3 });
|
|
245
|
+
assert.deepEqual(v591.dispatches, { p50: 7 });
|
|
246
|
+
assert.deepEqual(v591.findings, { blocker: { p50: 1 }, major: { p50: 2 }, minor: { p50: 3 } });
|
|
247
|
+
assert.deepEqual(v591.models, { "m/alpha": 2, "m/beta": 2 });
|
|
248
|
+
const v5110 = d.by_version[2];
|
|
249
|
+
assert.equal(v5110.n, 3);
|
|
250
|
+
assert.equal(v5110.shipped, 0);
|
|
251
|
+
assert.deepEqual(v5110.wall_s, { p50: null, max: null });
|
|
252
|
+
assert.deepEqual(v5110.models, {});
|
|
253
|
+
assert.equal(d.by_version[3].shipped, 1);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("row order is created_at then run_id; skipped lists unparseable, schema, not a record", (t) => {
|
|
257
|
+
const root = corpus(gitRepo()); cleanup(t, root);
|
|
258
|
+
const d = json(root);
|
|
259
|
+
assert.deepEqual(d.runs.map((r) => r.run_id[0]), ["a", "e", "b", "c", "f", "d", "g"]);
|
|
260
|
+
const reasons = Object.fromEntries(d.skipped.map((s) => [s.file.split("/").pop(), s.reason]));
|
|
261
|
+
assert.equal(reasons["broken.yaml"], "unparseable");
|
|
262
|
+
assert.equal(reasons["v2.yaml"], "schema 2");
|
|
263
|
+
assert.equal(reasons["norec.yaml"], "not a record");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("text output: header, shipped* marker, - cells, by version block, skipped lines", (t) => {
|
|
267
|
+
const root = corpus(gitRepo()); cleanup(t, root);
|
|
268
|
+
const r = invoke(root);
|
|
269
|
+
assert.equal(r.status, 0, r.stderr);
|
|
270
|
+
assert.equal(r.lines[0], `corpus: ${root.split("/").pop()}=7`);
|
|
271
|
+
assert.equal(r.lines[1], "runs (7)");
|
|
272
|
+
assert.match(r.lines[2], /^run_id\s+repo\s+spec\s+version\s+status\s+wall\s+b\/p\/i\/v\/s min\s+tokens\s+cost\s+models\s+disp\s+grants\s+reopens\s+loops\s+findings\s+council$/);
|
|
273
|
+
const bRow = r.lines.find((l) => l.startsWith("bbbbbbbb"));
|
|
274
|
+
assert.match(bRow, /\s5\.9\.1\s+shipped\*\s+-\s+10\/-\/-\/-\/-\s/);
|
|
275
|
+
assert.match(bRow, /\s-\s*$/);
|
|
276
|
+
const aRow = r.lines.find((l) => l.startsWith("aaaaaaaa"));
|
|
277
|
+
assert.match(aRow, /\sshipped\s+60m\s+10\/10\/10\/10\/10\s+6\.6k\s+2\.25\s+m\/alpha,m\/beta\s+7\s+1\s+0\s+2\s+1\/2\/3\s+4$/);
|
|
278
|
+
assert.ok(r.lines.includes("by version"));
|
|
279
|
+
const v = r.lines.find((l) => l.startsWith("5.9.1"));
|
|
280
|
+
assert.match(v, /^5\.9\.1\s+3\s+2\s+1\s+90m\/120m\s/);
|
|
281
|
+
assert.ok(r.lines.some((l) => /^skipped: .*broken\.yaml: unparseable$/.test(l)));
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("--since numeric compare: 5.10.0 > 5.9.1, unknown and malformed versions dropped; no rows -> no records found, exit 0", (t) => {
|
|
285
|
+
const root = corpus(gitRepo()); cleanup(t, root);
|
|
286
|
+
write(root, `${TDIR}/g.yaml`, COMPLETE.replace("run_id: aaaaaaaa-0000-0000-0000-000000000001", "run_id: 99999999-0000-0000-0000-000000000009").replace("versions: { pi-gauntlet: 5.9.1 }", "versions: {}"));
|
|
287
|
+
write(root, `${TDIR}/malformed-version.yaml`, COMPLETE.replace("run_id: aaaaaaaa-0000-0000-0000-000000000001", "run_id: 11111111-0000-0000-0000-000000000011").replace("pi-gauntlet: 5.9.1", "pi-gauntlet: 5.9.1garbage"));
|
|
288
|
+
write(root, `${TDIR}/leading-zero-version.yaml`, COMPLETE.replace("run_id: aaaaaaaa-0000-0000-0000-000000000001", "run_id: 33333333-0000-0000-0000-000000000013").replace("pi-gauntlet: 5.9.1", "pi-gauntlet: 5.09.1"));
|
|
289
|
+
assert.equal(byId(json(root).runs, "11111111").version, "unknown");
|
|
290
|
+
assert.equal(byId(json(root).runs, "33333333").version, "unknown");
|
|
291
|
+
const sinceFive = json(root, ["--since", "5.0.0"]);
|
|
292
|
+
assert.equal(byId(sinceFive.runs, "33333333"), undefined);
|
|
293
|
+
const d = json(root, ["--since", "5.10.0"]);
|
|
294
|
+
assert.equal(d.since, "5.10.0");
|
|
295
|
+
assert.deepEqual(d.runs.map((r) => r.version).sort(), ["5.10.0", "5.11.0", "5.11.0", "5.11.0"]);
|
|
296
|
+
const r = invoke(root, ["--since", "9.9.9"]);
|
|
297
|
+
assert.equal(r.status, 0);
|
|
298
|
+
assert.equal(r.lines[0], `corpus: ${root.split("/").pop()}=0 since: 9.9.9`);
|
|
299
|
+
assert.match(r.lines[1], /^no records found in .*\.pi\/gauntlet\/telemetry$/);
|
|
300
|
+
assert.equal(r.lines.length, 2);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("--dir: repo root with telemetry dir, repo root without (skipped, checkout not walked), bare telemetry dir; duplicate run_id collapses", (t) => {
|
|
304
|
+
const root = corpus(gitRepo());
|
|
305
|
+
const other = gitRepo("gp-other-");
|
|
306
|
+
write(other, `${TDIR}/dup.yaml`, COMPLETE);
|
|
307
|
+
write(other, `${TDIR}/h.yaml`, COMPLETE.replace("run_id: aaaaaaaa-0000-0000-0000-000000000001", "run_id: 88888888-0000-0000-0000-000000000008"));
|
|
308
|
+
const empty = gitRepo("gp-empty-");
|
|
309
|
+
write(empty, "doc/specs/decoy.yaml", COMPLETE.replace("run_id: aaaaaaaa-0000-0000-0000-000000000001", "run_id: 77777777-0000-0000-0000-000000000007"));
|
|
310
|
+
const bare = mkdtempSync(join(tmpdir(), "gp-bare-"));
|
|
311
|
+
write(bare, "x.yaml", COMPLETE.replace("run_id: aaaaaaaa-0000-0000-0000-000000000001", "run_id: 66666666-0000-0000-0000-000000000006"));
|
|
312
|
+
cleanup(t, root, other, empty, bare);
|
|
313
|
+
const d = json(root, ["--dir", other, "--dir", empty, "--dir", bare, "--dir", join(bare, "missing")]);
|
|
314
|
+
assert.equal(d.corpus[other.split("/").pop()], 1);
|
|
315
|
+
assert.equal(d.corpus[bare.split("/").pop()], 1);
|
|
316
|
+
assert.equal(d.corpus[empty.split("/").pop()], undefined);
|
|
317
|
+
assert.ok(d.runs.some((r) => r.run_id.startsWith("88888888")));
|
|
318
|
+
assert.ok(d.runs.some((r) => r.run_id.startsWith("66666666")));
|
|
319
|
+
assert.ok(!d.runs.some((r) => r.run_id.startsWith("77777777")));
|
|
320
|
+
assert.equal(d.runs.filter((r) => r.run_id.startsWith("aaaaaaaa")).length, 1);
|
|
321
|
+
assert.ok(d.skipped.some((s) => s.file.endsWith("dup.yaml") && s.reason === "duplicate run_id"));
|
|
322
|
+
assert.ok(d.skipped.some((s) => s.file === empty && s.reason === "no telemetry dir"));
|
|
323
|
+
assert.ok(d.skipped.some((s) => s.file === join(bare, "missing") && s.reason === "not found"));
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("--dir skips an existing regular file as not a directory", (t) => {
|
|
327
|
+
const root = gitRepo();
|
|
328
|
+
const file = join(root, "telemetry.yaml");
|
|
329
|
+
writeFileSync(file, COMPLETE);
|
|
330
|
+
cleanup(t, root);
|
|
331
|
+
const r = invoke(root, ["--json", "--dir", file]);
|
|
332
|
+
assert.equal(r.status, 0, r.stderr);
|
|
333
|
+
const d = JSON.parse(r.stdout);
|
|
334
|
+
assert.ok(d.skipped.some((s) => s.file === file && s.reason === "not a directory"));
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("repo .pi/settings.json telemetry.dir is honoured for the default corpus", (t) => {
|
|
338
|
+
const root = gitRepo(); cleanup(t, root);
|
|
339
|
+
write(root, ".pi/settings.json", JSON.stringify({ piGauntlet: { telemetry: { dir: "custom/dir" } } }));
|
|
340
|
+
write(root, "custom/dir/doc/specs/a.yaml", COMPLETE);
|
|
341
|
+
const d = json(root);
|
|
342
|
+
assert.equal(d.runs.length, 1);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test("usage error exits 1", (t) => {
|
|
346
|
+
const root = gitRepo(); cleanup(t, root);
|
|
347
|
+
assert.equal(invoke(root, ["--bogus"]).status, 1);
|
|
348
|
+
assert.equal(invoke(root, ["--since", "abc"]).status, 1);
|
|
349
|
+
assert.equal(invoke(root, ["--since", "5.09.1"]).status, 1);
|
|
350
|
+
});
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Restore a gauntlet telemetry record that a plan strip or a gate fix commit removed
|
|
3
3
|
// from the branch. The recorder pathspec-commits the record at every checkpoint, so the
|
|
4
|
-
// newest copy is always in the deleting commit's parent.
|
|
5
|
-
//
|
|
6
|
-
|
|
4
|
+
// newest copy is always in the deleting commit's parent. An in_progress record with no
|
|
5
|
+
// ship phase is stamped shipped at landing. Exit 0 on every outcome: the callers are ship
|
|
6
|
+
// paths and this script never blocks one.
|
|
7
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
7
8
|
import { homedir } from "node:os";
|
|
8
9
|
import { isAbsolute, join } from "node:path";
|
|
9
10
|
import { spawnSync } from "node:child_process";
|
|
@@ -11,6 +12,7 @@ import process from "node:process";
|
|
|
11
12
|
import { mergeGauntlet, resolveTelemetry } from "../extensions/lib/gauntlet-settings.ts";
|
|
12
13
|
import { isSpecPath, recordPathFor } from "../extensions/lib/telemetry-paths.ts";
|
|
13
14
|
import { BASE_REFS } from "../extensions/lib/telemetry-ship.ts";
|
|
15
|
+
import { parseRecord, serializeRecord } from "../extensions/lib/telemetry-record.ts";
|
|
14
16
|
|
|
15
17
|
const GIT_TIMEOUT_MS = 10_000;
|
|
16
18
|
const COMMIT_TIMEOUT_MS = Number(process.env.GAUNTLET_SALVAGE_COMMIT_TIMEOUT_MS) || 30_000;
|
|
@@ -67,10 +69,40 @@ function resolveBase(root, explicit) {
|
|
|
67
69
|
return undefined;
|
|
68
70
|
}
|
|
69
71
|
|
|
72
|
+
// in_progress with no ship phase: the recorder lost its binding before the ship, so the
|
|
73
|
+
// landing finishes the record instead of leaving it in_progress on main forever.
|
|
74
|
+
const unfinished = (rec) => {
|
|
75
|
+
const phases = rec?.derived?.phases;
|
|
76
|
+
return rec?.status === "in_progress" && (phases === null || typeof phases !== "object" || !("ship" in phases));
|
|
77
|
+
};
|
|
78
|
+
const stamped = (rec, now) => serializeRecord({ ...rec, status: "shipped", shipped_at: now });
|
|
79
|
+
|
|
80
|
+
function stampPresent(root, rec, check) {
|
|
81
|
+
const line = `present ${rec}`;
|
|
82
|
+
const abs = join(root, rec);
|
|
83
|
+
// Only a copy identical to HEAD in both worktree and index is script-owned.
|
|
84
|
+
if (!existsSync(abs) || !git(root, ["diff", "--quiet", "HEAD", "--", rec]).ok || !git(root, ["diff", "--quiet", "--cached", "HEAD", "--", rec]).ok) return line;
|
|
85
|
+
const parsed = parseRecord(readFileSync(abs, "utf8"));
|
|
86
|
+
if (!unfinished(parsed)) return line;
|
|
87
|
+
if (check) return `unfinished ${rec}`;
|
|
88
|
+
const prior = readFileSync(abs);
|
|
89
|
+
const failed = (reason) => {
|
|
90
|
+
writeFileSync(abs, prior);
|
|
91
|
+
git(root, ["reset", "-q", "--", rec]);
|
|
92
|
+
return `restore failed ${rec}: ${reason}`;
|
|
93
|
+
};
|
|
94
|
+
writeFileSync(abs, stamped(parsed, new Date().toISOString()));
|
|
95
|
+
const add = git(root, ["add", "-f", "--", rec]);
|
|
96
|
+
if (!add.ok) return failed(add.stderr);
|
|
97
|
+
const commit = git(root, ["commit", "-q", "-m", `telemetry: mark ${rec} shipped at landing`, "--", rec], COMMIT_TIMEOUT_MS);
|
|
98
|
+
if (!commit.ok) return failed(commit.stderr);
|
|
99
|
+
return `${line} (marked shipped)`;
|
|
100
|
+
}
|
|
101
|
+
|
|
70
102
|
function salvage(root, rec, base, check) {
|
|
71
103
|
const presence = git(root, ["cat-file", "-e", `HEAD:${rec}`]);
|
|
72
104
|
if (presence.timedOut) return `restore failed ${rec}: timed out`;
|
|
73
|
-
if (presence.ok) return
|
|
105
|
+
if (presence.ok) return stampPresent(root, rec, check);
|
|
74
106
|
const history = git(root, ["log", `${base}..HEAD`, "--grep=^telemetry: ", "-1", "--format=%H"]);
|
|
75
107
|
if (history.timedOut) return `restore failed ${rec}: timed out`;
|
|
76
108
|
if (!history.stdout) return `no telemetry run ${rec}`;
|
|
@@ -89,7 +121,12 @@ function salvage(root, rec, base, check) {
|
|
|
89
121
|
}
|
|
90
122
|
let created = false;
|
|
91
123
|
let stagedByScript = false;
|
|
124
|
+
let priorBytes;
|
|
92
125
|
const rollback = () => {
|
|
126
|
+
if (priorBytes !== undefined) {
|
|
127
|
+
writeFileSync(abs, priorBytes);
|
|
128
|
+
if (preStaged) git(root, ["add", "-f", "--", rec]);
|
|
129
|
+
}
|
|
93
130
|
if (stagedByScript) git(root, ["reset", "-q", "--", rec]);
|
|
94
131
|
if (created) rmSync(abs, { force: true });
|
|
95
132
|
};
|
|
@@ -102,15 +139,20 @@ function salvage(root, rec, base, check) {
|
|
|
102
139
|
const co = git(root, ["checkout", `${del}^`, "--", rec]);
|
|
103
140
|
if (!co.ok) return `restore failed ${rec}: ${co.stderr}`;
|
|
104
141
|
created = true;
|
|
105
|
-
stagedByScript = true;
|
|
106
|
-
} else if (!preStaged) {
|
|
107
|
-
const add = git(root, ["add", "-f", "--", rec]);
|
|
108
|
-
if (!add.ok) return failed(add.stderr);
|
|
109
|
-
stagedByScript = true;
|
|
110
142
|
}
|
|
143
|
+
stagedByScript = !preStaged;
|
|
144
|
+
let suffix = "";
|
|
145
|
+
const parsed = parseRecord(readFileSync(abs, "utf8"));
|
|
146
|
+
if (unfinished(parsed)) {
|
|
147
|
+
priorBytes = readFileSync(abs);
|
|
148
|
+
writeFileSync(abs, stamped(parsed, new Date().toISOString()));
|
|
149
|
+
suffix = " (marked shipped)";
|
|
150
|
+
}
|
|
151
|
+
const add = git(root, ["add", "-f", "--", rec]);
|
|
152
|
+
if (!add.ok) return failed(add.stderr);
|
|
111
153
|
const commit = git(root, ["commit", "-q", "-m", `telemetry: restore record stripped in ${del.slice(0, 10)}`, "--", rec], COMMIT_TIMEOUT_MS);
|
|
112
154
|
if (!commit.ok) return failed(commit.stderr);
|
|
113
|
-
return `restored ${rec} from ${del}`;
|
|
155
|
+
return `restored ${rec} from ${del}${suffix}`;
|
|
114
156
|
}
|
|
115
157
|
|
|
116
158
|
function main() {
|
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
const REC = ".pi/gauntlet/telemetry/doc/specs/x.yaml";
|
|
12
12
|
const RECORD_V1 = "schema: 1\nspec: doc/specs/x.md\nstatus: in_progress\n";
|
|
13
13
|
const RECORD_V2 = "schema: 1\nspec: doc/specs/x.md\nstatus: shipped\n";
|
|
14
|
+
// Records parseRecord accepts (run_id present). UNFINISHED: in_progress, no ship phase.
|
|
15
|
+
const UNFINISHED = "schema: 1\nspec: doc/specs/x.md\nrun_id: r-1\nstatus: in_progress\ncreated_at: 2026-09-17T16:11:45Z\nderived:\n duration_s: 60\n phases:\n brainstorm: { started_at: 2026-09-17T16:11:45Z, duration_s: 60 }\n";
|
|
16
|
+
const SHIP_PHASE = UNFINISHED.replace(" brainstorm:", " ship: { started_at: 2026-09-17T16:12:45Z }\n brainstorm:");
|
|
17
|
+
const SCALAR_PHASES = UNFINISHED.replace(" phases:\n brainstorm: { started_at: 2026-09-17T16:11:45Z, duration_s: 60 }", " phases: nope");
|
|
18
|
+
const FINISHED = UNFINISHED.replace("status: in_progress", "status: shipped\nshipped_at: 2026-09-17T18:56:13Z");
|
|
14
19
|
|
|
15
20
|
const git = (cwd, args, env = {}) =>
|
|
16
21
|
spawnSync("git", ["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false", ...args], {
|
|
@@ -32,7 +37,7 @@
|
|
|
32
37
|
return out(root, ["rev-parse", "HEAD"]);
|
|
33
38
|
};
|
|
34
39
|
// main: README only. feat: spec (+ record when withRecord).
|
|
35
|
-
const repo = ({ withRecord = true } = {}) => {
|
|
40
|
+
const repo = ({ withRecord = true, record = RECORD_V1 } = {}) => {
|
|
36
41
|
const root = mkdtempSync(join(tmpdir(), "gts-"));
|
|
37
42
|
git(root, ["init", "-q", "-b", "main"]);
|
|
38
43
|
write(root, "README.md", "# fixture\n");
|
|
@@ -41,7 +46,7 @@
|
|
|
41
46
|
write(root, SPEC, "# Spec X\n\n**Goal:** g.\n");
|
|
42
47
|
commit(root, "Add spec", SPEC);
|
|
43
48
|
if (withRecord) {
|
|
44
|
-
write(root, REC,
|
|
49
|
+
write(root, REC, record);
|
|
45
50
|
commit(root, "telemetry: doc/specs/x.md", REC);
|
|
46
51
|
}
|
|
47
52
|
return root;
|
|
@@ -348,6 +353,103 @@ PATH="${process.env.PATH.split(":").filter((part) => part !== bin).join(":")}" e
|
|
|
348
353
|
assert.equal(out(remote, ["for-each-ref"]), remoteBefore);
|
|
349
354
|
});
|
|
350
355
|
|
|
356
|
+
test("present + unfinished -> stamped shipped, one telemetry: commit, clean tree", (t) => {
|
|
357
|
+
const root = repo({ record: UNFINISHED }); cleanup(t, root);
|
|
358
|
+
const n = commitCount(root);
|
|
359
|
+
const r = run(root);
|
|
360
|
+
assert.equal(r.status, 0, r.stderr);
|
|
361
|
+
assert.deepEqual(r.lines, [`present ${REC} (marked shipped)`]);
|
|
362
|
+
const text = readFileSync(join(root, REC), "utf8");
|
|
363
|
+
assert.match(text, /^status: shipped$/m);
|
|
364
|
+
assert.match(text, /^shipped_at: \d{4}-\d{2}-\d{2}T/m);
|
|
365
|
+
assert.doesNotMatch(text, /in_progress/);
|
|
366
|
+
assert.equal(commitCount(root), n + 1);
|
|
367
|
+
assert.equal(headSubject(root), `telemetry: mark ${REC} shipped at landing`);
|
|
368
|
+
assert.deepEqual(headFiles(root), [REC]);
|
|
369
|
+
assert.equal(porcelain(root), "");
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("present + scalar phases -> stamped shipped, exit 0", (t) => {
|
|
373
|
+
const root = repo({ record: SCALAR_PHASES }); cleanup(t, root);
|
|
374
|
+
const r = run(root);
|
|
375
|
+
assert.equal(r.status, 0, r.stderr);
|
|
376
|
+
assert.deepEqual(r.lines, [`present ${REC} (marked shipped)`]);
|
|
377
|
+
assert.match(readFileSync(join(root, REC), "utf8"), /^status: shipped$/m);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test("present + in_progress with a ship phase -> untouched", (t) => {
|
|
381
|
+
const root = repo({ record: SHIP_PHASE }); cleanup(t, root);
|
|
382
|
+
const before = head(root);
|
|
383
|
+
const r = run(root);
|
|
384
|
+
assert.deepEqual(r.lines, [`present ${REC}`]);
|
|
385
|
+
assert.equal(head(root), before);
|
|
386
|
+
assert.equal(readFileSync(join(root, REC), "utf8"), SHIP_PHASE);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test("present + unfinished but worktree copy dirty -> untouched, present", (t) => {
|
|
390
|
+
const root = repo({ record: UNFINISHED }); cleanup(t, root);
|
|
391
|
+
write(root, REC, UNFINISHED + "# local edit\n");
|
|
392
|
+
const before = head(root);
|
|
393
|
+
const r = run(root);
|
|
394
|
+
assert.deepEqual(r.lines, [`present ${REC}`]);
|
|
395
|
+
assert.equal(head(root), before);
|
|
396
|
+
assert.equal(readFileSync(join(root, REC), "utf8"), UNFINISHED + "# local edit\n");
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("present + already shipped -> byte-identical, no commit", (t) => {
|
|
400
|
+
const root = repo({ record: FINISHED }); cleanup(t, root);
|
|
401
|
+
const before = head(root);
|
|
402
|
+
const r = run(root);
|
|
403
|
+
assert.deepEqual(r.lines, [`present ${REC}`]);
|
|
404
|
+
assert.equal(head(root), before);
|
|
405
|
+
assert.equal(readFileSync(join(root, REC), "utf8"), FINISHED);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("restore with scalar phases -> stamped shipped, exit 0", (t) => {
|
|
409
|
+
const root = repo({ record: SCALAR_PHASES }); cleanup(t, root);
|
|
410
|
+
const del = rmCommit(root, "strip", REC);
|
|
411
|
+
const r = run(root);
|
|
412
|
+
assert.equal(r.status, 0, r.stderr);
|
|
413
|
+
assert.deepEqual(r.lines, [`restored ${REC} from ${del} (marked shipped)`]);
|
|
414
|
+
assert.match(out(root, ["show", `HEAD:${REC}`]), /^status: shipped$/m);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test("restore of an unfinished record -> one commit whose bytes carry the stamp", (t) => {
|
|
418
|
+
const root = repo({ record: UNFINISHED }); cleanup(t, root);
|
|
419
|
+
const del = rmCommit(root, "strip", REC);
|
|
420
|
+
write(root, REC, UNFINISHED);
|
|
421
|
+
const n = commitCount(root);
|
|
422
|
+
const r = run(root);
|
|
423
|
+
assert.deepEqual(r.lines, [`restored ${REC} from ${del} (marked shipped)`]);
|
|
424
|
+
assert.equal(commitCount(root), n + 1);
|
|
425
|
+
const committed = out(root, ["show", `HEAD:${REC}`]);
|
|
426
|
+
assert.match(committed, /^status: shipped$/m);
|
|
427
|
+
assert.match(committed, /^shipped_at: /m);
|
|
428
|
+
assert.equal(porcelain(root), "");
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test("--check on present + unfinished -> unfinished <rec>, nothing changes", (t) => {
|
|
432
|
+
const root = repo({ record: UNFINISHED }); cleanup(t, root);
|
|
433
|
+
const before = head(root);
|
|
434
|
+
const r = run(root, ["--check"]);
|
|
435
|
+
assert.deepEqual(r.lines, [`unfinished ${REC}`]);
|
|
436
|
+
assert.equal(head(root), before);
|
|
437
|
+
assert.equal(readFileSync(join(root, REC), "utf8"), UNFINISHED);
|
|
438
|
+
assert.equal(porcelain(root), "");
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test("present + unfinished, rejecting pre-commit hook -> restore failed, HEAD bytes and index restored", (t) => {
|
|
442
|
+
const root = repo({ record: UNFINISHED }); cleanup(t, root);
|
|
443
|
+
write(root, ".git/hooks/pre-commit", "#!/bin/sh\nexit 1\n");
|
|
444
|
+
chmodSync(join(root, ".git/hooks/pre-commit"), 0o755);
|
|
445
|
+
const before = head(root);
|
|
446
|
+
const r = run(root);
|
|
447
|
+
assert.match(r.stdout, new RegExp(`^restore failed ${REC.replace(/\./g, "\\.")}: `));
|
|
448
|
+
assert.equal(head(root), before);
|
|
449
|
+
assert.equal(readFileSync(join(root, REC), "utf8"), UNFINISHED);
|
|
450
|
+
assert.equal(porcelain(root), "");
|
|
451
|
+
});
|
|
452
|
+
|
|
351
453
|
test("finishing Option 1 fixture: strip-on-branch + salvage + merge --squash leaves the record, not the plan, staged", (t) => {
|
|
352
454
|
const root = repo(); cleanup(t, root);
|
|
353
455
|
const plan = "doc/plans/x.md";
|
|
@@ -3,7 +3,7 @@ import { after, test } from "node:test";
|
|
|
3
3
|
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, realpathSync } from "node:fs";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
import { checkoutOf, parseCheckout, gitSync, type GitResult } from "./checkout.ts";
|
|
6
|
+
import { checkoutOf, checkoutOfSync, parseCheckout, gitSync, jjSync, type GitResult } from "./checkout.ts";
|
|
7
7
|
|
|
8
8
|
const tempDirs: string[] = [];
|
|
9
9
|
after(() => { for (const d of tempDirs) rmSync(d, { recursive: true, force: true }); });
|
|
@@ -32,7 +32,7 @@ test("checkoutOf: a file path runs git in its dirname", async () => {
|
|
|
32
32
|
writeFileSync(join(dir, "spec.md"), "# s\n");
|
|
33
33
|
const cwds: string[] = [];
|
|
34
34
|
const git = (_args: string[], cwd: string): GitResult => { cwds.push(cwd); return out([dir, dir + "/.git", dir + "/.git"]); };
|
|
35
|
-
assert.deepEqual(await checkoutOf(join(dir, "spec.md"), git), { toplevel: dir, isPrimary: true });
|
|
35
|
+
assert.deepEqual(await checkoutOf(join(dir, "spec.md"), git), { toplevel: dir, isPrimary: true, via: "git" });
|
|
36
36
|
assert.deepEqual(cwds, [dir]);
|
|
37
37
|
});
|
|
38
38
|
|
|
@@ -57,3 +57,66 @@ test("gitSync: never throws; nonzero code outside a repo", () => {
|
|
|
57
57
|
assert.notEqual(r.code, 0);
|
|
58
58
|
assert.equal(typeof r.stdout, "string");
|
|
59
59
|
});
|
|
60
|
+
|
|
61
|
+
test("checkoutOf: falls back to `jj root` when git fails, added workspace is not primary", async () => {
|
|
62
|
+
const dir = tmp();
|
|
63
|
+
mkdirSync(join(dir, ".jj"));
|
|
64
|
+
writeFileSync(join(dir, ".jj", "repo"), "/main/repo/.jj/repo\n"); // pointer file in added workspaces
|
|
65
|
+
const git = (): GitResult => ({ code: 128, stdout: "" });
|
|
66
|
+
const jj = (args: string[], cwd: string): GitResult => {
|
|
67
|
+
assert.deepEqual(args, ["root"]);
|
|
68
|
+
assert.equal(cwd, dir);
|
|
69
|
+
return { code: 0, stdout: dir + "\n" };
|
|
70
|
+
};
|
|
71
|
+
assert.deepEqual(await checkoutOf(join(dir, "doc", "plan.md"), git, jj), { toplevel: dir, isPrimary: false, via: "jj" });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("checkoutOf: jj primary checkout keeps .jj/repo as a directory", async () => {
|
|
75
|
+
const dir = tmp();
|
|
76
|
+
mkdirSync(join(dir, ".jj", "repo"), { recursive: true });
|
|
77
|
+
const git = (): GitResult => ({ code: 128, stdout: "" });
|
|
78
|
+
const jj = (): GitResult => ({ code: 0, stdout: dir + "\n" });
|
|
79
|
+
assert.deepEqual(await checkoutOf(dir, git, jj), { toplevel: dir, isPrimary: true, via: "jj" });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("checkoutOf: git result wins when it resolves, jj is not consulted", async () => {
|
|
83
|
+
const dir = tmp();
|
|
84
|
+
let jjCalled = false;
|
|
85
|
+
const git = (): GitResult => out([dir, dir + "/.git", dir + "/.git"]);
|
|
86
|
+
const jj = (): GitResult => { jjCalled = true; return { code: 0, stdout: "/elsewhere\n" }; };
|
|
87
|
+
assert.deepEqual(await checkoutOf(dir, git, jj), { toplevel: dir, isPrimary: true, via: "git" });
|
|
88
|
+
assert.equal(jjCalled, false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("checkoutOf: undefined when neither git nor jj resolves", async () => {
|
|
92
|
+
const fail = (): GitResult => ({ code: 1, stdout: "" });
|
|
93
|
+
assert.equal(await checkoutOf(join(tmp(), "x.md"), fail, fail), undefined);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("jjSync: never throws; nonzero code outside a jj repo", () => {
|
|
97
|
+
const r = jjSync(["root"], tmp());
|
|
98
|
+
assert.notEqual(r.code, 0);
|
|
99
|
+
assert.equal(typeof r.stdout, "string");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("checkoutOfSync: git wins without consulting jj", () => {
|
|
103
|
+
const dir = tmp();
|
|
104
|
+
let jjCalled = false;
|
|
105
|
+
const git = (): GitResult => out([dir, dir + "/.git", dir + "/.git"]);
|
|
106
|
+
const jj = (): GitResult => { jjCalled = true; return { code: 0, stdout: "/elsewhere\n" }; };
|
|
107
|
+
assert.deepEqual(checkoutOfSync(dir, git, jj), { toplevel: dir, isPrimary: true, via: "git" });
|
|
108
|
+
assert.equal(jjCalled, false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("checkoutOfSync: falls back to `jj root` when git fails", () => {
|
|
112
|
+
const dir = tmp();
|
|
113
|
+
mkdirSync(join(dir, ".jj", "repo"), { recursive: true });
|
|
114
|
+
const git = (): GitResult => ({ code: 128, stdout: "" });
|
|
115
|
+
const jj = (): GitResult => ({ code: 0, stdout: dir + "\n" });
|
|
116
|
+
assert.deepEqual(checkoutOfSync(join(dir, "doc", "specs"), git, jj), { toplevel: dir, isPrimary: true, via: "jj" });
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("checkoutOfSync: undefined when neither git nor jj resolves", () => {
|
|
120
|
+
const fail = (): GitResult => ({ code: 1, stdout: "" });
|
|
121
|
+
assert.equal(checkoutOfSync(join(tmp(), "x.md"), fail, fail), undefined);
|
|
122
|
+
});
|