Skip to content
Merged
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
19 changes: 19 additions & 0 deletions apps/server/src/project/RepositoryIdentityResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,25 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
}).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
);

it.effect("identifies the host of an SSH remote that leaves its user to ssh config", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const cwd = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-repository-identity-userless-remote-test-",
});

yield* git(cwd, ["init"]);
yield* git(cwd, ["remote", "add", "origin", "github.com:T3Tools/t3code.git"]);

const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
const identity = yield* resolver.resolve(cwd);

expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code");
expect(identity?.provider).toBe("github");
expect(identity?.displayName).toBe("T3Tools/t3code");
}).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
);

it.effect("resolves the fetch remote of a partial clone", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,24 @@ it.effect("leaves a remote no integration claims unknown, with its server's addr
}),
);

it.effect.each([
["github.com:team/project.git", "github"],
["gitlab.com:team/project.git", "gitlab"],
["bitbucket.org:team/project.git", "bitbucket"],
["code.example.test:team/project.git", "unknown"],
] as const)(
"reads the host of an SSH remote that leaves its user to ssh config: %s",
([url, expected]) =>
Effect.gen(function* () {
const registry = yield* makeRegistry({ remotes: [{ name: "origin", url }] });

const handle = yield* registry.resolveHandle({ cwd: "/repo" });

assert.strictEqual(handle.provider.kind, kind(expected));
assert.strictEqual(handle.context?.remoteUrl, url);
}),
);

