Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/opencode/src/tool/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { makeRuntime } from "@/effect/run-service"
import { DesktopTool } from "./desktop"
import { BrowserTool } from "./browser"
import { SwarmTool } from "./swarm"
import { EnterWorktreeTool, ExitWorktreeTool } from "./worktree"

export namespace ToolRegistry {
const log = Log.create({ service: "tool.registry" })
Expand Down Expand Up @@ -135,6 +136,8 @@ export namespace ToolRegistry {
DesktopTool,
BrowserTool,
SwarmTool,
EnterWorktreeTool,
ExitWorktreeTool,
SkillTool,
ApplyPatchTool,
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
Expand Down
5 changes: 5 additions & 0 deletions packages/opencode/src/tool/worktree-enter.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Use this tool to create and enter an isolated git worktree sandbox for the current project.

Call this tool when you need a safe sandbox for changes that should stay isolated from the primary workspace.

The tool returns the created worktree info (name, branch, directory) for follow-up actions.
5 changes: 5 additions & 0 deletions packages/opencode/src/tool/worktree-exit.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Use this tool to remove and exit an existing git worktree sandbox.

Call this tool when work in a sandbox is complete and the worktree should be torn down.

Provide the sandbox directory to remove.
58 changes: 58 additions & 0 deletions packages/opencode/src/tool/worktree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import z from "zod"
import { Tool } from "./tool"
import { Worktree } from "../worktree"
import ENTER_DESCRIPTION from "./worktree-enter.txt"
import EXIT_DESCRIPTION from "./worktree-exit.txt"

export const EnterWorktreeTool = Tool.define("worktree_enter", {
description: ENTER_DESCRIPTION,
parameters: Worktree.CreateInput,
async execute(input, ctx) {
const pattern = input.name?.trim() || "*"
await ctx.ask({
permission: "worktree_enter",
patterns: [pattern],
always: ["*"],
metadata: {
name: input.name,
startCommand: input.startCommand,
},
})

const info = await Worktree.create(input)
return {
title: `Entered worktree ${info.name}`,
output: [`name: ${info.name}`, `branch: ${info.branch}`, `directory: ${info.directory}`].join("\n"),
metadata: info,
}
},
})

const exit = z.object({
directory: Worktree.RemoveInput.shape.directory.describe("Sandbox worktree directory to remove"),
})

export const ExitWorktreeTool = Tool.define("worktree_exit", {
description: EXIT_DESCRIPTION,
parameters: exit,
async execute(input, ctx) {
await ctx.ask({
permission: "worktree_exit",
patterns: [input.directory],
always: ["*"],
metadata: {
directory: input.directory,
},
})

const removed = await Worktree.remove(input)
return {
title: removed ? "Removed worktree" : "Worktree removal skipped",
output: removed ? `Removed worktree: ${input.directory}` : `Worktree not removed: ${input.directory}`,
metadata: {
directory: input.directory,
removed,
},
}
},
})
95 changes: 95 additions & 0 deletions packages/opencode/test/tool/worktree.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"
import { SessionID, MessageID } from "../../src/session/schema"
import { EnterWorktreeTool, ExitWorktreeTool } from "../../src/tool/worktree"
import * as WorktreeModule from "../../src/worktree"
import { ToolRegistry } from "../../src/tool/registry"
import { Instance } from "../../src/project/instance"
import { tmpdir } from "../fixture/fixture"
import type { Permission } from "../../src/permission"

const ctx = {
sessionID: SessionID.make("ses_test"),
messageID: MessageID.make(""),
callID: "",
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => {},
ask: async () => {},
}

describe("tool.worktree", () => {
let create: ReturnType<typeof spyOn>
let remove: ReturnType<typeof spyOn>

beforeEach(() => {
create = spyOn(WorktreeModule.Worktree, "create")
remove = spyOn(WorktreeModule.Worktree, "remove")
})

afterEach(async () => {
create.mockRestore()
remove.mockRestore()
await Instance.disposeAll()
})

test("enter requests permission and creates worktree", async () => {
const info = {
name: "sandbox",
branch: "opencode/sandbox",
directory: "/tmp/sandbox",
}
create.mockResolvedValue(info)

const req: Array<{ permission: string; patterns: string[] }> = []
const tool = await EnterWorktreeTool.init()
const result = await tool.execute(
{ name: "sandbox", startCommand: "bun install" },
{
...ctx,
ask: async (input: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
req.push({ permission: input.permission, patterns: input.patterns })
},
},
)

expect(req).toEqual([{ permission: "worktree_enter", patterns: ["sandbox"] }])
expect(create).toHaveBeenCalledWith({ name: "sandbox", startCommand: "bun install" })
expect(result.metadata).toMatchObject(info)
expect(result.output).toContain("/tmp/sandbox")
})

test("exit requests permission and removes worktree", async () => {
remove.mockResolvedValue(true)
const req: Array<{ permission: string; patterns: string[] }> = []
const tool = await ExitWorktreeTool.init()

const result = await tool.execute(
{
directory: "/tmp/sandbox",
},
{
...ctx,
ask: async (input: Omit<Permission.Request, "id" | "sessionID" | "tool">) => {
req.push({ permission: input.permission, patterns: input.patterns })
},
},
)

expect(req).toEqual([{ permission: "worktree_exit", patterns: ["/tmp/sandbox"] }])
expect(remove).toHaveBeenCalledWith({ directory: "/tmp/sandbox" })
expect(result.metadata).toMatchObject({ directory: "/tmp/sandbox", removed: true })
})

test("registers enter and exit worktree tools", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const ids = await ToolRegistry.ids()
expect(ids).toContain("worktree_enter")
expect(ids).toContain("worktree_exit")
},
})
})
})