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