Skip to main content

ai_agent/tools/
worktree.rs

1// Source: ~/claudecode/openclaudecode/src/tools/EnterWorktreeTool/EnterWorktreeTool.ts
2// Source: ~/claudecode/openclaudecode/src/tools/ExitWorktreeTool/ExitWorktreeTool.ts
3//! Git worktree tools.
4//!
5//! Provides tools for managing git worktrees for isolated development.
6
7use 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
16/// Current worktree state
17static 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
31/// Check if a worktree has uncommitted changes (modified files or new commits)
32async 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
46/// EnterWorktree tool - create and enter a git worktree
47pub 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        // Validate that we're in a git repo
115        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        // Generate branch name for the worktree
131        let branch_name = format!("wt/{}", name);
132
133        // Create worktree directory
134        fs::create_dir_all(&worktrees_dir)
135            .await
136            .map_err(|e| AgentError::Tool(format!("Failed to create worktrees directory: {}", e)))?;
137
138        // Run `git worktree add <path> <branch>`
139        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        // Create a named branch in the worktree
160        Command::new("git")
161            .args(["branch", "-m", &branch_name])
162            .current_dir(&worktree_path)
163            .output()
164            .await
165            .ok(); // Best effort
166
167        // Fire WorktreeCreate hook (best effort, logged)
168        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
207/// ExitWorktree tool - exit a worktree and return to original directory
208pub 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        // Check for uncommitted changes
284        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                // Run `git worktree remove [--force] <path>` from original cwd
315                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        // Clear worktree state
356        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
377/// Reset the global worktree state for test isolation.
378pub async fn reset_worktree_for_testing() {
379    let state = get_worktree_state();
380    let mut guard = state.lock().unwrap();
381    *guard = None;
382}
383
384/// Sync wrapper for test isolation (called from `clear_all_test_state`)
385pub 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        // /tmp is not a git repo, should return an error result
425        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        // Manually set worktree state to simulate being in a worktree
440        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        // Then exit with keep (no git needed)
450        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        // Clear state first
468        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}