@aaronshaf/ger 2.0.0 → 2.0.2

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,59 @@
1
+ import { Effect, Console } from 'effect'
2
+ import chalk from 'chalk'
3
+ import {
4
+ CommitHookService,
5
+ type CommitHookServiceImpl,
6
+ type HookInstallError,
7
+ type NotGitRepoError,
8
+ } from '@/services/commit-hook'
9
+ import { type ConfigError, type ConfigServiceImpl } from '@/services/config'
10
+
11
+ export interface InstallHookOptions {
12
+ force?: boolean
13
+ xml?: boolean
14
+ }
15
+
16
+ export type InstallHookErrors = ConfigError | HookInstallError | NotGitRepoError
17
+
18
+ export const installHookCommand = (
19
+ options: InstallHookOptions,
20
+ ): Effect.Effect<void, InstallHookErrors, CommitHookServiceImpl | ConfigServiceImpl> =>
21
+ Effect.gen(function* () {
22
+ const commitHookService = yield* CommitHookService
23
+
24
+ // Check if hook already exists using service method
25
+ const hookExists = yield* commitHookService.hasHook()
26
+
27
+ if (hookExists && !options.force) {
28
+ if (options.xml) {
29
+ yield* Console.log('<?xml version="1.0" encoding="UTF-8"?>')
30
+ yield* Console.log('<install_hook_result>')
31
+ yield* Console.log(' <status>skipped</status>')
32
+ yield* Console.log(' <message><![CDATA[commit-msg hook already installed]]></message>')
33
+ yield* Console.log(' <hint><![CDATA[Use --force to overwrite]]></hint>')
34
+ yield* Console.log('</install_hook_result>')
35
+ } else {
36
+ yield* Console.log(chalk.yellow('commit-msg hook already installed'))
37
+ yield* Console.log(chalk.dim('Use --force to overwrite'))
38
+ }
39
+ return
40
+ }
41
+
42
+ if (hookExists && options.force) {
43
+ if (!options.xml) {
44
+ yield* Console.log(chalk.yellow('Overwriting existing commit-msg hook...'))
45
+ }
46
+ }
47
+
48
+ // Install the hook (service logs progress messages in non-XML mode)
49
+ yield* commitHookService.installHook()
50
+
51
+ // Only output XML here - service already logs success message for non-XML mode
52
+ if (options.xml) {
53
+ yield* Console.log('<?xml version="1.0" encoding="UTF-8"?>')
54
+ yield* Console.log('<install_hook_result>')
55
+ yield* Console.log(' <status>success</status>')
56
+ yield* Console.log(' <message><![CDATA[commit-msg hook installed successfully]]></message>')
57
+ yield* Console.log('</install_hook_result>')
58
+ }
59
+ })
@@ -1,5 +1,7 @@
1
1
  import { Effect } from 'effect'
2
2
  import { type ApiError, GerritApiService } from '@/api/gerrit'
3
+ import { GitError, NoChangeIdError, getChangeIdFromHead } from '@/utils/git-commit'
4
+ import { escapeXML, sanitizeCDATA } from '@/utils/shell-safety'
3
5
 
