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'; import { queryClaudeSDK } from '../claude-sdk.js'; import { spawnCursor } from '../cursor-cli.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 strip git diff headers function stripDiffHeaders(diff) { if (!diff) return ''; const lines = diff.split('\n'); const filteredLines = []; let startIncluding = false; for (const line of lines) { // Skip all header lines including diff --git, index, file mode, and --- / +++ file paths if (line.startsWith('diff --git') || line.startsWith('index ') || line.startsWith('new file mode') || line.startsWith('deleted file mode') || line.startsWith('---') || line.startsWith('+++')) { continue; } // Start including lines from @@ hunk headers onwards if (line.startsWith('@@') || startIncluding) { startIncluding = true; filteredLines.push(line); } } return filteredLines.join('\n'); } // 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 or deleted const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath }); 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 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 if (isDeleted) { // For deleted files, show the entire file content from HEAD as deletions const { stdout: fileContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath }); const lines = fileContent.split('\n'); diff = `--- a/${file}\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 execAsync(`git diff -- "${file}"`, { cwd: projectPath }); 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 execAsync(`git diff --cached -- "${file}"`, { cwd: projectPath }); diff = stripDiffHeaders(stagedDiff) || ''; } } res.json({ diff }); } catch (error) { console.error('Git diff error:', error); res.json({ error: error.message }); } }); // Get file content with diff information for CodeEditor router.get('/file-with-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 file status const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath }); const isUntracked = statusOutput.startsWith('??'); const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D'); let currentContent = ''; let oldContent = ''; if (isDeleted) { // For deleted files, get content from HEAD const { stdout: headContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath }); oldContent = headContent; currentContent = headContent; // Show the deleted content in editor } else { // Get current file content currentContent = await fs.readFile(path.join(projectPath, file), 'utf-8'); if (!isUntracked) { // Get the old content from HEAD for tracked files try { const { stdout: headContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath }); oldContent = headContent; } catch (error) { // File might be newly added to git (staged but not committed) oldContent = ''; } } } res.json({ currentContent, oldContent, isDeleted, isUntracked }); } catch (error) { console.error('Git file-with-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 using AI router.post('/generate-commit-message', async (req, res) => { const { project, files, provider = 'claude' } = req.body; if (!project || !files || files.length === 0) { return res.status(400).json({ error: 'Project name and files are required' }); } // Validate provider if (!['claude', 'cursor'].includes(provider)) { return res.status(400).json({ error: 'provider must be "claude" or "cursor"' }); } try { const projectPath = await getActualProjectPath(project); // Get diff for selected files let diffContext = ''; for (const file of files) { try { const { stdout } = await execAsync( `git diff HEAD -- "${file}"`, { cwd: projectPath } ); if (stdout) { diffContext += `\n--- ${file} ---\n${stdout}`; } } catch (error) { console.error(`Error getting diff for ${file}:`, error); } } // If no diff found, might be untracked files if (!diffContext.trim()) { // Try to get content of untracked files for (const file of files) { try { const filePath = path.join(projectPath, file); const content = await fs.readFile(filePath, 'utf-8'); diffContext += `\n--- ${file} (new file) ---\n${content.substring(0, 1000)}\n`; } catch (error) { console.error(`Error reading file ${file}:`, error); } } } // Generate commit message using AI const message = await generateCommitMessageWithAI(files, diffContext, provider, projectPath); res.json({ message }); } catch (error) { console.error('Generate commit message error:', error); res.status(500).json({ error: error.message }); } }); /** * Generates a commit message using AI (Claude SDK or Cursor CLI) * @param {Array} files - List of changed files * @param {string} diffContext - Git diff content * @param {string} provider - 'claude' or 'cursor' * @param {string} projectPath - Project directory path * @returns {Promise} Generated commit message */ async function generateCommitMessageWithAI(files, diffContext, provider, projectPath) { // Create the prompt const prompt = `You are a git commit message generator. Based on the following file changes and diffs, generate a commit message in conventional commit format. REQUIREMENTS: - Use conventional commit format: type(scope): subject - Include a body that explains what changed and why - Valid types: feat, fix, docs, style, refactor, perf, test, build, ci, chore - Keep subject line under 50 characters - Wrap body at 72 characters - Be specific and descriptive - Return ONLY the commit message, nothing else - no markdown, no explanations, no code blocks FILES CHANGED: ${files.map(f => `- ${f}`).join('\n')} DIFFS: ${diffContext.substring(0, 4000)} Generate the commit message now:`; try { // Create a simple writer that collects the response let responseText = ''; const writer = { send: (data) => { try { const parsed = typeof data === 'string' ? JSON.parse(data) : data; console.log('๐Ÿ” Writer received message type:', parsed.type); // Handle different message formats from Claude SDK and Cursor CLI // Claude SDK sends: {type: 'claude-response', data: {message: {content: [...]}}} if (parsed.type === 'claude-response' && parsed.data) { const message = parsed.data.message || parsed.data; console.log('๐Ÿ“ฆ Claude response message:', JSON.stringify(message, null, 2).substring(0, 500)); if (message.content && Array.isArray(message.content)) { // Extract text from content array for (const item of message.content) { if (item.type === 'text' && item.text) { console.log('โœ… Extracted text chunk:', item.text.substring(0, 100)); responseText += item.text; } } } } // Cursor CLI sends: {type: 'cursor-output', output: '...'} else if (parsed.type === 'cursor-output' && parsed.output) { console.log('โœ… Cursor output:', parsed.output.substring(0, 100)); responseText += parsed.output; } // Also handle direct text messages else if (parsed.type === 'text' && parsed.text) { console.log('โœ… Direct text:', parsed.text.substring(0, 100)); responseText += parsed.text; } } catch (e) { // Ignore parse errors console.error('Error parsing writer data:', e); } }, setSessionId: () => {}, // No-op for this use case }; console.log('๐Ÿš€ Calling AI agent with provider:', provider); console.log('๐Ÿ“ Prompt length:', prompt.length); // Call the appropriate agent if (provider === 'claude') { await queryClaudeSDK(prompt, { cwd: projectPath, permissionMode: 'bypassPermissions', model: 'sonnet' }, writer); } else if (provider === 'cursor') { await spawnCursor(prompt, { cwd: projectPath, skipPermissions: true }, writer); } console.log('๐Ÿ“Š Total response text collected:', responseText.length, 'characters'); console.log('๐Ÿ“„ Response preview:', responseText.substring(0, 200)); // Clean up the response const cleanedMessage = cleanCommitMessage(responseText); console.log('๐Ÿงน Cleaned message:', cleanedMessage.substring(0, 200)); return cleanedMessage || 'chore: update files'; } catch (error) { console.error('Error generating commit message with AI:', error); // Fallback to simple message return `chore: update ${files.length} file${files.length !== 1 ? 's' : ''}`; } } /** * Cleans the AI-generated commit message by removing markdown, code blocks, and extra formatting * @param {string} text - Raw AI response * @returns {string} Clean commit message */ function cleanCommitMessage(text) { if (!text || !text.trim()) { return ''; } let cleaned = text.trim(); // Remove markdown code blocks cleaned = cleaned.replace(/```[a-z]*\n/g, ''); cleaned = cleaned.replace(/```/g, ''); // Remove markdown headers cleaned = cleaned.replace(/^#+\s*/gm, ''); // Remove leading/trailing quotes cleaned = cleaned.replace(/^["']|["']$/g, ''); // If there are multiple lines, take everything (subject + body) // Just clean up extra blank lines cleaned = cleaned.replace(/\n{3,}/g, '\n\n'); // Remove any explanatory text before the actual commit message // Look for conventional commit pattern and start from there const conventionalCommitMatch = cleaned.match(/(feat|fix|docs|style|refactor|perf|test|build|ci|chore)(\(.+?\))?:.+/s); if (conventionalCommitMatch) { cleaned = cleaned.substring(cleaned.indexOf(conventionalCommitMatch[0])); } return cleaned.trim(); } // 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;