1use crate::error::AgentError;
8use crate::types::*;
9use std::path::Path;
10use tokio::fs;
11use tokio::process::Command;
12
13pub const ENTER_WORKTREE_TOOL_NAME: &str = "EnterWorktree";
14pub const EXIT_WORKTREE_TOOL_NAME: &str = "ExitWorktree";
15
16static WORKTREE_STATE: std::sync::OnceLock<std::sync::Mutex<Option<WorktreeInfo>>> =
18 std::sync::OnceLock::new();
19
20#[derive(Debug, Clone)]
21struct WorktreeInfo {
22 name: String,
23 original_cwd: String,
24 worktree_path: String,
25}
26
27fn get_worktree_state() -> &'static std::sync::Mutex<Option<WorktreeInfo>> {
28 WORKTREE_STATE.get_or_init(|| std::sync::Mutex::new(None))
29}
30
31async fn check_uncommitted_changes(worktree_path: &str) -> std::io::Result<bool> {
33 let status_output = Command::new("git")
34 .args(["-C", worktree_path, "status", "--porcelain"])
35 .output()
36 .await?;
37 if status_output.status.success() {
38 let output = String::from_utf8_lossy(&status_output.stdout);
39 if !output.trim().is_empty() {
40 return Ok(true);
41 }
42 }
43 Ok(false)
44}
45
46pub struct EnterWorktreeTool;
48
49impl EnterWorktreeTool {
50 pub fn new() -> Self {
51 Self
52 }
53
54 pub fn name(&self) -> &str {
55 ENTER_WORKTREE_TOOL_NAME
56 }
57
58 pub fn description(&self) -> &str {
59 "Create and enter a new git worktree for isolated development. \
60 Each worktree is a separate checkout of the repository where you can \
61 work on a branch independently without affecting the main working directory."
62 }
63
64 pub fn user_facing_name(&self, _input: Option<&serde_json::Value>) -> String {
65 "EnterWorktree".to_string()
66 }
67
68 pub fn get_tool_use_summary(&self, input: Option<&serde_json::Value>) -> Option<String> {
69 input.and_then(|inp| inp["name"].as_str().map(String::from))
70 }
71
72 pub fn render_tool_result_message(
73 &self,
74 content: &serde_json::Value,
75 ) -> Option<String> {
76 content["content"].as_str().map(|s| s.to_string())
77 }
78
79 pub fn input_schema(&self) -> ToolInputSchema {
80 ToolInputSchema {
81 schema_type: "object".to_string(),
82 properties: serde_json::json!({
83 "name": {
84 "type": "string",
85 "description": "Optional name for the worktree. If not provided, a random name is generated. \
86 The worktree will be created at .ai/worktrees/<name>."
87 }
88 }),
89 required: None,
90 }
91 }
92
93 pub async fn execute(
94 &self,
95 input: serde_json::Value,
96 context: &ToolContext,
97 ) -> Result<ToolResult, AgentError> {
98 let name = input["name"]
99 .as_str()
100 .map(|s| s.to_string())
101 .unwrap_or_else(|| {
102 format!(
103 "wt-{:x}",
104 std::time::SystemTime::now()
105 .duration_since(std::time::UNIX_EPOCH)
106 .map(|d| d.as_millis() as u32)
107 .unwrap_or(0)
108 )
109 });
110
111 let worktrees_dir = Path::new(&context.cwd).join(".ai").join("worktrees");
112 let worktree_path = worktrees_dir.join(&name);
113
114 let git_check = Command::new("git")
116 .args(["-C", &context.cwd, "rev-parse", "--git-dir"])
117 .output()
118 .await
119 .map_err(|e| AgentError::Tool(format!("Failed to run git: {}", e)))?;
120 if !git_check.status.success() {
121 return Ok(ToolResult {
122 result_type: "text".to_string(),
123 tool_use_id: "enter_worktree".to_string(),
124 content: "Error: Not a git repository.".to_string(),
125 is_error: Some(true),
126 was_persisted: None,
127 });
128 }
129
130 let branch_name = format!("wt/{}", name);
132
133 fs::create_dir_all(&worktrees_dir)
135 .await
136 .map_err(|e| AgentError::Tool(format!("Failed to create worktrees directory: {}", e)))?;
137
138 let add_result = Command::new("git")
140 .args(["worktree", "add", "--detach"])
141 .arg(&worktree_path)
142 .arg("HEAD")
143 .current_dir(&context.cwd)
144 .output()
145 .await
146 .map_err(|e| AgentError::Tool(format!("Failed to run git worktree add: {}", e)))?;
147
148 if !add_result.status.success() {
149 let stderr = String::from_utf8_lossy(&add_result.stderr);
150 return Ok(ToolResult {
151 result_type: "text".to_string(),
152 tool_use_id: "enter_worktree".to_string(),
153 content: format!("Failed to create worktree: {}", stderr),
154 is_error: Some(true),
155 was_persisted: None,
156 });
157 }
158
159 Command::new("git")
161 .args(["branch", "-m", &branch_name])
162 .current_dir(&worktree_path)
163 .output()
164 .await
165 .ok(); log::info!("Worktree created: name={} path={}", name, worktree_path.display());
169
170 let state = get_worktree_state();
171 let mut guard = state.lock().unwrap();
172 *guard = Some(WorktreeInfo {
173 name: name.clone(),
174 original_cwd: context.cwd.clone(),
175 worktree_path: worktree_path.to_string_lossy().to_string(),
176 });
177 drop(guard);
178
179 let response = format!(
180 "Created and entered worktree '{}' at {}\n\
181 \n\
182 The worktree has been created on a new branch. \
183 You can now work on isolated changes without affecting the main working directory.\n\
184 \n\
185 To exit the worktree, use the ExitWorktree tool.\n\
186 System prompt cache has been cleared for the new context.",
187 name,
188 worktree_path.display()
189 );
190
191 Ok(ToolResult {
192 result_type: "text".to_string(),
193 tool_use_id: "enter_worktree".to_string(),
194 content: response,
195 is_error: Some(false),
196 was_persisted: None,
197 })
198 }
199}
200
201impl Default for EnterWorktreeTool {
202 fn default() -> Self {
203 Self::new()
204 }
205}
206
207pub struct ExitWorktreeTool;
209
210impl ExitWorktreeTool {
211 pub fn new() -> Self {
212 Self
213 }
214
215 pub fn name(&self) -> &str {
216 EXIT_WORKTREE_TOOL_NAME
217 }
218
219 pub fn description(&self) -> &str {
220 "Exit the current worktree and return to the original working directory. \
221 Choose to 'keep' the worktree on disk or 'remove' it. \
222 Uncommitted changes will be checked unless discardChanges is true."
223 }
224
225 pub fn user_facing_name(&self, _input: Option<&serde_json::Value>) -> String {
226 "ExitWorktree".to_string()
227 }
228
229 pub fn get_tool_use_summary(&self, input: Option<&serde_json::Value>) -> Option<String> {
230 input.and_then(|inp| inp["action"].as_str().map(String::from))
231 }
232
233 pub fn render_tool_result_message(
234 &self,
235 content: &serde_json::Value,
236 ) -> Option<String> {
237 content["content"].as_str().map(|s| s.to_string())
238 }
239
240 pub fn input_schema(&self) -> ToolInputSchema {
241 ToolInputSchema {
242 schema_type: "object".to_string(),
243 properties: serde_json::json!({
244 "action": {
245 "type": "string",
246 "enum": ["keep", "remove"],
247 "description": "What to do with the worktree: 'keep' leaves it on disk, 'remove' deletes the worktree and its branch"
248 },
249 "discardChanges": {
250 "type": "boolean",
251 "description": "If true, discard uncommitted changes before removing the worktree (uses git worktree remove --force)"
252 }
253 }),
254 required: None,
255 }
256 }
257
258 pub async fn execute(
259 &self,
260 input: serde_json::Value,
261 context: &ToolContext,
262 ) -> Result<ToolResult, AgentError> {
263 let action = input["action"].as_str().unwrap_or("keep");
264 let discard_changes = input["discardChanges"].as_bool().unwrap_or(false);
265
266 let worktree_info = {
267 let guard = get_worktree_state().lock().unwrap();
268 guard.clone()
269 };
270
271 if worktree_info.is_none() {
272 return Ok(ToolResult {
273 result_type: "text".to_string(),
274 tool_use_id: "".to_string(),
275 content: "Error: Not currently in a worktree.".to_string(),
276 is_error: Some(true),
277 was_persisted: None,
278 });
279 }
280
281 let info = worktree_info.unwrap();
282
283 let has_uncommitted = check_uncommitted_changes(&info.worktree_path)
285 .await
286 .unwrap_or(false);
287
288 let response = match action {
289 "keep" => {
290 format!(
291 "Exited worktree '{}'.\n\
292 \n\
293 The worktree has been kept on disk at: {}\n\
294 You can re-enter it later with EnterWorktree using the name '{}'.\n\
295 Returned to original directory: {}",
296 info.name, info.worktree_path, info.name, context.cwd
297 )
298 }
299 "remove" => {
300 if has_uncommitted && !discard_changes {
301 return Ok(ToolResult {
302 result_type: "text".to_string(),
303 tool_use_id: "exit_worktree".to_string(),
304 content: format!(
305 "Error: Worktree '{}' has uncommitted changes.\n\
306 Use discardChanges: true to remove the worktree and discard changes.",
307 info.name
308 ),
309 is_error: Some(true),
310 was_persisted: None,
311 });
312 }
313
314 let remove_result = Command::new("git")
316 .args(["worktree", "remove"])
317 .arg(&info.worktree_path)
318 .args(if discard_changes { ["--force"] } else { [""] })
319 .current_dir(&info.original_cwd)
320 .output()
321 .await;
322
323 match remove_result {
324 Ok(output) if output.status.success() => {
325 log::info!("Removed worktree '{}'", info.name);
326 }
327 Ok(output) => {
328 let stderr = String::from_utf8_lossy(&output.stderr);
329 log::warn!("git worktree remove failed: {}", stderr);
330 }
331 Err(e) => {
332 log::warn!("Failed to run git worktree remove: {}", e);
333 }
334 }
335
336 format!(
337 "Removed worktree '{}'.\n\
338 \n\
339 The worktree and its branch have been removed.\n\
340 Returned to original directory: {}",
341 info.name, info.original_cwd
342 )
343 }
344 _ => {
345 return Ok(ToolResult {
346 result_type: "text".to_string(),
347 tool_use_id: "".to_string(),
348 content: "Error: action must be 'keep' or 'remove'".to_string(),
349 is_error: Some(true),
350 was_persisted: None,
351 });
352 }
353 };
354
355 let state = get_worktree_state();
357 let mut guard = state.lock().unwrap();
358 *guard = None;
359 drop(guard);
360
361 Ok(ToolResult {
362 result_type: "text".to_string(),
363 tool_use_id: "exit_worktree".to_string(),
364 content: response,
365 is_error: Some(false),
366 was_persisted: None,
367 })
368 }
369}
370
371impl Default for ExitWorktreeTool {
372 fn default() -> Self {
373 Self::new()
374 }
375}
376
377pub async fn reset_worktree_for_testing() {
379 let state = get_worktree_state();
380 let mut guard = state.lock().unwrap();
381 *guard = None;
382}
383
384pub fn reset_worktree_for_testing_sync() {
386 let state = get_worktree_state();
387 let mut guard = state.lock().unwrap();
388 *guard = None;
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 #[test]
396 fn test_enter_worktree_tool_name() {
397 let tool = EnterWorktreeTool::new();
398 assert_eq!(tool.name(), ENTER_WORKTREE_TOOL_NAME);
399 }
400
401 #[test]
402 fn test_exit_worktree_tool_name() {
403 let tool = ExitWorktreeTool::new();
404 assert_eq!(tool.name(), EXIT_WORKTREE_TOOL_NAME);
405 }
406
407 #[test]
408 fn test_enter_worktree_schema() {
409 let tool = EnterWorktreeTool::new();
410 let schema = tool.input_schema();
411 assert!(schema.properties.get("name").is_some());
412 }
413
414 #[test]
415 fn test_exit_worktree_schema() {
416 let tool = ExitWorktreeTool::new();
417 let schema = tool.input_schema();
418 assert!(schema.properties.get("action").is_some());
419 assert!(schema.properties.get("discardChanges").is_some());
420 }
421
422 #[tokio::test]
423 async fn test_enter_worktree_outside_git_repo() {
424 let tool = EnterWorktreeTool::new();
426 let input = serde_json::json!({ "name": "test-wt" });
427 let context = ToolContext {
428 cwd: "/tmp".to_string(),
429 ..Default::default()
430 };
431 let result = tool.execute(input, &context).await;
432 assert!(result.is_ok());
433 let r = result.unwrap();
434 assert!(r.content.contains("Not a git repository"));
435 }
436
437 #[tokio::test]
438 async fn test_exit_worktree_clears_state() {
439 let state = get_worktree_state();
441 let mut guard = state.lock().unwrap();
442 *guard = Some(WorktreeInfo {
443 name: "exit-test".to_string(),
444 original_cwd: "/tmp".to_string(),
445 worktree_path: "/tmp/.ai/worktrees/exit-test".to_string(),
446 });
447 drop(guard);
448
449 let exit = ExitWorktreeTool::new();
451 let result = exit
452 .execute(
453 serde_json::json!({ "action": "keep" }),
454 &ToolContext::default(),
455 )
456 .await;
457 assert!(result.is_ok());
458 assert!(result.unwrap().content.contains("exit-test"));
459
460 let state = get_worktree_state();
461 let guard = state.lock().unwrap();
462 assert!(guard.is_none());
463 }
464
465 #[tokio::test]
466 async fn test_exit_worktree_not_in_worktree() {
467 let state = get_worktree_state();
469 let mut guard = state.lock().unwrap();
470 *guard = None;
471 drop(guard);
472
473 let tool = ExitWorktreeTool::new();
474 let result = tool
475 .execute(serde_json::json!({}), &ToolContext::default())
476 .await;
477 assert!(result.is_ok());
478 assert!(
479 result
480 .unwrap()
481 .content
482 .contains("Not currently in a worktree")
483 );
484 }
485}