Skip to main content

pawan/tools/
git.rs

1//! Git operation tools
2//!
3//! Tools for git operations: status, diff, add, commit, log, blame, branch.
4
5use super::Tool;
6use async_trait::async_trait;
7use serde_json::{json, Value};
8use std::path::PathBuf;
9use std::process::Stdio;
10use tokio::io::AsyncReadExt;
11use tokio::process::Command;
12
13/// Run a git command in a workspace directory
14async fn run_git(workspace: &PathBuf, args: &[&str]) -> crate::Result<(bool, String, String)> {
15    let mut cmd = Command::new("git");
16    cmd.args(args)
17        .current_dir(workspace)
18        .stdout(Stdio::piped())
19        .stderr(Stdio::piped())
20        .stdin(Stdio::null());
21
22    let mut child = cmd.spawn().map_err(crate::PawanError::Io)?;
23
24    let mut stdout = String::new();
25    let mut stderr = String::new();
26
27    if let Some(mut handle) = child.stdout.take() {
28        handle.read_to_string(&mut stdout).await.ok();
29    }
30    if let Some(mut handle) = child.stderr.take() {
31        handle.read_to_string(&mut stderr).await.ok();
32    }
33
34    let status = child.wait().await.map_err(crate::PawanError::Io)?;
35    Ok((status.success(), stdout, stderr))
36}
37
38/// Tool for checking git status
39///
40/// This tool provides information about the current git repository status,
41/// including modified files, untracked files, and branch information.
42///
43/// # Fields
44/// - `workspace_root`: The root directory of the workspace
45pub struct GitStatusTool {
46    workspace_root: PathBuf,
47}
48
49impl GitStatusTool {
50    pub fn new(workspace_root: PathBuf) -> Self {
51        Self { workspace_root }
52    }
53}
54
55#[async_trait]
56impl Tool for GitStatusTool {
57    fn name(&self) -> &str {
58        "git_status"
59    }
60
61    fn description(&self) -> &str {
62        "Get the current git status showing staged, unstaged, and untracked files."
63    }
64
65    fn parameters_schema(&self) -> Value {
66        json!({
67            "type": "object",
68            "properties": {
69                "short": {
70                    "type": "boolean",
71                    "description": "Use short format output (default: false)"
72                }
73            },
74            "required": []
75        })
76    }
77
78    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
79        use thulp_core::{Parameter, ParameterType};
80        thulp_core::ToolDefinition::builder("git_status")
81            .description(self.description())
82            .parameter(Parameter::builder("short").param_type(ParameterType::Boolean).required(false)
83                .description("Use short format output (default: false)").build())
84            .build()
85    }
86
87    async fn execute(&self, args: Value) -> crate::Result<Value> {
88        let short = args["short"].as_bool().unwrap_or(false);
89
90        let mut git_args = vec!["status"];
91        if short {
92            git_args.push("-s");
93        }
94
95        let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args).await?;
96
97        if !success {
98            return Err(crate::PawanError::Git(format!(
99                "git status failed: {}",
100                stderr
101            )));
102        }
103
104        // Also get branch info
105        let (_, branch_output, _) =
106            run_git(&self.workspace_root, &["branch", "--show-current"]).await?;
107        let branch = branch_output.trim().to_string();
108
109        // Check if repo is clean
110        let (_, porcelain, _) = run_git(&self.workspace_root, &["status", "--porcelain"]).await?;
111        let is_clean = porcelain.trim().is_empty();
112
113        Ok(json!({
114            "status": stdout.trim(),
115            "branch": branch,
116            "is_clean": is_clean,
117            "success": true
118        }))
119    }
120}
121
122/// Tool for getting git diff
123///
124/// This tool shows the differences between files in the working directory
125/// and the git index, or between commits.
126///
127/// # Fields
128/// - `workspace_root`: The root directory of the workspace
129pub struct GitDiffTool {
130    workspace_root: PathBuf,
131}
132
133impl GitDiffTool {
134    pub fn new(workspace_root: PathBuf) -> Self {
135        Self { workspace_root }
136    }
137}
138
139#[async_trait]
140impl Tool for GitDiffTool {
141    fn name(&self) -> &str {
142        "git_diff"
143    }
144
145    fn description(&self) -> &str {
146        "Show git diff for staged or unstaged changes. Can diff against a specific commit or branch."
147    }
148
149    fn parameters_schema(&self) -> Value {
150        json!({
151            "type": "object",
152            "properties": {
153                "staged": {
154                    "type": "boolean",
155                    "description": "Show staged changes only (--cached). Default: false (shows unstaged)"
156                },
157                "file": {
158                    "type": "string",
159                    "description": "Specific file to diff (optional)"
160                },
161                "base": {
162                    "type": "string",
163                    "description": "Base commit/branch to diff against (e.g., 'main', 'HEAD~3')"
164                },
165                "stat": {
166                    "type": "boolean",
167                    "description": "Show diffstat summary instead of full diff"
168                }
169            },
170            "required": []
171        })
172    }
173
174    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
175        use thulp_core::{Parameter, ParameterType};
176        thulp_core::ToolDefinition::builder("git_diff")
177            .description(self.description())
178            .parameter(Parameter::builder("staged").param_type(ParameterType::Boolean).required(false)
179                .description("Show staged changes only (--cached). Default: false (shows unstaged)").build())
180            .parameter(Parameter::builder("file").param_type(ParameterType::String).required(false)
181                .description("Specific file to diff (optional)").build())
182            .parameter(Parameter::builder("base").param_type(ParameterType::String).required(false)
183                .description("Base commit/branch to diff against (e.g., 'main', 'HEAD~3')").build())
184            .parameter(Parameter::builder("stat").param_type(ParameterType::Boolean).required(false)
185                .description("Show diffstat summary instead of full diff").build())
186            .build()
187    }
188
189    async fn execute(&self, args: Value) -> crate::Result<Value> {
190        let staged = args["staged"].as_bool().unwrap_or(false);
191        let file = args["file"].as_str();
192        let base = args["base"].as_str();
193        let stat = args["stat"].as_bool().unwrap_or(false);
194
195        let mut git_args = vec!["diff"];
196
197        if staged {
198            git_args.push("--cached");
199        }
200
201        if stat {
202            git_args.push("--stat");
203        }
204
205        if let Some(b) = base {
206            git_args.push(b);
207        }
208
209        if let Some(f) = file {
210            git_args.push("--");
211            git_args.push(f);
212        }
213
214        let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args).await?;
215
216        if !success {
217            return Err(crate::PawanError::Git(format!(
218                "git diff failed: {}",
219                stderr
220            )));
221        }
222
223        // Truncate if too large
224        let max_size = 100_000;
225        let truncated = stdout.len() > max_size;
226        let diff = if truncated {
227            format!(
228                "{}...\n[truncated, {} bytes total]",
229                &stdout[..max_size],
230                stdout.len()
231            )
232        } else {
233            stdout
234        };
235
236        Ok(json!({
237            "diff": diff,
238            "truncated": truncated,
239            "has_changes": !diff.trim().is_empty(),
240            "success": true
241        }))
242    }
243}
244
245/// Tool for staging files
246///
247/// This tool adds files to the git staging area in preparation for commit.
248///
249/// # Fields
250/// - `workspace_root`: The root directory of the workspace
251pub struct GitAddTool {
252    workspace_root: PathBuf,
253}
254
255impl GitAddTool {
256    pub fn new(workspace_root: PathBuf) -> Self {
257        Self { workspace_root }
258    }
259}
260
261#[async_trait]
262impl Tool for GitAddTool {
263    fn name(&self) -> &str {
264        "git_add"
265    }
266
267    fn description(&self) -> &str {
268        "Stage files for commit. Can stage specific files or all changes."
269    }
270
271    fn parameters_schema(&self) -> Value {
272        json!({
273            "type": "object",
274            "properties": {
275                "files": {
276                    "type": "array",
277                    "items": {"type": "string"},
278                    "description": "List of files to stage. Use [\".\"] to stage all changes."
279                },
280                "all": {
281                    "type": "boolean",
282                    "description": "Stage all changes including untracked files (-A)"
283                }
284            },
285            "required": []
286        })
287    }
288
289    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
290        use thulp_core::{Parameter, ParameterType};
291        thulp_core::ToolDefinition::builder("git_add")
292            .description(self.description())
293            .parameter(Parameter::builder("files").param_type(ParameterType::Array).required(false)
294                .description("List of files to stage. Use [\".\"] to stage all changes.").build())
295            .parameter(Parameter::builder("all").param_type(ParameterType::Boolean).required(false)
296                .description("Stage all changes including untracked files (-A)").build())
297            .build()
298    }
299
300    async fn execute(&self, args: Value) -> crate::Result<Value> {
301        let all = args["all"].as_bool().unwrap_or(false);
302        let files: Vec<&str> = args["files"]
303            .as_array()
304            .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
305            .unwrap_or_default();
306
307        let mut git_args = vec!["add"];
308
309        if all {
310            git_args.push("-A");
311        } else if files.is_empty() {
312            return Err(crate::PawanError::Tool(
313                "Either 'files' or 'all: true' must be specified".into(),
314            ));
315        } else {
316            for f in &files {
317                git_args.push(f);
318            }
319        }
320
321        let (success, _, stderr) = run_git(&self.workspace_root, &git_args).await?;
322
323        if !success {
324            return Err(crate::PawanError::Git(format!(
325                "git add failed: {}",
326                stderr
327            )));
328        }
329
330        // Get status after adding
331        let (_, status_output, _) = run_git(&self.workspace_root, &["status", "-s"]).await?;
332        let staged_count = status_output
333            .lines()
334            .filter(|l| l.starts_with('A') || l.starts_with('M') || l.starts_with('D'))
335            .count();
336
337        Ok(json!({
338            "success": true,
339            "staged_count": staged_count,
340            "message": if all {
341                "Staged all changes".to_string()
342            } else {
343                format!("Staged {} file(s)", files.len())
344            }
345        }))
346    }
347}
348
349/// Tool for creating commits
350///
351/// This tool creates a new git commit with the staged changes.
352///
353/// # Fields
354/// - `workspace_root`: The root directory of the workspace
355pub struct GitCommitTool {
356    workspace_root: PathBuf,
357}
358
359impl GitCommitTool {
360    pub fn new(workspace_root: PathBuf) -> Self {
361        Self { workspace_root }
362    }
363}
364
365#[async_trait]
366impl Tool for GitCommitTool {
367    fn name(&self) -> &str {
368        "git_commit"
369    }
370
371    fn description(&self) -> &str {
372        "Create a git commit with the staged changes. Requires a commit message."
373    }
374
375    fn parameters_schema(&self) -> Value {
376        json!({
377            "type": "object",
378            "properties": {
379                "message": {
380                    "type": "string",
381                    "description": "Commit message (required)"
382                },
383                "body": {
384                    "type": "string",
385                    "description": "Extended commit body (optional)"
386                }
387            },
388            "required": ["message"]
389        })
390    }
391
392    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
393        use thulp_core::{Parameter, ParameterType};
394        thulp_core::ToolDefinition::builder("git_commit")
395            .description(self.description())
396            .parameter(Parameter::builder("message").param_type(ParameterType::String).required(true)
397                .description("Commit message (required)").build())
398            .parameter(Parameter::builder("body").param_type(ParameterType::String).required(false)
399                .description("Extended commit body (optional)").build())
400            .build()
401    }
402
403    async fn execute(&self, args: Value) -> crate::Result<Value> {
404        let message = args["message"]
405            .as_str()
406            .ok_or_else(|| crate::PawanError::Tool("commit message is required".into()))?;
407
408        let body = args["body"].as_str();
409
410        // Check if there are staged changes
411        let (_, staged, _) = run_git(&self.workspace_root, &["diff", "--cached", "--stat"]).await?;
412        if staged.trim().is_empty() {
413            return Err(crate::PawanError::Git(
414                "No staged changes to commit. Use git_add first.".into(),
415            ));
416        }
417
418        // Build commit message
419        let full_message = if let Some(b) = body {
420            format!("{}\n\n{}", message, b)
421        } else {
422            message.to_string()
423        };
424
425        let (success, stdout, stderr) =
426            run_git(&self.workspace_root, &["commit", "-m", &full_message]).await?;
427
428        if !success {
429            return Err(crate::PawanError::Git(format!(
430                "git commit failed: {}",
431                stderr
432            )));
433        }
434
435        // Get the commit hash
436        let (_, hash_output, _) =
437            run_git(&self.workspace_root, &["rev-parse", "--short", "HEAD"]).await?;
438        let commit_hash = hash_output.trim().to_string();
439
440        Ok(json!({
441            "success": true,
442            "commit_hash": commit_hash,
443            "message": message,
444            "output": stdout.trim()
445        }))
446    }
447}
448
449/// Tool for viewing git log
450///
451/// This tool provides access to the git commit history, allowing inspection
452/// of previous commits, authors, dates, and commit messages.
453///
454/// # Fields
455/// - `workspace_root`: The root directory of the workspace
456pub struct GitLogTool {
457    workspace_root: PathBuf,
458}
459
460impl GitLogTool {
461    pub fn new(workspace_root: PathBuf) -> Self {
462        Self { workspace_root }
463    }
464}
465
466#[async_trait]
467impl Tool for GitLogTool {
468    fn name(&self) -> &str {
469        "git_log"
470    }
471
472    fn description(&self) -> &str {
473        "Show git commit history. Supports limiting count, filtering by file, and custom format."
474    }
475
476    fn parameters_schema(&self) -> Value {
477        json!({
478            "type": "object",
479            "properties": {
480                "count": {
481                    "type": "integer",
482                    "description": "Number of commits to show (default: 10)"
483                },
484                "file": {
485                    "type": "string",
486                    "description": "Show commits for a specific file"
487                },
488                "oneline": {
489                    "type": "boolean",
490                    "description": "Use compact one-line format (default: false)"
491                }
492            },
493            "required": []
494        })
495    }
496
497    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
498        use thulp_core::{Parameter, ParameterType};
499        thulp_core::ToolDefinition::builder("git_log")
500            .description(self.description())
501            .parameter(Parameter::builder("count").param_type(ParameterType::Integer).required(false)
502                .description("Number of commits to show (default: 10)").build())
503            .parameter(Parameter::builder("file").param_type(ParameterType::String).required(false)
504                .description("Show commits for a specific file").build())
505            .parameter(Parameter::builder("oneline").param_type(ParameterType::Boolean).required(false)
506                .description("Use compact one-line format (default: false)").build())
507            .build()
508    }
509
510    async fn execute(&self, args: Value) -> crate::Result<Value> {
511        let count = args["count"].as_u64().unwrap_or(10);
512        let file = args["file"].as_str();
513        let oneline = args["oneline"].as_bool().unwrap_or(false);
514
515        let count_str = count.to_string();
516        let mut git_args = vec!["log", "-n", &count_str];
517
518        if oneline {
519            git_args.push("--oneline");
520        } else {
521            git_args.extend_from_slice(&["--pretty=format:%h %an %ar %s"]);
522        }
523
524        if let Some(f) = file {
525            git_args.push("--");
526            git_args.push(f);
527        }
528
529        let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args).await?;
530
531        if !success {
532            return Err(crate::PawanError::Git(format!(
533                "git log failed: {}",
534                stderr
535            )));
536        }
537
538        let commit_count = stdout.lines().count();
539
540        Ok(json!({
541            "log": stdout.trim(),
542            "commit_count": commit_count,
543            "success": true
544        }))
545    }
546}
547
548/// Tool for git blame
549///
550/// This tool shows line-by-line authorship information for files, indicating
551/// who last modified each line and when.
552///
553/// # Fields
554/// - `workspace_root`: The root directory of the workspace
555pub struct GitBlameTool {
556    workspace_root: PathBuf,
557}
558
559impl GitBlameTool {
560    pub fn new(workspace_root: PathBuf) -> Self {
561        Self { workspace_root }
562    }
563}
564
565#[async_trait]
566impl Tool for GitBlameTool {
567    fn name(&self) -> &str {
568        "git_blame"
569    }
570
571    fn description(&self) -> &str {
572        "Show line-by-line authorship of a file. Useful for understanding who changed what."
573    }
574
575    fn parameters_schema(&self) -> Value {
576        json!({
577            "type": "object",
578            "properties": {
579                "file": {
580                    "type": "string",
581                    "description": "File to blame (required)"
582                },
583                "lines": {
584                    "type": "string",
585                    "description": "Line range, e.g., '10,20' for lines 10-20"
586                }
587            },
588            "required": ["file"]
589        })
590    }
591
592    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
593        use thulp_core::{Parameter, ParameterType};
594        thulp_core::ToolDefinition::builder("git_blame")
595            .description(self.description())
596            .parameter(Parameter::builder("file").param_type(ParameterType::String).required(true)
597                .description("File to blame (required)").build())
598            .parameter(Parameter::builder("lines").param_type(ParameterType::String).required(false)
599                .description("Line range, e.g., '10,20' for lines 10-20").build())
600            .build()
601    }
602
603    async fn execute(&self, args: Value) -> crate::Result<Value> {
604        let file = args["file"]
605            .as_str()
606            .ok_or_else(|| crate::PawanError::Tool("file is required for git_blame".into()))?;
607        let lines = args["lines"].as_str();
608
609        let mut git_args = vec!["blame", "--porcelain"];
610
611        let line_range;
612        if let Some(l) = lines {
613            line_range = format!("-L{}", l);
614            git_args.push(&line_range);
615        }
616
617        git_args.push(file);
618
619        let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args).await?;
620
621        if !success {
622            return Err(crate::PawanError::Git(format!(
623                "git blame failed: {}",
624                stderr
625            )));
626        }
627
628        // Truncate if too large
629        let max_size = 50_000;
630        let output = if stdout.len() > max_size {
631            format!(
632                "{}...\n[truncated, {} bytes total]",
633                &stdout[..max_size],
634                stdout.len()
635            )
636        } else {
637            stdout
638        };
639
640        Ok(json!({
641            "blame": output.trim(),
642            "success": true
643        }))
644    }
645}
646
647/// Tool for listing and managing branches
648pub struct GitBranchTool {
649    workspace_root: PathBuf,
650}
651
652impl GitBranchTool {
653    pub fn new(workspace_root: PathBuf) -> Self {
654        Self { workspace_root }
655    }
656}
657
658#[async_trait]
659impl Tool for GitBranchTool {
660    fn name(&self) -> &str {
661        "git_branch"
662    }
663
664    fn description(&self) -> &str {
665        "List branches or get current branch name. Shows local and optionally remote branches."
666    }
667
668    fn parameters_schema(&self) -> Value {
669        json!({
670            "type": "object",
671            "properties": {
672                "all": {
673                    "type": "boolean",
674                    "description": "Show both local and remote branches (default: false)"
675                }
676            },
677            "required": []
678        })
679    }
680
681    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
682        use thulp_core::{Parameter, ParameterType};
683        thulp_core::ToolDefinition::builder("git_branch")
684            .description(self.description())
685            .parameter(Parameter::builder("all").param_type(ParameterType::Boolean).required(false)
686                .description("Show both local and remote branches (default: false)").build())
687            .build()
688    }
689
690    async fn execute(&self, args: Value) -> crate::Result<Value> {
691        let all = args["all"].as_bool().unwrap_or(false);
692
693        // Get current branch
694        let (_, current, _) = run_git(&self.workspace_root, &["branch", "--show-current"]).await?;
695        let current_branch = current.trim().to_string();
696
697        // List branches
698        let mut git_args = vec!["branch", "--format=%(refname:short)"];
699        if all {
700            git_args.push("-a");
701        }
702
703        let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args).await?;
704
705        if !success {
706            return Err(crate::PawanError::Git(format!(
707                "git branch failed: {}",
708                stderr
709            )));
710        }
711
712        let branches: Vec<&str> = stdout
713            .lines()
714            .map(|l| l.trim())
715            .filter(|l| !l.is_empty())
716            .collect();
717
718        Ok(json!({
719            "current": current_branch,
720            "branches": branches,
721            "count": branches.len(),
722            "success": true
723        }))
724    }
725}
726
727/// Tool for git checkout (switch branches or restore files)
728pub struct GitCheckoutTool {
729    workspace_root: PathBuf,
730}
731
732impl GitCheckoutTool {
733    pub fn new(workspace_root: PathBuf) -> Self {
734        Self { workspace_root }
735    }
736}
737
738#[async_trait]
739impl Tool for GitCheckoutTool {
740    fn name(&self) -> &str {
741        "git_checkout"
742    }
743
744    fn description(&self) -> &str {
745        "Switch branches or restore working tree files. Can create new branches with create=true."
746    }
747
748    fn parameters_schema(&self) -> Value {
749        json!({
750            "type": "object",
751            "properties": {
752                "target": {
753                    "type": "string",
754                    "description": "Branch name, commit, or file path to checkout"
755                },
756                "create": {
757                    "type": "boolean",
758                    "description": "Create a new branch (git checkout -b)"
759                },
760                "files": {
761                    "type": "array",
762                    "items": { "type": "string" },
763                    "description": "Specific files to restore (git checkout -- <files>)"
764                }
765            },
766            "required": ["target"]
767        })
768    }
769
770    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
771        use thulp_core::{Parameter, ParameterType};
772        thulp_core::ToolDefinition::builder("git_checkout")
773            .description(self.description())
774            .parameter(Parameter::builder("target").param_type(ParameterType::String).required(true)
775                .description("Branch name, commit, or file path to checkout").build())
776            .parameter(Parameter::builder("create").param_type(ParameterType::Boolean).required(false)
777                .description("Create a new branch (git checkout -b)").build())
778            .parameter(Parameter::builder("files").param_type(ParameterType::Array).required(false)
779                .description("Specific files to restore (git checkout -- <files>)").build())
780            .build()
781    }
782
783    async fn execute(&self, args: Value) -> crate::Result<Value> {
784        let target = args["target"]
785            .as_str()
786            .ok_or_else(|| crate::PawanError::Tool("target is required".into()))?;
787        let create = args["create"].as_bool().unwrap_or(false);
788        let files: Vec<&str> = args["files"]
789            .as_array()
790            .map(|a| a.iter().filter_map(|v| v.as_str()).collect())
791            .unwrap_or_default();
792
793        let mut git_args: Vec<&str> = vec!["checkout"];
794
795        if create {
796            git_args.push("-b");
797        }
798
799        git_args.push(target);
800
801        if !files.is_empty() {
802            git_args.push("--");
803            git_args.extend(files.iter());
804        }
805
806        let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args).await?;
807
808        if !success {
809            return Err(crate::PawanError::Git(format!(
810                "git checkout failed: {}",
811                stderr
812            )));
813        }
814
815        Ok(json!({
816            "success": true,
817            "target": target,
818            "created": create,
819            "output": format!("{}{}", stdout, stderr).trim().to_string()
820        }))
821    }
822}
823
824/// Tool for git stash operations
825pub struct GitStashTool {
826    workspace_root: PathBuf,
827}
828
829impl GitStashTool {
830    pub fn new(workspace_root: PathBuf) -> Self {
831        Self { workspace_root }
832    }
833}
834
835#[async_trait]
836impl Tool for GitStashTool {
837    fn name(&self) -> &str {
838        "git_stash"
839    }
840
841    fn description(&self) -> &str {
842        "Stash or restore uncommitted changes. Actions: push (default), pop, list, drop."
843    }
844
845    fn parameters_schema(&self) -> Value {
846        json!({
847            "type": "object",
848            "properties": {
849                "action": {
850                    "type": "string",
851                    "enum": ["push", "pop", "list", "drop", "show"],
852                    "description": "Stash action (default: push)"
853                },
854                "message": {
855                    "type": "string",
856                    "description": "Message for stash push"
857                },
858                "index": {
859                    "type": "integer",
860                    "description": "Stash index for pop/drop/show (default: 0)"
861                }
862            },
863            "required": []
864        })
865    }
866
867    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
868        use thulp_core::{Parameter, ParameterType};
869        thulp_core::ToolDefinition::builder("git_stash")
870            .description(self.description())
871            .parameter(Parameter::builder("action").param_type(ParameterType::String).required(false)
872                .description("Stash action (default: push)").build())
873            .parameter(Parameter::builder("message").param_type(ParameterType::String).required(false)
874                .description("Message for stash push").build())
875            .parameter(Parameter::builder("index").param_type(ParameterType::Integer).required(false)
876                .description("Stash index for pop/drop/show (default: 0)").build())
877            .build()
878    }
879
880    async fn execute(&self, args: Value) -> crate::Result<Value> {
881        let action = args["action"].as_str().unwrap_or("push");
882        let message = args["message"].as_str();
883        let index = args["index"].as_u64().unwrap_or(0);
884
885        let git_args: Vec<String> = match action {
886            "push" => {
887                let mut a = vec!["stash".to_string(), "push".to_string()];
888                if let Some(msg) = message {
889                    a.push("-m".to_string());
890                    a.push(msg.to_string());
891                }
892                a
893            }
894            "pop" => vec!["stash".to_string(), "pop".to_string(), format!("stash@{{{}}}", index)],
895            "list" => vec!["stash".to_string(), "list".to_string()],
896            "drop" => vec!["stash".to_string(), "drop".to_string(), format!("stash@{{{}}}", index)],
897            "show" => vec!["stash".to_string(), "show".to_string(), "-p".to_string(), format!("stash@{{{}}}", index)],
898            _ => return Err(crate::PawanError::Tool(format!("Unknown stash action: {}", action))),
899        };
900
901        let git_args_ref: Vec<&str> = git_args.iter().map(|s| s.as_str()).collect();
902        let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args_ref).await?;
903
904        if !success {
905            return Err(crate::PawanError::Git(format!(
906                "git stash {} failed: {}",
907                action, stderr
908            )));
909        }
910
911        Ok(json!({
912            "success": true,
913            "action": action,
914            "output": stdout.trim().to_string()
915        }))
916    }
917}
918
919#[cfg(test)]
920mod tests {
921    use super::*;
922    use tempfile::TempDir;
923
924    async fn setup_git_repo() -> TempDir {
925        let temp_dir = TempDir::new().unwrap();
926
927        // Initialize git repo
928        let mut cmd = Command::new("git");
929        cmd.args(["init"])
930            .current_dir(temp_dir.path())
931            .output()
932            .await
933            .unwrap();
934
935        // Configure git for test
936        let mut cmd = Command::new("git");
937        cmd.args(["config", "user.email", "test@test.com"])
938            .current_dir(temp_dir.path())
939            .output()
940            .await
941            .unwrap();
942
943        let mut cmd = Command::new("git");
944        cmd.args(["config", "user.name", "Test User"])
945            .current_dir(temp_dir.path())
946            .output()
947            .await
948            .unwrap();
949
950        temp_dir
951    }
952
953    #[tokio::test]
954    async fn test_git_status_empty_repo() {
955        let temp_dir = setup_git_repo().await;
956
957        let tool = GitStatusTool::new(temp_dir.path().to_path_buf());
958        let result = tool.execute(json!({})).await.unwrap();
959
960        assert!(result["success"].as_bool().unwrap());
961        assert!(result["is_clean"].as_bool().unwrap());
962    }
963
964    #[tokio::test]
965    async fn test_git_status_with_untracked() {
966        let temp_dir = setup_git_repo().await;
967
968        // Create an untracked file
969        std::fs::write(temp_dir.path().join("test.txt"), "hello").unwrap();
970
971        let tool = GitStatusTool::new(temp_dir.path().to_path_buf());
972        let result = tool.execute(json!({})).await.unwrap();
973
974        assert!(result["success"].as_bool().unwrap());
975        assert!(!result["is_clean"].as_bool().unwrap());
976    }
977
978    #[tokio::test]
979    async fn test_git_add_and_commit() {
980        let temp_dir = setup_git_repo().await;
981
982        // Create a file
983        std::fs::write(temp_dir.path().join("test.txt"), "hello").unwrap();
984
985        // Add the file
986        let add_tool = GitAddTool::new(temp_dir.path().to_path_buf());
987        let add_result = add_tool
988            .execute(json!({
989                "files": ["test.txt"]
990            }))
991            .await
992            .unwrap();
993        assert!(add_result["success"].as_bool().unwrap());
994
995        // Commit
996        let commit_tool = GitCommitTool::new(temp_dir.path().to_path_buf());
997        let commit_result = commit_tool
998            .execute(json!({
999                "message": "Add test file"
1000            }))
1001            .await
1002            .unwrap();
1003        assert!(commit_result["success"].as_bool().unwrap());
1004        assert!(!commit_result["commit_hash"].as_str().unwrap().is_empty());
1005    }
1006
1007    #[tokio::test]
1008    async fn test_git_diff_no_changes() {
1009        let temp_dir = setup_git_repo().await;
1010
1011        let tool = GitDiffTool::new(temp_dir.path().to_path_buf());
1012        let result = tool.execute(json!({})).await.unwrap();
1013
1014        assert!(result["success"].as_bool().unwrap());
1015        assert!(!result["has_changes"].as_bool().unwrap());
1016    }
1017    #[tokio::test]
1018    async fn test_git_status_tool_exists() {
1019        let temp_dir = setup_git_repo().await;
1020        let tool = GitStatusTool::new(temp_dir.path().to_path_buf());
1021        assert_eq!(tool.name(), "git_status");
1022    }
1023
1024    #[tokio::test]
1025    async fn test_git_log_tool_exists() {
1026        let temp_dir = setup_git_repo().await;
1027        let tool = GitLogTool::new(temp_dir.path().to_path_buf());
1028        assert_eq!(tool.name(), "git_log");
1029    }
1030
1031    #[tokio::test]
1032    async fn test_git_diff_schema() {
1033        let temp_dir = setup_git_repo().await;
1034        let tool = GitDiffTool::new(temp_dir.path().to_path_buf());
1035        let schema = tool.parameters_schema();
1036        let obj = schema.as_object().unwrap();
1037        let props = obj.get("properties").unwrap().as_object().unwrap();
1038        assert!(props.contains_key("staged"));
1039        assert!(props.contains_key("file"));
1040        assert!(props.contains_key("base"));
1041        assert!(props.contains_key("stat"));
1042    }
1043
1044    #[tokio::test]
1045    async fn test_git_diff_with_changes() {
1046        let temp_dir = setup_git_repo().await;
1047        // Create, add, commit a file
1048        std::fs::write(temp_dir.path().join("f.txt"), "original").unwrap();
1049        Command::new("git").args(["add", "."]).current_dir(temp_dir.path()).output().await.unwrap();
1050        Command::new("git").args(["commit", "-m", "init"]).current_dir(temp_dir.path()).output().await.unwrap();
1051        // Modify the file
1052        std::fs::write(temp_dir.path().join("f.txt"), "modified").unwrap();
1053
1054        let tool = GitDiffTool::new(temp_dir.path().into());
1055        let result = tool.execute(json!({})).await.unwrap();
1056        assert!(result["success"].as_bool().unwrap());
1057        assert!(result["has_changes"].as_bool().unwrap());
1058        assert!(result["diff"].as_str().unwrap().contains("modified"));
1059    }
1060
1061    #[tokio::test]
1062    async fn test_git_log_with_commits() {
1063        let temp_dir = setup_git_repo().await;
1064        std::fs::write(temp_dir.path().join("a.txt"), "a").unwrap();
1065        Command::new("git").args(["add", "."]).current_dir(temp_dir.path()).output().await.unwrap();
1066        Command::new("git").args(["commit", "-m", "first commit"]).current_dir(temp_dir.path()).output().await.unwrap();
1067        std::fs::write(temp_dir.path().join("b.txt"), "b").unwrap();
1068        Command::new("git").args(["add", "."]).current_dir(temp_dir.path()).output().await.unwrap();
1069        Command::new("git").args(["commit", "-m", "second commit"]).current_dir(temp_dir.path()).output().await.unwrap();
1070
1071        let tool = GitLogTool::new(temp_dir.path().into());
1072        let result = tool.execute(json!({"count": 5})).await.unwrap();
1073        assert!(result["success"].as_bool().unwrap());
1074        let log = result["log"].as_str().unwrap();
1075        assert!(log.contains("first commit"));
1076        assert!(log.contains("second commit"));
1077    }
1078
1079    #[tokio::test]
1080    async fn test_git_branch_list() {
1081        let temp_dir = setup_git_repo().await;
1082        std::fs::write(temp_dir.path().join("f.txt"), "init").unwrap();
1083        Command::new("git").args(["add", "."]).current_dir(temp_dir.path()).output().await.unwrap();
1084        Command::new("git").args(["commit", "-m", "init"]).current_dir(temp_dir.path()).output().await.unwrap();
1085
1086        let tool = GitBranchTool::new(temp_dir.path().into());
1087        let result = tool.execute(json!({})).await.unwrap();
1088        assert!(result["success"].as_bool().unwrap());
1089        let branches = result["branches"].as_array().unwrap();
1090        assert!(!branches.is_empty(), "Should have at least one branch");
1091        assert!(result["current"].as_str().is_some());
1092    }
1093
1094    #[tokio::test]
1095    async fn test_git_checkout_create_branch() {
1096        let temp_dir = setup_git_repo().await;
1097        std::fs::write(temp_dir.path().join("f.txt"), "init").unwrap();
1098        Command::new("git").args(["add", "."]).current_dir(temp_dir.path()).output().await.unwrap();
1099        Command::new("git").args(["commit", "-m", "init"]).current_dir(temp_dir.path()).output().await.unwrap();
1100
1101        let tool = GitCheckoutTool::new(temp_dir.path().into());
1102        let result = tool.execute(json!({"target": "feature-test", "create": true})).await.unwrap();
1103        assert!(result["success"].as_bool().unwrap());
1104
1105        // Verify we're on the new branch
1106        let branch_tool = GitBranchTool::new(temp_dir.path().into());
1107        let branches = branch_tool.execute(json!({})).await.unwrap();
1108        assert_eq!(branches["current"].as_str().unwrap(), "feature-test");
1109    }
1110
1111    #[tokio::test]
1112    async fn test_git_stash_on_clean_repo() {
1113        let temp_dir = setup_git_repo().await;
1114        let tool = GitStashTool::new(temp_dir.path().into());
1115        // List stashes on empty repo
1116        let result = tool.execute(json!({"action": "list"})).await.unwrap();
1117        assert!(result["success"].as_bool().unwrap());
1118    }
1119
1120    #[tokio::test]
1121    async fn test_git_blame_requires_file() {
1122        let temp_dir = setup_git_repo().await;
1123        let tool = GitBlameTool::new(temp_dir.path().into());
1124        let result = tool.execute(json!({})).await;
1125        assert!(result.is_err(), "blame without file should error");
1126    }
1127
1128    #[tokio::test]
1129    async fn test_git_tool_schemas() {
1130        let tmp = TempDir::new().unwrap();
1131        // Verify all git tools have correct names and non-empty schemas
1132        let tools: Vec<(&str, Box<dyn Tool>)> = vec![
1133            ("git_status", Box::new(GitStatusTool::new(tmp.path().into()))),
1134            ("git_diff", Box::new(GitDiffTool::new(tmp.path().into()))),
1135            ("git_add", Box::new(GitAddTool::new(tmp.path().into()))),
1136            ("git_commit", Box::new(GitCommitTool::new(tmp.path().into()))),
1137            ("git_log", Box::new(GitLogTool::new(tmp.path().into()))),
1138            ("git_blame", Box::new(GitBlameTool::new(tmp.path().into()))),
1139            ("git_branch", Box::new(GitBranchTool::new(tmp.path().into()))),
1140            ("git_checkout", Box::new(GitCheckoutTool::new(tmp.path().into()))),
1141            ("git_stash", Box::new(GitStashTool::new(tmp.path().into()))),
1142        ];
1143        for (expected_name, tool) in &tools {
1144            assert_eq!(tool.name(), *expected_name, "Tool name mismatch");
1145            assert!(!tool.description().is_empty(), "Missing description for {}", expected_name);
1146            let schema = tool.parameters_schema();
1147            assert!(schema.is_object(), "Schema should be object for {}", expected_name);
1148        }
1149    }
1150}