@aaronshaf/ger 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -283,10 +283,18 @@ ger push --wip
283
283
  Rebase a change on target branch.
284
284
 
285
285
  ```bash
286
- ger rebase <change-id>
287
- ger rebase <change-id> --base <ref>
286
+ ger rebase [change-id]
287
+ ger rebase 12345
288
+ ger rebase If5a3ae8... # Change-ID format
289
+ ger rebase # Auto-detect from HEAD
290
+ ger rebase --base <ref> # Rebase onto specific ref
288
291
  ```
289
292
 
293
+ | Option | Description |
294
+ |--------|-------------|
295
+ | `--base <ref>` | Base revision to rebase onto |
296
+ | `--xml` | Output as XML for LLM consumption |
297
+
290
298
  ### submit
291
299
 
292
300
  Submit a change for merge.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaronshaf/ger",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "Gerrit CLI and SDK - A modern CLI tool and TypeScript SDK for Gerrit Code Review, built with Effect-TS",
5
5
  "keywords": [
6
6
  "gerrit",
@@ -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
+ )
@@ -285,8 +285,8 @@ Note:
285
285
 
286
286
  // rebase command
287
287
  program
288
- .command('rebase <change-id>')
289
- .description('Rebase a change onto target branch (accepts change number or Change-ID)')
288
+ .command('rebase [change-id]')
289
+ .description('Rebase a change onto target branch (auto-detects from HEAD if not provided)')
290
290
  .option('--base <ref>', 'Base revision to rebase onto (default: target branch HEAD)')
291
291
  .option('--xml', 'XML output for LLM consumption')
292
292
  .action(async (changeId, options) => {
@@ -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
  })