From 3d6dbddac1740776d4bca4dfc8abd77a57304bef Mon Sep 17 00:00:00 2001 From: Taras Kornichuk Date: Mon, 1 Jun 2026 10:40:26 +0300 Subject: [PATCH] feat: configure workspace worktree storage --- docs/data-model.md | 21 +-- .../sidebar-workspace-list.test.tsx | 1 + .../src/components/workspace-setup-dialog.tsx | 17 +- ...space-shortcut-targets-subscriber.test.tsx | 1 + .../session-context.service-status.test.ts | 1 + .../session-workspace-upserts.test.ts | 1 + packages/app/src/git/actions-store.test.ts | 1 + .../hooks/use-active-worktree-new-action.ts | 16 +- .../app/src/hooks/use-open-project.test.ts | 1 + packages/app/src/hooks/use-projects.test.ts | 1 + .../app/src/screens/new-workspace-screen.tsx | 18 +- .../src/screens/project-settings-screen.tsx | 157 +++++++++++++++++- .../app/src/screens/projects-screen.test.tsx | 3 + .../workspace/workspace-route-state.test.ts | 1 + .../workspace-source-of-truth.test.ts | 1 + .../session-store-hooks/selectors.test.ts | 1 + packages/app/src/stores/session-store.test.ts | 46 +++++ packages/app/src/stores/session-store.ts | 2 + .../utils/navigate-to-agent/resolve.test.ts | 1 + packages/app/src/utils/projects.test.ts | 35 ++++ packages/app/src/utils/projects.ts | 6 + .../workspace-archive-navigation.test.ts | 1 + .../app/src/utils/workspace-execution.test.ts | 1 + .../src/workspace/workspace-archive.test.ts | 1 + packages/client/src/daemon-client.test.ts | 104 ++++++++++++ packages/client/src/daemon-client.ts | 31 ++++ packages/protocol/src/messages.ts | 33 ++++ .../agent/create-agent-lifecycle-dispatch.ts | 26 ++- .../server/src/server/agent/mcp-server.ts | 3 + .../auto-archive-on-merge/archive-if-safe.ts | 35 +++- packages/server/src/server/bootstrap.ts | 3 + .../server/paseo-worktree-archive-service.ts | 13 +- .../src/server/paseo-worktree-service.test.ts | 44 ++++- .../src/server/paseo-worktree-service.ts | 29 +++- packages/server/src/server/session.ts | 69 ++++++++ .../src/server/session.workspaces.test.ts | 121 ++++++++++++++ .../src/server/workspace-git-service.ts | 30 +++- .../src/server/workspace-registry.test.ts | 39 +++++ .../server/src/server/workspace-registry.ts | 7 + packages/server/src/server/worktree-core.ts | 3 + .../server/src/server/worktree-session.ts | 31 +++- .../server/src/server/worktree/commands.ts | 86 ++++++++-- packages/server/src/utils/checkout-git.ts | 13 +- packages/server/src/utils/worktree.test.ts | 28 ++++ packages/server/src/utils/worktree.ts | 41 ++++- public-docs/worktrees.md | 4 +- 46 files changed, 1073 insertions(+), 55 deletions(-) diff --git a/docs/data-model.md b/docs/data-model.md index 2287935bdc..fb245ac448 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -383,16 +383,17 @@ emptied duplicate. Array of workspace records. A workspace is a specific working directory within a project. -| Field | Type | Description | -| ------------- | ----------------------------------------------- | ------------------------------ | -| `workspaceId` | `string` | Primary key | -| `projectId` | `string` | FK to Project.projectId | -| `cwd` | `string` | Filesystem path | -| `kind` | `"local_checkout" \| "worktree" \| "directory"` | | -| `displayName` | `string` | | -| `createdAt` | `string` (ISO 8601) | | -| `updatedAt` | `string` (ISO 8601) | | -| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable | +| Field | Type | Description | +| --------------------- | ----------------------------------------------- | ---------------------------------------------------------------------- | +| `workspaceId` | `string` | Primary key | +| `projectId` | `string` | FK to Project.projectId | +| `cwd` | `string` | Filesystem path | +| `kind` | `"local_checkout" \| "worktree" \| "directory"` | | +| `displayName` | `string` | | +| `worktreeStoragePath` | `string \| null` | Optional local root for new Paseo-created worktrees for this workspace | +| `createdAt` | `string` (ISO 8601) | | +| `updatedAt` | `string` (ISO 8601) | | +| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable | --- diff --git a/packages/app/src/components/sidebar-workspace-list.test.tsx b/packages/app/src/components/sidebar-workspace-list.test.tsx index cf38bc4934..602e83c61f 100644 --- a/packages/app/src/components/sidebar-workspace-list.test.tsx +++ b/packages/app/src/components/sidebar-workspace-list.test.tsx @@ -91,6 +91,7 @@ function workspace(input: { name: input.name, status: input.status ?? "done", archivingAt: null, + worktreeStoragePath: null, diffStat: null, scripts: input.scripts ?? [], }; diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index 5792fade9d..6b8ac7f8ff 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -85,11 +85,12 @@ async function callWorkspaceCreation({ }: { creationMethod: "create_worktree" | "open_project"; connectedClient: DaemonClient; - input: { cwd: string }; + input: { cwd: string; worktreeStoragePath?: string | null }; }) { if (creationMethod === "create_worktree") { return connectedClient.createPaseoWorktree({ cwd: input.cwd, + ...(input.worktreeStoragePath ? { worktreeStoragePath: input.worktreeStoragePath } : {}), worktreeSlug: createNameId(), }); } @@ -158,6 +159,17 @@ export function WorkspaceSetupDialog() { const workspace = createdWorkspace; const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); + const worktreeStoragePath = useSessionStore((state) => { + if (!pendingWorkspaceSetup) { + return null; + } + const sourceWorkspace = pendingWorkspaceSetup.sourceWorkspaceId + ? state.sessions[pendingWorkspaceSetup.serverId]?.workspaces.get( + pendingWorkspaceSetup.sourceWorkspaceId, + ) + : null; + return sourceWorkspace?.worktreeStoragePath ?? null; + }); const chatDraft = useAgentInputDraft({ draftKey: `workspace-setup:${serverId}:${sourceDirectory}`, composer: buildChatDraftComposerArgs({ @@ -237,7 +249,7 @@ export function WorkspaceSetupDialog() { const payload = await callWorkspaceCreation({ creationMethod: pendingWorkspaceSetup.creationMethod, connectedClient, - input, + input: { ...input, worktreeStoragePath }, }); if (payload.error || !payload.workspace) { @@ -260,6 +272,7 @@ export function WorkspaceSetupDialog() { pendingWorkspaceSetup, setHasHydratedWorkspaces, withConnectedClient, + worktreeStoragePath, ], ); diff --git a/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx index 86e2ac3e8b..af3518464a 100644 --- a/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx +++ b/packages/app/src/components/workspace-shortcut-targets-subscriber.test.tsx @@ -32,6 +32,7 @@ function workspaceDescriptor(input: { name: input.name ?? input.id, status: "done", archivingAt: null, + worktreeStoragePath: null, diffStat: null, scripts: [], }; diff --git a/packages/app/src/contexts/session-context.service-status.test.ts b/packages/app/src/contexts/session-context.service-status.test.ts index 4365d2c252..b58b0b52d2 100644 --- a/packages/app/src/contexts/session-context.service-status.test.ts +++ b/packages/app/src/contexts/session-context.service-status.test.ts @@ -19,6 +19,7 @@ function workspace(input: { name: "main", status: "running", archivingAt: null, + worktreeStoragePath: null, diffStat: null, scripts: input.scripts ?? [], }; diff --git a/packages/app/src/contexts/session-workspace-upserts.test.ts b/packages/app/src/contexts/session-workspace-upserts.test.ts index a976ef8408..6b2945f245 100644 --- a/packages/app/src/contexts/session-workspace-upserts.test.ts +++ b/packages/app/src/contexts/session-workspace-upserts.test.ts @@ -18,6 +18,7 @@ const baseWorkspace: WorkspaceDescriptor = { name: "feature", status: "done", archivingAt: "2026-04-30T00:00:00.000Z", + worktreeStoragePath: null, diffStat: null, scripts: [], }; diff --git a/packages/app/src/git/actions-store.test.ts b/packages/app/src/git/actions-store.test.ts index b42e01da4f..ba79b1397c 100644 --- a/packages/app/src/git/actions-store.test.ts +++ b/packages/app/src/git/actions-store.test.ts @@ -43,6 +43,7 @@ function workspace(input: Partial & Pick { + const routeContext = useSessionStore((state) => { if (!serverId || !workspaceId) { return null; } @@ -22,7 +22,10 @@ export function useActiveWorktreeNewAction() { if (!workspace || workspace.projectKind !== "git") { return null; } - return workspace.projectRootPath; + return { + workingDir: workspace.projectRootPath, + projectId: workspace.projectId, + }; }); const displayName = useSessionStore((state) => { @@ -37,21 +40,22 @@ export function useActiveWorktreeNewAction() { }); const handle = useCallback(() => { - if (!serverId || !workingDir) { + if (!serverId || !routeContext) { return false; } router.navigate( - buildHostNewWorkspaceRoute(serverId, workingDir, { + buildHostNewWorkspaceRoute(serverId, routeContext.workingDir, { displayName: displayName ?? undefined, + projectId: routeContext.projectId, }) as never, ); return true; - }, [serverId, workingDir, displayName]); + }, [serverId, routeContext, displayName]); useKeyboardActionHandler({ handlerId: "worktree-new-active", actions: WORKTREE_NEW_ACTIONS, - enabled: serverId !== null && workingDir !== null, + enabled: serverId !== null && routeContext !== null, priority: 0, handle, }); diff --git a/packages/app/src/hooks/use-open-project.test.ts b/packages/app/src/hooks/use-open-project.test.ts index b291dad7a7..708d690c0b 100644 --- a/packages/app/src/hooks/use-open-project.test.ts +++ b/packages/app/src/hooks/use-open-project.test.ts @@ -16,6 +16,7 @@ function buildWorkspacePayload() { workspaceKind: "checkout" as const, name: "project", archivingAt: null, + worktreeStoragePath: null, status: "done" as const, activityAt: null, diffStat: null, diff --git a/packages/app/src/hooks/use-projects.test.ts b/packages/app/src/hooks/use-projects.test.ts index 0a0d363857..ae5419bd53 100644 --- a/packages/app/src/hooks/use-projects.test.ts +++ b/packages/app/src/hooks/use-projects.test.ts @@ -83,6 +83,7 @@ function workspace(input: { workspaceKind: "local_checkout", name: input.id, archivingAt: null, + worktreeStoragePath: null, status: "done", activityAt: null, diffStat: null, diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index cc0971dd7d..c1515144c0 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -534,6 +534,21 @@ export function NewWorkspaceScreen({ const isPending = pendingAction !== null; const client = useHostRuntimeClient(serverId); const isConnected = useHostRuntimeIsConnected(serverId); + const worktreeStoragePath = useSessionStore((state) => { + const session = state.sessions[serverId]; + if (!session) { + return null; + } + const workspaces = Array.from(session.workspaces.values()); + const sourceWorkspace = + workspaces.find( + (entry) => entry.projectId === projectId && entry.projectRootPath === sourceDirectory, + ) ?? + workspaces.find((entry) => entry.projectRootPath === sourceDirectory) ?? + workspaces.find((entry) => entry.workspaceDirectory === sourceDirectory) ?? + null; + return sourceWorkspace?.worktreeStoragePath ?? null; + }); const draftKey = `new-workspace:${serverId}:${sourceDirectory}`; const chatDraft = useAgentInputDraft({ draftKey, @@ -718,6 +733,7 @@ export function NewWorkspaceScreen({ return { cwd: input.cwd, ...(projectId ? { projectId } : {}), + ...(worktreeStoragePath ? { worktreeStoragePath } : {}), worktreeSlug: createNameId(), ...(hasFirstAgentContext ? { @@ -730,7 +746,7 @@ export function NewWorkspaceScreen({ ...checkoutRequest, }; }, - [currentBranch, projectId, selectedItem], + [currentBranch, projectId, selectedItem, worktreeStoragePath], ); const ensureWorkspace = useCallback( diff --git a/packages/app/src/screens/project-settings-screen.tsx b/packages/app/src/screens/project-settings-screen.tsx index 977eeed63c..422513ed13 100644 --- a/packages/app/src/screens/project-settings-screen.tsx +++ b/packages/app/src/screens/project-settings-screen.tsx @@ -3,7 +3,16 @@ import { Pressable, Text, TextInput, View } from "react-native"; import { router } from "expo-router"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ArrowLeft, Check, ChevronDown, MoreVertical, Pencil, Plus, X } from "lucide-react-native"; +import { + ArrowLeft, + Check, + ChevronDown, + FolderOpen, + MoreVertical, + Pencil, + Plus, + X, +} from "lucide-react-native"; import { ProjectIconView } from "@/components/project-icon-view"; import { projectIconToDataUri, useProjectIconQuery } from "@/hooks/use-project-icon-query"; import type { @@ -28,6 +37,8 @@ import { SettingsTextAreaCard } from "@/components/settings-textarea"; import { SettingsGroup } from "@/screens/settings/settings-group"; import { SettingsSection } from "@/screens/settings/settings-section"; import { settingsStyles } from "@/styles/settings"; +import { getIsElectron } from "@/constants/platform"; +import { pickDirectory } from "@/desktop/pick-directory"; import { useProjects } from "@/hooks/use-projects"; import { useHostRuntimeClient, useHostRuntimeSnapshot } from "@/runtime/host-runtime"; import { useToast } from "@/contexts/toast-context"; @@ -335,6 +346,7 @@ function renderContent({ baseConfig={loadedConfig} revision={loadedRevision} repoRoot={selectedHost.repoRoot} + selectedHost={selectedHost} queryKey={queryKey} client={client} onReload={onReload} @@ -416,6 +428,7 @@ interface ProjectConfigFormProps { queryKey: readonly [string, string, string]; client: DaemonClient; onReload: () => void; + selectedHost: ProjectHostEntry; } function ProjectConfigForm({ @@ -425,6 +438,7 @@ function ProjectConfigForm({ queryKey, client, onReload, + selectedHost, }: ProjectConfigFormProps) { const queryClient = useQueryClient(); const toast = useToast(); @@ -621,6 +635,8 @@ function ProjectConfigForm({ return ( + + { + setValue(host.worktreeStoragePath ?? ""); + }, [host.worktreeStoragePath]); + + const mutation = useMutation({ + mutationFn: (worktreeStoragePath: string | null) => + client.setWorkspaceWorktreeStoragePath(host.workspaceId, worktreeStoragePath), + onSuccess: (result) => { + setValue(result.worktreeStoragePath ?? ""); + queryClient.invalidateQueries({ queryKey: ["projects"] }); + toast.show("Worktree storage saved", { variant: "success" }); + }, + onError: (error) => { + const message = error instanceof Error ? error.message : "Couldn't save worktree storage"; + toast.show(message, { variant: "error" }); + }, + }); + + const handleSave = useCallback(() => { + const trimmed = value.trim(); + const next = trimmed.length === 0 ? null : trimmed; + if (next === (host.worktreeStoragePath ?? null)) { + return; + } + mutation.mutate(next); + }, [value, host.worktreeStoragePath, mutation]); + + const handleReset = useCallback(() => { + setValue(""); + mutation.mutate(null); + }, [mutation]); + + const handleChooseDirectory = useCallback(async () => { + const directory = await pickDirectory(); + if (!directory) { + return; + } + setValue(directory); + mutation.mutate(directory); + }, [mutation]); + + const canPickDirectory = getIsElectron(); + const unchanged = value.trim() === (host.worktreeStoragePath ?? ""); + + return ( + + + + + + {canPickDirectory ? ( + + ) : null} + + {host.worktreeStoragePath ? ( + + ) : null} + + + + + ); +} + interface ProjectNameEditorProps { project: ProjectSummary; client: DaemonClient; @@ -1244,6 +1380,25 @@ const styles = StyleSheet.create((theme) => ({ alignSelf: "flex-start", paddingHorizontal: 0, }, + storageRow: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingVertical: theme.spacing[3], + paddingHorizontal: theme.spacing[4], + }, + storageInput: { + flex: 1, + color: theme.colors.foreground, + fontSize: theme.fontSize.sm, + paddingVertical: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + borderRadius: theme.borderRadius.md, + borderWidth: 1, + borderColor: theme.colors.border, + backgroundColor: theme.colors.surface2, + minWidth: 0, + }, headerBlock: { marginTop: theme.spacing[2], marginBottom: theme.spacing[4], diff --git a/packages/app/src/screens/projects-screen.test.tsx b/packages/app/src/screens/projects-screen.test.tsx index fbefe63b45..79a5be840d 100644 --- a/packages/app/src/screens/projects-screen.test.tsx +++ b/packages/app/src/screens/projects-screen.test.tsx @@ -202,6 +202,7 @@ function workspaceSummary(overrides: Partial = {}): WorkspaceS workspaceKind: "directory", status: "done", currentBranch: "main", + worktreeStoragePath: null, ...overrides, }; } @@ -211,9 +212,11 @@ function hostEntry(overrides: Partial = {}): ProjectHostEntry serverId: "host-a", serverName: "alpha", isOnline: true, + workspaceId: "ws-1", repoRoot: "/home/me/proj", workspaceCount: 1, workspaces: [workspaceSummary()], + worktreeStoragePath: null, ...overrides, }; } diff --git a/packages/app/src/screens/workspace/workspace-route-state.test.ts b/packages/app/src/screens/workspace/workspace-route-state.test.ts index bd3ba6a294..8f2249f426 100644 --- a/packages/app/src/screens/workspace/workspace-route-state.test.ts +++ b/packages/app/src/screens/workspace/workspace-route-state.test.ts @@ -15,6 +15,7 @@ function createWorkspaceDescriptor(): WorkspaceDescriptor { status: "running", diffStat: null, scripts: [], + worktreeStoragePath: null, archivingAt: null, }; } diff --git a/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts b/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts index b90a9eb170..0a75fe39ef 100644 --- a/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts +++ b/packages/app/src/screens/workspace/workspace-source-of-truth.test.ts @@ -25,6 +25,7 @@ function createWorkspaceDescriptor(input: Partial = {}): Wo status: "running", diffStat: null, scripts: [], + worktreeStoragePath: input.worktreeStoragePath ?? null, ...input, archivingAt: input.archivingAt ?? null, }; diff --git a/packages/app/src/stores/session-store-hooks/selectors.test.ts b/packages/app/src/stores/session-store-hooks/selectors.test.ts index 9f40124823..257eb945db 100644 --- a/packages/app/src/stores/session-store-hooks/selectors.test.ts +++ b/packages/app/src/stores/session-store-hooks/selectors.test.ts @@ -35,6 +35,7 @@ function createWorkspace( name: input.name ?? "main", status: input.status ?? "done", archivingAt: input.archivingAt ?? null, + worktreeStoragePath: input.worktreeStoragePath ?? null, diffStat: input.diffStat ?? null, scripts: input.scripts ?? [], }; diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index ef64c124eb..83e40ad402 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -24,6 +24,7 @@ function createWorkspace( name: input.name ?? "main", status: input.status ?? "done", archivingAt: input.archivingAt ?? null, + worktreeStoragePath: input.worktreeStoragePath ?? null, diffStat: input.diffStat ?? null, scripts: input.scripts ?? [], }; @@ -75,6 +76,7 @@ describe("normalizeWorkspaceDescriptor", () => { workspaceKind: "checkout", name: "main", archivingAt: null, + worktreeStoragePath: null, status: "running", activityAt: "not-a-date", diffStat: null, @@ -108,6 +110,7 @@ describe("normalizeWorkspaceDescriptor", () => { workspaceKind: "checkout", name: "main", archivingAt: null, + worktreeStoragePath: null, status: "done", activityAt: null, diffStat: null, @@ -140,6 +143,48 @@ describe("normalizeWorkspaceDescriptor", () => { expect(workspace.archivingAt).toBeNull(); }); + it("defaults missing worktreeStoragePath to null", () => { + const payload = { + id: "1", + projectId: "1", + projectDisplayName: "Project 1", + projectRootPath: "/repo", + workspaceDirectory: "/repo", + projectKind: "git", + workspaceKind: "checkout", + name: "main", + status: "done", + activityAt: null, + diffStat: null, + scripts: [], + } as unknown as WorkspaceDescriptorPayload; + + const workspace = normalizeWorkspaceDescriptor(payload); + + expect(workspace.worktreeStoragePath).toBeNull(); + }); + + it("preserves worktreeStoragePath from workspace descriptor payloads", () => { + const workspace = normalizeWorkspaceDescriptor({ + id: "1", + projectId: "1", + projectDisplayName: "Project 1", + projectRootPath: "/repo", + workspaceDirectory: "/repo", + projectKind: "git", + workspaceKind: "checkout", + name: "main", + archivingAt: null, + worktreeStoragePath: "/tmp/paseo-worktrees/repo", + status: "done", + activityAt: null, + diffStat: null, + scripts: [], + }); + + expect(workspace.worktreeStoragePath).toBe("/tmp/paseo-worktrees/repo"); + }); + it("preserves project placement from workspace descriptor payloads", () => { const workspace = normalizeWorkspaceDescriptor({ id: "1", @@ -151,6 +196,7 @@ describe("normalizeWorkspaceDescriptor", () => { workspaceKind: "local_checkout", name: "main", archivingAt: null, + worktreeStoragePath: null, status: "done", activityAt: null, diffStat: null, diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index ea7def716e..6201ea353f 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -127,6 +127,7 @@ export interface WorkspaceDescriptor { name: string; status: WorkspaceDescriptorPayload["status"]; archivingAt: string | null; + worktreeStoragePath: string | null; diffStat: { additions: number; deletions: number } | null; scripts: WorkspaceDescriptorPayload["scripts"]; gitRuntime?: WorkspaceDescriptorPayload["gitRuntime"]; @@ -149,6 +150,7 @@ export function normalizeWorkspaceDescriptor( name: payload.name, status: payload.status, archivingAt: payload.archivingAt ?? null, + worktreeStoragePath: payload.worktreeStoragePath ?? null, diffStat: payload.diffStat ?? null, scripts: (payload.scripts ?? []).map((s) => Object.assign({}, s)), gitRuntime: payload.gitRuntime, diff --git a/packages/app/src/utils/navigate-to-agent/resolve.test.ts b/packages/app/src/utils/navigate-to-agent/resolve.test.ts index de61b33bee..fc3ce70a64 100644 --- a/packages/app/src/utils/navigate-to-agent/resolve.test.ts +++ b/packages/app/src/utils/navigate-to-agent/resolve.test.ts @@ -23,6 +23,7 @@ function createWorkspace(): WorkspaceDescriptor { name: "worktree", status: "done", archivingAt: null, + worktreeStoragePath: null, diffStat: null, scripts: [], }; diff --git a/packages/app/src/utils/projects.test.ts b/packages/app/src/utils/projects.test.ts index 203fd7174e..9abc94c0c5 100644 --- a/packages/app/src/utils/projects.test.ts +++ b/packages/app/src/utils/projects.test.ts @@ -32,6 +32,7 @@ function workspace(input: { projectId?: string; projectName?: string; remoteUrl?: string | null; + worktreeStoragePath?: string | null; }): WorkspaceDescriptor { return { id: input.id, @@ -44,6 +45,7 @@ function workspace(input: { name: input.id, status: "done", archivingAt: null, + worktreeStoragePath: input.worktreeStoragePath ?? null, diffStat: null, scripts: [], gitRuntime: { @@ -148,6 +150,39 @@ describe("buildProjects", () => { expect(laptop?.workspaces.map((entry) => entry.id)).toEqual(["main", "feature"]); }); + it("carries the canonical workspace worktree storage path to the host entry", () => { + const result = buildProjects({ + hosts: [ + { + serverId: "local", + serverName: "MacBook", + isOnline: true, + workspaces: [ + workspace({ + id: "main", + repoRoot: "/repo", + project: placement({ + projectKey: "remote:github.com/acme/repo", + projectName: "acme/repo", + cwd: "/repo", + remoteUrl: "git@github.com:acme/repo.git", + }), + worktreeStoragePath: "/tmp/paseo-worktrees/acme-repo", + }), + ], + }, + ], + }); + + expect(result.projects[0]?.hosts[0]?.workspaceId).toBe("main"); + expect(result.projects[0]?.hosts[0]?.worktreeStoragePath).toBe( + "/tmp/paseo-worktrees/acme-repo", + ); + expect(result.projects[0]?.hosts[0]?.workspaces[0]?.worktreeStoragePath).toBe( + "/tmp/paseo-worktrees/acme-repo", + ); + }); + it("collapses five workspaces on one host into a single host entry whose workspaceCount is five", () => { const result = buildProjects({ hosts: [ diff --git a/packages/app/src/utils/projects.ts b/packages/app/src/utils/projects.ts index cedbd7afdf..ebb21a181d 100644 --- a/packages/app/src/utils/projects.ts +++ b/packages/app/src/utils/projects.ts @@ -6,15 +6,18 @@ export interface WorkspaceSummary { workspaceKind: WorkspaceDescriptor["workspaceKind"]; status: WorkspaceDescriptor["status"]; currentBranch: string | null; + worktreeStoragePath: string | null; } export interface ProjectHostEntry { serverId: string; serverName: string; isOnline: boolean; + workspaceId: string; repoRoot: string; workspaceCount: number; workspaces: WorkspaceSummary[]; + worktreeStoragePath: string | null; gitRuntime?: WorkspaceDescriptor["gitRuntime"]; githubRuntime?: WorkspaceDescriptor["githubRuntime"]; } @@ -86,6 +89,7 @@ function toWorkspaceSummary(workspace: WorkspaceDescriptor): WorkspaceSummary { workspaceKind: workspace.workspaceKind, status: workspace.status, currentBranch: workspace.gitRuntime?.currentBranch ?? null, + worktreeStoragePath: workspace.worktreeStoragePath, }; } @@ -97,10 +101,12 @@ function toHostEntry(group: HostGroup): ProjectHostEntry { return { serverId: group.serverId, serverName: group.serverName, + workspaceId: canonical?.id ?? "", isOnline: group.isOnline, repoRoot, workspaceCount: group.workspaces.length, workspaces: group.workspaces.map(toWorkspaceSummary), + worktreeStoragePath: canonical?.worktreeStoragePath ?? null, gitRuntime: canonical?.gitRuntime, githubRuntime: canonical?.githubRuntime, }; diff --git a/packages/app/src/utils/workspace-archive-navigation.test.ts b/packages/app/src/utils/workspace-archive-navigation.test.ts index ed37fabc76..dd3549c457 100644 --- a/packages/app/src/utils/workspace-archive-navigation.test.ts +++ b/packages/app/src/utils/workspace-archive-navigation.test.ts @@ -21,6 +21,7 @@ function workspace( name: input.name ?? input.id, status: input.status ?? "done", archivingAt: input.archivingAt ?? null, + worktreeStoragePath: input.worktreeStoragePath ?? null, diffStat: input.diffStat ?? null, scripts: input.scripts ?? [], }; diff --git a/packages/app/src/utils/workspace-execution.test.ts b/packages/app/src/utils/workspace-execution.test.ts index 9d96e8f1e3..43a3b221cd 100644 --- a/packages/app/src/utils/workspace-execution.test.ts +++ b/packages/app/src/utils/workspace-execution.test.ts @@ -22,6 +22,7 @@ function createWorkspace( name: input.name ?? "main", status: input.status ?? "running", archivingAt: input.archivingAt ?? null, + worktreeStoragePath: input.worktreeStoragePath ?? null, diffStat: input.diffStat ?? null, scripts: input.scripts ?? [], }; diff --git a/packages/app/src/workspace/workspace-archive.test.ts b/packages/app/src/workspace/workspace-archive.test.ts index 265a8e1ff7..445099b43f 100644 --- a/packages/app/src/workspace/workspace-archive.test.ts +++ b/packages/app/src/workspace/workspace-archive.test.ts @@ -39,6 +39,7 @@ function workspace(input?: Partial): WorkspaceDescriptor { name: "workspace-1", status: "done", archivingAt: null, + worktreeStoragePath: input?.worktreeStoragePath ?? null, diffStat: null, scripts: [], ...input, diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 5ea7294a79..d235e655ed 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -1053,6 +1053,62 @@ test("sends worktree base-ref fields in create_paseo_worktree_request", async () }); }); +test("sends worktreeStoragePath in create_paseo_worktree_request when supplied", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const createPromise = client.createPaseoWorktree( + { + cwd: "/tmp/project", + worktreeSlug: "feature-a", + worktreeStoragePath: "/tmp/custom-worktrees/project", + }, + "req-worktree-storage", + ); + + expect(mock.sent).toHaveLength(1); + const request = parseSentFrame(mock.sent[0]); + expect(request).toEqual({ + type: "create_paseo_worktree_request", + cwd: "/tmp/project", + worktreeSlug: "feature-a", + worktreeStoragePath: "/tmp/custom-worktrees/project", + requestId: "req-worktree-storage", + }); + + mock.triggerMessage( + wrapSessionMessage({ + type: "create_paseo_worktree_response", + payload: { + requestId: request.requestId, + workspace: null, + error: "worktree storage path sentinel", + setupTerminalId: null, + }, + }), + ); + + await expect(createPromise).resolves.toEqual({ + requestId: request.requestId, + workspace: null, + error: "worktree storage path sentinel", + setupTerminalId: null, + }); +}); + test("omitting create_paseo_worktree_request worktree base-ref fields preserves legacy wire shape", async () => { const logger = createMockLogger(); const mock = createMockTransport(); @@ -1110,6 +1166,54 @@ test("omitting create_paseo_worktree_request worktree base-ref fields preserves }); }); +test("setWorkspaceWorktreeStoragePath sends workspace storage path request", async () => { + const logger = createMockLogger(); + const mock = createMockTransport(); + + const client = new DaemonClient({ + url: "ws://test", + clientId: "clsk_unit_test", + logger, + reconnect: { enabled: false }, + transportFactory: () => mock.transport, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen(); + await connectPromise; + + const updatePromise = client.setWorkspaceWorktreeStoragePath( + "/tmp/project", + "/tmp/custom-worktrees/project", + "req-set-storage", + ); + + expect(parseSentFrame(mock.sent[0])).toEqual({ + type: "workspace.set_worktree_storage_path.request", + workspaceId: "/tmp/project", + worktreeStoragePath: "/tmp/custom-worktrees/project", + requestId: "req-set-storage", + }); + + mock.triggerMessage( + wrapSessionMessage({ + type: "workspace.set_worktree_storage_path.response", + payload: { + requestId: "req-set-storage", + workspaceId: "/tmp/project", + accepted: true, + worktreeStoragePath: "/tmp/custom-worktrees/project", + error: null, + }, + }), + ); + + await expect(updatePromise).resolves.toEqual({ + worktreeStoragePath: "/tmp/custom-worktrees/project", + }); +}); + test("sends explicit shutdown_server_request via shutdownServer", async () => { const logger = createMockLogger(); const mock = createMockTransport(); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index a43ca6e09c..79d0f3616f 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -276,6 +276,7 @@ export interface CreatePaseoWorktreeInput extends Pick< | "refName" | "action" | "githubPrNumber" + | "worktreeStoragePath" > {} type CheckoutStatusPayload = CheckoutStatusResponse["payload"]; @@ -310,6 +311,10 @@ type CreatePaseoWorktreePayload = Extract< SessionOutboundMessage, { type: "create_paseo_worktree_response" } >["payload"]; +type WorkspaceSetWorktreeStoragePathPayload = Extract< + SessionOutboundMessage, + { type: "workspace.set_worktree_storage_path.response" } +>["payload"]; type FileExplorerPayload = FileExplorerResponse["payload"]; export type FileExplorerDirectoryPayload = NonNullable; type LegacyFileExplorerFilePayload = NonNullable; @@ -2056,6 +2061,29 @@ export class DaemonClient { return { customName: payload.customName }; } + async setWorkspaceWorktreeStoragePath( + workspaceId: string, + worktreeStoragePath: string | null, + requestId?: string, + ): Promise<{ worktreeStoragePath: string | null }> { + const payload: WorkspaceSetWorktreeStoragePathPayload = await this.sendCorrelatedSessionRequest( + { + requestId, + message: { + type: "workspace.set_worktree_storage_path.request", + workspaceId, + worktreeStoragePath, + }, + responseType: "workspace.set_worktree_storage_path.response", + timeout: 10000, + }, + ); + if (!payload.accepted) { + throw new Error(payload.error ?? "setWorkspaceWorktreeStoragePath rejected"); + } + return { worktreeStoragePath: payload.worktreeStoragePath }; + } + async resumeAgent( handle: AgentPersistenceHandle, overrides?: Partial, @@ -3176,6 +3204,9 @@ export class DaemonClient { ...(input.refName !== undefined ? { refName: input.refName } : {}), ...(input.action !== undefined ? { action: input.action } : {}), ...(input.githubPrNumber !== undefined ? { githubPrNumber: input.githubPrNumber } : {}), + ...(input.worktreeStoragePath !== undefined + ? { worktreeStoragePath: input.worktreeStoragePath } + : {}), }, responseType: "create_paseo_worktree_response", timeout: 60000, diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index fb6303b5c4..5ab75c0889 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -775,6 +775,13 @@ export const ProjectRenameRequestSchema = z.object({ requestId: z.string(), }); +export const WorkspaceSetWorktreeStoragePathRequestSchema = z.object({ + type: z.literal("workspace.set_worktree_storage_path.request"), + workspaceId: z.string(), + worktreeStoragePath: z.string().nullable(), + requestId: z.string(), +}); + export const SetVoiceModeMessageSchema = z.object({ type: z.literal("set_voice_mode"), enabled: z.boolean(), @@ -1301,6 +1308,19 @@ export const ProjectRenameResponseSchema = z.object({ payload: ProjectRenameResponsePayloadSchema, }); +export const WorkspaceSetWorktreeStoragePathResponsePayloadSchema = z.object({ + requestId: z.string(), + workspaceId: z.string(), + accepted: z.boolean(), + worktreeStoragePath: z.string().nullable(), + error: z.string().nullable(), +}); + +export const WorkspaceSetWorktreeStoragePathResponseSchema = z.object({ + type: z.literal("workspace.set_worktree_storage_path.response"), + payload: WorkspaceSetWorktreeStoragePathResponsePayloadSchema, +}); + export const SetVoiceModeResponseMessageSchema = z.object({ type: z.literal("set_voice_mode_response"), payload: z.object({ @@ -1560,6 +1580,7 @@ export const CreatePaseoWorktreeRequestSchema = z.object({ refName: z.string().min(1).optional(), action: z.enum(["branch-off", "checkout"]).optional(), githubPrNumber: z.number().int().positive().optional(), + worktreeStoragePath: z.string().nullable().optional(), requestId: z.string(), }); @@ -1863,6 +1884,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ CloseItemsRequestMessageSchema, UpdateAgentRequestMessageSchema, ProjectRenameRequestSchema, + WorkspaceSetWorktreeStoragePathRequestSchema, SetVoiceModeMessageSchema, SendAgentMessageRequestSchema, WaitForFinishRequestSchema, @@ -2412,6 +2434,7 @@ export const WorkspaceDescriptorPayloadSchema = z workspaceKind: z.enum(["directory", "local_checkout", "checkout", "worktree"]), name: z.string(), archivingAt: z.string().nullable().optional().default(null), + worktreeStoragePath: z.string().nullable().optional().default(null), status: WorkspaceStateBucketSchema, activityAt: z.string().nullable(), diffStat: z @@ -3704,6 +3727,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ AgentRewindResponseMessageSchema, UpdateAgentResponseMessageSchema, ProjectRenameResponseSchema, + WorkspaceSetWorktreeStoragePathResponseSchema, WaitForFinishResponseMessageSchema, AgentPermissionRequestMessageSchema, AgentPermissionResolvedMessageSchema, @@ -3846,6 +3870,12 @@ export type AgentRewindResponseMessage = z.infer; export type ProjectRenameResponse = z.infer; export type ProjectRenameResponsePayload = z.infer; +export type WorkspaceSetWorktreeStoragePathResponse = z.infer< + typeof WorkspaceSetWorktreeStoragePathResponseSchema +>; +export type WorkspaceSetWorktreeStoragePathResponsePayload = z.infer< + typeof WorkspaceSetWorktreeStoragePathResponsePayloadSchema +>; export type WaitForFinishResponseMessage = z.infer; export type AgentPermissionRequestMessage = z.infer; export type AgentPermissionResolvedMessage = z.infer; @@ -3960,6 +3990,9 @@ export type ResumeAgentRequestMessage = z.infer; export type UpdateAgentRequestMessage = z.infer; export type ProjectRenameRequest = z.infer; +export type WorkspaceSetWorktreeStoragePathRequest = z.infer< + typeof WorkspaceSetWorktreeStoragePathRequestSchema +>; export type SetAgentModeRequestMessage = z.infer; export type SetAgentModelRequestMessage = z.infer; export type SetAgentThinkingRequestMessage = z.infer; diff --git a/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts index 7f3ba8a39b..d757717770 100644 --- a/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts +++ b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts @@ -9,6 +9,7 @@ import type { CreatePaseoWorktreeWorkflowResult, } from "../worktree-session.js"; import type { WorkspaceGitService } from "../workspace-git-service.js"; +import type { WorkspaceRegistry } from "../workspace-registry.js"; import type { CreateAgentWorktreeTarget, FirstAgentContext, @@ -23,6 +24,7 @@ interface CreateAgentLifecycleDispatchDependencies { agentStorage: AgentStorage; github: GitHubService; workspaceGitService: WorkspaceGitService; + workspaceRegistry: Pick; createPaseoWorktreeWorkflow: CreatePaseoWorktreeWorkflowFn; archiveAgentForClose: (agentId: string) => Promise; archiveWorkspaceRecord: (workspaceId: string) => Promise; @@ -192,7 +194,10 @@ export class CreateAgentLifecycleDispatch { const ownership = await isPaseoOwnedWorktreeCwd(options.worktreePath, { paseoHome: this.dependencies.paseoHome, }); - if (!ownership.allowed) { + const persistedOwnership = ownership.allowed + ? ownership + : await this.resolvePersistedWorktreeOwnership(options); + if (!persistedOwnership.allowed) { throw new Error("Auto-created worktree is not a Paseo-owned worktree"); } @@ -213,8 +218,9 @@ export class CreateAgentLifecycleDispatch { }, { targetPath: options.worktreePath, - repoRoot: options.repoRoot ?? ownership.repoRoot ?? null, - worktreesRoot: ownership.worktreeRoot, + repoRoot: options.repoRoot ?? persistedOwnership.repoRoot ?? null, + resolveWorktreeRoot: persistedOwnership.worktreeRoot === undefined, + worktreesRoot: persistedOwnership.worktreeRoot, requestId: randomUUID(), }, ); @@ -223,4 +229,18 @@ export class CreateAgentLifecycleDispatch { this.dependencies.emitAgentRemove(options.agentId); } } + + private async resolvePersistedWorktreeOwnership(options: { worktreePath: string }) { + const workspaces = await this.dependencies.workspaceRegistry.list(); + const workspace = workspaces.find( + (record) => + record.kind === "worktree" && !record.archivedAt && record.cwd === options.worktreePath, + ); + if (!workspace) { + return { allowed: false as const }; + } + return isPaseoOwnedWorktreeCwd(options.worktreePath, { + worktreesRoot: workspace.worktreeStoragePath ?? undefined, + }); + } } diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 4d4e1ccaab..4829925f6d 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -72,6 +72,7 @@ import { } from "./lifecycle-command.js"; import type { GitHubService } from "../../services/github-service.js"; import type { WorkspaceGitService } from "../workspace-git-service.js"; +import type { WorkspaceRegistry } from "../workspace-registry.js"; import { WorktreeRequestError } from "../worktree-errors.js"; import { archivePaseoWorktreeCommand, @@ -93,6 +94,7 @@ export interface AgentMcpServerOptions { WorkspaceGitService, "getSnapshot" | "listWorktrees" | "resolveRepoRoot" >; + workspaceRegistry?: Pick; archiveWorkspaceRecord?: ArchivePaseoWorktreeDependencies["archiveWorkspaceRecord"]; emitWorkspaceUpdatesForWorkspaceIds?: ArchivePaseoWorktreeDependencies["emitWorkspaceUpdatesForWorkspaceIds"]; markWorkspaceArchiving?: ArchivePaseoWorktreeDependencies["markWorkspaceArchiving"]; @@ -2363,6 +2365,7 @@ function archiveWorktreeDependencies( paseoHome: options.paseoHome, github: options.github, workspaceGitService: options.workspaceGitService, + workspaceRegistry: options.workspaceRegistry, agentManager: context.agentManager, agentStorage: context.agentStorage, archiveWorkspaceRecord: options.archiveWorkspaceRecord, diff --git a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts index 6332ce9e13..408fe52444 100644 --- a/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts +++ b/packages/server/src/server/auto-archive-on-merge/archive-if-safe.ts @@ -9,6 +9,7 @@ import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitServiceImpl, } from "../workspace-git-service.js"; +import type { WorkspaceRegistry } from "../workspace-registry.js"; import type { GitHubService } from "../../services/github-service.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js"; import { isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js"; @@ -17,6 +18,7 @@ export interface AutoArchiveArchiveOptions { paseoHome: string; daemonConfigStore: DaemonConfigStore; workspaceGitService: WorkspaceGitServiceImpl; + workspaceRegistry: Pick; github: GitHubService; agentManager: AgentManager; agentStorage: AgentStorage; @@ -81,7 +83,12 @@ export async function archiveIfSafe(input: { return; } - const ownership = await deps.isPaseoOwnedWorktreeCwd(cwd, { paseoHome: options.paseoHome }); + const ownership = await resolveArchiveOwnership({ + cwd, + paseoHome: options.paseoHome, + workspaceRegistry: options.workspaceRegistry, + deps, + }); if (!ownership.allowed) { return; } @@ -115,6 +122,7 @@ export async function archiveIfSafe(input: { targetPath: cwd, repoRoot: ownership.repoRoot ?? null, worktreesRoot: ownership.worktreeRoot, + resolveWorktreeRoot: ownership.worktreeRoot === undefined, requestId: "auto-archive-on-merge", }, ); @@ -126,3 +134,28 @@ export async function archiveIfSafe(input: { inFlight.delete(cwd); } } + +async function resolveArchiveOwnership(input: { + cwd: string; + paseoHome: string; + workspaceRegistry: Pick; + deps: ArchiveIfSafeDependencies; +}) { + const defaultOwnership = await input.deps.isPaseoOwnedWorktreeCwd(input.cwd, { + paseoHome: input.paseoHome, + }); + if (defaultOwnership.allowed) { + return defaultOwnership; + } + + const workspaces = await input.workspaceRegistry.list(); + const workspace = workspaces.find( + (record) => record.kind === "worktree" && !record.archivedAt && record.cwd === input.cwd, + ); + if (!workspace) { + return defaultOwnership; + } + return input.deps.isPaseoOwnedWorktreeCwd(input.cwd, { + worktreesRoot: workspace.worktreeStoragePath ?? undefined, + }); +} diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 68cd1174be..9435eed1da 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -518,6 +518,7 @@ export async function createPaseoDaemon( const workspaceGitService = new WorkspaceGitServiceImpl({ logger, paseoHome: config.paseoHome, + workspaceRegistry, deps: { github, }, @@ -658,6 +659,7 @@ export async function createPaseoDaemon( setupAutoArchiveOnMerge({ paseoHome: config.paseoHome, daemonConfigStore, + workspaceRegistry, workspaceGitService, github, agentManager, @@ -686,6 +688,7 @@ export async function createPaseoDaemon( providerSnapshotManager, github, workspaceGitService, + workspaceRegistry, archiveWorkspaceRecord: archiveWorkspaceRecordExternal, emitWorkspaceUpdatesForWorkspaceIds: emitWorkspaceUpdatesExternal, markWorkspaceArchiving: markWorkspaceArchivingExternal, diff --git a/packages/server/src/server/paseo-worktree-archive-service.ts b/packages/server/src/server/paseo-worktree-archive-service.ts index b3d4daf55e..e4d0583683 100644 --- a/packages/server/src/server/paseo-worktree-archive-service.ts +++ b/packages/server/src/server/paseo-worktree-archive-service.ts @@ -37,15 +37,18 @@ export async function archivePaseoWorktree( targetPath: string; repoRoot: string | null; worktreesRoot?: string; + resolveWorktreeRoot?: boolean; requestId: string; }, ): Promise { let targetPath = options.targetPath; - const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(targetPath, { - paseoHome: dependencies.paseoHome, - }); - if (resolvedWorktree) { - targetPath = resolvedWorktree.worktreePath; + if (options.resolveWorktreeRoot !== false) { + const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(targetPath, { + paseoHome: dependencies.paseoHome, + }); + if (resolvedWorktree) { + targetPath = resolvedWorktree.worktreePath; + } } const archivedAgents = new Set(); diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index 0ee8bdde9e..73a1a3e759 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, expect, test, vi } from "vitest"; @@ -66,6 +66,46 @@ test("creates a worktree and registers it in the source workspace project withou ]); }); +test("creates a worktree under the source workspace storage path", async () => { + const { repoDir, tempDir } = createGitRepo(); + cleanupPaths.push(tempDir); + const deps = createDeps(); + const storagePath = path.join(tempDir, "custom-worktrees"); + const resolvedStoragePath = path.join( + realpathSync(path.dirname(storagePath)), + path.basename(storagePath), + ); + const sourceProject = createPersistedProjectRecordForTest({ + projectId: "remote:github.com/acme/repo", + rootPath: repoDir, + displayName: "acme/repo", + }); + const sourceWorkspace = createPersistedWorkspaceRecordForTest({ + workspaceId: repoDir, + projectId: sourceProject.projectId, + cwd: repoDir, + kind: "local_checkout", + displayName: "main", + worktreeStoragePath: storagePath, + }); + deps.projects.set(sourceProject.projectId, sourceProject); + deps.workspaces.set(sourceWorkspace.workspaceId, sourceWorkspace); + + const result = await createPaseoWorktree( + { + cwd: repoDir, + worktreeSlug: "feature-one", + runSetup: false, + paseoHome: path.join(tempDir, ".paseo"), + }, + deps, + ); + + expect(result.worktree.worktreePath).toBe(path.join(resolvedStoragePath, "feature-one")); + expect(result.workspace.cwd).toBe(path.join(resolvedStoragePath, "feature-one")); + expect(result.workspace.worktreeStoragePath).toBe(storagePath); +}); + test("registers a new worktree in the existing root project after the main checkout workspace is removed", async () => { const { repoDir, tempDir } = createGitRepo(); cleanupPaths.push(tempDir); @@ -536,6 +576,7 @@ function createPersistedWorkspaceRecordForTest(input: { cwd: string; kind: PersistedWorkspaceRecord["kind"]; displayName: string; + worktreeStoragePath?: string | null; }): PersistedWorkspaceRecord { return { workspaceId: input.workspaceId, @@ -543,6 +584,7 @@ function createPersistedWorkspaceRecordForTest(input: { cwd: input.cwd, kind: input.kind, displayName: input.displayName, + worktreeStoragePath: input.worktreeStoragePath ?? null, createdAt: "2026-04-22T00:00:00.000Z", updatedAt: "2026-04-22T00:00:00.000Z", archivedAt: null, diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index ec75540712..afe6f9413d 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -9,9 +9,11 @@ import { import { deriveProjectGroupingName, normalizeWorkspaceId } from "./workspace-registry-model.js"; import { createWorktreeCore, + resolveWorktreeRepoRoot, type CreateWorktreeCoreDeps, type CreateWorktreeCoreInput, } from "./worktree-core.js"; +import { expandUserPath } from "./path-utils.js"; import { validateBranchSlug, type WorktreeConfig } from "../utils/worktree.js"; import { getCurrentBranch, localBranchExists, renameCurrentBranch } from "../utils/checkout-git.js"; import { @@ -58,13 +60,27 @@ export async function createPaseoWorktree( input: CreatePaseoWorktreeInput, deps: CreatePaseoWorktreeDeps, ): Promise { - const createdWorktree = await createWorktreeCore(input, deps); + const repoRoot = await resolveWorktreeRepoRoot(input, deps.workspaceGitService); + const sourceWorkspace = await findWorkspaceForSource({ + inputCwd: normalizeWorkspaceId(input.cwd), + repoRoot: normalizeWorkspaceId(repoRoot), + workspaceRegistry: deps.workspaceRegistry, + }); + const worktreeStoragePath = resolveRequestedWorktreeStoragePath( + input.worktreeStoragePath ?? sourceWorkspace?.worktreeStoragePath ?? null, + ); + const createdWorktree = await createWorktreeCore( + { ...input, cwd: repoRoot, worktreeStoragePath }, + deps, + ); maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree }); const workspace = await upsertWorkspaceForWorktree({ inputCwd: input.cwd, projectId: input.projectId, repoRoot: createdWorktree.repoRoot, worktree: createdWorktree.worktree, + sourceWorkspace, + worktreeStoragePath, deps, }); @@ -195,6 +211,8 @@ async function upsertWorkspaceForWorktree(options: { projectId?: string; repoRoot: string; worktree: WorktreeConfig; + sourceWorkspace: PersistedWorkspaceRecord | null; + worktreeStoragePath: string | null; deps: Pick; }): Promise { const normalizedCwd = normalizeWorkspaceId(options.worktree.worktreePath); @@ -209,6 +227,7 @@ async function upsertWorkspaceForWorktree(options: { projectId: options.projectId, repoRoot: normalizedRepoRoot, existingWorkspace, + sourceWorkspace: options.sourceWorkspace, deps: options.deps, }); const workspaceId = normalizedCwd; @@ -235,6 +254,7 @@ async function upsertWorkspaceForWorktree(options: { displayName: options.worktree.branchName || normalizedCwd, createdAt: existingWorkspace?.createdAt ?? now, updatedAt: now, + worktreeStoragePath: options.worktreeStoragePath, archivedAt: null, }); @@ -315,6 +335,7 @@ async function resolveSourceProjectForWorktree(options: { projectId?: string; repoRoot: string; existingWorkspace: PersistedWorkspaceRecord | null; + sourceWorkspace: PersistedWorkspaceRecord | null; deps: Pick; }): Promise { if (options.projectId) { @@ -326,6 +347,7 @@ async function resolveSourceProjectForWorktree(options: { const sourceWorkspace = options.existingWorkspace ?? + options.sourceWorkspace ?? (await findWorkspaceForSource({ inputCwd: options.inputCwd, repoRoot: options.repoRoot, @@ -346,6 +368,11 @@ async function resolveSourceProjectForWorktree(options: { }); } +function resolveRequestedWorktreeStoragePath(value: string | null): string | null { + const trimmed = value?.trim() ?? ""; + return trimmed.length === 0 ? null : expandUserPath(trimmed); +} + async function findWorkspaceForSource(options: { inputCwd: string; repoRoot: string; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 174cb5c419..946edfe669 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -207,6 +207,7 @@ import { import { validateBranchSlug } from "@getpaseo/protocol/branch-slug"; import { getProjectIcon } from "../utils/project-icon.js"; import { expandTilde } from "../utils/path.js"; +import { expandUserPath } from "./path-utils.js"; import { searchHomeDirectories, searchWorkspaceEntries } from "../utils/directory-suggestions.js"; import { toCheckoutError } from "./checkout-git-utils.js"; import { CheckoutDiffManager } from "./checkout-diff-manager.js"; @@ -934,6 +935,7 @@ export class Session { agentStorage: this.agentStorage, github: this.github, workspaceGitService: this.workspaceGitService, + workspaceRegistry: this.workspaceRegistry, createPaseoWorktreeWorkflow: (input, workflowOptions) => this.createPaseoWorktreeWorkflow(input, workflowOptions), archiveAgentForClose: (agentId) => this.archiveAgentForClose(agentId), @@ -2117,6 +2119,8 @@ export class Session { return this.handlePaseoWorktreeArchiveRequest(msg); case "create_paseo_worktree_request": return this.handleCreatePaseoWorktreeRequest(msg); + case "workspace.set_worktree_storage_path.request": + return this.handleWorkspaceSetWorktreeStoragePathRequest(msg); case "workspace_setup_status_request": return this.handleWorkspaceSetupStatusRequest(msg); case "list_available_editors_request": @@ -2610,6 +2614,67 @@ export class Session { }); } } + private async handleWorkspaceSetWorktreeStoragePathRequest( + msg: Extract, + ): Promise { + this.sessionLogger.info( + { workspaceId: msg.workspaceId, requestId: msg.requestId }, + "session: workspace.set_worktree_storage_path.request", + ); + + try { + const existing = await this.workspaceRegistry.get(msg.workspaceId); + if (!existing) { + this.emit({ + type: "workspace.set_worktree_storage_path.response", + payload: { + requestId: msg.requestId, + workspaceId: msg.workspaceId, + accepted: false, + worktreeStoragePath: null, + error: "Workspace not found", + }, + }); + return; + } + + const trimmed = msg.worktreeStoragePath?.trim() ?? ""; + const nextWorktreeStoragePath = trimmed.length === 0 ? null : expandUserPath(trimmed); + const updatedWorkspace = { + ...existing, + worktreeStoragePath: nextWorktreeStoragePath, + updatedAt: new Date().toISOString(), + }; + await this.workspaceRegistry.upsert(updatedWorkspace); + + this.emit({ + type: "workspace.set_worktree_storage_path.response", + payload: { + requestId: msg.requestId, + workspaceId: msg.workspaceId, + accepted: true, + worktreeStoragePath: nextWorktreeStoragePath, + error: null, + }, + }); + await this.emitWorkspaceUpdatesForWorkspaceIds([msg.workspaceId], { skipReconcile: true }); + } catch (error) { + this.sessionLogger.error( + { err: error, workspaceId: msg.workspaceId, requestId: msg.requestId }, + "session: workspace.set_worktree_storage_path.request error", + ); + this.emit({ + type: "workspace.set_worktree_storage_path.response", + payload: { + requestId: msg.requestId, + workspaceId: msg.workspaceId, + accepted: false, + worktreeStoragePath: null, + error: getErrorMessageOr(error, "Failed to set worktree storage path"), + }, + }); + } + } private toVoiceFeatureUnavailableContext( state: SpeechReadinessState, @@ -5700,6 +5765,7 @@ export class Session { emit: (message) => this.emit(message), paseoHome: this.paseoHome, workspaceGitService: this.workspaceGitService, + workspaceRegistry: this.workspaceRegistry, }, msg, ); @@ -5713,6 +5779,7 @@ export class Session { paseoHome: this.paseoHome, github: this.github, workspaceGitService: this.workspaceGitService, + workspaceRegistry: this.workspaceRegistry, agentManager: this.agentManager, agentStorage: this.agentStorage, archiveWorkspaceRecord: (workspaceId) => this.archiveWorkspaceRecord(workspaceId), @@ -6279,6 +6346,7 @@ export class Session { workspaceKind: workspace.kind, name: workspace.displayName, archivingAt: null, + worktreeStoragePath: workspace.worktreeStoragePath, status: "done", activityAt: null, diffStat, @@ -6370,6 +6438,7 @@ export class Session { workspaceKind: result.workspace.kind, name: result.worktree.branchName || result.workspace.displayName, archivingAt: null, + worktreeStoragePath: result.workspace.worktreeStoragePath, status: "done", activityAt: null, diffStat: { additions: 0, deletions: 0 }, diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 84dc8d8517..c754ec8633 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -4589,6 +4589,127 @@ test("project.rename.request stores customName and emits an updated workspace de }); }); +test("workspace.set_worktree_storage_path.request stores normalized path and emits descriptor", async () => { + const messages: SessionOutboundMessage[] = []; + const session = createSessionForWorkspaceTests({ + onMessage: (message) => messages.push(message), + }); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: REPO_CWD, + projectId: "remote:github.com/acme/repo", + cwd: REPO_CWD, + kind: "local_checkout", + displayName: "repo", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }); + const project = createPersistedProjectRecord({ + projectId: workspace.projectId, + rootPath: REPO_CWD, + kind: "git", + displayName: "acme/repo", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }); + const workspaces = new Map([[workspace.workspaceId, workspace]]); + session.projectRegistry.get = async () => project; + session.workspaceRegistry.get = async (workspaceId: string) => + workspaces.get(workspaceId) ?? null; + session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.upsert = async (record: unknown) => { + const next = createPersistedWorkspaceRecord( + record as Parameters[0], + ); + workspaces.set(next.workspaceId, next); + }; + + session.workspaceUpdatesSubscription = { + subscriptionId: "sub-workspaces", + filter: {}, + isBootstrapping: false, + lastEmittedByWorkspaceId: new Map(), + pendingUpdatesByWorkspaceId: new Map(), + }; + + await session.handleMessage({ + type: "workspace.set_worktree_storage_path.request", + workspaceId: REPO_CWD, + worktreeStoragePath: "~/paseo-worktrees/acme-repo", + requestId: "req-set-worktree-storage", + }); + + const updated = workspaces.get(REPO_CWD); + expect(updated?.worktreeStoragePath).toBe(path.resolve(homedir(), "paseo-worktrees/acme-repo")); + expect(messages).toContainEqual({ + type: "workspace.set_worktree_storage_path.response", + payload: { + requestId: "req-set-worktree-storage", + workspaceId: REPO_CWD, + accepted: true, + worktreeStoragePath: path.resolve(homedir(), "paseo-worktrees/acme-repo"), + error: null, + }, + }); + expect(messages).toContainEqual( + expect.objectContaining({ + type: "workspace_update", + payload: expect.objectContaining({ + kind: "upsert", + workspace: expect.objectContaining({ + id: REPO_CWD, + worktreeStoragePath: path.resolve(homedir(), "paseo-worktrees/acme-repo"), + }), + }), + }), + ); +}); + +test("workspace.set_worktree_storage_path.request clears blank path", async () => { + const messages: SessionOutboundMessage[] = []; + const session = createSessionForWorkspaceTests({ + onMessage: (message) => messages.push(message), + }); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: REPO_CWD, + projectId: "remote:github.com/acme/repo", + cwd: REPO_CWD, + kind: "local_checkout", + displayName: "repo", + worktreeStoragePath: "/tmp/custom-worktrees/acme-repo", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }); + const workspaces = new Map([[workspace.workspaceId, workspace]]); + session.workspaceRegistry.get = async (workspaceId: string) => + workspaces.get(workspaceId) ?? null; + session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.upsert = async (record: unknown) => { + const next = createPersistedWorkspaceRecord( + record as Parameters[0], + ); + workspaces.set(next.workspaceId, next); + }; + + await session.handleMessage({ + type: "workspace.set_worktree_storage_path.request", + workspaceId: REPO_CWD, + worktreeStoragePath: " ", + requestId: "req-clear-worktree-storage", + }); + + expect(workspaces.get(REPO_CWD)?.worktreeStoragePath).toBeNull(); + expect(messages).toContainEqual({ + type: "workspace.set_worktree_storage_path.response", + payload: { + requestId: "req-clear-worktree-storage", + workspaceId: REPO_CWD, + accepted: true, + worktreeStoragePath: null, + error: null, + }, + }); +}); + test("project.rename.request with whitespace-only customName clears the override", async () => { const emitted: SessionOutboundMessage[] = []; const session = asTestSession( diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index 1d7b5e374c..332594e416 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -38,6 +38,7 @@ import { buildWorkspaceGitMetadataFromSnapshot, type WorkspaceGitMetadata, } from "./workspace-git-metadata.js"; +import type { WorkspaceRegistry } from "./workspace-registry.js"; import { checkoutLiteFromGitSnapshot, normalizeWorkspaceId } from "./workspace-registry-model.js"; const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 500; @@ -143,7 +144,7 @@ export interface WorkspaceGitService { ): Promise; listWorktrees( cwdOrRepoRoot: string, - options?: WorkspaceGitReadOptions, + options?: WorkspaceGitReadOptions & { worktreeStoragePath?: string | null }, ): Promise; getWorkspaceGitMetadata( cwd: string, @@ -259,6 +260,7 @@ interface WorkspaceGitServiceDependencies { interface WorkspaceGitServiceOptions { logger: pino.Logger; paseoHome: string; + workspaceRegistry?: Pick; deps?: Partial; } @@ -348,6 +350,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { private readonly logger: pino.Logger; private readonly paseoHome: string; private readonly deps: WorkspaceGitServiceDependencies; + private readonly workspaceRegistry: Pick | null; private readonly snapshotUpdatedListeners = new Set(); private readonly workspaceTargets = new Map(); private readonly repoTargets = new Map(); @@ -385,6 +388,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { constructor(options: WorkspaceGitServiceOptions) { this.logger = options.logger.child({ module: "workspace-git-service" }); this.paseoHome = options.paseoHome; + this.workspaceRegistry = options.workspaceRegistry ?? null; this.deps = resolveWorkspaceGitServiceDeps(options.deps); } @@ -573,14 +577,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { async listWorktrees( cwdOrRepoRoot: string, - options?: WorkspaceGitReadOptions, + options?: WorkspaceGitReadOptions & { worktreeStoragePath?: string | null }, ): Promise { const repoRoot = await this.resolveRepoRoot(cwdOrRepoRoot, options); - const key = JSON.stringify(["worktrees", repoRoot]); + const key = JSON.stringify(["worktrees", repoRoot, options?.worktreeStoragePath ?? null]); return this.readAuxiliaryCache(this.worktreeListCache, key, options, () => this.deps.listPaseoWorktrees({ cwd: repoRoot, paseoHome: this.paseoHome, + worktreeStoragePath: options?.worktreeStoragePath, }), ); } @@ -1563,10 +1568,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { ): Promise { const now = this.deps.now(); target.lastShellOutAtMs = now.getTime(); + const previousGitHubPollKey = this.getGitHubPollKey(target); const cwd = target.cwd; - const previousGitHubPollKey = this.getGitHubPollKey(target); - const baseContext: CheckoutContext = { paseoHome: this.paseoHome, logger: this.logger }; + const worktreesRoot = await this.resolvePersistedWorktreeStorageRoot(cwd); + const baseContext: CheckoutContext = { + paseoHome: this.paseoHome, + logger: this.logger, + ...(worktreesRoot ? { worktreesRoot } : {}), + }; const facts = await this.loadCheckoutFacts(target, { ...baseContext, allowRecent: !request.force, @@ -1833,6 +1843,16 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } target.workspaceKeys.clear(); } + private async resolvePersistedWorktreeStorageRoot(cwd: string): Promise { + if (!this.workspaceRegistry) { + return null; + } + const workspaces = await this.workspaceRegistry.list(); + const workspace = workspaces.find( + (record) => record.kind === "worktree" && !record.archivedAt && record.cwd === cwd, + ); + return workspace?.worktreeStoragePath ?? null; + } } async function loadGitHubSnapshot(options: { diff --git a/packages/server/src/server/workspace-registry.test.ts b/packages/server/src/server/workspace-registry.test.ts index f9b8b91581..0a23c5c2e8 100644 --- a/packages/server/src/server/workspace-registry.test.ts +++ b/packages/server/src/server/workspace-registry.test.ts @@ -180,4 +180,43 @@ describe("workspace registries", () => { expect(await workspaceRegistry.get("/tmp/repo")).toBeNull(); expect(await workspaceRegistry.list()).toEqual([]); }); + + test("workspace record schema accepts records without worktreeStoragePath", async () => { + await workspaceRegistry.initialize(); + + await workspaceRegistry.upsert( + createPersistedWorkspaceRecord({ + workspaceId: "/tmp/repo", + projectId: "remote:github.com/acme/repo", + cwd: "/tmp/repo", + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }), + ); + + const record = await workspaceRegistry.get("/tmp/repo"); + expect(record?.worktreeStoragePath).toBeNull(); + }); + + test("workspace record persists worktreeStoragePath", async () => { + await workspaceRegistry.initialize(); + + await workspaceRegistry.upsert( + createPersistedWorkspaceRecord({ + workspaceId: "/tmp/repo", + projectId: "remote:github.com/acme/repo", + cwd: "/tmp/repo", + kind: "local_checkout", + displayName: "main", + worktreeStoragePath: "/Volumes/paseo/worktrees/acme-repo", + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }), + ); + + const record = await workspaceRegistry.get("/tmp/repo"); + expect(record?.worktreeStoragePath).toBe("/Volumes/paseo/worktrees/acme-repo"); + }); }); diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts index 401afa6101..dbe79b2362 100644 --- a/packages/server/src/server/workspace-registry.ts +++ b/packages/server/src/server/workspace-registry.ts @@ -30,6 +30,11 @@ const PersistedWorkspaceRecordSchema = z.object({ cwd: z.string(), kind: z.enum(["local_checkout", "worktree", "directory"]), displayName: z.string(), + worktreeStoragePath: z + .string() + .nullable() + .optional() + .transform((value) => value ?? null), createdAt: z.string(), updatedAt: z.string(), archivedAt: z.string().nullable(), @@ -231,12 +236,14 @@ export function createPersistedWorkspaceRecord(input: { cwd: string; kind: PersistedWorkspaceKind; displayName: string; + worktreeStoragePath?: string | null; createdAt: string; updatedAt: string; archivedAt?: string | null; }): PersistedWorkspaceRecord { return PersistedWorkspaceRecordSchema.parse({ ...input, + worktreeStoragePath: input.worktreeStoragePath ?? null, archivedAt: input.archivedAt ?? null, }); } diff --git a/packages/server/src/server/worktree-core.ts b/packages/server/src/server/worktree-core.ts index 3891f44031..b7ff791052 100644 --- a/packages/server/src/server/worktree-core.ts +++ b/packages/server/src/server/worktree-core.ts @@ -24,6 +24,7 @@ export interface CreateWorktreeCoreInput { githubPrNumber?: number; firstAgentContext?: FirstAgentContext; paseoHome?: string; + worktreeStoragePath?: string | null; runSetup?: boolean; } @@ -98,6 +99,7 @@ export async function createWorktreeCore( slug: normalizedSlug, repoRoot, paseoHome: input.paseoHome, + worktreeStoragePath: input.worktreeStoragePath, }); if (existingWorktree) { return { worktree: existingWorktree, intent, repoRoot, created: false }; @@ -110,6 +112,7 @@ export async function createWorktreeCore( source: intent, runSetup: input.runSetup ?? true, paseoHome: input.paseoHome, + worktreeStoragePath: input.worktreeStoragePath, }), intent, repoRoot, diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 3b6a74d371..c38a7b44eb 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -378,6 +378,7 @@ export async function handlePaseoWorktreeListRequest( emit: EmitSessionMessage; paseoHome?: string; workspaceGitService: WorkspaceGitService; + workspaceRegistry: Pick; }, msg: Extract, ): Promise { @@ -396,9 +397,14 @@ export async function handlePaseoWorktreeListRequest( } try { + const sourceWorkspace = await findWorkspaceForWorktreeSource({ + cwd, + workspaceRegistry: dependencies.workspaceRegistry, + workspaceGitService: dependencies.workspaceGitService, + }); const worktrees = await listPaseoWorktreesCommand( { workspaceGitService: dependencies.workspaceGitService }, - { cwd }, + { cwd, worktreeStoragePath: sourceWorkspace?.worktreeStoragePath ?? null }, ); dependencies.emit({ type: "paseo_worktree_list_response", @@ -425,6 +431,27 @@ export async function handlePaseoWorktreeListRequest( } } +async function findWorkspaceForWorktreeSource(options: { + cwd: string; + workspaceRegistry: Pick; + workspaceGitService: Pick; +}) { + const inputCwd = options.cwd; + let repoRoot = inputCwd; + try { + repoRoot = await options.workspaceGitService.resolveRepoRoot(inputCwd); + } catch { + repoRoot = inputCwd; + } + + const workspaces = await options.workspaceRegistry.list(); + return ( + workspaces.find((workspace) => workspace.cwd === inputCwd && !workspace.archivedAt) ?? + workspaces.find((workspace) => workspace.cwd === repoRoot && !workspace.archivedAt) ?? + null + ); +} + export async function handlePaseoWorktreeArchiveRequest( dependencies: Omit< ArchivePaseoWorktreeDependencies, @@ -432,6 +459,7 @@ export async function handlePaseoWorktreeArchiveRequest( > & { emit: EmitSessionMessage; workspaceGitService: Pick; + workspaceRegistry?: Pick; emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable) => Promise; }, msg: Extract, @@ -501,6 +529,7 @@ export async function handleCreatePaseoWorktreeRequest( refName: request.refName, action: request.action, githubPrNumber: request.githubPrNumber, + worktreeStoragePath: request.worktreeStoragePath, }, ); diff --git a/packages/server/src/server/worktree/commands.ts b/packages/server/src/server/worktree/commands.ts index ec4e3eb525..5bda2a46ba 100644 --- a/packages/server/src/server/worktree/commands.ts +++ b/packages/server/src/server/worktree/commands.ts @@ -1,6 +1,10 @@ -import { join } from "node:path"; +import { dirname, join } from "node:path"; -import { getPaseoWorktreesRoot, isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js"; +import { + getPaseoWorktreesRoot, + isPaseoOwnedWorktreeCwd, + type PaseoWorktreeOwnership, +} from "../../utils/worktree.js"; import { archivePaseoWorktree, type ArchivePaseoWorktreeDependencies, @@ -11,6 +15,8 @@ import type { } from "../paseo-worktree-service.js"; import { toWorktreeWireError, type WorktreeWireError } from "../worktree-errors.js"; import type { WorkspaceGitService, WorkspaceGitWorktreeInfo } from "../workspace-git-service.js"; +import type { WorkspaceRegistry } from "../workspace-registry.js"; +import { normalizeWorkspaceId } from "../workspace-registry-model.js"; export interface ListPaseoWorktreesCommandDependencies { workspaceGitService: Pick; @@ -19,16 +25,17 @@ export interface ListPaseoWorktreesCommandDependencies { export interface ListPaseoWorktreesCommandInput { cwd: string; reason?: string; + worktreeStoragePath?: string | null; } export async function listPaseoWorktreesCommand( dependencies: ListPaseoWorktreesCommandDependencies, input: ListPaseoWorktreesCommandInput, ): Promise { - if (input.reason) { - return dependencies.workspaceGitService.listWorktrees(input.cwd, { reason: input.reason }); - } - return dependencies.workspaceGitService.listWorktrees(input.cwd); + return dependencies.workspaceGitService.listWorktrees(input.cwd, { + ...(input.reason ? { reason: input.reason } : {}), + worktreeStoragePath: input.worktreeStoragePath ?? null, + }); } type CreatePaseoWorktreeWorkflow = ( @@ -89,6 +96,7 @@ export interface ArchivePaseoWorktreeCommandDependencies extends Omit< "workspaceGitService" > { workspaceGitService: Pick; + workspaceRegistry?: Pick; } export interface ArchivePaseoWorktreeCommandInput { @@ -116,10 +124,7 @@ export async function archivePaseoWorktreeCommand( input: ArchivePaseoWorktreeCommandInput, ): Promise { const resolvedTarget = await resolveArchiveTarget(dependencies, input); - const ownership = await isPaseoOwnedWorktreeCwd(resolvedTarget.targetPath, { - paseoHome: dependencies.paseoHome, - }); - + const ownership = await resolveArchiveOwnership(dependencies, resolvedTarget); if (!ownership.allowed) { return { ok: false, @@ -131,6 +136,7 @@ export async function archivePaseoWorktreeCommand( const repoRoot = ownership.repoRoot ?? resolvedTarget.repoRoot ?? null; const removedAgents = await archivePaseoWorktree(dependencies, { + resolveWorktreeRoot: ownership.worktreeRoot === undefined, targetPath: resolvedTarget.targetPath, repoRoot, worktreesRoot: ownership.worktreeRoot, @@ -148,6 +154,56 @@ interface ResolvedArchiveTarget { repoRoot: string | null; } +async function resolveArchiveOwnership( + dependencies: ArchivePaseoWorktreeCommandDependencies, + target: ResolvedArchiveTarget, +): Promise { + const defaultOwnership = await isPaseoOwnedWorktreeCwd(target.targetPath, { + paseoHome: dependencies.paseoHome, + }); + if (defaultOwnership.allowed || !dependencies.workspaceRegistry) { + return defaultOwnership; + } + + const customOwnership = await resolvePersistedWorktreeOwnership( + dependencies.workspaceRegistry, + target, + ); + return customOwnership ?? defaultOwnership; +} + +async function resolvePersistedWorktreeOwnership( + workspaceRegistry: Pick, + target: ResolvedArchiveTarget, +): Promise { + const normalizedTarget = normalizeWorkspaceId(target.targetPath); + const workspaces = await workspaceRegistry.list(); + const workspace = workspaces.find( + (record) => + record.kind === "worktree" && + !record.archivedAt && + normalizeWorkspaceId(record.cwd) === normalizedTarget, + ); + if (!workspace) { + return null; + } + + const worktreeRoot = workspace.worktreeStoragePath ?? dirname(normalizedTarget); + const ownership = await isPaseoOwnedWorktreeCwd(normalizedTarget, { + worktreesRoot: worktreeRoot, + }); + if (!ownership.allowed) { + return null; + } + + return { + allowed: true, + repoRoot: ownership.repoRoot ?? target.repoRoot ?? undefined, + worktreeRoot, + worktreePath: normalizedTarget, + }; +} + async function resolveArchiveTarget( dependencies: ArchivePaseoWorktreeCommandDependencies, input: ArchivePaseoWorktreeCommandInput, @@ -184,6 +240,14 @@ async function resolveWorktreeSlugPath( repoRoot: string, worktreeSlug: string, ): Promise { - const worktreesRoot = await getPaseoWorktreesRoot(repoRoot, dependencies.paseoHome); + const workspaces = dependencies.workspaceRegistry + ? await dependencies.workspaceRegistry.list() + : []; + const sourceWorkspace = workspaces.find( + (workspace) => workspace.cwd === repoRoot && !workspace.archivedAt, + ); + const worktreesRoot = sourceWorkspace?.worktreeStoragePath + ? sourceWorkspace.worktreeStoragePath + : await getPaseoWorktreesRoot(repoRoot, dependencies.paseoHome); return join(worktreesRoot, worktreeSlug); } diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 2bad277c3f..c3dff5492c 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -779,6 +779,7 @@ export interface MergeFromBaseOptions { export interface CheckoutContext { paseoHome?: string; + worktreesRoot?: string | null; logger?: Pick; facts?: CheckoutSnapshotFacts | null; } @@ -1003,12 +1004,18 @@ async function getPaseoWorktreeForCwd( context?: CheckoutContext, knownWorktreeRoot?: string | null, ): Promise { - // Fast-path reject: non-worktree paths do not need expensive ownership checks. - if (!/[\\/]worktrees[\\/]/.test(cwd)) { + const shouldCheckOwnership = + knownWorktreeRoot !== null || + context?.worktreesRoot !== undefined || + /[\\/]worktrees[\\/]/.test(cwd); + if (!shouldCheckOwnership) { return { isPaseoOwnedWorktree: false }; } - const ownership = await isPaseoOwnedWorktreeCwd(cwd, { paseoHome: context?.paseoHome }); + const ownership = await isPaseoOwnedWorktreeCwd(cwd, { + paseoHome: context?.paseoHome, + worktreesRoot: context?.worktreesRoot, + }); if (!ownership.allowed) { return { isPaseoOwnedWorktree: false }; } diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index 0200c5a1c4..bd9a13ddec 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -4,6 +4,7 @@ import { deriveWorktreeProjectHash, deletePaseoWorktree, isPaseoOwnedWorktreeCwd, + listPaseoWorktrees, slugify, type CreateWorktreeOptions, type WorktreeConfig, @@ -20,6 +21,7 @@ interface LegacyCreateWorktreeTestOptions { worktreeSlug: string; runSetup?: boolean; paseoHome?: string; + worktreeStoragePath?: string | null; } function createLegacyWorktreeForTest( @@ -39,6 +41,7 @@ function createLegacyWorktreeForTest( }, runSetup: options.runSetup ?? true, paseoHome: options.paseoHome, + worktreeStoragePath: options.worktreeStoragePath, }); } @@ -111,6 +114,31 @@ describe("paseo worktree manager", () => { }); }); + it("creates and lists worktrees under a custom storage root", async () => { + const customRoot = join(tempDir, "custom-worktrees"); + const created = await createLegacyWorktreeForTest({ + branchName: "feature-custom-root", + cwd: repoDir, + baseBranch: "main", + worktreeSlug: "feature-custom-root", + runSetup: false, + paseoHome, + worktreeStoragePath: customRoot, + }); + + expect(created.worktreePath).toBe(join(customRoot, "feature-custom-root")); + const listed = await listPaseoWorktrees({ + cwd: repoDir, + paseoHome, + worktreeStoragePath: customRoot, + }); + expect(listed.map((worktree) => worktree.path)).toContain(created.worktreePath); + const ownership = await isPaseoOwnedWorktreeCwd(created.worktreePath, { + paseoHome, + worktreesRoot: customRoot, + }); + expect(ownership.allowed).toBe(true); + }); it("deletes a worktree whose .git admin dir has already been removed", async () => { const created = await createLegacyWorktreeForTest({ branchName: "orphan-delete-branch", diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index eb2b49c875..a3d7528822 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -168,12 +168,14 @@ export interface CreateWorktreeOptions { source: WorktreeSource; runSetup: boolean; paseoHome?: string; + worktreeStoragePath?: string | null; } interface ResolveExistingWorktreeForSlugOptions { slug: string; repoRoot: string; paseoHome?: string; + worktreeStoragePath?: string | null; } export class BranchAlreadyCheckedOutError extends Error { @@ -772,6 +774,18 @@ export async function getPaseoWorktreesRoot(cwd: string, paseoHome?: string): Pr return join(home, "worktrees", projectHash); } +export async function resolvePaseoWorktreesRoot(input: { + cwd: string; + paseoHome?: string; + worktreeStoragePath?: string | null; +}): Promise { + const customRoot = input.worktreeStoragePath?.trim(); + if (customRoot) { + return resolve(customRoot); + } + return getPaseoWorktreesRoot(input.cwd, input.paseoHome); +} + export async function computeWorktreePath( cwd: string, slug: string, @@ -798,7 +812,7 @@ function resolveRepoRootFromGitCommonDir(commonDir: string): string { export async function isPaseoOwnedWorktreeCwd( cwd: string, - options?: { paseoHome?: string }, + options?: { paseoHome?: string; worktreesRoot?: string | null }, ): Promise { const resolvedCwd = normalizePathForOwnership(cwd); @@ -820,6 +834,19 @@ export async function isPaseoOwnedWorktreeCwd( // The / prefix is Paseo-private — nothing else writes there — so the // path shape alone is sufficient proof of ownership, even when git has already // forgotten about the worktree. + + if (options?.worktreesRoot) { + const customRoot = normalizePathForOwnership(options.worktreesRoot); + const customRootPrefix = customRoot + sep; + if (resolvedCwd === customRoot || resolvedCwd.startsWith(customRootPrefix)) { + return { + allowed: resolvedCwd !== customRoot, + ...(repoRoot !== undefined ? { repoRoot } : {}), + worktreeRoot: customRoot, + worktreePath: resolvedCwd, + }; + } + } if (!resolvedCwd.startsWith(paseoWorktreesPrefix)) { return { allowed: false, @@ -901,11 +928,13 @@ function resolveWorktreeCreatedAtIso(worktreePath: string): string { export async function listPaseoWorktrees({ cwd, paseoHome, + worktreeStoragePath, }: { cwd: string; paseoHome?: string; + worktreeStoragePath?: string | null; }): Promise { - const worktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome); + const worktreesRoot = await resolvePaseoWorktreesRoot({ cwd, paseoHome, worktreeStoragePath }); const { stdout } = await runGitCommand(["worktree", "list", "--porcelain"], { cwd, envOverlay: READ_ONLY_GIT_ENV, @@ -924,10 +953,12 @@ export async function resolveExistingWorktreeForSlug({ slug, repoRoot, paseoHome, + worktreeStoragePath, }: ResolveExistingWorktreeForSlugOptions): Promise { const worktrees = await listPaseoWorktrees({ cwd: repoRoot, paseoHome, + worktreeStoragePath, }); const slugSuffix = `${sep}${slug}`; const existingWorktree = worktrees.find((worktree) => worktree.path.endsWith(slugSuffix)); @@ -1121,9 +1152,13 @@ export const createWorktree = async ({ worktreeSlug, runSetup, paseoHome, + worktreeStoragePath, }: CreateWorktreeOptions): Promise => { const sourcePlan = await resolveWorktreeSourcePlan({ cwd, source, desiredSlug: worktreeSlug }); - let worktreePath = join(await getPaseoWorktreesRoot(cwd, paseoHome), worktreeSlug); + let worktreePath = join( + await resolvePaseoWorktreesRoot({ cwd, paseoHome, worktreeStoragePath }), + worktreeSlug, + ); mkdirSync(dirname(worktreePath), { recursive: true }); // Also handle worktree path collision diff --git a/public-docs/worktrees.md b/public-docs/worktrees.md index 7537674193..f4ad0367bd 100644 --- a/public-docs/worktrees.md +++ b/public-docs/worktrees.md @@ -11,7 +11,7 @@ Each agent runs in its own git worktree, a separate directory on a separate bran ## Layout and workflow -Worktrees live under `$PASEO_HOME/worktrees/`, grouped by a hash of the source checkout path. Each worktree gets a random slug; the branch name is chosen when you first launch an agent. +By default, worktrees live under `$PASEO_HOME/worktrees/`, grouped by a hash of the source checkout path. Each worktree gets a random slug; the branch name is chosen when you first launch an agent. ``` ~/.paseo/worktrees/ @@ -20,6 +20,8 @@ Worktrees live under `$PASEO_HOME/worktrees/`, grouped by a hash of the source c └── bold-owl/ ``` +You can override the storage root per workspace in project settings. With a custom root, Paseo creates new worktrees directly under that directory by slug, for example `/Volumes/fast/paseo-worktrees/tidy-fox`. The setting is local to your host and workspace; it is not written to `paseo.json`. + 1. Create a worktree, Paseo runs your setup hooks 2. Launch an agent, a branch is created or assigned 3. Review the diff against the base branch