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