@markjaquith/agency 2.50.0 → 2.51.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 +211 -0
  27. package/src/services/VcsMigrationService.ts +816 -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
@@ -12,6 +12,7 @@ import type { RepositoryReference } from "../workbase/schemas"
12
12
  import type { BaseCommandOptions } from "../utils/command"
13
13
  import { createLoggers } from "../utils/effect"
14
14
  import { withWorktreeLocks } from "./WorktreeLock"
15
+ import { VersionControlService } from "./VersionControlService"
15
16
 
16
17
  class WorktreeError extends Data.TaggedError("WorktreeError")<{
17
18
  readonly message: string
@@ -23,7 +24,11 @@ class WorktreeError extends Data.TaggedError("WorktreeError")<{
23
24
  }> {}
24
25
 
25
26
  interface WorkspaceOperation {
26
- readonly action: "fetch" | "create-branch" | "create-worktree"
27
+ readonly action:
28
+ | "fetch"
29
+ | "create-branch"
30
+ | "create-worktree"
31
+ | "create-workspace"
27
32
  readonly repo: string
28
33
  readonly command: readonly string[]
29
34
  readonly status: "planned" | "completed"
@@ -120,6 +125,8 @@ export interface WorktreeRemovalSnapshot {
120
125
  readonly repositoryPath: string
121
126
  readonly head: string
122
127
  readonly branch?: string
128
+ readonly vcs?: "git" | "jj"
129
+ readonly workspaceName?: string
123
130
  }
124
131
 
125
132
  const parseWorktreeList = (output: string): readonly GitWorktree[] => {
@@ -177,9 +184,11 @@ const inspectExecution = (
177
184
  Effect.gen(function* () {
178
185
  const fs = yield* FileSystemService
179
186
  const workbase = yield* WorkbaseService
187
+ const versionControl = yield* VersionControlService
180
188
  const tasks = yield* TaskService
181
189
  const phases = yield* PhaseService
182
190
  const root = yield* workbase.discover(startPath)
191
+ const backend = yield* versionControl.forWorkbase(root)
183
192
  const task = yield* tasks.show(taskId, root)
184
193
 
185
194
  let execution:
@@ -304,6 +313,81 @@ const inspectExecution = (
304
313
  continue
305
314
  }
306
315
 
316
+ if (backend.kind === "jj") {
317
+ const exists = yield* fs.isDirectory(checkoutPath)
318
+ const expectedPath = exists
319
+ ? yield* fs.realPath(checkoutPath)
320
+ : resolve(checkoutPath)
321
+ const registered = yield* backend.listWorkspaces(repositoryPath)
322
+ const atPath = registered.find(
323
+ (workspace) => workspace.path === expectedPath,
324
+ )
325
+ const actualCommit = atPath
326
+ ? yield* backend.workspaceHead(checkoutPath)
327
+ : null
328
+ const dirty =
329
+ exists && atPath ? yield* backend.workspaceDirty(checkoutPath) : null
330
+ const expectedCommit = yield* backend.resolveRevision(
331
+ repositoryPath,
332
+ requestedRef,
333
+ )
334
+
335
+ if (owners.length > 1) {
336
+ conflict(
337
+ "duplicate-owner",
338
+ `Branch '${requestedRef}' for repository '${checkout.repo}' has multiple Agency owners`,
339
+ { registeredPath: atPath?.path, commit: actualCommit, dirty },
340
+ )
341
+ }
342
+ if (atPath && !exists) {
343
+ conflict(
344
+ "stale-registration",
345
+ `Workspace registry contains a missing checkout at ${checkoutPath}`,
346
+ { registeredPath: atPath.path, commit: actualCommit },
347
+ )
348
+ }
349
+ if (exists && !atPath) {
350
+ conflict(
351
+ "unregistered-checkout",
352
+ `Existing checkout ${checkoutPath} is not registered as a jj workspace`,
353
+ { commit: actualCommit, dirty },
354
+ )
355
+ }
356
+ if (
357
+ "ref" in checkout &&
358
+ actualCommit &&
359
+ expectedCommit &&
360
+ actualCommit !== expectedCommit
361
+ ) {
362
+ conflict(
363
+ "reference-drift",
364
+ `Reference checkout ${checkoutPath} is at ${actualCommit}, not ${expectedCommit}`,
365
+ {
366
+ registeredPath: atPath?.path,
367
+ commit: actualCommit,
368
+ dirty,
369
+ },
370
+ )
371
+ }
372
+
373
+ checkouts.push({
374
+ repo: checkout.repo,
375
+ kind,
376
+ path: checkoutPath,
377
+ registeredPath: atPath?.path ?? null,
378
+ requestedRef,
379
+ expectedCommit,
380
+ actualCommit,
381
+ actualBranch: "branch" in checkout ? checkout.branch : null,
382
+ exists,
383
+ registered: atPath !== undefined,
384
+ dirty,
385
+ owners,
386
+ conflicts,
387
+ })
388
+ continue
389
+ }
390
+
307
391
  const listed = yield* fs.runCommand(
308
392
  ["git", "-C", repositoryPath, "worktree", "list", "--porcelain", "-z"],
309
393
  { captureOutput: true },
@@ -557,6 +641,296 @@ const inspectExecution = (
557
641
  } satisfies WorktreeInspection
558
642
  })
559
643
 
644
+ const jjWorkspaceName = (
645
+ taskId: string,
646
+ phaseId: string | undefined,
647
+ repo: string,
648
+ ) => `agency-${taskId}-${phaseId ?? "task"}-${repo}`
649
+
650
+ const materializeJj = (options: {
651
+ readonly root: string
652
+ readonly taskId: string
653
+ readonly phaseId?: string
654
+ readonly taskPath: string
655
+ readonly phasePath: string | null
656
+ readonly codePath: string
657
+ readonly execution:
658
+ | {
659
+ repo: string
660
+ repos?: readonly RepositoryReference[]
661
+ branch: string
662
+ base: string
663
+ }
664
+ | { review: { repo: string; commit: string } }
665
+ readonly requestedCheckouts: readonly (
666
+ | { readonly repo: string; readonly branch: string }
667
+ | RepositoryReference
668
+ )[]
669
+ readonly commandOptions: MaterializeOptions
670
+ }) =>
671
+ Effect.gen(function* () {
672
+ const fs = yield* FileSystemService
673
+ const versionControl = yield* VersionControlService
674
+ const backend = yield* versionControl.forWorkbase(options.root)
675
+ const operations: WorkspaceOperation[] = []
676
+ const reports: WorkspaceCheckout[] = []
677
+ const created: {
678
+ repositoryPath: string
679
+ workspacePath: string
680
+ workspaceName: string
681
+ }[] = []
682
+ const base = "base" in options.execution ? options.execution.base : null
683
+
684
+ if (!options.commandOptions.dryRun)
685
+ yield* fs.createDirectory(options.codePath)
686
+
687
+ return yield* Effect.gen(function* () {
688
+ for (const checkout of options.requestedCheckouts) {
689
+ const repositoryPath = join(options.root, "repos", checkout.repo)
690
+ const workspacePath = join(options.codePath, checkout.repo)
691
+ const workspaceName = jjWorkspaceName(
692
+ options.taskId,
693
+ options.phaseId,
694
+ checkout.repo,
695
+ )
696
+ if (!(yield* fs.exists(repositoryPath))) {
697
+ return yield* new WorktreeError({
698
+ message: `Repository alias '${checkout.repo}' is not materialized; run 'agency repo setup --apply'`,
699
+ })
700
+ }
701
+
702
+ const exists = yield* fs.isDirectory(workspacePath)
703
+ const canonicalPath = exists
704
+ ? yield* fs.realPath(workspacePath)
705
+ : resolve(workspacePath)
706
+ const registered = (yield* backend.listWorkspaces(repositoryPath)).find(
707
+ (workspace) => workspace.path === canonicalPath,
708
+ )
709
+ if (exists && !registered) {
710
+ return yield* new WorktreeError({
711
+ message: `Existing checkout ${workspacePath} is not registered as a jj workspace`,
712
+ })
713
+ }
714
+ if (!exists && registered) {
715
+ return yield* new WorktreeError({
716
+ message: `Workspace registry contains a missing checkout at ${workspacePath}`,
717
+ })
718
+ }
719
+ if (exists && registered) {
720
+ reports.push({
721
+ repo: checkout.repo,
722
+ kind: "branch" in checkout ? "writable" : "reference",
723
+ path: workspacePath,
724
+ requestedRef: "branch" in checkout ? checkout.branch : checkout.ref,
725
+ resolvedCommit: yield* backend.workspaceHead(workspacePath),
726
+ action: "reused",
727
+ })
728
+ continue
729
+ }
730
+
731
+ const requestedRevision =
732
+ "branch" in checkout ? checkout.branch : checkout.ref
733
+ let revision = yield* backend.resolveRevision(
734
+ repositoryPath,
735
+ requestedRevision,
736
+ )
737
+ if (!revision && "branch" in checkout && base) {
738
+ revision = yield* backend.resolveRevision(repositoryPath, base)
739
+ }
740
+ if (!revision) {
741
+ return yield* new WorktreeError({
742
+ message: `${"branch" in checkout ? "Base" : "Reference"} '${"branch" in checkout ? base : checkout.ref}' for repository '${checkout.repo}' does not resolve to a commit`,
743
+ })
744
+ }
745
+
746
+ const command = [
747
+ "jj",
748
+ "-R",
749
+ repositoryPath,
750
+ "workspace",
751
+ "add",
752
+ "--name",
753
+ workspaceName,
754
+ "-r",
755
+ revision,
756
+ workspacePath,
757
+ ]
758
+ operations.push({
759
+ action: "create-workspace",
760
+ repo: checkout.repo,
761
+ command,
762
+ status: options.commandOptions.dryRun ? "planned" : "completed",
763
+ })
764
+ if (!options.commandOptions.dryRun) {
765
+ yield* backend.createWorkspace({
766
+ repositoryPath,
767
+ workspacePath,
768
+ workspaceName,
769
+ revision,
770
+ ...("branch" in checkout ? { branch: checkout.branch } : {}),
771
+ })
772
+ created.push({ repositoryPath, workspacePath, workspaceName })
773
+ }
774
+ reports.push({
775
+ repo: checkout.repo,
776
+ kind: "branch" in checkout ? "writable" : "reference",
777
+ path: workspacePath,
778
+ requestedRef: requestedRevision,
779
+ resolvedCommit: revision,
780
+ action: "created",
781
+ })
782
+ }
783
+
784
+ return {
785
+ root: options.root,
786
+ taskPath: options.taskPath,
787
+ phasePath: options.phasePath,
788
+ codePath: options.codePath,
789
+ writablePath:
790
+ "review" in options.execution
791
+ ? null
792
+ : join(options.codePath, options.execution.repo),
793
+ reviewPath:
794
+ "review" in options.execution
795
+ ? join(options.codePath, options.execution.review.repo)
796
+ : null,
797
+ repo:
798
+ "review" in options.execution
799
+ ? options.execution.review.repo
800
+ : options.execution.repo,
801
+ repos:
802
+ "review" in options.execution ? [] : (options.execution.repos ?? []),
803
+ dryRun: options.commandOptions.dryRun === true,
804
+ checkouts: reports,
805
+ operations,
806
+ } satisfies ExecutionWorkspace
807
+ }).pipe(
808
+ Effect.catchAll((cause) =>
809
+ Effect.gen(function* () {
810
+ for (const workspace of [...created].reverse()) {
811
+ yield* backend
812
+ .removeWorkspace({
813
+ repositoryPath: workspace.repositoryPath,
814
+ workspacePath: workspace.workspacePath,
815
+ workspaceName: workspace.workspaceName,
816
+ })
817
+ .pipe(Effect.ignore)
818
+ }
819
+ return yield* Effect.fail(cause)
820
+ }),
821
+ ),
822
+ )
823
+ })
824
+
825
+ const removeJj = (
826
+ taskId: string,
827
+ phaseId: string | undefined,
828
+ root: string,
829
+ options: RemoveOptions,
830
+ ) =>
831
+ Effect.gen(function* () {
832
+ const fs = yield* FileSystemService
833
+ const versionControl = yield* VersionControlService
834
+ const backend = yield* versionControl.forWorkbase(root)
835
+ const inspection = yield* inspectExecution(taskId, phaseId, root)
836
+ const blocking = inspection.conflicts.filter(
837
+ (conflict) => conflict.kind !== "stale-registration",
838
+ )
839
+ if (blocking.length > 0) {
840
+ return yield* new WorktreeError({
841
+ message: blocking.map(({ message }) => message).join("\n"),
842
+ conflicts: blocking,
843
+ })
844
+ }
845
+
846
+ const plans: {
847
+ repositoryPath: string
848
+ workspacePath: string
849
+ workspaceName: string
850
+ head: string
851
+ branch: string | null
852
+ }[] = []
853
+ for (const checkout of inspection.checkouts) {
854
+ if (checkout.dirty) {
855
+ return yield* new WorktreeError({
856
+ message: `Failed to remove workspace for '${checkout.repo}': checkout has uncommitted changes`,
857
+ })
858
+ }
859
+ if (!checkout.registeredPath) continue
860
+ const repositoryPath = join(root, "repos", checkout.repo)
861
+ const registered = (yield* backend.listWorkspaces(repositoryPath)).find(
862
+ (workspace) => workspace.path === checkout.registeredPath,
863
+ )
864
+ if (!registered?.name || !checkout.actualCommit) {
865
+ return yield* new WorktreeError({
866
+ message: `Cannot identify jj workspace at ${checkout.registeredPath}`,
867
+ })
868
+ }
869
+ plans.push({
870
+ repositoryPath,
871
+ workspacePath: checkout.path,
872
+ workspaceName: registered.name,
873
+ head: checkout.actualCommit,
874
+ branch: checkout.actualBranch,
875
+ })
876
+ }
877
+
878
+ for (const plan of plans) {
879
+ options.snapshots?.push({
880
+ path: plan.workspacePath,
881
+ repositoryPath: plan.repositoryPath,
882
+ head: plan.head,
883
+ ...(plan.branch ? { branch: plan.branch } : {}),
884
+ vcs: "jj",
885
+ workspaceName: plan.workspaceName,
886
+ })
887
+ }
888
+ if (options.dryRun) return plans.map((plan) => plan.workspacePath)
889
+
890
+ const completed: typeof plans = []
891
+ return yield* Effect.gen(function* () {
892
+ for (const plan of plans) {
893
+ yield* backend.removeWorkspace({
894
+ repositoryPath: plan.repositoryPath,
895
+ workspacePath: plan.workspacePath,
896
+ workspaceName: plan.workspaceName,
897
+ })
898
+ completed.push(plan)
899
+ }
900
+ yield* fs.deleteDirectoryIfEmpty(inspection.codePath)
901
+ return plans.map((plan) => plan.workspacePath)
902
+ }).pipe(
903
+ Effect.catchAll((cause) =>
904
+ Effect.gen(function* () {
905
+ const rolledBack: string[] = []
906
+ const manualRecovery: string[] = []
907
+ for (const plan of [...completed].reverse()) {
908
+ const restored = yield* Effect.either(
909
+ backend.createWorkspace({
910
+ repositoryPath: plan.repositoryPath,
911
+ workspacePath: plan.workspacePath,
912
+ workspaceName: plan.workspaceName,
913
+ revision: plan.head,
914
+ ...(plan.branch ? { branch: plan.branch } : {}),
915
+ }),
916
+ )
917
+ if (restored._tag === "Right") rolledBack.push(plan.workspacePath)
918
+ else manualRecovery.push(`Restore ${plan.workspacePath}`)
919
+ }
920
+ return yield* new WorktreeError({
921
+ message: manualRecovery.length
922
+ ? "Workspace removal failed and requires manual recovery"
923
+ : "Workspace removal failed; removed workspaces were restored",
924
+ completed: completed.map((plan) => plan.workspacePath),
925
+ rolledBack,
926
+ manualRecovery,
927
+ cause,
928
+ })
929
+ }),
930
+ ),
931
+ )
932
+ })
933
+
560
934
  export class WorktreeService extends Effect.Service<WorktreeService>()(
561
935
  "WorktreeService",
562
936
  {
@@ -599,12 +973,14 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
599
973
  Effect.gen(function* () {
600
974
  const fs = yield* FileSystemService
601
975
  const workbase = yield* WorkbaseService
976
+ const versionControl = yield* VersionControlService
602
977
  const tasks = yield* TaskService
603
978
  const phases = yield* PhaseService
604
979
  const { verboseLog } = createLoggers(options)
605
980
  const forwardCommandOutput =
606
981
  options.verbose === true && !options.silent && !options.json
607
982
  const { root, config } = yield* workbase.loadConfig(startPath)
983
+ const backend = yield* versionControl.forWorkbase(root)
608
984
  const materialization = Effect.gen(function* () {
609
985
  const report = yield* workbase.validate(root)
610
986
  const validationIssue = report.issues[0]
@@ -661,6 +1037,19 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
661
1037
  ...(execution.repos ?? []),
662
1038
  ]
663
1039
  const executionBase = "base" in execution ? execution.base : ""
1040
+ if (backend.kind === "jj") {
1041
+ return yield* materializeJj({
1042
+ root,
1043
+ taskId,
1044
+ ...(phaseId ? { phaseId } : {}),
1045
+ taskPath: task.path,
1046
+ phasePath,
1047
+ codePath,
1048
+ execution,
1049
+ requestedCheckouts,
1050
+ commandOptions: options,
1051
+ })
1052
+ }
664
1053
  const canonicalCodePath = (yield* fs.exists(codePath))
665
1054
  ? yield* fs.realPath(codePath)
666
1055
  : resolve(codePath)
@@ -1420,305 +1809,321 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1420
1809
  Effect.gen(function* () {
1421
1810
  const fs = yield* FileSystemService
1422
1811
  const workbase = yield* WorkbaseService
1812
+ const versionControl = yield* VersionControlService
1423
1813
  const tasks = yield* TaskService
1424
1814
  const phases = yield* PhaseService
1425
1815
  const root = yield* workbase.discover(startPath)
1426
- const removal = Effect.gen(function* () {
1427
- const inspection = yield* inspectExecution(taskId, phaseId, root)
1428
- const blockingConflicts = inspection.conflicts.filter(
1429
- (conflict) => conflict.kind !== "stale-registration",
1430
- )
1431
- if (blockingConflicts.length > 0) {
1432
- return yield* new WorktreeError({
1433
- message: blockingConflicts
1434
- .map(({ message }) => message)
1435
- .join("\n"),
1436
- conflicts: blockingConflicts,
1437
- })
1438
- }
1439
- const task = yield* tasks.show(taskId, root)
1440
-
1441
- let execution:
1442
- | {
1443
- repo: string
1444
- repos?: readonly RepositoryReference[]
1445
- branch: string
1446
- }
1447
- | { review: { repo: string; commit: string } }
1448
- let codePath: string
1449
- if ("phases" in task.data) {
1450
- if (!phaseId) {
1451
- return yield* new WorktreeError({
1452
- message: `Task '${taskId}' has multiple phases; phase ID is required`,
1453
- })
1454
- }
1455
- const phase = yield* phases.show(taskId, phaseId, root)
1456
- execution = phase.data
1457
- codePath = join(dirname(phase.path), "code")
1458
- } else {
1459
- if (phaseId) {
1460
- return yield* new WorktreeError({
1461
- message: `Task '${taskId}' is single-phase and does not accept a phase ID`,
1462
- })
1463
- }
1464
- execution = task.data
1465
- codePath = join(dirname(task.path), "code")
1466
- }
1467
-
1468
- const codeDirectoryExists = yield* fs.isDirectory(codePath)
1469
- const removalPlans: {
1470
- alias: string
1471
- repositoryPath: string
1472
- checkoutPath: string
1473
- registeredPath: string
1474
- checkoutExists: boolean
1475
- head?: string
1476
- branch?: string
1477
- }[] = []
1478
- const expectedCheckouts: readonly (
1479
- | { readonly repo: string; readonly branch: string }
1480
- | RepositoryReference
1481
- )[] =
1482
- "review" in execution
1483
- ? [
1484
- {
1485
- repo: execution.review.repo,
1486
- ref: execution.review.commit,
1487
- },
1488
- ]
1489
- : [
1490
- { repo: execution.repo, branch: execution.branch },
1491
- ...(execution.repos ?? []),
1492
- ]
1493
- const expectedAliases = expectedCheckouts.map(({ repo }) => repo)
1494
- if (codeDirectoryExists) {
1495
- const unmanaged = (yield* fs.readDirectory(codePath)).filter(
1496
- (entry) => !expectedAliases.includes(entry.name),
1497
- )
1498
- if (unmanaged.length > 0) {
1499
- return yield* new WorktreeError({
1500
- message: `Cannot remove ${codePath}; it contains unmanaged entries: ${unmanaged.map((entry) => entry.name).join(", ")}`,
1501
- })
1502
- }
1503
- }
1504
- for (const checkout of expectedCheckouts) {
1505
- const alias = checkout.repo
1506
- const repositoryPath = join(root, "repos", alias)
1507
- const checkoutPath = join(codePath, alias)
1508
- if (
1509
- (yield* fs.exists(checkoutPath)) &&
1510
- !(yield* fs.isDirectory(checkoutPath))
1511
- ) {
1512
- return yield* new WorktreeError({
1513
- message: `Cannot remove ${codePath}; expected checkout ${checkoutPath} is not a directory`,
1514
- })
1515
- }
1516
- const listed = yield* fs.runCommand(
1517
- [
1518
- "git",
1519
- "-C",
1520
- repositoryPath,
1521
- "worktree",
1522
- "list",
1523
- "--porcelain",
1524
- "-z",
1525
- ],
1526
- { captureOutput: true },
1527
- )
1528
- if (listed.exitCode !== 0) {
1529
- return yield* new WorktreeError({
1530
- message: `Failed to inspect worktrees for '${alias}': ${listed.stderr}`,
1531
- })
1532
- }
1533
-
1534
- const checkoutExists = yield* fs.isDirectory(checkoutPath)
1535
- const canonicalCheckoutPath = checkoutExists
1536
- ? yield* fs.realPath(checkoutPath)
1537
- : join(
1538
- yield* fs.realPath(dirname(codePath)),
1539
- basename(codePath),
1540
- alias,
1816
+ const backend = yield* versionControl.forWorkbase(root)
1817
+ const removal =
1818
+ backend.kind === "jj"
1819
+ ? removeJj(taskId, phaseId, root, options)
1820
+ : Effect.gen(function* () {
1821
+ const inspection = yield* inspectExecution(
1822
+ taskId,
1823
+ phaseId,
1824
+ root,
1541
1825
  )
1542
- let registered: GitWorktree | undefined
1543
- for (const worktree of parseWorktreeList(listed.stdout)) {
1544
- const worktreePath = (yield* fs.exists(worktree.path))
1545
- ? yield* fs.realPath(worktree.path)
1546
- : resolve(worktree.path)
1547
- if (worktreePath === canonicalCheckoutPath) {
1548
- registered = { ...worktree, path: worktreePath }
1549
- break
1550
- }
1551
- }
1552
- if (!registered) {
1553
- if (checkoutExists) {
1554
- return yield* new WorktreeError({
1555
- message: `Existing checkout ${checkoutPath} is not registered as a Git worktree`,
1556
- })
1557
- }
1558
- continue
1559
- }
1560
- if ("branch" in checkout) {
1561
- const actualBranch = registered.branch?.replace(
1562
- /^refs\/heads\//,
1563
- "",
1564
- )
1565
- if (actualBranch !== checkout.branch) {
1566
- return yield* new WorktreeError({
1567
- message: `Cannot remove ${checkoutPath}; expected branch '${checkout.branch}', found '${actualBranch ?? "detached HEAD"}'`,
1568
- })
1569
- }
1570
- } else {
1571
- if (registered.branch) {
1572
- return yield* new WorktreeError({
1573
- message: `Cannot remove reference checkout ${checkoutPath}; it is attached to branch '${registered.branch.replace(/^refs\/heads\//, "")}'`,
1574
- })
1575
- }
1576
- const expected = yield* fs.runCommand(
1577
- [
1578
- "git",
1579
- "-C",
1580
- repositoryPath,
1581
- "rev-parse",
1582
- "--verify",
1583
- `${checkout.ref}^{commit}`,
1584
- ],
1585
- { captureOutput: true },
1586
- )
1587
- if (
1588
- expected.exitCode !== 0 ||
1589
- registered.head !== expected.stdout.trim()
1590
- ) {
1591
- return yield* new WorktreeError({
1592
- message: `Cannot remove reference checkout ${checkoutPath}; it does not match '${checkout.ref}'`,
1593
- })
1594
- }
1595
- }
1596
- if (checkoutExists) {
1597
- const status = yield* fs.runCommand(
1598
- ["git", "-C", checkoutPath, "status", "--porcelain"],
1599
- { captureOutput: true },
1600
- )
1601
- if (status.exitCode !== 0 || status.stdout.trim()) {
1602
- return yield* new WorktreeError({
1603
- message: `Failed to remove worktree for '${alias}': checkout has uncommitted changes`,
1604
- })
1605
- }
1606
- }
1607
- removalPlans.push({
1608
- alias,
1609
- repositoryPath,
1610
- checkoutPath,
1611
- registeredPath: registered.path,
1612
- checkoutExists,
1613
- head: registered.head,
1614
- branch: registered.branch?.replace(/^refs\/heads\//, ""),
1615
- })
1616
- }
1617
- for (const plan of removalPlans) {
1618
- if (!plan.checkoutExists || !plan.head) continue
1619
- options.snapshots?.push({
1620
- path: plan.checkoutPath,
1621
- repositoryPath: plan.repositoryPath,
1622
- head: plan.head,
1623
- ...(plan.branch ? { branch: plan.branch } : {}),
1624
- })
1625
- }
1626
- if (options.dryRun) {
1627
- return removalPlans.map((plan) =>
1628
- plan.checkoutExists ? plan.checkoutPath : plan.registeredPath,
1629
- )
1630
- }
1826
+ const blockingConflicts = inspection.conflicts.filter(
1827
+ (conflict) => conflict.kind !== "stale-registration",
1828
+ )
1829
+ if (blockingConflicts.length > 0) {
1830
+ return yield* new WorktreeError({
1831
+ message: blockingConflicts
1832
+ .map(({ message }) => message)
1833
+ .join("\n"),
1834
+ conflicts: blockingConflicts,
1835
+ })
1836
+ }
1837
+ const task = yield* tasks.show(taskId, root)
1631
1838
 
1632
- const completed: typeof removalPlans = []
1633
- const removed = yield* Effect.gen(function* () {
1634
- for (const plan of removalPlans) {
1635
- const command = plan.checkoutExists
1636
- ? [
1637
- "git",
1638
- "-C",
1639
- plan.repositoryPath,
1640
- "worktree",
1641
- "remove",
1642
- plan.checkoutPath,
1643
- ]
1644
- : [
1645
- "git",
1646
- "-C",
1647
- plan.repositoryPath,
1648
- "worktree",
1649
- "prune",
1650
- "--expire",
1651
- "now",
1652
- ]
1653
- const result = yield* fs.runCommand(command, {
1654
- captureOutput: true,
1655
- })
1656
- if (result.exitCode !== 0) {
1657
- return yield* new WorktreeError({
1658
- message: `Failed to remove worktree for '${plan.alias}': ${result.stderr}`,
1659
- })
1660
- }
1661
- completed.push(plan)
1662
- }
1663
- if (codeDirectoryExists)
1664
- yield* fs.deleteDirectoryIfEmpty(codePath)
1665
- return removalPlans.map((plan) =>
1666
- plan.checkoutExists ? plan.checkoutPath : plan.registeredPath,
1667
- )
1668
- }).pipe(
1669
- Effect.catchAll((cause) =>
1670
- Effect.gen(function* () {
1671
- const rolledBack: string[] = []
1672
- const manualRecovery: string[] = []
1673
- for (const plan of [...completed].reverse()) {
1674
- if (!plan.checkoutExists) {
1675
- manualRecovery.push(
1676
- `Re-run worktree repair for stale registration ${plan.registeredPath}`,
1677
- )
1678
- continue
1839
+ let execution:
1840
+ | {
1841
+ repo: string
1842
+ repos?: readonly RepositoryReference[]
1843
+ branch: string
1844
+ }
1845
+ | { review: { repo: string; commit: string } }
1846
+ let codePath: string
1847
+ if ("phases" in task.data) {
1848
+ if (!phaseId) {
1849
+ return yield* new WorktreeError({
1850
+ message: `Task '${taskId}' has multiple phases; phase ID is required`,
1851
+ })
1852
+ }
1853
+ const phase = yield* phases.show(taskId, phaseId, root)
1854
+ execution = phase.data
1855
+ codePath = join(dirname(phase.path), "code")
1856
+ } else {
1857
+ if (phaseId) {
1858
+ return yield* new WorktreeError({
1859
+ message: `Task '${taskId}' is single-phase and does not accept a phase ID`,
1860
+ })
1679
1861
  }
1680
- yield* fs.createDirectory(dirname(plan.checkoutPath))
1681
- const command = plan.branch
1862
+ execution = task.data
1863
+ codePath = join(dirname(task.path), "code")
1864
+ }
1865
+
1866
+ const codeDirectoryExists = yield* fs.isDirectory(codePath)
1867
+ const removalPlans: {
1868
+ alias: string
1869
+ repositoryPath: string
1870
+ checkoutPath: string
1871
+ registeredPath: string
1872
+ checkoutExists: boolean
1873
+ head?: string
1874
+ branch?: string
1875
+ }[] = []
1876
+ const expectedCheckouts: readonly (
1877
+ | { readonly repo: string; readonly branch: string }
1878
+ | RepositoryReference
1879
+ )[] =
1880
+ "review" in execution
1682
1881
  ? [
1683
- "git",
1684
- "-C",
1685
- plan.repositoryPath,
1686
- "worktree",
1687
- "add",
1688
- plan.checkoutPath,
1689
- plan.branch,
1882
+ {
1883
+ repo: execution.review.repo,
1884
+ ref: execution.review.commit,
1885
+ },
1690
1886
  ]
1691
1887
  : [
1888
+ { repo: execution.repo, branch: execution.branch },
1889
+ ...(execution.repos ?? []),
1890
+ ]
1891
+ const expectedAliases = expectedCheckouts.map(
1892
+ ({ repo }) => repo,
1893
+ )
1894
+ if (codeDirectoryExists) {
1895
+ const unmanaged = (yield* fs.readDirectory(
1896
+ codePath,
1897
+ )).filter((entry) => !expectedAliases.includes(entry.name))
1898
+ if (unmanaged.length > 0) {
1899
+ return yield* new WorktreeError({
1900
+ message: `Cannot remove ${codePath}; it contains unmanaged entries: ${unmanaged.map((entry) => entry.name).join(", ")}`,
1901
+ })
1902
+ }
1903
+ }
1904
+ for (const checkout of expectedCheckouts) {
1905
+ const alias = checkout.repo
1906
+ const repositoryPath = join(root, "repos", alias)
1907
+ const checkoutPath = join(codePath, alias)
1908
+ if (
1909
+ (yield* fs.exists(checkoutPath)) &&
1910
+ !(yield* fs.isDirectory(checkoutPath))
1911
+ ) {
1912
+ return yield* new WorktreeError({
1913
+ message: `Cannot remove ${codePath}; expected checkout ${checkoutPath} is not a directory`,
1914
+ })
1915
+ }
1916
+ const listed = yield* fs.runCommand(
1917
+ [
1918
+ "git",
1919
+ "-C",
1920
+ repositoryPath,
1921
+ "worktree",
1922
+ "list",
1923
+ "--porcelain",
1924
+ "-z",
1925
+ ],
1926
+ { captureOutput: true },
1927
+ )
1928
+ if (listed.exitCode !== 0) {
1929
+ return yield* new WorktreeError({
1930
+ message: `Failed to inspect worktrees for '${alias}': ${listed.stderr}`,
1931
+ })
1932
+ }
1933
+
1934
+ const checkoutExists = yield* fs.isDirectory(checkoutPath)
1935
+ const canonicalCheckoutPath = checkoutExists
1936
+ ? yield* fs.realPath(checkoutPath)
1937
+ : join(
1938
+ yield* fs.realPath(dirname(codePath)),
1939
+ basename(codePath),
1940
+ alias,
1941
+ )
1942
+ let registered: GitWorktree | undefined
1943
+ for (const worktree of parseWorktreeList(listed.stdout)) {
1944
+ const worktreePath = (yield* fs.exists(worktree.path))
1945
+ ? yield* fs.realPath(worktree.path)
1946
+ : resolve(worktree.path)
1947
+ if (worktreePath === canonicalCheckoutPath) {
1948
+ registered = { ...worktree, path: worktreePath }
1949
+ break
1950
+ }
1951
+ }
1952
+ if (!registered) {
1953
+ if (checkoutExists) {
1954
+ return yield* new WorktreeError({
1955
+ message: `Existing checkout ${checkoutPath} is not registered as a Git worktree`,
1956
+ })
1957
+ }
1958
+ continue
1959
+ }
1960
+ if ("branch" in checkout) {
1961
+ const actualBranch = registered.branch?.replace(
1962
+ /^refs\/heads\//,
1963
+ "",
1964
+ )
1965
+ if (actualBranch !== checkout.branch) {
1966
+ return yield* new WorktreeError({
1967
+ message: `Cannot remove ${checkoutPath}; expected branch '${checkout.branch}', found '${actualBranch ?? "detached HEAD"}'`,
1968
+ })
1969
+ }
1970
+ } else {
1971
+ if (registered.branch) {
1972
+ return yield* new WorktreeError({
1973
+ message: `Cannot remove reference checkout ${checkoutPath}; it is attached to branch '${registered.branch.replace(/^refs\/heads\//, "")}'`,
1974
+ })
1975
+ }
1976
+ const expected = yield* fs.runCommand(
1977
+ [
1692
1978
  "git",
1693
1979
  "-C",
1694
- plan.repositoryPath,
1695
- "worktree",
1696
- "add",
1697
- "--detach",
1698
- plan.checkoutPath,
1699
- plan.head!,
1700
- ]
1701
- const restored = yield* fs.runCommand(command, {
1702
- captureOutput: true,
1980
+ repositoryPath,
1981
+ "rev-parse",
1982
+ "--verify",
1983
+ `${checkout.ref}^{commit}`,
1984
+ ],
1985
+ { captureOutput: true },
1986
+ )
1987
+ if (
1988
+ expected.exitCode !== 0 ||
1989
+ registered.head !== expected.stdout.trim()
1990
+ ) {
1991
+ return yield* new WorktreeError({
1992
+ message: `Cannot remove reference checkout ${checkoutPath}; it does not match '${checkout.ref}'`,
1993
+ })
1994
+ }
1995
+ }
1996
+ if (checkoutExists) {
1997
+ const status = yield* fs.runCommand(
1998
+ ["git", "-C", checkoutPath, "status", "--porcelain"],
1999
+ { captureOutput: true },
2000
+ )
2001
+ if (status.exitCode !== 0 || status.stdout.trim()) {
2002
+ return yield* new WorktreeError({
2003
+ message: `Failed to remove worktree for '${alias}': checkout has uncommitted changes`,
2004
+ })
2005
+ }
2006
+ }
2007
+ removalPlans.push({
2008
+ alias,
2009
+ repositoryPath,
2010
+ checkoutPath,
2011
+ registeredPath: registered.path,
2012
+ checkoutExists,
2013
+ head: registered.head,
2014
+ branch: registered.branch?.replace(/^refs\/heads\//, ""),
1703
2015
  })
1704
- if (restored.exitCode === 0)
1705
- rolledBack.push(plan.checkoutPath)
1706
- else manualRecovery.push(`Restore ${plan.checkoutPath}`)
1707
2016
  }
1708
- return yield* new WorktreeError({
1709
- message: manualRecovery.length
1710
- ? "Worktree removal failed and requires manual recovery"
1711
- : "Worktree removal failed; removed worktrees were restored",
1712
- completed: completed.map((plan) => plan.checkoutPath),
1713
- rolledBack,
1714
- manualRecovery,
1715
- cause,
1716
- })
1717
- }),
1718
- ),
1719
- )
1720
- return removed
1721
- })
2017
+ for (const plan of removalPlans) {
2018
+ if (!plan.checkoutExists || !plan.head) continue
2019
+ options.snapshots?.push({
2020
+ path: plan.checkoutPath,
2021
+ repositoryPath: plan.repositoryPath,
2022
+ head: plan.head,
2023
+ ...(plan.branch ? { branch: plan.branch } : {}),
2024
+ })
2025
+ }
2026
+ if (options.dryRun) {
2027
+ return removalPlans.map((plan) =>
2028
+ plan.checkoutExists
2029
+ ? plan.checkoutPath
2030
+ : plan.registeredPath,
2031
+ )
2032
+ }
2033
+
2034
+ const completed: typeof removalPlans = []
2035
+ const removed = yield* Effect.gen(function* () {
2036
+ for (const plan of removalPlans) {
2037
+ const command = plan.checkoutExists
2038
+ ? [
2039
+ "git",
2040
+ "-C",
2041
+ plan.repositoryPath,
2042
+ "worktree",
2043
+ "remove",
2044
+ plan.checkoutPath,
2045
+ ]
2046
+ : [
2047
+ "git",
2048
+ "-C",
2049
+ plan.repositoryPath,
2050
+ "worktree",
2051
+ "prune",
2052
+ "--expire",
2053
+ "now",
2054
+ ]
2055
+ const result = yield* fs.runCommand(command, {
2056
+ captureOutput: true,
2057
+ })
2058
+ if (result.exitCode !== 0) {
2059
+ return yield* new WorktreeError({
2060
+ message: `Failed to remove worktree for '${plan.alias}': ${result.stderr}`,
2061
+ })
2062
+ }
2063
+ completed.push(plan)
2064
+ }
2065
+ if (codeDirectoryExists)
2066
+ yield* fs.deleteDirectoryIfEmpty(codePath)
2067
+ return removalPlans.map((plan) =>
2068
+ plan.checkoutExists
2069
+ ? plan.checkoutPath
2070
+ : plan.registeredPath,
2071
+ )
2072
+ }).pipe(
2073
+ Effect.catchAll((cause) =>
2074
+ Effect.gen(function* () {
2075
+ const rolledBack: string[] = []
2076
+ const manualRecovery: string[] = []
2077
+ for (const plan of [...completed].reverse()) {
2078
+ if (!plan.checkoutExists) {
2079
+ manualRecovery.push(
2080
+ `Re-run worktree repair for stale registration ${plan.registeredPath}`,
2081
+ )
2082
+ continue
2083
+ }
2084
+ yield* fs.createDirectory(dirname(plan.checkoutPath))
2085
+ const command = plan.branch
2086
+ ? [
2087
+ "git",
2088
+ "-C",
2089
+ plan.repositoryPath,
2090
+ "worktree",
2091
+ "add",
2092
+ plan.checkoutPath,
2093
+ plan.branch,
2094
+ ]
2095
+ : [
2096
+ "git",
2097
+ "-C",
2098
+ plan.repositoryPath,
2099
+ "worktree",
2100
+ "add",
2101
+ "--detach",
2102
+ plan.checkoutPath,
2103
+ plan.head!,
2104
+ ]
2105
+ const restored = yield* fs.runCommand(command, {
2106
+ captureOutput: true,
2107
+ })
2108
+ if (restored.exitCode === 0)
2109
+ rolledBack.push(plan.checkoutPath)
2110
+ else
2111
+ manualRecovery.push(`Restore ${plan.checkoutPath}`)
2112
+ }
2113
+ return yield* new WorktreeError({
2114
+ message: manualRecovery.length
2115
+ ? "Worktree removal failed and requires manual recovery"
2116
+ : "Worktree removal failed; removed worktrees were restored",
2117
+ completed: completed.map((plan) => plan.checkoutPath),
2118
+ rolledBack,
2119
+ manualRecovery,
2120
+ cause,
2121
+ })
2122
+ }),
2123
+ ),
2124
+ )
2125
+ return removed
2126
+ })
1722
2127
  return yield* options.lockHeld
1723
2128
  ? removal
1724
2129
  : withWorktreeLocks(
@@ -1809,26 +2214,40 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1809
2214
  const manualRecovery: string[] = []
1810
2215
  for (const snapshot of [...snapshots].reverse()) {
1811
2216
  yield* fs.createDirectory(dirname(snapshot.path))
1812
- const command = snapshot.branch
1813
- ? [
1814
- "git",
1815
- "-C",
1816
- snapshot.repositoryPath,
1817
- "worktree",
1818
- "add",
1819
- snapshot.path,
1820
- snapshot.branch,
1821
- ]
1822
- : [
1823
- "git",
1824
- "-C",
1825
- snapshot.repositoryPath,
1826
- "worktree",
1827
- "add",
1828
- "--detach",
1829
- snapshot.path,
1830
- snapshot.head,
1831
- ]
2217
+ const command =
2218
+ snapshot.vcs === "jj"
2219
+ ? [
2220
+ "jj",
2221
+ "-R",
2222
+ snapshot.repositoryPath,
2223
+ "workspace",
2224
+ "add",
2225
+ "--name",
2226
+ snapshot.workspaceName!,
2227
+ "-r",
2228
+ snapshot.head,
2229
+ snapshot.path,
2230
+ ]
2231
+ : snapshot.branch
2232
+ ? [
2233
+ "git",
2234
+ "-C",
2235
+ snapshot.repositoryPath,
2236
+ "worktree",
2237
+ "add",
2238
+ snapshot.path,
2239
+ snapshot.branch,
2240
+ ]
2241
+ : [
2242
+ "git",
2243
+ "-C",
2244
+ snapshot.repositoryPath,
2245
+ "worktree",
2246
+ "add",
2247
+ "--detach",
2248
+ snapshot.path,
2249
+ snapshot.head,
2250
+ ]
1832
2251
  const restored = yield* fs.runCommand(command, {
1833
2252
  captureOutput: true,
1834
2253
  })
@@ -1873,8 +2292,10 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1873
2292
  Effect.gen(function* () {
1874
2293
  const fs = yield* FileSystemService
1875
2294
  const workbase = yield* WorkbaseService
2295
+ const versionControl = yield* VersionControlService
1876
2296
  const service = yield* WorktreeService
1877
2297
  const root = yield* workbase.discover(startPath)
2298
+ const backend = yield* versionControl.forWorkbase(root)
1878
2299
  if (!options.lockHeld) {
1879
2300
  return yield* withWorktreeLocks(
1880
2301
  root,
@@ -1886,6 +2307,84 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1886
2307
  )
1887
2308
  }
1888
2309
  const inspection = yield* inspectExecution(taskId, phaseId, root)
2310
+ if (backend.kind === "jj") {
2311
+ const unsafe = inspection.conflicts.filter(
2312
+ (conflict) => conflict.kind !== "stale-registration",
2313
+ )
2314
+ if (unsafe.length > 0) {
2315
+ return yield* new WorktreeError({
2316
+ message: unsafe.map(({ message }) => message).join("\n"),
2317
+ conflicts: unsafe,
2318
+ })
2319
+ }
2320
+ const actions: string[] = []
2321
+ for (const checkout of inspection.checkouts) {
2322
+ const repositoryPath = join(root, "repos", checkout.repo)
2323
+ for (const conflict of checkout.conflicts) {
2324
+ if (
2325
+ conflict.kind !== "stale-registration" ||
2326
+ !conflict.registeredPath
2327
+ )
2328
+ continue
2329
+ const workspace = (yield* backend.listWorkspaces(
2330
+ repositoryPath,
2331
+ )).find((item) => item.path === conflict.registeredPath)
2332
+ if (!workspace?.name) {
2333
+ return yield* new WorktreeError({
2334
+ message: `Cannot identify stale jj workspace at ${conflict.registeredPath}`,
2335
+ })
2336
+ }
2337
+ const command = formatCommand([
2338
+ "jj",
2339
+ "-R",
2340
+ repositoryPath,
2341
+ "workspace",
2342
+ "forget",
2343
+ workspace.name,
2344
+ ])
2345
+ actions.push(command)
2346
+ if (!options.dryRun) {
2347
+ yield* backend.removeWorkspace({
2348
+ repositoryPath,
2349
+ workspacePath: checkout.path,
2350
+ workspaceName: workspace.name,
2351
+ })
2352
+ }
2353
+ }
2354
+ }
2355
+ const missing = inspection.checkouts.some(
2356
+ (checkout) => !checkout.exists,
2357
+ )
2358
+ if (missing) {
2359
+ if (options.dryRun) {
2360
+ actions.push(
2361
+ ...inspection.checkouts
2362
+ .filter((checkout) => !checkout.exists)
2363
+ .map((checkout) => `prepare ${checkout.path}`),
2364
+ )
2365
+ } else {
2366
+ const workspace = yield* service.materialize(
2367
+ taskId,
2368
+ phaseId,
2369
+ root,
2370
+ { ...options, lockHeld: true },
2371
+ )
2372
+ actions.push(
2373
+ ...workspace.operations.map((operation) =>
2374
+ formatCommand(operation.command),
2375
+ ),
2376
+ )
2377
+ }
2378
+ }
2379
+ return {
2380
+ operation: "repair",
2381
+ dryRun: options.dryRun === true,
2382
+ inspection: options.dryRun
2383
+ ? inspection
2384
+ : yield* inspectExecution(taskId, phaseId, root),
2385
+ actions,
2386
+ } satisfies WorktreeLifecycleResult
2387
+ }
1889
2388
  const safeKinds = new Set<WorktreeConflict["kind"]>([
1890
2389
  "stale-registration",
1891
2390
  "unregistered-checkout",
@@ -2016,7 +2515,8 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2016
2515
  Effect.catchAll(
2017
2516
  (cause) =>
2018
2517
  new WorktreeError({
2019
- message: cause.message,
2518
+ message:
2519
+ cause instanceof Error ? cause.message : String(cause),
2020
2520
  completed,
2021
2521
  manualRecovery: completed.length
2022
2522
  ? [