diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index 62f095b6fd913..ca8197d9dd374 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -1483,6 +1483,65 @@ effectIt.effect("does not resume a continuation stopped while recovery was in fl }), ); +effectIt.effect.each([["node_modules"], ["node_modules", ".next"]])( + "recreates a managed worktree folder that holds only %s", + (leftovers) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceRoot = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-start-" }); + const worktreePath = path.join(workspaceRoot, "project", "worktree"); + // What a junction-skipping `git worktree remove` leaves behind: no `.git`, no files. + for (const leftover of leftovers) { + yield* fileSystem.makeDirectory(path.join(worktreePath, leftover), { recursive: true }); + } + const harness = makeLocalCommandHarness({ + text: "Continue", + worktreePath, + worktreesDir: workspaceRoot, + fileSystem, + failReadsAfterRunning: true, + }); + // `git worktree add` refuses a non-empty target, so the leftover must be gone first. + harness.createWorktree.mockImplementation(() => + fileSystem.exists(worktreePath).pipe( + Effect.tap((exists) => Effect.sync(() => expect(exists).toBe(false))), + Effect.as({} as never), + Effect.orDie, + ), + ); + yield* harness.start; + expect(harness.createWorktree).toHaveBeenCalledWith({ + cwd: "/tmp/native-account-project", + refName: "feature/restore", + path: worktreePath, + }); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +effectIt.effect.each(["notes.md", ".next"])( + "leaves a managed worktree folder holding a %s file untouched", + (userFile) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceRoot = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-start-" }); + const worktreePath = path.join(workspaceRoot, "project", "worktree"); + yield* fileSystem.makeDirectory(path.join(worktreePath, "node_modules"), { recursive: true }); + yield* fileSystem.writeFileString(path.join(worktreePath, userFile), "keep me"); + const harness = makeLocalCommandHarness({ + text: "Continue", + worktreePath, + worktreesDir: workspaceRoot, + fileSystem, + failReadsAfterRunning: true, + }); + yield* harness.start; + expect(harness.createWorktree).not.toHaveBeenCalled(); + expect(yield* fileSystem.readFileString(path.join(worktreePath, userFile))).toBe("keep me"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + effectIt.effect.each(["cleanup-first", "startup-first"])( "coordinates provider startup with %s", (order) => diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index 28d66e50bb0bb..cd4b99bd8e8c7 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -913,6 +913,25 @@ it.live("preserves late tracked edits when submodules were never initialized", ( ), ); +it.live("keeps a managed worktree folder left with only node_modules", () => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, repo, worktree, command } = yield* cleanupFixture(); + yield* fs.makeDirectory(`${worktree}/node_modules/pkg`, { recursive: true }); + for (const entry of yield* fs.readDirectory(worktree)) { + if (entry !== "node_modules") yield* fs.remove(`${worktree}/${entry}`, { recursive: true }); + } + yield* command(repo, ["worktree", "prune"]); + const report = yield* service.runNow; + expect(report.entries.find((entry) => entry.path === worktree)).toMatchObject({ + outcome: "kept", + reason: "Not a linked worktree", + }); + expect(yield* fs.exists(`${worktree}/node_modules/pkg`)).toBe(true); + }), + ), +); + describe("subagent worktree ownership", () => { it.effect.each([ "child", diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 0cf5eca0b80d0..7b5adc40323bb 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -1010,7 +1010,8 @@ 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") + const gitPath = path.join(worktreePath, ".git"); + if (!(yield* fs.exists(gitPath)) || (yield* fs.stat(gitPath)).type !== "File") return "not a linked worktree"; return null; }); diff --git a/apps/server/src/workspace/restoreMissingWorktree.ts b/apps/server/src/workspace/restoreMissingWorktree.ts index ee423c47d06ab..4008981bc2e09 100644 --- a/apps/server/src/workspace/restoreMissingWorktree.ts +++ b/apps/server/src/workspace/restoreMissingWorktree.ts @@ -33,6 +33,8 @@ class WorktreeRecreateNoBaseError extends Schema.TaggedError false), + ); + const resolveRecreateBase = Effect.fn("restoreMissingWorktree.resolveRecreateBase")(function* ( cwd: string, remoteName: string | null, @@ -116,7 +136,9 @@ 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; + if (!(yield* isManagedWorktreePath(worktreePath))) return; + const leftover = exists && (yield* isLeftoverWorktree(worktreePath)); + if (exists && !leftover) return; const project = yield* projects.getById(thread.projectId).pipe( Effect.map(Option.getOrUndefined), Effect.orElseSucceed(() => undefined), @@ -127,7 +149,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