From 759d2315298f06c81652ffaf74111cd5cdd0e250 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Tani=C3=A7a?= Date: Mon, 9 Nov 2026 22:19:09 +0000 Subject: [PATCH] fix(server): remove worktrees through their owning repository Storage cleanup ran `git worktree remove` from the project's current workspace root. A thread's worktree can belong to a different clone, such as after the project directory moves to another checkout. Git then reports "is not a working tree" on every pass, and the worktree stays on disk. Resolve the repository that registered the worktree from the checkout itself and run the removal there. The resolved common directory also exists after the Windows path deletes the folder before removing the registration. --- apps/server/src/storageCleanup.test.ts | 29 ++++++++++++++++++++++++++ apps/server/src/storageCleanup.ts | 20 ++++++++++++++++-- 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index de1ccfefcf64f..303c0de822517 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -2322,6 +2322,35 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); + it.live("removes a worktree owned by a repository other than the project root", () => + runCleanupTest( + Effect.gen(function* () { + const fixture = yield* cleanupFixture(); + const { fs, command, config } = fixture; + const oldRepo = yield* fs.realPath( + yield* fs.makeTempDirectoryScoped({ prefix: "cleanup-old-repo-" }), + ); + yield* command(oldRepo, ["init", "-b", "main"]); + yield* command(oldRepo, ["config", "user.email", "cleanup@example.test"]); + yield* command(oldRepo, ["config", "user.name", "Cleanup test"]); + yield* fs.writeFileString(`${oldRepo}/tracked.txt`, "original\n"); + yield* command(oldRepo, ["add", "."]); + yield* command(oldRepo, ["commit", "-m", "initial"]); + yield* fs.makeDirectory(`${config.worktreesDir}/old-repo`, { recursive: true }); + const oldWorktree = `${config.worktreesDir}/old-repo/feature`; + yield* command(oldRepo, ["worktree", "add", "-b", "feature", oldWorktree]); + fixture.setThreads([shell({ branch: "feature", worktreePath: oldWorktree })]); + const report = yield* fixture.service.runNow; + expect(report.entries.find((entry) => entry.path === oldWorktree)).toMatchObject({ + outcome: "removed", + }); + expect(yield* fs.exists(oldWorktree)).toBe(false); + expect((yield* command(oldRepo, ["worktree", "list", "--porcelain"])).stdout).not.toContain( + oldWorktree, + ); + }), + ), + ); it.live.each([ [ "its owner's branch is cleared", diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index a5f9cd7eefa12..6df5c6d6fbe7e 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -637,6 +637,21 @@ const make = Effect.gen(function* () { return (name?.startsWith("(") ? null : name) !== branch; }); + // A worktree can outlive its project moving to another clone. Git removes a + // worktree through the repository that registered it, not through the + // project's current checkout, so resolve the owning repository first. + const worktreeRepository = Effect.fn("StorageCleanup.worktreeRepository")(function* ( + worktreePath: string, + ) { + const commonDir = yield* git.execute({ + operation: "StorageCleanup.worktreeRepository", + cwd: worktreePath, + args: ["rev-parse", "--git-common-dir"], + }); + const resolved = path.resolve(worktreePath, commonDir.stdout.trim()); + return path.basename(resolved) === ".git" ? path.dirname(resolved) : resolved; + }); + const localChangesReason = Effect.fn("StorageCleanup.localChangesReason")(function* ( cwd: string, rules: WorktreeCleanupRules, @@ -1215,14 +1230,15 @@ const make = Effect.gen(function* () { const changes = yield* localChangesReason(worktreePath, settings); if (changes !== null) return keep(changes); } + const repository = yield* worktreeRepository(worktreePath); yield* removeLinkedWorktree({ operation: "StorageCleanup.cleanupGit", - cwd: project.workspaceRoot, + cwd: repository, worktreePath, // Git requires force for submodules; recursive checks above cannot make removal atomic. force: submodules.force, execute: git.execute, - run: (args) => cleanupGit(project.workspaceRoot, [...args], "git worktree remove"), + run: (args) => cleanupGit(repository, [...args], "git worktree remove"), }); entries.push({ ...entry, outcome: "removed", reason: sentence(removalReason), bytes }); yield* gitManager.invalidateStatus(project.workspaceRoot);