it.effect("refines the caller-selected remote instead of choosing another configured remote", () =>
Effect.gen(function* () {
const registry = yield* makeRegistry({
Expand Down
12 changes: 12 additions & 0 deletions apps/server/src/sourceControl/sourceControlDefinitions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,15 @@ it("runs a driver for every host the server and clients read, in their order", (
sourceControlDefinitions.definitions,
);
});

it.each([
"C:/repos/project",
"C:\\repos\\project",
"/srv/git/repo.git",
"./team:group/repo",
"file:///srv/git/repo.git",
"hg::team/repo",
"codecommit::us-east-1://repo",
])("finds no host in %s, which git does not read as SSH", (remoteUrl) => {
expect(sourceControlDefinitions.recognizeRemote(remoteUrl)).toBeNull();
});
25 changes: 25 additions & 0 deletions packages/shared/src/sourceControl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vite-plus/test";

import { isSshRemoteUrl } from "./sourceControl.ts";

describe("isSshRemoteUrl", () => {
it.each([
"git@github.com:owner/repo.git",
"ssh://git@github.com/owner/repo.git",
"github.com:owner/repo.git",
"github-work:owner/repo.git",
])("reads %s as SSH", (remoteUrl) => {
expect(isSshRemoteUrl(remoteUrl)).toBe(true);
});

it.each([
"https://github.com/owner/repo.git",
"C:/repos/repo",
"/srv/git/repo.git",
"./team:group/repo",
"file:///srv/git/repo.git",
"hg::owner/repo",
])("does not read %s as SSH", (remoteUrl) => {
expect(isSshRemoteUrl(remoteUrl)).toBe(false);
});
});
14 changes: 13 additions & 1 deletion packages/shared/src/sourceControl.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
const SCP_SSH_REMOTE_PATTERN = /^[a-zA-Z0-9._-]+@([^:/]+):/;
// Git reads `host:path` as SSH too, leaving the user to ssh config. A single letter is a Windows
// drive and `::` names a remote helper.
const USERLESS_SCP_SSH_REMOTE_PATTERN = /^([^@:/\\\s]{2,}):(?!:)\/?[^/\s]+(?:\/[^/\s]+)+\/?$/u;

/** Null unless the remote is `host:path`, with no user. */
export function userlessScpRemoteHost(remoteUrl: string): string | null {
return USERLESS_SCP_SSH_REMOTE_PATTERN.exec(remoteUrl.trim())?.[1] ?? null;
}

export function isSshRemoteUrl(remoteUrl: string): boolean {
const trimmed = remoteUrl.trim();
return SCP_SSH_REMOTE_PATTERN.test(trimmed) || trimmed.toLowerCase().startsWith("ssh://");
return (
SCP_SSH_REMOTE_PATTERN.test(trimmed) ||
trimmed.toLowerCase().startsWith("ssh://") ||
userlessScpRemoteHost(trimmed) !== null
);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { assert, it } from "@effect/vitest";

import { transportSafeSourceControlErrorValue } from "./SourceControlProvider.ts";
import {
repositoryPathFromRemoteUrl,
transportSafeSourceControlErrorValue,
} from "./SourceControlProvider.ts";

it("removes URL credentials, query parameters, and fragments from error transport values", () => {
assert.strictEqual(
Expand All @@ -10,3 +13,13 @@ it("removes URL credentials, query parameters, and fragments from error transpor
"https://example.test/org/repo/pull/42",
);
});

it.each([
["git@github.com:T3Tools/t3code.git", "T3Tools/t3code"],
["github.com:T3Tools/t3code.git", "T3Tools/t3code"],
["gitlab.com:T3Tools/platform/t3code", "T3Tools/platform/t3code"],
["https://github.com/T3Tools/t3code.git", "T3Tools/t3code"],
["C:/repos/t3code", null],
])("reads the repository path of %s", (remoteUrl, expected) => {
assert.strictEqual(repositoryPathFromRemoteUrl(remoteUrl), expected);
});
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ export function repositoryPathFromRemoteUrl(url: string | null): string | null {
}

const match =
/^(?:[^@/\s]+@[^:/\s]+:|(?:ssh|https?|git):\/\/[^/]+\/)((?:[^/\s]+\/)+[^/\s]+?)(?:\.git)?\/?$/iu.exec(
/^(?:[^@/\s]+@[^:/\s]+:|[^@:/\\\s]{2,}:(?!:)|(?:ssh|https?|git):\/\/[^/]+\/)((?:[^/\s]+\/)+[^/\s]+?)(?:\.git)?\/?$/iu.exec(
trimmed,
);
const path = match?.[1]?.trim() ?? "";
Expand Down
3 changes: 2 additions & 1 deletion packages/source-control-core/src/shared/definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
TrimmedString,
} from "@t3tools/contracts";
import { normalizeGitRemoteUrl, parseGitRemoteRepository } from "@t3tools/shared/git";
import { userlessScpRemoteHost } from "@t3tools/shared/sourceControl";
import * as Effect from "effect/Effect";
import * as Schema from "effect/Schema";
import * as SchemaTransformation from "effect/SchemaTransformation";
Expand Down Expand Up @@ -497,7 +498,7 @@ export function parseGitRemoteHost(remoteUrl: string): GitRemoteHost | null {
const trimmed = remoteUrl.trim();
if (trimmed.length === 0) return null;
let host: string;
const scpHost = SCP_REMOTE.exec(trimmed)?.[1];
const scpHost = SCP_REMOTE.exec(trimmed)?.[1] ?? userlessScpRemoteHost(trimmed);
if (scpHost) {
host = scpHost.toLowerCase();
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
import { randomUuidV4 } from "@t3tools/provider-core/server/randomUuid";
import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
import { isSshRemoteUrl } from "@t3tools/shared/sourceControl";
import { parsePullRequestUrl } from "../shared/definition.ts";

import * as GitCafeApi from "./GitCafeApi.ts";
Expand Down Expand Up @@ -165,7 +166,7 @@ const cloneUrls = (target: Target) => ({
});

const isSshRemote = (remoteUrl: string | undefined) =>
remoteUrl !== undefined && (/^ssh:\/\//iu.test(remoteUrl) || /^[^/:]+@[^/:]+:/u.test(remoteUrl));
remoteUrl !== undefined && isSshRemoteUrl(remoteUrl);

export const make = Effect.gen(function* () {
const api = yield* GitCafeApi.GitCafeApi;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ describe("gitCafeHosts", () => {
["https://staging.git.cafe/team/project.git/", "staging.git.cafe"],
["ssh@git.cafe:team/project.git", "git.cafe"],
["ssh://git@git.cafe/team/project.git", "git.cafe"],
["git.cafe:team/project.git", "git.cafe"],
])("reads the host and repository of %s", (url, host) => {
assert.deepStrictEqual(GitCafeHosts.parseGitCafeRemote(url), {
host,
Expand Down
2 changes: 1 addition & 1 deletion packages/source-control-gitcafe/src/server/gitCafeHosts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u;
const REMOTE_PATTERNS = [
/^https?:\/\/(?:[^@/]+@)?([^/:]+)(?::\d+)?\/(.+)$/iu,
/^ssh:\/\/[^@/]+@([^/:]+)(?::\d+)?\/(.+)$/iu,
/^[^@/:]+@([^/:]+):(.+)$/u,
/^(?:[^@/:]+@)?([^/:]+):(.+)$/u,
];

export function gitCafeRepositoryPath(path: string): string | null {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,7 @@ function refineUnknownGitLabRemote(input: SourceControlUnknownRemoteRefinementIn

/** The caller refines only an identity no host recognised, so its remote is on an unknown host. */
function unknownRemoteProvider(remoteUrl: string) {
// Remote host parsing does not recognize userless SCP remotes.
const scpHost = /^([^@:/\\\s]{2,}):\/?[^/\s]+(?:\/[^/\s]+)+$/u.exec(remoteUrl.trim())?.[1];
const remote = parseGitRemoteHost(scpHost ? `ssh://${scpHost}` : remoteUrl);
const remote = parseGitRemoteHost(remoteUrl);
return remote === null ? null : unknownHostProvider(remote);
}

Expand Down
Loading