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);