@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
|
@@ -0,0 +1,816 @@
|
|
|
1
|
+
import { Data, Effect, Either } from "effect"
|
|
2
|
+
import { rename, rm } from "node:fs/promises"
|
|
3
|
+
import { dirname, join } from "node:path"
|
|
4
|
+
import { documentRevision } from "../workbase/document-revision"
|
|
5
|
+
import type { WorkStatus } from "../workbase/schemas"
|
|
6
|
+
import {
|
|
7
|
+
documentWriteStep,
|
|
8
|
+
runLifecycleTransaction,
|
|
9
|
+
} from "./LifecycleTransaction"
|
|
10
|
+
import { FileSystemService } from "./FileSystemService"
|
|
11
|
+
import { PhaseService } from "./PhaseService"
|
|
12
|
+
import { RepositoryService } from "./RepositoryService"
|
|
13
|
+
import { TaskService } from "./TaskService"
|
|
14
|
+
import {
|
|
15
|
+
GitVersionControlService,
|
|
16
|
+
JjVersionControlService,
|
|
17
|
+
type VersionControlBackend,
|
|
18
|
+
} from "./VersionControlService"
|
|
19
|
+
import { WorkbaseService } from "./WorkbaseService"
|
|
20
|
+
import { withWorktreeLocks, type WorktreeLockTarget } from "./WorktreeLock"
|
|
21
|
+
import { WorktreeService } from "./WorktreeService"
|
|
22
|
+
|
|
23
|
+
type VcsKind = "git" | "jj"
|
|
24
|
+
|
|
25
|
+
class VcsMigrationError extends Data.TaggedError("VcsMigrationError")<{
|
|
26
|
+
readonly message: string
|
|
27
|
+
readonly blockers?: readonly MigrationBlocker[]
|
|
28
|
+
}> {}
|
|
29
|
+
|
|
30
|
+
interface MigrationBlocker {
|
|
31
|
+
readonly kind:
|
|
32
|
+
| "active-work"
|
|
33
|
+
| "dirty-workspace"
|
|
34
|
+
| "workspace-conflict"
|
|
35
|
+
| "repository"
|
|
36
|
+
| "tool"
|
|
37
|
+
| "jj-only-head"
|
|
38
|
+
readonly target: string
|
|
39
|
+
readonly message: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface WorkspacePlan {
|
|
43
|
+
readonly taskId: string
|
|
44
|
+
readonly phaseId?: string
|
|
45
|
+
readonly repo: string
|
|
46
|
+
readonly kind: "writable" | "reference"
|
|
47
|
+
readonly path: string
|
|
48
|
+
readonly head: string
|
|
49
|
+
readonly branch: string | null
|
|
50
|
+
readonly sourceName: string | null
|
|
51
|
+
readonly targetName: string
|
|
52
|
+
readonly previousBranchCommit: string | null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface RepositoryPlan {
|
|
56
|
+
readonly alias: string
|
|
57
|
+
readonly path: string
|
|
58
|
+
readonly target: string
|
|
59
|
+
readonly kind: "bare" | "repository" | "symlink"
|
|
60
|
+
readonly remote: string | null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface MigrationState {
|
|
64
|
+
readonly root: string
|
|
65
|
+
readonly configured: VcsKind | null
|
|
66
|
+
readonly source: VcsKind
|
|
67
|
+
readonly target: VcsKind
|
|
68
|
+
readonly available: { readonly git: boolean; readonly jj: boolean }
|
|
69
|
+
readonly repositories: readonly {
|
|
70
|
+
readonly alias: string
|
|
71
|
+
readonly path: string
|
|
72
|
+
readonly kind: "bare" | "repository" | "symlink" | null
|
|
73
|
+
readonly initialized: boolean
|
|
74
|
+
}[]
|
|
75
|
+
readonly workspaceCount: number
|
|
76
|
+
readonly blockers: readonly MigrationBlocker[]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface MigrationResult extends MigrationState {
|
|
80
|
+
readonly mode: "dry-run" | "apply"
|
|
81
|
+
readonly actions: readonly string[]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const command = (
|
|
85
|
+
fs: FileSystemService,
|
|
86
|
+
args: readonly string[],
|
|
87
|
+
label: string,
|
|
88
|
+
) =>
|
|
89
|
+
fs.runCommand(args, { captureOutput: true }).pipe(
|
|
90
|
+
Effect.flatMap((result) =>
|
|
91
|
+
result.exitCode === 0
|
|
92
|
+
? Effect.succeed(result.stdout.trim())
|
|
93
|
+
: Effect.fail(
|
|
94
|
+
new VcsMigrationError({
|
|
95
|
+
message: `${label}: ${result.stderr.trim() || result.stdout.trim()}`,
|
|
96
|
+
}),
|
|
97
|
+
),
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
const runBackend = <A>(
|
|
102
|
+
fs: FileSystemService,
|
|
103
|
+
effect: Effect.Effect<A, unknown, any>,
|
|
104
|
+
) =>
|
|
105
|
+
Effect.runPromise(
|
|
106
|
+
effect.pipe(Effect.provideService(FileSystemService, fs)) as Effect.Effect<
|
|
107
|
+
A,
|
|
108
|
+
unknown,
|
|
109
|
+
never
|
|
110
|
+
>,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
const workspaceName = (plan: {
|
|
114
|
+
readonly taskId: string
|
|
115
|
+
readonly phaseId?: string
|
|
116
|
+
readonly repo: string
|
|
117
|
+
}) => `agency-${plan.taskId}-${plan.phaseId ?? "task"}-${plan.repo}`
|
|
118
|
+
|
|
119
|
+
const createGitWorkspace = (
|
|
120
|
+
fs: FileSystemService,
|
|
121
|
+
plan: WorkspacePlan,
|
|
122
|
+
repositoryPath: string,
|
|
123
|
+
) =>
|
|
124
|
+
Effect.gen(function* () {
|
|
125
|
+
if (plan.branch) {
|
|
126
|
+
yield* command(
|
|
127
|
+
fs,
|
|
128
|
+
["git", "-C", repositoryPath, "branch", "-f", plan.branch, plan.head],
|
|
129
|
+
`Failed to prepare branch '${plan.branch}'`,
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
yield* command(
|
|
133
|
+
fs,
|
|
134
|
+
plan.branch
|
|
135
|
+
? [
|
|
136
|
+
"git",
|
|
137
|
+
"-C",
|
|
138
|
+
repositoryPath,
|
|
139
|
+
"worktree",
|
|
140
|
+
"add",
|
|
141
|
+
plan.path,
|
|
142
|
+
plan.branch,
|
|
143
|
+
]
|
|
144
|
+
: [
|
|
145
|
+
"git",
|
|
146
|
+
"-C",
|
|
147
|
+
repositoryPath,
|
|
148
|
+
"worktree",
|
|
149
|
+
"add",
|
|
150
|
+
"--detach",
|
|
151
|
+
plan.path,
|
|
152
|
+
plan.head,
|
|
153
|
+
],
|
|
154
|
+
`Failed to create Git worktree ${plan.path}`,
|
|
155
|
+
)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
const restoreGitBranch = (
|
|
159
|
+
fs: FileSystemService,
|
|
160
|
+
plan: WorkspacePlan,
|
|
161
|
+
repositoryPath: string,
|
|
162
|
+
) =>
|
|
163
|
+
plan.branch
|
|
164
|
+
? command(
|
|
165
|
+
fs,
|
|
166
|
+
plan.previousBranchCommit
|
|
167
|
+
? [
|
|
168
|
+
"git",
|
|
169
|
+
"-C",
|
|
170
|
+
repositoryPath,
|
|
171
|
+
"branch",
|
|
172
|
+
"-f",
|
|
173
|
+
plan.branch,
|
|
174
|
+
plan.previousBranchCommit,
|
|
175
|
+
]
|
|
176
|
+
: ["git", "-C", repositoryPath, "branch", "-D", plan.branch],
|
|
177
|
+
`Failed to restore branch '${plan.branch}'`,
|
|
178
|
+
).pipe(Effect.asVoid)
|
|
179
|
+
: Effect.void
|
|
180
|
+
|
|
181
|
+
const executionRecords = (root: string) =>
|
|
182
|
+
Effect.gen(function* () {
|
|
183
|
+
const tasks = yield* TaskService
|
|
184
|
+
const phases = yield* PhaseService
|
|
185
|
+
const records: {
|
|
186
|
+
taskId: string
|
|
187
|
+
phaseId?: string
|
|
188
|
+
status: WorkStatus
|
|
189
|
+
claimActive: boolean
|
|
190
|
+
}[] = []
|
|
191
|
+
for (const task of yield* tasks.list(root)) {
|
|
192
|
+
if ("phases" in task.data) {
|
|
193
|
+
for (const phase of yield* phases.list(task.id, root)) {
|
|
194
|
+
records.push({
|
|
195
|
+
taskId: task.id,
|
|
196
|
+
phaseId: phase.id,
|
|
197
|
+
status: phase.data.status,
|
|
198
|
+
claimActive: phase.data.claim?.state === "active",
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
records.push({
|
|
203
|
+
taskId: task.id,
|
|
204
|
+
status: task.data.status,
|
|
205
|
+
claimActive: task.data.claim?.state === "active",
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return records
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
const inspectMigration = (startPath: string, requestedTarget?: VcsKind) =>
|
|
213
|
+
Effect.gen(function* () {
|
|
214
|
+
const fs = yield* FileSystemService
|
|
215
|
+
const workbase = yield* WorkbaseService
|
|
216
|
+
const repositories = yield* RepositoryService
|
|
217
|
+
const worktrees = yield* WorktreeService
|
|
218
|
+
const git = yield* GitVersionControlService
|
|
219
|
+
const jj = yield* JjVersionControlService
|
|
220
|
+
const { root, config } = yield* workbase.loadConfig(startPath)
|
|
221
|
+
const source = config.vcs ?? "git"
|
|
222
|
+
const target = requestedTarget ?? source
|
|
223
|
+
const sourceBackend = source === "jj" ? jj : git
|
|
224
|
+
const available = {
|
|
225
|
+
git: Bun.which("git") !== null,
|
|
226
|
+
jj: Bun.which("jj") !== null,
|
|
227
|
+
}
|
|
228
|
+
const blockers: MigrationBlocker[] = []
|
|
229
|
+
if (!available.git) {
|
|
230
|
+
blockers.push({
|
|
231
|
+
kind: "tool",
|
|
232
|
+
target: "git",
|
|
233
|
+
message: "The git executable is required for VCS migration",
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
if (target === "jj" && !available.jj) {
|
|
237
|
+
blockers.push({
|
|
238
|
+
kind: "tool",
|
|
239
|
+
target: "jj",
|
|
240
|
+
message: "The jj executable is required for migration to jj",
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const records = yield* executionRecords(root)
|
|
245
|
+
for (const record of records) {
|
|
246
|
+
if (
|
|
247
|
+
record.status === "working" ||
|
|
248
|
+
record.status === "delegated" ||
|
|
249
|
+
record.claimActive
|
|
250
|
+
) {
|
|
251
|
+
const label = record.phaseId
|
|
252
|
+
? `phase:${record.taskId}/${record.phaseId}`
|
|
253
|
+
: `task:${record.taskId}`
|
|
254
|
+
blockers.push({
|
|
255
|
+
kind: "active-work",
|
|
256
|
+
target: label,
|
|
257
|
+
message: `${label} is active; finish or release it before migration`,
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const repositoryRecords = yield* repositories.list(root)
|
|
263
|
+
const repositoryPlans: RepositoryPlan[] = []
|
|
264
|
+
const repositoryStatus: MigrationState["repositories"][number][] = []
|
|
265
|
+
for (const repository of repositoryRecords) {
|
|
266
|
+
const initialized = yield* fs.exists(
|
|
267
|
+
join(
|
|
268
|
+
repository.kind === "symlink"
|
|
269
|
+
? yield* fs.realPath(repository.path)
|
|
270
|
+
: repository.path,
|
|
271
|
+
".jj",
|
|
272
|
+
),
|
|
273
|
+
)
|
|
274
|
+
repositoryStatus.push({
|
|
275
|
+
alias: repository.alias,
|
|
276
|
+
path: repository.path,
|
|
277
|
+
kind: repository.kind,
|
|
278
|
+
initialized,
|
|
279
|
+
})
|
|
280
|
+
if (
|
|
281
|
+
repository.kind === null ||
|
|
282
|
+
repository.states.includes("missing") ||
|
|
283
|
+
repository.states.includes("invalid")
|
|
284
|
+
) {
|
|
285
|
+
blockers.push({
|
|
286
|
+
kind: "repository",
|
|
287
|
+
target: `repository:${repository.alias}`,
|
|
288
|
+
message: `Repository '${repository.alias}' must be valid and materialized before migration`,
|
|
289
|
+
})
|
|
290
|
+
continue
|
|
291
|
+
}
|
|
292
|
+
if (source === "jj" && !initialized) {
|
|
293
|
+
blockers.push({
|
|
294
|
+
kind: "repository",
|
|
295
|
+
target: `repository:${repository.alias}`,
|
|
296
|
+
message: `Repository '${repository.alias}' is not initialized for the configured jj backend`,
|
|
297
|
+
})
|
|
298
|
+
}
|
|
299
|
+
const targetPath =
|
|
300
|
+
repository.kind === "symlink"
|
|
301
|
+
? yield* fs.realPath(repository.path)
|
|
302
|
+
: repository.path
|
|
303
|
+
repositoryPlans.push({
|
|
304
|
+
alias: repository.alias,
|
|
305
|
+
path: repository.path,
|
|
306
|
+
target: targetPath,
|
|
307
|
+
kind: repository.kind,
|
|
308
|
+
remote: repository.declaredRemote ?? repository.remote,
|
|
309
|
+
})
|
|
310
|
+
if (source === "jj" && target === "git" && initialized) {
|
|
311
|
+
const dirty = yield* sourceBackend.workspaceDirty(targetPath)
|
|
312
|
+
if (dirty !== false) {
|
|
313
|
+
blockers.push({
|
|
314
|
+
kind: "dirty-workspace",
|
|
315
|
+
target: `repository:${repository.alias}`,
|
|
316
|
+
message: `Primary jj workspace for '${repository.alias}' must be clean`,
|
|
317
|
+
})
|
|
318
|
+
}
|
|
319
|
+
const hiddenHeads = yield* command(
|
|
320
|
+
fs,
|
|
321
|
+
[
|
|
322
|
+
"jj",
|
|
323
|
+
"-R",
|
|
324
|
+
targetPath,
|
|
325
|
+
"log",
|
|
326
|
+
"--ignore-working-copy",
|
|
327
|
+
"--no-graph",
|
|
328
|
+
"-r",
|
|
329
|
+
"heads(all()) ~ (bookmarks() | remote_bookmarks() | working_copies())",
|
|
330
|
+
"-T",
|
|
331
|
+
'commit_id ++ "\\n"',
|
|
332
|
+
],
|
|
333
|
+
`Failed to inspect jj-only heads for '${repository.alias}'`,
|
|
334
|
+
)
|
|
335
|
+
if (hiddenHeads) {
|
|
336
|
+
blockers.push({
|
|
337
|
+
kind: "jj-only-head",
|
|
338
|
+
target: `repository:${repository.alias}`,
|
|
339
|
+
message: `Repository '${repository.alias}' has jj-only heads that are not preserved by bookmarks or workspaces`,
|
|
340
|
+
})
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const workspacePlans: WorkspacePlan[] = []
|
|
346
|
+
const inspected = yield* Effect.either(worktrees.list(root))
|
|
347
|
+
if (Either.isLeft(inspected)) {
|
|
348
|
+
blockers.push({
|
|
349
|
+
kind: "workspace-conflict",
|
|
350
|
+
target: "workbase",
|
|
351
|
+
message: "Managed workspaces could not be inspected",
|
|
352
|
+
})
|
|
353
|
+
} else {
|
|
354
|
+
for (const inspection of inspected.right) {
|
|
355
|
+
for (const checkout of inspection.checkouts) {
|
|
356
|
+
if (checkout.conflicts.length > 0) {
|
|
357
|
+
blockers.push({
|
|
358
|
+
kind: "workspace-conflict",
|
|
359
|
+
target: checkout.path,
|
|
360
|
+
message: checkout.conflicts
|
|
361
|
+
.map(({ message }) => message)
|
|
362
|
+
.join("; "),
|
|
363
|
+
})
|
|
364
|
+
continue
|
|
365
|
+
}
|
|
366
|
+
if (!checkout.exists) continue
|
|
367
|
+
if (checkout.dirty !== false) {
|
|
368
|
+
blockers.push({
|
|
369
|
+
kind: "dirty-workspace",
|
|
370
|
+
target: checkout.path,
|
|
371
|
+
message: `Workspace ${checkout.path} must be clean before migration`,
|
|
372
|
+
})
|
|
373
|
+
continue
|
|
374
|
+
}
|
|
375
|
+
if (!checkout.actualCommit || !checkout.registeredPath) {
|
|
376
|
+
blockers.push({
|
|
377
|
+
kind: "workspace-conflict",
|
|
378
|
+
target: checkout.path,
|
|
379
|
+
message: `Workspace ${checkout.path} has incomplete registration metadata`,
|
|
380
|
+
})
|
|
381
|
+
continue
|
|
382
|
+
}
|
|
383
|
+
const sourceName =
|
|
384
|
+
source === "jj"
|
|
385
|
+
? ((yield* sourceBackend.listWorkspaces(
|
|
386
|
+
join(root, "repos", checkout.repo),
|
|
387
|
+
)).find((item) => item.path === checkout.registeredPath)
|
|
388
|
+
?.name ?? null)
|
|
389
|
+
: null
|
|
390
|
+
const previousBranchCommit = checkout.actualBranch
|
|
391
|
+
? yield* command(
|
|
392
|
+
fs,
|
|
393
|
+
[
|
|
394
|
+
"git",
|
|
395
|
+
"-C",
|
|
396
|
+
join(root, "repos", checkout.repo),
|
|
397
|
+
"rev-parse",
|
|
398
|
+
"--verify",
|
|
399
|
+
`${checkout.actualBranch}^{commit}`,
|
|
400
|
+
],
|
|
401
|
+
`Failed to inspect branch '${checkout.actualBranch}'`,
|
|
402
|
+
).pipe(Effect.catchAll(() => Effect.succeed(null)))
|
|
403
|
+
: null
|
|
404
|
+
workspacePlans.push({
|
|
405
|
+
taskId: inspection.owner.taskId,
|
|
406
|
+
...(inspection.owner.phaseId
|
|
407
|
+
? { phaseId: inspection.owner.phaseId }
|
|
408
|
+
: {}),
|
|
409
|
+
repo: checkout.repo,
|
|
410
|
+
kind: checkout.kind,
|
|
411
|
+
path: checkout.path,
|
|
412
|
+
head: checkout.actualCommit,
|
|
413
|
+
branch: checkout.kind === "writable" ? checkout.requestedRef : null,
|
|
414
|
+
sourceName,
|
|
415
|
+
targetName: workspaceName({
|
|
416
|
+
taskId: inspection.owner.taskId,
|
|
417
|
+
phaseId: inspection.owner.phaseId,
|
|
418
|
+
repo: checkout.repo,
|
|
419
|
+
}),
|
|
420
|
+
previousBranchCommit,
|
|
421
|
+
})
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const uniqueRepositoryPlans = [
|
|
427
|
+
...new Map(repositoryPlans.map((plan) => [plan.target, plan])).values(),
|
|
428
|
+
]
|
|
429
|
+
return {
|
|
430
|
+
state: {
|
|
431
|
+
root,
|
|
432
|
+
configured: config.vcs ?? null,
|
|
433
|
+
source,
|
|
434
|
+
target,
|
|
435
|
+
available,
|
|
436
|
+
repositories: repositoryStatus,
|
|
437
|
+
workspaceCount: workspacePlans.length,
|
|
438
|
+
blockers,
|
|
439
|
+
} satisfies MigrationState,
|
|
440
|
+
repositoryPlans: uniqueRepositoryPlans,
|
|
441
|
+
workspacePlans,
|
|
442
|
+
records,
|
|
443
|
+
config,
|
|
444
|
+
sourceBackend,
|
|
445
|
+
targetBackend: target === "jj" ? jj : git,
|
|
446
|
+
}
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
export class VcsMigrationService extends Effect.Service<VcsMigrationService>()(
|
|
450
|
+
"VcsMigrationService",
|
|
451
|
+
{
|
|
452
|
+
sync: () => ({
|
|
453
|
+
status: (startPath: string = process.cwd()) =>
|
|
454
|
+
inspectMigration(startPath).pipe(Effect.map(({ state }) => state)),
|
|
455
|
+
|
|
456
|
+
migrate: (
|
|
457
|
+
target: VcsKind,
|
|
458
|
+
startPath: string = process.cwd(),
|
|
459
|
+
options: { readonly apply?: boolean } = {},
|
|
460
|
+
) =>
|
|
461
|
+
Effect.gen(function* () {
|
|
462
|
+
const fs = yield* FileSystemService
|
|
463
|
+
const inspected = yield* inspectMigration(startPath, target)
|
|
464
|
+
const { state, repositoryPlans, workspacePlans, records, config } =
|
|
465
|
+
inspected
|
|
466
|
+
const actions = [
|
|
467
|
+
...workspacePlans.map(
|
|
468
|
+
(plan) =>
|
|
469
|
+
`replace ${state.source} workspace with ${target} workspace at ${plan.path}`,
|
|
470
|
+
),
|
|
471
|
+
...repositoryPlans.map((plan) =>
|
|
472
|
+
target === "jj"
|
|
473
|
+
? `initialize jj repository ${plan.alias}`
|
|
474
|
+
: `remove jj metadata from repository ${plan.alias}`,
|
|
475
|
+
),
|
|
476
|
+
`set workbase vcs to ${target}`,
|
|
477
|
+
]
|
|
478
|
+
if (state.source === target) {
|
|
479
|
+
const explicit = config.vcs === target
|
|
480
|
+
const sameBackendActions = explicit
|
|
481
|
+
? []
|
|
482
|
+
: [`set workbase vcs to ${target}`]
|
|
483
|
+
if (options.apply && !explicit) {
|
|
484
|
+
const configPath = join(state.root, "agency.json")
|
|
485
|
+
const content = yield* fs.readFile(configPath)
|
|
486
|
+
yield* runLifecycleTransaction({
|
|
487
|
+
root: state.root,
|
|
488
|
+
preconditions: [
|
|
489
|
+
{ path: configPath, revision: documentRevision(content) },
|
|
490
|
+
],
|
|
491
|
+
steps: [
|
|
492
|
+
documentWriteStep(state.root, [
|
|
493
|
+
{
|
|
494
|
+
path: configPath,
|
|
495
|
+
content: `${JSON.stringify({ ...config, vcs: target }, null, 2)}\n`,
|
|
496
|
+
},
|
|
497
|
+
]),
|
|
498
|
+
],
|
|
499
|
+
})
|
|
500
|
+
}
|
|
501
|
+
return {
|
|
502
|
+
...state,
|
|
503
|
+
configured: options.apply ? target : state.configured,
|
|
504
|
+
mode: options.apply ? "apply" : "dry-run",
|
|
505
|
+
actions: sameBackendActions,
|
|
506
|
+
} satisfies MigrationResult
|
|
507
|
+
}
|
|
508
|
+
if (!options.apply) {
|
|
509
|
+
return {
|
|
510
|
+
...state,
|
|
511
|
+
mode: "dry-run",
|
|
512
|
+
actions,
|
|
513
|
+
} satisfies MigrationResult
|
|
514
|
+
}
|
|
515
|
+
if (state.blockers.length > 0) {
|
|
516
|
+
return yield* new VcsMigrationError({
|
|
517
|
+
message: state.blockers.map(({ message }) => message).join("\n"),
|
|
518
|
+
blockers: state.blockers,
|
|
519
|
+
})
|
|
520
|
+
}
|
|
521
|
+
const removedSource: WorkspacePlan[] = []
|
|
522
|
+
const createdTarget: WorkspacePlan[] = []
|
|
523
|
+
const repositoryBackups: {
|
|
524
|
+
plan: RepositoryPlan
|
|
525
|
+
kind: "swap" | "metadata"
|
|
526
|
+
backup: string
|
|
527
|
+
}[] = []
|
|
528
|
+
const sourceBackend = inspected.sourceBackend
|
|
529
|
+
const targetBackend = inspected.targetBackend
|
|
530
|
+
const repositoryPath = (repo: string) =>
|
|
531
|
+
join(state.root, "repos", repo)
|
|
532
|
+
|
|
533
|
+
const removeWorkspace = async (
|
|
534
|
+
backend: VersionControlBackend,
|
|
535
|
+
plan: WorkspacePlan,
|
|
536
|
+
name: string | null,
|
|
537
|
+
) =>
|
|
538
|
+
runBackend(
|
|
539
|
+
fs,
|
|
540
|
+
backend.removeWorkspace({
|
|
541
|
+
repositoryPath: repositoryPath(plan.repo),
|
|
542
|
+
workspacePath: plan.path,
|
|
543
|
+
workspaceName: name,
|
|
544
|
+
}),
|
|
545
|
+
)
|
|
546
|
+
const createWorkspace = async (
|
|
547
|
+
backend: VersionControlBackend,
|
|
548
|
+
plan: WorkspacePlan,
|
|
549
|
+
name: string,
|
|
550
|
+
) => {
|
|
551
|
+
await fs.createDirectory(dirname(plan.path)).pipe(Effect.runPromise)
|
|
552
|
+
if (backend.kind === "git") {
|
|
553
|
+
await Effect.runPromise(
|
|
554
|
+
createGitWorkspace(fs, plan, repositoryPath(plan.repo)),
|
|
555
|
+
)
|
|
556
|
+
} else {
|
|
557
|
+
await runBackend(
|
|
558
|
+
fs,
|
|
559
|
+
backend.createWorkspace({
|
|
560
|
+
repositoryPath: repositoryPath(plan.repo),
|
|
561
|
+
workspacePath: plan.path,
|
|
562
|
+
workspaceName: name,
|
|
563
|
+
revision: plan.head,
|
|
564
|
+
...(plan.branch ? { branch: plan.branch } : {}),
|
|
565
|
+
}),
|
|
566
|
+
)
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const configPath = join(state.root, "agency.json")
|
|
571
|
+
const configContent = yield* fs.readFile(configPath)
|
|
572
|
+
const targetConfig = `${JSON.stringify({ ...config, vcs: target }, null, 2)}\n`
|
|
573
|
+
const migration = runLifecycleTransaction({
|
|
574
|
+
root: state.root,
|
|
575
|
+
preconditions: [
|
|
576
|
+
{ path: configPath, revision: documentRevision(configContent) },
|
|
577
|
+
],
|
|
578
|
+
steps: [
|
|
579
|
+
{
|
|
580
|
+
label: `remove ${state.source} workspaces`,
|
|
581
|
+
preflight: async () => {
|
|
582
|
+
for (const plan of workspacePlans) {
|
|
583
|
+
const dirty = await runBackend(
|
|
584
|
+
fs,
|
|
585
|
+
sourceBackend.workspaceDirty(plan.path),
|
|
586
|
+
)
|
|
587
|
+
const head = await runBackend(
|
|
588
|
+
fs,
|
|
589
|
+
sourceBackend.workspaceHead(plan.path),
|
|
590
|
+
)
|
|
591
|
+
if (dirty !== false || head !== plan.head)
|
|
592
|
+
throw new Error(
|
|
593
|
+
`Workspace ${plan.path} changed after migration inspection`,
|
|
594
|
+
)
|
|
595
|
+
}
|
|
596
|
+
},
|
|
597
|
+
apply: async () => {
|
|
598
|
+
try {
|
|
599
|
+
for (const plan of workspacePlans) {
|
|
600
|
+
await removeWorkspace(
|
|
601
|
+
sourceBackend,
|
|
602
|
+
plan,
|
|
603
|
+
plan.sourceName,
|
|
604
|
+
)
|
|
605
|
+
removedSource.push(plan)
|
|
606
|
+
}
|
|
607
|
+
} catch (cause) {
|
|
608
|
+
for (const plan of [...removedSource].reverse())
|
|
609
|
+
await createWorkspace(
|
|
610
|
+
sourceBackend,
|
|
611
|
+
plan,
|
|
612
|
+
plan.sourceName ?? plan.targetName,
|
|
613
|
+
)
|
|
614
|
+
removedSource.length = 0
|
|
615
|
+
throw cause
|
|
616
|
+
}
|
|
617
|
+
},
|
|
618
|
+
rollback: async () => {
|
|
619
|
+
for (const plan of [...removedSource].reverse())
|
|
620
|
+
await createWorkspace(
|
|
621
|
+
sourceBackend,
|
|
622
|
+
plan,
|
|
623
|
+
plan.sourceName ?? plan.targetName,
|
|
624
|
+
)
|
|
625
|
+
},
|
|
626
|
+
manualRecovery: `Restore ${state.source} workspaces under the workbase task directories`,
|
|
627
|
+
},
|
|
628
|
+
{
|
|
629
|
+
label: `convert repositories to ${target}`,
|
|
630
|
+
apply: async () => {
|
|
631
|
+
try {
|
|
632
|
+
for (const plan of repositoryPlans) {
|
|
633
|
+
if (target === "jj") {
|
|
634
|
+
if (plan.kind === "bare") {
|
|
635
|
+
const staging = `${plan.path}.agency-jj-staging`
|
|
636
|
+
const backup = `${plan.path}.agency-git-backup`
|
|
637
|
+
await rm(staging, { recursive: true, force: true })
|
|
638
|
+
await Effect.runPromise(
|
|
639
|
+
command(
|
|
640
|
+
fs,
|
|
641
|
+
["git", "clone", plan.path, staging],
|
|
642
|
+
`Failed to convert repository '${plan.alias}'`,
|
|
643
|
+
),
|
|
644
|
+
)
|
|
645
|
+
if (plan.remote)
|
|
646
|
+
await Effect.runPromise(
|
|
647
|
+
command(
|
|
648
|
+
fs,
|
|
649
|
+
[
|
|
650
|
+
"git",
|
|
651
|
+
"-C",
|
|
652
|
+
staging,
|
|
653
|
+
"remote",
|
|
654
|
+
"set-url",
|
|
655
|
+
"origin",
|
|
656
|
+
plan.remote,
|
|
657
|
+
],
|
|
658
|
+
`Failed to restore remote for '${plan.alias}'`,
|
|
659
|
+
),
|
|
660
|
+
)
|
|
661
|
+
await runBackend(
|
|
662
|
+
fs,
|
|
663
|
+
targetBackend.initializeRepository(staging),
|
|
664
|
+
)
|
|
665
|
+
await rename(plan.path, backup)
|
|
666
|
+
await rename(staging, plan.path)
|
|
667
|
+
repositoryBackups.push({
|
|
668
|
+
plan,
|
|
669
|
+
kind: "swap",
|
|
670
|
+
backup,
|
|
671
|
+
})
|
|
672
|
+
} else {
|
|
673
|
+
await runBackend(
|
|
674
|
+
fs,
|
|
675
|
+
targetBackend.initializeRepository(plan.target),
|
|
676
|
+
)
|
|
677
|
+
const metadata = join(plan.target, ".jj")
|
|
678
|
+
repositoryBackups.push({
|
|
679
|
+
plan,
|
|
680
|
+
kind: "metadata",
|
|
681
|
+
backup: metadata,
|
|
682
|
+
})
|
|
683
|
+
}
|
|
684
|
+
} else {
|
|
685
|
+
const metadata = join(plan.target, ".jj")
|
|
686
|
+
const backup = join(plan.target, ".agency-jj-backup")
|
|
687
|
+
await rename(metadata, backup)
|
|
688
|
+
repositoryBackups.push({
|
|
689
|
+
plan,
|
|
690
|
+
kind: "metadata",
|
|
691
|
+
backup,
|
|
692
|
+
})
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
} catch (cause) {
|
|
696
|
+
for (const backup of [...repositoryBackups].reverse()) {
|
|
697
|
+
if (target === "jj") {
|
|
698
|
+
if (backup.kind === "swap") {
|
|
699
|
+
await rm(backup.plan.path, {
|
|
700
|
+
recursive: true,
|
|
701
|
+
force: true,
|
|
702
|
+
})
|
|
703
|
+
await rename(backup.backup, backup.plan.path)
|
|
704
|
+
} else {
|
|
705
|
+
await rm(backup.backup, {
|
|
706
|
+
recursive: true,
|
|
707
|
+
force: true,
|
|
708
|
+
})
|
|
709
|
+
}
|
|
710
|
+
} else {
|
|
711
|
+
await rename(
|
|
712
|
+
backup.backup,
|
|
713
|
+
join(backup.plan.target, ".jj"),
|
|
714
|
+
)
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
repositoryBackups.length = 0
|
|
718
|
+
throw cause
|
|
719
|
+
}
|
|
720
|
+
},
|
|
721
|
+
rollback: async () => {
|
|
722
|
+
for (const backup of [...repositoryBackups].reverse()) {
|
|
723
|
+
if (target === "jj") {
|
|
724
|
+
if (backup.kind === "swap") {
|
|
725
|
+
await rm(backup.plan.path, {
|
|
726
|
+
recursive: true,
|
|
727
|
+
force: true,
|
|
728
|
+
})
|
|
729
|
+
await rename(backup.backup, backup.plan.path)
|
|
730
|
+
} else {
|
|
731
|
+
await rm(backup.backup, {
|
|
732
|
+
recursive: true,
|
|
733
|
+
force: true,
|
|
734
|
+
})
|
|
735
|
+
}
|
|
736
|
+
} else {
|
|
737
|
+
await rename(
|
|
738
|
+
backup.backup,
|
|
739
|
+
join(backup.plan.target, ".jj"),
|
|
740
|
+
)
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
},
|
|
744
|
+
finalize: async () => {
|
|
745
|
+
for (const backup of repositoryBackups) {
|
|
746
|
+
if (target === "jj" && backup.kind === "swap")
|
|
747
|
+
await rm(backup.backup, { recursive: true, force: true })
|
|
748
|
+
if (target === "git" && backup.kind === "metadata")
|
|
749
|
+
await rm(backup.backup, { recursive: true, force: true })
|
|
750
|
+
}
|
|
751
|
+
},
|
|
752
|
+
manualRecovery: `Restore repository backups under ${join(state.root, "repos")}`,
|
|
753
|
+
},
|
|
754
|
+
{
|
|
755
|
+
label: `create ${target} workspaces`,
|
|
756
|
+
apply: async () => {
|
|
757
|
+
try {
|
|
758
|
+
for (const plan of workspacePlans) {
|
|
759
|
+
await createWorkspace(
|
|
760
|
+
targetBackend,
|
|
761
|
+
plan,
|
|
762
|
+
plan.targetName,
|
|
763
|
+
)
|
|
764
|
+
createdTarget.push(plan)
|
|
765
|
+
}
|
|
766
|
+
} catch (cause) {
|
|
767
|
+
for (const plan of [...createdTarget].reverse()) {
|
|
768
|
+
await removeWorkspace(
|
|
769
|
+
targetBackend,
|
|
770
|
+
plan,
|
|
771
|
+
target === "jj" ? plan.targetName : null,
|
|
772
|
+
)
|
|
773
|
+
if (target === "git")
|
|
774
|
+
await Effect.runPromise(
|
|
775
|
+
restoreGitBranch(fs, plan, repositoryPath(plan.repo)),
|
|
776
|
+
)
|
|
777
|
+
}
|
|
778
|
+
createdTarget.length = 0
|
|
779
|
+
throw cause
|
|
780
|
+
}
|
|
781
|
+
},
|
|
782
|
+
rollback: async () => {
|
|
783
|
+
for (const plan of [...createdTarget].reverse()) {
|
|
784
|
+
await removeWorkspace(
|
|
785
|
+
targetBackend,
|
|
786
|
+
plan,
|
|
787
|
+
target === "jj" ? plan.targetName : null,
|
|
788
|
+
)
|
|
789
|
+
if (target === "git")
|
|
790
|
+
await Effect.runPromise(
|
|
791
|
+
restoreGitBranch(fs, plan, repositoryPath(plan.repo)),
|
|
792
|
+
)
|
|
793
|
+
}
|
|
794
|
+
},
|
|
795
|
+
manualRecovery: `Remove partially created ${target} workspaces`,
|
|
796
|
+
},
|
|
797
|
+
documentWriteStep(state.root, [
|
|
798
|
+
{ path: configPath, content: targetConfig },
|
|
799
|
+
]),
|
|
800
|
+
],
|
|
801
|
+
})
|
|
802
|
+
const lockTargets: WorktreeLockTarget[] = records.map((record) => ({
|
|
803
|
+
taskId: record.taskId,
|
|
804
|
+
...(record.phaseId ? { phaseId: record.phaseId } : {}),
|
|
805
|
+
}))
|
|
806
|
+
yield* withWorktreeLocks(state.root, lockTargets, migration)
|
|
807
|
+
const current = yield* inspectMigration(state.root)
|
|
808
|
+
return {
|
|
809
|
+
...current.state,
|
|
810
|
+
mode: "apply",
|
|
811
|
+
actions,
|
|
812
|
+
} satisfies MigrationResult
|
|
813
|
+
}),
|
|
814
|
+
}),
|
|
815
|
+
},
|
|
816
|
+
) {}
|