| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548 |
- import { $ } from "bun"
- 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 { Storage } from "../storage/storage"
- import { fn } from "../util/fn"
- import { Log } from "../util/log"
- 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<typeof Info>
- 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<typeof CreateInput>
- export const RemoveInput = z
- .object({
- directory: z.string(),
- })
- .meta({
- ref: "WorktreeRemoveInput",
- })
- export type RemoveInput = z.infer<typeof RemoveInput>
- export const ResetInput = z
- .object({
- directory: z.string(),
- })
- .meta({
- ref: "WorktreeResetInput",
- })
- export type ResetInput = z.infer<typeof ResetInput>
- 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<const T extends readonly string[]>(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")
- }
- 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}`.quiet().nothrow().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 $`cmd /c ${cmd}`.nothrow().cwd(directory)
- }
- return $`bash -lc ${cmd}`.nothrow().cwd(directory)
- }
- export const create = fn(CreateInput.optional(), async (input) => {
- 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 = input?.name ? slug(input.name) : ""
- const info = await candidate(root, base || undefined)
- const created = await $`git worktree add --no-checkout -b ${info.branch} ${info.directory}`
- .quiet()
- .nothrow()
- .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 = input?.startCommand?.trim()
- setTimeout(() => {
- const start = async () => {
- const populated = await $`git reset --hard`.quiet().nothrow().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,
- },
- },
- })
- const project = await Storage.read<Project.Info>(["project", projectID]).catch(() => undefined)
- const startup = project?.commands?.start?.trim() ?? ""
- const run = async (cmd: string, kind: "project" | "worktree") => {
- const ran = await runStartCommand(info.directory, cmd)
- if (ran.exitCode === 0) return true
- log.error("worktree start command failed", {
- kind,
- directory: info.directory,
- message: errorText(ran),
- })
- return false
- }
- if (startup) {
- const ok = await run(startup, "project")
- if (!ok) return
- }
- if (extra) {
- await run(extra, "worktree")
- }
- }
- void start().catch((error) => {
- log.error("worktree start task failed", { directory: info.directory, error })
- })
- }, 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 = path.resolve(input.directory)
- const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
- if (list.exitCode !== 0) {
- throw new RemoveFailedError({ 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 = entries.find((item) => item.path && path.resolve(item.path) === directory)
- if (!entry?.path) {
- throw new RemoveFailedError({ message: "Worktree not found" })
- }
- const removed = await $`git worktree remove --force ${entry.path}`.quiet().nothrow().cwd(Instance.worktree)
- if (removed.exitCode !== 0) {
- throw new RemoveFailedError({ message: errorText(removed) || "Failed to remove git worktree" })
- }
- const branch = entry.branch?.replace(/^refs\/heads\//, "")
- if (branch) {
- const deleted = await $`git branch -D ${branch}`.quiet().nothrow().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 = path.resolve(input.directory)
- if (directory === path.resolve(Instance.worktree)) {
- throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
- }
- const list = await $`git worktree list --porcelain`.quiet().nothrow().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 = entries.find((item) => item.path && path.resolve(item.path) === directory)
- if (!entry?.path) {
- throw new ResetFailedError({ message: "Worktree not found" })
- }
- const remoteList = await $`git remote`.quiet().nothrow().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`.quiet().nothrow().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`.quiet().nothrow().cwd(Instance.worktree)
- const masterCheck = await $`git show-ref --verify --quiet refs/heads/master`
- .quiet()
- .nothrow()
- .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 fetch ${remote} ${remoteBranch}`.quiet().nothrow().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 reset --hard ${target}`.quiet().nothrow().cwd(worktreePath)
- if (resetToTarget.exitCode !== 0) {
- throw new ResetFailedError({ message: errorText(resetToTarget) || "Failed to reset worktree to target" })
- }
- const clean = await $`git clean -fdx`.quiet().nothrow().cwd(worktreePath)
- if (clean.exitCode !== 0) {
- throw new ResetFailedError({ message: errorText(clean) || "Failed to clean worktree" })
- }
- const update = await $`git submodule update --init --recursive --force`.quiet().nothrow().cwd(worktreePath)
- if (update.exitCode !== 0) {
- throw new ResetFailedError({ message: errorText(update) || "Failed to update submodules" })
- }
- const subReset = await $`git submodule foreach --recursive git reset --hard`.quiet().nothrow().cwd(worktreePath)
- if (subReset.exitCode !== 0) {
- throw new ResetFailedError({ message: errorText(subReset) || "Failed to reset submodules" })
- }
- const subClean = await $`git submodule foreach --recursive git clean -fdx`.quiet().nothrow().cwd(worktreePath)
- if (subClean.exitCode !== 0) {
- throw new ResetFailedError({ message: errorText(subClean) || "Failed to clean submodules" })
- }
- const status = await $`git status --porcelain=v1`.quiet().nothrow().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
- setTimeout(() => {
- const start = async () => {
- const project = await Storage.read<Project.Info>(["project", projectID]).catch(() => undefined)
- const startup = project?.commands?.start?.trim() ?? ""
- if (!startup) return
- const ran = await runStartCommand(worktreePath, startup)
- if (ran.exitCode === 0) return
- log.error("worktree start command failed", {
- kind: "project",
- directory: worktreePath,
- message: errorText(ran),
- })
- }
- void start().catch((error) => {
- log.error("worktree start task failed", { directory: worktreePath, error })
- })
- }, 0)
- return true
- })
- }
|