Skip to content

Commit 45ac080

Browse files
juliusmarmingecodex
andcommitted
Refresh Git worktree identity safely
Co-authored-by: codex <codex@users.noreply.github.com>
1 parent 653dbe4 commit 45ac080

2 files changed

Lines changed: 120 additions & 10 deletions

File tree

‎apps/server/src/vcs/GitVcsDriverCore.test.ts‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,21 @@ const makeNonRepositoryHandle = () =>
4242
getOutputFd: () => Stream.empty,
4343
});
4444

45+
const makeSuccessfulHandle = (stdout: string) =>
46+
ChildProcessSpawner.makeHandle({
47+
pid: ChildProcessSpawner.ProcessId(1),
48+
exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)),
49+
isRunning: Effect.succeed(false),
50+
kill: () => Effect.void,
51+
unref: Effect.succeed(Effect.void),
52+
stdin: Sink.drain,
53+
stdout: Stream.encodeText(Stream.make(stdout)),
54+
stderr: Stream.empty,
55+
all: Stream.empty,
56+
getInputFd: () => Sink.drain,
57+
getOutputFd: () => Stream.empty,
58+
});
59+
4560
const makeTmpDir = (
4661
prefix = "git-vcs-driver-test-",
4762
): Effect.Effect<string, PlatformError.PlatformError, FileSystem.FileSystem | Scope.Scope> =>
@@ -321,6 +336,70 @@ it.effect("marks the current branch when worktree metadata is unavailable", () =
321336
).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))),
322337
);
323338

339+
it.effect("ignores worktree metadata for directories that no longer exist", () =>
340+
Effect.scoped(
341+
Effect.gen(function* () {
342+
const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;
343+
const missingWorktreePath = "/missing/deleted-worktree";
344+
const staleWorktreeSpawner = ChildProcessSpawner.make((command) =>
345+
Effect.gen(function* () {
346+
if (!ChildProcess.isStandardCommand(command)) {
347+
return yield* Effect.die("expected a standard Git command");
348+
}
349+
const isWorktreeList =
350+
command.args.includes("worktree") && command.args.includes("--porcelain");
351+
if (isWorktreeList) {
352+
return makeSuccessfulHandle(
353+
`worktree ${missingWorktreePath}\nHEAD deadbeef\nbranch refs/heads/stale-worktree\n\n`,
354+
);
355+
}
356+
return yield* delegate.spawn(command);
357+
}),
358+
);
359+
const driver = yield* makeGitVcsDriverCore().pipe(
360+
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, staleWorktreeSpawner),
361+
);
362+
const cwd = yield* makeTmpDir();
363+
yield* initRepoWithCommit(cwd).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, driver));
364+
yield* git(cwd, ["branch", "stale-worktree"]).pipe(
365+
Effect.provideService(GitVcsDriver.GitVcsDriver, driver),
366+
);
367+
368+
const refs = yield* driver.listRefs({ cwd, refresh: true });
369+
370+
assert.equal(refs.refs.find((ref) => ref.name === "stale-worktree")?.worktreePath, null);
371+
}),
372+
).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))),
373+
);
374+
375+
it.effect("refreshes the current branch after an external checkout", () =>
376+
Effect.scoped(
377+
Effect.gen(function* () {
378+
const driver = yield* GitVcsDriver.GitVcsDriver;
379+
const cwd = yield* makeTmpDir();
380+
const { initialBranch } = yield* initRepoWithCommit(cwd);
381+
yield* git(cwd, ["branch", "external-checkout"]);
382+
383+
const initialRefs = yield* driver.listRefs({ cwd, refresh: true });
384+
assert.isTrue(initialRefs.refs.find((ref) => ref.name === initialBranch)?.current);
385+
386+
// Raw execute intentionally bypasses the driver's mutation invalidation,
387+
// matching a checkout performed by another process.
388+
yield* driver.execute({
389+
operation: "GitVcsDriver.test.externalCheckout",
390+
cwd,
391+
args: ["checkout", "external-checkout"],
392+
timeoutMs: 10_000,
393+
});
394+
yield* TestClock.adjust("6 seconds");
395+
396+
const refreshedRefs = yield* driver.listRefs({ cwd, refresh: true });
397+
assert.isTrue(refreshedRefs.refs.find((ref) => ref.name === "external-checkout")?.current);
398+
assert.isFalse(refreshedRefs.refs.find((ref) => ref.name === initialBranch)?.current);
399+
}),
400+
).pipe(Effect.provide(TestLayer)),
401+
);
402+
324403
it.effect("backs off failed upstream refreshes across linked worktrees", () =>
325404
Effect.scoped(
326405
Effect.gen(function* () {

‎apps/server/src/vcs/GitVcsDriverCore.ts‎

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ const STATUS_UPSTREAM_REFRESH_FAILURE_MAX_COOLDOWN = Duration.minutes(15);
5555
const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;
5656
const REPOSITORY_PATHS_CACHE_CAPACITY = 2_048;
5757
const REPOSITORY_PATHS_CACHE_TTL = Duration.minutes(10);
58+
const REPOSITORY_PATHS_REFRESH_COALESCE_TTL = Duration.seconds(5);
5859
const NON_REPOSITORY_PATHS_CACHE_TTL = Duration.seconds(1);
5960
const LIST_REFS_SNAPSHOT_CACHE_CAPACITY = 64;
6061
const LIST_REFS_SNAPSHOT_CACHE_TTL = Duration.minutes(2);
@@ -1085,9 +1086,27 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
10851086
}),
10861087
},
10871088
);
1089+
const repositoryPathsRefreshCache = yield* Cache.makeWith(
1090+
(cwd: string) =>
1091+
Cache.invalidate(repositoryPathsCache, cwd).pipe(
1092+
Effect.andThen(Cache.get(repositoryPathsCache, cwd)),
1093+
),
1094+
{
1095+
capacity: REPOSITORY_PATHS_CACHE_CAPACITY,
1096+
timeToLive: Exit.match({
1097+
onSuccess: (repositoryPaths) =>
1098+
repositoryPaths === null
1099+
? NON_REPOSITORY_PATHS_CACHE_TTL
1100+
: REPOSITORY_PATHS_REFRESH_COALESCE_TTL,
1101+
onFailure: () => Duration.zero,
1102+
}),
1103+
},
1104+
);
10881105
const normalizeRepositoryPathsCacheKey = (cwd: string) => path.normalize(path.resolve(cwd));
1089-
const resolveRepositoryPaths = (cwd: string) =>
1090-
Cache.get(repositoryPathsCache, normalizeRepositoryPathsCacheKey(cwd));
1106+
const resolveRepositoryPaths = (cwd: string, refresh = false) => {
1107+
const cacheKey = normalizeRepositoryPathsCacheKey(cwd);
1108+
return Cache.get(refresh ? repositoryPathsRefreshCache : repositoryPathsCache, cacheKey);
1109+
};
10911110

