From b21d87b362ed760e642ad7d2214ca2ed191e857b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 21:26:22 +0000 Subject: [PATCH 1/3] test(github): reproduce marked fork lost when a local remote is present (#24383) Co-Authored-By: Claude Opus 5.5 --- .../server/gitHubRepositoryResolution.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts b/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts index c78c1fd7612f8..518b1ad89a24d 100644 --- a/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts +++ b/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts @@ -2,6 +2,7 @@ import { assert, describe, it } from "@effect/vitest"; import { gitHubApiHostForRemote, + resolveGitHubRepository, selectGitHubBaseRepository, } from "./gitHubRepositoryResolution.ts"; @@ -79,6 +80,27 @@ describe("selectGitHubBaseRepository", () => { }); }); +describe("resolveGitHubRepository", () => { + it("keeps the remote gh marked as base when another remote is a local path", () => { + const remotes = [ + ["origin", "https://github.com/me/web.git"], + ["upstream", "https://github.com/acme/web.git"], + ["container-use", "/tmp/local.git"], + ] + .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) + .join("\n"); + assert.deepStrictEqual( + resolveGitHubRepository({ + remotes, + resolved: "remote.origin.gh-resolved base", + hostHint: undefined, + defaultHost: "github.com", + }).locator, + { host: "github.com", owner: "me", name: "web" }, + ); + }); +}); + describe("gitHubApiHostForRemote", () => { it("reads an SSH alias through github.com and a real host as itself", () => { assert.strictEqual(gitHubApiHostForRemote("git@github:acme/web.git"), "github.com"); From 1d05d43b900b11ec427c61d53a022e99639bf241 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 21:28:14 +0000 Subject: [PATCH 2/3] fix(github): a local-path remote no longer hides the remote gh marked as base (#24383) gh ignores remotes without a host, but selectGitHubBaseRepository gave up on any remote it could not place, so the fallback picked upstream over the remote gh repo set-default marked and PRs opened against the wrong repo. Co-Authored-By: Claude Opus 5.5 --- .../src/server/gitHubRepositoryResolution.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/source-control-github/src/server/gitHubRepositoryResolution.ts b/packages/source-control-github/src/server/gitHubRepositoryResolution.ts index ab0d5975d8440..c309e73c7db34 100644 --- a/packages/source-control-github/src/server/gitHubRepositoryResolution.ts +++ b/packages/source-control-github/src/server/gitHubRepositoryResolution.ts @@ -23,7 +23,7 @@ export function parseFetchRemotes( /** * The repository `gh pr list` reads in a checkout, picked the way gh picks one without a prompt: the * remote `gh repo set-default` marked, else the first of upstream, github, origin (in any case), else - * the only remote. + * the only remote. A remote with no host, such as a local path, is ignored as gh ignores it. * * Null whenever gh might weigh the remotes differently: a remote on another host or under an SSH alias, * more than one mark, or several remotes with none of those names. @@ -36,6 +36,7 @@ export function selectGitHubBaseRepository(input: { const host = input.host.toLowerCase(); const repositories = new Map(); for (const remote of parseFetchRemotes(input.remotes)) { + if (parseGitRemoteHost(remote.url) === null) continue; const [remoteHost, owner, name, ...rest] = normalizeGitRemoteUrl(remote.url).split("/"); if (remoteHost !== host || !owner || !name || rest.length > 0) return null; repositories.set(remote.name, { owner, name }); From e9322b87565f6f4e7c72ef7e1a904acb26665cd2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 24 Oct 2026 21:37:54 +0000 Subject: [PATCH 3/3] fix(github): a userless SCP remote still counts when skipping hostless remotes (#24383) parseGitRemoteHost has no host for `github.com:me/web.git`, so the local-path skip also dropped it: the fork gh marked as base lost to upstream, and a `gitlab.com:` mirror no longer made the choice gh's. Skip a remote only when neither parser finds a host. Co-Authored-By: Claude Opus 5.5 --- .../server/gitHubRepositoryResolution.test.ts | 21 +++++++++++++++++++ .../src/server/gitHubRepositoryResolution.ts | 7 +++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts b/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts index 518b1ad89a24d..9ec923c390abf 100644 --- a/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts +++ b/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts @@ -32,6 +32,18 @@ describe("selectGitHubBaseRepository", () => { select({ remotes: fork, resolved: "remote.origin.gh-resolved acme/other" }), { owner: "acme", name: "other" }, ); + assert.deepStrictEqual( + select({ + remotes: remotes( + ["origin", "github.com:me/web.git"], + ["upstream", "https://github.com/acme/web.git"], + ["local", "/tmp/local.git"], + ["file", "file:///tmp/local.git"], + ), + resolved: "remote.origin.gh-resolved base", + }), + { owner: "me", name: "web" }, + ); const partialClone = fork.replaceAll("(fetch)", "(fetch) [blob:none]"); assert.deepStrictEqual( select({ remotes: partialClone, resolved: "remote.origin.gh-resolved base" }), @@ -60,6 +72,15 @@ describe("selectGitHubBaseRepository", () => { ["mirror", "git@gitlab.com:me/web.git"], ), }, + { + remotes: remotes( + ["origin", "git@github.com:me/web.git"], + ["mirror", "gitlab.com:me/web.git"], + ), + }, + { + remotes: remotes(["origin", "git@github.com:me/web.git"], ["bad", "https://github.com/me"]), + }, { remotes: remotes(["origin", "git@github-work:me/web.git"]) }, { remotes: remotes( diff --git a/packages/source-control-github/src/server/gitHubRepositoryResolution.ts b/packages/source-control-github/src/server/gitHubRepositoryResolution.ts index c309e73c7db34..feb508fecf9ef 100644 --- a/packages/source-control-github/src/server/gitHubRepositoryResolution.ts +++ b/packages/source-control-github/src/server/gitHubRepositoryResolution.ts @@ -1,4 +1,4 @@ -import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; +import { normalizeGitRemoteUrl, parseGitRemoteRepository } from "@t3tools/shared/git"; import { isSshRemoteUrl } from "@t3tools/shared/sourceControl"; import { parseGitRemoteHost } from "@t3tools/source-control-core/shared/definition"; import { definition } from "../shared/definition.ts"; @@ -36,7 +36,10 @@ export function selectGitHubBaseRepository(input: { const host = input.host.toLowerCase(); const repositories = new Map(); for (const remote of parseFetchRemotes(input.remotes)) { - if (parseGitRemoteHost(remote.url) === null) continue; + // A userless SCP remote (`github.com:me/web`) has no host for parseGitRemoteHost, but gh reads one. + if (parseGitRemoteRepository(remote.url) === null && parseGitRemoteHost(remote.url) === null) { + continue; + } const [remoteHost, owner, name, ...rest] = normalizeGitRemoteUrl(remote.url).split("/"); if (remoteHost !== host || !owner || !name || rest.length > 0) return null; repositories.set(remote.name, { owner, name });