Skip to content

Commit c163d50

Browse files
authored
perf(server): avoid full patches for checkpoint summaries (#9694)
1 parent 4ee2a9d commit c163d50

14 files changed

Lines changed: 376 additions & 137 deletions

apps/server/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828
"@effect/sql-sqlite-bun": "catalog:",
2929
"@ff-labs/fff-node": "0.9.4",
3030
"@opencode-ai/sdk": "^1.3.15",
31-
"@pierre/diffs": "catalog:",
3231
"effect": "catalog:",
3332
"msgpackr-extract": "3.0.4",
3433
"node-pty": "^1.1.0",

apps/server/src/checkpointing/CheckpointStore.test.ts

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import * as NodePath from "node:path";
44
import * as NodeServices from "@effect/platform-node/NodeServices";
55
import { it } from "@effect/vitest";
66
import { ThreadId, type VcsError } from "@t3tools/contracts";
7+
import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
78
import * as Effect from "effect/Effect";
89
import * as FileSystem from "effect/FileSystem";
910
import * as Layer from "effect/Layer";
@@ -12,6 +13,7 @@ import * as Scope from "effect/Scope";
1213
import { describe, expect } from "vite-plus/test";
1314

1415
import { checkpointRefForThreadTurn } from "./Utils.ts";
16+
import { parseTurnDiffFilesFromNumstat } from "./Diffs.ts";
1517
import * as CheckpointStore from "./CheckpointStore.ts";
1618
import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
1719
import * as VcsProcess from "../vcs/VcsProcess.ts";
@@ -250,6 +252,182 @@ it.layer(TestLayer)("CheckpointStore.layer", (it) => {
250252
expect(whitespaceIgnoredDiff).toContain("+ <div>");
251253
expect(whitespaceIgnoredDiff).not.toContain("- <h1>Title</h1>");
252254
expect(whitespaceIgnoredDiff).not.toContain("+ <h1>Title</h1>");
255+
256+
for (const ignoreWhitespace of [false, true]) {
257+
const numstat = yield* checkpointStore.diffCheckpoints({
258+
cwd: tmp,
259+
fromCheckpointRef,
260+
toCheckpointRef,
261+
ignoreWhitespace,
262+
format: "numstat",
263+
});
264+
expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([
265+
{
266+
path: "Component.tsx",
267+
additions: ignoreWhitespace ? 4 : 6,
268+
deletions: ignoreWhitespace ? 0 : 2,
269+
},
270+
]);
271+
}
272+
}),
273+
);
274+
});
275+
276+
describe("checkpoint file summaries", () => {
277+
it.effect("counts changes whose full patch exceeds the output limit", () =>
278+
Effect.gen(function* () {
279+
const tmp = yield* makeTmpDir();
280+
yield* initRepoWithCommit(tmp);
281+
const checkpointStore = yield* CheckpointStore.CheckpointStore;
282+
const threadId = ThreadId.make("large-checkpoint-summary");
283+
const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0);
284+
const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1);
285+
const filePath = NodePath.join(tmp, "README.md");
286+
const lineCount = 20_000;
287+
yield* writeTextFile(filePath, `${"before".repeat(50)}\n`.repeat(lineCount));
288+
yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: fromCheckpointRef });
289+
yield* writeTextFile(filePath, `${"after".repeat(60)}\n`.repeat(lineCount));
290+
yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: toCheckpointRef });
291+
292+
const numstat = yield* checkpointStore.diffCheckpoints({
293+
cwd: tmp,
294+
fromCheckpointRef,
295+
toCheckpointRef,
296+
ignoreWhitespace: false,
297+
format: "numstat",
298+
});
299+
300+
expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([
301+
{ path: "README.md", additions: lineCount, deletions: lineCount },
302+
]);
303+
expect(numstat.length).toBeLessThan(100);
304+
}),
305+
);
306+
307+
it.effect("preserves file paths and turn ranges without changing the user index", () =>
308+
Effect.gen(function* () {
309+
const tmp = yield* makeTmpDir();
310+
yield* initRepoWithCommit(tmp);
311+
yield* git(tmp, ["config", "diff.renames", "copies"]);
312+
const fileSystem = yield* FileSystem.FileSystem;
313+
const checkpointStore = yield* CheckpointStore.CheckpointStore;
314+
const threadId = ThreadId.make("checkpoint-summary-paths");
315+
const baseline = checkpointRefForThreadTurn(threadId, 0);
316+
const firstTurn = checkpointRefForThreadTurn(threadId, 1);
317+
const secondTurn = checkpointRefForThreadTurn(threadId, 2);
318+
const copiedText = Array.from({ length: 20 }, (_, index) => `copy line ${index}\n`).join(
319+
"",
320+
);
321+
const platform = yield* HostProcessPlatform;
322+
const renamedPath = platform === "win32" ? "renamed café.txt" : "renamed\tcafé\nname.txt";
323+
const addedPath = platform === "win32" ? "new café.txt" : "new\tfile\n名.txt";
324+
for (const [path, contents] of Object.entries({
325+
"copy-source.txt": copiedText,
326+
"deleted.txt": "delete me\n",
327+
"rename-old.txt": "before\nkeep one\nkeep two\nkeep three\n",
328+
"binary.bin": "\0before",
329+
})) {
330+
yield* writeTextFile(NodePath.join(tmp, path), contents);
331+
}
332+
yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: baseline });
333+
334+
yield* fileSystem.rename(
335+
NodePath.join(tmp, "rename-old.txt"),
336+
NodePath.join(tmp, renamedPath),
337+
);
338+
yield* fileSystem.remove(NodePath.join(tmp, "deleted.txt"));
339+
for (const [path, contents] of Object.entries({
340+
"copy-source.txt": `${copiedText}one more\n`,
341+
"copied.txt": copiedText,
342+
[renamedPath]: "after\nkeep one\nkeep two\nkeep three\n",
343+
"binary.bin": "\0after",
344+
"empty.txt": "",
345+
[addedPath]: "first\nsecond\n",
346+
})) {
347+
yield* writeTextFile(NodePath.join(tmp, path), contents);
348+
}
349+
yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: firstTurn });
350+
const userIndex = yield* fileSystem.readFile(NodePath.join(tmp, ".git/index"));
351+
const input = {
352+
cwd: tmp,
353+
fromCheckpointRef: baseline,
354+
toCheckpointRef: firstTurn,
355+
ignoreWhitespace: false,
356+
format: "numstat" as const,
357+
};
358+
const firstSummary = parseTurnDiffFilesFromNumstat(
359+
yield* checkpointStore.diffCheckpoints(input),
360+
);
361+
const expectedFiles = [
362+
{ path: "binary.bin", additions: 0, deletions: 0 },
363+
{ path: "copied.txt", additions: 0, deletions: 0 },
364+
{ path: "copy-source.txt", additions: 1, deletions: 0 },
365+
{ path: "deleted.txt", additions: 0, deletions: 1 },
366+
{ path: "empty.txt", additions: 0, deletions: 0 },
367+
{ path: addedPath, additions: 2, deletions: 0 },
368+
{ path: renamedPath, additions: 1, deletions: 1 },
369+
].toSorted((left, right) => left.path.localeCompare(right.path));
370+
expect(firstSummary).toEqual(expectedFiles);
371+
372+
yield* fileSystem.remove(NodePath.join(tmp, "empty.txt"));
373+
yield* writeTextFile(NodePath.join(tmp, "copy-source.txt"), "replacement\n");
374+
yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: secondTurn });
375+
const secondSummary = parseTurnDiffFilesFromNumstat(
376+
yield* checkpointStore.diffCheckpoints({
377+
...input,
378+
fromCheckpointRef: firstTurn,
379+
toCheckpointRef: secondTurn,
380+
}),
381+
);
382+
expect(secondSummary).toEqual([
383+
{ path: "copy-source.txt", additions: 1, deletions: 21 },
384+
{ path: "empty.txt", additions: 0, deletions: 0 },
385+
]);
386+
387+
const inclusiveSummary = parseTurnDiffFilesFromNumstat(
388+
yield* checkpointStore.diffCheckpoints({ ...input, toCheckpointRef: secondTurn }),
389+
);
390+
expect(inclusiveSummary).toEqual(
391+
expectedFiles
392+
.filter((file) => file.path !== "empty.txt")
393+
.map((file) =>
394+
file.path === "copy-source.txt" ? { ...file, additions: 1, deletions: 20 } : file,
395+
),
396+
);
397+
expect(
398+
yield* checkpointStore.diffCheckpoints({ ...input, toCheckpointRef: baseline }),
399+
).toBe("");
400+
expect(yield* fileSystem.readFile(NodePath.join(tmp, ".git/index"))).toEqual(userIndex);
401+
}),
402+
);
403+
404+
it.effect("uses HEAD for a missing baseline only when requested", () =>
405+
Effect.gen(function* () {
406+
const tmp = yield* makeTmpDir();
407+
yield* initRepoWithCommit(tmp);
408+
const checkpointStore = yield* CheckpointStore.CheckpointStore;
409+
const threadId = ThreadId.make("checkpoint-summary-fallback");
410+
const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0);
411+
const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1);
412+
yield* writeTextFile(NodePath.join(tmp, "README.md"), "changed\n");
413+
yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: toCheckpointRef });
414+
const input = {
415+
cwd: tmp,
416+
fromCheckpointRef,
417+
toCheckpointRef,
418+
ignoreWhitespace: false,
419+
format: "numstat" as const,
420+
};
421+
422+
const error = yield* Effect.flip(checkpointStore.diffCheckpoints(input));
423+
expect(error._tag).toBe("VcsProcessExitError");
424+
const numstat = yield* checkpointStore.diffCheckpoints({
425+
...input,
426+
fallbackFromToHead: true,
427+
});
428+
expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([
429+
{ path: "README.md", additions: 1, deletions: 1 },
430+
]);
253431
}),
254432
);
255433
});

