@aaronshaf/ger 0.2.1 → 0.2.4

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.
@@ -7,6 +7,7 @@ import { formatDiffPretty } from '@/utils/diff-formatters'
7
7
  import { sanitizeCDATA, escapeXML } from '@/utils/shell-safety'
8
8
  import { formatDate } from '@/utils/formatters'
9
9
  import { getChangeIdFromHead, GitError, NoChangeIdError } from '@/utils/git-commit'
10
+ import { writeFileSync } from 'node:fs'
10
11
 
11
12
  interface ShowOptions {
12
13
  xml?: boolean
@@ -184,12 +185,12 @@ const removeUndefined = <T extends Record<string, any>>(obj: T): Partial<T> => {
184
185
  ) as Partial<T>
185
186
  }
186
187
 
187
- const formatShowJson = (
188
+ const formatShowJson = async (
188
189
  changeDetails: ChangeDetails,
189
190
  diff: string,
190
191
  commentsWithContext: Array<{ comment: CommentInfo; context?: any }>,
191
192
  messages: MessageInfo[],
192
- ): void => {
193
+ ): Promise<void> => {
193
194
  const output = {
194
195
  status: 'success',
195
196
  change: removeUndefined({
@@ -242,82 +243,118 @@ const formatShowJson = (
242
243
  ),
243
244
  }
244
245
 
245
- console.log(JSON.stringify(output, null, 2))
246
+ const jsonOutput = JSON.stringify(output, null, 2) + '\n'
247
+ // Write to stdout and ensure all data is flushed before process exits
248
+ // Using process.stdout.write with drain handling for large payloads
249
+ return new Promise<void>((resolve, reject) => {
250
+ const written = process.stdout.write(jsonOutput, (err) => {
251
+ if (err) {
252
+ reject(err)
253
+ } else {
254
+ resolve()
255
+ }
256
+ })
257
+
258
+ if (!written) {
259
+ // If write returned false, buffer is full, wait for drain
260
+ process.stdout.once('drain', resolve)
261
+ process.stdout.once('error', reject)
262
+ }
263
+ })
246
264
  }
247
265
 
248
- const formatShowXml = (
266
+ const formatShowXml = async (
249
267
  changeDetails: ChangeDetails,
250
268
  diff: string,
251
269
  commentsWithContext: Array<{ comment: CommentInfo; context?: any }>,
252
270
  messages: MessageInfo[],
253
- ): void => {
254
- console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
255
- console.log(`<show_result>`)
256
- console.log(` <status>success</status>`)
257
- console.log(` <change>`)
258
- console.log(` <id>${escapeXML(changeDetails.id)}</id>`)
259
- console.log(` <number>${changeDetails.number}</number>`)
260
- console.log(` <subject><![CDATA[${sanitizeCDATA(changeDetails.subject)}]]></subject>`)
261
- console.log(` <status>${escapeXML(changeDetails.status)}</status>`)
262
- console.log(` <project>${escapeXML(changeDetails.project)}</project>`)
263
- console.log(` <branch>${escapeXML(changeDetails.branch)}</branch>`)
264
- console.log(` <owner>`)
271
+ ): Promise<void> => {
272
+ // Build complete XML output as a single string to avoid multiple writes
273
+ const xmlParts: string[] = []
274
+ xmlParts.push(`<?xml version="1.0" encoding="UTF-8"?>`)
275
+ xmlParts.push(`<show_result>`)
276
+ xmlParts.push(` <status>success</status>`)
277
+ xmlParts.push(` <change>`)
278
+ xmlParts.push(` <id>${escapeXML(changeDetails.id)}</id>`)
279
+ xmlParts.push(` <number>${changeDetails.number}</number>`)
280
+ xmlParts.push(` <subject><![CDATA[${sanitizeCDATA(changeDetails.subject)}]]></subject>`)
281
+ xmlParts.push(` <status>${escapeXML(changeDetails.status)}</status>`)
282
+ xmlParts.push(` <project>${escapeXML(changeDetails.project)}</project>`)
283
+ xmlParts.push(` <branch>${escapeXML(changeDetails.branch)}</branch>`)
284
+ xmlParts.push(` <owner>`)
265
285
  if (changeDetails.owner.name) {
266
- console.log(` <name><![CDATA[${sanitizeCDATA(changeDetails.owner.name)}]]></name>`)
286
+ xmlParts.push(` <name><![CDATA[${sanitizeCDATA(changeDetails.owner.name)}]]></name>`)
267
287
  }
268
288
  if (changeDetails.owner.email) {
269
- console.log(` <email>${escapeXML(changeDetails.owner.email)}</email>`)
289
+ xmlParts.push(` <email>${escapeXML(changeDetails.owner.email)}</email>`)
270
290
  }
271
- console.log(` </owner>`)
272
- console.log(` <created>${escapeXML(changeDetails.created || '')}</created>`)
273
- console.log(` <updated>${escapeXML(changeDetails.updated || '')}</updated>`)
274
- console.log(` </change>`)
275
- console.log(` <diff><![CDATA[${sanitizeCDATA(diff)}]]></diff>`)
291
+ xmlParts.push(` </owner>`)
292
+ xmlParts.push(` <created>${escapeXML(changeDetails.created || '')}</created>`)
293
+ xmlParts.push(` <updated>${escapeXML(changeDetails.updated || '')}</updated>`)
294
+ xmlParts.push(` </change>`)
295
+ xmlParts.push(` <diff><![CDATA[${sanitizeCDATA(diff)}]]></diff>`)
276
296
 
277
297
  // Comments section
278
- console.log(` <comments>`)
279
- console.log(` <count>${commentsWithContext.length}</count>`)
298
+ xmlParts.push(` <comments>`)
299
+ xmlParts.push(` <count>${commentsWithContext.length}</count>`)
280
300
  for (const { comment } of commentsWithContext) {
281
- console.log(` <comment>`)
282
- if (comment.id) console.log(` <id>${escapeXML(comment.id)}</id>`)
283
- if (comment.path) console.log(` <path><![CDATA[${sanitizeCDATA(comment.path)}]]></path>`)
284
- if (comment.line) console.log(` <line>${comment.line}</line>`)
301
+ xmlParts.push(` <comment>`)
302
+ if (comment.id) xmlParts.push(` <id>${escapeXML(comment.id)}</id>`)
303
+ if (comment.path) xmlParts.push(` <path><![CDATA[${sanitizeCDATA(comment.path)}]]></path>`)
304
+ if (comment.line) xmlParts.push(` <line>${comment.line}</line>`)
285
305
  if (comment.author?.name) {
286
- console.log(` <author><![CDATA[${sanitizeCDATA(comment.author.name)}]]></author>`)
306
+ xmlParts.push(` <author><![CDATA[${sanitizeCDATA(comment.author.name)}]]></author>`)
287
307
  }
288
- if (comment.updated) console.log(` <updated>${escapeXML(comment.updated)}</updated>`)
308
+ if (comment.updated) xmlParts.push(` <updated>${escapeXML(comment.updated)}</updated>`)
289
309
  if (comment.message) {
290
- console.log(` <message><![CDATA[${sanitizeCDATA(comment.message)}]]></message>`)
310
+ xmlParts.push(` <message><![CDATA[${sanitizeCDATA(comment.message)}]]></message>`)
291
311
  }
292
- if (comment.unresolved) console.log(` <unresolved>true</unresolved>`)
293
- console.log(` </comment>`)
312
+ if (comment.unresolved) xmlParts.push(` <unresolved>true</unresolved>`)
313
+ xmlParts.push(` </comment>`)
294
314
  }
295
- console.log(` </comments>`)
315
+ xmlParts.push(` </comments>`)
296
316
 
297
317
  // Messages section
298
- console.log(` <messages>`)
299
- console.log(` <count>${messages.length}</count>`)
318
+ xmlParts.push(` <messages>`)
319
+ xmlParts.push(` <count>${messages.length}</count>`)
300
320
  for (const message of messages) {
301
- console.log(` <message>`)
302
- console.log(` <id>${escapeXML(message.id)}</id>`)
321
+ xmlParts.push(` <message>`)
322
+ xmlParts.push(` <id>${escapeXML(message.id)}</id>`)
303
323
  if (message.author?.name) {
304
- console.log(` <author><![CDATA[${sanitizeCDATA(message.author.name)}]]></author>`)
324
+ xmlParts.push(` <author><![CDATA[${sanitizeCDATA(message.author.name)}]]></author>`)
305
325
  }
306
326
  if (message.author?._account_id) {
307
- console.log(` <author_id>${message.author._account_id}</author_id>`)
327
+ xmlParts.push(` <author_id>${message.author._account_id}</author_id>`)
308
328
  }
309
- console.log(` <date>${escapeXML(message.date)}</date>`)
329
+ xmlParts.push(` <date>${escapeXML(message.date)}</date>`)
310
330
  if (message._revision_number) {
311
- console.log(` <revision>${message._revision_number}</revision>`)
331
+ xmlParts.push(` <revision>${message._revision_number}</revision>`)
312
332
  }
313
333
  if (message.tag) {
314
- console.log(` <tag>${escapeXML(message.tag)}</tag>`)
334
+ xmlParts.push(` <tag>${escapeXML(message.tag)}</tag>`)
315
335
  }
316
- console.log(` <message><![CDATA[${sanitizeCDATA(message.message)}]]></message>`)
317
- console.log(` </message>`)
336
+ xmlParts.push(` <message><![CDATA[${sanitizeCDATA(message.message)}]]></message>`)
337
+ xmlParts.push(` </message>`)
318
338
  }
319
- console.log(` </messages>`)
320
- console.log(`</show_result>`)
339
+ xmlParts.push(` </messages>`)
340
+ xmlParts.push(`</show_result>`)
341
+
342
+ const xmlOutput = xmlParts.join('\n') + '\n'
343
+ // Write to stdout with proper drain handling for large payloads
344
+ return new Promise<void>((resolve, reject) => {
345
+ const written = process.stdout.write(xmlOutput, (err) => {
346
+ if (err) {
347
+ reject(err)
348
+ } else {
349
+ resolve()
350
+ }
351
+ })
352
+
353
+ if (!written) {
354
+ process.stdout.once('drain', resolve)
355
+ process.stdout.once('error', reject)
356
+ }
357
+ })
321
358
  }
322
359
 
323
360
  export const showCommand = (
@@ -358,9 +395,11 @@ export const showCommand = (
358
395
 
359
396
  // Format output
360
397
  if (options.json) {
361
- formatShowJson(changeDetails, diff, commentsWithContext, messages)
398
+ yield* Effect.promise(() =>
399
+ formatShowJson(changeDetails, diff, commentsWithContext, messages),
400
+ )
362
401
  } else if (options.xml) {
363
- formatShowXml(changeDetails, diff, commentsWithContext, messages)
402
+ yield* Effect.promise(() => formatShowXml(changeDetails, diff, commentsWithContext, messages))
364
403
  } else {
365
404
  formatShowPretty(changeDetails, diff, commentsWithContext, messages)
366
405
  }
@@ -373,22 +412,57 @@ export const showCommand = (
373
412
  : String(error)
374
413
 
375
414
  if (options.json) {
376
- console.log(
377
- JSON.stringify(
378
- {
379
- status: 'error',
380
- error: errorMessage,
381
- },
382
- null,
383
- 2,
384
- ),
415
+ return Effect.promise(
416
+ () =>
417
+ new Promise<void>((resolve, reject) => {
418
+ const errorOutput =
419
+ JSON.stringify(
420
+ {
421
+ status: 'error',
422
+ error: errorMessage,
423
+ },
424
+ null,
425
+ 2,
426
+ ) + '\n'
427
+ const written = process.stdout.write(errorOutput, (err) => {
428
+ if (err) {
429
+ reject(err)
430
+ } else {
431
+ resolve()
432
+ }
433
+ })
434
+
435
+ if (!written) {
436
+ // Wait for drain if buffer is full
437
+ process.stdout.once('drain', resolve)
438
+ process.stdout.once('error', reject)
439
+ }
440
+ }),
385
441
  )
386
442
  } else if (options.xml) {
387
- console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
388
- console.log(`<show_result>`)
389
- console.log(` <status>error</status>`)
390
- console.log(` <error><![CDATA[${sanitizeCDATA(errorMessage)}]]></error>`)
391
- console.log(`</show_result>`)
443
+ return Effect.promise(
444
+ () =>
445
+ new Promise<void>((resolve, reject) => {
446
+ const xmlError =
447
+ `<?xml version="1.0" encoding="UTF-8"?>\n` +
448
+ `<show_result>\n` +
449
+ ` <status>error</status>\n` +
450
+ ` <error><![CDATA[${sanitizeCDATA(errorMessage)}]]></error>\n` +
451
+ `</show_result>\n`
452
+ const written = process.stdout.write(xmlError, (err) => {
453
+ if (err) {
454
+ reject(err)
455
+ } else {
456
+ resolve()
457
+ }
458
+ })
459
+
460
+ if (!written) {
461
+ process.stdout.once('drain', resolve)
462
+ process.stdout.once('error', reject)
463
+ }
464
+ }),
465
+ )
392
466
  } else {
393
467
  console.error(`✗ Error: ${errorMessage}`)
394
468
  }
@@ -84,7 +84,11 @@ export class NotGitRepoError
84
84
  readonly name = 'NotGitRepoError'
85
85
  }
86
86
 
87
- export type GitError = WorktreeCreationError | PatchsetFetchError | DirtyRepoError | NotGitRepoError
87
+ export type GitWorktreeError =
88
+ | WorktreeCreationError
89
+ | PatchsetFetchError
90
+ | DirtyRepoError
91
+ | NotGitRepoError
88
92
 
89
93
  // Worktree info
90
94
  export interface WorktreeInfo {
@@ -99,8 +103,8 @@ export interface WorktreeInfo {
99
103
  const runGitCommand = (
100
104
  args: string[],
101
105
  options: { cwd?: string } = {},
102
- ): Effect.Effect<string, GitError, never> =>
103
- Effect.async<string, GitError, never>((resume) => {
106
+ ): Effect.Effect<string, GitWorktreeError, never> =>
107
+ Effect.async<string, GitWorktreeError, never>((resume) => {
104
108
  const child = spawn('git', args, {
105
109
  cwd: options.cwd || process.cwd(),
106
110
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -182,7 +186,7 @@ const buildRefspec = (changeNumber: string, patchsetNumber: number = 1): string
182
186
  }
183
187
 
184
188
  // Get the current HEAD commit hash to avoid branch conflicts
185
- const getCurrentCommit = (): Effect.Effect<string, GitError, never> =>
189
+ const getCurrentCommit = (): Effect.Effect<string, GitWorktreeError, never> =>
186
190
  pipe(
187
191
  runGitCommand(['rev-parse', 'HEAD']),
188
192
  Effect.map((output) => output.trim()),
@@ -226,11 +230,13 @@ const getLatestPatchsetNumber = (
226
230
 
227
231
  // GitWorktreeService implementation
228
232
  export interface GitWorktreeServiceImpl {
229
- validatePreconditions: () => Effect.Effect<void, GitError, never>
230
- createWorktree: (changeId: string) => Effect.Effect<WorktreeInfo, GitError, never>
231
- fetchAndCheckoutPatchset: (worktreeInfo: WorktreeInfo) => Effect.Effect<void, GitError, never>
233
+ validatePreconditions: () => Effect.Effect<void, GitWorktreeError, never>
234
+ createWorktree: (changeId: string) => Effect.Effect<WorktreeInfo, GitWorktreeError, never>
235
+ fetchAndCheckoutPatchset: (
236
+ worktreeInfo: WorktreeInfo,
237
+ ) => Effect.Effect<void, GitWorktreeError, never>
232
238
  cleanup: (worktreeInfo: WorktreeInfo) => Effect.Effect<void, never, never>
233
- getChangedFiles: () => Effect.Effect<string[], GitError, never>
239
+ getChangedFiles: () => Effect.Effect<string[], GitWorktreeError, never>
234
240
  }
235
241
 
236
242
  const GitWorktreeServiceImplLive: GitWorktreeServiceImpl = {
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Utility functions for working with Gerrit
3
+ * @module utils
4
+ */
5
+
6
+ // Change ID utilities
7
+ export {
8
+ normalizeChangeIdentifier,
9
+ isChangeId,
10
+ isChangeNumber,
11
+ isValidChangeIdentifier,
12
+ getIdentifierType,
13
+ } from './change-id'
14
+
15
+ // Git commit utilities
16
+ export {
17
+ extractChangeIdFromCommitMessage,
18
+ getLastCommitMessage,
19
+ getChangeIdFromHead,
20
+ GitError,
21
+ NoChangeIdError,
22
+ } from './git-commit'
23
+
24
+ // URL parsing
25
+ export {
26
+ extractChangeNumber,
27
+ normalizeGerritHost,
28
+ isValidChangeId,
29
+ } from './url-parser'
30
+
31
+ // Message filtering
32
+ export { filterMeaningfulMessages, sortMessagesByDate } from './message-filters'
33
+
34
+ // Shell safety
35
+ export { sanitizeCDATA } from './shell-safety'
36
+
37
+ // Formatters
38
+ export {
39
+ formatDate,
40
+ getStatusIndicator,
41
+ colors,
42
+ } from './formatters'
43
+
44
+ export {
45
+ formatCommentsPretty,
46
+ formatCommentsXml,
47
+ type CommentWithContext,
48
+ } from './comment-formatters'
49
+
50
+ export {
51
+ formatDiffPretty,
52
+ formatDiffSummary,
53
+ formatFilesList,
54
+ extractDiffStats,
55
+ } from './diff-formatters'
@@ -75,6 +75,7 @@ ${JSON.stringify(mockChange)}`)
75
75
 
76
76
  let capturedLogs: string[] = []
77
77
  let capturedErrors: string[] = []
78
+ let capturedStdout: string[] = []
78
79
 
79
80
  const mockConsoleLog = mock((...args: any[]) => {
80
81
  capturedLogs.push(args.join(' '))
@@ -83,8 +84,19 @@ const mockConsoleError = mock((...args: any[]) => {
83
84
  capturedErrors.push(args.join(' '))
84
85
  })
85
86
 
87
+ // Mock process.stdout.write to capture JSON/XML output and handle callbacks
88
+ const mockStdoutWrite = mock((chunk: any, callback?: any) => {
89
+ capturedStdout.push(String(chunk))
90
+ // Call the callback synchronously if provided
91
+ if (typeof callback === 'function') {
92
+ callback()
93
+ }
94
+ return true
95
+ })
96
+
86
97
  const originalConsoleLog = console.log
87
98
  const originalConsoleError = console.error
99
+ const originalStdoutWrite = process.stdout.write
88
100
 
89
101
  let spawnSpy: ReturnType<typeof spyOn>
90
102
 
@@ -94,20 +106,26 @@ beforeAll(() => {
94
106
  console.log = mockConsoleLog
95
107
  // @ts-ignore
96
108
  console.error = mockConsoleError
109
+ // @ts-ignore
110
+ process.stdout.write = mockStdoutWrite
97
111
  })
98
112
 
99
113
  afterAll(() => {
100
114
  server.close()
101
115
  console.log = originalConsoleLog
102
116
  console.error = originalConsoleError
117
+ // @ts-ignore
118
+ process.stdout.write = originalStdoutWrite
103
119
  })
104
120
 
105
121
  afterEach(() => {
106
122
  server.resetHandlers()
107
123
  mockConsoleLog.mockClear()
108
124
  mockConsoleError.mockClear()
125
+ mockStdoutWrite.mockClear()
109
126
  capturedLogs = []
110
127
  capturedErrors = []
128
+ capturedStdout = []
111
129
 
112
130
  if (spawnSpy) {
113
131
  spawnSpy.mockRestore()
@@ -184,7 +202,7 @@ Change-Id: If5a3ae8cb5a107e187447802358417f311d0c4b1`
184
202
 
185
203
  await resultPromise
186
204
 
187
- const output = capturedLogs.join('\n')
205
+ const output = capturedStdout.join('')
188
206
  expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
189
207
  expect(output).toContain('<show_result>')
190
208
  expect(output).toContain('<status>success</status>')
@@ -297,7 +315,7 @@ This commit has no Change-ID footer.`
297
315
 
298
316
  await resultPromise
299
317
 
300
- const output = capturedLogs.join('\n')
318
+ const output = capturedStdout.join('')
301
319
  expect(output).toContain('<?xml version="1.0" encoding="UTF-8"?>')
302
320
  expect(output).toContain('<show_result>')
303
321
  expect(output).toContain('<status>error</status>')