/** * Git remote URL utilities — GitHub-focused. * * Parsing, validation, and compare-URL generation for git remote URLs. * Used by both the UI and the vite-plugin-file-sync AI agent tooling. */ const HTTPS_GIT_URL_RE = /^https?:\/\/[^\s/$.?#]+(?:\.[^\s/$.?#]+)+\/[^\s/$.?#]+(\/[^\s/$.?#]+)+(\.git)?\/?$/i; const SSH_GIT_URL_RE = /^[\w-]+@[\w.-]+:[\w./-]+(\.git)?$/; // ssh:// protocol format: ssh://git@host/path const SSH_PROTOCOL_RE = /^ssh:\/\/[\w-]+@[\w.-]+(\/[\w./-]+)+(\.git)?\/?$/i; export function isValidGitUrl(url: string): boolean { const trimmed = url.trim(); return HTTPS_GIT_URL_RE.test(trimmed) || SSH_GIT_URL_RE.test(trimmed) || SSH_PROTOCOL_RE.test(trimmed); } export function isGitHubUrl(url: string): boolean { const trimmed = url.trim(); try { const parsed = new URL(trimmed); if (parsed.hostname.toLowerCase() === 'github.com') return true; } catch { // Not a valid URL — try SSH format } const sshMatch = trimmed.match(/^[\w-]+@([\w.-]+):/); if (sshMatch?.[1]?.toLowerCase() === 'github.com') return true; return false; } /** * Converts a git remote URL (HTTPS or SSH) to a browser-friendly HTTPS URL. * Returns `null` when the URL cannot be converted. * * https://github.com/org/repo.git → https://github.com/org/repo * git@github.com:org/repo.git → https://github.com/org/repo */ export function gitRemoteUrlToWeb(url: string): string | null { const trimmed = url.trim(); try { const parsed = new URL(trimmed); if (parsed.protocol === 'https:' || parsed.protocol === 'http:') { // Strip userinfo (e.g. "org@" in Azure DevOps clone URLs) so the // result is a plain browser-friendly URL. parsed.username = ''; parsed.password = ''; return parsed.href.replace(/\/+$/, '').replace(/\.git\s*$/, ''); } // ssh:// protocol format: ssh://git@host/path if (parsed.protocol === 'ssh:') { const host = parsed.hostname; const path = parsed.pathname.replace(/\.git\s*$/, ''); // Azure DevOps SSH: ssh://git@ssh.dev.azure.com/v3/org/project/repo if (host === 'ssh.dev.azure.com') { const azureMatch = path.match(/^\/v3\/([^/]+)\/([^/]+)\/(.+?)$/); if (azureMatch) { return `https://dev.azure.com/${azureMatch[1]}/${azureMatch[2]}/_git/${azureMatch[3]}`; } } // Generic ssh:// → https:// (GitHub, GitLab, etc.) return `https://${host}${path}`; } } catch { // Not a standard URL — try SSH } // SCP-style SSH: git@host:org/repo.git → https://host/org/repo const sshMatch = trimmed.match(/^[\w-]+@([\w.-]+):(.+?)(?:\.git)?\s*$/); if (sshMatch?.[1] && sshMatch[2]) { const host = sshMatch[1]; const path = sshMatch[2]; // Azure DevOps SSH: git@ssh.dev.azure.com:v3/org/project/repo → https://dev.azure.com/org/project/_git/repo if (host === 'ssh.dev.azure.com') { const azureMatch = path.match(/^v3\/([^/]+)\/([^/]+)\/(.+)$/); if (azureMatch) { return `https://dev.azure.com/${azureMatch[1]}/${azureMatch[2]}/_git/${azureMatch[3]}`; } } return `https://${host}/${path}`; } return null; } /** * Returns a URL that opens the "new pull request" compare screen on GitHub. * Returns `null` when the remote URL is not a GitHub URL or cannot be parsed. */ export function getGitHubCompareUrl({ gitRemoteUrl, fromBranch, toBranch, title, body }: { gitRemoteUrl: string; fromBranch: string; toBranch: string; title?: string; body?: string; }): string | null { if (!isGitHubUrl(gitRemoteUrl)) return null; const webUrl = gitRemoteUrlToWeb(gitRemoteUrl); if (!webUrl) return null; const base = `${webUrl}/compare/${encodeURIComponent(toBranch)}...${encodeURIComponent(fromBranch)}`; if (!title && !body) return base; const params = new URLSearchParams(); params.set('expand', '1'); if (title) params.set('title', title); if (body) params.set('body', body); return `${base}?${params.toString()}`; } export type GitProvider = 'github' | 'gitlab' | 'bitbucket' | 'azure_devops' | 'unknown'; export const HOST_TO_PROVIDER: Record = { 'bitbucket.org': 'bitbucket', 'dev.azure.com': 'azure_devops', 'github.com': 'github', 'gitlab.com': 'gitlab', 'ssh.dev.azure.com': 'azure_devops' }; export function detectGitProvider(url: string): GitProvider { const trimmed = url.trim(); try { const parsed = new URL(trimmed); return HOST_TO_PROVIDER[parsed.hostname.toLowerCase()] ?? 'unknown'; } catch { // Not a standard URL — try SSH format. } const sshMatch = trimmed.match(/^[\w-]+@([\w.-]+):/); if (sshMatch?.[1]) { return HOST_TO_PROVIDER[sshMatch[1].toLowerCase()] ?? 'unknown'; } return 'unknown'; } function getCommitPathForProvider(params: { hash: string; provider: GitProvider }): string { const { hash, provider } = params; switch (provider) { case 'bitbucket': return `/commits/${hash}`; case 'gitlab': return `/-/commit/${hash}`; case 'azure_devops': case 'github': case 'unknown': return `/commit/${hash}`; } } const GIT_COMMIT_HASH_REGEX = /^[0-9a-f]{7,40}$/i; export function isGitCommitHash(value: string): boolean { return GIT_COMMIT_HASH_REGEX.test(value.trim()); } export function getCommitUrlForHash(params: { hash: string; repositoryWebUrl: string }): string | null { const { hash, repositoryWebUrl } = params; const trimmedHash = hash.trim(); if (!isGitCommitHash(trimmedHash)) { return null; } const trimmedRepositoryUrl = repositoryWebUrl.trim(); const webUrl = gitRemoteUrlToWeb(trimmedRepositoryUrl) ?? trimmedRepositoryUrl; const provider = detectGitProvider(trimmedRepositoryUrl); return `${webUrl.replace(/\/+$/, '')}${getCommitPathForProvider({ hash: trimmedHash, provider })}`; } /** * Converts an SSH git remote URL to its HTTPS equivalent suitable for * `git clone` / `git fetch` with a credential helper. HTTPS URLs are * returned as-is (with `.git` suffix ensured, except for Azure DevOps which * uses `/_git/repo` format without `.git`). Returns `null` when the URL * cannot be converted. * * git@github.com:org/repo.git → https://github.com/org/repo.git * git@gitlab-self.example.com:root/app.git → https://gitlab-self.example.com/root/app.git * ssh://git@github.com/org/repo → https://github.com/org/repo.git * https://github.com/org/repo.git → https://github.com/org/repo.git (passthrough) * https://org@dev.azure.com/org/proj/_git/repo → https://org@dev.azure.com/org/proj/_git/repo (no .git) */ export function sshGitUrlToHttps(url: string): string | null { const trimmed = url.trim(); try { const parsed = new URL(trimmed); if (parsed.protocol === 'https:' || parsed.protocol === 'http:') { // Azure DevOps HTTPS URLs use /_git/repo format without .git suffix if (parsed.hostname === 'dev.azure.com' && parsed.pathname.includes('/_git/')) { return trimmed.replace(/\/+$/, ''); } return ensureGitSuffix(trimmed); } if (parsed.protocol === 'ssh:') { const host = parsed.hostname; const path = parsed.pathname; if (host === 'ssh.dev.azure.com') { const azureMatch = path.match(/^\/v3\/([^/]+)\/([^/]+)\/(.+?)(?:\.git)?\/?$/); if (azureMatch) { return `https://dev.azure.com/${azureMatch[1]}/${azureMatch[2]}/_git/${azureMatch[3]}`; } } return ensureGitSuffix(`https://${host}${path}`); } } catch { // Not a standard URL — try SCP-style SSH below. } const sshMatch = trimmed.match(/^[\w-]+@([\w.-]+):(.+?)(?:\.git)?\s*$/); if (sshMatch?.[1] && sshMatch[2]) { const host = sshMatch[1]; const path = sshMatch[2]; if (host === 'ssh.dev.azure.com') { const azureMatch = path.match(/^v3\/([^/]+)\/([^/]+)\/(.+)$/); if (azureMatch) { return `https://dev.azure.com/${azureMatch[1]}/${azureMatch[2]}/_git/${azureMatch[3]}`; } } return `https://${host}/${path}.git`; } return null; } function ensureGitSuffix(url: string): string { const cleaned = url.replace(/\/+$/, ''); return cleaned.endsWith('.git') ? cleaned : `${cleaned}.git`; } export function isCommitUrlForHash(params: { hash: string; url: string }): boolean { const { hash, url } = params; const trimmedHash = hash.trim(); const trimmedUrl = url.trim(); if (!isGitCommitHash(trimmedHash)) { return false; } try { const parsed = new URL(trimmedUrl); const normalizedPathname = parsed.pathname.replace(/\/+$/, ''); return normalizedPathname.endsWith( getCommitPathForProvider({ hash: trimmedHash, provider: detectGitProvider(trimmedUrl) }) ); } catch { return false; } }