@markjaquith/agency 2.50.0 → 2.52.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.
Files changed (36) hide show
  1. package/README.md +34 -8
  2. package/cli.ts +33 -1
  3. package/package.json +1 -1
  4. package/schemas/agency-graph-v1.schema.json +1 -0
  5. package/src/cli-parser.test.ts +19 -0
  6. package/src/cli-parser.ts +23 -0
  7. package/src/cli.test.ts +4 -4
  8. package/src/commands/init.test.ts +2 -0
  9. package/src/commands/vcs.test.ts +75 -0
  10. package/src/commands/vcs.ts +72 -0
  11. package/src/commands/worktree.ts +1 -1
  12. package/src/graph-schema.test.ts +1 -1
  13. package/src/graph-schema.ts +1 -0
  14. package/src/services/ArchiveService.ts +23 -0
  15. package/src/services/ContextService.ts +62 -13
  16. package/src/services/DoctorService.ts +13 -6
  17. package/src/services/GraphService.ts +1 -0
  18. package/src/services/PhaseService.ts +191 -70
  19. package/src/services/PullRequestService.ts +14 -30
  20. package/src/services/RepositoryService.test.ts +31 -1
  21. package/src/services/RepositoryService.ts +63 -31
  22. package/src/services/ReviewService.ts +23 -0
  23. package/src/services/SyncService.test.ts +67 -0
  24. package/src/services/SyncService.ts +51 -84
  25. package/src/services/TaskPhaseService.test.ts +80 -0
  26. package/src/services/VcsMigrationService.test.ts +245 -0
  27. package/src/services/VcsMigrationService.ts +812 -0
  28. package/src/services/VersionControlService.test.ts +100 -0
  29. package/src/services/VersionControlService.ts +479 -0
  30. package/src/services/WorkbaseService.ts +5 -1
  31. package/src/services/WorktreeService.test.ts +72 -1
  32. package/src/services/WorktreeService.ts +808 -308
  33. package/src/test-utils.ts +10 -0
  34. package/src/workbase/AGENTS.md +2 -2
  35. package/src/workbase/schemas.ts +1 -0
  36. package/src/workbase/version-control.ts +5 -0
