@markjaquith/agency 1.11.1 → 2.1.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 (132) hide show
  1. package/README.md +313 -117
  2. package/cli.ts +124 -420
  3. package/index.ts +1 -1
  4. package/package.json +12 -17
  5. package/skills/agency/SKILL.md +266 -0
  6. package/src/cli.test.ts +290 -0
  7. package/src/commands/description.test.ts +103 -0
  8. package/src/commands/epic.test.ts +103 -0
  9. package/src/commands/epic.ts +101 -0
  10. package/src/commands/init.test.ts +42 -290
  11. package/src/commands/init.ts +15 -123
  12. package/src/commands/phase.ts +120 -0
  13. package/src/commands/pr.test.ts +19 -170
  14. package/src/commands/pr.ts +23 -179
  15. package/src/commands/repo.test.ts +80 -0
  16. package/src/commands/repo.ts +83 -0
  17. package/src/commands/status.test.ts +35 -477
  18. package/src/commands/status.ts +24 -213
  19. package/src/commands/task-phase.test.ts +206 -0
  20. package/src/commands/task.ts +83 -1285
  21. package/src/commands/validate.test.ts +91 -0
  22. package/src/commands/validate.ts +50 -0
  23. package/src/commands/work.test.ts +326 -399
  24. package/src/commands/work.ts +153 -153
  25. package/src/services/EpicService.test.ts +71 -0
  26. package/src/services/EpicService.ts +135 -0
  27. package/src/services/FileSystemService.ts +61 -92
  28. package/src/services/PhaseService.ts +323 -0
  29. package/src/services/PullRequestService.test.ts +386 -0
  30. package/src/services/PullRequestService.ts +143 -0
  31. package/src/services/RepositoryService.test.ts +106 -0
  32. package/src/services/RepositoryService.ts +159 -0
  33. package/src/services/TaskPhaseService.test.ts +217 -0
  34. package/src/services/TaskService.ts +192 -0
  35. package/src/services/WorkbaseService.test.ts +446 -0
  36. package/src/services/WorkbaseService.ts +493 -0
  37. package/src/services/WorktreeService.test.ts +599 -0
  38. package/src/services/WorktreeService.ts +357 -0
  39. package/src/test-utils.ts +41 -438
  40. package/src/types.ts +1 -215
  41. package/src/utils/command.ts +1 -5
  42. package/src/utils/effect.test.ts +24 -0
  43. package/src/utils/effect.ts +3 -273
  44. package/src/utils/process.test.ts +0 -15
  45. package/src/utils/process.ts +1 -60
  46. package/src/workbase/frontmatter.test.ts +50 -0
  47. package/src/workbase/frontmatter.ts +77 -0
  48. package/src/workbase/repository-reference.test.ts +25 -0
  49. package/src/workbase/repository-reference.ts +18 -0
  50. package/src/workbase/schemas.test.ts +146 -0
  51. package/src/workbase/schemas.ts +78 -0
  52. package/src/workbase/work-target.test.ts +77 -0
  53. package/src/workbase/work-target.ts +180 -0
  54. package/src/workbase/worktree-command.test.ts +61 -0
  55. package/src/workbase/worktree-command.ts +52 -0
  56. package/src/commands/base.test.ts +0 -198
  57. package/src/commands/base.ts +0 -198
  58. package/src/commands/clean.test.ts +0 -299
  59. package/src/commands/clean.ts +0 -274
  60. package/src/commands/completions.test.ts +0 -51
  61. package/src/commands/completions.ts +0 -308
  62. package/src/commands/emit.integration.test.ts +0 -549
  63. package/src/commands/emit.test.ts +0 -576
  64. package/src/commands/emit.ts +0 -700
  65. package/src/commands/emitted.test.ts +0 -226
  66. package/src/commands/emitted.ts +0 -57
  67. package/src/commands/loop.test.ts +0 -621
  68. package/src/commands/loop.ts +0 -406
  69. package/src/commands/merge.integration.test.ts +0 -195
  70. package/src/commands/merge.test.ts +0 -246
  71. package/src/commands/merge.ts +0 -244
  72. package/src/commands/pull.test.ts +0 -395
  73. package/src/commands/pull.ts +0 -205
  74. package/src/commands/push.test.ts +0 -641
  75. package/src/commands/push.ts +0 -482
  76. package/src/commands/rebase.test.ts +0 -521
  77. package/src/commands/rebase.ts +0 -232
  78. package/src/commands/save.test.ts +0 -247
  79. package/src/commands/save.ts +0 -162
  80. package/src/commands/source.test.ts +0 -195
  81. package/src/commands/source.ts +0 -72
  82. package/src/commands/switch.test.ts +0 -194
  83. package/src/commands/switch.ts +0 -84
  84. package/src/commands/task-branching.test.ts +0 -633
  85. package/src/commands/task-continue.test.ts +0 -311
  86. package/src/commands/task-edit.test.ts +0 -235
  87. package/src/commands/task-main.test.ts +0 -1066
  88. package/src/commands/task-squash.test.ts +0 -266
  89. package/src/commands/tasks.test.ts +0 -391
  90. package/src/commands/tasks.ts +0 -161
  91. package/src/commands/template-delete.test.ts +0 -178
  92. package/src/commands/template-delete.ts +0 -98
  93. package/src/commands/template-list.test.ts +0 -135
  94. package/src/commands/template-list.ts +0 -87
  95. package/src/commands/template-view.test.ts +0 -158
  96. package/src/commands/template-view.ts +0 -86
  97. package/src/commands/template.test.ts +0 -32
  98. package/src/commands/template.ts +0 -96
  99. package/src/commands/use.test.ts +0 -87
  100. package/src/commands/use.ts +0 -97
  101. package/src/constants.ts +0 -10
  102. package/src/errors.ts +0 -17
  103. package/src/schemas.ts +0 -33
  104. package/src/services/AgencyMetadataService.ts +0 -311
  105. package/src/services/ClaudeService.test.ts +0 -184
  106. package/src/services/ClaudeService.ts +0 -91
  107. package/src/services/ConfigService.ts +0 -115
  108. package/src/services/FilterRepoService.ts +0 -164
  109. package/src/services/FormatterService.test.ts +0 -432
  110. package/src/services/FormatterService.ts +0 -219
  111. package/src/services/GitService.ts +0 -1187
  112. package/src/services/MockFilterRepoService.ts +0 -140
  113. package/src/services/OpencodeService.ts +0 -263
  114. package/src/services/PromptService.ts +0 -183
  115. package/src/services/TemplateService.ts +0 -81
  116. package/src/types/native-exec.d.ts +0 -8
  117. package/src/utils/colors.ts +0 -178
  118. package/src/utils/git-path.ts +0 -6
  119. package/src/utils/glob.test.ts +0 -161
  120. package/src/utils/glob.ts +0 -78
  121. package/src/utils/paths.ts +0 -51
  122. package/src/utils/pr-branch.test.ts +0 -376
  123. package/src/utils/pr-branch.ts +0 -508
  124. package/src/utils/spinner.test.ts +0 -45
  125. package/src/utils/spinner.ts +0 -96
  126. package/src/utils/task-parser.test.ts +0 -172
  127. package/src/utils/task-parser.ts +0 -93
  128. package/templates/AGENCY.md +0 -22
  129. package/templates/AGENTS.md +0 -11
  130. package/templates/CLAUDE.md +0 -3
  131. package/templates/TASK.md +0 -5
  132. package/templates/opencode.json +0 -4
