fix(projects): preserve exact cwd across worktree lifecycle

This commit is contained in:
Mohamed Boudra
2026-07-16 16:43:27 +00:00
parent b4ac6a04d4
commit fe7d46ce54
17 changed files with 512 additions and 71 deletions

View File

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

View File

@@ -573,7 +573,7 @@ async function resolveMcpCwd(params: {
},
});
return {
resolvedCwd: createdWorktree.worktree.worktreePath,
resolvedCwd: createdWorktree.workspace.cwd,
setupContinuation: createdWorktree.setupContinuation,
createdWorkspaceId: createdWorktree.workspace.workspaceId,
};

View File

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

View File

@@ -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.

View File

@@ -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> {

View File

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

View File

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

View File

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

View File

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

View File

@@ -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", () => {

View File

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

View File

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

View File

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

View File

@@ -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 () => {

View File

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

View File

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

View File

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