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 eeaced590..5530ad689 100644 --- a/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts +++ b/packages/server/src/server/agent/create-agent-lifecycle-dispatch.ts @@ -3,11 +3,7 @@ import type pino from "pino"; import type { GitHubService } from "../../services/github-service.js"; import { isPaseoOwnedWorktreeCwd } from "../../utils/worktree.js"; -import { - archiveByScope, - type ActiveWorkspaceRef, - resolveWorkspaceIdAtPath, -} from "../workspace-archive-service.js"; +import { archiveByScope, type ActiveWorkspaceRef } from "../workspace-archive-service.js"; import type { CreatePaseoWorktreeWorkflowFn, CreatePaseoWorktreeWorkflowResult, @@ -42,6 +38,10 @@ interface CreateAgentLifecycleDispatchDependencies { logger: pino.Logger; } +type AutoArchiveTarget = + | { kind: "agent-only" } + | { kind: "created-worktree"; result: CreatePaseoWorktreeWorkflowResult }; + export class CreateAgentLifecycleDispatch { private readonly autoArchiveAgentIds = new Set(); @@ -72,10 +72,10 @@ export class CreateAgentLifecycleDispatch { return; } - this.registerAutoArchiveOnTerminalState(input.agentId, { - worktreePath: input.createdWorktree?.worktree.worktreePath ?? null, - repoRoot: input.createdWorktree?.repoRoot ?? null, - }); + this.registerAutoArchiveOnTerminalState( + input.agentId, + toAutoArchiveTarget(input.createdWorktree), + ); } async cleanupCreatedWorktreeAfterFailedAgentCreate(input: { @@ -89,8 +89,7 @@ export class CreateAgentLifecycleDispatch { await this.archiveAutoCreatedWorktree({ agentId: null, - worktreePath: createdWorktree.worktree.worktreePath, - repoRoot: createdWorktree.repoRoot, + createdWorktree, }).catch((archiveError) => { this.dependencies.logger.warn( { @@ -143,10 +142,7 @@ export class CreateAgentLifecycleDispatch { } } - private registerAutoArchiveOnTerminalState( - agentId: string, - options: { worktreePath: string | null; repoRoot: string | null }, - ): void { + private registerAutoArchiveOnTerminalState(agentId: string, target: AutoArchiveTarget): void { const unsubscribe = this.dependencies.agentManager.subscribe( (event) => { if (event.type !== "agent_stream") { @@ -160,27 +156,23 @@ export class CreateAgentLifecycleDispatch { return; } unsubscribe(); - void this.autoArchiveAgentOnce(agentId, options); + void this.autoArchiveAgentOnce(agentId, target); }, { agentId, replayState: false }, ); } - private async autoArchiveAgentOnce( - agentId: string, - options: { worktreePath: string | null; repoRoot: string | null }, - ): Promise { + private async autoArchiveAgentOnce(agentId: string, target: AutoArchiveTarget): Promise { if (this.autoArchiveAgentIds.has(agentId)) { return; } this.autoArchiveAgentIds.add(agentId); try { - if (options.worktreePath) { + if (target.kind === "created-worktree") { await this.archiveAutoCreatedWorktree({ agentId, - worktreePath: options.worktreePath, - repoRoot: options.repoRoot, + createdWorktree: target.result, }); return; } @@ -193,10 +185,11 @@ export class CreateAgentLifecycleDispatch { private async archiveAutoCreatedWorktree(options: { agentId: string | null; - worktreePath: string; - repoRoot: string | null; + createdWorktree: CreatePaseoWorktreeWorkflowResult; }): Promise { - const ownership = await isPaseoOwnedWorktreeCwd(options.worktreePath, { + const { createdWorktree } = options; + const worktreePath = createdWorktree.worktree.worktreePath; + const ownership = await isPaseoOwnedWorktreeCwd(worktreePath, { paseoHome: this.dependencies.paseoHome, worktreesRoot: this.dependencies.worktreesRoot, }); @@ -204,49 +197,41 @@ export class CreateAgentLifecycleDispatch { throw new Error("Auto-created worktree is not a Paseo-owned worktree"); } - const workspaceId = await resolveWorkspaceIdAtPath( + await archiveByScope( { + paseoHome: this.dependencies.paseoHome, + paseoWorktreesBaseRoot: this.dependencies.worktreesRoot, + github: this.dependencies.github, + workspaceGitService: this.dependencies.workspaceGitService, + agentManager: this.dependencies.agentManager, + agentStorage: this.dependencies.agentStorage, findWorkspaceIdForCwd: this.dependencies.findWorkspaceIdForCwd, listActiveWorkspaces: this.dependencies.listActiveWorkspaces, + archiveWorkspaceRecord: this.dependencies.archiveWorkspaceRecord, + emitWorkspaceUpdatesForWorkspaceIds: this.dependencies.emitWorkspaceUpdatesForWorkspaceIds, + markWorkspaceArchiving: this.dependencies.markWorkspaceArchiving, + clearWorkspaceArchiving: this.dependencies.clearWorkspaceArchiving, + killTerminalsForWorkspace: this.dependencies.killTerminalsForWorkspace, + sessionLogger: this.dependencies.logger, + }, + { + scope: { kind: "workspace", workspaceId: createdWorktree.workspace.workspaceId }, + repoRoot: createdWorktree.repoRoot ?? ownership.repoRoot ?? null, + paseoWorktreesBaseRoot: this.dependencies.worktreesRoot, + requestId: randomUUID(), }, - options.worktreePath, ); - if (!workspaceId) { - this.dependencies.logger.warn( - { worktreePath: options.worktreePath }, - "Could not resolve workspace for auto-archive; skipping", - ); - } else { - await archiveByScope( - { - paseoHome: this.dependencies.paseoHome, - paseoWorktreesBaseRoot: this.dependencies.worktreesRoot, - github: this.dependencies.github, - workspaceGitService: this.dependencies.workspaceGitService, - agentManager: this.dependencies.agentManager, - agentStorage: this.dependencies.agentStorage, - findWorkspaceIdForCwd: this.dependencies.findWorkspaceIdForCwd, - listActiveWorkspaces: this.dependencies.listActiveWorkspaces, - archiveWorkspaceRecord: this.dependencies.archiveWorkspaceRecord, - emitWorkspaceUpdatesForWorkspaceIds: - this.dependencies.emitWorkspaceUpdatesForWorkspaceIds, - markWorkspaceArchiving: this.dependencies.markWorkspaceArchiving, - clearWorkspaceArchiving: this.dependencies.clearWorkspaceArchiving, - killTerminalsForWorkspace: this.dependencies.killTerminalsForWorkspace, - sessionLogger: this.dependencies.logger, - }, - { - scope: { kind: "workspace", workspaceId }, - repoRoot: options.repoRoot ?? ownership.repoRoot ?? null, - paseoWorktreesBaseRoot: this.dependencies.worktreesRoot, - requestId: randomUUID(), - }, - ); - } - if (options.agentId) { this.dependencies.emitAgentRemove(options.agentId); } } } + +function toAutoArchiveTarget( + createdWorktree: CreatePaseoWorktreeWorkflowResult | null, +): AutoArchiveTarget { + return createdWorktree + ? { kind: "created-worktree", result: createdWorktree } + : { kind: "agent-only" }; +} diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index f261a678a..002ebf5e5 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -217,6 +217,62 @@ test("creates a worktree workspace at the selected project subdirectory", async }); }); +test("maps a nested cwd from an existing Paseo worktree into the next worktree", async () => { + const { repoDir, tempDir } = createGitRepo(); + cleanupPaths.push(tempDir); + const paseoHome = path.join(tempDir, ".paseo"); + const deps = createDeps(); + const source = await createPaseoWorktree( + { + cwd: repoDir, + worktreeSlug: "source-worktree", + runSetup: false, + paseoHome, + }, + deps, + ); + const sourceCwd = path.join(source.worktree.worktreePath, "packages", "app"); + mkdirSync(sourceCwd, { recursive: true }); + + const created = await createPaseoWorktree( + { + cwd: sourceCwd, + worktreeSlug: "nested-worktree", + runSetup: false, + paseoHome, + }, + deps, + ); + + expect(created.workspace.cwd).toBe(path.join(created.worktree.worktreePath, "packages", "app")); + expect(deps.workspaces.get(created.workspace.workspaceId)).toEqual(created.workspace); +}); + +test("rejects source checkout planning before creating a worktree", async () => { + const { repoDir, tempDir } = createGitRepo(); + cleanupPaths.push(tempDir); + const paseoHome = path.join(tempDir, ".paseo"); + const deps = createDeps(); + deps.workspaceGitService.getCheckout = async () => { + throw new Error("source checkout unavailable"); + }; + + await expect( + createPaseoWorktree( + { + cwd: repoDir, + worktreeSlug: "must-not-create", + runSetup: false, + paseoHome, + }, + deps, + ), + ).rejects.toThrow("source checkout unavailable"); + + expect(existsSync(path.join(paseoHome, "worktrees"))).toBe(false); + expect(Array.from(deps.workspaces.values())).toEqual([]); +}); + 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); diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index fd7d2229a..4482e6ecb 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -1,6 +1,6 @@ import type { WorkspaceGitService } from "./workspace-git-service.js"; import { resolve } from "node:path"; -import { areEquivalentPaths } from "../utils/path.js"; +import { areEquivalentPaths, getRealpathAwareRelativePath } from "../utils/path.js"; import { type PersistedProjectRecord, type PersistedWorkspaceRecord, @@ -15,7 +15,7 @@ import { type CreateWorktreeCoreInput, } from "./worktree-core.js"; import { - mapWorkspaceCwdToWorktree, + mapWorkspaceRelativeCwdToWorktree, validateBranchSlug, type WorktreeConfig, } from "../utils/worktree.js"; @@ -66,12 +66,14 @@ export async function createPaseoWorktree( input: CreatePaseoWorktreeInput, deps: CreatePaseoWorktreeDeps, ): Promise { + const workspaceCwdPlan = await planWorkspaceCwdForWorktree(input.cwd, deps.workspaceGitService); const createdWorktree = await createWorktreeCore(input, deps); maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree }); const workspace = await upsertWorkspaceForWorktree({ - inputCwd: input.cwd, + inputCwd: workspaceCwdPlan.inputCwd, projectId: input.projectId, repoRoot: createdWorktree.repoRoot, + relativeWorkspaceCwd: workspaceCwdPlan.relativeWorkspaceCwd, worktree: createdWorktree.worktree, baseBranch: resolveIntentBaseBranch(createdWorktree.intent), title: resolveFirstAgentPromptTitle(input.firstAgentContext), @@ -89,6 +91,20 @@ export async function createPaseoWorktree( }; } +async function planWorkspaceCwdForWorktree( + inputCwd: string, + workspaceGitService: Pick, +): Promise<{ inputCwd: string; relativeWorkspaceCwd: string }> { + const normalizedInputCwd = resolve(inputCwd); + const sourceCheckout = await workspaceGitService.getCheckout(normalizedInputCwd); + const sourceWorktreePath = sourceCheckout.worktreeRoot ?? normalizedInputCwd; + const relativeWorkspaceCwd = getRealpathAwareRelativePath(sourceWorktreePath, normalizedInputCwd); + if (relativeWorkspaceCwd === null) { + throw new Error(`Workspace cwd is outside its source worktree: ${normalizedInputCwd}`); + } + return { inputCwd: normalizedInputCwd, relativeWorkspaceCwd }; +} + export async function attemptFirstAgentBranchAutoName(options: { cwd: string; firstAgentContext: FirstAgentContext | undefined; @@ -217,6 +233,7 @@ async function upsertWorkspaceForWorktree(options: { inputCwd: string; projectId?: string; repoRoot: string; + relativeWorkspaceCwd: string; worktree: WorktreeConfig; baseBranch?: string | null; title?: string | null; @@ -227,9 +244,8 @@ async function upsertWorkspaceForWorktree(options: { }): Promise { const normalizedInputCwd = resolve(options.inputCwd); const normalizedRepoRoot = resolve(options.repoRoot); - const normalizedCwd = mapWorkspaceCwdToWorktree({ - sourceWorktreePath: normalizedRepoRoot, - workspaceCwd: normalizedInputCwd, + const normalizedCwd = mapWorkspaceRelativeCwdToWorktree({ + relativeWorkspaceCwd: options.relativeWorkspaceCwd, targetWorktreePath: options.worktree.worktreePath, }); // Creation never deduplicates by directory: a worktree directory may back diff --git a/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts b/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts index c76827e95..8507a935e 100644 --- a/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts +++ b/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, expect, test } from "vitest"; @@ -8,6 +8,7 @@ import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js"; import { createDaemonTestContext, type DaemonTestContext } from "./test-utils/index.js"; import type { CreateAgentOptions } from "./test-utils/index.js"; import type { CreateAgentWorktreeTarget } from "./messages.js"; +import { createRealpathAwarePathMatcher } from "../utils/path.js"; let ctx: DaemonTestContext; const tempRoots: string[] = []; @@ -42,6 +43,18 @@ function createGitRepo(): string { return repoDir; } +function createGitRepoWithNestedDirectory(): string { + const repoDir = createGitRepo(); + mkdirSync(path.join(repoDir, "packages", "app"), { recursive: true }); + writeFileSync(path.join(repoDir, "packages", "app", ".gitkeep"), ""); + execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add nested app"], { + cwd: repoDir, + stdio: "pipe", + }); + return repoDir; +} + async function expectAgentAbsentFromActiveList(agentId: string): Promise { await expect .poll( @@ -84,8 +97,9 @@ async function expectWorktreeListEmpty(repoDir: string): Promise { async function createAgentInBranchOffWorktree(options?: { autoArchive?: boolean; branchName?: string; + repoDir?: string; }): Promise<{ repoDir: string; agentId: string; worktreePath: string }> { - const repoDir = createGitRepo(); + const repoDir = options?.repoDir ?? createGitRepo(); const branchName = options?.branchName ?? `agent-lifecycle-${Date.now()}`; const created = await ctx.client.createAgent({ config: { @@ -142,6 +156,109 @@ test("create_agent_request creates a worktree and auto-archives both after the f await expect.poll(() => existsSync(created.cwd), { timeout: 10000, interval: 100 }).toBe(false); }, 30000); +test("create_agent_request auto-archives a nested workspace from an existing Paseo worktree", async () => { + const repoDir = createGitRepoWithNestedDirectory(); + const source = await createAgentInBranchOffWorktree({ branchName: "nested-source", repoDir }); + await ctx.client.waitForFinish(source.agentId, 10000); + const nestedCwd = path.join(source.worktreePath, "packages", "app"); + + const created = await ctx.client.createAgent({ + config: { + ...getFullAccessConfig("codex"), + cwd: nestedCwd, + }, + worktree: { + mode: "branch-off", + newBranch: "nested-auto-archive", + base: "main", + }, + autoArchive: true, + initialPrompt: "Say done.", + }); + + const createdWorktreeRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { + cwd: created.cwd, + stdio: "pipe", + }) + .toString() + .trim(); + expect( + createRealpathAwarePathMatcher(path.join(createdWorktreeRoot, "packages", "app"))(created.cwd), + ).toBe(true); + await ctx.client.waitForFinish(created.id, 10000); + + await expectAgentAbsentFromActiveList(created.id); + await expect + .poll( + async () => { + const workspaces = await ctx.client.fetchWorkspaces(); + const matchesCreatedWorkspace = createRealpathAwarePathMatcher(created.cwd); + return workspaces.entries.some((workspace) => + matchesCreatedWorkspace(workspace.workspaceDirectory), + ); + }, + { timeout: 10000, interval: 100 }, + ) + .toBe(false); + await expect.poll(() => existsSync(created.cwd), { timeout: 10000, interval: 100 }).toBe(false); + expect(existsSync(source.worktreePath)).toBe(true); + + await ctx.client.archivePaseoWorktree({ worktreePath: source.worktreePath }); +}, 30000); + +test("failed nested worktree creation cleans up the created workspace and backing directory", async () => { + const repoDir = createGitRepoWithNestedDirectory(); + const source = await createAgentInBranchOffWorktree({ + branchName: "nested-failure-source", + repoDir, + }); + await ctx.client.waitForFinish(source.agentId, 10000); + const nestedCwd = path.join(source.worktreePath, "packages", "app"); + + await expect( + ctx.client.createAgent({ + config: { provider: "unknown-provider", cwd: nestedCwd }, + worktree: { + mode: "branch-off", + newBranch: "nested-failure-cleanup", + base: "main", + }, + initialPrompt: "This agent cannot be created.", + }), + ).rejects.toThrow(); + + await expect + .poll( + async () => { + const listed = await ctx.client.getPaseoWorktreeList({ cwd: source.repoDir }); + return ( + listed.worktrees.length === 1 && + createRealpathAwarePathMatcher(source.worktreePath)( + listed.worktrees[0]?.worktreePath ?? "", + ) + ); + }, + { timeout: 10000, interval: 100 }, + ) + .toBe(true); + await expect + .poll( + async () => { + const workspaces = await ctx.client.fetchWorkspaces(); + return ( + workspaces.entries.length === 1 && + createRealpathAwarePathMatcher(source.worktreePath)( + workspaces.entries[0]?.workspaceDirectory ?? "", + ) + ); + }, + { timeout: 10000, interval: 100 }, + ) + .toBe(true); + + await ctx.client.archivePaseoWorktree({ worktreePath: source.worktreePath }); +}, 30000); + test("create_agent_request with autoArchive archives only the agent when no worktree was created", async () => { const repoDir = createGitRepo(); const created = await ctx.client.createAgent({ diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts index e024ecc00..f3fe24cb8 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts @@ -138,6 +138,7 @@ test("re-opening Windows-equivalent workspace cwd spellings reuses the active an test("re-opening an active workspace refreshes its checkout metadata", async () => { const repo = path.join(tmpDir, "repo"); const first = await provisioning.findOrCreateWorkspaceForDirectory(repo); + await workspaceRegistry.upsert({ ...first, title: "Pinned work" }); gitRoots.add(repo); gitBranches.set(repo, "feature/refresh"); @@ -147,6 +148,8 @@ test("re-opening an active workspace refreshes its checkout metadata", async () workspaceId: first.workspaceId, kind: "local_checkout", branch: "feature/refresh", + displayName: "feature/refresh", + title: "Pinned work", isPaseoOwnedWorktree: false, mainRepoRoot: null, }); @@ -279,22 +282,26 @@ test("uses one workspace snapshot when reopening an archived workspace", async ( expect(await workspaceRegistry.list()).toHaveLength(1); }); -test("reopening an archived workspace refreshes git-derived kind and branch", async () => { +test("reopening an archived workspace refreshes its checkout-derived fields", async () => { const repo = path.join(tmpDir, "repo"); gitRoots.add(repo); const created = await provisioning.findOrCreateWorkspaceForDirectory(repo); + await workspaceRegistry.upsert({ ...created, title: "Pinned archived work" }); await workspaceRegistry.archive(created.workspaceId, ARCHIVED_AT); gitRoots.delete(repo); const reopened = await provisioning.findOrCreateWorkspaceForDirectory(repo); - expect(reopened).toEqual({ - ...created, + expect(reopened).toMatchObject({ + workspaceId: created.workspaceId, + projectId: created.projectId, + title: "Pinned archived work", kind: "directory", branch: null, + displayName: "repo", archivedAt: null, - updatedAt: expect.any(String), }); + expect(reopened.updatedAt).toEqual(expect.any(String)); expect(await workspaceRegistry.get(created.workspaceId)).toEqual(reopened); }); diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts index 92666b238..c87a402e8 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts @@ -240,13 +240,7 @@ export function createWorkspaceProvisioningService(deps: { if (workspace.archivedAt && checkout) { next = { ...workspace, - kind: deriveWorkspaceKind(checkout), - branch: - checkout.currentBranch && checkout.currentBranch.toUpperCase() !== "HEAD" - ? checkout.currentBranch - : null, - isPaseoOwnedWorktree: checkout.isGit && checkout.isPaseoOwnedWorktree, - mainRepoRoot: checkout.isGit ? checkout.mainRepoRoot : null, + ...checkoutDerivedWorkspaceFields(workspace, checkout), archivedAt: null, updatedAt: timestamp, }; @@ -273,33 +267,44 @@ export function createWorkspaceProvisioningService(deps: { if (project && !project.archivedAt) { await refreshProjectKind(project, workspace.cwd, checkout); } - const kind = deriveWorkspaceKind(checkout); - const branch = - checkout.currentBranch && checkout.currentBranch.toUpperCase() !== "HEAD" - ? checkout.currentBranch - : null; - const isPaseoOwnedWorktree = checkout.isGit && checkout.isPaseoOwnedWorktree; - const mainRepoRoot = checkout.isGit ? checkout.mainRepoRoot : null; + const derived = checkoutDerivedWorkspaceFields(workspace, checkout); if ( - workspace.kind === kind && - workspace.branch === branch && - workspace.isPaseoOwnedWorktree === isPaseoOwnedWorktree && - workspace.mainRepoRoot === mainRepoRoot + workspace.kind === derived.kind && + workspace.branch === derived.branch && + workspace.displayName === derived.displayName && + workspace.isPaseoOwnedWorktree === derived.isPaseoOwnedWorktree && + workspace.mainRepoRoot === derived.mainRepoRoot ) { return workspace; } const next = { ...workspace, - kind, - branch, - isPaseoOwnedWorktree, - mainRepoRoot, + ...derived, updatedAt: new Date().toISOString(), }; await workspaceRegistry.upsert(next); return next; } + function checkoutDerivedWorkspaceFields( + workspace: PersistedWorkspaceRecord, + checkout: Awaited>, + ): Pick< + PersistedWorkspaceRecord, + "kind" | "branch" | "displayName" | "isPaseoOwnedWorktree" | "mainRepoRoot" + > { + return { + kind: deriveWorkspaceKind(checkout), + branch: + checkout.currentBranch && checkout.currentBranch.toUpperCase() !== "HEAD" + ? checkout.currentBranch + : null, + displayName: deriveWorkspaceDisplayName({ cwd: workspace.cwd, checkout }), + isPaseoOwnedWorktree: checkout.isGit && checkout.isPaseoOwnedWorktree, + mainRepoRoot: checkout.isGit ? checkout.mainRepoRoot : null, + }; + } + async function refreshProjectKind( project: PersistedProjectRecord, workspaceCwd: string, diff --git a/packages/server/src/server/worktree/commands.ts b/packages/server/src/server/worktree/commands.ts index 3fa7f601a..a0b0195e9 100644 --- a/packages/server/src/server/worktree/commands.ts +++ b/packages/server/src/server/worktree/commands.ts @@ -124,13 +124,12 @@ export async function archiveCommand( ): Promise { const resolvedTarget = await resolveArchiveTarget(dependencies, input); const scope = input.scope ?? "workspace"; + const ownership = await isPaseoOwnedWorktreeCwd(resolvedTarget.targetPath, { + paseoHome: dependencies.paseoHome, + worktreesRoot: dependencies.paseoWorktreesBaseRoot, + }); if (scope === "worktree") { - const ownership = await isPaseoOwnedWorktreeCwd(resolvedTarget.targetPath, { - paseoHome: dependencies.paseoHome, - worktreesRoot: dependencies.paseoWorktreesBaseRoot, - }); - if (!ownership.allowed) { return { ok: false, @@ -170,7 +169,8 @@ export async function archiveCommand( const result = await archiveByScope(dependencies, { scope: { kind: "workspace", workspaceId }, - repoRoot: resolvedTarget.repoRoot, + repoRoot: resolvedTarget.repoRoot ?? ownership.repoRoot ?? null, + repoWorktreesRoot: ownership.worktreeRoot, paseoWorktreesBaseRoot: dependencies.paseoWorktreesBaseRoot, requestId: input.requestId, }); diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 05ee28d23..1613fcdda 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -838,9 +838,19 @@ export function mapWorkspaceCwdToWorktree(input: { throw new Error(`Workspace cwd is outside its source worktree: ${input.workspaceCwd}`); } - const mappedCwd = resolve(input.targetWorktreePath, relativeWorkspaceCwd); + return mapWorkspaceRelativeCwdToWorktree({ + relativeWorkspaceCwd, + targetWorktreePath: input.targetWorktreePath, + }); +} + +export function mapWorkspaceRelativeCwdToWorktree(input: { + relativeWorkspaceCwd: string; + targetWorktreePath: string; +}): string { + const mappedCwd = resolve(input.targetWorktreePath, input.relativeWorkspaceCwd); if (!isPathInsideRoot(input.targetWorktreePath, mappedCwd)) { - throw new Error(`Workspace cwd escapes its target worktree: ${input.workspaceCwd}`); + throw new Error(`Workspace cwd escapes its target worktree: ${input.relativeWorkspaceCwd}`); } return mappedCwd; }