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 mutating(&self) -> bool {
66        false // Git status is read-only
67    }
68
69    fn parameters_schema(&self) -> Value {
70        json!({
71            "type": "object",
72            "properties": {
73                "short": {
74                    "type": "boolean",
75                    "description": "Use short format output (default: false)"
76                }
77            },
78            "required": []
79        })
80    }
81
82    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
83        use thulp_core::{Parameter, ParameterType};
84        thulp_core::ToolDefinition::builder("git_status")
85            .description(self.description())
86            .parameter(Parameter::builder("short").param_type(ParameterType::Boolean).required(false)
87                .description("Use short format output (default: false)").build())
88            .build()
89    }
90
91    async fn execute(&self, args: Value) -> crate::Result<Value> {
92        let short = args["short"].as_bool().unwrap_or(false);
93
94        let mut git_args = vec!["status"];
95        if short {
96            git_args.push("-s");
97        }
98
99        let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args).await?;
100
101        if !success {
102            return Err(crate::PawanError::Git(format!(
103                "git status failed: {}",
104                stderr
105            )));
106        }
107
108        // Also get branch info
109        let (_, branch_output, _) =
110            run_git(&self.workspace_root, &["branch", "--show-current"]).await?;
111        let branch = branch_output.trim().to_string();
112
113        // Check if repo is clean
114        let (_, porcelain, _) = run_git(&self.workspace_root, &["status", "--porcelain"]).await?;
115        let is_clean = porcelain.trim().is_empty();
116
117        Ok(json!({
118            "status": stdout.trim(),
119            "branch": branch,
120            "is_clean": is_clean,
121            "success": true
122        }))
123    }
124}
125
126/// Tool for getting git diff
127///
128/// This tool shows the differences between files in the working directory
129/// and the git index, or between commits.
130///
131/// # Fields
132/// - `workspace_root`: The root directory of the workspace
133pub struct GitDiffTool {
134    workspace_root: PathBuf,
135}
136
137impl GitDiffTool {
138    pub fn new(workspace_root: PathBuf) -> Self {
139        Self { workspace_root }
140    }
141}
142
143#[async_trait]
144impl Tool for GitDiffTool {
145    fn name(&self) -> &str {
146        "git_diff"
147    }
148
149    fn description(&self) -> &str {
150        "Show git diff for staged or unstaged changes. Can diff against a specific commit or branch."
151    }
152
153    fn parameters_schema(&self) -> Value {
154        json!({
155            "type": "object",
156            "properties": {
157                "staged": {
158                    "type": "boolean",
159                    "description": "Show staged changes only (--cached). Default: false (shows unstaged)"
160                },
161                "file": {
162                    "type": "string",
163                    "description": "Specific file to diff (optional)"
164                },
165                "base": {
166                    "type": "string",
167                    "description": "Base commit/branch to diff against (e.g., 'main', 'HEAD~3')"
168                },
169                "stat": {
170                    "type": "boolean",
171                    "description": "Show diffstat summary instead of full diff"
172                }
173            },
174            "required": []
175        })
176    }
177
178    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
179        use thulp_core::{Parameter, ParameterType};
180        thulp_core::ToolDefinition::builder("git_diff")
181            .description(self.description())
182            .parameter(Parameter::builder("staged").param_type(ParameterType::Boolean).required(false)
183                .description("Show staged changes only (--cached). Default: false (shows unstaged)").build())
184            .parameter(Parameter::builder("file").param_type(ParameterType::String).required(false)
185                .description("Specific file to diff (optional)").build())
186            .parameter(Parameter::builder("base").param_type(ParameterType::String).required(false)
187                .description("Base commit/branch to diff against (e.g., 'main', 'HEAD~3')").build())
188            .parameter(Parameter::builder("stat").param_type(ParameterType::Boolean).required(false)
189                .description("Show diffstat summary instead of full diff").build())
190            .build()
191    }
192
193    async fn execute(&self, args: Value) -> crate::Result<Value> {
194        let staged = args["staged"].as_bool().unwrap_or(false);
195        let file = args["file"].as_str();
196        let base = args["base"].as_str();
197        let stat = args["stat"].as_bool().unwrap_or(false);
198
199        let mut git_args = vec!["diff"];
200
201        if staged {
202            git_args.push("--cached");
203        }
204
205        if stat {
206            git_args.push("--stat");
207        }
208
209        if let Some(b) = base {
210            git_args.push(b);
211        }
212
213        if let Some(f) = file {
214            git_args.push("--");
215            git_args.push(f);
216        }
217
218        let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args).await?;
219
220        if !success {
221            return Err(crate::PawanError::Git(format!(
222                "git diff failed: {}",
223                stderr
224            )));
225        }
226
227        // Truncate if too large
228        let max_size = 100_000;
229        let truncated = stdout.len() > max_size;
230        let diff = if truncated {
231            format!(
232                "{}...\n[truncated, {} bytes total]",
233                &stdout[..max_size],
234                stdout.len()
235            )
236        } else {
237            stdout
238        };
239
240        Ok(json!({
241            "diff": diff,
242            "truncated": truncated,
243            "has_changes": !diff.trim().is_empty(),
244            "success": true
245        }))
246    }
247}
248
249/// Tool for staging files
250///
251/// This tool adds files to the git staging area in preparation for commit.
252///
253/// # Fields
254/// - `workspace_root`: The root directory of the workspace
255pub struct GitAddTool {
256    workspace_root: PathBuf,
257}
258
259impl GitAddTool {
260    pub fn new(workspace_root: PathBuf) -> Self {
261        Self { workspace_root }
262    }
263}
264
265#[async_trait]
266impl Tool for GitAddTool {
267    fn name(&self) -> &str {
268        "git_add"
269    }
270
271    fn description(&self) -> &str {
272        "Stage files for commit. Can stage specific files or all changes."
273    }
274
275    fn parameters_schema(&self) -> Value {
276        json!({
277            "type": "object",
278            "properties": {
279                "files": {
280                    "type": "array",
281                    "items": {"type": "string"},
282                    "description": "List of files to stage. Use [\".\"] to stage all changes."
283                },
284                "all": {
285                    "type": "boolean",
286                    "description": "Stage all changes including untracked files (-A)"
287                }
288            },
289            "required": []
290        })
291    }
292
293    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
294        use thulp_core::{Parameter, ParameterType};
295        thulp_core::ToolDefinition::builder("git_add")
296            .description(self.description())
297            .parameter(Parameter::builder("files").param_type(ParameterType::Array).required(false)
298                .description("List of files to stage. Use [\".\"] to stage all changes.").build())
299            .parameter(Parameter::builder("all").param_type(ParameterType::Boolean).required(false)
300                .description("Stage all changes including untracked files (-A)").build())
301            .build()
302    }
303
304    async fn execute(&self, args: Value) -> crate::Result<Value> {
305        let all = args["all"].as_bool().unwrap_or(false);
306        let files: Vec<&str> = args["files"]
307            .as_array()
308            .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
309            .unwrap_or_default();
310
311        let mut git_args = vec!["add"];
312
313        if all {
314            git_args.push("-A");
315        } else if files.is_empty() {
316            return Err(crate::PawanError::Tool(
317                "Either 'files' or 'all: true' must be specified".into(),
318            ));
319        } else {
320            for f in &files {
321                git_args.push(f);
322            }
323        }
324
325        let (success, _, stderr) = run_git(&self.workspace_root, &git_args).await?;
326
327        if !success {
328            return Err(crate::PawanError::Git(format!(
329                "git add failed: {}",
330                stderr
331            )));
332        }
333
334        // Get status after adding
335        let (_, status_output, _) = run_git(&self.workspace_root, &["status", "-s"]).await?;
336        let staged_count = status_output
337            .lines()
338            .filter(|l| l.starts_with('A') || l.starts_with('M') || l.starts_with('D'))
339            .count();
340
341        Ok(json!({
342            "success": true,
343            "staged_count": staged_count,
344            "message": if all {
345                "Staged all changes".to_string()
346            } else {
347                format!("Staged {} file(s)", files.len())
348            }
349        }))
350    }
351}
352
353/// Tool for creating commits
354///
355/// This tool creates a new git commit with the staged changes.
356///
357/// # Fields
358/// - `workspace_root`: The root directory of the workspace
359pub struct GitCommitTool {
360    workspace_root: PathBuf,
361}
362
363impl GitCommitTool {
364    pub fn new(workspace_root: PathBuf) -> Self {
365        Self { workspace_root }
366    }
367}
368
369#[async_trait]
370impl Tool for GitCommitTool {
371    fn name(&self) -> &str {
372        "git_commit"
373    }
374
375    fn description(&self) -> &str {
376        "Create a git commit with the staged changes. Requires a commit message."
377    }
378
379    fn parameters_schema(&self) -> Value {
380        json!({
381            "type": "object",
382            "properties": {
383                "message": {
384                    "type": "string",
385                    "description": "Commit message (required)"
386                },
387                "body": {
388                    "type": "string",
389                    "description": "Extended commit body (optional)"
390                }
391            },
392            "required": ["message"]
393        })
394    }
395
396    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
397        use thulp_core::{Parameter, ParameterType};
398        thulp_core::ToolDefinition::builder("git_commit")
399            .description(self.description())
400            .parameter(Parameter::builder("message").param_type(ParameterType::String).required(true)
401                .description("Commit message (required)").build())
402            .parameter(Parameter::builder("body").param_type(ParameterType::String).required(false)
403                .description("Extended commit body (optional)").build())
404            .build()
405    }
406
407    async fn execute(&self, args: Value) -> crate::Result<Value> {
408        let message = args["message"]
409            .as_str()
410            .ok_or_else(|| crate::PawanError::Tool("commit message is required".into()))?;
411
412        let body = args["body"].as_str();
413
414        // Check if there are staged changes
415        let (_, staged, _) = run_git(&self.workspace_root, &["diff", "--cached", "--stat"]).await?;
416        if staged.trim().is_empty() {
417            return Err(crate::PawanError::Git(
418                "No staged changes to commit. Use git_add first.".into(),
419            ));
420        }
421
422        // Build commit message
423        let full_message = if let Some(b) = body {
424            format!("{}\n\n{}", message, b)
425        } else {
426            message.to_string()
427        };
428
429        let (success, stdout, stderr) =
430            run_git(&self.workspace_root, &["commit", "-m", &full_message]).await?;
431
432        if !success {
433            return Err(crate::PawanError::Git(format!(
434                "git commit failed: {}",
435                stderr
436            )));
437        }
438
439        // Get the commit hash
440        let (_, hash_output, _) =
441            run_git(&self.workspace_root, &["rev-parse", "--short", "HEAD"]).await?;
442        let commit_hash = hash_output.trim().to_string();
443
444        Ok(json!({
445            "success": true,
446            "commit_hash": commit_hash,
447            "message": message,
448            "output": stdout.trim()
449        }))
450    }
451}
452
453/// Tool for viewing git log
454///
455/// This tool provides access to the git commit history, allowing inspection
456/// of previous commits, authors, dates, and commit messages.
457///
458/// # Fields
459/// - `workspace_root`: The root directory of the workspace
460pub struct GitLogTool {
461    workspace_root: PathBuf,
462}
463
464impl GitLogTool {
465    pub fn new(workspace_root: PathBuf) -> Self {
466        Self { workspace_root }
467    }
468}
469
470#[async_trait]
471impl Tool for GitLogTool {
472    fn name(&self) -> &str {
473        "git_log"
474    }
475
476    fn description(&self) -> &str {
477        "Show git commit history. Supports limiting count, filtering by file, and custom format."
478    }
479
480    fn parameters_schema(&self) -> Value {
481        json!({
482            "type": "object",
483            "properties": {
484                "count": {
485                    "type": "integer",
486                    "description": "Number of commits to show (default: 10)"
487                },
488                "file": {
489                    "type": "string",
490                    "description": "Show commits for a specific file"
491                },
492                "oneline": {
493                    "type": "boolean",
494                    "description": "Use compact one-line format (default: false)"
495                }
496            },
497            "required": []
498        })
499    }
500
501    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
502        use thulp_core::{Parameter, ParameterType};
503        thulp_core::ToolDefinition::builder("git_log")
504            .description(self.description())
505            .parameter(Parameter::builder("count").param_type(ParameterType::Integer).required(false)
506                .description("Number of commits to show (default: 10)").build())
507            .parameter(Parameter::builder("file").param_type(ParameterType::String).required(false)
508                .description("Show commits for a specific file").build())
509            .parameter(Parameter::builder("oneline").param_type(ParameterType::Boolean).required(false)
510                .description("Use compact one-line format (default: false)").build())
511            .build()
512    }
513
514    async fn execute(&self, args: Value) -> crate::Result<Value> {
515        let count = args["count"].as_u64().unwrap_or(10);
516        let file = args["file"].as_str();
517        let oneline = args["oneline"].as_bool().unwrap_or(false);
518
519        let count_str = count.to_string();
520        let mut git_args = vec!["log", "-n", &count_str];
521
522        if oneline {
523            git_args.push("--oneline");
524        } else {
525            git_args.extend_from_slice(&["--pretty=format:%h %an %ar %s"]);
526        }
527
528        if let Some(f) = file {
529            git_args.push("--");
530            git_args.push(f);
531        }
532
533        let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args).await?;
534
535        if !success {
536            return Err(crate::PawanError::Git(format!(
537                "git log failed: {}",
538                stderr
539            )));
540        }
541
542        let commit_count = stdout.lines().count();
543
544        Ok(json!({
545            "log": stdout.trim(),
546            "commit_count": commit_count,
547            "success": true
548        }))
549    }
550}
551
552/// Tool for git blame
553///
554/// This tool shows line-by-line authorship information for files, indicating
555/// who last modified each line and when.
556///
557/// # Fields
558/// - `workspace_root`: The root directory of the workspace
559pub struct GitBlameTool {
560    workspace_root: PathBuf,
561}
562
563impl GitBlameTool {
564    pub fn new(workspace_root: PathBuf) -> Self {
565        Self { workspace_root }
566    }
567}
568
569#[async_trait]
570impl Tool for GitBlameTool {
571    fn name(&self) -> &str {
572        "git_blame"
573    }
574
575    fn description(&self) -> &str {
576        "Show line-by-line authorship of a file. Useful for understanding who changed what."
577    }
578
579    fn parameters_schema(&self) -> Value {
580        json!({
581            "type": "object",
582            "properties": {
583                "file": {
584                    "type": "string",
585                    "description": "File to blame (required)"
586                },
587                "lines": {
588                    "type": "string",
589                    "description": "Line range, e.g., '10,20' for lines 10-20"
590                }
591            },
592            "required": ["file"]
593        })
594    }
595
596    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
597        use thulp_core::{Parameter, ParameterType};
598        thulp_core::ToolDefinition::builder("git_blame")
599            .description(self.description())
600            .parameter(Parameter::builder("file").param_type(ParameterType::String).required(true)
601                .description("File to blame (required)").build())
602            .parameter(Parameter::builder("lines").param_type(ParameterType::String).required(false)
603                .description("Line range, e.g., '10,20' for lines 10-20").build())
604            .build()
605    }
606
607    async fn execute(&self, args: Value) -> crate::Result<Value> {
608        let file = args["file"]
609            .as_str()
610            .ok_or_else(|| crate::PawanError::Tool("file is required for git_blame".into()))?;
611        let lines = args["lines"].as_str();
612
613        let mut git_args = vec!["blame", "--porcelain"];
614
615        let line_range;
616        if let Some(l) = lines {
617            line_range = format!("-L{}", l);
618            git_args.push(&line_range);
619        }
620
621        git_args.push(file);
622
623        let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args).await?;
624
625        if !success {
626            return Err(crate::PawanError::Git(format!(
627                "git blame failed: {}",
628                stderr
629            )));
630        }
631
632        // Truncate if too large
633        let max_size = 50_000;
634        let output = if stdout.len() > max_size {
635            format!(
636                "{}...\n[truncated, {} bytes total]",
637                &stdout[..max_size],
638                stdout.len()
639            )
640        } else {
641            stdout
642        };
643
644        Ok(json!({
645            "blame": output.trim(),
646            "success": true
647        }))
648    }
649}
650
651/// Tool for listing and managing branches
652pub struct GitBranchTool {
653    workspace_root: PathBuf,
654}
655
656impl GitBranchTool {
657    pub fn new(workspace_root: PathBuf) -> Self {
658        Self { workspace_root }
659    }
660}
661
662#[async_trait]
663impl Tool for GitBranchTool {
664    fn name(&self) -> &str {
665        "git_branch"
666    }
667
668    fn description(&self) -> &str {
669        "List branches or get current branch name. Shows local and optionally remote branches."
670    }
671
672    fn parameters_schema(&self) -> Value {
673        json!({
674            "type": "object",
675            "properties": {
676                "all": {
677                    "type": "boolean",
678                    "description": "Show both local and remote branches (default: false)"
679                }
680            },
681            "required": []
682        })
683    }
684
685    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
686        use thulp_core::{Parameter, ParameterType};
687        thulp_core::ToolDefinition::builder("git_branch")
688            .description(self.description())
689            .parameter(Parameter::builder("all").param_type(ParameterType::Boolean).required(false)
690                .description("Show both local and remote branches (default: false)").build())
691            .build()
692    }
693
694    async fn execute(&self, args: Value) -> crate::Result<Value> {
695        let all = args["all"].as_bool().unwrap_or(false);
696
697        // Get current branch
698        let (_, current, _) = run_git(&self.workspace_root, &["branch", "--show-current"]).await?;
699        let current_branch = current.trim().to_string();
700
701        // List branches
702        let mut git_args = vec!["branch", "--format=%(refname:short)"];
703        if all {
704            git_args.push("-a");
705        }
706
707        let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args).await?;
708
709        if !success {
710            return Err(crate::PawanError::Git(format!(
711                "git branch failed: {}",
712                stderr
713            )));
714        }
715
716        let branches: Vec<&str> = stdout
717            .lines()
718            .map(|l| l.trim())
719            .filter(|l| !l.is_empty())
720            .collect();
721
722        Ok(json!({
723            "current": current_branch,
724            "branches": branches,
725            "count": branches.len(),
726            "success": true
727        }))
728    }
729}
730
731/// Tool for git checkout (switch branches or restore files)
732pub struct GitCheckoutTool {
733    workspace_root: PathBuf,
734}
735
736impl GitCheckoutTool {
737    pub fn new(workspace_root: PathBuf) -> Self {
738        Self { workspace_root }
739    }
740}
741
742#[async_trait]
743impl Tool for GitCheckoutTool {
744    fn name(&self) -> &str {
745        "git_checkout"
746    }
747
748    fn description(&self) -> &str {
749        "Switch branches or restore working tree files. Can create new branches with create=true."
750    }
751
752    fn parameters_schema(&self) -> Value {
753        json!({
754            "type": "object",
755            "properties": {
756                "target": {
757                    "type": "string",
758                    "description": "Branch name, commit, or file path to checkout"
759                },
760                "create": {
761                    "type": "boolean",
762                    "description": "Create a new branch (git checkout -b)"
763                },
764                "files": {
765                    "type": "array",
766                    "items": { "type": "string" },
767                    "description": "Specific files to restore (git checkout -- <files>)"
768                }
769            },
770            "required": ["target"]
771        })
772    }
773
774    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
775        use thulp_core::{Parameter, ParameterType};
776        thulp_core::ToolDefinition::builder("git_checkout")
777            .description(self.description())
778            .parameter(Parameter::builder("target").param_type(ParameterType::String).required(true)
779                .description("Branch name, commit, or file path to checkout").build())
780            .parameter(Parameter::builder("create").param_type(ParameterType::Boolean).required(false)
781                .description("Create a new branch (git checkout -b)").build())
782            .parameter(Parameter::builder("files").param_type(ParameterType::Array).required(false)
783                .description("Specific files to restore (git checkout -- <files>)").build())
784            .build()
785    }
786
787    async fn execute(&self, args: Value) -> crate::Result<Value> {
788        let target = args["target"]
789            .as_str()
790            .ok_or_else(|| crate::PawanError::Tool("target is required".into()))?;
791        let create = args["create"].as_bool().unwrap_or(false);
792        let files: Vec<&str> = args["files"]
793            .as_array()
794            .map(|a| a.iter().filter_map(|v| v.as_str()).collect())
795            .unwrap_or_default();
796
797        let mut git_args: Vec<&str> = vec!["checkout"];
798
799        if create {
800            git_args.push("-b");
801        }
802
803        git_args.push(target);
804
805        if !files.is_empty() {
806            git_args.push("--");
807            git_args.extend(files.iter());
808        }
809
810        let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args).await?;
811
812        if !success {
813            return Err(crate::PawanError::Git(format!(
814                "git checkout failed: {}",
815                stderr
816            )));
817        }
818
819        Ok(json!({
820            "success": true,
821            "target": target,
822            "created": create,
823            "output": format!("{}{}", stdout, stderr).trim().to_string()
824        }))
825    }
826}
827
828/// Tool for git stash operations
829pub struct GitStashTool {
830    workspace_root: PathBuf,
831}
832
833impl GitStashTool {
834    pub fn new(workspace_root: PathBuf) -> Self {
835        Self { workspace_root }
836    }
837}
838
839#[async_trait]
840impl Tool for GitStashTool {
841    fn name(&self) -> &str {
842        "git_stash"
843    }
844
845    fn description(&self) -> &str {
846        "Stash or restore uncommitted changes. Actions: push (default), pop, list, drop."
847    }
848
849    fn parameters_schema(&self) -> Value {
850        json!({
851            "type": "object",
852            "properties": {
853                "action": {
854                    "type": "string",
855                    "enum": ["push", "pop", "list", "drop", "show"],
856                    "description": "Stash action (default: push)"
857                },
858                "message": {
859                    "type": "string",
860                    "description": "Message for stash push"
861                },
862                "index": {
863                    "type": "integer",
864                    "description": "Stash index for pop/drop/show (default: 0)"
865                }
866            },
867            "required": []
868        })
869    }
870
871    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
872        use thulp_core::{Parameter, ParameterType};
873        thulp_core::ToolDefinition::builder("git_stash")
874            .description(self.description())
875            .parameter(Parameter::builder("action").param_type(ParameterType::String).required(false)
876                .description("Stash action (default: push)").build())
877            .parameter(Parameter::builder("message").param_type(ParameterType::String).required(false)
878                .description("Message for stash push").build())
879            .parameter(Parameter::builder("index").param_type(ParameterType::Integer).required(false)
880                .description("Stash index for pop/drop/show (default: 0)").build())
881            .build()
882    }
883
884    async fn execute(&self, args: Value) -> crate::Result<Value> {
885        let action = args["action"].as_str().unwrap_or("push");
886        let message = args["message"].as_str();
887        let index = args["index"].as_u64().unwrap_or(0);
888
889        let git_args: Vec<String> = match action {
890            "push" => {
891                let mut a = vec!["stash".to_string(), "push".to_string()];
892                if let Some(msg) = message {
893                    a.push("-m".to_string());
894                    a.push(msg.to_string());
895                }
896                a
897            }
898            "pop" => vec!["stash".to_string(), "pop".to_string(), format!("stash@{{{}}}", index)],
899            "list" => vec!["stash".to_string(), "list".to_string()],
900            "drop" => vec!["stash".to_string(), "drop".to_string(), format!("stash@{{{}}}", index)],
901            "show" => vec!["stash".to_string(), "show".to_string(), "-p".to_string(), format!("stash@{{{}}}", index)],
902            _ => return Err(crate::PawanError::Tool(format!("Unknown stash action: {}", action))),
903        };
904
905        let git_args_ref: Vec<&str> = git_args.iter().map(|s| s.as_str()).collect();
906        let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args_ref).await?;
907
908        if !success {
909            return Err(crate::PawanError::Git(format!(
910                "git stash {} failed: {}",
911                action, stderr
912            )));
913        }
914
915        Ok(json!({
916            "success": true,
917            "action": action,
918            "output": stdout.trim().to_string()
919        }))
920    }
921}
922
923#[cfg(test)]
924mod tests {
925    use super::*;
926    use tempfile::TempDir;
927
928    async fn setup_git_repo() -> TempDir {
929        let temp_dir = TempDir::new().unwrap();
930
931        // Initialize git repo
932        let mut cmd = Command::new("git");
933        cmd.args(["init"])
934            .current_dir(temp_dir.path())
935            .output()
936            .await
937            .unwrap();
938
939        // Configure git for test
940        let mut cmd = Command::new("git");
941        cmd.args(["config", "user.email", "test@test.com"])
942            .current_dir(temp_dir.path())
943            .output()
944            .await
945            .unwrap();
946
947        let mut cmd = Command::new("git");
948        cmd.args(["config", "user.name", "Test User"])
949            .current_dir(temp_dir.path())
950            .output()
951            .await
952            .unwrap();
953
954        temp_dir
955    }
956
957    #[tokio::test]
958    async fn test_git_status_empty_repo() {
959        let temp_dir = setup_git_repo().await;
960
961        let tool = GitStatusTool::new(temp_dir.path().to_path_buf());
962        let result = tool.execute(json!({})).await.unwrap();
963
964        assert!(result["success"].as_bool().unwrap());
965        assert!(result["is_clean"].as_bool().unwrap());
966    }
967
968    #[tokio::test]
969    async fn test_git_status_with_untracked() {
970        let temp_dir = setup_git_repo().await;
971
972        // Create an untracked file
973        std::fs::write(temp_dir.path().join("test.txt"), "hello").unwrap();
974
975        let tool = GitStatusTool::new(temp_dir.path().to_path_buf());
976        let result = tool.execute(json!({})).await.unwrap();
977
978        assert!(result["success"].as_bool().unwrap());
979        assert!(!result["is_clean"].as_bool().unwrap());
980    }
981
982    #[tokio::test]
983    async fn test_git_add_and_commit() {
984        let temp_dir = setup_git_repo().await;
985
986        // Create a file
987        std::fs::write(temp_dir.path().join("test.txt"), "hello").unwrap();
988
989        // Add the file
990        let add_tool = GitAddTool::new(temp_dir.path().to_path_buf());
991        let add_result = add_tool
992            .execute(json!({
993                "files": ["test.txt"]
994            }))
995            .await
996            .unwrap();
997        assert!(add_result["success"].as_bool().unwrap());
998
999        // Commit
1000        let commit_tool = GitCommitTool::new(temp_dir.path().to_path_buf());
1001        let commit_result = commit_tool
1002            .execute(json!({
1003                "message": "Add test file"
1004            }))
1005            .await
1006            .unwrap();
1007        assert!(commit_result["success"].as_bool().unwrap());
1008        assert!(!commit_result["commit_hash"].as_str().unwrap().is_empty());
1009    }
1010
1011    #[tokio::test]
1012    async fn test_git_diff_no_changes() {
1013        let temp_dir = setup_git_repo().await;
1014
1015        let tool = GitDiffTool::new(temp_dir.path().to_path_buf());
1016        let result = tool.execute(json!({})).await.unwrap();
1017
1018        assert!(result["success"].as_bool().unwrap());
1019        assert!(!result["has_changes"].as_bool().unwrap());
1020    }
1021    #[tokio::test]
1022    async fn test_git_status_tool_exists() {
1023        let temp_dir = setup_git_repo().await;
1024        let tool = GitStatusTool::new(temp_dir.path().to_path_buf());
1025        assert_eq!(tool.name(), "git_status");
1026    }
1027
1028    #[tokio::test]
1029    async fn test_git_log_tool_exists() {
1030        let temp_dir = setup_git_repo().await;
1031        let tool = GitLogTool::new(temp_dir.path().to_path_buf());
1032        assert_eq!(tool.name(), "git_log");
1033    }
1034
1035    #[tokio::test]
1036    async fn test_git_diff_schema() {
1037        let temp_dir = setup_git_repo().await;
1038        let tool = GitDiffTool::new(temp_dir.path().to_path_buf());
1039        let schema = tool.parameters_schema();
1040        let obj = schema.as_object().unwrap();
1041        let props = obj.get("properties").unwrap().as_object().unwrap();
1042        assert!(props.contains_key("staged"));
1043        assert!(props.contains_key("file"));
1044        assert!(props.contains_key("base"));
1045        assert!(props.contains_key("stat"));
1046    }
1047
1048    #[tokio::test]
1049    async fn test_git_diff_with_changes() {
1050        let temp_dir = setup_git_repo().await;
1051        // Create, add, commit a file
1052        std::fs::write(temp_dir.path().join("f.txt"), "original").unwrap();
1053        Command::new("git").args(["add", "."]).current_dir(temp_dir.path()).output().await.unwrap();
1054        Command::new("git").args(["commit", "-m", "init"]).current_dir(temp_dir.path()).output().await.unwrap();
1055        // Modify the file
1056        std::fs::write(temp_dir.path().join("f.txt"), "modified").unwrap();
1057
1058        let tool = GitDiffTool::new(temp_dir.path().into());
1059        let result = tool.execute(json!({})).await.unwrap();
1060        assert!(result["success"].as_bool().unwrap());
1061        assert!(result["has_changes"].as_bool().unwrap());
1062        assert!(result["diff"].as_str().unwrap().contains("modified"));
1063    }
1064
1065    #[tokio::test]
1066    async fn test_git_log_with_commits() {
1067        let temp_dir = setup_git_repo().await;
1068        std::fs::write(temp_dir.path().join("a.txt"), "a").unwrap();
1069        Command::new("git").args(["add", "."]).current_dir(temp_dir.path()).output().await.unwrap();
1070        Command::new("git").args(["commit", "-m", "first commit"]).current_dir(temp_dir.path()).output().await.unwrap();
1071        std::fs::write(temp_dir.path().join("b.txt"), "b").unwrap();
1072        Command::new("git").args(["add", "."]).current_dir(temp_dir.path()).output().await.unwrap();
1073        Command::new("git").args(["commit", "-m", "second commit"]).current_dir(temp_dir.path()).output().await.unwrap();
1074
1075        let tool = GitLogTool::new(temp_dir.path().into());
1076        let result = tool.execute(json!({"count": 5})).await.unwrap();
1077        assert!(result["success"].as_bool().unwrap());
1078        let log = result["log"].as_str().unwrap();
1079        assert!(log.contains("first commit"));
1080        assert!(log.contains("second commit"));
1081    }
1082
1083    #[tokio::test]
1084    async fn test_git_branch_list() {
1085        let temp_dir = setup_git_repo().await;
1086        std::fs::write(temp_dir.path().join("f.txt"), "init").unwrap();
1087        Command::new("git").args(["add", "."]).current_dir(temp_dir.path()).output().await.unwrap();
1088        Command::new("git").args(["commit", "-m", "init"]).current_dir(temp_dir.path()).output().await.unwrap();
1089
1090        let tool = GitBranchTool::new(temp_dir.path().into());
1091        let result = tool.execute(json!({})).await.unwrap();
1092        assert!(result["success"].as_bool().unwrap());
1093        let branches = result["branches"].as_array().unwrap();
1094        assert!(!branches.is_empty(), "Should have at least one branch");
1095        assert!(result["current"].as_str().is_some());
1096    }
1097
1098    #[tokio::test]
1099    async fn test_git_checkout_create_branch() {
1100        let temp_dir = setup_git_repo().await;
1101        std::fs::write(temp_dir.path().join("f.txt"), "init").unwrap();
1102        Command::new("git").args(["add", "."]).current_dir(temp_dir.path()).output().await.unwrap();
1103        Command::new("git").args(["commit", "-m", "init"]).current_dir(temp_dir.path()).output().await.unwrap();
1104
1105        let tool = GitCheckoutTool::new(temp_dir.path().into());
1106        let result = tool.execute(json!({"target": "feature-test", "create": true})).await.unwrap();
1107        assert!(result["success"].as_bool().unwrap());
1108
1109        // Verify we're on the new branch
1110        let branch_tool = GitBranchTool::new(temp_dir.path().into());
1111        let branches = branch_tool.execute(json!({})).await.unwrap();
1112        assert_eq!(branches["current"].as_str().unwrap(), "feature-test");
1113    }
1114
1115    #[tokio::test]
1116    async fn test_git_stash_on_clean_repo() {
1117        let temp_dir = setup_git_repo().await;
1118        let tool = GitStashTool::new(temp_dir.path().into());
1119        // List stashes on empty repo
1120        let result = tool.execute(json!({"action": "list"})).await.unwrap();
1121        assert!(result["success"].as_bool().unwrap());
1122    }
1123
1124    #[tokio::test]
1125    async fn test_git_blame_requires_file() {
1126        let temp_dir = setup_git_repo().await;
1127        let tool = GitBlameTool::new(temp_dir.path().into());
1128        let result = tool.execute(json!({})).await;
1129        assert!(result.is_err(), "blame without file should error");
1130    }
1131
1132    #[tokio::test]
1133    async fn test_git_tool_schemas() {
1134        let tmp = TempDir::new().unwrap();
1135        // Verify all git tools have correct names and non-empty schemas
1136        let tools: Vec<(&str, Box<dyn Tool>)> = vec![
1137            ("git_status", Box::new(GitStatusTool::new(tmp.path().into()))),
1138            ("git_diff", Box::new(GitDiffTool::new(tmp.path().into()))),
1139            ("git_add", Box::new(GitAddTool::new(tmp.path().into()))),
1140            ("git_commit", Box::new(GitCommitTool::new(tmp.path().into()))),
1141            ("git_log", Box::new(GitLogTool::new(tmp.path().into()))),
1142            ("git_blame", Box::new(GitBlameTool::new(tmp.path().into()))),
1143            ("git_branch", Box::new(GitBranchTool::new(tmp.path().into()))),
1144            ("git_checkout", Box::new(GitCheckoutTool::new(tmp.path().into()))),
1145            ("git_stash", Box::new(GitStashTool::new(tmp.path().into()))),
1146        ];
1147        for (expected_name, tool) in &tools {
1148            assert_eq!(tool.name(), *expected_name, "Tool name mismatch");
1149            assert!(!tool.description().is_empty(), "Missing description for {}", expected_name);
1150            let schema = tool.parameters_schema();
1151            assert!(schema.is_object(), "Schema should be object for {}", expected_name);
1152        }
1153    }
1154
1155    #[tokio::test]
1156    async fn test_git_commit_missing_message_errors() {
1157        let temp_dir = setup_git_repo().await;
1158        let tool = GitCommitTool::new(temp_dir.path().to_path_buf());
1159        // No "message" field
1160        let result = tool.execute(json!({})).await;
1161        assert!(result.is_err(), "commit without message must error");
1162    }
1163
1164    #[tokio::test]
1165    async fn test_git_commit_multiline_message_preserved() {
1166        let temp_dir = setup_git_repo().await;
1167        std::fs::write(temp_dir.path().join("a.txt"), "content").unwrap();
1168
1169        GitAddTool::new(temp_dir.path().to_path_buf())
1170            .execute(json!({ "files": ["a.txt"] }))
1171            .await
1172            .unwrap();
1173
1174        // Commit with a message that has newlines, backticks, dollars,
1175        // and quotes — everything that could break shell escaping.
1176        let message = "feat: the subject line\n\nThis is the body.\nIt has `backticks`, $dollars, and \"quotes\".\n\nCo-Authored-By: Test <test@example.com>";
1177        let commit_result = GitCommitTool::new(temp_dir.path().to_path_buf())
1178            .execute(json!({ "message": message }))
1179            .await
1180            .unwrap();
1181        assert!(commit_result["success"].as_bool().unwrap());
1182
1183        // Read the commit message back via git log
1184        let log_result = GitLogTool::new(temp_dir.path().to_path_buf())
1185            .execute(json!({ "count": 1 }))
1186            .await
1187            .unwrap();
1188        let log_str = format!("{}", log_result);
1189        assert!(
1190            log_str.contains("the subject line"),
1191            "log should contain subject line, got: {}",
1192            log_str
1193        );
1194    }
1195
1196    #[tokio::test]
1197    async fn test_git_stash_on_dirty_repo_saves_changes() {
1198        let temp_dir = setup_git_repo().await;
1199        // First commit a base file
1200        std::fs::write(temp_dir.path().join("base.txt"), "v1").unwrap();
1201        GitAddTool::new(temp_dir.path().to_path_buf())
1202            .execute(json!({ "files": ["base.txt"] }))
1203            .await
1204            .unwrap();
1205        GitCommitTool::new(temp_dir.path().to_path_buf())
1206            .execute(json!({ "message": "base" }))
1207            .await
1208            .unwrap();
1209
1210        // Now modify it so there's something to stash
1211        std::fs::write(temp_dir.path().join("base.txt"), "v2-dirty").unwrap();
1212
1213        let stash_tool = GitStashTool::new(temp_dir.path().to_path_buf());
1214        let result = stash_tool
1215            .execute(json!({ "action": "push", "message": "test stash" }))
1216            .await
1217            .unwrap();
1218        assert!(result["success"].as_bool().unwrap());
1219
1220        // Working tree should be clean again (stash popped the change)
1221        let content = std::fs::read_to_string(temp_dir.path().join("base.txt")).unwrap();
1222        assert_eq!(content, "v1", "stash push should revert working tree");
1223    }
1224
1225    #[tokio::test]
1226    async fn test_git_log_with_count_limit() {
1227        let temp_dir = setup_git_repo().await;
1228        // Make 3 commits
1229        for i in 1..=3 {
1230            std::fs::write(
1231                temp_dir.path().join(format!("file{i}.txt")),
1232                format!("v{i}"),
1233            )
1234            .unwrap();
1235            GitAddTool::new(temp_dir.path().to_path_buf())
1236                .execute(json!({ "files": [format!("file{i}.txt")] }))
1237                .await
1238                .unwrap();
1239            GitCommitTool::new(temp_dir.path().to_path_buf())
1240                .execute(json!({ "message": format!("commit {i}") }))
1241                .await
1242                .unwrap();
1243        }
1244
1245        // Log with count=2 should only return 2 commits (one line per commit
1246        // under --pretty=format:%h %an %ar %s)
1247        let tool = GitLogTool::new(temp_dir.path().to_path_buf());
1248        let result = tool.execute(json!({ "count": 2 })).await.unwrap();
1249        assert!(result["success"].as_bool().unwrap());
1250        assert_eq!(
1251            result["commit_count"].as_u64().unwrap(),
1252            2,
1253            "count=2 should return exactly 2 commits, got: {}",
1254            result["log"].as_str().unwrap_or("")
1255        );
1256        // Sanity check: the log string should mention the 2 most recent commits
1257        let log = result["log"].as_str().unwrap();
1258        assert!(log.contains("commit 3"), "expected 'commit 3' in log, got: {}", log);
1259        assert!(log.contains("commit 2"), "expected 'commit 2' in log, got: {}", log);
1260        assert!(!log.contains("commit 1"), "'commit 1' should be excluded by count=2, got: {}", log);
1261    }
1262
1263    // ─── Edge cases for git tools (task #22/git) ────────────────────────
1264
1265    #[tokio::test]
1266    async fn test_git_add_neither_files_nor_all_returns_error() {
1267        // GitAddTool requires either `files` (non-empty) or `all: true`.
1268        // Omitting both must return a specific error message.
1269        let temp_dir = setup_git_repo().await;
1270        let tool = GitAddTool::new(temp_dir.path().to_path_buf());
1271        let result = tool.execute(json!({})).await;
1272        assert!(result.is_err(), "git_add with no args must return Err");
1273        let err = format!("{}", result.unwrap_err());
1274        assert!(
1275            err.contains("files") && err.contains("all"),
1276            "error must mention both 'files' and 'all', got: {}",
1277            err
1278        );
1279    }
1280
1281    #[tokio::test]
1282    async fn test_git_add_all_without_files_list_succeeds() {
1283        // all=true should work even when `files` is not specified at all.
1284        // This tests the early branch that skips the empty-files check.
1285        let temp_dir = setup_git_repo().await;
1286        std::fs::write(temp_dir.path().join("x.txt"), "a").unwrap();
1287        std::fs::write(temp_dir.path().join("y.txt"), "b").unwrap();
1288
1289        let tool = GitAddTool::new(temp_dir.path().to_path_buf());
1290        let result = tool.execute(json!({ "all": true })).await.unwrap();
1291        assert!(result["success"].as_bool().unwrap());
1292        assert!(
1293            result["message"]
1294                .as_str()
1295                .unwrap()
1296                .contains("Staged all changes"),
1297            "all=true should report 'Staged all changes'"
1298        );
1299    }
1300
1301    #[tokio::test]
1302    async fn test_git_add_empty_files_array_returns_error() {
1303        // files=[] with no all flag must ALSO error (empty array is falsy).
1304        let temp_dir = setup_git_repo().await;
1305        let tool = GitAddTool::new(temp_dir.path().to_path_buf());
1306        let result = tool.execute(json!({ "files": [] })).await;
1307        assert!(
1308            result.is_err(),
1309            "empty files array + no all flag must error"
1310        );
1311    }
1312
1313    #[tokio::test]
1314    async fn test_git_checkout_nonexistent_branch_without_create_errors() {
1315        // Checkout to a non-existent branch WITHOUT create=true must fail,
1316        // not silently create it. This pins the "safety" contract of the tool.
1317        let temp_dir = setup_git_repo().await;
1318        std::fs::write(temp_dir.path().join("init.txt"), "init").unwrap();
1319        Command::new("git")
1320            .args(["add", "."])
1321            .current_dir(temp_dir.path())
1322            .output()
1323            .await
1324            .unwrap();
1325        Command::new("git")
1326            .args(["commit", "-m", "init"])
1327            .current_dir(temp_dir.path())
1328            .output()
1329            .await
1330            .unwrap();
1331
1332        let tool = GitCheckoutTool::new(temp_dir.path().to_path_buf());
1333        let result = tool
1334            .execute(json!({
1335                "target": "nonexistent-branch-xyz-abc-9999",
1336                "create": false
1337            }))
1338            .await;
1339        assert!(
1340            result.is_err(),
1341            "checkout to nonexistent branch without create must error"
1342        );
1343    }
1344
1345    #[tokio::test]
1346    async fn test_git_status_detects_modified_file() {
1347        // GitStatusTool should report modified files that were previously committed
1348        let temp_dir = setup_git_repo().await;
1349        std::fs::write(temp_dir.path().join("tracked.txt"), "v1").unwrap();
1350        Command::new("git")
1351            .args(["add", "."])
1352            .current_dir(temp_dir.path())
1353            .output()
1354            .await
1355            .unwrap();
1356        Command::new("git")
1357            .args(["commit", "-m", "init tracked"])
1358            .current_dir(temp_dir.path())
1359            .output()
1360            .await
1361            .unwrap();
1362
1363        // Modify the tracked file
1364        std::fs::write(temp_dir.path().join("tracked.txt"), "v2").unwrap();
1365
1366        let tool = GitStatusTool::new(temp_dir.path().to_path_buf());
1367        let result = tool.execute(json!({})).await.unwrap();
1368        // Verify the status includes the modified file
1369        let serialized = result.to_string();
1370        assert!(
1371            serialized.contains("tracked.txt"),
1372            "status must mention modified tracked.txt, got: {}",
1373            serialized
1374        );
1375    }
1376
1377    #[tokio::test]
1378    async fn test_git_log_count_zero_uses_default_or_errors() {
1379        // count=0 is an unusual value — test that it either uses a default
1380        // or errors rather than returning unbounded output.
1381        let temp_dir = setup_git_repo().await;
1382        std::fs::write(temp_dir.path().join("f.txt"), "init").unwrap();
1383        Command::new("git")
1384            .args(["add", "."])
1385            .current_dir(temp_dir.path())
1386            .output()
1387            .await
1388            .unwrap();
1389        Command::new("git")
1390            .args(["commit", "-m", "init"])
1391            .current_dir(temp_dir.path())
1392            .output()
1393            .await
1394            .unwrap();
1395
1396        let tool = GitLogTool::new(temp_dir.path().to_path_buf());
1397        // count=0 — observe current behavior (documented pin)
1398        let result = tool.execute(json!({ "count": 0 })).await;
1399        // Either succeeds with default count OR errors — both are acceptable,
1400        // as long as it doesn't hang or return unbounded output
1401        assert!(
1402            result.is_ok() || result.is_err(),
1403            "count=0 should not hang"
1404        );
1405    }
1406}