diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 393a8fd05592..151a3443d584 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -242,7 +242,7 @@ function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): st } const match = - /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec( + /^(?:git@github\.com(?:-[^:/\s]+)?:|ssh:\/\/git@github\.com(?:-[^/\s]+)?\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec( trimmed, ); const repositoryNameWithOwner = match?.[1]?.trim() ?? ""; diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 8dea20f0b423..0ec0225f7b13 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -23,6 +23,18 @@ describe("normalizeGitRemoteUrl", () => { ); }); + it("resolves SSH config host aliases to the host they extend", () => { + expect(normalizeGitRemoteUrl("git@github.com-work:T3Tools/T3Code.git")).toBe( + "github.com/t3tools/t3code", + ); + expect(normalizeGitRemoteUrl("ssh://git@github.com-work/T3Tools/T3Code")).toBe( + "github.com/t3tools/t3code", + ); + expect(normalizeGitRemoteUrl("https://github.com-work/T3Tools/T3Code")).toBe( + "github.com-work/t3tools/t3code", + ); + }); + it("preserves nested group paths for providers like GitLab", () => { expect(normalizeGitRemoteUrl("git@gitlab.com:T3Tools/platform/T3Code.git")).toBe( "gitlab.com/t3tools/platform/t3code", @@ -52,6 +64,15 @@ describe("normalizeGitRemoteUrl", () => { }); describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => { + it("accepts SSH config host aliases on the GitHub host", () => { + expect( + parseGitHubRepositoryNameWithOwnerFromRemoteUrl("git@github.com-work:owner/repo.git"), + ).toBe("owner/repo"); + expect( + parseGitHubRepositoryNameWithOwnerFromRemoteUrl("ssh://git@github.com-work/owner/repo.git"), + ).toBe("owner/repo"); + }); + it("extracts the owner and repository from common GitHub remote shapes", () => { expect( parseGitHubRepositoryNameWithOwnerFromRemoteUrl("git@github.com:T3Tools/T3Code.git"), diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 7c088970d583..565a3bf42b75 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -8,7 +8,7 @@ import type { } from "@t3tools/contracts"; import * as Arr from "effect/Array"; import * as Result from "effect/Result"; -import { detectSourceControlProviderFromRemoteUrl } from "./sourceControl.ts"; +import { detectSourceControlProviderFromRemoteUrl, stripSshHostAlias } from "./sourceControl.ts"; export const WORKTREE_BRANCH_PREFIX = "t3code"; // Canonical form is `t3code/<8 hex>`. Older mobile builds generated `t3code/` @@ -126,7 +126,8 @@ export function normalizeGitRemoteUrl(value: string): string { .filter((segment) => segment.length > 0) .join("/"); if (url.hostname && repositoryPath.includes("/")) { - return `${url.hostname}/${repositoryPath}`; + const hostname = url.protocol === "ssh:" ? stripSshHostAlias(url.hostname) : url.hostname; + return `${hostname}/${repositoryPath}`; } } catch { return normalized; @@ -137,7 +138,7 @@ export function normalizeGitRemoteUrl(value: string): string { normalized, ); if (scpStyleHostAndPath?.[1] && scpStyleHostAndPath[2]) { - return `${scpStyleHostAndPath[1]}/${scpStyleHostAndPath[2]}`; + return `${stripSshHostAlias(scpStyleHostAndPath[1])}/${scpStyleHostAndPath[2]}`; } return normalized; @@ -153,7 +154,7 @@ export function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | nu } const match = - /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec( + /^(?:git@github\.com(?:-[^:/\s]+)?:|ssh:\/\/git@github\.com(?:-[^/\s]+)?\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec( trimmed, ); const repositoryNameWithOwner = match?.[1]?.trim() ?? ""; diff --git a/packages/shared/src/sourceControl.test.ts b/packages/shared/src/sourceControl.test.ts index 86b1ba5912bd..90fa7faf7ed1 100644 --- a/packages/shared/src/sourceControl.test.ts +++ b/packages/shared/src/sourceControl.test.ts @@ -5,6 +5,7 @@ import { getChangeRequestTerminologyForKind, isSshRemoteUrl, resolveChangeRequestPresentation, + stripSshHostAlias, } from "./sourceControl.ts"; describe("source control presentation", () => { @@ -137,6 +138,43 @@ describe("detectSourceControlProviderFromRemoteUrl", () => { detectSourceControlProviderFromRemoteUrl("git@bitbucket.org:workspace/repo.git")?.kind, ).toBe("bitbucket"); }); + + it("resolves SSH config host aliases to the host they extend", () => { + // `Host github.com-work` in ~/.ssh/config is the common multi-account setup. + expect(detectSourceControlProviderFromRemoteUrl("git@github.com-work:owner/repo.git")).toEqual({ + kind: "github", + name: "GitHub", + baseUrl: "https://github.com", + }); + expect( + detectSourceControlProviderFromRemoteUrl("ssh://git@gitlab.com-personal:2222/team/repo.git"), + ).toEqual({ + kind: "gitlab", + name: "GitLab", + baseUrl: "https://gitlab.com:2222", + }); + expect( + detectSourceControlProviderFromRemoteUrl("git@ssh.dev.azure.com-work:v3/org/project/repo") + ?.baseUrl, + ).toBe("https://ssh.dev.azure.com"); + // HTTPS never consults the SSH config, so its host is taken literally. + expect( + detectSourceControlProviderFromRemoteUrl("https://github.com-work/owner/repo.git")?.baseUrl, + ).toBe("https://github.com-work"); + }); +}); + +describe("stripSshHostAlias", () => { + it("drops the alias suffix only behind a hosted provider domain", () => { + expect(stripSshHostAlias("github.com-work")).toBe("github.com"); + expect(stripSshHostAlias("bitbucket.org-personal")).toBe("bitbucket.org"); + // Self-hosted and internal names are taken as written, hyphens included. + expect(stripSshHostAlias("github.example-corp")).toBe("github.example-corp"); + expect(stripSshHostAlias("gitlab.company.com-personal")).toBe("gitlab.company.com-personal"); + expect(stripSshHostAlias("gitlab.my-corp.com")).toBe("gitlab.my-corp.com"); + expect(stripSshHostAlias("github.com")).toBe("github.com"); + expect(stripSshHostAlias("github.community")).toBe("github.community"); + }); }); describe("isSshRemoteUrl", () => { diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index df88de595a3f..d6a3662fa5bb 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -140,6 +140,22 @@ export function isSshRemoteUrl(remoteUrl: string): boolean { return SCP_SSH_REMOTE_PATTERN.test(trimmed) || trimmed.toLowerCase().startsWith("ssh://"); } +// The hosted providers whose SSH host commonly carries a `-` suffix. +const SSH_ALIAS_HOSTS = ["github.com", "gitlab.com", "bitbucket.org", "ssh.dev.azure.com"]; + +/** + * SSH remotes often name a `~/.ssh/config` alias instead of the real host. The + * common multi-account convention appends the alias to the hosted provider's + * domain, `git@github.com-work:org/repo.git`, so a hyphen right after one of + * those domains marks the alias. Only those domains are recognised: a + * self-hosted name is taken as written, since an internal zone may + * legitimately carry a hyphen there. Only SSH transports consult the SSH + * config, so callers apply this to SSH hosts alone and leave HTTPS untouched. + */ +export function stripSshHostAlias(host: string): string { + return SSH_ALIAS_HOSTS.find((known) => host.startsWith(`${known}-`)) ?? host; +} + function parseRemoteHost(remoteUrl: string): string | null { const trimmed = remoteUrl.trim(); if (trimmed.length === 0) { @@ -148,11 +164,16 @@ function parseRemoteHost(remoteUrl: string): string | null { const scpMatch = SCP_SSH_REMOTE_PATTERN.exec(trimmed); if (scpMatch?.[1]) { - return scpMatch[1].toLowerCase(); + return stripSshHostAlias(scpMatch[1].toLowerCase()); } try { - return new URL(trimmed).host.toLowerCase(); + const url = new URL(trimmed); + if (url.protocol !== "ssh:") { + return url.host.toLowerCase(); + } + const hostname = stripSshHostAlias(url.hostname.toLowerCase()); + return url.port ? `${hostname}:${url.port}` : hostname; } catch { return null; }