Compare commits

...

13 Commits

Author SHA1 Message Date
Kit Langton
35df4c0184 simplify: remove unused Fiber import, parallelize branch checks, drop WHAT comments 2026-03-22 19:50:00 -04:00
Kit Langton
3a2279419c cleanup: remove dead Uint8Array helpers, fnUntraced for gitExpect 2026-03-22 19:44:47 -04:00
Kit Langton
9e29a0d96d effectify prune: use Effect.forEach with concurrency, remove fs/path imports 2026-03-22 19:42:15 -04:00
Kit Langton
eab05f5c5e effectify start scripts: use ChildProcessSpawner, delete dead async helpers
- Move runStartCommand/runStartScript/runStartScripts inside layer with spawner
- Replace queueStartScripts with Effect.forkIn(scope)
- Delete dead async helpers: exists, sweep, canonical, candidate
- Remove unused imports: Process, git (from util)
- Rename Effect-suffixed helpers: canonicalEffect→canonical, etc.
- Only prune remains async (called from sweep via Effect.promise)
2026-03-22 19:40:51 -04:00
Kit Langton
f0e214d0ac effectify Worktree.reset: use gitRun, canonicalEffect, sweepEffect, gitExpect helper 2026-03-22 19:36:29 -04:00
Kit Langton
3c8e0fd0f4 effectify Worktree.remove: use gitRun, FileSystem, Path throughout 2026-03-22 19:34:51 -04:00
Kit Langton
3042d867bb test(worktree): verify create returns immediately and Event.Ready fires after bootstrap 2026-03-22 19:31:47 -04:00
Kit Langton
45557d1b55 refactor createFromInfo: inline bootstrap as Effect, fork in create
- createFromInfo now does git add + populate + bootstrap as one Effect
- create forks it via Effect.forkIn(scope) — returns Info immediately
- Remove closure return pattern — simpler, no setTimeout
- Use gitRun for git reset --hard (effectful)
- Update control-plane adaptor (no more bootstrap() call)
2026-03-22 19:30:44 -04:00
Kit Langton
66e293133c cleanup: remove Fn suffixes, use shorthand property names 2026-03-22 19:24:56 -04:00
Kit Langton
3aa7fbfeb0 effectify Worktree: use ChildProcessSpawner for git, FileSystem + Path for fs
- Add gitRun helper using spawner.spawn + ChildProcess.make (Snapshot pattern)
- Convert makeWorktreeInfo + candidate to fully effectful (FileSystem, Path, gitRun)
- createFromInfo/remove/reset remain Effect.promise wrappers (incremental conversion)
- Layer depends on FileSystem, Path, ChildProcessSpawner; defaultLayer provides Node impls
2026-03-22 19:23:10 -04:00
Kit Langton
b9ca9588f7 effectify Worktree service: migrate to Effect service pattern
- Add Service/Interface/layer/makeRunPromise boilerplate
- Wrap all public functions in Effect.fn + Effect.promise
- Replace fn() input validators with raw zod schemas in routes
- Keep internal helpers and GlobalBus.emit as-is
- Add 9 comprehensive tests covering makeWorktreeInfo, create, createFromInfo, remove
2026-03-22 19:18:59 -04:00
Kit Langton
13f419c4f4 test(worktree): add comprehensive tests before Effect migration 2026-03-22 19:11:11 -04:00
David Hill
32f9dc6383 fix(ui): stop auto close of sidebar on resize (#18647) 2026-03-23 08:53:12 +10:00
6 changed files with 558 additions and 392 deletions

View File

@@ -2368,14 +2368,12 @@ export default function Layout(props: ParentProps) {
size={layout.sidebar.width()}
min={244}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
collapseThreshold={244}
onResize={(w) => {
setState("sizing", true)
if (sizet !== undefined) clearTimeout(sizet)
sizet = window.setTimeout(() => setState("sizing", false), 120)
layout.sidebar.resize(w)
}}
onCollapse={layout.sidebar.close}
/>
</div>
</Show>

View File

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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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,13 @@ 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 { 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 { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
import { makeRunPromise } from "@/effect/run-service"
export namespace Worktree {
const log = Log.create({ service: "worktree" })
@@ -206,24 +205,8 @@ export namespace Worktree {
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 +220,460 @@ 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)
}),
)
// ---------------------------------------------------------------------------
// Effect service
// ---------------------------------------------------------------------------
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 sweep(root: string) {
const first = await git(["clean", "-ffdx"], { cwd: root })
if (first.exitCode === 0) return first
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Worktree") {}
const entries = failed(first)
if (!entries.length) return first
type GitResult = { code: number; text: string; stderr: string }
await prune(root, entries)
return git(["clean", "-ffdx"], { cwd: root })
}
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
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
}
const git = Effect.fnUntraced(
function* (args: string[], opts?: { cwd?: string }) {
const handle = yield* spawner.spawn(ChildProcess.make("git", args, { cwd: opts?.cwd, extendEnv: true }))
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(() => Effect.succeed({ code: 1, text: "", stderr: "" } satisfies GitResult)),
)
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)
const candidate = Effect.fn("Worktree.candidate")(function* (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 = pathSvc.join(root, name)
if (await exists(directory)) continue
if (yield* fsys.exists(directory).pipe(Effect.orDie)) continue
const ref = `refs/heads/${branch}`
const branchCheck = await git(["show-ref", "--verify", "--quiet", ref], {
cwd: Instance.worktree,
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 })
}
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" })
}
throw new NameGenerationFailedError({ message: "Failed to generate a unique worktree name" })
}
const root = pathSvc.join(Global.Path.data, "worktree", Instance.project.id)
yield* fsys.makeDirectory(root, { recursive: true }).pipe(Effect.orDie)
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
}
function queueStartScripts(directory: string, input: { projectID: ProjectID; extra?: string }) {
setTimeout(() => {
const start = async () => {
await runStartScripts(directory, input)
}
void start().catch((error) => {
log.error("worktree start task failed", { directory, error })
const base = name ? slug(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 createFromInfo = Effect.fn("Worktree.createFromInfo")(function* (info: Info, startCommand?: string) {
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" })
}
const root = path.join(Global.Path.data, "worktree", Instance.project.id)
await fs.mkdir(root, { recursive: true })
yield* Effect.promise(() => Project.addSandbox(Instance.project.id, info.directory).catch(() => undefined))
const base = name ? slug(name) : ""
return candidate(root, base || undefined)
}
const projectID = Instance.project.id
const extra = startCommand?.trim()
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" })
}
await Project.addSandbox(Instance.project.id, info.directory).catch(() => undefined)
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 })
})
const create = Effect.fn("Worktree.create")(function* (input?: CreateInput) {
const info = yield* makeWorktreeInfo(input?.name)
yield* createFromInfo(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 start().catch((error) => {
log.error("worktree start task failed", { directory: info.directory, error })
})
}
}
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
}, [])
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
}
})()
}
return undefined
})
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" })
})
const stop = async (target: string) => {
if (!(await exists(target))) return
await git(["fsmonitor--daemon", "stop"], { cwd: target })
}
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 entry = await locate(list.stdout)
if (!entry?.path) {
const directoryExists = await exists(directory)
if (directoryExists) {
await stop(directory)
await clean(directory)
}
return true
}
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",
})
function stopFsmonitor(target: string) {
return fsys.exists(target).pipe(
Effect.orDie,
Effect.flatMap((exists) => (exists ? git(["fsmonitor--daemon", "stop"], { cwd: target }) : Effect.void)),
)
}
const stale = await locate(next.stdout)
if (stale?.path) {
throw new RemoveFailedError({ message: errorText(removed) || "Failed to remove git worktree" })
function cleanDirectory(target: string) {
return fsys.remove(target, { recursive: true }).pipe(Effect.ignore)
}
}
await clean(entry.path)
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 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" })
}
}
const directory = yield* canonical(input.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" })
}
export const reset = fn(ResetInput, async (input) => {
if (Instance.project.vcs !== "git") {
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
}
const entries = parseWorktreeList(list.text)
const entry = yield* locateWorktree(entries, directory)
const directory = await canonical(input.directory)
const primary = await canonical(Instance.worktree)
if (directory === primary) {
throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
}
if (!entry?.path) {
const directoryExists = yield* fsys.exists(directory).pipe(Effect.orDie)
if (directoryExists) {
yield* stopFsmonitor(directory)
yield* cleanDirectory(directory)
}
return true
}
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" })
}
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 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 stale = yield* locateWorktree(parseWorktreeList(next.text), directory)
if (stale?.path) {
throw new RemoveFailedError({ message: removed.stderr || removed.text || "Failed to remove git worktree" })
}
}
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" })
}
yield* cleanDirectory(entry.path)
const remoteList = await git(["remote"], { cwd: Instance.worktree })
if (remoteList.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(remoteList) || "Failed to list git remotes" })
}
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",
})
}
}
const remotes = outputText(remoteList.stdout)
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
return true
})
const remote = remotes.includes("origin")
? "origin"
: remotes.length === 1
? remotes[0]
: remotes.includes("upstream")
? "upstream"
: ""
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 remoteHead = remote
? await git(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: Instance.worktree })
: { exitCode: 1, stdout: undefined, stderr: undefined }
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 }))
const code = yield* handle.exitCode
return code
},
Effect.scoped,
Effect.catch(() => Effect.succeed(1)),
)
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 runStartScript = Effect.fnUntraced(function* (directory: string, cmd: string, kind: string) {
const text = cmd.trim()
if (!text) return true
const code = yield* runStartCommand(directory, text)
if (code === 0) return true
log.error("worktree start command failed", { kind, directory })
return false
})
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 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 target = remoteBranch ? `${remote}/${remoteBranch}` : localBranch
if (!target) {
throw new ResetFailedError({ message: "Default branch not found" })
}
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" },
)
})
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 sweep = Effect.fnUntraced(function* (root: string) {
const first = yield* git(["clean", "-ffdx"], { cwd: root })
if (first.code === 0) return first
if (!entry.path) {
throw new ResetFailedError({ message: "Worktree path not found" })
}
const entries = failedRemoves(first.stderr, first.text)
if (!entries.length) return first
const worktreePath = entry.path
yield* prune(root, entries)
return yield* git(["clean", "-ffdx"], { cwd: root })
})
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 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 clean = await sweep(worktreePath)
if (clean.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(clean) || "Failed to clean worktree" })
}
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 update = await git(["submodule", "update", "--init", "--recursive", "--force"], { cwd: worktreePath })
if (update.exitCode !== 0) {
throw new ResetFailedError({ message: errorText(update) || "Failed to update submodules" })
}
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 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 entry = yield* locateWorktree(parseWorktreeList(list.text), directory)
if (!entry?.path) {
throw new ResetFailedError({ message: "Worktree not found" })
}
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 worktreePath = entry.path
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 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 dirty = outputText(status.stdout)
if (dirty) {
throw new ResetFailedError({ message: `Worktree reset left local changes:\n${dirty}` })
}
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 projectID = Instance.project.id
queueStartScripts(worktreePath, { projectID })
const remoteHead = remote
? yield* git(["symbolic-ref", `refs/remotes/${remote}/HEAD`], { cwd: Instance.worktree })
: { code: 1, text: "", stderr: "" }
return true
})
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}` }),
)
}
yield* gitExpect(
["reset", "--hard", target],
{ cwd: worktreePath },
(r) => new ResetFailedError({ message: r.stderr || r.text || "Failed to reset worktree to target" }),
)
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(NodeChildProcessSpawner.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))
}
}

View File

@@ -0,0 +1,162 @@
import { $ } from "bun"
import { afterEach, describe, expect, test } from "bun:test"
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 })
}
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()
// Worktree directory should exist after bootstrap
await Bun.sleep(500)
const ok = await withInstance(tmp.path, () => Worktree.remove({ directory: info.directory }))
expect(ok).toBe(true)
// Directory should be cleaned up
const exists = await fs.stat(info.directory).then(() => true).catch(() => false)
expect(exists).toBe(false)
// Branch should be deleted
const ref = await $`git show-ref --verify --quiet refs/heads/${info.branch}`.cwd(tmp.path).quiet().nothrow()
expect(ref.exitCode).not.toBe(0)
})
test("create returns info immediately and fires Event.Ready after bootstrap", async () => {
await using tmp = await tmpdir({ git: true })
const { GlobalBus } = await import("../../src/bus/global")
const ready = 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: any } }) {
if (evt.payload.type !== Worktree.Event.Ready.type) return
clearTimeout(timer)
GlobalBus.off("event", on)
resolve(evt.payload.properties)
}
GlobalBus.on("event", on)
})
const info = await withInstance(tmp.path, () => Worktree.create())
// create returns immediately — info is available before bootstrap completes
expect(info.name).toBeDefined()
expect(info.branch).toStartWith("opencode/")
// 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 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 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 withInstance(tmp.path, () => Worktree.remove({ directory: info.directory }))
})
})
describe("createFromInfo", () => {
test("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
const list = await $`git worktree list --porcelain`.cwd(tmp.path).quiet().text()
expect(list).toContain(info.directory)
// 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")
})
})
})