diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 6136da3c8f..dae9c16f4f 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -112,7 +112,7 @@ export interface PaseoToolHostDependencies { github?: ForgeService; workspaceGitService?: Pick< WorkspaceGitService, - "getSnapshot" | "listWorktrees" | "resolveRepoRoot" + "getSnapshot" | "listWorktrees" | "resolveRepoRoot" | "invalidateWorktrees" >; findWorkspaceIdForCwd?: ArchiveDependencies["findWorkspaceIdForCwd"]; listActiveWorkspaces?: ArchiveDependencies["listActiveWorkspaces"]; diff --git a/packages/server/src/server/test-utils/workspace-git-service-stub.ts b/packages/server/src/server/test-utils/workspace-git-service-stub.ts index e7747f57ae..53b8a44c1e 100644 --- a/packages/server/src/server/test-utils/workspace-git-service-stub.ts +++ b/packages/server/src/server/test-utils/workspace-git-service-stub.ts @@ -55,6 +55,7 @@ export function createNoopWorkspaceGitService( suggestBranchesForCwd: async () => [], listStashes: async () => [], listWorktrees: async () => [], + invalidateWorktrees: () => {}, getProjectSlug: async (cwd: string) => { const snapshot = createNoGitWorkspaceRuntimeSnapshot(cwd); return deriveProjectSlug(cwd, snapshot.git.isGit ? snapshot.git.remoteUrl : null); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 0f145d8396..219e04b0c2 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -246,6 +246,7 @@ function createFallbackWorkspaceGitService(): WorkspaceGitService { suggestBranchesForCwd: async () => [], listStashes: async () => [], listWorktrees: async () => [], + invalidateWorktrees: () => {}, getProjectSlug: async (cwd: string) => { const snapshot = createFallbackWorkspaceGitSnapshot(cwd); return deriveProjectSlug(cwd, snapshot.git.isGit ? snapshot.git.remoteUrl : null); diff --git a/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts b/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts index cc05d496ba..87cb758ac5 100644 --- a/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts +++ b/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts @@ -1,10 +1,13 @@ -import { execFileSync, spawn } from "node:child_process"; +import { execFile, execFileSync, spawn } from "node:child_process"; import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; import { afterEach, beforeEach, expect, test } from "vitest"; import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js"; +import { createWorktree } from "../utils/worktree.js"; import { createDaemonTestContext, DaemonClient, @@ -54,6 +57,32 @@ function createGitRepo(): string { return repoDir; } +async function runCli(args: string[], cwd: string): Promise { + const result = await promisify(execFile)( + process.execPath, + [ + "--conditions=source", + "--import", + import.meta.resolve("tsx"), + fileURLToPath(new URL("../../../cli/src/index.ts", import.meta.url)), + "--host", + `127.0.0.1:${ctx.daemon.port}`, + ...args, + ], + { + cwd, + env: { + ...Object.fromEntries( + Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")), + ), + PASEO_HOME: ctx.daemon.paseoHome, + }, + timeout: 20000, + }, + ); + return result.stdout; +} + async function createLocalWorkspace(cwd: string, title: string): Promise { const result = await ctx.client.createWorkspace({ source: { kind: "directory", path: cwd }, @@ -248,6 +277,78 @@ test("renaming a workspace updates every subscribed client", async () => { } }); +test.each(["worktree", "workspace"] as const)( + "%s archive immediately removes the deleted worktree from a warm CLI listing", + async (command) => { + const repoDir = createGitRepo(); + const created = await ctx.client.createWorkspace({ + source: { + kind: "worktree", + cwd: repoDir, + worktreeSlug: "cached-worktree", + baseBranch: "main", + }, + }); + const workspace = created.workspace; + if (!workspace?.workspaceDirectory) { + throw new Error(created.error ?? "Failed to create worktree workspace"); + } + const worktreePath = workspace.workspaceDirectory; + const name = path.basename(worktreePath); + const before = JSON.parse(await runCli(["worktree", "ls", "--json"], repoDir)); + expect(before).toEqual([expect.objectContaining({ name, cwd: worktreePath })]); + + await runCli( + [command, "archive", command === "worktree" ? name : workspace.id, "--json"], + repoDir, + ); + expect(existsSync(worktreePath)).toBe(false); + expect( + execFileSync("git", ["worktree", "list", "--porcelain"], { cwd: repoDir }).toString(), + ).not.toContain(worktreePath); + const after = JSON.parse(await runCli(["worktree", "ls", "--json"], repoDir)); + console.log( + `${command} archive: warm listing=${JSON.stringify(before)}\nimmediate listing=${JSON.stringify(after)}`, + ); + expect(after).toEqual([]); + }, + 60000, +); + +test("archiving an unregistered worktree in a bare repo clears the main checkout listing", async () => { + const sourceRepo = createGitRepo(); + const repoRoot = makeTempDir("workspace-archive-bare-"); + const bareRepo = path.join(repoRoot, "repo.git"); + const mainCheckout = path.join(repoRoot, "main"); + execFileSync("git", ["clone", "--bare", sourceRepo, bareRepo], { stdio: "pipe" }); + execFileSync("git", ["worktree", "add", mainCheckout, "main"], { + cwd: bareRepo, + stdio: "pipe", + }); + const { worktreePath } = await createWorktree({ + cwd: mainCheckout, + source: { kind: "branch-off", baseBranch: "main", branchName: "cached-bare-worktree" }, + worktreeSlug: "cached-bare-worktree", + runSetup: false, + paseoHome: ctx.daemon.paseoHome, + }); + expect(await activeWorkspaceIds()).toEqual(new Set()); + const name = path.basename(worktreePath); + const before = JSON.parse(await runCli(["worktree", "ls", "--json"], mainCheckout)); + expect(before).toEqual([expect.objectContaining({ name, cwd: worktreePath })]); + + await runCli(["worktree", "archive", name, "--json"], mainCheckout); + expect(existsSync(worktreePath)).toBe(false); + expect( + execFileSync("git", ["worktree", "list", "--porcelain"], { cwd: mainCheckout }).toString(), + ).not.toContain(worktreePath); + const after = JSON.parse(await runCli(["worktree", "ls", "--json"], mainCheckout)); + console.log( + `bare repo archive: warm listing=${JSON.stringify(before)}\nimmediate listing=${JSON.stringify(after)}`, + ); + expect(after).toEqual([]); +}, 60000); + test("archiving the last reference to a worktree removes it from disk regardless of the disk flag", async () => { const repoDir = createGitRepo(); diff --git a/packages/server/src/server/workspace-archive-service.test.ts b/packages/server/src/server/workspace-archive-service.test.ts index c3b77f7070..d1cfb8ada7 100644 --- a/packages/server/src/server/workspace-archive-service.test.ts +++ b/packages/server/src/server/workspace-archive-service.test.ts @@ -140,7 +140,8 @@ function createArchiveDeps(input: ArchiveDepsInput): ArchiveTestDependencies { github: createGitHubServiceStub(), workspaceGitService: { getSnapshot: vi.fn(async () => null), - } as unknown as Pick, + invalidateWorktrees: () => {}, + } as unknown as Pick, agentManager: { listAgents: () => [], getAgent: () => null, @@ -617,28 +618,37 @@ describe("archiveByScope", () => { expect(deps.emitWorkspaceUpdatesForWorkspaceIds).not.toHaveBeenCalled(); }); - test("worktree scope removes an owned directory with zero matching records", async () => { - const { tempDir, repoDir } = createGitRepo(); - const paseoHome = path.join(tempDir, ".paseo"); - const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "zero-records"); + test.each(["intact", "missing"] as const)( + "worktree scope removes an owned directory with zero matching records and %s Git administration", + async (gitAdministration) => { + const { tempDir, repoDir } = createGitRepo(); + const paseoHome = path.join(tempDir, ".paseo"); + const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "zero-records"); + if (gitAdministration === "missing") { + const gitDir = execFileSync("git", ["rev-parse", "--absolute-git-dir"], { + cwd: worktree.worktreePath, + }) + .toString() + .trim(); + rmSync(gitDir, { recursive: true, force: true }); + } + const dependencies = createArchiveDeps({ paseoHome, activeWorkspaces: [] }); + const invalidateWorktrees = vi.fn(); + dependencies.workspaceGitService.invalidateWorktrees = invalidateWorktrees; - const result = await archiveByScope( - createArchiveDeps({ - paseoHome, - activeWorkspaces: [], - }), - { + const result = await archiveByScope(dependencies, { scope: { kind: "worktree", targetPath: worktree.worktreePath }, requestId: "req-zero-records", - }, - ); + }); - assertArchiveResult(result, { - archivedWorkspaceIds: [], - removedDirectory: true, - }); - expect(existsSync(worktree.worktreePath)).toBe(false); - }); + assertArchiveResult(result, { + archivedWorkspaceIds: [], + removedDirectory: true, + }); + expect(existsSync(worktree.worktreePath)).toBe(false); + expect(invalidateWorktrees).toHaveBeenCalledTimes(1); + }, + ); test("marks archiving, emits an upsert carrying the archiving state, then clears it and emits a remove", async () => { const { tempDir, repoDir } = createGitRepo(); diff --git a/packages/server/src/server/workspace-archive-service.ts b/packages/server/src/server/workspace-archive-service.ts index 4c61dcb172..e9e58681a1 100644 --- a/packages/server/src/server/workspace-archive-service.ts +++ b/packages/server/src/server/workspace-archive-service.ts @@ -32,7 +32,7 @@ export interface ArchiveDependencies { // Base directory that may hold worktrees across repositories. paseoWorktreesBaseRoot?: string; github: ForgeService; - workspaceGitService: Pick; + workspaceGitService: Pick; agentManager: Pick; agentStorage: Pick; // Resolves the worktree at a path to its workspaceId for archive-by-path. The @@ -408,6 +408,7 @@ async function maybeRemoveDirectory( paseoHome: dependencies.paseoHome, worktreesBaseRoot: dependencies.paseoWorktreesBaseRoot, }); + dependencies.workspaceGitService.invalidateWorktrees(); dependencies.github.invalidate({ cwd: backing.path }); return true; } catch (error) { diff --git a/packages/server/src/server/workspace-git-service.primitive.test.ts b/packages/server/src/server/workspace-git-service.primitive.test.ts index 65eb4a0ad5..ce052cdd7c 100644 --- a/packages/server/src/server/workspace-git-service.primitive.test.ts +++ b/packages/server/src/server/workspace-git-service.primitive.test.ts @@ -1819,6 +1819,46 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => { service.dispose(); }); + test("invalidating worktrees discards warm listings across checkout roots and in-flight reads", async () => { + const worktrees = [ + { + path: "/tmp/paseo-home/worktrees/repo/feature", + createdAt: "2026-04-12T00:00:00.000Z", + branchName: "feature", + }, + ]; + const readStarted = createDeferred(); + const oldRead = createDeferred(); + const alternateCheckout = resolvePath("/tmp/alternate-checkout"); + let reads = 0; + const service = createService({ + listPaseoWorktrees: async () => { + reads += 1; + if (reads <= 2) return worktrees; + if (reads === 3) { + readStarted.resolve(); + return oldRead.promise; + } + return []; + }, + }); + try { + await expect(service.listWorktrees(REPO_CWD)).resolves.toEqual(worktrees); + await expect(service.listWorktrees(alternateCheckout)).resolves.toEqual(worktrees); + const pending = service.listWorktrees(REPO_CWD, { force: true, reason: "test" }); + await readStarted.promise; + service.invalidateWorktrees(); + await expect(service.listWorktrees(REPO_CWD)).resolves.toEqual([]); + await expect(service.listWorktrees(alternateCheckout)).resolves.toEqual([]); + oldRead.resolve(worktrees); + await expect(pending).resolves.toEqual(worktrees); + await expect(service.listWorktrees(REPO_CWD)).resolves.toEqual([]); + } finally { + oldRead.resolve(worktrees); + await service.dispose(); + } + }); + test("listWorktrees shares one repo-root scoped read across sibling workspace cwds", async () => { const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "workspace-git-service-"))); const repoDir = join(tempDir, "repo"); diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index e62720e1c1..2f3483cb6f 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -241,6 +241,7 @@ export interface WorkspaceGitService { cwdOrRepoRoot: string, options?: WorkspaceGitReadOptions, ): Promise; + invalidateWorktrees(): void; getProjectSlug(cwd: string, options?: WorkspaceGitReadOptions): Promise; resolveRepoRoot(cwd: string, options?: WorkspaceGitReadOptions): Promise; resolveDefaultBranch(cwdOrRepoRoot: string, options?: WorkspaceGitReadOptions): Promise; @@ -918,6 +919,13 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { : (snapshot.git.repoRoot ?? resolve(cwd)); } + invalidateWorktrees(): void { + this.assertNotDisposed(); + // Checkout paths can cache the same list under different roots, including bare repos. + // Detach in-flight reads too, so a pre-mutation result cannot refill the cache. + this.worktreeListCache.clear(); + } + async resolveDefaultBranch( cwdOrRepoRoot: string, options?: WorkspaceGitReadOptions, diff --git a/packages/server/src/server/worktree-session.test.ts b/packages/server/src/server/worktree-session.test.ts index 744ba0726a..b146c59f31 100644 --- a/packages/server/src/server/worktree-session.test.ts +++ b/packages/server/src/server/worktree-session.test.ts @@ -2017,6 +2017,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => { workspaceGitService: { getSnapshot: vi.fn(async () => null), listWorktrees: vi.fn(async () => []), + invalidateWorktrees: () => {}, }, agentManager: { listAgents: () => [], @@ -2088,6 +2089,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => { workspaceGitService: { getSnapshot: vi.fn(async () => null), listWorktrees: vi.fn(async () => []), + invalidateWorktrees: () => {}, }, agentManager: { listAgents: () => [], @@ -2164,6 +2166,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => { workspaceGitService: { getSnapshot: vi.fn(async () => null), listWorktrees: vi.fn(async () => []), + invalidateWorktrees: () => {}, }, agentManager: { listAgents: () => [], @@ -2240,6 +2243,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => { workspaceGitService: { getSnapshot: vi.fn(async () => null), listWorktrees: vi.fn(async () => []), + invalidateWorktrees: () => {}, }, agentManager: { listAgents: () => [], diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 6087e6c82a..cdb0119b64 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -501,7 +501,10 @@ export async function handlePaseoWorktreeArchiveRequest( "emitWorkspaceUpdatesForWorkspaceIds" | "workspaceGitService" > & { emit: EmitSessionMessage; - workspaceGitService: Pick; + workspaceGitService: Pick< + WorkspaceGitService, + "getSnapshot" | "listWorktrees" | "invalidateWorktrees" + >; emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable) => Promise; }, msg: Extract, diff --git a/packages/server/src/server/worktree/commands.ts b/packages/server/src/server/worktree/commands.ts index cedc5013c9..4f76cb1887 100644 --- a/packages/server/src/server/worktree/commands.ts +++ b/packages/server/src/server/worktree/commands.ts @@ -93,7 +93,10 @@ export interface ArchiveCommandDependencies extends Omit< ArchiveDependencies, "workspaceGitService" > { - workspaceGitService: Pick; + workspaceGitService: Pick< + WorkspaceGitService, + "getSnapshot" | "listWorktrees" | "invalidateWorktrees" + >; } export interface ArchiveCommandInput {