Skip to main content

pawan/tools/git/
branch.rs

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