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