@aaronshaf/ger 1.2.10 → 2.0.0

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.
Files changed (180) hide show
  1. package/.ast-grep/rules/no-as-casting.yml +13 -0
  2. package/.claude-plugin/plugin.json +22 -0
  3. package/.github/workflows/ci-simple.yml +53 -0
  4. package/.github/workflows/ci.yml +171 -0
  5. package/.github/workflows/claude-code-review.yml +83 -0
  6. package/.github/workflows/claude.yml +50 -0
  7. package/.github/workflows/dependency-update.yml +84 -0
  8. package/.github/workflows/release.yml +166 -0
  9. package/.github/workflows/security-scan.yml +113 -0
  10. package/.github/workflows/security.yml +96 -0
  11. package/.husky/pre-commit +16 -0
  12. package/.husky/pre-push +25 -0
  13. package/.lintstagedrc.json +6 -0
  14. package/.tool-versions +1 -0
  15. package/CLAUDE.md +105 -0
  16. package/DEVELOPMENT.md +361 -0
  17. package/EXAMPLES.md +457 -0
  18. package/README.md +831 -16
  19. package/bin/ger +3 -18
  20. package/biome.json +36 -0
  21. package/bun.lock +678 -0
  22. package/bunfig.toml +8 -0
  23. package/docs/adr/0001-use-effect-for-side-effects.md +65 -0
  24. package/docs/adr/0002-use-bun-runtime.md +64 -0
  25. package/docs/adr/0003-store-credentials-in-home-directory.md +75 -0
  26. package/docs/adr/0004-use-commander-for-cli.md +76 -0
  27. package/docs/adr/0005-use-effect-schema-for-validation.md +93 -0
  28. package/docs/adr/0006-use-msw-for-api-mocking.md +89 -0
  29. package/docs/adr/0007-git-hooks-for-quality.md +94 -0
  30. package/docs/adr/0008-no-as-typecasting.md +83 -0
  31. package/docs/adr/0009-file-size-limits.md +82 -0
  32. package/docs/adr/0010-llm-friendly-xml-output.md +93 -0
  33. package/docs/adr/0011-ai-tool-strategy-pattern.md +102 -0
  34. package/docs/adr/0012-build-status-message-parsing.md +94 -0
  35. package/docs/adr/0013-git-subprocess-integration.md +98 -0
  36. package/docs/adr/0014-group-management-support.md +95 -0
  37. package/docs/adr/0015-batch-comment-processing.md +111 -0
  38. package/docs/adr/0016-flexible-change-identifiers.md +94 -0
  39. package/docs/adr/0017-git-worktree-support.md +102 -0
  40. package/docs/adr/0018-auto-install-commit-hook.md +103 -0
  41. package/docs/adr/0019-sdk-package-exports.md +95 -0
  42. package/docs/adr/0020-code-coverage-enforcement.md +105 -0
  43. package/docs/adr/0021-typescript-isolated-declarations.md +83 -0
  44. package/docs/adr/0022-biome-oxlint-tooling.md +124 -0
  45. package/docs/adr/README.md +30 -0
  46. package/docs/prd/README.md +12 -0
  47. package/docs/prd/architecture.md +325 -0
  48. package/docs/prd/commands.md +425 -0
  49. package/docs/prd/data-model.md +349 -0
  50. package/docs/prd/overview.md +124 -0
  51. package/index.ts +219 -0
  52. package/oxlint.json +24 -0
  53. package/package.json +82 -15
  54. package/scripts/check-coverage.ts +69 -0
  55. package/scripts/check-file-size.ts +38 -0
  56. package/scripts/fix-test-mocks.ts +55 -0
  57. package/skills/gerrit-workflow/SKILL.md +247 -0
  58. package/skills/gerrit-workflow/examples.md +572 -0
  59. package/skills/gerrit-workflow/reference.md +728 -0
  60. package/src/api/gerrit.ts +696 -0
  61. package/src/cli/commands/abandon.ts +65 -0
  62. package/src/cli/commands/add-reviewer.ts +156 -0
  63. package/src/cli/commands/build-status.ts +282 -0
  64. package/src/cli/commands/checkout.ts +422 -0
  65. package/src/cli/commands/comment.ts +460 -0
  66. package/src/cli/commands/comments.ts +85 -0
  67. package/src/cli/commands/diff.ts +71 -0
  68. package/src/cli/commands/extract-url.ts +266 -0
  69. package/src/cli/commands/groups-members.ts +104 -0
  70. package/src/cli/commands/groups-show.ts +169 -0
  71. package/src/cli/commands/groups.ts +137 -0
  72. package/src/cli/commands/incoming.ts +226 -0
  73. package/src/cli/commands/init.ts +164 -0
  74. package/src/cli/commands/mine.ts +115 -0
  75. package/src/cli/commands/open.ts +57 -0
  76. package/src/cli/commands/projects.ts +68 -0
  77. package/src/cli/commands/push.ts +430 -0
  78. package/src/cli/commands/rebase.ts +52 -0
  79. package/src/cli/commands/remove-reviewer.ts +123 -0
  80. package/src/cli/commands/restore.ts +50 -0
  81. package/src/cli/commands/review.ts +486 -0
  82. package/src/cli/commands/search.ts +162 -0
  83. package/src/cli/commands/setup.ts +286 -0
  84. package/src/cli/commands/show.ts +491 -0
  85. package/src/cli/commands/status.ts +35 -0
  86. package/src/cli/commands/submit.ts +108 -0
  87. package/src/cli/commands/vote.ts +119 -0
  88. package/src/cli/commands/workspace.ts +200 -0
  89. package/src/cli/index.ts +53 -0
  90. package/src/cli/register-commands.ts +659 -0
  91. package/src/cli/register-group-commands.ts +88 -0
  92. package/src/cli/register-reviewer-commands.ts +97 -0
  93. package/src/prompts/default-review.md +86 -0
  94. package/src/prompts/system-inline-review.md +135 -0
  95. package/src/prompts/system-overall-review.md +206 -0
  96. package/src/schemas/config.test.ts +245 -0
  97. package/src/schemas/config.ts +84 -0
  98. package/src/schemas/gerrit.ts +681 -0
  99. package/src/services/commit-hook.ts +314 -0
  100. package/src/services/config.test.ts +150 -0
  101. package/src/services/config.ts +250 -0
  102. package/src/services/git-worktree.ts +342 -0
  103. package/src/services/review-strategy.ts +292 -0
  104. package/src/test-utils/mock-generator.ts +138 -0
  105. package/src/utils/change-id.test.ts +98 -0
  106. package/src/utils/change-id.ts +63 -0
  107. package/src/utils/comment-formatters.ts +153 -0
  108. package/src/utils/diff-context.ts +103 -0
  109. package/src/utils/diff-formatters.ts +141 -0
  110. package/src/utils/formatters.ts +85 -0
  111. package/src/utils/git-commit.test.ts +277 -0
  112. package/src/utils/git-commit.ts +122 -0
  113. package/src/utils/index.ts +55 -0
  114. package/src/utils/message-filters.ts +26 -0
  115. package/src/utils/review-formatters.ts +89 -0
  116. package/src/utils/review-prompt-builder.ts +110 -0
  117. package/src/utils/shell-safety.ts +117 -0
  118. package/src/utils/status-indicators.ts +100 -0
  119. package/src/utils/url-parser.test.ts +271 -0
  120. package/src/utils/url-parser.ts +118 -0
  121. package/tests/abandon.test.ts +230 -0
  122. package/tests/add-reviewer.test.ts +579 -0
  123. package/tests/build-status-watch.test.ts +344 -0
  124. package/tests/build-status.test.ts +789 -0
  125. package/tests/change-id-formats.test.ts +268 -0
  126. package/tests/checkout/integration.test.ts +653 -0
  127. package/tests/checkout/parse-input.test.ts +55 -0
  128. package/tests/checkout/validation.test.ts +178 -0
  129. package/tests/comment-batch-advanced.test.ts +431 -0
  130. package/tests/comment-gerrit-api-compliance.test.ts +414 -0
  131. package/tests/comment.test.ts +708 -0
  132. package/tests/comments.test.ts +323 -0
  133. package/tests/config-service-simple.test.ts +100 -0
  134. package/tests/diff.test.ts +419 -0
  135. package/tests/extract-url.test.ts +517 -0
  136. package/tests/groups-members.test.ts +256 -0
  137. package/tests/groups-show.test.ts +323 -0
  138. package/tests/groups.test.ts +334 -0
  139. package/tests/helpers/build-status-test-setup.ts +83 -0
  140. package/tests/helpers/config-mock.ts +27 -0
  141. package/tests/incoming.test.ts +357 -0
  142. package/tests/init.test.ts +70 -0
  143. package/tests/integration/commit-hook.test.ts +246 -0
  144. package/tests/interactive-incoming.test.ts +173 -0
  145. package/tests/mine.test.ts +285 -0
  146. package/tests/mocks/msw-handlers.ts +80 -0
  147. package/tests/open.test.ts +233 -0
  148. package/tests/projects.test.ts +259 -0
  149. package/tests/rebase.test.ts +271 -0
  150. package/tests/remove-reviewer.test.ts +357 -0
  151. package/tests/restore.test.ts +237 -0
  152. package/tests/review.test.ts +135 -0
  153. package/tests/search.test.ts +712 -0
  154. package/tests/setup.test.ts +63 -0
  155. package/tests/show-auto-detect.test.ts +324 -0
  156. package/tests/show.test.ts +813 -0
  157. package/tests/status.test.ts +145 -0
  158. package/tests/submit.test.ts +316 -0
  159. package/tests/unit/commands/push.test.ts +194 -0
  160. package/tests/unit/git-branch-detection.test.ts +82 -0
  161. package/tests/unit/git-worktree.test.ts +55 -0
  162. package/tests/unit/patterns/push-patterns.test.ts +148 -0
  163. package/tests/unit/schemas/gerrit.test.ts +85 -0
  164. package/tests/unit/services/commit-hook.test.ts +132 -0
  165. package/tests/unit/services/review-strategy.test.ts +349 -0
  166. package/tests/unit/test-utils/mock-generator.test.ts +154 -0
  167. package/tests/unit/utils/comment-formatters.test.ts +415 -0
  168. package/tests/unit/utils/diff-context.test.ts +171 -0
  169. package/tests/unit/utils/diff-formatters.test.ts +165 -0
  170. package/tests/unit/utils/formatters.test.ts +411 -0
  171. package/tests/unit/utils/message-filters.test.ts +227 -0
  172. package/tests/unit/utils/shell-safety.test.ts +230 -0
  173. package/tests/unit/utils/status-indicators.test.ts +137 -0
  174. package/tests/vote.test.ts +317 -0
  175. package/tests/workspace.test.ts +295 -0
  176. package/tsconfig.json +36 -5
  177. package/src/commands/branch.ts +0 -180
  178. package/src/ger.ts +0 -22
  179. package/src/types.d.ts +0 -35
  180. package/src/utils.ts +0 -130
