diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index f1fb03e7e45..accca80889d 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -38,6 +38,7 @@ import { } from "@t3tools/shared/git"; import { getChangeRequestTerminologyForKind, + isSshRemoteUrl, type ChangeRequestTerminology, } from "@t3tools/shared/sourceControl"; @@ -498,8 +499,7 @@ function toResolvedPullRequest(pr: { function shouldPreferSshRemote(url: string | null): boolean { if (!url) return false; - const trimmed = url.trim(); - return trimmed.startsWith("git@") || trimmed.startsWith("ssh://"); + return isSshRemoteUrl(url); } function toPullRequestHeadRemoteInfo(pr: { diff --git a/apps/server/src/sourceControl/BitbucketApi.ts b/apps/server/src/sourceControl/BitbucketApi.ts index f7d7f6671a4..6af271c896f 100644 --- a/apps/server/src/sourceControl/BitbucketApi.ts +++ b/apps/server/src/sourceControl/BitbucketApi.ts @@ -14,7 +14,7 @@ import { } from "@t3tools/contracts"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import { sanitizeBranchFragment } from "@t3tools/shared/git"; -import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; +import { detectSourceControlProviderFromRemoteUrl, isSshRemoteUrl } from "@t3tools/shared/sourceControl"; import { BitbucketPullRequestListSchema, @@ -360,9 +360,9 @@ function requireRepositoryLocator( function parseBitbucketRemoteUrl(remoteUrl: string): BitbucketRepositoryLocator | null { const trimmed = remoteUrl.trim(); - if (trimmed.startsWith("git@")) { - const pathStart = trimmed.indexOf(":"); - return pathStart < 0 ? null : parseBitbucketRepositorySlug(trimmed.slice(pathStart + 1)); + const scpMatch = /^[a-zA-Z0-9._-]+@[^:/]+:(.+)$/.exec(trimmed); + if (scpMatch?.[1]) { + return parseBitbucketRepositorySlug(scpMatch[1]); } try { @@ -406,8 +406,8 @@ function defaultChangeRequestTargetBranch(input: { } function shouldPreferSshRemote(originRemoteUrl: string | null): boolean { - const trimmed = originRemoteUrl?.trim() ?? ""; - return trimmed.startsWith("git@") || trimmed.startsWith("ssh://"); + if (!originRemoteUrl) return false; + return isSshRemoteUrl(originRemoteUrl); } function selectCloneUrl(input: { diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 80578e262f9..524c9220d3d 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -40,6 +40,15 @@ describe("normalizeGitRemoteUrl", () => { "gitlab.company.com/team/project", ); }); + + it("normalizes SCP-like remotes with non-git SSH users", () => { + expect( + normalizeGitRemoteUrl("gitlab@gitlab.example.com:group/project.git"), + ).toBe("gitlab.example.com/group/project"); + expect( + normalizeGitRemoteUrl("deploy@bitbucket.org:workspace/repo.git"), + ).toBe("bitbucket.org/workspace/repo"); + }); }); describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => { diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index ae50b148835..6f2bfacd863 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -122,7 +122,7 @@ export function normalizeGitRemoteUrl(value: string): string { } } - const scpStyleHostAndPath = /^git@([^:/\s]+)[:/]([^/\s]+(?:\/[^/\s]+)+)$/i.exec(normalized); + const scpStyleHostAndPath = /^[a-zA-Z0-9._-]+@([^:/\s]+):([^/\s]+(?:\/[^/\s]+)+)$/i.exec(normalized); if (scpStyleHostAndPath?.[1] && scpStyleHostAndPath[2]) { return `${scpStyleHostAndPath[1]}/${scpStyleHostAndPath[2]}`; } diff --git a/packages/shared/src/sourceControl.test.ts b/packages/shared/src/sourceControl.test.ts index 368e8387ee6..67503e88853 100644 --- a/packages/shared/src/sourceControl.test.ts +++ b/packages/shared/src/sourceControl.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { detectSourceControlProviderFromRemoteUrl, getChangeRequestTerminologyForKind, + isSshRemoteUrl, resolveChangeRequestPresentation, } from "./sourceControl.ts"; @@ -75,4 +76,45 @@ describe("detectSourceControlProviderFromRemoteUrl", () => { baseUrl: "https://self-hosted.example.test:8443", }); }); + + it("detects SSH remotes with non-git SSH users (e.g. gitlab@, deploy@)", () => { + expect( + detectSourceControlProviderFromRemoteUrl( + "gitlab@gitlab.example.com:group/project.git", + )?.kind, + ).toBe("gitlab"); + expect( + detectSourceControlProviderFromRemoteUrl( + "gitlab@gitlab.example.com:group/project.git", + )?.baseUrl, + ).toBe("https://gitlab.example.com"); + expect( + detectSourceControlProviderFromRemoteUrl("deploy@github.com:owner/repo.git")?.kind, + ).toBe("github"); + expect( + detectSourceControlProviderFromRemoteUrl("git@bitbucket.org:workspace/repo.git")?.kind, + ).toBe("bitbucket"); + }); +}); + +describe("isSshRemoteUrl", () => { + it("recognises SCP-like SSH URLs with any SSH user prefix", () => { + expect(isSshRemoteUrl("git@github.com:owner/repo.git")).toBe(true); + expect(isSshRemoteUrl("gitlab@gitlab.example.com:group/project.git")).toBe(true); + expect(isSshRemoteUrl("deploy@bitbucket.org:workspace/repo.git")).toBe(true); + }); + + it("recognises ssh:// URLs with any case", () => { + expect(isSshRemoteUrl("ssh://git@gitlab.example.com/group/project.git")).toBe(true); + expect(isSshRemoteUrl("ssh://git@gitlab.example.com:22/group/project.git")).toBe(true); + expect(isSshRemoteUrl("SSH://git@gitlab.example.com/group/project.git")).toBe(true); + expect(isSshRemoteUrl("SsH://git@gitlab.example.com/group/project.git")).toBe(true); + }); + + it("returns false for HTTPS, local paths, and SCP-like paths without a colon", () => { + expect(isSshRemoteUrl("https://gitlab.example.com/group/project.git")).toBe(false); + expect(isSshRemoteUrl("/home/user/repos/project")).toBe(false); + expect(isSshRemoteUrl("")).toBe(false); + expect(isSshRemoteUrl("deploy@github.com/project/repo")).toBe(false); + }); }); diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index 15a98dc7355..fc2ff53a941 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -133,19 +133,22 @@ export function getChangeRequestTerminologyForKind( }; } +const SCP_SSH_REMOTE_PATTERN = /^[a-zA-Z0-9._-]+@([^:/]+):/; + +export function isSshRemoteUrl(remoteUrl: string): boolean { + const trimmed = remoteUrl.trim(); + return SCP_SSH_REMOTE_PATTERN.test(trimmed) || trimmed.toLowerCase().startsWith("ssh://"); +} + function parseRemoteHost(remoteUrl: string): string | null { const trimmed = remoteUrl.trim(); if (trimmed.length === 0) { return null; } - if (trimmed.startsWith("git@")) { - const hostWithPath = trimmed.slice("git@".length); - const separatorIndex = hostWithPath.search(/[:/]/); - if (separatorIndex <= 0) { - return null; - } - return hostWithPath.slice(0, separatorIndex).toLowerCase(); + const scpMatch = SCP_SSH_REMOTE_PATTERN.exec(trimmed); + if (scpMatch?.[1]) { + return scpMatch[1].toLowerCase(); } try {