fix(projects): preserve nested worktree lifecycle

This commit is contained in:
Mohamed Boudra
2026-07-16 19:33:03 +00:00
parent 434accb30e
commit 4220581103
8 changed files with 299 additions and 103 deletions

View File

@@ -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<string>();
@@ -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<void> {
private async autoArchiveAgentOnce(agentId: string, target: AutoArchiveTarget): Promise<void> {
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<void> {
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" };
}

View File

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

View File

@@ -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<CreatePaseoWorktreeResult> {
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<WorkspaceGitService, "getCheckout">,
): 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<PersistedWorkspaceRecord> {
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

View File

@@ -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<void> {
await expect
.poll(
@@ -84,8 +97,9 @@ async function expectWorktreeListEmpty(repoDir: string): Promise<void> {
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({

View File

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

View File

@@ -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<ReturnType<WorkspaceGitService["getCheckout"]>>,
): 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,

View File

@@ -124,13 +124,12 @@ export async function archiveCommand(
): Promise<ArchiveCommandResult> {
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,
});

View File

@@ -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;
}