Files
claudecodeui/server/routes/git.js

388 lines
11 KiB
JavaScript
Executable File

const express = require('express');
const { exec } = require('child_process');
const { promisify } = require('util');
const path = require('path');
const fs = require('fs').promises;
const router = express.Router();
const execAsync = promisify(exec);
// Helper function to get the actual project path from the encoded project name
function getActualProjectPath(projectName) {
// Claude stores projects with dashes instead of slashes
// Convert "-Users-dmieloch-Dev-experiments-claudecodeui" to "/Users/dmieloch/Dev/experiments/claudecodeui"
return projectName.replace(/-/g, '/');
}
// 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 = getActualProjectPath(project);
console.log('Git status for project:', project, '-> path:', projectPath);
// Check if directory exists
try {
await fs.access(projectPath);
} catch {
console.error('Project path not found:', projectPath);
return res.json({ error: 'Project not found' });
}
// Check if it's a git repository
try {
await execAsync('git rev-parse --git-dir', { cwd: projectPath });
} catch {
console.error('Not a git repository:', projectPath);
return res.json({ error: 'Not a git repository' });
}
// 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 });
}
});
// 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 = getActualProjectPath(project);
// Check if file is untracked
const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
const isUntracked = statusOutput.startsWith('??');
let diff;
if (isUntracked) {
// For untracked files, show the entire file content as additions
const fileContent = await fs.readFile(path.join(projectPath, file), 'utf-8');
const lines = fileContent.split('\n');
diff = `--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${lines.length} @@\n` +
lines.map(line => `+${line}`).join('\n');
} else {
// Get diff for tracked files
const { stdout } = await execAsync(`git diff HEAD -- "${file}"`, { cwd: projectPath });
diff = stdout || '';
// If no unstaged changes, check for staged changes
if (!diff) {
const { stdout: stagedDiff } = await execAsync(`git diff --cached -- "${file}"`, { cwd: projectPath });
diff = stagedDiff;
}
}
res.json({ diff });
} catch (error) {
console.error('Git diff error:', error);
res.json({ error: error.message });
}
});
// Commit changes
router.post('/commit', async (req, res) => {
const { project, message, files } = req.body;
if (!project || !message || !files || files.length === 0) {
return res.status(400).json({ error: 'Project name, commit message, and files are required' });
}
try {
const projectPath = getActualProjectPath(project);
// 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 = getActualProjectPath(project);
console.log('Git branches for project:', project, '-> path:', 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 = 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 = 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 = 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 = getActualProjectPath(project);
// Get diff for the commit
const { stdout } = await execAsync(
`git show ${commit}`,
{ cwd: projectPath }
);
res.json({ diff: stdout });
} catch (error) {
console.error('Git commit diff error:', error);
res.json({ error: error.message });
}
});
// Generate commit message based on staged changes
router.post('/generate-commit-message', async (req, res) => {
const { project, files } = req.body;
if (!project || !files || files.length === 0) {
return res.status(400).json({ error: 'Project name and files are required' });
}
try {
const projectPath = getActualProjectPath(project);
// Get diff for selected files
let combinedDiff = '';
for (const file of files) {
try {
const { stdout } = await execAsync(
`git diff HEAD -- "${file}"`,
{ cwd: projectPath }
);
if (stdout) {
combinedDiff += `\n--- ${file} ---\n${stdout}`;
}
} catch (error) {
console.error(`Error getting diff for ${file}:`, error);
}
}
// Use AI to generate commit message (simple implementation)
// In a real implementation, you might want to use GPT or Claude API
const message = generateSimpleCommitMessage(files, combinedDiff);
res.json({ message });
} catch (error) {
console.error('Generate commit message error:', error);
res.status(500).json({ error: error.message });
}
});
// Simple commit message generator (can be replaced with AI)
function generateSimpleCommitMessage(files, diff) {
const fileCount = files.length;
const isMultipleFiles = fileCount > 1;
// Analyze the diff to determine the type of change
const additions = (diff.match(/^\+[^+]/gm) || []).length;
const deletions = (diff.match(/^-[^-]/gm) || []).length;
// Determine the primary action
let action = 'Update';
if (additions > 0 && deletions === 0) {
action = 'Add';
} else if (deletions > 0 && additions === 0) {
action = 'Remove';
} else if (additions > deletions * 2) {
action = 'Enhance';
} else if (deletions > additions * 2) {
action = 'Refactor';
}
// Generate message based on files
if (isMultipleFiles) {
const components = new Set(files.map(f => {
const parts = f.split('/');
return parts[parts.length - 2] || parts[0];
}));
if (components.size === 1) {
return `${action} ${[...components][0]} component`;
} else {
return `${action} multiple components`;
}
} else {
const fileName = files[0].split('/').pop();
const componentName = fileName.replace(/\.(jsx?|tsx?|css|scss)$/, '');
return `${action} ${componentName}`;
}
}
module.exports = router;