@markjaquith/agency 2.49.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 (50) hide show
  1. package/README.md +56 -9
  2. package/cli.ts +56 -1
  3. package/package.json +1 -1
  4. package/schemas/agency-graph-v1.schema.json +142 -38
  5. package/src/cli-parser.test.ts +81 -0
  6. package/src/cli-parser.ts +78 -1
  7. package/src/cli.test.ts +4 -4
  8. package/src/commands/init.test.ts +2 -0
  9. package/src/commands/review.ts +38 -0
  10. package/src/commands/task.ts +27 -5
  11. package/src/commands/vcs.test.ts +75 -0
  12. package/src/commands/vcs.ts +72 -0
  13. package/src/commands/work.test.ts +2 -0
  14. package/src/commands/work.ts +2 -2
  15. package/src/commands/worktree.ts +1 -1
  16. package/src/graph-schema.test.ts +1 -1
  17. package/src/graph-schema.ts +36 -13
  18. package/src/protocol.ts +1 -0
  19. package/src/services/ArchiveService.test.ts +2 -2
  20. package/src/services/ArchiveService.ts +24 -0
  21. package/src/services/ClaimService.ts +3 -2
  22. package/src/services/ContextService.ts +118 -17
  23. package/src/services/DoctorService.ts +58 -7
  24. package/src/services/GraphMutationService.ts +5 -0
  25. package/src/services/GraphService.ts +119 -54
  26. package/src/services/PhaseService.ts +196 -70
  27. package/src/services/PullRequestService.test.ts +2 -2
  28. package/src/services/PullRequestService.ts +37 -33
  29. package/src/services/ReadinessService.ts +7 -3
  30. package/src/services/RepositoryService.test.ts +31 -1
  31. package/src/services/RepositoryService.ts +63 -31
  32. package/src/services/ReviewService.test.ts +472 -0
  33. package/src/services/ReviewService.ts +427 -0
  34. package/src/services/SyncService.test.ts +69 -2
  35. package/src/services/SyncService.ts +161 -90
  36. package/src/services/TaskPhaseService.test.ts +80 -0
  37. package/src/services/TaskService.ts +82 -9
  38. package/src/services/VcsMigrationService.test.ts +211 -0
  39. package/src/services/VcsMigrationService.ts +816 -0
  40. package/src/services/VersionControlService.test.ts +100 -0
  41. package/src/services/VersionControlService.ts +479 -0
  42. package/src/services/WorkbaseService.ts +7 -2
  43. package/src/services/WorktreeService.test.ts +88 -17
  44. package/src/services/WorktreeService.ts +865 -329
  45. package/src/test-utils.ts +12 -0
  46. package/src/work-view.ts +14 -6
  47. package/src/workbase/AGENTS.md +2 -2
  48. package/src/workbase/schemas.test.ts +57 -0
  49. package/src/workbase/schemas.ts +57 -0
  50. 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"
