@aaronshaf/ger 0.1.10 → 0.2.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/src/cli/index.ts CHANGED
@@ -34,6 +34,7 @@ import { abandonCommand } from './commands/abandon'
34
34
  import { commentCommand } from './commands/comment'
35
35
  import { commentsCommand } from './commands/comments'
36
36
  import { diffCommand } from './commands/diff'
37
+ import { extractUrlCommand } from './commands/extract-url'
37
38
  import { incomingCommand } from './commands/incoming'
38
39
  import { mineCommand } from './commands/mine'
39
40
  import { openCommand } from './commands/open'
@@ -42,6 +43,7 @@ import { setup } from './commands/setup'
42
43
  import { showCommand } from './commands/show'
43
44
  import { statusCommand } from './commands/status'
44
45
  import { workspaceCommand } from './commands/workspace'
46
+ import { sanitizeCDATA } from '@/utils/shell-safety'
45
47
 
46
48
  // Read version from package.json
47
49
  function getVersion(): string {
@@ -101,7 +103,7 @@ program
101
103
  // comment command
102
104
  program
103
105
  .command('comment <change-id>')
104
- .description('Post a comment on a change')
106
+ .description('Post a comment on a change (accepts change number or Change-ID)')
105
107
  .option('-m, --message <message>', 'Comment message')
106
108
  .option('--file <file>', 'File path for line-specific comment (relative to repo root)')
107
109
  .option(
@@ -116,9 +118,12 @@ program
116
118
  'after',
117
119
  `
118
120
  Examples:
119
- # Post a general comment on a change
121
+ # Post a general comment on a change (using change number)
120
122
  $ ger comment 12345 -m "Looks good to me!"
121
123
 
124
+ # Post a comment using Change-ID
125
+ $ ger comment If5a3ae8cb5a107e187447802358417f311d0c4b1 -m "LGTM"
126
+
122
127
  # Post a comment using piped input (useful for multi-line comments or scripts)
123
128
  $ echo "This is a comment from stdin!" | ger comment 12345
124
129
  $ cat review-notes.txt | ger comment 12345
@@ -135,9 +140,11 @@ Examples:
135
140
  {"file": "src/api.js", "line": 25, "message": "Check error handling", "unresolved": true}
136
141
  ]}' | ger comment 12345 --batch
137
142
 
138
- Note: Line numbers refer to the actual line numbers in the NEW version of the file,
139
- NOT the line numbers shown in the diff view. To find the correct line number,
140
- look at the file after all changes have been applied.`,
143
+ Note:
144
+ - Both change number (e.g., 12345) and Change-ID (e.g., If5a3ae8...) formats are accepted
145
+ - Line numbers refer to the actual line numbers in the NEW version of the file,
146
+ NOT the line numbers shown in the diff view. To find the correct line number,
147
+ look at the file after all changes have been applied.`,
141
148
  )
142
149
  .action(async (changeId, options) => {
143
150
  try {
@@ -165,7 +172,7 @@ Note: Line numbers refer to the actual line numbers in the NEW version of the fi
165
172
  // diff command
166
173
  program
167
174
  .command('diff <change-id>')
168
- .description('Get diff for a change')
175
+ .description('Get diff for a change (accepts change number or Change-ID)')
169
176
  .option('--xml', 'XML output for LLM consumption')
170
177
  .option('--file <file>', 'Specific file to diff')
171
178
  .option('--files-only', 'List changed files only')
@@ -224,7 +231,9 @@ program
224
231
  // workspace command
225
232
  program
226
233
  .command('workspace <change-id>')
227
- .description('Create or switch to a git worktree for a Gerrit change')
234
+ .description(
235
+ 'Create or switch to a git worktree for a Gerrit change (accepts change number or Change-ID)',
236
+ )
228
237
  .option('--xml', 'XML output for LLM consumption')
229
238
  .action(async (changeId, options) => {
230
239
  try {
@@ -281,7 +290,9 @@ program
281
290
  // abandon command
282
291
  program
283
292
  .command('abandon [change-id]')
284
- .description('Abandon a change (interactive mode if no change-id provided)')
293
+ .description(
294
+ 'Abandon a change (interactive mode if no change-id provided; accepts change number or Change-ID)',
295
+ )
285
296
  .option('-m, --message <message>', 'Abandon message')
286
297
  .option('--xml', 'XML output for LLM consumption')
287
298
  .action(async (changeId, options) => {
@@ -310,7 +321,9 @@ program
310
321
  // comments command
311
322
  program
312
323
  .command('comments <change-id>')
313
- .description('Show all comments on a change with diff context')
324
+ .description(
325
+ 'Show all comments on a change with diff context (accepts change number or Change-ID)',
326
+ )
314
327
  .option('--xml', 'XML output for LLM consumption')
315
328
  .action(async (changeId, options) => {
316
329
  try {
@@ -338,7 +351,7 @@ program
338
351
  // open command
339
352
  program
340
353
  .command('open <change-id>')
341
- .description('Open a change in the browser')
354
+ .description('Open a change in the browser (accepts change number or Change-ID)')
342
355
  .action(async (changeId, options) => {
343
356
  try {
344
357
  const effect = openCommand(changeId, options).pipe(
@@ -354,9 +367,34 @@ program
354
367
 
355
368
  // show command
356
369
  program
357
- .command('show <change-id>')
358
- .description('Show comprehensive change information including metadata, diff, and all comments')
370
+ .command('show [change-id]')
371
+ .description(
372
+ 'Show comprehensive change information (auto-detects from HEAD commit if not specified)',
373
+ )
359
374
  .option('--xml', 'XML output for LLM consumption')
375
+ .option('--json', 'JSON output for programmatic consumption')
376
+ .addHelpText(
377
+ 'after',
378
+ `
379
+ Examples:
380
+ # Show specific change (using change number)
381
+ $ ger show 392385
382
+
383
+ # Show specific change (using Change-ID)
384
+ $ ger show If5a3ae8cb5a107e187447802358417f311d0c4b1
385
+
386
+ # Auto-detect Change-ID from HEAD commit
387
+ $ ger show
388
+ $ ger show --xml
389
+ $ ger show --json
390
+
391
+ # Extract build failure URL with jq
392
+ $ ger show 392090 --json | jq -r '.messages[] | select(.message | contains("Build Failed")) | .message' | grep -oP 'https://[^\\s]+'
393
+
394
+ Note: When no change-id is provided, it will be automatically extracted from the
395
+ Change-ID footer in your HEAD commit. You must be in a git repository with
396
+ a commit that has a Change-ID.`,
397
+ )
360
398
  .action(async (changeId, options) => {
361
399
  try {
362
400
  const effect = showCommand(changeId, options).pipe(
@@ -365,16 +403,88 @@ program
365
403
  )
366
404
  await Effect.runPromise(effect)
367
405
  } catch (error) {
368
- if (options.xml) {
406
+ const errorMessage = error instanceof Error ? error.message : String(error)
407
+ if (options.json) {
408
+ console.log(JSON.stringify({ status: 'error', error: errorMessage }, null, 2))
409
+ } else if (options.xml) {
369
410
  console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
370
411
  console.log(`<show_result>`)
371
412
  console.log(` <status>error</status>`)
372
- console.log(
373
- ` <error><![CDATA[${error instanceof Error ? error.message : String(error)}]]></error>`,
374
- )
413
+ console.log(` <error><![CDATA[${sanitizeCDATA(errorMessage)}]]></error>`)
375
414
  console.log(`</show_result>`)
376
415
  } else {
377
- console.error('✗ Error:', error instanceof Error ? error.message : String(error))
416
+ console.error('✗ Error:', errorMessage)
417
+ }
418
+ process.exit(1)
419
+ }
420
+ })
421
+
422
+ // extract-url command
423
+ program
424
+ .command('extract-url <pattern> [change-id]')
425
+ .description(
426
+ 'Extract URLs from change messages and comments (auto-detects from HEAD commit if not specified)',
427
+ )
428
+ .option('--include-comments', 'Also search inline comments (default: messages only)')
429
+ .option('--regex', 'Treat pattern as regex instead of substring match')
430
+ .option('--xml', 'XML output for LLM consumption')
431
+ .option('--json', 'JSON output for programmatic consumption')
432
+ .addHelpText(
433
+ 'after',
434
+ `
435
+ Examples:
436
+ # Extract all Jenkins build-summary-report URLs (substring match)
437
+ $ ger extract-url "build-summary-report"
438
+
439
+ # Get the latest build URL using tail
440
+ $ ger extract-url "build-summary-report" | tail -1
441
+
442
+ # Get the first build URL using head
443
+ $ ger extract-url "jenkins.inst-ci.net" | head -1
444
+
445
+ # For a specific change (using change number)
446
+ $ ger extract-url "build-summary" 391831
447
+
448
+ # For a specific change (using Change-ID)
449
+ $ ger extract-url "jenkins" If5a3ae8cb5a107e187447802358417f311d0c4b1
450
+
451
+ # Use regex for precise matching
452
+ $ ger extract-url "job/Canvas/job/main/\\d+/" --regex
453
+
454
+ # Search both messages and inline comments
455
+ $ ger extract-url "github.com" --include-comments
456
+
457
+ # JSON output for scripting
458
+ $ ger extract-url "jenkins" --json | jq -r '.urls[-1]'
459
+
460
+ # XML output
461
+ $ ger extract-url "jenkins" --xml
462
+
463
+ Note:
464
+ - URLs are output in chronological order (oldest first)
465
+ - Use tail -1 to get the latest URL, head -1 for the oldest
466
+ - When no change-id is provided, it will be automatically extracted from the
467
+ Change-ID footer in your HEAD commit`,
468
+ )
469
+ .action(async (pattern, changeId, options) => {
470
+ try {
471
+ const effect = extractUrlCommand(pattern, changeId, options).pipe(
472
+ Effect.provide(GerritApiServiceLive),
473
+ Effect.provide(ConfigServiceLive),
474
+ )
475
+ await Effect.runPromise(effect)
476
+ } catch (error) {
477
+ const errorMessage = error instanceof Error ? error.message : String(error)
478
+ if (options.json) {
479
+ console.log(JSON.stringify({ status: 'error', error: errorMessage }, null, 2))
480
+ } else if (options.xml) {
481
+ console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
482
+ console.log(`<extract_url_result>`)
483
+ console.log(` <status>error</status>`)
484
+ console.log(` <error><![CDATA[${sanitizeCDATA(errorMessage)}]]></error>`)
485
+ console.log(`</extract_url_result>`)
486
+ } else {
487
+ console.error('✗ Error:', errorMessage)
378
488
  }
379
489
  process.exit(1)
380
490
  }
@@ -383,7 +493,9 @@ program
383
493
  // review command
384
494
  program
385
495
  .command('review <change-id>')
386
- .description('AI-powered code review that analyzes changes and optionally posts comments')
496
+ .description(
497
+ 'AI-powered code review that analyzes changes and optionally posts comments (accepts change number or Change-ID)',
498
+ )
387
499
  .option('--comment', 'Post the review as comments (prompts for confirmation)')
388
500
  .option('-y, --yes', 'Skip confirmation prompts when posting comments')
389
501
  .option('--debug', 'Show debug output including AI responses')
@@ -408,20 +520,25 @@ Requirements:
408
520
  - Gerrit credentials must be configured (run 'ger setup' first)
409
521
 
410
522
  Examples:
411
- # Review a change (display only)
523
+ # Review a change using change number (display only)
412
524
  $ ger review 12345
413
-
525
+
526
+ # Review using Change-ID
527
+ $ ger review If5a3ae8cb5a107e187447802358417f311d0c4b1
528
+
414
529
  # Review and prompt to post comments
415
530
  $ ger review 12345 --comment
416
-
531
+
417
532
  # Review and auto-post comments without prompting
418
533
  $ ger review 12345 --comment --yes
419
-
534
+
420
535
  # Use specific AI tool
421
536
  $ ger review 12345 --tool gemini
422
-
537
+
423
538
  # Show debug output to troubleshoot issues
424
539
  $ ger review 12345 --debug
540
+
541
+ Note: Both change number (e.g., 12345) and Change-ID (e.g., If5a3ae8...) formats are accepted
425
542
  `,
426
543
  )
427
544
  .action(async (changeId, options) => {
@@ -1,7 +1,13 @@
1
1
  import { Schema } from '@effect/schema'
2
2
 
3
3
  // Flat Application Configuration (similar to ji structure)
4
- export const AppConfig = Schema.Struct({
4
+ export const AppConfig: Schema.Struct<{
5
+ host: typeof Schema.String
6
+ username: typeof Schema.String
7
+ password: typeof Schema.String
8
+ aiTool: Schema.optional<Schema.Literal<['claude', 'llm', 'opencode', 'gemini']>>
9
+ aiAutoDetect: Schema.optionalWith<typeof Schema.Boolean, { default: () => boolean }>
10
+ }> = Schema.Struct({
5
11
  // Gerrit credentials (flattened)
6
12
  host: Schema.String.pipe(
7
13
  Schema.pattern(/^https?:\/\/.+$/),
@@ -17,15 +23,18 @@ export const AppConfig = Schema.Struct({
17
23
  ),
18
24
  // AI configuration (flattened)
19
25
  aiTool: Schema.optional(Schema.Literal('claude', 'llm', 'opencode', 'gemini')),
20
- aiAutoDetect: Schema.optionalWith(Schema.Boolean, { default: () => true }),
26
+ aiAutoDetect: Schema.optionalWith(Schema.Boolean, { default: (): boolean => true }),
21
27
  })
22
28
 
23
29
  export type AppConfig = Schema.Schema.Type<typeof AppConfig>
24
30
 
25
31
  // Legacy schemas for backward compatibility (deprecated)
26
- export const AiConfig = Schema.Struct({
32
+ export const AiConfig: Schema.Struct<{
33
+ tool: Schema.optional<Schema.Literal<['claude', 'llm', 'opencode', 'gemini']>>
34
+ autoDetect: Schema.optionalWith<typeof Schema.Boolean, { default: () => boolean }>
35
+ }> = Schema.Struct({
27
36
  tool: Schema.optional(Schema.Literal('claude', 'llm', 'opencode', 'gemini')),
28
- autoDetect: Schema.optionalWith(Schema.Boolean, { default: () => true }),
37
+ autoDetect: Schema.optionalWith(Schema.Boolean, { default: (): boolean => true }),
29
38
  })
30
39
 
31
40
  export type AiConfig = Schema.Schema.Type<typeof AiConfig>
@@ -0,0 +1,150 @@
1
+ import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
2
+ import { Effect } from 'effect'
3
+ import * as fs from 'node:fs'
4
+ import * as os from 'node:os'
5
+ import * as path from 'node:path'
6
+ import { ConfigService, ConfigServiceLive } from './config'
7
+
8
+ describe('ConfigService', () => {
9
+ let originalEnv: NodeJS.ProcessEnv
10
+ const CONFIG_DIR = path.join(os.homedir(), '.ger')
11
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json')
12
+ let originalConfigContent: string | null = null
13
+
14
+ beforeEach(() => {
15
+ // Store original env vars
16
+ originalEnv = { ...process.env }
17
+
18
+ // Clear environment variables for clean tests
19
+ delete process.env.GERRIT_HOST
20
+ delete process.env.GERRIT_USERNAME
21
+ delete process.env.GERRIT_PASSWORD
22
+
23
+ // Backup and remove existing config file for clean tests
24
+ try {
25
+ if (fs.existsSync(CONFIG_FILE)) {
26
+ originalConfigContent = fs.readFileSync(CONFIG_FILE, 'utf8')
27
+ fs.unlinkSync(CONFIG_FILE)
28
+ }
29
+ } catch {
30
+ // Ignore errors
31
+ }
32
+ })
33
+
34
+ afterEach(() => {
35
+ // Restore original env vars
36
+ process.env = originalEnv
37
+
38
+ // Restore original config file
39
+ try {
40
+ if (originalConfigContent !== null) {
41
+ if (!fs.existsSync(CONFIG_DIR)) {
42
+ fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 })
43
+ }
44
+ fs.writeFileSync(CONFIG_FILE, originalConfigContent, 'utf8')
45
+ fs.chmodSync(CONFIG_FILE, 0o600)
46
+ }
47
+ } catch {
48
+ // Ignore errors
49
+ }
50
+ originalConfigContent = null
51
+ })
52
+
53
+ describe('Environment Variable Configuration', () => {
54
+ test('loads config from environment variables when all required vars are present', async () => {
55
+ // Set environment variables
56
+ process.env.GERRIT_HOST = 'https://gerrit.example.com'
57
+ process.env.GERRIT_USERNAME = 'envuser'
58
+ process.env.GERRIT_PASSWORD = 'envpass123'
59
+
60
+ const result = await Effect.gen(function* () {
61
+ const configService = yield* ConfigService
62
+ return yield* configService.getFullConfig
63
+ }).pipe(Effect.provide(ConfigServiceLive), Effect.runPromise)
64
+
65
+ expect(result).toEqual({
66
+ host: 'https://gerrit.example.com',
67
+ username: 'envuser',
68
+ password: 'envpass123',
69
+ aiAutoDetect: true,
70
+ aiTool: undefined,
71
+ })
72
+ })
73
+
74
+ test('loads credentials from environment variables', async () => {
75
+ // Set environment variables
76
+ process.env.GERRIT_HOST = 'https://gerrit.example.com'
77
+ process.env.GERRIT_USERNAME = 'envuser'
78
+ process.env.GERRIT_PASSWORD = 'envpass123'
79
+
80
+ const result = await Effect.gen(function* () {
81
+ const configService = yield* ConfigService
82
+ return yield* configService.getCredentials
83
+ }).pipe(Effect.provide(ConfigServiceLive), Effect.runPromise)
84
+
85
+ expect(result).toEqual({
86
+ host: 'https://gerrit.example.com',
87
+ username: 'envuser',
88
+ password: 'envpass123',
89
+ })
90
+ })
91
+
92
+ test('fails when only some environment variables are present', async () => {
93
+ // Set only some environment variables
94
+ process.env.GERRIT_HOST = 'https://gerrit.example.com'
95
+ process.env.GERRIT_USERNAME = 'envuser'
96
+ // GERRIT_PASSWORD is missing
97
+
98
+ await expect(
99
+ Effect.gen(function* () {
100
+ const configService = yield* ConfigService
101
+ return yield* configService.getFullConfig
102
+ }).pipe(Effect.provide(ConfigServiceLive), Effect.runPromise),
103
+ ).rejects.toThrow('Configuration not found')
104
+ })
105
+
106
+ test('validates environment variable configuration format', async () => {
107
+ // Set invalid environment variables
108
+ process.env.GERRIT_HOST = 'not-a-url'
109
+ process.env.GERRIT_USERNAME = 'envuser'
110
+ process.env.GERRIT_PASSWORD = 'envpass123'
111
+
112
+ await expect(
113
+ Effect.gen(function* () {
114
+ const configService = yield* ConfigService
115
+ return yield* configService.getFullConfig
116
+ }).pipe(Effect.provide(ConfigServiceLive), Effect.runPromise),
117
+ ).rejects.toThrow('Invalid environment configuration format')
118
+ })
119
+
120
+ test('rejects empty environment variables', async () => {
121
+ // Set empty environment variables
122
+ process.env.GERRIT_HOST = 'https://gerrit.example.com'
123
+ process.env.GERRIT_USERNAME = ''
124
+ process.env.GERRIT_PASSWORD = 'envpass123'
125
+
126
+ await expect(
127
+ Effect.gen(function* () {
128
+ const configService = yield* ConfigService
129
+ return yield* configService.getFullConfig
130
+ }).pipe(Effect.provide(ConfigServiceLive), Effect.runPromise),
131
+ ).rejects.toThrow('Configuration not found')
132
+ })
133
+
134
+ test('provides helpful error message when no configuration is found', async () => {
135
+ // Clear all relevant environment variables
136
+ delete process.env.GERRIT_HOST
137
+ delete process.env.GERRIT_USERNAME
138
+ delete process.env.GERRIT_PASSWORD
139
+
140
+ await expect(
141
+ Effect.gen(function* () {
142
+ const configService = yield* ConfigService
143
+ return yield* configService.getFullConfig
144
+ }).pipe(Effect.provide(ConfigServiceLive), Effect.runPromise),
145
+ ).rejects.toThrow(
146
+ 'Configuration not found. Run "ger setup" to set up your credentials or set GERRIT_HOST, GERRIT_USERNAME, and GERRIT_PASSWORD environment variables.',
147
+ )
148
+ })
149
+ })
150
+ })
@@ -16,19 +16,50 @@ export interface ConfigServiceImpl {
16
16
  readonly saveFullConfig: (config: AppConfig) => Effect.Effect<void, ConfigError>
17
17
  }
18
18
 
19
- export class ConfigService extends Context.Tag('ConfigService')<
20
- ConfigService,
21
- ConfigServiceImpl
22
- >() {}
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
+ }
23
28
 
24
- export class ConfigError extends Schema.TaggedError<ConfigError>()('ConfigError', {
29
+ // Define error schema (not exported, so type can be implicit)
30
+ const ConfigErrorSchema = Schema.TaggedError<ConfigErrorFields>()('ConfigError', {
25
31
  message: Schema.String,
26
- } as const) {}
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
+ }
27
43
 
28
44
  // File-based storage
29
45
  const CONFIG_DIR = path.join(os.homedir(), '.ger')
30
46
  const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json')
31
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
+
32
63
  const readFileConfig = (): unknown | null => {
33
64
  try {
34
65
  if (fs.existsSync(CONFIG_FILE)) {
@@ -85,21 +116,34 @@ export const ConfigServiceLive: Layer.Layer<ConfigService, never, never> = Layer
85
116
  ConfigService,
86
117
  Effect.sync(() => {
87
118
  const getFullConfig = Effect.gen(function* () {
119
+ // First try to read from file
88
120
  const fileContent = readFileConfig()
89
- if (!fileContent) {
90
- return yield* Effect.fail(
91
- new ConfigError({
92
- message: 'Configuration not found. Run "ger setup" to set up your credentials.',
93
- }),
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' })),
94
125
  )
126
+ return fullConfigResult
95
127
  }
96
128
 
97
- // Parse as flat config
98
- const fullConfigResult = yield* Schema.decodeUnknown(AppConfig)(fileContent).pipe(
99
- Effect.mapError(() => new ConfigError({ message: 'Invalid configuration format' })),
100
- )
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
+ }
101
139
 
102
- return fullConfigResult
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
+ )
103
147
  })
104
148
 
105
149
  const saveFullConfig = (config: AppConfig) =>
@@ -5,30 +5,84 @@ import * as path from 'node:path'
5
5
  import * as fs from 'node:fs/promises'
6
6
  import { spawn } from 'node:child_process'
7
7
 
8
- // Error types
9
- export class WorktreeCreationError extends Schema.TaggedError<WorktreeCreationError>()(
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>()(
10
15
  'WorktreeCreationError',
11
16
  {
12
17
  message: Schema.String,
13
18
  cause: Schema.optional(Schema.Unknown),
14
19
  },
15
- ) {}
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
+ }
16
35
 
17
- export class PatchsetFetchError extends Schema.TaggedError<PatchsetFetchError>()(
36
+ const PatchsetFetchErrorSchema = Schema.TaggedError<PatchsetFetchErrorFields>()(
18
37
  'PatchsetFetchError',
19
38
  {
20
39
  message: Schema.String,
21
40
  cause: Schema.optional(Schema.Unknown),
22
41
  },
23
- ) {}
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
+ }
24
56
 
25
- export class DirtyRepoError extends Schema.TaggedError<DirtyRepoError>()('DirtyRepoError', {
57
+ const DirtyRepoErrorSchema = Schema.TaggedError<DirtyRepoErrorFields>()('DirtyRepoError', {
26
58
  message: Schema.String,
27
- }) {}
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
+ }
28
73
 
29
- export class NotGitRepoError extends Schema.TaggedError<NotGitRepoError>()('NotGitRepoError', {
74
+ const NotGitRepoErrorSchema = Schema.TaggedError<NotGitRepoErrorFields>()('NotGitRepoError', {
30
75
  message: Schema.String,
31
- }) {}
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
+ }
32
86
 
33
87
  export type GitError = WorktreeCreationError | PatchsetFetchError | DirtyRepoError | NotGitRepoError
34
88
 
@@ -286,11 +340,14 @@ const GitWorktreeServiceImplLive: GitWorktreeServiceImpl = {
286
340
  }),
287
341
  }
288
342
 
289
- // Export service tag for dependency injection
290
- export class GitWorktreeService extends Context.Tag('GitWorktreeService')<
291
- GitWorktreeService,
292
- GitWorktreeServiceImpl
293
- >() {}
343
+ // Export service tag for dependency injection with explicit type
344
+ export const GitWorktreeService: Context.Tag<GitWorktreeServiceImpl, GitWorktreeServiceImpl> =
345
+ Context.GenericTag<GitWorktreeServiceImpl>('GitWorktreeService')
346
+
347
+ export type GitWorktreeService = Context.Tag.Identifier<typeof GitWorktreeService>
294
348
 
295
- // Export service layer
296
- export const GitWorktreeServiceLive = Layer.succeed(GitWorktreeService, GitWorktreeServiceImplLive)
349
+ // Export service layer with explicit type
350
+ export const GitWorktreeServiceLive: Layer.Layer<GitWorktreeServiceImpl> = Layer.succeed(
351
+ GitWorktreeService,
352
+ GitWorktreeServiceImplLive,
353
+ )