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:
2026-08-16 16:27:50 +00:00
parent ca91fd7e2d
commit b772f5dad9
6 changed files with 206 additions and 46 deletions
+73
View File
@@ -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();
});
});
+62
View File
@@ -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(/\.$/, '');
+25
View File
@@ -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');
});
+2 -23
View File
@@ -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)) {