From 368e353b5d39d321bcad48c8ee79662b1cd17dbe Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 3 Jan 2026 12:49:00 +0000 Subject: [PATCH 1/9] Add worktree plugin for concurrent branch development - Implement use-worktree tool with actions: create, list, remove, merge, status, cleanup - Session-scoped worktrees stored at /tmp/opencode-worktree-{sessionID}/ - User-defined merge strategies: ours, theirs, manual with conflict resolution - Crash-proof: all exceptions caught and emitted as error messages - Automatic cleanup on session.deleted and session.error events - Clear agent hooks with comprehensive tool documentation Closes #17 --- .gitignore | 4 + .opencode/package.json | 5 + .opencode/plugin/worktree.ts | 720 +++++++++++++++++++++++++++++++++++ 3 files changed, 729 insertions(+) create mode 100644 .opencode/package.json create mode 100644 .opencode/plugin/worktree.ts diff --git a/.gitignore b/.gitignore index 887d563..0120990 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,7 @@ Icon Network Trash Folder Temporary Items .apdisk + +# OpenCode plugin dependencies (installed at runtime) +.opencode/node_modules/ +.opencode/bun.lock diff --git a/.opencode/package.json b/.opencode/package.json new file mode 100644 index 0000000..a6e93a3 --- /dev/null +++ b/.opencode/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "@opencode-ai/plugin": "latest" + } +} diff --git a/.opencode/plugin/worktree.ts b/.opencode/plugin/worktree.ts new file mode 100644 index 0000000..c055335 --- /dev/null +++ b/.opencode/plugin/worktree.ts @@ -0,0 +1,720 @@ +/** + * Worktree Plugin for OpenCode + * + * Provides git worktree management for concurrent branch development. + * Enables working on multiple unrelated changes simultaneously without + * stashing or switching branches in the main repository. + * + * ## Agent Usage Guide + * + * Use the `use-worktree` tool when you need to: + * - Work on multiple unrelated changes concurrently + * - Isolate changes for different branches without affecting the main worktree + * - Review or test code from another branch while preserving current work + * + * ### Workflow Example + * 1. Create a worktree: `use-worktree` with action "create" and branch name + * 2. Work in the worktree directory (returned in the result) + * 3. Merge changes back: `use-worktree` with action "merge" + * 4. Clean up: `use-worktree` with action "remove" or let session cleanup handle it + * + * ### Merge Strategies + * - "ours": Keep changes from the target branch on conflict + * - "theirs": Keep changes from the worktree branch on conflict + * - "manual": Stop on conflict and return diff for user decision + * + * Worktrees are automatically cleaned up when the session ends. + */ + +import { type Plugin, tool } from "@opencode-ai/plugin" + +// Type definitions for better safety +interface WorktreeInfo { + path: string + branch: string + createdAt: number +} + +interface WorktreeResult { + success: boolean + message: string + path?: string + branch?: string + conflicts?: string[] + diff?: string + worktrees?: WorktreeInfo[] +} + +// In-memory tracking of worktrees per session +const sessionWorktrees = new Map() + +/** + * Safely execute a shell command and handle errors gracefully + */ +async function safeExec( + $: typeof Bun.$, + command: string[], + options?: { cwd?: string } +): Promise<{ success: boolean; stdout: string; stderr: string }> { + try { + const cmd = command.join(" ") + const result = options?.cwd + ? await $`cd ${options.cwd} && ${cmd}`.quiet() + : await $`${cmd}`.quiet() + + return { + success: result.exitCode === 0, + stdout: result.stdout.toString().trim(), + stderr: result.stderr.toString().trim(), + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + return { + success: false, + stdout: "", + stderr: message, + } + } +} + +/** + * Get the base path for session worktrees + */ +function getSessionBasePath(sessionID: string): string { + return `/tmp/opencode-worktree-${sessionID}` +} + +/** + * Get the full worktree path for a branch + */ +function getWorktreePath(sessionID: string, branch: string): string { + // Sanitize branch name for filesystem safety + const safeBranch = branch.replace(/[^a-zA-Z0-9_-]/g, "_") + return `${getSessionBasePath(sessionID)}/${safeBranch}` +} + +/** + * Clean up all worktrees for a session + */ +async function cleanupSessionWorktrees( + $: typeof Bun.$, + sessionID: string, + directory: string +): Promise<{ cleaned: number; errors: string[] }> { + const worktrees = sessionWorktrees.get(sessionID) || [] + const errors: string[] = [] + let cleaned = 0 + + for (const wt of worktrees) { + const result = await safeExec($, ["git", "worktree", "remove", "--force", wt.path], { + cwd: directory, + }) + if (result.success) { + cleaned++ + } else { + errors.push(`Failed to remove ${wt.path}: ${result.stderr}`) + } + } + + // Also try to remove the session base directory + const basePath = getSessionBasePath(sessionID) + await safeExec($, ["rm", "-rf", basePath]) + + // Prune any dangling worktree references + await safeExec($, ["git", "worktree", "prune"], { cwd: directory }) + + // Clear tracking + sessionWorktrees.delete(sessionID) + + return { cleaned, errors } +} + +/** + * Worktree Plugin + * + * Provides the `use-worktree` tool and handles automatic cleanup on session end. + */ +export const WorktreePlugin: Plugin = async (ctx) => { + const { $, directory, worktree } = ctx + const repoRoot = worktree || directory + + return { + /** + * Event handler for session lifecycle management + * Automatically cleans up worktrees when session ends + */ + event: async ({ event }) => { + try { + // Clean up on session end or error + if ( + event.type === "session.deleted" || + event.type === "session.error" + ) { + const sessionID = event.properties?.sessionID + if (sessionID && sessionWorktrees.has(sessionID)) { + const result = await cleanupSessionWorktrees($, sessionID, repoRoot) + if (result.errors.length > 0) { + console.error( + `[worktree] Cleanup errors for session ${sessionID}:`, + result.errors + ) + } + } + } + } catch (error) { + // Never let plugin errors crash OpenCode + console.error("[worktree] Event handler error:", error) + } + }, + + /** + * Register the use-worktree tool + */ + tool: { + "use-worktree": tool({ + description: `Manage git worktrees for concurrent branch development. + +## Actions + +- **create**: Create a new worktree for a branch +- **list**: List all worktrees in the current session +- **remove**: Remove a specific worktree +- **merge**: Merge worktree changes back to a target branch +- **status**: Get the status of a worktree (changes, commits ahead/behind) +- **cleanup**: Remove all session worktrees + +## Merge Strategies + +When merging, use the \`mergeStrategy\` parameter: +- **ours**: On conflict, keep changes from the target branch +- **theirs**: On conflict, keep changes from the worktree branch +- **manual**: Stop on conflict and return diff for user to decide + +## Example Usage + +1. Create a worktree for a feature branch: + \`\`\` + action: "create", branch: "feature/new-ui" + \`\`\` + +2. After making changes, merge back to main: + \`\`\` + action: "merge", branch: "feature/new-ui", targetBranch: "main", mergeStrategy: "theirs" + \`\`\` + +3. Clean up when done: + \`\`\` + action: "remove", branch: "feature/new-ui" + \`\`\` + +Worktrees are stored at \`/tmp/opencode-worktree-{sessionID}/{branch}\` and are automatically cleaned up when the session ends.`, + + args: { + action: tool.schema + .enum(["create", "list", "remove", "merge", "status", "cleanup"]) + .describe("The worktree action to perform"), + branch: tool.schema + .string() + .optional() + .describe("Branch name for create/remove/merge/status actions"), + targetBranch: tool.schema + .string() + .optional() + .describe("Target branch to merge into (for merge action)"), + mergeStrategy: tool.schema + .enum(["ours", "theirs", "manual"]) + .optional() + .describe( + "Conflict resolution strategy: 'ours' (keep target), 'theirs' (keep worktree), 'manual' (return diff)" + ), + createBranch: tool.schema + .boolean() + .optional() + .describe("Create new branch if it doesn't exist (for create action)"), + baseBranch: tool.schema + .string() + .optional() + .describe("Base branch to create new branch from (defaults to HEAD)"), + commitMessage: tool.schema + .string() + .optional() + .describe("Commit message for merge commit"), + }, + + async execute(args, toolCtx): Promise { + const { sessionID } = toolCtx + let result: WorktreeResult + + try { + switch (args.action) { + case "create": + result = await createWorktree( + $, + sessionID, + repoRoot, + args.branch, + args.createBranch, + args.baseBranch + ) + break + + case "list": + result = await listWorktrees($, sessionID, repoRoot) + break + + case "remove": + result = await removeWorktree($, sessionID, repoRoot, args.branch) + break + + case "merge": + result = await mergeWorktree( + $, + sessionID, + repoRoot, + args.branch, + args.targetBranch, + args.mergeStrategy, + args.commitMessage + ) + break + + case "status": + result = await getWorktreeStatus($, sessionID, repoRoot, args.branch) + break + + case "cleanup": + result = await cleanupAll($, sessionID, repoRoot) + break + + default: + result = { + success: false, + message: `Unknown action: ${args.action}`, + } + } + } catch (error) { + // Catch-all for any unhandled errors - never crash OpenCode + const message = error instanceof Error ? error.message : String(error) + result = { + success: false, + message: `Unexpected error: ${message}`, + } + console.error("[worktree] Tool execution error:", error) + } + + return formatResult(result) + }, + }), + }, + } +} + +/** + * Create a new worktree + */ +async function createWorktree( + $: typeof Bun.$, + sessionID: string, + repoRoot: string, + branch?: string, + createBranch?: boolean, + baseBranch?: string +): Promise { + if (!branch) { + return { + success: false, + message: "Branch name is required for create action", + } + } + + const worktreePath = getWorktreePath(sessionID, branch) + + // Check if worktree already exists + const existing = sessionWorktrees.get(sessionID) || [] + if (existing.some((w) => w.branch === branch)) { + const existingWt = existing.find((w) => w.branch === branch)! + return { + success: true, + message: `Worktree for branch '${branch}' already exists`, + path: existingWt.path, + branch, + } + } + + // Ensure session directory exists + const basePath = getSessionBasePath(sessionID) + await safeExec($, ["mkdir", "-p", basePath]) + + // Build the git worktree add command + let cmd: string[] + if (createBranch) { + // Create new branch + const base = baseBranch || "HEAD" + cmd = ["git", "worktree", "add", "-b", branch, worktreePath, base] + } else { + // Use existing branch + cmd = ["git", "worktree", "add", worktreePath, branch] + } + + const result = await safeExec($, cmd, { cwd: repoRoot }) + + if (!result.success) { + // Check if branch doesn't exist and suggest creating it + if (result.stderr.includes("invalid reference") || result.stderr.includes("not a valid")) { + return { + success: false, + message: `Branch '${branch}' does not exist. Set createBranch: true to create it, or specify an existing branch.`, + } + } + // Check if branch is already checked out + if (result.stderr.includes("already checked out")) { + return { + success: false, + message: `Branch '${branch}' is already checked out in another worktree. Use a different branch name or remove the existing worktree first.`, + } + } + return { + success: false, + message: `Failed to create worktree: ${result.stderr}`, + } + } + + // Track the worktree + const worktreeInfo: WorktreeInfo = { + path: worktreePath, + branch, + createdAt: Date.now(), + } + + if (!sessionWorktrees.has(sessionID)) { + sessionWorktrees.set(sessionID, []) + } + sessionWorktrees.get(sessionID)!.push(worktreeInfo) + + return { + success: true, + message: `Created worktree for branch '${branch}'`, + path: worktreePath, + branch, + } +} + +/** + * List all worktrees in the session + */ +async function listWorktrees( + $: typeof Bun.$, + sessionID: string, + repoRoot: string +): Promise { + const tracked = sessionWorktrees.get(sessionID) || [] + + // Also get all git worktrees to show the full picture + const gitResult = await safeExec($, ["git", "worktree", "list", "--porcelain"], { + cwd: repoRoot, + }) + + let message = `Session worktrees: ${tracked.length}\n` + + if (tracked.length > 0) { + message += "\nSession-managed worktrees:\n" + for (const wt of tracked) { + message += ` - ${wt.branch}: ${wt.path}\n` + } + } + + if (gitResult.success && gitResult.stdout) { + message += `\nAll repository worktrees:\n${gitResult.stdout}` + } + + return { + success: true, + message, + worktrees: tracked, + } +} + +/** + * Remove a worktree + */ +async function removeWorktree( + $: typeof Bun.$, + sessionID: string, + repoRoot: string, + branch?: string +): Promise { + if (!branch) { + return { + success: false, + message: "Branch name is required for remove action", + } + } + + const tracked = sessionWorktrees.get(sessionID) || [] + const worktree = tracked.find((w) => w.branch === branch) + + if (!worktree) { + return { + success: false, + message: `No worktree found for branch '${branch}' in this session`, + } + } + + const result = await safeExec($, ["git", "worktree", "remove", "--force", worktree.path], { + cwd: repoRoot, + }) + + if (!result.success) { + return { + success: false, + message: `Failed to remove worktree: ${result.stderr}`, + } + } + + // Remove from tracking + const index = tracked.indexOf(worktree) + if (index > -1) { + tracked.splice(index, 1) + } + + return { + success: true, + message: `Removed worktree for branch '${branch}'`, + branch, + } +} + +/** + * Merge worktree changes into a target branch + */ +async function mergeWorktree( + $: typeof Bun.$, + sessionID: string, + repoRoot: string, + branch?: string, + targetBranch?: string, + mergeStrategy?: "ours" | "theirs" | "manual", + commitMessage?: string +): Promise { + if (!branch) { + return { + success: false, + message: "Branch name is required for merge action", + } + } + + if (!targetBranch) { + return { + success: false, + message: + "Target branch is required for merge action. Specify which branch to merge into.", + } + } + + const tracked = sessionWorktrees.get(sessionID) || [] + const worktree = tracked.find((w) => w.branch === branch) + + if (!worktree) { + return { + success: false, + message: `No worktree found for branch '${branch}' in this session. Create it first or check the branch name.`, + } + } + + const strategy = mergeStrategy || "manual" + const message = commitMessage || `Merge branch '${branch}' into ${targetBranch}` + + // First, ensure we're on the target branch in the main worktree + const checkoutResult = await safeExec($, ["git", "checkout", targetBranch], { + cwd: repoRoot, + }) + + if (!checkoutResult.success) { + return { + success: false, + message: `Failed to checkout target branch '${targetBranch}': ${checkoutResult.stderr}`, + } + } + + // Build merge command based on strategy + let mergeCmd: string[] + if (strategy === "ours") { + mergeCmd = ["git", "merge", "-X", "ours", "-m", message, branch] + } else if (strategy === "theirs") { + mergeCmd = ["git", "merge", "-X", "theirs", "-m", message, branch] + } else { + // Manual - try merge without auto-commit, return conflicts if any + mergeCmd = ["git", "merge", "--no-commit", "--no-ff", branch] + } + + const mergeResult = await safeExec($, mergeCmd, { cwd: repoRoot }) + + // Check for conflicts + if (!mergeResult.success) { + if ( + mergeResult.stderr.includes("CONFLICT") || + mergeResult.stdout.includes("CONFLICT") + ) { + // Get the diff for manual resolution + const diffResult = await safeExec($, ["git", "diff"], { cwd: repoRoot }) + const statusResult = await safeExec($, ["git", "status", "--porcelain"], { + cwd: repoRoot, + }) + + // Extract conflicted files + const conflicts = statusResult.stdout + .split("\n") + .filter((line) => line.startsWith("UU") || line.startsWith("AA")) + .map((line) => line.slice(3)) + + if (strategy === "manual") { + return { + success: false, + message: `Merge has conflicts that require manual resolution.\n\nConflicted files:\n${conflicts.join("\n")}\n\nUse mergeStrategy 'ours' or 'theirs' to auto-resolve, or resolve manually in the worktree.`, + conflicts, + diff: diffResult.stdout, + } + } + + // For ours/theirs, conflicts should have been auto-resolved + // This means there was a different error + return { + success: false, + message: `Merge failed with conflicts that couldn't be auto-resolved: ${mergeResult.stderr}`, + conflicts, + } + } + + return { + success: false, + message: `Merge failed: ${mergeResult.stderr}`, + } + } + + // For manual strategy with no conflicts, commit the merge + if (strategy === "manual") { + const commitResult = await safeExec($, ["git", "commit", "-m", message], { + cwd: repoRoot, + }) + + if (!commitResult.success && !commitResult.stderr.includes("nothing to commit")) { + return { + success: false, + message: `Merge completed but commit failed: ${commitResult.stderr}`, + } + } + } + + return { + success: true, + message: `Successfully merged '${branch}' into '${targetBranch}'`, + branch, + } +} + +/** + * Get status of a worktree + */ +async function getWorktreeStatus( + $: typeof Bun.$, + sessionID: string, + repoRoot: string, + branch?: string +): Promise { + if (!branch) { + return { + success: false, + message: "Branch name is required for status action", + } + } + + const tracked = sessionWorktrees.get(sessionID) || [] + const worktree = tracked.find((w) => w.branch === branch) + + if (!worktree) { + return { + success: false, + message: `No worktree found for branch '${branch}' in this session`, + } + } + + // Get status in the worktree + const statusResult = await safeExec($, ["git", "status", "--porcelain"], { + cwd: worktree.path, + }) + + // Get commits ahead/behind + const logResult = await safeExec( + $, + ["git", "log", "--oneline", `origin/${branch}..${branch}`, "2>/dev/null", "||", "true"], + { cwd: worktree.path } + ) + + let message = `Worktree status for '${branch}':\n` + message += `Path: ${worktree.path}\n` + message += `Created: ${new Date(worktree.createdAt).toISOString()}\n\n` + + if (statusResult.stdout) { + message += `Changed files:\n${statusResult.stdout}\n` + } else { + message += "Working tree clean\n" + } + + if (logResult.stdout) { + message += `\nUnpushed commits:\n${logResult.stdout}` + } + + return { + success: true, + message, + path: worktree.path, + branch, + } +} + +/** + * Clean up all worktrees in the session + */ +async function cleanupAll( + $: typeof Bun.$, + sessionID: string, + repoRoot: string +): Promise { + const result = await cleanupSessionWorktrees($, sessionID, repoRoot) + + if (result.errors.length > 0) { + return { + success: false, + message: `Cleaned ${result.cleaned} worktrees with errors:\n${result.errors.join("\n")}`, + } + } + + return { + success: true, + message: `Cleaned up ${result.cleaned} worktrees`, + } +} + +/** + * Format a result for display + */ +function formatResult(result: WorktreeResult): string { + let output = result.success ? "SUCCESS" : "ERROR" + output += `\n\n${result.message}` + + if (result.path) { + output += `\n\nWorktree path: ${result.path}` + } + + if (result.conflicts && result.conflicts.length > 0) { + output += `\n\nConflicted files:\n${result.conflicts.map((f) => ` - ${f}`).join("\n")}` + } + + if (result.diff) { + output += `\n\nDiff:\n${result.diff}` + } + + return output +} + +export default WorktreePlugin From b4fa5f007ce7c7e765b6a2777e5274251db296f8 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 3 Jan 2026 14:17:36 +0000 Subject: [PATCH 2/9] Add external_directory permission config Co-authored-by: elithrar --- .opencode/opencode.jsonc | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .opencode/opencode.jsonc diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc new file mode 100644 index 0000000..188c8f1 --- /dev/null +++ b/.opencode/opencode.jsonc @@ -0,0 +1,6 @@ +{ + "$schema": "https://opencode.ai/config.json", + "permissions": { + "external_directory": "allow" + } +} From b7ae656c86c7e3043eac20b1496056d4f7b1f59b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 3 Jan 2026 14:51:00 +0000 Subject: [PATCH 3/9] Fix security issues, add logging Co-authored-by: elithrar --- .opencode/plugin/worktree.ts | 125 +++++++++++++++++------------------ 1 file changed, 59 insertions(+), 66 deletions(-) diff --git a/.opencode/plugin/worktree.ts b/.opencode/plugin/worktree.ts index c055335..8fd1bab 100644 --- a/.opencode/plugin/worktree.ts +++ b/.opencode/plugin/worktree.ts @@ -28,7 +28,6 @@ import { type Plugin, tool } from "@opencode-ai/plugin" -// Type definitions for better safety interface WorktreeInfo { path: string branch: string @@ -45,27 +44,35 @@ interface WorktreeResult { worktrees?: WorktreeInfo[] } -// In-memory tracking of worktrees per session const sessionWorktrees = new Map() -/** - * Safely execute a shell command and handle errors gracefully - */ +function log(sessionID: string, action: string, detail: string): void { + console.log(`[worktree] session=${sessionID} action=${action} ${detail}`) +} + async function safeExec( - $: typeof Bun.$, + _$: typeof Bun.$, command: string[], options?: { cwd?: string } ): Promise<{ success: boolean; stdout: string; stderr: string }> { try { - const cmd = command.join(" ") - const result = options?.cwd - ? await $`cd ${options.cwd} && ${cmd}`.quiet() - : await $`${cmd}`.quiet() + const proc = Bun.spawn(command, { + cwd: options?.cwd, + stdout: "pipe", + stderr: "pipe", + }) + + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]) + + const exitCode = await proc.exited return { - success: result.exitCode === 0, - stdout: result.stdout.toString().trim(), - stderr: result.stderr.toString().trim(), + success: exitCode === 0, + stdout: stdout.trim(), + stderr: stderr.trim(), } } catch (error) { const message = error instanceof Error ? error.message : String(error) @@ -77,25 +84,23 @@ async function safeExec( } } -/** - * Get the base path for session worktrees - */ function getSessionBasePath(sessionID: string): string { return `/tmp/opencode-worktree-${sessionID}` } -/** - * Get the full worktree path for a branch - */ +function sanitizeBranchName(branch: string): string { + // Reject branches with shell metacharacters or git-unsafe patterns + if (/[\s;&|`$(){}[\]<>\\'"!*?~^]/.test(branch) || branch.startsWith("-")) { + throw new Error(`Invalid branch name: contains unsafe characters`) + } + return branch +} + function getWorktreePath(sessionID: string, branch: string): string { - // Sanitize branch name for filesystem safety - const safeBranch = branch.replace(/[^a-zA-Z0-9_-]/g, "_") + const safeBranch = branch.replace(/[^a-zA-Z0-9_\-/]/g, "_") return `${getSessionBasePath(sessionID)}/${safeBranch}` } -/** - * Clean up all worktrees for a session - */ async function cleanupSessionWorktrees( $: typeof Bun.$, sessionID: string, @@ -105,6 +110,8 @@ async function cleanupSessionWorktrees( const errors: string[] = [] let cleaned = 0 + log(sessionID, "cleanup", `worktrees=${worktrees.length}`) + for (const wt of worktrees) { const result = await safeExec($, ["git", "worktree", "remove", "--force", wt.path], { cwd: directory, @@ -116,36 +123,23 @@ async function cleanupSessionWorktrees( } } - // Also try to remove the session base directory const basePath = getSessionBasePath(sessionID) await safeExec($, ["rm", "-rf", basePath]) - // Prune any dangling worktree references await safeExec($, ["git", "worktree", "prune"], { cwd: directory }) - // Clear tracking sessionWorktrees.delete(sessionID) return { cleaned, errors } } -/** - * Worktree Plugin - * - * Provides the `use-worktree` tool and handles automatic cleanup on session end. - */ export const WorktreePlugin: Plugin = async (ctx) => { const { $, directory, worktree } = ctx const repoRoot = worktree || directory return { - /** - * Event handler for session lifecycle management - * Automatically cleans up worktrees when session ends - */ event: async ({ event }) => { try { - // Clean up on session end or error if ( event.type === "session.deleted" || event.type === "session.error" @@ -162,14 +156,10 @@ export const WorktreePlugin: Plugin = async (ctx) => { } } } catch (error) { - // Never let plugin errors crash OpenCode console.error("[worktree] Event handler error:", error) } }, - /** - * Register the use-worktree tool - */ tool: { "use-worktree": tool({ description: `Manage git worktrees for concurrent branch development. @@ -309,9 +299,6 @@ Worktrees are stored at \`/tmp/opencode-worktree-{sessionID}/{branch}\` and are } } -/** - * Create a new worktree - */ async function createWorktree( $: typeof Bun.$, sessionID: string, @@ -327,6 +314,16 @@ async function createWorktree( } } + try { + sanitizeBranchName(branch) + if (baseBranch) sanitizeBranchName(baseBranch) + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : String(error), + } + } + const worktreePath = getWorktreePath(sessionID, branch) // Check if worktree already exists @@ -379,7 +376,6 @@ async function createWorktree( } } - // Track the worktree const worktreeInfo: WorktreeInfo = { path: worktreePath, branch, @@ -391,6 +387,8 @@ async function createWorktree( } sessionWorktrees.get(sessionID)!.push(worktreeInfo) + log(sessionID, "create", `branch=${branch} path=${worktreePath}`) + return { success: true, message: `Created worktree for branch '${branch}'`, @@ -399,9 +397,6 @@ async function createWorktree( } } -/** - * List all worktrees in the session - */ async function listWorktrees( $: typeof Bun.$, sessionID: string, @@ -434,9 +429,6 @@ async function listWorktrees( } } -/** - * Remove a worktree - */ async function removeWorktree( $: typeof Bun.$, sessionID: string, @@ -471,12 +463,13 @@ async function removeWorktree( } } - // Remove from tracking const index = tracked.indexOf(worktree) if (index > -1) { tracked.splice(index, 1) } + log(sessionID, "remove", `branch=${branch} path=${worktree.path}`) + return { success: true, message: `Removed worktree for branch '${branch}'`, @@ -484,9 +477,6 @@ async function removeWorktree( } } -/** - * Merge worktree changes into a target branch - */ async function mergeWorktree( $: typeof Bun.$, sessionID: string, @@ -511,6 +501,16 @@ async function mergeWorktree( } } + try { + sanitizeBranchName(branch) + sanitizeBranchName(targetBranch) + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : String(error), + } + } + const tracked = sessionWorktrees.get(sessionID) || [] const worktree = tracked.find((w) => w.branch === branch) @@ -605,6 +605,8 @@ async function mergeWorktree( } } + log(sessionID, "merge", `branch=${branch} target=${targetBranch} strategy=${strategy}`) + return { success: true, message: `Successfully merged '${branch}' into '${targetBranch}'`, @@ -612,9 +614,6 @@ async function mergeWorktree( } } -/** - * Get status of a worktree - */ async function getWorktreeStatus( $: typeof Bun.$, sessionID: string, @@ -643,10 +642,10 @@ async function getWorktreeStatus( cwd: worktree.path, }) - // Get commits ahead/behind + // Get commits ahead/behind (ignore errors if remote branch doesn't exist) const logResult = await safeExec( $, - ["git", "log", "--oneline", `origin/${branch}..${branch}`, "2>/dev/null", "||", "true"], + ["git", "log", "--oneline", `origin/${branch}..${branch}`], { cwd: worktree.path } ) @@ -672,9 +671,6 @@ async function getWorktreeStatus( } } -/** - * Clean up all worktrees in the session - */ async function cleanupAll( $: typeof Bun.$, sessionID: string, @@ -695,9 +691,6 @@ async function cleanupAll( } } -/** - * Format a result for display - */ function formatResult(result: WorktreeResult): string { let output = result.success ? "SUCCESS" : "ERROR" output += `\n\n${result.message}` From 422d1ce743ef57dc1176b69b805fb92bf9b302fb Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 3 Jan 2026 15:09:54 +0000 Subject: [PATCH 4/9] Rename worktree plugin to git-worktree for clarity - Rename worktree.ts to git-worktree.ts - Change tool name from use-worktree to use-git-worktree - Update log prefix from [worktree] to [git-worktree] - Update temp path from /tmp/opencode-worktree-* to /tmp/opencode-git-worktree-* - Update export from WorktreePlugin to GitWorktreePlugin - Update all documentation references --- .../plugin/{worktree.ts => git-worktree.ts} | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) rename .opencode/plugin/{worktree.ts => git-worktree.ts} (92%) diff --git a/.opencode/plugin/worktree.ts b/.opencode/plugin/git-worktree.ts similarity index 92% rename from .opencode/plugin/worktree.ts rename to .opencode/plugin/git-worktree.ts index 8fd1bab..0e3b634 100644 --- a/.opencode/plugin/worktree.ts +++ b/.opencode/plugin/git-worktree.ts @@ -1,5 +1,5 @@ /** - * Worktree Plugin for OpenCode + * Git Worktree Plugin for OpenCode * * Provides git worktree management for concurrent branch development. * Enables working on multiple unrelated changes simultaneously without @@ -7,23 +7,23 @@ * * ## Agent Usage Guide * - * Use the `use-worktree` tool when you need to: + * Use the `use-git-worktree` tool when you need to: * - Work on multiple unrelated changes concurrently * - Isolate changes for different branches without affecting the main worktree * - Review or test code from another branch while preserving current work * * ### Workflow Example - * 1. Create a worktree: `use-worktree` with action "create" and branch name - * 2. Work in the worktree directory (returned in the result) - * 3. Merge changes back: `use-worktree` with action "merge" - * 4. Clean up: `use-worktree` with action "remove" or let session cleanup handle it + * 1. Create a git worktree: `use-git-worktree` with action "create" and branch name + * 2. Work in the git worktree directory (returned in the result) + * 3. Merge changes back: `use-git-worktree` with action "merge" + * 4. Clean up: `use-git-worktree` with action "remove" or let session cleanup handle it * * ### Merge Strategies * - "ours": Keep changes from the target branch on conflict * - "theirs": Keep changes from the worktree branch on conflict * - "manual": Stop on conflict and return diff for user decision * - * Worktrees are automatically cleaned up when the session ends. + * Git worktrees are automatically cleaned up when the session ends. */ import { type Plugin, tool } from "@opencode-ai/plugin" @@ -47,7 +47,7 @@ interface WorktreeResult { const sessionWorktrees = new Map() function log(sessionID: string, action: string, detail: string): void { - console.log(`[worktree] session=${sessionID} action=${action} ${detail}`) + console.log(`[git-worktree] session=${sessionID} action=${action} ${detail}`) } async function safeExec( @@ -85,7 +85,7 @@ async function safeExec( } function getSessionBasePath(sessionID: string): string { - return `/tmp/opencode-worktree-${sessionID}` + return `/tmp/opencode-git-worktree-${sessionID}` } function sanitizeBranchName(branch: string): string { @@ -133,7 +133,7 @@ async function cleanupSessionWorktrees( return { cleaned, errors } } -export const WorktreePlugin: Plugin = async (ctx) => { +export const GitWorktreePlugin: Plugin = async (ctx) => { const { $, directory, worktree } = ctx const repoRoot = worktree || directory @@ -149,40 +149,40 @@ export const WorktreePlugin: Plugin = async (ctx) => { const result = await cleanupSessionWorktrees($, sessionID, repoRoot) if (result.errors.length > 0) { console.error( - `[worktree] Cleanup errors for session ${sessionID}:`, + `[git-worktree] Cleanup errors for session ${sessionID}:`, result.errors ) } } } } catch (error) { - console.error("[worktree] Event handler error:", error) + console.error("[git-worktree] Event handler error:", error) } }, tool: { - "use-worktree": tool({ + "use-git-worktree": tool({ description: `Manage git worktrees for concurrent branch development. ## Actions -- **create**: Create a new worktree for a branch -- **list**: List all worktrees in the current session -- **remove**: Remove a specific worktree -- **merge**: Merge worktree changes back to a target branch -- **status**: Get the status of a worktree (changes, commits ahead/behind) -- **cleanup**: Remove all session worktrees +- **create**: Create a new git worktree for a branch +- **list**: List all git worktrees in the current session +- **remove**: Remove a specific git worktree +- **merge**: Merge git worktree changes back to a target branch +- **status**: Get the status of a git worktree (changes, commits ahead/behind) +- **cleanup**: Remove all session git worktrees ## Merge Strategies When merging, use the \`mergeStrategy\` parameter: - **ours**: On conflict, keep changes from the target branch -- **theirs**: On conflict, keep changes from the worktree branch +- **theirs**: On conflict, keep changes from the git worktree branch - **manual**: Stop on conflict and return diff for user to decide ## Example Usage -1. Create a worktree for a feature branch: +1. Create a git worktree for a feature branch: \`\`\` action: "create", branch: "feature/new-ui" \`\`\` @@ -197,12 +197,12 @@ When merging, use the \`mergeStrategy\` parameter: action: "remove", branch: "feature/new-ui" \`\`\` -Worktrees are stored at \`/tmp/opencode-worktree-{sessionID}/{branch}\` and are automatically cleaned up when the session ends.`, +Git worktrees are stored at \`/tmp/opencode-git-worktree-{sessionID}/{branch}\` and are automatically cleaned up when the session ends.`, args: { action: tool.schema .enum(["create", "list", "remove", "merge", "status", "cleanup"]) - .describe("The worktree action to perform"), + .describe("The git worktree action to perform"), branch: tool.schema .string() .optional() @@ -289,7 +289,7 @@ Worktrees are stored at \`/tmp/opencode-worktree-{sessionID}/{branch}\` and are success: false, message: `Unexpected error: ${message}`, } - console.error("[worktree] Tool execution error:", error) + console.error("[git-worktree] Tool execution error:", error) } return formatResult(result) @@ -710,4 +710,4 @@ function formatResult(result: WorktreeResult): string { return output } -export default WorktreePlugin +export default GitWorktreePlugin From 39a5f1f697455264a29d4869d658fcf0bfe60242 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 3 Jan 2026 15:42:25 +0000 Subject: [PATCH 5/9] Add timeout & logging to git-worktree Co-authored-by: elithrar --- .opencode/plugin/git-worktree.ts | 33 +++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/.opencode/plugin/git-worktree.ts b/.opencode/plugin/git-worktree.ts index 0e3b634..5996df9 100644 --- a/.opencode/plugin/git-worktree.ts +++ b/.opencode/plugin/git-worktree.ts @@ -50,11 +50,15 @@ function log(sessionID: string, action: string, detail: string): void { console.log(`[git-worktree] session=${sessionID} action=${action} ${detail}`) } +const EXEC_TIMEOUT_MS = 30_000 + async function safeExec( _$: typeof Bun.$, command: string[], - options?: { cwd?: string } + options?: { cwd?: string; timeoutMs?: number } ): Promise<{ success: boolean; stdout: string; stderr: string }> { + const timeout = options?.timeoutMs ?? EXEC_TIMEOUT_MS + try { const proc = Bun.spawn(command, { cwd: options?.cwd, @@ -62,9 +66,19 @@ async function safeExec( stderr: "pipe", }) - const [stdout, stderr] = await Promise.all([ - new Response(proc.stdout).text(), - new Response(proc.stderr).text(), + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => { + proc.kill() + reject(new Error(`Command timed out after ${timeout}ms: ${command.join(" ")}`)) + }, timeout) + }) + + const [stdout, stderr] = await Promise.race([ + Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]), + timeoutPromise, ]) const exitCode = await proc.exited @@ -340,7 +354,14 @@ async function createWorktree( // Ensure session directory exists const basePath = getSessionBasePath(sessionID) - await safeExec($, ["mkdir", "-p", basePath]) + const mkdirResult = await safeExec($, ["mkdir", "-p", basePath]) + if (!mkdirResult.success) { + return { + success: false, + message: `Failed to create session directory '${basePath}': ${mkdirResult.stderr}. Check filesystem permissions.`, + } + } + log(sessionID, "mkdir", `path=${basePath}`) // Build the git worktree add command let cmd: string[] @@ -353,9 +374,11 @@ async function createWorktree( cmd = ["git", "worktree", "add", worktreePath, branch] } + log(sessionID, "exec", `cmd="${cmd.join(" ")}" cwd=${repoRoot}`) const result = await safeExec($, cmd, { cwd: repoRoot }) if (!result.success) { + log(sessionID, "error", `stderr="${result.stderr}"`) // Check if branch doesn't exist and suggest creating it if (result.stderr.includes("invalid reference") || result.stderr.includes("not a valid")) { return { From ba5845516aaec3ff2beb17d95c77ad03c2aa790d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 3 Jan 2026 16:02:17 +0000 Subject: [PATCH 6/9] Enforce plugin usage for git worktree operations --- .opencode/plugin/git-worktree.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.opencode/plugin/git-worktree.ts b/.opencode/plugin/git-worktree.ts index 5996df9..40d0b17 100644 --- a/.opencode/plugin/git-worktree.ts +++ b/.opencode/plugin/git-worktree.ts @@ -7,6 +7,13 @@ * * ## Agent Usage Guide * + * IMPORTANT: Always use the `use-git-worktree` tool for ALL git worktree operations. + * Do NOT use `git worktree` commands directly via Bash - the plugin provides: + * - Session-scoped isolation (worktrees are namespaced per session) + * - Automatic cleanup when sessions end + * - Proper conflict resolution strategies + * - Logging and observability for non-interactive workflows + * * Use the `use-git-worktree` tool when you need to: * - Work on multiple unrelated changes concurrently * - Isolate changes for different branches without affecting the main worktree @@ -152,6 +159,21 @@ export const GitWorktreePlugin: Plugin = async (ctx) => { const repoRoot = worktree || directory return { + "tool.execute.before": async (input, output) => { + // Intercept direct `git worktree` commands and guide the agent to use the plugin + if (input.tool === "bash" && typeof output.args?.command === "string") { + const cmd = output.args.command + if (/\bgit\s+worktree\b/.test(cmd)) { + throw new Error( + `Direct 'git worktree' commands are not allowed. ` + + `Use the 'use-git-worktree' tool instead for session-scoped worktree management, ` + + `automatic cleanup, and proper logging. ` + + `Available actions: create, list, remove, merge, status, cleanup.` + ) + } + } + }, + event: async ({ event }) => { try { if ( @@ -178,6 +200,9 @@ export const GitWorktreePlugin: Plugin = async (ctx) => { "use-git-worktree": tool({ description: `Manage git worktrees for concurrent branch development. +IMPORTANT: Always use this tool instead of running \`git worktree\` commands directly via Bash. +This tool provides session-scoped worktrees, automatic cleanup, and proper logging. + ## Actions - **create**: Create a new git worktree for a branch From d72fe9d9fb75c823ab96a65c65eff71c9c9fc726 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 3 Jan 2026 19:54:38 +0000 Subject: [PATCH 7/9] Plugin tools available to subagents; stalls from permission prompts Co-authored-by: elithrar --- .opencode/plugin/git-worktree.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/.opencode/plugin/git-worktree.ts b/.opencode/plugin/git-worktree.ts index 40d0b17..d909ecd 100644 --- a/.opencode/plugin/git-worktree.ts +++ b/.opencode/plugin/git-worktree.ts @@ -30,6 +30,31 @@ * - "theirs": Keep changes from the worktree branch on conflict * - "manual": Stop on conflict and return diff for user decision * + * ## IMPORTANT: Subagent Limitations + * + * Plugin tools like `use-git-worktree` are only available to the MAIN AGENT. + * Task subagents (general, explore) CANNOT directly use this tool because: + * - Plugin tools are loaded at instance scope but subagents run with filtered tool access + * - The `external_directory` permission may block operations in non-interactive contexts + * + * ### Correct Pattern for Concurrent Work with Subagents + * + * 1. **Main agent creates worktrees** using `use-git-worktree` tool + * 2. **Main agent launches Task subagents** with the worktree PATH (not branch): + * ``` + * Task(subagent_type="general", prompt="Work in /tmp/opencode-git-worktree-{sessionID}/feature-branch. + * Use Read/Write/Edit/Bash tools to modify files in that directory.") + * ``` + * 3. **Subagents use standard tools** (Read, Write, Edit, Bash) in their assigned paths + * 4. **Main agent handles merge/cleanup** using `use-git-worktree` + * + * ### Required Configuration + * + * Your `.opencode/opencode.jsonc` must include: + * ```json + * { "permission": { "external_directory": "allow" } } + * ``` + * * Git worktrees are automatically cleaned up when the session ends. */ @@ -203,6 +228,10 @@ export const GitWorktreePlugin: Plugin = async (ctx) => { IMPORTANT: Always use this tool instead of running \`git worktree\` commands directly via Bash. This tool provides session-scoped worktrees, automatic cleanup, and proper logging. +NOTE: This tool is only available to the MAIN AGENT. Task subagents cannot use this tool directly. +For concurrent work: main agent creates worktrees first, then launches subagents with the worktree PATH. +Subagents should use standard tools (Read, Write, Edit, Bash) in their assigned worktree directory. + ## Actions - **create**: Create a new git worktree for a branch From 28eaf919c03547834d15237fc3c4230ece5b5b76 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 4 Jan 2026 01:17:52 +0000 Subject: [PATCH 8/9] Refactor plugin to use managed worktree API Co-authored-by: elithrar --- .opencode/opencode.jsonc | 5 +- .opencode/plugin/git-worktree.ts | 400 +++++++++++++------------------ 2 files changed, 161 insertions(+), 244 deletions(-) diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc index 188c8f1..720ece5 100644 --- a/.opencode/opencode.jsonc +++ b/.opencode/opencode.jsonc @@ -1,6 +1,3 @@ { - "$schema": "https://opencode.ai/config.json", - "permissions": { - "external_directory": "allow" - } + "$schema": "https://opencode.ai/config.json" } diff --git a/.opencode/plugin/git-worktree.ts b/.opencode/plugin/git-worktree.ts index d909ecd..f6fdcf5 100644 --- a/.opencode/plugin/git-worktree.ts +++ b/.opencode/plugin/git-worktree.ts @@ -2,15 +2,16 @@ * Git Worktree Plugin for OpenCode * * Provides git worktree management for concurrent branch development. - * Enables working on multiple unrelated changes simultaneously without - * stashing or switching branches in the main repository. + * Uses OpenCode's managed worktree API which stores worktrees in a protected + * directory (~/.opencode/data/worktree/{projectID}/), eliminating the need + * for external_directory permissions. * * ## Agent Usage Guide * * IMPORTANT: Always use the `use-git-worktree` tool for ALL git worktree operations. * Do NOT use `git worktree` commands directly via Bash - the plugin provides: - * - Session-scoped isolation (worktrees are namespaced per session) - * - Automatic cleanup when sessions end + * - Managed worktrees stored in OpenCode's protected data directory + * - Automatic cleanup and sandbox tracking * - Proper conflict resolution strategies * - Logging and observability for non-interactive workflows * @@ -20,50 +21,38 @@ * - Review or test code from another branch while preserving current work * * ### Workflow Example - * 1. Create a git worktree: `use-git-worktree` with action "create" and branch name + * 1. Create a git worktree: `use-git-worktree` with action "create" * 2. Work in the git worktree directory (returned in the result) * 3. Merge changes back: `use-git-worktree` with action "merge" - * 4. Clean up: `use-git-worktree` with action "remove" or let session cleanup handle it + * 4. Clean up: `use-git-worktree` with action "remove" * * ### Merge Strategies * - "ours": Keep changes from the target branch on conflict * - "theirs": Keep changes from the worktree branch on conflict * - "manual": Stop on conflict and return diff for user decision * - * ## IMPORTANT: Subagent Limitations - * - * Plugin tools like `use-git-worktree` are only available to the MAIN AGENT. - * Task subagents (general, explore) CANNOT directly use this tool because: - * - Plugin tools are loaded at instance scope but subagents run with filtered tool access - * - The `external_directory` permission may block operations in non-interactive contexts - * - * ### Correct Pattern for Concurrent Work with Subagents + * ## Subagent Usage * + * This tool uses OpenCode's managed worktree API. For concurrent work: * 1. **Main agent creates worktrees** using `use-git-worktree` tool - * 2. **Main agent launches Task subagents** with the worktree PATH (not branch): + * 2. **Main agent launches Task subagents** with the worktree PATH: * ``` - * Task(subagent_type="general", prompt="Work in /tmp/opencode-git-worktree-{sessionID}/feature-branch. + * Task(subagent_type="general", prompt="Work in {worktree_path}. * Use Read/Write/Edit/Bash tools to modify files in that directory.") * ``` * 3. **Subagents use standard tools** (Read, Write, Edit, Bash) in their assigned paths * 4. **Main agent handles merge/cleanup** using `use-git-worktree` * - * ### Required Configuration - * - * Your `.opencode/opencode.jsonc` must include: - * ```json - * { "permission": { "external_directory": "allow" } } - * ``` - * - * Git worktrees are automatically cleaned up when the session ends. + * Git worktrees are stored in ~/.opencode/data/worktree/{projectID}/ and are + * tracked as "sandboxes" in the project configuration. */ import { type Plugin, tool } from "@opencode-ai/plugin" interface WorktreeInfo { - path: string + name: string branch: string - createdAt: number + directory: string } interface WorktreeResult { @@ -76,6 +65,7 @@ interface WorktreeResult { worktrees?: WorktreeInfo[] } +// Session-scoped tracking of worktrees created in this session const sessionWorktrees = new Map() function log(sessionID: string, action: string, detail: string): void { @@ -85,7 +75,6 @@ function log(sessionID: string, action: string, detail: string): void { const EXEC_TIMEOUT_MS = 30_000 async function safeExec( - _$: typeof Bun.$, command: string[], options?: { cwd?: string; timeoutMs?: number } ): Promise<{ success: boolean; stdout: string; stderr: string }> { @@ -130,57 +119,15 @@ async function safeExec( } } -function getSessionBasePath(sessionID: string): string { - return `/tmp/opencode-git-worktree-${sessionID}` -} - function sanitizeBranchName(branch: string): string { - // Reject branches with shell metacharacters or git-unsafe patterns if (/[\s;&|`$(){}[\]<>\\'"!*?~^]/.test(branch) || branch.startsWith("-")) { throw new Error(`Invalid branch name: contains unsafe characters`) } return branch } -function getWorktreePath(sessionID: string, branch: string): string { - const safeBranch = branch.replace(/[^a-zA-Z0-9_\-/]/g, "_") - return `${getSessionBasePath(sessionID)}/${safeBranch}` -} - -async function cleanupSessionWorktrees( - $: typeof Bun.$, - sessionID: string, - directory: string -): Promise<{ cleaned: number; errors: string[] }> { - const worktrees = sessionWorktrees.get(sessionID) || [] - const errors: string[] = [] - let cleaned = 0 - - log(sessionID, "cleanup", `worktrees=${worktrees.length}`) - - for (const wt of worktrees) { - const result = await safeExec($, ["git", "worktree", "remove", "--force", wt.path], { - cwd: directory, - }) - if (result.success) { - cleaned++ - } else { - errors.push(`Failed to remove ${wt.path}: ${result.stderr}`) - } - } - - const basePath = getSessionBasePath(sessionID) - await safeExec($, ["rm", "-rf", basePath]) - - await safeExec($, ["git", "worktree", "prune"], { cwd: directory }) - - sessionWorktrees.delete(sessionID) - - return { cleaned, errors } -} - export const GitWorktreePlugin: Plugin = async (ctx) => { - const { $, directory, worktree } = ctx + const { $, directory, worktree, client } = ctx const repoRoot = worktree || directory return { @@ -191,7 +138,7 @@ export const GitWorktreePlugin: Plugin = async (ctx) => { if (/\bgit\s+worktree\b/.test(cmd)) { throw new Error( `Direct 'git worktree' commands are not allowed. ` + - `Use the 'use-git-worktree' tool instead for session-scoped worktree management, ` + + `Use the 'use-git-worktree' tool instead for managed worktree operations, ` + `automatic cleanup, and proper logging. ` + `Available actions: create, list, remove, merge, status, cleanup.` ) @@ -207,13 +154,8 @@ export const GitWorktreePlugin: Plugin = async (ctx) => { ) { const sessionID = event.properties?.sessionID if (sessionID && sessionWorktrees.has(sessionID)) { - const result = await cleanupSessionWorktrees($, sessionID, repoRoot) - if (result.errors.length > 0) { - console.error( - `[git-worktree] Cleanup errors for session ${sessionID}:`, - result.errors - ) - } + log(sessionID, "cleanup", "session ended, clearing local tracking") + sessionWorktrees.delete(sessionID) } } } catch (error) { @@ -226,16 +168,12 @@ export const GitWorktreePlugin: Plugin = async (ctx) => { description: `Manage git worktrees for concurrent branch development. IMPORTANT: Always use this tool instead of running \`git worktree\` commands directly via Bash. -This tool provides session-scoped worktrees, automatic cleanup, and proper logging. - -NOTE: This tool is only available to the MAIN AGENT. Task subagents cannot use this tool directly. -For concurrent work: main agent creates worktrees first, then launches subagents with the worktree PATH. -Subagents should use standard tools (Read, Write, Edit, Bash) in their assigned worktree directory. +This tool uses OpenCode's managed worktree API which stores worktrees in a protected directory. ## Actions -- **create**: Create a new git worktree for a branch -- **list**: List all git worktrees in the current session +- **create**: Create a new git worktree (uses OpenCode's managed worktree API) +- **list**: List all git worktrees (both session-tracked and project sandboxes) - **remove**: Remove a specific git worktree - **merge**: Merge git worktree changes back to a target branch - **status**: Get the status of a git worktree (changes, commits ahead/behind) @@ -250,31 +188,35 @@ When merging, use the \`mergeStrategy\` parameter: ## Example Usage -1. Create a git worktree for a feature branch: +1. Create a git worktree: \`\`\` - action: "create", branch: "feature/new-ui" + action: "create", name: "feature-work" \`\`\` 2. After making changes, merge back to main: \`\`\` - action: "merge", branch: "feature/new-ui", targetBranch: "main", mergeStrategy: "theirs" + action: "merge", branch: "opencode/feature-work", targetBranch: "main", mergeStrategy: "theirs" \`\`\` 3. Clean up when done: \`\`\` - action: "remove", branch: "feature/new-ui" + action: "remove", branch: "opencode/feature-work" \`\`\` -Git worktrees are stored at \`/tmp/opencode-git-worktree-{sessionID}/{branch}\` and are automatically cleaned up when the session ends.`, +Git worktrees are stored at \`~/.opencode/data/worktree/{projectID}/{name}\` with branches named \`opencode/{name}\`.`, args: { action: tool.schema .enum(["create", "list", "remove", "merge", "status", "cleanup"]) .describe("The git worktree action to perform"), + name: tool.schema + .string() + .optional() + .describe("Worktree name for create action (optional - auto-generated if not provided)"), branch: tool.schema .string() .optional() - .describe("Branch name for create/remove/merge/status actions"), + .describe("Branch name for remove/merge/status actions (e.g., 'opencode/calm-comet')"), targetBranch: tool.schema .string() .optional() @@ -285,14 +227,10 @@ Git worktrees are stored at \`/tmp/opencode-git-worktree-{sessionID}/{branch}\` .describe( "Conflict resolution strategy: 'ours' (keep target), 'theirs' (keep worktree), 'manual' (return diff)" ), - createBranch: tool.schema - .boolean() - .optional() - .describe("Create new branch if it doesn't exist (for create action)"), - baseBranch: tool.schema + startCommand: tool.schema .string() .optional() - .describe("Base branch to create new branch from (defaults to HEAD)"), + .describe("Optional command to run after creating the worktree (e.g., 'npm install')"), commitMessage: tool.schema .string() .optional() @@ -307,26 +245,23 @@ Git worktrees are stored at \`/tmp/opencode-git-worktree-{sessionID}/{branch}\` switch (args.action) { case "create": result = await createWorktree( - $, + client, sessionID, - repoRoot, - args.branch, - args.createBranch, - args.baseBranch + args.name, + args.startCommand ) break case "list": - result = await listWorktrees($, sessionID, repoRoot) + result = await listWorktrees(client, sessionID, repoRoot) break case "remove": - result = await removeWorktree($, sessionID, repoRoot, args.branch) + result = await removeWorktree(sessionID, repoRoot, args.branch) break case "merge": result = await mergeWorktree( - $, sessionID, repoRoot, args.branch, @@ -337,11 +272,11 @@ Git worktrees are stored at \`/tmp/opencode-git-worktree-{sessionID}/{branch}\` break case "status": - result = await getWorktreeStatus($, sessionID, repoRoot, args.branch) + result = await getWorktreeStatus(sessionID, repoRoot, args.branch) break case "cleanup": - result = await cleanupAll($, sessionID, repoRoot) + result = await cleanupAll(sessionID, repoRoot) break default: @@ -351,7 +286,6 @@ Git worktrees are stored at \`/tmp/opencode-git-worktree-{sessionID}/{branch}\` } } } catch (error) { - // Catch-all for any unhandled errors - never crash OpenCode const message = error instanceof Error ? error.message : String(error) result = { success: false, @@ -368,121 +302,74 @@ Git worktrees are stored at \`/tmp/opencode-git-worktree-{sessionID}/{branch}\` } async function createWorktree( - $: typeof Bun.$, + client: any, sessionID: string, - repoRoot: string, - branch?: string, - createBranch?: boolean, - baseBranch?: string + name?: string, + startCommand?: string ): Promise { - if (!branch) { - return { - success: false, - message: "Branch name is required for create action", - } - } + log(sessionID, "create", `name=${name ?? "auto"} startCommand=${startCommand ?? "none"}`) try { - sanitizeBranchName(branch) - if (baseBranch) sanitizeBranchName(baseBranch) - } catch (error) { - return { - success: false, - message: error instanceof Error ? error.message : String(error), - } - } - - const worktreePath = getWorktreePath(sessionID, branch) + // Use OpenCode's managed worktree API + const response = await client.worktree.create({ + name, + startCommand, + }) - // Check if worktree already exists - const existing = sessionWorktrees.get(sessionID) || [] - if (existing.some((w) => w.branch === branch)) { - const existingWt = existing.find((w) => w.branch === branch)! - return { - success: true, - message: `Worktree for branch '${branch}' already exists`, - path: existingWt.path, - branch, + if (!response.data) { + return { + success: false, + message: `Failed to create worktree: ${response.error?.message ?? "Unknown error"}`, + } } - } - // Ensure session directory exists - const basePath = getSessionBasePath(sessionID) - const mkdirResult = await safeExec($, ["mkdir", "-p", basePath]) - if (!mkdirResult.success) { - return { - success: false, - message: `Failed to create session directory '${basePath}': ${mkdirResult.stderr}. Check filesystem permissions.`, + const worktreeInfo: WorktreeInfo = { + name: response.data.name, + branch: response.data.branch, + directory: response.data.directory, } - } - log(sessionID, "mkdir", `path=${basePath}`) - // Build the git worktree add command - let cmd: string[] - if (createBranch) { - // Create new branch - const base = baseBranch || "HEAD" - cmd = ["git", "worktree", "add", "-b", branch, worktreePath, base] - } else { - // Use existing branch - cmd = ["git", "worktree", "add", worktreePath, branch] - } + // Track in session + if (!sessionWorktrees.has(sessionID)) { + sessionWorktrees.set(sessionID, []) + } + sessionWorktrees.get(sessionID)!.push(worktreeInfo) - log(sessionID, "exec", `cmd="${cmd.join(" ")}" cwd=${repoRoot}`) - const result = await safeExec($, cmd, { cwd: repoRoot }) + log(sessionID, "created", `name=${worktreeInfo.name} branch=${worktreeInfo.branch} path=${worktreeInfo.directory}`) - if (!result.success) { - log(sessionID, "error", `stderr="${result.stderr}"`) - // Check if branch doesn't exist and suggest creating it - if (result.stderr.includes("invalid reference") || result.stderr.includes("not a valid")) { - return { - success: false, - message: `Branch '${branch}' does not exist. Set createBranch: true to create it, or specify an existing branch.`, - } - } - // Check if branch is already checked out - if (result.stderr.includes("already checked out")) { - return { - success: false, - message: `Branch '${branch}' is already checked out in another worktree. Use a different branch name or remove the existing worktree first.`, - } + return { + success: true, + message: `Created managed worktree '${worktreeInfo.name}'`, + path: worktreeInfo.directory, + branch: worktreeInfo.branch, } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + log(sessionID, "error", `create failed: ${message}`) return { success: false, - message: `Failed to create worktree: ${result.stderr}`, + message: `Failed to create worktree: ${message}`, } } - - const worktreeInfo: WorktreeInfo = { - path: worktreePath, - branch, - createdAt: Date.now(), - } - - if (!sessionWorktrees.has(sessionID)) { - sessionWorktrees.set(sessionID, []) - } - sessionWorktrees.get(sessionID)!.push(worktreeInfo) - - log(sessionID, "create", `branch=${branch} path=${worktreePath}`) - - return { - success: true, - message: `Created worktree for branch '${branch}'`, - path: worktreePath, - branch, - } } async function listWorktrees( - $: typeof Bun.$, + client: any, sessionID: string, repoRoot: string ): Promise { const tracked = sessionWorktrees.get(sessionID) || [] + let projectSandboxes: string[] = [] + try { + const response = await client.worktree.list() + projectSandboxes = response.data ?? [] + } catch { + // Ignore errors listing sandboxes + } + // Also get all git worktrees to show the full picture - const gitResult = await safeExec($, ["git", "worktree", "list", "--porcelain"], { + const gitResult = await safeExec(["git", "worktree", "list", "--porcelain"], { cwd: repoRoot, }) @@ -491,7 +378,14 @@ async function listWorktrees( if (tracked.length > 0) { message += "\nSession-managed worktrees:\n" for (const wt of tracked) { - message += ` - ${wt.branch}: ${wt.path}\n` + message += ` - ${wt.name} (${wt.branch}): ${wt.directory}\n` + } + } + + if (projectSandboxes.length > 0) { + message += "\nProject sandboxes:\n" + for (const sandbox of projectSandboxes) { + message += ` - ${sandbox}\n` } } @@ -507,7 +401,6 @@ async function listWorktrees( } async function removeWorktree( - $: typeof Bun.$, sessionID: string, repoRoot: string, branch?: string @@ -523,13 +416,27 @@ async function removeWorktree( const worktree = tracked.find((w) => w.branch === branch) if (!worktree) { + // Try to find by name if branch doesn't match + const byName = tracked.find((w) => w.name === branch || `opencode/${w.name}` === branch) + if (byName) { + return removeWorktreeByInfo(sessionID, repoRoot, byName, tracked) + } return { success: false, - message: `No worktree found for branch '${branch}' in this session`, + message: `No worktree found for branch '${branch}' in this session. Use 'list' action to see available worktrees.`, } } - const result = await safeExec($, ["git", "worktree", "remove", "--force", worktree.path], { + return removeWorktreeByInfo(sessionID, repoRoot, worktree, tracked) +} + +async function removeWorktreeByInfo( + sessionID: string, + repoRoot: string, + worktree: WorktreeInfo, + tracked: WorktreeInfo[] +): Promise { + const result = await safeExec(["git", "worktree", "remove", "--force", worktree.directory], { cwd: repoRoot, }) @@ -545,17 +452,16 @@ async function removeWorktree( tracked.splice(index, 1) } - log(sessionID, "remove", `branch=${branch} path=${worktree.path}`) + log(sessionID, "remove", `name=${worktree.name} branch=${worktree.branch} path=${worktree.directory}`) return { success: true, - message: `Removed worktree for branch '${branch}'`, - branch, + message: `Removed worktree '${worktree.name}'`, + branch: worktree.branch, } } async function mergeWorktree( - $: typeof Bun.$, sessionID: string, repoRoot: string, branch?: string, @@ -588,21 +494,11 @@ async function mergeWorktree( } } - const tracked = sessionWorktrees.get(sessionID) || [] - const worktree = tracked.find((w) => w.branch === branch) - - if (!worktree) { - return { - success: false, - message: `No worktree found for branch '${branch}' in this session. Create it first or check the branch name.`, - } - } - const strategy = mergeStrategy || "manual" const message = commitMessage || `Merge branch '${branch}' into ${targetBranch}` // First, ensure we're on the target branch in the main worktree - const checkoutResult = await safeExec($, ["git", "checkout", targetBranch], { + const checkoutResult = await safeExec(["git", "checkout", targetBranch], { cwd: repoRoot, }) @@ -624,7 +520,7 @@ async function mergeWorktree( mergeCmd = ["git", "merge", "--no-commit", "--no-ff", branch] } - const mergeResult = await safeExec($, mergeCmd, { cwd: repoRoot }) + const mergeResult = await safeExec(mergeCmd, { cwd: repoRoot }) // Check for conflicts if (!mergeResult.success) { @@ -633,8 +529,8 @@ async function mergeWorktree( mergeResult.stdout.includes("CONFLICT") ) { // Get the diff for manual resolution - const diffResult = await safeExec($, ["git", "diff"], { cwd: repoRoot }) - const statusResult = await safeExec($, ["git", "status", "--porcelain"], { + const diffResult = await safeExec(["git", "diff"], { cwd: repoRoot }) + const statusResult = await safeExec(["git", "status", "--porcelain"], { cwd: repoRoot, }) @@ -654,7 +550,6 @@ async function mergeWorktree( } // For ours/theirs, conflicts should have been auto-resolved - // This means there was a different error return { success: false, message: `Merge failed with conflicts that couldn't be auto-resolved: ${mergeResult.stderr}`, @@ -670,7 +565,7 @@ async function mergeWorktree( // For manual strategy with no conflicts, commit the merge if (strategy === "manual") { - const commitResult = await safeExec($, ["git", "commit", "-m", message], { + const commitResult = await safeExec(["git", "commit", "-m", message], { cwd: repoRoot, }) @@ -692,7 +587,6 @@ async function mergeWorktree( } async function getWorktreeStatus( - $: typeof Bun.$, sessionID: string, repoRoot: string, branch?: string @@ -705,30 +599,34 @@ async function getWorktreeStatus( } const tracked = sessionWorktrees.get(sessionID) || [] - const worktree = tracked.find((w) => w.branch === branch) + let worktree = tracked.find((w) => w.branch === branch) + + // Try to find by name if branch doesn't match + if (!worktree) { + worktree = tracked.find((w) => w.name === branch || `opencode/${w.name}` === branch) + } if (!worktree) { return { success: false, - message: `No worktree found for branch '${branch}' in this session`, + message: `No worktree found for '${branch}' in this session. Use 'list' action to see available worktrees.`, } } // Get status in the worktree - const statusResult = await safeExec($, ["git", "status", "--porcelain"], { - cwd: worktree.path, + const statusResult = await safeExec(["git", "status", "--porcelain"], { + cwd: worktree.directory, }) // Get commits ahead/behind (ignore errors if remote branch doesn't exist) const logResult = await safeExec( - $, - ["git", "log", "--oneline", `origin/${branch}..${branch}`], - { cwd: worktree.path } + ["git", "log", "--oneline", `origin/${worktree.branch}..${worktree.branch}`], + { cwd: worktree.directory } ) - let message = `Worktree status for '${branch}':\n` - message += `Path: ${worktree.path}\n` - message += `Created: ${new Date(worktree.createdAt).toISOString()}\n\n` + let message = `Worktree status for '${worktree.name}':\n` + message += `Path: ${worktree.directory}\n` + message += `Branch: ${worktree.branch}\n\n` if (statusResult.stdout) { message += `Changed files:\n${statusResult.stdout}\n` @@ -743,28 +641,46 @@ async function getWorktreeStatus( return { success: true, message, - path: worktree.path, - branch, + path: worktree.directory, + branch: worktree.branch, } } async function cleanupAll( - $: typeof Bun.$, sessionID: string, repoRoot: string ): Promise { - const result = await cleanupSessionWorktrees($, sessionID, repoRoot) + const worktrees = sessionWorktrees.get(sessionID) || [] + const errors: string[] = [] + let cleaned = 0 - if (result.errors.length > 0) { + log(sessionID, "cleanup", `worktrees=${worktrees.length}`) + + for (const wt of worktrees) { + const result = await safeExec(["git", "worktree", "remove", "--force", wt.directory], { + cwd: repoRoot, + }) + if (result.success) { + cleaned++ + } else { + errors.push(`Failed to remove ${wt.name}: ${result.stderr}`) + } + } + + await safeExec(["git", "worktree", "prune"], { cwd: repoRoot }) + + sessionWorktrees.delete(sessionID) + + if (errors.length > 0) { return { success: false, - message: `Cleaned ${result.cleaned} worktrees with errors:\n${result.errors.join("\n")}`, + message: `Cleaned ${cleaned} worktrees with errors:\n${errors.join("\n")}`, } } return { success: true, - message: `Cleaned up ${result.cleaned} worktrees`, + message: `Cleaned up ${cleaned} worktrees`, } } @@ -776,6 +692,10 @@ function formatResult(result: WorktreeResult): string { output += `\n\nWorktree path: ${result.path}` } + if (result.branch) { + output += `\nBranch: ${result.branch}` + } + if (result.conflicts && result.conflicts.length > 0) { output += `\n\nConflicted files:\n${result.conflicts.map((f) => ` - ${f}`).join("\n")}` } From e8821ac827dba672263169853cb768f42effcd24 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sun, 4 Jan 2026 01:35:56 +0000 Subject: [PATCH 9/9] Fix worktree plugin to enforce tool usage and document subagent limitations - Remove dependency on client.worktree API (which may not exist) - Use direct git commands within the plugin for worktree management - Store worktrees in .opencode/worktrees/{sessionID}/ (project-local) - Add .opencode/worktrees/ to .gitignore - Document critical limitation: plugin tools/hooks NOT available to subagents - Update tool description with correct subagent pattern for concurrent work - Add generateWorktreeName() for auto-generated names like 'calm-comet' - The tool.execute.before hook only applies to main agent, not subagents --- .gitignore | 3 +- .opencode/plugin/git-worktree.ts | 198 +++++++++++++++++++++---------- 2 files changed, 139 insertions(+), 62 deletions(-) diff --git a/.gitignore b/.gitignore index 0120990..7e8c857 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ Network Trash Folder Temporary Items .apdisk -# OpenCode plugin dependencies (installed at runtime) +# OpenCode plugin dependencies and runtime data .opencode/node_modules/ .opencode/bun.lock +.opencode/worktrees/ diff --git a/.opencode/plugin/git-worktree.ts b/.opencode/plugin/git-worktree.ts index f6fdcf5..badebd0 100644 --- a/.opencode/plugin/git-worktree.ts +++ b/.opencode/plugin/git-worktree.ts @@ -2,16 +2,19 @@ * Git Worktree Plugin for OpenCode * * Provides git worktree management for concurrent branch development. - * Uses OpenCode's managed worktree API which stores worktrees in a protected - * directory (~/.opencode/data/worktree/{projectID}/), eliminating the need - * for external_directory permissions. + * Worktrees are stored in a session-scoped directory within the project's + * .opencode/worktrees/ folder to maintain isolation between sessions. * * ## Agent Usage Guide * - * IMPORTANT: Always use the `use-git-worktree` tool for ALL git worktree operations. - * Do NOT use `git worktree` commands directly via Bash - the plugin provides: - * - Managed worktrees stored in OpenCode's protected data directory - * - Automatic cleanup and sandbox tracking + * CRITICAL: The `use-git-worktree` tool MUST be used for ALL git worktree operations. + * - Do NOT use `git worktree` commands directly via Bash + * - Do NOT delegate worktree creation to Task subagents + * - The tool.execute.before hook blocks direct `git worktree` commands, but this + * hook ONLY applies to the main agent - subagents bypass plugin hooks entirely + * + * The plugin provides: + * - Session-scoped worktrees with automatic cleanup * - Proper conflict resolution strategies * - Logging and observability for non-interactive workflows * @@ -31,20 +34,28 @@ * - "theirs": Keep changes from the worktree branch on conflict * - "manual": Stop on conflict and return diff for user decision * - * ## Subagent Usage + * ## Subagent Usage - IMPORTANT + * + * Plugin tools and hooks are NOT available to Task subagents. Subagents run in + * isolated contexts without access to plugin state or hooks. This means: + * + * 1. The `use-git-worktree` tool is NOT available to subagents + * 2. The `tool.execute.before` hook that blocks `git worktree` commands does NOT + * apply to subagents - they can run any bash command + * 3. Subagents cannot create, manage, or cleanup worktrees * - * This tool uses OpenCode's managed worktree API. For concurrent work: - * 1. **Main agent creates worktrees** using `use-git-worktree` tool - * 2. **Main agent launches Task subagents** with the worktree PATH: + * ### Correct Pattern for Concurrent Work: + * 1. **Main agent creates ALL worktrees first** using `use-git-worktree` tool + * 2. **Main agent launches Task subagents** with the worktree PATH (not branch): * ``` * Task(subagent_type="general", prompt="Work in {worktree_path}. - * Use Read/Write/Edit/Bash tools to modify files in that directory.") + * Use Read/Write/Edit/Bash tools to modify files in that directory. + * Do NOT use git worktree commands - the worktree is already set up.") * ``` * 3. **Subagents use standard tools** (Read, Write, Edit, Bash) in their assigned paths - * 4. **Main agent handles merge/cleanup** using `use-git-worktree` + * 4. **Main agent handles merge/cleanup** using `use-git-worktree` after subagents complete * - * Git worktrees are stored in ~/.opencode/data/worktree/{projectID}/ and are - * tracked as "sandboxes" in the project configuration. + * Git worktrees are stored in .opencode/worktrees/{sessionID}/{name}/. */ import { type Plugin, tool } from "@opencode-ai/plugin" @@ -127,8 +138,10 @@ function sanitizeBranchName(branch: string): string { } export const GitWorktreePlugin: Plugin = async (ctx) => { - const { $, directory, worktree, client } = ctx + const { directory, worktree } = ctx const repoRoot = worktree || directory + // Worktrees are stored within the project directory to avoid permission issues + const worktreeBaseDir = `${repoRoot}/.opencode/worktrees` return { "tool.execute.before": async (input, output) => { @@ -167,13 +180,14 @@ export const GitWorktreePlugin: Plugin = async (ctx) => { "use-git-worktree": tool({ description: `Manage git worktrees for concurrent branch development. -IMPORTANT: Always use this tool instead of running \`git worktree\` commands directly via Bash. -This tool uses OpenCode's managed worktree API which stores worktrees in a protected directory. +CRITICAL: This tool MUST be used for ALL git worktree operations. +- Do NOT use \`git worktree\` commands directly via Bash - they will be blocked +- Do NOT delegate worktree creation to Task subagents - plugin tools are NOT available to subagents ## Actions -- **create**: Create a new git worktree (uses OpenCode's managed worktree API) -- **list**: List all git worktrees (both session-tracked and project sandboxes) +- **create**: Create a new git worktree with a session-scoped branch +- **list**: List all git worktrees in the current session - **remove**: Remove a specific git worktree - **merge**: Merge git worktree changes back to a target branch - **status**: Get the status of a git worktree (changes, commits ahead/behind) @@ -193,17 +207,32 @@ When merging, use the \`mergeStrategy\` parameter: action: "create", name: "feature-work" \`\`\` -2. After making changes, merge back to main: +2. Work in the worktree directory (use Read/Write/Edit/Bash with the returned path) + +3. After making changes, merge back to main: \`\`\` action: "merge", branch: "opencode/feature-work", targetBranch: "main", mergeStrategy: "theirs" \`\`\` -3. Clean up when done: +4. Clean up when done: \`\`\` action: "remove", branch: "opencode/feature-work" \`\`\` -Git worktrees are stored at \`~/.opencode/data/worktree/{projectID}/{name}\` with branches named \`opencode/{name}\`.`, +## Subagent Pattern for Concurrent Work + +Since plugin tools are NOT available to Task subagents: + +1. **Main agent creates all worktrees first** using this tool +2. **Main agent gets the worktree paths** from the create results +3. **Main agent launches Task subagents** with explicit paths: + \`\`\` + Task(prompt="Work in /path/to/worktree. Edit files using Read/Write/Edit tools. + Do NOT use git worktree commands - the worktree is already created.") + \`\`\` +4. **Main agent handles merge/cleanup** after subagents complete + +Git worktrees are stored at \`.opencode/worktrees/{sessionID}/{name}\` with branches named \`opencode/{name}\`.`, args: { action: tool.schema @@ -212,11 +241,11 @@ Git worktrees are stored at \`~/.opencode/data/worktree/{projectID}/{name}\` wit name: tool.schema .string() .optional() - .describe("Worktree name for create action (optional - auto-generated if not provided)"), + .describe("Worktree name for create action (optional - auto-generated like 'calm-comet' if not provided)"), branch: tool.schema .string() .optional() - .describe("Branch name for remove/merge/status actions (e.g., 'opencode/calm-comet')"), + .describe("Branch name. For create: custom branch name (default: 'opencode/{name}'). For remove/merge/status: the branch to operate on."), targetBranch: tool.schema .string() .optional() @@ -245,15 +274,17 @@ Git worktrees are stored at \`~/.opencode/data/worktree/{projectID}/{name}\` wit switch (args.action) { case "create": result = await createWorktree( - client, + repoRoot, + worktreeBaseDir, sessionID, args.name, + args.branch, args.startCommand ) break case "list": - result = await listWorktrees(client, sessionID, repoRoot) + result = await listWorktrees(sessionID, repoRoot) break case "remove": @@ -301,32 +332,87 @@ Git worktrees are stored at \`~/.opencode/data/worktree/{projectID}/{name}\` wit } } +function generateWorktreeName(): string { + const adjectives = ["calm", "swift", "bright", "bold", "quiet", "keen", "warm", "cool", "soft", "wild"] + const nouns = ["comet", "river", "storm", "cloud", "flame", "frost", "dawn", "dusk", "peak", "wave"] + const adj = adjectives[Math.floor(Math.random() * adjectives.length)] + const noun = nouns[Math.floor(Math.random() * nouns.length)] + return `${adj}-${noun}` +} + async function createWorktree( - client: any, + repoRoot: string, + worktreeBaseDir: string, sessionID: string, name?: string, + branch?: string, startCommand?: string ): Promise { - log(sessionID, "create", `name=${name ?? "auto"} startCommand=${startCommand ?? "none"}`) + const worktreeName = name ?? generateWorktreeName() + const branchName = branch ?? `opencode/${worktreeName}` + + log(sessionID, "create", `name=${worktreeName} branch=${branchName} startCommand=${startCommand ?? "none"}`) try { - // Use OpenCode's managed worktree API - const response = await client.worktree.create({ - name, - startCommand, - }) - - if (!response.data) { + // Sanitize branch name + sanitizeBranchName(branchName) + + // Create session-scoped worktree directory + const sessionWorktreeDir = `${worktreeBaseDir}/${sessionID}` + const worktreePath = `${sessionWorktreeDir}/${worktreeName}` + + // Ensure the base directory exists + const mkdirResult = await safeExec(["mkdir", "-p", sessionWorktreeDir]) + if (!mkdirResult.success) { return { success: false, - message: `Failed to create worktree: ${response.error?.message ?? "Unknown error"}`, + message: `Failed to create worktree directory: ${mkdirResult.stderr}`, + } + } + + // Check if worktree already exists + const existingWorktrees = sessionWorktrees.get(sessionID) || [] + const existing = existingWorktrees.find(w => w.name === worktreeName) + if (existing) { + return { + success: true, + message: `Worktree '${worktreeName}' already exists`, + path: existing.directory, + branch: existing.branch, + } + } + + // Create the worktree with a new branch + const gitResult = await safeExec( + ["git", "worktree", "add", "-b", branchName, worktreePath, "HEAD"], + { cwd: repoRoot } + ) + + if (!gitResult.success) { + // If branch already exists, try without -b + if (gitResult.stderr.includes("already exists")) { + const retryResult = await safeExec( + ["git", "worktree", "add", worktreePath, branchName], + { cwd: repoRoot } + ) + if (!retryResult.success) { + return { + success: false, + message: `Failed to create worktree: ${retryResult.stderr}`, + } + } + } else { + return { + success: false, + message: `Failed to create worktree: ${gitResult.stderr}`, + } } } const worktreeInfo: WorktreeInfo = { - name: response.data.name, - branch: response.data.branch, - directory: response.data.directory, + name: worktreeName, + branch: branchName, + directory: worktreePath, } // Track in session @@ -335,13 +421,19 @@ async function createWorktree( } sessionWorktrees.get(sessionID)!.push(worktreeInfo) - log(sessionID, "created", `name=${worktreeInfo.name} branch=${worktreeInfo.branch} path=${worktreeInfo.directory}`) + // Run start command if provided + if (startCommand) { + log(sessionID, "start-command", `running: ${startCommand}`) + await safeExec(["sh", "-c", startCommand], { cwd: worktreePath }) + } + + log(sessionID, "created", `name=${worktreeName} branch=${branchName} path=${worktreePath}`) return { success: true, - message: `Created managed worktree '${worktreeInfo.name}'`, - path: worktreeInfo.directory, - branch: worktreeInfo.branch, + message: `Created worktree '${worktreeName}' with branch '${branchName}'`, + path: worktreePath, + branch: branchName, } } catch (error) { const message = error instanceof Error ? error.message : String(error) @@ -354,21 +446,12 @@ async function createWorktree( } async function listWorktrees( - client: any, sessionID: string, repoRoot: string ): Promise { const tracked = sessionWorktrees.get(sessionID) || [] - let projectSandboxes: string[] = [] - try { - const response = await client.worktree.list() - projectSandboxes = response.data ?? [] - } catch { - // Ignore errors listing sandboxes - } - - // Also get all git worktrees to show the full picture + // Get all git worktrees to show the full picture const gitResult = await safeExec(["git", "worktree", "list", "--porcelain"], { cwd: repoRoot, }) @@ -382,13 +465,6 @@ async function listWorktrees( } } - if (projectSandboxes.length > 0) { - message += "\nProject sandboxes:\n" - for (const sandbox of projectSandboxes) { - message += ` - ${sandbox}\n` - } - } - if (gitResult.success && gitResult.stdout) { message += `\nAll repository worktrees:\n${gitResult.stdout}` }