diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index 62f095b6fd913..efb12a224e015 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -1127,6 +1127,8 @@ effectIt.effect( const expectedTips = { retained: { tip: threadTip, file: "thread.txt", upstream: null, base: "" }, + // A failed removal can leave the folder behind with only dependencies inside. + leftover: { tip: threadTip, file: "thread.txt", upstream: null, base: "" }, deleted: { tip: projectTip, file: "project.txt", upstream: null, base: "project-current" }, pushed: { tip: threadTip, @@ -1171,7 +1173,12 @@ effectIt.effect( yield* git(workspaceRoot, ["remote", "remove", "origin"]); } yield* fileSystem.remove(worktreePath, { recursive: true }); - if (scenario !== "retained") { + if (scenario === "leftover") { + yield* fileSystem.makeDirectory(path.join(worktreePath, "node_modules"), { + recursive: true, + }); + } + if (scenario !== "retained" && scenario !== "leftover") { yield* git(workspaceRoot, ["worktree", "prune"]); yield* git(workspaceRoot, ["branch", "-D", "feature/restore"]); } @@ -1220,6 +1227,27 @@ effectIt.effect( }).pipe(Effect.scoped, Effect.provide(layerRealGit)), ); +effectIt.effect("leaves a managed worktree folder without .git that holds user files", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const worktreesDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-leftover-" }); + const worktreePath = path.join(worktreesDir, "project", "restore"); + yield* fileSystem.makeDirectory(path.join(worktreePath, "node_modules"), { recursive: true }); + yield* fileSystem.writeFileString(path.join(worktreePath, "notes.md"), "keep me"); + const harness = makeLocalCommandHarness({ + text: "Continue", + worktreePath, + worktreesDir, + fileSystem, + failReadsAfterRunning: true, + }); + yield* harness.start; + expect(harness.createWorktree).not.toHaveBeenCalled(); + expect(yield* fileSystem.readFileString(path.join(worktreePath, "notes.md"))).toBe("keep me"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + effectIt.effect( "preserves recreation interrupts and retries, then fails the final attempt before provider start", () => diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index 28d66e50bb0bb..b0641a20428ec 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -2332,6 +2332,24 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); + it.effect("keeps a thread's worktree folder that lost its .git link", () => + runCleanupTest( + Effect.gen(function* () { + yield* TestClock.setTime(NOW_MS); + const fixture = yield* cleanupFixture(); + fixture.setThreads([ + shell({ branch: "feature", worktreePath: fixture.worktree, archivedAt: at(-DAY_MS) }), + ]); + yield* fixture.fs.remove(`${fixture.worktree}/.git`); + yield* fixture.fs.makeDirectory(`${fixture.worktree}/node_modules`); + const report = yield* fixture.service.runNow; + expect(report.entries).toEqual([ + expect.objectContaining({ path: fixture.worktree, outcome: "kept" }), + ]); + expect(yield* fixture.fs.exists(`${fixture.worktree}/node_modules`)).toBe(true); + }), + ), + ); it.live("skips a stored worktree path whose parent is a regular file", () => runCleanupTest( Effect.gen(function* () { diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 0cf5eca0b80d0..99097501d49d7 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -1010,8 +1010,10 @@ const make = Effect.gen(function* () { return "worktree is a symbolic link"; if (!roots.some((root) => inside(root, realPath))) return "outside the managed worktree folder"; - if ((yield* fs.stat(path.join(worktreePath, ".git"))).type !== "File") - return "not a linked worktree"; + const gitLink = yield* fs + .stat(path.join(worktreePath, ".git")) + .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.undefined)); + if (gitLink?.type !== "File") return "not a linked worktree"; return null; }); const deletedRows = yield* sql<{ payload_json: string; workspaceRoot: string }>` diff --git a/apps/server/src/workspace/restoreMissingWorktree.ts b/apps/server/src/workspace/restoreMissingWorktree.ts index ee423c47d06ab..4bc1ac8f533c8 100644 --- a/apps/server/src/workspace/restoreMissingWorktree.ts +++ b/apps/server/src/workspace/restoreMissingWorktree.ts @@ -33,6 +33,9 @@ class WorktreeRecreateNoBaseError extends Schema.TaggedError + fileSystem.exists(path.join(worktreePath, ".git")).pipe( + Effect.flatMap((hasGit) => + hasGit + ? Effect.succeed(false) + : fileSystem.readDirectory(worktreePath).pipe( + Effect.flatMap((entries) => + Effect.forEach(entries, (entry) => + LEFTOVER_WORKTREE_ENTRIES.has(entry) + ? fileSystem + .stat(path.join(worktreePath, entry)) + .pipe(Effect.map((info) => info.type === "Directory")) + : Effect.succeed(false), + ), + ), + Effect.map((leftovers) => leftovers.every(Boolean)), + ), + ), + Effect.orElseSucceed(() => false), + ); + const resolveRecreateBase = Effect.fn("restoreMissingWorktree.resolveRecreateBase")(function* ( cwd: string, remoteName: string | null, @@ -116,7 +142,8 @@ export const makeRestoreMissingWorktree = Effect.gen(function* () { const { worktreePath, branch } = thread; if (worktreePath === null || branch === null) return; const exists = yield* fileSystem.exists(worktreePath).pipe(Effect.orElseSucceed(() => true)); - if (exists || !(yield* isManagedWorktreePath(worktreePath))) return; + const leftover = exists && (yield* isLeftoverFolder(worktreePath)); + if ((exists && !leftover) || !(yield* isManagedWorktreePath(worktreePath))) return; const project = yield* projects.getById(thread.projectId).pipe( Effect.map(Option.getOrUndefined), Effect.orElseSucceed(() => undefined), @@ -127,7 +154,9 @@ export const makeRestoreMissingWorktree = Effect.gen(function* () { worktreePath, branch, }); - yield* recreateMissingWorktree({ cwd: project.workspaceRoot, worktreePath, branch }).pipe( + // `git worktree add` refuses a non-empty target. + yield* (leftover ? fileSystem.remove(worktreePath, { recursive: true }) : Effect.void).pipe( + Effect.andThen(recreateMissingWorktree({ cwd: project.workspaceRoot, worktreePath, branch })), Effect.mapError((cause) => cause._tag === "WorktreeRecreateNoBaseError" ? cause