Add git-worktree tool for managing worktrees
This commit is contained in:
241
.opencode/tools/git-worktree.ts
Normal file
241
.opencode/tools/git-worktree.ts
Normal file
@@ -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(),
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user