From fa538479e1fb262cbd4d8bfb73ec986c29a57801 Mon Sep 17 00:00:00 2001 From: Rin Date: Sun, 3 May 2026 19:38:57 +0700 Subject: [PATCH] refactor: extract shared sanitizeRepoName and update parseRepoNameFromUrl in storage/git.ts --- gitnexus/src/storage/git.ts | 53 ++++++++++++++++++++++++++++++------- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/gitnexus/src/storage/git.ts b/gitnexus/src/storage/git.ts index bb4763167..95369c265 100644 --- a/gitnexus/src/storage/git.ts +++ b/gitnexus/src/storage/git.ts @@ -93,6 +93,34 @@ export const getGitRoot = (fromPath: string): string | null => { return null; } }; + +/** + * Find a git root by checking only `.git` entries on the ancestor chain. + * + * Unlike `getGitRoot`, this does not spawn `git`, so MCP can cheaply decide + * whether a launch cwd is a worktree before running any subprocess there. + */ +export const findGitRootByDotGit = (fromPath: string): string | null => { + let current = path.resolve(fromPath); + try { + if (!statSync(current).isDirectory()) { + current = path.dirname(current); + } + } catch { + return null; + } + + while (true) { + try { + statSync(path.join(current, '.git')); + return current; + } catch { + const parent = path.dirname(current); + if (parent === current) return null; + current = parent; + } + } +}; /** * Check whether a directory contains a .git entry (file or folder). * @@ -136,35 +164,42 @@ export const getRemoteOriginUrl = (repoPath: string): string | null => { } }; +/** + * Sanitize a repository name to prevent argument injection and ensure + * cross-platform filesystem compatibility. + */ +export const sanitizeRepoName = (name: string): string => { + // 1. Prevent argument injection by stripping leading dashes. + // 2. Remove characters that are unsafe for directory names across platforms. + return name.replace(/^-+/, '').replace(/[<>:\"/\\\\|?*]/g, '_') || 'unknown'; +}; + /** * Parse a repository name out of a git remote URL. Handles the common * SSH (`git@host:owner/repo.git`), HTTPS (`https://host/owner/repo.git`), * `git://`, `ssh://`, and `file://` shapes. Returns `null` for empty / * unparseable input. - * - * The heuristic: strip a trailing `.git` and trailing slashes, then - * take the segment after the last `/` or `:`. */ export const parseRepoNameFromUrl = (url: string | null | undefined): string | null => { if (!url) return null; const trimmed = url.trim(); if (!trimmed) return null; // Strip `.git` suffix (case-insensitive) and any trailing slashes. - const withoutSuffix = trimmed.replace(/\.git\/*$/i, '').replace(/\/+$/, ''); + const withoutSuffix = trimmed.replace(/\\.git\\/*$/i, '').replace(/\\/+$/, ''); // Last path segment, handling colons for SSH URLs. // For HTTPS URLs, the only colon should be in the protocol. let candidate: string; if (withoutSuffix.includes('@') && withoutSuffix.includes(':') && !withoutSuffix.startsWith('http')) { // Likely an SSH URL - const m = withoutSuffix.match(/[/:]([^/:]+)$/); - candidate = m ? m[1] : withoutSuffix; + const segments = withoutSuffix.split(/[:/]/); + candidate = segments.pop() || withoutSuffix; } else { // Likely an HTTPS URL or local path candidate = withoutSuffix.split('/').pop() || withoutSuffix; } - return candidate || null; + return candidate ? sanitizeRepoName(candidate) : null; }; /** @@ -192,12 +227,12 @@ export interface FileDiff { export function parseDiffHunks(diffOutput: string): FileDiff[] { const files: FileDiff[] = []; let current: FileDiff | null = null; - for (const line of diffOutput.split('\n')) { + for (const line of diffOutput.split('\\n')) { if (line.startsWith('+++ b/')) { current = { filePath: line.slice(6), hunks: [] }; files.push(current); } else if (line.startsWith('@@') && current) { - const match = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/); + const match = line.match(/@@ -\\d+(?:,\\d+)? \\+(\\d+)(?:,(\\d+))? @@/); if (match) { const start = parseInt(match[1], 10); const count = match[2] !== undefined ? parseInt(match[2], 10) : 1;