@markjaquith/agency 2.24.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,7 +100,10 @@ interface MaterializeOptions extends BaseCommandOptions {
88
100
  readonly force?: boolean
89
101
  }
90
102
 
91
- interface RemoveOptions extends BaseCommandOptions {}
103
+ interface RemoveOptions extends BaseCommandOptions {
104
+ readonly snapshots?: WorktreeRemovalSnapshot[]
105
+ readonly lockHeld?: boolean
106
+ }
92
107
 
93
108
  export class WorktreeService extends Effect.Service<WorktreeService>()(
94
109
  "WorktreeService",
@@ -109,601 +124,1035 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
109
124
  const forwardCommandOutput =
110
125
  options.verbose === true && !options.silent && !options.json
111
126
  const { root, config } = yield* workbase.loadConfig(startPath)
112
- const report = yield* workbase.validate(root)
113
- const validationIssue = report.issues[0]
114
- if (validationIssue && !options.force) {
115
- return yield* new WorktreeError({
116
- message: `${validationIssue.path}: ${validationIssue.message}`,
117
- })
118
- }
119
- const task = yield* tasks.show(taskId, root)
120
-
121
- let execution: {
122
- repo: string
123
- repos?: readonly RepositoryReference[]
124
- branch: string
125
- base: string
126
- }
127
- let phasePath: string | null = null
128
- let codePath: string
129
- if ("phases" in task.data) {
130
- if (!phaseId) {
131
- return yield* new WorktreeError({
132
- message: `Task '${taskId}' has multiple phases; phase ID is required`,
133
- })
134
- }
135
- const phase = yield* phases.show(taskId, phaseId, root)
136
- execution = phase.data
137
- phasePath = phase.path
138
- codePath = join(dirname(phase.path), "code")
139
- } else {
140
- if (phaseId) {
141
- return yield* new WorktreeError({
142
- message: `Task '${taskId}' is single-phase and does not accept a phase ID`,
143
- })
144
- }
145
- execution = task.data
146
- codePath = join(dirname(task.path), "code")
147
- }
148
-
149
- if (!options.dryRun) yield* fs.createDirectory(codePath)
150
- const operations: WorkspaceOperation[] = []
151
- const checkoutReports: WorkspaceCheckout[] = []
152
- const checkouts: readonly (
153
- | { readonly repo: string; readonly branch: string }
154
- | RepositoryReference
155
- )[] = [
156
- { repo: execution.repo, branch: execution.branch },
157
- ...(execution.repos ?? []),
158
- ]
159
- for (const checkout of checkouts) {
160
- const alias = checkout.repo
161
- const repositoryPath = join(root, "repos", alias)
162
- const checkoutPath = join(codePath, alias)
163
- if (!(yield* fs.exists(repositoryPath))) {
164
- return yield* new WorktreeError({
165
- message: `Repository alias '${alias}' does not exist`,
166
- })
167
- }
168
-
169
- const fetchOrigin = (ref?: string) =>
170
- Effect.gen(function* () {
171
- const remote = yield* fs.runCommand(
172
- ["git", "-C", repositoryPath, "remote", "get-url", "origin"],
173
- { captureOutput: true },
174
- )
175
- if (remote.exitCode !== 0) return false
176
- const command = [
177
- "git",
178
- "-C",
179
- repositoryPath,
180
- "fetch",
181
- "origin",
182
- ...(ref ? [ref] : []),
183
- ]
184
- if (options.dryRun) {
185
- operations.push({
186
- action: "fetch",
187
- repo: alias,
188
- command,
189
- status: "planned",
190
- })
191
- return false
192
- }
193
-
194
- const fetch = yield* fs.runCommand(command, {
195
- 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}`,
196
136
  })
197
- 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) {
198
150
  return yield* new WorktreeError({
199
- message: `Failed to fetch '${alias}': ${fetch.stderr}`,
151
+ message: `Task '${taskId}' has multiple phases; phase ID is required`,
200
152
  })
201
153
  }
202
- operations.push({
203
- action: "fetch",
204
- repo: alias,
205
- command,
206
- status: "completed",
207
- })
208
- return true
209
- })
210
-
211
- const listed = yield* fs.runCommand(
212
- [
213
- "git",
214
- "-C",
215
- repositoryPath,
216
- "worktree",
217
- "list",
218
- "--porcelain",
219
- "-z",
220
- ],
221
- { captureOutput: true },
222
- )
223
- if (listed.exitCode !== 0) {
224
- return yield* new WorktreeError({
225
- message: `Failed to inspect worktrees for '${alias}': ${listed.stderr}`,
226
- })
227
- }
228
- const canonicalCodePath = (yield* fs.exists(codePath))
229
- ? yield* fs.realPath(codePath)
230
- : resolve(codePath)
231
- const canonicalCheckoutPath = join(canonicalCodePath, alias)
232
- const worktrees: GitWorktree[] = []
233
- for (const worktree of parseWorktreeList(listed.stdout)) {
234
- worktrees.push({
235
- ...worktree,
236
- path: (yield* fs.exists(worktree.path))
237
- ? yield* fs.realPath(worktree.path)
238
- : resolve(worktree.path),
239
- })
240
- }
241
- const registeredAtPath = worktrees.find(
242
- (worktree) => worktree.path === canonicalCheckoutPath,
243
- )
244
-
245
- if ("branch" in checkout) {
246
- const branchRef = `refs/heads/${checkout.branch}`
247
- const branchWorktree = worktrees.find(
248
- (worktree) => worktree.branch === branchRef,
249
- )
250
- if (
251
- branchWorktree &&
252
- branchWorktree.path !== canonicalCheckoutPath
253
- ) {
254
- return yield* new WorktreeError({
255
- message: `Branch '${checkout.branch}' for repository '${alias}' is already checked out at ${branchWorktree.path}`,
256
- })
257
- }
258
- if (yield* fs.isDirectory(checkoutPath)) {
259
- if (registeredAtPath?.branch === branchRef) {
260
- checkoutReports.push({
261
- repo: alias,
262
- kind: "writable",
263
- path: checkoutPath,
264
- requestedRef: checkout.branch,
265
- resolvedCommit: registeredAtPath.head ?? null,
266
- 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`,
267
162
  })
268
- continue
269
163
  }
270
- return yield* new WorktreeError({
271
- message: `Existing checkout ${checkoutPath} is not registered to branch '${checkout.branch}'`,
272
- })
164
+ execution = task.data
165
+ codePath = join(dirname(task.path), "code")
273
166
  }
274
- if (registeredAtPath) {
275
- return yield* new WorktreeError({
276
- message: `Worktree registry contains a missing checkout at ${checkoutPath}`,
277
- })
278
- }
279
- yield* fetchOrigin()
280
167
 
281
- let args: string[]
282
- let env: Record<string, string> | undefined
283
- if (config.worktreeCreateCommand) {
284
- const variables = {
285
- repo: repositoryPath,
286
- worktree: checkoutPath,
287
- branch: checkout.branch,
288
- base: execution.base,
289
- }
290
- try {
291
- args = expandWorktreeCreateCommand(
292
- config.worktreeCreateCommand,
293
- variables,
294
- )
295
- } 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))) {
296
185
  return yield* new WorktreeError({
297
- message:
298
- cause instanceof Error
299
- ? cause.message
300
- : "Invalid worktreeCreateCommand",
186
+ message: `Repository alias '${alias}' does not exist`,
301
187
  })
302
188
  }
