use crate::command::chat::tools::{
PlanDecision, Tool, ToolResult, parse_tool_args, schema_to_tool_params,
};
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::Value;
use std::path::PathBuf;
use std::process::Command;
use std::sync::{Arc, Mutex, atomic::AtomicBool};
#[derive(Clone, Debug)]
pub struct WorktreeSession {
pub original_cwd: PathBuf,
pub worktree_path: PathBuf,
pub branch: String,
pub original_head_commit: Option<String>,
}
#[derive(Debug)]
pub struct WorktreeState {
session: Mutex<Option<WorktreeSession>>,
}
impl Default for WorktreeState {
fn default() -> Self {
Self::new()
}
}
impl WorktreeState {
pub fn new() -> Self {
Self {
session: Mutex::new(None),
}
}
pub fn get_session(&self) -> Option<WorktreeSession> {
self.session.lock().ok()?.clone()
}
pub fn set_session(&self, session: WorktreeSession) {
if let Ok(mut s) = self.session.lock() {
*s = Some(session);
}
}
pub fn clear_session(&self) -> Option<WorktreeSession> {
self.session.lock().ok()?.take()
}
}
fn git_root() -> Result<PathBuf, String> {
let output = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output()
.map_err(|e| format!("执行 git 失败: {}", e))?;
if !output.status.success() {
return Err("当前目录不在 git 仓库中".to_string());
}
let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
Ok(PathBuf::from(root))
}
fn head_commit() -> Option<String> {
Command::new("git")
.args(["rev-parse", "HEAD"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
}
fn validate_slug(name: &str) -> Result<(), String> {
if name.is_empty() {
return Err("名称不能为空".to_string());
}
if name.len() > 64 {
return Err("名称不能超过 64 个字符".to_string());
}
if name.contains("..") {
return Err("名称不能包含 '..'".to_string());
}
for ch in name.chars() {
if !ch.is_alphanumeric() && ch != '.' && ch != '_' && ch != '-' {
return Err(format!("名称包含非法字符: '{}'", ch));
}
}
Ok(())
}
fn random_slug() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
format!("wt-{:x}", ts & 0xFFFFFF)
}
fn count_changes(worktree_path: &str, original_head: Option<&str>) -> (usize, usize) {
let changed_files = Command::new("git")
.args(["-C", worktree_path, "status", "--porcelain"])
.output()
.ok()
.map(|o| {
String::from_utf8_lossy(&o.stdout)
.lines()
.filter(|l| !l.trim().is_empty())
.count()
})
.unwrap_or(0);
let commits = original_head
.and_then(|base| {
Command::new("git")
.args([
"-C",
worktree_path,
"rev-list",
"--count",
&format!("{}..HEAD", base),
])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| {
String::from_utf8_lossy(&o.stdout)
.trim()
.parse::<usize>()
.unwrap_or(0)
})
})
.unwrap_or(0);
(changed_files, commits)
}
pub fn create_agent_worktree(agent_name: &str) -> Result<(PathBuf, String), String> {
let repo_root = git_root()?;
let slug: String = agent_name
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' {
c.to_ascii_lowercase()
} else {
'-'
}
})
.collect();
let slug = format!("agent-{}", slug);
let branch = format!("worktree-{}", slug);
let wt_path = repo_root.join(".jcli").join("worktrees").join(&slug);
if wt_path.exists() {
return Ok((wt_path, branch));
}
let worktrees_dir = repo_root.join(".jcli").join("worktrees");
std::fs::create_dir_all(&worktrees_dir)
.map_err(|e| format!("创建 worktrees 目录失败: {}", e))?;
let output = Command::new("git")
.current_dir(&repo_root)
.args([
"worktree",
"add",
"-B",
&branch,
&wt_path.to_string_lossy(),
"HEAD",
])
.output()
.map_err(|e| format!("执行 git worktree add 失败: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("创建 worktree 失败: {}", stderr.trim()));
}
Ok((wt_path, branch))
}
pub fn remove_agent_worktree(worktree_path: &std::path::Path, branch: &str) {
let wt_str = worktree_path.to_string_lossy().to_string();
let _ = Command::new("git")
.args(["worktree", "remove", "--force", &wt_str])
.output();
std::thread::sleep(std::time::Duration::from_millis(200));
let _ = Command::new("git").args(["branch", "-D", branch]).output();
}
#[derive(Deserialize, JsonSchema)]
struct EnterWorktreeParams {
#[serde(default)]
name: Option<String>,
}
#[derive(Debug)]
pub struct EnterWorktreeTool {
pub state: Arc<WorktreeState>,
}
impl EnterWorktreeTool {
pub const NAME: &'static str = "EnterWorktree";
}
impl Tool for EnterWorktreeTool {
fn name(&self) -> &str {
Self::NAME
}
fn description(&self) -> &str {
r#"
Creates an isolated git worktree and switches the session into it.
Use this when you need to work on code in isolation — for example, when multiple
sessions may be editing the same repository simultaneously.
The worktree is created at .jcli/worktrees/{name} under the git root,
with a branch named worktree-{name}.
Use ExitWorktree to leave the worktree (keep or remove it).
"#
}
fn parameters_schema(&self) -> Value {
schema_to_tool_params::<EnterWorktreeParams>()
}
fn execute(&self, arguments: &str, _cancelled: &Arc<AtomicBool>) -> ToolResult {
let params: EnterWorktreeParams = match parse_tool_args(arguments) {
Ok(p) => p,
Err(e) => return e,
};
if self.state.get_session().is_some() {
return ToolResult {
output: "已在 worktree 会话中,请先使用 ExitWorktree 退出".to_string(),
is_error: true,
images: vec![],
plan_decision: PlanDecision::None,
};
}
let repo_root = match git_root() {
Ok(r) => r,
Err(e) => {
return ToolResult {
output: e,
is_error: true,
images: vec![],
plan_decision: PlanDecision::None,
};
}
};
let slug = params.name.unwrap_or_else(random_slug);
if let Err(e) = validate_slug(&slug) {
return ToolResult {
output: format!("无效的 worktree 名称: {}", e),
is_error: true,
images: vec![],
plan_decision: PlanDecision::None,
};
}
let branch = format!("worktree-{}", slug);
let wt_path = repo_root.join(".jcli").join("worktrees").join(&slug);
if wt_path.exists() {
return ToolResult {
output: format!(
"Worktree 目录已存在: {}。请使用其他名称或先手动清理。",
wt_path.display()
),
is_error: true,
images: vec![],
plan_decision: PlanDecision::None,
};
}
let worktrees_dir = repo_root.join(".jcli").join("worktrees");
if let Err(e) = std::fs::create_dir_all(&worktrees_dir) {
return ToolResult {
output: format!("创建 worktrees 目录失败: {}", e),
is_error: true,
images: vec![],
plan_decision: PlanDecision::None,
};
}
let original_cwd = std::env::current_dir().unwrap_or_default();
let orig_head = head_commit();
let output = Command::new("git")
.current_dir(&repo_root)
.args([
"worktree",
"add",
"-B",
&branch,
&wt_path.to_string_lossy(),
"HEAD",
])
.output();
match output {
Ok(o) if o.status.success() => {}
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr);
return ToolResult {
output: format!("创建 worktree 失败: {}", stderr.trim()),
is_error: true,
images: vec![],
plan_decision: PlanDecision::None,
};
}
Err(e) => {
return ToolResult {
output: format!("执行 git worktree add 失败: {}", e),
is_error: true,
images: vec![],
plan_decision: PlanDecision::None,
};
}
}
if let Err(e) = std::env::set_current_dir(&wt_path) {
return ToolResult {
output: format!("切换到 worktree 目录失败: {}", e),
is_error: true,
images: vec![],
plan_decision: PlanDecision::None,
};
}
self.state.set_session(WorktreeSession {
original_cwd,
worktree_path: wt_path.clone(),
branch: branch.clone(),
original_head_commit: orig_head,
});
ToolResult {
output: format!(
"已创建并进入 worktree:\n 路径: {}\n 分支: {}\n\n当前会话在隔离的工作目录中,所有文件操作不会影响主仓库。\n完成后使用 ExitWorktree 退出(可选择保留或删除)。",
wt_path.display(),
branch,
),
is_error: false,
images: vec![],
plan_decision: PlanDecision::None,
}
}
fn requires_confirmation(&self) -> bool {
true
}
fn confirmation_message(&self, arguments: &str) -> String {
let name = serde_json::from_str::<EnterWorktreeParams>(arguments)
.ok()
.and_then(|p| p.name)
.unwrap_or_else(|| "(auto)".to_string());
format!("创建并进入 git worktree: {}", name)
}
}
#[derive(Deserialize, JsonSchema)]
struct ExitWorktreeParams {
action: String,
#[serde(default)]
discard_changes: bool,
}
#[derive(Debug)]
pub struct ExitWorktreeTool {
pub state: Arc<WorktreeState>,
}
impl ExitWorktreeTool {
pub const NAME: &'static str = "ExitWorktree";
}
impl Tool for ExitWorktreeTool {
fn name(&self) -> &str {
Self::NAME
}
fn description(&self) -> &str {
r#"
Exit the current worktree session created by EnterWorktree.
- action "keep": preserves the worktree directory and branch for later use
- action "remove": deletes the worktree and its branch (requires discard_changes: true if there are uncommitted changes or new commits)
"#
}
fn parameters_schema(&self) -> Value {
schema_to_tool_params::<ExitWorktreeParams>()
}
fn execute(&self, arguments: &str, _cancelled: &Arc<AtomicBool>) -> ToolResult {
let params: ExitWorktreeParams = match parse_tool_args(arguments) {
Ok(p) => p,
Err(e) => return e,
};
let session = match self.state.get_session() {
Some(s) => s,
None => {
return ToolResult {
output: "当前不在 worktree 会话中(仅对 EnterWorktree 创建的 worktree 有效)"
.to_string(),
is_error: true,
images: vec![],
plan_decision: PlanDecision::None,
};
}
};
let wt_path_str = session.worktree_path.to_string_lossy().to_string();
match params.action.as_str() {
"keep" => {
if let Err(e) = std::env::set_current_dir(&session.original_cwd) {
return ToolResult {
output: format!("切换回原目录失败: {}", e),
is_error: true,
images: vec![],
plan_decision: PlanDecision::None,
};
}
self.state.clear_session();
ToolResult {
output: format!(
"已退出 worktree,工作已保留:\n 路径: {}\n 分支: {}\n\n已切回原目录: {}",
wt_path_str,
session.branch,
session.original_cwd.display(),
),
is_error: false,
images: vec![],
plan_decision: PlanDecision::None,
}
}
"remove" => {
let (changed_files, commits) =
count_changes(&wt_path_str, session.original_head_commit.as_deref());
if (changed_files > 0 || commits > 0) && !params.discard_changes {
let mut parts = Vec::new();
if changed_files > 0 {
parts.push(format!("{} 个未提交的文件", changed_files));
}
if commits > 0 {
parts.push(format!("{} 个新 commit", commits));
}
return ToolResult {
output: format!(
"Worktree 中有 {}。删除将永久丢弃这些工作。\n请向用户确认后,使用 discard_changes: true 重新调用;或使用 action: \"keep\" 保留 worktree。",
parts.join(" 和 "),
),
is_error: true,
images: vec![],
plan_decision: PlanDecision::None,
};
}
if let Err(e) = std::env::set_current_dir(&session.original_cwd) {
return ToolResult {
output: format!("切换回原目录失败: {}", e),
is_error: true,
images: vec![],
plan_decision: PlanDecision::None,
};
}
let remove_result = Command::new("git")
.args(["worktree", "remove", "--force", &wt_path_str])
.output();
let mut messages = Vec::new();
match remove_result {
Ok(o) if o.status.success() => {
messages.push(format!("已删除 worktree: {}", wt_path_str));
}
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr);
messages.push(format!("删除 worktree 警告: {}", stderr.trim()));
let _ = std::fs::remove_dir_all(&session.worktree_path);
}
Err(e) => {
messages.push(format!("执行 git worktree remove 失败: {}", e));
}
}
std::thread::sleep(std::time::Duration::from_millis(100));
let branch_result = Command::new("git")
.args(["branch", "-D", &session.branch])
.output();
match branch_result {
Ok(o) if o.status.success() => {
messages.push(format!("已删除分支: {}", session.branch));
}
Ok(o) => {
let stderr = String::from_utf8_lossy(&o.stderr);
messages.push(format!("删除分支警告: {}", stderr.trim()));
}
Err(_) => {}
}
self.state.clear_session();
let mut output = messages.join("\n");
if changed_files > 0 || commits > 0 {
output.push_str(&format!(
"\n已丢弃 {} 个未提交文件和 {} 个 commit。",
changed_files, commits
));
}
output.push_str(&format!(
"\n已切回原目录: {}",
session.original_cwd.display()
));
ToolResult {
output,
is_error: false,
images: vec![],
plan_decision: PlanDecision::None,
}
}
other => ToolResult {
output: format!(
"无效的 action: \"{}\",只支持 \"keep\" 或 \"remove\"",
other
),
is_error: true,
images: vec![],
plan_decision: PlanDecision::None,
},
}
}
fn requires_confirmation(&self) -> bool {
true
}
fn confirmation_message(&self, arguments: &str) -> String {
let action = serde_json::from_str::<ExitWorktreeParams>(arguments)
.ok()
.map(|p| p.action)
.unwrap_or_else(|| "?".to_string());
match action.as_str() {
"keep" => "退出 worktree(保留工作目录和分支)".to_string(),
"remove" => "退出并删除 worktree(包括工作目录和分支)".to_string(),
_ => format!("退出 worktree (action: {})", action),
}
}
}