Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/src/services/git-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
3 changes: 2 additions & 1 deletion backend/src/services/git/GitService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
12 changes: 6 additions & 6 deletions backend/src/services/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, SCP_STYLE_URL_PATTERN } 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'
Expand Down Expand Up @@ -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@([^:]+):(.+?)(?:\.git)?$/)
const sshMatch = url.match(SCP_STYLE_URL_PATTERN)
if (sshMatch) {
const [, host, pathPart] = sshMatch
const path = pathPart ?? ''
const [, , host, pathPart] = sshMatch
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
}
}
Expand Down
3 changes: 2 additions & 1 deletion backend/src/services/schedule-worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
39 changes: 0 additions & 39 deletions backend/src/utils/git-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
188 changes: 188 additions & 0 deletions backend/test/utils/repo-url.test.ts
Original file line number Diff line number Diff line change
@@ -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('[email protected]:user/repo.git')).toBe(true)
})

it('matches custom-user host:path', () => {
expect(isScpStyleUrl('[email protected]: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://[email protected]/user/repo.git')).toBe(true)
})

it('detects git@host:path scp-style URLs', () => {
expect(isSSHUrl('[email protected]:user/repo.git')).toBe(true)
})

it('detects custom-user scp-style URLs', () => {
expect(isSSHUrl('[email protected]:orga/repo.git')).toBe(true)
expect(isSSHUrl('[email protected]: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:[email protected]/user/repo.git')).toBe(false)
expect(isSSHUrl('user/repo')).toBe(false)
})
})

describe('normalizeSSHUrl', () => {
it('returns ssh:// URLs unchanged', () => {
expect(normalizeSSHUrl('ssh://[email protected]:2222/user/repo.git')).toBe('ssh://[email protected]:2222/user/repo.git')
})

it('converts git@host:port/path to ssh:// form', () => {
expect(normalizeSSHUrl('[email protected]:2222/user/repo.git')).toBe('ssh://[email protected]:2222/user/repo.git')
})

it('converts custom-user host:port/path to ssh:// form', () => {
expect(normalizeSSHUrl('[email protected]:2222/orga/repo.git')).toBe('ssh://[email protected]:2222/orga/repo.git')
})

it('leaves scp-style URLs without a port unchanged', () => {
expect(normalizeSSHUrl('[email protected]:orga/repo.git')).toBe('[email protected]:orga/repo.git')
})

it('leaves out-of-range ports unchanged', () => {
expect(normalizeSSHUrl('[email protected]:99999/user/repo.git')).toBe('[email protected]:99999/user/repo.git')
})

it('reads digits as an owner when no repo path follows, matching git semantics', () => {
expect(normalizeSSHUrl('[email protected]:2222/repo.git')).toBe('[email protected]:2222/repo.git')
})

it('reads digits as a port when a full owner/repo path follows', () => {
expect(normalizeSSHUrl('[email protected]:2222/owner/repo.git')).toBe('ssh://[email protected]:2222/owner/repo.git')
})

it('treats a custom port before nested groups as a port', () => {
expect(normalizeSSHUrl('[email protected]:2222/group/subgroup/project.git')).toBe('ssh://[email protected]:2222/group/subgroup/project.git')
})
})

describe('extractHostFromSSHUrl', () => {
it('extracts host from git@host:path', () => {
expect(extractHostFromSSHUrl('[email protected]:user/repo.git')).toBe('github.com')
})

it('extracts host from custom-user scp-style URL', () => {
expect(extractHostFromSSHUrl('[email protected]:orga/repo.git')).toBe('company.ghe.com')
})

it('extracts host with port from ssh:// URL', () => {
expect(extractHostFromSSHUrl('ssh://[email protected]: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('[email protected]/owner:repo')).toBeNull()
})
})

describe('getRepoNameFromUrl', () => {
it('extracts repo name from custom-user scp URL', () => {
expect(getRepoNameFromUrl('[email protected]:orga/repo.git')).toBe('repo')
expect(getRepoNameFromUrl('[email protected]:repo.git')).toBe('repo')
})

it('extracts repo name from git@ scp URL', () => {
expect(getRepoNameFromUrl('[email protected]: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('[email protected]:orga/repo.git')).toBe('https://company.ghe.com/orga/repo')
})

it('normalizes git@ scp URL to https github path', () => {
expect(normalizeRepoUrlForCompare('[email protected]: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://[email protected]/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('[email protected]:3000/owner/repo.git')
const explicitSSH = normalizeRepoUrlForCompare('ssh://[email protected]: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('[email protected]:2222/repo.git')).toBe('https://github.com/2222/repo')
})

it('keeps distinct SSH ports distinct', () => {
expect(normalizeRepoUrlForCompare('ssh://[email protected]:3000/owner/repo.git'))
.not.toBe(normalizeRepoUrlForCompare('ssh://[email protected]: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:[email protected]/owner/repo.git')).toBe(clean)
expect(normalizeRepoUrlForCompare('https://x-access-token:[email protected]/owner/repo.git')).toBe(clean)
expect(normalizeRepoUrlForCompare('https://[email protected]/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:[email protected]/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')
})
})
6 changes: 1 addition & 5 deletions frontend/src/components/repo/AddRepoDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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'

Expand Down
Loading