spck 0.3.1

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 (155) hide show
  1. package/.oxlintrc.json +49 -0
  2. package/LICENSE +21 -0
  3. package/README.md +631 -0
  4. package/bin/cli.js +20 -0
  5. package/bin/validate-cwd.js +41 -0
  6. package/dist/config/__tests__/config.test.d.ts +2 -0
  7. package/dist/config/__tests__/config.test.js +262 -0
  8. package/dist/config/__tests__/credentials.test.d.ts +2 -0
  9. package/dist/config/__tests__/credentials.test.js +360 -0
  10. package/dist/config/config.d.ts +33 -0
  11. package/dist/config/config.js +185 -0
  12. package/dist/config/credentials.d.ts +75 -0
  13. package/dist/config/credentials.js +259 -0
  14. package/dist/config/server-selection.d.ts +40 -0
  15. package/dist/config/server-selection.js +130 -0
  16. package/dist/connection/__tests__/firebase-auth.test.d.ts +2 -0
  17. package/dist/connection/__tests__/firebase-auth.test.js +96 -0
  18. package/dist/connection/__tests__/hmac.test.d.ts +2 -0
  19. package/dist/connection/__tests__/hmac.test.js +372 -0
  20. package/dist/connection/auth.d.ts +13 -0
  21. package/dist/connection/auth.js +91 -0
  22. package/dist/connection/firebase-auth.d.ts +40 -0
  23. package/dist/connection/firebase-auth.js +429 -0
  24. package/dist/connection/hmac.d.ts +24 -0
  25. package/dist/connection/hmac.js +109 -0
  26. package/dist/i18n/index.d.ts +25 -0
  27. package/dist/i18n/index.js +101 -0
  28. package/dist/i18n/locales/en.json +313 -0
  29. package/dist/i18n/locales/es.json +302 -0
  30. package/dist/i18n/locales/fr.json +302 -0
  31. package/dist/i18n/locales/id.json +302 -0
  32. package/dist/i18n/locales/ja.json +302 -0
  33. package/dist/i18n/locales/ko.json +302 -0
  34. package/dist/i18n/locales/locales/en.json +309 -0
  35. package/dist/i18n/locales/locales/es.json +302 -0
  36. package/dist/i18n/locales/locales/fr.json +302 -0
  37. package/dist/i18n/locales/locales/id.json +302 -0
  38. package/dist/i18n/locales/locales/ja.json +302 -0
  39. package/dist/i18n/locales/locales/ko.json +302 -0
  40. package/dist/i18n/locales/locales/pt.json +302 -0
  41. package/dist/i18n/locales/locales/zh-Hans.json +302 -0
  42. package/dist/i18n/locales/pt.json +302 -0
  43. package/dist/i18n/locales/zh-Hans.json +302 -0
  44. package/dist/index.d.ts +25 -0
  45. package/dist/index.js +493 -0
  46. package/dist/proxy/ProxyClient.d.ts +125 -0
  47. package/dist/proxy/ProxyClient.js +781 -0
  48. package/dist/proxy/ProxySocketWrapper.d.ts +43 -0
  49. package/dist/proxy/ProxySocketWrapper.js +98 -0
  50. package/dist/proxy/__tests__/ProxyClient.test.d.ts +2 -0
  51. package/dist/proxy/__tests__/ProxyClient.test.js +445 -0
  52. package/dist/proxy/__tests__/ProxySocketWrapper.test.d.ts +2 -0
  53. package/dist/proxy/__tests__/ProxySocketWrapper.test.js +190 -0
  54. package/dist/proxy/__tests__/handshake-validation.test.d.ts +2 -0
  55. package/dist/proxy/__tests__/handshake-validation.test.js +282 -0
  56. package/dist/proxy/__tests__/token-refresh-race.test.d.ts +14 -0
  57. package/dist/proxy/__tests__/token-refresh-race.test.js +173 -0
  58. package/dist/proxy/chunking.d.ts +53 -0
  59. package/dist/proxy/chunking.js +127 -0
  60. package/dist/proxy/handshake-validation.d.ts +21 -0
  61. package/dist/proxy/handshake-validation.js +49 -0
  62. package/dist/rpc/__tests__/router.test.d.ts +2 -0
  63. package/dist/rpc/__tests__/router.test.js +262 -0
  64. package/dist/rpc/router.d.ts +37 -0
  65. package/dist/rpc/router.js +132 -0
  66. package/dist/services/BrowserProxyService.d.ts +13 -0
  67. package/dist/services/BrowserProxyService.js +139 -0
  68. package/dist/services/FilesystemService.d.ts +99 -0
  69. package/dist/services/FilesystemService.js +742 -0
  70. package/dist/services/GitService.d.ts +243 -0
  71. package/dist/services/GitService.js +1439 -0
  72. package/dist/services/SearchService.d.ts +93 -0
  73. package/dist/services/SearchService.js +670 -0
  74. package/dist/services/TerminalService.d.ts +62 -0
  75. package/dist/services/TerminalService.js +337 -0
  76. package/dist/services/__tests__/BrowserProxyService.test.d.ts +2 -0
  77. package/dist/services/__tests__/BrowserProxyService.test.js +145 -0
  78. package/dist/services/__tests__/FilesystemService.test.d.ts +2 -0
  79. package/dist/services/__tests__/FilesystemService.test.js +609 -0
  80. package/dist/services/__tests__/GitService.test.d.ts +2 -0
  81. package/dist/services/__tests__/GitService.test.js +953 -0
  82. package/dist/services/__tests__/SearchService.test.d.ts +2 -0
  83. package/dist/services/__tests__/SearchService.test.js +384 -0
  84. package/dist/services/__tests__/TerminalService.test.d.ts +2 -0
  85. package/dist/services/__tests__/TerminalService.test.js +513 -0
  86. package/dist/setup/wizard.d.ts +10 -0
  87. package/dist/setup/wizard.js +172 -0
  88. package/dist/types.d.ts +196 -0
  89. package/dist/types.js +44 -0
  90. package/dist/utils/__tests__/gitignore.test.d.ts +2 -0
  91. package/dist/utils/__tests__/gitignore.test.js +127 -0
  92. package/dist/utils/gitignore.d.ts +24 -0
  93. package/dist/utils/gitignore.js +77 -0
  94. package/dist/utils/logger.d.ts +96 -0
  95. package/dist/utils/logger.js +456 -0
  96. package/dist/utils/project-dir.d.ts +51 -0
  97. package/dist/utils/project-dir.js +191 -0
  98. package/dist/utils/ripgrep.d.ts +34 -0
  99. package/dist/utils/ripgrep.js +148 -0
  100. package/dist/utils/tool-detection.d.ts +17 -0
  101. package/dist/utils/tool-detection.js +126 -0
  102. package/dist/watcher/FileWatcher.d.ts +10 -0
  103. package/dist/watcher/FileWatcher.js +42 -0
  104. package/package.json +70 -0
  105. package/src/config/__tests__/config.test.ts +318 -0
  106. package/src/config/__tests__/credentials.test.ts +494 -0
  107. package/src/config/config.ts +206 -0
  108. package/src/config/credentials.ts +302 -0
  109. package/src/config/server-selection.ts +150 -0
  110. package/src/connection/__tests__/firebase-auth.test.ts +121 -0
  111. package/src/connection/__tests__/hmac.test.ts +509 -0
  112. package/src/connection/auth.ts +140 -0
  113. package/src/connection/firebase-auth.ts +504 -0
  114. package/src/connection/hmac.ts +139 -0
  115. package/src/i18n/index.ts +119 -0
  116. package/src/i18n/locales/en.json +313 -0
  117. package/src/i18n/locales/es.json +302 -0
  118. package/src/i18n/locales/fr.json +302 -0
  119. package/src/i18n/locales/id.json +302 -0
  120. package/src/i18n/locales/ja.json +302 -0
  121. package/src/i18n/locales/ko.json +302 -0
  122. package/src/i18n/locales/pt.json +302 -0
  123. package/src/i18n/locales/zh-Hans.json +302 -0
  124. package/src/index.ts +542 -0
  125. package/src/proxy/ProxyClient.ts +968 -0
  126. package/src/proxy/ProxySocketWrapper.ts +113 -0
  127. package/src/proxy/__tests__/ProxyClient.test.ts +575 -0
  128. package/src/proxy/__tests__/ProxySocketWrapper.test.ts +251 -0
  129. package/src/proxy/__tests__/handshake-validation.test.ts +367 -0
  130. package/src/proxy/chunking.ts +162 -0
  131. package/src/proxy/handshake-validation.ts +64 -0
  132. package/src/rpc/__tests__/router.test.ts +400 -0
  133. package/src/rpc/router.ts +183 -0
  134. package/src/services/BrowserProxyService.ts +179 -0
  135. package/src/services/FilesystemService.ts +841 -0
  136. package/src/services/GitService.ts +1639 -0
  137. package/src/services/SearchService.ts +809 -0
  138. package/src/services/TerminalService.ts +413 -0
  139. package/src/services/__tests__/BrowserProxyService.test.ts +155 -0
  140. package/src/services/__tests__/FilesystemService.test.ts +1002 -0
  141. package/src/services/__tests__/GitService.test.ts +1552 -0
  142. package/src/services/__tests__/SearchService.test.ts +484 -0
  143. package/src/services/__tests__/TerminalService.test.ts +702 -0
  144. package/src/setup/wizard.ts +242 -0
  145. package/src/types/fossil-delta.d.ts +4 -0
  146. package/src/types.ts +287 -0
  147. package/src/utils/__tests__/gitignore.test.ts +174 -0
  148. package/src/utils/gitignore.ts +91 -0
  149. package/src/utils/logger.ts +578 -0
  150. package/src/utils/project-dir.ts +218 -0
  151. package/src/utils/ripgrep.ts +180 -0
  152. package/src/utils/tool-detection.ts +141 -0
  153. package/src/watcher/FileWatcher.ts +53 -0
  154. package/tsconfig.json +24 -0
  155. package/vitest.config.ts +19 -0
