@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
@@ -8,16 +8,12 @@ import {
8
8
  expandWorktreeCreateCommand,
9
9
  worktreeCommandEnvironment,
10
10
  } from "../workbase/worktree-command"
11
- import {
12
- expandWorkspaceCreateCommand,
13
- workspaceCommandEnvironment,
14
- } from "../workbase/workspace-command"
15
11
  import {
16
12
  expandPostCheckoutCommand,
17
13
  postCheckoutCommandEnvironment,
18
14
  type CheckoutCommandVariables,
19
15
  } from "../workbase/checkout-command"
20
- import type { RepositoryReference, WorkbaseConfig } from "../workbase/schemas"
16
+ import type { RepositoryReference } from "../workbase/schemas"
21
17
  import type { BaseCommandOptions } from "../utils/command"
22
18
  import { createLoggers } from "../utils/effect"
23
19
  import { withWorktreeLocks } from "./WorktreeLock"
@@ -43,8 +39,6 @@ interface WorkspaceOperation {
43
39
  | "fetch"
44
40
  | "create-branch"
45
41
  | "create-worktree"
46
- | "create-workspace"
47
- | "restore-workspace"
48
42
  | "post-checkout"
49
43
  readonly repo: string
50
44
  readonly command: readonly string[]
@@ -81,6 +75,7 @@ interface GitWorktree {
81
75
  }
82
76
 
83
77
  const managedWorktreeIgnorePatterns = ["/.worktree.lock"] as const
78
+ const vcs = "git" as const
84
79
 
85
80
  interface WorktreeOwner {
86
81
  readonly kind: "task" | "phase"
@@ -157,24 +152,6 @@ export interface WorktreeRemovalSnapshot {
157
152
  readonly repositoryPath: string
158
153
  readonly head: string
159
154
  readonly branch?: string
160
- readonly vcs?: "git" | "jj"
161
- readonly workspaceName?: string
162
- }
163
-
164
- interface JjResumeCheckout {
165
- readonly repo: string
166
- readonly workspacePath: string
167
- readonly workspaceName: string
168
- readonly commitId: string
169
- readonly changeId: string
170
- readonly bookmark: string
171
- }
172
-
173
- interface JjResumeState {
174
- readonly version: 1
175
- readonly taskId: string
176
- readonly phaseId: string | null
177
- readonly checkouts: readonly JjResumeCheckout[]
178
155
  }
179
156
 
180
157
  const parseWorktreeList = (output: string): readonly GitWorktree[] => {
@@ -285,7 +262,6 @@ interface RemoveOptions extends BaseCommandOptions {
285
262
  readonly snapshots?: WorktreeRemovalSnapshot[]
286
263
  readonly lockHeld?: boolean
287
264
  readonly allowReferenceDrift?: boolean
288
- readonly persistResume?: boolean
289
265
  readonly task?: TaskRecord
290
266
  readonly phase?: PhaseRecord
291
267
  }
@@ -461,87 +437,6 @@ const inspectExecution = (
461
437
  continue
462
438
  }
463
439
 
464
- if (backend.kind === "jj") {
465
- const exists = yield* fs.isDirectory(checkoutPath)
466
- const expectedPath = exists
467
- ? yield* fs.realPath(checkoutPath)
468
- : resolve(checkoutPath)
469
- const registered =
470
- context?.workspacesByRepository.get(repositoryPath) ??
471
- (yield* backend.listWorkspaces(repositoryPath))
472
- const atPath = registered.find(
473
- (workspace) => workspace.path === expectedPath,
474
- )
475
- const actualCommit = atPath
476
- ? atPath.head !== undefined
477
- ? atPath.head
478
- : yield* backend.workspaceHead(checkoutPath)
479
- : null
480
- const dirty =
481
- exists && atPath
482
- ? yield* backend.workspaceDirty(checkoutPath)
483
- : (atPath?.dirty ?? null)
484
- const expectedCommit =
485
- "branch" in checkout && context?.skipWritableRevisionResolution
486
- ? actualCommit
487
- : yield* backend.resolveRevision(repositoryPath, requestedRef)
488
-
489
- if (owners.length > 1) {
490
- conflict(
491
- "duplicate-owner",
492
- `Branch '${requestedRef}' for repository '${checkout.repo}' has multiple Agency owners`,
493
- { registeredPath: atPath?.path, commit: actualCommit, dirty },
494
- )
495
- }
496
- if (atPath && !exists) {
497
- conflict(
498
- "stale-registration",
499
- `Workspace registry contains a missing checkout at ${checkoutPath}`,
500
- { registeredPath: atPath.path, commit: actualCommit },
501
- )
502
- }
503
- if (exists && !atPath) {
504
- conflict(
505
- "unregistered-checkout",
506
- `Existing checkout ${checkoutPath} is not registered as a jj workspace`,
507
- { commit: actualCommit, dirty },
508
- )
509
- }
510
- if (
511
- "ref" in checkout &&
512
- actualCommit &&
513
- expectedCommit &&
514
- actualCommit !== expectedCommit
515
- ) {
516
- conflict(
517
- "reference-drift",
518
- `Reference checkout ${checkoutPath} is at ${actualCommit}, not ${expectedCommit}`,
519
- {
520
- registeredPath: atPath?.path,
521
- commit: actualCommit,
522
- dirty,
523
- },
524
- )
525
- }
526
-
527
- checkouts.push({
528
- repo: checkout.repo,
529
- kind,
530
- path: checkoutPath,
531
- registeredPath: atPath?.path ?? null,
532
- requestedRef,
533
- expectedCommit,
534
- actualCommit,
535
- actualBranch: "branch" in checkout ? checkout.branch : null,
536
- exists,
537
- registered: atPath !== undefined,
538
- dirty,
539
- owners,
540
- conflicts,
541
- })
542
- continue
543
- }
544
-
545
440
  const listed = yield* fs.runCommand(
546
441
  ["git", "-C", repositoryPath, "worktree", "list", "--porcelain", "-z"],
547
442
  { captureOutput: true },
@@ -795,258 +690,6 @@ const inspectExecution = (
795
690
  } satisfies WorktreeInspection
796
691
  })
797
692
 
798
- const jjWorkspaceName = (
799
- taskId: string,
800
- phaseId: string | undefined,
801
- repo: string,
802
- ) => `agency-${taskId}-${phaseId ?? "task"}-${repo}`
803
-
804
- const jjResumePath = (taskPath: string, phasePath: string | null) =>
805
- join(dirname(phasePath ?? taskPath), ".agency-jj-resume.json")
806
-
807
- const jjResumeBookmark = (
808
- taskId: string,
809
- phaseId: string | undefined,
810
- repo: string,
811
- ) =>
812
- `agency-resume/${Buffer.from(`${taskId}:${phaseId ?? "task"}:${repo}:${crypto.randomUUID()}`).toString("hex")}`
813
-
814
- const parseJjResumeState = (content: string, path: string): JjResumeState => {
815
- let value: unknown
816
- try {
817
- value = JSON.parse(content)
818
- } catch {
819
- throw new WorktreeError({
820
- message: `Invalid jj resume metadata at ${path}`,
821
- })
822
- }
823
- if (
824
- typeof value !== "object" ||
825
- value === null ||
826
- !("version" in value) ||
827
- value.version !== 1 ||
828
- !("taskId" in value) ||
829
- typeof value.taskId !== "string" ||
830
- !("phaseId" in value) ||
831
- (value.phaseId !== null && typeof value.phaseId !== "string") ||
832
- !("checkouts" in value) ||
833
- !Array.isArray(value.checkouts) ||
834
- value.checkouts.some(
835
- (checkout) =>
836
- typeof checkout !== "object" ||
837
- checkout === null ||
838
- ![
839
- "repo",
840
- "workspacePath",
841
- "workspaceName",
842
- "commitId",
843
- "changeId",
844
- "bookmark",
845
- ].every((key) => key in checkout && typeof checkout[key] === "string"),
846
- )
847
- ) {
848
- throw new WorktreeError({
849
- message: `Invalid jj resume metadata at ${path}`,
850
- })
851
- }
852
- return value as JjResumeState
853
- }
854
-
855
- const readJjResumeState = (path: string) =>
856
- Effect.gen(function* () {
857
- const fs = yield* FileSystemService
858
- if (!(yield* fs.exists(path))) return null
859
- return parseJjResumeState(yield* fs.readFile(path), path)
860
- })
861
-
862
- const writeJjResumeState = (path: string, state: JjResumeState) =>
863
- Effect.gen(function* () {
864
- const fs = yield* FileSystemService
865
- const temporary = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`
866
- yield* Effect.gen(function* () {
867
- yield* fs.writeJSON(temporary, state)
868
- yield* fs.moveDirectory(temporary, path)
869
- }).pipe(
870
- Effect.catchAll((cause) =>
871
- Effect.gen(function* () {
872
- if (yield* fs.exists(temporary)) yield* fs.deleteFile(temporary)
873
- return yield* Effect.fail(cause)
874
- }),
875
- ),
876
- )
877
- })
878
-
879
- const jjIdentity = (repositoryPath: string, revision: string) =>
880
- Effect.gen(function* () {
881
- const fs = yield* FileSystemService
882
- const result = yield* fs.runCommand(
883
- [
884
- "jj",
885
- "-R",
886
- repositoryPath,
887
- "--no-pager",
888
- "log",
889
- "--ignore-working-copy",
890
- "--no-graph",
891
- "-r",
892
- revision,
893
- "-T",
894
- 'commit_id ++ "\\t" ++ change_id ++ "\\n"',
895
- ],
896
- { captureOutput: true },
897
- )
898
- const lines = result.stdout.trim().split("\n").filter(Boolean)
899
- if (result.exitCode !== 0 || lines.length !== 1) return null
900
- const [commitId, changeId] = lines[0]!.split("\t")
901
- return commitId && changeId ? { commitId, changeId } : null
902
- })
903
-
904
- const jjSetBookmark = (
905
- repositoryPath: string,
906
- bookmark: string,
907
- commitId: string,
908
- ) =>
909
- Effect.gen(function* () {
910
- const fs = yield* FileSystemService
911
- const result = yield* fs.runCommand(
912
- [
913
- "jj",
914
- "-R",
915
- repositoryPath,
916
- "bookmark",
917
- "create",
918
- bookmark,
919
- "-r",
920
- commitId,
921
- ],
922
- { captureOutput: true },
923
- )
924
- if (result.exitCode !== 0)
925
- return yield* new WorktreeError({
926
- message: `Failed to create internal jj resume bookmark '${bookmark}': ${result.stderr.trim()}`,
927
- })
928
- })
929
-
930
- const jjDeleteBookmark = (repositoryPath: string, bookmark: string) =>
931
- Effect.gen(function* () {
932
- const fs = yield* FileSystemService
933
- const result = yield* fs.runCommand(
934
- ["jj", "-R", repositoryPath, "bookmark", "delete", bookmark],
935
- { captureOutput: true },
936
- )
937
- if (result.exitCode !== 0)
938
- return yield* new WorktreeError({
939
- message: `Failed to delete internal jj resume bookmark '${bookmark}': ${result.stderr.trim()}`,
940
- })
941
- })
942
-
943
- const jjEditWorkspace = (workspacePath: string, commitId: string) =>
944
- Effect.gen(function* () {
945
- const fs = yield* FileSystemService
946
- const result = yield* fs.runCommand(
947
- ["jj", "-R", workspacePath, "edit", commitId],
948
- { captureOutput: true },
949
- )
950
- if (result.exitCode !== 0)
951
- return yield* new WorktreeError({
952
- message: `Failed to restore jj workspace ${workspacePath} at ${commitId}: ${result.stderr.trim()}`,
953
- })
954
- })
955
-
956
- const restoreJjWorkspace = (
957
- backend: VersionControlBackend,
958
- checkout: {
959
- repositoryPath: string
960
- workspacePath: string
961
- workspaceName: string
962
- commitId: string
963
- },
964
- ) =>
965
- Effect.gen(function* () {
966
- yield* backend.createWorkspace({
967
- repositoryPath: checkout.repositoryPath,
968
- workspacePath: checkout.workspacePath,
969
- workspaceName: checkout.workspaceName,
970
- revision: checkout.commitId,
971
- })
972
- yield* jjEditWorkspace(checkout.workspacePath, checkout.commitId)
973
- })
974
-
975
- // A process can stop after `jj workspace forget` but before checkout deletion.
976
- // Only recover that residual when its repository and complete tree still match.
977
- const inspectJjResidual = (
978
- root: string,
979
- repositoryPath: string,
980
- workspacePath: string,
981
- revision: string,
982
- ) =>
983
- Effect.gen(function* () {
984
- const fs = yield* FileSystemService
985
- const repoPointer = join(workspacePath, ".jj", "repo")
986
- if (!(yield* fs.exists(repoPointer))) return "unowned" as const
987
-
988
- const pointer = (yield* fs.readFile(repoPointer)).trim()
989
- if (!pointer) return "unowned" as const
990
- const linkedRepository = resolve(dirname(repoPointer), pointer)
991
- const expectedRepository = join(repositoryPath, ".jj", "repo")
992
- if (
993
- !(yield* fs.exists(linkedRepository)) ||
994
- (yield* fs.realPath(linkedRepository)) !==
995
- (yield* fs.realPath(expectedRepository))
996
- )
997
- return "unowned" as const
998
-
999
- const comparisonPath = join(
1000
- root,
1001
- `.agency-jj-residual-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
1002
- )
1003
- const comparisonName = basename(comparisonPath)
1004
- return yield* Effect.gen(function* () {
1005
- const created = yield* fs.runCommand(
1006
- [
1007
- "jj",
1008
- "-R",
1009
- repositoryPath,
1010
- "workspace",
1011
- "add",
1012
- "--name",
1013
- comparisonName,
1014
- "-r",
1015
- revision,
1016
- comparisonPath,
1017
- ],
1018
- { captureOutput: true },
1019
- )
1020
- if (created.exitCode !== 0) {
1021
- return yield* new WorktreeError({
1022
- message: `Cannot verify unregistered jj checkout ${workspacePath}: ${created.stderr}`,
1023
- })
1024
- }
1025
- const comparison = yield* fs.runCommand(
1026
- ["diff", "-qr", "-x", ".jj", workspacePath, comparisonPath],
1027
- { captureOutput: true },
1028
- )
1029
- if (comparison.exitCode > 1) {
1030
- return yield* new WorktreeError({
1031
- message: `Cannot verify unregistered jj checkout ${workspacePath}: ${comparison.stderr}`,
1032
- })
1033
- }
1034
- return comparison.exitCode === 0
1035
- ? ("clean" as const)
1036
- : ("modified" as const)
1037
- }).pipe(
1038
- Effect.ensuring(
1039
- Effect.gen(function* () {
1040
- yield* fs.runCommand(
1041
- ["jj", "-R", repositoryPath, "workspace", "forget", comparisonName],
1042
- { captureOutput: true },
1043
- )
1044
- yield* fs.deleteDirectory(comparisonPath)
1045
- }).pipe(Effect.ignore),
1046
- ),
1047
- )
1048
- })
1049
-
1050
693
  const buildInspectionContext = (
1051
694
  startPath: string,
1052
695
  skipWritableRevisionResolution: boolean,
@@ -1136,803 +779,86 @@ const buildInspectionContext = (
1136
779
  } satisfies InspectionContext
1137
780
  })
