Skip to content

Commit 60ed756

Browse files
fix: repo directory name collisions and custom directory support (#241)
* fix: repo directory name collisions and custom directory support Allow users to specify a custom directory name when adding a remote repo, preventing collisions when cloning a fork of an already-cloned repo. - Add shared validation and normalization utilities (shared/src/utils/repo.ts) for directory name sanitization, URL normalization, and path safety - Replace duplicated sanitizeWorkspaceAliasSegment with shared helper - Backend validates directoryName before use as filesystem path - Frontend detects directory collisions with normalized URL comparison - Move SSH credential setup after DB record creation * add backend origin validation and base-directory collision detection * fix potential ReDoS in repo url and directory name normalization
1 parent 932d6b8 commit 60ed756

7 files changed

Lines changed: 216 additions & 29 deletions

File tree

backend/src/routes/repos.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export function createRepoRoutes(
4646
app.post('/', async (c) => {
4747
try {
4848
const body = await c.req.json()
49-
const { repoUrl, localPath, branch, openCodeConfigName, useWorktree, skipSSHVerification, provider, baseBranch } = body
49+
const { repoUrl, localPath, branch, directoryName, openCodeConfigName, useWorktree, skipSSHVerification, provider, baseBranch } = body
5050

5151
if (!repoUrl && !localPath) {
5252
return c.json({ error: 'Either repoUrl or localPath is required' }, 400)
@@ -67,7 +67,7 @@ export function createRepoRoutes(
6767
database,
6868
gitAuthService,
6969
repoUrl!,
70-
{ branch, useWorktree, skipSSHVerification, baseBranch }
70+
{ branch, directoryName, useWorktree, skipSSHVerification, baseBranch }
7171
)
7272
}
7373

backend/src/services/repo.ts

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { Database } from 'bun:sqlite'
77
import type { Repo, CreateRepoInput } from '../types/repo'
88
import { logger } from '../utils/logger'
99
import { getReposPath } from '@opencode-manager/shared/config/env'
10+
import { normalizeRepoDirectoryName, sanitizeRepoDirectoryName, sanitizeBranchForDirectory, normalizeRepoUrlForCompare } from '@opencode-manager/shared/utils'
1011
import type { GitAuthService } from './git-auth'
1112
import { isGitHubHttpsUrl, isSSHUrl, normalizeSSHUrl } from '../utils/git-auth'
1213
import path from 'path'
@@ -103,27 +104,17 @@ async function isGitWorktreeRepo(targetPath: string): Promise<boolean> {
103104
}
104105
}
105106

106-
function sanitizeWorkspaceAliasSegment(segment: string): string {
107-
const sanitized = segment
108-
.trim()
109-
.replace(/[^a-zA-Z0-9._-]+/g, '-')
110-
.replace(/^-+/, '')
111-
.replace(/-+$/, '')
112-
113-
return sanitized || 'repo'
114-
}
115-
116107
function buildWorkspaceAliasCandidates(sourcePath: string, rootPath?: string): string[] {
117108
const candidates: string[] = []
118-
const baseName = sanitizeWorkspaceAliasSegment(path.basename(sourcePath))
109+
const baseName = sanitizeRepoDirectoryName(path.basename(sourcePath))
119110
candidates.push(baseName)
120111

121112
if (rootPath) {
122113
const relativePath = path.relative(rootPath, sourcePath)
123114
if (relativePath && !relativePath.startsWith('..')) {
124115
const relativeAlias = relativePath
125116
.split(path.sep)
126-
.map(sanitizeWorkspaceAliasSegment)
117+
.map(sanitizeRepoDirectoryName)
127118
.filter(Boolean)
128119
.join('--')
129120

@@ -579,6 +570,7 @@ export async function initLocalRepo(
579570

580571
export interface CloneRepoOptions {
581572
branch?: string
573+
directoryName?: string
582574
useWorktree?: boolean
583575
skipSSHVerification?: boolean
584576
baseBranch?: string
@@ -590,24 +582,22 @@ export async function cloneRepo(
590582
repoUrl: string,
591583
options: CloneRepoOptions = {}
592584
): Promise<Repo> {
593-
const { branch, useWorktree = false, skipSSHVerification = false, baseBranch } = options
585+
const { branch, directoryName, useWorktree = false, skipSSHVerification = false, baseBranch } = options
594586
const effectiveUrl = normalizeSSHUrl(repoUrl)
595587
const isSSH = isSSHUrl(effectiveUrl)
596588
const preserveSSH = isSSH
597-
const hasSSHCredential = await gitAuthService.setupSSHForRepoUrl(effectiveUrl, database, skipSSHVerification)
598-
599589
const { url: normalizedRepoUrl, name: repoName } = normalizeRepoUrl(effectiveUrl, preserveSSH)
600-
const baseRepoDirName = repoName
601-
const worktreeDirName = branch && useWorktree ? `${repoName}-${branch.replace(/[\\/]/g, '-')}` : repoName
590+
const dirName = directoryName === undefined
591+
? sanitizeRepoDirectoryName(repoName)
592+
: normalizeRepoDirectoryName(directoryName)
593+
const baseRepoDirName = dirName
594+
const worktreeDirName = branch && useWorktree ? `${dirName}-${sanitizeBranchForDirectory(branch)}` : dirName
602595
const localPath = worktreeDirName
603596

604597
const existing = getRepoByUrlAndBranch(database, normalizedRepoUrl, branch)
605598

606599
if (existing) {
607600
logger.info(`Repo branch already exists: ${normalizedRepoUrl}${branch ? `#${branch}` : ''}`)
608-
if (hasSSHCredential) {
609-
await gitAuthService.cleanupSSHKey()
610-
}
611601
return existing
612602
}
613603

@@ -632,6 +622,8 @@ export async function cloneRepo(
632622
const repo = createRepo(database, createRepoInput)
633623

634624
try {
625+
await gitAuthService.setupSSHForRepoUrl(effectiveUrl, database, skipSSHVerification)
626+
635627
const env = {
636628
...gitAuthService.getGitEnvironment(),
637629
...(isSSH ? gitAuthService.getSSHEnvironment() : {})
@@ -713,6 +705,17 @@ export async function cloneRepo(
713705
const isValidRepo = await executeCommand(['git', '-C', path.resolve(getReposPath(), baseRepoDirName), 'rev-parse', '--git-dir'], path.resolve(getReposPath())).then(() => 'valid').catch(() => 'invalid')
714706

715707
if (isValidRepo.trim() === 'valid') {
708+
const existingOriginUrl = await executeCommand(
709+
['git', '-C', path.resolve(getReposPath(), baseRepoDirName), 'remote', 'get-url', 'origin'],
710+
{ cwd: path.resolve(getReposPath()), silent: true }
711+
).then((output) => output.trim()).catch(() => '')
712+
713+
if (existingOriginUrl && normalizeRepoUrlForCompare(existingOriginUrl) !== normalizeRepoUrlForCompare(normalizedRepoUrl)) {
714+
const collisionError = new Error(`Directory '${baseRepoDirName}' already contains a different repository (${existingOriginUrl}). Choose a different directory name.`) as Error & { statusCode: number }
715+
collisionError.statusCode = 409
716+
throw collisionError
717+
}
718+
716719
logger.info(`Valid repository found: ${normalizedRepoUrl}`)
717720

718721
if (branch) {

frontend/src/api/repos.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export interface CreateRepoOptions {
77
repoUrl?: string
88
localPath?: string
99
branch?: string
10+
directoryName?: string
1011
openCodeConfigName?: string
1112
useWorktree?: boolean
1213
skipSSHVerification?: boolean

frontend/src/components/repo/AddRepoDialog.tsx

Lines changed: 81 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
1-
import { useState } from 'react'
2-
import { useMutation, useQueryClient } from '@tanstack/react-query'
3-
import { createRepo, discoverRepos } from '@/api/repos'
1+
import { useState, useRef, useCallback, useMemo } from 'react'
2+
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query'
3+
import { listRepos, createRepo, discoverRepos } from '@/api/repos'
44
import { Button } from '@/components/ui/button'
55
import { Input } from '@/components/ui/input'
66
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
77
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
88
import { Loader2 } from 'lucide-react'
99
import { showToast } from '@/lib/toast'
10+
import { getRepoBaseDirectoryName, getRepoDirectoryNameError, getRepoNameFromUrl, normalizeRepoUrlForCompare, sanitizeRepoDirectoryName } from '@opencode-manager/shared/utils'
1011
import type { DiscoverReposResponse } from '@opencode-manager/shared/types'
1112
import type { Repo } from '@/api/types'
1213

@@ -20,15 +21,40 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) {
2021
const [repoUrl, setRepoUrl] = useState('')
2122
const [localPath, setLocalPath] = useState('')
2223
const [folderPath, setFolderPath] = useState('')
24+
const [directoryName, setDirectoryName] = useState('')
2325
const [branch, setBranch] = useState('')
2426
const [skipSSHVerification, setSkipSSHVerification] = useState(false)
27+
const directoryTouched = useRef(false)
2528
const queryClient = useQueryClient()
2629

2730
const isSSHUrl = (url: string): boolean => {
2831
return url.startsWith('git@') || url.startsWith('ssh://')
2932
}
3033

3134
const showSkipSSHCheckbox = repoType === 'remote' && isSSHUrl(repoUrl)
35+
const showDirectoryName = repoType === 'remote'
36+
37+
const { data: existingRepos } = useQuery({
38+
queryKey: ['repos'],
39+
queryFn: listRepos,
40+
staleTime: 30_000,
41+
})
42+
43+
const directoryNameError = useMemo(() => {
44+
if (!showDirectoryName || !directoryName) return null
45+
return getRepoDirectoryNameError(directoryName)
46+
}, [showDirectoryName, directoryName])
47+
48+
const directoryCollision = useMemo(() => {
49+
if (!showDirectoryName || !directoryName || directoryNameError || !existingRepos) return null
50+
const normalizedNewUrl = normalizeRepoUrlForCompare(repoUrl)
51+
const colliding = existingRepos.find((r) => {
52+
if (r.localPath !== directoryName && getRepoBaseDirectoryName(r) !== directoryName) return false
53+
if (r.repoUrl && normalizeRepoUrlForCompare(r.repoUrl) === normalizedNewUrl) return false
54+
return true
55+
})
56+
return colliding ?? null
57+
}, [showDirectoryName, directoryName, directoryNameError, existingRepos, repoUrl])
3258

3359
type AddRepoResult =
3460
| { mode: 'single'; repo: Repo }
@@ -46,7 +72,13 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) {
4672
return { mode: 'discover', ...result }
4773
}
4874

49-
const repo = await createRepo({ repoUrl, branch: branch || undefined, useWorktree: false, skipSSHVerification })
75+
const repo = await createRepo({
76+
repoUrl,
77+
directoryName: directoryName || undefined,
78+
branch: branch || undefined,
79+
useWorktree: false,
80+
skipSSHVerification,
81+
})
5082
return { mode: 'single', repo }
5183
},
5284
onSuccess: (result) => {
@@ -55,9 +87,11 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) {
5587
setRepoUrl('')
5688
setLocalPath('')
5789
setFolderPath('')
90+
setDirectoryName('')
5891
setBranch('')
5992
setRepoType('remote')
6093
setSkipSSHVerification(false)
94+
directoryTouched.current = false
6195

6296
if (result.mode === 'discover') {
6397
const summary = [
@@ -91,12 +125,21 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) {
91125
}
92126
}
93127

94-
const handleRepoUrlChange = (value: string) => {
128+
const handleRepoUrlChange = useCallback((value: string) => {
95129
setRepoUrl(value)
96130
if (!isSSHUrl(value)) {
97131
setSkipSSHVerification(false)
98132
}
99-
}
133+
if (!directoryTouched.current) {
134+
const extracted = sanitizeRepoDirectoryName(getRepoNameFromUrl(value))
135+
setDirectoryName(extracted)
136+
}
137+
}, [])
138+
139+
const handleDirectoryNameChange = useCallback((value: string) => {
140+
directoryTouched.current = true
141+
setDirectoryName(value)
142+
}, [])
100143

101144
return (
102145
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -161,6 +204,37 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) {
161204
</p>
162205
</div>
163206
)}
207+
208+
{showDirectoryName && (
209+
<div className="space-y-2">
210+
<label className="text-sm text-zinc-400">Directory Name</label>
211+
<Input
212+
placeholder="Auto-detected from URL"
213+
value={directoryName}
214+
onChange={(e) => handleDirectoryNameChange(e.target.value)}
215+
disabled={mutation.isPending}
216+
className="bg-[#1a1a1a] border-[#2a2a2a] text-white placeholder:text-zinc-500 min-h-[44px] text-base"
217+
/>
218+
{directoryNameError ? (
219+
<p className="text-xs text-amber-400">
220+
{directoryNameError}.
221+
</p>
222+
) : directoryCollision ? (
223+
<p className="text-xs text-amber-400">
224+
A repository named '{directoryName}' already exists.
225+
{directoryCollision.repoUrl && directoryCollision.repoUrl !== repoUrl
226+
? ` (${directoryCollision.repoUrl})`
227+
: ''
228+
}
229+
{' '}Choose a different directory name to clone this fork.
230+
</p>
231+
) : (
232+
<p className="text-xs text-zinc-500">
233+
Custom directory name for the cloned repository
234+
</p>
235+
)}
236+
</div>
237+
)}
164238

165239
<div className="space-y-2">
166240
<label className="text-sm text-zinc-400">Branch (optional)</label>
@@ -204,7 +278,7 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) {
204278

205279
<Button
206280
type="submit"
207-
disabled={(!repoUrl && repoType === 'remote') || (!localPath && repoType === 'local') || (!folderPath && repoType === 'folder') || mutation.isPending}
281+
disabled={(!repoUrl && repoType === 'remote') || (!localPath && repoType === 'local') || (!folderPath && repoType === 'folder') || mutation.isPending || (showDirectoryName && (!!directoryNameError || !!directoryCollision))}
208282
className="w-full min-h-[48px] bg-blue-600 hover:bg-blue-700 text-white text-base font-medium"
209283
>
210284
{mutation.isPending ? (

shared/src/schemas/repo.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export const CreateRepoRequestSchema = z.object({
2727
repoUrl: z.string().url().optional(),
2828
localPath: z.string().optional(),
2929
branch: z.string().optional(),
30+
directoryName: z.string().optional(),
3031
openCodeConfigName: z.string().optional(),
3132
useWorktree: z.boolean().optional(),
3233
skipSSHVerification: z.boolean().optional(),

shared/src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './jsonc'
2+
export * from './repo'

0 commit comments

Comments
 (0)