@aaronshaf/ger 1.2.11 → 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 -196
  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,250 @@
1
+ import * as fs from 'node:fs'
2
+ import * as os from 'node:os'
3
+ import * as path from 'node:path'
4
+ import { Schema } from '@effect/schema'
5
+ import { Context, Effect, Layer } from 'effect'
6
+ import { GerritCredentials } from '@/schemas/gerrit'
7
+ import { AiConfig, AppConfig, aiConfigFromFlat, migrateFromNestedConfig } from '@/schemas/config'
8
+
9
+ export interface ConfigServiceImpl {
10
+ readonly getCredentials: Effect.Effect<GerritCredentials, ConfigError>
11
+ readonly saveCredentials: (credentials: GerritCredentials) => Effect.Effect<void, ConfigError>
12
+ readonly deleteCredentials: Effect.Effect<void, ConfigError>
13
+ readonly getAiConfig: Effect.Effect<AiConfig, ConfigError>
14
+ readonly saveAiConfig: (config: AiConfig) => Effect.Effect<void, ConfigError>
15
+ readonly getFullConfig: Effect.Effect<AppConfig, ConfigError>
16
+ readonly saveFullConfig: (config: AppConfig) => Effect.Effect<void, ConfigError>
17
+ }
18
+
19
+ // Export both the tag value and the type for use in Effect requirements
20
+ export const ConfigService: Context.Tag<ConfigServiceImpl, ConfigServiceImpl> =
21
+ Context.GenericTag<ConfigServiceImpl>('ConfigService')
22
+ export type ConfigService = Context.Tag.Identifier<typeof ConfigService>
23
+
24
+ // Export ConfigError fields interface explicitly
25
+ export interface ConfigErrorFields {
26
+ readonly message: string
27
+ }
28
+
29
+ // Define error schema (not exported, so type can be implicit)
30
+ const ConfigErrorSchema = Schema.TaggedError<ConfigErrorFields>()('ConfigError', {
31
+ message: Schema.String,
32
+ } as const) as unknown
33
+
34
+ // Export the error class with explicit constructor signature for isolatedDeclarations
35
+ export class ConfigError
36
+ extends (ConfigErrorSchema as new (
37
+ args: ConfigErrorFields,
38
+ ) => ConfigErrorFields & Error & { readonly _tag: 'ConfigError' })
39
+ implements Error
40
+ {
41
+ readonly name = 'ConfigError'
42
+ }
43
+
44
+ // File-based storage
45
+ const CONFIG_DIR = path.join(os.homedir(), '.ger')
46
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json')
47
+
48
+ const readEnvConfig = (): unknown | null => {
49
+ const { GERRIT_HOST, GERRIT_USERNAME, GERRIT_PASSWORD } = process.env
50
+
51
+ if (GERRIT_HOST && GERRIT_USERNAME && GERRIT_PASSWORD) {
52
+ return {
53
+ host: GERRIT_HOST,
54
+ username: GERRIT_USERNAME,
55
+ password: GERRIT_PASSWORD,
56
+ aiAutoDetect: true,
57
+ }
58
+ }
59
+
60
+ return null
61
+ }
62
+
63
+ const readFileConfig = (): unknown | null => {
64
+ try {
65
+ if (fs.existsSync(CONFIG_FILE)) {
66
+ const content = fs.readFileSync(CONFIG_FILE, 'utf8')
67
+ const parsed = JSON.parse(content)
68
+
69
+ // Check if this is the old nested format and migrate if needed
70
+ if (parsed && typeof parsed === 'object' && 'credentials' in parsed) {
71
+ // Migrate from nested format to flat format with validation
72
+ const migrated = migrateFromNestedConfig(parsed)
73
+
74
+ // Save the migrated config immediately
75
+ try {
76
+ writeFileConfig(migrated)
77
+ } catch (error) {
78
+ // Log migration write failure but continue to return migrated config
79
+ console.warn('Warning: Failed to save migrated config to disk:', error)
80
+ // Config migration succeeded in memory, user can still proceed
81
+ }
82
+
83
+ return migrated
84
+ }
85
+
86
+ return parsed
87
+ }
88
+ } catch {
89
+ // Ignore errors
90
+ }
91
+ return null
92
+ }
93
+
94
+ const writeFileConfig = (config: AppConfig): void => {
95
+ // Ensure config directory exists
96
+ if (!fs.existsSync(CONFIG_DIR)) {
97
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 })
98
+ }
99
+
100
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8')
101
+ // Set restrictive permissions
102
+ fs.chmodSync(CONFIG_FILE, 0o600)
103
+ }
104
+
105
+ const deleteFileConfig = (): void => {
106
+ try {
107
+ if (fs.existsSync(CONFIG_FILE)) {
108
+ fs.unlinkSync(CONFIG_FILE)
109
+ }
110
+ } catch {
111
+ // Ignore errors
112
+ }
113
+ }
114
+
115
+ export const ConfigServiceLive: Layer.Layer<ConfigService, never, never> = Layer.effect(
116
+ ConfigService,
117
+ Effect.sync(() => {
118
+ const getFullConfig = Effect.gen(function* () {
119
+ // First try to read from file
120
+ const fileContent = readFileConfig()
121
+ if (fileContent) {
122
+ // Parse as flat config
123
+ const fullConfigResult = yield* Schema.decodeUnknown(AppConfig)(fileContent).pipe(
124
+ Effect.mapError(() => new ConfigError({ message: 'Invalid configuration format' })),
125
+ )
126
+ return fullConfigResult
127
+ }
128
+
129
+ // Fallback to environment variables
130
+ const envContent = readEnvConfig()
131
+ if (envContent) {
132
+ const fullConfigResult = yield* Schema.decodeUnknown(AppConfig)(envContent).pipe(
133
+ Effect.mapError(
134
+ () => new ConfigError({ message: 'Invalid environment configuration format' }),
135
+ ),
136
+ )
137
+ return fullConfigResult
138
+ }
139
+
140
+ // No configuration found
141
+ return yield* Effect.fail(
142
+ new ConfigError({
143
+ message:
144
+ 'Configuration not found. Run "ger setup" to set up your credentials or set GERRIT_HOST, GERRIT_USERNAME, and GERRIT_PASSWORD environment variables.',
145
+ }),
146
+ )
147
+ })
148
+
149
+ const saveFullConfig = (config: AppConfig) =>
150
+ Effect.gen(function* () {
151
+ // Validate config using schema
152
+ const validatedConfig = yield* Schema.decodeUnknown(AppConfig)(config).pipe(
153
+ Effect.mapError(() => new ConfigError({ message: 'Invalid configuration format' })),
154
+ )
155
+
156
+ try {
157
+ writeFileConfig(validatedConfig)
158
+ } catch {
159
+ yield* Effect.fail(new ConfigError({ message: 'Failed to save configuration to file' }))
160
+ }
161
+ })
162
+
163
+ const getCredentials = Effect.gen(function* () {
164
+ const config = yield* getFullConfig
165
+ return {
166
+ host: config.host,
167
+ username: config.username,
168
+ password: config.password,
169
+ }
170
+ })
171
+
172
+ const saveCredentials = (credentials: GerritCredentials) =>
173
+ Effect.gen(function* () {
174
+ // Validate credentials using schema
175
+ const validatedCredentials = yield* Schema.decodeUnknown(GerritCredentials)(
176
+ credentials,
177
+ ).pipe(Effect.mapError(() => new ConfigError({ message: 'Invalid credentials format' })))
178
+
179
+ // Get existing config or create new one
180
+ const existingConfig = yield* getFullConfig.pipe(
181
+ Effect.orElseSucceed(() => {
182
+ // Create default config using Schema validation instead of type assertion
183
+ const defaultConfig = {
184
+ host: validatedCredentials.host,
185
+ username: validatedCredentials.username,
186
+ password: validatedCredentials.password,
187
+ aiAutoDetect: true,
188
+ }
189
+ // Validate the default config structure
190
+ return Schema.decodeUnknownSync(AppConfig)(defaultConfig)
191
+ }),
192
+ )
193
+
194
+ // Update credentials in flat config
195
+ const updatedConfig: AppConfig = {
196
+ ...existingConfig,
197
+ host: validatedCredentials.host,
198
+ username: validatedCredentials.username,
199
+ password: validatedCredentials.password,
200
+ }
201
+
202
+ yield* saveFullConfig(updatedConfig)
203
+ })
204
+
205
+ const deleteCredentials = Effect.gen(function* () {
206
+ try {
207
+ deleteFileConfig()
208
+ yield* Effect.void
209
+ } catch {
210
+ // Ignore errors
211
+ yield* Effect.void
212
+ }
213
+ })
214
+
215
+ const getAiConfig = Effect.gen(function* () {
216
+ const config = yield* getFullConfig
217
+ return aiConfigFromFlat(config)
218
+ })
219
+
220
+ const saveAiConfig = (aiConfig: AiConfig) =>
221
+ Effect.gen(function* () {
222
+ // Validate AI config using schema
223
+ const validatedAiConfig = yield* Schema.decodeUnknown(AiConfig)(aiConfig).pipe(
224
+ Effect.mapError(() => new ConfigError({ message: 'Invalid AI configuration format' })),
225
+ )
226
+
227
+ // Get existing config
228
+ const existingConfig = yield* getFullConfig
229
+
230
+ // Update AI config in flat structure
231
+ const updatedConfig: AppConfig = {
232
+ ...existingConfig,
233
+ aiTool: validatedAiConfig.tool,
234
+ aiAutoDetect: validatedAiConfig.autoDetect,
235
+ }
236
+
237
+ yield* saveFullConfig(updatedConfig)
238
+ })
239
+
240
+ return {
241
+ getCredentials,
242
+ saveCredentials,
243
+ deleteCredentials,
244
+ getAiConfig,
245
+ saveAiConfig,
246
+ getFullConfig,
247
+ saveFullConfig,
248
+ }
249
+ }),
250
+ )
@@ -0,0 +1,342 @@
1
+ import { Effect, Console, pipe, Layer, Context } from 'effect'
2
+ import { Schema } from 'effect'
3
+ import * as os from 'node:os'
4
+ import * as path from 'node:path'
5
+ import * as fs from 'node:fs/promises'
6
+ import { spawn } from 'node:child_process'
7
+
8
+ // Error types with explicit interfaces
9
+ export interface WorktreeCreationErrorFields {
10
+ readonly message: string
11
+ readonly cause?: unknown
12
+ }
13
+
14
+ const WorktreeCreationErrorSchema = Schema.TaggedError<WorktreeCreationErrorFields>()(
15
+ 'WorktreeCreationError',
16
+ {
17
+ message: Schema.String,
18
+ cause: Schema.optional(Schema.Unknown),
19
+ },
20
+ ) as unknown
21
+
22
+ export class WorktreeCreationError
23
+ extends (WorktreeCreationErrorSchema as new (
24
+ args: WorktreeCreationErrorFields,
25
+ ) => WorktreeCreationErrorFields & Error & { readonly _tag: 'WorktreeCreationError' })
26
+ implements Error
27
+ {
28
+ readonly name = 'WorktreeCreationError'
29
+ }
30
+
31
+ export interface PatchsetFetchErrorFields {
32
+ readonly message: string
33
+ readonly cause?: unknown
34
+ }
35
+
36
+ const PatchsetFetchErrorSchema = Schema.TaggedError<PatchsetFetchErrorFields>()(
37
+ 'PatchsetFetchError',
38
+ {
39
+ message: Schema.String,
40
+ cause: Schema.optional(Schema.Unknown),
41
+ },
42
+ ) as unknown
43
+
44
+ export class PatchsetFetchError
45
+ extends (PatchsetFetchErrorSchema as new (
46
+ args: PatchsetFetchErrorFields,
47
+ ) => PatchsetFetchErrorFields & Error & { readonly _tag: 'PatchsetFetchError' })
48
+ implements Error
49
+ {
50
+ readonly name = 'PatchsetFetchError'
51
+ }
52
+
53
+ export interface DirtyRepoErrorFields {
54
+ readonly message: string
55
+ }
56
+
57
+ const DirtyRepoErrorSchema = Schema.TaggedError<DirtyRepoErrorFields>()('DirtyRepoError', {
58
+ message: Schema.String,
59
+ }) as unknown
60
+
61
+ export class DirtyRepoError
62
+ extends (DirtyRepoErrorSchema as new (
63
+ args: DirtyRepoErrorFields,
64
+ ) => DirtyRepoErrorFields & Error & { readonly _tag: 'DirtyRepoError' })
65
+ implements Error
66
+ {
67
+ readonly name = 'DirtyRepoError'
68
+ }
69
+
70
+ export interface NotGitRepoErrorFields {
71
+ readonly message: string
72
+ }
73
+
74
+ const NotGitRepoErrorSchema = Schema.TaggedError<NotGitRepoErrorFields>()('NotGitRepoError', {
75
+ message: Schema.String,
76
+ }) as unknown
77
+
78
+ export class NotGitRepoError
79
+ extends (NotGitRepoErrorSchema as new (
80
+ args: NotGitRepoErrorFields,
81
+ ) => NotGitRepoErrorFields & Error & { readonly _tag: 'NotGitRepoError' })
82
+ implements Error
83
+ {
84
+ readonly name = 'NotGitRepoError'
85
+ }
86
+
87
+ export type GitWorktreeError =
88
+ | WorktreeCreationError
89
+ | PatchsetFetchError
90
+ | DirtyRepoError
91
+ | NotGitRepoError
92
+
93
+ // Worktree info
94
+ export interface WorktreeInfo {
95
+ path: string
96
+ changeId: string
97
+ originalCwd: string
98
+ timestamp: number
99
+ pid: number
100
+ }
101
+
102
+ // Git command runner with Effect
103
+ const runGitCommand = (
104
+ args: string[],
105
+ options: { cwd?: string } = {},
106
+ ): Effect.Effect<string, GitWorktreeError, never> =>
107
+ Effect.async<string, GitWorktreeError, never>((resume) => {
108
+ const child = spawn('git', args, {
109
+ cwd: options.cwd || process.cwd(),
110
+ stdio: ['ignore', 'pipe', 'pipe'],
111
+ })
112
+
113
+ let stdout = ''
114
+ let stderr = ''
115
+
116
+ child.stdout?.on('data', (data) => {
117
+ stdout += data.toString()
118
+ })
119
+
120
+ child.stderr?.on('data', (data) => {
121
+ stderr += data.toString()
122
+ })
123
+
124
+ child.on('close', (code) => {
125
+ if (code === 0) {
126
+ resume(Effect.succeed(stdout.trim()))
127
+ } else {
128
+ const errorMessage = `Git command failed: git ${args.join(' ')}\nStderr: ${stderr}`
129
+
130
+ // Classify error based on command and output
131
+ if (args[0] === 'worktree' && args[1] === 'add') {
132
+ resume(Effect.fail(new WorktreeCreationError({ message: errorMessage })))
133
+ } else if (args[0] === 'fetch' || args[0] === 'checkout') {
134
+ resume(Effect.fail(new PatchsetFetchError({ message: errorMessage })))
135
+ } else {
136
+ resume(Effect.fail(new WorktreeCreationError({ message: errorMessage })))
137
+ }
138
+ }
139
+ })
140
+
141
+ child.on('error', (error) => {
142
+ resume(
143
+ Effect.fail(
144
+ new WorktreeCreationError({
145
+ message: `Failed to spawn git: ${error.message}`,
146
+ cause: error,
147
+ }),
148
+ ),
149
+ )
150
+ })
151
+ })
152
+
153
+ // Check if current directory is a git repository
154
+ const validateGitRepo = (): Effect.Effect<void, NotGitRepoError, never> =>
155
+ pipe(
156
+ runGitCommand(['rev-parse', '--git-dir']),
157
+ Effect.mapError(
158
+ () => new NotGitRepoError({ message: 'Current directory is not a git repository' }),
159
+ ),
160
+ Effect.map(() => undefined),
161
+ )
162
+
163
+ // Generate unique worktree path
164
+ const generateWorktreePath = (changeId: string): string => {
165
+ const timestamp = Date.now()
166
+ const pid = process.pid
167
+ const uniqueId = `${changeId}-${timestamp}-${pid}`
168
+ return path.join(os.homedir(), '.ger', 'worktrees', uniqueId)
169
+ }
170
+
171
+ // Ensure .ger directory exists
172
+ const ensureGerDirectory = (): Effect.Effect<void, never, never> =>
173
+ Effect.tryPromise({
174
+ try: async () => {
175
+ const gerDir = path.join(os.homedir(), '.ger', 'worktrees')
176
+ await fs.mkdir(gerDir, { recursive: true })
177
+ },
178
+ catch: () => undefined, // Ignore errors, will fail later if directory can't be created
179
+ }).pipe(Effect.catchAll(() => Effect.succeed(undefined)))
180
+
181
+ // Build Gerrit refspec for change
182
+ const buildRefspec = (changeNumber: string, patchsetNumber: number = 1): string => {
183
+ // Extract change number from changeId if it contains non-numeric characters
184
+ const numericChangeNumber = changeNumber.replace(/\D/g, '')
185
+ return `refs/changes/${numericChangeNumber.slice(-2)}/${numericChangeNumber}/${patchsetNumber}`
186
+ }
187
+
188
+ // Get the current HEAD commit hash to avoid branch conflicts
189
+ const getCurrentCommit = (): Effect.Effect<string, GitWorktreeError, never> =>
190
+ pipe(
191
+ runGitCommand(['rev-parse', 'HEAD']),
192
+ Effect.map((output) => output.trim()),
193
+ Effect.catchAll(() =>
194
+ // Fallback: try to get commit from default branch
195
+ pipe(
196
+ runGitCommand(['rev-parse', 'origin/main']),
197
+ Effect.catchAll(() => runGitCommand(['rev-parse', 'origin/master'])),
198
+ Effect.catchAll(() => Effect.succeed('HEAD')),
199
+ ),
200
+ ),
201
+ )
202
+
203
+ // Get latest patchset number for a change
204
+ const getLatestPatchsetNumber = (
205
+ changeId: string,
206
+ ): Effect.Effect<number, PatchsetFetchError, never> =>
207
+ pipe(
208
+ runGitCommand(['ls-remote', 'origin', `refs/changes/*/${changeId.replace(/\D/g, '')}/*`]),
209
+ Effect.mapError(
210
+ (error) =>
211
+ new PatchsetFetchError({ message: `Failed to get patchset info: ${error.message}` }),
212
+ ),
213
+ Effect.map((output) => {
214
+ const lines = output.split('\n').filter((line) => line.trim())
215
+ if (lines.length === 0) {
216
+ return 1 // Default to patchset 1 if no refs found
217
+ }
218
+
219
+ // Extract patchset numbers and return the highest
220
+ const patchsetNumbers = lines
221
+ .map((line) => {
222
+ const match = line.match(/refs\/changes\/\d+\/\d+\/(\d+)$/)
223
+ return match ? parseInt(match[1], 10) : 0
224
+ })
225
+ .filter((num) => num > 0)
226
+
227
+ return patchsetNumbers.length > 0 ? Math.max(...patchsetNumbers) : 1
228
+ }),
229
+ )
230
+
231
+ // GitWorktreeService implementation
232
+ export interface GitWorktreeServiceImpl {
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>
238
+ cleanup: (worktreeInfo: WorktreeInfo) => Effect.Effect<void, never, never>
239
+ getChangedFiles: () => Effect.Effect<string[], GitWorktreeError, never>
240
+ }
241
+
242
+ const GitWorktreeServiceImplLive: GitWorktreeServiceImpl = {
243
+ validatePreconditions: () =>
244
+ Effect.gen(function* () {
245
+ yield* validateGitRepo()
246
+ yield* Console.log('✓ Git repository validation passed')
247
+ }),
248
+
249
+ createWorktree: (changeId: string) =>
250
+ Effect.gen(function* () {
251
+ yield* Console.log(`→ Creating worktree for change ${changeId}...`)
252
+
253
+ // Get current commit hash to avoid branch conflicts
254
+ const currentCommit = yield* getCurrentCommit()
255
+ yield* Console.log(`→ Using base commit: ${currentCommit.substring(0, 7)}`)
256
+
257
+ // Ensure .ger directory exists
258
+ yield* ensureGerDirectory()
259
+
260
+ // Generate unique path
261
+ const worktreePath = generateWorktreePath(changeId)
262
+ const originalCwd = process.cwd()
263
+
264
+ // Create worktree using commit hash (no branch conflicts)
265
+ yield* runGitCommand(['worktree', 'add', '--detach', worktreePath, currentCommit])
266
+
267
+ const worktreeInfo: WorktreeInfo = {
268
+ path: worktreePath,
269
+ changeId,
270
+ originalCwd,
271
+ timestamp: Date.now(),
272
+ pid: process.pid,
273
+ }
274
+
275
+ yield* Console.log(`✓ Worktree created at ${worktreePath}`)
276
+ return worktreeInfo
277
+ }),
278
+
279
+ fetchAndCheckoutPatchset: (worktreeInfo: WorktreeInfo) =>
280
+ Effect.gen(function* () {
281
+ yield* Console.log(`→ Fetching and checking out patchset for ${worktreeInfo.changeId}...`)
282
+
283
+ // Get latest patchset number
284
+ const patchsetNumber = yield* getLatestPatchsetNumber(worktreeInfo.changeId)
285
+ const refspec = buildRefspec(worktreeInfo.changeId, patchsetNumber)
286
+
287
+ yield* Console.log(`→ Using refspec: ${refspec}`)
288
+
289
+ // Fetch the change
290
+ yield* runGitCommand(['fetch', 'origin', refspec], { cwd: worktreeInfo.path })
291
+
292
+ // Checkout FETCH_HEAD
293
+ yield* runGitCommand(['checkout', 'FETCH_HEAD'], { cwd: worktreeInfo.path })
294
+
295
+ yield* Console.log(`✓ Checked out patchset ${patchsetNumber} for ${worktreeInfo.changeId}`)
296
+ }),
297
+
298
+ cleanup: (worktreeInfo: WorktreeInfo) =>
299
+ Effect.gen(function* () {
300
+ yield* Console.log(`→ Cleaning up worktree for ${worktreeInfo.changeId}...`)
301
+
302
+ // Always restore original working directory first
303
+ try {
304
+ process.chdir(worktreeInfo.originalCwd)
305
+ } catch (error) {
306
+ yield* Console.warn(`Warning: Could not restore original directory: ${error}`)
307
+ }
308
+
309
+ // Attempt to remove worktree (don't fail if this doesn't work)
310
+ yield* pipe(
311
+ runGitCommand(['worktree', 'remove', '--force', worktreeInfo.path]),
312
+ Effect.catchAll((error) =>
313
+ Effect.gen(function* () {
314
+ yield* Console.warn(`Warning: Could not remove worktree: ${error.message}`)
315
+ yield* Console.warn(`Manual cleanup may be required: ${worktreeInfo.path}`)
316
+ }),
317
+ ),
318
+ )
319
+
320
+ yield* Console.log(`✓ Cleanup completed for ${worktreeInfo.changeId}`)
321
+ }),
322
+
323
+ getChangedFiles: () =>
324
+ Effect.gen(function* () {
325
+ // Get list of changed files in current worktree
326
+ const output = yield* runGitCommand(['diff', '--name-only', 'HEAD~1'])
327
+ const files = output.split('\n').filter((file) => file.trim())
328
+ return files
329
+ }),
330
+ }
331
+
332
+ // Export service tag for dependency injection with explicit type
333
+ export const GitWorktreeService: Context.Tag<GitWorktreeServiceImpl, GitWorktreeServiceImpl> =
334
+ Context.GenericTag<GitWorktreeServiceImpl>('GitWorktreeService')
335
+
336
+ export type GitWorktreeService = Context.Tag.Identifier<typeof GitWorktreeService>
337
+
338
+ // Export service layer with explicit type
339
+ export const GitWorktreeServiceLive: Layer.Layer<GitWorktreeServiceImpl> = Layer.succeed(
340
+ GitWorktreeService,
341
+ GitWorktreeServiceImplLive,
342
+ )