303
- env = worktreeCommandEnvironment(variables)
304
- } else {
305
- const branchExists = yield* fs.runCommand(
189
+ const listed = yield* fs.runCommand(
306
190
  [
307
191
  "git",
308
192
  "-C",
309
193
  repositoryPath,
310
- "show-ref",
311
- "--verify",
312
- branchRef,
194
+ "worktree",
195
+ "list",
196
+ "--porcelain",
197
+ "-z",
313
198
  ],
314
199
  { captureOutput: true },
315
200
  )
316
- if (branchExists.exitCode !== 0) {
317
- const command = [
318
- "git",
319
- "-C",
320
- repositoryPath,
321
- "branch",
322
- checkout.branch,
323
- execution.base,
324
- ]
325
- operations.push({
326
- action: "create-branch",
327
- repo: alias,
328
- command,
329
- 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}`,
330
204
  })
331
- if (!options.dryRun) {
332
- const createBranch = yield* fs.runCommand(command, {
333
- 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}'`,
334
252
  })
335
- if (createBranch.exitCode !== 0) {
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}`,
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(
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
+ ) {
336
374
  return yield* new WorktreeError({
337
- message: `Failed to create branch '${checkout.branch}': ${createBranch.stderr}`,
375
+ message: `Existing checkout ${checkoutPath} does not match reference '${checkout.ref}' (${commit})`,
338
376
  })
339
377
  }
340
378
  }
341
379
  }
342
- args = [
343
- "git",
344
- "-C",
345
- repositoryPath,
346
- "worktree",
347
- "add",
348
- checkoutPath,
349
- checkout.branch,
350
- ]
351
380
  }
352
- if (options.dryRun) {
353
- operations.push({
354
- action: "create-worktree",
355
- repo: alias,
356
- command: args,
357
- status: "planned",
358
- })
359
- let resolved = yield* fs.runCommand(
360
- [
361
- "git",
362
- "-C",
363
- repositoryPath,
364
- "rev-parse",
365
- "--verify",
366
- `${checkout.branch}^{commit}`,
367
- ],
368
- { captureOutput: true },
369
- )
370
- if (resolved.exitCode !== 0) {
371
- 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(
372
453
  [
373
454
  "git",
374
455
  "-C",
375
456
  repositoryPath,
376
- "rev-parse",
377
- "--verify",
378
- `${execution.base}^{commit}`,
457
+ "worktree",
458
+ "list",
459
+ "--porcelain",
460
+ "-z",
379
461
  ],
380
462
  { captureOutput: true },
381
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
+ }
382
801
  }
383
- checkoutReports.push({
384
- repo: alias,
385
- kind: "writable",
386
- path: checkoutPath,
387
- requestedRef: checkout.branch,
388
- resolvedCommit:
389
- resolved.exitCode === 0 ? resolved.stdout.trim() : null,
390
- action: "created",
391
- })
392
- continue
393
- }
394
802
 
395
- if (config.worktreeCreateCommand) {
396
- verboseLog(`Running worktree command: ${formatCommand(args)}`)
397
- }
398
- const result = yield* fs.runCommand(args, {
399
- cwd: repositoryPath,
400
- captureOutput: true,
401
- forwardOutput:
402
- config.worktreeCreateCommand && forwardCommandOutput,
403
- env,
404
- })
405
- 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) {
406
946
  return yield* new WorktreeError({
407
- message: `Failed to create worktree for '${alias}': ${result.stderr}`,
947
+ message: `Task '${taskId}' has multiple phases; phase ID is required`,
408
948
  })
409
949
  }
410
- 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) {
411
955
  return yield* new WorktreeError({
412
- message: `Worktree command did not create ${checkoutPath}`,
956
+ message: `Task '${taskId}' is single-phase and does not accept a phase ID`,
413
957
  })
414
958
  }
415
- operations.push({
416
- action: "create-worktree",
417
- repo: alias,
418
- command: args,
419
- status: "completed",
420
- })
421
- const head = yield* fs.runCommand(
422
- ["git", "-C", checkoutPath, "rev-parse", "HEAD"],
423
- { 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),
424
980
  )
425
- checkoutReports.push({
426
- repo: alias,
427
- kind: "writable",
428
- path: checkoutPath,
429
- requestedRef: checkout.branch,
430
- resolvedCommit: head.exitCode === 0 ? head.stdout.trim() : null,
431
- action: "created",
432
- })
433
- } else {
434
- const fetched = isCommitId(checkout.ref)
435
- ? false
436
- : yield* fetchOrigin(originRef(checkout.ref))
437
- const resolvedRefName = fetched ? "FETCH_HEAD" : checkout.ref
438
- 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(
439
991
  [
440
992
  "git",
441
993
  "-C",
442
994
  repositoryPath,
443
- "rev-parse",
444
- "--verify",
445
- `${resolvedRefName}^{commit}`,
995
+ "worktree",
996
+ "list",
997
+ "--porcelain",
998
+ "-z",
446
999
  ],
447
1000
  { captureOutput: true },
448
1001
  )
449
- if (resolvedRef.exitCode !== 0) {
1002
+ if (listed.exitCode !== 0) {
450
1003
  return yield* new WorktreeError({
451
- message: `Reference '${checkout.ref}' for repository '${alias}' does not resolve to a commit`,
1004
+ message: `Failed to inspect worktrees for '${alias}': ${listed.stderr}`,
452
1005
  })
453
1006
  }
454
- const commit = resolvedRef.stdout.trim()
455
- if (yield* fs.isDirectory(checkoutPath)) {
456
- if (!registeredAtPath) {
457
- return yield* new WorktreeError({
458
- message: `Existing checkout ${checkoutPath} is not registered as a Git worktree`,
459
- })
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
460
1024
  }
461
- if (registeredAtPath.branch) {
1025
+ }
1026
+ if (!registered) {
1027
+ if (checkoutExists) {
462
1028
  return yield* new WorktreeError({
463
- 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`,
464
1030
  })
