diff --git a/gitnexus/src/server/api.ts b/gitnexus/src/server/api.ts index d9aa00ae6..59096e5b8 100644 --- a/gitnexus/src/server/api.ts +++ b/gitnexus/src/server/api.ts @@ -33,7 +33,7 @@ import { mountMCPEndpoints } from './mcp-http.js'; import { fork } from 'child_process'; import { fileURLToPath, pathToFileURL } from 'url'; import { JobManager } from './analyze-job.js'; -import { extractRepoName, getCloneDir, cloneOrPull } from './git-clone.js'; +import { extractRepoName, extractRepoSlug, getCloneDir, cloneOrPull } from './git-clone.js'; const _require = createRequire(import.meta.url); const pkg = _require('../../package.json'); @@ -776,7 +776,9 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => await fs.rm(storagePath, { recursive: true, force: true }).catch(() => {}); // 2. Delete the cloned repo dir if it lives under ~/.gitnexus/repos/ - const cloneDir = getCloneDir(entry.name); + const cloneDir = getCloneDir( + entry.remoteUrl ? extractRepoSlug(entry.remoteUrl) : entry.name, + ); try { const stat = await fs.stat(cloneDir); if (stat.isDirectory()) { @@ -1291,7 +1293,8 @@ export const createServer = async (port: number, host: string = '127.0.0.1') => // Clone if URL provided if (repoUrl && !repoLocalPath) { const repoName = extractRepoName(repoUrl); - targetPath = getCloneDir(repoName); + const repoSlug = extractRepoSlug(repoUrl); + targetPath = getCloneDir(repoSlug); jobManager.updateJob(job.id, { status: 'cloning', diff --git a/gitnexus/src/server/git-clone.ts b/gitnexus/src/server/git-clone.ts index 0f7bc2653..b07171f1c 100644 --- a/gitnexus/src/server/git-clone.ts +++ b/gitnexus/src/server/git-clone.ts @@ -15,10 +15,51 @@ import { isIP } from 'net'; export function extractRepoName(url: string): string { const cleaned = url.replace(/\/+$/, ''); const lastSegment = cleaned.split(/[/:]/).pop() || 'unknown'; - return lastSegment.replace(/\.git$/, ''); + return lastSegment.replace(/\.git$/i, ''); } -/** Get the clone target directory for a repo name. */ +const sanitizeClonePathSegment = (segment: string): string => { + const sanitized = segment.replace(/[^a-zA-Z0-9._-]/g, '-'); + return sanitized && sanitized !== '.' && sanitized !== '..' ? sanitized : 'unknown'; +}; + +/** + * Extract an owner-qualified clone slug from an HTTP(S) git URL. + * + * The server's clone cache must not be keyed only by the repository basename: + * different owners can legitimately publish repositories with the same name. + * Include the host and path components so those repositories do not silently + * reuse each other's cached clone directory. + */ +export function extractRepoSlug(url: string): string { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return sanitizeClonePathSegment(extractRepoName(url)); + } + + const rawSegments = parsed.pathname.split('/').filter(Boolean); + if (rawSegments.length === 0) { + return sanitizeClonePathSegment(extractRepoName(url)); + } + + rawSegments[rawSegments.length - 1] = rawSegments[rawSegments.length - 1].replace( + /\.git$/i, + '', + ); + const segments = [parsed.hostname.toLowerCase(), ...rawSegments].map((segment) => { + try { + return sanitizeClonePathSegment(decodeURIComponent(segment)); + } catch { + return sanitizeClonePathSegment(segment); + } + }); + + return segments.join('/'); +} + +/** Get the clone target directory for a repo name or owner-qualified slug. */ export function getCloneDir(repoName: string): string { return path.join(os.homedir(), '.gitnexus', 'repos', repoName); } diff --git a/gitnexus/test/unit/git-clone.test.ts b/gitnexus/test/unit/git-clone.test.ts index 832f95641..fa4152f85 100644 --- a/gitnexus/test/unit/git-clone.test.ts +++ b/gitnexus/test/unit/git-clone.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { extractRepoName, getCloneDir, validateGitUrl } from '../../src/server/git-clone.js'; +import { + extractRepoName, + extractRepoSlug, + getCloneDir, + validateGitUrl, +} from '../../src/server/git-clone.js'; describe('git-clone', () => { describe('extractRepoName', () => { @@ -24,6 +29,23 @@ describe('git-clone', () => { }); }); + describe('extractRepoSlug', () => { + it('keeps the owner path so same-named repositories do not collide', () => { + expect(extractRepoSlug('https://github.com/owner-a/my-repo.git')).toBe( + 'github.com/owner-a/my-repo', + ); + expect(extractRepoSlug('https://github.com/owner-b/my-repo.git')).toBe( + 'github.com/owner-b/my-repo', + ); + }); + + it('keeps nested group paths for non-GitHub hosts', () => { + expect(extractRepoSlug('https://gitlab.com/group/subgroup/repo.git')).toBe( + 'gitlab.com/group/subgroup/repo', + ); + }); + }); + describe('getCloneDir', () => { it('returns path under ~/.gitnexus/repos/', () => { const dir = getCloneDir('my-repo'); @@ -31,6 +53,18 @@ describe('git-clone', () => { expect(dir).toMatch(/repos/); expect(dir).toContain('my-repo'); }); + + it('returns distinct paths for owner-qualified clone slugs', () => { + const first = getCloneDir( + extractRepoSlug('https://github.com/owner-a/shared.git'), + ); + const second = getCloneDir( + extractRepoSlug('https://github.com/owner-b/shared.git'), + ); + expect(first).not.toBe(second); + expect(first).toContain('owner-a'); + expect(second).toContain('owner-b'); + }); }); describe('validateGitUrl', () => {