Skip to main content

pawan/tools/git/
diff.rs

1use super::super::Tool;
2use super::run_git;
3use async_trait::async_trait;
4use serde_json::{json, Value};
5use std::path::PathBuf;
6
7/// Tool for getting git diff
8///
9/// This tool shows the differences between files in the working directory
10/// and the git index, or between commits.
11///
12/// # Fields
13/// - `workspace_root`: The root directory of the workspace
14pub struct GitDiffTool {
15    workspace_root: PathBuf,
16}
17
18impl GitDiffTool {
19    pub fn new(workspace_root: PathBuf) -> Self {
20        Self { workspace_root }
21    }
22}
23
24#[async_trait]
25impl Tool for GitDiffTool {
26    fn name(&self) -> &str {
27        "git_diff"
28    }
29
30    fn description(&self) -> &str {
31        "Show git diff for staged or unstaged changes. Can diff against a specific commit or branch."
32    }
33
34    fn parameters_schema(&self) -> Value {
35        json!({
36            "type": "object",
37            "properties": {
38                "staged": {
39                    "type": "boolean",
40                    "description": "Show staged changes only (--cached). Default: false (shows unstaged)"
41                },
42                "file": {
43                    "type": "string",
44                    "description": "Specific file to diff (optional)"
45                },
46                "base": {
47                    "type": "string",
48                    "description": "Base commit/branch to diff against (e.g., 'main', 'HEAD~3')"
49                },
50                "stat": {
51                    "type": "boolean",
52                    "description": "Show diffstat summary instead of full diff"
53                }
54            },
55            "required": []
56        })
57    }
58
59    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
60        use thulp_core::{Parameter, ParameterType};
61        thulp_core::ToolDefinition::builder("git_diff")
62            .description(self.description())
63            .parameter(
64                Parameter::builder("staged")
65                    .param_type(ParameterType::Boolean)
66                    .required(false)
67                    .description(
68                        "Show staged changes only (--cached). Default: false (shows unstaged)",
69                    )
70                    .build(),
71            )
72            .parameter(
73                Parameter::builder("file")
74                    .param_type(ParameterType::String)
75                    .required(false)
76                    .description("Specific file to diff (optional)")
77                    .build(),
78            )
79            .parameter(
80                Parameter::builder("base")
81                    .param_type(ParameterType::String)
82                    .required(false)
83                    .description("Base commit/branch to diff against (e.g., 'main', 'HEAD~3')")
84                    .build(),
85            )
86            .parameter(
87                Parameter::builder("stat")
88                    .param_type(ParameterType::Boolean)
89                    .required(false)
90                    .description("Show diffstat summary instead of full diff")
91                    .build(),
92            )
93            .build()
94    }
95
96    async fn execute(&self, args: Value) -> crate::Result<Value> {
97        let staged = args["staged"].as_bool().unwrap_or(false);
98        let file = args["file"].as_str();
99        let base = args["base"].as_str();
100        let stat = args["stat"].as_bool().unwrap_or(false);
101
102        let mut git_args = vec!["diff"];
103
104        if staged {
105            git_args.push("--cached");
106        }
107
108        if stat {
109            git_args.push("--stat");
110        }
111
112        if let Some(b) = base {
113            git_args.push(b);
114        }
115
116        if let Some(f) = file {
117            git_args.push("--");
118            git_args.push(f);
119        }
120
121        let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args).await?;
122
123        if !success {
124            return Err(crate::PawanError::Git(format!(
125                "git diff failed: {}",
126                stderr
127            )));
128        }
129
130        // Truncate if too large
131        let max_size = 100_000;
132        let truncated = stdout.len() > max_size;
133        let diff = if truncated {
134            format!(
135                "{}...\n[truncated, {} bytes total]",
136                &stdout[..max_size],
137                stdout.len()
138            )
139        } else {
140            stdout
141        };
142
143        Ok(json!({
144            "diff": diff,
145            "truncated": truncated,
146            "has_changes": !diff.trim().is_empty(),
147            "success": true
148        }))
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use serde_json::json;
156    use tempfile::TempDir;
157    use tokio::process::Command;
158
159    async fn setup_git_repo() -> TempDir {
160        let temp_dir = TempDir::new().unwrap();
161
162        Command::new("git")
163            .args(["init"])
164            .current_dir(temp_dir.path())
165            .output()
166            .await
167            .unwrap();
168
169        Command::new("git")
170            .args(["config", "user.email", "test@test.com"])
171            .current_dir(temp_dir.path())
172            .output()
173            .await
174            .unwrap();
175
176        Command::new("git")
177            .args(["config", "user.name", "Test User"])
178            .current_dir(temp_dir.path())
179            .output()
180            .await
181            .unwrap();
182
183        temp_dir
184    }
185
186    #[tokio::test]
187    async fn test_git_diff_no_changes() {
188        let temp_dir = setup_git_repo().await;
189
190        let tool = GitDiffTool::new(temp_dir.path().to_path_buf());
191        let result = tool.execute(json!({})).await.unwrap();
192
193        assert!(result["success"].as_bool().unwrap());
194        assert!(!result["has_changes"].as_bool().unwrap());
195    }
196
197    #[tokio::test]
198    async fn test_git_diff_schema() {
199        let temp_dir = setup_git_repo().await;
200        let tool = GitDiffTool::new(temp_dir.path().to_path_buf());
201        let schema = tool.parameters_schema();
202        let obj = schema.as_object().unwrap();
203        let props = obj.get("properties").unwrap().as_object().unwrap();
204        assert!(props.contains_key("staged"));
205        assert!(props.contains_key("file"));
206        assert!(props.contains_key("base"));
207        assert!(props.contains_key("stat"));
208    }
209
210    #[tokio::test]
211    async fn test_git_diff_with_changes() {
212        let temp_dir = setup_git_repo().await;
213        // Create, add, commit a file
214        std::fs::write(temp_dir.path().join("f.txt"), "original").unwrap();
215        Command::new("git")
216            .args(["add", "."])
217            .current_dir(temp_dir.path())
218            .output()
219            .await
220            .unwrap();
221        Command::new("git")
222            .args(["commit", "-m", "init"])
223            .current_dir(temp_dir.path())
224            .output()
225            .await
226            .unwrap();
227        // Modify the file
228        std::fs::write(temp_dir.path().join("f.txt"), "modified").unwrap();
229
230        let tool = GitDiffTool::new(temp_dir.path().into());
231        let result = tool.execute(json!({})).await.unwrap();
232        assert!(result["success"].as_bool().unwrap());
233        assert!(result["has_changes"].as_bool().unwrap());
234        assert!(result["diff"].as_str().unwrap().contains("modified"));
235    }
236}