From b214216f4fa0f3edfaa938f9988898a1f2f1ee98 Mon Sep 17 00:00:00 2001 From: Michael Perreux <5790877+mperreux@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:19:54 -0400 Subject: [PATCH 1/4] fix(github): read github.com subdomain SSH hosts through github.com A remote on a subdomain of github.com, such as GitHub's SSH-over-443 host `ssh.github.com` or an ~/.ssh/config alias like `work.github.com`, was treated as a self-hosted GitHub API host. Pull request lookups then asked for a credential on that host and failed, even though `gh` itself resolves the repository fine. github.com serves no API under its subdomains, so read them through github.com: in the remote resolver, the same way dotless SSH aliases already are, and in `pullRequestHostOf`, which names a project's host for the pull request list and the other places the server and clients share. --- packages/contracts/src/pullRequest.test.ts | 15 +++++++ packages/contracts/src/pullRequest.ts | 13 +++++- .../server/gitHubRepositoryResolution.test.ts | 41 +++++++++++++++++++ .../src/server/gitHubRepositoryResolution.ts | 11 +++-- 4 files changed, 76 insertions(+), 4 deletions(-) diff --git a/packages/contracts/src/pullRequest.test.ts b/packages/contracts/src/pullRequest.test.ts index c83a7fb92822..f66644132e98 100644 --- a/packages/contracts/src/pullRequest.test.ts +++ b/packages/contracts/src/pullRequest.test.ts @@ -89,6 +89,21 @@ describe("PullRequestListResult", () => { ), ).toBe("forge.example"); }); + it("names a GitHub checkout behind a github.com subdomain by github.com", () => { + const identity = { + canonicalKey: "work.github.com/acme/web", + locator: { remoteUrl: "git@work.github.com:acme/web.git" }, + }; + expect(pullRequestHostOf(identity, SourceControlProviderKind.make("github"))).toBe( + "github.com", + ); + expect( + pullRequestHostOf( + { canonicalKey: "github.example.com/acme/web" }, + SourceControlProviderKind.make("github"), + ), + ).toBe("github.example.com"); + }); /** * The RPC builds this codec at call time, so a shape it cannot lower — an open-keyed record * with an optional value, for one — fails as an interrupted request rather than as a schema diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 57f775f29747..8f92615aa818 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -1385,7 +1385,18 @@ export function pullRequestHostOf( } } const host = identity?.canonicalKey?.split("/")[0]?.trim(); - return host === undefined || host.length === 0 ? kind : host.toLowerCase(); + if (host === undefined || host.length === 0) return kind; + return kind === "github" ? gitHubApiHostOf(host) : host.toLowerCase(); +} + +/** + * The host a GitHub checkout is read through. github.com serves no API under its subdomains, so + * a remote there (`ssh.github.com`, or an SSH alias like `work.github.com`) is read through + * github.com; a self-hosted GitHub lives on its own domain. + */ +export function gitHubApiHostOf(host: string): string { + const normalized = host.toLowerCase(); + return normalized.endsWith(".github.com") ? "github.com" : normalized; } /** diff --git a/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts b/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts index 3b33f0c38e40..fad8f97a3aff 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"; @@ -84,4 +85,44 @@ describe("gitHubApiHostForRemote", () => { ); assert.strictEqual(gitHubApiHostForRemote("git@gitlab.com:a/b.git"), null); }); + + it("reads a subdomain of github.com through github.com", () => { + assert.strictEqual( + gitHubApiHostForRemote("ssh://git@ssh.github.com:443/acme/web.git"), + "github.com", + ); + assert.strictEqual(gitHubApiHostForRemote("git@work.github.com:acme/web.git"), "github.com"); + }); +}); + +describe("resolveGitHubRepository", () => { + it("resolves a checkout behind a github.com subdomain alias to github.com", () => { + const remotes = [ + "origin\tgit@work.github.com:acme/web.git (fetch)", + "origin\tgit@work.github.com:acme/web.git (push)", + ].join("\n"); + const expected = { + host: "github.com", + locator: { host: "github.com", owner: "acme", name: "web" }, + }; + assert.deepStrictEqual( + resolveGitHubRepository({ + remotes, + resolved: "", + hostHint: undefined, + defaultHost: "github.com", + }), + expected, + ); + // The provider context names the alias as the checkout's host. + assert.deepStrictEqual( + resolveGitHubRepository({ + remotes, + resolved: "", + hostHint: "work.github.com", + defaultHost: "github.com", + }), + expected, + ); + }); }); diff --git a/packages/source-control-github/src/server/gitHubRepositoryResolution.ts b/packages/source-control-github/src/server/gitHubRepositoryResolution.ts index 21f68b4cdb20..7af4a5cfae0a 100644 --- a/packages/source-control-github/src/server/gitHubRepositoryResolution.ts +++ b/packages/source-control-github/src/server/gitHubRepositoryResolution.ts @@ -1,3 +1,4 @@ +import { gitHubApiHostOf } from "@t3tools/contracts"; import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; import { detectSourceControlProviderFromRemoteUrl, @@ -172,12 +173,14 @@ export function parsePullRequestReference( /** * The GitHub host a remote URL is served from, or null for a remote that is not GitHub. An SSH * alias (`git@github-work:owner/repo`) names no API host of its own; it is read through - * `github.com`, which is what such an alias almost always stands for (issue #6198). + * `github.com`, which is what such an alias almost always stands for (issue #6198). So is a + * subdomain of github.com, such as `ssh.github.com` or an alias like `work.github.com`. */ export function gitHubApiHostForRemote(remoteUrl: string): string | null { const provider = detectSourceControlProviderFromRemoteUrl(remoteUrl); if (provider === null) return null; const host = new URL(provider.baseUrl).host.toLowerCase(); + if (gitHubApiHostOf(host) === "github.com") return "github.com"; // A dotless SSH host is an alias from ~/.ssh/config, never a real API host. if (isSshRemoteUrl(remoteUrl) && !host.includes(".")) { return host.includes("github") ? "github.com" : null; @@ -185,8 +188,10 @@ export function gitHubApiHostForRemote(remoteUrl: string): string | null { return provider.kind === "github" ? host : null; } -/** A caller's host hint, read the way a remote's host is: a dotless alias is not an API host. */ +/** A caller's host hint, read the way a remote's host is: an alias is not an API host. */ function apiHostForHint(host: string): string { const normalized = host.toLowerCase(); - return !normalized.includes(".") && normalized.includes("github") ? "github.com" : normalized; + return !normalized.includes(".") && normalized.includes("github") + ? "github.com" + : gitHubApiHostOf(normalized); } From daa20228463dd173da781f084cccd10d12f7c01d Mon Sep 17 00:00:00 2001 From: Michael Perreux <5790877+mperreux@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:57:30 -0400 Subject: [PATCH 2/4] fix(github): normalize GH_REPO hosts and ported github.com subdomains Read a github.com subdomain with an explicit port through github.com, and give a GH_REPO owner/name the same host normalization as remote host hints. --- packages/contracts/src/pullRequest.test.ts | 6 +++++ packages/contracts/src/pullRequest.ts | 2 +- .../GitHubSourceControlProvider.test.ts | 27 +++++++++++++++++++ .../src/server/GitHubSourceControlProvider.ts | 3 ++- .../server/gitHubRepositoryResolution.test.ts | 4 +++ .../src/server/gitHubRepositoryResolution.ts | 2 +- 6 files changed, 41 insertions(+), 3 deletions(-) diff --git a/packages/contracts/src/pullRequest.test.ts b/packages/contracts/src/pullRequest.test.ts index f66644132e98..9afe12435e0c 100644 --- a/packages/contracts/src/pullRequest.test.ts +++ b/packages/contracts/src/pullRequest.test.ts @@ -10,6 +10,7 @@ import { PullRequestListResult, PullRequestReviewerRequestInput, PullRequestSetFilesViewedInput, + gitHubApiHostOf, pullRequestHostOf, resolvePullRequestAuthorFilter, } from "./pullRequest.ts"; @@ -104,6 +105,11 @@ describe("PullRequestListResult", () => { ), ).toBe("github.example.com"); }); + + it("reads a github.com subdomain with a port through github.com", () => { + expect(gitHubApiHostOf("work.github.com:8443")).toBe("github.com"); + expect(gitHubApiHostOf("github.example.com:8443")).toBe("github.example.com:8443"); + }); /** * The RPC builds this codec at call time, so a shape it cannot lower — an open-keyed record * with an optional value, for one — fails as an interrupted request rather than as a schema diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 8f92615aa818..33ca061890fe 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -1396,7 +1396,7 @@ export function pullRequestHostOf( */ export function gitHubApiHostOf(host: string): string { const normalized = host.toLowerCase(); - return normalized.endsWith(".github.com") ? "github.com" : normalized; + return normalized.replace(/:\d+$/, "").endsWith(".github.com") ? "github.com" : normalized; } /** diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts index 73475840ae22..3b444dcaa46b 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts @@ -201,6 +201,33 @@ describe("GitHubSourceControlProvider repository resolution", () => { }).pipe(Effect.provide(layer)); }); + it.effect("reads a GH_REPO owner/name behind a github.com subdomain through github.com", () => { + const hosts: string[] = []; + const { layer } = harness({ + remotes: remotesOutput(["origin", "git@work.github.com:acme/web.git"]), + api: { + rest: (input) => + Effect.sync(() => { + hosts.push(`${input.host} ${input.path}`); + return restResponse({ + full_name: "chosen/repo", + html_url: "https://github.com/chosen/repo", + ssh_url: "git@github.com:chosen/repo.git", + default_branch: "main", + }); + }), + }, + }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + yield* gh.getDefaultBranch({ cwd: "/repo", context: githubContext("work.github.com") }); + assert.deepStrictEqual(hosts, ["github.com repos/chosen/repo"]); + }).pipe( + Effect.provide(layer), + Effect.provideService(HostProcess.Environment, { GH_REPO: "chosen/repo" }), + ); + }); + it.effect("names a missing repository rather than a pull request", () => { const { layer } = harness({ remotes: remotesOutput(["origin", "git@github.com:acme/gone.git"]), diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts index 04fe68874412..58c4b0e9b5e3 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts @@ -28,6 +28,7 @@ import { type NormalizedGitHubPullRequestRecord, } from "./gitHubPullRequests.ts"; import { + apiHostForHint, gitHubApiHostForRemote, parseFetchRemotes, parseGitHubRepositorySelector, @@ -513,7 +514,7 @@ export const make = Effect.gen(function* () { }) { const envRepository = environment.GH_REPO?.trim(); const hostHint = input.host ?? contextHost(input.context); - const defaultHost = (hostHint ?? environment.GH_HOST ?? "github.com").toLowerCase(); + const defaultHost = apiHostForHint(hostHint ?? environment.GH_HOST ?? "github.com"); if (envRepository) { const locator = parseGitHubRepositorySelector(envRepository, defaultHost); if (locator !== null) return locator; diff --git a/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts b/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts index fad8f97a3aff..23458dff98fe 100644 --- a/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts +++ b/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts @@ -92,6 +92,10 @@ describe("gitHubApiHostForRemote", () => { "github.com", ); assert.strictEqual(gitHubApiHostForRemote("git@work.github.com:acme/web.git"), "github.com"); + assert.strictEqual( + gitHubApiHostForRemote("https://work.github.com:8443/acme/web.git"), + "github.com", + ); }); }); diff --git a/packages/source-control-github/src/server/gitHubRepositoryResolution.ts b/packages/source-control-github/src/server/gitHubRepositoryResolution.ts index 7af4a5cfae0a..1e22c116535e 100644 --- a/packages/source-control-github/src/server/gitHubRepositoryResolution.ts +++ b/packages/source-control-github/src/server/gitHubRepositoryResolution.ts @@ -189,7 +189,7 @@ export function gitHubApiHostForRemote(remoteUrl: string): string | null { } /** A caller's host hint, read the way a remote's host is: an alias is not an API host. */ -function apiHostForHint(host: string): string { +export function apiHostForHint(host: string): string { const normalized = host.toLowerCase(); return !normalized.includes(".") && normalized.includes("github") ? "github.com" From 52daedaebaac536d7fee2f2feea7590c6eebbb69 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 06:10:32 +0000 Subject: [PATCH 3/4] fix(github): normalize subdomain host fallbacks consistently --- packages/contracts/src/pullRequest.test.ts | 30 ++-- packages/contracts/src/pullRequest.ts | 6 +- .../GitHubSourceControlProvider.test.ts | 141 +++++++++++++++++- .../src/server/GitHubSourceControlProvider.ts | 16 +- .../server/gitHubRepositoryResolution.test.ts | 45 ------ .../src/server/gitHubRepositoryResolution.ts | 8 +- 6 files changed, 158 insertions(+), 88 deletions(-) diff --git a/packages/contracts/src/pullRequest.test.ts b/packages/contracts/src/pullRequest.test.ts index 242a2dd030d7..911524fbbe5a 100644 --- a/packages/contracts/src/pullRequest.test.ts +++ b/packages/contracts/src/pullRequest.test.ts @@ -10,7 +10,6 @@ import { PullRequestListResult, PullRequestReviewerRequestInput, PullRequestSetFilesViewedInput, - gitHubApiHostOf, pullRequestHostOf, resolvePullRequestAuthorFilter, } from "./pullRequest.ts"; @@ -90,26 +89,19 @@ describe("PullRequestListResult", () => { ), ).toBe("forge.example"); }); - it("names a GitHub checkout behind a github.com subdomain by github.com", () => { - const identity = { - canonicalKey: "work.github.com/acme/web", - locator: { remoteUrl: "git@work.github.com:acme/web.git" }, - }; - expect(pullRequestHostOf(identity, SourceControlProviderKind.make("github"))).toBe( - "github.com", - ); - expect( - pullRequestHostOf( - { canonicalKey: "github.example.com/acme/web" }, - SourceControlProviderKind.make("github"), - ), - ).toBe("github.example.com"); - }); - it("reads a github.com subdomain with a port through github.com", () => { - expect(gitHubApiHostOf("work.github.com:8443")).toBe("github.com"); - expect(gitHubApiHostOf("github.example.com:8443")).toBe("github.example.com:8443"); + it("names GitHub subdomain checkouts by their API host and preserves enterprise ports", () => { + for (const [host, expected] of [ + ["ssh.github.com", "github.com"], + ["work.github.com:8443", "github.com"], + ["github.example.com:8443", "github.example.com:8443"], + ]) { + const identity = { canonicalKey: `${host}/acme/web` }; + expect(pullRequestHostOf(identity, SourceControlProviderKind.make("github"))).toBe(expected); + expect(pullRequestHostOf(identity, SourceControlProviderKind.make("gitlab"))).toBe(host); + } }); + /** * The RPC builds this codec at call time, so a shape it cannot lower — an open-keyed record * with an optional value, for one — fails as an interrupted request rather than as a schema diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 31c672c054ae..d898483b091a 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -912,11 +912,7 @@ export function pullRequestHostOf( return kind === "github" ? gitHubApiHostOf(host) : host.toLowerCase(); } -/** - * The host a GitHub checkout is read through. github.com serves no API under its subdomains, so - * a remote there (`ssh.github.com`, or an SSH alias like `work.github.com`) is read through - * github.com; a self-hosted GitHub lives on its own domain. - */ +/** SSH remotes under github.com share github.com's API host and credentials. */ export function gitHubApiHostOf(host: string): string { const normalized = host.toLowerCase(); return normalized.replace(/:\d+$/, "").endsWith(".github.com") ? "github.com" : normalized; diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts index c396e71f0db6..2fad87e28ec4 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts @@ -318,6 +318,54 @@ describe("GitHubSourceControlProvider repository resolution", () => { }).pipe(Effect.provide(layer)); }); + it.effect( + "reads GitHub subdomain remotes through github.com and preserves enterprise ports", + () => + Effect.gen(function* () { + for (const [remote, host, apiHost] of [ + ["ssh://git@ssh.github.com:443/acme/web.git", "ssh.github.com", "github.com"], + ["git@work.github.com:acme/web.git", "work.github.com", "github.com"], + [ + "https://github.example.com:8443/acme/web.git", + "github.example.com:8443", + "github.example.com:8443", + ], + ] as const) { + const requests: string[] = []; + const { layer } = harness({ + remotes: remotesOutput(["origin", remote]), + api: { + rest: (input) => + Effect.sync(() => { + requests.push(`${input.host} ${input.path}`); + return restResponse({ + full_name: "acme/web", + html_url: `https://${apiHost}/acme/web`, + ssh_url: `git@${apiHost}:acme/web.git`, + default_branch: "trunk", + }); + }), + }, + }); + yield* Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + assert.strictEqual(yield* gh.getDefaultBranch({ cwd: "/repo" }), "trunk"); + assert.strictEqual( + yield* gh.getDefaultBranch({ + cwd: "/repo", + context: { ...githubContext(host), remoteUrl: remote }, + }), + "trunk", + ); + assert.deepStrictEqual(requests, [ + `${apiHost} repos/acme/web`, + `${apiHost} repos/acme/web`, + ]); + }).pipe(Effect.provide(layer), Effect.provideService(HostProcess.Environment, {})); + } + }), + ); + it.effect("reads a GH_REPO owner/name behind a github.com subdomain through github.com", () => { const hosts: string[] = []; const { layer } = harness({ @@ -337,11 +385,54 @@ describe("GitHubSourceControlProvider repository resolution", () => { }); return Effect.gen(function* () { const gh = yield* GitHubSourceControlProvider.make; - yield* gh.getDefaultBranch({ cwd: "/repo", context: githubContext("work.github.com") }); - assert.deepStrictEqual(hosts, ["github.com repos/chosen/repo"]); + assert.strictEqual( + yield* gh.getDefaultBranch({ cwd: "/repo", context: githubContext("work.github.com") }), + "main", + ); + assert.strictEqual(yield* gh.getDefaultBranch({ cwd: "/repo" }), "main"); + assert.deepStrictEqual(hosts, [ + "github.com repos/chosen/repo", + "github.com repos/chosen/repo", + ]); }).pipe( Effect.provide(layer), - Effect.provideService(HostProcess.Environment, { GH_REPO: "chosen/repo" }), + Effect.provideService(HostProcess.Environment, { + GH_REPO: "chosen/repo", + GH_HOST: "ssh.github.com", + }), + ); + }); + + it.effect("uses github.com for a GH_HOST alias when cloning without a checkout remote", () => { + const requests: string[] = []; + const { layer } = harness({ + remotes: "", + api: { + rest: (input) => + Effect.sync(() => { + requests.push(`${input.host} ${input.path}`); + return restResponse({ + full_name: "chosen/repo", + html_url: "https://github.com/chosen/repo", + ssh_url: "git@github.com:chosen/repo.git", + }); + }), + }, + }); + return Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + assert.deepStrictEqual( + yield* gh.getRepositoryCloneUrls({ cwd: "/repo", repository: "chosen/repo" }), + { + nameWithOwner: "chosen/repo", + url: "https://github.com/chosen/repo", + sshUrl: "git@github.com:chosen/repo.git", + }, + ); + assert.deepStrictEqual(requests, ["github.com repos/chosen/repo"]); + }).pipe( + Effect.provide(layer), + Effect.provideService(HostProcess.Environment, { GH_HOST: "work.github.com" }), ); }); @@ -683,6 +774,50 @@ describe("GitHubSourceControlProvider writes", () => { }).pipe(Effect.provide(layer)); }); + it.effect("creates repositories on the API host named by GH_HOST", () => + Effect.gen(function* () { + for (const [host, apiHost] of [ + ["work.github.com", "github.com"], + ["github.example.com:8443", "github.example.com:8443"], + ]) { + const requests: string[] = []; + const { layer } = harness({ + remotes: "", + api: { + rest: (input) => + Effect.sync(() => { + requests.push(`${input.host} ${input.method ?? "GET"} ${input.path}`); + return input.path === "user" + ? restResponse({ login: "me" }) + : restResponse({ + full_name: "acme/new", + html_url: `https://${apiHost}/acme/new`, + ssh_url: `git@${apiHost}:acme/new.git`, + }); + }), + }, + }); + yield* Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + yield* gh.createRepository({ + cwd: "/repo", + repository: "acme/new", + visibility: "private", + }); + yield* gh.createRepository({ cwd: "/repo", repository: "new", visibility: "private" }); + assert.deepStrictEqual(requests, [ + `${apiHost} GET user`, + `${apiHost} POST orgs/acme/repos`, + `${apiHost} POST user/repos`, + ]); + }).pipe( + Effect.provide(layer), + Effect.provideService(HostProcess.Environment, { GH_HOST: host }), + ); + } + }), + ); + it.effect("looks up a bare repository name under the signed-in account on that host", () => { const requests: Array = []; return Effect.gen(function* () { diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts index 624077567254..44c274b4a95f 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts @@ -454,6 +454,7 @@ export const make = Effect.gen(function* () { const sourceControlHost = yield* SourceControlHost.SourceControlHost; const process = sourceControlHost.process; const environment = yield* HostProcess.Environment; + const environmentHost = apiHostForHint(environment.GH_HOST?.trim() || "github.com"); const git = sourceControlHost.git; const fileSystem = yield* FileSystem.FileSystem; @@ -475,7 +476,7 @@ export const make = Effect.gen(function* () { }) { const envRepository = environment.GH_REPO?.trim(); const hostHint = input.host ?? contextHost(input.context); - const defaultHost = apiHostForHint(hostHint ?? environment.GH_HOST ?? "github.com"); + const defaultHost = apiHostForHint(hostHint ?? environmentHost); if (envRepository) { const locator = parseGitHubRepositorySelector(envRepository, defaultHost); if (locator !== null) return locator; @@ -961,10 +962,10 @@ export const make = Effect.gen(function* () { ), getRepositoryCloneUrls: (input) => Effect.gen(function* () { - const fallbackHost = (yield* resolveRepository(input).pipe( + const fallbackHost = yield* resolveRepository(input).pipe( Effect.map((locator) => locator.host), - Effect.orElseSucceed(() => environment.GH_HOST ?? "github.com"), - )).toLowerCase(); + Effect.orElseSucceed(() => environmentHost), + ); const bareName = input.repository.trim().replace(/\.git$/i, ""); const locator = parseGitHubRepositorySelector(input.repository, fallbackHost) ?? @@ -980,14 +981,11 @@ export const make = Effect.gen(function* () { ), createRepository: (input) => Effect.gen(function* () { - const locator = parseGitHubRepositorySelector( - input.repository, - (environment.GH_HOST ?? "github.com").toLowerCase(), - ); + const locator = parseGitHubRepositorySelector(input.repository, environmentHost); const viewer = locator === null ? null : yield* readViewerLogin(locator.host); const owner = locator?.owner ?? viewer; const name = locator?.name ?? input.repository.trim(); - const host = locator?.host ?? (environment.GH_HOST?.trim().toLowerCase() || "github.com"); + const host = locator?.host ?? environmentHost; const isViewer = viewer !== null && owner?.toLowerCase() === viewer.toLowerCase(); const response = yield* rest( { diff --git a/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts b/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts index 92e5df716d71..7c6f754192bb 100644 --- a/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts +++ b/packages/source-control-github/src/server/gitHubRepositoryResolution.test.ts @@ -2,7 +2,6 @@ import { assert, describe, it } from "@effect/vitest"; import { gitHubApiHostForRemote, - resolveGitHubRepository, selectGitHubBaseRepository, } from "./gitHubRepositoryResolution.ts"; @@ -84,48 +83,4 @@ describe("gitHubApiHostForRemote", () => { ); assert.strictEqual(gitHubApiHostForRemote("git@gitlab.com:a/b.git"), null); }); - - it("reads a subdomain of github.com through github.com", () => { - assert.strictEqual( - gitHubApiHostForRemote("ssh://git@ssh.github.com:443/acme/web.git"), - "github.com", - ); - assert.strictEqual(gitHubApiHostForRemote("git@work.github.com:acme/web.git"), "github.com"); - assert.strictEqual( - gitHubApiHostForRemote("https://work.github.com:8443/acme/web.git"), - "github.com", - ); - }); -}); - -describe("resolveGitHubRepository", () => { - it("resolves a checkout behind a github.com subdomain alias to github.com", () => { - const remotes = [ - "origin\tgit@work.github.com:acme/web.git (fetch)", - "origin\tgit@work.github.com:acme/web.git (push)", - ].join("\n"); - const expected = { - host: "github.com", - locator: { host: "github.com", owner: "acme", name: "web" }, - }; - assert.deepStrictEqual( - resolveGitHubRepository({ - remotes, - resolved: "", - hostHint: undefined, - defaultHost: "github.com", - }), - expected, - ); - // The provider context names the alias as the checkout's host. - assert.deepStrictEqual( - resolveGitHubRepository({ - remotes, - resolved: "", - hostHint: "work.github.com", - defaultHost: "github.com", - }), - expected, - ); - }); }); diff --git a/packages/source-control-github/src/server/gitHubRepositoryResolution.ts b/packages/source-control-github/src/server/gitHubRepositoryResolution.ts index 2703fe5347ae..6c1661c34c6e 100644 --- a/packages/source-control-github/src/server/gitHubRepositoryResolution.ts +++ b/packages/source-control-github/src/server/gitHubRepositoryResolution.ts @@ -157,12 +157,7 @@ export function parsePullRequestReference( return { kind: "branch", headSelector: trimmed }; } -/** - * The GitHub host a remote URL is served from, or null for a remote that is not GitHub. An SSH - * alias (`git@github-work:owner/repo`) names no API host of its own; it is read through - * `github.com`, which is what such an alias almost always stands for (issue #6198). So is a - * subdomain of github.com, such as `ssh.github.com` or an alias like `work.github.com`. - */ +/** Dotless GitHub SSH aliases and github.com subdomains use github.com for API requests. */ export function gitHubApiHostForRemote(remoteUrl: string): string | null { const provider = detectSourceControlProviderFromRemoteUrl(remoteUrl); if (provider === null) return null; @@ -175,7 +170,6 @@ export function gitHubApiHostForRemote(remoteUrl: string): string | null { return provider.kind === "github" ? host : null; } -/** A caller's host hint, read the way a remote's host is: an alias is not an API host. */ export function apiHostForHint(host: string): string { const normalized = host.toLowerCase(); return !normalized.includes(".") && normalized.includes("github") From c3b363aa961875ec1892889e8bb21fc8ea65dc6b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 19 Oct 2026 07:02:06 +0000 Subject: [PATCH 4/4] fix(github): preserve marked subdomain remote precedence --- .../GitHubSourceControlProvider.test.ts | 28 +++++++++++++++++++ .../src/server/gitHubRepositoryResolution.ts | 7 +++-- 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts index 2fad87e28ec4..b1dd6c3ea3e6 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts @@ -293,6 +293,34 @@ describe("GitHubSourceControlProvider repository resolution", () => { }).pipe(Effect.provide(layer)); }); + it.effect("prefers a marked GitHub subdomain origin over upstream", () => { + const targets: string[] = []; + const { layer } = harness({ + remotes: remotesOutput( + ["origin", "git@work.github.com:me/web.git"], + ["upstream", "git@github.com:acme/web.git"], + ), + resolved: "remote.origin.gh-resolved base", + api: { + rest: (input) => + Effect.sync(() => { + targets.push(`${input.host} ${input.path}`); + return restResponse({ + full_name: "me/web", + html_url: "https://github.com/me/web", + ssh_url: "git@github.com:me/web.git", + default_branch: "main", + }); + }), + }, + }); + return Effect.gen(function* () { + const provider = yield* GitHubSourceControlProvider.make; + assert.strictEqual(yield* provider.getDefaultBranch({ cwd: "/repo" }), "main"); + assert.deepStrictEqual(targets, ["github.com repos/me/web"]); + }).pipe(Effect.provide(layer), Effect.provideService(HostProcess.Environment, {})); + }); + it.effect("reads an SSH alias remote through github.com", () => { const hosts: string[] = []; const { layer } = harness({ diff --git a/packages/source-control-github/src/server/gitHubRepositoryResolution.ts b/packages/source-control-github/src/server/gitHubRepositoryResolution.ts index 6c1661c34c6e..a2327da42bbf 100644 --- a/packages/source-control-github/src/server/gitHubRepositoryResolution.ts +++ b/packages/source-control-github/src/server/gitHubRepositoryResolution.ts @@ -26,8 +26,8 @@ export function parseFetchRemotes( * remote `gh repo set-default` marked, else the first of upstream, github, origin (in any case), else * the only remote. * - * 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. + * Null whenever gh might weigh the remotes differently: a remote on another API host or under a + * dotless SSH alias, more than one mark, or several remotes with none of those names. */ export function selectGitHubBaseRepository(input: { readonly remotes: string; @@ -38,7 +38,8 @@ export function selectGitHubBaseRepository(input: { const repositories = new Map(); for (const remote of parseFetchRemotes(input.remotes)) { const [remoteHost, owner, name, ...rest] = normalizeGitRemoteUrl(remote.url).split("/"); - if (remoteHost !== host || !owner || !name || rest.length > 0) return null; + if (!remoteHost || gitHubApiHostOf(remoteHost) !== host || !owner || !name || rest.length > 0) + return null; repositories.set(remote.name, { owner, name }); } const marks = input.resolved