git.js 49 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483
  1. import express from 'express';
  2. import { spawn } from 'child_process';
  3. import path from 'path';
  4. import { promises as fs } from 'fs';
  5. import { extractProjectDirectory } from '../projects.js';
  6. import { runChatViaGateway } from '../pilotdeck-bridge.js';
  7. const router = express.Router();
  8. const COMMIT_DIFF_CHARACTER_LIMIT = 500_000;
  9. function spawnAsync(command, args, options = {}) {
  10. return new Promise((resolve, reject) => {
  11. const child = spawn(command, args, {
  12. ...options,
  13. shell: false,
  14. });
  15. let stdout = '';
  16. let stderr = '';
  17. child.stdout.on('data', (data) => {
  18. stdout += data.toString();
  19. });
  20. child.stderr.on('data', (data) => {
  21. stderr += data.toString();
  22. });
  23. child.on('error', (error) => {
  24. reject(error);
  25. });
  26. child.on('close', (code) => {
  27. if (code === 0) {
  28. resolve({ stdout, stderr });
  29. return;
  30. }
  31. const error = new Error(`Command failed: ${command} ${args.join(' ')}`);
  32. error.code = code;
  33. error.stdout = stdout;
  34. error.stderr = stderr;
  35. reject(error);
  36. });
  37. });
  38. }
  39. // Input validation helpers (defense-in-depth)
  40. function validateCommitRef(commit) {
  41. // Allow hex hashes, HEAD, HEAD~N, HEAD^N, tag names, branch names
  42. if (!/^[a-zA-Z0-9._~^{}@\/-]+$/.test(commit)) {
  43. throw new Error('Invalid commit reference');
  44. }
  45. return commit;
  46. }
  47. function validateBranchName(branch) {
  48. if (!/^[a-zA-Z0-9._\/-]+$/.test(branch)) {
  49. throw new Error('Invalid branch name');
  50. }
  51. return branch;
  52. }
  53. function validateFilePath(file, projectPath) {
  54. if (!file || file.includes('\0')) {
  55. throw new Error('Invalid file path');
  56. }
  57. // Prevent path traversal: resolve the file relative to the project root
  58. // and ensure the result stays within the project directory
  59. if (projectPath) {
  60. const resolved = path.resolve(projectPath, file);
  61. const normalizedRoot = path.resolve(projectPath) + path.sep;
  62. if (!resolved.startsWith(normalizedRoot) && resolved !== path.resolve(projectPath)) {
  63. throw new Error('Invalid file path: path traversal detected');
  64. }
  65. }
  66. return file;
  67. }
  68. function validateRemoteName(remote) {
  69. if (!/^[a-zA-Z0-9._-]+$/.test(remote)) {
  70. throw new Error('Invalid remote name');
  71. }
  72. return remote;
  73. }
  74. function validateProjectPath(projectPath) {
  75. if (!projectPath || projectPath.includes('\0')) {
  76. throw new Error('Invalid project path');
  77. }
  78. const resolved = path.resolve(projectPath);
  79. // Must be an absolute path after resolution
  80. if (!path.isAbsolute(resolved)) {
  81. throw new Error('Invalid project path: must be absolute');
  82. }
  83. // Block obviously dangerous paths
  84. if (resolved === '/' || resolved === path.sep) {
  85. throw new Error('Invalid project path: root directory not allowed');
  86. }
  87. return resolved;
  88. }
  89. // Helper function to get the actual project path from the encoded project name
  90. async function getActualProjectPath(projectName) {
  91. let projectPath;
  92. try {
  93. projectPath = await extractProjectDirectory(projectName);
  94. } catch (error) {
  95. console.error(`Error extracting project directory for ${projectName}:`, error);
  96. throw new Error(`Unable to resolve project path for "${projectName}"`);
  97. }
  98. return validateProjectPath(projectPath);
  99. }
  100. // Helper function to strip git diff headers
  101. function stripDiffHeaders(diff) {
  102. if (!diff) return '';
  103. const lines = diff.split('\n');
  104. const filteredLines = [];
  105. let startIncluding = false;
  106. for (const line of lines) {
  107. // Skip all header lines including diff --git, index, file mode, and --- / +++ file paths
  108. if (line.startsWith('diff --git') ||
  109. line.startsWith('index ') ||
  110. line.startsWith('new file mode') ||
  111. line.startsWith('deleted file mode') ||
  112. line.startsWith('---') ||
  113. line.startsWith('+++')) {
  114. continue;
  115. }
  116. // Start including lines from @@ hunk headers onwards
  117. if (line.startsWith('@@') || startIncluding) {
  118. startIncluding = true;
  119. filteredLines.push(line);
  120. }
  121. }
  122. return filteredLines.join('\n');
  123. }
  124. // Helper function to validate git repository
  125. async function validateGitRepository(projectPath) {
  126. try {
  127. // Check if directory exists
  128. await fs.access(projectPath);
  129. } catch {
  130. throw new Error(`Project path not found: ${projectPath}`);
  131. }
  132. try {
  133. // Allow any directory that is inside a work tree (repo root or nested folder).
  134. const { stdout: insideWorkTreeOutput } = await spawnAsync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: projectPath });
  135. const isInsideWorkTree = insideWorkTreeOutput.trim() === 'true';
  136. if (!isInsideWorkTree) {
  137. throw new Error('Not inside a git work tree');
  138. }
  139. // Ensure git can resolve the repository root for this directory.
  140. await spawnAsync('git', ['rev-parse', '--show-toplevel'], { cwd: projectPath });
  141. } catch {
  142. 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.');
  143. }
  144. }
  145. function getGitErrorDetails(error) {
  146. return `${error?.message || ''} ${error?.stderr || ''} ${error?.stdout || ''}`;
  147. }
  148. function isMissingHeadRevisionError(error) {
  149. const errorDetails = getGitErrorDetails(error).toLowerCase();
  150. return errorDetails.includes('unknown revision')
  151. || errorDetails.includes('ambiguous argument')
  152. || errorDetails.includes('needed a single revision')
  153. || errorDetails.includes('bad revision');
  154. }
  155. async function getCurrentBranchName(projectPath) {
  156. try {
  157. // symbolic-ref works even when the repository has no commits.
  158. const { stdout } = await spawnAsync('git', ['symbolic-ref', '--short', 'HEAD'], { cwd: projectPath });
  159. const branchName = stdout.trim();
  160. if (branchName) {
  161. return branchName;
  162. }
  163. } catch (error) {
  164. // Fall back to rev-parse for detached HEAD and older git edge cases.
  165. }
  166. const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
  167. return stdout.trim();
  168. }
  169. async function repositoryHasCommits(projectPath) {
  170. try {
  171. await spawnAsync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: projectPath });
  172. return true;
  173. } catch (error) {
  174. if (isMissingHeadRevisionError(error)) {
  175. return false;
  176. }
  177. throw error;
  178. }
  179. }
  180. async function getRepositoryRootPath(projectPath) {
  181. const { stdout } = await spawnAsync('git', ['rev-parse', '--show-toplevel'], { cwd: projectPath });
  182. return stdout.trim();
  183. }
  184. function normalizeRepositoryRelativeFilePath(filePath) {
  185. return String(filePath)
  186. .replace(/\\/g, '/')
  187. .replace(/^\.\/+/, '')
  188. .replace(/^\/+/, '')
  189. .trim();
  190. }
  191. function parseStatusFilePaths(statusOutput) {
  192. return statusOutput
  193. .split('\n')
  194. .map((line) => line.trimEnd())
  195. .filter((line) => line.trim())
  196. .map((line) => {
  197. const statusPath = line.substring(3);
  198. const renamedFilePath = statusPath.split(' -> ')[1];
  199. return normalizeRepositoryRelativeFilePath(renamedFilePath || statusPath);
  200. })
  201. .filter(Boolean);
  202. }
  203. function buildFilePathCandidates(projectPath, repositoryRootPath, filePath) {
  204. const normalizedFilePath = normalizeRepositoryRelativeFilePath(filePath);
  205. const projectRelativePath = normalizeRepositoryRelativeFilePath(path.relative(repositoryRootPath, projectPath));
  206. const candidates = [normalizedFilePath];
  207. if (
  208. projectRelativePath
  209. && projectRelativePath !== '.'
  210. && !normalizedFilePath.startsWith(`${projectRelativePath}/`)
  211. ) {
  212. candidates.push(`${projectRelativePath}/${normalizedFilePath}`);
  213. }
  214. return Array.from(new Set(candidates.filter(Boolean)));
  215. }
  216. async function resolveRepositoryFilePath(projectPath, filePath) {
  217. validateFilePath(filePath);
  218. const repositoryRootPath = await getRepositoryRootPath(projectPath);
  219. const candidateFilePaths = buildFilePathCandidates(projectPath, repositoryRootPath, filePath);
  220. for (const candidateFilePath of candidateFilePaths) {
  221. const { stdout } = await spawnAsync('git', ['status', '--porcelain', '--', candidateFilePath], { cwd: repositoryRootPath });
  222. if (stdout.trim()) {
  223. return {
  224. repositoryRootPath,
  225. repositoryRelativeFilePath: candidateFilePath,
  226. };
  227. }
  228. }
  229. // If the caller sent a bare filename (e.g. "hello.ts"), recover it from changed files.
  230. const normalizedFilePath = normalizeRepositoryRelativeFilePath(filePath);
  231. if (!normalizedFilePath.includes('/')) {
  232. const { stdout: repositoryStatusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: repositoryRootPath });
  233. const changedFilePaths = parseStatusFilePaths(repositoryStatusOutput);
  234. const suffixMatches = changedFilePaths.filter(
  235. (changedFilePath) => changedFilePath === normalizedFilePath || changedFilePath.endsWith(`/${normalizedFilePath}`),
  236. );
  237. if (suffixMatches.length === 1) {
  238. return {
  239. repositoryRootPath,
  240. repositoryRelativeFilePath: suffixMatches[0],
  241. };
  242. }
  243. }
  244. return {
  245. repositoryRootPath,
  246. repositoryRelativeFilePath: candidateFilePaths[0],
  247. };
  248. }
  249. // Get git status for a project
  250. router.get('/status', async (req, res) => {
  251. const { project } = req.query;
  252. if (!project) {
  253. return res.status(400).json({ error: 'Project name is required' });
  254. }
  255. try {
  256. const projectPath = await getActualProjectPath(project);
  257. // Validate git repository
  258. await validateGitRepository(projectPath);
  259. const branch = await getCurrentBranchName(projectPath);
  260. const hasCommits = await repositoryHasCommits(projectPath);
  261. // Get git status
  262. const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: projectPath });
  263. const modified = [];
  264. const added = [];
  265. const deleted = [];
  266. const untracked = [];
  267. statusOutput.split('\n').forEach(line => {
  268. if (!line.trim()) return;
  269. const status = line.substring(0, 2);
  270. const file = line.substring(3);
  271. if (status === 'M ' || status === ' M' || status === 'MM') {
  272. modified.push(file);
  273. } else if (status === 'A ' || status === 'AM') {
  274. added.push(file);
  275. } else if (status === 'D ' || status === ' D') {
  276. deleted.push(file);
  277. } else if (status === '??') {
  278. untracked.push(file);
  279. }
  280. });
  281. res.json({
  282. branch,
  283. hasCommits,
  284. modified,
  285. added,
  286. deleted,
  287. untracked
  288. });
  289. } catch (error) {
  290. console.error('Git status error:', error);
  291. res.json({
  292. error: error.message.includes('not a git repository') || error.message.includes('Project directory is not a git repository')
  293. ? error.message
  294. : 'Git operation failed',
  295. details: error.message.includes('not a git repository') || error.message.includes('Project directory is not a git repository')
  296. ? error.message
  297. : `Failed to get git status: ${error.message}`
  298. });
  299. }
  300. });
  301. // Get diff for a specific file
  302. router.get('/diff', async (req, res) => {
  303. const { project, file } = req.query;
  304. if (!project || !file) {
  305. return res.status(400).json({ error: 'Project name and file path are required' });
  306. }
  307. try {
  308. const projectPath = await getActualProjectPath(project);
  309. // Validate git repository
  310. await validateGitRepository(projectPath);
  311. const {
  312. repositoryRootPath,
  313. repositoryRelativeFilePath,
  314. } = await resolveRepositoryFilePath(projectPath, file);
  315. // Check if file is untracked or deleted
  316. const { stdout: statusOutput } = await spawnAsync(
  317. 'git',
  318. ['status', '--porcelain', '--', repositoryRelativeFilePath],
  319. { cwd: repositoryRootPath },
  320. );
  321. const isUntracked = statusOutput.startsWith('??');
  322. const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
  323. let diff;
  324. if (isUntracked) {
  325. // For untracked files, show the entire file content as additions
  326. const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
  327. const stats = await fs.stat(filePath);
  328. if (stats.isDirectory()) {
  329. // For directories, show a simple message
  330. diff = `Directory: ${repositoryRelativeFilePath}\n(Cannot show diff for directories)`;
  331. } else {
  332. const fileContent = await fs.readFile(filePath, 'utf-8');
  333. const lines = fileContent.split('\n');
  334. diff = `--- /dev/null\n+++ b/${repositoryRelativeFilePath}\n@@ -0,0 +1,${lines.length} @@\n` +
  335. lines.map(line => `+${line}`).join('\n');
  336. }
  337. } else if (isDeleted) {
  338. // For deleted files, show the entire file content from HEAD as deletions
  339. const { stdout: fileContent } = await spawnAsync(
  340. 'git',
  341. ['show', `HEAD:${repositoryRelativeFilePath}`],
  342. { cwd: repositoryRootPath },
  343. );
  344. const lines = fileContent.split('\n');
  345. diff = `--- a/${repositoryRelativeFilePath}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n` +
  346. lines.map(line => `-${line}`).join('\n');
  347. } else {
  348. // Get diff for tracked files
  349. // First check for unstaged changes (working tree vs index)
  350. const { stdout: unstagedDiff } = await spawnAsync(
  351. 'git',
  352. ['diff', '--', repositoryRelativeFilePath],
  353. { cwd: repositoryRootPath },
  354. );
  355. if (unstagedDiff) {
  356. // Show unstaged changes if they exist
  357. diff = stripDiffHeaders(unstagedDiff);
  358. } else {
  359. // If no unstaged changes, check for staged changes (index vs HEAD)
  360. const { stdout: stagedDiff } = await spawnAsync(
  361. 'git',
  362. ['diff', '--cached', '--', repositoryRelativeFilePath],
  363. { cwd: repositoryRootPath },
  364. );
  365. diff = stripDiffHeaders(stagedDiff) || '';
  366. }
  367. }
  368. res.json({ diff });
  369. } catch (error) {
  370. console.error('Git diff error:', error);
  371. res.json({ error: error.message });
  372. }
  373. });
  374. // Get file content with diff information for CodeEditor
  375. router.get('/file-with-diff', async (req, res) => {
  376. const { project, file } = req.query;
  377. if (!project || !file) {
  378. return res.status(400).json({ error: 'Project name and file path are required' });
  379. }
  380. try {
  381. const projectPath = await getActualProjectPath(project);
  382. // Validate git repository
  383. await validateGitRepository(projectPath);
  384. const {
  385. repositoryRootPath,
  386. repositoryRelativeFilePath,
  387. } = await resolveRepositoryFilePath(projectPath, file);
  388. // Check file status
  389. const { stdout: statusOutput } = await spawnAsync(
  390. 'git',
  391. ['status', '--porcelain', '--', repositoryRelativeFilePath],
  392. { cwd: repositoryRootPath },
  393. );
  394. const isUntracked = statusOutput.startsWith('??');
  395. const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
  396. let currentContent = '';
  397. let oldContent = '';
  398. if (isDeleted) {
  399. // For deleted files, get content from HEAD
  400. const { stdout: headContent } = await spawnAsync(
  401. 'git',
  402. ['show', `HEAD:${repositoryRelativeFilePath}`],
  403. { cwd: repositoryRootPath },
  404. );
  405. oldContent = headContent;
  406. currentContent = headContent; // Show the deleted content in editor
  407. } else {
  408. // Get current file content
  409. const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
  410. const stats = await fs.stat(filePath);
  411. if (stats.isDirectory()) {
  412. // Cannot show content for directories
  413. return res.status(400).json({ error: 'Cannot show diff for directories' });
  414. }
  415. currentContent = await fs.readFile(filePath, 'utf-8');
  416. if (!isUntracked) {
  417. // Get the old content from HEAD for tracked files
  418. try {
  419. const { stdout: headContent } = await spawnAsync(
  420. 'git',
  421. ['show', `HEAD:${repositoryRelativeFilePath}`],
  422. { cwd: repositoryRootPath },
  423. );
  424. oldContent = headContent;
  425. } catch (error) {
  426. // File might be newly added to git (staged but not committed)
  427. oldContent = '';
  428. }
  429. }
  430. }
  431. res.json({
  432. currentContent,
  433. oldContent,
  434. isDeleted,
  435. isUntracked
  436. });
  437. } catch (error) {
  438. console.error('Git file-with-diff error:', error);
  439. res.json({ error: error.message });
  440. }
  441. });
  442. // Create initial commit
  443. router.post('/initial-commit', async (req, res) => {
  444. const { project } = req.body;
  445. if (!project) {
  446. return res.status(400).json({ error: 'Project name is required' });
  447. }
  448. try {
  449. const projectPath = await getActualProjectPath(project);
  450. // Validate git repository
  451. await validateGitRepository(projectPath);
  452. // Check if there are already commits
  453. try {
  454. await spawnAsync('git', ['rev-parse', 'HEAD'], { cwd: projectPath });
  455. return res.status(400).json({ error: 'Repository already has commits. Use regular commit instead.' });
  456. } catch (error) {
  457. // No HEAD - this is good, we can create initial commit
  458. }
  459. // Add all files
  460. await spawnAsync('git', ['add', '.'], { cwd: projectPath });
  461. // Create initial commit
  462. const { stdout } = await spawnAsync('git', ['commit', '-m', 'Initial commit'], { cwd: projectPath });
  463. res.json({ success: true, output: stdout, message: 'Initial commit created successfully' });
  464. } catch (error) {
  465. console.error('Git initial commit error:', error);
  466. // Handle the case where there's nothing to commit
  467. if (error.message.includes('nothing to commit')) {
  468. return res.status(400).json({
  469. error: 'Nothing to commit',
  470. details: 'No files found in the repository. Add some files first.'
  471. });
  472. }
  473. res.status(500).json({ error: error.message });
  474. }
  475. });
  476. // Commit changes
  477. router.post('/commit', async (req, res) => {
  478. const { project, message, files } = req.body;
  479. if (!project || !message || !files || files.length === 0) {
  480. return res.status(400).json({ error: 'Project name, commit message, and files are required' });
  481. }
  482. try {
  483. const projectPath = await getActualProjectPath(project);
  484. // Validate git repository
  485. await validateGitRepository(projectPath);
  486. const repositoryRootPath = await getRepositoryRootPath(projectPath);
  487. // Stage selected files
  488. for (const file of files) {
  489. const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
  490. await spawnAsync('git', ['add', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
  491. }
  492. // Commit with message
  493. const { stdout } = await spawnAsync('git', ['commit', '-m', message], { cwd: repositoryRootPath });
  494. res.json({ success: true, output: stdout });
  495. } catch (error) {
  496. console.error('Git commit error:', error);
  497. res.status(500).json({ error: error.message });
  498. }
  499. });
  500. // Revert latest local commit (keeps changes staged)
  501. router.post('/revert-local-commit', async (req, res) => {
  502. const { project } = req.body;
  503. if (!project) {
  504. return res.status(400).json({ error: 'Project name is required' });
  505. }
  506. try {
  507. const projectPath = await getActualProjectPath(project);
  508. await validateGitRepository(projectPath);
  509. try {
  510. await spawnAsync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: projectPath });
  511. } catch (error) {
  512. return res.status(400).json({
  513. error: 'No local commit to revert',
  514. details: 'This repository has no commit yet.',
  515. });
  516. }
  517. try {
  518. // Soft reset rewinds one commit while preserving all file changes in the index.
  519. await spawnAsync('git', ['reset', '--soft', 'HEAD~1'], { cwd: projectPath });
  520. } catch (error) {
  521. const errorDetails = `${error.stderr || ''} ${error.message || ''}`;
  522. const isInitialCommit = errorDetails.includes('HEAD~1') &&
  523. (errorDetails.includes('unknown revision') || errorDetails.includes('ambiguous argument'));
  524. if (!isInitialCommit) {
  525. throw error;
  526. }
  527. // Initial commit has no parent; deleting HEAD uncommits it and keeps files staged.
  528. await spawnAsync('git', ['update-ref', '-d', 'HEAD'], { cwd: projectPath });
  529. }
  530. res.json({
  531. success: true,
  532. output: 'Latest local commit reverted successfully. Changes were kept staged.',
  533. });
  534. } catch (error) {
  535. console.error('Git revert local commit error:', error);
  536. res.status(500).json({ error: error.message });
  537. }
  538. });
  539. // Get list of branches
  540. router.get('/branches', async (req, res) => {
  541. const { project } = req.query;
  542. if (!project) {
  543. return res.status(400).json({ error: 'Project name is required' });
  544. }
  545. try {
  546. const projectPath = await getActualProjectPath(project);
  547. // Validate git repository
  548. await validateGitRepository(projectPath);
  549. // Get all branches
  550. const { stdout } = await spawnAsync('git', ['branch', '-a'], { cwd: projectPath });
  551. const rawLines = stdout
  552. .split('\n')
  553. .map(b => b.trim())
  554. .filter(b => b && !b.includes('->'));
  555. // Local branches (may start with '* ' for current)
  556. const localBranches = rawLines
  557. .filter(b => !b.startsWith('remotes/'))
  558. .map(b => (b.startsWith('* ') ? b.substring(2) : b));
  559. // Remote branches — strip 'remotes/<remote>/' prefix
  560. const remoteBranches = rawLines
  561. .filter(b => b.startsWith('remotes/'))
  562. .map(b => b.replace(/^remotes\/[^/]+\//, ''))
  563. .filter(name => !localBranches.includes(name)); // skip if already a local branch
  564. // Backward-compat flat list (local + unique remotes, deduplicated)
  565. const branches = [...localBranches, ...remoteBranches]
  566. .filter((b, i, arr) => arr.indexOf(b) === i);
  567. res.json({ branches, localBranches, remoteBranches });
  568. } catch (error) {
  569. console.error('Git branches error:', error);
  570. res.json({ error: error.message });
  571. }
  572. });
  573. // Checkout branch
  574. router.post('/checkout', async (req, res) => {
  575. const { project, branch } = req.body;
  576. if (!project || !branch) {
  577. return res.status(400).json({ error: 'Project name and branch are required' });
  578. }
  579. try {
  580. const projectPath = await getActualProjectPath(project);
  581. // Checkout the branch
  582. validateBranchName(branch);
  583. const { stdout } = await spawnAsync('git', ['checkout', branch], { cwd: projectPath });
  584. res.json({ success: true, output: stdout });
  585. } catch (error) {
  586. console.error('Git checkout error:', error);
  587. res.status(500).json({ error: error.message });
  588. }
  589. });
  590. // Create new branch
  591. router.post('/create-branch', async (req, res) => {
  592. const { project, branch } = req.body;
  593. if (!project || !branch) {
  594. return res.status(400).json({ error: 'Project name and branch name are required' });
  595. }
  596. try {
  597. const projectPath = await getActualProjectPath(project);
  598. // Create and checkout new branch
  599. validateBranchName(branch);
  600. const { stdout } = await spawnAsync('git', ['checkout', '-b', branch], { cwd: projectPath });
  601. res.json({ success: true, output: stdout });
  602. } catch (error) {
  603. console.error('Git create branch error:', error);
  604. res.status(500).json({ error: error.message });
  605. }
  606. });
  607. // Delete a local branch
  608. router.post('/delete-branch', async (req, res) => {
  609. const { project, branch } = req.body;
  610. if (!project || !branch) {
  611. return res.status(400).json({ error: 'Project name and branch name are required' });
  612. }
  613. try {
  614. const projectPath = await getActualProjectPath(project);
  615. await validateGitRepository(projectPath);
  616. // Safety: cannot delete the currently checked-out branch
  617. const { stdout: currentBranch } = await spawnAsync('git', ['branch', '--show-current'], { cwd: projectPath });
  618. if (currentBranch.trim() === branch) {
  619. return res.status(400).json({ error: 'Cannot delete the currently checked-out branch' });
  620. }
  621. const { stdout } = await spawnAsync('git', ['branch', '-d', branch], { cwd: projectPath });
  622. res.json({ success: true, output: stdout });
  623. } catch (error) {
  624. console.error('Git delete branch error:', error);
  625. res.status(500).json({ error: error.message });
  626. }
  627. });
  628. // Get recent commits
  629. router.get('/commits', async (req, res) => {
  630. const { project, limit = 10 } = req.query;
  631. if (!project) {
  632. return res.status(400).json({ error: 'Project name is required' });
  633. }
  634. try {
  635. const projectPath = await getActualProjectPath(project);
  636. await validateGitRepository(projectPath);
  637. const parsedLimit = Number.parseInt(String(limit), 10);
  638. const safeLimit = Number.isFinite(parsedLimit) && parsedLimit > 0
  639. ? Math.min(parsedLimit, 100)
  640. : 10;
  641. // Get commit log with stats
  642. const { stdout } = await spawnAsync(
  643. 'git',
  644. ['log', '--pretty=format:%H|%an|%ae|%ad|%s', '--date=iso-strict', '-n', String(safeLimit)],
  645. { cwd: projectPath },
  646. );
  647. const commits = stdout
  648. .split('\n')
  649. .filter(line => line.trim())
  650. .map(line => {
  651. const [hash, author, email, date, ...messageParts] = line.split('|');
  652. return {
  653. hash,
  654. author,
  655. email,
  656. date,
  657. message: messageParts.join('|')
  658. };
  659. });
  660. // Get stats for each commit
  661. for (const commit of commits) {
  662. try {
  663. const { stdout: stats } = await spawnAsync(
  664. 'git', ['show', '--stat', '--format=', commit.hash],
  665. { cwd: projectPath }
  666. );
  667. commit.stats = stats.trim().split('\n').pop(); // Get the summary line
  668. } catch (error) {
  669. commit.stats = '';
  670. }
  671. }
  672. res.json({ commits });
  673. } catch (error) {
  674. console.error('Git commits error:', error);
  675. res.json({ error: error.message });
  676. }
  677. });
  678. // Get diff for a specific commit
  679. router.get('/commit-diff', async (req, res) => {
  680. const { project, commit } = req.query;
  681. if (!project || !commit) {
  682. return res.status(400).json({ error: 'Project name and commit hash are required' });
  683. }
  684. try {
  685. const projectPath = await getActualProjectPath(project);
  686. // Validate commit reference (defense-in-depth)
  687. validateCommitRef(commit);
  688. // Get diff for the commit
  689. const { stdout } = await spawnAsync(
  690. 'git', ['show', commit],
  691. { cwd: projectPath }
  692. );
  693. const isTruncated = stdout.length > COMMIT_DIFF_CHARACTER_LIMIT;
  694. const diff = isTruncated
  695. ? `${stdout.slice(0, COMMIT_DIFF_CHARACTER_LIMIT)}\n\n... Diff truncated to keep the UI responsive ...`
  696. : stdout;
  697. res.json({ diff, isTruncated });
  698. } catch (error) {
  699. console.error('Git commit diff error:', error);
  700. res.json({ error: error.message });
  701. }
  702. });
  703. // Generate commit message based on staged changes using AI
  704. router.post('/generate-commit-message', async (req, res) => {
  705. const { project, files, provider = 'pilotdeck' } = req.body;
  706. if (!project || !files || files.length === 0) {
  707. return res.status(400).json({ error: 'Project name and files are required' });
  708. }
  709. // Validate provider
  710. if (!['claude', 'pilotdeck', 'cursor'].includes(provider)) {
  711. return res.status(400).json({ error: 'provider must be "claude", "pilotdeck" or "cursor"' });
  712. }
  713. try {
  714. const projectPath = await getActualProjectPath(project);
  715. await validateGitRepository(projectPath);
  716. const repositoryRootPath = await getRepositoryRootPath(projectPath);
  717. // Get diff for selected files
  718. let diffContext = '';
  719. for (const file of files) {
  720. try {
  721. const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
  722. const { stdout } = await spawnAsync(
  723. 'git', ['diff', 'HEAD', '--', repositoryRelativeFilePath],
  724. { cwd: repositoryRootPath }
  725. );
  726. if (stdout) {
  727. diffContext += `\n--- ${repositoryRelativeFilePath} ---\n${stdout}`;
  728. }
  729. } catch (error) {
  730. console.error(`Error getting diff for ${file}:`, error);
  731. }
  732. }
  733. // If no diff found, might be untracked files
  734. if (!diffContext.trim()) {
  735. // Try to get content of untracked files
  736. for (const file of files) {
  737. try {
  738. const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
  739. const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
  740. const stats = await fs.stat(filePath);
  741. if (!stats.isDirectory()) {
  742. const content = await fs.readFile(filePath, 'utf-8');
  743. diffContext += `\n--- ${repositoryRelativeFilePath} (new file) ---\n${content.substring(0, 1000)}\n`;
  744. } else {
  745. diffContext += `\n--- ${repositoryRelativeFilePath} (new directory) ---\n`;
  746. }
  747. } catch (error) {
  748. console.error(`Error reading file ${file}:`, error);
  749. }
  750. }
  751. }
  752. // Generate commit message using AI
  753. const message = await generateCommitMessageWithAI(files, diffContext, provider, projectPath);
  754. res.json({ message });
  755. } catch (error) {
  756. console.error('Generate commit message error:', error);
  757. res.status(500).json({ error: error.message });
  758. }
  759. });
  760. /**
  761. * @param {Array<string>} files - List of changed files
  762. * @param {string} diffContext - Git diff content
  763. * @param {string} projectPath - Project directory path
  764. * @returns {Promise<string>} Generated commit message
  765. */
  766. async function generateCommitMessageWithAI(files, diffContext, provider, projectPath) {
  767. // Create the prompt
  768. const prompt = `Generate a conventional commit message for these changes.
  769. REQUIREMENTS:
  770. - Format: type(scope): subject
  771. - Include body explaining what changed and why
  772. - Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore
  773. - Subject under 50 chars, body wrapped at 72 chars
  774. - Focus on user-facing changes, not implementation details
  775. - Consider what's being added AND removed
  776. - Return ONLY the commit message (no markdown, explanations, or code blocks)
  777. FILES CHANGED:
  778. ${files.map(f => `- ${f}`).join('\n')}
  779. DIFFS:
  780. ${diffContext.substring(0, 4000)}
  781. Generate the commit message:`;
  782. try {
  783. // Create a simple writer that collects the response
  784. let responseText = '';
  785. const writer = {
  786. send: (data) => {
  787. try {
  788. const parsed = typeof data === 'string' ? JSON.parse(data) : data;
  789. console.log('🔍 Writer received message type:', parsed.type);
  790. if ((parsed.type === 'claude-response' || parsed.type === 'pilotdeck-response') && parsed.data) {
  791. const message = parsed.data.message || parsed.data;
  792. console.log('📦 PilotDeck response message:', JSON.stringify(message, null, 2).substring(0, 500));
  793. if (message.content && Array.isArray(message.content)) {
  794. // Extract text from content array
  795. for (const item of message.content) {
  796. if (item.type === 'text' && item.text) {
  797. console.log('✅ Extracted text chunk:', item.text.substring(0, 100));
  798. responseText += item.text;
  799. }
  800. }
  801. }
  802. }
  803. // Cursor CLI sends: {type: 'cursor-output', output: '...'}
  804. else if (parsed.type === 'cursor-output' && parsed.output) {
  805. console.log('✅ Cursor output:', parsed.output.substring(0, 100));
  806. responseText += parsed.output;
  807. }
  808. // Also handle direct text messages
  809. else if (parsed.type === 'text' && parsed.text) {
  810. console.log('✅ Direct text:', parsed.text.substring(0, 100));
  811. responseText += parsed.text;
  812. }
  813. } catch (e) {
  814. // Ignore parse errors
  815. console.error('Error parsing writer data:', e);
  816. }
  817. },
  818. setSessionId: () => {}, // No-op for this use case
  819. };
  820. console.log('🚀 Calling AI agent with provider:', provider);
  821. console.log('📝 Prompt length:', prompt.length);
  822. // All providers route through the PilotDeck gateway. The `provider`
  823. // value is kept only as a label in the resulting message frames.
  824. await runChatViaGateway(
  825. prompt,
  826. {
  827. cwd: projectPath,
  828. projectPath,
  829. permissionMode: 'bypassPermissions',
  830. model: provider === 'cursor' ? undefined : 'sonnet',
  831. },
  832. writer,
  833. provider || 'pilotdeck',
  834. );
  835. console.log('📊 Total response text collected:', responseText.length, 'characters');
  836. console.log('📄 Response preview:', responseText.substring(0, 200));
  837. // Clean up the response
  838. const cleanedMessage = cleanCommitMessage(responseText);
  839. console.log('🧹 Cleaned message:', cleanedMessage.substring(0, 200));
  840. return cleanedMessage || 'chore: update files';
  841. } catch (error) {
  842. console.error('Error generating commit message with AI:', error);
  843. // Fallback to simple message
  844. return `chore: update ${files.length} file${files.length !== 1 ? 's' : ''}`;
  845. }
  846. }
  847. /**
  848. * Cleans the AI-generated commit message by removing markdown, code blocks, and extra formatting
  849. * @param {string} text - Raw AI response
  850. * @returns {string} Clean commit message
  851. */
  852. function cleanCommitMessage(text) {
  853. if (!text || !text.trim()) {
  854. return '';
  855. }
  856. let cleaned = text.trim();
  857. // Remove markdown code blocks
  858. cleaned = cleaned.replace(/```[a-z]*\n/g, '');
  859. cleaned = cleaned.replace(/```/g, '');
  860. // Remove markdown headers
  861. cleaned = cleaned.replace(/^#+\s*/gm, '');
  862. // Remove leading/trailing quotes
  863. cleaned = cleaned.replace(/^["']|["']$/g, '');
  864. // If there are multiple lines, take everything (subject + body)
  865. // Just clean up extra blank lines
  866. cleaned = cleaned.replace(/\n{3,}/g, '\n\n');
  867. // Remove any explanatory text before the actual commit message
  868. // Look for conventional commit pattern and start from there
  869. const conventionalCommitMatch = cleaned.match(/(feat|fix|docs|style|refactor|perf|test|build|ci|chore)(\(.+?\))?:.+/s);
  870. if (conventionalCommitMatch) {
  871. cleaned = cleaned.substring(cleaned.indexOf(conventionalCommitMatch[0]));
  872. }
  873. return cleaned.trim();
  874. }
  875. // Get remote status (ahead/behind commits with smart remote detection)
  876. router.get('/remote-status', async (req, res) => {
  877. const { project } = req.query;
  878. if (!project) {
  879. return res.status(400).json({ error: 'Project name is required' });
  880. }
  881. try {
  882. const projectPath = await getActualProjectPath(project);
  883. await validateGitRepository(projectPath);
  884. const branch = await getCurrentBranchName(projectPath);
  885. const hasCommits = await repositoryHasCommits(projectPath);
  886. const { stdout: remoteOutput } = await spawnAsync('git', ['remote'], { cwd: projectPath });
  887. const remotes = remoteOutput.trim().split('\n').filter(r => r.trim());
  888. const hasRemote = remotes.length > 0;
  889. const fallbackRemoteName = hasRemote
  890. ? (remotes.includes('origin') ? 'origin' : remotes[0])
  891. : null;
  892. // Repositories initialized with `git init` can have a branch but no commits.
  893. // Return a non-error state so the UI can show the initial-commit workflow.
  894. if (!hasCommits) {
  895. return res.json({
  896. hasRemote,
  897. hasUpstream: false,
  898. branch,
  899. remoteName: fallbackRemoteName,
  900. ahead: 0,
  901. behind: 0,
  902. isUpToDate: false,
  903. message: 'Repository has no commits yet'
  904. });
  905. }
  906. // Check if there's a remote tracking branch (smart detection)
  907. let trackingBranch;
  908. let remoteName;
  909. try {
  910. const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
  911. trackingBranch = stdout.trim();
  912. remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin")
  913. } catch (error) {
  914. return res.json({
  915. hasRemote,
  916. hasUpstream: false,
  917. branch,
  918. remoteName: fallbackRemoteName,
  919. message: 'No remote tracking branch configured'
  920. });
  921. }
  922. // Get ahead/behind counts
  923. const { stdout: countOutput } = await spawnAsync(
  924. 'git', ['rev-list', '--count', '--left-right', `${trackingBranch}...HEAD`],
  925. { cwd: projectPath }
  926. );
  927. const [behind, ahead] = countOutput.trim().split('\t').map(Number);
  928. res.json({
  929. hasRemote: true,
  930. hasUpstream: true,
  931. branch,
  932. remoteBranch: trackingBranch,
  933. remoteName,
  934. ahead: ahead || 0,
  935. behind: behind || 0,
  936. isUpToDate: ahead === 0 && behind === 0
  937. });
  938. } catch (error) {
  939. console.error('Git remote status error:', error);
  940. res.json({ error: error.message });
  941. }
  942. });
  943. // Fetch from remote (using smart remote detection)
  944. router.post('/fetch', async (req, res) => {
  945. const { project } = req.body;
  946. if (!project) {
  947. return res.status(400).json({ error: 'Project name is required' });
  948. }
  949. try {
  950. const projectPath = await getActualProjectPath(project);
  951. await validateGitRepository(projectPath);
  952. // Get current branch and its upstream remote
  953. const branch = await getCurrentBranchName(projectPath);
  954. let remoteName = 'origin'; // fallback
  955. try {
  956. const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
  957. remoteName = stdout.trim().split('/')[0]; // Extract remote name
  958. } catch (error) {
  959. // No upstream, try to fetch from origin anyway
  960. console.log('No upstream configured, using origin as fallback');
  961. }
  962. validateRemoteName(remoteName);
  963. const { stdout } = await spawnAsync('git', ['fetch', remoteName], { cwd: projectPath });
  964. res.json({ success: true, output: stdout || 'Fetch completed successfully', remoteName });
  965. } catch (error) {
  966. console.error('Git fetch error:', error);
  967. res.status(500).json({
  968. error: 'Fetch failed',
  969. details: error.message.includes('Could not resolve hostname')
  970. ? 'Unable to connect to remote repository. Check your internet connection.'
  971. : error.message.includes('fatal: \'origin\' does not appear to be a git repository')
  972. ? 'No remote repository configured. Add a remote with: git remote add origin <url>'
  973. : error.message
  974. });
  975. }
  976. });
  977. // Pull from remote (fetch + merge using smart remote detection)
  978. router.post('/pull', async (req, res) => {
  979. const { project } = req.body;
  980. if (!project) {
  981. return res.status(400).json({ error: 'Project name is required' });
  982. }
  983. try {
  984. const projectPath = await getActualProjectPath(project);
  985. await validateGitRepository(projectPath);
  986. // Get current branch and its upstream remote
  987. const branch = await getCurrentBranchName(projectPath);
  988. let remoteName = 'origin'; // fallback
  989. let remoteBranch = branch; // fallback
  990. try {
  991. const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
  992. const tracking = stdout.trim();
  993. remoteName = tracking.split('/')[0]; // Extract remote name
  994. remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name
  995. } catch (error) {
  996. // No upstream, use fallback
  997. console.log('No upstream configured, using origin/branch as fallback');
  998. }
  999. validateRemoteName(remoteName);
  1000. validateBranchName(remoteBranch);
  1001. const { stdout } = await spawnAsync('git', ['pull', remoteName, remoteBranch], { cwd: projectPath });
  1002. res.json({
  1003. success: true,
  1004. output: stdout || 'Pull completed successfully',
  1005. remoteName,
  1006. remoteBranch
  1007. });
  1008. } catch (error) {
  1009. console.error('Git pull error:', error);
  1010. // Enhanced error handling for common pull scenarios
  1011. let errorMessage = 'Pull failed';
  1012. let details = error.message;
  1013. if (error.message.includes('CONFLICT')) {
  1014. errorMessage = 'Merge conflicts detected';
  1015. details = 'Pull created merge conflicts. Please resolve conflicts manually in the editor, then commit the changes.';
  1016. } else if (error.message.includes('Please commit your changes or stash them')) {
  1017. errorMessage = 'Uncommitted changes detected';
  1018. details = 'Please commit or stash your local changes before pulling.';
  1019. } else if (error.message.includes('Could not resolve hostname')) {
  1020. errorMessage = 'Network error';
  1021. details = 'Unable to connect to remote repository. Check your internet connection.';
  1022. } else if (error.message.includes('fatal: \'origin\' does not appear to be a git repository')) {
  1023. errorMessage = 'Remote not configured';
  1024. details = 'No remote repository configured. Add a remote with: git remote add origin <url>';
  1025. } else if (error.message.includes('diverged')) {
  1026. errorMessage = 'Branches have diverged';
  1027. details = 'Your local branch and remote branch have diverged. Consider fetching first to review changes.';
  1028. }
  1029. res.status(500).json({
  1030. error: errorMessage,
  1031. details: details
  1032. });
  1033. }
  1034. });
  1035. // Push commits to remote repository
  1036. router.post('/push', async (req, res) => {
  1037. const { project } = req.body;
  1038. if (!project) {
  1039. return res.status(400).json({ error: 'Project name is required' });
  1040. }
  1041. try {
  1042. const projectPath = await getActualProjectPath(project);
  1043. await validateGitRepository(projectPath);
  1044. // Get current branch and its upstream remote
  1045. const branch = await getCurrentBranchName(projectPath);
  1046. let remoteName = 'origin'; // fallback
  1047. let remoteBranch = branch; // fallback
  1048. try {
  1049. const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
  1050. const tracking = stdout.trim();
  1051. remoteName = tracking.split('/')[0]; // Extract remote name
  1052. remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name
  1053. } catch (error) {
  1054. // No upstream, use fallback
  1055. console.log('No upstream configured, using origin/branch as fallback');
  1056. }
  1057. validateRemoteName(remoteName);
  1058. validateBranchName(remoteBranch);
  1059. const { stdout } = await spawnAsync('git', ['push', remoteName, remoteBranch], { cwd: projectPath });
  1060. res.json({
  1061. success: true,
  1062. output: stdout || 'Push completed successfully',
  1063. remoteName,
  1064. remoteBranch
  1065. });
  1066. } catch (error) {
  1067. console.error('Git push error:', error);
  1068. // Enhanced error handling for common push scenarios
  1069. let errorMessage = 'Push failed';
  1070. let details = error.message;
  1071. if (error.message.includes('rejected')) {
  1072. errorMessage = 'Push rejected';
  1073. details = 'The remote has newer commits. Pull first to merge changes before pushing.';
  1074. } else if (error.message.includes('non-fast-forward')) {
  1075. errorMessage = 'Non-fast-forward push';
  1076. details = 'Your branch is behind the remote. Pull the latest changes first.';
  1077. } else if (error.message.includes('Could not resolve hostname')) {
  1078. errorMessage = 'Network error';
  1079. details = 'Unable to connect to remote repository. Check your internet connection.';
  1080. } else if (error.message.includes('fatal: \'origin\' does not appear to be a git repository')) {
  1081. errorMessage = 'Remote not configured';
  1082. details = 'No remote repository configured. Add a remote with: git remote add origin <url>';
  1083. } else if (error.message.includes('Permission denied')) {
  1084. errorMessage = 'Authentication failed';
  1085. details = 'Permission denied. Check your credentials or SSH keys.';
  1086. } else if (error.message.includes('no upstream branch')) {
  1087. errorMessage = 'No upstream branch';
  1088. details = 'No upstream branch configured. Use: git push --set-upstream origin <branch>';
  1089. }
  1090. res.status(500).json({
  1091. error: errorMessage,
  1092. details: details
  1093. });
  1094. }
  1095. });
  1096. // Publish branch to remote (set upstream and push)
  1097. router.post('/publish', async (req, res) => {
  1098. const { project, branch } = req.body;
  1099. if (!project || !branch) {
  1100. return res.status(400).json({ error: 'Project name and branch are required' });
  1101. }
  1102. try {
  1103. const projectPath = await getActualProjectPath(project);
  1104. await validateGitRepository(projectPath);
  1105. // Validate branch name
  1106. validateBranchName(branch);
  1107. // Get current branch to verify it matches the requested branch
  1108. const currentBranchName = await getCurrentBranchName(projectPath);
  1109. if (currentBranchName !== branch) {
  1110. return res.status(400).json({
  1111. error: `Branch mismatch. Current branch is ${currentBranchName}, but trying to publish ${branch}`
  1112. });
  1113. }
  1114. // Check if remote exists
  1115. let remoteName = 'origin';
  1116. try {
  1117. const { stdout } = await spawnAsync('git', ['remote'], { cwd: projectPath });
  1118. const remotes = stdout.trim().split('\n').filter(r => r.trim());
  1119. if (remotes.length === 0) {
  1120. return res.status(400).json({
  1121. error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
  1122. });
  1123. }
  1124. remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
  1125. } catch (error) {
  1126. return res.status(400).json({
  1127. error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
  1128. });
  1129. }
  1130. // Publish the branch (set upstream and push)
  1131. validateRemoteName(remoteName);
  1132. const { stdout } = await spawnAsync('git', ['push', '--set-upstream', remoteName, branch], { cwd: projectPath });
  1133. res.json({
  1134. success: true,
  1135. output: stdout || 'Branch published successfully',
  1136. remoteName,
  1137. branch
  1138. });
  1139. } catch (error) {
  1140. console.error('Git publish error:', error);
  1141. // Enhanced error handling for common publish scenarios
  1142. let errorMessage = 'Publish failed';
  1143. let details = error.message;
  1144. if (error.message.includes('rejected')) {
  1145. errorMessage = 'Publish rejected';
  1146. details = 'The remote branch already exists and has different commits. Use push instead.';
  1147. } else if (error.message.includes('Could not resolve hostname')) {
  1148. errorMessage = 'Network error';
  1149. details = 'Unable to connect to remote repository. Check your internet connection.';
  1150. } else if (error.message.includes('Permission denied')) {
  1151. errorMessage = 'Authentication failed';
  1152. details = 'Permission denied. Check your credentials or SSH keys.';
  1153. } else if (error.message.includes('fatal:') && error.message.includes('does not appear to be a git repository')) {
  1154. errorMessage = 'Remote not configured';
  1155. details = 'Remote repository not properly configured. Check your remote URL.';
  1156. }
  1157. res.status(500).json({
  1158. error: errorMessage,
  1159. details: details
  1160. });
  1161. }
  1162. });
  1163. // Discard changes for a specific file
  1164. router.post('/discard', async (req, res) => {
  1165. const { project, file } = req.body;
  1166. if (!project || !file) {
  1167. return res.status(400).json({ error: 'Project name and file path are required' });
  1168. }
  1169. try {
  1170. const projectPath = await getActualProjectPath(project);
  1171. await validateGitRepository(projectPath);
  1172. const {
  1173. repositoryRootPath,
  1174. repositoryRelativeFilePath,
  1175. } = await resolveRepositoryFilePath(projectPath, file);
  1176. // Check file status to determine correct discard command
  1177. const { stdout: statusOutput } = await spawnAsync(
  1178. 'git',
  1179. ['status', '--porcelain', '--', repositoryRelativeFilePath],
  1180. { cwd: repositoryRootPath },
  1181. );
  1182. if (!statusOutput.trim()) {
  1183. return res.status(400).json({ error: 'No changes to discard for this file' });
  1184. }
  1185. const status = statusOutput.substring(0, 2);
  1186. if (status === '??') {
  1187. // Untracked file or directory - delete it
  1188. const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
  1189. const stats = await fs.stat(filePath);
  1190. if (stats.isDirectory()) {
  1191. await fs.rm(filePath, { recursive: true, force: true });
  1192. } else {
  1193. await fs.unlink(filePath);
  1194. }
  1195. } else if (status.includes('M') || status.includes('D')) {
  1196. // Modified or deleted file - restore from HEAD
  1197. await spawnAsync('git', ['restore', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
  1198. } else if (status.includes('A')) {
  1199. // Added file - unstage it
  1200. await spawnAsync('git', ['reset', 'HEAD', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
  1201. }
  1202. res.json({ success: true, message: `Changes discarded for ${repositoryRelativeFilePath}` });
  1203. } catch (error) {
  1204. console.error('Git discard error:', error);
  1205. res.status(500).json({ error: error.message });
  1206. }
  1207. });
  1208. // Delete untracked file
  1209. router.post('/delete-untracked', async (req, res) => {
  1210. const { project, file } = req.body;
  1211. if (!project || !file) {
  1212. return res.status(400).json({ error: 'Project name and file path are required' });
  1213. }
  1214. try {
  1215. const projectPath = await getActualProjectPath(project);
  1216. await validateGitRepository(projectPath);
  1217. const {
  1218. repositoryRootPath,
  1219. repositoryRelativeFilePath,
  1220. } = await resolveRepositoryFilePath(projectPath, file);
  1221. // Check if file is actually untracked
  1222. const { stdout: statusOutput } = await spawnAsync(
  1223. 'git',
  1224. ['status', '--porcelain', '--', repositoryRelativeFilePath],
  1225. { cwd: repositoryRootPath },
  1226. );
  1227. if (!statusOutput.trim()) {
  1228. return res.status(400).json({ error: 'File is not untracked or does not exist' });
  1229. }
  1230. const status = statusOutput.substring(0, 2);
  1231. if (status !== '??') {
  1232. return res.status(400).json({ error: 'File is not untracked. Use discard for tracked files.' });
  1233. }
  1234. // Delete the untracked file or directory
  1235. const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
  1236. const stats = await fs.stat(filePath);
  1237. if (stats.isDirectory()) {
  1238. // Use rm with recursive option for directories
  1239. await fs.rm(filePath, { recursive: true, force: true });
  1240. res.json({ success: true, message: `Untracked directory ${repositoryRelativeFilePath} deleted successfully` });
  1241. } else {
  1242. await fs.unlink(filePath);
  1243. res.json({ success: true, message: `Untracked file ${repositoryRelativeFilePath} deleted successfully` });
  1244. }
  1245. } catch (error) {
  1246. console.error('Git delete untracked error:', error);
  1247. res.status(500).json({ error: error.message });
  1248. }
  1249. });
  1250. export default router;