From 912c26ba2d1da6ebac6ad175ba456965ba228f91 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:28:47 +0200 Subject: [PATCH 1/3] fix(source-control): explain clone failures --- .../SourceControlRepositoryService.test.ts | 68 +++++++++++++++++++ .../SourceControlRepositoryService.ts | 57 +++++++++++++++- 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 461bff08668a..ebb234bfe082 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -197,6 +197,74 @@ it.effect("clones a looked-up repository into the requested destination", () => }).pipe(Effect.provide(NodeServices.layer)), ); +it.effect("returns actionable, transport-safe clone failures", () => { + const cases = [ + { + stderr: "Host key verification failed.\nfatal: Could not read from remote repository.\n", + expected: + "SSH could not verify the source control host. Add its host key to known_hosts and try again.", + }, + { + stderr: "git@example.com: Permission denied (publickey).\n", + expected: + "SSH authentication failed. Add an SSH key to your source control account and try again.", + }, + { + stderr: + "fatal: could not read Username for 'https://example.com': terminal prompts disabled\n", + expected: + "HTTPS authentication failed. Configure Git credentials for the source control host and try again.", + }, + { + stderr: + "ssh: Could not resolve hostname example.com: nodename nor servname provided\nfatal: Could not read from remote repository.\n", + expected: + "The source control host could not be resolved. Check your network or VPN connection and try again.", + }, + { + stderr: "fatal: an unrecognized clone failure\n", + expected: + "Git could not clone the repository. Verify that the remote works in a terminal and try again.", + }, + ] as const; + + return Effect.forEach(cases, ({ stderr, expected }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const parent = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-source-control-clone-failure-", + }); + const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; + const error = yield* Effect.flip( + service.cloneRepository({ + remoteUrl: CLONE_URLS.sshUrl, + destinationPath: `${parent}/t3code`, + }), + ); + + assert.strictEqual(error.operation, "cloneRepository"); + assert.strictEqual(error.provider, "github"); + assert.strictEqual(error.detail, expected); + assert.instanceOf(error.cause, GitCommandError); + assert.strictEqual(error.cause.detail, expected); + assert.strictEqual(error.cause.stderrLength, stderr.length); + }).pipe( + Effect.provide( + makeLayer({ + git: { + execute: () => + Effect.succeed({ + ...processOutput(), + exitCode: ChildProcessSpawner.ExitCode(128), + stderr, + }), + }, + }), + ), + ), + ).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); +}); + it.effect("preserves destination probe failures instead of treating them as missing paths", () => { const fileSystemCause = PlatformError.systemError({ _tag: "PermissionDenied", diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index b38fe3d5c302..7d06ec97e4e9 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -6,6 +6,7 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { + GitCommandError, SourceControlRepositoryError, type SourceControlCloneRepositoryInput, type SourceControlCloneRepositoryResult, @@ -18,12 +19,41 @@ import { type SourceControlRepositoryLookupInput, } from "@t3tools/contracts"; +import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; + import { ServerConfig } from "../config.ts"; import { expandHomePathWith } from "../pathExpansion.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError); +function cloneFailureDetail(stderr: string): string { + if (/host key verification failed/iu.test(stderr)) { + return "SSH could not verify the source control host. Add its host key to known_hosts and try again."; + } + if (/permission denied \(publickey(?:,[^)]+)?\)/iu.test(stderr)) { + return "SSH authentication failed. Add an SSH key to your source control account and try again."; + } + if ( + /authentication failed|http basic: access denied|could not read username|terminal prompts disabled/iu.test( + stderr, + ) + ) { + return "HTTPS authentication failed. Configure Git credentials for the source control host and try again."; + } + if (/could not resolve (?:host|hostname)/iu.test(stderr)) { + return "The source control host could not be resolved. Check your network or VPN connection and try again."; + } + if (/connection (?:timed out|refused)|failed to connect/iu.test(stderr)) { + return "Git could not connect to the source control host. Check your network or VPN connection and try again."; + } + if (/repository not found|could not read from remote repository/iu.test(stderr)) { + return "The repository could not be read. Check that it exists and that your Git credentials have access."; + } + + return "Git could not clone the repository. Verify that the remote works in a terminal and try again."; +} + export class SourceControlRepositoryService extends Context.Service< SourceControlRepositoryService, { @@ -173,7 +203,10 @@ export const make = Effect.gen(function* () { const preparedDestination = yield* prepareDestination(input.destinationPath); let repository: SourceControlRepositoryInfo | null = null; let remoteUrl = input.remoteUrl?.trim() ?? null; - let provider: SourceControlProviderKind = input.provider ?? "unknown"; + let provider: SourceControlProviderKind = + input.provider ?? + (remoteUrl ? detectSourceControlProviderFromRemoteUrl(remoteUrl)?.kind : null) ?? + "unknown"; if (input.provider && input.repository) { repository = yield* lookupRepository({ @@ -193,14 +226,34 @@ export const make = Effect.gen(function* () { }); } - yield* git.execute({ + const cloneResult = yield* git.execute({ operation: "SourceControlRepositoryService.cloneRepository", cwd: preparedDestination.parentPath, args: ["clone", remoteUrl, preparedDestination.directoryName], + allowNonZeroExit: true, timeoutMs: 120_000, maxOutputBytes: 256 * 1024, }); + if (cloneResult.exitCode !== 0) { + const detail = cloneFailureDetail(cloneResult.stderr); + return yield* new SourceControlRepositoryError({ + operation: "cloneRepository", + provider, + detail, + cause: new GitCommandError({ + operation: "SourceControlRepositoryService.cloneRepository", + command: "git", + cwd: preparedDestination.parentPath, + argumentCount: 3, + exitCode: cloneResult.exitCode, + stdoutLength: cloneResult.stdout.length, + stderrLength: cloneResult.stderr.length, + detail, + }), + }); + } + return { cwd: preparedDestination.destinationPath, remoteUrl, From 8072f198a30f2926a37c6f61de5378d1393bdff8 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:59:58 +0200 Subject: [PATCH 2/3] fix(source-control): correct clone failure classification for SSH auth and timeouts Azure DevOps SSH public-key failures no longer get HTTPS advice; macOS SSH Operation timed out maps to connectivity; GitCommandError cause uses git-level detail. --- .../SourceControlRepositoryService.test.ts | 14 +++++++++- .../SourceControlRepositoryService.ts | 27 ++++++++++++------- 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index ebb234bfe082..b17e2945d909 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -209,6 +209,12 @@ it.effect("returns actionable, transport-safe clone failures", () => { expected: "SSH authentication failed. Add an SSH key to your source control account and try again.", }, + { + stderr: + "git@ssh.dev.azure.com: Public key authentication failed.\nfatal: Could not read from remote repository.\n", + expected: + "SSH authentication failed. Add an SSH key to your source control account and try again.", + }, { stderr: "fatal: could not read Username for 'https://example.com': terminal prompts disabled\n", @@ -221,6 +227,12 @@ it.effect("returns actionable, transport-safe clone failures", () => { expected: "The source control host could not be resolved. Check your network or VPN connection and try again.", }, + { + stderr: + "ssh: connect to host github.com port 22: Operation timed out\nfatal: Could not read from remote repository.\n", + expected: + "Git could not connect to the source control host. Check your network or VPN connection and try again.", + }, { stderr: "fatal: an unrecognized clone failure\n", expected: @@ -246,7 +258,7 @@ it.effect("returns actionable, transport-safe clone failures", () => { assert.strictEqual(error.provider, "github"); assert.strictEqual(error.detail, expected); assert.instanceOf(error.cause, GitCommandError); - assert.strictEqual(error.cause.detail, expected); + assert.strictEqual(error.cause.detail, "git clone exited with a non-zero status."); assert.strictEqual(error.cause.stderrLength, stderr.length); }).pipe( Effect.provide( diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index 7d06ec97e4e9..8fee4735f18e 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -19,7 +19,10 @@ import { type SourceControlRepositoryLookupInput, } from "@t3tools/contracts"; -import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; +import { + detectSourceControlProviderFromRemoteUrl, + isSshRemoteUrl, +} from "@t3tools/shared/sourceControl"; import { ServerConfig } from "../config.ts"; import { expandHomePathWith } from "../pathExpansion.ts"; @@ -27,24 +30,30 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError); -function cloneFailureDetail(stderr: string): string { +function cloneFailureDetail(stderr: string, remoteUrl?: string | null): string { if (/host key verification failed/iu.test(stderr)) { return "SSH could not verify the source control host. Add its host key to known_hosts and try again."; } - if (/permission denied \(publickey(?:,[^)]+)?\)/iu.test(stderr)) { + if ( + /permission denied \(publickey(?:,[^)]+)?\)|public key authentication failed/iu.test(stderr) + ) { return "SSH authentication failed. Add an SSH key to your source control account and try again."; } if ( - /authentication failed|http basic: access denied|could not read username|terminal prompts disabled/iu.test( - stderr, - ) + /http basic: access denied|could not read username|terminal prompts disabled/iu.test(stderr) ) { return "HTTPS authentication failed. Configure Git credentials for the source control host and try again."; } + if (/authentication failed/iu.test(stderr)) { + if (remoteUrl && isSshRemoteUrl(remoteUrl)) { + return "SSH authentication failed. Add an SSH key to your source control account and try again."; + } + return "HTTPS authentication failed. Configure Git credentials for the source control host and try again."; + } if (/could not resolve (?:host|hostname)/iu.test(stderr)) { return "The source control host could not be resolved. Check your network or VPN connection and try again."; } - if (/connection (?:timed out|refused)|failed to connect/iu.test(stderr)) { + if (/connection (?:timed out|refused)|operation timed out|failed to connect/iu.test(stderr)) { return "Git could not connect to the source control host. Check your network or VPN connection and try again."; } if (/repository not found|could not read from remote repository/iu.test(stderr)) { @@ -236,7 +245,7 @@ export const make = Effect.gen(function* () { }); if (cloneResult.exitCode !== 0) { - const detail = cloneFailureDetail(cloneResult.stderr); + const detail = cloneFailureDetail(cloneResult.stderr, remoteUrl); return yield* new SourceControlRepositoryError({ operation: "cloneRepository", provider, @@ -249,7 +258,7 @@ export const make = Effect.gen(function* () { exitCode: cloneResult.exitCode, stdoutLength: cloneResult.stdout.length, stderrLength: cloneResult.stderr.length, - detail, + detail: "git clone exited with a non-zero status.", }), }); } From 7590f3e427057b1f3b82ffdbfc54b77d224ce564 Mon Sep 17 00:00:00 2001 From: Lars Nieuwenhuis <35393046+lnieuwenhuis@users.noreply.github.com> Date: Sun, 6 Sep 2026 21:32:50 +0200 Subject: [PATCH 3/3] fix(server): keep unknown clone transport advice neutral --- .../SourceControlRepositoryService.test.ts | 28 ++++++++++++++++--- .../SourceControlRepositoryService.ts | 5 +++- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index b17e2945d909..59da45a84d32 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -198,7 +198,7 @@ it.effect("clones a looked-up repository into the requested destination", () => ); it.effect("returns actionable, transport-safe clone failures", () => { - const cases = [ + const cases: ReadonlyArray<{ stderr: string; expected: string; remoteUrl?: string }> = [ { stderr: "Host key verification failed.\nfatal: Could not read from remote repository.\n", expected: @@ -233,6 +233,26 @@ it.effect("returns actionable, transport-safe clone failures", () => { expected: "Git could not connect to the source control host. Check your network or VPN connection and try again.", }, + ...( + [ + [ + CLONE_URLS.sshUrl, + "SSH authentication failed. Add an SSH key to your source control account and try again.", + ], + [ + CLONE_URLS.url, + "HTTPS authentication failed. Configure Git credentials for the source control host and try again.", + ], + [ + "custom::repository", + "Git authentication failed. Check the credentials configured for this remote and try again.", + ], + ] as const + ).map(([remoteUrl, expected]) => ({ + remoteUrl, + stderr: "fatal: Authentication failed", + expected, + })), { stderr: "fatal: an unrecognized clone failure\n", expected: @@ -240,7 +260,7 @@ it.effect("returns actionable, transport-safe clone failures", () => { }, ] as const; - return Effect.forEach(cases, ({ stderr, expected }) => + return Effect.forEach(cases, ({ stderr, expected, remoteUrl }) => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const parent = yield* fs.makeTempDirectoryScoped({ @@ -249,13 +269,13 @@ it.effect("returns actionable, transport-safe clone failures", () => { const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; const error = yield* Effect.flip( service.cloneRepository({ - remoteUrl: CLONE_URLS.sshUrl, + remoteUrl: remoteUrl ?? CLONE_URLS.sshUrl, destinationPath: `${parent}/t3code`, }), ); assert.strictEqual(error.operation, "cloneRepository"); - assert.strictEqual(error.provider, "github"); + assert.strictEqual(error.provider, remoteUrl === "custom::repository" ? "unknown" : "github"); assert.strictEqual(error.detail, expected); assert.instanceOf(error.cause, GitCommandError); assert.strictEqual(error.cause.detail, "git clone exited with a non-zero status."); diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index 8fee4735f18e..1730823e8a80 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -48,7 +48,10 @@ function cloneFailureDetail(stderr: string, remoteUrl?: string | null): string { if (remoteUrl && isSshRemoteUrl(remoteUrl)) { return "SSH authentication failed. Add an SSH key to your source control account and try again."; } - return "HTTPS authentication failed. Configure Git credentials for the source control host and try again."; + if (remoteUrl && /^https?:\/\//iu.test(remoteUrl)) { + return "HTTPS authentication failed. Configure Git credentials for the source control host and try again."; + } + return "Git authentication failed. Check the credentials configured for this remote and try again."; } if (/could not resolve (?:host|hostname)/iu.test(stderr)) { return "The source control host could not be resolved. Check your network or VPN connection and try again.";