| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483 |
- 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 { runChatViaGateway } from '../pilotdeck-bridge.js';
- const router = express.Router();
- const COMMIT_DIFF_CHARACTER_LIMIT = 500_000;
- 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, projectPath) {
- if (!file || file.includes('\0')) {
- throw new Error('Invalid file path');
- }
- // Prevent path traversal: resolve the file relative to the project root
- // and ensure the result stays within the project directory
- if (projectPath) {
- const resolved = path.resolve(projectPath, file);
- const normalizedRoot = path.resolve(projectPath) + path.sep;
- if (!resolved.startsWith(normalizedRoot) && resolved !== path.resolve(projectPath)) {
- throw new Error('Invalid file path: path traversal detected');
- }
- }
- return file;
- }
- function validateRemoteName(remote) {
- if (!/^[a-zA-Z0-9._-]+$/.test(remote)) {
- throw new Error('Invalid remote name');
- }
- return remote;
- }
- function validateProjectPath(projectPath) {
- if (!projectPath || projectPath.includes('\0')) {
- throw new Error('Invalid project path');
- }
- const resolved = path.resolve(projectPath);
- // Must be an absolute path after resolution
- if (!path.isAbsolute(resolved)) {
- throw new Error('Invalid project path: must be absolute');
- }
- // Block obviously dangerous paths
- if (resolved === '/' || resolved === path.sep) {
- throw new Error('Invalid project path: root directory not allowed');
- }
- return resolved;
- }
- // Helper function to get the actual project path from the encoded project name
- async function getActualProjectPath(projectName) {
- let projectPath;
- try {
- projectPath = await extractProjectDirectory(projectName);
- } catch (error) {
- console.error(`Error extracting project directory for ${projectName}:`, error);
- throw new Error(`Unable to resolve project path for "${projectName}"`);
- }
- return validateProjectPath(projectPath);
- }
- // 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 });
- const rawLines = stdout
- .split('\n')
- .map(b => b.trim())
- .filter(b => b && !b.includes('->'));
- // Local branches (may start with '* ' for current)
- const localBranches = rawLines
- .filter(b => !b.startsWith('remotes/'))
- .map(b => (b.startsWith('* ') ? b.substring(2) : b));
- // Remote branches — strip 'remotes/<remote>/' prefix
- const remoteBranches = rawLines
- .filter(b => b.startsWith('remotes/'))
- .map(b => b.replace(/^remotes\/[^/]+\//, ''))
- .filter(name => !localBranches.includes(name)); // skip if already a local branch
- // Backward-compat flat list (local + unique remotes, deduplicated)
- const branches = [...localBranches, ...remoteBranches]
- .filter((b, i, arr) => arr.indexOf(b) === i);
- res.json({ branches, localBranches, remoteBranches });
- } 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 });
- }
- });
- // Delete a local branch
- router.post('/delete-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);
- await validateGitRepository(projectPath);
- // Safety: cannot delete the currently checked-out branch
- const { stdout: currentBranch } = await spawnAsync('git', ['branch', '--show-current'], { cwd: projectPath });
- if (currentBranch.trim() === branch) {
- return res.status(400).json({ error: 'Cannot delete the currently checked-out branch' });
- }
- const { stdout } = await spawnAsync('git', ['branch', '-d', branch], { cwd: projectPath });
- res.json({ success: true, output: stdout });
- } catch (error) {
- console.error('Git delete 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=iso-strict', '-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 }
- );
- const isTruncated = stdout.length > COMMIT_DIFF_CHARACTER_LIMIT;
- const diff = isTruncated
- ? `${stdout.slice(0, COMMIT_DIFF_CHARACTER_LIMIT)}\n\n... Diff truncated to keep the UI responsive ...`
- : stdout;
- res.json({ diff, isTruncated });
- } 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 = 'pilotdeck' } = req.body;
- if (!project || !files || files.length === 0) {
- return res.status(400).json({ error: 'Project name and files are required' });
- }
- // Validate provider
- if (!['claude', 'pilotdeck', 'cursor'].includes(provider)) {
- return res.status(400).json({ error: 'provider must be "claude", "pilotdeck" 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 });
- }
- });
- /**
- * @param {Array<string>} files - List of changed files
- * @param {string} diffContext - Git diff content
- * @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);
- if ((parsed.type === 'claude-response' || parsed.type === 'pilotdeck-response') && parsed.data) {
- const message = parsed.data.message || parsed.data;
- console.log('📦 PilotDeck 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);
- // All providers route through the PilotDeck gateway. The `provider`
- // value is kept only as a label in the resulting message frames.
- await runChatViaGateway(
- prompt,
- {
- cwd: projectPath,
- projectPath,
- permissionMode: 'bypassPermissions',
- model: provider === 'cursor' ? undefined : 'sonnet',
- },
- writer,
- provider || 'pilotdeck',
- );
- 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;
|