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