mirror of
https://github.com/siteboon/claudecodeui.git
synced 2025-12-10 11:09:38 +00:00
Add global settings integration and persistent user preferences for the code editor. Settings are now stored in localStorage and persist across sessions. Changes: - Add theme, word wrap, minimap, line numbers, and font size settings - Load editor preferences from localStorage on initialization - Expose global openSettings function for cross-component access - Add settingsInitialTab state to control which settings tab opens - Pass initialTab prop to Settings component for navigation This improves UX by remembering user preferences and allows other components to open settings to specific tabs programmatically.
1033 lines
35 KiB
JavaScript
Executable File
1033 lines
35 KiB
JavaScript
Executable File
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<string>} 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<string>} Generated commit message
|
|
*/
|
|
async function generateCommitMessageWithAI(files, diffContext, provider, projectPath) {
|
|
// Create the prompt
|
|
const prompt = `Generate a conventional commit message for these changes.
|
|
|
|
REQUIREMENTS:
|
|
- Format: type(scope): subject
|
|
- Include body explaining what changed and why
|
|
- Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore
|
|
- Subject under 50 chars, body wrapped at 72 chars
|
|
- Focus on user-facing changes, not implementation details
|
|
- Consider what's being added AND removed
|
|
- Return ONLY the commit message (no markdown, explanations, or code blocks)
|
|
|
|
FILES CHANGED:
|
|
${files.map(f => `- ${f}`).join('\n')}
|
|
|
|
DIFFS:
|
|
${diffContext.substring(0, 4000)}
|
|
|
|
Generate the commit message:`;
|
|
|
|
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 <url>'
|
|
: 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 <url>';
|
|
} 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 <url>';
|
|
} 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 <branch>';
|
|
}
|
|
|
|
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 <url>'
|
|
});
|
|
}
|
|
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 <url>'
|
|
});
|
|
}
|
|
|
|
// 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; |