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