@aaronshaf/ger 0.1.11 → 0.2.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.
@@ -5,30 +5,84 @@ import * as path from 'node:path'
5
5
  import * as fs from 'node:fs/promises'
6
6
  import { spawn } from 'node:child_process'
7
7
 
8
- // Error types
9
- export class WorktreeCreationError extends Schema.TaggedError<WorktreeCreationError>()(
8
+ // Error types with explicit interfaces
9
+ export interface WorktreeCreationErrorFields {
10
+ readonly message: string
11
+ readonly cause?: unknown
12
+ }
13
+
14
+ const WorktreeCreationErrorSchema = Schema.TaggedError<WorktreeCreationErrorFields>()(
10
15
  'WorktreeCreationError',
11
16
  {
12
17
  message: Schema.String,
13
18
  cause: Schema.optional(Schema.Unknown),
14
19
  },
15
- ) {}
20
+ ) as unknown
21
+
22
+ export class WorktreeCreationError
23
+ extends (WorktreeCreationErrorSchema as new (
24
+ args: WorktreeCreationErrorFields,
25
+ ) => WorktreeCreationErrorFields & Error & { readonly _tag: 'WorktreeCreationError' })
26
+ implements Error
27
+ {
28
+ readonly name = 'WorktreeCreationError'
29
+ }
30
+
31
+ export interface PatchsetFetchErrorFields {
32
+ readonly message: string
33
+ readonly cause?: unknown
34
+ }
16
35
 
17
- export class PatchsetFetchError extends Schema.TaggedError<PatchsetFetchError>()(
36
+ const PatchsetFetchErrorSchema = Schema.TaggedError<PatchsetFetchErrorFields>()(
18
37
  'PatchsetFetchError',
19
38
  {
20
39
  message: Schema.String,
21
40
  cause: Schema.optional(Schema.Unknown),
22
41
  },
23
- ) {}
42
+ ) as unknown
43
+
44
+ export class PatchsetFetchError
45
+ extends (PatchsetFetchErrorSchema as new (
46
+ args: PatchsetFetchErrorFields,
47
+ ) => PatchsetFetchErrorFields & Error & { readonly _tag: 'PatchsetFetchError' })
48
+ implements Error
49
+ {
50
+ readonly name = 'PatchsetFetchError'
51
+ }
52
+
53
+ export interface DirtyRepoErrorFields {
54
+ readonly message: string
55
+ }
24
56
 
25
- export class DirtyRepoError extends Schema.TaggedError<DirtyRepoError>()('DirtyRepoError', {
57
+ const DirtyRepoErrorSchema = Schema.TaggedError<DirtyRepoErrorFields>()('DirtyRepoError', {
26
58
  message: Schema.String,
27
- }) {}
59
+ }) as unknown
60
+
61
+ export class DirtyRepoError
62
+ extends (DirtyRepoErrorSchema as new (
63
+ args: DirtyRepoErrorFields,
64
+ ) => DirtyRepoErrorFields & Error & { readonly _tag: 'DirtyRepoError' })
65
+ implements Error
66
+ {
67
+ readonly name = 'DirtyRepoError'
68
+ }
28
69
 
29
- export class NotGitRepoError extends Schema.TaggedError<NotGitRepoError>()('NotGitRepoError', {
70
+ export interface NotGitRepoErrorFields {
71
+ readonly message: string
72
+ }
73
+
74
+ const NotGitRepoErrorSchema = Schema.TaggedError<NotGitRepoErrorFields>()('NotGitRepoError', {
30
75
  message: Schema.String,
31
- }) {}
76
+ }) as unknown
77
+
78
+ export class NotGitRepoError
79
+ extends (NotGitRepoErrorSchema as new (
80
+ args: NotGitRepoErrorFields,
81
+ ) => NotGitRepoErrorFields & Error & { readonly _tag: 'NotGitRepoError' })
82
+ implements Error
83
+ {
84
+ readonly name = 'NotGitRepoError'
85
+ }
32
86
 
33
87
  export type GitError = WorktreeCreationError | PatchsetFetchError | DirtyRepoError | NotGitRepoError
34
88
 