@@ -43,7 +48,8 @@ interface ExecutionWorkspace {
43
48
  readonly taskPath: string
44
49
  readonly phasePath: string | null
45
50
  readonly codePath: string
46
- readonly writablePath: string
51
+ readonly writablePath: string | null
52
+ readonly reviewPath: string | null
47
53
  readonly repo: string
48
54
  readonly repos: readonly RepositoryReference[]
49
55
  readonly dryRun: boolean
@@ -119,6 +125,8 @@ export interface WorktreeRemovalSnapshot {
119
125
  readonly repositoryPath: string
120
126
  readonly head: string
121
127
  readonly branch?: string
128
+ readonly vcs?: "git" | "jj"
129
+ readonly workspaceName?: string
122
130
  }
123
131
 
124
132
  const parseWorktreeList = (output: string): readonly GitWorktree[] => {
@@ -176,17 +184,21 @@ const inspectExecution = (
176
184
  Effect.gen(function* () {
177
185
  const fs = yield* FileSystemService
178
186
  const workbase = yield* WorkbaseService
187
+ const versionControl = yield* VersionControlService
179
188
  const tasks = yield* TaskService
180
189
  const phases = yield* PhaseService
181
190
  const root = yield* workbase.discover(startPath)
191
+ const backend = yield* versionControl.forWorkbase(root)
182
192
  const task = yield* tasks.show(taskId, root)
183
193
 
184
- let execution: {
185
- repo: string
186
- repos?: readonly RepositoryReference[]
187
- branch: string
188
- base: string
189
- }
194
+ let execution:
195
+ | {
196
+ repo: string
197
+ repos?: readonly RepositoryReference[]
198
+ branch: string
199
+ base: string
200
+ }
201
+ | { review: { repo: string; commit: string } }
190
202
  let owner: WorktreeOwner
191
203
  let codePath: string
192
204
  if ("phases" in task.data) {
@@ -230,6 +242,7 @@ const inspectExecution = (
230
242
  ownership.set(key, owners)
231
243
  }
232
244
  } else {
245
+ if (!("repo" in taskRecord.data)) continue
233
246
  const key = `${taskRecord.data.repo}:${taskRecord.data.branch}`
234
247
  const owners = ownership.get(key) ?? []
235
248
  owners.push({
@@ -244,10 +257,13 @@ const inspectExecution = (
244
257
  const declared: readonly (
245
258
  | { readonly repo: string; readonly branch: string }
246
259
  | RepositoryReference
247
- )[] = [
248
- { repo: execution.repo, branch: execution.branch },
249
- ...(execution.repos ?? []),
250
- ]
260
+ )[] =
261
+ "review" in execution
262
+ ? [{ repo: execution.review.repo, ref: execution.review.commit }]
263
+ : [
264
+ { repo: execution.repo, branch: execution.branch },
265
+ ...(execution.repos ?? []),
266
+ ]
251
267
  const checkouts: WorktreeCheckoutInspection[] = []
252
268
  for (const checkout of declared) {
253
269
  const repositoryPath = join(root, "repos", checkout.repo)
@@ -297,6 +313,81 @@ const inspectExecution = (
297
313
  continue
298
314
  }
299
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
+
300
391
  const listed = yield* fs.runCommand(
301
392
  ["git", "-C", repositoryPath, "worktree", "list", "--porcelain", "-z"],
302
393
  { captureOutput: true },
@@ -550,6 +641,296 @@ const inspectExecution = (
550
641
  } satisfies WorktreeInspection
551
642
  })
552
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
+
553
934
  export class WorktreeService extends Effect.Service<WorktreeService>()(
554
935
  "WorktreeService",
555
936
  {
@@ -592,12 +973,14 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
592
973
  Effect.gen(function* () {
593
974
  const fs = yield* FileSystemService
594
975
  const workbase = yield* WorkbaseService
976
+ const versionControl = yield* VersionControlService
595
977
  const tasks = yield* TaskService
596
978
  const phases = yield* PhaseService
597
979
  const { verboseLog } = createLoggers(options)
598
980
  const forwardCommandOutput =
599
981
  options.verbose === true && !options.silent && !options.json
600
982
  const { root, config } = yield* workbase.loadConfig(startPath)
983
+ const backend = yield* versionControl.forWorkbase(root)
601
984
  const materialization = Effect.gen(function* () {
602
985
  const report = yield* workbase.validate(root)
603
986
  const validationIssue = report.issues[0]
@@ -608,12 +991,14 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
608
991
  }
609
992
  const task = yield* tasks.show(taskId, root)
610
993
 
611
- let execution: {
612
- repo: string
613
- repos?: readonly RepositoryReference[]
614
- branch: string
615
- base: string
616
- }
994
+ let execution:
995
+ | {
996
+ repo: string
997
+ repos?: readonly RepositoryReference[]
998
+ branch: string
999
+ base: string
1000
+ }
1001
+ | { review: { repo: string; commit: string } }
617
1002
  let phasePath: string | null = null
618
1003
  let codePath: string
619
1004
  if ("phases" in task.data) {
@@ -639,10 +1024,32 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
639
1024
  const requestedCheckouts: readonly (
640
1025
  | { readonly repo: string; readonly branch: string }
641
1026
  | RepositoryReference
642
- )[] = [
643
- { repo: execution.repo, branch: execution.branch },
644
- ...(execution.repos ?? []),
645
- ]
1027
+ )[] =
1028
+ "review" in execution
1029
+ ? [
1030
+ {
1031
+ repo: execution.review.repo,
1032
+ ref: execution.review.commit,
1033
+ },
1034
+ ]
1035
+ : [
1036
+ { repo: execution.repo, branch: execution.branch },
1037
+ ...(execution.repos ?? []),
1038
+ ]
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
+ }
646
1053
  const canonicalCodePath = (yield* fs.exists(codePath))
647
1054
  ? yield* fs.realPath(codePath)
648
1055
  : resolve(codePath)
@@ -732,7 +1139,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
732
1139
  repositoryPath,
733
1140
  "rev-parse",
734
1141
  "--verify",
735
- `${execution.base}^{commit}`,
1142
+ `${executionBase}^{commit}`,
736
1143
  ],
737
1144
  { captureOutput: true },
738
1145
  )
@@ -744,7 +1151,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
744
1151
  repositoryPath,
745
1152
  "ls-remote",
746
1153
  "origin",
747
- originRef(execution.base),
1154
+ originRef(executionBase),
748
1155
  ],
749
1156
  { captureOutput: true },
750
1157
  )
@@ -753,7 +1160,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
753
1160
  !remoteBase.stdout.trim()
754
1161
  ) {
755
1162
  return yield* new WorktreeError({
756
- message: `Base '${execution.base}' for repository '${alias}' does not resolve to a commit`,
1163
+ message: `Base '${executionBase}' for repository '${alias}' does not resolve to a commit`,
757
1164
  })
758
1165
  }
759
1166
  }
@@ -764,7 +1171,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
764
1171
  repo: repositoryPath,
765
1172
  worktree: checkoutPath,
766
1173
  branch: checkout.branch,
767
- base: execution.base,
1174
+ base: executionBase,
768
1175
  })
769
1176
  } catch (cause) {
770
1177
  return yield* new WorktreeError({
@@ -991,7 +1398,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
991
1398
  repo: repositoryPath,
992
1399
  worktree: checkoutPath,
993
1400
  branch: checkout.branch,
994
- base: execution.base,
1401
+ base: executionBase,
995
1402
  }
996
1403
  try {
997
1404
  args = expandWorktreeCreateCommand(
@@ -1026,7 +1433,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1026
1433
  repositoryPath,
1027
1434
  "branch",
1028
1435
  checkout.branch,
1029
- execution.base,
1436
+ executionBase,
1030
1437
  ]
1031
1438
  operations.push({
1032
1439
  action: "create-branch",
@@ -1085,7 +1492,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1085
1492
  repositoryPath,
1086
1493
  "rev-parse",
1087
1494
  "--verify",
1088
- `${execution.base}^{commit}`,
1495
+ `${executionBase}^{commit}`,
1089
1496
  ],
1090
1497
  { captureOutput: true },
1091
1498
  )
@@ -1266,9 +1673,17 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1266
1673
  taskPath: task.path,
1267
1674
  phasePath,
1268
1675
  codePath,
1269
- writablePath: join(codePath, execution.repo),
1270
- repo: execution.repo,
1271
- repos: execution.repos ?? [],
1676
+ writablePath:
1677
+ "review" in execution ? null : join(codePath, execution.repo),
1678
+ reviewPath:
1679
+ "review" in execution
1680
+ ? join(codePath, execution.review.repo)
1681
+ : null,
1682
+ repo:
1683
+ "review" in execution
1684
+ ? execution.review.repo
1685
+ : execution.repo,
1686
+ repos: "review" in execution ? [] : (execution.repos ?? []),
1272
1687
  dryRun: options.dryRun === true,
1273
1688
  checkouts: checkoutReports,
1274
1689
  operations,
@@ -1394,295 +1809,321 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1394
1809
  Effect.gen(function* () {
1395
1810
  const fs = yield* FileSystemService
1396
1811
  const workbase = yield* WorkbaseService
1812
+ const versionControl = yield* VersionControlService
1397
1813
  const tasks = yield* TaskService
1398
1814
  const phases = yield* PhaseService
1399
1815
  const root = yield* workbase.discover(startPath)
1400
- const removal = Effect.gen(function* () {
1401
- const inspection = yield* inspectExecution(taskId, phaseId, root)
1402
- const blockingConflicts = inspection.conflicts.filter(
1403
- (conflict) => conflict.kind !== "stale-registration",
1404
- )
1405
- if (blockingConflicts.length > 0) {
1406
- return yield* new WorktreeError({
1407
- message: blockingConflicts
1408
- .map(({ message }) => message)
1409
- .join("\n"),
1410
- conflicts: blockingConflicts,
1411
- })
1412
- }
1413
- const task = yield* tasks.show(taskId, root)
1414
-
1415
- let execution: {
1416
- repo: string
1417
- repos?: readonly RepositoryReference[]
1418
- branch: string
1419
- }
1420
- let codePath: string
1421
- if ("phases" in task.data) {
1422
- if (!phaseId) {
1423
- return yield* new WorktreeError({
1424
- message: `Task '${taskId}' has multiple phases; phase ID is required`,
1425
- })
1426
- }
1427
- const phase = yield* phases.show(taskId, phaseId, root)
1428
- execution = phase.data
1429
- codePath = join(dirname(phase.path), "code")
1430
- } else {
1431
- if (phaseId) {
1432
- return yield* new WorktreeError({
1433
- message: `Task '${taskId}' is single-phase and does not accept a phase ID`,
1434
- })
1435
- }
1436
- execution = task.data
1437
- codePath = join(dirname(task.path), "code")
1438
- }
1439
-
1440
- const codeDirectoryExists = yield* fs.isDirectory(codePath)
1441
- const removalPlans: {
1442
- alias: string
1443
- repositoryPath: string
1444
- checkoutPath: string
1445
- registeredPath: string
1446
- checkoutExists: boolean
1447
- head?: string
1448
- branch?: string
1449
- }[] = []
1450
- const expectedCheckouts: readonly (
1451
- | { readonly repo: string; readonly branch: string }
1452
- | RepositoryReference
1453
- )[] = [
1454
- { repo: execution.repo, branch: execution.branch },
1455
- ...(execution.repos ?? []),
1456
- ]
1457
- const expectedAliases = expectedCheckouts.map(({ repo }) => repo)
1458
- if (codeDirectoryExists) {
1459
- const unmanaged = (yield* fs.readDirectory(codePath)).filter(
1460
- (entry) => !expectedAliases.includes(entry.name),
1461
- )
1462
- if (unmanaged.length > 0) {
1463
- return yield* new WorktreeError({
1464
- message: `Cannot remove ${codePath}; it contains unmanaged entries: ${unmanaged.map((entry) => entry.name).join(", ")}`,
1465
- })
1466
- }
1467
- }
1468
- for (const checkout of expectedCheckouts) {
1469
- const alias = checkout.repo
1470
- const repositoryPath = join(root, "repos", alias)
1471
- const checkoutPath = join(codePath, alias)
1472
- if (
1473
- (yield* fs.exists(checkoutPath)) &&
1474
- !(yield* fs.isDirectory(checkoutPath))
1475
- ) {
1476
- return yield* new WorktreeError({
1477
- message: `Cannot remove ${codePath}; expected checkout ${checkoutPath} is not a directory`,
1478
- })
1479
- }
1480
- const listed = yield* fs.runCommand(
1481
- [
1482
- "git",
1483
- "-C",
1484
- repositoryPath,
1485
- "worktree",
1486
- "list",
1487
- "--porcelain",
1488
- "-z",
1489
- ],
1490
- { captureOutput: true },
1491
- )
1492
- if (listed.exitCode !== 0) {
1493
- return yield* new WorktreeError({
1494
- message: `Failed to inspect worktrees for '${alias}': ${listed.stderr}`,
1495
- })
1496
- }
1497
-
1498
- const checkoutExists = yield* fs.isDirectory(checkoutPath)
1499
- const canonicalCheckoutPath = checkoutExists
1500
- ? yield* fs.realPath(checkoutPath)
1501
- : join(
1502
- yield* fs.realPath(dirname(codePath)),
1503
- basename(codePath),
1504
- 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,
1505
1825
  )
1506
- let registered: GitWorktree | undefined
1507
- for (const worktree of parseWorktreeList(listed.stdout)) {
1508
- const worktreePath = (yield* fs.exists(worktree.path))
1509
- ? yield* fs.realPath(worktree.path)
1510
- : resolve(worktree.path)
1511
- if (worktreePath === canonicalCheckoutPath) {
1512
- registered = { ...worktree, path: worktreePath }
1513
- break
1514
- }
1515
- }
1516
- if (!registered) {
1517
- if (checkoutExists) {
1518
- return yield* new WorktreeError({
1519
- message: `Existing checkout ${checkoutPath} is not registered as a Git worktree`,
1520
- })
1521
- }
1522
- continue
1523
- }
1524
- if ("branch" in checkout) {
1525
- const actualBranch = registered.branch?.replace(
1526
- /^refs\/heads\//,
1527
- "",
1528
- )
1529
- if (actualBranch !== checkout.branch) {
1530
- return yield* new WorktreeError({
1531
- message: `Cannot remove ${checkoutPath}; expected branch '${checkout.branch}', found '${actualBranch ?? "detached HEAD"}'`,
1532
- })
1533
- }
1534
- } else {
1535
- if (registered.branch) {
1536
- return yield* new WorktreeError({
1537
- message: `Cannot remove reference checkout ${checkoutPath}; it is attached to branch '${registered.branch.replace(/^refs\/heads\//, "")}'`,
1538
- })
1539
- }
1540
- const expected = yield* fs.runCommand(
1541
- [
1542
- "git",
1543
- "-C",
1544
- repositoryPath,
1545
- "rev-parse",
1546
- "--verify",
1547
- `${checkout.ref}^{commit}`,
1548
- ],
1549
- { captureOutput: true },
1550
- )
1551
- if (
1552
- expected.exitCode !== 0 ||
1553
- registered.head !== expected.stdout.trim()
1554
- ) {
1555
- return yield* new WorktreeError({
1556
- message: `Cannot remove reference checkout ${checkoutPath}; it does not match '${checkout.ref}'`,
1557
- })
1558
- }
1559
- }
1560
- if (checkoutExists) {
1561
- const status = yield* fs.runCommand(
1562
- ["git", "-C", checkoutPath, "status", "--porcelain"],
1563
- { captureOutput: true },
1564
- )
1565
- if (status.exitCode !== 0 || status.stdout.trim()) {
1566
- return yield* new WorktreeError({
1567
- message: `Failed to remove worktree for '${alias}': checkout has uncommitted changes`,
1568
- })
1569
- }
1570
- }
1571
- removalPlans.push({
1572
- alias,
1573
- repositoryPath,
1574
- checkoutPath,
1575
- registeredPath: registered.path,
1576
- checkoutExists,
1577
- head: registered.head,
1578
- branch: registered.branch?.replace(/^refs\/heads\//, ""),
1579
- })
1580
- }
1581
- for (const plan of removalPlans) {
1582
- if (!plan.checkoutExists || !plan.head) continue
1583
- options.snapshots?.push({
1584
- path: plan.checkoutPath,
1585
- repositoryPath: plan.repositoryPath,
1586
- head: plan.head,
1587
- ...(plan.branch ? { branch: plan.branch } : {}),
1588
- })
1589
- }
1590
- if (options.dryRun) {
1591
- return removalPlans.map((plan) =>
1592
- plan.checkoutExists ? plan.checkoutPath : plan.registeredPath,
1593
- )
1594
- }
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)
1595
1838
 
1596
- const completed: typeof removalPlans = []
1597
- const removed = yield* Effect.gen(function* () {
1598
- for (const plan of removalPlans) {
1599
- const command = plan.checkoutExists
1600
- ? [
1601
- "git",
1602
- "-C",
1603
- plan.repositoryPath,
1604
- "worktree",
1605
- "remove",
1606
- plan.checkoutPath,
1607
- ]
1608
- : [
1609
- "git",
1610
- "-C",
1611
- plan.repositoryPath,
1612
- "worktree",
1613
- "prune",
1614
- "--expire",
1615
- "now",
1616
- ]
1617
- const result = yield* fs.runCommand(command, {
1618
- captureOutput: true,
1619
- })
1620
- if (result.exitCode !== 0) {
1621
- return yield* new WorktreeError({
1622
- message: `Failed to remove worktree for '${plan.alias}': ${result.stderr}`,
1623
- })
1624
- }
1625
- completed.push(plan)
1626
- }
1627
- if (codeDirectoryExists)
1628
- yield* fs.deleteDirectoryIfEmpty(codePath)
1629
- return removalPlans.map((plan) =>
1630
- plan.checkoutExists ? plan.checkoutPath : plan.registeredPath,
1631
- )
1632
- }).pipe(
1633
- Effect.catchAll((cause) =>
1634
- Effect.gen(function* () {
1635
- const rolledBack: string[] = []
1636
- const manualRecovery: string[] = []
1637
- for (const plan of [...completed].reverse()) {
1638
- if (!plan.checkoutExists) {
1639
- manualRecovery.push(
1640
- `Re-run worktree repair for stale registration ${plan.registeredPath}`,
1641
- )
1642
- 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
+ })
1643
1861
  }
1644
- yield* fs.createDirectory(dirname(plan.checkoutPath))
1645
- 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
1646
1881
  ? [
1647
- "git",
1648
- "-C",
1649
- plan.repositoryPath,
1650
- "worktree",
1651
- "add",
1652
- plan.checkoutPath,
1653
- plan.branch,
1882
+ {
1883
+ repo: execution.review.repo,
1884
+ ref: execution.review.commit,
1885
+ },
1654
1886
  ]
1655
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
+ [
1656
1978
  "git",
1657
1979
  "-C",
1658
- plan.repositoryPath,
1659
- "worktree",
1660
- "add",
1661
- "--detach",
1662
- plan.checkoutPath,
1663
- plan.head!,
1664
- ]
1665
- const restored = yield* fs.runCommand(command, {
1666
- 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\//, ""),
1667
2015
  })
1668
- if (restored.exitCode === 0)
1669
- rolledBack.push(plan.checkoutPath)
1670
- else manualRecovery.push(`Restore ${plan.checkoutPath}`)
1671
2016
  }
1672
- return yield* new WorktreeError({
1673
- message: manualRecovery.length
1674
- ? "Worktree removal failed and requires manual recovery"
1675
- : "Worktree removal failed; removed worktrees were restored",
1676
- completed: completed.map((plan) => plan.checkoutPath),
1677
- rolledBack,
1678
- manualRecovery,
1679
- cause,
1680
- })
1681
- }),
1682
- ),
1683
- )
1684
- return removed
1685
- })
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
+ })
1686
2127
  return yield* options.lockHeld
1687
2128
  ? removal
1688
2129
  : withWorktreeLocks(
@@ -1773,26 +2214,40 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1773
2214
  const manualRecovery: string[] = []
1774
2215
  for (const snapshot of [...snapshots].reverse()) {
1775
2216
  yield* fs.createDirectory(dirname(snapshot.path))
1776
- const command = snapshot.branch
1777
- ? [
1778
- "git",
1779
- "-C",
1780
- snapshot.repositoryPath,
1781
- "worktree",
1782
- "add",
1783
- snapshot.path,
1784
- snapshot.branch,
1785
- ]
1786
- : [
1787
- "git",
1788
- "-C",
1789
- snapshot.repositoryPath,
1790
- "worktree",
1791
- "add",
1792
- "--detach",
1793
- snapshot.path,
1794
- snapshot.head,
1795
- ]
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
+ ]
1796
2251
  const restored = yield* fs.runCommand(command, {
1797
2252
  captureOutput: true,
1798
2253
  })
@@ -1837,8 +2292,10 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1837
2292
  Effect.gen(function* () {
1838
2293
  const fs = yield* FileSystemService
1839
2294
  const workbase = yield* WorkbaseService
2295
+ const versionControl = yield* VersionControlService
1840
2296
  const service = yield* WorktreeService
1841
2297
  const root = yield* workbase.discover(startPath)
2298
+ const backend = yield* versionControl.forWorkbase(root)
1842
2299
  if (!options.lockHeld) {
1843
2300
  return yield* withWorktreeLocks(
1844
2301
  root,
@@ -1850,6 +2307,84 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1850
2307
  )
1851
2308
  }
1852
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
+ }
1853
2388
  const safeKinds = new Set<WorktreeConflict["kind"]>([
1854
2389
  "stale-registration",
1855
2390
  "unregistered-checkout",
@@ -1980,7 +2515,8 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1980
2515
  Effect.catchAll(
1981
2516
  (cause) =>
1982
2517
  new WorktreeError({
1983
- message: cause.message,
2518
+ message:
2519
+ cause instanceof Error ? cause.message : String(cause),
1984
2520
  completed,
1985
2521
  manualRecovery: completed.length
1986
2522
  ? [