diff --git a/packages/server/src/server/paseo-worktree-service.test.ts b/packages/server/src/server/paseo-worktree-service.test.ts index 12e2a2fc2..bab9d53f2 100644 --- a/packages/server/src/server/paseo-worktree-service.test.ts +++ b/packages/server/src/server/paseo-worktree-service.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { mkdirSync, 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, expect, test, vi } from "vitest"; @@ -20,9 +20,8 @@ import { type CreatePaseoWorktreeDeps, } from "./paseo-worktree-service.js"; import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js"; -import { createWorktree } from "../utils/worktree.js"; +import { createWorktree, getPaseoWorktreesRoot } from "../utils/worktree.js"; import { isPlatform } from "../test-utils/platform.js"; -import { existsSync } from "node:fs"; import { areEquivalentPaths, createRealpathAwarePathMatcher } from "../utils/path.js"; const cleanupPaths: string[] = []; @@ -160,6 +159,7 @@ test("uses an equivalent source workspace path when creating a worktree", async const sourceDir = path.join(repoDir, "app"); mkdirSync(sourceDir); writeFileSync(path.join(repoDir, "app", ".gitkeep"), ""); + commitAll(repoDir, "add app"); const deps = createDeps(); const sourceProject = createPersistedProjectRecordForTest({ projectId: "prj_source-folder", @@ -194,6 +194,8 @@ test("creates a worktree workspace at the selected project subdirectory", async cleanupPaths.push(tempDir); const sourceDir = path.join(repoDir, "packages", "app"); mkdirSync(sourceDir, { recursive: true }); + writeFileSync(path.join(sourceDir, "package.json"), "{}\n"); + commitAll(repoDir, "add subproject"); const deps = createDeps(); const project = createPersistedProjectRecordForTest({ projectId: "prj_selected-subdirectory", @@ -221,10 +223,52 @@ test("creates a worktree workspace at the selected project subdirectory", async }); }); +test("removes a new worktree when its ref does not contain the selected project directory", async () => { + const { repoDir, tempDir } = createGitRepo(); + cleanupPaths.push(tempDir); + execFileSync("git", ["branch", "without-subproject"], { cwd: repoDir, stdio: "pipe" }); + const sourceDir = path.join(repoDir, "packages", "app"); + mkdirSync(sourceDir, { recursive: true }); + writeFileSync(path.join(sourceDir, "package.json"), "{}\n"); + commitAll(repoDir, "add subproject"); + const deps = createDeps(); + const paseoHome = path.join(tempDir, ".paseo"); + const worktreePath = path.join( + await getPaseoWorktreesRoot(repoDir, paseoHome), + "missing-subproject", + ); + + await expect( + createPaseoWorktree( + { + cwd: sourceDir, + action: "checkout", + refName: "without-subproject", + worktreeSlug: "missing-subproject", + runSetup: false, + paseoHome, + }, + deps, + ), + ).rejects.toThrow("Selected project directory is missing from the worktree"); + + expect(deps.workspaces.size).toBe(0); + expect(existsSync(worktreePath)).toBe(false); + expect( + execFileSync("git", ["worktree", "list", "--porcelain"], { cwd: repoDir, stdio: "pipe" }) + .toString() + .includes("missing-subproject"), + ).toBe(false); +}); + 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 projectDir = path.join(repoDir, "packages", "app"); + mkdirSync(projectDir, { recursive: true }); + writeFileSync(path.join(projectDir, "package.json"), "{}\n"); + commitAll(repoDir, "add subproject"); const deps = createDeps(); const source = await createPaseoWorktree( { @@ -236,7 +280,6 @@ test("maps a nested cwd from an existing Paseo worktree into the next worktree", deps, ); const sourceCwd = path.join(source.worktree.worktreePath, "packages", "app"); - mkdirSync(sourceCwd, { recursive: true }); const created = await createPaseoWorktree( { @@ -1154,6 +1197,11 @@ function createGitRepo(): { tempDir: string; repoDir: string } { return { tempDir, repoDir }; } +function commitAll(repoDir: string, message: string): void { + execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["commit", "-m", message], { cwd: repoDir, stdio: "pipe" }); +} + function createGitHubPrRemoteRepo(): { tempDir: string; repoDir: string } { const { tempDir, repoDir } = createGitRepo(); execFileSync("git", ["checkout", "-b", "pr-123"], { cwd: repoDir, stdio: "pipe" }); diff --git a/packages/server/src/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index ae3fe1107..35d56dc3c 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -1,5 +1,7 @@ -import type { WorkspaceGitService } from "./workspace-git-service.js"; +import { stat } from "node:fs/promises"; import { resolve } from "node:path"; + +import type { WorkspaceGitService } from "./workspace-git-service.js"; import { getRealpathAwareRelativePath } from "../utils/path.js"; import type { PersistedWorkspaceRecord } from "./workspace-registry.js"; import type { WorkspaceProvisioningService } from "./session/workspace-provisioning/workspace-provisioning-service.js"; @@ -10,6 +12,7 @@ import { } from "./worktree-core.js"; import { mapWorkspaceRelativeCwdToWorktree, + deletePaseoWorktree, validateBranchSlug, type WorktreeConfig, } from "../utils/worktree.js"; @@ -66,6 +69,17 @@ export async function createPaseoWorktree( relativeWorkspaceCwd: workspaceCwdPlan.relativeWorkspaceCwd, targetWorktreePath: createdWorktree.worktree.worktreePath, }); + if (!(await isDirectory(workspaceCwd))) { + if (createdWorktree.created) { + await deletePaseoWorktree({ + cwd: createdWorktree.repoRoot, + worktreePath: createdWorktree.worktree.worktreePath, + paseoHome: input.paseoHome, + worktreesBaseRoot: input.worktreesRoot, + }); + } + throw new Error(`Selected project directory is missing from the worktree: ${workspaceCwd}`); + } const workspace = await deps.workspaceProvisioning.createWorkspaceForWorktree({ sourceCwd: workspaceCwdPlan.inputCwd, projectId: input.projectId, @@ -88,6 +102,14 @@ export async function createPaseoWorktree( }; } +async function isDirectory(targetPath: string): Promise { + try { + return (await stat(targetPath)).isDirectory(); + } catch { + return false; + } +} + async function planWorkspaceCwdForWorktree( inputCwd: string, workspaceGitService: Pick, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 624c64ff1..f92362d19 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -3818,7 +3818,7 @@ export class Session { projectCustomName: projectRecord?.customName ?? null, projectRootPath: projectRecord?.rootPath ?? result.repoRoot, workspaceDirectory: result.workspace.cwd, - projectKind: "git", + projectKind: projectRecord?.kind ?? "git", workspaceKind: result.workspace.kind, name: resolveWorkspaceName({ title: result.workspace.title, diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index e087f8872..720eca3ac 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -3594,7 +3594,7 @@ test("project.remove.request removes an already-empty project", async () => { ]); }); -test("create paseo worktree request returns a registered workspace descriptor", async () => { +test("create paseo worktree response preserves an explicit non-Git project", async () => { const emitted: SessionOutboundMessage[] = []; const createdAt = "2026-05-12T12:00:00.000Z"; vi.setSystemTime(new Date(createdAt)); @@ -3657,7 +3657,15 @@ test("create paseo worktree request returns a registered workspace descriptor", ); const workspaces = new Map(); - const projects = new Map(); + const explicitProject = createPersistedProjectRecord({ + projectId: "prj_explicit_non_git", + rootPath: path.join(tempDir, "selected-project"), + kind: "non_git", + displayName: "Selected project", + createdAt, + updatedAt: createdAt, + }); + const projects = new Map([[explicitProject.projectId, explicitProject]]); session.paseoHome = paseoHome; session.workspaceRegistry.get = async (lookupWorkspaceId: string) => workspaces.get(lookupWorkspaceId) ?? null; @@ -3697,6 +3705,7 @@ test("create paseo worktree request returns a registered workspace descriptor", await session.handleCreatePaseoWorktreeRequest({ type: "create_paseo_worktree_request", cwd: repoDir, + projectId: explicitProject.projectId, worktreeSlug: "worktree-123", requestId: "req-worktree", }); @@ -3709,8 +3718,10 @@ test("create paseo worktree request returns a registered workspace descriptor", expect(response?.payload.error).toBeNull(); expect(response?.payload.workspace).toMatchObject({ - projectDisplayName: "repo", - projectKind: "git", + projectId: explicitProject.projectId, + projectDisplayName: explicitProject.displayName, + projectRootPath: explicitProject.rootPath, + projectKind: "non_git", workspaceKind: "worktree", name: "worktree-123", status: "done", @@ -3719,7 +3730,7 @@ test("create paseo worktree request returns a registered workspace descriptor", expect(response?.payload.workspace?.id).toMatch(/^wks_[0-9a-f]{16}$/); expect(response?.payload.workspace?.workspaceDirectory).toContain(path.join("worktree-123")); expect(workspaces.has(response?.payload.workspace?.id ?? "")).toBe(true); - expect(projects.has(response?.payload.workspace?.projectId ?? "")).toBe(true); + expect(projects.get(explicitProject.projectId)).toEqual(explicitProject); }); test("workspace update fanout for multiple cwd values is deduplicated", async () => {