@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.
@@ -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
- return yield* withWorktreeLocks(
128
- root,
129
- [{ taskId, ...(phaseId ? { phaseId } : {}) }],
130
- Effect.gen(function* () {
131
- const report = yield* workbase.validate(root)
132
- const validationIssue = report.issues[0]
133
- if (validationIssue && !options.force) {
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: `${validationIssue.path}: ${validationIssue.message}`,
632
+ message: `Task '${taskId}' is single-phase and does not accept a phase ID`,
136
633
  })
137
634
  }
138
- const task = yield* tasks.show(taskId, root)
635
+ execution = task.data
636
+ codePath = join(dirname(task.path), "code")
637
+ }
139
638
 
140
- let execution: {
141
- repo: string
142
- repos?: readonly RepositoryReference[]
143
- branch: string
144
- base: string
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
- let phasePath: string | null = null
147
- let codePath: string
148
- if ("phases" in task.data) {
149
- if (!phaseId) {
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: `Task '${taskId}' has multiple phases; phase ID is required`,
714
+ message: `Branch '${checkout.branch}' for repository '${alias}' is already checked out at ${branchWorktree.path}`,
152
715
  })
153
716
  }
154
- const phase = yield* phases.show(taskId, phaseId, root)
155
- execution = phase.data
156
- phasePath = phase.path
157
- codePath = join(dirname(phase.path), "code")
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 (phaseId) {
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: `Task '${taskId}' is single-phase and does not accept a phase ID`,
826
+ message: `Reference '${checkout.ref}' for repository '${alias}' does not resolve to a commit`,
162
827
  })
163
828
  }
