authorgravatar for 71508487+JackatDJL@users.noreply.github.comJackatDJL <71508487+JackatDJL@users.noreply.github.com> 2026-08-15 14:26:45+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-15 12:26:45+00:00
log7ce4194707e2a883ccd6a44fd2e07eba08f5369a
tree5d3a3a5e817182faddd694a7246987d93e328bbc
parentdc0ff8f13eaa9753fdf0de60203c9e819f1cee27
signature Signed by PGP key B5690EEEBB952194

fix: detect SSH remotes with non-git user prefixes (e.g. gitlab@) (#3649)


6 files changed, 72 insertions(+), 16 deletions(-)

apps/server/src/git/GitManager.ts+2-2
...@@ -42,6 +42,7 @@ import {...@@ -42,6 +42,7 @@ import {
42} from "@t3tools/shared/git";42} from "@t3tools/shared/git";
43import {43import {
44 getChangeRequestTerminologyForKind,44 getChangeRequestTerminologyForKind,
45 isSshRemoteUrl,
45 type ChangeRequestTerminology,46 type ChangeRequestTerminology,
46} from "@t3tools/shared/sourceControl";47} from "@t3tools/shared/sourceControl";
4748
...@@ -574,8 +575,7 @@ function toResolvedPullRequest(pr: {...@@ -574,8 +575,7 @@ function toResolvedPullRequest(pr: {
574575
575function shouldPreferSshRemote(url: string | null): boolean {576function shouldPreferSshRemote(url: string | null): boolean {
576 if (!url) return false;577 if (!url) return false;
577 const trimmed = url.trim();578 return isSshRemoteUrl(url);
578 return trimmed.startsWith("git@") || trimmed.startsWith("ssh://");
579}579}
580580
581function toPullRequestHeadRemoteInfo(pr: {581function toPullRequestHeadRemoteInfo(pr: {
apps/server/src/sourceControl/BitbucketApi.ts+9-6
...@@ -14,7 +14,10 @@ import {...@@ -14,7 +14,10 @@ import {
14} from "@t3tools/contracts";14} from "@t3tools/contracts";
15import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";15import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
16import { sanitizeBranchFragment } from "@t3tools/shared/git";16import { sanitizeBranchFragment } from "@t3tools/shared/git";
17import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";17import {
18 detectSourceControlProviderFromRemoteUrl,
19 isSshRemoteUrl,
20} from "@t3tools/shared/sourceControl";
1821
19import {22import {
20 BitbucketPullRequestListSchema,23 BitbucketPullRequestListSchema,
...@@ -448,9 +451,9 @@ function requireRepositoryLocator(...@@ -448,9 +451,9 @@ function requireRepositoryLocator(
448451
449function parseBitbucketRemoteUrl(remoteUrl: string): BitbucketRepositoryLocator | null {452function parseBitbucketRemoteUrl(remoteUrl: string): BitbucketRepositoryLocator | null {
450 const trimmed = remoteUrl.trim();453 const trimmed = remoteUrl.trim();
451 if (trimmed.startsWith("git@")) {454 const scpMatch = /^[a-zA-Z0-9._-]+@[^:/]+:(.+)$/.exec(trimmed);
452 const pathStart = trimmed.indexOf(":");455 if (scpMatch?.[1]) {
453 return pathStart < 0 ? null : parseBitbucketRepositorySlug(trimmed.slice(pathStart + 1));456 return parseBitbucketRepositorySlug(scpMatch[1]);
454 }457 }
455458
456 try {459 try {
...@@ -494,8 +497,8 @@ function defaultChangeRequestTargetBranch(input: {...@@ -494,8 +497,8 @@ function defaultChangeRequestTargetBranch(input: {
494}497}
495498
496function shouldPreferSshRemote(originRemoteUrl: string | null): boolean {499function shouldPreferSshRemote(originRemoteUrl: string | null): boolean {
497 const trimmed = originRemoteUrl?.trim() ?? "";500 if (!originRemoteUrl) return false;
498 return trimmed.startsWith("git@") || trimmed.startsWith("ssh://");501 return isSshRemoteUrl(originRemoteUrl);
499}502}
500503
501function selectCloneUrl(input: {504function selectCloneUrl(input: {
packages/shared/src/git.test.ts+9
...@@ -40,6 +40,15 @@ describe("normalizeGitRemoteUrl", () => {...@@ -40,6 +40,15 @@ describe("normalizeGitRemoteUrl", () => {
40 "gitlab.company.com/team/project",40 "gitlab.company.com/team/project",
41 );41 );
42 });42 });
43
44 it("normalizes SCP-like remotes with non-git SSH users", () => {
45 expect(normalizeGitRemoteUrl("gitlab@gitlab.example.com:group/project.git")).toBe(
46 "gitlab.example.com/group/project",
47 );
48 expect(normalizeGitRemoteUrl("deploy@bitbucket.org:workspace/repo.git")).toBe(
49 "bitbucket.org/workspace/repo",
50 );
51 });
43});52});
4453
45describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => {54describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => {
packages/shared/src/git.ts+3-1
...@@ -133,7 +133,9 @@ export function normalizeGitRemoteUrl(value: string): string {...@@ -133,7 +133,9 @@ export function normalizeGitRemoteUrl(value: string): string {
133 }133 }
134 }134 }
135135
136 const scpStyleHostAndPath = /^git@([^:/\s]+)[:/]([^/\s]+(?:\/[^/\s]+)+)$/i.exec(normalized);136 const scpStyleHostAndPath = /^[a-zA-Z0-9._-]+@([^:/\s]+):([^/\s]+(?:\/[^/\s]+)+)$/i.exec(
137 normalized,
138 );
137 if (scpStyleHostAndPath?.[1] && scpStyleHostAndPath[2]) {139 if (scpStyleHostAndPath?.[1] && scpStyleHostAndPath[2]) {
138 return `${scpStyleHostAndPath[1]}/${scpStyleHostAndPath[2]}`;140 return `${scpStyleHostAndPath[1]}/${scpStyleHostAndPath[2]}`;
139 }141 }
packages/shared/src/sourceControl.test.ts+39
...@@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test";...@@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test";
3import {3import {
4 detectSourceControlProviderFromRemoteUrl,4 detectSourceControlProviderFromRemoteUrl,
5 getChangeRequestTerminologyForKind,5 getChangeRequestTerminologyForKind,
6 isSshRemoteUrl,
6 resolveChangeRequestPresentation,7 resolveChangeRequestPresentation,
7} from "./sourceControl.ts";8} from "./sourceControl.ts";
89
...@@ -120,4 +121,42 @@ describe("detectSourceControlProviderFromRemoteUrl", () => {...@@ -120,4 +121,42 @@ describe("detectSourceControlProviderFromRemoteUrl", () => {
120 )?.kind,121 )?.kind,
121 ).toBe("unknown");122 ).toBe("unknown");
122 });123 });
124
125 it("detects SSH remotes with non-git SSH users (e.g. gitlab@, deploy@)", () => {
126 expect(
127 detectSourceControlProviderFromRemoteUrl("gitlab@gitlab.example.com:group/project.git")?.kind,
128 ).toBe("gitlab");
129 expect(
130 detectSourceControlProviderFromRemoteUrl("gitlab@gitlab.example.com:group/project.git")
131 ?.baseUrl,
132 ).toBe("https://gitlab.example.com");
133 expect(detectSourceControlProviderFromRemoteUrl("deploy@github.com:owner/repo.git")?.kind).toBe(
134 "github",
135 );
136 expect(
137 detectSourceControlProviderFromRemoteUrl("git@bitbucket.org:workspace/repo.git")?.kind,
138 ).toBe("bitbucket");
139 });
140});
141
142describe("isSshRemoteUrl", () => {
143 it("recognises SCP-like SSH URLs with any SSH user prefix", () => {
144 expect(isSshRemoteUrl("git@github.com:owner/repo.git")).toBe(true);
145 expect(isSshRemoteUrl("gitlab@gitlab.example.com:group/project.git")).toBe(true);
146 expect(isSshRemoteUrl("deploy@bitbucket.org:workspace/repo.git")).toBe(true);
147 });
148
149 it("recognises ssh:// URLs with any case", () => {
150 expect(isSshRemoteUrl("ssh://git@gitlab.example.com/group/project.git")).toBe(true);
151 expect(isSshRemoteUrl("ssh://git@gitlab.example.com:22/group/project.git")).toBe(true);
152 expect(isSshRemoteUrl("SSH://git@gitlab.example.com/group/project.git")).toBe(true);
153 expect(isSshRemoteUrl("SsH://git@gitlab.example.com/group/project.git")).toBe(true);
154 });
155
156 it("returns false for HTTPS, local paths, and SCP-like paths without a colon", () => {
157 expect(isSshRemoteUrl("https://gitlab.example.com/group/project.git")).toBe(false);
158 expect(isSshRemoteUrl("/home/user/repos/project")).toBe(false);
159 expect(isSshRemoteUrl("")).toBe(false);
160 expect(isSshRemoteUrl("deploy@github.com/project/repo")).toBe(false);
161 });
123});162});
packages/shared/src/sourceControl.ts+10-7
...@@ -133,19 +133,22 @@ export function getChangeRequestTerminologyForKind(...@@ -133,19 +133,22 @@ export function getChangeRequestTerminologyForKind(
133 };133 };
134}134}
135135
136const SCP_SSH_REMOTE_PATTERN = /^[a-zA-Z0-9._-]+@([^:/]+):/;
137
138export function isSshRemoteUrl(remoteUrl: string): boolean {
139 const trimmed = remoteUrl.trim();
140 return SCP_SSH_REMOTE_PATTERN.test(trimmed) || trimmed.toLowerCase().startsWith("ssh://");
141}
142
136function parseRemoteHost(remoteUrl: string): string | null {143function parseRemoteHost(remoteUrl: string): string | null {
137 const trimmed = remoteUrl.trim();144 const trimmed = remoteUrl.trim();
138 if (trimmed.length === 0) {145 if (trimmed.length === 0) {
139 return null;146 return null;
140 }147 }
141148
142 if (trimmed.startsWith("git@")) {149 const scpMatch = SCP_SSH_REMOTE_PATTERN.exec(trimmed);
143 const hostWithPath = trimmed.slice("git@".length);150 if (scpMatch?.[1]) {
144 const separatorIndex = hostWithPath.search(/[:/]/);151 return scpMatch[1].toLowerCase();
145 if (separatorIndex <= 0) {
146 return null;
147 }
148 return hostWithPath.slice(0, separatorIndex).toLowerCase();
149 }152 }
150153
151 try {154 try {