10921111
const resolveGitCommonDir = Effect.fn("resolveGitCommonDir")(function* (cwd: string) {
10931112
const repositoryPaths = yield* resolveRepositoryPaths(cwd);
@@ -2189,15 +2208,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
21892208
defaultRefResult.exitCode === 0
21902209
? defaultRefResult.stdout.trim().replace(/^refs\/remotes\/origin\//, "")
21912210
: null;
2192-
const worktreeMap =
2211+
const parsedWorktreeEntries =
21932212
worktreeListResult.exitCode === 0
2194-
? new Map(
2195-
[...parseWorktreeBranchPaths(worktreeListResult.stdout)].map(
2196-
([branchName, worktreePath]) =>
2197-
[branchName, path.normalize(path.resolve(worktreePath))] as const,
2198-
),
2213+
? [...parseWorktreeBranchPaths(worktreeListResult.stdout)].map(
2214+
([branchName, worktreePath]) =>
2215+
[branchName, path.normalize(path.resolve(worktreePath))] as const,
21992216
)
2200-
: new Map<string, string>();
2217+
: [];
2218+
const existingWorktreeEntries = yield* Effect.filter(
2219+
parsedWorktreeEntries,
2220+
([, worktreePath]) =>
2221+
fileSystem.stat(worktreePath).pipe(
2222+
Effect.as(true),
2223+
Effect.orElseSucceed(() => false),
2224+
),
2225+
{ concurrency: 16 },
2226+
);
2227+
const worktreeMap = new Map(existingWorktreeEntries);
22012228
const localBranches: Array<{ readonly ref: VcsRef; readonly lastCommit: number }> = [];
22022229
const remoteBranches: Array<{ readonly ref: VcsRef; readonly lastCommit: number }> = [];
22032230

@@ -2313,12 +2340,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
23132340
if (repositoryPaths === null) return;
23142341
yield* Cache.invalidate(listRefsRefreshSnapshotCache, repositoryPaths.gitCommonDir);
23152342
bumpListRefsEpoch(repositoryPaths.gitCommonDir);
2343+
yield* Cache.invalidate(repositoryPathsRefreshCache, repositoryPathsCacheKey);
23162344
yield* Cache.invalidate(repositoryPathsCache, repositoryPathsCacheKey);
23172345
});
23182346

23192347
const listRefs: GitVcsDriver.GitVcsDriver["Service"]["listRefs"] = Effect.fn("listRefs")(
23202348
function* (input) {
2321-
const repositoryPaths = yield* resolveRepositoryPaths(input.cwd).pipe(
2349+
const repositoryPaths = yield* resolveRepositoryPaths(input.cwd, input.refresh === true).pipe(
23222350
Effect.catchTags({
23232351
GitCommandError: (error) =>
23242352
isMissingGitCwdError(error) ? Effect.succeed(null) : Effect.fail(error),
@@ -2675,6 +2703,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
26752703
input,
26762704
) =>
26772705
initRepo(input).pipe(
2706+
Effect.tap(() =>
2707+
Cache.invalidate(repositoryPathsRefreshCache, normalizeRepositoryPathsCacheKey(input.cwd)),
2708+
),
26782709
Effect.tap(() =>
26792710
Cache.invalidate(repositoryPathsCache, normalizeRepositoryPathsCacheKey(input.cwd)),
26802711
),

0 commit comments

Comments
 (0)