1138
781
 
1139
- const materializeJj = (options: {
1140
- readonly root: string
1141
- readonly taskId: string
1142
- readonly phaseId?: string
1143
- readonly taskPath: string
1144
- readonly phasePath: string | null
1145
- readonly codePath: string
1146
- readonly execution:
1147
- | {
1148
- repo: string
1149
- repos?: readonly RepositoryReference[]
1150
- branch: string
1151
- base: string
1152
- }
1153
- | { review: { repo: string; commit: string } }
1154
- readonly requestedCheckouts: readonly (
1155
- | { readonly repo: string; readonly branch: string }
1156
- | RepositoryReference
1157
- )[]
1158
- readonly config: WorkbaseConfig
1159
- readonly commandOptions: MaterializeOptions
1160
- }) =>
1161
- Effect.gen(function* () {
1162
- const fs = yield* FileSystemService
1163
- const versionControl = yield* VersionControlService
1164
- const backend = yield* versionControl.forWorkbase(options.root)
1165
- const operations: WorkspaceOperation[] = []
1166
- const reports: WorkspaceCheckout[] = []
1167
- const created: {
1168
- repositoryPath: string
1169
- workspacePath: string
1170
- workspaceName: string | null
1171
- }[] = []
1172
- const resumePath = jjResumePath(options.taskPath, options.phasePath)
1173
- const resume = yield* readJjResumeState(resumePath)
1174
- const base = "base" in options.execution ? options.execution.base : null
1175
- const { verboseLog } = createLoggers(options.commandOptions)
1176
- const forwardCommandOutput =
1177
- options.commandOptions.verbose === true &&
1178
- !options.commandOptions.silent &&
1179
- !options.commandOptions.json
1180
-
1181
- if (!options.commandOptions.dryRun)
1182
- yield* fs.createDirectory(options.codePath)
1183
- if (resume) {
1184
- const requestedRepos = [
1185
- ...options.requestedCheckouts.map(({ repo }) => repo),
1186
- ].sort()
1187
- const resumeRepos = [...resume.checkouts.map(({ repo }) => repo)].sort()
1188
- if (
1189
- resume.taskId !== options.taskId ||
1190
- resume.phaseId !== (options.phaseId ?? null) ||
1191
- requestedRepos.join("\0") !== resumeRepos.join("\0")
1192
- ) {
1193
- return yield* new WorktreeError({
1194
- message: `Jj resume metadata at ${resumePath} does not match this execution unit; restore or remove it manually before retrying`,
1195
- })
1196
- }
1197
- for (const checkout of resume.checkouts) {
1198
- const repositoryPath = join(options.root, "repos", checkout.repo)
1199
- const bookmark = yield* jjIdentity(repositoryPath, checkout.bookmark)
1200
- const commit = yield* jjIdentity(repositoryPath, checkout.commitId)
1201
- if (
1202
- !bookmark ||
1203
- !commit ||
1204
- bookmark.commitId !== checkout.commitId ||
1205
- bookmark.changeId !== checkout.changeId ||
1206
- commit.changeId !== checkout.changeId ||
1207
- checkout.workspacePath !== join(options.codePath, checkout.repo) ||
1208
- checkout.workspaceName !==
1209
- jjWorkspaceName(options.taskId, options.phaseId, checkout.repo)
1210
- ) {
1211
- return yield* new WorktreeError({
1212
- message: `Cannot resume jj workspace for '${checkout.repo}'; recorded commit, change ID, path, or internal bookmark no longer agrees`,
1213
- })
1214
- }
1215
- }
1216
- }
1217
-
1218
- return yield* Effect.gen(function* () {
1219
- for (const checkout of options.requestedCheckouts) {
1220
- const repositoryPath = join(options.root, "repos", checkout.repo)
1221
- const workspacePath = join(options.codePath, checkout.repo)
1222
- const workspaceName = jjWorkspaceName(
1223
- options.taskId,
1224
- options.phaseId,
1225
- checkout.repo,
1226
- )
1227
- const resumeCheckout = resume?.checkouts.find(
1228
- (entry) => entry.repo === checkout.repo,
1229
- )
1230
- if (!(yield* fs.exists(repositoryPath))) {
1231
- return yield* new WorktreeError({
1232
- message: `Repository alias '${checkout.repo}' is not materialized; run 'agency repo setup --apply'`,
1233
- })
1234
- }
1235
-
1236
- let exists = yield* fs.isDirectory(workspacePath)
1237
- const canonicalPath = exists
1238
- ? yield* fs.realPath(workspacePath)
1239
- : resolve(workspacePath)
1240
- const registeredWorkspaces =
1241
- yield* backend.listWorkspaces(repositoryPath)
1242
- const registered = registeredWorkspaces.find(
1243
- (workspace) => workspace.path === canonicalPath,
1244
- )
1245
- const registeredByName = registeredWorkspaces.find(
1246
- (workspace) => workspace.name === workspaceName,
1247
- )
1248
- if (exists && !registered && resumeCheckout) {
1249
- const residual = yield* inspectJjResidual(
1250
- options.root,
1251
- repositoryPath,
1252
- workspacePath,
1253
- resumeCheckout.commitId,
782
+ export class WorktreeService extends Effect.Service<WorktreeService>()(
783
+ "WorktreeService",
784
+ {
785
+ sync: () => ({
786
+ list: (startPath: string = process.cwd(), options: ListOptions = {}) =>
787
+ Effect.gen(function* () {
788
+ const fs = yield* FileSystemService
789
+ const context = yield* buildInspectionContext(
790
+ startPath,
791
+ options.materializedOnly === true,
792
+ options.tasks,
793
+ options.phasesByTask,
1254
794
  )
1255
- if (residual !== "clean") {
1256
- return yield* new WorktreeError({
1257
- message: `Cannot resume unregistered jj checkout ${workspacePath}; ${residual === "modified" ? "its contents differ from the recorded commit" : "its repository identity cannot be verified"}`,
1258
- })
1259
- }
1260
- if (!options.commandOptions.dryRun)
1261
- yield* fs.deleteDirectory(workspacePath)
1262
- exists = false
1263
- }
1264
- if (exists && !registered) {
1265
- return yield* new WorktreeError({
1266
- message: `Existing checkout ${workspacePath} is not registered as a jj workspace`,
1267
- })
1268
- }
1269
- if (!exists && registered) {
1270
- return yield* new WorktreeError({
1271
- message: `Workspace registry contains a missing checkout at ${workspacePath}`,
1272
- })
1273
- }
1274
- if (!exists && registeredByName) {
1275
- return yield* new WorktreeError({
1276
- message: `Jj workspace name '${workspaceName}' is already registered at ${registeredByName.path}`,
1277
- })
1278
- }
1279
- if (exists && registered) {
1280
- const actualCommit = resumeCheckout
1281
- ? ((yield* jjIdentity(workspacePath, "@"))?.commitId ?? null)
1282
- : yield* backend.workspaceHead(workspacePath)
1283
- if (resumeCheckout && actualCommit !== resumeCheckout.commitId) {
1284
- return yield* new WorktreeError({
1285
- message: `Existing jj workspace ${workspacePath} is at ${actualCommit ?? "an unknown target"}, not recorded resume commit ${resumeCheckout.commitId}`,
795
+ const inspections: WorktreeInspection[] = []
796
+ const shouldInspect = (
797
+ documentPath: string,
798
+ execution:
799
+ | {
800
+ repo: string
801
+ repos?: readonly RepositoryReference[]
802
+ }
803
+ | { review: { repo: string } },
804
+ ) =>
805
+ Effect.gen(function* () {
806
+ if (!options.materializedOnly) return true
807
+ const codePath = join(dirname(documentPath), "code")
808
+ const aliases =
809
+ "review" in execution
810
+ ? [execution.review.repo]
811
+ : [
812
+ execution.repo,
813
+ ...(execution.repos ?? []).map(
814
+ (reference) => reference.repo,
815
+ ),
816
+ ]
817
+ for (const alias of aliases) {
818
+ if (yield* fs.isDirectory(join(codePath, alias))) return true
819
+ }
820
+ return [...context.workspacesByRepository.values()].some(
821
+ (workspaces) =>
822
+ workspaces.some(
823
+ (workspace) =>
824
+ workspace.path === codePath ||
825
+ workspace.path.startsWith(`${codePath}/`),
826
+ ),
827
+ )
1286
828
  })
1287
- }
1288
- if ("ref" in checkout && !resumeCheckout) {
1289
- const expectedCommit = yield* backend.resolveRevision(
1290
- repositoryPath,
1291
- checkout.ref,
1292
- )
1293
- if (!expectedCommit) {
1294
- return yield* new WorktreeError({
1295
- message: `Reference '${checkout.ref}' for repository '${checkout.repo}' does not resolve to a commit; run 'agency repo fetch ${checkout.repo}' and retry`,
1296
- })
1297
- }
1298
- if (
1299
- actualCommit !== expectedCommit &&
1300
- !options.commandOptions.allowReferenceDrift
1301
- ) {
1302
- return yield* new WorktreeError({
1303
- message: `Existing checkout ${workspacePath} does not match reference '${checkout.ref}' (${expectedCommit})`,
1304
- })
829
+ for (const task of context.tasks) {
830
+ if ("phases" in task.data) {
831
+ for (const phase of context.phasesByTask.get(task.id) ?? []) {
832
+ if (!(yield* shouldInspect(phase.path, phase.data))) continue
833
+ inspections.push(
834
+ yield* inspectExecution(
835
+ task.id,
836
+ phase.id,
837
+ context.root,
838
+ context,
839
+ ),
840
+ )
841
+ }
842
+ } else {
843
+ if (!(yield* shouldInspect(task.path, task.data))) continue
844
+ inspections.push(
845
+ yield* inspectExecution(
846
+ task.id,
847
+ undefined,
848
+ context.root,
849
+ context,
850
+ ),
851
+ )
1305
852
  }
1306
853
  }
1307
- reports.push({
1308
- repo: checkout.repo,
1309
- kind: "branch" in checkout ? "writable" : "reference",
1310
- path: workspacePath,
1311
- requestedRef: "branch" in checkout ? checkout.branch : checkout.ref,
1312
- resolvedCommit: actualCommit,
1313
- action: "reused",
1314
- })
1315
- continue
1316
- }
854
+ return inspections
855
+ }),
1317
856
 
1318
- const requestedRevision =
1319
- "branch" in checkout ? checkout.branch : checkout.ref
1320
- let revision =
1321
- resumeCheckout?.commitId ??
1322
- (yield* backend.resolveRevision(repositoryPath, requestedRevision))
1323
- if (!revision && "branch" in checkout && base) {
1324
- revision = yield* backend.resolveRevision(repositoryPath, base)
1325
- }
1326
- if (!revision) {
1327
- const recovery =
1328
- backend.kind === "jj"
1329
- ? `; run 'agency repo fetch ${checkout.repo}' and retry`
1330
- : ""
1331
- return yield* new WorktreeError({
1332
- message: `${"branch" in checkout ? "Base" : "Reference"} '${"branch" in checkout ? base : checkout.ref}' for repository '${checkout.repo}' does not resolve to a commit${recovery}`,
1333
- })
1334
- }
1335
-
1336
- const defaultCommand = [
1337
- "jj",
1338
- "-R",
1339
- repositoryPath,
1340
- "workspace",
1341
- "add",
1342
- "--name",
1343
- workspaceName,
1344
- "-r",
1345
- revision,
1346
- workspacePath,
1347
- ]
1348
- const workspaceVariables = {
1349
- repo: repositoryPath,
1350
- workspace: workspacePath,
1351
- name: workspaceName,
1352
- revision,
1353
- kind:
1354
- "branch" in checkout
1355
- ? ("writable" as const)
1356
- : ("reference" as const),
1357
- requestedRef: requestedRevision,
1358
- }
1359
- let command = defaultCommand
1360
- let commandEnvironment: Record<string, string> | undefined
1361
- if (options.config.workspaceCreateCommand && !resumeCheckout) {
1362
- try {
1363
- command = expandWorkspaceCreateCommand(
1364
- options.config.workspaceCreateCommand,
1365
- workspaceVariables,
1366
- )
1367
- commandEnvironment = workspaceCommandEnvironment(workspaceVariables)
1368
- } catch (cause) {
1369
- return yield* new WorktreeError({
1370
- message:
1371
- cause instanceof Error
1372
- ? cause.message
1373
- : "Invalid workspaceCreateCommand",
1374
- cause,
1375
- })
1376
- }
1377
- }
1378
- operations.push({
1379
- action: "create-workspace",
1380
- repo: checkout.repo,
1381
- command,
1382
- status: options.commandOptions.dryRun ? "planned" : "completed",
1383
- })
1384
- if (resumeCheckout) {
1385
- operations.push({
1386
- action: "restore-workspace",
1387
- repo: checkout.repo,
1388
- command: [
1389
- "jj",
1390
- "-R",
1391
- workspacePath,
1392
- "edit",
1393
- resumeCheckout.commitId,
1394
- ],
1395
- status: options.commandOptions.dryRun ? "planned" : "completed",
1396
- })
1397
- }
1398
- if (!options.commandOptions.dryRun) {
1399
- if (resumeCheckout) {
1400
- yield* restoreJjWorkspace(backend, {
1401
- repositoryPath,
1402
- workspacePath,
1403
- workspaceName,
1404
- commitId: resumeCheckout.commitId,
1405
- })
1406
- } else if (options.config.workspaceCreateCommand) {
1407
- verboseLog(`Running workspace command: ${formatCommand(command)}`)
1408
- const result = yield* fs.runCommand(command, {
1409
- cwd: repositoryPath,
1410
- captureOutput: true,
1411
- forwardOutput: forwardCommandOutput,
1412
- env: commandEnvironment,
1413
- })
1414
- const createdPath = yield* fs.isDirectory(workspacePath)
1415
- const canonicalCreatedPath = createdPath
1416
- ? yield* fs.realPath(workspacePath)
1417
- : resolve(workspacePath)
1418
- const registeredAfterCommand = (yield* backend.listWorkspaces(
1419
- repositoryPath,
1420
- )).find((workspace) => workspace.path === canonicalCreatedPath)
1421
- if (createdPath || registeredAfterCommand) {
1422
- created.push({
1423
- repositoryPath,
1424
- workspacePath,
1425
- workspaceName: registeredAfterCommand?.name ?? null,
1426
- })
1427
- }
1428
- if (result.exitCode !== 0) {
1429
- return yield* new WorktreeError({
1430
- message: `Failed to create jj workspace for '${checkout.repo}': ${result.stderr.trim() || result.stdout.trim()}`,
1431
- })
1432
- }
1433
- if (!createdPath) {
1434
- return yield* new WorktreeError({
1435
- message: `Workspace command did not create ${workspacePath}`,
1436
- })
1437
- }
1438
- } else {
1439
- yield* backend.createWorkspace({
1440
- repositoryPath,
1441
- workspacePath,
1442
- workspaceName,
1443
- revision,
1444
- ...("branch" in checkout ? { branch: checkout.branch } : {}),
1445
- })
1446
- }
1447
- if (!options.config.workspaceCreateCommand || resumeCheckout) {
1448
- created.push({ repositoryPath, workspacePath, workspaceName })
1449
- }
1450
- const canonicalWorkspacePath = yield* fs.realPath(workspacePath)
1451
- const registeredAfterCreate = (yield* backend.listWorkspaces(
1452
- repositoryPath,
1453
- )).find((workspace) => workspace.path === canonicalWorkspacePath)
1454
- const head = resumeCheckout
1455
- ? ((yield* jjIdentity(workspacePath, "@"))?.commitId ?? null)
1456
- : yield* backend.workspaceHead(workspacePath)
1457
- if (
1458
- registeredAfterCreate?.name !== workspaceName ||
1459
- head !== revision
1460
- ) {
1461
- return yield* new WorktreeError({
1462
- message: `Created jj workspace for '${checkout.repo}' failed validation`,
1463
- })
1464
- }
1465
- }
1466
- yield* runPostCheckoutHook({
1467
- command:
1468
- options.config.repositories?.[checkout.repo]?.postCheckoutCommand,
1469
- variables: {
1470
- repoAlias: checkout.repo,
1471
- repositoryPath,
1472
- checkoutPath: workspacePath,
1473
- checkoutKind: "branch" in checkout ? "writable" : "reference",
1474
- requestedRef: requestedRevision,
1475
- base: base ?? "",
1476
- vcs: "jj",
1477
- workbaseRoot: options.root,
1478
- taskId: options.taskId,
1479
- phaseId: options.phaseId ?? "",
1480
- },
1481
- dryRun: options.commandOptions.dryRun === true,
1482
- forwardOutput: forwardCommandOutput,
1483
- verboseLog,
1484
- operations,
1485
- })
1486
- reports.push({
1487
- repo: checkout.repo,
1488
- kind: "branch" in checkout ? "writable" : "reference",
1489
- path: workspacePath,
1490
- requestedRef: requestedRevision,
1491
- resolvedCommit: revision,
1492
- action: "created",
1493
- })
1494
- }
1495
-
1496
- if (resume && !options.commandOptions.dryRun) {
1497
- yield* fs.deleteFile(resumePath)
1498
- for (const checkout of resume.checkouts) {
1499
- yield* jjDeleteBookmark(
1500
- join(options.root, "repos", checkout.repo),
1501
- checkout.bookmark,
1502
- ).pipe(Effect.ignore)
1503
- }
1504
- }
1505
-
1506
- return {
1507
- root: options.root,
1508
- taskPath: options.taskPath,
1509
- phasePath: options.phasePath,
1510
- codePath: options.codePath,
1511
- writablePath:
1512
- "review" in options.execution
1513
- ? null
1514
- : join(options.codePath, options.execution.repo),
1515
- reviewPath:
1516
- "review" in options.execution
1517
- ? join(options.codePath, options.execution.review.repo)
1518
- : null,
1519
- repo:
1520
- "review" in options.execution
1521
- ? options.execution.review.repo
1522
- : options.execution.repo,
1523
- repos:
1524
- "review" in options.execution ? [] : (options.execution.repos ?? []),
1525
- dryRun: options.commandOptions.dryRun === true,
1526
- checkouts: reports,
1527
- operations,
1528
- } satisfies ExecutionWorkspace
1529
- }).pipe(
1530
- Effect.catchAll((cause) =>
1531
- Effect.gen(function* () {
1532
- const rolledBack: string[] = []
1533
- const manualRecovery: string[] = []
1534
- for (const workspace of [...created].reverse()) {
1535
- const removed = yield* (
1536
- workspace.workspaceName
1537
- ? backend.removeWorkspace({
1538
- repositoryPath: workspace.repositoryPath,
1539
- workspacePath: workspace.workspacePath,
1540
- workspaceName: workspace.workspaceName,
1541
- })
1542
- : fs.deleteDirectory(workspace.workspacePath)
1543
- ).pipe(
1544
- Effect.as(true),
1545
- Effect.catchAll(() => Effect.succeed(false)),
1546
- )
1547
- if (removed)
1548
- rolledBack.push(`create-workspace ${workspace.workspaceName}`)
1549
- else
1550
- manualRecovery.push(
1551
- `Remove jj workspace ${workspace.workspacePath}`,
1552
- )
1553
- }
1554
- return yield* new WorktreeError({
1555
- message: `${cause instanceof Error ? cause.message : "Workspace creation failed"}. ${
1556
- manualRecovery.length
1557
- ? "Some effects require manual recovery"
1558
- : "Created jj workspaces were rolled back"
1559
- }`,
1560
- rolledBack,
1561
- manualRecovery,
1562
- cause,
1563
- })
1564
- }),
1565
- ),
1566
- )
1567
- })
1568
-
1569
- const removeJj = (
1570
- taskId: string,
1571
- phaseId: string | undefined,
1572
- root: string,
1573
- options: RemoveOptions,
1574
- ) =>
1575
- Effect.gen(function* () {
1576
- const fs = yield* FileSystemService
1577
- const versionControl = yield* VersionControlService
1578
- const tasks = yield* TaskService
1579
- const phases = yield* PhaseService
1580
- const backend = yield* versionControl.forWorkbase(root)
1581
- const inspection = yield* inspectExecution(taskId, phaseId, root)
1582
- const task = options.task ?? (yield* tasks.show(taskId, root))
1583
- const execution =
1584
- "phases" in task.data && phaseId
1585
- ? (options.phase ?? (yield* phases.show(taskId, phaseId, root))).data
1586
- : task.data
1587
- const phasePath =
1588
- "phases" in task.data && phaseId
1589
- ? (options.phase ?? (yield* phases.show(taskId, phaseId, root))).path
1590
- : null
1591
- const resumePath = jjResumePath(task.path, phasePath)
1592
- if (yield* fs.exists(resumePath)) {
1593
- return yield* new WorktreeError({
1594
- message: `Jj resume metadata already exists at ${resumePath}; run 'agency work prepare' to complete the previous resume before removing the workspace again`,
1595
- })
1596
- }
1597
- const recoverable = new Map<string, string>()
1598
- const modifiedResiduals = new Map<string, string>()
1599
- for (const checkout of inspection.checkouts) {
1600
- const recoveryRevision =
1601
- checkout.expectedCommit ??
1602
- (checkout.kind === "writable" && "base" in execution
1603
- ? yield* backend.resolveRevision(
1604
- join(root, "repos", checkout.repo),
1605
- execution.base,
1606
- )
1607
- : null)
1608
- if (
1609
- !checkout.conflicts.some(
1610
- (conflict) => conflict.kind === "unregistered-checkout",
1611
- ) ||
1612
- !recoveryRevision
1613
- )
1614
- continue
1615
- const repositoryPath = join(root, "repos", checkout.repo)
1616
- const residual = yield* inspectJjResidual(
1617
- root,
1618
- repositoryPath,
1619
- checkout.path,
1620
- recoveryRevision,
1621
- )
1622
- if (residual === "clean") recoverable.set(checkout.path, recoveryRevision)
1623
- else if (residual === "modified")
1624
- modifiedResiduals.set(checkout.path, recoveryRevision)
1625
- }
1626
- const blocking = inspection.checkouts.flatMap((checkout) =>
1627
- checkout.conflicts
1628
- .filter(
1629
- (conflict) =>
1630
- !(
1631
- options.allowReferenceDrift && conflict.kind === "reference-drift"
1632
- ) &&
1633
- !(
1634
- conflict.kind === "unregistered-checkout" &&
1635
- recoverable.has(checkout.path)
1636
- ),
1637
- )
1638
- .map((conflict) =>
1639
- conflict.kind === "unregistered-checkout" &&
1640
- modifiedResiduals.has(checkout.path)
1641
- ? {
1642
- ...conflict,
1643
- message: `Cannot remove unregistered jj checkout ${checkout.path}; its contents do not match expected revision '${modifiedResiduals.get(checkout.path)}'`,
1644
- }
1645
- : conflict,
1646
- ),
1647
- )
1648
- if (blocking.length > 0) {
1649
- return yield* new WorktreeError({
1650
- message: blocking.map(({ message }) => message).join("\n"),
1651
- conflicts: blocking,
1652
- })
1653
- }
1654
-
1655
- const plans: {
1656
- repo: string
1657
- repositoryPath: string
1658
- workspacePath: string
1659
- workspaceName: string
1660
- head: string
1661
- branch: string | null
1662
- registered: boolean
1663
- commitId: string
1664
- changeId: string
1665
- bookmark: string
1666
- }[] = []
1667
- for (const checkout of inspection.checkouts) {
1668
- const repositoryPath = join(root, "repos", checkout.repo)
1669
- const registered = checkout.registeredPath
1670
- ? (yield* backend.listWorkspaces(repositoryPath)).find(
1671
- (workspace) => workspace.path === checkout.registeredPath,
1672
- )
1673
- : undefined
1674
- if (checkout.dirty === true && options.persistResume === false) {
1675
- const preservedByBookmark =
1676
- checkout.kind === "writable" &&
1677
- registered?.commit !== null &&
1678
- registered?.commit !== undefined &&
1679
- (yield* backend.resolveRevision(
1680
- repositoryPath,
1681
- checkout.requestedRef,
1682
- )) === registered.commit
1683
- if (!preservedByBookmark) {
1684
- return yield* new WorktreeError({
1685
- message: `Failed to remove workspace for '${checkout.repo}': checkout has uncommitted changes`,
1686
- })
1687
- }
1688
- }
1689
- if (
1690
- checkout.exists &&
1691
- checkout.dirty === null &&
1692
- !recoverable.has(checkout.path)
1693
- ) {
1694
- return yield* new WorktreeError({
1695
- message: `Failed to remove workspace for '${checkout.repo}': checkout cleanliness could not be verified`,
1696
- })
1697
- }
1698
- if (!checkout.registeredPath) {
1699
- const recoveryRevision = recoverable.get(checkout.path)
1700
- if (!recoveryRevision) continue
1701
- const identity = yield* jjIdentity(repositoryPath, recoveryRevision)
1702
- if (!identity) {
1703
- return yield* new WorktreeError({
1704
- message: `Cannot prove the exact jj target for unregistered checkout ${checkout.path}`,
1705
- })
1706
- }
1707
- plans.push({
1708
- repo: checkout.repo,
1709
- repositoryPath,
1710
- workspacePath: checkout.path,
1711
- workspaceName: jjWorkspaceName(taskId, phaseId, checkout.repo),
1712
- head: recoveryRevision,
1713
- branch: checkout.actualBranch,
1714
- registered: false,
1715
- ...identity,
1716
- bookmark: jjResumeBookmark(taskId, phaseId, checkout.repo),
1717
- })
1718
- continue
1719
- }
1720
- if (!registered?.name || !checkout.actualCommit || !registered.commit) {
1721
- return yield* new WorktreeError({
1722
- message: `Cannot identify jj workspace at ${checkout.registeredPath}`,
1723
- })
1724
- }
1725
- const identity = yield* jjIdentity(repositoryPath, registered.commit)
1726
- if (!identity || identity.commitId !== registered.commit) {
1727
- return yield* new WorktreeError({
1728
- message: `Cannot prove the exact jj target for workspace at ${checkout.registeredPath}`,
1729
- })
1730
- }
1731
- plans.push({
1732
- repo: checkout.repo,
1733
- repositoryPath,
1734
- workspacePath: checkout.path,
1735
- workspaceName: registered.name,
1736
- head: identity.commitId,
1737
- branch: checkout.actualBranch,
1738
- registered: true,
1739
- ...identity,
1740
- bookmark: jjResumeBookmark(taskId, phaseId, checkout.repo),
1741
- })
1742
- }
1743
-
1744
- for (const plan of plans) {
1745
- options.snapshots?.push({
1746
- path: plan.workspacePath,
1747
- repositoryPath: plan.repositoryPath,
1748
- head: plan.head,
1749
- ...(plan.branch ? { branch: plan.branch } : {}),
1750
- vcs: "jj",
1751
- workspaceName: plan.workspaceName,
1752
- })
1753
- }
1754
- if (options.dryRun) return plans.map((plan) => plan.workspacePath)
1755
-
1756
- const completed: typeof plans = []
1757
- const persistent = options.persistResume !== false && plans.length > 0
1758
- const resume: JjResumeState = {
1759
- version: 1,
1760
- taskId,
1761
- phaseId: phaseId ?? null,
1762
- checkouts: plans.map((plan) => ({
1763
- repo: plan.repo,
1764
- workspacePath: plan.workspacePath,
1765
- workspaceName: plan.workspaceName,
1766
- commitId: plan.commitId,
1767
- changeId: plan.changeId,
1768
- bookmark: plan.bookmark,
1769
- })),
1770
- }
1771
- const bookmarks: typeof plans = []
1772
- return yield* Effect.gen(function* () {
1773
- if (persistent) {
1774
- for (const plan of plans) {
1775
- yield* jjSetBookmark(
1776
- plan.repositoryPath,
1777
- plan.bookmark,
1778
- plan.commitId,
1779
- )
1780
- bookmarks.push(plan)
1781
- }
1782
- yield* writeJjResumeState(resumePath, resume)
1783
- }
1784
- for (const plan of plans) {
1785
- if (plan.registered) {
1786
- yield* backend.removeWorkspace({
1787
- repositoryPath: plan.repositoryPath,
1788
- workspacePath: plan.workspacePath,
1789
- workspaceName: plan.workspaceName,
1790
- })
1791
- } else {
1792
- yield* fs.deleteDirectory(plan.workspacePath)
1793
- }
1794
- completed.push(plan)
1795
- }
1796
- yield* fs.deleteDirectoryIfEmpty(inspection.codePath)
1797
- return plans.map((plan) => plan.workspacePath)
1798
- }).pipe(
1799
- Effect.catchAll((cause) =>
1800
- Effect.gen(function* () {
1801
- const rolledBack: string[] = []
1802
- const manualRecovery: string[] = []
1803
- const rollback = [...completed]
1804
- for (const plan of plans) {
1805
- if (completed.includes(plan)) continue
1806
- const registered = (yield* backend.listWorkspaces(
1807
- plan.repositoryPath,
1808
- )).some((workspace) => workspace.name === plan.workspaceName)
1809
- if (!registered) rollback.push(plan)
1810
- }
1811
- for (const plan of rollback.reverse()) {
1812
- if (yield* fs.exists(plan.workspacePath)) {
1813
- const removed = yield* fs
1814
- .deleteDirectory(plan.workspacePath)
1815
- .pipe(
1816
- Effect.as(true),
1817
- Effect.catchAll(() => Effect.succeed(false)),
1818
- )
1819
- if (!removed) {
1820
- manualRecovery.push(`Restore ${plan.workspacePath}`)
1821
- continue
1822
- }
1823
- }
1824
- const restored = yield* Effect.either(
1825
- restoreJjWorkspace(backend, {
1826
- repositoryPath: plan.repositoryPath,
1827
- workspacePath: plan.workspacePath,
1828
- workspaceName: plan.workspaceName,
1829
- commitId: plan.commitId,
1830
- }),
1831
- )
1832
- if (restored._tag === "Right") rolledBack.push(plan.workspacePath)
1833
- else manualRecovery.push(`Restore ${plan.workspacePath}`)
1834
- }
1835
- if (manualRecovery.length === 0 && persistent) {
1836
- if (yield* fs.exists(resumePath)) yield* fs.deleteFile(resumePath)
1837
- for (const plan of bookmarks)
1838
- yield* jjDeleteBookmark(plan.repositoryPath, plan.bookmark).pipe(
1839
- Effect.ignore,
1840
- )
1841
- }
1842
- return yield* new WorktreeError({
1843
- message: manualRecovery.length
1844
- ? "Workspace removal failed and requires manual recovery"
1845
- : "Workspace removal failed; removed workspaces were restored",
1846
- completed: completed.map((plan) => plan.workspacePath),
1847
- rolledBack,
1848
- manualRecovery,
1849
- cause,
1850
- })
1851
- }),
1852
- ),
1853
- )
1854
- })
1855
-
1856
- export class WorktreeService extends Effect.Service<WorktreeService>()(
1857
- "WorktreeService",
1858
- {
1859
- sync: () => ({
1860
- list: (startPath: string = process.cwd(), options: ListOptions = {}) =>
1861
- Effect.gen(function* () {
1862
- const fs = yield* FileSystemService
1863
- const context = yield* buildInspectionContext(
1864
- startPath,
1865
- options.materializedOnly === true,
1866
- options.tasks,
1867
- options.phasesByTask,
1868
- )
1869
- const inspections: WorktreeInspection[] = []
1870
- const shouldInspect = (
1871
- documentPath: string,
1872
- execution:
1873
- | {
1874
- repo: string
1875
- repos?: readonly RepositoryReference[]
1876
- }
1877
- | { review: { repo: string } },
1878
- ) =>
1879
- Effect.gen(function* () {
1880
- if (!options.materializedOnly) return true
1881
- const codePath = join(dirname(documentPath), "code")
1882
- const aliases =
1883
- "review" in execution
1884
- ? [execution.review.repo]
1885
- : [
1886
- execution.repo,
1887
- ...(execution.repos ?? []).map(
1888
- (reference) => reference.repo,
1889
- ),
1890
- ]
1891
- for (const alias of aliases) {
1892
- if (yield* fs.isDirectory(join(codePath, alias))) return true
1893
- }
1894
- return [...context.workspacesByRepository.values()].some(
1895
- (workspaces) =>
1896
- workspaces.some(
1897
- (workspace) =>
1898
- workspace.path === codePath ||
1899
- workspace.path.startsWith(`${codePath}/`),
1900
- ),
1901
- )
1902
- })
1903
- for (const task of context.tasks) {
1904
- if ("phases" in task.data) {
1905
- for (const phase of context.phasesByTask.get(task.id) ?? []) {
1906
- if (!(yield* shouldInspect(phase.path, phase.data))) continue
1907
- inspections.push(
1908
- yield* inspectExecution(
1909
- task.id,
1910
- phase.id,
1911
- context.root,
1912
- context,
1913
- ),
1914
- )
1915
- }
1916
- } else {
1917
- if (!(yield* shouldInspect(task.path, task.data))) continue
1918
- inspections.push(
1919
- yield* inspectExecution(
1920
- task.id,
1921
- undefined,
1922
- context.root,
1923
- context,
1924
- ),
1925
- )
1926
- }
1927
- }
1928
- return inspections
1929
- }),
1930
-
1931
- inspect: (
1932
- taskId: string,
1933
- phaseId?: string,
1934
- startPath: string = process.cwd(),
1935
- ) => inspectExecution(taskId, phaseId, startPath),
857
+ inspect: (
858
+ taskId: string,
859
+ phaseId?: string,
860
+ startPath: string = process.cwd(),
861
+ ) => inspectExecution(taskId, phaseId, startPath),
1936
862
 
1937
863
  materialize: (
1938
864
  taskId: string,
@@ -1943,14 +869,12 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
1943
869
  Effect.gen(function* () {
1944
870
  const fs = yield* FileSystemService
1945
871
  const workbase = yield* WorkbaseService
1946
- const versionControl = yield* VersionControlService
1947
872
  const tasks = yield* TaskService
1948
873
  const phases = yield* PhaseService
1949
874
  const { verboseLog } = createLoggers(options)
1950
875
  const forwardCommandOutput =
1951
876
  options.verbose === true && !options.silent && !options.json
1952
877
  const { root, config } = yield* workbase.loadConfig(startPath)
1953
- const backend = yield* versionControl.forWorkbase(root)
1954
878
  const materialization = Effect.gen(function* () {
1955
879
  if (!options.validationAlreadyPerformed) {
1956
880
  const report = yield* workbase.validate(root)
@@ -2009,20 +933,6 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2009
933
  ...(execution.repos ?? []),
2010
934
  ]
2011
935
  const executionBase = "base" in execution ? execution.base : ""
2012
- if (backend.kind === "jj") {
2013
- return yield* materializeJj({
2014
- root,
2015
- taskId,
2016
- ...(phaseId ? { phaseId } : {}),
2017
- taskPath: task.path,
2018
- phasePath,
2019
- codePath,
2020
- execution,
2021
- requestedCheckouts,
2022
- config,
2023
- commandOptions: options,
2024
- })
2025
- }
2026
936
  const canonicalCodePath = (yield* fs.exists(codePath))
2027
937
  ? yield* fs.realPath(codePath)
2028
938
  : resolve(codePath)
@@ -2519,7 +1429,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2519
1429
  checkoutKind: "writable",
2520
1430
  requestedRef: checkout.branch,
2521
1431
  base: executionBase,
2522
- vcs: "git",
1432
+ vcs,
2523
1433
  workbaseRoot: root,
2524
1434
  taskId,
2525
1435
  phaseId: phaseId ?? "",
@@ -2596,7 +1506,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2596
1506
  checkoutKind: "writable",
2597
1507
  requestedRef: checkout.branch,
2598
1508
  base: executionBase,
2599
- vcs: "git",
1509
+ vcs,
2600
1510
  workbaseRoot: root,
2601
1511
  taskId,
2602
1512
  phaseId: phaseId ?? "",
@@ -2711,7 +1621,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2711
1621
  checkoutKind: "reference",
2712
1622
  requestedRef: checkout.ref,
2713
1623
  base: executionBase,
2714
- vcs: "git",
1624
+ vcs,
2715
1625
  workbaseRoot: root,
2716
1626
  taskId,
2717
1627
  phaseId: phaseId ?? "",
@@ -2764,7 +1674,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2764
1674
  checkoutKind: "reference",
2765
1675
  requestedRef: checkout.ref,
2766
1676
  base: executionBase,
2767
- vcs: "git",
1677
+ vcs,
2768
1678
  workbaseRoot: root,
2769
1679
  taskId,
2770
1680
  phaseId: phaseId ?? "",
@@ -2927,328 +1837,311 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2927
1837
  Effect.gen(function* () {
2928
1838
  const fs = yield* FileSystemService
2929
1839
  const workbase = yield* WorkbaseService
2930
- const versionControl = yield* VersionControlService
2931
1840
  const tasks = yield* TaskService
2932
1841
  const phases = yield* PhaseService
2933
1842
  const root = yield* workbase.discover(startPath)
2934
- const backend = yield* versionControl.forWorkbase(root)
2935
- const removal =
2936
- backend.kind === "jj"
2937
- ? removeJj(taskId, phaseId, root, options)
2938
- : Effect.gen(function* () {
2939
- const inspection = yield* inspectExecution(
2940
- taskId,
2941
- phaseId,
2942
- root,
2943
- )
2944
- const blockingConflicts = inspection.conflicts.filter(
2945
- (conflict) =>
2946
- conflict.kind !== "stale-registration" &&
2947
- !(
2948
- options.allowReferenceDrift &&
2949
- conflict.kind === "reference-drift"
2950
- ),
2951
- )
2952
- if (blockingConflicts.length > 0) {
2953
- return yield* new WorktreeError({
2954
- message: blockingConflicts
2955
- .map(({ message }) => message)
2956
- .join("\n"),
2957
- conflicts: blockingConflicts,
2958
- })
2959
- }
2960
- const task = options.task ?? (yield* tasks.show(taskId, root))
2961
-
2962
- let execution:
2963
- | {
2964
- repo: string
2965
- repos?: readonly RepositoryReference[]
2966
- branch: string
2967
- }
2968
- | { review: { repo: string; commit: string } }
2969
- let codePath: string
2970
- if ("phases" in task.data) {
2971
- if (!phaseId) {
2972
- return yield* new WorktreeError({
2973
- message: `Task '${taskId}' has multiple phases; phase ID is required`,
2974
- })
2975
- }
2976
- const phase =
2977
- options.phase ??
2978
- (yield* phases.show(taskId, phaseId, root))
2979
- execution = phase.data
2980
- codePath = join(dirname(phase.path), "code")
2981
- } else {
2982
- if (phaseId) {
2983
- return yield* new WorktreeError({
2984
- message: `Task '${taskId}' is single-phase and does not accept a phase ID`,
2985
- })
2986
- }
2987
- execution = task.data
2988
- codePath = join(dirname(task.path), "code")
2989
- }
1843
+ const removal = Effect.gen(function* () {
1844
+ const inspection = yield* inspectExecution(taskId, phaseId, root)
1845
+ const blockingConflicts = inspection.conflicts.filter(
1846
+ (conflict) =>
1847
+ conflict.kind !== "stale-registration" &&
1848
+ !(
1849
+ options.allowReferenceDrift &&
1850
+ conflict.kind === "reference-drift"
1851
+ ),
1852
+ )
1853
+ if (blockingConflicts.length > 0) {
1854
+ return yield* new WorktreeError({
1855
+ message: blockingConflicts
1856
+ .map(({ message }) => message)
1857
+ .join("\n"),
1858
+ conflicts: blockingConflicts,
1859
+ })
1860
+ }
1861
+ const task = options.task ?? (yield* tasks.show(taskId, root))
2990
1862
 
2991
- const codeDirectoryExists = yield* fs.isDirectory(codePath)
2992
- const removalPlans: {
2993
- alias: string
2994
- repositoryPath: string
2995
- checkoutPath: string
2996
- registeredPath: string
2997
- checkoutExists: boolean
2998
- head?: string
2999
- branch?: string
3000
- }[] = []
3001
- const expectedCheckouts: readonly (
3002
- | { readonly repo: string; readonly branch: string }
3003
- | RepositoryReference
3004
- )[] =
3005
- "review" in execution
3006
- ? [
3007
- {
3008
- repo: execution.review.repo,
3009
- ref: execution.review.commit,
3010
- },
3011
- ]
3012
- : [
3013
- { repo: execution.repo, branch: execution.branch },
3014
- ...(execution.repos ?? []),
3015
- ]
3016
- const expectedAliases = expectedCheckouts.map(
3017
- ({ repo }) => repo,
1863
+ let execution:
1864
+ | {
1865
+ repo: string
1866
+ repos?: readonly RepositoryReference[]
1867
+ branch: string
1868
+ }
1869
+ | { review: { repo: string; commit: string } }
1870
+ let codePath: string
1871
+ if ("phases" in task.data) {
1872
+ if (!phaseId) {
1873
+ return yield* new WorktreeError({
1874
+ message: `Task '${taskId}' has multiple phases; phase ID is required`,
1875
+ })
1876
+ }
1877
+ const phase =
1878
+ options.phase ?? (yield* phases.show(taskId, phaseId, root))
1879
+ execution = phase.data
1880
+ codePath = join(dirname(phase.path), "code")
1881
+ } else {
1882
+ if (phaseId) {
1883
+ return yield* new WorktreeError({
1884
+ message: `Task '${taskId}' is single-phase and does not accept a phase ID`,
1885
+ })
1886
+ }
1887
+ execution = task.data
1888
+ codePath = join(dirname(task.path), "code")
1889
+ }
1890
+
1891
+ const codeDirectoryExists = yield* fs.isDirectory(codePath)
1892
+ const removalPlans: {
1893
+ alias: string
1894
+ repositoryPath: string
1895
+ checkoutPath: string
1896
+ registeredPath: string
1897
+ checkoutExists: boolean
1898
+ head?: string
1899
+ branch?: string
1900
+ }[] = []
1901
+ const expectedCheckouts: readonly (
1902
+ | { readonly repo: string; readonly branch: string }
1903
+ | RepositoryReference
1904
+ )[] =
1905
+ "review" in execution
1906
+ ? [
1907
+ {
1908
+ repo: execution.review.repo,
1909
+ ref: execution.review.commit,
1910
+ },
1911
+ ]
1912
+ : [
1913
+ { repo: execution.repo, branch: execution.branch },
1914
+ ...(execution.repos ?? []),
1915
+ ]
1916
+ const expectedAliases = expectedCheckouts.map(({ repo }) => repo)
1917
+ if (codeDirectoryExists) {
1918
+ const unmanaged = (yield* fs.readDirectory(codePath)).filter(
1919
+ (entry) => !expectedAliases.includes(entry.name),
1920
+ )
1921
+ if (unmanaged.length > 0) {
1922
+ return yield* new WorktreeError({
1923
+ message: `Cannot remove ${codePath}; it contains unmanaged entries: ${unmanaged.map((entry) => entry.name).join(", ")}`,
1924
+ })
1925
+ }
1926
+ }
1927
+ for (const checkout of expectedCheckouts) {
1928
+ const alias = checkout.repo
1929
+ const repositoryPath = join(root, "repos", alias)
1930
+ const checkoutPath = join(codePath, alias)
1931
+ if (
1932
+ (yield* fs.exists(checkoutPath)) &&
1933
+ !(yield* fs.isDirectory(checkoutPath))
1934
+ ) {
1935
+ return yield* new WorktreeError({
1936
+ message: `Cannot remove ${codePath}; expected checkout ${checkoutPath} is not a directory`,
1937
+ })
1938
+ }
1939
+ const listed = yield* fs.runCommand(
1940
+ [
1941
+ "git",
1942
+ "-C",
1943
+ repositoryPath,
1944
+ "worktree",
1945
+ "list",
1946
+ "--porcelain",
1947
+ "-z",
1948
+ ],
1949
+ { captureOutput: true },
1950
+ )
1951
+ if (listed.exitCode !== 0) {
1952
+ return yield* new WorktreeError({
1953
+ message: `Failed to inspect worktrees for '${alias}': ${listed.stderr}`,
1954
+ })
1955
+ }
1956
+
1957
+ const checkoutExists = yield* fs.isDirectory(checkoutPath)
1958
+ const canonicalCheckoutPath = checkoutExists
1959
+ ? yield* fs.realPath(checkoutPath)
1960
+ : join(
1961
+ yield* fs.realPath(dirname(codePath)),
1962
+ basename(codePath),
1963
+ alias,
3018
1964
  )
3019
- if (codeDirectoryExists) {
3020
- const unmanaged = (yield* fs.readDirectory(
3021
- codePath,
3022
- )).filter((entry) => !expectedAliases.includes(entry.name))
3023
- if (unmanaged.length > 0) {
3024
- return yield* new WorktreeError({
3025
- message: `Cannot remove ${codePath}; it contains unmanaged entries: ${unmanaged.map((entry) => entry.name).join(", ")}`,
3026
- })
3027
- }
3028
- }
3029
- for (const checkout of expectedCheckouts) {
3030
- const alias = checkout.repo
3031
- const repositoryPath = join(root, "repos", alias)
3032
- const checkoutPath = join(codePath, alias)
3033
- if (
3034
- (yield* fs.exists(checkoutPath)) &&
3035
- !(yield* fs.isDirectory(checkoutPath))
3036
- ) {
3037
- return yield* new WorktreeError({
3038
- message: `Cannot remove ${codePath}; expected checkout ${checkoutPath} is not a directory`,
3039
- })
3040
- }
3041
- const listed = yield* fs.runCommand(
3042
- [
3043
- "git",
3044
- "-C",
3045
- repositoryPath,
3046
- "worktree",
3047
- "list",
3048
- "--porcelain",
3049
- "-z",
3050
- ],
3051
- { captureOutput: true },
3052
- )
3053
- if (listed.exitCode !== 0) {
3054
- return yield* new WorktreeError({
3055
- message: `Failed to inspect worktrees for '${alias}': ${listed.stderr}`,
3056
- })
3057
- }
1965
+ let registered: GitWorktree | undefined
1966
+ for (const worktree of parseWorktreeList(listed.stdout)) {
1967
+ const worktreePath = (yield* fs.exists(worktree.path))
1968
+ ? yield* fs.realPath(worktree.path)
1969
+ : resolve(worktree.path)
1970
+ if (worktreePath === canonicalCheckoutPath) {
1971
+ registered = { ...worktree, path: worktreePath }
1972
+ break
1973
+ }
1974
+ }
1975
+ if (!registered) {
1976
+ if (checkoutExists) {
1977
+ return yield* new WorktreeError({
1978
+ message: `Existing checkout ${checkoutPath} is not registered as a Git worktree`,
1979
+ })
1980
+ }
1981
+ continue
1982
+ }
1983
+ if ("branch" in checkout) {
1984
+ const actualBranch = registered.branch?.replace(
1985
+ /^refs\/heads\//,
1986
+ "",
1987
+ )
1988
+ if (actualBranch !== checkout.branch) {
1989
+ return yield* new WorktreeError({
1990
+ message: `Cannot remove ${checkoutPath}; expected branch '${checkout.branch}', found '${actualBranch ?? "detached HEAD"}'`,
1991
+ })
1992
+ }
1993
+ } else {
1994
+ if (registered.branch) {
1995
+ return yield* new WorktreeError({
1996
+ message: `Cannot remove reference checkout ${checkoutPath}; it is attached to branch '${registered.branch.replace(/^refs\/heads\//, "")}'`,
1997
+ })
1998
+ }
1999
+ const expected = yield* fs.runCommand(
2000
+ [
2001
+ "git",
2002
+ "-C",
2003
+ repositoryPath,
2004
+ "rev-parse",
2005
+ "--verify",
2006
+ `${checkout.ref}^{commit}`,
2007
+ ],
2008
+ { captureOutput: true },
2009
+ )
2010
+ if (
2011
+ expected.exitCode !== 0 ||
2012
+ registered.head !== expected.stdout.trim()
2013
+ ) {
2014
+ return yield* new WorktreeError({
2015
+ message: `Cannot remove reference checkout ${checkoutPath}; it does not match '${checkout.ref}'`,
2016
+ })
2017
+ }
2018
+ }
2019
+ if (checkoutExists) {
2020
+ const status = yield* fs.runCommand(
2021
+ ["git", "-C", checkoutPath, "status", "--porcelain"],
2022
+ { captureOutput: true },
2023
+ )
2024
+ if (status.exitCode !== 0 || status.stdout.trim()) {
2025
+ return yield* new WorktreeError({
2026
+ message: `Failed to remove worktree for '${alias}': checkout has uncommitted changes`,
2027
+ })
2028
+ }
2029
+ }
2030
+ removalPlans.push({
2031
+ alias,
2032
+ repositoryPath,
2033
+ checkoutPath,
2034
+ registeredPath: registered.path,
2035
+ checkoutExists,
2036
+ head: registered.head,
2037
+ branch: registered.branch?.replace(/^refs\/heads\//, ""),
2038
+ })
2039
+ }
2040
+ for (const plan of removalPlans) {
2041
+ if (!plan.checkoutExists || !plan.head) continue
2042
+ options.snapshots?.push({
2043
+ path: plan.checkoutPath,
2044
+ repositoryPath: plan.repositoryPath,
2045
+ head: plan.head,
2046
+ ...(plan.branch ? { branch: plan.branch } : {}),
2047
+ })
2048
+ }
2049
+ if (options.dryRun) {
2050
+ return removalPlans.map((plan) =>
2051
+ plan.checkoutExists ? plan.checkoutPath : plan.registeredPath,
2052
+ )
2053
+ }
3058
2054
 
3059
- const checkoutExists = yield* fs.isDirectory(checkoutPath)
3060
- const canonicalCheckoutPath = checkoutExists
3061
- ? yield* fs.realPath(checkoutPath)
3062
- : join(
3063
- yield* fs.realPath(dirname(codePath)),
3064
- basename(codePath),
3065
- alias,
3066
- )
3067
- let registered: GitWorktree | undefined
3068
- for (const worktree of parseWorktreeList(listed.stdout)) {
3069
- const worktreePath = (yield* fs.exists(worktree.path))
3070
- ? yield* fs.realPath(worktree.path)
3071
- : resolve(worktree.path)
3072
- if (worktreePath === canonicalCheckoutPath) {
3073
- registered = { ...worktree, path: worktreePath }
3074
- break
3075
- }
3076
- }
3077
- if (!registered) {
3078
- if (checkoutExists) {
3079
- return yield* new WorktreeError({
3080
- message: `Existing checkout ${checkoutPath} is not registered as a Git worktree`,
3081
- })
3082
- }
2055
+ const completed: typeof removalPlans = []
2056
+ const removed = yield* Effect.gen(function* () {
2057
+ for (const plan of removalPlans) {
2058
+ const command = plan.checkoutExists
2059
+ ? [
2060
+ "git",
2061
+ "-C",
2062
+ plan.repositoryPath,
2063
+ "worktree",
2064
+ "remove",
2065
+ plan.checkoutPath,
2066
+ ]
2067
+ : [
2068
+ "git",
2069
+ "-C",
2070
+ plan.repositoryPath,
2071
+ "worktree",
2072
+ "prune",
2073
+ "--expire",
2074
+ "now",
2075
+ ]
2076
+ const result = yield* fs.runCommand(command, {
2077
+ captureOutput: true,
2078
+ })
2079
+ if (result.exitCode !== 0) {
2080
+ return yield* new WorktreeError({
2081
+ message: `Failed to remove worktree for '${plan.alias}': ${result.stderr}`,
2082
+ })
2083
+ }
2084
+ completed.push(plan)
2085
+ }
2086
+ if (codeDirectoryExists)
2087
+ yield* fs.deleteDirectoryIfEmpty(codePath)
2088
+ return removalPlans.map((plan) =>
2089
+ plan.checkoutExists ? plan.checkoutPath : plan.registeredPath,
2090
+ )
2091
+ }).pipe(
2092
+ Effect.catchAll((cause) =>
2093
+ Effect.gen(function* () {
2094
+ const rolledBack: string[] = []
2095
+ const manualRecovery: string[] = []
2096
+ for (const plan of [...completed].reverse()) {
2097
+ if (!plan.checkoutExists) {
2098
+ manualRecovery.push(
2099
+ `Re-run worktree repair for stale registration ${plan.registeredPath}`,
2100
+ )
3083
2101
  continue
3084
2102
  }
3085
- if ("branch" in checkout) {
3086
- const actualBranch = registered.branch?.replace(
3087
- /^refs\/heads\//,
3088
- "",
3089
- )
3090
- if (actualBranch !== checkout.branch) {
3091
- return yield* new WorktreeError({
3092
- message: `Cannot remove ${checkoutPath}; expected branch '${checkout.branch}', found '${actualBranch ?? "detached HEAD"}'`,
3093
- })
3094
- }
3095
- } else {
3096
- if (registered.branch) {
3097
- return yield* new WorktreeError({
3098
- message: `Cannot remove reference checkout ${checkoutPath}; it is attached to branch '${registered.branch.replace(/^refs\/heads\//, "")}'`,
3099
- })
3100
- }
3101
- const expected = yield* fs.runCommand(
3102
- [
2103
+ yield* fs.createDirectory(dirname(plan.checkoutPath))
2104
+ const command = plan.branch
2105
+ ? [
3103
2106
  "git",
3104
2107
  "-C",
3105
- repositoryPath,
3106
- "rev-parse",
3107
- "--verify",
3108
- `${checkout.ref}^{commit}`,
3109
- ],
3110
- { captureOutput: true },
3111
- )
3112
- if (
3113
- expected.exitCode !== 0 ||
3114
- registered.head !== expected.stdout.trim()
3115
- ) {
3116
- return yield* new WorktreeError({
3117
- message: `Cannot remove reference checkout ${checkoutPath}; it does not match '${checkout.ref}'`,
3118
- })
3119
- }
3120
- }
3121
- if (checkoutExists) {
3122
- const status = yield* fs.runCommand(
3123
- ["git", "-C", checkoutPath, "status", "--porcelain"],
3124
- { captureOutput: true },
3125
- )
3126
- if (status.exitCode !== 0 || status.stdout.trim()) {
3127
- return yield* new WorktreeError({
3128
- message: `Failed to remove worktree for '${alias}': checkout has uncommitted changes`,
3129
- })
3130
- }
3131
- }
3132
- removalPlans.push({
3133
- alias,
3134
- repositoryPath,
3135
- checkoutPath,
3136
- registeredPath: registered.path,
3137
- checkoutExists,
3138
- head: registered.head,
3139
- branch: registered.branch?.replace(/^refs\/heads\//, ""),
3140
- })
3141
- }
3142
- for (const plan of removalPlans) {
3143
- if (!plan.checkoutExists || !plan.head) continue
3144
- options.snapshots?.push({
3145
- path: plan.checkoutPath,
3146
- repositoryPath: plan.repositoryPath,
3147
- head: plan.head,
3148
- ...(plan.branch ? { branch: plan.branch } : {}),
2108
+ plan.repositoryPath,
2109
+ "worktree",
2110
+ "add",
2111
+ plan.checkoutPath,
2112
+ plan.branch,
2113
+ ]
2114
+ : [
2115
+ "git",
2116
+ "-C",
2117
+ plan.repositoryPath,
2118
+ "worktree",
2119
+ "add",
2120
+ "--detach",
2121
+ plan.checkoutPath,
2122
+ plan.head!,
2123
+ ]
2124
+ const restored = yield* fs.runCommand(command, {
2125
+ captureOutput: true,
3149
2126
  })
2127
+ if (restored.exitCode === 0)
2128
+ rolledBack.push(plan.checkoutPath)
2129
+ else manualRecovery.push(`Restore ${plan.checkoutPath}`)
3150
2130
  }
3151
- if (options.dryRun) {
3152
- return removalPlans.map((plan) =>
3153
- plan.checkoutExists
3154
- ? plan.checkoutPath
3155
- : plan.registeredPath,
3156
- )
3157
- }
3158
-
3159
- const completed: typeof removalPlans = []
3160
- const removed = yield* Effect.gen(function* () {
3161
- for (const plan of removalPlans) {
3162
- const command = plan.checkoutExists
3163
- ? [
3164
- "git",
3165
- "-C",
3166
- plan.repositoryPath,
3167
- "worktree",
3168
- "remove",
3169
- plan.checkoutPath,
3170
- ]
3171
- : [
3172
- "git",
3173
- "-C",
3174
- plan.repositoryPath,
3175
- "worktree",
3176
- "prune",
3177
- "--expire",
3178
- "now",
3179
- ]
3180
- const result = yield* fs.runCommand(command, {
3181
- captureOutput: true,
3182
- })
3183
- if (result.exitCode !== 0) {
3184
- return yield* new WorktreeError({
3185
- message: `Failed to remove worktree for '${plan.alias}': ${result.stderr}`,
3186
- })
3187
- }
3188
- completed.push(plan)
3189
- }
3190
- if (codeDirectoryExists)
3191
- yield* fs.deleteDirectoryIfEmpty(codePath)
3192
- return removalPlans.map((plan) =>
3193
- plan.checkoutExists
3194
- ? plan.checkoutPath
3195
- : plan.registeredPath,
3196
- )
3197
- }).pipe(
3198
- Effect.catchAll((cause) =>
3199
- Effect.gen(function* () {
3200
- const rolledBack: string[] = []
3201
- const manualRecovery: string[] = []
3202
- for (const plan of [...completed].reverse()) {
3203
- if (!plan.checkoutExists) {
3204
- manualRecovery.push(
3205
- `Re-run worktree repair for stale registration ${plan.registeredPath}`,
3206
- )
3207
- continue
3208
- }
3209
- yield* fs.createDirectory(dirname(plan.checkoutPath))
3210
- const command = plan.branch
3211
- ? [
3212
- "git",
3213
- "-C",
3214
- plan.repositoryPath,
3215
- "worktree",
3216
- "add",
3217
- plan.checkoutPath,
3218
- plan.branch,
3219
- ]
3220
- : [
3221
- "git",
3222
- "-C",
3223
- plan.repositoryPath,
3224
- "worktree",
3225
- "add",
3226
- "--detach",
3227
- plan.checkoutPath,
3228
- plan.head!,
3229
- ]
3230
- const restored = yield* fs.runCommand(command, {
3231
- captureOutput: true,
3232
- })
3233
- if (restored.exitCode === 0)
3234
- rolledBack.push(plan.checkoutPath)
3235
- else
3236
- manualRecovery.push(`Restore ${plan.checkoutPath}`)
3237
- }
3238
- return yield* new WorktreeError({
3239
- message: manualRecovery.length
3240
- ? "Worktree removal failed and requires manual recovery"
3241
- : "Worktree removal failed; removed worktrees were restored",
3242
- completed: completed.map((plan) => plan.checkoutPath),
3243
- rolledBack,
3244
- manualRecovery,
3245
- cause,
3246
- })
3247
- }),
3248
- ),
3249
- )
3250
- return removed
3251
- })
2131
+ return yield* new WorktreeError({
2132
+ message: manualRecovery.length
2133
+ ? "Worktree removal failed and requires manual recovery"
2134
+ : "Worktree removal failed; removed worktrees were restored",
2135
+ completed: completed.map((plan) => plan.checkoutPath),
2136
+ rolledBack,
2137
+ manualRecovery,
2138
+ cause,
2139
+ })
2140
+ }),
2141
+ ),
2142
+ )
2143
+ return removed
2144
+ })
3252
2145
  return yield* options.lockHeld
3253
2146
  ? removal
3254
2147
  : withWorktreeLocks(
@@ -3335,7 +2228,6 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
3335
2228
  snapshots,
3336
2229
  lockHeld: true,
3337
2230
  allowReferenceDrift: true,
3338
- persistResume: false,
3339
2231
  },
3340
2232
  )
3341
2233
  const workspace = yield* service
@@ -3350,51 +2242,30 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
3350
2242
  const manualRecovery: string[] = []
3351
2243
  for (const snapshot of [...snapshots].reverse()) {
3352
2244
  yield* fs.createDirectory(dirname(snapshot.path))
3353
- const command =
3354
- snapshot.vcs === "jj"
3355
- ? [
3356
- "jj",
3357
- "-R",
3358
- snapshot.repositoryPath,
3359
- "workspace",
3360
- "add",
3361
- "--name",
3362
- snapshot.workspaceName!,
3363
- "-r",
3364
- snapshot.head,
3365
- snapshot.path,
3366
- ]
3367
- : snapshot.branch
3368
- ? [
3369
- "git",
3370
- "-C",
3371
- snapshot.repositoryPath,
3372
- "worktree",
3373
- "add",
3374
- snapshot.path,
3375
- snapshot.branch,
3376
- ]
3377
- : [
3378
- "git",
3379
- "-C",
3380
- snapshot.repositoryPath,
3381
- "worktree",
3382
- "add",
3383
- "--detach",
3384
- snapshot.path,
3385
- snapshot.head,
3386
- ]
2245
+ const command = snapshot.branch
2246
+ ? [
2247
+ "git",
2248
+ "-C",
2249
+ snapshot.repositoryPath,
2250
+ "worktree",
2251
+ "add",
2252
+ snapshot.path,
2253
+ snapshot.branch,
2254
+ ]
2255
+ : [
2256
+ "git",
2257
+ "-C",
2258
+ snapshot.repositoryPath,
2259
+ "worktree",
2260
+ "add",
2261
+ "--detach",
2262
+ snapshot.path,
2263
+ snapshot.head,
2264
+ ]
3387
2265
  const restored = yield* fs.runCommand(command, {
3388
2266
  captureOutput: true,
3389
2267
  })
3390
- const edited =
3391
- restored.exitCode === 0 && snapshot.vcs === "jj"
3392
- ? yield* fs.runCommand(
3393
- ["jj", "-R", snapshot.path, "edit", snapshot.head],
3394
- { captureOutput: true },
3395
- )
3396
- : restored
3397
- if (edited.exitCode === 0) rolledBack.push(snapshot.path)
2268
+ if (restored.exitCode === 0) rolledBack.push(snapshot.path)
3398
2269
  else manualRecovery.push(`Restore ${snapshot.path}`)
3399
2270
  }
3400
2271
  return yield* new WorktreeError({
@@ -3435,10 +2306,8 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
3435
2306
  Effect.gen(function* () {
3436
2307
  const fs = yield* FileSystemService
3437
2308
  const workbase = yield* WorkbaseService
3438
- const versionControl = yield* VersionControlService
3439
2309
  const service = yield* WorktreeService
3440
2310
  const root = yield* workbase.discover(startPath)
3441
- const backend = yield* versionControl.forWorkbase(root)
3442
2311
  if (!options.lockHeld) {
3443
2312
  return yield* withWorktreeLocks(
3444
2313
  root,
@@ -3450,84 +2319,6 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
3450
2319
  )
3451
2320
  }
3452
2321
  const inspection = yield* inspectExecution(taskId, phaseId, root)
3453
- if (backend.kind === "jj") {
3454
- const unsafe = inspection.conflicts.filter(
3455
- (conflict) => conflict.kind !== "stale-registration",
3456
- )
3457
- if (unsafe.length > 0) {
3458
- return yield* new WorktreeError({
3459
- message: unsafe.map(({ message }) => message).join("\n"),
3460
- conflicts: unsafe,
3461
- })
3462
- }
3463
- const actions: string[] = []
3464
- for (const checkout of inspection.checkouts) {
3465
- const repositoryPath = join(root, "repos", checkout.repo)
3466
- for (const conflict of checkout.conflicts) {
3467
- if (
3468
- conflict.kind !== "stale-registration" ||
3469
- !conflict.registeredPath
3470
- )
3471
- continue
3472
- const workspace = (yield* backend.listWorkspaces(
3473
- repositoryPath,
3474
- )).find((item) => item.path === conflict.registeredPath)
3475
- if (!workspace?.name) {
3476
- return yield* new WorktreeError({
3477
- message: `Cannot identify stale jj workspace at ${conflict.registeredPath}`,
3478
- })
3479
- }
3480
- const command = formatCommand([
3481
- "jj",
3482
- "-R",
3483
- repositoryPath,
3484
- "workspace",
3485
- "forget",
3486
- workspace.name,
3487
- ])
3488
- actions.push(command)
3489
- if (!options.dryRun) {
3490
- yield* backend.removeWorkspace({
3491
- repositoryPath,
3492
- workspacePath: checkout.path,
3493
- workspaceName: workspace.name,
3494
- })
3495
- }
3496
- }
3497
- }
3498
- const missing = inspection.checkouts.some(
3499
- (checkout) => !checkout.exists,
3500
- )
3501
- if (missing) {
3502
- if (options.dryRun) {
3503
- actions.push(
3504
- ...inspection.checkouts
3505
- .filter((checkout) => !checkout.exists)
3506
- .map((checkout) => `prepare ${checkout.path}`),
3507
- )
3508
- } else {
3509
- const workspace = yield* service.materialize(
3510
- taskId,
3511
- phaseId,
3512
- root,
3513
- { ...options, lockHeld: true },
3514
- )
3515
- actions.push(
3516
- ...workspace.operations.map((operation) =>
3517
- formatCommand(operation.command),
3518
- ),
3519
- )
3520
- }
3521
- }
3522
- return {
3523
- operation: "repair",
3524
- dryRun: options.dryRun === true,
3525
- inspection: options.dryRun
3526
- ? inspection
3527
- : yield* inspectExecution(taskId, phaseId, root),
3528
- actions,
3529
- } satisfies WorktreeLifecycleResult
3530
- }
3531
2322
  const safeKinds = new Set<WorktreeConflict["kind"]>([
3532
2323
  "stale-registration",
3533
2324
  "unregistered-checkout",