Skip to content
Merged
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
21 changes: 17 additions & 4 deletions apps/server/src/sourceControl/GitHubAccountResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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://hub.lumenfield.work.helper",
GIT_CONFIG_VALUE_0: "",
GIT_CONFIG_KEY_1: "credential.https://hub.lumenfield.work.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/",
},
);
});
Expand All @@ -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/",
},
);
});
Expand All @@ -148,21 +157,25 @@ 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://hub.lumenfield.work.helper");
assert.equal(env.GIT_CONFIG_VALUE_1, "");
assert.equal(env.GIT_CONFIG_KEY_2, "credential.https://hub.lumenfield.work.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", () => {
const env = 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://hub.lumenfield.work.helper");
});
});
Expand Down
17 changes: 15 additions & 2 deletions apps/server/src/sourceControl/GitHubAccountResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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://<host>/.insteadOf`), so a repo whose remote is
* `git@<host>: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.
Expand All @@ -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}/`,
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ function parseGitHubAccounts(input: SourceControlAuthProbeInput) {
login: account.account,
authenticated: account.authenticated,
active: account.active,
...(account.error === null ? {} : { authError: account.error }),
}));
}

Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/terminal/Manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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://hub.lumenfield.work.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:");
}),
);

Expand Down
77 changes: 55 additions & 22 deletions apps/server/src/vcs/GitVcsDriverCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NodeJS.ProcessEnv> =>
resolveAccountAuthEnv(cwd).pipe(
Effect.map((authEnv) => ({ ...STATUS_UPSTREAM_REFRESH_ENV, ...authEnv })),
);

const runGitWithEnv = (
operation: string,
cwd: string,
Expand Down Expand Up @@ -1020,16 +1037,21 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
): Effect.Effect<void, GitCommandError> => {
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* (
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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",
});
Expand Down Expand Up @@ -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,
Expand All @@ -2601,20 +2627,21 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
`+refs/pull/${input.prNumber}/head:refs/heads/${input.branch}`,
],
{
env,
fallbackErrorDetail: "git fetch pull request branch failed",
},
);
});

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`,
},
);
Expand Down Expand Up @@ -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,
Expand All @@ -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) =>
Expand Down
36 changes: 31 additions & 5 deletions apps/web/src/components/SidebarV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1059,21 +1059,40 @@ 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],
);
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 = <span className="font-medium text-foreground">GitHub account</span>;

if (accounts.length === 0) {
Expand Down Expand Up @@ -1137,6 +1156,13 @@ function ProjectGitHubAccountField({
))}
</SelectPopup>
</Select>
{current !== null && currentAuthError !== null ? (
<p className="text-xs text-amber-600 dark:text-amber-400">
{githubAccountLabel(current)} can’t be used because {currentAuthError}. Git runs as your
default account until you re-authenticate with{" "}
<code className="rounded bg-muted px-1 py-px text-[11px]">gh auth login</code>.
</p>
) : null}
</label>
);
}
Expand Down
4 changes: 2 additions & 2 deletions docs/integrations/source-control-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions packages/contracts/src/sourceControl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down