Skip to main content

pawan/tools/git/
branch.rs

1use super::run_git;
2use super::super::Tool;
3use async_trait::async_trait;
4use serde_json::{json, Value};
5use std::path::PathBuf;
6
7/// Tool for listing and managing branches
8pub struct GitBranchTool {
9    workspace_root: PathBuf,
10}
11
12impl GitBranchTool {
13    pub fn new(workspace_root: PathBuf) -> Self {
14        Self { workspace_root }
15    }
16}
17
18#[async_trait]
19impl Tool for GitBranchTool {
20    fn name(&self) -> &str {
21        "git_branch"
22    }
23
24    fn description(&self) -> &str {
25        "List branches or get current branch name. Shows local and optionally remote branches."
26    }
27
28    fn parameters_schema(&self) -> Value {
29        json!({
30            "type": "object",
31            "properties": {
32                "all": {
33                    "type": "boolean",
34                    "description": "Show both local and remote branches (default: false)"
35                }
36            },
37            "required": []
38        })
39    }
40
41    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
42        use thulp_core::{Parameter, ParameterType};
43        thulp_core::ToolDefinition::builder("git_branch")
44            .description(self.description())
45            .parameter(Parameter::builder("all").param_type(ParameterType::Boolean).required(false)
46                .description("Show both local and remote branches (default: false)").build())
47            .build()
48    }
49
50    async fn execute(&self, args: Value) -> crate::Result<Value> {
51        let all = args["all"].as_bool().unwrap_or(false);
52
53        // Get current branch
54        let (_, current, _) = run_git(&self.workspace_root, &["branch", "--show-current"]).await?;
55        let current_branch = current.trim().to_string();
56
57        // List branches
58        let mut git_args = vec!["branch", "--format=%(refname:short)"];
59        if all {
60            git_args.push("-a");
61        }
62
63        let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args).await?;
64
65        if !success {
66            return Err(crate::PawanError::Git(format!(
67                "git branch failed: {}",
68                stderr
69            )));
70        }
71
72        let branches: Vec<&str> = stdout
73            .lines()
74            .map(|l| l.trim())
75            .filter(|l| !l.is_empty())
76            .collect();
77
78        Ok(json!({
79            "current": current_branch,
80            "branches": branches,
81            "count": branches.len(),
82            "success": true
83        }))
84    }
85}
86
87/// Tool for git checkout (switch branches or restore files)
88pub struct GitCheckoutTool {
89    workspace_root: PathBuf,
90}
91
92impl GitCheckoutTool {
93    pub fn new(workspace_root: PathBuf) -> Self {
94        Self { workspace_root }
95    }
96}
97
98#[async_trait]
99impl Tool for GitCheckoutTool {
100    fn name(&self) -> &str {
101        "git_checkout"
102    }
103
104    fn description(&self) -> &str {
105        "Switch branches or restore working tree files. Can create new branches with create=true."
106    }
107
108    fn parameters_schema(&self) -> Value {
109        json!({
110            "type": "object",
111            "properties": {
112                "target": {
113                    "type": "string",
114                    "description": "Branch name, commit, or file path to checkout"
115                },
116                "create": {
117                    "type": "boolean",
118                    "description": "Create a new branch (git checkout -b)"
119                },
120                "files": {
121                    "type": "array",
122                    "items": { "type": "string" },
123                    "description": "Specific files to restore (git checkout -- <files>)"
124                }
125            },
126            "required": ["target"]
127        })
128    }
129
130    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
131        use thulp_core::{Parameter, ParameterType};
132        thulp_core::ToolDefinition::builder("git_checkout")
133            .description(self.description())
134            .parameter(Parameter::builder("target").param_type(ParameterType::String).required(true)
135                .description("Branch name, commit, or file path to checkout").build())
136            .parameter(Parameter::builder("create").param_type(ParameterType::Boolean).required(false)
137                .description("Create a new branch (git checkout -b)").build())
138            .parameter(Parameter::builder("files").param_type(ParameterType::Array).required(false)
139                .description("Specific files to restore (git checkout -- <files>)").build())
140            .build()
141    }
142
143    async fn execute(&self, args: Value) -> crate::Result<Value> {
144        let target = args["target"]
145            .as_str()
146            .ok_or_else(|| crate::PawanError::Tool("target is required".into()))?;
147        let create = args["create"].as_bool().unwrap_or(false);
148        let files: Vec<&str> = args["files"]
149            .as_array()
150            .map(|a| a.iter().filter_map(|v| v.as_str()).collect())
151            .unwrap_or_default();
152
153        let mut git_args: Vec<&str> = vec!["checkout"];
154
155        if create {
156            git_args.push("-b");
157        }
158
159        git_args.push(target);
160
161        if !files.is_empty() {
162            git_args.push("--");
163            git_args.extend(files.iter());
164        }
165
166        let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args).await?;
167
168        if !success {
169            return Err(crate::PawanError::Git(format!(
170                "git checkout failed: {}",
171                stderr
172            )));
173        }
174
175        Ok(json!({
176            "success": true,
177            "target": target,
178            "created": create,
179            "output": format!("{}{}", stdout, stderr).trim().to_string()
180        }))
181    }
182}
183
184/// Tool for git stash operations
185pub struct GitStashTool {
186    workspace_root: PathBuf,
187}
188
189impl GitStashTool {
190    pub fn new(workspace_root: PathBuf) -> Self {
191        Self { workspace_root }
192    }
193}
194
195#[async_trait]
196impl Tool for GitStashTool {
197    fn name(&self) -> &str {
198        "git_stash"
199    }
200
201    fn description(&self) -> &str {
202        "Stash or restore uncommitted changes. Actions: push (default), pop, list, drop."
203    }
204
205    fn parameters_schema(&self) -> Value {
206        json!({
207            "type": "object",
208            "properties": {
209                "action": {
210                    "type": "string",
211                    "enum": ["push", "pop", "list", "drop", "show"],
212                    "description": "Stash action (default: push)"
213                },
214                "message": {
215                    "type": "string",
216                    "description": "Message for stash push"
217                },
218                "index": {
219                    "type": "integer",
220                    "description": "Stash index for pop/drop/show (default: 0)"
221                }
222            },
223            "required": []
224        })
225    }
226
227    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
228        use thulp_core::{Parameter, ParameterType};
229        thulp_core::ToolDefinition::builder("git_stash")
230            .description(self.description())
231            .parameter(Parameter::builder("action").param_type(ParameterType::String).required(false)
232                .description("Stash action (default: push)").build())
233            .parameter(Parameter::builder("message").param_type(ParameterType::String).required(false)
234                .description("Message for stash push").build())
235            .parameter(Parameter::builder("index").param_type(ParameterType::Integer).required(false)
236                .description("Stash index for pop/drop/show (default: 0)").build())
237            .build()
238    }
239
240    async fn execute(&self, args: Value) -> crate::Result<Value> {
241        let action = args["action"].as_str().unwrap_or("push");
242        let message = args["message"].as_str();
243        let index = args["index"].as_u64().unwrap_or(0);
244
245        let git_args: Vec<String> = match action {
246            "push" => {
247                let mut a = vec!["stash".to_string(), "push".to_string()];
248                if let Some(msg) = message {
249                    a.push("-m".to_string());
250                    a.push(msg.to_string());
251                }
252                a
253            }
254            "pop" => vec!["stash".to_string(), "pop".to_string(), format!("stash@{{{}}}", index)],
255            "list" => vec!["stash".to_string(), "list".to_string()],
256            "drop" => vec!["stash".to_string(), "drop".to_string(), format!("stash@{{{}}}", index)],
257            "show" => vec!["stash".to_string(), "show".to_string(), "-p".to_string(), format!("stash@{{{}}}", index)],
258            _ => return Err(crate::PawanError::Tool(format!("Unknown stash action: {}", action))),
259        };
260
261        let git_args_ref: Vec<&str> = git_args.iter().map(|s| s.as_str()).collect();
262        let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args_ref).await?;
263
264        if !success {
265            return Err(crate::PawanError::Git(format!(
266                "git stash {} failed: {}",
267                action, stderr
268            )));
269        }
270
271        Ok(json!({
272            "success": true,
273            "action": action,
274            "output": stdout.trim().to_string()
275        }))
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use crate::tools::git::staging::{GitAddTool, GitCommitTool};
283    use serde_json::json;
284    use tempfile::TempDir;
285    use tokio::process::Command;
286
287    async fn setup_git_repo() -> TempDir {
288        let temp_dir = TempDir::new().unwrap();
289
290        Command::new("git")
291            .args(["init"])
292            .current_dir(temp_dir.path())
293            .output()
294            .await
295            .unwrap();
296
297        Command::new("git")
298            .args(["config", "user.email", "test@test.com"])
299            .current_dir(temp_dir.path())
300            .output()
301            .await
302            .unwrap();
303
304        Command::new("git")
305            .args(["config", "user.name", "Test User"])
306            .current_dir(temp_dir.path())
307            .output()
308            .await
309            .unwrap();
310
311        temp_dir
312    }
313
314    #[tokio::test]
315    async fn test_git_branch_list() {
316        let temp_dir = setup_git_repo().await;
317        std::fs::write(temp_dir.path().join("f.txt"), "init").unwrap();
318        Command::new("git").args(["add", "."]).current_dir(temp_dir.path()).output().await.unwrap();
319        Command::new("git").args(["commit", "-m", "init"]).current_dir(temp_dir.path()).output().await.unwrap();
320
321        let tool = GitBranchTool::new(temp_dir.path().into());
322        let result = tool.execute(json!({})).await.unwrap();
323        assert!(result["success"].as_bool().unwrap());
324        let branches = result["branches"].as_array().unwrap();
325        assert!(!branches.is_empty(), "Should have at least one branch");
326        assert!(result["current"].as_str().is_some());
327    }
328
329    #[tokio::test]
330    async fn test_git_checkout_create_branch() {
331        let temp_dir = setup_git_repo().await;
332        std::fs::write(temp_dir.path().join("f.txt"), "init").unwrap();
333        Command::new("git").args(["add", "."]).current_dir(temp_dir.path()).output().await.unwrap();
334        Command::new("git").args(["commit", "-m", "init"]).current_dir(temp_dir.path()).output().await.unwrap();
335
336        let tool = GitCheckoutTool::new(temp_dir.path().into());
337        let result = tool.execute(json!({"target": "feature-test", "create": true})).await.unwrap();
338        assert!(result["success"].as_bool().unwrap());
339
340        // Verify we're on the new branch
341        let branch_tool = GitBranchTool::new(temp_dir.path().into());
342        let branches = branch_tool.execute(json!({})).await.unwrap();
343        assert_eq!(branches["current"].as_str().unwrap(), "feature-test");
344    }
345
346    #[tokio::test]
347    async fn test_git_stash_on_clean_repo() {
348        let temp_dir = setup_git_repo().await;
349        let tool = GitStashTool::new(temp_dir.path().into());
350        // List stashes on empty repo
351        let result = tool.execute(json!({"action": "list"})).await.unwrap();
352        assert!(result["success"].as_bool().unwrap());
353    }
354
355    #[tokio::test]
356    async fn test_git_stash_on_dirty_repo_saves_changes() {
357        let temp_dir = setup_git_repo().await;
358        // First commit a base file
359        std::fs::write(temp_dir.path().join("base.txt"), "v1").unwrap();
360        GitAddTool::new(temp_dir.path().to_path_buf())
361            .execute(json!({ "files": ["base.txt"] }))
362            .await
363            .unwrap();
364        GitCommitTool::new(temp_dir.path().to_path_buf())
365            .execute(json!({ "message": "base" }))
366            .await
367            .unwrap();
368
369        // Now modify it so there's something to stash
370        std::fs::write(temp_dir.path().join("base.txt"), "v2-dirty").unwrap();
371
372        let stash_tool = GitStashTool::new(temp_dir.path().to_path_buf());
373        let result = stash_tool
374            .execute(json!({ "action": "push", "message": "test stash" }))
375            .await
376            .unwrap();
377        assert!(result["success"].as_bool().unwrap());
378
379        // Working tree should be clean again (stash popped the change)
380        let content = std::fs::read_to_string(temp_dir.path().join("base.txt")).unwrap();
381        assert_eq!(content, "v1", "stash push should revert working tree");
382    }
383
384    #[tokio::test]
385    async fn test_git_checkout_nonexistent_branch_without_create_errors() {
386        // Checkout to a non-existent branch WITHOUT create=true must fail,
387        // not silently create it. This pins the "safety" contract of the tool.
388        let temp_dir = setup_git_repo().await;
389        std::fs::write(temp_dir.path().join("init.txt"), "init").unwrap();
390        Command::new("git")
391            .args(["add", "."])
392            .current_dir(temp_dir.path())
393            .output()
394            .await
395            .unwrap();
396        Command::new("git")
397            .args(["commit", "-m", "init"])
398            .current_dir(temp_dir.path())
399            .output()
400            .await
401            .unwrap();
402
403        let tool = GitCheckoutTool::new(temp_dir.path().to_path_buf());
404        let result = tool
405            .execute(json!({
406                "target": "nonexistent-branch-xyz-abc-9999",
407                "create": false
408            }))
409            .await;
410        assert!(
411            result.is_err(),
412            "checkout to nonexistent branch without create must error"
413        );
414    }
415}