diff --git a/packages/app/src/projects/project-settings-target.test.ts b/packages/app/src/projects/project-settings-target.test.ts index fcc9717e3d..c2ddc87b67 100644 --- a/packages/app/src/projects/project-settings-target.test.ts +++ b/packages/app/src/projects/project-settings-target.test.ts @@ -33,12 +33,39 @@ describe("project settings target", () => { }; const routeKey = resolveProjectSettingsRouteKey(changedProject); - expect(routeKey).toBe(`host:host-b:project:${legacyKey}`); + expect(routeKey).toBe(`host:host-b:project:${encodeURIComponent(legacyKey)}`); expect(findProjectSettingsTarget([unchangedProject, changedProject], routeKey)).toBe( changedProject, ); }); + it("frames opaque host and project IDs without collisions", () => { + const first = resolveHostProjectSettingsRouteKey({ + serverId: "a", + projectId: "b:project:c", + }); + const second = resolveHostProjectSettingsRouteKey({ + serverId: "a:project:b", + projectId: "c", + }); + + expect(first).not.toBe(second); + }); + + it("prefers an online host for a generic grouped settings route", () => { + const groupedProject = { + projectKey: "remote:github.com/acme/app", + hosts: [ + { serverId: "host-a", projectId: "project-a", isOnline: false }, + { serverId: "host-b", projectId: "project-b", isOnline: true }, + ], + }; + + expect(resolveProjectSettingsRouteKey(groupedProject)).toBe( + resolveHostProjectSettingsRouteKey(groupedProject.hosts[1]), + ); + }); + it("preserves the host identified by a grouped settings route", () => { const groupedProject = { projectKey: "remote:github.com/acme/app", diff --git a/packages/app/src/projects/project-settings-target.ts b/packages/app/src/projects/project-settings-target.ts index c3b5ba7d06..d0c09805ec 100644 --- a/packages/app/src/projects/project-settings-target.ts +++ b/packages/app/src/projects/project-settings-target.ts @@ -1,6 +1,6 @@ interface ProjectSettingsTarget { projectKey: string; - hosts: ReadonlyArray<{ serverId: string; projectId?: string }>; + hosts: ReadonlyArray<{ serverId: string; projectId?: string; isOnline?: boolean }>; } export function resolveHostProjectSettingsRouteKey(host: { @@ -9,10 +9,15 @@ export function resolveHostProjectSettingsRouteKey(host: { }): string | null { const projectId = host.projectId?.trim(); if (!projectId) return null; - return `host:${host.serverId}:project:${projectId}`; + return `host:${encodeURIComponent(host.serverId)}:project:${encodeURIComponent(projectId)}`; } export function resolveProjectSettingsRouteKey(project: ProjectSettingsTarget): string { + for (const host of project.hosts) { + if (!host.isOnline) continue; + const hostLocalKey = resolveHostProjectSettingsRouteKey(host); + if (hostLocalKey) return hostLocalKey; + } for (const host of project.hosts) { const hostLocalKey = resolveHostProjectSettingsRouteKey(host); if (hostLocalKey) return hostLocalKey; diff --git a/packages/server/src/server/project-group-key.test.ts b/packages/server/src/server/project-group-key.test.ts index b27d619b43..56c80dc5dd 100644 --- a/packages/server/src/server/project-group-key.test.ts +++ b/packages/server/src/server/project-group-key.test.ts @@ -72,6 +72,22 @@ describe("deriveProjectGroupKey", () => { expect(derive("git@github.com:acme/foo.git")).toBe(derive("https://github.com/acme/foo")); }); + test.each(["ssh://git@github.com/acme/foo.git", "ssh://git@ssh.github.com:443/acme/foo.git"])( + "normalizes known forge SSH URLs across remote forms: %s", + (remoteUrl) => { + const rootPath = path.resolve("repo"); + const derive = (value: string) => + deriveProjectGroupKey({ + rootPath, + remoteUrl: value, + worktreeRoot: rootPath, + mainRepoRoot: null, + }); + + expect(derive(remoteUrl)).toBe(derive("git@github.com:acme/foo.git")); + }, + ); + test("accepts an SCP-style remote without a username", () => { const rootPath = path.resolve("repo"); diff --git a/packages/server/src/server/project-group-key.ts b/packages/server/src/server/project-group-key.ts index a4b6d3447b..c78357d266 100644 --- a/packages/server/src/server/project-group-key.ts +++ b/packages/server/src/server/project-group-key.ts @@ -5,6 +5,13 @@ import { getRealpathAwareRelativePath } from "../utils/path.js"; const CLOUD_FORGE_HOSTS = new Set( FORGE_DEFINITIONS.flatMap((forge) => forge.cloudHosts ?? []).map((host) => host.toLowerCase()), ); +const CANONICAL_CLOUD_FORGE_HOSTS = new Map( + FORGE_DEFINITIONS.flatMap((forge) => { + const hosts = forge.cloudHosts ?? []; + const canonicalHost = hosts[0]?.toLowerCase(); + return canonicalHost ? hosts.map((host) => [host.toLowerCase(), canonicalHost] as const) : []; + }), +); const DEFAULT_REMOTE_PORTS: Partial> = { "git:": "9418", "git+ssh:": "22", @@ -82,8 +89,9 @@ function parseScpRemote(remoteUrl: string): RemoteLocation | null { const preserveLeadingSlash = remotePath.startsWith("/"); const user = match.groups?.user; + const normalizedHost = normalizeCloudForgeHost(host); return { - host, + host: normalizedHost, path: remotePath, relativePathUser: user && user !== "git" && !preserveLeadingSlash ? user : null, preserveLeadingSlash, @@ -95,8 +103,10 @@ function parseScpRemote(remoteUrl: string): RemoteLocation | null { function parseUrlRemote(remoteUrl: string): RemoteLocation | null { try { const parsed = new URL(remoteUrl); - const host = deriveRemoteHost(parsed); - const preserveLeadingSlash = ["git+ssh:", "ssh:", "ssh+git:"].includes(parsed.protocol); + const isSsh = ["git+ssh:", "ssh:", "ssh+git:"].includes(parsed.protocol); + const forgeHost = deriveCanonicalUrlForgeHost(parsed, isSsh); + const host = forgeHost ?? deriveRemoteHost(parsed); + const preserveLeadingSlash = isSsh && !forgeHost; let remotePath = parsed.pathname || null; if (remotePath && !preserveLeadingSlash) remotePath = remotePath.replace(/^\/+/, ""); if (!host || !remotePath) return null; @@ -106,15 +116,27 @@ function parseUrlRemote(remoteUrl: string): RemoteLocation | null { relativePathUser: null, preserveLeadingSlash, decodePercentEncoding: true, - stripDotGitSuffix: - (!parsed.port || parsed.port === DEFAULT_REMOTE_PORTS[parsed.protocol]) && - CLOUD_FORGE_HOSTS.has(parsed.hostname.toLowerCase()), + stripDotGitSuffix: forgeHost !== null, }; } catch { return null; } } +function normalizeCloudForgeHost(host: string): string { + return CANONICAL_CLOUD_FORGE_HOSTS.get(host.toLowerCase()) ?? host; +} + +function deriveCanonicalUrlForgeHost(remoteUrl: URL, isSsh: boolean): string | null { + const hostname = remoteUrl.hostname.toLowerCase(); + const canonicalHost = CANONICAL_CLOUD_FORGE_HOSTS.get(hostname); + if (!canonicalHost) return null; + const usesDefaultPort = + !remoteUrl.port || remoteUrl.port === DEFAULT_REMOTE_PORTS[remoteUrl.protocol]; + const usesGithubSshAliasPort = isSsh && hostname === "ssh.github.com" && remoteUrl.port === "443"; + return usesDefaultPort || usesGithubSshAliasPort ? canonicalHost : null; +} + function normalizeRemotePath( remotePath: string, preserveLeadingSlash: boolean,