@markjaquith/agency 0.5.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 (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +109 -0
  3. package/cli.ts +569 -0
  4. package/index.ts +1 -0
  5. package/package.json +65 -0
  6. package/src/commands/base.test.ts +198 -0
  7. package/src/commands/base.ts +198 -0
  8. package/src/commands/clean.test.ts +299 -0
  9. package/src/commands/clean.ts +320 -0
  10. package/src/commands/emit.test.ts +412 -0
  11. package/src/commands/emit.ts +521 -0
  12. package/src/commands/emitted.test.ts +226 -0
  13. package/src/commands/emitted.ts +57 -0
  14. package/src/commands/init.test.ts +311 -0
  15. package/src/commands/init.ts +140 -0
  16. package/src/commands/merge.test.ts +365 -0
  17. package/src/commands/merge.ts +253 -0
  18. package/src/commands/pull.test.ts +385 -0
  19. package/src/commands/pull.ts +205 -0
  20. package/src/commands/push.test.ts +394 -0
  21. package/src/commands/push.ts +346 -0
  22. package/src/commands/save.test.ts +247 -0
  23. package/src/commands/save.ts +162 -0
  24. package/src/commands/source.test.ts +195 -0
  25. package/src/commands/source.ts +72 -0
  26. package/src/commands/status.test.ts +489 -0
  27. package/src/commands/status.ts +258 -0
  28. package/src/commands/switch.test.ts +194 -0
  29. package/src/commands/switch.ts +84 -0
  30. package/src/commands/task-branching.test.ts +334 -0
  31. package/src/commands/task-edit.test.ts +141 -0
  32. package/src/commands/task-main.test.ts +872 -0
  33. package/src/commands/task.ts +712 -0
  34. package/src/commands/tasks.test.ts +335 -0
  35. package/src/commands/tasks.ts +155 -0
  36. package/src/commands/template-delete.test.ts +178 -0
  37. package/src/commands/template-delete.ts +98 -0
  38. package/src/commands/template-list.test.ts +135 -0
  39. package/src/commands/template-list.ts +87 -0
  40. package/src/commands/template-view.test.ts +158 -0
  41. package/src/commands/template-view.ts +86 -0
  42. package/src/commands/template.test.ts +32 -0
  43. package/src/commands/template.ts +96 -0
  44. package/src/commands/use.test.ts +87 -0
  45. package/src/commands/use.ts +97 -0
  46. package/src/commands/work.test.ts +462 -0
  47. package/src/commands/work.ts +193 -0
  48. package/src/errors.ts +17 -0
  49. package/src/schemas.ts +33 -0
  50. package/src/services/AgencyMetadataService.ts +287 -0
  51. package/src/services/ClaudeService.test.ts +184 -0
  52. package/src/services/ClaudeService.ts +91 -0
  53. package/src/services/ConfigService.ts +115 -0
  54. package/src/services/FileSystemService.ts +222 -0
  55. package/src/services/GitService.ts +751 -0
  56. package/src/services/OpencodeService.ts +263 -0
  57. package/src/services/PromptService.ts +183 -0
  58. package/src/services/TemplateService.ts +75 -0
  59. package/src/test-utils.ts +362 -0
  60. package/src/types/native-exec.d.ts +8 -0
  61. package/src/types.ts +216 -0
  62. package/src/utils/colors.ts +178 -0
  63. package/src/utils/command.ts +17 -0
  64. package/src/utils/effect.ts +281 -0
  65. package/src/utils/exec.ts +48 -0
  66. package/src/utils/paths.ts +51 -0
  67. package/src/utils/pr-branch.test.ts +372 -0
  68. package/src/utils/pr-branch.ts +473 -0
  69. package/src/utils/process.ts +110 -0
  70. package/src/utils/spinner.ts +82 -0
  71. package/templates/AGENCY.md +20 -0
  72. package/templates/AGENTS.md +11 -0
  73. package/templates/CLAUDE.md +3 -0
  74. package/templates/TASK.md +5 -0
  75. package/templates/opencode.json +4 -0
@@ -0,0 +1,205 @@
1
+ import { Effect } from "effect"
2
+ import type { BaseCommandOptions } from "../utils/command"
3
+ import { GitService } from "../services/GitService"
4
+ import { ConfigService } from "../services/ConfigService"
5
+ import { resolveBranchPairWithAgencyJson } from "../utils/pr-branch"
6
+ import highlight, { done } from "../utils/colors"
7
+ import {
8
+ createLoggers,
9
+ ensureGitRepo,
10
+ withBranchProtection,
11
+ } from "../utils/effect"
12
+ import { withSpinner } from "../utils/spinner"
13
+
14
+ interface PullOptions extends BaseCommandOptions {
15
+ remote?: string
16
+ }
17
+
18
+ export const pull = (options: PullOptions = {}) =>
19
+ Effect.gen(function* () {
20
+ const gitRoot = yield* ensureGitRepo()
21
+
22
+ // Wrap the entire pull operation with branch protection
23
+ // This ensures we return to the original branch on Ctrl-C interrupt
24
+ yield* withBranchProtection(gitRoot, pullCore(gitRoot, options))
25
+ })
26
+
27
+ const pullCore = (gitRoot: string, options: PullOptions) =>
28
+ Effect.gen(function* () {
29
+ const { verbose = false } = options
30
+ const { log, verboseLog } = createLoggers(options)
31
+
32
+ const git = yield* GitService
33
+ const configService = yield* ConfigService
34
+
35
+ // Resolve remote name (use provided option, config, or auto-detect)
36
+ const remote = options.remote
37
+ ? yield* git.resolveRemote(gitRoot, options.remote)
38
+ : yield* git.resolveRemote(gitRoot)
39
+
40
+ // Load config
41
+ const config = yield* configService.loadConfig()
42
+
43
+ // Get current branch
44
+ let currentBranch = yield* git.getCurrentBranch(gitRoot)
45
+
46
+ // Resolve branch pair to find source and emit branches
47
+ const branches = yield* resolveBranchPairWithAgencyJson(
48
+ gitRoot,
49
+ currentBranch,
50
+ config.sourceBranchPattern,
51
+ config.emitBranch,
52
+ )
53
+ const { sourceBranch, emitBranch, isOnEmitBranch } = branches
54
+
55
+ // If we're on emit branch, switch to source branch
56
+ if (isOnEmitBranch) {
57
+ verboseLog(
58
+ `Currently on emit branch ${highlight.branch(currentBranch)}, switching to source branch ${highlight.branch(sourceBranch)}`,
59
+ )
60
+ const sourceExists = yield* git.branchExists(gitRoot, sourceBranch)
61
+ if (!sourceExists) {
62
+ return yield* Effect.fail(
63
+ new Error(
64
+ `Source branch ${highlight.branch(sourceBranch)} does not exist`,
65
+ ),
66
+ )
67
+ }
68
+ yield* git.checkoutBranch(gitRoot, sourceBranch)
69
+ currentBranch = sourceBranch
70
+ }
71
+
72
+ verboseLog(`Source branch: ${highlight.branch(sourceBranch)}`)
73
+ verboseLog(`Emit branch: ${highlight.branch(emitBranch)}`)
74
+
75
+ // Fetch the remote emit branch
76
+ const remoteEmitBranch = `${remote}/${emitBranch}`
77
+ const fetchOperation = Effect.gen(function* () {
78
+ const result = yield* git.fetch(gitRoot, remote, emitBranch).pipe(
79
+ Effect.map(() => true),
80
+ Effect.catchAll(() => Effect.succeed(false)),
81
+ )
82
+
83
+ if (!result) {
84
+ return yield* Effect.fail(
85
+ new Error(
86
+ `Failed to fetch ${highlight.branch(remoteEmitBranch)}. Does the remote branch exist?`,
87
+ ),
88
+ )
89
+ }
90
+
91
+ verboseLog(`Fetched ${highlight.branch(remoteEmitBranch)}`)
92
+
93
+ // Check if remote emit branch exists after fetch
94
+ const remoteExists = yield* git.branchExists(gitRoot, remoteEmitBranch)
95
+ if (!remoteExists) {
96
+ return yield* Effect.fail(
97
+ new Error(
98
+ `Remote emit branch ${highlight.branch(remoteEmitBranch)} does not exist`,
99
+ ),
100
+ )
101
+ }
102
+ })
103
+
104
+ yield* withSpinner(fetchOperation, {
105
+ text: `Fetching ${highlight.branch(remoteEmitBranch)}`,
106
+ successText: `Fetched ${highlight.branch(remoteEmitBranch)}`,
107
+ enabled: !options.silent && !verbose,
108
+ })
109
+
110
+ // Check if local emit branch exists
111
+ const localEmitExists = yield* git.branchExists(gitRoot, emitBranch)
112
+ if (!localEmitExists) {
113
+ verboseLog(
114
+ `Local emit branch ${highlight.branch(emitBranch)} does not exist, comparing against source branch`,
115
+ )
116
+ }
117
+
118
+ // Get the list of commits on remote emit branch that aren't on local emit branch
119
+ // If local emit branch doesn't exist, compare against source branch
120
+ const compareBase = localEmitExists ? emitBranch : sourceBranch
121
+ verboseLog(
122
+ `Comparing ${highlight.branch(remoteEmitBranch)} against ${highlight.branch(compareBase)}`,
123
+ )
124
+
125
+ const commitsResult = yield* git
126
+ .getCommitsBetween(gitRoot, compareBase, remoteEmitBranch)
127
+ .pipe(Effect.catchAll(() => Effect.succeed("")))
128
+
129
+ if (!commitsResult || commitsResult.trim() === "") {
130
+ log(done("No new commits to pull"))
131
+ return
132
+ }
133
+
134
+ const commits = commitsResult.split("\n").filter((c) => c.trim().length > 0)
135
+ verboseLog(`Found ${commits.length} commits to cherry-pick`)
136
+
137
+ // Cherry-pick each commit
138
+ let successCount = 0
139
+ let failedCommit: string | null = null
140
+
141
+ for (const commit of commits) {
142
+ verboseLog(`Cherry-picking ${highlight.commit(commit.substring(0, 8))}`)
143
+
144
+ const result = yield* git.cherryPick(gitRoot, commit).pipe(
145
+ Effect.map(() => true),
146
+ Effect.catchAll(() => {
147
+ failedCommit = commit
148
+ return Effect.succeed(false)
149
+ }),
150
+ )
151
+
152
+ if (!result) {
153
+ break
154
+ }
155
+
156
+ successCount++
157
+ }
158
+
159
+ if (failedCommit) {
160
+ log(
161
+ `Cherry-picked ${successCount} of ${commits.length} commits before conflict`,
162
+ )
163
+ return yield* Effect.fail(
164
+ new Error(
165
+ `Cherry-pick conflict at commit ${highlight.commit(failedCommit.substring(0, 8))}. Resolve conflicts and continue with: git cherry-pick --continue`,
166
+ ),
167
+ )
168
+ }
169
+
170
+ log(
171
+ done(
172
+ `Pulled ${commits.length} commit${commits.length === 1 ? "" : "s"} from ${highlight.branch(remoteEmitBranch)}`,
173
+ ),
174
+ )
175
+ })
176
+
177
+ export const help = `
178
+ Usage: agency pull [options]
179
+
180
+ Pull commits from the remote emit branch and cherry-pick them onto the source branch.
181
+
182
+ This command is useful when someone else has pushed commits to the emit branch
183
+ (e.g., after a PR review) and you want to bring those changes back into your
184
+ source branch.
185
+
186
+ Workflow:
187
+ 1. Determines the source and emit branch names
188
+ 2. If on emit branch, switches to source branch
189
+ 3. Fetches the remote emit branch
190
+ 4. Finds commits on remote emit branch that aren't on source branch
191
+ 5. Cherry-picks each commit onto the source branch
192
+
193
+ Options:
194
+ -r, --remote Remote name to fetch from (defaults to 'origin')
195
+
196
+ Examples:
197
+ agency pull # Pull from origin/<emit-branch>
198
+ agency pull --remote upstream # Pull from upstream/<emit-branch>
199
+
200
+ Notes:
201
+ - If a cherry-pick conflict occurs, you'll need to resolve it manually
202
+ - Use 'git cherry-pick --continue' after resolving conflicts
203
+ - The command will stop at the first conflict
204
+ - Only commits that don't exist on the source branch are cherry-picked
205
+ `
@@ -0,0 +1,394 @@
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
+ } from "../test-utils"
17
+
18
+ async function setupAgencyJson(gitRoot: string): Promise<void> {
19
+ const agencyJson = {
20
+ version: 1,
21
+ injectedFiles: ["AGENTS.MD", "TASK.md"],
22
+ template: "test",
23
+ createdAt: new Date().toISOString(),
24
+ }
25
+ await Bun.write(
26
+ join(gitRoot, "agency.json"),
27
+ JSON.stringify(agencyJson, null, 2) + "\n",
28
+ )
29
+ await addAndCommit(gitRoot, "agency.json", "Add agency.json")
30
+ }
31
+
32
+ async function setupBareRemote(tempDir: string): Promise<string> {
33
+ // Create a bare repository to use as remote
34
+ const remoteDir = join(tempDir, "remote.git")
35
+ await Bun.spawn(["git", "init", "--bare", remoteDir], {
36
+ stdout: "pipe",
37
+ stderr: "pipe",
38
+ }).exited
39
+ return remoteDir
40
+ }
41
+
42
+ describe("push command", () => {
43
+ let tempDir: string
44
+ let remoteDir: string
45
+ let originalCwd: string
46
+
47
+ beforeEach(async () => {
48
+ tempDir = await createTempDir()
49
+ originalCwd = process.cwd()
50
+ process.chdir(tempDir)
51
+
52
+ // Set config path to non-existent file to use defaults
53
+ process.env.AGENCY_CONFIG_PATH = join(tempDir, "non-existent-config.json")
54
+
55
+ // Initialize git repo
56
+ await initGitRepo(tempDir)
57
+ await createCommit(tempDir, "Initial commit")
58
+
59
+ // Rename to main if needed
60
+ const currentBranch = await getCurrentBranch(tempDir)
61
+ if (currentBranch === "master") {
62
+ await renameBranch(tempDir, "main")
63
+ }
64
+
65
+ // Setup bare remote and push main
66
+ remoteDir = await setupBareRemote(tempDir)
67
+ await Bun.spawn(["git", "remote", "add", "origin", remoteDir], {
68
+ cwd: tempDir,
69
+ stdout: "pipe",
70
+ stderr: "pipe",
71
+ }).exited
72
+ await Bun.spawn(["git", "push", "-u", "origin", "main"], {
73
+ cwd: tempDir,
74
+ stdout: "pipe",
75
+ stderr: "pipe",
76
+ }).exited
77
+
78
+ // Setup agency.json
79
+ await setupAgencyJson(tempDir)
80
+
81
+ // Create a source branch (with agency/ prefix per new default config)
82
+ await createBranch(tempDir, "agency/feature")
83
+ await createCommit(tempDir, "Feature work")
84
+ })
85
+
86
+ afterEach(async () => {
87
+ process.chdir(originalCwd)
88
+ delete process.env.AGENCY_CONFIG_PATH
89
+ await cleanupTempDir(tempDir)
90
+ })
91
+
92
+ describe("basic functionality", () => {
93
+ test("creates emit branch, pushes it, and returns to source", async () => {
94
+ // We're on agency/feature branch (source)
95
+ expect(await getCurrentBranch(tempDir)).toBe("agency/feature")
96
+
97
+ // Run push command
98
+ await runTestEffect(
99
+ push({ baseBranch: "main", silent: true, skipFilter: true }),
100
+ )
101
+
102
+ // Should be back on agency/feature branch (source)
103
+ expect(await getCurrentBranch(tempDir)).toBe("agency/feature")
104
+
105
+ // emit branch (feature) should exist locally and on remote
106
+ const branches = await getGitOutput(tempDir, ["branch"])
107
+ expect(branches).toContain("feature")
108
+
109
+ const remoteBranches = await getGitOutput(tempDir, [
110
+ "ls-remote",
111
+ "--heads",
112
+ "origin",
113
+ "feature",
114
+ ])
115
+ expect(remoteBranches).toContain("feature")
116
+ })
117
+
118
+ test("works with custom branch name", async () => {
119
+ await runTestEffect(
120
+ push({
121
+ baseBranch: "main",
122
+ branch: "custom-pr-branch",
123
+ silent: true,
124
+ skipFilter: true,
125
+ }),
126
+ )
127
+
128
+ // Should be back on agency/feature branch (source)
129
+ expect(await getCurrentBranch(tempDir)).toBe("agency/feature")
130
+
131
+ // Custom branch should exist
132
+ const branches = await getGitOutput(tempDir, ["branch"])
133
+ expect(branches).toContain("custom-pr-branch")
134
+ })
135
+
136
+ test("recreates emit branch if it already exists", async () => {
137
+ // First push
138
+ await runTestEffect(
139
+ push({ baseBranch: "main", silent: true, skipFilter: true }),
140
+ )
141
+
142
+ // Make more changes on source branch
143
+ await checkoutBranch(tempDir, "agency/feature")
144
+ await createCommit(tempDir, "More feature work")
145
+
146
+ // Second push should recreate the emit branch
147
+ await runTestEffect(
148
+ push({ baseBranch: "main", silent: true, skipFilter: true }),
149
+ )
150
+
151
+ // Should still be back on agency/feature branch (source)
152
+ expect(await getCurrentBranch(tempDir)).toBe("agency/feature")
153
+ })
154
+ })
155
+
156
+ describe("error handling", () => {
157
+ test("switches to source branch when run from emit branch", async () => {
158
+ // First create the emit branch from source branch
159
+ await runTestEffect(
160
+ push({ baseBranch: "main", silent: true, skipFilter: true }),
161
+ )
162
+
163
+ // Now we're on agency/feature, switch to the emit branch (feature)
164
+ await checkoutBranch(tempDir, "feature")
165
+
166
+ // Verify we're on the emit branch
167
+ expect(await getCurrentBranch(tempDir)).toBe("feature")
168
+
169
+ // Make a change on source branch that we'll push
170
+ await checkoutBranch(tempDir, "agency/feature")
171
+ await createCommit(tempDir, "Another feature commit")
172
+
173
+ // Switch back to emit branch
174
+ await checkoutBranch(tempDir, "feature")
175
+
176
+ // Run push from emit branch - should detect we're on emit branch,
177
+ // switch to source (agency/feature), and continue
178
+ await runTestEffect(
179
+ push({ baseBranch: "main", silent: true, skipFilter: true }),
180
+ )
181
+
182
+ // Should be back on agency/feature branch (the source branch)
183
+ expect(await getCurrentBranch(tempDir)).toBe("agency/feature")
184
+ })
185
+
186
+ test("throws error when not in a git repository", async () => {
187
+ const nonGitDir = await createTempDir()
188
+ process.chdir(nonGitDir)
189
+
190
+ await expect(
191
+ runTestEffect(
192
+ push({ baseBranch: "main", silent: true, skipFilter: true }),
193
+ ),
194
+ ).rejects.toThrow("Not in a git repository")
195
+
196
+ await cleanupTempDir(nonGitDir)
197
+ })
198
+
199
+ test("handles push failure gracefully", async () => {
200
+ // Remove the remote to cause push to fail
201
+ await Bun.spawn(["git", "remote", "remove", "origin"], {
202
+ cwd: tempDir,
203
+ stdout: "pipe",
204
+ stderr: "pipe",
205
+ }).exited
206
+
207
+ // Push should fail because no remote exists
208
+ await expect(
209
+ runTestEffect(
210
+ push({ baseBranch: "main", silent: true, skipFilter: true }),
211
+ ),
212
+ ).rejects.toThrow(/No git remotes found/)
213
+ })
214
+ })
215
+
216
+ describe("silent mode", () => {
217
+ test("silent flag suppresses output", async () => {
218
+ // Capture output
219
+ const originalLog = console.log
220
+ let logCalled = false
221
+ console.log = () => {
222
+ logCalled = true
223
+ }
224
+
225
+ await runTestEffect(
226
+ push({ baseBranch: "main", silent: true, skipFilter: true }),
227
+ )
228
+
229
+ console.log = originalLog
230
+ expect(logCalled).toBe(false)
231
+ })
232
+ })
233
+
234
+ describe("force push", () => {
235
+ test("force pushes when branch has diverged and --force is provided", async () => {
236
+ // First push
237
+ await runTestEffect(
238
+ push({ baseBranch: "main", silent: true, skipFilter: true }),
239
+ )
240
+
241
+ // Make changes on source branch
242
+ await checkoutBranch(tempDir, "agency/feature")
243
+ await createCommit(tempDir, "More feature work")
244
+
245
+ // Modify the emit branch to create divergence
246
+ await checkoutBranch(tempDir, "feature")
247
+ await createCommit(tempDir, "Direct emit branch commit")
248
+ await Bun.spawn(["git", "push"], {
249
+ cwd: tempDir,
250
+ stdout: "pipe",
251
+ stderr: "pipe",
252
+ }).exited
253
+
254
+ // Go back to source branch and try to push again with --force
255
+ await checkoutBranch(tempDir, "agency/feature")
256
+
257
+ // Capture output to check for force push message
258
+ const originalLog = console.log
259
+ let logMessages: string[] = []
260
+ console.log = (msg: string) => {
261
+ logMessages.push(msg)
262
+ }
263
+
264
+ await runTestEffect(
265
+ push({
266
+ baseBranch: "main",
267
+ force: true,
268
+ silent: false,
269
+ skipFilter: true,
270
+ }),
271
+ )
272
+
273
+ console.log = originalLog
274
+
275
+ // Should be back on agency/feature branch (source)
276
+ expect(await getCurrentBranch(tempDir)).toBe("agency/feature")
277
+
278
+ // Should have reported force push
279
+ expect(logMessages.some((msg) => msg.includes("(forced)"))).toBe(true)
280
+ })
281
+
282
+ test("suggests using --force when push is rejected without it", async () => {
283
+ // First push
284
+ await runTestEffect(
285
+ push({ baseBranch: "main", silent: true, skipFilter: true }),
286
+ )
287
+
288
+ // Make changes on source branch
289
+ await checkoutBranch(tempDir, "agency/feature")
290
+ await createCommit(tempDir, "More feature work")
291
+
292
+ // Modify the emit branch to create divergence
293
+ await checkoutBranch(tempDir, "feature")
294
+ await createCommit(tempDir, "Direct emit branch commit")
295
+ await Bun.spawn(["git", "push"], {
296
+ cwd: tempDir,
297
+ stdout: "pipe",
298
+ stderr: "pipe",
299
+ }).exited
300
+
301
+ // Go back to source branch and try to push without --force
302
+ await checkoutBranch(tempDir, "agency/feature")
303
+
304
+ // Should throw error suggesting --force
305
+ await expect(
306
+ runTestEffect(
307
+ push({ baseBranch: "main", silent: true, skipFilter: true }),
308
+ ),
309
+ ).rejects.toThrow(/agency push --force/)
310
+
311
+ // Should still be on agency/feature branch (not left in intermediate state)
312
+ expect(await getCurrentBranch(tempDir)).toBe("agency/feature")
313
+ })
314
+
315
+ test("does not report force push when --force is provided but not needed", async () => {
316
+ // Capture output to check for force push message
317
+ const originalLog = console.log
318
+ let logMessages: string[] = []
319
+ console.log = (msg: string) => {
320
+ logMessages.push(msg)
321
+ }
322
+
323
+ // First push with --force (but it won't actually need force)
324
+ await runTestEffect(
325
+ push({
326
+ baseBranch: "main",
327
+ force: true,
328
+ silent: false,
329
+ skipFilter: true,
330
+ }),
331
+ )
332
+
333
+ console.log = originalLog
334
+
335
+ // Should be back on agency/feature branch (source)
336
+ expect(await getCurrentBranch(tempDir)).toBe("agency/feature")
337
+
338
+ // Should NOT have reported force push (since it wasn't actually used)
339
+ expect(logMessages.some((msg) => msg.includes("Force pushed"))).toBe(
340
+ false,
341
+ )
342
+ expect(logMessages.some((msg) => msg.includes("Pushed"))).toBe(true)
343
+ })
344
+ })
345
+
346
+ describe("--pr flag", () => {
347
+ test("handles gh CLI failure gracefully and continues", async () => {
348
+ // Capture error output
349
+ const originalError = console.error
350
+ let errorMessages: string[] = []
351
+ console.error = (msg: string) => {
352
+ errorMessages.push(msg)
353
+ }
354
+
355
+ // Should not throw - command should complete despite gh failure
356
+ // (gh will fail in test environment because there's no GitHub remote)
357
+ await runTestEffect(
358
+ push({ baseBranch: "main", pr: true, silent: true, skipFilter: true }),
359
+ )
360
+
361
+ console.error = originalError
362
+
363
+ // Should be back on agency/feature branch (command completes despite gh failure)
364
+ expect(await getCurrentBranch(tempDir)).toBe("agency/feature")
365
+
366
+ // Should have warned about gh failure
367
+ expect(
368
+ errorMessages.some((msg) => msg.includes("Failed to open GitHub PR")),
369
+ ).toBe(true)
370
+ })
371
+
372
+ test("does not call gh when --pr flag is not set", async () => {
373
+ // Capture error output
374
+ const originalError = console.error
375
+ let errorMessages: string[] = []
376
+ console.error = (msg: string) => {
377
+ errorMessages.push(msg)
378
+ }
379
+
380
+ // Push without --pr flag
381
+ await runTestEffect(
382
+ push({ baseBranch: "main", silent: true, skipFilter: true }),
383
+ )
384
+
385
+ console.error = originalError
386
+
387
+ // Should be back on agency/feature branch (source)
388
+ expect(await getCurrentBranch(tempDir)).toBe("agency/feature")
389
+
390
+ // gh should NOT have been called (no error about GitHub PR)
391
+ expect(errorMessages.some((msg) => msg.includes("GitHub PR"))).toBe(false)
392
+ })
393
+ })
394
+ })