@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.
- package/EXAMPLES.md +409 -0
- package/index.ts +219 -0
- package/package.json +46 -1
- package/src/cli/commands/show.ts +138 -64
- package/src/services/git-worktree.ts +14 -8
- package/src/utils/index.ts +55 -0
- package/tests/show-auto-detect.test.ts +20 -2
- package/tests/show.test.ts +226 -8
package/src/cli/commands/show.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
286
|
+
xmlParts.push(` <name><![CDATA[${sanitizeCDATA(changeDetails.owner.name)}]]></name>`)
|
|
267
287
|
}
|
|
268
288
|
if (changeDetails.owner.email) {
|
|
269
|
-
|
|
289
|
+
xmlParts.push(` <email>${escapeXML(changeDetails.owner.email)}</email>`)
|
|
270
290
|
}
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
279
|
-
|
|
298
|
+
xmlParts.push(` <comments>`)
|
|
299
|
+
xmlParts.push(` <count>${commentsWithContext.length}</count>`)
|
|
280
300
|
for (const { comment } of commentsWithContext) {
|
|
281
|
-
|
|
282
|
-
if (comment.id)
|
|
283
|
-
if (comment.path)
|
|
284
|
-
if (comment.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
|
-
|
|
306
|
+
xmlParts.push(` <author><![CDATA[${sanitizeCDATA(comment.author.name)}]]></author>`)
|
|
287
307
|
}
|
|
288
|
-
if (comment.updated)
|
|
308
|
+
if (comment.updated) xmlParts.push(` <updated>${escapeXML(comment.updated)}</updated>`)
|
|
289
309
|
if (comment.message) {
|
|
290
|
-
|
|
310
|
+
xmlParts.push(` <message><![CDATA[${sanitizeCDATA(comment.message)}]]></message>`)
|
|
291
311
|
}
|
|
292
|
-
if (comment.unresolved)
|
|
293
|
-
|
|
312
|
+
if (comment.unresolved) xmlParts.push(` <unresolved>true</unresolved>`)
|
|
313
|
+
xmlParts.push(` </comment>`)
|
|
294
314
|
}
|
|
295
|
-
|
|
315
|
+
xmlParts.push(` </comments>`)
|
|
296
316
|
|
|
297
317
|
// Messages section
|
|
298
|
-
|
|
299
|
-
|
|
318
|
+
xmlParts.push(` <messages>`)
|
|
319
|
+
xmlParts.push(` <count>${messages.length}</count>`)
|
|
300
320
|
for (const message of messages) {
|
|
301
|
-
|
|
302
|
-
|
|
321
|
+
xmlParts.push(` <message>`)
|
|
322
|
+
xmlParts.push(` <id>${escapeXML(message.id)}</id>`)
|
|
303
323
|
if (message.author?.name) {
|
|
304
|
-
|
|
324
|
+
xmlParts.push(` <author><![CDATA[${sanitizeCDATA(message.author.name)}]]></author>`)
|
|
305
325
|
}
|
|
306
326
|
if (message.author?._account_id) {
|
|
307
|
-
|
|
327
|
+
xmlParts.push(` <author_id>${message.author._account_id}</author_id>`)
|
|
308
328
|
}
|
|
309
|
-
|
|
329
|
+
xmlParts.push(` <date>${escapeXML(message.date)}</date>`)
|
|
310
330
|
if (message._revision_number) {
|
|
311
|
-
|
|
331
|
+
xmlParts.push(` <revision>${message._revision_number}</revision>`)
|
|
312
332
|
}
|
|
313
333
|
if (message.tag) {
|
|
314
|
-
|
|
334
|
+
xmlParts.push(` <tag>${escapeXML(message.tag)}</tag>`)
|
|
315
335
|
}
|
|
316
|
-
|
|
317
|
-
|
|
336
|
+
xmlParts.push(` <message><![CDATA[${sanitizeCDATA(message.message)}]]></message>`)
|
|
337
|
+
xmlParts.push(` </message>`)
|
|
318
338
|
}
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
{
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
|
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,
|
|
103
|
-
Effect.async<string,
|
|
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,
|
|
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,
|
|
230
|
-
createWorktree: (changeId: string) => Effect.Effect<WorktreeInfo,
|
|
231
|
-
fetchAndCheckoutPatchset: (
|
|
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[],
|
|
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 =
|
|
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 =
|
|
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>')
|