test: migrate prompt tests to HTTP mock LLM server (#20304)
This commit is contained in:
@@ -90,8 +90,9 @@ export namespace Bus {
|
||||
if (ps) yield* PubSub.publish(ps, payload)
|
||||
yield* PubSub.publish(state.wildcard, payload)
|
||||
|
||||
const dir = yield* InstanceState.directory
|
||||
GlobalBus.emit("event", {
|
||||
directory: Instance.directory,
|
||||
directory: dir,
|
||||
payload,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1486,7 +1486,8 @@ export namespace Config {
|
||||
})
|
||||
|
||||
const update = Effect.fn("Config.update")(function* (config: Info) {
|
||||
const file = path.join(Instance.directory, "config.json")
|
||||
const dir = yield* InstanceState.directory
|
||||
const file = path.join(dir, "config.json")
|
||||
const existing = yield* loadFile(file)
|
||||
yield* fs.writeFileString(file, JSON.stringify(mergeDeep(existing, config), null, 2)).pipe(Effect.orDie)
|
||||
yield* Effect.promise(() => Instance.dispose())
|
||||
|
||||
6
packages/opencode/src/effect/instance-ref.ts
Normal file
6
packages/opencode/src/effect/instance-ref.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { ServiceMap } from "effect"
|
||||
import type { InstanceContext } from "@/project/instance"
|
||||
|
||||
export const InstanceRef = ServiceMap.Reference<InstanceContext | undefined>("~opencode/InstanceRef", {
|
||||
defaultValue: () => undefined,
|
||||
})
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Effect, ScopedCache, Scope } from "effect"
|
||||
import { Effect, Fiber, ScopedCache, Scope, ServiceMap } from "effect"
|
||||
import { Instance, type InstanceContext } from "@/project/instance"
|
||||
import { Context } from "@/util/context"
|
||||
import { InstanceRef } from "./instance-ref"
|
||||
import { registerDisposer } from "./instance-registry"
|
||||
|
||||
const TypeId = "~opencode/InstanceState"
|
||||
@@ -10,13 +12,34 @@ export interface InstanceState<A, E = never, R = never> {
|
||||
}
|
||||
|
||||
export namespace InstanceState {
|
||||
export const bind = <F extends (...args: any[]) => any>(fn: F): F => {
|
||||
try {
|
||||
return Instance.bind(fn)
|
||||
} catch (err) {
|
||||
if (!(err instanceof Context.NotFound)) throw err
|
||||
}
|
||||
const fiber = Fiber.getCurrent()
|
||||
const ctx = fiber ? ServiceMap.getReferenceUnsafe(fiber.services, InstanceRef) : undefined
|
||||
if (!ctx) return fn
|
||||
return ((...args: any[]) => Instance.restore(ctx, () => fn(...args))) as F
|
||||
}
|
||||
|
||||
export const context = Effect.fnUntraced(function* () {
|
||||
return (yield* InstanceRef) ?? Instance.current
|
||||
})()
|
||||
|
||||
export const directory = Effect.map(context, (ctx) => ctx.directory)
|
||||
|
||||
export const make = <A, E = never, R = never>(
|
||||
init: (ctx: InstanceContext) => Effect.Effect<A, E, R | Scope.Scope>,
|
||||
): Effect.Effect<InstanceState<A, E, Exclude<R, Scope.Scope>>, never, R | Scope.Scope> =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* ScopedCache.make<string, A, E, R>({
|
||||
capacity: Number.POSITIVE_INFINITY,
|
||||
lookup: () => init(Instance.current),
|
||||
lookup: () =>
|
||||
Effect.fnUntraced(function* () {
|
||||
return yield* init(yield* context)
|
||||
})(),
|
||||
})
|
||||
|
||||
const off = registerDisposer((directory) => Effect.runPromise(ScopedCache.invalidate(cache, directory)))
|
||||
@@ -29,7 +52,9 @@ export namespace InstanceState {
|
||||
})
|
||||
|
||||
export const get = <A, E, R>(self: InstanceState<A, E, R>) =>
|
||||
Effect.suspend(() => ScopedCache.get(self.cache, Instance.directory))
|
||||
Effect.gen(function* () {
|
||||
return yield* ScopedCache.get(self.cache, yield* directory)
|
||||
})
|
||||
|
||||
export const use = <A, E, R, B>(self: InstanceState<A, E, R>, select: (value: A) => B) =>
|
||||
Effect.map(get(self), select)
|
||||
@@ -40,8 +65,18 @@ export namespace InstanceState {
|
||||
) => Effect.flatMap(get(self), select)
|
||||
|
||||
export const has = <A, E, R>(self: InstanceState<A, E, R>) =>
|
||||
Effect.suspend(() => ScopedCache.has(self.cache, Instance.directory))
|
||||
Effect.gen(function* () {
|
||||
return yield* ScopedCache.has(self.cache, yield* directory)
|
||||
})
|
||||
|
||||
export const invalidate = <A, E, R>(self: InstanceState<A, E, R>) =>
|
||||
Effect.suspend(() => ScopedCache.invalidate(self.cache, Instance.directory))
|
||||
Effect.gen(function* () {
|
||||
return yield* ScopedCache.invalidate(self.cache, yield* directory)
|
||||
})
|
||||
|
||||
/**
|
||||
* Effect finalizers run on the fiber scheduler after the original async
|
||||
* boundary, so ALS reads like Instance.directory can be gone by then.
|
||||
*/
|
||||
export const withALS = <T>(fn: () => T) => Effect.map(context, (ctx) => Instance.restore(ctx, fn))
|
||||
}
|
||||
|
||||
@@ -1,19 +1,33 @@
|
||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||
import * as ServiceMap from "effect/ServiceMap"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Context } from "@/util/context"
|
||||
import { InstanceRef } from "./instance-ref"
|
||||
|
||||
export const memoMap = Layer.makeMemoMapUnsafe()
|
||||
|
||||
function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
|
||||
try {
|
||||
const ctx = Instance.current
|
||||
return Effect.provideService(effect, InstanceRef, ctx)
|
||||
} catch (err) {
|
||||
if (!(err instanceof Context.NotFound)) throw err
|
||||
}
|
||||
return effect
|
||||
}
|
||||
|
||||
export function makeRuntime<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) {
|
||||
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
|
||||
const getRuntime = () => (rt ??= ManagedRuntime.make(layer, { memoMap }))
|
||||
|
||||
return {
|
||||
runSync: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runSync(service.use(fn)),
|
||||
runSync: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runSync(attach(service.use(fn))),
|
||||
runPromiseExit: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
|
||||
getRuntime().runPromiseExit(service.use(fn), options),
|
||||
getRuntime().runPromiseExit(attach(service.use(fn)), options),
|
||||
runPromise: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
|
||||
getRuntime().runPromise(service.use(fn), options),
|
||||
runFork: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runFork(service.use(fn)),
|
||||
runCallback: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runCallback(service.use(fn)),
|
||||
getRuntime().runPromise(attach(service.use(fn)), options),
|
||||
runFork: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runFork(attach(service.use(fn))),
|
||||
runCallback: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) =>
|
||||
getRuntime().runCallback(attach(service.use(fn))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,10 +108,11 @@ export namespace Format {
|
||||
for (const item of yield* Effect.promise(() => getFormatter(ext))) {
|
||||
log.info("running", { command: item.command })
|
||||
const cmd = item.command.map((x) => x.replace("$FILE", filepath))
|
||||
const dir = yield* InstanceState.directory
|
||||
const code = yield* spawner
|
||||
.spawn(
|
||||
ChildProcess.make(cmd[0]!, cmd.slice(1), {
|
||||
cwd: Instance.directory,
|
||||
cwd: dir,
|
||||
env: item.environment,
|
||||
extendEnv: true,
|
||||
}),
|
||||
|
||||
@@ -9,11 +9,7 @@ import z from "zod"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { Log } from "../util/log"
|
||||
|
||||
declare global {
|
||||
const OPENCODE_VERSION: string
|
||||
const OPENCODE_CHANNEL: string
|
||||
}
|
||||
import { CHANNEL as channel, VERSION as version } from "./meta"
|
||||
|
||||
import semver from "semver"
|
||||
|
||||
@@ -60,8 +56,8 @@ export namespace Installation {
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
|
||||
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
|
||||
export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
|
||||
export const VERSION = version
|
||||
export const CHANNEL = channel
|
||||
export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}`
|
||||
|
||||
export function isPreview() {
|
||||
|
||||
7
packages/opencode/src/installation/meta.ts
Normal file
7
packages/opencode/src/installation/meta.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
declare global {
|
||||
const OPENCODE_VERSION: string
|
||||
const OPENCODE_CHANNEL: string
|
||||
}
|
||||
|
||||
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
|
||||
export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
|
||||
@@ -114,6 +114,14 @@ export const Instance = {
|
||||
const ctx = context.use()
|
||||
return ((...args: any[]) => context.provide(ctx, () => fn(...args))) as F
|
||||
},
|
||||
/**
|
||||
* Run a synchronous function within the given instance context ALS.
|
||||
* Use this to bridge from Effect (where InstanceRef carries context)
|
||||
* back to sync code that reads Instance.directory from ALS.
|
||||
*/
|
||||
restore<R>(ctx: InstanceContext, fn: () => R): R {
|
||||
return context.provide(ctx, fn)
|
||||
},
|
||||
state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): () => S {
|
||||
return State.create(() => Instance.directory, init, dispose)
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@ import { NotFoundError } from "@/storage/db"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { isOverflow as overflow } from "./overflow"
|
||||
|
||||
export namespace SessionCompaction {
|
||||
@@ -213,6 +214,7 @@ When constructing the summary, try to stick to this template:
|
||||
const msgs = structuredClone(messages)
|
||||
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
|
||||
const modelMessages = yield* Effect.promise(() => MessageV2.toModelMessages(msgs, model, { stripMedia: true }))
|
||||
const ctx = yield* InstanceState.context
|
||||
const msg: MessageV2.Assistant = {
|
||||
id: MessageID.ascending(),
|
||||
role: "assistant",
|
||||
@@ -223,8 +225,8 @@ When constructing the summary, try to stick to this template:
|
||||
variant: userMessage.variant,
|
||||
summary: true,
|
||||
path: {
|
||||
cwd: Instance.directory,
|
||||
root: Instance.worktree,
|
||||
cwd: ctx.directory,
|
||||
root: ctx.worktree,
|
||||
},
|
||||
cost: 0,
|
||||
tokens: {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { Log } from "../util/log"
|
||||
import { updateSchema } from "../util/update-schema"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { Instance } from "../project/instance"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { SessionPrompt } from "./prompt"
|
||||
import { fn } from "@/util/fn"
|
||||
import { Command } from "../command"
|
||||
@@ -382,11 +383,12 @@ export namespace Session {
|
||||
directory: string
|
||||
permission?: Permission.Ruleset
|
||||
}) {
|
||||
const ctx = yield* InstanceState.context
|
||||
const result: Info = {
|
||||
id: SessionID.descending(input.id),
|
||||
slug: Slug.create(),
|
||||
version: Installation.VERSION,
|
||||
projectID: Instance.project.id,
|
||||
projectID: ctx.project.id,
|
||||
directory: input.directory,
|
||||
workspaceID: input.workspaceID,
|
||||
parentID: input.parentID,
|
||||
@@ -444,12 +446,12 @@ export namespace Session {
|
||||
})
|
||||
|
||||
const children = Effect.fn("Session.children")(function* (parentID: SessionID) {
|
||||
const project = Instance.project
|
||||
const ctx = yield* InstanceState.context
|
||||
const rows = yield* db((d) =>
|
||||
d
|
||||
.select()
|
||||
.from(SessionTable)
|
||||
.where(and(eq(SessionTable.project_id, project.id), eq(SessionTable.parent_id, parentID)))
|
||||
.where(and(eq(SessionTable.project_id, ctx.project.id), eq(SessionTable.parent_id, parentID)))
|
||||
.all(),
|
||||
)
|
||||
return rows.map(fromRow)
|
||||
@@ -496,9 +498,10 @@ export namespace Session {
|
||||
permission?: Permission.Ruleset
|
||||
workspaceID?: WorkspaceID
|
||||
}) {
|
||||
const directory = yield* InstanceState.directory
|
||||
return yield* createNext({
|
||||
parentID: input?.parentID,
|
||||
directory: Instance.directory,
|
||||
directory,
|
||||
title: input?.title,
|
||||
permission: input?.permission,
|
||||
workspaceID: input?.workspaceID,
|
||||
@@ -506,10 +509,11 @@ export namespace Session {
|
||||
})
|
||||
|
||||
const fork = Effect.fn("Session.fork")(function* (input: { sessionID: SessionID; messageID?: MessageID }) {
|
||||
const directory = yield* InstanceState.directory
|
||||
const original = yield* get(input.sessionID)
|
||||
const title = getForkedTitle(original.title)
|
||||
const session = yield* createNext({
|
||||
directory: Instance.directory,
|
||||
directory,
|
||||
workspaceID: original.workspaceID,
|
||||
title,
|
||||
})
|
||||
|
||||
@@ -148,6 +148,7 @@ export namespace SessionPrompt {
|
||||
})
|
||||
|
||||
const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) {
|
||||
const ctx = yield* InstanceState.context
|
||||
const parts: PromptInput["parts"] = [{ type: "text", text: template }]
|
||||
const files = ConfigMarkdown.files(template)
|
||||
const seen = new Set<string>()
|
||||
@@ -159,7 +160,7 @@ export namespace SessionPrompt {
|
||||
seen.add(name)
|
||||
const filepath = name.startsWith("~/")
|
||||
? path.join(os.homedir(), name.slice(2))
|
||||
: path.resolve(Instance.worktree, name)
|
||||
: path.resolve(ctx.worktree, name)
|
||||
|
||||
const info = yield* fsys.stat(filepath).pipe(Effect.option)
|
||||
if (Option.isNone(info)) {
|
||||
@@ -553,6 +554,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
msgs: MessageV2.WithParts[]
|
||||
}) {
|
||||
const { task, model, lastUser, sessionID, session, msgs } = input
|
||||
const ctx = yield* InstanceState.context
|
||||
const taskTool = yield* Effect.promise(() => TaskTool.init())
|
||||
const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model
|
||||
const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({
|
||||
@@ -563,7 +565,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
mode: task.agent,
|
||||
agent: task.agent,
|
||||
variant: lastUser.variant,
|
||||
path: { cwd: Instance.directory, root: Instance.worktree },
|
||||
path: { cwd: ctx.directory, root: ctx.worktree },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
modelID: taskModel.id,
|
||||
@@ -734,6 +736,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
})
|
||||
|
||||
const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* (input: ShellInput, signal: AbortSignal) {
|
||||
const ctx = yield* InstanceState.context
|
||||
const session = yield* sessions.get(input.sessionID)
|
||||
if (session.revert) {
|
||||
yield* Effect.promise(() => SessionRevert.cleanup(session))
|
||||
@@ -773,7 +776,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
mode: input.agent,
|
||||
agent: input.agent,
|
||||
cost: 0,
|
||||
path: { cwd: Instance.directory, root: Instance.worktree },
|
||||
path: { cwd: ctx.directory, root: ctx.worktree },
|
||||
time: { created: Date.now() },
|
||||
role: "assistant",
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
@@ -832,7 +835,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
}
|
||||
|
||||
const args = (invocations[shellName] ?? invocations[""]).args
|
||||
const cwd = Instance.directory
|
||||
const cwd = ctx.directory
|
||||
const shellEnv = yield* plugin.trigger(
|
||||
"shell.env",
|
||||
{ cwd, sessionID: input.sessionID, callID: part.callID },
|
||||
@@ -976,7 +979,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
variant,
|
||||
}
|
||||
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => InstructionPrompt.clear(info.id)))
|
||||
yield* Effect.addFinalizer(() => InstanceState.withALS(() => InstructionPrompt.clear(info.id)))
|
||||
|
||||
type Draft<T> = T extends MessageV2.Part ? Omit<T, "id"> & { id?: string } : never
|
||||
const assign = (part: Draft<MessageV2.Part>): MessageV2.Part => ({
|
||||
@@ -1330,6 +1333,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
|
||||
const runLoop: (sessionID: SessionID) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.run")(
|
||||
function* (sessionID: SessionID) {
|
||||
const ctx = yield* InstanceState.context
|
||||
let structured: unknown | undefined
|
||||
let step = 0
|
||||
const session = yield* sessions.get(sessionID)
|
||||
@@ -1421,7 +1425,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
mode: agent.name,
|
||||
agent: agent.name,
|
||||
variant: lastUser.variant,
|
||||
path: { cwd: Instance.directory, root: Instance.worktree },
|
||||
path: { cwd: ctx.directory, root: ctx.worktree },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
modelID: model.id,
|
||||
@@ -1538,7 +1542,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
}),
|
||||
Effect.fnUntraced(function* (exit) {
|
||||
if (Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) yield* handle.abort()
|
||||
InstructionPrompt.clear(handle.message.id)
|
||||
yield* InstanceState.withALS(() => InstructionPrompt.clear(handle.message.id))
|
||||
}),
|
||||
)
|
||||
if (outcome === "break") break
|
||||
|
||||
@@ -10,8 +10,9 @@ import { NamedError } from "@opencode-ai/util/error"
|
||||
import z from "zod"
|
||||
import path from "path"
|
||||
import { readFileSync, readdirSync, existsSync } from "fs"
|
||||
import { Installation } from "../installation"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { CHANNEL } from "../installation/meta"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { iife } from "@/util/iife"
|
||||
import { init } from "#db"
|
||||
|
||||
@@ -28,10 +29,9 @@ const log = Log.create({ service: "db" })
|
||||
|
||||
export namespace Database {
|
||||
export function getChannelPath() {
|
||||
const channel = Installation.CHANNEL
|
||||
if (["latest", "beta"].includes(channel) || Flag.OPENCODE_DISABLE_CHANNEL_DB)
|
||||
if (["latest", "beta"].includes(CHANNEL) || Flag.OPENCODE_DISABLE_CHANNEL_DB)
|
||||
return path.join(Global.Path.data, "opencode.db")
|
||||
const safe = channel.replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
const safe = CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
return path.join(Global.Path.data, `opencode-${safe}.db`)
|
||||
}
|
||||
|
||||
@@ -142,10 +142,11 @@ export namespace Database {
|
||||
}
|
||||
|
||||
export function effect(fn: () => any | Promise<any>) {
|
||||
const bound = InstanceState.bind(fn)
|
||||
try {
|
||||
ctx.use().effects.push(fn)
|
||||
ctx.use().effects.push(bound)
|
||||
} catch {
|
||||
fn()
|
||||
bound()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,12 +163,8 @@ export namespace Database {
|
||||
} catch (err) {
|
||||
if (err instanceof Context.NotFound) {
|
||||
const effects: (() => void | Promise<void>)[] = []
|
||||
const result = Client().transaction(
|
||||
(tx: TxOrDb) => {
|
||||
return ctx.provide({ tx, effects }, () => callback(tx))
|
||||
},
|
||||
{ behavior: options?.behavior },
|
||||
)
|
||||
const txCallback = InstanceState.bind((tx: TxOrDb) => ctx.provide({ tx, effects }, () => callback(tx)))
|
||||
const result = Client().transaction(txCallback, { behavior: options?.behavior })
|
||||
for (const effect of effects) effect()
|
||||
return result as NotPromise<T>
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { NodePath } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
|
||||
export namespace Worktree {
|
||||
const log = Log.create({ service: "worktree" })
|
||||
@@ -199,6 +200,7 @@ export namespace Worktree {
|
||||
|
||||
const MAX_NAME_ATTEMPTS = 26
|
||||
const candidate = Effect.fn("Worktree.candidate")(function* (root: string, base?: string) {
|
||||
const ctx = yield* InstanceState.context
|
||||
for (const attempt of Array.from({ length: MAX_NAME_ATTEMPTS }, (_, i) => i)) {
|
||||
const name = base ? (attempt === 0 ? base : `${base}-${Slug.create()}`) : Slug.create()
|
||||
const branch = `opencode/${name}`
|
||||
@@ -207,7 +209,7 @@ export namespace Worktree {
|
||||
if (yield* fs.exists(directory).pipe(Effect.orDie)) continue
|
||||
|
||||
const ref = `refs/heads/${branch}`
|
||||
const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: Instance.worktree })
|
||||
const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: ctx.worktree })
|
||||
if (branchCheck.code === 0) continue
|
||||
|
||||
return Info.parse({ name, branch, directory })
|
||||
@@ -216,11 +218,12 @@ export namespace Worktree {
|
||||
})
|
||||
|
||||
const makeWorktreeInfo = Effect.fn("Worktree.makeWorktreeInfo")(function* (name?: string) {
|
||||
if (Instance.project.vcs !== "git") {
|
||||
const ctx = yield* InstanceState.context
|
||||
if (ctx.project.vcs !== "git") {
|
||||
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
|
||||
}
|
||||
|
||||
const root = pathSvc.join(Global.Path.data, "worktree", Instance.project.id)
|
||||
const root = pathSvc.join(Global.Path.data, "worktree", ctx.project.id)
|
||||
yield* fs.makeDirectory(root, { recursive: true }).pipe(Effect.orDie)
|
||||
|
||||
const base = name ? slugify(name) : ""
|
||||
@@ -228,18 +231,20 @@ export namespace Worktree {
|
||||
})
|
||||
|
||||
const setup = Effect.fnUntraced(function* (info: Info) {
|
||||
const ctx = yield* InstanceState.context
|
||||
const created = yield* git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], {
|
||||
cwd: Instance.worktree,
|
||||
cwd: ctx.worktree,
|
||||
})
|
||||
if (created.code !== 0) {
|
||||
throw new CreateFailedError({ message: created.stderr || created.text || "Failed to create git worktree" })
|
||||
}
|
||||
|
||||
yield* project.addSandbox(Instance.project.id, info.directory).pipe(Effect.catch(() => Effect.void))
|
||||
yield* project.addSandbox(ctx.project.id, info.directory).pipe(Effect.catch(() => Effect.void))
|
||||
})
|
||||
|
||||
const boot = Effect.fnUntraced(function* (info: Info, startCommand?: string) {
|
||||
const projectID = Instance.project.id
|
||||
const ctx = yield* InstanceState.context
|
||||
const projectID = ctx.project.id
|
||||
const extra = startCommand?.trim()
|
||||
|
||||
const populated = yield* git(["reset", "--hard"], { cwd: info.directory })
|
||||
|
||||
Reference in New Issue
Block a user