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