@@ -102,23 +156,6 @@ const validateGitRepo = (): Effect.Effect<void, NotGitRepoError, never> =>
102
156
  Effect.map(() => undefined),
103
157
  )
104
158
 
105
- // Check if working directory is clean
106
- const validateCleanRepo = (): Effect.Effect<void, DirtyRepoError, never> =>
107
- pipe(
108
- runGitCommand(['status', '--porcelain']),
109
- Effect.mapError(() => new DirtyRepoError({ message: 'Failed to check repository status' })),
110
- Effect.flatMap((output) =>
111
- output.trim() === ''
112
- ? Effect.succeed(undefined)
113
- : Effect.fail(
114
- new DirtyRepoError({
115
- message:
116
- 'Working directory has uncommitted changes. Please commit or stash changes before review.',
117
- }),
118
- ),
119
- ),
120
- )
121
-
122
159
  // Generate unique worktree path
123
160
  const generateWorktreePath = (changeId: string): string => {
124
161
  const timestamp = Date.now()
@@ -286,11 +323,14 @@ const GitWorktreeServiceImplLive: GitWorktreeServiceImpl = {
286
323
  }),
287
324
  }
288
325
 
289
- // Export service tag for dependency injection
290
- export class GitWorktreeService extends Context.Tag('GitWorktreeService')<
291
- GitWorktreeService,
292
- GitWorktreeServiceImpl
293
- >() {}
326
+ // Export service tag for dependency injection with explicit type
327
+ export const GitWorktreeService: Context.Tag<GitWorktreeServiceImpl, GitWorktreeServiceImpl> =
328
+ Context.GenericTag<GitWorktreeServiceImpl>('GitWorktreeService')
294
329
 
295
- // Export service layer
296
- export const GitWorktreeServiceLive = Layer.succeed(GitWorktreeService, GitWorktreeServiceImplLive)
330
+ export type GitWorktreeService = Context.Tag.Identifier<typeof GitWorktreeService>
331
+
332
+ // Export service layer with explicit type
333
+ export const GitWorktreeServiceLive: Layer.Layer<GitWorktreeServiceImpl> = Layer.succeed(
334
+ GitWorktreeService,
335
+ GitWorktreeServiceImplLive,
336
+ )
@@ -12,10 +12,23 @@ const extractResponse = (stdout: string): string => {
12
12
  }
13
13
 
14
14
  // Simple strategy focused only on review needs
15
- export class ReviewStrategyError extends Data.TaggedError('ReviewStrategyError')<{
16
- message: string
17
- cause?: unknown
18
- }> {}
15
+ export interface ReviewStrategyErrorFields {
16
+ readonly message: string
17
+ readonly cause?: unknown
18
+ }
19
+
20
+ const ReviewStrategyErrorBase = Data.TaggedError(
21
+ 'ReviewStrategyError',
22
+ )<ReviewStrategyErrorFields> as unknown
23
+
24
+ export class ReviewStrategyError
25
+ extends (ReviewStrategyErrorBase as new (
26
+ args: ReviewStrategyErrorFields,
27
+ ) => ReviewStrategyErrorFields & Error & { readonly _tag: 'ReviewStrategyError' })
28
+ implements Error
29
+ {
30
+ readonly name = 'ReviewStrategyError'
31
+ }
19
32
 
20
33
  // Review strategy interface - focused on specific review patterns
21
34
  export interface ReviewStrategy {
@@ -202,25 +215,30 @@ export const openCodeCliStrategy: ReviewStrategy = {
202
215
  }),
203
216
  }
204
217
 
