@markjaquith/agency 2.58.2 → 2.60.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.
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
2
  import { Effect } from "effect"
3
- import { mkdir, realpath, rename, rm, stat } from "node:fs/promises"
3
+ import { chmod, mkdir, realpath, rename, rm, stat } from "node:fs/promises"
4
4
  import { join } from "node:path"
5
5
  import {
6
6
  captureErrors,
@@ -34,6 +34,21 @@ const jj = async (args: string[], cwd?: string) => {
34
34
  throw new Error(await new Response(process.stderr).text())
35
35
  }
36
36
 
37
+ const jjOutput = async (args: string[], cwd?: string) => {
38
+ const process = Bun.spawn(["jj", ...args], {
39
+ cwd,
40
+ stdout: "pipe",
41
+ stderr: "pipe",
42
+ })
43
+ const [exitCode, stdout, stderr] = await Promise.all([
44
+ process.exited,
45
+ new Response(process.stdout).text(),
46
+ new Response(process.stderr).text(),
47
+ ])
48
+ if (exitCode !== 0) throw new Error(stderr)
49
+ return stdout.trim()
50
+ }
51
+
37
52
  describe("WorktreeService", () => {
38
53
  let root: string
39
54
  let source: string
@@ -304,6 +319,269 @@ describe("WorktreeService", () => {
304
319
  expect(await Bun.file(workspace.writablePath!).exists()).toBe(false)
305
320
  })
306
321
 
322
+ test("suspends and resumes the exact jj working-copy target", async () => {
323
+ if (!Bun.which("jj")) return
324
+ const repository = join(root, "repos/agency")
325
+ await rm(repository, { recursive: true, force: true })
326
+ await git(["clone", source, repository])
327
+ await jj(["git", "init", "--colocate", repository])
328
+ await Bun.write(
329
+ join(root, "agency.json"),
330
+ JSON.stringify({ version: 2, vcs: "jj" }),
331
+ )
332
+ await runTestEffect(
333
+ TaskService.pipe(
334
+ Effect.flatMap((service) =>
335
+ service.create(
336
+ {
337
+ id: "jj-resume",
338
+ ticketUrl: null,
339
+ repo: "agency",
340
+ branch: "task/jj-resume",
341
+ base: "main",
342
+ },
343
+ root,
344
+ ),
345
+ ),
346
+ ),
347
+ )
348
+ const workspace = await runTestEffect(
349
+ WorktreeService.pipe(
350
+ Effect.flatMap((service) =>
351
+ service.materialize("jj-resume", undefined, root),
352
+ ),
353
+ ),
354
+ )
355
+ await jj(["new", "-m", "local-only parent"], workspace.writablePath!)
356
+ await Bun.write(join(workspace.writablePath!, "local.txt"), "preserve me\n")
357
+ await jj(
358
+ ["describe", "-m", "local working target"],
359
+ workspace.writablePath!,
360
+ )
361
+ const target = await jjOutput(
362
+ ["log", "--no-graph", "-r", "@", "-T", 'commit_id ++ "\\t" ++ change_id'],
363
+ workspace.writablePath!,
364
+ )
365
+ const [commitId, changeId] = target.split("\t")
366
+ if (!commitId || !changeId)
367
+ throw new Error(`Unexpected jj identity: ${target}`)
368
+ await jj(["new", "-m", "temporary successor"], workspace.writablePath!)
369
+ await jj(["edit", commitId], workspace.writablePath!)
370
+
371
+ await runTestEffect(
372
+ WorktreeService.pipe(
373
+ Effect.flatMap((service) =>
374
+ service.remove("jj-resume", undefined, root),
375
+ ),
376
+ ),
377
+ )
378
+ const resumePath = join(root, "tasks/jj-resume/.agency-jj-resume.json")
379
+ const resume = await Bun.file(resumePath).json()
380
+ expect(resume.checkouts[0]).toMatchObject({ commitId, changeId })
381
+ expect(
382
+ await jjOutput(
383
+ [
384
+ "log",
385
+ "--no-graph",
386
+ "-r",
387
+ resume.checkouts[0].bookmark,
388
+ "-T",
389
+ "commit_id",
390
+ ],
391
+ repository,
392
+ ),
393
+ ).toBe(commitId)
394
+ expect(
395
+ await jjOutput(
396
+ ["log", "--no-graph", "-r", "task/jj-resume", "-T", "commit_id"],
397
+ repository,
398
+ ).catch(() => null),
399
+ ).toBeNull()
400
+
401
+ await runTestEffect(
402
+ WorktreeService.pipe(
403
+ Effect.flatMap((service) =>
404
+ service.materialize("jj-resume", undefined, root),
405
+ ),
406
+ ),
407
+ )
408
+ expect(
409
+ await jjOutput(
410
+ ["log", "--no-graph", "-r", "@", "-T", "commit_id"],
411
+ workspace.writablePath!,
412
+ ),
413
+ ).toBe(commitId)
414
+ expect(
415
+ await Bun.file(join(workspace.writablePath!, "local.txt")).text(),
416
+ ).toBe("preserve me\n")
417
+ expect(await Bun.file(resumePath).exists()).toBe(false)
418
+ expect(
419
+ await jjOutput(
420
+ [
421
+ "log",
422
+ "--no-graph",
423
+ "-r",
424
+ resume.checkouts[0].bookmark,
425
+ "-T",
426
+ "commit_id",
427
+ ],
428
+ repository,
429
+ ).catch(() => null),
430
+ ).toBeNull()
431
+ })
432
+
433
+ test("refuses stale jj resume identity and stale registrations", async () => {
434
+ if (!Bun.which("jj")) return
435
+ const repository = join(root, "repos/agency")
436
+ await rm(repository, { recursive: true, force: true })
437
+ await git(["clone", source, repository])
438
+ await jj(["git", "init", "--colocate", repository])
439
+ await Bun.write(
440
+ join(root, "agency.json"),
441
+ JSON.stringify({ version: 2, vcs: "jj" }),
442
+ )
443
+ for (const id of ["stale-resume", "stale-registration"]) {
444
+ await runTestEffect(
445
+ TaskService.pipe(
446
+ Effect.flatMap((service) =>
447
+ service.create(
448
+ {
449
+ id,
450
+ ticketUrl: null,
451
+ repo: "agency",
452
+ branch: `task/${id}`,
453
+ base: "main",
454
+ },
455
+ root,
456
+ ),
457
+ ),
458
+ ),
459
+ )
460
+ }
461
+ const suspended = await runTestEffect(
462
+ WorktreeService.pipe(
463
+ Effect.flatMap((service) =>
464
+ service.materialize("stale-resume", undefined, root),
465
+ ),
466
+ ),
467
+ )
468
+ await runTestEffect(
469
+ WorktreeService.pipe(
470
+ Effect.flatMap((service) =>
471
+ service.remove("stale-resume", undefined, root),
472
+ ),
473
+ ),
474
+ )
475
+ const resumePath = join(root, "tasks/stale-resume/.agency-jj-resume.json")
476
+ const resume = await Bun.file(resumePath).json()
477
+ await jj([
478
+ "-R",
479
+ repository,
480
+ "bookmark",
481
+ "delete",
482
+ resume.checkouts[0].bookmark,
483
+ ])
484
+ await expect(
485
+ runTestEffect(
486
+ WorktreeService.pipe(
487
+ Effect.flatMap((service) =>
488
+ service.materialize("stale-resume", undefined, root),
489
+ ),
490
+ ),
491
+ ),
492
+ ).rejects.toThrow("recorded commit, change ID, path, or internal bookmark")
493
+ expect(await Bun.file(resumePath).exists()).toBe(true)
494
+ expect(await Bun.file(suspended.writablePath!).exists()).toBe(false)
495
+
496
+ const stale = await runTestEffect(
497
+ WorktreeService.pipe(
498
+ Effect.flatMap((service) =>
499
+ service.materialize("stale-registration", undefined, root),
500
+ ),
501
+ ),
502
+ )
503
+ await rm(join(stale.writablePath!, ".jj"), { recursive: true, force: true })
504
+ await expect(
505
+ runTestEffect(
506
+ WorktreeService.pipe(
507
+ Effect.flatMap((service) =>
508
+ service.remove("stale-registration", undefined, root),
509
+ ),
510
+ ),
511
+ ),
512
+ ).rejects.toThrow("checkout cleanliness could not be verified")
513
+ })
514
+
515
+ test("retains jj resume state when workspace deletion and rollback fail", async () => {
516
+ if (!Bun.which("jj")) return
517
+ const repository = join(root, "repos/agency")
518
+ await rm(repository, { recursive: true, force: true })
519
+ await git(["clone", source, repository])
520
+ await jj(["git", "init", "--colocate", repository])
521
+ await Bun.write(
522
+ join(root, "agency.json"),
523
+ JSON.stringify({ version: 2, vcs: "jj" }),
524
+ )
525
+ await runTestEffect(
526
+ TaskService.pipe(
527
+ Effect.flatMap((service) =>
528
+ service.create(
529
+ {
530
+ id: "jj-rollback",
531
+ ticketUrl: null,
532
+ repo: "agency",
533
+ branch: "task/jj-rollback",
534
+ base: "main",
535
+ },
536
+ root,
537
+ ),
538
+ ),
539
+ ),
540
+ )
541
+ const workspace = await runTestEffect(
542
+ WorktreeService.pipe(
543
+ Effect.flatMap((service) =>
544
+ service.materialize("jj-rollback", undefined, root),
545
+ ),
546
+ ),
547
+ )
548
+ const fakeBin = join(root, "fake-bin")
549
+ await mkdir(fakeBin)
550
+ const fakeRm = join(fakeBin, "rm")
551
+ await Bun.write(
552
+ fakeRm,
553
+ `#!/bin/sh\nif [ "$1" = "-rf" ] && [ "$2" = ${JSON.stringify(workspace.writablePath!)} ]; then exit 1; fi\nexec ${JSON.stringify(Bun.which("rm")!)} "$@"\n`,
554
+ )
555
+ await chmod(fakeRm, 0o755)
556
+ const originalPath = process.env.PATH
557
+ process.env.PATH = `${fakeBin}:${originalPath}`
558
+ try {
559
+ await expect(
560
+ runTestEffect(
561
+ WorktreeService.pipe(
562
+ Effect.flatMap((service) =>
563
+ service.remove("jj-rollback", undefined, root),
564
+ ),
565
+ ),
566
+ ),
567
+ ).rejects.toThrow("requires manual recovery")
568
+ } finally {
569
+ process.env.PATH = originalPath
570
+ }
571
+ const resumePath = join(root, "tasks/jj-rollback/.agency-jj-resume.json")
572
+ expect(await Bun.file(resumePath).exists()).toBe(true)
573
+
574
+ await runTestEffect(
575
+ WorktreeService.pipe(
576
+ Effect.flatMap((service) =>
577
+ service.materialize("jj-rollback", undefined, root),
578
+ ),
579
+ ),
580
+ )
581
+ expect((await stat(workspace.writablePath!)).isDirectory()).toBe(true)
582
+ expect(await Bun.file(resumePath).exists()).toBe(false)
583
+ })
584
+
307
585
  test("recommends a repository fetch when jj cannot resolve a base", async () => {
308
586
  if (!Bun.which("jj")) return
309
587
  const repository = join(root, "repos/agency")