| author | |
| committer | |
| log | f3a69c70c6a5bc624e1b5eb80901d8b7c901ad6f |
| tree | 27281f42930b98cb8b367fcd14b9675e3f032a6c |
| parent | e858ddb65668d54be5de3996f30c0bd5c3149657 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 48 | function layerFor(input: { |
| 49 | 49 | readonly projection: Effect.Effect<ProjectionCheckpointContext, OrchestratorProjectionError>; |
| 50 | 50 | readonly diffCheckpoints?: CheckpointStore.CheckpointStore["Service"]["diffCheckpoints"]; |
| 51 | readonly authoredPaths?: ReadonlySet<string>; | |
| 51 | 52 | }) { |
| 52 | 53 | return CheckpointDiffQuery.layer.pipe( |
| 53 | 54 | Layer.provide( |
| ... | ... | @@ -57,6 +58,7 @@ function layerFor(input: { |
| 57 | 58 | }), |
| 58 | 59 | Layer.mock(CheckpointStore.CheckpointStore)({ |
| 59 | 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 | 95 | }).pipe(Effect.provide(layer)); |
| 94 | 96 | }); |
| 95 | 97 | |
| 98 | it.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 | ||
| 96 | 126 | it.effect("preserves the typed missing-thread error contract", () => { |
| 97 | 127 | const layer = layerFor({ |
| 98 | 128 | projection: Effect.fail(new OrchestratorProjectionError({ threadId })), |
apps/server/src/checkpointing/CheckpointDiffQuery.ts+59-9| ... | ... | @@ -31,6 +31,31 @@ import { |
| 31 | 31 | type CheckpointServiceError, |
| 32 | 32 | } from "./Errors.ts"; |
| 33 | 33 | import * as CheckpointStore from "./CheckpointStore.ts"; |
| 34 | import { 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. | |
| 37 | const 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. */ | |
| 40 | function 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 | } | |
| 34 | 59 | |
| 35 | 60 | /** Service tag for checkpoint diff queries. */ |
| 36 | 61 | export class CheckpointDiffQuery extends Context.Service< |
| ... | ... | @@ -182,15 +207,40 @@ export const make = Effect.gen(function* () { |
| 182 | 207 | }); |
| 183 | 208 | } |
| 184 | 209 | |
| 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(""); | |
| 194 | 244 | |
| 195 | 245 | const turnDiff = buildTurnDiffResult(input, diff); |
| 196 | 246 | if (!isTurnDiffResult(turnDiff)) { |
apps/server/src/checkpointing/CheckpointStore.test.ts+128-4| ... | ... | @@ -57,15 +57,22 @@ function writeTextFile( |
| 57 | 57 | function git( |
| 58 | 58 | cwd: string, |
| 59 | 59 | args: ReadonlyArray<string>, |
| 60 | options: { readonly committedAt?: string; readonly allowNonZeroExit?: boolean } = {}, | |
| 60 | 61 | ): Effect.Effect<string, VcsError, VcsProcess.VcsProcess> { |
| 61 | 62 | 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({ | |
| 64 | 65 | operation: "CheckpointStore.test.git", |
| 65 | 66 | command: "git", |
| 66 | 67 | cwd, |
| 67 | 68 | args, |
| 68 | 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 | 77 | return result.stdout.trim(); |
| 71 | 78 | }); |
| ... | ... | @@ -371,12 +378,12 @@ it.layer(layerTest)("CheckpointStore.layer", (it) => { |
| 371 | 378 | ); |
| 372 | 379 | const expectedFiles = [ |
| 373 | 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 | 382 | { path: "copy-source.txt", additions: 1, deletions: 0 }, |
| 376 | 383 | { path: "deleted.txt", additions: 0, deletions: 1 }, |
| 377 | 384 | { path: "empty.txt", additions: 0, deletions: 0 }, |
| 378 | 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 | 387 | ].toSorted((left, right) => left.path.localeCompare(right.path)); |
| 381 | 388 | expect(firstSummary).toEqual(expectedFiles); |
| 382 | 389 | |
| ... | ... | @@ -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 | 40 | readonly fallbackFromToHead?: boolean; |
| 41 | 41 | readonly ignoreWhitespace: boolean; |
| 42 | 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 | ||
| 47 | export interface ListAuthoredPathsInput { | |
| 48 | readonly cwd: string; | |
| 49 | readonly fromCheckpointRef: CheckpointRef; | |
| 50 | readonly toCheckpointRef: CheckpointRef; | |
| 43 | 51 | } |
| 44 | 52 | |
| 45 | 53 | export interface DeleteCheckpointRefsInput { |
| ... | ... | @@ -87,6 +95,19 @@ export class CheckpointStore extends Context.Service< |
| 87 | 95 | input: DiffCheckpointsInput, |
| 88 | 96 | ) => Effect.Effect<string, CheckpointStoreError>; |
| 89 | 97 | |
| 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 | 112 | * Delete the provided checkpoint refs. |
| 92 | 113 | * |
| ... | ... | @@ -150,6 +171,13 @@ export const make = Effect.gen(function* () { |
| 150 | 171 | return yield* checkpoints.diffCheckpoints(input); |
| 151 | 172 | }); |
| 152 | 173 | |
| 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 | 181 | const deleteCheckpointRefs: CheckpointStore["Service"]["deleteCheckpointRefs"] = Effect.fn( |
| 154 | 182 | "deleteCheckpointRefs", |
| 155 | 183 | )(function* (input) { |
| ... | ... | @@ -166,6 +194,7 @@ export const make = Effect.gen(function* () { |
| 166 | 194 | hasCheckpointRef, |
| 167 | 195 | restoreCheckpoint, |
| 168 | 196 | diffCheckpoints, |
| 197 | listAuthoredPaths, | |
| 169 | 198 | deleteCheckpointRefs, |
| 170 | 199 | }); |
| 171 | 200 | }); |
apps/server/src/checkpointing/Diffs.test.ts+6-4| ... | ... | @@ -15,7 +15,7 @@ describe("parseTurnDiffFilesFromNumstat", () => { |
| 15 | 15 | ]); |
| 16 | 16 | }); |
| 17 | 17 | |
| 18 | it("uses destination paths for renames and copies", () => { | |
| 18 | it("preserves both paths for renames and copies", () => { | |
| 19 | 19 | const numstat = [ |
| 20 | 20 | "0\t0\t", |
| 21 | 21 | "src/old.ts", |
| ... | ... | @@ -29,8 +29,8 @@ describe("parseTurnDiffFilesFromNumstat", () => { |
| 29 | 29 | |
| 30 | 30 | expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([ |
| 31 | 31 | { 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 }, | |
| 34 | 34 | ]); |
| 35 | 35 | }); |
| 36 | 36 | |
| ... | ... | @@ -46,7 +46,9 @@ describe("parseTurnDiffFilesFromNumstat", () => { |
| 46 | 46 | const path = " café\tline\r\nname.txt "; |
| 47 | 47 | const numstat = `3\t2\t\0old\tname\n.txt\0${path}\0`; |
| 48 | 48 | |
| 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 | 52 | expect(parseTurnDiffFilesFromNumstat(`1\t0\t${path}\0`)).toEqual([ |
| 51 | 53 | { path, additions: 1, deletions: 0 }, |
| 52 | 54 | ]); |
apps/server/src/checkpointing/Diffs.ts+20| ... | ... | @@ -1,5 +1,6 @@ |
| 1 | 1 | export interface TurnDiffFileSummary { |
| 2 | 2 | readonly path: string; |
| 3 | readonly previousPath?: string; | |
| 3 | 4 | readonly additions: number; |
| 4 | 5 | readonly deletions: number; |
| 5 | 6 | } |
| ... | ... | @@ -15,8 +16,10 @@ export function parseTurnDiffFilesFromNumstat(numstat: string): ReadonlyArray<Tu |
| 15 | 16 | if (!counts) continue; |
| 16 | 17 | |
| 17 | 18 | let path = record.slice(counts[0].length); |
| 19 | let previousPath: string | undefined; | |
| 18 | 20 | if (path.length === 0) { |
| 19 | 21 | // Renames and copies use two more records: the source and destination. |
| 22 | previousPath = records[index + 1]; | |
| 20 | 23 | path = records[index + 2] ?? ""; |
| 21 | 24 | index += 2; |
| 22 | 25 | } |
| ... | ... | @@ -24,6 +27,7 @@ export function parseTurnDiffFilesFromNumstat(numstat: string): ReadonlyArray<Tu |
| 24 | 27 | |
| 25 | 28 | files.push({ |
| 26 | 29 | path, |
| 30 | ...(previousPath === undefined ? {} : { previousPath }), | |
| 27 | 31 | additions: counts[1] === "-" ? 0 : Number(counts[1]), |
| 28 | 32 | deletions: counts[2] === "-" ? 0 : Number(counts[2]), |
| 29 | 33 | }); |
| ... | ... | @@ -31,3 +35,19 @@ export function parseTurnDiffFilesFromNumstat(numstat: string): ReadonlyArray<Tu |
| 31 | 35 | |
| 32 | 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 | */ | |
| 44 | export 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 | 277 | IdAllocator.layer, |
| 278 | 278 | NodeCrypto.layer, |
| 279 | 279 | Layer.mock(CheckpointStore.CheckpointStore)({ |
| 280 | listAuthoredPaths: () => Effect.succeed(null), | |
| 280 | 281 | isGitRepository: () => Effect.succeed(true), |
| 281 | 282 | captureCheckpoint: () => Effect.void, |
| 282 | 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 | 219 | getCheckpointContext: () => Effect.succeed(context), |
| 220 | 220 | }), |
| 221 | 221 | Layer.mock(CheckpointStore.CheckpointStore)({ |
| 222 | listAuthoredPaths: () => Effect.succeed(null), | |
| 222 | 223 | diffCheckpoints: (input) => { |
| 223 | 224 | assert.equal(input.cwd, "/prepared-repo"); |
| 224 | 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 | 19 | import * as Layer from "effect/Layer"; |
| 20 | 20 | import * as Schema from "effect/Schema"; |
| 21 | 21 | |
| 22 | import { parseTurnDiffFilesFromNumstat } from "../checkpointing/Diffs.ts"; | |
| 22 | import { isGitImport, parseTurnDiffFilesFromNumstat } from "../checkpointing/Diffs.ts"; | |
| 23 | 23 | import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; |
| 24 | 24 | import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; |
| 25 | 25 | |
| ... | ... | @@ -440,33 +440,49 @@ export const layer: Layer.Layer< |
| 440 | 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 | 450 | 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, | |
| 449 | 454 | fallbackFromToHead: false, |
| 450 | 455 | ignoreWhitespace: false, |
| 451 | 456 | 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( | |
| 462 | 459 | Effect.catch((cause) => |
| 463 | Effect.logWarning("orchestration V2 checkpoint diff summary failed", { | |
| 460 | Effect.logWarning("orchestration V2 checkpoint authored paths failed", { | |
| 464 | 461 | scopeId: input.scope.id, |
| 465 | 462 | checkpointRef, |
| 466 | 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 | : []; |
| 471 | 487 | |
| 472 | 488 | return makeCheckpoint({ |
apps/server/src/vcs/GitVcsDriver.ts+63-1| ... | ... | @@ -1049,10 +1049,18 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( |
| 1049 | 1049 | } |
| 1050 | 1050 | |
| 1051 | 1051 | const message = `t3 checkpoint ref=${input.checkpointRef}`; |
| 1052 | // HEAD is the parent so listAuthoredPaths can see how HEAD moved between checkpoints. | |
| 1052 | 1053 | const commitTreeResult = yield* execute({ |
| 1053 | 1054 | operation, |
| 1054 | 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 | 1064 | env: commitEnv, |
| 1057 | 1065 | }); |
| 1058 | 1066 | const commitOid = commitTreeResult.stdout.trim(); |
| ... | ... | @@ -1157,6 +1165,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( |
| 1157 | 1165 | |
| 1158 | 1166 | diffCheckpoints: Effect.fn("GitVcsDriver.checkpoints.diffCheckpoints")(function* (input) { |
| 1159 | 1167 | const operation = "GitVcsDriver.checkpoints.diffCheckpoints"; |
| 1168 | if (input.filePaths?.length === 0) return ""; | |
| 1160 | 1169 | yield* Effect.annotateCurrentSpan({ |
| 1161 | 1170 | "checkpoint.cwd": input.cwd, |
| 1162 | 1171 | "checkpoint.from_ref": input.fromCheckpointRef, |
| ... | ... | @@ -1202,6 +1211,10 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( |
| 1202 | 1211 | ...(input.ignoreWhitespace ? ["--ignore-all-space"] : []), |
| 1203 | 1212 | `${fromRevision}^{commit}`, |
| 1204 | 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 | 1219 | allowNonZeroExit: true, |
| 1207 | 1220 | maxOutputBytes: CHECKPOINT_DIFF_MAX_OUTPUT_BYTES, |
| ... | ... | @@ -1221,6 +1234,55 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( |
| 1221 | 1234 | return result.stdout; |
| 1222 | 1235 | }), |
| 1223 | 1236 | |
| 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 | 1286 | deleteCheckpointRefs: Effect.fn("GitVcsDriver.checkpoints.deleteCheckpointRefs")( |
| 1225 | 1287 | function* (input) { |
| 1226 | 1288 | yield* Effect.forEach( |
apps/server/src/vcs/VcsDriver.ts+11| ... | ... | @@ -32,6 +32,14 @@ export interface VcsDiffCheckpointsInput { |
| 32 | 32 | readonly fallbackFromToHead?: boolean; |
| 33 | 33 | readonly ignoreWhitespace: boolean; |
| 34 | 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 | ||
| 39 | export interface VcsListAuthoredPathsInput { | |
| 40 | readonly cwd: string; | |
| 41 | readonly fromCheckpointRef: CheckpointRef; | |
| 42 | readonly toCheckpointRef: CheckpointRef; | |
| 35 | 43 | } |
| 36 | 44 | |
| 37 | 45 | export interface VcsDeleteCheckpointRefsInput { |
| ... | ... | @@ -48,6 +56,9 @@ export interface VcsCheckpointOps { |
| 48 | 56 | input: VcsRestoreCheckpointInput, |
| 49 | 57 | ) => Effect.Effect<boolean, VcsError>; |
| 50 | 58 | readonly diffCheckpoints: (input: VcsDiffCheckpointsInput) => Effect.Effect<string, VcsError>; |
| 59 | readonly listAuthoredPaths: ( | |
| 60 | input: VcsListAuthoredPathsInput, | |
| 61 | ) => Effect.Effect<ReadonlySet<string> | null, VcsError>; | |
| 51 | 62 | readonly deleteCheckpointRefs: ( |
| 52 | 63 | input: VcsDeleteCheckpointRefsInput, |
| 53 | 64 | ) => Effect.Effect<void, VcsError>; |
docs/user/source-control.md+7| ... | ... | @@ -3,6 +3,13 @@ |
| 3 | 3 | T3 Code integrates with GitHub, GitLab, Forgejo, Gitea, Bitbucket, and Azure DevOps to clone and publish |
| 4 | 4 | repositories, create pull requests, and review changes. |
| 5 | 5 | |
| 6 | ## Review turn changes | |
| 7 | ||
| 8 | A turn's changed files and diff show only the turn's own work. When a turn pulls, merges, or | |
| 9 | rebases, the files Git brought in are left out. A file stays in the list when the turn edited it, | |
| 10 | committed it, or fixed a conflict in it. Use the branch comparison to review everything that changed | |
| 11 | against your base branch. Restore still returns the complete saved workspace. | |
| 12 | ||
| 6 | 13 | ## Connect an account |
| 7 | 14 | |
| 8 | 15 | Install Git and configure authentication on the machine running your T3 Code server. For a remote |