From 79ca6933bd9fdf9835effae7eda087c0609f4a12 Mon Sep 17 00:00:00 2001 From: Keirara Date: Thu, 24 Sep 2026 22:55:40 -0400 Subject: [PATCH 1/4] fix(server): reuse the checkpoint index so unchanged untracked files are not rehashed every turn Checkpoint capture staged from a copy of the user's index, which has no stat data for untracked files, so `git add -A` rehashed every untracked file on every capture. In a workspace with 83,524 untracked files that took 39 minutes, while listing them took 10 s, and every capture hit the 30 s process timeout. A capture of a whole, non-sparse worktree now saves its private index in the worktree's Git directory and starts the next capture from it, so `add -A` only rehashes files whose stat data changed. Ignored entries are reconciled against the user's index so the tree matches a fresh capture, and a saved index Git rejects is deleted and the capture starts over. Staging gets its own 5-minute bound instead of the 30 s default. Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/src/vcs/GitVcsDriver.test.ts | 158 +++++++++++++++++++++++ apps/server/src/vcs/GitVcsDriver.ts | 112 +++++++++++++++- 2 files changed, 267 insertions(+), 3 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 4df6d0eefc5c..66dcc457a896 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -941,6 +941,164 @@ for (const indexState of ["missing", "invalid"] as const) { ); } +const makeRecordingDriver = Effect.fn("makeRecordingDriver")(function* () { + const liveProcess = yield* VcsProcess.VcsProcess; + const commands: ReadonlyArray[] = []; + const driver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(VcsProcess.VcsProcess, { + run: (input) => { + commands.push(input.args); + return liveProcess.run(input); + }, + }), + ); + return { driver, commands }; +}); + +it.effect("checkpoint capture reuses its index so unchanged untracked files are not rehashed", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { driver, commands } = yield* makeRecordingDriver(); + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-reuse-" }); + const { git } = yield* makeCheckpointFixture(driver, cwd); + // An old mtime keeps a file out of Git's racy window, where it would be hashed again. + const write = Effect.fn(function* (name: string, contents: string) { + yield* fs.makeDirectory(path.dirname(path.join(cwd, name)), { recursive: true }); + yield* fs.writeFileString(path.join(cwd, name), contents); + yield* fs.utimes(path.join(cwd, name), 1_700_000_000, 1_700_000_000); + }); + yield* fs.writeFileString(path.join(cwd, ".gitattributes"), "*.dat filter=probe\n"); + yield* fs.writeFileString( + path.join(cwd, ".git/filter.cjs"), + 'require("node:fs").appendFileSync(".git/reads", "read\\n"); process.stdin.pipe(process.stdout);', + ); + yield* git(["config", "filter.probe.clean", "node .git/filter.cjs"]); + for (const name of ["cache/stable.dat", "cache/edited.dat", "deleted.dat"]) { + yield* write(name, `${name}\n`); + } + const originalIndex = yield* fs.readFile(path.join(cwd, ".git/index")); + const checkpointIndex = path.join(cwd, ".git/t3-checkpoint-index"); + const first = CheckpointRef.make("refs/t3/checkpoints/reuse/first"); + const second = CheckpointRef.make("refs/t3/checkpoints/reuse/second"); + const cold = CheckpointRef.make("refs/t3/checkpoints/reuse/cold"); + const captureCommands = Effect.fn(function* (checkpointRef: CheckpointRef) { + commands.length = 0; + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + return commands.map((args) => args.join(" ")); + }); + + yield* captureCommands(first); + assert.isTrue(yield* fs.exists(checkpointIndex)); + yield* write("cache/edited.dat", "edited\n"); + yield* fs.remove(path.join(cwd, "deleted.dat")); + yield* fs.writeFileString(path.join(cwd, "file.txt"), "second turn\n"); + yield* write("added.txt", "added\n"); + yield* fs.writeFileString(path.join(cwd, ".git/reads"), ""); + const warmCommands = yield* captureCommands(second); + + assert.strictEqual(yield* fs.readFileString(path.join(cwd, ".git/reads")), "read\n"); + assert.isFalse(warmCommands.some((command) => command.includes("read-tree"))); + for (const [name, content] of [ + ["cache/stable.dat", "cache/stable.dat\n"], + ["cache/edited.dat", "edited\n"], + ["file.txt", "second turn\n"], + ["added.txt", "added\n"], + ]) { + assert.strictEqual((yield* git(["show", `${second}:${name}`])).stdout, content); + } + assert.notInclude((yield* git(["ls-tree", "-r", "--name-only", second])).stdout, "deleted"); + + yield* fs.remove(checkpointIndex); + const coldCommands = yield* captureCommands(cold); + assert.isTrue(coldCommands.some((command) => command.includes("read-tree"))); + assert.strictEqual( + (yield* git(["rev-parse", `${second}^{tree}`])).stdout, + (yield* git(["rev-parse", `${cold}^{tree}`])).stdout, + ); + assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git/index")), originalIndex); + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), +); + +it.effect("checkpoint index reuse follows ignore rules like a fresh capture", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-reuse-ignored-" }); + const { git } = yield* makeCheckpointFixture(driver, cwd); + const write = (name: string) => + fs + .makeDirectory(path.dirname(path.join(cwd, name)), { recursive: true }) + .pipe(Effect.andThen(fs.writeFileString(path.join(cwd, name), `${name}\n`))); + yield* write("tracked.log"); + yield* git(["add", "tracked.log"]); + yield* git(["commit", "-m", "track a log"]); + yield* write("later.log"); + yield* write("build/out.o"); + const first = CheckpointRef.make("refs/t3/checkpoints/ignored/first"); + const second = CheckpointRef.make("refs/t3/checkpoints/ignored/second"); + const cold = CheckpointRef.make("refs/t3/checkpoints/ignored/cold"); + const files = (checkpointRef: CheckpointRef) => + git(["ls-tree", "-r", "--name-only", checkpointRef]).pipe( + Effect.map((result) => result.stdout.trim().split("\n")), + ); + + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef: first }); + assert.includeMembers(yield* files(first), ["later.log", "build/out.o", "tracked.log"]); + yield* fs.writeFileString(path.join(cwd, ".gitignore"), "*.log\nbuild/\n"); + yield* write("forced.log"); + yield* git(["add", "--force", "forced.log"]); + yield* git(["commit", "-m", "track an ignored log"]); + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef: second }); + + assert.sameMembers(yield* files(second), [ + ".gitignore", + "file.txt", + "forced.log", + "tracked.log", + ]); + yield* fs.remove(path.join(cwd, ".git/t3-checkpoint-index")); + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef: cold }); + assert.strictEqual( + (yield* git(["rev-parse", `${second}^{tree}`])).stdout, + (yield* git(["rev-parse", `${cold}^{tree}`])).stdout, + ); + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), +); + +it.effect.each(["corrupt", "pruned"] as const)( + "checkpoint capture starts over when its reused index is %s", + (state) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const { driver, commands } = yield* makeRecordingDriver(); + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-reuse-invalid-" }); + const { git } = yield* makeCheckpointFixture(driver, cwd); + yield* fs.writeFileString(path.join(cwd, "untracked.txt"), "untracked\n"); + const checkpointIndex = path.join(cwd, ".git/t3-checkpoint-index"); + const first = CheckpointRef.make("refs/t3/checkpoints/invalid/first"); + const second = CheckpointRef.make("refs/t3/checkpoints/invalid/second"); + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef: first }); + if (state === "corrupt") { + yield* fs.writeFileString(checkpointIndex, "invalid index"); + } else { + // Nothing else references the untracked blob, so Git prunes it from under the index. + yield* git(["update-ref", "-d", first]); + yield* git(["prune", "--expire=now"]); + } + commands.length = 0; + + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef: second }); + + assert.isTrue(commands.some((args) => args.includes("read-tree"))); + assert.strictEqual((yield* git(["show", `${second}:untracked.txt`])).stdout, "untracked\n"); + const replaced = yield* fs.readFile(checkpointIndex); + assert.strictEqual(new TextDecoder().decode(replaced.subarray(0, 4)), "DIRC"); + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), +); + it.effect("restores empty checkpoints without changing paths outside the workspace", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index f450eef0dc24..5d8d8711670d 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -392,6 +392,12 @@ export class GitVcsDriver extends Context.Service< const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024; const CHECKPOINT_RECOVERY_MAX_CANDIDATES = 64; const CHECKPOINT_RECOVERY_TIMEOUT = "5 seconds"; +// Staging hashes every file the index holds no stat data for. In one workspace with 83,524 +// untracked files, listing them took 10 s but a cold `add -A` took 39 minutes, so the 30 s +// default failed every capture. A reused checkpoint index keeps later captures to changed files. +// Captures run in CheckpointReactor, not on the provider turn, so the bound only keeps one slow +// capture from holding the checkpoint queue indefinitely. +const CHECKPOINT_STAGE_TIMEOUT_MS = 5 * 60_000; const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024; const CHECKPOINT_DIFF_MAX_OUTPUT_BYTES = 10_000_000; const WORKSPACE_GIT_HARDENED_CONFIG_ARGS = [ @@ -805,7 +811,78 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( { discard: true }, ); - yield* Effect.gen(function* () { + // Each capture keeps its index for the next, so `add -A` rehashes only files whose stat data + // changed instead of every untracked file. Only captures of a whole worktree use it: a nested + // workspace stages its own subtree, so a reused index would carry stale content elsewhere. + const checkpointIndexPath = yield* execute({ + operation, + cwd: input.cwd, + args: [ + "rev-parse", + "--show-prefix", + "--path-format=absolute", + "--git-path", + "t3-checkpoint-index", + ], + allowNonZeroExit: true, + }).pipe( + Effect.map((result) => { + const [prefix, indexPath] = result.stdout.split("\n"); + return result.exitCode === 0 && prefix === "" && indexPath?.trim() + ? indexPath.trim() + : null; + }), + ); + // A capture never stages from an index with assume-unchanged or skip-worktree entries (the + // checks below rebuild one), so the index it leaves needs no flag checks. Backdate the copy + // for the same racy-timestamp reason as the user index. + const startFromCheckpointIndex = (checkpointIndex: string) => + Effect.gen(function* () { + const { mtime } = yield* fileSystem.stat(checkpointIndex); + if (Option.isNone(mtime)) return false; + const indexTime = Math.floor((mtime.value.getTime() - 1) / 1000); + if (indexTime <= 0) return false; + yield* fileSystem.copyFile(checkpointIndex, tempIndexPath); + yield* fileSystem.utimes(tempIndexPath, indexTime, indexTime); + return true; + }).pipe(Effect.catch(() => cleanupTempIndex.pipe(Effect.as(false)))); + // `add -A` never drops an ignored entry or adds an ignored file, so a reused index would keep + // files captured before they became ignored and miss ignored files tracked since. Keep an + // ignored path only if the user's index tracks it, as a fresh capture does. + const syncIgnoredEntries = Effect.gen(function* () { + const listIgnored = (env?: NodeJS.ProcessEnv) => + execute({ + operation, + cwd: input.cwd, + args: [...indexConfig, "ls-files", "-z", "--cached", "--ignored", "--exclude-standard"], + ...(env !== undefined ? { env } : {}), + maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, + outputMode: "error", + }).pipe(Effect.map((result) => new Set(splitNullSeparatedGitStdoutPaths(result)))); + const captured = yield* listIgnored(commitEnv); + const tracked = yield* listIgnored(); + const updateIndex = (flags: ReadonlyArray, paths: ReadonlyArray) => + paths.length === 0 + ? Effect.void + : execute({ + operation, + cwd: input.cwd, + args: [...indexConfig, ...durableWrite, "update-index", ...flags, "-z", "--stdin"], + stdin: `${paths.join("\0")}\0`, + env: commitEnv, + }); + yield* updateIndex( + ["--force-remove"], + [...captured].filter((entry) => !tracked.has(entry)), + ); + yield* updateIndex( + ["--add", "--remove"], + [...tracked].filter((entry) => !captured.has(entry)), + ); + }); + + let reusedCheckpointIndex = false; + const capture = Effect.fn(function* (reuseCheckpointIndex: boolean) { const headExists = yield* hasHeadCommit(input.cwd); const sparseConfig = yield* execute({ operation, @@ -823,7 +900,13 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( }); sparseCheckout = /--(?:\[no-\])?sparse\b/.test(`${help.stdout}${help.stderr}`); } - if (headExists) { + reusedCheckpointIndex = + reuseCheckpointIndex && + checkpointIndexPath !== null && + !sparseCheckout && + (yield* startFromCheckpointIndex(checkpointIndexPath)); + // `add -A` resyncs a reused index with the whole worktree, so it need not start from HEAD. + if (headExists && !reusedCheckpointIndex) { const reusedIndex = yield* Effect.gen(function* () { const indexPath = yield* execute({ operation, @@ -947,6 +1030,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ...exclusions, ], env: commitEnv, + timeoutMs: CHECKPOINT_STAGE_TIMEOUT_MS, }); yield* stageFiles([]).pipe( Effect.catchTags({ @@ -1000,6 +1084,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ), }), ); + if (reusedCheckpointIndex) yield* syncIgnoredEntries; const writeTreeResult = yield* execute({ operation, @@ -1041,7 +1126,28 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( cwd: input.cwd, args: [...durableWrite, "update-ref", input.checkpointRef, commitOid], }); - }).pipe(Effect.ensuring(cleanupTempIndex)); + if (checkpointIndexPath !== null && !sparseCheckout) { + yield* fileSystem.rename(tempIndexPath, checkpointIndexPath).pipe(Effect.ignore); + } + }); + yield* capture(true).pipe( + // A reused index can be corrupt or name objects Git has since pruned, which `write-tree` + // or `commit-tree` reject. Start over from the user index. A timeout is the working + // tree's own cost, which a fresh index would only repeat. + Effect.catchIf( + (error) => reusedCheckpointIndex && error._tag !== "VcsProcessTimeoutError", + () => + cleanupTempIndex.pipe( + Effect.andThen( + checkpointIndexPath === null + ? Effect.void + : fileSystem.remove(checkpointIndexPath, { force: true }).pipe(Effect.ignore), + ), + Effect.andThen(capture(false)), + ), + ), + Effect.ensuring(cleanupTempIndex), + ); }), hasCheckpointRef: (input) => From 8ef57d3e3cee81707f138ff418915204ead07751 Mon Sep 17 00:00:00 2001 From: Keirara Date: Thu, 24 Sep 2026 23:15:14 -0400 Subject: [PATCH 2/4] fix(server): run checkpoint capture cold when ignored-path listing overflows Reusing the saved checkpoint index depends on listing its ignored entries and the user's, and both listings are capped at 16 MiB. Past the cap the capture fell back to a cold capture, which then saved a new index, so every later capture repeated the overflow and the cold rehash. When either listing hits the cap, the server now stops saving the index for that worktree. The failed capture still deletes the saved index and finishes cold, and later captures run cold, as they did before index reuse. Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/src/vcs/GitVcsDriver.test.ts | 51 ++++++++++++++++++++++++ apps/server/src/vcs/GitVcsDriver.ts | 19 ++++++++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 66dcc457a896..c429812371fc 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -1099,6 +1099,57 @@ it.effect.each(["corrupt", "pruned"] as const)( }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), ); +it.effect("checkpoint capture stays cold when its ignored entries overflow the listing", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const liveProcess = yield* VcsProcess.VcsProcess; + const commands: string[] = []; + const driver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(VcsProcess.VcsProcess, { + run: (input) => { + commands.push(input.args.join(" ")); + // Any listing is over a one-byte cap, as a huge one is over the real cap. + return liveProcess.run( + input.args.includes("--ignored") ? { ...input, maxOutputBytes: 1 } : input, + ); + }, + }), + ); + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-reuse-overflow-" }); + const { git } = yield* makeCheckpointFixture(driver, cwd); + yield* fs.writeFileString(path.join(cwd, ".gitignore"), "*.log\n"); + yield* fs.writeFileString(path.join(cwd, "tracked.log"), "tracked\n"); + yield* git(["add", "--force", ".gitignore", "tracked.log"]); + yield* git(["commit", "-m", "track an ignored log"]); + yield* fs.writeFileString(path.join(cwd, "untracked.txt"), "untracked\n"); + const checkpointIndex = path.join(cwd, ".git/t3-checkpoint-index"); + const tree = (checkpointRef: CheckpointRef) => + git(["rev-parse", `${checkpointRef}^{tree}`]).pipe(Effect.map((result) => result.stdout)); + const first = CheckpointRef.make("refs/t3/checkpoints/overflow/first"); + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef: first }); + assert.isTrue(yield* fs.exists(checkpointIndex)); + + for (const turn of ["second", "third"]) { + const checkpointRef = CheckpointRef.make(`refs/t3/checkpoints/overflow/${turn}`); + commands.length = 0; + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + + assert.strictEqual( + commands.some((command) => command.includes("--ignored")), + turn === "second", + ); + assert.isTrue(commands.some((command) => command.includes("read-tree"))); + assert.strictEqual(yield* tree(checkpointRef), yield* tree(first)); + assert.strictEqual( + (yield* git(["show", `${checkpointRef}:tracked.log`])).stdout, + "tracked\n", + ); + assert.isFalse(yield* fs.exists(checkpointIndex)); + } + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), +); + it.effect("restores empty checkpoints without changing paths outside the workspace", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 5d8d8711670d..10bea857f953 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -780,6 +780,8 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( "-c", "core.fsyncMethod=fsync", ] as const; + // Saved checkpoint indexes this server no longer writes; see `syncIgnoredEntries`. + const coldOnlyCheckpointIndexes = new Set(); const checkpoints: VcsDriver.VcsCheckpointOps = { captureCheckpoint: Effect.fn("GitVcsDriver.checkpoints.captureCheckpoint")(function* (input) { @@ -858,7 +860,16 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ...(env !== undefined ? { env } : {}), maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, outputMode: "error", - }).pipe(Effect.map((result) => new Set(splitNullSeparatedGitStdoutPaths(result)))); + }).pipe( + Effect.map((result) => new Set(splitNullSeparatedGitStdoutPaths(result))), + // Past the output cap, reuse cannot match a fresh capture, so stay cold like before. + Effect.tapErrorTag("VcsProcessOutputLimitError", () => + Effect.sync(() => { + if (checkpointIndexPath !== null) + coldOnlyCheckpointIndexes.add(checkpointIndexPath); + }), + ), + ); const captured = yield* listIgnored(commitEnv); const tracked = yield* listIgnored(); const updateIndex = (flags: ReadonlyArray, paths: ReadonlyArray) => @@ -1126,7 +1137,11 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( cwd: input.cwd, args: [...durableWrite, "update-ref", input.checkpointRef, commitOid], }); - if (checkpointIndexPath !== null && !sparseCheckout) { + if ( + checkpointIndexPath !== null && + !sparseCheckout && + !coldOnlyCheckpointIndexes.has(checkpointIndexPath) + ) { yield* fileSystem.rename(tempIndexPath, checkpointIndexPath).pipe(Effect.ignore); } }); From 07dfc2d13594161435c0b70e4a3e3c77fb293d6b Mon Sep 17 00:00:00 2001 From: Keirara Date: Thu, 24 Sep 2026 23:40:18 -0400 Subject: [PATCH 3/4] fix(server): compare a reused checkpoint index's ignored entries against HEAD A reused checkpoint index kept or added ignored files based on the user's index, but a fresh capture starts from HEAD, so staged changes never reach it. A force-added but uncommitted ignored file entered only the reused checkpoint, an ignored file removed with `git rm --cached` left only the reused one, and with no commits a staged ignored file did the same. Diffs and restores then depended on whether the index had been reused. The ignored entries are now compared against a scratch index read from HEAD, or against nothing when there is no commit. The listing keeps the same output cap and the same cold fallback on overflow. Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/src/vcs/GitVcsDriver.test.ts | 18 ++++++++++------ apps/server/src/vcs/GitVcsDriver.ts | 26 ++++++++++++++++-------- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index c429812371fc..916fceb35b48 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -998,7 +998,7 @@ it.effect("checkpoint capture reuses its index so unchanged untracked files are const warmCommands = yield* captureCommands(second); assert.strictEqual(yield* fs.readFileString(path.join(cwd, ".git/reads")), "read\n"); - assert.isFalse(warmCommands.some((command) => command.includes("read-tree"))); + assert.isFalse(warmCommands.some((command) => command.includes("read-tree --reset"))); for (const [name, content] of [ ["cache/stable.dat", "cache/stable.dat\n"], ["cache/edited.dat", "edited\n"], @@ -1011,7 +1011,7 @@ it.effect("checkpoint capture reuses its index so unchanged untracked files are yield* fs.remove(checkpointIndex); const coldCommands = yield* captureCommands(cold); - assert.isTrue(coldCommands.some((command) => command.includes("read-tree"))); + assert.isTrue(coldCommands.some((command) => command.includes("read-tree --reset"))); assert.strictEqual( (yield* git(["rev-parse", `${second}^{tree}`])).stdout, (yield* git(["rev-parse", `${cold}^{tree}`])).stdout, @@ -1032,8 +1032,9 @@ it.effect("checkpoint index reuse follows ignore rules like a fresh capture", () .makeDirectory(path.dirname(path.join(cwd, name)), { recursive: true }) .pipe(Effect.andThen(fs.writeFileString(path.join(cwd, name), `${name}\n`))); yield* write("tracked.log"); - yield* git(["add", "tracked.log"]); - yield* git(["commit", "-m", "track a log"]); + yield* write("unindexed.log"); + yield* git(["add", "tracked.log", "unindexed.log"]); + yield* git(["commit", "-m", "track logs"]); yield* write("later.log"); yield* write("build/out.o"); const first = CheckpointRef.make("refs/t3/checkpoints/ignored/first"); @@ -1050,6 +1051,10 @@ it.effect("checkpoint index reuse follows ignore rules like a fresh capture", () yield* write("forced.log"); yield* git(["add", "--force", "forced.log"]); yield* git(["commit", "-m", "track an ignored log"]); + // A fresh capture starts from HEAD, so staged changes to ignored files do not count. + yield* write("staged.log"); + yield* git(["add", "--force", "staged.log"]); + yield* git(["rm", "--cached", "--quiet", "unindexed.log"]); yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef: second }); assert.sameMembers(yield* files(second), [ @@ -1057,6 +1062,7 @@ it.effect("checkpoint index reuse follows ignore rules like a fresh capture", () "file.txt", "forced.log", "tracked.log", + "unindexed.log", ]); yield* fs.remove(path.join(cwd, ".git/t3-checkpoint-index")); yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef: cold }); @@ -1092,7 +1098,7 @@ it.effect.each(["corrupt", "pruned"] as const)( yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef: second }); - assert.isTrue(commands.some((args) => args.includes("read-tree"))); + assert.isTrue(commands.some((args) => args.join(" ").includes("read-tree --reset"))); assert.strictEqual((yield* git(["show", `${second}:untracked.txt`])).stdout, "untracked\n"); const replaced = yield* fs.readFile(checkpointIndex); assert.strictEqual(new TextDecoder().decode(replaced.subarray(0, 4)), "DIRC"); @@ -1139,7 +1145,7 @@ it.effect("checkpoint capture stays cold when its ignored entries overflow the l commands.some((command) => command.includes("--ignored")), turn === "second", ); - assert.isTrue(commands.some((command) => command.includes("read-tree"))); + assert.isTrue(commands.some((command) => command.includes("read-tree --reset"))); assert.strictEqual(yield* tree(checkpointRef), yield* tree(first)); assert.strictEqual( (yield* git(["show", `${checkpointRef}:tracked.log`])).stdout, diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 10bea857f953..5c5db1bdea8f 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -806,9 +806,10 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( GIT_COMMITTER_EMAIL: "t3code@users.noreply.github.com", }; + const headIndexPath = `${tempIndexPath}-head`; // Forced process termination can leave Git's private index lock behind. const cleanupTempIndex = Effect.forEach( - [tempIndexPath, `${tempIndexPath}.lock`], + [tempIndexPath, `${tempIndexPath}.lock`, headIndexPath, `${headIndexPath}.lock`], (indexFile) => fileSystem.remove(indexFile, { force: true }).pipe(Effect.ignore), { discard: true }, ); @@ -849,15 +850,16 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( return true; }).pipe(Effect.catch(() => cleanupTempIndex.pipe(Effect.as(false)))); // `add -A` never drops an ignored entry or adds an ignored file, so a reused index would keep - // files captured before they became ignored and miss ignored files tracked since. Keep an - // ignored path only if the user's index tracks it, as a fresh capture does. - const syncIgnoredEntries = Effect.gen(function* () { - const listIgnored = (env?: NodeJS.ProcessEnv) => + // files captured before they became ignored and miss ignored files tracked since. A fresh + // capture starts from HEAD, not the user's staged changes, so keep an ignored path only if + // HEAD tracks it. + const syncIgnoredEntries = Effect.fn(function* (headExists: boolean) { + const listIgnored = (env: NodeJS.ProcessEnv) => execute({ operation, cwd: input.cwd, args: [...indexConfig, "ls-files", "-z", "--cached", "--ignored", "--exclude-standard"], - ...(env !== undefined ? { env } : {}), + env, maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, outputMode: "error", }).pipe( @@ -871,7 +873,15 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ), ); const captured = yield* listIgnored(commitEnv); - const tracked = yield* listIgnored(); + const headEnv = { ...commitEnv, GIT_INDEX_FILE: headIndexPath }; + const tracked = headExists + ? yield* execute({ + operation, + cwd: input.cwd, + args: [...indexConfig, "read-tree", "HEAD"], + env: headEnv, + }).pipe(Effect.andThen(listIgnored(headEnv))) + : new Set(); const updateIndex = (flags: ReadonlyArray, paths: ReadonlyArray) => paths.length === 0 ? Effect.void @@ -1095,7 +1105,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ), }), ); - if (reusedCheckpointIndex) yield* syncIgnoredEntries; + if (reusedCheckpointIndex) yield* syncIgnoredEntries(headExists); const writeTreeResult = yield* execute({ operation, From 58b8c13f2177203cdfeef76a01bcb46ffc321c9b Mon Sep 17 00:00:00 2001 From: Keirara Date: Fri, 25 Sep 2026 16:05:23 -0400 Subject: [PATCH 4/4] test(server): a timed-out reused checkpoint capture does not retry cold A capture that reuses the saved checkpoint index starts over from the user's index when Git rejects the reused index, but not when staging times out, since a fresh index would only repeat the same work. Nothing tested that exclusion. The new test times out `git add -A` on a reused capture and checks that the capture fails with the timeout, that staging ran once with no cold `read-tree --reset`, and that the saved index is left unchanged. Co-authored-by: Claude Opus 5.5 (1M context) --- apps/server/src/vcs/GitVcsDriver.test.ts | 57 +++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 916fceb35b48..e8ec9aaa57ed 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -14,7 +14,12 @@ import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; import { assert, it } from "@effect/vitest"; -import { CheckpointRef, GitCommandError, VcsProcessExitError } from "@t3tools/contracts"; +import { + CheckpointRef, + GitCommandError, + VcsProcessExitError, + VcsProcessTimeoutError, +} from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; import * as CheckpointStore from "../checkpointing/CheckpointStore.ts"; import * as ProcessRunner from "../processRunner.ts"; @@ -1105,6 +1110,56 @@ it.effect.each(["corrupt", "pruned"] as const)( }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), ); +it.effect("a timed-out reused checkpoint capture keeps its index and does not retry cold", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const liveProcess = yield* VcsProcess.VcsProcess; + const commands: ReadonlyArray[] = []; + const isStaging = (args: ReadonlyArray) => args.includes("add") && args.includes("-A"); + let timeOutStaging = false; + const driver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(VcsProcess.VcsProcess, { + run: (input) => { + commands.push(input.args); + return timeOutStaging && isStaging(input.args) + ? Effect.fail( + new VcsProcessTimeoutError({ + operation: input.operation, + command: input.command, + cwd: input.cwd, + argumentCount: input.args.length, + timeoutMs: input.timeoutMs ?? 0, + }), + ) + : liveProcess.run(input); + }, + }), + ); + const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-reuse-timeout-" }); + yield* makeCheckpointFixture(driver, cwd); + yield* fs.writeFileString(path.join(cwd, "untracked.txt"), "untracked\n"); + const checkpointIndex = path.join(cwd, ".git/t3-checkpoint-index"); + const first = CheckpointRef.make("refs/t3/checkpoints/timeout/first"); + const second = CheckpointRef.make("refs/t3/checkpoints/timeout/second"); + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef: first }); + const savedIndex = yield* fs.readFile(checkpointIndex); + timeOutStaging = true; + commands.length = 0; + + const error = yield* driver.checkpoints + .captureCheckpoint({ cwd, checkpointRef: second }) + .pipe(Effect.flip); + + assert.strictEqual(error._tag, "VcsProcessTimeoutError"); + assert.lengthOf(commands.filter(isStaging), 1); + assert.isFalse(commands.some((args) => args.join(" ").includes("read-tree --reset"))); + // A timeout is the working tree's cost, not a bad index, so the saved index stays as it was. + assert.deepEqual(yield* fs.readFile(checkpointIndex), savedIndex); + assert.isFalse(yield* driver.checkpoints.hasCheckpointRef({ cwd, checkpointRef: second })); + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), +); + it.effect("checkpoint capture stays cold when its ignored entries overflow the listing", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem;