diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 4df6d0eefc5c..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"; @@ -941,6 +946,271 @@ 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 --reset"))); + 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 --reset"))); + 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* 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"); + 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"]); + // 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), [ + ".gitignore", + "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 }); + 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.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"); + }).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; + 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 --reset"))); + 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 f450eef0dc24..5c5db1bdea8f 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 = [ @@ -774,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) { @@ -798,14 +806,104 @@ 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 }, ); - 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. 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, + maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, + outputMode: "error", + }).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 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 + : 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 +921,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 +1051,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ...exclusions, ], env: commitEnv, + timeoutMs: CHECKPOINT_STAGE_TIMEOUT_MS, }); yield* stageFiles([]).pipe( Effect.catchTags({ @@ -1000,6 +1105,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( ), }), ); + if (reusedCheckpointIndex) yield* syncIgnoredEntries(headExists); const writeTreeResult = yield* execute({ operation, @@ -1041,7 +1147,32 @@ 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 && + !coldOnlyCheckpointIndexes.has(checkpointIndexPath) + ) { + 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) =>