From 73a81531d1b018c5f553b713e29f4dc5a3186b18 Mon Sep 17 00:00:00 2001 From: MarcoDotIO Date: Mon, 2 Nov 2026 11:42:16 -0500 Subject: [PATCH] fix(source-control): remotes like github.com:owner/repo reach their host Git reads `host:path` as SSH and takes the user from ssh config, but only `user@host:path` was read as naming a host. A checkout on such a remote got no provider, so pull request sync skipped it and every provider call failed as an unknown host. Co-Authored-By: Claude Opus 5.5 --- .../RepositoryIdentityResolver.test.ts | 19 ++++++++++++++ .../SourceControlProviderRegistry.test.ts | 18 +++++++++++++ .../sourceControlDefinitions.test.ts | 12 +++++++++ packages/shared/src/sourceControl.test.ts | 25 +++++++++++++++++++ packages/shared/src/sourceControl.ts | 14 ++++++++++- .../src/server/SourceControlProvider.test.ts | 15 ++++++++++- .../src/server/SourceControlProvider.ts | 2 +- .../src/shared/definition.ts | 3 ++- .../server/GitCafeSourceControlProvider.ts | 3 ++- .../src/server/gitCafeHosts.test.ts | 1 + .../src/server/gitCafeHosts.ts | 2 +- .../src/server/GitLabSourceControlProvider.ts | 4 +-- 12 files changed, 109 insertions(+), 9 deletions(-) create mode 100644 packages/shared/src/sourceControl.test.ts diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index 0cef7d96bbdfe..4604da894a86c 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -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; diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts index 05b6a1a8d0e42..c532d93e81ad9 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts @@ -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({ diff --git a/apps/server/src/sourceControl/sourceControlDefinitions.test.ts b/apps/server/src/sourceControl/sourceControlDefinitions.test.ts index e21efd8173a73..36996bdf3f370 100644 --- a/apps/server/src/sourceControl/sourceControlDefinitions.test.ts +++ b/apps/server/src/sourceControl/sourceControlDefinitions.test.ts @@ -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(); +}); diff --git a/packages/shared/src/sourceControl.test.ts b/packages/shared/src/sourceControl.test.ts new file mode 100644 index 0000000000000..4e892996d34c4 --- /dev/null +++ b/packages/shared/src/sourceControl.test.ts @@ -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); + }); +}); diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index 31fba958776e4..6be2669a1930d 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -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 + ); } diff --git a/packages/source-control-core/src/server/SourceControlProvider.test.ts b/packages/source-control-core/src/server/SourceControlProvider.test.ts index 8a6b434d78403..4e20f8872c4b4 100644 --- a/packages/source-control-core/src/server/SourceControlProvider.test.ts +++ b/packages/source-control-core/src/server/SourceControlProvider.test.ts @@ -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( @@ -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); +}); diff --git a/packages/source-control-core/src/server/SourceControlProvider.ts b/packages/source-control-core/src/server/SourceControlProvider.ts index c128b683ad731..a54e47921065c 100644 --- a/packages/source-control-core/src/server/SourceControlProvider.ts +++ b/packages/source-control-core/src/server/SourceControlProvider.ts @@ -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() ?? ""; diff --git a/packages/source-control-core/src/shared/definition.ts b/packages/source-control-core/src/shared/definition.ts index 30e88377590f8..d746a850690d7 100644 --- a/packages/source-control-core/src/shared/definition.ts +++ b/packages/source-control-core/src/shared/definition.ts @@ -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"; @@ -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 { diff --git a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts index e02ebd0b45527..6c7b413541d67 100644 --- a/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts +++ b/packages/source-control-gitcafe/src/server/GitCafeSourceControlProvider.ts @@ -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"; @@ -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; diff --git a/packages/source-control-gitcafe/src/server/gitCafeHosts.test.ts b/packages/source-control-gitcafe/src/server/gitCafeHosts.test.ts index 77fa0ed78609d..5fd9a25127b08 100644 --- a/packages/source-control-gitcafe/src/server/gitCafeHosts.test.ts +++ b/packages/source-control-gitcafe/src/server/gitCafeHosts.test.ts @@ -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, diff --git a/packages/source-control-gitcafe/src/server/gitCafeHosts.ts b/packages/source-control-gitcafe/src/server/gitCafeHosts.ts index 33f3aa3204b72..9f73a6d794239 100644 --- a/packages/source-control-gitcafe/src/server/gitCafeHosts.ts +++ b/packages/source-control-gitcafe/src/server/gitCafeHosts.ts @@ -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 { diff --git a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts index beeb6b38af1d0..9190d518045bb 100644 --- a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts +++ b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts @@ -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); }