use super::Tool;
use crate::config::SafetyConfig;
use anyhow::{Context, Result};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::env;
use std::path::{Path, PathBuf};
use tracing::{info, warn};
use std::sync::Mutex;
static WORKTREE_STATE: Mutex<WorktreeState> = Mutex::new(WorktreeState::new());
#[derive(Debug, Clone)]
struct WorktreeState {
directory_stack: Vec<PathBuf>,
current_worktree: Option<PathBuf>,
}
impl WorktreeState {
const fn new() -> Self {
Self {
directory_stack: Vec::new(),
current_worktree: None,
}
}
fn initialize(&mut self) -> Result<()> {
if self.directory_stack.is_empty() {
let current = env::current_dir().context("Failed to get current directory")?;
self.directory_stack.push(current);
}
Ok(())
}
fn push_worktree(&mut self, worktree_path: PathBuf) -> Result<PathBuf> {
self.initialize()?;
self.directory_stack.push(worktree_path.clone());
self.current_worktree = Some(worktree_path.clone());
env::set_current_dir(&worktree_path).with_context(|| {
format!(
"Failed to change to worktree directory: {}",
worktree_path.display()
)
})?;
Ok(worktree_path)
}
fn pop_worktree(&mut self, remove: bool) -> Result<(PathBuf, Option<PathBuf>)> {
self.initialize()?;
let current = self.directory_stack.pop();
let _previous_path = current.clone();
let removed_path = if remove { current } else { None };
self.current_worktree = self.directory_stack.last().cloned();
if let Some(ref root) = self.directory_stack.first() {
env::set_current_dir(root).with_context(|| {
format!(
"Failed to change back to root directory: {}",
root.display()
)
})?;
}
Ok((
self.directory_stack
.first()
.cloned()
.unwrap_or_else(|| PathBuf::from(".")),
removed_path,
))
}
#[allow(dead_code)]
fn current(&self) -> Option<&PathBuf> {
self.directory_stack.last()
}
#[allow(dead_code)]
fn root(&self) -> Option<&PathBuf> {
self.directory_stack.first()
}
fn is_in_worktree(&self) -> bool {
self.directory_stack.len() > 1
}
}
const DEFAULT_WORKTREE_BASE: &str = ".selfware/worktrees";
fn generate_worktree_name() -> String {
let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
format!("worktree_{}", timestamp)
}
#[allow(dead_code)]
fn resolve_worktree_path(path: Option<&str>) -> Result<PathBuf> {
if let Some(p) = path {
let path_buf = PathBuf::from(p);
if path_buf.is_absolute() {
Ok(path_buf)
} else {
env::current_dir()
.map(|cwd| cwd.join(&path_buf))
.context("Failed to resolve relative path")
}
} else {
let name = generate_worktree_name();
env::current_dir()
.map(|cwd| cwd.join(DEFAULT_WORKTREE_BASE).join(name))
.context("Failed to create default worktree path")
}
}
async fn find_git_root() -> Result<PathBuf> {
let output = tokio::process::Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output()
.await
.context("Failed to execute git rev-parse")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Not a git repository: {}", stderr);
}
let root = String::from_utf8_lossy(&output.stdout);
Ok(PathBuf::from(root.trim()))
}
fn validate_branch_name(name: &str) -> Result<()> {
if name.is_empty() {
anyhow::bail!("Branch name must not be empty");
}
if name.len() > 255 {
anyhow::bail!("Branch name too long (max 255 characters)");
}
for c in name.chars() {
if c.is_control() || matches!(c, ';' | '&' | '|' | '$' | '`' | '<' | '>') {
anyhow::bail!("Invalid character '{}' in branch name", c);
}
}
if name.starts_with('-') {
anyhow::bail!("Branch name must not start with '-'");
}
Ok(())
}
fn validate_path(path: &str, _safety_config: Option<&SafetyConfig>) -> Result<()> {
if path.contains("..") {
let normalized = Path::new(path).components().collect::<PathBuf>();
if normalized
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
}
}
if path.contains('\0') {
anyhow::bail!("Path contains null bytes");
}
Ok(())
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorktreeEntry {
pub path: String,
pub branch: Option<String>,
pub detached: bool,
pub bare: bool,
}
#[derive(Default)]
pub struct EnterWorktreeTool {
pub safety_config: Option<SafetyConfig>,
}
#[derive(Default)]
pub struct ExitWorktreeTool {
pub safety_config: Option<SafetyConfig>,
}
#[derive(Default)]
pub struct ListWorktreesTool {
pub safety_config: Option<SafetyConfig>,
}
impl EnterWorktreeTool {
pub fn new() -> Self {
Self::default()
}
pub fn with_safety_config(config: SafetyConfig) -> Self {
Self {
safety_config: Some(config),
}
}
}
impl ExitWorktreeTool {
pub fn new() -> Self {
Self::default()
}
pub fn with_safety_config(config: SafetyConfig) -> Self {
Self {
safety_config: Some(config),
}
}
}
impl ListWorktreesTool {
pub fn new() -> Self {
Self::default()
}
pub fn with_safety_config(config: SafetyConfig) -> Self {
Self {
safety_config: Some(config),
}
}
}
#[async_trait]
impl Tool for EnterWorktreeTool {
fn name(&self) -> &str {
"enter_worktree"
}
fn description(&self) -> &str {
"Create and enter a git worktree for isolated development. Changes working directory to the new worktree. \
If no path is provided, creates worktree at .selfware/worktrees/{timestamp}/. \
If no branch is provided, creates a detached worktree."
}
fn schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path for the new worktree (default: .selfware/worktrees/{timestamp}/)"
},
"branch": {
"type": "string",
"description": "Branch to checkout (default: detached HEAD)"
}
}
})
}
async fn execute(&self, args: Value) -> Result<Value> {
let path_arg = args.get("path").and_then(|v| v.as_str());
let branch_arg = args.get("branch").and_then(|v| v.as_str());
if let Some(p) = path_arg {
validate_path(p, self.safety_config.as_ref())?;
}
if let Some(b) = branch_arg {
validate_branch_name(b)?;
}
let git_root = find_git_root().await?;
let original_dir = env::current_dir().context("Failed to get current directory")?;
let worktree_path = if let Some(p) = path_arg {
PathBuf::from(p)
} else {
let name = generate_worktree_name();
git_root.join(DEFAULT_WORKTREE_BASE).join(name)
};
if let Some(parent) = worktree_path.parent() {
tokio::fs::create_dir_all(parent)
.await
.with_context(|| format!("Failed to create directory: {}", parent.display()))?;
}
info!("Creating worktree at: {}", worktree_path.display());
let mut cmd = tokio::process::Command::new("git");
cmd.arg("worktree").arg("add");
if branch_arg.is_none() {
cmd.arg("--detach");
}
cmd.arg(&worktree_path);
if let Some(branch) = branch_arg {
cmd.arg(branch);
}
let output = cmd
.current_dir(&git_root)
.output()
.await
.context("Failed to execute git worktree add")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Failed to create worktree: {}", stderr);
}
let worktree_path_str = worktree_path.to_string_lossy().to_string();
let branch_used = branch_arg.unwrap_or("(detached)").to_string();
let mut state = WORKTREE_STATE
.lock()
.map_err(|e| anyhow::anyhow!("Lock poisoned: {}", e))?;
state.push_worktree(worktree_path.clone())?;
info!(
"Entered worktree: {} (branch: {})",
worktree_path.display(),
branch_used
);
Ok(serde_json::json!({
"success": true,
"worktree_path": worktree_path_str,
"branch": branch_used,
"previous_path": original_dir.to_string_lossy().to_string(),
"git_root": git_root.to_string_lossy().to_string()
}))
}
fn metadata(&self) -> crate::safety::ToolMetadata {
crate::safety::ToolMetadata::custom(
false,
false,
crate::safety::RiskLevel::Medium,
false,
false,
)
}
}
#[async_trait]
impl Tool for ExitWorktreeTool {
fn name(&self) -> &str {
"exit_worktree"
}
fn description(&self) -> &str {
"Exit the current git worktree and return to the main repository. \
Optionally remove the worktree directory."
}
fn schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path of worktree to exit (default: current worktree)"
},
"remove": {
"type": "boolean",
"description": "Remove the worktree after exiting",
"default": false
}
}
})
}
async fn execute(&self, args: Value) -> Result<Value> {
let _path_arg = args.get("path").and_then(|v| v.as_str());
let remove = args
.get("remove")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let (root_path, removed_path) = {
let mut state = WORKTREE_STATE
.lock()
.map_err(|e| anyhow::anyhow!("Lock poisoned: {}", e))?;
if !state.is_in_worktree() {
anyhow::bail!("Not currently in a worktree");
}
state.pop_worktree(remove)?
};
let mut removed = false;
if remove {
if let Some(ref worktree_path) = removed_path {
let output = tokio::process::Command::new("git")
.args(["worktree", "remove", &worktree_path.to_string_lossy()])
.output()
.await
.context("Failed to execute git worktree remove")?;
if output.status.success() {
removed = true;
info!("Removed worktree: {}", worktree_path.display());
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
warn!("Failed to remove worktree: {}", stderr);
}
}
}
info!("Exited worktree, returned to: {}", root_path.display());
Ok(serde_json::json!({
"success": true,
"previous_path": removed_path.map(|p| p.to_string_lossy().to_string()).unwrap_or_default(),
"current_path": root_path.to_string_lossy().to_string(),
"removed": removed
}))
}
fn metadata(&self) -> crate::safety::ToolMetadata {
crate::safety::ToolMetadata::custom(
false,
true, crate::safety::RiskLevel::Medium,
false,
false,
)
}
}
#[async_trait]
impl Tool for ListWorktreesTool {
fn name(&self) -> &str {
"list_worktrees"
}
fn description(&self) -> &str {
"List all git worktrees with their paths and branches."
}
fn schema(&self) -> Value {
serde_json::json!({
"type": "object",
"properties": {}
})
}
async fn execute(&self, _args: Value) -> Result<Value> {
let output = tokio::process::Command::new("git")
.args(["worktree", "list", "--porcelain"])
.output()
.await
.context("Failed to execute git worktree list")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Failed to list worktrees: {}", stderr);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let worktrees = parse_worktree_list(&stdout);
let state = WORKTREE_STATE
.lock()
.map_err(|e| anyhow::anyhow!("Lock poisoned: {}", e))?;
let current_worktree = state
.current_worktree
.as_ref()
.map(|p| p.to_string_lossy().to_string());
Ok(serde_json::json!({
"worktrees": worktrees,
"count": worktrees.len(),
"current_worktree": current_worktree
}))
}
fn metadata(&self) -> crate::safety::ToolMetadata {
crate::safety::ToolMetadata::read_only()
}
}
fn parse_worktree_list(output: &str) -> Vec<WorktreeEntry> {
let mut worktrees = Vec::new();
let mut current = WorktreeEntry {
path: String::new(),
branch: None,
detached: false,
bare: false,
};
for line in output.lines() {
if line.is_empty() {
if !current.path.is_empty() {
worktrees.push(current);
current = WorktreeEntry {
path: String::new(),
branch: None,
detached: false,
bare: false,
};
}
continue;
}
if let Some(path) = line.strip_prefix("worktree ") {
current.path = path.to_string();
} else if let Some(branch) = line.strip_prefix("branch ") {
current.branch = branch.split('/').next_back().map(|s| s.to_string());
} else if line == "detached" {
current.detached = true;
} else if line == "bare" {
current.bare = true;
}
}
if !current.path.is_empty() {
worktrees.push(current);
}
worktrees
}
pub fn get_current_worktree() -> Option<PathBuf> {
WORKTREE_STATE
.lock()
.ok()
.and_then(|state| state.current_worktree.clone())
}
pub fn is_in_worktree() -> bool {
WORKTREE_STATE
.lock()
.map(|state| state.is_in_worktree())
.unwrap_or(false)
}
#[cfg(test)]
#[path = "../../tests/unit/tools/git_worktree/git_worktree_test.rs"]
mod tests;