From 869edcbf11485e17dcbd66a333afdea72b3d5ffa Mon Sep 17 00:00:00 2001 From: Li Mu Zhi Date: Tue, 28 Jul 2026 04:00:46 +0800 Subject: [PATCH] fix(forge): preserve non-default port in forge web URLs (#2478) "Open in browser" links for a self-hosted forge served on a non-standard port (e.g. Forgejo/Gitea on :60443) dropped the port, producing https://host/owner/repo/... instead of https://host:60443/owner/repo/..., which 404s or hits the wrong service. parseGitRemoteLocation discarded parsed.port (GitRemoteLocation had no port field), and buildForgeBranchTreeUrl / buildForgeBlobUrl rebuilt the origin from the portless host. Preserve the port on GitRemoteLocation and reattach it in the web-URL builders, only for self-hosted http(s) origins (an SSH port isn't the web port; a canonicalized cloud host uses the default port). Host-identity matching (forge detection, cloud-host checks) stays port-agnostic. Co-authored-by: Claude Opus 4.8 --- packages/app/src/git/forge-url.test.ts | 30 ++++++++++++++++++++++++ packages/app/src/git/forge-url.ts | 28 +++++++++++++++------- packages/protocol/src/git-remote.test.ts | 22 +++++++++++++++++ packages/protocol/src/git-remote.ts | 9 ++++++- 4 files changed, 80 insertions(+), 9 deletions(-) diff --git a/packages/app/src/git/forge-url.test.ts b/packages/app/src/git/forge-url.test.ts index aa647998a..46a2dfe6c 100644 --- a/packages/app/src/git/forge-url.test.ts +++ b/packages/app/src/git/forge-url.test.ts @@ -44,6 +44,24 @@ describe("buildForgeBranchTreeUrl", () => { ).toBe("https://codeberg.org/acme/repo/src/branch/main"); }); + it("preserves a non-default port for a self-hosted https remote", () => { + expect( + buildForgeBranchTreeUrl("forgejo", { + remoteUrl: "https://home-git.example.com:60443/team/repo.git", + branch: "master", + }), + ).toBe("https://home-git.example.com:60443/team/repo/src/branch/master"); + }); + + it("omits the port for a self-hosted remote on the default port", () => { + expect( + buildForgeBranchTreeUrl("forgejo", { + remoteUrl: "https://home-git.example.com/team/repo.git", + branch: "master", + }), + ).toBe("https://home-git.example.com/team/repo/src/branch/master"); + }); + it("returns null when the current branch is unavailable", () => { expect( buildForgeBranchTreeUrl("github", { @@ -131,6 +149,18 @@ describe("buildForgeBlobUrl", () => { ).toBe("https://github.acme.internal/team/repo/blob/main/src/index.ts"); }); + it("preserves a non-default port for a self-hosted https remote", () => { + expect( + buildForgeBlobUrl("forgejo", { + remoteUrl: "https://home-git.example.com:60443/team/repo.git", + branch: "master", + path: "src/index.ts", + lineStart: 12, + lineEnd: 20, + }), + ).toBe("https://home-git.example.com:60443/team/repo/src/branch/master/src/index.ts#L12-L20"); + }); + it("canonicalizes the github.com SSH-alias host to the web host", () => { expect( buildForgeBlobUrl("github", { diff --git a/packages/app/src/git/forge-url.ts b/packages/app/src/git/forge-url.ts index 79090d923..a19190d82 100644 --- a/packages/app/src/git/forge-url.ts +++ b/packages/app/src/git/forge-url.ts @@ -29,6 +29,8 @@ export interface ForgeBranchTreeUrlInput { interface ForgeWebLocation { host: string; + /** Non-default port for a self-hosted http(s) origin, or undefined. */ + port?: string; repo: string; } @@ -58,18 +60,28 @@ function resolveForgeWebLocation( if (!location || !isValidRepoPath(location.path)) { return null; } - const cloudHosts = getForgeDefinition(forge)?.cloudHosts; - const webHost = - cloudHosts && cloudHosts.length > 0 && cloudHosts.map(normalizeHost).includes(location.host) - ? normalizeHost(cloudHosts[0]) - : location.host; - return { host: webHost, repo: location.path }; + const cloudHosts = (getForgeDefinition(forge)?.cloudHosts ?? []).map(normalizeHost); + const isCloudHost = cloudHosts.includes(location.host); + const webHost = isCloudHost ? cloudHosts[0] : location.host; + // Carry a non-default port only for a self-hosted http(s) origin (e.g. + // `:60443`): the web UI shares that origin. An SSH/scp remote's port is not the + // web port, and a canonicalized cloud host always serves on the default port. + const port = + !isCloudHost && (location.transport === "http" || location.transport === "https") + ? location.port + : undefined; + return { host: webHost, port, repo: location.path }; } function encodeBranch(branch: string): string { return branch.split("/").map(encodeURIComponent).join("/"); } +/** Host, plus `:port` when the remote pins a non-default port. */ +function forgeAuthority(location: ForgeWebLocation): string { + return location.port ? `${location.host}:${location.port}` : location.host; +} + function normalizeBlobPath(path: string | null | undefined): string | null { const segments: string[] = []; const trimmed = path?.trim().replace(/\\/g, "/").replace(/^\/+/, ""); @@ -102,7 +114,7 @@ export function buildForgeBranchTreeUrl( if (!grammar || !location || !branch || branch === "HEAD") { return null; } - return `https://${location.host}/${location.repo}${grammar.treeInfix}${encodeBranch(branch)}`; + return `https://${forgeAuthority(location)}/${location.repo}${grammar.treeInfix}${encodeBranch(branch)}`; } export function buildForgeBlobUrl(forge: string, input: ForgeBlobUrlInput): string | null { @@ -114,7 +126,7 @@ export function buildForgeBlobUrl(forge: string, input: ForgeBlobUrlInput): stri return null; } const encodedPath = filePath.split("/").map(encodeURIComponent).join("/"); - let url = `https://${location.host}/${location.repo}${grammar.blobInfix}${encodeBranch(branch)}/${encodedPath}`; + let url = `https://${forgeAuthority(location)}/${location.repo}${grammar.blobInfix}${encodeBranch(branch)}/${encodedPath}`; if (input.lineStart && input.lineStart > 0) { url += grammar.lineAnchor(input.lineStart, input.lineEnd); } diff --git a/packages/protocol/src/git-remote.test.ts b/packages/protocol/src/git-remote.test.ts index c098d0340..26d70f3db 100644 --- a/packages/protocol/src/git-remote.test.ts +++ b/packages/protocol/src/git-remote.test.ts @@ -27,3 +27,25 @@ describe("isCompleteGitRemote", () => { } }); }); + +describe("parseGitRemoteLocation port", () => { + it("preserves an explicit non-default port from an https remote", () => { + expect(parseGitRemoteLocation("https://home-git.example.com:60443/team/repo.git")?.port).toBe( + "60443", + ); + }); + + it("preserves a port from a plain http remote", () => { + expect(parseGitRemoteLocation("http://internal.example.com:3000/team/repo.git")?.port).toBe( + "3000", + ); + }); + + it("omits the port for a default-port remote", () => { + expect(parseGitRemoteLocation("https://github.com/acme/repo.git")?.port).toBeUndefined(); + }); + + it("has no port for an scp-form remote", () => { + expect(parseGitRemoteLocation("git@host.example.com:team/repo.git")?.port).toBeUndefined(); + }); +}); diff --git a/packages/protocol/src/git-remote.ts b/packages/protocol/src/git-remote.ts index 30110180b..bc88ba0b7 100644 --- a/packages/protocol/src/git-remote.ts +++ b/packages/protocol/src/git-remote.ts @@ -11,6 +11,13 @@ const TRANSPORT_BY_PROTOCOL: Record = { export interface GitRemoteLocation { transport: "scp" | "ssh" | "http" | "https"; host: string; + /** + * Explicit non-default port from the remote (e.g. a self-hosted forge on + * `:60443`), or undefined for a default-port or scp-form remote. Kept separate + * from `host` so host-identity matching (forge detection, cloud-host checks) + * stays port-agnostic; only consumers that reconstruct a URL (web links) use it. + */ + port?: string; path: string; } @@ -71,7 +78,7 @@ export function parseGitRemoteLocation(remoteUrl: string): GitRemoteLocation | n const normalizedPath = normalizeRemotePath(path); if (!isValidRemoteHost(host) || !normalizedPath) return null; - return { transport, host, path: normalizedPath }; + return { transport, host, port: parsed.port || undefined, path: normalizedPath }; } export function parseGitHubRemoteIdentity(path: string): GitHubRemoteIdentity | null {