164
- execution = task.data
165
- codePath = join(dirname(task.path), "code")
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
- const requestedCheckouts: readonly (
169
- | { readonly repo: string; readonly branch: string }
170
- | RepositoryReference
171
- )[] = [
172
- { repo: execution.repo, branch: execution.branch },
173
- ...(execution.repos ?? []),
174
- ]
175
- const canonicalCodePath = (yield* fs.exists(codePath))
176
- ? yield* fs.realPath(codePath)
177
- : resolve(codePath)
178
- const preflightCommits = new Map<string, string>()
179
- const preexistingBranches = new Set<string>()
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
- const checkoutExists = yield* fs.isDirectory(checkoutPath)
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
- checkoutExists &&
248
- registeredAtPath?.branch !== branchRef
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 (!checkoutExists && registeredAtPath) {
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
- if (!checkoutExists && branchExists.exitCode !== 0) {
260
- const localBase = yield* fs.runCommand(
261
- [
262
- "git",
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
- } else {
314
- if (checkoutExists && !registeredAtPath) {
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
- "rev-parse",
1016
+ "show-ref",
352
1017
  "--verify",
353
- `${checkout.ref}^{commit}`,
1018
+ branchRef,
354
1019
  ],
355
1020
  { captureOutput: true },
356
1021
  )
357
- if (local.exitCode === 0) commit = local.stdout.trim()
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
- "fetch",
422
- "origin",
423
- ...(ref ? [ref] : []),
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: "fetch",
1032
+ action: "create-branch",
445
1033
  repo: alias,
446
1034
  command,
447
- status: "completed",
1035
+ status: options.dryRun ? "planned" : "completed",
448
1036
  })
449
- return true
450
- })
451
-
452
- const listed = yield* fs.runCommand(
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
- "list",
459
- "--porcelain",
460
- "-z",
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
- const canonicalCodePath = (yield* fs.exists(codePath))
470
- ? yield* fs.realPath(codePath)
471
- : resolve(codePath)
472
- const canonicalCheckoutPath = join(canonicalCodePath, alias)
473
- const worktrees: GitWorktree[] = []
474
- for (const worktree of parseWorktreeList(listed.stdout)) {
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
- const registeredAtPath = worktrees.find(
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
- "worktree",
592
- "add",
593
- checkoutPath,
594
- checkout.branch,
595
- ]
596
- }
597
- if (options.dryRun) {
598
- operations.push({
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
- `${checkout.branch}^{commit}`,
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
- head.exitCode === 0 ? head.stdout.trim() : null,
1099
+ resolved.exitCode === 0 ? resolved.stdout.trim() : null,
681
1100
  action: "created",
682
1101
  })
683
- } else {
684
- const fetched = isCommitId(checkout.ref)
685
- ? false
686
- : yield* fetchOrigin(originRef(checkout.ref))
687
- const resolvedRefName = fetched
688
- ? "FETCH_HEAD"
689
- : checkout.ref
690
- const resolvedRef = options.dryRun
691
- ? {
692
- exitCode: 0,
693
- stdout: preflightCommits.get(alias)!,
694
- stderr: "",
695
- }
696
- : yield* fs.runCommand(
697
- [
698
- "git",
699
- "-C",
700
- repositoryPath,
701
- "rev-parse",
702
- "--verify",
703
- `${resolvedRefName}^{commit}`,
704
- ],
705
- { captureOutput: true },
706
- )
707
- if (resolvedRef.exitCode !== 0) {
708
- return yield* new WorktreeError({
709
- message: `Reference '${checkout.ref}' for repository '${alias}' does not resolve to a commit`,
710
- })
711
- }
712
- const commit = resolvedRef.stdout.trim()
713
- if (yield* fs.isDirectory(checkoutPath)) {
714
- if (!registeredAtPath) {
715
- return yield* new WorktreeError({
716
- message: `Existing checkout ${checkoutPath} is not registered as a Git worktree`,
717
- })
718
- }
719
- if (registeredAtPath.branch) {
720
- return yield* new WorktreeError({
721
- message: `Reference checkout ${checkoutPath} is attached to branch '${registeredAtPath.branch.replace(/^refs\/heads\//, "")}'`,
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
- const currentHead = yield* fs.runCommand(
725
- ["git", "-C", checkoutPath, "rev-parse", "HEAD"],
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
- if (
729
- currentHead.exitCode === 0 &&
730
- currentHead.stdout.trim() === commit
731
- ) {
732
- checkoutReports.push({
733
- repo: alias,
734
- kind: "reference",
735
- path: checkoutPath,
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} does not match reference '${checkout.ref}' (${commit})`,
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: `Worktree registry contains a missing checkout at ${checkoutPath}`,
1182
+ message: `Reference checkout ${checkoutPath} is attached to branch '${registeredAtPath.branch.replace(/^refs\/heads\//, "")}'`,
749
1183
  })
750
1184
  }
751
- const command = [
752
- "git",
753
- "-C",
754
- repositoryPath,
755
- "worktree",
756
- "add",
757
- "--detach",
758
- checkoutPath,
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: "created",
1199
+ action: "reused",
775
1200
  })
776
1201
  continue
777
1202
  }
778
- const result = yield* fs.runCommand(command, {
779
- captureOutput: true,
1203
+ return yield* new WorktreeError({
1204
+ message: `Existing checkout ${checkoutPath} does not match reference '${checkout.ref}' (${commit})`,
780
1205
  })
781
- if (result.exitCode !== 0) {
782
- return yield* new WorktreeError({
783
- message: `Failed to create worktree for '${alias}': ${result.stderr}`,
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: "completed",
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
- return {
804
- root,
805
- taskPath: task.path,
806
- phasePath,
807
- codePath,
808
- writablePath: join(codePath, execution.repo),
809
- repo: execution.repo,
810
- repos: execution.repos ?? [],
811
- dryRun: options.dryRun === true,
812
- checkouts: checkoutReports,
813
- operations,
814
- } satisfies ExecutionWorkspace
815
- }).pipe(
816
- Effect.catchAll((cause) =>
817
- Effect.gen(function* () {
818
- if (options.dryRun) return yield* cause
819
- const completed = operations
820
- .filter((operation) => operation.status === "completed")
821
- .map(
822
- (operation) => `${operation.action} ${operation.repo}`,
823
- )
824
- const rolledBack: string[] = []
825
- const manualRecovery = operations
826
- .filter(
827
- (operation) =>
828
- operation.action === "fetch" &&
829
- operation.status === "completed",
830
- )
831
- .map(
832
- (operation) =>
833
- `Review fetched refs for repository '${operation.repo}'`,
834
- )
835
- for (const checkout of [...checkouts].reverse()) {
836
- const checkoutPath = join(codePath, checkout.repo)
837
- if (
838
- preexistingPaths.has(checkoutPath) ||
839
- !(yield* fs.isDirectory(checkoutPath))
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
- ...createdBranches,
861
- ...checkouts
862
- .filter(
863
- (
864
- checkout,
865
- ): checkout is {
866
- repo: string
867
- branch: string
868
- } => "branch" in checkout,
869
- )
870
- .filter(
871
- (checkout) =>
872
- !preexistingBranches.has(
873
- `${checkout.repo}:${checkout.branch}`,
874
- ),
875
- ),
876
- ].map((branch) => [
877
- `${branch.repo}:${branch.branch}`,
878
- branch,
879
- ]),
880
- ).values()
881
- for (const branch of branchCandidates) {
882
- const deleted = yield* fs.runCommand(
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
- if (
901
- (yield* fs.isDirectory(codePath)) &&
902
- (yield* fs.readDirectory(codePath)).length === 0
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
- yield* fs.deleteDirectory(codePath)
905
- return yield* new WorktreeError({
906
- message: `${cause.message}. ${
907
- manualRecovery.length
908
- ? "Some effects require manual recovery"
909
- : "Created worktrees and branches were rolled back"
910
- }`,
911
- completed,
912
- rolledBack,
913
- manualRecovery,
914
- cause,
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 expectedAliases = [
974
- execution.repo,
975
- ...(execution.repos ?? []).map((reference) => reference.repo),
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 alias of [...expectedAliases]) {
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
- .filter((plan) => plan.checkoutExists)
1067
- .map((plan) => plan.checkoutPath)
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 result = yield* fs.runCommand(
1074
- [
1075
- "git",
1076
- "-C",
1077
- plan.repositoryPath,
1078
- "worktree",
1079
- "remove",
1080
- ...(!plan.checkoutExists ? ["--force"] : []),
1081
- plan.checkoutExists
1082
- ? plan.checkoutPath
1083
- : plan.registeredPath,
1084
- ],
1085
- { captureOutput: true },
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 && (yield* fs.isDirectory(codePath))) {
1095
- yield* fs.deleteDirectory(codePath)
1096
- }
1097
- return removalPlans
1098
- .filter((plan) => plan.checkoutExists)
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) continue
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
  ) {}