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);