Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
} from "@t3tools/shared/git";
import {
getChangeRequestTerminologyForKind,
isSshRemoteUrl,
type ChangeRequestTerminology,
} from "@t3tools/shared/sourceControl";

Expand Down Expand Up @@ -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: {
Expand Down
12 changes: 6 additions & 6 deletions apps/server/src/sourceControl/BitbucketApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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: {
Expand Down
9 changes: 9 additions & 0 deletions packages/shared/src/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/shared/src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]}`;
}
Expand Down
42 changes: 42 additions & 0 deletions packages/shared/src/sourceControl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test";
import {
detectSourceControlProviderFromRemoteUrl,
getChangeRequestTerminologyForKind,
isSshRemoteUrl,
resolveChangeRequestPresentation,
} from "./sourceControl.ts";

Expand Down Expand Up @@ -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);
});
});
17 changes: 10 additions & 7 deletions packages/shared/src/sourceControl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Comment thread
JackatDJL marked this conversation as resolved.
}

try {
Expand Down
Loading