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