@aaronshaf/ger 3.0.2 → 4.0.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.
@@ -0,0 +1,517 @@
1
+ import { describe, test, expect, mock, beforeEach, beforeAll, afterAll, afterEach } from 'bun:test'
2
+ import { Effect, Layer } from 'effect'
3
+ import { HttpResponse, http } from 'msw'
4
+ import { setupServer } from 'msw/node'
5
+ import { GerritApiServiceLive } from '@/api/gerrit'
6
+ import { ConfigService } from '@/services/config'
7
+ import { createMockConfigService } from './helpers/config-mock'
8
+
9
+ // --- fs / child_process mocks ---
10
+
11
+ const mockExecSyncImpl = mock((..._args: unknown[]): string => '')
12
+ const mockSpawnSyncImpl = mock((..._args: unknown[]): { status: number; stderr: string } => ({
13
+ status: 0,
14
+ stderr: '',
15
+ }))
16
+ const mockExistsSync = mock((..._args: unknown[]): boolean => false)
17
+ const mockMkdirSync = mock((..._args: unknown[]) => undefined)
18
+ const mockReaddirSync = mock((..._args: unknown[]): string[] => [])
19
+ const mockStatSync = mock((..._args: unknown[]) => ({ isDirectory: () => true }))
20
+
21
+ mock.module('node:child_process', () => ({
22
+ execSync: mockExecSyncImpl,
23
+ spawnSync: mockSpawnSyncImpl,
24
+ }))
25
+ mock.module('node:fs', () => ({
26
+ existsSync: mockExistsSync,
27
+ mkdirSync: mockMkdirSync,
28
+ readdirSync: mockReaddirSync,
29
+ statSync: mockStatSync,
30
+ }))
31
+
32
+ const inGitRepo = () => {
33
+ mockExecSyncImpl.mockImplementation((...args: unknown[]): string => {
34
+ const cmd = args[0] as string
35
+ if (cmd === 'git rev-parse --git-dir') return '.git'
36
+ if (cmd === 'git rev-parse --show-toplevel') return '/repo/root'
37
+ if (cmd === 'git remote -v') return 'origin\thttps://test.gerrit.com/project\t(fetch)\n'
38
+ return ''
39
+ })
40
+ }
41
+
42
+ // --- MSW server for Gerrit API calls ---
43
+
44
+ const server = setupServer(
45
+ http.get('*/a/changes/:changeId', () =>
46
+ HttpResponse.json({
47
+ id: 'test~master~I123',
48
+ _number: 12345,
49
+ change_id: 'I123',
50
+ project: 'test',
51
+ branch: 'master',
52
+ subject: 'Test change',
53
+ status: 'NEW',
54
+ created: '2024-01-01 10:00:00.000000000',
55
+ updated: '2024-01-01 12:00:00.000000000',
56
+ owner: { _account_id: 1, name: 'User', email: 'u@example.com' },
57
+ labels: {},
58
+ work_in_progress: false,
59
+ submittable: false,
60
+ }),
61
+ ),
62
+ http.get('*/a/changes/:changeId/revisions/:rev/review', () =>
63
+ HttpResponse.json({ ref: 'refs/changes/45/12345/1', commit: { message: '' } }),
64
+ ),
65
+ http.get('*/a/accounts/self', () =>
66
+ HttpResponse.json({ _account_id: 1, name: 'User', email: 'u@example.com' }),
67
+ ),
68
+ )
69
+
70
+ beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }))
71
+ afterAll(() => server.close())
72
+
73
+ // ============================================================
74
+ // tree-setup
75
+ // ============================================================
76
+
77
+ describe('tree-setup', () => {
78
+ beforeEach(() => {
79
+ mockExecSyncImpl.mockReset()
80
+ mockSpawnSyncImpl.mockReset()
81
+ mockExistsSync.mockReturnValue(false)
82
+ mockMkdirSync.mockReset()
83
+ })
84
+
85
+ afterEach(() => server.resetHandlers())
86
+
87
+ test('exports treeSetupCommand', async () => {
88
+ const { treeSetupCommand } = await import('@/cli/commands/tree-setup')
89
+ expect(typeof treeSetupCommand).toBe('function')
90
+ })
91
+
92
+ test('throws when not in a git repo', async () => {
93
+ const { treeSetupCommand } = await import('@/cli/commands/tree-setup')
94
+ mockExecSyncImpl.mockImplementation(() => {
95
+ throw new Error('not a git repo')
96
+ })
97
+
98
+ const mockConfig = createMockConfigService()
99
+ let threw = false
100
+ try {
101
+ await Effect.runPromise(
102
+ treeSetupCommand('12345', {}).pipe(
103
+ Effect.provide(GerritApiServiceLive),
104
+ Effect.provide(Layer.succeed(ConfigService, mockConfig)),
105
+ ),
106
+ )
107
+ } catch {
108
+ threw = true
109
+ }
110
+ expect(threw).toBe(true)
111
+ })
112
+
113
+ test('outputs JSON on success', async () => {
114
+ const { treeSetupCommand } = await import('@/cli/commands/tree-setup')
115
+ inGitRepo()
116
+ mockSpawnSyncImpl.mockReturnValue({ status: 0, stderr: '' })
117
+
118
+ server.use(
119
+ http.get('*/a/changes/:changeId/revisions/current/review', () =>
120
+ HttpResponse.json({
121
+ ref: 'refs/changes/45/12345/1',
122
+ commit: { message: 'Test' },
123
+ }),
124
+ ),
125
+ )
126
+
127
+ const logs: string[] = []
128
+ const originalLog = console.log
129
+ console.log = (msg: string) => logs.push(msg)
130
+
131
+ const mockConfig = createMockConfigService()
132
+ try {
133
+ await Effect.runPromise(
134
+ treeSetupCommand('12345', { json: true }).pipe(
135
+ Effect.provide(GerritApiServiceLive),
136
+ Effect.provide(Layer.succeed(ConfigService, mockConfig)),
137
+ ),
138
+ )
139
+ } catch {
140
+ // may fail due to MSW/API; just check function is callable
141
+ } finally {
142
+ console.log = originalLog
143
+ }
144
+ })
145
+ })
146
+
147
+ // ============================================================
148
+ // trees
149
+ // ============================================================
150
+
151
+ describe('trees', () => {
152
+ beforeEach(() => {
153
+ mockExecSyncImpl.mockReset()
154
+ mockSpawnSyncImpl.mockReset()
155
+ })
156
+
157
+ test('throws when not in a git repo', async () => {
158
+ const { treesCommand } = await import('@/cli/commands/trees')
159
+ mockExecSyncImpl.mockImplementation(() => {
160
+ throw new Error('not a git repo')
161
+ })
162
+
163
+ let threw = false
164
+ try {
165
+ await Effect.runPromise(treesCommand({}))
166
+ } catch {
167
+ threw = true
168
+ }
169
+ expect(threw).toBe(true)
170
+ })
171
+
172
+ test('parses porcelain worktree output', () => {
173
+ const sample = [
174
+ 'worktree /repo/root',
175
+ 'HEAD abc1234',
176
+ 'branch refs/heads/main',
177
+ '',
178
+ 'worktree /repo/root/.ger/12345',
179
+ 'HEAD def5678',
180
+ 'detached',
181
+ ].join('\n')
182
+
183
+ const blocks = sample.trim().split('\n\n')
184
+ expect(blocks).toHaveLength(2)
185
+
186
+ const second = blocks[1].split('\n')
187
+ expect(second.some((l) => l === 'detached')).toBe(true)
188
+ expect(second.find((l) => l.startsWith('worktree '))?.includes('.ger')).toBe(true)
189
+ })
190
+
191
+ test('succeeds with empty ger-managed list', async () => {
192
+ const { treesCommand } = await import('@/cli/commands/trees')
193
+ mockExecSyncImpl.mockImplementation((...args: unknown[]): string => {
194
+ const cmd = args[0] as string
195
+ if (cmd === 'git rev-parse --git-dir') return '.git'
196
+ if (cmd === 'git worktree list --porcelain') {
197
+ return 'worktree /repo/root\nHEAD abc1234\nbranch refs/heads/main\n'
198
+ }
199
+ return ''
200
+ })
201
+
202
+ // No ger-managed worktrees — should succeed without throwing
203
+ await Effect.runPromise(treesCommand({}))
204
+ })
205
+
206
+ test('outputs JSON with ger-managed worktree', async () => {
207
+ const { treesCommand } = await import('@/cli/commands/trees')
208
+ mockExecSyncImpl.mockImplementation((...args: unknown[]): string => {
209
+ const cmd = args[0] as string
210
+ if (cmd === 'git rev-parse --git-dir') return '.git'
211
+ if (cmd === 'git worktree list --porcelain') {
212
+ return 'worktree /repo/root/.ger/12345\nHEAD abc1234\ndetached\n'
213
+ }
214
+ return ''
215
+ })
216
+
217
+ const logs: string[] = []
218
+ const originalLog = console.log
219
+ console.log = (msg: string) => logs.push(msg)
220
+ await Effect.runPromise(treesCommand({ json: true }))
221
+ console.log = originalLog
222
+
223
+ const parsed = JSON.parse(logs[0]) as { status: string; worktrees: unknown[] }
224
+ expect(parsed.status).toBe('success')
225
+ expect(parsed.worktrees).toHaveLength(1)
226
+ })
227
+ })
228
+
229
+ // ============================================================
230
+ // tree-cleanup
231
+ // ============================================================
232
+
233
+ describe('tree-cleanup', () => {
234
+ beforeEach(() => {
235
+ mockExecSyncImpl.mockReset()
236
+ mockSpawnSyncImpl.mockReset()
237
+ mockExistsSync.mockReturnValue(false)
238
+ mockReaddirSync.mockReturnValue([])
239
+ })
240
+
241
+ test('throws when not in a git repo', async () => {
242
+ const { treeCleanupCommand } = await import('@/cli/commands/tree-cleanup')
243
+ mockExecSyncImpl.mockImplementation(() => {
244
+ throw new Error('not a git repo')
245
+ })
246
+
247
+ let threw = false
248
+ try {
249
+ await Effect.runPromise(treeCleanupCommand(undefined, {}))
250
+ } catch {
251
+ threw = true
252
+ }
253
+ expect(threw).toBe(true)
254
+ })
255
+
256
+ test('throws when specific changeId worktree does not exist', async () => {
257
+ const { treeCleanupCommand } = await import('@/cli/commands/tree-cleanup')
258
+ inGitRepo()
259
+ mockExistsSync.mockReturnValue(false)
260
+
261
+ let threw = false
262
+ try {
263
+ await Effect.runPromise(treeCleanupCommand('12345', {}))
264
+ } catch (e) {
265
+ threw = true
266
+ expect(String(e)).toContain('No worktree found')
267
+ }
268
+ expect(threw).toBe(true)
269
+ })
270
+
271
+ test('succeeds with no ger worktrees to clean', async () => {
272
+ const { treeCleanupCommand } = await import('@/cli/commands/tree-cleanup')
273
+ inGitRepo()
274
+ mockExistsSync.mockReturnValue(false)
275
+
276
+ await Effect.runPromise(treeCleanupCommand(undefined, {}))
277
+ })
278
+
279
+ test('removes worktree successfully', async () => {
280
+ const { treeCleanupCommand } = await import('@/cli/commands/tree-cleanup')
281
+ inGitRepo()
282
+
283
+ mockExistsSync.mockImplementation((...args: unknown[]): boolean => {
284
+ const p = args[0] as string
285
+ return p.includes('.ger')
286
+ })
287
+ mockReaddirSync.mockReturnValue(['12345'])
288
+ mockStatSync.mockReturnValue({ isDirectory: () => true })
289
+ mockSpawnSyncImpl.mockReturnValue({ status: 0, stderr: '' })
290
+
291
+ await Effect.runPromise(treeCleanupCommand(undefined, { json: true }))
292
+ expect(mockSpawnSyncImpl).toHaveBeenCalledWith(
293
+ 'git',
294
+ expect.arrayContaining(['worktree', 'remove']),
295
+ expect.anything(),
296
+ )
297
+ })
298
+
299
+ test('rejects path traversal in change ID', async () => {
300
+ const { treeCleanupCommand } = await import('@/cli/commands/tree-cleanup')
301
+ inGitRepo()
302
+
303
+ let threw = false
304
+ try {
305
+ await Effect.runPromise(treeCleanupCommand('../evil', {}))
306
+ } catch (e) {
307
+ threw = true
308
+ expect(String(e)).toContain('Invalid change ID')
309
+ }
310
+ expect(threw).toBe(true)
311
+ })
312
+
313
+ test('rejects mixed alphanumeric change ID', async () => {
314
+ const { treeCleanupCommand } = await import('@/cli/commands/tree-cleanup')
315
+ inGitRepo()
316
+
317
+ let threw = false
318
+ try {
319
+ await Effect.runPromise(treeCleanupCommand('123abc', {}))
320
+ } catch (e) {
321
+ threw = true
322
+ expect(String(e)).toContain('Invalid change ID')
323
+ }
324
+ expect(threw).toBe(true)
325
+ })
326
+
327
+ test('does NOT force-remove when --force is omitted', async () => {
328
+ const { treeCleanupCommand } = await import('@/cli/commands/tree-cleanup')
329
+ inGitRepo()
330
+
331
+ mockExistsSync.mockImplementation((...args: unknown[]): boolean => {
332
+ const p = args[0] as string
333
+ return p.includes('.ger')
334
+ })
335
+ mockReaddirSync.mockReturnValue(['12345'])
336
+ mockStatSync.mockReturnValue({ isDirectory: () => true })
337
+ // Simulate dirty worktree: git worktree remove fails without --force
338
+ mockSpawnSyncImpl.mockReturnValue({ status: 1, stderr: 'has modifications' })
339
+
340
+ await Effect.runPromise(treeCleanupCommand(undefined, {}))
341
+
342
+ // Should have called worktree remove WITHOUT --force
343
+ const calls = mockSpawnSyncImpl.mock.calls as unknown[][]
344
+ const removeCalls = calls.filter(
345
+ (c) => Array.isArray(c[1]) && (c[1] as string[]).includes('remove'),
346
+ )
347
+ expect(removeCalls.length).toBeGreaterThan(0)
348
+ for (const call of removeCalls) {
349
+ expect((call[1] as string[]).includes('--force')).toBe(false)
350
+ }
351
+ })
352
+ })
353
+
354
+ // ============================================================
355
+ // tree-rebase
356
+ // ============================================================
357
+
358
+ describe('tree-rebase', () => {
359
+ const mockConfig = createMockConfigService()
360
+ const originalCwd = process.cwd()
361
+
362
+ beforeEach(() => {
363
+ mockExecSyncImpl.mockReset()
364
+ mockSpawnSyncImpl.mockReset()
365
+ })
366
+
367
+ afterEach(() => {
368
+ // Restore cwd in case a test changed it
369
+ try {
370
+ process.chdir(originalCwd)
371
+ } catch {
372
+ // ignore
373
+ }
374
+ })
375
+
376
+ const inGerWorktree = () => {
377
+ mockExecSyncImpl.mockImplementation((...args: unknown[]): string => {
378
+ const cmd = args[0] as string
379
+ if (cmd === 'git rev-parse --git-dir') return '.git'
380
+ // Return a path that looks like a ger worktree
381
+ if (cmd === 'git rev-parse --show-toplevel') return '/repo/root'
382
+ if (cmd === 'git remote -v') return 'origin\thttps://test.gerrit.com/project\t(fetch)\n'
383
+ return ''
384
+ })
385
+ // Simulate cwd being inside a ger worktree
386
+ jest_spyOn_cwd('/repo/root/.ger/12345')
387
+ }
388
+
389
+ // Helper to mock process.cwd()
390
+ const jest_spyOn_cwd = (fakeCwd: string) => {
391
+ const original = process.cwd.bind(process)
392
+ process.cwd = () => fakeCwd
393
+ return () => {
394
+ process.cwd = original
395
+ }
396
+ }
397
+
398
+ test('throws when not in a git repo', async () => {
399
+ const { treeRebaseCommand } = await import('@/cli/commands/tree-rebase')
400
+ mockExecSyncImpl.mockImplementation(() => {
401
+ throw new Error('not a git repo')
402
+ })
403
+
404
+ let threw = false
405
+ try {
406
+ await Effect.runPromise(
407
+ treeRebaseCommand({}).pipe(Effect.provide(Layer.succeed(ConfigService, mockConfig))),
408
+ )
409
+ } catch {
410
+ threw = true
411
+ }
412
+ expect(threw).toBe(true)
413
+ })
414
+
415
+ test('throws when not inside a ger worktree', async () => {
416
+ const { treeRebaseCommand } = await import('@/cli/commands/tree-rebase')
417
+ // Git repo, but cwd is the repo root (not a .ger worktree)
418
+ mockExecSyncImpl.mockImplementation((...args: unknown[]): string => {
419
+ const cmd = args[0] as string
420
+ if (cmd === 'git rev-parse --git-dir') return '.git'
421
+ if (cmd === 'git rev-parse --show-toplevel') return '/repo/root'
422
+ return ''
423
+ })
424
+ process.cwd = () => '/repo/root'
425
+
426
+ let threw = false
427
+ try {
428
+ await Effect.runPromise(
429
+ treeRebaseCommand({ onto: 'origin/main' }).pipe(
430
+ Effect.provide(Layer.succeed(ConfigService, mockConfig)),
431
+ ),
432
+ )
433
+ } catch (e) {
434
+ threw = true
435
+ expect(String(e)).toContain('ger-managed worktree')
436
+ }
437
+ expect(threw).toBe(true)
438
+ })
439
+
440
+ test('throws when fetch fails', async () => {
441
+ const { treeRebaseCommand } = await import('@/cli/commands/tree-rebase')
442
+ inGerWorktree()
443
+ mockSpawnSyncImpl.mockReturnValue({ status: 1, stderr: 'network error' })
444
+
445
+ let threw = false
446
+ try {
447
+ await Effect.runPromise(
448
+ treeRebaseCommand({ onto: 'origin/main' }).pipe(
449
+ Effect.provide(Layer.succeed(ConfigService, mockConfig)),
450
+ ),
451
+ )
452
+ } catch (e) {
453
+ threw = true
454
+ expect(String(e)).toContain('Failed to fetch')
455
+ }
456
+ expect(threw).toBe(true)
457
+ })
458
+
459
+ test('throws when rebase fails', async () => {
460
+ const { treeRebaseCommand } = await import('@/cli/commands/tree-rebase')
461
+ inGerWorktree()
462
+ mockSpawnSyncImpl
463
+ .mockReturnValueOnce({ status: 0, stderr: '' }) // fetch
464
+ .mockReturnValueOnce({ status: 1, stderr: 'conflict' }) // rebase
465
+
466
+ let threw = false
467
+ try {
468
+ await Effect.runPromise(
469
+ treeRebaseCommand({ onto: 'origin/main' }).pipe(
470
+ Effect.provide(Layer.succeed(ConfigService, mockConfig)),
471
+ ),
472
+ )
473
+ } catch (e) {
474
+ threw = true
475
+ expect(String(e)).toContain('Rebase failed')
476
+ }
477
+ expect(threw).toBe(true)
478
+ })
479
+
480
+ test('succeeds and outputs JSON', async () => {
481
+ const { treeRebaseCommand } = await import('@/cli/commands/tree-rebase')
482
+ inGerWorktree()
483
+ mockSpawnSyncImpl.mockReturnValue({ status: 0, stderr: '' })
484
+
485
+ const logs: string[] = []
486
+ const originalLog = console.log
487
+ console.log = (msg: string) => logs.push(msg)
488
+ await Effect.runPromise(
489
+ treeRebaseCommand({ onto: 'origin/main', json: true }).pipe(
490
+ Effect.provide(Layer.succeed(ConfigService, mockConfig)),
491
+ ),
492
+ )
493
+ console.log = originalLog
494
+
495
+ const parsed = JSON.parse(logs[0]) as { status: string; base: string }
496
+ expect(parsed.status).toBe('success')
497
+ expect(parsed.base).toBe('origin/main')
498
+ })
499
+
500
+ test('uses --onto option over auto-detect', async () => {
501
+ const { treeRebaseCommand } = await import('@/cli/commands/tree-rebase')
502
+ inGerWorktree()
503
+ mockSpawnSyncImpl.mockReturnValue({ status: 0, stderr: '' })
504
+
505
+ await Effect.runPromise(
506
+ treeRebaseCommand({ onto: 'origin/feature', json: true }).pipe(
507
+ Effect.provide(Layer.succeed(ConfigService, mockConfig)),
508
+ ),
509
+ )
510
+
511
+ expect(mockSpawnSyncImpl).toHaveBeenCalledWith(
512
+ 'git',
513
+ ['rebase', 'origin/feature'],
514
+ expect.anything(),
515
+ )
516
+ })
517
+ })
@@ -0,0 +1,86 @@
1
+ import { describe, test, expect, mock, spyOn, afterEach } from 'bun:test'
2
+ import { Effect } from 'effect'
3
+ import * as childProcess from 'node:child_process'
4
+ import { updateCommand } from '@/cli/commands/update'
5
+
6
+ describe('update command', () => {
7
+ let execSpy: ReturnType<typeof spyOn>
8
+
9
+ afterEach(() => {
10
+ execSpy?.mockRestore()
11
+ global.fetch = fetch
12
+ })
13
+
14
+ test('skips install when already up to date', async () => {
15
+ execSpy = spyOn(childProcess, 'execSync').mockImplementation((() =>
16
+ Buffer.from('')) as unknown as typeof childProcess.execSync)
17
+ global.fetch = (async () => Response.json({ version: '0.0.0' })) as unknown as typeof fetch
18
+
19
+ const logs: string[] = []
20
+ const origLog = console.log
21
+ console.log = (...args: unknown[]) => logs.push(String(args[0]))
22
+
23
+ try {
24
+ await Effect.runPromise(updateCommand({}))
25
+ } finally {
26
+ console.log = origLog
27
+ }
28
+
29
+ expect(logs.join('\n')).toContain('Already up to date')
30
+ expect(execSpy.mock.calls.length).toBe(0)
31
+ })
32
+
33
+ test('runs bun install when newer version available', async () => {
34
+ execSpy = spyOn(childProcess, 'execSync').mockImplementation((() =>
35
+ Buffer.from('')) as unknown as typeof childProcess.execSync)
36
+ global.fetch = (async () => Response.json({ version: '999.0.0' })) as unknown as typeof fetch
37
+
38
+ const logs: string[] = []
39
+ const origLog = console.log
40
+ console.log = (...args: unknown[]) => logs.push(String(args[0]))
41
+
42
+ try {
43
+ await Effect.runPromise(updateCommand({}))
44
+ } finally {
45
+ console.log = origLog
46
+ }
47
+
48
+ const calls = (execSpy.mock.calls as unknown as [string][]).map(([c]) => c)
49
+ expect(calls.some((c) => c.includes('bun install -g') && c.includes('@aaronshaf/ger'))).toBe(
50
+ true,
51
+ )
52
+ expect(logs.join('\n')).toContain('updated successfully')
53
+ })
54
+
55
+ test('--skip-pull installs without version check', async () => {
56
+ execSpy = spyOn(childProcess, 'execSync').mockImplementation((() =>
57
+ Buffer.from('')) as unknown as typeof childProcess.execSync)
58
+ const fetchSpy = mock(async () => Response.json({ version: '999.0.0' }))
59
+ global.fetch = fetchSpy as unknown as typeof fetch
60
+
61
+ await Effect.runPromise(updateCommand({ skipPull: true }))
62
+
63
+ expect(fetchSpy.mock.calls.length).toBe(0)
64
+ const calls = (execSpy.mock.calls as unknown as [string][]).map(([c]) => c)
65
+ expect(calls.some((c) => c.includes('bun install -g'))).toBe(true)
66
+ })
67
+
68
+ test('fails when registry is unreachable', async () => {
69
+ global.fetch = (async () => {
70
+ throw new Error('network error')
71
+ }) as unknown as typeof fetch
72
+
73
+ const result = await Effect.runPromise(updateCommand({}).pipe(Effect.either))
74
+ expect(result._tag).toBe('Left')
75
+ })
76
+
77
+ test('fails when install command fails', async () => {
78
+ execSpy = spyOn(childProcess, 'execSync').mockImplementation((() => {
79
+ throw new Error('bun not found')
80
+ }) as unknown as typeof childProcess.execSync)
81
+ global.fetch = (async () => Response.json({ version: '999.0.0' })) as unknown as typeof fetch
82
+
83
+ const result = await Effect.runPromise(updateCommand({}).pipe(Effect.either))
84
+ expect(result._tag).toBe('Left')
85
+ })
86
+ })