diff --git a/.opencode/tools/git-worktree.ts b/.opencode/tools/git-worktree.ts new file mode 100644 index 0000000..1491e41 --- /dev/null +++ b/.opencode/tools/git-worktree.ts @@ -0,0 +1,241 @@ +import { tool } from "@opencode-ai/plugin" +import { $ } from "bun" +import { join, dirname } from "node:path" + +async function getWorktreeLocation() { + const repoRoot = await $`git rev-parse --show-toplevel`.text() + const worktreesDir = join(dirname(repoRoot), "worktrees") + return { repoRoot, worktreesDir } +} + +export const worktreeCreate = tool({ + description: "Create a new git worktree with specified name and branch", + args: { + name: tool.schema.string().describe("Worktree directory name (e.g., 'issue-42')"), + branch: tool.schema.string().describe("Git branch to attach (must exist or will be created from base)"), + base: tool.schema.string().optional().describe("Base branch for new branches (default: origin/main)"), + }, + async execute(args, context) { + try { + const { worktreesDir } = await getWorktreeLocation() + const worktreePath = join(worktreesDir, args.name) + + await $`mkdir -p ${worktreesDir}`.nothrow() + + const existingBranch = await $`git branch --list ${args.branch}`.text() + const base = args.base || "origin/main" + + if (existingBranch.trim()) { + await $`git worktree add ${worktreePath} ${args.branch}` + } else { + await $`git worktree add ${worktreePath} -b ${args.branch} ${base}` + } + + return { + success: true, + path: worktreePath, + } + } catch (error: any) { + return { + success: false, + error: "Failed to create worktree", + message: error.message, + stderr: error.stderr?.toString(), + } + } + }, +}) + +export const worktreeList = tool({ + description: "List all worktrees with their paths and branches", + args: { + path: tool.schema.string().optional().describe("Specific worktree path to list (lists all if omitted)"), + }, + async execute(args, context) { + try { + const { repoRoot } = await getWorktreeLocation() + + let cmd + if (args.path) { + cmd = $`git worktree list --porcelain` + } else { + cmd = $`git worktree list --porcelain` + } + + const output = await cmd.text() + + const worktrees = output + .split("\n") + .filter((line) => line.startsWith("worktree ")) + .map((line) => { + const path = line.replace("worktree ", "").trim() + return { path } + }) + + return { + success: true, + worktrees, + } + } catch (error: any) { + return { + success: false, + error: "Failed to list worktrees", + message: error.message, + stderr: error.stderr?.toString(), + } + } + }, +}) + +export const worktreeCheckout = tool({ + description: "Checkout an existing worktree by name", + args: { + name: tool.schema.string().describe("Worktree name to checkout"), + }, + async execute(args, context) { + try { + const { worktreesDir } = await getWorktreeLocation() + const worktreePath = join(worktreesDir, args.name) + + if (!(await $`test -d ${worktreePath}`.nothrow().text())) { + return { + success: false, + error: "Worktree does not exist", + message: `Worktree '${args.name}' not found at ${worktreePath}`, + } + } + + return { + success: true, + path: worktreePath, + } + } catch (error: any) { + return { + success: false, + error: "Failed to checkout worktree", + message: error.message, + stderr: error.stderr?.toString(), + } + } + }, +}) + +export const worktreeRemove = tool({ + description: "Remove a worktree with dirty-check safety", + args: { + name: tool.schema.string().describe("Worktree name to remove"), + force: tool.schema.boolean().optional().describe("Force remove even if dirty"), + }, + async execute(args, context) { + try { + const { worktreesDir } = await getWorktreeLocation() + const worktreePath = join(worktreesDir, args.name) + + if (!(await $`test -d ${worktreePath}`.nothrow().text())) { + return { + success: false, + error: "Worktree does not exist", + message: `Worktree '${args.name}' not found at ${worktreePath}`, + } + } + + if (!args.force) { + const status = await $`git -C ${worktreePath} status --porcelain`.nothrow().text() + if (status.trim()) { + return { + success: false, + error: "Worktree has uncommitted changes", + message: "Use --force to remove anyway", + } + } + } + + await $`git worktree remove ${worktreePath} ${args.force ? "--force" : ""}` + + return { + success: true, + message: `Worktree '${args.name}' removed`, + } + } catch (error: any) { + return { + success: false, + error: "Failed to remove worktree", + message: error.message, + stderr: error.stderr?.toString(), + } + } + }, +}) + +export const worktreeCleanup = tool({ + description: "Remove stale worktrees (manually invoked)", + args: { + olderThan: tool.schema.number().optional().describe("Remove worktrees older than N days (default: 7)"), + force: tool.schema.boolean().optional().describe("Force cleanup without confirmation"), + }, + async execute(args, context) { + try { + const { worktreesDir } = await getWorktreeLocation() + const olderThan = args.olderThan || 7 + + const output = await $`git worktree list --porcelain`.text() + + const worktrees = output + .split("\n") + .filter((line) => line.startsWith("worktree ")) + .map((line) => { + const path = line.replace("worktree ", "").trim() + return path + }) + + const now = new Date() + const staleWorktrees = [] + + for (const path of worktrees) { + if (!(await $`test -d ${path}`.nothrow().text())) { + continue + } + + const stat = await $`stat -f %m ${path}`.nothrow().text() + const mtime = parseInt(stat.trim()) * 1000 + const ageDays = (now.getTime() - mtime) / (1000 * 60 * 60 * 24) + + if (ageDays > olderThan) { + staleWorktrees.push({ path, age: Math.round(ageDays) }) + } + } + + if (staleWorktrees.length === 0) { + return { + success: true, + message: "No stale worktrees found", + } + } + + if (!args.force) { + return { + success: true, + staleWorktrees, + message: `Found ${staleWorktrees.length} stale worktree(s). Use --force to remove.`, + } + } + + for (const { path } of staleWorktrees) { + await $`git worktree remove ${path} --force`.nothrow() + } + + return { + success: true, + removed: staleWorktrees, + message: `Removed ${staleWorktrees.length} stale worktree(s)`, + } + } catch (error: any) { + return { + success: false, + error: "Failed to cleanup worktrees", + message: error.message, + stderr: error.stderr?.toString(), + } + } + }, +})