Repository navigation
Expand file tree
/
Copy pathworktree.ts
More file actions
377 lines (344 loc) · 15.3 KB
/
Copy pathworktree.ts
File metadata and controls
377 lines (344 loc) · 15.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
export * as Worktree from "./worktree.js"
import { Context, Effect, Layer, Option, Schema } from "effect"
import { and, asc, desc, eq, isNull, sql } from "drizzle-orm"
import path from "path"
import { AbsolutePath } from "./schema.js"
import { FSUtil } from "@opencode/util/fs-util"
import { Git } from "./git.js"
import { Node } from "@opencode/util/effect/app-node"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Slug } from "./util/slug.js"
import { Bus } from "./bus.js"
import { Database } from "./database/database.js"
import { Location } from "./location.js"
import { LocationServiceMap } from "./location-service-map.js"
import { Project } from "./project.js"
import { Worktree } from "@opencode/schema/worktree"
import { WorktreeTable } from "./worktree/sql.js"
import { canonical, DirectoryUnavailableError } from "./worktree/directory.js"
import { WorktreeGit } from "./worktree/git.js"
import type { EffectDrizzleSqlite } from "./database/drizzle.js"
import { ProjectTable } from "./project/sql.js"
import { AppProcess } from "@opencode/util/process"
import { ChildProcess } from "effect/unstable/process"
import { WorktreeStrategies } from "./worktree/strategies.js"
export type { Strategy, Editor } from "./worktree/strategies.js"
export { DirectoryUnavailableError } from "./worktree/directory.js"
export { OperationError } from "@opencode/schema/worktree"
export const StrategyID = Worktree.StrategyID
export type StrategyID = typeof StrategyID.Type
export const CreateInput = Worktree.CreateInput
export type CreateInput = typeof CreateInput.Type
export const RemoveInput = Worktree.RemoveInput
export type RemoveInput = typeof RemoveInput.Type
export const RefreshResult = Schema.Struct({
updated: Schema.Array(AbsolutePath),
removed: Schema.Array(AbsolutePath),
}).annotate({ identifier: "Worktree.RefreshResult" })
export type RefreshResult = typeof RefreshResult.Type
export const Info = Worktree.Info
export type Info = typeof Info.Type
export const List = Worktree.List
export type List = typeof List.Type
export const ListEntry = Worktree.ListEntry
export type ListEntry = typeof ListEntry.Type
export class SourceDirectoryNotFoundError extends Schema.TaggedError<SourceDirectoryNotFoundError>()(
"Worktree.SourceDirectoryNotFoundError",
{ projectID: Project.ID, directory: Schema.optional(AbsolutePath) },
) {}
export class DestinationExistsError extends Schema.TaggedError<DestinationExistsError>()(
"Worktree.DestinationExistsError",
{ directory: AbsolutePath },
) {}
export class InvalidDirectoryError extends Schema.TaggedError<InvalidDirectoryError>()(
"Worktree.InvalidDirectoryError",
{ directory: AbsolutePath },
) {}
export class StrategyUnavailableError extends Schema.TaggedError<StrategyUnavailableError>()(
"Worktree.StrategyUnavailableError",
{ strategy: StrategyID },
) {}
export type Error =
| Project.NotFoundError
| SourceDirectoryNotFoundError
| DestinationExistsError
| DirectoryUnavailableError
| InvalidDirectoryError
| StrategyUnavailableError
| Worktree.OperationError
| AppProcess.AppProcessError
| Git.WorktreeError
export const Event = Worktree.Event
interface StoredInput {
readonly directory: AbsolutePath
readonly strategy?: string
readonly replace?: boolean
}
type DatabaseClient = EffectDrizzleSqlite.EffectSQLiteDatabase
type Transaction = Parameters<Parameters<DatabaseClient["transaction"]>[0]>[0]
export interface Interface {
readonly list: (input: { projectID: Project.ID }) => Effect.Effect<List, Project.NotFoundError>
// The plugin bridge supplies its registry so canonical-project setup can use registrations made so far.
readonly create: (input: CreateInput, current?: WorktreeStrategies.Interface) => Effect.Effect<Info, Error>
readonly remove: (input: RemoveInput, current?: WorktreeStrategies.Interface) => Effect.Effect<void, Error>
readonly refresh: (
input: { projectID: Project.ID },
current?: WorktreeStrategies.Interface,
) => Effect.Effect<RefreshResult, Error>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Worktree") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const database = yield* Database.Service
const db = database.db
const bus = yield* Bus.Service
const processService = yield* AppProcess.Service
const locations = yield* LocationServiceMap.Service
const gitStrategy = yield* WorktreeGit.make
const project = Effect.fnUntraced(function* (projectID: Project.ID) {
const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get().pipe(Effect.orDie)
if (!row) return yield* new Project.NotFoundError({ projectID })
return row
})
const load = Effect.fnUntraced(function* (directory: AbsolutePath, current?: WorktreeStrategies.Interface) {
if (!(yield* fs.isDir(directory))) return yield* new DirectoryUnavailableError({ directory })
if (current?.directory === directory) return current.get()
const { Plugin } = yield* Effect.promise(() => import("./plugin.js"))
const context = yield* locations.contextEffect(Location.Ref.make({ directory }))
yield* Context.get(context, Plugin.Service).awaitActivation
return Context.get(context, WorktreeStrategies.Service).get()
})
const changed = Effect.fnUntraced(function* (projectID: Project.ID, update: boolean) {
if (update) yield* bus.publish(Event.Updated, { projectID })
})
const inventory = (projectID: Project.ID) => ({
list: Effect.fnUntraced(function* () {
const rows = yield* db
.select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
.from(WorktreeTable)
.where(eq(WorktreeTable.project_id, projectID))
.orderBy(desc(WorktreeTable.time_created), asc(WorktreeTable.directory))
.all()
.pipe(Effect.orDie)
return rows.map((row) => ({ directory: row.directory, strategy: row.strategy ?? undefined }))
}),
find: Effect.fnUntraced(function* (directory: AbsolutePath) {
const row = yield* db
.select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
.from(WorktreeTable)
.where(and(eq(WorktreeTable.project_id, projectID), eq(WorktreeTable.directory, directory)))
.get()
.pipe(Effect.orDie)
return row ? { directory: row.directory, strategy: row.strategy ?? undefined } : undefined
}),
create: (input: StoredInput, tx?: Transaction) =>
(tx ?? db)
.insert(WorktreeTable)
.values({
project_id: projectID,
directory: input.directory,
strategy: input.strategy,
})
.onConflictDoUpdate({
target: [WorktreeTable.project_id, WorktreeTable.directory],
set: {
strategy: input.strategy ?? null,
},
// Discovery may claim an unowned row, but never replace another strategy's ownership.
setWhere: input.replace ? undefined : input.strategy ? isNull(WorktreeTable.strategy) : sql`false`,
})
.returning({ directory: WorktreeTable.directory })
.get()
.pipe(
Effect.orDie,
Effect.map((row) => row !== undefined),
),
remove: (directory: AbsolutePath, tx?: Transaction) =>
(tx ?? db)
.delete(WorktreeTable)
.where(and(eq(WorktreeTable.project_id, projectID), eq(WorktreeTable.directory, directory)))
.returning({ directory: WorktreeTable.directory })
.get()
.pipe(
Effect.orDie,
Effect.map((row) => row !== undefined),
),
})
const source = Effect.fnUntraced(function* (projectID: Project.ID, sourceDirectory: AbsolutePath) {
const resolved = yield* canonical(fs, sourceDirectory)
if ((yield* inventory(projectID).find(resolved)) === undefined)
return yield* new SourceDirectoryNotFoundError({ projectID, directory: resolved })
return resolved
})
const getStrategy = Effect.fnUntraced(function* (
id: StrategyID,
strategies: ReadonlyMap<StrategyID, WorktreeStrategies.Strategy>,
) {
const found = strategies.get(id)
if (!found) return yield* new StrategyUnavailableError({ strategy: id })
return found
})
const create = Effect.fn("Worktree.create")(function* (input: CreateInput, current?: WorktreeStrategies.Interface) {
const row = yield* project(input.projectID)
const settings = yield* load(row.worktree, current)
const selected = yield* getStrategy(settings.selected, settings.strategies)
const directory = input.directory ?? settings.directory
const sourceDirectory = yield* source(input.projectID, input.from ?? row.worktree)
yield* fs.makeDirectory(directory, { recursive: true }).pipe(Effect.orDie)
const name = input.name ?? Slug.create()
let suffix = 1
let worktreeDirectory = AbsolutePath.make(path.join(directory, name))
while (yield* fs.existsSafe(worktreeDirectory)) {
suffix++
if (suffix > 10) return yield* new DestinationExistsError({ directory: worktreeDirectory })
worktreeDirectory = AbsolutePath.make(path.join(directory, `${name}-${suffix}`))
}
const created = yield* selected
.create({
directory: worktreeDirectory,
sourceDirectory,
branch: input.branch,
})
.pipe(Effect.mapError((error) => operationError(selected.id, "create", error)))
const result = { directory: yield* canonical(fs, created.directory) }
yield* changed(
input.projectID,
yield* inventory(input.projectID).create({
directory: result.directory,
strategy: selected.id,
replace: true,
}),
)
const command = row.commands?.start?.trim()
if (command) {
const windows = process.platform === "win32"
yield* processService
.run(
ChildProcess.make(windows ? command : "bash", windows ? [] : ["-lc", command], {
cwd: result.directory,
env: {
OPENCODE_WORKTREE_BASE: sourceDirectory,
OPENCODE_WORKTREE_PATH: result.directory,
},
extendEnv: true,
stdin: "ignore",
shell: windows,
}),
)
.pipe(Effect.flatMap(AppProcess.requireSuccess))
}
return result
}, Effect.scoped)
const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput, current?: WorktreeStrategies.Interface) {
const row = yield* project(input.projectID)
const ops = inventory(input.projectID)
const worktreeDirectory = yield* canonical(fs, input.directory)
const stored = yield* ops.find(worktreeDirectory)
if (!stored?.strategy) return yield* new InvalidDirectoryError({ directory: worktreeDirectory })
// Inspect only an already-loaded canonical registry. Removing must never boot config or plugins.
const strategies =
current?.directory === row.worktree
? current.get().strategies
: yield* locations.contextEffectOption(Location.Ref.make({ directory: row.worktree })).pipe(
Effect.map(
Option.match({
onSome: (context) => Context.get(context, WorktreeStrategies.Service).get().strategies,
onNone: () => new Map([[gitStrategy.id, gitStrategy]]),
}),
),
)
const strategy = yield* getStrategy(StrategyID.make(stored.strategy), strategies)
yield* strategy
.remove({
directory: worktreeDirectory,
force: input.force,
})
.pipe(Effect.mapError((error) => operationError(strategy.id, "remove", error)))
yield* changed(input.projectID, yield* ops.remove(worktreeDirectory))
}, Effect.scoped)
const refresh = Effect.fn("Worktree.refresh")(function* (
input: { projectID: Project.ID },
current?: WorktreeStrategies.Interface,
) {
const row = yield* project(input.projectID)
const settings = yield* load(row.worktree, current)
const ops = inventory(input.projectID)
const stored = yield* ops.list()
const checked = yield* Effect.forEach(
stored,
(item) => fs.isDir(item.directory).pipe(Effect.map((exists) => ({ ...item, exists }))),
{ concurrency: "unbounded" },
)
const strategies = Array.from(settings.strategies.values()).toReversed()
const discovered = new Map<AbsolutePath, StoredInput>()
// Unowned rows are checkout/discovery roots. Managed children are enumerated by their backend.
const roots = new Set([
row.worktree,
...checked.filter((item) => item.exists && !item.strategy).map((item) => item.directory),
])
for (const directory of roots) {
if (!(yield* fs.isDir(directory))) continue
for (const strategy of strategies) {
const entries = yield* strategy.list(directory).pipe(
Effect.mapError((error) => operationError(strategy.id, "list", error)),
Effect.catch((error) =>
Effect.logWarning("worktree discovery failed", { directory, strategy: strategy.id, error }).pipe(
Effect.as([]),
),
),
)
for (const entry of entries) {
const directory = yield* canonical(fs, entry.directory).pipe(
Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.undefined),
)
if (!directory || discovered.has(directory)) continue
discovered.set(directory, {
directory,
strategy: entry.type === "worktree" ? strategy.id : undefined,
})
}
}
}
const removed = checked.filter((item) => !item.exists).map((item) => item.directory)
const changes = yield* db
.transaction((tx) =>
Effect.all({
updated: Effect.filter(Array.from(discovered.values()), (item) => ops.create(item, tx)).pipe(
Effect.map((items) => items.map((item) => item.directory)),
),
removed: Effect.filter(removed, (directory) => ops.remove(directory, tx)),
}),
)
.pipe(Effect.orDie)
yield* changed(input.projectID, changes.updated.length > 0 || changes.removed.length > 0)
return changes
}, Effect.scoped)
return Service.of({
list: Effect.fn("Worktree.list")(function* (input) {
yield* project(input.projectID)
return yield* inventory(input.projectID).list()
}),
create,
remove,
refresh,
})
}),
)
export const node: LayerNode.Provider<Service, never, typeof Node.tags.values.global> = Node.makeGlobalNode({
service: Service,
layer,
deps: [FSUtil.node, Git.node, Bus.node, Database.node, AppProcess.node, LocationServiceMap.node],
})
function operationError(strategy: StrategyID, operation: string, error: unknown) {
if (
error instanceof Git.WorktreeError ||
error instanceof DirectoryUnavailableError ||
error instanceof Worktree.OperationError
)
return error
return new Worktree.OperationError({
message: `Worktree strategy ${strategy} failed to ${operation}: ${error instanceof globalThis.Error ? error.message : String(error)}`,
})
}