From 92e2845b7c3198a7c68b4f54207ea8bfa7b48e1e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 3 May 2026 17:16:37 -0700 Subject: [PATCH 1/5] Add remote repository publish and discovery support - add source control repository service for lookup, clone, and publish - implement GitHub/GitLab repository creation and update discovery handling - wire UI and server flows to use remote repository actions --- apps/server/src/git/GitManager.test.ts | 7 + apps/server/src/server.test.ts | 10 + apps/server/src/server.ts | 19 +- .../src/sourceControl/GitHubCli.test.ts | 52 ++ apps/server/src/sourceControl/GitHubCli.ts | 58 +- .../GitHubSourceControlProvider.ts | 4 + .../src/sourceControl/GitLabCli.test.ts | 61 ++ apps/server/src/sourceControl/GitLabCli.ts | 80 ++- .../GitLabSourceControlProvider.ts | 4 + .../SourceControlDiscovery.test.ts | 9 +- .../sourceControl/SourceControlDiscovery.ts | 94 ++- .../sourceControl/SourceControlProvider.ts | 6 + .../SourceControlProviderRegistry.ts | 1 + .../SourceControlRepositoryService.test.ts | 288 +++++++++ .../SourceControlRepositoryService.ts | 320 ++++++++++ apps/server/src/ws.ts | 28 + apps/web/src/components/ChatView.browser.tsx | 133 +++- apps/web/src/components/CommandPalette.tsx | 582 ++++++++++++++++-- .../GitActionsControl.logic.test.ts | 27 +- .../src/components/GitActionsControl.logic.ts | 31 +- apps/web/src/components/GitActionsControl.tsx | 554 ++++++++++++++++- .../settings/SourceControlSettings.tsx | 11 +- apps/web/src/environmentApi.ts | 5 + apps/web/src/lib/gitReactQuery.ts | 23 + apps/web/src/localApi.test.ts | 5 + apps/web/src/rpc/wsRpcClient.ts | 13 + packages/contracts/src/ipc.ts | 21 +- packages/contracts/src/rpc.ts | 43 +- packages/contracts/src/sourceControl.ts | 76 ++- 29 files changed, 2411 insertions(+), 154 deletions(-) create mode 100644 apps/server/src/sourceControl/SourceControlRepositoryService.test.ts create mode 100644 apps/server/src/sourceControl/SourceControlRepositoryService.ts diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index b05384c685..9c47e8099b 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -580,6 +580,13 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { cwd: input.cwd, args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"], }).pipe(Effect.map((result) => JSON.parse(result.stdout))), + createRepository: (input) => + Effect.fail( + new GitHubCliError({ + operation: "createRepository", + detail: `Unexpected repository create: ${input.repository}`, + }), + ), checkoutPullRequest: (input) => execute({ cwd: input.cwd, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index a1064752e2..dbe90e63ad 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -117,6 +117,10 @@ import { } from "./vcs/VcsDriverRegistry.ts"; import { layer as VcsProvisioningServiceLayer } from "./vcs/VcsProvisioningService.ts"; import { layer as GitWorkflowServiceLayer } from "./git/GitWorkflowService.ts"; +import { + SourceControlRepositoryService, + type SourceControlRepositoryServiceShape, +} from "./sourceControl/SourceControlRepositoryService.ts"; import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts"; import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts"; @@ -330,6 +334,7 @@ const buildAppUnderTest = (options?: { vcsDriverRegistry?: Partial; gitVcsDriver?: Partial; gitManager?: Partial; + sourceControlRepositoryService?: Partial; vcsStatusBroadcaster?: Partial; projectSetupScriptRunner?: Partial; terminalManager?: Partial; @@ -545,6 +550,11 @@ const buildAppUnderTest = (options?: { Layer.provide(gitVcsDriverLayer), Layer.provide(gitWorkflowLayer), Layer.provide(vcsProvisioningLayer), + Layer.provide( + Layer.mock(SourceControlRepositoryService)({ + ...options?.layers?.sourceControlRepositoryService, + }), + ), Layer.provideMerge(vcsStatusBroadcasterLayer), Layer.provide( Layer.mock(ProjectSetupScriptRunner)({ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index e35f4572a0..49e18ffefa 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -54,6 +54,7 @@ import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; +import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner.ts"; import { ObservabilityLive } from "./observability/Layers/Observability.ts"; import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment.ts"; @@ -161,15 +162,15 @@ const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( Layer.provide(VcsProjectConfig.layer), ); +const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.layer.pipe( + Layer.provide(Layer.mergeAll(GitHubCli.layer, GitLabCli.layer)), + Layer.provideMerge(VcsDriverRegistryLayerLive), +); + const GitManagerLayerLive = GitManager.layer.pipe( Layer.provideMerge(ProjectSetupScriptRunnerLive), Layer.provideMerge(GitVcsDriver.layer), - Layer.provideMerge( - SourceControlProviderRegistry.layer.pipe( - Layer.provide(Layer.mergeAll(GitHubCli.layer, GitLabCli.layer)), - Layer.provideMerge(VcsDriverRegistryLayerLive), - ), - ), + Layer.provideMerge(SourceControlProviderRegistryLayerLive), Layer.provideMerge(TextGeneration.layer), ); @@ -183,11 +184,17 @@ const GitWorkflowLayerLive = GitWorkflowService.layer.pipe( Layer.provideMerge(GitLayerLive), ); +const SourceControlRepositoryServiceLayerLive = SourceControlRepositoryService.layer.pipe( + Layer.provideMerge(GitVcsDriver.layer), + Layer.provideMerge(SourceControlProviderRegistryLayerLive), +); + const VcsLayerLive = Layer.empty.pipe( Layer.provideMerge(VcsProjectConfig.layer), Layer.provideMerge(VcsDriverRegistryLayerLive), Layer.provideMerge(VcsProvisioningService.layer.pipe(Layer.provide(VcsDriverRegistryLayerLive))), Layer.provideMerge(GitWorkflowLayerLive), + Layer.provideMerge(SourceControlRepositoryServiceLayerLive), Layer.provideMerge(VcsStatusBroadcaster.layer.pipe(Layer.provide(GitWorkflowLayerLive))), ); diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index c4675fa2f1..7f1ac44152 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -211,6 +211,58 @@ describe("GitHubCli.layer", () => { }).pipe(Effect.provide(layer)), ); + it.effect("creates repositories and parses clone URLs from create output", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce( + Effect.succeed( + processOutput( + "✓ Created repository octocat/codething-mvp on github.com\nhttps://github.com/octocat/codething-mvp\n", + ), + ), + ); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.createRepository({ + cwd: "/repo", + repository: "octocat/codething-mvp", + visibility: "private", + }); + + assert.deepStrictEqual(result, { + nameWithOwner: "octocat/codething-mvp", + url: "https://github.com/octocat/codething-mvp", + sshUrl: "git@github.com:octocat/codething-mvp.git", + }); + expect(mockRun).toHaveBeenCalledTimes(1); + expect(mockRun).toHaveBeenNthCalledWith(1, { + operation: "GitHubCli.execute", + command: "gh", + args: ["repo", "create", "octocat/codething-mvp", "--private"], + cwd: "/repo", + timeoutMs: 30_000, + }); + }).pipe(Effect.provide(layer)), + ); + + it.effect("falls back to constructed URLs when create output omits a URL", () => + Effect.gen(function* () { + mockRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); + + const gh = yield* GitHubCli.GitHubCli; + const result = yield* gh.createRepository({ + cwd: "/repo", + repository: "octocat/codething-mvp", + visibility: "private", + }); + + assert.deepStrictEqual(result, { + nameWithOwner: "octocat/codething-mvp", + url: "https://github.com/octocat/codething-mvp", + sshUrl: "git@github.com:octocat/codething-mvp.git", + }); + }).pipe(Effect.provide(layer)), + ); + it.effect("surfaces a friendly error when the pull request is not found", () => Effect.gen(function* () { mockRun.mockReturnValueOnce( diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 9dcf0ffec7..b646613853 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -1,6 +1,10 @@ import { Context, Effect, Layer, Result, Schema, SchemaIssue } from "effect"; -import { TrimmedNonEmptyString, type VcsError } from "@t3tools/contracts"; +import { + TrimmedNonEmptyString, + type SourceControlRepositoryVisibility, + type VcsError, +} from "@t3tools/contracts"; import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts"; import { @@ -62,6 +66,12 @@ export interface GitHubCliShape { readonly repository: string; }) => Effect.Effect; + readonly createRepository: (input: { + readonly cwd: string; + readonly repository: string; + readonly visibility: SourceControlRepositoryVisibility; + }) => Effect.Effect; + readonly createPullRequest: (input: { readonly cwd: string; readonly baseBranch: string; @@ -160,6 +170,43 @@ function normalizeRepositoryCloneUrls( }; } +/** + * `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 + * follow-up `gh repo view`, which can race GitHub's GraphQL eventual + * consistency window and falsely report the just-created repo as missing. + */ +function deriveRepositoryCloneUrlsFromCreateOutput( + stdout: string, + repository: string, +): GitHubRepositoryCloneUrls { + const fallbackHost = "github.com"; + const match = stdout.match(/https?:\/\/[^\s]+/); + if (match) { + const cleaned = match[0].replace(/\.git$/, ""); + try { + const parsed = new URL(cleaned); + const pathname = parsed.pathname.replace(/^\/+|\/+$/g, ""); + const segments = pathname.split("/").filter(Boolean); + if (segments.length === 2) { + const nameWithOwner = `${segments[0]}/${segments[1]}`; + return { + nameWithOwner, + url: `${parsed.origin}/${nameWithOwner}`, + sshUrl: `git@${parsed.host}:${nameWithOwner}.git`, + }; + } + } catch { + // Fall through to the input-derived defaults below. + } + } + return { + nameWithOwner: repository, + url: `https://${fallbackHost}/${repository}`, + sshUrl: `git@${fallbackHost}:${repository}.git`, + }; +} + function decodeGitHubJson( raw: string, schema: S, @@ -281,6 +328,15 @@ export const make = Effect.fn("makeGitHubCli")(function* () { ), Effect.map(normalizeRepositoryCloneUrls), ), + createRepository: (input) => + execute({ + cwd: input.cwd, + args: ["repo", "create", input.repository, `--${input.visibility}`], + }).pipe( + Effect.map((result) => + deriveRepositoryCloneUrlsFromCreateOutput(result.stdout, input.repository), + ), + ), createPullRequest: (input) => execute({ cwd: input.cwd, diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 916db45fd4..b893e70f7b 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -130,6 +130,10 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { github .getRepositoryCloneUrls(input) .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), + createRepository: (input) => + github + .createRepository(input) + .pipe(Effect.mapError((error) => providerError("createRepository", error))), getDefaultBranch: (input) => github .getDefaultBranch(input) diff --git a/apps/server/src/sourceControl/GitLabCli.test.ts b/apps/server/src/sourceControl/GitLabCli.test.ts index 5eb6276e3a..5c7ee8d80c 100644 --- a/apps/server/src/sourceControl/GitLabCli.test.ts +++ b/apps/server/src/sourceControl/GitLabCli.test.ts @@ -217,6 +217,67 @@ layer("GitLabCli.layer", (it) => { }), ); + it.effect("creates repositories under an explicit namespace", () => + Effect.gen(function* () { + mockedRun + .mockReturnValueOnce(Effect.succeed(processOutput(JSON.stringify({ id: 1234 })))) + .mockReturnValueOnce( + Effect.succeed( + processOutput( + JSON.stringify({ + path_with_namespace: "octocat/t3code", + web_url: "https://gitlab.com/octocat/t3code", + http_url_to_repo: "https://gitlab.com/octocat/t3code.git", + ssh_url_to_repo: "git@gitlab.com:octocat/t3code.git", + }), + ), + ), + ); + + const glab = yield* GitLabCli.GitLabCli; + const result = yield* glab.createRepository({ + cwd: "/repo", + repository: "octocat/t3code", + visibility: "public", + }); + + assert.deepStrictEqual(result, { + nameWithOwner: "octocat/t3code", + url: "https://gitlab.com/octocat/t3code.git", + sshUrl: "git@gitlab.com:octocat/t3code.git", + }); + expect(mockedRun).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + command: "glab", + cwd: "/repo", + args: ["api", "namespaces/octocat"], + }), + ); + expect(mockedRun).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + command: "glab", + cwd: "/repo", + args: [ + "api", + "--method", + "POST", + "projects", + "--raw-field", + "path=t3code", + "--raw-field", + "name=t3code", + "--raw-field", + "visibility=public", + "--raw-field", + "namespace_id=1234", + ], + }), + ); + }), + ); + it.effect("does not pass unsupported force flags when checking out merge requests", () => Effect.gen(function* () { mockedRun.mockReturnValueOnce(Effect.succeed(processOutput(""))); diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index 04c597b0e7..cfc3c1c1b3 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -1,6 +1,6 @@ import { Context, Effect, Layer, Option, Result, Schema, SchemaIssue, type DateTime } from "effect"; -import { TrimmedNonEmptyString } from "@t3tools/contracts"; +import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts"; import { decodeGitLabMergeRequestJson, @@ -66,6 +66,12 @@ export interface GitLabCliShape { readonly repository: string; }) => Effect.Effect; + readonly createRepository: (input: { + readonly cwd: string; + readonly repository: string; + readonly visibility: SourceControlRepositoryVisibility; + }) => Effect.Effect; + readonly createMergeRequest: (input: { readonly cwd: string; readonly baseBranch: string; @@ -161,6 +167,10 @@ const RawGitLabDefaultBranchSchema = Schema.Struct({ default_branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), }); +const RawGitLabNamespaceSchema = Schema.Struct({ + id: Schema.Number, +}); + function normalizeRepositoryCloneUrls( raw: Schema.Schema.Type, ): GitLabRepositoryCloneUrls { @@ -174,7 +184,7 @@ function normalizeRepositoryCloneUrls( function decodeGitLabJson( raw: string, schema: S, - operation: "getRepositoryCloneUrls" | "getDefaultBranch", + operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository", invalidDetail: string, ): Effect.Effect { return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe( @@ -228,6 +238,19 @@ function toSummaryWithOptionalUpdatedAt( return Option.isSome(updatedAt) ? { ...summary, updatedAt } : summary; } +function parseRepositoryPath(repository: string): { + readonly namespacePath: string | null; + readonly projectPath: string; +} { + const parts = repository + .split("/") + .map((part) => part.trim()) + .filter((part) => part.length > 0); + const projectPath = parts.at(-1) ?? repository.trim(); + const namespacePath = parts.length > 1 ? parts.slice(0, -1).join("/") : null; + return { namespacePath, projectPath }; +} + export const make = Effect.fn("makeGitLabCli")(function* () { const process = yield* VcsProcess; @@ -320,6 +343,59 @@ export const make = Effect.fn("makeGitLabCli")(function* () { ), Effect.map(normalizeRepositoryCloneUrls), ), + createRepository: (input) => { + const { namespacePath, projectPath } = parseRepositoryPath(input.repository); + const namespaceId: Effect.Effect = namespacePath + ? execute({ + cwd: input.cwd, + args: ["api", `namespaces/${encodeURIComponent(namespacePath)}`], + }).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeGitLabJson( + raw, + RawGitLabNamespaceSchema, + "createRepository", + "GitLab CLI returned invalid namespace JSON.", + ), + ), + Effect.map((namespace) => namespace.id), + ) + : Effect.succeed(null); + + return namespaceId.pipe( + Effect.flatMap((resolvedNamespaceId) => + execute({ + cwd: input.cwd, + args: [ + "api", + "--method", + "POST", + "projects", + "--raw-field", + `path=${projectPath}`, + "--raw-field", + `name=${projectPath}`, + "--raw-field", + `visibility=${input.visibility}`, + ...(resolvedNamespaceId === null + ? [] + : ["--raw-field", `namespace_id=${resolvedNamespaceId}`]), + ], + }), + ), + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((raw) => + decodeGitLabJson( + raw, + RawGitLabRepositoryCloneUrlsSchema, + "createRepository", + "GitLab CLI returned invalid repository JSON.", + ), + ), + Effect.map(normalizeRepositoryCloneUrls), + ); + }, createMergeRequest: (input) => { const sourceProject = sourceProjectIdentifier(input.source); return execute({ diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts index f61f1f5926..41c5598e67 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -92,6 +92,10 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { gitlab .getRepositoryCloneUrls(input) .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))), + createRepository: (input) => + gitlab + .createRepository(input) + .pipe(Effect.mapError((error) => providerError("createRepository", error))), getDefaultBranch: (input) => gitlab .getDefaultBranch(input) diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 501edff988..4ac3a3ffb4 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -115,6 +115,9 @@ Logged in to github.com account juliusmarminge (keyring) }, ], ); + const bitbucket = result.sourceControlProviders.find((item) => item.kind === "bitbucket"); + assert.ok(bitbucket); + assert.strictEqual("executable" in bitbucket, false); }).pipe(Effect.provide(testLayer)); }); @@ -204,9 +207,9 @@ Logged in as bitbucket-user }, { kind: "bitbucket", - auth: "authenticated", - account: Option.some("bitbucket-user"), - detail: Option.none(), + auth: "unknown", + account: Option.none(), + detail: Option.some("Bitbucket provider support is not available yet."), }, ], ); diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.ts b/apps/server/src/sourceControl/SourceControlDiscovery.ts index 1e94a09001..8a387a96cd 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.ts @@ -13,20 +13,22 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; interface DiscoveryProbe { readonly label: string; - readonly executable: string; - readonly versionArgs: ReadonlyArray; + readonly executable?: string; + readonly versionArgs?: ReadonlyArray; readonly implemented: boolean; readonly installHint: string; } type VcsProbe = DiscoveryProbe & { readonly kind: VcsDriverKind; + readonly executable: string; + readonly versionArgs: ReadonlyArray; }; type ProviderProbe = DiscoveryProbe & { readonly kind: SourceControlProviderKind; - readonly authArgs: ReadonlyArray; - readonly parseAuth: (input: AuthProbeInput) => SourceControlProviderAuth; + readonly authArgs?: ReadonlyArray; + readonly parseAuth?: (input: AuthProbeInput) => SourceControlProviderAuth; }; interface AuthProbeInput { @@ -38,7 +40,7 @@ interface AuthProbeInput { interface DiscoveryProbeResult { readonly kind: Kind; readonly label: string; - readonly executable: string; + readonly executable?: string; readonly implemented: boolean; readonly status: "available" | "missing"; readonly version: Option.Option; @@ -101,12 +103,8 @@ const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray = [ { kind: "bitbucket", label: "Bitbucket", - executable: "bb", - versionArgs: ["--version"], - authArgs: ["auth", "status"], - parseAuth: parseBitbucketAuth, implemented: false, - installHint: "Install a Bitbucket CLI (`bb`) and authenticate it for your Bitbucket workspace.", + installHint: "Bitbucket provider support is not available yet.", }, ]; @@ -266,37 +264,6 @@ function parseAzureAuth(input: AuthProbeInput): SourceControlProviderAuth { }); } -function parseBitbucketAuth(input: AuthProbeInput): SourceControlProviderAuth { - const output = combinedAuthOutput(input); - const account = matchFirst(output, [ - /Logged in to .* as\s+([^\s(]+)/iu, - /Logged in as\s+([^\s(]+)/iu, - /account:\s*([^\s(]+)/iu, - /user:\s*([^\s(]+)/iu, - /username:\s*([^\s(]+)/iu, - ]); - const host = parseCliHost(output) ?? "bitbucket.org"; - - if (input.exitCode !== 0) { - return providerAuth({ - status: "unauthenticated", - host, - detail: - firstSafeAuthLine(output) ?? "Authenticate the Bitbucket CLI before enabling Bitbucket.", - }); - } - - if (account) { - return providerAuth({ status: "authenticated", account, host }); - } - - return providerAuth({ - status: "unknown", - host, - detail: firstSafeAuthLine(output) ?? "Bitbucket CLI auth status could not be parsed.", - }); -} - export interface SourceControlDiscoveryShape { readonly discover: Effect.Effect; } @@ -314,12 +281,27 @@ export const layer = Layer.effect( const probe = ( input: DiscoveryProbe & { readonly kind: Kind }, - ): Effect.Effect> => - process + ): Effect.Effect> => { + const executable = input.executable; + const versionArgs = input.versionArgs; + + if (!executable || !versionArgs) { + return Effect.succeed({ + kind: input.kind, + label: input.label, + implemented: input.implemented, + status: "missing" as const, + version: Option.none(), + installHint: input.installHint, + detail: Option.some(input.installHint), + } satisfies DiscoveryProbeResult); + } + + return process .run({ operation: "source-control.discovery.probe", - command: input.executable, - args: input.versionArgs, + command: executable, + args: versionArgs, cwd: config.cwd, timeoutMs: 5_000, maxOutputBytes: 8_000, @@ -331,7 +313,7 @@ export const layer = Layer.effect( ({ kind: input.kind, label: input.label, - executable: input.executable, + executable, implemented: input.implemented, status: "available" as const, version: Option.orElse(firstNonEmptyLine(result.stdout), () => @@ -345,7 +327,7 @@ export const layer = Layer.effect( Effect.succeed({ kind: input.kind, label: input.label, - executable: input.executable, + executable, implemented: input.implemented, status: "missing" as const, version: Option.none(), @@ -354,10 +336,22 @@ export const layer = Layer.effect( } satisfies DiscoveryProbeResult), ), ); + }; const probeProvider = (input: ProviderProbe) => probe(input).pipe( Effect.flatMap((item) => { + const executable = input.executable; + const authArgs = input.authArgs; + const parseAuth = input.parseAuth; + + if (!executable || !authArgs || !parseAuth) { + return Effect.succeed({ + ...item, + auth: unknownAuth(input.installHint), + } satisfies SourceControlProviderDiscoveryItem); + } + if (item.status !== "available") { return Effect.succeed({ ...item, @@ -368,8 +362,8 @@ export const layer = Layer.effect( return process .run({ operation: "source-control.discovery.auth", - command: input.executable, - args: input.authArgs, + command: executable, + args: authArgs, cwd: config.cwd, allowNonZeroExit: true, timeoutMs: 5_000, @@ -381,7 +375,7 @@ export const layer = Layer.effect( (result) => ({ ...item, - auth: input.parseAuth(result), + auth: parseAuth(result), }) satisfies SourceControlProviderDiscoveryItem, ), Effect.catch((cause) => diff --git a/apps/server/src/sourceControl/SourceControlProvider.ts b/apps/server/src/sourceControl/SourceControlProvider.ts index baa18cb9c4..ef376eedb4 100644 --- a/apps/server/src/sourceControl/SourceControlProvider.ts +++ b/apps/server/src/sourceControl/SourceControlProvider.ts @@ -6,6 +6,7 @@ import type { SourceControlProviderInfo, SourceControlProviderKind, SourceControlRepositoryCloneUrls, + SourceControlRepositoryVisibility, } from "@t3tools/contracts"; export interface SourceControlProviderContext { @@ -50,6 +51,11 @@ export interface SourceControlProviderShape { readonly context?: SourceControlProviderContext; readonly repository: string; }) => Effect.Effect; + readonly createRepository: (input: { + readonly cwd: string; + readonly repository: string; + readonly visibility: SourceControlRepositoryVisibility; + }) => Effect.Effect; readonly getDefaultBranch: (input: { readonly cwd: string; readonly context?: SourceControlProviderContext; diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts index 3ead930d38..6b04b1f8bb 100644 --- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts +++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts @@ -58,6 +58,7 @@ function unsupportedProvider(kind: SourceControlProviderKind): SourceControlProv getChangeRequest: () => unsupported("getChangeRequest"), createChangeRequest: () => unsupported("createChangeRequest"), getRepositoryCloneUrls: () => unsupported("getRepositoryCloneUrls"), + createRepository: () => unsupported("createRepository"), getDefaultBranch: () => unsupported("getDefaultBranch"), checkoutChangeRequest: () => unsupported("checkoutChangeRequest"), }); diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts new file mode 100644 index 0000000000..537481999e --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -0,0 +1,288 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import { Effect, FileSystem, Layer } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import { + GitCommandError, + type SourceControlProviderError, +} from "@t3tools/contracts"; + +import { ServerConfig } from "../config.ts"; +import { + GitVcsDriver, + type ExecuteGitResult, + type GitVcsDriverShape, +} from "../vcs/GitVcsDriver.ts"; +import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts"; +import type { SourceControlProviderShape } from "./SourceControlProvider.ts"; +import { SourceControlRepositoryService, layer } from "./SourceControlRepositoryService.ts"; + +const CLONE_URLS = { + nameWithOwner: "octocat/t3code", + url: "https://github.com/octocat/t3code", + sshUrl: "git@github.com:octocat/t3code.git", +}; + +function makeProvider( + overrides: Partial = {}, +): SourceControlProviderShape { + const unsupported = (operation: string) => + Effect.die(`unexpected provider operation ${operation}`) as Effect.Effect< + never, + SourceControlProviderError + >; + + return { + kind: "github", + listChangeRequests: () => unsupported("listChangeRequests"), + getChangeRequest: () => unsupported("getChangeRequest"), + createChangeRequest: () => unsupported("createChangeRequest"), + getRepositoryCloneUrls: () => Effect.succeed(CLONE_URLS), + createRepository: () => Effect.succeed(CLONE_URLS), + getDefaultBranch: () => Effect.succeed(null), + checkoutChangeRequest: () => unsupported("checkoutChangeRequest"), + ...overrides, + }; +} + +function processOutput(): ExecuteGitResult { + return { + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: "", + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }; +} + +function makeLayer(input: { + readonly provider?: SourceControlProviderShape; + readonly git?: Partial; +}) { + return layer.pipe( + Layer.provide( + Layer.mock(SourceControlProviderRegistry)({ + get: () => Effect.succeed(input.provider ?? makeProvider()), + }), + ), + Layer.provide( + Layer.mock(GitVcsDriver)({ + execute: () => Effect.succeed(processOutput()), + ensureRemote: () => Effect.succeed("origin"), + pushCurrentBranch: () => + Effect.succeed({ + status: "pushed" as const, + branch: "feature/remote-v1", + upstreamBranch: "origin/feature/remote-v1", + setUpstream: true, + }), + ...input.git, + }), + ), + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-repos-" })), + Layer.provideMerge(NodeServices.layer), + ); +} + +it.effect("looks up repositories through the requested provider without search", () => { + const calls: Array<{ cwd: string; repository: string }> = []; + const provider = makeProvider({ + getRepositoryCloneUrls: (input) => + Effect.sync(() => { + calls.push({ cwd: input.cwd, repository: input.repository }); + return CLONE_URLS; + }), + }); + + return Effect.gen(function* () { + const service = yield* SourceControlRepositoryService; + const result = yield* service.lookupRepository({ + provider: "github", + repository: "octocat/t3code", + cwd: "/workspace", + }); + + assert.deepStrictEqual(result, { provider: "github", ...CLONE_URLS }); + assert.deepStrictEqual(calls, [{ cwd: "/workspace", repository: "octocat/t3code" }]); + }).pipe(Effect.provide(makeLayer({ provider }))); +}); + +it.effect("clones a looked-up repository into the requested destination", () => + 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; + const result = yield* service.cloneRepository({ + provider: "github", + repository: "octocat/t3code", + destinationPath, + protocol: "https", + }); + + assert.deepStrictEqual(result, { + cwd: destinationPath, + remoteUrl: CLONE_URLS.url, + repository: { provider: "github", ...CLONE_URLS }, + }); + assert.deepStrictEqual(cloneCalls, [ + { + cwd: parent, + args: ["clone", CLONE_URLS.url, "t3code"], + }, + ]); + }).pipe( + Effect.provide( + makeLayer({ + git: { + execute: (input) => + Effect.sync(() => { + cloneCalls.push({ cwd: input.cwd, args: input.args }); + return processOutput(); + }), + }, + }), + ), + ); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("publishes by creating the repository, adding a remote, and pushing upstream", () => { + const createCalls: Array<{ cwd: string; repository: string; visibility: string }> = []; + const remoteCalls: Array<{ cwd: string; preferredName: string; url: string }> = []; + const pushCalls: string[] = []; + const provider = makeProvider({ + createRepository: (input) => + Effect.sync(() => { + createCalls.push({ + cwd: input.cwd, + repository: input.repository, + visibility: input.visibility, + }); + return CLONE_URLS; + }), + }); + + return Effect.gen(function* () { + const service = yield* SourceControlRepositoryService; + const result = yield* service.publishRepository({ + cwd: "/workspace", + provider: "github", + repository: "octocat/t3code", + visibility: "private", + remoteName: "origin", + protocol: "ssh", + }); + + assert.deepStrictEqual(result, { + repository: { provider: "github", ...CLONE_URLS }, + remoteName: "origin", + remoteUrl: CLONE_URLS.sshUrl, + branch: "feature/remote-v1", + upstreamBranch: "origin/feature/remote-v1", + status: "pushed", + }); + assert.deepStrictEqual(createCalls, [ + { cwd: "/workspace", repository: "octocat/t3code", visibility: "private" }, + ]); + assert.deepStrictEqual(remoteCalls, [ + { cwd: "/workspace", preferredName: "origin", url: CLONE_URLS.sshUrl }, + ]); + assert.deepStrictEqual(pushCalls, ["/workspace"]); + }).pipe( + Effect.provide( + makeLayer({ + provider, + git: { + ensureRemote: (input) => + Effect.sync(() => { + remoteCalls.push(input); + return "origin"; + }), + pushCurrentBranch: (cwd) => + Effect.sync(() => { + pushCalls.push(cwd); + return { + status: "pushed" as const, + branch: "feature/remote-v1", + upstreamBranch: "origin/feature/remote-v1", + setUpstream: true, + }; + }), + }, + }), + ), + ); +}); + +it.effect("publish succeeds with status remote_added when the local repo has no commits", () => { + let pushCalls = 0; + return Effect.gen(function* () { + const service = yield* SourceControlRepositoryService; + const result = yield* service.publishRepository({ + cwd: "/workspace", + provider: "github", + repository: "octocat/t3code", + visibility: "private", + remoteName: "origin", + protocol: "ssh", + }); + + assert.deepStrictEqual(result, { + repository: { provider: "github", ...CLONE_URLS }, + remoteName: "origin", + remoteUrl: CLONE_URLS.sshUrl, + branch: "main", + status: "remote_added", + }); + assert.strictEqual(pushCalls, 0); + }).pipe( + Effect.provide( + makeLayer({ + git: { + execute: (input) => + input.args[0] === "rev-parse" + ? Effect.fail( + new GitCommandError({ + operation: input.operation, + command: "git rev-parse --verify HEAD", + cwd: input.cwd, + detail: "fatal: Needed a single revision", + }), + ) + : Effect.succeed(processOutput()), + statusDetails: () => + Effect.succeed({ + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: true, + branch: "main", + upstreamRef: null, + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: false, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: 0, + }), + pushCurrentBranch: () => + Effect.sync(() => { + pushCalls += 1; + return { + status: "pushed" as const, + branch: "main", + upstreamBranch: "origin/main", + setUpstream: true, + }; + }), + }, + }), + ), + ); +}); diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts new file mode 100644 index 0000000000..acd69ea77a --- /dev/null +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -0,0 +1,320 @@ +import OS from "node:os"; +import { Context, Effect, FileSystem, Layer, Path, Schema } from "effect"; + +import { + SourceControlRepositoryError, + type SourceControlCloneRepositoryInput, + type SourceControlCloneRepositoryResult, + type SourceControlCloneProtocol, + type SourceControlProviderKind, + type SourceControlPublishRepositoryInput, + type SourceControlPublishRepositoryResult, + type SourceControlRepositoryCloneUrls, + type SourceControlRepositoryInfo, + type SourceControlRepositoryLookupInput, +} from "@t3tools/contracts"; + +import { ServerConfig } from "../config.ts"; +import { GitVcsDriver } from "../vcs/GitVcsDriver.ts"; +import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts"; + +export interface SourceControlRepositoryServiceShape { + readonly lookupRepository: ( + input: SourceControlRepositoryLookupInput, + ) => Effect.Effect; + readonly cloneRepository: ( + input: SourceControlCloneRepositoryInput, + ) => Effect.Effect; + readonly publishRepository: ( + input: SourceControlPublishRepositoryInput, + ) => Effect.Effect; +} + +export class SourceControlRepositoryService extends Context.Service< + SourceControlRepositoryService, + SourceControlRepositoryServiceShape +>()("t3/source-control/SourceControlRepositoryService") {} + +function detailFromUnknown(cause: unknown): string { + if (typeof cause === "object" && cause !== null) { + if ("detail" in cause && typeof cause.detail === "string" && cause.detail.length > 0) { + return cause.detail; + } + if ("message" in cause && typeof cause.message === "string" && cause.message.length > 0) { + return cause.message; + } + } + + return "An unexpected source control error occurred."; +} + +function repositoryError(input: { + readonly operation: string; + readonly provider: SourceControlProviderKind; + readonly detail: string; + readonly cause?: unknown; +}): SourceControlRepositoryError { + return new SourceControlRepositoryError({ + provider: input.provider, + operation: input.operation, + detail: input.detail, + ...(input.cause === undefined ? {} : { cause: input.cause }), + }); +} + +function mapRepositoryError(operation: string, provider: SourceControlProviderKind) { + return Effect.mapError((cause: unknown) => + Schema.is(SourceControlRepositoryError)(cause) + ? cause + : repositoryError({ + operation, + provider, + detail: detailFromUnknown(cause), + cause, + }), + ); +} + +function toRepositoryInfo( + provider: SourceControlProviderKind, + urls: SourceControlRepositoryCloneUrls, +): SourceControlRepositoryInfo { + return { + provider, + nameWithOwner: urls.nameWithOwner, + url: urls.url, + sshUrl: urls.sshUrl, + }; +} + +function selectRemoteUrl( + urls: SourceControlRepositoryCloneUrls, + protocol: SourceControlCloneProtocol | undefined, +): string { + switch (protocol ?? "auto") { + case "https": + return urls.url; + case "ssh": + case "auto": + return urls.sshUrl; + } +} + +function expandHomePath(input: string, path: Path.Path): string { + if (input === "~") { + return OS.homedir(); + } + if (input.startsWith("~/") || input.startsWith("~\\")) { + return path.join(OS.homedir(), input.slice(2)); + } + return input; +} + +export const make = Effect.fn("makeSourceControlRepositoryService")(function* () { + const config = yield* ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const git = yield* GitVcsDriver; + const path = yield* Path.Path; + const providers = yield* SourceControlProviderRegistry; + + const ensureConcreteProvider = (input: { + readonly operation: string; + readonly provider: SourceControlProviderKind; + }) => { + if (input.provider !== "unknown") { + return Effect.succeed(input.provider); + } + + return Effect.fail( + repositoryError({ + operation: input.operation, + provider: input.provider, + detail: "Choose a source control provider before continuing.", + }), + ); + }; + + const lookupRepository = Effect.fn("SourceControlRepositoryService.lookupRepository")(function* ( + input: SourceControlRepositoryLookupInput, + ) { + const providerKind = yield* ensureConcreteProvider({ + operation: "lookupRepository", + provider: input.provider, + }); + const provider = yield* providers.get(providerKind); + const urls = yield* provider.getRepositoryCloneUrls({ + cwd: input.cwd ?? config.cwd, + repository: input.repository.trim(), + }); + return toRepositoryInfo(providerKind, urls); + }); + + const normalizeDestinationPath = Effect.fn("SourceControlRepositoryService.normalizeDestination")( + function* (destinationPath: string) { + const trimmed = destinationPath.trim(); + if (trimmed.length === 0) { + return yield* Effect.fail( + repositoryError({ + operation: "cloneRepository", + provider: "unknown", + detail: "Choose a destination path before cloning.", + }), + ); + } + + return path.resolve(expandHomePath(trimmed, path)); + }, + ); + + const prepareDestination = Effect.fn("SourceControlRepositoryService.prepareDestination")( + function* (destinationPath: string) { + const normalizedDestination = yield* normalizeDestinationPath(destinationPath); + if (yield* fileSystem.exists(normalizedDestination).pipe(Effect.orElseSucceed(() => false))) { + const entries = yield* fileSystem + .readDirectory(normalizedDestination, { recursive: false }) + .pipe( + Effect.mapError((cause) => + repositoryError({ + operation: "cloneRepository", + provider: "unknown", + detail: "Destination path already exists and is not a directory.", + cause, + }), + ), + ); + if (entries.length > 0) { + return yield* Effect.fail( + repositoryError({ + operation: "cloneRepository", + provider: "unknown", + detail: "Destination path already exists and is not empty.", + }), + ); + } + } else { + yield* fileSystem.makeDirectory(path.dirname(normalizedDestination), { recursive: true }); + } + + return { + destinationPath: normalizedDestination, + parentPath: path.dirname(normalizedDestination), + directoryName: path.basename(normalizedDestination), + }; + }, + ); + + const cloneRepository = Effect.fn("SourceControlRepositoryService.cloneRepository")(function* ( + input: SourceControlCloneRepositoryInput, + ) { + const preparedDestination = yield* prepareDestination(input.destinationPath); + let repository: SourceControlRepositoryInfo | null = null; + let remoteUrl = input.remoteUrl?.trim() ?? null; + let provider: SourceControlProviderKind = input.provider ?? "unknown"; + + if (input.provider && input.repository) { + repository = yield* lookupRepository({ + provider: input.provider, + repository: input.repository, + cwd: preparedDestination.parentPath, + }); + remoteUrl = selectRemoteUrl(repository, input.protocol); + provider = input.provider; + } + + if (!remoteUrl) { + return yield* Effect.fail( + repositoryError({ + operation: "cloneRepository", + provider, + detail: "Enter a repository path or clone URL before cloning.", + }), + ); + } + + yield* git.execute({ + operation: "SourceControlRepositoryService.cloneRepository", + cwd: preparedDestination.parentPath, + args: ["clone", remoteUrl, preparedDestination.directoryName], + timeoutMs: 120_000, + maxOutputBytes: 256 * 1024, + }); + + return { + cwd: preparedDestination.destinationPath, + remoteUrl, + repository, + }; + }); + + const publishRepository = Effect.fn("SourceControlRepositoryService.publishRepository")( + function* (input: SourceControlPublishRepositoryInput) { + const providerKind = yield* ensureConcreteProvider({ + operation: "publishRepository", + provider: input.provider, + }); + const provider = yield* providers.get(providerKind); + const urls = yield* provider.createRepository({ + cwd: input.cwd, + repository: input.repository.trim(), + visibility: input.visibility, + }); + const remoteUrl = selectRemoteUrl(urls, input.protocol); + const remoteName = yield* git.ensureRemote({ + cwd: input.cwd, + preferredName: input.remoteName?.trim() || "origin", + url: remoteUrl, + }); + + // An empty local repo (no commits) would make `git push HEAD:...` fail + // with an opaque "src refspec HEAD does not match any". Treat this as a + // partial success: the remote was created and wired up, but there is + // nothing to push yet. + const hasCommits = yield* git + .execute({ + operation: "SourceControlRepositoryService.publishRepository.headCheck", + cwd: input.cwd, + args: ["rev-parse", "--verify", "HEAD"], + }) + .pipe( + Effect.map(() => true), + Effect.catch(() => Effect.succeed(false)), + ); + if (!hasCommits) { + const details = yield* git + .statusDetails(input.cwd) + .pipe(Effect.catch(() => Effect.succeed(null))); + return { + repository: toRepositoryInfo(providerKind, urls), + remoteName, + remoteUrl, + branch: details?.branch ?? "main", + status: "remote_added" as const, + }; + } + + const pushResult = yield* git.pushCurrentBranch(input.cwd, null); + + return { + repository: toRepositoryInfo(providerKind, urls), + remoteName, + remoteUrl, + branch: pushResult.branch, + ...(pushResult.upstreamBranch ? { upstreamBranch: pushResult.upstreamBranch } : {}), + status: "pushed" as const, + }; + }, + ); + + return SourceControlRepositoryService.of({ + lookupRepository: (input) => + lookupRepository(input).pipe(mapRepositoryError("lookupRepository", input.provider)), + cloneRepository: (input) => + cloneRepository(input).pipe( + mapRepositoryError("cloneRepository", input.provider ?? "unknown"), + ), + publishRepository: (input) => + publishRepository(input).pipe(mapRepositoryError("publishRepository", input.provider)), + }); +}); + +export const layer = Layer.effect(SourceControlRepositoryService, make()); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index c3827d98bf..d7015a54ca 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -55,6 +55,7 @@ import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentit import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts"; import { ServerAuth } from "./auth/Services/ServerAuth.ts"; import * as SourceControlDiscoveryLayer from "./sourceControl/SourceControlDiscovery.ts"; +import { SourceControlRepositoryService } from "./sourceControl/SourceControlRepositoryService.ts"; import * as VcsProcess from "./vcs/VcsProcess.ts"; import { BootstrapCredentialService, @@ -154,6 +155,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const serverEnvironment = yield* ServerEnvironment; const serverAuth = yield* ServerAuth; const sourceControlDiscovery = yield* SourceControlDiscoveryLayer.SourceControlDiscovery; + const sourceControlRepositories = yield* SourceControlRepositoryService; const bootstrapCredentials = yield* BootstrapCredentialService; const sessions = yield* SessionCredentialService; const serverCommandId = (tag: string) => @@ -794,6 +796,32 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => "rpc.aggregate": "server", }, ), + [WS_METHODS.sourceControlLookupRepository]: (input) => + observeRpcEffect( + WS_METHODS.sourceControlLookupRepository, + sourceControlRepositories.lookupRepository(input), + { + "rpc.aggregate": "source-control", + }, + ), + [WS_METHODS.sourceControlCloneRepository]: (input) => + observeRpcEffect( + WS_METHODS.sourceControlCloneRepository, + sourceControlRepositories.cloneRepository(input), + { + "rpc.aggregate": "source-control", + }, + ), + [WS_METHODS.sourceControlPublishRepository]: (input) => + observeRpcEffect( + WS_METHODS.sourceControlPublishRepository, + sourceControlRepositories + .publishRepository(input) + .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { + "rpc.aggregate": "source-control", + }, + ), [WS_METHODS.projectsSearchEntries]: (input) => observeRpcEffect( WS_METHODS.projectsSearchEntries, diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 2a1edb9181..5bfe57d352 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -206,6 +206,7 @@ function createMockEnvironmentApi(input: { filesystem: { browse: input.browse, }, + sourceControl: {} as EnvironmentApi["sourceControl"], vcs: {} as EnvironmentApi["vcs"], git: {} as EnvironmentApi["git"], orchestration: { @@ -4257,8 +4258,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }); try { - await waitForServerConfigToApply(); - await waitForCommandPaletteShortcutLabel(); + await Promise.all([waitForServerConfigToApply(), waitForCommandPaletteShortcutLabel()]); const palette = page.getByTestId("command-palette"); await openCommandPaletteFromTrigger(); @@ -4310,8 +4310,7 @@ describe("ChatView timeline estimator parity (full app)", () => { }); try { - await waitForServerConfigToApply(); - await waitForCommandPaletteShortcutLabel(); + await Promise.all([waitForServerConfigToApply(), waitForCommandPaletteShortcutLabel()]); const palette = page.getByTestId("command-palette"); await openCommandPaletteFromTrigger(); @@ -4384,13 +4383,13 @@ describe("ChatView timeline estimator parity (full app)", () => { }); try { - await waitForServerConfigToApply(); - await waitForCommandPaletteShortcutLabel(); + await Promise.all([waitForServerConfigToApply(), waitForCommandPaletteShortcutLabel()]); const palette = page.getByTestId("command-palette"); await openCommandPaletteFromTrigger(); await expect.element(palette).toBeInTheDocument(); await palette.getByText("Add project", { exact: true }).click(); + await palette.getByText("Local folder", { exact: true }).click(); const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Development/"); @@ -4437,6 +4436,126 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("shows clone destination controls after resolving an add project repository", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-command-palette-add-project-remote" as MessageId, + targetText: "command palette add project remote", + }), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + keybindings: [ + { + command: "commandPalette.toggle", + shortcut: { + key: "k", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ], + }; + }, + resolveRpc: (body) => { + if (body._tag === WS_METHODS.filesystemBrowse) { + return { + parentPath: "~/", + entries: [{ name: "Development", fullPath: "~/Development" }], + }; + } + + if (body._tag === WS_METHODS.sourceControlLookupRepository) { + return { + provider: "github", + nameWithOwner: "t3-oss/t3-env", + url: "https://github.com/t3-oss/t3-env", + sshUrl: "git@github.com:t3-oss/t3-env.git", + }; + } + + if (body._tag === WS_METHODS.sourceControlCloneRepository) { + return { + cwd: body.destinationPath, + remoteUrl: body.remoteUrl, + repository: null, + }; + } + + if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return { + sequence: fixture.snapshot.snapshotSequence + 1, + }; + } + + return undefined; + }, + }); + + try { + await Promise.all([waitForServerConfigToApply(), waitForCommandPaletteShortcutLabel()]); + const palette = page.getByTestId("command-palette"); + await openCommandPaletteFromTrigger(); + + await expect.element(palette).toBeInTheDocument(); + await palette.getByText("Add project", { exact: true }).click(); + await palette.getByText("GitHub repository", { exact: true }).click(); + + const repositoryInput = await waitForCommandPaletteInput( + "Enter GitHub repository (owner/repo)", + ); + await page.getByPlaceholder("Enter GitHub repository (owner/repo)").fill("t3-oss/t3-env"); + await dispatchInputKey(repositoryInput, { key: "Enter" }); + + await vi.waitFor( + () => { + const clonePathInput = document.querySelector( + 'input[placeholder="Enter path (e.g. ~/projects/my-app)"]', + ); + expect(clonePathInput?.value).toBe("~/"); + expect(document.body.textContent).toContain("Repository"); + expect(document.body.textContent).toContain("t3-oss/t3-env"); + expect(document.body.textContent).toContain("https://github.com/t3-oss/t3-env"); + expect(document.body.textContent).toContain("Select where to clone"); + expect(document.body.textContent).toContain("Development"); + expect(document.body.textContent).toContain("Clone"); + }, + { timeout: 8_000, interval: 16 }, + ); + + await page + .getByPlaceholder("Enter path (e.g. ~/projects/my-app)") + .fill("~/Development/t3env"); + const clonePathInput = await waitForCommandPaletteInput( + "Enter path (e.g. ~/projects/my-app)", + ); + await dispatchInputKey(clonePathInput, { key: "Enter" }); + + await vi.waitFor( + () => { + const cloneRequest = wsRequests.find( + (request) => request._tag === WS_METHODS.sourceControlCloneRepository, + ) as { destinationPath?: string; remoteUrl?: string } | undefined; + expect(cloneRequest).toMatchObject({ + remoteUrl: "git@github.com:t3-oss/t3-env.git", + destinationPath: "~/Development/t3env", + }); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("opens add project browse mode from the sidebar add button", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, @@ -4789,6 +4908,7 @@ describe("ChatView timeline estimator parity (full app)", () => { await expect.element(palette).toBeInTheDocument(); await palette.getByText("Add project", { exact: true }).click(); + await palette.getByText("Local folder", { exact: true }).click(); await expect.element(palette.getByText("Environments", { exact: true })).toBeInTheDocument(); await expect .element(palette.getByText("This device", { exact: true }).first()) @@ -5004,6 +5124,7 @@ describe("ChatView timeline estimator parity (full app)", () => { await expect.element(palette).toBeInTheDocument(); await palette.getByText("Add project", { exact: true }).click(); + await palette.getByText("Local folder", { exact: true }).click(); const browseInput = await waitForCommandPaletteInput(ADD_PROJECT_SUBMENU_PLACEHOLDER); await page.getByPlaceholder(ADD_PROJECT_SUBMENU_PLACEHOLDER).fill("~/Development/"); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index d64c80ff96..6b40066147 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -7,6 +7,8 @@ import { type FilesystemBrowseResult, type ProjectId, ProviderInstanceId, + type SourceControlProviderKind, + type SourceControlRepositoryInfo, } from "@t3tools/contracts"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate, useParams } from "@tanstack/react-router"; @@ -17,6 +19,7 @@ import { CornerLeftUpIcon, FolderIcon, FolderPlusIcon, + LinkIcon, MessageSquareIcon, SettingsIcon, SquarePenIcon, @@ -89,6 +92,7 @@ import { } from "./CommandPalette.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { CommandPaletteResults } from "./CommandPaletteResults"; +import { GitHubIcon, GitLabIcon } from "./Icons"; import { ProjectFavicon } from "./ProjectFavicon"; import { ThreadRowLeadingStatus, ThreadRowTrailingStatus } from "./ThreadStatusIndicators"; import { useServerKeybindings } from "../rpc/serverState"; @@ -139,6 +143,70 @@ interface AddProjectEnvironmentOption { readonly isPrimary: boolean; } +type AddProjectRemoteProviderKind = Extract; +type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; + +type AddProjectCloneFlow = + | { + readonly step: "repository"; + readonly environmentId: EnvironmentId; + readonly source: AddProjectRemoteSource; + } + | { + readonly step: "confirm"; + readonly environmentId: EnvironmentId; + readonly source: AddProjectRemoteSource; + readonly repositoryInput: string; + readonly repository: SourceControlRepositoryInfo | null; + readonly remoteUrl: string; + }; + +const REMOTE_PROJECT_SOURCES: ReadonlyArray = ["github", "gitlab", "url"]; + +function remoteProjectSourceLabel(source: AddProjectRemoteSource): string { + switch (source) { + case "github": + return "GitHub"; + case "gitlab": + return "GitLab"; + case "url": + return "Git URL"; + } +} + +function remoteProjectSourceProvider( + source: AddProjectRemoteSource, +): AddProjectRemoteProviderKind | null { + return source === "url" ? null : source; +} + +function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: string): ReactNode { + switch (source) { + case "github": + return ; + case "gitlab": + return ; + case "url": + return ; + } +} + +function remoteProjectInputPlaceholder(flow: AddProjectCloneFlow | null): string | null { + if (!flow) return null; + if (flow.step === "confirm") return null; + if (flow.source === "url") { + return "Enter Git clone URL"; + } + return `Enter ${remoteProjectSourceLabel(flow.source)} repository (owner/repo)`; +} + +function errorMessage(error: unknown): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + return "An error occurred."; +} + export function CommandPalette({ children }: { children: ReactNode }) { const open = useCommandPaletteStore((store) => store.open); const setOpen = useCommandPaletteStore((store) => store.setOpen); @@ -227,6 +295,9 @@ function OpenCommandPaletteDialog() { null, ); const [isPickingProjectFolder, setIsPickingProjectFolder] = useState(false); + const [addProjectCloneFlow, setAddProjectCloneFlow] = useState(null); + const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false); + const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false); const primaryEnvironmentId = usePrimaryEnvironmentId(); const primaryEnvironmentLabel = readPrimaryEnvironmentDescriptor()?.label ?? null; const savedEnvironmentRegistry = useSavedEnvironmentRegistryStore((state) => state.byId); @@ -295,7 +366,10 @@ function OpenCommandPaletteDialog() { : null; return getEnvironmentBrowsePlatform(os); }, [browseEnvironmentId, primaryEnvironmentId, savedEnvironmentRuntimeById]); - const isBrowsing = isFilesystemBrowseQuery(query, browseEnvironmentPlatform); + const isRemoteProjectCloneFlow = addProjectCloneFlow !== null; + const isRemoteProjectRepositoryStep = addProjectCloneFlow?.step === "repository"; + const isBrowsing = + !isRemoteProjectRepositoryStep && isFilesystemBrowseQuery(query, browseEnvironmentPlatform); const paletteMode = getCommandPaletteMode({ currentView, isBrowsing }); const getAddProjectInitialQueryForEnvironment = useCallback( (environmentId: EnvironmentId | null): string => { @@ -541,6 +615,7 @@ function OpenCommandPaletteDialog() { } function popView(): void { + setAddProjectCloneFlow(null); if (viewStack.length <= 1) { setAddProjectEnvironmentId(null); } @@ -560,6 +635,7 @@ function OpenCommandPaletteDialog() { const startAddProjectBrowse = useCallback( (environmentId: EnvironmentId): void => { setAddProjectEnvironmentId(environmentId); + setAddProjectCloneFlow(null); pushPaletteView({ addonIcon: , groups: [], @@ -569,6 +645,19 @@ function OpenCommandPaletteDialog() { [getAddProjectInitialQueryForEnvironment], ); + const startAddProjectClone = useCallback( + (environmentId: EnvironmentId, source: AddProjectRemoteSource): void => { + setAddProjectEnvironmentId(environmentId); + setAddProjectCloneFlow({ step: "repository", environmentId, source }); + pushPaletteView({ + addonIcon: remoteProjectSourceIcon(source, ADDON_ICON_CLASS), + groups: [], + initialQuery: "", + }); + }, + [], + ); + const addProjectEnvironmentItems: CommandPaletteActionItem[] = addProjectEnvironmentOptions.map( (option) => ({ kind: "action", @@ -595,6 +684,156 @@ function OpenCommandPaletteDialog() { [addProjectEnvironmentItems], ); + const addProjectRemoteEnvironmentGroups = useMemo< + Record + >(() => { + const buildGroups = (source: AddProjectRemoteSource): CommandPaletteView["groups"] => [ + { + value: `environments:${source}`, + label: "Environments", + items: addProjectEnvironmentOptions.map((option) => ({ + kind: "action" as const, + value: `action:add-project:${source}:environment:${option.environmentId}`, + searchTerms: [ + option.label, + option.environmentId, + option.isPrimary ? "this device" : "", + remoteProjectSourceLabel(source), + ], + title: option.label, + description: option.isPrimary ? "This device" : option.environmentId, + icon: remoteProjectSourceIcon(source, ITEM_ICON_CLASS), + keepOpen: true, + run: async () => { + startAddProjectClone(option.environmentId, source); + }, + })), + }, + ]; + + return { + github: buildGroups("github"), + gitlab: buildGroups("gitlab"), + url: buildGroups("url"), + }; + }, [addProjectEnvironmentOptions, startAddProjectClone]); + + const openAddProjectCloneFlow = useCallback( + (source: AddProjectRemoteSource) => { + if (addProjectEnvironmentOptions.length > 1) { + pushPaletteView({ + addonIcon: remoteProjectSourceIcon(source, ADDON_ICON_CLASS), + groups: addProjectRemoteEnvironmentGroups[source], + }); + return; + } + + const environmentId = defaultAddProjectEnvironmentId; + if (!environmentId) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to clone project", + description: "No environment is available.", + }), + ); + return; + } + + startAddProjectClone(environmentId, source); + }, + [ + addProjectEnvironmentOptions.length, + addProjectRemoteEnvironmentGroups, + defaultAddProjectEnvironmentId, + startAddProjectClone, + ], + ); + + const addProjectSourceGroups = useMemo(() => { + const sourceItems: Array = []; + + if (addProjectEnvironmentOptions.length > 1) { + sourceItems.push({ + kind: "submenu", + value: "action:add-project:local", + searchTerms: ["local", "folder", "directory", "browse", "environment"], + title: "Local folder", + description: "Browse a folder on disk", + icon: , + addonIcon: , + groups: addProjectEnvironmentGroups, + }); + } else { + sourceItems.push({ + kind: "action", + value: "action:add-project:local", + searchTerms: ["local", "folder", "directory", "browse"], + title: "Local folder", + description: "Browse a folder on disk", + icon: , + keepOpen: true, + run: async () => { + const environmentId = defaultAddProjectEnvironmentId; + if (!environmentId) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to browse projects", + description: "No environment is available.", + }), + ); + return; + } + startAddProjectBrowse(environmentId); + }, + }); + } + + for (const source of REMOTE_PROJECT_SOURCES) { + const label = remoteProjectSourceLabel(source); + const title = source === "url" ? "Git URL" : `${label} repository`; + const description = + source === "url" ? "Clone from a remote URL" : `Clone ${label} owner/repo`; + + if (addProjectEnvironmentOptions.length > 1) { + sourceItems.push({ + kind: "submenu", + value: `action:add-project:${source}`, + searchTerms: ["clone", "remote", "repository", "repo", "git", label], + title, + description, + icon: remoteProjectSourceIcon(source, ITEM_ICON_CLASS), + addonIcon: remoteProjectSourceIcon(source, ADDON_ICON_CLASS), + groups: addProjectRemoteEnvironmentGroups[source], + }); + continue; + } + + sourceItems.push({ + kind: "action", + value: `action:add-project:${source}`, + searchTerms: ["clone", "remote", "repository", "repo", "git", label], + title, + description, + icon: remoteProjectSourceIcon(source, ITEM_ICON_CLASS), + keepOpen: true, + run: async () => { + openAddProjectCloneFlow(source); + }, + }); + } + + return [{ value: "sources", label: "Sources", items: sourceItems }]; + }, [ + addProjectEnvironmentGroups, + addProjectEnvironmentOptions.length, + addProjectRemoteEnvironmentGroups, + defaultAddProjectEnvironmentId, + openAddProjectCloneFlow, + startAddProjectBrowse, + ]); + const openAddProjectFlow = useCallback(() => { if (addProjectEnvironmentOptions.length > 1) { pushPaletteView({ @@ -674,29 +913,29 @@ function OpenCommandPaletteDialog() { }); } - if (addProjectEnvironmentOptions.length > 1) { - actionItems.push({ - kind: "submenu", - value: "action:add-project", - searchTerms: ["add project", "folder", "directory", "browse", "environment"], - title: "Add project", - icon: , - addonIcon: , - groups: addProjectEnvironmentGroups, - }); - } else { - actionItems.push({ - kind: "action", - value: "action:add-project", - searchTerms: ["add project", "folder", "directory", "browse"], - title: "Add project", - icon: , - keepOpen: true, - run: async () => { - openAddProjectFlow(); - }, - }); - } + actionItems.push({ + kind: "submenu", + value: "action:add-project", + searchTerms: [ + "add project", + "folder", + "directory", + "browse", + "clone", + "remote", + "repository", + "repo", + "git", + "github", + "gitlab", + "url", + "environment", + ], + title: "Add project", + icon: , + addonIcon: , + groups: addProjectSourceGroups, + }); actionItems.push({ kind: "action", @@ -820,6 +1059,137 @@ function OpenCommandPaletteDialog() { ], ); + function getDefaultCloneParentPath(environmentId: EnvironmentId): string { + return getAddProjectInitialQueryForEnvironment(environmentId); + } + + async function submitAddProjectCloneFlow(destinationPathInput?: string): Promise { + if (!addProjectCloneFlow) { + return; + } + + const api = readEnvironmentApi(addProjectCloneFlow.environmentId); + if (!api) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to clone project", + description: "Environment API is not available.", + }), + ); + return; + } + + if (addProjectCloneFlow.step === "repository") { + const rawRepository = query.trim(); + if (rawRepository.length === 0 || isRemoteProjectLookingUp) { + return; + } + + const provider = remoteProjectSourceProvider(addProjectCloneFlow.source); + if (!provider) { + const destinationPath = getDefaultCloneParentPath(addProjectCloneFlow.environmentId); + setAddProjectCloneFlow({ + step: "confirm", + environmentId: addProjectCloneFlow.environmentId, + source: addProjectCloneFlow.source, + repositoryInput: rawRepository, + repository: null, + remoteUrl: rawRepository, + }); + setHighlightedItemValue(null); + setQuery(destinationPath); + setBrowseGeneration((generation) => generation + 1); + return; + } + + setIsRemoteProjectLookingUp(true); + try { + const repository = await api.sourceControl.lookupRepository({ + provider, + repository: rawRepository, + }); + const destinationPath = getDefaultCloneParentPath(addProjectCloneFlow.environmentId); + setAddProjectCloneFlow({ + step: "confirm", + environmentId: addProjectCloneFlow.environmentId, + source: addProjectCloneFlow.source, + repositoryInput: rawRepository, + repository, + remoteUrl: repository.sshUrl, + }); + setHighlightedItemValue(null); + setQuery(destinationPath); + setBrowseGeneration((generation) => generation + 1); + } catch (error) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Repository lookup failed", + description: errorMessage(error), + }), + ); + } finally { + setIsRemoteProjectLookingUp(false); + } + return; + } + + const rawDestination = (destinationPathInput ?? query).trim(); + if (rawDestination.length === 0 || isRemoteProjectCloning) { + return; + } + + if (isUnsupportedWindowsProjectPath(rawDestination, browseEnvironmentPlatform)) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Clone failed", + description: "Windows-style paths are only supported on Windows.", + }), + ); + return; + } + + if (isExplicitRelativeProjectPath(rawDestination) && !currentProjectCwdForBrowse) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Clone failed", + description: "Relative paths require an active project.", + }), + ); + return; + } + + const destinationPath = resolveProjectPathForDispatch( + rawDestination, + currentProjectCwdForBrowse, + ); + if (destinationPath.length === 0) { + return; + } + + setIsRemoteProjectCloning(true); + try { + const result = await api.sourceControl.cloneRepository({ + remoteUrl: addProjectCloneFlow.remoteUrl, + destinationPath, + }); + await handleAddProject(result.cwd); + } catch (error) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Clone failed", + description: errorMessage(error), + }), + ); + } finally { + setIsRemoteProjectCloning(false); + } + } + function browseTo(name: string): void { const nextQuery = appendBrowsePathSegment(query, name); setHighlightedItemValue(null); @@ -858,13 +1228,38 @@ function OpenCommandPaletteDialog() { browseUp, browseTo, }); + const cloneDestinationBrowseGroups = useMemo( + () => + browseGroups.map((group) => + group.value === "directories" ? { ...group, label: "Select where to clone" } : group, + ), + [browseGroups], + ); + + const remoteProjectContext = useMemo(() => { + if (addProjectCloneFlow?.step !== "confirm") { + return null; + } - let displayedGroups = filteredGroups; - if (isBrowsing) { + return { + title: addProjectCloneFlow.repository?.nameWithOwner ?? addProjectCloneFlow.repositoryInput, + description: addProjectCloneFlow.repository?.url ?? addProjectCloneFlow.remoteUrl, + icon: remoteProjectSourceIcon(addProjectCloneFlow.source, ITEM_ICON_CLASS), + }; + }, [addProjectCloneFlow]); + + let displayedGroups: CommandPaletteView["groups"] = filteredGroups; + if (addProjectCloneFlow?.step === "repository") { + displayedGroups = []; + } else if (addProjectCloneFlow?.step === "confirm") { + displayedGroups = relativePathNeedsActiveProject ? [] : cloneDestinationBrowseGroups; + } else if (isBrowsing) { displayedGroups = relativePathNeedsActiveProject ? [] : browseGroups; } - const inputPlaceholder = getCommandPaletteInputPlaceholder(paletteMode); + const inputPlaceholder = + remoteProjectInputPlaceholder(addProjectCloneFlow) ?? + getCommandPaletteInputPlaceholder(paletteMode); const isSubmenu = paletteMode === "submenu" || paletteMode === "submenu-browse"; const hasHighlightedBrowseItem = highlightedItemValue?.startsWith("browse:") ?? false; const canSubmitBrowsePath = isBrowsing && !relativePathNeedsActiveProject; @@ -876,8 +1271,25 @@ function OpenCommandPaletteDialog() { (hasTrailingPathSeparator(query) ? !browseResult : exactBrowseEntry === null); const useMetaForMod = isMacPlatform(navigator.platform); const submitModifierLabel = useMetaForMod ? "\u2318" : "Ctrl"; - const submitActionLabel = willCreateProjectPath ? "Create & Add" : "Add"; + const isCloneDestinationStep = addProjectCloneFlow?.step === "confirm"; + const submitActionLabel = isCloneDestinationStep + ? willCreateProjectPath + ? "Create & Clone" + : "Clone" + : willCreateProjectPath + ? "Create & Add" + : "Add"; const addShortcutLabel = hasHighlightedBrowseItem ? `${submitModifierLabel} Enter` : "Enter"; + const remoteProjectButtonLabel = addProjectCloneFlow + ? addProjectCloneFlow.source === "url" + ? "Continue" + : "Lookup" + : null; + const isRemoteProjectPending = isRemoteProjectLookingUp || isRemoteProjectCloning; + const canSubmitRemoteProjectFlow = + addProjectCloneFlow?.step === "repository" && + query.trim().length > 0 && + !isRemoteProjectPending; const fileManagerName = getLocalFileManagerName(navigator.platform); const canOpenProjectFromFileManager = isBrowsing && @@ -915,6 +1327,12 @@ function OpenCommandPaletteDialog() { } function handleKeyDown(event: KeyboardEvent): void { + if (addProjectCloneFlow?.step === "repository" && event.key === "Enter") { + event.preventDefault(); + void submitAddProjectCloneFlow(); + return; + } + const shouldSubmitBrowsePath = canSubmitBrowsePath && event.key === "Enter" && @@ -922,7 +1340,11 @@ function OpenCommandPaletteDialog() { if (shouldSubmitBrowsePath) { event.preventDefault(); - void handleAddProject(resolvedAddProjectPath); + if (isCloneDestinationStep) { + void submitAddProjectCloneFlow(resolvedAddProjectPath); + } else { + void handleAddProject(resolvedAddProjectPath); + } return; } @@ -999,9 +1421,9 @@ function OpenCommandPaletteDialog() { }} > { setHighlightedItemValue(typeof value === "string" ? value : null); @@ -1011,7 +1433,15 @@ function OpenCommandPaletteDialog() { >
- {isBrowsing ? ( + {addProjectCloneFlow?.step === "repository" ? ( + + ) : isBrowsing ? (
+ {remoteProjectContext ? ( +
+
+ Repository +
+
+ {remoteProjectContext.icon} + + + {remoteProjectContext.title} + + + {remoteProjectContext.description} + + +
+
+ ) : null}
@@ -1092,7 +1579,14 @@ function OpenCommandPaletteDialog() { Navigate - {!canSubmitBrowsePath || hasHighlightedBrowseItem ? ( + {addProjectCloneFlow?.step === "repository" ? ( + + Enter + + {remoteProjectButtonLabel ?? "Continue"} + + + ) : !canSubmitBrowsePath || hasHighlightedBrowseItem ? ( Enter Select diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index ac026e36b0..7950753330 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -730,7 +730,7 @@ describe("when: ref has no upstream configured", () => { }); }); - it("resolveQuickAction disables push-and-pr flows when no origin remote exists", () => { + it("resolveQuickAction publishes when no origin remote exists", () => { const quick = resolveQuickAction( status({ hasUpstream: false, @@ -742,10 +742,9 @@ describe("when: ref has no upstream configured", () => { false, ); assert.deepEqual(quick, { - kind: "show_hint", - label: "Push", - hint: 'Add an "origin" remote before pushing or creating a pull request.', - disabled: true, + kind: "open_publish", + label: "Publish repository", + disabled: false, }); }); @@ -779,7 +778,7 @@ describe("when: ref has no upstream configured", () => { ]); }); - it("buildMenuItems disables push and create PR when no origin remote exists", () => { + it("buildMenuItems hides push and create PR when no origin remote exists", () => { const items = buildMenuItems( status({ hasUpstream: false, pr: null, aheadCount: 2 }), false, @@ -794,22 +793,6 @@ describe("when: ref has no upstream configured", () => { kind: "open_dialog", dialogAction: "commit", }, - { - id: "push", - label: "Push", - disabled: true, - icon: "push", - kind: "open_dialog", - dialogAction: "push", - }, - { - id: "pr", - label: "Create PR", - disabled: true, - icon: "pr", - kind: "open_dialog", - dialogAction: "create_pr", - }, ]); }); diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 6ce9eb9ed3..3f6bae61cd 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -26,7 +26,7 @@ export interface GitActionMenuItem { export interface GitQuickAction { label: string; disabled: boolean; - kind: "run_action" | "run_pull" | "open_pr" | "show_hint"; + kind: "run_action" | "run_pull" | "open_pr" | "open_publish" | "show_hint"; action?: GitStackedAction; hint?: string; } @@ -122,15 +122,21 @@ export function buildMenuItems( (gitStatus.hasUpstream || canPushWithoutUpstream); const canOpenPr = !isBusy && hasOpenPr; + const commitItem: GitActionMenuItem = { + id: "commit", + label: "Commit", + disabled: !canCommit, + icon: "commit", + kind: "open_dialog", + dialogAction: "commit", + }; + + if (!hasPrimaryRemote) { + return [commitItem]; + } + return [ - { - id: "commit", - label: "Commit", - disabled: !canCommit, - icon: "commit", - kind: "open_dialog", - dialogAction: "commit", - }, + commitItem, { id: "push", label: "Push", @@ -216,10 +222,9 @@ export function resolveQuickAction( return { label: `View ${terminology.shortLabel}`, disabled: false, kind: "open_pr" }; } return { - label: "Push", - disabled: true, - kind: "show_hint", - hint: `Add an "origin" remote before pushing or creating a ${terminology.singular}.`, + label: "Publish repository", + disabled: false, + kind: "open_publish", }; } if (!isAhead) { diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index b4fae35929..7c466a10ce 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -3,11 +3,30 @@ import type { GitActionProgressEvent, GitRunStackedActionResult, GitStackedAction, + SourceControlCloneProtocol, + SourceControlProviderKind, + SourceControlPublishRepositoryResult, + SourceControlRepositoryVisibility, VcsStatusResult, } from "@t3tools/contracts"; import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query"; +import { Option } from "effect"; import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; -import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; +import { + CheckIcon, + ChevronDownIcon, + CloudUploadIcon, + ExternalLinkIcon, + GitCommitIcon, + InfoIcon, + LockIcon, + GlobeIcon, +} from "lucide-react"; +import { Radio as RadioPrimitive } from "@base-ui/react/radio"; +import { GitHubIcon, GitLabIcon } from "~/components/Icons"; +import { RadioGroup } from "~/components/ui/radio-group"; +import { Spinner } from "~/components/ui/spinner"; +import { cn } from "~/lib/utils"; import { buildGitActionProgressStages, buildMenuItems, @@ -33,9 +52,18 @@ import { DialogTitle, } from "~/components/ui/dialog"; import { Group, GroupSeparator } from "~/components/ui/group"; +import { Input } from "~/components/ui/input"; import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { ScrollArea } from "~/components/ui/scroll-area"; +import { + Select, + SelectGroup, + SelectItem, + SelectPopup, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; import { Textarea } from "~/components/ui/textarea"; import { stackedThreadToast, toastManager, type ThreadToastData } from "~/components/ui/toast"; import { openInPreferredEditor } from "~/editorPreferences"; @@ -44,8 +72,10 @@ import { gitMutationKeys, gitPullMutationOptions, gitRunStackedActionMutationOptions, + sourceControlPublishRepositoryMutationOptions, } from "~/lib/gitReactQuery"; import { refreshGitStatus, useGitStatus } from "~/lib/gitStatusState"; +import { useSourceControlDiscovery } from "~/lib/sourceControlDiscoveryState"; import { newCommandId, randomUUID } from "~/lib/utils"; import { resolvePathLinkTarget } from "~/terminal-links"; import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; @@ -70,6 +100,8 @@ interface PendingDefaultBranchAction { filePaths?: string[]; } +type PublishProviderKind = Extract; + type GitActionToastId = ReturnType; interface ActiveGitActionProgress { @@ -211,6 +243,7 @@ function GitQuickActionIcon({ }) { const iconClassName = "size-3.5"; if (quickAction.kind === "open_pr") return ; + if (quickAction.kind === "open_publish") return ; if (quickAction.kind === "run_pull") return ; if (quickAction.kind === "run_action") { if (quickAction.action === "commit") return ; @@ -252,8 +285,35 @@ export default function GitActionsControl({ const [dialogCommitMessage, setDialogCommitMessage] = useState(""); const [excludedFiles, setExcludedFiles] = useState>(new Set()); const [isEditingFiles, setIsEditingFiles] = useState(false); + const [isPublishDialogOpen, setIsPublishDialogOpen] = useState(false); + const [publishProvider, setPublishProvider] = useState("github"); + const [publishRepository, setPublishRepository] = useState(""); + const [publishVisibility, setPublishVisibility] = + useState("private"); + const [publishRemoteName, setPublishRemoteName] = useState("origin"); + const [publishProtocol, setPublishProtocol] = useState("ssh"); + const [publishWizardStep, setPublishWizardStep] = useState(0); + const [publishAdvancedOpen, setPublishAdvancedOpen] = useState(false); + const [publishError, setPublishError] = useState(null); + const [publishResult, setPublishResult] = useState( + null, + ); + const [hasUserEditedPublishRepository, setHasUserEditedPublishRepository] = useState(false); const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = useState(null); + const sourceControlDiscovery = useSourceControlDiscovery(); + const publishAccountByProvider = useMemo(() => { + const accounts: Record = { github: null, gitlab: null }; + for (const provider of sourceControlDiscovery.data?.sourceControlProviders ?? []) { + if (provider.kind === "github" || provider.kind === "gitlab") { + accounts[provider.kind] = Option.getOrNull(provider.auth.account); + } + } + return accounts; + }, [sourceControlDiscovery.data]); + const publishRepositoryPrefill = publishAccountByProvider[publishProvider] + ? `${publishAccountByProvider[publishProvider]}/` + : ""; const activeGitActionProgressRef = useRef(null); let runGitActionWithToast: (input: RunGitActionWithToastInput) => Promise; @@ -365,6 +425,13 @@ export default function GitActionsControl({ const pullMutation = useMutation( gitPullMutationOptions({ environmentId: activeEnvironmentId, cwd: gitCwd, queryClient }), ); + const publishRepositoryMutation = useMutation( + sourceControlPublishRepositoryMutationOptions({ + environmentId: activeEnvironmentId, + cwd: gitCwd, + queryClient, + }), + ); const isRunStackedActionRunning = useIsMutating({ @@ -372,7 +439,11 @@ export default function GitActionsControl({ }) > 0; const isPullRunning = useIsMutating({ mutationKey: gitMutationKeys.pull(activeEnvironmentId, gitCwd) }) > 0; - const isGitActionRunning = isRunStackedActionRunning || isPullRunning; + const isPublishRunning = + useIsMutating({ + mutationKey: gitMutationKeys.publishRepository(activeEnvironmentId, gitCwd), + }) > 0; + const isGitActionRunning = isRunStackedActionRunning || isPullRunning || isPublishRunning; const isSelectingWorktreeBase = !activeServerThread && activeDraftThread?.envMode === "worktree" && @@ -439,6 +510,13 @@ export default function GitActionsControl({ }; }, [updateActiveProgressToast]); + useEffect(() => { + if (!isPublishDialogOpen || hasUserEditedPublishRepository) { + return; + } + setPublishRepository(publishRepositoryPrefill); + }, [isPublishDialogOpen, hasUserEditedPublishRepository, publishRepositoryPrefill]); + useEffect(() => { if (gitCwd === null) { return; @@ -785,6 +863,10 @@ export default function GitActionsControl({ void openExistingPr(); return; } + if (quickAction.kind === "open_publish") { + setIsPublishDialogOpen(true); + return; + } if (quickAction.kind === "run_pull") { const promise = pullMutation.mutateAsync(); void toastManager.promise< @@ -882,6 +964,45 @@ export default function GitActionsControl({ [gitCwd, threadToastData], ); + const canPublishRepository = isRepo && gitStatusForActions !== null && !hasPrimaryRemote; + const canSubmitPublishRepository = (() => { + if (publishRepositoryMutation.isPending) return false; + const [owner, ...rest] = publishRepository.trim().split("/"); + const name = rest.join("/").trim(); + return owner.trim().length > 0 && name.length > 0; + })(); + + const publishHost = publishProvider === "github" ? "github.com" : "gitlab.com"; + const publishPathPlaceholder = publishProvider === "github" ? "owner/repo" : "group/project"; + const publishProviderLabel = publishProvider === "github" ? "GitHub" : "GitLab"; + + const submitPublishRepository = () => { + if (!canSubmitPublishRepository) { + return; + } + + setPublishError(null); + + void publishRepositoryMutation + .mutateAsync({ + provider: publishProvider, + repository: publishRepository.trim(), + visibility: publishVisibility, + remoteName: publishRemoteName.trim() || "origin", + protocol: publishProtocol, + }) + .then((result) => { + setPublishResult(result); + setPublishWizardStep(2); + void refreshGitStatus({ environmentId: activeEnvironmentId, cwd: gitCwd }).catch( + () => undefined, + ); + }) + .catch((err: unknown) => { + setPublishError(err instanceof Error ? err.message : "An error occurred."); + }); + }; + if (!gitCwd) return null; return ( @@ -996,6 +1117,17 @@ export default function GitActionsControl({ ); })} + {canPublishRepository ? ( + { + setIsPublishDialogOpen(true); + }} + > + + Publish repository... + + ) : null} {gitStatusForActions?.refName === null && (

Detached HEAD: create and checkout a refName to enable push and pull request @@ -1188,6 +1320,424 @@ export default function GitActionsControl({ +

{ + setIsPublishDialogOpen(open); + if (!open) { + setPublishRemoteName("origin"); + setPublishRepository(""); + setHasUserEditedPublishRepository(false); + setPublishWizardStep(0); + setPublishAdvancedOpen(false); + setPublishError(null); + setPublishResult(null); + } + }} + > + + + Publish repository + + Pick where to host it, then point us at a repo to push to. + +
+ {(["Provider", "Repository", "Summary"] as const).map((label, index) => { + const summary = + index === 0 && publishWizardStep > 0 + ? publishProviderLabel + : index === 1 && publishWizardStep > 1 && publishResult + ? publishResult.repository.nameWithOwner + : null; + const isComplete = index < publishWizardStep; + const isDoneStep = index === 2; + // The summary step is only reachable via a successful submit; + // disallow user-driven navigation to or from it. + const isClickable = + publishWizardStep !== 2 && !isDoneStep && index <= publishWizardStep; + return ( + + ); + })} +
+
+ + {/* Step 1: Provider */} + {publishWizardStep === 0 && ( + +
+ + Provider + + setPublishProvider(value as PublishProviderKind)} + aria-labelledby="publish-provider-cards-label" + className="grid grid-cols-2 gap-2.5" + > + {( + [ + { + value: "github" as const, + label: "GitHub", + description: "github.com", + Icon: GitHubIcon, + }, + { + value: "gitlab" as const, + label: "GitLab", + description: "gitlab.com", + Icon: GitLabIcon, + }, + ] + ).map((option) => { + const isSelected = publishProvider === option.value; + return ( + + + + + {option.label} + + + {option.description} + + + + ); + })} + +
+
+ )} + {publishWizardStep === 1 && ( + +
+ +
+ + {publishProvider === "github" ? ( + + ) : ( + + )} + {publishHost}/ + + { + setPublishRepository(event.target.value); + setHasUserEditedPublishRepository(true); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + submitPublishRepository(); + } + }} + placeholder={publishPathPlaceholder} + disabled={publishRepositoryMutation.isPending} + className="w-full bg-transparent px-3 py-2 font-mono text-sm placeholder:text-muted-foreground/60 focus:outline-none" + /> +
+
+
+ + Visibility + + + setPublishVisibility(value as SourceControlRepositoryVisibility) + } + aria-labelledby="publish-visibility-cards-label" + disabled={publishRepositoryMutation.isPending} + className="grid grid-cols-2 gap-2.5" + > + {( + [ + { + value: "private" as const, + label: "Private", + description: "Only invited people", + Icon: LockIcon, + }, + { + value: "public" as const, + label: "Public", + description: "Anyone on the web", + Icon: GlobeIcon, + }, + ] + ).map((option) => { + const isSelected = publishVisibility === option.value; + return ( + + + + + {option.label} + + + {option.description} + + + + ); + })} + +
+
+ + {publishAdvancedOpen && ( +
+ +
+ + Protocol + + + setPublishProtocol(value as SourceControlCloneProtocol) + } + aria-labelledby="publish-protocol-label" + disabled={publishRepositoryMutation.isPending} + className="grid grid-cols-2 gap-2" + > + {(["ssh", "https"] as const).map((value) => { + const isSelected = publishProtocol === value; + return ( + + {value === "ssh" ? "SSH" : "HTTPS"} + + ); + })} + +
+
+ )} +
+ {publishRepositoryMutation.isPending && ( +
+ + Publishing repository to {publishProviderLabel}... +
+ )} + {publishError && !publishRepositoryMutation.isPending && ( +
+

Publish failed

+

{publishError}

+
+ )} +
+ )} + {publishWizardStep === 2 && publishResult && ( + +
+ + + +

+ {publishResult.status === "pushed" + ? "Repository published" + : "Repository created"} +

+

+ {publishResult.status === "pushed" + ? `${publishResult.branch} is now live on ${publishProviderLabel}.` + : `Remote "${publishResult.remoteName}" is set up. Make a commit and push it to share your code.`} +

+
+
+ {publishProvider === "github" ? ( + + ) : ( + + )} + + {publishResult.repository.nameWithOwner} + +
+ +
+ )} + + + {publishWizardStep === 2 ? ( + + ) : ( + <> + + {publishWizardStep < 1 ? ( + + ) : ( + + )} + + )} + +
+
+ { diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 9e7d9b032b..947d071062 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -57,6 +57,8 @@ const VCS_ICONS: Partial> = { jj: JujutsuIcon, }; +const SOURCE_CONTROL_SKELETON_ROWS = ["primary", "secondary"] as const; + function optionLabel(value: Option.Option): string | null { return Option.getOrNull(value); } @@ -157,6 +159,11 @@ function itemSummary({ ); } + + if (!item.executable) { + return {item.installHint}; + } + if (auth.status === "unauthenticated") { return Sign in with the {item.executable} CLI to enable pull request actions.; } @@ -232,8 +239,8 @@ function SourceControlSectionSkeleton({ }) { return ( - {Array.from({ length: 2 }, (_, index) => ( -
+ {SOURCE_CONTROL_SKELETON_ROWS.map((row) => ( +
diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts index d2c84b1df1..335d68d372 100644 --- a/apps/web/src/environmentApi.ts +++ b/apps/web/src/environmentApi.ts @@ -23,6 +23,11 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { filesystem: { browse: rpcClient.filesystem.browse, }, + sourceControl: { + lookupRepository: rpcClient.sourceControl.lookupRepository, + cloneRepository: rpcClient.sourceControl.cloneRepository, + publishRepository: rpcClient.sourceControl.publishRepository, + }, vcs: { pull: rpcClient.vcs.pull, refreshStatus: rpcClient.vcs.refreshStatus, diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index 44dd705ceb..9db96420aa 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -2,6 +2,7 @@ import { type EnvironmentId, type GitActionProgressEvent, type GitStackedAction, + type SourceControlPublishRepositoryInput, type ThreadId, } from "@t3tools/contracts"; import { @@ -36,6 +37,8 @@ export const gitMutationKeys = { ["git", "mutation", "pull", environmentId ?? null, cwd] as const, preparePullRequestThread: (environmentId: EnvironmentId | null, cwd: string | null) => ["git", "mutation", "prepare-pull-request-thread", environmentId ?? null, cwd] as const, + publishRepository: (environmentId: EnvironmentId | null, cwd: string | null) => + ["git", "mutation", "publish-repository", environmentId ?? null, cwd] as const, }; export function invalidateGitQueries( @@ -228,6 +231,26 @@ export function gitPullMutationOptions(input: { }); } +export function sourceControlPublishRepositoryMutationOptions(input: { + environmentId: EnvironmentId | null; + cwd: string | null; + queryClient: QueryClient; +}) { + return mutationOptions({ + mutationKey: gitMutationKeys.publishRepository(input.environmentId, input.cwd), + mutationFn: async (args: Omit) => { + if (!input.cwd || !input.environmentId) { + throw new Error("Repository publishing is unavailable."); + } + const api = ensureEnvironmentApi(input.environmentId); + return api.sourceControl.publishRepository({ cwd: input.cwd, ...args }); + }, + onSuccess: async () => { + await invalidateGitBranchQueries(input.queryClient, input.environmentId, input.cwd); + }, + }); +} + /** * @deprecated Use a VCS-named mutation helper once the UI naming migration lands. */ diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index b627286199..8810cfd980 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -56,6 +56,11 @@ const rpcClientMock = { filesystem: { browse: vi.fn(), }, + sourceControl: { + lookupRepository: vi.fn(), + cloneRepository: vi.fn(), + publishRepository: vi.fn(), + }, shell: { openInEditor: vi.fn(), }, diff --git a/apps/web/src/rpc/wsRpcClient.ts b/apps/web/src/rpc/wsRpcClient.ts index 39abcb2c09..b2f57fb0a9 100644 --- a/apps/web/src/rpc/wsRpcClient.ts +++ b/apps/web/src/rpc/wsRpcClient.ts @@ -71,6 +71,11 @@ export interface WsRpcClient { readonly filesystem: { readonly browse: RpcUnaryMethod; }; + readonly sourceControl: { + readonly lookupRepository: RpcUnaryMethod; + readonly cloneRepository: RpcUnaryMethod; + readonly publishRepository: RpcUnaryMethod; + }; readonly shell: { readonly openInEditor: (input: { readonly cwd: Parameters[0]; @@ -165,6 +170,14 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { filesystem: { browse: (input) => transport.request((client) => client[WS_METHODS.filesystemBrowse](input)), }, + sourceControl: { + lookupRepository: (input) => + transport.request((client) => client[WS_METHODS.sourceControlLookupRepository](input)), + cloneRepository: (input) => + transport.request((client) => client[WS_METHODS.sourceControlCloneRepository](input)), + publishRepository: (input) => + transport.request((client) => client[WS_METHODS.sourceControlPublishRepository](input)), + }, shell: { openInEditor: (input) => transport.request((client) => client[WS_METHODS.shellOpenInEditor](input)), diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 35539a86e3..010f51d876 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -55,7 +55,15 @@ import type { import type { EnvironmentId } from "./baseSchemas.ts"; import { EditorId } from "./editor.ts"; import { ServerSettings, type ClientSettings, type ServerSettingsPatch } from "./settings.ts"; -import type { SourceControlDiscoveryResult } from "./sourceControl.ts"; +import type { + SourceControlCloneRepositoryInput, + SourceControlCloneRepositoryResult, + SourceControlDiscoveryResult, + SourceControlPublishRepositoryInput, + SourceControlPublishRepositoryResult, + SourceControlRepositoryInfo, + SourceControlRepositoryLookupInput, +} from "./sourceControl.ts"; export interface ContextMenuItem { id: T; @@ -258,6 +266,17 @@ export interface EnvironmentApi { filesystem: { browse: (input: FilesystemBrowseInput) => Promise; }; + sourceControl: { + lookupRepository: ( + input: SourceControlRepositoryLookupInput, + ) => Promise; + cloneRepository: ( + input: SourceControlCloneRepositoryInput, + ) => Promise; + publishRepository: ( + input: SourceControlPublishRepositoryInput, + ) => Promise; + }; vcs: { listRefs: (input: VcsListRefsInput) => Promise; createWorktree: (input: VcsCreateWorktreeInput) => Promise; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index f2da90e190..4167bd0a76 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -77,7 +77,16 @@ import { ServerUpsertKeybindingResult, } from "./server.ts"; import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; -import { SourceControlDiscoveryResult } from "./sourceControl.ts"; +import { + SourceControlCloneRepositoryInput, + SourceControlCloneRepositoryResult, + SourceControlDiscoveryResult, + SourceControlPublishRepositoryInput, + SourceControlPublishRepositoryResult, + SourceControlRepositoryError, + SourceControlRepositoryInfo, + SourceControlRepositoryLookupInput, +} from "./sourceControl.ts"; import { VcsError } from "./vcs.ts"; export const WS_METHODS = { @@ -125,6 +134,11 @@ export const WS_METHODS = { serverUpdateSettings: "server.updateSettings", serverDiscoverSourceControl: "server.discoverSourceControl", + // Source control methods + sourceControlLookupRepository: "sourceControl.lookupRepository", + sourceControlCloneRepository: "sourceControl.cloneRepository", + sourceControlPublishRepository: "sourceControl.publishRepository", + // Streaming subscriptions subscribeVcsStatus: "subscribeVcsStatus", subscribeTerminalEvents: "subscribeTerminalEvents", @@ -175,6 +189,30 @@ export const WsServerDiscoverSourceControlRpc = Rpc.make(WS_METHODS.serverDiscov success: SourceControlDiscoveryResult, }); +export const WsSourceControlLookupRepositoryRpc = Rpc.make( + WS_METHODS.sourceControlLookupRepository, + { + payload: SourceControlRepositoryLookupInput, + success: SourceControlRepositoryInfo, + error: SourceControlRepositoryError, + }, +); + +export const WsSourceControlCloneRepositoryRpc = Rpc.make(WS_METHODS.sourceControlCloneRepository, { + payload: SourceControlCloneRepositoryInput, + success: SourceControlCloneRepositoryResult, + error: SourceControlRepositoryError, +}); + +export const WsSourceControlPublishRepositoryRpc = Rpc.make( + WS_METHODS.sourceControlPublishRepository, + { + payload: SourceControlPublishRepositoryInput, + success: SourceControlPublishRepositoryResult, + error: SourceControlRepositoryError, + }, +); + export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntries, { payload: ProjectSearchEntriesInput, success: ProjectSearchEntriesResult, @@ -381,6 +419,9 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetSettingsRpc, WsServerUpdateSettingsRpc, WsServerDiscoverSourceControlRpc, + WsSourceControlLookupRepositoryRpc, + WsSourceControlCloneRepositoryRpc, + WsSourceControlPublishRepositoryRpc, WsProjectsSearchEntriesRpc, WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index 5776e84ff7..acc4a733ae 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -43,6 +43,66 @@ export const SourceControlRepositoryCloneUrls = Schema.Struct({ }); export type SourceControlRepositoryCloneUrls = typeof SourceControlRepositoryCloneUrls.Type; +export const SourceControlRepositoryVisibility = Schema.Literals(["private", "public"]); +export type SourceControlRepositoryVisibility = typeof SourceControlRepositoryVisibility.Type; + +export const SourceControlCloneProtocol = Schema.Literals(["auto", "ssh", "https"]); +export type SourceControlCloneProtocol = typeof SourceControlCloneProtocol.Type; + +export const SourceControlRepositoryInfo = Schema.Struct({ + provider: SourceControlProviderKind, + nameWithOwner: TrimmedNonEmptyString, + url: TrimmedNonEmptyString, + sshUrl: TrimmedNonEmptyString, +}); +export type SourceControlRepositoryInfo = typeof SourceControlRepositoryInfo.Type; + +export const SourceControlRepositoryLookupInput = Schema.Struct({ + provider: SourceControlProviderKind, + repository: TrimmedNonEmptyString, + cwd: Schema.optional(TrimmedNonEmptyString), +}); +export type SourceControlRepositoryLookupInput = typeof SourceControlRepositoryLookupInput.Type; + +export const SourceControlCloneRepositoryInput = Schema.Struct({ + provider: Schema.optional(SourceControlProviderKind), + repository: Schema.optional(TrimmedNonEmptyString), + remoteUrl: Schema.optional(TrimmedNonEmptyString), + destinationPath: TrimmedNonEmptyString, + protocol: Schema.optional(SourceControlCloneProtocol), +}); +export type SourceControlCloneRepositoryInput = typeof SourceControlCloneRepositoryInput.Type; + +export const SourceControlCloneRepositoryResult = Schema.Struct({ + cwd: TrimmedNonEmptyString, + remoteUrl: TrimmedNonEmptyString, + repository: Schema.NullOr(SourceControlRepositoryInfo), +}); +export type SourceControlCloneRepositoryResult = typeof SourceControlCloneRepositoryResult.Type; + +export const SourceControlPublishRepositoryInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + provider: SourceControlProviderKind, + repository: TrimmedNonEmptyString, + visibility: SourceControlRepositoryVisibility, + remoteName: Schema.optional(TrimmedNonEmptyString), + protocol: Schema.optional(SourceControlCloneProtocol), +}); +export type SourceControlPublishRepositoryInput = typeof SourceControlPublishRepositoryInput.Type; + +export const SourceControlPublishStatus = Schema.Literals(["pushed", "remote_added"]); +export type SourceControlPublishStatus = typeof SourceControlPublishStatus.Type; + +export const SourceControlPublishRepositoryResult = Schema.Struct({ + repository: SourceControlRepositoryInfo, + remoteName: TrimmedNonEmptyString, + remoteUrl: TrimmedNonEmptyString, + branch: TrimmedNonEmptyString, + upstreamBranch: Schema.optional(TrimmedNonEmptyString), + status: SourceControlPublishStatus, +}); +export type SourceControlPublishRepositoryResult = typeof SourceControlPublishRepositoryResult.Type; + export const SourceControlDiscoveryStatus = Schema.Literals(["available", "missing"]); export type SourceControlDiscoveryStatus = typeof SourceControlDiscoveryStatus.Type; @@ -63,7 +123,7 @@ export type SourceControlProviderAuth = typeof SourceControlProviderAuth.Type; const SourceControlDiscoveryItemFields = { label: TrimmedNonEmptyString, - executable: TrimmedNonEmptyString, + executable: Schema.optional(TrimmedNonEmptyString), implemented: Schema.Boolean, status: SourceControlDiscoveryStatus, version: Schema.Option(TrimmedNonEmptyString), @@ -109,3 +169,17 @@ export class SourceControlProviderError extends Schema.TaggedErrorClass()( + "SourceControlRepositoryError", + { + provider: SourceControlProviderKind, + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect), + }, +) { + override get message(): string { + return `Source control repository operation ${this.operation} failed for ${this.provider}: ${this.detail}`; + } +} From 27697abbad7306c5b93f2aeedd22ec9fd775c799 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 3 May 2026 17:31:48 -0700 Subject: [PATCH 2/5] Push publishes to the selected remote - Pass the ensured remote name through publish flow - Push directly to that remote when publishing a repository - Add coverage for remote-specific push behavior --- .../SourceControlRepositoryService.test.ts | 50 ++- .../SourceControlRepositoryService.ts | 2 +- apps/server/src/vcs/GitVcsDriver.ts | 1 + apps/server/src/vcs/GitVcsDriverCore.test.ts | 36 ++ apps/server/src/vcs/GitVcsDriverCore.ts | 19 +- apps/web/src/components/GitActionsControl.tsx | 392 +++++++++--------- 6 files changed, 293 insertions(+), 207 deletions(-) diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 537481999e..56ab64b54b 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -3,10 +3,7 @@ import { assert, it } from "@effect/vitest"; import { Effect, FileSystem, Layer } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { - GitCommandError, - type SourceControlProviderError, -} from "@t3tools/contracts"; +import { GitCommandError, type SourceControlProviderError } from "@t3tools/contracts"; import { ServerConfig } from "../config.ts"; import { @@ -156,7 +153,7 @@ it.effect("clones a looked-up repository into the requested destination", () => it.effect("publishes by creating the repository, adding a remote, and pushing upstream", () => { const createCalls: Array<{ cwd: string; repository: string; visibility: string }> = []; const remoteCalls: Array<{ cwd: string; preferredName: string; url: string }> = []; - const pushCalls: string[] = []; + const pushCalls: Array<{ cwd: string; remoteName: string | null | undefined }> = []; const provider = makeProvider({ createRepository: (input) => Effect.sync(() => { @@ -194,7 +191,7 @@ it.effect("publishes by creating the repository, adding a remote, and pushing up assert.deepStrictEqual(remoteCalls, [ { cwd: "/workspace", preferredName: "origin", url: CLONE_URLS.sshUrl }, ]); - assert.deepStrictEqual(pushCalls, ["/workspace"]); + assert.deepStrictEqual(pushCalls, [{ cwd: "/workspace", remoteName: "origin" }]); }).pipe( Effect.provide( makeLayer({ @@ -205,9 +202,9 @@ it.effect("publishes by creating the repository, adding a remote, and pushing up remoteCalls.push(input); return "origin"; }), - pushCurrentBranch: (cwd) => + pushCurrentBranch: (cwd, _fallbackBranch, options) => Effect.sync(() => { - pushCalls.push(cwd); + pushCalls.push({ cwd, remoteName: options?.remoteName }); return { status: "pushed" as const, branch: "feature/remote-v1", @@ -221,6 +218,43 @@ it.effect("publishes by creating the repository, adding a remote, and pushing up ); }); +it.effect("publishes to the remote name returned by ensureRemote", () => { + const pushCalls: Array<{ cwd: string; remoteName: string | null | undefined }> = []; + + return Effect.gen(function* () { + const service = yield* SourceControlRepositoryService; + const result = yield* service.publishRepository({ + cwd: "/workspace", + provider: "github", + repository: "octocat/t3code", + visibility: "private", + remoteName: "origin", + protocol: "ssh", + }); + + assert.equal(result.remoteName, "origin-1"); + assert.deepStrictEqual(pushCalls, [{ cwd: "/workspace", remoteName: "origin-1" }]); + }).pipe( + Effect.provide( + makeLayer({ + git: { + ensureRemote: () => Effect.succeed("origin-1"), + pushCurrentBranch: (cwd, _fallbackBranch, options) => + Effect.sync(() => { + pushCalls.push({ cwd, remoteName: options?.remoteName }); + return { + status: "pushed" as const, + branch: "feature/remote-v1", + upstreamBranch: `${options?.remoteName ?? "missing"}/feature/remote-v1`, + setUpstream: true, + }; + }), + }, + }), + ), + ); +}); + it.effect("publish succeeds with status remote_added when the local repo has no commits", () => { let pushCalls = 0; return Effect.gen(function* () { diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index acd69ea77a..bfd19f2cdf 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -292,7 +292,7 @@ export const make = Effect.fn("makeSourceControlRepositoryService")(function* () }; } - const pushResult = yield* git.pushCurrentBranch(input.cwd, null); + const pushResult = yield* git.pushCurrentBranch(input.cwd, null, { remoteName }); return { repository: toRepositoryInfo(providerKind, urls), diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 661490b71a..727f7e8650 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -165,6 +165,7 @@ export interface GitVcsDriverShape { readonly pushCurrentBranch: ( cwd: string, fallbackBranch: string | null, + options?: { readonly remoteName?: string | null }, ) => Effect.Effect; readonly readRangeContext: ( cwd: string, diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index aa15bff76b..25e9071b83 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -326,5 +326,41 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { assert.notEqual(badBranch.exitCode, 0); }), ); + + it.effect("pushes to the requested remote instead of the primary remote", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const originRemote = yield* makeTmpDir("git-origin-remote-"); + const publishRemote = yield* makeTmpDir("git-publish-remote-"); + yield* initRepoWithCommit(cwd); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* git(cwd, ["branch", "-M", "main"]); + yield* git(originRemote, ["init", "--bare"]); + yield* git(publishRemote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", originRemote]); + yield* git(cwd, ["remote", "add", "origin-1", publishRemote]); + + const pushed = yield* driver.pushCurrentBranch(cwd, null, { remoteName: "origin-1" }); + + assert.deepInclude(pushed, { + status: "pushed", + branch: "main", + upstreamBranch: "origin-1/main", + setUpstream: true, + }); + assert.equal( + yield* git(publishRemote, ["log", "-1", "--pretty=%s", "main"]), + "initial commit", + ); + const originMain = yield* driver.execute({ + operation: "GitVcsDriver.test.originMainMissing", + cwd: originRemote, + args: ["show-ref", "--verify", "--quiet", "refs/heads/main"], + allowNonZeroExit: true, + timeoutMs: 10_000, + }); + assert.notEqual(originMain.exitCode, 0); + }), + ); }); }); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index dd71d53f90..707461cebc 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1431,7 +1431,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); const pushCurrentBranch: GitVcsDriverShape["pushCurrentBranch"] = Effect.fn("pushCurrentBranch")( - function* (cwd, fallbackBranch) { + function* (cwd, fallbackBranch, options) { const details = yield* statusDetails(cwd); const branch = details.branch ?? fallbackBranch; if (!branch) { @@ -1443,6 +1443,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); } + const requestedRemoteName = options?.remoteName?.trim() || null; + if (requestedRemoteName) { + const publishBranch = yield* resolvePublishBranchName(cwd, branch); + yield* runGit("GitVcsDriver.pushCurrentBranch.pushWithRequestedRemote", cwd, [ + "push", + "-u", + requestedRemoteName, + `HEAD:refs/heads/${publishBranch}`, + ]); + return { + status: "pushed" as const, + branch, + upstreamBranch: `${requestedRemoteName}/${publishBranch}`, + setUpstream: true, + }; + } + const hasNoLocalDelta = details.aheadCount === 0 && details.behindCount === 0; if (hasNoLocalDelta) { if (details.hasUpstream) { diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 7c466a10ce..f2977d4c66 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -967,9 +967,11 @@ export default function GitActionsControl({ const canPublishRepository = isRepo && gitStatusForActions !== null && !hasPrimaryRemote; const canSubmitPublishRepository = (() => { if (publishRepositoryMutation.isPending) return false; - const [owner, ...rest] = publishRepository.trim().split("/"); + const repositoryParts = publishRepository.trim().split("/"); + const owner = repositoryParts[0]?.trim() ?? ""; + const rest = repositoryParts.slice(1); const name = rest.join("/").trim(); - return owner.trim().length > 0 && name.length > 0; + return owner.length > 0 && name.length > 0; })(); const publishHost = publishProvider === "github" ? "github.com" : "gitlab.com"; @@ -1413,22 +1415,20 @@ export default function GitActionsControl({ aria-labelledby="publish-provider-cards-label" className="grid grid-cols-2 gap-2.5" > - {( - [ - { - value: "github" as const, - label: "GitHub", - description: "github.com", - Icon: GitHubIcon, - }, - { - value: "gitlab" as const, - label: "GitLab", - description: "gitlab.com", - Icon: GitLabIcon, - }, - ] - ).map((option) => { + {[ + { + value: "github" as const, + label: "GitHub", + description: "github.com", + Icon: GitHubIcon, + }, + { + value: "gitlab" as const, + label: "GitLab", + description: "gitlab.com", + Icon: GitLabIcon, + }, + ].map((option) => { const isSelected = publishProvider === option.value; return ( )} {publishWizardStep === 1 && ( - -
- -
- - {publishProvider === "github" ? ( - - ) : ( - - )} - {publishHost}/ - - { - setPublishRepository(event.target.value); - setHasUserEditedPublishRepository(true); - }} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - submitPublishRepository(); - } - }} - placeholder={publishPathPlaceholder} - disabled={publishRepositoryMutation.isPending} - className="w-full bg-transparent px-3 py-2 font-mono text-sm placeholder:text-muted-foreground/60 focus:outline-none" - /> -
-
-
- - Visibility - - - setPublishVisibility(value as SourceControlRepositoryVisibility) + +
+ +
+ + {publishProvider === "github" ? ( + + ) : ( + + )} + {publishHost}/ + + { + setPublishRepository(event.target.value); + setHasUserEditedPublishRepository(true); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + submitPublishRepository(); } - aria-labelledby="publish-visibility-cards-label" - disabled={publishRepositoryMutation.isPending} - className="grid grid-cols-2 gap-2.5" - > - {( - [ - { - value: "private" as const, - label: "Private", - description: "Only invited people", - Icon: LockIcon, - }, - { - value: "public" as const, - label: "Public", - description: "Anyone on the web", - Icon: GlobeIcon, - }, - ] - ).map((option) => { - const isSelected = publishVisibility === option.value; - return ( - - - - - {option.label} - - - {option.description} - - - - ); - })} - -
-
-
+
+
+ + Visibility + + + setPublishVisibility(value as SourceControlRepositoryVisibility) + } + aria-labelledby="publish-visibility-cards-label" + disabled={publishRepositoryMutation.isPending} + className="grid grid-cols-2 gap-2.5" + > + {[ + { + value: "private" as const, + label: "Private", + description: "Only invited people", + Icon: LockIcon, + }, + { + value: "public" as const, + label: "Public", + description: "Anyone on the web", + Icon: GlobeIcon, + }, + ].map((option) => { + const isSelected = publishVisibility === option.value; + return ( + - Advanced - - {publishAdvancedOpen && ( -
- -
- - Protocol + > + + + + {option.label} - - setPublishProtocol(value as SourceControlCloneProtocol) - } - aria-labelledby="publish-protocol-label" - disabled={publishRepositoryMutation.isPending} - className="grid grid-cols-2 gap-2" - > - {(["ssh", "https"] as const).map((value) => { - const isSelected = publishProtocol === value; - return ( - - {value === "ssh" ? "SSH" : "HTTPS"} - - ); - })} - -
-
+ + {option.description} + + +
+ ); + })} +
+
+
+ + {publishAdvancedOpen && ( +
+ +
+ + Protocol + + + setPublishProtocol(value as SourceControlCloneProtocol) + } + aria-labelledby="publish-protocol-label" + disabled={publishRepositoryMutation.isPending} + className="grid grid-cols-2 gap-2" + > + {(["ssh", "https"] as const).map((value) => { + const isSelected = publishProtocol === value; + return ( + + {value === "ssh" ? "SSH" : "HTTPS"} + + ); + })} +
- )} - +
+ )} +
+ {publishRepositoryMutation.isPending && ( +
+ + Publishing repository to {publishProviderLabel}... +
+ )} + {publishError && !publishRepositoryMutation.isPending && ( +
+

Publish failed

+

{publishError}

+
+ )} +
)} {publishWizardStep === 2 && publishResult && ( From 22470229a988db035c98bd7fb8cc26c2602d5e13 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 3 May 2026 17:41:45 -0700 Subject: [PATCH 3/5] Mock publish repository git mutations - add publishRepository query key mock - add sourceControlPublishRepository mutation options mock Co-authored-by: codex --- apps/web/src/components/GitActionsControl.browser.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/components/GitActionsControl.browser.tsx b/apps/web/src/components/GitActionsControl.browser.tsx index 787e034bd1..a2a801d54a 100644 --- a/apps/web/src/components/GitActionsControl.browser.tsx +++ b/apps/web/src/components/GitActionsControl.browser.tsx @@ -100,12 +100,14 @@ vi.mock("~/editorPreferences", () => ({ vi.mock("~/lib/gitReactQuery", () => ({ gitInitMutationOptions: vi.fn(() => ({ __kind: "init" })), gitMutationKeys: { + publishRepository: vi.fn(() => ["publish-repository"]), pull: vi.fn(() => ["pull"]), runStackedAction: vi.fn(() => ["run-stacked-action"]), }, gitPullMutationOptions: vi.fn(() => ({ __kind: "pull" })), gitRunStackedActionMutationOptions: vi.fn(() => ({ __kind: "run-stacked-action" })), invalidateGitQueries: invalidateGitQueriesSpy, + sourceControlPublishRepositoryMutationOptions: vi.fn(() => ({ __kind: "publish-repository" })), })); vi.mock("~/lib/gitStatusState", () => ({ From 153bb29c8016b8622e67874b1935938d62a74baa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 4 May 2026 00:44:28 +0000 Subject: [PATCH 4/5] fix: use web_url instead of http_url_to_repo for GitLab repository URL The normalizeRepositoryCloneUrls function was using http_url_to_repo (which has a .git suffix) as the url field. This caused the 'Open on GitLab' button to open a .git-suffixed URL and display it in the UI. Use web_url instead, which is the proper browser URL and also works as an HTTPS clone URL. --- apps/server/src/sourceControl/GitLabCli.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index cfc3c1c1b3..6dac7f7eab 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -176,7 +176,7 @@ function normalizeRepositoryCloneUrls( ): GitLabRepositoryCloneUrls { return { nameWithOwner: raw.path_with_namespace, - url: raw.http_url_to_repo || raw.web_url, + url: raw.web_url, sshUrl: raw.ssh_url_to_repo, }; } From df59af7222059416acab4ce12c27833752861ff0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 4 May 2026 01:01:34 +0000 Subject: [PATCH 5/5] Fix GitLab test URL assertions to match normalizeRepositoryCloneUrls using web_url The normalizeRepositoryCloneUrls function was changed to return raw.web_url instead of raw.http_url_to_repo || raw.web_url, but the test assertions still expected the http_url_to_repo value (with .git suffix). Updated both assertions to expect the web_url value (without .git suffix). Applied via @cursor push command --- apps/server/src/sourceControl/GitLabCli.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/sourceControl/GitLabCli.test.ts b/apps/server/src/sourceControl/GitLabCli.test.ts index 5c7ee8d80c..d4e07efb88 100644 --- a/apps/server/src/sourceControl/GitLabCli.test.ts +++ b/apps/server/src/sourceControl/GitLabCli.test.ts @@ -175,7 +175,7 @@ layer("GitLabCli.layer", (it) => { assert.deepStrictEqual(result, { nameWithOwner: "octocat/t3code", - url: "https://gitlab.com/octocat/t3code.git", + url: "https://gitlab.com/octocat/t3code", sshUrl: "git@gitlab.com:octocat/t3code.git", }); }), @@ -243,7 +243,7 @@ layer("GitLabCli.layer", (it) => { assert.deepStrictEqual(result, { nameWithOwner: "octocat/t3code", - url: "https://gitlab.com/octocat/t3code.git", + url: "https://gitlab.com/octocat/t3code", sshUrl: "git@gitlab.com:octocat/t3code.git", }); expect(mockedRun).toHaveBeenNthCalledWith(