@markjaquith/agency 2.26.0 → 2.28.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 +10 -0
- package/cli.ts +41 -0
- package/package.json +1 -1
- package/src/cli-parser.test.ts +31 -0
- package/src/cli-parser.ts +63 -0
- package/src/cli.test.ts +43 -0
- package/src/commands/doctor.test.ts +156 -0
- package/src/commands/doctor.ts +47 -0
- package/src/commands/read-only.test.ts +2 -0
- package/src/commands/worktree.ts +138 -0
- package/src/services/DoctorService.ts +419 -0
- package/src/services/FileSystemService.ts +26 -0
- package/src/services/WorktreeService.test.ts +453 -2
- package/src/services/WorktreeService.ts +1510 -655
- package/src/test-utils.ts +2 -0
|
@@ -15,6 +15,7 @@ import { withWorktreeLocks } from "./WorktreeLock"
|
|
|
15
15
|
|
|
16
16
|
class WorktreeError extends Data.TaggedError("WorktreeError")<{
|
|
17
17
|
readonly message: string
|
|
18
|
+
readonly conflicts?: readonly WorktreeConflict[]
|
|
18
19
|
readonly completed?: readonly string[]
|
|
19
20
|
readonly rolledBack?: readonly string[]
|
|
20
21
|
readonly manualRecovery?: readonly string[]
|
|
@@ -56,6 +57,63 @@ interface GitWorktree {
|
|
|
56
57
|
readonly branch?: string
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
interface WorktreeOwner {
|
|
61
|
+
readonly kind: "task" | "phase"
|
|
62
|
+
readonly taskId: string
|
|
63
|
+
readonly phaseId?: string
|
|
64
|
+
readonly documentPath: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface WorktreeConflict {
|
|
68
|
+
readonly kind:
|
|
69
|
+
| "missing-repository"
|
|
70
|
+
| "inspection-failed"
|
|
71
|
+
| "duplicate-owner"
|
|
72
|
+
| "branch-conflict"
|
|
73
|
+
| "stale-registration"
|
|
74
|
+
| "unregistered-checkout"
|
|
75
|
+
| "wrong-branch"
|
|
76
|
+
| "attached-reference"
|
|
77
|
+
| "reference-drift"
|
|
78
|
+
readonly message: string
|
|
79
|
+
readonly registeredPath: string | null
|
|
80
|
+
readonly branch: string | null
|
|
81
|
+
readonly commit: string | null
|
|
82
|
+
readonly dirty: boolean | null
|
|
83
|
+
readonly owners: readonly WorktreeOwner[]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface WorktreeCheckoutInspection {
|
|
87
|
+
readonly repo: string
|
|
88
|
+
readonly kind: "writable" | "reference"
|
|
89
|
+
readonly path: string
|
|
90
|
+
readonly registeredPath: string | null
|
|
91
|
+
readonly requestedRef: string
|
|
92
|
+
readonly expectedCommit: string | null
|
|
93
|
+
readonly actualCommit: string | null
|
|
94
|
+
readonly actualBranch: string | null
|
|
95
|
+
readonly exists: boolean
|
|
96
|
+
readonly registered: boolean
|
|
97
|
+
readonly dirty: boolean | null
|
|
98
|
+
readonly owners: readonly WorktreeOwner[]
|
|
99
|
+
readonly conflicts: readonly WorktreeConflict[]
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface WorktreeInspection {
|
|
103
|
+
readonly root: string
|
|
104
|
+
readonly owner: WorktreeOwner
|
|
105
|
+
readonly codePath: string
|
|
106
|
+
readonly checkouts: readonly WorktreeCheckoutInspection[]
|
|
107
|
+
readonly conflicts: readonly WorktreeConflict[]
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface WorktreeLifecycleResult {
|
|
111
|
+
readonly operation: "remove" | "rebuild" | "repair"
|
|
112
|
+
readonly dryRun: boolean
|
|
113
|
+
readonly inspection: WorktreeInspection
|
|
114
|
+
readonly actions: readonly string[]
|
|
115
|
+
}
|
|
116
|
+
|
|
59
117
|
export interface WorktreeRemovalSnapshot {
|
|
60
118
|
readonly path: string
|
|
61
119
|
readonly repositoryPath: string
|
|
@@ -98,6 +156,7 @@ const originRef = (ref: string) =>
|
|
|
98
156
|
|
|
99
157
|
interface MaterializeOptions extends BaseCommandOptions {
|
|
100
158
|
readonly force?: boolean
|
|
159
|
+
readonly lockHeld?: boolean
|
|
101
160
|
}
|
|
102
161
|
|
|
103
162
|
interface RemoveOptions extends BaseCommandOptions {
|
|
@@ -105,10 +164,425 @@ interface RemoveOptions extends BaseCommandOptions {
|
|
|
105
164
|
readonly lockHeld?: boolean
|
|
106
165
|
}
|
|
107
166
|
|
|
167
|
+
interface LifecycleOptions extends BaseCommandOptions {
|
|
168
|
+
readonly lockHeld?: boolean
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const inspectExecution = (
|
|
172
|
+
taskId: string,
|
|
173
|
+
phaseId: string | undefined,
|
|
174
|
+
startPath: string,
|
|
175
|
+
) =>
|
|
176
|
+
Effect.gen(function* () {
|
|
177
|
+
const fs = yield* FileSystemService
|
|
178
|
+
const workbase = yield* WorkbaseService
|
|
179
|
+
const tasks = yield* TaskService
|
|
180
|
+
const phases = yield* PhaseService
|
|
181
|
+
const root = yield* workbase.discover(startPath)
|
|
182
|
+
const task = yield* tasks.show(taskId, root)
|
|
183
|
+
|
|
184
|
+
let execution: {
|
|
185
|
+
repo: string
|
|
186
|
+
repos?: readonly RepositoryReference[]
|
|
187
|
+
branch: string
|
|
188
|
+
base: string
|
|
189
|
+
}
|
|
190
|
+
let owner: WorktreeOwner
|
|
191
|
+
let codePath: string
|
|
192
|
+
if ("phases" in task.data) {
|
|
193
|
+
if (!phaseId) {
|
|
194
|
+
return yield* new WorktreeError({
|
|
195
|
+
message: `Task '${taskId}' has multiple phases; phase ID is required`,
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
const phase = yield* phases.show(taskId, phaseId, root)
|
|
199
|
+
execution = phase.data
|
|
200
|
+
owner = {
|
|
201
|
+
kind: "phase",
|
|
202
|
+
taskId,
|
|
203
|
+
phaseId,
|
|
204
|
+
documentPath: phase.path,
|
|
205
|
+
}
|
|
206
|
+
codePath = join(dirname(phase.path), "code")
|
|
207
|
+
} else {
|
|
208
|
+
if (phaseId) {
|
|
209
|
+
return yield* new WorktreeError({
|
|
210
|
+
message: `Task '${taskId}' is single-phase and does not accept a phase ID`,
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
execution = task.data
|
|
214
|
+
owner = { kind: "task", taskId, documentPath: task.path }
|
|
215
|
+
codePath = join(dirname(task.path), "code")
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const ownership = new Map<string, WorktreeOwner[]>()
|
|
219
|
+
for (const taskRecord of yield* tasks.list(root)) {
|
|
220
|
+
if ("phases" in taskRecord.data) {
|
|
221
|
+
for (const phaseRecord of yield* phases.list(taskRecord.id, root)) {
|
|
222
|
+
const key = `${phaseRecord.data.repo}:${phaseRecord.data.branch}`
|
|
223
|
+
const owners = ownership.get(key) ?? []
|
|
224
|
+
owners.push({
|
|
225
|
+
kind: "phase",
|
|
226
|
+
taskId: taskRecord.id,
|
|
227
|
+
phaseId: phaseRecord.id,
|
|
228
|
+
documentPath: phaseRecord.path,
|
|
229
|
+
})
|
|
230
|
+
ownership.set(key, owners)
|
|
231
|
+
}
|
|
232
|
+
} else {
|
|
233
|
+
const key = `${taskRecord.data.repo}:${taskRecord.data.branch}`
|
|
234
|
+
const owners = ownership.get(key) ?? []
|
|
235
|
+
owners.push({
|
|
236
|
+
kind: "task",
|
|
237
|
+
taskId: taskRecord.id,
|
|
238
|
+
documentPath: taskRecord.path,
|
|
239
|
+
})
|
|
240
|
+
ownership.set(key, owners)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const declared: readonly (
|
|
245
|
+
| { readonly repo: string; readonly branch: string }
|
|
246
|
+
| RepositoryReference
|
|
247
|
+
)[] = [
|
|
248
|
+
{ repo: execution.repo, branch: execution.branch },
|
|
249
|
+
...(execution.repos ?? []),
|
|
250
|
+
]
|
|
251
|
+
const checkouts: WorktreeCheckoutInspection[] = []
|
|
252
|
+
for (const checkout of declared) {
|
|
253
|
+
const repositoryPath = join(root, "repos", checkout.repo)
|
|
254
|
+
const checkoutPath = join(codePath, checkout.repo)
|
|
255
|
+
const kind = "branch" in checkout ? "writable" : "reference"
|
|
256
|
+
const requestedRef = "branch" in checkout ? checkout.branch : checkout.ref
|
|
257
|
+
const owners =
|
|
258
|
+
"branch" in checkout
|
|
259
|
+
? (ownership.get(`${checkout.repo}:${checkout.branch}`) ?? [])
|
|
260
|
+
: [owner]
|
|
261
|
+
const conflicts: WorktreeConflict[] = []
|
|
262
|
+
const conflict = (
|
|
263
|
+
kind: WorktreeConflict["kind"],
|
|
264
|
+
message: string,
|
|
265
|
+
details: Partial<WorktreeConflict> = {},
|
|
266
|
+
) =>
|
|
267
|
+
conflicts.push({
|
|
268
|
+
kind,
|
|
269
|
+
message,
|
|
270
|
+
registeredPath: details.registeredPath ?? null,
|
|
271
|
+
branch: details.branch ?? null,
|
|
272
|
+
commit: details.commit ?? null,
|
|
273
|
+
dirty: details.dirty ?? null,
|
|
274
|
+
owners,
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
if (!(yield* fs.exists(repositoryPath))) {
|
|
278
|
+
conflict(
|
|
279
|
+
"missing-repository",
|
|
280
|
+
`Repository alias '${checkout.repo}' does not exist`,
|
|
281
|
+
)
|
|
282
|
+
checkouts.push({
|
|
283
|
+
repo: checkout.repo,
|
|
284
|
+
kind,
|
|
285
|
+
path: checkoutPath,
|
|
286
|
+
registeredPath: null,
|
|
287
|
+
requestedRef,
|
|
288
|
+
expectedCommit: null,
|
|
289
|
+
actualCommit: null,
|
|
290
|
+
actualBranch: null,
|
|
291
|
+
exists: false,
|
|
292
|
+
registered: false,
|
|
293
|
+
dirty: null,
|
|
294
|
+
owners,
|
|
295
|
+
conflicts,
|
|
296
|
+
})
|
|
297
|
+
continue
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const listed = yield* fs.runCommand(
|
|
301
|
+
["git", "-C", repositoryPath, "worktree", "list", "--porcelain", "-z"],
|
|
302
|
+
{ captureOutput: true },
|
|
303
|
+
)
|
|
304
|
+
if (listed.exitCode !== 0) {
|
|
305
|
+
conflict(
|
|
306
|
+
"inspection-failed",
|
|
307
|
+
`Failed to inspect worktrees for '${checkout.repo}': ${listed.stderr}`,
|
|
308
|
+
)
|
|
309
|
+
checkouts.push({
|
|
310
|
+
repo: checkout.repo,
|
|
311
|
+
kind,
|
|
312
|
+
path: checkoutPath,
|
|
313
|
+
registeredPath: null,
|
|
314
|
+
requestedRef,
|
|
315
|
+
expectedCommit: null,
|
|
316
|
+
actualCommit: null,
|
|
317
|
+
actualBranch: null,
|
|
318
|
+
exists: yield* fs.isDirectory(checkoutPath),
|
|
319
|
+
registered: false,
|
|
320
|
+
dirty: null,
|
|
321
|
+
owners,
|
|
322
|
+
conflicts,
|
|
323
|
+
})
|
|
324
|
+
continue
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const exists = yield* fs.isDirectory(checkoutPath)
|
|
328
|
+
const expectedPath = exists
|
|
329
|
+
? yield* fs.realPath(checkoutPath)
|
|
330
|
+
: join(
|
|
331
|
+
yield* fs.realPath(dirname(codePath)),
|
|
332
|
+
basename(codePath),
|
|
333
|
+
checkout.repo,
|
|
334
|
+
)
|
|
335
|
+
const registered: GitWorktree[] = []
|
|
336
|
+
for (const item of parseWorktreeList(listed.stdout)) {
|
|
337
|
+
registered.push({
|
|
338
|
+
...item,
|
|
339
|
+
path: (yield* fs.exists(item.path))
|
|
340
|
+
? yield* fs.realPath(item.path)
|
|
341
|
+
: resolve(item.path),
|
|
342
|
+
})
|
|
343
|
+
}
|
|
344
|
+
const atPath = registered.find((item) => item.path === expectedPath)
|
|
345
|
+
const branchRef =
|
|
346
|
+
"branch" in checkout ? `refs/heads/${checkout.branch}` : null
|
|
347
|
+
const branchElsewhere = branchRef
|
|
348
|
+
? registered.find(
|
|
349
|
+
(item) => item.branch === branchRef && item.path !== expectedPath,
|
|
350
|
+
)
|
|
351
|
+
: undefined
|
|
352
|
+
let direct: GitWorktree | undefined
|
|
353
|
+
if (exists && !atPath) {
|
|
354
|
+
const directHead = yield* fs.runCommand(
|
|
355
|
+
["git", "-C", checkoutPath, "rev-parse", "HEAD"],
|
|
356
|
+
{ captureOutput: true },
|
|
357
|
+
)
|
|
358
|
+
const directBranch = yield* fs.runCommand(
|
|
359
|
+
[
|
|
360
|
+
"git",
|
|
361
|
+
"-C",
|
|
362
|
+
checkoutPath,
|
|
363
|
+
"symbolic-ref",
|
|
364
|
+
"--quiet",
|
|
365
|
+
"--short",
|
|
366
|
+
"HEAD",
|
|
367
|
+
],
|
|
368
|
+
{ captureOutput: true },
|
|
369
|
+
)
|
|
370
|
+
if (directHead.exitCode === 0) {
|
|
371
|
+
direct = {
|
|
372
|
+
path: expectedPath,
|
|
373
|
+
head: directHead.stdout.trim(),
|
|
374
|
+
...(directBranch.exitCode === 0
|
|
375
|
+
? { branch: `refs/heads/${directBranch.stdout.trim()}` }
|
|
376
|
+
: {}),
|
|
377
|
+
}
|
|
378
|
+
} else {
|
|
379
|
+
conflict(
|
|
380
|
+
"inspection-failed",
|
|
381
|
+
`Cannot inspect existing checkout ${checkoutPath}`,
|
|
382
|
+
)
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
const branchElsewhereExists = branchElsewhere
|
|
386
|
+
? yield* fs.isDirectory(branchElsewhere.path)
|
|
387
|
+
: false
|
|
388
|
+
const movedRegistration =
|
|
389
|
+
branchElsewhere !== undefined &&
|
|
390
|
+
!branchElsewhereExists &&
|
|
391
|
+
direct?.branch === branchRef
|
|
392
|
+
const actual = atPath ?? direct ?? branchElsewhere
|
|
393
|
+
const actualExists = actual ? yield* fs.isDirectory(actual.path) : false
|
|
394
|
+
const status =
|
|
395
|
+
actual && actualExists
|
|
396
|
+
? yield* fs.runCommand(
|
|
397
|
+
["git", "-C", actual.path, "status", "--porcelain"],
|
|
398
|
+
{ captureOutput: true },
|
|
399
|
+
)
|
|
400
|
+
: null
|
|
401
|
+
const dirty = status
|
|
402
|
+
? status.exitCode === 0
|
|
403
|
+
? status.stdout.length > 0
|
|
404
|
+
: null
|
|
405
|
+
: null
|
|
406
|
+
const expectedRef = "branch" in checkout ? checkout.branch : checkout.ref
|
|
407
|
+
const expected = yield* fs.runCommand(
|
|
408
|
+
[
|
|
409
|
+
"git",
|
|
410
|
+
"-C",
|
|
411
|
+
repositoryPath,
|
|
412
|
+
"rev-parse",
|
|
413
|
+
"--verify",
|
|
414
|
+
`${expectedRef}^{commit}`,
|
|
415
|
+
],
|
|
416
|
+
{ captureOutput: true },
|
|
417
|
+
)
|
|
418
|
+
const expectedCommit =
|
|
419
|
+
expected.exitCode === 0 ? expected.stdout.trim() : null
|
|
420
|
+
const actualBranch = actual?.branch?.replace(/^refs\/heads\//, "") ?? null
|
|
421
|
+
|
|
422
|
+
if (owners.length > 1) {
|
|
423
|
+
conflict(
|
|
424
|
+
"duplicate-owner",
|
|
425
|
+
`Branch '${requestedRef}' for repository '${checkout.repo}' has multiple Agency owners`,
|
|
426
|
+
{
|
|
427
|
+
registeredPath: actual?.path,
|
|
428
|
+
branch: actualBranch,
|
|
429
|
+
commit: actual?.head,
|
|
430
|
+
dirty,
|
|
431
|
+
},
|
|
432
|
+
)
|
|
433
|
+
}
|
|
434
|
+
if (branchElsewhere && !movedRegistration) {
|
|
435
|
+
conflict(
|
|
436
|
+
"branch-conflict",
|
|
437
|
+
`Branch '${requestedRef}' for repository '${checkout.repo}' is already checked out at ${branchElsewhere.path}`,
|
|
438
|
+
{
|
|
439
|
+
registeredPath: branchElsewhere.path,
|
|
440
|
+
branch: actualBranch,
|
|
441
|
+
commit: branchElsewhere.head,
|
|
442
|
+
dirty,
|
|
443
|
+
},
|
|
444
|
+
)
|
|
445
|
+
}
|
|
446
|
+
if (atPath && !exists) {
|
|
447
|
+
conflict(
|
|
448
|
+
"stale-registration",
|
|
449
|
+
`Worktree registry contains a missing checkout at ${checkoutPath}`,
|
|
450
|
+
{
|
|
451
|
+
registeredPath: atPath.path,
|
|
452
|
+
branch: actualBranch,
|
|
453
|
+
commit: atPath.head,
|
|
454
|
+
},
|
|
455
|
+
)
|
|
456
|
+
}
|
|
457
|
+
if (branchElsewhere && movedRegistration) {
|
|
458
|
+
conflict(
|
|
459
|
+
"stale-registration",
|
|
460
|
+
`Worktree registry points to the old checkout path ${branchElsewhere.path}`,
|
|
461
|
+
{
|
|
462
|
+
registeredPath: branchElsewhere.path,
|
|
463
|
+
branch: actualBranch,
|
|
464
|
+
commit: branchElsewhere.head,
|
|
465
|
+
dirty,
|
|
466
|
+
},
|
|
467
|
+
)
|
|
468
|
+
}
|
|
469
|
+
if (exists && !atPath) {
|
|
470
|
+
conflict(
|
|
471
|
+
"unregistered-checkout",
|
|
472
|
+
`Existing checkout ${checkoutPath} is not registered as a Git worktree`,
|
|
473
|
+
{
|
|
474
|
+
branch: direct?.branch?.replace(/^refs\/heads\//, ""),
|
|
475
|
+
commit: direct?.head,
|
|
476
|
+
dirty,
|
|
477
|
+
},
|
|
478
|
+
)
|
|
479
|
+
}
|
|
480
|
+
const checkoutAtPath = atPath ?? direct
|
|
481
|
+
if (
|
|
482
|
+
"branch" in checkout &&
|
|
483
|
+
checkoutAtPath &&
|
|
484
|
+
checkoutAtPath.branch !== branchRef
|
|
485
|
+
) {
|
|
486
|
+
conflict(
|
|
487
|
+
"wrong-branch",
|
|
488
|
+
`Existing checkout ${checkoutPath} is on '${actualBranch ?? "detached HEAD"}', not branch '${checkout.branch}'`,
|
|
489
|
+
{
|
|
490
|
+
registeredPath: atPath?.path,
|
|
491
|
+
branch: actualBranch,
|
|
492
|
+
commit: checkoutAtPath.head,
|
|
493
|
+
dirty,
|
|
494
|
+
},
|
|
495
|
+
)
|
|
496
|
+
}
|
|
497
|
+
if ("ref" in checkout && checkoutAtPath?.branch) {
|
|
498
|
+
conflict(
|
|
499
|
+
"attached-reference",
|
|
500
|
+
`Reference checkout ${checkoutPath} is attached to branch '${actualBranch}'`,
|
|
501
|
+
{
|
|
502
|
+
registeredPath: atPath?.path,
|
|
503
|
+
branch: actualBranch,
|
|
504
|
+
commit: checkoutAtPath.head,
|
|
505
|
+
dirty,
|
|
506
|
+
},
|
|
507
|
+
)
|
|
508
|
+
}
|
|
509
|
+
if (
|
|
510
|
+
"ref" in checkout &&
|
|
511
|
+
checkoutAtPath?.head &&
|
|
512
|
+
expectedCommit &&
|
|
513
|
+
checkoutAtPath.head !== expectedCommit
|
|
514
|
+
) {
|
|
515
|
+
conflict(
|
|
516
|
+
"reference-drift",
|
|
517
|
+
`Reference checkout ${checkoutPath} is at ${checkoutAtPath.head}, not ${expectedCommit}`,
|
|
518
|
+
{
|
|
519
|
+
registeredPath: atPath?.path,
|
|
520
|
+
branch: actualBranch,
|
|
521
|
+
commit: checkoutAtPath.head,
|
|
522
|
+
dirty,
|
|
523
|
+
},
|
|
524
|
+
)
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
checkouts.push({
|
|
528
|
+
repo: checkout.repo,
|
|
529
|
+
kind,
|
|
530
|
+
path: checkoutPath,
|
|
531
|
+
registeredPath: atPath?.path ?? branchElsewhere?.path ?? null,
|
|
532
|
+
requestedRef,
|
|
533
|
+
expectedCommit,
|
|
534
|
+
actualCommit: actual?.head ?? null,
|
|
535
|
+
actualBranch,
|
|
536
|
+
exists,
|
|
537
|
+
registered: atPath !== undefined,
|
|
538
|
+
dirty,
|
|
539
|
+
owners,
|
|
540
|
+
conflicts,
|
|
541
|
+
})
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
return {
|
|
545
|
+
root,
|
|
546
|
+
owner,
|
|
547
|
+
codePath,
|
|
548
|
+
checkouts,
|
|
549
|
+
conflicts: checkouts.flatMap((checkout) => checkout.conflicts),
|
|
550
|
+
} satisfies WorktreeInspection
|
|
551
|
+
})
|
|
552
|
+
|
|
108
553
|
export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
109
554
|
"WorktreeService",
|
|
110
555
|
{
|
|
111
556
|
sync: () => ({
|
|
557
|
+
list: (startPath: string = process.cwd()) =>
|
|
558
|
+
Effect.gen(function* () {
|
|
559
|
+
const workbase = yield* WorkbaseService
|
|
560
|
+
const tasks = yield* TaskService
|
|
561
|
+
const phases = yield* PhaseService
|
|
562
|
+
const root = yield* workbase.discover(startPath)
|
|
563
|
+
const inspections: WorktreeInspection[] = []
|
|
564
|
+
for (const task of yield* tasks.list(root)) {
|
|
565
|
+
if ("phases" in task.data) {
|
|
566
|
+
for (const phase of yield* phases.list(task.id, root)) {
|
|
567
|
+
inspections.push(
|
|
568
|
+
yield* inspectExecution(task.id, phase.id, root),
|
|
569
|
+
)
|
|
570
|
+
}
|
|
571
|
+
} else {
|
|
572
|
+
inspections.push(
|
|
573
|
+
yield* inspectExecution(task.id, undefined, root),
|
|
574
|
+
)
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
return inspections
|
|
578
|
+
}),
|
|
579
|
+
|
|
580
|
+
inspect: (
|
|
581
|
+
taskId: string,
|
|
582
|
+
phaseId?: string,
|
|
583
|
+
startPath: string = process.cwd(),
|
|
584
|
+
) => inspectExecution(taskId, phaseId, startPath),
|
|
585
|
+
|
|
112
586
|
materialize: (
|
|
113
587
|
taskId: string,
|
|
114
588
|
phaseId?: string,
|
|
@@ -124,60 +598,264 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
124
598
|
const forwardCommandOutput =
|
|
125
599
|
options.verbose === true && !options.silent && !options.json
|
|
126
600
|
const { root, config } = yield* workbase.loadConfig(startPath)
|
|
127
|
-
|
|
128
|
-
root
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
601
|
+
const materialization = Effect.gen(function* () {
|
|
602
|
+
const report = yield* workbase.validate(root)
|
|
603
|
+
const validationIssue = report.issues[0]
|
|
604
|
+
if (validationIssue && !options.force) {
|
|
605
|
+
return yield* new WorktreeError({
|
|
606
|
+
message: `${validationIssue.path}: ${validationIssue.message}`,
|
|
607
|
+
})
|
|
608
|
+
}
|
|
609
|
+
const task = yield* tasks.show(taskId, root)
|
|
610
|
+
|
|
611
|
+
let execution: {
|
|
612
|
+
repo: string
|
|
613
|
+
repos?: readonly RepositoryReference[]
|
|
614
|
+
branch: string
|
|
615
|
+
base: string
|
|
616
|
+
}
|
|
617
|
+
let phasePath: string | null = null
|
|
618
|
+
let codePath: string
|
|
619
|
+
if ("phases" in task.data) {
|
|
620
|
+
if (!phaseId) {
|
|
621
|
+
return yield* new WorktreeError({
|
|
622
|
+
message: `Task '${taskId}' has multiple phases; phase ID is required`,
|
|
623
|
+
})
|
|
624
|
+
}
|
|
625
|
+
const phase = yield* phases.show(taskId, phaseId, root)
|
|
626
|
+
execution = phase.data
|
|
627
|
+
phasePath = phase.path
|
|
628
|
+
codePath = join(dirname(phase.path), "code")
|
|
629
|
+
} else {
|
|
630
|
+
if (phaseId) {
|
|
134
631
|
return yield* new WorktreeError({
|
|
135
|
-
message:
|
|
632
|
+
message: `Task '${taskId}' is single-phase and does not accept a phase ID`,
|
|
136
633
|
})
|
|
137
634
|
}
|
|
138
|
-
|
|
635
|
+
execution = task.data
|
|
636
|
+
codePath = join(dirname(task.path), "code")
|
|
637
|
+
}
|
|
139
638
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
639
|
+
const requestedCheckouts: readonly (
|
|
640
|
+
| { readonly repo: string; readonly branch: string }
|
|
641
|
+
| RepositoryReference
|
|
642
|
+
)[] = [
|
|
643
|
+
{ repo: execution.repo, branch: execution.branch },
|
|
644
|
+
...(execution.repos ?? []),
|
|
645
|
+
]
|
|
646
|
+
const canonicalCodePath = (yield* fs.exists(codePath))
|
|
647
|
+
? yield* fs.realPath(codePath)
|
|
648
|
+
: resolve(codePath)
|
|
649
|
+
const preflightCommits = new Map<string, string>()
|
|
650
|
+
const preexistingBranches = new Set<string>()
|
|
651
|
+
for (const checkout of requestedCheckouts) {
|
|
652
|
+
const alias = checkout.repo
|
|
653
|
+
const repositoryPath = join(root, "repos", alias)
|
|
654
|
+
const checkoutPath = join(codePath, alias)
|
|
655
|
+
if (!(yield* fs.exists(repositoryPath))) {
|
|
656
|
+
return yield* new WorktreeError({
|
|
657
|
+
message: `Repository alias '${alias}' does not exist`,
|
|
658
|
+
})
|
|
659
|
+
}
|
|
660
|
+
const listed = yield* fs.runCommand(
|
|
661
|
+
[
|
|
662
|
+
"git",
|
|
663
|
+
"-C",
|
|
664
|
+
repositoryPath,
|
|
665
|
+
"worktree",
|
|
666
|
+
"list",
|
|
667
|
+
"--porcelain",
|
|
668
|
+
"-z",
|
|
669
|
+
],
|
|
670
|
+
{ captureOutput: true },
|
|
671
|
+
)
|
|
672
|
+
if (listed.exitCode !== 0) {
|
|
673
|
+
return yield* new WorktreeError({
|
|
674
|
+
message: `Failed to inspect worktrees for '${alias}': ${listed.stderr}`,
|
|
675
|
+
})
|
|
145
676
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
677
|
+
const canonicalCheckoutPath = join(canonicalCodePath, alias)
|
|
678
|
+
const worktrees: GitWorktree[] = []
|
|
679
|
+
for (const worktree of parseWorktreeList(listed.stdout)) {
|
|
680
|
+
worktrees.push({
|
|
681
|
+
...worktree,
|
|
682
|
+
path: (yield* fs.exists(worktree.path))
|
|
683
|
+
? yield* fs.realPath(worktree.path)
|
|
684
|
+
: resolve(worktree.path),
|
|
685
|
+
})
|
|
686
|
+
}
|
|
687
|
+
const registeredAtPath = worktrees.find(
|
|
688
|
+
(worktree) => worktree.path === canonicalCheckoutPath,
|
|
689
|
+
)
|
|
690
|
+
const checkoutExists = yield* fs.isDirectory(checkoutPath)
|
|
691
|
+
if ("branch" in checkout) {
|
|
692
|
+
const branchRef = `refs/heads/${checkout.branch}`
|
|
693
|
+
const branchExists = yield* fs.runCommand(
|
|
694
|
+
[
|
|
695
|
+
"git",
|
|
696
|
+
"-C",
|
|
697
|
+
repositoryPath,
|
|
698
|
+
"show-ref",
|
|
699
|
+
"--verify",
|
|
700
|
+
branchRef,
|
|
701
|
+
],
|
|
702
|
+
{ captureOutput: true },
|
|
703
|
+
)
|
|
704
|
+
if (branchExists.exitCode === 0)
|
|
705
|
+
preexistingBranches.add(`${alias}:${checkout.branch}`)
|
|
706
|
+
const branchWorktree = worktrees.find(
|
|
707
|
+
(worktree) => worktree.branch === branchRef,
|
|
708
|
+
)
|
|
709
|
+
if (
|
|
710
|
+
branchWorktree &&
|
|
711
|
+
branchWorktree.path !== canonicalCheckoutPath
|
|
712
|
+
) {
|
|
150
713
|
return yield* new WorktreeError({
|
|
151
|
-
message: `
|
|
714
|
+
message: `Branch '${checkout.branch}' for repository '${alias}' is already checked out at ${branchWorktree.path}`,
|
|
152
715
|
})
|
|
153
716
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
717
|
+
if (checkoutExists && registeredAtPath?.branch !== branchRef) {
|
|
718
|
+
return yield* new WorktreeError({
|
|
719
|
+
message: `Existing checkout ${checkoutPath} is not registered to branch '${checkout.branch}'`,
|
|
720
|
+
})
|
|
721
|
+
}
|
|
722
|
+
if (!checkoutExists && registeredAtPath) {
|
|
723
|
+
return yield* new WorktreeError({
|
|
724
|
+
message: `Worktree registry contains a missing checkout at ${checkoutPath}`,
|
|
725
|
+
})
|
|
726
|
+
}
|
|
727
|
+
if (!checkoutExists && branchExists.exitCode !== 0) {
|
|
728
|
+
const localBase = yield* fs.runCommand(
|
|
729
|
+
[
|
|
730
|
+
"git",
|
|
731
|
+
"-C",
|
|
732
|
+
repositoryPath,
|
|
733
|
+
"rev-parse",
|
|
734
|
+
"--verify",
|
|
735
|
+
`${execution.base}^{commit}`,
|
|
736
|
+
],
|
|
737
|
+
{ captureOutput: true },
|
|
738
|
+
)
|
|
739
|
+
if (localBase.exitCode !== 0) {
|
|
740
|
+
const remoteBase = yield* fs.runCommand(
|
|
741
|
+
[
|
|
742
|
+
"git",
|
|
743
|
+
"-C",
|
|
744
|
+
repositoryPath,
|
|
745
|
+
"ls-remote",
|
|
746
|
+
"origin",
|
|
747
|
+
originRef(execution.base),
|
|
748
|
+
],
|
|
749
|
+
{ captureOutput: true },
|
|
750
|
+
)
|
|
751
|
+
if (
|
|
752
|
+
remoteBase.exitCode !== 0 ||
|
|
753
|
+
!remoteBase.stdout.trim()
|
|
754
|
+
) {
|
|
755
|
+
return yield* new WorktreeError({
|
|
756
|
+
message: `Base '${execution.base}' for repository '${alias}' does not resolve to a commit`,
|
|
757
|
+
})
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
if (config.worktreeCreateCommand) {
|
|
762
|
+
try {
|
|
763
|
+
expandWorktreeCreateCommand(config.worktreeCreateCommand, {
|
|
764
|
+
repo: repositoryPath,
|
|
765
|
+
worktree: checkoutPath,
|
|
766
|
+
branch: checkout.branch,
|
|
767
|
+
base: execution.base,
|
|
768
|
+
})
|
|
769
|
+
} catch (cause) {
|
|
770
|
+
return yield* new WorktreeError({
|
|
771
|
+
message:
|
|
772
|
+
cause instanceof Error
|
|
773
|
+
? cause.message
|
|
774
|
+
: "Invalid worktreeCreateCommand",
|
|
775
|
+
})
|
|
776
|
+
}
|
|
777
|
+
}
|
|
158
778
|
} else {
|
|
159
|
-
if (
|
|
779
|
+
if (checkoutExists && !registeredAtPath) {
|
|
780
|
+
return yield* new WorktreeError({
|
|
781
|
+
message: `Existing checkout ${checkoutPath} is not registered as a Git worktree`,
|
|
782
|
+
})
|
|
783
|
+
}
|
|
784
|
+
if (registeredAtPath?.branch) {
|
|
785
|
+
return yield* new WorktreeError({
|
|
786
|
+
message: `Reference checkout ${checkoutPath} is attached to branch '${registeredAtPath.branch.replace(/^refs\/heads\//, "")}'`,
|
|
787
|
+
})
|
|
788
|
+
}
|
|
789
|
+
if (!checkoutExists && registeredAtPath) {
|
|
790
|
+
return yield* new WorktreeError({
|
|
791
|
+
message: `Worktree registry contains a missing checkout at ${checkoutPath}`,
|
|
792
|
+
})
|
|
793
|
+
}
|
|
794
|
+
let commit: string | undefined
|
|
795
|
+
if (!isCommitId(checkout.ref)) {
|
|
796
|
+
const remote = yield* fs.runCommand(
|
|
797
|
+
[
|
|
798
|
+
"git",
|
|
799
|
+
"-C",
|
|
800
|
+
repositoryPath,
|
|
801
|
+
"ls-remote",
|
|
802
|
+
"origin",
|
|
803
|
+
originRef(checkout.ref),
|
|
804
|
+
],
|
|
805
|
+
{ captureOutput: true },
|
|
806
|
+
)
|
|
807
|
+
if (remote.exitCode === 0 && remote.stdout.trim())
|
|
808
|
+
commit = remote.stdout.trim().split(/\s+/)[0]
|
|
809
|
+
}
|
|
810
|
+
if (!commit) {
|
|
811
|
+
const local = yield* fs.runCommand(
|
|
812
|
+
[
|
|
813
|
+
"git",
|
|
814
|
+
"-C",
|
|
815
|
+
repositoryPath,
|
|
816
|
+
"rev-parse",
|
|
817
|
+
"--verify",
|
|
818
|
+
`${checkout.ref}^{commit}`,
|
|
819
|
+
],
|
|
820
|
+
{ captureOutput: true },
|
|
821
|
+
)
|
|
822
|
+
if (local.exitCode === 0) commit = local.stdout.trim()
|
|
823
|
+
}
|
|
824
|
+
if (!commit) {
|
|
160
825
|
return yield* new WorktreeError({
|
|
161
|
-
message: `
|
|
826
|
+
message: `Reference '${checkout.ref}' for repository '${alias}' does not resolve to a commit`,
|
|
162
827
|
})
|
|
163
828
|
}
|
|
164
|
-
|
|
165
|
-
|
|
829
|
+
preflightCommits.set(alias, commit)
|
|
830
|
+
if (checkoutExists && registeredAtPath) {
|
|
831
|
+
const currentHead = yield* fs.runCommand(
|
|
832
|
+
["git", "-C", checkoutPath, "rev-parse", "HEAD"],
|
|
833
|
+
{ captureOutput: true },
|
|
834
|
+
)
|
|
835
|
+
if (
|
|
836
|
+
currentHead.exitCode !== 0 ||
|
|
837
|
+
currentHead.stdout.trim() !== commit
|
|
838
|
+
) {
|
|
839
|
+
return yield* new WorktreeError({
|
|
840
|
+
message: `Existing checkout ${checkoutPath} does not match reference '${checkout.ref}' (${commit})`,
|
|
841
|
+
})
|
|
842
|
+
}
|
|
843
|
+
}
|
|
166
844
|
}
|
|
845
|
+
}
|
|
167
846
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
for (const checkout of requestedCheckouts) {
|
|
847
|
+
if (!options.dryRun) yield* fs.createDirectory(codePath)
|
|
848
|
+
const operations: WorkspaceOperation[] = []
|
|
849
|
+
const checkoutReports: WorkspaceCheckout[] = []
|
|
850
|
+
const checkouts = requestedCheckouts
|
|
851
|
+
const createdBranches: { repo: string; branch: string }[] = []
|
|
852
|
+
const preexistingPaths = new Set<string>()
|
|
853
|
+
for (const checkout of checkouts) {
|
|
854
|
+
const path = join(codePath, checkout.repo)
|
|
855
|
+
if (yield* fs.isDirectory(path)) preexistingPaths.add(path)
|
|
856
|
+
}
|
|
857
|
+
const materialized = yield* Effect.gen(function* () {
|
|
858
|
+
for (const checkout of checkouts) {
|
|
181
859
|
const alias = checkout.repo
|
|
182
860
|
const repositoryPath = join(root, "repos", alias)
|
|
183
861
|
const checkoutPath = join(codePath, alias)
|
|
@@ -186,6 +864,56 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
186
864
|
message: `Repository alias '${alias}' does not exist`,
|
|
187
865
|
})
|
|
188
866
|
}
|
|
867
|
+
|
|
868
|
+
const fetchOrigin = (ref?: string) =>
|
|
869
|
+
Effect.gen(function* () {
|
|
870
|
+
const remote = yield* fs.runCommand(
|
|
871
|
+
[
|
|
872
|
+
"git",
|
|
873
|
+
"-C",
|
|
874
|
+
repositoryPath,
|
|
875
|
+
"remote",
|
|
876
|
+
"get-url",
|
|
877
|
+
"origin",
|
|
878
|
+
],
|
|
879
|
+
{ captureOutput: true },
|
|
880
|
+
)
|
|
881
|
+
if (remote.exitCode !== 0) return false
|
|
882
|
+
const command = [
|
|
883
|
+
"git",
|
|
884
|
+
"-C",
|
|
885
|
+
repositoryPath,
|
|
886
|
+
"fetch",
|
|
887
|
+
"origin",
|
|
888
|
+
...(ref ? [ref] : []),
|
|
889
|
+
]
|
|
890
|
+
if (options.dryRun) {
|
|
891
|
+
operations.push({
|
|
892
|
+
action: "fetch",
|
|
893
|
+
repo: alias,
|
|
894
|
+
command,
|
|
895
|
+
status: "planned",
|
|
896
|
+
})
|
|
897
|
+
return false
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
const fetch = yield* fs.runCommand(command, {
|
|
901
|
+
captureOutput: true,
|
|
902
|
+
})
|
|
903
|
+
if (fetch.exitCode !== 0) {
|
|
904
|
+
return yield* new WorktreeError({
|
|
905
|
+
message: `Failed to fetch '${alias}': ${fetch.stderr}`,
|
|
906
|
+
})
|
|
907
|
+
}
|
|
908
|
+
operations.push({
|
|
909
|
+
action: "fetch",
|
|
910
|
+
repo: alias,
|
|
911
|
+
command,
|
|
912
|
+
status: "completed",
|
|
913
|
+
})
|
|
914
|
+
return true
|
|
915
|
+
})
|
|
916
|
+
|
|
189
917
|
const listed = yield* fs.runCommand(
|
|
190
918
|
[
|
|
191
919
|
"git",
|
|
@@ -203,6 +931,9 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
203
931
|
message: `Failed to inspect worktrees for '${alias}': ${listed.stderr}`,
|
|
204
932
|
})
|
|
205
933
|
}
|
|
934
|
+
const canonicalCodePath = (yield* fs.exists(codePath))
|
|
935
|
+
? yield* fs.realPath(codePath)
|
|
936
|
+
: resolve(codePath)
|
|
206
937
|
const canonicalCheckoutPath = join(canonicalCodePath, alias)
|
|
207
938
|
const worktrees: GitWorktree[] = []
|
|
208
939
|
for (const worktree of parseWorktreeList(listed.stdout)) {
|
|
@@ -216,22 +947,9 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
216
947
|
const registeredAtPath = worktrees.find(
|
|
217
948
|
(worktree) => worktree.path === canonicalCheckoutPath,
|
|
218
949
|
)
|
|
219
|
-
|
|
950
|
+
|
|
220
951
|
if ("branch" in checkout) {
|
|
221
952
|
const branchRef = `refs/heads/${checkout.branch}`
|
|
222
|
-
const branchExists = yield* fs.runCommand(
|
|
223
|
-
[
|
|
224
|
-
"git",
|
|
225
|
-
"-C",
|
|
226
|
-
repositoryPath,
|
|
227
|
-
"show-ref",
|
|
228
|
-
"--verify",
|
|
229
|
-
branchRef,
|
|
230
|
-
],
|
|
231
|
-
{ captureOutput: true },
|
|
232
|
-
)
|
|
233
|
-
if (branchExists.exitCode === 0)
|
|
234
|
-
preexistingBranches.add(`${alias}:${checkout.branch}`)
|
|
235
953
|
const branchWorktree = worktrees.find(
|
|
236
954
|
(worktree) => worktree.branch === branchRef,
|
|
237
955
|
)
|
|
@@ -243,63 +961,42 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
243
961
|
message: `Branch '${checkout.branch}' for repository '${alias}' is already checked out at ${branchWorktree.path}`,
|
|
244
962
|
})
|
|
245
963
|
}
|
|
246
|
-
if (
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
964
|
+
if (yield* fs.isDirectory(checkoutPath)) {
|
|
965
|
+
if (registeredAtPath?.branch === branchRef) {
|
|
966
|
+
checkoutReports.push({
|
|
967
|
+
repo: alias,
|
|
968
|
+
kind: "writable",
|
|
969
|
+
path: checkoutPath,
|
|
970
|
+
requestedRef: checkout.branch,
|
|
971
|
+
resolvedCommit: registeredAtPath.head ?? null,
|
|
972
|
+
action: "reused",
|
|
973
|
+
})
|
|
974
|
+
continue
|
|
975
|
+
}
|
|
250
976
|
return yield* new WorktreeError({
|
|
251
977
|
message: `Existing checkout ${checkoutPath} is not registered to branch '${checkout.branch}'`,
|
|
252
978
|
})
|
|
253
979
|
}
|
|
254
|
-
if (
|
|
980
|
+
if (registeredAtPath) {
|
|
255
981
|
return yield* new WorktreeError({
|
|
256
982
|
message: `Worktree registry contains a missing checkout at ${checkoutPath}`,
|
|
257
983
|
})
|
|
258
984
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
"-C",
|
|
264
|
-
repositoryPath,
|
|
265
|
-
"rev-parse",
|
|
266
|
-
"--verify",
|
|
267
|
-
`${execution.base}^{commit}`,
|
|
268
|
-
],
|
|
269
|
-
{ captureOutput: true },
|
|
270
|
-
)
|
|
271
|
-
if (localBase.exitCode !== 0) {
|
|
272
|
-
const remoteBase = yield* fs.runCommand(
|
|
273
|
-
[
|
|
274
|
-
"git",
|
|
275
|
-
"-C",
|
|
276
|
-
repositoryPath,
|
|
277
|
-
"ls-remote",
|
|
278
|
-
"origin",
|
|
279
|
-
originRef(execution.base),
|
|
280
|
-
],
|
|
281
|
-
{ captureOutput: true },
|
|
282
|
-
)
|
|
283
|
-
if (
|
|
284
|
-
remoteBase.exitCode !== 0 ||
|
|
285
|
-
!remoteBase.stdout.trim()
|
|
286
|
-
) {
|
|
287
|
-
return yield* new WorktreeError({
|
|
288
|
-
message: `Base '${execution.base}' for repository '${alias}' does not resolve to a commit`,
|
|
289
|
-
})
|
|
290
|
-
}
|
|
291
|
-
}
|
|
292
|
-
}
|
|
985
|
+
yield* fetchOrigin()
|
|
986
|
+
|
|
987
|
+
let args: string[]
|
|
988
|
+
let env: Record<string, string> | undefined
|
|
293
989
|
if (config.worktreeCreateCommand) {
|
|
990
|
+
const variables = {
|
|
991
|
+
repo: repositoryPath,
|
|
992
|
+
worktree: checkoutPath,
|
|
993
|
+
branch: checkout.branch,
|
|
994
|
+
base: execution.base,
|
|
995
|
+
}
|
|
294
996
|
try {
|
|
295
|
-
expandWorktreeCreateCommand(
|
|
997
|
+
args = expandWorktreeCreateCommand(
|
|
296
998
|
config.worktreeCreateCommand,
|
|
297
|
-
|
|
298
|
-
repo: repositoryPath,
|
|
299
|
-
worktree: checkoutPath,
|
|
300
|
-
branch: checkout.branch,
|
|
301
|
-
base: execution.base,
|
|
302
|
-
},
|
|
999
|
+
variables,
|
|
303
1000
|
)
|
|
304
1001
|
} catch (cause) {
|
|
305
1002
|
return yield* new WorktreeError({
|
|
@@ -309,485 +1006,225 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
309
1006
|
: "Invalid worktreeCreateCommand",
|
|
310
1007
|
})
|
|
311
1008
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
return yield* new WorktreeError({
|
|
316
|
-
message: `Existing checkout ${checkoutPath} is not registered as a Git worktree`,
|
|
317
|
-
})
|
|
318
|
-
}
|
|
319
|
-
if (registeredAtPath?.branch) {
|
|
320
|
-
return yield* new WorktreeError({
|
|
321
|
-
message: `Reference checkout ${checkoutPath} is attached to branch '${registeredAtPath.branch.replace(/^refs\/heads\//, "")}'`,
|
|
322
|
-
})
|
|
323
|
-
}
|
|
324
|
-
if (!checkoutExists && registeredAtPath) {
|
|
325
|
-
return yield* new WorktreeError({
|
|
326
|
-
message: `Worktree registry contains a missing checkout at ${checkoutPath}`,
|
|
327
|
-
})
|
|
328
|
-
}
|
|
329
|
-
let commit: string | undefined
|
|
330
|
-
if (!isCommitId(checkout.ref)) {
|
|
331
|
-
const remote = yield* fs.runCommand(
|
|
332
|
-
[
|
|
333
|
-
"git",
|
|
334
|
-
"-C",
|
|
335
|
-
repositoryPath,
|
|
336
|
-
"ls-remote",
|
|
337
|
-
"origin",
|
|
338
|
-
originRef(checkout.ref),
|
|
339
|
-
],
|
|
340
|
-
{ captureOutput: true },
|
|
341
|
-
)
|
|
342
|
-
if (remote.exitCode === 0 && remote.stdout.trim())
|
|
343
|
-
commit = remote.stdout.trim().split(/\s+/)[0]
|
|
344
|
-
}
|
|
345
|
-
if (!commit) {
|
|
346
|
-
const local = yield* fs.runCommand(
|
|
1009
|
+
env = worktreeCommandEnvironment(variables)
|
|
1010
|
+
} else {
|
|
1011
|
+
const branchExists = yield* fs.runCommand(
|
|
347
1012
|
[
|
|
348
1013
|
"git",
|
|
349
1014
|
"-C",
|
|
350
1015
|
repositoryPath,
|
|
351
|
-
"
|
|
1016
|
+
"show-ref",
|
|
352
1017
|
"--verify",
|
|
353
|
-
|
|
1018
|
+
branchRef,
|
|
354
1019
|
],
|
|
355
1020
|
{ captureOutput: true },
|
|
356
1021
|
)
|
|
357
|
-
if (
|
|
358
|
-
}
|
|
359
|
-
if (!commit) {
|
|
360
|
-
return yield* new WorktreeError({
|
|
361
|
-
message: `Reference '${checkout.ref}' for repository '${alias}' does not resolve to a commit`,
|
|
362
|
-
})
|
|
363
|
-
}
|
|
364
|
-
preflightCommits.set(alias, commit)
|
|
365
|
-
if (checkoutExists && registeredAtPath) {
|
|
366
|
-
const currentHead = yield* fs.runCommand(
|
|
367
|
-
["git", "-C", checkoutPath, "rev-parse", "HEAD"],
|
|
368
|
-
{ captureOutput: true },
|
|
369
|
-
)
|
|
370
|
-
if (
|
|
371
|
-
currentHead.exitCode !== 0 ||
|
|
372
|
-
currentHead.stdout.trim() !== commit
|
|
373
|
-
) {
|
|
374
|
-
return yield* new WorktreeError({
|
|
375
|
-
message: `Existing checkout ${checkoutPath} does not match reference '${checkout.ref}' (${commit})`,
|
|
376
|
-
})
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
if (!options.dryRun) yield* fs.createDirectory(codePath)
|
|
383
|
-
const operations: WorkspaceOperation[] = []
|
|
384
|
-
const checkoutReports: WorkspaceCheckout[] = []
|
|
385
|
-
const checkouts = requestedCheckouts
|
|
386
|
-
const createdBranches: { repo: string; branch: string }[] = []
|
|
387
|
-
const preexistingPaths = new Set<string>()
|
|
388
|
-
for (const checkout of checkouts) {
|
|
389
|
-
const path = join(codePath, checkout.repo)
|
|
390
|
-
if (yield* fs.isDirectory(path)) preexistingPaths.add(path)
|
|
391
|
-
}
|
|
392
|
-
const materialized = yield* Effect.gen(function* () {
|
|
393
|
-
for (const checkout of checkouts) {
|
|
394
|
-
const alias = checkout.repo
|
|
395
|
-
const repositoryPath = join(root, "repos", alias)
|
|
396
|
-
const checkoutPath = join(codePath, alias)
|
|
397
|
-
if (!(yield* fs.exists(repositoryPath))) {
|
|
398
|
-
return yield* new WorktreeError({
|
|
399
|
-
message: `Repository alias '${alias}' does not exist`,
|
|
400
|
-
})
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
const fetchOrigin = (ref?: string) =>
|
|
404
|
-
Effect.gen(function* () {
|
|
405
|
-
const remote = yield* fs.runCommand(
|
|
406
|
-
[
|
|
407
|
-
"git",
|
|
408
|
-
"-C",
|
|
409
|
-
repositoryPath,
|
|
410
|
-
"remote",
|
|
411
|
-
"get-url",
|
|
412
|
-
"origin",
|
|
413
|
-
],
|
|
414
|
-
{ captureOutput: true },
|
|
415
|
-
)
|
|
416
|
-
if (remote.exitCode !== 0) return false
|
|
1022
|
+
if (branchExists.exitCode !== 0) {
|
|
417
1023
|
const command = [
|
|
418
1024
|
"git",
|
|
419
1025
|
"-C",
|
|
420
1026
|
repositoryPath,
|
|
421
|
-
"
|
|
422
|
-
|
|
423
|
-
|
|
1027
|
+
"branch",
|
|
1028
|
+
checkout.branch,
|
|
1029
|
+
execution.base,
|
|
424
1030
|
]
|
|
425
|
-
if (options.dryRun) {
|
|
426
|
-
operations.push({
|
|
427
|
-
action: "fetch",
|
|
428
|
-
repo: alias,
|
|
429
|
-
command,
|
|
430
|
-
status: "planned",
|
|
431
|
-
})
|
|
432
|
-
return false
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
const fetch = yield* fs.runCommand(command, {
|
|
436
|
-
captureOutput: true,
|
|
437
|
-
})
|
|
438
|
-
if (fetch.exitCode !== 0) {
|
|
439
|
-
return yield* new WorktreeError({
|
|
440
|
-
message: `Failed to fetch '${alias}': ${fetch.stderr}`,
|
|
441
|
-
})
|
|
442
|
-
}
|
|
443
1031
|
operations.push({
|
|
444
|
-
action: "
|
|
1032
|
+
action: "create-branch",
|
|
445
1033
|
repo: alias,
|
|
446
1034
|
command,
|
|
447
|
-
status: "completed",
|
|
1035
|
+
status: options.dryRun ? "planned" : "completed",
|
|
448
1036
|
})
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
1037
|
+
if (!options.dryRun) {
|
|
1038
|
+
const createBranch = yield* fs.runCommand(command, {
|
|
1039
|
+
captureOutput: true,
|
|
1040
|
+
})
|
|
1041
|
+
if (createBranch.exitCode !== 0) {
|
|
1042
|
+
return yield* new WorktreeError({
|
|
1043
|
+
message: `Failed to create branch '${checkout.branch}': ${createBranch.stderr}`,
|
|
1044
|
+
})
|
|
1045
|
+
}
|
|
1046
|
+
createdBranches.push({
|
|
1047
|
+
repo: alias,
|
|
1048
|
+
branch: checkout.branch,
|
|
1049
|
+
})
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
args = [
|
|
454
1053
|
"git",
|
|
455
1054
|
"-C",
|
|
456
1055
|
repositoryPath,
|
|
457
1056
|
"worktree",
|
|
458
|
-
"
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
]
|
|
462
|
-
{ captureOutput: true },
|
|
463
|
-
)
|
|
464
|
-
if (listed.exitCode !== 0) {
|
|
465
|
-
return yield* new WorktreeError({
|
|
466
|
-
message: `Failed to inspect worktrees for '${alias}': ${listed.stderr}`,
|
|
467
|
-
})
|
|
1057
|
+
"add",
|
|
1058
|
+
checkoutPath,
|
|
1059
|
+
checkout.branch,
|
|
1060
|
+
]
|
|
468
1061
|
}
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
worktrees.push({
|
|
476
|
-
...worktree,
|
|
477
|
-
path: (yield* fs.exists(worktree.path))
|
|
478
|
-
? yield* fs.realPath(worktree.path)
|
|
479
|
-
: resolve(worktree.path),
|
|
1062
|
+
if (options.dryRun) {
|
|
1063
|
+
operations.push({
|
|
1064
|
+
action: "create-worktree",
|
|
1065
|
+
repo: alias,
|
|
1066
|
+
command: args,
|
|
1067
|
+
status: "planned",
|
|
480
1068
|
})
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
(worktree) => worktree.path === canonicalCheckoutPath,
|
|
484
|
-
)
|
|
485
|
-
|
|
486
|
-
if ("branch" in checkout) {
|
|
487
|
-
const branchRef = `refs/heads/${checkout.branch}`
|
|
488
|
-
const branchWorktree = worktrees.find(
|
|
489
|
-
(worktree) => worktree.branch === branchRef,
|
|
490
|
-
)
|
|
491
|
-
if (
|
|
492
|
-
branchWorktree &&
|
|
493
|
-
branchWorktree.path !== canonicalCheckoutPath
|
|
494
|
-
) {
|
|
495
|
-
return yield* new WorktreeError({
|
|
496
|
-
message: `Branch '${checkout.branch}' for repository '${alias}' is already checked out at ${branchWorktree.path}`,
|
|
497
|
-
})
|
|
498
|
-
}
|
|
499
|
-
if (yield* fs.isDirectory(checkoutPath)) {
|
|
500
|
-
if (registeredAtPath?.branch === branchRef) {
|
|
501
|
-
checkoutReports.push({
|
|
502
|
-
repo: alias,
|
|
503
|
-
kind: "writable",
|
|
504
|
-
path: checkoutPath,
|
|
505
|
-
requestedRef: checkout.branch,
|
|
506
|
-
resolvedCommit: registeredAtPath.head ?? null,
|
|
507
|
-
action: "reused",
|
|
508
|
-
})
|
|
509
|
-
continue
|
|
510
|
-
}
|
|
511
|
-
return yield* new WorktreeError({
|
|
512
|
-
message: `Existing checkout ${checkoutPath} is not registered to branch '${checkout.branch}'`,
|
|
513
|
-
})
|
|
514
|
-
}
|
|
515
|
-
if (registeredAtPath) {
|
|
516
|
-
return yield* new WorktreeError({
|
|
517
|
-
message: `Worktree registry contains a missing checkout at ${checkoutPath}`,
|
|
518
|
-
})
|
|
519
|
-
}
|
|
520
|
-
yield* fetchOrigin()
|
|
521
|
-
|
|
522
|
-
let args: string[]
|
|
523
|
-
let env: Record<string, string> | undefined
|
|
524
|
-
if (config.worktreeCreateCommand) {
|
|
525
|
-
const variables = {
|
|
526
|
-
repo: repositoryPath,
|
|
527
|
-
worktree: checkoutPath,
|
|
528
|
-
branch: checkout.branch,
|
|
529
|
-
base: execution.base,
|
|
530
|
-
}
|
|
531
|
-
try {
|
|
532
|
-
args = expandWorktreeCreateCommand(
|
|
533
|
-
config.worktreeCreateCommand,
|
|
534
|
-
variables,
|
|
535
|
-
)
|
|
536
|
-
} catch (cause) {
|
|
537
|
-
return yield* new WorktreeError({
|
|
538
|
-
message:
|
|
539
|
-
cause instanceof Error
|
|
540
|
-
? cause.message
|
|
541
|
-
: "Invalid worktreeCreateCommand",
|
|
542
|
-
})
|
|
543
|
-
}
|
|
544
|
-
env = worktreeCommandEnvironment(variables)
|
|
545
|
-
} else {
|
|
546
|
-
const branchExists = yield* fs.runCommand(
|
|
547
|
-
[
|
|
548
|
-
"git",
|
|
549
|
-
"-C",
|
|
550
|
-
repositoryPath,
|
|
551
|
-
"show-ref",
|
|
552
|
-
"--verify",
|
|
553
|
-
branchRef,
|
|
554
|
-
],
|
|
555
|
-
{ captureOutput: true },
|
|
556
|
-
)
|
|
557
|
-
if (branchExists.exitCode !== 0) {
|
|
558
|
-
const command = [
|
|
559
|
-
"git",
|
|
560
|
-
"-C",
|
|
561
|
-
repositoryPath,
|
|
562
|
-
"branch",
|
|
563
|
-
checkout.branch,
|
|
564
|
-
execution.base,
|
|
565
|
-
]
|
|
566
|
-
operations.push({
|
|
567
|
-
action: "create-branch",
|
|
568
|
-
repo: alias,
|
|
569
|
-
command,
|
|
570
|
-
status: options.dryRun ? "planned" : "completed",
|
|
571
|
-
})
|
|
572
|
-
if (!options.dryRun) {
|
|
573
|
-
const createBranch = yield* fs.runCommand(command, {
|
|
574
|
-
captureOutput: true,
|
|
575
|
-
})
|
|
576
|
-
if (createBranch.exitCode !== 0) {
|
|
577
|
-
return yield* new WorktreeError({
|
|
578
|
-
message: `Failed to create branch '${checkout.branch}': ${createBranch.stderr}`,
|
|
579
|
-
})
|
|
580
|
-
}
|
|
581
|
-
createdBranches.push({
|
|
582
|
-
repo: alias,
|
|
583
|
-
branch: checkout.branch,
|
|
584
|
-
})
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
args = [
|
|
1069
|
+
let resolved = yield* fs.runCommand(
|
|
1070
|
+
[
|
|
588
1071
|
"git",
|
|
589
1072
|
"-C",
|
|
590
1073
|
repositoryPath,
|
|
591
|
-
"
|
|
592
|
-
"
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
if (
|
|
598
|
-
|
|
599
|
-
action: "create-worktree",
|
|
600
|
-
repo: alias,
|
|
601
|
-
command: args,
|
|
602
|
-
status: "planned",
|
|
603
|
-
})
|
|
604
|
-
let resolved = yield* fs.runCommand(
|
|
1074
|
+
"rev-parse",
|
|
1075
|
+
"--verify",
|
|
1076
|
+
`${checkout.branch}^{commit}`,
|
|
1077
|
+
],
|
|
1078
|
+
{ captureOutput: true },
|
|
1079
|
+
)
|
|
1080
|
+
if (resolved.exitCode !== 0) {
|
|
1081
|
+
resolved = yield* fs.runCommand(
|
|
605
1082
|
[
|
|
606
1083
|
"git",
|
|
607
1084
|
"-C",
|
|
608
1085
|
repositoryPath,
|
|
609
1086
|
"rev-parse",
|
|
610
1087
|
"--verify",
|
|
611
|
-
`${
|
|
1088
|
+
`${execution.base}^{commit}`,
|
|
612
1089
|
],
|
|
613
1090
|
{ captureOutput: true },
|
|
614
1091
|
)
|
|
615
|
-
if (resolved.exitCode !== 0) {
|
|
616
|
-
resolved = yield* fs.runCommand(
|
|
617
|
-
[
|
|
618
|
-
"git",
|
|
619
|
-
"-C",
|
|
620
|
-
repositoryPath,
|
|
621
|
-
"rev-parse",
|
|
622
|
-
"--verify",
|
|
623
|
-
`${execution.base}^{commit}`,
|
|
624
|
-
],
|
|
625
|
-
{ captureOutput: true },
|
|
626
|
-
)
|
|
627
|
-
}
|
|
628
|
-
checkoutReports.push({
|
|
629
|
-
repo: alias,
|
|
630
|
-
kind: "writable",
|
|
631
|
-
path: checkoutPath,
|
|
632
|
-
requestedRef: checkout.branch,
|
|
633
|
-
resolvedCommit:
|
|
634
|
-
resolved.exitCode === 0
|
|
635
|
-
? resolved.stdout.trim()
|
|
636
|
-
: null,
|
|
637
|
-
action: "created",
|
|
638
|
-
})
|
|
639
|
-
continue
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
if (config.worktreeCreateCommand) {
|
|
643
|
-
verboseLog(
|
|
644
|
-
`Running worktree command: ${formatCommand(args)}`,
|
|
645
|
-
)
|
|
646
|
-
}
|
|
647
|
-
const result = yield* fs.runCommand(args, {
|
|
648
|
-
cwd: repositoryPath,
|
|
649
|
-
captureOutput: true,
|
|
650
|
-
forwardOutput:
|
|
651
|
-
config.worktreeCreateCommand && forwardCommandOutput,
|
|
652
|
-
env,
|
|
653
|
-
})
|
|
654
|
-
if (result.exitCode !== 0) {
|
|
655
|
-
return yield* new WorktreeError({
|
|
656
|
-
message: `Failed to create worktree for '${alias}': ${result.stderr}`,
|
|
657
|
-
})
|
|
658
|
-
}
|
|
659
|
-
if (!(yield* fs.isDirectory(checkoutPath))) {
|
|
660
|
-
return yield* new WorktreeError({
|
|
661
|
-
message: `Worktree command did not create ${checkoutPath}`,
|
|
662
|
-
})
|
|
663
1092
|
}
|
|
664
|
-
operations.push({
|
|
665
|
-
action: "create-worktree",
|
|
666
|
-
repo: alias,
|
|
667
|
-
command: args,
|
|
668
|
-
status: "completed",
|
|
669
|
-
})
|
|
670
|
-
const head = yield* fs.runCommand(
|
|
671
|
-
["git", "-C", checkoutPath, "rev-parse", "HEAD"],
|
|
672
|
-
{ captureOutput: true },
|
|
673
|
-
)
|
|
674
1093
|
checkoutReports.push({
|
|
675
1094
|
repo: alias,
|
|
676
1095
|
kind: "writable",
|
|
677
1096
|
path: checkoutPath,
|
|
678
1097
|
requestedRef: checkout.branch,
|
|
679
1098
|
resolvedCommit:
|
|
680
|
-
|
|
1099
|
+
resolved.exitCode === 0 ? resolved.stdout.trim() : null,
|
|
681
1100
|
action: "created",
|
|
682
1101
|
})
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
1102
|
+
continue
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
if (config.worktreeCreateCommand) {
|
|
1106
|
+
verboseLog(
|
|
1107
|
+
`Running worktree command: ${formatCommand(args)}`,
|
|
1108
|
+
)
|
|
1109
|
+
}
|
|
1110
|
+
const result = yield* fs.runCommand(args, {
|
|
1111
|
+
cwd: repositoryPath,
|
|
1112
|
+
captureOutput: true,
|
|
1113
|
+
forwardOutput:
|
|
1114
|
+
config.worktreeCreateCommand && forwardCommandOutput,
|
|
1115
|
+
env,
|
|
1116
|
+
})
|
|
1117
|
+
if (result.exitCode !== 0) {
|
|
1118
|
+
return yield* new WorktreeError({
|
|
1119
|
+
message: `Failed to create worktree for '${alias}': ${result.stderr}`,
|
|
1120
|
+
})
|
|
1121
|
+
}
|
|
1122
|
+
if (!(yield* fs.isDirectory(checkoutPath))) {
|
|
1123
|
+
return yield* new WorktreeError({
|
|
1124
|
+
message: `Worktree command did not create ${checkoutPath}`,
|
|
1125
|
+
})
|
|
1126
|
+
}
|
|
1127
|
+
operations.push({
|
|
1128
|
+
action: "create-worktree",
|
|
1129
|
+
repo: alias,
|
|
1130
|
+
command: args,
|
|
1131
|
+
status: "completed",
|
|
1132
|
+
})
|
|
1133
|
+
const head = yield* fs.runCommand(
|
|
1134
|
+
["git", "-C", checkoutPath, "rev-parse", "HEAD"],
|
|
1135
|
+
{ captureOutput: true },
|
|
1136
|
+
)
|
|
1137
|
+
checkoutReports.push({
|
|
1138
|
+
repo: alias,
|
|
1139
|
+
kind: "writable",
|
|
1140
|
+
path: checkoutPath,
|
|
1141
|
+
requestedRef: checkout.branch,
|
|
1142
|
+
resolvedCommit:
|
|
1143
|
+
head.exitCode === 0 ? head.stdout.trim() : null,
|
|
1144
|
+
action: "created",
|
|
1145
|
+
})
|
|
1146
|
+
} else {
|
|
1147
|
+
const fetched = isCommitId(checkout.ref)
|
|
1148
|
+
? false
|
|
1149
|
+
: yield* fetchOrigin(originRef(checkout.ref))
|
|
1150
|
+
const resolvedRefName = fetched ? "FETCH_HEAD" : checkout.ref
|
|
1151
|
+
const resolvedRef = options.dryRun
|
|
1152
|
+
? {
|
|
1153
|
+
exitCode: 0,
|
|
1154
|
+
stdout: preflightCommits.get(alias)!,
|
|
1155
|
+
stderr: "",
|
|
723
1156
|
}
|
|
724
|
-
|
|
725
|
-
[
|
|
1157
|
+
: yield* fs.runCommand(
|
|
1158
|
+
[
|
|
1159
|
+
"git",
|
|
1160
|
+
"-C",
|
|
1161
|
+
repositoryPath,
|
|
1162
|
+
"rev-parse",
|
|
1163
|
+
"--verify",
|
|
1164
|
+
`${resolvedRefName}^{commit}`,
|
|
1165
|
+
],
|
|
726
1166
|
{ captureOutput: true },
|
|
727
1167
|
)
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
requestedRef: checkout.ref,
|
|
737
|
-
resolvedCommit: commit,
|
|
738
|
-
action: "reused",
|
|
739
|
-
})
|
|
740
|
-
continue
|
|
741
|
-
}
|
|
1168
|
+
if (resolvedRef.exitCode !== 0) {
|
|
1169
|
+
return yield* new WorktreeError({
|
|
1170
|
+
message: `Reference '${checkout.ref}' for repository '${alias}' does not resolve to a commit`,
|
|
1171
|
+
})
|
|
1172
|
+
}
|
|
1173
|
+
const commit = resolvedRef.stdout.trim()
|
|
1174
|
+
if (yield* fs.isDirectory(checkoutPath)) {
|
|
1175
|
+
if (!registeredAtPath) {
|
|
742
1176
|
return yield* new WorktreeError({
|
|
743
|
-
message: `Existing checkout ${checkoutPath}
|
|
1177
|
+
message: `Existing checkout ${checkoutPath} is not registered as a Git worktree`,
|
|
744
1178
|
})
|
|
745
1179
|
}
|
|
746
|
-
if (registeredAtPath) {
|
|
1180
|
+
if (registeredAtPath.branch) {
|
|
747
1181
|
return yield* new WorktreeError({
|
|
748
|
-
message: `
|
|
1182
|
+
message: `Reference checkout ${checkoutPath} is attached to branch '${registeredAtPath.branch.replace(/^refs\/heads\//, "")}'`,
|
|
749
1183
|
})
|
|
750
1184
|
}
|
|
751
|
-
const
|
|
752
|
-
"git",
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
commit,
|
|
760
|
-
]
|
|
761
|
-
if (options.dryRun) {
|
|
762
|
-
operations.push({
|
|
763
|
-
action: "create-worktree",
|
|
764
|
-
repo: alias,
|
|
765
|
-
command,
|
|
766
|
-
status: "planned",
|
|
767
|
-
})
|
|
1185
|
+
const currentHead = yield* fs.runCommand(
|
|
1186
|
+
["git", "-C", checkoutPath, "rev-parse", "HEAD"],
|
|
1187
|
+
{ captureOutput: true },
|
|
1188
|
+
)
|
|
1189
|
+
if (
|
|
1190
|
+
currentHead.exitCode === 0 &&
|
|
1191
|
+
currentHead.stdout.trim() === commit
|
|
1192
|
+
) {
|
|
768
1193
|
checkoutReports.push({
|
|
769
1194
|
repo: alias,
|
|
770
1195
|
kind: "reference",
|
|
771
1196
|
path: checkoutPath,
|
|
772
1197
|
requestedRef: checkout.ref,
|
|
773
1198
|
resolvedCommit: commit,
|
|
774
|
-
action: "
|
|
1199
|
+
action: "reused",
|
|
775
1200
|
})
|
|
776
1201
|
continue
|
|
777
1202
|
}
|
|
778
|
-
|
|
779
|
-
|
|
1203
|
+
return yield* new WorktreeError({
|
|
1204
|
+
message: `Existing checkout ${checkoutPath} does not match reference '${checkout.ref}' (${commit})`,
|
|
780
1205
|
})
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
}
|
|
785
|
-
}
|
|
1206
|
+
}
|
|
1207
|
+
if (registeredAtPath) {
|
|
1208
|
+
return yield* new WorktreeError({
|
|
1209
|
+
message: `Worktree registry contains a missing checkout at ${checkoutPath}`,
|
|
1210
|
+
})
|
|
1211
|
+
}
|
|
1212
|
+
const command = [
|
|
1213
|
+
"git",
|
|
1214
|
+
"-C",
|
|
1215
|
+
repositoryPath,
|
|
1216
|
+
"worktree",
|
|
1217
|
+
"add",
|
|
1218
|
+
"--detach",
|
|
1219
|
+
checkoutPath,
|
|
1220
|
+
commit,
|
|
1221
|
+
]
|
|
1222
|
+
if (options.dryRun) {
|
|
786
1223
|
operations.push({
|
|
787
1224
|
action: "create-worktree",
|
|
788
1225
|
repo: alias,
|
|
789
1226
|
command,
|
|
790
|
-
status: "
|
|
1227
|
+
status: "planned",
|
|
791
1228
|
})
|
|
792
1229
|
checkoutReports.push({
|
|
793
1230
|
repo: alias,
|
|
@@ -797,128 +1234,155 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
797
1234
|
resolvedCommit: commit,
|
|
798
1235
|
action: "created",
|
|
799
1236
|
})
|
|
1237
|
+
continue
|
|
1238
|
+
}
|
|
1239
|
+
const result = yield* fs.runCommand(command, {
|
|
1240
|
+
captureOutput: true,
|
|
1241
|
+
})
|
|
1242
|
+
if (result.exitCode !== 0) {
|
|
1243
|
+
return yield* new WorktreeError({
|
|
1244
|
+
message: `Failed to create worktree for '${alias}': ${result.stderr}`,
|
|
1245
|
+
})
|
|
800
1246
|
}
|
|
1247
|
+
operations.push({
|
|
1248
|
+
action: "create-worktree",
|
|
1249
|
+
repo: alias,
|
|
1250
|
+
command,
|
|
1251
|
+
status: "completed",
|
|
1252
|
+
})
|
|
1253
|
+
checkoutReports.push({
|
|
1254
|
+
repo: alias,
|
|
1255
|
+
kind: "reference",
|
|
1256
|
+
path: checkoutPath,
|
|
1257
|
+
requestedRef: checkout.ref,
|
|
1258
|
+
resolvedCommit: commit,
|
|
1259
|
+
action: "created",
|
|
1260
|
+
})
|
|
801
1261
|
}
|
|
1262
|
+
}
|
|
802
1263
|
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
)
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
continue
|
|
842
|
-
const removed = yield* fs.runCommand(
|
|
843
|
-
[
|
|
844
|
-
"git",
|
|
845
|
-
"-C",
|
|
846
|
-
join(root, "repos", checkout.repo),
|
|
847
|
-
"worktree",
|
|
848
|
-
"remove",
|
|
849
|
-
"--force",
|
|
850
|
-
checkoutPath,
|
|
851
|
-
],
|
|
852
|
-
{ captureOutput: true },
|
|
853
|
-
)
|
|
854
|
-
if (removed.exitCode === 0)
|
|
855
|
-
rolledBack.push(`create-worktree ${checkout.repo}`)
|
|
856
|
-
else manualRecovery.push(`Remove ${checkoutPath}`)
|
|
857
|
-
}
|
|
858
|
-
const branchCandidates = new Map(
|
|
1264
|
+
return {
|
|
1265
|
+
root,
|
|
1266
|
+
taskPath: task.path,
|
|
1267
|
+
phasePath,
|
|
1268
|
+
codePath,
|
|
1269
|
+
writablePath: join(codePath, execution.repo),
|
|
1270
|
+
repo: execution.repo,
|
|
1271
|
+
repos: execution.repos ?? [],
|
|
1272
|
+
dryRun: options.dryRun === true,
|
|
1273
|
+
checkouts: checkoutReports,
|
|
1274
|
+
operations,
|
|
1275
|
+
} satisfies ExecutionWorkspace
|
|
1276
|
+
}).pipe(
|
|
1277
|
+
Effect.catchAll((cause) =>
|
|
1278
|
+
Effect.gen(function* () {
|
|
1279
|
+
if (options.dryRun) return yield* cause
|
|
1280
|
+
const completed = operations
|
|
1281
|
+
.filter((operation) => operation.status === "completed")
|
|
1282
|
+
.map((operation) => `${operation.action} ${operation.repo}`)
|
|
1283
|
+
const rolledBack: string[] = []
|
|
1284
|
+
const manualRecovery = operations
|
|
1285
|
+
.filter(
|
|
1286
|
+
(operation) =>
|
|
1287
|
+
operation.action === "fetch" &&
|
|
1288
|
+
operation.status === "completed",
|
|
1289
|
+
)
|
|
1290
|
+
.map(
|
|
1291
|
+
(operation) =>
|
|
1292
|
+
`Review fetched refs for repository '${operation.repo}'`,
|
|
1293
|
+
)
|
|
1294
|
+
for (const checkout of [...checkouts].reverse()) {
|
|
1295
|
+
const checkoutPath = join(codePath, checkout.repo)
|
|
1296
|
+
if (
|
|
1297
|
+
preexistingPaths.has(checkoutPath) ||
|
|
1298
|
+
!(yield* fs.isDirectory(checkoutPath))
|
|
1299
|
+
)
|
|
1300
|
+
continue
|
|
1301
|
+
const removed = yield* fs.runCommand(
|
|
859
1302
|
[
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
"git",
|
|
885
|
-
"-C",
|
|
886
|
-
join(root, "repos", branch.repo),
|
|
887
|
-
"branch",
|
|
888
|
-
"-D",
|
|
889
|
-
branch.branch,
|
|
890
|
-
],
|
|
891
|
-
{ captureOutput: true },
|
|
892
|
-
)
|
|
893
|
-
if (deleted.exitCode === 0)
|
|
894
|
-
rolledBack.push(`create-branch ${branch.repo}`)
|
|
895
|
-
else
|
|
896
|
-
manualRecovery.push(
|
|
897
|
-
`Delete branch '${branch.branch}' in repository '${branch.repo}'`,
|
|
1303
|
+
"git",
|
|
1304
|
+
"-C",
|
|
1305
|
+
join(root, "repos", checkout.repo),
|
|
1306
|
+
"worktree",
|
|
1307
|
+
"remove",
|
|
1308
|
+
checkoutPath,
|
|
1309
|
+
],
|
|
1310
|
+
{ captureOutput: true },
|
|
1311
|
+
)
|
|
1312
|
+
if (removed.exitCode === 0)
|
|
1313
|
+
rolledBack.push(`create-worktree ${checkout.repo}`)
|
|
1314
|
+
else manualRecovery.push(`Remove ${checkoutPath}`)
|
|
1315
|
+
}
|
|
1316
|
+
const branchCandidates = new Map(
|
|
1317
|
+
[
|
|
1318
|
+
...createdBranches,
|
|
1319
|
+
...checkouts
|
|
1320
|
+
.filter(
|
|
1321
|
+
(
|
|
1322
|
+
checkout,
|
|
1323
|
+
): checkout is {
|
|
1324
|
+
repo: string
|
|
1325
|
+
branch: string
|
|
1326
|
+
} => "branch" in checkout,
|
|
898
1327
|
)
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
1328
|
+
.filter(
|
|
1329
|
+
(checkout) =>
|
|
1330
|
+
!preexistingBranches.has(
|
|
1331
|
+
`${checkout.repo}:${checkout.branch}`,
|
|
1332
|
+
),
|
|
1333
|
+
),
|
|
1334
|
+
].map((branch) => [
|
|
1335
|
+
`${branch.repo}:${branch.branch}`,
|
|
1336
|
+
branch,
|
|
1337
|
+
]),
|
|
1338
|
+
).values()
|
|
1339
|
+
for (const branch of branchCandidates) {
|
|
1340
|
+
const deleted = yield* fs.runCommand(
|
|
1341
|
+
[
|
|
1342
|
+
"git",
|
|
1343
|
+
"-C",
|
|
1344
|
+
join(root, "repos", branch.repo),
|
|
1345
|
+
"branch",
|
|
1346
|
+
"-D",
|
|
1347
|
+
branch.branch,
|
|
1348
|
+
],
|
|
1349
|
+
{ captureOutput: true },
|
|
903
1350
|
)
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
1351
|
+
if (deleted.exitCode === 0)
|
|
1352
|
+
rolledBack.push(`create-branch ${branch.repo}`)
|
|
1353
|
+
else
|
|
1354
|
+
manualRecovery.push(
|
|
1355
|
+
`Delete branch '${branch.branch}' in repository '${branch.repo}'`,
|
|
1356
|
+
)
|
|
1357
|
+
}
|
|
1358
|
+
if (
|
|
1359
|
+
(yield* fs.isDirectory(codePath)) &&
|
|
1360
|
+
(yield* fs.readDirectory(codePath)).length === 0
|
|
1361
|
+
)
|
|
1362
|
+
yield* fs.deleteDirectory(codePath)
|
|
1363
|
+
return yield* new WorktreeError({
|
|
1364
|
+
message: `${cause.message}. ${
|
|
1365
|
+
manualRecovery.length
|
|
1366
|
+
? "Some effects require manual recovery"
|
|
1367
|
+
: "Created worktrees and branches were rolled back"
|
|
1368
|
+
}`,
|
|
1369
|
+
completed,
|
|
1370
|
+
rolledBack,
|
|
1371
|
+
manualRecovery,
|
|
1372
|
+
cause,
|
|
1373
|
+
})
|
|
1374
|
+
}),
|
|
1375
|
+
),
|
|
1376
|
+
)
|
|
1377
|
+
return materialized
|
|
1378
|
+
})
|
|
1379
|
+
return yield* options.lockHeld
|
|
1380
|
+
? materialization
|
|
1381
|
+
: withWorktreeLocks(
|
|
1382
|
+
root,
|
|
1383
|
+
[{ taskId, ...(phaseId ? { phaseId } : {}) }],
|
|
1384
|
+
materialization,
|
|
918
1385
|
)
|
|
919
|
-
return materialized
|
|
920
|
-
}),
|
|
921
|
-
)
|
|
922
1386
|
}),
|
|
923
1387
|
|
|
924
1388
|
remove: (
|
|
@@ -934,11 +1398,24 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
934
1398
|
const phases = yield* PhaseService
|
|
935
1399
|
const root = yield* workbase.discover(startPath)
|
|
936
1400
|
const removal = Effect.gen(function* () {
|
|
1401
|
+
const inspection = yield* inspectExecution(taskId, phaseId, root)
|
|
1402
|
+
const blockingConflicts = inspection.conflicts.filter(
|
|
1403
|
+
(conflict) => conflict.kind !== "stale-registration",
|
|
1404
|
+
)
|
|
1405
|
+
if (blockingConflicts.length > 0) {
|
|
1406
|
+
return yield* new WorktreeError({
|
|
1407
|
+
message: blockingConflicts
|
|
1408
|
+
.map(({ message }) => message)
|
|
1409
|
+
.join("\n"),
|
|
1410
|
+
conflicts: blockingConflicts,
|
|
1411
|
+
})
|
|
1412
|
+
}
|
|
937
1413
|
const task = yield* tasks.show(taskId, root)
|
|
938
1414
|
|
|
939
1415
|
let execution: {
|
|
940
1416
|
repo: string
|
|
941
1417
|
repos?: readonly RepositoryReference[]
|
|
1418
|
+
branch: string
|
|
942
1419
|
}
|
|
943
1420
|
let codePath: string
|
|
944
1421
|
if ("phases" in task.data) {
|
|
@@ -970,10 +1447,14 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
970
1447
|
head?: string
|
|
971
1448
|
branch?: string
|
|
972
1449
|
}[] = []
|
|
973
|
-
const
|
|
974
|
-
|
|
975
|
-
|
|
1450
|
+
const expectedCheckouts: readonly (
|
|
1451
|
+
| { readonly repo: string; readonly branch: string }
|
|
1452
|
+
| RepositoryReference
|
|
1453
|
+
)[] = [
|
|
1454
|
+
{ repo: execution.repo, branch: execution.branch },
|
|
1455
|
+
...(execution.repos ?? []),
|
|
976
1456
|
]
|
|
1457
|
+
const expectedAliases = expectedCheckouts.map(({ repo }) => repo)
|
|
977
1458
|
if (codeDirectoryExists) {
|
|
978
1459
|
const unmanaged = (yield* fs.readDirectory(codePath)).filter(
|
|
979
1460
|
(entry) => !expectedAliases.includes(entry.name),
|
|
@@ -984,9 +1465,18 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
984
1465
|
})
|
|
985
1466
|
}
|
|
986
1467
|
}
|
|
987
|
-
for (const
|
|
1468
|
+
for (const checkout of expectedCheckouts) {
|
|
1469
|
+
const alias = checkout.repo
|
|
988
1470
|
const repositoryPath = join(root, "repos", alias)
|
|
989
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
|
+
}
|
|
990
1480
|
const listed = yield* fs.runCommand(
|
|
991
1481
|
[
|
|
992
1482
|
"git",
|
|
@@ -1031,6 +1521,42 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
1031
1521
|
}
|
|
1032
1522
|
continue
|
|
1033
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
|
+
}
|
|
1034
1560
|
if (checkoutExists) {
|
|
1035
1561
|
const status = yield* fs.runCommand(
|
|
1036
1562
|
["git", "-C", checkoutPath, "status", "--porcelain"],
|
|
@@ -1062,28 +1588,35 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
1062
1588
|
})
|
|
1063
1589
|
}
|
|
1064
1590
|
if (options.dryRun) {
|
|
1065
|
-
return removalPlans
|
|
1066
|
-
.
|
|
1067
|
-
|
|
1591
|
+
return removalPlans.map((plan) =>
|
|
1592
|
+
plan.checkoutExists ? plan.checkoutPath : plan.registeredPath,
|
|
1593
|
+
)
|
|
1068
1594
|
}
|
|
1069
1595
|
|
|
1070
1596
|
const completed: typeof removalPlans = []
|
|
1071
1597
|
const removed = yield* Effect.gen(function* () {
|
|
1072
1598
|
for (const plan of removalPlans) {
|
|
1073
|
-
const
|
|
1074
|
-
[
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1599
|
+
const command = plan.checkoutExists
|
|
1600
|
+
? [
|
|
1601
|
+
"git",
|
|
1602
|
+
"-C",
|
|
1603
|
+
plan.repositoryPath,
|
|
1604
|
+
"worktree",
|
|
1605
|
+
"remove",
|
|
1606
|
+
plan.checkoutPath,
|
|
1607
|
+
]
|
|
1608
|
+
: [
|
|
1609
|
+
"git",
|
|
1610
|
+
"-C",
|
|
1611
|
+
plan.repositoryPath,
|
|
1612
|
+
"worktree",
|
|
1613
|
+
"prune",
|
|
1614
|
+
"--expire",
|
|
1615
|
+
"now",
|
|
1616
|
+
]
|
|
1617
|
+
const result = yield* fs.runCommand(command, {
|
|
1618
|
+
captureOutput: true,
|
|
1619
|
+
})
|
|
1087
1620
|
if (result.exitCode !== 0) {
|
|
1088
1621
|
return yield* new WorktreeError({
|
|
1089
1622
|
message: `Failed to remove worktree for '${plan.alias}': ${result.stderr}`,
|
|
@@ -1091,19 +1624,23 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
1091
1624
|
}
|
|
1092
1625
|
completed.push(plan)
|
|
1093
1626
|
}
|
|
1094
|
-
if (codeDirectoryExists
|
|
1095
|
-
yield* fs.
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
.map((plan) => plan.checkoutPath)
|
|
1627
|
+
if (codeDirectoryExists)
|
|
1628
|
+
yield* fs.deleteDirectoryIfEmpty(codePath)
|
|
1629
|
+
return removalPlans.map((plan) =>
|
|
1630
|
+
plan.checkoutExists ? plan.checkoutPath : plan.registeredPath,
|
|
1631
|
+
)
|
|
1100
1632
|
}).pipe(
|
|
1101
1633
|
Effect.catchAll((cause) =>
|
|
1102
1634
|
Effect.gen(function* () {
|
|
1103
1635
|
const rolledBack: string[] = []
|
|
1104
1636
|
const manualRecovery: string[] = []
|
|
1105
1637
|
for (const plan of [...completed].reverse()) {
|
|
1106
|
-
if (!plan.checkoutExists)
|
|
1638
|
+
if (!plan.checkoutExists) {
|
|
1639
|
+
manualRecovery.push(
|
|
1640
|
+
`Re-run worktree repair for stale registration ${plan.registeredPath}`,
|
|
1641
|
+
)
|
|
1642
|
+
continue
|
|
1643
|
+
}
|
|
1107
1644
|
yield* fs.createDirectory(dirname(plan.checkoutPath))
|
|
1108
1645
|
const command = plan.branch
|
|
1109
1646
|
? [
|
|
@@ -1154,6 +1691,324 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
1154
1691
|
removal,
|
|
1155
1692
|
)
|
|
1156
1693
|
}),
|
|
1694
|
+
|
|
1695
|
+
rebuild: (
|
|
1696
|
+
taskId: string,
|
|
1697
|
+
phaseId?: string,
|
|
1698
|
+
startPath: string = process.cwd(),
|
|
1699
|
+
options: LifecycleOptions = {},
|
|
1700
|
+
): Effect.Effect<WorktreeLifecycleResult, unknown, any> =>
|
|
1701
|
+
Effect.gen(function* () {
|
|
1702
|
+
const fs = yield* FileSystemService
|
|
1703
|
+
const workbase = yield* WorkbaseService
|
|
1704
|
+
const service = yield* WorktreeService
|
|
1705
|
+
const root = yield* workbase.discover(startPath)
|
|
1706
|
+
if (!options.lockHeld) {
|
|
1707
|
+
return yield* withWorktreeLocks(
|
|
1708
|
+
root,
|
|
1709
|
+
[{ taskId, ...(phaseId ? { phaseId } : {}) }],
|
|
1710
|
+
service.rebuild(taskId, phaseId, root, {
|
|
1711
|
+
...options,
|
|
1712
|
+
lockHeld: true,
|
|
1713
|
+
}),
|
|
1714
|
+
)
|
|
1715
|
+
}
|
|
1716
|
+
const inspection = yield* inspectExecution(taskId, phaseId, root)
|
|
1717
|
+
const dirty = inspection.checkouts.filter(
|
|
1718
|
+
(checkout) => checkout.dirty === true,
|
|
1719
|
+
)
|
|
1720
|
+
if (dirty.length > 0) {
|
|
1721
|
+
return yield* new WorktreeError({
|
|
1722
|
+
message: `Cannot rebuild ${dirty.map((checkout) => checkout.path).join(", ")}; checkout has uncommitted changes`,
|
|
1723
|
+
conflicts: dirty.flatMap((checkout) => checkout.conflicts),
|
|
1724
|
+
})
|
|
1725
|
+
}
|
|
1726
|
+
if (inspection.conflicts.length > 0) {
|
|
1727
|
+
return yield* new WorktreeError({
|
|
1728
|
+
message: inspection.conflicts
|
|
1729
|
+
.map(({ message }) => message)
|
|
1730
|
+
.join("\n"),
|
|
1731
|
+
conflicts: inspection.conflicts,
|
|
1732
|
+
})
|
|
1733
|
+
}
|
|
1734
|
+
yield* service.materialize(taskId, phaseId, inspection.root, {
|
|
1735
|
+
...options,
|
|
1736
|
+
dryRun: true,
|
|
1737
|
+
silent: true,
|
|
1738
|
+
lockHeld: true,
|
|
1739
|
+
})
|
|
1740
|
+
if (options.dryRun) {
|
|
1741
|
+
return {
|
|
1742
|
+
operation: "rebuild",
|
|
1743
|
+
dryRun: true,
|
|
1744
|
+
inspection,
|
|
1745
|
+
actions: inspection.checkouts.flatMap((checkout) =>
|
|
1746
|
+
checkout.exists
|
|
1747
|
+
? [`remove ${checkout.path}`, `create ${checkout.path}`]
|
|
1748
|
+
: [`create ${checkout.path}`],
|
|
1749
|
+
),
|
|
1750
|
+
} satisfies WorktreeLifecycleResult
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
const snapshots: WorktreeRemovalSnapshot[] = []
|
|
1754
|
+
const removed = yield* service.remove(
|
|
1755
|
+
taskId,
|
|
1756
|
+
phaseId,
|
|
1757
|
+
inspection.root,
|
|
1758
|
+
{
|
|
1759
|
+
...options,
|
|
1760
|
+
snapshots,
|
|
1761
|
+
lockHeld: true,
|
|
1762
|
+
},
|
|
1763
|
+
)
|
|
1764
|
+
const workspace = yield* service
|
|
1765
|
+
.materialize(taskId, phaseId, inspection.root, {
|
|
1766
|
+
...options,
|
|
1767
|
+
lockHeld: true,
|
|
1768
|
+
})
|
|
1769
|
+
.pipe(
|
|
1770
|
+
Effect.catchAll((cause) =>
|
|
1771
|
+
Effect.gen(function* () {
|
|
1772
|
+
const rolledBack: string[] = []
|
|
1773
|
+
const manualRecovery: string[] = []
|
|
1774
|
+
for (const snapshot of [...snapshots].reverse()) {
|
|
1775
|
+
yield* fs.createDirectory(dirname(snapshot.path))
|
|
1776
|
+
const command = snapshot.branch
|
|
1777
|
+
? [
|
|
1778
|
+
"git",
|
|
1779
|
+
"-C",
|
|
1780
|
+
snapshot.repositoryPath,
|
|
1781
|
+
"worktree",
|
|
1782
|
+
"add",
|
|
1783
|
+
snapshot.path,
|
|
1784
|
+
snapshot.branch,
|
|
1785
|
+
]
|
|
1786
|
+
: [
|
|
1787
|
+
"git",
|
|
1788
|
+
"-C",
|
|
1789
|
+
snapshot.repositoryPath,
|
|
1790
|
+
"worktree",
|
|
1791
|
+
"add",
|
|
1792
|
+
"--detach",
|
|
1793
|
+
snapshot.path,
|
|
1794
|
+
snapshot.head,
|
|
1795
|
+
]
|
|
1796
|
+
const restored = yield* fs.runCommand(command, {
|
|
1797
|
+
captureOutput: true,
|
|
1798
|
+
})
|
|
1799
|
+
if (restored.exitCode === 0) rolledBack.push(snapshot.path)
|
|
1800
|
+
else manualRecovery.push(`Restore ${snapshot.path}`)
|
|
1801
|
+
}
|
|
1802
|
+
return yield* new WorktreeError({
|
|
1803
|
+
message: manualRecovery.length
|
|
1804
|
+
? "Worktree rebuild failed and requires manual recovery"
|
|
1805
|
+
: "Worktree rebuild failed; original worktrees were restored",
|
|
1806
|
+
completed: removed.map((path) => `remove ${path}`),
|
|
1807
|
+
rolledBack,
|
|
1808
|
+
manualRecovery,
|
|
1809
|
+
cause,
|
|
1810
|
+
})
|
|
1811
|
+
}),
|
|
1812
|
+
),
|
|
1813
|
+
)
|
|
1814
|
+
return {
|
|
1815
|
+
operation: "rebuild",
|
|
1816
|
+
dryRun: false,
|
|
1817
|
+
inspection: yield* inspectExecution(
|
|
1818
|
+
taskId,
|
|
1819
|
+
phaseId,
|
|
1820
|
+
inspection.root,
|
|
1821
|
+
),
|
|
1822
|
+
actions: [
|
|
1823
|
+
...removed.map((path) => `remove ${path}`),
|
|
1824
|
+
...workspace.operations.map(
|
|
1825
|
+
(operation) => `${operation.action} ${operation.repo}`,
|
|
1826
|
+
),
|
|
1827
|
+
],
|
|
1828
|
+
} satisfies WorktreeLifecycleResult
|
|
1829
|
+
}),
|
|
1830
|
+
|
|
1831
|
+
repair: (
|
|
1832
|
+
taskId: string,
|
|
1833
|
+
phaseId?: string,
|
|
1834
|
+
startPath: string = process.cwd(),
|
|
1835
|
+
options: LifecycleOptions = {},
|
|
1836
|
+
): Effect.Effect<WorktreeLifecycleResult, unknown, any> =>
|
|
1837
|
+
Effect.gen(function* () {
|
|
1838
|
+
const fs = yield* FileSystemService
|
|
1839
|
+
const workbase = yield* WorkbaseService
|
|
1840
|
+
const service = yield* WorktreeService
|
|
1841
|
+
const root = yield* workbase.discover(startPath)
|
|
1842
|
+
if (!options.lockHeld) {
|
|
1843
|
+
return yield* withWorktreeLocks(
|
|
1844
|
+
root,
|
|
1845
|
+
[{ taskId, ...(phaseId ? { phaseId } : {}) }],
|
|
1846
|
+
service.repair(taskId, phaseId, root, {
|
|
1847
|
+
...options,
|
|
1848
|
+
lockHeld: true,
|
|
1849
|
+
}),
|
|
1850
|
+
)
|
|
1851
|
+
}
|
|
1852
|
+
const inspection = yield* inspectExecution(taskId, phaseId, root)
|
|
1853
|
+
const safeKinds = new Set<WorktreeConflict["kind"]>([
|
|
1854
|
+
"stale-registration",
|
|
1855
|
+
"unregistered-checkout",
|
|
1856
|
+
])
|
|
1857
|
+
const unsafe = inspection.conflicts.filter(
|
|
1858
|
+
(conflict) => !safeKinds.has(conflict.kind),
|
|
1859
|
+
)
|
|
1860
|
+
if (unsafe.length > 0) {
|
|
1861
|
+
return yield* new WorktreeError({
|
|
1862
|
+
message: unsafe.map(({ message }) => message).join("\n"),
|
|
1863
|
+
conflicts: unsafe,
|
|
1864
|
+
})
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
const actions: string[] = []
|
|
1868
|
+
const completed: string[] = []
|
|
1869
|
+
for (const checkout of inspection.checkouts) {
|
|
1870
|
+
const repositoryPath = join(inspection.root, "repos", checkout.repo)
|
|
1871
|
+
const repairMovedCheckout = checkout.conflicts.some(
|
|
1872
|
+
(conflict) => conflict.kind === "unregistered-checkout",
|
|
1873
|
+
)
|
|
1874
|
+
for (const conflict of checkout.conflicts) {
|
|
1875
|
+
const command =
|
|
1876
|
+
conflict.kind === "stale-registration" &&
|
|
1877
|
+
conflict.registeredPath &&
|
|
1878
|
+
!repairMovedCheckout
|
|
1879
|
+
? [
|
|
1880
|
+
"git",
|
|
1881
|
+
"-C",
|
|
1882
|
+
repositoryPath,
|
|
1883
|
+
"worktree",
|
|
1884
|
+
"prune",
|
|
1885
|
+
"--expire",
|
|
1886
|
+
"now",
|
|
1887
|
+
]
|
|
1888
|
+
: conflict.kind === "unregistered-checkout"
|
|
1889
|
+
? [
|
|
1890
|
+
"git",
|
|
1891
|
+
"-C",
|
|
1892
|
+
repositoryPath,
|
|
1893
|
+
"worktree",
|
|
1894
|
+
"repair",
|
|
1895
|
+
checkout.path,
|
|
1896
|
+
]
|
|
1897
|
+
: null
|
|
1898
|
+
if (!command) continue
|
|
1899
|
+
actions.push(formatCommand(command))
|
|
1900
|
+
if (options.dryRun) continue
|
|
1901
|
+
if (
|
|
1902
|
+
conflict.kind === "stale-registration" &&
|
|
1903
|
+
conflict.registeredPath &&
|
|
1904
|
+
(yield* fs.exists(conflict.registeredPath))
|
|
1905
|
+
) {
|
|
1906
|
+
return yield* new WorktreeError({
|
|
1907
|
+
message: `Refusing to remove stale registration because ${conflict.registeredPath} now exists`,
|
|
1908
|
+
completed,
|
|
1909
|
+
manualRecovery: completed.length
|
|
1910
|
+
? [
|
|
1911
|
+
`Inspect and re-run worktree repair for ${inspection.codePath}`,
|
|
1912
|
+
]
|
|
1913
|
+
: [],
|
|
1914
|
+
})
|
|
1915
|
+
}
|
|
1916
|
+
const result = yield* fs.runCommand(command, {
|
|
1917
|
+
captureOutput: true,
|
|
1918
|
+
})
|
|
1919
|
+
if (result.exitCode !== 0) {
|
|
1920
|
+
return yield* new WorktreeError({
|
|
1921
|
+
message: `Failed to repair worktree for '${checkout.repo}': ${result.stderr}`,
|
|
1922
|
+
conflicts: checkout.conflicts,
|
|
1923
|
+
completed,
|
|
1924
|
+
manualRecovery: completed.length
|
|
1925
|
+
? [
|
|
1926
|
+
`Inspect and re-run worktree repair for ${inspection.codePath}`,
|
|
1927
|
+
]
|
|
1928
|
+
: [],
|
|
1929
|
+
})
|
|
1930
|
+
}
|
|
1931
|
+
completed.push(formatCommand(command))
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
if (options.dryRun) {
|
|
1935
|
+
actions.push(
|
|
1936
|
+
...inspection.checkouts
|
|
1937
|
+
.filter((checkout) => !checkout.exists)
|
|
1938
|
+
.map((checkout) => `prepare ${checkout.path}`),
|
|
1939
|
+
)
|
|
1940
|
+
return {
|
|
1941
|
+
operation: "repair",
|
|
1942
|
+
dryRun: true,
|
|
1943
|
+
inspection,
|
|
1944
|
+
actions,
|
|
1945
|
+
} satisfies WorktreeLifecycleResult
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
const repaired = yield* inspectExecution(
|
|
1949
|
+
taskId,
|
|
1950
|
+
phaseId,
|
|
1951
|
+
inspection.root,
|
|
1952
|
+
)
|
|
1953
|
+
if (repaired.conflicts.length > 0) {
|
|
1954
|
+
return yield* new WorktreeError({
|
|
1955
|
+
message: repaired.conflicts
|
|
1956
|
+
.map(({ message }) => message)
|
|
1957
|
+
.join("\n"),
|
|
1958
|
+
conflicts: repaired.conflicts,
|
|
1959
|
+
completed,
|
|
1960
|
+
manualRecovery: completed.length
|
|
1961
|
+
? [
|
|
1962
|
+
`Inspect and re-run worktree repair for ${inspection.codePath}`,
|
|
1963
|
+
]
|
|
1964
|
+
: [],
|
|
1965
|
+
})
|
|
1966
|
+
}
|
|
1967
|
+
|
|
1968
|
+
const current = yield* inspectExecution(
|
|
1969
|
+
taskId,
|
|
1970
|
+
phaseId,
|
|
1971
|
+
inspection.root,
|
|
1972
|
+
)
|
|
1973
|
+
if (current.checkouts.some((checkout) => !checkout.exists)) {
|
|
1974
|
+
const workspace = yield* service
|
|
1975
|
+
.materialize(taskId, phaseId, inspection.root, {
|
|
1976
|
+
...options,
|
|
1977
|
+
lockHeld: true,
|
|
1978
|
+
})
|
|
1979
|
+
.pipe(
|
|
1980
|
+
Effect.catchAll(
|
|
1981
|
+
(cause) =>
|
|
1982
|
+
new WorktreeError({
|
|
1983
|
+
message: cause.message,
|
|
1984
|
+
completed,
|
|
1985
|
+
manualRecovery: completed.length
|
|
1986
|
+
? [
|
|
1987
|
+
`Inspect and re-run worktree repair for ${inspection.codePath}`,
|
|
1988
|
+
]
|
|
1989
|
+
: [],
|
|
1990
|
+
cause,
|
|
1991
|
+
}),
|
|
1992
|
+
),
|
|
1993
|
+
)
|
|
1994
|
+
actions.push(
|
|
1995
|
+
...workspace.operations.map((operation) =>
|
|
1996
|
+
formatCommand(operation.command),
|
|
1997
|
+
),
|
|
1998
|
+
)
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
return {
|
|
2002
|
+
operation: "repair",
|
|
2003
|
+
dryRun: false,
|
|
2004
|
+
inspection: yield* inspectExecution(
|
|
2005
|
+
taskId,
|
|
2006
|
+
phaseId,
|
|
2007
|
+
inspection.root,
|
|
2008
|
+
),
|
|
2009
|
+
actions,
|
|
2010
|
+
} satisfies WorktreeLifecycleResult
|
|
2011
|
+
}),
|
|
1157
2012
|
}),
|
|
1158
2013
|
},
|
|
1159
2014
|
) {}
|