diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 393a8fd05592..bc18ee71d78e 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -36,6 +36,7 @@ import { detectSourceControlProviderFromGitRemoteUrl, mergeGitStatusParts, normalizeGitRemoteUrl, + parseGitHubRepositoryNameWithOwnerFromRemoteUrl, resolveAutoFeatureBranchName, sanitizeBranchFragment, sanitizeFeatureBranchName, @@ -235,20 +236,6 @@ function resolvePullRequestWorktreeLocalBranchName( return `t3code/pr-${pullRequest.number}/${suffix}`; } -function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null { - const trimmed = url?.trim() ?? ""; - if (trimmed.length === 0) { - return null; - } - - const match = - /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec( - trimmed, - ); - const repositoryNameWithOwner = match?.[1]?.trim() ?? ""; - return repositoryNameWithOwner.length > 0 ? repositoryNameWithOwner : null; -} - function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null { const trimmed = nameWithOwner?.trim() ?? ""; if (trimmed.length === 0) { diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 8dea20f0b423..1036f76d8faa 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -172,3 +172,35 @@ describe("applyGitStatusStreamEvent", () => { }); }); }); + +describe("SSH host aliases", () => { + it("canonicalizes multi-account SSH alias hosts to the real provider host", () => { + expect(normalizeGitRemoteUrl("git@github.com-work:T3Tools/T3Code.git")).toBe( + "github.com/t3tools/t3code", + ); + expect(normalizeGitRemoteUrl("ssh://git@github.com-work/T3Tools/T3Code.git")).toBe( + "github.com/t3tools/t3code", + ); + expect(normalizeGitRemoteUrl("git@gitlab.com-personal:group/project.git")).toBe( + "gitlab.com/group/project", + ); + }); + + it("leaves non-SSH remotes and unrelated hosts alone", () => { + expect(normalizeGitRemoteUrl("https://github.com-work/T3Tools/T3Code.git")).toBe( + "github.com-work/t3tools/t3code", + ); + expect(normalizeGitRemoteUrl("git@github.company.com:T3Tools/T3Code.git")).toBe( + "github.company.com/t3tools/t3code", + ); + }); + + it("parses owner and repo through a GitHub SSH alias", () => { + expect( + parseGitHubRepositoryNameWithOwnerFromRemoteUrl("git@github.com-work:owner/repo.git"), + ).toBe("owner/repo"); + expect( + parseGitHubRepositoryNameWithOwnerFromRemoteUrl("ssh://git@github.com-work/owner/repo"), + ).toBe("owner/repo"); + }); +}); diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 7c088970d583..3831809f3d8e 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -8,7 +8,10 @@ import type { } from "@t3tools/contracts"; import * as Arr from "effect/Array"; import * as Result from "effect/Result"; -import { detectSourceControlProviderFromRemoteUrl } from "./sourceControl.ts"; +import { + canonicalizeSshRemoteHostname, + detectSourceControlProviderFromRemoteUrl, +} from "./sourceControl.ts"; export const WORKTREE_BRANCH_PREFIX = "t3code"; // Canonical form is `t3code/<8 hex>`. Older mobile builds generated `t3code/` @@ -126,7 +129,9 @@ 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:" ? canonicalizeSshRemoteHostname(url.hostname) : url.hostname; + return `${hostname}/${repositoryPath}`; } } catch { return normalized; @@ -137,7 +142,7 @@ export function normalizeGitRemoteUrl(value: string): string { normalized, ); if (scpStyleHostAndPath?.[1] && scpStyleHostAndPath[2]) { - return `${scpStyleHostAndPath[1]}/${scpStyleHostAndPath[2]}`; + return `${canonicalizeSshRemoteHostname(scpStyleHostAndPath[1])}/${scpStyleHostAndPath[2]}`; } return normalized; @@ -152,8 +157,9 @@ export function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | nu return null; } + // SSH shapes tolerate a multi-account alias suffix such as `github.com-work`. 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..14c5efbf1958 100644 --- a/packages/shared/src/sourceControl.test.ts +++ b/packages/shared/src/sourceControl.test.ts @@ -160,3 +160,30 @@ describe("isSshRemoteUrl", () => { expect(isSshRemoteUrl("deploy@github.com/project/repo")).toBe(false); }); }); + +describe("SSH host aliases", () => { + it("resolves multi-account SSH alias hosts to the real provider", () => { + 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/group/repo.git"), + ).toEqual({ + kind: "gitlab", + name: "GitLab", + baseUrl: "https://gitlab.com", + }); + }); + + it("keeps literal hosts for non-SSH remotes that merely resemble aliases", () => { + expect( + detectSourceControlProviderFromRemoteUrl("https://github.com-work/owner/repo.git"), + ).toEqual({ + kind: "github", + name: "GitHub Self-Hosted", + baseUrl: "https://github.com-work", + }); + }); +}); diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index df88de595a3f..194bdb0a0d28 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -170,6 +170,15 @@ function toBaseUrl(host: string): string { return `https://${host}`; } +// Multi-account ~/.ssh/config setups follow the documented convention of +// suffixing the real host in the alias, e.g. `Host github.com-work`. The alias +// only exists for SSH, so HTTPS remotes keep their literal host. +const SSH_ALIAS_HOSTNAME_PATTERN = /^(github\.com|gitlab\.com|bitbucket\.org)-[^./]+$/; + +export function canonicalizeSshRemoteHostname(hostname: string): string { + return SSH_ALIAS_HOSTNAME_PATTERN.exec(hostname)?.[1] ?? hostname; +} + function hasDnsLabel(host: string, label: string): boolean { return host.split(".").includes(label); } @@ -201,11 +210,17 @@ function isBitbucketHost(host: string): boolean { export function detectSourceControlProviderFromRemoteUrl( remoteUrl: string, ): SourceControlProviderInfo | null { - const host = parseRemoteHost(remoteUrl); - if (!host) { + const parsedHost = parseRemoteHost(remoteUrl); + if (!parsedHost) { return null; } - const hostname = parseHostName(host); + const parsedHostname = parseHostName(parsedHost); + const hostname = isSshRemoteUrl(remoteUrl) + ? canonicalizeSshRemoteHostname(parsedHostname) + : parsedHostname; + // Stripping the alias suffix drops any port, but SSH ports never belong in + // the https base URL anyway; unaliased hosts keep their port as before. + const host = hostname === parsedHostname ? parsedHost : hostname; if (isGitHubHost(hostname)) { return {