@markjaquith/agency 2.58.2 → 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")
|
|
@@ -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
|
|
@@ -261,6 +278,7 @@ interface RemoveOptions extends BaseCommandOptions {
|
|
|
261
278
|
readonly snapshots?: WorktreeRemovalSnapshot[]
|
|
262
279
|
readonly lockHeld?: boolean
|
|
263
280
|
readonly allowReferenceDrift?: boolean
|
|
281
|
+
readonly persistResume?: boolean
|
|
264
282
|
}
|
|
265
283
|
|
|
266
284
|
interface LifecycleOptions extends BaseCommandOptions {
|
|
@@ -772,6 +790,177 @@ const jjWorkspaceName = (
|
|
|
772
790
|
repo: string,
|
|
773
791
|
) => `agency-${taskId}-${phaseId ?? "task"}-${repo}`
|
|
774
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
|
+
|
|
775
964
|
// A process can stop after `jj workspace forget` but before checkout deletion.
|
|
776
965
|
// Only recover that residual when its repository and complete tree still match.
|
|
777
966
|
const inspectJjResidual = (
|
|
@@ -969,6 +1158,8 @@ const materializeJj = (options: {
|
|
|
969
1158
|
workspacePath: string
|
|
970
1159
|
workspaceName: string
|
|
971
1160
|
}[] = []
|
|
1161
|
+
const resumePath = jjResumePath(options.taskPath, options.phasePath)
|
|
1162
|
+
const resume = yield* readJjResumeState(resumePath)
|
|
972
1163
|
const base = "base" in options.execution ? options.execution.base : null
|
|
973
1164
|
const { verboseLog } = createLoggers(options.commandOptions)
|
|
974
1165
|
const forwardCommandOutput =
|
|
@@ -978,6 +1169,40 @@ const materializeJj = (options: {
|
|
|
978
1169
|
|
|
979
1170
|
if (!options.commandOptions.dryRun)
|
|
980
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
|
+
}
|
|
981
1206
|
|
|
982
1207
|
return yield* Effect.gen(function* () {
|
|
983
1208
|
for (const checkout of options.requestedCheckouts) {
|
|
@@ -988,19 +1213,38 @@ const materializeJj = (options: {
|
|
|
988
1213
|
options.phaseId,
|
|
989
1214
|
checkout.repo,
|
|
990
1215
|
)
|
|
1216
|
+
const resumeCheckout = resume?.checkouts.find(
|
|
1217
|
+
(entry) => entry.repo === checkout.repo,
|
|
1218
|
+
)
|
|
991
1219
|
if (!(yield* fs.exists(repositoryPath))) {
|
|
992
1220
|
return yield* new WorktreeError({
|
|
993
1221
|
message: `Repository alias '${checkout.repo}' is not materialized; run 'agency repo setup --apply'`,
|
|
994
1222
|
})
|
|
995
1223
|
}
|
|
996
1224
|
|
|
997
|
-
|
|
1225
|
+
let exists = yield* fs.isDirectory(workspacePath)
|
|
998
1226
|
const canonicalPath = exists
|
|
999
1227
|
? yield* fs.realPath(workspacePath)
|
|
1000
1228
|
: resolve(workspacePath)
|
|
1001
1229
|
const registered = (yield* backend.listWorkspaces(repositoryPath)).find(
|
|
1002
1230
|
(workspace) => workspace.path === canonicalPath,
|
|
1003
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
|
+
}
|
|
1004
1248
|
if (exists && !registered) {
|
|
1005
1249
|
return yield* new WorktreeError({
|
|
1006
1250
|
message: `Existing checkout ${workspacePath} is not registered as a jj workspace`,
|
|
@@ -1012,8 +1256,15 @@ const materializeJj = (options: {
|
|
|
1012
1256
|
})
|
|
1013
1257
|
}
|
|
1014
1258
|
if (exists && registered) {
|
|
1015
|
-
const actualCommit =
|
|
1016
|
-
|
|
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) {
|
|
1017
1268
|
const expectedCommit = yield* backend.resolveRevision(
|
|
1018
1269
|
repositoryPath,
|
|
1019
1270
|
checkout.ref,
|
|
@@ -1045,10 +1296,9 @@ const materializeJj = (options: {
|
|
|
1045
1296
|
|
|
1046
1297
|
const requestedRevision =
|
|
1047
1298
|
"branch" in checkout ? checkout.branch : checkout.ref
|
|
1048
|
-
let revision =
|
|
1049
|
-
|
|
1050
|
-
requestedRevision
|
|
1051
|
-
)
|
|
1299
|
+
let revision =
|
|
1300
|
+
resumeCheckout?.commitId ??
|
|
1301
|
+
(yield* backend.resolveRevision(repositoryPath, requestedRevision))
|
|
1052
1302
|
if (!revision && "branch" in checkout && base) {
|
|
1053
1303
|
revision = yield* backend.resolveRevision(repositoryPath, base)
|
|
1054
1304
|
}
|
|
@@ -1080,20 +1330,45 @@ const materializeJj = (options: {
|
|
|
1080
1330
|
command,
|
|
1081
1331
|
status: options.commandOptions.dryRun ? "planned" : "completed",
|
|
1082
1332
|
})
|
|
1083
|
-
if (
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
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",
|
|
1090
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
|
+
}
|
|
1091
1364
|
created.push({ repositoryPath, workspacePath, workspaceName })
|
|
1092
1365
|
const canonicalWorkspacePath = yield* fs.realPath(workspacePath)
|
|
1093
1366
|
const registeredAfterCreate = (yield* backend.listWorkspaces(
|
|
1094
1367
|
repositoryPath,
|
|
1095
1368
|
)).find((workspace) => workspace.path === canonicalWorkspacePath)
|
|
1096
|
-
const head =
|
|
1369
|
+
const head = resumeCheckout
|
|
1370
|
+
? ((yield* jjIdentity(workspacePath, "@"))?.commitId ?? null)
|
|
1371
|
+
: yield* backend.workspaceHead(workspacePath)
|
|
1097
1372
|
if (!registeredAfterCreate || head !== revision) {
|
|
1098
1373
|
return yield* new WorktreeError({
|
|
1099
1374
|
message: `Created jj workspace for '${checkout.repo}' failed validation`,
|
|
@@ -1130,6 +1405,16 @@ const materializeJj = (options: {
|
|
|
1130
1405
|
})
|
|
1131
1406
|
}
|
|
1132
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
|
+
|
|
1133
1418
|
return {
|
|
1134
1419
|
root: options.root,
|
|
1135
1420
|
taskPath: options.taskPath,
|
|
@@ -1209,6 +1494,16 @@ const removeJj = (
|
|
|
1209
1494
|
"phases" in task.data && phaseId
|
|
1210
1495
|
? (yield* phases.show(taskId, phaseId, root)).data
|
|
1211
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
|
+
}
|
|
1212
1507
|
const recoverable = new Map<string, string>()
|
|
1213
1508
|
const modifiedResiduals = new Map<string, string>()
|
|
1214
1509
|
for (const checkout of inspection.checkouts) {
|
|
@@ -1242,7 +1537,6 @@ const removeJj = (
|
|
|
1242
1537
|
checkout.conflicts
|
|
1243
1538
|
.filter(
|
|
1244
1539
|
(conflict) =>
|
|
1245
|
-
conflict.kind !== "stale-registration" &&
|
|
1246
1540
|
!(
|
|
1247
1541
|
options.allowReferenceDrift && conflict.kind === "reference-drift"
|
|
1248
1542
|
) &&
|
|
@@ -1269,48 +1563,79 @@ const removeJj = (
|
|
|
1269
1563
|
}
|
|
1270
1564
|
|
|
1271
1565
|
const plans: {
|
|
1566
|
+
repo: string
|
|
1272
1567
|
repositoryPath: string
|
|
1273
1568
|
workspacePath: string
|
|
1274
1569
|
workspaceName: string
|
|
1275
1570
|
head: string
|
|
1276
1571
|
branch: string | null
|
|
1277
1572
|
registered: boolean
|
|
1573
|
+
commitId: string
|
|
1574
|
+
changeId: string
|
|
1575
|
+
bookmark: string
|
|
1278
1576
|
}[] = []
|
|
1279
1577
|
for (const checkout of inspection.checkouts) {
|
|
1280
|
-
if (checkout.dirty) {
|
|
1578
|
+
if (checkout.dirty === true && options.persistResume === false) {
|
|
1281
1579
|
return yield* new WorktreeError({
|
|
1282
1580
|
message: `Failed to remove workspace for '${checkout.repo}': checkout has uncommitted changes`,
|
|
1283
1581
|
})
|
|
1284
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
|
+
}
|
|
1285
1592
|
const repositoryPath = join(root, "repos", checkout.repo)
|
|
1286
1593
|
if (!checkout.registeredPath) {
|
|
1287
1594
|
const recoveryRevision = recoverable.get(checkout.path)
|
|
1288
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
|
+
}
|
|
1289
1602
|
plans.push({
|
|
1603
|
+
repo: checkout.repo,
|
|
1290
1604
|
repositoryPath,
|
|
1291
1605
|
workspacePath: checkout.path,
|
|
1292
1606
|
workspaceName: jjWorkspaceName(taskId, phaseId, checkout.repo),
|
|
1293
1607
|
head: recoveryRevision,
|
|
1294
1608
|
branch: checkout.actualBranch,
|
|
1295
1609
|
registered: false,
|
|
1610
|
+
...identity,
|
|
1611
|
+
bookmark: jjResumeBookmark(taskId, phaseId, checkout.repo),
|
|
1296
1612
|
})
|
|
1297
1613
|
continue
|
|
1298
1614
|
}
|
|
1299
1615
|
const registered = (yield* backend.listWorkspaces(repositoryPath)).find(
|
|
1300
1616
|
(workspace) => workspace.path === checkout.registeredPath,
|
|
1301
1617
|
)
|
|
1302
|
-
if (!registered?.name || !checkout.actualCommit) {
|
|
1618
|
+
if (!registered?.name || !checkout.actualCommit || !registered.commit) {
|
|
1303
1619
|
return yield* new WorktreeError({
|
|
1304
1620
|
message: `Cannot identify jj workspace at ${checkout.registeredPath}`,
|
|
1305
1621
|
})
|
|
1306
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
|
+
}
|
|
1307
1629
|
plans.push({
|
|
1630
|
+
repo: checkout.repo,
|
|
1308
1631
|
repositoryPath,
|
|
1309
1632
|
workspacePath: checkout.path,
|
|
1310
1633
|
workspaceName: registered.name,
|
|
1311
|
-
head:
|
|
1634
|
+
head: identity.commitId,
|
|
1312
1635
|
branch: checkout.actualBranch,
|
|
1313
1636
|
registered: true,
|
|
1637
|
+
...identity,
|
|
1638
|
+
bookmark: jjResumeBookmark(taskId, phaseId, checkout.repo),
|
|
1314
1639
|
})
|
|
1315
1640
|
}
|
|
1316
1641
|
|
|
@@ -1327,7 +1652,33 @@ const removeJj = (
|
|
|
1327
1652
|
if (options.dryRun) return plans.map((plan) => plan.workspacePath)
|
|
1328
1653
|
|
|
1329
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 = []
|
|
1330
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
|
+
}
|
|
1331
1682
|
for (const plan of plans) {
|
|
1332
1683
|
if (plan.registered) {
|
|
1333
1684
|
yield* backend.removeWorkspace({
|
|
@@ -1347,19 +1698,45 @@ const removeJj = (
|
|
|
1347
1698
|
Effect.gen(function* () {
|
|
1348
1699
|
const rolledBack: string[] = []
|
|
1349
1700
|
const manualRecovery: string[] = []
|
|
1350
|
-
|
|
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
|
+
}
|
|
1351
1722
|
const restored = yield* Effect.either(
|
|
1352
|
-
backend
|
|
1723
|
+
restoreJjWorkspace(backend, {
|
|
1353
1724
|
repositoryPath: plan.repositoryPath,
|
|
1354
1725
|
workspacePath: plan.workspacePath,
|
|
1355
1726
|
workspaceName: plan.workspaceName,
|
|
1356
|
-
|
|
1357
|
-
...(plan.branch ? { branch: plan.branch } : {}),
|
|
1727
|
+
commitId: plan.commitId,
|
|
1358
1728
|
}),
|
|
1359
1729
|
)
|
|
1360
1730
|
if (restored._tag === "Right") rolledBack.push(plan.workspacePath)
|
|
1361
1731
|
else manualRecovery.push(`Restore ${plan.workspacePath}`)
|
|
1362
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
|
+
}
|
|
1363
1740
|
return yield* new WorktreeError({
|
|
1364
1741
|
message: manualRecovery.length
|
|
1365
1742
|
? "Workspace removal failed and requires manual recovery"
|
|
@@ -2808,6 +3185,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
2808
3185
|
snapshots,
|
|
2809
3186
|
lockHeld: true,
|
|
2810
3187
|
allowReferenceDrift: true,
|
|
3188
|
+
persistResume: false,
|
|
2811
3189
|
},
|
|
2812
3190
|
)
|
|
2813
3191
|
const workspace = yield* service
|
|
@@ -2859,7 +3237,14 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
2859
3237
|
const restored = yield* fs.runCommand(command, {
|
|
2860
3238
|
captureOutput: true,
|
|
2861
3239
|
})
|
|
2862
|
-
|
|
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)
|
|
2863
3248
|
else manualRecovery.push(`Restore ${snapshot.path}`)
|
|
2864
3249
|
}
|
|
2865
3250
|
return yield* new WorktreeError({
|