From 80aa9a1d9760d926f473e8b5459ced65a5a49da0 Mon Sep 17 00:00:00 2001 From: Stefan van der Velden Date: Thu, 15 Oct 2026 14:32:02 +1100 Subject: [PATCH] fix(server): deleting an old pre-v2 fork thread removes its folder and workspace file The pre-v2 multi-repo fork wrote its workspace file as a real file inside the run's container. remove() keeps real files, so deleting a migrated thread left the container, the beside .code-workspace and the layout file behind. A run with no root repository whose only leftover is that one .code-workspace file is now removed in full; any other leftover still keeps the folder. Co-Authored-By: Claude Opus 5.5 --- .../server/src/git/WorkspaceWorktrees.test.ts | 70 +++++++++++++++++++ apps/server/src/git/WorkspaceWorktrees.ts | 14 +++- 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/apps/server/src/git/WorkspaceWorktrees.test.ts b/apps/server/src/git/WorkspaceWorktrees.test.ts index 575f2cee23ac..963e66107820 100644 --- a/apps/server/src/git/WorkspaceWorktrees.test.ts +++ b/apps/server/src/git/WorkspaceWorktrees.test.ts @@ -214,6 +214,76 @@ it.effect("keeps a container that holds anything besides its worktrees and links }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); +it.effect("removes a pre-V2 fork run whose own workspace file is all that is left", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-workspace-worktrees-", + }); + yield* Effect.gen(function* () { + const worktrees = yield* WorkspaceWorktrees.WorkspaceWorktrees; + const workspaceRoot = path.join(baseDir, "project"); + yield* initRepository(path.join(workspaceRoot, "api")); + yield* initRepository(path.join(workspaceRoot, "web")); + const created = yield* worktrees.create({ + workspaceRoot, + repositories: ["api", "web"].map((name) => ({ + relativePath: name, + name, + path: path.join(workspaceRoot, name), + })), + branch: "fork-run", + startFromOrigin: false, + }); + // The fork's copy, and V2's file the startup step wrote beside the container. + yield* fileSystem.writeFileString( + path.join(created.path, "project.code-workspace"), + `{ "folders": [{ "path": "./api" }, { "path": "./web" }] }`, + ); + const besideFile = WorkspaceRepositories.isolatedRunWorkspaceFilePath(created.path); + yield* fileSystem.writeFileString(besideFile, "{}"); + + yield* worktrees.remove({ path: created.path, force: false }); + + assert.isFalse(yield* fileSystem.exists(created.path)); + assert.isFalse(yield* fileSystem.exists(besideFile)); + assert.isFalse( + yield* fileSystem.exists(WorkspaceRepositories.isolatedRunLayoutPath(created.path)), + ); + }).pipe(Effect.provide(layerTest(baseDir))); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("keeps a pre-V2 fork run that holds another file besides its workspace file", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-workspace-worktrees-", + }); + yield* Effect.gen(function* () { + const worktrees = yield* WorkspaceWorktrees.WorkspaceWorktrees; + const workspaceRoot = path.join(baseDir, "project"); + yield* initRepository(path.join(workspaceRoot, "api")); + const created = yield* worktrees.create({ + workspaceRoot, + repositories: [{ relativePath: "api", name: "api", path: path.join(workspaceRoot, "api") }], + branch: "fork-notes", + startFromOrigin: false, + }); + const forkCopy = path.join(created.path, "project.code-workspace"); + yield* fileSystem.writeFileString(forkCopy, `{ "folders": [{ "path": "./api" }] }`); + yield* fileSystem.writeFileString(path.join(created.path, "notes.md"), "mine\n"); + + yield* worktrees.remove({ path: created.path, force: false }); + + assert.isTrue(yield* fileSystem.exists(forkCopy)); + assert.equal(yield* fileSystem.readFileString(path.join(created.path, "notes.md")), "mine\n"); + }).pipe(Effect.provide(layerTest(baseDir))); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + it.effect("with a workspace file, links files, dot-folders and listed folders only", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/git/WorkspaceWorktrees.ts b/apps/server/src/git/WorkspaceWorktrees.ts index c15d5f0d412c..cf2fc9cd0bbf 100644 --- a/apps/server/src/git/WorkspaceWorktrees.ts +++ b/apps/server/src/git/WorkspaceWorktrees.ts @@ -434,7 +434,19 @@ const make = Effect.gen(function* () { }); } if (root === undefined && (yield* exists(input.path))) { - yield* removeLeftovers(input.path).pipe( + yield* Effect.gen(function* () { + if (yield* removeLeftovers(input.path)) return; + // The pre-V2 multi-repo fork wrote its workspace file as a real file in the container, + // so a run it made still holds that file once everything else is gone. + const names = yield* fileSystem.readDirectory(input.path); + if ( + names.length === 1 && + names[0]!.endsWith(WorkspaceRepositories.CODE_WORKSPACE_EXTENSION) && + (yield* fileSystem.stat(path.join(input.path, names[0]!))).type === "File" + ) { + yield* fileSystem.remove(input.path, { recursive: true }); + } + }).pipe( Effect.mapError((cause) => fail(operation, input.path, "Could not remove the isolated run folder.", cause), ),