diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index 9ebdfe8ca95dc934be979f535c4f0b4adce5df6a..52a201e321c5bd76f2cbc083e829016b84fc69cf 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -106,6 +106,9 @@ const makeIsolatedGitCore = (gitService: GitServiceShape) => listBranches: (input) => core.listBranches(input), createWorktree: (input) => core.createWorktree(input), fetchPullRequestBranch: (input) => core.fetchPullRequestBranch(input), + ensureRemote: (input) => core.ensureRemote(input), + fetchRemoteBranch: (input) => core.fetchRemoteBranch(input), + setBranchUpstream: (input) => core.setBranchUpstream(input), removeWorktree: (input) => core.removeWorktree(input), renameBranch: (input) => core.renameBranch(input), createBranch: (input) => core.createBranch(input), diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 6df74aaafcc8889f1e539166ebc60138d0981a75..57a2390dd4b9031ec88f7c76203b05ae4e5b943b 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -100,6 +100,31 @@ function parseRemoteNames(stdout: string): ReadonlyArray { .toSorted((a, b) => b.length - a.length); } +function sanitizeRemoteName(value: string): string { + const sanitized = value.trim().replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, ""); + return sanitized.length > 0 ? sanitized : "fork"; +} + +function normalizeRemoteUrl(value: string): string { + return value.trim().replace(/\.git$/i, "").replace(/\/+$/g, "").toLowerCase(); +} + +function parseRemoteFetchUrls(stdout: string): Map { + const remotes = new Map(); + for (const line of stdout.split("\n")) { + const trimmed = line.trim(); + if (trimmed.length === 0) continue; + const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed); + if (!match) continue; + const [, remoteName = "", remoteUrl = "", direction = ""] = match; + if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) { + continue; + } + remotes.set(remoteName, remoteUrl); + } + return remotes; +} + function parseRemoteRefWithRemoteNames( branchName: string, remoteNames: ReadonlyArray, @@ -441,6 +466,32 @@ const makeGitCore = Effect.gen(function* () { ); }); + const ensureRemote: GitCoreShape["ensureRemote"] = (input) => + Effect.gen(function* () { + const preferredName = sanitizeRemoteName(input.preferredName); + const normalizedTargetUrl = normalizeRemoteUrl(input.url); + const remoteFetchUrls = yield* runGitStdout("GitCore.ensureRemote.listRemoteUrls", input.cwd, [ + "remote", + "-v", + ]).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout))); + + for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) { + if (normalizeRemoteUrl(remoteUrl) === normalizedTargetUrl) { + return remoteName; + } + } + + let remoteName = preferredName; + let suffix = 1; + while (remoteFetchUrls.has(remoteName)) { + remoteName = `${preferredName}-${suffix}`; + suffix += 1; + } + + yield* runGit("GitCore.ensureRemote.add", input.cwd, ["remote", "add", remoteName, input.url]); + return remoteName; + }); + const resolveBaseBranchForNoUpstream = ( cwd: string, branch: string, @@ -1084,6 +1135,35 @@ const makeGitCore = Effect.gen(function* () { ); }).pipe(Effect.asVoid); + const fetchRemoteBranch: GitCoreShape["fetchRemoteBranch"] = (input) => + Effect.gen(function* () { + yield* runGit("GitCore.fetchRemoteBranch.fetch", input.cwd, [ + "fetch", + "--quiet", + "--no-tags", + input.remoteName, + `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`, + ]); + + const localBranchAlreadyExists = yield* branchExists(input.cwd, input.localBranch); + const targetRef = `${input.remoteName}/${input.remoteBranch}`; + yield* runGit( + "GitCore.fetchRemoteBranch.materialize", + input.cwd, + localBranchAlreadyExists + ? ["branch", "--force", input.localBranch, targetRef] + : ["branch", input.localBranch, targetRef], + ); + }).pipe(Effect.asVoid); + + const setBranchUpstream: GitCoreShape["setBranchUpstream"] = (input) => + runGit("GitCore.setBranchUpstream", input.cwd, [ + "branch", + "--set-upstream-to", + `${input.remoteName}/${input.remoteBranch}`, + input.branch, + ]); + const removeWorktree: GitCoreShape["removeWorktree"] = (input) => Effect.gen(function* () { const args = ["worktree", "remove"]; @@ -1244,6 +1324,9 @@ const makeGitCore = Effect.gen(function* () { listBranches, createWorktree, fetchPullRequestBranch, + ensureRemote, + fetchRemoteBranch, + setBranchUpstream, removeWorktree, renameBranch, createBranch, diff --git a/apps/server/src/git/Layers/GitHubCli.test.ts b/apps/server/src/git/Layers/GitHubCli.test.ts index 2242c307cf1836441778ac66ef6879002f0118e7..e809b4368e3a63fc20d592ac496f8afbe8923566 100644 --- a/apps/server/src/git/Layers/GitHubCli.test.ts +++ b/apps/server/src/git/Layers/GitHubCli.test.ts @@ -22,6 +22,13 @@ describe("GitHubCliLive", () => { headRefName: "feature/pr-threads", state: "OPEN", mergedAt: null, + isCrossRepository: true, + headRepository: { + nameWithOwner: "octocat/codething-mvp", + }, + headRepositoryOwner: { + login: "octocat", + }, }), stderr: "", code: 0, @@ -46,14 +53,53 @@ describe("GitHubCliLive", () => { baseRefName: "main", headRefName: "feature/pr-threads", state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/codething-mvp", + headRepositoryOwnerLogin: "octocat", }); expect(mockedRunProcess).toHaveBeenCalledWith( "gh", - ["pr", "view", "#42", "--json", "number,title,url,baseRefName,headRefName,state,mergedAt"], + [ + "pr", + "view", + "#42", + "--json", + "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + ], expect.objectContaining({ cwd: "/repo" }), ); }); + it("reads repository clone URLs", async () => { + mockedRunProcess.mockResolvedValueOnce({ + stdout: JSON.stringify({ + nameWithOwner: "octocat/codething-mvp", + url: "https://github.com/octocat/codething-mvp", + sshUrl: "git@github.com:octocat/codething-mvp.git", + }), + stderr: "", + code: 0, + signal: null, + timedOut: false, + }); + + const result = await Effect.runPromise( + Effect.gen(function* () { + const gh = yield* GitHubCli; + return yield* gh.getRepositoryCloneUrls({ + cwd: "/repo", + repository: "octocat/codething-mvp", + }); + }).pipe(Effect.provide(GitHubCliLive)), + ); + + expect(result).toEqual({ + nameWithOwner: "octocat/codething-mvp", + url: "https://github.com/octocat/codething-mvp", + sshUrl: "git@github.com:octocat/codething-mvp.git", + }); + }); + it("surfaces a friendly error when the pull request is not found", async () => { mockedRunProcess.mockRejectedValueOnce( new Error( diff --git a/apps/server/src/git/Layers/GitHubCli.ts b/apps/server/src/git/Layers/GitHubCli.ts index 334cd8be3b3ae1dfd441fcf065f080b8ed405db7..8a34d17ac15bcde0d81d9ce7382b125eb8a0c765 100644 --- a/apps/server/src/git/Layers/GitHubCli.ts +++ b/apps/server/src/git/Layers/GitHubCli.ts @@ -4,6 +4,7 @@ import { runProcess } from "../../processRunner"; import { GitHubCliError } from "../Errors.ts"; import { GitHubCli, + type GitHubRepositoryCloneUrls, type GitHubCliShape, type GitHubPullRequestSummary, } from "../Services/GitHubCli.ts"; @@ -85,6 +86,25 @@ function parsePullRequestSummary( const url = record.url; const baseRefName = record.baseRefName; const headRefName = record.headRefName; + const isCrossRepository = record.isCrossRepository; + const headRepository = + record.headRepository && typeof record.headRepository === "object" && !Array.isArray(record.headRepository) + ? (record.headRepository as Record) + : null; + const headRepositoryOwner = + record.headRepositoryOwner && + typeof record.headRepositoryOwner === "object" && + !Array.isArray(record.headRepositoryOwner) + ? (record.headRepositoryOwner as Record) + : null; + const headRepositoryNameWithOwner = + typeof headRepository?.nameWithOwner === "string" ? headRepository.nameWithOwner : null; + const headRepositoryOwnerLogin = + typeof headRepositoryOwner?.login === "string" + ? headRepositoryOwner.login + : typeof headRepositoryNameWithOwner === "string" && headRepositoryNameWithOwner.includes("/") + ? headRepositoryNameWithOwner.split("/")[0] ?? null + : null; if ( typeof number !== "number" || !Number.isInteger(number) || @@ -103,6 +123,34 @@ function parsePullRequestSummary( baseRefName, headRefName, state: normalizePullRequestState(record), + ...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}), + ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}), + ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}), + }; +} + +function parseRepositoryCloneUrls(raw: unknown): GitHubRepositoryCloneUrls | null { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) { + return null; + } + const record = raw as Record; + const nameWithOwner = record.nameWithOwner; + const url = record.url; + const sshUrl = record.sshUrl; + if ( + typeof nameWithOwner !== "string" || + typeof url !== "string" || + typeof sshUrl !== "string" || + nameWithOwner.trim().length === 0 || + url.trim().length === 0 || + sshUrl.trim().length === 0 + ) { + return null; + } + return { + nameWithOwner, + url, + sshUrl, }; } @@ -179,7 +227,7 @@ const makeGitHubCli = Effect.sync(() => { "view", input.reference, "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt", + "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => result.stdout.trim()), @@ -205,6 +253,40 @@ const makeGitHubCli = Effect.sync(() => { }), ), ), + getRepositoryCloneUrls: (input) => + execute({ + cwd: input.cwd, + args: [ + "repo", + "view", + input.repository, + "--json", + "nameWithOwner,url,sshUrl", + ], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + Effect.try({ + try: () => { + const parsed = raw.length > 0 ? JSON.parse(raw) : null; + const repository = parseRepositoryCloneUrls(parsed); + if (!repository) { + throw new Error("GitHub CLI returned invalid repository clone URL JSON."); + } + return repository; + }, + catch: (error: unknown) => + new GitHubCliError({ + operation: "getRepositoryCloneUrls", + detail: + error instanceof Error + ? `GitHub CLI returned invalid repository JSON: ${error.message}` + : "GitHub CLI returned invalid repository JSON.", + ...(error !== undefined ? { cause: error } : {}), + }), + }), + ), + ), createPullRequest: (input) => execute({ cwd: input.cwd, diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index ff02b5b776faefc06ea34b365a360de949fa19bf..0245430954d3f427b076ac45e8d2d717fad354fe 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -30,7 +30,11 @@ interface FakeGhScenario { baseRefName: string; headRefName: string; state?: "open" | "closed" | "merged"; + isCrossRepository?: boolean; + headRepositoryNameWithOwner?: string | null; + headRepositoryOwnerLogin?: string | null; }; + repositoryCloneUrls?: Record; failWith?: GitHubCliError; } @@ -59,6 +63,8 @@ interface FakeGitTextGeneration { }) => Effect.Effect<{ branch: string }, TextGenerationError>; } +type FakePullRequest = NonNullable; + function makeTempDir( prefix: string, ): Effect.Effect { @@ -211,18 +217,33 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { } if (args[0] === "pr" && args[1] === "view") { + const pullRequest: FakePullRequest = scenario.pullRequest ?? { + number: 101, + title: "Pull request", + url: "https://github.com/pingdotgg/codething-mvp/pull/101", + baseRefName: "main", + headRefName: "feature/pull-request", + state: "open", + }; return Effect.succeed({ stdout: - JSON.stringify( - scenario.pullRequest ?? { - number: 101, - title: "Pull request", - url: "https://github.com/pingdotgg/codething-mvp/pull/101", - baseRefName: "main", - headRefName: "feature/pull-request", - state: "OPEN", - }, - ) + "\n", + JSON.stringify({ + ...pullRequest, + ...(pullRequest.headRepositoryNameWithOwner + ? { + headRepository: { + nameWithOwner: pullRequest.headRepositoryNameWithOwner, + }, + } + : {}), + ...(pullRequest.headRepositoryOwnerLogin + ? { + headRepositoryOwner: { + login: pullRequest.headRepositoryOwnerLogin, + }, + } + : {}), + }) + "\n", stderr: "", code: 0, signal: null, @@ -241,6 +262,30 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { } if (args[0] === "repo" && args[1] === "view") { + const repository = args[2]; + if (typeof repository === "string" && args.includes("nameWithOwner,url,sshUrl")) { + const cloneUrls = scenario.repositoryCloneUrls?.[repository]; + if (!cloneUrls) { + return Effect.fail( + new GitHubCliError({ + operation: "execute", + detail: `Unexpected repository lookup: ${repository}`, + }), + ); + } + return Effect.succeed({ + stdout: + JSON.stringify({ + nameWithOwner: repository, + url: cloneUrls.url, + sshUrl: cloneUrls.sshUrl, + }) + "\n", + stderr: "", + code: 0, + signal: null, + timedOut: false, + }); + } return Effect.succeed({ stdout: `${scenario.defaultBranch ?? "main"}\n`, stderr: "", @@ -315,11 +360,16 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { "view", input.reference, "--json", - "number,title,url,baseRefName,headRefName,state,mergedAt", + "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => JSON.parse(result.stdout) as GitHubPullRequestSummary), ), + getRepositoryCloneUrls: (input) => + execute({ + cwd: input.cwd, + args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], + }).pipe(Effect.map((result) => JSON.parse(result.stdout))), checkoutPullRequest: (input) => execute({ cwd: input.cwd, @@ -1102,6 +1152,64 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("preserves fork upstream tracking when preparing a worktree PR thread", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "add", "fork-seed", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/pr-fork"]); + fs.writeFileSync(path.join(repoDir, "fork.txt"), "fork\n"); + yield* runGit(repoDir, ["add", "fork.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Fork PR branch"]); + yield* runGit(repoDir, ["push", "-u", "fork-seed", "feature/pr-fork"]); + yield* runGit(repoDir, ["checkout", "main"]); + + const { manager } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 81, + title: "Fork PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/81", + baseRefName: "main", + headRefName: "feature/pr-fork", + state: "open", + isCrossRepository: true, + headRepositoryNameWithOwner: "octocat/codething-mvp", + headRepositoryOwnerLogin: "octocat", + }, + repositoryCloneUrls: { + "octocat/codething-mvp": { + url: forkDir, + sshUrl: forkDir, + }, + }, + }, + }); + + const result = yield* preparePullRequestThread(manager, { + cwd: repoDir, + reference: "81", + mode: "worktree", + }); + + expect(result.worktreePath).not.toBeNull(); + const upstreamRef = ( + yield* runGit(result.worktreePath as string, ["rev-parse", "--abbrev-ref", "@{upstream}"]) + ).stdout.trim(); + expect(upstreamRef).toBe("fork-seed/feature/pr-fork"); + expect(upstreamRef.startsWith("origin/")).toBe(false); + expect( + (yield* runGit(result.worktreePath as string, ["config", "--get", "remote.fork-seed.url"])) + .stdout + .trim(), + ).toBe(forkDir); + }), + ); + it.effect("reuses an existing dedicated worktree for the PR head branch", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index f25213540600dc99975e652e8b1306f90389644e..9dc10e8fd8bf3adb04434b6a68384d39e28dd8dc 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -32,6 +32,12 @@ interface ResolvedPullRequest { state: "open" | "closed" | "merged"; } +interface PullRequestHeadRemoteInfo { + isCrossRepository?: boolean; + headRepositoryNameWithOwner?: string | null; + headRepositoryOwnerLogin?: string | null; +} + function parsePullRequestList(raw: unknown): PullRequestInfo[] { if (!Array.isArray(raw)) return []; @@ -217,10 +223,126 @@ function toResolvedPullRequest(pr: { }; } +function shouldPreferSshRemote(url: string | null): boolean { + if (!url) return false; + const trimmed = url.trim(); + return trimmed.startsWith("git@") || trimmed.startsWith("ssh://"); +} + +function toPullRequestHeadRemoteInfo(pr: { + isCrossRepository?: boolean; + headRepositoryNameWithOwner?: string | null; + headRepositoryOwnerLogin?: string | null; +}): PullRequestHeadRemoteInfo { + return { + ...(pr.isCrossRepository !== undefined ? { isCrossRepository: pr.isCrossRepository } : {}), + ...(pr.headRepositoryNameWithOwner !== undefined + ? { headRepositoryNameWithOwner: pr.headRepositoryNameWithOwner } + : {}), + ...(pr.headRepositoryOwnerLogin !== undefined + ? { headRepositoryOwnerLogin: pr.headRepositoryOwnerLogin } + : {}), + }; +} + export const makeGitManager = Effect.gen(function* () { const gitCore = yield* GitCore; const gitHubCli = yield* GitHubCli; const textGeneration = yield* TextGeneration; + + const configurePullRequestHeadUpstream = ( + cwd: string, + pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, + ) => + Effect.gen(function* () { + const repositoryNameWithOwner = pullRequest.headRepositoryNameWithOwner?.trim() ?? ""; + if (repositoryNameWithOwner.length === 0) { + return; + } + + const cloneUrls = yield* gitHubCli.getRepositoryCloneUrls({ + cwd, + repository: repositoryNameWithOwner, + }); + const originRemoteUrl = yield* gitCore.readConfigValue(cwd, "remote.origin.url"); + const remoteUrl = shouldPreferSshRemote(originRemoteUrl) ? cloneUrls.sshUrl : cloneUrls.url; + const preferredRemoteName = + pullRequest.headRepositoryOwnerLogin?.trim() || + repositoryNameWithOwner.split("/")[0]?.trim() || + "fork"; + const remoteName = yield* gitCore.ensureRemote({ + cwd, + preferredName: preferredRemoteName, + url: remoteUrl, + }); + + yield* gitCore.setBranchUpstream({ + cwd, + branch: pullRequest.headBranch, + remoteName, + remoteBranch: pullRequest.headBranch, + }); + }).pipe( + Effect.catch((error) => + Effect.logWarning( + `GitManager.configurePullRequestHeadUpstream: failed to configure upstream for ${pullRequest.headBranch} in ${cwd}: ${error.message}`, + ).pipe(Effect.asVoid), + ), + ); + + const materializePullRequestHeadBranch = ( + cwd: string, + pullRequest: ResolvedPullRequest & PullRequestHeadRemoteInfo, + ) => + Effect.gen(function* () { + const repositoryNameWithOwner = pullRequest.headRepositoryNameWithOwner?.trim() ?? ""; + + if (repositoryNameWithOwner.length === 0) { + yield* gitCore.fetchPullRequestBranch({ + cwd, + prNumber: pullRequest.number, + branch: pullRequest.headBranch, + }); + return; + } + + const cloneUrls = yield* gitHubCli.getRepositoryCloneUrls({ + cwd, + repository: repositoryNameWithOwner, + }); + const originRemoteUrl = yield* gitCore.readConfigValue(cwd, "remote.origin.url"); + const remoteUrl = shouldPreferSshRemote(originRemoteUrl) ? cloneUrls.sshUrl : cloneUrls.url; + const preferredRemoteName = + pullRequest.headRepositoryOwnerLogin?.trim() || + repositoryNameWithOwner.split("/")[0]?.trim() || + "fork"; + const remoteName = yield* gitCore.ensureRemote({ + cwd, + preferredName: preferredRemoteName, + url: remoteUrl, + }); + + yield* gitCore.fetchRemoteBranch({ + cwd, + remoteName, + remoteBranch: pullRequest.headBranch, + localBranch: pullRequest.headBranch, + }); + yield* gitCore.setBranchUpstream({ + cwd, + branch: pullRequest.headBranch, + remoteName, + remoteBranch: pullRequest.headBranch, + }); + }).pipe( + Effect.catch(() => + gitCore.fetchPullRequestBranch({ + cwd, + prNumber: pullRequest.number, + branch: pullRequest.headBranch, + }), + ), + ); const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -504,12 +626,12 @@ export const makeGitManager = Effect.gen(function* () { function* (input) { const normalizedReference = normalizePullRequestReference(input.reference); const rootWorktreePath = canonicalizeExistingPath(input.cwd); - const pullRequest = yield* gitHubCli + const pullRequestSummary = yield* gitHubCli .getPullRequest({ cwd: input.cwd, reference: normalizedReference, - }) - .pipe(Effect.map((resolved) => toResolvedPullRequest(resolved))); + }); + const pullRequest = toResolvedPullRequest(pullRequestSummary); if (input.mode === "local") { yield* gitHubCli.checkoutPullRequest({ @@ -517,6 +639,10 @@ export const makeGitManager = Effect.gen(function* () { reference: normalizedReference, force: true, }); + yield* configurePullRequestHeadUpstream(input.cwd, { + ...pullRequest, + ...toPullRequestHeadRemoteInfo(pullRequestSummary), + }); const details = yield* gitCore.statusDetails(input.cwd); return { pullRequest, @@ -555,10 +681,9 @@ export const makeGitManager = Effect.gen(function* () { ); } - yield* gitCore.fetchPullRequestBranch({ - cwd: input.cwd, - prNumber: pullRequest.number, - branch: pullRequest.headBranch, + yield* materializePullRequestHeadBranch(input.cwd, { + ...pullRequest, + ...toPullRequestHeadRemoteInfo(pullRequestSummary), }); const existingBranchAfterFetch = yield* findLocalHeadBranch(input.cwd); diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts index 7fbfc33506908aa00a421649a5d6fb0c9e9c4322..dce859ccb6d32439968850a354b700e3f7b506c2 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -62,6 +62,26 @@ export interface GitFetchPullRequestBranchInput { branch: string; } +export interface GitEnsureRemoteInput { + cwd: string; + preferredName: string; + url: string; +} + +export interface GitFetchRemoteBranchInput { + cwd: string; + remoteName: string; + remoteBranch: string; + localBranch: string; +} + +export interface GitSetBranchUpstreamInput { + cwd: string; + branch: string; + remoteName: string; + remoteBranch: string; +} + /** * GitCoreShape - Service API for low-level Git repository interactions. */ @@ -142,6 +162,27 @@ export interface GitCoreShape { input: GitFetchPullRequestBranchInput, ) => Effect.Effect; + /** + * Ensure a named remote exists for the provided URL, returning the reused or created remote name. + */ + readonly ensureRemote: ( + input: GitEnsureRemoteInput, + ) => Effect.Effect; + + /** + * Fetch a remote branch into a local branch without checkout. + */ + readonly fetchRemoteBranch: ( + input: GitFetchRemoteBranchInput, + ) => Effect.Effect; + + /** + * Set the upstream tracking branch for a local branch. + */ + readonly setBranchUpstream: ( + input: GitSetBranchUpstreamInput, + ) => Effect.Effect; + /** * Remove an existing worktree. */ diff --git a/apps/server/src/git/Services/GitHubCli.ts b/apps/server/src/git/Services/GitHubCli.ts index aa0402d8f27a916e4517584ff4083bc6a65d3ee3..aa95bf628a7b7e1095b1951cff6ccf777f0bcb25 100644 --- a/apps/server/src/git/Services/GitHubCli.ts +++ b/apps/server/src/git/Services/GitHubCli.ts @@ -18,6 +18,15 @@ export interface GitHubPullRequestSummary { readonly baseRefName: string; readonly headRefName: string; readonly state?: "open" | "closed" | "merged"; + readonly isCrossRepository?: boolean; + readonly headRepositoryNameWithOwner?: string | null; + readonly headRepositoryOwnerLogin?: string | null; +} + +export interface GitHubRepositoryCloneUrls { + readonly nameWithOwner: string; + readonly url: string; + readonly sshUrl: string; } /** @@ -50,6 +59,14 @@ export interface GitHubCliShape { readonly reference: string; }) => Effect.Effect; + /** + * Resolve clone URLs for a GitHub repository. + */ + readonly getRepositoryCloneUrls: (input: { + readonly cwd: string; + readonly repository: string; + }) => Effect.Effect; + /** * Create a pull request from branch context and body file. */