diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts index 326b7931da292..e0b3df7d85ad9 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts @@ -303,18 +303,21 @@ function rejectingSsh( const url = input.args.at(-2)!; urls.push(url); if (url.startsWith("https://") && !httpsFails) return processOutput(); + const https = url.startsWith("https://"); yield* ( input.progress?.onStderrLine?.( - url.startsWith("https://") + https ? "fatal: could not read Username for 'https://github.com': terminal prompts disabled" - : "git@github.com: Permission denied (publickey).", + : reason === "host_key_unverified" + ? "Host key verification failed." + : "git@github.com: Permission denied (publickey).", ) ?? Effect.void ); return yield* new GitCommandError({ operation: input.operation, command: "git", cwd: input.cwd, - reason, + reason: https ? "authentication_failed" : reason, detail: "Git command exited with a non-zero status.", }); }), @@ -362,11 +365,76 @@ it.effect("reports both failures when the HTTPS retry fails too", () => assert.strictEqual(urls.length, 2); assert.strictEqual( error.detail, - "git@github.com: Permission denied (publickey). Cloning over HTTPS instead also failed: fatal: could not read Username for 'https://github.com': terminal prompts disabled", + "Clones use this environment's Git credentials, not a token saved in Settings. Git could not authenticate. git@github.com: Permission denied (publickey). Cloning over HTTPS instead also failed: Git could not authenticate. fatal: could not read Username for 'https://github.com': terminal prompts disabled", ); }), ); +it.effect("leads with the credential note when only the HTTPS retry lacks credentials", () => + Effect.gen(function* () { + const urls: Array = []; + const error = yield* Effect.flip( + cloneInTemporaryDirectory(rejectingSsh(urls, true, "host_key_unverified"), { + remoteUrl: CLONE_URLS.sshUrl, + }), + ); + + assert.strictEqual( + error.detail, + "Clones use this environment's Git credentials, not a token saved in Settings. Host key verification failed. Cloning over HTTPS instead also failed: Git could not authenticate. fatal: could not read Username for 'https://github.com': terminal prompts disabled", + ); + }), +); + +it.effect.each([ + [ + "Git has no credentials for the remote", + "authentication_failed", + ["fatal: could not read Username for 'https://github.com': terminal prompts disabled"], + ], + [ + "the remote hides the repository", + "remote_unreachable", + [ + "remote: Repository not found.", + "fatal: repository 'https://github.com/octocat/t3code/' not found", + ], + ], +] as const)( + "explains that clones use this environment's Git credentials when %s", + ([, reason, stderrLines]) => + Effect.gen(function* () { + const error = yield* Effect.flip( + cloneInTemporaryDirectory( + { + execute: (input) => + Effect.gen(function* () { + for (const line of stderrLines) { + yield* input.progress?.onStderrLine?.(line) ?? Effect.void; + } + return yield* new GitCommandError({ + operation: input.operation, + command: "git", + cwd: input.cwd, + reason, + detail: "Git command exited with a non-zero status.", + exitCode: 128, + }); + }), + }, + { remoteUrl: CLONE_URLS.url }, + ), + ); + + for (const line of stderrLines) assert.include(error.detail, line); + // Clients clamp the error, so the guidance must come before Git's lines. + const guidanceEnd = error.detail.indexOf("Settings"); + assert.isAbove(guidanceEnd, -1); + assert.isBelow(guidanceEnd, error.detail.indexOf(stderrLines[0])); + assert.match(error.detail.slice(0, guidanceEnd), /credential/i); + }), +); + it.effect.each([ "git@ssh.dev.azure.com:v3/Fabrikam/Project1/fab_repo", "ssh://user:pa@rt@ssh.dev.azure.com/v3/Fabrikam/Project1/fab_repo", diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index 07c7b28d82ff2..ba27650908548 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -164,6 +164,40 @@ function publishPushFailureDetail(input: { } } +const CLONE_CREDENTIALS_NOTE = + "Clones use this environment's Git credentials, not a token saved in Settings."; + +// Hosts report private repositories the credentials cannot see as not found. +const REPOSITORY_NOT_FOUND_LINE = /^fatal: repository '.+' not found$/i; + +// Clients clamp clone errors to a few lines, so the explanation leads; Git's lines follow +// because hosts explain rejected credentials there. +function cloneFailureDetail( + reason: GitCommandFailureReason | undefined, + stderrTail: ReadonlyArray, +): string { + const output = stderrTail.join(" "); + const explanation = + reason === "authentication_failed" + ? "Git could not authenticate." + : stderrTail.some((line) => REPOSITORY_NOT_FOUND_LINE.test(line)) + ? "The repository was not found, or Git's credentials cannot access it." + : null; + if (explanation === null) { + return output.length > 0 ? output : "The repository could not be cloned."; + } + return `${explanation} ${CLONE_CREDENTIALS_NOTE}${output.length > 0 ? ` ${output}` : ""}`; +} + +// The note leads once even when only the HTTPS attempt carries it. +function combinedCloneFailureDetail(sshDetail: string, httpsDetail: string): string { + const withoutNote = (detail: string) => detail.replace(` ${CLONE_CREDENTIALS_NOTE}`, ""); + const combined = `${withoutNote(sshDetail)} Cloning over HTTPS instead also failed: ${withoutNote(httpsDetail)}`; + return sshDetail.includes(CLONE_CREDENTIALS_NOTE) || httpsDetail.includes(CLONE_CREDENTIALS_NOTE) + ? `${CLONE_CREDENTIALS_NOTE} ${combined}` + : combined; +} + /** * SSH rejection can be retried over HTTPS when the host's credential helper is available. * Explicit SSH requests must not fall back; unknown URL shapes must not be guessed. @@ -368,10 +402,7 @@ export const make = Effect.gen(function* () { new SourceControlRepositoryError({ operation: "cloneRepository", provider: input.provider ?? SourceControlProviderKind.make("unknown"), - detail: - stderrTail.length > 0 - ? stderrTail.join(" ") - : "The repository could not be cloned.", + detail: cloneFailureDetail(cause.reason, stderrTail), cause, }), ), @@ -402,7 +433,7 @@ export const make = Effect.gen(function* () { new SourceControlRepositoryError({ operation: "cloneRepository", provider: sshError.provider, - detail: `${sshError.detail} Cloning over HTTPS instead also failed: ${httpsError.detail}`, + detail: combinedCloneFailureDetail(sshError.detail, httpsError.detail), cause: httpsError.cause, }), ), diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index a73322fc65114..a623a3f8629eb 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -2431,6 +2431,14 @@ function OpenCommandPaletteDialog(props: { ); const canCloneProject = canWriteSourceControl && canCreateClonedProject; const isCloneDestinationStep = addProjectCloneFlow?.step === "confirm"; + const cloneEnvironmentLabel = + addProjectCloneFlow !== null && environments.length > 1 + ? (addProjectEnvironmentOptions.find( + (option) => option.environmentId === addProjectCloneFlow.environmentId, + )?.label ?? + environmentLabelById.get(addProjectCloneFlow.environmentId) ?? + null) + : null; const canSubmitBrowsePath = isBrowsing && !relativePathNeedsActiveProject && @@ -2944,6 +2952,7 @@ function OpenCommandPaletteDialog(props: { addProjectCloneFlow.repository?.nameWithOwner ?? addProjectCloneFlow.repositoryInput } description={addProjectCloneFlow.repository?.url ?? addProjectCloneFlow.remoteUrl} + environmentLabel={cloneEnvironmentLabel} /> ) : null} {browseAccessError ? ( diff --git a/apps/web/src/components/CommandPaletteAddProject.tsx b/apps/web/src/components/CommandPaletteAddProject.tsx index ce3797848e317..8b78c3f15e175 100644 --- a/apps/web/src/components/CommandPaletteAddProject.tsx +++ b/apps/web/src/components/CommandPaletteAddProject.tsx @@ -154,6 +154,7 @@ export function RemoteRepositorySummary(props: { readonly source: AddProjectRemoteSource; readonly title: string; readonly description: string; + readonly environmentLabel: string | null; }) { return (
@@ -165,6 +166,11 @@ export function RemoteRepositorySummary(props: { {props.title} {props.description} + {props.environmentLabel === null ? null : ( + + Clones on {props.environmentLabel} + + )}
diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 39731cb3d35b6..43f3ee21f913d 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -41,6 +41,9 @@ If `gh` is signed in to several accounts or hosts, expand **GitHub** in the same the account each host uses or turn a host off. A saved token or `GH_TOKEN` takes precedence over that choice; a host turned off stays off either way. +Cloning runs Git on the server, so it uses the server's Git credentials, not a token saved +in Settings. Run `gh auth setup-git` there, or configure another credential helper or an SSH key. + For GitHub Enterprise, sign in with `gh auth login --hostname YOUR_HOST`. T3 Code treats a custom server name as GitHub once it has a credential for that host.