Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 6 additions & 14 deletions apps/desktop/src/lib/trpc/routers/changes/git-operations.ts
Original file line number Diff line number Diff line change
@@ -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 "../..";
Expand Down Expand Up @@ -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 };
Expand Down
29 changes: 5 additions & 24 deletions apps/desktop/src/lib/trpc/routers/projects/utils/github.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -15,26 +8,14 @@ export async function fetchGitHubOwner(
repoPath: string,
): Promise<string | null> {
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;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import {
} from "../utils/db-helpers";
import {
createWorktreeFromPr,
fetchPrBranch,
generateBranchName,
getBranchPrefix,
getBranchWorktreePath,
Expand Down Expand Up @@ -200,11 +199,6 @@ async function handleNewWorktree({
);
}

await fetchPrBranch({
repoPath: project.mainRepoPath,
prInfo,
});

const worktreePath = resolveWorktreePath(project, localBranchName);

await createWorktreeFromPr({
Expand Down
127 changes: 29 additions & 98 deletions apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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,
Expand All @@ -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",
Expand All @@ -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}`);
}
}
Loading
Loading