@markjaquith/agency 2.24.0 → 2.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -2
- package/cli.ts +10 -0
- package/package.json +1 -1
- package/skills/agency/SKILL.md +10 -1
- package/src/cli-parser.test.ts +26 -0
- package/src/cli-parser.ts +57 -2
- package/src/commands/archive.test.ts +22 -0
- package/src/commands/archive.ts +3 -1
- package/src/commands/repo.test.ts +44 -0
- package/src/commands/repo.ts +107 -1
- package/src/commands/workbase.test.ts +47 -0
- package/src/commands/workbase.ts +52 -1
- package/src/services/ArchiveService.test.ts +59 -0
- package/src/services/ArchiveService.ts +282 -113
- package/src/services/LifecycleTransaction.test.ts +107 -0
- package/src/services/LifecycleTransaction.ts +302 -0
- package/src/services/PhaseService.ts +156 -48
- package/src/services/RepositoryService.test.ts +129 -1
- package/src/services/RepositoryService.ts +181 -0
- package/src/services/TaskPhaseService.test.ts +47 -1
- package/src/services/TaskService.ts +28 -4
- package/src/services/WorkbaseService.test.ts +46 -0
- package/src/services/WorkbaseService.ts +94 -25
- package/src/services/WorktreeLock.ts +60 -0
- package/src/services/WorktreeService.test.ts +174 -1
- package/src/services/WorktreeService.ts +972 -523
|
@@ -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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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: `
|
|
151
|
+
message: `Task '${taskId}' has multiple phases; phase ID is required`,
|
|
200
152
|
})
|
|
201
153
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
271
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
311
|
-
"
|
|
312
|
-
|
|
194
|
+
"worktree",
|
|
195
|
+
"list",
|
|
196
|
+
"--porcelain",
|
|
197
|
+
"-z",
|
|
313
198
|
],
|
|
314
199
|
{ captureOutput: true },
|
|
315
200
|
)
|
|
316
|
-
if (
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
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: `
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
"
|
|
377
|
-
"
|
|
378
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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: `
|
|
947
|
+
message: `Task '${taskId}' has multiple phases; phase ID is required`,
|
|
408
948
|
})
|
|
409
949
|
}
|
|
410
|
-
|
|
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: `
|
|
956
|
+
message: `Task '${taskId}' is single-phase and does not accept a phase ID`,
|
|
413
957
|
})
|
|
414
958
|
}
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
const
|
|
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
|
-
"
|
|
444
|
-
"
|
|
445
|
-
|
|
995
|
+
"worktree",
|
|
996
|
+
"list",
|
|
997
|
+
"--porcelain",
|
|
998
|
+
"-z",
|
|
446
999
|
],
|
|
447
1000
|
{ captureOutput: true },
|
|
448
1001
|
)
|
|
449
|
-
if (
|
|
1002
|
+
if (listed.exitCode !== 0) {
|
|
450
1003
|
return yield* new WorktreeError({
|
|
451
|
-
message: `
|
|
1004
|
+
message: `Failed to inspect worktrees for '${alias}': ${listed.stderr}`,
|
|
452
1005
|
})
|
|
453
1006
|
}
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
|
|
1025
|
+
}
|
|
1026
|
+
if (!registered) {
|
|
1027
|
+
if (checkoutExists) {
|
|
462
1028
|
return yield* new WorktreeError({
|
|
463
|
-
message: `
|
|
1029
|
+
message: `Existing checkout ${checkoutPath} is not registered as a Git worktree`,
|
|
464
1030
|
})
|
|
465
1031
|
}
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
472
|
-
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
"-C",
|
|
1045
|
+
removalPlans.push({
|
|
1046
|
+
alias,
|
|
496
1047
|
repositoryPath,
|
|
497
|
-
"worktree",
|
|
498
|
-
"add",
|
|
499
|
-
"--detach",
|
|
500
1048
|
checkoutPath,
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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
|
-
|
|
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
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
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
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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
|
},
|