@aaronshaf/ger 0.1.11 → 0.2.0

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.
@@ -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
+ })
@@ -0,0 +1,122 @@
1
+ import { Effect } from 'effect'
2
+ import { spawn } from 'node:child_process'
3
+
4
+ /**
5
+ * Error thrown when git operations fail
6
+ */
7
+ export class GitError extends Error {
8
+ constructor(
9
+ message: string,
10
+ public readonly cause?: unknown,
11
+ ) {
12
+ super(message)
13
+ this.name = 'GitError'
14
+ }
15
+ }
16
+
17
+ /**
18
+ * Error thrown when no Change-ID is found in commit message
19
+ */
20
+ export class NoChangeIdError extends Error {
21
+ constructor(message: string) {
22
+ super(message)
23
+ this.name = 'NoChangeIdError'
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Extracts the Change-ID from a git commit message.
29
+ * Gerrit adds Change-ID as a footer line in the format: "Change-Id: I<40-char-hash>"
30
+ *
31
+ * @param message - The full commit message
32
+ * @returns The Change-ID if found, null otherwise
33
+ *
34
+ * @example
35
+ * ```ts
36
+ * const msg = "feat: add feature\n\nChange-Id: If5a3ae8cb5a107e187447802358417f311d0c4b1"
37
+ * extractChangeIdFromCommitMessage(msg) // "If5a3ae8cb5a107e187447802358417f311d0c4b1"
38
+ * ```
39
+ */
40
+ export function extractChangeIdFromCommitMessage(message: string): string | null {
41
+ // Match "Change-Id: I<40-hex-chars>" in commit footer
42
+ // Case-insensitive, allows whitespace, multiline mode
43
+ const changeIdRegex = /^Change-Id:\s*(I[0-9a-f]{40})\s*$/im
44
+
45
+ const match = message.match(changeIdRegex)
46
+ return match ? match[1] : null
47
+ }
48
+
49
+ /**
50
+ * Runs a git command and returns the output
51
+ */
52
+ const runGitCommand = (args: readonly string[]): Effect.Effect<string, GitError> =>
53
+ Effect.async<string, GitError>((resume) => {
54
+ const child = spawn('git', [...args], {
55
+ stdio: ['ignore', 'pipe', 'pipe'],
56
+ })
57
+
58
+ let stdout = ''
59
+ let stderr = ''
60
+
61
+ child.stdout.on('data', (data: Buffer) => {
62
+ stdout += data.toString()
63
+ })
64
+
65
+ child.stderr.on('data', (data: Buffer) => {
66
+ stderr += data.toString()
67
+ })
68
+
69
+ child.on('error', (error: Error) => {
70
+ resume(Effect.fail(new GitError('Failed to execute git command', error)))
71
+ })
72
+
73
+ child.on('close', (code: number | null) => {
74
+ if (code === 0) {
75
+ resume(Effect.succeed(stdout.trim()))
76
+ } else {
77
+ const errorMessage =
78
+ stderr.trim() || `Git command failed with exit code ${code ?? 'unknown'}`
79
+ resume(Effect.fail(new GitError(errorMessage)))
80
+ }
81
+ })
82
+ })
83
+
84
+ /**
85
+ * Gets the commit message of the HEAD commit
86
+ *
87
+ * @returns Effect that resolves to the commit message
88
+ * @throws GitError if not in a git repository or git command fails
89
+ */
90
+ export const getLastCommitMessage = (): Effect.Effect<string, GitError> =>
91
+ runGitCommand(['log', '-1', '--pretty=format:%B'])
92
+
93
+ /**
94
+ * Extracts the Change-ID from the HEAD commit message
95
+ *
96
+ * @returns Effect that resolves to the Change-ID
97
+ * @throws GitError if not in a git repository or git command fails
98
+ * @throws NoChangeIdError if no Change-ID is found in the commit message
99
+ *
100
+ * @example
101
+ * ```ts
102
+ * const effect = getChangeIdFromHead()
103
+ * const changeId = await Effect.runPromise(effect)
104
+ * console.log(changeId) // "If5a3ae8cb5a107e187447802358417f311d0c4b1"
105
+ * ```
106
+ */
107
+ export const getChangeIdFromHead = (): Effect.Effect<string, GitError | NoChangeIdError> =>
108
+ Effect.gen(function* () {
109
+ const message = yield* getLastCommitMessage()
110
+
111
+ const changeId = extractChangeIdFromCommitMessage(message)
112
+
113
+ if (!changeId) {
114
+ return yield* Effect.fail(
115
+ new NoChangeIdError(
116
+ 'No Change-ID found in HEAD commit. Please provide a change number or Change-ID explicitly.',
117
+ ),
118
+ )
119
+ }
120
+
121
+ return changeId
122
+ })