@@ -1,641 +0,0 @@
1
- import { test, expect, describe, beforeEach, afterEach } from "bun:test"
2
- import { join } from "path"
3
- import { push } from "./push"
4
- import {
5
- createTempDir,
6
- cleanupTempDir,
7
- initGitRepo,
8
- getCurrentBranch,
9
- getGitOutput,
10
- createCommit,
11
- checkoutBranch,
12
- createBranch,
13
- addAndCommit,
14
- renameBranch,
15
- runTestEffect,
16
- runTestEffectWithMockFilterRepo,
17
- } from "../test-utils"
18
-
19
- async function setupAgencyJson(gitRoot: string): Promise<void> {
20
- const agencyJson = {
21
- version: 1,
22
- injectedFiles: ["AGENTS.MD", "TASK.md"],
23
- template: "test",
24
- createdAt: new Date().toISOString(),
25
- }
26
- await Bun.write(
27
- join(gitRoot, "agency.json"),
28
- JSON.stringify(agencyJson, null, 2) + "\n",
29
- )
30
- await addAndCommit(gitRoot, "agency.json", "Add agency.json")
31
- }
32
-
33
- async function setupBareRemote(): Promise<string> {
34
- // Create a bare repository outside the working tree to avoid polluting git status
35
- const remoteDir = await createTempDir()
36
- await Bun.spawn(["git", "init", "--bare", remoteDir], {
37
- stdout: "pipe",
38
- stderr: "pipe",
39
- }).exited
40
- return remoteDir
41
- }
42
-
43
- async function checkoutNonAgencyBranch(gitRoot: string): Promise<void> {
44
- const proc = Bun.spawn(
45
- ["git", "checkout", "-q", "-b", "plain-feature", "origin/main"],
46
- {
47
- cwd: gitRoot,
48
- stdout: "pipe",
49
- stderr: "pipe",
50
- },
51
- )
52
- const exitCode = await proc.exited
53
- if (exitCode !== 0) {
54
- throw new Error(await new Response(proc.stderr).text())
55
- }
56
- }
57
-
58
- describe("push command", () => {
59
- let tempDir: string
60
- let remoteDir: string
61
- let originalCwd: string
62
-
63
- beforeEach(async () => {
64
- tempDir = await createTempDir()
65
- originalCwd = process.cwd()
66
- process.chdir(tempDir)
67
-
68
- // Set config path to non-existent file to use defaults
69
- process.env.AGENCY_CONFIG_PATH = join(tempDir, "non-existent-config.json")
70
-
71
- // Initialize git repo
72
- await initGitRepo(tempDir)
73
- await createCommit(tempDir, "Initial commit")
74
-
75
- // Rename to main if needed
76
- const currentBranch = await getCurrentBranch(tempDir)
77
- if (currentBranch === "master") {
78
- await renameBranch(tempDir, "main")
79
- }
80
-
81
- // Setup bare remote and push main
82
- remoteDir = await setupBareRemote()
83
- await Bun.spawn(["git", "remote", "add", "origin", remoteDir], {
84
- cwd: tempDir,
85
- stdout: "pipe",
86
- stderr: "pipe",
87
- }).exited
88
- await Bun.spawn(["git", "push", "-u", "origin", "main"], {
89
- cwd: tempDir,
90
- stdout: "pipe",
91
- stderr: "pipe",
92
- }).exited
93
-
94
- // Setup agency.json
95
- await setupAgencyJson(tempDir)
96
-
97
- // Create a source branch (with agency-- prefix per new default config)
98
- await createBranch(tempDir, "agency--feature")
99
- await createCommit(tempDir, "Feature work")
100
- })
101
-
102
- afterEach(async () => {
103
- process.chdir(originalCwd)
104
- delete process.env.AGENCY_CONFIG_PATH
105
- await cleanupTempDir(tempDir)
106
- await cleanupTempDir(remoteDir)
107
- })
108
-
109
- describe("basic functionality", () => {
110
- test("falls back to git push outside agency context", async () => {
111
- await checkoutNonAgencyBranch(tempDir)
112
- await createCommit(tempDir, "Plain feature work")
113
- await Bun.write(join(tempDir, "uncommitted.txt"), "uncommitted\n")
114
-
115
- await runTestEffect(
116
- push({
117
- gitArgs: ["origin", "plain-feature"],
118
- silent: true,
119
- }),
120
- )
121
-
122
- const localHead = await getGitOutput(tempDir, ["rev-parse", "HEAD"])
123
- const remoteHead = await getGitOutput(tempDir, [
124
- "rev-parse",
125
- "refs/remotes/origin/plain-feature",
126
- ])
127
- expect(remoteHead.trim()).toBe(localHead.trim())
128
- expect(await Bun.file(join(tempDir, "uncommitted.txt")).exists()).toBe(
129
- true,
130
- )
131
- })
132
-
133
- test("forwards --no-verify to the git push fallback", async () => {
134
- await checkoutNonAgencyBranch(tempDir)
135
- await createCommit(tempDir, "Plain feature work")
136
-
137
- await Bun.spawn(
138
- ["git", "config", "core.hooksPath", join(tempDir, ".git", "hooks")],
139
- {
140
- cwd: tempDir,
141
- stdout: "pipe",
142
- stderr: "pipe",
143
- },
144
- ).exited
145
- const hookPath = join(tempDir, ".git", "hooks", "pre-push")
146
- await Bun.write(hookPath, "#!/bin/sh\nexit 1\n")
147
- await Bun.spawn(["chmod", "+x", hookPath], {
148
- cwd: tempDir,
149
- stdout: "pipe",
150
- stderr: "pipe",
151
- }).exited
152
-
153
- await runTestEffect(
154
- push({
155
- gitArgs: ["origin", "plain-feature"],
156
- noVerify: true,
157
- silent: true,
158
- }),
159
- )
160
-
161
- const remoteBranches = await getGitOutput(tempDir, [
162
- "ls-remote",
163
- "--heads",
164
- "origin",
165
- "plain-feature",
166
- ])
167
- expect(remoteBranches).toContain("plain-feature")
168
- })
169
-
170
- test("forwards force modes to the git push fallback", async () => {
171
- await checkoutNonAgencyBranch(tempDir)
172
- await createCommit(tempDir, "Initial plain feature work")
173
- await Bun.spawn(["git", "push", "-u", "origin", "plain-feature"], {
174
- cwd: tempDir,
175
- stdout: "pipe",
176
- stderr: "pipe",
177
- }).exited
178
-
179
- await createCommit(tempDir, "Remote state for lease")
180
- await Bun.spawn(["git", "push"], {
181
- cwd: tempDir,
182
- stdout: "pipe",
183
- stderr: "pipe",
184
- }).exited
185
- await Bun.spawn(["git", "reset", "--hard", "HEAD~1"], {
186
- cwd: tempDir,
187
- stdout: "pipe",
188
- stderr: "pipe",
189
- }).exited
190
- await createCommit(tempDir, "Lease-protected rewrite")
191
-
192
- await runTestEffect(push({ forceWithLease: true, silent: true }))
193
-
194
- await createCommit(tempDir, "Remote state for force")
195
- await Bun.spawn(["git", "push"], {
196
- cwd: tempDir,
197
- stdout: "pipe",
198
- stderr: "pipe",
199
- }).exited
200
- await Bun.spawn(["git", "reset", "--hard", "HEAD~1"], {
201
- cwd: tempDir,
202
- stdout: "pipe",
203
- stderr: "pipe",
204
- }).exited
205
- await createCommit(tempDir, "Forced rewrite")
206
-
207
- await runTestEffect(push({ force: true, silent: true }))
208
-
209
- const localHead = await getGitOutput(tempDir, ["rev-parse", "HEAD"])
210
- const remoteHead = await getGitOutput(tempDir, [
211
- "ls-remote",
212
- "origin",
213
- "refs/heads/plain-feature",
214
- ])
215
- expect(remoteHead.split("\t")[0]).toBe(localHead.trim())
216
- })
217
-
218
- test("fails when there are uncommitted changes", async () => {
219
- // Create uncommitted changes
220
- await Bun.write(join(tempDir, "uncommitted.txt"), "uncommitted\n")
221
-
222
- expect(
223
- runTestEffect(
224
- push({ baseBranch: "main", silent: true, skipFilter: true }),
225
- ),
226
- ).rejects.toThrow(/uncommitted changes/)
227
- })
228
-
229
- test("fails when there are staged but uncommitted changes", async () => {
230
- // Create a staged but uncommitted change
231
- await Bun.write(join(tempDir, "staged.txt"), "staged\n")
232
- await Bun.spawn(["git", "add", "staged.txt"], {
233
- cwd: tempDir,
234
- stdout: "pipe",
235
- stderr: "pipe",
236
- }).exited
237
-
238
- expect(
239
- runTestEffect(
240
- push({ baseBranch: "main", silent: true, skipFilter: true }),
241
- ),
242
- ).rejects.toThrow(/uncommitted changes/)
243
- })
244
-
245
- test("creates emit branch, pushes it, and returns to source", async () => {
246
- // We're on agency--feature branch (source)
247
- expect(await getCurrentBranch(tempDir)).toBe("agency--feature")
248
-
249
- // Run push command
250
- await runTestEffect(
251
- push({ baseBranch: "main", silent: true, skipFilter: true }),
252
- )
253
-
254
- // Should be back on agency--feature branch (source)
255
- expect(await getCurrentBranch(tempDir)).toBe("agency--feature")
256
-
257
- // emit branch (feature) should exist locally and on remote
258
- const branches = await getGitOutput(tempDir, ["branch"])
259
- expect(branches).toContain("feature")
260
-
261
- const remoteBranches = await getGitOutput(tempDir, [
262
- "ls-remote",
263
- "--heads",
264
- "origin",
265
- "feature",
266
- ])
267
- expect(remoteBranches).toContain("feature")
268
- })
269
-
270
- test("forwards verbose output from emit step", async () => {
271
- const originalLog = console.log
272
- const logMessages: string[] = []
273
- console.log = (msg: string) => {
274
- logMessages.push(msg)
275
- }
276
-
277
- try {
278
- await runTestEffect(
279
- push({
280
- baseBranch: "main",
281
- verbose: true,
282
- skipFilter: true,
283
- }),
284
- )
285
- } finally {
286
- console.log = originalLog
287
- }
288
-
289
- expect(logMessages).toContain("Step 1: Emitting...")
290
- expect(
291
- logMessages.some(
292
- (msg) => msg.includes("Using base branch:") && msg.includes("main"),
293
- ),
294
- ).toBe(true)
295
- expect(
296
- logMessages.some((msg) =>
297
- msg.includes("Skipping git-filter-repo (skipFilter=true)"),
298
- ),
299
- ).toBe(true)
300
- expect(logMessages.some((msg) => msg.includes("Emitted"))).toBe(true)
301
- })
302
-
303
- test("works with custom branch name", async () => {
304
- await runTestEffect(
305
- push({
306
- baseBranch: "main",
307
- emit: "custom-pr-branch",
308
- silent: true,
309
- skipFilter: true,
310
- }),
311
- )
312
-
313
- // Should be back on agency--feature branch (source)
314
- expect(await getCurrentBranch(tempDir)).toBe("agency--feature")
315
-
316
- // Custom branch should exist
317
- const branches = await getGitOutput(tempDir, ["branch"])
318
- expect(branches).toContain("custom-pr-branch")
319
- })
320
-
321
- test("recreates emit branch if it already exists", async () => {
322
- // First push
323
- await runTestEffect(
324
- push({ baseBranch: "main", silent: true, skipFilter: true }),
325
- )
326
-
327
- // Make more changes on source branch
328
- await checkoutBranch(tempDir, "agency--feature")
329
- await createCommit(tempDir, "More feature work")
330
-
331
- // Second push should recreate the emit branch
332
- await runTestEffect(
333
- push({ baseBranch: "main", silent: true, skipFilter: true }),
334
- )
335
-
336
- // Should still be back on agency--feature branch (source)
337
- expect(await getCurrentBranch(tempDir)).toBe("agency--feature")
338
- })
339
- })
340
-
341
- describe("error handling", () => {
342
- test("throws when emitted CLAUDE.md references AGENCY.md", async () => {
343
- await Bun.write(
344
- join(tempDir, "CLAUDE.md"),
345
- "# Instructions\n\n@AGENCY.md\n",
346
- )
347
- await addAndCommit(tempDir, "CLAUDE.md", "Add Claude agency reference")
348
-
349
- await expect(
350
- runTestEffectWithMockFilterRepo(
351
- push({ baseBranch: "main", silent: true }),
352
- ),
353
- ).rejects.toThrow(
354
- "Failed to create emit branch: CLAUDE.md on emitted branch feature still references @AGENCY.md",
355
- )
356
- })
357
-
358
- test("switches to source branch when run from emit branch", async () => {
359
- // First create the emit branch from source branch
360
- await runTestEffect(
361
- push({ baseBranch: "main", silent: true, skipFilter: true }),
362
- )
363
-
364
- // Now we're on agency--feature, switch to the emit branch (feature)
365
- await checkoutBranch(tempDir, "feature")
366
-
367
- // Verify we're on the emit branch
368
- expect(await getCurrentBranch(tempDir)).toBe("feature")
369
-
370
- // Make a change on source branch that we'll push
371
- await checkoutBranch(tempDir, "agency--feature")
372
- await createCommit(tempDir, "Another feature commit")
373
-
374
- // Switch back to emit branch
375
- await checkoutBranch(tempDir, "feature")
376
-
377
- // Run push from emit branch - should detect we're on emit branch,
378
- // switch to source (agency--feature), and continue
379
- await runTestEffect(
380
- push({ baseBranch: "main", silent: true, skipFilter: true }),
381
- )
382
-
383
- // Should be back on agency--feature branch (the source branch)
384
- expect(await getCurrentBranch(tempDir)).toBe("agency--feature")
385
- })
386
-
387
- test("throws error when not in a git repository", async () => {
388
- const nonGitDir = await createTempDir()
389
- process.chdir(nonGitDir)
390
-
391
- await expect(
392
- runTestEffect(
393
- push({ baseBranch: "main", silent: true, skipFilter: true }),
394
- ),
395
- ).rejects.toThrow("Not in a git repository")
396
-
397
- await cleanupTempDir(nonGitDir)
398
- })
399
-
400
- test("handles push failure gracefully", async () => {
401
- // Remove the remote to cause push to fail
402
- await Bun.spawn(["git", "remote", "remove", "origin"], {
403
- cwd: tempDir,
404
- stdout: "pipe",
405
- stderr: "pipe",
406
- }).exited
407
-
408
- // Push should fail because no remote exists
409
- await expect(
410
- runTestEffect(
411
- push({ baseBranch: "main", silent: true, skipFilter: true }),
412
- ),
413
- ).rejects.toThrow(/No git remotes found/)
414
- })
415
- })
416
-
417
- describe("silent mode", () => {
418
- test("silent flag suppresses output", async () => {
419
- // Capture output
420
- const originalLog = console.log
421
- let logCalled = false
422
- console.log = () => {
423
- logCalled = true
424
- }
425
-
426
- await runTestEffect(
427
- push({ baseBranch: "main", silent: true, skipFilter: true }),
428
- )
429
-
430
- console.log = originalLog
431
- expect(logCalled).toBe(false)
432
- })
433
- })
434
-
435
- describe("force push", () => {
436
- test("force pushes when branch has diverged and --force is provided", async () => {
437
- // First push
438
- await runTestEffect(
439
- push({ baseBranch: "main", silent: true, skipFilter: true }),
440
- )
441
-
442
- // Make changes on source branch
443
- await checkoutBranch(tempDir, "agency--feature")
444
- await createCommit(tempDir, "More feature work")
445
-
446
- // Modify the emit branch to create divergence
447
- await checkoutBranch(tempDir, "feature")
448
- await createCommit(tempDir, "Direct emit branch commit")
449
- await Bun.spawn(["git", "push"], {
450
- cwd: tempDir,
451
- stdout: "pipe",
452
- stderr: "pipe",
453
- }).exited
454
-
455
- // Go back to source branch and try to push again with --force
456
- await checkoutBranch(tempDir, "agency--feature")
457
-
458
- // Capture output to check for force push message
459
- const originalLog = console.log
460
- let logMessages: string[] = []
461
- console.log = (msg: string) => {
462
- logMessages.push(msg)
463
- }
464
-
465
- await runTestEffect(
466
- push({
467
- baseBranch: "main",
468
- force: true,
469
- silent: false,
470
- skipFilter: true,
471
- }),
472
- )
473
-
474
- console.log = originalLog
475
-
476
- // Should be back on agency--feature branch (source)
477
- expect(await getCurrentBranch(tempDir)).toBe("agency--feature")
478
-
479
- // Should have reported force push
480
- expect(logMessages.some((msg) => msg.includes("(forced)"))).toBe(true)
481
- })
482
-
483
- test("suggests using --force when push is rejected without it", async () => {
484
- // First push
485
- await runTestEffect(
486
- push({ baseBranch: "main", silent: true, skipFilter: true }),
487
- )
488
-
489
- // Make changes on source branch
490
- await checkoutBranch(tempDir, "agency--feature")
491
- await createCommit(tempDir, "More feature work")
492
-
493
- // Modify the emit branch to create divergence
494
- await checkoutBranch(tempDir, "feature")
495
- await createCommit(tempDir, "Direct emit branch commit")
496
- await Bun.spawn(["git", "push"], {
497
- cwd: tempDir,
498
- stdout: "pipe",
499
- stderr: "pipe",
500
- }).exited
501
-
502
- // Go back to source branch and try to push without --force
503
- await checkoutBranch(tempDir, "agency--feature")
504
-
505
- // Should throw error suggesting --force
506
- await expect(
507
- runTestEffect(
508
- push({ baseBranch: "main", silent: true, skipFilter: true }),
509
- ),
510
- ).rejects.toThrow(
511
- /non-fast-forward update.*agency push --force|agency push --force.*non-fast-forward update/s,
512
- )
513
-
514
- // Should still be on agency--feature branch (not left in intermediate state)
515
- expect(await getCurrentBranch(tempDir)).toBe("agency--feature")
516
- })
517
-
518
- test("reports pre-push hook output without suggesting --force", async () => {
519
- await Bun.spawn(
520
- ["git", "config", "core.hooksPath", join(tempDir, ".git", "hooks")],
521
- {
522
- cwd: tempDir,
523
- stdout: "pipe",
524
- stderr: "pipe",
525
- },
526
- ).exited
527
- const hookPath = join(tempDir, ".git", "hooks", "pre-push")
528
- await Bun.write(
529
- hookPath,
530
- [
531
- "#!/bin/sh",
532
- "printf 'pre-push hook stdout failure\\n'",
533
- "printf 'pre-push hook stderr failure\\n' >&2",
534
- "exit 1",
535
- "",
536
- ].join("\n"),
537
- )
538
- await Bun.spawn(["chmod", "+x", hookPath], {
539
- cwd: tempDir,
540
- stdout: "pipe",
541
- stderr: "pipe",
542
- }).exited
543
-
544
- let error: unknown
545
- try {
546
- await runTestEffect(
547
- push({ baseBranch: "main", silent: true, skipFilter: true }),
548
- )
549
- } catch (caught) {
550
- error = caught
551
- }
552
-
553
- expect(error).toBeInstanceOf(Error)
554
- const message = error instanceof Error ? error.message : String(error)
555
- expect(message).toContain("pre-push hook stdout failure")
556
- expect(message).toContain("pre-push hook stderr failure")
557
- expect(message).not.toContain("agency push --force")
558
-
559
- expect(await getCurrentBranch(tempDir)).toBe("agency--feature")
560
- })
561
-
562
- test("does not report force push when --force is provided but not needed", async () => {
563
- // Capture output to check for force push message
564
- const originalLog = console.log
565
- let logMessages: string[] = []
566
- console.log = (msg: string) => {
567
- logMessages.push(msg)
568
- }
569
-
570
- // First push with --force (but it won't actually need force)
571
- await runTestEffect(
572
- push({
573
- baseBranch: "main",
574
- force: true,
575
- silent: false,
576
- skipFilter: true,
577
- }),
578
- )
579
-
580
- console.log = originalLog
581
-
582
- // Should be back on agency--feature branch (source)
583
- expect(await getCurrentBranch(tempDir)).toBe("agency--feature")
584
-
585
- // Should NOT have reported force push (since it wasn't actually used)
586
- expect(logMessages.some((msg) => msg.includes("Force pushed"))).toBe(
587
- false,
588
- )
589
- expect(logMessages.some((msg) => msg.includes("Pushed"))).toBe(true)
590
- })
591
- })
592
-
593
- describe("--pr flag", () => {
594
- test("handles gh CLI failure gracefully and continues", async () => {
595
- // Capture error output
596
- const originalError = console.error
597
- let errorMessages: string[] = []
598
- console.error = (msg: string) => {
599
- errorMessages.push(msg)
600
- }
601
-
602
- // Should not throw - command should complete despite gh failure
603
- // (gh will fail in test environment because there's no GitHub remote)
604
- await runTestEffect(
605
- push({ baseBranch: "main", pr: true, silent: true, skipFilter: true }),
606
- )
607
-
608
- console.error = originalError
609
-
610
- // Should be back on agency--feature branch (command completes despite gh failure)
611
- expect(await getCurrentBranch(tempDir)).toBe("agency--feature")
612
-
613
- // Should have warned about gh failure
614
- expect(
615
- errorMessages.some((msg) => msg.includes("Failed to open GitHub PR")),
616
- ).toBe(true)
617
- })
618
-
619
- test("does not call gh when --pr flag is not set", async () => {
620
- // Capture error output
621
- const originalError = console.error
622
- let errorMessages: string[] = []
623
- console.error = (msg: string) => {
624
- errorMessages.push(msg)
625
- }
626
-
627
- // Push without --pr flag
628
- await runTestEffect(
629
- push({ baseBranch: "main", silent: true, skipFilter: true }),
630
- )
631
-
632
- console.error = originalError
633
-
634
- // Should be back on agency--feature branch (source)
635
- expect(await getCurrentBranch(tempDir)).toBe("agency--feature")
636
-
637
- // gh should NOT have been called (no error about GitHub PR)
638
- expect(errorMessages.some((msg) => msg.includes("GitHub PR"))).toBe(false)
639
- })
640
- })
641
- })