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 {
4848function layerFor(input: {
4949 readonly projection: Effect.Effect<ProjectionCheckpointContext, OrchestratorProjectionError>;
5050 readonly diffCheckpoints?: CheckpointStore.CheckpointStore["Service"]["diffCheckpoints"];
51 readonly authoredPaths?: ReadonlySet<string>;
5152}) {
5253 return CheckpointDiffQuery.layer.pipe(
5354 Layer.provide(
......@@ -57,6 +58,7 @@ function layerFor(input: {
5758 }),
5859 Layer.mock(CheckpointStore.CheckpointStore)({
5960 diffCheckpoints: input.diffCheckpoints ?? (() => Effect.succeed("diff")),
61 listAuthoredPaths: () => Effect.succeed(input.authoredPaths ?? null),
6062 }),
6163 ),
6264 ),
......@@ -93,6 +95,34 @@ it.effect("computes V2 run diffs from projected checkpoint scopes", () => {
9395 }).pipe(Effect.provide(layer));
9496});
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
96126it.effect("preserves the typed missing-thread error contract", () => {
97127 const layer = layerFor({
98128 projection: Effect.fail(new OrchestratorProjectionError({ threadId })),
apps/server/src/checkpointing/CheckpointDiffQuery.ts+59-9
......@@ -31,6 +31,31 @@ import {
3131 type CheckpointServiceError,
3232} from "./Errors.ts";
3333import * 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
3560/** Service tag for checkpoint diff queries. */
3661export class CheckpointDiffQuery extends Context.Service<
......@@ -182,15 +207,40 @@ export const make = Effect.gen(function* () {
182207 });
183208 }
184209
185 const diff = yield* checkpointStore
186 .diffCheckpoints({
187 cwd: toScope.cwd,
188 fromCheckpointRef,
189 toCheckpointRef: toCheckpoint.ref,
190 fallbackFromToHead: false,
191 ignoreWhitespace,
192 })
193 .pipe(Effect.withSpan("checkpoint.turnDiff.diffCheckpoints"));
210 const comparison = {
211 cwd: toScope.cwd,
212 fromCheckpointRef,
213 toCheckpointRef: toCheckpoint.ref,
214 fallbackFromToHead: false,
215 ignoreWhitespace,
216 };
217 // Leave out files that a pull, merge, or rebase brought in, matching the turn's file summary.
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
195245 const turnDiff = buildTurnDiffResult(input, diff);
196246 if (!isTurnDiffResult(turnDiff)) {
apps/server/src/checkpointing/CheckpointStore.test.ts+128-4
......@@ -57,15 +57,22 @@ function writeTextFile(
5757function git(
5858 cwd: string,
5959 args: ReadonlyArray<string>,
60 options: { readonly committedAt?: string; readonly allowNonZeroExit?: boolean } = {},
6061): Effect.Effect<string, VcsError, VcsProcess.VcsProcess> {
6162 return Effect.gen(function* () {
62 const process = yield* VcsProcess.VcsProcess;
63 const result = yield* process.run({
63 const vcsProcess = yield* VcsProcess.VcsProcess;
64 const result = yield* vcsProcess.run({
6465 operation: "CheckpointStore.test.git",
6566 command: "git",
6667 cwd,
6768 args,
6869 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 }),
6976 });
7077 return result.stdout.trim();
7178 });
......@@ -371,12 +378,12 @@ it.layer(layerTest)("CheckpointStore.layer", (it) => {
371378 );
372379 const expectedFiles = [
373380 { 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 },
375382 { path: "copy-source.txt", additions: 1, deletions: 0 },
376383 { path: "deleted.txt", additions: 0, deletions: 1 },
377384 { path: "empty.txt", additions: 0, deletions: 0 },
378385 { path: addedPath, additions: 2, deletions: 0 },
379 { path: renamedPath, additions: 1, deletions: 1 },
386 { path: renamedPath, previousPath: "rename-old.txt", additions: 1, deletions: 1 },
380387 ].toSorted((left, right) => left.path.localeCompare(right.path));
381388 expect(firstSummary).toEqual(expectedFiles);
382389
......@@ -441,5 +448,122 @@ it.layer(layerTest)("CheckpointStore.layer", (it) => {
441448 ]);
442449 }),
443450 );
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 );
444568 });
445569});
apps/server/src/checkpointing/CheckpointStore.ts+29
......@@ -40,6 +40,14 @@ export interface DiffCheckpointsInput {
4040 readonly fallbackFromToHead?: boolean;
4141 readonly ignoreWhitespace: boolean;
4242 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;
4351}
4452
4553export interface DeleteCheckpointRefsInput {
......@@ -87,6 +95,19 @@ export class CheckpointStore extends Context.Service<
8795 input: DiffCheckpointsInput,
8896 ) => 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
90111 /**
91112 * Delete the provided checkpoint refs.
92113 *
......@@ -150,6 +171,13 @@ export const make = Effect.gen(function* () {
150171 return yield* checkpoints.diffCheckpoints(input);
151172 });
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
153181 const deleteCheckpointRefs: CheckpointStore["Service"]["deleteCheckpointRefs"] = Effect.fn(
154182 "deleteCheckpointRefs",
155183 )(function* (input) {
......@@ -166,6 +194,7 @@ export const make = Effect.gen(function* () {
166194 hasCheckpointRef,
167195 restoreCheckpoint,
168196 diffCheckpoints,
197 listAuthoredPaths,
169198 deleteCheckpointRefs,
170199 });
171200});
apps/server/src/checkpointing/Diffs.test.ts+6-4
......@@ -15,7 +15,7 @@ describe("parseTurnDiffFilesFromNumstat", () => {
1515 ]);
1616 });
1717
18 it("uses destination paths for renames and copies", () => {
18 it("preserves both paths for renames and copies", () => {
1919 const numstat = [
2020 "0\t0\t",
2121 "src/old.ts",
......@@ -29,8 +29,8 @@ describe("parseTurnDiffFilesFromNumstat", () => {
2929
3030 expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([
3131 { path: "other.ts", additions: 1, deletions: 0 },
32 { path: "src/copied.ts", additions: 2, deletions: 1 },
33 { path: "src/new.ts", additions: 0, deletions: 0 },
32 { path: "src/copied.ts", previousPath: "src/source.ts", additions: 2, deletions: 1 },
33 { path: "src/new.ts", previousPath: "src/old.ts", additions: 0, deletions: 0 },
3434 ]);
3535 });
3636
......@@ -46,7 +46,9 @@ describe("parseTurnDiffFilesFromNumstat", () => {
4646 const path = " café\tline\r\nname.txt ";
4747 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 ]);
5052 expect(parseTurnDiffFilesFromNumstat(`1\t0\t${path}\0`)).toEqual([
5153 { path, additions: 1, deletions: 0 },
5254 ]);
apps/server/src/checkpointing/Diffs.ts+20
......@@ -1,5 +1,6 @@
11export interface TurnDiffFileSummary {
22 readonly path: string;
3 readonly previousPath?: string;
34 readonly additions: number;
45 readonly deletions: number;
56}
......@@ -15,8 +16,10 @@ export function parseTurnDiffFilesFromNumstat(numstat: string): ReadonlyArray<Tu
1516 if (!counts) continue;
1617
1718 let path = record.slice(counts[0].length);
19 let previousPath: string | undefined;
1820 if (path.length === 0) {
1921 // Renames and copies use two more records: the source and destination.
22 previousPath = records[index + 1];
2023 path = records[index + 2] ?? "";
2124 index += 2;
2225 }
......@@ -24,6 +27,7 @@ export function parseTurnDiffFilesFromNumstat(numstat: string): ReadonlyArray<Tu
2427
2528 files.push({
2629 path,
30 ...(previousPath === undefined ? {} : { previousPath }),
2731 additions: counts[1] === "-" ? 0 : Number(counts[1]),
2832 deletions: counts[2] === "-" ? 0 : Number(counts[2]),
2933 });
......@@ -31,3 +35,19 @@ export function parseTurnDiffFilesFromNumstat(numstat: string): ReadonlyArray<Tu
3135
3236 return files.toSorted((left, right) => left.path.localeCompare(right.path));
3337}
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) => {
277277 IdAllocator.layer,
278278 NodeCrypto.layer,
279279 Layer.mock(CheckpointStore.CheckpointStore)({
280 listAuthoredPaths: () => Effect.succeed(null),
280281 isGitRepository: () => Effect.succeed(true),
281282 captureCheckpoint: () => Effect.void,
282283 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
219219 getCheckpointContext: () => Effect.succeed(context),
220220 }),
221221 Layer.mock(CheckpointStore.CheckpointStore)({
222 listAuthoredPaths: () => Effect.succeed(null),
222223 diffCheckpoints: (input) => {
223224 assert.equal(input.cwd, "/prepared-repo");
224225 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";
1919import * as Layer from "effect/Layer";
2020import * as Schema from "effect/Schema";
2121
22import { parseTurnDiffFilesFromNumstat } from "../checkpointing/Diffs.ts";
22import { isGitImport, parseTurnDiffFilesFromNumstat } from "../checkpointing/Diffs.ts";
2323import * as CheckpointStore from "../checkpointing/CheckpointStore.ts";
2424import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator";
2525
......@@ -440,33 +440,49 @@ export const layer: Layer.Layer<
440440 }).pipe(Effect.as(false)),
441441 ),
442442 );
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.
443450 const files = previousExists
444 ? yield* checkpointStore
445 .diffCheckpoints({
446 cwd: input.scope.cwd,
447 fromCheckpointRef: previousCheckpointRef,
448 toCheckpointRef: checkpointRef,
451 ? yield* Effect.all([
452 checkpointStore.diffCheckpoints({
453 ...refs,
449454 fallbackFromToHead: false,
450455 ignoreWhitespace: false,
451456 format: "numstat",
452 })
453 .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 ),
457 }),
458 checkpointStore.listAuthoredPaths(refs).pipe(
462459 Effect.catch((cause) =>
463 Effect.logWarning("orchestration V2 checkpoint diff summary failed", {
460 Effect.logWarning("orchestration V2 checkpoint authored paths failed", {
464461 scopeId: input.scope.id,
465462 checkpointRef,
466463 cause: String(cause),
467 }).pipe(Effect.as([])),
464 }).pipe(Effect.as(null)),
468465 ),
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 )
470486 : [];
471487
472488 return makeCheckpoint({
apps/server/src/vcs/GitVcsDriver.ts+63-1
......@@ -1049,10 +1049,18 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
10491049 }
10501050
10511051 const message = `t3 checkpoint ref=${input.checkpointRef}`;
1052 // HEAD is the parent so listAuthoredPaths can see how HEAD moved between checkpoints.
10521053 const commitTreeResult = yield* execute({
10531054 operation,
10541055 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 ],
10561064 env: commitEnv,
10571065 });
10581066 const commitOid = commitTreeResult.stdout.trim();
......@@ -1157,6 +1165,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
11571165
11581166 diffCheckpoints: Effect.fn("GitVcsDriver.checkpoints.diffCheckpoints")(function* (input) {
11591167 const operation = "GitVcsDriver.checkpoints.diffCheckpoints";
1168 if (input.filePaths?.length === 0) return "";
11601169 yield* Effect.annotateCurrentSpan({
11611170 "checkpoint.cwd": input.cwd,
11621171 "checkpoint.from_ref": input.fromCheckpointRef,
......@@ -1202,6 +1211,10 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
12021211 ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []),
12031212 `${fromRevision}^{commit}`,
12041213 `${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 : []),
12051218 ],
12061219 allowNonZeroExit: true,
12071220 maxOutputBytes: CHECKPOINT_DIFF_MAX_OUTPUT_BYTES,
......@@ -1221,6 +1234,55 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
12211234 return result.stdout;
12221235 }),
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
12241286 deleteCheckpointRefs: Effect.fn("GitVcsDriver.checkpoints.deleteCheckpointRefs")(
12251287 function* (input) {
12261288 yield* Effect.forEach(
apps/server/src/vcs/VcsDriver.ts+11
......@@ -32,6 +32,14 @@ export interface VcsDiffCheckpointsInput {
3232 readonly fallbackFromToHead?: boolean;
3333 readonly ignoreWhitespace: boolean;
3434 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;
3543}
3644
3745export interface VcsDeleteCheckpointRefsInput {
......@@ -48,6 +56,9 @@ export interface VcsCheckpointOps {
4856 input: VcsRestoreCheckpointInput,
4957 ) => Effect.Effect<boolean, VcsError>;
5058 readonly diffCheckpoints: (input: VcsDiffCheckpointsInput) => Effect.Effect<string, VcsError>;
59 readonly listAuthoredPaths: (
60 input: VcsListAuthoredPathsInput,
61 ) => Effect.Effect<ReadonlySet<string> | null, VcsError>;
5162 readonly deleteCheckpointRefs: (
5263 input: VcsDeleteCheckpointRefsInput,
5364 ) => Effect.Effect<void, VcsError>;
docs/user/source-control.md+7
......@@ -3,6 +3,13 @@
33T3 Code integrates with GitHub, GitLab, Forgejo, Gitea, Bitbucket, and Azure DevOps to clone and publish
44repositories, 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
613## Connect an account
714
815Install Git and configure authentication on the machine running your T3 Code server. For a remote