@aaronshaf/ger 1.2.10 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/.ast-grep/rules/no-as-casting.yml +13 -0
  2. package/.claude-plugin/plugin.json +22 -0
  3. package/.github/workflows/ci-simple.yml +53 -0
  4. package/.github/workflows/ci.yml +171 -0
  5. package/.github/workflows/claude-code-review.yml +83 -0
  6. package/.github/workflows/claude.yml +50 -0
  7. package/.github/workflows/dependency-update.yml +84 -0
  8. package/.github/workflows/release.yml +166 -0
  9. package/.github/workflows/security-scan.yml +113 -0
  10. package/.github/workflows/security.yml +96 -0
  11. package/.husky/pre-commit +16 -0
  12. package/.husky/pre-push +25 -0
  13. package/.lintstagedrc.json +6 -0
  14. package/.tool-versions +1 -0
  15. package/CLAUDE.md +105 -0
  16. package/DEVELOPMENT.md +361 -0
  17. package/EXAMPLES.md +457 -0
  18. package/README.md +831 -16
  19. package/bin/ger +3 -18
  20. package/biome.json +36 -0
  21. package/bun.lock +678 -0
  22. package/bunfig.toml +8 -0
  23. package/docs/adr/0001-use-effect-for-side-effects.md +65 -0
  24. package/docs/adr/0002-use-bun-runtime.md +64 -0
  25. package/docs/adr/0003-store-credentials-in-home-directory.md +75 -0
  26. package/docs/adr/0004-use-commander-for-cli.md +76 -0
  27. package/docs/adr/0005-use-effect-schema-for-validation.md +93 -0
  28. package/docs/adr/0006-use-msw-for-api-mocking.md +89 -0
  29. package/docs/adr/0007-git-hooks-for-quality.md +94 -0
  30. package/docs/adr/0008-no-as-typecasting.md +83 -0
  31. package/docs/adr/0009-file-size-limits.md +82 -0
  32. package/docs/adr/0010-llm-friendly-xml-output.md +93 -0
  33. package/docs/adr/0011-ai-tool-strategy-pattern.md +102 -0
  34. package/docs/adr/0012-build-status-message-parsing.md +94 -0
  35. package/docs/adr/0013-git-subprocess-integration.md +98 -0
  36. package/docs/adr/0014-group-management-support.md +95 -0
  37. package/docs/adr/0015-batch-comment-processing.md +111 -0
  38. package/docs/adr/0016-flexible-change-identifiers.md +94 -0
  39. package/docs/adr/0017-git-worktree-support.md +102 -0
  40. package/docs/adr/0018-auto-install-commit-hook.md +103 -0
  41. package/docs/adr/0019-sdk-package-exports.md +95 -0
  42. package/docs/adr/0020-code-coverage-enforcement.md +105 -0
  43. package/docs/adr/0021-typescript-isolated-declarations.md +83 -0
  44. package/docs/adr/0022-biome-oxlint-tooling.md +124 -0
  45. package/docs/adr/README.md +30 -0
  46. package/docs/prd/README.md +12 -0
  47. package/docs/prd/architecture.md +325 -0
  48. package/docs/prd/commands.md +425 -0
  49. package/docs/prd/data-model.md +349 -0
  50. package/docs/prd/overview.md +124 -0
  51. package/index.ts +219 -0
  52. package/oxlint.json +24 -0
  53. package/package.json +82 -15
  54. package/scripts/check-coverage.ts +69 -0
  55. package/scripts/check-file-size.ts +38 -0
  56. package/scripts/fix-test-mocks.ts +55 -0
  57. package/skills/gerrit-workflow/SKILL.md +247 -0
  58. package/skills/gerrit-workflow/examples.md +572 -0
  59. package/skills/gerrit-workflow/reference.md +728 -0
  60. package/src/api/gerrit.ts +696 -0
  61. package/src/cli/commands/abandon.ts +65 -0
  62. package/src/cli/commands/add-reviewer.ts +156 -0
  63. package/src/cli/commands/build-status.ts +282 -0
  64. package/src/cli/commands/checkout.ts +422 -0
  65. package/src/cli/commands/comment.ts +460 -0
  66. package/src/cli/commands/comments.ts +85 -0
  67. package/src/cli/commands/diff.ts +71 -0
  68. package/src/cli/commands/extract-url.ts +266 -0
  69. package/src/cli/commands/groups-members.ts +104 -0
  70. package/src/cli/commands/groups-show.ts +169 -0
  71. package/src/cli/commands/groups.ts +137 -0
  72. package/src/cli/commands/incoming.ts +226 -0
  73. package/src/cli/commands/init.ts +164 -0
  74. package/src/cli/commands/mine.ts +115 -0
  75. package/src/cli/commands/open.ts +57 -0
  76. package/src/cli/commands/projects.ts +68 -0
  77. package/src/cli/commands/push.ts +430 -0
  78. package/src/cli/commands/rebase.ts +52 -0
  79. package/src/cli/commands/remove-reviewer.ts +123 -0
  80. package/src/cli/commands/restore.ts +50 -0
  81. package/src/cli/commands/review.ts +486 -0
  82. package/src/cli/commands/search.ts +162 -0
  83. package/src/cli/commands/setup.ts +286 -0
  84. package/src/cli/commands/show.ts +491 -0
  85. package/src/cli/commands/status.ts +35 -0
  86. package/src/cli/commands/submit.ts +108 -0
  87. package/src/cli/commands/vote.ts +119 -0
  88. package/src/cli/commands/workspace.ts +200 -0
  89. package/src/cli/index.ts +53 -0
  90. package/src/cli/register-commands.ts +659 -0
  91. package/src/cli/register-group-commands.ts +88 -0
  92. package/src/cli/register-reviewer-commands.ts +97 -0
  93. package/src/prompts/default-review.md +86 -0
  94. package/src/prompts/system-inline-review.md +135 -0
  95. package/src/prompts/system-overall-review.md +206 -0
  96. package/src/schemas/config.test.ts +245 -0
  97. package/src/schemas/config.ts +84 -0
  98. package/src/schemas/gerrit.ts +681 -0
  99. package/src/services/commit-hook.ts +314 -0
  100. package/src/services/config.test.ts +150 -0
  101. package/src/services/config.ts +250 -0
  102. package/src/services/git-worktree.ts +342 -0
  103. package/src/services/review-strategy.ts +292 -0
  104. package/src/test-utils/mock-generator.ts +138 -0
  105. package/src/utils/change-id.test.ts +98 -0
  106. package/src/utils/change-id.ts +63 -0
  107. package/src/utils/comment-formatters.ts +153 -0
  108. package/src/utils/diff-context.ts +103 -0
  109. package/src/utils/diff-formatters.ts +141 -0
  110. package/src/utils/formatters.ts +85 -0
  111. package/src/utils/git-commit.test.ts +277 -0
  112. package/src/utils/git-commit.ts +122 -0
  113. package/src/utils/index.ts +55 -0
  114. package/src/utils/message-filters.ts +26 -0
  115. package/src/utils/review-formatters.ts +89 -0
  116. package/src/utils/review-prompt-builder.ts +110 -0
  117. package/src/utils/shell-safety.ts +117 -0
  118. package/src/utils/status-indicators.ts +100 -0
  119. package/src/utils/url-parser.test.ts +271 -0
  120. package/src/utils/url-parser.ts +118 -0
  121. package/tests/abandon.test.ts +230 -0
  122. package/tests/add-reviewer.test.ts +579 -0
  123. package/tests/build-status-watch.test.ts +344 -0
  124. package/tests/build-status.test.ts +789 -0
  125. package/tests/change-id-formats.test.ts +268 -0
  126. package/tests/checkout/integration.test.ts +653 -0
  127. package/tests/checkout/parse-input.test.ts +55 -0
  128. package/tests/checkout/validation.test.ts +178 -0
  129. package/tests/comment-batch-advanced.test.ts +431 -0
  130. package/tests/comment-gerrit-api-compliance.test.ts +414 -0
  131. package/tests/comment.test.ts +708 -0
  132. package/tests/comments.test.ts +323 -0
  133. package/tests/config-service-simple.test.ts +100 -0
  134. package/tests/diff.test.ts +419 -0
  135. package/tests/extract-url.test.ts +517 -0
  136. package/tests/groups-members.test.ts +256 -0
  137. package/tests/groups-show.test.ts +323 -0
  138. package/tests/groups.test.ts +334 -0
  139. package/tests/helpers/build-status-test-setup.ts +83 -0
  140. package/tests/helpers/config-mock.ts +27 -0
  141. package/tests/incoming.test.ts +357 -0
  142. package/tests/init.test.ts +70 -0
  143. package/tests/integration/commit-hook.test.ts +246 -0
  144. package/tests/interactive-incoming.test.ts +173 -0
  145. package/tests/mine.test.ts +285 -0
  146. package/tests/mocks/msw-handlers.ts +80 -0
  147. package/tests/open.test.ts +233 -0
  148. package/tests/projects.test.ts +259 -0
  149. package/tests/rebase.test.ts +271 -0
  150. package/tests/remove-reviewer.test.ts +357 -0
  151. package/tests/restore.test.ts +237 -0
  152. package/tests/review.test.ts +135 -0
  153. package/tests/search.test.ts +712 -0
  154. package/tests/setup.test.ts +63 -0
  155. package/tests/show-auto-detect.test.ts +324 -0
  156. package/tests/show.test.ts +813 -0
  157. package/tests/status.test.ts +145 -0
  158. package/tests/submit.test.ts +316 -0
  159. package/tests/unit/commands/push.test.ts +194 -0
  160. package/tests/unit/git-branch-detection.test.ts +82 -0
  161. package/tests/unit/git-worktree.test.ts +55 -0
  162. package/tests/unit/patterns/push-patterns.test.ts +148 -0
  163. package/tests/unit/schemas/gerrit.test.ts +85 -0
  164. package/tests/unit/services/commit-hook.test.ts +132 -0
  165. package/tests/unit/services/review-strategy.test.ts +349 -0
  166. package/tests/unit/test-utils/mock-generator.test.ts +154 -0
  167. package/tests/unit/utils/comment-formatters.test.ts +415 -0
  168. package/tests/unit/utils/diff-context.test.ts +171 -0
  169. package/tests/unit/utils/diff-formatters.test.ts +165 -0
  170. package/tests/unit/utils/formatters.test.ts +411 -0
  171. package/tests/unit/utils/message-filters.test.ts +227 -0
  172. package/tests/unit/utils/shell-safety.test.ts +230 -0
  173. package/tests/unit/utils/status-indicators.test.ts +137 -0
  174. package/tests/vote.test.ts +317 -0
  175. package/tests/workspace.test.ts +295 -0
  176. package/tsconfig.json +36 -5
  177. package/src/commands/branch.ts +0 -180
  178. package/src/ger.ts +0 -22
  179. package/src/types.d.ts +0 -35
  180. package/src/utils.ts +0 -130
