diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index c05d4aed4cfbf..3fe2052830118 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -1198,6 +1198,71 @@ describe("orphan worktree reports", () => { ), ); + it.live("reports linked worktrees nested deeper inside a plain folder", () => + runCleanupTest( + Effect.gen(function* () { + const fixture = yield* cleanupFixture(); + fixture.setThreads([]); + const replay = `${fixture.config.worktreesDir}/repository/replay`; + const deep = `${fixture.config.worktreesDir}/repository/deep`; + const worktrees = [`${deep}/a/b`, `${replay}/1376`]; + for (const [index, worktree] of worktrees.entries()) { + yield* fixture.command(fixture.repo, [ + "worktree", + "add", + "-b", + `nested-${index}`, + worktree, + ]); + } + for (const folder of [...worktrees, `${deep}/a`, deep, replay]) { + yield* fixture.fs.utimes(folder, 1, 1); + } + const report = yield* fixture.service.runNow; + expect( + report.entries + .map((entry) => ({ path: entry.path, reason: entry.reason })) + .sort((left, right) => String(left.path).localeCompare(String(right.path))), + ).toEqual(worktrees.map((path) => ({ path, reason: "No thread uses this worktree" }))); + }), + ), + ); + + it.live("still reports a plain folder whose nested worktree registration is stale", () => + runCleanupTest( + Effect.gen(function* () { + const fixture = yield* cleanupFixture(); + fixture.setThreads([]); + const replay = `${fixture.config.worktreesDir}/repository/replay`; + const worktree = `${replay}/1376`; + yield* fixture.command(fixture.repo, ["worktree", "add", "-b", "locked", worktree]); + yield* fixture.command(fixture.repo, ["worktree", "lock", worktree]); + yield* fixture.fs.rename(worktree, `${replay}/moved`); + yield* fixture.fs.makeDirectory(worktree); + yield* fixture.fs.utimes(replay, 1, 1); + expect((yield* fixture.service.runNow).entries.map((entry) => entry.path)).toEqual([ + replay, + ]); + }), + ), + ); + + it.live("does not report nested worktrees inside a project checkout", () => + runCleanupTest( + Effect.gen(function* () { + const fixture = yield* cleanupFixture(); + fixture.setThreads([]); + const project = `${fixture.config.worktreesDir}/repository/replay/project`; + const worktree = `${project}/nested`; + yield* fixture.fs.makeDirectory(project, { recursive: true }); + fixture.setProjectRoots([fixture.repo, project]); + yield* fixture.command(fixture.repo, ["worktree", "add", "-b", "nested", worktree]); + for (const folder of [worktree, project]) yield* fixture.fs.utimes(folder, 1, 1); + expect((yield* fixture.service.runNow).entries).toEqual([]); + }), + ), + ); + it.live("continues after an inaccessible directory's git probe", (context) => runCleanupTest( Effect.gen(function* () { diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 526aaffa2abb3..a601d48ad82b1 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -452,6 +452,26 @@ const make = Effect.gen(function* () { }, Effect.orElseSucceed(() => undefined), ); + // Linked worktrees can sit deeper inside a plain folder, like /replay/. Ask + // the projects' Git for them rather than walking folders that may hold large non-git trees. + let registered: ReadonlyArray | undefined; + const nestedWorktrees = Effect.fnUntraced(function* (folder: string, managedRoot: string) { + if (yield* fs.exists(path.join(folder, ".git"))) return []; + registered ??= (yield* Effect.forEach(new Set(projectRoots), (root) => + git.listWorktreePaths(root).pipe(Effect.orElseSucceed((): string[] => [])), + )).flat(); + // Locked registrations outlive their directory, whose path may since hold something else. + return yield* Effect.filter( + registered.filter( + (worktree) => inside(folder, worktree) && !inProject(worktree, managedRoot), + ), + (worktree) => + Effect.gen(function* () { + if (pathKey(yield* fs.realPath(worktree)) !== pathKey(worktree)) return false; + return (yield* fs.stat(path.join(worktree, ".git"))).type === "File"; + }).pipe(Effect.orElseSucceed(() => false)), + ); + }); for (const root of roots) { // Worktrees can be // or explicitly /. for (const projectDirectory of yield* directories(root, root)) { @@ -463,7 +483,8 @@ const make = Effect.gen(function* () { return; } for (const worktreePath of yield* directories(projectDirectory, root)) { - yield* report(worktreePath); + const nested = yield* nestedWorktrees(worktreePath, root); + for (const target of nested.length > 0 ? nested : [worktreePath]) yield* report(target); } }).pipe(Effect.orElseSucceed(() => undefined)); }