205
- // Review service using strategy pattern
206
- export class ReviewStrategyService extends Context.Tag('ReviewStrategyService')<
218
+ // Review service interface using strategy pattern
219
+ export interface ReviewStrategyServiceImpl {
220
+ readonly getAvailableStrategies: () => Effect.Effect<ReviewStrategy[], never>
221
+ readonly selectStrategy: (
222
+ preferredName?: string,
223
+ ) => Effect.Effect<ReviewStrategy, ReviewStrategyError>
224
+ readonly executeWithStrategy: (
225
+ strategy: ReviewStrategy,
226
+ prompt: string,
227
+ options?: { cwd?: string; systemPrompt?: string },
228
+ ) => Effect.Effect<string, ReviewStrategyError>
229
+ }
230
+
231
+ // Export the service tag with explicit type
232
+ export const ReviewStrategyService: Context.Tag<
233
+ ReviewStrategyServiceImpl,
234
+ ReviewStrategyServiceImpl
235
+ > = Context.GenericTag<ReviewStrategyServiceImpl>('ReviewStrategyService')
236
+
237
+ export type ReviewStrategyService = Context.Tag.Identifier<typeof ReviewStrategyService>
238
+
239
+ export const ReviewStrategyServiceLive: Layer.Layer<ReviewStrategyServiceImpl> = Layer.succeed(
207
240
  ReviewStrategyService,
208
241
  {
209
- readonly getAvailableStrategies: () => Effect.Effect<ReviewStrategy[], never>
210
- readonly selectStrategy: (
211
- preferredName?: string,
212
- ) => Effect.Effect<ReviewStrategy, ReviewStrategyError>
213
- readonly executeWithStrategy: (
214
- strategy: ReviewStrategy,
215
- prompt: string,
216
- options?: { cwd?: string; systemPrompt?: string },
217
- ) => Effect.Effect<string, ReviewStrategyError>
218
- }
219
- >() {}
220
-
221
- export const ReviewStrategyServiceLive = Layer.succeed(
222
- ReviewStrategyService,
223
- ReviewStrategyService.of({
224
242
  getAvailableStrategies: () =>
225
243
  Effect.gen(function* () {
226
244
  const strategies = [claudeCliStrategy, geminiCliStrategy, openCodeCliStrategy]
@@ -270,5 +288,5 @@ export const ReviewStrategyServiceLive = Layer.succeed(
270
288
 
271
289
  executeWithStrategy: (strategy, prompt, options = {}) =>
272
290
  strategy.executeReview(prompt, options),
273
- }),
291
+ },
274
292
  )
@@ -0,0 +1,98 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import {
3
+ isChangeId,
4
+ isChangeNumber,
5
+ isValidChangeIdentifier,
6
+ normalizeChangeIdentifier,
7
+ getIdentifierType,
8
+ } from './change-id'
9
+
10
+ describe('change-id utilities', () => {
11
+ describe('isChangeId', () => {
12
+ test('returns true for valid Change-ID format', () => {
13
+ expect(isChangeId('If5a3ae8cb5a107e187447802358417f311d0c4b1')).toBe(true)
14
+ expect(isChangeId('I0123456789abcdef0123456789abcdef01234567')).toBe(true)
15
+ })
16
+
17
+ test('returns false for invalid Change-ID format', () => {
18
+ expect(isChangeId('392385')).toBe(false)
19
+ expect(isChangeId('if5a3ae8cb5a107e187447802358417f311d0c4b1')).toBe(false) // lowercase 'i'
20
+ expect(isChangeId('If5a3ae8cb5a107e187447802358417f311d0c4b')).toBe(false) // too short
21
+ expect(isChangeId('If5a3ae8cb5a107e187447802358417f311d0c4b11')).toBe(false) // too long
22
+ expect(isChangeId('Gf5a3ae8cb5a107e187447802358417f311d0c4b1')).toBe(false) // wrong prefix
23
+ })
24
+ })
25
+
26
+ describe('isChangeNumber', () => {
27
+ test('returns true for numeric strings', () => {
28
+ expect(isChangeNumber('392385')).toBe(true)
29
+ expect(isChangeNumber('12345')).toBe(true)
30
+ expect(isChangeNumber('1')).toBe(true)
31
+ })
32
+
33
+ test('returns false for non-numeric strings', () => {
34
+ expect(isChangeNumber('If5a3ae8cb5a107e187447802358417f311d0c4b1')).toBe(false)
35
+ expect(isChangeNumber('abc')).toBe(false)
36
+ expect(isChangeNumber('123abc')).toBe(false)
37
+ expect(isChangeNumber('')).toBe(false)
38
+ })
39
+ })
40
+
41
+ describe('isValidChangeIdentifier', () => {
42
+ test('returns true for valid change numbers', () => {
43
+ expect(isValidChangeIdentifier('392385')).toBe(true)
44
+ expect(isValidChangeIdentifier('12345')).toBe(true)
45
+ })
46
+
47
+ test('returns true for valid Change-IDs', () => {
48
+ expect(isValidChangeIdentifier('If5a3ae8cb5a107e187447802358417f311d0c4b1')).toBe(true)
49
+ expect(isValidChangeIdentifier('I0123456789abcdef0123456789abcdef01234567')).toBe(true)
50
+ })
51
+
52
+ test('returns false for invalid identifiers', () => {
53
+ expect(isValidChangeIdentifier('abc')).toBe(false)
54
+ expect(isValidChangeIdentifier('I123')).toBe(false)
55
+ expect(isValidChangeIdentifier('')).toBe(false)
56
+ })
57
+ })
58
+
59
+ describe('normalizeChangeIdentifier', () => {
60
+ test('returns trimmed change number', () => {
61
+ expect(normalizeChangeIdentifier('392385')).toBe('392385')
62
+ expect(normalizeChangeIdentifier(' 392385 ')).toBe('392385')
63
+ })
64
+
65
+ test('returns trimmed Change-ID', () => {
66
+ expect(normalizeChangeIdentifier('If5a3ae8cb5a107e187447802358417f311d0c4b1')).toBe(
67
+ 'If5a3ae8cb5a107e187447802358417f311d0c4b1',
68
+ )
69
+ expect(normalizeChangeIdentifier(' If5a3ae8cb5a107e187447802358417f311d0c4b1 ')).toBe(
70
+ 'If5a3ae8cb5a107e187447802358417f311d0c4b1',
71
+ )
72
+ })
73
+
74
+ test('throws error for invalid identifiers', () => {
75
+ expect(() => normalizeChangeIdentifier('abc')).toThrow(/Invalid change identifier/)
76
+ expect(() => normalizeChangeIdentifier('I123')).toThrow(/Invalid change identifier/)
77
+ expect(() => normalizeChangeIdentifier('')).toThrow(/Invalid change identifier/)
78
+ })
79
+ })
80
+
81
+ describe('getIdentifierType', () => {
82
+ test('returns "change-number" for numeric strings', () => {
83
+ expect(getIdentifierType('392385')).toBe('change-number')
84
+ expect(getIdentifierType(' 12345 ')).toBe('change-number')
85
+ })
86
+
87
+ test('returns "change-id" for Change-ID format', () => {
88
+ expect(getIdentifierType('If5a3ae8cb5a107e187447802358417f311d0c4b1')).toBe('change-id')
89
+ expect(getIdentifierType(' If5a3ae8cb5a107e187447802358417f311d0c4b1 ')).toBe('change-id')
90
+ })
91
+
92
+ test('returns "invalid" for invalid identifiers', () => {
93
+ expect(getIdentifierType('abc')).toBe('invalid')
94
+ expect(getIdentifierType('I123')).toBe('invalid')
95
+ expect(getIdentifierType('')).toBe('invalid')
96
+ })
97
+ })
98
+ })
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Utilities for handling Gerrit change identifiers
3
+ * Supports both numeric change numbers (e.g., "392385") and Change-IDs (e.g., "If5a3ae8cb5a107e187447802358417f311d0c4b1")
4
+ */
5
+
6
+ /**
7
+ * Validates if a string is a valid Gerrit Change-ID format
8
+ * Change-IDs start with 'I' followed by a 40-character SHA-1 hash
9
+ */
10
+ export function isChangeId(value: string): boolean {
11
+ return /^I[0-9a-f]{40}$/.test(value)
12
+ }
13
+
14
+ /**
15
+ * Validates if a string is a numeric change number
16
+ */
17
+ export function isChangeNumber(value: string): boolean {
18
+ return /^\d+$/.test(value)
19
+ }
20
+
21
+ /**
22
+ * Validates if a string is either a valid Change-ID or change number
23
+ */
24
+ export function isValidChangeIdentifier(value: string): boolean {
25
+ return isChangeId(value) || isChangeNumber(value)
26
+ }
27
+
28
+ /**
29
+ * Normalizes a change identifier for use with Gerrit API
30
+ * Gerrit API accepts both formats, so we just validate and return as-is
31
+ *
32
+ * @param value - Either a numeric change number or a Change-ID
33
+ * @returns The normalized identifier
34
+ * @throws Error if the identifier is invalid
35
+ */
36
+ export function normalizeChangeIdentifier(value: string): string {
37
+ const trimmed = value.trim()
38
+
39
+ if (!isValidChangeIdentifier(trimmed)) {
40
+ throw new Error(
41
+ `Invalid change identifier: "${value}". Expected either a numeric change number (e.g., "392385") or a Change-ID starting with "I" (e.g., "If5a3ae8cb5a107e187447802358417f311d0c4b1")`,
42
+ )
43
+ }
44
+
45
+ return trimmed
46
+ }
47
+
48
+ /**
49
+ * Gets a user-friendly description of what type of identifier was provided
50
+ */
51
+ export function getIdentifierType(value: string): 'change-number' | 'change-id' | 'invalid' {
52
+ const trimmed = value.trim()
53
+
54
+ if (isChangeNumber(trimmed)) {
55
+ return 'change-number'
56
+ }
57
+
58
+ if (isChangeId(trimmed)) {
59
+ return 'change-id'
60
+ }
61
+
62
+ return 'invalid'
63
+ }
@@ -0,0 +1,277 @@
1
+ import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test'
2
+ import { Effect } from 'effect'
3
+ import {
4
+ extractChangeIdFromCommitMessage,
5
+ getLastCommitMessage,
6
+ getChangeIdFromHead,
7
+ } from './git-commit'
8
+ import * as childProcess from 'node:child_process'
9
+ import { EventEmitter } from 'node:events'
10
+
11
+ let spawnSpy: ReturnType<typeof spyOn>
12
+
13
+ describe('git-commit utilities', () => {
14
+ describe('extractChangeIdFromCommitMessage', () => {
15
+ test('extracts Change-ID from typical commit message', () => {
16
+ const message = `feat: add new feature
17
+
18
+ This is a longer description of the feature.
19
+
20
+ Change-Id: If5a3ae8cb5a107e187447802358417f311d0c4b1`
21
+
22
+ expect(extractChangeIdFromCommitMessage(message)).toBe(
23
+ 'If5a3ae8cb5a107e187447802358417f311d0c4b1',
24
+ )
25
+ })
26
+
27
+ test('extracts Change-ID with extra whitespace', () => {
28
+ const message = `fix: bug fix
29
+
30
+ Change-Id: If5a3ae8cb5a107e187447802358417f311d0c4b1 `
31
+
32
+ expect(extractChangeIdFromCommitMessage(message)).toBe(
33
+ 'If5a3ae8cb5a107e187447802358417f311d0c4b1',
34
+ )
35
+ })
36
+
37
+ test('extracts Change-ID from minimal commit', () => {
38
+ const message = `Change-Id: I0123456789abcdef0123456789abcdef01234567`
39
+
40
+ expect(extractChangeIdFromCommitMessage(message)).toBe(
41
+ 'I0123456789abcdef0123456789abcdef01234567',
42
+ )
43
+ })
44
+
45
+ test('extracts first Change-ID when multiple exist', () => {
46
+ const message = `feat: feature
47
+
48
+ Change-Id: If5a3ae8cb5a107e187447802358417f311d0c4b1
49
+ Change-Id: I1111111111111111111111111111111111111111`
50
+
51
+ expect(extractChangeIdFromCommitMessage(message)).toBe(
52
+ 'If5a3ae8cb5a107e187447802358417f311d0c4b1',
53
+ )
54
+ })
55
+
56
+ test('returns null when no Change-ID present', () => {
57
+ const message = `feat: add feature
58
+
59
+ This commit has no Change-ID footer.`
60
+
61
+ expect(extractChangeIdFromCommitMessage(message)).toBe(null)
62
+ })
63
+
64
+ test('returns null for empty message', () => {
65
+ expect(extractChangeIdFromCommitMessage('')).toBe(null)
66
+ })
67
+
68
+ test('ignores Change-ID in commit body (not footer)', () => {
69
+ const message = `feat: update
70
+
71
+ This mentions Change-Id: If5a3ae8cb5a107e187447802358417f311d0c4b1 in body
72
+ but it's not in the footer.
73
+
74
+ Signed-off-by: User`
75
+
76
+ // Should not match because it's not at the start of a line (footer position)
77
+ expect(extractChangeIdFromCommitMessage(message)).toBe(null)
78
+ })
79
+
80
+ test('handles Change-ID with lowercase hex digits', () => {
81
+ const message = `Change-Id: Iabcdef0123456789abcdef0123456789abcdef01`
82
+
83
+ expect(extractChangeIdFromCommitMessage(message)).toBe(
84
+ 'Iabcdef0123456789abcdef0123456789abcdef01',
85
+ )
86
+ })
87
+
88
+ test('returns null for malformed Change-ID (too short)', () => {
89
+ const message = `Change-Id: If5a3ae8cb5a107e187447`
90
+
91
+ expect(extractChangeIdFromCommitMessage(message)).toBe(null)
92
+ })
93
+
94
+ test('returns null for malformed Change-ID (too long)', () => {
95
+ const message = `Change-Id: If5a3ae8cb5a107e187447802358417f311d0c4b11111`
96
+
97
+ expect(extractChangeIdFromCommitMessage(message)).toBe(null)
98
+ })
99
+
100
+ test('returns null for Change-ID not starting with I', () => {
101
+ const message = `Change-Id: Gf5a3ae8cb5a107e187447802358417f311d0c4b1`
102
+
103
+ expect(extractChangeIdFromCommitMessage(message)).toBe(null)
104
+ })
105
+
106
+ test('handles CRLF line endings', () => {
107
+ const message = `feat: feature\r\n\r\nChange-Id: If5a3ae8cb5a107e187447802358417f311d0c4b1\r\n`
108
+
109
+ expect(extractChangeIdFromCommitMessage(message)).toBe(
110
+ 'If5a3ae8cb5a107e187447802358417f311d0c4b1',
111
+ )
112
+ })
113
+
114
+ test('is case-insensitive for "Change-Id" label', () => {
115
+ const message = `change-id: If5a3ae8cb5a107e187447802358417f311d0c4b1`
116
+
117
+ expect(extractChangeIdFromCommitMessage(message)).toBe(
118
+ 'If5a3ae8cb5a107e187447802358417f311d0c4b1',
119
+ )
120
+ })
121
+ })
122
+
123
+ describe('getLastCommitMessage', () => {
124
+ let mockChildProcess: EventEmitter
125
+
126
+ beforeEach(() => {
127
+ mockChildProcess = new EventEmitter()
128
+ // @ts-ignore - adding missing properties for mock
129
+ mockChildProcess.stdout = new EventEmitter()
130
+ // @ts-ignore
131
+ mockChildProcess.stderr = new EventEmitter()
132
+
133
+ spawnSpy = spyOn(childProcess, 'spawn')
134
+ spawnSpy.mockReturnValue(mockChildProcess as any)
135
+ })
136
+
137
+ afterEach(() => {
138
+ spawnSpy.mockRestore()
139
+ })
140
+
141
+ test('returns commit message on success', async () => {
142
+ const commitMessage = `feat: add feature
143
+
144
+ Change-Id: If5a3ae8cb5a107e187447802358417f311d0c4b1`
145
+
146
+ const effect = getLastCommitMessage()
147
+
148
+ const resultPromise = Effect.runPromise(effect)
149
+
150
+ // Simulate git command success
151
+ setImmediate(() => {
152
+ // @ts-ignore
153
+ mockChildProcess.stdout.emit('data', Buffer.from(commitMessage))
154
+ mockChildProcess.emit('close', 0)
155
+ })
156
+
157
+ const result = await resultPromise
158
+ expect(result).toBe(commitMessage)
159
+ })
160
+
161
+ test('throws GitError when not in git repository', async () => {
162
+ const effect = getLastCommitMessage()
163
+
164
+ const resultPromise = Effect.runPromise(effect)
165
+
166
+ setImmediate(() => {
167
+ // @ts-ignore
168
+ mockChildProcess.stderr.emit('data', Buffer.from('fatal: not a git repository'))
169
+ mockChildProcess.emit('close', 128)
170
+ })
171
+
172
+ try {
173
+ await resultPromise
174
+ expect(true).toBe(false) // Should not reach here
175
+ } catch (error: any) {
176
+ expect(error.message).toContain('fatal: not a git repository')
177
+ }
178
+ })
179
+
180
+ test('throws GitError on spawn error', async () => {
181
+ const effect = getLastCommitMessage()
182
+
183
+ const resultPromise = Effect.runPromise(effect)
184
+
185
+ setImmediate(() => {
186
+ mockChildProcess.emit('error', new Error('ENOENT: git not found'))
187
+ })
188
+
189
+ try {
190
+ await resultPromise
191
+ expect(true).toBe(false) // Should not reach here
192
+ } catch (error: any) {
193
+ expect(error.message).toContain('Failed to execute git command')
194
+ }
195
+ })
196
+ })
197
+
198
+ describe('getChangeIdFromHead', () => {
199
+ let mockChildProcess: EventEmitter
200
+
201
+ beforeEach(() => {
202
+ mockChildProcess = new EventEmitter()
203
+ // @ts-ignore
204
+ mockChildProcess.stdout = new EventEmitter()
205
+ // @ts-ignore
206
+ mockChildProcess.stderr = new EventEmitter()
207
+
208
+ spawnSpy = spyOn(childProcess, 'spawn')
209
+ spawnSpy.mockReturnValue(mockChildProcess as any)
210
+ })
211
+
212
+ afterEach(() => {
213
+ spawnSpy.mockRestore()
214
+ })
215
+
216
+ test('returns Change-ID from HEAD commit', async () => {
217
+ const commitMessage = `feat: add feature
218
+
219
+ Change-Id: If5a3ae8cb5a107e187447802358417f311d0c4b1`
220
+
221
+ const effect = getChangeIdFromHead()
222
+
223
+ const resultPromise = Effect.runPromise(effect)
224
+
225
+ setImmediate(() => {
226
+ // @ts-ignore
227
+ mockChildProcess.stdout.emit('data', Buffer.from(commitMessage))
228
+ mockChildProcess.emit('close', 0)
229
+ })
230
+
231
+ const result = await resultPromise
232
+ expect(result).toBe('If5a3ae8cb5a107e187447802358417f311d0c4b1')
233
+ })
234
+
235
+ test('throws NoChangeIdError when commit has no Change-ID', async () => {
236
+ const commitMessage = `feat: add feature
237
+
238
+ This commit has no Change-ID.`
239
+
240
+ const effect = getChangeIdFromHead()
241
+
242
+ const resultPromise = Effect.runPromise(effect)
243
+
244
+ setImmediate(() => {
245
+ // @ts-ignore
246
+ mockChildProcess.stdout.emit('data', Buffer.from(commitMessage))
247
+ mockChildProcess.emit('close', 0)
248
+ })
249
+
250
+ try {
251
+ await resultPromise
252
+ expect(true).toBe(false) // Should not reach here
253
+ } catch (error: any) {
254
+ expect(error.message).toContain('No Change-ID found in HEAD commit')
255
+ }
256
+ })
257
+
258
+ test('throws GitError when not in git repository', async () => {
259
+ const effect = getChangeIdFromHead()
260
+
261
+ const resultPromise = Effect.runPromise(effect)
262
+
263
+ setImmediate(() => {
264
+ // @ts-ignore
265
+ mockChildProcess.stderr.emit('data', Buffer.from('fatal: not a git repository'))
266
+ mockChildProcess.emit('close', 128)
267
+ })
268
+
269
+ try {
270
+ await resultPromise
271
+ expect(true).toBe(false) // Should not reach here
272
+ } catch (error: any) {
273
+ expect(error.message).toContain('fatal: not a git repository')
274
+ }
275
+ })
276
+ })
277
+ })