fix(ui): support bare scp and IPv6 host forms in git provider detection
Extract a single shared parseGitHost (packages/ui/src/lib/gitHost.ts) used by both the custom-domain registry (normalizeProviderDomain) and remote detection (gitProvider). Previously both parsers required a user@ prefix, rejecting valid bare scp remotes like codeberg.org:owner/repo.git, and mangled IPv6 hosts. Now scp forms with or without a user, ssh:// URLs with ports, git+ssh scheme, and bracketed/unbracketed IPv6 all normalize to a bare hostname; Windows-path-like input is rejected.
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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(/\.$/, '');
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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<string>();
|
||||
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<string>();
|
||||
for (const url of fetchUrls) {
|
||||
const host = parseGitRemoteHost(url);
|
||||
const host = parseGitHost(url);
|
||||
if (host) {
|
||||
remoteHosts.add(host);
|
||||
}
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user