import fs from "fs/promises" import path from "path" import z from "zod" import { NamedError } from "@opencode-ai/util/error" import { Global } from "../global" import { Instance } from "../project/instance" import { InstanceBootstrap } from "../project/bootstrap" 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 "@/git" import { BusEvent } from "@/bus/bus-event" import { GlobalBus } from "@/bus/global" export namespace Worktree { const log = Log.create({ service: "worktree" }) export const Event = { Ready: BusEvent.define( "worktree.ready", z.object({ name: z.string(), branch: z.string(), }), ), Failed: BusEvent.define( "worktree.failed", z.object({ message: z.string(), }), ), } export const Info = z .object({ name: z.string(), branch: z.string(), directory: z.string(), }) .meta({ ref: "Worktree", }) export type Info = z.infer export const CreateInput = z .object({ name: z.string().optional(), startCommand: z .string() .optional() .describe("Additional startup script to run after the project's start command"), }) .meta({ ref: "WorktreeCreateInput", }) export type CreateInput = z.infer export const RemoveInput = z .object({ directory: z.string(), }) .meta({ ref: "WorktreeRemoveInput", }) export type RemoveInput = z.infer export const ResetInput = z .object({ directory: z.string(), }) .meta({ ref: "WorktreeResetInput", }) export type ResetInput = z.infer export const NotGitError = NamedError.create( "WorktreeNotGitError", z.object({ message: z.string(), }), ) export const NameGenerationFailedError = NamedError.create( "WorktreeNameGenerationFailedError", z.object({ message: z.string(), }), ) export const CreateFailedError = NamedError.create( "WorktreeCreateFailedError", z.object({ message: z.string(), }), ) export const StartCommandFailedError = NamedError.create( "WorktreeStartCommandFailedError", z.object({ message: z.string(), }), ) export const RemoveFailedError = NamedError.create( "WorktreeRemoveFailedError", z.object({ message: z.string(), }), ) export const ResetFailedError = NamedError.create( "WorktreeResetFailedError", z.object({ message: z.string(), }), ) 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(list: T) { return list[Math.floor(Math.random() * list.length)] } function slug(input: string) { return input .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+/, "") .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) => chunk .split("\n") .map((line) => line.trim()) .flatMap((line) => { const match = line.match(/^warning:\s+failed to remove\s+(.+):\s+/i) if (!match) return [] const value = match[1]?.trim().replace(/^['"]|['"]$/g, "") if (!value) return [] return [value] }), ) } 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.run(["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.run(["clean", "-ffdx"], { cwd: root }) } 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 } 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.run(["show-ref", "--verify", "--quiet", ref], { cwd: Instance.worktree, }) 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 } 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 }) }) }, 0) } export async function makeWorktreeInfo(name?: string): Promise { 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.run(["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.run(["reset", "--hard"], { cwd: info.directory }) if (populated.exitCode !== 0) { const message = errorText(populated) || "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, }, }, }) 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 }) if (!booted) return GlobalBus.emit("event", { directory: info.directory, payload: { type: Event.Ready.type, properties: { name: info.name, branch: info.branch, }, }, }) await runStartScripts(info.directory, { projectID, extra }) } 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 () => { for (const item of entries) { if (!item.path) continue const key = await 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" }) }) const stop = async (target: string) => { if (!(await exists(target))) return await Git.run(["fsmonitor--daemon", "stop"], { cwd: target }) } const list = await Git.run(["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.run(["worktree", "remove", "--force", entry.path], { cwd: Instance.worktree, }) if (removed.exitCode !== 0) { const next = await Git.run(["worktree", "list", "--porcelain"], { cwd: Instance.worktree }) if (next.exitCode !== 0) { throw new RemoveFailedError({ message: errorText(removed) || errorText(next) || "Failed to remove git worktree", }) } const stale = await locate(next.stdout) if (stale?.path) { throw new RemoveFailedError({ message: errorText(removed) || "Failed to remove git worktree" }) } } await clean(entry.path) const branch = entry.branch?.replace(/^refs\/heads\//, "") if (branch) { const deleted = await Git.run(["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.run(["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.run(["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.run(["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.run(["show-ref", "--verify", "--quiet", "refs/heads/main"], { cwd: Instance.worktree, }) const masterCheck = await Git.run(["show-ref", "--verify", "--quiet", "refs/heads/master"], { cwd: Instance.worktree, }) const localBranch = mainCheck.exitCode === 0 ? "main" : masterCheck.exitCode === 0 ? "master" : "" const target = remoteBranch ? `${remote}/${remoteBranch}` : localBranch if (!target) { throw new ResetFailedError({ message: "Default branch not found" }) } if (remoteBranch) { const fetch = await Git.run(["fetch", remote, remoteBranch], { cwd: Instance.worktree }) if (fetch.exitCode !== 0) { throw new ResetFailedError({ message: errorText(fetch) || `Failed to fetch ${target}` }) } } if (!entry.path) { throw new ResetFailedError({ message: "Worktree path not found" }) } const worktreePath = entry.path const resetToTarget = await Git.run(["reset", "--hard", target], { cwd: worktreePath }) if (resetToTarget.exitCode !== 0) { throw new ResetFailedError({ message: errorText(resetToTarget) || "Failed to reset worktree to target" }) } const clean = await sweep(worktreePath) if (clean.exitCode !== 0) { throw new ResetFailedError({ message: errorText(clean) || "Failed to clean worktree" }) } const update = await Git.run(["submodule", "update", "--init", "--recursive", "--force"], { cwd: worktreePath }) if (update.exitCode !== 0) { throw new ResetFailedError({ message: errorText(update) || "Failed to update submodules" }) } const subReset = await Git.run(["submodule", "foreach", "--recursive", "git", "reset", "--hard"], { cwd: worktreePath, }) if (subReset.exitCode !== 0) { throw new ResetFailedError({ message: errorText(subReset) || "Failed to reset submodules" }) } const subClean = await Git.run(["submodule", "foreach", "--recursive", "git", "clean", "-fdx"], { cwd: worktreePath, }) if (subClean.exitCode !== 0) { throw new ResetFailedError({ message: errorText(subClean) || "Failed to clean submodules" }) } const status = await Git.run(["-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 dirty = outputText(status.stdout) if (dirty) { throw new ResetFailedError({ message: `Worktree reset left local changes:\n${dirty}` }) } const projectID = Instance.project.id queueStartScripts(worktreePath, { projectID }) return true }) }