@pixelbyte-software/pixcode 1.54.10 → 1.55.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 (534) hide show
  1. package/CODE_OF_CONDUCT.md +41 -41
  2. package/CONTRIBUTING.md +156 -156
  3. package/LICENSE +21 -718
  4. package/README.de.md +169 -169
  5. package/README.ja.md +167 -167
  6. package/README.ko.md +167 -167
  7. package/README.md +435 -418
  8. package/README.ru.md +169 -169
  9. package/README.tr.md +298 -298
  10. package/README.zh-CN.md +167 -167
  11. package/SECURITY.md +46 -46
  12. package/dist-server/server/claude-sdk.js +1 -1
  13. package/dist-server/server/claude-sdk.js.map +1 -1
  14. package/dist-server/server/cli.js +100 -100
  15. package/dist-server/server/daemon/manager.js +33 -33
  16. package/dist-server/server/daemon-manager.js +64 -64
  17. package/dist-server/server/index.js +6 -11
  18. package/dist-server/server/index.js.map +1 -1
  19. package/dist-server/server/modules/security/permission-policy.js.map +1 -0
  20. package/dist-server/server/modules/tasks/git-worktree.js +98 -0
  21. package/dist-server/server/modules/tasks/git-worktree.js.map +1 -0
  22. package/dist-server/server/modules/tasks/index.js +8 -0
  23. package/dist-server/server/modules/tasks/index.js.map +1 -0
  24. package/dist-server/server/modules/tasks/prompt-builder.js +85 -0
  25. package/dist-server/server/modules/tasks/prompt-builder.js.map +1 -0
  26. package/dist-server/server/modules/tasks/role-presets.js +94 -0
  27. package/dist-server/server/modules/tasks/role-presets.js.map +1 -0
  28. package/dist-server/server/modules/tasks/runners/claude-runner.js +109 -0
  29. package/dist-server/server/modules/tasks/runners/claude-runner.js.map +1 -0
  30. package/dist-server/server/modules/tasks/runners/codex-runner.js +67 -0
  31. package/dist-server/server/modules/tasks/runners/codex-runner.js.map +1 -0
  32. package/dist-server/server/modules/tasks/runners/spawn-runner.js +111 -0
  33. package/dist-server/server/modules/tasks/runners/spawn-runner.js.map +1 -0
  34. package/dist-server/server/modules/tasks/runners/types.js +5 -0
  35. package/dist-server/server/modules/tasks/runners/types.js.map +1 -0
  36. package/dist-server/server/modules/tasks/scheduler.js +69 -0
  37. package/dist-server/server/modules/tasks/scheduler.js.map +1 -0
  38. package/dist-server/server/modules/tasks/task-queue.js +81 -0
  39. package/dist-server/server/modules/tasks/task-queue.js.map +1 -0
  40. package/dist-server/server/modules/tasks/task-routes.js +195 -0
  41. package/dist-server/server/modules/tasks/task-routes.js.map +1 -0
  42. package/dist-server/server/modules/tasks/task-runner.js +187 -0
  43. package/dist-server/server/modules/tasks/task-runner.js.map +1 -0
  44. package/dist-server/server/modules/tasks/task-store.js +196 -0
  45. package/dist-server/server/modules/tasks/task-store.js.map +1 -0
  46. package/dist-server/server/modules/tasks/task-types.js +5 -0
  47. package/dist-server/server/modules/tasks/task-types.js.map +1 -0
  48. package/dist-server/server/modules/tasks/user-interaction.js +73 -0
  49. package/dist-server/server/modules/tasks/user-interaction.js.map +1 -0
  50. package/dist-server/server/routes/commands.js +25 -25
  51. package/dist-server/server/routes/git.js +17 -17
  52. package/dist-server/server/routes/live-view.js +46 -46
  53. package/dist-server/server/services/control-room.js +9 -1
  54. package/dist-server/server/services/control-room.js.map +1 -1
  55. package/dist-server/server/services/public-api-manifest.js +54 -54
  56. package/dist-server/server/services/public-api-manifest.js.map +1 -1
  57. package/dist-server/server/services/telegram/control-center.js +10 -10
  58. package/dist-server/server/services/telegram/control-center.js.map +1 -1
  59. package/dist-server/server/services/telegram/telegram-gateway.js +2 -2
  60. package/dist-server/server/services/telegram/telegram-gateway.js.map +1 -1
  61. package/dist-server/server/services/telegram/translations.js +5 -5
  62. package/dist-server/server/services/telegram/translations.js.map +1 -1
  63. package/package.json +224 -224
  64. package/scripts/fix-node-pty.js +67 -67
  65. package/scripts/github/create-v1.38-issues.mjs +351 -351
  66. package/scripts/github/create-vscode-workbench-issues.mjs +121 -121
  67. package/scripts/smoke/backend-resource-bounds.mjs +152 -152
  68. package/scripts/smoke/changes-panel-layout.mjs +48 -48
  69. package/scripts/smoke/chat-composer-fixed-layout.mjs +55 -55
  70. package/scripts/smoke/chat-message-timeline-order.mjs +41 -41
  71. package/scripts/smoke/chat-realtime-hydration.mjs +44 -44
  72. package/scripts/smoke/chat-session-provider-pools.mjs +35 -35
  73. package/scripts/smoke/chat-session-state.mjs +19 -19
  74. package/scripts/smoke/code-editor-theme.mjs +55 -55
  75. package/scripts/smoke/code-editor-vscode-engine.mjs +91 -91
  76. package/scripts/smoke/command-center-agent-writes.mjs +79 -79
  77. package/scripts/smoke/command-center-non-git.mjs +46 -46
  78. package/scripts/smoke/context-packet.mjs +43 -43
  79. package/scripts/smoke/control-room-ux-redesign.mjs +91 -91
  80. package/scripts/smoke/daemon-entrypoint.mjs +20 -20
  81. package/scripts/smoke/default-landing-routing.mjs +33 -33
  82. package/scripts/smoke/desktop-native-notifications.mjs +30 -30
  83. package/scripts/smoke/desktop-tray-icon.mjs +33 -33
  84. package/scripts/smoke/discord-release-workflow.mjs +24 -24
  85. package/scripts/smoke/git-install-update.mjs +255 -255
  86. package/scripts/smoke/handoff-artifact-protocol.mjs +50 -50
  87. package/scripts/smoke/live-view-diagnostics.mjs +53 -53
  88. package/scripts/smoke/live-view-environment.mjs +92 -92
  89. package/scripts/smoke/live-view-integration.mjs +450 -450
  90. package/scripts/smoke/mac-desktop-runtime.mjs +37 -37
  91. package/scripts/smoke/mobile-tunnel-guidance.mjs +29 -29
  92. package/scripts/smoke/mobile-ux.mjs +76 -76
  93. package/scripts/smoke/model-registry.mjs +36 -36
  94. package/scripts/smoke/multi-project-ui.mjs +45 -45
  95. package/scripts/smoke/multi-worker-slots.mjs +42 -42
  96. package/scripts/smoke/notification-center.mjs +87 -87
  97. package/scripts/smoke/notification-inapp-preference.mjs +23 -23
  98. package/scripts/smoke/notification-taxonomy.mjs +58 -58
  99. package/scripts/smoke/orchestration-api.mjs +172 -172
  100. package/scripts/smoke/orchestration-execution-dashboard.mjs +33 -33
  101. package/scripts/smoke/orchestration-live-run.mjs +176 -176
  102. package/scripts/smoke/orchestration-mobile-scroll.mjs +29 -29
  103. package/scripts/smoke/orchestration-model-sync.mjs +30 -30
  104. package/scripts/smoke/orchestration-permission-fallback.mjs +34 -34
  105. package/scripts/smoke/orchestration-runtime-guards.mjs +48 -48
  106. package/scripts/smoke/orchestration-user-facing-output.mjs +25 -25
  107. package/scripts/smoke/permission-policy.mjs +50 -50
  108. package/scripts/smoke/pixcode-workbench-1-48.mjs +167 -167
  109. package/scripts/smoke/provider-models-opencode-live.mjs +66 -66
  110. package/scripts/smoke/provider-rest-api.mjs +124 -124
  111. package/scripts/smoke/provider-selection-status.mjs +52 -52
  112. package/scripts/smoke/run-state-refresh.mjs +52 -52
  113. package/scripts/smoke/runtime-manager.mjs +99 -99
  114. package/scripts/smoke/shell-manual-disconnect.mjs +30 -30
  115. package/scripts/smoke/side-panel-editor-layout.mjs +34 -34
  116. package/scripts/smoke/static-root-routing.mjs +21 -21
  117. package/scripts/smoke/strict-handoff-compact.mjs +60 -60
  118. package/scripts/smoke/taskmaster-config.mjs +24 -24
  119. package/scripts/smoke/taskmaster-execution-telegram.mjs +3 -3
  120. package/scripts/smoke/taskmaster-onboarding.mjs +3 -3
  121. package/scripts/smoke/taskmaster-run-graph.mjs +3 -3
  122. package/scripts/smoke/telegram-control.mjs +331 -331
  123. package/scripts/smoke/tunnel-persistence.mjs +56 -56
  124. package/scripts/smoke/update-issue-progress.mjs +69 -69
  125. package/scripts/smoke/update-ux.mjs +55 -55
  126. package/scripts/smoke/v138-completion.mjs +132 -132
  127. package/scripts/smoke/v138-desktop-release-hardening.mjs +69 -69
  128. package/scripts/smoke/v138-diagnostics.mjs +63 -63
  129. package/scripts/smoke/v138-issue-planner.mjs +33 -33
  130. package/scripts/smoke/v143-remote-control.mjs +76 -76
  131. package/scripts/smoke/v144-production-loop.mjs +47 -47
  132. package/scripts/smoke/v145-platformization.mjs +46 -46
  133. package/scripts/smoke/v146-control-room-ui.mjs +150 -150
  134. package/scripts/smoke/version-modal-autoshow.mjs +29 -29
  135. package/scripts/smoke/vscode-workbench-layout.mjs +63 -63
  136. package/scripts/smoke/vscode-workbench-polish.mjs +461 -461
  137. package/scripts/smoke/workflow-fallback-replay.mjs +56 -56
  138. package/scripts/smoke/workflow-templates.mjs +43 -43
  139. package/scripts/smoke/workflow-trace-timeline.mjs +46 -46
  140. package/scripts/update-git-install.mjs +298 -298
  141. package/server/claude-sdk.js +927 -927
  142. package/server/cli.js +1106 -1106
  143. package/server/constants/config.js +4 -4
  144. package/server/cursor-cli.js +344 -344
  145. package/server/daemon/manager.js +563 -563
  146. package/server/daemon-manager.js +964 -964
  147. package/server/database/db.js +988 -988
  148. package/server/database/json-store.js +197 -197
  149. package/server/gemini-cli.js +550 -550
  150. package/server/gemini-response-handler.js +79 -79
  151. package/server/index.js +5654 -5671
  152. package/server/load-env.js +35 -35
  153. package/server/middleware/account-lockout.js +112 -112
  154. package/server/middleware/auth.js +277 -277
  155. package/server/middleware/rate-limiter.js +87 -87
  156. package/server/modules/providers/index.ts +2 -2
  157. package/server/modules/providers/list/claude/claude-auth.provider.ts +146 -146
  158. package/server/modules/providers/list/claude/claude-mcp.provider.ts +135 -135
  159. package/server/modules/providers/list/claude/claude-sessions.provider.ts +306 -306
  160. package/server/modules/providers/list/claude/claude.provider.ts +15 -15
  161. package/server/modules/providers/list/codex/codex-auth.provider.ts +117 -117
  162. package/server/modules/providers/list/codex/codex-mcp.provider.ts +135 -135
  163. package/server/modules/providers/list/codex/codex-sessions.provider.ts +319 -319
  164. package/server/modules/providers/list/codex/codex.provider.ts +15 -15
  165. package/server/modules/providers/list/cursor/cursor-auth.provider.ts +147 -147
  166. package/server/modules/providers/list/cursor/cursor-mcp.provider.ts +108 -108
  167. package/server/modules/providers/list/cursor/cursor-sessions.provider.ts +421 -421
  168. package/server/modules/providers/list/cursor/cursor.provider.ts +15 -15
  169. package/server/modules/providers/list/gemini/gemini-auth.provider.ts +173 -173
  170. package/server/modules/providers/list/gemini/gemini-mcp.provider.ts +110 -110
  171. package/server/modules/providers/list/gemini/gemini-sessions.provider.ts +227 -227
  172. package/server/modules/providers/list/gemini/gemini.provider.ts +15 -15
  173. package/server/modules/providers/list/opencode/opencode-auth.provider.ts +131 -131
  174. package/server/modules/providers/list/opencode/opencode-sessions.provider.ts +286 -286
  175. package/server/modules/providers/list/qwen/qwen-auth.provider.ts +146 -146
  176. package/server/modules/providers/list/qwen/qwen-sessions.provider.ts +265 -265
  177. package/server/modules/providers/provider.registry.ts +40 -40
  178. package/server/modules/providers/provider.routes.ts +982 -982
  179. package/server/modules/providers/services/mcp.service.ts +86 -86
  180. package/server/modules/providers/services/provider-auth.service.ts +26 -26
  181. package/server/modules/providers/services/sessions.service.ts +45 -45
  182. package/server/modules/providers/shared/base/abstract.provider.ts +20 -20
  183. package/server/modules/providers/shared/mcp/mcp.provider.ts +151 -151
  184. package/server/modules/providers/tests/mcp.test.ts +293 -293
  185. package/server/modules/{orchestration/security → security}/permission-policy.ts +401 -401
  186. package/server/modules/tasks/git-worktree.ts +102 -0
  187. package/server/modules/tasks/index.ts +9 -0
  188. package/server/modules/tasks/prompt-builder.ts +98 -0
  189. package/server/modules/tasks/role-presets.ts +113 -0
  190. package/server/modules/tasks/runners/claude-runner.ts +120 -0
  191. package/server/modules/tasks/runners/codex-runner.ts +80 -0
  192. package/server/modules/tasks/runners/spawn-runner.ts +122 -0
  193. package/server/modules/tasks/runners/types.ts +25 -0
  194. package/server/modules/tasks/scheduler.ts +75 -0
  195. package/server/modules/tasks/task-queue.ts +91 -0
  196. package/server/modules/tasks/task-routes.ts +245 -0
  197. package/server/modules/tasks/task-runner.ts +219 -0
  198. package/server/modules/tasks/task-store.ts +223 -0
  199. package/server/modules/tasks/task-types.ts +100 -0
  200. package/server/modules/tasks/user-interaction.ts +93 -0
  201. package/server/openai-codex.js +468 -468
  202. package/server/opencode-cli.js +491 -491
  203. package/server/opencode-response-handler.js +111 -111
  204. package/server/projects.js +3135 -3135
  205. package/server/qwen-code-cli.js +410 -410
  206. package/server/routes/agent.js +1462 -1462
  207. package/server/routes/auth.js +298 -298
  208. package/server/routes/codex.js +20 -20
  209. package/server/routes/commands.js +581 -581
  210. package/server/routes/cursor.js +61 -61
  211. package/server/routes/diagnostics.js +44 -44
  212. package/server/routes/gemini.js +25 -25
  213. package/server/routes/git.js +1822 -1822
  214. package/server/routes/live-view.js +434 -434
  215. package/server/routes/mcp-utils.js +13 -13
  216. package/server/routes/messages.js +97 -97
  217. package/server/routes/network.js +143 -143
  218. package/server/routes/platformization.js +231 -231
  219. package/server/routes/plugins.js +690 -690
  220. package/server/routes/production-agent-loop.js +90 -90
  221. package/server/routes/projects.js +918 -918
  222. package/server/routes/public-api.js +34 -34
  223. package/server/routes/qwen.js +27 -27
  224. package/server/routes/remote.js +56 -56
  225. package/server/routes/settings.js +321 -321
  226. package/server/routes/telegram.js +163 -163
  227. package/server/routes/user.js +125 -125
  228. package/server/routes/webhooks.js +63 -63
  229. package/server/services/control-room.js +111 -102
  230. package/server/services/diagnostics.js +165 -165
  231. package/server/services/external-access.js +403 -403
  232. package/server/services/install-jobs.js +715 -715
  233. package/server/services/live-view.js +956 -956
  234. package/server/services/managed-runtimes.js +493 -493
  235. package/server/services/model-registry.js +144 -144
  236. package/server/services/notification-orchestrator.js +365 -365
  237. package/server/services/notification-taxonomy.js +204 -204
  238. package/server/services/platformization.js +1052 -1052
  239. package/server/services/production-agent-loop.js +248 -248
  240. package/server/services/provider-cli-versions.js +149 -149
  241. package/server/services/provider-credentials.js +189 -189
  242. package/server/services/provider-models.js +396 -396
  243. package/server/services/public-api-manifest.js +181 -181
  244. package/server/services/qr-login.js +126 -126
  245. package/server/services/remote-connection.js +127 -127
  246. package/server/services/runtime-manager.js +323 -323
  247. package/server/services/startup-update.js +259 -259
  248. package/server/services/telegram/bot.js +393 -393
  249. package/server/services/telegram/control-center.js +2453 -2453
  250. package/server/services/telegram/telegram-gateway.js +314 -314
  251. package/server/services/telegram/telegram-http-client.js +201 -201
  252. package/server/services/telegram/translations.js +470 -470
  253. package/server/services/webhooks.js +216 -216
  254. package/server/sessionManager.js +225 -225
  255. package/server/setup-wizard.js +283 -283
  256. package/server/shared/interfaces.ts +54 -54
  257. package/server/shared/types.ts +172 -172
  258. package/server/shared/utils.ts +193 -193
  259. package/server/tsconfig.json +36 -36
  260. package/server/utils/colors.js +21 -21
  261. package/server/utils/commandParser.js +305 -305
  262. package/server/utils/frontmatter.js +18 -18
  263. package/server/utils/gitConfig.js +34 -34
  264. package/server/utils/plugin-loader.js +461 -461
  265. package/server/utils/plugin-process-manager.js +185 -185
  266. package/server/utils/port-access.js +227 -227
  267. package/server/utils/runtime-paths.js +37 -37
  268. package/server/utils/security-log.js +84 -84
  269. package/server/utils/url-detection.js +71 -71
  270. package/server/vite-daemon.js +79 -79
  271. package/shared/modelConstants.js +161 -161
  272. package/shared/networkHosts.js +22 -22
  273. package/dist/api-automation.html +0 -110
  274. package/dist/api-docs.html +0 -548
  275. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  276. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  277. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  278. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  279. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  280. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  281. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  282. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  283. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  284. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  285. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  286. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  287. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  288. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  289. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  290. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  291. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  292. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  293. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  294. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  295. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  296. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  297. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  298. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  299. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  300. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  301. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  302. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  303. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  304. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  305. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  306. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  307. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  308. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  309. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  310. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  311. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  312. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  313. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  314. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  315. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  316. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  317. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  318. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  319. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  320. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  321. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  322. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  323. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  324. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  325. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  326. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  327. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  328. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  329. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  330. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  331. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  332. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  333. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  334. package/dist/assets/index-DWOW_Jn0.js +0 -877
  335. package/dist/assets/index-Dz1luE0u.css +0 -32
  336. package/dist/assets/index-ak1p_4ew.js +0 -11
  337. package/dist/assets/localMonaco-DFIbtDNp.js +0 -1
  338. package/dist/assets/vendor-codemirror-CIYNS698.js +0 -41
  339. package/dist/assets/vendor-react-DB6V5Fl1.js +0 -59
  340. package/dist/assets/vendor-xterm-C7tpxJl7.js +0 -9
  341. package/dist/clear-cache.html +0 -85
  342. package/dist/convert-icons.md +0 -53
  343. package/dist/docs.html +0 -308
  344. package/dist/favicon.png +0 -0
  345. package/dist/favicon.svg +0 -8
  346. package/dist/features.html +0 -133
  347. package/dist/generate-icons.js +0 -49
  348. package/dist/humans.txt +0 -15
  349. package/dist/icons/claude-ai-icon.svg +0 -1
  350. package/dist/icons/codex-white.svg +0 -3
  351. package/dist/icons/codex.svg +0 -3
  352. package/dist/icons/cursor-white.svg +0 -12
  353. package/dist/icons/cursor.svg +0 -1
  354. package/dist/icons/gemini-ai-icon.svg +0 -1
  355. package/dist/icons/icon-128x128.png +0 -0
  356. package/dist/icons/icon-128x128.svg +0 -9
  357. package/dist/icons/icon-144x144.png +0 -0
  358. package/dist/icons/icon-144x144.svg +0 -9
  359. package/dist/icons/icon-152x152.png +0 -0
  360. package/dist/icons/icon-152x152.svg +0 -9
  361. package/dist/icons/icon-192x192.png +0 -0
  362. package/dist/icons/icon-192x192.svg +0 -9
  363. package/dist/icons/icon-384x384.png +0 -0
  364. package/dist/icons/icon-384x384.svg +0 -9
  365. package/dist/icons/icon-512x512.png +0 -0
  366. package/dist/icons/icon-512x512.svg +0 -9
  367. package/dist/icons/icon-72x72.png +0 -0
  368. package/dist/icons/icon-72x72.svg +0 -9
  369. package/dist/icons/icon-96x96.png +0 -0
  370. package/dist/icons/icon-96x96.svg +0 -9
  371. package/dist/icons/icon-template.svg +0 -9
  372. package/dist/icons/opencode-logo-dark.svg +0 -1
  373. package/dist/icons/opencode-logo-light.svg +0 -1
  374. package/dist/icons/qwen-ai-icon.png +0 -0
  375. package/dist/icons/qwen-logo.svg +0 -15
  376. package/dist/index.html +0 -62
  377. package/dist/landing.html +0 -268
  378. package/dist/llms-full.txt +0 -119
  379. package/dist/llms.txt +0 -53
  380. package/dist/logo-128.png +0 -0
  381. package/dist/logo-256.png +0 -0
  382. package/dist/logo-32.png +0 -0
  383. package/dist/logo-512.png +0 -0
  384. package/dist/logo-64.png +0 -0
  385. package/dist/logo.png +0 -0
  386. package/dist/logo.svg +0 -12
  387. package/dist/manifest.json +0 -61
  388. package/dist/openapi.yaml +0 -1696
  389. package/dist/orchestration.html +0 -125
  390. package/dist/robots.txt +0 -4
  391. package/dist/screenshots/cli-selection.png +0 -0
  392. package/dist/screenshots/desktop-main.png +0 -0
  393. package/dist/screenshots/mobile-chat.png +0 -0
  394. package/dist/screenshots/tools-modal.png +0 -0
  395. package/dist/site.css +0 -692
  396. package/dist/sitemap.xml +0 -51
  397. package/dist/sw.js +0 -132
  398. package/dist-server/server/modules/orchestration/a2a/adapter-registry.js +0 -73
  399. package/dist-server/server/modules/orchestration/a2a/adapter-registry.js.map +0 -1
  400. package/dist-server/server/modules/orchestration/a2a/adapters/abstract-a2a.adapter.js +0 -17
  401. package/dist-server/server/modules/orchestration/a2a/adapters/abstract-a2a.adapter.js.map +0 -1
  402. package/dist-server/server/modules/orchestration/a2a/adapters/claude-code.adapter.js +0 -236
  403. package/dist-server/server/modules/orchestration/a2a/adapters/claude-code.adapter.js.map +0 -1
  404. package/dist-server/server/modules/orchestration/a2a/adapters/codex.adapter.js +0 -202
  405. package/dist-server/server/modules/orchestration/a2a/adapters/codex.adapter.js.map +0 -1
  406. package/dist-server/server/modules/orchestration/a2a/adapters/cursor.adapter.js +0 -205
  407. package/dist-server/server/modules/orchestration/a2a/adapters/cursor.adapter.js.map +0 -1
  408. package/dist-server/server/modules/orchestration/a2a/adapters/gemini.adapter.js +0 -205
  409. package/dist-server/server/modules/orchestration/a2a/adapters/gemini.adapter.js.map +0 -1
  410. package/dist-server/server/modules/orchestration/a2a/adapters/json-event.adapter.js +0 -84
  411. package/dist-server/server/modules/orchestration/a2a/adapters/json-event.adapter.js.map +0 -1
  412. package/dist-server/server/modules/orchestration/a2a/adapters/json-event.adapter.test.js +0 -43
  413. package/dist-server/server/modules/orchestration/a2a/adapters/json-event.adapter.test.js.map +0 -1
  414. package/dist-server/server/modules/orchestration/a2a/adapters/opencode.adapter.js +0 -205
  415. package/dist-server/server/modules/orchestration/a2a/adapters/opencode.adapter.js.map +0 -1
  416. package/dist-server/server/modules/orchestration/a2a/adapters/qwen.adapter.js +0 -205
  417. package/dist-server/server/modules/orchestration/a2a/adapters/qwen.adapter.js.map +0 -1
  418. package/dist-server/server/modules/orchestration/a2a/agent-card.js +0 -50
  419. package/dist-server/server/modules/orchestration/a2a/agent-card.js.map +0 -1
  420. package/dist-server/server/modules/orchestration/a2a/auth.middleware.js +0 -25
  421. package/dist-server/server/modules/orchestration/a2a/auth.middleware.js.map +0 -1
  422. package/dist-server/server/modules/orchestration/a2a/bus.js +0 -34
  423. package/dist-server/server/modules/orchestration/a2a/bus.js.map +0 -1
  424. package/dist-server/server/modules/orchestration/a2a/routes.js +0 -502
  425. package/dist-server/server/modules/orchestration/a2a/routes.js.map +0 -1
  426. package/dist-server/server/modules/orchestration/a2a/task-dispatcher.js +0 -295
  427. package/dist-server/server/modules/orchestration/a2a/task-dispatcher.js.map +0 -1
  428. package/dist-server/server/modules/orchestration/a2a/task-store.js +0 -144
  429. package/dist-server/server/modules/orchestration/a2a/task-store.js.map +0 -1
  430. package/dist-server/server/modules/orchestration/a2a/types.js +0 -6
  431. package/dist-server/server/modules/orchestration/a2a/types.js.map +0 -1
  432. package/dist-server/server/modules/orchestration/a2a/validator.js +0 -101
  433. package/dist-server/server/modules/orchestration/a2a/validator.js.map +0 -1
  434. package/dist-server/server/modules/orchestration/index.js +0 -27
  435. package/dist-server/server/modules/orchestration/index.js.map +0 -1
  436. package/dist-server/server/modules/orchestration/preview/port-watcher.js +0 -90
  437. package/dist-server/server/modules/orchestration/preview/port-watcher.js.map +0 -1
  438. package/dist-server/server/modules/orchestration/preview/preview-proxy.js +0 -58
  439. package/dist-server/server/modules/orchestration/preview/preview-proxy.js.map +0 -1
  440. package/dist-server/server/modules/orchestration/preview/types.js +0 -2
  441. package/dist-server/server/modules/orchestration/preview/types.js.map +0 -1
  442. package/dist-server/server/modules/orchestration/security/permission-policy.js.map +0 -1
  443. package/dist-server/server/modules/orchestration/tasks/orchestration-task-store.js +0 -34
  444. package/dist-server/server/modules/orchestration/tasks/orchestration-task-store.js.map +0 -1
  445. package/dist-server/server/modules/orchestration/tasks/orchestration-task.routes.js +0 -70
  446. package/dist-server/server/modules/orchestration/tasks/orchestration-task.routes.js.map +0 -1
  447. package/dist-server/server/modules/orchestration/tasks/orchestration-task.service.js +0 -181
  448. package/dist-server/server/modules/orchestration/tasks/orchestration-task.service.js.map +0 -1
  449. package/dist-server/server/modules/orchestration/tasks/orchestration-task.types.js +0 -2
  450. package/dist-server/server/modules/orchestration/tasks/orchestration-task.types.js.map +0 -1
  451. package/dist-server/server/modules/orchestration/tasks/task-run-graph.js +0 -100
  452. package/dist-server/server/modules/orchestration/tasks/task-run-graph.js.map +0 -1
  453. package/dist-server/server/modules/orchestration/workflows/approval-queue.js +0 -72
  454. package/dist-server/server/modules/orchestration/workflows/approval-queue.js.map +0 -1
  455. package/dist-server/server/modules/orchestration/workflows/built-in-workflows.js +0 -126
  456. package/dist-server/server/modules/orchestration/workflows/built-in-workflows.js.map +0 -1
  457. package/dist-server/server/modules/orchestration/workflows/context-packet.js +0 -89
  458. package/dist-server/server/modules/orchestration/workflows/context-packet.js.map +0 -1
  459. package/dist-server/server/modules/orchestration/workflows/handoff-artifact.js +0 -123
  460. package/dist-server/server/modules/orchestration/workflows/handoff-artifact.js.map +0 -1
  461. package/dist-server/server/modules/orchestration/workflows/workflow-fallback-policy.js +0 -114
  462. package/dist-server/server/modules/orchestration/workflows/workflow-fallback-policy.js.map +0 -1
  463. package/dist-server/server/modules/orchestration/workflows/workflow-replay.js +0 -177
  464. package/dist-server/server/modules/orchestration/workflows/workflow-replay.js.map +0 -1
  465. package/dist-server/server/modules/orchestration/workflows/workflow-runner.js +0 -1718
  466. package/dist-server/server/modules/orchestration/workflows/workflow-runner.js.map +0 -1
  467. package/dist-server/server/modules/orchestration/workflows/workflow-store.js +0 -76
  468. package/dist-server/server/modules/orchestration/workflows/workflow-store.js.map +0 -1
  469. package/dist-server/server/modules/orchestration/workflows/workflow-templates.js +0 -242
  470. package/dist-server/server/modules/orchestration/workflows/workflow-templates.js.map +0 -1
  471. package/dist-server/server/modules/orchestration/workflows/workflow-trace.js +0 -388
  472. package/dist-server/server/modules/orchestration/workflows/workflow-trace.js.map +0 -1
  473. package/dist-server/server/modules/orchestration/workflows/workflow.routes.js +0 -508
  474. package/dist-server/server/modules/orchestration/workflows/workflow.routes.js.map +0 -1
  475. package/dist-server/server/modules/orchestration/workflows/workflow.types.js +0 -2
  476. package/dist-server/server/modules/orchestration/workflows/workflow.types.js.map +0 -1
  477. package/dist-server/server/modules/orchestration/workflows/workspace-target.js +0 -100
  478. package/dist-server/server/modules/orchestration/workflows/workspace-target.js.map +0 -1
  479. package/dist-server/server/modules/orchestration/workspace/docker-workspace.js +0 -123
  480. package/dist-server/server/modules/orchestration/workspace/docker-workspace.js.map +0 -1
  481. package/dist-server/server/modules/orchestration/workspace/path-safety.js +0 -48
  482. package/dist-server/server/modules/orchestration/workspace/path-safety.js.map +0 -1
  483. package/dist-server/server/modules/orchestration/workspace/types.js +0 -11
  484. package/dist-server/server/modules/orchestration/workspace/types.js.map +0 -1
  485. package/dist-server/server/modules/orchestration/workspace/workspace-manager.js +0 -84
  486. package/dist-server/server/modules/orchestration/workspace/workspace-manager.js.map +0 -1
  487. package/dist-server/server/modules/orchestration/workspace/worktree-workspace.js +0 -97
  488. package/dist-server/server/modules/orchestration/workspace/worktree-workspace.js.map +0 -1
  489. package/server/modules/orchestration/a2a/adapter-registry.ts +0 -108
  490. package/server/modules/orchestration/a2a/adapters/abstract-a2a.adapter.ts +0 -63
  491. package/server/modules/orchestration/a2a/adapters/claude-code.adapter.ts +0 -286
  492. package/server/modules/orchestration/a2a/adapters/codex.adapter.ts +0 -244
  493. package/server/modules/orchestration/a2a/adapters/cursor.adapter.ts +0 -249
  494. package/server/modules/orchestration/a2a/adapters/gemini.adapter.ts +0 -248
  495. package/server/modules/orchestration/a2a/adapters/json-event.adapter.test.ts +0 -60
  496. package/server/modules/orchestration/a2a/adapters/json-event.adapter.ts +0 -101
  497. package/server/modules/orchestration/a2a/adapters/opencode.adapter.ts +0 -248
  498. package/server/modules/orchestration/a2a/adapters/qwen.adapter.ts +0 -248
  499. package/server/modules/orchestration/a2a/agent-card.ts +0 -55
  500. package/server/modules/orchestration/a2a/auth.middleware.ts +0 -29
  501. package/server/modules/orchestration/a2a/bus.ts +0 -46
  502. package/server/modules/orchestration/a2a/routes.ts +0 -586
  503. package/server/modules/orchestration/a2a/task-dispatcher.ts +0 -345
  504. package/server/modules/orchestration/a2a/task-store.ts +0 -178
  505. package/server/modules/orchestration/a2a/types.ts +0 -126
  506. package/server/modules/orchestration/a2a/validator.ts +0 -113
  507. package/server/modules/orchestration/index.ts +0 -99
  508. package/server/modules/orchestration/preview/port-watcher.ts +0 -112
  509. package/server/modules/orchestration/preview/preview-proxy.ts +0 -60
  510. package/server/modules/orchestration/preview/types.ts +0 -19
  511. package/server/modules/orchestration/tasks/orchestration-task-store.ts +0 -41
  512. package/server/modules/orchestration/tasks/orchestration-task.routes.ts +0 -81
  513. package/server/modules/orchestration/tasks/orchestration-task.service.ts +0 -201
  514. package/server/modules/orchestration/tasks/orchestration-task.types.ts +0 -40
  515. package/server/modules/orchestration/tasks/task-run-graph.ts +0 -155
  516. package/server/modules/orchestration/workflows/approval-queue.ts +0 -106
  517. package/server/modules/orchestration/workflows/built-in-workflows.ts +0 -127
  518. package/server/modules/orchestration/workflows/context-packet.ts +0 -186
  519. package/server/modules/orchestration/workflows/handoff-artifact.ts +0 -175
  520. package/server/modules/orchestration/workflows/workflow-fallback-policy.ts +0 -161
  521. package/server/modules/orchestration/workflows/workflow-replay.ts +0 -254
  522. package/server/modules/orchestration/workflows/workflow-runner.ts +0 -2062
  523. package/server/modules/orchestration/workflows/workflow-store.ts +0 -97
  524. package/server/modules/orchestration/workflows/workflow-templates.ts +0 -272
  525. package/server/modules/orchestration/workflows/workflow-trace.ts +0 -424
  526. package/server/modules/orchestration/workflows/workflow.routes.ts +0 -586
  527. package/server/modules/orchestration/workflows/workflow.types.ts +0 -111
  528. package/server/modules/orchestration/workflows/workspace-target.ts +0 -122
  529. package/server/modules/orchestration/workspace/docker-workspace.ts +0 -136
  530. package/server/modules/orchestration/workspace/path-safety.ts +0 -55
  531. package/server/modules/orchestration/workspace/types.ts +0 -52
  532. package/server/modules/orchestration/workspace/workspace-manager.ts +0 -102
  533. package/server/modules/orchestration/workspace/worktree-workspace.ts +0 -126
  534. /package/dist-server/server/modules/{orchestration/security → security}/permission-policy.js +0 -0
