From 31caa9f64981b313f6dfc7d2c20005578d97a091 Mon Sep 17 00:00:00 2001 From: Mnigos Date: Mon, 12 Oct 2026 18:26:09 +0200 Subject: [PATCH 1/2] fix(server): publishing with the auto protocol follows the gh CLI's git_protocol New projects published to GitHub always got an SSH origin: the new-project flow sends no protocol and the server treated auto as SSH, so users whose GitHub access only works over HTTPS ended up with an empty repository they could not push to. Under auto, the GitHub provider now reads gh config get -h git_protocol and picks the matching URL for publish and server-side clone. If gh is missing or the lookup fails it stays on SSH. An explicit protocol, like the Git menu's Publish Repository choice, still wins; other providers are unchanged. --- .../SourceControlRepositoryService.test.ts | 62 +++++++++++++++++++ .../SourceControlRepositoryService.ts | 25 +++++++- .../src/server/SourceControlProvider.ts | 8 +++ .../GitHubSourceControlProvider.test.ts | 38 +++++++++++- .../src/server/GitHubSourceControlProvider.ts | 25 ++++++++ 5 files changed, 155 insertions(+), 3 deletions(-) diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 509fd0582eff..c9689e781420 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -703,3 +703,65 @@ 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: "https", protocol: "auto", expected: CLONE_URLS.url }, + { cli: "ssh", protocol: undefined, expected: CLONE_URLS.sshUrl }, + { cli: null, protocol: undefined, expected: CLONE_URLS.sshUrl }, + { cli: "https", protocol: "ssh", expected: CLONE_URLS.sshUrl }, + { cli: "ssh", protocol: "https", expected: CLONE_URLS.url }, + ] 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 c273535bd69f..cb9ac10ef15c 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -28,6 +28,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); @@ -163,6 +164,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; @@ -269,7 +285,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; } @@ -414,7 +435,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 44d4cf4e95c5..1f4a081d87a4 100644 --- a/packages/source-control-core/src/server/SourceControlProvider.ts +++ b/packages/source-control-core/src/server/SourceControlProvider.ts @@ -183,6 +183,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 73475840ae22..2db4520a3566 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"; @@ -561,6 +561,42 @@ 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.succeed(processOutput("ssh\n")), "ssh"], + [Effect.succeed(processOutput("git\n")), null], + [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( + Layer.mergeAll( + TestSourceControlHost.layer({ + process: { + run: (input) => { + calls.push(`${input.command} ${input.args.join(" ")}`); + return output; + }, + }, + }), + Layer.mock(GitHubApi.GitHubApi)({}), + NodeServices.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 c52b3c2dd2c8..97ca29ab52ab 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.ts @@ -479,6 +479,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* GitHubApi.GitHubApi; const sourceControlHost = yield* SourceControlHost.SourceControlHost; @@ -1078,6 +1086,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({ From a81ee8a2e79962e46f31652505ff74325a57d516 Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Mon, 19 Oct 2026 09:46:38 -0700 Subject: [PATCH 2/2] test(server): trim auto-protocol tests to the behaviors users notice The provider test mocked GitHubApi.GitHubApi, which main removed; it now uses the file's harness with a run override. Mirrored protocol mappings are dropped so one case per behavior remains. Co-Authored-By: Claude Fable 5.1 --- .../SourceControlRepositoryService.test.ts | 5 +-- .../GitHubSourceControlProvider.test.ts | 36 +++++++++---------- 2 files changed, 17 insertions(+), 24 deletions(-) diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 0b36c86cf829..ab223030c8bd 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -760,11 +760,8 @@ it.effect("publishes over the protocol the provider's CLI prefers unless one was Effect.gen(function* () { const cases = [ { cli: "https", protocol: undefined, expected: CLONE_URLS.url }, - { cli: "https", protocol: "auto", expected: CLONE_URLS.url }, - { cli: "ssh", protocol: undefined, expected: CLONE_URLS.sshUrl }, - { cli: null, protocol: undefined, expected: CLONE_URLS.sshUrl }, + { cli: null, protocol: "auto", expected: CLONE_URLS.sshUrl }, { cli: "https", protocol: "ssh", expected: CLONE_URLS.sshUrl }, - { cli: "ssh", protocol: "https", expected: CLONE_URLS.url }, ] as const; for (const { cli, protocol, expected } of cases) { const asked: Array<{ cwd: string; url: string }> = []; diff --git a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts index ac2b49f71913..2abb02994bd6 100644 --- a/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts +++ b/packages/source-control-github/src/server/GitHubSourceControlProvider.test.ts @@ -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 = @@ -703,8 +706,6 @@ it.effect("prefers the protocol gh's git_protocol sets for the repository's host const missing = new VcsProcessSpawnError({ operation: "", command: "gh", cwd: "", cause: 0 }); const cases = [ [Effect.succeed(processOutput("https\n")), "https"], - [Effect.succeed(processOutput("ssh\n")), "ssh"], - [Effect.succeed(processOutput("git\n")), null], [Effect.fail(missing), null], ] as const; for (const [output, expected] of cases) { @@ -713,18 +714,13 @@ it.effect("prefers the protocol gh's git_protocol sets for the repository's host return yield* gh.preferredCloneProtocol!({ cwd: "/repo", url: "https://ghe.test/a/b" }); }).pipe( Effect.provide( - Layer.mergeAll( - TestSourceControlHost.layer({ - process: { - run: (input) => { - calls.push(`${input.command} ${input.args.join(" ")}`); - return output; - }, - }, - }), - Layer.mock(GitHubApi.GitHubApi)({}), - NodeServices.layer, - ), + harness({ + remotes: "", + run: (input) => { + calls.push(`${input.command} ${input.args.join(" ")}`); + return output; + }, + }).layer, ), ); assert.strictEqual(protocol, expected);