diff --git a/packages/ui/src/lib/gitHost.test.ts b/packages/ui/src/lib/gitHost.test.ts new file mode 100644 index 00000000..9320b160 --- /dev/null +++ b/packages/ui/src/lib/gitHost.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from 'bun:test'; +import { parseGitHost } from './gitHost'; + +describe('parseGitHost', () => { + test('scp-like form with user', () => { + expect(parseGitHost('git@codeberg.org:owner/repo.git')).toBe('codeberg.org'); + expect(parseGitHost('git@git.example.com:group/repo.git')).toBe('git.example.com'); + }); + + test('scp-like form without user (bare host:path)', () => { + expect(parseGitHost('codeberg.org:owner/repo.git')).toBe('codeberg.org'); + expect(parseGitHost('git.example.com:group/repo.git')).toBe('git.example.com'); + }); + + test('ssh URL with port', () => { + expect(parseGitHost('ssh://git@codeberg.org:2222/owner/repo.git')).toBe('codeberg.org'); + }); + + test('git+ssh and git schemes', () => { + expect(parseGitHost('git+ssh://git@host/owner/repo.git')).toBe('host'); + expect(parseGitHost('git://host/owner/repo.git')).toBe('host'); + }); + + test('https with user and port', () => { + expect(parseGitHost('https://user@host:8443/owner/repo.git')).toBe('host'); + }); + + test('IPv6 URL form returns the bare address', () => { + expect(parseGitHost('ssh://git@[2001:db8::1]/owner/repo.git')).toBe('2001:db8::1'); + expect(parseGitHost('ssh://git@[2001:DB8::1]:2222/owner/repo.git')).toBe('2001:db8::1'); + }); + + test('bracketed IPv6 input', () => { + expect(parseGitHost('[2001:db8::1]')).toBe('2001:db8::1'); + expect(parseGitHost('[2001:db8::1]:owner/repo.git')).toBe('2001:db8::1'); + }); + + test('unbracketed IPv6 input', () => { + expect(parseGitHost('2001:db8::1')).toBe('2001:db8::1'); + }); + + test('plain hostname and hostname-with-user without a colon', () => { + expect(parseGitHost('git.example.com')).toBe('git.example.com'); + expect(parseGitHost('git@host')).toBe('host'); + }); + + test('hostname with trailing dot is stripped once', () => { + expect(parseGitHost('git.example.com.')).toBe('git.example.com'); + expect(parseGitHost('ssh://git.example.com./owner/repo.git')).toBe('git.example.com'); + }); + + test('input is trimmed', () => { + expect(parseGitHost(' codeberg.org:owner/repo.git ')).toBe('codeberg.org'); + expect(parseGitHost(' ssh://git@codeberg.org/owner/repo.git ')).toBe('codeberg.org'); + }); + + test('empty, whitespace, and null input', () => { + expect(parseGitHost('')).toBeNull(); + expect(parseGitHost(' ')).toBeNull(); + expect(parseGitHost(' \t ')).toBeNull(); + expect(parseGitHost(null as unknown as string)).toBeNull(); + }); + + test('garbage input', () => { + expect(parseGitHost('not a url')).toBeNull(); + expect(parseGitHost('ssh://not a url')).toBeNull(); + }); + + test('Windows-path-like input is not a host', () => { + expect(parseGitHost('C:\\foo')).toBeNull(); + expect(parseGitHost('C:\\foo\\bar.git')).toBeNull(); + }); +}); diff --git a/packages/ui/src/lib/gitHost.ts b/packages/ui/src/lib/gitHost.ts new file mode 100644 index 00000000..2e370b0a --- /dev/null +++ b/packages/ui/src/lib/gitHost.ts @@ -0,0 +1,62 @@ +/** + * Parse a git remote or custom-domain string and return its bare hostname. + * + * Understands URL forms with any scheme (`https://`, `ssh://`, `git+ssh://`, + * `git://`, ...), scp-like forms with or without a `user@` prefix + * (`git@host:owner/repo.git`, `host:owner/repo.git`), and IPv6 addresses + * (bracketed or unbracketed). The result is normalized: lowercase, no + * surrounding IPv6 brackets, no scheme, port, or path, and at most one + * trailing dot stripped. Returns `null` for empty, whitespace, or unparseable + * input. + */ +export const parseGitHost = (raw: string): string | null => { + const value = (raw ?? '').trim(); + if (!value) { + return null; + } + + // scp-like form: [user@]host:path — never applies once a scheme is present. + if (!value.includes('://')) { + const authority = value.slice(value.lastIndexOf('@') + 1); + // Bracketed IPv6, e.g. `[2001:db8::1]` or `[2001:db8::1]:owner/repo.git`. + if (authority.startsWith('[')) { + const close = authority.indexOf(']'); + if (close > 0 && authority.slice(1, close).includes(':')) { + return normalizeHost(authority.slice(1, close)); + } + // Malformed brackets fall through to URL parsing, which rejects them. + } else { + const colon = authority.indexOf(':'); + if (colon > 0) { + const candidate = authority.slice(0, colon); + // A single-segment pre-colon value without a dot is not a host — the + // guard rejects Windows paths like `C:\foo`, which then fall through + // to URL parsing and fail on the non-numeric port. Hosts with a + // numeric port (`localhost:3000`) still resolve via the URL branch. + if (!candidate.includes('/') && candidate.includes('.')) { + return normalizeHost(candidate); + } + } + // Unbracketed IPv6 (e.g. `2001:db8::1`): parse it as a bracketed host + // so the address survives instead of tripping over the scp colon split. + if (authority.includes(':') && !authority.includes('/') && authority.length > 2) { + try { + return normalizeHost(new URL(`ssh://[${authority}]`).hostname); + } catch { + // Not an IPv6 address; fall through to generic URL parsing. + } + } + } + // No colon or a non-host pre-colon segment: fall through to URL parsing. + } + + try { + const parsed = new URL(value.includes('://') ? value : `ssh://${value}`); + return normalizeHost(parsed.hostname); + } catch { + return null; + } +}; + +const normalizeHost = (host: string): string => + host.replace(/^\[|\]$/g, '').toLowerCase().replace(/\.$/, ''); diff --git a/packages/ui/src/lib/gitProvider.test.ts b/packages/ui/src/lib/gitProvider.test.ts index 43882f27..aa0b9a62 100644 --- a/packages/ui/src/lib/gitProvider.test.ts +++ b/packages/ui/src/lib/gitProvider.test.ts @@ -58,6 +58,31 @@ describe('detectGitProvider', () => { expect(detectGitProvider(['git@codeberg.org:owner/repo.git'], hosts)).toBe('gitea'); }); + test('classifies a bare scp remote (no user) through configured gitea hosts', () => { + const hosts: GitProviderHosts = { ...EMPTY_HOSTS, gitea: ['codeberg.org'] }; + expect(detectGitProvider(['codeberg.org:owner/repo.git'], hosts)).toBe('gitea'); + }); + + test('classifies an ssh URL with a non-default port through configured gitea hosts', () => { + const hosts: GitProviderHosts = { ...EMPTY_HOSTS, gitea: ['codeberg.org'] }; + expect(detectGitProvider(['ssh://git@codeberg.org:2222/owner/repo.git'], hosts)).toBe('gitea'); + }); + + test('matches a custom domain entered as an scp remote against an ssh URL remote', () => { + const hosts: GitProviderHosts = { ...EMPTY_HOSTS, gitea: ['git@ssh.example.com:org/repo.git'] }; + expect(detectGitProvider(['ssh://git@ssh.example.com/org/repo.git'], hosts)).toBe('gitea'); + }); + + test('matches a custom domain entered bare against an scp remote', () => { + const hosts: GitProviderHosts = { ...EMPTY_HOSTS, gitea: ['codeberg.org:owner/repo.git'] }; + expect(detectGitProvider(['git@codeberg.org:owner/repo.git'], hosts)).toBe('gitea'); + }); + + test('classifies an IPv6 remote through a configured gitea host', () => { + const hosts: GitProviderHosts = { ...EMPTY_HOSTS, gitea: ['2001:db8::1'] }; + expect(detectGitProvider(['ssh://git@[2001:db8::1]/owner/repo.git'], hosts)).toBe('gitea'); + }); + test('does not classify codeberg.org as gitea without a configured host', () => { expect(detectGitProvider(['git@codeberg.org:owner/repo.git'], EMPTY_HOSTS)).toBe('other'); }); diff --git a/packages/ui/src/lib/gitProvider.ts b/packages/ui/src/lib/gitProvider.ts index eada3336..5d0777fc 100644 --- a/packages/ui/src/lib/gitProvider.ts +++ b/packages/ui/src/lib/gitProvider.ts @@ -1,4 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; +import { parseGitHost } from '@/lib/gitHost'; import { getRemotes } from '@/lib/gitApi'; import { useGitLabAuthStore } from '@/stores/useGitLabAuthStore'; import { useGiteaAuthStore } from '@/stores/useGiteaAuthStore'; @@ -18,28 +19,6 @@ export type GitProviderHosts = { gitea: string[]; }; -const parseGitRemoteHost = (value: string): string | null => { - const url = value.trim(); - if (!url) { - return null; - } - // scp-like form: git@host:owner/repo.git - const at = url.indexOf('@'); - if (at >= 0) { - const rest = url.slice(at + 1); - const colon = rest.indexOf(':'); - if (colon > 0 && !rest.slice(0, colon).includes('/')) { - return rest.slice(0, colon).toLowerCase(); - } - } - try { - const parsed = new URL(url.includes('://') ? url : `ssh://${url}`); - return parsed.hostname.toLowerCase(); - } catch { - return null; - } -}; - const normalizeHostList = (hosts: string[] | undefined): string[] => { const seen = new Set(); const result: string[] = []; @@ -64,7 +43,7 @@ const normalizeHostList = (hosts: string[] | undefined): string[] => { export const detectGitProvider = (fetchUrls: string[], hosts: GitProviderHosts): GitProvider | null => { const remoteHosts = new Set(); for (const url of fetchUrls) { - const host = parseGitRemoteHost(url); + const host = parseGitHost(url); if (host) { remoteHosts.add(host); } diff --git a/packages/ui/src/stores/useGitProviderDomainsStore.test.ts b/packages/ui/src/stores/useGitProviderDomainsStore.test.ts new file mode 100644 index 00000000..45a666d3 --- /dev/null +++ b/packages/ui/src/stores/useGitProviderDomainsStore.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from 'bun:test'; +import { useGitProviderDomainsStore } from './useGitProviderDomainsStore'; + +const resetDomains = () => { + useGitProviderDomainsStore.setState({ + domains: { github: [], gitlab: [], gitea: [] }, + }); +}; + +describe('useGitProviderDomainsStore', () => { + test('normalizes and dedupes custom domains into bare hostnames', () => { + resetDomains(); + useGitProviderDomainsStore.getState().setDomains('gitea', [ + 'git@codeberg.org:owner/repo.git', + 'ssh://git@gitea.example.com:2222/o/r.git', + 'https://g.example.com/x', + ' git.example.org ', + ]); + expect(useGitProviderDomainsStore.getState().domains.gitea).toEqual([ + 'codeberg.org', + 'gitea.example.com', + 'g.example.com', + 'git.example.org', + ]); + }); + + test('drops unparseable and duplicate entries', () => { + resetDomains(); + useGitProviderDomainsStore.getState().setDomains('gitlab', [ + 'git@gitlab.example.com:group/repo.git', + 'git@gitlab.example.com:group/repo.git', + '', + ' ', + 'not a url', + 'C:\\foo', + ]); + expect(useGitProviderDomainsStore.getState().domains.gitlab).toEqual(['gitlab.example.com']); + }); +}); diff --git a/packages/ui/src/stores/useGitProviderDomainsStore.ts b/packages/ui/src/stores/useGitProviderDomainsStore.ts index 849bbe2e..325a461f 100644 --- a/packages/ui/src/stores/useGitProviderDomainsStore.ts +++ b/packages/ui/src/stores/useGitProviderDomainsStore.ts @@ -1,5 +1,6 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; +import { parseGitHost } from '@/lib/gitHost'; import { createDeferredSafeJSONStorage } from './utils/safeStorage'; export type GitProviderName = 'github' | 'gitlab' | 'gitea'; @@ -20,30 +21,11 @@ const EMPTY_DOMAINS: GitProviderDomains = { github: [], gitlab: [], gitea: [] }; /** * Normalize a raw user-supplied domain into a bare hostname. Accepts plain - * hostnames, URLs (scheme/port/path stripped), and scp-like git remotes - * (`git@host:owner/repo.git`). Returns null for empty or unparseable input. + * hostnames, URLs (scheme/port/path stripped), and scp-like git remotes with + * or without a user prefix (`git@host:owner/repo.git`, `host:owner/repo.git`). + * Returns null for empty or unparseable input. */ -export const normalizeProviderDomain = (raw: string): string | null => { - const value = (raw ?? '').trim(); - if (!value) { - return null; - } - // scp-like form: git@host:owner/repo.git - const at = value.indexOf('@'); - if (at >= 0) { - const rest = value.slice(at + 1); - const colon = rest.indexOf(':'); - if (colon > 0 && !rest.slice(0, colon).includes('/')) { - return rest.slice(0, colon).toLowerCase().replace(/\.$/, ''); - } - } - try { - const parsed = new URL(value.includes('://') ? value : `ssh://${value}`); - return parsed.hostname.toLowerCase().replace(/\.$/, ''); - } catch { - return null; - } -}; +export const normalizeProviderDomain = (raw: string): string | null => parseGitHost(raw); const normalizeDomainList = (entries: unknown): string[] => { if (!Array.isArray(entries)) {