From ee5a23d962534a9e95bccab761d858d5d071da75 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ska=C5=82ecki?= Date: Mon, 27 Apr 2026 14:28:10 +0200 Subject: [PATCH] Support GitHub SSH host aliases for PR actions Resolves the case where a remote like `git@github-work:owner/repo.git` points at github.com via `~/.ssh/config` but Paseo treats it as non-GitHub. Adds a centralized GitHub remote resolver that parses standard remotes directly and resolves SSH aliases via `ssh -G` (with hostname validation to keep the alias out of ssh's option parser). The resolver is shared by workspace GitHub feature detection, PR status, PR creation, and workspace git metadata. --- packages/server/src/server/session.ts | 1 - .../src/server/workspace-git-metadata.ts | 37 +---- .../workspace-git-service.primitive.test.ts | 26 ++++ .../src/server/workspace-git-service.test.ts | 2 +- .../src/server/workspace-git-service.ts | 138 ++++++++--------- .../src/services/github-service.test.ts | 28 ++-- .../server/src/services/github-service.ts | 45 +----- packages/server/src/utils/checkout-git.ts | 4 +- .../server/src/utils/github-remote.test.ts | 109 +++++++++++++ packages/server/src/utils/github-remote.ts | 145 ++++++++++++++++++ 10 files changed, 377 insertions(+), 158 deletions(-) create mode 100644 packages/server/src/utils/github-remote.test.ts create mode 100644 packages/server/src/utils/github-remote.ts diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 2da7b488a..445c32f94 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -5190,7 +5190,6 @@ export class Session { base: msg.baseRef, }, this.github, - this.workspaceGitService, ); await this.notifyGitMutation(cwd, "create-pr", { invalidateGithub: true }); diff --git a/packages/server/src/server/workspace-git-metadata.ts b/packages/server/src/server/workspace-git-metadata.ts index c3480b6e8..e78e7139e 100644 --- a/packages/server/src/server/workspace-git-metadata.ts +++ b/packages/server/src/server/workspace-git-metadata.ts @@ -1,4 +1,5 @@ import { basename } from "path"; +import { parseGitHubRemoteUrl } from "../utils/github-remote.js"; import { slugify } from "../utils/worktree.js"; export interface WorkspaceGitMetadata { @@ -14,41 +15,7 @@ export interface WorkspaceGitMetadata { } export function parseGitHubRepoFromRemote(remoteUrl: string): string | null { - let cleaned = remoteUrl.trim(); - if (!cleaned) { - return null; - } - - if (cleaned.startsWith("git@github.com:")) { - cleaned = cleaned.slice("git@github.com:".length); - } else { - let parsed: URL; - try { - parsed = new URL(cleaned); - } catch { - return null; - } - - if (parsed.hostname !== "github.com") { - return null; - } - - try { - cleaned = decodeURIComponent(parsed.pathname.replace(/^\/+/, "")); - } catch { - return null; - } - } - - if (cleaned.endsWith(".git")) { - cleaned = cleaned.slice(0, -".git".length); - } - - if (!cleaned.includes("/")) { - return null; - } - - return cleaned; + return parseGitHubRemoteUrl(remoteUrl)?.repo ?? null; } export function parseGitHubRepoNameFromRemote(remoteUrl: string): string | null { diff --git a/packages/server/src/server/workspace-git-service.primitive.test.ts b/packages/server/src/server/workspace-git-service.primitive.test.ts index 0211e644c..35c721dd1 100644 --- a/packages/server/src/server/workspace-git-service.primitive.test.ts +++ b/packages/server/src/server/workspace-git-service.primitive.test.ts @@ -782,6 +782,32 @@ describe("WorkspaceGitServiceImpl primitive refresh entrypoint", () => { service.dispose(); }); + test("subscription starts GitHub self-heal polling for ssh.github.com remotes", async () => { + const retainCurrentPullRequestStatusPoll = vi.fn(() => ({ unsubscribe: vi.fn() })); + const github = { + ...createGitHubServiceStub(), + retainCurrentPullRequestStatusPoll, + }; + const getCheckoutStatus = vi.fn(async (cwd: string) => + createCheckoutStatus(cwd, { + remoteUrl: "ssh://git@ssh.github.com/acme/repo.git", + }), + ); + const service = createService({ + getCheckoutStatus, + github, + }); + const subscription = service.registerWorkspace({ cwd: "/tmp/repo" }, vi.fn()); + await flushPromises(); + + await vi.waitFor(() => { + expect(retainCurrentPullRequestStatusPoll).toHaveBeenCalledTimes(1); + }); + + subscription.unsubscribe(); + service.dispose(); + }); + test("multiple subscribers on the same target share one self-heal timer", async () => { let nowMs = 0; const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd)); diff --git a/packages/server/src/server/workspace-git-service.test.ts b/packages/server/src/server/workspace-git-service.test.ts index 4bc73cc5a..966f3f91e 100644 --- a/packages/server/src/server/workspace-git-service.test.ts +++ b/packages/server/src/server/workspace-git-service.test.ts @@ -337,7 +337,7 @@ describe("WorkspaceGitServiceImpl", () => { service.dispose(); }); - test("cold getSnapshot calls share one workspace target and cache the snapshot", async () => { + test("cold getSnapshot calls share one workspace target setup and cache the snapshot", async () => { const checkoutStatusDeferred = createDeferred(); const getCheckoutStatus = vi.fn(async () => checkoutStatusDeferred.promise); const getPullRequestStatus = vi.fn(async () => createPullRequestStatusResult()); diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index a23dce2f4..1fca27249 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -23,6 +23,7 @@ import { import { createGitHubService, type GitHubService } from "../services/github-service.js"; import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js"; import { runGitCommand } from "../utils/run-git-command.js"; +import { resolveGitHubRemote, type GitHubRemoteIdentity } from "../utils/github-remote.js"; import { listPaseoWorktrees, type PaseoWorktreeInfo } from "../utils/worktree.js"; import { READ_ONLY_GIT_ENV } from "./checkout-git-utils.js"; import { @@ -254,6 +255,7 @@ interface WorkspaceGitTarget { latestFingerprint: string | null; lastShellOutAtMs: number | null; repoGitRoot: string | null; + cachedGitHubRemote: { remoteUrl: string; identity: GitHubRemoteIdentity | null } | null; observationSetupPromise: Promise | null; observationSetupComplete: boolean; closed: boolean; @@ -752,6 +754,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { latestFingerprint: null, lastShellOutAtMs: null, repoGitRoot: null, + cachedGitHubRemote: null, observationSetupPromise: null, observationSetupComplete: false, closed: false, @@ -1043,7 +1046,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } const headRef = snapshot.git.currentBranch; - if (!headRef || !hasGitHubRemoteUrl(snapshot.git.remoteUrl)) { + const hasGitHubRemote = + target.cachedGitHubRemote?.remoteUrl === snapshot.git.remoteUrl && + target.cachedGitHubRemote.identity !== null; + if (!headRef || !hasGitHubRemote) { this.stopGitHubPollForTarget(target); return; } @@ -1314,6 +1320,22 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { ); } + private async resolveGitHubRemoteForTarget( + target: WorkspaceGitTarget, + remoteUrl: string | null, + ): Promise { + if (!remoteUrl) { + target.cachedGitHubRemote = null; + return null; + } + if (target.cachedGitHubRemote?.remoteUrl === remoteUrl) { + return target.cachedGitHubRemote.identity; + } + const identity = await resolveGitHubRemote({ remoteUrl }); + target.cachedGitHubRemote = { remoteUrl, identity }; + return identity; + } + private shouldThrottleNonForcedRefresh( target: WorkspaceGitTarget, ): target is WorkspaceGitTarget & { @@ -1386,15 +1408,49 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { if (forceGitHub) { this.deps.github.invalidate({ cwd: target.cwd }); } - const snapshot = await loadWorkspaceGitRuntimeSnapshot( - target.cwd, - { paseoHome: this.paseoHome }, - now, - this.deps, - { force: request.force, forceGitHub, reason: request.reason }, - ); + + const cwd = target.cwd; + const context: CheckoutContext = { paseoHome: this.paseoHome }; + const checkoutStatus = await this.deps.getCheckoutStatus(cwd, context); + if (!checkoutStatus.isGit) { + target.latestSnapshotLoadedAtMs = now.getTime(); + return buildNotGitSnapshot(cwd); + } + + const githubRemote = await this.resolveGitHubRemoteForTarget(target, checkoutStatus.remoteUrl); + + const [diffStat, github] = await Promise.all([ + this.deps.getCheckoutShortstat(cwd, context, { force: request.force }).catch(() => null), + loadGitHubSnapshot({ + cwd, + githubRemote, + now, + deps: this.deps, + force: forceGitHub, + reason: request.reason, + }), + ]); + target.latestSnapshotLoadedAtMs = now.getTime(); - return snapshot; + return { + cwd, + git: { + isGit: true, + repoRoot: checkoutStatus.repoRoot, + mainRepoRoot: checkoutStatus.mainRepoRoot, + currentBranch: checkoutStatus.currentBranch, + remoteUrl: checkoutStatus.remoteUrl, + isPaseoOwnedWorktree: checkoutStatus.isPaseoOwnedWorktree, + isDirty: checkoutStatus.isDirty, + baseRef: checkoutStatus.baseRef, + aheadBehind: checkoutStatus.aheadBehind, + aheadOfOrigin: checkoutStatus.aheadOfOrigin, + behindOfOrigin: checkoutStatus.behindOfOrigin, + hasRemote: checkoutStatus.hasRemote, + diffStat, + }, + github, + }; } private rememberSnapshot( @@ -1542,63 +1598,15 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { } } -async function loadWorkspaceGitRuntimeSnapshot( - cwd: string, - context: CheckoutContext, - now: Date, - deps: Pick< - WorkspaceGitServiceDependencies, - "getCheckoutStatus" | "getCheckoutShortstat" | "getPullRequestStatus" | "github" - >, - options?: { force?: boolean; forceGitHub?: boolean; reason?: string }, -): Promise { - const checkoutStatus = await deps.getCheckoutStatus(cwd, context); - if (!checkoutStatus.isGit) { - return buildNotGitSnapshot(cwd); - } - - const [diffStat, github] = await Promise.all([ - deps.getCheckoutShortstat(cwd, context, { force: options?.force }).catch(() => null), - loadGitHubSnapshot({ - cwd, - remoteUrl: checkoutStatus.remoteUrl, - now, - deps, - force: options?.forceGitHub, - reason: options?.reason, - }), - ]); - - return { - cwd, - git: { - isGit: true, - repoRoot: checkoutStatus.repoRoot, - mainRepoRoot: checkoutStatus.mainRepoRoot, - currentBranch: checkoutStatus.currentBranch, - remoteUrl: checkoutStatus.remoteUrl, - isPaseoOwnedWorktree: checkoutStatus.isPaseoOwnedWorktree, - isDirty: checkoutStatus.isDirty, - baseRef: checkoutStatus.baseRef, - aheadBehind: checkoutStatus.aheadBehind, - aheadOfOrigin: checkoutStatus.aheadOfOrigin, - behindOfOrigin: checkoutStatus.behindOfOrigin, - hasRemote: checkoutStatus.hasRemote, - diffStat, - }, - github, - }; -} - async function loadGitHubSnapshot(options: { cwd: string; - remoteUrl: string | null; + githubRemote: GitHubRemoteIdentity | null; now: Date; deps: Pick; force?: boolean; reason?: string; }): Promise { - if (!hasGitHubRemoteUrl(options.remoteUrl)) { + if (!options.githubRemote) { return { featuresEnabled: false, pullRequest: null, @@ -1637,18 +1645,6 @@ async function loadGitHubSnapshot(options: { } } -function hasGitHubRemoteUrl(remoteUrl: string | null): boolean { - if (!remoteUrl) { - return false; - } - - return ( - remoteUrl.includes("github.com/") || - remoteUrl.startsWith("git@github.com:") || - remoteUrl.startsWith("ssh://git@github.com/") - ); -} - function parseWorkspaceGitStashList( stdout: string, options: { paseoOnly: boolean }, diff --git a/packages/server/src/services/github-service.test.ts b/packages/server/src/services/github-service.test.ts index 2f8585529..e9567d908 100644 --- a/packages/server/src/services/github-service.test.ts +++ b/packages/server/src/services/github-service.test.ts @@ -1,3 +1,7 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { GitHubAuthenticationError, @@ -1837,16 +1841,20 @@ describe("GitHubService", () => { expect(valid.reason).toBe("test"); }); - it("resolves GitHub repos from a WorkspaceGitService-owned remote URL", async () => { - const workspaceGitService = { - resolveRepoRemoteUrl: async (cwd: string) => { - expect(cwd).toBe("/repo"); - return "git@github.com:getpaseo/paseo.git"; - }, - }; + it("resolves GitHub repos from the origin remote URL", async () => { + vi.useRealTimers(); + const cwd = mkdtempSync(join(tmpdir(), "github-service-repo-")); - await expect(resolveGitHubRepo("/repo", { workspaceGitService })).resolves.toBe( - "getpaseo/paseo", - ); + try { + execFileSync("git", ["init", "-b", "main"], { cwd, stdio: "ignore" }); + execFileSync("git", ["remote", "add", "origin", "git@github.com:getpaseo/paseo.git"], { + cwd, + stdio: "ignore", + }); + + await expect(resolveGitHubRepo(cwd)).resolves.toBe("getpaseo/paseo"); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } }); }); diff --git a/packages/server/src/services/github-service.ts b/packages/server/src/services/github-service.ts index 2f95a2585..e39063a70 100644 --- a/packages/server/src/services/github-service.ts +++ b/packages/server/src/services/github-service.ts @@ -1,6 +1,8 @@ import { z } from "zod"; import type { GitHubSearchKind } from "../shared/messages.js"; import { findExecutable } from "../utils/executable.js"; +import { resolveGitHubRemote } from "../utils/github-remote.js"; +import { runGitCommand } from "../utils/run-git-command.js"; import { execCommand } from "../utils/spawn.js"; const DEFAULT_GITHUB_CACHE_TTL_MS = 30_000; @@ -1865,46 +1867,15 @@ function mapReviewDecision(value: unknown): PullRequestReviewDecision { return null; } -export interface GitHubRepoRemoteUrlResolver { - resolveRepoRemoteUrl(cwd: string, options?: GitHubReadOptions): Promise; -} - -export async function resolveGitHubRepo( - cwd: string, - options: { workspaceGitService: GitHubRepoRemoteUrlResolver; readOptions?: GitHubReadOptions }, -): Promise { +export async function resolveGitHubRepo(cwd: string): Promise { try { - const remoteUrl = await options.workspaceGitService.resolveRepoRemoteUrl( + const { stdout } = await runGitCommand(["config", "--get", "remote.origin.url"], { cwd, - options.readOptions, - ); - return parseGitHubRepoFromRemote(remoteUrl?.trim() ?? ""); + env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, + }); + const remote = await resolveGitHubRemote({ remoteUrl: stdout.trim() }); + return remote?.repo ?? null; } catch { return null; } } - -function parseGitHubRepoFromRemote(url: string): string | null { - if (!url) { - return null; - } - let cleaned = url; - if (cleaned.startsWith("git@github.com:")) { - cleaned = cleaned.slice("git@github.com:".length); - } else if (cleaned.startsWith("https://github.com/")) { - cleaned = cleaned.slice("https://github.com/".length); - } else if (cleaned.startsWith("http://github.com/")) { - cleaned = cleaned.slice("http://github.com/".length); - } else { - const marker = "github.com/"; - const index = cleaned.indexOf(marker); - if (index === -1) { - return null; - } - cleaned = cleaned.slice(index + marker.length); - } - if (cleaned.endsWith(".git")) { - cleaned = cleaned.slice(0, -".git".length); - } - return cleaned.includes("/") ? cleaned : null; -} diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index dac1c34aa..009e8f9f5 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -11,7 +11,6 @@ import { GitHubCommandError, createGitHubService, resolveGitHubRepo, - type GitHubRepoRemoteUrlResolver, type GitHubService, } from "../services/github-service.js"; import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js"; @@ -2277,11 +2276,10 @@ export async function createPullRequest( cwd: string, options: CreatePullRequestOptions, github: GitHubService = createGitHubService(), - workspaceGitService: GitHubRepoRemoteUrlResolver, context?: CheckoutContext, ): Promise<{ url: string; number: number }> { await requireGitRepo(cwd); - const repo = await resolveGitHubRepo(cwd, { workspaceGitService }); + const repo = await resolveGitHubRepo(cwd); if (!repo) { throw new Error("Unable to determine GitHub repo from git remote"); } diff --git a/packages/server/src/utils/github-remote.test.ts b/packages/server/src/utils/github-remote.test.ts new file mode 100644 index 000000000..3982f47fb --- /dev/null +++ b/packages/server/src/utils/github-remote.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; + +import { parseGitHubRemoteUrl, resolveGitHubRemote } from "./github-remote.js"; + +function createSshHostnameResolver(hostnameByAlias: Record) { + return async (host: string): Promise => { + return hostnameByAlias[host] ?? null; + }; +} + +describe("parseGitHubRemoteUrl", () => { + it.each([ + ["https://github.com/acme/repo.git", { owner: "acme", name: "repo", repo: "acme/repo" }], + ["http://github.com/acme/repo.git", { owner: "acme", name: "repo", repo: "acme/repo" }], + ["git@github.com:acme/repo.git", { owner: "acme", name: "repo", repo: "acme/repo" }], + ["ssh://git@github.com/acme/repo.git", { owner: "acme", name: "repo", repo: "acme/repo" }], + ["ssh://git@ssh.github.com/acme/repo.git", { owner: "acme", name: "repo", repo: "acme/repo" }], + ])("parses direct GitHub remotes: %s", (remoteUrl, expected) => { + expect(parseGitHubRemoteUrl(remoteUrl)).toEqual(expected); + }); + + it("returns null for non-GitHub remotes", () => { + expect(parseGitHubRemoteUrl("git@gitlab.com:acme/repo.git")).toBeNull(); + }); + + it("returns null for SSH aliases before hostname resolution", () => { + expect(parseGitHubRemoteUrl("git@github-work:acme/repo.git")).toBeNull(); + }); +}); + +describe("resolveGitHubRemote", () => { + it("resolves an SSH alias that maps to github.com", async () => { + await expect( + resolveGitHubRemote({ + remoteUrl: "git@github-work:acme/repo.git", + resolveSshHostname: createSshHostnameResolver({ "github-work": "github.com" }), + }), + ).resolves.toEqual({ owner: "acme", name: "repo", repo: "acme/repo" }); + }); + + it("resolves a dotted SSH alias that maps to github.com", async () => { + await expect( + resolveGitHubRemote({ + remoteUrl: "git@mindnexus.github.com:JakubMindNexus/postline.git", + resolveSshHostname: createSshHostnameResolver({ + "mindnexus.github.com": "github.com", + }), + }), + ).resolves.toEqual({ + owner: "JakubMindNexus", + name: "postline", + repo: "JakubMindNexus/postline", + }); + }); + + it("resolves an SSH alias that maps to ssh.github.com", async () => { + await expect( + resolveGitHubRemote({ + remoteUrl: "ssh://git@github-work/acme/repo.git", + resolveSshHostname: createSshHostnameResolver({ "github-work": "ssh.github.com" }), + }), + ).resolves.toEqual({ owner: "acme", name: "repo", repo: "acme/repo" }); + }); + + it("returns null when an SSH alias resolves to a non-GitHub host", async () => { + await expect( + resolveGitHubRemote({ + remoteUrl: "git@github-work:acme/repo.git", + resolveSshHostname: createSshHostnameResolver({ "github-work": "gitlab.com" }), + }), + ).resolves.toBeNull(); + }); + + it("does not use SSH hostname resolution for HTTPS remotes", async () => { + let resolverCalls = 0; + + await expect( + resolveGitHubRemote({ + remoteUrl: "https://github.com/acme/repo.git", + resolveSshHostname: async () => { + resolverCalls += 1; + return "github.com"; + }, + }), + ).resolves.toEqual({ owner: "acme", name: "repo", repo: "acme/repo" }); + + expect(resolverCalls).toBe(0); + }); + + it.each([ + "git@-oProxyCommand=evil:acme/repo.git", + "ssh://git@-oProxyCommand=evil/acme/repo.git", + "git@host with space:acme/repo.git", + ])("rejects malformed hostnames without invoking the SSH resolver: %s", async (remoteUrl) => { + let resolverCalls = 0; + + await expect( + resolveGitHubRemote({ + remoteUrl, + resolveSshHostname: async (host) => { + resolverCalls += 1; + return host; + }, + }), + ).resolves.toBeNull(); + + expect(resolverCalls).toBe(0); + }); +}); diff --git a/packages/server/src/utils/github-remote.ts b/packages/server/src/utils/github-remote.ts new file mode 100644 index 000000000..bc72f7ab8 --- /dev/null +++ b/packages/server/src/utils/github-remote.ts @@ -0,0 +1,145 @@ +import { findExecutable } from "./executable.js"; +import { execCommand } from "./spawn.js"; + +const GITHUB_HOSTS = new Set(["github.com", "ssh.github.com"]); + +const TRANSPORT_BY_PROTOCOL: Record = { + "https:": "https", + "http:": "http", + "ssh:": "ssh", +}; + +let sshExecutableLookup: Promise | null = null; +const sshHostnameResolutionCache = new Map>(); + +interface GitRemoteLocation { + transport: "scp" | "ssh" | "http" | "https"; + host: string; + path: string; +} + +export interface GitHubRemoteIdentity { + owner: string; + name: string; + repo: string; +} + +export type SshHostnameResolver = (host: string) => Promise; + +export function parseGitHubRemoteUrl(remoteUrl: string): GitHubRemoteIdentity | null { + const location = parseGitRemoteLocation(remoteUrl); + if (!location || !GITHUB_HOSTS.has(location.host)) return null; + return parseGitHubRemoteIdentity(location.path); +} + +export async function resolveGitHubRemote(input: { + remoteUrl: string; + resolveSshHostname?: SshHostnameResolver; +}): Promise { + const location = parseGitRemoteLocation(input.remoteUrl); + if (!location) return null; + if (GITHUB_HOSTS.has(location.host)) return parseGitHubRemoteIdentity(location.path); + if (location.transport !== "scp" && location.transport !== "ssh") return null; + + const resolve = input.resolveSshHostname ?? resolveSshHostname; + const resolvedHost = await resolve(location.host); + if (!resolvedHost || !GITHUB_HOSTS.has(resolvedHost)) return null; + return parseGitHubRemoteIdentity(location.path); +} + +export async function resolveSshHostname(host: string): Promise { + const normalized = normalizeHost(host); + if (!normalized) return null; + + const cached = sshHostnameResolutionCache.get(normalized); + if (cached) return cached; + + const resolution = runSshHostnameLookup(normalized); + sshHostnameResolutionCache.set(normalized, resolution); + return resolution; +} + +async function runSshHostnameLookup(host: string): Promise { + sshExecutableLookup ??= findExecutable("ssh"); + const sshPath = await sshExecutableLookup; + if (!sshPath) return null; + + try { + const { stdout } = await execCommand(sshPath, ["-G", host], { + env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, + maxBuffer: 1024 * 1024, + }); + return parseSshHostname(stdout); + } catch { + return null; + } +} + +function parseSshHostname(stdout: string): string | null { + for (const line of stdout.split(/\r?\n/u)) { + const trimmed = line.trim(); + if (!trimmed) continue; + const [key, value] = trimmed.split(/\s+/u); + if (key?.toLowerCase() !== "hostname") continue; + const normalized = normalizeHost(value ?? ""); + if (normalized) return normalized; + } + return null; +} + +function parseGitRemoteLocation(remoteUrl: string): GitRemoteLocation | null { + const trimmed = remoteUrl.trim(); + if (!trimmed) return null; + + const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/u); + if (scpLike) { + const host = normalizeHost(scpLike[1] ?? ""); + const path = normalizeRemotePath(scpLike[2] ?? ""); + if (!isValidRemoteHost(host) || !path) return null; + return { transport: "scp", host, path }; + } + + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + return null; + } + + const transport = TRANSPORT_BY_PROTOCOL[parsed.protocol.toLowerCase()]; + if (!transport) return null; + + const host = normalizeHost(parsed.hostname); + let path: string; + try { + path = decodeURIComponent(parsed.pathname); + } catch { + return null; + } + const normalizedPath = normalizeRemotePath(path); + if (!isValidRemoteHost(host) || !normalizedPath) return null; + + return { transport, host, path: normalizedPath }; +} + +function parseGitHubRemoteIdentity(path: string): GitHubRemoteIdentity | null { + const segments = path.split("/").filter(Boolean); + if (segments.length !== 2) return null; + const [owner, name] = segments; + if (!owner || !name) return null; + return { owner, name, repo: `${owner}/${name}` }; +} + +function normalizeRemotePath(path: string): string | null { + let normalized = path.trim().replace(/^\/+|\/+$/gu, ""); + if (normalized.endsWith(".git")) normalized = normalized.slice(0, -4); + return normalized || null; +} + +function normalizeHost(host: string): string { + return host.trim().replace(/\.+$/u, "").toLowerCase(); +} + +function isValidRemoteHost(host: string): boolean { + return /^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/u.test(host); +}