@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.
- package/README.md +55 -4
- package/cli-main.ts +5 -0
- package/index.ts +1 -0
- package/package.json +4 -1
- package/schemas/agency-kickoff-v1.schema.json +124 -0
- package/src/cli-parser.test.ts +1 -1
- package/src/cli-parser.ts +23 -6
- package/src/commands/task.test.ts +77 -1
- package/src/commands/task.ts +76 -6
- package/src/commands/work.test.ts +62 -3
- package/src/commands/work.ts +106 -3
- package/src/services/ArchiveService.ts +9 -0
- package/src/services/IntegrationService.test.ts +37 -0
- package/src/services/ReviewService.ts +2 -0
- package/src/services/WorktreeService.test.ts +279 -1
- package/src/services/WorktreeService.ts +418 -30
- package/src/workbase/AGENTS.md +55 -4
- package/src/workbase/kickoff-contract.test.ts +145 -0
- package/src/workbase/kickoff-contract.ts +358 -0
|
@@ -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")
|