From 78e48fb58706c106a39e2241c1d38f8c9dc215ce Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Mon, 10 Aug 2026 14:12:39 -0400 Subject: [PATCH 1/2] fix(repos): parse and preserve custom-user SSH remotes SSH remotes with a non-git user (e.g. company@company.ghe.com:orga/repo.git) were misparsed as GitHub shorthand and stored as https://github.com/company@company.ghe.com:orga/repo. The scp-style matcher hardcoded the git@ user, so custom-user remotes fell through to the owner/repo shorthand branch. SSH URL parsing is now unified in shared/utils/repo.ts and applied across backend and frontend: any user@host:path remote is detected, preserved, and deduplicated correctly. The compare key also converges the scp-with-port and ssh:// spellings, strips embedded credentials, and fails closed on malformed host segments. --- backend/src/services/git-auth.ts | 2 +- backend/src/services/git/GitService.ts | 3 +- backend/src/services/repo.ts | 8 +- backend/src/services/schedule-worktree.ts | 3 +- backend/src/utils/git-auth.ts | 39 ---- backend/test/utils/repo-url.test.ts | 188 ++++++++++++++++++ .../src/components/repo/AddRepoDialog.tsx | 6 +- shared/src/utils/repo.ts | 71 +++++-- 8 files changed, 255 insertions(+), 65 deletions(-) create mode 100644 backend/test/utils/repo-url.test.ts diff --git a/backend/src/services/git-auth.ts b/backend/src/services/git-auth.ts index ceb595b0..9d8b65aa 100644 --- a/backend/src/services/git-auth.ts +++ b/backend/src/services/git-auth.ts @@ -4,7 +4,7 @@ import { AskpassHandler } from '../ipc/askpassHandler' import { SSHHostKeyHandler } from '../ipc/sshHostKeyHandler' import { writeTemporarySSHKey, buildSSHCommand, buildSSHCommandWithKnownHosts, cleanupSSHKey, parseSSHHost } from '../utils/ssh-key-manager' import { decryptSecret } from '../utils/crypto' -import { isSSHUrl, normalizeSSHUrl, extractHostFromSSHUrl } from '../utils/git-auth' +import { isSSHUrl, normalizeSSHUrl, extractHostFromSSHUrl } from '@opencode-manager/shared/utils' import type { GitCredential } from '@opencode-manager/shared' import { logger } from '../utils/logger' import { CredentialProvider } from './credential-provider' diff --git a/backend/src/services/git/GitService.ts b/backend/src/services/git/GitService.ts index f58e7fe5..7f49284e 100644 --- a/backend/src/services/git/GitService.ts +++ b/backend/src/services/git/GitService.ts @@ -3,7 +3,8 @@ import { executeCommand } from '../../utils/process' import { logger } from '../../utils/logger' import { getErrorMessage } from '../../utils/error-utils' import { getRepoById } from '../../db/queries' -import { resolveGitIdentity, createGitIdentityEnv, isSSHUrl } from '../../utils/git-auth' +import { resolveGitIdentity, createGitIdentityEnv } from '../../utils/git-auth' +import { isSSHUrl } from '@opencode-manager/shared/utils' import { isNoUpstreamError, parseBranchNameFromError } from '../../utils/git-errors' import { SettingsService } from '../settings' import { CredentialProvider } from '../credential-provider' diff --git a/backend/src/services/repo.ts b/backend/src/services/repo.ts index 55fc2b56..a709220f 100644 --- a/backend/src/services/repo.ts +++ b/backend/src/services/repo.ts @@ -7,9 +7,9 @@ import type { Database } from 'bun:sqlite' import type { Repo, CreateRepoInput } from '../types/repo' import { logger } from '../utils/logger' import { getReposPath, getScheduleWorktreesPath } from '@opencode-manager/shared/config/env' -import { normalizeRepoDirectoryName, sanitizeRepoDirectoryName, sanitizeBranchForDirectory, normalizeRepoUrlForCompare } from '@opencode-manager/shared/utils' +import { normalizeRepoDirectoryName, sanitizeRepoDirectoryName, sanitizeBranchForDirectory, normalizeRepoUrlForCompare, isSSHUrl, normalizeSSHUrl } from '@opencode-manager/shared/utils' import type { GitAuthService } from './git-auth' -import { isGitHubHttpsUrl, isSSHUrl, normalizeSSHUrl } from '../utils/git-auth' +import { isGitHubHttpsUrl } from '../utils/git-auth' import path from 'path' import { parseSSHHost } from '../utils/ssh-key-manager' import { getErrorMessage } from '../utils/error-utils' @@ -957,9 +957,9 @@ export async function deleteRepoFiles(database: Database, repoId: number): Promi } function normalizeRepoUrl(url: string, preserveSSH: boolean = false): { url: string; name: string } { - const sshMatch = url.match(/^git@([^:]+):(.+?)(?:\.git)?$/) + const sshMatch = url.match(/^([^@/:]+)@([^:]+):(.+?)(?:\.git)?$/) if (sshMatch) { - const [, host, pathPart] = sshMatch + const [, , host, pathPart] = sshMatch const path = pathPart ?? '' const repoName = path.split('/').pop() || `repo-${Date.now()}` return { diff --git a/backend/src/services/schedule-worktree.ts b/backend/src/services/schedule-worktree.ts index 3fa4b08d..78d28fa9 100644 --- a/backend/src/services/schedule-worktree.ts +++ b/backend/src/services/schedule-worktree.ts @@ -8,7 +8,8 @@ import type { GitAuthService } from './git-auth' import type { SettingsService } from './settings' import type { CredentialProvider } from './credential-provider' import type { OpenCodeClient } from './opencode/client' -import { resolveGitIdentity, createGitIdentityEnv, isSSHUrl } from '../utils/git-auth' +import { resolveGitIdentity, createGitIdentityEnv } from '../utils/git-auth' +import { isSSHUrl } from '@opencode-manager/shared/utils' import { executeCommand } from '../utils/process' import { resolveDefaultBranch, createWorktreeSafely, removeWorktree } from './repo' import { logger } from '../utils/logger' diff --git a/backend/src/utils/git-auth.ts b/backend/src/utils/git-auth.ts index 2b20002f..a7461a0e 100644 --- a/backend/src/utils/git-auth.ts +++ b/backend/src/utils/git-auth.ts @@ -24,45 +24,6 @@ function normalizeGitCredentialUrl(host: string): URL | null { } } -export function isSSHUrl(url: string): boolean { - return url.startsWith('git@') || url.startsWith('ssh://') -} - -export function normalizeSSHUrl(url: string): string { - if (url.startsWith('ssh://')) { - return url - } - - const match = url.match(/^git@([^:]+):(\d{1,5})\/(.+)$/) - if (match) { - const [, host, port, path] = match - const portNum = parseInt(port!, 10) - if (portNum > 0 && portNum <= 65535) { - return `ssh://git@${host}:${port}/${path}` - } - } - return url -} - -export function extractHostFromSSHUrl(url: string): string | null { - if (url.startsWith('git@')) { - const match = url.match(/^git@([^:]+):/) - const host = match?.[1] - return host || null - } - if (url.startsWith('ssh://')) { - try { - const parsed = new URL(url) - const hostname = parsed.hostname ?? '' - const port = parsed.port ?? '' - return port ? `${hostname}:${port}` : parsed.hostname || null - } catch { - return null - } - } - return null -} - export function normalizeHost(host: string): string | null { const url = normalizeGitCredentialUrl(host) return url ? `${url.protocol}//${url.host}/` : null diff --git a/backend/test/utils/repo-url.test.ts b/backend/test/utils/repo-url.test.ts new file mode 100644 index 00000000..efe8b9eb --- /dev/null +++ b/backend/test/utils/repo-url.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect } from 'vitest' +import { + isScpStyleUrl, + isSSHUrl, + normalizeSSHUrl, + extractHostFromSSHUrl, + getRepoNameFromUrl, + normalizeRepoUrlForCompare, +} from '@opencode-manager/shared/utils' + +describe('isScpStyleUrl', () => { + it('matches git@host:path', () => { + expect(isScpStyleUrl('git@github.com:user/repo.git')).toBe(true) + }) + + it('matches custom-user host:path', () => { + expect(isScpStyleUrl('company@company.ghe.com:orga/repo.git')).toBe(true) + }) + + it('does not match https URLs', () => { + expect(isScpStyleUrl('https://github.com/user/repo.git')).toBe(false) + }) + + it('does not match shorthand owner/repo', () => { + expect(isScpStyleUrl('user/repo')).toBe(false) + }) + + it('does not match ssh:// URLs', () => { + expect(isScpStyleUrl('ssh://git@host/repo.git')).toBe(false) + }) +}) + +describe('isSSHUrl', () => { + it('detects ssh:// URLs', () => { + expect(isSSHUrl('ssh://git@github.com/user/repo.git')).toBe(true) + }) + + it('detects git@host:path scp-style URLs', () => { + expect(isSSHUrl('git@github.com:user/repo.git')).toBe(true) + }) + + it('detects custom-user scp-style URLs', () => { + expect(isSSHUrl('company@company.ghe.com:orga/repo.git')).toBe(true) + expect(isSSHUrl('deploy@git.example.com:apps/repo')).toBe(true) + }) + + it('does not detect https, credentialed https, or shorthand URLs', () => { + expect(isSSHUrl('https://github.com/user/repo.git')).toBe(false) + expect(isSSHUrl('https://oauth2:TOKEN@gitlab.com/user/repo.git')).toBe(false) + expect(isSSHUrl('user/repo')).toBe(false) + }) +}) + +describe('normalizeSSHUrl', () => { + it('returns ssh:// URLs unchanged', () => { + expect(normalizeSSHUrl('ssh://git@github.com:2222/user/repo.git')).toBe('ssh://git@github.com:2222/user/repo.git') + }) + + it('converts git@host:port/path to ssh:// form', () => { + expect(normalizeSSHUrl('git@github.com:2222/user/repo.git')).toBe('ssh://git@github.com:2222/user/repo.git') + }) + + it('converts custom-user host:port/path to ssh:// form', () => { + expect(normalizeSSHUrl('company@company.ghe.com:2222/orga/repo.git')).toBe('ssh://company@company.ghe.com:2222/orga/repo.git') + }) + + it('leaves scp-style URLs without a port unchanged', () => { + expect(normalizeSSHUrl('company@company.ghe.com:orga/repo.git')).toBe('company@company.ghe.com:orga/repo.git') + }) + + it('leaves out-of-range ports unchanged', () => { + expect(normalizeSSHUrl('git@github.com:99999/user/repo.git')).toBe('git@github.com:99999/user/repo.git') + }) + + it('reads digits as an owner when no repo path follows, matching git semantics', () => { + expect(normalizeSSHUrl('git@github.com:2222/repo.git')).toBe('git@github.com:2222/repo.git') + }) + + it('reads digits as a port when a full owner/repo path follows', () => { + expect(normalizeSSHUrl('git@git.example.com:2222/owner/repo.git')).toBe('ssh://git@git.example.com:2222/owner/repo.git') + }) + + it('treats a custom port before nested groups as a port', () => { + expect(normalizeSSHUrl('git@git.example.com:2222/group/subgroup/project.git')).toBe('ssh://git@git.example.com:2222/group/subgroup/project.git') + }) +}) + +describe('extractHostFromSSHUrl', () => { + it('extracts host from git@host:path', () => { + expect(extractHostFromSSHUrl('git@github.com:user/repo.git')).toBe('github.com') + }) + + it('extracts host from custom-user scp-style URL', () => { + expect(extractHostFromSSHUrl('company@company.ghe.com:orga/repo.git')).toBe('company.ghe.com') + }) + + it('extracts host with port from ssh:// URL', () => { + expect(extractHostFromSSHUrl('ssh://git@git.example.com:2222/user/repo.git')).toBe('git.example.com:2222') + }) + + it('returns null for non-SSH URLs', () => { + expect(extractHostFromSSHUrl('https://github.com/user/repo.git')).toBeNull() + }) + + it('fails closed when the host segment contains a path separator', () => { + expect(extractHostFromSSHUrl('git@github.com/owner:repo')).toBeNull() + }) +}) + +describe('getRepoNameFromUrl', () => { + it('extracts repo name from custom-user scp URL', () => { + expect(getRepoNameFromUrl('company@company.ghe.com:orga/repo.git')).toBe('repo') + expect(getRepoNameFromUrl('company@company.ghe.com:repo.git')).toBe('repo') + }) + + it('extracts repo name from git@ scp URL', () => { + expect(getRepoNameFromUrl('git@github.com:user/repo.git')).toBe('repo') + }) + + it('extracts repo name from https URL', () => { + expect(getRepoNameFromUrl('https://github.com/user/repo.git')).toBe('repo') + }) +}) + +describe('normalizeRepoUrlForCompare', () => { + it('normalizes custom-user scp URL to https host/path', () => { + expect(normalizeRepoUrlForCompare('company@company.ghe.com:orga/repo.git')).toBe('https://company.ghe.com/orga/repo') + }) + + it('normalizes git@ scp URL to https github path', () => { + expect(normalizeRepoUrlForCompare('git@github.com:user/repo.git')).toBe('https://github.com/user/repo') + }) + + it('normalizes shorthand owner/repo to github URL', () => { + expect(normalizeRepoUrlForCompare('user/repo')).toBe('https://github.com/user/repo') + }) + + it('normalizes ssh:// URL to https host/path', () => { + expect(normalizeRepoUrlForCompare('ssh://git@gitlab.com/user/repo.git')).toBe('https://gitlab.com/user/repo') + }) + + it('normalizes https URL case-insensitively', () => { + expect(normalizeRepoUrlForCompare('HTTPS://GitHub.com/User/Repo.git')).toBe('https://github.com/user/repo') + }) + + it('gives scp-with-port and ssh:// spellings of the same remote one identity', () => { + const scpWithPort = normalizeRepoUrlForCompare('git@git.example.com:3000/owner/repo.git') + const explicitSSH = normalizeRepoUrlForCompare('ssh://git@git.example.com:3000/owner/repo.git') + + expect(scpWithPort).toBe('https://git.example.com:3000/owner/repo') + expect(scpWithPort).toBe(explicitSSH) + }) + + it('keeps a digit-named owner in the path instead of reading it as a port', () => { + expect(normalizeRepoUrlForCompare('git@github.com:2222/repo.git')).toBe('https://github.com/2222/repo') + }) + + it('keeps distinct SSH ports distinct', () => { + expect(normalizeRepoUrlForCompare('ssh://git@git.example.com:3000/owner/repo.git')) + .not.toBe(normalizeRepoUrlForCompare('ssh://git@git.example.com:2222/owner/repo.git')) + }) + + it('strips embedded credentials so tokenized and clean https URLs match', () => { + const clean = normalizeRepoUrlForCompare('https://gitlab.com/owner/repo.git') + + expect(normalizeRepoUrlForCompare('https://oauth2:TOKEN@gitlab.com/owner/repo.git')).toBe(clean) + expect(normalizeRepoUrlForCompare('https://x-access-token:TOKEN@gitlab.com/owner/repo.git')).toBe(clean) + expect(normalizeRepoUrlForCompare('https://user@gitlab.com/owner/repo.git')).toBe(clean) + expect(clean).toBe('https://gitlab.com/owner/repo') + }) + + it('does not leak a token into the comparison key', () => { + expect(normalizeRepoUrlForCompare('https://oauth2:SECRETTOKEN@gitlab.com/owner/repo.git')).not.toContain('secrettoken') + }) + + it('upgrades http to https so both spellings match', () => { + expect(normalizeRepoUrlForCompare('http://github.com/owner/repo.git')).toBe('https://github.com/owner/repo') + }) + + it('preserves non-default https ports', () => { + expect(normalizeRepoUrlForCompare('https://git.example.com:8443/owner/repo.git')).toBe('https://git.example.com:8443/owner/repo') + }) + + it('leaves local paths and file URLs alone', () => { + expect(normalizeRepoUrlForCompare('/Users/me/repo')).toBe('/users/me/repo') + expect(normalizeRepoUrlForCompare('file:///Users/me/repo')).toBe('file:///users/me/repo') + }) +}) diff --git a/frontend/src/components/repo/AddRepoDialog.tsx b/frontend/src/components/repo/AddRepoDialog.tsx index e29acc6e..416461a6 100644 --- a/frontend/src/components/repo/AddRepoDialog.tsx +++ b/frontend/src/components/repo/AddRepoDialog.tsx @@ -9,7 +9,7 @@ import { DirectoryPickerDialog } from './DirectoryPickerDialog' import { Loader2, FolderSearch } from 'lucide-react' import { showToast } from '@/lib/toast' import { invalidateRepoListCaches } from '@/lib/queryInvalidation' -import { getRepoBaseDirectoryName, getRepoDirectoryNameError, getRepoNameFromUrl, normalizeRepoUrlForCompare, sanitizeRepoDirectoryName } from '@opencode-manager/shared/utils' +import { getRepoBaseDirectoryName, getRepoDirectoryNameError, getRepoNameFromUrl, isSSHUrl, normalizeRepoUrlForCompare, sanitizeRepoDirectoryName } from '@opencode-manager/shared/utils' import type { DiscoverReposResponse } from '@opencode-manager/shared/types' import type { Repo } from '@/api/types' @@ -30,10 +30,6 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) { const directoryTouched = useRef(false) const queryClient = useQueryClient() - const isSSHUrl = (url: string): boolean => { - return url.startsWith('git@') || url.startsWith('ssh://') - } - const showSkipSSHCheckbox = repoType === 'remote' && isSSHUrl(repoUrl) const showDirectoryName = repoType === 'remote' diff --git a/shared/src/utils/repo.ts b/shared/src/utils/repo.ts index d3b6db89..fc5b8a2c 100644 --- a/shared/src/utils/repo.ts +++ b/shared/src/utils/repo.ts @@ -66,12 +66,58 @@ export function getRepoBaseDirectoryName(repo: { localPath: string; branch?: str return repo.localPath } +export const SCP_STYLE_URL_PATTERN = /^([^@/:]+)@([^/:]+):(.+)$/ +const SCP_STYLE_URL_WITH_PORT_PATTERN = /^([^@/:]+)@([^/:]+):(\d{1,5})\/(.+\/.+)$/ + +export function isScpStyleUrl(url: string): boolean { + return SCP_STYLE_URL_PATTERN.test(url.trim()) +} + +export function isSSHUrl(url: string): boolean { + return url.trim().startsWith('ssh://') || isScpStyleUrl(url) +} + +export function normalizeSSHUrl(url: string): string { + if (url.startsWith('ssh://')) { + return url + } + + const match = url.match(SCP_STYLE_URL_WITH_PORT_PATTERN) + if (match) { + const [, user, host, port, path] = match + const portNumber = Number(port) + if (portNumber > 0 && portNumber <= 65535) { + return `ssh://${user}@${host}:${port}/${path}` + } + } + + return url +} + +export function extractHostFromSSHUrl(url: string): string | null { + const scpMatch = url.match(SCP_STYLE_URL_PATTERN) + if (scpMatch) { + return scpMatch[2] || null + } + + if (url.startsWith('ssh://')) { + try { + const parsed = new URL(url) + return parsed.port ? `${parsed.hostname}:${parsed.port}` : parsed.hostname || null + } catch { + return null + } + } + + return null +} + export function getRepoNameFromUrl(url: string): string { const cleaned = trimTrailingChar(url.trim().replace(/\.git$/, ''), '/') - const scpMatch = cleaned.match(/^git@[^:]+:(.+)$/) + const scpMatch = cleaned.match(SCP_STYLE_URL_PATTERN) if (scpMatch) { - const parts = scpMatch[1]?.split('/') ?? [] + const parts = scpMatch[3]?.split('/') ?? [] return parts[parts.length - 1] || '' } @@ -97,19 +143,20 @@ export function getRepoDisplayName(repo: { } export function normalizeRepoUrlForCompare(url: string): string { - let normalized = trimTrailingChar(url.trim().replace(/\.git$/, ''), '/') - const shorthandMatch = normalized.match(/^([^/]+)\/([^/]+)$/) + const normalized = trimTrailingChar(normalizeSSHUrl(url.trim()).replace(/\.git$/, ''), '/') + const scpMatch = normalized.match(SCP_STYLE_URL_PATTERN) - if (shorthandMatch && !normalized.includes('://') && !normalized.startsWith('git@')) { - return `https://github.com/${normalized}`.toLowerCase() + if (scpMatch) { + return `https://${scpMatch[2]}/${scpMatch[3]}`.toLowerCase() } - const scpMatch = normalized.match(/^git@([^:]+):(.+)$/) - if (scpMatch) { - return `https://${scpMatch[1]}/${scpMatch[2]}`.toLowerCase() + const shorthandMatch = normalized.match(/^([^/]+)\/([^/]+)$/) + + if (shorthandMatch && !normalized.includes('://')) { + return `https://github.com/${normalized}`.toLowerCase() } - if (normalized.startsWith('ssh://')) { + if (/^(?:ssh|https?):\/\//.test(normalized)) { try { const parsed = new URL(normalized) const path = parsed.pathname.replace(/^\/+/, '') @@ -120,9 +167,5 @@ export function normalizeRepoUrlForCompare(url: string): string { } } - if (normalized.startsWith('http://')) { - normalized = `https://${normalized.slice(7)}` - } - return normalized.toLowerCase() } From ab69bcc00ed1f6677950bc5b3f9d5405fb63c42f Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:51:04 +0000 Subject: [PATCH 2/2] fix: apply CodeRabbit auto-fixes Fixed 1 file(s) based on 1 unresolved review comment. Co-authored-by: CodeRabbit --- backend/src/services/repo.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/src/services/repo.ts b/backend/src/services/repo.ts index a709220f..024316bd 100644 --- a/backend/src/services/repo.ts +++ b/backend/src/services/repo.ts @@ -7,7 +7,7 @@ import type { Database } from 'bun:sqlite' import type { Repo, CreateRepoInput } from '../types/repo' import { logger } from '../utils/logger' import { getReposPath, getScheduleWorktreesPath } from '@opencode-manager/shared/config/env' -import { normalizeRepoDirectoryName, sanitizeRepoDirectoryName, sanitizeBranchForDirectory, normalizeRepoUrlForCompare, isSSHUrl, normalizeSSHUrl } from '@opencode-manager/shared/utils' +import { normalizeRepoDirectoryName, sanitizeRepoDirectoryName, sanitizeBranchForDirectory, normalizeRepoUrlForCompare, isSSHUrl, normalizeSSHUrl, SCP_STYLE_URL_PATTERN } from '@opencode-manager/shared/utils' import type { GitAuthService } from './git-auth' import { isGitHubHttpsUrl } from '../utils/git-auth' import path from 'path' @@ -957,13 +957,13 @@ export async function deleteRepoFiles(database: Database, repoId: number): Promi } function normalizeRepoUrl(url: string, preserveSSH: boolean = false): { url: string; name: string } { - const sshMatch = url.match(/^([^@/:]+)@([^:]+):(.+?)(?:\.git)?$/) + const sshMatch = url.match(SCP_STYLE_URL_PATTERN) if (sshMatch) { const [, , host, pathPart] = sshMatch - const path = pathPart ?? '' + const path = (pathPart ?? '').replace(/\.git$/, '') const repoName = path.split('/').pop() || `repo-${Date.now()}` return { - url: preserveSSH ? url : `https://${host}/${path.replace(/\.git$/, '')}`, + url: preserveSSH ? url : `https://${host}/${path}`, name: repoName } }