@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.
- package/README.md +16 -0
- package/docs/prd/commands.md +81 -2
- package/llms.txt +217 -0
- package/package.json +1 -1
- package/src/api/gerrit.ts +63 -60
- package/src/cli/commands/comment.ts +18 -0
- package/src/cli/commands/install-hook.ts +59 -0
- package/src/cli/commands/rebase.ts +31 -12
- package/src/cli/commands/show.ts +9 -0
- package/src/cli/commands/topic.ts +108 -0
- package/src/cli/register-commands.ts +57 -35
- package/src/schemas/gerrit.ts +2 -0
- package/tests/rebase.test.ts +114 -17
- package/tests/topic.test.ts +443 -0
- package/tests/unit/commands/install-hook.test.ts +258 -0
|
@@ -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,
|
|
23
|
+
): Effect.Effect<void, never, GerritApiService> =>
|
|
22
24
|
Effect.gen(function* () {
|
|
23
25
|
const gerritApi = yield* GerritApiService
|
|
24
26
|
|
|
25
|
-
|
|
26
|
-
|
|
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(
|
|
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
|
+
)
|
package/src/cli/commands/show.ts
CHANGED
|
@@ -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
|
|
289
|
-
.description('Rebase a change onto target branch (
|
|
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')
|
package/src/schemas/gerrit.ts
CHANGED
|
@@ -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
|
|
package/tests/rebase.test.ts
CHANGED
|
@@ -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
|
-
//
|
|
187
|
-
await
|
|
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
|
|
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('
|
|
201
|
-
expect(errorOutput).toContain('
|
|
228
|
+
expect(errorOutput).toContain('Error:')
|
|
229
|
+
expect(errorOutput).toContain('No Change-ID found in HEAD commit')
|
|
202
230
|
})
|
|
203
231
|
|
|
204
|
-
it('should
|
|
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
|
-
//
|
|
218
|
-
await
|
|
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
|
-
//
|
|
235
|
-
await
|
|
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
|
-
//
|
|
252
|
-
await
|
|
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
|
-
//
|
|
269
|
-
await
|
|
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
|
})
|