pi-gauntlet 5.18.6 → 5.18.7
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
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v5.18.7 - 2026-09-24
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- The telemetry recorder derives `modified_files` and diff buckets in plain jj workspaces through `jj` (remote trunk or local `main`/`master` as the mainline), with jj-named warnings and an intact record on any jj failure. (#51)
|
|
8
|
+
|
|
3
9
|
## v5.18.6 - 2026-09-24
|
|
4
10
|
|
|
5
11
|
### Changed
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
matchDiscardStatement,
|
|
10
10
|
matchShipStatement,
|
|
11
11
|
matchTestStatement,
|
|
12
|
+
parsePatchNumstat,
|
|
12
13
|
parseSpecLinks,
|
|
13
14
|
planSpecHeader,
|
|
14
15
|
recordPathFor,
|
|
@@ -95,6 +96,48 @@ test("aggregateNumstat sums per bucket over the given file set; binary rows coun
|
|
|
95
96
|
});
|
|
96
97
|
});
|
|
97
98
|
|
|
99
|
+
const patch = (...blocks: string[][]) => blocks.map((b) => b.join("\n")).join("\n") + "\n";
|
|
100
|
+
|
|
101
|
+
test("parsePatchNumstat counts hunk lines and paths with spaces", () => {
|
|
102
|
+
const p = patch(
|
|
103
|
+
["diff --git a/src/a.ts b/src/a.ts", "--- a/src/a.ts", "+++ b/src/a.ts", "@@ -1 +1 @@", "+a", "+b", "-c", "\"],
|
|
104
|
+
["diff --git a/sp ace.txt b/sp ace.txt", "--- a/sp ace.txt", "+++ b/sp ace.txt", "@@ -1 +1 @@", "-x", "+y"],
|
|
105
|
+
);
|
|
106
|
+
assert.deepEqual(parsePatchNumstat(p), [{ added: 2, removed: 1, path: "src/a.ts" }, { added: 1, removed: 1, path: "sp ace.txt" }]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("parsePatchNumstat handles renames and delete/add pairs", () => {
|
|
110
|
+
assert.deepEqual(parsePatchNumstat(patch(
|
|
111
|
+
["diff --git a/old.ts b/new.ts", "rename from old.ts", "rename to new.ts"],
|
|
112
|
+
["diff --git a/o2.ts b/n2.ts", "rename from o2.ts", "rename to n2.ts", "--- a/o2.ts", "+++ b/n2.ts", "@@ -1 +1,2 @@", " k", "+extra"],
|
|
113
|
+
["diff --git a/gone.ts b/gone.ts", "deleted file mode 100644", "--- a/gone.ts", "+++ /dev/null", "@@ -1 +0,0 @@", "-old"],
|
|
114
|
+
["diff --git a/newer.ts b/newer.ts", "new file mode 100644", "--- /dev/null", "+++ b/newer.ts", "@@ -0,0 +1 @@", "+new"],
|
|
115
|
+
)), [
|
|
116
|
+
{ added: 0, removed: 0, path: "new.ts" }, { added: 1, removed: 0, path: "n2.ts" },
|
|
117
|
+
{ added: 0, removed: 1, path: "gone.ts" }, { added: 1, removed: 0, path: "newer.ts" },
|
|
118
|
+
]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("parsePatchNumstat handles binary, mode-only and positional hunk headers", () => {
|
|
122
|
+
assert.deepEqual(parsePatchNumstat(patch(
|
|
123
|
+
["diff --git a/dir b/icon.png b/dir b/icon.png", "Binary files a/dir b/icon.png and b/dir b/icon.png differ"],
|
|
124
|
+
["diff --git a/run.sh b/run.sh", "old mode 100644", "new mode 100755"],
|
|
125
|
+
["diff --git a/image.png b/image.png", "GIT binary patch", "literal 4"],
|
|
126
|
+
["diff --git a/test/fm.md b/test/fm.md", "--- a/test/fm.md", "+++ b/test/fm.md", "@@ -1 +1 @@", "----", "++text"],
|
|
127
|
+
)), [
|
|
128
|
+
{ added: 0, removed: 0, path: "dir b/icon.png" }, { added: 0, removed: 0, path: "run.sh" },
|
|
129
|
+
{ added: 0, removed: 0, path: "image.png" }, { added: 1, removed: 1, path: "test/fm.md" },
|
|
130
|
+
]);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("parsePatchNumstat rejects malformed input and accepts empty patches", () => {
|
|
134
|
+
assert.deepEqual(parsePatchNumstat(""), []);
|
|
135
|
+
assert.deepEqual(parsePatchNumstat(" \n\n"), []);
|
|
136
|
+
assert.equal(parsePatchNumstat("garbage\n"), null);
|
|
137
|
+
assert.equal(parsePatchNumstat("diff --git a/x b/y\nindex 1..2\n"), null);
|
|
138
|
+
assert.equal(parsePatchNumstat("diff --git c/x b/x\n"), null);
|
|
139
|
+
});
|
|
140
|
+
|
|
98
141
|
test("parseSpecLinks reads Supersedes/Fixes banners as paths or markdown links", () => {
|
|
99
142
|
const body = "# T\n\n> **Supersedes:** [doc/specs/a.md](./a.md), doc/specs/b.md\n> **Fixes:** [doc/specs/c.md](./c.md)\n";
|
|
100
143
|
assert.deepEqual(parseSpecLinks(body), { supersedes: ["doc/specs/a.md", "doc/specs/b.md"], fixes: ["doc/specs/c.md"] });
|
|
@@ -116,6 +116,60 @@ export function aggregateNumstat(numstat: string, files: Set<string>, buckets: [
|
|
|
116
116
|
return out;
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
export interface PatchRow {
|
|
120
|
+
added: number;
|
|
121
|
+
removed: number;
|
|
122
|
+
path: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function patchBlockPath(headers: string[]): string | undefined {
|
|
126
|
+
const plus = headers.find((l) => l.startsWith("+++ b/"));
|
|
127
|
+
if (plus) return plus.slice(6);
|
|
128
|
+
const renameTo = headers.find((l) => l.startsWith("rename to "));
|
|
129
|
+
if (renameTo) return renameTo.slice(10);
|
|
130
|
+
const minus = headers.find((l) => l.startsWith("--- a/"));
|
|
131
|
+
if (minus && headers.includes("+++ /dev/null")) return minus.slice(6);
|
|
132
|
+
if (!headers[0].startsWith("diff --git a/")) return undefined;
|
|
133
|
+
const rest = headers[0].slice("diff --git a/".length);
|
|
134
|
+
const mid = (rest.length - 3) / 2;
|
|
135
|
+
if (Number.isInteger(mid) && mid > 0 && rest.slice(mid, mid + 3) === " b/" && rest.slice(0, mid) === rest.slice(mid + 3)) return rest.slice(0, mid);
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function parsePatchBlock(block: string[]): PatchRow | undefined {
|
|
140
|
+
const hunk = block.findIndex((l) => l.startsWith("@@"));
|
|
141
|
+
const headers = hunk < 0 ? block : block.slice(0, hunk);
|
|
142
|
+
const path = patchBlockPath(headers);
|
|
143
|
+
if (path === undefined) return undefined;
|
|
144
|
+
let added = 0;
|
|
145
|
+
let removed = 0;
|
|
146
|
+
for (const line of hunk < 0 ? [] : block.slice(hunk)) {
|
|
147
|
+
if (line.startsWith("+")) added++;
|
|
148
|
+
else if (line.startsWith("-")) removed++;
|
|
149
|
+
}
|
|
150
|
+
return { added, removed, path };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// jj has no numstat: --stat truncates paths and -T exposes no line counts.
|
|
154
|
+
// Headers end at the first @@; subsequent ---- and ++text lines count as content.
|
|
155
|
+
export function parsePatchNumstat(patch: string): PatchRow[] | null {
|
|
156
|
+
if (!patch.trim()) return [];
|
|
157
|
+
const lines = patch.split("\n");
|
|
158
|
+
if (!lines.find((l) => l.trim())!.startsWith("diff --git ")) return null;
|
|
159
|
+
const blocks: string[][] = [];
|
|
160
|
+
for (const line of lines) {
|
|
161
|
+
if (line.startsWith("diff --git ")) blocks.push([line]);
|
|
162
|
+
else if (blocks.length) blocks[blocks.length - 1].push(line);
|
|
163
|
+
}
|
|
164
|
+
const rows: PatchRow[] = [];
|
|
165
|
+
for (const block of blocks) {
|
|
166
|
+
const row = parsePatchBlock(block);
|
|
167
|
+
if (!row) return null;
|
|
168
|
+
rows.push(row);
|
|
169
|
+
}
|
|
170
|
+
return rows;
|
|
171
|
+
}
|
|
172
|
+
|
|
119
173
|
// ---- spec banners --------------------------------------------------------------
|
|
120
174
|
|
|
121
175
|
const LINK_BANNER_RE = /^> \*\*(Supersedes|Fixes):\*\*\s*(.+)$/gm;
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
type Handler = (event: any, ctx: any) => unknown;
|
|
22
22
|
|
|
23
|
-
function harness(o: { branch?: unknown[]; enabled?: boolean; gitFail?: (args: string[], cwd: string) => GitResult | undefined; jjWorkspace?: boolean; cwdSub?: string; sessionId?: string; model?: { provider: string; id: string }; thinkingLevel?: string; contextTokens?: number | null; telemetryWarning?: string; telemetryDir?: string } = {}) {
|
|
23
|
+
function harness(o: { branch?: unknown[]; enabled?: boolean; gitFail?: (args: string[], cwd: string) => GitResult | undefined; jjWorkspace?: boolean; jjFail?: (args: string[], cwd: string) => GitResult | undefined; cwdSub?: string; sessionId?: string; model?: { provider: string; id: string }; thinkingLevel?: string; contextTokens?: number | null; telemetryWarning?: string; telemetryDir?: string } = {}) {
|
|
24
24
|
const root = mkdtempSync(join(tmpdir(), "telemetry-test-"));
|
|
25
25
|
tempDirs.push(root);
|
|
26
26
|
mkdirSync(join(root, "doc/specs"), { recursive: true });
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
const handlers = new Map<string, Handler[]>();
|
|
30
30
|
const gitCalls: string[][] = [];
|
|
31
31
|
const gitCwds: string[] = [];
|
|
32
|
+
const jjCalls: string[][] = [];
|
|
32
33
|
const readFileCalls: string[] = [];
|
|
33
34
|
let clock = Date.parse("2026-09-17T10:00:00Z");
|
|
34
35
|
const deps: Deps = {
|
|
@@ -53,7 +54,13 @@
|
|
|
53
54
|
},
|
|
54
55
|
settings: () => ({ telemetry: { enabled: o.enabled ?? true, dir: o.telemetryDir ?? ".pi/gauntlet/telemetry", buckets: DEFAULT_TELEMETRY_BUCKETS, warning: o.telemetryWarning }, errors: [], agentOverrides: { implementer: { model: "p/x" } }, versions: { pi: "0.85.1" } }),
|
|
55
56
|
// A plain jj workspace: `jj root` answers the temp root while git rev-parse fails.
|
|
56
|
-
|
|
57
|
+
jj: async (args, cwd) => {
|
|
58
|
+
jjCalls.push(args);
|
|
59
|
+
const forced = o.jjFail?.(args, cwd);
|
|
60
|
+
if (forced) return forced;
|
|
61
|
+
if (!o.jjWorkspace) return { code: 1, stdout: "", stderr: "jj stub: not a jj workspace" };
|
|
62
|
+
return shipJj(args, root);
|
|
63
|
+
},
|
|
57
64
|
};
|
|
58
65
|
let branch = o.branch ?? [];
|
|
59
66
|
const ctx = {
|
|
@@ -79,11 +86,39 @@
|
|
|
79
86
|
await emit("tool_call", { toolName: "write", toolCallId: "w", input: { path: rel, content: body } });
|
|
80
87
|
return emit("tool_result", { toolName: "write", toolCallId: "w", input: { path: rel, content: body }, content: [], isError: false, details: undefined });
|
|
81
88
|
};
|
|
82
|
-
return { root, handlers, emit, gitCalls, gitCwds, readFileCalls, commits, recordPath, readRecord, phaseResult, writeSpec, setBranch: (b: unknown[]) => (branch = b), ctx };
|
|
89
|
+
return { root, handlers, emit, gitCalls, gitCwds, jjCalls, readFileCalls, commits, recordPath, readRecord, phaseResult, writeSpec, setBranch: (b: unknown[]) => (branch = b), ctx };
|
|
83
90
|
}
|
|
84
91
|
|
|
85
92
|
const P = (over: Record<string, string> = {}) => Object.fromEntries(["brainstorm", "plan", "implement", "verify", "ship"].map((p) => [p, { status: over[p] ?? "pending" }]));
|
|
86
93
|
|
|
94
|
+
const JJ_PATCH = [
|
|
95
|
+
"diff --git a/src/a.ts b/src/a.ts",
|
|
96
|
+
"index 1111111..2222222 100644",
|
|
97
|
+
"--- a/src/a.ts",
|
|
98
|
+
"+++ b/src/a.ts",
|
|
99
|
+
"@@ -1,4 +1,12 @@",
|
|
100
|
+
...Array.from({ length: 10 }, (_, i) => `+added ${i}`),
|
|
101
|
+
"-removed 1",
|
|
102
|
+
"-removed 2",
|
|
103
|
+
" context",
|
|
104
|
+
"diff --git a/test/a.test.ts b/test/a.test.ts",
|
|
105
|
+
"new file mode 100644",
|
|
106
|
+
"index 0000000..3333333",
|
|
107
|
+
"--- /dev/null",
|
|
108
|
+
"+++ b/test/a.test.ts",
|
|
109
|
+
"@@ -0,0 +1,5 @@",
|
|
110
|
+
...Array.from({ length: 5 }, (_, i) => `+t ${i}`),
|
|
111
|
+
"",
|
|
112
|
+
].join("\n");
|
|
113
|
+
|
|
114
|
+
function shipJj(args: string[], root: string): GitResult {
|
|
115
|
+
if (args[0] === "root") return { code: 0, stdout: root + "\n", stderr: "" };
|
|
116
|
+
if (args.includes("--count")) return { code: 0, stdout: args.some((a) => a.includes('files(~glob:".pi/gauntlet/telemetry/**")')) ? "2\n" : "3\n", stderr: "" };
|
|
117
|
+
if (args.includes("log")) return { code: 0, stdout: "jjbase0001\n", stderr: "" };
|
|
118
|
+
if (args.includes("diff")) return { code: 0, stdout: JJ_PATCH, stderr: "" };
|
|
119
|
+
return { code: 1, stdout: "", stderr: `jj stub: unexpected call ${args.join(" ")}` };
|
|
120
|
+
}
|
|
121
|
+
|
|
87
122
|
test("enabled: false registers handlers that no-op", async () => {
|
|
88
123
|
const h = harness({ enabled: false });
|
|
89
124
|
await h.emit("session_start", { type: "session_start", reason: "startup" });
|
|
@@ -726,6 +761,100 @@
|
|
|
726
761
|
assert.ok(rec.events.some((e) => e.kind === "warning" && /base/.test((e as any).message)));
|
|
727
762
|
});
|
|
728
763
|
|
|
764
|
+
async function boundInJjShip(jjFail?: (args: string[]) => GitResult | undefined) {
|
|
765
|
+
const h = harness({
|
|
766
|
+
jjWorkspace: true,
|
|
767
|
+
jjFail: jjFail ? (args) => jjFail(args) : undefined,
|
|
768
|
+
gitFail: (args) => (args[0] === "rev-parse" && args.includes("--path-format=absolute") ? { code: 128, stdout: "", stderr: "fatal: not a git repository" } : undefined),
|
|
769
|
+
});
|
|
770
|
+
await h.phaseResult("start", P({ brainstorm: "in_progress" }));
|
|
771
|
+
await h.writeSpec("doc/specs/a.md");
|
|
772
|
+
await h.phaseResult("complete", P({ brainstorm: "complete" }));
|
|
773
|
+
await h.phaseResult("start", P({ brainstorm: "complete", plan: "complete", implement: "complete", verify: "complete", ship: "in_progress" }));
|
|
774
|
+
await h.phaseResult("complete", P({ brainstorm: "complete", plan: "complete", implement: "complete", verify: "complete", ship: "complete" }));
|
|
775
|
+
return h;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const diffWarnings = (h: { readRecord: () => ReturnType<typeof parseRecord> }) =>
|
|
779
|
+
h.readRecord()!.events.filter((e) => e.kind === "warning").map((e: any) => e.message as string).filter((m) => m.startsWith("diff omitted"));
|
|
780
|
+
|
|
781
|
+
const isBaseLog = (args: string[]) => args.includes("log") && !args.includes("--count");
|
|
782
|
+
|
|
783
|
+
test("jj ship (AC 1): keep path derives modified_files and diff through jj with pinned output flags", async () => {
|
|
784
|
+
const h = await boundInJjShip();
|
|
785
|
+
const rec = h.readRecord();
|
|
786
|
+
assert.equal(rec.status, "shipped");
|
|
787
|
+
assert.equal(rec.derived.gates.ship_option, "keep");
|
|
788
|
+
assert.deepEqual(rec.derived.modified_files, ["src/a.ts", "test/a.test.ts"]);
|
|
789
|
+
assert.deepEqual(rec.derived.diff, { base: "jjbase0001", commits: 2, buckets: { code: { files: 1, insertions: 10, deletions: 2 }, test: { files: 1, insertions: 5, deletions: 0 } } });
|
|
790
|
+
assert.deepEqual(diffWarnings(h), []);
|
|
791
|
+
const diffSteps = h.jjCalls.filter((a) => a[0] !== "root");
|
|
792
|
+
assert.equal(diffSteps.length, 3);
|
|
793
|
+
assert.ok(diffSteps.every((a) => a.includes("--color=never")));
|
|
794
|
+
assert.deepEqual(diffSteps[1].slice(0, 4), ["--color=never", "--config", "diff.git.show-path-prefix=true", "diff"]);
|
|
795
|
+
assert.ok(diffSteps[0].some((a) => a.includes("fork_point(") && a.includes("~ root() & ::")));
|
|
796
|
+
});
|
|
797
|
+
|
|
798
|
+
test("jj ship (AC 2): empty mainline -> jj-named warning, both derived fields absent, record written", async () => {
|
|
799
|
+
const h = await boundInJjShip((args) => (isBaseLog(args) ? { code: 0, stdout: "", stderr: "" } : undefined));
|
|
800
|
+
const rec = h.readRecord();
|
|
801
|
+
assert.equal(rec.status, "shipped");
|
|
802
|
+
assert.equal(rec.derived.diff, undefined);
|
|
803
|
+
assert.equal(rec.derived.modified_files, undefined);
|
|
804
|
+
assert.deepEqual(diffWarnings(h), ["diff omitted: jj mainline unresolved (trunk() is root(); no main/master bookmark)"]);
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
test("jj ship: jj diff nonzero exit -> first stderr line in the warning", async () => {
|
|
808
|
+
const h = await boundInJjShip((args) => (args.includes("diff") ? { code: 1, stdout: "", stderr: "Error: Revision `jjbase0001` doesn't exist\nHint: try jj log\n" } : undefined));
|
|
809
|
+
assert.deepEqual(diffWarnings(h), ["diff omitted: jj diff failed: Error: Revision `jjbase0001` doesn't exist"]);
|
|
810
|
+
assert.equal(h.readRecord().derived.diff, undefined);
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
test("jj ship: ENOENT-shaped runner result (empty stderr) -> 'unknown error'", async () => {
|
|
814
|
+
const h = await boundInJjShip((args) => (isBaseLog(args) ? { code: 127, stdout: "", stderr: "" } : undefined));
|
|
815
|
+
assert.deepEqual(diffWarnings(h), ["diff omitted: jj log failed: unknown error"]);
|
|
816
|
+
assert.equal(h.readRecord().status, "shipped");
|
|
817
|
+
});
|
|
818
|
+
|
|
819
|
+
test("jj ship: unparseable patch -> warning, neither field set", async () => {
|
|
820
|
+
const h = await boundInJjShip((args) => (args.includes("diff") ? { code: 0, stdout: "not a patch\n", stderr: "" } : undefined));
|
|
821
|
+
assert.deepEqual(diffWarnings(h), ["diff omitted: jj diff unparseable"]);
|
|
822
|
+
const rec = h.readRecord();
|
|
823
|
+
assert.equal(rec.derived.diff, undefined);
|
|
824
|
+
assert.equal(rec.derived.modified_files, undefined);
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
test("jj ship: malformed patch header -> warning, neither field set, record shipped", async () => {
|
|
828
|
+
const h = await boundInJjShip((args) => (args.includes("diff") ? { code: 0, stdout: "diff --git c/x b/x\n", stderr: "" } : undefined));
|
|
829
|
+
assert.deepEqual(diffWarnings(h), ["diff omitted: jj diff unparseable"]);
|
|
830
|
+
const rec = h.readRecord();
|
|
831
|
+
assert.equal(rec.status, "shipped");
|
|
832
|
+
assert.equal(rec.derived.diff, undefined);
|
|
833
|
+
assert.equal(rec.derived.modified_files, undefined);
|
|
834
|
+
});
|
|
835
|
+
|
|
836
|
+
test("jj ship: --count failure -> jj log warning, no derived diff", async () => {
|
|
837
|
+
const h = await boundInJjShip((args) => (args.includes("--count") ? { code: 1, stdout: "", stderr: "Error: invalid revset\nHint: check the mainline\n" } : undefined));
|
|
838
|
+
assert.deepEqual(diffWarnings(h), ["diff omitted: jj log failed: Error: invalid revset"]);
|
|
839
|
+
const rec = h.readRecord();
|
|
840
|
+
assert.equal(rec.status, "shipped");
|
|
841
|
+
assert.equal(rec.derived.diff, undefined);
|
|
842
|
+
assert.equal(rec.derived.modified_files, undefined);
|
|
843
|
+
});
|
|
844
|
+
|
|
845
|
+
test("jj ship: unparseable --count -> warning with the raw output", async () => {
|
|
846
|
+
const h = await boundInJjShip((args) => (args.includes("--count") ? { code: 0, stdout: "abc\n", stderr: "" } : undefined));
|
|
847
|
+
assert.deepEqual(diffWarnings(h), ["diff omitted: jj log --count unparseable: abc"]);
|
|
848
|
+
assert.equal(h.readRecord().derived.diff, undefined);
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
test("git ship never calls jj", async () => {
|
|
852
|
+
const h = await boundInShip();
|
|
853
|
+
await h.bash("s1", "git push");
|
|
854
|
+
assert.deepEqual(h.readRecord().derived.modified_files, ["README.md", "extensions/telemetry.test.ts", "extensions/telemetry.ts"]);
|
|
855
|
+
assert.deepEqual(h.jjCalls, []);
|
|
856
|
+
});
|
|
857
|
+
|
|
729
858
|
test("guard: write to a shipped spec during brainstorm is blocked before any state change; edit passes; other phases pass", async () => {
|
|
730
859
|
const h = await boundInShip();
|
|
731
860
|
await h.bash("s1", "git push");
|
package/extensions/telemetry.ts
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import { checkoutOf } from "./lib/checkout.ts";
|
|
19
19
|
import { CONTEXT_DRAFT_MARKER } from "./lib/phase-tracker-helpers.ts";
|
|
20
20
|
import { sha256 } from "./lib/plan-check.ts";
|
|
21
|
-
import { SUPERSEDED_BY_RE, aggregateNumstat, isPlanPath, isSpecPath, isSupersededByBanner, matchDiscardStatement, matchShipStatement, matchTestStatement, parseSpecLinks, planSpecHeader, recordPathFor, repoRelativeToolPath, toPosix, truncateCommand } from "./lib/telemetry-paths.ts";
|
|
21
|
+
import { SUPERSEDED_BY_RE, aggregateNumstat, isPlanPath, isSpecPath, isSupersededByBanner, matchDiscardStatement, matchShipStatement, matchTestStatement, parsePatchNumstat, parseSpecLinks, planSpecHeader, recordPathFor, repoRelativeToolPath, toPosix, truncateCommand } from "./lib/telemetry-paths.ts";
|
|
22
22
|
import { COUNTED_USER_PHASES, REVIEWER_AGENTS, countFindings, countOpenGaps, hasReopen, insertedText, planTotals, textOf, usageToTokens } from "./lib/telemetry-collect.ts";
|
|
23
23
|
import { addTokens, capEvents, compact, currentPhase, derive, diffPhases, emptyAccumulators, emptyPhases, foldAccumulators, implementAutoCompletes, liveShipEvent, newRecord, parseRecord, serializeRecord, type Accumulators, type BaseEvent, type PhaseAcc, type PhaseKey, type PhaseMap, type TelemetryEvent, type TelemetryRecord, type Tokens } from "./lib/telemetry-record.ts";
|
|
24
24
|
import { BASE_REFS, guardReason, modifiedFilesFrom } from "./lib/telemetry-ship.ts";
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
export interface Deps {
|
|
49
49
|
fs: FsPort;
|
|
50
50
|
git: (args: string[], cwd: string) => Promise<GitResult>;
|
|
51
|
-
//
|
|
51
|
+
// jj runner; production uses realJj for both checkout detection and the ship-time diff.
|
|
52
52
|
jj?: (args: string[], cwd: string) => GitResult | Promise<GitResult>;
|
|
53
53
|
now: () => string;
|
|
54
54
|
settings: (cwd: string) => SettingsSnapshot;
|
|
@@ -78,6 +78,16 @@
|
|
|
78
78
|
});
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
+
// jj diff --git returns full patches; execFile's default buffer is too small.
|
|
82
|
+
// On ENOENT stderr is empty, so preserve the error message.
|
|
83
|
+
export const realJj = (args: string[], cwd: string): Promise<GitResult> =>
|
|
84
|
+
new Promise((res) => {
|
|
85
|
+
execFile("jj", args, { cwd, timeout: 10_000, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
86
|
+
const code = err ? (typeof (err as { code?: unknown }).code === "number" ? ((err as { code: number }).code) : 1) : 0;
|
|
87
|
+
res({ code, stdout: String(stdout ?? ""), stderr: String(stderr ?? "").trim() || String(err?.message ?? "") });
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
81
91
|
const isoNow = (): string => new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
82
92
|
|
|
83
93
|
const readPkgVersion = (p: string): string | undefined => {
|
|
@@ -115,7 +125,7 @@
|
|
|
115
125
|
return { telemetry, errors, agentOverrides, versions: realVersions(), testCommands };
|
|
116
126
|
}
|
|
117
127
|
|
|
118
|
-
export const realDeps: Deps = { fs: realFs, git: realGit, now: isoNow, settings: realSettings };
|
|
128
|
+
export const realDeps: Deps = { fs: realFs, git: realGit, jj: realJj, now: isoNow, settings: realSettings };
|
|
119
129
|
|
|
120
130
|
// ---- replay ------------------------------------------------------------------------
|
|
121
131
|
|
|
@@ -635,6 +645,38 @@
|
|
|
635
645
|
let pendingShip: { id: string; option: "squash" | "pr" | "discard" } | undefined;
|
|
636
646
|
|
|
637
647
|
const computeDiff = async (snap: SettingsSnapshot) => {
|
|
648
|
+
if (checkoutVia === "jj") await computeJjDiff(snap);
|
|
649
|
+
else await computeGitDiff(snap);
|
|
650
|
+
};
|
|
651
|
+
|
|
652
|
+
const JJ_MAINLINE = "coalesce(trunk() ~ root(), present(main), present(master))";
|
|
653
|
+
|
|
654
|
+
const computeJjDiff = async (snap: SettingsSnapshot) => {
|
|
655
|
+
if (!record || !toplevel) return;
|
|
656
|
+
const jj = deps.jj ?? realJj;
|
|
657
|
+
const failed = (sub: string, r: GitResult) => warn(`diff omitted: jj ${sub} failed: ${r.stderr.trim().split("\n")[0] || "unknown error"}`);
|
|
658
|
+
// Without the ::M guard an empty mainline resolves to @ and counts the full history.
|
|
659
|
+
const baseR = await jj(["--color=never", "log", "-r", `fork_point(${JJ_MAINLINE} | @) ~ root() & ::${JJ_MAINLINE}`, "--no-graph", "-T", 'commit_id ++ "\\n"'], toplevel);
|
|
660
|
+
if (baseR.code !== 0) return failed("log", baseR);
|
|
661
|
+
const base = baseR.stdout.trim().split("\n")[0].trim();
|
|
662
|
+
if (!base) return warn("diff omitted: jj mainline unresolved (trunk() is root(); no main/master bookmark)");
|
|
663
|
+
const patch = await jj(["--color=never", "--config", "diff.git.show-path-prefix=true", "diff", "--from", base, "--to", "@", "--git"], toplevel);
|
|
664
|
+
if (patch.code !== 0) return failed("diff", patch);
|
|
665
|
+
const rows = parsePatchNumstat(patch.stdout);
|
|
666
|
+
if (!rows) return warn("diff omitted: jj diff unparseable");
|
|
667
|
+
const dir = currentDir.replace(/\/+$/, "");
|
|
668
|
+
// Exclude record-only @ and other telemetry-only revisions from the count.
|
|
669
|
+
const count = await jj(["--color=never", "log", "-r", `(${base}::@ ~ ${base}) & files(~glob:"${dir}/**")`, "--count"], toplevel);
|
|
670
|
+
if (count.code !== 0) return failed("log", count);
|
|
671
|
+
const n = count.stdout.trim();
|
|
672
|
+
if (!/^\d+$/.test(n)) return warn(`diff omitted: jj log --count unparseable: ${n}`);
|
|
673
|
+
const files = modifiedFilesFrom(rows.map((r) => r.path).join("\n"), record.spec, currentDir);
|
|
674
|
+
const numstat = rows.map((r) => `${r.added}\t${r.removed}\t${r.path}`).join("\n");
|
|
675
|
+
record.derived.modified_files = files;
|
|
676
|
+
record.derived.diff = { base, commits: Number(n), buckets: aggregateNumstat(numstat, new Set(files), snap.telemetry.buckets) };
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
const computeGitDiff = async (snap: SettingsSnapshot) => {
|
|
638
680
|
if (!record || !toplevel) return;
|
|
639
681
|
let baseRef: string | undefined;
|
|
640
682
|
for (const ref of BASE_REFS) {
|
package/package.json
CHANGED