const express = require('express'); const { exec } = require('child_process'); const { promisify } = require('util'); const path = require('path'); const fs = require('fs').promises; const { extractProjectDirectory } = require('../projects'); const router = express.Router(); const execAsync = promisify(exec); // Helper function to get the actual project path from the encoded project name async function getActualProjectPath(projectName) { try { return await extractProjectDirectory(projectName); } catch (error) { console.error(`Error extracting project directory for ${projectName}:`, error); // Fallback to the old method return projectName.replace(/-/g, '/'); } } // Helper function to validate git repository async function validateGitRepository(projectPath) { try { // Check if directory exists await fs.access(projectPath); } catch { throw new Error(`Project path not found: ${projectPath}`); } try { // Use --show-toplevel to get the root of the git repository const { stdout: gitRoot } = await execAsync('git rev-parse --show-toplevel', { cwd: projectPath }); const normalizedGitRoot = path.resolve(gitRoot.trim()); const normalizedProjectPath = path.resolve(projectPath); // Ensure the git root matches our project path (prevent using parent git repos) if (normalizedGitRoot !== normalizedProjectPath) { throw new Error(`Project directory is not a git repository. This directory is inside a git repository at ${normalizedGitRoot}, but git operations should be run from the repository root.`); } } catch (error) { if (error.message.includes('Project directory is not a git repository')) { throw error; } throw new Error('Not a git repository. This directory does not contain a .git folder. Initialize a git repository with "git init" to use source control features.'); } } // Get git status for a project router.get('/status', async (req, res) => { const { project } = req.query; if (!project) { return res.status(400).json({ error: 'Project name is required' }); } try { const projectPath = await getActualProjectPath(project); console.log('Git status for project:', project, '-> path:', projectPath); // Validate git repository await validateGitRepository(projectPath); // Get current branch const { stdout: branch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath }); // Get git status const { stdout: statusOutput } = await execAsync('git status --porcelain', { cwd: projectPath }); const modified = []; const added = []; const deleted = []; const untracked = []; statusOutput.split('\n').forEach(line => { if (!line.trim()) return; const status = line.substring(0, 2); const file = line.substring(3); if (status === 'M ' || status === ' M' || status === 'MM') { modified.push(file); } else if (status === 'A ' || status === 'AM') { added.push(file); } else if (status === 'D ' || status === ' D') { deleted.push(file); } else if (status === '??') { untracked.push(file); } }); res.json({ branch: branch.trim(), modified, added, deleted, untracked }); } catch (error) { console.error('Git status error:', error); res.json({ error: error.message.includes('not a git repository') || error.message.includes('Project directory is not a git repository') ? error.message : 'Git operation failed', details: error.message.includes('not a git repository') || error.message.includes('Project directory is not a git repository') ? error.message : `Failed to get git status: ${error.message}` }); } }); // Get diff for a specific file router.get('/diff', async (req, res) => { const { project, file } = req.query; if (!project || !file) { return res.status(400).json({ error: 'Project name and file path are required' }); } try { const projectPath = await getActualProjectPath(project); // Validate git repository await validateGitRepository(projectPath); // Check if file is untracked const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath }); const isUntracked = statusOutput.startsWith('??'); let diff; if (isUntracked) { // For untracked files, show the entire file content as additions const fileContent = await fs.readFile(path.join(projectPath, file), 'utf-8'); const lines = fileContent.split('\n'); diff = `--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${lines.length} @@\n` + lines.map(line => `+${line}`).join('\n'); } else { // Get diff for tracked files const { stdout } = await execAsync(`git diff HEAD -- "${file}"`, { cwd: projectPath }); diff = stdout || ''; // If no unstaged changes, check for staged changes if (!diff) { const { stdout: stagedDiff } = await execAsync(`git diff --cached -- "${file}"`, { cwd: projectPath }); diff = stagedDiff; } } res.json({ diff }); } catch (error) { console.error('Git diff error:', error); res.json({ error: error.message }); } }); // Commit changes router.post('/commit', async (req, res) => { const { project, message, files } = req.body; if (!project || !message || !files || files.length === 0) { return res.status(400).json({ error: 'Project name, commit message, and files are required' }); } try { const projectPath = await getActualProjectPath(project); // Validate git repository await validateGitRepository(projectPath); // Stage selected files for (const file of files) { await execAsync(`git add "${file}"`, { cwd: projectPath }); } // Commit with message const { stdout } = await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd: projectPath }); res.json({ success: true, output: stdout }); } catch (error) { console.error('Git commit error:', error); res.status(500).json({ error: error.message }); } }); // Get list of branches router.get('/branches', async (req, res) => { const { project } = req.query; if (!project) { return res.status(400).json({ error: 'Project name is required' }); } try { const projectPath = await getActualProjectPath(project); console.log('Git branches for project:', project, '-> path:', projectPath); // Validate git repository await validateGitRepository(projectPath); // Get all branches const { stdout } = await execAsync('git branch -a', { cwd: projectPath }); // Parse branches const branches = stdout .split('\n') .map(branch => branch.trim()) .filter(branch => branch && !branch.includes('->')) // Remove empty lines and HEAD pointer .map(branch => { // Remove asterisk from current branch if (branch.startsWith('* ')) { return branch.substring(2); } // Remove remotes/ prefix if (branch.startsWith('remotes/origin/')) { return branch.substring(15); } return branch; }) .filter((branch, index, self) => self.indexOf(branch) === index); // Remove duplicates res.json({ branches }); } catch (error) { console.error('Git branches error:', error); res.json({ error: error.message }); } }); // Checkout branch router.post('/checkout', async (req, res) => { const { project, branch } = req.body; if (!project || !branch) { return res.status(400).json({ error: 'Project name and branch are required' }); } try { const projectPath = await getActualProjectPath(project); // Checkout the branch const { stdout } = await execAsync(`git checkout "${branch}"`, { cwd: projectPath }); res.json({ success: true, output: stdout }); } catch (error) { console.error('Git checkout error:', error); res.status(500).json({ error: error.message }); } }); // Create new branch router.post('/create-branch', async (req, res) => { const { project, branch } = req.body; if (!project || !branch) { return res.status(400).json({ error: 'Project name and branch name are required' }); } try { const projectPath = await getActualProjectPath(project); // Create and checkout new branch const { stdout } = await execAsync(`git checkout -b "${branch}"`, { cwd: projectPath }); res.json({ success: true, output: stdout }); } catch (error) { console.error('Git create branch error:', error); res.status(500).json({ error: error.message }); } }); // Get recent commits router.get('/commits', async (req, res) => { const { project, limit = 10 } = req.query; if (!project) { return res.status(400).json({ error: 'Project name is required' }); } try { const projectPath = await getActualProjectPath(project); // Get commit log with stats const { stdout } = await execAsync( `git log --pretty=format:'%H|%an|%ae|%ad|%s' --date=relative -n ${limit}`, { cwd: projectPath } ); const commits = stdout .split('\n') .filter(line => line.trim()) .map(line => { const [hash, author, email, date, ...messageParts] = line.split('|'); return { hash, author, email, date, message: messageParts.join('|') }; }); // Get stats for each commit for (const commit of commits) { try { const { stdout: stats } = await execAsync( `git show --stat --format='' ${commit.hash}`, { cwd: projectPath } ); commit.stats = stats.trim().split('\n').pop(); // Get the summary line } catch (error) { commit.stats = ''; } } res.json({ commits }); } catch (error) { console.error('Git commits error:', error); res.json({ error: error.message }); } }); // Get diff for a specific commit router.get('/commit-diff', async (req, res) => { const { project, commit } = req.query; if (!project || !commit) { return res.status(400).json({ error: 'Project name and commit hash are required' }); } try { const projectPath = await getActualProjectPath(project); // Get diff for the commit const { stdout } = await execAsync( `git show ${commit}`, { cwd: projectPath } ); res.json({ diff: stdout }); } catch (error) { console.error('Git commit diff error:', error); res.json({ error: error.message }); } }); // Generate commit message based on staged changes router.post('/generate-commit-message', async (req, res) => { const { project, files } = req.body; if (!project || !files || files.length === 0) { return res.status(400).json({ error: 'Project name and files are required' }); } try { const projectPath = await getActualProjectPath(project); // Get diff for selected files let combinedDiff = ''; for (const file of files) { try { const { stdout } = await execAsync( `git diff HEAD -- "${file}"`, { cwd: projectPath } ); if (stdout) { combinedDiff += `\n--- ${file} ---\n${stdout}`; } } catch (error) { console.error(`Error getting diff for ${file}:`, error); } } // Use AI to generate commit message (simple implementation) // In a real implementation, you might want to use GPT or Claude API const message = generateSimpleCommitMessage(files, combinedDiff); res.json({ message }); } catch (error) { console.error('Generate commit message error:', error); res.status(500).json({ error: error.message }); } }); // Simple commit message generator (can be replaced with AI) function generateSimpleCommitMessage(files, diff) { const fileCount = files.length; const isMultipleFiles = fileCount > 1; // Analyze the diff to determine the type of change const additions = (diff.match(/^\+[^+]/gm) || []).length; const deletions = (diff.match(/^-[^-]/gm) || []).length; // Determine the primary action let action = 'Update'; if (additions > 0 && deletions === 0) { action = 'Add'; } else if (deletions > 0 && additions === 0) { action = 'Remove'; } else if (additions > deletions * 2) { action = 'Enhance'; } else if (deletions > additions * 2) { action = 'Refactor'; } // Generate message based on files if (isMultipleFiles) { const components = new Set(files.map(f => { const parts = f.split('/'); return parts[parts.length - 2] || parts[0]; })); if (components.size === 1) { return `${action} ${[...components][0]} component`; } else { return `${action} multiple components`; } } else { const fileName = files[0].split('/').pop(); const componentName = fileName.replace(/\.(jsx?|tsx?|css|scss)$/, ''); return `${action} ${componentName}`; } } module.exports = router;