From f6f462673805d0c315595734cf0e31e90f05b6e3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 09:21:41 +0000 Subject: [PATCH 1/6] test(server): reproduce #25845 nested orphan worktrees reported as plain folders These tests reproduce #25845: linked worktrees nested more than one level below a managed project folder are never reported, and their holding folder is labelled as a non-worktree. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/storageCleanup.test.ts | 30 ++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index c05d4aed4cfbf..2bdd80ca2a241 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -1198,6 +1198,36 @@ 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) => left.path.localeCompare(right.path)), + ).toEqual(worktrees.map((path) => ({ path, reason: "No thread uses this worktree" }))); + }), + ), + ); + it.live("continues after an inaccessible directory's git probe", (context) => runCleanupTest( Effect.gen(function* () { From 1f5d2b3ef75cce51cda33c28b72c332352433d45 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 09:31:40 +0000 Subject: [PATCH 2/6] fix(server): storage report finds linked worktrees nested inside plain folders A folder at the worktree level without .git was reported as a plain folder without looking inside, so worktrees such as /replay/ never appeared. Read up to 64 plain folders inside it, breadth first, for linked worktrees and report those instead. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/storageCleanup.test.ts | 2 +- apps/server/src/storageCleanup.ts | 22 +++++++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index 2bdd80ca2a241..728c9e1e8790e 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -1222,7 +1222,7 @@ describe("orphan worktree reports", () => { expect( report.entries .map((entry) => ({ path: entry.path, reason: entry.reason })) - .sort((left, right) => left.path.localeCompare(right.path)), + .sort((left, right) => String(left.path).localeCompare(String(right.path))), ).toEqual(worktrees.map((path) => ({ path, reason: "No thread uses this worktree" }))); }), ), diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 526aaffa2abb3..2563569260679 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -452,6 +452,25 @@ const make = Effect.gen(function* () { }, Effect.orElseSucceed(() => undefined), ); + // Linked worktrees can sit deeper inside a plain folder, like /replay/. + // Read at most 64 plain folders per candidate, nearest first, so a large non-git tree stays cheap. + const nestedWorktrees = Effect.fnUntraced( + function* (folder: string, managedRoot: string) { + if (yield* fs.exists(path.join(folder, ".git"))) return []; + const found: string[] = []; + const pending = [folder]; + for (let read = 0; read < 64 && read < pending.length; read++) { + for (const directory of yield* directories(pending[read]!, managedRoot)) { + if (referenced.has(pathKey(directory))) continue; + const gitPath = path.join(directory, ".git"); + if (!(yield* fs.exists(gitPath))) pending.push(directory); + else if ((yield* fs.stat(gitPath)).type === "File") found.push(directory); + } + } + return found; + }, + Effect.orElseSucceed((): string[] => []), + ); for (const root of roots) { // Worktrees can be // or explicitly /. for (const projectDirectory of yield* directories(root, root)) { @@ -463,7 +482,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)); } From db85fd542fdfe9ec6f5aac30ab73c93b94bbbbea Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 09:48:06 +0000 Subject: [PATCH 3/6] fix(server): keep nested worktrees when a sibling probe fails and bound the scan by entries A failed .git probe on one sibling no longer discards worktrees already found, the nested scan is bounded by a per-report budget of checked folders instead of 64 folder reads, and a folder whose scan runs out is still reported itself. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/storageCleanup.test.ts | 22 ++++++++++++++ apps/server/src/storageCleanup.ts | 40 +++++++++++++++----------- 2 files changed, 45 insertions(+), 17 deletions(-) diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index 728c9e1e8790e..640ca3d8809de 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -1228,6 +1228,28 @@ describe("orphan worktree reports", () => { ), ); + it.live("keeps nested worktrees found next to an unreadable folder", (context) => + runCleanupTest( + Effect.gen(function* () { + if ((yield* HostProcess.isWindows) || (yield* HostProcess.UserId) === 0) + return context.skip(); + const fixture = yield* cleanupFixture(); + fixture.setThreads([]); + const replay = `${fixture.config.worktreesDir}/repository/replay`; + const worktree = `${replay}/1376`; + const blocked = `${replay}/z-blocked`; + yield* fixture.command(fixture.repo, ["worktree", "add", "-b", "nested", worktree]); + yield* fixture.fs.makeDirectory(blocked); + yield* fixture.fs.chmod(blocked, 0); + for (const folder of [worktree, replay]) yield* fixture.fs.utimes(folder, 1, 1); + const report = yield* fixture.service.runNow.pipe( + Effect.ensuring(fixture.fs.chmod(blocked, 0o700).pipe(Effect.orDie)), + ); + expect(report.entries.map((entry) => entry.path)).toEqual([worktree]); + }), + ), + ); + 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 2563569260679..9eb327d567a4c 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -452,25 +452,31 @@ const make = Effect.gen(function* () { }, Effect.orElseSucceed(() => undefined), ); + const gitEntryType = (directory: string) => + fs.stat(path.join(directory, ".git")).pipe( + Effect.map((info) => info.type), + Effect.orElseSucceed(() => null), + ); // Linked worktrees can sit deeper inside a plain folder, like /replay/. - // Read at most 64 plain folders per candidate, nearest first, so a large non-git tree stays cheap. - const nestedWorktrees = Effect.fnUntraced( - function* (folder: string, managedRoot: string) { - if (yield* fs.exists(path.join(folder, ".git"))) return []; - const found: string[] = []; - const pending = [folder]; - for (let read = 0; read < 64 && read < pending.length; read++) { - for (const directory of yield* directories(pending[read]!, managedRoot)) { - if (referenced.has(pathKey(directory))) continue; - const gitPath = path.join(directory, ".git"); - if (!(yield* fs.exists(gitPath))) pending.push(directory); - else if ((yield* fs.stat(gitPath)).type === "File") found.push(directory); - } + // One budget of checked folders per report keeps large non-git trees cheap; a folder whose + // scan runs out is still reported itself so nothing it holds silently disappears. + let nestedBudget = 10_000; + const nestedWorktrees = Effect.fnUntraced(function* (folder: string, managedRoot: string) { + const found: string[] = []; + if ((yield* gitEntryType(folder)) !== null) return found; + const pending = [folder]; + for (let index = 0; index < pending.length; index++) { + if (nestedBudget <= 0) return [...found, folder]; + for (const directory of yield* directories(pending[index]!, managedRoot)) { + nestedBudget--; + if (referenced.has(pathKey(directory))) continue; + const type = yield* gitEntryType(directory); + if (type === null) pending.push(directory); + else if (type === "File") found.push(directory); } - return found; - }, - Effect.orElseSucceed((): string[] => []), - ); + } + return found; + }); for (const root of roots) { // Worktrees can be // or explicitly /. for (const projectDirectory of yield* directories(root, root)) { From 79dcb47f964520477f32aca2a2a21f475c2b8b35 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 10:14:14 +0000 Subject: [PATCH 4/6] fix(server): find nested worktrees through the projects' git worktree lists Walking plain folders had no reliable bound and followed links; the report now asks each project's Git for its registered worktrees once per report and lists those that sit inside a plain folder. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/storageCleanup.test.ts | 22 ---------------- apps/server/src/storageCleanup.ts | 35 ++++++++------------------ 2 files changed, 10 insertions(+), 47 deletions(-) diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index 640ca3d8809de..728c9e1e8790e 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -1228,28 +1228,6 @@ describe("orphan worktree reports", () => { ), ); - it.live("keeps nested worktrees found next to an unreadable folder", (context) => - runCleanupTest( - Effect.gen(function* () { - if ((yield* HostProcess.isWindows) || (yield* HostProcess.UserId) === 0) - return context.skip(); - const fixture = yield* cleanupFixture(); - fixture.setThreads([]); - const replay = `${fixture.config.worktreesDir}/repository/replay`; - const worktree = `${replay}/1376`; - const blocked = `${replay}/z-blocked`; - yield* fixture.command(fixture.repo, ["worktree", "add", "-b", "nested", worktree]); - yield* fixture.fs.makeDirectory(blocked); - yield* fixture.fs.chmod(blocked, 0); - for (const folder of [worktree, replay]) yield* fixture.fs.utimes(folder, 1, 1); - const report = yield* fixture.service.runNow.pipe( - Effect.ensuring(fixture.fs.chmod(blocked, 0o700).pipe(Effect.orDie)), - ); - expect(report.entries.map((entry) => entry.path)).toEqual([worktree]); - }), - ), - ); - 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 9eb327d567a4c..f0642325a150c 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -452,30 +452,15 @@ const make = Effect.gen(function* () { }, Effect.orElseSucceed(() => undefined), ); - const gitEntryType = (directory: string) => - fs.stat(path.join(directory, ".git")).pipe( - Effect.map((info) => info.type), - Effect.orElseSucceed(() => null), - ); - // Linked worktrees can sit deeper inside a plain folder, like /replay/. - // One budget of checked folders per report keeps large non-git trees cheap; a folder whose - // scan runs out is still reported itself so nothing it holds silently disappears. - let nestedBudget = 10_000; - const nestedWorktrees = Effect.fnUntraced(function* (folder: string, managedRoot: string) { - const found: string[] = []; - if ((yield* gitEntryType(folder)) !== null) return found; - const pending = [folder]; - for (let index = 0; index < pending.length; index++) { - if (nestedBudget <= 0) return [...found, folder]; - for (const directory of yield* directories(pending[index]!, managedRoot)) { - nestedBudget--; - if (referenced.has(pathKey(directory))) continue; - const type = yield* gitEntryType(directory); - if (type === null) pending.push(directory); - else if (type === "File") found.push(directory); - } - } - return found; + // 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) { + 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(); + return registered.filter((worktree) => inside(folder, worktree)); }); for (const root of roots) { // Worktrees can be // or explicitly /. @@ -488,7 +473,7 @@ const make = Effect.gen(function* () { return; } for (const worktreePath of yield* directories(projectDirectory, root)) { - const nested = yield* nestedWorktrees(worktreePath, root); + const nested = yield* nestedWorktrees(worktreePath); for (const target of nested.length > 0 ? nested : [worktreePath]) yield* report(target); } }).pipe(Effect.orElseSucceed(() => undefined)); From 4985e8d102a817fafe2baffc69be14e01187ebfe Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 10:28:07 +0000 Subject: [PATCH 5/6] fix(server): skip stale and project-internal registrations when listing nested worktrees Co-Authored-By: Claude Opus 5.5 --- apps/server/src/storageCleanup.test.ts | 34 ++++++++++++++++++++++++++ apps/server/src/storageCleanup.ts | 12 ++++++--- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index 728c9e1e8790e..3bbc16fe31aef 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -1228,6 +1228,40 @@ describe("orphan worktree reports", () => { ), ); + 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.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 f0642325a150c..c452bdcb79f7d 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -455,12 +455,18 @@ const make = Effect.gen(function* () { // 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) { + 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(); - return registered.filter((worktree) => inside(folder, worktree)); + // Locked registrations outlive their directory, so only replace the folder with real ones. + return yield* Effect.filter( + registered.filter( + (worktree) => inside(folder, worktree) && !inProject(worktree, managedRoot), + ), + (worktree) => fs.exists(worktree).pipe(Effect.orElseSucceed(() => false)), + ); }); for (const root of roots) { // Worktrees can be // or explicitly /. @@ -473,7 +479,7 @@ const make = Effect.gen(function* () { return; } for (const worktreePath of yield* directories(projectDirectory, root)) { - const nested = yield* nestedWorktrees(worktreePath); + const nested = yield* nestedWorktrees(worktreePath, root); for (const target of nested.length > 0 ? nested : [worktreePath]) yield* report(target); } }).pipe(Effect.orElseSucceed(() => undefined)); From 6a845c02e4d51d6866d4f4562afea9ec137f472e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 10:52:10 +0000 Subject: [PATCH 6/6] fix(server): only replace a plain folder with registered paths that are still linked worktrees Co-Authored-By: Claude Opus 5.5 --- apps/server/src/storageCleanup.test.ts | 1 + apps/server/src/storageCleanup.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index 3bbc16fe31aef..3fe2052830118 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -1238,6 +1238,7 @@ describe("orphan worktree reports", () => { 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, diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index c452bdcb79f7d..a601d48ad82b1 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -460,12 +460,16 @@ const make = Effect.gen(function* () { registered ??= (yield* Effect.forEach(new Set(projectRoots), (root) => git.listWorktreePaths(root).pipe(Effect.orElseSucceed((): string[] => [])), )).flat(); - // Locked registrations outlive their directory, so only replace the folder with real ones. + // 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) => fs.exists(worktree).pipe(Effect.orElseSucceed(() => false)), + (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) {