diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 5df4862b409..f96d18e3a33 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -210,18 +210,20 @@ describe("GitHubCli.layer", () => { it.effect("reads repository clone URLs", () => Effect.gen(function* () { - mockRun.mockReturnValueOnce( - Effect.succeed( - processOutput( - // @effect-diagnostics-next-line preferSchemaOverJson:off - JSON.stringify({ - nameWithOwner: "octocat/codething-mvp", - url: "https://github.com/octocat/codething-mvp", - sshUrl: "git@github.com:octocat/codething-mvp.git", - }), + mockRun + .mockReturnValueOnce( + Effect.succeed( + processOutput( + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ + nameWithOwner: "octocat/codething-mvp", + url: "https://github.com/octocat/codething-mvp", + sshUrl: "git@github.com:octocat/codething-mvp.git", + }), + ), ), - ), - ); + ) + .mockReturnValueOnce(Effect.succeed(processOutput("https\n"))); const gh = yield* GitHubCli.GitHubCli; const result = yield* gh.getRepositoryCloneUrls({ @@ -233,6 +235,14 @@ describe("GitHubCli.layer", () => { nameWithOwner: "octocat/codething-mvp", url: "https://github.com/octocat/codething-mvp", sshUrl: "git@github.com:octocat/codething-mvp.git", + preferredProtocol: "https", + }); + expect(mockRun).toHaveBeenNthCalledWith(2, { + operation: "GitHubCli.execute", + command: "gh", + args: ["config", "get", "git_protocol", "--host", "github.com"], + cwd: "/repo", + timeoutMs: 30_000, }); }).pipe(Effect.provide(layer)), ); diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index bf3f27378b5..17d4da82540 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -194,6 +194,7 @@ export interface GitHubRepositoryCloneUrls { readonly nameWithOwner: string; readonly url: string; readonly sshUrl: string; + readonly preferredProtocol?: "ssh" | "https"; } export class GitHubCli extends Context.Service< @@ -266,6 +267,22 @@ function normalizeRepositoryCloneUrls( }; } +function repositoryHost(url: string): string { + try { + return new URL(url).hostname; + } catch { + return "github.com"; + } +} + +function parseConfiguredGitProtocol(stdout: string): "ssh" | "https" | undefined { + const protocol = stdout.trim().toLowerCase(); + if (protocol === "ssh" || protocol === "https") { + return protocol; + } + return undefined; +} + /** * `gh repo create` prints the canonical URL of the new repository on stdout * (e.g. `https://github.com/owner/repo`). Reading it back here avoids a @@ -409,6 +426,20 @@ export const make = Effect.gen(function* () { ), ), Effect.map(normalizeRepositoryCloneUrls), + Effect.flatMap((urls) => + execute({ + cwd: input.cwd, + args: ["config", "get", "git_protocol", "--host", repositoryHost(urls.url)], + }).pipe( + Effect.map((result) => parseConfiguredGitProtocol(result.stdout)), + Effect.map((preferredProtocol) => + preferredProtocol === undefined ? urls : { ...urls, preferredProtocol }, + ), + // Repository lookup should still work when older `gh` versions or + // incomplete host configuration cannot report a preference. + Effect.orElseSucceed(() => urls), + ), + ), ), createRepository: (input) => execute({ diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 861da9a10e0..0aea0942680 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -150,7 +150,7 @@ it.effect("preserves provider failures without deriving the repository message f }).pipe(Effect.provide(makeLayer({ provider }))); }); -it.effect("clones a looked-up repository into the requested destination", () => +it.effect("clones a looked-up repository using the provider's HTTPS preference", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const parent = yield* fs.makeTempDirectoryScoped({ @@ -158,6 +158,10 @@ it.effect("clones a looked-up repository into the requested destination", () => }); const destinationPath = `${parent}/t3code`; const cloneCalls: Array<{ cwd: string; args: ReadonlyArray }> = []; + const provider = makeProvider({ + getRepositoryCloneUrls: () => + Effect.succeed({ ...CLONE_URLS, preferredProtocol: "https" as const }), + }); yield* Effect.gen(function* () { const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; @@ -165,14 +169,58 @@ it.effect("clones a looked-up repository into the requested destination", () => provider: "github", repository: "octocat/t3code", destinationPath, - protocol: "https", }); assert.deepStrictEqual(result, { cwd: destinationPath, remoteUrl: CLONE_URLS.url, - repository: { provider: "github", ...CLONE_URLS }, + repository: { + provider: "github", + ...CLONE_URLS, + preferredProtocol: "https", + }, + }); + assert.deepStrictEqual(cloneCalls, [ + { + cwd: parent, + args: ["clone", CLONE_URLS.url, "t3code"], + }, + ]); + }).pipe( + Effect.provide( + makeLayer({ + provider, + git: { + execute: (input) => + Effect.sync(() => { + cloneCalls.push({ cwd: input.cwd, args: input.args }); + return processOutput(); + }), + }, + }), + ), + ); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("uses HTTPS as the automatic fallback without a provider preference", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const parent = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-source-control-clone-parent-", + }); + const destinationPath = `${parent}/t3code`; + const cloneCalls: Array<{ cwd: string; args: ReadonlyArray }> = []; + + yield* Effect.gen(function* () { + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const result = yield* service.cloneRepository({ + provider: "github", + repository: "octocat/t3code", + destinationPath, }); + + assert.strictEqual(result.remoteUrl, CLONE_URLS.url); assert.deepStrictEqual(cloneCalls, [ { cwd: parent, diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index 1b46369e25c..cd2426d4762 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -61,6 +61,7 @@ function toRepositoryInfo( nameWithOwner: urls.nameWithOwner, url: urls.url, sshUrl: urls.sshUrl, + ...(urls.preferredProtocol ? { preferredProtocol: urls.preferredProtocol } : {}), }; } @@ -71,8 +72,9 @@ function selectRemoteUrl( switch (protocol ?? "auto") { case "https": return urls.url; - case "ssh": case "auto": + return urls.preferredProtocol === "ssh" ? urls.sshUrl : urls.url; + case "ssh": return urls.sshUrl; } } diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 651fe34e4b4..f638e79a211 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -4,6 +4,7 @@ import type { Thread } from "../types"; import { buildThreadActionItems, filterCommandPaletteGroups, + getCloneSourceInput, type CommandPaletteGroup, } from "./CommandPalette.logic"; @@ -35,6 +36,26 @@ function makeThread(overrides: Partial = {}): Thread { }; } +it("uses provider-based automatic selection for looked-up repositories", () => { + expect( + getCloneSourceInput({ + repository: { + provider: "github", + nameWithOwner: "octocat/t3code", + url: "https://github.com/octocat/t3code", + sshUrl: "git@github.com:octocat/t3code.git", + }, + remoteUrl: "https://github.com/octocat/t3code", + }), + ).toEqual({ provider: "github", repository: "octocat/t3code", protocol: "auto" }); +}); + +it("passes explicit clone URLs through unchanged", () => { + expect( + getCloneSourceInput({ repository: null, remoteUrl: "git@example.com:org/repo.git" }), + ).toEqual({ remoteUrl: "git@example.com:org/repo.git" }); +}); + describe("buildThreadActionItems", () => { it("orders threads by most recent activity and formats timestamps from updatedAt", () => { vi.useFakeTimers(); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index ab53adbefb1..e7a0c5c14e8 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -1,4 +1,9 @@ -import { type KeybindingCommand, type FilesystemBrowseEntry } from "@t3tools/contracts"; +import { + type FilesystemBrowseEntry, + type KeybindingCommand, + type SourceControlCloneRepositoryInput, + type SourceControlRepositoryInfo, +} from "@t3tools/contracts"; import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import * as Arr from "effect/Array"; import * as Result from "effect/Result"; @@ -54,6 +59,21 @@ export interface CommandPaletteView { export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse"; +export function getCloneSourceInput(input: { + repository: SourceControlRepositoryInfo | null; + remoteUrl: string; +}): Pick { + if (input.repository) { + return { + provider: input.repository.provider, + repository: input.repository.nameWithOwner, + protocol: "auto", + }; + } + + return { remoteUrl: input.remoteUrl }; +} + export function filterBrowseEntries(input: { browseEntries: ReadonlyArray; browseFilterQuery: string; diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 8dccf984457..f3098c8ef40 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -102,6 +102,7 @@ import { filterBrowseEntries, filterCommandPaletteGroups, getCommandPaletteInputPlaceholder, + getCloneSourceInput, getCommandPaletteMode, ITEM_ICON_CLASS, RECENT_THREAD_LIMIT, @@ -1282,7 +1283,7 @@ function OpenCommandPaletteDialog(props: { source: addProjectCloneFlow.source, repositoryInput: rawRepository, repository, - remoteUrl: repository.sshUrl, + remoteUrl: repository.url, }); setHighlightedItemValue(null); setQuery(destinationPath); @@ -1329,7 +1330,7 @@ function OpenCommandPaletteDialog(props: { const cloneResult = await cloneRepository({ environmentId: addProjectCloneFlow.environmentId, input: { - remoteUrl: addProjectCloneFlow.remoteUrl, + ...getCloneSourceInput(addProjectCloneFlow), destinationPath, }, }); diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index 104aadd9161..a0f64342960 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -40,6 +40,7 @@ export const SourceControlRepositoryCloneUrls = Schema.Struct({ nameWithOwner: TrimmedNonEmptyString, url: TrimmedNonEmptyString, sshUrl: TrimmedNonEmptyString, + preferredProtocol: Schema.optional(Schema.Literals(["ssh", "https"])), }); export type SourceControlRepositoryCloneUrls = typeof SourceControlRepositoryCloneUrls.Type; @@ -54,6 +55,7 @@ export const SourceControlRepositoryInfo = Schema.Struct({ nameWithOwner: TrimmedNonEmptyString, url: TrimmedNonEmptyString, sshUrl: TrimmedNonEmptyString, + preferredProtocol: Schema.optional(Schema.Literals(["ssh", "https"])), }); export type SourceControlRepositoryInfo = typeof SourceControlRepositoryInfo.Type;