@markjaquith/agency 2.23.0 → 2.25.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.
@@ -11,9 +11,14 @@ import {
11
11
  import type { RepositoryReference } from "../workbase/schemas"
12
12
  import type { BaseCommandOptions } from "../utils/command"
13
13
  import { createLoggers } from "../utils/effect"
14
+ import { withWorktreeLocks } from "./WorktreeLock"
14
15
 
15
16
  class WorktreeError extends Data.TaggedError("WorktreeError")<{
16
17
  readonly message: string
18
+ readonly completed?: readonly string[]
19
+ readonly rolledBack?: readonly string[]
20
+ readonly manualRecovery?: readonly string[]
21
+ readonly cause?: unknown
17
22
  }> {}
18
23
 
19
24
  interface WorkspaceOperation {
@@ -51,6 +56,13 @@ interface GitWorktree {
51
56
  readonly branch?: string
52
57
  }
53
58
 
59
+ export interface WorktreeRemovalSnapshot {
60
+ readonly path: string
61
+ readonly repositoryPath: string
62
+ readonly head: string
63
+ readonly branch?: string
64
+ }
65
+
54
66
  const parseWorktreeList = (output: string): readonly GitWorktree[] => {
55
67
  const worktrees: GitWorktree[] = []
56
68
  let current: { path: string; head?: string; branch?: string } | undefined
@@ -88,6 +100,11 @@ interface MaterializeOptions extends BaseCommandOptions {
88
100
  readonly force?: boolean
89
101
  }
90
102
 
103
+ interface RemoveOptions extends BaseCommandOptions {
104
+ readonly snapshots?: WorktreeRemovalSnapshot[]
105
+ readonly lockHeld?: boolean
106
+ }
107
+
91
108
  export class WorktreeService extends Effect.Service<WorktreeService>()(
92
109
  "WorktreeService",
93
110
  {
@@ -107,573 +124,1035 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
107
124
  const forwardCommandOutput =
108
125
  options.verbose === true && !options.silent && !options.json
109
126
  const { root, config } = yield* workbase.loadConfig(startPath)
110
- const report = yield* workbase.validate(root)
111
- const validationIssue = report.issues[0]
112
- if (validationIssue && !options.force) {
113
- return yield* new WorktreeError({
114
- message: `${validationIssue.path}: ${validationIssue.message}`,
115
- })
116
- }
117
- const task = yield* tasks.show(taskId, root)
118
-
119
- let execution: {
120
- repo: string
121
- repos?: readonly RepositoryReference[]
122
- branch: string
123
- base: string
124
- }
125
- let phasePath: string | null = null
126
- let codePath: string
127
- if ("phases" in task.data) {
128
- if (!phaseId) {
129
- return yield* new WorktreeError({
130
- message: `Task '${taskId}' has multiple phases; phase ID is required`,
131
- })
132
- }
133
- const phase = yield* phases.show(taskId, phaseId, root)
134
- execution = phase.data
135
- phasePath = phase.path
136
- codePath = join(dirname(phase.path), "code")
137
- } else {
138
- if (phaseId) {
139
- return yield* new WorktreeError({
140
- message: `Task '${taskId}' is single-phase and does not accept a phase ID`,
141
- })
142
- }
143
- execution = task.data
144
- codePath = join(dirname(task.path), "code")
145
- }
146
-
147
- if (!options.dryRun) yield* fs.createDirectory(codePath)
148
- const operations: WorkspaceOperation[] = []
149
- const checkoutReports: WorkspaceCheckout[] = []
150
- const checkouts: readonly (
151
- | { readonly repo: string; readonly branch: string }
152
- | RepositoryReference
153
- )[] = [
154
- { repo: execution.repo, branch: execution.branch },
155
- ...(execution.repos ?? []),
156
- ]
157
- for (const checkout of checkouts) {
158
- const alias = checkout.repo
159
- const repositoryPath = join(root, "repos", alias)
160
- const checkoutPath = join(codePath, alias)
161
- if (!(yield* fs.exists(repositoryPath))) {
162
- return yield* new WorktreeError({
163
- message: `Repository alias '${alias}' does not exist`,
164
- })
165
- }
166
-
167
- const fetchOrigin = (ref?: string) =>
168
- Effect.gen(function* () {
169
- const remote = yield* fs.runCommand(
170
- ["git", "-C", repositoryPath, "remote", "get-url", "origin"],
171
- { captureOutput: true },
172
- )
173
- if (remote.exitCode !== 0) return false
174
- const command = [
175
- "git",
176
- "-C",
177
- repositoryPath,
178
- "fetch",
179
- "origin",
180
- ...(ref ? [ref] : []),
181
- ]
182
- if (options.dryRun) {
183
- operations.push({
184
- action: "fetch",
185
- repo: alias,
186
- command,
187
- status: "planned",
188
- })
189
- return false
190
- }
191
-
192
- const fetch = yield* fs.runCommand(command, {
193
- captureOutput: true,
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) {
134
+ return yield* new WorktreeError({
135
+ message: `${validationIssue.path}: ${validationIssue.message}`,
194
136
  })
195
- if (fetch.exitCode !== 0) {
137
+ }
138
+ const task = yield* tasks.show(taskId, root)
139
+
140
+ let execution: {
141
+ repo: string
142
+ repos?: readonly RepositoryReference[]
143
+ branch: string
144
+ base: string
145
+ }
146
+ let phasePath: string | null = null
147
+ let codePath: string
148
+ if ("phases" in task.data) {
149
+ if (!phaseId) {
196
150
  return yield* new WorktreeError({
197
- message: `Failed to fetch '${alias}': ${fetch.stderr}`,
151
+ message: `Task '${taskId}' has multiple phases; phase ID is required`,
198
152
  })
199
153
  }
200
- operations.push({
201
- action: "fetch",
202
- repo: alias,
203
- command,
204
- status: "completed",
205
- })
206
- return true
207
- })
208
-
209
- const listed = yield* fs.runCommand(
210
- [
211
- "git",
212
- "-C",
213
- repositoryPath,
214
- "worktree",
215
- "list",
216
- "--porcelain",
217
- "-z",
218
- ],
219
- { captureOutput: true },
220
- )
221
- if (listed.exitCode !== 0) {
222
- return yield* new WorktreeError({
223
- message: `Failed to inspect worktrees for '${alias}': ${listed.stderr}`,
224
- })
225
- }
226
- const canonicalCodePath = (yield* fs.exists(codePath))
227
- ? yield* fs.realPath(codePath)
228
- : resolve(codePath)
229
- const canonicalCheckoutPath = join(canonicalCodePath, alias)
230
- const worktrees: GitWorktree[] = []
231
- for (const worktree of parseWorktreeList(listed.stdout)) {
232
- worktrees.push({
233
- ...worktree,
234
- path: (yield* fs.exists(worktree.path))
235
- ? yield* fs.realPath(worktree.path)
236
- : resolve(worktree.path),
237
- })
238
- }
239
- const registeredAtPath = worktrees.find(
240
- (worktree) => worktree.path === canonicalCheckoutPath,
241
- )
242
-
243
- if ("branch" in checkout) {
244
- const branchRef = `refs/heads/${checkout.branch}`
245
- const branchWorktree = worktrees.find(
246
- (worktree) => worktree.branch === branchRef,
247
- )
248
- if (
249
- branchWorktree &&
250
- branchWorktree.path !== canonicalCheckoutPath
251
- ) {
252
- return yield* new WorktreeError({
253
- message: `Branch '${checkout.branch}' for repository '${alias}' is already checked out at ${branchWorktree.path}`,
254
- })
255
- }
256
- if (yield* fs.isDirectory(checkoutPath)) {
257
- if (registeredAtPath?.branch === branchRef) {
258
- checkoutReports.push({
259
- repo: alias,
260
- kind: "writable",
261
- path: checkoutPath,
262
- requestedRef: checkout.branch,
263
- resolvedCommit: registeredAtPath.head ?? null,
264
- action: "reused",
154
+ const phase = yield* phases.show(taskId, phaseId, root)
155
+ execution = phase.data
156
+ phasePath = phase.path
157
+ codePath = join(dirname(phase.path), "code")
158
+ } else {
159
+ if (phaseId) {
160
+ return yield* new WorktreeError({
161
+ message: `Task '${taskId}' is single-phase and does not accept a phase ID`,
265
162
  })
266
- continue
267
163
  }
268
- return yield* new WorktreeError({
269
- message: `Existing checkout ${checkoutPath} is not registered to branch '${checkout.branch}'`,
270
- })
164
+ execution = task.data
165
+ codePath = join(dirname(task.path), "code")
271
166
  }
272
- if (registeredAtPath) {
273
- return yield* new WorktreeError({
274
- message: `Worktree registry contains a missing checkout at ${checkoutPath}`,
275
- })
276
- }
277
- yield* fetchOrigin()
278
167
 
279
- let args: string[]
280
- let env: Record<string, string> | undefined
281
- if (config.worktreeCreateCommand) {
282
- const variables = {
283
- repo: repositoryPath,
284
- worktree: checkoutPath,
285
- branch: checkout.branch,
286
- base: execution.base,
287
- }
288
- try {
289
- args = expandWorktreeCreateCommand(
290
- config.worktreeCreateCommand,
291
- variables,
292
- )
293
- } catch (cause) {
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) {
181
+ const alias = checkout.repo
182
+ const repositoryPath = join(root, "repos", alias)
183
+ const checkoutPath = join(codePath, alias)
184
+ if (!(yield* fs.exists(repositoryPath))) {
294
185
  return yield* new WorktreeError({
295
- message:
296
- cause instanceof Error
297
- ? cause.message
298
- : "Invalid worktreeCreateCommand",
186
+ message: `Repository alias '${alias}' does not exist`,
299
187
  })
300
188
  }
301
- env = worktreeCommandEnvironment(variables)
302
- } else {
303
- const branchExists = yield* fs.runCommand(
189
+ const listed = yield* fs.runCommand(
304
190
  [
305
191
  "git",
306
192
  "-C",
307
193
  repositoryPath,
308
- "show-ref",
309
- "--verify",
310
- branchRef,
194
+ "worktree",
195
+ "list",
196
+ "--porcelain",
197
+ "-z",
311
198
  ],
312
199
  { captureOutput: true },
313
200
  )
314
- if (branchExists.exitCode !== 0) {
315
- const command = [
316
- "git",
317
- "-C",
318
- repositoryPath,
319
- "branch",
320
- checkout.branch,
321
- execution.base,
322
- ]
323
- operations.push({
324
- action: "create-branch",
325
- repo: alias,
326
- command,
327
- status: options.dryRun ? "planned" : "completed",
201
+ if (listed.exitCode !== 0) {
202
+ return yield* new WorktreeError({
203
+ message: `Failed to inspect worktrees for '${alias}': ${listed.stderr}`,
328
204
  })
329
- if (!options.dryRun) {
330
- const createBranch = yield* fs.runCommand(command, {
331
- captureOutput: true,
205
+ }
206
+ const canonicalCheckoutPath = join(canonicalCodePath, alias)
207
+ const worktrees: GitWorktree[] = []
208
+ for (const worktree of parseWorktreeList(listed.stdout)) {
209
+ worktrees.push({
210
+ ...worktree,
211
+ path: (yield* fs.exists(worktree.path))
212
+ ? yield* fs.realPath(worktree.path)
213
+ : resolve(worktree.path),
214
+ })
215
+ }
216
+ const registeredAtPath = worktrees.find(
217
+ (worktree) => worktree.path === canonicalCheckoutPath,
218
+ )
219
+ const checkoutExists = yield* fs.isDirectory(checkoutPath)
220
+ if ("branch" in checkout) {
221
+ 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
+ const branchWorktree = worktrees.find(
236
+ (worktree) => worktree.branch === branchRef,
237
+ )
238
+ if (
239
+ branchWorktree &&
240
+ branchWorktree.path !== canonicalCheckoutPath
241
+ ) {
242
+ return yield* new WorktreeError({
243
+ message: `Branch '${checkout.branch}' for repository '${alias}' is already checked out at ${branchWorktree.path}`,
244
+ })
245
+ }
246
+ if (
247
+ checkoutExists &&
248
+ registeredAtPath?.branch !== branchRef
249
+ ) {
250
+ return yield* new WorktreeError({
251
+ message: `Existing checkout ${checkoutPath} is not registered to branch '${checkout.branch}'`,
252
+ })
253
+ }
254
+ if (!checkoutExists && registeredAtPath) {
255
+ return yield* new WorktreeError({
256
+ message: `Worktree registry contains a missing checkout at ${checkoutPath}`,
257
+ })
258
+ }
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
+ }
293
+ if (config.worktreeCreateCommand) {
294
+ try {
295
+ expandWorktreeCreateCommand(
296
+ config.worktreeCreateCommand,
297
+ {
298
+ repo: repositoryPath,
299
+ worktree: checkoutPath,
300
+ branch: checkout.branch,
301
+ base: execution.base,
302
+ },
303
+ )
304
+ } catch (cause) {
305
+ return yield* new WorktreeError({
306
+ message:
307
+ cause instanceof Error
308
+ ? cause.message
309
+ : "Invalid worktreeCreateCommand",
310
+ })
311
+ }
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}`,
332
327
  })
333
- if (createBranch.exitCode !== 0) {
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(
347
+ [
348
+ "git",
349
+ "-C",
350
+ repositoryPath,
351
+ "rev-parse",
352
+ "--verify",
353
+ `${checkout.ref}^{commit}`,
354
+ ],
355
+ { captureOutput: true },
356
+ )
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
+ ) {
334
374
  return yield* new WorktreeError({
335
- message: `Failed to create branch '${checkout.branch}': ${createBranch.stderr}`,
375
+ message: `Existing checkout ${checkoutPath} does not match reference '${checkout.ref}' (${commit})`,
336
376
  })
337
377
  }
338
378
  }
339
379
  }
340
- args = [
341
- "git",
342
- "-C",
343
- repositoryPath,
344
- "worktree",
345
- "add",
346
- checkoutPath,
347
- checkout.branch,
348
- ]
349
380
  }
350
- if (options.dryRun) {
351
- operations.push({
352
- action: "create-worktree",
353
- repo: alias,
354
- command: args,
355
- status: "planned",
356
- })
357
- let resolved = yield* fs.runCommand(
358
- [
359
- "git",
360
- "-C",
361
- repositoryPath,
362
- "rev-parse",
363
- "--verify",
364
- `${checkout.branch}^{commit}`,
365
- ],
366
- { captureOutput: true },
367
- )
368
- if (resolved.exitCode !== 0) {
369
- resolved = yield* fs.runCommand(
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
417
+ const command = [
418
+ "git",
419
+ "-C",
420
+ repositoryPath,
421
+ "fetch",
422
+ "origin",
423
+ ...(ref ? [ref] : []),
424
+ ]
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
+ operations.push({
444
+ action: "fetch",
445
+ repo: alias,
446
+ command,
447
+ status: "completed",
448
+ })
449
+ return true
450
+ })
451
+
452
+ const listed = yield* fs.runCommand(
370
453
  [
371
454
  "git",
372
455
  "-C",
373
456
  repositoryPath,
374
- "rev-parse",
375
- "--verify",
376
- `${execution.base}^{commit}`,
457
+ "worktree",
458
+ "list",
459
+ "--porcelain",
460
+ "-z",
377
461
  ],
378
462
  { captureOutput: true },
379
463
  )
464
+ if (listed.exitCode !== 0) {
465
+ return yield* new WorktreeError({
466
+ message: `Failed to inspect worktrees for '${alias}': ${listed.stderr}`,
467
+ })
468
+ }
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),
480
+ })
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 = [
588
+ "git",
589
+ "-C",
590
+ 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(
605
+ [
606
+ "git",
607
+ "-C",
608
+ repositoryPath,
609
+ "rev-parse",
610
+ "--verify",
611
+ `${checkout.branch}^{commit}`,
612
+ ],
613
+ { captureOutput: true },
614
+ )
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
+ }
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
+ checkoutReports.push({
675
+ repo: alias,
676
+ kind: "writable",
677
+ path: checkoutPath,
678
+ requestedRef: checkout.branch,
679
+ resolvedCommit:
680
+ head.exitCode === 0 ? head.stdout.trim() : null,
681
+ action: "created",
682
+ })
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
+ })
723
+ }
724
+ const currentHead = yield* fs.runCommand(
725
+ ["git", "-C", checkoutPath, "rev-parse", "HEAD"],
726
+ { captureOutput: true },
727
+ )
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
+ }
742
+ return yield* new WorktreeError({
743
+ message: `Existing checkout ${checkoutPath} does not match reference '${checkout.ref}' (${commit})`,
744
+ })
745
+ }
746
+ if (registeredAtPath) {
747
+ return yield* new WorktreeError({
748
+ message: `Worktree registry contains a missing checkout at ${checkoutPath}`,
749
+ })
750
+ }
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
+ })
768
+ checkoutReports.push({
769
+ repo: alias,
770
+ kind: "reference",
771
+ path: checkoutPath,
772
+ requestedRef: checkout.ref,
773
+ resolvedCommit: commit,
774
+ action: "created",
775
+ })
776
+ continue
777
+ }
778
+ const result = yield* fs.runCommand(command, {
779
+ captureOutput: true,
780
+ })
781
+ if (result.exitCode !== 0) {
782
+ return yield* new WorktreeError({
783
+ message: `Failed to create worktree for '${alias}': ${result.stderr}`,
784
+ })
785
+ }
786
+ operations.push({
787
+ action: "create-worktree",
788
+ repo: alias,
789
+ command,
790
+ status: "completed",
791
+ })
792
+ checkoutReports.push({
793
+ repo: alias,
794
+ kind: "reference",
795
+ path: checkoutPath,
796
+ requestedRef: checkout.ref,
797
+ resolvedCommit: commit,
798
+ action: "created",
799
+ })
800
+ }
380
801
  }
381
- checkoutReports.push({
382
- repo: alias,
383
- kind: "writable",
384
- path: checkoutPath,
385
- requestedRef: checkout.branch,
386
- resolvedCommit:
387
- resolved.exitCode === 0 ? resolved.stdout.trim() : null,
388
- action: "created",
389
- })
390
- continue
391
- }
392
802
 
393
- if (config.worktreeCreateCommand) {
394
- verboseLog(`Running worktree command: ${formatCommand(args)}`)
395
- }
396
- const result = yield* fs.runCommand(args, {
397
- cwd: repositoryPath,
398
- captureOutput: true,
399
- forwardOutput:
400
- config.worktreeCreateCommand && forwardCommandOutput,
401
- env,
402
- })
403
- if (result.exitCode !== 0) {
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(
859
+ [
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}'`,
898
+ )
899
+ }
900
+ if (
901
+ (yield* fs.isDirectory(codePath)) &&
902
+ (yield* fs.readDirectory(codePath)).length === 0
903
+ )
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
+ ),
918
+ )
919
+ return materialized
920
+ }),
921
+ )
922
+ }),
923
+
924
+ remove: (
925
+ taskId: string,
926
+ phaseId?: string,
927
+ startPath: string = process.cwd(),
928
+ options: RemoveOptions = {},
929
+ ) =>
930
+ Effect.gen(function* () {
931
+ const fs = yield* FileSystemService
932
+ const workbase = yield* WorkbaseService
933
+ const tasks = yield* TaskService
934
+ const phases = yield* PhaseService
935
+ const root = yield* workbase.discover(startPath)
936
+ const removal = Effect.gen(function* () {
937
+ const task = yield* tasks.show(taskId, root)
938
+
939
+ let execution: {
940
+ repo: string
941
+ repos?: readonly RepositoryReference[]
942
+ }
943
+ let codePath: string
944
+ if ("phases" in task.data) {
945
+ if (!phaseId) {
404
946
  return yield* new WorktreeError({
405
- message: `Failed to create worktree for '${alias}': ${result.stderr}`,
947
+ message: `Task '${taskId}' has multiple phases; phase ID is required`,
406
948
  })
407
949
  }
408
- if (!(yield* fs.isDirectory(checkoutPath))) {
950
+ const phase = yield* phases.show(taskId, phaseId, root)
951
+ execution = phase.data
952
+ codePath = join(dirname(phase.path), "code")
953
+ } else {
954
+ if (phaseId) {
409
955
  return yield* new WorktreeError({
410
- message: `Worktree command did not create ${checkoutPath}`,
956
+ message: `Task '${taskId}' is single-phase and does not accept a phase ID`,
411
957
  })
412
958
  }
413
- operations.push({
414
- action: "create-worktree",
415
- repo: alias,
416
- command: args,
417
- status: "completed",
418
- })
419
- const head = yield* fs.runCommand(
420
- ["git", "-C", checkoutPath, "rev-parse", "HEAD"],
421
- { captureOutput: true },
959
+ execution = task.data
960
+ codePath = join(dirname(task.path), "code")
961
+ }
962
+
963
+ const codeDirectoryExists = yield* fs.isDirectory(codePath)
964
+ const removalPlans: {
965
+ alias: string
966
+ repositoryPath: string
967
+ checkoutPath: string
968
+ registeredPath: string
969
+ checkoutExists: boolean
970
+ head?: string
971
+ branch?: string
972
+ }[] = []
973
+ const expectedAliases = [
974
+ execution.repo,
975
+ ...(execution.repos ?? []).map((reference) => reference.repo),
976
+ ]
977
+ if (codeDirectoryExists) {
978
+ const unmanaged = (yield* fs.readDirectory(codePath)).filter(
979
+ (entry) => !expectedAliases.includes(entry.name),
422
980
  )
423
- checkoutReports.push({
424
- repo: alias,
425
- kind: "writable",
426
- path: checkoutPath,
427
- requestedRef: checkout.branch,
428
- resolvedCommit: head.exitCode === 0 ? head.stdout.trim() : null,
429
- action: "created",
430
- })
431
- } else {
432
- const fetched = isCommitId(checkout.ref)
433
- ? false
434
- : yield* fetchOrigin(originRef(checkout.ref))
435
- const resolvedRefName = fetched ? "FETCH_HEAD" : checkout.ref
436
- const resolvedRef = yield* fs.runCommand(
981
+ if (unmanaged.length > 0) {
982
+ return yield* new WorktreeError({
983
+ message: `Cannot remove ${codePath}; it contains unmanaged entries: ${unmanaged.map((entry) => entry.name).join(", ")}`,
984
+ })
985
+ }
986
+ }
987
+ for (const alias of [...expectedAliases]) {
988
+ const repositoryPath = join(root, "repos", alias)
989
+ const checkoutPath = join(codePath, alias)
990
+ const listed = yield* fs.runCommand(
437
991
  [
438
992
  "git",
439
993
  "-C",
440
994
  repositoryPath,
441
- "rev-parse",
442
- "--verify",
443
- `${resolvedRefName}^{commit}`,
995
+ "worktree",
996
+ "list",
997
+ "--porcelain",
998
+ "-z",
444
999
  ],
445
1000
  { captureOutput: true },
446
1001
  )
447
- if (resolvedRef.exitCode !== 0) {
1002
+ if (listed.exitCode !== 0) {
448
1003
  return yield* new WorktreeError({
449
- message: `Reference '${checkout.ref}' for repository '${alias}' does not resolve to a commit`,
1004
+ message: `Failed to inspect worktrees for '${alias}': ${listed.stderr}`,
450
1005
  })
451
1006
  }
452
- const commit = resolvedRef.stdout.trim()
453
- if (yield* fs.isDirectory(checkoutPath)) {
454
- if (!registeredAtPath) {
455
- return yield* new WorktreeError({
456
- message: `Existing checkout ${checkoutPath} is not registered as a Git worktree`,
457
- })
1007
+
1008
+ const checkoutExists = yield* fs.isDirectory(checkoutPath)
1009
+ const canonicalCheckoutPath = checkoutExists
1010
+ ? yield* fs.realPath(checkoutPath)
1011
+ : join(
1012
+ yield* fs.realPath(dirname(codePath)),
1013
+ basename(codePath),
1014
+ alias,
1015
+ )
1016
+ let registered: GitWorktree | undefined
1017
+ for (const worktree of parseWorktreeList(listed.stdout)) {
1018
+ const worktreePath = (yield* fs.exists(worktree.path))
1019
+ ? yield* fs.realPath(worktree.path)
1020
+ : resolve(worktree.path)
1021
+ if (worktreePath === canonicalCheckoutPath) {
1022
+ registered = { ...worktree, path: worktreePath }
1023
+ break
458
1024
  }
459
- if (registeredAtPath.branch) {
1025
+ }
1026
+ if (!registered) {
1027
+ if (checkoutExists) {
460
1028
  return yield* new WorktreeError({
461
- message: `Reference checkout ${checkoutPath} is attached to branch '${registeredAtPath.branch.replace(/^refs\/heads\//, "")}'`,
1029
+ message: `Existing checkout ${checkoutPath} is not registered as a Git worktree`,
462
1030
  })
463
1031
  }
464
- const currentHead = yield* fs.runCommand(
465
- ["git", "-C", checkoutPath, "rev-parse", "HEAD"],
1032
+ continue
1033
+ }
1034
+ if (checkoutExists) {
1035
+ const status = yield* fs.runCommand(
1036
+ ["git", "-C", checkoutPath, "status", "--porcelain"],
466
1037
  { captureOutput: true },
467
1038
  )
468
- if (
469
- currentHead.exitCode === 0 &&
470
- currentHead.stdout.trim() === commit
471
- ) {
472
- checkoutReports.push({
473
- repo: alias,
474
- kind: "reference",
475
- path: checkoutPath,
476
- requestedRef: checkout.ref,
477
- resolvedCommit: commit,
478
- action: "reused",
1039
+ if (status.exitCode !== 0 || status.stdout.trim()) {
1040
+ return yield* new WorktreeError({
1041
+ message: `Failed to remove worktree for '${alias}': checkout has uncommitted changes`,
479
1042
  })
480
- continue
481
1043
  }
482
- return yield* new WorktreeError({
483
- message: `Existing checkout ${checkoutPath} does not match reference '${checkout.ref}' (${commit})`,
484
- })
485
1044
  }
486
- if (registeredAtPath) {
487
- return yield* new WorktreeError({
488
- message: `Worktree registry contains a missing checkout at ${checkoutPath}`,
489
- })
490
- }
491
- const command = [
492
- "git",
493
- "-C",
1045
+ removalPlans.push({
1046
+ alias,
494
1047
  repositoryPath,
495
- "worktree",
496
- "add",
497
- "--detach",
498
1048
  checkoutPath,
499
- commit,
500
- ]
501
- if (options.dryRun) {
502
- operations.push({
503
- action: "create-worktree",
504
- repo: alias,
505
- command,
506
- status: "planned",
507
- })
508
- checkoutReports.push({
509
- repo: alias,
510
- kind: "reference",
511
- path: checkoutPath,
512
- requestedRef: checkout.ref,
513
- resolvedCommit: commit,
514
- action: "created",
515
- })
516
- continue
517
- }
518
- const result = yield* fs.runCommand(command, {
519
- captureOutput: true,
520
- })
521
- if (result.exitCode !== 0) {
522
- return yield* new WorktreeError({
523
- message: `Failed to create worktree for '${alias}': ${result.stderr}`,
524
- })
525
- }
526
- operations.push({
527
- action: "create-worktree",
528
- repo: alias,
529
- command,
530
- status: "completed",
531
- })
532
- checkoutReports.push({
533
- repo: alias,
534
- kind: "reference",
535
- path: checkoutPath,
536
- requestedRef: checkout.ref,
537
- resolvedCommit: commit,
538
- action: "created",
539
- })
540
- }
541
- }
542
-
543
- return {
544
- root,
545
- taskPath: task.path,
546
- phasePath,
547
- codePath,
548
- writablePath: join(codePath, execution.repo),
549
- repo: execution.repo,
550
- repos: execution.repos ?? [],
551
- dryRun: options.dryRun === true,
552
- checkouts: checkoutReports,
553
- operations,
554
- } satisfies ExecutionWorkspace
555
- }),
556
-
557
- remove: (
558
- taskId: string,
559
- phaseId?: string,
560
- startPath: string = process.cwd(),
561
- ) =>
562
- Effect.gen(function* () {
563
- const fs = yield* FileSystemService
564
- const workbase = yield* WorkbaseService
565
- const tasks = yield* TaskService
566
- const phases = yield* PhaseService
567
- const root = yield* workbase.discover(startPath)
568
- const task = yield* tasks.show(taskId, root)
569
-
570
- let execution: {
571
- repo: string
572
- repos?: readonly RepositoryReference[]
573
- }
574
- let codePath: string
575
- if ("phases" in task.data) {
576
- if (!phaseId) {
577
- return yield* new WorktreeError({
578
- message: `Task '${taskId}' has multiple phases; phase ID is required`,
1049
+ registeredPath: registered.path,
1050
+ checkoutExists,
1051
+ head: registered.head,
1052
+ branch: registered.branch?.replace(/^refs\/heads\//, ""),
579
1053
  })
580
1054
  }
581
- const phase = yield* phases.show(taskId, phaseId, root)
582
- execution = phase.data
583
- codePath = join(dirname(phase.path), "code")
584
- } else {
585
- if (phaseId) {
586
- return yield* new WorktreeError({
587
- message: `Task '${taskId}' is single-phase and does not accept a phase ID`,
1055
+ for (const plan of removalPlans) {
1056
+ if (!plan.checkoutExists || !plan.head) continue
1057
+ options.snapshots?.push({
1058
+ path: plan.checkoutPath,
1059
+ repositoryPath: plan.repositoryPath,
1060
+ head: plan.head,
1061
+ ...(plan.branch ? { branch: plan.branch } : {}),
588
1062
  })
589
1063
  }
590
- execution = task.data
591
- codePath = join(dirname(task.path), "code")
592
- }
593
-
594
- const codeDirectoryExists = yield* fs.isDirectory(codePath)
595
- const removed: string[] = []
596
- for (const alias of [
597
- execution.repo,
598
- ...(execution.repos ?? []).map((reference) => reference.repo),
599
- ]) {
600
- const repositoryPath = join(root, "repos", alias)
601
- const checkoutPath = join(codePath, alias)
602
- const listed = yield* fs.runCommand(
603
- [
604
- "git",
605
- "-C",
606
- repositoryPath,
607
- "worktree",
608
- "list",
609
- "--porcelain",
610
- "-z",
611
- ],
612
- { captureOutput: true },
613
- )
614
- if (listed.exitCode !== 0) {
615
- return yield* new WorktreeError({
616
- message: `Failed to inspect worktrees for '${alias}': ${listed.stderr}`,
617
- })
1064
+ if (options.dryRun) {
1065
+ return removalPlans
1066
+ .filter((plan) => plan.checkoutExists)
1067
+ .map((plan) => plan.checkoutPath)
618
1068
  }
619
1069
 
620
- const checkoutExists = yield* fs.isDirectory(checkoutPath)
621
- const canonicalCheckoutPath = checkoutExists
622
- ? yield* fs.realPath(checkoutPath)
623
- : join(
624
- yield* fs.realPath(dirname(codePath)),
625
- basename(codePath),
626
- alias,
1070
+ const completed: typeof removalPlans = []
1071
+ const removed = yield* Effect.gen(function* () {
1072
+ 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 },
627
1086
  )
628
- let registeredPath: string | undefined
629
- for (const worktree of parseWorktreeList(listed.stdout)) {
630
- const worktreePath = (yield* fs.exists(worktree.path))
631
- ? yield* fs.realPath(worktree.path)
632
- : resolve(worktree.path)
633
- if (worktreePath === canonicalCheckoutPath) {
634
- registeredPath = worktreePath
635
- break
1087
+ if (result.exitCode !== 0) {
1088
+ return yield* new WorktreeError({
1089
+ message: `Failed to remove worktree for '${plan.alias}': ${result.stderr}`,
1090
+ })
1091
+ }
1092
+ completed.push(plan)
636
1093
  }
637
- }
638
- if (!registeredPath) {
639
- if (checkoutExists) {
640
- return yield* new WorktreeError({
641
- message: `Existing checkout ${checkoutPath} is not registered as a Git worktree`,
642
- })
1094
+ if (codeDirectoryExists && (yield* fs.isDirectory(codePath))) {
1095
+ yield* fs.deleteDirectory(codePath)
643
1096
  }
644
- continue
645
- }
646
-
647
- const result = yield* fs.runCommand(
648
- [
649
- "git",
650
- "-C",
651
- repositoryPath,
652
- "worktree",
653
- "remove",
654
- ...(!checkoutExists ? ["--force"] : []),
655
- checkoutExists ? checkoutPath : registeredPath,
656
- ],
657
- { captureOutput: true },
1097
+ return removalPlans
1098
+ .filter((plan) => plan.checkoutExists)
1099
+ .map((plan) => plan.checkoutPath)
1100
+ }).pipe(
1101
+ Effect.catchAll((cause) =>
1102
+ Effect.gen(function* () {
1103
+ const rolledBack: string[] = []
1104
+ const manualRecovery: string[] = []
1105
+ for (const plan of [...completed].reverse()) {
1106
+ if (!plan.checkoutExists) continue
1107
+ yield* fs.createDirectory(dirname(plan.checkoutPath))
1108
+ const command = plan.branch
1109
+ ? [
1110
+ "git",
1111
+ "-C",
1112
+ plan.repositoryPath,
1113
+ "worktree",
1114
+ "add",
1115
+ plan.checkoutPath,
1116
+ plan.branch,
1117
+ ]
1118
+ : [
1119
+ "git",
1120
+ "-C",
1121
+ plan.repositoryPath,
1122
+ "worktree",
1123
+ "add",
1124
+ "--detach",
1125
+ plan.checkoutPath,
1126
+ plan.head!,
1127
+ ]
1128
+ const restored = yield* fs.runCommand(command, {
1129
+ captureOutput: true,
1130
+ })
1131
+ if (restored.exitCode === 0)
1132
+ rolledBack.push(plan.checkoutPath)
1133
+ else manualRecovery.push(`Restore ${plan.checkoutPath}`)
1134
+ }
1135
+ return yield* new WorktreeError({
1136
+ message: manualRecovery.length
1137
+ ? "Worktree removal failed and requires manual recovery"
1138
+ : "Worktree removal failed; removed worktrees were restored",
1139
+ completed: completed.map((plan) => plan.checkoutPath),
1140
+ rolledBack,
1141
+ manualRecovery,
1142
+ cause,
1143
+ })
1144
+ }),
1145
+ ),
658
1146
  )
659
- if (result.exitCode !== 0) {
660
- return yield* new WorktreeError({
661
- message: `Failed to remove worktree for '${alias}': ${result.stderr}`,
662
- })
663
- }
664
- if (checkoutExists) removed.push(checkoutPath)
665
- }
666
-
667
- if (codeDirectoryExists && (yield* fs.isDirectory(codePath))) {
668
- const remaining = yield* fs.readDirectory(codePath)
669
- if (remaining.length > 0) {
670
- return yield* new WorktreeError({
671
- message: `Cannot remove ${codePath}; it contains unmanaged entries: ${remaining.map((entry) => entry.name).join(", ")}`,
672
- })
673
- }
674
- yield* fs.deleteDirectory(codePath)
675
- }
676
- return removed
1147
+ return removed
1148
+ })
1149
+ return yield* options.lockHeld
1150
+ ? removal
1151
+ : withWorktreeLocks(
1152
+ root,
1153
+ [{ taskId, ...(phaseId ? { phaseId } : {}) }],
1154
+ removal,
1155
+ )
677
1156
  }),
678
1157
  }),
679
1158
  },