@aaronshaf/ger 0.3.1 → 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.
@@ -0,0 +1,194 @@
1
+ import { test, expect, describe } from 'bun:test'
2
+ import { buildPushRefspec, validateEmails, PushError } from '@/cli/commands/push'
3
+
4
+ describe('Push Command', () => {
5
+ describe('buildPushRefspec', () => {
6
+ test('should build basic refspec without options', () => {
7
+ const refspec = buildPushRefspec('master', {})
8
+ expect(refspec).toBe('refs/for/master')
9
+ })
10
+
11
+ test('should build refspec with topic', () => {
12
+ const refspec = buildPushRefspec('master', { topic: 'my-feature' })
13
+ expect(refspec).toBe('refs/for/master%topic=my-feature')
14
+ })
15
+
16
+ test('should URL-encode topic with special characters', () => {
17
+ const refspec = buildPushRefspec('master', { topic: 'feature/auth-fix' })
18
+ expect(refspec).toBe('refs/for/master%topic=feature%2Fauth-fix')
19
+ })
20
+
21
+ test('should build refspec with wip flag', () => {
22
+ const refspec = buildPushRefspec('master', { wip: true })
23
+ expect(refspec).toBe('refs/for/master%wip')
24
+ })
25
+
26
+ test('should build refspec with draft flag (alias for wip)', () => {
27
+ const refspec = buildPushRefspec('master', { draft: true })
28
+ expect(refspec).toBe('refs/for/master%wip')
29
+ })
30
+
31
+ test('should build refspec with ready flag', () => {
32
+ const refspec = buildPushRefspec('master', { ready: true })
33
+ expect(refspec).toBe('refs/for/master%ready')
34
+ })
35
+
36
+ test('should build refspec with private flag', () => {
37
+ const refspec = buildPushRefspec('master', { private: true })
38
+ expect(refspec).toBe('refs/for/master%private')
39
+ })
40
+
41
+ test('should build refspec with single reviewer', () => {
42
+ const refspec = buildPushRefspec('master', { reviewer: ['alice@example.com'] })
43
+ expect(refspec).toBe('refs/for/master%r=alice@example.com')
44
+ })
45
+
46
+ test('should build refspec with multiple reviewers', () => {
47
+ const refspec = buildPushRefspec('master', {
48
+ reviewer: ['alice@example.com', 'bob@example.com'],
49
+ })
50
+ expect(refspec).toBe('refs/for/master%r=alice@example.com,r=bob@example.com')
51
+ })
52
+
53
+ test('should build refspec with single cc', () => {
54
+ const refspec = buildPushRefspec('master', { cc: ['manager@example.com'] })
55
+ expect(refspec).toBe('refs/for/master%cc=manager@example.com')
56
+ })
57
+
58
+ test('should build refspec with multiple ccs', () => {
59
+ const refspec = buildPushRefspec('master', {
60
+ cc: ['manager@example.com', 'lead@example.com'],
61
+ })
62
+ expect(refspec).toBe('refs/for/master%cc=manager@example.com,cc=lead@example.com')
63
+ })
64
+
65
+ test('should build refspec with single hashtag', () => {
66
+ const refspec = buildPushRefspec('master', { hashtag: ['bugfix'] })
67
+ expect(refspec).toBe('refs/for/master%hashtag=bugfix')
68
+ })
69
+
70
+ test('should build refspec with multiple hashtags', () => {
71
+ const refspec = buildPushRefspec('master', { hashtag: ['bugfix', 'urgent'] })
72
+ expect(refspec).toBe('refs/for/master%hashtag=bugfix,hashtag=urgent')
73
+ })
74
+
75
+ test('should URL-encode hashtags with special characters', () => {
76
+ const refspec = buildPushRefspec('master', { hashtag: ['release/v1.0'] })
77
+ expect(refspec).toBe('refs/for/master%hashtag=release%2Fv1.0')
78
+ })
79
+
80
+ test('should build refspec with multiple options combined', () => {
81
+ const refspec = buildPushRefspec('main', {
82
+ topic: 'auth-refactor',
83
+ reviewer: ['alice@example.com'],
84
+ cc: ['manager@example.com'],
85
+ wip: true,
86
+ hashtag: ['security'],
87
+ })
88
+ expect(refspec).toBe(
89
+ 'refs/for/main%topic=auth-refactor,wip,r=alice@example.com,cc=manager@example.com,hashtag=security',
90
+ )
91
+ })
92
+
93
+ test('should handle different branch names', () => {
94
+ expect(buildPushRefspec('main', {})).toBe('refs/for/main')
95
+ expect(buildPushRefspec('develop', {})).toBe('refs/for/develop')
96
+ expect(buildPushRefspec('feature/my-branch', {})).toBe('refs/for/feature/my-branch')
97
+ expect(buildPushRefspec('release/v1.0', {})).toBe('refs/for/release/v1.0')
98
+ })
99
+
100
+ test('should preserve order of parameters', () => {
101
+ // The order should be: topic, wip, ready, private, reviewers, ccs, hashtags
102
+ const refspec = buildPushRefspec('master', {
103
+ hashtag: ['tag1'],
104
+ reviewer: ['r@example.com'],
105
+ topic: 'topic1',
106
+ wip: true,
107
+ cc: ['cc@example.com'],
108
+ private: true,
109
+ })
110
+ // Order in the code: topic, wip, ready, private, reviewer, cc, hashtag
111
+ expect(refspec).toBe(
112
+ 'refs/for/master%topic=topic1,wip,private,r=r@example.com,cc=cc@example.com,hashtag=tag1',
113
+ )
114
+ })
115
+
116
+ test('should handle empty arrays gracefully', () => {
117
+ const refspec = buildPushRefspec('master', {
118
+ reviewer: [],
119
+ cc: [],
120
+ hashtag: [],
121
+ })
122
+ expect(refspec).toBe('refs/for/master')
123
+ })
124
+
125
+ test('should not add wip twice when both wip and draft are true', () => {
126
+ const refspec = buildPushRefspec('master', { wip: true, draft: true })
127
+ // Both wip and draft set the wip flag, but we check wip first, so only one 'wip' should appear
128
+ expect(refspec).toBe('refs/for/master%wip')
129
+ })
130
+ })
131
+
132
+ describe('validateEmails', () => {
133
+ test('should accept valid email addresses', () => {
134
+ expect(() => validateEmails(['user@example.com'], 'reviewer')).not.toThrow()
135
+ expect(() => validateEmails(['alice@company.org'], 'cc')).not.toThrow()
136
+ expect(() => validateEmails(['test.user@sub.domain.com'], 'reviewer')).not.toThrow()
137
+ })
138
+
139
+ test('should accept multiple valid emails', () => {
140
+ expect(() =>
141
+ validateEmails(['user1@example.com', 'user2@example.com'], 'reviewer'),
142
+ ).not.toThrow()
143
+ })
144
+
145
+ test('should accept undefined', () => {
146
+ expect(() => validateEmails(undefined, 'reviewer')).not.toThrow()
147
+ })
148
+
149
+ test('should accept empty array', () => {
150
+ expect(() => validateEmails([], 'reviewer')).not.toThrow()
151
+ })
152
+
153
+ test('should reject email without @', () => {
154
+ expect(() => validateEmails(['userexample.com'], 'reviewer')).toThrow(PushError)
155
+ })
156
+
157
+ test('should reject email without domain', () => {
158
+ expect(() => validateEmails(['user@'], 'reviewer')).toThrow(PushError)
159
+ })
160
+
161
+ test('should reject email without user', () => {
162
+ expect(() => validateEmails(['@example.com'], 'reviewer')).toThrow(PushError)
163
+ })
164
+
165
+ test('should reject email with spaces', () => {
166
+ expect(() => validateEmails(['user @example.com'], 'reviewer')).toThrow(PushError)
167
+ })
168
+
169
+ test('should reject plain username', () => {
170
+ expect(() => validateEmails(['username'], 'reviewer')).toThrow(PushError)
171
+ })
172
+
173
+ test('should include field name in error message', () => {
174
+ try {
175
+ validateEmails(['invalid'], 'reviewer')
176
+ expect(true).toBe(false) // Should not reach here
177
+ } catch (e) {
178
+ expect(e).toBeInstanceOf(PushError)
179
+ expect((e as PushError).message).toContain('reviewer')
180
+ expect((e as PushError).message).toContain('invalid')
181
+ }
182
+ })
183
+
184
+ test('should fail on first invalid email in array', () => {
185
+ try {
186
+ validateEmails(['valid@example.com', 'invalid', 'another@example.com'], 'cc')
187
+ expect(true).toBe(false)
188
+ } catch (e) {
189
+ expect(e).toBeInstanceOf(PushError)
190
+ expect((e as PushError).message).toContain('invalid')
191
+ }
192
+ })
193
+ })
194
+ })
@@ -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,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
+ })