@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,253 @@
1
+ import { Effect } from "effect"
2
+ import type { BaseCommandOptions } from "../utils/command"
3
+ import { GitService, GitCommandError } from "../services/GitService"
4
+ import { ConfigService } from "../services/ConfigService"
5
+ import { resolveBranchPairWithAgencyJson } from "../utils/pr-branch"
6
+ import { FileSystemService } from "../services/FileSystemService"
7
+ import { emit } from "./emit"
8
+ import highlight, { done } from "../utils/colors"
9
+ import {
10
+ createLoggers,
11
+ ensureGitRepo,
12
+ ensureBranchExists,
13
+ getBaseBranchFromMetadataEffect,
14
+ getBaseBranchFromBranch,
15
+ getRemoteName,
16
+ } from "../utils/effect"
17
+
18
+ interface MergeOptions extends BaseCommandOptions {
19
+ squash?: boolean
20
+ push?: boolean
21
+ skipFilter?: boolean
22
+ }
23
+
24
+ // Helper to merge a branch using git
25
+ const mergeBranchEffect = (
26
+ gitRoot: string,
27
+ branch: string,
28
+ squash: boolean = false,
29
+ ) =>
30
+ Effect.gen(function* () {
31
+ const git = yield* GitService
32
+ const args = squash
33
+ ? ["git", "merge", "--squash", branch]
34
+ : ["git", "merge", branch]
35
+
36
+ const result = yield* git.runGitCommand(args, gitRoot, {
37
+ captureOutput: true,
38
+ })
39
+
40
+ if (result.exitCode !== 0) {
41
+ return yield* Effect.fail(
42
+ new GitCommandError({
43
+ command: args.join(" "),
44
+ exitCode: result.exitCode,
45
+ stderr: result.stderr,
46
+ }),
47
+ )
48
+ }
49
+ })
50
+
51
+ export const merge = (options: MergeOptions = {}) =>
52
+ Effect.gen(function* () {
53
+ const { squash = false, push = false, verbose = false } = options
54
+ const { log, verboseLog } = createLoggers(options)
55
+
56
+ const git = yield* GitService
57
+ const configService = yield* ConfigService
58
+
59
+ const gitRoot = yield* ensureGitRepo()
60
+
61
+ const config = yield* configService.loadConfig()
62
+ const currentBranch = yield* git.getCurrentBranch(gitRoot)
63
+
64
+ verboseLog(`Current branch: ${highlight.branch(currentBranch)}`)
65
+
66
+ // Use proper branch resolution with agency.json support
67
+ const fs = yield* FileSystemService
68
+ const branchInfo = yield* resolveBranchPairWithAgencyJson(
69
+ gitRoot,
70
+ currentBranch,
71
+ config.sourceBranchPattern,
72
+ config.emitBranch,
73
+ )
74
+
75
+ let emitBranchToMerge: string
76
+ let baseBranchToMergeInto: string
77
+
78
+ if (branchInfo.isOnEmitBranch) {
79
+ const sourceBranch = branchInfo.sourceBranch
80
+ verboseLog(
81
+ `Current branch appears to be an emit branch for source: ${highlight.branch(sourceBranch)}`,
82
+ )
83
+
84
+ yield* ensureBranchExists(
85
+ gitRoot,
86
+ sourceBranch,
87
+ `Current branch ${highlight.branch(currentBranch)} appears to be an emit branch, but source branch ${highlight.branch(sourceBranch)} does not exist.\n` +
88
+ `Cannot merge without a valid source branch.`,
89
+ )
90
+
91
+ // Get the base branch from the source branch's agency.json using git show
92
+ // No need to checkout the branch - we can read the file directly
93
+ const configuredBase = yield* getBaseBranchFromBranch(
94
+ gitRoot,
95
+ sourceBranch,
96
+ )
97
+
98
+ if (!configuredBase) {
99
+ return yield* Effect.fail(
100
+ new Error(
101
+ `No base branch configured for ${highlight.branch(sourceBranch)}.\n` +
102
+ `Please switch to ${highlight.branch(sourceBranch)} and run: agency base set <branch>`,
103
+ ),
104
+ )
105
+ }
106
+
107
+ verboseLog(`Configured base branch: ${highlight.branch(configuredBase)}`)
108
+
109
+ // For git operations (checkout/merge), use local branch name
110
+ baseBranchToMergeInto = git.stripRemotePrefix(configuredBase)
111
+
112
+ // Verify local base branch exists
113
+ yield* ensureBranchExists(
114
+ gitRoot,
115
+ baseBranchToMergeInto,
116
+ `Base branch ${highlight.branch(baseBranchToMergeInto)} does not exist locally.\n` +
117
+ `You may need to checkout the branch first or update your base branch configuration.`,
118
+ )
119
+
120
+ emitBranchToMerge = currentBranch
121
+ } else {
122
+ // We're on a source branch - need to create/update emit branch first
123
+ verboseLog(
124
+ `Current branch appears to be a source branch, will create emit branch first`,
125
+ )
126
+
127
+ // Use the emit branch from branchInfo we already computed
128
+ const emitBranch = branchInfo.emitBranch
129
+ const emitExists = yield* git.branchExists(gitRoot, emitBranch)
130
+
131
+ if (emitExists) {
132
+ verboseLog(
133
+ `Emit branch ${highlight.branch(emitBranch)} already exists, will recreate it`,
134
+ )
135
+ }
136
+
137
+ // Run 'agency emit' to create/update the emit branch
138
+ verboseLog(`Creating emit branch ${highlight.branch(emitBranch)}...`)
139
+ yield* emit({ silent: true, verbose, skipFilter: options.skipFilter })
140
+
141
+ // emit() leaves us on the source branch, so we can read agency.json directly
142
+ const configuredBase = yield* getBaseBranchFromMetadataEffect(gitRoot)
143
+ if (!configuredBase) {
144
+ return yield* Effect.fail(
145
+ new Error(
146
+ `No base branch configured for ${highlight.branch(currentBranch)}.\n` +
147
+ `Please set one with: agency base set <branch>`,
148
+ ),
149
+ )
150
+ }
151
+
152
+ verboseLog(`Configured base branch: ${highlight.branch(configuredBase)}`)
153
+
154
+ // For git operations (checkout/merge), use local branch name
155
+ baseBranchToMergeInto = git.stripRemotePrefix(configuredBase)
156
+
157
+ // Verify local base branch exists
158
+ yield* ensureBranchExists(
159
+ gitRoot,
160
+ baseBranchToMergeInto,
161
+ `Base branch ${highlight.branch(baseBranchToMergeInto)} does not exist locally.\n` +
162
+ `You may need to checkout the branch first or update your base branch configuration.`,
163
+ )
164
+
165
+ emitBranchToMerge = emitBranch
166
+ }
167
+
168
+ // Now switch to the base branch
169
+ verboseLog(`Switching to ${highlight.branch(baseBranchToMergeInto)}...`)
170
+ yield* git.checkoutBranch(gitRoot, baseBranchToMergeInto)
171
+
172
+ // Merge the emit branch
173
+ verboseLog(
174
+ `Merging ${highlight.branch(emitBranchToMerge)} into ${highlight.branch(baseBranchToMergeInto)}${squash ? " (squash)" : ""}...`,
175
+ )
176
+ yield* mergeBranchEffect(gitRoot, emitBranchToMerge, squash)
177
+
178
+ if (squash) {
179
+ log(done(`Squash merged (awaiting commit)`))
180
+ } else {
181
+ log(done("Merged"))
182
+ }
183
+
184
+ // Push the base branch if --push flag is set
185
+ if (push) {
186
+ const remote = yield* getRemoteName(gitRoot)
187
+ verboseLog(
188
+ `Pushing ${highlight.branch(baseBranchToMergeInto)} to ${highlight.remote(remote)}...`,
189
+ )
190
+
191
+ const pushResult = yield* git.runGitCommand(
192
+ ["git", "push", remote, baseBranchToMergeInto],
193
+ gitRoot,
194
+ { captureOutput: true },
195
+ )
196
+
197
+ if (pushResult.exitCode !== 0) {
198
+ return yield* Effect.fail(
199
+ new GitCommandError({
200
+ command: `git push ${remote} ${baseBranchToMergeInto}`,
201
+ exitCode: pushResult.exitCode,
202
+ stderr: pushResult.stderr,
203
+ }),
204
+ )
205
+ }
206
+
207
+ log(done(`Pushed to ${highlight.remote(remote)}`))
208
+ }
209
+ })
210
+
211
+ export const help = `
212
+ Usage: agency merge [options]
213
+
214
+ Merge the current emit branch into the configured base branch.
215
+
216
+ This command handles two scenarios:
217
+ 1. If on an emit branch (e.g., feature--PR): Switches to the base branch and merges the emit branch
218
+ 2. If on a source branch (e.g., feature): Runs 'agency emit' first to create/update the emit branch, then merges it
219
+
220
+ Behavior:
221
+ - Automatically detects whether you're on a source or emit branch
222
+ - Retrieves the configured base branch (e.g., 'main') from git config
223
+ - Switches to the base branch
224
+ - Merges the emit branch into the base branch
225
+ - Leaves you on the base branch after merge
226
+
227
+ This is useful for local development workflows where you want to test merging
228
+ your clean emit branch (without AGENTS.md modifications) into the base branch
229
+ before pushing.
230
+
231
+ Prerequisites:
232
+ - Must be on either a source branch or its corresponding emit branch
233
+ - Base branch must exist locally
234
+ - For source branches: Must have a corresponding emit branch or be able to create one
235
+
236
+ Options:
237
+ --squash # Use squash merge instead of regular merge
238
+ --push # Push the base branch to origin after merging
239
+
240
+ Examples:
241
+ agency merge # From source branch: creates emit branch then merges
242
+ agency merge --squash # Squash merge (stages changes, requires manual commit)
243
+ agency merge --push # Merge and push the base branch to origin
244
+
245
+ Notes:
246
+ - The command determines the base branch from git config (agency.pr.<branch>.baseBranch)
247
+ - If you're on a source branch, 'agency emit' is run automatically
248
+ - The emit branch must have both a source branch and base branch configured
249
+ - After merge, you remain on the base branch
250
+ - Merge conflicts must be resolved manually if they occur
251
+ - With --squash, changes are staged but not committed (you must commit manually)
252
+ - With --push, the base branch is pushed to origin after a successful merge
253
+ `
@@ -0,0 +1,385 @@
1
+ import { test, expect, describe, beforeEach, afterEach } from "bun:test"
2
+ import { join } from "path"
3
+ import { pull } from "./pull"
4
+ import {
5
+ createTempDir,
6
+ cleanupTempDir,
7
+ initGitRepo,
8
+ getCurrentBranch,
9
+ createCommit,
10
+ checkoutBranch,
11
+ runTestEffect,
12
+ } from "../test-utils"
13
+
14
+ async function createBranch(cwd: string, branchName: string): Promise<void> {
15
+ await Bun.spawn(["git", "checkout", "-b", branchName], {
16
+ cwd,
17
+ stdout: "pipe",
18
+ stderr: "pipe",
19
+ }).exited
20
+ }
21
+
22
+ async function setupAgencyJson(
23
+ gitRoot: string,
24
+ emitBranch?: string,
25
+ ): Promise<void> {
26
+ const agencyJson = {
27
+ version: 1,
28
+ injectedFiles: ["AGENTS.MD", "TASK.md"],
29
+ template: "test",
30
+ createdAt: new Date().toISOString(),
31
+ ...(emitBranch ? { emitBranch } : {}),
32
+ }
33
+ await Bun.write(
34
+ join(gitRoot, "agency.json"),
35
+ JSON.stringify(agencyJson, null, 2) + "\n",
36
+ )
37
+ await Bun.spawn(["git", "add", "agency.json"], {
38
+ cwd: gitRoot,
39
+ stdout: "pipe",
40
+ stderr: "pipe",
41
+ }).exited
42
+ await createCommit(gitRoot, "Add agency.json")
43
+ }
44
+
45
+ async function setupBareRemote(tempDir: string): Promise<string> {
46
+ // Create a bare repository to use as remote
47
+ const remoteDir = join(tempDir, "remote.git")
48
+ await Bun.spawn(["git", "init", "--bare", remoteDir], {
49
+ stdout: "pipe",
50
+ stderr: "pipe",
51
+ }).exited
52
+
53
+ return remoteDir
54
+ }
55
+
56
+ async function addRemote(cwd: string, remoteUrl: string): Promise<void> {
57
+ await Bun.spawn(["git", "remote", "add", "origin", remoteUrl], {
58
+ cwd,
59
+ stdout: "pipe",
60
+ stderr: "pipe",
61
+ }).exited
62
+ }
63
+
64
+ async function getCommitCount(cwd: string, branch: string): Promise<number> {
65
+ const proc = Bun.spawn(["git", "rev-list", "--count", branch], {
66
+ cwd,
67
+ stdout: "pipe",
68
+ stderr: "pipe",
69
+ })
70
+ await proc.exited
71
+ const output = await new Response(proc.stdout).text()
72
+ return parseInt(output.trim(), 10)
73
+ }
74
+
75
+ async function getLatestCommitMessage(cwd: string): Promise<string> {
76
+ const proc = Bun.spawn(["git", "log", "-1", "--pretty=%B"], {
77
+ cwd,
78
+ stdout: "pipe",
79
+ stderr: "pipe",
80
+ })
81
+ await proc.exited
82
+ const output = await new Response(proc.stdout).text()
83
+ return output.trim()
84
+ }
85
+
86
+ describe("pull command", () => {
87
+ let tempDir: string
88
+ let remoteDir: string
89
+ let originalCwd: string
90
+
91
+ beforeEach(async () => {
92
+ tempDir = await createTempDir()
93
+ originalCwd = process.cwd()
94
+ process.chdir(tempDir)
95
+
96
+ // Set config path to non-existent file to use defaults
97
+ process.env.AGENCY_CONFIG_PATH = join(tempDir, "non-existent-config.json")
98
+
99
+ // Initialize git repo
100
+ await initGitRepo(tempDir)
101
+ await createCommit(tempDir, "Initial commit")
102
+
103
+ // Rename to main if needed
104
+ const currentBranch = await getCurrentBranch(tempDir)
105
+ if (currentBranch === "master") {
106
+ await Bun.spawn(["git", "branch", "-m", "main"], {
107
+ cwd: tempDir,
108
+ stdout: "pipe",
109
+ stderr: "pipe",
110
+ }).exited
111
+ }
112
+
113
+ // Setup bare remote
114
+ remoteDir = await setupBareRemote(tempDir)
115
+ await addRemote(tempDir, remoteDir)
116
+
117
+ // Push main to remote
118
+ await Bun.spawn(["git", "push", "-u", "origin", "main"], {
119
+ cwd: tempDir,
120
+ stdout: "pipe",
121
+ stderr: "pipe",
122
+ }).exited
123
+ })
124
+
125
+ afterEach(async () => {
126
+ process.chdir(originalCwd)
127
+ delete process.env.AGENCY_CONFIG_PATH
128
+ await cleanupTempDir(tempDir)
129
+ })
130
+
131
+ describe("basic functionality", () => {
132
+ test("pulls commits from remote emit branch to source branch", async () => {
133
+ // Setup agency.json
134
+ await setupAgencyJson(tempDir)
135
+
136
+ // Create source branch: A1 A2
137
+ await createBranch(tempDir, "agency/feature")
138
+ await createCommit(tempDir, "A1: Feature work")
139
+
140
+ // Simulate agency emit: create local emit branch (B1) with rewritten history
141
+ await createBranch(tempDir, "feature")
142
+ // For test simplicity, we're not actually rewriting history, just creating emit branch
143
+
144
+ // Push emit branch to remote
145
+ await Bun.spawn(["git", "push", "-u", "origin", "feature"], {
146
+ cwd: tempDir,
147
+ stdout: "pipe",
148
+ stderr: "pipe",
149
+ }).exited
150
+
151
+ // Now local emit and remote emit are in sync (both at B1)
152
+ // Simulate someone adding commits to remote emit branch (B2, B3)
153
+ await Bun.write(join(tempDir, "file1.txt"), "content1")
154
+ await Bun.spawn(["git", "add", "file1.txt"], {
155
+ cwd: tempDir,
156
+ stdout: "pipe",
157
+ stderr: "pipe",
158
+ }).exited
159
+ await createCommit(tempDir, "B2: Remote commit 1")
160
+
161
+ await Bun.write(join(tempDir, "file2.txt"), "content2")
162
+ await Bun.spawn(["git", "add", "file2.txt"], {
163
+ cwd: tempDir,
164
+ stdout: "pipe",
165
+ stderr: "pipe",
166
+ }).exited
167
+ await createCommit(tempDir, "B3: Remote commit 2")
168
+
169
+ await Bun.spawn(["git", "push"], {
170
+ cwd: tempDir,
171
+ stdout: "pipe",
172
+ stderr: "pipe",
173
+ }).exited
174
+
175
+ // Reset local emit branch to B1 (before the remote commits B2, B3)
176
+ // This simulates the state where remote has moved ahead
177
+ await Bun.spawn(["git", "reset", "--hard", "HEAD~2"], {
178
+ cwd: tempDir,
179
+ stdout: "pipe",
180
+ stderr: "pipe",
181
+ }).exited
182
+
183
+ // Go back to source branch (still at A1)
184
+ await checkoutBranch(tempDir, "agency/feature")
185
+ const beforeCommitCount = await getCommitCount(tempDir, "agency/feature")
186
+
187
+ // Run pull command - should find B2 and B3 on remote that aren't on local emit
188
+ // and cherry-pick them onto source as A2 and A3
189
+ await runTestEffect(pull({ silent: true }))
190
+
191
+ // Should still be on source branch
192
+ expect(await getCurrentBranch(tempDir)).toBe("agency/feature")
193
+
194
+ // Should have the new commits
195
+ const afterCommitCount = await getCommitCount(tempDir, "agency/feature")
196
+ expect(afterCommitCount).toBe(beforeCommitCount + 2)
197
+
198
+ // Last commit should be the second remote commit
199
+ const lastCommit = await getLatestCommitMessage(tempDir)
200
+ expect(lastCommit).toBe("B3: Remote commit 2")
201
+ })
202
+
203
+ test("handles no new commits gracefully", async () => {
204
+ // Setup agency.json
205
+ await setupAgencyJson(tempDir)
206
+
207
+ // Create a source branch
208
+ await createBranch(tempDir, "agency/feature")
209
+ await createCommit(tempDir, "Feature work")
210
+
211
+ // Create emit branch with same commits
212
+ await createBranch(tempDir, "feature")
213
+
214
+ // Push emit branch to remote
215
+ await Bun.spawn(["git", "push", "-u", "origin", "feature"], {
216
+ cwd: tempDir,
217
+ stdout: "pipe",
218
+ stderr: "pipe",
219
+ }).exited
220
+
221
+ // Go back to source branch
222
+ await checkoutBranch(tempDir, "agency/feature")
223
+
224
+ // Capture output
225
+ const originalLog = console.log
226
+ let logMessages: string[] = []
227
+ console.log = (msg: string) => {
228
+ logMessages.push(msg)
229
+ }
230
+
231
+ // Run pull command - should report no new commits
232
+ await runTestEffect(pull({ silent: false }))
233
+
234
+ console.log = originalLog
235
+
236
+ // Should report no new commits
237
+ expect(logMessages.some((msg) => msg.includes("No new commits"))).toBe(
238
+ true,
239
+ )
240
+
241
+ // Should still be on source branch
242
+ expect(await getCurrentBranch(tempDir)).toBe("agency/feature")
243
+ })
244
+
245
+ test("works with custom remote", async () => {
246
+ // Setup agency.json
247
+ await setupAgencyJson(tempDir)
248
+
249
+ // Create a source branch
250
+ await createBranch(tempDir, "agency/feature")
251
+ await createCommit(tempDir, "Feature work")
252
+
253
+ // Create local emit branch
254
+ await createBranch(tempDir, "feature")
255
+
256
+ // Add a second remote
257
+ const upstreamDir = await setupBareRemote(tempDir)
258
+ await Bun.spawn(["git", "remote", "add", "upstream", upstreamDir], {
259
+ cwd: tempDir,
260
+ stdout: "pipe",
261
+ stderr: "pipe",
262
+ }).exited
263
+
264
+ // Push emit branch to upstream
265
+ await Bun.spawn(["git", "push", "-u", "upstream", "feature"], {
266
+ cwd: tempDir,
267
+ stdout: "pipe",
268
+ stderr: "pipe",
269
+ }).exited
270
+
271
+ // Add a commit to upstream remote
272
+ await Bun.write(join(tempDir, "upstream-file.txt"), "upstream content")
273
+ await Bun.spawn(["git", "add", "upstream-file.txt"], {
274
+ cwd: tempDir,
275
+ stdout: "pipe",
276
+ stderr: "pipe",
277
+ }).exited
278
+ await createCommit(tempDir, "Emit commit")
279
+ await Bun.spawn(["git", "push", "upstream", "feature"], {
280
+ cwd: tempDir,
281
+ stdout: "pipe",
282
+ stderr: "pipe",
283
+ }).exited
284
+
285
+ // Reset local emit branch to before the upstream commit
286
+ await Bun.spawn(["git", "reset", "--hard", "HEAD~1"], {
287
+ cwd: tempDir,
288
+ stdout: "pipe",
289
+ stderr: "pipe",
290
+ }).exited
291
+
292
+ // Go back to source branch
293
+ await checkoutBranch(tempDir, "agency/feature")
294
+
295
+ // Run pull with custom remote
296
+ await runTestEffect(pull({ remote: "upstream", silent: true }))
297
+
298
+ // Should still be on source branch
299
+ expect(await getCurrentBranch(tempDir)).toBe("agency/feature")
300
+
301
+ // Should have the new commit
302
+ const lastCommit = await getLatestCommitMessage(tempDir)
303
+ expect(lastCommit).toBe("Emit commit")
304
+ })
305
+ })
306
+
307
+ describe("error handling", () => {
308
+ test("throws error when not in a git repository", async () => {
309
+ const nonGitDir = await createTempDir()
310
+ process.chdir(nonGitDir)
311
+
312
+ await expect(runTestEffect(pull({ silent: true }))).rejects.toThrow(
313
+ "Not in a git repository",
314
+ )
315
+
316
+ await cleanupTempDir(nonGitDir)
317
+ })
318
+
319
+ test("handles case when only source branch exists (no emit on remote)", async () => {
320
+ // Setup agency.json
321
+ await setupAgencyJson(tempDir)
322
+
323
+ // Create a source branch
324
+ await createBranch(tempDir, "agency/feature")
325
+ await setupAgencyJson(tempDir, "feature")
326
+ await createCommit(tempDir, "Feature work")
327
+
328
+ // Don't create or push emit branch - just test that pull handles this gracefully
329
+ // Run pull - should fail because remote emit branch doesn't exist
330
+ await expect(runTestEffect(pull({ silent: true }))).rejects.toThrow()
331
+ })
332
+
333
+ test("throws error when remote emit branch does not exist", async () => {
334
+ // Setup agency.json
335
+ await setupAgencyJson(tempDir)
336
+
337
+ // Create a source branch
338
+ await createBranch(tempDir, "agency/feature")
339
+ await setupAgencyJson(tempDir, "feature")
340
+ await createCommit(tempDir, "Feature work")
341
+
342
+ // Run pull - should fail because remote emit branch doesn't exist
343
+ await expect(runTestEffect(pull({ silent: true }))).rejects.toThrow(
344
+ "Failed to fetch",
345
+ )
346
+ })
347
+ })
348
+
349
+ describe("silent mode", () => {
350
+ test("silent flag suppresses output", async () => {
351
+ // Setup agency.json
352
+ await setupAgencyJson(tempDir)
353
+
354
+ // Create a source branch
355
+ await createBranch(tempDir, "agency/feature")
356
+ await createCommit(tempDir, "Feature work")
357
+
358
+ // Create emit branch
359
+ await createBranch(tempDir, "feature")
360
+ await createCommit(tempDir, "Emit commit")
361
+
362
+ // Push emit branch to remote
363
+ await Bun.spawn(["git", "push", "-u", "origin", "feature"], {
364
+ cwd: tempDir,
365
+ stdout: "pipe",
366
+ stderr: "pipe",
367
+ }).exited
368
+
369
+ // Go back to source branch
370
+ await checkoutBranch(tempDir, "agency/feature")
371
+
372
+ // Capture output
373
+ const originalLog = console.log
374
+ let logCalled = false
375
+ console.log = () => {
376
+ logCalled = true
377
+ }
378
+
379
+ await runTestEffect(pull({ silent: true }))
380
+
381
+ console.log = originalLog
382
+ expect(logCalled).toBe(false)
383
+ })
384
+ })
385
+ })