authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-09 19:09:46-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-09 19:09:46-07:00
logf3a69c70c6a5bc624e1b5eb80901d8b7c901ad6f
tree27281f42930b98cb8b367fcd14b9675e3f032a6c
parente858ddb65668d54be5de3996f30c0bd5c3149657
signature Signed by PGP key B5690EEEBB952194

fix(checkpoint): pulls and rebases no longer flood a turn's changed files (#17161)

Co-authored-by: Dylan Zahn <dylan.zahn@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

12 files changed, 390 insertions(+), 37 deletions(-)

apps/server/src/checkpointing/CheckpointDiffQuery.test.ts+30
...@@ -48,6 +48,7 @@ function makeProjection(): ProjectionCheckpointContext {...@@ -48,6 +48,7 @@ function makeProjection(): ProjectionCheckpointContext {
48function layerFor(input: {48function layerFor(input: {
49 readonly projection: Effect.Effect<ProjectionCheckpointContext, OrchestratorProjectionError>;49 readonly projection: Effect.Effect<ProjectionCheckpointContext, OrchestratorProjectionError>;
50 readonly diffCheckpoints?: CheckpointStore.CheckpointStore["Service"]["diffCheckpoints"];50 readonly diffCheckpoints?: CheckpointStore.CheckpointStore["Service"]["diffCheckpoints"];
51 readonly authoredPaths?: ReadonlySet<string>;
51}) {52}) {
52 return CheckpointDiffQuery.layer.pipe(53 return CheckpointDiffQuery.layer.pipe(
53 Layer.provide(54 Layer.provide(
...@@ -57,6 +58,7 @@ function layerFor(input: {...@@ -57,6 +58,7 @@ function layerFor(input: {
57 }),58 }),
58 Layer.mock(CheckpointStore.CheckpointStore)({59 Layer.mock(CheckpointStore.CheckpointStore)({
59 diffCheckpoints: input.diffCheckpoints ?? (() => Effect.succeed("diff")),60 diffCheckpoints: input.diffCheckpoints ?? (() => Effect.succeed("diff")),
61 listAuthoredPaths: () => Effect.succeed(input.authoredPaths ?? null),
60 }),62 }),
61 ),63 ),
62 ),64 ),
...@@ -93,6 +95,34 @@ it.effect("computes V2 run diffs from projected checkpoint scopes", () => {...@@ -93,6 +95,34 @@ it.effect("computes V2 run diffs from projected checkpoint scopes", () => {
93 }).pipe(Effect.provide(layer));95 }).pipe(Effect.provide(layer));
94});96});
9597
98it.effect("leaves Git imports out of large turn diffs", () => {
99 const ownPaths = Array.from({ length: 1_000 }, (_, index) => `src/module-${index}/file.ts`);
100 const diffCheckpoints = vi.fn((input: CheckpointStore.DiffCheckpointsInput) =>
101 Effect.succeed(
102 input.format === "numstat"
103 ? [...ownPaths, "upstream.ts"].map((path) => `1\t0\t${path}\0`).join("")
104 : (input.filePaths ?? ["all"]).join("\n"),
105 ),
106 );
107 const layer = layerFor({
108 projection: Effect.succeed(makeProjection()),
109 diffCheckpoints,
110 authoredPaths: new Set(ownPaths),
111 });
112
113 return Effect.gen(function* () {
114 const query = yield* CheckpointDiffQuery.CheckpointDiffQuery;
115 yield* query.getFullThreadDiff({ threadId, toTurnCount: 2 });
116
117 const patchPaths = diffCheckpoints.mock.calls
118 .map(([call]) => call)
119 .filter((call) => call.format !== "numstat")
120 .map((call) => call.filePaths ?? ["all"]);
121 assert.isAbove(patchPaths.length, 1);
122 assert.sameMembers(patchPaths.flat(), ownPaths);
123 }).pipe(Effect.provide(layer));
124});
125
96it.effect("preserves the typed missing-thread error contract", () => {126it.effect("preserves the typed missing-thread error contract", () => {
97 const layer = layerFor({127 const layer = layerFor({
98 projection: Effect.fail(new OrchestratorProjectionError({ threadId })),128 projection: Effect.fail(new OrchestratorProjectionError({ threadId })),
apps/server/src/checkpointing/CheckpointDiffQuery.ts+59-9
...@@ -31,6 +31,31 @@ import {...@@ -31,6 +31,31 @@ import {
31 type CheckpointServiceError,31 type CheckpointServiceError,
32} from "./Errors.ts";32} from "./Errors.ts";
33import * as CheckpointStore from "./CheckpointStore.ts";33import * as CheckpointStore from "./CheckpointStore.ts";
34import { isGitImport, parseTurnDiffFilesFromNumstat, type TurnDiffFileSummary } from "./Diffs.ts";
35
36// Windows limits a command line to 32,767 characters. Leave room for the rest of git's arguments.
37const MAX_PATHSPEC_CHARS = 24_000;
38
39/** Splits files into pathspec lists that each fit on one git command line. Renames keep both paths together. */
40function batchFilePaths(files: ReadonlyArray<TurnDiffFileSummary>) {
41 const batches: string[][] = [];
42 let batch: string[] = [];
43 let length = 0;
44 for (const file of files) {
45 const paths = file.previousPath === undefined ? [file.path] : [file.previousPath, file.path];
46 // Each pathspec also carries a `:(top,literal)` prefix and a separator.
47 const size = paths.reduce((total, path) => total + path.length + 16, 0);
48 if (batch.length > 0 && length + size > MAX_PATHSPEC_CHARS) {
49 batches.push(batch);
50 batch = [];
51 length = 0;
52 }
53 batch.push(...paths);
54 length += size;
55 }
56 if (batch.length > 0) batches.push(batch);
57 return batches;
58}
3459
35/** Service tag for checkpoint diff queries. */60/** Service tag for checkpoint diff queries. */
36export class CheckpointDiffQuery extends Context.Service<61export class CheckpointDiffQuery extends Context.Service<
...@@ -182,15 +207,40 @@ export const make = Effect.gen(function* () {...@@ -182,15 +207,40 @@ export const make = Effect.gen(function* () {
182 });207 });
183 }208 }
184209
185 const diff = yield* checkpointStore210 const comparison = {
186 .diffCheckpoints({211 cwd: toScope.cwd,
187 cwd: toScope.cwd,212 fromCheckpointRef,
188 fromCheckpointRef,213 toCheckpointRef: toCheckpoint.ref,
189 toCheckpointRef: toCheckpoint.ref,214 fallbackFromToHead: false,
190 fallbackFromToHead: false,215 ignoreWhitespace,
191 ignoreWhitespace,216 };
192 })217 // Leave out files that a pull, merge, or rebase brought in, matching the turn's file summary.
193 .pipe(Effect.withSpan("checkpoint.turnDiff.diffCheckpoints"));218 // Filtering is optional: when it cannot run, the complete diff still loads.
219 const authoredPaths = yield* checkpointStore
220 .listAuthoredPaths(comparison)
221 .pipe(Effect.orElseSucceed(() => null));
222 const files =
223 authoredPaths === null
224 ? []
225 : yield* checkpointStore.diffCheckpoints({ ...comparison, format: "numstat" }).pipe(
226 Effect.map(parseTurnDiffFilesFromNumstat),
227 Effect.orElseSucceed(() => []),
228 );
229 const retainedFiles = files.filter((file) => !isGitImport(file, authoredPaths));
230 // Select retained paths before generating a patch, so imported bulk cannot exhaust its output limit.
231 const diff =
232 retainedFiles.length === files.length
233 ? yield* checkpointStore
234 .diffCheckpoints(comparison)
235 .pipe(Effect.withSpan("checkpoint.turnDiff.diffCheckpoints"))
236 : (yield* Effect.forEach(
237 batchFilePaths(retainedFiles),
238 (filePaths) =>
239 checkpointStore
240 .diffCheckpoints({ ...comparison, filePaths })
241 .pipe(Effect.withSpan("checkpoint.turnDiff.diffCheckpoints")),
242 { concurrency: 4 },
243 )).join("");
194244
195 const turnDiff = buildTurnDiffResult(input, diff);245 const turnDiff = buildTurnDiffResult(input, diff);
196 if (!isTurnDiffResult(turnDiff)) {246 if (!isTurnDiffResult(turnDiff)) {
apps/server/src/checkpointing/CheckpointStore.test.ts+128-4
...@@ -57,15 +57,22 @@ function writeTextFile(...@@ -57,15 +57,22 @@ function writeTextFile(
57function git(57function git(
58 cwd: string,58 cwd: string,
59 args: ReadonlyArray<string>,59 args: ReadonlyArray<string>,
60 options: { readonly committedAt?: string; readonly allowNonZeroExit?: boolean } = {},
60): Effect.Effect<string, VcsError, VcsProcess.VcsProcess> {61): Effect.Effect<string, VcsError, VcsProcess.VcsProcess> {
61 return Effect.gen(function* () {62 return Effect.gen(function* () {
62 const process = yield* VcsProcess.VcsProcess;63 const vcsProcess = yield* VcsProcess.VcsProcess;
63 const result = yield* process.run({64 const result = yield* vcsProcess.run({
64 operation: "CheckpointStore.test.git",65 operation: "CheckpointStore.test.git",
65 command: "git",66 command: "git",
66 cwd,67 cwd,
67 args,68 args,
68 timeoutMs: 10_000,69 timeoutMs: 10_000,
70 ...(options.committedAt === undefined
71 ? {}
72 : { env: { ...process.env, GIT_COMMITTER_DATE: options.committedAt } }),
73 ...(options.allowNonZeroExit === undefined
74 ? {}
75 : { allowNonZeroExit: options.allowNonZeroExit }),
69 });76 });
70 return result.stdout.trim();77 return result.stdout.trim();
71 });78 });
...@@ -371,12 +378,12 @@ it.layer(layerTest)("CheckpointStore.layer", (it) => {...@@ -371,12 +378,12 @@ it.layer(layerTest)("CheckpointStore.layer", (it) => {
371 );378 );
372 const expectedFiles = [379 const expectedFiles = [
373 { path: "binary.bin", additions: 0, deletions: 0 },380 { path: "binary.bin", additions: 0, deletions: 0 },
374 { path: "copied.txt", additions: 0, deletions: 0 },381 { path: "copied.txt", previousPath: "copy-source.txt", additions: 0, deletions: 0 },
375 { path: "copy-source.txt", additions: 1, deletions: 0 },382 { path: "copy-source.txt", additions: 1, deletions: 0 },
376 { path: "deleted.txt", additions: 0, deletions: 1 },383 { path: "deleted.txt", additions: 0, deletions: 1 },
377 { path: "empty.txt", additions: 0, deletions: 0 },384 { path: "empty.txt", additions: 0, deletions: 0 },
378 { path: addedPath, additions: 2, deletions: 0 },385 { path: addedPath, additions: 2, deletions: 0 },
379 { path: renamedPath, additions: 1, deletions: 1 },386 { path: renamedPath, previousPath: "rename-old.txt", additions: 1, deletions: 1 },
380 ].toSorted((left, right) => left.path.localeCompare(right.path));387 ].toSorted((left, right) => left.path.localeCompare(right.path));
381 expect(firstSummary).toEqual(expectedFiles);388 expect(firstSummary).toEqual(expectedFiles);
382389
...@@ -441,5 +448,122 @@ it.layer(layerTest)("CheckpointStore.layer", (it) => {...@@ -441,5 +448,122 @@ it.layer(layerTest)("CheckpointStore.layer", (it) => {
441 ]);448 ]);
442 }),449 }),
443 );450 );
451 it.effect("limits a diff to repository paths from a subdirectory workspace", () =>
452 Effect.gen(function* () {
453 const tmp = yield* makeTmpDir();
454 yield* initRepoWithCommit(tmp);
455 const fileSystem = yield* FileSystem.FileSystem;
456 const cwd = NodePath.join(tmp, "app");
457 yield* fileSystem.makeDirectory(cwd);
458 const checkpointStore = yield* CheckpointStore.CheckpointStore;
459 const threadId = ThreadId.make("checkpoint-file-paths-subdirectory");
460 const refs = {
461 cwd,
462 fromCheckpointRef: checkpointRefForThreadTurn(threadId, 0),
463 toCheckpointRef: checkpointRefForThreadTurn(threadId, 1),
464 };
465 yield* checkpointStore.captureCheckpoint({ cwd, checkpointRef: refs.fromCheckpointRef });
466 yield* writeTextFile(NodePath.join(cwd, "kept.txt"), "kept\n");
467 yield* writeTextFile(NodePath.join(tmp, "README.md"), "# hidden\n");
468 yield* checkpointStore.captureCheckpoint({ cwd, checkpointRef: refs.toCheckpointRef });
469
470 const patch = yield* checkpointStore.diffCheckpoints({
471 ...refs,
472 ignoreWhitespace: false,
473 filePaths: ["app/kept.txt"],
474 });
475 expect(patch).toContain("+kept");
476 expect(patch).not.toContain("hidden");
477 }),
478 );
479 });
480
481 describe("listAuthoredPaths", () => {
482 it.effect("leaves out paths that only a merged upstream changed", () =>
483 Effect.gen(function* () {
484 const tmp = yield* makeTmpDir();
485 yield* initRepoWithCommit(tmp);
486 const checkpointStore = yield* CheckpointStore.CheckpointStore;
487 const threadId = ThreadId.make("checkpoint-authored-paths");
488 const refs = {
489 cwd: tmp,
490 fromCheckpointRef: checkpointRefForThreadTurn(threadId, 0),
491 toCheckpointRef: checkpointRefForThreadTurn(threadId, 1),
492 };
493 const commitBeforeTurn = (file: string, contents: string) =>
494 Effect.gen(function* () {
495 yield* writeTextFile(NodePath.join(tmp, file), contents);
496 yield* git(tmp, ["add", "."]);
497 yield* git(tmp, ["commit", "-m", file], { committedAt: "2020-01-01T00:00:00Z" });
498 });
499
500 yield* git(tmp, ["checkout", "-b", "upstream"]);
501 yield* commitBeforeTurn("upstream.txt", "upstream\n");
502 yield* commitBeforeTurn("shared.txt", "upstream\n");
503 yield* git(tmp, ["checkout", "-"]);
504 yield* commitBeforeTurn("shared.txt", "ours\n");
505 yield* checkpointStore.captureCheckpoint({
506 cwd: tmp,
507 checkpointRef: refs.fromCheckpointRef,
508 });
509
510 yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: refs.toCheckpointRef });
511 expect(yield* checkpointStore.listAuthoredPaths(refs)).toBeNull();
512
513 // The turn merges upstream, fixes the conflict by taking upstream, and edits one more file.
514 yield* git(tmp, ["merge", "upstream", "--no-edit"], { allowNonZeroExit: true });
515 yield* writeTextFile(NodePath.join(tmp, "shared.txt"), "upstream\n");
516 yield* git(tmp, ["commit", "-am", "merge upstream"]);
517 yield* writeTextFile(NodePath.join(tmp, "README.md"), "# edited\n");
518 yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: refs.toCheckpointRef });
519
520 const numstat = yield* checkpointStore.diffCheckpoints({
521 ...refs,
522 ignoreWhitespace: false,
523 format: "numstat",
524 });
525 expect(parseTurnDiffFilesFromNumstat(numstat).map((file) => file.path)).toEqual([
526 "README.md",
527 "shared.txt",
528 "upstream.txt",
529 ]);
530 expect(yield* checkpointStore.listAuthoredPaths(refs)).toEqual(
531 new Set(["README.md", "shared.txt"]),
532 );
533 }),
534 );
535
536 it.effect("leaves out paths that only a rebase onto upstream brought in", () =>
537 Effect.gen(function* () {
538 const tmp = yield* makeTmpDir();
539 yield* initRepoWithCommit(tmp);
540 const checkpointStore = yield* CheckpointStore.CheckpointStore;
541 const threadId = ThreadId.make("checkpoint-authored-paths-rebase");
542 const refs = {
543 cwd: tmp,
544 fromCheckpointRef: checkpointRefForThreadTurn(threadId, 0),
545 toCheckpointRef: checkpointRefForThreadTurn(threadId, 1),
546 };
547 const commitBeforeTurn = (file: string) =>
548 Effect.gen(function* () {
549 yield* writeTextFile(NodePath.join(tmp, file), `${file}\n`);
550 yield* git(tmp, ["add", "."]);
551 yield* git(tmp, ["commit", "-m", file], { committedAt: "2020-01-01T00:00:00Z" });
552 });
553
554 yield* git(tmp, ["checkout", "-b", "upstream"]);
555 yield* commitBeforeTurn("upstream.txt");
556 yield* git(tmp, ["checkout", "-"]);
557 yield* commitBeforeTurn("feature.txt");
558 yield* checkpointStore.captureCheckpoint({
559 cwd: tmp,
560 checkpointRef: refs.fromCheckpointRef,
561 });
562 yield* git(tmp, ["rebase", "upstream"]);
563 yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: refs.toCheckpointRef });
564
565 expect(yield* checkpointStore.listAuthoredPaths(refs)).toEqual(new Set(["feature.txt"]));
566 }),
567 );
444 });568 });
445});569});
apps/server/src/checkpointing/CheckpointStore.ts+29
...@@ -40,6 +40,14 @@ export interface DiffCheckpointsInput {...@@ -40,6 +40,14 @@ export interface DiffCheckpointsInput {
40 readonly fallbackFromToHead?: boolean;40 readonly fallbackFromToHead?: boolean;
41 readonly ignoreWhitespace: boolean;41 readonly ignoreWhitespace: boolean;
42 readonly format?: "patch" | "numstat";42 readonly format?: "patch" | "numstat";
43 /** Limits the diff to these exact paths. An empty list yields an empty diff. */
44 readonly filePaths?: ReadonlyArray<string>;
45}
46
47export interface ListAuthoredPathsInput {
48 readonly cwd: string;
49 readonly fromCheckpointRef: CheckpointRef;
50 readonly toCheckpointRef: CheckpointRef;
43}51}
4452
45export interface DeleteCheckpointRefsInput {53export interface DeleteCheckpointRefsInput {
...@@ -87,6 +95,19 @@ export class CheckpointStore extends Context.Service<...@@ -87,6 +95,19 @@ export class CheckpointStore extends Context.Service<
87 input: DiffCheckpointsInput,95 input: DiffCheckpointsInput,
88 ) => Effect.Effect<string, CheckpointStoreError>;96 ) => Effect.Effect<string, CheckpointStoreError>;
8997
98 /**
99 * List paths changed by work done after the "from" checkpoint: uncommitted
100 * edits at either checkpoint, commits made after "from", and commits that
101 * left HEAD. Commits a pull, merge, or rebase brought in are older than
102 * "from", so their paths are not listed.
103 *
104 * Returns null when HEAD did not move or a checkpoint does not record HEAD.
105 * Then every changed path belongs to the turn.
106 */
107 readonly listAuthoredPaths: (
108 input: ListAuthoredPathsInput,
109 ) => Effect.Effect<ReadonlySet<string> | null, CheckpointStoreError>;
110
90 /**111 /**
91 * Delete the provided checkpoint refs.112 * Delete the provided checkpoint refs.
92 *113 *
...@@ -150,6 +171,13 @@ export const make = Effect.gen(function* () {...@@ -150,6 +171,13 @@ export const make = Effect.gen(function* () {
150 return yield* checkpoints.diffCheckpoints(input);171 return yield* checkpoints.diffCheckpoints(input);
151 });172 });
152173
174 const listAuthoredPaths: CheckpointStore["Service"]["listAuthoredPaths"] = Effect.fn(
175 "listAuthoredPaths",
176 )(function* (input) {
177 const checkpoints = yield* resolveCheckpoints("CheckpointStore.listAuthoredPaths", input.cwd);
178 return yield* checkpoints.listAuthoredPaths(input);
179 });
180
153 const deleteCheckpointRefs: CheckpointStore["Service"]["deleteCheckpointRefs"] = Effect.fn(181 const deleteCheckpointRefs: CheckpointStore["Service"]["deleteCheckpointRefs"] = Effect.fn(
154 "deleteCheckpointRefs",182 "deleteCheckpointRefs",
155 )(function* (input) {183 )(function* (input) {
...@@ -166,6 +194,7 @@ export const make = Effect.gen(function* () {...@@ -166,6 +194,7 @@ export const make = Effect.gen(function* () {
166 hasCheckpointRef,194 hasCheckpointRef,
167 restoreCheckpoint,195 restoreCheckpoint,
168 diffCheckpoints,196 diffCheckpoints,
197 listAuthoredPaths,
169 deleteCheckpointRefs,198 deleteCheckpointRefs,
170 });199 });
171});200});
apps/server/src/checkpointing/Diffs.test.ts+6-4
...@@ -15,7 +15,7 @@ describe("parseTurnDiffFilesFromNumstat", () => {...@@ -15,7 +15,7 @@ describe("parseTurnDiffFilesFromNumstat", () => {
15 ]);15 ]);
16 });16 });
1717
18 it("uses destination paths for renames and copies", () => {18 it("preserves both paths for renames and copies", () => {
19 const numstat = [19 const numstat = [
20 "0\t0\t",20 "0\t0\t",
21 "src/old.ts",21 "src/old.ts",
...@@ -29,8 +29,8 @@ describe("parseTurnDiffFilesFromNumstat", () => {...@@ -29,8 +29,8 @@ describe("parseTurnDiffFilesFromNumstat", () => {
2929
30 expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([30 expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([
31 { path: "other.ts", additions: 1, deletions: 0 },31 { path: "other.ts", additions: 1, deletions: 0 },
32 { path: "src/copied.ts", additions: 2, deletions: 1 },32 { path: "src/copied.ts", previousPath: "src/source.ts", additions: 2, deletions: 1 },
33 { path: "src/new.ts", additions: 0, deletions: 0 },33 { path: "src/new.ts", previousPath: "src/old.ts", additions: 0, deletions: 0 },
34 ]);34 ]);
35 });35 });
3636
...@@ -46,7 +46,9 @@ describe("parseTurnDiffFilesFromNumstat", () => {...@@ -46,7 +46,9 @@ describe("parseTurnDiffFilesFromNumstat", () => {
46 const path = " café\tline\r\nname.txt ";46 const path = " café\tline\r\nname.txt ";
47 const numstat = `3\t2\t\0old\tname\n.txt\0${path}\0`;47 const numstat = `3\t2\t\0old\tname\n.txt\0${path}\0`;
4848
49 expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([{ path, additions: 3, deletions: 2 }]);49 expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([
50 { path, previousPath: "old\tname\n.txt", additions: 3, deletions: 2 },
51 ]);
50 expect(parseTurnDiffFilesFromNumstat(`1\t0\t${path}\0`)).toEqual([52 expect(parseTurnDiffFilesFromNumstat(`1\t0\t${path}\0`)).toEqual([
51 { path, additions: 1, deletions: 0 },53 { path, additions: 1, deletions: 0 },
52 ]);54 ]);
apps/server/src/checkpointing/Diffs.ts+20
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1export interface TurnDiffFileSummary {1export interface TurnDiffFileSummary {
2 readonly path: string;2 readonly path: string;
3 readonly previousPath?: string;
3 readonly additions: number;4 readonly additions: number;
4 readonly deletions: number;5 readonly deletions: number;
5}6}
...@@ -15,8 +16,10 @@ export function parseTurnDiffFilesFromNumstat(numstat: string): ReadonlyArray<Tu...@@ -15,8 +16,10 @@ export function parseTurnDiffFilesFromNumstat(numstat: string): ReadonlyArray<Tu
15 if (!counts) continue;16 if (!counts) continue;
1617
17 let path = record.slice(counts[0].length);18 let path = record.slice(counts[0].length);
19 let previousPath: string | undefined;
18 if (path.length === 0) {20 if (path.length === 0) {
19 // Renames and copies use two more records: the source and destination.21 // Renames and copies use two more records: the source and destination.
22 previousPath = records[index + 1];
20 path = records[index + 2] ?? "";23 path = records[index + 2] ?? "";
21 index += 2;24 index += 2;
22 }25 }
...@@ -24,6 +27,7 @@ export function parseTurnDiffFilesFromNumstat(numstat: string): ReadonlyArray<Tu...@@ -24,6 +27,7 @@ export function parseTurnDiffFilesFromNumstat(numstat: string): ReadonlyArray<Tu
2427
25 files.push({28 files.push({
26 path,29 path,
30 ...(previousPath === undefined ? {} : { previousPath }),
27 additions: counts[1] === "-" ? 0 : Number(counts[1]),31 additions: counts[1] === "-" ? 0 : Number(counts[1]),
28 deletions: counts[2] === "-" ? 0 : Number(counts[2]),32 deletions: counts[2] === "-" ? 0 : Number(counts[2]),
29 });33 });
...@@ -31,3 +35,19 @@ export function parseTurnDiffFilesFromNumstat(numstat: string): ReadonlyArray<Tu...@@ -31,3 +35,19 @@ export function parseTurnDiffFilesFromNumstat(numstat: string): ReadonlyArray<Tu
3135
32 return files.toSorted((left, right) => left.path.localeCompare(right.path));36 return files.toSorted((left, right) => left.path.localeCompare(right.path));
33}37}
38
39/**
40 * True when Git brought this file in and no work in the range touched it.
41 * `authoredPaths` comes from `CheckpointStore.listAuthoredPaths`; null means nothing was imported.
42 * A rename stays visible when either side was authored, because the summary shows it as one file.
43 */
44export function isGitImport(
45 file: TurnDiffFileSummary,
46 authoredPaths: ReadonlySet<string> | null,
47): boolean {
48 return (
49 authoredPaths !== null &&
50 !authoredPaths.has(file.path) &&
51 (file.previousPath === undefined || !authoredPaths.has(file.previousPath))
52 );
53}
apps/server/src/orchestration-v2/CheckpointCaptureService.test.ts+1
...@@ -277,6 +277,7 @@ it.layer(layerProjectionStoreTest)("CheckpointCaptureServiceV2", (it) => {...@@ -277,6 +277,7 @@ it.layer(layerProjectionStoreTest)("CheckpointCaptureServiceV2", (it) => {
277 IdAllocator.layer,277 IdAllocator.layer,
278 NodeCrypto.layer,278 NodeCrypto.layer,
279 Layer.mock(CheckpointStore.CheckpointStore)({279 Layer.mock(CheckpointStore.CheckpointStore)({
280 listAuthoredPaths: () => Effect.succeed(null),
280 isGitRepository: () => Effect.succeed(true),281 isGitRepository: () => Effect.succeed(true),
281 captureCheckpoint: () => Effect.void,282 captureCheckpoint: () => Effect.void,
282 hasCheckpointRef: () =>283 hasCheckpointRef: () =>
apps/server/src/orchestration-v2/CheckpointScopeOwnership.test.ts+1
...@@ -219,6 +219,7 @@ it.effect("resolves the thread baseline after a second root run replaces scope o...@@ -219,6 +219,7 @@ it.effect("resolves the thread baseline after a second root run replaces scope o
219 getCheckpointContext: () => Effect.succeed(context),219 getCheckpointContext: () => Effect.succeed(context),
220 }),220 }),
221 Layer.mock(CheckpointStore.CheckpointStore)({221 Layer.mock(CheckpointStore.CheckpointStore)({
222 listAuthoredPaths: () => Effect.succeed(null),
222 diffCheckpoints: (input) => {223 diffCheckpoints: (input) => {
223 assert.equal(input.cwd, "/prepared-repo");224 assert.equal(input.cwd, "/prepared-repo");
224 assert.equal(input.fromCheckpointRef, checkpointRefs[0]);225 assert.equal(input.fromCheckpointRef, checkpointRefs[0]);
apps/server/src/orchestration-v2/CheckpointService.ts+35-19
...@@ -19,7 +19,7 @@ import * as KeyedLock from "@t3tools/shared/KeyedLock";...@@ -19,7 +19,7 @@ import * as KeyedLock from "@t3tools/shared/KeyedLock";
19import * as Layer from "effect/Layer";19import * as Layer from "effect/Layer";
20import * as Schema from "effect/Schema";20import * as Schema from "effect/Schema";
2121
22import { parseTurnDiffFilesFromNumstat } from "../checkpointing/Diffs.ts";22import { isGitImport, parseTurnDiffFilesFromNumstat } from "../checkpointing/Diffs.ts";
23import * as CheckpointStore from "../checkpointing/CheckpointStore.ts";23import * as CheckpointStore from "../checkpointing/CheckpointStore.ts";
24import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";24import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
2525
...@@ -440,33 +440,49 @@ export const layer: Layer.Layer<...@@ -440,33 +440,49 @@ export const layer: Layer.Layer<
440 }).pipe(Effect.as(false)),440 }).pipe(Effect.as(false)),
441 ),441 ),
442 );442 );
443 const refs = {
444 cwd: input.scope.cwd,
445 fromCheckpointRef: previousCheckpointRef,
446 toCheckpointRef: checkpointRef,
447 };
448 // A pull or rebase can change thousands of files the turn did not write.
449 // Keep only the files the turn's own work touched.
443 const files = previousExists450 const files = previousExists
444 ? yield* checkpointStore451 ? yield* Effect.all([
445 .diffCheckpoints({452 checkpointStore.diffCheckpoints({
446 cwd: input.scope.cwd,453 ...refs,
447 fromCheckpointRef: previousCheckpointRef,
448 toCheckpointRef: checkpointRef,
449 fallbackFromToHead: false,454 fallbackFromToHead: false,
450 ignoreWhitespace: false,455 ignoreWhitespace: false,
451 format: "numstat",456 format: "numstat",
452 })457 }),
453 .pipe(458 checkpointStore.listAuthoredPaths(refs).pipe(
454 Effect.map((diff) =>
455 parseTurnDiffFilesFromNumstat(diff).map((file) => ({
456 path: file.path,
457 kind: "modified",
458 additions: file.additions,
459 deletions: file.deletions,
460 })),
461 ),
462 Effect.catch((cause) =>459 Effect.catch((cause) =>
463 Effect.logWarning("orchestration V2 checkpoint diff summary failed", {460 Effect.logWarning("orchestration V2 checkpoint authored paths failed", {
464 scopeId: input.scope.id,461 scopeId: input.scope.id,
465 checkpointRef,462 checkpointRef,
466 cause: String(cause),463 cause: String(cause),
467 }).pipe(Effect.as([])),464 }).pipe(Effect.as(null)),
468 ),465 ),
469 )466 ),
467 ]).pipe(
468 Effect.map(([diff, authoredPaths]) =>
469 parseTurnDiffFilesFromNumstat(diff)
470 .filter((file) => !isGitImport(file, authoredPaths))
471 .map((file) => ({
472 path: file.path,
473 kind: "modified",
474 additions: file.additions,
475 deletions: file.deletions,
476 })),
477 ),
478 Effect.catch((cause) =>
479 Effect.logWarning("orchestration V2 checkpoint diff summary failed", {
480 scopeId: input.scope.id,
481 checkpointRef,
482 cause: String(cause),
483 }).pipe(Effect.as([])),
484 ),
485 )
470 : [];486 : [];
471487
472 return makeCheckpoint({488 return makeCheckpoint({
apps/server/src/vcs/GitVcsDriver.ts+63-1
...@@ -1049,10 +1049,18 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (...@@ -1049,10 +1049,18 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
1049 }1049 }
10501050
1051 const message = `t3 checkpoint ref=${input.checkpointRef}`;1051 const message = `t3 checkpoint ref=${input.checkpointRef}`;
1052 // HEAD is the parent so listAuthoredPaths can see how HEAD moved between checkpoints.
1052 const commitTreeResult = yield* execute({1053 const commitTreeResult = yield* execute({
1053 operation,1054 operation,
1054 cwd: input.cwd,1055 cwd: input.cwd,
1055 args: [...durableWrite, "commit-tree", treeOid, "-m", message],1056 args: [
1057 ...durableWrite,
1058 "commit-tree",
1059 treeOid,
1060 ...(headExists ? ["-p", "HEAD"] : []),
1061 "-m",
1062 message,
1063 ],
1056 env: commitEnv,1064 env: commitEnv,
1057 });1065 });
1058 const commitOid = commitTreeResult.stdout.trim();1066 const commitOid = commitTreeResult.stdout.trim();
...@@ -1157,6 +1165,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (...@@ -1157,6 +1165,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
11571165
1158 diffCheckpoints: Effect.fn("GitVcsDriver.checkpoints.diffCheckpoints")(function* (input) {1166 diffCheckpoints: Effect.fn("GitVcsDriver.checkpoints.diffCheckpoints")(function* (input) {
1159 const operation = "GitVcsDriver.checkpoints.diffCheckpoints";1167 const operation = "GitVcsDriver.checkpoints.diffCheckpoints";
1168 if (input.filePaths?.length === 0) return "";
1160 yield* Effect.annotateCurrentSpan({1169 yield* Effect.annotateCurrentSpan({
1161 "checkpoint.cwd": input.cwd,1170 "checkpoint.cwd": input.cwd,
1162 "checkpoint.from_ref": input.fromCheckpointRef,1171 "checkpoint.from_ref": input.fromCheckpointRef,
...@@ -1202,6 +1211,10 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (...@@ -1202,6 +1211,10 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
1202 ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []),1211 ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []),
1203 `${fromRevision}^{commit}`,1212 `${fromRevision}^{commit}`,
1204 `${input.toCheckpointRef}^{commit}`,1213 `${input.toCheckpointRef}^{commit}`,
1214 // Paths are repository-relative, while cwd can be a subdirectory.
1215 ...(input.filePaths
1216 ? ["--", ...input.filePaths.map((file) => `:(top,literal)${file}`)]
1217 : []),
1205 ],1218 ],
1206 allowNonZeroExit: true,1219 allowNonZeroExit: true,
1207 maxOutputBytes: CHECKPOINT_DIFF_MAX_OUTPUT_BYTES,1220 maxOutputBytes: CHECKPOINT_DIFF_MAX_OUTPUT_BYTES,
...@@ -1221,6 +1234,55 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (...@@ -1221,6 +1234,55 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
1221 return result.stdout;1234 return result.stdout;
1222 }),1235 }),
12231236
1237 listAuthoredPaths: Effect.fn("GitVcsDriver.checkpoints.listAuthoredPaths")(function* (input) {
1238 const operation = "GitVcsDriver.checkpoints.listAuthoredPaths";
1239 const heads = yield* execute({
1240 operation,
1241 cwd: input.cwd,
1242 args: [
1243 "log",
1244 "--no-walk=unsorted",
1245 "--format=%P %ct",
1246 `${input.fromCheckpointRef}^{commit}`,
1247 `${input.toCheckpointRef}^{commit}`,
1248 ],
1249 });
1250 // Each line is "<parent> <committer time>". The parent is the HEAD at capture.
1251 const [fromLine = "", toLine = ""] = heads.stdout.trimEnd().split("\n");
1252 const [startHead = "", capturedAt = ""] = fromLine.split(" ");
1253 const [endHead = ""] = toLine.split(" ");
1254 if (startHead === "" || endHead === "" || startHead === endHead) {
1255 return null;
1256 }
1257
1258 const listPaths = (args: ReadonlyArray<string>) =>
1259 execute({
1260 operation,
1261 cwd: input.cwd,
1262 args: [...args, "--name-only", "-z", "--no-renames", "--no-ext-diff"],
1263 maxOutputBytes: CHECKPOINT_DIFF_MAX_OUTPUT_BYTES,
1264 outputMode: "error",
1265 }).pipe(Effect.map((result) => result.stdout.split("\0")));
1266 // Remerge diff lists a merge's paths only where the result differs from Git's
1267 // automatic merge, so a merge keeps its conflict fixes and drops clean upstream changes.
1268 const pathLists = yield* Effect.all(
1269 [
1270 listPaths(["diff", startHead, `${input.fromCheckpointRef}^{commit}`]),
1271 listPaths(["diff", endHead, `${input.toCheckpointRef}^{commit}`]),
1272 listPaths(["log", "--format=", "--diff-merges=remerge", `${endHead}..${startHead}`]),
1273 listPaths([
1274 "log",
1275 "--format=",
1276 "--diff-merges=remerge",
1277 `--since=@${capturedAt}`,
1278 `${startHead}..${endHead}`,
1279 ]),
1280 ],
1281 { concurrency: "unbounded" },
1282 );
1283 return new Set(pathLists.flat().filter((path) => path.length > 0));
1284 }),
1285
1224 deleteCheckpointRefs: Effect.fn("GitVcsDriver.checkpoints.deleteCheckpointRefs")(1286 deleteCheckpointRefs: Effect.fn("GitVcsDriver.checkpoints.deleteCheckpointRefs")(
1225 function* (input) {1287 function* (input) {
1226 yield* Effect.forEach(1288 yield* Effect.forEach(
apps/server/src/vcs/VcsDriver.ts+11
...@@ -32,6 +32,14 @@ export interface VcsDiffCheckpointsInput {...@@ -32,6 +32,14 @@ export interface VcsDiffCheckpointsInput {
32 readonly fallbackFromToHead?: boolean;32 readonly fallbackFromToHead?: boolean;
33 readonly ignoreWhitespace: boolean;33 readonly ignoreWhitespace: boolean;
34 readonly format?: "patch" | "numstat";34 readonly format?: "patch" | "numstat";
35 /** Limits the diff to these exact paths. An empty list yields an empty diff. */
36 readonly filePaths?: ReadonlyArray<string>;
37}
38
39export interface VcsListAuthoredPathsInput {
40 readonly cwd: string;
41 readonly fromCheckpointRef: CheckpointRef;
42 readonly toCheckpointRef: CheckpointRef;
35}43}
3644
37export interface VcsDeleteCheckpointRefsInput {45export interface VcsDeleteCheckpointRefsInput {
...@@ -48,6 +56,9 @@ export interface VcsCheckpointOps {...@@ -48,6 +56,9 @@ export interface VcsCheckpointOps {
48 input: VcsRestoreCheckpointInput,56 input: VcsRestoreCheckpointInput,
49 ) => Effect.Effect<boolean, VcsError>;57 ) => Effect.Effect<boolean, VcsError>;
50 readonly diffCheckpoints: (input: VcsDiffCheckpointsInput) => Effect.Effect<string, VcsError>;58 readonly diffCheckpoints: (input: VcsDiffCheckpointsInput) => Effect.Effect<string, VcsError>;
59 readonly listAuthoredPaths: (
60 input: VcsListAuthoredPathsInput,
61 ) => Effect.Effect<ReadonlySet<string> | null, VcsError>;
51 readonly deleteCheckpointRefs: (62 readonly deleteCheckpointRefs: (
52 input: VcsDeleteCheckpointRefsInput,63 input: VcsDeleteCheckpointRefsInput,
53 ) => Effect.Effect<void, VcsError>;64 ) => Effect.Effect<void, VcsError>;
docs/user/source-control.md+7
...@@ -3,6 +3,13 @@...@@ -3,6 +3,13 @@
3T3 Code integrates with GitHub, GitLab, Forgejo, Gitea, Bitbucket, and Azure DevOps to clone and publish3T3 Code integrates with GitHub, GitLab, Forgejo, Gitea, Bitbucket, and Azure DevOps to clone and publish
4repositories, create pull requests, and review changes.4repositories, create pull requests, and review changes.
55
6## Review turn changes
7
8A turn's changed files and diff show only the turn's own work. When a turn pulls, merges, or
9rebases, the files Git brought in are left out. A file stays in the list when the turn edited it,
10committed it, or fixed a conflict in it. Use the branch comparison to review everything that changed
11against your base branch. Restore still returns the complete saved workspace.
12
6## Connect an account13## Connect an account
714
8Install Git and configure authentication on the machine running your T3 Code server. For a remote15Install Git and configure authentication on the machine running your T3 Code server. For a remote