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('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')
})
})
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