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,1552 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ /**
3
+ * Tests for GitService
4
+ */
5
+
6
+ import * as fs from 'fs/promises';
7
+ import * as path from 'path';
8
+ import * as os from 'os';
9
+ import { spawn } from 'child_process';
10
+ import { GitService } from '../GitService.js';
11
+ import { ErrorCode } from '../../types.js';
12
+
13
+ // Helper to execute git commands for test setup
14
+ async function execGit(args: string[], cwd: string): Promise<string> {
15
+ return new Promise((resolve, reject) => {
16
+ const git = spawn('git', args, { cwd });
17
+ let stdout = '';
18
+ let stderr = '';
19
+
20
+ git.stdout.on('data', (data) => (stdout += data.toString()));
21
+ git.stderr.on('data', (data) => (stderr += data.toString()));
22
+
23
+ git.on('close', (code) => {
24
+ if (code !== 0) {
25
+ reject(new Error(`Git failed: ${stderr}`));
26
+ } else {
27
+ resolve(stdout.trim());
28
+ }
29
+ });
30
+ });
31
+ }
32
+
33
+ describe('GitService', () => {
34
+ let service: GitService;
35
+ let testRoot: string;
36
+ let repoPath: string;
37
+ let mockSocket: any;
38
+
39
+ beforeEach(async () => {
40
+ // Create temporary test directory
41
+ testRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'git-test-'));
42
+ repoPath = path.join(testRoot, 'repo');
43
+ await fs.mkdir(repoPath);
44
+
45
+ service = new GitService(testRoot);
46
+
47
+ mockSocket = {
48
+ id: 'test-socket',
49
+ data: { uid: 'test-user', deviceId: 'test-device' },
50
+ emit: vi.fn(),
51
+ on: vi.fn(),
52
+ off: vi.fn(),
53
+ broadcast: {
54
+ emit: vi.fn(),
55
+ },
56
+ };
57
+ });
58
+
59
+ afterEach(async () => {
60
+ // Clean up test directory
61
+ try {
62
+ await fs.rm(testRoot, { recursive: true, force: true });
63
+ } catch {}
64
+ });
65
+
66
+ describe('Path Validation', () => {
67
+ it('should accept valid paths', async () => {
68
+ await execGit(['init'], repoPath);
69
+
70
+ const result = await service.handle(
71
+ 'currentBranch',
72
+ { dir: '/repo' },
73
+ mockSocket
74
+ );
75
+
76
+ // Should not throw error
77
+ expect(result).toBeDefined();
78
+ });
79
+
80
+ it('should prevent directory traversal', async () => {
81
+ await expect(
82
+ service.handle('currentBranch', { dir: '../../../etc' }, mockSocket)
83
+ ).rejects.toMatchObject({
84
+ code: ErrorCode.INVALID_PATH,
85
+ message: expect.stringContaining('directory traversal'),
86
+ });
87
+ });
88
+
89
+ it('should prevent access outside root', async () => {
90
+ // Path validation clamps to root, so the directory just won't exist
91
+ // This will result in a git error rather than a path validation error
92
+ const result = await service.handle(
93
+ 'currentBranch',
94
+ { dir: '/../../../../etc' },
95
+ mockSocket
96
+ );
97
+
98
+ // currentBranch returns null for invalid repos (no error thrown)
99
+ expect(result.branch).toBeNull();
100
+ });
101
+ });
102
+
103
+ describe('Git Operations - init', () => {
104
+ it('should initialize a new repository', async () => {
105
+ const result = await service.handle('init', { dir: '/repo' }, mockSocket);
106
+
107
+ expect(result.success).toBe(true);
108
+
109
+ // Verify .git directory was created
110
+ const gitDir = path.join(repoPath, '.git');
111
+ const stats = await fs.stat(gitDir);
112
+ expect(stats.isDirectory()).toBe(true);
113
+ });
114
+
115
+ it('should initialize with custom default branch', async () => {
116
+ await service.handle(
117
+ 'init',
118
+ { dir: '/repo', defaultBranch: 'main' },
119
+ mockSocket
120
+ );
121
+
122
+ const branch = await execGit(['symbolic-ref', '--short', 'HEAD'], repoPath);
123
+ expect(branch).toBe('main');
124
+ });
125
+ });
126
+
127
+ describe('Git Operations - currentBranch', () => {
128
+ beforeEach(async () => {
129
+ await execGit(['init'], repoPath);
130
+ await execGit(['config', 'user.email', 'test@example.com'], repoPath);
131
+ await execGit(['config', 'user.name', 'Test User'], repoPath);
132
+ await fs.writeFile(path.join(repoPath, 'file.txt'), 'content');
133
+ await execGit(['add', '.'], repoPath);
134
+ await execGit(['commit', '-m', 'Initial commit'], repoPath);
135
+ });
136
+
137
+ it('should return current branch short name', async () => {
138
+ const result = await service.handle(
139
+ 'currentBranch',
140
+ { dir: '/repo' },
141
+ mockSocket
142
+ );
143
+
144
+ expect(result.branch).toBeTruthy();
145
+ expect(result.branch).not.toContain('refs/heads/');
146
+ });
147
+
148
+ it('should return full branch name when fullname is true', async () => {
149
+ const result = await service.handle(
150
+ 'currentBranch',
151
+ { dir: '/repo', fullname: true },
152
+ mockSocket
153
+ );
154
+
155
+ expect(result.branch).toContain('refs/heads/');
156
+ });
157
+
158
+ it('should return null for detached HEAD', async () => {
159
+ // Get commit hash and checkout to detach HEAD
160
+ const oid = await execGit(['rev-parse', 'HEAD'], repoPath);
161
+ await execGit(['checkout', oid], repoPath);
162
+
163
+ const result = await service.handle(
164
+ 'currentBranch',
165
+ { dir: '/repo' },
166
+ mockSocket
167
+ );
168
+
169
+ expect(result.branch).toBeNull();
170
+ });
171
+ });
172
+
173
+ describe('Git Operations - add', () => {
174
+ beforeEach(async () => {
175
+ await execGit(['init'], repoPath);
176
+ await execGit(['config', 'user.email', 'test@example.com'], repoPath);
177
+ await execGit(['config', 'user.name', 'Test User'], repoPath);
178
+ });
179
+
180
+ it('should stage single file', async () => {
181
+ await fs.writeFile(path.join(repoPath, 'new.txt'), 'content');
182
+
183
+ const result = await service.handle(
184
+ 'add',
185
+ { dir: '/repo', filepaths: ['new.txt'] },
186
+ mockSocket
187
+ );
188
+
189
+ expect(result.success).toBe(true);
190
+
191
+ const status = await execGit(['status', '--porcelain'], repoPath);
192
+ expect(status).toContain('A new.txt');
193
+ });
194
+
195
+ it('should stage multiple files', async () => {
196
+ await fs.writeFile(path.join(repoPath, 'file1.txt'), 'content1');
197
+ await fs.writeFile(path.join(repoPath, 'file2.txt'), 'content2');
198
+
199
+ await service.handle(
200
+ 'add',
201
+ { dir: '/repo', filepaths: ['file1.txt', 'file2.txt'] },
202
+ mockSocket
203
+ );
204
+
205
+ const status = await execGit(['status', '--porcelain'], repoPath);
206
+ expect(status).toContain('file1.txt');
207
+ expect(status).toContain('file2.txt');
208
+ });
209
+ });
210
+
211
+ describe('Git Operations - commit', () => {
212
+ beforeEach(async () => {
213
+ await execGit(['init'], repoPath);
214
+ await execGit(['config', 'user.email', 'test@example.com'], repoPath);
215
+ await execGit(['config', 'user.name', 'Test User'], repoPath);
216
+ await fs.writeFile(path.join(repoPath, 'file.txt'), 'content');
217
+ await execGit(['add', '.'], repoPath);
218
+ });
219
+
220
+ it('should create a commit', async () => {
221
+ const result = await service.handle(
222
+ 'commit',
223
+ {
224
+ dir: '/repo',
225
+ message: 'Test commit',
226
+ author: {
227
+ name: 'Test Author',
228
+ email: 'author@example.com',
229
+ },
230
+ },
231
+ mockSocket
232
+ );
233
+
234
+ expect(result.oid).toBeTruthy();
235
+ expect(result.oid).toMatch(/^[0-9a-f]{40}$/);
236
+
237
+ const commitMsg = await execGit(['log', '-1', '--format=%s'], repoPath);
238
+ expect(commitMsg).toBe('Test commit');
239
+ });
240
+
241
+ it('should broadcast change notification', async () => {
242
+ await service.handle(
243
+ 'commit',
244
+ {
245
+ dir: '/repo',
246
+ message: 'Test commit',
247
+ author: {
248
+ name: 'Test Author',
249
+ email: 'author@example.com',
250
+ },
251
+ },
252
+ mockSocket
253
+ );
254
+
255
+ expect(mockSocket.broadcast.emit).toHaveBeenCalledWith(
256
+ 'rpc',
257
+ expect.objectContaining({
258
+ jsonrpc: '2.0',
259
+ method: 'git.changed',
260
+ params: { dir: expect.any(String) },
261
+ })
262
+ );
263
+ });
264
+ });
265
+
266
+ describe('Git Operations - readCommit', () => {
267
+ let commitOid: string;
268
+
269
+ beforeEach(async () => {
270
+ await execGit(['init'], repoPath);
271
+ await execGit(['config', 'user.email', 'test@example.com'], repoPath);
272
+ await execGit(['config', 'user.name', 'Test User'], repoPath);
273
+ await fs.writeFile(path.join(repoPath, 'file.txt'), 'content');
274
+ await execGit(['add', '.'], repoPath);
275
+ await execGit(['commit', '-m', 'Test commit message'], repoPath);
276
+ commitOid = await execGit(['rev-parse', 'HEAD'], repoPath);
277
+ });
278
+
279
+ it('should read commit object', async () => {
280
+ const result = await service.handle(
281
+ 'readCommit',
282
+ { dir: '/repo', oid: commitOid },
283
+ mockSocket
284
+ );
285
+
286
+ expect(result.oid).toBe(commitOid);
287
+ expect(result.commit).toMatchObject({
288
+ message: expect.stringContaining('Test commit message'),
289
+ tree: expect.stringMatching(/^[0-9a-f]{40}$/),
290
+ parent: expect.any(Array),
291
+ author: expect.objectContaining({
292
+ name: expect.any(String),
293
+ email: expect.any(String),
294
+ timestamp: expect.any(Number),
295
+ }),
296
+ committer: expect.objectContaining({
297
+ name: expect.any(String),
298
+ email: expect.any(String),
299
+ timestamp: expect.any(Number),
300
+ }),
301
+ });
302
+ });
303
+
304
+ it('should parse author and committer correctly', async () => {
305
+ const result = await service.handle(
306
+ 'readCommit',
307
+ { dir: '/repo', oid: commitOid },
308
+ mockSocket
309
+ );
310
+
311
+ expect(result.commit.author.name).toBe('Test User');
312
+ expect(result.commit.author.email).toBe('test@example.com');
313
+ expect(result.commit.committer.name).toBe('Test User');
314
+ expect(result.commit.committer.email).toBe('test@example.com');
315
+ });
316
+ });
317
+
318
+ describe('Git Operations - log', () => {
319
+ beforeEach(async () => {
320
+ await execGit(['init'], repoPath);
321
+ await execGit(['config', 'user.email', 'test@example.com'], repoPath);
322
+ await execGit(['config', 'user.name', 'Test User'], repoPath);
323
+
324
+ // Create multiple commits
325
+ for (let i = 1; i <= 3; i++) {
326
+ await fs.writeFile(path.join(repoPath, `file${i}.txt`), `content${i}`);
327
+ await execGit(['add', '.'], repoPath);
328
+ await execGit(['commit', '-m', `Commit ${i}`], repoPath);
329
+ }
330
+ });
331
+
332
+ it('should return commit history', async () => {
333
+ const result = await service.handle('log', { dir: '/repo' }, mockSocket);
334
+
335
+ expect(result.commits).toHaveLength(3);
336
+ expect(result.commits[0].commit.message).toContain('Commit 3');
337
+ expect(result.commits[2].commit.message).toContain('Commit 1');
338
+ });
339
+
340
+ it('should limit history with depth parameter', async () => {
341
+ const result = await service.handle(
342
+ 'log',
343
+ { dir: '/repo', depth: 2 },
344
+ mockSocket
345
+ );
346
+
347
+ expect(result.commits).toHaveLength(2);
348
+ });
349
+
350
+ it('should return commits in reverse chronological order', async () => {
351
+ const result = await service.handle('log', { dir: '/repo' }, mockSocket);
352
+
353
+ const messages = result.commits.map((c: any) => c.commit.message.trim());
354
+ expect(messages).toEqual(['Commit 3', 'Commit 2', 'Commit 1']);
355
+ });
356
+ });
357
+
358
+ describe('Git Operations - status', () => {
359
+ beforeEach(async () => {
360
+ await execGit(['init'], repoPath);
361
+ await execGit(['config', 'user.email', 'test@example.com'], repoPath);
362
+ await execGit(['config', 'user.name', 'Test User'], repoPath);
363
+ await fs.writeFile(path.join(repoPath, 'committed.txt'), 'content');
364
+ await execGit(['add', '.'], repoPath);
365
+ await execGit(['commit', '-m', 'Initial'], repoPath);
366
+ });
367
+
368
+ it('should return empty array for clean working directory', async () => {
369
+ const result = await service.handle(
370
+ 'status',
371
+ { dir: '/repo' },
372
+ mockSocket
373
+ );
374
+
375
+ expect(result).toEqual([]);
376
+ });
377
+
378
+ it('should show modified files in status', async () => {
379
+ await fs.writeFile(path.join(repoPath, 'committed.txt'), 'modified');
380
+
381
+ const result = await service.handle(
382
+ 'status',
383
+ { dir: '/repo' },
384
+ mockSocket
385
+ );
386
+
387
+ expect(result.length).toBeGreaterThan(0);
388
+ expect(result[0].path).toBe('committed.txt');
389
+ expect(result[0].status).toBeDefined();
390
+ });
391
+
392
+ it('should show new untracked files', async () => {
393
+ await fs.writeFile(path.join(repoPath, 'new.txt'), 'content');
394
+
395
+ const result = await service.handle(
396
+ 'status',
397
+ { dir: '/repo' },
398
+ mockSocket
399
+ );
400
+
401
+ expect(result.some((item: any) => item.path === 'new.txt')).toBe(true);
402
+ });
403
+ });
404
+
405
+ describe('Git Operations - listBranches', () => {
406
+ beforeEach(async () => {
407
+ await execGit(['init'], repoPath);
408
+ await execGit(['config', 'user.email', 'test@example.com'], repoPath);
409
+ await execGit(['config', 'user.name', 'Test User'], repoPath);
410
+ await fs.writeFile(path.join(repoPath, 'file.txt'), 'content');
411
+ await execGit(['add', '.'], repoPath);
412
+ await execGit(['commit', '-m', 'Initial'], repoPath);
413
+ });
414
+
415
+ it('should list local branches', async () => {
416
+ await execGit(['checkout', '-b', 'feature'], repoPath);
417
+
418
+ const result = await service.handle(
419
+ 'listBranches',
420
+ { dir: '/repo' },
421
+ mockSocket
422
+ );
423
+
424
+ expect(result.branches).toContain('feature');
425
+ });
426
+
427
+ it('should filter out empty lines', async () => {
428
+ const result = await service.handle(
429
+ 'listBranches',
430
+ { dir: '/repo' },
431
+ mockSocket
432
+ );
433
+
434
+ expect(result.branches.every((b: string) => b.length > 0)).toBe(true);
435
+ });
436
+ });
437
+
438
+ describe('Git Operations - checkout', () => {
439
+ beforeEach(async () => {
440
+ await execGit(['init'], repoPath);
441
+ await execGit(['config', 'user.email', 'test@example.com'], repoPath);
442
+ await execGit(['config', 'user.name', 'Test User'], repoPath);
443
+ await fs.writeFile(path.join(repoPath, 'file.txt'), 'content');
444
+ await execGit(['add', '.'], repoPath);
445
+ await execGit(['commit', '-m', 'Initial'], repoPath);
446
+ await execGit(['checkout', '-b', 'develop'], repoPath);
447
+ });
448
+
449
+ it('should checkout branch', async () => {
450
+ const defaultBranch = await execGit(['symbolic-ref', '--short', 'HEAD'], repoPath);
451
+
452
+ await service.handle(
453
+ 'checkout',
454
+ { dir: '/repo', ref: defaultBranch },
455
+ mockSocket
456
+ );
457
+
458
+ const currentBranch = await execGit(['symbolic-ref', '--short', 'HEAD'], repoPath);
459
+ expect(currentBranch).toBe(defaultBranch);
460
+ });
461
+
462
+ it('should checkout with force option', async () => {
463
+ // Make uncommitted changes
464
+ await fs.writeFile(path.join(repoPath, 'file.txt'), 'modified');
465
+
466
+ const result = await service.handle(
467
+ 'checkout',
468
+ { dir: '/repo', ref: 'HEAD', force: true },
469
+ mockSocket
470
+ );
471
+
472
+ expect(result.success).toBe(true);
473
+ });
474
+ });
475
+
476
+ describe('Git Operations - requestAuth', () => {
477
+ it('should request authentication from client', async () => {
478
+ const mockResponse = {
479
+ username: 'testuser',
480
+ password: 'testpass',
481
+ };
482
+
483
+ let requestId: number | null = null;
484
+
485
+ // Capture the request ID when emit is called
486
+ mockSocket.emit.mockImplementation((event: string, data: any) => {
487
+ if (event === 'rpc' && data.method === 'git.requestAuth') {
488
+ requestId = data.id;
489
+ }
490
+ });
491
+
492
+ // Set up response handler
493
+ const onHandlers: Function[] = [];
494
+ mockSocket.on.mockImplementation((event: string, handler: Function) => {
495
+ if (event === 'rpc') {
496
+ onHandlers.push(handler);
497
+ }
498
+ });
499
+
500
+ // Start the auth request
501
+ const authPromise = service.handle(
502
+ 'requestAuth',
503
+ { dir: '/repo', url: 'https://github.com/user/repo.git' },
504
+ mockSocket
505
+ );
506
+
507
+ // Wait a tick for the emit to happen
508
+ await new Promise((resolve) => setTimeout(resolve, 50));
509
+
510
+ // Simulate client response
511
+ expect(requestId).not.toBeNull();
512
+ onHandlers.forEach((handler) => {
513
+ handler({
514
+ id: requestId,
515
+ result: mockResponse,
516
+ });
517
+ });
518
+
519
+ const result = await authPromise;
520
+ expect(result).toEqual(mockResponse);
521
+ });
522
+ });
523
+
524
+ describe('Error Handling', () => {
525
+ it('should throw error for unknown git method', async () => {
526
+ await execGit(['init'], repoPath);
527
+
528
+ await expect(
529
+ service.handle('unknownMethod', { dir: '/repo' }, mockSocket)
530
+ ).rejects.toMatchObject({
531
+ code: ErrorCode.METHOD_NOT_FOUND,
532
+ message: expect.stringContaining('Method not found'),
533
+ });
534
+ });
535
+
536
+ it('should wrap git command errors', async () => {
537
+ await execGit(['init'], repoPath);
538
+
539
+ // Try to read non-existent commit
540
+ await expect(
541
+ service.handle(
542
+ 'readCommit',
543
+ { dir: '/repo', oid: '0000000000000000000000000000000000000000' },
544
+ mockSocket
545
+ )
546
+ ).rejects.toMatchObject({
547
+ code: ErrorCode.GIT_OPERATION_FAILED,
548
+ message: expect.stringContaining('Git operation failed'),
549
+ });
550
+ });
551
+
552
+ it('should handle invalid git directories', async () => {
553
+ // currentBranch catches errors and returns null for detached/invalid repos
554
+ const result = await service.handle(
555
+ 'currentBranch',
556
+ { dir: '/nonexistent' },
557
+ mockSocket
558
+ );
559
+
560
+ expect(result.branch).toBeNull();
561
+ });
562
+ });
563
+
564
+ describe('Git Operations - remove', () => {
565
+ beforeEach(async () => {
566
+ await execGit(['init'], repoPath);
567
+ await execGit(['config', 'user.email', 'test@example.com'], repoPath);
568
+ await execGit(['config', 'user.name', 'Test User'], repoPath);
569
+ await fs.writeFile(path.join(repoPath, 'file.txt'), 'content');
570
+ await execGit(['add', '.'], repoPath);
571
+ await execGit(['commit', '-m', 'Initial'], repoPath);
572
+ });
573
+
574
+ it('should remove files from index', async () => {
575
+ await fs.writeFile(path.join(repoPath, 'new.txt'), 'content');
576
+ await execGit(['add', 'new.txt'], repoPath);
577
+
578
+ const result = await service.handle(
579
+ 'remove',
580
+ { dir: '/repo', filepaths: ['new.txt'] },
581
+ mockSocket
582
+ );
583
+
584
+ expect(result.success).toBe(true);
585
+
586
+ const status = await execGit(['status', '--porcelain'], repoPath);
587
+ expect(status).not.toContain('A new.txt');
588
+ expect(status).toContain('?? new.txt');
589
+ });
590
+
591
+ it('should broadcast change notification', async () => {
592
+ await fs.writeFile(path.join(repoPath, 'new.txt'), 'content');
593
+ await execGit(['add', 'new.txt'], repoPath);
594
+
595
+ await service.handle(
596
+ 'remove',
597
+ { dir: '/repo', filepaths: ['new.txt'] },
598
+ mockSocket
599
+ );
600
+
601
+ expect(mockSocket.broadcast.emit).toHaveBeenCalledWith(
602
+ 'rpc',
603
+ expect.objectContaining({
604
+ jsonrpc: '2.0',
605
+ method: 'git.changed',
606
+ params: { dir: expect.any(String) },
607
+ })
608
+ );
609
+ });
610
+ });
611
+
612
+ describe('Git Operations - resetIndex', () => {
613
+ beforeEach(async () => {
614
+ await execGit(['init'], repoPath);
615
+ await execGit(['config', 'user.email', 'test@example.com'], repoPath);
616
+ await execGit(['config', 'user.name', 'Test User'], repoPath);
617
+ await fs.writeFile(path.join(repoPath, 'file.txt'), 'original');
618
+ await execGit(['add', '.'], repoPath);
619
+ await execGit(['commit', '-m', 'Initial'], repoPath);
620
+ });
621
+
622
+ it('should reset modified file to HEAD', async () => {
623
+ await fs.writeFile(path.join(repoPath, 'file.txt'), 'modified');
624
+ await execGit(['add', 'file.txt'], repoPath);
625
+
626
+ const result = await service.handle(
627
+ 'resetIndex',
628
+ { dir: '/repo', filepath: 'file.txt' },
629
+ mockSocket
630
+ );
631
+
632
+ expect(result.success).toBe(true);
633
+
634
+ const status = await execGit(['status', '--porcelain'], repoPath);
635
+ // After reset, file should be unstaged (not "M " which is staged)
636
+ expect(status).not.toContain('M file.txt');
637
+ // File should show as modified in working directory
638
+ expect(status).toContain('file.txt');
639
+ });
640
+
641
+ it('should reset new file (remove from index)', async () => {
642
+ await fs.writeFile(path.join(repoPath, 'new.txt'), 'content');
643
+ await execGit(['add', 'new.txt'], repoPath);
644
+
645
+ await service.handle(
646
+ 'resetIndex',
647
+ { dir: '/repo', filepath: 'new.txt' },
648
+ mockSocket
649
+ );
650
+
651
+ const status = await execGit(['status', '--porcelain'], repoPath);
652
+ expect(status).not.toContain('A new.txt');
653
+ expect(status).toContain('?? new.txt');
654
+ });
655
+
656
+ it('should use custom ref when provided', async () => {
657
+ // Create a second commit
658
+ await fs.writeFile(path.join(repoPath, 'file.txt'), 'second version');
659
+ await execGit(['add', '.'], repoPath);
660
+ await execGit(['commit', '-m', 'Second'], repoPath);
661
+
662
+ // Modify and stage
663
+ await fs.writeFile(path.join(repoPath, 'file.txt'), 'modified');
664
+ await execGit(['add', 'file.txt'], repoPath);
665
+
666
+ // Reset to HEAD~1 (first commit)
667
+ await service.handle(
668
+ 'resetIndex',
669
+ { dir: '/repo', filepath: 'file.txt', ref: 'HEAD~1' },
670
+ mockSocket
671
+ );
672
+
673
+ const result = await service.handle(
674
+ 'resetIndex',
675
+ { dir: '/repo', filepath: 'file.txt' },
676
+ mockSocket
677
+ );
678
+
679
+ expect(result.success).toBe(true);
680
+ });
681
+
682
+ it('should broadcast change notification', async () => {
683
+ await fs.writeFile(path.join(repoPath, 'file.txt'), 'modified');
684
+ await execGit(['add', 'file.txt'], repoPath);
685
+
686
+ await service.handle(
687
+ 'resetIndex',
688
+ { dir: '/repo', filepath: 'file.txt' },
689
+ mockSocket
690
+ );
691
+
692
+ expect(mockSocket.broadcast.emit).toHaveBeenCalledWith(
693
+ 'rpc',
694
+ expect.objectContaining({
695
+ jsonrpc: '2.0',
696
+ method: 'git.changed',
697
+ params: { dir: expect.any(String) },
698
+ })
699
+ );
700
+ });
701
+ });
702
+
703
+ describe('Git Operations - getConfig and setConfig', () => {
704
+ beforeEach(async () => {
705
+ await execGit(['init'], repoPath);
706
+ await execGit(['config', 'user.email', 'test@example.com'], repoPath);
707
+ await execGit(['config', 'user.name', 'Test User'], repoPath);
708
+ });
709
+
710
+ it('should get existing config value', async () => {
711
+ const result = await service.handle(
712
+ 'getConfig',
713
+ { dir: '/repo', path: 'user.name' },
714
+ mockSocket
715
+ );
716
+
717
+ expect(result.value).toBe('Test User');
718
+ });
719
+
720
+ it('should return undefined for non-existent config key', async () => {
721
+ const result = await service.handle(
722
+ 'getConfig',
723
+ { dir: '/repo', path: 'core.pager' },
724
+ mockSocket
725
+ );
726
+
727
+ expect(result.value).toBeUndefined();
728
+ });
729
+
730
+ it('should set config value', async () => {
731
+ await service.handle(
732
+ 'setConfig',
733
+ { dir: '/repo', path: 'user.name', value: 'New Name' },
734
+ mockSocket
735
+ );
736
+
737
+ const result = await execGit(['config', '--get', 'user.name'], repoPath);
738
+ expect(result).toBe('New Name');
739
+ });
740
+
741
+ it('should set boolean config value', async () => {
742
+ await service.handle(
743
+ 'setConfig',
744
+ { dir: '/repo', path: 'core.bare', value: true },
745
+ mockSocket
746
+ );
747
+
748
+ const result = await execGit(['config', '--get', 'core.bare'], repoPath);
749
+ expect(result).toBe('true');
750
+ });
751
+
752
+ it('should set number config value', async () => {
753
+ await service.handle(
754
+ 'setConfig',
755
+ { dir: '/repo', path: 'core.compression', value: 5 },
756
+ mockSocket
757
+ );
758
+
759
+ const result = await execGit(['config', '--get', 'core.compression'], repoPath);
760
+ expect(result).toBe('5');
761
+ });
762
+
763
+ it('should unset config value when value is undefined', async () => {
764
+ await execGit(['config', 'core.pager', 'less'], repoPath);
765
+
766
+ await service.handle(
767
+ 'setConfig',
768
+ { dir: '/repo', path: 'core.pager', value: undefined },
769
+ mockSocket
770
+ );
771
+
772
+ const result = await service.handle(
773
+ 'getConfig',
774
+ { dir: '/repo', path: 'core.pager' },
775
+ mockSocket
776
+ );
777
+
778
+ expect(result.value).toBeUndefined();
779
+ });
780
+
781
+ it('should append config value when append is true', async () => {
782
+ await execGit(['config', 'remote.origin.fetch', 'value1'], repoPath);
783
+
784
+ await service.handle(
785
+ 'setConfig',
786
+ { dir: '/repo', path: 'remote.origin.fetch', value: 'value2', append: true },
787
+ mockSocket
788
+ );
789
+
790
+ // Get all values
791
+ const result = await execGit(['config', '--get-all', 'remote.origin.fetch'], repoPath);
792
+ expect(result).toContain('value1');
793
+ expect(result).toContain('value2');
794
+ });
795
+
796
+ it('should handle unsetting non-existent key gracefully', async () => {
797
+ const result = await service.handle(
798
+ 'setConfig',
799
+ { dir: '/repo', path: 'core.pager', value: undefined },
800
+ mockSocket
801
+ );
802
+
803
+ expect(result.success).toBe(true);
804
+ });
805
+ });
806
+
807
+ describe('Commit Parsing', () => {
808
+ it('should handle commits with multiple parents (merge commits)', async () => {
809
+ await execGit(['init'], repoPath);
810
+ await execGit(['config', 'user.email', 'test@example.com'], repoPath);
811
+ await execGit(['config', 'user.name', 'Test User'], repoPath);
812
+
813
+ // Create initial commit on default branch
814
+ await fs.writeFile(path.join(repoPath, 'file.txt'), 'base');
815
+ await execGit(['add', '.'], repoPath);
816
+ await execGit(['commit', '-m', 'Initial'], repoPath);
817
+ const defaultBranch = await execGit(['symbolic-ref', '--short', 'HEAD'], repoPath);
818
+
819
+ // Create a commit on the default branch to prevent fast-forward
820
+ await fs.writeFile(path.join(repoPath, 'base.txt'), 'content');
821
+ await execGit(['add', '.'], repoPath);
822
+ await execGit(['commit', '-m', 'Base commit'], repoPath);
823
+
824
+ // Create branch from first commit
825
+ const firstCommit = await execGit(['rev-list', '--max-parents=0', 'HEAD'], repoPath);
826
+ await execGit(['checkout', '-b', 'feature', firstCommit], repoPath);
827
+ await fs.writeFile(path.join(repoPath, 'feature.txt'), 'feature');
828
+ await execGit(['add', '.'], repoPath);
829
+ await execGit(['commit', '-m', 'Feature'], repoPath);
830
+
831
+ // Merge back to create a true merge commit
832
+ await execGit(['checkout', defaultBranch], repoPath);
833
+ await execGit(['merge', 'feature', '--no-ff', '-m', 'Merge feature'], repoPath);
834
+
835
+ const mergeOid = await execGit(['rev-parse', 'HEAD'], repoPath);
836
+ const result = await service.handle(
837
+ 'readCommit',
838
+ { dir: '/repo', oid: mergeOid },
839
+ mockSocket
840
+ );
841
+
842
+ // Should have 2 parents (merge commit)
843
+ expect(result.commit.parent.length).toBeGreaterThanOrEqual(2);
844
+ });
845
+
846
+ it('should handle multiline commit messages', async () => {
847
+ await execGit(['init'], repoPath);
848
+ await execGit(['config', 'user.email', 'test@example.com'], repoPath);
849
+ await execGit(['config', 'user.name', 'Test User'], repoPath);
850
+ await fs.writeFile(path.join(repoPath, 'file.txt'), 'content');
851
+ await execGit(['add', '.'], repoPath);
852
+
853
+ const multilineMessage = 'First line\n\nSecond paragraph\nThird line';
854
+ await execGit(['commit', '-m', multilineMessage], repoPath);
855
+
856
+ const oid = await execGit(['rev-parse', 'HEAD'], repoPath);
857
+ const result = await service.handle(
858
+ 'readCommit',
859
+ { dir: '/repo', oid },
860
+ mockSocket
861
+ );
862
+
863
+ expect(result.commit.message.trim()).toBe(multilineMessage);
864
+ });
865
+ });
866
+
867
+ describe('Git Operations - listRemotes', () => {
868
+ it('should return empty array when no remotes', async () => {
869
+ await execGit(['init'], repoPath);
870
+
871
+ const result = await service.handle(
872
+ 'listRemotes',
873
+ { dir: '/repo' },
874
+ mockSocket
875
+ );
876
+
877
+ expect(result.remotes).toEqual([]);
878
+ });
879
+
880
+ it('should list configured remotes', async () => {
881
+ await execGit(['init'], repoPath);
882
+ await execGit(['remote', 'add', 'origin', 'https://github.com/user/repo.git'], repoPath);
883
+ await execGit(['remote', 'add', 'upstream', 'https://github.com/upstream/repo.git'], repoPath);
884
+
885
+ const result = await service.handle(
886
+ 'listRemotes',
887
+ { dir: '/repo' },
888
+ mockSocket
889
+ );
890
+
891
+ expect(result.remotes).toHaveLength(2);
892
+ expect(result.remotes.some((r: any) => r.remote === 'origin')).toBe(true);
893
+ expect(result.remotes.some((r: any) => r.remote === 'upstream')).toBe(true);
894
+ expect(result.remotes[0]).toHaveProperty('url');
895
+ });
896
+ });
897
+
898
+ describe('Git Operations - addRemote', () => {
899
+ it('should add a new remote', async () => {
900
+ await execGit(['init'], repoPath);
901
+
902
+ const result = await service.handle(
903
+ 'addRemote',
904
+ { dir: '/repo', remote: 'origin', url: 'https://github.com/user/repo.git' },
905
+ mockSocket
906
+ );
907
+
908
+ expect(result.success).toBe(true);
909
+
910
+ // Verify remote was added
911
+ const remotes = await execGit(['remote', '-v'], repoPath);
912
+ expect(remotes).toContain('origin');
913
+ expect(remotes).toContain('https://github.com/user/repo.git');
914
+ });
915
+ });
916
+
917
+ describe('Git Operations - deleteRemote', () => {
918
+ it('should delete an existing remote', async () => {
919
+ await execGit(['init'], repoPath);
920
+ await execGit(['remote', 'add', 'origin', 'https://github.com/user/repo.git'], repoPath);
921
+
922
+ const result = await service.handle(
923
+ 'deleteRemote',
924
+ { dir: '/repo', remote: 'origin' },
925
+ mockSocket
926
+ );
927
+
928
+ expect(result.success).toBe(true);
929
+
930
+ // Verify remote was deleted
931
+ const remotes = await execGit(['remote'], repoPath);
932
+ expect(remotes).not.toContain('origin');
933
+ });
934
+ });
935
+
936
+ describe('Git Operations - clearIndex', () => {
937
+ it('should clear the git index', async () => {
938
+ await execGit(['init'], repoPath);
939
+ await execGit(['config', 'user.email', 'test@example.com'], repoPath);
940
+ await execGit(['config', 'user.name', 'Test User'], repoPath);
941
+
942
+ // Add files to index
943
+ await fs.writeFile(path.join(repoPath, 'file1.txt'), 'content1');
944
+ await fs.writeFile(path.join(repoPath, 'file2.txt'), 'content2');
945
+ await execGit(['add', '.'], repoPath);
946
+
947
+ const result = await service.handle(
948
+ 'clearIndex',
949
+ { dir: '/repo' },
950
+ mockSocket
951
+ );
952
+
953
+ expect(result.success).toBe(true);
954
+
955
+ // Verify index is empty
956
+ const status = await execGit(['status', '--porcelain'], repoPath);
957
+ expect(status).toContain('??'); // Files should be untracked now
958
+ });
959
+ });
960
+
961
+ describe('Git Operations - isIgnored', () => {
962
+ it('should return false for non-ignored files', async () => {
963
+ await execGit(['init'], repoPath);
964
+ await fs.writeFile(path.join(repoPath, 'regular.txt'), 'content');
965
+
966
+ const result = await service.handle(
967
+ 'isIgnored',
968
+ { dir: '/repo', filepath: 'regular.txt' },
969
+ mockSocket
970
+ );
971
+
972
+ expect(result).toBe(false);
973
+ });
974
+
975
+ it('should return true for ignored files', async () => {
976
+ await execGit(['init'], repoPath);
977
+ await fs.writeFile(path.join(repoPath, '.gitignore'), '*.log\nnode_modules/');
978
+ await fs.writeFile(path.join(repoPath, 'test.log'), 'logs');
979
+
980
+ const result = await service.handle(
981
+ 'isIgnored',
982
+ { dir: '/repo', filepath: 'test.log' },
983
+ mockSocket
984
+ );
985
+
986
+ expect(result).toBe(true);
987
+ });
988
+ });
989
+
990
+ describe('Git Operations - isInitialized', () => {
991
+ it('should return false for non-git directory', async () => {
992
+ const result = await service.handle(
993
+ 'isInitialized',
994
+ { dir: '/repo' },
995
+ mockSocket
996
+ );
997
+
998
+ expect(result).toBe(false);
999
+ });
1000
+
1001
+ it('should return true for initialized git repository', async () => {
1002
+ await execGit(['init'], repoPath);
1003
+
1004
+ const result = await service.handle(
1005
+ 'isInitialized',
1006
+ { dir: '/repo' },
1007
+ mockSocket
1008
+ );
1009
+
1010
+ expect(result).toBe(true);
1011
+ });
1012
+ });
1013
+
1014
+ describe('Git Operations - resolveRef', () => {
1015
+ it('should resolve HEAD to commit oid', async () => {
1016
+ await execGit(['init'], repoPath);
1017
+ await execGit(['config', 'user.email', 'test@example.com'], repoPath);
1018
+ await execGit(['config', 'user.name', 'Test User'], repoPath);
1019
+ await fs.writeFile(path.join(repoPath, 'file.txt'), 'content');
1020
+ await execGit(['add', '.'], repoPath);
1021
+ await execGit(['commit', '-m', 'Initial'], repoPath);
1022
+
1023
+ const expected = await execGit(['rev-parse', 'HEAD'], repoPath);
1024
+
1025
+ const result = await service.handle(
1026
+ 'resolveRef',
1027
+ { dir: '/repo', ref: 'HEAD' },
1028
+ mockSocket
1029
+ );
1030
+
1031
+ expect(result.oid).toBe(expected);
1032
+ });
1033
+
1034
+ it('should resolve branch name to commit oid', async () => {
1035
+ await execGit(['init'], repoPath);
1036
+ await execGit(['config', 'user.email', 'test@example.com'], repoPath);
1037
+ await execGit(['config', 'user.name', 'Test User'], repoPath);
1038
+ await fs.writeFile(path.join(repoPath, 'file.txt'), 'content');
1039
+ await execGit(['add', '.'], repoPath);
1040
+ await execGit(['commit', '-m', 'Initial'], repoPath);
1041
+
1042
+ const branch = await execGit(['symbolic-ref', '--short', 'HEAD'], repoPath);
1043
+ const expected = await execGit(['rev-parse', 'HEAD'], repoPath);
1044
+
1045
+ const result = await service.handle(
1046
+ 'resolveRef',
1047
+ { dir: '/repo', ref: branch },
1048
+ mockSocket
1049
+ );
1050
+
1051
+ expect(result.oid).toBe(expected);
1052
+ });
1053
+ });
1054
+
1055
+ describe('Command Injection Prevention', () => {
1056
+ beforeEach(async () => {
1057
+ await execGit(['init'], repoPath);
1058
+ await execGit(['config', 'user.email', 'test@example.com'], repoPath);
1059
+ await execGit(['config', 'user.name', 'Test User'], repoPath);
1060
+ });
1061
+
1062
+ describe('add command', () => {
1063
+ it('should reject filenames starting with dash', async () => {
1064
+ await fs.writeFile(path.join(repoPath, '-evil.txt'), 'content');
1065
+
1066
+ await expect(
1067
+ service.handle(
1068
+ 'add',
1069
+ { dir: '/repo', filepaths: ['-evil.txt'] },
1070
+ mockSocket
1071
+ )
1072
+ ).rejects.toMatchObject({
1073
+ code: ErrorCode.INVALID_PATH,
1074
+ message: expect.stringContaining('cannot start with dash'),
1075
+ });
1076
+ });
1077
+
1078
+ it('should reject filenames with control characters', async () => {
1079
+ await expect(
1080
+ service.handle(
1081
+ 'add',
1082
+ { dir: '/repo', filepaths: ['file\x00.txt'] },
1083
+ mockSocket
1084
+ )
1085
+ ).rejects.toMatchObject({
1086
+ code: ErrorCode.INVALID_PATH,
1087
+ message: expect.stringContaining('control characters'),
1088
+ });
1089
+ });
1090
+
1091
+ it('should reject filenames with newlines', async () => {
1092
+ await expect(
1093
+ service.handle(
1094
+ 'add',
1095
+ { dir: '/repo', filepaths: ['file\n.txt'] },
1096
+ mockSocket
1097
+ )
1098
+ ).rejects.toMatchObject({
1099
+ code: ErrorCode.INVALID_PATH,
1100
+ message: expect.stringContaining('newline'),
1101
+ });
1102
+ });
1103
+
1104
+ it('should accept valid filenames with special characters', async () => {
1105
+ await fs.writeFile(path.join(repoPath, 'file@#$%.txt'), 'content');
1106
+
1107
+ const result = await service.handle(
1108
+ 'add',
1109
+ { dir: '/repo', filepaths: ['file@#$%.txt'] },
1110
+ mockSocket
1111
+ );
1112
+
1113
+ expect(result.success).toBe(true);
1114
+ });
1115
+
1116
+ it('should reject when any filename in array is malicious', async () => {
1117
+ await fs.writeFile(path.join(repoPath, 'good.txt'), 'content');
1118
+ await fs.writeFile(path.join(repoPath, '-evil.txt'), 'content');
1119
+
1120
+ await expect(
1121
+ service.handle(
1122
+ 'add',
1123
+ { dir: '/repo', filepaths: ['good.txt', '-evil.txt'] },
1124
+ mockSocket
1125
+ )
1126
+ ).rejects.toMatchObject({
1127
+ code: ErrorCode.INVALID_PATH,
1128
+ message: expect.stringContaining('cannot start with dash'),
1129
+ });
1130
+ });
1131
+ });
1132
+
1133
+ describe('remove command', () => {
1134
+ beforeEach(async () => {
1135
+ await fs.writeFile(path.join(repoPath, 'file.txt'), 'content');
1136
+ await execGit(['add', '.'], repoPath);
1137
+ await execGit(['commit', '-m', 'Initial'], repoPath);
1138
+ });
1139
+
1140
+ it('should reject filenames starting with dash', async () => {
1141
+ await expect(
1142
+ service.handle(
1143
+ 'remove',
1144
+ { dir: '/repo', filepaths: ['-evil.txt'] },
1145
+ mockSocket
1146
+ )
1147
+ ).rejects.toMatchObject({
1148
+ code: ErrorCode.INVALID_PATH,
1149
+ message: expect.stringContaining('cannot start with dash'),
1150
+ });
1151
+ });
1152
+
1153
+ it('should reject filenames with control characters', async () => {
1154
+ await expect(
1155
+ service.handle(
1156
+ 'remove',
1157
+ { dir: '/repo', filepaths: ['file\x1F.txt'] },
1158
+ mockSocket
1159
+ )
1160
+ ).rejects.toMatchObject({
1161
+ code: ErrorCode.INVALID_PATH,
1162
+ message: expect.stringContaining('control characters'),
1163
+ });
1164
+ });
1165
+
1166
+ it('should reject filenames with carriage returns', async () => {
1167
+ await expect(
1168
+ service.handle(
1169
+ 'remove',
1170
+ { dir: '/repo', filepaths: ['file\r.txt'] },
1171
+ mockSocket
1172
+ )
1173
+ ).rejects.toMatchObject({
1174
+ code: ErrorCode.INVALID_PATH,
1175
+ message: expect.stringContaining('newline'),
1176
+ });
1177
+ });
1178
+ });
1179
+
1180
+ describe('resetIndex command', () => {
1181
+ beforeEach(async () => {
1182
+ await fs.writeFile(path.join(repoPath, 'file.txt'), 'content');
1183
+ await execGit(['add', '.'], repoPath);
1184
+ await execGit(['commit', '-m', 'Initial'], repoPath);
1185
+ });
1186
+
1187
+ it('should reject single filepath starting with dash', async () => {
1188
+ await expect(
1189
+ service.handle(
1190
+ 'resetIndex',
1191
+ { dir: '/repo', filepath: '-evil.txt' },
1192
+ mockSocket
1193
+ )
1194
+ ).rejects.toMatchObject({
1195
+ code: ErrorCode.INVALID_PATH,
1196
+ message: expect.stringContaining('cannot start with dash'),
1197
+ });
1198
+ });
1199
+
1200
+ it('should reject filepaths array with dash-prefixed filename', async () => {
1201
+ await expect(
1202
+ service.handle(
1203
+ 'resetIndex',
1204
+ { dir: '/repo', filepaths: ['-evil.txt'] },
1205
+ mockSocket
1206
+ )
1207
+ ).rejects.toMatchObject({
1208
+ code: ErrorCode.INVALID_PATH,
1209
+ message: expect.stringContaining('cannot start with dash'),
1210
+ });
1211
+ });
1212
+
1213
+ it('should reject filepath with DEL character', async () => {
1214
+ await expect(
1215
+ service.handle(
1216
+ 'resetIndex',
1217
+ { dir: '/repo', filepath: 'file\x7F.txt' },
1218
+ mockSocket
1219
+ )
1220
+ ).rejects.toMatchObject({
1221
+ code: ErrorCode.INVALID_PATH,
1222
+ message: expect.stringContaining('control characters'),
1223
+ });
1224
+ });
1225
+
1226
+ it('should accept valid filepath in single mode', async () => {
1227
+ await fs.writeFile(path.join(repoPath, 'valid.txt'), 'content');
1228
+ await execGit(['add', 'valid.txt'], repoPath);
1229
+
1230
+ const result = await service.handle(
1231
+ 'resetIndex',
1232
+ { dir: '/repo', filepath: 'valid.txt' },
1233
+ mockSocket
1234
+ );
1235
+
1236
+ expect(result.success).toBe(true);
1237
+ });
1238
+
1239
+ it('should accept valid filepaths in array mode', async () => {
1240
+ await fs.writeFile(path.join(repoPath, 'valid1.txt'), 'content1');
1241
+ await fs.writeFile(path.join(repoPath, 'valid2.txt'), 'content2');
1242
+ await execGit(['add', '.'], repoPath);
1243
+
1244
+ const result = await service.handle(
1245
+ 'resetIndex',
1246
+ { dir: '/repo', filepaths: ['valid1.txt', 'valid2.txt'] },
1247
+ mockSocket
1248
+ );
1249
+
1250
+ expect(result.success).toBe(true);
1251
+ });
1252
+ });
1253
+
1254
+ describe('flag injection via -- separator', () => {
1255
+ it('should safely handle files that look like git flags in add', async () => {
1256
+ // This test verifies that the -- separator prevents flag injection
1257
+ // Even though the filename starts with -, it's sanitized before reaching git
1258
+ await expect(
1259
+ service.handle(
1260
+ 'add',
1261
+ { dir: '/repo', filepaths: ['--version'] },
1262
+ mockSocket
1263
+ )
1264
+ ).rejects.toMatchObject({
1265
+ code: ErrorCode.INVALID_PATH,
1266
+ });
1267
+ });
1268
+
1269
+ it('should safely handle files that look like git flags in remove', async () => {
1270
+ await expect(
1271
+ service.handle(
1272
+ 'remove',
1273
+ { dir: '/repo', filepaths: ['--help'] },
1274
+ mockSocket
1275
+ )
1276
+ ).rejects.toMatchObject({
1277
+ code: ErrorCode.INVALID_PATH,
1278
+ });
1279
+ });
1280
+
1281
+ it('should safely handle files that look like git flags in resetIndex', async () => {
1282
+ await expect(
1283
+ service.handle(
1284
+ 'resetIndex',
1285
+ { dir: '/repo', filepath: '--cached' },
1286
+ mockSocket
1287
+ )
1288
+ ).rejects.toMatchObject({
1289
+ code: ErrorCode.INVALID_PATH,
1290
+ });
1291
+ });
1292
+ });
1293
+ });
1294
+
1295
+ describe('bulkIsIgnored', () => {
1296
+ beforeEach(async () => {
1297
+ // Initialize git repository
1298
+ await execGit(['init'], repoPath);
1299
+ await execGit(['config', 'user.email', 'test@example.com'], repoPath);
1300
+ await execGit(['config', 'user.name', 'Test User'], repoPath);
1301
+
1302
+ // Create .gitignore with test patterns
1303
+ await fs.writeFile(
1304
+ path.join(repoPath, '.gitignore'),
1305
+ '*.log\nnode_modules/\n.env\nbuild/\n'
1306
+ );
1307
+
1308
+ // Create test files
1309
+ await fs.writeFile(path.join(repoPath, 'file1.js'), 'test');
1310
+ await fs.writeFile(path.join(repoPath, 'file2.log'), 'test');
1311
+ await fs.writeFile(path.join(repoPath, '.env'), 'test');
1312
+ await fs.mkdir(path.join(repoPath, 'src'), { recursive: true });
1313
+ await fs.writeFile(path.join(repoPath, 'src', 'index.js'), 'test');
1314
+ await fs.mkdir(path.join(repoPath, 'node_modules'), { recursive: true });
1315
+ await fs.writeFile(path.join(repoPath, 'node_modules', 'test.js'), 'test');
1316
+ });
1317
+
1318
+ it('should check multiple files and return 1/0 for ignored status', async () => {
1319
+ const result = await service.handle(
1320
+ 'bulkIsIgnored',
1321
+ {
1322
+ dir: '/repo',
1323
+ filepaths: [
1324
+ 'file1.js', // not ignored
1325
+ 'file2.log', // ignored (*.log)
1326
+ '.env', // ignored
1327
+ 'src/index.js', // not ignored
1328
+ 'node_modules/test.js' // ignored
1329
+ ]
1330
+ },
1331
+ mockSocket
1332
+ );
1333
+
1334
+ expect(result).toEqual([0, 1, 1, 0, 1]);
1335
+ });
1336
+
1337
+ it('should return empty array for empty input', async () => {
1338
+ const result = await service.handle(
1339
+ 'bulkIsIgnored',
1340
+ {
1341
+ dir: '/repo',
1342
+ filepaths: []
1343
+ },
1344
+ mockSocket
1345
+ );
1346
+
1347
+ expect(result).toEqual([]);
1348
+ });
1349
+
1350
+ it('should handle all ignored files', async () => {
1351
+ const result = await service.handle(
1352
+ 'bulkIsIgnored',
1353
+ {
1354
+ dir: '/repo',
1355
+ filepaths: [
1356
+ 'file2.log',
1357
+ 'debug.log',
1358
+ '.env',
1359
+ 'node_modules/package.json'
1360
+ ]
1361
+ },
1362
+ mockSocket
1363
+ );
1364
+
1365
+ expect(result).toEqual([1, 1, 1, 1]);
1366
+ });
1367
+
1368
+ it('should handle all non-ignored files', async () => {
1369
+ const result = await service.handle(
1370
+ 'bulkIsIgnored',
1371
+ {
1372
+ dir: '/repo',
1373
+ filepaths: [
1374
+ 'file1.js',
1375
+ 'src/index.js',
1376
+ 'package.json',
1377
+ 'README.md'
1378
+ ]
1379
+ },
1380
+ mockSocket
1381
+ );
1382
+
1383
+ expect(result).toEqual([0, 0, 0, 0]);
1384
+ });
1385
+
1386
+ it('should work with single file', async () => {
1387
+ const result = await service.handle(
1388
+ 'bulkIsIgnored',
1389
+ {
1390
+ dir: '/repo',
1391
+ filepaths: ['file2.log']
1392
+ },
1393
+ mockSocket
1394
+ );
1395
+
1396
+ expect(result).toEqual([1]);
1397
+ });
1398
+
1399
+ it('should handle nested gitignore patterns', async () => {
1400
+ await fs.mkdir(path.join(repoPath, 'build'), { recursive: true });
1401
+ await fs.writeFile(path.join(repoPath, 'build', 'output.js'), 'test');
1402
+
1403
+ const result = await service.handle(
1404
+ 'bulkIsIgnored',
1405
+ {
1406
+ dir: '/repo',
1407
+ filepaths: [
1408
+ 'build/output.js', // ignored (build/ pattern)
1409
+ 'src/build.js' // not ignored
1410
+ ]
1411
+ },
1412
+ mockSocket
1413
+ );
1414
+
1415
+ expect(result).toEqual([1, 0]);
1416
+ });
1417
+
1418
+ it('should throw error for invalid params', async () => {
1419
+ await expect(
1420
+ service.handle(
1421
+ 'bulkIsIgnored',
1422
+ {
1423
+ dir: '/repo',
1424
+ filepaths: 'not-an-array'
1425
+ },
1426
+ mockSocket
1427
+ )
1428
+ ).rejects.toMatchObject({
1429
+ code: ErrorCode.INVALID_PARAMS,
1430
+ message: 'filepaths must be an array'
1431
+ });
1432
+ });
1433
+
1434
+ it('should respect .gitignore inside submodules', async () => {
1435
+ // Create a submodule with its own .gitignore
1436
+ const submodulePath = path.join(testRoot, 'mylib');
1437
+ await fs.mkdir(submodulePath);
1438
+ await execGit(['init'], submodulePath);
1439
+ await execGit(['config', 'user.email', 'test@example.com'], submodulePath);
1440
+ await execGit(['config', 'user.name', 'Test User'], submodulePath);
1441
+ await fs.writeFile(path.join(submodulePath, '.gitignore'), 'dist/\n*.tmp\n');
1442
+ await fs.writeFile(path.join(submodulePath, 'index.js'), 'module.exports = {}');
1443
+ await fs.mkdir(path.join(submodulePath, 'dist'), { recursive: true });
1444
+ await fs.writeFile(path.join(submodulePath, 'dist', 'bundle.js'), 'bundled');
1445
+ await fs.writeFile(path.join(submodulePath, 'cache.tmp'), 'temp');
1446
+ await execGit(['add', '.'], submodulePath);
1447
+ await execGit(['commit', '-m', 'init submodule'], submodulePath);
1448
+
1449
+ // Add submodule to main repo (allow file transport for local test)
1450
+ await execGit(['-c', 'protocol.file.allow=always', 'submodule', 'add', submodulePath, 'mylib'], repoPath);
1451
+ await execGit(['commit', '-m', 'add submodule'], repoPath);
1452
+
1453
+ // Create submodule-specific ignored files in the working tree
1454
+ await fs.mkdir(path.join(repoPath, 'mylib', 'dist'), { recursive: true });
1455
+ await fs.writeFile(path.join(repoPath, 'mylib', 'dist', 'bundle.js'), 'bundled');
1456
+ await fs.writeFile(path.join(repoPath, 'mylib', 'cache.tmp'), 'temp');
1457
+
1458
+ const result = await service.handle(
1459
+ 'bulkIsIgnored',
1460
+ {
1461
+ dir: '/repo',
1462
+ filepaths: [
1463
+ 'mylib/index.js', // not ignored by submodule's .gitignore
1464
+ 'mylib/dist/bundle.js', // ignored by submodule's .gitignore (dist/)
1465
+ 'mylib/cache.tmp', // ignored by submodule's .gitignore (*.tmp)
1466
+ 'file1.js', // not ignored by main repo
1467
+ 'file2.log', // ignored by main repo's .gitignore (*.log)
1468
+ ]
1469
+ },
1470
+ mockSocket
1471
+ );
1472
+
1473
+ expect(result).toEqual([0, 1, 1, 0, 1]);
1474
+ });
1475
+
1476
+ it('should respect .gitignore inside submodules for paths not ignored by parent', async () => {
1477
+ // Create a submodule with its own .gitignore that differs from parent
1478
+ const submodulePath = path.join(testRoot, 'mylib');
1479
+ await fs.mkdir(submodulePath);
1480
+ await execGit(['init'], submodulePath);
1481
+ await execGit(['config', 'user.email', 'test@example.com'], submodulePath);
1482
+ await execGit(['config', 'user.name', 'Test User'], submodulePath);
1483
+ // Submodule ignores "vendor/" which the parent does NOT ignore
1484
+ await fs.writeFile(path.join(submodulePath, '.gitignore'), 'vendor/\n');
1485
+ await fs.writeFile(path.join(submodulePath, 'lib.js'), 'code');
1486
+ await execGit(['add', '.'], submodulePath);
1487
+ await execGit(['commit', '-m', 'init submodule'], submodulePath);
1488
+
1489
+ // Add submodule to main repo
1490
+ await execGit(['-c', 'protocol.file.allow=always', 'submodule', 'add', submodulePath, 'mylib'], repoPath);
1491
+ await execGit(['commit', '-m', 'add submodule'], repoPath);
1492
+
1493
+ // Create vendor dir inside submodule in working tree
1494
+ await fs.mkdir(path.join(repoPath, 'mylib', 'vendor'), { recursive: true });
1495
+ await fs.writeFile(path.join(repoPath, 'mylib', 'vendor', 'dep.js'), 'dependency');
1496
+
1497
+ const result = await service.handle(
1498
+ 'bulkIsIgnored',
1499
+ {
1500
+ dir: '/repo',
1501
+ filepaths: [
1502
+ 'mylib/lib.js', // not ignored
1503
+ 'mylib/vendor/dep.js', // ignored by submodule's .gitignore (vendor/)
1504
+ ]
1505
+ },
1506
+ mockSocket
1507
+ );
1508
+
1509
+ // vendor/dep.js should be ignored by the submodule's .gitignore, NOT the parent's
1510
+ expect(result).toEqual([0, 1]);
1511
+ });
1512
+
1513
+ it('should respect .gitignore when gitRoot targets a submodule', async () => {
1514
+ // Create a submodule with its own .gitignore
1515
+ const submodulePath = path.join(testRoot, 'mylib');
1516
+ await fs.mkdir(submodulePath);
1517
+ await execGit(['init'], submodulePath);
1518
+ await execGit(['config', 'user.email', 'test@example.com'], submodulePath);
1519
+ await execGit(['config', 'user.name', 'Test User'], submodulePath);
1520
+ await fs.writeFile(path.join(submodulePath, '.gitignore'), 'coverage/\n*.bak\n');
1521
+ await fs.writeFile(path.join(submodulePath, 'src.js'), 'code');
1522
+ await execGit(['add', '.'], submodulePath);
1523
+ await execGit(['commit', '-m', 'init submodule'], submodulePath);
1524
+
1525
+ // Add submodule to main repo
1526
+ await execGit(['-c', 'protocol.file.allow=always', 'submodule', 'add', submodulePath, 'mylib'], repoPath);
1527
+ await execGit(['commit', '-m', 'add submodule'], repoPath);
1528
+
1529
+ // Create ignored content in working tree
1530
+ await fs.mkdir(path.join(repoPath, 'mylib', 'coverage'), { recursive: true });
1531
+ await fs.writeFile(path.join(repoPath, 'mylib', 'coverage', 'report.html'), 'report');
1532
+ await fs.writeFile(path.join(repoPath, 'mylib', 'old.bak'), 'backup');
1533
+
1534
+ // Call with gitRoot pointing to submodule, filepaths relative to submodule
1535
+ const result = await service.handle(
1536
+ 'bulkIsIgnored',
1537
+ {
1538
+ dir: '/repo',
1539
+ gitRoot: 'mylib',
1540
+ filepaths: [
1541
+ 'src.js', // not ignored
1542
+ 'coverage/report.html', // ignored (coverage/)
1543
+ 'old.bak', // ignored (*.bak)
1544
+ ]
1545
+ },
1546
+ mockSocket
1547
+ );
1548
+
1549
+ expect(result).toEqual([0, 1, 1]);
1550
+ });
1551
+ });
1552
+ });