import express from 'express'; import { exec } from 'child_process'; import { promisify } from 'util'; import path from 'path'; import { promises as fs } from 'fs'; import { extractProjectDirectory } from '../projects.js'; 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); // 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 // First check for unstaged changes (working tree vs index) const { stdout: unstagedDiff } = await execAsync(`git diff -- "${file}"`, { cwd: projectPath }); if (unstagedDiff) { // Show unstaged changes if they exist diff = unstagedDiff; } else { // If no unstaged changes, check for staged changes (index vs HEAD) 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); // 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}`; } } // Get remote status (ahead/behind commits with smart remote detection) router.get('/remote-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); await validateGitRepository(projectPath); // Get current branch const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath }); const branch = currentBranch.trim(); // Check if there's a remote tracking branch (smart detection) let trackingBranch; let remoteName; try { const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath }); trackingBranch = stdout.trim(); remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin") } catch (error) { // No upstream branch configured - but check if we have remotes let hasRemote = false; let remoteName = null; try { const { stdout } = await execAsync('git remote', { cwd: projectPath }); const remotes = stdout.trim().split('\n').filter(r => r.trim()); if (remotes.length > 0) { hasRemote = true; remoteName = remotes.includes('origin') ? 'origin' : remotes[0]; } } catch (remoteError) { // No remotes configured } return res.json({ hasRemote, hasUpstream: false, branch, remoteName, message: 'No remote tracking branch configured' }); } // Get ahead/behind counts const { stdout: countOutput } = await execAsync( `git rev-list --count --left-right ${trackingBranch}...HEAD`, { cwd: projectPath } ); const [behind, ahead] = countOutput.trim().split('\t').map(Number); res.json({ hasRemote: true, hasUpstream: true, branch, remoteBranch: trackingBranch, remoteName, ahead: ahead || 0, behind: behind || 0, isUpToDate: ahead === 0 && behind === 0 }); } catch (error) { console.error('Git remote status error:', error); res.json({ error: error.message }); } }); // Fetch from remote (using smart remote detection) router.post('/fetch', async (req, res) => { const { project } = req.body; if (!project) { return res.status(400).json({ error: 'Project name is required' }); } try { const projectPath = await getActualProjectPath(project); await validateGitRepository(projectPath); // Get current branch and its upstream remote const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath }); const branch = currentBranch.trim(); let remoteName = 'origin'; // fallback try { const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath }); remoteName = stdout.trim().split('/')[0]; // Extract remote name } catch (error) { // No upstream, try to fetch from origin anyway console.log('No upstream configured, using origin as fallback'); } const { stdout } = await execAsync(`git fetch ${remoteName}`, { cwd: projectPath }); res.json({ success: true, output: stdout || 'Fetch completed successfully', remoteName }); } catch (error) { console.error('Git fetch error:', error); res.status(500).json({ error: 'Fetch failed', details: error.message.includes('Could not resolve hostname') ? 'Unable to connect to remote repository. Check your internet connection.' : error.message.includes('fatal: \'origin\' does not appear to be a git repository') ? 'No remote repository configured. Add a remote with: git remote add origin ' : error.message }); } }); // Pull from remote (fetch + merge using smart remote detection) router.post('/pull', async (req, res) => { const { project } = req.body; if (!project) { return res.status(400).json({ error: 'Project name is required' }); } try { const projectPath = await getActualProjectPath(project); await validateGitRepository(projectPath); // Get current branch and its upstream remote const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath }); const branch = currentBranch.trim(); let remoteName = 'origin'; // fallback let remoteBranch = branch; // fallback try { const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath }); const tracking = stdout.trim(); remoteName = tracking.split('/')[0]; // Extract remote name remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name } catch (error) { // No upstream, use fallback console.log('No upstream configured, using origin/branch as fallback'); } const { stdout } = await execAsync(`git pull ${remoteName} ${remoteBranch}`, { cwd: projectPath }); res.json({ success: true, output: stdout || 'Pull completed successfully', remoteName, remoteBranch }); } catch (error) { console.error('Git pull error:', error); // Enhanced error handling for common pull scenarios let errorMessage = 'Pull failed'; let details = error.message; if (error.message.includes('CONFLICT')) { errorMessage = 'Merge conflicts detected'; details = 'Pull created merge conflicts. Please resolve conflicts manually in the editor, then commit the changes.'; } else if (error.message.includes('Please commit your changes or stash them')) { errorMessage = 'Uncommitted changes detected'; details = 'Please commit or stash your local changes before pulling.'; } else if (error.message.includes('Could not resolve hostname')) { errorMessage = 'Network error'; details = 'Unable to connect to remote repository. Check your internet connection.'; } else if (error.message.includes('fatal: \'origin\' does not appear to be a git repository')) { errorMessage = 'Remote not configured'; details = 'No remote repository configured. Add a remote with: git remote add origin '; } else if (error.message.includes('diverged')) { errorMessage = 'Branches have diverged'; details = 'Your local branch and remote branch have diverged. Consider fetching first to review changes.'; } res.status(500).json({ error: errorMessage, details: details }); } }); // Push commits to remote repository router.post('/push', async (req, res) => { const { project } = req.body; if (!project) { return res.status(400).json({ error: 'Project name is required' }); } try { const projectPath = await getActualProjectPath(project); await validateGitRepository(projectPath); // Get current branch and its upstream remote const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath }); const branch = currentBranch.trim(); let remoteName = 'origin'; // fallback let remoteBranch = branch; // fallback try { const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath }); const tracking = stdout.trim(); remoteName = tracking.split('/')[0]; // Extract remote name remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name } catch (error) { // No upstream, use fallback console.log('No upstream configured, using origin/branch as fallback'); } const { stdout } = await execAsync(`git push ${remoteName} ${remoteBranch}`, { cwd: projectPath }); res.json({ success: true, output: stdout || 'Push completed successfully', remoteName, remoteBranch }); } catch (error) { console.error('Git push error:', error); // Enhanced error handling for common push scenarios let errorMessage = 'Push failed'; let details = error.message; if (error.message.includes('rejected')) { errorMessage = 'Push rejected'; details = 'The remote has newer commits. Pull first to merge changes before pushing.'; } else if (error.message.includes('non-fast-forward')) { errorMessage = 'Non-fast-forward push'; details = 'Your branch is behind the remote. Pull the latest changes first.'; } else if (error.message.includes('Could not resolve hostname')) { errorMessage = 'Network error'; details = 'Unable to connect to remote repository. Check your internet connection.'; } else if (error.message.includes('fatal: \'origin\' does not appear to be a git repository')) { errorMessage = 'Remote not configured'; details = 'No remote repository configured. Add a remote with: git remote add origin '; } else if (error.message.includes('Permission denied')) { errorMessage = 'Authentication failed'; details = 'Permission denied. Check your credentials or SSH keys.'; } else if (error.message.includes('no upstream branch')) { errorMessage = 'No upstream branch'; details = 'No upstream branch configured. Use: git push --set-upstream origin '; } res.status(500).json({ error: errorMessage, details: details }); } }); // Publish branch to remote (set upstream and push) router.post('/publish', 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); await validateGitRepository(projectPath); // Get current branch to verify it matches the requested branch const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath }); const currentBranchName = currentBranch.trim(); if (currentBranchName !== branch) { return res.status(400).json({ error: `Branch mismatch. Current branch is ${currentBranchName}, but trying to publish ${branch}` }); } // Check if remote exists let remoteName = 'origin'; try { const { stdout } = await execAsync('git remote', { cwd: projectPath }); const remotes = stdout.trim().split('\n').filter(r => r.trim()); if (remotes.length === 0) { return res.status(400).json({ error: 'No remote repository configured. Add a remote with: git remote add origin ' }); } remoteName = remotes.includes('origin') ? 'origin' : remotes[0]; } catch (error) { return res.status(400).json({ error: 'No remote repository configured. Add a remote with: git remote add origin ' }); } // Publish the branch (set upstream and push) const { stdout } = await execAsync(`git push --set-upstream ${remoteName} ${branch}`, { cwd: projectPath }); res.json({ success: true, output: stdout || 'Branch published successfully', remoteName, branch }); } catch (error) { console.error('Git publish error:', error); // Enhanced error handling for common publish scenarios let errorMessage = 'Publish failed'; let details = error.message; if (error.message.includes('rejected')) { errorMessage = 'Publish rejected'; details = 'The remote branch already exists and has different commits. Use push instead.'; } else if (error.message.includes('Could not resolve hostname')) { errorMessage = 'Network error'; details = 'Unable to connect to remote repository. Check your internet connection.'; } else if (error.message.includes('Permission denied')) { errorMessage = 'Authentication failed'; details = 'Permission denied. Check your credentials or SSH keys.'; } else if (error.message.includes('fatal:') && error.message.includes('does not appear to be a git repository')) { errorMessage = 'Remote not configured'; details = 'Remote repository not properly configured. Check your remote URL.'; } res.status(500).json({ error: errorMessage, details: details }); } }); // Discard changes for a specific file router.post('/discard', async (req, res) => { const { project, file } = req.body; if (!project || !file) { return res.status(400).json({ error: 'Project name and file path are required' }); } try { const projectPath = await getActualProjectPath(project); await validateGitRepository(projectPath); // Check file status to determine correct discard command const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath }); if (!statusOutput.trim()) { return res.status(400).json({ error: 'No changes to discard for this file' }); } const status = statusOutput.substring(0, 2); if (status === '??') { // Untracked file - delete it await fs.unlink(path.join(projectPath, file)); } else if (status.includes('M') || status.includes('D')) { // Modified or deleted file - restore from HEAD await execAsync(`git restore "${file}"`, { cwd: projectPath }); } else if (status.includes('A')) { // Added file - unstage it await execAsync(`git reset HEAD "${file}"`, { cwd: projectPath }); } res.json({ success: true, message: `Changes discarded for ${file}` }); } catch (error) { console.error('Git discard error:', error); res.status(500).json({ error: error.message }); } }); // Delete untracked file router.post('/delete-untracked', async (req, res) => { const { project, file } = req.body; if (!project || !file) { return res.status(400).json({ error: 'Project name and file path are required' }); } try { const projectPath = await getActualProjectPath(project); await validateGitRepository(projectPath); // Check if file is actually untracked const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath }); if (!statusOutput.trim()) { return res.status(400).json({ error: 'File is not untracked or does not exist' }); } const status = statusOutput.substring(0, 2); if (status !== '??') { return res.status(400).json({ error: 'File is not untracked. Use discard for tracked files.' }); } // Delete the untracked file await fs.unlink(path.join(projectPath, file)); res.json({ success: true, message: `Untracked file ${file} deleted successfully` }); } catch (error) { console.error('Git delete untracked error:', error); res.status(500).json({ error: error.message }); } }); export default router;