From a6374de9414e1e8054d2facf48cf8859d76f412d Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Tue, 3 Nov 2026 08:44:37 +1100 Subject: [PATCH 1/3] fix(server): branch PRs stay matched when a remote has a second push URL Co-Authored-By: Claude Opus 5.5 --- apps/server/src/git/GitManager.test.ts | 48 ++++++++++++++++++++++++++ apps/server/src/git/GitManager.ts | 34 +++++++++++++----- 2 files changed, 74 insertions(+), 8 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 350cb9e8340aa..e89387dafb31b 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -889,6 +889,54 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); + it.effect("status reads a remote's first URL when it has a second push URL", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/mirrored"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/mirrored"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.com:pingdotgg/codething-mvp.git", + remoteDir, + ); + // `git remote set-url --add`: Git fetches from the first URL and also pushes to this one. + yield* runGit(repoDir, [ + "config", + "--add", + "remote.origin.url", + "git@gitlab.com:mirror-owner/codething-mvp.git", + ]); + + const { manager } = yield* makeManager({ + providerScenario: { + prListSequence: [ + [ + { + number: 16, + title: "Mirrored branch", + url: "https://github.com/pingdotgg/codething-mvp/pull/16", + baseRefName: "main", + headRefName: "feature/mirrored", + isCrossRepository: false, + headRepositoryNameWithOwner: "pingdotgg/codething-mvp", + headRepositoryOwnerLogin: "pingdotgg", + }, + ], + ], + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.sourceControlProvider?.kind).toBe("github"); + expect(status.pr?.number).toBe(16); + }), + ); + it.effect("status reports the latest of a branch's closed and merged PRs", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 6715f9fd65ed0..31b3c17f26108 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -658,6 +658,16 @@ export const make = Effect.gen(function* () { const path = yield* Path.Path; const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd }); + // Git fetches from a remote's first URL; `config --get` returns the last of several. + const readRemoteUrl = (cwd: string, remoteName: string) => + gitCore + .execute({ + operation: "GitManager.readRemoteUrl", + cwd, + args: ["config", "--get-all", `remote.${remoteName}.url`], + allowNonZeroExit: true, + }) + .pipe(Effect.map((result) => result.stdout.split("\n", 1)[0]?.trim() || null)); const changeRequestTermsFor = Effect.fn("GitManager.changeRequestTermsFor")(function* ( cwd: string, ) { @@ -930,7 +940,7 @@ export const make = Effect.gen(function* () { repository: repositoryNameWithOwner, ...pullRequestHostOptions(pullRequest.url), }); - const originRemoteUrl = yield* gitCore.readConfigValue(cwd, "remote.origin.url"); + const originRemoteUrl = yield* readRemoteUrl(cwd, "origin"); const remoteUrl = shouldPreferSshRemote(originRemoteUrl) ? cloneUrls.sshUrl : cloneUrls.url; const preferredRemoteName = pullRequest.headRepositoryOwnerLogin?.trim() || @@ -1015,7 +1025,7 @@ export const make = Effect.gen(function* () { ? Effect.fail(cause) : Effect.gen(function* () { const remoteName = yield* gitCore.resolvePrimaryRemoteName(cwd); - const remoteUrl = yield* gitCore.readConfigValue(cwd, `remote.${remoteName}.url`); + const remoteUrl = yield* readRemoteUrl(cwd, remoteName); const pullRequestKey = pullRequestRepositoryKey(pullRequest.url); if ( remoteUrl === null || @@ -1044,7 +1054,7 @@ export const make = Effect.gen(function* () { repository: repositoryNameWithOwner, ...pullRequestHostOptions(pullRequest.url), }); - const originRemoteUrl = yield* gitCore.readConfigValue(cwd, "remote.origin.url"); + const originRemoteUrl = yield* readRemoteUrl(cwd, "origin"); const remoteUrl = shouldPreferSshRemote(originRemoteUrl) ? cloneUrls.sshUrl : cloneUrls.url; const preferredRemoteName = pullRequest.headRepositoryOwnerLogin?.trim() || @@ -1422,6 +1432,8 @@ export const make = Effect.gen(function* () { const readConfigValueNullable = (cwd: string, key: string) => gitCore.readConfigValue(cwd, key).pipe(Effect.orElseSucceed(() => null)); + const readRemoteUrlNullable = (cwd: string, remoteName: string) => + readRemoteUrl(cwd, remoteName).pipe(Effect.orElseSucceed(() => null)); // Whether the remote's host creates change requests in the only remote. A host's tools can let // configuration outside the remotes name another repository; the remote itself otherwise. @@ -1455,8 +1467,8 @@ export const make = Effect.gen(function* () { ? "origin" : ((yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)) ?? "origin"); const remoteUrl = - (yield* readConfigValueNullable(cwd, `remote.${preferredRemoteName}.url`)) ?? - (yield* readConfigValueNullable(cwd, "remote.origin.url")); + (yield* readRemoteUrlNullable(cwd, preferredRemoteName)) ?? + (yield* readRemoteUrlNullable(cwd, "origin")); const provider = remoteUrl ? sourceControlDefinitions.recognizeRemote(remoteUrl) : null; if (!remoteUrl || provider?.kind !== "unknown") return provider; @@ -1482,7 +1494,7 @@ export const make = Effect.gen(function* () { }; } - const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`); + const remoteUrl = yield* readRemoteUrlNullable(cwd, remoteName); let repositoryNameWithOwner = parseRepositoryNameWithOwnerFromRemoteUrl(remoteUrl); if ( remoteUrl !== null && @@ -1731,7 +1743,7 @@ export const make = Effect.gen(function* () { remoteName = matches[0]; } if (remoteName === undefined) return null; - const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`); + const remoteUrl = yield* readRemoteUrlNullable(cwd, remoteName); return remoteUrl === null ? null : remoteName; }); @@ -2451,7 +2463,13 @@ export const make = Effect.gen(function* () { const readRemoteUrlKey = (remoteName: string | null) => remoteName === null ? Effect.succeed(null) - : readConfig(`remote.${remoteName}.url`).pipe( + : rememberPrLookupGitState( + cwd, + `remoteUrl\u0000${remoteName}`, + fresh, + readRemoteUrl(cwd, remoteName), + ).pipe( + Effect.orElseSucceed(() => null), Effect.map((remoteUrl) => remoteUrl ? sourceControlDefinitions.repositoryKeyFromRemote(remoteUrl) : null, ), From 0f4434118e46a5a5c3dad33f2b06f8c932a74701 Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:34:45 +1100 Subject: [PATCH 2/3] fix(source-control): PR checkout picks the fork protocol from the fetched remote URL Co-Authored-By: Claude Opus 5.5 --- .../src/server/BitbucketApi.test.ts | 31 +++++++++++++++++ .../src/server/BitbucketApi.ts | 7 +++- .../GitHubSourceControlProvider.test.ts | 34 +++++++++++++++++++ .../src/server/GitHubSourceControlProvider.ts | 4 ++- 4 files changed, 74 insertions(+), 2 deletions(-) diff --git a/packages/source-control-bitbucket/src/server/BitbucketApi.test.ts b/packages/source-control-bitbucket/src/server/BitbucketApi.test.ts index f18c8f7242129..ddffca32e6428 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketApi.test.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketApi.test.ts @@ -253,6 +253,37 @@ it.effect.each([false, true])("honors PR URL repositories, context=%s", (withCon }).pipe(Effect.provide(layer)); }); +it.effect("adds a fork remote with the protocol of the URL origin fetches from", () => { + // `readConfigValue` answers `remote.origin.url` with an SSH second URL. + const { git, layer } = makeLayer({ + remoteUrl: "https://bitbucket.org/pingdotgg/t3code.git", + response: (request) => + Response.json( + request.url.endsWith("/pullrequests/42") + ? bitbucketPullRequest + : { + ...repositoryJson, + full_name: "octocat/t3code", + links: { + clone: [ + { name: "https", href: "https://bitbucket.org/octocat/t3code.git" }, + { name: "ssh", href: "git@bitbucket.org:octocat/t3code.git" }, + ], + }, + }, + ), + }); + return Effect.gen(function* () { + const bitbucket = yield* BitbucketApi.BitbucketApi; + yield* bitbucket.checkoutPullRequest({ cwd: "/repo", reference: "#42" }); + assert.deepStrictEqual(git.ensureRemote.mock.calls[0]?.[0], { + cwd: "/repo", + preferredName: "octocat", + url: "https://bitbucket.org/octocat/t3code.git", + }); + }).pipe(Effect.provide(layer)); +}); + it.effect.each([false, true])( "reuses matching checkout remotes for numeric references, context=%s", (withContext) => { diff --git a/packages/source-control-bitbucket/src/server/BitbucketApi.ts b/packages/source-control-bitbucket/src/server/BitbucketApi.ts index 2f46fcb80a65a..a378013ad30b6 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketApi.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketApi.ts @@ -915,7 +915,12 @@ export const make = Effect.gen(function* () { repository: input.sourceRepositoryName, ...(input.context ? { context: input.context } : {}), }).pipe(Effect.map(normalizeRepositoryCloneUrls)); - const originRemoteUrl = yield* readConfigValueNullable(input.cwd, "remote.origin.url"); + // Git fetches from a remote's first URL; `config --get` returns the last of several. + const originRemoteUrl = yield* git.remotes(input.cwd).pipe( + Effect.flatten, + Effect.map(({ remotes }) => remotes.find((remote) => remote.name === "origin")?.url ?? null), + Effect.orElseSucceed(() => null), + ); return yield* git.ensureRemote({ cwd: input.cwd, preferredName: input.isCrossRepository diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts index a5a08fcfc2cc4..1045439e5cc94 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts @@ -651,6 +651,40 @@ describe("GitHubSourceControlProvider.checkoutChangeRequest", () => { }).pipe(Effect.provide(layer)); }); + it.effect("adds a fork remote with the protocol of the URL origin fetches from", () => { + // The harness's `remote.origin.url` config read answers with an SSH second URL. + const { layer, git } = harness({ + remotes: [ + "origin\thttps://github.com/acme/web.git (fetch)", + "origin\thttps://github.com/acme/web.git (push)", + "origin\tgit@github.com:acme/web.git (push)", + ].join("\n"), + graphql: () => ({ pullRequest: node(7, "feature", "someone") }), + api: { + rest: (input) => { + const fullName = input.path.slice("repos/".length); + return Effect.succeed( + restResponse({ + full_name: fullName, + html_url: `https://github.com/${fullName}`, + ssh_url: `git@github.com:${fullName}.git`, + default_branch: "main", + }), + ); + }, + }, + }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + yield* gh.checkoutChangeRequest({ cwd: "/repo", reference: "7" }); + assert.deepStrictEqual(git.find(([name]) => name === "ensureRemote")?.[1], { + cwd: "/repo", + preferredName: "someone", + url: "https://github.com/someone/web", + }); + }).pipe(Effect.provide(layer)); + }); + it.effect("keeps git's own output out of what a failed checkout reports", () => { const { layer } = harness({ remotes: remotesOutput(["origin", "git@github.com:acme/web.git"]), diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts index 8583797a9688b..b170376f281f6 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts @@ -724,7 +724,9 @@ export const make = Effect.gen(function* () { if (known !== null) return known; const [owner, name] = headNameWithOwner.split("/"); const fork = yield* readRepository({ host: base.host, owner: owner!, name: name! }); - const originUrl = yield* git.readConfigValue(input.cwd, "remote.origin.url"); + // Git fetches from a remote's first URL; `config --get` returns the last of several. + const originUrl = + parseFetchRemotes(remotes).find((remote) => remote.name === "origin")?.url ?? null; return yield* git.ensureRemote({ cwd: input.cwd, preferredName: headOwner ?? "fork", From d89b4e5f7d5f4ab5b672331ec06162d8c6529dfb Mon Sep 17 00:00:00 2001 From: Alex Southwell <4596216+saphid@users.noreply.github.com> Date: Tue, 3 Nov 2026 10:28:55 +1100 Subject: [PATCH 3/3] fix(source-control): fork protocol comes from origin's configured URL, not its insteadOf rewrite Co-Authored-By: Claude Opus 5.5 --- .../src/server/BitbucketApi.test.ts | 23 +++++++++++++++++-- .../src/server/BitbucketApi.ts | 18 ++++++++++----- .../GitHubSourceControlProvider.test.ts | 12 ++++++---- .../src/server/GitHubSourceControlProvider.ts | 13 ++++++++--- 4 files changed, 51 insertions(+), 15 deletions(-) diff --git a/packages/source-control-bitbucket/src/server/BitbucketApi.test.ts b/packages/source-control-bitbucket/src/server/BitbucketApi.test.ts index ddffca32e6428..a0b6639797b63 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketApi.test.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketApi.test.ts @@ -9,6 +9,7 @@ import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as Queue from "effect/Queue"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; @@ -63,6 +64,14 @@ const repositoryJson = { mainbranch: { name: "main" }, }; +const gitOutput = (stdout: string) => ({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}); + function makeLayer(input: { readonly response: (request: HttpClientRequest.HttpClientRequest) => Response; /** Holds the answer until it resolves, in place of answering at once. */ @@ -85,6 +94,9 @@ function makeLayer(input: { ), ); const gitMock = { + execute: vi.fn(() => + Effect.succeed(gitOutput("git@bitbucket.org:pingdotgg/t3code.git\n")), + ), readConfigValue: vi.fn(() => Effect.succeed("git@bitbucket.org:pingdotgg/t3code.git"), ), @@ -254,9 +266,16 @@ it.effect.each([false, true])("honors PR URL repositories, context=%s", (withCon }); it.effect("adds a fork remote with the protocol of the URL origin fetches from", () => { - // `readConfigValue` answers `remote.origin.url` with an SSH second URL. + // `git remote -v` shows origin's SSH `insteadOf` rewrite; config holds HTTPS, then an SSH push URL. const { git, layer } = makeLayer({ - remoteUrl: "https://bitbucket.org/pingdotgg/t3code.git", + git: { + execute: () => + Effect.succeed( + gitOutput( + "https://bitbucket.org/pingdotgg/t3code.git\ngit@bitbucket.org:pingdotgg/t3code.git\n", + ), + ), + }, response: (request) => Response.json( request.url.endsWith("/pullrequests/42") diff --git a/packages/source-control-bitbucket/src/server/BitbucketApi.ts b/packages/source-control-bitbucket/src/server/BitbucketApi.ts index a378013ad30b6..2dc00313cf57e 100644 --- a/packages/source-control-bitbucket/src/server/BitbucketApi.ts +++ b/packages/source-control-bitbucket/src/server/BitbucketApi.ts @@ -915,12 +915,18 @@ export const make = Effect.gen(function* () { repository: input.sourceRepositoryName, ...(input.context ? { context: input.context } : {}), }).pipe(Effect.map(normalizeRepositoryCloneUrls)); - // Git fetches from a remote's first URL; `config --get` returns the last of several. - const originRemoteUrl = yield* git.remotes(input.cwd).pipe( - Effect.flatten, - Effect.map(({ remotes }) => remotes.find((remote) => remote.name === "origin")?.url ?? null), - Effect.orElseSucceed(() => null), - ); + // Git fetches from a remote's first URL; `config --get` returns the last of several, and + // `git remote -v` applies `insteadOf` rewrites. + const originRemoteUrl = yield* git + .execute({ + operation: "BitbucketApi.readOriginRemoteUrl", + cwd: input.cwd, + args: ["config", "--get-all", "remote.origin.url"], + }) + .pipe( + Effect.map((result) => result.stdout.split("\n", 1)[0]?.trim() || null), + Effect.orElseSucceed(() => null), + ); return yield* git.ensureRemote({ cwd: input.cwd, preferredName: input.isCrossRepository diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts index 1045439e5cc94..c52ec014a866d 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts @@ -164,6 +164,7 @@ function harness(input: { readonly localBranches?: ReadonlyArray; readonly resolved?: string; readonly gitFailure?: unknown; + readonly originUrls?: string; }) { const git: Array = []; const graphqlCalls: Array = []; @@ -178,7 +179,9 @@ function harness(input: { execute: (args) => Effect.sync(() => { git.push(["execute", args.args]); - return processOutput(""); + return processOutput( + args.args[0] === "config" ? (input.originUrls ?? "git@github.com:acme/web.git\n") : "", + ); }), resolvePrimaryRemoteName: () => Effect.succeed("origin"), readConfigValue: () => Effect.succeed("git@github.com:acme/web.git"), @@ -652,13 +655,14 @@ describe("GitHubSourceControlProvider.checkoutChangeRequest", () => { }); it.effect("adds a fork remote with the protocol of the URL origin fetches from", () => { - // The harness's `remote.origin.url` config read answers with an SSH second URL. + // `git remote -v` shows origin's SSH `insteadOf` rewrite; config holds HTTPS, then an SSH push URL. const { layer, git } = harness({ remotes: [ - "origin\thttps://github.com/acme/web.git (fetch)", - "origin\thttps://github.com/acme/web.git (push)", + "origin\tgit@github.com:acme/web.git (fetch)", + "origin\tgit@github.com:acme/web.git (push)", "origin\tgit@github.com:acme/web.git (push)", ].join("\n"), + originUrls: "https://github.com/acme/web.git\ngit@github.com:acme/web.git\n", graphql: () => ({ pullRequest: node(7, "feature", "someone") }), api: { rest: (input) => { diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts index b170376f281f6..922d8428968b4 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts @@ -724,9 +724,16 @@ export const make = Effect.gen(function* () { if (known !== null) return known; const [owner, name] = headNameWithOwner.split("/"); const fork = yield* readRepository({ host: base.host, owner: owner!, name: name! }); - // Git fetches from a remote's first URL; `config --get` returns the last of several. - const originUrl = - parseFetchRemotes(remotes).find((remote) => remote.name === "origin")?.url ?? null; + // Git fetches from a remote's first URL; `config --get` returns the last of several, and + // `git remote -v` applies `insteadOf` rewrites. + const originUrl = yield* runGit(input.cwd, "readOriginUrl", [ + "config", + "--get-all", + "remote.origin.url", + ]).pipe( + Effect.map((result) => result.stdout.split("\n", 1)[0]?.trim() || null), + Effect.orElseSucceed(() => null), + ); return yield* git.ensureRemote({ cwd: input.cwd, preferredName: headOwner ?? "fork",