From 1340a801448c9c11dd4fc670be92108add5fb836 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 17 Jun 2026 15:15:58 +0800 Subject: [PATCH] Fix New Workspace PR worktree creation (#1565) * Fix PR worktree creation from New Workspace * Use fixture default branch in GitHub E2E clone --- packages/app/e2e/helpers/github-fixtures.ts | 42 +++- packages/app/e2e/new-workspace.spec.ts | 52 ++++- .../src/screens/new-workspace-picker-item.ts | 6 +- .../app/src/screens/new-workspace-screen.tsx | 6 +- .../src/server/session.workspaces.test.ts | 204 +++++++++++++++++- 5 files changed, 294 insertions(+), 16 deletions(-) diff --git a/packages/app/e2e/helpers/github-fixtures.ts b/packages/app/e2e/helpers/github-fixtures.ts index 67dbfd22a..e3694b671 100644 --- a/packages/app/e2e/helpers/github-fixtures.ts +++ b/packages/app/e2e/helpers/github-fixtures.ts @@ -48,11 +48,17 @@ export interface GhRepoFixture { owner: string; name: string; fullName: string; + defaultBranch: string; prs: GhPrFixture[]; issues: GhIssueFixture[]; cleanup(): Promise; } +export interface GhDefaultBranchClone { + path: string; + cleanup(): Promise; +} + function gh(args: string[], opts?: { cwd?: string }): string { return execFileSync("gh", args, { cwd: opts?.cwd, @@ -77,8 +83,9 @@ async function seedPr(args: { authedUrl: string; fullName: string; repoName: string; + defaultBranch: string; }): Promise<{ fixture: GhPrFixture; localPath: string }> { - const { spec, branch, index, basePath, authedUrl, fullName, repoName } = args; + const { spec, branch, index, basePath, authedUrl, fullName, repoName, defaultBranch } = args; const createArgs = [ "pr", @@ -86,7 +93,7 @@ async function seedPr(args: { "--title", spec.title, "--base", - "main", + defaultBranch, "--head", branch, "--body", @@ -166,10 +173,11 @@ export async function createTempGithubRepo(options: { const { category, prs = [], issues = [] } = options; const uniqueSuffix = `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; const repoName = `${TEMP_GITHUB_REPO_PREFIX}${category}-${uniqueSuffix}`; + const defaultBranch = "main"; // Bootstrap local git repo const basePath = await mkdtemp(path.join("/tmp", `${repoName}-base-`)); - git(["init", "-b", "main"], basePath); + git(["init", "-b", defaultBranch], basePath); git(["config", "user.email", "e2e@paseo.test"], basePath); git(["config", "user.name", "Paseo E2E"], basePath); git(["config", "commit.gpgsign", "false"], basePath); @@ -197,7 +205,7 @@ export async function createTempGithubRepo(options: { await writeFile(path.join(basePath, `pr-${i + 1}.txt`), `PR ${i + 1}\n`); git(["add", `pr-${i + 1}.txt`], basePath); git(["commit", "-m", `Add PR ${i + 1}`], basePath); - git(["checkout", "main"], basePath); + git(["checkout", defaultBranch], basePath); } if (branches.length > 0) { @@ -217,6 +225,7 @@ export async function createTempGithubRepo(options: { authedUrl, fullName, repoName, + defaultBranch, }); localPaths.push(localPath); prFixtures.push(fixture); @@ -232,6 +241,7 @@ export async function createTempGithubRepo(options: { owner, name: repoName, fullName, + defaultBranch, prs: prFixtures, issues: issueFixtures, cleanup: async () => { @@ -247,3 +257,27 @@ export async function createTempGithubRepo(options: { }, }; } + +export async function cloneGithubRepoDefaultBranchOnly( + repo: Pick, +): Promise { + const token = gh(["auth", "token"]); + const authedUrl = `https://x-access-token:${token}@github.com/${repo.fullName}.git`; + const clonePath = await mkdtemp(path.join("/tmp", `${repo.name}-${repo.defaultBranch}-only-`)); + + execFileSync( + "git", + ["clone", "--quiet", "--single-branch", "--branch", repo.defaultBranch, authedUrl, clonePath], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + git(["config", "user.email", "e2e@paseo.test"], clonePath); + git(["config", "user.name", "Paseo E2E"], clonePath); + git(["config", "commit.gpgsign", "false"], clonePath); + + return { + path: clonePath, + cleanup: async () => { + await rm(clonePath, { recursive: true, force: true }); + }, + }; +} diff --git a/packages/app/e2e/new-workspace.spec.ts b/packages/app/e2e/new-workspace.spec.ts index 37e3c8694..dd8a2f9aa 100644 --- a/packages/app/e2e/new-workspace.spec.ts +++ b/packages/app/e2e/new-workspace.spec.ts @@ -29,7 +29,11 @@ import { submitNewWorkspacePrompt, } from "./helpers/new-workspace"; import { createTempGitRepo, readWorktreeBranchInfo } from "./helpers/workspace"; -import { createTempGithubRepo, hasGithubAuth } from "./helpers/github-fixtures"; +import { + cloneGithubRepoDefaultBranchOnly, + createTempGithubRepo, + hasGithubAuth, +} from "./helpers/github-fixtures"; import { getServerId } from "./helpers/server-id"; import { expectSidebarWorkspaceSelected, @@ -751,4 +755,50 @@ test.describe("New workspace flow", () => { await ghRepo.cleanup(); } }); + + test("selected GitHub PR creates the worktree from the PR head even when the head branch is not fetched", async ({ + page, + }) => { + test.skip(!hasGithubAuth(), "Requires GitHub authentication (gh auth login)"); + + const ghRepo = await createTempGithubRepo({ + category: "new-workspace-pr-worktree", + prs: [{ title: "Checkout PR worktree", state: "open" }], + }); + const pr = ghRepo.prs[0]!; + const mainCheckout = await cloneGithubRepoDefaultBranchOnly(ghRepo); + + try { + const openedProject = await openProjectViaDaemon(client, mainCheckout.path); + localWorkspaceIds.add(openedProject.workspaceId); + + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openNewWorkspaceComposer(page, { + projectKey: openedProject.projectKey, + projectDisplayName: openedProject.projectDisplayName, + }); + await selectWorkspaceIsolation(page, "worktree"); + await openStartingRefPicker(page); + await selectGitHubPrInPicker(page, pr.number); + await submitNewWorkspaceWithoutPrompt(page); + + const worktree = await assertNewWorkspaceSidebarAndHeader(page, { + serverId: getServerId(), + client, + previousWorkspaceId: openedProject.workspaceId, + projectDisplayName: openedProject.projectDisplayName, + }); + createdWorktreeDirectories.add(worktree.workspaceDirectory); + + const branchInfo = await readWorktreeBranchInfo({ + worktreePath: worktree.workspaceDirectory, + }); + expect(branchInfo.currentBranch).toBe(pr.branch); + expect(existsSync(path.join(worktree.workspaceDirectory, "pr-1.txt"))).toBe(true); + } finally { + await mainCheckout.cleanup(); + await ghRepo.cleanup(); + } + }); }); diff --git a/packages/app/src/screens/new-workspace-picker-item.ts b/packages/app/src/screens/new-workspace-picker-item.ts index 42ccd9534..be3a2105e 100644 --- a/packages/app/src/screens/new-workspace-picker-item.ts +++ b/packages/app/src/screens/new-workspace-picker-item.ts @@ -20,11 +20,13 @@ export function pickerItemToCheckoutRequest( switch (item.kind) { case "branch": return { action: "branch-off", refName: item.name }; - case "github-pr": + case "github-pr": { + const headRefName = item.item.headRefName?.trim(); return { action: "checkout", - refName: item.item.headRefName ?? "", + ...(headRefName ? { refName: headRefName } : {}), githubPrNumber: item.item.number, }; + } } } diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 2d51d8a03..3f76659ff 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -804,8 +804,8 @@ async function createMultiplicityWorkspace(input: { createFailedMessage: string; }): Promise> { const isWorktree = input.isolation === "worktree"; - const baseBranch = isWorktree - ? (resolveCheckoutRequest(input.selectedItem, input.currentBranch)?.refName ?? undefined) + const checkoutRequest = isWorktree + ? resolveCheckoutRequest(input.selectedItem, input.currentBranch) : undefined; const firstAgentContext = buildFirstAgentContext({ prompt: input.prompt, @@ -818,7 +818,7 @@ async function createMultiplicityWorkspace(input: { cwd: input.project.iconWorkingDir, projectId: input.project.projectKey, worktreeSlug: createNameId(), - ...(baseBranch ? { baseBranch } : {}), + ...checkoutRequest, } : { kind: "directory", diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 73916b6ee..1b8693773 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -25,6 +25,7 @@ import type { import { createWorktree } from "../utils/worktree.js"; import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js"; import type { GeneratedWorkspaceName } from "./worktree-branch-name-generator.js"; +import type { GitHubService } from "../services/github-service.js"; import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js"; import { asSessionLogger, @@ -49,6 +50,8 @@ import { FileBackedWorkspaceRegistry, createPersistedProjectRecord, createPersistedWorkspaceRecord, + type PersistedProjectRecord, + type PersistedWorkspaceRecord, } from "./workspace-registry.js"; const REPO_CWD = path.resolve("/tmp/repo"); @@ -486,6 +489,9 @@ function createSessionForWorkspaceTests( terminalManager?: TerminalManager | null; projectRegistry?: SessionOptions["projectRegistry"]; workspaceRegistry?: SessionOptions["workspaceRegistry"]; + github?: GitHubService; + paseoHome?: string; + worktreesRoot?: string; renameCurrentBranch?: ( cwd: string, newName: string, @@ -510,7 +516,8 @@ function createSessionForWorkspaceTests( logger: asSessionLogger(logger), downloadTokenStore: asDownloadTokenStore(), pushTokenStore: asPushTokenStore(), - paseoHome: "/tmp/paseo-test", + paseoHome: options.paseoHome ?? "/tmp/paseo-test", + worktreesRoot: options.worktreesRoot, agentManager: asAgentManager({ subscribe: () => () => {}, listAgents: () => [], @@ -604,6 +611,7 @@ function createSessionForWorkspaceTests( }), dispose: () => {}, }), + github: options.github, workspaceGitService: options.workspaceGitService ?? createNoopWorkspaceGitService(), renameCurrentBranch: options.renameCurrentBranch, generateWorkspaceName: options.generateWorkspaceName, @@ -5986,11 +5994,195 @@ test("removing a contributing terminal clears workspace status", async () => { }); }); -// Worktree-source forwarding (action/refName/githubPrNumber/worktreeSlug) is -// covered end-to-end against a real git repo in -// workspace-create-worktree-source.e2e.test.ts, where the created worktree's -// observable branch proves the request fields reached createWorktreeCore. We do -// not intercept the private workflow here. +interface WorkspaceCreatePrRepoFixture { + tempDir: string; + repoDir: string; + paseoHome: string; + headRef: string; + prFileName: string; + prNumber: number; +} + +function createWorkspaceCreatePrRepo(): WorkspaceCreatePrRepoFixture { + const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "workspace-create-pr-"))); + const repoDir = path.join(tempDir, "repo"); + const remoteDir = path.join(tempDir, "origin.git"); + const paseoHome = path.join(tempDir, ".paseo"); + const prNumber = 123; + const headRef = "feature/review-pr"; + const prFileName = "pr-123.txt"; + + execFileSync("git", ["init", "-b", "main", repoDir], { stdio: "pipe" }); + execFileSync("git", ["config", "user.email", "test@getpaseo.local"], { + cwd: repoDir, + stdio: "pipe", + }); + execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["config", "commit.gpgsign", "false"], { cwd: repoDir, stdio: "pipe" }); + writeFileSync(path.join(repoDir, "README.md"), "main\n"); + execFileSync("git", ["add", "README.md"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["commit", "-m", "initial"], { cwd: repoDir, stdio: "pipe" }); + + execFileSync("git", ["checkout", "-b", headRef], { cwd: repoDir, stdio: "pipe" }); + writeFileSync(path.join(repoDir, prFileName), "review branch\n"); + execFileSync("git", ["add", prFileName], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["commit", "-m", "review branch"], { cwd: repoDir, stdio: "pipe" }); + const prHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoDir, stdio: "pipe" }) + .toString() + .trim(); + + execFileSync("git", ["clone", "--bare", repoDir, remoteDir], { stdio: "pipe" }); + execFileSync( + "git", + [`--git-dir=${remoteDir}`, "update-ref", `refs/pull/${prNumber}/head`, prHead], + { + stdio: "pipe", + }, + ); + execFileSync("git", [`--git-dir=${remoteDir}`, "update-ref", "-d", `refs/heads/${headRef}`], { + stdio: "pipe", + }); + execFileSync("git", ["checkout", "main"], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["branch", "-D", headRef], { cwd: repoDir, stdio: "pipe" }); + execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir, stdio: "pipe" }); + + return { tempDir, repoDir, paseoHome, headRef, prFileName, prNumber }; +} + +function createPrCheckoutGitHubService(params: { headRef: string }): GitHubService { + return { + listPullRequests: async () => [], + listIssues: async () => [], + getPullRequest: async ({ number }) => ({ + number, + title: `PR ${number}`, + url: `https://github.com/acme/repo/pull/${number}`, + state: "OPEN", + body: null, + baseRefName: "main", + headRefName: params.headRef, + labels: [], + updatedAt: "2026-03-01T12:00:00Z", + }), + getPullRequestHeadRef: async () => params.headRef, + getPullRequestCheckoutTarget: async ({ number }) => ({ + number, + baseRefName: "main", + headRefName: params.headRef, + headOwnerLogin: null, + headRepositorySshUrl: null, + headRepositoryUrl: null, + isCrossRepository: false, + }), + getCurrentPullRequestStatus: async () => null, + getPullRequestTimeline: async ({ prNumber }) => ({ + prNumber, + repoOwner: "acme", + repoName: "repo", + items: [], + truncated: false, + error: null, + }), + getGitHubCheckDetails: async ({ checkRunId, workflowRunId }) => ({ + checkRunId, + workflowRunId: workflowRunId ?? null, + name: "test", + status: null, + conclusion: null, + url: null, + detailsUrl: null, + output: null, + annotations: [], + failedJobs: [], + truncated: false, + }), + searchIssuesAndPrs: async () => ({ items: [], githubFeaturesEnabled: true }), + createPullRequest: async () => ({ number: 1, url: "https://github.com/acme/repo/pull/1" }), + mergePullRequest: async () => ({ success: true }), + enablePullRequestAutoMerge: async () => ({ success: true }), + disablePullRequestAutoMerge: async () => ({ success: true }), + isAuthenticated: async () => true, + invalidate: () => {}, + }; +} + +function readCurrentBranch(cwd: string): string { + return execFileSync("git", ["branch", "--show-current"], { cwd, stdio: "pipe" }) + .toString() + .trim(); +} + +test("workspace.create worktree source checks out a GitHub PR from githubPrNumber", async () => { + const emitted: SessionOutboundMessage[] = []; + const fixture = createWorkspaceCreatePrRepo(); + const projects = new Map(); + const workspaces = new Map(); + const projectRegistry: SessionOptions["projectRegistry"] = { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => Array.from(projects.values()), + get: async (projectId: string) => projects.get(projectId) ?? null, + upsert: async (record) => { + projects.set(record.projectId, record); + }, + archive: async () => {}, + remove: async () => {}, + }; + const workspaceRegistry: SessionOptions["workspaceRegistry"] = { + initialize: async () => {}, + existsOnDisk: async () => true, + list: async () => Array.from(workspaces.values()), + get: async (workspaceId: string) => workspaces.get(workspaceId) ?? null, + upsert: async (record) => { + workspaces.set(record.workspaceId, record); + }, + archive: async () => {}, + remove: async () => {}, + }; + const session = createSessionForWorkspaceTests({ + onMessage: (message) => emitted.push(message), + github: createPrCheckoutGitHubService({ + headRef: fixture.headRef, + }), + paseoHome: fixture.paseoHome, + projectRegistry, + workspaceRegistry, + workspaceGitService: createNoopWorkspaceGitService({ + resolveRepoRoot: async () => fixture.repoDir, + resolveDefaultBranch: async () => "main", + }), + }); + + try { + await session.handleMessage({ + type: "workspace.create.request", + requestId: "req-workspace-create-pr", + source: { + kind: "worktree", + cwd: fixture.repoDir, + action: "checkout", + githubPrNumber: fixture.prNumber, + worktreeSlug: "review-pr-workspace", + }, + }); + + const response = findByType(emitted, "workspace.create.response"); + expect(response?.payload.error).toBeNull(); + expect(response?.payload.workspace).toMatchObject({ + workspaceDirectory: expect.any(String), + gitRuntime: { currentBranch: fixture.headRef }, + }); + const workspaceDirectory = response?.payload.workspace?.workspaceDirectory as string; + expect(readCurrentBranch(workspaceDirectory)).toBe(fixture.headRef); + expect(existsSync(path.join(workspaceDirectory, fixture.prFileName))).toBe(true); + } finally { + await flushTerminalContributionWork(); + rmSync(fixture.tempDir, { recursive: true, force: true }); + } +}); + +// Worktree-source forwarding for action/refName/worktreeSlug is also covered +// end-to-end against a daemon in workspace-create-worktree-source.e2e.test.ts. test("failed local create_agent_request does not schedule workspace title generation", async () => { vi.useFakeTimers();