Przeglądaj źródła

effectify Worktree service (#18679)

Kit Langton 3 tygodni temu
rodzic
commit
4647aa80ac

+ 1 - 1
packages/opencode/specs/effect-migration.md

@@ -164,7 +164,7 @@ Still open and likely worth migrating:
 - [x] `Plugin`
 - [x] `ToolRegistry`
 - [ ] `Pty`
-- [ ] `Worktree`
+- [x] `Worktree`
 - [ ] `Bus`
 - [x] `Command`
 - [ ] `Config`

+ 1 - 2
packages/opencode/src/control-plane/adaptors/worktree.ts

@@ -22,12 +22,11 @@ export const WorktreeAdaptor: Adaptor = {
   },
   async create(info) {
     const config = Config.parse(info)
-    const bootstrap = await Worktree.createFromInfo({
+    await Worktree.createFromInfo({
       name: config.name,
       directory: config.directory,
       branch: config.branch,
     })
-    return bootstrap()
   },
   async remove(info) {
     const config = Config.parse(info)

+ 3 - 3
packages/opencode/src/server/routes/experimental.ts

@@ -108,7 +108,7 @@ export const ExperimentalRoutes = lazy(() =>
           ...errors(400),
         },
       }),
-      validator("json", Worktree.create.schema),
+      validator("json", Worktree.CreateInput.optional()),
       async (c) => {
         const body = c.req.valid("json")
         const worktree = await Worktree.create(body)
@@ -155,7 +155,7 @@ export const ExperimentalRoutes = lazy(() =>
           ...errors(400),
         },
       }),
-      validator("json", Worktree.remove.schema),
+      validator("json", Worktree.RemoveInput),
       async (c) => {
         const body = c.req.valid("json")
         await Worktree.remove(body)
@@ -181,7 +181,7 @@ export const ExperimentalRoutes = lazy(() =>
           ...errors(400),
         },
       }),
-      validator("json", Worktree.reset.schema),
+      validator("json", Worktree.ResetInput),
       async (c) => {
         const body = c.req.valid("json")
         await Worktree.reset(body)

+ 431 - 472
packages/opencode/src/worktree/index.ts

@@ -1,5 +1,3 @@
-import fs from "fs/promises"
-import path from "path"
 import z from "zod"
 import { NamedError } from "@opencode-ai/util/error"
 import { Global } from "../global"
@@ -9,12 +7,15 @@ import { Project } from "../project/project"
 import { Database, eq } from "../storage/db"
 import { ProjectTable } from "../project/project.sql"
 import type { ProjectID } from "../project/schema"
-import { fn } from "../util/fn"
 import { Log } from "../util/log"
-import { Process } from "../util/process"
-import { git } from "../util/git"
+import { Slug } from "@opencode-ai/util/slug"
 import { BusEvent } from "@/bus/bus-event"
 import { GlobalBus } from "@/bus/global"
+import { Effect, FileSystem, Layer, Path, Scope, ServiceMap, Stream } from "effect"
+import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
+import { NodeFileSystem, NodePath } from "@effect/platform-node"
+import { makeRunPromise } from "@/effect/run-service"
+import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
 
 export namespace Worktree {
   const log = Log.create({ service: "worktree" })
@@ -123,77 +124,7 @@ export namespace Worktree {
     }),
   )
 
