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    async fn execute(&self, args: Value) -> crate::Result<Value> {
79        let short = args["short"].as_bool().unwrap_or(false);
80
81        let mut git_args = vec!["status"];
82        if short {
83            git_args.push("-s");
84        }
85
86        let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args).await?;
87
88        if !success {
89            return Err(crate::PawanError::Git(format!(
90                "git status failed: {}",
91                stderr
92            )));
93        }
94
95        // Also get branch info
96        let (_, branch_output, _) =
97            run_git(&self.workspace_root, &["branch", "--show-current"]).await?;
98        let branch = branch_output.trim().to_string();
99
100        // Check if repo is clean
101        let (_, porcelain, _) = run_git(&self.workspace_root, &["status", "--porcelain"]).await?;
102        let is_clean = porcelain.trim().is_empty();
103
104        Ok(json!({
105            "status": stdout.trim(),
106            "branch": branch,
107            "is_clean": is_clean,
108            "success": true
109        }))
110    }
111}
112
113/// Tool for getting git diff
114///
115/// This tool shows the differences between files in the working directory
116/// and the git index, or between commits.
117///
118/// # Fields
119/// - `workspace_root`: The root directory of the workspace
120pub struct GitDiffTool {
121    workspace_root: PathBuf,
122}
123
124impl GitDiffTool {
125    pub fn new(workspace_root: PathBuf) -> Self {
126        Self { workspace_root }
127    }
128}
129
130#[async_trait]
131impl Tool for GitDiffTool {
132    fn name(&self) -> &str {
133        "git_diff"
134    }
135
136    fn description(&self) -> &str {
137        "Show git diff for staged or unstaged changes. Can diff against a specific commit or branch."
138    }
139
140    fn parameters_schema(&self) -> Value {
141        json!({
142            "type": "object",
143            "properties": {
144                "staged": {
145                    "type": "boolean",
146                    "description": "Show staged changes only (--cached). Default: false (shows unstaged)"
147                },
148                "file": {
149                    "type": "string",
150                    "description": "Specific file to diff (optional)"
151                },
152                "base": {
153                    "type": "string",
154                    "description": "Base commit/branch to diff against (e.g., 'main', 'HEAD~3')"
155                },
156                "stat": {
157                    "type": "boolean",
158                    "description": "Show diffstat summary instead of full diff"
159                }
160            },
161            "required": []
162        })
163    }
164
165    async fn execute(&self, args: Value) -> crate::Result<Value> {
166        let staged = args["staged"].as_bool().unwrap_or(false);
167        let file = args["file"].as_str();
168        let base = args["base"].as_str();
169        let stat = args["stat"].as_bool().unwrap_or(false);
170
171        let mut git_args = vec!["diff"];
172
173        if staged {
174            git_args.push("--cached");
175        }
176
177        if stat {
178            git_args.push("--stat");
179        }
180
181        if let Some(b) = base {
182            git_args.push(b);
183        }
184
185        if let Some(f) = file {
186            git_args.push("--");
187            git_args.push(f);
188        }
189
190        let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args).await?;
191
192        if !success {
193            return Err(crate::PawanError::Git(format!(
194                "git diff failed: {}",
195                stderr
196            )));
197        }
198
199        // Truncate if too large
200        let max_size = 100_000;
201        let truncated = stdout.len() > max_size;
202        let diff = if truncated {
203            format!(
204                "{}...\n[truncated, {} bytes total]",
205                &stdout[..max_size],
206                stdout.len()
207            )
208        } else {
209            stdout
210        };
211
212        Ok(json!({
213            "diff": diff,
214            "truncated": truncated,
215            "has_changes": !diff.trim().is_empty(),
216            "success": true
217        }))
218    }
219}
220
221/// Tool for staging files
222///
223/// This tool adds files to the git staging area in preparation for commit.
224///
225/// # Fields
226/// - `workspace_root`: The root directory of the workspace
227pub struct GitAddTool {
228    workspace_root: PathBuf,
229}
230
231impl GitAddTool {
232    pub fn new(workspace_root: PathBuf) -> Self {
233        Self { workspace_root }
234    }
235}
236
237#[async_trait]
238impl Tool for GitAddTool {
239    fn name(&self) -> &str {
240        "git_add"
241    }
242
243    fn description(&self) -> &str {
244        "Stage files for commit. Can stage specific files or all changes."
245    }
246
247    fn parameters_schema(&self) -> Value {
248        json!({
249            "type": "object",
250            "properties": {
251                "files": {
252                    "type": "array",
253                    "items": {"type": "string"},
254                    "description": "List of files to stage. Use [\".\"] to stage all changes."
255                },
256                "all": {
257                    "type": "boolean",
258                    "description": "Stage all changes including untracked files (-A)"
259                }
260            },
261            "required": []
262        })
263    }
264
265    async fn execute(&self, args: Value) -> crate::Result<Value> {
266        let all = args["all"].as_bool().unwrap_or(false);
267        let files: Vec<&str> = args["files"]
268            .as_array()
269            .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
270            .unwrap_or_default();
271
272        let mut git_args = vec!["add"];
273
274        if all {
275            git_args.push("-A");
276        } else if files.is_empty() {
277            return Err(crate::PawanError::Tool(
278                "Either 'files' or 'all: true' must be specified".into(),
279            ));
280        } else {
281            for f in &files {
282                git_args.push(f);
283            }
284        }
285
286        let (success, _, stderr) = run_git(&self.workspace_root, &git_args).await?;
287
288        if !success {
289            return Err(crate::PawanError::Git(format!(
290                "git add failed: {}",
291                stderr
292            )));
293        }
294
295        // Get status after adding
296        let (_, status_output, _) = run_git(&self.workspace_root, &["status", "-s"]).await?;
297        let staged_count = status_output
298            .lines()
299            .filter(|l| l.starts_with('A') || l.starts_with('M') || l.starts_with('D'))
300            .count();
301
302        Ok(json!({
303            "success": true,
304            "staged_count": staged_count,
305            "message": if all {
306                "Staged all changes".to_string()
307            } else {
308                format!("Staged {} file(s)", files.len())
309            }
310        }))
311    }
312}
313
314/// Tool for creating commits
315///
316/// This tool creates a new git commit with the staged changes.
317///
318/// # Fields
319/// - `workspace_root`: The root directory of the workspace
320pub struct GitCommitTool {
321    workspace_root: PathBuf,
322}
323
324impl GitCommitTool {
325    pub fn new(workspace_root: PathBuf) -> Self {
326        Self { workspace_root }
327    }
328}
329
330#[async_trait]
331impl Tool for GitCommitTool {
332    fn name(&self) -> &str {
333        "git_commit"
334    }
335
336    fn description(&self) -> &str {
337        "Create a git commit with the staged changes. Requires a commit message."
338    }
339
340    fn parameters_schema(&self) -> Value {
341        json!({
342            "type": "object",
343            "properties": {
344                "message": {
345                    "type": "string",
346                    "description": "Commit message (required)"
347                },
348                "body": {
349                    "type": "string",
350                    "description": "Extended commit body (optional)"
351                }
352            },
353            "required": ["message"]
354        })
355    }
356
357    async fn execute(&self, args: Value) -> crate::Result<Value> {
358        let message = args["message"]
359            .as_str()
360            .ok_or_else(|| crate::PawanError::Tool("commit message is required".into()))?;
361
362        let body = args["body"].as_str();
363
364        // Check if there are staged changes
365        let (_, staged, _) = run_git(&self.workspace_root, &["diff", "--cached", "--stat"]).await?;
366        if staged.trim().is_empty() {
367            return Err(crate::PawanError::Git(
368                "No staged changes to commit. Use git_add first.".into(),
369            ));
370        }
371
372        // Build commit message
373        let full_message = if let Some(b) = body {
374            format!("{}\n\n{}", message, b)
375        } else {
376            message.to_string()
377        };
378
379        let (success, stdout, stderr) =
380            run_git(&self.workspace_root, &["commit", "-m", &full_message]).await?;
381
382        if !success {
383            return Err(crate::PawanError::Git(format!(
384                "git commit failed: {}",
385                stderr
386            )));
387        }
388
389        // Get the commit hash
390        let (_, hash_output, _) =
391            run_git(&self.workspace_root, &["rev-parse", "--short", "HEAD"]).await?;
392        let commit_hash = hash_output.trim().to_string();
393
394        Ok(json!({
395            "success": true,
396            "commit_hash": commit_hash,
397            "message": message,
398            "output": stdout.trim()
399        }))
400    }
401}
402
403/// Tool for viewing git log
404///
405/// This tool provides access to the git commit history, allowing inspection
406/// of previous commits, authors, dates, and commit messages.
407///
408/// # Fields
409/// - `workspace_root`: The root directory of the workspace
410pub struct GitLogTool {
411    workspace_root: PathBuf,
412}
413
414impl GitLogTool {
415    pub fn new(workspace_root: PathBuf) -> Self {
416        Self { workspace_root }
417    }
418}
419
420#[async_trait]
421impl Tool for GitLogTool {
422    fn name(&self) -> &str {
423        "git_log"
424    }
425
426    fn description(&self) -> &str {
427        "Show git commit history. Supports limiting count, filtering by file, and custom format."
428    }
429
430    fn parameters_schema(&self) -> Value {
431        json!({
432            "type": "object",
433            "properties": {
434                "count": {
435                    "type": "integer",
436                    "description": "Number of commits to show (default: 10)"
437                },
438                "file": {
439                    "type": "string",
440                    "description": "Show commits for a specific file"
441                },
442                "oneline": {
443                    "type": "boolean",
444                    "description": "Use compact one-line format (default: false)"
445                }
446            },
447            "required": []
448        })
449    }
450
451    async fn execute(&self, args: Value) -> crate::Result<Value> {
452        let count = args["count"].as_u64().unwrap_or(10);
453        let file = args["file"].as_str();
454        let oneline = args["oneline"].as_bool().unwrap_or(false);
455
456        let count_str = count.to_string();
457        let mut git_args = vec!["log", "-n", &count_str];
458
459        if oneline {
460            git_args.push("--oneline");
461        } else {
462            git_args.extend_from_slice(&["--pretty=format:%h %an %ar %s"]);
463        }
464
465        if let Some(f) = file {
466            git_args.push("--");
467            git_args.push(f);
468        }
469
470        let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args).await?;
471
472        if !success {
473            return Err(crate::PawanError::Git(format!(
474                "git log failed: {}",
475                stderr
476            )));
477        }
478
479        let commit_count = stdout.lines().count();
480
481        Ok(json!({
482            "log": stdout.trim(),
483            "commit_count": commit_count,
484            "success": true
485        }))
486    }
487}
488
489/// Tool for git blame
490///
491/// This tool shows line-by-line authorship information for files, indicating
492/// who last modified each line and when.
493///
494/// # Fields
495/// - `workspace_root`: The root directory of the workspace
496pub struct GitBlameTool {
497    workspace_root: PathBuf,
498}
499
500impl GitBlameTool {
501    pub fn new(workspace_root: PathBuf) -> Self {
502        Self { workspace_root }
503    }
504}
505
506#[async_trait]
507impl Tool for GitBlameTool {
508    fn name(&self) -> &str {
509        "git_blame"
510    }
511
512    fn description(&self) -> &str {
513        "Show line-by-line authorship of a file. Useful for understanding who changed what."
514    }
515
516    fn parameters_schema(&self) -> Value {
517        json!({
518            "type": "object",
519            "properties": {
520                "file": {
521                    "type": "string",
522                    "description": "File to blame (required)"
523                },
524                "lines": {
525                    "type": "string",
526                    "description": "Line range, e.g., '10,20' for lines 10-20"
527                }
528            },
529            "required": ["file"]
530        })
531    }
532
533    async fn execute(&self, args: Value) -> crate::Result<Value> {
534        let file = args["file"]
535            .as_str()
536            .ok_or_else(|| crate::PawanError::Tool("file is required for git_blame".into()))?;
537        let lines = args["lines"].as_str();
538
539        let mut git_args = vec!["blame", "--porcelain"];
540
541        let line_range;
542        if let Some(l) = lines {
543            line_range = format!("-L{}", l);
544            git_args.push(&line_range);
545        }
546
547        git_args.push(file);
548
549        let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args).await?;
550
551        if !success {
552            return Err(crate::PawanError::Git(format!(
553                "git blame failed: {}",
554                stderr
555            )));
556        }
557
558        // Truncate if too large
559        let max_size = 50_000;
560        let output = if stdout.len() > max_size {
561            format!(
562                "{}...\n[truncated, {} bytes total]",
563                &stdout[..max_size],
564                stdout.len()
565            )
566        } else {
567            stdout
568        };
569
570        Ok(json!({
571            "blame": output.trim(),
572            "success": true
573        }))
574    }
575}
576
577/// Tool for listing and managing branches
578pub struct GitBranchTool {
579    workspace_root: PathBuf,
580}
581
582impl GitBranchTool {
583    pub fn new(workspace_root: PathBuf) -> Self {
584        Self { workspace_root }
585    }
586}
587
588#[async_trait]
589impl Tool for GitBranchTool {
590    fn name(&self) -> &str {
591        "git_branch"
592    }
593
594    fn description(&self) -> &str {
595        "List branches or get current branch name. Shows local and optionally remote branches."
596    }
597
598    fn parameters_schema(&self) -> Value {
599        json!({
600            "type": "object",
601            "properties": {
602                "all": {
603                    "type": "boolean",
604                    "description": "Show both local and remote branches (default: false)"
605                }
606            },
607            "required": []
608        })
609    }
610
611    async fn execute(&self, args: Value) -> crate::Result<Value> {
612        let all = args["all"].as_bool().unwrap_or(false);
613
614        // Get current branch
615        let (_, current, _) = run_git(&self.workspace_root, &["branch", "--show-current"]).await?;
616        let current_branch = current.trim().to_string();
617
618        // List branches
619        let mut git_args = vec!["branch", "--format=%(refname:short)"];
620        if all {
621            git_args.push("-a");
622        }
623
624        let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args).await?;
625
626        if !success {
627            return Err(crate::PawanError::Git(format!(
628                "git branch failed: {}",
629                stderr
630            )));
631        }
632
633        let branches: Vec<&str> = stdout
634            .lines()
635            .map(|l| l.trim())
636            .filter(|l| !l.is_empty())
637            .collect();
638
639        Ok(json!({
640            "current": current_branch,
641            "branches": branches,
642            "count": branches.len(),
643            "success": true
644        }))
645    }
646}
647
648/// Tool for git checkout (switch branches or restore files)
649pub struct GitCheckoutTool {
650    workspace_root: PathBuf,
651}
652
653impl GitCheckoutTool {
654    pub fn new(workspace_root: PathBuf) -> Self {
655        Self { workspace_root }
656    }
657}
658
659#[async_trait]
660impl Tool for GitCheckoutTool {
661    fn name(&self) -> &str {
662        "git_checkout"
663    }
664
665    fn description(&self) -> &str {
666        "Switch branches or restore working tree files. Can create new branches with create=true."
667    }
668
669    fn parameters_schema(&self) -> Value {
670        json!({
671            "type": "object",
672            "properties": {
673                "target": {
674                    "type": "string",
675                    "description": "Branch name, commit, or file path to checkout"
676                },
677                "create": {
678                    "type": "boolean",
679                    "description": "Create a new branch (git checkout -b)"
680                },
681                "files": {
682                    "type": "array",
683                    "items": { "type": "string" },
684                    "description": "Specific files to restore (git checkout -- <files>)"
685                }
686            },
687            "required": ["target"]
688        })
689    }
690
691    async fn execute(&self, args: Value) -> crate::Result<Value> {
692        let target = args["target"]
693            .as_str()
694            .ok_or_else(|| crate::PawanError::Tool("target is required".into()))?;
695        let create = args["create"].as_bool().unwrap_or(false);
696        let files: Vec<&str> = args["files"]
697            .as_array()
698            .map(|a| a.iter().filter_map(|v| v.as_str()).collect())
699            .unwrap_or_default();
700
701        let mut git_args: Vec<&str> = vec!["checkout"];
702
703        if create {
704            git_args.push("-b");
705        }
706
707        git_args.push(target);
708
709        if !files.is_empty() {
710            git_args.push("--");
711            git_args.extend(files.iter());
712        }
713
714        let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args).await?;
715
716        if !success {
717            return Err(crate::PawanError::Git(format!(
718                "git checkout failed: {}",
719                stderr
720            )));
721        }
722
723        Ok(json!({
724            "success": true,
725            "target": target,
726            "created": create,
727            "output": format!("{}{}", stdout, stderr).trim().to_string()
728        }))
729    }
730}
731
732/// Tool for git stash operations
733pub struct GitStashTool {
734    workspace_root: PathBuf,
735}
736
737impl GitStashTool {
738    pub fn new(workspace_root: PathBuf) -> Self {
739        Self { workspace_root }
740    }
741}
742
743#[async_trait]
744impl Tool for GitStashTool {
745    fn name(&self) -> &str {
746        "git_stash"
747    }
748
749    fn description(&self) -> &str {
750        "Stash or restore uncommitted changes. Actions: push (default), pop, list, drop."
751    }
752
753    fn parameters_schema(&self) -> Value {
754        json!({
755            "type": "object",
756            "properties": {
757                "action": {
758                    "type": "string",
759                    "enum": ["push", "pop", "list", "drop", "show"],
760                    "description": "Stash action (default: push)"
761                },
762                "message": {
763                    "type": "string",
764                    "description": "Message for stash push"
765                },
766                "index": {
767                    "type": "integer",
768                    "description": "Stash index for pop/drop/show (default: 0)"
769                }
770            },
771            "required": []
772        })
773    }
774
775    async fn execute(&self, args: Value) -> crate::Result<Value> {
776        let action = args["action"].as_str().unwrap_or("push");
777        let message = args["message"].as_str();
778        let index = args["index"].as_u64().unwrap_or(0);
779
780        let git_args: Vec<String> = match action {
781            "push" => {
782                let mut a = vec!["stash".to_string(), "push".to_string()];
783                if let Some(msg) = message {
784                    a.push("-m".to_string());
785                    a.push(msg.to_string());
786                }
787                a
788            }
789            "pop" => vec!["stash".to_string(), "pop".to_string(), format!("stash@{{{}}}", index)],
790            "list" => vec!["stash".to_string(), "list".to_string()],
791            "drop" => vec!["stash".to_string(), "drop".to_string(), format!("stash@{{{}}}", index)],
792            "show" => vec!["stash".to_string(), "show".to_string(), "-p".to_string(), format!("stash@{{{}}}", index)],
793            _ => return Err(crate::PawanError::Tool(format!("Unknown stash action: {}", action))),
794        };
795
796        let git_args_ref: Vec<&str> = git_args.iter().map(|s| s.as_str()).collect();
797        let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args_ref).await?;
798
799        if !success {
800            return Err(crate::PawanError::Git(format!(
801                "git stash {} failed: {}",
802                action, stderr
803            )));
804        }
805
806        Ok(json!({
807            "success": true,
808            "action": action,
809            "output": stdout.trim().to_string()
810        }))
811    }
812}
813
814#[cfg(test)]
815mod tests {
816    use super::*;
817    use tempfile::TempDir;
818
819    async fn setup_git_repo() -> TempDir {
820        let temp_dir = TempDir::new().unwrap();
821
822        // Initialize git repo
823        let mut cmd = Command::new("git");
824        cmd.args(["init"])
825            .current_dir(temp_dir.path())
826            .output()
827            .await
828            .unwrap();
829
830        // Configure git for test
831        let mut cmd = Command::new("git");
832        cmd.args(["config", "user.email", "test@test.com"])
833            .current_dir(temp_dir.path())
834            .output()
835            .await
836            .unwrap();
837
838        let mut cmd = Command::new("git");
839        cmd.args(["config", "user.name", "Test User"])
840            .current_dir(temp_dir.path())
841            .output()
842            .await
843            .unwrap();
844
845        temp_dir
846    }
847
848    #[tokio::test]
849    async fn test_git_status_empty_repo() {
850        let temp_dir = setup_git_repo().await;
851
852        let tool = GitStatusTool::new(temp_dir.path().to_path_buf());
853        let result = tool.execute(json!({})).await.unwrap();
854
855        assert!(result["success"].as_bool().unwrap());
856        assert!(result["is_clean"].as_bool().unwrap());
857    }
858
859    #[tokio::test]
860    async fn test_git_status_with_untracked() {
861        let temp_dir = setup_git_repo().await;
862
863        // Create an untracked file
864        std::fs::write(temp_dir.path().join("test.txt"), "hello").unwrap();
865
866        let tool = GitStatusTool::new(temp_dir.path().to_path_buf());
867        let result = tool.execute(json!({})).await.unwrap();
868
869        assert!(result["success"].as_bool().unwrap());
870        assert!(!result["is_clean"].as_bool().unwrap());
871    }
872
873    #[tokio::test]
874    async fn test_git_add_and_commit() {
875        let temp_dir = setup_git_repo().await;
876
877        // Create a file
878        std::fs::write(temp_dir.path().join("test.txt"), "hello").unwrap();
879
880        // Add the file
881        let add_tool = GitAddTool::new(temp_dir.path().to_path_buf());
882        let add_result = add_tool
883            .execute(json!({
884                "files": ["test.txt"]
885            }))
886            .await
887            .unwrap();
888        assert!(add_result["success"].as_bool().unwrap());
889
890        // Commit
891        let commit_tool = GitCommitTool::new(temp_dir.path().to_path_buf());
892        let commit_result = commit_tool
893            .execute(json!({
894                "message": "Add test file"
895            }))
896            .await
897            .unwrap();
898        assert!(commit_result["success"].as_bool().unwrap());
899        assert!(!commit_result["commit_hash"].as_str().unwrap().is_empty());
900    }
901
902    #[tokio::test]
903    async fn test_git_diff_no_changes() {
904        let temp_dir = setup_git_repo().await;
905
906        let tool = GitDiffTool::new(temp_dir.path().to_path_buf());
907        let result = tool.execute(json!({})).await.unwrap();
908
909        assert!(result["success"].as_bool().unwrap());
910        assert!(!result["has_changes"].as_bool().unwrap());
911    }
912    #[tokio::test]
913    async fn test_git_status_tool_exists() {
914        let temp_dir = setup_git_repo().await;
915        let tool = GitStatusTool::new(temp_dir.path().to_path_buf());
916        assert_eq!(tool.name(), "git_status");
917    }
918
919    #[tokio::test]
920    async fn test_git_log_tool_exists() {
921        let temp_dir = setup_git_repo().await;
922        let tool = GitLogTool::new(temp_dir.path().to_path_buf());
923        assert_eq!(tool.name(), "git_log");
924    }
925
926    #[tokio::test]
927    async fn test_git_diff_schema() {
928        let temp_dir = setup_git_repo().await;
929        let tool = GitDiffTool::new(temp_dir.path().to_path_buf());
930        let schema = tool.parameters_schema();
931        // Check that schema has the expected parameters
932        let obj = schema.as_object().unwrap();
933        let props = obj.get("properties").unwrap().as_object().unwrap();
934        assert!(props.contains_key("staged"));
935        assert!(props.contains_key("file"));
936        assert!(props.contains_key("base"));
937        assert!(props.contains_key("stat"));
938    }
939}