upfynai-code 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/README.md +131 -0
  2. package/client/dist/assets/{AppContent-CRld2UWX.js → AppContent-C0CyP3g5.js} +3 -3
  3. package/client/dist/assets/{CanvasPanel-CB4sweQq.js → CanvasPanel-0u9QR7U-.js} +5 -5
  4. package/client/dist/assets/DashboardPanel-Dgqw1yZk.js +1 -0
  5. package/client/dist/assets/{LoginModal-BwkvjfPR.js → LoginModal-CZDEzqjK.js} +1 -1
  6. package/client/dist/assets/Onboarding-DR6NZ4Vz.js +1 -0
  7. package/client/dist/assets/{SetupForm-CH5EA5W0.js → SetupForm-D49gtWY4.js} +1 -1
  8. package/client/dist/assets/{WorkflowsPanel-CO5g5yGG.js → WorkflowsPanel-CqlbEJA_.js} +1 -1
  9. package/client/dist/assets/{ar-SA-G6X2FPQ2-DoJuo98H.js → ar-SA-G6X2FPQ2-BWqa1yBH.js} +1 -1
  10. package/client/dist/assets/{arc-B0wBaTeh.js → arc-BegSKqEW.js} +1 -1
  11. package/client/dist/assets/{az-AZ-76LH7QW2-xdrt1Z13.js → az-AZ-76LH7QW2-DrVlbZDP.js} +1 -1
  12. package/client/dist/assets/{bg-BG-XCXSNQG7-D8NAiF6Y.js → bg-BG-XCXSNQG7-DdunjBgT.js} +1 -1
  13. package/client/dist/assets/{blockDiagram-38ab4fdb-DSnyKzK4.js → blockDiagram-38ab4fdb-BKMbwGHu.js} +1 -1
  14. package/client/dist/assets/{bn-BD-2XOGV67Q-B0qWv8_J.js → bn-BD-2XOGV67Q-_7DtmvwO.js} +1 -1
  15. package/client/dist/assets/{c4Diagram-3d4e48cf-DoZJ13XA.js → c4Diagram-3d4e48cf-hJuiHhSn.js} +1 -1
  16. package/client/dist/assets/{ca-ES-6MX7JW3Y-RgLhfbZZ.js → ca-ES-6MX7JW3Y-BFIrmojG.js} +1 -1
  17. package/client/dist/assets/channel-Bur-rRTp.js +1 -0
  18. package/client/dist/assets/{classDiagram-70f12bd4-GNyDrRCk.js → classDiagram-70f12bd4-BjiAf9cM.js} +1 -1
  19. package/client/dist/assets/{classDiagram-v2-f2320105-CxdGhHm2.js → classDiagram-v2-f2320105-pwBewejc.js} +1 -1
  20. package/client/dist/assets/clone-BtqXeoBJ.js +1 -0
  21. package/client/dist/assets/{createText-2e5e7dd3-DiPywQOa.js → createText-2e5e7dd3-Dq_acOWe.js} +1 -1
  22. package/client/dist/assets/{cs-CZ-2BRQDIVT-BAjmnuoC.js → cs-CZ-2BRQDIVT-B-x4F6TJ.js} +1 -1
  23. package/client/dist/assets/{da-DK-5WZEPLOC-JxKVGt8o.js → da-DK-5WZEPLOC-Btlc8Dgn.js} +1 -1
  24. package/client/dist/assets/{de-DE-XR44H4JA-CrnRlt4z.js → de-DE-XR44H4JA-BVu3ZIoD.js} +1 -1
  25. package/client/dist/assets/{edges-e0da2a9e-DDsXzXLJ.js → edges-e0da2a9e-DH0wVTXR.js} +1 -1
  26. package/client/dist/assets/{el-GR-BZB4AONW-DQd8iogq.js → el-GR-BZB4AONW-h2ll8_ZC.js} +1 -1
  27. package/client/dist/assets/{erDiagram-9861fffd-CBiCC4rl.js → erDiagram-9861fffd-BYezLIR7.js} +1 -1
  28. package/client/dist/assets/{es-ES-U4NZUMDT-vvUblc5i.js → es-ES-U4NZUMDT-Cveiulwt.js} +1 -1
  29. package/client/dist/assets/{eu-ES-A7QVB2H4-De4NNCc1.js → eu-ES-A7QVB2H4-DQluL2PY.js} +1 -1
  30. package/client/dist/assets/{fa-IR-HGAKTJCU-DFBXqIqq.js → fa-IR-HGAKTJCU-BJtcMBSv.js} +1 -1
  31. package/client/dist/assets/{fi-FI-Z5N7JZ37-DV9zESPg.js → fi-FI-Z5N7JZ37-D8NfbVXV.js} +1 -1
  32. package/client/dist/assets/{flowDb-956e92f1-BhdSHbdO.js → flowDb-956e92f1-scnUykhM.js} +1 -1
  33. package/client/dist/assets/{flowDiagram-66a62f08-M-fp1_Ie.js → flowDiagram-66a62f08-jVyWsfyU.js} +1 -1
  34. package/client/dist/assets/flowDiagram-v2-96b9c2cf-N6xgi25h.js +1 -0
  35. package/client/dist/assets/{flowchart-elk-definition-4a651766-Bp0SonQx.js → flowchart-elk-definition-4a651766-gKGX3HqR.js} +1 -1
  36. package/client/dist/assets/{fr-FR-RHASNOE6-CKTMXuGk.js → fr-FR-RHASNOE6-vdj42kC6.js} +1 -1
  37. package/client/dist/assets/{ganttDiagram-c361ad54-iA737GUS.js → ganttDiagram-c361ad54-C2CiWFUP.js} +1 -1
  38. package/client/dist/assets/{gitGraphDiagram-72cf32ee-BX-wj-PV.js → gitGraphDiagram-72cf32ee-C59Yz2LK.js} +1 -1
  39. package/client/dist/assets/{gl-ES-HMX3MZ6V-Cdiqq4jY.js → gl-ES-HMX3MZ6V-DQo0TzoP.js} +1 -1
  40. package/client/dist/assets/{graph-Rxkx3sEa.js → graph-Dx_H43Kv.js} +1 -1
  41. package/client/dist/assets/{he-IL-6SHJWFNN-gYmR5_KT.js → he-IL-6SHJWFNN-DKXK5e33.js} +1 -1
  42. package/client/dist/assets/{hi-IN-IWLTKZ5I-pyqK94AR.js → hi-IN-IWLTKZ5I-C2Qgqc0R.js} +1 -1
  43. package/client/dist/assets/{hu-HU-A5ZG7DT2-DpacJgJy.js → hu-HU-A5ZG7DT2-Ss-6vX0m.js} +1 -1
  44. package/client/dist/assets/{id-ID-SAP4L64H-CAvIX-mj.js → id-ID-SAP4L64H-D7Wsg1S2.js} +1 -1
  45. package/client/dist/assets/{index-3862675e-BX3Fpn6V.js → index-3862675e-u8Nv7hHC.js} +1 -1
  46. package/client/dist/assets/{index-BBlwbHq_.js → index-BVowJdZF.js} +4 -4
  47. package/client/dist/assets/{index-ClfzLIqY.js → index-ce18TYkg.js} +4 -4
  48. package/client/dist/assets/index-kQoJx-bc.css +1 -0
  49. package/client/dist/assets/{infoDiagram-f8f76790-Ckv8imiv.js → infoDiagram-f8f76790-LmoJYsxo.js} +1 -1
  50. package/client/dist/assets/{it-IT-JPQ66NNP-BtpNRSce.js → it-IT-JPQ66NNP-CAPTVl7M.js} +1 -1
  51. package/client/dist/assets/{ja-JP-DBVTYXUO-CwJRyY6M.js → ja-JP-DBVTYXUO-eNVPawR2.js} +1 -1
  52. package/client/dist/assets/{journeyDiagram-49397b02-DWWZssji.js → journeyDiagram-49397b02-BaJqehpR.js} +1 -1
  53. package/client/dist/assets/{kaa-6HZHGXH3-DIWQEb4A.js → kaa-6HZHGXH3-tpuNkKhS.js} +1 -1
  54. package/client/dist/assets/{kab-KAB-ZGHBKWFO-DjGbqhUg.js → kab-KAB-ZGHBKWFO-Dp83kx4x.js} +1 -1
  55. package/client/dist/assets/{kk-KZ-P5N5QNE5-B_VzJdWf.js → kk-KZ-P5N5QNE5-B9IlC6YN.js} +1 -1
  56. package/client/dist/assets/{km-KH-HSX4SM5Z-DUD5mi0o.js → km-KH-HSX4SM5Z-B_KMYaMj.js} +1 -1
  57. package/client/dist/assets/{ko-KR-MTYHY66A--sDB10db.js → ko-KR-MTYHY66A-yebnUNdb.js} +1 -1
  58. package/client/dist/assets/{ku-TR-6OUDTVRD-CKvKrkcX.js → ku-TR-6OUDTVRD-BR6fh6-5.js} +1 -1
  59. package/client/dist/assets/{layout-CkB7sSeq.js → layout-DLl5Jwcl.js} +1 -1
  60. package/client/dist/assets/{line-DC7MA9qY.js → line-FpB7omSK.js} +1 -1
  61. package/client/dist/assets/{linear-C1lBBthf.js → linear-CkXqUFJ8.js} +1 -1
  62. package/client/dist/assets/{lt-LT-XHIRWOB4-MSZf7xYG.js → lt-LT-XHIRWOB4-SutZSWtR.js} +1 -1
  63. package/client/dist/assets/{lv-LV-5QDEKY6T-C-gvvmBB.js → lv-LV-5QDEKY6T-DuAxdcZL.js} +1 -1
  64. package/client/dist/assets/{mindmap-definition-fc14e90a-B3O7hztq.js → mindmap-definition-fc14e90a-DyxXOExh.js} +1 -1
  65. package/client/dist/assets/{mr-IN-CRQNXWMA-XHtBUWQH.js → mr-IN-CRQNXWMA-DqDUWM_8.js} +1 -1
  66. package/client/dist/assets/{my-MM-5M5IBNSE-D9eD2edL.js → my-MM-5M5IBNSE-C40kMFMR.js} +1 -1
  67. package/client/dist/assets/{nb-NO-T6EIAALU-BlImC6gp.js → nb-NO-T6EIAALU-DVij32Ju.js} +1 -1
  68. package/client/dist/assets/{nl-NL-IS3SIHDZ-CPFhnaSP.js → nl-NL-IS3SIHDZ-rT84mDYq.js} +1 -1
  69. package/client/dist/assets/{nn-NO-6E72VCQL-BMvoJSKQ.js → nn-NO-6E72VCQL-BBZXBW8V.js} +1 -1
  70. package/client/dist/assets/{oc-FR-POXYY2M6-Buye63LS.js → oc-FR-POXYY2M6-DzjOugOf.js} +1 -1
  71. package/client/dist/assets/{pa-IN-N4M65BXN-D9uQ3niy.js → pa-IN-N4M65BXN-DD1iU8_F.js} +1 -1
  72. package/client/dist/assets/{percentages-BXMCSKIN-BzXIakGM.js → percentages-BXMCSKIN-WVlHS4wx.js} +6 -6
  73. package/client/dist/assets/{pieDiagram-8a3498a8-BU38mzx-.js → pieDiagram-8a3498a8-Dd_85qBH.js} +1 -1
  74. package/client/dist/assets/{pl-PL-T2D74RX3-BqM4xdcg.js → pl-PL-T2D74RX3-ukVXa48G.js} +1 -1
  75. package/client/dist/assets/{pt-BR-5N22H2LF-rAjrxGyI.js → pt-BR-5N22H2LF-BibawarT.js} +1 -1
  76. package/client/dist/assets/{pt-PT-UZXXM6DQ-DXsqcwLt.js → pt-PT-UZXXM6DQ-So3i9l9w.js} +1 -1
  77. package/client/dist/assets/{quadrantDiagram-120e2f19-HhK4H1WU.js → quadrantDiagram-120e2f19-C4dFVDEx.js} +1 -1
  78. package/client/dist/assets/{requirementDiagram-deff3bca-aDrcyj-A.js → requirementDiagram-deff3bca-DrTO7yFl.js} +1 -1
  79. package/client/dist/assets/{ro-RO-JPDTUUEW-D_F9UKer.js → ro-RO-JPDTUUEW-DY0Xq_Hd.js} +1 -1
  80. package/client/dist/assets/{ru-RU-B4JR7IUQ-MirqN29p.js → ru-RU-B4JR7IUQ-B7u_Zvkd.js} +1 -1
  81. package/client/dist/assets/{sankeyDiagram-04a897e0-C6ij7qbQ.js → sankeyDiagram-04a897e0-D24gfzuS.js} +1 -1
  82. package/client/dist/assets/{sequenceDiagram-704730f1-C0EKO3th.js → sequenceDiagram-704730f1-Dgji2XLQ.js} +1 -1
  83. package/client/dist/assets/{si-LK-N5RQ5JYF-DyZC3mkC.js → si-LK-N5RQ5JYF-OejsLzQ_.js} +1 -1
  84. package/client/dist/assets/{sk-SK-C5VTKIMK-D-ksz-WY.js → sk-SK-C5VTKIMK-_vy2Bt-M.js} +1 -1
  85. package/client/dist/assets/{sl-SI-NN7IZMDC-CknuYoQ1.js → sl-SI-NN7IZMDC-DKOl_u2M.js} +1 -1
  86. package/client/dist/assets/{stateDiagram-587899a1-CYoq2VjL.js → stateDiagram-587899a1-CJ8eBaiU.js} +1 -1
  87. package/client/dist/assets/{stateDiagram-v2-d93cdb3a-C5lbp5px.js → stateDiagram-v2-d93cdb3a-C5K3l-Nt.js} +1 -1
  88. package/client/dist/assets/{styles-6aaf32cf-Dkfsk8gt.js → styles-6aaf32cf-DAKE0jbx.js} +1 -1
  89. package/client/dist/assets/{styles-9a916d00-CMYqtcEN.js → styles-9a916d00-LFAJCgEy.js} +1 -1
  90. package/client/dist/assets/{styles-c10674c1-Bp-5OlRU.js → styles-c10674c1-CllKO8NG.js} +1 -1
  91. package/client/dist/assets/{subset-shared.chunk-kfIB1Zam.js → subset-shared.chunk-Uy-J87FQ.js} +1 -1
  92. package/client/dist/assets/{subset-worker.chunk-DwQBgc4z.js → subset-worker.chunk-dvgDvqt9.js} +1 -1
  93. package/client/dist/assets/{sv-SE-XGPEYMSR-DwN13se1.js → sv-SE-XGPEYMSR-CDCB2ZV5.js} +1 -1
  94. package/client/dist/assets/{svgDrawCommon-08f97a94-CEgCMqs4.js → svgDrawCommon-08f97a94-CObOzbFQ.js} +1 -1
  95. package/client/dist/assets/{ta-IN-2NMHFXQM-ejDfFhwa.js → ta-IN-2NMHFXQM-DHUNdO69.js} +1 -1
  96. package/client/dist/assets/{th-TH-HPSO5L25-Bqc90ZNn.js → th-TH-HPSO5L25-zI2hnBq3.js} +1 -1
  97. package/client/dist/assets/{timeline-definition-85554ec2-BmGdKqG0.js → timeline-definition-85554ec2-C2XHRmxK.js} +1 -1
  98. package/client/dist/assets/{tr-TR-DEFEU3FU-CJvlPbcW.js → tr-TR-DEFEU3FU-l-6Hu4-D.js} +1 -1
  99. package/client/dist/assets/{uk-UA-QMV73CPH-D26-cbWL.js → uk-UA-QMV73CPH-CqSOwrl7.js} +1 -1
  100. package/client/dist/assets/{vendor-icons-aNdOvTr_.js → vendor-icons-Lb69KSFJ.js} +136 -126
  101. package/client/dist/assets/{vi-VN-M7AON7JQ-MbqIIwYM.js → vi-VN-M7AON7JQ-CUL8-mBZ.js} +1 -1
  102. package/client/dist/assets/{xychartDiagram-e933f94c-gfcTauxU.js → xychartDiagram-e933f94c-1fmf6slj.js} +1 -1
  103. package/client/dist/assets/{zh-CN-LNUGB5OW-BZSmhUdL.js → zh-CN-LNUGB5OW-CB5y5VVU.js} +1 -1
  104. package/client/dist/assets/{zh-HK-E62DVLB3-BJqejpiX.js → zh-HK-E62DVLB3-BHcrrEeJ.js} +1 -1
  105. package/client/dist/assets/{zh-TW-RAJ6MFWO-BBXtV-Uz.js → zh-TW-RAJ6MFWO-DoDUdkaJ.js} +1 -1
  106. package/client/dist/index.html +3 -3
  107. package/package.json +17 -14
  108. package/server/cli.js +44 -0
  109. package/server/database/db.js +16 -2
  110. package/server/index.js +2738 -2621
  111. package/server/middleware/auth.js +10 -2
  112. package/server/relay-client.js +73 -20
  113. package/server/routes/agent.js +1226 -1266
  114. package/server/routes/auth.js +32 -29
  115. package/server/routes/commands.js +598 -601
  116. package/server/routes/cursor.js +806 -807
  117. package/server/routes/dashboard.js +154 -1
  118. package/server/routes/git.js +1151 -1165
  119. package/server/routes/mcp.js +534 -551
  120. package/server/routes/settings.js +261 -269
  121. package/server/routes/taskmaster.js +1927 -1963
  122. package/server/routes/vapi-chat.js +94 -0
  123. package/server/routes/voice.js +0 -4
  124. package/server/sandbox.js +120 -0
  125. package/client/dist/assets/DashboardPanel-BXaA-b9z.js +0 -1
  126. package/client/dist/assets/Onboarding-2A_5fPxy.js +0 -1
  127. package/client/dist/assets/channel-BmO6nY0W.js +0 -1
  128. package/client/dist/assets/clone-xuHMqFoD.js +0 -1
  129. package/client/dist/assets/flowDiagram-v2-96b9c2cf-C5eiN8Pg.js +0 -1
  130. package/client/dist/assets/index-Td4UdtLF.css +0 -1
