From 32b2d15445ab8a53b5510c90af57799452134eb3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 19:18:16 +0000 Subject: [PATCH 1/5] test(gitlab): reproduce #26606 repository lookup ignoring the only signed-in host These tests reproduce #26606: with no glab default host and a single signed-in self-hosted host, getRepositoryCloneUrls runs `glab api` without --hostname, so glab queries gitlab.com instead. Co-Authored-By: Claude Opus 5.5 --- .../src/server/GitLabCli.test.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/packages/source-control-gitlab/src/server/GitLabCli.test.ts b/packages/source-control-gitlab/src/server/GitLabCli.test.ts index a0609421e1222..b345969661b39 100644 --- a/packages/source-control-gitlab/src/server/GitLabCli.test.ts +++ b/packages/source-control-gitlab/src/server/GitLabCli.test.ts @@ -207,6 +207,45 @@ layer("GitLabCli.layer", (it) => { }), ); + it.effect("looks up repositories on the only signed-in host when glab has no default host", () => + Effect.gen(function* () { + mockedRun.mockImplementation((input) => { + const args = input.args.join(" "); + if (args === "config get host") return Effect.succeed(processOutput("")); + if (args.startsWith("auth status")) { + return Effect.succeed({ + ...processOutput(""), + stderr: `gitlab.selfhosted.test + ✓ Logged in to gitlab.selfhosted.test as selfhosted-user + ✓ Token found: ****** +`, + }); + } + if (input.args[0] === "api" && input.args.includes("projects/team%2Fapp")) { + return Effect.succeed( + processOutput( + JSON.stringify({ + path_with_namespace: "team/app", + web_url: "https://gitlab.selfhosted.test/team/app", + http_url_to_repo: "https://gitlab.selfhosted.test/team/app.git", + ssh_url_to_repo: "git@gitlab.selfhosted.test:team/app.git", + }), + ), + ); + } + return Effect.die(`unexpected glab ${args}`); + }); + + const glab = yield* GitLabCli.GitLabCli; + yield* glab.getRepositoryCloneUrls({ cwd: "/server", repository: "team/app" }); + + const apiArgs = mockedRun.mock.calls + .map(([input]) => input.args) + .find((args) => args[0] === "api"); + expect(apiArgs?.join(" ")).toContain("--hostname gitlab.selfhosted.test"); + }), + ); + it.effect("preserves rate-limit failures as a distinct error", () => Effect.gen(function* () { const cause = new VcsProcessExitError({ From e6b0fd6ec74fbbb6fb54cc4805531ff8a4dd6032 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 20:03:02 +0000 Subject: [PATCH 2/5] fix(gitlab): repository lookup and publish target the only signed-in glab host Outside a GitLab checkout glab falls back to gitlab.com, so a user signed in only to a self-hosted GitLab got a 404 when adding a GitLab repository or publishing to one. When `glab auth status` reports exactly one signed-in host, pass `--hostname ` to those `glab api` calls; otherwise, or if auth status cannot run, keep today's argv. Fixes pingdotgg/t3code#26606 Co-Authored-By: Claude Opus 5.5 --- .../src/server/GitLabCli.test.ts | 20 +++++++-- .../src/server/GitLabCli.ts | 43 ++++++++++++++++--- 2 files changed, 53 insertions(+), 10 deletions(-) diff --git a/packages/source-control-gitlab/src/server/GitLabCli.test.ts b/packages/source-control-gitlab/src/server/GitLabCli.test.ts index b345969661b39..41a9fdaa046ad 100644 --- a/packages/source-control-gitlab/src/server/GitLabCli.test.ts +++ b/packages/source-control-gitlab/src/server/GitLabCli.test.ts @@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import { ChildProcessSpawner } from "effect/process"; -import { VcsProcessExitError } from "@t3tools/contracts"; +import { VcsProcessExitError, VcsProcessTimeoutError } from "@t3tools/contracts"; import type * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; @@ -192,7 +192,18 @@ layer("GitLabCli.layer", (it) => { detail: "GET 404 project not found", failureKind: "not-found", }); - mockedRun.mockReturnValueOnce(Effect.fail(cause)); + mockedRun.mockImplementation((input) => + input.args[0] === "auth" + ? Effect.fail( + new VcsProcessTimeoutError({ + operation: "GitLabCli.authStatus", + command: "glab", + cwd: "/repo", + timeoutMs: 5_000, + }), + ) + : Effect.fail(cause), + ); const error = yield* Effect.gen(function* () { const glab = yield* GitLabCli.GitLabCli; @@ -204,10 +215,13 @@ layer("GitLabCli.layer", (it) => { assert.strictEqual(error._tag, "GitLabCliCommandError"); assert.strictEqual(error.cause, cause); + expect(mockedRun).toHaveBeenLastCalledWith( + expect.objectContaining({ args: ["api", "projects/missing%2Fproject"] }), + ); }), ); - it.effect("looks up repositories on the only signed-in host when glab has no default host", () => + it.effect("looks up repositories on the only signed-in host", () => Effect.gen(function* () { mockedRun.mockImplementation((input) => { const args = input.args.join(" "); diff --git a/packages/source-control-gitlab/src/server/GitLabCli.ts b/packages/source-control-gitlab/src/server/GitLabCli.ts index 0234b6fcd9f5e..9e7bf8d1bf32f 100644 --- a/packages/source-control-gitlab/src/server/GitLabCli.ts +++ b/packages/source-control-gitlab/src/server/GitLabCli.ts @@ -14,7 +14,9 @@ import { type VcsProcessExitFailureKind, } from "@t3tools/contracts"; +import { combinedAuthOutput } from "@t3tools/source-control-core/server/discovery"; import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; +import { parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts"; import { decodeGitLabMergeRequestJson, decodeGitLabMergeRequestListJson, @@ -22,6 +24,7 @@ import { import type * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider"; const DEFAULT_TIMEOUT_MS = 30_000; +const AUTH_STATUS_TIMEOUT_MS = 5_000; const gitLabCliExecutionErrorContext = { operation: Schema.Literal("execute"), @@ -440,6 +443,27 @@ export const make = Effect.gen(function* () { }) .pipe(Effect.mapError(mapError)); + // Outside a GitLab checkout glab targets gitlab.com, so aim at the only signed-in host. + const signedInHostArgs = (cwd: string) => + process + .run({ + operation: "GitLabCli.authStatus", + command: "glab", + args: ["auth", "status"], + cwd, + allowNonZeroExit: true, + timeoutMs: AUTH_STATUS_TIMEOUT_MS, + }) + .pipe( + Effect.map((result): ReadonlyArray => { + const [only, ...others] = parseGitLabAuthStatusHosts(combinedAuthOutput(result)).filter( + (host) => host.account !== null, + ); + return only && others.length === 0 ? ["--hostname", only.host] : []; + }), + Effect.orElseSucceed((): ReadonlyArray => []), + ); + const execute: GitLabCli["Service"]["execute"] = (input) => run(input, (error) => GitLabCliCommandError.fromVcsError( @@ -534,10 +558,13 @@ export const make = Effect.gen(function* () { ); }), getRepositoryCloneUrls: Effect.fn("GitLabCli.getRepositoryCloneUrls")(function (input) { - return execute({ - cwd: input.cwd, - args: ["api", `projects/${encodeURIComponent(input.repository)}`], - }).pipe( + return signedInHostArgs(input.cwd).pipe( + Effect.flatMap((hostArgs) => + execute({ + cwd: input.cwd, + args: ["api", ...hostArgs, `projects/${encodeURIComponent(input.repository)}`], + }), + ), Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => decodeGitLabRepositoryCloneUrls(raw).pipe( @@ -556,12 +583,13 @@ export const make = Effect.gen(function* () { Effect.map(normalizeRepositoryCloneUrls), ); }), - createRepository: Effect.fn("GitLabCli.createRepository")(function (input) { + createRepository: Effect.fn("GitLabCli.createRepository")(function* (input) { + const hostArgs = yield* signedInHostArgs(input.cwd); const { namespacePath, projectPath } = parseRepositoryPath(input.repository); const namespaceId: Effect.Effect = namespacePath ? execute({ cwd: input.cwd, - args: ["api", `namespaces/${encodeURIComponent(namespacePath)}`], + args: ["api", ...hostArgs, `namespaces/${encodeURIComponent(namespacePath)}`], }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => @@ -582,12 +610,13 @@ export const make = Effect.gen(function* () { ) : Effect.succeed(null); - return namespaceId.pipe( + return yield* namespaceId.pipe( Effect.flatMap((resolvedNamespaceId) => execute({ cwd: input.cwd, args: [ "api", + ...hostArgs, "--method", "POST", "projects", From ca2fea17a791448b4f92fefa4bd00f52a43e692d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 20:26:14 +0000 Subject: [PATCH 3/5] fix(gitlab): point glab's fallback host at the only signed-in host via GITLAB_HOST --hostname rejects host:port and IP hosts and overrides a checkout's remote. Pass GITLAB_HOST in the child env instead, only when the user has not set GITLAB_HOST/GITLAB_URI/GL_HOST and no remote of the cwd is on gitlab.com or a host glab lists (glab would read its host from that remote, and GITLAB_HOST overrides it for `glab api`). Keep the host as `glab auth status` printed it, since glab matches config keys case-sensitively; existing comparisons lowercase at the call site. Co-Authored-By: Claude Opus 5.5 --- .../src/server/GitLabCli.test.ts | 157 ++++++++++++++---- .../src/server/GitLabCli.ts | 72 +++++--- .../src/server/GitLabSourceControlProvider.ts | 4 +- .../src/server/gitLabAuthStatus.ts | 2 +- 4 files changed, 175 insertions(+), 60 deletions(-) diff --git a/packages/source-control-gitlab/src/server/GitLabCli.test.ts b/packages/source-control-gitlab/src/server/GitLabCli.test.ts index 41a9fdaa046ad..fe93875c547a3 100644 --- a/packages/source-control-gitlab/src/server/GitLabCli.test.ts +++ b/packages/source-control-gitlab/src/server/GitLabCli.test.ts @@ -1,6 +1,9 @@ import { assert, it, afterEach, expect, vi } from "@effect/vitest"; +import * as ConfigProvider from "effect/ConfigProvider"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import { ChildProcessSpawner } from "effect/process"; import { VcsProcessExitError, VcsProcessTimeoutError } from "@t3tools/contracts"; @@ -10,8 +13,20 @@ import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSour import * as GitLabCli from "./GitLabCli.ts"; const mockedRun = vi.fn(); +const mockedRemotes = vi.fn(); const layer = it.layer( - GitLabCli.layer.pipe(Layer.provide(TestSourceControlHost.layer({ process: { run: mockedRun } }))), + Layer.merge( + GitLabCli.layer.pipe( + Layer.provide( + TestSourceControlHost.layer({ + process: { run: mockedRun }, + git: { remotes: mockedRemotes }, + }), + ), + ), + // Keeps a developer's own GITLAB_HOST out of these tests. + ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })), + ), ); function processOutput(stdout: string): SourceControlHost.SourceControlProcessOutput { @@ -24,8 +39,50 @@ function processOutput(stdout: string): SourceControlHost.SourceControlProcessOu }; } +function mockGlab(signedInHosts: ReadonlyArray, remoteUrls: ReadonlyArray = []) { + mockedRemotes.mockReturnValue( + Effect.succeed( + Effect.succeed({ + remotes: remoteUrls.map((url) => ({ + name: "origin", + url, + pushUrl: Option.none(), + isPrimary: true, + })), + freshness: { + source: "live-local" as const, + observedAt: DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"), + expiresAt: Option.none(), + }, + }), + ), + ); + mockedRun.mockImplementation((input) => { + if (input.args[0] === "auth") { + return Effect.succeed({ + ...processOutput(""), + stderr: signedInHosts.map((host) => `${host}\n ✓ Logged in to ${host} as user\n`).join(""), + }); + } + if (input.args[1]?.startsWith("namespaces/")) { + return Effect.succeed(processOutput(JSON.stringify({ id: 9 }))); + } + return Effect.succeed( + processOutput( + JSON.stringify({ + path_with_namespace: "team/app", + web_url: "https://gitlab.example.test/team/app", + http_url_to_repo: "https://gitlab.example.test/team/app.git", + ssh_url_to_repo: "git@gitlab.example.test:team/app.git", + }), + ), + ); + }); +} + afterEach(() => { mockedRun.mockReset(); + mockedRemotes.mockReset(); }); layer("GitLabCli.layer", (it) => { @@ -221,42 +278,78 @@ layer("GitLabCli.layer", (it) => { }), ); - it.effect("looks up repositories on the only signed-in host", () => + it.effect("looks up repositories on the only signed-in host, spelled as glab prints it", () => Effect.gen(function* () { - mockedRun.mockImplementation((input) => { - const args = input.args.join(" "); - if (args === "config get host") return Effect.succeed(processOutput("")); - if (args.startsWith("auth status")) { - return Effect.succeed({ - ...processOutput(""), - stderr: `gitlab.selfhosted.test - ✓ Logged in to gitlab.selfhosted.test as selfhosted-user - ✓ Token found: ****** -`, - }); - } - if (input.args[0] === "api" && input.args.includes("projects/team%2Fapp")) { - return Effect.succeed( - processOutput( - JSON.stringify({ - path_with_namespace: "team/app", - web_url: "https://gitlab.selfhosted.test/team/app", - http_url_to_repo: "https://gitlab.selfhosted.test/team/app.git", - ssh_url_to_repo: "git@gitlab.selfhosted.test:team/app.git", - }), - ), - ); - } - return Effect.die(`unexpected glab ${args}`); - }); + mockGlab(["Git.Example.test:8443"]); const glab = yield* GitLabCli.GitLabCli; yield* glab.getRepositoryCloneUrls({ cwd: "/server", repository: "team/app" }); - const apiArgs = mockedRun.mock.calls - .map(([input]) => input.args) - .find((args) => args[0] === "api"); - expect(apiArgs?.join(" ")).toContain("--hostname gitlab.selfhosted.test"); + expect(mockedRun).toHaveBeenLastCalledWith( + expect.objectContaining({ + args: ["api", "projects/team%2Fapp"], + env: { GITLAB_HOST: "Git.Example.test:8443" }, + }), + ); + }), + ); + + it.effect("leaves an explicit GITLAB_HOST to glab without probing sign-ins", () => + Effect.gen(function* () { + mockGlab(["gitlab.selfhosted.test"]); + + const glab = yield* GitLabCli.GitLabCli; + yield* glab + .getRepositoryCloneUrls({ cwd: "/server", repository: "team/app" }) + .pipe( + Effect.provide( + ConfigProvider.layer(ConfigProvider.fromEnv({ env: { GITLAB_HOST: "gitlab.com" } })), + ), + ); + + expect(mockedRun.mock.calls.map(([input]) => [input.args[0], input.env])).toEqual([ + ["api", undefined], + ]); + }), + ); + + it.effect.each([ + { case: "several hosts are signed in", hosts: ["gitlab.com", "gitlab.selfhosted.test"] }, + { + case: "the checkout has a gitlab.com remote", + hosts: ["gitlab.selfhosted.test"], + remotes: ["git@gitlab.com:team/app.git"], + }, + ])("leaves the host to glab when $case", ({ hosts, remotes }) => + Effect.gen(function* () { + mockGlab(hosts, remotes); + + const glab = yield* GitLabCli.GitLabCli; + yield* glab.getRepositoryCloneUrls({ cwd: "/server", repository: "team/app" }); + + expect(mockedRun.mock.lastCall?.[0].env).toBeUndefined(); + }), + ); + + it.effect("publishes to the only signed-in host", () => + Effect.gen(function* () { + mockGlab(["gitlab.selfhosted.test"]); + + const glab = yield* GitLabCli.GitLabCli; + yield* glab.createRepository({ + cwd: "/server", + repository: "team/app", + visibility: "private", + }); + + expect( + mockedRun.mock.calls + .filter(([input]) => input.args[0] === "api") + .map(([input]) => [input.args[1], input.env]), + ).toEqual([ + ["namespaces/team", { GITLAB_HOST: "gitlab.selfhosted.test" }], + ["--method", { GITLAB_HOST: "gitlab.selfhosted.test" }], + ]); }), ); diff --git a/packages/source-control-gitlab/src/server/GitLabCli.ts b/packages/source-control-gitlab/src/server/GitLabCli.ts index 9e7bf8d1bf32f..9d1585c51dd47 100644 --- a/packages/source-control-gitlab/src/server/GitLabCli.ts +++ b/packages/source-control-gitlab/src/server/GitLabCli.ts @@ -1,3 +1,4 @@ +import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -15,6 +16,7 @@ import { } from "@t3tools/contracts"; import { combinedAuthOutput } from "@t3tools/source-control-core/server/discovery"; +import { parseGitRemoteHost } from "@t3tools/source-control-core/shared/definition"; import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; import { parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts"; import { @@ -26,6 +28,13 @@ import type * as SourceControlProvider from "@t3tools/source-control-core/server const DEFAULT_TIMEOUT_MS = 30_000; const AUTH_STATUS_TIMEOUT_MS = 5_000; +// glab reads its fallback host from these, so a set one means the user chose the host. +const glabHostIsExplicit = Config.all( + ["GITLAB_HOST", "GITLAB_URI", "GL_HOST"].map((name) => + Config.String(name).pipe(Config.withDefault("")), + ), +).pipe(Config.map((hosts) => hosts.some((host) => host.length > 0))); + const gitLabCliExecutionErrorContext = { operation: Schema.Literal("execute"), command: Schema.Literal("glab"), @@ -421,14 +430,15 @@ const classifyGlabFailure = (stderr: string): VcsProcessExitFailureKind | null = return null; }; +type ExecuteInput = Parameters[0] & { + readonly env?: NodeJS.ProcessEnv | undefined; +}; + /** @public */ export const make = Effect.gen(function* () { - const { process } = yield* SourceControlHost.SourceControlHost; + const { process, git } = yield* SourceControlHost.SourceControlHost; - const run = ( - input: Parameters[0], - mapError: (error: VcsError) => GitLabCliError, - ) => + const run = (input: ExecuteInput, mapError: (error: VcsError) => GitLabCliError) => process .run({ operation: "GitLabCli.execute", @@ -440,31 +450,41 @@ export const make = Effect.gen(function* () { timeoutMs: input.timeoutMs ?? DEFAULT_TIMEOUT_MS, ...(input.stdin === undefined ? {} : { stdin: input.stdin }), ...(input.maxOutputBytes === undefined ? {} : { maxOutputBytes: input.maxOutputBytes }), + ...(input.env === undefined ? {} : { env: input.env }), }) .pipe(Effect.mapError(mapError)); - // Outside a GitLab checkout glab targets gitlab.com, so aim at the only signed-in host. - const signedInHostArgs = (cwd: string) => - process - .run({ + // glab takes its host from a remote on a host it knows, else falls back to gitlab.com. Point only + // that fallback at the only signed-in host: GITLAB_HOST would override a remote's host too. + const signedInHostEnv = Effect.fn("GitLabCli.signedInHostEnv")( + function* (cwd: string) { + if (yield* glabHostIsExplicit) return undefined; + const result = yield* process.run({ operation: "GitLabCli.authStatus", command: "glab", args: ["auth", "status"], cwd, allowNonZeroExit: true, timeoutMs: AUTH_STATUS_TIMEOUT_MS, - }) - .pipe( - Effect.map((result): ReadonlyArray => { - const [only, ...others] = parseGitLabAuthStatusHosts(combinedAuthOutput(result)).filter( - (host) => host.account !== null, - ); - return only && others.length === 0 ? ["--hostname", only.host] : []; - }), - Effect.orElseSucceed((): ReadonlyArray => []), + }); + const hosts = parseGitLabAuthStatusHosts(combinedAuthOutput(result)); + const [only, ...others] = hosts.filter((host) => host.account !== null); + if (!only || others.length > 0) return undefined; + const knownHosts = new Set(["gitlab.com", ...hosts.map((host) => host.host.toLowerCase())]); + const remotes = yield* git.remotes(cwd).pipe( + Effect.flatten, + Effect.map((result) => result.remotes), + Effect.orElseSucceed(() => []), ); + const followsRemote = remotes.some((remote) => + knownHosts.has(parseGitRemoteHost(remote.url)?.host ?? ""), + ); + return followsRemote ? undefined : { GITLAB_HOST: only.host }; + }, + Effect.orElseSucceed(() => undefined), + ); - const execute: GitLabCli["Service"]["execute"] = (input) => + const execute = (input: ExecuteInput) => run(input, (error) => GitLabCliCommandError.fromVcsError( { operation: "execute", command: "glab", cwd: input.cwd }, @@ -558,11 +578,12 @@ export const make = Effect.gen(function* () { ); }), getRepositoryCloneUrls: Effect.fn("GitLabCli.getRepositoryCloneUrls")(function (input) { - return signedInHostArgs(input.cwd).pipe( - Effect.flatMap((hostArgs) => + return signedInHostEnv(input.cwd).pipe( + Effect.flatMap((env) => execute({ cwd: input.cwd, - args: ["api", ...hostArgs, `projects/${encodeURIComponent(input.repository)}`], + args: ["api", `projects/${encodeURIComponent(input.repository)}`], + env, }), ), Effect.map((result) => result.stdout.trim()), @@ -584,12 +605,13 @@ export const make = Effect.gen(function* () { ); }), createRepository: Effect.fn("GitLabCli.createRepository")(function* (input) { - const hostArgs = yield* signedInHostArgs(input.cwd); + const env = yield* signedInHostEnv(input.cwd); const { namespacePath, projectPath } = parseRepositoryPath(input.repository); const namespaceId: Effect.Effect = namespacePath ? execute({ cwd: input.cwd, - args: ["api", ...hostArgs, `namespaces/${encodeURIComponent(namespacePath)}`], + args: ["api", `namespaces/${encodeURIComponent(namespacePath)}`], + env, }).pipe( Effect.map((result) => result.stdout.trim()), Effect.flatMap((raw) => @@ -616,7 +638,6 @@ export const make = Effect.gen(function* () { cwd: input.cwd, args: [ "api", - ...hostArgs, "--method", "POST", "projects", @@ -630,6 +651,7 @@ export const make = Effect.gen(function* () { ? [] : ["--raw-field", `namespace_id=${resolvedNamespaceId}`]), ], + env, }), ), Effect.map((result) => result.stdout.trim()), diff --git a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts index beeb6b38af1d0..176c7eb7acb2a 100644 --- a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts +++ b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts @@ -66,7 +66,7 @@ function parseGitLabAuth(input: SourceControlAuthProbeInput) { /Logged in to .* account\s+([^\s(]+)/iu, /account:\s*([^\s(]+)/iu, ]); - const host = authenticatedHost?.host ?? parseCliHost(output); + const host = authenticatedHost?.host.toLowerCase() ?? parseCliHost(output); if (account) { return providerAuth({ status: "authenticated", account, host }); @@ -94,7 +94,7 @@ function refineUnknownGitLabRemote(input: SourceControlUnknownRemoteRefinementIn const host = input.context.provider.name.toLowerCase(); const output = combinedAuthOutput(input.auth); const authenticated = parseGitLabAuthStatusHosts(output).some( - (entry) => entry.account !== null && entry.host === host, + (entry) => entry.account !== null && entry.host.toLowerCase() === host, ); if (!authenticated) { diff --git a/packages/source-control-gitlab/src/server/gitLabAuthStatus.ts b/packages/source-control-gitlab/src/server/gitLabAuthStatus.ts index f54e300353ad4..9c89c451e8612 100644 --- a/packages/source-control-gitlab/src/server/gitLabAuthStatus.ts +++ b/packages/source-control-gitlab/src/server/gitLabAuthStatus.ts @@ -28,7 +28,7 @@ export function parseGitLabAuthStatusHosts(text: string): ReadonlyArray Date: Wed, 28 Oct 2026 20:35:10 +0000 Subject: [PATCH 4/5] fix(gitlab): userless SCP remotes keep glab's remote host The signed-in host fallback skipped remotes such as gitlab.com:team/app.git and set GITLAB_HOST over them. Share the provider's SCP-aware remote host parsing with the fallback guard. Co-Authored-By: Claude Opus 5.5 --- .../source-control-gitlab/src/server/GitLabCli.test.ts | 5 +++++ packages/source-control-gitlab/src/server/GitLabCli.ts | 8 +++++++- .../src/server/GitLabSourceControlProvider.ts | 9 ++------- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/source-control-gitlab/src/server/GitLabCli.test.ts b/packages/source-control-gitlab/src/server/GitLabCli.test.ts index fe93875c547a3..8de7a40129e7e 100644 --- a/packages/source-control-gitlab/src/server/GitLabCli.test.ts +++ b/packages/source-control-gitlab/src/server/GitLabCli.test.ts @@ -320,6 +320,11 @@ layer("GitLabCli.layer", (it) => { hosts: ["gitlab.selfhosted.test"], remotes: ["git@gitlab.com:team/app.git"], }, + { + case: "the checkout has a userless SCP gitlab.com remote", + hosts: ["gitlab.selfhosted.test"], + remotes: ["gitlab.com:team/app.git"], + }, ])("leaves the host to glab when $case", ({ hosts, remotes }) => Effect.gen(function* () { mockGlab(hosts, remotes); diff --git a/packages/source-control-gitlab/src/server/GitLabCli.ts b/packages/source-control-gitlab/src/server/GitLabCli.ts index 9d1585c51dd47..5f7e931faca9f 100644 --- a/packages/source-control-gitlab/src/server/GitLabCli.ts +++ b/packages/source-control-gitlab/src/server/GitLabCli.ts @@ -422,6 +422,12 @@ function parseRepositoryPath(repository: string): { return { namespacePath, projectPath }; } +export function parseGitLabRemoteHost(remoteUrl: string) { + // Remote host parsing does not recognize userless SCP remotes. + const scpHost = /^([^@:/\\\s]{2,}):\/?[^/\s]+(?:\/[^/\s]+)+$/u.exec(remoteUrl.trim())?.[1]; + return parseGitRemoteHost(scpHost ? `ssh://${scpHost}` : remoteUrl); +} + // glab prints a missing project or merge request as a bare 404. const classifyGlabFailure = (stderr: string): VcsProcessExitFailureKind | null => { const normalized = stderr.toLowerCase(); @@ -477,7 +483,7 @@ export const make = Effect.gen(function* () { Effect.orElseSucceed(() => []), ); const followsRemote = remotes.some((remote) => - knownHosts.has(parseGitRemoteHost(remote.url)?.host ?? ""), + knownHosts.has(parseGitLabRemoteHost(remote.url)?.host ?? ""), ); return followsRemote ? undefined : { GITLAB_HOST: only.host }; }, diff --git a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts index 176c7eb7acb2a..c2c7579a68c68 100644 --- a/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts +++ b/packages/source-control-gitlab/src/server/GitLabSourceControlProvider.ts @@ -3,10 +3,7 @@ import * as Schema from "effect/Schema"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contracts"; -import { - parseGitRemoteHost, - unknownRemoteProvider as unknownHostProvider, -} from "@t3tools/source-control-core/shared/definition"; +import { unknownRemoteProvider as unknownHostProvider } from "@t3tools/source-control-core/shared/definition"; import * as GitLabCli from "./GitLabCli.ts"; import * as GitLabChangeRequestTemplate from "./gitLabChangeRequestTemplate.ts"; @@ -110,9 +107,7 @@ function refineUnknownGitLabRemote(input: SourceControlUnknownRemoteRefinementIn /** The caller refines only an identity no host recognised, so its remote is on an unknown host. */ function unknownRemoteProvider(remoteUrl: string) { - // Remote host parsing does not recognize userless SCP remotes. - const scpHost = /^([^@:/\\\s]{2,}):\/?[^/\s]+(?:\/[^/\s]+)+$/u.exec(remoteUrl.trim())?.[1]; - const remote = parseGitRemoteHost(scpHost ? `ssh://${scpHost}` : remoteUrl); + const remote = GitLabCli.parseGitLabRemoteHost(remoteUrl); return remote === null ? null : unknownHostProvider(remote); } From 828fce58ae1c0b602ae56ddd5a04916bea633815 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 20:47:41 +0000 Subject: [PATCH 5/5] fix(gitlab): glab push URLs and trailing-slash SCP remotes keep their host glab also identifies a repository by a remote's push URL, and accepts userless SCP remotes with a trailing slash. Check both before pointing the fallback host at the only signed-in host. Co-Authored-By: Claude Opus 5.5 --- .../src/server/GitLabCli.test.ts | 21 +++++++++++++++---- .../src/server/GitLabCli.ts | 6 ++++-- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/source-control-gitlab/src/server/GitLabCli.test.ts b/packages/source-control-gitlab/src/server/GitLabCli.test.ts index 8de7a40129e7e..0b6829b59ec7d 100644 --- a/packages/source-control-gitlab/src/server/GitLabCli.test.ts +++ b/packages/source-control-gitlab/src/server/GitLabCli.test.ts @@ -39,14 +39,17 @@ function processOutput(stdout: string): SourceControlHost.SourceControlProcessOu }; } -function mockGlab(signedInHosts: ReadonlyArray, remoteUrls: ReadonlyArray = []) { +function mockGlab( + signedInHosts: ReadonlyArray, + remoteUrls: ReadonlyArray = [], +) { mockedRemotes.mockReturnValue( Effect.succeed( Effect.succeed({ - remotes: remoteUrls.map((url) => ({ + remotes: remoteUrls.map((remote) => ({ name: "origin", - url, - pushUrl: Option.none(), + url: typeof remote === "string" ? remote : remote.url, + pushUrl: typeof remote === "string" ? Option.none() : Option.some(remote.pushUrl), isPrimary: true, })), freshness: { @@ -325,6 +328,16 @@ layer("GitLabCli.layer", (it) => { hosts: ["gitlab.selfhosted.test"], remotes: ["gitlab.com:team/app.git"], }, + { + case: "the checkout has a userless SCP gitlab.com remote with a trailing slash", + hosts: ["gitlab.selfhosted.test"], + remotes: ["gitlab.com:team/app.git/"], + }, + { + case: "the checkout fetches from a mirror and pushes to gitlab.com", + hosts: ["gitlab.selfhosted.test"], + remotes: [{ url: "/srv/mirror/app.git", pushUrl: "git@gitlab.com:team/app.git" }], + }, ])("leaves the host to glab when $case", ({ hosts, remotes }) => Effect.gen(function* () { mockGlab(hosts, remotes); diff --git a/packages/source-control-gitlab/src/server/GitLabCli.ts b/packages/source-control-gitlab/src/server/GitLabCli.ts index 5f7e931faca9f..58d66eee7c123 100644 --- a/packages/source-control-gitlab/src/server/GitLabCli.ts +++ b/packages/source-control-gitlab/src/server/GitLabCli.ts @@ -424,7 +424,7 @@ function parseRepositoryPath(repository: string): { export function parseGitLabRemoteHost(remoteUrl: string) { // Remote host parsing does not recognize userless SCP remotes. - const scpHost = /^([^@:/\\\s]{2,}):\/?[^/\s]+(?:\/[^/\s]+)+$/u.exec(remoteUrl.trim())?.[1]; + const scpHost = /^([^@:/\\\s]{2,}):\/?[^/\s]+(?:\/[^/\s]+)+\/?$/u.exec(remoteUrl.trim())?.[1]; return parseGitRemoteHost(scpHost ? `ssh://${scpHost}` : remoteUrl); } @@ -483,7 +483,9 @@ export const make = Effect.gen(function* () { Effect.orElseSucceed(() => []), ); const followsRemote = remotes.some((remote) => - knownHosts.has(parseGitLabRemoteHost(remote.url)?.host ?? ""), + [remote.url, ...Option.toArray(remote.pushUrl)].some((url) => + knownHosts.has(parseGitLabRemoteHost(url)?.host ?? ""), + ), ); return followsRemote ? undefined : { GITLAB_HOST: only.host }; },