From 34cf7b76a50b77a7a155b8a3e2e95ad1d0dbec2b Mon Sep 17 00:00:00 2001 From: Krishna Vijay <228381532+im-kvijay@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:58:12 -0700 Subject: [PATCH] fix(server): clean up failed checkpoint writes --- .../src/checkpointing/CheckpointStore.test.ts | 97 ++++++ apps/server/src/sourceControl/GitLabCli.ts | 1 + apps/server/src/vcs/GitVcsDriver.test.ts | 288 +++++++++++++++++- apps/server/src/vcs/GitVcsDriver.ts | 129 +++++--- packages/contracts/src/vcs.ts | 14 + 5 files changed, 488 insertions(+), 41 deletions(-) diff --git a/apps/server/src/checkpointing/CheckpointStore.test.ts b/apps/server/src/checkpointing/CheckpointStore.test.ts index 5a9b5cc5d6b0..bca4f105e014 100644 --- a/apps/server/src/checkpointing/CheckpointStore.test.ts +++ b/apps/server/src/checkpointing/CheckpointStore.test.ts @@ -95,6 +95,103 @@ function buildLargeText(lineCount = 5_000): string { } it.layer(TestLayer)("CheckpointStore.layer", (it) => { + describe("checkpoint capture", () => { + it.effect("reads existing objects when the repository path contains control characters", () => + Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + if (platform === "win32") return; + const tmp = yield* makeTmpDir("checkpoint-store-\u0001-"); + yield* initRepoWithCommit(tmp); + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const checkpointRef = checkpointRefForThreadTurn(ThreadId.make("control-path"), 0); + yield* writeTextFile(NodePath.join(tmp, "new.txt"), "checkpoint contents\n"); + + yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef }); + + expect(yield* git(tmp, ["show", `${checkpointRef}:README.md`])).toBe("# test"); + expect(yield* git(tmp, ["show", `${checkpointRef}:new.txt`])).toBe("checkpoint contents"); + }), + ); + + it.effect("restores large untracked files without changing the user index during capture", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + yield* git(tmp, ["config", "core.bigFileThreshold", "1k"]); + const fileSystem = yield* FileSystem.FileSystem; + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const checkpointRef = checkpointRefForThreadTurn(ThreadId.make("large-checkpoint"), 0); + const artifactPath = NodePath.join(tmp, "artifact[1].bin"); + const ignoredPath = NodePath.join(tmp, "ignored.bin"); + const unrelatedPackPath = NodePath.join(tmp, ".git/objects/pack/tmp_pack_unrelated"); + + yield* writeTextFile(NodePath.join(tmp, "README.md"), "staged\n"); + yield* git(tmp, ["add", "README.md"]); + yield* writeTextFile(NodePath.join(tmp, "README.md"), "workspace\n"); + yield* writeTextFile(NodePath.join(tmp, ".gitignore"), "ignored.bin\n"); + yield* writeTextFile(ignoredPath, "ignored before capture\n"); + yield* writeTextFile(artifactPath, "checkpoint contents\n"); + yield* fileSystem.truncate(artifactPath, 32 * 1024 * 1024 + 1); + yield* writeTextFile(unrelatedPackPath, "another Git operation owns this file\n"); + const artifactOid = yield* git(tmp, ["hash-object", "--", "artifact[1].bin"]); + const userIndex = yield* fileSystem.readFile(NodePath.join(tmp, ".git/index")); + + yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef }); + + expect(yield* fileSystem.readFile(NodePath.join(tmp, ".git/index"))).toEqual(userIndex); + expect(yield* git(tmp, ["rev-parse", `${checkpointRef}:artifact[1].bin`])).toBe( + artifactOid, + ); + expect(yield* git(tmp, ["show", `${checkpointRef}:README.md`])).toBe("workspace"); + expect(yield* git(tmp, ["ls-tree", "--name-only", checkpointRef])).not.toContain( + "ignored.bin", + ); + expect( + (yield* fileSystem.readDirectory(NodePath.join(tmp, ".git/objects/pack"))).some((name) => + name.endsWith(".pack"), + ), + ).toBe(true); + yield* git(tmp, ["fsck", "--full", "--no-dangling"]); + + yield* writeTextFile(artifactPath, "changed and now small\n"); + yield* writeTextFile(ignoredPath, "ignored after capture\n"); + yield* writeTextFile(NodePath.join(tmp, "extra.txt"), "created after capture\n"); + expect(yield* checkpointStore.restoreCheckpoint({ cwd: tmp, checkpointRef })).toBe(true); + + expect(yield* git(tmp, ["hash-object", "--", "artifact[1].bin"])).toBe(artifactOid); + expect(yield* fileSystem.readFileString(ignoredPath)).toBe("ignored after capture\n"); + expect(yield* fileSystem.exists(NodePath.join(tmp, "extra.txt"))).toBe(false); + expect(yield* fileSystem.readFileString(unrelatedPackPath)).toBe( + "another Git operation owns this file\n", + ); + }), + ); + + it.effect("publishes checkpoint objects to the shared repository from a linked worktree", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const worktree = NodePath.join(yield* makeTmpDir(), "worktree"); + yield* git(tmp, ["worktree", "add", "--detach", worktree]); + const checkpointStore = yield* CheckpointStore.CheckpointStore; + const checkpointRef = checkpointRefForThreadTurn(ThreadId.make("worktree-checkpoint"), 0); + const filePath = NodePath.join(worktree, "new.txt"); + yield* writeTextFile(filePath, "worktree checkpoint\n"); + + yield* checkpointStore.captureCheckpoint({ cwd: worktree, checkpointRef }); + + expect(yield* git(tmp, ["show", `${checkpointRef}:new.txt`])).toBe("worktree checkpoint"); + yield* git(tmp, ["fsck", "--full", "--no-dangling"]); + yield* writeTextFile(filePath, "changed\n"); + expect(yield* checkpointStore.restoreCheckpoint({ cwd: worktree, checkpointRef })).toBe( + true, + ); + const fileSystem = yield* FileSystem.FileSystem; + expect(yield* fileSystem.readFileString(filePath)).toBe("worktree checkpoint\n"); + }), + ); + }); + describe("isGitRepository", () => { it.effect("returns false when no Git repository is detected", () => Effect.gen(function* () { diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index ab8dfbb5f334..7c5df43b63c6 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -153,6 +153,7 @@ export class GitLabCliCommandError extends Schema.TaggedErrorClass new GitLabCliCommandError({ ...context, cause }), VcsProcessMissingExitCodeError: (cause) => new GitLabCliCommandError({ ...context, cause }), VcsRepositoryDetectionError: (cause) => new GitLabCliCommandError({ ...context, cause }), + VcsCheckpointStorageError: (cause) => new GitLabCliCommandError({ ...context, cause }), VcsUnsupportedOperationError: (cause) => new GitLabCliCommandError({ ...context, cause }), }); } diff --git a/apps/server/src/vcs/GitVcsDriver.test.ts b/apps/server/src/vcs/GitVcsDriver.test.ts index 031055a3b6cb..8efc1447d4e1 100644 --- a/apps/server/src/vcs/GitVcsDriver.test.ts +++ b/apps/server/src/vcs/GitVcsDriver.test.ts @@ -1,5 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; @@ -7,7 +9,13 @@ 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, + VcsProcessExitError, + VcsProcessTimeoutError, +} from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as ServerConfig from "../config.ts"; import * as GitVcsDriver from "./GitVcsDriver.ts"; import * as VcsProcess from "./VcsProcess.ts"; @@ -65,6 +73,127 @@ 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* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + const originalRef = (yield* git(["rev-parse", checkpointRef])).stdout; + yield* fileSystem.writeFileString(path.join(cwd, "file.txt"), "staged\n"); + yield* git(["add", "."]); + const originalIndex = yield* fileSystem.readFile(path.join(cwd, ".git", "index")); + yield* fileSystem.writeFileString(path.join(cwd, "file.txt"), "unstaged\n"); + return { git, checkpointRef, originalRef, originalIndex }; +}); + +for (const outcome of ["failure", "timeout", "interruption"] as const) { + it.effect(`checkpoint ${outcome} cleans only its own temporary files`, () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const liveProcess = yield* VcsProcess.VcsProcess; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-cleanup-" }); + const concurrentPack = path.join(cwd, ".git", "objects", "pack", "tmp_pack_concurrent"); + const addStarted = yield* Deferred.make(); + let injectFailure = false; + let captureDirectory: string | undefined; + let partialPack: string | undefined; + const driver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(VcsProcess.VcsProcess, { + run: Effect.fn(function* (input: VcsProcess.VcsProcessInput) { + if (!injectFailure || !input.args.includes("add")) { + return yield* liveProcess.run(input); + } + const objectDirectory = + input.env?.GIT_OBJECT_DIRECTORY ?? path.join(cwd, ".git", "objects"); + const indexPath = input.env?.GIT_INDEX_FILE; + assert.ok(indexPath); + captureDirectory = path.dirname(indexPath); + yield* liveProcess.run(input); + const packDirectory = path.join(objectDirectory, "pack"); + yield* fileSystem.makeDirectory(packDirectory, { recursive: true }).pipe(Effect.orDie); + partialPack = path.join(packDirectory, "tmp_pack_partial"); + yield* fileSystem.writeFileString(partialPack, "partial").pipe(Effect.orDie); + yield* fileSystem + .writeFileString(concurrentPack, "another operation started during capture") + .pipe(Effect.orDie); + yield* fileSystem + .writeFileString(`${indexPath}.lock`, "partial index") + .pipe(Effect.orDie); + yield* Deferred.succeed(addStarted, undefined); + if (outcome === "interruption") { + return yield* Effect.never; + } + if (outcome === "timeout") { + return yield* new VcsProcessTimeoutError({ + operation: input.operation, + command: "git", + cwd, + timeoutMs: 30_000, + }); + } + return yield* new VcsProcessExitError({ + operation: input.operation, + command: "git", + cwd, + exitCode: 1, + detail: "Injected failure after writing checkpoint objects.", + }); + }), + }), + ); + const { git, checkpointRef, originalRef, originalIndex } = yield* makeCheckpointFixture( + driver, + cwd, + ); + const unrelatedPack = path.join(cwd, ".git", "objects", "pack", "tmp_pack_unrelated"); + yield* fileSystem.writeFileString(unrelatedPack, "another operation"); + injectFailure = true; + const capture = driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + if (outcome === "interruption") { + const fiber = yield* capture.pipe(Effect.forkChild); + yield* Deferred.await(addStarted); + yield* Fiber.interrupt(fiber); + } else { + const error = yield* capture.pipe(Effect.flip); + assert.strictEqual( + error._tag, + outcome === "timeout" ? "VcsProcessTimeoutError" : "VcsProcessExitError", + ); + } + assert.ok(partialPack); + assert.isFalse(yield* fileSystem.exists(partialPack)); + assert.ok(captureDirectory); + assert.isFalse(yield* fileSystem.exists(captureDirectory)); + assert.strictEqual(yield* fileSystem.readFileString(unrelatedPack), "another operation"); + assert.strictEqual( + yield* fileSystem.readFileString(concurrentPack), + "another operation started during capture", + ); + assert.strictEqual((yield* git(["rev-parse", checkpointRef])).stdout, originalRef); + assert.deepStrictEqual( + yield* fileSystem.readFile(path.join(cwd, ".git", "index")), + originalIndex, + ); + assert.strictEqual( + yield* fileSystem.readFileString(path.join(cwd, "file.txt")), + "unstaged\n", + ); + }).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; @@ -112,3 +241,160 @@ it.effect("GitVcsDriver forwards execute env to the VCS process", () => { ), ); }); + +for (const destinationExists of [false, true]) { + it.effect( + `checkpoint publication handles rename failure when destination exists: ${destinationExists}`, + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-publish-" }); + const publicationFailure = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "rename", + description: "Injected checkpoint object publication failure.", + }); + let injectFailure = false; + let captureDirectory: string | undefined; + const driver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fileSystem, + rename: Effect.fn(function* (source: string, destination: string) { + if (injectFailure && !(yield* fileSystem.exists(destination))) { + captureDirectory = path.dirname(path.dirname(path.dirname(source))); + if (destinationExists) { + yield* fileSystem.copyFile(source, destination); + } + return yield* publicationFailure; + } + return yield* fileSystem.rename(source, destination); + }), + }), + ); + const { git, checkpointRef, originalRef, originalIndex } = yield* makeCheckpointFixture( + driver, + cwd, + ); + injectFailure = true; + + const capture = driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + if (destinationExists) { + yield* capture; + assert.notStrictEqual((yield* git(["rev-parse", checkpointRef])).stdout, originalRef); + assert.strictEqual( + (yield* git(["show", `${checkpointRef}:file.txt`])).stdout, + "unstaged\n", + ); + yield* git(["fsck", "--no-dangling", checkpointRef]); + } else { + const error = yield* capture.pipe(Effect.flip); + assert.strictEqual(error._tag, "VcsCheckpointStorageError"); + if (error._tag === "VcsCheckpointStorageError") { + assert.strictEqual(error.cause, publicationFailure); + } + assert.strictEqual((yield* git(["rev-parse", checkpointRef])).stdout, originalRef); + } + assert.ok(captureDirectory); + assert.isFalse(yield* fileSystem.exists(captureDirectory)); + assert.deepStrictEqual( + yield* fileSystem.readFile(path.join(cwd, ".git", "index")), + originalIndex, + ); + assert.strictEqual( + yield* fileSystem.readFileString(path.join(cwd, "file.txt")), + "unstaged\n", + ); + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), + ); +} + +it.effect("checkpoint publication preserves shared repository directory permissions", () => + Effect.gen(function* () { + if ((yield* HostProcessPlatform) === "win32") return; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-shared-" }); + let objectPath: string | undefined; + let sourceMode: number | undefined; + const driver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fileSystem, + rename: Effect.fn(function* (source: string, destination: string) { + if (destination === objectPath) { + sourceMode = (yield* fileSystem.stat(path.dirname(source))).mode; + } + return yield* fileSystem.rename(source, destination); + }), + }), + ); + const git = (args: ReadonlyArray) => + driver.execute({ operation: "checkpoint-shared-test", cwd, args }); + yield* git(["init"]); + yield* fileSystem.writeFileString(path.join(cwd, "file.txt"), "unstaged\n"); + const checkpointRef = CheckpointRef.make("refs/t3/checkpoints/shared"); + yield* git(["config", "core.sharedRepository", "group"]); + const objectId = (yield* git(["hash-object", "file.txt"])).stdout.trim(); + const objectDirectory = path.join(cwd, ".git", "objects", objectId.slice(0, 2)); + objectPath = path.join(objectDirectory, objectId.slice(2)); + assert.isFalse(yield* fileSystem.exists(objectDirectory)); + + yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }); + + assert.ok(sourceMode); + const publishedMode = (yield* fileSystem.stat(objectDirectory)).mode; + assert.strictEqual(publishedMode & 0o7777, sourceMode & 0o7777); + assert.strictEqual(publishedMode & 0o020, 0o020); + assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "unstaged\n"); + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), +); + +it.effect("checkpoint publication finishes before honoring interruption", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-checkpoint-interrupt-" }); + const publishStarted = yield* Deferred.make(); + const publishAllowed = yield* Deferred.make(); + let interruptPublication = false; + let captureDirectory: string | undefined; + const driver = yield* GitVcsDriver.makeVcsDriverShape().pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fileSystem, + rename: Effect.fn(function* (source: string, destination: string) { + if (interruptPublication) { + interruptPublication = false; + captureDirectory = path.dirname(path.dirname(path.dirname(source))); + yield* Deferred.succeed(publishStarted, undefined); + yield* Deferred.await(publishAllowed); + } + return yield* fileSystem.rename(source, destination); + }), + }), + ); + const { git, checkpointRef, originalRef, originalIndex } = yield* makeCheckpointFixture( + driver, + cwd, + ); + interruptPublication = true; + const fiber = yield* driver.checkpoints + .captureCheckpoint({ cwd, checkpointRef }) + .pipe(Effect.forkChild); + yield* Deferred.await(publishStarted); + assert.strictEqual((yield* git(["rev-parse", checkpointRef])).stdout, originalRef); + fiber.interruptUnsafe(); + yield* Deferred.succeed(publishAllowed, undefined); + yield* Fiber.await(fiber); + + assert.notStrictEqual((yield* git(["rev-parse", checkpointRef])).stdout, originalRef); + assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "unstaged\n"); + yield* git(["fsck", "--no-dangling", checkpointRef]); + assert.ok(captureDirectory); + assert.isFalse(yield* fileSystem.exists(captureDirectory)); + assert.deepStrictEqual( + yield* fileSystem.readFile(path.join(cwd, ".git", "index")), + originalIndex, + ); + }).pipe(Effect.scoped, Effect.provide(GitContractLayer)), +); diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index f1e48a24d6fe..4e2325ac2459 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -1,5 +1,3 @@ -import * as NodeCrypto from "node:crypto"; - import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -12,6 +10,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError, VcsProcessExitError, + VcsCheckpointStorageError, type VcsSwitchRefInput, type VcsSwitchRefResult, type VcsCreateRefInput, @@ -700,39 +699,38 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( }), ); - const resolveGitCommonDir = (cwd: string) => - Effect.gen(function* () { - const result = yield* execute({ - operation: "GitVcsDriver.checkpoints.resolveGitCommonDir", - cwd, - args: ["rev-parse", "--git-common-dir"], - }); - const gitCommonDir = result.stdout.trim(); - return path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(cwd, gitCommonDir); - }); - const checkpoints: VcsDriver.VcsCheckpointOps = { - captureCheckpoint: Effect.fn("GitVcsDriver.checkpoints.captureCheckpoint")(function* (input) { - const operation = "GitVcsDriver.checkpoints.captureCheckpoint"; - const gitCommonDir = yield* resolveGitCommonDir(input.cwd); - const tempIndexPath = path.join( - gitCommonDir, - `t3-checkpoint-index-${NodeCrypto.randomUUID()}`, - ); - const commitEnv: NodeJS.ProcessEnv = { - ...process.env, - GIT_INDEX_FILE: tempIndexPath, - GIT_AUTHOR_NAME: "T3 Code", - GIT_AUTHOR_EMAIL: "t3code@users.noreply.github.com", - GIT_COMMITTER_NAME: "T3 Code", - GIT_COMMITTER_EMAIL: "t3code@users.noreply.github.com", - }; - - const cleanupTempIndex = fileSystem - .remove(tempIndexPath, { force: true }) - .pipe(Effect.ignore); + captureCheckpoint: Effect.fn("GitVcsDriver.checkpoints.captureCheckpoint")( + function* (input) { + const operation = "GitVcsDriver.checkpoints.captureCheckpoint"; + const objectDirResult = yield* execute({ + operation, + cwd: input.cwd, + args: ["rev-parse", "--path-format=absolute", "--git-path", "objects"], + }); + const objectDir = objectDirResult.stdout.trim(); + // Git prune recognizes this prefix when reclaiming stale crash leftovers. + const tempDir = yield* fileSystem.makeTempDirectoryScoped({ + directory: objectDir, + prefix: "tmp_objdir-t3-checkpoint-", + }); + const tempObjectDir = path.join(tempDir, "objects"); + yield* fileSystem.makeDirectory(path.join(tempObjectDir, "info"), { recursive: true }); + // Alternate paths are relative to this object directory, not the working tree. + yield* fileSystem.writeFileString( + path.join(tempObjectDir, "info", "alternates"), + "../..\n", + ); + const commitEnv: NodeJS.ProcessEnv = { + ...process.env, + GIT_INDEX_FILE: path.join(tempDir, "index"), + GIT_OBJECT_DIRECTORY: tempObjectDir, + GIT_AUTHOR_NAME: "T3 Code", + GIT_AUTHOR_EMAIL: "t3code@users.noreply.github.com", + GIT_COMMITTER_NAME: "T3 Code", + GIT_COMMITTER_EMAIL: "t3code@users.noreply.github.com", + }; - yield* Effect.gen(function* () { const headExists = yield* hasHeadCommit(input.cwd); if (headExists) { yield* execute({ @@ -785,13 +783,64 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( }); } - yield* execute({ - operation, - cwd: input.cwd, - args: ["update-ref", input.checkpointRef, commitOid], - }); - }).pipe(Effect.ensuring(cleanupTempIndex)); - }), + yield* Effect.gen(function* () { + // Finish publishing objects and the ref before honoring an interruption. + yield* Effect.forEach( + yield* fileSystem.readDirectory(tempObjectDir), + Effect.fn(function* (directory: string) { + if (directory !== "pack" && !/^[0-9a-f]{2}$/.test(directory)) return; + const sourceDir = path.join(tempObjectDir, directory); + const targetDir = path.join(objectDir, directory); + // Keep Git's shared-repository permissions on newly published directories. + yield* fileSystem.makeDirectory(targetDir).pipe( + Effect.andThen(fileSystem.stat(sourceDir)), + Effect.flatMap((info) => fileSystem.chmod(targetDir, info.mode)), + Effect.catch((error) => + error.reason._tag === "AlreadyExists" ? Effect.void : Effect.fail(error), + ), + ); + const names = yield* fileSystem.readDirectory(sourceDir); + // Git discovers packs through their indexes, so publish each index last. + names.sort((a, b) => Number(a.endsWith(".idx")) - Number(b.endsWith(".idx"))); + for (const name of names) { + if (name.startsWith("tmp_")) continue; + const destination = path.join(targetDir, name); + yield* fileSystem + .rename(path.join(sourceDir, name), destination) + .pipe( + Effect.catch((error) => + fileSystem + .exists(destination) + .pipe( + Effect.flatMap((exists) => (exists ? Effect.void : Effect.fail(error))), + ), + ), + ); + } + }), + { concurrency: 4, discard: true }, + ); + yield* execute({ + operation, + cwd: input.cwd, + args: ["update-ref", input.checkpointRef, commitOid], + }); + }).pipe(Effect.uninterruptible); + }, + (effect, input) => + effect.pipe( + Effect.scoped, + Effect.catchTag( + "PlatformError", + (cause) => + new VcsCheckpointStorageError({ + operation: "GitVcsDriver.checkpoints.captureCheckpoint", + cwd: input.cwd, + cause, + }), + ), + ), + ), hasCheckpointRef: (input) => resolveCheckpointCommit(input.cwd, input.checkpointRef).pipe( diff --git a/packages/contracts/src/vcs.ts b/packages/contracts/src/vcs.ts index a0956e83bd5b..a90df702e160 100644 --- a/packages/contracts/src/vcs.ts +++ b/packages/contracts/src/vcs.ts @@ -269,7 +269,21 @@ export class VcsUnsupportedOperationError extends Schema.TaggedErrorClass()( + "VcsCheckpointStorageError", + { + operation: Schema.String, + cwd: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Could not store checkpoint objects in ${this.operation}: ${this.cwd}`; + } +} + export const VcsError = Schema.Union([ + VcsCheckpointStorageError, VcsProcessSpawnError, VcsProcessExitError, VcsProcessTimeoutError,