@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.
- package/.ast-grep/rules/no-as-casting.yml +13 -0
- package/.claude-plugin/plugin.json +22 -0
- package/.github/workflows/ci-simple.yml +53 -0
- package/.github/workflows/ci.yml +171 -0
- package/.github/workflows/claude-code-review.yml +83 -0
- package/.github/workflows/claude.yml +50 -0
- package/.github/workflows/dependency-update.yml +84 -0
- package/.github/workflows/release.yml +166 -0
- package/.github/workflows/security-scan.yml +113 -0
- package/.github/workflows/security.yml +96 -0
- package/.husky/pre-commit +16 -0
- package/.husky/pre-push +25 -0
- package/.lintstagedrc.json +6 -0
- package/.tool-versions +1 -0
- package/CLAUDE.md +105 -0
- package/DEVELOPMENT.md +361 -0
- package/EXAMPLES.md +457 -0
- package/README.md +831 -16
- package/bin/ger +3 -18
- package/biome.json +36 -0
- package/bun.lock +678 -0
- package/bunfig.toml +8 -0
- package/docs/adr/0001-use-effect-for-side-effects.md +65 -0
- package/docs/adr/0002-use-bun-runtime.md +64 -0
- package/docs/adr/0003-store-credentials-in-home-directory.md +75 -0
- package/docs/adr/0004-use-commander-for-cli.md +76 -0
- package/docs/adr/0005-use-effect-schema-for-validation.md +93 -0
- package/docs/adr/0006-use-msw-for-api-mocking.md +89 -0
- package/docs/adr/0007-git-hooks-for-quality.md +94 -0
- package/docs/adr/0008-no-as-typecasting.md +83 -0
- package/docs/adr/0009-file-size-limits.md +82 -0
- package/docs/adr/0010-llm-friendly-xml-output.md +93 -0
- package/docs/adr/0011-ai-tool-strategy-pattern.md +102 -0
- package/docs/adr/0012-build-status-message-parsing.md +94 -0
- package/docs/adr/0013-git-subprocess-integration.md +98 -0
- package/docs/adr/0014-group-management-support.md +95 -0
- package/docs/adr/0015-batch-comment-processing.md +111 -0
- package/docs/adr/0016-flexible-change-identifiers.md +94 -0
- package/docs/adr/0017-git-worktree-support.md +102 -0
- package/docs/adr/0018-auto-install-commit-hook.md +103 -0
- package/docs/adr/0019-sdk-package-exports.md +95 -0
- package/docs/adr/0020-code-coverage-enforcement.md +105 -0
- package/docs/adr/0021-typescript-isolated-declarations.md +83 -0
- package/docs/adr/0022-biome-oxlint-tooling.md +124 -0
- package/docs/adr/README.md +30 -0
- package/docs/prd/README.md +12 -0
- package/docs/prd/architecture.md +325 -0
- package/docs/prd/commands.md +425 -0
- package/docs/prd/data-model.md +349 -0
- package/docs/prd/overview.md +124 -0
- package/index.ts +219 -0
- package/oxlint.json +24 -0
- package/package.json +82 -15
- package/scripts/check-coverage.ts +69 -0
- package/scripts/check-file-size.ts +38 -0
- package/scripts/fix-test-mocks.ts +55 -0
- package/skills/gerrit-workflow/SKILL.md +247 -0
- package/skills/gerrit-workflow/examples.md +572 -0
- package/skills/gerrit-workflow/reference.md +728 -0
- package/src/api/gerrit.ts +696 -0
- package/src/cli/commands/abandon.ts +65 -0
- package/src/cli/commands/add-reviewer.ts +156 -0
- package/src/cli/commands/build-status.ts +282 -0
- package/src/cli/commands/checkout.ts +422 -0
- package/src/cli/commands/comment.ts +460 -0
- package/src/cli/commands/comments.ts +85 -0
- package/src/cli/commands/diff.ts +71 -0
- package/src/cli/commands/extract-url.ts +266 -0
- package/src/cli/commands/groups-members.ts +104 -0
- package/src/cli/commands/groups-show.ts +169 -0
- package/src/cli/commands/groups.ts +137 -0
- package/src/cli/commands/incoming.ts +226 -0
- package/src/cli/commands/init.ts +164 -0
- package/src/cli/commands/mine.ts +115 -0
- package/src/cli/commands/open.ts +57 -0
- package/src/cli/commands/projects.ts +68 -0
- package/src/cli/commands/push.ts +430 -0
- package/src/cli/commands/rebase.ts +52 -0
- package/src/cli/commands/remove-reviewer.ts +123 -0
- package/src/cli/commands/restore.ts +50 -0
- package/src/cli/commands/review.ts +486 -0
- package/src/cli/commands/search.ts +162 -0
- package/src/cli/commands/setup.ts +286 -0
- package/src/cli/commands/show.ts +491 -0
- package/src/cli/commands/status.ts +35 -0
- package/src/cli/commands/submit.ts +108 -0
- package/src/cli/commands/vote.ts +119 -0
- package/src/cli/commands/workspace.ts +200 -0
- package/src/cli/index.ts +53 -0
- package/src/cli/register-commands.ts +659 -0
- package/src/cli/register-group-commands.ts +88 -0
- package/src/cli/register-reviewer-commands.ts +97 -0
- package/src/prompts/default-review.md +86 -0
- package/src/prompts/system-inline-review.md +135 -0
- package/src/prompts/system-overall-review.md +206 -0
- package/src/schemas/config.test.ts +245 -0
- package/src/schemas/config.ts +84 -0
- package/src/schemas/gerrit.ts +681 -0
- package/src/services/commit-hook.ts +314 -0
- package/src/services/config.test.ts +150 -0
- package/src/services/config.ts +250 -0
- package/src/services/git-worktree.ts +342 -0
- package/src/services/review-strategy.ts +292 -0
- package/src/test-utils/mock-generator.ts +138 -0
- package/src/utils/change-id.test.ts +98 -0
- package/src/utils/change-id.ts +63 -0
- package/src/utils/comment-formatters.ts +153 -0
- package/src/utils/diff-context.ts +103 -0
- package/src/utils/diff-formatters.ts +141 -0
- package/src/utils/formatters.ts +85 -0
- package/src/utils/git-commit.test.ts +277 -0
- package/src/utils/git-commit.ts +122 -0
- package/src/utils/index.ts +55 -0
- package/src/utils/message-filters.ts +26 -0
- package/src/utils/review-formatters.ts +89 -0
- package/src/utils/review-prompt-builder.ts +110 -0
- package/src/utils/shell-safety.ts +117 -0
- package/src/utils/status-indicators.ts +100 -0
- package/src/utils/url-parser.test.ts +271 -0
- package/src/utils/url-parser.ts +118 -0
- package/tests/abandon.test.ts +230 -0
- package/tests/add-reviewer.test.ts +579 -0
- package/tests/build-status-watch.test.ts +344 -0
- package/tests/build-status.test.ts +789 -0
- package/tests/change-id-formats.test.ts +268 -0
- package/tests/checkout/integration.test.ts +653 -0
- package/tests/checkout/parse-input.test.ts +55 -0
- package/tests/checkout/validation.test.ts +178 -0
- package/tests/comment-batch-advanced.test.ts +431 -0
- package/tests/comment-gerrit-api-compliance.test.ts +414 -0
- package/tests/comment.test.ts +708 -0
- package/tests/comments.test.ts +323 -0
- package/tests/config-service-simple.test.ts +100 -0
- package/tests/diff.test.ts +419 -0
- package/tests/extract-url.test.ts +517 -0
- package/tests/groups-members.test.ts +256 -0
- package/tests/groups-show.test.ts +323 -0
- package/tests/groups.test.ts +334 -0
- package/tests/helpers/build-status-test-setup.ts +83 -0
- package/tests/helpers/config-mock.ts +27 -0
- package/tests/incoming.test.ts +357 -0
- package/tests/init.test.ts +70 -0
- package/tests/integration/commit-hook.test.ts +246 -0
- package/tests/interactive-incoming.test.ts +173 -0
- package/tests/mine.test.ts +285 -0
- package/tests/mocks/msw-handlers.ts +80 -0
- package/tests/open.test.ts +233 -0
- package/tests/projects.test.ts +259 -0
- package/tests/rebase.test.ts +271 -0
- package/tests/remove-reviewer.test.ts +357 -0
- package/tests/restore.test.ts +237 -0
- package/tests/review.test.ts +135 -0
- package/tests/search.test.ts +712 -0
- package/tests/setup.test.ts +63 -0
- package/tests/show-auto-detect.test.ts +324 -0
- package/tests/show.test.ts +813 -0
- package/tests/status.test.ts +145 -0
- package/tests/submit.test.ts +316 -0
- package/tests/unit/commands/push.test.ts +194 -0
- package/tests/unit/git-branch-detection.test.ts +82 -0
- package/tests/unit/git-worktree.test.ts +55 -0
- package/tests/unit/patterns/push-patterns.test.ts +148 -0
- package/tests/unit/schemas/gerrit.test.ts +85 -0
- package/tests/unit/services/commit-hook.test.ts +132 -0
- package/tests/unit/services/review-strategy.test.ts +349 -0
- package/tests/unit/test-utils/mock-generator.test.ts +154 -0
- package/tests/unit/utils/comment-formatters.test.ts +415 -0
- package/tests/unit/utils/diff-context.test.ts +171 -0
- package/tests/unit/utils/diff-formatters.test.ts +165 -0
- package/tests/unit/utils/formatters.test.ts +411 -0
- package/tests/unit/utils/message-filters.test.ts +227 -0
- package/tests/unit/utils/shell-safety.test.ts +230 -0
- package/tests/unit/utils/status-indicators.test.ts +137 -0
- package/tests/vote.test.ts +317 -0
- package/tests/workspace.test.ts +295 -0
- package/tsconfig.json +36 -5
- package/src/commands/branch.ts +0 -180
- package/src/ger.ts +0 -22
- package/src/types.d.ts +0 -35
- 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
|
+
)
|