diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index cbce130dcc8f..ab223030c8bd 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -755,3 +755,62 @@ it.effect("publish succeeds with status remote_added when the local repo has no ), ); }); + +it.effect("publishes over the protocol the provider's CLI prefers unless one was chosen", () => + Effect.gen(function* () { + const cases = [ + { cli: "https", protocol: undefined, expected: CLONE_URLS.url }, + { cli: null, protocol: "auto", expected: CLONE_URLS.sshUrl }, + { cli: "https", protocol: "ssh", expected: CLONE_URLS.sshUrl }, + ] as const; + for (const { cli, protocol, expected } of cases) { + const asked: Array<{ cwd: string; url: string }> = []; + const provider = makeProvider({ + preferredCloneProtocol: (input) => + Effect.sync(() => { + asked.push(input); + return cli; + }), + }); + const result = yield* Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + return yield* service.publishRepository({ + cwd: "/workspace", + provider: SourceControlProviderKind.make("github"), + repository: "octocat/t3code", + visibility: "private", + ...(protocol === undefined ? {} : { protocol }), + }); + }).pipe(Effect.provide(layer({ provider }))); + + assert.strictEqual(result.remoteUrl, expected); + assert.deepStrictEqual( + asked, + protocol === undefined || protocol === "auto" + ? [{ cwd: "/workspace", url: CLONE_URLS.url }] + : [], + ); + } + }), +); + +it.effect("clones over the protocol the provider's CLI prefers when asked for auto", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-clone-protocol-" }); + const provider = makeProvider({ preferredCloneProtocol: () => Effect.succeed("https") }); + + const prepared = yield* Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + return yield* service.prepareClone({ + provider: SourceControlProviderKind.make("github"), + repository: "octocat/t3code", + destinationPath: path.join(parent, "t3code"), + protocol: "auto", + }); + }).pipe(Effect.provide(layer({ provider }))); + + assert.strictEqual(prepared.cloneUrl, CLONE_URLS.url); + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index fb59349749a3..c542aaae6e46 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -29,6 +29,7 @@ import { } from "../project/gitCloneProgress.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; +import type * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError); @@ -182,6 +183,21 @@ function selectRemoteUrl( } } +/** An explicit protocol wins; `auto` follows the provider's CLI setting, else SSH. */ +function resolveRemoteUrl( + provider: SourceControlProvider.SourceControlProvider["Service"], + cwd: string, + urls: SourceControlRepositoryCloneUrls, + protocol: SourceControlCloneProtocol | undefined, +) { + if ((protocol ?? "auto") !== "auto" || provider.preferredCloneProtocol === undefined) { + return Effect.succeed(selectRemoteUrl(urls, protocol)); + } + return provider + .preferredCloneProtocol({ cwd, url: urls.url }) + .pipe(Effect.map((preferred) => selectRemoteUrl(urls, preferred ?? "ssh"))); +} + /** @public Service construction is part of the canonical Effect module API. */ export const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; @@ -288,7 +304,12 @@ export const make = Effect.gen(function* () { repository: input.repository, cwd: preparedDestination.parentPath, }); - remoteUrl = selectRemoteUrl(repository, input.protocol); + remoteUrl = yield* resolveRemoteUrl( + yield* providers.get(input.provider), + preparedDestination.parentPath, + repository, + input.protocol, + ); provider = input.provider; } @@ -433,7 +454,7 @@ export const make = Effect.gen(function* () { repository: input.repository.trim(), visibility: input.visibility, }); - const remoteUrl = selectRemoteUrl(urls, input.protocol); + const remoteUrl = yield* resolveRemoteUrl(provider, input.cwd, urls, input.protocol); const remoteName = yield* git.ensureRemote({ cwd: input.cwd, preferredName: input.remoteName?.trim() || "origin", diff --git a/packages/source-control-core/src/server/SourceControlProvider.ts b/packages/source-control-core/src/server/SourceControlProvider.ts index e149fcd202a2..ca75cfa4ba79 100644 --- a/packages/source-control-core/src/server/SourceControlProvider.ts +++ b/packages/source-control-core/src/server/SourceControlProvider.ts @@ -162,6 +162,14 @@ export class SourceControlProvider extends Context.Service< readonly repository: string; readonly visibility: SourceControlRepositoryVisibility; }) => Effect.Effect; + /** + * The protocol an `auto` clone or publish of the repository at `url` uses, from the host + * CLI's own setting. Absent, or `null`, means SSH. + */ + readonly preferredCloneProtocol?: (input: { + readonly cwd: string; + readonly url: string; + }) => Effect.Effect<"ssh" | "https" | null>; readonly getDefaultBranch: (input: { readonly cwd: string; readonly context?: SourceControlProviderContext; diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts index d60ae1641f47..2abb02994bd6 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts @@ -1,4 +1,4 @@ -import { SourceControlProviderKind } from "@t3tools/contracts"; +import { SourceControlProviderKind, VcsProcessSpawnError } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; @@ -172,6 +172,7 @@ function harness(input: { readonly localBranches?: ReadonlyArray; readonly resolved?: string; readonly gitFailure?: unknown; + readonly run?: SourceControlHost.SourceControlHost["Service"]["process"]["run"]; }) { const git: Array = []; const graphqlCalls: Array = []; @@ -207,12 +208,14 @@ function harness(input: { const host = TestSourceControlHost.layer({ git: hostGit, process: { - run: (args) => - Effect.succeed( - args.args[0] === "remote" - ? processOutput(input.remotes) - : processOutput(input.resolved ?? "", input.resolved ? 0 : 1), - ), + run: + input.run ?? + ((args) => + Effect.succeed( + args.args[0] === "remote" + ? processOutput(input.remotes) + : processOutput(input.resolved ?? "", input.resolved ? 0 : 1), + )), }, }); const graphql = @@ -697,6 +700,35 @@ describe("GitHubSourceControlProvider writes", () => { }); }); +it.effect("prefers the protocol gh's git_protocol sets for the repository's host", () => + Effect.gen(function* () { + const calls: Array = []; + const missing = new VcsProcessSpawnError({ operation: "", command: "gh", cwd: "", cause: 0 }); + const cases = [ + [Effect.succeed(processOutput("https\n")), "https"], + [Effect.fail(missing), null], + ] as const; + for (const [output, expected] of cases) { + const protocol = yield* Effect.gen(function* () { + const gh = yield* GitHubSourceControlProvider.make; + return yield* gh.preferredCloneProtocol!({ cwd: "/repo", url: "https://ghe.test/a/b" }); + }).pipe( + Effect.provide( + harness({ + remotes: "", + run: (input) => { + calls.push(`${input.command} ${input.args.join(" ")}`); + return output; + }, + }).layer, + ), + ); + assert.strictEqual(protocol, expected); + } + assert.deepStrictEqual(new Set(calls), new Set(["gh config get -h ghe.test git_protocol"])); + }), +); + describe("GitHubSourceControlProvider.checkoutChangeRequest", () => { const repository = (fullName: string, defaultBranch = "main") => restResponse({ diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts index 8c8aa06cd9a0..bdf50ab5f765 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts @@ -448,6 +448,14 @@ export function pullRequestCheckoutBranchName(input: { const contextHost = (context: SourceControlProvider.SourceControlProviderContext | undefined) => context === undefined ? undefined : new URL(context.provider.baseUrl).host; +const isCloneProtocol = Schema.is(Schema.Literals(["ssh", "https"])); + +/** `-h ` for a repository URL gh's per-host config can key on. */ +const hostArgs = (url: string): ReadonlyArray => { + const host = URL.parse(url)?.host; + return host ? ["-h", host] : []; +}; + export const make = Effect.gen(function* () { const api = yield* GitHubRest.GitHubRest; const sourceControlHost = yield* SourceControlHost.SourceControlHost; @@ -1011,6 +1019,23 @@ export const make = Effect.gen(function* () { providerError("createRepository", input.cwd, { repository: input.repository }), ), ), + // gh prints its own default (https) when nothing is set, the protocol `gh repo clone` uses. + preferredCloneProtocol: (input) => + process + .run({ + operation: "GitHubSourceControlProvider.preferredCloneProtocol", + command: "gh", + args: ["config", "get", ...hostArgs(input.url), "git_protocol"], + cwd: input.cwd, + timeoutMs: 5_000, + }) + .pipe( + Effect.map((output) => { + const protocol = output.stdout.trim(); + return isCloneProtocol(protocol) ? protocol : null; + }), + Effect.orElseSucceed(() => null), + ), getDefaultBranch: (input) => Effect.gen(function* () { const locator = yield* resolveRepository({