feat: add managed git worktrees (#6674)

This commit is contained in:
Adam
2026-01-02 20:17:02 -06:00
committed by GitHub
parent f6fe709f6e
commit 052de3c556
11 changed files with 692 additions and 116 deletions

View File

@@ -1,4 +1,5 @@
import z from "zod"
import fs from "fs/promises"
import { Filesystem } from "../util/filesystem"
import path from "path"
import { $ } from "bun"
@@ -31,6 +32,7 @@ export namespace Project {
updated: z.number(),
initialized: z.number().optional(),
}),
sandboxes: z.array(z.string()).optional(),
})
.meta({
ref: "Project",
@@ -76,12 +78,16 @@ export namespace Project {
worktree,
vcs: "git",
}
worktree = await $`git rev-parse --show-toplevel`
worktree = await $`git rev-parse --git-common-dir`
.quiet()
.nothrow()
.cwd(worktree)
.text()
.then((x) => path.resolve(worktree, x.trim()))
.then((x) => {
const dirname = path.dirname(x.trim())
if (dirname === ".") return worktree
return dirname
})
return { id, worktree, vcs: "git" }
}
@@ -218,4 +224,32 @@ export namespace Project {
return result
},
)
export async function addSandbox(projectID: string, directory: string) {
const result = await Storage.update<Info>(["project", projectID], (draft) => {
if (!draft.sandboxes) draft.sandboxes = []
if (!draft.sandboxes.includes(directory)) {
draft.sandboxes.push(directory)
}
draft.time.updated = Date.now()
})
GlobalBus.emit("event", {
payload: {
type: Event.Updated.type,
properties: result,
},
})
return result
}
export async function sandboxes(projectID: string) {
const project = await Storage.read<Info>(["project", projectID]).catch(() => undefined)
if (!project?.sandboxes) return []
const valid: string[] = []
for (const dir of project.sandboxes) {
const stat = await fs.stat(dir).catch(() => undefined)
if (stat?.isDirectory()) valid.push(dir)
}
return valid
}
}

View File

@@ -21,6 +21,7 @@ import { Format } from "../format"
import { MessageV2 } from "../session/message-v2"
import { TuiRoute } from "./tui"
import { Instance } from "../project/instance"
import { Project } from "../project/project"
import { Vcs } from "../project/vcs"
import { Agent } from "../agent/agent"
import { Auth } from "../auth"
@@ -49,6 +50,7 @@ import { Pty } from "@/pty"
import { PermissionNext } from "@/permission/next"
import { Installation } from "@/installation"
import { MDNS } from "./mdns"
import { Worktree } from "../worktree"
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -79,6 +81,7 @@ export namespace Server {
let status: ContentfulStatusCode
if (err instanceof Storage.NotFoundError) status = 404
else if (err instanceof Provider.ModelNotFoundError) status = 400
else if (err.name.startsWith("Worktree")) status = 400
else status = 500
return c.json(err.toObject(), { status })
}
@@ -610,6 +613,53 @@ export namespace Server {
})
},
)
.post(
"/experimental/worktree",
describeRoute({
summary: "Create worktree",
description: "Create a new git worktree for the current project.",
operationId: "worktree.create",
responses: {
200: {
description: "Worktree created",
content: {
"application/json": {
schema: resolver(Worktree.Info),
},
},
},
...errors(400),
},
}),
validator("json", Worktree.create.schema),
async (c) => {
const body = c.req.valid("json")
const worktree = await Worktree.create(body)
return c.json(worktree)
},
)
.get(
"/experimental/worktree",
describeRoute({
summary: "List worktrees",
description: "List all sandbox worktrees for the current project.",
operationId: "worktree.list",
responses: {
200: {
description: "List of worktree directories",
content: {
"application/json": {
schema: resolver(z.array(z.string())),
},
},
},
},
}),
async (c) => {
const sandboxes = await Project.sandboxes(Instance.project.id)
return c.json(sandboxes)
},
)
.get(
"/vcs",
describeRoute({

View File

@@ -0,0 +1,215 @@
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 { Project } from "../project/project"
import { fn } from "../util/fn"
export namespace Worktree {
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(),
})
.meta({
ref: "WorktreeCreateInput",
})
export type CreateInput = z.infer<typeof CreateInput>
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(),
}),
)
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 -b ${info.branch} ${info.directory}`.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)
const cmd = input?.startCommand?.trim()
if (!cmd) return info
const ran = await runStartCommand(info.directory, cmd)
if (ran.exitCode !== 0) {
throw new StartCommandFailedError({ message: errorText(ran) || "Worktree start command failed" })
}
return info
})
}