fix(git): skip upstream remote probes when the remote is absent (#18455)

* fix(git): skip upstream remote probes when the remote is absent

Issue and PR resolvers listed remotes by probing `git remote get-url
upstream` on every poll, including origin-only clones where that remote
cannot exist. List remotes once, cache against git config, and skip the
probe unless `upstream` is present.

* fix(git): avoid stale remote probe cache entries

* fix(github): observe origin repository probe failures

* fix(github): observe verified origin probe failures

* fix(github): skip missing upstream probe for PR lists

* test(github): scope the #9171 lazy-resolution guard to default-branch commands

The guard asserted that no git command runs for an open PR, using "no git at
all" as a proxy for "no default-branch resolution". Remote-name listing is a
separate concern, so allow it and keep every other command forbidden; the
symbolic-ref/rev-parse resolution this issue is about stays unreachable.

---------

Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
Wooseong Kim
2026-09-13 20:18:22 -07:00
committed by GitHub
co-authored by Neil
parent 16d1ab81d3
commit 31db2774f8
13 changed files with 1016 additions and 241 deletions
+52 -141
View File
@@ -4,27 +4,19 @@ import {
githubRepoIdentityKey,
isDefaultGitHubHost
} from '../../shared/github/repository-identity-key'
import {
getOwnerRepoForRemote,
ghRepoExecOptions,
githubRepoContext,
type GitHubRemoteIdentityProbeOptions,
type LocalGitExecOptions
} from './gh-utils'
import {
getEnterpriseGitHubRepoSlug,
getEnterpriseGitHubRepoSlugForRemote,
isGitHubHostAuthenticated
} from './github-enterprise-repository'
import { shouldProbeGitRemote } from '../git/remote-name-listing'
import { ghRepoExecOptions, githubRepoContext, type LocalGitExecOptions } from './gh-utils'
import { isGitHubHostAuthenticated } from './github-enterprise-repository'
import { githubHostExecOptions } from './github-repository-host'
import {
isValidGitHubApiRepository,
type GitHubApiRepositoryResolution
} from './github-api-repository-validation'
import {
githubApiRepositoryProbeCacheKey,
resolveGitHubApiRepositoryProbe
} from './github-api-repository-probe'
_resetOriginGitHubApiRepositoryCache,
getGitHubApiRepositoryForRemote,
getOriginGitHubApiRepository
} from './github-api-repository-remote-probe'
export {
githubHostExecOptions,
@@ -40,123 +32,10 @@ export type GitHubRepoExecution = {
ownerRepo: GitHubApiRepository | null
ghOptions: GitHubRepoExecOptions
}
// Why: cache the uncached Enterprise remote probe used by hot paths.
const ORIGIN_REPO_CACHE_TTL_MS = 30_000
const ORIGIN_REPO_CACHE_MAX_ENTRIES = 512
const originRepoCache = new Map<string, { value: GitHubApiRepository | null; expiresAt: number }>()
const originRepoInFlight = new Map<string, Promise<GitHubApiRepository | null>>()
/** @internal - exposed for tests only */
export function _resetOriginGitHubApiRepositoryCache(): void {
originRepoCache.clear()
originRepoInFlight.clear()
}
function pruneOriginRepoCache(now: number): void {
for (const [key, entry] of originRepoCache) {
if (entry.expiresAt <= now) {
originRepoCache.delete(key)
}
}
while (originRepoCache.size > ORIGIN_REPO_CACHE_MAX_ENTRIES) {
const oldestKey = originRepoCache.keys().next().value
if (oldestKey === undefined) {
return
}
originRepoCache.delete(oldestKey)
}
}
/**
* Host-qualified repository identity for one remote: github.com remotes come
* from the cached slug parser; any other GitHub-shaped host is auth-gated so a
* non-GitHub forge never routes to the GitHub provider.
*/
export async function getGitHubApiRepositoryForRemote(
repoPath: string,
remoteName: string,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {},
probeOptions: GitHubRemoteIdentityProbeOptions = {}
): Promise<GitHubApiRepository | null> {
// Why: generic PR resolution prefers upstream, but this API represents the
// caller-selected remote exactly (#7331).
const requireVerifiedSshProbe = probeOptions.requireVerifiedSshProbe === true
const verifiedIdentityArgs = requireVerifiedSshProbe ? ([probeOptions] as const) : []
const ownerRepo = await getOwnerRepoForRemote(
repoPath,
remoteName,
connectionId,
localGitOptions,
...verifiedIdentityArgs
)
if (ownerRepo) {
return { ...ownerRepo, host: 'github.com' }
}
const cacheKey = githubApiRepositoryProbeCacheKey(
repoPath,
remoteName,
connectionId,
localGitOptions,
requireVerifiedSshProbe
)
const now = Date.now()
pruneOriginRepoCache(now)
const cached = originRepoCache.get(cacheKey)
if (cached && cached.expiresAt > now) {
return cached.value
}
const inFlight = originRepoInFlight.get(cacheKey)
if (inFlight) {
return inFlight
}
const probe = (async () => {
const enterpriseOptions =
Object.keys(localGitOptions).length > 0 ? { localGitExecOptions: localGitOptions } : {}
const verifiedEnterpriseArgs = requireVerifiedSshProbe ? ([true] as const) : []
const slug =
remoteName === 'origin'
? await getEnterpriseGitHubRepoSlug(
repoPath,
connectionId,
enterpriseOptions,
...verifiedEnterpriseArgs
)
: await getEnterpriseGitHubRepoSlugForRemote(
repoPath,
remoteName,
connectionId,
enterpriseOptions,
...verifiedEnterpriseArgs
)
// Why: undefined means the gh auth inventory could not be read. Caching it
// as a negative would turn a transient spawn failure into a 30-second miss.
if (slug !== undefined) {
originRepoCache.set(cacheKey, {
value: slug,
expiresAt: Date.now() + ORIGIN_REPO_CACHE_TTL_MS
})
pruneOriginRepoCache(Date.now())
}
return resolveGitHubApiRepositoryProbe(slug, requireVerifiedSshProbe)
})()
originRepoInFlight.set(cacheKey, probe)
try {
return await probe
} finally {
if (originRepoInFlight.get(cacheKey) === probe) {
originRepoInFlight.delete(cacheKey)
}
}
}
export async function getOriginGitHubApiRepository(
repoPath: string,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<GitHubApiRepository | null> {
return getGitHubApiRepositoryForRemote(repoPath, 'origin', connectionId, localGitOptions)
export {
_resetOriginGitHubApiRepositoryCache,
getGitHubApiRepositoryForRemote,
getOriginGitHubApiRepository
}
/** Hosted mirror of getIssueOwnerRepo: issues prefer `upstream` over `origin`. */
@@ -165,16 +44,26 @@ export async function getIssueGitHubApiRepository(
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<GitHubApiRepository | null> {
const upstream = await getGitHubApiRepositoryForRemote(
const originPromise = getGitHubApiRepositoryForRemote(
repoPath,
'upstream',
'origin',
connectionId,
localGitOptions
).then(
(value) => ({ status: 'fulfilled' as const, value }),
(reason: unknown) => ({ status: 'rejected' as const, reason })
)
const upstream = (await shouldProbeGitRemote(repoPath, 'upstream', connectionId, localGitOptions))
? await getGitHubApiRepositoryForRemote(repoPath, 'upstream', connectionId, localGitOptions)
: null
if (upstream) {
return upstream
}
return getGitHubApiRepositoryForRemote(repoPath, 'origin', connectionId, localGitOptions)
const origin = await originPromise
if (origin.status === 'rejected') {
throw origin.reason
}
return origin.value
}
export type GitHubApiRepositoryCandidates = {
@@ -188,14 +77,36 @@ export async function resolveGitHubApiRepositoryCandidates(
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<GitHubApiRepositoryCandidates> {
const [upstream, origin] = await Promise.all([
getGitHubApiRepositoryForRemote(repoPath, 'upstream', connectionId, localGitOptions, {
const originPromise = getGitHubApiRepositoryForRemote(
repoPath,
'origin',
connectionId,
localGitOptions,
{
requireVerifiedSshProbe: true
}),
getGitHubApiRepositoryForRemote(repoPath, 'origin', connectionId, localGitOptions, {
requireVerifiedSshProbe: true
})
}
).then(
(value) => ({ status: 'fulfilled' as const, value }),
(reason: unknown) => ({ status: 'rejected' as const, reason })
)
const probeUpstream = await shouldProbeGitRemote(
repoPath,
'upstream',
connectionId,
localGitOptions
)
const [upstream, originResult] = await Promise.all([
probeUpstream
? getGitHubApiRepositoryForRemote(repoPath, 'upstream', connectionId, localGitOptions, {
requireVerifiedSshProbe: true
})
: null,
originPromise
])
if (originResult.status === 'rejected') {
throw originResult.reason
}
const origin = originResult.value
const seen = new Set<string>()
const candidates: GitHubApiRepository[] = []
for (const candidate of [upstream, origin]) {