4
6
  interface RebaseOptions {
5
7
  base?: string
@@ -9,7 +11,7 @@ interface RebaseOptions {
9
11
  /**
10
12
  * Rebases a Gerrit change onto the target branch or specified base.
11
13
  *
12
- * @param changeId - Change number or Change-ID to rebase
14
+ * @param changeId - Change number or Change-ID to rebase (optional, auto-detects from HEAD if not provided)
13
15
  * @param options - Configuration options
14
16
  * @param options.base - Optional base revision to rebase onto (default: target branch HEAD)
15
17
  * @param options.xml - Whether to output in XML format for LLM consumption
@@ -18,28 +20,25 @@ interface RebaseOptions {
18
20
  export const rebaseCommand = (
19
21
  changeId?: string,
20
22
  options: RebaseOptions = {},
21
- ): Effect.Effect<void, ApiError, GerritApiService> =>
23
+ ): Effect.Effect<void, never, GerritApiService> =>
22
24
  Effect.gen(function* () {
23
25
  const gerritApi = yield* GerritApiService
24
26
 
25
- if (!changeId || changeId.trim() === '') {
26
- console.error('✗ Change ID is required')
27
- console.error(' Usage: ger rebase <change-id> [--base <ref>]')
28
- return
29
- }
27
+ // Auto-detect Change-ID from HEAD commit if not provided
28
+ const resolvedChangeId = changeId || (yield* getChangeIdFromHead())
30
29
 
31
30
  // Perform the rebase - this returns the rebased change info
32
- const change = yield* gerritApi.rebaseChange(changeId, { base: options.base })
31
+ const change = yield* gerritApi.rebaseChange(resolvedChangeId, { base: options.base })
33
32
 
34
33
  if (options.xml) {
35
34
  console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
36
35
  console.log(`<rebase_result>`)
37
36
  console.log(` <status>success</status>`)
38
37
  console.log(` <change_number>${change._number}</change_number>`)
39
- console.log(` <subject><![CDATA[${change.subject}]]></subject>`)
40
- console.log(` <branch>${change.branch}</branch>`)
38
+ console.log(` <subject><![CDATA[${sanitizeCDATA(change.subject)}]]></subject>`)
39
+ console.log(` <branch>${escapeXML(change.branch)}</branch>`)
41
40
  if (options.base) {
42
- console.log(` <base><![CDATA[${options.base}]]></base>`)
41
+ console.log(` <base><![CDATA[${sanitizeCDATA(options.base)}]]></base>`)
43
42
  }
44
43
  console.log(`</rebase_result>`)
45
44
  } else {
@@ -49,4 +48,24 @@ export const rebaseCommand = (
49
48
  console.log(` Base: ${options.base}`)
50
49
  }
51
50
  }
52
- })
51
+ }).pipe(
52
+ // Regional error boundary for the entire command
53
+ Effect.catchAll((error: ApiError | GitError | NoChangeIdError) =>
54
+ Effect.sync(() => {
55
+ const errorMessage =
56
+ error instanceof GitError || error instanceof NoChangeIdError || error instanceof Error
57
+ ? error.message
58
+ : String(error)
59
+
60
+ if (options.xml) {
61
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
62
+ console.log(`<rebase_result>`)
63
+ console.log(` <status>error</status>`)
64
+ console.log(` <error><![CDATA[${sanitizeCDATA(errorMessage)}]]></error>`)
65
+ console.log(`</rebase_result>`)
66
+ } else {
67
+ console.error(`✗ Error: ${errorMessage}`)
68
+ }
69
+ }),
70
+ ),
71
+ )
@@ -48,6 +48,7 @@ interface ChangeDetails {
48
48
  created?: string
49
49
  updated?: string
50
50
  commitMessage: string
51
+ topic?: string
51
52
  }
52
53
 
53
54
  const getChangeDetails = (
@@ -71,6 +72,7 @@ const getChangeDetails = (
71
72
  created: change.created,
72
73
  updated: change.updated,
73
74
  commitMessage: change.subject, // For now, using subject as commit message
75
+ topic: change.topic,
74
76
  }
75
77
  })
76
78
 
@@ -142,6 +144,9 @@ const formatShowPretty = (
142
144
  console.log(` Project: ${changeDetails.project}`)
143
145
  console.log(` Branch: ${changeDetails.branch}`)
144
146
  console.log(` Status: ${changeDetails.status}`)
147
+ if (changeDetails.topic) {
148
+ console.log(` Topic: ${changeDetails.topic}`)
149
+ }
145
150
  console.log(` Owner: ${changeDetails.owner.name || changeDetails.owner.email || 'Unknown'}`)
146
151
  console.log(
147
152
  ` Created: ${changeDetails.created ? formatDate(changeDetails.created) : 'Unknown'}`,
@@ -220,6 +225,7 @@ const formatShowJson = async (
220
225
  status: changeDetails.status,
221
226
  project: changeDetails.project,
222
227
  branch: changeDetails.branch,
228
+ topic: changeDetails.topic,
223
229
  owner: removeUndefined(changeDetails.owner),
224
230
  created: changeDetails.created,
225
231
  updated: changeDetails.updated,
@@ -301,6 +307,9 @@ const formatShowXml = async (
301
307
  xmlParts.push(` <status>${escapeXML(changeDetails.status)}</status>`)
302
308
  xmlParts.push(` <project>${escapeXML(changeDetails.project)}</project>`)
303
309
  xmlParts.push(` <branch>${escapeXML(changeDetails.branch)}</branch>`)
310
+ if (changeDetails.topic) {
311
+ xmlParts.push(` <topic><![CDATA[${sanitizeCDATA(changeDetails.topic)}]]></topic>`)
312
+ }
304
313
  xmlParts.push(` <owner>`)
305
314
  if (changeDetails.owner.name) {
306
315
  xmlParts.push(` <name><![CDATA[${sanitizeCDATA(changeDetails.owner.name)}]]></name>`)
@@ -0,0 +1,108 @@
1
+ import { Effect } from 'effect'
2
+ import { type ApiError, GerritApiService } from '@/api/gerrit'
3
+ import { sanitizeCDATA } from '@/utils/shell-safety'
4
+ import { getChangeIdFromHead, GitError, NoChangeIdError } from '@/utils/git-commit'
5
+
6
+ export const TOPIC_HELP_TEXT = `
7
+ Examples:
8
+ # View current topic (auto-detect from HEAD)
9
+ $ ger topic
10
+
11
+ # View topic for specific change
12
+ $ ger topic 12345
13
+
14
+ # Set topic on a change
15
+ $ ger topic 12345 my-feature
16
+
17
+ # Remove topic from a change
18
+ $ ger topic 12345 --delete
19
+ $ ger topic --delete # auto-detect from HEAD
20
+
21
+ Note: When no change-id is provided, it will be auto-detected from the HEAD commit.`
22
+
23
+ interface TopicOptions {
24
+ xml?: boolean
25
+ delete?: boolean
26
+ }
27
+
28
+ /**
29
+ * Manages topic for a Gerrit change.
30
+ *
31
+ * - No topic argument: get current topic
32
+ * - With topic argument: set topic
33
+ * - With --delete flag: remove topic
34
+ *
35
+ * @param changeId - Change number or Change-ID (auto-detects from HEAD if not provided)
36
+ * @param topic - Optional topic to set
37
+ * @param options - Configuration options
38
+ * @returns Effect that completes when the operation finishes
39
+ */
40
+ export const topicCommand = (
41
+ changeId: string | undefined,
42
+ topic: string | undefined,
43
+ options: TopicOptions = {},
44
+ ): Effect.Effect<void, ApiError | GitError | NoChangeIdError, GerritApiService> =>
45
+ Effect.gen(function* () {
46
+ const gerritApi = yield* GerritApiService
47
+
48
+ // Auto-detect Change-ID from HEAD commit if not provided
49
+ const resolvedChangeId = changeId?.trim() || (yield* getChangeIdFromHead())
50
+
51
+ // Handle delete operation
52
+ if (options.delete) {
53
+ yield* gerritApi.deleteTopic(resolvedChangeId)
54
+
55
+ if (options.xml) {
56
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
57
+ console.log(`<topic_result>`)
58
+ console.log(` <status>success</status>`)
59
+ console.log(` <action>deleted</action>`)
60
+ console.log(` <change_id><![CDATA[${sanitizeCDATA(resolvedChangeId)}]]></change_id>`)
61
+ console.log(`</topic_result>`)
62
+ } else {
63
+ console.log(`✓ Removed topic from change ${resolvedChangeId}`)
64
+ }
65
+ return
66
+ }
67
+
68
+ // Handle set operation
69
+ if (topic !== undefined && topic.trim() !== '') {
70
+ const newTopic = yield* gerritApi.setTopic(resolvedChangeId, topic)
71
+
72
+ if (options.xml) {
73
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
74
+ console.log(`<topic_result>`)
75
+ console.log(` <status>success</status>`)
76
+ console.log(` <action>set</action>`)
77
+ console.log(` <change_id><![CDATA[${sanitizeCDATA(resolvedChangeId)}]]></change_id>`)
78
+ console.log(` <topic><![CDATA[${sanitizeCDATA(newTopic)}]]></topic>`)
79
+ console.log(`</topic_result>`)
80
+ } else {
81
+ console.log(`✓ Set topic on change ${resolvedChangeId}: ${newTopic}`)
82
+ }
83
+ return
84
+ }
85
+
86
+ // Handle get operation (default)
87
+ const currentTopic = yield* gerritApi.getTopic(resolvedChangeId)
88
+
89
+ if (options.xml) {
90
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
91
+ console.log(`<topic_result>`)
92
+ console.log(` <status>success</status>`)
93
+ console.log(` <action>get</action>`)
94
+ console.log(` <change_id><![CDATA[${sanitizeCDATA(resolvedChangeId)}]]></change_id>`)
95
+ if (currentTopic) {
96
+ console.log(` <topic><![CDATA[${sanitizeCDATA(currentTopic)}]]></topic>`)
97
+ } else {
98
+ console.log(` <topic />`)
99
+ }
100
+ console.log(`</topic_result>`)
101
+ } else {
102
+ if (currentTopic) {
103
+ console.log(currentTopic)
104
+ } else {
105
+ console.log(`No topic set for change ${resolvedChangeId}`)
106
+ }
107
+ }
108
+ })
@@ -9,15 +9,17 @@ import { abandonCommand } from './commands/abandon'
9
9
  import { restoreCommand } from './commands/restore'
10
10
  import { rebaseCommand } from './commands/rebase'
11
11
  import { submitCommand } from './commands/submit'
12
+ import { topicCommand, TOPIC_HELP_TEXT } from './commands/topic'
12
13
  import { voteCommand } from './commands/vote'
13
14
  import { projectsCommand } from './commands/projects'
14
15
  import { buildStatusCommand, BUILD_STATUS_HELP_TEXT } from './commands/build-status'
15
16
  import { checkoutCommand, CHECKOUT_HELP_TEXT } from './commands/checkout'
16
- import { commentCommand } from './commands/comment'
17
+ import { commentCommand, COMMENT_HELP_TEXT } from './commands/comment'
17
18
  import { commentsCommand } from './commands/comments'
18
19
  import { diffCommand } from './commands/diff'
19
20
  import { extractUrlCommand } from './commands/extract-url'
20
21
  import { incomingCommand } from './commands/incoming'
22
+ import { installHookCommand } from './commands/install-hook'
21
23
  import { mineCommand } from './commands/mine'
22
24
  import { openCommand } from './commands/open'
23
25
  import { pushCommand, PUSH_HELP_TEXT } from './commands/push'
@@ -106,38 +108,7 @@ export function registerCommands(program: Command): void {
106
108
  .option('--unresolved', 'Mark comment as unresolved (requires human attention)')
107
109
  .option('--batch', 'Read batch comments from stdin as JSON (see examples below)')
108
110
  .option('--xml', 'XML output for LLM consumption')
109
- .addHelpText(
110
- 'after',
111
- `
112
- Examples:
113
- # Post a general comment on a change (using change number)
114
- $ ger comment 12345 -m "Looks good to me!"
115
-
116
- # Post a comment using Change-ID
117
- $ ger comment If5a3ae8cb5a107e187447802358417f311d0c4b1 -m "LGTM"
118
-
119
- # Post a comment using piped input (useful for multi-line comments or scripts)
120
- $ echo "This is a comment from stdin!" | ger comment 12345
121
- $ cat review-notes.txt | ger comment 12345
122
-
123
- # Post a line-specific comment (line number from NEW file version)
124
- $ ger comment 12345 --file src/main.js --line 42 -m "Consider using const here"
125
-
126
- # Post an unresolved comment requiring human attention
127
- $ ger comment 12345 --file src/api.js --line 15 -m "Security concern" --unresolved
128
-
129
- # Post multiple comments using batch mode
130
- $ echo '{"message": "Review complete", "comments": [
131
- {"file": "src/main.js", "line": 10, "message": "Good refactor"},
132
- {"file": "src/api.js", "line": 25, "message": "Check error handling", "unresolved": true}
133
- ]}' | ger comment 12345 --batch
134
-
135
- Note:
136
- - Both change number (e.g., 12345) and Change-ID (e.g., If5a3ae8...) formats are accepted
137
- - Line numbers refer to the actual line numbers in the NEW version of the file,
138
- NOT the line numbers shown in the diff view. To find the correct line number,
139
- look at the file after all changes have been applied.`,
140
- )
111
+ .addHelpText('after', COMMENT_HELP_TEXT)
141
112
  .action(async (changeId, options) => {
142
113
  await executeEffect(
143
114
  commentCommand(changeId, options).pipe(
@@ -285,8 +256,8 @@ Note:
285
256
 
286
257
  // rebase command
287
258
  program
288
- .command('rebase <change-id>')
289
- .description('Rebase a change onto target branch (accepts change number or Change-ID)')
259
+ .command('rebase [change-id]')
260
+ .description('Rebase a change onto target branch (auto-detects from HEAD if not provided)')
290
261
  .option('--base <ref>', 'Base revision to rebase onto (default: target branch HEAD)')
291
262
  .option('--xml', 'XML output for LLM consumption')
292
263
  .action(async (changeId, options) => {
@@ -316,6 +287,24 @@ Note:
316
287
  )
317
288
  })
318
289
 
290
+ // topic command
291
+ program
292
+ .command('topic [change-id] [topic]')
293
+ .description('Get, set, or remove topic for a change (auto-detects from HEAD if not specified)')
294
+ .option('--delete', 'Remove the topic from the change')
295
+ .option('--xml', 'XML output for LLM consumption')
296
+ .addHelpText('after', TOPIC_HELP_TEXT)
297
+ .action(async (changeId, topic, options) => {
298
+ await executeEffect(
299
+ topicCommand(changeId, topic, options).pipe(
300
+ Effect.provide(GerritApiServiceLive),
301
+ Effect.provide(ConfigServiceLive),
302
+ ),
303
+ options,
304
+ 'topic_result',
305
+ )
306
+ })
307
+
319
308
  // vote command
320
309
  program
321
310
  .command('vote <change-id>')
@@ -528,6 +517,39 @@ Note:
528
517
  }
529
518
  })
530
519
 
520
+ // install-hook command
521
+ program
522
+ .command('install-hook')
523
+ .description('Install the Gerrit commit-msg hook for automatic Change-Id generation')
524
+ .option('--force', 'Overwrite existing hook')
525
+ .option('--xml', 'XML output for LLM consumption')
526
+ .addHelpText(
527
+ 'after',
528
+ `
529
+ Examples:
530
+ # Install the commit-msg hook
531
+ $ ger install-hook
532
+
533
+ # Force reinstall (overwrite existing)
534
+ $ ger install-hook --force
535
+
536
+ Note:
537
+ - Downloads hook from your configured Gerrit server
538
+ - Installs to .git/hooks/commit-msg
539
+ - Makes hook executable (chmod +x)
540
+ - Required for commits to have Change-Id footers`,
541
+ )
542
+ .action(async (options) => {
543
+ await executeEffect(
544
+ installHookCommand(options).pipe(
545
+ Effect.provide(CommitHookServiceLive),
546
+ Effect.provide(ConfigServiceLive),
547
+ ),
548
+ options,
549
+ 'install_hook_result',
550
+ )
551
+ })
552
+
531
553
  // push command
532
554
  program
533
555
  .command('push')
@@ -118,6 +118,7 @@ export const ChangeInfo: Schema.Schema<{
118
118
  readonly work_in_progress?: boolean
119
119
  readonly current_revision?: string
120
120
  readonly revisions?: Record<string, RevisionInfoType>
121
+ readonly topic?: string
121
122
  }> = Schema.Struct({
122
123
  id: Schema.String,
123
124
  project: Schema.String,
@@ -182,6 +183,7 @@ export const ChangeInfo: Schema.Schema<{
182
183
  work_in_progress: Schema.optional(Schema.Boolean),
183
184
  current_revision: Schema.optional(Schema.String),
184
185
  revisions: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Any })),
186
+ topic: Schema.optional(Schema.String),
185
187
  })
186
188
  export type ChangeInfo = Schema.Schema.Type<typeof ChangeInfo>
187
189
 
@@ -170,7 +170,7 @@ describe('rebase command', () => {
170
170
  expect(output).toContain('</rebase_result>')
171
171
  })
172
172
 
173
- it('should handle not found errors gracefully', async () => {
173
+ it('should handle not found errors gracefully with pretty output', async () => {
174
174
  server.use(
175
175
  http.post('*/a/changes/99999/revisions/current/rebase', () => {
176
176
  return HttpResponse.text('Change not found', { status: 404 })
@@ -183,25 +183,86 @@ describe('rebase command', () => {
183
183
  Effect.provide(mockConfigLayer),
184
184
  )
185
185
 
186
- // Should fail when change is not found
187
- await expect(Effect.runPromise(program)).rejects.toThrow()
186
+ // Error boundary catches and outputs to console.error
187
+ await Effect.runPromise(program)
188
+
189
+ const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
190
+ expect(errorOutput).toContain('Error:')
188
191
  })
189
192
 
190
- it('should show error when change ID is not provided', async () => {
193
+ it('should handle not found errors with XML output when --xml flag is used', async () => {
194
+ server.use(
195
+ http.post('*/a/changes/99999/revisions/current/rebase', () => {
196
+ return HttpResponse.text('Change not found', { status: 404 })
197
+ }),
198
+ )
199
+
200
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
201
+ const program = rebaseCommand('99999', { xml: true }).pipe(
202
+ Effect.provide(GerritApiServiceLive),
203
+ Effect.provide(mockConfigLayer),
204
+ )
205
+
206
+ // Error boundary catches and outputs XML error
207
+ await Effect.runPromise(program)
208
+
209
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
210
+ expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
211
+ expect(output).toContain('<rebase_result>')
212
+ expect(output).toContain('<status>error</status>')
213
+ expect(output).toContain('<error><![CDATA[')
214
+ expect(output).toContain('</rebase_result>')
215
+ })
216
+
217
+ it('should output error to console.error when no change ID and HEAD has no Change-Id', async () => {
191
218
  const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
192
219
  const program = rebaseCommand(undefined, {}).pipe(
193
220
  Effect.provide(GerritApiServiceLive),
194
221
  Effect.provide(mockConfigLayer),
195
222
  )
196
223
 
224
+ // Error boundary catches NoChangeIdError and outputs to console.error
197
225
  await Effect.runPromise(program)
198
226
 
199
227
  const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
200
- expect(errorOutput).toContain('Change ID is required')
201
- expect(errorOutput).toContain('Usage: ger rebase <change-id>')
228
+ expect(errorOutput).toContain('Error:')
229
+ expect(errorOutput).toContain('No Change-ID found in HEAD commit')
202
230
  })
203
231
 
204
- it('should handle rebase conflicts', async () => {
232
+ it('should output XML error when no change ID and HEAD has no Change-Id with --xml flag', async () => {
233
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
234
+ const program = rebaseCommand(undefined, { xml: true }).pipe(
235
+ Effect.provide(GerritApiServiceLive),
236
+ Effect.provide(mockConfigLayer),
237
+ )
238
+
239
+ // Error boundary catches NoChangeIdError and outputs XML error
240
+ await Effect.runPromise(program)
241
+
242
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
243
+ expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
244
+ expect(output).toContain('<rebase_result>')
245
+ expect(output).toContain('<status>error</status>')
246
+ expect(output).toContain('No Change-ID found in HEAD commit')
247
+ expect(output).toContain('</rebase_result>')
248
+ })
249
+
250
+ it('should treat empty string as missing change ID and auto-detect', async () => {
251
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
252
+ const program = rebaseCommand('', {}).pipe(
253
+ Effect.provide(GerritApiServiceLive),
254
+ Effect.provide(mockConfigLayer),
255
+ )
256
+
257
+ // Empty string triggers auto-detection, which fails with NoChangeIdError
258
+ await Effect.runPromise(program)
259
+
260
+ const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
261
+ expect(errorOutput).toContain('Error:')
262
+ expect(errorOutput).toContain('No Change-ID found in HEAD commit')
263
+ })
264
+
265
+ it('should handle rebase conflicts gracefully', async () => {
205
266
  server.use(
206
267
  http.post('*/a/changes/12345/revisions/current/rebase', () => {
207
268
  return HttpResponse.text('Rebase conflict detected', { status: 409 })
@@ -214,11 +275,14 @@ describe('rebase command', () => {
214
275
  Effect.provide(mockConfigLayer),
215
276
  )
216
277
 
217
- // Should throw/fail
218
- await expect(Effect.runPromise(program)).rejects.toThrow()
278
+ // Error boundary catches and outputs to console.error
279
+ await Effect.runPromise(program)
280
+
281
+ const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
282
+ expect(errorOutput).toContain('Error:')
219
283
  })
220
284
 
221
- it('should handle API errors', async () => {
285
+ it('should handle API errors gracefully', async () => {
222
286
  server.use(
223
287
  http.post('*/a/changes/12345/revisions/current/rebase', () => {
224
288
  return HttpResponse.text('Forbidden', { status: 403 })
@@ -231,8 +295,11 @@ describe('rebase command', () => {
231
295
  Effect.provide(mockConfigLayer),
232
296
  )
233
297
 
234
- // Should throw/fail
235
- await expect(Effect.runPromise(program)).rejects.toThrow()
298
+ // Error boundary catches and outputs to console.error
299
+ await Effect.runPromise(program)
300
+
301
+ const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
302
+ expect(errorOutput).toContain('Error:')
236
303
  })
237
304
 
238
305
  it('should handle changes that are already up to date', async () => {
@@ -248,11 +315,14 @@ describe('rebase command', () => {
248
315
  Effect.provide(mockConfigLayer),
249
316
  )
250
317
 
251
- // Should throw/fail
252
- await expect(Effect.runPromise(program)).rejects.toThrow()
318
+ // Error boundary catches and outputs to console.error
319
+ await Effect.runPromise(program)
320
+
321
+ const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
322
+ expect(errorOutput).toContain('Error:')
253
323
  })
254
324
 
255
- it('should handle network errors', async () => {
325
+ it('should handle network errors gracefully', async () => {
256
326
  server.use(
257
327
  http.post('*/a/changes/12345/revisions/current/rebase', () => {
258
328
  return HttpResponse.error()
@@ -265,7 +335,34 @@ describe('rebase command', () => {
265
335
  Effect.provide(mockConfigLayer),
266
336
  )
267
337
 
268
- // Should throw/fail
269
- await expect(Effect.runPromise(program)).rejects.toThrow()
338
+ // Error boundary catches and outputs to console.error
339
+ await Effect.runPromise(program)
340
+
341
+ const errorOutput = mockConsoleError.mock.calls.map((call) => call[0]).join('\n')
342
+ expect(errorOutput).toContain('Error:')
343
+ })
344
+
345
+ it('should handle network errors with XML output', async () => {
346
+ server.use(
347
+ http.post('*/a/changes/12345/revisions/current/rebase', () => {
348
+ return HttpResponse.error()
349
+ }),
350
+ )
351
+
352
+ const mockConfigLayer = Layer.succeed(ConfigService, createMockConfigService())
353
+ const program = rebaseCommand('12345', { xml: true }).pipe(
354
+ Effect.provide(GerritApiServiceLive),
355
+ Effect.provide(mockConfigLayer),
356
+ )
357
+
358
+ // Error boundary catches and outputs XML error
359
+ await Effect.runPromise(program)
360
+
361
+ const output = mockConsoleLog.mock.calls.map((call) => call[0]).join('\n')
362
+ expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
363
+ expect(output).toContain('<rebase_result>')
364
+ expect(output).toContain('<status>error</status>')
365
+ expect(output).toContain('<error><![CDATA[')
366
+ expect(output).toContain('</rebase_result>')
270
367
  })
271
368
  })