Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions apps/server/src/sourceControl/SourceControlRepositoryService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,106 @@ 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: ReadonlyArray<{ stderr: string; expected: string; remoteUrl?: string }> = [
{
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:
"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",
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:
"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.",
},
...(
[
[
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:
"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, remoteUrl }) =>
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: remoteUrl ?? CLONE_URLS.sshUrl,
destinationPath: `${parent}/t3code`,
}),
);

assert.strictEqual(error.operation, "cloneRepository");
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.");
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",
Expand Down
69 changes: 67 additions & 2 deletions apps/server/src/sourceControl/SourceControlRepositoryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as Path from "effect/Path";
import * as Schema from "effect/Schema";

import {
GitCommandError,
SourceControlRepositoryError,
type SourceControlCloneRepositoryInput,
type SourceControlCloneRepositoryResult,
Expand All @@ -18,12 +19,53 @@ import {
type SourceControlRepositoryLookupInput,
} from "@t3tools/contracts";

import {
detectSourceControlProviderFromRemoteUrl,
isSshRemoteUrl,
} 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, 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(?:,[^)]+)?\)|public key authentication failed/iu.test(stderr)
) {
return "SSH authentication failed. Add an SSH key to your source control account and try again.";
}
if (
/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.";
Comment thread
cursor[bot] marked this conversation as resolved.
}
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.";
}
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.";
}
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)) {
return "The repository could not be read. Check that it exists and that your Git credentials have access.";
Comment thread
cursor[bot] marked this conversation as resolved.
}

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,
{
Expand Down Expand Up @@ -173,7 +215,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({
Expand All @@ -193,14 +238,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, remoteUrl);
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: "git clone exited with a non-zero status.",
}),
});
}

return {
cwd: preparedDestination.destinationPath,
remoteUrl,
Expand Down
Loading