mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
fix(server): invalidate worktree listings after archive (#6545)
* fix(server): invalidate worktree listings after archive * fix(server): clear archived worktrees across cached checkout roots
This commit is contained in:
@@ -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"];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string> {
|
||||
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<string> {
|
||||
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();
|
||||
|
||||
|
||||
@@ -140,7 +140,8 @@ function createArchiveDeps(input: ArchiveDepsInput): ArchiveTestDependencies {
|
||||
github: createGitHubServiceStub(),
|
||||
workspaceGitService: {
|
||||
getSnapshot: vi.fn(async () => null),
|
||||
} as unknown as Pick<WorkspaceGitService, "getSnapshot">,
|
||||
invalidateWorktrees: () => {},
|
||||
} as unknown as Pick<WorkspaceGitService, "getSnapshot" | "invalidateWorktrees">,
|
||||
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 () => {
|
||||
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);
|
||||
});
|
||||
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();
|
||||
|
||||
@@ -32,7 +32,7 @@ export interface ArchiveDependencies {
|
||||
// Base directory that may hold worktrees across repositories.
|
||||
paseoWorktreesBaseRoot?: string;
|
||||
github: ForgeService;
|
||||
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot">;
|
||||
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot" | "invalidateWorktrees">;
|
||||
agentManager: Pick<AgentManager, "listAgents" | "getAgent" | "archiveAgent" | "archiveSnapshot">;
|
||||
agentStorage: Pick<AgentStorage, "listByWorkspace">;
|
||||
// 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) {
|
||||
|
||||
@@ -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<void>();
|
||||
const oldRead = createDeferred<typeof worktrees>();
|
||||
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");
|
||||
|
||||
@@ -241,6 +241,7 @@ export interface WorkspaceGitService {
|
||||
cwdOrRepoRoot: string,
|
||||
options?: WorkspaceGitReadOptions,
|
||||
): Promise<WorkspaceGitWorktreeInfo[]>;
|
||||
invalidateWorktrees(): void;
|
||||
getProjectSlug(cwd: string, options?: WorkspaceGitReadOptions): Promise<string>;
|
||||
resolveRepoRoot(cwd: string, options?: WorkspaceGitReadOptions): Promise<string>;
|
||||
resolveDefaultBranch(cwdOrRepoRoot: string, options?: WorkspaceGitReadOptions): Promise<string>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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: () => [],
|
||||
|
||||
@@ -501,7 +501,10 @@ export async function handlePaseoWorktreeArchiveRequest(
|
||||
"emitWorkspaceUpdatesForWorkspaceIds" | "workspaceGitService"
|
||||
> & {
|
||||
emit: EmitSessionMessage;
|
||||
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot" | "listWorktrees">;
|
||||
workspaceGitService: Pick<
|
||||
WorkspaceGitService,
|
||||
"getSnapshot" | "listWorktrees" | "invalidateWorktrees"
|
||||
>;
|
||||
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>;
|
||||
},
|
||||
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_archive_request" }>,
|
||||
|
||||
@@ -93,7 +93,10 @@ export interface ArchiveCommandDependencies extends Omit<
|
||||
ArchiveDependencies,
|
||||
"workspaceGitService"
|
||||
> {
|
||||
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot" | "listWorktrees">;
|
||||
workspaceGitService: Pick<
|
||||
WorkspaceGitService,
|
||||
"getSnapshot" | "listWorktrees" | "invalidateWorktrees"
|
||||
>;
|
||||
}
|
||||
|
||||
export interface ArchiveCommandInput {
|
||||
|
||||
Reference in New Issue
Block a user