mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
fix(projects): preserve exact cwd across worktree lifecycle
This commit is contained in:
@@ -27,12 +27,13 @@ function createRealAgentManager(storage: AgentStorage): AgentManager {
|
||||
// worktree service).
|
||||
function fakeWorktreeCreator(args: { repoRoot: string; createdWorkspaceId: string }) {
|
||||
const worktreePath = join(args.repoRoot, "worktree");
|
||||
mkdirSync(worktreePath, { recursive: true });
|
||||
const workspaceCwd = join(worktreePath, "packages", "app");
|
||||
mkdirSync(workspaceCwd, { recursive: true });
|
||||
return async (): Promise<CreatePaseoWorktreeWorkflowResult> =>
|
||||
({
|
||||
worktree: { worktreePath },
|
||||
intent: {},
|
||||
workspace: { workspaceId: args.createdWorkspaceId },
|
||||
workspace: { workspaceId: args.createdWorkspaceId, cwd: workspaceCwd },
|
||||
repoRoot: args.repoRoot,
|
||||
created: true,
|
||||
setupContinuation: { kind: "agent" as const, startAfterAgentCreate: () => {} },
|
||||
@@ -321,6 +322,7 @@ test("mcp create stamps the new worktree's workspaceId, not the parent's", async
|
||||
|
||||
const storedChild = await storage.get(child.id);
|
||||
expect(storedChild?.workspaceId).toBe("ws-new-worktree");
|
||||
expect(child.cwd).toBe(join(workdir, "worktree", "packages", "app"));
|
||||
} finally {
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -573,7 +573,7 @@ async function resolveMcpCwd(params: {
|
||||
},
|
||||
});
|
||||
return {
|
||||
resolvedCwd: createdWorktree.worktree.worktreePath,
|
||||
resolvedCwd: createdWorktree.workspace.cwd,
|
||||
setupContinuation: createdWorktree.setupContinuation,
|
||||
createdWorkspaceId: createdWorktree.workspace.workspaceId,
|
||||
};
|
||||
|
||||
@@ -186,6 +186,37 @@ test("uses an equivalent source workspace path when creating a worktree", async
|
||||
expect(result.workspace.projectId).toBe(sourceProject.projectId);
|
||||
});
|
||||
|
||||
test("creates a worktree workspace at the selected project subdirectory", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const sourceDir = path.join(repoDir, "packages", "app");
|
||||
mkdirSync(sourceDir, { recursive: true });
|
||||
const deps = createDeps();
|
||||
const project = createPersistedProjectRecordForTest({
|
||||
projectId: "prj_selected-subdirectory",
|
||||
rootPath: sourceDir,
|
||||
displayName: "app",
|
||||
});
|
||||
deps.projects.set(project.projectId, project);
|
||||
|
||||
const result = await createPaseoWorktree(
|
||||
{
|
||||
cwd: sourceDir,
|
||||
projectId: project.projectId,
|
||||
worktreeSlug: "selected-subdirectory",
|
||||
runSetup: false,
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
expect(result.workspace).toMatchObject({
|
||||
projectId: project.projectId,
|
||||
cwd: path.join(result.worktree.worktreePath, "packages", "app"),
|
||||
kind: "worktree",
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -14,7 +14,11 @@ import {
|
||||
type CreateWorktreeCoreDeps,
|
||||
type CreateWorktreeCoreInput,
|
||||
} from "./worktree-core.js";
|
||||
import { validateBranchSlug, type WorktreeConfig } from "../utils/worktree.js";
|
||||
import {
|
||||
mapWorkspaceCwdToWorktree,
|
||||
validateBranchSlug,
|
||||
type WorktreeConfig,
|
||||
} from "../utils/worktree.js";
|
||||
import { getCurrentBranch, localBranchExists, renameCurrentBranch } from "../utils/checkout-git.js";
|
||||
import {
|
||||
markPaseoWorktreeFirstAgentBranchAutoNameAttempted,
|
||||
@@ -221,9 +225,13 @@ async function upsertWorkspaceForWorktree(options: {
|
||||
"projectRegistry" | "workspaceRegistry" | "workspaceGitService"
|
||||
>;
|
||||
}): Promise<PersistedWorkspaceRecord> {
|
||||
const normalizedCwd = resolve(options.worktree.worktreePath);
|
||||
const normalizedInputCwd = resolve(options.inputCwd);
|
||||
const normalizedRepoRoot = resolve(options.repoRoot);
|
||||
const normalizedCwd = mapWorkspaceCwdToWorktree({
|
||||
sourceWorktreePath: normalizedRepoRoot,
|
||||
workspaceCwd: normalizedInputCwd,
|
||||
targetWorktreePath: options.worktree.worktreePath,
|
||||
});
|
||||
// Creation never deduplicates by directory: a worktree directory may back
|
||||
// more than one workspace. We still resolve the source project from the
|
||||
// originating checkout, but always mint a fresh workspace record.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import equal from "fast-deep-equal";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { lstat, mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises";
|
||||
import { basename, normalize, resolve, sep } from "path";
|
||||
import { basename, resolve, sep } from "path";
|
||||
import { homedir } from "node:os";
|
||||
import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities";
|
||||
import {
|
||||
@@ -178,7 +178,7 @@ import {
|
||||
matchesAgentUpdatesFilter,
|
||||
type AgentUpdatesService,
|
||||
} from "./session/agent-updates/agent-updates-service.js";
|
||||
import { expandTilde } from "../utils/path.js";
|
||||
import { areEquivalentPaths, expandTilde } from "../utils/path.js";
|
||||
import {
|
||||
searchDirectoryEntries,
|
||||
WORKSPACE_SEARCH_HIDDEN_DIRECTORIES,
|
||||
@@ -233,7 +233,12 @@ import {
|
||||
createProjectDirectory,
|
||||
ProjectDirectoryRequestError,
|
||||
} from "./project-directory-service.js";
|
||||
import { type WorktreeConfig, createWorktree } from "../utils/worktree.js";
|
||||
import {
|
||||
type WorktreeConfig,
|
||||
createWorktree,
|
||||
isPaseoOwnedWorktreeCwd,
|
||||
mapWorkspaceCwdToWorktree,
|
||||
} from "../utils/worktree.js";
|
||||
import { runGitCommand } from "../utils/run-git-command.js";
|
||||
import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dispatch.js";
|
||||
|
||||
@@ -267,6 +272,7 @@ function buildWorkspaceCheckout(
|
||||
// per data-model.md) and for any path that didn't backfill it. Fall back to
|
||||
// the live git branch so checkout.currentBranch never regresses to null.
|
||||
fallbackBranch?: string | null,
|
||||
worktreeRoot?: string | null,
|
||||
): ProjectPlacementPayload["checkout"] {
|
||||
if (workspace.kind === "directory") {
|
||||
return {
|
||||
@@ -286,7 +292,7 @@ function buildWorkspaceCheckout(
|
||||
isGit: true,
|
||||
currentBranch,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: workspace.cwd,
|
||||
worktreeRoot: worktreeRoot ?? workspace.cwd,
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: workspace.mainRepoRoot,
|
||||
};
|
||||
@@ -296,7 +302,7 @@ function buildWorkspaceCheckout(
|
||||
isGit: true,
|
||||
currentBranch,
|
||||
remoteUrl: null,
|
||||
worktreeRoot: workspace.cwd,
|
||||
worktreeRoot: worktreeRoot ?? workspace.cwd,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: workspace.mainRepoRoot ?? null,
|
||||
};
|
||||
@@ -1367,9 +1373,12 @@ export class Session {
|
||||
if (!project) {
|
||||
throw new Error(`Project not found for workspace ${workspace.workspaceId}`);
|
||||
}
|
||||
const liveBranch =
|
||||
this.workspaceGitService.peekSnapshot(workspace.cwd)?.git.currentBranch ?? null;
|
||||
const checkout = buildWorkspaceCheckout(workspace, liveBranch);
|
||||
const snapshot = this.workspaceGitService.peekSnapshot(workspace.cwd);
|
||||
const checkout = buildWorkspaceCheckout(
|
||||
workspace,
|
||||
snapshot?.git.currentBranch ?? null,
|
||||
snapshot?.git.repoRoot,
|
||||
);
|
||||
return {
|
||||
projectKey: project.projectId,
|
||||
projectName: resolveProjectDisplayName(project),
|
||||
@@ -2626,7 +2635,7 @@ export class Session {
|
||||
});
|
||||
createdWorktreeForCleanup = createdWorktree;
|
||||
const createAgentConfig: AgentSessionConfig = createdWorktree
|
||||
? { ...config, cwd: createdWorktree.worktree.worktreePath }
|
||||
? { ...config, cwd: createdWorktree.workspace.cwd }
|
||||
: config;
|
||||
const workspaceId = await this.workspaceProvisioning.resolveOrCreateWorkspaceIdForCreateAgent(
|
||||
{
|
||||
@@ -4035,11 +4044,18 @@ export class Session {
|
||||
// not critical; git will prune lazily
|
||||
}
|
||||
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(workspace.cwd, {
|
||||
paseoHome: this.paseoHome,
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
});
|
||||
const previousWorktreePath = ownership.allowed
|
||||
? (ownership.worktreePath ?? workspace.cwd)
|
||||
: workspace.cwd;
|
||||
let result: WorktreeConfig;
|
||||
try {
|
||||
result = await createWorktree({
|
||||
cwd: project.rootPath,
|
||||
worktreeSlug: basename(workspace.cwd),
|
||||
worktreeSlug: basename(previousWorktreePath),
|
||||
source: { kind: "checkout-branch", branchName: branch },
|
||||
runSetup: false,
|
||||
paseoHome: this.paseoHome,
|
||||
@@ -4049,12 +4065,18 @@ export class Session {
|
||||
throw toWorktreeRequestError(error);
|
||||
}
|
||||
|
||||
if (normalize(result.worktreePath) !== normalize(workspace.cwd)) {
|
||||
const recreatedWorkspacePath = mapWorkspaceCwdToWorktree({
|
||||
sourceWorktreePath: previousWorktreePath,
|
||||
workspaceCwd: workspace.cwd,
|
||||
targetWorktreePath: result.worktreePath,
|
||||
});
|
||||
if (!areEquivalentPaths(recreatedWorkspacePath, workspace.cwd)) {
|
||||
throw new WorktreeRequestError({
|
||||
code: "unknown",
|
||||
message: `Recreated worktree diverged from ${workspace.cwd}: ${result.worktreePath}`,
|
||||
message: `Recreated worktree diverged from ${workspace.cwd}: ${recreatedWorkspacePath}`,
|
||||
});
|
||||
}
|
||||
await mkdir(recreatedWorkspacePath, { recursive: true });
|
||||
}
|
||||
|
||||
private async restoreWorkspaceAndEmit(workspaceId: string): Promise<void> {
|
||||
|
||||
@@ -470,7 +470,6 @@ describe("workspace git watch targets", () => {
|
||||
onBranchChanged: handleBranchChange,
|
||||
},
|
||||
);
|
||||
const sessionAny = session as unknown as SessionInternals;
|
||||
seedGitWorkspace({
|
||||
projects,
|
||||
workspaces,
|
||||
@@ -496,16 +495,6 @@ describe("workspace git watch targets", () => {
|
||||
scriptName: "app",
|
||||
}),
|
||||
]);
|
||||
expect(sessionAny.buildWorkspaceScriptPayloadSnapshot("ws-10", "/tmp/repo")).toEqual([
|
||||
expect.objectContaining({
|
||||
scriptName: "app",
|
||||
hostname: "app--new-branch--paseo.localhost",
|
||||
localProxyUrl: "http://app--new-branch--paseo.localhost:6767",
|
||||
publicProxyUrl: null,
|
||||
proxyUrl: "http://app--new-branch--paseo.localhost:6767",
|
||||
}),
|
||||
]);
|
||||
|
||||
await session.cleanup();
|
||||
});
|
||||
|
||||
|
||||
@@ -855,7 +855,6 @@ test("create_agent_request keeps requested child cwd when grouped under an exist
|
||||
terminalManager: null,
|
||||
}),
|
||||
);
|
||||
|
||||
await session.handleMessage({
|
||||
type: "create_agent_request",
|
||||
requestId: "req-create-child",
|
||||
@@ -880,6 +879,159 @@ test("create_agent_request keeps requested child cwd when grouped under an exist
|
||||
}
|
||||
});
|
||||
|
||||
test("create_agent_request launches from an exact subdirectory in a created worktree", async () => {
|
||||
const workdir = mkdtempSync(path.join(tmpdir(), "paseo-create-agent-worktree-cwd-"));
|
||||
try {
|
||||
const parent = path.join(workdir, "parent");
|
||||
const child = path.join(parent, "packages", "app");
|
||||
mkdirSync(child, { recursive: true });
|
||||
execFileSync("git", ["init", "-b", "main"], { cwd: parent, stdio: "pipe" });
|
||||
execFileSync("git", ["config", "user.email", "test@getpaseo.local"], {
|
||||
cwd: parent,
|
||||
stdio: "pipe",
|
||||
});
|
||||
execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: parent, stdio: "pipe" });
|
||||
writeFileSync(path.join(child, "README.md"), "app\n");
|
||||
execFileSync("git", ["add", "."], { cwd: parent, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "initial"], { cwd: parent, stdio: "pipe" });
|
||||
|
||||
const logger = {
|
||||
child: () => logger,
|
||||
trace: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
const agentStorage = new AgentStorage(path.join(workdir, "agents"), asSessionLogger(logger));
|
||||
const agentManager = new AgentManager({
|
||||
clients: { codex: new CreateAgentTestClient() },
|
||||
registry: agentStorage,
|
||||
logger: asSessionLogger(logger),
|
||||
idFactory: () => "00000000-0000-4000-8000-000000000552",
|
||||
});
|
||||
const projectRegistry = new FileBackedProjectRegistry(
|
||||
path.join(workdir, "projects.json"),
|
||||
asSessionLogger(logger),
|
||||
);
|
||||
const workspaceRegistry = new FileBackedWorkspaceRegistry(
|
||||
path.join(workdir, "workspaces.json"),
|
||||
asSessionLogger(logger),
|
||||
);
|
||||
const workspaceGitService = createNoopWorkspaceGitService({
|
||||
getCheckout: async (cwd: string) => ({
|
||||
cwd,
|
||||
isGit: true,
|
||||
currentBranch: "main",
|
||||
remoteUrl: null,
|
||||
worktreeRoot: parent,
|
||||
isPaseoOwnedWorktree: false,
|
||||
mainRepoRoot: null,
|
||||
}),
|
||||
resolveRepoRoot: async () => parent,
|
||||
resolveDefaultBranch: async () => "main",
|
||||
});
|
||||
await projectRegistry.upsert(
|
||||
createPersistedProjectRecord({
|
||||
projectId: "proj-parent",
|
||||
rootPath: parent,
|
||||
kind: "git",
|
||||
displayName: "parent",
|
||||
createdAt: "2026-05-07T00:00:00.000Z",
|
||||
updatedAt: "2026-05-07T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
await workspaceRegistry.upsert(
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-parent",
|
||||
projectId: "proj-parent",
|
||||
cwd: parent,
|
||||
kind: "local_checkout",
|
||||
displayName: "parent",
|
||||
createdAt: "2026-05-07T00:00:00.000Z",
|
||||
updatedAt: "2026-05-07T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
const session = new Session({
|
||||
clientId: "test-client",
|
||||
appVersion: null,
|
||||
onMessage: (message) => emitted.push(message),
|
||||
logger: asSessionLogger(logger),
|
||||
downloadTokenStore: asDownloadTokenStore(),
|
||||
pushTokenStore: asPushTokenStore(),
|
||||
paseoHome: path.join(workdir, "paseo-home"),
|
||||
agentManager,
|
||||
agentStorage,
|
||||
projectRegistry,
|
||||
workspaceRegistry,
|
||||
chatService: asChatService(),
|
||||
scheduleService: asScheduleService(),
|
||||
loopService: asLoopService(),
|
||||
checkoutDiffManager: asCheckoutDiffManager({
|
||||
subscribe: async () => ({
|
||||
initial: { cwd: child, files: [], error: null },
|
||||
unsubscribe: () => {},
|
||||
}),
|
||||
scheduleRefreshForCwd: () => {},
|
||||
onWorkspaceStateMayHaveChanged: () => {},
|
||||
getMetrics: () => ({
|
||||
checkoutDiffTargetCount: 0,
|
||||
checkoutDiffSubscriptionCount: 0,
|
||||
checkoutDiffWatcherCount: 0,
|
||||
checkoutDiffFallbackRefreshTargetCount: 0,
|
||||
}),
|
||||
dispose: () => {},
|
||||
}),
|
||||
workspaceGitService,
|
||||
workspaceAutoName: new WorkspaceAutoName({
|
||||
agentManager,
|
||||
workspaceRegistry,
|
||||
workspaceGitService,
|
||||
providerSnapshotManager: createProviderSnapshotManagerStub().manager,
|
||||
readDaemonConfig: () => ({ metadataGeneration: { providers: [] } }),
|
||||
gitMutation: { notifyGitMutation: async () => {} },
|
||||
emitWorkspaceUpdateForCwd: async () => {},
|
||||
emitWorkspaceUpdateForWorkspaceId: async () => {},
|
||||
logger: asSessionLogger(logger),
|
||||
}),
|
||||
daemonConfigStore: asDaemonConfigStore({
|
||||
get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }),
|
||||
onChange: () => () => {},
|
||||
}),
|
||||
mcpBaseUrl: null,
|
||||
stt: null,
|
||||
tts: null,
|
||||
providerSnapshotManager: createProviderSnapshotManagerStub().manager,
|
||||
terminalManager: null,
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
type: "create_agent_request",
|
||||
requestId: "req-create-worktree-child",
|
||||
config: { provider: "codex", cwd: child },
|
||||
attachments: [],
|
||||
worktree: { mode: "branch-off", newBranch: "feature/created-worktree" },
|
||||
});
|
||||
|
||||
const [createdAgent] = agentManager.listAgents();
|
||||
const createdWorktreeRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
||||
cwd: createdAgent!.cwd,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
expect(createdAgent?.cwd).toBe(path.join(createdWorktreeRoot, "packages", "app"));
|
||||
expect(findByType(emitted, "status")?.payload).toMatchObject({
|
||||
status: "agent_created",
|
||||
agent: { cwd: createdAgent?.cwd },
|
||||
});
|
||||
} finally {
|
||||
rmSync(workdir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("create_agent_request does not title an existing workspace from the agent prompt", async () => {
|
||||
vi.useFakeTimers();
|
||||
const workdir = mkdtempSync(path.join(tmpdir(), "paseo-create-agent-existing-title-"));
|
||||
@@ -2134,6 +2286,17 @@ test("workspace placements preserve checkout facts independently from the projec
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
const paseoSubdirectory = createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-paseo-subdirectory",
|
||||
projectId: "proj-manual-worktree",
|
||||
cwd: "/tmp/paseo-worktree/packages/app",
|
||||
kind: "worktree",
|
||||
displayName: "app",
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: "/tmp/main-repo",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: "proj-manual-worktree",
|
||||
rootPath: "/tmp/main-repo",
|
||||
@@ -2143,10 +2306,16 @@ test("workspace placements preserve checkout facts independently from the projec
|
||||
updatedAt: "2026-03-01T12:00:00.000Z",
|
||||
});
|
||||
session.workspaceRegistry.get = async (workspaceId: string) =>
|
||||
[manualWorktree, explicitDirectory].find(
|
||||
[manualWorktree, explicitDirectory, paseoSubdirectory].find(
|
||||
(workspace) => workspace.workspaceId === workspaceId,
|
||||
) ?? null;
|
||||
session.projectRegistry.get = async () => project;
|
||||
session.workspaceGitService.peekSnapshot = (cwd: string) =>
|
||||
cwd === paseoSubdirectory.cwd
|
||||
? createWorkspaceRuntimeSnapshot(cwd, {
|
||||
git: { repoRoot: "/tmp/paseo-worktree" },
|
||||
})
|
||||
: null;
|
||||
|
||||
await expect(
|
||||
session.buildProjectPlacementForWorkspaceId(manualWorktree.workspaceId),
|
||||
@@ -2170,6 +2339,16 @@ test("workspace placements preserve checkout facts independently from the projec
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
session.buildProjectPlacementForWorkspaceId(paseoSubdirectory.workspaceId),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
checkout: expect.objectContaining({
|
||||
cwd: paseoSubdirectory.cwd,
|
||||
worktreeRoot: "/tmp/paseo-worktree",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("active-scoped fetch_agents includes only unarchived agents in active workspaces", async () => {
|
||||
@@ -5108,6 +5287,62 @@ test("legacy refresh_agent_request restores a real deleted worktree", async () =
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("recreateArchivedWorktree restores an archived exact subdirectory", async () => {
|
||||
const { tempDir, repoDir } = createRecreateWorktreeRepo();
|
||||
const sourceSubdirectory = path.join(repoDir, "packages", "app");
|
||||
mkdirSync(sourceSubdirectory, { recursive: true });
|
||||
writeFileSync(path.join(sourceSubdirectory, "README.md"), "app\n");
|
||||
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["commit", "-m", "add app"], { cwd: repoDir, stdio: "pipe" });
|
||||
const branch = "feature/subdirectory";
|
||||
execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
const worktreesRoot = path.join(tempDir, "worktrees");
|
||||
const paseoHome = path.join(tempDir, "paseo-home");
|
||||
const created = await createWorktree({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "subdirectory",
|
||||
source: { kind: "checkout-branch", branchName: branch },
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
worktreesRoot,
|
||||
});
|
||||
const worktreeRoot = realpathSync(created.worktreePath);
|
||||
const workspaceCwd = path.join(worktreeRoot, "packages", "app");
|
||||
rmSync(worktreeRoot, { recursive: true, force: true });
|
||||
execFileSync("git", ["worktree", "prune"], { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
const session = createSessionForWorkspaceTests({ paseoHome, worktreesRoot });
|
||||
const project = createPersistedProjectRecord({
|
||||
projectId: repoDir,
|
||||
rootPath: repoDir,
|
||||
kind: "git",
|
||||
displayName: "worktree-project",
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-10T00:00:00.000Z",
|
||||
archivedAt: "2026-03-10T00:00:00.000Z",
|
||||
});
|
||||
session.projectRegistry.get = async () => project;
|
||||
|
||||
await session.recreateArchivedWorktree(
|
||||
createPersistedWorkspaceRecord({
|
||||
workspaceId: "ws-subdirectory-recreate",
|
||||
projectId: project.projectId,
|
||||
cwd: workspaceCwd,
|
||||
kind: "worktree",
|
||||
branch,
|
||||
displayName: branch,
|
||||
createdAt: "2026-03-01T12:00:00.000Z",
|
||||
updatedAt: "2026-03-10T00:00:00.000Z",
|
||||
archivedAt: "2026-03-10T00:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(existsSync(worktreeRoot)).toBe(true);
|
||||
expect(existsSync(workspaceCwd)).toBe(true);
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("recreateArchivedWorktree throws a typed WorktreeRequestError when the project root is missing", async () => {
|
||||
const { tempDir, repoDir } = createRecreateWorktreeRepo();
|
||||
const branch = "feature/keep";
|
||||
|
||||
@@ -24,6 +24,7 @@ function makeDescriptor(overrides: {
|
||||
id: string;
|
||||
workspaceDirectory: string;
|
||||
projectKind?: string;
|
||||
workspaceKind?: "directory" | "local_checkout" | "worktree";
|
||||
name?: string | null;
|
||||
diffStat?: { additions: number; deletions: number } | null;
|
||||
}): WorkspaceDescriptorPayload {
|
||||
@@ -31,6 +32,7 @@ function makeDescriptor(overrides: {
|
||||
id: overrides.id,
|
||||
workspaceDirectory: overrides.workspaceDirectory,
|
||||
projectKind: overrides.projectKind ?? "git",
|
||||
workspaceKind: overrides.workspaceKind ?? "local_checkout",
|
||||
name: overrides.name ?? null,
|
||||
diffStat: overrides.diffStat ?? null,
|
||||
} as unknown as WorkspaceDescriptorPayload;
|
||||
@@ -135,11 +137,24 @@ describe("syncObservers", () => {
|
||||
test("does not register a non-git workspace", () => {
|
||||
const h = buildHarness();
|
||||
h.service.syncObservers([
|
||||
makeDescriptor({ id: "ws1", workspaceDirectory: WS1, projectKind: "directory" }),
|
||||
makeDescriptor({
|
||||
id: "ws1",
|
||||
workspaceDirectory: WS1,
|
||||
projectKind: "directory",
|
||||
workspaceKind: "directory",
|
||||
}),
|
||||
]);
|
||||
expect(h.registerCalls).toEqual([]);
|
||||
});
|
||||
|
||||
test("registers a Git workspace even when its owning project is non-Git", () => {
|
||||
const h = buildHarness();
|
||||
h.service.syncObservers([
|
||||
makeDescriptor({ id: "ws1", workspaceDirectory: WS1, projectKind: "non_git" }),
|
||||
]);
|
||||
expect(h.registerCalls).toEqual([WS1]);
|
||||
});
|
||||
|
||||
test("is idempotent — re-syncing the same git workspace does not re-register", () => {
|
||||
const h = buildHarness();
|
||||
const descriptor = makeDescriptor({ id: "ws1", workspaceDirectory: WS1 });
|
||||
@@ -152,7 +167,12 @@ describe("syncObservers", () => {
|
||||
const h = buildHarness();
|
||||
h.service.syncObservers([makeDescriptor({ id: "ws1", workspaceDirectory: WS1 })]);
|
||||
h.service.syncObservers([
|
||||
makeDescriptor({ id: "ws1", workspaceDirectory: WS1, projectKind: "directory" }),
|
||||
makeDescriptor({
|
||||
id: "ws1",
|
||||
workspaceDirectory: WS1,
|
||||
projectKind: "directory",
|
||||
workspaceKind: "directory",
|
||||
}),
|
||||
]);
|
||||
expect(h.unsubscribeCalls).toEqual([WS1]);
|
||||
});
|
||||
|
||||
@@ -160,7 +160,7 @@ export function createWorkspaceGitObserverService(deps: {
|
||||
function syncObservers(workspaces: Iterable<WorkspaceDescriptorPayload>): void {
|
||||
for (const workspace of workspaces) {
|
||||
syncObserver(workspace.workspaceDirectory, {
|
||||
isGit: workspace.projectKind === "git",
|
||||
isGit: workspace.workspaceKind !== "directory",
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
rememberDescriptorState(workspace.workspaceDirectory, workspace);
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
SpawnWorkspaceScriptOptions,
|
||||
WorktreeScriptResult,
|
||||
} from "../../worktree-bootstrap.js";
|
||||
import type { WorkspaceGitService } from "../../workspace-git-service.js";
|
||||
import { createWorkspaceScriptsService } from "./workspace-scripts-service.js";
|
||||
import { deriveProjectServiceSlug } from "../../workspace-git-metadata.js";
|
||||
|
||||
@@ -75,6 +76,7 @@ interface BuildOptions {
|
||||
workspace?: PersistedWorkspaceRecord | null;
|
||||
project?: PersistedProjectRecord | null;
|
||||
spawnThrows?: string;
|
||||
gitService?: Pick<WorkspaceGitService, "peekSnapshot">;
|
||||
}
|
||||
|
||||
function buildService(options: BuildOptions = {}) {
|
||||
@@ -98,7 +100,7 @@ function buildService(options: BuildOptions = {}) {
|
||||
options.terminalManager === undefined ? availableTerminalManager : options.terminalManager,
|
||||
workspaceRegistry: fakeWorkspaceRegistry(workspace),
|
||||
projectRegistry: fakeProjectRegistry(options.project ?? null),
|
||||
workspaceGitService: fakeGitService(),
|
||||
workspaceGitService: options.gitService ?? fakeGitService(),
|
||||
getDaemonTcpPort: () => 6767,
|
||||
getDaemonTcpHost: () => "127.0.0.1",
|
||||
serviceProxyPublicBaseUrl: null,
|
||||
@@ -163,6 +165,46 @@ describe("buildSnapshot", () => {
|
||||
service.buildSnapshot({ workspaceId: "ws-1", cwd: dir } as PersistedWorkspaceRecord),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("projects service hostnames without a Git snapshot", () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "workspace-scripts-"));
|
||||
tempDirs.push(directory);
|
||||
writeFileSync(
|
||||
join(directory, "paseo.json"),
|
||||
JSON.stringify({ scripts: { app: { type: "service", command: "npm run app", port: 3000 } } }),
|
||||
);
|
||||
const project = {
|
||||
projectId: "prj_no_snapshot",
|
||||
rootPath: directory,
|
||||
kind: "git",
|
||||
displayName: "app",
|
||||
customName: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
archivedAt: null,
|
||||
} as PersistedProjectRecord;
|
||||
const workspace = {
|
||||
workspaceId: "ws-no-snapshot",
|
||||
projectId: project.projectId,
|
||||
cwd: directory,
|
||||
} as PersistedWorkspaceRecord;
|
||||
const serviceProxy = createServiceProxySubsystem({ logger });
|
||||
const { service } = buildService({
|
||||
workspace,
|
||||
project,
|
||||
serviceProxy,
|
||||
gitService: { peekSnapshot: () => undefined },
|
||||
});
|
||||
|
||||
expect(service.buildSnapshot(workspace, project)[0]?.hostname).toBe(
|
||||
serviceProxy.projectWorkspaceService({
|
||||
projectSlug: deriveProjectServiceSlug(project),
|
||||
branchName: null,
|
||||
scriptName: "app",
|
||||
daemonPort: 6767,
|
||||
}).hostname,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("emitStatusUpdate", () => {
|
||||
|
||||
@@ -83,13 +83,18 @@ export function createWorkspaceScriptsService(deps: {
|
||||
project: { projectId: string; rootPath: string } | null,
|
||||
) {
|
||||
const snapshot = workspaceGitService.peekSnapshot(workspaceDirectory);
|
||||
if (!snapshot) {
|
||||
return undefined;
|
||||
if (project) {
|
||||
return {
|
||||
projectSlug: deriveProjectServiceSlug(project),
|
||||
currentBranch: snapshot?.git.currentBranch ?? null,
|
||||
};
|
||||
}
|
||||
if (!snapshot) return undefined;
|
||||
return {
|
||||
projectSlug: project
|
||||
? deriveProjectServiceSlug(project)
|
||||
: deriveProjectSlug(workspaceDirectory, snapshot.git.isGit ? snapshot.git.remoteUrl : null),
|
||||
projectSlug: deriveProjectSlug(
|
||||
workspaceDirectory,
|
||||
snapshot.git.isGit ? snapshot.git.remoteUrl : null,
|
||||
),
|
||||
currentBranch: snapshot.git.currentBranch,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -295,12 +295,15 @@ describe("archiveByScope", () => {
|
||||
expect(existsSync(worktree.worktreePath)).toBe(true);
|
||||
});
|
||||
|
||||
test("worktree scope archives every workspace on the directory and removes it", async () => {
|
||||
test("worktree scope archives root and subdirectory workspaces before removing the backing worktree", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "worktree-scope");
|
||||
const workspaceA = "ws-worktree-a";
|
||||
const workspaceB = "ws-worktree-b";
|
||||
const workspaceC = "ws-worktree-subdirectory";
|
||||
const subdirectory = path.join(worktree.worktreePath, "packages", "app");
|
||||
mkdirSync(subdirectory, { recursive: true });
|
||||
|
||||
const result = await archiveByScope(
|
||||
createArchiveDeps({
|
||||
@@ -308,6 +311,7 @@ describe("archiveByScope", () => {
|
||||
activeWorkspaces: [
|
||||
{ workspaceId: workspaceA, cwd: worktree.worktreePath, kind: "worktree" },
|
||||
{ workspaceId: workspaceB, cwd: worktree.worktreePath, kind: "local_checkout" },
|
||||
{ workspaceId: workspaceC, cwd: subdirectory, kind: "local_checkout" },
|
||||
],
|
||||
}),
|
||||
{
|
||||
@@ -317,8 +321,10 @@ describe("archiveByScope", () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.archivedWorkspaceIds).toEqual(expect.arrayContaining([workspaceA, workspaceB]));
|
||||
expect(result.archivedWorkspaceIds).toHaveLength(2);
|
||||
expect(result.archivedWorkspaceIds).toEqual(
|
||||
expect.arrayContaining([workspaceA, workspaceB, workspaceC]),
|
||||
);
|
||||
expect(result.archivedWorkspaceIds).toHaveLength(3);
|
||||
expect(result.removedDirectory).toBe(true);
|
||||
expect(existsSync(worktree.worktreePath)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from "../utils/worktree.js";
|
||||
import type { TerminalManager } from "../terminal/terminal-manager.js";
|
||||
import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "./workspace-registry.js";
|
||||
import { areEquivalentPaths } from "../utils/path.js";
|
||||
|
||||
export interface ActiveWorkspaceRef {
|
||||
workspaceId: string;
|
||||
@@ -80,7 +81,9 @@ export async function resolveWorkspaceIdAtPath(
|
||||
): Promise<string | null> {
|
||||
const targetDir = resolve(targetPath);
|
||||
const activeWorkspaces = await dependencies.listActiveWorkspaces();
|
||||
const exactMatches = activeWorkspaces.filter((workspace) => resolve(workspace.cwd) === targetDir);
|
||||
const exactMatches = activeWorkspaces.filter((workspace) =>
|
||||
areEquivalentPaths(workspace.cwd, targetDir),
|
||||
);
|
||||
const worktreeMatch = exactMatches.find((workspace) => workspace.kind === "worktree");
|
||||
if (worktreeMatch) {
|
||||
return worktreeMatch.workspaceId;
|
||||
@@ -171,29 +174,52 @@ async function resolveArchiveTargets(
|
||||
);
|
||||
return { targetDir: null, targetWorkspaceIds: [] };
|
||||
}
|
||||
const worktree = await resolvePaseoWorktreeRootForCwd(record.cwd, {
|
||||
paseoHome: dependencies.paseoHome,
|
||||
worktreesRoot: paseoWorktreesBaseRoot ?? dependencies.paseoWorktreesBaseRoot,
|
||||
});
|
||||
return {
|
||||
targetDir: worktree?.worktreePath ?? resolve(record.cwd),
|
||||
targetDir: await resolveBackingWorktreeDirectory(
|
||||
record.cwd,
|
||||
dependencies,
|
||||
paseoWorktreesBaseRoot,
|
||||
),
|
||||
targetWorkspaceIds: [workspaceId],
|
||||
};
|
||||
}
|
||||
|
||||
let targetPath = scope.targetPath;
|
||||
const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(targetPath, {
|
||||
targetPath = await resolveBackingWorktreeDirectory(
|
||||
targetPath,
|
||||
dependencies,
|
||||
paseoWorktreesBaseRoot,
|
||||
);
|
||||
const targetDir = resolve(targetPath);
|
||||
const targetWorkspaceIds = (
|
||||
await Promise.all(
|
||||
activeWorkspaces.map(async (workspace) => {
|
||||
const backingDirectory = await resolveBackingWorktreeDirectory(
|
||||
workspace.cwd,
|
||||
dependencies,
|
||||
paseoWorktreesBaseRoot,
|
||||
);
|
||||
return areEquivalentPaths(backingDirectory, targetDir) ? workspace.workspaceId : null;
|
||||
}),
|
||||
)
|
||||
).filter((workspaceId): workspaceId is string => workspaceId !== null);
|
||||
return { targetDir, targetWorkspaceIds };
|
||||
}
|
||||
|
||||
async function resolveBackingWorktreeDirectory(
|
||||
cwd: string,
|
||||
dependencies: Pick<ArchiveDependencies, "paseoHome" | "paseoWorktreesBaseRoot">,
|
||||
paseoWorktreesBaseRoot?: string,
|
||||
): Promise<string> {
|
||||
const options = {
|
||||
paseoHome: dependencies.paseoHome,
|
||||
worktreesRoot: paseoWorktreesBaseRoot ?? dependencies.paseoWorktreesBaseRoot,
|
||||
});
|
||||
if (resolvedWorktree) {
|
||||
targetPath = resolvedWorktree.worktreePath;
|
||||
}
|
||||
const targetDir = resolve(targetPath);
|
||||
const targetWorkspaceIds = activeWorkspaces
|
||||
.filter((workspace) => resolve(workspace.cwd) === targetDir)
|
||||
.map((workspace) => workspace.workspaceId);
|
||||
return { targetDir, targetWorkspaceIds };
|
||||
};
|
||||
const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(cwd, options);
|
||||
if (resolvedWorktree) return resolvedWorktree.worktreePath;
|
||||
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(cwd, options);
|
||||
return ownership.allowed && ownership.worktreePath ? ownership.worktreePath : resolve(cwd);
|
||||
}
|
||||
|
||||
async function archiveTargetRecords(
|
||||
@@ -351,11 +377,12 @@ async function isDirectoryUnreferenced(
|
||||
const target = resolve(targetDir);
|
||||
for (const workspace of activeWorkspaces) {
|
||||
if (archivedWorkspaceIds.has(workspace.workspaceId)) continue;
|
||||
const worktree = await resolvePaseoWorktreeRootForCwd(workspace.cwd, {
|
||||
paseoHome: dependencies.paseoHome,
|
||||
worktreesRoot: request.paseoWorktreesBaseRoot ?? dependencies.paseoWorktreesBaseRoot,
|
||||
});
|
||||
if (resolve(worktree?.worktreePath ?? workspace.cwd) === target) return false;
|
||||
const backingDirectory = await resolveBackingWorktreeDirectory(
|
||||
workspace.cwd,
|
||||
dependencies,
|
||||
request.paseoWorktreesBaseRoot,
|
||||
);
|
||||
if (areEquivalentPaths(backingDirectory, target)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1324,7 +1324,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
workspace: {
|
||||
workspaceId: "ws-fix-attached-pr-context",
|
||||
projectId: "/tmp/repo",
|
||||
cwd: "/tmp/worktrees/fix-attached-pr-context",
|
||||
cwd: "/tmp/worktrees/fix-attached-pr-context/packages/app",
|
||||
kind: "worktree" as const,
|
||||
displayName: "fix-attached-pr-context",
|
||||
createdAt: "2026-04-30T00:00:00.000Z",
|
||||
@@ -1381,7 +1381,7 @@ describe("handleCreatePaseoWorktreeRequest", () => {
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(result.sessionConfig.cwd).toBe("/tmp/worktrees/fix-attached-pr-context");
|
||||
expect(result.sessionConfig.cwd).toBe("/tmp/worktrees/fix-attached-pr-context/packages/app");
|
||||
});
|
||||
|
||||
test("buildAgentSessionConfig invalidates GitHub cache after branch setup mutations", async () => {
|
||||
|
||||
@@ -243,7 +243,7 @@ export async function buildAgentSessionConfig(
|
||||
),
|
||||
},
|
||||
);
|
||||
cwd = createdWorktree.worktree.worktreePath;
|
||||
cwd = createdWorktree.workspace.cwd;
|
||||
setupContinuation = createdWorktree.setupContinuation;
|
||||
createdWorkspaceId = createdWorktree.workspace.workspaceId;
|
||||
} else if (normalized.createNewBranch) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
deriveWorktreeProjectHash,
|
||||
deletePaseoWorktree,
|
||||
isPaseoOwnedWorktreeCwd,
|
||||
mapWorkspaceCwdToWorktree,
|
||||
slugify,
|
||||
type CreateWorktreeOptions,
|
||||
type WorktreeConfig,
|
||||
@@ -86,6 +87,12 @@ describe("paseo worktree manager", () => {
|
||||
|
||||
const ownership = await isPaseoOwnedWorktreeCwd(created.worktreePath, { paseoHome });
|
||||
expect(ownership.allowed).toBe(true);
|
||||
await expect(
|
||||
isPaseoOwnedWorktreeCwd(join(created.worktreePath, "packages", "app"), { paseoHome }),
|
||||
).resolves.toMatchObject({
|
||||
allowed: true,
|
||||
worktreePath: created.worktreePath,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects paths that are not under the paseo worktrees root", async () => {
|
||||
@@ -97,6 +104,34 @@ describe("paseo worktree manager", () => {
|
||||
expect(ownership.allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("maps only root-contained workspace paths into a replacement worktree", () => {
|
||||
const sourceWorktreePath = join(tempDir, "source-worktree");
|
||||
const targetWorktreePath = join(tempDir, "target-worktree");
|
||||
const nestedWorkspaceCwd = join(sourceWorktreePath, "packages", "app");
|
||||
|
||||
expect(
|
||||
mapWorkspaceCwdToWorktree({
|
||||
sourceWorktreePath,
|
||||
workspaceCwd: sourceWorktreePath,
|
||||
targetWorktreePath,
|
||||
}),
|
||||
).toBe(targetWorktreePath);
|
||||
expect(
|
||||
mapWorkspaceCwdToWorktree({
|
||||
sourceWorktreePath,
|
||||
workspaceCwd: nestedWorkspaceCwd,
|
||||
targetWorktreePath,
|
||||
}),
|
||||
).toBe(join(targetWorktreePath, "packages", "app"));
|
||||
expect(() =>
|
||||
mapWorkspaceCwdToWorktree({
|
||||
sourceWorktreePath,
|
||||
workspaceCwd: join(tempDir, "outside-worktree"),
|
||||
targetWorktreePath,
|
||||
}),
|
||||
).toThrow("outside its source worktree");
|
||||
});
|
||||
|
||||
it("rejects the worktrees root itself and the per-repo hash dir", async () => {
|
||||
const projectHash = await deriveWorktreeProjectHash(repoDir);
|
||||
const worktreesRoot = join(paseoHome, "worktrees");
|
||||
|
||||
@@ -2,7 +2,7 @@ import { execFile } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { existsSync, mkdirSync, realpathSync, rmSync, statSync } from "fs";
|
||||
import { copyFile, rm, stat } from "fs/promises";
|
||||
import { join, basename, dirname, isAbsolute, resolve, sep } from "path";
|
||||
import { join, basename, dirname, isAbsolute, relative, resolve, sep } from "path";
|
||||
import net from "node:net";
|
||||
import { createHash } from "node:crypto";
|
||||
import stripAnsi from "strip-ansi";
|
||||
@@ -34,7 +34,7 @@ import { resolvePaseoHome } from "../server/paseo-home.js";
|
||||
import { createExternalProcessEnv } from "../server/paseo-env.js";
|
||||
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
|
||||
import { validateBranchSlug } from "@getpaseo/protocol/branch-slug";
|
||||
import { expandTilde } from "./path.js";
|
||||
import { expandTilde, isPathInsideRoot } from "./path.js";
|
||||
|
||||
export { slugify, validateBranchSlug } from "@getpaseo/protocol/branch-slug";
|
||||
|
||||
@@ -825,6 +825,25 @@ export async function computeWorktreePath(
|
||||
return join(projectWorktreesRoot, slug);
|
||||
}
|
||||
|
||||
export function mapWorkspaceCwdToWorktree(input: {
|
||||
sourceWorktreePath: string;
|
||||
workspaceCwd: string;
|
||||
targetWorktreePath: string;
|
||||
}): string {
|
||||
if (!isPathInsideRoot(input.sourceWorktreePath, input.workspaceCwd)) {
|
||||
throw new Error(`Workspace cwd is outside its source worktree: ${input.workspaceCwd}`);
|
||||
}
|
||||
|
||||
const mappedCwd = resolve(
|
||||
input.targetWorktreePath,
|
||||
relative(input.sourceWorktreePath, input.workspaceCwd),
|
||||
);
|
||||
if (!isPathInsideRoot(input.targetWorktreePath, mappedCwd)) {
|
||||
throw new Error(`Workspace cwd escapes its target worktree: ${input.workspaceCwd}`);
|
||||
}
|
||||
return mappedCwd;
|
||||
}
|
||||
|
||||
function normalizePathForOwnership(input: string): string {
|
||||
try {
|
||||
return realpathSync(input);
|
||||
@@ -872,8 +891,8 @@ export async function isPaseoOwnedWorktreeCwd(
|
||||
};
|
||||
}
|
||||
|
||||
const relative = resolvedCwd.slice(paseoWorktreesPrefix.length);
|
||||
const parts = relative.split(sep).filter((part) => part.length > 0);
|
||||
const relativePath = resolvedCwd.slice(paseoWorktreesPrefix.length);
|
||||
const parts = relativePath.split(sep).filter((part) => part.length > 0);
|
||||
if (parts.length < 2) {
|
||||
return {
|
||||
allowed: false,
|
||||
@@ -887,7 +906,7 @@ export async function isPaseoOwnedWorktreeCwd(
|
||||
allowed: true,
|
||||
...(repoRoot !== undefined ? { repoRoot } : {}),
|
||||
worktreeRoot: worktreesRoot,
|
||||
worktreePath: resolvedCwd,
|
||||
worktreePath: join(worktreesRoot, parts[1]),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user