1use crate::constants::WORKTREE_NAME_MAX_LEN;
2use crate::tools::{PlanDecision, Tool, ToolResult, parse_tool_args, schema_to_tool_params};
3use schemars::JsonSchema;
4use serde::Deserialize;
5use serde_json::Value;
6use std::borrow::Cow;
7use std::path::PathBuf;
8use std::process::Command;
9use std::sync::{Arc, Mutex, atomic::AtomicBool};
10
11const GIT_LOCK_RELEASE_WAIT_MS: u64 = 200;
13const WORKTREE_CLEANUP_WAIT_MS: u64 = 100;
15
16#[derive(Clone, Debug)]
20pub struct WorktreeSession {
21 pub original_cwd: PathBuf,
23 pub worktree_path: PathBuf,
25 pub branch: String,
27 pub original_head_commit: Option<String>,
29}
30
31#[derive(Debug)]
33pub struct WorktreeState {
34 session: Mutex<Option<WorktreeSession>>,
35}
36
37impl Default for WorktreeState {
38 fn default() -> Self {
39 Self::new()
40 }
41}
42
43impl WorktreeState {
44 pub fn new() -> Self {
45 Self {
46 session: Mutex::new(None),
47 }
48 }
49
50 pub fn get_session(&self) -> Option<WorktreeSession> {
51 self.session.lock().ok()?.clone()
52 }
53
54 pub fn set_session(&self, session: WorktreeSession) {
55 if let Ok(mut s) = self.session.lock() {
56 *s = Some(session);
57 }
58 }
59
60 pub fn clear_session(&self) -> Option<WorktreeSession> {
62 self.session.lock().ok()?.take()
63 }
64}
65
66fn git_root() -> Result<PathBuf, String> {
70 let output = Command::new("git")
71 .args(["rev-parse", "--show-toplevel"])
72 .output()
73 .map_err(|e| format!("执行 git 失败: {}", e))?;
74 if !output.status.success() {
75 return Err("当前目录不在 git 仓库中".to_string());
76 }
77 let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
78 Ok(PathBuf::from(root))
79}
80
81fn head_commit() -> Option<String> {
83 Command::new("git")
84 .args(["rev-parse", "HEAD"])
85 .output()
86 .ok()
87 .filter(|o| o.status.success())
88 .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
89}
90
91fn validate_slug(name: &str) -> Result<(), String> {
93 if name.is_empty() {
94 return Err("名称不能为空".to_string());
95 }
96 if name.len() > WORKTREE_NAME_MAX_LEN {
97 return Err(format!("名称不能超过 {WORKTREE_NAME_MAX_LEN} 个字符"));
98 }
99 if name.contains("..") {
100 return Err("名称不能包含 '..'".to_string());
101 }
102 for ch in name.chars() {
103 if !ch.is_alphanumeric() && ch != '.' && ch != '_' && ch != '-' {
104 return Err(format!("名称包含非法字符: '{}'", ch));
105 }
106 }
107 Ok(())
108}
109
110fn random_slug() -> String {
112 use std::time::{SystemTime, UNIX_EPOCH};
113 let ts = SystemTime::now()
114 .duration_since(UNIX_EPOCH)
115 .unwrap_or_default()
116 .as_millis();
117 format!("wt-{:x}", ts & 0xFFFFFF)
118}
119
120fn count_changes(worktree_path: &str, original_head: Option<&str>) -> (usize, usize) {
122 let changed_files = Command::new("git")
124 .args(["-C", worktree_path, "status", "--porcelain"])
125 .output()
126 .ok()
127 .map(|o| {
128 String::from_utf8_lossy(&o.stdout)
129 .lines()
130 .filter(|l| !l.trim().is_empty())
131 .count()
132 })
133 .unwrap_or(0);
134
135 let commits = original_head
137 .and_then(|base| {
138 Command::new("git")
139 .args([
140 "-C",
141 worktree_path,
142 "rev-list",
143 "--count",
144 &format!("{}..HEAD", base),
145 ])
146 .output()
147 .ok()
148 .filter(|o| o.status.success())
149 .map(|o| {
150 String::from_utf8_lossy(&o.stdout)
151 .trim()
152 .parse::<usize>()
153 .unwrap_or(0)
154 })
155 })
156 .unwrap_or(0);
157
158 (changed_files, commits)
159}
160
161pub fn create_agent_worktree(agent_name: &str) -> Result<(PathBuf, String), String> {
168 let repo_root = git_root()?;
169
170 let slug: String = agent_name
172 .chars()
173 .map(|c| {
174 if c.is_alphanumeric() || c == '-' || c == '_' {
175 c.to_ascii_lowercase()
176 } else {
177 '-'
178 }
179 })
180 .collect();
181 let slug = format!("agent-{}", slug);
182 let branch = format!("worktree-{}", slug);
183 let wt_path = repo_root.join(".jcli").join("worktrees").join(&slug);
184
185 if wt_path.exists() {
187 return Ok((wt_path, branch));
188 }
189
190 let worktrees_dir = repo_root.join(".jcli").join("worktrees");
191 std::fs::create_dir_all(&worktrees_dir)
192 .map_err(|e| format!("创建 worktrees 目录失败: {}", e))?;
193
194 let output = Command::new("git")
195 .current_dir(&repo_root)
196 .args([
197 "worktree",
198 "add",
199 "-B",
200 &branch,
201 &wt_path.to_string_lossy(),
202 "HEAD",
203 ])
204 .output()
205 .map_err(|e| format!("执行 git worktree add 失败: {}", e))?;
206
207 if !output.status.success() {
208 let stderr = String::from_utf8_lossy(&output.stderr);
209 return Err(format!("创建 worktree 失败: {}", stderr.trim()));
210 }
211
212 Ok((wt_path, branch))
213}
214
215pub fn remove_agent_worktree(worktree_path: &std::path::Path, branch: &str) {
217 let wt_str = worktree_path.to_string_lossy().to_string();
218 let _ = Command::new("git")
219 .args(["worktree", "remove", "--force", &wt_str])
220 .output();
221 std::thread::sleep(std::time::Duration::from_millis(GIT_LOCK_RELEASE_WAIT_MS));
223 let _ = Command::new("git").args(["branch", "-D", branch]).output();
224}
225
226#[derive(Deserialize, JsonSchema)]
229struct EnterWorktreeParams {
230 #[serde(default)]
232 name: Option<String>,
233}
234
235#[derive(Debug)]
237pub struct EnterWorktreeTool {
238 pub state: Arc<WorktreeState>,
240}
241
242impl EnterWorktreeTool {
243 pub const NAME: &'static str = "EnterWorktree";
244}
245
246impl Tool for EnterWorktreeTool {
247 fn name(&self) -> &str {
248 Self::NAME
249 }
250
251 fn description(&self) -> Cow<'_, str> {
252 r#"
253 Creates an isolated git worktree and switches the session into it.
254 Use this when you need to work on code in isolation — for example, when multiple
255 sessions may be editing the same repository simultaneously.
256
257 The worktree is created at .jcli/worktrees/{name} under the git root,
258 with a branch named worktree-{name}.
259
260 Use ExitWorktree to leave the worktree (keep or remove it).
261 "#
262 .into()
263 }
264
265 fn parameters_schema(&self) -> Value {
266 schema_to_tool_params::<EnterWorktreeParams>()
267 }
268
269 fn execute(&self, arguments: &str, _cancelled: &Arc<AtomicBool>) -> ToolResult {
270 let params: EnterWorktreeParams = match parse_tool_args(arguments) {
271 Ok(p) => p,
272 Err(e) => return e,
273 };
274
275 if self.state.get_session().is_some() {
277 return ToolResult {
278 output: "已在 worktree 会话中,请先使用 ExitWorktree 退出".to_string(),
279 is_error: true,
280 images: vec![],
281 plan_decision: PlanDecision::None,
282 };
283 }
284
285 let repo_root = match git_root() {
287 Ok(r) => r,
288 Err(e) => {
289 return ToolResult {
290 output: e,
291 is_error: true,
292 images: vec![],
293 plan_decision: PlanDecision::None,
294 };
295 }
296 };
297
298 let slug = params.name.unwrap_or_else(random_slug);
299 if let Err(e) = validate_slug(&slug) {
300 return ToolResult {
301 output: format!("无效的 worktree 名称: {}", e),
302 is_error: true,
303 images: vec![],
304 plan_decision: PlanDecision::None,
305 };
306 }
307
308 self.create_and_enter(&repo_root, &slug)
309 }
310
311 fn requires_confirmation(&self) -> bool {
312 true
313 }
314
315 fn confirmation_message(&self, arguments: &str) -> String {
316 let name = serde_json::from_str::<EnterWorktreeParams>(arguments)
317 .ok()
318 .and_then(|p| p.name)
319 .unwrap_or_else(|| "(auto)".to_string());
320 format!("创建并进入 git worktree: {}", name)
321 }
322}
323
324impl EnterWorktreeTool {
325 fn create_and_enter(&self, repo_root: &std::path::Path, slug: &str) -> ToolResult {
327 let branch = format!("worktree-{}", slug);
328 let wt_path = repo_root.join(".jcli").join("worktrees").join(slug);
329
330 if wt_path.exists() {
331 return ToolResult {
332 output: format!(
333 "Worktree 目录已存在: {}。请使用其他名称或先手动清理。",
334 wt_path.display()
335 ),
336 is_error: true,
337 images: vec![],
338 plan_decision: PlanDecision::None,
339 };
340 }
341
342 let worktrees_dir = repo_root.join(".jcli").join("worktrees");
343 if let Err(e) = std::fs::create_dir_all(&worktrees_dir) {
344 return ToolResult {
345 output: format!("创建 worktrees 目录失败: {}", e),
346 is_error: true,
347 images: vec![],
348 plan_decision: PlanDecision::None,
349 };
350 }
351
352 let original_cwd = std::env::current_dir().unwrap_or_default();
353 let orig_head = head_commit();
354
355 let output = Command::new("git")
356 .current_dir(repo_root)
357 .args([
358 "worktree",
359 "add",
360 "-B",
361 &branch,
362 &wt_path.to_string_lossy(),
363 "HEAD",
364 ])
365 .output();
366
367 match output {
368 Ok(o) if o.status.success() => {}
369 Ok(o) => {
370 let stderr = String::from_utf8_lossy(&o.stderr);
371 return ToolResult {
372 output: format!("创建 worktree 失败: {}", stderr.trim()),
373 is_error: true,
374 images: vec![],
375 plan_decision: PlanDecision::None,
376 };
377 }
378 Err(e) => {
379 return ToolResult {
380 output: format!("执行 git worktree add 失败: {}", e),
381 is_error: true,
382 images: vec![],
383 plan_decision: PlanDecision::None,
384 };
385 }
386 }
387
388 if let Err(e) = std::env::set_current_dir(&wt_path) {
389 return ToolResult {
390 output: format!("切换到 worktree 目录失败: {}", e),
391 is_error: true,
392 images: vec![],
393 plan_decision: PlanDecision::None,
394 };
395 }
396
397 self.state.set_session(WorktreeSession {
398 original_cwd,
399 worktree_path: wt_path.clone(),
400 branch: branch.clone(),
401 original_head_commit: orig_head,
402 });
403
404 ToolResult {
405 output: format!(
406 "已创建并进入 worktree:\n 路径: {}\n 分支: {}\n\n当前会话在隔离的工作目录中,所有文件操作不会影响主仓库。\n完成后使用 ExitWorktree 退出(可选择保留或删除)。",
407 wt_path.display(),
408 branch,
409 ),
410 is_error: false,
411 images: vec![],
412 plan_decision: PlanDecision::None,
413 }
414 }
415}
416
417#[derive(Deserialize, JsonSchema)]
420struct ExitWorktreeParams {
421 action: String,
423 #[serde(default)]
425 discard_changes: bool,
426}
427
428#[derive(Debug)]
430pub struct ExitWorktreeTool {
431 pub state: Arc<WorktreeState>,
433}
434
435impl ExitWorktreeTool {
436 pub const NAME: &'static str = "ExitWorktree";
437}
438
439impl Tool for ExitWorktreeTool {
440 fn name(&self) -> &str {
441 Self::NAME
442 }
443
444 fn description(&self) -> Cow<'_, str> {
445 r#"
446 Exit the current worktree session created by EnterWorktree.
447 - action "keep": preserves the worktree directory and branch for later use
448 - action "remove": deletes the worktree and its branch (requires discard_changes: true if there are uncommitted changes or new commits)
449 "#.into()
450 }
451
452 fn parameters_schema(&self) -> Value {
453 schema_to_tool_params::<ExitWorktreeParams>()
454 }
455
456 fn execute(&self, arguments: &str, _cancelled: &Arc<AtomicBool>) -> ToolResult {
457 let params: ExitWorktreeParams = match parse_tool_args(arguments) {
458 Ok(p) => p,
459 Err(e) => return e,
460 };
461
462 let session = match self.state.get_session() {
463 Some(s) => s,
464 None => {
465 return ToolResult {
466 output: "当前不在 worktree 会话中(仅对 EnterWorktree 创建的 worktree 有效)"
467 .to_string(),
468 is_error: true,
469 images: vec![],
470 plan_decision: PlanDecision::None,
471 };
472 }
473 };
474
475 let wt_path_str = session.worktree_path.to_string_lossy().to_string();
476
477 match params.action.as_str() {
478 "keep" => self.handle_keep(&session, &wt_path_str),
479 "remove" => self.handle_remove(&session, &wt_path_str, params.discard_changes),
480 other => ToolResult {
481 output: format!(
482 "无效的 action: \"{}\",只支持 \"keep\" 或 \"remove\"",
483 other
484 ),
485 is_error: true,
486 images: vec![],
487 plan_decision: PlanDecision::None,
488 },
489 }
490 }
491
492 fn requires_confirmation(&self) -> bool {
493 true
494 }
495
496 fn confirmation_message(&self, arguments: &str) -> String {
497 let action = serde_json::from_str::<ExitWorktreeParams>(arguments)
498 .ok()
499 .map(|p| p.action)
500 .unwrap_or_else(|| "?".to_string());
501 match action.as_str() {
502 "keep" => "退出 worktree(保留工作目录和分支)".to_string(),
503 "remove" => "退出并删除 worktree(包括工作目录和分支)".to_string(),
504 _ => format!("退出 worktree (action: {})", action),
505 }
506 }
507}
508
509impl ExitWorktreeTool {
510 fn handle_keep(&self, session: &WorktreeSession, wt_path_str: &str) -> ToolResult {
512 if let Err(e) = std::env::set_current_dir(&session.original_cwd) {
513 return ToolResult {
514 output: format!("切换回原目录失败: {}", e),
515 is_error: true,
516 images: vec![],
517 plan_decision: PlanDecision::None,
518 };
519 }
520 self.state.clear_session();
521
522 ToolResult {
523 output: format!(
524 "已退出 worktree,工作已保留:\n 路径: {}\n 分支: {}\n\n已切回原目录: {}",
525 wt_path_str,
526 session.branch,
527 session.original_cwd.display(),
528 ),
529 is_error: false,
530 images: vec![],
531 plan_decision: PlanDecision::None,
532 }
533 }
534
535 fn handle_remove(
537 &self,
538 session: &WorktreeSession,
539 wt_path_str: &str,
540 discard_changes: bool,
541 ) -> ToolResult {
542 let (changed_files, commits) =
543 count_changes(wt_path_str, session.original_head_commit.as_deref());
544
545 if (changed_files > 0 || commits > 0) && !discard_changes {
546 let mut parts = Vec::new();
547 if changed_files > 0 {
548 parts.push(format!("{} 个未提交的文件", changed_files));
549 }
550 if commits > 0 {
551 parts.push(format!("{} 个新 commit", commits));
552 }
553 return ToolResult {
554 output: format!(
555 "Worktree 中有 {}。删除将永久丢弃这些工作。\n请向用户确认后,使用 discard_changes: true 重新调用;或使用 action: \"keep\" 保留 worktree。",
556 parts.join(" 和 "),
557 ),
558 is_error: true,
559 images: vec![],
560 plan_decision: PlanDecision::None,
561 };
562 }
563
564 if let Err(e) = std::env::set_current_dir(&session.original_cwd) {
565 return ToolResult {
566 output: format!("切换回原目录失败: {}", e),
567 is_error: true,
568 images: vec![],
569 plan_decision: PlanDecision::None,
570 };
571 }
572
573 let remove_result = Command::new("git")
575 .args(["worktree", "remove", "--force", wt_path_str])
576 .output();
577
578 let mut messages = Vec::new();
579 match remove_result {
580 Ok(o) if o.status.success() => {
581 messages.push(format!("已删除 worktree: {}", wt_path_str));
582 }
583 Ok(o) => {
584 let stderr = String::from_utf8_lossy(&o.stderr);
585 messages.push(format!("删除 worktree 警告: {}", stderr.trim()));
586 let _ = std::fs::remove_dir_all(&session.worktree_path);
587 }
588 Err(e) => {
589 messages.push(format!("执行 git worktree remove 失败: {}", e));
590 }
591 }
592
593 std::thread::sleep(std::time::Duration::from_millis(WORKTREE_CLEANUP_WAIT_MS));
594
595 let branch_result = Command::new("git")
596 .args(["branch", "-D", &session.branch])
597 .output();
598
599 match branch_result {
600 Ok(o) if o.status.success() => {
601 messages.push(format!("已删除分支: {}", session.branch));
602 }
603 Ok(o) => {
604 let stderr = String::from_utf8_lossy(&o.stderr);
605 messages.push(format!("删除分支警告: {}", stderr.trim()));
606 }
607 Err(_) => {}
608 }
609
610 self.state.clear_session();
611
612 let mut output = messages.join("\n");
613 if changed_files > 0 || commits > 0 {
614 output.push_str(&format!(
615 "\n已丢弃 {} 个未提交文件和 {} 个 commit。",
616 changed_files, commits
617 ));
618 }
619 output.push_str(&format!(
620 "\n已切回原目录: {}",
621 session.original_cwd.display()
622 ));
623
624 ToolResult {
625 output,
626 is_error: false,
627 images: vec![],
628 plan_decision: PlanDecision::None,
629 }
630 }
631}