@aaronshaf/ger 0.3.2 → 0.3.3
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/EXAMPLES.md +48 -0
- package/README.md +87 -0
- package/package.json +1 -1
- package/src/api/gerrit.ts +28 -0
- package/src/cli/commands/add-reviewer.ts +135 -0
- package/src/cli/commands/build-status.ts +49 -0
- package/src/cli/commands/push.ts +430 -0
- package/src/cli/commands/search.ts +162 -0
- package/src/cli/commands/show.ts +20 -0
- package/src/cli/index.ts +115 -74
- package/src/schemas/gerrit.ts +43 -0
- package/src/services/commit-hook.ts +314 -0
- package/tests/add-reviewer.test.ts +393 -0
- package/tests/integration/commit-hook.test.ts +246 -0
- package/tests/search.test.ts +712 -0
- package/tests/unit/commands/push.test.ts +194 -0
- package/tests/unit/patterns/push-patterns.test.ts +148 -0
- package/tests/unit/services/commit-hook.test.ts +132 -0
package/src/cli/index.ts
CHANGED
|
@@ -30,8 +30,10 @@ import { GerritApiServiceLive } from '@/api/gerrit'
|
|
|
30
30
|
import { ConfigServiceLive } from '@/services/config'
|
|
31
31
|
import { ReviewStrategyServiceLive } from '@/services/review-strategy'
|
|
32
32
|
import { GitWorktreeServiceLive } from '@/services/git-worktree'
|
|
33
|
+
import { CommitHookServiceLive } from '@/services/commit-hook'
|
|
33
34
|
import { abandonCommand } from './commands/abandon'
|
|
34
|
-
import {
|
|
35
|
+
import { addReviewerCommand } from './commands/add-reviewer'
|
|
36
|
+
import { buildStatusCommand, BUILD_STATUS_HELP_TEXT } from './commands/build-status'
|
|
35
37
|
import { commentCommand } from './commands/comment'
|
|
36
38
|
import { commentsCommand } from './commands/comments'
|
|
37
39
|
import { diffCommand } from './commands/diff'
|
|
@@ -39,9 +41,11 @@ import { extractUrlCommand } from './commands/extract-url'
|
|
|
39
41
|
import { incomingCommand } from './commands/incoming'
|
|
40
42
|
import { mineCommand } from './commands/mine'
|
|
41
43
|
import { openCommand } from './commands/open'
|
|
44
|
+
import { pushCommand, PUSH_HELP_TEXT } from './commands/push'
|
|
42
45
|
import { reviewCommand } from './commands/review'
|
|
46
|
+
import { searchCommand, SEARCH_HELP_TEXT } from './commands/search'
|
|
43
47
|
import { setup } from './commands/setup'
|
|
44
|
-
import { showCommand } from './commands/show'
|
|
48
|
+
import { showCommand, SHOW_HELP_TEXT } from './commands/show'
|
|
45
49
|
import { statusCommand } from './commands/status'
|
|
46
50
|
import { workspaceCommand } from './commands/workspace'
|
|
47
51
|
import { sanitizeCDATA } from '@/utils/shell-safety'
|
|
@@ -229,6 +233,34 @@ program
|
|
|
229
233
|
}
|
|
230
234
|
})
|
|
231
235
|
|
|
236
|
+
// search command
|
|
237
|
+
program
|
|
238
|
+
.command('search [query]')
|
|
239
|
+
.description('Search changes using Gerrit query syntax')
|
|
240
|
+
.option('--xml', 'XML output for LLM consumption')
|
|
241
|
+
.option('-n, --limit <number>', 'Limit results (default: 25)')
|
|
242
|
+
.addHelpText('after', SEARCH_HELP_TEXT)
|
|
243
|
+
.action(async (query, options) => {
|
|
244
|
+
const effect = searchCommand(query, options).pipe(
|
|
245
|
+
Effect.provide(GerritApiServiceLive),
|
|
246
|
+
Effect.provide(ConfigServiceLive),
|
|
247
|
+
)
|
|
248
|
+
await Effect.runPromise(effect).catch((error: unknown) => {
|
|
249
|
+
if (options.xml) {
|
|
250
|
+
console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
251
|
+
console.log(`<search_result>`)
|
|
252
|
+
console.log(` <status>error</status>`)
|
|
253
|
+
console.log(
|
|
254
|
+
` <error><![CDATA[${error instanceof Error ? error.message : String(error)}]]></error>`,
|
|
255
|
+
)
|
|
256
|
+
console.log(`</search_result>`)
|
|
257
|
+
} else {
|
|
258
|
+
console.error('✗ Error:', error instanceof Error ? error.message : String(error))
|
|
259
|
+
}
|
|
260
|
+
process.exit(1)
|
|
261
|
+
})
|
|
262
|
+
})
|
|
263
|
+
|
|
232
264
|
// workspace command
|
|
233
265
|
program
|
|
234
266
|
.command('workspace <change-id>')
|
|
@@ -319,6 +351,49 @@ program
|
|
|
319
351
|
}
|
|
320
352
|
})
|
|
321
353
|
|
|
354
|
+
// add-reviewer command
|
|
355
|
+
program
|
|
356
|
+
.command('add-reviewer <reviewers...>')
|
|
357
|
+
.description('Add reviewers to a change')
|
|
358
|
+
.option('-c, --change <change-id>', 'Change ID (required until auto-detection is implemented)')
|
|
359
|
+
.option('--cc', 'Add as CC instead of reviewer')
|
|
360
|
+
.option(
|
|
361
|
+
'--notify <level>',
|
|
362
|
+
'Notification level: none, owner, owner_reviewers, all (default: all)',
|
|
363
|
+
)
|
|
364
|
+
.option('--xml', 'XML output for LLM consumption')
|
|
365
|
+
.addHelpText(
|
|
366
|
+
'after',
|
|
367
|
+
`
|
|
368
|
+
Examples:
|
|
369
|
+
$ ger add-reviewer user@example.com -c 12345 # Add a reviewer
|
|
370
|
+
$ ger add-reviewer user1@example.com user2@example.com -c 12345 # Multiple
|
|
371
|
+
$ ger add-reviewer --cc user@example.com -c 12345 # Add as CC
|
|
372
|
+
$ ger add-reviewer --notify none user@example.com -c 12345 # No email`,
|
|
373
|
+
)
|
|
374
|
+
.action(async (reviewers, options) => {
|
|
375
|
+
try {
|
|
376
|
+
const effect = addReviewerCommand(reviewers, options).pipe(
|
|
377
|
+
Effect.provide(GerritApiServiceLive),
|
|
378
|
+
Effect.provide(ConfigServiceLive),
|
|
379
|
+
)
|
|
380
|
+
await Effect.runPromise(effect)
|
|
381
|
+
} catch (error) {
|
|
382
|
+
if (options.xml) {
|
|
383
|
+
console.log(`<?xml version="1.0" encoding="UTF-8"?>`)
|
|
384
|
+
console.log(`<add_reviewer_result>`)
|
|
385
|
+
console.log(` <status>error</status>`)
|
|
386
|
+
console.log(
|
|
387
|
+
` <error><![CDATA[${error instanceof Error ? error.message : String(error)}]]></error>`,
|
|
388
|
+
)
|
|
389
|
+
console.log(`</add_reviewer_result>`)
|
|
390
|
+
} else {
|
|
391
|
+
console.error('✗ Error:', error instanceof Error ? error.message : String(error))
|
|
392
|
+
}
|
|
393
|
+
process.exit(1)
|
|
394
|
+
}
|
|
395
|
+
})
|
|
396
|
+
|
|
322
397
|
// comments command
|
|
323
398
|
program
|
|
324
399
|
.command('comments <change-id>')
|
|
@@ -374,28 +449,7 @@ program
|
|
|
374
449
|
)
|
|
375
450
|
.option('--xml', 'XML output for LLM consumption')
|
|
376
451
|
.option('--json', 'JSON output for programmatic consumption')
|
|
377
|
-
.addHelpText(
|
|
378
|
-
'after',
|
|
379
|
-
`
|
|
380
|
-
Examples:
|
|
381
|
-
# Show specific change (using change number)
|
|
382
|
-
$ ger show 392385
|
|
383
|
-
|
|
384
|
-
# Show specific change (using Change-ID)
|
|
385
|
-
$ ger show If5a3ae8cb5a107e187447802358417f311d0c4b1
|
|
386
|
-
|
|
387
|
-
# Auto-detect Change-ID from HEAD commit
|
|
388
|
-
$ ger show
|
|
389
|
-
$ ger show --xml
|
|
390
|
-
$ ger show --json
|
|
391
|
-
|
|
392
|
-
# Extract build failure URL with jq
|
|
393
|
-
$ ger show 392090 --json | jq -r '.messages[] | select(.message | contains("Build Failed")) | .message' | grep -oP 'https://[^\\s]+'
|
|
394
|
-
|
|
395
|
-
Note: When no change-id is provided, it will be automatically extracted from the
|
|
396
|
-
Change-ID footer in your HEAD commit. You must be in a git repository with
|
|
397
|
-
a commit that has a Change-ID.`,
|
|
398
|
-
)
|
|
452
|
+
.addHelpText('after', SHOW_HELP_TEXT)
|
|
399
453
|
.action(async (changeId, options) => {
|
|
400
454
|
try {
|
|
401
455
|
const effect = showCommand(changeId, options).pipe(
|
|
@@ -430,56 +484,7 @@ program
|
|
|
430
484
|
.option('-i, --interval <seconds>', 'Refresh interval in seconds (default: 10)', '10')
|
|
431
485
|
.option('--timeout <seconds>', 'Maximum wait time in seconds (default: 1800 / 30min)', '1800')
|
|
432
486
|
.option('--exit-status', 'Exit with non-zero status if build fails')
|
|
433
|
-
.addHelpText(
|
|
434
|
-
'after',
|
|
435
|
-
`
|
|
436
|
-
This command parses Gerrit change messages to determine build status.
|
|
437
|
-
It looks for "Build Started" messages and subsequent verification labels.
|
|
438
|
-
|
|
439
|
-
Output is JSON with a "state" field that can be:
|
|
440
|
-
- pending: No build has started yet
|
|
441
|
-
- running: Build started but no verification yet
|
|
442
|
-
- success: Build completed with Verified+1
|
|
443
|
-
- failure: Build completed with Verified-1
|
|
444
|
-
- not_found: Change does not exist
|
|
445
|
-
|
|
446
|
-
Exit codes:
|
|
447
|
-
- 0: Default for all states (like gh run watch)
|
|
448
|
-
- 1: Only when --exit-status is used AND build fails
|
|
449
|
-
- 2: Timeout reached in watch mode
|
|
450
|
-
- 3: API/network errors
|
|
451
|
-
|
|
452
|
-
Examples:
|
|
453
|
-
# Single check (current behavior)
|
|
454
|
-
$ ger build-status 392385
|
|
455
|
-
{"state":"success"}
|
|
456
|
-
|
|
457
|
-
# Watch until completion (outputs JSON on each poll)
|
|
458
|
-
$ ger build-status 392385 --watch
|
|
459
|
-
{"state":"pending"}
|
|
460
|
-
{"state":"running"}
|
|
461
|
-
{"state":"running"}
|
|
462
|
-
{"state":"success"}
|
|
463
|
-
|
|
464
|
-
# Watch with custom interval (check every 5 seconds)
|
|
465
|
-
$ ger build-status --watch --interval 5
|
|
466
|
-
|
|
467
|
-
# Watch with custom timeout (60 minutes)
|
|
468
|
-
$ ger build-status --watch --timeout 3600
|
|
469
|
-
|
|
470
|
-
# Exit with code 1 on failure (for CI/CD pipelines)
|
|
471
|
-
$ ger build-status --watch --exit-status && deploy.sh
|
|
472
|
-
|
|
473
|
-
# Trigger notification when done (like gh run watch pattern)
|
|
474
|
-
$ ger build-status --watch && notify-send 'Build is done!'
|
|
475
|
-
|
|
476
|
-
# Parse final state in scripts
|
|
477
|
-
$ ger build-status --watch | tail -1 | jq -r '.state'
|
|
478
|
-
success
|
|
479
|
-
|
|
480
|
-
Note: When no change-id is provided, it will be automatically extracted from the
|
|
481
|
-
Change-ID footer in your HEAD commit.`,
|
|
482
|
-
)
|
|
487
|
+
.addHelpText('after', BUILD_STATUS_HELP_TEXT)
|
|
483
488
|
.action(async (changeId, cmdOptions) => {
|
|
484
489
|
try {
|
|
485
490
|
const effect = buildStatusCommand(changeId, {
|
|
@@ -570,6 +575,42 @@ Note:
|
|
|
570
575
|
}
|
|
571
576
|
})
|
|
572
577
|
|
|
578
|
+
// push command
|
|
579
|
+
program
|
|
580
|
+
.command('push')
|
|
581
|
+
.description('Push commits to Gerrit for code review')
|
|
582
|
+
.option('-b, --branch <branch>', 'Target branch (default: auto-detect)')
|
|
583
|
+
.option('-t, --topic <topic>', 'Set change topic')
|
|
584
|
+
.option('-r, --reviewer <email...>', 'Add reviewer(s)')
|
|
585
|
+
.option('--cc <email...>', 'Add CC recipient(s)')
|
|
586
|
+
.option('--wip', 'Mark as work-in-progress')
|
|
587
|
+
.option('--ready', 'Mark as ready for review')
|
|
588
|
+
.option('--hashtag <tag...>', 'Add hashtag(s)')
|
|
589
|
+
.option('--private', 'Mark change as private')
|
|
590
|
+
.option('--draft', 'Alias for --wip')
|
|
591
|
+
.option('--dry-run', 'Show what would be pushed without pushing')
|
|
592
|
+
.addHelpText('after', PUSH_HELP_TEXT)
|
|
593
|
+
.action(async (options) => {
|
|
594
|
+
try {
|
|
595
|
+
const effect = pushCommand({
|
|
596
|
+
branch: options.branch,
|
|
597
|
+
topic: options.topic,
|
|
598
|
+
reviewer: options.reviewer,
|
|
599
|
+
cc: options.cc,
|
|
600
|
+
wip: options.wip,
|
|
601
|
+
ready: options.ready,
|
|
602
|
+
hashtag: options.hashtag,
|
|
603
|
+
private: options.private,
|
|
604
|
+
draft: options.draft,
|
|
605
|
+
dryRun: options.dryRun,
|
|
606
|
+
}).pipe(Effect.provide(CommitHookServiceLive), Effect.provide(ConfigServiceLive))
|
|
607
|
+
await Effect.runPromise(effect)
|
|
608
|
+
} catch (error) {
|
|
609
|
+
console.error('Error:', error instanceof Error ? error.message : String(error))
|
|
610
|
+
process.exit(1)
|
|
611
|
+
}
|
|
612
|
+
})
|
|
613
|
+
|
|
573
614
|
// review command
|
|
574
615
|
program
|
|
575
616
|
.command('review <change-id>')
|
package/src/schemas/gerrit.ts
CHANGED
|
@@ -444,6 +444,49 @@ export const DiffCommandOptions: Schema.Schema<{
|
|
|
444
444
|
})
|
|
445
445
|
export type DiffCommandOptions = Schema.Schema.Type<typeof DiffCommandOptions>
|
|
446
446
|
|
|
447
|
+
// Reviewer schemas
|
|
448
|
+
export const ReviewerInput: Schema.Schema<{
|
|
449
|
+
readonly reviewer: string
|
|
450
|
+
readonly state?: 'REVIEWER' | 'CC' | 'REMOVED'
|
|
451
|
+
readonly confirmed?: boolean
|
|
452
|
+
readonly notify?: 'NONE' | 'OWNER' | 'OWNER_REVIEWERS' | 'ALL'
|
|
453
|
+
}> = Schema.Struct({
|
|
454
|
+
reviewer: Schema.String,
|
|
455
|
+
state: Schema.optional(Schema.Literal('REVIEWER', 'CC', 'REMOVED')),
|
|
456
|
+
confirmed: Schema.optional(Schema.Boolean),
|
|
457
|
+
notify: Schema.optional(Schema.Literal('NONE', 'OWNER', 'OWNER_REVIEWERS', 'ALL')),
|
|
458
|
+
})
|
|
459
|
+
export type ReviewerInput = Schema.Schema.Type<typeof ReviewerInput>
|
|
460
|
+
|
|
461
|
+
const ReviewerAccountInfo = Schema.Struct({
|
|
462
|
+
_account_id: Schema.Number,
|
|
463
|
+
name: Schema.optional(Schema.String),
|
|
464
|
+
email: Schema.optional(Schema.String),
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
export const ReviewerResult: Schema.Schema<{
|
|
468
|
+
readonly input: string
|
|
469
|
+
readonly reviewers?: ReadonlyArray<{
|
|
470
|
+
readonly _account_id: number
|
|
471
|
+
readonly name?: string
|
|
472
|
+
readonly email?: string
|
|
473
|
+
}>
|
|
474
|
+
readonly ccs?: ReadonlyArray<{
|
|
475
|
+
readonly _account_id: number
|
|
476
|
+
readonly name?: string
|
|
477
|
+
readonly email?: string
|
|
478
|
+
}>
|
|
479
|
+
readonly error?: string
|
|
480
|
+
readonly confirm?: boolean
|
|
481
|
+
}> = Schema.Struct({
|
|
482
|
+
input: Schema.String,
|
|
483
|
+
reviewers: Schema.optional(Schema.Array(ReviewerAccountInfo)),
|
|
484
|
+
ccs: Schema.optional(Schema.Array(ReviewerAccountInfo)),
|
|
485
|
+
error: Schema.optional(Schema.String),
|
|
486
|
+
confirm: Schema.optional(Schema.Boolean),
|
|
487
|
+
})
|
|
488
|
+
export type ReviewerResult = Schema.Schema.Type<typeof ReviewerResult>
|
|
489
|
+
|
|
447
490
|
// API Response schemas
|
|
448
491
|
export const GerritError: Schema.Schema<{
|
|
449
492
|
readonly message: string
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { Effect, Context, Layer, Console, pipe } from 'effect'
|
|
2
|
+
import { Schema } from 'effect'
|
|
3
|
+
import * as fs from 'node:fs'
|
|
4
|
+
import * as path from 'node:path'
|
|
5
|
+
import { execSync, spawnSync } from 'node:child_process'
|
|
6
|
+
import { ConfigService, type ConfigServiceImpl } from '@/services/config'
|
|
7
|
+
|
|
8
|
+
// Error types
|
|
9
|
+
//
|
|
10
|
+
// NOTE: The `as unknown` casts below are a workaround for Effect Schema's TaggedError
|
|
11
|
+
// type inference limitations. Schema.TaggedError returns a complex union type that
|
|
12
|
+
// doesn't directly satisfy the class extension pattern we need. The cast allows us
|
|
13
|
+
// to extend the schema as a class while maintaining the tagged error behavior.
|
|
14
|
+
// This pattern is used consistently across the codebase for Effect Schema errors.
|
|
15
|
+
// See: https://effect.website/docs/schema/basic-usage#tagged-errors
|
|
16
|
+
|
|
17
|
+
export interface HookInstallErrorFields {
|
|
18
|
+
readonly message: string
|
|
19
|
+
readonly cause?: unknown
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const HookInstallErrorSchema = Schema.TaggedError<HookInstallErrorFields>()('HookInstallError', {
|
|
23
|
+
message: Schema.String,
|
|
24
|
+
cause: Schema.optional(Schema.Unknown),
|
|
25
|
+
}) as unknown
|
|
26
|
+
|
|
27
|
+
export class HookInstallError
|
|
28
|
+
extends (HookInstallErrorSchema as new (
|
|
29
|
+
args: HookInstallErrorFields,
|
|
30
|
+
) => HookInstallErrorFields & Error & { readonly _tag: 'HookInstallError' })
|
|
31
|
+
implements Error
|
|
32
|
+
{
|
|
33
|
+
readonly name = 'HookInstallError'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface MissingChangeIdErrorFields {
|
|
37
|
+
readonly message: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const MissingChangeIdErrorSchema = Schema.TaggedError<MissingChangeIdErrorFields>()(
|
|
41
|
+
'MissingChangeIdError',
|
|
42
|
+
{
|
|
43
|
+
message: Schema.String,
|
|
44
|
+
},
|
|
45
|
+
) as unknown
|
|
46
|
+
|
|
47
|
+
export class MissingChangeIdError
|
|
48
|
+
extends (MissingChangeIdErrorSchema as new (
|
|
49
|
+
args: MissingChangeIdErrorFields,
|
|
50
|
+
) => MissingChangeIdErrorFields & Error & { readonly _tag: 'MissingChangeIdError' })
|
|
51
|
+
implements Error
|
|
52
|
+
{
|
|
53
|
+
readonly name = 'MissingChangeIdError'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface NotGitRepoErrorFields {
|
|
57
|
+
readonly message: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const NotGitRepoErrorSchema = Schema.TaggedError<NotGitRepoErrorFields>()('NotGitRepoError', {
|
|
61
|
+
message: Schema.String,
|
|
62
|
+
}) as unknown
|
|
63
|
+
|
|
64
|
+
export class NotGitRepoError
|
|
65
|
+
extends (NotGitRepoErrorSchema as new (
|
|
66
|
+
args: NotGitRepoErrorFields,
|
|
67
|
+
) => NotGitRepoErrorFields & Error & { readonly _tag: 'NotGitRepoError' })
|
|
68
|
+
implements Error
|
|
69
|
+
{
|
|
70
|
+
readonly name = 'NotGitRepoError'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type CommitHookError = HookInstallError | MissingChangeIdError | NotGitRepoError
|
|
74
|
+
|
|
75
|
+
/** Regex pattern to match Gerrit Change-Id in commit messages */
|
|
76
|
+
export const CHANGE_ID_PATTERN: RegExp = /^Change-Id: I[0-9a-f]{40}$/m
|
|
77
|
+
|
|
78
|
+
// Get .git directory path (handles both regular repos and worktrees)
|
|
79
|
+
export const getGitDir = (): string => {
|
|
80
|
+
try {
|
|
81
|
+
return execSync('git rev-parse --git-dir', { encoding: 'utf8' }).trim()
|
|
82
|
+
} catch {
|
|
83
|
+
throw new Error('Not in a git repository')
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Get absolute .git directory path
|
|
88
|
+
export const getGitDirAbsolute = (): string => {
|
|
89
|
+
try {
|
|
90
|
+
return execSync('git rev-parse --absolute-git-dir', { encoding: 'utf8' }).trim()
|
|
91
|
+
} catch {
|
|
92
|
+
throw new Error('Not in a git repository')
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Check if commit-msg hook exists and is executable
|
|
97
|
+
export const hasCommitMsgHook = (): boolean => {
|
|
98
|
+
try {
|
|
99
|
+
const gitDir = getGitDir()
|
|
100
|
+
const hookPath = path.join(gitDir, 'hooks', 'commit-msg')
|
|
101
|
+
|
|
102
|
+
if (!fs.existsSync(hookPath)) {
|
|
103
|
+
return false
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Check if file is executable
|
|
107
|
+
const stats = fs.statSync(hookPath)
|
|
108
|
+
// Check owner execute bit (0o100)
|
|
109
|
+
return (stats.mode & 0o100) !== 0
|
|
110
|
+
} catch {
|
|
111
|
+
return false
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Check if a commit has a Change-Id in its message
|
|
116
|
+
export const commitHasChangeId = (commit: string = 'HEAD'): boolean => {
|
|
117
|
+
try {
|
|
118
|
+
const result = spawnSync('git', ['log', '-1', '--format=%B', commit], { encoding: 'utf8' })
|
|
119
|
+
if (result.status !== 0) {
|
|
120
|
+
return false
|
|
121
|
+
}
|
|
122
|
+
return CHANGE_ID_PATTERN.test(result.stdout)
|
|
123
|
+
} catch {
|
|
124
|
+
return false
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Get the hooks directory path
|
|
129
|
+
export const getHooksDir = (): string => {
|
|
130
|
+
const gitDir = getGitDir()
|
|
131
|
+
return path.join(gitDir, 'hooks')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Service interface
|
|
135
|
+
export interface CommitHookServiceImpl {
|
|
136
|
+
readonly hasHook: () => Effect.Effect<boolean, NotGitRepoError>
|
|
137
|
+
readonly hasChangeId: (commit?: string) => Effect.Effect<boolean, NotGitRepoError>
|
|
138
|
+
readonly installHook: () => Effect.Effect<
|
|
139
|
+
void,
|
|
140
|
+
HookInstallError | NotGitRepoError,
|
|
141
|
+
ConfigServiceImpl
|
|
142
|
+
>
|
|
143
|
+
readonly ensureChangeId: () => Effect.Effect<
|
|
144
|
+
void,
|
|
145
|
+
HookInstallError | MissingChangeIdError | NotGitRepoError,
|
|
146
|
+
ConfigServiceImpl
|
|
147
|
+
>
|
|
148
|
+
readonly amendWithChangeId: () => Effect.Effect<void, HookInstallError | NotGitRepoError>
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const CommitHookServiceImplLive: CommitHookServiceImpl = {
|
|
152
|
+
hasHook: () =>
|
|
153
|
+
Effect.try({
|
|
154
|
+
try: () => hasCommitMsgHook(),
|
|
155
|
+
catch: () => new NotGitRepoError({ message: 'Not in a git repository' }),
|
|
156
|
+
}),
|
|
157
|
+
|
|
158
|
+
hasChangeId: (commit = 'HEAD') =>
|
|
159
|
+
Effect.try({
|
|
160
|
+
try: () => commitHasChangeId(commit),
|
|
161
|
+
catch: () => new NotGitRepoError({ message: 'Not in a git repository' }),
|
|
162
|
+
}),
|
|
163
|
+
|
|
164
|
+
installHook: () =>
|
|
165
|
+
Effect.gen(function* () {
|
|
166
|
+
const configService = yield* ConfigService
|
|
167
|
+
|
|
168
|
+
// Get config to find Gerrit host
|
|
169
|
+
const config = yield* pipe(
|
|
170
|
+
configService.getCredentials,
|
|
171
|
+
Effect.mapError(
|
|
172
|
+
(e) => new HookInstallError({ message: `Failed to get config: ${e.message}` }),
|
|
173
|
+
),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
// Try to get hook via HTTP first (most reliable)
|
|
177
|
+
const normalizedHost = config.host.replace(/\/$/, '')
|
|
178
|
+
const hookUrl = `${normalizedHost}/tools/hooks/commit-msg`
|
|
179
|
+
|
|
180
|
+
yield* Console.log(`Installing commit-msg hook from ${config.host}...`)
|
|
181
|
+
|
|
182
|
+
const hookContent = yield* Effect.tryPromise({
|
|
183
|
+
try: async () => {
|
|
184
|
+
const response = await fetch(hookUrl)
|
|
185
|
+
if (!response.ok) {
|
|
186
|
+
throw new Error(`Failed to fetch hook: ${response.status} ${response.statusText}`)
|
|
187
|
+
}
|
|
188
|
+
return response.text()
|
|
189
|
+
},
|
|
190
|
+
catch: (error) =>
|
|
191
|
+
new HookInstallError({
|
|
192
|
+
message: `Failed to download commit-msg hook from ${hookUrl}: ${error}`,
|
|
193
|
+
cause: error,
|
|
194
|
+
}),
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
// Validate hook content (should be a shell script)
|
|
198
|
+
if (!hookContent.startsWith('#!')) {
|
|
199
|
+
yield* Effect.fail(
|
|
200
|
+
new HookInstallError({
|
|
201
|
+
message: 'Downloaded hook does not appear to be a valid script',
|
|
202
|
+
}),
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Get hooks directory and ensure it exists
|
|
207
|
+
const hooksDir = yield* Effect.try({
|
|
208
|
+
try: () => getHooksDir(),
|
|
209
|
+
catch: () => new NotGitRepoError({ message: 'Not in a git repository' }),
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
yield* Effect.try({
|
|
213
|
+
try: () => {
|
|
214
|
+
if (!fs.existsSync(hooksDir)) {
|
|
215
|
+
fs.mkdirSync(hooksDir, { recursive: true })
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
catch: (error) =>
|
|
219
|
+
new HookInstallError({
|
|
220
|
+
message: `Failed to create hooks directory: ${error}`,
|
|
221
|
+
cause: error,
|
|
222
|
+
}),
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
// Write hook file
|
|
226
|
+
const hookPath = path.join(hooksDir, 'commit-msg')
|
|
227
|
+
|
|
228
|
+
yield* Effect.try({
|
|
229
|
+
try: () => {
|
|
230
|
+
fs.writeFileSync(hookPath, hookContent, { mode: 0o755 })
|
|
231
|
+
},
|
|
232
|
+
catch: (error) =>
|
|
233
|
+
new HookInstallError({
|
|
234
|
+
message: `Failed to write commit-msg hook: ${error}`,
|
|
235
|
+
cause: error,
|
|
236
|
+
}),
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
yield* Console.log('commit-msg hook installed successfully')
|
|
240
|
+
}),
|
|
241
|
+
|
|
242
|
+
ensureChangeId: () =>
|
|
243
|
+
Effect.gen(function* () {
|
|
244
|
+
// Check if HEAD already has a Change-Id (using pure function directly)
|
|
245
|
+
if (commitHasChangeId()) {
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Check if hook is installed (using pure function directly)
|
|
250
|
+
if (!hasCommitMsgHook()) {
|
|
251
|
+
// Install hook and amend commit
|
|
252
|
+
yield* CommitHookServiceImplLive.installHook()
|
|
253
|
+
yield* CommitHookServiceImplLive.amendWithChangeId()
|
|
254
|
+
} else {
|
|
255
|
+
// Hook exists but commit doesn't have Change-Id
|
|
256
|
+
// This means the commit was created without the hook or hook failed
|
|
257
|
+
yield* Effect.fail(
|
|
258
|
+
new MissingChangeIdError({
|
|
259
|
+
message:
|
|
260
|
+
'Commit is missing Change-Id. The commit-msg hook is installed but did not run.\n' +
|
|
261
|
+
'Please amend your commit: git commit --amend',
|
|
262
|
+
}),
|
|
263
|
+
)
|
|
264
|
+
}
|
|
265
|
+
}),
|
|
266
|
+
|
|
267
|
+
amendWithChangeId: () =>
|
|
268
|
+
Effect.gen(function* () {
|
|
269
|
+
yield* Console.log('Amending commit to add Change-Id...')
|
|
270
|
+
|
|
271
|
+
yield* Effect.try({
|
|
272
|
+
try: () => {
|
|
273
|
+
// Use --no-edit to keep the same message, hook will add Change-Id
|
|
274
|
+
const result = spawnSync('git', ['commit', '--amend', '--no-edit'], {
|
|
275
|
+
encoding: 'utf8',
|
|
276
|
+
stdio: ['inherit', 'pipe', 'pipe'],
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
if (result.status !== 0) {
|
|
280
|
+
throw new Error(result.stderr || 'git commit --amend failed')
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
catch: (error) =>
|
|
284
|
+
new HookInstallError({
|
|
285
|
+
message: `Failed to amend commit: ${error}`,
|
|
286
|
+
cause: error,
|
|
287
|
+
}),
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
// Verify Change-Id was added
|
|
291
|
+
const hasId = commitHasChangeId()
|
|
292
|
+
if (!hasId) {
|
|
293
|
+
yield* Effect.fail(
|
|
294
|
+
new HookInstallError({
|
|
295
|
+
message: 'Failed to add Change-Id to commit. Hook may not be working correctly.',
|
|
296
|
+
}),
|
|
297
|
+
)
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
yield* Console.log('Change-Id added to commit')
|
|
301
|
+
}),
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Export service tag
|
|
305
|
+
export const CommitHookService: Context.Tag<CommitHookServiceImpl, CommitHookServiceImpl> =
|
|
306
|
+
Context.GenericTag<CommitHookServiceImpl>('CommitHookService')
|
|
307
|
+
|
|
308
|
+
export type CommitHookService = Context.Tag.Identifier<typeof CommitHookService>
|
|
309
|
+
|
|
310
|
+
// Export service layer
|
|
311
|
+
export const CommitHookServiceLive: Layer.Layer<CommitHookServiceImpl> = Layer.succeed(
|
|
312
|
+
CommitHookService,
|
|
313
|
+
CommitHookServiceImplLive,
|
|
314
|
+
)
|