apps/server/src/checkpointing/CheckpointStore.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export interface DiffCheckpointsInput {
3939
readonly toCheckpointRef: CheckpointRef;
4040
readonly fallbackFromToHead?: boolean;
4141
readonly ignoreWhitespace: boolean;
42+
readonly format?: "patch" | "numstat";
4243
}
4344

4445
export interface DeleteCheckpointRefsInput {
@@ -77,8 +78,9 @@ export class CheckpointStore extends Context.Service<
7778
) => Effect.Effect<boolean, CheckpointStoreError>;
7879

7980
/**
80-
* Compute a patch diff between two checkpoint refs.
81+
* Compute a diff between two checkpoint refs. Defaults to a full patch.
8182
*
83+
* Numstat output has NUL-delimited paths for file summaries.
8284
* Can optionally treat a missing "from" ref as `HEAD`.
8385
*/
8486
readonly diffCheckpoints: (
Lines changed: 34 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,54 @@
11
import { describe, expect, it } from "vite-plus/test";
22

3-
import { parseTurnDiffFilesFromUnifiedDiff } from "./Diffs.ts";
3+
import { parseTurnDiffFilesFromNumstat } from "./Diffs.ts";
44

5-
describe("parseTurnDiffFilesFromUnifiedDiff", () => {
6-
it("returns empty list for empty diff", () => {
7-
expect(parseTurnDiffFilesFromUnifiedDiff("")).toEqual([]);
5+
describe("parseTurnDiffFilesFromNumstat", () => {
6+
it("returns an empty list when no files changed", () => {
7+
expect(parseTurnDiffFilesFromNumstat("")).toEqual([]);
88
});
99

10-
it("parses per-file additions and deletions", () => {
11-
const diff = [
12-
"diff --git a/a.txt b/a.txt",
13-
"index 1111111..2222222 100644",
14-
"--- a/a.txt",
15-
"+++ b/a.txt",
16-
"@@ -1,2 +1,3 @@",
17-
" one",
18-
"-two",
19-
"+two updated",
20-
"+three",
21-
"diff --git a/src/b.ts b/src/b.ts",
22-
"index 3333333..4444444 100644",
23-
"--- a/src/b.ts",
24-
"+++ b/src/b.ts",
25-
"@@ -3,2 +3,0 @@",
26-
"-old",
27-
"-stale",
28-
"",
29-
].join("\n");
30-
31-
expect(parseTurnDiffFilesFromUnifiedDiff(diff)).toEqual([
10+
it("sorts files and preserves addition and deletion counts", () => {
11+
const numstat = ["0\t2\tsrc/b.ts", "2\t1\ta.txt", ""].join("\0");
12+
expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([
3213
{ path: "a.txt", additions: 2, deletions: 1 },
3314
{ path: "src/b.ts", additions: 0, deletions: 2 },
3415
]);
3516
});
3617

37-
it("parses rename-only diffs with zero line changes", () => {
38-
const diff = [
39-
"diff --git a/src/old.ts b/src/new.ts",
40-
"similarity index 100%",
41-
"rename from src/old.ts",
42-
"rename to src/new.ts",
18+
it("uses destination paths for renames and copies", () => {
19+
const numstat = [
20+
"0\t0\t",
21+
"src/old.ts",
22+
"src/new.ts",
23+
"2\t1\t",
24+
"src/source.ts",
25+
"src/copied.ts",
26+
"1\t0\tother.ts",
4327
"",
44-
].join("\n");
28+
].join("\0");
4529

46-
expect(parseTurnDiffFilesFromUnifiedDiff(diff)).toEqual([
30+
expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([
31+
{ path: "other.ts", additions: 1, deletions: 0 },
32+
{ path: "src/copied.ts", additions: 2, deletions: 1 },
4733
{ path: "src/new.ts", additions: 0, deletions: 0 },
4834
]);
4935
});
5036

51-
it("normalizes CRLF input before parsing", () => {
52-
const diff = [
53-
"diff --git a/a.txt b/a.txt",
54-
"index 1111111..2222222 100644",
55-
"--- a/a.txt",
56-
"+++ b/a.txt",
57-
"@@ -1 +1,2 @@",
58-
"-one",
59-
"+one updated",
60-
"+two",
61-
"",
62-
].join("\r\n");
37+
it("keeps binary files and empty files with zero line changes", () => {
38+
const numstat = ["-\t-\timage.png", "0\t0\tempty.txt", ""].join("\0");
39+
expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([
40+
{ path: "empty.txt", additions: 0, deletions: 0 },
41+
{ path: "image.png", additions: 0, deletions: 0 },
42+
]);
43+
});
6344

64-
expect(parseTurnDiffFilesFromUnifiedDiff(diff)).toEqual([
65-
{ path: "a.txt", additions: 2, deletions: 1 },
45+
it("preserves Unicode, tabs, line endings, and spaces in paths", () => {
46+
const path = " café\tline\r\nname.txt ";
47+
const numstat = `3\t2\t\0old\tname\n.txt\0${path}\0`;
48+
49+
expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([{ path, additions: 3, deletions: 2 }]);
50+
expect(parseTurnDiffFilesFromNumstat(`1\t0\t${path}\0`)).toEqual([
51+
{ path, additions: 1, deletions: 0 },
6652
]);
6753
});
6854
});
Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,33 @@
1-
import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles";
2-
31
export interface TurnDiffFileSummary {
42
readonly path: string;
53
readonly additions: number;
64
readonly deletions: number;
75
}
86

9-
export function parseTurnDiffFilesFromUnifiedDiff(
10-
diff: string,
11-
): ReadonlyArray<TurnDiffFileSummary> {
12-
const normalized = diff.replace(/\r\n/g, "\n").trim();
13-
if (normalized.length === 0) {
14-
return [];
15-
}
7+
/** Reads Git's NUL-delimited numstat output without decoding display paths. */
8+
export function parseTurnDiffFilesFromNumstat(numstat: string): ReadonlyArray<TurnDiffFileSummary> {
9+
const records = numstat.split("\0");
10+
const files: TurnDiffFileSummary[] = [];
11+
12+
for (let index = 0; index < records.length; index += 1) {
13+
const record = records[index]!;
14+
const counts = /^(\d+|-)\t(\d+|-)\t/.exec(record);
15+
if (!counts) continue;
1616

17-
const parsedPatches = parsePatchFiles(normalized);
18-
const files = parsedPatches.flatMap((patch) =>
19-
patch.files.map((file) => ({
20-
path: file.name,
21-
additions: file.hunks.reduce((total, hunk) => total + hunk.additionLines, 0),
22-
deletions: file.hunks.reduce((total, hunk) => total + hunk.deletionLines, 0),
23-
})),
24-
);
17+
let path = record.slice(counts[0].length);
18+
if (path.length === 0) {
19+
// Renames and copies use two more records: the source and destination.
20+
path = records[index + 2] ?? "";
21+
index += 2;
22+
}
23+
if (path.length === 0) continue;
24+
25+
files.push({
26+
path,
27+
additions: counts[1] === "-" ? 0 : Number(counts[1]),
28+
deletions: counts[2] === "-" ? 0 : Number(counts[2]),
29+
});
30+
}
2531

2632
return files.toSorted((left, right) => left.path.localeCompare(right.path));
2733
}

0 commit comments

Comments
 (0)