Skip to content
Closed
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
2 changes: 1 addition & 1 deletion apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): st
}

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() ?? "";
Expand Down
21 changes: 21 additions & 0 deletions packages/shared/src/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,18 @@ describe("normalizeGitRemoteUrl", () => {
);
});

it("resolves SSH config host aliases to the host they extend", () => {
expect(normalizeGitRemoteUrl("git@github.com-work:T3Tools/T3Code.git")).toBe(
"github.com/t3tools/t3code",
);
expect(normalizeGitRemoteUrl("ssh://git@github.com-work/T3Tools/T3Code")).toBe(
"github.com/t3tools/t3code",
);
expect(normalizeGitRemoteUrl("https://github.com-work/T3Tools/T3Code")).toBe(
"github.com-work/t3tools/t3code",
);
});

it("preserves nested group paths for providers like GitLab", () => {
expect(normalizeGitRemoteUrl("git@gitlab.com:T3Tools/platform/T3Code.git")).toBe(
"gitlab.com/t3tools/platform/t3code",
Expand Down Expand Up @@ -52,6 +64,15 @@ describe("normalizeGitRemoteUrl", () => {
});

describe("parseGitHubRepositoryNameWithOwnerFromRemoteUrl", () => {
it("accepts SSH config host aliases on the GitHub host", () => {
expect(
parseGitHubRepositoryNameWithOwnerFromRemoteUrl("git@github.com-work:owner/repo.git"),
).toBe("owner/repo");
expect(
parseGitHubRepositoryNameWithOwnerFromRemoteUrl("ssh://git@github.com-work/owner/repo.git"),
).toBe("owner/repo");
});

it("extracts the owner and repository from common GitHub remote shapes", () => {
expect(
parseGitHubRepositoryNameWithOwnerFromRemoteUrl("git@github.com:T3Tools/T3Code.git"),
Expand Down
9 changes: 5 additions & 4 deletions packages/shared/src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type {
} from "@t3tools/contracts";
import * as Arr from "effect/Array";
import * as Result from "effect/Result";
import { detectSourceControlProviderFromRemoteUrl } from "./sourceControl.ts";
import { detectSourceControlProviderFromRemoteUrl, stripSshHostAlias } from "./sourceControl.ts";

export const WORKTREE_BRANCH_PREFIX = "t3code";
// Canonical form is `t3code/<8 hex>`. Older mobile builds generated `t3code/<uuid>`
Expand Down Expand Up @@ -126,7 +126,8 @@ 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:" ? stripSshHostAlias(url.hostname) : url.hostname;
return `${hostname}/${repositoryPath}`;
}
} catch {
return normalized;
Expand All @@ -137,7 +138,7 @@ export function normalizeGitRemoteUrl(value: string): string {
normalized,
);
if (scpStyleHostAndPath?.[1] && scpStyleHostAndPath[2]) {
return `${scpStyleHostAndPath[1]}/${scpStyleHostAndPath[2]}`;
return `${stripSshHostAlias(scpStyleHostAndPath[1])}/${scpStyleHostAndPath[2]}`;
}

return normalized;
Expand All @@ -153,7 +154,7 @@ export function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | nu
}

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() ?? "";
Expand Down
38 changes: 38 additions & 0 deletions packages/shared/src/sourceControl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
getChangeRequestTerminologyForKind,
isSshRemoteUrl,
resolveChangeRequestPresentation,
stripSshHostAlias,
} from "./sourceControl.ts";

describe("source control presentation", () => {
Expand Down Expand Up @@ -137,6 +138,43 @@ describe("detectSourceControlProviderFromRemoteUrl", () => {
detectSourceControlProviderFromRemoteUrl("git@bitbucket.org:workspace/repo.git")?.kind,
).toBe("bitbucket");
});

it("resolves SSH config host aliases to the host they extend", () => {
// `Host github.com-work` in ~/.ssh/config is the common multi-account setup.
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:2222/team/repo.git"),
).toEqual({
kind: "gitlab",
name: "GitLab",
baseUrl: "https://gitlab.com:2222",
});
expect(
detectSourceControlProviderFromRemoteUrl("git@ssh.dev.azure.com-work:v3/org/project/repo")
?.baseUrl,
).toBe("https://ssh.dev.azure.com");
// HTTPS never consults the SSH config, so its host is taken literally.
expect(
detectSourceControlProviderFromRemoteUrl("https://github.com-work/owner/repo.git")?.baseUrl,
).toBe("https://github.com-work");
});
});

describe("stripSshHostAlias", () => {
it("drops the alias suffix only behind a hosted provider domain", () => {
expect(stripSshHostAlias("github.com-work")).toBe("github.com");
expect(stripSshHostAlias("bitbucket.org-personal")).toBe("bitbucket.org");
// Self-hosted and internal names are taken as written, hyphens included.
expect(stripSshHostAlias("github.example-corp")).toBe("github.example-corp");
expect(stripSshHostAlias("gitlab.company.com-personal")).toBe("gitlab.company.com-personal");
expect(stripSshHostAlias("gitlab.my-corp.com")).toBe("gitlab.my-corp.com");
expect(stripSshHostAlias("github.com")).toBe("github.com");
expect(stripSshHostAlias("github.community")).toBe("github.community");
});
});

describe("isSshRemoteUrl", () => {
Expand Down
25 changes: 23 additions & 2 deletions packages/shared/src/sourceControl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,22 @@ export function isSshRemoteUrl(remoteUrl: string): boolean {
return SCP_SSH_REMOTE_PATTERN.test(trimmed) || trimmed.toLowerCase().startsWith("ssh://");
}

// The hosted providers whose SSH host commonly carries a `-<alias>` suffix.
const SSH_ALIAS_HOSTS = ["github.com", "gitlab.com", "bitbucket.org", "ssh.dev.azure.com"];

/**
* SSH remotes often name a `~/.ssh/config` alias instead of the real host. The
* common multi-account convention appends the alias to the hosted provider's
* domain, `git@github.com-work:org/repo.git`, so a hyphen right after one of
* those domains marks the alias. Only those domains are recognised: a
* self-hosted name is taken as written, since an internal zone may
* legitimately carry a hyphen there. Only SSH transports consult the SSH
* config, so callers apply this to SSH hosts alone and leave HTTPS untouched.
*/
export function stripSshHostAlias(host: string): string {
return SSH_ALIAS_HOSTS.find((known) => host.startsWith(`${known}-`)) ?? host;
}

function parseRemoteHost(remoteUrl: string): string | null {
const trimmed = remoteUrl.trim();
if (trimmed.length === 0) {
Expand All @@ -148,11 +164,16 @@ function parseRemoteHost(remoteUrl: string): string | null {

const scpMatch = SCP_SSH_REMOTE_PATTERN.exec(trimmed);
if (scpMatch?.[1]) {
return scpMatch[1].toLowerCase();
return stripSshHostAlias(scpMatch[1].toLowerCase());
}

try {
return new URL(trimmed).host.toLowerCase();
const url = new URL(trimmed);
if (url.protocol !== "ssh:") {
return url.host.toLowerCase();
}
const hostname = stripSshHostAlias(url.hostname.toLowerCase());
return url.port ? `${hostname}:${url.port}` : hostname;
} catch {
return null;
}
Expand Down
Loading