feat: add managed git worktrees (#6674)
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
215
packages/opencode/src/worktree/index.ts
Normal file
215
packages/opencode/src/worktree/index.ts
Normal 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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user