Files
claudecodeui/server/routes/git.js
Haileyesus 68787e01fc fix(git): resolve file paths against repo root for paths with spaces
Fix path resolution for git file operations when project directories include spaces
or when API calls are issued from subdirectories inside a repository.

Problem
- operations like commit/discard/diff could receive file paths that were valid from
  repo root but were executed from a nested cwd
- this produced pathspec errors like:
  - warning: could not open directory '4/4/'
  - fatal: pathspec '4/hello_world.ts' did not match any files

Root cause
- file arguments were passed directly to git commands using the project cwd
- inconsistent path forms (repo-root-relative vs cwd-relative) were not normalized

Changes
- remove unsafe fallback decode in `getActualProjectPath`; fail explicitly when the
  real project path cannot be resolved
- add repository/file-path helpers:
  - `getRepositoryRootPath`
  - `normalizeRepositoryRelativeFilePath`
  - `parseStatusFilePaths`
  - `buildFilePathCandidates`
  - `resolveRepositoryFilePath`
- update file-based git endpoints to resolve paths before executing commands:
  - GET `/diff`
  - GET `/file-with-diff`
  - POST `/commit`
  - POST `/generate-commit-message`
  - POST `/discard`
  - POST `/delete-untracked`
- stage/restore/reset operations now use `--` before pathspecs for safer argument
  separation

Behavioral impact
- git operations now work reliably for repositories under directories containing spaces
- file operations are consistent even when project cwd is a subdirectory of repo root
- endpoint responses continue to preserve existing payload shapes

Verification
- syntax check: `node --check server/routes/git.js`
- typecheck: `npm run typecheck`
- reproduced failing scenario in a temp path with spaces; confirmed root-resolved
  path staging succeeds where subdir-cwd pathspec previously failed
2026-03-10 23:14:28 +03:00

1428 lines
47 KiB
JavaScript
Executable File

