@aaronshaf/ger 0.1.0 → 0.1.3

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.
@@ -1,89 +1,15 @@
1
- import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from 'bun:test'
1
+ import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test'
2
2
  import { Effect, Layer } from 'effect'
3
- import { reviewCommand } from '@/cli/commands/review'
4
- import { AiService, NoAiToolFoundError } from '@/services/ai'
5
- import { GerritApiService } from '@/api/gerrit'
6
- import { ConfigService } from '@/services/config'
7
- import { createMockConfigService } from './helpers/config-mock'
8
- import type { ChangeInfo, CommentInfo, MessageInfo } from '@/schemas/gerrit'
3
+ import { GitWorktreeService, WorktreeCreationError } from '@/services/git-worktree'
9
4
 
10
- describe('Review Command', () => {
5
+ describe('Review Command - Focused Tests', () => {
11
6
  let consoleSpy: any
12
- let mockAiService: any
13
- let mockApiService: any
14
7
 
15
8
  beforeEach(() => {
16
9
  consoleSpy = {
17
10
  log: spyOn(console, 'log').mockImplementation(() => {}),
18
11
  error: spyOn(console, 'error').mockImplementation(() => {}),
19
12
  }
20
-
21
- // Mock AI Service
22
- mockAiService = {
23
- detectAiTool: () => Effect.succeed('claude'),
24
- extractResponseTag: (output: string) => Effect.succeed(output),
25
- runPrompt: (prompt: string, input: string) => {
26
- if (
27
- prompt.includes('JSON Structure for Inline Comments') ||
28
- prompt.includes('Priority Guidelines for Inline Comments')
29
- ) {
30
- // Return mock inline comments
31
- return Effect.succeed(
32
- JSON.stringify([
33
- {
34
- file: 'src/main.ts',
35
- line: 10,
36
- message: '🤖 Consider adding error handling here',
37
- },
38
- ]),
39
- )
40
- } else {
41
- // Return mock overall review
42
- return Effect.succeed(
43
- '🤖 Code Review\n\nOVERALL ASSESSMENT\n\nThe code looks good overall.',
44
- )
45
- }
46
- },
47
- }
48
-
49
- // Mock Gerrit API Service
50
- const mockChange: ChangeInfo = {
51
- id: 'project~master~I123',
52
- _number: 12345,
53
- change_id: 'I123',
54
- project: 'test-project',
55
- branch: 'master',
56
- subject: 'Test change',
57
- status: 'NEW',
58
- created: '2024-01-01 10:00:00.000000000',
59
- updated: '2024-01-01 12:00:00.000000000',
60
- owner: {
61
- _account_id: 1000,
62
- name: 'Test User',
63
- email: 'test@example.com',
64
- },
65
- }
66
-
67
- mockApiService = {
68
- getChange: () => Effect.succeed(mockChange),
69
- getDiff: () => Effect.succeed('diff --git a/src/main.ts b/src/main.ts\n+console.log("test")'),
70
- getComments: () => Effect.succeed({} as Record<string, CommentInfo[]>),
71
- getMessages: () => Effect.succeed([] as MessageInfo[]),
72
- listChanges: () => Effect.fail({ _tag: 'ApiError' as const, message: 'Not implemented' }),
73
- postReview: mock(() => Effect.succeed(undefined as void)),
74
- abandonChange: () => Effect.fail({ _tag: 'ApiError' as const, message: 'Not implemented' }),
75
- testConnection: Effect.succeed(true),
76
- getRevision: () => Effect.fail({ _tag: 'ApiError' as const, message: 'Not implemented' }),
77
- getFiles: () =>
78
- Effect.succeed({
79
- 'src/main.ts': { status: 'M' as const },
80
- 'app/controllers/users_controller.rb': { status: 'M' as const },
81
- 'lib/utils/helper.rb': { status: 'A' as const },
82
- }),
83
- getFileDiff: () => Effect.fail({ _tag: 'ApiError' as const, message: 'Not implemented' }),
84
- getFileContent: () => Effect.fail({ _tag: 'ApiError' as const, message: 'Not implemented' }),
85
- getPatch: () => Effect.fail({ _tag: 'ApiError' as const, message: 'Not implemented' }),
86
- }
87
13
  })
88
14
 
89
15
  afterEach(() => {
@@ -91,579 +17,119 @@ describe('Review Command', () => {
91
17
  consoleSpy.error.mockRestore()
92
18
  })
93
19
 
94
- test('should detect AI tool and perform review', async () => {
95
- const result = await Effect.runPromise(
96
- Effect.either(
97
- reviewCommand('12345', { debug: false }).pipe(
98
- Effect.provide(Layer.succeed(AiService, mockAiService)),
99
- Effect.provide(Layer.succeed(GerritApiService, mockApiService)),
100
- Effect.provide(Layer.succeed(ConfigService, createMockConfigService())),
101
- ),
102
- ),
103
- )
104
-
105
- expect(result._tag).toBe('Right')
106
-
107
- // Check that AI tool detection was logged
108
- expect(consoleSpy.log).toHaveBeenCalledWith(' Checking for AI tool availability...')
109
- expect(consoleSpy.log).toHaveBeenCalledWith('✓ Found AI tool: claude')
110
-
111
- // Check that review stages were executed
112
- expect(consoleSpy.log).toHaveBeenCalledWith('→ Generating inline comments for change 12345...')
113
- expect(consoleSpy.log).toHaveBeenCalledWith(
114
- '→ Generating overall review comment for change 12345...',
115
- )
116
- expect(consoleSpy.log).toHaveBeenCalledWith('✓ Review complete for 12345')
117
- })
118
-
119
- test('should handle comment mode with auto-yes', async () => {
120
- const result = await Effect.runPromise(
121
- Effect.either(
122
- reviewCommand('12345', { comment: true, yes: true }).pipe(
123
- Effect.provide(Layer.succeed(AiService, mockAiService)),
124
- Effect.provide(Layer.succeed(GerritApiService, mockApiService)),
125
- Effect.provide(Layer.succeed(ConfigService, createMockConfigService())),
126
- ),
127
- ),
128
- )
129
-
130
- expect(result._tag).toBe('Right')
131
-
132
- // Check that comments were posted without prompts
133
- expect(consoleSpy.log).toHaveBeenCalledWith('✓ Inline comments posted for 12345')
134
- expect(consoleSpy.log).toHaveBeenCalledWith('✓ Overall review posted for 12345')
135
- })
136
-
137
- test('should show debug output when debug flag is set', async () => {
138
- const result = await Effect.runPromise(
139
- Effect.either(
140
- reviewCommand('12345', { debug: true }).pipe(
141
- Effect.provide(Layer.succeed(AiService, mockAiService)),
142
- Effect.provide(Layer.succeed(GerritApiService, mockApiService)),
143
- Effect.provide(Layer.succeed(ConfigService, createMockConfigService())),
144
- ),
145
- ),
146
- )
147
-
148
- expect(result._tag).toBe('Right')
149
-
150
- // Check that debug messages were shown
151
- expect(consoleSpy.log).toHaveBeenCalledWith('[DEBUG] Running AI for inline comments...')
152
- expect(consoleSpy.log).toHaveBeenCalledWith('[DEBUG] Running AI for overall review...')
153
- })
154
-
155
- test('should fail when no AI tool is available', async () => {
156
- const noToolService = {
157
- detectAiTool: () => Effect.fail(new NoAiToolFoundError({ message: 'No AI tool found' })),
158
- extractResponseTag: mockAiService.extractResponseTag,
159
- runPrompt: mockAiService.runPrompt,
160
- }
161
-
162
- const result = await Effect.runPromise(
163
- Effect.either(
164
- reviewCommand('12345', {}).pipe(
165
- Effect.provide(Layer.succeed(AiService, noToolService)),
166
- Effect.provide(Layer.succeed(GerritApiService, mockApiService)),
167
- Effect.provide(Layer.succeed(ConfigService, createMockConfigService())),
168
- ),
169
- ),
170
- )
171
-
172
- expect(result._tag).toBe('Left')
173
- if (result._tag === 'Left') {
174
- expect(result.left).toBeInstanceOf(Error)
175
- expect(result.left.message).toContain('No AI tool found')
176
- }
177
- })
178
-
179
- test('should handle invalid JSON response for inline comments', async () => {
180
- const badJsonService = {
181
- detectAiTool: () => Effect.succeed('claude'),
182
- extractResponseTag: (output: string) => Effect.succeed(output),
183
- runPrompt: (prompt: string, input: string) => {
184
- if (
185
- prompt.includes('INLINE_REVIEW_SYSTEM_PROMPT') ||
186
- prompt.includes('Example Output (THIS IS THE ONLY ACCEPTABLE FORMAT)')
187
- ) {
188
- // Return invalid JSON
189
- return Effect.succeed('not valid json')
190
- } else {
191
- return Effect.succeed(
192
- '🤖 Claude Code\n\nOVERALL ASSESSMENT\n\nThe code looks good overall.',
193
- )
194
- }
195
- },
196
- }
197
-
198
- const result = await Effect.runPromise(
199
- Effect.either(
200
- reviewCommand('12345', {}).pipe(
201
- Effect.provide(Layer.succeed(AiService, badJsonService)),
202
- Effect.provide(Layer.succeed(GerritApiService, mockApiService)),
203
- Effect.provide(Layer.succeed(ConfigService, createMockConfigService())),
204
- ),
205
- ),
206
- )
207
-
208
- expect(result._tag).toBe('Left')
209
- expect(consoleSpy.error).toHaveBeenCalledWith(
210
- expect.stringContaining('Failed to parse inline comments JSON'),
211
- )
212
- })
213
-
214
- test('should handle empty inline comments array', async () => {
215
- const emptyCommentsService = {
216
- detectAiTool: () => Effect.succeed('claude'),
217
- extractResponseTag: (output: string) => Effect.succeed(output),
218
- runPrompt: (prompt: string, input: string) => {
219
- if (
220
- prompt.includes('JSON Structure for Inline Comments') ||
221
- prompt.includes('Priority Guidelines for Inline Comments')
222
- ) {
223
- // Return empty array
224
- return Effect.succeed('[]')
225
- } else {
226
- return Effect.succeed('🤖 Code Review\n\nOVERALL ASSESSMENT\n\nNo issues found.')
227
- }
228
- },
229
- }
230
-
231
- const result = await Effect.runPromise(
232
- Effect.either(
233
- reviewCommand('12345', {}).pipe(
234
- Effect.provide(Layer.succeed(AiService, emptyCommentsService)),
235
- Effect.provide(Layer.succeed(GerritApiService, mockApiService)),
236
- Effect.provide(Layer.succeed(ConfigService, createMockConfigService())),
237
- ),
238
- ),
239
- )
240
-
241
- expect(result._tag).toBe('Right')
242
- expect(consoleSpy.log).toHaveBeenCalledWith('\n→ No inline comments')
243
- })
244
-
245
- test('should format change data as XML for inline review', async () => {
246
- let capturedXmlData: string | undefined
247
-
248
- const captureService = {
249
- detectAiTool: () => Effect.succeed('claude'),
250
- extractResponseTag: (output: string) => Effect.succeed(output),
251
- runPrompt: (prompt: string, input: string) => {
252
- // Check if this is the inline review prompt (which gets XML data)
253
- // Inline prompt contains "JSON Structure for Inline Comments" in its system prompt
254
- if (prompt.includes('JSON Structure for Inline Comments')) {
255
- capturedXmlData = input
256
- return Effect.succeed('[]')
257
- } else {
258
- return Effect.succeed('🤖 Code Review\n\nOVERALL ASSESSMENT\n\nLooks good.')
259
- }
260
- },
261
- }
262
-
263
- await Effect.runPromise(
264
- reviewCommand('12345', {}).pipe(
265
- Effect.provide(Layer.succeed(AiService, captureService)),
266
- Effect.provide(Layer.succeed(GerritApiService, mockApiService)),
267
- Effect.provide(Layer.succeed(ConfigService, createMockConfigService())),
268
- ),
269
- )
270
-
271
- expect(capturedXmlData).toBeDefined()
272
- expect(capturedXmlData).toContain('<?xml version="1.0" encoding="UTF-8"?>')
273
- expect(capturedXmlData).toContain('<show_result>')
274
- expect(capturedXmlData).toContain('<change>')
275
- expect(capturedXmlData).toContain('<id>I123</id>')
276
- expect(capturedXmlData).toContain('<number>12345</number>')
277
- })
278
-
279
- test('should display review without posting when --comment flag is not present', async () => {
280
- const result = await Effect.runPromise(
281
- Effect.either(
282
- reviewCommand('12345', {}).pipe(
283
- Effect.provide(Layer.succeed(AiService, mockAiService)),
284
- Effect.provide(Layer.succeed(GerritApiService, mockApiService)),
285
- Effect.provide(Layer.succeed(ConfigService, createMockConfigService())),
286
- ),
287
- ),
288
- )
289
-
290
- expect(result._tag).toBe('Right')
291
-
292
- // Check that it displays the reviews but doesn't post
293
- expect(consoleSpy.log).toHaveBeenCalledWith(
294
- expect.stringContaining('━━━━━━ INLINE COMMENTS ━━━━━━'),
295
- )
296
- expect(consoleSpy.log).toHaveBeenCalledWith(
297
- expect.stringContaining('━━━━━━ OVERALL REVIEW ━━━━━━'),
298
- )
299
-
300
- // Verify it doesn't post
301
- expect(consoleSpy.log).not.toHaveBeenCalledWith('✓ Inline comments posted for 12345')
302
- expect(consoleSpy.log).not.toHaveBeenCalledWith('✓ Overall review posted for 12345')
303
- })
304
-
305
- test('should handle error during comment posting', async () => {
306
- // Create a failing API service
307
- const failingApiService = {
308
- ...mockApiService,
309
- postReview: () => Effect.fail({ _tag: 'ApiError' as const, message: 'Network error' }),
20
+ test('should integrate GitWorktreeService with review workflow', async () => {
21
+ // Mock Git Worktree Service
22
+ const mockGitService = {
23
+ validatePreconditions: () => Effect.succeed(undefined),
24
+ createWorktree: (changeId: string) =>
25
+ Effect.succeed({
26
+ path: `/tmp/test-worktree-${changeId}`,
27
+ changeId,
28
+ originalCwd: '/test/current',
29
+ timestamp: Date.now(),
30
+ pid: process.pid,
31
+ }),
32
+ fetchAndCheckoutPatchset: () => Effect.succeed(undefined),
33
+ cleanup: () => Effect.succeed(undefined),
34
+ getChangedFiles: () => Effect.succeed(['src/main.ts', 'tests/main.test.ts']),
310
35
  }
311
36
 
37
+ // Test the complete GitWorktreeService workflow
312
38
  const result = await Effect.runPromise(
313
- Effect.either(
314
- reviewCommand('12345', { comment: true, yes: true }).pipe(
315
- Effect.provide(Layer.succeed(AiService, mockAiService)),
316
- Effect.provide(Layer.succeed(GerritApiService, failingApiService)),
317
- Effect.provide(Layer.succeed(ConfigService, createMockConfigService())),
318
- ),
319
- ),
320
- )
321
-
322
- expect(result._tag).toBe('Left')
323
- expect(consoleSpy.error).toHaveBeenCalledWith(
324
- expect.stringContaining('Failed to post inline comments'),
325
- )
326
- })
327
-
328
- test('should format change data as pretty text for overall review', async () => {
329
- let capturedPrettyData: string | undefined
330
-
331
- const captureService = {
332
- detectAiTool: () => Effect.succeed('claude'),
333
- extractResponseTag: (output: string) => Effect.succeed(output),
334
- runPrompt: (prompt: string, input: string) => {
335
- // Check if this is the inline review prompt (which gets XML) or overall (which gets pretty text)
336
- if (
337
- prompt.includes('JSON Structure for Inline Comments') ||
338
- prompt.includes('Priority Guidelines for Inline Comments')
339
- ) {
340
- // This is inline review with XML data
341
- return Effect.succeed('[]')
342
- } else if (prompt.includes('Review Structure and Formatting')) {
343
- // This is overall review with pretty text data
344
- capturedPrettyData = input
345
- return Effect.succeed('🤖 Code Review\n\nOVERALL ASSESSMENT\n\nLooks good.')
346
- } else {
347
- return Effect.succeed('🤖 Code Review\n\nOVERALL ASSESSMENT\n\nLooks good.')
348
- }
349
- },
350
- }
39
+ Effect.gen(function* () {
40
+ const service = yield* GitWorktreeService
351
41
 
352
- await Effect.runPromise(
353
- reviewCommand('12345', {}).pipe(
354
- Effect.provide(Layer.succeed(AiService, captureService)),
355
- Effect.provide(Layer.succeed(GerritApiService, mockApiService)),
356
- Effect.provide(Layer.succeed(ConfigService, createMockConfigService())),
357
- ),
358
- )
359
-
360
- expect(capturedPrettyData).toBeDefined()
361
- expect(capturedPrettyData).toContain('📋 Change 12345: Test change')
362
- expect(capturedPrettyData).toContain('Project: test-project')
363
- expect(capturedPrettyData).toContain('Branch: master')
364
- expect(capturedPrettyData).toContain('Status: NEW')
365
- })
366
-
367
- test('should use custom prompt file when --prompt option is provided', async () => {
368
- const customPromptContent = 'Custom prompt for testing\n\nSpecial instructions here.'
369
- const tempDir = require('os').tmpdir()
370
- const tempFile = require('path').join(tempDir, `test-prompt-${Date.now()}.md`)
371
-
372
- // Create temporary prompt file
373
- require('fs').writeFileSync(tempFile, customPromptContent, 'utf8')
374
-
375
- let capturedPrompt: string | undefined
376
-
377
- const captureService = {
378
- detectAiTool: () => Effect.succeed('claude'),
379
- extractResponseTag: (output: string) => Effect.succeed(output),
380
- runPrompt: (prompt: string, input: string) => {
381
- capturedPrompt = prompt
382
- if (prompt.includes('JSON Structure for Inline Comments')) {
383
- return Effect.succeed('[]')
384
- } else {
385
- return Effect.succeed('🤖 Custom Review\n\nOVERALL ASSESSMENT\n\nUsed custom prompt.')
386
- }
387
- },
388
- }
42
+ // Test precondition validation
43
+ yield* service.validatePreconditions()
389
44
 
390
- try {
391
- const result = await Effect.runPromise(
392
- Effect.either(
393
- reviewCommand('12345', { prompt: tempFile }).pipe(
394
- Effect.provide(Layer.succeed(AiService, captureService)),
395
- Effect.provide(Layer.succeed(GerritApiService, mockApiService)),
396
- Effect.provide(Layer.succeed(ConfigService, createMockConfigService())),
397
- ),
398
- ),
399
- )
45
+ // Test worktree creation
46
+ const worktree = yield* service.createWorktree('12345')
47
+ expect(worktree.changeId).toBe('12345')
48
+ expect(worktree.path).toContain('12345')
49
+ expect(worktree.originalCwd).toBe('/test/current')
400
50
 
401
- expect(result._tag).toBe('Right')
402
-
403
- // Check that custom prompt was loaded
404
- expect(consoleSpy.log).toHaveBeenCalledWith(`✓ Using custom review prompt from ${tempFile}`)
405
-
406
- // Verify the custom prompt content was used
407
- expect(capturedPrompt).toContain(customPromptContent)
408
- } finally {
409
- // Clean up temporary file
410
- require('fs').unlinkSync(tempFile)
411
- }
412
- })
413
-
414
- test('should expand tilde (~/) in prompt file paths', async () => {
415
- const customPromptContent = 'Home directory prompt test'
416
- const homeDir = require('os').homedir()
417
- const relativeFileName = `.test-prompt-${Date.now()}.md`
418
- const absolutePath = require('path').join(homeDir, relativeFileName)
419
- const tildePath = `~/${relativeFileName}`
420
-
421
- // Create prompt file in home directory
422
- require('fs').writeFileSync(absolutePath, customPromptContent, 'utf8')
423
-
424
- let capturedPrompt: string | undefined
425
-
426
- const captureService = {
427
- detectAiTool: () => Effect.succeed('claude'),
428
- extractResponseTag: (output: string) => Effect.succeed(output),
429
- runPrompt: (prompt: string, input: string) => {
430
- capturedPrompt = prompt
431
- if (prompt.includes('JSON Structure for Inline Comments')) {
432
- return Effect.succeed('[]')
433
- } else {
434
- return Effect.succeed('🤖 Home Prompt Review')
435
- }
436
- },
437
- }
438
-
439
- try {
440
- const result = await Effect.runPromise(
441
- Effect.either(
442
- reviewCommand('12345', { prompt: tildePath }).pipe(
443
- Effect.provide(Layer.succeed(AiService, captureService)),
444
- Effect.provide(Layer.succeed(GerritApiService, mockApiService)),
445
- Effect.provide(Layer.succeed(ConfigService, createMockConfigService())),
446
- ),
447
- ),
448
- )
449
-
450
- expect(result._tag).toBe('Right')
451
-
452
- // Check that tilde path was correctly expanded and file was loaded
453
- expect(consoleSpy.log).toHaveBeenCalledWith(`✓ Using custom review prompt from ${tildePath}`)
454
- expect(capturedPrompt).toContain(customPromptContent)
455
- } finally {
456
- // Clean up
457
- require('fs').unlinkSync(absolutePath)
458
- }
459
- })
460
-
461
- test('should fallback to default prompt when custom prompt file is missing', async () => {
462
- const nonExistentFile = '/tmp/does-not-exist-prompt.md'
463
-
464
- let capturedPrompt: string | undefined
465
-
466
- const captureService = {
467
- detectAiTool: () => Effect.succeed('claude'),
468
- extractResponseTag: (output: string) => Effect.succeed(output),
469
- runPrompt: (prompt: string, input: string) => {
470
- capturedPrompt = prompt
471
- if (prompt.includes('JSON Structure for Inline Comments')) {
472
- return Effect.succeed('[]')
473
- } else {
474
- return Effect.succeed('🤖 Default Review')
475
- }
476
- },
477
- }
51
+ // Test patchset fetch
52
+ yield* service.fetchAndCheckoutPatchset(worktree)
478
53
 
479
- const result = await Effect.runPromise(
480
- Effect.either(
481
- reviewCommand('12345', { prompt: nonExistentFile }).pipe(
482
- Effect.provide(Layer.succeed(AiService, captureService)),
483
- Effect.provide(Layer.succeed(GerritApiService, mockApiService)),
484
- Effect.provide(Layer.succeed(ConfigService, createMockConfigService())),
485
- ),
486
- ),
487
- )
54
+ // Test getting changed files
55
+ const files = yield* service.getChangedFiles()
56
+ expect(files).toEqual(['src/main.ts', 'tests/main.test.ts'])
488
57
 
489
- expect(result._tag).toBe('Right')
58
+ // Test cleanup
59
+ yield* service.cleanup(worktree)
490
60
 
491
- // Check that error was logged but execution continued
492
- expect(consoleSpy.log).toHaveBeenCalledWith(
493
- `⚠ Could not read custom prompt file: ${nonExistentFile}`,
61
+ return { success: true, worktree, files }
62
+ }).pipe(Effect.provide(Layer.succeed(GitWorktreeService, mockGitService))),
494
63
  )
495
- expect(consoleSpy.log).toHaveBeenCalledWith('→ Using default review prompt')
496
64
 
497
- // Verify default prompt was used (should not contain custom content)
498
- expect(capturedPrompt).toContain('Code Review Guidelines')
65
+ // Verify the workflow completed successfully
66
+ expect(result.success).toBe(true)
67
+ expect(result.worktree.path).toContain('12345')
68
+ expect(result.files).toHaveLength(2)
69
+ expect(result.files).toEqual(['src/main.ts', 'tests/main.test.ts'])
499
70
  })
500
71
 
501
- test('should handle permission errors gracefully for custom prompt files', async () => {
502
- const restrictedDir = '/root' // Directory that typically has restricted permissions
503
- const restrictedFile = `${restrictedDir}/test-prompt.md`
504
-
505
- let capturedPrompt: string | undefined
506
-
507
- const captureService = {
508
- detectAiTool: () => Effect.succeed('claude'),
509
- extractResponseTag: (output: string) => Effect.succeed(output),
510
- runPrompt: (prompt: string, input: string) => {
511
- capturedPrompt = prompt
512
- if (prompt.includes('JSON Structure for Inline Comments')) {
513
- return Effect.succeed('[]')
514
- } else {
515
- return Effect.succeed('🤖 Default Review')
516
- }
517
- },
518
- }
519
-
520
- const result = await Effect.runPromise(
521
- Effect.either(
522
- reviewCommand('12345', { prompt: restrictedFile }).pipe(
523
- Effect.provide(Layer.succeed(AiService, captureService)),
524
- Effect.provide(Layer.succeed(GerritApiService, mockApiService)),
525
- Effect.provide(Layer.succeed(ConfigService, createMockConfigService())),
526
- ),
72
+ test('should handle concurrent worktree scenarios with unique paths', async () => {
73
+ const mockGitService = {
74
+ validatePreconditions: () => Effect.succeed(undefined),
75
+ createWorktree: (changeId: string) =>
76
+ Effect.succeed({
77
+ path: `/tmp/test-worktree-${changeId}-${Date.now()}-${process.pid}`,
78
+ changeId,
79
+ originalCwd: process.cwd(),
80
+ timestamp: Date.now(),
81
+ pid: process.pid,
82
+ }),
83
+ fetchAndCheckoutPatchset: () => Effect.succeed(undefined),
84
+ cleanup: () => Effect.succeed(undefined),
85
+ getChangedFiles: () => Effect.succeed(['test.ts']),
86
+ }
87
+
88
+ // Simulate concurrent worktree creation
89
+ const [result1, result2] = await Promise.all([
90
+ Effect.runPromise(
91
+ Effect.gen(function* () {
92
+ const service = yield* GitWorktreeService
93
+ return yield* service.createWorktree('change-1')
94
+ }).pipe(Effect.provide(Layer.succeed(GitWorktreeService, mockGitService))),
527
95
  ),
528
- )
529
-
530
- expect(result._tag).toBe('Right')
531
-
532
- // Should fallback gracefully without crashing
533
- expect(consoleSpy.log).toHaveBeenCalledWith(
534
- `⚠ Could not read custom prompt file: ${restrictedFile}`,
535
- )
536
- expect(consoleSpy.log).toHaveBeenCalledWith('→ Using default review prompt')
537
- })
538
-
539
- test('should work normally without --prompt option (default behavior)', async () => {
540
- let capturedPrompt: string | undefined
541
-
542
- const captureService = {
543
- detectAiTool: () => Effect.succeed('claude'),
544
- extractResponseTag: (output: string) => Effect.succeed(output),
545
- runPrompt: (prompt: string, input: string) => {
546
- capturedPrompt = prompt
547
- if (prompt.includes('JSON Structure for Inline Comments')) {
548
- return Effect.succeed('[]')
549
- } else {
550
- return Effect.succeed('🤖 Default Review')
551
- }
552
- },
553
- }
554
-
555
- const result = await Effect.runPromise(
556
- Effect.either(
557
- reviewCommand('12345', {}).pipe(
558
- Effect.provide(Layer.succeed(AiService, captureService)),
559
- Effect.provide(Layer.succeed(GerritApiService, mockApiService)),
560
- Effect.provide(Layer.succeed(ConfigService, createMockConfigService())),
561
- ),
96
+ Effect.runPromise(
97
+ Effect.gen(function* () {
98
+ const service = yield* GitWorktreeService
99
+ return yield* service.createWorktree('change-2')
100
+ }).pipe(Effect.provide(Layer.succeed(GitWorktreeService, mockGitService))),
562
101
  ),
563
- )
564
-
565
- expect(result._tag).toBe('Right')
102
+ ])
566
103
 
567
- // Should not show any custom prompt messages
568
- expect(consoleSpy.log).not.toHaveBeenCalledWith(
569
- expect.stringContaining('Using custom review prompt'),
570
- )
571
- expect(consoleSpy.log).not.toHaveBeenCalledWith(
572
- expect.stringContaining('Could not read custom prompt file'),
573
- )
574
-
575
- // Should use default review prompt
576
- expect(capturedPrompt).toContain('Code Review Guidelines')
104
+ // Verify both worktrees have unique paths
105
+ expect(result1.path).not.toBe(result2.path)
106
+ expect(result1.changeId).toBe('change-1')
107
+ expect(result2.changeId).toBe('change-2')
577
108
  })
578
109
 
579
- test('should validate and fix inline comment file paths', async () => {
580
- const aiService = {
581
- detectAiTool: () => Effect.succeed('claude'),
582
- extractResponseTag: (output: string) => Effect.succeed(output),
583
- runPrompt: (prompt: string, input: string) => {
584
- if (prompt.includes('JSON Structure for Inline Comments')) {
585
- // Return comments with incomplete file paths that need fixing
586
- return Effect.succeed(
587
- JSON.stringify([
588
- {
589
- file: 'users_controller.rb',
590
- line: 10,
591
- message: '🤖 Test comment with incomplete path',
592
- },
593
- {
594
- file: 'app/controllers/users_controller.rb',
595
- line: 20,
596
- message: '🤖 Test comment with complete path',
597
- },
598
- { file: 'nonexistent.rb', line: 30, message: '🤖 Test comment for nonexistent file' },
599
- { file: 'helper.rb', line: 40, message: '🤖 Test comment with partial path' },
600
- ]),
601
- )
602
- }
603
- return Effect.succeed('Overall review comment')
604
- },
110
+ test('should handle error scenarios in worktree operations', async () => {
111
+ const failingGitService = {
112
+ validatePreconditions: () =>
113
+ Effect.fail(new WorktreeCreationError({ message: 'Git repository validation failed' })),
114
+ createWorktree: () =>
115
+ Effect.fail(new WorktreeCreationError({ message: 'Worktree creation failed' })),
116
+ fetchAndCheckoutPatchset: () =>
117
+ Effect.fail(new WorktreeCreationError({ message: 'Patchset fetch failed' })),
118
+ cleanup: () => Effect.succeed(undefined), // Cleanup should never fail
119
+ getChangedFiles: () => Effect.fail(new WorktreeCreationError({ message: 'Git diff failed' })),
605
120
  }
606
121
 
607
- const result = await Effect.runPromiseExit(
608
- reviewCommand('12345', { comment: true, yes: true }).pipe(
609
- Effect.provide(Layer.succeed(AiService, aiService)),
610
- Effect.provide(Layer.succeed(GerritApiService, mockApiService)),
611
- Effect.provide(Layer.succeed(ConfigService, createMockConfigService())),
612
- ),
122
+ // Test validation failure
123
+ const validationResult = await Effect.runPromiseExit(
124
+ Effect.gen(function* () {
125
+ const service = yield* GitWorktreeService
126
+ yield* service.validatePreconditions()
127
+ }).pipe(Effect.provide(Layer.succeed(GitWorktreeService, failingGitService))),
613
128
  )
614
129
 
615
- expect(result._tag).toBe('Success')
616
-
617
- // Verify that the postReview was called (meaning some comments were valid after validation)
618
- expect(mockApiService.postReview).toHaveBeenCalled()
619
- })
620
-
621
- test('should combine custom prompt with system prompts correctly', async () => {
622
- const customPromptContent = 'CUSTOM: Focus on security issues\nAnd performance concerns'
623
- const tempDir = require('os').tmpdir()
624
- const tempFile = require('path').join(tempDir, `test-combine-prompt-${Date.now()}.md`)
625
-
626
- require('fs').writeFileSync(tempFile, customPromptContent, 'utf8')
627
-
628
- let inlinePromptCaptured: string | undefined
629
- let overallPromptCaptured: string | undefined
630
-
631
- const captureService = {
632
- detectAiTool: () => Effect.succeed('claude'),
633
- extractResponseTag: (output: string) => Effect.succeed(output),
634
- runPrompt: (prompt: string, input: string) => {
635
- if (prompt.includes('JSON Structure for Inline Comments')) {
636
- inlinePromptCaptured = prompt
637
- return Effect.succeed('[]')
638
- } else {
639
- overallPromptCaptured = prompt
640
- return Effect.succeed('🤖 Combined Review')
641
- }
642
- },
643
- }
644
-
645
- try {
646
- const result = await Effect.runPromise(
647
- Effect.either(
648
- reviewCommand('12345', { prompt: tempFile }).pipe(
649
- Effect.provide(Layer.succeed(AiService, captureService)),
650
- Effect.provide(Layer.succeed(GerritApiService, mockApiService)),
651
- Effect.provide(Layer.succeed(ConfigService, createMockConfigService())),
652
- ),
653
- ),
654
- )
655
-
656
- expect(result._tag).toBe('Right')
657
-
658
- // Both inline and overall prompts should contain custom content
659
- expect(inlinePromptCaptured).toContain(customPromptContent)
660
- expect(overallPromptCaptured).toContain(customPromptContent)
661
-
662
- // Both should also contain their respective system prompts
663
- expect(inlinePromptCaptured).toContain('JSON Structure for Inline Comments')
664
- expect(overallPromptCaptured).toContain('Review Structure and Formatting')
665
- } finally {
666
- require('fs').unlinkSync(tempFile)
130
+ expect(validationResult._tag).toBe('Failure')
131
+ if (validationResult._tag === 'Failure') {
132
+ expect(String(validationResult.cause)).toContain('Git repository validation failed')
667
133
  }
668
134
  })
669
135
  })