From 95f544aedfd24974145b9d6a2d156a32f1340516 Mon Sep 17 00:00:00 2001 From: zzr <103238445+wswsadadbaba123@users.noreply.github.com> Date: Tue, 20 Oct 2026 01:58:05 +0800 Subject: [PATCH] fix(git): derive clone folder from scp-style remote path (#30763) --- src/main/git/repo-clone-path.test.ts | 42 +++++++++++++++++++++++++++- src/main/git/repo-clone-path.ts | 6 +++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/main/git/repo-clone-path.test.ts b/src/main/git/repo-clone-path.test.ts index 6f1680c89bc..2e644507d2a 100644 --- a/src/main/git/repo-clone-path.test.ts +++ b/src/main/git/repo-clone-path.test.ts @@ -2,9 +2,49 @@ import { describe, expect, it } from 'vitest' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { deriveValidatedClonePath, getClonePathComparisonKey } from './repo-clone-path' +import { + deriveCloneRepoNameFromUrl, + deriveValidatedClonePath, + getClonePathComparisonKey +} from './repo-clone-path' describe('repo clone path helpers', () => { + it.each([ + 'git@example.com:project.git', + 'example.com:project.git', + 'git@[2001:db8::1]:project.git', + '[2001:db8::1]:project.git', + 'git@example.com:team/project.git', + 'git@example.com:/srv/project.git' + ])('derives the repository directory from the path in %s', (url) => { + expect(deriveCloneRepoNameFromUrl(url)).toBe('project') + expect(deriveValidatedClonePath({ url, destination: process.cwd() })).toBe( + join(process.cwd(), 'project') + ) + }) + + it.each([ + 'https://example.com/team/project.git', + 'ssh://git@example.com/team/project.git', + '/srv/project.git', + 'C:\\repos\\project.git', + 'C:/repos/project.git', + '\\\\server\\share\\project.git' + ])('preserves repository names for URL and local path inputs: %s', (url) => { + expect(deriveCloneRepoNameFromUrl(url)).toBe('project') + }) + + it('preserves a colon in an explicitly local relative path', () => { + expect(deriveCloneRepoNameFromUrl('./local:project.git')).toBe('local:project') + }) + + it.each(['git@example.com:.', 'git@example.com:..'])( + 'rejects an unsafe repository basename in %s', + (url) => { + expect(() => deriveCloneRepoNameFromUrl(url)).toThrow('Invalid repository name') + } + ) + it('allows safe repository names that start with two dots', async () => { const destination = await mkdtemp(join(tmpdir(), 'orca-clone-path-')) try { diff --git a/src/main/git/repo-clone-path.ts b/src/main/git/repo-clone-path.ts index a8204e333a8..c1a3ecde65a 100644 --- a/src/main/git/repo-clone-path.ts +++ b/src/main/git/repo-clone-path.ts @@ -19,7 +19,11 @@ export function deriveCloneRepoNameFromUrl(url: string): string { // be "." or ".."; rejecting them prevents parent/destination deletion. const source = url.replace(/\.git\/?$/, '') const isWindowsLocalSource = /^[A-Za-z]:[\\/]/.test(source) || source.startsWith('\\\\') - const repoName = isWindowsLocalSource ? win32.basename(source) : posix.basename(source) + const scpPath = + !isWindowsLocalSource && !source.includes('://') + ? source.match(/^(?:[^@/:]+@)?(?:\[[^\]]+\]|[^:/\s]+):(.+)$/)?.[1] + : undefined + const repoName = isWindowsLocalSource ? win32.basename(source) : posix.basename(scpPath ?? source) if (!repoName || repoName === '.' || repoName === '..') { throw new Error('Invalid repository name derived from URL') }