import express from 'express';
import { spawn } from 'child_process';
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();
function spawnAsync(command, args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
...options,
shell: false,
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => {
stdout += data.toString();
});
child.stderr.on('data', (data) => {
stderr += data.toString();
});
child.on('error', (error) => {
reject(error);
});
child.on('close', (code) => {
if (code === 0) {
resolve({ stdout, stderr });
return;
}
const error = new Error(`Command failed: ${command} ${args.join(' ')}`);
error.code = code;
error.stdout = stdout;
error.stderr = stderr;
reject(error);
});
});
}
// Input validation helpers (defense-in-depth)
function validateCommitRef(commit) {
// Allow hex hashes, HEAD, HEAD~N, HEAD^N, tag names, branch names
if (!/^[a-zA-Z0-9._~^{}@\/-]+$/.test(commit)) {
throw new Error('Invalid commit reference');
}
return commit;
}
function validateBranchName(branch) {
if (!/^[a-zA-Z0-9._\/-]+$/.test(branch)) {
throw new Error('Invalid branch name');
}
return branch;
}
function validateFilePath(file) {
if (!file || file.includes('\0')) {
throw new Error('Invalid file path');
}
return file;
}
function validateRemoteName(remote) {
if (!/^[a-zA-Z0-9._-]+$/.test(remote)) {
throw new Error('Invalid remote name');
}
return remote;
}
// 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);
throw new Error(`Unable to resolve project path for "${projectName}"`);
}
}
// 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 {
// Allow any directory that is inside a work tree (repo root or nested folder).
const { stdout: insideWorkTreeOutput } = await spawnAsync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: projectPath });
const isInsideWorkTree = insideWorkTreeOutput.trim() === 'true';
if (!isInsideWorkTree) {
throw new Error('Not inside a git work tree');
}
// Ensure git can resolve the repository root for this directory.
await spawnAsync('git', ['rev-parse', '--show-toplevel'], { cwd: projectPath });
} catch {
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.');
}
}
function getGitErrorDetails(error) {
return `${error?.message || ''} ${error?.stderr || ''} ${error?.stdout || ''}`;
}
function isMissingHeadRevisionError(error) {
const errorDetails = getGitErrorDetails(error).toLowerCase();
return errorDetails.includes('unknown revision')
|| errorDetails.includes('ambiguous argument')
|| errorDetails.includes('needed a single revision')
|| errorDetails.includes('bad revision');
}
async function getCurrentBranchName(projectPath) {
try {
// symbolic-ref works even when the repository has no commits.
const { stdout } = await spawnAsync('git', ['symbolic-ref', '--short', 'HEAD'], { cwd: projectPath });
const branchName = stdout.trim();
if (branchName) {
return branchName;
}
} catch (error) {
// Fall back to rev-parse for detached HEAD and older git edge cases.
}
const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
return stdout.trim();
}
async function repositoryHasCommits(projectPath) {
try {
await spawnAsync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: projectPath });
return true;
} catch (error) {
if (isMissingHeadRevisionError(error)) {
return false;
}
throw error;
}
}
async function getRepositoryRootPath(projectPath) {
const { stdout } = await spawnAsync('git', ['rev-parse', '--show-toplevel'], { cwd: projectPath });
return stdout.trim();
}
function normalizeRepositoryRelativeFilePath(filePath) {
return String(filePath)
.replace(/\\/g, '/')
.replace(/^\.\/+/, '')
.replace(/^\/+/, '')
.trim();
}
function parseStatusFilePaths(statusOutput) {
return statusOutput
.split('\n')
.map((line) => line.trimEnd())
.filter((line) => line.trim())
.map((line) => {
const statusPath = line.substring(3);
const renamedFilePath = statusPath.split(' -> ')[1];
return normalizeRepositoryRelativeFilePath(renamedFilePath || statusPath);
})
.filter(Boolean);
}
function buildFilePathCandidates(projectPath, repositoryRootPath, filePath) {
const normalizedFilePath = normalizeRepositoryRelativeFilePath(filePath);
const projectRelativePath = normalizeRepositoryRelativeFilePath(path.relative(repositoryRootPath, projectPath));
const candidates = [normalizedFilePath];
if (
projectRelativePath
&& projectRelativePath !== '.'
&& !normalizedFilePath.startsWith(`${projectRelativePath}/`)
) {
candidates.push(`${projectRelativePath}/${normalizedFilePath}`);
}
return Array.from(new Set(candidates.filter(Boolean)));
}
async function resolveRepositoryFilePath(projectPath, filePath) {
validateFilePath(filePath);
const repositoryRootPath = await getRepositoryRootPath(projectPath);
const candidateFilePaths = buildFilePathCandidates(projectPath, repositoryRootPath, filePath);
for (const candidateFilePath of candidateFilePaths) {
const { stdout } = await spawnAsync('git', ['status', '--porcelain', '--', candidateFilePath], { cwd: repositoryRootPath });
if (stdout.trim()) {
return {
repositoryRootPath,
repositoryRelativeFilePath: candidateFilePath,
};
}
}
// If the caller sent a bare filename (e.g. "hello.ts"), recover it from changed files.
const normalizedFilePath = normalizeRepositoryRelativeFilePath(filePath);
if (!normalizedFilePath.includes('/')) {
const { stdout: repositoryStatusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: repositoryRootPath });
const changedFilePaths = parseStatusFilePaths(repositoryStatusOutput);
const suffixMatches = changedFilePaths.filter(
(changedFilePath) => changedFilePath === normalizedFilePath || changedFilePath.endsWith(`/${normalizedFilePath}`),
);
if (suffixMatches.length === 1) {
return {
repositoryRootPath,
repositoryRelativeFilePath: suffixMatches[0],
};
}
}
return {
repositoryRootPath,
repositoryRelativeFilePath: candidateFilePaths[0],
};
}
// 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);
const branch = await getCurrentBranchName(projectPath);
const hasCommits = await repositoryHasCommits(projectPath);
// Get git status
const { stdout: statusOutput } = await spawnAsync('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);
const {
repositoryRootPath,
repositoryRelativeFilePath,
} = await resolveRepositoryFilePath(projectPath, file);
// Check if file is untracked or deleted
const { stdout: statusOutput } = await spawnAsync(
'git',
['status', '--porcelain', '--', repositoryRelativeFilePath],
{ cwd: repositoryRootPath },
);
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(repositoryRootPath, repositoryRelativeFilePath);
const stats = await fs.stat(filePath);
if (stats.isDirectory()) {
// For directories, show a simple message
diff = `Directory: ${repositoryRelativeFilePath}\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/${repositoryRelativeFilePath}\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 spawnAsync(
'git',
['show', `HEAD:${repositoryRelativeFilePath}`],
{ cwd: repositoryRootPath },
);
const lines = fileContent.split('\n');
diff = `--- a/${repositoryRelativeFilePath}\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 spawnAsync(
'git',
['diff', '--', repositoryRelativeFilePath],
{ cwd: repositoryRootPath },
);
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 spawnAsync(
'git',
['diff', '--cached', '--', repositoryRelativeFilePath],
{ cwd: repositoryRootPath },
);
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);
const {
repositoryRootPath,
repositoryRelativeFilePath,
} = await resolveRepositoryFilePath(projectPath, file);
// Check file status
const { stdout: statusOutput } = await spawnAsync(
'git',
['status', '--porcelain', '--', repositoryRelativeFilePath],
{ cwd: repositoryRootPath },
);
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 spawnAsync(
'git',
['show', `HEAD:${repositoryRelativeFilePath}`],
{ cwd: repositoryRootPath },
);
oldContent = headContent;
currentContent = headContent; // Show the deleted content in editor
} else {
// Get current file content
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
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 spawnAsync(
'git',
['show', `HEAD:${repositoryRelativeFilePath}`],
{ cwd: repositoryRootPath },
);
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 spawnAsync('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 spawnAsync('git', ['add', '.'], { cwd: projectPath });
// Create initial commit
const { stdout } = await spawnAsync('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);
const repositoryRootPath = await getRepositoryRootPath(projectPath);
// Stage selected files
for (const file of files) {
const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
await spawnAsync('git', ['add', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
}
// Commit with message
const { stdout } = await spawnAsync('git', ['commit', '-m', message], { cwd: repositoryRootPath });
res.json({ success: true, output: stdout });
} catch (error) {
console.error('Git commit error:', error);
res.status(500).json({ error: error.message });
}
});
// Revert latest local commit (keeps changes staged)
router.post('/revert-local-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);
await validateGitRepository(projectPath);
try {
await spawnAsync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: projectPath });
} catch (error) {
return res.status(400).json({
error: 'No local commit to revert',
details: 'This repository has no commit yet.',
});
}
try {
// Soft reset rewinds one commit while preserving all file changes in the index.
await spawnAsync('git', ['reset', '--soft', 'HEAD~1'], { cwd: projectPath });
} catch (error) {
const errorDetails = `${error.stderr || ''} ${error.message || ''}`;
const isInitialCommit = errorDetails.includes('HEAD~1') &&
(errorDetails.includes('unknown revision') || errorDetails.includes('ambiguous argument'));
if (!isInitialCommit) {
throw error;
}
// Initial commit has no parent; deleting HEAD uncommits it and keeps files staged.
await spawnAsync('git', ['update-ref', '-d', 'HEAD'], { cwd: projectPath });
}
res.json({
success: true,
output: 'Latest local commit reverted successfully. Changes were kept staged.',
});
} catch (error) {
console.error('Git revert local 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 spawnAsync('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
validateBranchName(branch);
const { stdout } = await spawnAsync('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
validateBranchName(branch);
const { stdout } = await spawnAsync('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);
await validateGitRepository(projectPath);
const parsedLimit = Number.parseInt(String(limit), 10);
const safeLimit = Number.isFinite(parsedLimit) && parsedLimit > 0
? Math.min(parsedLimit, 100)
: 10;
// Get commit log with stats
const { stdout } = await spawnAsync(
'git',
['log', '--pretty=format:%H|%an|%ae|%ad|%s', '--date=relative', '-n', String(safeLimit)],
{ 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 spawnAsync(
'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);
// Validate commit reference (defense-in-depth)
validateCommitRef(commit);
// Get diff for the commit
const { stdout } = await spawnAsync(
'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);
await validateGitRepository(projectPath);
const repositoryRootPath = await getRepositoryRootPath(projectPath);
// Get diff for selected files
let diffContext = '';
for (const file of files) {
try {
const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
const { stdout } = await spawnAsync(
'git', ['diff', 'HEAD', '--', repositoryRelativeFilePath],
{ cwd: repositoryRootPath }
);
if (stdout) {
diffContext += `\n--- ${repositoryRelativeFilePath} ---\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 { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
const stats = await fs.stat(filePath);
if (!stats.isDirectory()) {
const content = await fs.readFile(filePath, 'utf-8');
diffContext += `\n--- ${repositoryRelativeFilePath} (new file) ---\n${content.substring(0, 1000)}\n`;
} else {
diffContext += `\n--- ${repositoryRelativeFilePath} (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);
const branch = await getCurrentBranchName(projectPath);
const hasCommits = await repositoryHasCommits(projectPath);
const { stdout: remoteOutput } = await spawnAsync('git', ['remote'], { cwd: projectPath });
const remotes = remoteOutput.trim().split('\n').filter(r => r.trim());
const hasRemote = remotes.length > 0;
const fallbackRemoteName = hasRemote
? (remotes.includes('origin') ? 'origin' : remotes[0])
: null;
// Repositories initialized with `git init` can have a branch but no commits.
// Return a non-error state so the UI can show the initial-commit workflow.
if (!hasCommits) {
return res.json({
hasRemote,
hasUpstream: false,
branch,
remoteName: fallbackRemoteName,
ahead: 0,
behind: 0,
isUpToDate: false,
message: 'Repository has no commits yet'
});
}
// Check if there's a remote tracking branch (smart detection)
let trackingBranch;
let remoteName;
try {
const { stdout } = await spawnAsync('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) {
return res.json({
hasRemote,
hasUpstream: false,
branch,
remoteName: fallbackRemoteName,
message: 'No remote tracking branch configured'
});
}
// Get ahead/behind counts
const { stdout: countOutput } = await spawnAsync(
'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 branch = await getCurrentBranchName(projectPath);
let remoteName = 'origin'; // fallback
try {
const { stdout } = await spawnAsync('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');
}
validateRemoteName(remoteName);
const { stdout } = await spawnAsync('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 branch = await getCurrentBranchName(projectPath);
let remoteName = 'origin'; // fallback
let remoteBranch = branch; // fallback
try {
const { stdout } = await spawnAsync('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');
}
validateRemoteName(remoteName);
validateBranchName(remoteBranch);
const { stdout } = await spawnAsync('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 branch = await getCurrentBranchName(projectPath);
let remoteName = 'origin'; // fallback
let remoteBranch = branch; // fallback
try {
const { stdout } = await spawnAsync('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');
}
validateRemoteName(remoteName);
validateBranchName(remoteBranch);
const { stdout } = await spawnAsync('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);
// Validate branch name
validateBranchName(branch);
// Get current branch to verify it matches the requested branch
const currentBranchName = await getCurrentBranchName(projectPath);
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 spawnAsync('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)
validateRemoteName(remoteName);
const { stdout } = await spawnAsync('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);
const {
repositoryRootPath,
repositoryRelativeFilePath,
} = await resolveRepositoryFilePath(projectPath, file);
// Check file status to determine correct discard command
const { stdout: statusOutput } = await spawnAsync(
'git',
['status', '--porcelain', '--', repositoryRelativeFilePath],
{ cwd: repositoryRootPath },
);
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(repositoryRootPath, repositoryRelativeFilePath);
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 spawnAsync('git', ['restore', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
} else if (status.includes('A')) {
// Added file - unstage it
await spawnAsync('git', ['reset', 'HEAD', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
}
res.json({ success: true, message: `Changes discarded for ${repositoryRelativeFilePath}` });
} 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);
const {
repositoryRootPath,
repositoryRelativeFilePath,
} = await resolveRepositoryFilePath(projectPath, file);
// Check if file is actually untracked
const { stdout: statusOutput } = await spawnAsync(
'git',
['status', '--porcelain', '--', repositoryRelativeFilePath],
{ cwd: repositoryRootPath },
);
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(repositoryRootPath, repositoryRelativeFilePath);
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 ${repositoryRelativeFilePath} deleted successfully` });
} else {
await fs.unlink(filePath);
res.json({ success: true, message: `Untracked file ${repositoryRelativeFilePath} deleted successfully` });
}
} catch (error) {
console.error('Git delete untracked error:', error);
res.status(500).json({ error: error.message });
}
});
export default router;