-  const ADJECTIVES = [
-    "brave",
-    "calm",
-    "clever",
-    "cosmic",
-    "crisp",
-    "curious",
-    "eager",
-    "gentle",
-    "glowing",
-    "happy",
-    "hidden",
-    "jolly",
-    "kind",
-    "lucky",
-    "mighty",
-    "misty",
-    "neon",
-    "nimble",
-    "playful",
-    "proud",
-    "quick",
-    "quiet",
-    "shiny",
-    "silent",
-    "stellar",
-    "sunny",
-    "swift",
-    "tidy",
-    "witty",
-  ] as const
-
-  const NOUNS = [
-    "cabin",
-    "cactus",
-    "canyon",
-    "circuit",
-    "comet",
-    "eagle",
-    "engine",
-    "falcon",
-    "forest",
-    "garden",
-    "harbor",
-    "island",
-    "knight",
-    "lagoon",
-    "meadow",
-    "moon",
-    "mountain",
-    "nebula",
-    "orchid",
-    "otter",
-    "panda",
-    "pixel",
-    "planet",
-    "river",
-    "rocket",
-    "sailor",
-    "squid",
-    "star",
-    "tiger",
-    "wizard",
-    "wolf",
-  ] as const
-
-  function pick<const T extends readonly string[]>(list: T) {
-    return list[Math.floor(Math.random() * list.length)]
-  }
-
-  function slug(input: string) {
+  function slugify(input: string) {
     return input
       .trim()
       .toLowerCase()
@@ -202,28 +133,8 @@ export namespace Worktree {
       .replace(/-+$/, "")
   }
 
-  function randomName() {
-    return `${pick(ADJECTIVES)}-${pick(NOUNS)}`
-  }
-
-  async function exists(target: string) {
-    return fs
-      .stat(target)
-      .then(() => true)
-      .catch(() => false)
-  }
-
-  function outputText(input: Uint8Array | undefined) {
-    if (!input?.length) return ""
-    return new TextDecoder().decode(input).trim()
-  }
-
-  function errorText(result: { stdout?: Uint8Array; stderr?: Uint8Array }) {
-    return [outputText(result.stderr), outputText(result.stdout)].filter(Boolean).join("\n")
-  }
-
-  function failed(result: { stdout?: Uint8Array; stderr?: Uint8Array }) {
-    return [outputText(result.stderr), outputText(result.stdout)].filter(Boolean).flatMap((chunk) =>
+  function failedRemoves(...chunks: string[]) {
+    return chunks.filter(Boolean).flatMap((chunk) =>
       chunk
         .split("\n")
         .map((line) => line.trim())
@@ -237,436 +148,484 @@ export namespace Worktree {
     )
   }
 
-  async function prune(root: string, entries: string[]) {
-    const base = await canonical(root)
-    await Promise.all(
-      entries.map(async (entry) => {
-        const target = await canonical(path.resolve(root, entry))
-        if (target === base) return
-        if (!target.startsWith(`${base}${path.sep}`)) return
-        await fs.rm(target, { recursive: true, force: true }).catch(() => undefined)
-      }),
-    )
-  }
-
-  async function sweep(root: string) {
-    const first = await git(["clean", "-ffdx"], { cwd: root })
-    if (first.exitCode === 0) return first
-
-    const entries = failed(first)
-    if (!entries.length) return first
-
-    await prune(root, entries)
-    return git(["clean", "-ffdx"], { cwd: root })
-  }
+  // ---------------------------------------------------------------------------
+  // Effect service
+  // ---------------------------------------------------------------------------
 
-  async function canonical(input: string) {
-    const abs = path.resolve(input)
-    const real = await fs.realpath(abs).catch(() => abs)
-    const normalized = path.normalize(real)
-    return process.platform === "win32" ? normalized.toLowerCase() : normalized
+  export interface Interface {
+    readonly makeWorktreeInfo: (name?: string) => Effect.Effect<Info>
+    readonly createFromInfo: (info: Info, startCommand?: string) => Effect.Effect<void>
+    readonly create: (input?: CreateInput) => Effect.Effect<Info>
+    readonly remove: (input: RemoveInput) => Effect.Effect<boolean>
+    readonly reset: (input: ResetInput) => Effect.Effect<boolean>
   }
 
-  async function candidate(root: string, base?: string) {
-    for (const attempt of Array.from({ length: 26 }, (_, i) => i)) {
-      const name = base ? (attempt === 0 ? base : `${base}-${randomName()}`) : randomName()
-      const branch = `opencode/${name}`
-      const directory = path.join(root, name)
-
-      if (await exists(directory)) continue
-
-      const ref = `refs/heads/${branch}`
-      const branchCheck = await git(["show-ref", "--verify", "--quiet", ref], {
-        cwd: Instance.worktree,
+  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Worktree") {}
+
+  type GitResult = { code: number; text: string; stderr: string }
+
+  export const layer: Layer.Layer<
+    Service,
+    never,
+    FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
+  > = Layer.effect(
+    Service,
+    Effect.gen(function* () {
+      const scope = yield* Scope.Scope
+      const fsys = yield* FileSystem.FileSystem
+      const pathSvc = yield* Path.Path
+      const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
+
+      const git = Effect.fnUntraced(
+        function* (args: string[], opts?: { cwd?: string }) {
+          const handle = yield* spawner.spawn(
+            ChildProcess.make("git", args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }),
+          )
+          const [text, stderr] = yield* Effect.all(
+            [Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
+            { concurrency: 2 },
+          )
+          const code = yield* handle.exitCode
+          return { code, text, stderr } satisfies GitResult
+        },
+        Effect.scoped,
+        Effect.catch((e) =>
+          Effect.succeed({ code: 1, text: "", stderr: e instanceof Error ? e.message : String(e) } satisfies GitResult),
+        ),
+      )
+
+      const MAX_NAME_ATTEMPTS = 26
+      const candidate = Effect.fn("Worktree.candidate")(function* (root: string, base?: string) {
+        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}`
+          const directory = pathSvc.join(root, name)
+
+          if (yield* fsys.exists(directory).pipe(Effect.orDie)) continue
+
+          const ref = `refs/heads/${branch}`
+          const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: Instance.worktree })
+          if (branchCheck.code === 0) continue
+
+          return Info.parse({ name, branch, directory })
+        }
+        throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" })
       })
-      if (branchCheck.exitCode === 0) continue
-
-      return Info.parse({ name, branch, directory })
-    }
-
-    throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" })
-  }
-
-  async function runStartCommand(directory: string, cmd: string) {
-    if (process.platform === "win32") {
-      return Process.run(["cmd", "/c", cmd], { cwd: directory, nothrow: true })
-    }
-    return Process.run(["bash", "-lc", cmd], { cwd: directory, nothrow: true })
-  }
 
-  type StartKind = "project" | "worktree"
-
-  async function runStartScript(directory: string, cmd: string, kind: StartKind) {
-    const text = cmd.trim()
-    if (!text) return true
-
-    const ran = await runStartCommand(directory, text)
-    if (ran.code === 0) return true
-
-    log.error("worktree start command failed", {
-      kind,
-      directory,
-      message: errorText(ran),
-    })
-    return false
-  }
-
-  async function runStartScripts(directory: string, input: { projectID: ProjectID; extra?: string }) {
-    const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, input.projectID)).get())
-    const project = row ? Project.fromRow(row) : undefined
-    const startup = project?.commands?.start?.trim() ?? ""
-    const ok = await runStartScript(directory, startup, "project")
-    if (!ok) return false
-
-    const extra = input.extra ?? ""
-    await runStartScript(directory, extra, "worktree")
-    return true
-  }
+      const makeWorktreeInfo = Effect.fn("Worktree.makeWorktreeInfo")(function* (name?: string) {
+        if (Instance.project.vcs !== "git") {
+          throw new NotGitError({ message: "Worktrees are only supported for git projects" })
+        }
 
-  function queueStartScripts(directory: string, input: { projectID: ProjectID; extra?: string }) {
-    setTimeout(() => {
-      const start = async () => {
-        await runStartScripts(directory, input)
-      }
+        const root = pathSvc.join(Global.Path.data, "worktree", Instance.project.id)
+        yield* fsys.makeDirectory(root, { recursive: true }).pipe(Effect.orDie)
 
-      void start().catch((error) => {
-        log.error("worktree start task failed", { directory, error })
+        const base = name ? slugify(name) : ""
+        return yield* candidate(root, base || undefined)
       })
-    }, 0)
-  }
-
-  export async function makeWorktreeInfo(name?: string): Promise<Info> {
-    if (Instance.project.vcs !== "git") {
-      throw new NotGitError({ message: "Worktrees are only supported for git projects" })
-    }
-
-    const root = path.join(Global.Path.data, "worktree", Instance.project.id)
-    await fs.mkdir(root, { recursive: true })
 
-    const base = name ? slug(name) : ""
-    return candidate(root, base || undefined)
-  }
-
-  export async function createFromInfo(info: Info, startCommand?: string) {
-    const created = await git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], {
-      cwd: Instance.worktree,
-    })
-    if (created.exitCode !== 0) {
-      throw new CreateFailedError({ message: errorText(created) || "Failed to create git worktree" })
-    }
+      const setup = Effect.fnUntraced(function* (info: Info) {
+        const created = yield* git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], {
+          cwd: Instance.worktree,
+        })
+        if (created.code !== 0) {
+          throw new CreateFailedError({ message: created.stderr || created.text || "Failed to create git worktree" })
+        }
 
-    await Project.addSandbox(Instance.project.id, info.directory).catch(() => undefined)
+        yield* Effect.promise(() => Project.addSandbox(Instance.project.id, info.directory).catch(() => undefined))
+      })
 
-    const projectID = Instance.project.id
-    const extra = startCommand?.trim()
+      const boot = Effect.fnUntraced(function* (info: Info, startCommand?: string) {
+        const projectID = Instance.project.id
+        const extra = startCommand?.trim()
 
-    return () => {
-      const start = async () => {
-        const populated = await git(["reset", "--hard"], { cwd: info.directory })
-        if (populated.exitCode !== 0) {
-          const message = errorText(populated) || "Failed to populate worktree"
+        const populated = yield* git(["reset", "--hard"], { cwd: info.directory })
+        if (populated.code !== 0) {
+          const message = populated.stderr || populated.text || "Failed to populate worktree"
           log.error("worktree checkout failed", { directory: info.directory, message })
           GlobalBus.emit("event", {
             directory: info.directory,
-            payload: {
-              type: Event.Failed.type,
-              properties: {
-                message,
-              },
-            },
+            payload: { type: Event.Failed.type, properties: { message } },
           })
           return
         }
 
-        const booted = await Instance.provide({
-          directory: info.directory,
-          init: InstanceBootstrap,
-          fn: () => undefined,
-        })
-          .then(() => true)
-          .catch((error) => {
-            const message = error instanceof Error ? error.message : String(error)
-            log.error("worktree bootstrap failed", { directory: info.directory, message })
-            GlobalBus.emit("event", {
-              directory: info.directory,
-              payload: {
-                type: Event.Failed.type,
-                properties: {
-                  message,
-                },
-              },
-            })
-            return false
+        const booted = yield* Effect.promise(() =>
+          Instance.provide({
+            directory: info.directory,
+            init: InstanceBootstrap,
+            fn: () => undefined,
           })
+            .then(() => true)
+            .catch((error) => {
+              const message = error instanceof Error ? error.message : String(error)
+              log.error("worktree bootstrap failed", { directory: info.directory, message })
+              GlobalBus.emit("event", {
+                directory: info.directory,
+                payload: { type: Event.Failed.type, properties: { message } },
+              })
+              return false
+            }),
+        )
         if (!booted) return
 
         GlobalBus.emit("event", {
           directory: info.directory,
           payload: {
             type: Event.Ready.type,
-            properties: {
-              name: info.name,
-              branch: info.branch,
-            },
+            properties: { name: info.name, branch: info.branch },
           },
         })
 
-        await runStartScripts(info.directory, { projectID, extra })
-      }
+        yield* runStartScripts(info.directory, { projectID, extra })
+      })
 
-      return start().catch((error) => {
-        log.error("worktree start task failed", { directory: info.directory, error })
+      const createFromInfo = Effect.fn("Worktree.createFromInfo")(function* (info: Info, startCommand?: string) {
+        yield* setup(info)
+        yield* boot(info, startCommand)
       })
-    }
-  }
 
-  export const create = fn(CreateInput.optional(), async (input) => {
-    const info = await makeWorktreeInfo(input?.name)
-    const bootstrap = await createFromInfo(info, input?.startCommand)
-    // This is needed due to how worktrees currently work in the
-    // desktop app
-    setTimeout(() => {
-      bootstrap()
-    }, 0)
-    return info
-  })
-
-  export const remove = fn(RemoveInput, async (input) => {
-    if (Instance.project.vcs !== "git") {
-      throw new NotGitError({ message: "Worktrees are only supported for git projects" })
-    }
-
-    const directory = await canonical(input.directory)
-    const locate = async (stdout: Uint8Array | undefined) => {
-      const lines = outputText(stdout)
-        .split("\n")
-        .map((line) => line.trim())
-      const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
-        if (!line) return acc
-        if (line.startsWith("worktree ")) {
-          acc.push({ path: line.slice("worktree ".length).trim() })
-          return acc
-        }
-        const current = acc[acc.length - 1]
-        if (!current) return acc
-        if (line.startsWith("branch ")) {
-          current.branch = line.slice("branch ".length).trim()
-        }
-        return acc
-      }, [])
+      const create = Effect.fn("Worktree.create")(function* (input?: CreateInput) {
+        const info = yield* makeWorktreeInfo(input?.name)
+        yield* setup(info)
+        yield* boot(info, input?.startCommand).pipe(
+          Effect.catchCause((cause) => Effect.sync(() => log.error("worktree bootstrap failed", { cause }))),
+          Effect.forkIn(scope),
+        )
+        return info
+      })
+
+      const canonical = Effect.fnUntraced(function* (input: string) {
+        const abs = pathSvc.resolve(input)
+        const real = yield* fsys.realPath(abs).pipe(Effect.catch(() => Effect.succeed(abs)))
+        const normalized = pathSvc.normalize(real)
+        return process.platform === "win32" ? normalized.toLowerCase() : normalized
+      })
+
+      function parseWorktreeList(text: string) {
+        return text
+          .split("\n")
+          .map((line) => line.trim())
+          .reduce<{ path?: string; branch?: string }[]>((acc, line) => {
+            if (!line) return acc
+            if (line.startsWith("worktree ")) {
+              acc.push({ path: line.slice("worktree ".length).trim() })
+              return acc
+            }
+            const current = acc[acc.length - 1]
+            if (!current) return acc
+            if (line.startsWith("branch ")) {
+              current.branch = line.slice("branch ".length).trim()
+            }
+            return acc
+          }, [])
+      }
 
-      return (async () => {
+      const locateWorktree = Effect.fnUntraced(function* (
+        entries: { path?: string; branch?: string }[],
+        directory: string,
+      ) {
         for (const item of entries) {
           if (!item.path) continue
-          const key = await canonical(item.path)
+          const key = yield* canonical(item.path)
           if (key === directory) return item
         }
-      })()
-    }
-
-    const clean = (target: string) =>
-      fs
-        .rm(target, {
-          recursive: true,
-          force: true,
-          maxRetries: 5,
-          retryDelay: 100,
-        })
-        .catch((error) => {
-          const message = error instanceof Error ? error.message : String(error)
-          throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" })
-        })
+        return undefined
+      })
+
+      function stopFsmonitor(target: string) {
+        return fsys.exists(target).pipe(
+          Effect.orDie,
+          Effect.flatMap((exists) => (exists ? git(["fsmonitor--daemon", "stop"], { cwd: target }) : Effect.void)),
+        )
+      }
 
-    const stop = async (target: string) => {
-      if (!(await exists(target))) return
-      await git(["fsmonitor--daemon", "stop"], { cwd: target })
-    }
+      function cleanDirectory(target: string) {
+        return Effect.promise(() =>
+          import("fs/promises").then((fsp) =>
+            fsp.rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }),
+          ),
+        )
+      }
 
-    const list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
-    if (list.exitCode !== 0) {
-      throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" })
-    }
+      const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput) {
+        if (Instance.project.vcs !== "git") {
+          throw new NotGitError({ message: "Worktrees are only supported for git projects" })
+        }
 
-    const entry = await locate(list.stdout)
+        const directory = yield* canonical(input.directory)
 
-    if (!entry?.path) {
-      const directoryExists = await exists(directory)
-      if (directoryExists) {
-        await stop(directory)
-        await clean(directory)
-      }
-      return true
-    }
+        const list = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
+        if (list.code !== 0) {
+          throw new RemoveFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" })
+        }
 
-    await stop(entry.path)
-    const removed = await git(["worktree", "remove", "--force", entry.path], {
-      cwd: Instance.worktree,
-    })
-    if (removed.exitCode !== 0) {
-      const next = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
-      if (next.exitCode !== 0) {
-        throw new RemoveFailedError({
-          message: errorText(removed) || errorText(next) || "Failed to remove git worktree",
-        })
-      }
+        const entries = parseWorktreeList(list.text)
+        const entry = yield* locateWorktree(entries, directory)
 
-      const stale = await locate(next.stdout)
-      if (stale?.path) {
-        throw new RemoveFailedError({ message: errorText(removed) || "Failed to remove git worktree" })
-      }
-    }
+        if (!entry?.path) {
+          const directoryExists = yield* fsys.exists(directory).pipe(Effect.orDie)
+          if (directoryExists) {
+            yield* stopFsmonitor(directory)
+            yield* cleanDirectory(directory)
+          }
+          return true
+        }
 
-    await clean(entry.path)
+        yield* stopFsmonitor(entry.path)
+        const removed = yield* git(["worktree", "remove", "--force", entry.path], { cwd: Instance.worktree })
+        if (removed.code !== 0) {
+          const next = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
+          if (next.code !== 0) {
+            throw new RemoveFailedError({
+              message: removed.stderr || removed.text || next.stderr || next.text || "Failed to remove git worktree",
+            })
+          }
 
-    const branch = entry.branch?.replace(/^refs\/heads\//, "")
-    if (branch) {
-      const deleted = await git(["branch", "-D", branch], { cwd: Instance.worktree })
-      if (deleted.exitCode !== 0) {
-        throw new RemoveFailedError({ message: errorText(deleted) || "Failed to delete worktree branch" })
-      }
-    }
-
-    return true
-  })
-
-  export const reset = fn(ResetInput, async (input) => {
-    if (Instance.project.vcs !== "git") {
-      throw new NotGitError({ message: "Worktrees are only supported for git projects" })
-    }
-
-    const directory = await canonical(input.directory)
-    const primary = await canonical(Instance.worktree)
-    if (directory === primary) {
-      throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
-    }
-
-    const list = await git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
-    if (list.exitCode !== 0) {
-      throw new ResetFailedError({ message: errorText(list) || "Failed to read git worktrees" })
-    }
-
-    const lines = outputText(list.stdout)
-      .split("\n")
-      .map((line) => line.trim())
-    const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => {
-      if (!line) return acc
-      if (line.startsWith("worktree ")) {
-        acc.push({ path: line.slice("worktree ".length).trim() })
-        return acc
-      }
-      const current = acc[acc.length - 1]
-      if (!current) return acc
-      if (line.startsWith("branch ")) {
-        current.branch = line.slice("branch ".length).trim()
-      }
-      return acc
-    }, [])
-
-    const entry = await (async () => {
-      for (const item of entries) {
-        if (!item.path) continue
-        const key = await canonical(item.path)
-        if (key === directory) return item
-      }
-    })()
-    if (!entry?.path) {
-      throw new ResetFailedError({ message: "Worktree not found" })
-    }
-
-    const remoteList = await git(["remote"], { cwd: Instance.worktree })
-    if (remoteList.exitCode !== 0) {
-      throw new ResetFailedError({ message: errorText(remoteList) || "Failed to list git remotes" })
-    }
-
-    const remotes = outputText(remoteList.stdout)
-      .split("\n")
-      .map((line) => line.trim())
-      .filter(Boolean)
-
-    const remote = remotes.includes("origin")
-      ? "origin"
-      : remotes.length === 1
-        ? remotes[0]
-        : remotes.includes("upstream")
-          ? "upstream"
-          : ""
-
-    const remoteHead = remote
-      ? await git(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: Instance.worktree })
-      : { exitCode: 1, stdout: undefined, stderr: undefined }
-
-    const remoteRef = remoteHead.exitCode === 0 ? outputText(remoteHead.stdout) : ""
-    const remoteTarget = remoteRef ? remoteRef.replace(/^refs\/remotes\//, "") : ""
-    const remoteBranch = remote && remoteTarget.startsWith(`${remote}/`) ? remoteTarget.slice(`${remote}/`.length) : ""
-
-    const mainCheck = await git(["show-ref", "--verify", "--quiet", "refs/heads/main"], {
-      cwd: Instance.worktree,
-    })
-    const masterCheck = await git(["show-ref", "--verify", "--quiet", "refs/heads/master"], {
-      cwd: Instance.worktree,
-    })
-    const localBranch = mainCheck.exitCode === 0 ? "main" : masterCheck.exitCode === 0 ? "master" : ""
+          const stale = yield* locateWorktree(parseWorktreeList(next.text), directory)
+          if (stale?.path) {
+            throw new RemoveFailedError({ message: removed.stderr || removed.text || "Failed to remove git worktree" })
+          }
+        }
 
-    const target = remoteBranch ? `${remote}/${remoteBranch}` : localBranch
-    if (!target) {
-      throw new ResetFailedError({ message: "Default branch not found" })
-    }
+        yield* cleanDirectory(entry.path)
 
-    if (remoteBranch) {
-      const fetch = await git(["fetch", remote, remoteBranch], { cwd: Instance.worktree })
-      if (fetch.exitCode !== 0) {
-        throw new ResetFailedError({ message: errorText(fetch) || `Failed to fetch ${target}` })
-      }
-    }
+        const branch = entry.branch?.replace(/^refs\/heads\//, "")
+        if (branch) {
+          const deleted = yield* git(["branch", "-D", branch], { cwd: Instance.worktree })
+          if (deleted.code !== 0) {
+            throw new RemoveFailedError({
+              message: deleted.stderr || deleted.text || "Failed to delete worktree branch",
+            })
+          }
+        }
+
+        return true
+      })
+
+      const gitExpect = Effect.fnUntraced(function* (
+        args: string[],
+        opts: { cwd: string },
+        error: (r: GitResult) => Error,
+      ) {
+        const result = yield* git(args, opts)
+        if (result.code !== 0) throw error(result)
+        return result
+      })
+
+      const runStartCommand = Effect.fnUntraced(
+        function* (directory: string, cmd: string) {
+          const [shell, args] = process.platform === "win32" ? ["cmd", ["/c", cmd]] : ["bash", ["-lc", cmd]]
+          const handle = yield* spawner.spawn(
+            ChildProcess.make(shell, args, { cwd: directory, extendEnv: true, stdin: "ignore" }),
+          )
+          // Drain stdout, capture stderr for error reporting
+          const [, stderr] = yield* Effect.all(
+            [Stream.runDrain(handle.stdout), Stream.mkString(Stream.decodeText(handle.stderr))],
+            { concurrency: 2 },
+          ).pipe(Effect.orDie)
+          const code = yield* handle.exitCode
+          return { code, stderr }
+        },
+        Effect.scoped,
+        Effect.catch(() => Effect.succeed({ code: 1, stderr: "" })),
+      )
+
+      const runStartScript = Effect.fnUntraced(function* (directory: string, cmd: string, kind: string) {
+        const text = cmd.trim()
+        if (!text) return true
+        const result = yield* runStartCommand(directory, text)
+        if (result.code === 0) return true
+        log.error("worktree start command failed", { kind, directory, message: result.stderr })
+        return false
+      })
 
-    if (!entry.path) {
-      throw new ResetFailedError({ message: "Worktree path not found" })
-    }
+      const runStartScripts = Effect.fnUntraced(function* (
+        directory: string,
+        input: { projectID: ProjectID; extra?: string },
+      ) {
+        const row = yield* Effect.sync(() =>
+          Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, input.projectID)).get()),
+        )
+        const project = row ? Project.fromRow(row) : undefined
+        const startup = project?.commands?.start?.trim() ?? ""
+        const ok = yield* runStartScript(directory, startup, "project")
+        if (!ok) return false
+        yield* runStartScript(directory, input.extra ?? "", "worktree")
+        return true
+      })
 
-    const worktreePath = entry.path
+      const prune = Effect.fnUntraced(function* (root: string, entries: string[]) {
+        const base = yield* canonical(root)
+        yield* Effect.forEach(
+          entries,
+          (entry) =>
+            Effect.gen(function* () {
+              const target = yield* canonical(pathSvc.resolve(root, entry))
+              if (target === base) return
+              if (!target.startsWith(`${base}${pathSvc.sep}`)) return
+              yield* fsys.remove(target, { recursive: true }).pipe(Effect.ignore)
+            }),
+          { concurrency: "unbounded" },
+        )
+      })
 
-    const resetToTarget = await git(["reset", "--hard", target], { cwd: worktreePath })
-    if (resetToTarget.exitCode !== 0) {
-      throw new ResetFailedError({ message: errorText(resetToTarget) || "Failed to reset worktree to target" })
-    }
+      const sweep = Effect.fnUntraced(function* (root: string) {
+        const first = yield* git(["clean", "-ffdx"], { cwd: root })
+        if (first.code === 0) return first
 
-    const clean = await sweep(worktreePath)
-    if (clean.exitCode !== 0) {
-      throw new ResetFailedError({ message: errorText(clean) || "Failed to clean worktree" })
-    }
+        const entries = failedRemoves(first.stderr, first.text)
+        if (!entries.length) return first
 
-    const update = await git(["submodule", "update", "--init", "--recursive", "--force"], { cwd: worktreePath })
-    if (update.exitCode !== 0) {
-      throw new ResetFailedError({ message: errorText(update) || "Failed to update submodules" })
-    }
+        yield* prune(root, entries)
+        return yield* git(["clean", "-ffdx"], { cwd: root })
+      })
 
-    const subReset = await git(["submodule", "foreach", "--recursive", "git", "reset", "--hard"], {
-      cwd: worktreePath,
-    })
-    if (subReset.exitCode !== 0) {
-      throw new ResetFailedError({ message: errorText(subReset) || "Failed to reset submodules" })
-    }
+      const reset = Effect.fn("Worktree.reset")(function* (input: ResetInput) {
+        if (Instance.project.vcs !== "git") {
+          throw new NotGitError({ message: "Worktrees are only supported for git projects" })
+        }
 
-    const subClean = await git(["submodule", "foreach", "--recursive", "git", "clean", "-fdx"], {
-      cwd: worktreePath,
-    })
-    if (subClean.exitCode !== 0) {
-      throw new ResetFailedError({ message: errorText(subClean) || "Failed to clean submodules" })
-    }
+        const directory = yield* canonical(input.directory)
+        const primary = yield* canonical(Instance.worktree)
+        if (directory === primary) {
+          throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
+        }
 
-    const status = await git(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath })
-    if (status.exitCode !== 0) {
-      throw new ResetFailedError({ message: errorText(status) || "Failed to read git status" })
-    }
+        const list = yield* git(["worktree", "list", "--porcelain"], { cwd: Instance.worktree })
+        if (list.code !== 0) {
+          throw new ResetFailedError({ message: list.stderr || list.text || "Failed to read git worktrees" })
+        }
+
+        const entry = yield* locateWorktree(parseWorktreeList(list.text), directory)
+        if (!entry?.path) {
+          throw new ResetFailedError({ message: "Worktree not found" })
+        }
 
-    const dirty = outputText(status.stdout)
-    if (dirty) {
-      throw new ResetFailedError({ message: `Worktree reset left local changes:\n${dirty}` })
-    }
+        const worktreePath = entry.path
+
+        const remoteList = yield* git(["remote"], { cwd: Instance.worktree })
+        if (remoteList.code !== 0) {
+          throw new ResetFailedError({ message: remoteList.stderr || remoteList.text || "Failed to list git remotes" })
+        }
+
+        const remotes = remoteList.text
+          .split("\n")
+          .map((l) => l.trim())
+          .filter(Boolean)
+        const remote = remotes.includes("origin")
+          ? "origin"
+          : remotes.length === 1
+            ? remotes[0]
+            : remotes.includes("upstream")
+              ? "upstream"
+              : ""
+
+        const remoteHead = remote
+          ? yield* git(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: Instance.worktree })
+          : { code: 1, text: "", stderr: "" }
+
+        const remoteRef = remoteHead.code === 0 ? remoteHead.text.trim() : ""
+        const remoteTarget = remoteRef ? remoteRef.replace(/^refs\/remotes\//, "") : ""
+        const remoteBranch =
+          remote && remoteTarget.startsWith(`${remote}/`) ? remoteTarget.slice(`${remote}/`.length) : ""
+
+        const [mainCheck, masterCheck] = yield* Effect.all(
+          [
+            git(["show-ref", "--verify", "--quiet", "refs/heads/main"], { cwd: Instance.worktree }),
+            git(["show-ref", "--verify", "--quiet", "refs/heads/master"], { cwd: Instance.worktree }),
+          ],
+          { concurrency: 2 },
+        )
+        const localBranch = mainCheck.code === 0 ? "main" : masterCheck.code === 0 ? "master" : ""
+
+        const target = remoteBranch ? `${remote}/${remoteBranch}` : localBranch
+        if (!target) {
+          throw new ResetFailedError({ message: "Default branch not found" })
+        }
+
+        if (remoteBranch) {
+          yield* gitExpect(
+            ["fetch", remote, remoteBranch],
+            { cwd: Instance.worktree },
+            (r) => new ResetFailedError({ message: r.stderr || r.text || `Failed to fetch ${target}` }),
+          )
+        }
 
-    const projectID = Instance.project.id
-    queueStartScripts(worktreePath, { projectID })
+        yield* gitExpect(
+          ["reset", "--hard", target],
+          { cwd: worktreePath },
+          (r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to reset worktree to target" }),
+        )
 
-    return true
-  })
+        const cleanResult = yield* sweep(worktreePath)
+        if (cleanResult.code !== 0) {
+          throw new ResetFailedError({ message: cleanResult.stderr || cleanResult.text || "Failed to clean worktree" })
+        }
+
+        yield* gitExpect(
+          ["submodule", "update", "--init", "--recursive", "--force"],
+          { cwd: worktreePath },
+          (r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to update submodules" }),
+        )
+
+        yield* gitExpect(
+          ["submodule", "foreach", "--recursive", "git", "reset", "--hard"],
+          { cwd: worktreePath },
+          (r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to reset submodules" }),
+        )
+
+        yield* gitExpect(
+          ["submodule", "foreach", "--recursive", "git", "clean", "-fdx"],
+          { cwd: worktreePath },
+          (r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to clean submodules" }),
+        )
+
+        const status = yield* git(["-c", "core.fsmonitor=false", "status", "--porcelain=v1"], { cwd: worktreePath })
+        if (status.code !== 0) {
+          throw new ResetFailedError({ message: status.stderr || status.text || "Failed to read git status" })
+        }
+
+        if (status.text.trim()) {
+          throw new ResetFailedError({ message: `Worktree reset left local changes:\n${status.text.trim()}` })
+        }
+
+        yield* runStartScripts(worktreePath, { projectID: Instance.project.id }).pipe(
+          Effect.catchCause((cause) => Effect.sync(() => log.error("worktree start task failed", { cause }))),
+          Effect.forkIn(scope),
+        )
+
+        return true
+      })
+
+      return Service.of({ makeWorktreeInfo, createFromInfo, create, remove, reset })
+    }),
+  )
+
+  const defaultLayer = layer.pipe(
+    Layer.provide(CrossSpawnSpawner.layer),
+    Layer.provide(NodeFileSystem.layer),
+    Layer.provide(NodePath.layer),
+  )
+  const runPromise = makeRunPromise(Service, defaultLayer)
+
+  export async function makeWorktreeInfo(name?: string) {
+    return runPromise((svc) => svc.makeWorktreeInfo(name))
+  }
+
+  export async function createFromInfo(info: Info, startCommand?: string) {
+    return runPromise((svc) => svc.createFromInfo(info, startCommand))
+  }
+
+  export async function create(input?: CreateInput) {
+    return runPromise((svc) => svc.create(input))
+  }
+
+  export async function remove(input: RemoveInput) {
+    return runPromise((svc) => svc.remove(input))
+  }
+
+  export async function reset(input: ResetInput) {
+    return runPromise((svc) => svc.reset(input))
+  }
 }

+ 173 - 0
packages/opencode/test/project/worktree.test.ts

@@ -0,0 +1,173 @@
+import { $ } from "bun"
+import { afterEach, describe, expect, test } from "bun:test"
+
+const wintest = process.platform !== "win32" ? test : test.skip
+import fs from "fs/promises"
+import path from "path"
+import { Instance } from "../../src/project/instance"
+import { Worktree } from "../../src/worktree"
+import { tmpdir } from "../fixture/fixture"
+
+function withInstance(directory: string, fn: () => Promise<any>) {
+  return Instance.provide({ directory, fn })
+}
+
+function normalize(input: string) {
+  return input.replace(/\\/g, "/").toLowerCase()
+}
+
+async function waitReady() {
+  const { GlobalBus } = await import("../../src/bus/global")
+
+  return await new Promise<{ name: string; branch: string }>((resolve, reject) => {
+    const timer = setTimeout(() => {
+      GlobalBus.off("event", on)
+      reject(new Error("timed out waiting for worktree.ready"))
+    }, 10_000)
+
+    function on(evt: { directory?: string; payload: { type: string; properties: { name: string; branch: string } } }) {
+      if (evt.payload.type !== Worktree.Event.Ready.type) return
+      clearTimeout(timer)
+      GlobalBus.off("event", on)
+      resolve(evt.payload.properties)
+    }
+
+    GlobalBus.on("event", on)
+  })
+}
+
+describe("Worktree", () => {
+  afterEach(() => Instance.disposeAll())
+
+  describe("makeWorktreeInfo", () => {
+    test("returns info with name, branch, and directory", async () => {
+      await using tmp = await tmpdir({ git: true })
+
+      const info = await withInstance(tmp.path, () => Worktree.makeWorktreeInfo())
+
+      expect(info.name).toBeDefined()
+      expect(typeof info.name).toBe("string")
+      expect(info.branch).toBe(`opencode/${info.name}`)
+      expect(info.directory).toContain(info.name)
+    })
+
+    test("uses provided name as base", async () => {
+      await using tmp = await tmpdir({ git: true })
+
+      const info = await withInstance(tmp.path, () => Worktree.makeWorktreeInfo("my-feature"))
+
+      expect(info.name).toBe("my-feature")
+      expect(info.branch).toBe("opencode/my-feature")
+    })
+
+    test("slugifies the provided name", async () => {
+      await using tmp = await tmpdir({ git: true })
+
+      const info = await withInstance(tmp.path, () => Worktree.makeWorktreeInfo("My Feature Branch!"))
+
+      expect(info.name).toBe("my-feature-branch")
+    })
+
+    test("throws NotGitError for non-git directories", async () => {
+      await using tmp = await tmpdir()
+
+      await expect(withInstance(tmp.path, () => Worktree.makeWorktreeInfo())).rejects.toThrow("WorktreeNotGitError")
+    })
+  })
+
+  describe("create + remove lifecycle", () => {
+    test("create returns worktree info and remove cleans up", async () => {
+      await using tmp = await tmpdir({ git: true })
+
+      const info = await withInstance(tmp.path, () => Worktree.create())
+
+      expect(info.name).toBeDefined()
+      expect(info.branch).toStartWith("opencode/")
+      expect(info.directory).toBeDefined()
+
+      // Wait for bootstrap to complete
+      await Bun.sleep(1000)
+
+      const ok = await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory }))
+      expect(ok).toBe(true)
+    })
+
+    test("create returns after setup and fires Event.Ready after bootstrap", async () => {
+      await using tmp = await tmpdir({ git: true })
+      const ready = waitReady()
+
+      const info = await withInstance(tmp.path, () => Worktree.create())
+
+      // create returns before bootstrap completes, but the worktree already exists
+      expect(info.name).toBeDefined()
+      expect(info.branch).toStartWith("opencode/")
+
+      const text = await $`git worktree list --porcelain`.cwd(tmp.path).quiet().text()
+      const dir = await fs.realpath(info.directory).catch(() => info.directory)
+      expect(normalize(text)).toContain(normalize(dir))
+
+      // Event.Ready fires after bootstrap finishes in the background
+      const props = await ready
+      expect(props.name).toBe(info.name)
+      expect(props.branch).toBe(info.branch)
+
+      // Cleanup
+      await withInstance(info.directory, () => Instance.dispose())
+      await Bun.sleep(100)
+      await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory }))
+    })
+
+    test("create with custom name", async () => {
+      await using tmp = await tmpdir({ git: true })
+      const ready = waitReady()
+
+      const info = await withInstance(tmp.path, () => Worktree.create({ name: "test-workspace" }))
+
+      expect(info.name).toBe("test-workspace")
+      expect(info.branch).toBe("opencode/test-workspace")
+
+      // Cleanup
+      await ready
+      await withInstance(info.directory, () => Instance.dispose())
+      await Bun.sleep(100)
+      await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory }))
+    })
+  })
+
+  describe("createFromInfo", () => {
+    wintest("creates and bootstraps git worktree", async () => {
+      await using tmp = await tmpdir({ git: true })
+
+      const info = await withInstance(tmp.path, () => Worktree.makeWorktreeInfo("from-info-test"))
+      await withInstance(tmp.path, () => Worktree.createFromInfo(info))
+
+      // Worktree should exist in git (normalize slashes for Windows)
+      const list = await $`git worktree list --porcelain`.cwd(tmp.path).quiet().text()
+      const normalizedList = list.replace(/\\/g, "/")
+      const normalizedDir = info.directory.replace(/\\/g, "/")
+      expect(normalizedList).toContain(normalizedDir)
+
+      // Cleanup
+      await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory }))
+    })
+  })
+
+  describe("remove edge cases", () => {
+    test("remove non-existent directory succeeds silently", async () => {
+      await using tmp = await tmpdir({ git: true })
+
+      const ok = await withInstance(tmp.path, () =>
+        Worktree.remove({ directory: path.join(tmp.path, "does-not-exist") }),
+      )
+      expect(ok).toBe(true)
+    })
+
+    test("throws NotGitError for non-git directories", async () => {
+      await using tmp = await tmpdir()
+
+      await expect(withInstance(tmp.path, () => Worktree.remove({ directory: "/tmp/fake" }))).rejects.toThrow(
+        "WorktreeNotGitError",
+      )
+    })
+  })
+})