465
1031
  }
466
- const currentHead = yield* fs.runCommand(
467
- ["git", "-C", checkoutPath, "rev-parse", "HEAD"],
1032
+ continue
1033
+ }
1034
+ if (checkoutExists) {
1035
+ const status = yield* fs.runCommand(
1036
+ ["git", "-C", checkoutPath, "status", "--porcelain"],
468
1037
  { captureOutput: true },
469
1038
  )
470
- if (
471
- currentHead.exitCode === 0 &&
472
- currentHead.stdout.trim() === commit
473
- ) {
474
- checkoutReports.push({
475
- repo: alias,
476
- kind: "reference",
477
- path: checkoutPath,
478
- requestedRef: checkout.ref,
479
- resolvedCommit: commit,
480
- 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`,
481
1042
  })
482
- continue
483
1043
  }
484
- return yield* new WorktreeError({
485
- message: `Existing checkout ${checkoutPath} does not match reference '${checkout.ref}' (${commit})`,
486
- })
487
- }
488
- if (registeredAtPath) {
489
- return yield* new WorktreeError({
490
- message: `Worktree registry contains a missing checkout at ${checkoutPath}`,
491
- })
492
1044
  }
493
- const command = [
494
- "git",
495
- "-C",
1045
+ removalPlans.push({
1046
+ alias,
496
1047
  repositoryPath,
497
- "worktree",
498
- "add",
499
- "--detach",
500
1048
  checkoutPath,
501
- commit,
502
- ]
503
- if (options.dryRun) {
504
- operations.push({
505
- action: "create-worktree",
506
- repo: alias,
507
- command,
508
- status: "planned",
509
- })
510
- checkoutReports.push({
511
- repo: alias,
512
- kind: "reference",
513
- path: checkoutPath,
514
- requestedRef: checkout.ref,
515
- resolvedCommit: commit,
516
- action: "created",
517
- })
518
- continue
519
- }
520
- const result = yield* fs.runCommand(command, {
521
- captureOutput: true,
522
- })
523
- if (result.exitCode !== 0) {
524
- return yield* new WorktreeError({
525
- message: `Failed to create worktree for '${alias}': ${result.stderr}`,
526
- })
527
- }
528
- operations.push({
529
- action: "create-worktree",
530
- repo: alias,
531
- command,
532
- status: "completed",
533
- })
534
- checkoutReports.push({
535
- repo: alias,
536
- kind: "reference",
537
- path: checkoutPath,
538
- requestedRef: checkout.ref,
539
- resolvedCommit: commit,
540
- action: "created",
1049
+ registeredPath: registered.path,
1050
+ checkoutExists,
1051
+ head: registered.head,
1052
+ branch: registered.branch?.replace(/^refs\/heads\//, ""),
541
1053
  })
542
1054
  }
543
- }
544
-
545
- return {
546
- root,
547
- taskPath: task.path,
548
- phasePath,
549
- codePath,
550
- writablePath: join(codePath, execution.repo),
551
- repo: execution.repo,
552
- repos: execution.repos ?? [],
553
- dryRun: options.dryRun === true,
554
- checkouts: checkoutReports,
555
- operations,
556
- } satisfies ExecutionWorkspace
557
- }),
558
-
559
- remove: (
560
- taskId: string,
561
- phaseId?: string,
562
- startPath: string = process.cwd(),
563
- options: RemoveOptions = {},
564
- ) =>
565
- Effect.gen(function* () {
566
- const fs = yield* FileSystemService
567
- const workbase = yield* WorkbaseService
568
- const tasks = yield* TaskService
569
- const phases = yield* PhaseService
570
- const root = yield* workbase.discover(startPath)
571
- const task = yield* tasks.show(taskId, root)
572
-
573
- let execution: {
574
- repo: string
575
- repos?: readonly RepositoryReference[]
576
- }
577
- let codePath: string
578
- if ("phases" in task.data) {
579
- if (!phaseId) {
580
- return yield* new WorktreeError({
581
- message: `Task '${taskId}' has multiple phases; phase ID is required`,
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 } : {}),
582
1062
  })
583
1063
  }
584
- const phase = yield* phases.show(taskId, phaseId, root)
585
- execution = phase.data
586
- codePath = join(dirname(phase.path), "code")
587
- } else {
588
- if (phaseId) {
589
- return yield* new WorktreeError({
590
- message: `Task '${taskId}' is single-phase and does not accept a phase ID`,
591
- })
1064
+ if (options.dryRun) {
1065
+ return removalPlans
1066
+ .filter((plan) => plan.checkoutExists)
1067
+ .map((plan) => plan.checkoutPath)
592
1068
  }
593
- execution = task.data
594
- codePath = join(dirname(task.path), "code")
595
- }
596
1069
 
597
- const codeDirectoryExists = yield* fs.isDirectory(codePath)
598
- const aliases = [
599
- execution.repo,
600
- ...(execution.repos ?? []).map((reference) => reference.repo),
601
- ]
602
- if (codeDirectoryExists) {
603
- const unmanaged = (yield* fs.readDirectory(codePath)).filter(
604
- (entry) => !aliases.includes(entry.name),
605
- )
606
- if (unmanaged.length > 0) {
607
- return yield* new WorktreeError({
608
- message: `Cannot remove ${codePath}; it contains unmanaged entries: ${unmanaged.map((entry) => entry.name).join(", ")}`,
609
- })
610
- }
611
- }
612
- const removed: string[] = []
613
- for (const alias of aliases) {
614
- const repositoryPath = join(root, "repos", alias)
615
- const checkoutPath = join(codePath, alias)
616
- const listed = yield* fs.runCommand(
617
- [
618
- "git",
619
- "-C",
620
- repositoryPath,
621
- "worktree",
622
- "list",
623
- "--porcelain",
624
- "-z",
625
- ],
626
- { captureOutput: true },
627
- )
628
- if (listed.exitCode !== 0) {
629
- return yield* new WorktreeError({
630
- message: `Failed to inspect worktrees for '${alias}': ${listed.stderr}`,
631
- })
632
- }
633
-
634
- const checkoutExists = yield* fs.isDirectory(checkoutPath)
635
- const canonicalCheckoutPath = checkoutExists
636
- ? yield* fs.realPath(checkoutPath)
637
- : join(
638
- yield* fs.realPath(dirname(codePath)),
639
- basename(codePath),
640
- 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 },
641
1086
  )
642
- let registeredPath: string | undefined
643
- for (const worktree of parseWorktreeList(listed.stdout)) {
644
- const worktreePath = (yield* fs.exists(worktree.path))
645
- ? yield* fs.realPath(worktree.path)
646
- : resolve(worktree.path)
647
- if (worktreePath === canonicalCheckoutPath) {
648
- registeredPath = worktreePath
649
- break
650
- }
651
- }
652
- if (!registeredPath) {
653
- if (checkoutExists) {
654
- return yield* new WorktreeError({
655
- message: `Existing checkout ${checkoutPath} is not registered as a Git worktree`,
656
- })
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)
657
1093
  }
658
- continue
659
- }
660
- if (checkoutExists) {
661
- const status = yield* fs.runCommand(
662
- ["git", "-C", checkoutPath, "status", "--porcelain"],
663
- { captureOutput: true },
664
- )
665
- if (status.exitCode !== 0 || status.stdout.trim()) {
666
- return yield* new WorktreeError({
667
- message: `Failed to remove worktree for '${alias}': worktree has uncommitted changes`,
668
- })
1094
+ if (codeDirectoryExists && (yield* fs.isDirectory(codePath))) {
1095
+ yield* fs.deleteDirectory(codePath)
669
1096
  }
670
- removed.push(checkoutPath)
671
- }
672
- if (options.dryRun) continue
673
-
674
- const result = yield* fs.runCommand(
675
- [
676
- "git",
677
- "-C",
678
- repositoryPath,
679
- "worktree",
680
- "remove",
681
- ...(!checkoutExists ? ["--force"] : []),
682
- checkoutExists ? checkoutPath : registeredPath,
683
- ],
684
- { 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
+ ),
685
1146
  )
686
- if (result.exitCode !== 0) {
687
- return yield* new WorktreeError({
688
- message: `Failed to remove worktree for '${alias}': ${result.stderr}`,
689
- })
690
- }
691
- }
692
-
693
- if (
694
- !options.dryRun &&
695
- codeDirectoryExists &&
696
- (yield* fs.isDirectory(codePath))
697
- ) {
698
- const remaining = yield* fs.readDirectory(codePath)
699
- if (remaining.length > 0) {
700
- return yield* new WorktreeError({
701
- message: `Cannot remove ${codePath}; it contains unmanaged entries: ${remaining.map((entry) => entry.name).join(", ")}`,
702
- })
703
- }
704
- yield* fs.deleteDirectory(codePath)
705
- }
706
- return removed
1147
+ return removed
1148
+ })
1149
+ return yield* options.lockHeld
1150
+ ? removal
1151
+ : withWorktreeLocks(
1152
+ root,
1153
+ [{ taskId, ...(phaseId ? { phaseId } : {}) }],
1154
+ removal,
1155
+ )
707
1156
  }),
708
1157
  }),
709
1158
  },