Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions apps/server/src/vcs/GitVcsDriverCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as PlatformError from "effect/PlatformError";
import * as Ref from "effect/Ref";
Expand Down Expand Up @@ -541,6 +542,51 @@ it.effect("marks the current branch when worktree metadata is unavailable", () =
).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))),
);

it.effect("still creates the worktree when the index refresh fails", () =>
Effect.scoped(
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const pathService = yield* Path.Path;
const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;
let refreshAttempts = 0;
const failingRefreshSpawner = ChildProcessSpawner.make((command) =>
Effect.gen(function* () {
if (!ChildProcess.isStandardCommand(command)) {
return yield* Effect.die("expected a standard Git command");
}
if (command.args.includes("update-index")) {
refreshAttempts++;
return makeNonRepositoryHandle();
}
return yield* delegate.spawn(command);
}),
);
const driver = yield* makeGitVcsDriverCore().pipe(
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, failingRefreshSpawner),
);
const cwd = yield* makeTmpDir();
const { initialBranch } = yield* initRepoWithCommit(cwd).pipe(
Effect.provideService(GitVcsDriver.GitVcsDriver, driver),
);
const worktreePath = pathService.join(
yield* makeTmpDir("git-worktrees-"),
"refresh-fails-worktree",
);

const created = yield* driver.createWorktree({
cwd,
path: worktreePath,
refName: initialBranch,
newRefName: "feature/refresh-fails",
});

assert.equal(refreshAttempts, 1);
assert.equal(created.worktree.path, worktreePath);
assert.isTrue(yield* fileSystem.exists(pathService.join(worktreePath, "README.md")));
}),
).pipe(Effect.provide(layerServerConfig.pipe(Layer.provideMerge(NodeServices.layer)))),
);

it.effect("ignores worktree metadata for directories that no longer exist", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down Expand Up @@ -3083,6 +3129,50 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => {
}),
);

it.effect("leaves no racily clean entries in a new worktree's index", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const pathService = yield* Path.Path;
const cwd = yield* makeTmpDir();
const { initialBranch } = yield* initRepoWithCommit(cwd);
for (let index = 0; index < 20; index++) {
yield* writeTextFile(cwd, `src/file-${index}.txt`, `file ${index}\n`);
}
yield* git(cwd, ["add", "."]);
yield* git(cwd, ["commit", "-m", "add files"]);

const worktreePath = pathService.join(
yield* makeTmpDir("git-worktrees-"),
"fresh-worktree",
);
const driver = yield* GitVcsDriver.GitVcsDriver;
yield* driver.createWorktree({
cwd,
path: worktreePath,
refName: initialBranch,
newRefName: "feature/racy-index",
});

// Git re-hashes every entry whose mtime second is not before the index file's own second.
const indexPath = pathService.resolve(
worktreePath,
yield* git(worktreePath, ["rev-parse", "--git-path", "index"]),
);
const { mtime } = yield* fileSystem.stat(indexPath);
const indexSecond = Math.floor(Option.getOrThrow(mtime).getTime() / 1000);
const entries = yield* git(worktreePath, ["ls-files", "--debug"]);
const entrySeconds = Array.from(entries.matchAll(/^\s+mtime: (\d+):/gm), (match) =>
Number(match[1]),
);

assert.equal(entrySeconds.length, 21);
assert.deepEqual(
entrySeconds.filter((second) => second >= indexSecond),
[],
);
}),
);

it.effect("creates worktrees under the configured worktrees directory", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand Down
31 changes: 31 additions & 0 deletions apps/server/src/vcs/GitVcsDriverCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ const DEFAULT_TIMEOUT_MS = 30_000;
const gitProcesses = Semaphore.makeUnsafe(8);
const WORKTREE_ADD_TIMEOUT_MS = 300_000;
const WORKTREE_REMOVE_TIMEOUT_MS = Duration.toMillis(Duration.minutes(5));
// Covers file timestamps that lag the wall clock by a kernel tick.
const INDEX_SECOND_MARGIN_MS = 25;
const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]";
const PREPARED_COMMIT_PATCH_MAX_OUTPUT_BYTES = 49_000;
Expand Down Expand Up @@ -3683,6 +3685,33 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
},
);

// Git only trusts an index entry's cached stat when the file is older than the index file's own
// second, and `worktree add` writes the index last, so the files it wrote in that second stay
// racily clean: every status, diff and checkpoint `add` re-hashes them. Nothing T3 runs writes a
// worktree's index (status polling and capture deliberately never do), so refresh it once here,
// after that second has passed. Index mtimes are wall-clock time, hence the live clock.
const refreshWorktreeIndex = Effect.fn("refreshWorktreeIndex")(function* (worktreePath: string) {
const indexValue = yield* runGitStdout("GitVcsDriver.createWorktree.indexPath", worktreePath, [
"rev-parse",
"--git-path",
"index",
]);
const { mtime } = yield* fileSystem.stat(path.resolve(worktreePath, indexValue.trim()));
if (Option.isSome(mtime)) {
const settledAt =
(Math.floor(mtime.value.getTime() / 1000) + 1) * 1000 + INDEX_SECOND_MARGIN_MS;
yield* Clock.currentTimeMillis.pipe(
Effect.flatMap((now) => (now < settledAt ? Effect.sleep(settledAt - now) : Effect.void)),
Effect.provideService(Clock.Clock, Clock.Clock.defaultValue()),
);
}
yield* runGit("GitVcsDriver.createWorktree.refreshIndex", worktreePath, [
"update-index",
"-q",
"--refresh",
]);
});

const createWorktree: GitVcsDriver.GitVcsDriver["Service"]["createWorktree"] = Effect.fn(
"createWorktree",
)(function* (input, options) {
Expand Down Expand Up @@ -3840,6 +3869,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
}
}

yield* refreshWorktreeIndex(worktreePath).pipe(Effect.ignore({ log: "Warn" }));

return {
worktree: {
path: worktreePath,
Expand Down
Loading