@pixelbyte-software/pixcode 1.51.2 → 1.51.4

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