use std::path::PathBuf;
use async_trait::async_trait;
use serde::Deserialize;
use super::traits::*;
pub struct WorktreeTool {
workspace: PathBuf,
}
impl WorktreeTool {
pub fn new(workspace: PathBuf) -> Self {
Self { workspace }
}
fn worktrees_dir(&self) -> PathBuf {
self.workspace.join(".worktrees")
}
}
#[derive(Deserialize)]
struct WorktreeArgs {
action: String,
#[serde(default)]
name: String,
#[serde(default)]
base_branch: String,
}
#[async_trait]
impl Tool for WorktreeTool {
fn name(&self) -> &str {
"worktree"
}
fn spec(&self) -> ToolSpec {
ToolSpec {
name: "worktree".to_string(),
description: "Manage isolated git worktrees for parallel or sandboxed work. \
'add' creates a new branch + worktree in .worktrees/<name>. \
'remove' deletes the worktree and its branch. \
'list' shows all active worktrees."
.to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["add", "remove", "list"],
"description": "add = create worktree, remove = clean up, list = show all"
},
"name": {
"type": "string",
"description": "Worktree name (becomes the branch name and directory name)"
},
"base_branch": {
"type": "string",
"description": "Branch to base the new worktree on (default: current HEAD)"
}
},
"required": ["action"]
}),
}
}
async fn execute(&self, arguments: &str) -> anyhow::Result<ToolResult> {
let args: WorktreeArgs = serde_json::from_str(arguments)?;
match args.action.as_str() {
"list" => {
let output = run_git(&self.workspace, &["worktree", "list", "--porcelain"]).await?;
Ok(ToolResult::success(if output.is_empty() {
"No worktrees.".to_string()
} else {
output
}))
}
"add" => {
if args.name.is_empty() {
return Ok(ToolResult::error("name is required"));
}
let name: String = args
.name
.chars()
.filter(|c| c.is_alphanumeric() || matches!(c, '-' | '_'))
.collect();
if name.is_empty() {
return Ok(ToolResult::error("invalid name"));
}
let worktree_path = self.worktrees_dir().join(&name);
let branch = format!("worktree/{}", name);
tokio::fs::create_dir_all(self.worktrees_dir()).await?;
let mut cmd_args = vec!["worktree", "add", "-b"];
let branch_ref = branch.as_str();
let path_str = worktree_path.to_string_lossy().to_string();
cmd_args.extend_from_slice(&[branch_ref, &path_str]);
if !args.base_branch.is_empty() {
}
let mut full_args = vec!["worktree", "add", "-b", branch_ref, &path_str];
let base = args.base_branch.clone();
if !base.is_empty() {
full_args.push(&base);
}
let output = run_git(&self.workspace, &full_args).await?;
Ok(ToolResult::success(format!(
"Created worktree '{}' at {} on branch {}\n{}",
name,
worktree_path.display(),
branch,
output
)))
}
"remove" => {
if args.name.is_empty() {
return Ok(ToolResult::error("name is required"));
}
let name: String = args
.name
.chars()
.filter(|c| c.is_alphanumeric() || matches!(c, '-' | '_'))
.collect();
let worktree_path = self.worktrees_dir().join(&name);
let branch = format!("worktree/{}", name);
let path_str = worktree_path.to_string_lossy().to_string();
let _ = run_git(
&self.workspace,
&["worktree", "remove", "--force", &path_str],
)
.await;
let _ = run_git(&self.workspace, &["branch", "-D", &branch]).await;
Ok(ToolResult::success(format!(
"Removed worktree '{}' and branch {}",
name, branch
)))
}
other => Ok(ToolResult::error(format!("Unknown action: {}", other))),
}
}
}
async fn run_git(workspace: &PathBuf, args: &[&str]) -> anyhow::Result<String> {
let output = tokio::process::Command::new("git")
.args(args)
.current_dir(workspace)
.output()
.await?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if !output.status.success() && !stderr.is_empty() {
return Err(anyhow::anyhow!("git error: {}", stderr.trim()));
}
Ok(if stdout.is_empty() { stderr } else { stdout })
}