@markjaquith/agency 2.54.3 → 2.55.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.
@@ -0,0 +1,516 @@
1
+ import { afterEach, describe, expect, test } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import { mkdir } from "node:fs/promises"
4
+ import { join } from "node:path"
5
+ import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
6
+ import { TaskService } from "./TaskService"
7
+ import { PushService } from "./PushService"
8
+ import { WorktreeService } from "./WorktreeService"
9
+
10
+ interface CommandResult {
11
+ readonly stdout: string
12
+ readonly stderr: string
13
+ readonly exitCode: number
14
+ }
15
+
16
+ const runCommand = async (
17
+ args: readonly string[],
18
+ cwd?: string,
19
+ ): Promise<CommandResult> => {
20
+ const child = Bun.spawn([...args], {
21
+ cwd,
22
+ stdout: "pipe",
23
+ stderr: "pipe",
24
+ })
25
+ const [stdout, stderr, exitCode] = await Promise.all([
26
+ new Response(child.stdout).text(),
27
+ new Response(child.stderr).text(),
28
+ child.exited,
29
+ ])
30
+ return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }
31
+ }
32
+
33
+ const requireCommand = async (args: readonly string[], cwd?: string) => {
34
+ const result = await runCommand(args, cwd)
35
+ if (result.exitCode !== 0) {
36
+ throw new Error(`${args.join(" ")} failed: ${result.stderr}`)
37
+ }
38
+ return result
39
+ }
40
+
41
+ describe("PushService", () => {
42
+ const roots: string[] = []
43
+
44
+ afterEach(async () => {
45
+ await Promise.all(roots.splice(0).map(cleanupTempDir))
46
+ })
47
+
48
+ const setup = async (vcs: "git" | "jj") => {
49
+ const root = await createTempDir()
50
+ roots.push(root)
51
+ const remote = join(root, "remote.git")
52
+ const seed = join(root, "seed")
53
+ const repository = join(root, "repos", "agency")
54
+ await mkdir(join(root, "repos"), { recursive: true })
55
+ await Bun.write(
56
+ join(root, "agency.json"),
57
+ `${JSON.stringify({ version: 2, vcs }, null, 2)}\n`,
58
+ )
59
+ await requireCommand([
60
+ "git",
61
+ "init",
62
+ "--bare",
63
+ "--initial-branch=main",
64
+ remote,
65
+ ])
66
+ await requireCommand(["git", "init", "--initial-branch=main", seed])
67
+ await requireCommand(["git", "config", "user.name", "Agency Test"], seed)
68
+ await requireCommand(
69
+ ["git", "config", "user.email", "agency@example.com"],
70
+ seed,
71
+ )
72
+ await Bun.write(join(seed, "README.md"), "# Test repository\n")
73
+ await requireCommand(["git", "add", "README.md"], seed)
74
+ await requireCommand(["git", "commit", "-m", "Initial commit"], seed)
75
+ await requireCommand(["git", "remote", "add", "origin", remote], seed)
76
+ await requireCommand(["git", "push", "-u", "origin", "main"], seed)
77
+ await requireCommand(["git", "clone", "--bare", remote, repository])
78
+ if (vcs === "jj") {
79
+ await requireCommand(["jj", "git", "init", "--colocate", repository])
80
+ await requireCommand(
81
+ ["jj", "git", "remote", "add", "origin", remote],
82
+ repository,
83
+ )
84
+ await requireCommand(
85
+ ["jj", "git", "fetch", "--remote", "origin"],
86
+ repository,
87
+ )
88
+ await requireCommand(
89
+ ["jj", "bookmark", "create", "main", "-r", "main@origin"],
90
+ repository,
91
+ )
92
+ await requireCommand(
93
+ ["jj", "config", "set", "--repo", "user.name", "Agency Test"],
94
+ repository,
95
+ )
96
+ await requireCommand(
97
+ ["jj", "config", "set", "--repo", "user.email", "agency@example.com"],
98
+ repository,
99
+ )
100
+ }
101
+
102
+ await runTestEffect(
103
+ TaskService.pipe(
104
+ Effect.flatMap((service) =>
105
+ service.create(
106
+ {
107
+ id: "example",
108
+ ticketUrl: null,
109
+ repo: "agency",
110
+ branch: "task/example",
111
+ base: "main",
112
+ },
113
+ root,
114
+ ),
115
+ ),
116
+ ),
117
+ )
118
+ await runTestEffect(
119
+ TaskService.pipe(
120
+ Effect.flatMap((service) =>
121
+ service.setStatus("example", "working", root),
122
+ ),
123
+ ),
124
+ )
125
+ const workspace = await runTestEffect(
126
+ WorktreeService.pipe(
127
+ Effect.flatMap((service) =>
128
+ service.materialize("example", undefined, root),
129
+ ),
130
+ ),
131
+ )
132
+ return {
133
+ root,
134
+ remote,
135
+ checkout: workspace.writablePath!,
136
+ taskPath: join(root, "tasks", "example"),
137
+ }
138
+ }
139
+
140
+ const publish = (taskPath: string) =>
141
+ runTestEffect(
142
+ PushService.pipe(Effect.flatMap((service) => service.publish(taskPath))),
143
+ )
144
+
145
+ const remoteBranch = async (remote: string, branch = "task/example") =>
146
+ (
147
+ await requireCommand([
148
+ "git",
149
+ "--git-dir",
150
+ remote,
151
+ "rev-parse",
152
+ `refs/heads/${branch}`,
153
+ ])
154
+ ).stdout
155
+
156
+ test("publishes a clean Git HEAD and establishes upstream tracking", async () => {
157
+ const fixture = await setup("git")
158
+ await requireCommand(
159
+ ["git", "config", "user.name", "Agency Test"],
160
+ fixture.checkout,
161
+ )
162
+ await requireCommand(
163
+ ["git", "config", "user.email", "agency@example.com"],
164
+ fixture.checkout,
165
+ )
166
+ await Bun.write(join(fixture.checkout, "feature.txt"), "published\n")
167
+ await requireCommand(["git", "add", "feature.txt"], fixture.checkout)
168
+ await requireCommand(
169
+ ["git", "commit", "-m", "Add published feature"],
170
+ fixture.checkout,
171
+ )
172
+
173
+ const result = await publish(fixture.taskPath)
174
+ expect(result).toMatchObject({
175
+ vcs: "git",
176
+ branch: "task/example",
177
+ base: "main",
178
+ remote: "origin",
179
+ })
180
+ expect(await remoteBranch(fixture.remote)).toBe(result.tip)
181
+ expect(
182
+ (
183
+ await requireCommand(
184
+ ["git", "rev-parse", "--abbrev-ref", "@{upstream}"],
185
+ fixture.checkout,
186
+ )
187
+ ).stdout,
188
+ ).toBe("origin/task/example")
189
+ })
190
+
191
+ test("rejects dirty, mismatched, and undescribed Git publication", async () => {
192
+ const dirty = await setup("git")
193
+ await Bun.write(join(dirty.checkout, "dirty.txt"), "dirty\n")
194
+ await expect(publish(dirty.taskPath)).rejects.toThrow("dirty Git worktree")
195
+
196
+ const mismatched = await setup("git")
197
+ await requireCommand(
198
+ ["git", "branch", "-m", "wrong-branch"],
199
+ mismatched.checkout,
200
+ )
201
+ await expect(publish(mismatched.taskPath)).rejects.toThrow(
202
+ "does not match checked-out Git branch",
203
+ )
204
+
205
+ const undescribed = await setup("git")
206
+ await requireCommand(
207
+ ["git", "config", "user.name", "Agency Test"],
208
+ undescribed.checkout,
209
+ )
210
+ await requireCommand(
211
+ ["git", "config", "user.email", "agency@example.com"],
212
+ undescribed.checkout,
213
+ )
214
+ await requireCommand(
215
+ ["git", "commit", "--allow-empty", "--allow-empty-message", "-m", ""],
216
+ undescribed.checkout,
217
+ )
218
+ await expect(publish(undescribed.taskPath)).rejects.toThrow(
219
+ "has an empty message",
220
+ )
221
+ })
222
+
223
+ test("rejects Git remote divergence after refreshing remote state", async () => {
224
+ const fixture = await setup("git")
225
+ for (const [key, value] of [
226
+ ["user.name", "Agency Test"],
227
+ ["user.email", "agency@example.com"],
228
+ ] as const) {
229
+ await requireCommand(["git", "config", key, value], fixture.checkout)
230
+ }
231
+ await Bun.write(join(fixture.checkout, "feature.txt"), "first\n")
232
+ await requireCommand(["git", "add", "feature.txt"], fixture.checkout)
233
+ await requireCommand(
234
+ ["git", "commit", "-m", "First change"],
235
+ fixture.checkout,
236
+ )
237
+ await publish(fixture.taskPath)
238
+
239
+ const other = join(fixture.root, "other")
240
+ await requireCommand(["git", "clone", fixture.remote, other])
241
+ await requireCommand(["git", "checkout", "task/example"], other)
242
+ await requireCommand(["git", "config", "user.name", "Other"], other)
243
+ await requireCommand(
244
+ ["git", "config", "user.email", "other@example.com"],
245
+ other,
246
+ )
247
+ await Bun.write(join(other, "remote.txt"), "remote\n")
248
+ await requireCommand(["git", "add", "remote.txt"], other)
249
+ await requireCommand(["git", "commit", "-m", "Remote change"], other)
250
+ await requireCommand(["git", "push", "origin", "task/example"], other)
251
+
252
+ await Bun.write(join(fixture.checkout, "local.txt"), "local\n")
253
+ await requireCommand(["git", "add", "local.txt"], fixture.checkout)
254
+ await requireCommand(
255
+ ["git", "commit", "-m", "Local change"],
256
+ fixture.checkout,
257
+ )
258
+ await expect(publish(fixture.taskPath)).rejects.toThrow(
259
+ "refusing a non-fast-forward update",
260
+ )
261
+ })
262
+
263
+ test("requires working status and the declared Git base in history", async () => {
264
+ const open = await setup("git")
265
+ await runTestEffect(
266
+ TaskService.pipe(
267
+ Effect.flatMap((service) =>
268
+ service.setStatus("example", "open", open.root),
269
+ ),
270
+ ),
271
+ )
272
+ await expect(publish(open.taskPath)).rejects.toThrow(
273
+ "status 'open'; status must be working",
274
+ )
275
+
276
+ const unrelated = await setup("git")
277
+ await requireCommand(
278
+ ["git", "config", "user.name", "Agency Test"],
279
+ unrelated.checkout,
280
+ )
281
+ await requireCommand(
282
+ ["git", "config", "user.email", "agency@example.com"],
283
+ unrelated.checkout,
284
+ )
285
+ await requireCommand(
286
+ ["git", "checkout", "--orphan", "unrelated"],
287
+ unrelated.checkout,
288
+ )
289
+ await requireCommand(["git", "rm", "-rf", "."], unrelated.checkout)
290
+ await Bun.write(join(unrelated.checkout, "unrelated.txt"), "unrelated\n")
291
+ await requireCommand(["git", "add", "unrelated.txt"], unrelated.checkout)
292
+ await requireCommand(
293
+ ["git", "commit", "-m", "Unrelated history"],
294
+ unrelated.checkout,
295
+ )
296
+ await requireCommand(
297
+ ["git", "branch", "-M", "task/example"],
298
+ unrelated.checkout,
299
+ )
300
+ await expect(publish(unrelated.taskPath)).rejects.toThrow(
301
+ "is not an ancestor of Git HEAD",
302
+ )
303
+ })
304
+
305
+ test("rejects invalid Git and jj authors", async () => {
306
+ const git = await setup("git")
307
+ await requireCommand(
308
+ ["git", "config", "user.name", "Agency Test"],
309
+ git.checkout,
310
+ )
311
+ await requireCommand(
312
+ ["git", "config", "user.email", "agency@example.com"],
313
+ git.checkout,
314
+ )
315
+ await requireCommand(
316
+ [
317
+ "git",
318
+ "commit",
319
+ "--allow-empty",
320
+ "--author",
321
+ "Bad <bad>",
322
+ "-m",
323
+ "Invalid author",
324
+ ],
325
+ git.checkout,
326
+ )
327
+ await expect(publish(git.taskPath)).rejects.toThrow("has an invalid author")
328
+
329
+ if (!Bun.which("jj")) return
330
+ const jj = await setup("jj")
331
+ await requireCommand(
332
+ ["jj", "describe", "-m", "Invalid author"],
333
+ jj.checkout,
334
+ )
335
+ await requireCommand(
336
+ ["jj", "metaedit", "--author", "Bad <bad>"],
337
+ jj.checkout,
338
+ )
339
+ const changeId = (
340
+ await requireCommand(
341
+ ["jj", "log", "--no-graph", "-r", "@", "-T", "change_id"],
342
+ jj.checkout,
343
+ )
344
+ ).stdout
345
+ await expect(publish(jj.taskPath)).rejects.toThrow(
346
+ `Change ${changeId} has an invalid author. Run: jj metaedit -r ${changeId} --author 'Name <email>'`,
347
+ )
348
+ })
349
+
350
+ test("publishes a described jj working-copy change under the declared bookmark", async () => {
351
+ if (!Bun.which("jj")) return
352
+ const fixture = await setup("jj")
353
+ await Bun.write(join(fixture.checkout, "feature.txt"), "published\n")
354
+ await requireCommand(
355
+ ["jj", "describe", "-m", "Add published feature"],
356
+ fixture.checkout,
357
+ )
358
+
359
+ const result = await publish(fixture.taskPath)
360
+ expect(result).toMatchObject({
361
+ vcs: "jj",
362
+ branch: "task/example",
363
+ base: "main",
364
+ remote: "origin",
365
+ })
366
+ expect(await remoteBranch(fixture.remote)).toBe(result.tip)
367
+ expect(
368
+ (
369
+ await requireCommand(
370
+ [
371
+ "jj",
372
+ "bookmark",
373
+ "list",
374
+ "--all-remotes",
375
+ "-T",
376
+ 'if(name == "task/example" && remote == "origin", tracked, "")',
377
+ ],
378
+ fixture.checkout,
379
+ )
380
+ ).stdout,
381
+ ).toBe("true")
382
+ })
383
+
384
+ test("selects the described parent of a canonical jj post-commit working copy", async () => {
385
+ if (!Bun.which("jj")) return
386
+ const fixture = await setup("jj")
387
+ await Bun.write(join(fixture.checkout, "feature.txt"), "published\n")
388
+ await requireCommand(
389
+ ["jj", "commit", "-m", "Add published feature"],
390
+ fixture.checkout,
391
+ )
392
+ const parent = (
393
+ await requireCommand(
394
+ ["jj", "log", "--no-graph", "-r", "@-", "-T", "commit_id"],
395
+ fixture.checkout,
396
+ )
397
+ ).stdout
398
+
399
+ const result = await publish(fixture.taskPath)
400
+ expect(result.tip).toBe(parent)
401
+ expect(await remoteBranch(fixture.remote)).toBe(parent)
402
+ })
403
+
404
+ test("preserves a described empty jj change and diagnoses missing semantics", async () => {
405
+ if (!Bun.which("jj")) return
406
+ const described = await setup("jj")
407
+ await requireCommand(
408
+ ["jj", "describe", "-m", "Record intentional empty change"],
409
+ described.checkout,
410
+ )
411
+ const describedTip = (
412
+ await requireCommand(
413
+ ["jj", "log", "--no-graph", "-r", "@", "-T", "commit_id"],
414
+ described.checkout,
415
+ )
416
+ ).stdout
417
+ expect((await publish(described.taskPath)).tip).toBe(describedTip)
418
+
419
+ const undescribed = await setup("jj")
420
+ await Bun.write(join(undescribed.checkout, "feature.txt"), "missing\n")
421
+ const changeId = (
422
+ await requireCommand(
423
+ ["jj", "log", "--no-graph", "-r", "@", "-T", "change_id"],
424
+ undescribed.checkout,
425
+ )
426
+ ).stdout
427
+ await expect(publish(undescribed.taskPath)).rejects.toThrow(
428
+ `Change ${changeId} has no description. Run: jj describe -r ${changeId}`,
429
+ )
430
+ })
431
+
432
+ test("rejects an empty jj task and remote bookmark divergence", async () => {
433
+ if (!Bun.which("jj")) return
434
+ const empty = await setup("jj")
435
+ await expect(publish(empty.taskPath)).rejects.toThrow(
436
+ "No changes to publish after base 'main'",
437
+ )
438
+
439
+ const fixture = await setup("jj")
440
+ await Bun.write(join(fixture.checkout, "feature.txt"), "first\n")
441
+ await requireCommand(
442
+ ["jj", "describe", "-m", "First change"],
443
+ fixture.checkout,
444
+ )
445
+ await publish(fixture.taskPath)
446
+
447
+ const other = join(fixture.root, "other")
448
+ await requireCommand(["git", "clone", fixture.remote, other])
449
+ await requireCommand(["git", "checkout", "task/example"], other)
450
+ await requireCommand(["git", "config", "user.name", "Other"], other)
451
+ await requireCommand(
452
+ ["git", "config", "user.email", "other@example.com"],
453
+ other,
454
+ )
455
+ await Bun.write(join(other, "remote.txt"), "remote\n")
456
+ await requireCommand(["git", "add", "remote.txt"], other)
457
+ await requireCommand(["git", "commit", "-m", "Remote change"], other)
458
+ await requireCommand(["git", "push", "origin", "task/example"], other)
459
+
460
+ await requireCommand(["jj", "new", "@"], fixture.checkout)
461
+ await Bun.write(join(fixture.checkout, "local.txt"), "local\n")
462
+ await requireCommand(
463
+ ["jj", "describe", "-m", "Local change"],
464
+ fixture.checkout,
465
+ )
466
+ await expect(publish(fixture.taskPath)).rejects.toThrow(
467
+ "refusing to move it",
468
+ )
469
+ })
470
+
471
+ test("rejects conflicted jj changes with an actionable change ID", async () => {
472
+ if (!Bun.which("jj")) return
473
+ const fixture = await setup("jj")
474
+ await Bun.write(join(fixture.checkout, "README.md"), "left\n")
475
+ await requireCommand(
476
+ ["jj", "describe", "-m", "Left change"],
477
+ fixture.checkout,
478
+ )
479
+ await requireCommand(
480
+ ["jj", "bookmark", "create", "left", "-r", "@"],
481
+ fixture.checkout,
482
+ )
483
+ await requireCommand(["jj", "new", "main@origin"], fixture.checkout)
484
+ await Bun.write(join(fixture.checkout, "README.md"), "right\n")
485
+ await requireCommand(
486
+ ["jj", "describe", "-m", "Right change"],
487
+ fixture.checkout,
488
+ )
489
+ await requireCommand(
490
+ ["jj", "bookmark", "create", "right", "-r", "@"],
491
+ fixture.checkout,
492
+ )
493
+ await requireCommand(["jj", "new", "left", "right"], fixture.checkout)
494
+ await requireCommand(
495
+ ["jj", "describe", "-m", "Conflicted merge"],
496
+ fixture.checkout,
497
+ )
498
+ const changeId = (
499
+ await requireCommand(
500
+ ["jj", "log", "--no-graph", "-r", "@", "-T", "change_id"],
501
+ fixture.checkout,
502
+ )
503
+ ).stdout
504
+ expect(
505
+ (
506
+ await requireCommand(
507
+ ["jj", "log", "--no-graph", "-r", "@", "-T", "conflict"],
508
+ fixture.checkout,
509
+ )
510
+ ).stdout,
511
+ ).toBe("true")
512
+ await expect(publish(fixture.taskPath)).rejects.toThrow(
513
+ `Change ${changeId} contains conflicts. Run: jj resolve -r ${changeId}`,
514
+ )
515
+ })
516
+ })