Skip to content
Open
111 changes: 104 additions & 7 deletions apps/server/src/vcs/GitVcsDriver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@ import { ChildProcessSpawner } from "effect/process";
import * as Metric from "effect/Metric";
import { assert, it } from "@effect/vitest";

import { CheckpointRef, GitCommandError, VcsProcessExitError } from "@t3tools/contracts";
import {
CheckpointRef,
GitCommandError,
VcsProcessExitError,
VcsProcessTimeoutError,
} from "@t3tools/contracts";
import * as HostProcess from "@t3tools/shared/HostProcess";
import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
import * as ServerConfig from "../config.ts";
Expand Down Expand Up @@ -276,16 +281,30 @@ it.effect("checkpoint recovery discovers nested HEAD independently of inherited
const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-git-dir-" });
const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd);
yield* git(["init", "empty"]);
yield* git(["config", "core.sharedRepository", "group"]);
const originalIndex = yield* fs.readFile(path.join(cwd, ".git", "index"));
yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }).pipe(
Effect.provideService(HostProcess.Environment, {
...(yield* HostProcess.Environment),
GIT_DIR: path.join(cwd, ".git"),
}),
yield* fs.makeDirectory(path.join(cwd, "inherited-objects"));
const hostEnv = yield* HostProcess.Environment;
yield* Effect.acquireUseRelease(
Effect.sync(() => process.umask(0o022)),
() =>
driver.checkpoints.captureCheckpoint({ cwd, checkpointRef }).pipe(
Effect.provideService(HostProcess.Environment, {
...hostEnv,
GIT_DIR: path.join(cwd, ".git"),
GIT_OBJECT_DIRECTORY: path.join(cwd, "inherited-objects"),
}),
),
(umask) => Effect.sync(() => process.umask(umask)),
);
assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "unstaged\n");
assert.strictEqual((yield* git(["ls-tree", "-r", checkpointRef, "--", "empty"])).stdout, "");
assert.deepEqual(yield* fs.readFile(path.join(cwd, ".git", "index")), originalIndex);
if ((yield* HostProcess.Platform) !== "win32") {
const blob = (yield* git(["rev-parse", `${checkpointRef}:file.txt`])).stdout.trim();
const fanout = yield* fs.stat(path.join(cwd, ".git", "objects", blob.slice(0, 2)));
assert.strictEqual(fanout.mode & 0o020, 0o020);
}
}).pipe(Effect.scoped, Effect.provide(layerGitContract)),
);

Expand Down Expand Up @@ -366,6 +385,81 @@ it.effect("checkpoint capture refuses a truncated nested repository listing", ()
}).pipe(Effect.scoped, Effect.provide(layerGitContract)),
);

it.effect("checkpoint capture removes the batch object directory a killed add leaves", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const liveProcess = yield* VcsProcess.VcsProcess;
const driver = yield* GitVcsDriver.makeVcsDriverShape();
const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-killed-add-" });
const { checkpointRef } = yield* makeCheckpointFixture(driver, cwd);
const gitDir = path.join(cwd, ".git");
const objects = path.join(gitDir, "objects");
yield* fs.makeDirectory(path.join(objects, "tmp_objdir-bulk-fsync-other"));
const captureDriver = yield* GitVcsDriver.makeVcsDriverShape().pipe(
Effect.provideService(VcsProcess.VcsProcess, {
run: (input) =>
input.args.includes("add")
? fs
.makeDirectory(
path.join(
input.env?.GIT_OBJECT_DIRECTORY ?? objects,
"tmp_objdir-bulk-fsync-killed",
"ab",
),
{ recursive: true },
)
.pipe(
Effect.orDie,
Effect.andThen(
Effect.fail(
new VcsProcessTimeoutError({
operation: input.operation,
command: input.command,
cwd: input.cwd,
timeoutMs: 30_000,
}),
),
),
)
: liveProcess.run(input),
}),
);

const result = yield* Effect.result(
captureDriver.checkpoints.captureCheckpoint({ cwd, checkpointRef }),
);

assert.strictEqual(result._tag, "Failure");
assert.deepEqual(
(yield* fs.readDirectory(objects)).filter(
(entry) => entry.startsWith("tmp_objdir-") || entry.startsWith("t3-checkpoint-"),
),
["tmp_objdir-bulk-fsync-other"],
);
assert.deepEqual(
(yield* fs.readDirectory(gitDir)).filter((entry) => entry.startsWith("t3-checkpoint-")),
[],
);
}).pipe(Effect.scoped, Effect.provide(layerGitContract)),
);