@@ -0,0 +1,812 @@
1
+ import { Data, Effect, Either } from "effect"
2
+ import { rename, rm } from "node:fs/promises"
3
+ import { dirname, join } from "node:path"
4
+ import { documentRevision } from "../workbase/document-revision"
5
+ import type { WorkStatus } from "../workbase/schemas"
6
+ import {
7
+ documentWriteStep,
8
+ runLifecycleTransaction,
9
+ } from "./LifecycleTransaction"
10
+ import { FileSystemService } from "./FileSystemService"
11
+ import { PhaseService } from "./PhaseService"
12
+ import { RepositoryService } from "./RepositoryService"
13
+ import { TaskService } from "./TaskService"
14
+ import {
15
+ GitVersionControlService,
16
+ JjVersionControlService,
17
+ type VersionControlBackend,
18
+ } from "./VersionControlService"
19
+ import { WorkbaseService } from "./WorkbaseService"
20
+ import { withWorktreeLocks, type WorktreeLockTarget } from "./WorktreeLock"
21
+ import { WorktreeService } from "./WorktreeService"
22
+
23
+ type VcsKind = "git" | "jj"
24
+
25
+ class VcsMigrationError extends Data.TaggedError("VcsMigrationError")<{
26
+ readonly message: string
27
+ readonly blockers?: readonly MigrationBlocker[]
28
+ }> {}
29
+
30
+ interface MigrationBlocker {
31
+ readonly kind:
32
+ | "active-work"
33
+ | "dirty-workspace"
34
+ | "workspace-conflict"
35
+ | "repository"
36
+ | "tool"
37
+ | "jj-only-head"
38
+ readonly target: string
39
+ readonly message: string
40
+ }
41
+
42
+ interface WorkspacePlan {
43
+ readonly taskId: string
44
+ readonly phaseId?: string
45
+ readonly repo: string
46
+ readonly kind: "writable" | "reference"
47
+ readonly path: string
48
+ readonly head: string
49
+ readonly branch: string | null
50
+ readonly sourceName: string | null
51
+ readonly targetName: string
52
+ readonly previousBranchCommit: string | null
53
+ }
54
+
55
+ interface RepositoryPlan {
56
+ readonly alias: string
57
+ readonly path: string
58
+ readonly target: string
59
+ readonly kind: "bare" | "repository" | "symlink"
60
+ readonly remote: string | null
61
+ }
62
+
63
+ interface MigrationState {
64
+ readonly root: string
65
+ readonly configured: VcsKind | null
66
+ readonly source: VcsKind
67
+ readonly target: VcsKind
68
+ readonly available: { readonly git: boolean; readonly jj: boolean }
69
+ readonly repositories: readonly {
70
+ readonly alias: string
71
+ readonly path: string
72
+ readonly kind: "bare" | "repository" | "symlink" | null
73
+ readonly initialized: boolean
74
+ }[]
75
+ readonly workspaceCount: number
76
+ readonly blockers: readonly MigrationBlocker[]
77
+ }
78
+
79
+ interface MigrationResult extends MigrationState {
80
+ readonly mode: "dry-run" | "apply"
81
+ readonly actions: readonly string[]
82
+ }
83
+
84
+ const command = (
85
+ fs: FileSystemService,
86
+ args: readonly string[],
87
+ label: string,
88
+ ) =>
89
+ fs.runCommand(args, { captureOutput: true }).pipe(
90
+ Effect.flatMap((result) =>
91
+ result.exitCode === 0
92
+ ? Effect.succeed(result.stdout.trim())
93
+ : Effect.fail(
94
+ new VcsMigrationError({
95
+ message: `${label}: ${result.stderr.trim() || result.stdout.trim()}`,
96
+ }),
97
+ ),
98
+ ),
99
+ )
100
+
101
+ const runBackend = <A>(
102
+ fs: FileSystemService,
103
+ effect: Effect.Effect<A, unknown, any>,
104
+ ) =>
105
+ Effect.runPromise(
106
+ effect.pipe(Effect.provideService(FileSystemService, fs)) as Effect.Effect<
107
+ A,
108
+ unknown,
109
+ never
110
+ >,
111
+ )
112
+
113
+ const workspaceName = (plan: {
114
+ readonly taskId: string
115
+ readonly phaseId?: string
116
+ readonly repo: string
117
+ }) => `agency-${plan.taskId}-${plan.phaseId ?? "task"}-${plan.repo}`
118
+
119
+ const createGitWorkspace = (
120
+ fs: FileSystemService,
121
+ plan: WorkspacePlan,
122
+ repositoryPath: string,
123
+ ) =>
124
+ Effect.gen(function* () {
125
+ if (plan.branch) {
126
+ yield* command(
127
+ fs,
128
+ ["git", "-C", repositoryPath, "branch", "-f", plan.branch, plan.head],
129
+ `Failed to prepare branch '${plan.branch}'`,
130
+ )
131
+ }
132
+ yield* command(
133
+ fs,
134
+ plan.branch
135
+ ? [
136
+ "git",
137
+ "-C",
138
+ repositoryPath,
139
+ "worktree",
140
+ "add",
141
+ plan.path,
142
+ plan.branch,
143
+ ]
144
+ : [
145
+ "git",
146
+ "-C",
147
+ repositoryPath,
148
+ "worktree",
149
+ "add",
150
+ "--detach",
151
+ plan.path,
152
+ plan.head,
153
+ ],
154
+ `Failed to create Git worktree ${plan.path}`,
155
+ )
156
+ })
157
+
158
+ const restoreGitBranch = (
159
+ fs: FileSystemService,
160
+ plan: WorkspacePlan,
161
+ repositoryPath: string,
162
+ ) =>
163
+ plan.branch
164
+ ? command(
165
+ fs,
166
+ plan.previousBranchCommit
167
+ ? [
168
+ "git",
169
+ "-C",
170
+ repositoryPath,
171
+ "branch",
172
+ "-f",
173
+ plan.branch,
174
+ plan.previousBranchCommit,
175
+ ]
176
+ : ["git", "-C", repositoryPath, "branch", "-D", plan.branch],
177
+ `Failed to restore branch '${plan.branch}'`,
178
+ ).pipe(Effect.asVoid)
179
+ : Effect.void
180
+
181
+ const executionRecords = (root: string) =>
182
+ Effect.gen(function* () {
183
+ const tasks = yield* TaskService
184
+ const phases = yield* PhaseService
185
+ const records: {
186
+ taskId: string
187
+ phaseId?: string
188
+ status: WorkStatus
189
+ claimActive: boolean
190
+ }[] = []
191
+ for (const task of yield* tasks.list(root)) {
192
+ if ("phases" in task.data) {
193
+ for (const phase of yield* phases.list(task.id, root)) {
194
+ records.push({
195
+ taskId: task.id,
196
+ phaseId: phase.id,
197
+ status: phase.data.status,
198
+ claimActive: phase.data.claim?.state === "active",
199
+ })
200
+ }
201
+ } else {
202
+ records.push({
203
+ taskId: task.id,
204
+ status: task.data.status,
205
+ claimActive: task.data.claim?.state === "active",
206
+ })
207
+ }
208
+ }
209
+ return records
210
+ })
211
+
212
+ const inspectMigration = (startPath: string, requestedTarget?: VcsKind) =>
213
+ Effect.gen(function* () {
214
+ const fs = yield* FileSystemService
215
+ const workbase = yield* WorkbaseService
216
+ const repositories = yield* RepositoryService
217
+ const worktrees = yield* WorktreeService
218
+ const git = yield* GitVersionControlService
219
+ const jj = yield* JjVersionControlService
220
+ const { root, config } = yield* workbase.loadConfig(startPath)
221
+ const source = config.vcs ?? "git"
222
+ const target = requestedTarget ?? source
223
+ const sourceBackend = source === "jj" ? jj : git
224
+ const available = {
225
+ git: Bun.which("git") !== null,
226
+ jj: Bun.which("jj") !== null,
227
+ }
228
+ const blockers: MigrationBlocker[] = []
229
+ if (!available.git) {
230
+ blockers.push({
231
+ kind: "tool",
232
+ target: "git",
233
+ message: "The git executable is required for VCS migration",
234
+ })
235
+ }
236
+ if (target === "jj" && !available.jj) {
237
+ blockers.push({
238
+ kind: "tool",
239
+ target: "jj",
240
+ message: "The jj executable is required for migration to jj",
241
+ })
242
+ }
243
+
244
+ const records = yield* executionRecords(root)
245
+ for (const record of records) {
246
+ if (record.claimActive) {
247
+ const label = record.phaseId
248
+ ? `phase:${record.taskId}/${record.phaseId}`
249
+ : `task:${record.taskId}`
250
+ blockers.push({
251
+ kind: "active-work",
252
+ target: label,
253
+ message: `${label} is active; finish or release it before migration`,
254
+ })
255
+ }
256
+ }
257
+
258
+ const repositoryRecords = yield* repositories.list(root)
259
+ const repositoryPlans: RepositoryPlan[] = []
260
+ const repositoryStatus: MigrationState["repositories"][number][] = []
261
+ for (const repository of repositoryRecords) {
262
+ const initialized = yield* fs.exists(
263
+ join(
264
+ repository.kind === "symlink"
265
+ ? yield* fs.realPath(repository.path)
266
+ : repository.path,
267
+ ".jj",
268
+ ),
269
+ )
270
+ repositoryStatus.push({
271
+ alias: repository.alias,
272
+ path: repository.path,
273
+ kind: repository.kind,
274
+ initialized,
275
+ })
276
+ if (
277
+ repository.kind === null ||
278
+ repository.states.includes("missing") ||
279
+ repository.states.includes("invalid")
280
+ ) {
281
+ blockers.push({
282
+ kind: "repository",
283
+ target: `repository:${repository.alias}`,
284
+ message: `Repository '${repository.alias}' must be valid and materialized before migration`,
285
+ })
286
+ continue
287
+ }
288
+ if (source === "jj" && !initialized) {
289
+ blockers.push({
290
+ kind: "repository",
291
+ target: `repository:${repository.alias}`,
292
+ message: `Repository '${repository.alias}' is not initialized for the configured jj backend`,
293
+ })
294
+ }
295
+ const targetPath =
296
+ repository.kind === "symlink"
297
+ ? yield* fs.realPath(repository.path)
298
+ : repository.path
299
+ repositoryPlans.push({
300
+ alias: repository.alias,
301
+ path: repository.path,
302
+ target: targetPath,
303
+ kind: repository.kind,
304
+ remote: repository.declaredRemote ?? repository.remote,
305
+ })
306
+ if (source === "jj" && target === "git" && initialized) {
307
+ const dirty = yield* sourceBackend.workspaceDirty(targetPath)
308
+ if (dirty !== false) {
309
+ blockers.push({
310
+ kind: "dirty-workspace",
311
+ target: `repository:${repository.alias}`,
312
+ message: `Primary jj workspace for '${repository.alias}' must be clean`,
313
+ })
314
+ }
315
+ const hiddenHeads = yield* command(
316
+ fs,
317
+ [
318
+ "jj",
319
+ "-R",
320
+ targetPath,
321
+ "log",
322
+ "--ignore-working-copy",
323
+ "--no-graph",
324
+ "-r",
325
+ "heads(all()) ~ (bookmarks() | remote_bookmarks() | working_copies())",
326
+ "-T",
327
+ 'commit_id ++ "\\n"',
328
+ ],
329
+ `Failed to inspect jj-only heads for '${repository.alias}'`,
330
+ )
331
+ if (hiddenHeads) {
332
+ blockers.push({
333
+ kind: "jj-only-head",
334
+ target: `repository:${repository.alias}`,
335
+ message: `Repository '${repository.alias}' has jj-only heads that are not preserved by bookmarks or workspaces`,
336
+ })
337
+ }
338
+ }
339
+ }
340
+
341
+ const workspacePlans: WorkspacePlan[] = []
342
+ const inspected = yield* Effect.either(worktrees.list(root))
343
+ if (Either.isLeft(inspected)) {
344
+ blockers.push({
345
+ kind: "workspace-conflict",
346
+ target: "workbase",
347
+ message: "Managed workspaces could not be inspected",
348
+ })
349
+ } else {
350
+ for (const inspection of inspected.right) {
351
+ for (const checkout of inspection.checkouts) {
352
+ if (checkout.conflicts.length > 0) {
353
+ blockers.push({
354
+ kind: "workspace-conflict",
355
+ target: checkout.path,
356
+ message: checkout.conflicts
357
+ .map(({ message }) => message)
358
+ .join("; "),
359
+ })
360
+ continue
361
+ }
362
+ if (!checkout.exists) continue
363
+ if (checkout.dirty !== false) {
364
+ blockers.push({
365
+ kind: "dirty-workspace",
366
+ target: checkout.path,
367
+ message: `Workspace ${checkout.path} must be clean before migration`,
368
+ })
369
+ continue
370
+ }
371
+ if (!checkout.actualCommit || !checkout.registeredPath) {
372
+ blockers.push({
373
+ kind: "workspace-conflict",
374
+ target: checkout.path,
375
+ message: `Workspace ${checkout.path} has incomplete registration metadata`,
376
+ })
377
+ continue
378
+ }
379
+ const sourceName =
380
+ source === "jj"
381
+ ? ((yield* sourceBackend.listWorkspaces(
382
+ join(root, "repos", checkout.repo),
383
+ )).find((item) => item.path === checkout.registeredPath)
384
+ ?.name ?? null)
385
+ : null
386
+ const previousBranchCommit = checkout.actualBranch
387
+ ? yield* command(
388
+ fs,
389
+ [
390
+ "git",
391
+ "-C",
392
+ join(root, "repos", checkout.repo),
393
+ "rev-parse",
394
+ "--verify",
395
+ `${checkout.actualBranch}^{commit}`,
396
+ ],
397
+ `Failed to inspect branch '${checkout.actualBranch}'`,
398
+ ).pipe(Effect.catchAll(() => Effect.succeed(null)))
399
+ : null
400
+ workspacePlans.push({
401
+ taskId: inspection.owner.taskId,
402
+ ...(inspection.owner.phaseId
403
+ ? { phaseId: inspection.owner.phaseId }
404
+ : {}),
405
+ repo: checkout.repo,
406
+ kind: checkout.kind,
407
+ path: checkout.path,
408
+ head: checkout.actualCommit,
409
+ branch: checkout.kind === "writable" ? checkout.requestedRef : null,
410
+ sourceName,
411
+ targetName: workspaceName({
412
+ taskId: inspection.owner.taskId,
413
+ phaseId: inspection.owner.phaseId,
414
+ repo: checkout.repo,
415
+ }),
416
+ previousBranchCommit,
417
+ })
418
+ }
419
+ }
420
+ }
421
+
422
+ const uniqueRepositoryPlans = [
423
+ ...new Map(repositoryPlans.map((plan) => [plan.target, plan])).values(),
424
+ ]
425
+ return {
426
+ state: {
427
+ root,
428
+ configured: config.vcs ?? null,
429
+ source,
430
+ target,
431
+ available,
432
+ repositories: repositoryStatus,
433
+ workspaceCount: workspacePlans.length,
434
+ blockers,
435
+ } satisfies MigrationState,
436
+ repositoryPlans: uniqueRepositoryPlans,
437
+ workspacePlans,
438
+ records,
439
+ config,
440
+ sourceBackend,
441
+ targetBackend: target === "jj" ? jj : git,
442
+ }
443
+ })
444
+
445
+ export class VcsMigrationService extends Effect.Service<VcsMigrationService>()(
446
+ "VcsMigrationService",
447
+ {
448
+ sync: () => ({
449
+ status: (startPath: string = process.cwd()) =>
450
+ inspectMigration(startPath).pipe(Effect.map(({ state }) => state)),
451
+
452
+ migrate: (
453
+ target: VcsKind,
454
+ startPath: string = process.cwd(),
455
+ options: { readonly apply?: boolean } = {},
456
+ ) =>
457
+ Effect.gen(function* () {
458
+ const fs = yield* FileSystemService
459
+ const inspected = yield* inspectMigration(startPath, target)
460
+ const { state, repositoryPlans, workspacePlans, records, config } =
461
+ inspected
462
+ const actions = [
463
+ ...workspacePlans.map(
464
+ (plan) =>
465
+ `replace ${state.source} workspace with ${target} workspace at ${plan.path}`,
466
+ ),
467
+ ...repositoryPlans.map((plan) =>
468
+ target === "jj"
469
+ ? `initialize jj repository ${plan.alias}`
470
+ : `remove jj metadata from repository ${plan.alias}`,
471
+ ),
472
+ `set workbase vcs to ${target}`,
473
+ ]
474
+ if (state.source === target) {
475
+ const explicit = config.vcs === target
476
+ const sameBackendActions = explicit
477
+ ? []
478
+ : [`set workbase vcs to ${target}`]
479
+ if (options.apply && !explicit) {
480
+ const configPath = join(state.root, "agency.json")
481
+ const content = yield* fs.readFile(configPath)
482
+ yield* runLifecycleTransaction({
483
+ root: state.root,
484
+ preconditions: [
485
+ { path: configPath, revision: documentRevision(content) },
486
+ ],
487
+ steps: [
488
+ documentWriteStep(state.root, [
489
+ {
490
+ path: configPath,
491
+ content: `${JSON.stringify({ ...config, vcs: target }, null, 2)}\n`,
492
+ },
493
+ ]),
494
+ ],
495
+ })
496
+ }
497
+ return {
498
+ ...state,
499
+ configured: options.apply ? target : state.configured,
500
+ mode: options.apply ? "apply" : "dry-run",
501
+ actions: sameBackendActions,
502
+ } satisfies MigrationResult
503
+ }
504
+ if (!options.apply) {
505
+ return {
506
+ ...state,
507
+ mode: "dry-run",
508
+ actions,
509
+ } satisfies MigrationResult
510
+ }
511
+ if (state.blockers.length > 0) {
512
+ return yield* new VcsMigrationError({
513
+ message: state.blockers.map(({ message }) => message).join("\n"),
514
+ blockers: state.blockers,
515
+ })
516
+ }
517
+ const removedSource: WorkspacePlan[] = []
518
+ const createdTarget: WorkspacePlan[] = []
519
+ const repositoryBackups: {
520
+ plan: RepositoryPlan
521
+ kind: "swap" | "metadata"
522
+ backup: string
523
+ }[] = []
524
+ const sourceBackend = inspected.sourceBackend
525
+ const targetBackend = inspected.targetBackend
526
+ const repositoryPath = (repo: string) =>
527
+ join(state.root, "repos", repo)
528
+
529
+ const removeWorkspace = async (
530
+ backend: VersionControlBackend,
531
+ plan: WorkspacePlan,
532
+ name: string | null,
533
+ ) =>
534
+ runBackend(
535
+ fs,
536
+ backend.removeWorkspace({
537
+ repositoryPath: repositoryPath(plan.repo),
538
+ workspacePath: plan.path,
539
+ workspaceName: name,
540
+ }),
541
+ )
542
+ const createWorkspace = async (
543
+ backend: VersionControlBackend,
544
+ plan: WorkspacePlan,
545
+ name: string,
546
+ ) => {
547
+ await fs.createDirectory(dirname(plan.path)).pipe(Effect.runPromise)
548
+ if (backend.kind === "git") {
549
+ await Effect.runPromise(
550
+ createGitWorkspace(fs, plan, repositoryPath(plan.repo)),
551
+ )
552
+ } else {
553
+ await runBackend(
554
+ fs,
555
+ backend.createWorkspace({
556
+ repositoryPath: repositoryPath(plan.repo),
557
+ workspacePath: plan.path,
558
+ workspaceName: name,
559
+ revision: plan.head,
560
+ ...(plan.branch ? { branch: plan.branch } : {}),
561
+ }),
562
+ )
563
+ }
564
+ }
565
+
566
+ const configPath = join(state.root, "agency.json")
567
+ const configContent = yield* fs.readFile(configPath)
568
+ const targetConfig = `${JSON.stringify({ ...config, vcs: target }, null, 2)}\n`
569
+ const migration = runLifecycleTransaction({
570
+ root: state.root,
571
+ preconditions: [
572
+ { path: configPath, revision: documentRevision(configContent) },
573
+ ],
574
+ steps: [
575
+ {
576
+ label: `remove ${state.source} workspaces`,
577
+ preflight: async () => {
578
+ for (const plan of workspacePlans) {
579
+ const dirty = await runBackend(
580
+ fs,
581
+ sourceBackend.workspaceDirty(plan.path),
582
+ )
583
+ const head = await runBackend(
584
+ fs,
585
+ sourceBackend.workspaceHead(plan.path),
586
+ )
587
+ if (dirty !== false || head !== plan.head)
588
+ throw new Error(
589
+ `Workspace ${plan.path} changed after migration inspection`,
590
+ )
591
+ }
592
+ },
593
+ apply: async () => {
594
+ try {
595
+ for (const plan of workspacePlans) {
596
+ await removeWorkspace(
597
+ sourceBackend,
598
+ plan,
599
+ plan.sourceName,
600
+ )
601
+ removedSource.push(plan)
602
+ }
603
+ } catch (cause) {
604
+ for (const plan of [...removedSource].reverse())
605
+ await createWorkspace(
606
+ sourceBackend,
607
+ plan,
608
+ plan.sourceName ?? plan.targetName,
609
+ )
610
+ removedSource.length = 0
611
+ throw cause
612
+ }
613
+ },
614
+ rollback: async () => {
615
+ for (const plan of [...removedSource].reverse())
616
+ await createWorkspace(
617
+ sourceBackend,
618
+ plan,
619
+ plan.sourceName ?? plan.targetName,
620
+ )
621
+ },
622
+ manualRecovery: `Restore ${state.source} workspaces under the workbase task directories`,
623
+ },
624
+ {
625
+ label: `convert repositories to ${target}`,
626
+ apply: async () => {
627
+ try {
628
+ for (const plan of repositoryPlans) {
629
+ if (target === "jj") {
630
+ if (plan.kind === "bare") {
631
+ const staging = `${plan.path}.agency-jj-staging`
632
+ const backup = `${plan.path}.agency-git-backup`
633
+ await rm(staging, { recursive: true, force: true })
634
+ await Effect.runPromise(
635
+ command(
636
+ fs,
637
+ ["git", "clone", plan.path, staging],
638
+ `Failed to convert repository '${plan.alias}'`,
639
+ ),
640
+ )
641
+ if (plan.remote)
642
+ await Effect.runPromise(
643
+ command(
644
+ fs,
645
+ [
646
+ "git",
647
+ "-C",
648
+ staging,
649
+ "remote",
650
+ "set-url",
651
+ "origin",
652
+ plan.remote,
653
+ ],
654
+ `Failed to restore remote for '${plan.alias}'`,
655
+ ),
656
+ )
657
+ await runBackend(
658
+ fs,
659
+ targetBackend.initializeRepository(staging),
660
+ )
661
+ await rename(plan.path, backup)
662
+ await rename(staging, plan.path)
663
+ repositoryBackups.push({
664
+ plan,
665
+ kind: "swap",
666
+ backup,
667
+ })
668
+ } else {
669
+ await runBackend(
670
+ fs,
671
+ targetBackend.initializeRepository(plan.target),
672
+ )
673
+ const metadata = join(plan.target, ".jj")
674
+ repositoryBackups.push({
675
+ plan,
676
+ kind: "metadata",
677
+ backup: metadata,
678
+ })
679
+ }
680
+ } else {
681
+ const metadata = join(plan.target, ".jj")
682
+ const backup = join(plan.target, ".agency-jj-backup")
683
+ await rename(metadata, backup)
684
+ repositoryBackups.push({
685
+ plan,
686
+ kind: "metadata",
687
+ backup,
688
+ })
689
+ }
690
+ }
691
+ } catch (cause) {
692
+ for (const backup of [...repositoryBackups].reverse()) {
693
+ if (target === "jj") {
694
+ if (backup.kind === "swap") {
695
+ await rm(backup.plan.path, {
696
+ recursive: true,
697
+ force: true,
698
+ })
699
+ await rename(backup.backup, backup.plan.path)
700
+ } else {
701
+ await rm(backup.backup, {
702
+ recursive: true,
703
+ force: true,
704
+ })
705
+ }
706
+ } else {
707
+ await rename(
708
+ backup.backup,
709
+ join(backup.plan.target, ".jj"),
710
+ )
711
+ }
712
+ }
713
+ repositoryBackups.length = 0
714
+ throw cause
715
+ }
716
+ },
717
+ rollback: async () => {
718
+ for (const backup of [...repositoryBackups].reverse()) {
719
+ if (target === "jj") {
720
+ if (backup.kind === "swap") {
721
+ await rm(backup.plan.path, {
722
+ recursive: true,
723
+ force: true,
724
+ })
725
+ await rename(backup.backup, backup.plan.path)
726
+ } else {
727
+ await rm(backup.backup, {
728
+ recursive: true,
729
+ force: true,
730
+ })
731
+ }
732
+ } else {
733
+ await rename(
734
+ backup.backup,
735
+ join(backup.plan.target, ".jj"),
736
+ )
737
+ }
738
+ }
739
+ },
740
+ finalize: async () => {
741
+ for (const backup of repositoryBackups) {
742
+ if (target === "jj" && backup.kind === "swap")
743
+ await rm(backup.backup, { recursive: true, force: true })
744
+ if (target === "git" && backup.kind === "metadata")
745
+ await rm(backup.backup, { recursive: true, force: true })
746
+ }
747
+ },
748
+ manualRecovery: `Restore repository backups under ${join(state.root, "repos")}`,
749
+ },
750
+ {
751
+ label: `create ${target} workspaces`,
752
+ apply: async () => {
753
+ try {
754
+ for (const plan of workspacePlans) {
755
+ await createWorkspace(
756
+ targetBackend,
757
+ plan,
758
+ plan.targetName,
759
+ )
760
+ createdTarget.push(plan)
761
+ }
762
+ } catch (cause) {
763
+ for (const plan of [...createdTarget].reverse()) {
764
+ await removeWorkspace(
765
+ targetBackend,
766
+ plan,
767
+ target === "jj" ? plan.targetName : null,
768
+ )
769
+ if (target === "git")
770
+ await Effect.runPromise(
771
+ restoreGitBranch(fs, plan, repositoryPath(plan.repo)),
772
+ )
773
+ }
774
+ createdTarget.length = 0
775
+ throw cause
776
+ }
777
+ },
778
+ rollback: async () => {
779
+ for (const plan of [...createdTarget].reverse()) {
780
+ await removeWorkspace(
781
+ targetBackend,
782
+ plan,
783
+ target === "jj" ? plan.targetName : null,
784
+ )
785
+ if (target === "git")
786
+ await Effect.runPromise(
787
+ restoreGitBranch(fs, plan, repositoryPath(plan.repo)),
788
+ )
789
+ }
790
+ },
791
+ manualRecovery: `Remove partially created ${target} workspaces`,
792
+ },
793
+ documentWriteStep(state.root, [
794
+ { path: configPath, content: targetConfig },
795
+ ]),
796
+ ],
797
+ })
798
+ const lockTargets: WorktreeLockTarget[] = records.map((record) => ({
799
+ taskId: record.taskId,
800
+ ...(record.phaseId ? { phaseId: record.phaseId } : {}),
801
+ }))
802
+ yield* withWorktreeLocks(state.root, lockTargets, migration)
803
+ const current = yield* inspectMigration(state.root)
804
+ return {
805
+ ...current.state,
806
+ mode: "apply",
807
+ actions,
808
+ } satisfies MigrationResult
809
+ }),
810
+ }),
811
+ },
812
+ ) {}