diff --git a/apps/server/src/sourceControl/GitHubAccountResolver.test.ts b/apps/server/src/sourceControl/GitHubAccountResolver.test.ts index 8cca549fea71..bbc7b963a253 100644 --- a/apps/server/src/sourceControl/GitHubAccountResolver.test.ts +++ b/apps/server/src/sourceControl/GitHubAccountResolver.test.ts @@ -117,13 +117,18 @@ describe("gitHubAccountAuthEnv", () => { { GH_HOST: "github.com", GH_TOKEN: "t0ken", - GIT_CONFIG_COUNT: "2", + GIT_CONFIG_COUNT: "4", // An empty helper resets the list, so the machine-global helper (usually // an OS keychain) cannot answer for this host first. GIT_CONFIG_KEY_0: "credential.https://github.com.helper", GIT_CONFIG_VALUE_0: "", GIT_CONFIG_KEY_1: "credential.https://github.com.helper", GIT_CONFIG_VALUE_1: "!gh auth git-credential", + // SSH remotes for the host are rewritten to HTTPS so the token applies. + GIT_CONFIG_KEY_2: "url.https://github.com/.insteadOf", + GIT_CONFIG_VALUE_2: "git@github.com:", + GIT_CONFIG_KEY_3: "url.https://github.com/.insteadOf", + GIT_CONFIG_VALUE_3: "ssh://git@github.com/", }, ); }); @@ -134,11 +139,15 @@ describe("gitHubAccountAuthEnv", () => { { GH_HOST: "ghe.corp", GH_ENTERPRISE_TOKEN: "t0ken", - GIT_CONFIG_COUNT: "2", + GIT_CONFIG_COUNT: "4", GIT_CONFIG_KEY_0: "credential.https://ghe.corp.helper", GIT_CONFIG_VALUE_0: "", GIT_CONFIG_KEY_1: "credential.https://ghe.corp.helper", GIT_CONFIG_VALUE_1: "!gh auth git-credential", + GIT_CONFIG_KEY_2: "url.https://ghe.corp/.insteadOf", + GIT_CONFIG_VALUE_2: "git@ghe.corp:", + GIT_CONFIG_KEY_3: "url.https://ghe.corp/.insteadOf", + GIT_CONFIG_VALUE_3: "ssh://git@ghe.corp/", }, ); }); @@ -148,13 +157,17 @@ describe("gitHubAccountAuthEnv", () => { { account: { host: "github.com", login: "octo" }, token: "t0ken" }, { GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: "core.pager", GIT_CONFIG_VALUE_0: "cat" }, ); - assert.equal(env.GIT_CONFIG_COUNT, "3"); + assert.equal(env.GIT_CONFIG_COUNT, "5"); // Index 0 stays with the inherited pair instead of being overwritten. assert.equal(env.GIT_CONFIG_KEY_0, undefined); assert.equal(env.GIT_CONFIG_KEY_1, "credential.https://github.com.helper"); assert.equal(env.GIT_CONFIG_VALUE_1, ""); assert.equal(env.GIT_CONFIG_KEY_2, "credential.https://github.com.helper"); assert.equal(env.GIT_CONFIG_VALUE_2, "!gh auth git-credential"); + assert.equal(env.GIT_CONFIG_KEY_3, "url.https://github.com/.insteadOf"); + assert.equal(env.GIT_CONFIG_VALUE_3, "git@github.com:"); + assert.equal(env.GIT_CONFIG_KEY_4, "url.https://github.com/.insteadOf"); + assert.equal(env.GIT_CONFIG_VALUE_4, "ssh://git@github.com/"); }); it("ignores a malformed inherited GIT_CONFIG_COUNT", () => { @@ -162,7 +175,7 @@ describe("gitHubAccountAuthEnv", () => { { account: { host: "github.com", login: "octo" }, token: "t0ken" }, { GIT_CONFIG_COUNT: "not-a-number" }, ); - assert.equal(env.GIT_CONFIG_COUNT, "2"); + assert.equal(env.GIT_CONFIG_COUNT, "4"); assert.equal(env.GIT_CONFIG_KEY_0, "credential.https://github.com.helper"); }); }); diff --git a/apps/server/src/sourceControl/GitHubAccountResolver.ts b/apps/server/src/sourceControl/GitHubAccountResolver.ts index 29757e981f69..9e6a41212b6c 100644 --- a/apps/server/src/sourceControl/GitHubAccountResolver.ts +++ b/apps/server/src/sourceControl/GitHubAccountResolver.ts @@ -79,6 +79,13 @@ export function gitHubAccountGhEnv(resolved: ResolvedGitHubAccount): NodeJS.Proc * account wins; `!gh auth git-credential` then returns the `GH_TOKEN` above. * This is the same mechanism CI uses (`GH_TOKEN` + gh credential helper). * + * It also rewrites the account host's SSH remote forms to HTTPS + * (`url.https:///.insteadOf`), so a repo whose remote is + * `git@:owner/repo` still authenticates as the selected account. Without + * it, an SSH remote bypasses the token entirely and uses the machine's SSH key + * — i.e. account selection would silently do nothing. The rewrite is scoped to + * this process and never touches the stored remote URL. + * * Pairs are appended after any `GIT_CONFIG_COUNT` already present in `baseEnv`, * so a runtime git config the process inherited keeps working instead of being * silently overwritten by our pairs claiming indices 0 and 1. @@ -87,15 +94,21 @@ export function gitHubAccountAuthEnv( resolved: ResolvedGitHubAccount, baseEnv: NodeJS.ProcessEnv, ): NodeJS.ProcessEnv { - const helperKey = `credential.https://${resolved.account.host}.helper`; + const host = resolved.account.host; + const helperKey = `credential.https://${host}.helper`; + const insteadOfKey = `url.https://${host}/.insteadOf`; const offset = parseGitConfigCount(baseEnv.GIT_CONFIG_COUNT); return { ...gitHubAccountGhEnv(resolved), - GIT_CONFIG_COUNT: String(offset + 2), + GIT_CONFIG_COUNT: String(offset + 4), [`GIT_CONFIG_KEY_${offset}`]: helperKey, [`GIT_CONFIG_VALUE_${offset}`]: "", [`GIT_CONFIG_KEY_${offset + 1}`]: helperKey, [`GIT_CONFIG_VALUE_${offset + 1}`]: "!gh auth git-credential", + [`GIT_CONFIG_KEY_${offset + 2}`]: insteadOfKey, + [`GIT_CONFIG_VALUE_${offset + 2}`]: `git@${host}:`, + [`GIT_CONFIG_KEY_${offset + 3}`]: insteadOfKey, + [`GIT_CONFIG_VALUE_${offset + 3}`]: `ssh://git@${host}/`, }; } diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 91b3dd9fce9d..28a935c86b9d 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -88,6 +88,7 @@ function parseGitHubAccounts(input: SourceControlAuthProbeInput) { login: account.account, authenticated: account.authenticated, active: account.active, + ...(account.error === null ? {} : { authError: account.error }), })); } diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index a1b5625ead4a..c9dfdd6257ed 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1285,9 +1285,11 @@ it.layer( const env = ptyAdapter.spawnInputs[0]?.env; assert.equal(env?.GH_HOST, "github.com"); assert.equal(env?.GH_TOKEN, "gho_project"); - assert.equal(env?.GIT_CONFIG_COUNT, "2"); + assert.equal(env?.GIT_CONFIG_COUNT, "4"); assert.equal(env?.GIT_CONFIG_KEY_1, "credential.https://github.com.helper"); assert.equal(env?.GIT_CONFIG_VALUE_1, "!gh auth git-credential"); + assert.equal(env?.GIT_CONFIG_KEY_2, "url.https://github.com/.insteadOf"); + assert.equal(env?.GIT_CONFIG_VALUE_2, "git@github.com:"); }), ); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index dfd56cade4b4..ca822c090b74 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -923,6 +923,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return resolved === null ? {} : gitHubAccountAuthEnv(resolved, process.env); }); + /** + * Env for a git command that talks to a remote (fetch/pull/push). Combines + * the non-interactive base — so the server never blocks on a credential + * prompt — with the project's selected-account auth env (token + gh + * credential helper + SSH→HTTPS rewrite). When no account is attached, or the + * resolver isn't provided, this is just the non-interactive base and git uses + * ambient auth. + * + * Every remote-touching git command routes through this so account selection + * can't be silently skipped by a call site that forgot to opt in — the class + * of bug that made "set an account for a project" only half-work. + */ + const resolveNetworkGitEnv = (cwd: string): Effect.Effect => + resolveAccountAuthEnv(cwd).pipe( + Effect.map((authEnv) => ({ ...STATUS_UPSTREAM_REFRESH_ENV, ...authEnv })), + ); + const runGitWithEnv = ( operation: string, cwd: string, @@ -1020,16 +1037,21 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ): Effect.Effect => { const fetchCwd = path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; - return executeGit( - "GitVcsDriver.fetchRemoteForStatus", - fetchCwd, - ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", remoteName], - { - env: STATUS_UPSTREAM_REFRESH_ENV, - fallbackErrorDetail: "Background Git fetch exited with a non-zero status.", - timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT), - }, - ).pipe(Effect.asVoid); + return resolveNetworkGitEnv(fetchCwd).pipe( + Effect.flatMap((env) => + executeGit( + "GitVcsDriver.fetchRemoteForStatus", + fetchCwd, + ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", remoteName], + { + env, + fallbackErrorDetail: "Background Git fetch exited with a non-zero status.", + timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT), + }, + ), + ), + Effect.asVoid, + ); }; const resolveRepositoryPathsUncached = Effect.fn("resolveRepositoryPathsUncached")(function* ( @@ -1856,8 +1878,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); } - // Authenticate the push as the project's selected GitHub account (if any). - const authEnv = yield* resolveAccountAuthEnv(cwd); + // Authenticate the push as the project's selected GitHub account (if any), + // non-interactively so it fails fast instead of prompting on the server. + const authEnv = yield* resolveNetworkGitEnv(cwd); const requestedRemoteName = options?.remoteName?.trim() || null; if (requestedRemoteName) { @@ -1997,7 +2020,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ["rev-parse", "HEAD"], true, ).pipe(Effect.map((stdout) => stdout.trim())); + const pullEnv = yield* resolveNetworkGitEnv(cwd); yield* executeGit("GitVcsDriver.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], { + env: pullEnv, timeoutMs: 30_000, fallbackErrorDetail: "git pull failed", }); @@ -2590,6 +2615,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const fetchPullRequestBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchPullRequestBranch"] = Effect.fn("fetchPullRequestBranch")(function* (input) { const remoteName = yield* resolvePrimaryRemoteName(input.cwd); + const env = yield* resolveNetworkGitEnv(input.cwd); yield* executeGit( "GitVcsDriver.fetchPullRequestBranch", input.cwd, @@ -2601,6 +2627,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* `+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`, ], { + env, fallbackErrorDetail: "git fetch pull request branch failed", }, ); @@ -2608,13 +2635,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const fetchRemote: GitVcsDriver.GitVcsDriver["Service"]["fetchRemote"] = Effect.fn("fetchRemote")( function* (input) { - const authEnv = yield* resolveAccountAuthEnv(input.cwd); + const env = yield* resolveNetworkGitEnv(input.cwd); yield* executeGit( "GitVcsDriver.fetchRemote", input.cwd, ["fetch", "--quiet", input.remoteName], { - env: { ...STATUS_UPSTREAM_REFRESH_ENV, ...authEnv }, + env, fallbackErrorDetail: `git fetch ${input.remoteName} failed`, }, ); @@ -2642,7 +2669,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const fetchRemoteBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchRemoteBranch"] = Effect.fn( "fetchRemoteBranch", )(function* (input) { - const authEnv = yield* resolveAccountAuthEnv(input.cwd); + const authEnv = yield* resolveNetworkGitEnv(input.cwd); yield* runGitWithEnv( "GitVcsDriver.fetchRemoteBranch.fetch", input.cwd, @@ -2669,13 +2696,19 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const fetchRemoteTrackingBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchRemoteTrackingBranch"] = Effect.fn("fetchRemoteTrackingBranch")(function* (input) { - yield* runGit("GitVcsDriver.fetchRemoteTrackingBranch", input.cwd, [ - "fetch", - "--quiet", - "--no-tags", - input.remoteName, - `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`, - ]); + const env = yield* resolveNetworkGitEnv(input.cwd); + yield* runGitWithEnv( + "GitVcsDriver.fetchRemoteTrackingBranch", + input.cwd, + [ + "fetch", + "--quiet", + "--no-tags", + input.remoteName, + `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`, + ], + env, + ); }); const setBranchUpstream: GitVcsDriver.GitVcsDriver["Service"]["setBranchUpstream"] = (input) => diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 5f9f305b04d4..9c594245bb95 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -1059,14 +1059,16 @@ function ProjectGitHubAccountField({ const discovery = useEnvironmentQuery( sourceControlEnvironment.discovery({ environmentId: member.environmentId, input: {} }), ); - const accounts = useMemo( + const allAccounts = useMemo( () => - ( - discovery.data?.sourceControlProviders.find((provider) => provider.kind === "github") - ?.accounts ?? [] - ).filter((account) => account.authenticated), + discovery.data?.sourceControlProviders.find((provider) => provider.kind === "github") + ?.accounts ?? [], [discovery.data], ); + const accounts = useMemo( + () => allAccounts.filter((account) => account.authenticated), + [allAccounts], + ); const accountByValue = useMemo( () => new Map(accounts.map((account) => [githubAccountValue(account), account] as const)), [accounts], @@ -1074,6 +1076,23 @@ function ProjectGitHubAccountField({ const current = member.gitHubAccount; const isLoading = discovery.isPending && discovery.data === null; + // The selected account is unusable when `gh` no longer lists it as + // authenticated (token expired/revoked) or doesn't know it at all. Git then + // silently falls back to the machine default account, so flag it here — the + // one place where the selection can be changed or re-authenticated. + const currentHealth = + current === null + ? undefined + : allAccounts.find( + (account) => account.host === current.host && account.login === current.login, + ); + const currentAuthError = + current !== null && + discovery.data !== null && + (currentHealth === undefined || !currentHealth.authenticated) + ? (currentHealth?.authError ?? "it is not signed in to the GitHub CLI") + : null; + const label = GitHub account; if (accounts.length === 0) { @@ -1137,6 +1156,13 @@ function ProjectGitHubAccountField({ ))} + {current !== null && currentAuthError !== null ? ( +

+ {githubAccountLabel(current)} can’t be used because {currentAuthError}. Git runs as your + default account until you re-authenticate with{" "} + gh auth login. +

+ ) : null} ); } diff --git a/docs/integrations/source-control-providers.md b/docs/integrations/source-control-providers.md index 5def6a78a293..a5bf409114b2 100644 --- a/docs/integrations/source-control-providers.md +++ b/docs/integrations/source-control-providers.md @@ -67,12 +67,12 @@ If you juggle more than one GitHub identity — a work account and a personal on Once attached, every GitHub operation T3 Code runs for that project acts as the chosen account: - **`gh` operations** — creating and listing pull requests, checking out a PR, viewing or creating repositories. -- **`git` over HTTPS** — push, pull, and fetch, including the push half of **Commit, Push & PR**. T3 Code points Git's credential lookup for that host at the chosen account instead of letting your OS keychain answer with whichever account it cached first. Only that host is redirected, so credentials for other hosts keep working. +- **`git` over the network** — push, pull, fetch, and the background refresh that keeps each project's branch list and ahead/behind counts up to date (including the push half of **Commit, Push & PR**). T3 Code points Git's credential lookup for that host at the chosen account instead of letting your OS keychain answer with whichever account it cached first. Only that host is redirected, so credentials for other hosts keep working. If the project's remote is an SSH URL (`git@github.com:…`), T3 Code rewrites just that host to HTTPS for its own operations so the chosen account's token still applies — your stored remote URL is left unchanged. - **The integrated terminal** — each terminal starts with `gh` and `git` already pinned to the project's account, so `gh repo view` and `git push` see the same repositories the app does. You don't need `gh auth switch`; switching globally won't help either, because the pin is per project. T3 Code never stores your token; it asks the GitHub CLI for a short-lived token for that account only when it needs one, and it never changes which account is globally active, so projects on different accounts don't interfere with each other. -> **Notes:** A terminal picks up the account when it starts — restart an open terminal after changing the selection. Account pinning only applies to HTTPS remotes; SSH remotes (`git@github.com:...`) authenticate with your SSH keys, which T3 Code does not manage. +> **Notes:** A terminal picks up the account when it starts — restart an open terminal after changing the selection. If the selected account can't be used (its GitHub CLI token expired or was revoked), the **GitHub account** selector flags it and Git falls back to your default account until you re-run `gh auth login`. Commit authorship still comes from your Git config (`user.name`/`user.email`), not the selected account. ## Getting Started diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index 8b79c02229b7..53226f75df9e 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -159,6 +159,13 @@ export const SourceControlAccountInfo = Schema.Struct({ login: TrimmedNonEmptyString, authenticated: Schema.Boolean, active: Schema.Boolean, + /** + * When the CLI knows about this account but it is not usable (e.g. its token + * expired or was revoked), the human-readable reason from `gh auth status`. + * Absent for healthy accounts. Lets the UI explain why a project's selected + * account fell back to the machine default instead of failing silently. + */ + authError: Schema.optional(TrimmedNonEmptyString), }); export type SourceControlAccountInfo = typeof SourceControlAccountInfo.Type;