@@ -0,0 +1,82 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import { Effect, Layer } from 'effect'
3
+ import { GitWorktreeService } from '@/services/git-worktree'
4
+
5
+ describe('Git Worktree Creation', () => {
6
+ test('should handle commit-based worktree creation in service interface', async () => {
7
+ // This test verifies that the GitWorktreeService creates worktrees using
8
+ // commit hashes to avoid branch conflicts (detached HEAD approach)
9
+
10
+ const mockGitService = {
11
+ validatePreconditions: () => Effect.succeed(undefined),
12
+ createWorktree: (changeId: string) => {
13
+ // Simulate commit-based worktree creation (detached HEAD)
14
+ return Effect.succeed({
15
+ path: `/tmp/test-worktree-${changeId}`,
16
+ changeId,
17
+ originalCwd: process.cwd(),
18
+ timestamp: Date.now(),
19
+ pid: process.pid,
20
+ })
21
+ },
22
+ fetchAndCheckoutPatchset: () => Effect.succeed(undefined),
23
+ cleanup: () => Effect.succeed(undefined),
24
+ getChangedFiles: () => Effect.succeed(['test.ts']),
25
+ }
26
+
27
+ const result = await Effect.runPromise(
28
+ Effect.gen(function* () {
29
+ const service = yield* GitWorktreeService
30
+
31
+ // This call should work without specifying a base branch
32
+ // The implementation will auto-detect main vs master vs other
33
+ const worktree = yield* service.createWorktree('12345')
34
+
35
+ return worktree
36
+ }).pipe(Effect.provide(Layer.succeed(GitWorktreeService, mockGitService))),
37
+ )
38
+
39
+ expect(result.changeId).toBe('12345')
40
+ expect(result.path).toContain('12345')
41
+ })
42
+
43
+ test('should demonstrate branch detection scenarios', () => {
44
+ // Test various branch detection patterns that the real implementation should handle
45
+ const testCases = [
46
+ { input: 'refs/remotes/origin/main', expected: 'main' },
47
+ { input: 'refs/remotes/origin/master', expected: 'master' },
48
+ { input: 'refs/remotes/origin/develop', expected: 'develop' },
49
+ ]
50
+
51
+ testCases.forEach(({ input, expected }) => {
52
+ // Simulate the regex pattern used in getDefaultBranch
53
+ const match = input.match(/refs\/remotes\/origin\/(.+)$/)
54
+ const result = match ? match[1] : 'main'
55
+ expect(result).toBe(expected)
56
+ })
57
+ })
58
+
59
+ test('should handle branch list parsing scenarios', () => {
60
+ // Test branch list parsing scenarios
61
+ const testCases = [
62
+ { input: ' origin/main\n origin/feature-branch', expected: 'main' },
63
+ { input: ' origin/master\n origin/develop', expected: 'master' },
64
+ { input: ' origin/main\n origin/master', expected: 'main' }, // main takes precedence
65
+ { input: ' origin/feature-only', expected: 'main' }, // fallback
66
+ { input: '', expected: 'main' }, // empty fallback
67
+ ]
68
+
69
+ testCases.forEach(({ input, expected }) => {
70
+ // Simulate the branch detection logic
71
+ let result: string
72
+ if (input.includes('origin/main')) {
73
+ result = 'main'
74
+ } else if (input.includes('origin/master')) {
75
+ result = 'master'
76
+ } else {
77
+ result = 'main' // fallback
78
+ }
79
+ expect(result).toBe(expected)
80
+ })
81
+ })
82
+ })
@@ -0,0 +1,55 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import { Effect, Layer } from 'effect'
3
+ import { GitWorktreeService, WorktreeInfo } from '@/services/git-worktree'
4
+
5
+ describe('GitWorktreeService Types and Structure', () => {
6
+ test('should export WorktreeInfo interface with correct structure', () => {
7
+ const mockWorktreeInfo: WorktreeInfo = {
8
+ path: '/tmp/test-worktree',
9
+ changeId: '12345',
10
+ originalCwd: '/test/current',
11
+ timestamp: Date.now(),
12
+ pid: process.pid,
13
+ }
14
+
15
+ expect(mockWorktreeInfo.path).toBe('/tmp/test-worktree')
16
+ expect(mockWorktreeInfo.changeId).toBe('12345')
17
+ expect(mockWorktreeInfo.originalCwd).toBe('/test/current')
18
+ expect(typeof mockWorktreeInfo.timestamp).toBe('number')
19
+ expect(typeof mockWorktreeInfo.pid).toBe('number')
20
+ })
21
+
22
+ test('should create service tag correctly', () => {
23
+ expect(GitWorktreeService).toBeDefined()
24
+ expect(typeof GitWorktreeService).toBe('object')
25
+ expect(GitWorktreeService.key).toBe('GitWorktreeService')
26
+ })
27
+
28
+ test('should be able to create mock service implementation', async () => {
29
+ const mockService = {
30
+ validatePreconditions: () => Effect.succeed(undefined),
31
+ createWorktree: (changeId: string) =>
32
+ Effect.succeed({
33
+ path: `/tmp/test-worktree-${changeId}`,
34
+ changeId,
35
+ originalCwd: process.cwd(),
36
+ timestamp: Date.now(),
37
+ pid: process.pid,
38
+ }),
39
+ fetchAndCheckoutPatchset: () => Effect.succeed(undefined),
40
+ cleanup: () => Effect.succeed(undefined),
41
+ getChangedFiles: () => Effect.succeed(['test.ts']),
42
+ }
43
+
44
+ const result = await Effect.runPromise(
45
+ Effect.gen(function* () {
46
+ const service = yield* GitWorktreeService
47
+ const worktree = yield* service.createWorktree('12345')
48
+ return worktree
49
+ }).pipe(Effect.provide(Layer.succeed(GitWorktreeService, mockService))),
50
+ )
51
+
52
+ expect(result.changeId).toBe('12345')
53
+ expect(result.path).toContain('12345')
54
+ })
55
+ })
@@ -0,0 +1,148 @@
1
+ import { describe, test, expect } from 'bun:test'
2
+ import { CHANGE_ID_PATTERN } from '@/services/commit-hook'
3
+
4
+ // Pattern matching tests for push command output parsing
5
+ // These test regex patterns and string parsing logic used by the push command
6
+ // Unit tests for buildPushRefspec are in tests/unit/commands/push.test.ts
7
+
8
+ describe('Push Command', () => {
9
+ describe('remote detection logic', () => {
10
+ test('should handle SSH remote format', () => {
11
+ const sshRemote = 'git@gerrit.example.com:project.git'
12
+
13
+ // Extract hostname from SSH format
14
+ const hostname = sshRemote.split('@')[1].split(':')[0]
15
+ expect(hostname).toBe('gerrit.example.com')
16
+ })
17
+
18
+ test('should handle HTTPS remote format', () => {
19
+ const httpsRemote = 'https://gerrit.example.com/project'
20
+
21
+ const url = new URL(httpsRemote)
22
+ expect(url.hostname).toBe('gerrit.example.com')
23
+ })
24
+
25
+ test('should parse remote output format', () => {
26
+ const remoteOutput = `origin\thttps://gerrit.example.com/project\t(fetch)
27
+ origin\thttps://gerrit.example.com/project\t(push)
28
+ upstream\tgit@github.com:org/project.git\t(fetch)
29
+ upstream\tgit@github.com:org/project.git\t(push)`
30
+
31
+ const remotes: Record<string, string> = {}
32
+ for (const line of remoteOutput.split('\n')) {
33
+ const match = line.match(/^(\S+)\s+(\S+)\s+\(push\)$/)
34
+ if (match) {
35
+ remotes[match[1]] = match[2]
36
+ }
37
+ }
38
+
39
+ expect(remotes['origin']).toBe('https://gerrit.example.com/project')
40
+ expect(remotes['upstream']).toBe('git@github.com:org/project.git')
41
+ })
42
+ })
43
+
44
+ // Note: refspec building tests are in tests/unit/commands/push.test.ts
45
+
46
+ describe('change URL extraction', () => {
47
+ test('should extract change URL from push output', () => {
48
+ const output = `Enumerating objects: 5, done.
49
+ Counting objects: 100% (5/5), done.
50
+ Delta compression using up to 8 threads
51
+ Compressing objects: 100% (3/3), done.
52
+ Writing objects: 100% (3/3), 512 bytes | 512.00 KiB/s, done.
53
+ Total 3 (delta 2), reused 0 (delta 0), pack-reused 0
54
+ remote: Resolving deltas: 100% (2/2)
55
+ remote: Processing changes: refs: 1, new: 1, done
56
+ remote:
57
+ remote: SUCCESS
58
+ remote:
59
+ remote: https://gerrit.example.com/c/project/+/12345 Fix auth bug [NEW]
60
+ remote:
61
+ To https://gerrit.example.com/project
62
+ * [new reference] HEAD -> refs/for/master`
63
+
64
+ const urlMatch = output.match(/remote:\s+(https?:\/\/\S+\/c\/\S+\/\+\/\d+)/)
65
+ expect(urlMatch).not.toBeNull()
66
+ expect(urlMatch![1]).toBe('https://gerrit.example.com/c/project/+/12345')
67
+ })
68
+
69
+ test('should handle output without change URL', () => {
70
+ const output = `Everything up-to-date
71
+ remote: no new changes`
72
+
73
+ const urlMatch = output.match(/remote:\s+(https?:\/\/\S+\/c\/\S+\/\+\/\d+)/)
74
+ expect(urlMatch).toBeNull()
75
+ })
76
+ })
77
+
78
+ describe('commit-msg hook detection', () => {
79
+ test('should check for Change-Id in commit message', () => {
80
+ const commitWithChangeId = `Fix authentication bug
81
+
82
+ This commit fixes the login issue.
83
+
84
+ Change-Id: I1234567890123456789012345678901234567890`
85
+
86
+ expect(CHANGE_ID_PATTERN.test(commitWithChangeId)).toBe(true)
87
+ })
88
+
89
+ test('should detect missing Change-Id', () => {
90
+ const commitWithoutChangeId = `Fix authentication bug
91
+
92
+ This commit fixes the login issue.`
93
+
94
+ expect(CHANGE_ID_PATTERN.test(commitWithoutChangeId)).toBe(false)
95
+ })
96
+ })
97
+
98
+ describe('error handling', () => {
99
+ test('should detect permission denied error', () => {
100
+ const errorOutput = 'fatal: remote error: Permission denied (prohibited by Gerrit)'
101
+
102
+ expect(errorOutput).toContain('prohibited by Gerrit')
103
+ })
104
+
105
+ test('should detect network error', () => {
106
+ const errorOutput =
107
+ "fatal: unable to access 'https://gerrit.example.com/': Could not resolve host"
108
+
109
+ expect(errorOutput).toContain('Could not resolve host')
110
+ })
111
+
112
+ test('should detect invalid ref error', () => {
113
+ const errorOutput = 'fatal: invalid refspec'
114
+
115
+ expect(errorOutput).toContain('invalid refspec')
116
+ })
117
+
118
+ test('should detect no new changes', () => {
119
+ const output = 'Everything up-to-date\nremote: no new changes'
120
+
121
+ expect(output).toContain('no new changes')
122
+ })
123
+
124
+ test('should detect authentication failure', () => {
125
+ const errorOutput = 'fatal: Authentication failed for'
126
+
127
+ expect(errorOutput).toContain('Authentication failed')
128
+ })
129
+ })
130
+
131
+ describe('git command patterns', () => {
132
+ test('should build correct push command args', () => {
133
+ const remote = 'origin'
134
+ const refspec = 'refs/for/master%topic=test'
135
+
136
+ const args = ['push', remote, `HEAD:${refspec}`]
137
+ expect(args).toEqual(['push', 'origin', 'HEAD:refs/for/master%topic=test'])
138
+ })
139
+
140
+ test('should build correct dry-run push command args', () => {
141
+ const remote = 'origin'
142
+ const refspec = 'refs/for/master'
143
+
144
+ const args = ['push', '--dry-run', remote, `HEAD:${refspec}`]
145
+ expect(args).toEqual(['push', '--dry-run', 'origin', 'HEAD:refs/for/master'])
146
+ })
147
+ })
148
+ })
@@ -0,0 +1,85 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { Schema } from '@effect/schema'
3
+ import { CommentInput, GerritCredentials } from '@/schemas/gerrit'
4
+
5
+ describe('Gerrit Schemas', () => {
6
+ describe('GerritCredentials', () => {
7
+ test('should validate valid credentials', () => {
8
+ const validCredentials = {
9
+ host: 'https://gerrit.example.com',
10
+ username: 'testuser',
11
+ password: 'testpass123',
12
+ }
13
+
14
+ const result = Schema.decodeUnknownSync(GerritCredentials)(validCredentials)
15
+ expect(result).toEqual(validCredentials)
16
+ })
17
+
18
+ test('should reject invalid URL', () => {
19
+ const invalidCredentials = {
20
+ host: 'not-a-url',
21
+ username: 'testuser',
22
+ password: 'testpass123',
23
+ }
24
+
25
+ expect(() => {
26
+ Schema.decodeUnknownSync(GerritCredentials)(invalidCredentials)
27
+ }).toThrow()
28
+ })
29
+
30
+ test('should reject empty username', () => {
31
+ const invalidCredentials = {
32
+ host: 'https://gerrit.example.com',
33
+ username: '',
34
+ password: 'testpass123',
35
+ }
36
+
37
+ expect(() => {
38
+ Schema.decodeUnknownSync(GerritCredentials)(invalidCredentials)
39
+ }).toThrow()
40
+ })
41
+
42
+ test('should reject empty password', () => {
43
+ const invalidCredentials = {
44
+ host: 'https://gerrit.example.com',
45
+ username: 'testuser',
46
+ password: '',
47
+ }
48
+
49
+ expect(() => {
50
+ Schema.decodeUnknownSync(GerritCredentials)(invalidCredentials)
51
+ }).toThrow()
52
+ })
53
+ })
54
+
55
+ describe('CommentInput', () => {
56
+ test('should validate valid comment input', () => {
57
+ const validComment = {
58
+ message: 'This is a test comment',
59
+ unresolved: true,
60
+ }
61
+
62
+ const result = Schema.decodeUnknownSync(CommentInput)(validComment)
63
+ expect(result).toEqual(validComment)
64
+ })
65
+
66
+ test('should validate comment without unresolved flag', () => {
67
+ const validComment = {
68
+ message: 'This is a test comment',
69
+ }
70
+
71
+ const result = Schema.decodeUnknownSync(CommentInput)(validComment)
72
+ expect(result).toEqual(validComment)
73
+ })
74
+
75
+ test('should reject empty message', () => {
76
+ const invalidComment = {
77
+ message: '',
78
+ }
79
+
80
+ expect(() => {
81
+ Schema.decodeUnknownSync(CommentInput)(invalidComment)
82
+ }).toThrow()
83
+ })
84
+ })
85
+ })
@@ -0,0 +1,132 @@
1
+ import { test, expect, describe } from 'bun:test'
2
+ import { CHANGE_ID_PATTERN } from '@/services/commit-hook'
3
+
4
+ // Tests for commit-hook service patterns
5
+ // Note: Tests that require actual git operations are skipped in the full test suite
6
+ // due to mock pollution from other test files. Run these tests in isolation for full coverage:
7
+ // bun test tests/unit/services/commit-hook.test.ts
8
+
9
+ describe('Commit Hook Service', () => {
10
+ describe('CHANGE_ID_PATTERN', () => {
11
+ test('should match valid Change-Id', () => {
12
+ const validIds = [
13
+ 'Change-Id: I1234567890123456789012345678901234567890',
14
+ 'Change-Id: Iabcdefabcdefabcdefabcdefabcdefabcdefabcd',
15
+ 'Change-Id: I0000000000000000000000000000000000000000',
16
+ 'Change-Id: Iffffffffffffffffffffffffffffffffffffffff',
17
+ ]
18
+
19
+ for (const id of validIds) {
20
+ expect(CHANGE_ID_PATTERN.test(id)).toBe(true)
21
+ }
22
+ })
23
+
24
+ test('should not match invalid Change-Id', () => {
25
+ const invalidIds = [
26
+ 'Change-Id: 1234567890123456789012345678901234567890', // Missing I prefix
27
+ 'Change-Id: I123456789012345678901234567890123456789', // Too short (39 chars)
28
+ 'Change-Id: I12345678901234567890123456789012345678901', // Too long (41 chars)
29
+ 'Change-Id: IGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGGG', // Invalid hex chars
30
+ 'Change-Id: i1234567890123456789012345678901234567890', // Lowercase I
31
+ 'change-id: I1234567890123456789012345678901234567890', // Lowercase prefix
32
+ ]
33
+
34
+ for (const id of invalidIds) {
35
+ expect(CHANGE_ID_PATTERN.test(id)).toBe(false)
36
+ }
37
+ })
38
+
39
+ test('should match Change-Id in multiline commit message', () => {
40
+ const commitMessage = `Fix authentication bug
41
+
42
+ This commit fixes the login issue where users
43
+ were being logged out unexpectedly.
44
+
45
+ Change-Id: I1234567890123456789012345678901234567890
46
+ Signed-off-by: Test User <test@example.com>`
47
+
48
+ expect(CHANGE_ID_PATTERN.test(commitMessage)).toBe(true)
49
+ })
50
+
51
+ test('should not match Change-Id in wrong position', () => {
52
+ // Change-Id should be at start of line
53
+ const wrongPosition = ' Change-Id: I1234567890123456789012345678901234567890'
54
+ expect(CHANGE_ID_PATTERN.test(wrongPosition)).toBe(false)
55
+ })
56
+ })
57
+
58
+ describe('hook path patterns', () => {
59
+ test('should construct correct hooks directory path', () => {
60
+ const gitDir = '.git'
61
+ const hooksDir = `${gitDir}/hooks`
62
+ expect(hooksDir).toBe('.git/hooks')
63
+ })
64
+
65
+ test('should construct correct commit-msg hook path', () => {
66
+ const gitDir = '.git'
67
+ const hookPath = `${gitDir}/hooks/commit-msg`
68
+ expect(hookPath).toBe('.git/hooks/commit-msg')
69
+ })
70
+
71
+ test('should handle absolute git dir path', () => {
72
+ const gitDir = '/home/user/project/.git'
73
+ const hookPath = `${gitDir}/hooks/commit-msg`
74
+ expect(hookPath).toBe('/home/user/project/.git/hooks/commit-msg')
75
+ })
76
+ })
77
+
78
+ describe('hook URL construction', () => {
79
+ test('should construct correct hook URL', () => {
80
+ const host = 'https://gerrit.example.com'
81
+ const hookUrl = `${host}/tools/hooks/commit-msg`
82
+ expect(hookUrl).toBe('https://gerrit.example.com/tools/hooks/commit-msg')
83
+ })
84
+
85
+ test('should handle host with trailing slash', () => {
86
+ const host = 'https://gerrit.example.com/'
87
+ const normalizedHost = host.replace(/\/$/, '')
88
+ const hookUrl = `${normalizedHost}/tools/hooks/commit-msg`
89
+ expect(hookUrl).toBe('https://gerrit.example.com/tools/hooks/commit-msg')
90
+ })
91
+ })
92
+
93
+ describe('hook content validation', () => {
94
+ test('should validate shell script header', () => {
95
+ const validHook = '#!/bin/sh\necho "Adding Change-Id"'
96
+ expect(validHook.startsWith('#!')).toBe(true)
97
+ })
98
+
99
+ test('should reject non-script content', () => {
100
+ const invalidHook = 'This is not a script'
101
+ expect(invalidHook.startsWith('#!')).toBe(false)
102
+ })
103
+
104
+ test('should validate bash script header', () => {
105
+ const bashHook = '#!/bin/bash\necho "Adding Change-Id"'
106
+ expect(bashHook.startsWith('#!')).toBe(true)
107
+ })
108
+ })
109
+
110
+ describe('executable bit checking', () => {
111
+ test('should identify executable mode', () => {
112
+ const executableMode = 0o755
113
+ const ownerExecuteBit = 0o100
114
+
115
+ expect((executableMode & ownerExecuteBit) !== 0).toBe(true)
116
+ })
117
+
118
+ test('should identify non-executable mode', () => {
119
+ const nonExecutableMode = 0o644
120
+ const ownerExecuteBit = 0o100
121
+
122
+ expect((nonExecutableMode & ownerExecuteBit) !== 0).toBe(false)
123
+ })
124
+
125
+ test('should handle read-only mode', () => {
126
+ const readOnlyMode = 0o444
127
+ const ownerExecuteBit = 0o100
128
+
129
+ expect((readOnlyMode & ownerExecuteBit) !== 0).toBe(false)
130
+ })
131
+ })
132
+ })