From 536dc179add4c08b27b331475cf051bf225243f1 Mon Sep 17 00:00:00 2001 From: JackatDJL <71508487+JackatDJL@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:52:11 +0200 Subject: [PATCH 1/2] fix: detect SSH remotes with non-git user prefixes (e.g. gitlab@) parseRemoteHost only handled the git@ prefix when parsing SCP-like SSH remote URLs. Enterprise GitLab instances commonly use gitlab@ as the SSH user, causing host detection to fail and the provider to stay unknown. Replace startsWith("git@") with a regex matching any SCP-like SSH syntax (user@host:path). Extract a shared isSshRemoteUrl helper and use it in GitManager and BitbucketApi to fix the same pattern there. Closes #3648 --- apps/server/src/git/GitManager.ts | 4 +- apps/server/src/sourceControl/BitbucketApi.ts | 12 +++--- packages/shared/src/sourceControl.test.ts | 42 +++++++++++++++++++ packages/shared/src/sourceControl.ts | 17 ++++---- 4 files changed, 60 insertions(+), 15 deletions(-) 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/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 { From 8242b99285b07ad7a1b20b9a616086c27ac07a7f Mon Sep 17 00:00:00 2001 From: JackatDJL <71508487+JackatDJL@users.noreply.github.com> Date: Thu, 2 Jul 2026 14:17:19 +0200 Subject: [PATCH 2/2] fix: align normalizeGitRemoteUrl SCP pattern with parseRemoteHost normalizeGitRemoteUrl in packages/shared/src/git.ts still used the old git@ prefix and [:/] separator, inconsistent with the fixed parseRemoteHost which now requires : and accepts any SSH user prefix. - Require : as SCP separator (git@host:path, not git@host/path) - Generalize git@ to [a-zA-Z0-9._-]+@ for gitlab@, deploy@, etc. Addresses Cursor Bugbot review comment on PR #3649. --- packages/shared/src/git.test.ts | 9 +++++++++ packages/shared/src/git.ts | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) 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]}`; }