/ src / utils / detectRepository.ts
detectRepository.ts
  1  import { getCwd } from './cwd.js'
  2  import { logForDebugging } from './debug.js'
  3  import { getRemoteUrl } from './git.js'
  4  
  5  export type ParsedRepository = {
  6    host: string
  7    owner: string
  8    name: string
  9  }
 10  
 11  const repositoryWithHostCache = new Map<string, ParsedRepository | null>()
 12  
 13  export function clearRepositoryCaches(): void {
 14    repositoryWithHostCache.clear()
 15  }
 16  
 17  export async function detectCurrentRepository(): Promise<string | null> {
 18    const result = await detectCurrentRepositoryWithHost()
 19    if (!result) return null
 20    // Only return results for github.com to avoid breaking downstream consumers
 21    // that assume the result is a github.com repository.
 22    // Use detectCurrentRepositoryWithHost() for GHE support.
 23    if (result.host !== 'github.com') return null
 24    return `${result.owner}/${result.name}`
 25  }
 26  
 27  /**
 28   * Like detectCurrentRepository, but also returns the host (e.g. "github.com"
 29   * or a GHE hostname). Callers that need to construct URLs against a specific
 30   * GitHub host should use this variant.
 31   */
 32  export async function detectCurrentRepositoryWithHost(): Promise<ParsedRepository | null> {
 33    const cwd = getCwd()
 34  
 35    if (repositoryWithHostCache.has(cwd)) {
 36      return repositoryWithHostCache.get(cwd) ?? null
 37    }
 38  
 39    try {
 40      const remoteUrl = await getRemoteUrl()
 41      logForDebugging(`Git remote URL: ${remoteUrl}`)
 42      if (!remoteUrl) {
 43        logForDebugging('No git remote URL found')
 44        repositoryWithHostCache.set(cwd, null)
 45        return null
 46      }
 47  
 48      const parsed = parseGitRemote(remoteUrl)
 49      logForDebugging(
 50        `Parsed repository: ${parsed ? `${parsed.host}/${parsed.owner}/${parsed.name}` : null} from URL: ${remoteUrl}`,
 51      )
 52      repositoryWithHostCache.set(cwd, parsed)
 53      return parsed
 54    } catch (error) {
 55      logForDebugging(`Error detecting repository: ${error}`)
 56      repositoryWithHostCache.set(cwd, null)
 57      return null
 58    }
 59  }
 60  
 61  /**
 62   * Synchronously returns the cached github.com repository for the current cwd
 63   * as "owner/name", or null if it hasn't been resolved yet or the host is not
 64   * github.com. Call detectCurrentRepository() first to populate the cache.
 65   *
 66   * Callers construct github.com URLs, so GHE hosts are filtered out here.
 67   */
 68  export function getCachedRepository(): string | null {
 69    const parsed = repositoryWithHostCache.get(getCwd())
 70    if (!parsed || parsed.host !== 'github.com') return null
 71    return `${parsed.owner}/${parsed.name}`
 72  }
 73  
 74  /**
 75   * Parses a git remote URL into host, owner, and name components.
 76   * Accepts any host (github.com, GHE instances, etc.).
 77   *
 78   * Supports:
 79   *   https://host/owner/repo.git
 80   *   git@host:owner/repo.git
 81   *   ssh://git@host/owner/repo.git
 82   *   git://host/owner/repo.git
 83   *   https://host/owner/repo (no .git)
 84   *
 85   * Note: repo names can contain dots (e.g., cc.kurs.web)
 86   */
 87  export function parseGitRemote(input: string): ParsedRepository | null {
 88    const trimmed = input.trim()
 89  
 90    // SSH format: git@host:owner/repo.git
 91    const sshMatch = trimmed.match(/^git@([^:]+):([^/]+)\/([^/]+?)(?:\.git)?$/)
 92    if (sshMatch?.[1] && sshMatch[2] && sshMatch[3]) {
 93      if (!looksLikeRealHostname(sshMatch[1])) return null
 94      return {
 95        host: sshMatch[1],
 96        owner: sshMatch[2],
 97        name: sshMatch[3],
 98      }
 99    }
100  
101    // URL format: https://host/owner/repo.git, ssh://git@host/owner/repo, git://host/owner/repo
102    const urlMatch = trimmed.match(
103      /^(https?|ssh|git):\/\/(?:[^@]+@)?([^/:]+(?::\d+)?)\/([^/]+)\/([^/]+?)(?:\.git)?$/,
104    )
105    if (urlMatch?.[1] && urlMatch[2] && urlMatch[3] && urlMatch[4]) {
106      const protocol = urlMatch[1]
107      const hostWithPort = urlMatch[2]
108      const hostWithoutPort = hostWithPort.split(':')[0] ?? ''
109      if (!looksLikeRealHostname(hostWithoutPort)) return null
110      // Only preserve port for HTTPS — SSH/git ports are not usable for constructing
111      // web URLs (e.g. ssh://git@ghe.corp.com:2222 → port 2222 is SSH, not HTTPS).
112      const host =
113        protocol === 'https' || protocol === 'http'
114          ? hostWithPort
115          : hostWithoutPort
116      return {
117        host,
118        owner: urlMatch[3],
119        name: urlMatch[4],
120      }
121    }
122  
123    return null
124  }
125  
126  /**
127   * Parses a git remote URL or "owner/repo" string and returns "owner/repo".
128   * Only returns results for github.com hosts — GHE URLs return null.
129   * Use parseGitRemote() for GHE support.
130   * Also accepts plain "owner/repo" strings for backward compatibility.
131   */
132  export function parseGitHubRepository(input: string): string | null {
133    const trimmed = input.trim()
134  
135    // Try parsing as a full remote URL first.
136    // Only return results for github.com hosts — existing callers (VS Code extension,
137    // bridge) assume this function is GitHub.com-specific. Use parseGitRemote() directly
138    // for GHE support.
139    const parsed = parseGitRemote(trimmed)
140    if (parsed) {
141      if (parsed.host !== 'github.com') return null
142      return `${parsed.owner}/${parsed.name}`
143    }
144  
145    // If no URL pattern matched, check if it's already in owner/repo format
146    if (
147      !trimmed.includes('://') &&
148      !trimmed.includes('@') &&
149      trimmed.includes('/')
150    ) {
151      const parts = trimmed.split('/')
152      if (parts.length === 2 && parts[0] && parts[1]) {
153        // Remove .git extension if present
154        const repo = parts[1].replace(/\.git$/, '')
155        return `${parts[0]}/${repo}`
156      }
157    }
158  
159    logForDebugging(`Could not parse repository from: ${trimmed}`)
160    return null
161  }
162  
163  /**
164   * Checks whether a hostname looks like a real domain name rather than an
165   * SSH config alias. A simple dot-check is not enough because aliases like
166   * "github.com-work" still contain a dot. We additionally require that the
167   * last segment (the TLD) is purely alphabetic — real TLDs (com, org, io, net)
168   * never contain hyphens or digits.
169   */
170  function looksLikeRealHostname(host: string): boolean {
171    if (!host.includes('.')) return false
172    const lastSegment = host.split('.').pop()
173    if (!lastSegment) return false
174    // Real TLDs are purely alphabetic (e.g., "com", "org", "io").
175    // SSH aliases like "github.com-work" have a last segment "com-work" which
176    // contains a hyphen.
177    return /^[a-zA-Z]+$/.test(lastSegment)
178  }