@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.
- package/README.md +23 -121
- package/cli-main.ts +0 -26
- package/cli.ts +1 -17
- package/package.json +1 -2
- package/schemas/agency-graph-v1.schema.json +1 -1
- package/src/cli-parser.test.ts +0 -19
- package/src/cli-parser.ts +0 -23
- package/src/cli.test.ts +3 -3
- package/src/commands/context.test.ts +0 -67
- package/src/commands/init.test.ts +1 -2
- package/src/commands/pr.test.ts +2 -87
- package/src/commands/pr.ts +2 -79
- package/src/commands/push.test.ts +1 -2
- package/src/graph-schema.ts +1 -1
- package/src/services/ArchiveBulkService.test.ts +1 -142
- package/src/services/ArchiveService.test.ts +1 -172
- package/src/services/ArchiveService.ts +0 -26
- package/src/services/ContextService.ts +3 -47
- package/src/services/DoctorService.ts +8 -24
- package/src/services/GraphService.test.ts +1 -0
- package/src/services/GraphService.ts +2 -5
- package/src/services/PhaseService.ts +70 -191
- package/src/services/PullRequestService.test.ts +1 -45
- package/src/services/PullRequestService.ts +1 -22
- package/src/services/PushService.test.ts +29 -295
- package/src/services/PushService.ts +10 -239
- package/src/services/RepositoryService.test.ts +1 -61
- package/src/services/RepositoryService.ts +2 -13
- package/src/services/ReviewService.test.ts +1 -84
- package/src/services/ReviewService.ts +9 -48
- package/src/services/SyncService.test.ts +0 -81
- package/src/services/SyncService.ts +12 -39
- package/src/services/TaskPhaseService.test.ts +0 -80
- package/src/services/TaskService.ts +1 -9
- package/src/services/VersionControlService.test.ts +4 -117
- package/src/services/VersionControlService.ts +3 -426
- package/src/services/WorkbaseService.test.ts +0 -20
- package/src/services/WorkbaseService.ts +1 -18
- package/src/services/WorktreeService.test.ts +1 -705
- package/src/services/WorktreeService.ts +395 -1604
- package/src/services/push-validation.ts +0 -7
- package/src/test-utils.ts +0 -4
- package/src/workbase/AGENTS.md +4 -5
- package/src/workbase/checkout-command.test.ts +1 -1
- package/src/workbase/checkout-command.ts +1 -1
- package/src/workbase/delivery-command.test.ts +4 -35
- package/src/workbase/delivery-command.ts +2 -15
- package/src/workbase/schemas.test.ts +0 -19
- package/src/workbase/schemas.ts +1 -2
- package/src/commands/vcs.test.ts +0 -75
- package/src/commands/vcs.ts +0 -72
- package/src/services/VcsMigrationService.test.ts +0 -348
- package/src/services/VcsMigrationService.ts +0 -857
- package/src/vcs-status-fast.ts +0 -310
- package/src/workbase/version-control.ts +0 -5
- package/src/workbase/workspace-command.test.ts +0 -70
- 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
|
|
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
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
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
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
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
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
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
|
-
|
|
1308
|
-
|
|
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
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
2952
|
-
|
|
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
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
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
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
|
|
3036
|
-
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
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
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
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
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
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
|
-
"
|
|
3107
|
-
"
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
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
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
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
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
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
|
-
|
|
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",
|