@@ -1,1165 +1,1151 @@
1
- import express from 'express';
2
- import { exec, spawn } from 'child_process';
3
- import { promisify } from 'util';
4
- import path from 'path';
5
- import { promises as fs } from 'fs';
6
- import { extractProjectDirectory } from '../projects.js';
7
- import { queryClaudeSDK } from '../claude-sdk.js';
8
- import { spawnCursor } from '../cursor-cli.js';
9
-
10
- const router = express.Router();
11
- const execAsync = promisify(exec);
12
-
13
- function spawnAsync(command, args, options = {}) {
14
- return new Promise((resolve, reject) => {
15
- const child = spawn(command, args, {
16
- ...options,
17
- shell: false,
18
- });
19
-
20
- let stdout = '';
21
- let stderr = '';
22
-
23
- child.stdout.on('data', (data) => {
24
- stdout += data.toString();
25
- });
26
-
27
- child.stderr.on('data', (data) => {
28
- stderr += data.toString();
29
- });
30
-
31
- child.on('error', (error) => {
32
- reject(error);
33
- });
34
-
35
- child.on('close', (code) => {
36
- if (code === 0) {
37
- resolve({ stdout, stderr });
38
- return;
39
- }
40
-
41
- const error = new Error(`Command failed: ${command} ${args.join(' ')}`);
42
- error.code = code;
43
- error.stdout = stdout;
44
- error.stderr = stderr;
45
- reject(error);
46
- });
47
- });
48
- }
49
-
50
- // Helper function to get the actual project path from the encoded project name
51
- async function getActualProjectPath(projectName) {
52
- try {
53
- return await extractProjectDirectory(projectName);
54
- } catch (error) {
55
- // project directory extraction error
56
- // Fallback to the old method
57
- return projectName.replace(/-/g, '/');
58
- }
59
- }
60
-
61
- // Helper function to strip git diff headers
62
- function stripDiffHeaders(diff) {
63
- if (!diff) return '';
64
-
65
- const lines = diff.split('\n');
66
- const filteredLines = [];
67
- let startIncluding = false;
68
-
69
- for (const line of lines) {
70
- // Skip all header lines including diff --git, index, file mode, and --- / +++ file paths
71
- if (line.startsWith('diff --git') ||
72
- line.startsWith('index ') ||
73
- line.startsWith('new file mode') ||
74
- line.startsWith('deleted file mode') ||
75
- line.startsWith('---') ||
76
- line.startsWith('+++')) {
77
- continue;
78
- }
79
-
80
- // Start including lines from @@ hunk headers onwards
81
- if (line.startsWith('@@') || startIncluding) {
82
- startIncluding = true;
83
- filteredLines.push(line);
84
- }
85
- }
86
-
87
- return filteredLines.join('\n');
88
- }
89
-
90
- // Helper function to validate git repository
91
- async function validateGitRepository(projectPath) {
92
- try {
93
- // Check if directory exists
94
- await fs.access(projectPath);
95
- } catch {
96
- throw new Error(`Project path not found: ${projectPath}`);
97
- }
98
-
99
- try {
100
- // Allow any directory that is inside a work tree (repo root or nested folder).
101
- const { stdout: insideWorkTreeOutput } = await execAsync('git rev-parse --is-inside-work-tree', { cwd: projectPath });
102
- const isInsideWorkTree = insideWorkTreeOutput.trim() === 'true';
103
- if (!isInsideWorkTree) {
104
- throw new Error('Not inside a git work tree');
105
- }
106
-
107
- // Ensure git can resolve the repository root for this directory.
108
- await execAsync('git rev-parse --show-toplevel', { cwd: projectPath });
109
- } catch {
110
- 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.');
111
- }
112
- }
113
-
114
- // Get git status for a project
115
- router.get('/status', async (req, res) => {
116
- const { project } = req.query;
117
-
118
- if (!project) {
119
- return res.status(400).json({ error: 'Project name is required' });
120
- }
121
-
122
- try {
123
- const projectPath = await getActualProjectPath(project);
124
-
125
- // Validate git repository
126
- await validateGitRepository(projectPath);
127
-
128
- // Get current branch - handle case where there are no commits yet
129
- let branch = 'main';
130
- let hasCommits = true;
131
- try {
132
- const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
133
- branch = branchOutput.trim();
134
- } catch (error) {
135
- // No HEAD exists - repository has no commits yet
136
- if (error.message.includes('unknown revision') || error.message.includes('ambiguous argument')) {
137
- hasCommits = false;
138
- branch = 'main';
139
- } else {
140
- throw error;
141
- }
142
- }
143
-
144
- // Get git status
145
- const { stdout: statusOutput } = await execAsync('git status --porcelain', { cwd: projectPath });
146
-
147
- const modified = [];
148
- const added = [];
149
- const deleted = [];
150
- const untracked = [];
151
-
152
- statusOutput.split('\n').forEach(line => {
153
- if (!line.trim()) return;
154
-
155
- const status = line.substring(0, 2);
156
- const file = line.substring(3);
157
-
158
- if (status === 'M ' || status === ' M' || status === 'MM') {
159
- modified.push(file);
160
- } else if (status === 'A ' || status === 'AM') {
161
- added.push(file);
162
- } else if (status === 'D ' || status === ' D') {
163
- deleted.push(file);
164
- } else if (status === '??') {
165
- untracked.push(file);
166
- }
167
- });
168
-
169
- res.json({
170
- branch,
171
- hasCommits,
172
- modified,
173
- added,
174
- deleted,
175
- untracked
176
- });
177
- } catch (error) {
178
- // git status error
179
- const isNotRepo = error.message?.includes('not a git repository');
180
- res.json({
181
- error: isNotRepo ? 'Not a git repository' : 'Git operation failed'
182
- });
183
- }
184
- });
185
-
186
- // Get diff for a specific file
187
- router.get('/diff', async (req, res) => {
188
- const { project, file } = req.query;
189
-
190
- if (!project || !file) {
191
- return res.status(400).json({ error: 'Project name and file path are required' });
192
- }
193
-
194
- try {
195
- const projectPath = await getActualProjectPath(project);
196
-
197
- // Validate git repository
198
- await validateGitRepository(projectPath);
199
-
200
- // Check if file is untracked or deleted
201
- const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
202
- const isUntracked = statusOutput.startsWith('??');
203
- const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
204
-
205
- let diff;
206
- if (isUntracked) {
207
- // For untracked files, show the entire file content as additions
208
- const filePath = path.join(projectPath, file);
209
- const stats = await fs.stat(filePath);
210
-
211
- if (stats.isDirectory()) {
212
- // For directories, show a simple message
213
- diff = `Directory: ${file}\n(Cannot show diff for directories)`;
214
- } else {
215
- const fileContent = await fs.readFile(filePath, 'utf-8');
216
- const lines = fileContent.split('\n');
217
- diff = `--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${lines.length} @@\n` +
218
- lines.map(line => `+${line}`).join('\n');
219
- }
220
- } else if (isDeleted) {
221
- // For deleted files, show the entire file content from HEAD as deletions
222
- const { stdout: fileContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath });
223
- const lines = fileContent.split('\n');
224
- diff = `--- a/${file}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n` +
225
- lines.map(line => `-${line}`).join('\n');
226
- } else {
227
- // Get diff for tracked files
228
- // First check for unstaged changes (working tree vs index)
229
- const { stdout: unstagedDiff } = await execAsync(`git diff -- "${file}"`, { cwd: projectPath });
230
-
231
- if (unstagedDiff) {
232
- // Show unstaged changes if they exist
233
- diff = stripDiffHeaders(unstagedDiff);
234
- } else {
235
- // If no unstaged changes, check for staged changes (index vs HEAD)
236
- const { stdout: stagedDiff } = await execAsync(`git diff --cached -- "${file}"`, { cwd: projectPath });
237
- diff = stripDiffHeaders(stagedDiff) || '';
238
- }
239
- }
240
-
241
- res.json({ diff });
242
- } catch (error) {
243
- // git diff error
244
- res.json({ error: 'Git operation failed' });
245
- }
246
- });
247
-
248
- // Get file content with diff information for CodeEditor
249
- router.get('/file-with-diff', async (req, res) => {
250
- const { project, file } = req.query;
251
-
252
- if (!project || !file) {
253
- return res.status(400).json({ error: 'Project name and file path are required' });
254
- }
255
-
256
- try {
257
- const projectPath = await getActualProjectPath(project);
258
-
259
- // Validate git repository
260
- await validateGitRepository(projectPath);
261
-
262
- // Check file status
263
- const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
264
- const isUntracked = statusOutput.startsWith('??');
265
- const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
266
-
267
- let currentContent = '';
268
- let oldContent = '';
269
-
270
- if (isDeleted) {
271
- // For deleted files, get content from HEAD
272
- const { stdout: headContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath });
273
- oldContent = headContent;
274
- currentContent = headContent; // Show the deleted content in editor
275
- } else {
276
- // Get current file content
277
- const filePath = path.join(projectPath, file);
278
- const stats = await fs.stat(filePath);
279
-
280
- if (stats.isDirectory()) {
281
- // Cannot show content for directories
282
- return res.status(400).json({ error: 'Cannot show diff for directories' });
283
- }
284
-
285
- currentContent = await fs.readFile(filePath, 'utf-8');
286
-
287
- if (!isUntracked) {
288
- // Get the old content from HEAD for tracked files
289
- try {
290
- const { stdout: headContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath });
291
- oldContent = headContent;
292
- } catch (error) {
293
- // File might be newly added to git (staged but not committed)
294
- oldContent = '';
295
- }
296
- }
297
- }
298
-
299
- res.json({
300
- currentContent,
301
- oldContent,
302
- isDeleted,
303
- isUntracked
304
- });
305
- } catch (error) {
306
- // git file-with-diff error
307
- res.json({ error: 'Git operation failed' });
308
- }
309
- });
310
-
311
- // Create initial commit
312
- router.post('/initial-commit', async (req, res) => {
313
- const { project } = req.body;
314
-
315
- if (!project) {
316
- return res.status(400).json({ error: 'Project name is required' });
317
- }
318
-
319
- try {
320
- const projectPath = await getActualProjectPath(project);
321
-
322
- // Validate git repository
323
- await validateGitRepository(projectPath);
324
-
325
- // Check if there are already commits
326
- try {
327
- await execAsync('git rev-parse HEAD', { cwd: projectPath });
328
- return res.status(400).json({ error: 'Repository already has commits. Use regular commit instead.' });
329
- } catch (error) {
330
- // No HEAD - this is good, we can create initial commit
331
- }
332
-
333
- // Add all files
334
- await execAsync('git add .', { cwd: projectPath });
335
-
336
- // Create initial commit
337
- const { stdout } = await execAsync('git commit -m "Initial commit"', { cwd: projectPath });
338
-
339
- res.json({ success: true, output: stdout, message: 'Initial commit created successfully' });
340
- } catch (error) {
341
- // git initial commit error
342
-
343
- // Handle the case where there's nothing to commit
344
- if (error.message.includes('nothing to commit')) {
345
- return res.status(400).json({
346
- error: 'Nothing to commit',
347
- details: 'No files found in the repository. Add some files first.'
348
- });
349
- }
350
-
351
- res.status(500).json({ error: 'Git operation failed' });
352
- }
353
- });
354
-
355
- // Commit changes
356
- router.post('/commit', async (req, res) => {
357
- const { project, message, files } = req.body;
358
-
359
- if (!project || !message || !files || files.length === 0) {
360
- return res.status(400).json({ error: 'Project name, commit message, and files are required' });
361
- }
362
-
363
- try {
364
- const projectPath = await getActualProjectPath(project);
365
-
366
- // Validate git repository
367
- await validateGitRepository(projectPath);
368
-
369
- // Stage selected files
370
- for (const file of files) {
371
- await execAsync(`git add "${file}"`, { cwd: projectPath });
372
- }
373
-
374
- // Commit with message
375
- const { stdout } = await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd: projectPath });
376
-
377
- res.json({ success: true, output: stdout });
378
- } catch (error) {
379
- // git commit error
380
- res.status(500).json({ error: 'Git operation failed' });
381
- }
382
- });
383
-
384
- // Get list of branches
385
- router.get('/branches', async (req, res) => {
386
- const { project } = req.query;
387
-
388
- if (!project) {
389
- return res.status(400).json({ error: 'Project name is required' });
390
- }
391
-
392
- try {
393
- const projectPath = await getActualProjectPath(project);
394
-
395
- // Validate git repository
396
- await validateGitRepository(projectPath);
397
-
398
- // Get all branches
399
- const { stdout } = await execAsync('git branch -a', { cwd: projectPath });
400
-
401
- // Parse branches
402
- const branches = stdout
403
- .split('\n')
404
- .map(branch => branch.trim())
405
- .filter(branch => branch && !branch.includes('->')) // Remove empty lines and HEAD pointer
406
- .map(branch => {
407
- // Remove asterisk from current branch
408
- if (branch.startsWith('* ')) {
409
- return branch.substring(2);
410
- }
411
- // Remove remotes/ prefix
412
- if (branch.startsWith('remotes/origin/')) {
413
- return branch.substring(15);
414
- }
415
- return branch;
416
- })
417
- .filter((branch, index, self) => self.indexOf(branch) === index); // Remove duplicates
418
-
419
- res.json({ branches });
420
- } catch (error) {
421
- // git branches error
422
- res.json({ error: 'Git operation failed' });
423
- }
424
- });
425
-
426
- // Checkout branch
427
- router.post('/checkout', async (req, res) => {
428
- const { project, branch } = req.body;
429
-
430
- if (!project || !branch) {
431
- return res.status(400).json({ error: 'Project name and branch are required' });
432
- }
433
-
434
- try {
435
- const projectPath = await getActualProjectPath(project);
436
-
437
- // Checkout the branch
438
- const { stdout } = await execAsync(`git checkout "${branch}"`, { cwd: projectPath });
439
-
440
- res.json({ success: true, output: stdout });
441
- } catch (error) {
442
- // git checkout error
443
- res.status(500).json({ error: 'Git operation failed' });
444
- }
445
- });
446
-
447
- // Create new branch
448
- router.post('/create-branch', async (req, res) => {
449
- const { project, branch } = req.body;
450
-
451
- if (!project || !branch) {
452
- return res.status(400).json({ error: 'Project name and branch name are required' });
453
- }
454
-
455
- try {
456
- const projectPath = await getActualProjectPath(project);
457
-
458
- // Create and checkout new branch
459
- const { stdout } = await execAsync(`git checkout -b "${branch}"`, { cwd: projectPath });
460
-
461
- res.json({ success: true, output: stdout });
462
- } catch (error) {
463
- // git create branch error
464
- res.status(500).json({ error: 'Git operation failed' });
465
- }
466
- });
467
-
468
- // Get recent commits
469
- router.get('/commits', async (req, res) => {
470
- const { project, limit = 10 } = req.query;
471
-
472
- if (!project) {
473
- return res.status(400).json({ error: 'Project name is required' });
474
- }
475
-
476
- try {
477
- const projectPath = await getActualProjectPath(project);
478
- await validateGitRepository(projectPath);
479
- const parsedLimit = Number.parseInt(String(limit), 10);
480
- const safeLimit = Number.isFinite(parsedLimit) && parsedLimit > 0
481
- ? Math.min(parsedLimit, 100)
482
- : 10;
483
-
484
- // Get commit log with stats
485
- const { stdout } = await spawnAsync(
486
- 'git',
487
- ['log', '--pretty=format:%H|%an|%ae|%ad|%s', '--date=relative', '-n', String(safeLimit)],
488
- { cwd: projectPath },
489
- );
490
-
491
- const commits = stdout
492
- .split('\n')
493
- .filter(line => line.trim())
494
- .map(line => {
495
- const [hash, author, email, date, ...messageParts] = line.split('|');
496
- return {
497
- hash,
498
- author,
499
- email,
500
- date,
501
- message: messageParts.join('|')
502
- };
503
- });
504
-
505
- // Get stats for each commit
506
- for (const commit of commits) {
507
- try {
508
- const { stdout: stats } = await execAsync(
509
- `git show --stat --format='' ${commit.hash}`,
510
- { cwd: projectPath }
511
- );
512
- commit.stats = stats.trim().split('\n').pop(); // Get the summary line
513
- } catch (error) {
514
- commit.stats = '';
515
- }
516
- }
517
-
518
- res.json({ commits });
519
- } catch (error) {
520
- // git commits error
521
- res.json({ error: 'Git operation failed' });
522
- }
523
- });
524
-
525
- // Get diff for a specific commit
526
- router.get('/commit-diff', async (req, res) => {
527
- const { project, commit } = req.query;
528
-
529
- if (!project || !commit) {
530
- return res.status(400).json({ error: 'Project name and commit hash are required' });
531
- }
532
-
533
- try {
534
- const projectPath = await getActualProjectPath(project);
535
-
536
- // Get diff for the commit
537
- const { stdout } = await execAsync(
538
- `git show ${commit}`,
539
- { cwd: projectPath }
540
- );
541
-
542
- res.json({ diff: stdout });
543
- } catch (error) {
544
- // git commit diff error
545
- res.json({ error: 'Git operation failed' });
546
- }
547
- });
548
-
549
- // Generate commit message based on staged changes using AI
550
- router.post('/generate-commit-message', async (req, res) => {
551
- const { project, files, provider = 'claude' } = req.body;
552
-
553
- if (!project || !files || files.length === 0) {
554
- return res.status(400).json({ error: 'Project name and files are required' });
555
- }
556
-
557
- // Validate provider
558
- if (!['claude', 'cursor'].includes(provider)) {
559
- return res.status(400).json({ error: 'provider must be "claude" or "cursor"' });
560
- }
561
-
562
- try {
563
- const projectPath = await getActualProjectPath(project);
564
-
565
- // Get diff for selected files
566
- let diffContext = '';
567
- for (const file of files) {
568
- try {
569
- const { stdout } = await execAsync(
570
- `git diff HEAD -- "${file}"`,
571
- { cwd: projectPath }
572
- );
573
- if (stdout) {
574
- diffContext += `\n--- ${file} ---\n${stdout}`;
575
- }
576
- } catch (error) {
577
- // diff error
578
- }
579
- }
580
-
581
- // If no diff found, might be untracked files
582
- if (!diffContext.trim()) {
583
- // Try to get content of untracked files
584
- for (const file of files) {
585
- try {
586
- const filePath = path.join(projectPath, file);
587
- const stats = await fs.stat(filePath);
588
-
589
- if (!stats.isDirectory()) {
590
- const content = await fs.readFile(filePath, 'utf-8');
591
- diffContext += `\n--- ${file} (new file) ---\n${content.substring(0, 1000)}\n`;
592
- } else {
593
- diffContext += `\n--- ${file} (new directory) ---\n`;
594
- }
595
- } catch (error) {
596
- // file read error
597
- }
598
- }
599
- }
600
-
601
- // Generate commit message using AI
602
- const message = await generateCommitMessageWithAI(files, diffContext, provider, projectPath);
603
-
604
- res.json({ message });
605
- } catch (error) {
606
- // commit message error
607
- res.status(500).json({ error: 'Git operation failed' });
608
- }
609
- });
610
-
611
- /**
612
- * Generates a commit message using AI (Claude SDK or Cursor CLI)
613
- * @param {Array<string>} files - List of changed files
614
- * @param {string} diffContext - Git diff content
615
- * @param {string} provider - 'claude' or 'cursor'
616
- * @param {string} projectPath - Project directory path
617
- * @returns {Promise<string>} Generated commit message
618
- */
619
- async function generateCommitMessageWithAI(files, diffContext, provider, projectPath) {
620
- // Create the prompt
621
- const prompt = `Generate a conventional commit message for these changes.
622
-
623
- REQUIREMENTS:
624
- - Format: type(scope): subject
625
- - Include body explaining what changed and why
626
- - Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore
627
- - Subject under 50 chars, body wrapped at 72 chars
628
- - Focus on user-facing changes, not implementation details
629
- - Consider what's being added AND removed
630
- - Return ONLY the commit message (no markdown, explanations, or code blocks)
631
-
632
- FILES CHANGED:
633
- ${files.map(f => `- ${f}`).join('\n')}
634
-
635
- DIFFS:
636
- ${diffContext.substring(0, 4000)}
637
-
638
- Generate the commit message:`;
639
-
640
- try {
641
- // Create a simple writer that collects the response
642
- let responseText = '';
643
- const writer = {
644
- send: (data) => {
645
- try {
646
- const parsed = typeof data === 'string' ? JSON.parse(data) : data;
647
- console.log('🔍 Writer received message type:', parsed.type);
648
-
649
- // Handle different message formats from Claude SDK and Cursor CLI
650
- // Claude SDK sends: {type: 'claude-response', data: {message: {content: [...]}}}
651
- if (parsed.type === 'claude-response' && parsed.data) {
652
- const message = parsed.data.message || parsed.data;
653
- console.log('📦 Claude response message:', JSON.stringify(message, null, 2).substring(0, 500));
654
- if (message.content && Array.isArray(message.content)) {
655
- // Extract text from content array
656
- for (const item of message.content) {
657
- if (item.type === 'text' && item.text) {
658
- console.log('✅ Extracted text chunk:', item.text.substring(0, 100));
659
- responseText += item.text;
660
- }
661
- }
662
- }
663
- }
664
- // Cursor CLI sends: {type: 'cursor-output', output: '...'}
665
- else if (parsed.type === 'cursor-output' && parsed.output) {
666
- console.log('✅ Cursor output:', parsed.output.substring(0, 100));
667
- responseText += parsed.output;
668
- }
669
- // Also handle direct text messages
670
- else if (parsed.type === 'text' && parsed.text) {
671
- console.log('✅ Direct text:', parsed.text.substring(0, 100));
672
- responseText += parsed.text;
673
- }
674
- } catch (e) {
675
- // Ignore parse errors
676
- console.error('Error parsing writer data:', e);
677
- }
678
- },
679
- setSessionId: () => {}, // No-op for this use case
680
- };
681
-
682
- console.log('🚀 Calling AI agent with provider:', provider);
683
- console.log('📝 Prompt length:', prompt.length);
684
-
685
- // Call the appropriate agent
686
- if (provider === 'claude') {
687
- await queryClaudeSDK(prompt, {
688
- cwd: projectPath,
689
- permissionMode: 'bypassPermissions',
690
- model: 'sonnet'
691
- }, writer);
692
- } else if (provider === 'cursor') {
693
- await spawnCursor(prompt, {
694
- cwd: projectPath,
695
- skipPermissions: true
696
- }, writer);
697
- }
698
-
699
- console.log('📊 Total response text collected:', responseText.length, 'characters');
700
- console.log('📄 Response preview:', responseText.substring(0, 200));
701
-
702
- // Clean up the response
703
- const cleanedMessage = cleanCommitMessage(responseText);
704
- console.log('🧹 Cleaned message:', cleanedMessage.substring(0, 200));
705
-
706
- return cleanedMessage || 'chore: update files';
707
- } catch (error) {
708
- // AI commit message error
709
- // Fallback to simple message
710
- return `chore: update ${files.length} file${files.length !== 1 ? 's' : ''}`;
711
- }
712
- }
713
-
714
- /**
715
- * Cleans the AI-generated commit message by removing markdown, code blocks, and extra formatting
716
- * @param {string} text - Raw AI response
717
- * @returns {string} Clean commit message
718
- */
719
- function cleanCommitMessage(text) {
720
- if (!text || !text.trim()) {
721
- return '';
722
- }
723
-
724
- let cleaned = text.trim();
725
-
726
- // Remove markdown code blocks
727
- cleaned = cleaned.replace(/```[a-z]*\n/g, '');
728
- cleaned = cleaned.replace(/```/g, '');
729
-
730
- // Remove markdown headers
731
- cleaned = cleaned.replace(/^#+\s*/gm, '');
732
-
733
- // Remove leading/trailing quotes
734
- cleaned = cleaned.replace(/^["']|["']$/g, '');
735
-
736
- // If there are multiple lines, take everything (subject + body)
737
- // Just clean up extra blank lines
738
- cleaned = cleaned.replace(/\n{3,}/g, '\n\n');
739
-
740
- // Remove any explanatory text before the actual commit message
741
- // Look for conventional commit pattern and start from there
742
- const conventionalCommitMatch = cleaned.match(/(feat|fix|docs|style|refactor|perf|test|build|ci|chore)(\(.+?\))?:.+/s);
743
- if (conventionalCommitMatch) {
744
- cleaned = cleaned.substring(cleaned.indexOf(conventionalCommitMatch[0]));
745
- }
746
-
747
- return cleaned.trim();
748
- }
749
-
750
- // Get remote status (ahead/behind commits with smart remote detection)
751
- router.get('/remote-status', async (req, res) => {
752
- const { project } = req.query;
753
-
754
- if (!project) {
755
- return res.status(400).json({ error: 'Project name is required' });
756
- }
757
-
758
- try {
759
- const projectPath = await getActualProjectPath(project);
760
- await validateGitRepository(projectPath);
761
-
762
- // Get current branch
763
- const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
764
- const branch = currentBranch.trim();
765
-
766
- // Check if there's a remote tracking branch (smart detection)
767
- let trackingBranch;
768
- let remoteName;
769
- try {
770
- const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
771
- trackingBranch = stdout.trim();
772
- remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin")
773
- } catch (error) {
774
- // No upstream branch configured - but check if we have remotes
775
- let hasRemote = false;
776
- let remoteName = null;
777
- try {
778
- const { stdout } = await execAsync('git remote', { cwd: projectPath });
779
- const remotes = stdout.trim().split('\n').filter(r => r.trim());
780
- if (remotes.length > 0) {
781
- hasRemote = true;
782
- remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
783
- }
784
- } catch (remoteError) {
785
- // No remotes configured
786
- }
787
-
788
- return res.json({
789
- hasRemote,
790
- hasUpstream: false,
791
- branch,
792
- remoteName,
793
- message: 'No remote tracking branch configured'
794
- });
795
- }
796
-
797
- // Get ahead/behind counts
798
- const { stdout: countOutput } = await execAsync(
799
- `git rev-list --count --left-right ${trackingBranch}...HEAD`,
800
- { cwd: projectPath }
801
- );
802
-
803
- const [behind, ahead] = countOutput.trim().split('\t').map(Number);
804
-
805
- res.json({
806
- hasRemote: true,
807
- hasUpstream: true,
808
- branch,
809
- remoteBranch: trackingBranch,
810
- remoteName,
811
- ahead: ahead || 0,
812
- behind: behind || 0,
813
- isUpToDate: ahead === 0 && behind === 0
814
- });
815
- } catch (error) {
816
- // git remote status error
817
- res.json({ error: 'Git operation failed' });
818
- }
819
- });
820
-
821
- // Fetch from remote (using smart remote detection)
822
- router.post('/fetch', async (req, res) => {
823
- const { project } = req.body;
824
-
825
- if (!project) {
826
- return res.status(400).json({ error: 'Project name is required' });
827
- }
828
-
829
- try {
830
- const projectPath = await getActualProjectPath(project);
831
- await validateGitRepository(projectPath);
832
-
833
- // Get current branch and its upstream remote
834
- const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
835
- const branch = currentBranch.trim();
836
-
837
- let remoteName = 'origin'; // fallback
838
- try {
839
- const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
840
- remoteName = stdout.trim().split('/')[0]; // Extract remote name
841
- } catch (error) {
842
- // No upstream, try to fetch from origin anyway
843
- console.log('No upstream configured, using origin as fallback');
844
- }
845
-
846
- const { stdout } = await execAsync(`git fetch ${remoteName}`, { cwd: projectPath });
847
-
848
- res.json({ success: true, output: stdout || 'Fetch completed successfully', remoteName });
849
- } catch (error) {
850
- // git fetch error
851
- const msg = error.message || '';
852
- res.status(500).json({
853
- error: 'Fetch failed',
854
- details: msg.includes('Could not resolve hostname')
855
- ? 'Unable to connect to remote repository. Check your internet connection.'
856
- : msg.includes('does not appear to be a git repository')
857
- ? 'No remote repository configured.'
858
- : 'Failed to fetch from remote'
859
- });
860
- }
861
- });
862
-
863
- // Pull from remote (fetch + merge using smart remote detection)
864
- router.post('/pull', async (req, res) => {
865
- const { project } = req.body;
866
-
867
- if (!project) {
868
- return res.status(400).json({ error: 'Project name is required' });
869
- }
870
-
871
- try {
872
- const projectPath = await getActualProjectPath(project);
873
- await validateGitRepository(projectPath);
874
-
875
- // Get current branch and its upstream remote
876
- const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
877
- const branch = currentBranch.trim();
878
-
879
- let remoteName = 'origin'; // fallback
880
- let remoteBranch = branch; // fallback
881
- try {
882
- const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
883
- const tracking = stdout.trim();
884
- remoteName = tracking.split('/')[0]; // Extract remote name
885
- remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name
886
- } catch (error) {
887
- // No upstream, use fallback
888
- console.log('No upstream configured, using origin/branch as fallback');
889
- }
890
-
891
- const { stdout } = await execAsync(`git pull ${remoteName} ${remoteBranch}`, { cwd: projectPath });
892
-
893
- res.json({
894
- success: true,
895
- output: stdout || 'Pull completed successfully',
896
- remoteName,
897
- remoteBranch
898
- });
899
- } catch (error) {
900
- // git pull error
901
-
902
- // Enhanced error handling for common pull scenarios
903
- let errorMessage = 'Pull failed';
904
- let details = 'Operation failed';
905
-
906
- if (error.message.includes('CONFLICT')) {
907
- errorMessage = 'Merge conflicts detected';
908
- details = 'Pull created merge conflicts. Please resolve conflicts manually in the editor, then commit the changes.';
909
- } else if (error.message.includes('Please commit your changes or stash them')) {
910
- errorMessage = 'Uncommitted changes detected';
911
- details = 'Please commit or stash your local changes before pulling.';
912
- } else if (error.message.includes('Could not resolve hostname')) {
913
- errorMessage = 'Network error';
914
- details = 'Unable to connect to remote repository. Check your internet connection.';
915
- } else if (error.message.includes('fatal: \'origin\' does not appear to be a git repository')) {
916
- errorMessage = 'Remote not configured';
917
- details = 'No remote repository configured. Add a remote with: git remote add origin <url>';
918
- } else if (error.message.includes('diverged')) {
919
- errorMessage = 'Branches have diverged';
920
- details = 'Your local branch and remote branch have diverged. Consider fetching first to review changes.';
921
- }
922
-
923
- res.status(500).json({
924
- error: errorMessage,
925
- details: details
926
- });
927
- }
928
- });
929
-
930
- // Push commits to remote repository
931
- router.post('/push', async (req, res) => {
932
- const { project } = req.body;
933
-
934
- if (!project) {
935
- return res.status(400).json({ error: 'Project name is required' });
936
- }
937
-
938
- try {
939
- const projectPath = await getActualProjectPath(project);
940
- await validateGitRepository(projectPath);
941
-
942
- // Get current branch and its upstream remote
943
- const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
944
- const branch = currentBranch.trim();
945
-
946
- let remoteName = 'origin'; // fallback
947
- let remoteBranch = branch; // fallback
948
- try {
949
- const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
950
- const tracking = stdout.trim();
951
- remoteName = tracking.split('/')[0]; // Extract remote name
952
- remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name
953
- } catch (error) {
954
- // No upstream, use fallback
955
- console.log('No upstream configured, using origin/branch as fallback');
956
- }
957
-
958
- const { stdout } = await execAsync(`git push ${remoteName} ${remoteBranch}`, { cwd: projectPath });
959
-
960
- res.json({
961
- success: true,
962
- output: stdout || 'Push completed successfully',
963
- remoteName,
964
- remoteBranch
965
- });
966
- } catch (error) {
967
- // git push error
968
-
969
- // Enhanced error handling for common push scenarios
970
- let errorMessage = 'Push failed';
971
- let details = 'Operation failed';
972
-
973
- if (error.message.includes('rejected')) {
974
- errorMessage = 'Push rejected';
975
- details = 'The remote has newer commits. Pull first to merge changes before pushing.';
976
- } else if (error.message.includes('non-fast-forward')) {
977
- errorMessage = 'Non-fast-forward push';
978
- details = 'Your branch is behind the remote. Pull the latest changes first.';
979
- } else if (error.message.includes('Could not resolve hostname')) {
980
- errorMessage = 'Network error';
981
- details = 'Unable to connect to remote repository. Check your internet connection.';
982
- } else if (error.message.includes('fatal: \'origin\' does not appear to be a git repository')) {
983
- errorMessage = 'Remote not configured';
984
- details = 'No remote repository configured. Add a remote with: git remote add origin <url>';
985
- } else if (error.message.includes('Permission denied')) {
986
- errorMessage = 'Authentication failed';
987
- details = 'Permission denied. Check your credentials or SSH keys.';
988
- } else if (error.message.includes('no upstream branch')) {
989
- errorMessage = 'No upstream branch';
990
- details = 'No upstream branch configured. Use: git push --set-upstream origin <branch>';
991
- }
992
-
993
- res.status(500).json({
994
- error: errorMessage,
995
- details: details
996
- });
997
- }
998
- });
999
-
1000
- // Publish branch to remote (set upstream and push)
1001
- router.post('/publish', async (req, res) => {
1002
- const { project, branch } = req.body;
1003
-
1004
- if (!project || !branch) {
1005
- return res.status(400).json({ error: 'Project name and branch are required' });
1006
- }
1007
-
1008
- try {
1009
- const projectPath = await getActualProjectPath(project);
1010
- await validateGitRepository(projectPath);
1011
-
1012
- // Get current branch to verify it matches the requested branch
1013
- const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
1014
- const currentBranchName = currentBranch.trim();
1015
-
1016
- if (currentBranchName !== branch) {
1017
- return res.status(400).json({
1018
- error: `Branch mismatch. Current branch is ${currentBranchName}, but trying to publish ${branch}`
1019
- });
1020
- }
1021
-
1022
- // Check if remote exists
1023
- let remoteName = 'origin';
1024
- try {
1025
- const { stdout } = await execAsync('git remote', { cwd: projectPath });
1026
- const remotes = stdout.trim().split('\n').filter(r => r.trim());
1027
- if (remotes.length === 0) {
1028
- return res.status(400).json({
1029
- error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
1030
- });
1031
- }
1032
- remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
1033
- } catch (error) {
1034
- return res.status(400).json({
1035
- error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
1036
- });
1037
- }
1038
-
1039
- // Publish the branch (set upstream and push)
1040
- const { stdout } = await execAsync(`git push --set-upstream ${remoteName} ${branch}`, { cwd: projectPath });
1041
-
1042
- res.json({
1043
- success: true,
1044
- output: stdout || 'Branch published successfully',
1045
- remoteName,
1046
- branch
1047
- });
1048
- } catch (error) {
1049
- // git publish error
1050
-
1051
- // Enhanced error handling for common publish scenarios
1052
- let errorMessage = 'Publish failed';
1053
- let details = 'Operation failed';
1054
-
1055
- if (error.message.includes('rejected')) {
1056
- errorMessage = 'Publish rejected';
1057
- details = 'The remote branch already exists and has different commits. Use push instead.';
1058
- } else if (error.message.includes('Could not resolve hostname')) {
1059
- errorMessage = 'Network error';
1060
- details = 'Unable to connect to remote repository. Check your internet connection.';
1061
- } else if (error.message.includes('Permission denied')) {
1062
- errorMessage = 'Authentication failed';
1063
- details = 'Permission denied. Check your credentials or SSH keys.';
1064
- } else if (error.message.includes('fatal:') && error.message.includes('does not appear to be a git repository')) {
1065
- errorMessage = 'Remote not configured';
1066
- details = 'Remote repository not properly configured. Check your remote URL.';
1067
- }
1068
-
1069
- res.status(500).json({
1070
- error: errorMessage,
1071
- details: details
1072
- });
1073
- }
1074
- });
1075
-
1076
- // Discard changes for a specific file
1077
- router.post('/discard', async (req, res) => {
1078
- const { project, file } = req.body;
1079
-
1080
- if (!project || !file) {
1081
- return res.status(400).json({ error: 'Project name and file path are required' });
1082
- }
1083
-
1084
- try {
1085
- const projectPath = await getActualProjectPath(project);
1086
- await validateGitRepository(projectPath);
1087
-
1088
- // Check file status to determine correct discard command
1089
- const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
1090
-
1091
- if (!statusOutput.trim()) {
1092
- return res.status(400).json({ error: 'No changes to discard for this file' });
1093
- }
1094
-
1095
- const status = statusOutput.substring(0, 2);
1096
-
1097
- if (status === '??') {
1098
- // Untracked file or directory - delete it
1099
- const filePath = path.join(projectPath, file);
1100
- const stats = await fs.stat(filePath);
1101
-
1102
- if (stats.isDirectory()) {
1103
- await fs.rm(filePath, { recursive: true, force: true });
1104
- } else {
1105
- await fs.unlink(filePath);
1106
- }
1107
- } else if (status.includes('M') || status.includes('D')) {
1108
- // Modified or deleted file - restore from HEAD
1109
- await execAsync(`git restore "${file}"`, { cwd: projectPath });
1110
- } else if (status.includes('A')) {
1111
- // Added file - unstage it
1112
- await execAsync(`git reset HEAD "${file}"`, { cwd: projectPath });
1113
- }
1114
-
1115
- res.json({ success: true, message: `Changes discarded for ${file}` });
1116
- } catch (error) {
1117
- // git discard error
1118
- res.status(500).json({ error: 'Git operation failed' });
1119
- }
1120
- });
1121
-
1122
- // Delete untracked file
1123
- router.post('/delete-untracked', async (req, res) => {
1124
- const { project, file } = req.body;
1125
-
1126
- if (!project || !file) {
1127
- return res.status(400).json({ error: 'Project name and file path are required' });
1128
- }
1129
-
1130
- try {
1131
- const projectPath = await getActualProjectPath(project);
1132
- await validateGitRepository(projectPath);
1133
-
1134
- // Check if file is actually untracked
1135
- const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
1136
-
1137
- if (!statusOutput.trim()) {
1138
- return res.status(400).json({ error: 'File is not untracked or does not exist' });
1139
- }
1140
-
1141
- const status = statusOutput.substring(0, 2);
1142
-
1143
- if (status !== '??') {
1144
- return res.status(400).json({ error: 'File is not untracked. Use discard for tracked files.' });
1145
- }
1146
-
1147
- // Delete the untracked file or directory
1148
- const filePath = path.join(projectPath, file);
1149
- const stats = await fs.stat(filePath);
1150
-
1151
- if (stats.isDirectory()) {
1152
- // Use rm with recursive option for directories
1153
- await fs.rm(filePath, { recursive: true, force: true });
1154
- res.json({ success: true, message: `Untracked directory ${file} deleted successfully` });
1155
- } else {
1156
- await fs.unlink(filePath);
1157
- res.json({ success: true, message: `Untracked file ${file} deleted successfully` });
1158
- }
1159
- } catch (error) {
1160
- // git delete untracked error
1161
- res.status(500).json({ error: 'Git operation failed' });
1162
- }
1163
- });
1164
-
1165
- export default router;
1
+ import express from 'express';
2
+ import { exec, spawn } from 'child_process';
3
+ import { promisify } from 'util';
4
+ import path from 'path';
5
+ import { promises as fs } from 'fs';
6
+ import { extractProjectDirectory } from '../projects.js';
7
+ import { queryClaudeSDK } from '../claude-sdk.js';
8
+ import { spawnCursor } from '../cursor-cli.js';
9
+
10
+ const router = express.Router();
11
+ const execAsync = promisify(exec);
12
+
13
+ function spawnAsync(command, args, options = {}) {
14
+ return new Promise((resolve, reject) => {
15
+ const child = spawn(command, args, {
16
+ ...options,
17
+ shell: false,
18
+ });
19
+
20
+ let stdout = '';
21
+ let stderr = '';
22
+
23
+ child.stdout.on('data', (data) => {
24
+ stdout += data.toString();
25
+ });
26
+
27
+ child.stderr.on('data', (data) => {
28
+ stderr += data.toString();
29
+ });
30
+
31
+ child.on('error', (error) => {
32
+ reject(error);
33
+ });
34
+
35
+ child.on('close', (code) => {
36
+ if (code === 0) {
37
+ resolve({ stdout, stderr });
38
+ return;
39
+ }
40
+
41
+ const error = new Error(`Command failed: ${command} ${args.join(' ')}`);
42
+ error.code = code;
43
+ error.stdout = stdout;
44
+ error.stderr = stderr;
45
+ reject(error);
46
+ });
47
+ });
48
+ }
49
+
50
+ // Helper function to get the actual project path from the encoded project name
51
+ async function getActualProjectPath(projectName) {
52
+ try {
53
+ return await extractProjectDirectory(projectName);
54
+ } catch (error) {
55
+ // project directory extraction error
56
+ // Fallback to the old method
57
+ return projectName.replace(/-/g, '/');
58
+ }
59
+ }
60
+
61
+ // Helper function to strip git diff headers
62
+ function stripDiffHeaders(diff) {
63
+ if (!diff) return '';
64
+
65
+ const lines = diff.split('\n');
66
+ const filteredLines = [];
67
+ let startIncluding = false;
68
+
69
+ for (const line of lines) {
70
+ // Skip all header lines including diff --git, index, file mode, and --- / +++ file paths
71
+ if (line.startsWith('diff --git') ||
72
+ line.startsWith('index ') ||
73
+ line.startsWith('new file mode') ||
74
+ line.startsWith('deleted file mode') ||
75
+ line.startsWith('---') ||
76
+ line.startsWith('+++')) {
77
+ continue;
78
+ }
79
+
80
+ // Start including lines from @@ hunk headers onwards
81
+ if (line.startsWith('@@') || startIncluding) {
82
+ startIncluding = true;
83
+ filteredLines.push(line);
84
+ }
85
+ }
86
+
87
+ return filteredLines.join('\n');
88
+ }
89
+
90
+ // Helper function to validate git repository
91
+ async function validateGitRepository(projectPath) {
92
+ try {
93
+ // Check if directory exists
94
+ await fs.access(projectPath);
95
+ } catch {
96
+ throw new Error(`Project path not found: ${projectPath}`);
97
+ }
98
+
99
+ try {
100
+ // Allow any directory that is inside a work tree (repo root or nested folder).
101
+ const { stdout: insideWorkTreeOutput } = await execAsync('git rev-parse --is-inside-work-tree', { cwd: projectPath });
102
+ const isInsideWorkTree = insideWorkTreeOutput.trim() === 'true';
103
+ if (!isInsideWorkTree) {
104
+ throw new Error('Not inside a git work tree');
105
+ }
106
+
107
+ // Ensure git can resolve the repository root for this directory.
108
+ await execAsync('git rev-parse --show-toplevel', { cwd: projectPath });
109
+ } catch {
110
+ 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.');
111
+ }
112
+ }
113
+
114
+ // Get git status for a project
115
+ router.get('/status', async (req, res) => {
116
+ const { project } = req.query;
117
+
118
+ if (!project) {
119
+ return res.status(400).json({ error: 'Project name is required' });
120
+ }
121
+
122
+ try {
123
+ const projectPath = await getActualProjectPath(project);
124
+
125
+ // Validate git repository
126
+ await validateGitRepository(projectPath);
127
+
128
+ // Get current branch - handle case where there are no commits yet
129
+ let branch = 'main';
130
+ let hasCommits = true;
131
+ try {
132
+ const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
133
+ branch = branchOutput.trim();
134
+ } catch (error) {
135
+ // No HEAD exists - repository has no commits yet
136
+ if (error.message.includes('unknown revision') || error.message.includes('ambiguous argument')) {
137
+ hasCommits = false;
138
+ branch = 'main';
139
+ } else {
140
+ throw error;
141
+ }
142
+ }
143
+
144
+ // Get git status
145
+ const { stdout: statusOutput } = await execAsync('git status --porcelain', { cwd: projectPath });
146
+
147
+ const modified = [];
148
+ const added = [];
149
+ const deleted = [];
150
+ const untracked = [];
151
+
152
+ statusOutput.split('\n').forEach(line => {
153
+ if (!line.trim()) return;
154
+
155
+ const status = line.substring(0, 2);
156
+ const file = line.substring(3);
157
+
158
+ if (status === 'M ' || status === ' M' || status === 'MM') {
159
+ modified.push(file);
160
+ } else if (status === 'A ' || status === 'AM') {
161
+ added.push(file);
162
+ } else if (status === 'D ' || status === ' D') {
163
+ deleted.push(file);
164
+ } else if (status === '??') {
165
+ untracked.push(file);
166
+ }
167
+ });
168
+
169
+ res.json({
170
+ branch,
171
+ hasCommits,
172
+ modified,
173
+ added,
174
+ deleted,
175
+ untracked
176
+ });
177
+ } catch (error) {
178
+ // git status error
179
+ const isNotRepo = error.message?.includes('not a git repository');
180
+ res.json({
181
+ error: isNotRepo ? 'Not a git repository' : 'Git operation failed'
182
+ });
183
+ }
184
+ });
185
+
186
+ // Get diff for a specific file
187
+ router.get('/diff', async (req, res) => {
188
+ const { project, file } = req.query;
189
+
190
+ if (!project || !file) {
191
+ return res.status(400).json({ error: 'Project name and file path are required' });
192
+ }
193
+
194
+ try {
195
+ const projectPath = await getActualProjectPath(project);
196
+
197
+ // Validate git repository
198
+ await validateGitRepository(projectPath);
199
+
200
+ // Check if file is untracked or deleted
201
+ const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
202
+ const isUntracked = statusOutput.startsWith('??');
203
+ const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
204
+
205
+ let diff;
206
+ if (isUntracked) {
207
+ // For untracked files, show the entire file content as additions
208
+ const filePath = path.join(projectPath, file);
209
+ const stats = await fs.stat(filePath);
210
+
211
+ if (stats.isDirectory()) {
212
+ // For directories, show a simple message
213
+ diff = `Directory: ${file}\n(Cannot show diff for directories)`;
214
+ } else {
215
+ const fileContent = await fs.readFile(filePath, 'utf-8');
216
+ const lines = fileContent.split('\n');
217
+ diff = `--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${lines.length} @@\n` +
218
+ lines.map(line => `+${line}`).join('\n');
219
+ }
220
+ } else if (isDeleted) {
221
+ // For deleted files, show the entire file content from HEAD as deletions
222
+ const { stdout: fileContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath });
223
+ const lines = fileContent.split('\n');
224
+ diff = `--- a/${file}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n` +
225
+ lines.map(line => `-${line}`).join('\n');
226
+ } else {
227
+ // Get diff for tracked files
228
+ // First check for unstaged changes (working tree vs index)
229
+ const { stdout: unstagedDiff } = await execAsync(`git diff -- "${file}"`, { cwd: projectPath });
230
+
231
+ if (unstagedDiff) {
232
+ // Show unstaged changes if they exist
233
+ diff = stripDiffHeaders(unstagedDiff);
234
+ } else {
235
+ // If no unstaged changes, check for staged changes (index vs HEAD)
236
+ const { stdout: stagedDiff } = await execAsync(`git diff --cached -- "${file}"`, { cwd: projectPath });
237
+ diff = stripDiffHeaders(stagedDiff) || '';
238
+ }
239
+ }
240
+
241
+ res.json({ diff });
242
+ } catch (error) {
243
+ // git diff error
244
+ res.json({ error: 'Git operation failed' });
245
+ }
246
+ });
247
+
248
+ // Get file content with diff information for CodeEditor
249
+ router.get('/file-with-diff', async (req, res) => {
250
+ const { project, file } = req.query;
251
+
252
+ if (!project || !file) {
253
+ return res.status(400).json({ error: 'Project name and file path are required' });
254
+ }
255
+
256
+ try {
257
+ const projectPath = await getActualProjectPath(project);
258
+
259
+ // Validate git repository
260
+ await validateGitRepository(projectPath);
261
+
262
+ // Check file status
263
+ const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
264
+ const isUntracked = statusOutput.startsWith('??');
265
+ const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
266
+
267
+ let currentContent = '';
268
+ let oldContent = '';
269
+
270
+ if (isDeleted) {
271
+ // For deleted files, get content from HEAD
272
+ const { stdout: headContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath });
273
+ oldContent = headContent;
274
+ currentContent = headContent; // Show the deleted content in editor
275
+ } else {
276
+ // Get current file content
277
+ const filePath = path.join(projectPath, file);
278
+ const stats = await fs.stat(filePath);
279
+
280
+ if (stats.isDirectory()) {
281
+ // Cannot show content for directories
282
+ return res.status(400).json({ error: 'Cannot show diff for directories' });
283
+ }
284
+
285
+ currentContent = await fs.readFile(filePath, 'utf-8');
286
+
287
+ if (!isUntracked) {
288
+ // Get the old content from HEAD for tracked files
289
+ try {
290
+ const { stdout: headContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath });
291
+ oldContent = headContent;
292
+ } catch (error) {
293
+ // File might be newly added to git (staged but not committed)
294
+ oldContent = '';
295
+ }
296
+ }
297
+ }
298
+
299
+ res.json({
300
+ currentContent,
301
+ oldContent,
302
+ isDeleted,
303
+ isUntracked
304
+ });
305
+ } catch (error) {
306
+ // git file-with-diff error
307
+ res.json({ error: 'Git operation failed' });
308
+ }
309
+ });
310
+
311
+ // Create initial commit
312
+ router.post('/initial-commit', async (req, res) => {
313
+ const { project } = req.body;
314
+
315
+ if (!project) {
316
+ return res.status(400).json({ error: 'Project name is required' });
317
+ }
318
+
319
+ try {
320
+ const projectPath = await getActualProjectPath(project);
321
+
322
+ // Validate git repository
323
+ await validateGitRepository(projectPath);
324
+
325
+ // Check if there are already commits
326
+ try {
327
+ await execAsync('git rev-parse HEAD', { cwd: projectPath });
328
+ return res.status(400).json({ error: 'Repository already has commits. Use regular commit instead.' });
329
+ } catch (error) {
330
+ // No HEAD - this is good, we can create initial commit
331
+ }
332
+
333
+ // Add all files
334
+ await execAsync('git add .', { cwd: projectPath });
335
+
336
+ // Create initial commit
337
+ const { stdout } = await execAsync('git commit -m "Initial commit"', { cwd: projectPath });
338
+
339
+ res.json({ success: true, output: stdout, message: 'Initial commit created successfully' });
340
+ } catch (error) {
341
+ // git initial commit error
342
+
343
+ // Handle the case where there's nothing to commit
344
+ if (error.message.includes('nothing to commit')) {
345
+ return res.status(400).json({
346
+ error: 'Nothing to commit',
347
+ details: 'No files found in the repository. Add some files first.'
348
+ });
349
+ }
350
+
351
+ res.status(500).json({ error: 'Git operation failed' });
352
+ }
353
+ });
354
+
355
+ // Commit changes
356
+ router.post('/commit', async (req, res) => {
357
+ const { project, message, files } = req.body;
358
+
359
+ if (!project || !message || !files || files.length === 0) {
360
+ return res.status(400).json({ error: 'Project name, commit message, and files are required' });
361
+ }
362
+
363
+ try {
364
+ const projectPath = await getActualProjectPath(project);
365
+
366
+ // Validate git repository
367
+ await validateGitRepository(projectPath);
368
+
369
+ // Stage selected files
370
+ for (const file of files) {
371
+ await execAsync(`git add "${file}"`, { cwd: projectPath });
372
+ }
373
+
374
+ // Commit with message
375
+ const { stdout } = await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd: projectPath });
376
+
377
+ res.json({ success: true, output: stdout });
378
+ } catch (error) {
379
+ // git commit error
380
+ res.status(500).json({ error: 'Git operation failed' });
381
+ }
382
+ });
383
+
384
+ // Get list of branches
385
+ router.get('/branches', async (req, res) => {
386
+ const { project } = req.query;
387
+
388
+ if (!project) {
389
+ return res.status(400).json({ error: 'Project name is required' });
390
+ }
391
+
392
+ try {
393
+ const projectPath = await getActualProjectPath(project);
394
+
395
+ // Validate git repository
396
+ await validateGitRepository(projectPath);
397
+
398
+ // Get all branches
399
+ const { stdout } = await execAsync('git branch -a', { cwd: projectPath });
400
+
401
+ // Parse branches
402
+ const branches = stdout
403
+ .split('\n')
404
+ .map(branch => branch.trim())
405
+ .filter(branch => branch && !branch.includes('->')) // Remove empty lines and HEAD pointer
406
+ .map(branch => {
407
+ // Remove asterisk from current branch
408
+ if (branch.startsWith('* ')) {
409
+ return branch.substring(2);
410
+ }
411
+ // Remove remotes/ prefix
412
+ if (branch.startsWith('remotes/origin/')) {
413
+ return branch.substring(15);
414
+ }
415
+ return branch;
416
+ })
417
+ .filter((branch, index, self) => self.indexOf(branch) === index); // Remove duplicates
418
+
419
+ res.json({ branches });
420
+ } catch (error) {
421
+ // git branches error
422
+ res.json({ error: 'Git operation failed' });
423
+ }
424
+ });
425
+
426
+ // Checkout branch
427
+ router.post('/checkout', async (req, res) => {
428
+ const { project, branch } = req.body;
429
+
430
+ if (!project || !branch) {
431
+ return res.status(400).json({ error: 'Project name and branch are required' });
432
+ }
433
+
434
+ try {
435
+ const projectPath = await getActualProjectPath(project);
436
+
437
+ // Checkout the branch
438
+ const { stdout } = await execAsync(`git checkout "${branch}"`, { cwd: projectPath });
439
+
440
+ res.json({ success: true, output: stdout });
441
+ } catch (error) {
442
+ // git checkout error
443
+ res.status(500).json({ error: 'Git operation failed' });
444
+ }
445
+ });
446
+
447
+ // Create new branch
448
+ router.post('/create-branch', async (req, res) => {
449
+ const { project, branch } = req.body;
450
+
451
+ if (!project || !branch) {
452
+ return res.status(400).json({ error: 'Project name and branch name are required' });
453
+ }
454
+
455
+ try {
456
+ const projectPath = await getActualProjectPath(project);
457
+
458
+ // Create and checkout new branch
459
+ const { stdout } = await execAsync(`git checkout -b "${branch}"`, { cwd: projectPath });
460
+
461
+ res.json({ success: true, output: stdout });
462
+ } catch (error) {
463
+ // git create branch error
464
+ res.status(500).json({ error: 'Git operation failed' });
465
+ }
466
+ });
467
+
468
+ // Get recent commits
469
+ router.get('/commits', async (req, res) => {
470
+ const { project, limit = 10 } = req.query;
471
+
472
+ if (!project) {
473
+ return res.status(400).json({ error: 'Project name is required' });
474
+ }
475
+
476
+ try {
477
+ const projectPath = await getActualProjectPath(project);
478
+ await validateGitRepository(projectPath);
479
+ const parsedLimit = Number.parseInt(String(limit), 10);
480
+ const safeLimit = Number.isFinite(parsedLimit) && parsedLimit > 0
481
+ ? Math.min(parsedLimit, 100)
482
+ : 10;
483
+
484
+ // Get commit log with stats
485
+ const { stdout } = await spawnAsync(
486
+ 'git',
487
+ ['log', '--pretty=format:%H|%an|%ae|%ad|%s', '--date=relative', '-n', String(safeLimit)],
488
+ { cwd: projectPath },
489
+ );
490
+
491
+ const commits = stdout
492
+ .split('\n')
493
+ .filter(line => line.trim())
494
+ .map(line => {
495
+ const [hash, author, email, date, ...messageParts] = line.split('|');
496
+ return {
497
+ hash,
498
+ author,
499
+ email,
500
+ date,
501
+ message: messageParts.join('|')
502
+ };
503
+ });
504
+
505
+ // Get stats for each commit
506
+ for (const commit of commits) {
507
+ try {
508
+ const { stdout: stats } = await execAsync(
509
+ `git show --stat --format='' ${commit.hash}`,
510
+ { cwd: projectPath }
511
+ );
512
+ commit.stats = stats.trim().split('\n').pop(); // Get the summary line
513
+ } catch (error) {
514
+ commit.stats = '';
515
+ }
516
+ }
517
+
518
+ res.json({ commits });
519
+ } catch (error) {
520
+ // git commits error
521
+ res.json({ error: 'Git operation failed' });
522
+ }
523
+ });
524
+
525
+ // Get diff for a specific commit
526
+ router.get('/commit-diff', async (req, res) => {
527
+ const { project, commit } = req.query;
528
+
529
+ if (!project || !commit) {
530
+ return res.status(400).json({ error: 'Project name and commit hash are required' });
531
+ }
532
+
533
+ try {
534
+ const projectPath = await getActualProjectPath(project);
535
+
536
+ // Get diff for the commit
537
+ const { stdout } = await execAsync(
538
+ `git show ${commit}`,
539
+ { cwd: projectPath }
540
+ );
541
+
542
+ res.json({ diff: stdout });
543
+ } catch (error) {
544
+ // git commit diff error
545
+ res.json({ error: 'Git operation failed' });
546
+ }
547
+ });
548
+
549
+ // Generate commit message based on staged changes using AI
550
+ router.post('/generate-commit-message', async (req, res) => {
551
+ const { project, files, provider = 'claude' } = req.body;
552
+
553
+ if (!project || !files || files.length === 0) {
554
+ return res.status(400).json({ error: 'Project name and files are required' });
555
+ }
556
+
557
+ // Validate provider
558
+ if (!['claude', 'cursor'].includes(provider)) {
559
+ return res.status(400).json({ error: 'provider must be "claude" or "cursor"' });
560
+ }
561
+
562
+ try {
563
+ const projectPath = await getActualProjectPath(project);
564
+
565
+ // Get diff for selected files
566
+ let diffContext = '';
567
+ for (const file of files) {
568
+ try {
569
+ const { stdout } = await execAsync(
570
+ `git diff HEAD -- "${file}"`,
571
+ { cwd: projectPath }
572
+ );
573
+ if (stdout) {
574
+ diffContext += `\n--- ${file} ---\n${stdout}`;
575
+ }
576
+ } catch (error) {
577
+ // diff error
578
+ }
579
+ }
580
+
581
+ // If no diff found, might be untracked files
582
+ if (!diffContext.trim()) {
583
+ // Try to get content of untracked files
584
+ for (const file of files) {
585
+ try {
586
+ const filePath = path.join(projectPath, file);
587
+ const stats = await fs.stat(filePath);
588
+
589
+ if (!stats.isDirectory()) {
590
+ const content = await fs.readFile(filePath, 'utf-8');
591
+ diffContext += `\n--- ${file} (new file) ---\n${content.substring(0, 1000)}\n`;
592
+ } else {
593
+ diffContext += `\n--- ${file} (new directory) ---\n`;
594
+ }
595
+ } catch (error) {
596
+ // file read error
597
+ }
598
+ }
599
+ }
600
+
601
+ // Generate commit message using AI
602
+ const message = await generateCommitMessageWithAI(files, diffContext, provider, projectPath);
603
+
604
+ res.json({ message });
605
+ } catch (error) {
606
+ // commit message error
607
+ res.status(500).json({ error: 'Git operation failed' });
608
+ }
609
+ });
610
+
611
+ /**
612
+ * Generates a commit message using AI (Claude SDK or Cursor CLI)
613
+ * @param {Array<string>} files - List of changed files
614
+ * @param {string} diffContext - Git diff content
615
+ * @param {string} provider - 'claude' or 'cursor'
616
+ * @param {string} projectPath - Project directory path
617
+ * @returns {Promise<string>} Generated commit message
618
+ */
619
+ async function generateCommitMessageWithAI(files, diffContext, provider, projectPath) {
620
+ // Create the prompt
621
+ const prompt = `Generate a conventional commit message for these changes.
622
+
623
+ REQUIREMENTS:
624
+ - Format: type(scope): subject
625
+ - Include body explaining what changed and why
626
+ - Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore
627
+ - Subject under 50 chars, body wrapped at 72 chars
628
+ - Focus on user-facing changes, not implementation details
629
+ - Consider what's being added AND removed
630
+ - Return ONLY the commit message (no markdown, explanations, or code blocks)
631
+
632
+ FILES CHANGED:
633
+ ${files.map(f => `- ${f}`).join('\n')}
634
+
635
+ DIFFS:
636
+ ${diffContext.substring(0, 4000)}
637
+
638
+ Generate the commit message:`;
639
+
640
+ try {
641
+ // Create a simple writer that collects the response
642
+ let responseText = '';
643
+ const writer = {
644
+ send: (data) => {
645
+ try {
646
+ const parsed = typeof data === 'string' ? JSON.parse(data) : data;
647
+
648
+ // Handle different message formats from Claude SDK and Cursor CLI
649
+ // Claude SDK sends: {type: 'claude-response', data: {message: {content: [...]}}}
650
+ if (parsed.type === 'claude-response' && parsed.data) {
651
+ const message = parsed.data.message || parsed.data;
652
+ if (message.content && Array.isArray(message.content)) {
653
+ // Extract text from content array
654
+ for (const item of message.content) {
655
+ if (item.type === 'text' && item.text) {
656
+ responseText += item.text;
657
+ }
658
+ }
659
+ }
660
+ }
661
+ // Cursor CLI sends: {type: 'cursor-output', output: '...'}
662
+ else if (parsed.type === 'cursor-output' && parsed.output) {
663
+ responseText += parsed.output;
664
+ }
665
+ // Also handle direct text messages
666
+ else if (parsed.type === 'text' && parsed.text) {
667
+ responseText += parsed.text;
668
+ }
669
+ } catch (e) {
670
+ // Ignore parse errors
671
+ }
672
+ },
673
+ setSessionId: () => {}, // No-op for this use case
674
+ };
675
+
676
+
677
+ // Call the appropriate agent
678
+ if (provider === 'claude') {
679
+ await queryClaudeSDK(prompt, {
680
+ cwd: projectPath,
681
+ permissionMode: 'bypassPermissions',
682
+ model: 'sonnet'
683
+ }, writer);
684
+ } else if (provider === 'cursor') {
685
+ await spawnCursor(prompt, {
686
+ cwd: projectPath,
687
+ skipPermissions: true
688
+ }, writer);
689
+ }
690
+
691
+
692
+ // Clean up the response
693
+ const cleanedMessage = cleanCommitMessage(responseText);
694
+
695
+ return cleanedMessage || 'chore: update files';
696
+ } catch (error) {
697
+ // AI commit message error
698
+ // Fallback to simple message
699
+ return `chore: update ${files.length} file${files.length !== 1 ? 's' : ''}`;
700
+ }
701
+ }
702
+
703
+ /**
704
+ * Cleans the AI-generated commit message by removing markdown, code blocks, and extra formatting
705
+ * @param {string} text - Raw AI response
706
+ * @returns {string} Clean commit message
707
+ */
708
+ function cleanCommitMessage(text) {
709
+ if (!text || !text.trim()) {
710
+ return '';
711
+ }
712
+
713
+ let cleaned = text.trim();
714
+
715
+ // Remove markdown code blocks
716
+ cleaned = cleaned.replace(/```[a-z]*\n/g, '');
717
+ cleaned = cleaned.replace(/```/g, '');
718
+
719
+ // Remove markdown headers
720
+ cleaned = cleaned.replace(/^#+\s*/gm, '');
721
+
722
+ // Remove leading/trailing quotes
723
+ cleaned = cleaned.replace(/^["']|["']$/g, '');
724
+
725
+ // If there are multiple lines, take everything (subject + body)
726
+ // Just clean up extra blank lines
727
+ cleaned = cleaned.replace(/\n{3,}/g, '\n\n');
728
+
729
+ // Remove any explanatory text before the actual commit message
730
+ // Look for conventional commit pattern and start from there
731
+ const conventionalCommitMatch = cleaned.match(/(feat|fix|docs|style|refactor|perf|test|build|ci|chore)(\(.+?\))?:.+/s);
732
+ if (conventionalCommitMatch) {
733
+ cleaned = cleaned.substring(cleaned.indexOf(conventionalCommitMatch[0]));
734
+ }
735
+
736
+ return cleaned.trim();
737
+ }
738
+
739
+ // Get remote status (ahead/behind commits with smart remote detection)
740
+ router.get('/remote-status', async (req, res) => {
741
+ const { project } = req.query;
742
+
743
+ if (!project) {
744
+ return res.status(400).json({ error: 'Project name is required' });
745
+ }
746
+
747
+ try {
748
+ const projectPath = await getActualProjectPath(project);
749
+ await validateGitRepository(projectPath);
750
+
751
+ // Get current branch
752
+ const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
753
+ const branch = currentBranch.trim();
754
+
755
+ // Check if there's a remote tracking branch (smart detection)
756
+ let trackingBranch;
757
+ let remoteName;
758
+ try {
759
+ const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
760
+ trackingBranch = stdout.trim();
761
+ remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin")
762
+ } catch (error) {
763
+ // No upstream branch configured - but check if we have remotes
764
+ let hasRemote = false;
765
+ let remoteName = null;
766
+ try {
767
+ const { stdout } = await execAsync('git remote', { cwd: projectPath });
768
+ const remotes = stdout.trim().split('\n').filter(r => r.trim());
769
+ if (remotes.length > 0) {
770
+ hasRemote = true;
771
+ remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
772
+ }
773
+ } catch (remoteError) {
774
+ // No remotes configured
775
+ }
776
+
777
+ return res.json({
778
+ hasRemote,
779
+ hasUpstream: false,
780
+ branch,
781
+ remoteName,
782
+ message: 'No remote tracking branch configured'
783
+ });
784
+ }
785
+
786
+ // Get ahead/behind counts
787
+ const { stdout: countOutput } = await execAsync(
788
+ `git rev-list --count --left-right ${trackingBranch}...HEAD`,
789
+ { cwd: projectPath }
790
+ );
791
+
792
+ const [behind, ahead] = countOutput.trim().split('\t').map(Number);
793
+
794
+ res.json({
795
+ hasRemote: true,
796
+ hasUpstream: true,
797
+ branch,
798
+ remoteBranch: trackingBranch,
799
+ remoteName,
800
+ ahead: ahead || 0,
801
+ behind: behind || 0,
802
+ isUpToDate: ahead === 0 && behind === 0
803
+ });
804
+ } catch (error) {
805
+ // git remote status error
806
+ res.json({ error: 'Git operation failed' });
807
+ }
808
+ });
809
+
810
+ // Fetch from remote (using smart remote detection)
811
+ router.post('/fetch', async (req, res) => {
812
+ const { project } = req.body;
813
+
814
+ if (!project) {
815
+ return res.status(400).json({ error: 'Project name is required' });
816
+ }
817
+
818
+ try {
819
+ const projectPath = await getActualProjectPath(project);
820
+ await validateGitRepository(projectPath);
821
+
822
+ // Get current branch and its upstream remote
823
+ const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
824
+ const branch = currentBranch.trim();
825
+
826
+ let remoteName = 'origin'; // fallback
827
+ try {
828
+ const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
829
+ remoteName = stdout.trim().split('/')[0]; // Extract remote name
830
+ } catch (error) {
831
+ // No upstream, try to fetch from origin anyway
832
+ }
833
+
834
+ const { stdout } = await execAsync(`git fetch ${remoteName}`, { cwd: projectPath });
835
+
836
+ res.json({ success: true, output: stdout || 'Fetch completed successfully', remoteName });
837
+ } catch (error) {
838
+ // git fetch error
839
+ const msg = error.message || '';
840
+ res.status(500).json({
841
+ error: 'Fetch failed',
842
+ details: msg.includes('Could not resolve hostname')
843
+ ? 'Unable to connect to remote repository. Check your internet connection.'
844
+ : msg.includes('does not appear to be a git repository')
845
+ ? 'No remote repository configured.'
846
+ : 'Failed to fetch from remote'
847
+ });
848
+ }
849
+ });
850
+
851
+ // Pull from remote (fetch + merge using smart remote detection)
852
+ router.post('/pull', async (req, res) => {
853
+ const { project } = req.body;
854
+
855
+ if (!project) {
856
+ return res.status(400).json({ error: 'Project name is required' });
857
+ }
858
+
859
+ try {
860
+ const projectPath = await getActualProjectPath(project);
861
+ await validateGitRepository(projectPath);
862
+
863
+ // Get current branch and its upstream remote
864
+ const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
865
+ const branch = currentBranch.trim();
866
+
867
+ let remoteName = 'origin'; // fallback
868
+ let remoteBranch = branch; // fallback
869
+ try {
870
+ const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
871
+ const tracking = stdout.trim();
872
+ remoteName = tracking.split('/')[0]; // Extract remote name
873
+ remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name
874
+ } catch (error) {
875
+ // No upstream, use fallback
876
+ }
877
+
878
+ const { stdout } = await execAsync(`git pull ${remoteName} ${remoteBranch}`, { cwd: projectPath });
879
+
880
+ res.json({
881
+ success: true,
882
+ output: stdout || 'Pull completed successfully',
883
+ remoteName,
884
+ remoteBranch
885
+ });
886
+ } catch (error) {
887
+ // git pull error
888
+
889
+ // Enhanced error handling for common pull scenarios
890
+ let errorMessage = 'Pull failed';
891
+ let details = 'Operation failed';
892
+
893
+ if (error.message.includes('CONFLICT')) {
894
+ errorMessage = 'Merge conflicts detected';
895
+ details = 'Pull created merge conflicts. Please resolve conflicts manually in the editor, then commit the changes.';
896
+ } else if (error.message.includes('Please commit your changes or stash them')) {
897
+ errorMessage = 'Uncommitted changes detected';
898
+ details = 'Please commit or stash your local changes before pulling.';
899
+ } else if (error.message.includes('Could not resolve hostname')) {
900
+ errorMessage = 'Network error';
901
+ details = 'Unable to connect to remote repository. Check your internet connection.';
902
+ } else if (error.message.includes('fatal: \'origin\' does not appear to be a git repository')) {
903
+ errorMessage = 'Remote not configured';
904
+ details = 'No remote repository configured. Add a remote with: git remote add origin <url>';
905
+ } else if (error.message.includes('diverged')) {
906
+ errorMessage = 'Branches have diverged';
907
+ details = 'Your local branch and remote branch have diverged. Consider fetching first to review changes.';
908
+ }
909
+
910
+ res.status(500).json({
911
+ error: errorMessage,
912
+ details: details
913
+ });
914
+ }
915
+ });
916
+
917
+ // Push commits to remote repository
918
+ router.post('/push', async (req, res) => {
919
+ const { project } = req.body;
920
+
921
+ if (!project) {
922
+ return res.status(400).json({ error: 'Project name is required' });
923
+ }
924
+
925
+ try {
926
+ const projectPath = await getActualProjectPath(project);
927
+ await validateGitRepository(projectPath);
928
+
929
+ // Get current branch and its upstream remote
930
+ const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
931
+ const branch = currentBranch.trim();
932
+
933
+ let remoteName = 'origin'; // fallback
934
+ let remoteBranch = branch; // fallback
935
+ try {
936
+ const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
937
+ const tracking = stdout.trim();
938
+ remoteName = tracking.split('/')[0]; // Extract remote name
939
+ remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name
940
+ } catch (error) {
941
+ // No upstream, use fallback
942
+ }
943
+
944
+ const { stdout } = await execAsync(`git push ${remoteName} ${remoteBranch}`, { cwd: projectPath });
945
+
946
+ res.json({
947
+ success: true,
948
+ output: stdout || 'Push completed successfully',
949
+ remoteName,
950
+ remoteBranch
951
+ });
952
+ } catch (error) {
953
+ // git push error
954
+
955
+ // Enhanced error handling for common push scenarios
956
+ let errorMessage = 'Push failed';
957
+ let details = 'Operation failed';
958
+
959
+ if (error.message.includes('rejected')) {
960
+ errorMessage = 'Push rejected';
961
+ details = 'The remote has newer commits. Pull first to merge changes before pushing.';
962
+ } else if (error.message.includes('non-fast-forward')) {
963
+ errorMessage = 'Non-fast-forward push';
964
+ details = 'Your branch is behind the remote. Pull the latest changes first.';
965
+ } else if (error.message.includes('Could not resolve hostname')) {
966
+ errorMessage = 'Network error';
967
+ details = 'Unable to connect to remote repository. Check your internet connection.';
968
+ } else if (error.message.includes('fatal: \'origin\' does not appear to be a git repository')) {
969
+ errorMessage = 'Remote not configured';
970
+ details = 'No remote repository configured. Add a remote with: git remote add origin <url>';
971
+ } else if (error.message.includes('Permission denied')) {
972
+ errorMessage = 'Authentication failed';
973
+ details = 'Permission denied. Check your credentials or SSH keys.';
974
+ } else if (error.message.includes('no upstream branch')) {
975
+ errorMessage = 'No upstream branch';
976
+ details = 'No upstream branch configured. Use: git push --set-upstream origin <branch>';
977
+ }
978
+
979
+ res.status(500).json({
980
+ error: errorMessage,
981
+ details: details
982
+ });
983
+ }
984
+ });
985
+
986
+ // Publish branch to remote (set upstream and push)
987
+ router.post('/publish', async (req, res) => {
988
+ const { project, branch } = req.body;
989
+
990
+ if (!project || !branch) {
991
+ return res.status(400).json({ error: 'Project name and branch are required' });
992
+ }
993
+
994
+ try {
995
+ const projectPath = await getActualProjectPath(project);
996
+ await validateGitRepository(projectPath);
997
+
998
+ // Get current branch to verify it matches the requested branch
999
+ const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
1000
+ const currentBranchName = currentBranch.trim();
1001
+
1002
+ if (currentBranchName !== branch) {
1003
+ return res.status(400).json({
1004
+ error: `Branch mismatch. Current branch is ${currentBranchName}, but trying to publish ${branch}`
1005
+ });
1006
+ }
1007
+
1008
+ // Check if remote exists
1009
+ let remoteName = 'origin';
1010
+ try {
1011
+ const { stdout } = await execAsync('git remote', { cwd: projectPath });
1012
+ const remotes = stdout.trim().split('\n').filter(r => r.trim());
1013
+ if (remotes.length === 0) {
1014
+ return res.status(400).json({
1015
+ error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
1016
+ });
1017
+ }
1018
+ remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
1019
+ } catch (error) {
1020
+ return res.status(400).json({
1021
+ error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
1022
+ });
1023
+ }
1024
+
1025
+ // Publish the branch (set upstream and push)
1026
+ const { stdout } = await execAsync(`git push --set-upstream ${remoteName} ${branch}`, { cwd: projectPath });
1027
+
1028
+ res.json({
1029
+ success: true,
1030
+ output: stdout || 'Branch published successfully',
1031
+ remoteName,
1032
+ branch
1033
+ });
1034
+ } catch (error) {
1035
+ // git publish error
1036
+
1037
+ // Enhanced error handling for common publish scenarios
1038
+ let errorMessage = 'Publish failed';
1039
+ let details = 'Operation failed';
1040
+
1041
+ if (error.message.includes('rejected')) {
1042
+ errorMessage = 'Publish rejected';
1043
+ details = 'The remote branch already exists and has different commits. Use push instead.';
1044
+ } else if (error.message.includes('Could not resolve hostname')) {
1045
+ errorMessage = 'Network error';
1046
+ details = 'Unable to connect to remote repository. Check your internet connection.';
1047
+ } else if (error.message.includes('Permission denied')) {
1048
+ errorMessage = 'Authentication failed';
1049
+ details = 'Permission denied. Check your credentials or SSH keys.';
1050
+ } else if (error.message.includes('fatal:') && error.message.includes('does not appear to be a git repository')) {
1051
+ errorMessage = 'Remote not configured';
1052
+ details = 'Remote repository not properly configured. Check your remote URL.';
1053
+ }
1054
+
1055
+ res.status(500).json({
1056
+ error: errorMessage,
1057
+ details: details
1058
+ });
1059
+ }
1060
+ });
1061
+
1062
+ // Discard changes for a specific file
1063
+ router.post('/discard', async (req, res) => {
1064
+ const { project, file } = req.body;
1065
+
1066
+ if (!project || !file) {
1067
+ return res.status(400).json({ error: 'Project name and file path are required' });
1068
+ }
1069
+
1070
+ try {
1071
+ const projectPath = await getActualProjectPath(project);
1072
+ await validateGitRepository(projectPath);
1073
+
1074
+ // Check file status to determine correct discard command
1075
+ const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
1076
+
1077
+ if (!statusOutput.trim()) {
1078
+ return res.status(400).json({ error: 'No changes to discard for this file' });
1079
+ }
1080
+
1081
+ const status = statusOutput.substring(0, 2);
1082
+
1083
+ if (status === '??') {
1084
+ // Untracked file or directory - delete it
1085
+ const filePath = path.join(projectPath, file);
1086
+ const stats = await fs.stat(filePath);
1087
+
1088
+ if (stats.isDirectory()) {
1089
+ await fs.rm(filePath, { recursive: true, force: true });
1090
+ } else {
1091
+ await fs.unlink(filePath);
1092
+ }
1093
+ } else if (status.includes('M') || status.includes('D')) {
1094
+ // Modified or deleted file - restore from HEAD
1095
+ await execAsync(`git restore "${file}"`, { cwd: projectPath });
1096
+ } else if (status.includes('A')) {
1097
+ // Added file - unstage it
1098
+ await execAsync(`git reset HEAD "${file}"`, { cwd: projectPath });
1099
+ }
1100
+
1101
+ res.json({ success: true, message: `Changes discarded for ${file}` });
1102
+ } catch (error) {
1103
+ // git discard error
1104
+ res.status(500).json({ error: 'Git operation failed' });
1105
+ }
1106
+ });
1107
+
1108
+ // Delete untracked file
1109
+ router.post('/delete-untracked', async (req, res) => {
1110
+ const { project, file } = req.body;
1111
+
1112
+ if (!project || !file) {
1113
+ return res.status(400).json({ error: 'Project name and file path are required' });
1114
+ }
1115
+
1116
+ try {
1117
+ const projectPath = await getActualProjectPath(project);
1118
+ await validateGitRepository(projectPath);
1119
+
1120
+ // Check if file is actually untracked
1121
+ const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
1122
+
1123
+ if (!statusOutput.trim()) {
1124
+ return res.status(400).json({ error: 'File is not untracked or does not exist' });
1125
+ }
1126
+
1127
+ const status = statusOutput.substring(0, 2);
1128
+
1129
+ if (status !== '??') {
1130
+ return res.status(400).json({ error: 'File is not untracked. Use discard for tracked files.' });
1131
+ }
1132
+
1133
+ // Delete the untracked file or directory
1134
+ const filePath = path.join(projectPath, file);
1135
+ const stats = await fs.stat(filePath);
1136
+
1137
+ if (stats.isDirectory()) {
1138
+ // Use rm with recursive option for directories
1139
+ await fs.rm(filePath, { recursive: true, force: true });
1140
+ res.json({ success: true, message: `Untracked directory ${file} deleted successfully` });
1141
+ } else {
1142
+ await fs.unlink(filePath);
1143
+ res.json({ success: true, message: `Untracked file ${file} deleted successfully` });
1144
+ }
1145
+ } catch (error) {
1146
+ // git delete untracked error
1147
+ res.status(500).json({ error: 'Git operation failed' });
1148
+ }
1149
+ });
1150
+
1151
+ export default router;