import type { SshGitProvider } from '../providers/ssh-git-provider' import { extractExecError, ghExecFileAsync, gitExecFileAsync } from './runner' import { parseHostedRemote } from './hosted-remote-url' import { resolveDefaultBaseRefViaExec } from './repo' const EXPLICIT_USERNAME_CONFIG_KEYS = ['github.user', 'user.username'] as const const GH_LOGIN_PROBE_TIMEOUT_MS = 2500 // Why: a timeout-killed gh can leave a grandchild holding the stdio pipes, so // the exec promise may settle long after the kill. The wall keeps the resolver // on schedule either way (issue #7225: a hung gh froze startup for 127s). It // must exceed ghExecFileAsync's full worst-case envelope — 3 attempts × // GH_LOGIN_PROBE_TIMEOUT_MS plus its transient-retry backoff sleeps (250ms + // 1000ms ≈ 8.75s total) — or a slow-but-recovering gh would be misread as // stuck and start the retry cooldown. Retry-After sleeps can exceed any wall; // bounding those is exactly what the wall is for. const GH_LOGIN_PROBE_WALL_MS = 10_000 // Why: a timed-out probe says nothing about the account, so don't pin '' for // the whole session — retry after a cooldown instead of hammering a stuck gh. const GH_LOGIN_TIMEOUT_RETRY_MS = 5 * 60 * 1000 const LOCAL_GIT_READ_TIMEOUT_MS = 5000 export function normalizeGitUsername(value: string): string { const trimmed = value.trim() if (!trimmed) { return '' } const localPart = trimmed.includes('@') ? trimmed.split('@')[0] : trimmed return localPart.replace(/^\d+\+/, '') } /** * Hosted account logins used as branch-prefix segments must be single-path * tokens. Reject multi-line / JSON error bodies from a failed `gh api user` * (rate-limit 403 still prints JSON on stdout) so they never become branch names. */ export function isPlausibleHostedLogin(value: string): boolean { // GitHub usernames: 1–39 chars, alphanumerics and single hyphens, no leading/trailing hyphen. return ( /^[A-Za-z0-9]$/.test(value) || (/^[A-Za-z0-9][A-Za-z0-9-]{0,37}[A-Za-z0-9]$/.test(value) && !value.includes('--')) ) } // Not a check-ref-format rule: a login becomes one slash-free branch component, // which a loose ref stores as a single filename (255-byte cap on ext4/APFS/NTFS). // The ASCII-only charset below makes character count equal byte count. const MAX_BRANCH_SAFE_LOGIN_LENGTH = 255 /** * Provider-agnostic branch-safe token: GitLab/Bitbucket/self-hosted logins may * carry `_`/`.` and run longer than GitHub's 39-char limit, so the strict * GitHub rule must not gate explicitly configured usernames. */ export function isBranchSafeHostedLogin(value: string): boolean { if (value.length > MAX_BRANCH_SAFE_LOGIN_LENGTH) { return false } if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value)) { return false } // Dot placements git check-ref-format rejects; `.lock` is case-sensitive there too. return !value.includes('..') && !value.endsWith('.') && !value.endsWith('.lock') } function normalizeHostedLogin(value: string): string { const normalized = normalizeGitUsername(value) return normalized && isPlausibleHostedLogin(normalized) ? normalized : '' } /** Explicit `github.user`/`user.username` config is provider-agnostic; only reject non-tokens. */ function normalizeConfiguredLogin(value: string): string { const normalized = normalizeGitUsername(value) return normalized && isBranchSafeHostedLogin(normalized) ? normalized : '' } /** * A resolved username plus whether every probe on the way to it completed. * Non-authoritative '' (a probe timed out) must not overwrite a previously * persisted username; authoritative '' should clear one. */ export type ResolvedGitUsername = { username: string; authoritative: boolean } export async function getSshGitUsername( provider: SshGitProvider, repoPath: string ): Promise { // Why: SSH targets cannot rely on the local `gh` account, and git email/name // are author identity rather than hosted-account usernames. for (const key of EXPLICIT_USERNAME_CONFIG_KEYS) { try { const { stdout } = await provider.exec(['config', '--get', key], repoPath) const username = normalizeConfiguredLogin(stdout) if (username) { return username } } catch { // Missing config keys are expected; try the next explicit username key. } } return '' } type GhLoginProbeResult = { stdout: string; stderr: string; timedOut: boolean } type GhLoginOutcome = { login: string; timedOut: boolean } // gh reports one account for the whole machine, so the login is cached // per-process rather than per-repo (mirrors the old sync cache). Timed-out // probes use the soft retry timestamp instead of the permanent cache. let cachedGhLogin: string | null = null let ghLoginTimedOutAt: number | null = null let ghLoginProbeInFlight: Promise | null = null function isExecTimeoutError(err: unknown): boolean { if (!err || typeof err !== 'object') { return false } const { code, killed, signal } = err as { code?: unknown; killed?: unknown; signal?: unknown } // Why: on Windows a timeout kill surfaces as killed/SIGTERM with a null // code, not ETIMEDOUT — the old ETIMEDOUT-only check let a stuck first // probe fall through to a second equally stuck probe (issue #7225). return code === 'ETIMEDOUT' || killed === true || signal === 'SIGTERM' } async function runGhLoginProbe(args: string[]): Promise { let wallTimer: ReturnType | undefined const wall = new Promise((resolve) => { wallTimer = setTimeout( () => resolve({ stdout: '', stderr: '', timedOut: true }), GH_LOGIN_PROBE_WALL_MS ) wallTimer.unref?.() }) const exec = ghExecFileAsync(args, { timeout: GH_LOGIN_PROBE_TIMEOUT_MS }).then( ({ stdout, stderr }) => ({ stdout, stderr, timedOut: false }), (err: unknown) => { // Why: `gh auth status` reports the login on stderr with a non-zero // exit when partially authenticated, so failures still carry output. const { stdout, stderr } = extractExecError(err) return { stdout, stderr, timedOut: isExecTimeoutError(err) } } ) try { return await Promise.race([exec, wall]) } finally { if (wallTimer) { clearTimeout(wallTimer) } } } // Why: `gh auth status` prints one block per account — the login line first, // then `Active account: true/false`. Parse block-wise so a multi-account // output resolves the ACTIVE account instead of whatever login line happens // to follow the first `Active account: true` marker. function parseGhAuthStatusLogin(output: string): string { let currentLogin = '' let firstLogin = '' for (const line of output.split('\n')) { const login = line.match(/Logged in to github\.com account\s+([A-Za-z0-9-]+)/)?.[1] if (login) { currentLogin = login if (!firstLogin) { firstLogin = login } continue } if (/Active account:\s+true/.test(line) && currentLogin) { return currentLogin } } return firstLogin } async function probeGhLoginOnce(): Promise { const api = await runGhLoginProbe(['api', 'user', '-q', '.login']) const apiLogin = normalizeHostedLogin(api.stdout) if (apiLogin) { return { login: apiLogin, timedOut: false } } if (api.timedOut) { // Why: if `gh api user` timed out, `gh auth status` is likely to hit the // same stuck keychain/network path. Keep resolution bounded to one probe. return { login: '', timedOut: true } } const status = await runGhLoginProbe(['auth', 'status']) if (status.timedOut) { return { login: '', timedOut: true } } const output = `${status.stdout}\n${status.stderr}` return { login: normalizeHostedLogin(parseGhAuthStatusLogin(output)), timedOut: false } } async function getGhLoginOutcome(): Promise { if (cachedGhLogin !== null) { return { login: cachedGhLogin, timedOut: false } } if (ghLoginTimedOutAt !== null && Date.now() - ghLoginTimedOutAt < GH_LOGIN_TIMEOUT_RETRY_MS) { return { login: '', timedOut: true } } if (ghLoginProbeInFlight) { return ghLoginProbeInFlight } const probe = probeGhLoginOnce() .then((outcome) => { if (outcome.timedOut) { ghLoginTimedOutAt = Date.now() } else { cachedGhLogin = outcome.login ghLoginTimedOutAt = null } return outcome }) .finally(() => { ghLoginProbeInFlight = null }) ghLoginProbeInFlight = probe return probe } async function readGitStdout(repoPath: string, args: string[]): Promise { try { const { stdout } = await gitExecFileAsync(args, { cwd: repoPath, timeout: LOCAL_GIT_READ_TIMEOUT_MS }) return stdout.trim() } catch { return '' } } function getRemoteNameFromRef(shortRef: string, remotes: readonly string[]): string { const sortedRemotes = [...remotes].sort((a, b) => b.length - a.length) return sortedRemotes.find((remote) => shortRef.startsWith(`${remote}/`)) ?? '' } function getDefaultBranchName(shortRef: string, remoteName: string): string { if (!shortRef.includes('/')) { return shortRef } return remoteName ? shortRef.slice(remoteName.length + 1) : shortRef.split('/').slice(1).join('/') } async function getConfiguredBranchRemote(repoPath: string, branch: string | null): Promise { if (!branch) { return '' } const remote = await readGitStdout(repoPath, ['config', '--get', `branch.${branch}.remote`]) return remote === '.' ? '' : remote } /** * Faithful async port of the old candidate-ordered GitHub gate: only the * repo's *effective* remote (current-branch remote, default-branch remote, * default-base remote, origin, or a lone remote) may authorize the gh login. * Why: a GitLab-primary repo with a secondary GitHub mirror must NOT pick up * the GitHub account name as its branch prefix. */ async function localRepoHasEffectiveGitHubRemote(repoPath: string): Promise { const remoteList = await gitExecFileAsync(['remote'], { cwd: repoPath, timeout: LOCAL_GIT_READ_TIMEOUT_MS }).catch(() => null) const remotes = (remoteList?.stdout.trim() ?? '').split('\n').filter(Boolean) // Only a successful empty list proves there is no hosted remote to inspect. if (remoteList && remotes.length === 0) { return false } const defaultBaseRef = await resolveDefaultBaseRefViaExec((argv) => gitExecFileAsync(argv, { cwd: repoPath, timeout: LOCAL_GIT_READ_TIMEOUT_MS }) ) const defaultBaseRemote = defaultBaseRef ? getRemoteNameFromRef(defaultBaseRef, remotes) : '' const defaultBranch = defaultBaseRef ? getDefaultBranchName(defaultBaseRef, defaultBaseRemote) : null const currentBranch = await readGitStdout(repoPath, ['branch', '--show-current']) const candidateRemotes = [ await getConfiguredBranchRemote(repoPath, currentBranch || null), await getConfiguredBranchRemote(repoPath, defaultBranch), defaultBaseRemote, 'origin', remotes.length === 1 ? remotes[0] : '' ] const seen = new Set() for (const remote of candidateRemotes) { if (!remote || seen.has(remote)) { continue } seen.add(remote) const remoteUrl = await readGitStdout(repoPath, ['remote', 'get-url', remote]) if (remoteUrl && parseHostedRemote(remoteUrl)?.provider === 'github') { return true } } return false } /** * Async replacement for the old sync `getGitUsername`: explicit config keys * first, then the `gh` login — but only for repos whose effective remote is * GitHub, since a GitHub account name would be the wrong branch prefix for * GitLab/Bitbucket/self-hosted repos. Never rejects; unknown resolves to * { username: '', authoritative: false }. */ export async function resolveLocalGitUsernameDetailed( repoPath: string ): Promise { for (const key of EXPLICIT_USERNAME_CONFIG_KEYS) { try { const { stdout } = await gitExecFileAsync(['config', '--get', key], { cwd: repoPath, timeout: LOCAL_GIT_READ_TIMEOUT_MS }) // Why: config can hold free-form strings; only branch-safe logins become prefixes. const username = normalizeConfiguredLogin(stdout) if (username) { return { username, authoritative: true } } } catch { // Missing config keys are expected; try the next explicit username key. } } if (await localRepoHasEffectiveGitHubRemote(repoPath)) { const outcome = await getGhLoginOutcome() return { username: outcome.login, authoritative: !outcome.timedOut } } return { username: '', authoritative: true } } export async function resolveLocalGitUsername(repoPath: string): Promise { return (await resolveLocalGitUsernameDetailed(repoPath)).username } export function resetGhLoginCacheForTests(): void { cachedGhLogin = null ghLoginTimedOutAt = null ghLoginProbeInFlight = null }