@@ -0,0 +1,422 @@
1
+ import { execSync } from 'node:child_process'
2
+ import { Console, Effect, Schema } from 'effect'
3
+ import chalk from 'chalk'
4
+ import { ConfigService, type ConfigError, type ConfigServiceImpl } from '@/services/config'
5
+ import { GerritApiService, type ApiError, type GerritApiServiceImpl } from '@/api/gerrit'
6
+ import { extractChangeNumber } from '@/utils/url-parser'
7
+
8
+ /** Help text for checkout command */
9
+ export const CHECKOUT_HELP_TEXT = `
10
+ Examples:
11
+ # Checkout latest patchset
12
+ $ ger checkout 12345
13
+
14
+ # Checkout specific patchset
15
+ $ ger checkout 12345/3
16
+
17
+ # Checkout by Change-ID
18
+ $ ger checkout If5a3ae8cb5a107e187447802358417f311d0c4b1
19
+
20
+ # Checkout from URL
21
+ $ ger checkout https://gerrit.example.com/c/my-project/+/392385
22
+
23
+ # Detached HEAD mode (for quick review)
24
+ $ ger checkout 12345 --detach
25
+
26
+ # Use specific remote
27
+ $ ger checkout 12345 --remote upstream
28
+
29
+ Notes:
30
+ - Creates/updates branch named review/<change-number>
31
+ - Sets upstream tracking to target branch
32
+ - Updates existing review branch if it exists`
33
+
34
+ export interface CheckoutOptions {
35
+ detach?: boolean
36
+ remote?: string
37
+ }
38
+
39
+ // Custom error for checkout-specific failures
40
+ export class CheckoutError extends Error {
41
+ readonly _tag = 'CheckoutError'
42
+ constructor(message: string) {
43
+ super(message)
44
+ this.name = 'CheckoutError'
45
+ }
46
+ }
47
+
48
+ export class NotGitRepoError extends Error {
49
+ readonly _tag = 'NotGitRepoError'
50
+ constructor(message: string) {
51
+ super(message)
52
+ this.name = 'NotGitRepoError'
53
+ }
54
+ }
55
+
56
+ export class PatchsetNotFoundError extends Error {
57
+ readonly _tag = 'PatchsetNotFoundError'
58
+ constructor(public readonly patchset: number) {
59
+ super(`Patchset ${patchset} not found`)
60
+ this.name = 'PatchsetNotFoundError'
61
+ }
62
+ }
63
+
64
+ export class InvalidInputError extends Error {
65
+ readonly _tag = 'InvalidInputError'
66
+ constructor(message: string) {
67
+ super(message)
68
+ this.name = 'InvalidInputError'
69
+ }
70
+ }
71
+
72
+ export type CheckoutErrors =
73
+ | ConfigError
74
+ | CheckoutError
75
+ | NotGitRepoError
76
+ | PatchsetNotFoundError
77
+ | ApiError
78
+ | InvalidInputError
79
+
80
+ // Git-safe string validation - prevents command injection
81
+ // Allows alphanumeric, hyphens, underscores, slashes, and dots
82
+ const GitSafeString = Schema.String.pipe(
83
+ Schema.pattern(/^[a-zA-Z0-9_\-/.]+$/),
84
+ Schema.annotations({ message: () => 'Invalid characters in git identifier' }),
85
+ )
86
+
87
+ // Gerrit ref validation (refs/changes/xx/xxxxx/x)
88
+ const GerritRef = Schema.String.pipe(
89
+ Schema.pattern(/^refs\/changes\/\d{2}\/\d+\/\d+$/),
90
+ Schema.annotations({ message: () => 'Invalid Gerrit ref format' }),
91
+ )
92
+
93
+ // Validate git-safe strings to prevent command injection
94
+ const validateGitSafe = (
95
+ value: string,
96
+ fieldName: string,
97
+ ): Effect.Effect<string, InvalidInputError> =>
98
+ Schema.decodeUnknown(GitSafeString)(value).pipe(
99
+ Effect.mapError(() => {
100
+ // Sanitize error message to avoid exposing potentially sensitive data
101
+ const sanitized = value.length > 20 ? `${value.substring(0, 20)}...` : value
102
+ return new InvalidInputError(`${fieldName} contains invalid characters: ${sanitized}`)
103
+ }),
104
+ )
105
+
106
+ const validateGerritRef = (value: string): Effect.Effect<string, InvalidInputError> =>
107
+ Schema.decodeUnknown(GerritRef)(value).pipe(
108
+ Effect.mapError(() => {
109
+ // Sanitize error message to avoid exposing potentially sensitive data
110
+ const sanitized = value.length > 30 ? `${value.substring(0, 30)}...` : value
111
+ return new InvalidInputError(`Invalid Gerrit ref format: ${sanitized}`)
112
+ }),
113
+ )
114
+
115
+ /** Parse change input to extract change ID and optional patchset */
116
+ interface ParsedChange {
117
+ changeId: string
118
+ patchset?: number
119
+ }
120
+
121
+ export const parseChangeInput = (input: string): ParsedChange => {
122
+ const trimmed = input.trim()
123
+
124
+ // 1. If it's a URL, extract change number and check for patchset
125
+ if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
126
+ const changeId = extractChangeNumber(trimmed)
127
+
128
+ // Try to extract patchset from URL path: /c/project/+/12345/3
129
+ const patchsetMatch = trimmed.match(/\/(\d+)\/(\d+)(?:\/|$)/)
130
+ if (patchsetMatch?.[2]) {
131
+ return {
132
+ changeId: patchsetMatch[1],
133
+ patchset: parseInt(patchsetMatch[2], 10),
134
+ }
135
+ }
136
+
137
+ return { changeId }
138
+ }
139
+
140
+ // 2. Check for change/patchset format: 12345/3
141
+ if (trimmed.includes('/') && !trimmed.startsWith('http')) {
142
+ const parts = trimmed.split('/')
143
+ if (parts.length === 2) {
144
+ const [changeId, patchsetStr] = parts
145
+ const patchset = parseInt(patchsetStr, 10)
146
+
147
+ if (!Number.isNaN(patchset) && patchset > 0) {
148
+ return { changeId, patchset }
149
+ }
150
+ // If patchset is invalid, just return the changeId part
151
+ return { changeId }
152
+ }
153
+ }
154
+
155
+ // 3. Plain change number or Change-ID
156
+ return { changeId: trimmed }
157
+ }
158
+
159
+ // Get git remotes
160
+ const getGitRemotes = (): Record<string, string> => {
161
+ try {
162
+ const output = execSync('git remote -v', { encoding: 'utf8', timeout: 5000 })
163
+ const remotes: Record<string, string> = {}
164
+
165
+ for (const line of output.split('\n')) {
166
+ const match = line.match(/^(\S+)\s+(\S+)\s+\(push\)$/)
167
+ if (match) {
168
+ remotes[match[1]] = match[2]
169
+ }
170
+ }
171
+
172
+ return remotes
173
+ } catch {
174
+ // Silently return empty object - remote detection is optional
175
+ return {}
176
+ }
177
+ }
178
+
179
+ // Find remote matching Gerrit host
180
+ const findMatchingRemote = (gerritHost: string): string | null => {
181
+ const remotes = getGitRemotes()
182
+
183
+ // Parse gerrit host
184
+ const gerritUrl = new URL(gerritHost)
185
+ const gerritHostname = gerritUrl.hostname
186
+
187
+ // Check each remote
188
+ for (const [name, url] of Object.entries(remotes)) {
189
+ try {
190
+ let remoteHostname: string
191
+
192
+ if (url.startsWith('git@') || url.includes('://')) {
193
+ if (url.startsWith('git@')) {
194
+ // SSH format: git@hostname:project
195
+ remoteHostname = url.split('@')[1].split(':')[0]
196
+ } else {
197
+ // HTTP format
198
+ const remoteUrl = new URL(url)
199
+ remoteHostname = remoteUrl.hostname
200
+ }
201
+
202
+ if (remoteHostname === gerritHostname) {
203
+ return name
204
+ }
205
+ }
206
+ } catch {
207
+ // Ignore malformed URLs
208
+ }
209
+ }
210
+
211
+ return null
212
+ }
213
+
214
+ // Check if we're in a git repo
215
+ const isInGitRepo = (): boolean => {
216
+ try {
217
+ execSync('git rev-parse --git-dir', { encoding: 'utf8', timeout: 5000 })
218
+ return true
219
+ } catch {
220
+ return false
221
+ }
222
+ }
223
+
224
+ // Get current branch name
225
+ const getCurrentBranch = (): string | null => {
226
+ try {
227
+ const branch = execSync('git symbolic-ref --short HEAD', {
228
+ encoding: 'utf8',
229
+ timeout: 5000,
230
+ }).trim()
231
+ return branch || null
232
+ } catch {
233
+ return null
234
+ }
235
+ }
236
+
237
+ // Check if a local branch exists (internal helper using Effect pattern)
238
+ const localBranchExists = (branchName: string): Effect.Effect<boolean, InvalidInputError> =>
239
+ validateGitSafe(branchName, 'branch name').pipe(
240
+ Effect.flatMap((validated) =>
241
+ Effect.sync(() => {
242
+ try {
243
+ execSync(`git rev-parse --verify ${validated}`, {
244
+ encoding: 'utf8',
245
+ stdio: ['pipe', 'pipe', 'pipe'],
246
+ timeout: 5000,
247
+ })
248
+ return true
249
+ } catch {
250
+ // Branch doesn't exist is expected, return false
251
+ return false
252
+ }
253
+ }),
254
+ ),
255
+ )
256
+
257
+ export const checkoutCommand = (
258
+ changeInput: string,
259
+ options: CheckoutOptions,
260
+ ): Effect.Effect<void, CheckoutErrors, ConfigServiceImpl | GerritApiServiceImpl> =>
261
+ Effect.gen(function* () {
262
+ // 1. Parse input
263
+ const parsed = parseChangeInput(changeInput)
264
+
265
+ // 2. Verify git repo
266
+ if (!isInGitRepo()) {
267
+ return yield* Effect.fail(new NotGitRepoError('Not in a git repository'))
268
+ }
269
+
270
+ // 3. Get config and API service
271
+ const configService = yield* ConfigService
272
+ const apiService = yield* GerritApiService
273
+ const credentials = yield* configService.getCredentials
274
+
275
+ // 4. Get change details
276
+ const change = yield* apiService.getChange(parsed.changeId)
277
+
278
+ // 5. Get revision details - use from change if available, otherwise fetch separately
279
+ const revision = yield* Effect.gen(function* () {
280
+ // If requesting a specific patchset, always fetch it
281
+ if (parsed.patchset) {
282
+ const patchsetNum = parsed.patchset
283
+ if (patchsetNum === undefined) {
284
+ return yield* Effect.fail(
285
+ new InvalidInputError('Patchset number is required but was undefined'),
286
+ )
287
+ }
288
+ return yield* apiService
289
+ .getRevision(parsed.changeId, patchsetNum.toString())
290
+ .pipe(Effect.catchAll(() => Effect.fail(new PatchsetNotFoundError(patchsetNum))))
291
+ }
292
+
293
+ // For current revision, use it from change response if available
294
+ if (change.current_revision && change.revisions) {
295
+ const currentRevision = change.revisions[change.current_revision]
296
+ if (currentRevision) {
297
+ return currentRevision
298
+ }
299
+ }
300
+
301
+ // Fallback to fetching revision separately
302
+ return yield* apiService.getRevision(parsed.changeId, 'current')
303
+ })
304
+
305
+ // 6. Validate inputs before using in shell commands
306
+ const validatedRef = yield* validateGerritRef(revision.ref)
307
+
308
+ // 7. Find matching remote and validate
309
+ const rawRemote = options.remote || findMatchingRemote(credentials.host) || 'origin'
310
+ const remote = yield* validateGitSafe(rawRemote, 'remote')
311
+
312
+ // 8. Determine branch name and validate
313
+ const rawBranchName = `review/${change._number}`
314
+ const branchName = yield* validateGitSafe(rawBranchName, 'branch name')
315
+ const currentBranch = getCurrentBranch()
316
+ const branchExists = yield* localBranchExists(branchName)
317
+
318
+ // 9. Validate target branch for upstream tracking
319
+ const targetBranch = yield* validateGitSafe(change.branch, 'target branch')
320
+
321
+ // 10. Display information
322
+ yield* Console.log(chalk.bold('Checking out Gerrit change'))
323
+ yield* Console.log(` Change: ${change._number} - ${change.subject}`)
324
+ yield* Console.log(` Patchset: ${revision._number}`)
325
+ yield* Console.log(` Status: ${change.status}`)
326
+ yield* Console.log(` Branch: ${branchName}`)
327
+ yield* Console.log(` Remote: ${remote}`)
328
+ yield* Console.log('')
329
+
330
+ // 11. Fetch the change (using validated inputs)
331
+ yield* Console.log(chalk.cyan(`Fetching ${validatedRef}...`))
332
+ yield* Effect.try({
333
+ try: () => {
334
+ execSync(`git fetch ${remote} ${validatedRef}`, { stdio: 'inherit', timeout: 60000 })
335
+ },
336
+ catch: (e) => {
337
+ const errorMsg = e instanceof Error ? e.message : String(e)
338
+ return new CheckoutError(`Failed to fetch change from remote: ${errorMsg}`)
339
+ },
340
+ })
341
+
342
+ // 12. Checkout/update branch
343
+ if (options.detach) {
344
+ // Detached HEAD mode
345
+ yield* Effect.try({
346
+ try: () => {
347
+ execSync('git checkout FETCH_HEAD', { stdio: 'inherit', timeout: 30000 })
348
+ },
349
+ catch: (e) => {
350
+ const errorMsg = e instanceof Error ? e.message : String(e)
351
+ return new CheckoutError(`Failed to checkout in detached HEAD mode: ${errorMsg}`)
352
+ },
353
+ })
354
+ yield* Console.log(chalk.green('Checked out in detached HEAD mode'))
355
+ } else {
356
+ // Named branch mode
357
+ if (branchExists) {
358
+ // Update existing branch
359
+ if (currentBranch !== branchName) {
360
+ yield* Effect.try({
361
+ try: () => {
362
+ execSync(`git checkout ${branchName}`, { stdio: 'inherit', timeout: 30000 })
363
+ },
364
+ catch: (e) => {
365
+ const errorMsg = e instanceof Error ? e.message : String(e)
366
+ return new CheckoutError(`Failed to switch to branch: ${errorMsg}`)
367
+ },
368
+ })
369
+ }
370
+ yield* Effect.try({
371
+ try: () => {
372
+ execSync('git reset --hard FETCH_HEAD', { stdio: 'inherit', timeout: 30000 })
373
+ },
374
+ catch: (e) => {
375
+ const errorMsg = e instanceof Error ? e.message : String(e)
376
+ return new CheckoutError(`Failed to update branch: ${errorMsg}`)
377
+ },
378
+ })
379
+ yield* Console.log(chalk.green(`Updated and checked out ${branchName}`))
380
+ } else {
381
+ // Create new branch
382
+ yield* Effect.try({
383
+ try: () => {
384
+ execSync(`git checkout -b ${branchName} FETCH_HEAD`, {
385
+ stdio: 'inherit',
386
+ timeout: 30000,
387
+ })
388
+ },
389
+ catch: (e) => {
390
+ const errorMsg = e instanceof Error ? e.message : String(e)
391
+ return new CheckoutError(`Failed to create branch: ${errorMsg}`)
392
+ },
393
+ })
394
+ yield* Console.log(chalk.green(`Created and checked out ${branchName}`))
395
+ }
396
+
397
+ // 13. Set upstream tracking
398
+ const upstreamRef = `${remote}/${targetBranch}`
399
+ yield* Effect.try({
400
+ try: () => {
401
+ execSync(`git branch --set-upstream-to=${upstreamRef} ${branchName}`, {
402
+ stdio: ['pipe', 'pipe', 'pipe'],
403
+ timeout: 10000,
404
+ })
405
+ },
406
+ catch: (e) => {
407
+ const errorMsg = e instanceof Error ? e.message : String(e)
408
+ return new CheckoutError(`Failed to set upstream tracking: ${errorMsg}`)
409
+ },
410
+ }).pipe(
411
+ Effect.flatMap(() => Console.log(`Tracking ${upstreamRef}`)),
412
+ Effect.catchAll(() =>
413
+ Console.log(chalk.yellow(`Note: Could not set upstream tracking to ${upstreamRef}`)),
414
+ ),
415
+ )
416
+ }
417
+
418
+ yield* Console.log('')
419
+ yield* Console.log(
420
+ chalk.cyan(`Change URL: ${credentials.host}/c/${change.project}/+/${change._number}`),
421
+ )
422
+ })