it.effect("checkpoint capture publishes the pack Git writes for a large file", () =>
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-large-file-" });
const { git, checkpointRef } = yield* makeCheckpointFixture(driver, cwd);
yield* git(["config", "core.bigFileThreshold", "1"]);

yield* driver.checkpoints.captureCheckpoint({ cwd, checkpointRef });

assert.strictEqual((yield* git(["show", `${checkpointRef}:file.txt`])).stdout, "unstaged\n");
assert.isNotEmpty(yield* fs.readDirectory(path.join(cwd, ".git", "objects", "pack")));
}).pipe(Effect.scoped, Effect.provide(layerGitContract)),
);

it.effect("checkpoint capture skips an ignored project while keeping its allowed sibling", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
Expand Down Expand Up @@ -621,7 +715,10 @@ it.effect.each(
},
}),
);
const cwd = yield* fs.makeTempDirectoryScoped({ prefix: "t3-checkpoint-sparse-" });
const hostPlatform = yield* HostProcess.Platform;
const cwd = yield* fs.makeTempDirectoryScoped({
prefix: `t3-checkpoint-sparse-${hostPlatform === "win32" ? ";" : ":"}`,
});
const { git } = yield* makeCheckpointFixture(driver, cwd);
const write = Effect.fn(function* (name: string, contents: string) {
yield* fs.makeDirectory(path.dirname(path.join(cwd, name)), { recursive: true });
Expand Down
95 changes: 89 additions & 6 deletions apps/server/src/vcs/GitVcsDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import type * as PlatformError from "effect/PlatformError";
import { ChildProcessSpawner } from "effect/process";

import {
Expand Down Expand Up @@ -37,6 +38,7 @@ import {
} from "@t3tools/contracts";
import { isMissingExecutable } from "@t3tools/source-control-core/server/discovery";
import * as HostProcess from "@t3tools/shared/HostProcess";
import { quoteGitPatchPath } from "@t3tools/shared/gitPatchPath";
import {
makeGitVcsDriverCore,
PATCH_RENDER_PREFIX_ARGS,
Expand Down Expand Up @@ -896,6 +898,66 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
// core.longpaths is disabled in the user's Git configuration.
const longPaths = hostPlatform === "win32" ? ["-c", "core.longpaths=true"] : [];

const checkpointObjectsError = (cwd: string) =>
Effect.mapError(
(cause: PlatformError.PlatformError) =>
new VcsProcessExitError({
operation: VcsProcess.CHECKPOINT_CAPTURE_OPERATION,
command: "git add",
cwd,
exitCode: 0,
detail: "Could not stage checkpoint objects.",
cause,
}),
);

// Git's own batch migration is also a rename; the durable update-ref flushes these renames.
const publishStagedObjects = Effect.fn("GitVcsDriver.publishStagedObjects")(
function* (cwd: string, from: string, to: string) {
// Per-directory moves share the enclosing publication span.
const move = Effect.fnUntraced(function* (directory: string, names: ReadonlyArray<string>) {
// Keep Git's shared-repository permissions on newly published directories.
const moved = yield* fileSystem
.rename(path.join(from, directory), path.join(to, directory))
.pipe(
Effect.as(true),
Effect.catch((error) =>
fileSystem
.exists(path.join(to, directory))
.pipe(
Effect.flatMap((exists) => (exists ? Effect.succeed(false) : Effect.fail(error))),
),
),
);
if (moved) return;
for (const name of names) {
const target = path.join(to, directory, name);
// Another writer may have published the same read-only object, which Windows won't replace.
yield* fileSystem
.rename(path.join(from, directory, name), target)
.pipe(
Effect.catch((error) =>
fileSystem
.exists(target)
.pipe(Effect.flatMap((exists) => (exists ? Effect.void : Effect.fail(error)))),
),
);
}
});
for (const entry of yield* fileSystem.readDirectory(from)) {
if (/^[0-9a-f]{2}$/.test(entry)) {
yield* move(entry, yield* fileSystem.readDirectory(path.join(from, entry)));
} else if (entry === "pack") {
// Large blobs arrive as a pack, which Git reads only once its .idx exists.
const names = yield* fileSystem.readDirectory(path.join(from, entry));
const isIndex = (name: string) => name.endsWith(".idx");
yield* move(entry, [...names.filter((n) => !isIndex(n)), ...names.filter(isIndex)]);
}
}
},
(effect, cwd) => effect.pipe(checkpointObjectsError(cwd)),
);

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const checkpoints: VcsDriver.VcsCheckpointOps = {
captureCheckpoint: Effect.fn("GitVcsDriver.checkpoints.captureCheckpoint")(function* (input) {
const operation = VcsProcess.CHECKPOINT_CAPTURE_OPERATION;
Expand All @@ -913,19 +975,37 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
const { gitCommonDir, indexPath, headExists } = yield* resolveCaptureRepository(input.cwd);
const indexId = yield* random.uuidV4();
const tempIndexPath = path.join(gitCommonDir, `t3-checkpoint-index-${indexId}`);
const objectsDir = path.join(gitCommonDir, "objects");
const hostEnv = yield* HostProcess.Environment;
const commitEnv: NodeJS.ProcessEnv = {
...hostEnv,
GIT_INDEX_FILE: tempIndexPath,
GIT_OBJECT_DIRECTORY: objectsDir,
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 = Effect.forEach(
[tempIndexPath, `${tempIndexPath}.lock`],
(indexFile) => fileSystem.remove(indexFile, { force: true }).pipe(Effect.ignore),
// Batch fsync stages objects in a tmp_objdir-bulk-fsync-* that a killed Git leaves behind.
// Staging into a private object directory makes that leftover ours to delete.
const stagingObjectsDir = path.join(objectsDir, `t3-checkpoint-objects-${indexId}`);
const quotedObjectsDir = quoteGitPatchPath(objectsDir);
const stageEnv: NodeJS.ProcessEnv = {
...commitEnv,
GIT_OBJECT_DIRECTORY: stagingObjectsDir,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
GIT_ALTERNATE_OBJECT_DIRECTORIES: [
quotedObjectsDir.startsWith('"') ? quotedObjectsDir : `"${quotedObjectsDir}"`,
hostEnv.GIT_ALTERNATE_OBJECT_DIRECTORIES,
]
.filter(Boolean)
.join(hostPlatform === "win32" ? ";" : ":"),
};

const cleanupScratch = Effect.forEach(
[tempIndexPath, `${tempIndexPath}.lock`, stagingObjectsDir],
(scratch) =>
fileSystem.remove(scratch, { force: true, recursive: true }).pipe(Effect.ignore),
{ discard: true },
);

Expand Down Expand Up @@ -1091,7 +1171,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
});
}
}
yield* cleanupTempIndex;
yield* cleanupScratch;
yield* execute({
operation,
cwd: input.cwd,
Expand Down Expand Up @@ -1180,8 +1260,9 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
".",
...exclusions,
],
env: commitEnv,
env: stageEnv,
});
yield* fileSystem.makeDirectory(stagingObjectsDir).pipe(checkpointObjectsError(input.cwd));
yield* stageFiles([]).pipe(
Effect.catchTags({
VcsProcessExitError: (error) =>
Expand Down Expand Up @@ -1238,6 +1319,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
),
}),
);
yield* publishStagedObjects(input.cwd, stagingObjectsDir, objectsDir);

const writeTreeResult = yield* execute({
operation,
Expand Down Expand Up @@ -1286,8 +1368,9 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
operation,
cwd: input.cwd,
args: [...durableWrite, "update-ref", input.checkpointRef, commitOid],
env: commitEnv,
});
}).pipe(Effect.ensuring(cleanupTempIndex));
}).pipe(Effect.ensuring(cleanupScratch));
}),

hasCheckpointRef: (input) =>
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/vcs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export class VcsProcessExitError extends Schema.TaggedError<VcsProcessExitError>
retryable: Schema.optional(Schema.Boolean),
stderrLength: Schema.optional(NonNegativeInt),
stderrTruncated: Schema.optional(Schema.Boolean),
cause: Schema.optional(Schema.Defect()),
},
) {
override get message(): string {
Expand Down
Loading