From 68787e01fc4538061ce30c9e6f40c1ecf039f540 Mon Sep 17 00:00:00 2001 From: Haileyesus Date: Tue, 10 Mar 2026 23:14:28 +0300 Subject: [PATCH] fix(git): resolve file paths against repo root for paths with spaces Fix path resolution for git file operations when project directories include spaces or when API calls are issued from subdirectories inside a repository. Problem - operations like commit/discard/diff could receive file paths that were valid from repo root but were executed from a nested cwd - this produced pathspec errors like: - warning: could not open directory '4/4/' - fatal: pathspec '4/hello_world.ts' did not match any files Root cause - file arguments were passed directly to git commands using the project cwd - inconsistent path forms (repo-root-relative vs cwd-relative) were not normalized Changes - remove unsafe fallback decode in `getActualProjectPath`; fail explicitly when the real project path cannot be resolved - add repository/file-path helpers: - `getRepositoryRootPath` - `normalizeRepositoryRelativeFilePath` - `parseStatusFilePaths` - `buildFilePathCandidates` - `resolveRepositoryFilePath` - update file-based git endpoints to resolve paths before executing commands: - GET `/diff` - GET `/file-with-diff` - POST `/commit` - POST `/generate-commit-message` - POST `/discard` - POST `/delete-untracked` - stage/restore/reset operations now use `--` before pathspecs for safer argument separation Behavioral impact - git operations now work reliably for repositories under directories containing spaces - file operations are consistent even when project cwd is a subdirectory of repo root - endpoint responses continue to preserve existing payload shapes Verification - syntax check: `node --check server/routes/git.js` - typecheck: `npm run typecheck` - reproduced failing scenario in a temp path with spaces; confirmed root-resolved path staging succeeds where subdir-cwd pathspec previously failed --- server/routes/git.js | 214 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 170 insertions(+), 44 deletions(-) diff --git a/server/routes/git.js b/server/routes/git.js index fe2d90f..6cd56f4 100755 --- a/server/routes/git.js +++ b/server/routes/git.js @@ -81,8 +81,7 @@ async function getActualProjectPath(projectName) { return await extractProjectDirectory(projectName); } catch (error) { console.error(`Error extracting project directory for ${projectName}:`, error); - // Fallback to the old method - return projectName.replace(/-/g, '/'); + throw new Error(`Unable to resolve project path for "${projectName}"`); } } @@ -179,6 +178,87 @@ async function repositoryHasCommits(projectPath) { } } +async function getRepositoryRootPath(projectPath) { + const { stdout } = await spawnAsync('git', ['rev-parse', '--show-toplevel'], { cwd: projectPath }); + return stdout.trim(); +} + +function normalizeRepositoryRelativeFilePath(filePath) { + return String(filePath) + .replace(/\\/g, '/') + .replace(/^\.\/+/, '') + .replace(/^\/+/, '') + .trim(); +} + +function parseStatusFilePaths(statusOutput) { + return statusOutput + .split('\n') + .map((line) => line.trimEnd()) + .filter((line) => line.trim()) + .map((line) => { + const statusPath = line.substring(3); + const renamedFilePath = statusPath.split(' -> ')[1]; + return normalizeRepositoryRelativeFilePath(renamedFilePath || statusPath); + }) + .filter(Boolean); +} + +function buildFilePathCandidates(projectPath, repositoryRootPath, filePath) { + const normalizedFilePath = normalizeRepositoryRelativeFilePath(filePath); + const projectRelativePath = normalizeRepositoryRelativeFilePath(path.relative(repositoryRootPath, projectPath)); + const candidates = [normalizedFilePath]; + + if ( + projectRelativePath + && projectRelativePath !== '.' + && !normalizedFilePath.startsWith(`${projectRelativePath}/`) + ) { + candidates.push(`${projectRelativePath}/${normalizedFilePath}`); + } + + return Array.from(new Set(candidates.filter(Boolean))); +} + +async function resolveRepositoryFilePath(projectPath, filePath) { + validateFilePath(filePath); + + const repositoryRootPath = await getRepositoryRootPath(projectPath); + const candidateFilePaths = buildFilePathCandidates(projectPath, repositoryRootPath, filePath); + + for (const candidateFilePath of candidateFilePaths) { + const { stdout } = await spawnAsync('git', ['status', '--porcelain', '--', candidateFilePath], { cwd: repositoryRootPath }); + if (stdout.trim()) { + return { + repositoryRootPath, + repositoryRelativeFilePath: candidateFilePath, + }; + } + } + + // If the caller sent a bare filename (e.g. "hello.ts"), recover it from changed files. + const normalizedFilePath = normalizeRepositoryRelativeFilePath(filePath); + if (!normalizedFilePath.includes('/')) { + const { stdout: repositoryStatusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: repositoryRootPath }); + const changedFilePaths = parseStatusFilePaths(repositoryStatusOutput); + const suffixMatches = changedFilePaths.filter( + (changedFilePath) => changedFilePath === normalizedFilePath || changedFilePath.endsWith(`/${normalizedFilePath}`), + ); + + if (suffixMatches.length === 1) { + return { + repositoryRootPath, + repositoryRelativeFilePath: suffixMatches[0], + }; + } + } + + return { + repositoryRootPath, + repositoryRelativeFilePath: candidateFilePaths[0], + }; +} + // Get git status for a project router.get('/status', async (req, res) => { const { project } = req.query; @@ -255,47 +335,65 @@ router.get('/diff', async (req, res) => { // Validate git repository await validateGitRepository(projectPath); - - // Validate file path - validateFilePath(file); + + const { + repositoryRootPath, + repositoryRelativeFilePath, + } = await resolveRepositoryFilePath(projectPath, file); // Check if file is untracked or deleted - const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath }); + const { stdout: statusOutput } = await spawnAsync( + 'git', + ['status', '--porcelain', '--', repositoryRelativeFilePath], + { cwd: repositoryRootPath }, + ); const isUntracked = statusOutput.startsWith('??'); const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D'); let diff; if (isUntracked) { // For untracked files, show the entire file content as additions - const filePath = path.join(projectPath, file); + const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath); const stats = await fs.stat(filePath); if (stats.isDirectory()) { // For directories, show a simple message - diff = `Directory: ${file}\n(Cannot show diff for directories)`; + diff = `Directory: ${repositoryRelativeFilePath}\n(Cannot show diff for directories)`; } else { const fileContent = await fs.readFile(filePath, 'utf-8'); const lines = fileContent.split('\n'); - diff = `--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${lines.length} @@\n` + + diff = `--- /dev/null\n+++ b/${repositoryRelativeFilePath}\n@@ -0,0 +1,${lines.length} @@\n` + lines.map(line => `+${line}`).join('\n'); } } else if (isDeleted) { // For deleted files, show the entire file content from HEAD as deletions - const { stdout: fileContent } = await spawnAsync('git', ['show', `HEAD:${file}`], { cwd: projectPath }); + const { stdout: fileContent } = await spawnAsync( + 'git', + ['show', `HEAD:${repositoryRelativeFilePath}`], + { cwd: repositoryRootPath }, + ); const lines = fileContent.split('\n'); - diff = `--- a/${file}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n` + + diff = `--- a/${repositoryRelativeFilePath}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n` + lines.map(line => `-${line}`).join('\n'); } else { // Get diff for tracked files // First check for unstaged changes (working tree vs index) - const { stdout: unstagedDiff } = await spawnAsync('git', ['diff', '--', file], { cwd: projectPath }); + const { stdout: unstagedDiff } = await spawnAsync( + 'git', + ['diff', '--', repositoryRelativeFilePath], + { cwd: repositoryRootPath }, + ); if (unstagedDiff) { // Show unstaged changes if they exist diff = stripDiffHeaders(unstagedDiff); } else { // If no unstaged changes, check for staged changes (index vs HEAD) - const { stdout: stagedDiff } = await spawnAsync('git', ['diff', '--cached', '--', file], { cwd: projectPath }); + const { stdout: stagedDiff } = await spawnAsync( + 'git', + ['diff', '--cached', '--', repositoryRelativeFilePath], + { cwd: repositoryRootPath }, + ); diff = stripDiffHeaders(stagedDiff) || ''; } } @@ -321,11 +419,17 @@ router.get('/file-with-diff', async (req, res) => { // Validate git repository await validateGitRepository(projectPath); - // Validate file path - validateFilePath(file); + const { + repositoryRootPath, + repositoryRelativeFilePath, + } = await resolveRepositoryFilePath(projectPath, file); // Check file status - const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath }); + const { stdout: statusOutput } = await spawnAsync( + 'git', + ['status', '--porcelain', '--', repositoryRelativeFilePath], + { cwd: repositoryRootPath }, + ); const isUntracked = statusOutput.startsWith('??'); const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D'); @@ -334,12 +438,16 @@ router.get('/file-with-diff', async (req, res) => { if (isDeleted) { // For deleted files, get content from HEAD - const { stdout: headContent } = await spawnAsync('git', ['show', `HEAD:${file}`], { cwd: projectPath }); + const { stdout: headContent } = await spawnAsync( + 'git', + ['show', `HEAD:${repositoryRelativeFilePath}`], + { cwd: repositoryRootPath }, + ); oldContent = headContent; currentContent = headContent; // Show the deleted content in editor } else { // Get current file content - const filePath = path.join(projectPath, file); + const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath); const stats = await fs.stat(filePath); if (stats.isDirectory()) { @@ -352,7 +460,11 @@ router.get('/file-with-diff', async (req, res) => { if (!isUntracked) { // Get the old content from HEAD for tracked files try { - const { stdout: headContent } = await spawnAsync('git', ['show', `HEAD:${file}`], { cwd: projectPath }); + const { stdout: headContent } = await spawnAsync( + 'git', + ['show', `HEAD:${repositoryRelativeFilePath}`], + { cwd: repositoryRootPath }, + ); oldContent = headContent; } catch (error) { // File might be newly added to git (staged but not committed) @@ -430,15 +542,16 @@ router.post('/commit', async (req, res) => { // Validate git repository await validateGitRepository(projectPath); + const repositoryRootPath = await getRepositoryRootPath(projectPath); // Stage selected files for (const file of files) { - validateFilePath(file); - await spawnAsync('git', ['add', file], { cwd: projectPath }); + const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file); + await spawnAsync('git', ['add', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath }); } // Commit with message - const { stdout } = await spawnAsync('git', ['commit', '-m', message], { cwd: projectPath }); + const { stdout } = await spawnAsync('git', ['commit', '-m', message], { cwd: repositoryRootPath }); res.json({ success: true, output: stdout }); } catch (error) { @@ -679,18 +792,20 @@ router.post('/generate-commit-message', async (req, res) => { try { const projectPath = await getActualProjectPath(project); + await validateGitRepository(projectPath); + const repositoryRootPath = await getRepositoryRootPath(projectPath); // Get diff for selected files let diffContext = ''; for (const file of files) { try { - validateFilePath(file); + const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file); const { stdout } = await spawnAsync( - 'git', ['diff', 'HEAD', '--', file], - { cwd: projectPath } + 'git', ['diff', 'HEAD', '--', repositoryRelativeFilePath], + { cwd: repositoryRootPath } ); if (stdout) { - diffContext += `\n--- ${file} ---\n${stdout}`; + diffContext += `\n--- ${repositoryRelativeFilePath} ---\n${stdout}`; } } catch (error) { console.error(`Error getting diff for ${file}:`, error); @@ -702,14 +817,15 @@ router.post('/generate-commit-message', async (req, res) => { // Try to get content of untracked files for (const file of files) { try { - const filePath = path.join(projectPath, file); + const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file); + const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath); const stats = await fs.stat(filePath); if (!stats.isDirectory()) { const content = await fs.readFile(filePath, 'utf-8'); - diffContext += `\n--- ${file} (new file) ---\n${content.substring(0, 1000)}\n`; + diffContext += `\n--- ${repositoryRelativeFilePath} (new file) ---\n${content.substring(0, 1000)}\n`; } else { - diffContext += `\n--- ${file} (new directory) ---\n`; + diffContext += `\n--- ${repositoryRelativeFilePath} (new directory) ---\n`; } } catch (error) { console.error(`Error reading file ${file}:`, error); @@ -1214,12 +1330,17 @@ router.post('/discard', async (req, res) => { try { const projectPath = await getActualProjectPath(project); await validateGitRepository(projectPath); - - // Validate file path - validateFilePath(file); + const { + repositoryRootPath, + repositoryRelativeFilePath, + } = await resolveRepositoryFilePath(projectPath, file); // Check file status to determine correct discard command - const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath }); + const { stdout: statusOutput } = await spawnAsync( + 'git', + ['status', '--porcelain', '--', repositoryRelativeFilePath], + { cwd: repositoryRootPath }, + ); if (!statusOutput.trim()) { return res.status(400).json({ error: 'No changes to discard for this file' }); @@ -1229,7 +1350,7 @@ router.post('/discard', async (req, res) => { if (status === '??') { // Untracked file or directory - delete it - const filePath = path.join(projectPath, file); + const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath); const stats = await fs.stat(filePath); if (stats.isDirectory()) { @@ -1239,13 +1360,13 @@ router.post('/discard', async (req, res) => { } } else if (status.includes('M') || status.includes('D')) { // Modified or deleted file - restore from HEAD - await spawnAsync('git', ['restore', file], { cwd: projectPath }); + await spawnAsync('git', ['restore', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath }); } else if (status.includes('A')) { // Added file - unstage it - await spawnAsync('git', ['reset', 'HEAD', file], { cwd: projectPath }); + await spawnAsync('git', ['reset', 'HEAD', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath }); } - res.json({ success: true, message: `Changes discarded for ${file}` }); + res.json({ success: true, message: `Changes discarded for ${repositoryRelativeFilePath}` }); } catch (error) { console.error('Git discard error:', error); res.status(500).json({ error: error.message }); @@ -1263,12 +1384,17 @@ router.post('/delete-untracked', async (req, res) => { try { const projectPath = await getActualProjectPath(project); await validateGitRepository(projectPath); - - // Validate file path - validateFilePath(file); + const { + repositoryRootPath, + repositoryRelativeFilePath, + } = await resolveRepositoryFilePath(projectPath, file); // Check if file is actually untracked - const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath }); + const { stdout: statusOutput } = await spawnAsync( + 'git', + ['status', '--porcelain', '--', repositoryRelativeFilePath], + { cwd: repositoryRootPath }, + ); if (!statusOutput.trim()) { return res.status(400).json({ error: 'File is not untracked or does not exist' }); @@ -1281,16 +1407,16 @@ router.post('/delete-untracked', async (req, res) => { } // Delete the untracked file or directory - const filePath = path.join(projectPath, file); + const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath); const stats = await fs.stat(filePath); if (stats.isDirectory()) { // Use rm with recursive option for directories await fs.rm(filePath, { recursive: true, force: true }); - res.json({ success: true, message: `Untracked directory ${file} deleted successfully` }); + res.json({ success: true, message: `Untracked directory ${repositoryRelativeFilePath} deleted successfully` }); } else { await fs.unlink(filePath); - res.json({ success: true, message: `Untracked file ${file} deleted successfully` }); + res.json({ success: true, message: `Untracked file ${repositoryRelativeFilePath} deleted successfully` }); } } catch (error) { console.error('Git delete untracked error:', error);