Skip to content
74 changes: 74 additions & 0 deletions apps/server/src/vcs/GitVcsDriverCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import * as Exit from "effect/Exit";
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 Queue from "effect/Queue";
import * as PlatformError from "effect/PlatformError";
Expand Down Expand Up @@ -4678,6 +4679,79 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => {
assert.isTrue(Result.isSuccess(result), "worktree creation failed in the hook");
}),
);
it.effect("refreshes a new worktree's index once its checkout second is over", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
const { initialBranch } = yield* initRepoWithCommit(cwd);
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const driver = yield* GitVcsDriver.GitVcsDriver;
for (let file = 0; file < 50; file++) {
yield* writeTextFile(cwd, `src/file-${file}.txt`, `contents ${file}\n`);
}
yield* git(cwd, ["add", "."]);
yield* git(cwd, ["commit", "-m", "more files"]);
const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "fresh");

// The refresh waits on the clock that stamps the files.
yield* TestClock.withLive(
driver.createWorktree({
cwd,
path: worktreePath,
refName: initialBranch,
newRefName: "feature/fresh",
}),
);

// Git re-reads every entry stamped in the index's own second or later.
const indexPath = path.resolve(
worktreePath,
yield* git(worktreePath, ["rev-parse", "--git-path", "index"]),
);
const indexMtime = Option.getOrThrow((yield* fs.stat(indexPath)).mtime);
const indexSecond = Math.floor(indexMtime.getTime() / 1000);
const entrySeconds = Array.from(
(yield* git(worktreePath, ["ls-files", "--debug"])).matchAll(/^ {2}mtime: (\d+):/gm),
([, seconds]) => Number(seconds),
);
assert.equal(entrySeconds.length, 51);
assert.deepEqual(
entrySeconds.filter((seconds) => seconds >= indexSecond),
[],
);
}),
);
it.effect("returns the worktree when its index refresh fails", () =>
Effect.gen(function* () {
const cwd = yield* makeTmpDir();
const { initialBranch } = yield* initRepoWithCommit(cwd);
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const driver = yield* GitVcsDriver.GitVcsDriver;
// Something else takes the new worktree's index lock as soon as its checkout ends.
yield* writeTextFile(
cwd,
".git/hooks/post-checkout",
'#!/bin/sh\ntouch "$(git rev-parse --git-path index).lock"\n',
);
yield* fs.chmod(path.join(cwd, ".git/hooks/post-checkout"), 0o755);
const worktreePath = path.join(yield* makeTmpDir("git-worktrees-"), "locked");

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

assert.equal(created.worktree.path, worktreePath);
const indexPath = path.resolve(
worktreePath,
yield* git(worktreePath, ["rev-parse", "--git-path", "index"]),
);
assert.isTrue(yield* fs.exists(`${indexPath}.lock`));
}),
);
it.effect.each(["commit", "short-commit", "tag", "revision"] as const)(
"creates a worktree from a %s without recording it as a PR base",
(kind) =>
Expand Down
58 changes: 58 additions & 0 deletions apps/server/src/vcs/GitVcsDriverCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ const branchBaseConfigKey = (branch: string) => `branch.${branch}.t3-merge-base`
const legacyBranchBaseConfigKey = (branch: string) => `branch.${branch}.gh-merge-base`;
const gitProcesses = Semaphore.makeUnsafe(8);
const backgroundGitProcesses = Semaphore.makeUnsafe(2);
const WORKTREE_INDEX_REFRESH_TIMEOUT_MS = Duration.toMillis(Duration.minutes(5));
const WORKTREE_REMOVE_TIMEOUT_MS = Duration.toMillis(Duration.minutes(5));
const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
const OUTPUT_TRUNCATED_MARKER = "\n\n[truncated]";
Expand Down Expand Up @@ -4247,6 +4248,52 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
},
);

// A checkout stamps most files in the second it writes the index, and Git re-reads every file
// stamped in the index's own second or later ("racily clean") on each status, diff and add.
// T3's own git calls never rewrite a worktree's index, so refresh it once.
const refreshNewWorktreeIndex = Effect.fn("refreshNewWorktreeIndex")(function* (
worktreePath: string,
) {
const indexPath = path.resolve(
worktreePath,
(yield* runGitStdout("GitVcsDriver.createWorktree.indexPath", worktreePath, [
"rev-parse",
"--git-path",
"index",
])).trim(),
);
const indexSecond = fileSystem.stat(indexPath).pipe(
Effect.map(({ mtime }) => Option.map(mtime, (date) => Math.floor(date.getTime() / 1000))),
Effect.orElseSucceed(() => Option.none<number>()),
);
// Older Git (such as 2.30) skips the write when racy entries merely check out clean.
// A refresh re-reads every checked-out file, so allow five minutes outside shared permits.
const refresh = runGit(
"GitVcsDriver.createWorktree.refreshIndex",
worktreePath,
["update-index", "-q", "--refresh", "--force-write-index"],
{ timeoutMs: WORKTREE_INDEX_REFRESH_TIMEOUT_MS },
);
const checkoutSecond = yield* indexSecond;
yield* refresh;
const refreshedSecond = yield* indexSecond;
if (
Option.isNone(checkoutSecond) ||
Option.isNone(refreshedSecond) ||
refreshedSecond.value > checkoutSecond.value
) {
return;
}
// The refresh finished within the checkout's second, so its index is just as racy. Wait that
// second out (file timestamps can trail the clock by a kernel tick) and refresh again. Two
// seconds or more means this clock does not match the file timestamps, as on a share stamped
// by another machine, so it cannot say when that second ends.
const delay = (checkoutSecond.value + 1) * 1000 + 50 - (yield* Clock.currentTimeMillis);
if (delay >= 2_000) return;
if (delay > 0) yield* Effect.sleep(Duration.millis(delay));
yield* refresh;
});

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

// Before the worktree is returned: a refresh holds index.lock, so running it in the
// background would race the setup script's and the agent's own git commands.
yield* refreshNewWorktreeIndex(worktreePath).pipe(
Effect.catch((cause) =>
Effect.logWarning(
"worktree index refresh failed; Git keeps re-reading its files until the index is rewritten",
{ worktreePath, cause },
),
),
);

if (newRefName && base) {
yield* Effect.gen(function* () {
yield* writeConfigValue({
Expand Down
Loading