fix(github): resolve owner/repo through SSH Host aliases (#10284) (#10361)

* fix(github): resolve owner/repo through SSH Host aliases (#10284)

Expand OpenSSH Host → HostName via ssh -G before classifying github.com
identity so PR merge works when origin is git@alias:owner/repo.git.
Transport URLs stay unchanged so IdentityFile selection is preserved.
Do not long-negative-cache indeterminate ssh -G failures.

* fix(github): harden SSH alias resolution
This commit is contained in:
Neil 2026-07-25 23:34:17 -07:00 committed by GitHub
parent c67aadbc18
commit 19d082a164
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1011 additions and 140 deletions

View File

@ -501,6 +501,32 @@ describe('runner execFile timeout handling', () => {
})
})
it('routes fixed commands through an explicitly selected WSL distro', async () => {
await withPlatform('win32', async () => {
const child = createMockChildProcess(1234)
execFileMock.mockImplementation((_cmd, _args, _opts, cb) => {
cb(null, 'hostname github.com\n', '')
return child
})
await commandExecFileAsync('ssh', ['-G', '--', 'github-work'], {
cwd: String.raw`C:\repo`,
timeout: 5_000,
wslDistro: 'Ubuntu'
})
expect(execFileMock).toHaveBeenCalledWith(
'wsl.exe',
['-d', 'Ubuntu', '--', 'bash', '-c', expect.any(String)],
expect.objectContaining({ cwd: undefined }),
expect.any(Function)
)
const shellCommand = execFileMock.mock.calls[0]?.[1]?.[5] as string
expect(shellCommand).toContain('/mnt/c/repo')
expect(shellCommand).toContain("'ssh' '-G' '--' 'github-work'")
})
})
it('forwards synthesized network SSH policy into the selected WSL distro', async () => {
await withPlatform('win32', async () => {
const child = createMockChildProcess(1234)

View File

@ -278,6 +278,7 @@ type CommandExecOptions = {
timeout?: number
env?: NodeJS.ProcessEnv
signal?: AbortSignal
wslDistro?: string
}
function isMissingCommandError(error: unknown): boolean {
@ -879,23 +880,24 @@ export async function commandExecFileAsync(
args: string[],
options: CommandExecOptions = {}
): Promise<{ stdout: string; stderr: string }> {
const resolved = resolveCommand(command, args, options.cwd)
const { wslDistro, ...execOptions } = options
const resolved = resolveCommand(command, args, options.cwd, wslDistro)
const binary =
resolved.wsl === null ? resolveWindowsCommand(resolved.binary, options.env) : resolved.binary
if (isWindowsBatchScript(binary)) {
return spawnCommandCapture(binary, resolved.args, {
...options,
...execOptions,
cwd: resolved.cwd
})
}
try {
const { stdout, stderr } = await execFileCapture(binary, resolved.args, {
cwd: resolved.cwd,
encoding: options.encoding ?? 'utf-8',
maxBuffer: options.maxBuffer,
timeout: options.timeout,
env: options.env,
signal: options.signal
encoding: execOptions.encoding ?? 'utf-8',
maxBuffer: execOptions.maxBuffer,
timeout: execOptions.timeout,
env: execOptions.env,
signal: execOptions.signal
})
return { stdout: stdout as string, stderr: stderr as string }
} catch (error) {
@ -904,7 +906,7 @@ export async function commandExecFileAsync(
resolveWindowsCommand(`${resolved.binary}.cmd`, options.env),
resolved.args,
{
...options,
...execOptions,
cwd: resolved.cwd
}
)

View File

@ -3,10 +3,13 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { gitExecFileAsyncMock, getSshGitProviderMock } = vi.hoisted(() => ({
gitExecFileAsyncMock: vi.fn(),
getSshGitProviderMock: vi.fn()
}))
const { gitExecFileAsyncMock, getSshGitProviderGenerationMock, getSshGitProviderMock } = vi.hoisted(
() => ({
gitExecFileAsyncMock: vi.fn(),
getSshGitProviderGenerationMock: vi.fn(() => 0),
getSshGitProviderMock: vi.fn()
})
)
vi.mock('../git/runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock,
@ -14,6 +17,7 @@ vi.mock('../git/runner', () => ({
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProviderGeneration: getSshGitProviderGenerationMock,
getSshGitProvider: getSshGitProviderMock
}))
@ -38,6 +42,8 @@ import {
describe('github owner/repo resolution', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
getSshGitProviderGenerationMock.mockReset()
getSshGitProviderGenerationMock.mockReturnValue(0)
getSshGitProviderMock.mockReset()
_resetOwnerRepoCache()
__resetLocalGitConfigSignatureCacheForTests()

View File

@ -13,25 +13,26 @@ export { classifyGhError, classifyListIssuesError } from './gh-error-classificat
export {
_getOwnerRepoCacheSize,
_resetOwnerRepoCache,
getIssueOwnerRepo,
getOwnerRepo,
getOwnerRepoForRemote,
getRemoteUrlForRepo,
ghRepoExecOptions,
githubRepoContext,
parseGitHubOwnerRepo,
parseGitHubRemoteIdentity,
resolveIssueSource,
resolvePRRepositoryCandidates
parseGitHubRemoteIdentity
} from './github-repository-identity'
export type {
GitHubRemoteIdentity,
GitHubRepoContext,
LocalGitExecOptions,
OwnerRepo,
PRRepositoryCandidates,
ResolvedIssueSource
OwnerRepo
} from './github-repository-identity'
export {
getIssueOwnerRepo,
getOwnerRepo,
resolveIssueSource,
resolvePRRepositoryCandidates
} from './github-owner-repo-selection'
export type { PRRepositoryCandidates, ResolvedIssueSource } from './github-owner-repo-selection'
const MAX_CONCURRENT = 4
let running = 0

View File

@ -1,23 +1,32 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { ghExecFileAsyncMock, gitExecFileAsyncMock } = vi.hoisted(() => ({
ghExecFileAsyncMock: vi.fn(),
gitExecFileAsyncMock: vi.fn()
}))
const { commandExecFileAsyncMock, ghExecFileAsyncMock, gitExecFileAsyncMock, resolveWithSshGMock } =
vi.hoisted(() => ({
commandExecFileAsyncMock: vi.fn(),
ghExecFileAsyncMock: vi.fn(),
gitExecFileAsyncMock: vi.fn(),
resolveWithSshGMock: vi.fn()
}))
// Mock only the exec boundary so the real remote-identity parsing, runtime
// option resolution, and `gh auth status` parsing run against controlled output.
vi.mock('../git/runner', () => ({
commandExecFileAsync: commandExecFileAsyncMock,
ghExecFileAsync: ghExecFileAsyncMock,
gitExecFileAsync: gitExecFileAsyncMock
}))
vi.mock('../ssh/ssh-g-config-resolution', () => ({
resolveWithSshG: resolveWithSshGMock
}))
import {
_resetGitHubHostAuthCache,
getEnterpriseGitHubRepoSlug,
isGitHubHostAuthenticated,
isGitHubHostAuthenticatedForGlobalCli
} from './github-enterprise-repository'
import { _resetSshHostnameResolutionCache } from './github-ssh-host-alias-resolution'
function mockOriginRemote(url: string): void {
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
@ -28,6 +37,19 @@ function mockOriginRemote(url: string): void {
})
}
function sshConfig(hostname: string, port = 22) {
return {
hostname,
port,
identityFile: [],
identitiesOnly: false,
forwardAgent: false,
proxyUseFdpass: false,
controlMaster: 'no',
controlPersist: 'no'
}
}
// gh auth status inventory entries represent hosts with configured credentials.
function mockHostAuthenticated(host = 'github.acme-corp.com'): void {
mockAuthenticatedHosts([host])
@ -54,9 +76,13 @@ function mockHostNotAuthenticated(): void {
describe('getEnterpriseGitHubRepoSlug', () => {
beforeEach(() => {
commandExecFileAsyncMock.mockReset()
ghExecFileAsyncMock.mockReset()
gitExecFileAsyncMock.mockReset()
resolveWithSshGMock.mockReset()
resolveWithSshGMock.mockResolvedValue(null)
_resetGitHubHostAuthCache()
_resetSshHostnameResolutionCache()
})
it('resolves a GHES remote whose host the user is gh-authenticated to (#8312)', async () => {
@ -84,6 +110,67 @@ describe('getEnterpriseGitHubRepoSlug', () => {
})
})
it('expands an SSH Host alias to the authenticated GHES HostName (#10284)', async () => {
mockOriginRemote('git@ghe-work:team/orca.git')
resolveWithSshGMock.mockResolvedValueOnce(sshConfig('github.acme-corp.com'))
mockHostAuthenticated('github.acme-corp.com')
await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toEqual({
owner: 'team',
repo: 'orca',
host: 'github.acme-corp.com'
})
expect(resolveWithSshGMock).toHaveBeenCalledWith('ghe-work')
})
it('keeps a failed GHES alias probe indeterminate and recovers on retry', async () => {
vi.useFakeTimers()
mockOriginRemote('git@ghe-work:team/orca.git')
resolveWithSshGMock
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(sshConfig('github.acme-corp.com'))
mockHostNotAuthenticated()
await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeUndefined()
await vi.advanceTimersByTimeAsync(5_001)
ghExecFileAsyncMock.mockReset()
mockHostAuthenticated('github.acme-corp.com')
await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toEqual({
owner: 'team',
repo: 'orca',
host: 'github.acme-corp.com'
})
})
it('returns null for a Host alias that resolves to github.com (dotcom path owns it)', async () => {
mockOriginRemote('git@github-work:team/orca.git')
resolveWithSshGMock.mockResolvedValueOnce(sshConfig('ssh.github.com', 443))
await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeNull()
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
})
it('expands aliases in the repository WSL runtime', async () => {
mockOriginRemote('git@github-work:team/orca.git')
commandExecFileAsyncMock.mockResolvedValueOnce({
stdout: 'hostname github.com\nport 22\n',
stderr: ''
})
await expect(
getEnterpriseGitHubRepoSlug('/repo', null, {
localGitExecOptions: { wslDistro: 'Ubuntu' }
})
).resolves.toBeNull()
expect(commandExecFileAsyncMock).toHaveBeenCalledWith('ssh', ['-G', '--', 'github-work'], {
cwd: '/repo',
timeout: 5_000,
wslDistro: 'Ubuntu'
})
expect(resolveWithSshGMock).not.toHaveBeenCalled()
})
it('uses the unique ported auth host for a hostname-only SSH remote', async () => {
mockOriginRemote('git@ghe.acme.com:team/orca.git')
mockHostAuthenticated('ghe.acme.com:8443')

View File

@ -12,6 +12,11 @@ import {
parseGitHubRemoteIdentity,
type LocalGitExecOptions
} from './github-repository-identity'
import {
effectiveGitHubRemoteHost,
gitHubSshConfigHostAlias
} from './github-remote-identity-parsing'
import { resolveSshConfigHostname } from './github-ssh-host-alias-resolution'
import { parseWslPath } from '../wsl'
export type GitHubEnterpriseRepoSlug = GitHubOwnerRepo & { host: string }
@ -225,11 +230,32 @@ export async function getEnterpriseGitHubRepoSlugForRemote(
return null
}
const identity = remoteUrl ? parseGitHubRemoteIdentity(remoteUrl) : null
if (!identity || identity.host === 'github.com') {
if (!identity) {
return null
}
// Why: GHES routing needs the effective host behind an SSH alias.
let effectiveHost = identity.host
const aliasHost = remoteUrl ? gitHubSshConfigHostAlias(remoteUrl) : null
if (aliasHost) {
const { hostname, resolved } = await resolveSshConfigHostname(aliasHost, context)
if (!resolved || !hostname) {
const authenticatedLiteralHost = await resolveAuthenticatedGitHubHost(
identity.host,
repoPath,
connectionId,
localGitOptions
)
return authenticatedLiteralHost
? { owner: identity.owner, repo: identity.repo, host: authenticatedLiteralHost }
: undefined
}
effectiveHost = effectiveGitHubRemoteHost(identity.host, hostname)
}
if (effectiveHost === 'github.com') {
return null
}
const authenticatedHost = await resolveAuthenticatedGitHubHost(
identity.host,
effectiveHost,
repoPath,
connectionId,
localGitOptions

View File

@ -0,0 +1,91 @@
import type { IssueSourcePreference } from '../../shared/types'
import { githubRepoIdentityKey } from '../../shared/github-repository-identity-key'
import {
getOwnerRepoForRemote,
type LocalGitExecOptions,
type OwnerRepo
} from './github-repository-identity'
export async function getOwnerRepo(
repoPath: string,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<OwnerRepo | null> {
// Why: on a fork checkout PRs live on the upstream parent, not origin (#7331).
const upstream = await getOwnerRepoForRemote(repoPath, 'upstream', connectionId, localGitOptions)
if (upstream) {
return upstream
}
return getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions)
}
export const getIssueOwnerRepo = getOwnerRepo
export type PRRepositoryCandidates = {
candidates: OwnerRepo[]
headRepo: OwnerRepo | null
}
export async function resolvePRRepositoryCandidates(
repoPath: string,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<PRRepositoryCandidates> {
const [upstream, origin] = await Promise.all([
getOwnerRepoForRemote(repoPath, 'upstream', connectionId, localGitOptions),
getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions)
])
const seen = new Set<string>()
const candidates: OwnerRepo[] = []
for (const candidate of [upstream, origin]) {
if (!candidate) {
continue
}
const key = githubRepoIdentityKey(candidate)
if (seen.has(key)) {
continue
}
seen.add(key)
candidates.push(candidate)
}
return { candidates, headRepo: origin }
}
export type ResolvedIssueSource = {
source: OwnerRepo | null
/** True when explicit upstream is gone and resolver fell back to origin. */
fellBack: boolean
}
export async function resolveIssueSource(
repoPath: string,
preference: IssueSourcePreference | undefined,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<ResolvedIssueSource> {
if (preference === 'upstream') {
const upstream = await getOwnerRepoForRemote(
repoPath,
'upstream',
connectionId,
localGitOptions
)
if (upstream) {
return { source: upstream, fellBack: false }
}
const origin = await getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions)
return { source: origin, fellBack: origin !== null }
}
if (preference === 'origin') {
return {
source: await getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions),
fellBack: false
}
}
return {
source: await getIssueOwnerRepo(repoPath, connectionId, localGitOptions),
fellBack: false
}
}

View File

@ -1,6 +1,13 @@
import { describe, expect, it } from 'vitest'
import { parseGitHubOwnerRepo, parseGitHubRemoteIdentity } from './github-remote-identity-parsing'
import {
effectiveGitHubRemoteHost,
gitHubSshConfigHostAlias,
parseGitHubOwnerRepo,
parseGitHubOwnerRepoWithResolvedSshHostname,
parseGitHubRemoteIdentity,
remoteUrlUsesSshTransport
} from './github-remote-identity-parsing'
describe('parseGitHubRemoteIdentity', () => {
it('parses a plain github.com https remote', () => {
@ -106,4 +113,78 @@ describe('parseGitHubOwnerRepo', () => {
repo: 'orca'
})
})
it('returns null for an SSH Host alias remote without HostName resolution', () => {
expect(parseGitHubOwnerRepo('git@github-work:team/orca.git')).toBeNull()
expect(parseGitHubOwnerRepo('git@github.com-work:team/orca.git')).toBeNull()
expect(parseGitHubOwnerRepo('ssh://git@github-work/team/orca.git')).toBeNull()
})
})
describe('SSH Host alias identity (#10284)', () => {
it('detects SCP and ssh:// remotes as SSH transport', () => {
expect(remoteUrlUsesSshTransport('git@github-work:team/orca.git')).toBe(true)
expect(remoteUrlUsesSshTransport('ssh://git@github-work/team/orca.git')).toBe(true)
expect(remoteUrlUsesSshTransport('git+ssh://git@github-work/team/orca.git')).toBe(true)
expect(remoteUrlUsesSshTransport('https://github.com/team/orca.git')).toBe(false)
})
it('exposes Host aliases that need ssh -G expansion', () => {
expect(gitHubSshConfigHostAlias('git@github-work:team/orca.git')).toBe('github-work')
expect(gitHubSshConfigHostAlias('git@github.com-work:team/orca.git')).toBe('github.com-work')
expect(gitHubSshConfigHostAlias('ssh://git@github-work/team/orca.git')).toBe('github-work')
expect(gitHubSshConfigHostAlias('git@github.com:team/orca.git')).toBeNull()
expect(gitHubSshConfigHostAlias('https://github.com/team/orca.git')).toBeNull()
})
it('preserves SSH Host alias case for OpenSSH Host matching', () => {
expect(gitHubSshConfigHostAlias('git@GitHub-Work:team/orca.git')).toBe('GitHub-Work')
expect(gitHubSshConfigHostAlias('ssh://git@GitHub-Work/team/orca.git')).toBe('GitHub-Work')
expect(gitHubSshConfigHostAlias('git+ssh://git@GitHub-Work/team/orca.git')).toBe('GitHub-Work')
})
it('returns owner/repo when resolved HostName is github.com', () => {
expect(
parseGitHubOwnerRepoWithResolvedSshHostname('git@github-work:team/orca.git', 'github.com')
).toEqual({ owner: 'team', repo: 'orca' })
})
it('returns owner/repo when resolved HostName is ssh.github.com (SSH-over-HTTPS)', () => {
expect(
parseGitHubOwnerRepoWithResolvedSshHostname('git@github-work:team/orca.git', 'ssh.github.com')
).toEqual({ owner: 'team', repo: 'orca' })
})
it('keeps owner/repo for literal github.com even if resolved host is unused', () => {
expect(
parseGitHubOwnerRepoWithResolvedSshHostname('git@github.com:team/orca.git', null)
).toEqual({ owner: 'team', repo: 'orca' })
})
it('returns null when resolved HostName is a non-GitHub forge', () => {
expect(
parseGitHubOwnerRepoWithResolvedSshHostname('git@gitlab-work:team/orca.git', 'gitlab.com')
).toBeNull()
})
it('does not apply SSH HostName resolution to HTTPS remotes', () => {
expect(
parseGitHubOwnerRepoWithResolvedSshHostname('https://github-work/team/orca.git', 'github.com')
).toBeNull()
})
it('returns null when SSH resolution is missing', () => {
expect(
parseGitHubOwnerRepoWithResolvedSshHostname('git@github-work:team/orca.git', null)
).toBeNull()
expect(
parseGitHubOwnerRepoWithResolvedSshHostname('git@github-work:team/orca.git', ' ')
).toBeNull()
})
it('normalizes effective host for enterprise routing after HostName expansion', () => {
expect(effectiveGitHubRemoteHost('github-work', 'ssh.github.com')).toBe('github.com')
expect(effectiveGitHubRemoteHost('ghe-work', 'ghe.acme.com')).toBe('ghe.acme.com')
expect(effectiveGitHubRemoteHost('github.com', null)).toBe('github.com')
})
})

View File

@ -2,7 +2,7 @@ import type { GitHubOwnerRepo } from '../../shared/types'
export type GitHubRemoteIdentity = GitHubOwnerRepo & { host: string }
function normalizeGitHubRemoteHost(host: string): string {
export function normalizeGitHubRemoteHost(host: string): string {
const normalizedHost = host.toLowerCase()
// Why: GitHub documents ssh.github.com as SSH-over-HTTPS for github.com repos.
return normalizedHost === 'ssh.github.com' ? 'github.com' : normalizedHost
@ -28,6 +28,44 @@ function parseGitHubRemotePath(path: string): Pick<GitHubRemoteIdentity, 'owner'
return { owner, repo }
}
/** SCP-style / ssh:// / git+ssh:// remotes may use an OpenSSH Host alias. */
export function remoteUrlUsesSshTransport(remoteUrl: string): boolean {
const trimmed = remoteUrl.trim().toLowerCase()
return (
trimmed.startsWith('git@') || trimmed.startsWith('ssh://') || trimmed.startsWith('git+ssh://')
)
}
/** SSH transport host as written, preserving SCP alias case. */
export function rawSshTransportHost(remoteUrl: string): string | null {
const trimmed = remoteUrl.trim()
const scpMatch = trimmed.match(/^git@([^:]+):/i)
if (scpMatch) {
return scpMatch[1]
}
try {
const url = new URL(trimmed)
if (!['ssh:', 'git+ssh:'].includes(url.protocol.toLowerCase())) {
return null
}
return url.hostname || null
} catch {
return null
}
}
/** Non-GitHub SSH host that may need OpenSSH alias expansion. */
export function gitHubSshConfigHostAlias(remoteUrl: string): string | null {
if (!remoteUrlUsesSshTransport(remoteUrl)) {
return null
}
const identity = parseGitHubRemoteIdentity(remoteUrl)
if (!identity || identity.host === 'github.com') {
return null
}
return rawSshTransportHost(remoteUrl) ?? identity.host
}
export function parseGitHubRemoteIdentity(remoteUrl: string): GitHubRemoteIdentity | null {
const trimmed = remoteUrl.trim()
const sshMatch = trimmed.match(/^git@([^:]+):([^/]+)\/([^/]+?)(?:\.git)?$/i)
@ -54,3 +92,37 @@ export function parseGitHubOwnerRepo(remoteUrl: string): GitHubOwnerRepo | null
}
return { owner: identity.owner, repo: identity.repo }
}
/** Parse github.com identity using an expanded SSH HostName. */
export function parseGitHubOwnerRepoWithResolvedSshHostname(
remoteUrl: string,
resolvedSshHostname: string | null | undefined
): GitHubOwnerRepo | null {
const direct = parseGitHubOwnerRepo(remoteUrl)
if (direct) {
return direct
}
if (!remoteUrlUsesSshTransport(remoteUrl)) {
return null
}
if (!resolvedSshHostname?.trim()) {
return null
}
const identity = parseGitHubRemoteIdentity(remoteUrl)
if (!identity) {
return null
}
if (normalizeGitHubRemoteHost(resolvedSshHostname.trim()) !== 'github.com') {
return null
}
return { owner: identity.owner, repo: identity.repo }
}
/** Effective forge host after optional SSH config HostName expansion. */
export function effectiveGitHubRemoteHost(
parsedHost: string,
resolvedSshHostname?: string | null
): string {
const candidate = resolvedSshHostname?.trim() || parsedHost
return normalizeGitHubRemoteHost(candidate)
}

View File

@ -28,12 +28,8 @@ vi.mock('./local-git-config-signature', () => ({
readLocalGitConfigSignature: readLocalGitConfigSignatureMock
}))
import {
getOwnerRepo,
getIssueOwnerRepo,
getOwnerRepoForRemote,
_resetOwnerRepoCache
} from './github-repository-identity'
import { getOwnerRepoForRemote, _resetOwnerRepoCache } from './github-repository-identity'
import { getOwnerRepo, getIssueOwnerRepo } from './github-owner-repo-selection'
import { getRepoUpstream } from './client'
const FORK_PATH = '/tmp/fork-checkout'

View File

@ -0,0 +1,358 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as GitRunner from '../git/runner'
const {
commandExecFileAsyncMock,
getSshGitProviderGenerationMock,
getSshGitProviderMock,
gitExecFileAsyncMock,
resolveWithSshGMock,
readLocalGitConfigSignatureMock
} = vi.hoisted(() => ({
commandExecFileAsyncMock: vi.fn(),
getSshGitProviderGenerationMock: vi.fn(() => 0),
getSshGitProviderMock: vi.fn(),
gitExecFileAsyncMock: vi.fn(),
resolveWithSshGMock: vi.fn(),
readLocalGitConfigSignatureMock: vi.fn(async () => 'sig-10284')
}))
vi.mock('../git/runner', async (importOriginal) => ({
...(await importOriginal<typeof GitRunner>()),
commandExecFileAsync: commandExecFileAsyncMock,
gitExecFileAsync: gitExecFileAsyncMock
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProvider: getSshGitProviderMock,
getSshGitProviderGeneration: getSshGitProviderGenerationMock
}))
vi.mock('./local-git-config-signature', () => ({
readLocalGitConfigSignature: readLocalGitConfigSignatureMock
}))
vi.mock('../ssh/ssh-g-config-resolution', () => ({
resolveWithSshG: resolveWithSshGMock
}))
import {
getOwnerRepoForRemote,
_resetOwnerRepoCache,
_getOwnerRepoCacheSize
} from './github-repository-identity'
import {
classifyGitHubOwnerRepoFromRemoteUrl,
resolveGitHubOwnerRepoFromRemoteUrl,
_resetSshHostnameResolutionCache
} from './github-ssh-host-alias-resolution'
const REPO = '/tmp/ssh-alias-checkout'
function sshConfig(hostname: string, port = 22) {
return {
hostname,
port,
identityFile: [],
identitiesOnly: false,
forwardAgent: false,
proxyUseFdpass: false,
controlMaster: 'no',
controlPersist: 'no'
}
}
function mockRemoteUrl(url: string): void {
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args[0] === 'remote' && args[1] === 'get-url') {
return { stdout: `${url}\n` }
}
throw new Error(`unexpected git args: ${args.join(' ')}`)
})
}
function sshProvider(hostname: string, remoteUrl = 'git@github-work:team/orca.git') {
return {
exec: vi.fn().mockResolvedValue({
stdout: `${remoteUrl}\n`,
stderr: ''
}),
execNonInteractive: vi.fn().mockResolvedValue({
stdout: `hostname ${hostname}\nport 22\n`,
stderr: '',
exitCode: 0,
timedOut: false,
canceled: false
})
}
}
beforeEach(() => {
_resetOwnerRepoCache()
_resetSshHostnameResolutionCache()
commandExecFileAsyncMock.mockReset()
getSshGitProviderGenerationMock.mockReset()
getSshGitProviderGenerationMock.mockReturnValue(0)
getSshGitProviderMock.mockReset()
gitExecFileAsyncMock.mockReset()
resolveWithSshGMock.mockReset()
readLocalGitConfigSignatureMock.mockClear()
})
afterEach(() => {
vi.useRealTimers()
})
describe('#10284 SSH Host alias → github.com owner/repo', () => {
it('resolveGitHubOwnerRepoFromRemoteUrl expands HostName ssh.github.com', async () => {
resolveWithSshGMock.mockResolvedValueOnce(sshConfig('ssh.github.com', 443))
await expect(
resolveGitHubOwnerRepoFromRemoteUrl('git@github-work:team/orca.git')
).resolves.toEqual({ owner: 'team', repo: 'orca' })
expect(resolveWithSshGMock).toHaveBeenCalledWith('github-work')
})
it('getOwnerRepoForRemote resolves SCP alias remote used for multi-account GitHub', async () => {
mockRemoteUrl('git@github-work:team/orca.git')
resolveWithSshGMock.mockResolvedValueOnce(sshConfig('github.com'))
await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toEqual({
owner: 'team',
repo: 'orca'
})
expect(resolveWithSshGMock).toHaveBeenCalledWith('github-work')
})
it('getOwnerRepoForRemote resolves ssh:// Host alias remotes', async () => {
mockRemoteUrl('ssh://git@github.com-work/acme/widgets.git')
resolveWithSshGMock.mockResolvedValueOnce(sshConfig('ssh.github.com', 443))
await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toEqual({
owner: 'acme',
repo: 'widgets'
})
expect(resolveWithSshGMock).toHaveBeenCalledWith('github.com-work')
})
it('resolves aliases inside the repository WSL runtime', async () => {
mockRemoteUrl('git@github-work:team/orca.git')
commandExecFileAsyncMock.mockResolvedValueOnce({
stdout: 'hostname github.com\nport 22\n',
stderr: ''
})
await expect(
getOwnerRepoForRemote(REPO, 'origin', null, { wslDistro: 'Ubuntu' })
).resolves.toEqual({
owner: 'team',
repo: 'orca'
})
expect(commandExecFileAsyncMock).toHaveBeenCalledWith('ssh', ['-G', '--', 'github-work'], {
cwd: REPO,
timeout: 5_000,
wslDistro: 'Ubuntu'
})
expect(resolveWithSshGMock).not.toHaveBeenCalled()
})
it('resolves aliases inside the repository SSH runtime', async () => {
const provider = sshProvider('github.com')
getSshGitProviderMock.mockReturnValue(provider)
getSshGitProviderGenerationMock.mockReturnValue(4)
await expect(getOwnerRepoForRemote('/remote/repo', 'origin', 'ssh-1')).resolves.toEqual({
owner: 'team',
repo: 'orca'
})
expect(provider.execNonInteractive).toHaveBeenCalledWith(
'ssh',
['-G', '--', 'github-work'],
'/remote/repo',
5_000
)
expect(resolveWithSshGMock).not.toHaveBeenCalled()
})
it('isolates the same alias across native and WSL runtimes', async () => {
resolveWithSshGMock.mockResolvedValueOnce(sshConfig('github.com'))
commandExecFileAsyncMock.mockResolvedValueOnce({
stdout: 'hostname gitlab.com\nport 22\n',
stderr: ''
})
await expect(
resolveGitHubOwnerRepoFromRemoteUrl('git@forge-work:team/orca.git')
).resolves.toEqual({ owner: 'team', repo: 'orca' })
await expect(
resolveGitHubOwnerRepoFromRemoteUrl('git@forge-work:team/orca.git', {
repoPath: REPO,
wslDistro: 'Ubuntu'
})
).resolves.toBeNull()
expect(resolveWithSshGMock).toHaveBeenCalledTimes(1)
expect(commandExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('invalidates alias resolution when an SSH provider reconnects', async () => {
const context = { repoPath: '/remote/repo', connectionId: 'ssh-1' }
getSshGitProviderGenerationMock.mockReturnValue(1)
getSshGitProviderMock.mockReturnValue(sshProvider('github.com'))
await expect(
resolveGitHubOwnerRepoFromRemoteUrl('git@forge-work:team/orca.git', context)
).resolves.toEqual({ owner: 'team', repo: 'orca' })
getSshGitProviderGenerationMock.mockReturnValue(2)
getSshGitProviderMock.mockReturnValue(sshProvider('gitlab.com'))
await expect(
resolveGitHubOwnerRepoFromRemoteUrl('git@forge-work:team/orca.git', context)
).resolves.toBeNull()
})
it('invalidates owner/repo identity when an SSH provider reconnects', async () => {
getSshGitProviderGenerationMock.mockReturnValue(1)
getSshGitProviderMock.mockReturnValue(
sshProvider('github.com', 'git@github-work:team/orca.git')
)
await expect(getOwnerRepoForRemote('/remote/repo', 'origin', 'ssh-1')).resolves.toEqual({
owner: 'team',
repo: 'orca'
})
getSshGitProviderGenerationMock.mockReturnValue(2)
getSshGitProviderMock.mockReturnValue(
sshProvider('github.com', 'git@github-work:acme/widgets.git')
)
await expect(getOwnerRepoForRemote('/remote/repo', 'origin', 'ssh-1')).resolves.toEqual({
owner: 'acme',
repo: 'widgets'
})
})
it('does not call ssh -G for literal github.com remotes', async () => {
mockRemoteUrl('git@github.com:team/orca.git')
await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toEqual({
owner: 'team',
repo: 'orca'
})
expect(resolveWithSshGMock).not.toHaveBeenCalled()
})
it('does not call ssh -G for https remotes', async () => {
mockRemoteUrl('https://github.com/team/orca.git')
await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toEqual({
owner: 'team',
repo: 'orca'
})
expect(resolveWithSshGMock).not.toHaveBeenCalled()
})
it('returns null when alias resolves to a non-GitHub host', async () => {
mockRemoteUrl('git@gitlab-work:team/orca.git')
resolveWithSshGMock.mockResolvedValueOnce(sshConfig('gitlab.com'))
await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toBeNull()
})
it('returns null when ssh -G fails for an alias', async () => {
mockRemoteUrl('git@github-work:team/orca.git')
resolveWithSshGMock.mockResolvedValueOnce(null)
await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toBeNull()
})
it('does not rewrite transport: identity resolution only consumes HostName', async () => {
const remote = 'git@github-work:team/orca.git'
mockRemoteUrl(remote)
resolveWithSshGMock.mockResolvedValueOnce(sshConfig('github.com'))
await getOwnerRepoForRemote(REPO, 'origin')
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['remote', 'get-url', 'origin'],
expect.objectContaining({ cwd: REPO })
)
const gitArgLists = gitExecFileAsyncMock.mock.calls.map(([args]) => args.join(' '))
expect(gitArgLists.every((cmd) => cmd.startsWith('remote get-url'))).toBe(true)
})
it('classifies ssh -G failure as indeterminate (not stable not-github)', async () => {
resolveWithSshGMock.mockResolvedValueOnce(null)
await expect(
classifyGitHubOwnerRepoFromRemoteUrl('git@github-work:team/orca.git')
).resolves.toEqual({ kind: 'indeterminate' })
})
it('does not long-negative-cache owner/repo when ssh -G is indeterminate', async () => {
mockRemoteUrl('git@github-work:team/orca.git')
resolveWithSshGMock.mockResolvedValue(null)
await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toBeNull()
expect(_getOwnerRepoCacheSize()).toBe(0)
_resetSshHostnameResolutionCache()
await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toBeNull()
expect(resolveWithSshGMock).toHaveBeenCalledTimes(2)
expect(_getOwnerRepoCacheSize()).toBe(0)
})
it('does not pin an SSH-config-dependent miss to the Git config signature', async () => {
vi.useFakeTimers()
mockRemoteUrl('git@forge-work:team/orca.git')
resolveWithSshGMock
.mockResolvedValueOnce(sshConfig('gitlab.com'))
.mockResolvedValueOnce(sshConfig('github.com'))
await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toBeNull()
await vi.advanceTimersByTimeAsync(60_001)
await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toEqual({
owner: 'team',
repo: 'orca'
})
})
it('caches a successful HostName expansion so repeat probes skip ssh -G', async () => {
mockRemoteUrl('git@github-work:team/orca.git')
resolveWithSshGMock.mockResolvedValue(sshConfig('github.com'))
await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toEqual({
owner: 'team',
repo: 'orca'
})
await expect(getOwnerRepoForRemote(REPO, 'origin')).resolves.toEqual({
owner: 'team',
repo: 'orca'
})
expect(resolveWithSshGMock).toHaveBeenCalledTimes(1)
})
it('isolates case-sensitive OpenSSH Host aliases in the cache', async () => {
resolveWithSshGMock.mockImplementation(async (host: string) =>
sshConfig(host === 'GitHub-Work' ? 'github.com' : 'gitlab.com')
)
await expect(
classifyGitHubOwnerRepoFromRemoteUrl('git@GitHub-Work:team/orca.git')
).resolves.toEqual({
kind: 'github',
ownerRepo: { owner: 'team', repo: 'orca' }
})
await expect(
classifyGitHubOwnerRepoFromRemoteUrl('git@github-work:team/orca.git')
).resolves.toEqual({
kind: 'not-github',
cacheWithGitConfigSignature: false
})
expect(resolveWithSshGMock).toHaveBeenCalledTimes(2)
})
it('classifies a resolved non-GitHub HostName as not-github', async () => {
resolveWithSshGMock.mockResolvedValueOnce(sshConfig('gitlab.com'))
await expect(
classifyGitHubOwnerRepoFromRemoteUrl('git@gitlab-work:team/orca.git')
).resolves.toEqual({ kind: 'not-github', cacheWithGitConfigSignature: false })
})
})

View File

@ -1,14 +1,14 @@
import { gitExecFileAsync } from '../git/runner'
import type { GitHubOwnerRepo, IssueSourcePreference } from '../../shared/types'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
import type { GitHubOwnerRepo } from '../../shared/types'
import { getSshGitProvider, getSshGitProviderGeneration } from '../providers/ssh-git-dispatch'
import { readLocalGitConfigSignature } from './local-git-config-signature'
import {
parseGitHubOwnerRepo,
parseGitHubRemoteIdentity,
type GitHubRemoteIdentity
} from './github-remote-identity-parsing'
import { classifyGitHubOwnerRepoFromRemoteUrl } from './github-ssh-host-alias-resolution'
import { isStableMissingGitRemoteError } from './stable-missing-git-remote-error'
import { githubRepoIdentityKey } from '../../shared/github-repository-identity-key'
export type OwnerRepo = GitHubOwnerRepo
@ -122,7 +122,9 @@ export async function getOwnerRepoForRemote(
localGitOptions: LocalGitExecOptions = {}
): Promise<OwnerRepo | null> {
const context = githubRepoContext(repoPath, connectionId, localGitOptions)
const runtimeKey = context.connectionId ?? `local:${context.wslDistro ?? 'host'}`
const runtimeKey = context.connectionId
? `ssh:${context.connectionId}:${getSshGitProviderGeneration(context.connectionId)}`
: `local:${context.wslDistro ?? 'host'}`
const cacheKey = `${runtimeKey}\0${context.repoPath}\0${remoteName}`
const now = Date.now()
pruneOwnerRepoCache(now)
@ -177,15 +179,40 @@ async function resolveOwnerRepoForRemote(
const now = Date.now()
try {
const remoteUrl = await getRemoteUrlForRepo(context, remoteName)
const result = remoteUrl ? parseGitHubOwnerRepo(remoteUrl) : null
if (result) {
if (!remoteUrl) {
// Empty remote URL is stable until git config changes.
ownerRepoCache.set(cacheKey, {
value: result,
expiresAt: now + getOwnerRepoCacheTtl(result, configSignature)
value: null,
expiresAt: now + getOwnerRepoCacheTtl(null, configSignature),
...(configSignature ? { configSignature } : {})
})
pruneOwnerRepoCache(now)
return result
return null
}
// Why: PR mutations need the effective host behind an SSH alias.
const classification = await classifyGitHubOwnerRepoFromRemoteUrl(remoteUrl, context)
if (classification.kind === 'github') {
ownerRepoCache.set(cacheKey, {
value: classification.ownerRepo,
expiresAt: now + getOwnerRepoCacheTtl(classification.ownerRepo, configSignature)
})
pruneOwnerRepoCache(now)
return classification.ownerRepo
}
if (classification.kind === 'indeterminate') {
// Why: a failed ssh -G probe is not a stable "not GitHub" result.
return null
}
const stableConfigSignature = classification.cacheWithGitConfigSignature
? configSignature
: undefined
ownerRepoCache.set(cacheKey, {
value: null,
expiresAt: now + getOwnerRepoCacheTtl(null, stableConfigSignature),
...(stableConfigSignature ? { configSignature: stableConfigSignature } : {})
})
pruneOwnerRepoCache(now)
return null
} catch (error) {
// Why: only stable "no such remote" misses are safe to hold for minutes.
// Transient git lock/IO failures must retry on the next lookup.
@ -193,7 +220,7 @@ async function resolveOwnerRepoForRemote(
return null
}
}
// Why: a missing/non-GitHub remote is stable until `.git/config` changes.
// Why: a missing remote is stable until `.git/config` changes.
// Holding that negative longer avoids Git process churn across PR polling.
ownerRepoCache.set(cacheKey, {
value: null,
@ -203,99 +230,3 @@ async function resolveOwnerRepoForRemote(
pruneOwnerRepoCache(now)
return null
}
export async function getOwnerRepo(
repoPath: string,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<OwnerRepo | null> {
// Why: on a fork checkout PRs live on the upstream parent, not origin (#7331).
const upstream = await getOwnerRepoForRemote(repoPath, 'upstream', connectionId, localGitOptions)
if (upstream) {
return upstream
}
return getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions)
}
export async function getIssueOwnerRepo(
repoPath: string,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<OwnerRepo | null> {
const upstream = await getOwnerRepoForRemote(repoPath, 'upstream', connectionId, localGitOptions)
if (upstream) {
return upstream
}
return getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions)
}
export type PRRepositoryCandidates = {
candidates: OwnerRepo[]
headRepo: OwnerRepo | null
}
function ownerRepoKey(ownerRepo: OwnerRepo): string {
return githubRepoIdentityKey(ownerRepo)
}
export async function resolvePRRepositoryCandidates(
repoPath: string,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<PRRepositoryCandidates> {
const upstream = await getOwnerRepoForRemote(repoPath, 'upstream', connectionId, localGitOptions)
const origin = await getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions)
const seen = new Set<string>()
const candidates: OwnerRepo[] = []
for (const candidate of [upstream, origin]) {
if (!candidate) {
continue
}
const key = ownerRepoKey(candidate)
if (seen.has(key)) {
continue
}
seen.add(key)
candidates.push(candidate)
}
return { candidates, headRepo: origin }
}
export type ResolvedIssueSource = {
source: OwnerRepo | null
/** True when explicit upstream is gone and resolver fell back to origin. */
fellBack: boolean
}
export async function resolveIssueSource(
repoPath: string,
preference: IssueSourcePreference | undefined,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<ResolvedIssueSource> {
if (preference === 'upstream') {
const upstream = await getOwnerRepoForRemote(
repoPath,
'upstream',
connectionId,
localGitOptions
)
if (upstream) {
return { source: upstream, fellBack: false }
}
const origin = await getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions)
return { source: origin, fellBack: origin !== null }
}
if (preference === 'origin') {
return {
source: await getOwnerRepoForRemote(repoPath, 'origin', connectionId, localGitOptions),
fellBack: false
}
}
return {
source: await getIssueOwnerRepo(repoPath, connectionId, localGitOptions),
fellBack: false
}
}

View File

@ -0,0 +1,192 @@
import type { GitHubOwnerRepo } from '../../shared/types'
import { commandExecFileAsync } from '../git/runner'
import { getSshGitProvider, getSshGitProviderGeneration } from '../providers/ssh-git-dispatch'
import { parseWslPath } from '../wsl'
import { resolveWithSshG } from '../ssh/ssh-g-config-resolution'
import {
gitHubSshConfigHostAlias,
parseGitHubOwnerRepo,
parseGitHubOwnerRepoWithResolvedSshHostname
} from './github-remote-identity-parsing'
/** `indeterminate` means SSH alias expansion failed and must remain retryable. */
export type GitHubOwnerRepoResolution =
| { kind: 'github'; ownerRepo: GitHubOwnerRepo }
| { kind: 'not-github'; cacheWithGitConfigSignature: boolean }
| { kind: 'indeterminate' }
const SSH_HOSTNAME_CACHE_TTL_MS = 60_000
const SSH_HOSTNAME_FAILURE_CACHE_TTL_MS = 5_000
const SSH_HOSTNAME_CACHE_MAX = 256
const SSH_G_TIMEOUT_MS = 5_000
export type SshConfigResolutionContext = {
repoPath: string
connectionId?: string | null
wslDistro?: string
}
type SshHostnameCacheEntry = {
hostname: string | null
resolved: boolean
expiresAt: number
}
const sshHostnameCache = new Map<string, SshHostnameCacheEntry>()
const sshHostnameInFlight = new Map<string, Promise<SshHostnameCacheEntry>>()
/** @internal - tests only */
export function _resetSshHostnameResolutionCache(): void {
sshHostnameCache.clear()
sshHostnameInFlight.clear()
}
function pruneSshHostnameCache(now: number): void {
for (const [key, entry] of sshHostnameCache) {
if (entry.expiresAt <= now) {
sshHostnameCache.delete(key)
}
}
while (sshHostnameCache.size > SSH_HOSTNAME_CACHE_MAX) {
const oldest = sshHostnameCache.keys().next().value
if (oldest === undefined) {
return
}
sshHostnameCache.delete(oldest)
}
}
function sshRuntimeCacheKey(context: SshConfigResolutionContext): string {
if (context.connectionId) {
const generation = getSshGitProviderGeneration(context.connectionId)
return `ssh:${context.connectionId}:${generation}`
}
const distro = context.wslDistro ?? parseWslPath(context.repoPath)?.distro
return `local:${distro?.toLowerCase() ?? 'host'}`
}
function parseSshGHostname(stdout: string): string | null {
for (const line of stdout.split(/\r?\n/)) {
const match = line.match(/^hostname\s+(.+)$/i)
if (match?.[1].trim()) {
return match[1].trim()
}
}
return null
}
async function resolveSshHostnameInRuntime(
host: string,
context: SshConfigResolutionContext
): Promise<string | null> {
if (context.connectionId) {
const provider = getSshGitProvider(context.connectionId)
if (!provider) {
return null
}
try {
const result = await provider.execNonInteractive(
'ssh',
['-G', '--', host],
context.repoPath,
SSH_G_TIMEOUT_MS
)
return result.exitCode === 0 && !result.timedOut && !result.canceled
? parseSshGHostname(result.stdout)
: null
} catch {
return null
}
}
const wslDistro = context.wslDistro ?? parseWslPath(context.repoPath)?.distro
if (!wslDistro) {
return (await resolveWithSshG(host))?.hostname?.trim() || null
}
try {
const { stdout } = await commandExecFileAsync('ssh', ['-G', '--', host], {
cwd: context.repoPath,
timeout: SSH_G_TIMEOUT_MS,
wslDistro
})
return parseSshGHostname(stdout)
} catch {
return null
}
}
/** Resolve OpenSSH Host → HostName in the repository runtime. */
export async function resolveSshConfigHostname(
host: string,
context: SshConfigResolutionContext = { repoPath: '' }
): Promise<{
hostname: string | null
resolved: boolean
}> {
const cacheKey = `${sshRuntimeCacheKey(context)}\0${host}`
const now = Date.now()
pruneSshHostnameCache(now)
const cached = sshHostnameCache.get(cacheKey)
if (cached && cached.expiresAt > now) {
return { hostname: cached.hostname, resolved: cached.resolved }
}
const inFlight = sshHostnameInFlight.get(cacheKey)
if (inFlight) {
const entry = await inFlight
return { hostname: entry.hostname, resolved: entry.resolved }
}
const probe = (async (): Promise<SshHostnameCacheEntry> => {
const hostname = await resolveSshHostnameInRuntime(host, context)
const resolved = hostname != null && hostname.length > 0
const entry: SshHostnameCacheEntry = {
hostname: resolved ? hostname : null,
resolved,
expiresAt:
Date.now() + (resolved ? SSH_HOSTNAME_CACHE_TTL_MS : SSH_HOSTNAME_FAILURE_CACHE_TTL_MS)
}
sshHostnameCache.set(cacheKey, entry)
pruneSshHostnameCache(Date.now())
return entry
})()
sshHostnameInFlight.set(cacheKey, probe)
try {
const entry = await probe
return { hostname: entry.hostname, resolved: entry.resolved }
} finally {
if (sshHostnameInFlight.get(cacheKey) === probe) {
sshHostnameInFlight.delete(cacheKey)
}
}
}
/** Resolve github.com identity without rewriting the Git transport URL. */
export async function classifyGitHubOwnerRepoFromRemoteUrl(
remoteUrl: string,
context: SshConfigResolutionContext = { repoPath: '' }
): Promise<GitHubOwnerRepoResolution> {
const direct = parseGitHubOwnerRepo(remoteUrl)
if (direct) {
return { kind: 'github', ownerRepo: direct }
}
const aliasHost = gitHubSshConfigHostAlias(remoteUrl)
if (!aliasHost) {
return { kind: 'not-github', cacheWithGitConfigSignature: true }
}
const { hostname, resolved } = await resolveSshConfigHostname(aliasHost, context)
if (!resolved || !hostname) {
return { kind: 'indeterminate' }
}
const ownerRepo = parseGitHubOwnerRepoWithResolvedSshHostname(remoteUrl, hostname)
return ownerRepo
? { kind: 'github', ownerRepo }
: { kind: 'not-github', cacheWithGitConfigSignature: false }
}
/** Convenience wrapper for callers that only need owner/repo or null. */
export async function resolveGitHubOwnerRepoFromRemoteUrl(
remoteUrl: string,
context: SshConfigResolutionContext = { repoPath: '' }
): Promise<GitHubOwnerRepo | null> {
const result = await classifyGitHubOwnerRepoFromRemoteUrl(remoteUrl, context)
return result.kind === 'github' ? result.ownerRepo : null
}

View File

@ -120,6 +120,7 @@ vi.mock('../worktree-root-preparation', () => ({
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProviderGeneration: () => 0,
getSshGitProvider: vi.fn().mockImplementation((id: string) => {
if (id === 'conn-1') {
return mockGitProvider

View File

@ -156,6 +156,7 @@ vi.mock('../source-control/hosted-review', () => ({
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProviderGeneration: () => 0,
getSshGitProvider: getSshGitProviderMock,
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE:
'Remote connection dropped. Click Reconnect on the SSH target before retrying.',