@@ -1,1822 +1,1822 @@
1
- import { spawn } from 'child_process';
2
- import path from 'path';
3
- import { promises as fs } from 'fs';
4
-
5
- import express from 'express';
6
-
7
- import { extractProjectDirectory } from '../projects.js';
8
- import { queryClaudeSDK } from '../claude-sdk.js';
9
- import { spawnCursor } from '../cursor-cli.js';
10
- import { userHasProjectAccess } from '../services/platformization.js';
11
-
12
- const router = express.Router();
13
- const COMMIT_DIFF_CHARACTER_LIMIT = 500_000;
14
- const FILESYSTEM_SCAN_MAX_FILES = 5_000;
15
- const FILESYSTEM_SCAN_MAX_DEPTH = 10;
16
- const filesystemChangeSnapshots = new Map();
17
- const FILESYSTEM_SCAN_EXCLUDED_DIRS = new Set([
18
- '.git',
19
- '.hg',
20
- '.svn',
21
- 'node_modules',
22
- 'dist',
23
- 'dist-server',
24
- 'build',
25
- '.next',
26
- '.nuxt',
27
- '.svelte-kit',
28
- 'coverage',
29
- '.turbo',
30
- '.cache',
31
- '.pixcode-dev',
32
- '.gradle',
33
- '.repo',
34
- '.venv',
35
- 'out',
36
- 'target',
37
- 'vendor',
38
- 'prebuilts',
39
- 'Pods',
40
- 'DerivedData',
41
- 'venv',
42
- ]);
43
-
44
- router.use((req, res, next) => {
45
- const project = req.query.project || req.body?.project;
46
- if (!project) {
47
- return next();
48
- }
49
-
50
- const capability = req.method === 'GET' ? 'viewFiles' : 'editFiles';
51
- if (!userHasProjectAccess(req.user, { name: String(project), projectName: String(project) }, capability)) {
52
- return res.status(403).json({ error: 'Project access denied.' });
53
- }
54
-
55
- next();
56
- });
57
-
58
- function isNotGitRepositoryMessage(message = '') {
59
- const normalized = String(message || '').toLowerCase();
60
- return normalized.includes('not a git repository')
61
- || normalized.includes('not inside a git work tree')
62
- || normalized.includes('does not contain a .git folder')
63
- || normalized.includes('project directory is not a git repository');
64
- }
65
-
66
- function isMissingFileError(error) {
67
- const message = `${error?.message || ''} ${error?.stderr || ''} ${error?.stdout || ''}`.toLowerCase();
68
- return error?.code === 'ENOENT'
69
- || message.includes('enoent')
70
- || message.includes('no such file or directory')
71
- || message.includes('path does not exist');
72
- }
73
-
74
- function shouldSkipFilesystemEntry(entryName) {
75
- return FILESYSTEM_SCAN_EXCLUDED_DIRS.has(entryName)
76
- || entryName.endsWith('.log')
77
- || entryName === '.DS_Store';
78
- }
79
-
80
- function toProjectRelativePath(projectPath, filePath) {
81
- return path.relative(projectPath, filePath).replace(/\\/g, '/');
82
- }
83
-
84
- async function collectFilesystemSnapshot(projectPath) {
85
- const snapshot = new Map();
86
- let limitReached = false;
87
-
88
- async function walk(directoryPath, depth) {
89
- if (limitReached || depth > FILESYSTEM_SCAN_MAX_DEPTH) {
90
- return;
91
- }
92
-
93
- try {
94
- const dir = await fs.opendir(directoryPath);
95
- try {
96
- for await (const entry of dir) {
97
- if (limitReached || shouldSkipFilesystemEntry(entry.name)) {
98
- continue;
99
- }
100
-
101
- const absolutePath = path.join(directoryPath, entry.name);
102
-
103
- if (entry.isDirectory()) {
104
- await walk(absolutePath, depth + 1);
105
- continue;
106
- }
107
-
108
- if (!entry.isFile()) {
109
- continue;
110
- }
111
-
112
- try {
113
- const stat = await fs.stat(absolutePath);
114
- snapshot.set(toProjectRelativePath(projectPath, absolutePath), {
115
- mtimeMs: Math.round(stat.mtimeMs),
116
- size: stat.size,
117
- });
118
- } catch {
119
- continue;
120
- }
121
-
122
- if (snapshot.size >= FILESYSTEM_SCAN_MAX_FILES) {
123
- limitReached = true;
124
- break;
125
- }
126
- }
127
- } finally {
128
- await dir.close().catch(() => undefined);
129
- }
130
- } catch {
131
- return;
132
- }
133
- }
134
-
135
- await walk(projectPath, 0);
136
- return { snapshot, limitReached };
137
- }
138
-
139
- function diffFilesystemSnapshots(previousSnapshot, nextSnapshot) {
140
- if (!previousSnapshot) {
141
- return { modified: [], added: [], deleted: [] };
142
- }
143
-
144
- const modified = [];
145
- const added = [];
146
- const deleted = [];
147
-
148
- for (const [filePath, nextMeta] of nextSnapshot.entries()) {
149
- const previousMeta = previousSnapshot.get(filePath);
150
- if (!previousMeta) {
151
- added.push(filePath);
152
- continue;
153
- }
154
-
155
- if (previousMeta.mtimeMs !== nextMeta.mtimeMs || previousMeta.size !== nextMeta.size) {
156
- modified.push(filePath);
157
- }
158
- }
159
-
160
- for (const filePath of previousSnapshot.keys()) {
161
- if (!nextSnapshot.has(filePath)) {
162
- deleted.push(filePath);
163
- }
164
- }
165
-
166
- return {
167
- modified: modified.sort(),
168
- added: added.sort(),
169
- deleted: deleted.sort(),
170
- };
171
- }
172
-
173
- async function buildFilesystemStatus(projectPath) {
174
- const normalizedProjectPath = path.resolve(projectPath);
175
- const previousSnapshot = filesystemChangeSnapshots.get(normalizedProjectPath) ?? null;
176
- const { snapshot, limitReached } = await collectFilesystemSnapshot(normalizedProjectPath);
177
- filesystemChangeSnapshots.set(normalizedProjectPath, snapshot);
178
- const { modified, added, deleted } = diffFilesystemSnapshots(previousSnapshot, snapshot);
179
-
180
- return {
181
- isGitRepository: false,
182
- trackingMode: 'filesystem',
183
- branch: null,
184
- hasCommits: false,
185
- modified,
186
- added,
187
- deleted,
188
- untracked: [],
189
- snapshotReady: Boolean(previousSnapshot),
190
- fileCount: snapshot.size,
191
- scanLimitReached: limitReached,
192
- };
193
- }
194
-
195
- async function readFilesystemFileWithDiff(projectPath, filePath) {
196
- const projectRoot = path.resolve(projectPath);
197
- const resolvedFilePath = path.resolve(projectRoot, String(filePath || ''));
198
- const relativePath = path.relative(projectRoot, resolvedFilePath);
199
-
200
- if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
201
- const error = new Error('Invalid file path: path traversal detected');
202
- error.statusCode = 400;
203
- throw error;
204
- }
205
-
206
- const stats = await fs.stat(resolvedFilePath);
207
- if (stats.isDirectory()) {
208
- const error = new Error('Cannot show diff for directories');
209
- error.statusCode = 400;
210
- throw error;
211
- }
212
-
213
- const currentContent = await fs.readFile(resolvedFilePath, 'utf-8');
214
- return {
215
- currentContent,
216
- oldContent: currentContent,
217
- isDeleted: false,
218
- isUntracked: false,
219
- isGitRepository: false,
220
- trackingMode: 'filesystem',
221
- };
222
- }
223
-
224
- function spawnAsync(command, args, options = {}) {
225
- return new Promise((resolve, reject) => {
226
- const child = spawn(command, args, {
227
- ...options,
228
- shell: false,
229
- });
230
-
231
- let stdout = '';
232
- let stderr = '';
233
-
234
- child.stdout.on('data', (data) => {
235
- stdout += data.toString();
236
- });
237
-
238
- child.stderr.on('data', (data) => {
239
- stderr += data.toString();
240
- });
241
-
242
- child.on('error', (error) => {
243
- reject(error);
244
- });
245
-
246
- child.on('close', (code) => {
247
- if (code === 0) {
248
- resolve({ stdout, stderr });
249
- return;
250
- }
251
-
252
- const error = new Error(`Command failed: ${command} ${args.join(' ')}`);
253
- error.code = code;
254
- error.stdout = stdout;
255
- error.stderr = stderr;
256
- reject(error);
257
- });
258
- });
259
- }
260
-
261
- // Input validation helpers (defense-in-depth)
262
- function validateCommitRef(commit) {
263
- // Allow hex hashes, HEAD, HEAD~N, HEAD^N, tag names, branch names
264
- if (!/^[a-zA-Z0-9._~^{}@\/-]+$/.test(commit)) {
265
- throw new Error('Invalid commit reference');
266
- }
267
- return commit;
268
- }
269
-
270
- function validateBranchName(branch) {
271
- if (!/^[a-zA-Z0-9._\/-]+$/.test(branch)) {
272
- throw new Error('Invalid branch name');
273
- }
274
- return branch;
275
- }
276
-
277
- function validateFilePath(file, projectPath) {
278
- if (!file || file.includes('\0')) {
279
- throw new Error('Invalid file path');
280
- }
281
- // Prevent path traversal: resolve the file relative to the project root
282
- // and ensure the result stays within the project directory
283
- if (projectPath) {
284
- const resolved = path.resolve(projectPath, file);
285
- const normalizedRoot = path.resolve(projectPath) + path.sep;
286
- if (!resolved.startsWith(normalizedRoot) && resolved !== path.resolve(projectPath)) {
287
- throw new Error('Invalid file path: path traversal detected');
288
- }
289
- }
290
- return file;
291
- }
292
-
293
- function validateRemoteName(remote) {
294
- if (!/^[a-zA-Z0-9._-]+$/.test(remote)) {
295
- throw new Error('Invalid remote name');
296
- }
297
- return remote;
298
- }
299
-
300
- function validateProjectPath(projectPath) {
301
- if (!projectPath || projectPath.includes('\0')) {
302
- throw new Error('Invalid project path');
303
- }
304
- const resolved = path.resolve(projectPath);
305
- // Must be an absolute path after resolution
306
- if (!path.isAbsolute(resolved)) {
307
- throw new Error('Invalid project path: must be absolute');
308
- }
309
- // Block obviously dangerous paths
310
- if (resolved === '/' || resolved === path.sep) {
311
- throw new Error('Invalid project path: root directory not allowed');
312
- }
313
- return resolved;
314
- }
315
-
316
- // Helper function to get the actual project path from the encoded project name
317
- async function getActualProjectPath(projectName) {
318
- let projectPath;
319
- try {
320
- projectPath = await extractProjectDirectory(projectName);
321
- } catch (error) {
322
- console.error(`Error extracting project directory for ${projectName}:`, error);
323
- throw new Error(`Unable to resolve project path for "${projectName}"`);
324
- }
325
- return validateProjectPath(projectPath);
326
- }
327
-
328
- // Helper function to strip git diff headers
329
- function stripDiffHeaders(diff) {
330
- if (!diff) return '';
331
-
332
- const lines = diff.split('\n');
333
- const filteredLines = [];
334
- let startIncluding = false;
335
-
336
- for (const line of lines) {
337
- // Skip all header lines including diff --git, index, file mode, and --- / +++ file paths
338
- if (line.startsWith('diff --git') ||
339
- line.startsWith('index ') ||
340
- line.startsWith('new file mode') ||
341
- line.startsWith('deleted file mode') ||
342
- line.startsWith('---') ||
343
- line.startsWith('+++')) {
344
- continue;
345
- }
346
-
347
- // Start including lines from @@ hunk headers onwards
348
- if (line.startsWith('@@') || startIncluding) {
349
- startIncluding = true;
350
- filteredLines.push(line);
351
- }
352
- }
353
-
354
- return filteredLines.join('\n');
355
- }
356
-
357
- // Helper function to validate git repository
358
- async function validateGitRepository(projectPath) {
359
- try {
360
- // Check if directory exists
361
- await fs.access(projectPath);
362
- } catch {
363
- throw new Error(`Project path not found: ${projectPath}`);
364
- }
365
-
366
- try {
367
- // Allow any directory that is inside a work tree (repo root or nested folder).
368
- const { stdout: insideWorkTreeOutput } = await spawnAsync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: projectPath });
369
- const isInsideWorkTree = insideWorkTreeOutput.trim() === 'true';
370
- if (!isInsideWorkTree) {
371
- throw new Error('Not inside a git work tree');
372
- }
373
-
374
- // Ensure git can resolve the repository root for this directory.
375
- await spawnAsync('git', ['rev-parse', '--show-toplevel'], { cwd: projectPath });
376
- } catch {
377
- 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.');
378
- }
379
- }
380
-
381
- function getGitErrorDetails(error) {
382
- return `${error?.message || ''} ${error?.stderr || ''} ${error?.stdout || ''}`;
383
- }
384
-
385
- function isMissingHeadRevisionError(error) {
386
- const errorDetails = getGitErrorDetails(error).toLowerCase();
387
- return errorDetails.includes('unknown revision')
388
- || errorDetails.includes('ambiguous argument')
389
- || errorDetails.includes('needed a single revision')
390
- || errorDetails.includes('bad revision');
391
- }
392
-
393
- async function getCurrentBranchName(projectPath) {
394
- try {
395
- // symbolic-ref works even when the repository has no commits.
396
- const { stdout } = await spawnAsync('git', ['symbolic-ref', '--short', 'HEAD'], { cwd: projectPath });
397
- const branchName = stdout.trim();
398
- if (branchName) {
399
- return branchName;
400
- }
401
- } catch (error) {
402
- // Fall back to rev-parse for detached HEAD and older git edge cases.
403
- }
404
-
405
- const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
406
- return stdout.trim();
407
- }
408
-
409
- async function repositoryHasCommits(projectPath) {
410
- try {
411
- await spawnAsync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: projectPath });
412
- return true;
413
- } catch (error) {
414
- if (isMissingHeadRevisionError(error)) {
415
- return false;
416
- }
417
- throw error;
418
- }
419
- }
420
-
421
- async function getRepositoryRootPath(projectPath) {
422
- const { stdout } = await spawnAsync('git', ['rev-parse', '--show-toplevel'], { cwd: projectPath });
423
- return stdout.trim();
424
- }
425
-
426
- function normalizeRepositoryRelativeFilePath(filePath) {
427
- return String(filePath)
428
- .replace(/\\/g, '/')
429
- .replace(/^\.\/+/, '')
430
- .replace(/^\/+/, '')
431
- .trim();
432
- }
433
-
434
- function normalizeRequestedRepositoryFilePath(repositoryRootPath, filePath) {
435
- const rawFilePath = String(filePath || '').replace(/\\/g, '/').trim();
436
- if (!rawFilePath) {
437
- return '';
438
- }
439
-
440
- if (path.isAbsolute(rawFilePath)) {
441
- const resolvedFilePath = path.resolve(rawFilePath);
442
- const resolvedRepositoryRoot = path.resolve(repositoryRootPath);
443
- const relativePath = path.relative(resolvedRepositoryRoot, resolvedFilePath);
444
-
445
- if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
446
- throw new Error('Invalid file path: outside repository');
447
- }
448
-
449
- return normalizeRepositoryRelativeFilePath(relativePath);
450
- }
451
-
452
- return normalizeRepositoryRelativeFilePath(rawFilePath);
453
- }
454
-
455
- function parseStatusFilePaths(statusOutput) {
456
- return statusOutput
457
- .split('\n')
458
- .map((line) => line.trimEnd())
459
- .filter((line) => line.trim())
460
- .map((line) => {
461
- const statusPath = line.substring(3);
462
- const renamedFilePath = statusPath.split(' -> ')[1];
463
- return normalizeRepositoryRelativeFilePath(renamedFilePath || statusPath);
464
- })
465
- .filter(Boolean);
466
- }
467
-
468
- function buildFilePathCandidates(projectPath, repositoryRootPath, filePath) {
469
- const normalizedFilePath = normalizeRequestedRepositoryFilePath(repositoryRootPath, filePath);
470
- const projectRelativePath = normalizeRepositoryRelativeFilePath(path.relative(repositoryRootPath, projectPath));
471
- const candidates = [normalizedFilePath];
472
-
473
- if (
474
- projectRelativePath
475
- && projectRelativePath !== '.'
476
- && !normalizedFilePath.startsWith(`${projectRelativePath}/`)
477
- ) {
478
- candidates.push(`${projectRelativePath}/${normalizedFilePath}`);
479
- }
480
-
481
- return Array.from(new Set(candidates.filter(Boolean)));
482
- }
483
-
484
- async function resolveRepositoryFilePath(projectPath, filePath) {
485
- validateFilePath(filePath);
486
-
487
- const repositoryRootPath = await getRepositoryRootPath(projectPath);
488
- const candidateFilePaths = buildFilePathCandidates(projectPath, repositoryRootPath, filePath);
489
-
490
- for (const candidateFilePath of candidateFilePaths) {
491
- const { stdout } = await spawnAsync('git', ['status', '--porcelain', '--', candidateFilePath], { cwd: repositoryRootPath });
492
- if (stdout.trim()) {
493
- return {
494
- repositoryRootPath,
495
- repositoryRelativeFilePath: candidateFilePath,
496
- };
497
- }
498
- }
499
-
500
- // If the caller sent a bare filename (e.g. "hello.ts"), recover it from changed files.
501
- const normalizedFilePath = normalizeRepositoryRelativeFilePath(filePath);
502
- if (!normalizedFilePath.includes('/')) {
503
- const { stdout: repositoryStatusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: repositoryRootPath });
504
- const changedFilePaths = parseStatusFilePaths(repositoryStatusOutput);
505
- const suffixMatches = changedFilePaths.filter(
506
- (changedFilePath) => changedFilePath === normalizedFilePath || changedFilePath.endsWith(`/${normalizedFilePath}`),
507
- );
508
-
509
- if (suffixMatches.length === 1) {
510
- return {
511
- repositoryRootPath,
512
- repositoryRelativeFilePath: suffixMatches[0],
513
- };
514
- }
515
- }
516
-
517
- return {
518
- repositoryRootPath,
519
- repositoryRelativeFilePath: candidateFilePaths[0],
520
- };
521
- }
522
-
523
- // Get git status for a project
524
- router.get('/status', async (req, res) => {
525
- const { project } = req.query;
526
- const requestedTrackingMode = String(req.query.mode || req.query.trackingMode || '').toLowerCase();
527
- const gitOnly = requestedTrackingMode === 'git';
528
-
529
- if (!project) {
530
- return res.status(400).json({ error: 'Project name is required' });
531
- }
532
-
533
- let projectPath;
534
- try {
535
- projectPath = await getActualProjectPath(project);
536
-
537
- // Validate git repository
538
- await validateGitRepository(projectPath);
539
-
540
- const branch = await getCurrentBranchName(projectPath);
541
- const hasCommits = await repositoryHasCommits(projectPath);
542
-
543
- // Get git status
544
- const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: projectPath });
545
-
546
- const modified = [];
547
- const added = [];
548
- const deleted = [];
549
- const untracked = [];
550
-
551
- statusOutput.split('\n').forEach(line => {
552
- if (!line.trim()) return;
553
-
554
- const status = line.substring(0, 2);
555
- const file = line.substring(3);
556
-
557
- if (status === 'M ' || status === ' M' || status === 'MM') {
558
- modified.push(file);
559
- } else if (status === 'A ' || status === 'AM') {
560
- added.push(file);
561
- } else if (status === 'D ' || status === ' D') {
562
- deleted.push(file);
563
- } else if (status === '??') {
564
- untracked.push(file);
565
- }
566
- });
567
-
568
- res.json({
569
- isGitRepository: true,
570
- trackingMode: 'git',
571
- branch,
572
- hasCommits,
573
- modified,
574
- added,
575
- deleted,
576
- untracked
577
- });
578
- } catch (error) {
579
- if (projectPath && !gitOnly && isNotGitRepositoryMessage(error.message)) {
580
- try {
581
- res.json(await buildFilesystemStatus(projectPath));
582
- return;
583
- } catch (fallbackError) {
584
- console.error('Filesystem status fallback error:', fallbackError);
585
- }
586
- }
587
-
588
- console.error('Git status error:', error);
589
- res.json({
590
- isGitRepository: false,
591
- trackingMode: gitOnly ? 'git' : undefined,
592
- error: error.message.includes('not a git repository') || error.message.includes('Project directory is not a git repository')
593
- ? error.message
594
- : 'Git operation failed',
595
- details: error.message.includes('not a git repository') || error.message.includes('Project directory is not a git repository')
596
- ? error.message
597
- : `Failed to get git status: ${error.message}`
598
- });
599
- }
600
- });
601
-
602
- // Get diff for a specific file
603
- router.get('/diff', async (req, res) => {
604
- const { project, file } = req.query;
605
-
606
- if (!project || !file) {
607
- return res.status(400).json({ error: 'Project name and file path are required' });
608
- }
609
-
610
- try {
611
- const projectPath = await getActualProjectPath(project);
612
-
613
- // Validate git repository
614
- await validateGitRepository(projectPath);
615
-
616
- const {
617
- repositoryRootPath,
618
- repositoryRelativeFilePath,
619
- } = await resolveRepositoryFilePath(projectPath, file);
620
-
621
- // Check if file is untracked or deleted
622
- const { stdout: statusOutput } = await spawnAsync(
623
- 'git',
624
- ['status', '--porcelain', '--', repositoryRelativeFilePath],
625
- { cwd: repositoryRootPath },
626
- );
627
- const isUntracked = statusOutput.startsWith('??');
628
- const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
629
-
630
- let diff;
631
- if (isUntracked) {
632
- // For untracked files, show the entire file content as additions
633
- const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
634
- const stats = await fs.stat(filePath);
635
-
636
- if (stats.isDirectory()) {
637
- // For directories, show a simple message
638
- diff = `Directory: ${repositoryRelativeFilePath}\n(Cannot show diff for directories)`;
639
- } else {
640
- const fileContent = await fs.readFile(filePath, 'utf-8');
641
- const lines = fileContent.split('\n');
642
- diff = `--- /dev/null\n+++ b/${repositoryRelativeFilePath}\n@@ -0,0 +1,${lines.length} @@\n` +
643
- lines.map(line => `+${line}`).join('\n');
644
- }
645
- } else if (isDeleted) {
646
- // For deleted files, show the entire file content from HEAD as deletions
647
- const { stdout: fileContent } = await spawnAsync(
648
- 'git',
649
- ['show', `HEAD:${repositoryRelativeFilePath}`],
650
- { cwd: repositoryRootPath },
651
- );
652
- const lines = fileContent.split('\n');
653
- diff = `--- a/${repositoryRelativeFilePath}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n` +
654
- lines.map(line => `-${line}`).join('\n');
655
- } else {
656
- // Get diff for tracked files
657
- // First check for unstaged changes (working tree vs index)
658
- const { stdout: unstagedDiff } = await spawnAsync(
659
- 'git',
660
- ['diff', '--', repositoryRelativeFilePath],
661
- { cwd: repositoryRootPath },
662
- );
663
-
664
- if (unstagedDiff) {
665
- // Show unstaged changes if they exist
666
- diff = stripDiffHeaders(unstagedDiff);
667
- } else {
668
- // If no unstaged changes, check for staged changes (index vs HEAD)
669
- const { stdout: stagedDiff } = await spawnAsync(
670
- 'git',
671
- ['diff', '--cached', '--', repositoryRelativeFilePath],
672
- { cwd: repositoryRootPath },
673
- );
674
- diff = stripDiffHeaders(stagedDiff) || '';
675
- }
676
- }
677
-
678
- res.json({ diff });
679
- } catch (error) {
680
- if (isNotGitRepositoryMessage(getGitErrorDetails(error))) {
681
- return res.json({
682
- diff: '',
683
- isGitRepository: false,
684
- trackingMode: 'filesystem',
685
- });
686
- }
687
-
688
- console.error('Git diff error:', error);
689
- res.json({ error: error.message });
690
- }
691
- });
692
-
693
- // Get file content with diff information for CodeEditor
694
- router.get('/file-with-diff', async (req, res) => {
695
- const { project, file } = req.query;
696
-
697
- if (!project || !file) {
698
- return res.status(400).json({ error: 'Project name and file path are required' });
699
- }
700
-
701
- try {
702
- const projectPath = await getActualProjectPath(project);
703
-
704
- // Validate git repository
705
- await validateGitRepository(projectPath);
706
-
707
- const {
708
- repositoryRootPath,
709
- repositoryRelativeFilePath,
710
- } = await resolveRepositoryFilePath(projectPath, file);
711
-
712
- const readFileFromIndex = async () => {
713
- const { stdout } = await spawnAsync(
714
- 'git',
715
- ['show', `:${repositoryRelativeFilePath}`],
716
- { cwd: repositoryRootPath },
717
- );
718
- return stdout;
719
- };
720
- const readFileFromHead = async () => {
721
- const { stdout } = await spawnAsync(
722
- 'git',
723
- ['show', `HEAD:${repositoryRelativeFilePath}`],
724
- { cwd: repositoryRootPath },
725
- );
726
- return stdout;
727
- };
728
-
729
- // Check file status
730
- const { stdout: statusOutput } = await spawnAsync(
731
- 'git',
732
- ['status', '--porcelain', '--', repositoryRelativeFilePath],
733
- { cwd: repositoryRootPath },
734
- );
735
- const statusLine = statusOutput.split('\n').find((line) => line.trim()) || '';
736
- const indexStatus = statusLine[0] || ' ';
737
- const worktreeStatus = statusLine[1] || ' ';
738
- const isUntracked = statusLine.startsWith('??');
739
- const hasUnstagedChange = !isUntracked && worktreeStatus !== ' ';
740
- const hasStagedChange = !isUntracked && indexStatus !== ' ';
741
- const isDeleted = worktreeStatus === 'D' || (!hasUnstagedChange && indexStatus === 'D');
742
-
743
- let currentContent = '';
744
- let oldContent = '';
745
-
746
- if (hasUnstagedChange) {
747
- try {
748
- oldContent = await readFileFromIndex();
749
- } catch {
750
- oldContent = '';
751
- }
752
-
753
- if (worktreeStatus === 'D') {
754
- currentContent = '';
755
- } else {
756
- const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
757
- const stats = await fs.stat(filePath);
758
- if (stats.isDirectory()) {
759
- return res.status(400).json({ error: 'Cannot show diff for directories' });
760
- }
761
- currentContent = await fs.readFile(filePath, 'utf-8');
762
- }
763
- } else if (hasStagedChange) {
764
- try {
765
- oldContent = await readFileFromHead();
766
- } catch {
767
- // A staged add has no HEAD copy; showing the whole file as added is correct.
768
- oldContent = '';
769
- }
770
-
771
- if (indexStatus === 'D') {
772
- currentContent = '';
773
- } else {
774
- try {
775
- currentContent = await readFileFromIndex();
776
- } catch {
777
- currentContent = '';
778
- }
779
- }
780
- } else if (isUntracked) {
781
- const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
782
- const stats = await fs.stat(filePath);
783
-
784
- if (stats.isDirectory()) {
785
- return res.status(400).json({ error: 'Cannot show diff for directories' });
786
- }
787
-
788
- currentContent = await fs.readFile(filePath, 'utf-8');
789
- } else {
790
- const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
791
- const stats = await fs.stat(filePath);
792
-
793
- if (stats.isDirectory()) {
794
- return res.status(400).json({ error: 'Cannot show diff for directories' });
795
- }
796
-
797
- currentContent = await fs.readFile(filePath, 'utf-8');
798
- oldContent = currentContent;
799
- }
800
-
801
- res.json({
802
- currentContent,
803
- oldContent,
804
- isDeleted,
805
- isUntracked
806
- });
807
- } catch (error) {
808
- if (isMissingFileError(error)) {
809
- return res.status(404).json({
810
- error: 'File not found',
811
- currentContent: '',
812
- oldContent: '',
813
- isDeleted: true,
814
- isUntracked: false,
815
- });
816
- }
817
-
818
- if (isNotGitRepositoryMessage(getGitErrorDetails(error))) {
819
- try {
820
- return res.json(await readFilesystemFileWithDiff(
821
- await getActualProjectPath(project),
822
- file,
823
- ));
824
- } catch (fallbackError) {
825
- if (isMissingFileError(fallbackError)) {
826
- return res.status(404).json({
827
- error: 'File not found',
828
- currentContent: '',
829
- oldContent: '',
830
- isDeleted: true,
831
- isUntracked: false,
832
- isGitRepository: false,
833
- trackingMode: 'filesystem',
834
- });
835
- }
836
-
837
- return res.status(fallbackError.statusCode || 500).json({
838
- error: fallbackError.message || 'Failed to read file',
839
- isGitRepository: false,
840
- trackingMode: 'filesystem',
841
- });
842
- }
843
- }
844
-
845
- console.error('Git file-with-diff error:', error);
846
- res.json({ error: error.message });
847
- }
848
- });
849
-
850
- // Create initial commit
851
- router.post('/initial-commit', async (req, res) => {
852
- const { project } = req.body;
853
-
854
- if (!project) {
855
- return res.status(400).json({ error: 'Project name is required' });
856
- }
857
-
858
- try {
859
- const projectPath = await getActualProjectPath(project);
860
-
861
- // Validate git repository
862
- await validateGitRepository(projectPath);
863
-
864
- // Check if there are already commits
865
- try {
866
- await spawnAsync('git', ['rev-parse', 'HEAD'], { cwd: projectPath });
867
- return res.status(400).json({ error: 'Repository already has commits. Use regular commit instead.' });
868
- } catch (error) {
869
- // No HEAD - this is good, we can create initial commit
870
- }
871
-
872
- // Add all files
873
- await spawnAsync('git', ['add', '.'], { cwd: projectPath });
874
-
875
- // Create initial commit
876
- const { stdout } = await spawnAsync('git', ['commit', '-m', 'Initial commit'], { cwd: projectPath });
877
-
878
- res.json({ success: true, output: stdout, message: 'Initial commit created successfully' });
879
- } catch (error) {
880
- console.error('Git initial commit error:', error);
881
-
882
- // Handle the case where there's nothing to commit
883
- if (error.message.includes('nothing to commit')) {
884
- return res.status(400).json({
885
- error: 'Nothing to commit',
886
- details: 'No files found in the repository. Add some files first.'
887
- });
888
- }
889
-
890
- res.status(500).json({ error: "Internal server error" });
891
- }
892
- });
893
-
894
- // Commit changes
895
- router.post('/commit', async (req, res) => {
896
- const { project, message, files } = req.body;
897
-
898
- if (!project || !message || !files || files.length === 0) {
899
- return res.status(400).json({ error: 'Project name, commit message, and files are required' });
900
- }
901
-
902
- try {
903
- const projectPath = await getActualProjectPath(project);
904
-
905
- // Validate git repository
906
- await validateGitRepository(projectPath);
907
- const repositoryRootPath = await getRepositoryRootPath(projectPath);
908
-
909
- // Stage selected files
910
- for (const file of files) {
911
- const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
912
- await spawnAsync('git', ['add', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
913
- }
914
-
915
- // Commit with message
916
- const { stdout } = await spawnAsync('git', ['commit', '-m', message], { cwd: repositoryRootPath });
917
-
918
- res.json({ success: true, output: stdout });
919
- } catch (error) {
920
- console.error('Git commit error:', error);
921
- res.status(500).json({ error: "Internal server error" });
922
- }
923
- });
924
-
925
- // Revert latest local commit (keeps changes staged)
926
- router.post('/revert-local-commit', async (req, res) => {
927
- const { project } = req.body;
928
-
929
- if (!project) {
930
- return res.status(400).json({ error: 'Project name is required' });
931
- }
932
-
933
- try {
934
- const projectPath = await getActualProjectPath(project);
935
- await validateGitRepository(projectPath);
936
-
937
- try {
938
- await spawnAsync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: projectPath });
939
- } catch (error) {
940
- return res.status(400).json({
941
- error: 'No local commit to revert',
942
- details: 'This repository has no commit yet.',
943
- });
944
- }
945
-
946
- try {
947
- // Soft reset rewinds one commit while preserving all file changes in the index.
948
- await spawnAsync('git', ['reset', '--soft', 'HEAD~1'], { cwd: projectPath });
949
- } catch (error) {
950
- const errorDetails = `${error.stderr || ''} ${error.message || ''}`;
951
- const isInitialCommit = errorDetails.includes('HEAD~1') &&
952
- (errorDetails.includes('unknown revision') || errorDetails.includes('ambiguous argument'));
953
-
954
- if (!isInitialCommit) {
955
- throw error;
956
- }
957
-
958
- // Initial commit has no parent; deleting HEAD uncommits it and keeps files staged.
959
- await spawnAsync('git', ['update-ref', '-d', 'HEAD'], { cwd: projectPath });
960
- }
961
-
962
- res.json({
963
- success: true,
964
- output: 'Latest local commit reverted successfully. Changes were kept staged.',
965
- });
966
- } catch (error) {
967
- console.error('Git revert local commit error:', error);
968
- res.status(500).json({ error: "Internal server error" });
969
- }
970
- });
971
-
972
- // Get list of branches
973
- router.get('/branches', async (req, res) => {
974
- const { project } = req.query;
975
-
976
- if (!project) {
977
- return res.status(400).json({ error: 'Project name is required' });
978
- }
979
-
980
- try {
981
- const projectPath = await getActualProjectPath(project);
982
-
983
- // Validate git repository
984
- await validateGitRepository(projectPath);
985
-
986
- // Get all branches
987
- const { stdout } = await spawnAsync('git', ['branch', '-a'], { cwd: projectPath });
988
-
989
- const rawLines = stdout
990
- .split('\n')
991
- .map(b => b.trim())
992
- .filter(b => b && !b.includes('->'));
993
-
994
- // Local branches (may start with '* ' for current)
995
- const localBranches = rawLines
996
- .filter(b => !b.startsWith('remotes/'))
997
- .map(b => (b.startsWith('* ') ? b.substring(2) : b));
998
-
999
- // Remote branches — strip 'remotes/<remote>/' prefix
1000
- const remoteBranches = rawLines
1001
- .filter(b => b.startsWith('remotes/'))
1002
- .map(b => b.replace(/^remotes\/[^/]+\//, ''))
1003
- .filter(name => !localBranches.includes(name)); // skip if already a local branch
1004
-
1005
- // Backward-compat flat list (local + unique remotes, deduplicated)
1006
- const branches = [...localBranches, ...remoteBranches]
1007
- .filter((b, i, arr) => arr.indexOf(b) === i);
1008
-
1009
- res.json({ branches, localBranches, remoteBranches });
1010
- } catch (error) {
1011
- console.error('Git branches error:', error);
1012
- res.json({ error: error.message });
1013
- }
1014
- });
1015
-
1016
- // Checkout branch
1017
- router.post('/checkout', async (req, res) => {
1018
- const { project, branch } = req.body;
1019
-
1020
- if (!project || !branch) {
1021
- return res.status(400).json({ error: 'Project name and branch are required' });
1022
- }
1023
-
1024
- try {
1025
- const projectPath = await getActualProjectPath(project);
1026
-
1027
- // Checkout the branch
1028
- validateBranchName(branch);
1029
- const { stdout } = await spawnAsync('git', ['checkout', branch], { cwd: projectPath });
1030
-
1031
- res.json({ success: true, output: stdout });
1032
- } catch (error) {
1033
- console.error('Git checkout error:', error);
1034
- res.status(500).json({ error: "Internal server error" });
1035
- }
1036
- });
1037
-
1038
- // Create new branch
1039
- router.post('/create-branch', async (req, res) => {
1040
- const { project, branch } = req.body;
1041
-
1042
- if (!project || !branch) {
1043
- return res.status(400).json({ error: 'Project name and branch name are required' });
1044
- }
1045
-
1046
- try {
1047
- const projectPath = await getActualProjectPath(project);
1048
-
1049
- // Create and checkout new branch
1050
- validateBranchName(branch);
1051
- const { stdout } = await spawnAsync('git', ['checkout', '-b', branch], { cwd: projectPath });
1052
-
1053
- res.json({ success: true, output: stdout });
1054
- } catch (error) {
1055
- console.error('Git create branch error:', error);
1056
- res.status(500).json({ error: "Internal server error" });
1057
- }
1058
- });
1059
-
1060
- // Delete a local branch
1061
- router.post('/delete-branch', async (req, res) => {
1062
- const { project, branch } = req.body;
1063
-
1064
- if (!project || !branch) {
1065
- return res.status(400).json({ error: 'Project name and branch name are required' });
1066
- }
1067
-
1068
- try {
1069
- const projectPath = await getActualProjectPath(project);
1070
- await validateGitRepository(projectPath);
1071
-
1072
- // Safety: cannot delete the currently checked-out branch
1073
- const { stdout: currentBranch } = await spawnAsync('git', ['branch', '--show-current'], { cwd: projectPath });
1074
- if (currentBranch.trim() === branch) {
1075
- return res.status(400).json({ error: 'Cannot delete the currently checked-out branch' });
1076
- }
1077
-
1078
- const { stdout } = await spawnAsync('git', ['branch', '-d', branch], { cwd: projectPath });
1079
- res.json({ success: true, output: stdout });
1080
- } catch (error) {
1081
- console.error('Git delete branch error:', error);
1082
- res.status(500).json({ error: "Internal server error" });
1083
- }
1084
- });
1085
-
1086
- // Get recent commits
1087
- router.get('/commits', async (req, res) => {
1088
- const { project, limit = 10 } = req.query;
1089
-
1090
- if (!project) {
1091
- return res.status(400).json({ error: 'Project name is required' });
1092
- }
1093
-
1094
- try {
1095
- const projectPath = await getActualProjectPath(project);
1096
- await validateGitRepository(projectPath);
1097
- const parsedLimit = Number.parseInt(String(limit), 10);
1098
- const safeLimit = Number.isFinite(parsedLimit) && parsedLimit > 0
1099
- ? Math.min(parsedLimit, 100)
1100
- : 10;
1101
-
1102
- // Get commit log with stats
1103
- const { stdout } = await spawnAsync(
1104
- 'git',
1105
- ['log', '--pretty=format:%H|%an|%ae|%ad|%s', '--date=iso-strict', '-n', String(safeLimit)],
1106
- { cwd: projectPath },
1107
- );
1108
-
1109
- const commits = stdout
1110
- .split('\n')
1111
- .filter(line => line.trim())
1112
- .map(line => {
1113
- const [hash, author, email, date, ...messageParts] = line.split('|');
1114
- return {
1115
- hash,
1116
- author,
1117
- email,
1118
- date,
1119
- message: messageParts.join('|')
1120
- };
1121
- });
1122
-
1123
- // Get stats for each commit
1124
- for (const commit of commits) {
1125
- try {
1126
- const { stdout: stats } = await spawnAsync(
1127
- 'git', ['show', '--stat', '--format=', commit.hash],
1128
- { cwd: projectPath }
1129
- );
1130
- commit.stats = stats.trim().split('\n').pop(); // Get the summary line
1131
- } catch (error) {
1132
- commit.stats = '';
1133
- }
1134
- }
1135
-
1136
- res.json({ commits });
1137
- } catch (error) {
1138
- console.error('Git commits error:', error);
1139
- res.json({ error: error.message });
1140
- }
1141
- });
1142
-
1143
- // Get diff for a specific commit
1144
- router.get('/commit-diff', async (req, res) => {
1145
- const { project, commit } = req.query;
1146
-
1147
- if (!project || !commit) {
1148
- return res.status(400).json({ error: 'Project name and commit hash are required' });
1149
- }
1150
-
1151
- try {
1152
- const projectPath = await getActualProjectPath(project);
1153
-
1154
- // Validate commit reference (defense-in-depth)
1155
- validateCommitRef(commit);
1156
-
1157
- // Get diff for the commit
1158
- const { stdout } = await spawnAsync(
1159
- 'git', ['show', commit],
1160
- { cwd: projectPath }
1161
- );
1162
-
1163
- const isTruncated = stdout.length > COMMIT_DIFF_CHARACTER_LIMIT;
1164
- const diff = isTruncated
1165
- ? `${stdout.slice(0, COMMIT_DIFF_CHARACTER_LIMIT)}\n\n... Diff truncated to keep the UI responsive ...`
1166
- : stdout;
1167
-
1168
- res.json({ diff, isTruncated });
1169
- } catch (error) {
1170
- console.error('Git commit diff error:', error);
1171
- res.json({ error: error.message });
1172
- }
1173
- });
1174
-
1175
- // Generate commit message based on staged changes using AI
1176
- router.post('/generate-commit-message', async (req, res) => {
1177
- const { project, files, provider = 'claude' } = req.body;
1178
-
1179
- if (!project || !files || files.length === 0) {
1180
- return res.status(400).json({ error: 'Project name and files are required' });
1181
- }
1182
-
1183
- // Validate provider
1184
- if (!['claude', 'cursor'].includes(provider)) {
1185
- return res.status(400).json({ error: 'provider must be "claude" or "cursor"' });
1186
- }
1187
-
1188
- try {
1189
- const projectPath = await getActualProjectPath(project);
1190
- await validateGitRepository(projectPath);
1191
- const repositoryRootPath = await getRepositoryRootPath(projectPath);
1192
-
1193
- // Get diff for selected files
1194
- let diffContext = '';
1195
- for (const file of files) {
1196
- try {
1197
- const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
1198
- const { stdout } = await spawnAsync(
1199
- 'git', ['diff', 'HEAD', '--', repositoryRelativeFilePath],
1200
- { cwd: repositoryRootPath }
1201
- );
1202
- if (stdout) {
1203
- diffContext += `\n--- ${repositoryRelativeFilePath} ---\n${stdout}`;
1204
- }
1205
- } catch (error) {
1206
- console.error(`Error getting diff for ${file}:`, error);
1207
- }
1208
- }
1209
-
1210
- // If no diff found, might be untracked files
1211
- if (!diffContext.trim()) {
1212
- // Try to get content of untracked files
1213
- for (const file of files) {
1214
- try {
1215
- const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
1216
- const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
1217
- const stats = await fs.stat(filePath);
1218
-
1219
- if (!stats.isDirectory()) {
1220
- const content = await fs.readFile(filePath, 'utf-8');
1221
- diffContext += `\n--- ${repositoryRelativeFilePath} (new file) ---\n${content.substring(0, 1000)}\n`;
1222
- } else {
1223
- diffContext += `\n--- ${repositoryRelativeFilePath} (new directory) ---\n`;
1224
- }
1225
- } catch (error) {
1226
- console.error(`Error reading file ${file}:`, error);
1227
- }
1228
- }
1229
- }
1230
-
1231
- // Generate commit message using AI
1232
- const message = await generateCommitMessageWithAI(files, diffContext, provider, projectPath);
1233
-
1234
- res.json({ message });
1235
- } catch (error) {
1236
- console.error('Generate commit message error:', error);
1237
- res.status(500).json({ error: "Internal server error" });
1238
- }
1239
- });
1240
-
1241
- /**
1242
- * Generates a commit message using AI (Claude SDK or Cursor CLI)
1243
- * @param {Array<string>} files - List of changed files
1244
- * @param {string} diffContext - Git diff content
1245
- * @param {string} provider - 'claude' or 'cursor'
1246
- * @param {string} projectPath - Project directory path
1247
- * @returns {Promise<string>} Generated commit message
1248
- */
1249
- async function generateCommitMessageWithAI(files, diffContext, provider, projectPath) {
1250
- // Create the prompt
1251
- const prompt = `Generate a conventional commit message for these changes.
1252
-
1253
- REQUIREMENTS:
1254
- - Format: type(scope): subject
1255
- - Include body explaining what changed and why
1256
- - Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore
1257
- - Subject under 50 chars, body wrapped at 72 chars
1258
- - Focus on user-facing changes, not implementation details
1259
- - Consider what's being added AND removed
1260
- - Return ONLY the commit message (no markdown, explanations, or code blocks)
1261
-
1262
- FILES CHANGED:
1263
- ${files.map(f => `- ${f}`).join('\n')}
1264
-
1265
- DIFFS:
1266
- ${diffContext.substring(0, 4000)}
1267
-
1268
- Generate the commit message:`;
1269
-
1270
- try {
1271
- // Create a simple writer that collects the response
1272
- let responseText = '';
1273
- const writer = {
1274
- send: (data) => {
1275
- try {
1276
- const parsed = typeof data === 'string' ? JSON.parse(data) : data;
1277
- console.log('🔍 Writer received message type:', parsed.type);
1278
-
1279
- // Handle different message formats from Claude SDK and Cursor CLI
1280
- // Claude SDK sends: {type: 'claude-response', data: {message: {content: [...]}}}
1281
- if (parsed.type === 'claude-response' && parsed.data) {
1282
- const message = parsed.data.message || parsed.data;
1283
- console.log('📦 Claude response message:', JSON.stringify(message, null, 2).substring(0, 500));
1284
- if (message.content && Array.isArray(message.content)) {
1285
- // Extract text from content array
1286
- for (const item of message.content) {
1287
- if (item.type === 'text' && item.text) {
1288
- console.log('✅ Extracted text chunk:', item.text.substring(0, 100));
1289
- responseText += item.text;
1290
- }
1291
- }
1292
- }
1293
- }
1294
- // Cursor CLI sends: {type: 'cursor-output', output: '...'}
1295
- else if (parsed.type === 'cursor-output' && parsed.output) {
1296
- console.log('✅ Cursor output:', parsed.output.substring(0, 100));
1297
- responseText += parsed.output;
1298
- }
1299
- // Also handle direct text messages
1300
- else if (parsed.type === 'text' && parsed.text) {
1301
- console.log('✅ Direct text:', parsed.text.substring(0, 100));
1302
- responseText += parsed.text;
1303
- }
1304
- } catch (e) {
1305
- // Ignore parse errors
1306
- console.error('Error parsing writer data:', e);
1307
- }
1308
- },
1309
- setSessionId: () => {}, // No-op for this use case
1310
- };
1311
-
1312
- console.log('🚀 Calling AI agent with provider:', provider);
1313
- console.log('📝 Prompt length:', prompt.length);
1314
-
1315
- // Call the appropriate agent
1316
- if (provider === 'claude') {
1317
- await queryClaudeSDK(prompt, {
1318
- cwd: projectPath,
1319
- permissionMode: 'bypassPermissions',
1320
- model: 'sonnet'
1321
- }, writer);
1322
- } else if (provider === 'cursor') {
1323
- await spawnCursor(prompt, {
1324
- cwd: projectPath,
1325
- skipPermissions: true
1326
- }, writer);
1327
- }
1328
-
1329
- console.log('📊 Total response text collected:', responseText.length, 'characters');
1330
- console.log('📄 Response preview:', responseText.substring(0, 200));
1331
-
1332
- // Clean up the response
1333
- const cleanedMessage = cleanCommitMessage(responseText);
1334
- console.log('🧹 Cleaned message:', cleanedMessage.substring(0, 200));
1335
-
1336
- return cleanedMessage || 'chore: update files';
1337
- } catch (error) {
1338
- console.error('Error generating commit message with AI:', error);
1339
- // Fallback to simple message
1340
- return `chore: update ${files.length} file${files.length !== 1 ? 's' : ''}`;
1341
- }
1342
- }
1343
-
1344
- /**
1345
- * Cleans the AI-generated commit message by removing markdown, code blocks, and extra formatting
1346
- * @param {string} text - Raw AI response
1347
- * @returns {string} Clean commit message
1348
- */
1349
- function cleanCommitMessage(text) {
1350
- if (!text || !text.trim()) {
1351
- return '';
1352
- }
1353
-
1354
- let cleaned = text.trim();
1355
-
1356
- // Remove markdown code blocks
1357
- cleaned = cleaned.replace(/```[a-z]*\n/g, '');
1358
- cleaned = cleaned.replace(/```/g, '');
1359
-
1360
- // Remove markdown headers
1361
- cleaned = cleaned.replace(/^#+\s*/gm, '');
1362
-
1363
- // Remove leading/trailing quotes
1364
- cleaned = cleaned.replace(/^["']|["']$/g, '');
1365
-
1366
- // If there are multiple lines, take everything (subject + body)
1367
- // Just clean up extra blank lines
1368
- cleaned = cleaned.replace(/\n{3,}/g, '\n\n');
1369
-
1370
- // Remove any explanatory text before the actual commit message
1371
- // Look for conventional commit pattern and start from there
1372
- const conventionalCommitMatch = cleaned.match(/(feat|fix|docs|style|refactor|perf|test|build|ci|chore)(\(.+?\))?:.+/s);
1373
- if (conventionalCommitMatch) {
1374
- cleaned = cleaned.substring(cleaned.indexOf(conventionalCommitMatch[0]));
1375
- }
1376
-
1377
- return cleaned.trim();
1378
- }
1379
-
1380
- // Get remote status (ahead/behind commits with smart remote detection)
1381
- router.get('/remote-status', async (req, res) => {
1382
- const { project } = req.query;
1383
-
1384
- if (!project) {
1385
- return res.status(400).json({ error: 'Project name is required' });
1386
- }
1387
-
1388
- try {
1389
- const projectPath = await getActualProjectPath(project);
1390
- await validateGitRepository(projectPath);
1391
-
1392
- const branch = await getCurrentBranchName(projectPath);
1393
- const hasCommits = await repositoryHasCommits(projectPath);
1394
-
1395
- const { stdout: remoteOutput } = await spawnAsync('git', ['remote'], { cwd: projectPath });
1396
- const remotes = remoteOutput.trim().split('\n').filter(r => r.trim());
1397
- const hasRemote = remotes.length > 0;
1398
- const fallbackRemoteName = hasRemote
1399
- ? (remotes.includes('origin') ? 'origin' : remotes[0])
1400
- : null;
1401
-
1402
- // Repositories initialized with `git init` can have a branch but no commits.
1403
- // Return a non-error state so the UI can show the initial-commit workflow.
1404
- if (!hasCommits) {
1405
- return res.json({
1406
- hasRemote,
1407
- hasUpstream: false,
1408
- branch,
1409
- remoteName: fallbackRemoteName,
1410
- ahead: 0,
1411
- behind: 0,
1412
- isUpToDate: false,
1413
- message: 'Repository has no commits yet'
1414
- });
1415
- }
1416
-
1417
- // Check if there's a remote tracking branch (smart detection)
1418
- let trackingBranch;
1419
- let remoteName;
1420
- try {
1421
- const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
1422
- trackingBranch = stdout.trim();
1423
- remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin")
1424
- } catch (error) {
1425
- return res.json({
1426
- hasRemote,
1427
- hasUpstream: false,
1428
- branch,
1429
- remoteName: fallbackRemoteName,
1430
- message: 'No remote tracking branch configured'
1431
- });
1432
- }
1433
-
1434
- // Get ahead/behind counts
1435
- const { stdout: countOutput } = await spawnAsync(
1436
- 'git', ['rev-list', '--count', '--left-right', `${trackingBranch}...HEAD`],
1437
- { cwd: projectPath }
1438
- );
1439
-
1440
- const [behind, ahead] = countOutput.trim().split('\t').map(Number);
1441
-
1442
- res.json({
1443
- hasRemote: true,
1444
- hasUpstream: true,
1445
- branch,
1446
- remoteBranch: trackingBranch,
1447
- remoteName,
1448
- ahead: ahead || 0,
1449
- behind: behind || 0,
1450
- isUpToDate: ahead === 0 && behind === 0
1451
- });
1452
- } catch (error) {
1453
- console.error('Git remote status error:', error);
1454
- res.json({ error: error.message });
1455
- }
1456
- });
1457
-
1458
- // Fetch from remote (using smart remote detection)
1459
- router.post('/fetch', async (req, res) => {
1460
- const { project } = req.body;
1461
-
1462
- if (!project) {
1463
- return res.status(400).json({ error: 'Project name is required' });
1464
- }
1465
-
1466
- try {
1467
- const projectPath = await getActualProjectPath(project);
1468
- await validateGitRepository(projectPath);
1469
-
1470
- // Get current branch and its upstream remote
1471
- const branch = await getCurrentBranchName(projectPath);
1472
-
1473
- let remoteName = 'origin'; // fallback
1474
- try {
1475
- const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
1476
- remoteName = stdout.trim().split('/')[0]; // Extract remote name
1477
- } catch (error) {
1478
- // No upstream, try to fetch from origin anyway
1479
- console.log('No upstream configured, using origin as fallback');
1480
- }
1481
-
1482
- validateRemoteName(remoteName);
1483
- const { stdout } = await spawnAsync('git', ['fetch', remoteName], { cwd: projectPath });
1484
-
1485
- res.json({ success: true, output: stdout || 'Fetch completed successfully', remoteName });
1486
- } catch (error) {
1487
- console.error('Git fetch error:', error);
1488
- res.status(500).json({
1489
- error: 'Fetch failed',
1490
- details: error.message.includes('Could not resolve hostname')
1491
- ? 'Unable to connect to remote repository. Check your internet connection.'
1492
- : error.message.includes('fatal: \'origin\' does not appear to be a git repository')
1493
- ? 'No remote repository configured. Add a remote with: git remote add origin <url>'
1494
- : error.message
1495
- });
1496
- }
1497
- });
1498
-
1499
- // Pull from remote (fetch + merge using smart remote detection)
1500
- router.post('/pull', async (req, res) => {
1501
- const { project } = req.body;
1502
-
1503
- if (!project) {
1504
- return res.status(400).json({ error: 'Project name is required' });
1505
- }
1506
-
1507
- try {
1508
- const projectPath = await getActualProjectPath(project);
1509
- await validateGitRepository(projectPath);
1510
-
1511
- // Get current branch and its upstream remote
1512
- const branch = await getCurrentBranchName(projectPath);
1513
-
1514
- let remoteName = 'origin'; // fallback
1515
- let remoteBranch = branch; // fallback
1516
- try {
1517
- const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
1518
- const tracking = stdout.trim();
1519
- remoteName = tracking.split('/')[0]; // Extract remote name
1520
- remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name
1521
- } catch (error) {
1522
- // No upstream, use fallback
1523
- console.log('No upstream configured, using origin/branch as fallback');
1524
- }
1525
-
1526
- validateRemoteName(remoteName);
1527
- validateBranchName(remoteBranch);
1528
- const { stdout } = await spawnAsync('git', ['pull', remoteName, remoteBranch], { cwd: projectPath });
1529
-
1530
- res.json({
1531
- success: true,
1532
- output: stdout || 'Pull completed successfully',
1533
- remoteName,
1534
- remoteBranch
1535
- });
1536
- } catch (error) {
1537
- console.error('Git pull error:', error);
1538
-
1539
- // Enhanced error handling for common pull scenarios
1540
- let errorMessage = 'Pull failed';
1541
- let details = error.message;
1542
-
1543
- if (error.message.includes('CONFLICT')) {
1544
- errorMessage = 'Merge conflicts detected';
1545
- details = 'Pull created merge conflicts. Please resolve conflicts manually in the editor, then commit the changes.';
1546
- } else if (error.message.includes('Please commit your changes or stash them')) {
1547
- errorMessage = 'Uncommitted changes detected';
1548
- details = 'Please commit or stash your local changes before pulling.';
1549
- } else if (error.message.includes('Could not resolve hostname')) {
1550
- errorMessage = 'Network error';
1551
- details = 'Unable to connect to remote repository. Check your internet connection.';
1552
- } else if (error.message.includes('fatal: \'origin\' does not appear to be a git repository')) {
1553
- errorMessage = 'Remote not configured';
1554
- details = 'No remote repository configured. Add a remote with: git remote add origin <url>';
1555
- } else if (error.message.includes('diverged')) {
1556
- errorMessage = 'Branches have diverged';
1557
- details = 'Your local branch and remote branch have diverged. Consider fetching first to review changes.';
1558
- }
1559
-
1560
- res.status(500).json({
1561
- error: errorMessage,
1562
- details: details
1563
- });
1564
- }
1565
- });
1566
-
1567
- // Push commits to remote repository
1568
- router.post('/push', async (req, res) => {
1569
- const { project } = req.body;
1570
-
1571
- if (!project) {
1572
- return res.status(400).json({ error: 'Project name is required' });
1573
- }
1574
-
1575
- try {
1576
- const projectPath = await getActualProjectPath(project);
1577
- await validateGitRepository(projectPath);
1578
-
1579
- // Get current branch and its upstream remote
1580
- const branch = await getCurrentBranchName(projectPath);
1581
-
1582
- let remoteName = 'origin'; // fallback
1583
- let remoteBranch = branch; // fallback
1584
- try {
1585
- const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
1586
- const tracking = stdout.trim();
1587
- remoteName = tracking.split('/')[0]; // Extract remote name
1588
- remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name
1589
- } catch (error) {
1590
- // No upstream, use fallback
1591
- console.log('No upstream configured, using origin/branch as fallback');
1592
- }
1593
-
1594
- validateRemoteName(remoteName);
1595
- validateBranchName(remoteBranch);
1596
- const { stdout } = await spawnAsync('git', ['push', remoteName, remoteBranch], { cwd: projectPath });
1597
-
1598
- res.json({
1599
- success: true,
1600
- output: stdout || 'Push completed successfully',
1601
- remoteName,
1602
- remoteBranch
1603
- });
1604
- } catch (error) {
1605
- console.error('Git push error:', error);
1606
-
1607
- // Enhanced error handling for common push scenarios
1608
- let errorMessage = 'Push failed';
1609
- let details = error.message;
1610
-
1611
- if (error.message.includes('rejected')) {
1612
- errorMessage = 'Push rejected';
1613
- details = 'The remote has newer commits. Pull first to merge changes before pushing.';
1614
- } else if (error.message.includes('non-fast-forward')) {
1615
- errorMessage = 'Non-fast-forward push';
1616
- details = 'Your branch is behind the remote. Pull the latest changes first.';
1617
- } else if (error.message.includes('Could not resolve hostname')) {
1618
- errorMessage = 'Network error';
1619
- details = 'Unable to connect to remote repository. Check your internet connection.';
1620
- } else if (error.message.includes('fatal: \'origin\' does not appear to be a git repository')) {
1621
- errorMessage = 'Remote not configured';
1622
- details = 'No remote repository configured. Add a remote with: git remote add origin <url>';
1623
- } else if (error.message.includes('Permission denied')) {
1624
- errorMessage = 'Authentication failed';
1625
- details = 'Permission denied. Check your credentials or SSH keys.';
1626
- } else if (error.message.includes('no upstream branch')) {
1627
- errorMessage = 'No upstream branch';
1628
- details = 'No upstream branch configured. Use: git push --set-upstream origin <branch>';
1629
- }
1630
-
1631
- res.status(500).json({
1632
- error: errorMessage,
1633
- details: details
1634
- });
1635
- }
1636
- });
1637
-
1638
- // Publish branch to remote (set upstream and push)
1639
- router.post('/publish', async (req, res) => {
1640
- const { project, branch } = req.body;
1641
-
1642
- if (!project || !branch) {
1643
- return res.status(400).json({ error: 'Project name and branch are required' });
1644
- }
1645
-
1646
- try {
1647
- const projectPath = await getActualProjectPath(project);
1648
- await validateGitRepository(projectPath);
1649
-
1650
- // Validate branch name
1651
- validateBranchName(branch);
1652
-
1653
- // Get current branch to verify it matches the requested branch
1654
- const currentBranchName = await getCurrentBranchName(projectPath);
1655
-
1656
- if (currentBranchName !== branch) {
1657
- return res.status(400).json({
1658
- error: `Branch mismatch. Current branch is ${currentBranchName}, but trying to publish ${branch}`
1659
- });
1660
- }
1661
-
1662
- // Check if remote exists
1663
- let remoteName = 'origin';
1664
- try {
1665
- const { stdout } = await spawnAsync('git', ['remote'], { cwd: projectPath });
1666
- const remotes = stdout.trim().split('\n').filter(r => r.trim());
1667
- if (remotes.length === 0) {
1668
- return res.status(400).json({
1669
- error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
1670
- });
1671
- }
1672
- remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
1673
- } catch (error) {
1674
- return res.status(400).json({
1675
- error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
1676
- });
1677
- }
1678
-
1679
- // Publish the branch (set upstream and push)
1680
- validateRemoteName(remoteName);
1681
- const { stdout } = await spawnAsync('git', ['push', '--set-upstream', remoteName, branch], { cwd: projectPath });
1682
-
1683
- res.json({
1684
- success: true,
1685
- output: stdout || 'Branch published successfully',
1686
- remoteName,
1687
- branch
1688
- });
1689
- } catch (error) {
1690
- console.error('Git publish error:', error);
1691
-
1692
- // Enhanced error handling for common publish scenarios
1693
- let errorMessage = 'Publish failed';
1694
- let details = error.message;
1695
-
1696
- if (error.message.includes('rejected')) {
1697
- errorMessage = 'Publish rejected';
1698
- details = 'The remote branch already exists and has different commits. Use push instead.';
1699
- } else if (error.message.includes('Could not resolve hostname')) {
1700
- errorMessage = 'Network error';
1701
- details = 'Unable to connect to remote repository. Check your internet connection.';
1702
- } else if (error.message.includes('Permission denied')) {
1703
- errorMessage = 'Authentication failed';
1704
- details = 'Permission denied. Check your credentials or SSH keys.';
1705
- } else if (error.message.includes('fatal:') && error.message.includes('does not appear to be a git repository')) {
1706
- errorMessage = 'Remote not configured';
1707
- details = 'Remote repository not properly configured. Check your remote URL.';
1708
- }
1709
-
1710
- res.status(500).json({
1711
- error: errorMessage,
1712
- details: details
1713
- });
1714
- }
1715
- });
1716
-
1717
- // Discard changes for a specific file
1718
- router.post('/discard', async (req, res) => {
1719
- const { project, file } = req.body;
1720
-
1721
- if (!project || !file) {
1722
- return res.status(400).json({ error: 'Project name and file path are required' });
1723
- }
1724
-
1725
- try {
1726
- const projectPath = await getActualProjectPath(project);
1727
- await validateGitRepository(projectPath);
1728
- const {
1729
- repositoryRootPath,
1730
- repositoryRelativeFilePath,
1731
- } = await resolveRepositoryFilePath(projectPath, file);
1732
-
1733
- // Check file status to determine correct discard command
1734
- const { stdout: statusOutput } = await spawnAsync(
1735
- 'git',
1736
- ['status', '--porcelain', '--', repositoryRelativeFilePath],
1737
- { cwd: repositoryRootPath },
1738
- );
1739
-
1740
- if (!statusOutput.trim()) {
1741
- return res.status(400).json({ error: 'No changes to discard for this file' });
1742
- }
1743
-
1744
- const status = statusOutput.substring(0, 2);
1745
-
1746
- if (status === '??') {
1747
- // Untracked file or directory - delete it
1748
- const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
1749
- const stats = await fs.stat(filePath);
1750
-
1751
- if (stats.isDirectory()) {
1752
- await fs.rm(filePath, { recursive: true, force: true });
1753
- } else {
1754
- await fs.unlink(filePath);
1755
- }
1756
- } else if (status.includes('M') || status.includes('D')) {
1757
- // Modified or deleted file - restore from HEAD
1758
- await spawnAsync('git', ['restore', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
1759
- } else if (status.includes('A')) {
1760
- // Added file - unstage it
1761
- await spawnAsync('git', ['reset', 'HEAD', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
1762
- }
1763
-
1764
- res.json({ success: true, message: `Changes discarded for ${repositoryRelativeFilePath}` });
1765
- } catch (error) {
1766
- console.error('Git discard error:', error);
1767
- res.status(500).json({ error: "Internal server error" });
1768
- }
1769
- });
1770
-
1771
- // Delete untracked file
1772
- router.post('/delete-untracked', async (req, res) => {
1773
- const { project, file } = req.body;
1774
-
1775
- if (!project || !file) {
1776
- return res.status(400).json({ error: 'Project name and file path are required' });
1777
- }
1778
-
1779
- try {
1780
- const projectPath = await getActualProjectPath(project);
1781
- await validateGitRepository(projectPath);
1782
- const {
1783
- repositoryRootPath,
1784
- repositoryRelativeFilePath,
1785
- } = await resolveRepositoryFilePath(projectPath, file);
1786
-
1787
- // Check if file is actually untracked
1788
- const { stdout: statusOutput } = await spawnAsync(
1789
- 'git',
1790
- ['status', '--porcelain', '--', repositoryRelativeFilePath],
1791
- { cwd: repositoryRootPath },
1792
- );
1793
-
1794
- if (!statusOutput.trim()) {
1795
- return res.status(400).json({ error: 'File is not untracked or does not exist' });
1796
- }
1797
-
1798
- const status = statusOutput.substring(0, 2);
1799
-
1800
- if (status !== '??') {
1801
- return res.status(400).json({ error: 'File is not untracked. Use discard for tracked files.' });
1802
- }
1803
-
1804
- // Delete the untracked file or directory
1805
- const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
1806
- const stats = await fs.stat(filePath);
1807
-
1808
- if (stats.isDirectory()) {
1809
- // Use rm with recursive option for directories
1810
- await fs.rm(filePath, { recursive: true, force: true });
1811
- res.json({ success: true, message: `Untracked directory ${repositoryRelativeFilePath} deleted successfully` });
1812
- } else {
1813
- await fs.unlink(filePath);
1814
- res.json({ success: true, message: `Untracked file ${repositoryRelativeFilePath} deleted successfully` });
1815
- }
1816
- } catch (error) {
1817
- console.error('Git delete untracked error:', error);
1818
- res.status(500).json({ error: "Internal server error" });
1819
- }
1820
- });
1821
-
1822
- export default router;
1
+ import { spawn } from 'child_process';
2
+ import path from 'path';
3
+ import { promises as fs } from 'fs';
4
+
5
+ import express from 'express';
6
+
7
+ import { extractProjectDirectory } from '../projects.js';
8
+ import { queryClaudeSDK } from '../claude-sdk.js';
9
+ import { spawnCursor } from '../cursor-cli.js';
10
+ import { userHasProjectAccess } from '../services/platformization.js';
11
+
12
+ const router = express.Router();
13
+ const COMMIT_DIFF_CHARACTER_LIMIT = 500_000;
14
+ const FILESYSTEM_SCAN_MAX_FILES = 5_000;
15
+ const FILESYSTEM_SCAN_MAX_DEPTH = 10;
16
+ const filesystemChangeSnapshots = new Map();
17
+ const FILESYSTEM_SCAN_EXCLUDED_DIRS = new Set([
18
+ '.git',
19
+ '.hg',
20
+ '.svn',
21
+ 'node_modules',
22
+ 'dist',
23
+ 'dist-server',
24
+ 'build',
25
+ '.next',
26
+ '.nuxt',
27
+ '.svelte-kit',
28
+ 'coverage',
29
+ '.turbo',
30
+ '.cache',
31
+ '.pixcode-dev',
32
+ '.gradle',
33
+ '.repo',
34
+ '.venv',
35
+ 'out',
36
+ 'target',
37
+ 'vendor',
38
+ 'prebuilts',
39
+ 'Pods',
40
+ 'DerivedData',
41
+ 'venv',
42
+ ]);
43
+
44
+ router.use((req, res, next) => {
45
+ const project = req.query.project || req.body?.project;
46
+ if (!project) {
47
+ return next();
48
+ }
49
+
50
+ const capability = req.method === 'GET' ? 'viewFiles' : 'editFiles';
51
+ if (!userHasProjectAccess(req.user, { name: String(project), projectName: String(project) }, capability)) {
52
+ return res.status(403).json({ error: 'Project access denied.' });
53
+ }
54
+
55
+ next();
56
+ });
57
+
58
+ function isNotGitRepositoryMessage(message = '') {
59
+ const normalized = String(message || '').toLowerCase();
60
+ return normalized.includes('not a git repository')
61
+ || normalized.includes('not inside a git work tree')
62
+ || normalized.includes('does not contain a .git folder')
63
+ || normalized.includes('project directory is not a git repository');
64
+ }
65
+
66
+ function isMissingFileError(error) {
67
+ const message = `${error?.message || ''} ${error?.stderr || ''} ${error?.stdout || ''}`.toLowerCase();
68
+ return error?.code === 'ENOENT'
69
+ || message.includes('enoent')
70
+ || message.includes('no such file or directory')
71
+ || message.includes('path does not exist');
72
+ }
73
+
74
+ function shouldSkipFilesystemEntry(entryName) {
75
+ return FILESYSTEM_SCAN_EXCLUDED_DIRS.has(entryName)
76
+ || entryName.endsWith('.log')
77
+ || entryName === '.DS_Store';
78
+ }
79
+
80
+ function toProjectRelativePath(projectPath, filePath) {
81
+ return path.relative(projectPath, filePath).replace(/\\/g, '/');
82
+ }
83
+
84
+ async function collectFilesystemSnapshot(projectPath) {
85
+ const snapshot = new Map();
86
+ let limitReached = false;
87
+
88
+ async function walk(directoryPath, depth) {
89
+ if (limitReached || depth > FILESYSTEM_SCAN_MAX_DEPTH) {
90
+ return;
91
+ }
92
+
93
+ try {
94
+ const dir = await fs.opendir(directoryPath);
95
+ try {
96
+ for await (const entry of dir) {
97
+ if (limitReached || shouldSkipFilesystemEntry(entry.name)) {
98
+ continue;
99
+ }
100
+
101
+ const absolutePath = path.join(directoryPath, entry.name);
102
+
103
+ if (entry.isDirectory()) {
104
+ await walk(absolutePath, depth + 1);
105
+ continue;
106
+ }
107
+
108
+ if (!entry.isFile()) {
109
+ continue;
110
+ }
111
+
112
+ try {
113
+ const stat = await fs.stat(absolutePath);
114
+ snapshot.set(toProjectRelativePath(projectPath, absolutePath), {
115
+ mtimeMs: Math.round(stat.mtimeMs),
116
+ size: stat.size,
117
+ });
118
+ } catch {
119
+ continue;
120
+ }
121
+
122
+ if (snapshot.size >= FILESYSTEM_SCAN_MAX_FILES) {
123
+ limitReached = true;
124
+ break;
125
+ }
126
+ }
127
+ } finally {
128
+ await dir.close().catch(() => undefined);
129
+ }
130
+ } catch {
131
+ return;
132
+ }
133
+ }
134
+
135
+ await walk(projectPath, 0);
136
+ return { snapshot, limitReached };
137
+ }
138
+
139
+ function diffFilesystemSnapshots(previousSnapshot, nextSnapshot) {
140
+ if (!previousSnapshot) {
141
+ return { modified: [], added: [], deleted: [] };
142
+ }
143
+
144
+ const modified = [];
145
+ const added = [];
146
+ const deleted = [];
147
+
148
+ for (const [filePath, nextMeta] of nextSnapshot.entries()) {
149
+ const previousMeta = previousSnapshot.get(filePath);
150
+ if (!previousMeta) {
151
+ added.push(filePath);
152
+ continue;
153
+ }
154
+
155
+ if (previousMeta.mtimeMs !== nextMeta.mtimeMs || previousMeta.size !== nextMeta.size) {
156
+ modified.push(filePath);
157
+ }
158
+ }
159
+
160
+ for (const filePath of previousSnapshot.keys()) {
161
+ if (!nextSnapshot.has(filePath)) {
162
+ deleted.push(filePath);
163
+ }
164
+ }
165
+
166
+ return {
167
+ modified: modified.sort(),
168
+ added: added.sort(),
169
+ deleted: deleted.sort(),
170
+ };
171
+ }
172
+
173
+ async function buildFilesystemStatus(projectPath) {
174
+ const normalizedProjectPath = path.resolve(projectPath);
175
+ const previousSnapshot = filesystemChangeSnapshots.get(normalizedProjectPath) ?? null;
176
+ const { snapshot, limitReached } = await collectFilesystemSnapshot(normalizedProjectPath);
177
+ filesystemChangeSnapshots.set(normalizedProjectPath, snapshot);
178
+ const { modified, added, deleted } = diffFilesystemSnapshots(previousSnapshot, snapshot);
179
+
180
+ return {
181
+ isGitRepository: false,
182
+ trackingMode: 'filesystem',
183
+ branch: null,
184
+ hasCommits: false,
185
+ modified,
186
+ added,
187
+ deleted,
188
+ untracked: [],
189
+ snapshotReady: Boolean(previousSnapshot),
190
+ fileCount: snapshot.size,
191
+ scanLimitReached: limitReached,
192
+ };
193
+ }
194
+
195
+ async function readFilesystemFileWithDiff(projectPath, filePath) {
196
+ const projectRoot = path.resolve(projectPath);
197
+ const resolvedFilePath = path.resolve(projectRoot, String(filePath || ''));
198
+ const relativePath = path.relative(projectRoot, resolvedFilePath);
199
+
200
+ if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
201
+ const error = new Error('Invalid file path: path traversal detected');
202
+ error.statusCode = 400;
203
+ throw error;
204
+ }
205
+
206
+ const stats = await fs.stat(resolvedFilePath);
207
+ if (stats.isDirectory()) {
208
+ const error = new Error('Cannot show diff for directories');
209
+ error.statusCode = 400;
210
+ throw error;
211
+ }
212
+
213
+ const currentContent = await fs.readFile(resolvedFilePath, 'utf-8');
214
+ return {
215
+ currentContent,
216
+ oldContent: currentContent,
217
+ isDeleted: false,
218
+ isUntracked: false,
219
+ isGitRepository: false,
220
+ trackingMode: 'filesystem',
221
+ };
222
+ }
223
+
224
+ function spawnAsync(command, args, options = {}) {
225
+ return new Promise((resolve, reject) => {
226
+ const child = spawn(command, args, {
227
+ ...options,
228
+ shell: false,
229
+ });
230
+
231
+ let stdout = '';
232
+ let stderr = '';
233
+
234
+ child.stdout.on('data', (data) => {
235
+ stdout += data.toString();
236
+ });
237
+
238
+ child.stderr.on('data', (data) => {
239
+ stderr += data.toString();
240
+ });
241
+
242
+ child.on('error', (error) => {
243
+ reject(error);
244
+ });
245
+
246
+ child.on('close', (code) => {
247
+ if (code === 0) {
248
+ resolve({ stdout, stderr });
249
+ return;
250
+ }
251
+
252
+ const error = new Error(`Command failed: ${command} ${args.join(' ')}`);
253
+ error.code = code;
254
+ error.stdout = stdout;
255
+ error.stderr = stderr;
256
+ reject(error);
257
+ });
258
+ });
259
+ }
260
+
261
+ // Input validation helpers (defense-in-depth)
262
+ function validateCommitRef(commit) {
263
+ // Allow hex hashes, HEAD, HEAD~N, HEAD^N, tag names, branch names
264
+ if (!/^[a-zA-Z0-9._~^{}@\/-]+$/.test(commit)) {
265
+ throw new Error('Invalid commit reference');
266
+ }
267
+ return commit;
268
+ }
269
+
270
+ function validateBranchName(branch) {
271
+ if (!/^[a-zA-Z0-9._\/-]+$/.test(branch)) {
272
+ throw new Error('Invalid branch name');
273
+ }
274
+ return branch;
275
+ }
276
+
277
+ function validateFilePath(file, projectPath) {
278
+ if (!file || file.includes('\0')) {
279
+ throw new Error('Invalid file path');
280
+ }
281
+ // Prevent path traversal: resolve the file relative to the project root
282
+ // and ensure the result stays within the project directory
283
+ if (projectPath) {
284
+ const resolved = path.resolve(projectPath, file);
285
+ const normalizedRoot = path.resolve(projectPath) + path.sep;
286
+ if (!resolved.startsWith(normalizedRoot) && resolved !== path.resolve(projectPath)) {
287
+ throw new Error('Invalid file path: path traversal detected');
288
+ }
289
+ }
290
+ return file;
291
+ }
292
+
293
+ function validateRemoteName(remote) {
294
+ if (!/^[a-zA-Z0-9._-]+$/.test(remote)) {
295
+ throw new Error('Invalid remote name');
296
+ }
297
+ return remote;
298
+ }
299
+
300
+ function validateProjectPath(projectPath) {
301
+ if (!projectPath || projectPath.includes('\0')) {
302
+ throw new Error('Invalid project path');
303
+ }
304
+ const resolved = path.resolve(projectPath);
305
+ // Must be an absolute path after resolution
306
+ if (!path.isAbsolute(resolved)) {
307
+ throw new Error('Invalid project path: must be absolute');
308
+ }
309
+ // Block obviously dangerous paths
310
+ if (resolved === '/' || resolved === path.sep) {
311
+ throw new Error('Invalid project path: root directory not allowed');
312
+ }
313
+ return resolved;
314
+ }
315
+
316
+ // Helper function to get the actual project path from the encoded project name
317
+ async function getActualProjectPath(projectName) {
318
+ let projectPath;
319
+ try {
320
+ projectPath = await extractProjectDirectory(projectName);
321
+ } catch (error) {
322
+ console.error(`Error extracting project directory for ${projectName}:`, error);
323
+ throw new Error(`Unable to resolve project path for "${projectName}"`);
324
+ }
325
+ return validateProjectPath(projectPath);
326
+ }
327
+
328
+ // Helper function to strip git diff headers
329
+ function stripDiffHeaders(diff) {
330
+ if (!diff) return '';
331
+
332
+ const lines = diff.split('\n');
333
+ const filteredLines = [];
334
+ let startIncluding = false;
335
+
336
+ for (const line of lines) {
337
+ // Skip all header lines including diff --git, index, file mode, and --- / +++ file paths
338
+ if (line.startsWith('diff --git') ||
339
+ line.startsWith('index ') ||
340
+ line.startsWith('new file mode') ||
341
+ line.startsWith('deleted file mode') ||
342
+ line.startsWith('---') ||
343
+ line.startsWith('+++')) {
344
+ continue;
345
+ }
346
+
347
+ // Start including lines from @@ hunk headers onwards
348
+ if (line.startsWith('@@') || startIncluding) {
349
+ startIncluding = true;
350
+ filteredLines.push(line);
351
+ }
352
+ }
353
+
354
+ return filteredLines.join('\n');
355
+ }
356
+
357
+ // Helper function to validate git repository
358
+ async function validateGitRepository(projectPath) {
359
+ try {
360
+ // Check if directory exists
361
+ await fs.access(projectPath);
362
+ } catch {
363
+ throw new Error(`Project path not found: ${projectPath}`);
364
+ }
365
+
366
+ try {
367
+ // Allow any directory that is inside a work tree (repo root or nested folder).
368
+ const { stdout: insideWorkTreeOutput } = await spawnAsync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: projectPath });
369
+ const isInsideWorkTree = insideWorkTreeOutput.trim() === 'true';
370
+ if (!isInsideWorkTree) {
371
+ throw new Error('Not inside a git work tree');
372
+ }
373
+
374
+ // Ensure git can resolve the repository root for this directory.
375
+ await spawnAsync('git', ['rev-parse', '--show-toplevel'], { cwd: projectPath });
376
+ } catch {
377
+ 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.');
378
+ }
379
+ }
380
+
381
+ function getGitErrorDetails(error) {
382
+ return `${error?.message || ''} ${error?.stderr || ''} ${error?.stdout || ''}`;
383
+ }
384
+
385
+ function isMissingHeadRevisionError(error) {
386
+ const errorDetails = getGitErrorDetails(error).toLowerCase();
387
+ return errorDetails.includes('unknown revision')
388
+ || errorDetails.includes('ambiguous argument')
389
+ || errorDetails.includes('needed a single revision')
390
+ || errorDetails.includes('bad revision');
391
+ }
392
+
393
+ async function getCurrentBranchName(projectPath) {
394
+ try {
395
+ // symbolic-ref works even when the repository has no commits.
396
+ const { stdout } = await spawnAsync('git', ['symbolic-ref', '--short', 'HEAD'], { cwd: projectPath });
397
+ const branchName = stdout.trim();
398
+ if (branchName) {
399
+ return branchName;
400
+ }
401
+ } catch (error) {
402
+ // Fall back to rev-parse for detached HEAD and older git edge cases.
403
+ }
404
+
405
+ const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
406
+ return stdout.trim();
407
+ }
408
+
409
+ async function repositoryHasCommits(projectPath) {
410
+ try {
411
+ await spawnAsync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: projectPath });
412
+ return true;
413
+ } catch (error) {
414
+ if (isMissingHeadRevisionError(error)) {
415
+ return false;
416
+ }
417
+ throw error;
418
+ }
419
+ }
420
+
421
+ async function getRepositoryRootPath(projectPath) {
422
+ const { stdout } = await spawnAsync('git', ['rev-parse', '--show-toplevel'], { cwd: projectPath });
423
+ return stdout.trim();
424
+ }
425
+
426
+ function normalizeRepositoryRelativeFilePath(filePath) {
427
+ return String(filePath)
428
+ .replace(/\\/g, '/')
429
+ .replace(/^\.\/+/, '')
430
+ .replace(/^\/+/, '')
431
+ .trim();
432
+ }
433
+
434
+ function normalizeRequestedRepositoryFilePath(repositoryRootPath, filePath) {
435
+ const rawFilePath = String(filePath || '').replace(/\\/g, '/').trim();
436
+ if (!rawFilePath) {
437
+ return '';
438
+ }
439
+
440
+ if (path.isAbsolute(rawFilePath)) {
441
+ const resolvedFilePath = path.resolve(rawFilePath);
442
+ const resolvedRepositoryRoot = path.resolve(repositoryRootPath);
443
+ const relativePath = path.relative(resolvedRepositoryRoot, resolvedFilePath);
444
+
445
+ if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
446
+ throw new Error('Invalid file path: outside repository');
447
+ }
448
+
449
+ return normalizeRepositoryRelativeFilePath(relativePath);
450
+ }
451
+
452
+ return normalizeRepositoryRelativeFilePath(rawFilePath);
453
+ }
454
+
455
+ function parseStatusFilePaths(statusOutput) {
456
+ return statusOutput
457
+ .split('\n')
458
+ .map((line) => line.trimEnd())
459
+ .filter((line) => line.trim())
460
+ .map((line) => {
461
+ const statusPath = line.substring(3);
462
+ const renamedFilePath = statusPath.split(' -> ')[1];
463
+ return normalizeRepositoryRelativeFilePath(renamedFilePath || statusPath);
464
+ })
465
+ .filter(Boolean);
466
+ }
467
+
468
+ function buildFilePathCandidates(projectPath, repositoryRootPath, filePath) {
469
+ const normalizedFilePath = normalizeRequestedRepositoryFilePath(repositoryRootPath, filePath);
470
+ const projectRelativePath = normalizeRepositoryRelativeFilePath(path.relative(repositoryRootPath, projectPath));
471
+ const candidates = [normalizedFilePath];
472
+
473
+ if (
474
+ projectRelativePath
475
+ && projectRelativePath !== '.'
476
+ && !normalizedFilePath.startsWith(`${projectRelativePath}/`)
477
+ ) {
478
+ candidates.push(`${projectRelativePath}/${normalizedFilePath}`);
479
+ }
480
+
481
+ return Array.from(new Set(candidates.filter(Boolean)));
482
+ }
483
+
484
+ async function resolveRepositoryFilePath(projectPath, filePath) {
485
+ validateFilePath(filePath);
486
+
487
+ const repositoryRootPath = await getRepositoryRootPath(projectPath);
488
+ const candidateFilePaths = buildFilePathCandidates(projectPath, repositoryRootPath, filePath);
489
+
490
+ for (const candidateFilePath of candidateFilePaths) {
491
+ const { stdout } = await spawnAsync('git', ['status', '--porcelain', '--', candidateFilePath], { cwd: repositoryRootPath });
492
+ if (stdout.trim()) {
493
+ return {
494
+ repositoryRootPath,
495
+ repositoryRelativeFilePath: candidateFilePath,
496
+ };
497
+ }
498
+ }
499
+
500
+ // If the caller sent a bare filename (e.g. "hello.ts"), recover it from changed files.
501
+ const normalizedFilePath = normalizeRepositoryRelativeFilePath(filePath);
502
+ if (!normalizedFilePath.includes('/')) {
503
+ const { stdout: repositoryStatusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: repositoryRootPath });
504
+ const changedFilePaths = parseStatusFilePaths(repositoryStatusOutput);
505
+ const suffixMatches = changedFilePaths.filter(
506
+ (changedFilePath) => changedFilePath === normalizedFilePath || changedFilePath.endsWith(`/${normalizedFilePath}`),
507
+ );
508
+
509
+ if (suffixMatches.length === 1) {
510
+ return {
511
+ repositoryRootPath,
512
+ repositoryRelativeFilePath: suffixMatches[0],
513
+ };
514
+ }
515
+ }
516
+
517
+ return {
518
+ repositoryRootPath,
519
+ repositoryRelativeFilePath: candidateFilePaths[0],
520
+ };
521
+ }
522
+
523
+ // Get git status for a project
524
+ router.get('/status', async (req, res) => {
525
+ const { project } = req.query;
526
+ const requestedTrackingMode = String(req.query.mode || req.query.trackingMode || '').toLowerCase();
527
+ const gitOnly = requestedTrackingMode === 'git';
528
+
529
+ if (!project) {
530
+ return res.status(400).json({ error: 'Project name is required' });
531
+ }
532
+
533
+ let projectPath;
534
+ try {
535
+ projectPath = await getActualProjectPath(project);
536
+
537
+ // Validate git repository
538
+ await validateGitRepository(projectPath);
539
+
540
+ const branch = await getCurrentBranchName(projectPath);
541
+ const hasCommits = await repositoryHasCommits(projectPath);
542
+
543
+ // Get git status
544
+ const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: projectPath });
545
+
546
+ const modified = [];
547
+ const added = [];
548
+ const deleted = [];
549
+ const untracked = [];
550
+
551
+ statusOutput.split('\n').forEach(line => {
552
+ if (!line.trim()) return;
553
+
554
+ const status = line.substring(0, 2);
555
+ const file = line.substring(3);
556
+
557
+ if (status === 'M ' || status === ' M' || status === 'MM') {
558
+ modified.push(file);
559
+ } else if (status === 'A ' || status === 'AM') {
560
+ added.push(file);
561
+ } else if (status === 'D ' || status === ' D') {
562
+ deleted.push(file);
563
+ } else if (status === '??') {
564
+ untracked.push(file);
565
+ }
566
+ });
567
+
568
+ res.json({
569
+ isGitRepository: true,
570
+ trackingMode: 'git',
571
+ branch,
572
+ hasCommits,
573
+ modified,
574
+ added,
575
+ deleted,
576
+ untracked
577
+ });
578
+ } catch (error) {
579
+ if (projectPath && !gitOnly && isNotGitRepositoryMessage(error.message)) {
580
+ try {
581
+ res.json(await buildFilesystemStatus(projectPath));
582
+ return;
583
+ } catch (fallbackError) {
584
+ console.error('Filesystem status fallback error:', fallbackError);
585
+ }
586
+ }
587
+
588
+ console.error('Git status error:', error);
589
+ res.json({
590
+ isGitRepository: false,
591
+ trackingMode: gitOnly ? 'git' : undefined,
592
+ error: error.message.includes('not a git repository') || error.message.includes('Project directory is not a git repository')
593
+ ? error.message
594
+ : 'Git operation failed',
595
+ details: error.message.includes('not a git repository') || error.message.includes('Project directory is not a git repository')
596
+ ? error.message
597
+ : `Failed to get git status: ${error.message}`
598
+ });
599
+ }
600
+ });
601
+
602
+ // Get diff for a specific file
603
+ router.get('/diff', async (req, res) => {
604
+ const { project, file } = req.query;
605
+
606
+ if (!project || !file) {
607
+ return res.status(400).json({ error: 'Project name and file path are required' });
608
+ }
609
+
610
+ try {
611
+ const projectPath = await getActualProjectPath(project);
612
+
613
+ // Validate git repository
614
+ await validateGitRepository(projectPath);
615
+
616
+ const {
617
+ repositoryRootPath,
618
+ repositoryRelativeFilePath,
619
+ } = await resolveRepositoryFilePath(projectPath, file);
620
+
621
+ // Check if file is untracked or deleted
622
+ const { stdout: statusOutput } = await spawnAsync(
623
+ 'git',
624
+ ['status', '--porcelain', '--', repositoryRelativeFilePath],
625
+ { cwd: repositoryRootPath },
626
+ );
627
+ const isUntracked = statusOutput.startsWith('??');
628
+ const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
629
+
630
+ let diff;
631
+ if (isUntracked) {
632
+ // For untracked files, show the entire file content as additions
633
+ const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
634
+ const stats = await fs.stat(filePath);
635
+
636
+ if (stats.isDirectory()) {
637
+ // For directories, show a simple message
638
+ diff = `Directory: ${repositoryRelativeFilePath}\n(Cannot show diff for directories)`;
639
+ } else {
640
+ const fileContent = await fs.readFile(filePath, 'utf-8');
641
+ const lines = fileContent.split('\n');
642
+ diff = `--- /dev/null\n+++ b/${repositoryRelativeFilePath}\n@@ -0,0 +1,${lines.length} @@\n` +
643
+ lines.map(line => `+${line}`).join('\n');
644
+ }
645
+ } else if (isDeleted) {
646
+ // For deleted files, show the entire file content from HEAD as deletions
647
+ const { stdout: fileContent } = await spawnAsync(
648
+ 'git',
649
+ ['show', `HEAD:${repositoryRelativeFilePath}`],
650
+ { cwd: repositoryRootPath },
651
+ );
652
+ const lines = fileContent.split('\n');
653
+ diff = `--- a/${repositoryRelativeFilePath}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n` +
654
+ lines.map(line => `-${line}`).join('\n');
655
+ } else {
656
+ // Get diff for tracked files
657
+ // First check for unstaged changes (working tree vs index)
658
+ const { stdout: unstagedDiff } = await spawnAsync(
659
+ 'git',
660
+ ['diff', '--', repositoryRelativeFilePath],
661
+ { cwd: repositoryRootPath },
662
+ );
663
+
664
+ if (unstagedDiff) {
665
+ // Show unstaged changes if they exist
666
+ diff = stripDiffHeaders(unstagedDiff);
667
+ } else {
668
+ // If no unstaged changes, check for staged changes (index vs HEAD)
669
+ const { stdout: stagedDiff } = await spawnAsync(
670
+ 'git',
671
+ ['diff', '--cached', '--', repositoryRelativeFilePath],
672
+ { cwd: repositoryRootPath },
673
+ );
674
+ diff = stripDiffHeaders(stagedDiff) || '';
675
+ }
676
+ }
677
+
678
+ res.json({ diff });
679
+ } catch (error) {
680
+ if (isNotGitRepositoryMessage(getGitErrorDetails(error))) {
681
+ return res.json({
682
+ diff: '',
683
+ isGitRepository: false,
684
+ trackingMode: 'filesystem',
685
+ });
686
+ }
687
+
688
+ console.error('Git diff error:', error);
689
+ res.json({ error: error.message });
690
+ }
691
+ });
692
+
693
+ // Get file content with diff information for CodeEditor
694
+ router.get('/file-with-diff', async (req, res) => {
695
+ const { project, file } = req.query;
696
+
697
+ if (!project || !file) {
698
+ return res.status(400).json({ error: 'Project name and file path are required' });
699
+ }
700
+
701
+ try {
702
+ const projectPath = await getActualProjectPath(project);
703
+
704
+ // Validate git repository
705
+ await validateGitRepository(projectPath);
706
+
707
+ const {
708
+ repositoryRootPath,
709
+ repositoryRelativeFilePath,
710
+ } = await resolveRepositoryFilePath(projectPath, file);
711
+
712
+ const readFileFromIndex = async () => {
713
+ const { stdout } = await spawnAsync(
714
+ 'git',
715
+ ['show', `:${repositoryRelativeFilePath}`],
716
+ { cwd: repositoryRootPath },
717
+ );
718
+ return stdout;
719
+ };
720
+ const readFileFromHead = async () => {
721
+ const { stdout } = await spawnAsync(
722
+ 'git',
723
+ ['show', `HEAD:${repositoryRelativeFilePath}`],
724
+ { cwd: repositoryRootPath },
725
+ );
726
+ return stdout;
727
+ };
728
+
729
+ // Check file status
730
+ const { stdout: statusOutput } = await spawnAsync(
731
+ 'git',
732
+ ['status', '--porcelain', '--', repositoryRelativeFilePath],
733
+ { cwd: repositoryRootPath },
734
+ );
735
+ const statusLine = statusOutput.split('\n').find((line) => line.trim()) || '';
736
+ const indexStatus = statusLine[0] || ' ';
737
+ const worktreeStatus = statusLine[1] || ' ';
738
+ const isUntracked = statusLine.startsWith('??');
739
+ const hasUnstagedChange = !isUntracked && worktreeStatus !== ' ';
740
+ const hasStagedChange = !isUntracked && indexStatus !== ' ';
741
+ const isDeleted = worktreeStatus === 'D' || (!hasUnstagedChange && indexStatus === 'D');
742
+
743
+ let currentContent = '';
744
+ let oldContent = '';
745
+
746
+ if (hasUnstagedChange) {
747
+ try {
748
+ oldContent = await readFileFromIndex();
749
+ } catch {
750
+ oldContent = '';
751
+ }
752
+
753
+ if (worktreeStatus === 'D') {
754
+ currentContent = '';
755
+ } else {
756
+ const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
757
+ const stats = await fs.stat(filePath);
758
+ if (stats.isDirectory()) {
759
+ return res.status(400).json({ error: 'Cannot show diff for directories' });
760
+ }
761
+ currentContent = await fs.readFile(filePath, 'utf-8');
762
+ }
763
+ } else if (hasStagedChange) {
764
+ try {
765
+ oldContent = await readFileFromHead();
766
+ } catch {
767
+ // A staged add has no HEAD copy; showing the whole file as added is correct.
768
+ oldContent = '';
769
+ }
770
+
771
+ if (indexStatus === 'D') {
772
+ currentContent = '';
773
+ } else {
774
+ try {
775
+ currentContent = await readFileFromIndex();
776
+ } catch {
777
+ currentContent = '';
778
+ }
779
+ }
780
+ } else if (isUntracked) {
781
+ const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
782
+ const stats = await fs.stat(filePath);
783
+
784
+ if (stats.isDirectory()) {
785
+ return res.status(400).json({ error: 'Cannot show diff for directories' });
786
+ }
787
+
788
+ currentContent = await fs.readFile(filePath, 'utf-8');
789
+ } else {
790
+ const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
791
+ const stats = await fs.stat(filePath);
792
+
793
+ if (stats.isDirectory()) {
794
+ return res.status(400).json({ error: 'Cannot show diff for directories' });
795
+ }
796
+
797
+ currentContent = await fs.readFile(filePath, 'utf-8');
798
+ oldContent = currentContent;
799
+ }
800
+
801
+ res.json({
802
+ currentContent,
803
+ oldContent,
804
+ isDeleted,
805
+ isUntracked
806
+ });
807
+ } catch (error) {
808
+ if (isMissingFileError(error)) {
809
+ return res.status(404).json({
810
+ error: 'File not found',
811
+ currentContent: '',
812
+ oldContent: '',
813
+ isDeleted: true,
814
+ isUntracked: false,
815
+ });
816
+ }
817
+
818
+ if (isNotGitRepositoryMessage(getGitErrorDetails(error))) {
819
+ try {
820
+ return res.json(await readFilesystemFileWithDiff(
821
+ await getActualProjectPath(project),
822
+ file,
823
+ ));
824
+ } catch (fallbackError) {
825
+ if (isMissingFileError(fallbackError)) {
826
+ return res.status(404).json({
827
+ error: 'File not found',
828
+ currentContent: '',
829
+ oldContent: '',
830
+ isDeleted: true,
831
+ isUntracked: false,
832
+ isGitRepository: false,
833
+ trackingMode: 'filesystem',
834
+ });
835
+ }
836
+
837
+ return res.status(fallbackError.statusCode || 500).json({
838
+ error: fallbackError.message || 'Failed to read file',
839
+ isGitRepository: false,
840
+ trackingMode: 'filesystem',
841
+ });
842
+ }
843
+ }
844
+
845
+ console.error('Git file-with-diff error:', error);
846
+ res.json({ error: error.message });
847
+ }
848
+ });
849
+
850
+ // Create initial commit
851
+ router.post('/initial-commit', async (req, res) => {
852
+ const { project } = req.body;
853
+
854
+ if (!project) {
855
+ return res.status(400).json({ error: 'Project name is required' });
856
+ }
857
+
858
+ try {
859
+ const projectPath = await getActualProjectPath(project);
860
+
861
+ // Validate git repository
862
+ await validateGitRepository(projectPath);
863
+
864
+ // Check if there are already commits
865
+ try {
866
+ await spawnAsync('git', ['rev-parse', 'HEAD'], { cwd: projectPath });
867
+ return res.status(400).json({ error: 'Repository already has commits. Use regular commit instead.' });
868
+ } catch (error) {
869
+ // No HEAD - this is good, we can create initial commit
870
+ }
871
+
872
+ // Add all files
873
+ await spawnAsync('git', ['add', '.'], { cwd: projectPath });
874
+
875
+ // Create initial commit
876
+ const { stdout } = await spawnAsync('git', ['commit', '-m', 'Initial commit'], { cwd: projectPath });
877
+
878
+ res.json({ success: true, output: stdout, message: 'Initial commit created successfully' });
879
+ } catch (error) {
880
+ console.error('Git initial commit error:', error);
881
+
882
+ // Handle the case where there's nothing to commit
883
+ if (error.message.includes('nothing to commit')) {
884
+ return res.status(400).json({
885
+ error: 'Nothing to commit',
886
+ details: 'No files found in the repository. Add some files first.'
887
+ });
888
+ }
889
+
890
+ res.status(500).json({ error: "Internal server error" });
891
+ }
892
+ });
893
+
894
+ // Commit changes
895
+ router.post('/commit', async (req, res) => {
896
+ const { project, message, files } = req.body;
897
+
898
+ if (!project || !message || !files || files.length === 0) {
899
+ return res.status(400).json({ error: 'Project name, commit message, and files are required' });
900
+ }
901
+
902
+ try {
903
+ const projectPath = await getActualProjectPath(project);
904
+
905
+ // Validate git repository
906
+ await validateGitRepository(projectPath);
907
+ const repositoryRootPath = await getRepositoryRootPath(projectPath);
908
+
909
+ // Stage selected files
910
+ for (const file of files) {
911
+ const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
912
+ await spawnAsync('git', ['add', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
913
+ }
914
+
915
+ // Commit with message
916
+ const { stdout } = await spawnAsync('git', ['commit', '-m', message], { cwd: repositoryRootPath });
917
+
918
+ res.json({ success: true, output: stdout });
919
+ } catch (error) {
920
+ console.error('Git commit error:', error);
921
+ res.status(500).json({ error: "Internal server error" });
922
+ }
923
+ });
924
+
925
+ // Revert latest local commit (keeps changes staged)
926
+ router.post('/revert-local-commit', async (req, res) => {
927
+ const { project } = req.body;
928
+
929
+ if (!project) {
930
+ return res.status(400).json({ error: 'Project name is required' });
931
+ }
932
+
933
+ try {
934
+ const projectPath = await getActualProjectPath(project);
935
+ await validateGitRepository(projectPath);
936
+
937
+ try {
938
+ await spawnAsync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: projectPath });
939
+ } catch (error) {
940
+ return res.status(400).json({
941
+ error: 'No local commit to revert',
942
+ details: 'This repository has no commit yet.',
943
+ });
944
+ }
945
+
946
+ try {
947
+ // Soft reset rewinds one commit while preserving all file changes in the index.
948
+ await spawnAsync('git', ['reset', '--soft', 'HEAD~1'], { cwd: projectPath });
949
+ } catch (error) {
950
+ const errorDetails = `${error.stderr || ''} ${error.message || ''}`;
951
+ const isInitialCommit = errorDetails.includes('HEAD~1') &&
952
+ (errorDetails.includes('unknown revision') || errorDetails.includes('ambiguous argument'));
953
+
954
+ if (!isInitialCommit) {
955
+ throw error;
956
+ }
957
+
958
+ // Initial commit has no parent; deleting HEAD uncommits it and keeps files staged.
959
+ await spawnAsync('git', ['update-ref', '-d', 'HEAD'], { cwd: projectPath });
960
+ }
961
+
962
+ res.json({
963
+ success: true,
964
+ output: 'Latest local commit reverted successfully. Changes were kept staged.',
965
+ });
966
+ } catch (error) {
967
+ console.error('Git revert local commit error:', error);
968
+ res.status(500).json({ error: "Internal server error" });
969
+ }
970
+ });
971
+
972
+ // Get list of branches
973
+ router.get('/branches', async (req, res) => {
974
+ const { project } = req.query;
975
+
976
+ if (!project) {
977
+ return res.status(400).json({ error: 'Project name is required' });
978
+ }
979
+
980
+ try {
981
+ const projectPath = await getActualProjectPath(project);
982
+
983
+ // Validate git repository
984
+ await validateGitRepository(projectPath);
985
+
986
+ // Get all branches
987
+ const { stdout } = await spawnAsync('git', ['branch', '-a'], { cwd: projectPath });
988
+
989
+ const rawLines = stdout
990
+ .split('\n')
991
+ .map(b => b.trim())
992
+ .filter(b => b && !b.includes('->'));
993
+
994
+ // Local branches (may start with '* ' for current)
995
+ const localBranches = rawLines
996
+ .filter(b => !b.startsWith('remotes/'))
997
+ .map(b => (b.startsWith('* ') ? b.substring(2) : b));
998
+
999
+ // Remote branches — strip 'remotes/<remote>/' prefix
1000
+ const remoteBranches = rawLines
1001
+ .filter(b => b.startsWith('remotes/'))
1002
+ .map(b => b.replace(/^remotes\/[^/]+\//, ''))
1003
+ .filter(name => !localBranches.includes(name)); // skip if already a local branch
1004
+
1005
+ // Backward-compat flat list (local + unique remotes, deduplicated)
1006
+ const branches = [...localBranches, ...remoteBranches]
1007
+ .filter((b, i, arr) => arr.indexOf(b) === i);
1008
+
1009
+ res.json({ branches, localBranches, remoteBranches });
1010
+ } catch (error) {
1011
+ console.error('Git branches error:', error);
1012
+ res.json({ error: error.message });
1013
+ }
1014
+ });
1015
+
1016
+ // Checkout branch
1017
+ router.post('/checkout', async (req, res) => {
1018
+ const { project, branch } = req.body;
1019
+
1020
+ if (!project || !branch) {
1021
+ return res.status(400).json({ error: 'Project name and branch are required' });
1022
+ }
1023
+
1024
+ try {
1025
+ const projectPath = await getActualProjectPath(project);
1026
+
1027
+ // Checkout the branch
1028
+ validateBranchName(branch);
1029
+ const { stdout } = await spawnAsync('git', ['checkout', branch], { cwd: projectPath });
1030
+
1031
+ res.json({ success: true, output: stdout });
1032
+ } catch (error) {
1033
+ console.error('Git checkout error:', error);
1034
+ res.status(500).json({ error: "Internal server error" });
1035
+ }
1036
+ });
1037
+
1038
+ // Create new branch
1039
+ router.post('/create-branch', async (req, res) => {
1040
+ const { project, branch } = req.body;
1041
+
1042
+ if (!project || !branch) {
1043
+ return res.status(400).json({ error: 'Project name and branch name are required' });
1044
+ }
1045
+
1046
+ try {
1047
+ const projectPath = await getActualProjectPath(project);
1048
+
1049
+ // Create and checkout new branch
1050
+ validateBranchName(branch);
1051
+ const { stdout } = await spawnAsync('git', ['checkout', '-b', branch], { cwd: projectPath });
1052
+
1053
+ res.json({ success: true, output: stdout });
1054
+ } catch (error) {
1055
+ console.error('Git create branch error:', error);
1056
+ res.status(500).json({ error: "Internal server error" });
1057
+ }
1058
+ });
1059
+
1060
+ // Delete a local branch
1061
+ router.post('/delete-branch', async (req, res) => {
1062
+ const { project, branch } = req.body;
1063
+
1064
+ if (!project || !branch) {
1065
+ return res.status(400).json({ error: 'Project name and branch name are required' });
1066
+ }
1067
+
1068
+ try {
1069
+ const projectPath = await getActualProjectPath(project);
1070
+ await validateGitRepository(projectPath);
1071
+
1072
+ // Safety: cannot delete the currently checked-out branch
1073
+ const { stdout: currentBranch } = await spawnAsync('git', ['branch', '--show-current'], { cwd: projectPath });
1074
+ if (currentBranch.trim() === branch) {
1075
+ return res.status(400).json({ error: 'Cannot delete the currently checked-out branch' });
1076
+ }
1077
+
1078
+ const { stdout } = await spawnAsync('git', ['branch', '-d', branch], { cwd: projectPath });
1079
+ res.json({ success: true, output: stdout });
1080
+ } catch (error) {
1081
+ console.error('Git delete branch error:', error);
1082
+ res.status(500).json({ error: "Internal server error" });
1083
+ }
1084
+ });
1085
+
1086
+ // Get recent commits
1087
+ router.get('/commits', async (req, res) => {
1088
+ const { project, limit = 10 } = req.query;
1089
+
1090
+ if (!project) {
1091
+ return res.status(400).json({ error: 'Project name is required' });
1092
+ }
1093
+
1094
+ try {
1095
+ const projectPath = await getActualProjectPath(project);
1096
+ await validateGitRepository(projectPath);
1097
+ const parsedLimit = Number.parseInt(String(limit), 10);
1098
+ const safeLimit = Number.isFinite(parsedLimit) && parsedLimit > 0
1099
+ ? Math.min(parsedLimit, 100)
1100
+ : 10;
1101
+
1102
+ // Get commit log with stats
1103
+ const { stdout } = await spawnAsync(
1104
+ 'git',
1105
+ ['log', '--pretty=format:%H|%an|%ae|%ad|%s', '--date=iso-strict', '-n', String(safeLimit)],
1106
+ { cwd: projectPath },
1107
+ );
1108
+
1109
+ const commits = stdout
1110
+ .split('\n')
1111
+ .filter(line => line.trim())
1112
+ .map(line => {
1113
+ const [hash, author, email, date, ...messageParts] = line.split('|');
1114
+ return {
1115
+ hash,
1116
+ author,
1117
+ email,
1118
+ date,
1119
+ message: messageParts.join('|')
1120
+ };
1121
+ });
1122
+
1123
+ // Get stats for each commit
1124
+ for (const commit of commits) {
1125
+ try {
1126
+ const { stdout: stats } = await spawnAsync(
1127
+ 'git', ['show', '--stat', '--format=', commit.hash],
1128
+ { cwd: projectPath }
1129
+ );
1130
+ commit.stats = stats.trim().split('\n').pop(); // Get the summary line
1131
+ } catch (error) {
1132
+ commit.stats = '';
1133
+ }
1134
+ }
1135
+
1136
+ res.json({ commits });
1137
+ } catch (error) {
1138
+ console.error('Git commits error:', error);
1139
+ res.json({ error: error.message });
1140
+ }
1141
+ });
1142
+
1143
+ // Get diff for a specific commit
1144
+ router.get('/commit-diff', async (req, res) => {
1145
+ const { project, commit } = req.query;
1146
+
1147
+ if (!project || !commit) {
1148
+ return res.status(400).json({ error: 'Project name and commit hash are required' });
1149
+ }
1150
+
1151
+ try {
1152
+ const projectPath = await getActualProjectPath(project);
1153
+
1154
+ // Validate commit reference (defense-in-depth)
1155
+ validateCommitRef(commit);
1156
+
1157
+ // Get diff for the commit
1158
+ const { stdout } = await spawnAsync(
1159
+ 'git', ['show', commit],
1160
+ { cwd: projectPath }
1161
+ );
1162
+
1163
+ const isTruncated = stdout.length > COMMIT_DIFF_CHARACTER_LIMIT;
1164
+ const diff = isTruncated
1165
+ ? `${stdout.slice(0, COMMIT_DIFF_CHARACTER_LIMIT)}\n\n... Diff truncated to keep the UI responsive ...`
1166
+ : stdout;
1167
+
1168
+ res.json({ diff, isTruncated });
1169
+ } catch (error) {
1170
+ console.error('Git commit diff error:', error);
1171
+ res.json({ error: error.message });
1172
+ }
1173
+ });
1174
+
1175
+ // Generate commit message based on staged changes using AI
1176
+ router.post('/generate-commit-message', async (req, res) => {
1177
+ const { project, files, provider = 'claude' } = req.body;
1178
+
1179
+ if (!project || !files || files.length === 0) {
1180
+ return res.status(400).json({ error: 'Project name and files are required' });
1181
+ }
1182
+
1183
+ // Validate provider
1184
+ if (!['claude', 'cursor'].includes(provider)) {
1185
+ return res.status(400).json({ error: 'provider must be "claude" or "cursor"' });
1186
+ }
1187
+
1188
+ try {
1189
+ const projectPath = await getActualProjectPath(project);
1190
+ await validateGitRepository(projectPath);
1191
+ const repositoryRootPath = await getRepositoryRootPath(projectPath);
1192
+
1193
+ // Get diff for selected files
1194
+ let diffContext = '';
1195
+ for (const file of files) {
1196
+ try {
1197
+ const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
1198
+ const { stdout } = await spawnAsync(
1199
+ 'git', ['diff', 'HEAD', '--', repositoryRelativeFilePath],
1200
+ { cwd: repositoryRootPath }
1201
+ );
1202
+ if (stdout) {
1203
+ diffContext += `\n--- ${repositoryRelativeFilePath} ---\n${stdout}`;
1204
+ }
1205
+ } catch (error) {
1206
+ console.error(`Error getting diff for ${file}:`, error);
1207
+ }
1208
+ }
1209
+
1210
+ // If no diff found, might be untracked files
1211
+ if (!diffContext.trim()) {
1212
+ // Try to get content of untracked files
1213
+ for (const file of files) {
1214
+ try {
1215
+ const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
1216
+ const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
1217
+ const stats = await fs.stat(filePath);
1218
+
1219
+ if (!stats.isDirectory()) {
1220
+ const content = await fs.readFile(filePath, 'utf-8');
1221
+ diffContext += `\n--- ${repositoryRelativeFilePath} (new file) ---\n${content.substring(0, 1000)}\n`;
1222
+ } else {
1223
+ diffContext += `\n--- ${repositoryRelativeFilePath} (new directory) ---\n`;
1224
+ }
1225
+ } catch (error) {
1226
+ console.error(`Error reading file ${file}:`, error);
1227
+ }
1228
+ }
1229
+ }
1230
+
1231
+ // Generate commit message using AI
1232
+ const message = await generateCommitMessageWithAI(files, diffContext, provider, projectPath);
1233
+
1234
+ res.json({ message });
1235
+ } catch (error) {
1236
+ console.error('Generate commit message error:', error);
1237
+ res.status(500).json({ error: "Internal server error" });
1238
+ }
1239
+ });
1240
+
1241
+ /**
1242
+ * Generates a commit message using AI (Claude SDK or Cursor CLI)
1243
+ * @param {Array<string>} files - List of changed files
1244
+ * @param {string} diffContext - Git diff content
1245
+ * @param {string} provider - 'claude' or 'cursor'
1246
+ * @param {string} projectPath - Project directory path
1247
+ * @returns {Promise<string>} Generated commit message
1248
+ */
1249
+ async function generateCommitMessageWithAI(files, diffContext, provider, projectPath) {
1250
+ // Create the prompt
1251
+ const prompt = `Generate a conventional commit message for these changes.
1252
+
1253
+ REQUIREMENTS:
1254
+ - Format: type(scope): subject
1255
+ - Include body explaining what changed and why
1256
+ - Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore
1257
+ - Subject under 50 chars, body wrapped at 72 chars
1258
+ - Focus on user-facing changes, not implementation details
1259
+ - Consider what's being added AND removed
1260
+ - Return ONLY the commit message (no markdown, explanations, or code blocks)
1261
+
1262
+ FILES CHANGED:
1263
+ ${files.map(f => `- ${f}`).join('\n')}
1264
+
1265
+ DIFFS:
1266
+ ${diffContext.substring(0, 4000)}
1267
+
1268
+ Generate the commit message:`;
1269
+
1270
+ try {
1271
+ // Create a simple writer that collects the response
1272
+ let responseText = '';
1273
+ const writer = {
1274
+ send: (data) => {
1275
+ try {
1276
+ const parsed = typeof data === 'string' ? JSON.parse(data) : data;
1277
+ console.log('🔍 Writer received message type:', parsed.type);
1278
+
1279
+ // Handle different message formats from Claude SDK and Cursor CLI
1280
+ // Claude SDK sends: {type: 'claude-response', data: {message: {content: [...]}}}
1281
+ if (parsed.type === 'claude-response' && parsed.data) {
1282
+ const message = parsed.data.message || parsed.data;
1283
+ console.log('📦 Claude response message:', JSON.stringify(message, null, 2).substring(0, 500));
1284
+ if (message.content && Array.isArray(message.content)) {
1285
+ // Extract text from content array
1286
+ for (const item of message.content) {
1287
+ if (item.type === 'text' && item.text) {
1288
+ console.log('✅ Extracted text chunk:', item.text.substring(0, 100));
1289
+ responseText += item.text;
1290
+ }
1291
+ }
1292
+ }
1293
+ }
1294
+ // Cursor CLI sends: {type: 'cursor-output', output: '...'}
1295
+ else if (parsed.type === 'cursor-output' && parsed.output) {
1296
+ console.log('✅ Cursor output:', parsed.output.substring(0, 100));
1297
+ responseText += parsed.output;
1298
+ }
1299
+ // Also handle direct text messages
1300
+ else if (parsed.type === 'text' && parsed.text) {
1301
+ console.log('✅ Direct text:', parsed.text.substring(0, 100));
1302
+ responseText += parsed.text;
1303
+ }
1304
+ } catch (e) {
1305
+ // Ignore parse errors
1306
+ console.error('Error parsing writer data:', e);
1307
+ }
1308
+ },
1309
+ setSessionId: () => {}, // No-op for this use case
1310
+ };
1311
+
1312
+ console.log('🚀 Calling AI agent with provider:', provider);
1313
+ console.log('📝 Prompt length:', prompt.length);
1314
+
1315
+ // Call the appropriate agent
1316
+ if (provider === 'claude') {
1317
+ await queryClaudeSDK(prompt, {
1318
+ cwd: projectPath,
1319
+ permissionMode: 'bypassPermissions',
1320
+ model: 'sonnet'
1321
+ }, writer);
1322
+ } else if (provider === 'cursor') {
1323
+ await spawnCursor(prompt, {
1324
+ cwd: projectPath,
1325
+ skipPermissions: true
1326
+ }, writer);
1327
+ }
1328
+
1329
+ console.log('📊 Total response text collected:', responseText.length, 'characters');
1330
+ console.log('📄 Response preview:', responseText.substring(0, 200));
1331
+
1332
+ // Clean up the response
1333
+ const cleanedMessage = cleanCommitMessage(responseText);
1334
+ console.log('🧹 Cleaned message:', cleanedMessage.substring(0, 200));
1335
+
1336
+ return cleanedMessage || 'chore: update files';
1337
+ } catch (error) {
1338
+ console.error('Error generating commit message with AI:', error);
1339
+ // Fallback to simple message
1340
+ return `chore: update ${files.length} file${files.length !== 1 ? 's' : ''}`;
1341
+ }
1342
+ }
1343
+
1344
+ /**
1345
+ * Cleans the AI-generated commit message by removing markdown, code blocks, and extra formatting
1346
+ * @param {string} text - Raw AI response
1347
+ * @returns {string} Clean commit message
1348
+ */
1349
+ function cleanCommitMessage(text) {
1350
+ if (!text || !text.trim()) {
1351
+ return '';
1352
+ }
1353
+
1354
+ let cleaned = text.trim();
1355
+
1356
+ // Remove markdown code blocks
1357
+ cleaned = cleaned.replace(/```[a-z]*\n/g, '');
1358
+ cleaned = cleaned.replace(/```/g, '');
1359
+
1360
+ // Remove markdown headers
1361
+ cleaned = cleaned.replace(/^#+\s*/gm, '');
1362
+
1363
+ // Remove leading/trailing quotes
1364
+ cleaned = cleaned.replace(/^["']|["']$/g, '');
1365
+
1366
+ // If there are multiple lines, take everything (subject + body)
1367
+ // Just clean up extra blank lines
1368
+ cleaned = cleaned.replace(/\n{3,}/g, '\n\n');
1369
+
1370
+ // Remove any explanatory text before the actual commit message
1371
+ // Look for conventional commit pattern and start from there
1372
+ const conventionalCommitMatch = cleaned.match(/(feat|fix|docs|style|refactor|perf|test|build|ci|chore)(\(.+?\))?:.+/s);
1373
+ if (conventionalCommitMatch) {
1374
+ cleaned = cleaned.substring(cleaned.indexOf(conventionalCommitMatch[0]));
1375
+ }
1376
+
1377
+ return cleaned.trim();
1378
+ }
1379
+
1380
+ // Get remote status (ahead/behind commits with smart remote detection)
1381
+ router.get('/remote-status', async (req, res) => {
1382
+ const { project } = req.query;
1383
+
1384
+ if (!project) {
1385
+ return res.status(400).json({ error: 'Project name is required' });
1386
+ }
1387
+
1388
+ try {
1389
+ const projectPath = await getActualProjectPath(project);
1390
+ await validateGitRepository(projectPath);
1391
+
1392
+ const branch = await getCurrentBranchName(projectPath);
1393
+ const hasCommits = await repositoryHasCommits(projectPath);
1394
+
1395
+ const { stdout: remoteOutput } = await spawnAsync('git', ['remote'], { cwd: projectPath });
1396
+ const remotes = remoteOutput.trim().split('\n').filter(r => r.trim());
1397
+ const hasRemote = remotes.length > 0;
1398
+ const fallbackRemoteName = hasRemote
1399
+ ? (remotes.includes('origin') ? 'origin' : remotes[0])
1400
+ : null;
1401
+
1402
+ // Repositories initialized with `git init` can have a branch but no commits.
1403
+ // Return a non-error state so the UI can show the initial-commit workflow.
1404
+ if (!hasCommits) {
1405
+ return res.json({
1406
+ hasRemote,
1407
+ hasUpstream: false,
1408
+ branch,
1409
+ remoteName: fallbackRemoteName,
1410
+ ahead: 0,
1411
+ behind: 0,
1412
+ isUpToDate: false,
1413
+ message: 'Repository has no commits yet'
1414
+ });
1415
+ }
1416
+
1417
+ // Check if there's a remote tracking branch (smart detection)
1418
+ let trackingBranch;
1419
+ let remoteName;
1420
+ try {
1421
+ const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
1422
+ trackingBranch = stdout.trim();
1423
+ remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin")
1424
+ } catch (error) {
1425
+ return res.json({
1426
+ hasRemote,
1427
+ hasUpstream: false,
1428
+ branch,
1429
+ remoteName: fallbackRemoteName,
1430
+ message: 'No remote tracking branch configured'
1431
+ });
1432
+ }
1433
+
1434
+ // Get ahead/behind counts
1435
+ const { stdout: countOutput } = await spawnAsync(
1436
+ 'git', ['rev-list', '--count', '--left-right', `${trackingBranch}...HEAD`],
1437
+ { cwd: projectPath }
1438
+ );
1439
+
1440
+ const [behind, ahead] = countOutput.trim().split('\t').map(Number);
1441
+
1442
+ res.json({
1443
+ hasRemote: true,
1444
+ hasUpstream: true,
1445
+ branch,
1446
+ remoteBranch: trackingBranch,
1447
+ remoteName,
1448
+ ahead: ahead || 0,
1449
+ behind: behind || 0,
1450
+ isUpToDate: ahead === 0 && behind === 0
1451
+ });
1452
+ } catch (error) {
1453
+ console.error('Git remote status error:', error);
1454
+ res.json({ error: error.message });
1455
+ }
1456
+ });
1457
+
1458
+ // Fetch from remote (using smart remote detection)
1459
+ router.post('/fetch', async (req, res) => {
1460
+ const { project } = req.body;
1461
+
1462
+ if (!project) {
1463
+ return res.status(400).json({ error: 'Project name is required' });
1464
+ }
1465
+
1466
+ try {
1467
+ const projectPath = await getActualProjectPath(project);
1468
+ await validateGitRepository(projectPath);
1469
+
1470
+ // Get current branch and its upstream remote
1471
+ const branch = await getCurrentBranchName(projectPath);
1472
+
1473
+ let remoteName = 'origin'; // fallback
1474
+ try {
1475
+ const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
1476
+ remoteName = stdout.trim().split('/')[0]; // Extract remote name
1477
+ } catch (error) {
1478
+ // No upstream, try to fetch from origin anyway
1479
+ console.log('No upstream configured, using origin as fallback');
1480
+ }
1481
+
1482
+ validateRemoteName(remoteName);
1483
+ const { stdout } = await spawnAsync('git', ['fetch', remoteName], { cwd: projectPath });
1484
+
1485
+ res.json({ success: true, output: stdout || 'Fetch completed successfully', remoteName });
1486
+ } catch (error) {
1487
+ console.error('Git fetch error:', error);
1488
+ res.status(500).json({
1489
+ error: 'Fetch failed',
1490
+ details: error.message.includes('Could not resolve hostname')
1491
+ ? 'Unable to connect to remote repository. Check your internet connection.'
1492
+ : error.message.includes('fatal: \'origin\' does not appear to be a git repository')
1493
+ ? 'No remote repository configured. Add a remote with: git remote add origin <url>'
1494
+ : error.message
1495
+ });
1496
+ }
1497
+ });
1498
+
1499
+ // Pull from remote (fetch + merge using smart remote detection)
1500
+ router.post('/pull', async (req, res) => {
1501
+ const { project } = req.body;
1502
+
1503
+ if (!project) {
1504
+ return res.status(400).json({ error: 'Project name is required' });
1505
+ }
1506
+
1507
+ try {
1508
+ const projectPath = await getActualProjectPath(project);
1509
+ await validateGitRepository(projectPath);
1510
+
1511
+ // Get current branch and its upstream remote
1512
+ const branch = await getCurrentBranchName(projectPath);
1513
+
1514
+ let remoteName = 'origin'; // fallback
1515
+ let remoteBranch = branch; // fallback
1516
+ try {
1517
+ const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
1518
+ const tracking = stdout.trim();
1519
+ remoteName = tracking.split('/')[0]; // Extract remote name
1520
+ remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name
1521
+ } catch (error) {
1522
+ // No upstream, use fallback
1523
+ console.log('No upstream configured, using origin/branch as fallback');
1524
+ }
1525
+
1526
+ validateRemoteName(remoteName);
1527
+ validateBranchName(remoteBranch);
1528
+ const { stdout } = await spawnAsync('git', ['pull', remoteName, remoteBranch], { cwd: projectPath });
1529
+
1530
+ res.json({
1531
+ success: true,
1532
+ output: stdout || 'Pull completed successfully',
1533
+ remoteName,
1534
+ remoteBranch
1535
+ });
1536
+ } catch (error) {
1537
+ console.error('Git pull error:', error);
1538
+
1539
+ // Enhanced error handling for common pull scenarios
1540
+ let errorMessage = 'Pull failed';
1541
+ let details = error.message;
1542
+
1543
+ if (error.message.includes('CONFLICT')) {
1544
+ errorMessage = 'Merge conflicts detected';
1545
+ details = 'Pull created merge conflicts. Please resolve conflicts manually in the editor, then commit the changes.';
1546
+ } else if (error.message.includes('Please commit your changes or stash them')) {
1547
+ errorMessage = 'Uncommitted changes detected';
1548
+ details = 'Please commit or stash your local changes before pulling.';
1549
+ } else if (error.message.includes('Could not resolve hostname')) {
1550
+ errorMessage = 'Network error';
1551
+ details = 'Unable to connect to remote repository. Check your internet connection.';
1552
+ } else if (error.message.includes('fatal: \'origin\' does not appear to be a git repository')) {
1553
+ errorMessage = 'Remote not configured';
1554
+ details = 'No remote repository configured. Add a remote with: git remote add origin <url>';
1555
+ } else if (error.message.includes('diverged')) {
1556
+ errorMessage = 'Branches have diverged';
1557
+ details = 'Your local branch and remote branch have diverged. Consider fetching first to review changes.';
1558
+ }
1559
+
1560
+ res.status(500).json({
1561
+ error: errorMessage,
1562
+ details: details
1563
+ });
1564
+ }
1565
+ });
1566
+
1567
+ // Push commits to remote repository
1568
+ router.post('/push', async (req, res) => {
1569
+ const { project } = req.body;
1570
+
1571
+ if (!project) {
1572
+ return res.status(400).json({ error: 'Project name is required' });
1573
+ }
1574
+
1575
+ try {
1576
+ const projectPath = await getActualProjectPath(project);
1577
+ await validateGitRepository(projectPath);
1578
+
1579
+ // Get current branch and its upstream remote
1580
+ const branch = await getCurrentBranchName(projectPath);
1581
+
1582
+ let remoteName = 'origin'; // fallback
1583
+ let remoteBranch = branch; // fallback
1584
+ try {
1585
+ const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
1586
+ const tracking = stdout.trim();
1587
+ remoteName = tracking.split('/')[0]; // Extract remote name
1588
+ remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name
1589
+ } catch (error) {
1590
+ // No upstream, use fallback
1591
+ console.log('No upstream configured, using origin/branch as fallback');
1592
+ }
1593
+
1594
+ validateRemoteName(remoteName);
1595
+ validateBranchName(remoteBranch);
1596
+ const { stdout } = await spawnAsync('git', ['push', remoteName, remoteBranch], { cwd: projectPath });
1597
+
1598
+ res.json({
1599
+ success: true,
1600
+ output: stdout || 'Push completed successfully',
1601
+ remoteName,
1602
+ remoteBranch
1603
+ });
1604
+ } catch (error) {
1605
+ console.error('Git push error:', error);
1606
+
1607
+ // Enhanced error handling for common push scenarios
1608
+ let errorMessage = 'Push failed';
1609
+ let details = error.message;
1610
+
1611
+ if (error.message.includes('rejected')) {
1612
+ errorMessage = 'Push rejected';
1613
+ details = 'The remote has newer commits. Pull first to merge changes before pushing.';
1614
+ } else if (error.message.includes('non-fast-forward')) {
1615
+ errorMessage = 'Non-fast-forward push';
1616
+ details = 'Your branch is behind the remote. Pull the latest changes first.';
1617
+ } else if (error.message.includes('Could not resolve hostname')) {
1618
+ errorMessage = 'Network error';
1619
+ details = 'Unable to connect to remote repository. Check your internet connection.';
1620
+ } else if (error.message.includes('fatal: \'origin\' does not appear to be a git repository')) {
1621
+ errorMessage = 'Remote not configured';
1622
+ details = 'No remote repository configured. Add a remote with: git remote add origin <url>';
1623
+ } else if (error.message.includes('Permission denied')) {
1624
+ errorMessage = 'Authentication failed';
1625
+ details = 'Permission denied. Check your credentials or SSH keys.';
1626
+ } else if (error.message.includes('no upstream branch')) {
1627
+ errorMessage = 'No upstream branch';
1628
+ details = 'No upstream branch configured. Use: git push --set-upstream origin <branch>';
1629
+ }
1630
+
1631
+ res.status(500).json({
1632
+ error: errorMessage,
1633
+ details: details
1634
+ });
1635
+ }
1636
+ });
1637
+
1638
+ // Publish branch to remote (set upstream and push)
1639
+ router.post('/publish', async (req, res) => {
1640
+ const { project, branch } = req.body;
1641
+
1642
+ if (!project || !branch) {
1643
+ return res.status(400).json({ error: 'Project name and branch are required' });
1644
+ }
1645
+
1646
+ try {
1647
+ const projectPath = await getActualProjectPath(project);
1648
+ await validateGitRepository(projectPath);
1649
+
1650
+ // Validate branch name
1651
+ validateBranchName(branch);
1652
+
1653
+ // Get current branch to verify it matches the requested branch
1654
+ const currentBranchName = await getCurrentBranchName(projectPath);
1655
+
1656
+ if (currentBranchName !== branch) {
1657
+ return res.status(400).json({
1658
+ error: `Branch mismatch. Current branch is ${currentBranchName}, but trying to publish ${branch}`
1659
+ });
1660
+ }
1661
+
1662
+ // Check if remote exists
1663
+ let remoteName = 'origin';
1664
+ try {
1665
+ const { stdout } = await spawnAsync('git', ['remote'], { cwd: projectPath });
1666
+ const remotes = stdout.trim().split('\n').filter(r => r.trim());
1667
+ if (remotes.length === 0) {
1668
+ return res.status(400).json({
1669
+ error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
1670
+ });
1671
+ }
1672
+ remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
1673
+ } catch (error) {
1674
+ return res.status(400).json({
1675
+ error: 'No remote repository configured. Add a remote with: git remote add origin <url>'
1676
+ });
1677
+ }
1678
+
1679
+ // Publish the branch (set upstream and push)
1680
+ validateRemoteName(remoteName);
1681
+ const { stdout } = await spawnAsync('git', ['push', '--set-upstream', remoteName, branch], { cwd: projectPath });
1682
+
1683
+ res.json({
1684
+ success: true,
1685
+ output: stdout || 'Branch published successfully',
1686
+ remoteName,
1687
+ branch
1688
+ });
1689
+ } catch (error) {
1690
+ console.error('Git publish error:', error);
1691
+
1692
+ // Enhanced error handling for common publish scenarios
1693
+ let errorMessage = 'Publish failed';
1694
+ let details = error.message;
1695
+
1696
+ if (error.message.includes('rejected')) {
1697
+ errorMessage = 'Publish rejected';
1698
+ details = 'The remote branch already exists and has different commits. Use push instead.';
1699
+ } else if (error.message.includes('Could not resolve hostname')) {
1700
+ errorMessage = 'Network error';
1701
+ details = 'Unable to connect to remote repository. Check your internet connection.';
1702
+ } else if (error.message.includes('Permission denied')) {
1703
+ errorMessage = 'Authentication failed';
1704
+ details = 'Permission denied. Check your credentials or SSH keys.';
1705
+ } else if (error.message.includes('fatal:') && error.message.includes('does not appear to be a git repository')) {
1706
+ errorMessage = 'Remote not configured';
1707
+ details = 'Remote repository not properly configured. Check your remote URL.';
1708
+ }
1709
+
1710
+ res.status(500).json({
1711
+ error: errorMessage,
1712
+ details: details
1713
+ });
1714
+ }
1715
+ });
1716
+
1717
+ // Discard changes for a specific file
1718
+ router.post('/discard', async (req, res) => {
1719
+ const { project, file } = req.body;
1720
+
1721
+ if (!project || !file) {
1722
+ return res.status(400).json({ error: 'Project name and file path are required' });
1723
+ }
1724
+
1725
+ try {
1726
+ const projectPath = await getActualProjectPath(project);
1727
+ await validateGitRepository(projectPath);
1728
+ const {
1729
+ repositoryRootPath,
1730
+ repositoryRelativeFilePath,
1731
+ } = await resolveRepositoryFilePath(projectPath, file);
1732
+
1733
+ // Check file status to determine correct discard command
1734
+ const { stdout: statusOutput } = await spawnAsync(
1735
+ 'git',
1736
+ ['status', '--porcelain', '--', repositoryRelativeFilePath],
1737
+ { cwd: repositoryRootPath },
1738
+ );
1739
+
1740
+ if (!statusOutput.trim()) {
1741
+ return res.status(400).json({ error: 'No changes to discard for this file' });
1742
+ }
1743
+
1744
+ const status = statusOutput.substring(0, 2);
1745
+
1746
+ if (status === '??') {
1747
+ // Untracked file or directory - delete it
1748
+ const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
1749
+ const stats = await fs.stat(filePath);
1750
+
1751
+ if (stats.isDirectory()) {
1752
+ await fs.rm(filePath, { recursive: true, force: true });
1753
+ } else {
1754
+ await fs.unlink(filePath);
1755
+ }
1756
+ } else if (status.includes('M') || status.includes('D')) {
1757
+ // Modified or deleted file - restore from HEAD
1758
+ await spawnAsync('git', ['restore', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
1759
+ } else if (status.includes('A')) {
1760
+ // Added file - unstage it
1761
+ await spawnAsync('git', ['reset', 'HEAD', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
1762
+ }
1763
+
1764
+ res.json({ success: true, message: `Changes discarded for ${repositoryRelativeFilePath}` });
1765
+ } catch (error) {
1766
+ console.error('Git discard error:', error);
1767
+ res.status(500).json({ error: "Internal server error" });
1768
+ }
1769
+ });
1770
+
1771
+ // Delete untracked file
1772
+ router.post('/delete-untracked', async (req, res) => {
1773
+ const { project, file } = req.body;
1774
+
1775
+ if (!project || !file) {
1776
+ return res.status(400).json({ error: 'Project name and file path are required' });
1777
+ }
1778
+
1779
+ try {
1780
+ const projectPath = await getActualProjectPath(project);
1781
+ await validateGitRepository(projectPath);
1782
+ const {
1783
+ repositoryRootPath,
1784
+ repositoryRelativeFilePath,
1785
+ } = await resolveRepositoryFilePath(projectPath, file);
1786
+
1787
+ // Check if file is actually untracked
1788
+ const { stdout: statusOutput } = await spawnAsync(
1789
+ 'git',
1790
+ ['status', '--porcelain', '--', repositoryRelativeFilePath],
1791
+ { cwd: repositoryRootPath },
1792
+ );
1793
+
1794
+ if (!statusOutput.trim()) {
1795
+ return res.status(400).json({ error: 'File is not untracked or does not exist' });
1796
+ }
1797
+
1798
+ const status = statusOutput.substring(0, 2);
1799
+
1800
+ if (status !== '??') {
1801
+ return res.status(400).json({ error: 'File is not untracked. Use discard for tracked files.' });
1802
+ }
1803
+
1804
+ // Delete the untracked file or directory
1805
+ const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
1806
+ const stats = await fs.stat(filePath);
1807
+
1808
+ if (stats.isDirectory()) {
1809
+ // Use rm with recursive option for directories
1810
+ await fs.rm(filePath, { recursive: true, force: true });
1811
+ res.json({ success: true, message: `Untracked directory ${repositoryRelativeFilePath} deleted successfully` });
1812
+ } else {
1813
+ await fs.unlink(filePath);
1814
+ res.json({ success: true, message: `Untracked file ${repositoryRelativeFilePath} deleted successfully` });
1815
+ }
1816
+ } catch (error) {
1817
+ console.error('Git delete untracked error:', error);
1818
+ res.status(500).json({ error: "Internal server error" });
1819
+ }
1820
+ });
1821
+
1822
+ export default router;