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
15 changes: 1 addition & 14 deletions apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
detectSourceControlProviderFromGitRemoteUrl,
mergeGitStatusParts,
normalizeGitRemoteUrl,
parseGitHubRepositoryNameWithOwnerFromRemoteUrl,
resolveAutoFeatureBranchName,
sanitizeBranchFragment,
sanitizeFeatureBranchName,
Expand Down Expand Up @@ -235,20 +236,6 @@ function resolvePullRequestWorktreeLocalBranchName(
return `t3code/pr-${pullRequest.number}/${suffix}`;
}

function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null {
const trimmed = url?.trim() ?? "";
if (trimmed.length === 0) {
return null;
}

const match =
/^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec(
trimmed,
);
const repositoryNameWithOwner = match?.[1]?.trim() ?? "";
return repositoryNameWithOwner.length > 0 ? repositoryNameWithOwner : null;
}

function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null {
const trimmed = nameWithOwner?.trim() ?? "";
if (trimmed.length === 0) {
Expand Down
32 changes: 32 additions & 0 deletions packages/shared/src/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,35 @@ describe("applyGitStatusStreamEvent", () => {
});
});
});

describe("SSH host aliases", () => {
it("canonicalizes multi-account SSH alias hosts to the real provider host", () => {
expect(normalizeGitRemoteUrl("git@github.com-work:T3Tools/T3Code.git")).toBe(
"github.com/t3tools/t3code",
);
expect(normalizeGitRemoteUrl("ssh://git@github.com-work/T3Tools/T3Code.git")).toBe(
"github.com/t3tools/t3code",
);
expect(normalizeGitRemoteUrl("git@gitlab.com-personal:group/project.git")).toBe(
"gitlab.com/group/project",
);
});

it("leaves non-SSH remotes and unrelated hosts alone", () => {
expect(normalizeGitRemoteUrl("https://github.com-work/T3Tools/T3Code.git")).toBe(
"github.com-work/t3tools/t3code",
);
expect(normalizeGitRemoteUrl("git@github.company.com:T3Tools/T3Code.git")).toBe(
"github.company.com/t3tools/t3code",
);
});

it("parses owner and repo through a GitHub SSH alias", () => {
expect(
parseGitHubRepositoryNameWithOwnerFromRemoteUrl("git@github.com-work:owner/repo.git"),
).toBe("owner/repo");
expect(
parseGitHubRepositoryNameWithOwnerFromRemoteUrl("ssh://git@github.com-work/owner/repo"),
).toBe("owner/repo");
});
});
14 changes: 10 additions & 4 deletions packages/shared/src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import type {
} from "@t3tools/contracts";
import * as Arr from "effect/Array";
import * as Result from "effect/Result";
import { detectSourceControlProviderFromRemoteUrl } from "./sourceControl.ts";
import {
canonicalizeSshRemoteHostname,
detectSourceControlProviderFromRemoteUrl,
} 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 +129,9 @@ 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:" ? canonicalizeSshRemoteHostname(url.hostname) : url.hostname;
return `${hostname}/${repositoryPath}`;
}
} catch {
return normalized;
Expand All @@ -137,7 +142,7 @@ export function normalizeGitRemoteUrl(value: string): string {
normalized,
);
if (scpStyleHostAndPath?.[1] && scpStyleHostAndPath[2]) {
return `${scpStyleHostAndPath[1]}/${scpStyleHostAndPath[2]}`;
return `${canonicalizeSshRemoteHostname(scpStyleHostAndPath[1])}/${scpStyleHostAndPath[2]}`;
}

return normalized;
Expand All @@ -152,8 +157,9 @@ export function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | nu
return null;
}

// SSH shapes tolerate a multi-account alias suffix such as `github.com-work`.
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
27 changes: 27 additions & 0 deletions packages/shared/src/sourceControl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,3 +160,30 @@ describe("isSshRemoteUrl", () => {
expect(isSshRemoteUrl("deploy@github.com/project/repo")).toBe(false);
});
});

describe("SSH host aliases", () => {
it("resolves multi-account SSH alias hosts to the real provider", () => {
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/group/repo.git"),
).toEqual({
kind: "gitlab",
name: "GitLab",
baseUrl: "https://gitlab.com",
});
});

it("keeps literal hosts for non-SSH remotes that merely resemble aliases", () => {
expect(
detectSourceControlProviderFromRemoteUrl("https://github.com-work/owner/repo.git"),
).toEqual({
kind: "github",
name: "GitHub Self-Hosted",
baseUrl: "https://github.com-work",
});
});
});
21 changes: 18 additions & 3 deletions packages/shared/src/sourceControl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,15 @@ function toBaseUrl(host: string): string {
return `https://${host}`;
}

// Multi-account ~/.ssh/config setups follow the documented convention of
// suffixing the real host in the alias, e.g. `Host github.com-work`. The alias
// only exists for SSH, so HTTPS remotes keep their literal host.
const SSH_ALIAS_HOSTNAME_PATTERN = /^(github\.com|gitlab\.com|bitbucket\.org)-[^./]+$/;

export function canonicalizeSshRemoteHostname(hostname: string): string {
return SSH_ALIAS_HOSTNAME_PATTERN.exec(hostname)?.[1] ?? hostname;
}

function hasDnsLabel(host: string, label: string): boolean {
return host.split(".").includes(label);
}
Expand Down Expand Up @@ -201,11 +210,17 @@ function isBitbucketHost(host: string): boolean {
export function detectSourceControlProviderFromRemoteUrl(
remoteUrl: string,
): SourceControlProviderInfo | null {
const host = parseRemoteHost(remoteUrl);
if (!host) {
const parsedHost = parseRemoteHost(remoteUrl);
if (!parsedHost) {
return null;
}
const hostname = parseHostName(host);
const parsedHostname = parseHostName(parsedHost);
const hostname = isSshRemoteUrl(remoteUrl)
? canonicalizeSshRemoteHostname(parsedHostname)
: parsedHostname;
// Stripping the alias suffix drops any port, but SSH ports never belong in
// the https base URL anyway; unaliased hosts keep their port as before.
const host = hostname === parsedHostname ? parsedHost : hostname;

if (isGitHubHost(hostname)) {
return {
Expand Down
Loading