diff --git a/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts b/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts index a64d05e001..442abc69b2 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/git-operations.ts @@ -1,5 +1,4 @@ import { TRPCError } from "@trpc/server"; -import { shell } from "electron"; import simpleGit from "simple-git"; import { z } from "zod"; import { publicProcedure, router } from "../.."; @@ -247,20 +246,13 @@ export const createGitOperationsRouter = () => { } } - // Get the remote URL to construct the GitHub compare URL - const remoteUrl = (await git.remote(["get-url", "origin"])) || ""; - const repoMatch = remoteUrl - .trim() - .match(/github\.com[:/](.+?)(?:\.git)?$/); - - if (!repoMatch) { - throw new Error("Could not determine GitHub repository URL"); - } - - const repo = repoMatch[1].replace(/\.git$/, ""); - const url = `https://github.com/${repo}/compare/${branch}?expand=1`; + const { stdout } = await execWithShellEnv( + "gh", + ["pr", "create", "--web", "--fill", "--head", branch], + { cwd: input.worktreePath }, + ); - await shell.openExternal(url); + const url = stdout.trim() || "https://github.com"; await fetchCurrentBranch(git); return { success: true, url }; diff --git a/apps/desktop/src/lib/trpc/routers/projects/utils/github.ts b/apps/desktop/src/lib/trpc/routers/projects/utils/github.ts index 7c9341b94c..506edc50ec 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/utils/github.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/utils/github.ts @@ -1,12 +1,5 @@ -import { z } from "zod"; import { execWithShellEnv } from "../../workspaces/utils/shell-env"; -const GHRepoOwnerResponseSchema = z.object({ - owner: z.object({ - login: z.string(), - }), -}); - /** * Fetches the GitHub owner (user or org) for a repository using the `gh` CLI. * Returns null if `gh` is not installed, not authenticated, or on error. @@ -15,26 +8,14 @@ export async function fetchGitHubOwner( repoPath: string, ): Promise { try { - console.log("[fetchGitHubOwner] Running gh repo view in:", repoPath); - const { stdout, stderr } = await execWithShellEnv( + const { stdout } = await execWithShellEnv( "gh", - ["repo", "view", "--json", "owner"], + ["repo", "view", "--jq", ".owner.login"], { cwd: repoPath }, ); - if (stderr) { - console.log("[fetchGitHubOwner] stderr:", stderr); - } - console.log("[fetchGitHubOwner] stdout:", stdout); - const raw = JSON.parse(stdout); - const result = GHRepoOwnerResponseSchema.safeParse(raw); - if (!result.success) { - console.error("[GitHub] Owner schema validation failed:", result.error); - return null; - } - console.log("[fetchGitHubOwner] Parsed owner:", result.data.owner.login); - return result.data.owner.login; - } catch (error) { - console.error("[fetchGitHubOwner] Error:", error); + const owner = stdout.trim(); + return owner || null; + } catch { return null; } } diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts index d76b7c50f1..a6fb32f071 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts @@ -20,7 +20,6 @@ import { } from "../utils/db-helpers"; import { createWorktreeFromPr, - fetchPrBranch, generateBranchName, getBranchPrefix, getBranchWorktreePath, @@ -200,11 +199,6 @@ async function handleNewWorktree({ ); } - await fetchPrBranch({ - repoPath: project.mainRepoPath, - prInfo, - }); - const worktreePath = resolveWorktreePath(project, localBranchName); await createWorktreeFromPr({ diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts index c6e13a4140..c1ab987fcb 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts @@ -1606,57 +1606,9 @@ export async function getPrInfo({ } } -/** - * Fetches a PR branch into the repository, handling cross-repository (fork) PRs. - * For fork PRs, adds the fork as a remote and fetches from there. - * @returns The local branch name to use for the worktree - */ -export async function fetchPrBranch({ - repoPath, - prInfo, -}: { - repoPath: string; - prInfo: PullRequestInfo; -}): Promise { - const git = simpleGit(repoPath); - const env = await getGitEnv(); - - if (prInfo.isCrossRepository) { - const forkOwner = prInfo.headRepositoryOwner.login; - const remoteName = forkOwner.toLowerCase(); - const forkRepo = prInfo.headRepository.name; - const headBranch = prInfo.headRefName; - - const remotes = await git.getRemotes(); - const remoteExists = remotes.some((r) => r.name === remoteName); - - if (!remoteExists) { - const forkUrl = `https://github.com/${forkOwner}/${forkRepo}.git`; - await git.addRemote(remoteName, forkUrl); - console.log(`[git] Added remote ${remoteName} -> ${forkUrl}`); - } - - await execFileAsync( - "git", - ["-C", repoPath, "fetch", remoteName, headBranch], - { env, timeout: 120_000 }, - ); - - return `${remoteName}/${headBranch}`; - } - - await execFileAsync( - "git", - ["-C", repoPath, "fetch", "origin", prInfo.headRefName], - { env, timeout: 120_000 }, - ); - - return prInfo.headRefName; -} - /** * Creates a worktree from a PR. - * Handles fetching the PR branch (including from forks) and creating the worktree. + * Uses `gh pr checkout` inside the new worktree to resolve fork/head remotes. */ export async function createWorktreeFromPr({ mainRepoPath, @@ -1677,64 +1629,44 @@ export async function createWorktreeFromPr({ const git = simpleGit(mainRepoPath); const localBranches = await git.branchLocal(); - - const remoteRef = prInfo.isCrossRepository - ? `refs/remotes/${prInfo.headRepositoryOwner.login.toLowerCase()}/${prInfo.headRefName}` - : `refs/remotes/origin/${prInfo.headRefName}`; - const branchName = prInfo.isCrossRepository - ? localBranchName - : prInfo.headRefName; - const branchExists = localBranches.all.includes(branchName); + const branchExists = localBranches.all.includes(localBranchName); if (branchExists) { - const localCommit = (await git.revparse([branchName])).trim(); - const remoteCommit = (await git.revparse([remoteRef])).trim(); - - if (localCommit !== remoteCommit) { - try { - await execFileAsync( - "git", - [ - "-C", - mainRepoPath, - "merge-base", - "--is-ancestor", - localCommit, - remoteCommit, - ], - { env, timeout: 10_000 }, - ); - } catch { - throw new Error( - `Local branch "${branchName}" has diverged from the PR. ` + - `Please delete or rename it before opening this PR.`, - ); - } - } - await execWorktreeAdd({ mainRepoPath, - args: ["-C", mainRepoPath, "worktree", "add", worktreePath, branchName], + args: [ + "-C", + mainRepoPath, + "worktree", + "add", + worktreePath, + localBranchName, + ], env, worktreePath, }); - - if (localCommit !== remoteCommit) { - await execFileAsync( - "git", - ["-C", worktreePath, "reset", "--hard", remoteRef], - { env, timeout: 30_000 }, - ); - } } else { - const args = ["-C", mainRepoPath, "worktree", "add"]; - if (!prInfo.isCrossRepository) { - args.push("--track"); - } - args.push("-b", branchName, worktreePath, remoteRef); - await execWorktreeAdd({ mainRepoPath, args, env, worktreePath }); + await execWorktreeAdd({ + mainRepoPath, + args: ["-C", mainRepoPath, "worktree", "add", "--detach", worktreePath], + env, + worktreePath, + }); } + await execWithShellEnv( + "gh", + [ + "pr", + "checkout", + String(prInfo.number), + "--branch", + localBranchName, + "--force", + ], + { cwd: worktreePath, timeout: 120_000 }, + ); + // Enable autoSetupRemote so `git push` just works without -u flag. await execFileAsync( "git", @@ -1757,7 +1689,6 @@ export async function createWorktreeFromPr({ `This PR's branch is already checked out in another worktree.`, ); } - throw new Error(`Failed to create worktree from PR: ${errorMessage}`); } } diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts index fd6a92748c..a8432f0c5c 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts @@ -85,14 +85,7 @@ const PR_JSON_FIELDS = async function getPRForBranch( worktreePath: string, ): Promise { - // Try branch tracking first (fast, works for `gh pr checkout` forks), - // then fall back to explicit head-branch lookup. - const branchResult = await getPRByBranchTracking(worktreePath); - if (branchResult !== undefined) { - return branchResult; - } - - return findPRByHeadBranch(worktreePath); + return getPRByBranchTracking(worktreePath); } /** @@ -101,7 +94,7 @@ async function getPRForBranch( */ async function getPRByBranchTracking( worktreePath: string, -): Promise { +): Promise { try { const { stdout } = await execWithShellEnv( "gh", @@ -114,75 +107,34 @@ async function getPRByBranchTracking( return null; } - // `gh pr view` matches by branch name, which can find a stale PR if the - // branch was recreated. Verify shared commit ancestry to confirm the match. - if (!(await sharesAncestry(worktreePath, data.headRefOid))) { - return null; - } - return formatPRData(data); } catch (error) { if ( error instanceof Error && - error.message.includes("no pull requests found") + error.message.toLowerCase().includes("no pull requests found") ) { - return undefined; + return null; } throw error; } } -/** - * Finds a PR by explicitly searching for the current branch name as the head ref. - * Covers cases where `gh pr view` (no args) fails to match. - */ -async function findPRByHeadBranch( - worktreePath: string, -): Promise { - try { - const { stdout: branchOutput } = await execFileAsync( - "git", - ["-C", worktreePath, "rev-parse", "--abbrev-ref", "HEAD"], - { timeout: 10_000 }, - ); - const branchName = branchOutput.trim(); +function parsePRResponse(stdout: string): GHPRResponse | null { + const trimmed = stdout.trim(); + if (!trimmed || trimmed === "null") { + return null; + } - const { stdout } = await execWithShellEnv( - "gh", - [ - "pr", - "list", - "--head", - branchName, - "--json", - PR_JSON_FIELDS, - "--jq", - ".[0]", - ], - { cwd: worktreePath }, + let raw: unknown; + try { + raw = JSON.parse(trimmed); + } catch (error) { + console.warn( + "[GitHub] Failed to parse PR response JSON:", + error instanceof Error ? error.message : String(error), ); - - if (!stdout.trim()) { - return null; - } - - const data = parsePRResponse(stdout); - if (!data) { - return null; - } - - if (!(await sharesAncestry(worktreePath, data.headRefOid))) { - return null; - } - - return formatPRData(data); - } catch { return null; } -} - -function parsePRResponse(stdout: string): GHPRResponse | null { - const raw = JSON.parse(stdout); const result = GHPRResponseSchema.safeParse(raw); if (!result.success) { console.error("[GitHub] PR schema validation failed:", result.error); @@ -207,61 +159,6 @@ function formatPRData(data: GHPRResponse): NonNullable { }; } -/** - * Returns true if local HEAD and the given commit share ancestry - * (one is an ancestor of the other, or they are the same commit). - * Falls back to true when ancestry can't be verified (e.g., commit not fetched). - */ -async function sharesAncestry( - worktreePath: string, - prHeadOid: string, -): Promise { - try { - const { stdout: localHead } = await execFileAsync( - "git", - ["-C", worktreePath, "rev-parse", "HEAD"], - { timeout: 10_000 }, - ); - const localOid = localHead.trim(); - - if (localOid === prHeadOid) { - return true; - } - - // Check both directions: local ahead of PR, and PR ahead of local - for (const [ancestor, descendant] of [ - [prHeadOid, localOid], - [localOid, prHeadOid], - ]) { - try { - await execFileAsync( - "git", - [ - "-C", - worktreePath, - "merge-base", - "--is-ancestor", - ancestor, - descendant, - ], - { timeout: 10_000 }, - ); - return true; - } catch { - // Not an ancestor in this direction - } - } - - return false; - } catch (error) { - console.warn( - "[GitHub] Could not verify PR commit ancestry:", - error instanceof Error ? error.message : String(error), - ); - return true; - } -} - function mapPRState( state: GHPRResponse["state"], isDraft: boolean,