@@ -0,0 +1,1639 @@
1
+ /**
2
+ * Git service - executes command-line git operations
3
+ */
4
+
5
+ import { spawn, execFileSync } from 'child_process';
6
+ import * as path from 'path';
7
+ import * as http from 'http';
8
+ import * as fs from 'fs';
9
+ import * as os from 'os';
10
+ import * as crypto from 'crypto';
11
+ import { AuthenticatedSocket, ErrorCode, createRPCError } from '../types.js';
12
+ import { logGitRead, logGitWrite } from '../utils/logger.js';
13
+
14
+ interface ExecGitOptions {
15
+ cwd?: string;
16
+ env?: NodeJS.ProcessEnv;
17
+ input?: string;
18
+ acceptExitCodes?: number[];
19
+ }
20
+
21
+ interface ExecGitResult {
22
+ stdout: string;
23
+ stderr: string;
24
+ code: number;
25
+ }
26
+
27
+ export class GitService {
28
+ private submoduleCache: Map<string, { submodules: Array<{ name: string; path: string }>; timestamp: number }> = new Map();
29
+ private static SUBMODULE_CACHE_TTL = 30000; // 30 seconds
30
+ private _hasCurl: boolean | null = null;
31
+
32
+ constructor(private rootPath: string) {}
33
+
34
+ /**
35
+ * Check if curl is available on the system (cached)
36
+ */
37
+ private hasCurl(): boolean {
38
+ if (this._hasCurl === null) {
39
+ try {
40
+ execFileSync('curl', ['--version'], { stdio: 'ignore' });
41
+ this._hasCurl = true;
42
+ } catch {
43
+ this._hasCurl = false;
44
+ }
45
+ }
46
+ return this._hasCurl;
47
+ }
48
+
49
+ /**
50
+ * Get submodules for a directory, using a short-lived cache to avoid
51
+ * repeated git submodule status calls during bulk operations.
52
+ */
53
+ private async getCachedSubmodules(dir: string): Promise<Array<{ name: string; path: string }>> {
54
+ const cached = this.submoduleCache.get(dir);
55
+ if (cached && Date.now() - cached.timestamp < GitService.SUBMODULE_CACHE_TTL) {
56
+ return cached.submodules;
57
+ }
58
+ const { submodules } = await this.listSubmodules(dir);
59
+ this.submoduleCache.set(dir, { submodules, timestamp: Date.now() });
60
+ return submodules;
61
+ }
62
+
63
+ /**
64
+ * Resolve the effective git working directory.
65
+ * When gitRoot is provided (submodule path), resolve it relative to dir.
66
+ */
67
+ private resolveGitCwd(dir: string, gitRoot?: string): string {
68
+ if (gitRoot) {
69
+ const resolved = path.resolve(dir, gitRoot);
70
+ // Security: ensure resolved path stays within dir
71
+ if (!resolved.startsWith(dir)) {
72
+ throw createRPCError(ErrorCode.INVALID_PATH, 'Invalid gitRoot: path traversal not allowed');
73
+ }
74
+ return resolved;
75
+ }
76
+ return dir;
77
+ }
78
+
79
+ /**
80
+ * Handle git RPC methods
81
+ */
82
+ async handle(method: string, params: any, socket: AuthenticatedSocket): Promise<any> {
83
+ const deviceId = socket.data.deviceId;
84
+ let result: any;
85
+ let error: any;
86
+
87
+ // Validate and resolve git directory
88
+ const dir = this.validateGitDir(params.dir);
89
+ // Resolve effective cwd for submodule support
90
+ const gitCwd = this.resolveGitCwd(dir, params.gitRoot);
91
+
92
+ // Define read and write operations
93
+ const readOps = ['readCommit', 'readObject', 'getHeadTree', 'getOidAtPath', 'listFiles', 'resolveRef',
94
+ 'currentBranch', 'log', 'status', 'statusCounts', 'getConfig', 'listBranches',
95
+ 'listRemotes', 'isIgnored', 'bulkIsIgnored', 'isInitialized', 'listSubmodules', 'requestAuth',
96
+ 'getDefaultAuthor'];
97
+ const writeOps = ['add', 'remove', 'resetIndex', 'commit', 'setConfig', 'checkout', 'init',
98
+ 'addRemote', 'deleteRemote', 'clearIndex', 'push', 'pull', 'fetch', 'clone'];
99
+
100
+ try {
101
+ switch (method) {
102
+ case 'readCommit':
103
+ result = await this.readCommit(gitCwd, params);
104
+ logGitRead(method, params, deviceId, true, undefined, { oid: params.oid });
105
+ return result;
106
+ case 'readObject':
107
+ result = await this.readObject(gitCwd, params);
108
+ logGitRead(method, params, deviceId, true, undefined, { oid: params.oid });
109
+ return result;
110
+ case 'getHeadTree':
111
+ result = await this.getHeadTree(gitCwd, params);
112
+ logGitRead(method, params, deviceId, true, undefined, { oid: result.oid });
113
+ return result;
114
+ case 'getOidAtPath':
115
+ result = await this.getOidAtPath(gitCwd, params);
116
+ logGitRead(method, params, deviceId, true, undefined, { path: params.path, oid: result.oid });
117
+ return result;
118
+ case 'listFiles':
119
+ result = await this.listFiles(gitCwd, params);
120
+ logGitRead(method, params, deviceId, true, undefined, { count: result.files.length });
121
+ return result;
122
+ case 'resolveRef':
123
+ result = await this.resolveRef(gitCwd, params);
124
+ logGitRead(method, params, deviceId, true, undefined, { ref: params.ref, oid: result.oid });
125
+ return result;
126
+ case 'currentBranch':
127
+ result = await this.currentBranch(gitCwd, params);
128
+ logGitRead(method, params, deviceId, true, undefined, { branch: result.branch });
129
+ return result;
130
+ case 'log':
131
+ result = await this.log(gitCwd, params);
132
+ logGitRead(method, params, deviceId, true, undefined, { commits: result.commits.length });
133
+ return result;
134
+ case 'status':
135
+ result = await this.status(gitCwd, params);
136
+ logGitRead(method, params, deviceId, true, undefined, { files: result.length });
137
+ return result;
138
+ case 'statusCounts':
139
+ result = await this.statusCounts(gitCwd, params);
140
+ logGitRead(method, params, deviceId, true, undefined, result);
141
+ return result;
142
+ case 'add':
143
+ result = await this.add(gitCwd, params, socket);
144
+ logGitWrite(method, params, deviceId, true, undefined, { count: params.filepaths?.length || 0 });
145
+ return result;
146
+ case 'remove':
147
+ result = await this.remove(gitCwd, params, socket);
148
+ logGitWrite(method, params, deviceId, true, undefined, { count: params.filepaths?.length || 0 });
149
+ return result;
150
+ case 'resetIndex':
151
+ result = await this.resetIndex(gitCwd, params, socket);
152
+ logGitWrite(method, params, deviceId, true);
153
+ return result;
154
+ case 'commit':
155
+ result = await this.commit(gitCwd, params, socket);
156
+ logGitWrite(method, params, deviceId, true, undefined, { oid: result.oid });
157
+ return result;
158
+ case 'getConfig':
159
+ result = await this.getConfig(gitCwd, params);
160
+ logGitRead(method, params, deviceId, true, undefined, { path: params.path });
161
+ return result;
162
+ case 'setConfig':
163
+ result = await this.setConfig(gitCwd, params);
164
+ logGitWrite(method, params, deviceId, true, undefined, { path: params.path });
165
+ return result;
166
+ case 'listBranches':
167
+ result = await this.listBranches(gitCwd, params);
168
+ logGitRead(method, params, deviceId, true, undefined, { count: result.branches.length });
169
+ return result;
170
+ case 'checkout':
171
+ result = await this.checkout(gitCwd, params);
172
+ logGitWrite(method, params, deviceId, true);
173
+ return result;
174
+ case 'init':
175
+ result = await this.init(gitCwd, params);
176
+ logGitWrite(method, params, deviceId, true);
177
+ return result;
178
+ case 'listRemotes':
179
+ result = await this.listRemotes(gitCwd, params);
180
+ logGitRead(method, params, deviceId, true, undefined, { count: result.remotes.length });
181
+ return result;
182
+ case 'addRemote':
183
+ result = await this.addRemote(gitCwd, params);
184
+ logGitWrite(method, params, deviceId, true, undefined, { remote: params.remote, url: params.url });
185
+ return result;
186
+ case 'deleteRemote':
187
+ result = await this.deleteRemote(gitCwd, params);
188
+ logGitWrite(method, params, deviceId, true, undefined, { remote: params.remote });
189
+ return result;
190
+ case 'clearIndex':
191
+ result = await this.clearIndex(gitCwd, params);
192
+ logGitWrite(method, params, deviceId, true);
193
+ return result;
194
+ case 'isIgnored':
195
+ result = await this.isIgnored(gitCwd, params);
196
+ logGitRead(method, params, deviceId, true, undefined, { filepath: params.filepath, ignored: result });
197
+ return result;
198
+ case 'bulkIsIgnored':
199
+ result = await this.bulkIsIgnored(gitCwd, params);
200
+ logGitRead(method, params, deviceId, true, undefined, { count: result.length });
201
+ return result;
202
+ case 'isInitialized':
203
+ result = await this.isInitialized(gitCwd, params);
204
+ logGitRead(method, params, deviceId, true, undefined, { initialized: result });
205
+ return result;
206
+ case 'listSubmodules':
207
+ result = await this.listSubmodules(dir);
208
+ logGitRead(method, params, deviceId, true, undefined, { count: result.submodules.length });
209
+ return result;
210
+ case 'requestAuth':
211
+ // This is called by server to request credentials from client
212
+ result = await this.requestAuth(socket, params);
213
+ logGitRead(method, params, deviceId, true);
214
+ return result;
215
+ case 'getDefaultAuthor':
216
+ result = await this.getDefaultAuthor(gitCwd);
217
+ logGitRead(method, params, deviceId, true, undefined, result);
218
+ return result;
219
+ case 'push':
220
+ result = await this.push(gitCwd, params, socket);
221
+ logGitWrite(method, params, deviceId, true, undefined, { remote: params.remote });
222
+ return result;
223
+ case 'pull':
224
+ result = await this.pull(gitCwd, params, socket);
225
+ logGitWrite(method, params, deviceId, true, undefined, { remote: params.remote });
226
+ return result;
227
+ case 'fetch':
228
+ result = await this.fetch(gitCwd, params, socket);
229
+ logGitWrite(method, params, deviceId, true, undefined, { remote: params.remote });
230
+ return result;
231
+ case 'clone':
232
+ result = await this.clone(gitCwd, params, socket);
233
+ logGitWrite(method, params, deviceId, true, undefined, { url: params.url });
234
+ return result;
235
+ default:
236
+ throw createRPCError(ErrorCode.METHOD_NOT_FOUND, `Method not found: git.${method}`);
237
+ }
238
+ } catch (err: any) {
239
+ error = err;
240
+ // Log error based on operation type
241
+ if (readOps.includes(method)) {
242
+ logGitRead(method, params, deviceId, false, error);
243
+ } else if (writeOps.includes(method)) {
244
+ logGitWrite(method, params, deviceId, false, error);
245
+ }
246
+
247
+ if (error.code && error.message) {
248
+ throw error; // Re-throw RPC errors
249
+ }
250
+ throw createRPCError(
251
+ ErrorCode.GIT_OPERATION_FAILED,
252
+ `Git operation failed: ${error.message}`,
253
+ { method, error: error.toString() }
254
+ );
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Validate git directory
260
+ */
261
+ private validateGitDir(dir: string): string {
262
+ const normalized = path.normalize(dir);
263
+ if (normalized.includes('..')) {
264
+ throw createRPCError(ErrorCode.INVALID_PATH, 'Invalid path: directory traversal not allowed');
265
+ }
266
+
267
+ const absolute = path.resolve(this.rootPath, normalized.startsWith('/') ? normalized.slice(1) : normalized);
268
+ if (!absolute.startsWith(path.resolve(this.rootPath))) {
269
+ throw createRPCError(ErrorCode.INVALID_PATH, 'Access denied: path outside root directory');
270
+ }
271
+
272
+ return absolute;
273
+ }
274
+
275
+ /**
276
+ * Sanitize filename to prevent command injection
277
+ * Rejects filenames that could be interpreted as git flags or contain control characters
278
+ */
279
+ private sanitizeFilename(filename: string): string {
280
+ // Reject filenames starting with dash (potential git flag injection)
281
+ if (filename.startsWith('-')) {
282
+ throw createRPCError(
283
+ ErrorCode.INVALID_PATH,
284
+ 'Invalid filename: cannot start with dash (potential command injection)'
285
+ );
286
+ }
287
+
288
+ // Reject newlines (could break git command parsing) - check before general control chars
289
+ if (filename.includes('\n') || filename.includes('\r')) {
290
+ throw createRPCError(
291
+ ErrorCode.INVALID_PATH,
292
+ 'Invalid filename: contains newline characters'
293
+ );
294
+ }
295
+
296
+ // Reject other control characters (including null bytes)
297
+ if (/[\x00-\x1F\x7F]/.test(filename)) {
298
+ throw createRPCError(
299
+ ErrorCode.INVALID_PATH,
300
+ 'Invalid filename: contains control characters'
301
+ );
302
+ }
303
+
304
+ return filename;
305
+ }
306
+
307
+ /**
308
+ * Execute git command
309
+ */
310
+ private async execGit(args: string[], options: ExecGitOptions = {}): Promise<ExecGitResult> {
311
+ return new Promise((resolve, reject) => {
312
+ const git = spawn('git', args, {
313
+ cwd: options.cwd,
314
+ env: { ...process.env, ...options.env },
315
+ stdio: options.input !== undefined ? ['pipe', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe'],
316
+ });
317
+
318
+ let stdout = '';
319
+ let stderr = '';
320
+
321
+ if (git.stdout) {
322
+ git.stdout.on('data', (data) => {
323
+ stdout += data.toString();
324
+ });
325
+ }
326
+
327
+ if (git.stderr) {
328
+ git.stderr.on('data', (data) => {
329
+ stderr += data.toString();
330
+ });
331
+ }
332
+
333
+ git.on('error', (error) => {
334
+ // Log full error server-side
335
+ console.error('Failed to execute git:', {
336
+ args,
337
+ error: error.message,
338
+ cwd: options.cwd,
339
+ });
340
+ // Send sanitized error to client
341
+ reject(new Error('Failed to execute git command'));
342
+ });
343
+
344
+ git.on('close', (code) => {
345
+ const exitCode = code || 0;
346
+ if (exitCode !== 0 && !(options.acceptExitCodes && options.acceptExitCodes.includes(exitCode))) {
347
+ // Log full output server-side for debugging
348
+ console.error('Git command failed:', {
349
+ args,
350
+ code,
351
+ stderr: stderr.substring(0, 500), // Truncate for logs
352
+ stdout: stdout.substring(0, 500),
353
+ });
354
+ // Send sanitized error to client (no file paths from stderr/stdout)
355
+ reject(new Error(`Git command failed with exit code ${code}`));
356
+ } else {
357
+ resolve({ stdout, stderr, code: exitCode });
358
+ }
359
+ });
360
+
361
+ if (options.input !== undefined && git.stdin) {
362
+ git.stdin.write(options.input);
363
+ git.stdin.end();
364
+ }
365
+ });
366
+ }
367
+
368
+ /**
369
+ * Read commit object
370
+ */
371
+ private async readCommit(dir: string, params: any): Promise<any> {
372
+ const { stdout } = await this.execGit(['cat-file', 'commit', params.oid], { cwd: dir });
373
+ const commit = this.parseCommit(stdout);
374
+ return {
375
+ oid: params.oid,
376
+ commit,
377
+ };
378
+ }
379
+
380
+ /**
381
+ * Parse commit object
382
+ */
383
+ private parseCommit(commitText: string): any {
384
+ const lines = commitText.split('\n');
385
+ const commit: any = {
386
+ message: '',
387
+ tree: '',
388
+ parent: [],
389
+ author: null,
390
+ committer: null,
391
+ };
392
+
393
+ let i = 0;
394
+ while (i < lines.length && lines[i] !== '') {
395
+ const line = lines[i];
396
+ if (line.startsWith('tree ')) {
397
+ commit.tree = line.substring(5);
398
+ } else if (line.startsWith('parent ')) {
399
+ commit.parent.push(line.substring(7));
400
+ } else if (line.startsWith('author ')) {
401
+ commit.author = this.parseIdentity(line.substring(7));
402
+ } else if (line.startsWith('committer ')) {
403
+ commit.committer = this.parseIdentity(line.substring(10));
404
+ }
405
+ i++;
406
+ }
407
+
408
+ i++;
409
+ commit.message = lines.slice(i).join('\n');
410
+
411
+ return commit;
412
+ }
413
+
414
+ /**
415
+ * Parse git identity (author/committer)
416
+ */
417
+ private parseIdentity(identityString: string): any {
418
+ const match = identityString.match(/^(.+) <(.+)> (\d+) ([+-]\d{4})$/);
419
+ if (!match) {
420
+ // Log full identity string server-side for debugging
421
+ console.error('Invalid git identity format:', identityString);
422
+ // Send sanitized error to client
423
+ throw new Error('Invalid git identity format');
424
+ }
425
+
426
+ return {
427
+ name: match[1],
428
+ email: match[2],
429
+ timestamp: parseInt(match[3], 10),
430
+ timezoneOffset: (parseInt(match[4], 10) / 100) * 60,
431
+ };
432
+ }
433
+
434
+ /**
435
+ * Read git object
436
+ */
437
+ private async readObject(dir: string, params: any): Promise<any> {
438
+ const { oid, encoding } = params;
439
+ const { stdout } = await this.execGit(['cat-file', '-p', oid], { cwd: dir });
440
+
441
+ return {
442
+ oid,
443
+ object: encoding === 'utf8' ? stdout : Buffer.from(stdout, 'utf8'),
444
+ format: 'content'
445
+ };
446
+ }
447
+
448
+ /**
449
+ * Get HEAD tree oid
450
+ */
451
+ private async getHeadTree(dir: string, _params: any): Promise<any> {
452
+ try {
453
+ const { stdout } = await this.execGit(['rev-parse', 'HEAD^{tree}'], { cwd: dir });
454
+ return { oid: stdout.trim() };
455
+ } catch (error) {
456
+ // No commits yet
457
+ return { oid: null };
458
+ }
459
+ }
460
+
461
+ /**
462
+ * Get object ID at path in tree or index stage
463
+ * @param dir - Git repository directory
464
+ * @param params.path - File path to lookup
465
+ * @param params.tree - Tree object ID to search in (e.g., HEAD tree)
466
+ * @param params.stage - Index stage number (0=normal, 1=ancestor, 2=ours, 3=theirs during merge)
467
+ */
468
+ private async getOidAtPath(dir: string, params: any): Promise<any> {
469
+ const { path, tree, stage } = params;
470
+
471
+ try {
472
+ // If stage is provided, read from git index at that stage
473
+ if (stage !== undefined) {
474
+ const { stdout } = await this.execGit(['ls-files', '--stage', path], { cwd: dir });
475
+
476
+ // Parse output: <mode> <hash> <stage> <path>
477
+ // Example: "100644 abc123... 0 path/to/file.txt"
478
+ const lines = stdout.split('\n').filter(l => l.trim());
479
+
480
+ for (const line of lines) {
481
+ const match = line.match(/^(\d+)\s+([a-f0-9]+)\s+(\d+)\s+(.+)$/);
482
+ if (match) {
483
+ const fileStage = parseInt(match[3], 10);
484
+ const filePath = match[4];
485
+
486
+ // Match both stage number and path
487
+ if (fileStage === stage && filePath === path) {
488
+ return { oid: match[2] };
489
+ }
490
+ }
491
+ }
492
+
493
+ return { oid: null };
494
+ }
495
+
496
+ // If tree is provided, read from tree object (original behavior)
497
+ if (tree) {
498
+ const { stdout } = await this.execGit(['ls-tree', tree, path], { cwd: dir });
499
+ const match = stdout.trim().match(/^(\d+)\s+(\w+)\s+([a-f0-9]+)\s+(.+)$/);
500
+ if (match) {
501
+ return { oid: match[3] };
502
+ }
503
+ }
504
+
505
+ return { oid: null };
506
+ } catch (error) {
507
+ return { oid: null };
508
+ }
509
+ }
510
+
511
+ /**
512
+ * List files in ref
513
+ */
514
+ private async listFiles(dir: string, params: any): Promise<any> {
515
+ const { ref } = params;
516
+ try {
517
+ const { stdout } = await this.execGit(['ls-tree', '-r', '--name-only', ref || 'HEAD'], { cwd: dir });
518
+ const files = stdout.split('\n').filter(f => f.trim());
519
+ return { files };
520
+ } catch (error) {
521
+ return { files: [] };
522
+ }
523
+ }
524
+
525
+ /**
526
+ * Resolve ref to oid
527
+ */
528
+ private async resolveRef(dir: string, params: any): Promise<any> {
529
+ const { ref } = params;
530
+ try {
531
+ const { stdout } = await this.execGit(['rev-parse', ref], { cwd: dir });
532
+ return { oid: stdout.trim() };
533
+ } catch (error) {
534
+ return { oid: null };
535
+ }
536
+ }
537
+
538
+ /**
539
+ * Get current branch
540
+ */
541
+ private async currentBranch(dir: string, params: any): Promise<any> {
542
+ try {
543
+ const args = params.fullname
544
+ ? ['symbolic-ref', 'HEAD']
545
+ : ['symbolic-ref', '--short', 'HEAD'];
546
+ const { stdout } = await this.execGit(args, { cwd: dir });
547
+ return { branch: stdout.trim() };
548
+ } catch {
549
+ return { branch: null }; // Detached HEAD
550
+ }
551
+ }
552
+
553
+ /**
554
+ * Get commit history
555
+ */
556
+ private async log(dir: string, params: any): Promise<any> {
557
+ const args = [
558
+ 'log',
559
+ '--format=%H%n%T%n%P%n%an%n%ae%n%at%n%cn%n%ce%n%ct%n%B%n--END-COMMIT--',
560
+ params.ref ? this.sanitizeRef(params.ref) : 'HEAD',
561
+ ];
562
+
563
+ if (params.depth) {
564
+ args.push(`-${params.depth}`);
565
+ }
566
+
567
+ const { stdout } = await this.execGit(args, { cwd: dir });
568
+ const commits = [];
569
+ const commitTexts = stdout.split('--END-COMMIT--\n').filter((t) => t.trim());
570
+
571
+ for (const commitText of commitTexts) {
572
+ const lines = commitText.trim().split('\n');
573
+ commits.push({
574
+ oid: lines[0],
575
+ commit: {
576
+ tree: lines[1],
577
+ parent: lines[2] ? lines[2].split(' ') : [],
578
+ author: {
579
+ name: lines[3],
580
+ email: lines[4],
581
+ timestamp: parseInt(lines[5], 10),
582
+ timezoneOffset: 0,
583
+ },
584
+ committer: {
585
+ name: lines[6],
586
+ email: lines[7],
587
+ timestamp: parseInt(lines[8], 10),
588
+ timezoneOffset: 0,
589
+ },
590
+ message: lines.slice(9).join('\n'),
591
+ },
592
+ });
593
+ }
594
+
595
+ return { commits };
596
+ }
597
+
598
+ /**
599
+ * Get working directory status
600
+ * Uses two git commands:
601
+ * 1. With -uall for detailed untracked files
602
+ * 2. With --ignored (no -uall) for rolled-up ignored directories
603
+ */
604
+ private async status(dir: string, params: any): Promise<any> {
605
+ const filterFilepath = params?.filepath;
606
+
607
+ // Run two git status commands in parallel
608
+ const [untrackedResult, ignoredResult] = await Promise.all([
609
+ // Get all untracked files individually with -uall
610
+ this.execGit(['status', '--porcelain=v1', '-M', '-uall'], { cwd: dir }),
611
+ // Get ignored files rolled up to directories (without -uall)
612
+ this.execGit(['status', '--porcelain=v1', '--ignored'], { cwd: dir })
613
+ ]);
614
+
615
+ // Parse untracked files (exclude ignored files from this result)
616
+ const untrackedFiles = await this.parseGitStatus(
617
+ untrackedResult.stdout,
618
+ dir,
619
+ filterFilepath,
620
+ new Set(['!!']) // Exclude ignored files
621
+ );
622
+
623
+ // Parse ignored files only
624
+ const ignoredFiles = await this.parseGitStatus(
625
+ ignoredResult.stdout,
626
+ dir,
627
+ filterFilepath,
628
+ new Set(), // Include all
629
+ new Set(['!!']) // Only include ignored files
630
+ );
631
+
632
+ // Merge results: untracked + ignored
633
+ return [...untrackedFiles, ...ignoredFiles];
634
+ }
635
+
636
+ /**
637
+ * Parse git status output into status array
638
+ */
639
+ private async parseGitStatus(
640
+ stdout: string,
641
+ dir: string,
642
+ filterFilepath: string | undefined,
643
+ excludeStatuses: Set<string> = new Set(),
644
+ includeOnlyStatuses?: Set<string>
645
+ ): Promise<Array<{ path: string; status: string }>> {
646
+ const result: Array<{ path: string; status: string }> = [];
647
+ const conflictCodes = new Set(['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU']);
648
+
649
+ const lines = stdout.split('\n').filter((l) => l.trim());
650
+ for (const line of lines) {
651
+ const gitStatus = line.substring(0, 2);
652
+
653
+ // Skip excluded statuses
654
+ if (excludeStatuses.has(gitStatus)) {
655
+ continue;
656
+ }
657
+
658
+ // If includeOnlyStatuses is specified, only include those
659
+ if (includeOnlyStatuses && !includeOnlyStatuses.has(gitStatus)) {
660
+ continue;
661
+ }
662
+
663
+ let filepath = line.substring(3);
664
+
665
+ // Handle rename format: "R old_name -> new_name"
666
+ if (gitStatus.startsWith('R')) {
667
+ const renameMatch = filepath.match(/^(.+)\s+->\s+(.+)$/);
668
+ if (renameMatch) {
669
+ filepath = renameMatch[2]; // Use new filename
670
+ }
671
+ }
672
+
673
+ // Skip if we're filtering for a specific filepath and this isn't it
674
+ if (filterFilepath && filepath !== filterFilepath) {
675
+ continue;
676
+ }
677
+
678
+ // Check if this is a conflict
679
+ const isConflict = conflictCodes.has(gitStatus);
680
+
681
+ // Direct mapping from git porcelain codes to status strings
682
+ let status: string;
683
+ switch (gitStatus) {
684
+ // Untracked
685
+ case '??':
686
+ status = ' ?';
687
+ break;
688
+ case '!!':
689
+ status = ' I';
690
+ break;
691
+
692
+ // Added
693
+ case 'A ':
694
+ status = 'A ';
695
+ break;
696
+ case 'AM':
697
+ status = 'AM';
698
+ break;
699
+ case 'AD':
700
+ status = 'AD';
701
+ break;
702
+
703
+ // Modified
704
+ case 'M ':
705
+ status = 'M ';
706
+ break;
707
+ case ' M':
708
+ status = ' M';
709
+ break;
710
+ case 'MM':
711
+ status = 'MM';
712
+ break;
713
+
714
+ // Deleted
715
+ case 'D ':
716
+ status = 'D ';
717
+ break;
718
+ case ' D':
719
+ status = ' D';
720
+ break;
721
+ case 'MD':
722
+ status = 'MD';
723
+ break;
724
+
725
+ // Renamed - check if file was new (not in HEAD)
726
+ case 'R ':
727
+ // For renamed files, check if they existed in HEAD
728
+ // If not in HEAD, treat as Added instead of Renamed
729
+ status = await this.isFileInHead(dir, filepath) ? 'R ' : 'A ';
730
+ break;
731
+ case ' R':
732
+ status = ' R';
733
+ break;
734
+
735
+ // Copied
736
+ case 'C ':
737
+ status = 'C ';
738
+ break;
739
+ case ' C':
740
+ status = ' C';
741
+ break;
742
+
743
+ // Type changed
744
+ case 'T ':
745
+ status = 'T ';
746
+ break;
747
+ case ' T':
748
+ status = ' T';
749
+ break;
750
+
751
+ // Conflicts (all marked with !)
752
+ case 'DD':
753
+ case 'AU':
754
+ case 'UD':
755
+ case 'UA':
756
+ case 'DU':
757
+ case 'AA':
758
+ case 'UU':
759
+ status = gitStatus;
760
+ break;
761
+
762
+ // No changes
763
+ case ' ':
764
+ default:
765
+ status = ' ';
766
+ break;
767
+ }
768
+
769
+ // Add conflict marker
770
+ status += isConflict ? '!' : ' ';
771
+
772
+ result.push({ path: filepath, status });
773
+ }
774
+
775
+ return result;
776
+ }
777
+
778
+ /**
779
+ * Get status counts (conflicts, changes, fileCount)
780
+ */
781
+ private async statusCounts(dir: string, params: any): Promise<any> {
782
+ const statusArray = await this.status(dir, params);
783
+ let conflicts = 0;
784
+ let changes = 0;
785
+
786
+ for (const { status } of statusArray) {
787
+ // Check for conflict marker (3rd character is '!')
788
+ if (status[2] === '!') conflicts++;
789
+ // Check for changes (not ' ')
790
+ if (status.substring(0, 2) !== ' ') changes++;
791
+ }
792
+
793
+ return {
794
+ conflicts,
795
+ changes,
796
+ fileCount: statusArray.length
797
+ };
798
+ }
799
+
800
+ /**
801
+ * Stage files
802
+ */
803
+ private async add(dir: string, params: any, socket: AuthenticatedSocket): Promise<{ success: boolean }> {
804
+ // Skip if no files to add (prevents "No pathspec was given" error)
805
+ if (!params.filepaths || params.filepaths.length === 0) {
806
+ return { success: true };
807
+ }
808
+
809
+ // Sanitize filenames to prevent command injection
810
+ const sanitizedPaths = params.filepaths.map((p: string) => this.sanitizeFilename(p));
811
+
812
+ // Use -- separator to prevent filenames being interpreted as flags (command injection prevention)
813
+ await this.execGit(['add', '--', ...sanitizedPaths], { cwd: dir });
814
+
815
+ // Send change notification
816
+ socket.broadcast.emit('rpc', {
817
+ jsonrpc: '2.0',
818
+ method: 'git.changed',
819
+ params: { dir },
820
+ });
821
+
822
+ return { success: true };
823
+ }
824
+
825
+ /**
826
+ * Remove files from index (git rm --cached)
827
+ */
828
+ private async remove(dir: string, params: any, socket: AuthenticatedSocket): Promise<{ success: boolean }> {
829
+ // Skip if no files to remove (prevents "No pathspec was given" error)
830
+ if (!params.filepaths || params.filepaths.length === 0) {
831
+ return { success: true };
832
+ }
833
+
834
+ // Sanitize filenames to prevent command injection
835
+ const sanitizedPaths = params.filepaths.map((p: string) => this.sanitizeFilename(p));
836
+
837
+ // Use -- separator to prevent filenames being interpreted as flags (command injection prevention)
838
+ await this.execGit(['rm', '--cached', '--', ...sanitizedPaths], { cwd: dir });
839
+
840
+ // Send change notification
841
+ socket.broadcast.emit('rpc', {
842
+ jsonrpc: '2.0',
843
+ method: 'git.changed',
844
+ params: { dir },
845
+ });
846
+
847
+ return { success: true };
848
+ }
849
+
850
+ /**
851
+ * Reset file(s) in index to match ref (git reset <ref> -- <filepath(s)>)
852
+ */
853
+ private async resetIndex(dir: string, params: any, socket: AuthenticatedSocket): Promise<{ success: boolean }> {
854
+ const ref = params.ref ? this.sanitizeRef(params.ref) : 'HEAD';
855
+
856
+ // Handle both single filepath and multiple filepaths
857
+ if (params.filepaths && Array.isArray(params.filepaths)) {
858
+ // Skip if no files to reset (prevents "No pathspec was given" error)
859
+ if (params.filepaths.length === 0) {
860
+ return { success: true };
861
+ }
862
+
863
+ // Sanitize filenames to prevent command injection
864
+ const sanitizedPaths = params.filepaths.map((p: string) => this.sanitizeFilename(p));
865
+
866
+ await this.execGit(['reset', ref, '--', ...sanitizedPaths], { cwd: dir });
867
+ } else if (params.filepath) {
868
+ // Reset single file (backward compatibility)
869
+ const sanitizedPath = this.sanitizeFilename(params.filepath);
870
+ await this.execGit(['reset', ref, '--', sanitizedPath], { cwd: dir });
871
+ } else {
872
+ return { success: true };
873
+ }
874
+
875
+ // Send change notification
876
+ socket.broadcast.emit('rpc', {
877
+ jsonrpc: '2.0',
878
+ method: 'git.changed',
879
+ params: { dir },
880
+ });
881
+
882
+ return { success: true };
883
+ }
884
+
885
+ /**
886
+ * Create commit
887
+ */
888
+ private async commit(dir: string, params: any, socket: AuthenticatedSocket): Promise<any> {
889
+ const env = {
890
+ GIT_AUTHOR_NAME: params.author.name,
891
+ GIT_AUTHOR_EMAIL: params.author.email,
892
+ GIT_COMMITTER_NAME: params.author.name,
893
+ GIT_COMMITTER_EMAIL: params.author.email,
894
+ };
895
+
896
+ await this.execGit(['commit', '-m', params.message], { cwd: dir, env });
897
+
898
+ const { stdout } = await this.execGit(['rev-parse', 'HEAD'], { cwd: dir });
899
+ const oid = stdout.trim();
900
+
901
+ // Send change notification
902
+ socket.broadcast.emit('rpc', {
903
+ jsonrpc: '2.0',
904
+ method: 'git.changed',
905
+ params: { dir },
906
+ });
907
+
908
+ return { oid };
909
+ }
910
+
911
+ /**
912
+ * Allowed git config key patterns.
913
+ * Only these keys can be read or written via the RPC interface
914
+ * to prevent injection of dangerous config like core.sshCommand.
915
+ */
916
+ private static ALLOWED_CONFIG_KEYS = new Set([
917
+ 'user.name',
918
+ 'user.email',
919
+ 'push.default',
920
+ 'pull.rebase',
921
+ 'pull.ff',
922
+ 'init.defaultBranch',
923
+ 'merge.ff',
924
+ ]);
925
+
926
+ /**
927
+ * Allowed git config key prefixes for dynamic keys (e.g. remote.origin.url)
928
+ */
929
+ private static ALLOWED_CONFIG_PREFIXES = [
930
+ 'remote.',
931
+ 'branch.',
932
+ 'core.',
933
+ ];
934
+
935
+ /**
936
+ * Validate that a git config key is allowed
937
+ */
938
+ private validateConfigKey(key: string): void {
939
+ if (GitService.ALLOWED_CONFIG_KEYS.has(key)) {
940
+ return;
941
+ }
942
+ for (const prefix of GitService.ALLOWED_CONFIG_PREFIXES) {
943
+ if (key.startsWith(prefix)) {
944
+ return;
945
+ }
946
+ }
947
+ throw createRPCError(
948
+ ErrorCode.INVALID_PARAMS,
949
+ `Git config key not allowed: ${key}`
950
+ );
951
+ }
952
+
953
+ /**
954
+ * Get config value
955
+ */
956
+ private async getConfig(dir: string, params: any): Promise<any> {
957
+ this.validateConfigKey(params.path);
958
+ try {
959
+ const { stdout } = await this.execGit(['config', '--get', params.path], { cwd: dir });
960
+ return { value: stdout.trim() };
961
+ } catch (error: any) {
962
+ // Git returns exit code 1 if config key doesn't exist
963
+ if (error.message.includes('code 1')) {
964
+ return { value: undefined };
965
+ }
966
+ throw error;
967
+ }
968
+ }
969
+
970
+ /**
971
+ * Set config value
972
+ */
973
+ private async setConfig(dir: string, params: any): Promise<{ success: boolean }> {
974
+ const { path, value, append } = params;
975
+ this.validateConfigKey(path);
976
+
977
+ if (value === undefined) {
978
+ // Delete the config entry
979
+ try {
980
+ await this.execGit(['config', '--unset', path], { cwd: dir });
981
+ } catch (error: any) {
982
+ // Ignore error if key doesn't exist
983
+ if (!error.message.includes('code 5')) {
984
+ throw error;
985
+ }
986
+ }
987
+ } else if (append) {
988
+ // Append to existing value (for multi-valued config options)
989
+ await this.execGit(['config', '--add', path, String(value)], { cwd: dir });
990
+ } else {
991
+ // Set/replace the value
992
+ await this.execGit(['config', path, String(value)], { cwd: dir });
993
+ }
994
+
995
+ return { success: true };
996
+ }
997
+
998
+ /**
999
+ * List branches
1000
+ * When remote is specified, returns branch names without the remote prefix
1001
+ * to match isomorphic-git behavior (e.g. 'main' instead of 'origin/main')
1002
+ */
1003
+ private async listBranches(dir: string, params: any): Promise<any> {
1004
+ const args = params.remote ? ['branch', '-r'] : ['branch'];
1005
+ const { stdout } = await this.execGit(args, { cwd: dir });
1006
+
1007
+ const remote = params.remote;
1008
+ const remotePrefix = remote ? `${remote}/` : '';
1009
+
1010
+ const branches = stdout
1011
+ .split('\n')
1012
+ .map((line) => line.trim().replace(/^\* /, ''))
1013
+ .filter((line) => line)
1014
+ .filter((line) => {
1015
+ // When listing remote branches, filter to only the specified remote
1016
+ // and exclude HEAD pointer lines like "origin/HEAD -> origin/main"
1017
+ if (remote) {
1018
+ return line.startsWith(remotePrefix) && !line.includes(' -> ');
1019
+ }
1020
+ return true;
1021
+ })
1022
+ .map((line) => {
1023
+ // Strip remote prefix to match isomorphic-git behavior
1024
+ if (remote && line.startsWith(remotePrefix)) {
1025
+ return line.substring(remotePrefix.length);
1026
+ }
1027
+ return line;
1028
+ });
1029
+
1030
+ return { branches };
1031
+ }
1032
+
1033
+ /**
1034
+ * Validate a git ref (branch name, tag, or commit hash)
1035
+ * Rejects refs that could be interpreted as flags
1036
+ */
1037
+ private sanitizeRef(ref: string): string {
1038
+ if (!ref || typeof ref !== 'string') {
1039
+ throw createRPCError(ErrorCode.INVALID_PARAMS, 'Invalid ref: must be a non-empty string');
1040
+ }
1041
+ if (ref.startsWith('-')) {
1042
+ throw createRPCError(ErrorCode.INVALID_PARAMS, 'Invalid ref: cannot start with dash');
1043
+ }
1044
+ if (/[\x00-\x1F\x7F]/.test(ref)) {
1045
+ throw createRPCError(ErrorCode.INVALID_PARAMS, 'Invalid ref: contains control characters');
1046
+ }
1047
+ return ref;
1048
+ }
1049
+
1050
+ /**
1051
+ * Checkout branch or commit
1052
+ */
1053
+ private async checkout(dir: string, params: any): Promise<{ success: boolean }> {
1054
+ const ref = this.sanitizeRef(params.ref);
1055
+ const args = ['checkout'];
1056
+ if (params.force) {
1057
+ args.push('--force');
1058
+ }
1059
+ args.push(ref);
1060
+
1061
+ await this.execGit(args, { cwd: dir });
1062
+
1063
+ return { success: true };
1064
+ }
1065
+
1066
+ /**
1067
+ * Initialize repository
1068
+ */
1069
+ private async init(dir: string, params: any): Promise<{ success: boolean }> {
1070
+ const args = ['init'];
1071
+ if (params.defaultBranch) {
1072
+ args.push('--initial-branch', params.defaultBranch);
1073
+ }
1074
+
1075
+ await this.execGit(args, { cwd: dir });
1076
+ return { success: true };
1077
+ }
1078
+
1079
+ /**
1080
+ * List git remotes
1081
+ */
1082
+ private async listRemotes(dir: string, _params: any): Promise<any> {
1083
+ try {
1084
+ const { stdout } = await this.execGit(['remote', '-v'], { cwd: dir });
1085
+ const remotes: Array<{ remote: string; url: string }> = [];
1086
+
1087
+ const lines = stdout.split('\n').filter(l => l.trim());
1088
+ const seen = new Set<string>();
1089
+
1090
+ for (const line of lines) {
1091
+ const match = line.match(/^(\S+)\s+(\S+)\s+\(fetch\)$/);
1092
+ if (match && !seen.has(match[1])) {
1093
+ remotes.push({
1094
+ remote: match[1],
1095
+ url: match[2]
1096
+ });
1097
+ seen.add(match[1]);
1098
+ }
1099
+ }
1100
+
1101
+ return { remotes };
1102
+ } catch (error) {
1103
+ return { remotes: [] };
1104
+ }
1105
+ }
1106
+
1107
+ /**
1108
+ * Add git remote
1109
+ */
1110
+ private async addRemote(dir: string, params: any): Promise<{ success: boolean }> {
1111
+ const { remote, url } = params;
1112
+ await this.execGit(['remote', 'add', remote, url], { cwd: dir });
1113
+ return { success: true };
1114
+ }
1115
+
1116
+ /**
1117
+ * Delete git remote
1118
+ */
1119
+ private async deleteRemote(dir: string, params: any): Promise<{ success: boolean }> {
1120
+ const { remote } = params;
1121
+ await this.execGit(['remote', 'remove', remote], { cwd: dir });
1122
+ return { success: true };
1123
+ }
1124
+
1125
+ /**
1126
+ * Clear git index (remove cached files)
1127
+ */
1128
+ private async clearIndex(dir: string, _params: any): Promise<{ success: boolean }> {
1129
+ try {
1130
+ await this.execGit(['rm', '-r', '--cached', '-f', '.'], { cwd: dir });
1131
+ return { success: true };
1132
+ } catch (error) {
1133
+ // If no files in index, this is fine
1134
+ return { success: true };
1135
+ }
1136
+ }
1137
+
1138
+ /**
1139
+ * Find which submodule a filepath belongs to (if any).
1140
+ * Returns the submodule path and the filepath relative to the submodule root.
1141
+ */
1142
+ private findSubmoduleForPath(
1143
+ filepath: string,
1144
+ submodules: Array<{ name: string; path: string }>
1145
+ ): { submodulePath: string; relativePath: string } | null {
1146
+ for (const sub of submodules) {
1147
+ if (filepath === sub.path || filepath.startsWith(sub.path + '/')) {
1148
+ const relativePath = filepath === sub.path ? '.' : filepath.slice(sub.path.length + 1);
1149
+ return { submodulePath: sub.path, relativePath };
1150
+ }
1151
+ }
1152
+ return null;
1153
+ }
1154
+
1155
+ /**
1156
+ * Check if file is ignored by .gitignore
1157
+ * Handles files inside submodules by running check-ignore from the submodule dir
1158
+ */
1159
+ private async isIgnored(dir: string, params: any): Promise<boolean> {
1160
+ const { filepath } = params;
1161
+
1162
+ // Check if filepath is inside a submodule
1163
+ const submodules = await this.getCachedSubmodules(dir);
1164
+ if (submodules.length > 0) {
1165
+ const match = this.findSubmoduleForPath(filepath, submodules);
1166
+ if (match) {
1167
+ const subDir = path.join(dir, match.submodulePath);
1168
+ // Exit code 0 = ignored, exit code 1 = not ignored
1169
+ const result = await this.execGit(['check-ignore', match.relativePath], { cwd: subDir, acceptExitCodes: [1] });
1170
+ return result.code === 0;
1171
+ }
1172
+ }
1173
+
1174
+ // Exit code 0 = ignored, exit code 1 = not ignored
1175
+ const result = await this.execGit(['check-ignore', filepath], { cwd: dir, acceptExitCodes: [1] });
1176
+ return result.code === 0;
1177
+ }
1178
+
1179
+ /**
1180
+ * Run git check-ignore for a list of paths in a single directory.
1181
+ * Returns a Set of ignored paths.
1182
+ */
1183
+ private async checkIgnorePaths(cwd: string, filepaths: string[]): Promise<Set<string>> {
1184
+ if (filepaths.length === 0) return new Set();
1185
+ // Exit code 1 means none are ignored - this is expected
1186
+ const result = await this.execGit(['check-ignore', '--stdin', '-z'], {
1187
+ cwd,
1188
+ input: filepaths.join('\0') + '\0',
1189
+ acceptExitCodes: [1]
1190
+ });
1191
+ return new Set(result.stdout.split('\0').filter(p => p.length > 0));
1192
+ }
1193
+
1194
+ /**
1195
+ * Check multiple files if they are ignored by .gitignore
1196
+ * Returns array of 1 (ignored) or 0 (not ignored) for bandwidth efficiency
1197
+ * Handles files inside submodules by routing check-ignore to the submodule dir
1198
+ */
1199
+ private async bulkIsIgnored(dir: string, params: any): Promise<number[]> {
1200
+ const { filepaths } = params;
1201
+ if (!Array.isArray(filepaths)) {
1202
+ throw createRPCError(ErrorCode.INVALID_PARAMS, 'filepaths must be an array');
1203
+ }
1204
+
1205
+ if (filepaths.length === 0) {
1206
+ return [];
1207
+ }
1208
+
1209
+ // Check for submodules to handle paths inside them
1210
+ const submodules = await this.getCachedSubmodules(dir);
1211
+
1212
+ if (submodules.length === 0) {
1213
+ // No submodules, check all paths in the main repo
1214
+ const ignoredPaths = await this.checkIgnorePaths(dir, filepaths);
1215
+ return filepaths.map(fp => ignoredPaths.has(fp) ? 1 : 0);
1216
+ }
1217
+
1218
+ // Group paths by main repo vs submodule
1219
+ const mainPaths: Array<{ index: number; filepath: string }> = [];
1220
+ const submoduleGroups: Map<string, Array<{ index: number; relativePath: string }>> = new Map();
1221
+
1222
+ for (let i = 0; i < filepaths.length; i++) {
1223
+ const fp = filepaths[i];
1224
+ const match = this.findSubmoduleForPath(fp, submodules);
1225
+ if (match) {
1226
+ let group = submoduleGroups.get(match.submodulePath);
1227
+ if (!group) {
1228
+ group = [];
1229
+ submoduleGroups.set(match.submodulePath, group);
1230
+ }
1231
+ group.push({ index: i, relativePath: match.relativePath });
1232
+ } else {
1233
+ mainPaths.push({ index: i, filepath: fp });
1234
+ }
1235
+ }
1236
+
1237
+ const results = Array.from({ length: filepaths.length }).fill(0) as number[];
1238
+ const promises: Promise<void>[] = [];
1239
+
1240
+ // Check main repo paths
1241
+ if (mainPaths.length > 0) {
1242
+ const mainFilepaths = mainPaths.map(m => m.filepath);
1243
+ promises.push(
1244
+ this.checkIgnorePaths(dir, mainFilepaths).then(ignoredPaths => {
1245
+ for (let i = 0; i < mainPaths.length; i++) {
1246
+ results[mainPaths[i].index] = ignoredPaths.has(mainPaths[i].filepath) ? 1 : 0;
1247
+ }
1248
+ })
1249
+ );
1250
+ }
1251
+
1252
+ // Check each submodule's paths from its own directory
1253
+ for (const [submodulePath, group] of submoduleGroups) {
1254
+ const subDir = path.join(dir, submodulePath);
1255
+ const subFilepaths = group.map(g => g.relativePath);
1256
+ promises.push(
1257
+ this.checkIgnorePaths(subDir, subFilepaths).then(ignoredPaths => {
1258
+ for (let i = 0; i < group.length; i++) {
1259
+ results[group[i].index] = ignoredPaths.has(group[i].relativePath) ? 1 : 0;
1260
+ }
1261
+ })
1262
+ );
1263
+ }
1264
+
1265
+ await Promise.all(promises);
1266
+ return results;
1267
+ }
1268
+
1269
+ /**
1270
+ * Check if directory is a git repository
1271
+ * Uses git rev-parse --is-inside-work-tree
1272
+ */
1273
+ private async isInitialized(dir: string, _params: any): Promise<boolean> {
1274
+ try {
1275
+ const { stdout } = await this.execGit(['rev-parse', '--is-inside-work-tree'], { cwd: dir });
1276
+ // Exit code 0 and stdout "true" means it's a git repository
1277
+ return stdout.trim() === 'true';
1278
+ } catch (error) {
1279
+ // Exit code 128 means not a git repository
1280
+ return false;
1281
+ }
1282
+ }
1283
+
1284
+ /**
1285
+ * List git submodules
1286
+ * Uses git submodule status to detect submodules
1287
+ * Note: always runs from the main repo dir (not gitCwd)
1288
+ */
1289
+ private async listSubmodules(dir: string): Promise<{ submodules: Array<{ name: string; path: string }> }> {
1290
+ try {
1291
+ const { stdout } = await this.execGit(['submodule', 'status'], { cwd: dir });
1292
+ const submodules: Array<{ name: string; path: string }> = [];
1293
+ const lines = stdout.split('\n').filter(l => l.trim());
1294
+ for (const line of lines) {
1295
+ // Format: " <sha1> <path> (<describe>)" or "-<sha1> <path>" (uninitialized)
1296
+ const match = line.match(/^[\s+-]?([a-f0-9]+)\s+(\S+)/);
1297
+ if (match) {
1298
+ const subPath = match[2];
1299
+ submodules.push({ name: subPath, path: subPath });
1300
+ }
1301
+ }
1302
+ return { submodules };
1303
+ } catch (error) {
1304
+ return { submodules: [] };
1305
+ }
1306
+ }
1307
+
1308
+ /**
1309
+ * Check if a file exists in HEAD commit
1310
+ * Used to determine if a renamed file is truly new or was previously committed
1311
+ */
1312
+ private async isFileInHead(dir: string, filepath: string): Promise<boolean> {
1313
+ try {
1314
+ // Use git ls-tree to check if file exists in HEAD
1315
+ // This is faster than checking out or reading the entire tree
1316
+ const { stdout } = await this.execGit(['ls-tree', 'HEAD', '--', filepath], { cwd: dir });
1317
+ return stdout.trim().length > 0;
1318
+ } catch (error) {
1319
+ // File doesn't exist in HEAD or no commits yet
1320
+ return false;
1321
+ }
1322
+ }
1323
+
1324
+ /**
1325
+ * Get default author from git config
1326
+ */
1327
+ private async getDefaultAuthor(dir: string): Promise<{ name: string; email: string }> {
1328
+ let name = '';
1329
+ let email = '';
1330
+ try {
1331
+ const nameResult = await this.execGit(['config', 'user.name'], { cwd: dir, acceptExitCodes: [1] });
1332
+ if (nameResult.code === 0) name = nameResult.stdout.trim();
1333
+ } catch (_) { /* ignore */ }
1334
+ try {
1335
+ const emailResult = await this.execGit(['config', 'user.email'], { cwd: dir, acceptExitCodes: [1] });
1336
+ if (emailResult.code === 0) email = emailResult.stdout.trim();
1337
+ } catch (_) { /* ignore */ }
1338
+ return { name, email };
1339
+ }
1340
+
1341
+ /**
1342
+ * Push to remote
1343
+ */
1344
+ private async push(dir: string, params: any, socket: AuthenticatedSocket): Promise<{ success: boolean }> {
1345
+ const remote = params.remote || 'origin';
1346
+ const args = ['push', remote];
1347
+
1348
+ if (params.remoteRef) {
1349
+ // Get current branch for the local side of the refspec
1350
+ const { stdout } = await this.execGit(['symbolic-ref', '--short', 'HEAD'], { cwd: dir });
1351
+ const currentBranch = stdout.trim();
1352
+ args.push(`${currentBranch}:${params.remoteRef}`);
1353
+ }
1354
+
1355
+ const { env: askpassEnv, cleanup } = await this.setupAskpass(socket);
1356
+ try {
1357
+ await this.execGit(args, { cwd: dir, env: askpassEnv });
1358
+ } finally {
1359
+ cleanup();
1360
+ }
1361
+
1362
+ // Send change notification
1363
+ socket.broadcast.emit('rpc', {
1364
+ jsonrpc: '2.0',
1365
+ method: 'git.changed',
1366
+ params: { dir },
1367
+ });
1368
+
1369
+ return { success: true };
1370
+ }
1371
+
1372
+ /**
1373
+ * Pull from remote
1374
+ */
1375
+ private async pull(dir: string, params: any, socket: AuthenticatedSocket): Promise<any> {
1376
+ const remote = params.remote || 'origin';
1377
+ const args = ['pull'];
1378
+
1379
+ if (params.fastForwardOnly) {
1380
+ args.push('--ff-only');
1381
+ }
1382
+
1383
+ args.push(remote);
1384
+
1385
+ if (params.ref) {
1386
+ args.push(this.sanitizeRef(params.ref));
1387
+ }
1388
+
1389
+ const env: Record<string, string> = {};
1390
+ if (params.author) {
1391
+ env.GIT_AUTHOR_NAME = params.author.name;
1392
+ env.GIT_AUTHOR_EMAIL = params.author.email;
1393
+ env.GIT_COMMITTER_NAME = params.author.name;
1394
+ env.GIT_COMMITTER_EMAIL = params.author.email;
1395
+ }
1396
+
1397
+ const { env: askpassEnv, cleanup } = await this.setupAskpass(socket);
1398
+ Object.assign(env, askpassEnv);
1399
+
1400
+ let result;
1401
+ try {
1402
+ result = await this.execGit(args, { cwd: dir, env });
1403
+ } finally {
1404
+ cleanup();
1405
+ }
1406
+ const output = result.stdout + result.stderr;
1407
+
1408
+ // Parse output to determine merge type
1409
+ const alreadyMerged = output.includes('Already up to date') || output.includes('Already up-to-date');
1410
+ const fastForward = output.includes('Fast-forward');
1411
+ const recursiveMerge = !alreadyMerged && !fastForward;
1412
+
1413
+ // Send change notification
1414
+ socket.broadcast.emit('rpc', {
1415
+ jsonrpc: '2.0',
1416
+ method: 'git.changed',
1417
+ params: { dir },
1418
+ });
1419
+
1420
+ return {
1421
+ alreadyMerged,
1422
+ fastForward,
1423
+ recursiveMerge,
1424
+ mergeCommit: recursiveMerge ? true : undefined,
1425
+ };
1426
+ }
1427
+
1428
+ /**
1429
+ * Fetch from remote
1430
+ */
1431
+ private async fetch(dir: string, params: any, socket: AuthenticatedSocket): Promise<{ success: boolean }> {
1432
+ const remote = params.remote || 'origin';
1433
+
1434
+ const { env: askpassEnv, cleanup } = await this.setupAskpass(socket);
1435
+ try {
1436
+ await this.execGit(['fetch', remote], { cwd: dir, env: askpassEnv });
1437
+ } finally {
1438
+ cleanup();
1439
+ }
1440
+
1441
+ // Send change notification
1442
+ socket.broadcast.emit('rpc', {
1443
+ jsonrpc: '2.0',
1444
+ method: 'git.changed',
1445
+ params: { dir },
1446
+ });
1447
+
1448
+ return { success: true };
1449
+ }
1450
+
1451
+ /**
1452
+ * Clone a repository
1453
+ */
1454
+ private async clone(dir: string, params: any, socket: AuthenticatedSocket): Promise<{ success: boolean }> {
1455
+ // Validate clone URL - reject dangerous transports
1456
+ const url = params.url;
1457
+ if (!url || typeof url !== 'string') {
1458
+ throw createRPCError(ErrorCode.INVALID_PARAMS, 'Clone URL is required');
1459
+ }
1460
+ if (/^ext::/i.test(url)) {
1461
+ throw createRPCError(ErrorCode.INVALID_PARAMS, 'ext:: transport is not allowed');
1462
+ }
1463
+
1464
+ const args = ['clone', url, '.'];
1465
+
1466
+ if (params.ref) {
1467
+ args.push('--branch', this.sanitizeRef(params.ref));
1468
+ }
1469
+
1470
+ if (params.depth) {
1471
+ args.push('--depth', String(params.depth));
1472
+ }
1473
+
1474
+ if (params.singleBranch) {
1475
+ args.push('--single-branch');
1476
+ }
1477
+
1478
+ const { env: askpassEnv, cleanup } = await this.setupAskpass(socket);
1479
+ try {
1480
+ await this.execGit(args, { cwd: dir, env: askpassEnv });
1481
+ } finally {
1482
+ cleanup();
1483
+ }
1484
+
1485
+ // Send change notification
1486
+ socket.broadcast.emit('rpc', {
1487
+ jsonrpc: '2.0',
1488
+ method: 'git.changed',
1489
+ params: { dir },
1490
+ });
1491
+
1492
+ return { success: true };
1493
+ }
1494
+
1495
+ /**
1496
+ * Set up an askpass mechanism for SSH/Git credential prompts.
1497
+ * Creates a temporary HTTP server and shell script that SSH_ASKPASS/GIT_ASKPASS
1498
+ * points to. When SSH or git needs a passphrase/password, it calls the script,
1499
+ * which forwards the prompt to the client via requestAuth.
1500
+ */
1501
+ private async setupAskpass(socket: AuthenticatedSocket): Promise<{
1502
+ env: Record<string, string>;
1503
+ cleanup: () => void;
1504
+ }> {
1505
+ return new Promise((resolve, reject) => {
1506
+ const server = http.createServer((req, res) => {
1507
+ let body = '';
1508
+ req.on('data', (chunk: Buffer) => { body += chunk.toString(); });
1509
+ req.on('end', async () => {
1510
+ try {
1511
+ const prompt = body || 'Password:';
1512
+ const result = await this.requestAuth(socket, {
1513
+ prompt,
1514
+ type: 'askpass'
1515
+ });
1516
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
1517
+ res.end(result?.password || '');
1518
+ } catch (err) {
1519
+ // Auth was denied or timed out
1520
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
1521
+ res.end('');
1522
+ }
1523
+ });
1524
+ });
1525
+
1526
+ // Don't prevent Node.js from exiting
1527
+ server.unref();
1528
+
1529
+ server.listen(0, '127.0.0.1', () => {
1530
+ const addr = server.address() as { port: number };
1531
+ const port = addr.port;
1532
+ const useCurl = this.hasCurl();
1533
+ const isWindows = process.platform === 'win32';
1534
+ const filesToClean: string[] = [];
1535
+
1536
+ let scriptPath: string;
1537
+
1538
+ if (useCurl) {
1539
+ // Use curl directly — available on macOS, most Linux, and newer Windows
1540
+ if (isWindows) {
1541
+ scriptPath = path.join(os.tmpdir(), `spck-askpass-${crypto.randomBytes(16).toString('hex')}.cmd`);
1542
+ fs.writeFileSync(scriptPath,
1543
+ `@curl -s --max-time 0 -X POST -d "%~1" "http://127.0.0.1:${port}/askpass" 2>nul\r\n`
1544
+ );
1545
+ } else {
1546
+ scriptPath = path.join(os.tmpdir(), `spck-askpass-${crypto.randomBytes(16).toString('hex')}.sh`);
1547
+ fs.writeFileSync(scriptPath,
1548
+ `#!/bin/sh\ncurl -s --max-time 0 -X POST -d "$1" "http://127.0.0.1:${port}/askpass" 2>/dev/null\n`,
1549
+ { mode: 0o700 }
1550
+ );
1551
+ }
1552
+ filesToClean.push(scriptPath);
1553
+ } else {
1554
+ // Fallback to Node.js script (node is always available since we're running on it)
1555
+ const nodeExe = process.execPath;
1556
+ const jsPath = path.join(os.tmpdir(), `spck-askpass-${crypto.randomBytes(16).toString('hex')}.js`);
1557
+ fs.writeFileSync(jsPath, [
1558
+ `const h=require('http'),d=process.argv[2]||'';`,
1559
+ `const r=h.request({hostname:'127.0.0.1',port:${port},path:'/askpass',method:'POST',` +
1560
+ `headers:{'Content-Length':Buffer.byteLength(d)}},s=>{let b='';s.on('data',c=>b+=c);s.on('end',()=>process.stdout.write(b))});`,
1561
+ `r.write(d);r.end();`,
1562
+ ''
1563
+ ].join('\n'));
1564
+ filesToClean.push(jsPath);
1565
+
1566
+ if (isWindows) {
1567
+ scriptPath = path.join(os.tmpdir(), `spck-askpass-${crypto.randomBytes(16).toString('hex')}.cmd`);
1568
+ fs.writeFileSync(scriptPath,
1569
+ `@"${nodeExe}" "${jsPath}" %1\r\n`
1570
+ );
1571
+ } else {
1572
+ scriptPath = path.join(os.tmpdir(), `spck-askpass-${crypto.randomBytes(16).toString('hex')}.sh`);
1573
+ fs.writeFileSync(scriptPath,
1574
+ `#!/bin/sh\n"${nodeExe}" "${jsPath}" "$1"\n`,
1575
+ { mode: 0o700 }
1576
+ );
1577
+ }
1578
+ filesToClean.push(scriptPath);
1579
+ }
1580
+
1581
+ const cleanup = () => {
1582
+ server.close();
1583
+ for (const f of filesToClean) {
1584
+ try { fs.unlinkSync(f); } catch (_) { /* ignore */ }
1585
+ }
1586
+ };
1587
+
1588
+ resolve({
1589
+ env: {
1590
+ GIT_ASKPASS: scriptPath,
1591
+ SSH_ASKPASS: scriptPath,
1592
+ SSH_ASKPASS_REQUIRE: 'force',
1593
+ DISPLAY: process.env.DISPLAY || ':0',
1594
+ GIT_TERMINAL_PROMPT: '0',
1595
+ },
1596
+ cleanup
1597
+ });
1598
+ });
1599
+
1600
+ server.on('error', reject);
1601
+ });
1602
+ }
1603
+
1604
+ /**
1605
+ * Request authentication from client (server-to-client RPC)
1606
+ */
1607
+ private async requestAuth(socket: AuthenticatedSocket, params: any): Promise<any> {
1608
+ return new Promise((resolve, reject) => {
1609
+ const requestId = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
1610
+
1611
+ // Set up one-time response listener (no timeout - wait indefinitely for user input)
1612
+ const responseHandler = (response: any) => {
1613
+ if (response.id === requestId) {
1614
+ socket.off('rpc', responseHandler);
1615
+ if (response.error) {
1616
+ reject(new Error(response.error.message));
1617
+ } else {
1618
+ resolve(response.result);
1619
+ }
1620
+ }
1621
+ };
1622
+
1623
+ socket.on('rpc', responseHandler);
1624
+
1625
+ // Send request to client
1626
+ socket.emit('rpc', {
1627
+ jsonrpc: '2.0',
1628
+ method: 'git.requestAuth',
1629
+ params: {
1630
+ url: params.url,
1631
+ prompt: params.prompt,
1632
+ type: params.type,
1633
+ attempt: params.attempt || 1,
1634
+ },
1635
+ id: requestId,
1636
+ });
1637
+ });
1638
+ }
1639
+ }