@markjaquith/agency 2.58.1 → 2.59.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
CHANGED
|
@@ -925,6 +925,15 @@ conflicting worktrees. Repair is deliberately conservative: it repairs safe Git
|
|
|
925
925
|
registration issues and materializes missing checkouts, but never switches a
|
|
926
926
|
branch, resets a commit, or discards uncommitted work.
|
|
927
927
|
|
|
928
|
+
For jj, explicit removal is a suspend operation. Agency records each workspace's
|
|
929
|
+
exact `@` commit and stable change ID in `.agency-jj-resume.json`, roots that
|
|
930
|
+
commit with an internal local bookmark, and only then forgets the workspace.
|
|
931
|
+
The next `work prepare` validates all three identities, restores the workspace by
|
|
932
|
+
editing the recorded commit, and consumes the resume metadata and bookmarks.
|
|
933
|
+
Missing, ambiguous, or conflicting resume state stops instead of falling back to
|
|
934
|
+
the declared delivery bookmark, `@-`, or the base. Unknown checkout cleanliness
|
|
935
|
+
also stops removal. Git worktree removal and preparation are unchanged.
|
|
936
|
+
|
|
928
937
|
Agency launches every agent beside its epic or task document. Single-phase tasks
|
|
929
938
|
and phases first fetch repositories and create or reuse worktrees under `code/`,
|
|
930
939
|
then launch the execution agent from the task directory with absolute context
|
package/package.json
CHANGED
|
@@ -413,6 +413,7 @@ const restoreWorktreeSnapshots = async (
|
|
|
413
413
|
snapshot.head,
|
|
414
414
|
snapshot.path,
|
|
415
415
|
])
|
|
416
|
+
await runGit(["jj", "-R", snapshot.path, "edit", snapshot.head])
|
|
416
417
|
continue
|
|
417
418
|
}
|
|
418
419
|
await runGit(
|
|
@@ -723,6 +724,7 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
|
|
|
723
724
|
removedWorktrees.push(
|
|
724
725
|
...(yield* worktrees.remove(unit.taskId, unit.phaseId, root, {
|
|
725
726
|
dryRun: true,
|
|
727
|
+
persistResume: false,
|
|
726
728
|
})),
|
|
727
729
|
)
|
|
728
730
|
}
|
|
@@ -781,6 +783,7 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
|
|
|
781
783
|
worktrees.remove(unit.taskId, unit.phaseId, root, {
|
|
782
784
|
snapshots,
|
|
783
785
|
lockHeld: true,
|
|
786
|
+
persistResume: false,
|
|
784
787
|
}),
|
|
785
788
|
)
|
|
786
789
|
} catch (cause) {
|
|
@@ -891,6 +894,7 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
|
|
|
891
894
|
const result = yield* worktrees
|
|
892
895
|
.remove(unit.taskId, executionPhaseId(unit), root, {
|
|
893
896
|
dryRun: true,
|
|
897
|
+
persistResume: false,
|
|
894
898
|
})
|
|
895
899
|
.pipe(Effect.either)
|
|
896
900
|
if (Either.isLeft(result)) {
|
|
@@ -1064,6 +1068,7 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
|
|
|
1064
1068
|
worktrees.remove(unit.taskId, unit.phaseId, root, {
|
|
1065
1069
|
snapshots,
|
|
1066
1070
|
lockHeld: true,
|
|
1071
|
+
persistResume: false,
|
|
1067
1072
|
}),
|
|
1068
1073
|
)
|
|
1069
1074
|
}
|
|
@@ -1212,6 +1217,7 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
|
|
|
1212
1217
|
root,
|
|
1213
1218
|
{
|
|
1214
1219
|
dryRun: true,
|
|
1220
|
+
persistResume: false,
|
|
1215
1221
|
},
|
|
1216
1222
|
)),
|
|
1217
1223
|
)
|
|
@@ -1267,6 +1273,7 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
|
|
|
1267
1273
|
{
|
|
1268
1274
|
snapshots,
|
|
1269
1275
|
lockHeld: true,
|
|
1276
|
+
persistResume: false,
|
|
1270
1277
|
},
|
|
1271
1278
|
),
|
|
1272
1279
|
)
|
|
@@ -1359,6 +1366,7 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
|
|
|
1359
1366
|
yield* rejectExistingDestination(destination, "Archive")
|
|
1360
1367
|
const removedWorktrees = yield* worktrees.remove(taskId, id, root, {
|
|
1361
1368
|
dryRun: true,
|
|
1369
|
+
persistResume: false,
|
|
1362
1370
|
})
|
|
1363
1371
|
const at = new Date().toISOString()
|
|
1364
1372
|
const content = yield* declarationContent(task, {
|
|
@@ -1407,6 +1415,7 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
|
|
|
1407
1415
|
worktrees.remove(taskId, id, root, {
|
|
1408
1416
|
snapshots,
|
|
1409
1417
|
lockHeld: true,
|
|
1418
|
+
persistResume: false,
|
|
1410
1419
|
}),
|
|
1411
1420
|
)
|
|
1412
1421
|
},
|
|
@@ -101,6 +101,7 @@ const restoreSnapshots = async (
|
|
|
101
101
|
snapshot.head,
|
|
102
102
|
snapshot.path,
|
|
103
103
|
])
|
|
104
|
+
await runGit(["jj", "-R", snapshot.path, "edit", snapshot.head])
|
|
104
105
|
continue
|
|
105
106
|
}
|
|
106
107
|
await runGit(
|
|
@@ -392,6 +393,7 @@ export class ReviewService extends Effect.Service<ReviewService>()(
|
|
|
392
393
|
worktrees.remove(taskId, undefined, root, {
|
|
393
394
|
snapshots,
|
|
394
395
|
lockHeld: true,
|
|
396
|
+
persistResume: false,
|
|
395
397
|
}),
|
|
396
398
|
).then(() => undefined),
|
|
397
399
|
rollback: () => restoreSnapshots(snapshots),
|
|
@@ -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")
|
|
@@ -1882,6 +2160,111 @@ pr: null
|
|
|
1882
2160
|
).toBe(true)
|
|
1883
2161
|
})
|
|
1884
2162
|
|
|
2163
|
+
test("rebuilds clean drifted jj references without discarding changes", async () => {
|
|
2164
|
+
if (!Bun.which("jj")) return
|
|
2165
|
+
const first = new TextDecoder()
|
|
2166
|
+
.decode(
|
|
2167
|
+
Bun.spawnSync(["git", "-C", source, "rev-parse", "HEAD"], {
|
|
2168
|
+
stdout: "pipe",
|
|
2169
|
+
}).stdout,
|
|
2170
|
+
)
|
|
2171
|
+
.trim()
|
|
2172
|
+
await Bun.write(join(source, "README.md"), "updated\n")
|
|
2173
|
+
await git(["add", "README.md"], source)
|
|
2174
|
+
await git(["-c", "commit.gpgsign=false", "commit", "-m", "update"], source)
|
|
2175
|
+
const second = new TextDecoder()
|
|
2176
|
+
.decode(
|
|
2177
|
+
Bun.spawnSync(["git", "-C", source, "rev-parse", "HEAD"], {
|
|
2178
|
+
stdout: "pipe",
|
|
2179
|
+
}).stdout,
|
|
2180
|
+
)
|
|
2181
|
+
.trim()
|
|
2182
|
+
const repository = join(root, "repos/agency")
|
|
2183
|
+
await rm(repository, { recursive: true, force: true })
|
|
2184
|
+
await jj(["git", "clone", "--no-colocate", source, repository])
|
|
2185
|
+
await Bun.write(
|
|
2186
|
+
join(root, "agency.json"),
|
|
2187
|
+
JSON.stringify({ version: 2, vcs: "jj" }),
|
|
2188
|
+
)
|
|
2189
|
+
const createDriftedReview = async (id: string) => {
|
|
2190
|
+
const task = await runTestEffect(
|
|
2191
|
+
TaskService.pipe(
|
|
2192
|
+
Effect.flatMap((service) =>
|
|
2193
|
+
service.create(
|
|
2194
|
+
{
|
|
2195
|
+
id,
|
|
2196
|
+
ticketUrl: null,
|
|
2197
|
+
review: {
|
|
2198
|
+
repo: "agency",
|
|
2199
|
+
source: {
|
|
2200
|
+
kind: "branch",
|
|
2201
|
+
ref: "refs/heads/main",
|
|
2202
|
+
},
|
|
2203
|
+
commit: first,
|
|
2204
|
+
refreshedAt: "2026-08-10T00:00:00.000Z",
|
|
2205
|
+
},
|
|
2206
|
+
},
|
|
2207
|
+
root,
|
|
2208
|
+
),
|
|
2209
|
+
),
|
|
2210
|
+
),
|
|
2211
|
+
)
|
|
2212
|
+
const workspace = await runTestEffect(
|
|
2213
|
+
WorktreeService.pipe(
|
|
2214
|
+
Effect.flatMap((service) => service.materialize(id, undefined, root)),
|
|
2215
|
+
),
|
|
2216
|
+
)
|
|
2217
|
+
await Bun.write(task.path, task.content.replace(first, second))
|
|
2218
|
+
return workspace
|
|
2219
|
+
}
|
|
2220
|
+
|
|
2221
|
+
const workspace = await createDriftedReview("drifted-review")
|
|
2222
|
+
|
|
2223
|
+
await expect(
|
|
2224
|
+
runTestEffect(
|
|
2225
|
+
WorktreeService.pipe(
|
|
2226
|
+
Effect.flatMap((service) =>
|
|
2227
|
+
service.materialize("drifted-review", undefined, root),
|
|
2228
|
+
),
|
|
2229
|
+
),
|
|
2230
|
+
),
|
|
2231
|
+
).rejects.toThrow("does not match reference")
|
|
2232
|
+
const inspection = await runTestEffect(
|
|
2233
|
+
WorktreeService.pipe(
|
|
2234
|
+
Effect.flatMap((service) =>
|
|
2235
|
+
service.inspect("drifted-review", undefined, root),
|
|
2236
|
+
),
|
|
2237
|
+
),
|
|
2238
|
+
)
|
|
2239
|
+
expect(inspection.conflicts).toEqual([
|
|
2240
|
+
expect.objectContaining({ kind: "reference-drift" }),
|
|
2241
|
+
])
|
|
2242
|
+
await runTestEffect(
|
|
2243
|
+
WorktreeService.pipe(
|
|
2244
|
+
Effect.flatMap((service) =>
|
|
2245
|
+
service.rebuild("drifted-review", undefined, root),
|
|
2246
|
+
),
|
|
2247
|
+
),
|
|
2248
|
+
)
|
|
2249
|
+
expect(
|
|
2250
|
+
await Bun.file(join(workspace.reviewPath!, "README.md")).text(),
|
|
2251
|
+
).toBe("updated\n")
|
|
2252
|
+
|
|
2253
|
+
const dirtyWorkspace = await createDriftedReview("dirty-drifted-review")
|
|
2254
|
+
const dirtyPath = join(dirtyWorkspace.reviewPath!, "dirty.txt")
|
|
2255
|
+
await Bun.write(dirtyPath, "preserve me\n")
|
|
2256
|
+
await expect(
|
|
2257
|
+
runTestEffect(
|
|
2258
|
+
WorktreeService.pipe(
|
|
2259
|
+
Effect.flatMap((service) =>
|
|
2260
|
+
service.rebuild("dirty-drifted-review", undefined, root),
|
|
2261
|
+
),
|
|
2262
|
+
),
|
|
2263
|
+
),
|
|
2264
|
+
).rejects.toThrow("checkout has uncommitted changes")
|
|
2265
|
+
expect(await Bun.file(dirtyPath).text()).toBe("preserve me\n")
|
|
2266
|
+
})
|
|
2267
|
+
|
|
1885
2268
|
test("restores removed worktrees when rebuild creation fails", async () => {
|
|
1886
2269
|
await runTestEffect(
|
|
1887
2270
|
TaskService.pipe(
|
|
@@ -40,6 +40,7 @@ interface WorkspaceOperation {
|
|
|
40
40
|
| "create-branch"
|
|
41
41
|
| "create-worktree"
|
|
42
42
|
| "create-workspace"
|
|
43
|
+
| "restore-workspace"
|
|
43
44
|
| "post-checkout"
|
|
44
45
|
readonly repo: string
|
|
45
46
|
readonly command: readonly string[]
|
|
@@ -154,6 +155,22 @@ export interface WorktreeRemovalSnapshot {
|
|
|
154
155
|
readonly workspaceName?: string
|
|
155
156
|
}
|
|
156
157
|
|
|
158
|
+
interface JjResumeCheckout {
|
|
159
|
+
readonly repo: string
|
|
160
|
+
readonly workspacePath: string
|
|
161
|
+
readonly workspaceName: string
|
|
162
|
+
readonly commitId: string
|
|
163
|
+
readonly changeId: string
|
|
164
|
+
readonly bookmark: string
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
interface JjResumeState {
|
|
168
|
+
readonly version: 1
|
|
169
|
+
readonly taskId: string
|
|
170
|
+
readonly phaseId: string | null
|
|
171
|
+
readonly checkouts: readonly JjResumeCheckout[]
|
|
172
|
+
}
|
|
173
|
+
|
|
157
174
|
const parseWorktreeList = (output: string): readonly GitWorktree[] => {
|
|
158
175
|
const worktrees: GitWorktree[] = []
|
|
159
176
|
let current: { path: string; head?: string; branch?: string } | undefined
|
|
@@ -254,11 +271,14 @@ const originRef = (ref: string) =>
|
|
|
254
271
|
interface MaterializeOptions extends BaseCommandOptions {
|
|
255
272
|
readonly force?: boolean
|
|
256
273
|
readonly lockHeld?: boolean
|
|
274
|
+
readonly allowReferenceDrift?: boolean
|
|
257
275
|
}
|
|
258
276
|
|
|
259
277
|
interface RemoveOptions extends BaseCommandOptions {
|
|
260
278
|
readonly snapshots?: WorktreeRemovalSnapshot[]
|
|
261
279
|
readonly lockHeld?: boolean
|
|
280
|
+
readonly allowReferenceDrift?: boolean
|
|
281
|
+
readonly persistResume?: boolean
|
|
262
282
|
}
|
|
263
283
|
|
|
264
284
|
interface LifecycleOptions extends BaseCommandOptions {
|
|
@@ -770,6 +790,177 @@ const jjWorkspaceName = (
|
|
|
770
790
|
repo: string,
|
|
771
791
|
) => `agency-${taskId}-${phaseId ?? "task"}-${repo}`
|
|
772
792
|
|
|
793
|
+
const jjResumePath = (taskPath: string, phasePath: string | null) =>
|
|
794
|
+
join(dirname(phasePath ?? taskPath), ".agency-jj-resume.json")
|
|
795
|
+
|
|
796
|
+
const jjResumeBookmark = (
|
|
797
|
+
taskId: string,
|
|
798
|
+
phaseId: string | undefined,
|
|
799
|
+
repo: string,
|
|
800
|
+
) =>
|
|
801
|
+
`agency-resume/${Buffer.from(`${taskId}:${phaseId ?? "task"}:${repo}:${crypto.randomUUID()}`).toString("hex")}`
|
|
802
|
+
|
|
803
|
+
const parseJjResumeState = (content: string, path: string): JjResumeState => {
|
|
804
|
+
let value: unknown
|
|
805
|
+
try {
|
|
806
|
+
value = JSON.parse(content)
|
|
807
|
+
} catch {
|
|
808
|
+
throw new WorktreeError({
|
|
809
|
+
message: `Invalid jj resume metadata at ${path}`,
|
|
810
|
+
})
|
|
811
|
+
}
|
|
812
|
+
if (
|
|
813
|
+
typeof value !== "object" ||
|
|
814
|
+
value === null ||
|
|
815
|
+
!("version" in value) ||
|
|
816
|
+
value.version !== 1 ||
|
|
817
|
+
!("taskId" in value) ||
|
|
818
|
+
typeof value.taskId !== "string" ||
|
|
819
|
+
!("phaseId" in value) ||
|
|
820
|
+
(value.phaseId !== null && typeof value.phaseId !== "string") ||
|
|
821
|
+
!("checkouts" in value) ||
|
|
822
|
+
!Array.isArray(value.checkouts) ||
|
|
823
|
+
value.checkouts.some(
|
|
824
|
+
(checkout) =>
|
|
825
|
+
typeof checkout !== "object" ||
|
|
826
|
+
checkout === null ||
|
|
827
|
+
![
|
|
828
|
+
"repo",
|
|
829
|
+
"workspacePath",
|
|
830
|
+
"workspaceName",
|
|
831
|
+
"commitId",
|
|
832
|
+
"changeId",
|
|
833
|
+
"bookmark",
|
|
834
|
+
].every((key) => key in checkout && typeof checkout[key] === "string"),
|
|
835
|
+
)
|
|
836
|
+
) {
|
|
837
|
+
throw new WorktreeError({
|
|
838
|
+
message: `Invalid jj resume metadata at ${path}`,
|
|
839
|
+
})
|
|
840
|
+
}
|
|
841
|
+
return value as JjResumeState
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const readJjResumeState = (path: string) =>
|
|
845
|
+
Effect.gen(function* () {
|
|
846
|
+
const fs = yield* FileSystemService
|
|
847
|
+
if (!(yield* fs.exists(path))) return null
|
|
848
|
+
return parseJjResumeState(yield* fs.readFile(path), path)
|
|
849
|
+
})
|
|
850
|
+
|
|
851
|
+
const writeJjResumeState = (path: string, state: JjResumeState) =>
|
|
852
|
+
Effect.gen(function* () {
|
|
853
|
+
const fs = yield* FileSystemService
|
|
854
|
+
const temporary = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`
|
|
855
|
+
yield* Effect.gen(function* () {
|
|
856
|
+
yield* fs.writeJSON(temporary, state)
|
|
857
|
+
yield* fs.moveDirectory(temporary, path)
|
|
858
|
+
}).pipe(
|
|
859
|
+
Effect.catchAll((cause) =>
|
|
860
|
+
Effect.gen(function* () {
|
|
861
|
+
if (yield* fs.exists(temporary)) yield* fs.deleteFile(temporary)
|
|
862
|
+
return yield* Effect.fail(cause)
|
|
863
|
+
}),
|
|
864
|
+
),
|
|
865
|
+
)
|
|
866
|
+
})
|
|
867
|
+
|
|
868
|
+
const jjIdentity = (repositoryPath: string, revision: string) =>
|
|
869
|
+
Effect.gen(function* () {
|
|
870
|
+
const fs = yield* FileSystemService
|
|
871
|
+
const result = yield* fs.runCommand(
|
|
872
|
+
[
|
|
873
|
+
"jj",
|
|
874
|
+
"-R",
|
|
875
|
+
repositoryPath,
|
|
876
|
+
"--no-pager",
|
|
877
|
+
"log",
|
|
878
|
+
"--ignore-working-copy",
|
|
879
|
+
"--no-graph",
|
|
880
|
+
"-r",
|
|
881
|
+
revision,
|
|
882
|
+
"-T",
|
|
883
|
+
'commit_id ++ "\\t" ++ change_id ++ "\\n"',
|
|
884
|
+
],
|
|
885
|
+
{ captureOutput: true },
|
|
886
|
+
)
|
|
887
|
+
const lines = result.stdout.trim().split("\n").filter(Boolean)
|
|
888
|
+
if (result.exitCode !== 0 || lines.length !== 1) return null
|
|
889
|
+
const [commitId, changeId] = lines[0]!.split("\t")
|
|
890
|
+
return commitId && changeId ? { commitId, changeId } : null
|
|
891
|
+
})
|
|
892
|
+
|
|
893
|
+
const jjSetBookmark = (
|
|
894
|
+
repositoryPath: string,
|
|
895
|
+
bookmark: string,
|
|
896
|
+
commitId: string,
|
|
897
|
+
) =>
|
|
898
|
+
Effect.gen(function* () {
|
|
899
|
+
const fs = yield* FileSystemService
|
|
900
|
+
const result = yield* fs.runCommand(
|
|
901
|
+
[
|
|
902
|
+
"jj",
|
|
903
|
+
"-R",
|
|
904
|
+
repositoryPath,
|
|
905
|
+
"bookmark",
|
|
906
|
+
"create",
|
|
907
|
+
bookmark,
|
|
908
|
+
"-r",
|
|
909
|
+
commitId,
|
|
910
|
+
],
|
|
911
|
+
{ captureOutput: true },
|
|
912
|
+
)
|
|
913
|
+
if (result.exitCode !== 0)
|
|
914
|
+
return yield* new WorktreeError({
|
|
915
|
+
message: `Failed to create internal jj resume bookmark '${bookmark}': ${result.stderr.trim()}`,
|
|
916
|
+
})
|
|
917
|
+
})
|
|
918
|
+
|
|
919
|
+
const jjDeleteBookmark = (repositoryPath: string, bookmark: string) =>
|
|
920
|
+
Effect.gen(function* () {
|
|
921
|
+
const fs = yield* FileSystemService
|
|
922
|
+
const result = yield* fs.runCommand(
|
|
923
|
+
["jj", "-R", repositoryPath, "bookmark", "delete", bookmark],
|
|
924
|
+
{ captureOutput: true },
|
|
925
|
+
)
|
|
926
|
+
if (result.exitCode !== 0)
|
|
927
|
+
return yield* new WorktreeError({
|
|
928
|
+
message: `Failed to delete internal jj resume bookmark '${bookmark}': ${result.stderr.trim()}`,
|
|
929
|
+
})
|
|
930
|
+
})
|
|
931
|
+
|
|
932
|
+
const jjEditWorkspace = (workspacePath: string, commitId: string) =>
|
|
933
|
+
Effect.gen(function* () {
|
|
934
|
+
const fs = yield* FileSystemService
|
|
935
|
+
const result = yield* fs.runCommand(
|
|
936
|
+
["jj", "-R", workspacePath, "edit", commitId],
|
|
937
|
+
{ captureOutput: true },
|
|
938
|
+
)
|
|
939
|
+
if (result.exitCode !== 0)
|
|
940
|
+
return yield* new WorktreeError({
|
|
941
|
+
message: `Failed to restore jj workspace ${workspacePath} at ${commitId}: ${result.stderr.trim()}`,
|
|
942
|
+
})
|
|
943
|
+
})
|
|
944
|
+
|
|
945
|
+
const restoreJjWorkspace = (
|
|
946
|
+
backend: VersionControlBackend,
|
|
947
|
+
checkout: {
|
|
948
|
+
repositoryPath: string
|
|
949
|
+
workspacePath: string
|
|
950
|
+
workspaceName: string
|
|
951
|
+
commitId: string
|
|
952
|
+
},
|
|
953
|
+
) =>
|
|
954
|
+
Effect.gen(function* () {
|
|
955
|
+
yield* backend.createWorkspace({
|
|
956
|
+
repositoryPath: checkout.repositoryPath,
|
|
957
|
+
workspacePath: checkout.workspacePath,
|
|
958
|
+
workspaceName: checkout.workspaceName,
|
|
959
|
+
revision: checkout.commitId,
|
|
960
|
+
})
|
|
961
|
+
yield* jjEditWorkspace(checkout.workspacePath, checkout.commitId)
|
|
962
|
+
})
|
|
963
|
+
|
|
773
964
|
// A process can stop after `jj workspace forget` but before checkout deletion.
|
|
774
965
|
// Only recover that residual when its repository and complete tree still match.
|
|
775
966
|
const inspectJjResidual = (
|
|
@@ -967,6 +1158,8 @@ const materializeJj = (options: {
|
|
|
967
1158
|
workspacePath: string
|
|
968
1159
|
workspaceName: string
|
|
969
1160
|
}[] = []
|
|
1161
|
+
const resumePath = jjResumePath(options.taskPath, options.phasePath)
|
|
1162
|
+
const resume = yield* readJjResumeState(resumePath)
|
|
970
1163
|
const base = "base" in options.execution ? options.execution.base : null
|
|
971
1164
|
const { verboseLog } = createLoggers(options.commandOptions)
|
|
972
1165
|
const forwardCommandOutput =
|
|
@@ -976,6 +1169,40 @@ const materializeJj = (options: {
|
|
|
976
1169
|
|
|
977
1170
|
if (!options.commandOptions.dryRun)
|
|
978
1171
|
yield* fs.createDirectory(options.codePath)
|
|
1172
|
+
if (resume) {
|
|
1173
|
+
const requestedRepos = [
|
|
1174
|
+
...options.requestedCheckouts.map(({ repo }) => repo),
|
|
1175
|
+
].sort()
|
|
1176
|
+
const resumeRepos = [...resume.checkouts.map(({ repo }) => repo)].sort()
|
|
1177
|
+
if (
|
|
1178
|
+
resume.taskId !== options.taskId ||
|
|
1179
|
+
resume.phaseId !== (options.phaseId ?? null) ||
|
|
1180
|
+
requestedRepos.join("\0") !== resumeRepos.join("\0")
|
|
1181
|
+
) {
|
|
1182
|
+
return yield* new WorktreeError({
|
|
1183
|
+
message: `Jj resume metadata at ${resumePath} does not match this execution unit; restore or remove it manually before retrying`,
|
|
1184
|
+
})
|
|
1185
|
+
}
|
|
1186
|
+
for (const checkout of resume.checkouts) {
|
|
1187
|
+
const repositoryPath = join(options.root, "repos", checkout.repo)
|
|
1188
|
+
const bookmark = yield* jjIdentity(repositoryPath, checkout.bookmark)
|
|
1189
|
+
const commit = yield* jjIdentity(repositoryPath, checkout.commitId)
|
|
1190
|
+
if (
|
|
1191
|
+
!bookmark ||
|
|
1192
|
+
!commit ||
|
|
1193
|
+
bookmark.commitId !== checkout.commitId ||
|
|
1194
|
+
bookmark.changeId !== checkout.changeId ||
|
|
1195
|
+
commit.changeId !== checkout.changeId ||
|
|
1196
|
+
checkout.workspacePath !== join(options.codePath, checkout.repo) ||
|
|
1197
|
+
checkout.workspaceName !==
|
|
1198
|
+
jjWorkspaceName(options.taskId, options.phaseId, checkout.repo)
|
|
1199
|
+
) {
|
|
1200
|
+
return yield* new WorktreeError({
|
|
1201
|
+
message: `Cannot resume jj workspace for '${checkout.repo}'; recorded commit, change ID, path, or internal bookmark no longer agrees`,
|
|
1202
|
+
})
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
979
1206
|
|
|
980
1207
|
return yield* Effect.gen(function* () {
|
|
981
1208
|
for (const checkout of options.requestedCheckouts) {
|
|
@@ -986,19 +1213,38 @@ const materializeJj = (options: {
|
|
|
986
1213
|
options.phaseId,
|
|
987
1214
|
checkout.repo,
|
|
988
1215
|
)
|
|
1216
|
+
const resumeCheckout = resume?.checkouts.find(
|
|
1217
|
+
(entry) => entry.repo === checkout.repo,
|
|
1218
|
+
)
|
|
989
1219
|
if (!(yield* fs.exists(repositoryPath))) {
|
|
990
1220
|
return yield* new WorktreeError({
|
|
991
1221
|
message: `Repository alias '${checkout.repo}' is not materialized; run 'agency repo setup --apply'`,
|
|
992
1222
|
})
|
|
993
1223
|
}
|
|
994
1224
|
|
|
995
|
-
|
|
1225
|
+
let exists = yield* fs.isDirectory(workspacePath)
|
|
996
1226
|
const canonicalPath = exists
|
|
997
1227
|
? yield* fs.realPath(workspacePath)
|
|
998
1228
|
: resolve(workspacePath)
|
|
999
1229
|
const registered = (yield* backend.listWorkspaces(repositoryPath)).find(
|
|
1000
1230
|
(workspace) => workspace.path === canonicalPath,
|
|
1001
1231
|
)
|
|
1232
|
+
if (exists && !registered && resumeCheckout) {
|
|
1233
|
+
const residual = yield* inspectJjResidual(
|
|
1234
|
+
options.root,
|
|
1235
|
+
repositoryPath,
|
|
1236
|
+
workspacePath,
|
|
1237
|
+
resumeCheckout.commitId,
|
|
1238
|
+
)
|
|
1239
|
+
if (residual !== "clean") {
|
|
1240
|
+
return yield* new WorktreeError({
|
|
1241
|
+
message: `Cannot resume unregistered jj checkout ${workspacePath}; ${residual === "modified" ? "its contents differ from the recorded commit" : "its repository identity cannot be verified"}`,
|
|
1242
|
+
})
|
|
1243
|
+
}
|
|
1244
|
+
if (!options.commandOptions.dryRun)
|
|
1245
|
+
yield* fs.deleteDirectory(workspacePath)
|
|
1246
|
+
exists = false
|
|
1247
|
+
}
|
|
1002
1248
|
if (exists && !registered) {
|
|
1003
1249
|
return yield* new WorktreeError({
|
|
1004
1250
|
message: `Existing checkout ${workspacePath} is not registered as a jj workspace`,
|
|
@@ -1010,12 +1256,39 @@ const materializeJj = (options: {
|
|
|
1010
1256
|
})
|
|
1011
1257
|
}
|
|
1012
1258
|
if (exists && registered) {
|
|
1259
|
+
const actualCommit = resumeCheckout
|
|
1260
|
+
? ((yield* jjIdentity(workspacePath, "@"))?.commitId ?? null)
|
|
1261
|
+
: yield* backend.workspaceHead(workspacePath)
|
|
1262
|
+
if (resumeCheckout && actualCommit !== resumeCheckout.commitId) {
|
|
1263
|
+
return yield* new WorktreeError({
|
|
1264
|
+
message: `Existing jj workspace ${workspacePath} is at ${actualCommit ?? "an unknown target"}, not recorded resume commit ${resumeCheckout.commitId}`,
|
|
1265
|
+
})
|
|
1266
|
+
}
|
|
1267
|
+
if ("ref" in checkout && !resumeCheckout) {
|
|
1268
|
+
const expectedCommit = yield* backend.resolveRevision(
|
|
1269
|
+
repositoryPath,
|
|
1270
|
+
checkout.ref,
|
|
1271
|
+
)
|
|
1272
|
+
if (!expectedCommit) {
|
|
1273
|
+
return yield* new WorktreeError({
|
|
1274
|
+
message: `Reference '${checkout.ref}' for repository '${checkout.repo}' does not resolve to a commit; run 'agency repo fetch ${checkout.repo}' and retry`,
|
|
1275
|
+
})
|
|
1276
|
+
}
|
|
1277
|
+
if (
|
|
1278
|
+
actualCommit !== expectedCommit &&
|
|
1279
|
+
!options.commandOptions.allowReferenceDrift
|
|
1280
|
+
) {
|
|
1281
|
+
return yield* new WorktreeError({
|
|
1282
|
+
message: `Existing checkout ${workspacePath} does not match reference '${checkout.ref}' (${expectedCommit})`,
|
|
1283
|
+
})
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1013
1286
|
reports.push({
|
|
1014
1287
|
repo: checkout.repo,
|
|
1015
1288
|
kind: "branch" in checkout ? "writable" : "reference",
|
|
1016
1289
|
path: workspacePath,
|
|
1017
1290
|
requestedRef: "branch" in checkout ? checkout.branch : checkout.ref,
|
|
1018
|
-
resolvedCommit:
|
|
1291
|
+
resolvedCommit: actualCommit,
|
|
1019
1292
|
action: "reused",
|
|
1020
1293
|
})
|
|
1021
1294
|
continue
|
|
@@ -1023,10 +1296,9 @@ const materializeJj = (options: {
|
|
|
1023
1296
|
|
|
1024
1297
|
const requestedRevision =
|
|
1025
1298
|
"branch" in checkout ? checkout.branch : checkout.ref
|
|
1026
|
-
let revision =
|
|
1027
|
-
|
|
1028
|
-
requestedRevision
|
|
1029
|
-
)
|
|
1299
|
+
let revision =
|
|
1300
|
+
resumeCheckout?.commitId ??
|
|
1301
|
+
(yield* backend.resolveRevision(repositoryPath, requestedRevision))
|
|
1030
1302
|
if (!revision && "branch" in checkout && base) {
|
|
1031
1303
|
revision = yield* backend.resolveRevision(repositoryPath, base)
|
|
1032
1304
|
}
|
|
@@ -1058,20 +1330,45 @@ const materializeJj = (options: {
|
|
|
1058
1330
|
command,
|
|
1059
1331
|
status: options.commandOptions.dryRun ? "planned" : "completed",
|
|
1060
1332
|
})
|
|
1061
|
-
if (
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1333
|
+
if (resumeCheckout) {
|
|
1334
|
+
operations.push({
|
|
1335
|
+
action: "restore-workspace",
|
|
1336
|
+
repo: checkout.repo,
|
|
1337
|
+
command: [
|
|
1338
|
+
"jj",
|
|
1339
|
+
"-R",
|
|
1340
|
+
workspacePath,
|
|
1341
|
+
"edit",
|
|
1342
|
+
resumeCheckout.commitId,
|
|
1343
|
+
],
|
|
1344
|
+
status: options.commandOptions.dryRun ? "planned" : "completed",
|
|
1068
1345
|
})
|
|
1346
|
+
}
|
|
1347
|
+
if (!options.commandOptions.dryRun) {
|
|
1348
|
+
if (resumeCheckout) {
|
|
1349
|
+
yield* restoreJjWorkspace(backend, {
|
|
1350
|
+
repositoryPath,
|
|
1351
|
+
workspacePath,
|
|
1352
|
+
workspaceName,
|
|
1353
|
+
commitId: resumeCheckout.commitId,
|
|
1354
|
+
})
|
|
1355
|
+
} else {
|
|
1356
|
+
yield* backend.createWorkspace({
|
|
1357
|
+
repositoryPath,
|
|
1358
|
+
workspacePath,
|
|
1359
|
+
workspaceName,
|
|
1360
|
+
revision,
|
|
1361
|
+
...("branch" in checkout ? { branch: checkout.branch } : {}),
|
|
1362
|
+
})
|
|
1363
|
+
}
|
|
1069
1364
|
created.push({ repositoryPath, workspacePath, workspaceName })
|
|
1070
1365
|
const canonicalWorkspacePath = yield* fs.realPath(workspacePath)
|
|
1071
1366
|
const registeredAfterCreate = (yield* backend.listWorkspaces(
|
|
1072
1367
|
repositoryPath,
|
|
1073
1368
|
)).find((workspace) => workspace.path === canonicalWorkspacePath)
|
|
1074
|
-
const head =
|
|
1369
|
+
const head = resumeCheckout
|
|
1370
|
+
? ((yield* jjIdentity(workspacePath, "@"))?.commitId ?? null)
|
|
1371
|
+
: yield* backend.workspaceHead(workspacePath)
|
|
1075
1372
|
if (!registeredAfterCreate || head !== revision) {
|
|
1076
1373
|
return yield* new WorktreeError({
|
|
1077
1374
|
message: `Created jj workspace for '${checkout.repo}' failed validation`,
|
|
@@ -1108,6 +1405,16 @@ const materializeJj = (options: {
|
|
|
1108
1405
|
})
|
|
1109
1406
|
}
|
|
1110
1407
|
|
|
1408
|
+
if (resume && !options.commandOptions.dryRun) {
|
|
1409
|
+
yield* fs.deleteFile(resumePath)
|
|
1410
|
+
for (const checkout of resume.checkouts) {
|
|
1411
|
+
yield* jjDeleteBookmark(
|
|
1412
|
+
join(options.root, "repos", checkout.repo),
|
|
1413
|
+
checkout.bookmark,
|
|
1414
|
+
).pipe(Effect.ignore)
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1111
1418
|
return {
|
|
1112
1419
|
root: options.root,
|
|
1113
1420
|
taskPath: options.taskPath,
|
|
@@ -1187,6 +1494,16 @@ const removeJj = (
|
|
|
1187
1494
|
"phases" in task.data && phaseId
|
|
1188
1495
|
? (yield* phases.show(taskId, phaseId, root)).data
|
|
1189
1496
|
: task.data
|
|
1497
|
+
const phasePath =
|
|
1498
|
+
"phases" in task.data && phaseId
|
|
1499
|
+
? (yield* phases.show(taskId, phaseId, root)).path
|
|
1500
|
+
: null
|
|
1501
|
+
const resumePath = jjResumePath(task.path, phasePath)
|
|
1502
|
+
if (yield* fs.exists(resumePath)) {
|
|
1503
|
+
return yield* new WorktreeError({
|
|
1504
|
+
message: `Jj resume metadata already exists at ${resumePath}; run 'agency work prepare' to complete the previous resume before removing the workspace again`,
|
|
1505
|
+
})
|
|
1506
|
+
}
|
|
1190
1507
|
const recoverable = new Map<string, string>()
|
|
1191
1508
|
const modifiedResiduals = new Map<string, string>()
|
|
1192
1509
|
for (const checkout of inspection.checkouts) {
|
|
@@ -1220,7 +1537,9 @@ const removeJj = (
|
|
|
1220
1537
|
checkout.conflicts
|
|
1221
1538
|
.filter(
|
|
1222
1539
|
(conflict) =>
|
|
1223
|
-
|
|
1540
|
+
!(
|
|
1541
|
+
options.allowReferenceDrift && conflict.kind === "reference-drift"
|
|
1542
|
+
) &&
|
|
1224
1543
|
!(
|
|
1225
1544
|
conflict.kind === "unregistered-checkout" &&
|
|
1226
1545
|
recoverable.has(checkout.path)
|
|
@@ -1244,48 +1563,79 @@ const removeJj = (
|
|
|
1244
1563
|
}
|
|
1245
1564
|
|
|
1246
1565
|
const plans: {
|
|
1566
|
+
repo: string
|
|
1247
1567
|
repositoryPath: string
|
|
1248
1568
|
workspacePath: string
|
|
1249
1569
|
workspaceName: string
|
|
1250
1570
|
head: string
|
|
1251
1571
|
branch: string | null
|
|
1252
1572
|
registered: boolean
|
|
1573
|
+
commitId: string
|
|
1574
|
+
changeId: string
|
|
1575
|
+
bookmark: string
|
|
1253
1576
|
}[] = []
|
|
1254
1577
|
for (const checkout of inspection.checkouts) {
|
|
1255
|
-
if (checkout.dirty) {
|
|
1578
|
+
if (checkout.dirty === true && options.persistResume === false) {
|
|
1256
1579
|
return yield* new WorktreeError({
|
|
1257
1580
|
message: `Failed to remove workspace for '${checkout.repo}': checkout has uncommitted changes`,
|
|
1258
1581
|
})
|
|
1259
1582
|
}
|
|
1583
|
+
if (
|
|
1584
|
+
checkout.exists &&
|
|
1585
|
+
checkout.dirty === null &&
|
|
1586
|
+
!recoverable.has(checkout.path)
|
|
1587
|
+
) {
|
|
1588
|
+
return yield* new WorktreeError({
|
|
1589
|
+
message: `Failed to remove workspace for '${checkout.repo}': checkout cleanliness could not be verified`,
|
|
1590
|
+
})
|
|
1591
|
+
}
|
|
1260
1592
|
const repositoryPath = join(root, "repos", checkout.repo)
|
|
1261
1593
|
if (!checkout.registeredPath) {
|
|
1262
1594
|
const recoveryRevision = recoverable.get(checkout.path)
|
|
1263
1595
|
if (!recoveryRevision) continue
|
|
1596
|
+
const identity = yield* jjIdentity(repositoryPath, recoveryRevision)
|
|
1597
|
+
if (!identity) {
|
|
1598
|
+
return yield* new WorktreeError({
|
|
1599
|
+
message: `Cannot prove the exact jj target for unregistered checkout ${checkout.path}`,
|
|
1600
|
+
})
|
|
1601
|
+
}
|
|
1264
1602
|
plans.push({
|
|
1603
|
+
repo: checkout.repo,
|
|
1265
1604
|
repositoryPath,
|
|
1266
1605
|
workspacePath: checkout.path,
|
|
1267
1606
|
workspaceName: jjWorkspaceName(taskId, phaseId, checkout.repo),
|
|
1268
1607
|
head: recoveryRevision,
|
|
1269
1608
|
branch: checkout.actualBranch,
|
|
1270
1609
|
registered: false,
|
|
1610
|
+
...identity,
|
|
1611
|
+
bookmark: jjResumeBookmark(taskId, phaseId, checkout.repo),
|
|
1271
1612
|
})
|
|
1272
1613
|
continue
|
|
1273
1614
|
}
|
|
1274
1615
|
const registered = (yield* backend.listWorkspaces(repositoryPath)).find(
|
|
1275
1616
|
(workspace) => workspace.path === checkout.registeredPath,
|
|
1276
1617
|
)
|
|
1277
|
-
if (!registered?.name || !checkout.actualCommit) {
|
|
1618
|
+
if (!registered?.name || !checkout.actualCommit || !registered.commit) {
|
|
1278
1619
|
return yield* new WorktreeError({
|
|
1279
1620
|
message: `Cannot identify jj workspace at ${checkout.registeredPath}`,
|
|
1280
1621
|
})
|
|
1281
1622
|
}
|
|
1623
|
+
const identity = yield* jjIdentity(repositoryPath, registered.commit)
|
|
1624
|
+
if (!identity || identity.commitId !== registered.commit) {
|
|
1625
|
+
return yield* new WorktreeError({
|
|
1626
|
+
message: `Cannot prove the exact jj target for workspace at ${checkout.registeredPath}`,
|
|
1627
|
+
})
|
|
1628
|
+
}
|
|
1282
1629
|
plans.push({
|
|
1630
|
+
repo: checkout.repo,
|
|
1283
1631
|
repositoryPath,
|
|
1284
1632
|
workspacePath: checkout.path,
|
|
1285
1633
|
workspaceName: registered.name,
|
|
1286
|
-
head:
|
|
1634
|
+
head: identity.commitId,
|
|
1287
1635
|
branch: checkout.actualBranch,
|
|
1288
1636
|
registered: true,
|
|
1637
|
+
...identity,
|
|
1638
|
+
bookmark: jjResumeBookmark(taskId, phaseId, checkout.repo),
|
|
1289
1639
|
})
|
|
1290
1640
|
}
|
|
1291
1641
|
|
|
@@ -1302,7 +1652,33 @@ const removeJj = (
|
|
|
1302
1652
|
if (options.dryRun) return plans.map((plan) => plan.workspacePath)
|
|
1303
1653
|
|
|
1304
1654
|
const completed: typeof plans = []
|
|
1655
|
+
const persistent = options.persistResume !== false && plans.length > 0
|
|
1656
|
+
const resume: JjResumeState = {
|
|
1657
|
+
version: 1,
|
|
1658
|
+
taskId,
|
|
1659
|
+
phaseId: phaseId ?? null,
|
|
1660
|
+
checkouts: plans.map((plan) => ({
|
|
1661
|
+
repo: plan.repo,
|
|
1662
|
+
workspacePath: plan.workspacePath,
|
|
1663
|
+
workspaceName: plan.workspaceName,
|
|
1664
|
+
commitId: plan.commitId,
|
|
1665
|
+
changeId: plan.changeId,
|
|
1666
|
+
bookmark: plan.bookmark,
|
|
1667
|
+
})),
|
|
1668
|
+
}
|
|
1669
|
+
const bookmarks: typeof plans = []
|
|
1305
1670
|
return yield* Effect.gen(function* () {
|
|
1671
|
+
if (persistent) {
|
|
1672
|
+
for (const plan of plans) {
|
|
1673
|
+
yield* jjSetBookmark(
|
|
1674
|
+
plan.repositoryPath,
|
|
1675
|
+
plan.bookmark,
|
|
1676
|
+
plan.commitId,
|
|
1677
|
+
)
|
|
1678
|
+
bookmarks.push(plan)
|
|
1679
|
+
}
|
|
1680
|
+
yield* writeJjResumeState(resumePath, resume)
|
|
1681
|
+
}
|
|
1306
1682
|
for (const plan of plans) {
|
|
1307
1683
|
if (plan.registered) {
|
|
1308
1684
|
yield* backend.removeWorkspace({
|
|
@@ -1322,19 +1698,45 @@ const removeJj = (
|
|
|
1322
1698
|
Effect.gen(function* () {
|
|
1323
1699
|
const rolledBack: string[] = []
|
|
1324
1700
|
const manualRecovery: string[] = []
|
|
1325
|
-
|
|
1701
|
+
const rollback = [...completed]
|
|
1702
|
+
for (const plan of plans) {
|
|
1703
|
+
if (completed.includes(plan)) continue
|
|
1704
|
+
const registered = (yield* backend.listWorkspaces(
|
|
1705
|
+
plan.repositoryPath,
|
|
1706
|
+
)).some((workspace) => workspace.name === plan.workspaceName)
|
|
1707
|
+
if (!registered) rollback.push(plan)
|
|
1708
|
+
}
|
|
1709
|
+
for (const plan of rollback.reverse()) {
|
|
1710
|
+
if (yield* fs.exists(plan.workspacePath)) {
|
|
1711
|
+
const removed = yield* fs
|
|
1712
|
+
.deleteDirectory(plan.workspacePath)
|
|
1713
|
+
.pipe(
|
|
1714
|
+
Effect.as(true),
|
|
1715
|
+
Effect.catchAll(() => Effect.succeed(false)),
|
|
1716
|
+
)
|
|
1717
|
+
if (!removed) {
|
|
1718
|
+
manualRecovery.push(`Restore ${plan.workspacePath}`)
|
|
1719
|
+
continue
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1326
1722
|
const restored = yield* Effect.either(
|
|
1327
|
-
backend
|
|
1723
|
+
restoreJjWorkspace(backend, {
|
|
1328
1724
|
repositoryPath: plan.repositoryPath,
|
|
1329
1725
|
workspacePath: plan.workspacePath,
|
|
1330
1726
|
workspaceName: plan.workspaceName,
|
|
1331
|
-
|
|
1332
|
-
...(plan.branch ? { branch: plan.branch } : {}),
|
|
1727
|
+
commitId: plan.commitId,
|
|
1333
1728
|
}),
|
|
1334
1729
|
)
|
|
1335
1730
|
if (restored._tag === "Right") rolledBack.push(plan.workspacePath)
|
|
1336
1731
|
else manualRecovery.push(`Restore ${plan.workspacePath}`)
|
|
1337
1732
|
}
|
|
1733
|
+
if (manualRecovery.length === 0 && persistent) {
|
|
1734
|
+
if (yield* fs.exists(resumePath)) yield* fs.deleteFile(resumePath)
|
|
1735
|
+
for (const plan of bookmarks)
|
|
1736
|
+
yield* jjDeleteBookmark(plan.repositoryPath, plan.bookmark).pipe(
|
|
1737
|
+
Effect.ignore,
|
|
1738
|
+
)
|
|
1739
|
+
}
|
|
1338
1740
|
return yield* new WorktreeError({
|
|
1339
1741
|
message: manualRecovery.length
|
|
1340
1742
|
? "Workspace removal failed and requires manual recovery"
|
|
@@ -2392,7 +2794,12 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
2392
2794
|
root,
|
|
2393
2795
|
)
|
|
2394
2796
|
const blockingConflicts = inspection.conflicts.filter(
|
|
2395
|
-
(conflict) =>
|
|
2797
|
+
(conflict) =>
|
|
2798
|
+
conflict.kind !== "stale-registration" &&
|
|
2799
|
+
!(
|
|
2800
|
+
options.allowReferenceDrift &&
|
|
2801
|
+
conflict.kind === "reference-drift"
|
|
2802
|
+
),
|
|
2396
2803
|
)
|
|
2397
2804
|
if (blockingConflicts.length > 0) {
|
|
2398
2805
|
return yield* new WorktreeError({
|
|
@@ -2723,21 +3130,29 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
2723
3130
|
)
|
|
2724
3131
|
}
|
|
2725
3132
|
const inspection = yield* inspectExecution(taskId, phaseId, root)
|
|
2726
|
-
const
|
|
2727
|
-
(checkout) => checkout.dirty
|
|
3133
|
+
const notProvablyClean = inspection.checkouts.filter(
|
|
3134
|
+
(checkout) => checkout.exists && checkout.dirty !== false,
|
|
2728
3135
|
)
|
|
2729
|
-
if (
|
|
3136
|
+
if (notProvablyClean.length > 0) {
|
|
3137
|
+
const unknown = notProvablyClean.some(
|
|
3138
|
+
(checkout) => checkout.dirty === null,
|
|
3139
|
+
)
|
|
2730
3140
|
return yield* new WorktreeError({
|
|
2731
|
-
message: `Cannot rebuild ${
|
|
2732
|
-
conflicts:
|
|
3141
|
+
message: `Cannot rebuild ${notProvablyClean.map((checkout) => checkout.path).join(", ")}; ${unknown ? "checkout cleanliness could not be verified" : "checkout has uncommitted changes"}`,
|
|
3142
|
+
conflicts: notProvablyClean.flatMap(
|
|
3143
|
+
(checkout) => checkout.conflicts,
|
|
3144
|
+
),
|
|
2733
3145
|
})
|
|
2734
3146
|
}
|
|
2735
|
-
|
|
3147
|
+
const blockingConflicts = inspection.conflicts.filter(
|
|
3148
|
+
(conflict) => conflict.kind !== "reference-drift",
|
|
3149
|
+
)
|
|
3150
|
+
if (blockingConflicts.length > 0) {
|
|
2736
3151
|
return yield* new WorktreeError({
|
|
2737
|
-
message:
|
|
3152
|
+
message: blockingConflicts
|
|
2738
3153
|
.map(({ message }) => message)
|
|
2739
3154
|
.join("\n"),
|
|
2740
|
-
conflicts:
|
|
3155
|
+
conflicts: blockingConflicts,
|
|
2741
3156
|
})
|
|
2742
3157
|
}
|
|
2743
3158
|
yield* service.materialize(taskId, phaseId, inspection.root, {
|
|
@@ -2745,6 +3160,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
2745
3160
|
dryRun: true,
|
|
2746
3161
|
silent: true,
|
|
2747
3162
|
lockHeld: true,
|
|
3163
|
+
allowReferenceDrift: true,
|
|
2748
3164
|
})
|
|
2749
3165
|
if (options.dryRun) {
|
|
2750
3166
|
return {
|
|
@@ -2768,6 +3184,8 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
2768
3184
|
...options,
|
|
2769
3185
|
snapshots,
|
|
2770
3186
|
lockHeld: true,
|
|
3187
|
+
allowReferenceDrift: true,
|
|
3188
|
+
persistResume: false,
|
|
2771
3189
|
},
|
|
2772
3190
|
)
|
|
2773
3191
|
const workspace = yield* service
|
|
@@ -2819,7 +3237,14 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
2819
3237
|
const restored = yield* fs.runCommand(command, {
|
|
2820
3238
|
captureOutput: true,
|
|
2821
3239
|
})
|
|
2822
|
-
|
|
3240
|
+
const edited =
|
|
3241
|
+
restored.exitCode === 0 && snapshot.vcs === "jj"
|
|
3242
|
+
? yield* fs.runCommand(
|
|
3243
|
+
["jj", "-R", snapshot.path, "edit", snapshot.head],
|
|
3244
|
+
{ captureOutput: true },
|
|
3245
|
+
)
|
|
3246
|
+
: restored
|
|
3247
|
+
if (edited.exitCode === 0) rolledBack.push(snapshot.path)
|
|
2823
3248
|
else manualRecovery.push(`Restore ${snapshot.path}`)
|
|
2824
3249
|
}
|
|
2825
3250
|
return yield* new WorktreeError({
|