1use 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
13async 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
38pub 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 }
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 let (_, branch_output, _) =
110 run_git(&self.workspace_root, &["branch", "--show-current"]).await?;
111 let branch = branch_output.trim().to_string();
112
113 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
126pub 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 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
249pub 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 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
353pub 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 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 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 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
453pub 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
552pub 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 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
651pub 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 let (_, current, _) = run_git(&self.workspace_root, &["branch", "--show-current"]).await?;
699 let current_branch = current.trim().to_string();
700
701 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
731pub 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
828pub 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 let mut cmd = Command::new("git");
933 cmd.args(["init"])
934 .current_dir(temp_dir.path())
935 .output()
936 .await
937 .unwrap();
938
939 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 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 std::fs::write(temp_dir.path().join("test.txt"), "hello").unwrap();
988
989 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[tokio::test]
1266 async fn test_git_add_neither_files_nor_all_returns_error() {
1267 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 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 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 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 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 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 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 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 let result = tool.execute(json!({ "count": 0 })).await;
1399 assert!(
1402 result.is_ok() || result.is_err(),
1403 "count=0 should not hang"
1404 );
1405 }
1406}