diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 031055a3b6c..6abddc64165 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -7,7 +7,7 @@ import * as PlatformError from "effect/PlatformError"; import { ChildProcessSpawner } from "effect/unstable/process"; import { assert, it } from "@effect/vitest"; -import { GitCommandError } from "@t3tools/contracts"; +import { CheckpointRef, GitCommandError } from "@t3tools/contracts"; import * as ServerConfig from "../config.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; import * as VcsProcess from "./VcsProcess.ts"; @@ -65,6 +65,168 @@ runVcsDriverContractSuite({ }, }); +const makeCheckpointFixture = Effect.fn("makeCheckpointFixture")(function* ( + driver: Effect.Success>, + cwd: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const git = (args: ReadonlyArray) => + driver.execute({ operation: "checkpoint-test", cwd, args }); + yield* git(["init"]); + yield* git(["config", "user.name", "Test"]); + yield* git(["config", "user.email", "test@test.com"]); + yield* fileSystem.writeFileString(path.join(cwd, "file.txt"), "initial\n"); + yield* git(["add", "."]); + yield* git(["commit", "-m", "initial"]); + const checkpointRef = CheckpointRef.make("refs/t3/checkpoints/test"); + yield* fileSystem.writeFileString(path.join(cwd, "file.txt"), "staged\n"); + yield* git(["add", "."]); + yield* fileSystem.writeFileString(path.join(cwd, "file.txt"), "unstaged\n"); + return { git, checkpointRef }; +}); + +it.effect("checkpoint capture does not rerun clean filters for unchanged indexed files", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-cache-" }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + yield* fileSystem.writeFileString( + path.join(cwd, ".gitattributes"), + "stable.txt filter=probe\n", + ); + yield* fileSystem.writeFileString(path.join(cwd, "stable.txt"), "unchanged\n"); + yield* fileSystem.writeFileString( + path.join(cwd, ".git", "filter.cjs"), + 'require("node:fs").appendFileSync(".git/filter-runs", "read\\n"); process.stdin.pipe(process.stdout);', + ); + yield* git(["config", "filter.probe.clean", "node .git/filter.cjs"]); + yield* fileSystem.utimes(path.join(cwd, "stable.txt"), 1_700_000_000, 1_700_000_000); + yield* git(["add", "."]); + yield* git(["commit", "-m", "record stable file"]); + yield* fileSystem.writeFileString(path.join(cwd, ".git", "filter-runs"), ""); + yield* fileSystem.writeFileString(path.join(cwd, "file.txt"), "changed\n"); + const originalIndex = yield* fileSystem.readFile(path.join(cwd, ".git", "index")); + + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + + assert.strictEqual(yield* fileSystem.readFileString(path.join(cwd, ".git", "filter-runs")), ""); + assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "changed\n"); + assert.strictEqual((yield* git(["show", `${checkpointRef}:stable.txt`])).stdout, "unchanged\n"); + assert.deepEqual(yield* fileSystem.readFile(path.join(cwd, ".git", "index")), originalIndex); + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), +); + +for (const nested of [false, true]) { + for (const indexMode of ["normal", "flags", "split"] as const) { + it.effect( + `checkpoint index reuse preserves two turns (nested=${nested}, index=${indexMode})`, + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-turns-" }); + const { git } = yield* makeCheckpointFixture(driver, cwd); + const write = (name: string, contents: string) => + fileSystem.writeFileString(path.join(cwd, name), contents); + yield* fileSystem.makeDirectory(path.join(cwd, "scope")); + for (const name of [ + "scope/staged", + "scope/deleted", + "scope/assumed", + "scope/skipped", + "outside", + ]) { + yield* write(name, "original\n"); + } + yield* git(["add", "."]); + yield* git(["commit", "-m", "initial scoped files"]); + yield* write("scope/staged", "staged\n"); + yield* write("scope/new-deleted", "staged then deleted\n"); + yield* write("outside", "staged outside\n"); + yield* git(["add", "."]); + if (indexMode === "flags") { + yield* git(["update-index", "--assume-unchanged", "scope/assumed"]); + yield* git(["update-index", "--skip-worktree", "scope/skipped"]); + } + if (indexMode === "split") { + yield* git(["update-index", "--split-index"]); + } + const originalIndex = yield* fileSystem.readFile(path.join(cwd, ".git", "index")); + for (const name of ["scope/staged", "scope/assumed", "scope/skipped", "outside"]) { + yield* write(name, "working\n"); + } + yield* write("scope/new", "first\n"); + yield* fileSystem.remove(path.join(cwd, "scope/deleted")); + yield* fileSystem.remove(path.join(cwd, "scope/new-deleted")); + const captureCwd = nested ? path.join(cwd, "scope") : cwd; + const first = CheckpointRef.make("refs/t3/checkpoints/turns/1"); + const second = CheckpointRef.make("refs/t3/checkpoints/turns/2"); + yield* driver.checkpoints.captureCheckpoint({ cwd: captureCwd, checkpointRef: first }); + for (const name of ["scope/staged", "scope/assumed", "scope/skipped"]) { + assert.strictEqual((yield* git(["show", `${first}:${name}`])).stdout, "working\n"); + } + assert.strictEqual( + (yield* git(["show", `${first}:outside`])).stdout, + nested ? "original\n" : "working\n", + ); + const files = (yield* git(["ls-tree", "-r", "--name-only", first])).stdout.split("\n"); + assert.notInclude(files, "scope/deleted"); + assert.notInclude(files, "scope/new-deleted"); + assert.include(files, "scope/new"); + + yield* write("scope/staged", "second\n"); + yield* fileSystem.remove(path.join(cwd, "scope/new")); + yield* write("scope/second", "added in second turn\n"); + yield* driver.checkpoints.captureCheckpoint({ cwd: captureCwd, checkpointRef: second }); + assert.strictEqual( + (yield* git(["diff", "--name-only", first, second])).stdout, + "scope/new\nscope/second\nscope/staged\n", + ); + assert.strictEqual((yield* git(["show", `${second}:scope/staged`])).stdout, "second\n"); + assert.strictEqual( + (yield* git(["show", `${second}:scope/second`])).stdout, + "added in second turn\n", + ); + assert.deepEqual( + yield* fileSystem.readFile(path.join(cwd, ".git", "index")), + originalIndex, + ); + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), + ); + } +} + +for (const indexState of ["missing", "invalid"] as const) { + it.effect(`checkpoint capture falls back when the user index is ${indexState}`, () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriver.makeVcsDriverShape(); + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-index-" }); + const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd); + const indexPath = path.join(cwd, ".git", "index"); + if (indexState === "missing") { + yield* fileSystem.remove(indexPath); + } else { + yield* fileSystem.writeFileString(indexPath, "invalid index"); + } + + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + + assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "unstaged\n"); + if (indexState === "missing") { + assert.isFalse(yield* fileSystem.exists(indexPath)); + } else { + assert.strictEqual(yield* fileSystem.readFileString(indexPath), "invalid index"); + } + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), + ); +} + it.effect("GitVcsDriver forwards execute env to the VCS process", () => { let observedEnv: NodeJS.ProcessEnv | undefined; let observedAppendTruncationMarker: boolean | undefined; diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index f1e48a24d6f..3426b193145 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -735,12 +735,38 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( yield* Effect.gen(function* () { const headExists = yield* hasHeadCommit(input.cwd); if (headExists) { - yield* execute({ - operation, - cwd: input.cwd, - args: ["read-tree", "HEAD"], - env: commitEnv, - }); + const reusedIndex = yield* Effect.gen(function* () { + const indexPath = yield* execute({ + operation, + cwd: input.cwd, + args: ["rev-parse", "--path-format=absolute", "--git-path", "index"], + }); + yield* fileSystem.copyFile(indexPath.stdout.trim(), tempIndexPath); + // Retain stat data only where the copied index already matches HEAD. + yield* execute({ + operation, + cwd: input.cwd, + args: ["-c", "core.fsmonitor=false", "read-tree", "--reset", "HEAD"], + env: commitEnv, + }); + const entries = yield* execute({ + operation, + cwd: input.cwd, + args: ["ls-files", "-v"], + env: commitEnv, + maxOutputBytes: WORKSPACE_FILES_MAX_OUTPUT_BYTES, + }); + // A fresh index must still capture assume-unchanged/skip-worktree files. + return !entries.stdoutTruncated && !/^[a-zS] /m.test(entries.stdout); + }).pipe(Effect.orElseSucceed(() => false)); + if (!reusedIndex) { + yield* execute({ + operation, + cwd: input.cwd, + args: ["read-tree", "HEAD"], + env: commitEnv, + }); + } } yield* execute({