Files
orca/src/main/git/remote-name-listing.ts
T
Wooseong KimandNeil 31db2774f8 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>
2026-09-13 20:18:22 -07:00

175 lines
5.7 KiB
TypeScript

import { readLocalGitConfigSignature } from '../github/local-git-config-signature'
import { getSshGitProvider, getSshGitProviderGeneration } from '../providers/ssh-git-dispatch'
import { runCoalescedProbe, type CoalescedProbes } from './coalesced-probe'
import type { GitAdmissionTier } from './command-runner/git-exec-options'
import { REMOTE_URL_PROBE_TIMEOUT_MS } from './remote-url-probe'
import { gitExecFileAsync } from './runner'
export type RemoteNameListingGitOptions = {
wslDistro?: string
admissionTier?: GitAdmissionTier
}
const SIGNED_REMOTE_NAME_LISTING_TTL_MS = 5 * 60_000
const UNSIGNED_REMOTE_NAME_LISTING_TTL_MS = 30_000
const REMOTE_NAME_LISTING_CACHE_MAX_ENTRIES = 512
type CachedRemoteNames = {
remotes: string[]
expiresAt: number
configSignature?: string
}
const remoteNameListingCache = new Map<string, CachedRemoteNames>()
const remoteNameListingInFlight: CoalescedProbes<string[] | null> = new Map()
/** @internal - exposed for tests only */
export function _resetRemoteNameListingCache(): void {
remoteNameListingCache.clear()
remoteNameListingInFlight.clear()
}
function parseRemoteNames(stdout: string): string[] {
return stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
}
function remoteNameListingCacheKey(
repoPath: string,
connectionId?: string | null,
localGitOptions: RemoteNameListingGitOptions = {}
): string {
const runtimeKey = connectionId
? `ssh:${connectionId}:${getSshGitProviderGeneration(connectionId)}`
: `local:${localGitOptions.wslDistro ?? 'host'}`
return `${runtimeKey}\0${repoPath}`
}
function pruneRemoteNameListingCache(now: number): void {
for (const [key, entry] of remoteNameListingCache) {
if (entry.expiresAt <= now) {
remoteNameListingCache.delete(key)
}
}
while (remoteNameListingCache.size > REMOTE_NAME_LISTING_CACHE_MAX_ENTRIES) {
const oldestKey = remoteNameListingCache.keys().next().value
if (oldestKey === undefined) {
return
}
remoteNameListingCache.delete(oldestKey)
}
}
function listingGitConfigContext(
repoPath: string,
connectionId?: string | null,
localGitOptions: RemoteNameListingGitOptions = {}
): { repoPath: string; connectionId: string | null; wslDistro?: string } {
return {
repoPath,
connectionId: connectionId ?? null,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
}
}
/**
* `git remote` names for one repo/runtime. Failed listings are not cached: a
* missed `upstream` would otherwise send issue/PR resolvers to origin on a
* contributor clone (#7331).
*/
export async function listCachedRemoteNames(
repoPath: string,
connectionId?: string | null,
localGitOptions: RemoteNameListingGitOptions = {}
): Promise<string[] | null> {
const cacheKey = remoteNameListingCacheKey(repoPath, connectionId, localGitOptions)
const now = Date.now()
pruneRemoteNameListingCache(now)
const cached = remoteNameListingCache.get(cacheKey)
if (cached && cached.expiresAt > now) {
if (cached.configSignature !== undefined) {
const currentSignature = await readLocalGitConfigSignature(
listingGitConfigContext(repoPath, connectionId, localGitOptions)
)
if (currentSignature === cached.configSignature) {
return cached.remotes
}
remoteNameListingCache.delete(cacheKey)
} else {
return cached.remotes
}
}
return runCoalescedProbe(remoteNameListingInFlight, cacheKey, async (ownsKey) => {
const configContext = listingGitConfigContext(repoPath, connectionId, localGitOptions)
const configSignatureBefore = await readLocalGitConfigSignature(configContext)
const remotes = await listUncachedRemoteNames(repoPath, connectionId, localGitOptions)
if (remotes === null) {
return null
}
if (ownsKey()) {
const configSignatureAfter = await readLocalGitConfigSignature(configContext)
const configSignature =
configSignatureBefore !== undefined && configSignatureBefore === configSignatureAfter
? configSignatureAfter
: undefined
remoteNameListingCache.set(cacheKey, {
remotes,
expiresAt:
Date.now() +
(configSignature
? SIGNED_REMOTE_NAME_LISTING_TTL_MS
: UNSIGNED_REMOTE_NAME_LISTING_TTL_MS),
...(configSignature ? { configSignature } : {})
})
pruneRemoteNameListingCache(Date.now())
}
return remotes
})
}
async function listUncachedRemoteNames(
repoPath: string,
connectionId?: string | null,
localGitOptions: RemoteNameListingGitOptions = {}
): Promise<string[] | null> {
if (connectionId) {
const provider = getSshGitProvider(connectionId)
if (!provider) {
return null
}
try {
const { stdout } = await provider.exec(['remote'], repoPath, {
signal: AbortSignal.timeout(REMOTE_URL_PROBE_TIMEOUT_MS)
})
return parseRemoteNames(stdout)
} catch {
return null
}
}
try {
const { stdout } = await gitExecFileAsync(['remote'], {
cwd: repoPath,
timeout: REMOTE_URL_PROBE_TIMEOUT_MS,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {}),
...(localGitOptions.admissionTier ? { admissionTier: localGitOptions.admissionTier } : {})
})
return parseRemoteNames(stdout)
} catch {
return null
}
}
/** Probe a named remote only when listing says it exists, or listing failed. */
export async function shouldProbeGitRemote(
repoPath: string,
remoteName: string,
connectionId?: string | null,
localGitOptions: RemoteNameListingGitOptions = {}
): Promise<boolean> {
const remotes = await listCachedRemoteNames(repoPath, connectionId, localGitOptions)
return remotes === null || remotes.includes(remoteName)
}