@aaronshaf/ger 3.0.2 → 4.0.1
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/package.json +1 -1
- package/skills/gerrit-workflow/SKILL.md +228 -141
- package/skills/gerrit-workflow/examples.md +133 -426
- package/skills/gerrit-workflow/reference.md +470 -408
- package/src/api/gerrit-types.ts +121 -0
- package/src/api/gerrit.ts +55 -96
- package/src/cli/commands/analyze.ts +284 -0
- package/src/cli/commands/cherry.ts +268 -0
- package/src/cli/commands/failures.ts +74 -0
- package/src/cli/commands/list.ts +239 -0
- package/src/cli/commands/rebase.ts +5 -1
- package/src/cli/commands/retrigger.ts +91 -0
- package/src/cli/commands/setup.ts +19 -6
- package/src/cli/commands/tree-cleanup.ts +139 -0
- package/src/cli/commands/tree-rebase.ts +168 -0
- package/src/cli/commands/tree-setup.ts +202 -0
- package/src/cli/commands/trees.ts +107 -0
- package/src/cli/commands/update.ts +73 -0
- package/src/cli/index.ts +1 -1
- package/src/cli/register-analytics-commands.ts +90 -0
- package/src/cli/register-commands.ts +56 -40
- package/src/cli/register-list-commands.ts +105 -0
- package/src/cli/register-tree-commands.ts +128 -0
- package/src/schemas/config.ts +3 -0
- package/src/services/config.ts +15 -0
- package/tests/analyze.test.ts +197 -0
- package/tests/cherry.test.ts +208 -0
- package/tests/failures.test.ts +212 -0
- package/tests/helpers/config-mock.ts +4 -0
- package/tests/list.test.ts +220 -0
- package/tests/retrigger.test.ts +159 -0
- package/tests/tree.test.ts +517 -0
- package/tests/update.test.ts +86 -0
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
import { describe, test, expect, mock, beforeEach, beforeAll, afterAll, afterEach } from 'bun:test'
|
|
2
|
+
import { Effect, Layer } from 'effect'
|
|
3
|
+
import { HttpResponse, http } from 'msw'
|
|
4
|
+
import { setupServer } from 'msw/node'
|
|
5
|
+
import { GerritApiServiceLive } from '@/api/gerrit'
|
|
6
|
+
import { ConfigService } from '@/services/config'
|
|
7
|
+
import { createMockConfigService } from './helpers/config-mock'
|
|
8
|
+
|
|
9
|
+
// --- fs / child_process mocks ---
|
|
10
|
+
|
|
11
|
+
const mockExecSyncImpl = mock((..._args: unknown[]): string => '')
|
|
12
|
+
const mockSpawnSyncImpl = mock((..._args: unknown[]): { status: number; stderr: string } => ({
|
|
13
|
+
status: 0,
|
|
14
|
+
stderr: '',
|
|
15
|
+
}))
|
|
16
|
+
const mockExistsSync = mock((..._args: unknown[]): boolean => false)
|
|
17
|
+
const mockMkdirSync = mock((..._args: unknown[]) => undefined)
|
|
18
|
+
const mockReaddirSync = mock((..._args: unknown[]): string[] => [])
|
|
19
|
+
const mockStatSync = mock((..._args: unknown[]) => ({ isDirectory: () => true }))
|
|
20
|
+
|
|
21
|
+
mock.module('node:child_process', () => ({
|
|
22
|
+
execSync: mockExecSyncImpl,
|
|
23
|
+
spawnSync: mockSpawnSyncImpl,
|
|
24
|
+
}))
|
|
25
|
+
mock.module('node:fs', () => ({
|
|
26
|
+
existsSync: mockExistsSync,
|
|
27
|
+
mkdirSync: mockMkdirSync,
|
|
28
|
+
readdirSync: mockReaddirSync,
|
|
29
|
+
statSync: mockStatSync,
|
|
30
|
+
}))
|
|
31
|
+
|
|
32
|
+
const inGitRepo = () => {
|
|
33
|
+
mockExecSyncImpl.mockImplementation((...args: unknown[]): string => {
|
|
34
|
+
const cmd = args[0] as string
|
|
35
|
+
if (cmd === 'git rev-parse --git-dir') return '.git'
|
|
36
|
+
if (cmd === 'git rev-parse --show-toplevel') return '/repo/root'
|
|
37
|
+
if (cmd === 'git remote -v') return 'origin\thttps://test.gerrit.com/project\t(fetch)\n'
|
|
38
|
+
return ''
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// --- MSW server for Gerrit API calls ---
|
|
43
|
+
|
|
44
|
+
const server = setupServer(
|
|
45
|
+
http.get('*/a/changes/:changeId', () =>
|
|
46
|
+
HttpResponse.json({
|
|
47
|
+
id: 'test~master~I123',
|
|
48
|
+
_number: 12345,
|
|
49
|
+
change_id: 'I123',
|
|
50
|
+
project: 'test',
|
|
51
|
+
branch: 'master',
|
|
52
|
+
subject: 'Test change',
|
|
53
|
+
status: 'NEW',
|
|
54
|
+
created: '2024-01-01 10:00:00.000000000',
|
|
55
|
+
updated: '2024-01-01 12:00:00.000000000',
|
|
56
|
+
owner: { _account_id: 1, name: 'User', email: 'u@example.com' },
|
|
57
|
+
labels: {},
|
|
58
|
+
work_in_progress: false,
|
|
59
|
+
submittable: false,
|
|
60
|
+
}),
|
|
61
|
+
),
|
|
62
|
+
http.get('*/a/changes/:changeId/revisions/:rev/review', () =>
|
|
63
|
+
HttpResponse.json({ ref: 'refs/changes/45/12345/1', commit: { message: '' } }),
|
|
64
|
+
),
|
|
65
|
+
http.get('*/a/accounts/self', () =>
|
|
66
|
+
HttpResponse.json({ _account_id: 1, name: 'User', email: 'u@example.com' }),
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }))
|
|
71
|
+
afterAll(() => server.close())
|
|
72
|
+
|
|
73
|
+
// ============================================================
|
|
74
|
+
// tree-setup
|
|
75
|
+
// ============================================================
|
|
76
|
+
|
|
77
|
+
describe('tree-setup', () => {
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
mockExecSyncImpl.mockReset()
|
|
80
|
+
mockSpawnSyncImpl.mockReset()
|
|
81
|
+
mockExistsSync.mockReturnValue(false)
|
|
82
|
+
mockMkdirSync.mockReset()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
afterEach(() => server.resetHandlers())
|
|
86
|
+
|
|
87
|
+
test('exports treeSetupCommand', async () => {
|
|
88
|
+
const { treeSetupCommand } = await import('@/cli/commands/tree-setup')
|
|
89
|
+
expect(typeof treeSetupCommand).toBe('function')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('throws when not in a git repo', async () => {
|
|
93
|
+
const { treeSetupCommand } = await import('@/cli/commands/tree-setup')
|
|
94
|
+
mockExecSyncImpl.mockImplementation(() => {
|
|
95
|
+
throw new Error('not a git repo')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
const mockConfig = createMockConfigService()
|
|
99
|
+
let threw = false
|
|
100
|
+
try {
|
|
101
|
+
await Effect.runPromise(
|
|
102
|
+
treeSetupCommand('12345', {}).pipe(
|
|
103
|
+
Effect.provide(GerritApiServiceLive),
|
|
104
|
+
Effect.provide(Layer.succeed(ConfigService, mockConfig)),
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
} catch {
|
|
108
|
+
threw = true
|
|
109
|
+
}
|
|
110
|
+
expect(threw).toBe(true)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('outputs JSON on success', async () => {
|
|
114
|
+
const { treeSetupCommand } = await import('@/cli/commands/tree-setup')
|
|
115
|
+
inGitRepo()
|
|
116
|
+
mockSpawnSyncImpl.mockReturnValue({ status: 0, stderr: '' })
|
|
117
|
+
|
|
118
|
+
server.use(
|
|
119
|
+
http.get('*/a/changes/:changeId/revisions/current/review', () =>
|
|
120
|
+
HttpResponse.json({
|
|
121
|
+
ref: 'refs/changes/45/12345/1',
|
|
122
|
+
commit: { message: 'Test' },
|
|
123
|
+
}),
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
const logs: string[] = []
|
|
128
|
+
const originalLog = console.log
|
|
129
|
+
console.log = (msg: string) => logs.push(msg)
|
|
130
|
+
|
|
131
|
+
const mockConfig = createMockConfigService()
|
|
132
|
+
try {
|
|
133
|
+
await Effect.runPromise(
|
|
134
|
+
treeSetupCommand('12345', { json: true }).pipe(
|
|
135
|
+
Effect.provide(GerritApiServiceLive),
|
|
136
|
+
Effect.provide(Layer.succeed(ConfigService, mockConfig)),
|
|
137
|
+
),
|
|
138
|
+
)
|
|
139
|
+
} catch {
|
|
140
|
+
// may fail due to MSW/API; just check function is callable
|
|
141
|
+
} finally {
|
|
142
|
+
console.log = originalLog
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
// ============================================================
|
|
148
|
+
// trees
|
|
149
|
+
// ============================================================
|
|
150
|
+
|
|
151
|
+
describe('trees', () => {
|
|
152
|
+
beforeEach(() => {
|
|
153
|
+
mockExecSyncImpl.mockReset()
|
|
154
|
+
mockSpawnSyncImpl.mockReset()
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
test('throws when not in a git repo', async () => {
|
|
158
|
+
const { treesCommand } = await import('@/cli/commands/trees')
|
|
159
|
+
mockExecSyncImpl.mockImplementation(() => {
|
|
160
|
+
throw new Error('not a git repo')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
let threw = false
|
|
164
|
+
try {
|
|
165
|
+
await Effect.runPromise(treesCommand({}))
|
|
166
|
+
} catch {
|
|
167
|
+
threw = true
|
|
168
|
+
}
|
|
169
|
+
expect(threw).toBe(true)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
test('parses porcelain worktree output', () => {
|
|
173
|
+
const sample = [
|
|
174
|
+
'worktree /repo/root',
|
|
175
|
+
'HEAD abc1234',
|
|
176
|
+
'branch refs/heads/main',
|
|
177
|
+
'',
|
|
178
|
+
'worktree /repo/root/.ger/12345',
|
|
179
|
+
'HEAD def5678',
|
|
180
|
+
'detached',
|
|
181
|
+
].join('\n')
|
|
182
|
+
|
|
183
|
+
const blocks = sample.trim().split('\n\n')
|
|
184
|
+
expect(blocks).toHaveLength(2)
|
|
185
|
+
|
|
186
|
+
const second = blocks[1].split('\n')
|
|
187
|
+
expect(second.some((l) => l === 'detached')).toBe(true)
|
|
188
|
+
expect(second.find((l) => l.startsWith('worktree '))?.includes('.ger')).toBe(true)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
test('succeeds with empty ger-managed list', async () => {
|
|
192
|
+
const { treesCommand } = await import('@/cli/commands/trees')
|
|
193
|
+
mockExecSyncImpl.mockImplementation((...args: unknown[]): string => {
|
|
194
|
+
const cmd = args[0] as string
|
|
195
|
+
if (cmd === 'git rev-parse --git-dir') return '.git'
|
|
196
|
+
if (cmd === 'git worktree list --porcelain') {
|
|
197
|
+
return 'worktree /repo/root\nHEAD abc1234\nbranch refs/heads/main\n'
|
|
198
|
+
}
|
|
199
|
+
return ''
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
// No ger-managed worktrees — should succeed without throwing
|
|
203
|
+
await Effect.runPromise(treesCommand({}))
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
test('outputs JSON with ger-managed worktree', async () => {
|
|
207
|
+
const { treesCommand } = await import('@/cli/commands/trees')
|
|
208
|
+
mockExecSyncImpl.mockImplementation((...args: unknown[]): string => {
|
|
209
|
+
const cmd = args[0] as string
|
|
210
|
+
if (cmd === 'git rev-parse --git-dir') return '.git'
|
|
211
|
+
if (cmd === 'git worktree list --porcelain') {
|
|
212
|
+
return 'worktree /repo/root/.ger/12345\nHEAD abc1234\ndetached\n'
|
|
213
|
+
}
|
|
214
|
+
return ''
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
const logs: string[] = []
|
|
218
|
+
const originalLog = console.log
|
|
219
|
+
console.log = (msg: string) => logs.push(msg)
|
|
220
|
+
await Effect.runPromise(treesCommand({ json: true }))
|
|
221
|
+
console.log = originalLog
|
|
222
|
+
|
|
223
|
+
const parsed = JSON.parse(logs[0]) as { status: string; worktrees: unknown[] }
|
|
224
|
+
expect(parsed.status).toBe('success')
|
|
225
|
+
expect(parsed.worktrees).toHaveLength(1)
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
// ============================================================
|
|
230
|
+
// tree-cleanup
|
|
231
|
+
// ============================================================
|
|
232
|
+
|
|
233
|
+
describe('tree-cleanup', () => {
|
|
234
|
+
beforeEach(() => {
|
|
235
|
+
mockExecSyncImpl.mockReset()
|
|
236
|
+
mockSpawnSyncImpl.mockReset()
|
|
237
|
+
mockExistsSync.mockReturnValue(false)
|
|
238
|
+
mockReaddirSync.mockReturnValue([])
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
test('throws when not in a git repo', async () => {
|
|
242
|
+
const { treeCleanupCommand } = await import('@/cli/commands/tree-cleanup')
|
|
243
|
+
mockExecSyncImpl.mockImplementation(() => {
|
|
244
|
+
throw new Error('not a git repo')
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
let threw = false
|
|
248
|
+
try {
|
|
249
|
+
await Effect.runPromise(treeCleanupCommand(undefined, {}))
|
|
250
|
+
} catch {
|
|
251
|
+
threw = true
|
|
252
|
+
}
|
|
253
|
+
expect(threw).toBe(true)
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
test('throws when specific changeId worktree does not exist', async () => {
|
|
257
|
+
const { treeCleanupCommand } = await import('@/cli/commands/tree-cleanup')
|
|
258
|
+
inGitRepo()
|
|
259
|
+
mockExistsSync.mockReturnValue(false)
|
|
260
|
+
|
|
261
|
+
let threw = false
|
|
262
|
+
try {
|
|
263
|
+
await Effect.runPromise(treeCleanupCommand('12345', {}))
|
|
264
|
+
} catch (e) {
|
|
265
|
+
threw = true
|
|
266
|
+
expect(String(e)).toContain('No worktree found')
|
|
267
|
+
}
|
|
268
|
+
expect(threw).toBe(true)
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
test('succeeds with no ger worktrees to clean', async () => {
|
|
272
|
+
const { treeCleanupCommand } = await import('@/cli/commands/tree-cleanup')
|
|
273
|
+
inGitRepo()
|
|
274
|
+
mockExistsSync.mockReturnValue(false)
|
|
275
|
+
|
|
276
|
+
await Effect.runPromise(treeCleanupCommand(undefined, {}))
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
test('removes worktree successfully', async () => {
|
|
280
|
+
const { treeCleanupCommand } = await import('@/cli/commands/tree-cleanup')
|
|
281
|
+
inGitRepo()
|
|
282
|
+
|
|
283
|
+
mockExistsSync.mockImplementation((...args: unknown[]): boolean => {
|
|
284
|
+
const p = args[0] as string
|
|
285
|
+
return p.includes('.ger')
|
|
286
|
+
})
|
|
287
|
+
mockReaddirSync.mockReturnValue(['12345'])
|
|
288
|
+
mockStatSync.mockReturnValue({ isDirectory: () => true })
|
|
289
|
+
mockSpawnSyncImpl.mockReturnValue({ status: 0, stderr: '' })
|
|
290
|
+
|
|
291
|
+
await Effect.runPromise(treeCleanupCommand(undefined, { json: true }))
|
|
292
|
+
expect(mockSpawnSyncImpl).toHaveBeenCalledWith(
|
|
293
|
+
'git',
|
|
294
|
+
expect.arrayContaining(['worktree', 'remove']),
|
|
295
|
+
expect.anything(),
|
|
296
|
+
)
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
test('rejects path traversal in change ID', async () => {
|
|
300
|
+
const { treeCleanupCommand } = await import('@/cli/commands/tree-cleanup')
|
|
301
|
+
inGitRepo()
|
|
302
|
+
|
|
303
|
+
let threw = false
|
|
304
|
+
try {
|
|
305
|
+
await Effect.runPromise(treeCleanupCommand('../evil', {}))
|
|
306
|
+
} catch (e) {
|
|
307
|
+
threw = true
|
|
308
|
+
expect(String(e)).toContain('Invalid change ID')
|
|
309
|
+
}
|
|
310
|
+
expect(threw).toBe(true)
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
test('rejects mixed alphanumeric change ID', async () => {
|
|
314
|
+
const { treeCleanupCommand } = await import('@/cli/commands/tree-cleanup')
|
|
315
|
+
inGitRepo()
|
|
316
|
+
|
|
317
|
+
let threw = false
|
|
318
|
+
try {
|
|
319
|
+
await Effect.runPromise(treeCleanupCommand('123abc', {}))
|
|
320
|
+
} catch (e) {
|
|
321
|
+
threw = true
|
|
322
|
+
expect(String(e)).toContain('Invalid change ID')
|
|
323
|
+
}
|
|
324
|
+
expect(threw).toBe(true)
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
test('does NOT force-remove when --force is omitted', async () => {
|
|
328
|
+
const { treeCleanupCommand } = await import('@/cli/commands/tree-cleanup')
|
|
329
|
+
inGitRepo()
|
|
330
|
+
|
|
331
|
+
mockExistsSync.mockImplementation((...args: unknown[]): boolean => {
|
|
332
|
+
const p = args[0] as string
|
|
333
|
+
return p.includes('.ger')
|
|
334
|
+
})
|
|
335
|
+
mockReaddirSync.mockReturnValue(['12345'])
|
|
336
|
+
mockStatSync.mockReturnValue({ isDirectory: () => true })
|
|
337
|
+
// Simulate dirty worktree: git worktree remove fails without --force
|
|
338
|
+
mockSpawnSyncImpl.mockReturnValue({ status: 1, stderr: 'has modifications' })
|
|
339
|
+
|
|
340
|
+
await Effect.runPromise(treeCleanupCommand(undefined, {}))
|
|
341
|
+
|
|
342
|
+
// Should have called worktree remove WITHOUT --force
|
|
343
|
+
const calls = mockSpawnSyncImpl.mock.calls as unknown[][]
|
|
344
|
+
const removeCalls = calls.filter(
|
|
345
|
+
(c) => Array.isArray(c[1]) && (c[1] as string[]).includes('remove'),
|
|
346
|
+
)
|
|
347
|
+
expect(removeCalls.length).toBeGreaterThan(0)
|
|
348
|
+
for (const call of removeCalls) {
|
|
349
|
+
expect((call[1] as string[]).includes('--force')).toBe(false)
|
|
350
|
+
}
|
|
351
|
+
})
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
// ============================================================
|
|
355
|
+
// tree-rebase
|
|
356
|
+
// ============================================================
|
|
357
|
+
|
|
358
|
+
describe('tree-rebase', () => {
|
|
359
|
+
const mockConfig = createMockConfigService()
|
|
360
|
+
const originalCwd = process.cwd()
|
|
361
|
+
|
|
362
|
+
beforeEach(() => {
|
|
363
|
+
mockExecSyncImpl.mockReset()
|
|
364
|
+
mockSpawnSyncImpl.mockReset()
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
afterEach(() => {
|
|
368
|
+
// Restore cwd in case a test changed it
|
|
369
|
+
try {
|
|
370
|
+
process.chdir(originalCwd)
|
|
371
|
+
} catch {
|
|
372
|
+
// ignore
|
|
373
|
+
}
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
const inGerWorktree = () => {
|
|
377
|
+
mockExecSyncImpl.mockImplementation((...args: unknown[]): string => {
|
|
378
|
+
const cmd = args[0] as string
|
|
379
|
+
if (cmd === 'git rev-parse --git-dir') return '.git'
|
|
380
|
+
// Return a path that looks like a ger worktree
|
|
381
|
+
if (cmd === 'git rev-parse --show-toplevel') return '/repo/root'
|
|
382
|
+
if (cmd === 'git remote -v') return 'origin\thttps://test.gerrit.com/project\t(fetch)\n'
|
|
383
|
+
return ''
|
|
384
|
+
})
|
|
385
|
+
// Simulate cwd being inside a ger worktree
|
|
386
|
+
jest_spyOn_cwd('/repo/root/.ger/12345')
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Helper to mock process.cwd()
|
|
390
|
+
const jest_spyOn_cwd = (fakeCwd: string) => {
|
|
391
|
+
const original = process.cwd.bind(process)
|
|
392
|
+
process.cwd = () => fakeCwd
|
|
393
|
+
return () => {
|
|
394
|
+
process.cwd = original
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
test('throws when not in a git repo', async () => {
|
|
399
|
+
const { treeRebaseCommand } = await import('@/cli/commands/tree-rebase')
|
|
400
|
+
mockExecSyncImpl.mockImplementation(() => {
|
|
401
|
+
throw new Error('not a git repo')
|
|
402
|
+
})
|
|
403
|
+
|
|
404
|
+
let threw = false
|
|
405
|
+
try {
|
|
406
|
+
await Effect.runPromise(
|
|
407
|
+
treeRebaseCommand({}).pipe(Effect.provide(Layer.succeed(ConfigService, mockConfig))),
|
|
408
|
+
)
|
|
409
|
+
} catch {
|
|
410
|
+
threw = true
|
|
411
|
+
}
|
|
412
|
+
expect(threw).toBe(true)
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
test('throws when not inside a ger worktree', async () => {
|
|
416
|
+
const { treeRebaseCommand } = await import('@/cli/commands/tree-rebase')
|
|
417
|
+
// Git repo, but cwd is the repo root (not a .ger worktree)
|
|
418
|
+
mockExecSyncImpl.mockImplementation((...args: unknown[]): string => {
|
|
419
|
+
const cmd = args[0] as string
|
|
420
|
+
if (cmd === 'git rev-parse --git-dir') return '.git'
|
|
421
|
+
if (cmd === 'git rev-parse --show-toplevel') return '/repo/root'
|
|
422
|
+
return ''
|
|
423
|
+
})
|
|
424
|
+
process.cwd = () => '/repo/root'
|
|
425
|
+
|
|
426
|
+
let threw = false
|
|
427
|
+
try {
|
|
428
|
+
await Effect.runPromise(
|
|
429
|
+
treeRebaseCommand({ onto: 'origin/main' }).pipe(
|
|
430
|
+
Effect.provide(Layer.succeed(ConfigService, mockConfig)),
|
|
431
|
+
),
|
|
432
|
+
)
|
|
433
|
+
} catch (e) {
|
|
434
|
+
threw = true
|
|
435
|
+
expect(String(e)).toContain('ger-managed worktree')
|
|
436
|
+
}
|
|
437
|
+
expect(threw).toBe(true)
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
test('throws when fetch fails', async () => {
|
|
441
|
+
const { treeRebaseCommand } = await import('@/cli/commands/tree-rebase')
|
|
442
|
+
inGerWorktree()
|
|
443
|
+
mockSpawnSyncImpl.mockReturnValue({ status: 1, stderr: 'network error' })
|
|
444
|
+
|
|
445
|
+
let threw = false
|
|
446
|
+
try {
|
|
447
|
+
await Effect.runPromise(
|
|
448
|
+
treeRebaseCommand({ onto: 'origin/main' }).pipe(
|
|
449
|
+
Effect.provide(Layer.succeed(ConfigService, mockConfig)),
|
|
450
|
+
),
|
|
451
|
+
)
|
|
452
|
+
} catch (e) {
|
|
453
|
+
threw = true
|
|
454
|
+
expect(String(e)).toContain('Failed to fetch')
|
|
455
|
+
}
|
|
456
|
+
expect(threw).toBe(true)
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
test('throws when rebase fails', async () => {
|
|
460
|
+
const { treeRebaseCommand } = await import('@/cli/commands/tree-rebase')
|
|
461
|
+
inGerWorktree()
|
|
462
|
+
mockSpawnSyncImpl
|
|
463
|
+
.mockReturnValueOnce({ status: 0, stderr: '' }) // fetch
|
|
464
|
+
.mockReturnValueOnce({ status: 1, stderr: 'conflict' }) // rebase
|
|
465
|
+
|
|
466
|
+
let threw = false
|
|
467
|
+
try {
|
|
468
|
+
await Effect.runPromise(
|
|
469
|
+
treeRebaseCommand({ onto: 'origin/main' }).pipe(
|
|
470
|
+
Effect.provide(Layer.succeed(ConfigService, mockConfig)),
|
|
471
|
+
),
|
|
472
|
+
)
|
|
473
|
+
} catch (e) {
|
|
474
|
+
threw = true
|
|
475
|
+
expect(String(e)).toContain('Rebase failed')
|
|
476
|
+
}
|
|
477
|
+
expect(threw).toBe(true)
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
test('succeeds and outputs JSON', async () => {
|
|
481
|
+
const { treeRebaseCommand } = await import('@/cli/commands/tree-rebase')
|
|
482
|
+
inGerWorktree()
|
|
483
|
+
mockSpawnSyncImpl.mockReturnValue({ status: 0, stderr: '' })
|
|
484
|
+
|
|
485
|
+
const logs: string[] = []
|
|
486
|
+
const originalLog = console.log
|
|
487
|
+
console.log = (msg: string) => logs.push(msg)
|
|
488
|
+
await Effect.runPromise(
|
|
489
|
+
treeRebaseCommand({ onto: 'origin/main', json: true }).pipe(
|
|
490
|
+
Effect.provide(Layer.succeed(ConfigService, mockConfig)),
|
|
491
|
+
),
|
|
492
|
+
)
|
|
493
|
+
console.log = originalLog
|
|
494
|
+
|
|
495
|
+
const parsed = JSON.parse(logs[0]) as { status: string; base: string }
|
|
496
|
+
expect(parsed.status).toBe('success')
|
|
497
|
+
expect(parsed.base).toBe('origin/main')
|
|
498
|
+
})
|
|
499
|
+
|
|
500
|
+
test('uses --onto option over auto-detect', async () => {
|
|
501
|
+
const { treeRebaseCommand } = await import('@/cli/commands/tree-rebase')
|
|
502
|
+
inGerWorktree()
|
|
503
|
+
mockSpawnSyncImpl.mockReturnValue({ status: 0, stderr: '' })
|
|
504
|
+
|
|
505
|
+
await Effect.runPromise(
|
|
506
|
+
treeRebaseCommand({ onto: 'origin/feature', json: true }).pipe(
|
|
507
|
+
Effect.provide(Layer.succeed(ConfigService, mockConfig)),
|
|
508
|
+
),
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
expect(mockSpawnSyncImpl).toHaveBeenCalledWith(
|
|
512
|
+
'git',
|
|
513
|
+
['rebase', 'origin/feature'],
|
|
514
|
+
expect.anything(),
|
|
515
|
+
)
|
|
516
|
+
})
|
|
517
|
+
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, test, expect, mock, spyOn, afterEach } from 'bun:test'
|
|
2
|
+
import { Effect } from 'effect'
|
|
3
|
+
import * as childProcess from 'node:child_process'
|
|
4
|
+
import { updateCommand } from '@/cli/commands/update'
|
|
5
|
+
|
|
6
|
+
describe('update command', () => {
|
|
7
|
+
let execSpy: ReturnType<typeof spyOn>
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
execSpy?.mockRestore()
|
|
11
|
+
global.fetch = fetch
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('skips install when already up to date', async () => {
|
|
15
|
+
execSpy = spyOn(childProcess, 'execSync').mockImplementation((() =>
|
|
16
|
+
Buffer.from('')) as unknown as typeof childProcess.execSync)
|
|
17
|
+
global.fetch = (async () => Response.json({ version: '0.0.0' })) as unknown as typeof fetch
|
|
18
|
+
|
|
19
|
+
const logs: string[] = []
|
|
20
|
+
const origLog = console.log
|
|
21
|
+
console.log = (...args: unknown[]) => logs.push(String(args[0]))
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
await Effect.runPromise(updateCommand({}))
|
|
25
|
+
} finally {
|
|
26
|
+
console.log = origLog
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
expect(logs.join('\n')).toContain('Already up to date')
|
|
30
|
+
expect(execSpy.mock.calls.length).toBe(0)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('runs bun install when newer version available', async () => {
|
|
34
|
+
execSpy = spyOn(childProcess, 'execSync').mockImplementation((() =>
|
|
35
|
+
Buffer.from('')) as unknown as typeof childProcess.execSync)
|
|
36
|
+
global.fetch = (async () => Response.json({ version: '999.0.0' })) as unknown as typeof fetch
|
|
37
|
+
|
|
38
|
+
const logs: string[] = []
|
|
39
|
+
const origLog = console.log
|
|
40
|
+
console.log = (...args: unknown[]) => logs.push(String(args[0]))
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
await Effect.runPromise(updateCommand({}))
|
|
44
|
+
} finally {
|
|
45
|
+
console.log = origLog
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const calls = (execSpy.mock.calls as unknown as [string][]).map(([c]) => c)
|
|
49
|
+
expect(calls.some((c) => c.includes('bun install -g') && c.includes('@aaronshaf/ger'))).toBe(
|
|
50
|
+
true,
|
|
51
|
+
)
|
|
52
|
+
expect(logs.join('\n')).toContain('updated successfully')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
test('--skip-pull installs without version check', async () => {
|
|
56
|
+
execSpy = spyOn(childProcess, 'execSync').mockImplementation((() =>
|
|
57
|
+
Buffer.from('')) as unknown as typeof childProcess.execSync)
|
|
58
|
+
const fetchSpy = mock(async () => Response.json({ version: '999.0.0' }))
|
|
59
|
+
global.fetch = fetchSpy as unknown as typeof fetch
|
|
60
|
+
|
|
61
|
+
await Effect.runPromise(updateCommand({ skipPull: true }))
|
|
62
|
+
|
|
63
|
+
expect(fetchSpy.mock.calls.length).toBe(0)
|
|
64
|
+
const calls = (execSpy.mock.calls as unknown as [string][]).map(([c]) => c)
|
|
65
|
+
expect(calls.some((c) => c.includes('bun install -g'))).toBe(true)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('fails when registry is unreachable', async () => {
|
|
69
|
+
global.fetch = (async () => {
|
|
70
|
+
throw new Error('network error')
|
|
71
|
+
}) as unknown as typeof fetch
|
|
72
|
+
|
|
73
|
+
const result = await Effect.runPromise(updateCommand({}).pipe(Effect.either))
|
|
74
|
+
expect(result._tag).toBe('Left')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('fails when install command fails', async () => {
|
|
78
|
+
execSpy = spyOn(childProcess, 'execSync').mockImplementation((() => {
|
|
79
|
+
throw new Error('bun not found')
|
|
80
|
+
}) as unknown as typeof childProcess.execSync)
|
|
81
|
+
global.fetch = (async () => Response.json({ version: '999.0.0' })) as unknown as typeof fetch
|
|
82
|
+
|
|
83
|
+
const result = await Effect.runPromise(updateCommand({}).pipe(Effect.either))
|
|
84
|
+
expect(result._tag).toBe('Left')
|
|
85
|
+
})
|
|
86
|
+
})
|