mirror of
https://github.com/siteboon/claudecodeui.git
synced 2025-12-11 23:59:47 +00:00
1128 lines
38 KiB
JavaScript
Executable File
1128 lines
38 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 - handle case where there are no commits yet
|
|
let branch = 'main';
|
|
let hasCommits = true;
|
|
try {
|
|
const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
|
|
branch = branchOutput.trim();
|
|
} catch (error) {
|
|
// No HEAD exists - repository has no commits yet
|
|
if (error.message.includes('unknown revision') || error.message.includes('ambiguous argument')) {
|
|
hasCommits = false;
|
|
branch = 'main';
|
|
} else {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
hasCommits,
|
|
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 filePath = path.join(projectPath, file);
|
|
const stats = await fs.stat(filePath);
|
|
|
|
if (stats.isDirectory()) {
|
|
// For directories, show a simple message
|
|
diff = `Directory: ${file}\n(Cannot show diff for directories)`;
|
|
} else {
|
|
const fileContent = await fs.readFile(filePath, 'utf-8');
|
|
const lines = fileContent.split('\n');
|
|
diff = `--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${lines.length} @@\n` +
|
|
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
|
|
const filePath = path.join(projectPath, file);
|
|
const stats = await fs.stat(filePath);
|
|
|
|
if (stats.isDirectory()) {
|
|
// Cannot show content for directories
|
|
return res.status(400).json({ error: 'Cannot show diff for directories' });
|
|
}
|
|
|
|
currentContent = await fs.readFile(filePath, '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 });
|
|
}
|
|
});
|
|
|
|
// Create initial commit
|
|
router.post('/initial-commit', 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);
|
|
|
|
// Validate git repository
|
|
await validateGitRepository(projectPath);
|
|
|
|
// Check if there are already commits
|
|
try {
|
|
await execAsync('git rev-parse HEAD', { cwd: projectPath });
|
|
return res.status(400).json({ error: 'Repository already has commits. Use regular commit instead.' });
|
|
} catch (error) {
|
|
// No HEAD - this is good, we can create initial commit
|
|
}
|
|
|
|
// Add all files
|
|
await execAsync('git add .', { cwd: projectPath });
|
|
|
|
// Create initial commit
|
|
const { stdout } = await execAsync('git commit -m "Initial commit"', { cwd: projectPath });
|
|
|
|
res.json({ success: true, output: stdout, message: 'Initial commit created successfully' });
|
|
} catch (error) {
|
|
console.error('Git initial commit error:', error);
|
|
|
|
// Handle the case where there's nothing to commit
|
|
if (error.message.includes('nothing to commit')) {
|
|
return res.status(400).json({
|
|
error: 'Nothing to commit',
|
|
details: 'No files found in the repository. Add some files first.'
|
|
});
|
|
}
|
|
|
|
res.status(500).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 stats = await fs.stat(filePath);
|
|
|
|
if (!stats.isDirectory()) {
|
|
const content = await fs.readFile(filePath, 'utf-8');
|
|
diffContext += `\n--- ${file} (new file) ---\n${content.substring(0, 1000)}\n`;
|
|
} else {
|
|
diffContext += `\n--- ${file} (new directory) ---\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 or directory - delete it
|
|
const filePath = path.join(projectPath, file);
|
|
const stats = await fs.stat(filePath);
|
|
|
|
if (stats.isDirectory()) {
|
|
await fs.rm(filePath, { recursive: true, force: true });
|
|
} else {
|
|
await fs.unlink(filePath);
|
|
}
|
|
} 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 or directory
|
|
const filePath = path.join(projectPath, file);
|
|
const stats = await fs.stat(filePath);
|
|
|
|
if (stats.isDirectory()) {
|
|
// Use rm with recursive option for directories
|
|
await fs.rm(filePath, { recursive: true, force: true });
|
|
res.json({ success: true, message: `Untracked directory ${file} deleted successfully` });
|
|
} else {
|
|
await fs.unlink(filePath);
|
|
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; |