From 0590257277cba022ae7dcfc32678b6f189159ab2 Mon Sep 17 00:00:00 2001
From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com>
Date: Sat, 30 May 2026 15:34:19 +0000
Subject: [PATCH 1/3] 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
---
backend/src/routes/repos.ts | 4 +-
backend/src/services/repo.ts | 32 +++----
frontend/src/api/repos.ts | 1 +
.../src/components/repo/AddRepoDialog.tsx | 88 ++++++++++++++++--
shared/src/schemas/repo.ts | 1 +
shared/src/utils/index.ts | 1 +
shared/src/utils/repo.ts | 89 +++++++++++++++++++
7 files changed, 187 insertions(+), 29 deletions(-)
create mode 100644 shared/src/utils/repo.ts
diff --git a/backend/src/routes/repos.ts b/backend/src/routes/repos.ts
index 43ea8278..30c29b0e 100644
--- a/backend/src/routes/repos.ts
+++ b/backend/src/routes/repos.ts
@@ -46,7 +46,7 @@ export function createRepoRoutes(
app.post('/', async (c) => {
try {
const body = await c.req.json()
- const { repoUrl, localPath, branch, openCodeConfigName, useWorktree, skipSSHVerification, provider, baseBranch } = body
+ const { repoUrl, localPath, branch, directoryName, openCodeConfigName, useWorktree, skipSSHVerification, provider, baseBranch } = body
if (!repoUrl && !localPath) {
return c.json({ error: 'Either repoUrl or localPath is required' }, 400)
@@ -67,7 +67,7 @@ export function createRepoRoutes(
database,
gitAuthService,
repoUrl!,
- { branch, useWorktree, skipSSHVerification, baseBranch }
+ { branch, directoryName, useWorktree, skipSSHVerification, baseBranch }
)
}
diff --git a/backend/src/services/repo.ts b/backend/src/services/repo.ts
index cdf1221c..10ca0fc8 100644
--- a/backend/src/services/repo.ts
+++ b/backend/src/services/repo.ts
@@ -7,6 +7,7 @@ import type { Database } from 'bun:sqlite'
import type { Repo, CreateRepoInput } from '../types/repo'
import { logger } from '../utils/logger'
import { getReposPath } from '@opencode-manager/shared/config/env'
+import { normalizeRepoDirectoryName, sanitizeRepoDirectoryName } from '@opencode-manager/shared/utils'
import type { GitAuthService } from './git-auth'
import { isGitHubHttpsUrl, isSSHUrl, normalizeSSHUrl } from '../utils/git-auth'
import path from 'path'
@@ -103,19 +104,9 @@ async function isGitWorktreeRepo(targetPath: string): Promise {
}
}
-function sanitizeWorkspaceAliasSegment(segment: string): string {
- const sanitized = segment
- .trim()
- .replace(/[^a-zA-Z0-9._-]+/g, '-')
- .replace(/^-+/, '')
- .replace(/-+$/, '')
-
- return sanitized || 'repo'
-}
-
function buildWorkspaceAliasCandidates(sourcePath: string, rootPath?: string): string[] {
const candidates: string[] = []
- const baseName = sanitizeWorkspaceAliasSegment(path.basename(sourcePath))
+ const baseName = sanitizeRepoDirectoryName(path.basename(sourcePath))
candidates.push(baseName)
if (rootPath) {
@@ -123,7 +114,7 @@ function buildWorkspaceAliasCandidates(sourcePath: string, rootPath?: string): s
if (relativePath && !relativePath.startsWith('..')) {
const relativeAlias = relativePath
.split(path.sep)
- .map(sanitizeWorkspaceAliasSegment)
+ .map(sanitizeRepoDirectoryName)
.filter(Boolean)
.join('--')
@@ -579,6 +570,7 @@ export async function initLocalRepo(
export interface CloneRepoOptions {
branch?: string
+ directoryName?: string
useWorktree?: boolean
skipSSHVerification?: boolean
baseBranch?: string
@@ -590,24 +582,22 @@ export async function cloneRepo(
repoUrl: string,
options: CloneRepoOptions = {}
): Promise {
- const { branch, useWorktree = false, skipSSHVerification = false, baseBranch } = options
+ const { branch, directoryName, useWorktree = false, skipSSHVerification = false, baseBranch } = options
const effectiveUrl = normalizeSSHUrl(repoUrl)
const isSSH = isSSHUrl(effectiveUrl)
const preserveSSH = isSSH
- const hasSSHCredential = await gitAuthService.setupSSHForRepoUrl(effectiveUrl, database, skipSSHVerification)
-
const { url: normalizedRepoUrl, name: repoName } = normalizeRepoUrl(effectiveUrl, preserveSSH)
- const baseRepoDirName = repoName
- const worktreeDirName = branch && useWorktree ? `${repoName}-${branch.replace(/[\\/]/g, '-')}` : repoName
+ const dirName = directoryName === undefined
+ ? sanitizeRepoDirectoryName(repoName)
+ : normalizeRepoDirectoryName(directoryName)
+ const baseRepoDirName = dirName
+ const worktreeDirName = branch && useWorktree ? `${dirName}-${branch.replace(/[\\/]/g, '-')}` : dirName
const localPath = worktreeDirName
const existing = getRepoByUrlAndBranch(database, normalizedRepoUrl, branch)
if (existing) {
logger.info(`Repo branch already exists: ${normalizedRepoUrl}${branch ? `#${branch}` : ''}`)
- if (hasSSHCredential) {
- await gitAuthService.cleanupSSHKey()
- }
return existing
}
@@ -632,6 +622,8 @@ export async function cloneRepo(
const repo = createRepo(database, createRepoInput)
try {
+ await gitAuthService.setupSSHForRepoUrl(effectiveUrl, database, skipSSHVerification)
+
const env = {
...gitAuthService.getGitEnvironment(),
...(isSSH ? gitAuthService.getSSHEnvironment() : {})
diff --git a/frontend/src/api/repos.ts b/frontend/src/api/repos.ts
index 11017f9c..e76cd1f5 100644
--- a/frontend/src/api/repos.ts
+++ b/frontend/src/api/repos.ts
@@ -7,6 +7,7 @@ export interface CreateRepoOptions {
repoUrl?: string
localPath?: string
branch?: string
+ directoryName?: string
openCodeConfigName?: string
useWorktree?: boolean
skipSSHVerification?: boolean
diff --git a/frontend/src/components/repo/AddRepoDialog.tsx b/frontend/src/components/repo/AddRepoDialog.tsx
index 4e77b29e..2b1d699d 100644
--- a/frontend/src/components/repo/AddRepoDialog.tsx
+++ b/frontend/src/components/repo/AddRepoDialog.tsx
@@ -1,12 +1,13 @@
-import { useState } from 'react'
-import { useMutation, useQueryClient } from '@tanstack/react-query'
-import { createRepo, discoverRepos } from '@/api/repos'
+import { useState, useRef, useCallback, useMemo } from 'react'
+import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query'
+import { listRepos, createRepo, discoverRepos } from '@/api/repos'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Loader2 } from 'lucide-react'
import { showToast } from '@/lib/toast'
+import { getRepoDirectoryNameError, getRepoNameFromUrl, normalizeRepoUrlForCompare, sanitizeRepoDirectoryName } from '@opencode-manager/shared/utils'
import type { DiscoverReposResponse } from '@opencode-manager/shared/types'
import type { Repo } from '@/api/types'
@@ -20,8 +21,10 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) {
const [repoUrl, setRepoUrl] = useState('')
const [localPath, setLocalPath] = useState('')
const [folderPath, setFolderPath] = useState('')
+ const [directoryName, setDirectoryName] = useState('')
const [branch, setBranch] = useState('')
const [skipSSHVerification, setSkipSSHVerification] = useState(false)
+ const directoryTouched = useRef(false)
const queryClient = useQueryClient()
const isSSHUrl = (url: string): boolean => {
@@ -29,6 +32,29 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) {
}
const showSkipSSHCheckbox = repoType === 'remote' && isSSHUrl(repoUrl)
+ const showDirectoryName = repoType === 'remote'
+
+ const { data: existingRepos } = useQuery({
+ queryKey: ['repos'],
+ queryFn: listRepos,
+ staleTime: 30_000,
+ })
+
+ const directoryNameError = useMemo(() => {
+ if (!showDirectoryName || !directoryName) return null
+ return getRepoDirectoryNameError(directoryName)
+ }, [showDirectoryName, directoryName])
+
+ const directoryCollision = useMemo(() => {
+ if (!showDirectoryName || !directoryName || directoryNameError || !existingRepos) return null
+ const normalizedNewUrl = normalizeRepoUrlForCompare(repoUrl)
+ const colliding = existingRepos.find((r) => {
+ if (r.localPath !== directoryName) return false
+ if (r.repoUrl && normalizeRepoUrlForCompare(r.repoUrl) === normalizedNewUrl) return false
+ return true
+ })
+ return colliding ?? null
+ }, [showDirectoryName, directoryName, directoryNameError, existingRepos, repoUrl])
type AddRepoResult =
| { mode: 'single'; repo: Repo }
@@ -46,7 +72,13 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) {
return { mode: 'discover', ...result }
}
- const repo = await createRepo({ repoUrl, branch: branch || undefined, useWorktree: false, skipSSHVerification })
+ const repo = await createRepo({
+ repoUrl,
+ directoryName: directoryName || undefined,
+ branch: branch || undefined,
+ useWorktree: false,
+ skipSSHVerification,
+ })
return { mode: 'single', repo }
},
onSuccess: (result) => {
@@ -55,9 +87,11 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) {
setRepoUrl('')
setLocalPath('')
setFolderPath('')
+ setDirectoryName('')
setBranch('')
setRepoType('remote')
setSkipSSHVerification(false)
+ directoryTouched.current = false
if (result.mode === 'discover') {
const summary = [
@@ -91,12 +125,21 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) {
}
}
- const handleRepoUrlChange = (value: string) => {
+ const handleRepoUrlChange = useCallback((value: string) => {
setRepoUrl(value)
if (!isSSHUrl(value)) {
setSkipSSHVerification(false)
}
- }
+ if (!directoryTouched.current) {
+ const extracted = sanitizeRepoDirectoryName(getRepoNameFromUrl(value))
+ setDirectoryName(extracted)
+ }
+ }, [])
+
+ const handleDirectoryNameChange = useCallback((value: string) => {
+ directoryTouched.current = true
+ setDirectoryName(value)
+ }, [])
return (
@@ -161,6 +204,37 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) {
)}
+
+ {showDirectoryName && (
+
+
Directory Name
+
handleDirectoryNameChange(e.target.value)}
+ disabled={mutation.isPending}
+ className="bg-[#1a1a1a] border-[#2a2a2a] text-white placeholder:text-zinc-500 min-h-[44px] text-base"
+ />
+ {directoryNameError ? (
+
+ {directoryNameError}.
+
+ ) : directoryCollision ? (
+
+ A repository named '{directoryName}' already exists.
+ {directoryCollision.repoUrl && directoryCollision.repoUrl !== repoUrl
+ ? ` (${directoryCollision.repoUrl})`
+ : ''
+ }
+ {' '}Choose a different directory name to clone this fork.
+
+ ) : (
+
+ Custom directory name for the cloned repository
+
+ )}
+
+ )}
Branch (optional)
@@ -204,7 +278,7 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) {
{mutation.isPending ? (
diff --git a/shared/src/schemas/repo.ts b/shared/src/schemas/repo.ts
index 6144ead4..c2e5d6f4 100644
--- a/shared/src/schemas/repo.ts
+++ b/shared/src/schemas/repo.ts
@@ -27,6 +27,7 @@ export const CreateRepoRequestSchema = z.object({
repoUrl: z.string().url().optional(),
localPath: z.string().optional(),
branch: z.string().optional(),
+ directoryName: z.string().optional(),
openCodeConfigName: z.string().optional(),
useWorktree: z.boolean().optional(),
skipSSHVerification: z.boolean().optional(),
diff --git a/shared/src/utils/index.ts b/shared/src/utils/index.ts
index 89f7368b..adcbed0e 100644
--- a/shared/src/utils/index.ts
+++ b/shared/src/utils/index.ts
@@ -1 +1,2 @@
export * from './jsonc'
+export * from './repo'
diff --git a/shared/src/utils/repo.ts b/shared/src/utils/repo.ts
new file mode 100644
index 00000000..1d0993e9
--- /dev/null
+++ b/shared/src/utils/repo.ts
@@ -0,0 +1,89 @@
+export function sanitizeRepoDirectoryName(input: string): string {
+ const sanitized = input
+ .trim()
+ .replace(/[^a-zA-Z0-9._-]+/g, '-')
+ .replace(/^-+/, '')
+ .replace(/-+$/, '')
+
+ return sanitized || 'repo'
+}
+
+export function getRepoDirectoryNameError(input: string): string | null {
+ const trimmed = input.trim()
+
+ if (!trimmed) {
+ return 'Directory name is required'
+ }
+
+ if (trimmed === '.' || trimmed.includes('..')) {
+ return 'Directory name cannot contain dot-dot path segments'
+ }
+
+ if (/^(?:[a-zA-Z]:)?[\\/]/.test(trimmed)) {
+ return 'Directory name must be relative'
+ }
+
+ if (/[\\/]/.test(trimmed)) {
+ return 'Directory name cannot contain path separators'
+ }
+
+ if (sanitizeRepoDirectoryName(trimmed) !== trimmed) {
+ return 'Directory name can only contain letters, numbers, dots, underscores, and hyphens'
+ }
+
+ return null
+}
+
+export function normalizeRepoDirectoryName(input: string): string {
+ const error = getRepoDirectoryNameError(input)
+
+ if (error) {
+ throw new Error(error)
+ }
+
+ return input.trim()
+}
+
+export function getRepoNameFromUrl(url: string): string {
+ const cleaned = url.trim().replace(/\.git$/, '').replace(/\/+$/, '')
+ const scpMatch = cleaned.match(/^git@[^:]+:(.+)$/)
+
+ if (scpMatch) {
+ const parts = scpMatch[1]?.split('/') ?? []
+ return parts[parts.length - 1] || ''
+ }
+
+ const parts = cleaned.split('/')
+ return parts[parts.length - 1] || ''
+}
+
+export function normalizeRepoUrlForCompare(url: string): string {
+ let normalized = url.trim().replace(/\.git$/, '').replace(/\/+$/, '')
+ const shorthandMatch = normalized.match(/^([^/]+)\/([^/]+)$/)
+
+ if (shorthandMatch && !normalized.includes('://') && !normalized.startsWith('git@')) {
+ return `https://github.com/${normalized}`.toLowerCase()
+ }
+
+ const scpMatch = normalized.match(/^git@([^:]+):(.+)$/)
+ if (scpMatch) {
+ return `https://${scpMatch[1]}/${scpMatch[2]}`.toLowerCase()
+ }
+
+ if (normalized.startsWith('ssh://')) {
+ try {
+ const parsed = new URL(normalized)
+ const path = parsed.pathname.replace(/^\/+/, '')
+ const host = parsed.port ? `${parsed.hostname}:${parsed.port}` : parsed.hostname
+ return `https://${host}/${path}`.toLowerCase()
+ } catch {
+ return normalized.toLowerCase()
+ }
+ }
+
+ if (normalized.startsWith('http://')) {
+ normalized = `https://${normalized.slice(7)}`
+ }
+
+ return normalized.toLowerCase()
+}
From f9c81e125a6ded93b46946c4f90ae720cb1d2a09 Mon Sep 17 00:00:00 2001
From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com>
Date: Sat, 30 May 2026 16:50:22 -0400
Subject: [PATCH 2/3] add backend origin validation and base-directory
collision detection
---
backend/src/services/repo.ts | 15 +++++++++++++--
frontend/src/components/repo/AddRepoDialog.tsx | 4 ++--
shared/src/utils/repo.ts | 15 +++++++++++++++
3 files changed, 30 insertions(+), 4 deletions(-)
diff --git a/backend/src/services/repo.ts b/backend/src/services/repo.ts
index 10ca0fc8..5e12a5f4 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 } from '@opencode-manager/shared/config/env'
-import { normalizeRepoDirectoryName, sanitizeRepoDirectoryName } from '@opencode-manager/shared/utils'
+import { normalizeRepoDirectoryName, sanitizeRepoDirectoryName, sanitizeBranchForDirectory, normalizeRepoUrlForCompare } from '@opencode-manager/shared/utils'
import type { GitAuthService } from './git-auth'
import { isGitHubHttpsUrl, isSSHUrl, normalizeSSHUrl } from '../utils/git-auth'
import path from 'path'
@@ -591,7 +591,7 @@ export async function cloneRepo(
? sanitizeRepoDirectoryName(repoName)
: normalizeRepoDirectoryName(directoryName)
const baseRepoDirName = dirName
- const worktreeDirName = branch && useWorktree ? `${dirName}-${branch.replace(/[\\/]/g, '-')}` : dirName
+ const worktreeDirName = branch && useWorktree ? `${dirName}-${sanitizeBranchForDirectory(branch)}` : dirName
const localPath = worktreeDirName
const existing = getRepoByUrlAndBranch(database, normalizedRepoUrl, branch)
@@ -705,6 +705,17 @@ export async function cloneRepo(
const isValidRepo = await executeCommand(['git', '-C', path.resolve(getReposPath(), baseRepoDirName), 'rev-parse', '--git-dir'], path.resolve(getReposPath())).then(() => 'valid').catch(() => 'invalid')
if (isValidRepo.trim() === 'valid') {
+ const existingOriginUrl = await executeCommand(
+ ['git', '-C', path.resolve(getReposPath(), baseRepoDirName), 'remote', 'get-url', 'origin'],
+ { cwd: path.resolve(getReposPath()), silent: true }
+ ).then((output) => output.trim()).catch(() => '')
+
+ if (existingOriginUrl && normalizeRepoUrlForCompare(existingOriginUrl) !== normalizeRepoUrlForCompare(normalizedRepoUrl)) {
+ const collisionError = new Error(`Directory '${baseRepoDirName}' already contains a different repository (${existingOriginUrl}). Choose a different directory name.`) as Error & { statusCode: number }
+ collisionError.statusCode = 409
+ throw collisionError
+ }
+
logger.info(`Valid repository found: ${normalizedRepoUrl}`)
if (branch) {
diff --git a/frontend/src/components/repo/AddRepoDialog.tsx b/frontend/src/components/repo/AddRepoDialog.tsx
index 2b1d699d..b18a6778 100644
--- a/frontend/src/components/repo/AddRepoDialog.tsx
+++ b/frontend/src/components/repo/AddRepoDialog.tsx
@@ -7,7 +7,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Loader2 } from 'lucide-react'
import { showToast } from '@/lib/toast'
-import { getRepoDirectoryNameError, getRepoNameFromUrl, normalizeRepoUrlForCompare, sanitizeRepoDirectoryName } from '@opencode-manager/shared/utils'
+import { getRepoBaseDirectoryName, getRepoDirectoryNameError, getRepoNameFromUrl, normalizeRepoUrlForCompare, sanitizeRepoDirectoryName } from '@opencode-manager/shared/utils'
import type { DiscoverReposResponse } from '@opencode-manager/shared/types'
import type { Repo } from '@/api/types'
@@ -49,7 +49,7 @@ export function AddRepoDialog({ open, onOpenChange }: AddRepoDialogProps) {
if (!showDirectoryName || !directoryName || directoryNameError || !existingRepos) return null
const normalizedNewUrl = normalizeRepoUrlForCompare(repoUrl)
const colliding = existingRepos.find((r) => {
- if (r.localPath !== directoryName) return false
+ if (r.localPath !== directoryName && getRepoBaseDirectoryName(r) !== directoryName) return false
if (r.repoUrl && normalizeRepoUrlForCompare(r.repoUrl) === normalizedNewUrl) return false
return true
})
diff --git a/shared/src/utils/repo.ts b/shared/src/utils/repo.ts
index 1d0993e9..af2ea252 100644
--- a/shared/src/utils/repo.ts
+++ b/shared/src/utils/repo.ts
@@ -44,6 +44,21 @@ export function normalizeRepoDirectoryName(input: string): string {
return input.trim()
}
+export function sanitizeBranchForDirectory(branch: string): string {
+ return branch.replace(/[\\/]/g, '-')
+}
+
+export function getRepoBaseDirectoryName(repo: { localPath: string; branch?: string; isWorktree?: boolean }): string {
+ if (repo.isWorktree && repo.branch) {
+ const suffix = `-${sanitizeBranchForDirectory(repo.branch)}`
+ if (repo.localPath.endsWith(suffix)) {
+ return repo.localPath.slice(0, -suffix.length)
+ }
+ }
+
+ return repo.localPath
+}
+
export function getRepoNameFromUrl(url: string): string {
const cleaned = url.trim().replace(/\.git$/, '').replace(/\/+$/, '')
const scpMatch = cleaned.match(/^git@[^:]+:(.+)$/)
From c9f9a00127c6269cc4f4b4720429588c62f87f81 Mon Sep 17 00:00:00 2001
From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com>
Date: Sat, 30 May 2026 16:55:30 -0400
Subject: [PATCH 3/3] fix potential ReDoS in repo url and directory name
normalization
---
shared/src/utils/repo.ts | 17 ++++++++++-------
1 file changed, 10 insertions(+), 7 deletions(-)
diff --git a/shared/src/utils/repo.ts b/shared/src/utils/repo.ts
index af2ea252..06a3b60d 100644
--- a/shared/src/utils/repo.ts
+++ b/shared/src/utils/repo.ts
@@ -1,9 +1,12 @@
+function trimTrailingChar(value: string, char: string): string {
+ let end = value.length
+ while (end > 0 && value[end - 1] === char) end--
+ return value.slice(0, end)
+}
+
export function sanitizeRepoDirectoryName(input: string): string {
- const sanitized = input
- .trim()
- .replace(/[^a-zA-Z0-9._-]+/g, '-')
- .replace(/^-+/, '')
- .replace(/-+$/, '')
+ const collapsed = input.trim().replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+/, '')
+ const sanitized = trimTrailingChar(collapsed, '-')
return sanitized || 'repo'
}
@@ -60,7 +63,7 @@ export function getRepoBaseDirectoryName(repo: { localPath: string; branch?: str
}
export function getRepoNameFromUrl(url: string): string {
- const cleaned = url.trim().replace(/\.git$/, '').replace(/\/+$/, '')
+ const cleaned = trimTrailingChar(url.trim().replace(/\.git$/, ''), '/')
const scpMatch = cleaned.match(/^git@[^:]+:(.+)$/)
if (scpMatch) {
@@ -73,7 +76,7 @@ export function getRepoNameFromUrl(url: string): string {
}
export function normalizeRepoUrlForCompare(url: string): string {
- let normalized = url.trim().replace(/\.git$/, '').replace(/\/+$/, '')
+ let normalized = trimTrailingChar(url.trim().replace(/\.git$/, ''), '/')
const shorthandMatch = normalized.match(/^([^/]+)\/([^/]+)$/)
if (shorthandMatch && !normalized.includes('://') && !normalized.startsWith('git@')) {