Skip to main content

pawan/tools/git/
diff.rs

1use super::run_git;
2use super::super::Tool;
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(Parameter::builder("staged").param_type(ParameterType::Boolean).required(false)
64                .description("Show staged changes only (--cached). Default: false (shows unstaged)").build())
65            .parameter(Parameter::builder("file").param_type(ParameterType::String).required(false)
66                .description("Specific file to diff (optional)").build())
67            .parameter(Parameter::builder("base").param_type(ParameterType::String).required(false)
68                .description("Base commit/branch to diff against (e.g., 'main', 'HEAD~3')").build())
69            .parameter(Parameter::builder("stat").param_type(ParameterType::Boolean).required(false)
70                .description("Show diffstat summary instead of full diff").build())
71            .build()
72    }
73
74    async fn execute(&self, args: Value) -> crate::Result<Value> {
75        let staged = args["staged"].as_bool().unwrap_or(false);
76        let file = args["file"].as_str();
77        let base = args["base"].as_str();
78        let stat = args["stat"].as_bool().unwrap_or(false);
79
80        let mut git_args = vec!["diff"];
81
82        if staged {
83            git_args.push("--cached");
84        }
85
86        if stat {
87            git_args.push("--stat");
88        }
89
90        if let Some(b) = base {
91            git_args.push(b);
92        }
93
94        if let Some(f) = file {
95            git_args.push("--");
96            git_args.push(f);
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 diff failed: {}",
104                stderr
105            )));
106        }
107
108        // Truncate if too large
109        let max_size = 100_000;
110        let truncated = stdout.len() > max_size;
111        let diff = if truncated {
112            format!(
113                "{}...\n[truncated, {} bytes total]",
114                &stdout[..max_size],
115                stdout.len()
116            )
117        } else {
118            stdout
119        };
120
121        Ok(json!({
122            "diff": diff,
123            "truncated": truncated,
124            "has_changes": !diff.trim().is_empty(),
125            "success": true
126        }))
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use serde_json::json;
134    use tempfile::TempDir;
135    use tokio::process::Command;
136
137    async fn setup_git_repo() -> TempDir {
138        let temp_dir = TempDir::new().unwrap();
139
140        Command::new("git")
141            .args(["init"])
142            .current_dir(temp_dir.path())
143            .output()
144            .await
145            .unwrap();
146
147        Command::new("git")
148            .args(["config", "user.email", "test@test.com"])
149            .current_dir(temp_dir.path())
150            .output()
151            .await
152            .unwrap();
153
154        Command::new("git")
155            .args(["config", "user.name", "Test User"])
156            .current_dir(temp_dir.path())
157            .output()
158            .await
159            .unwrap();
160
161        temp_dir
162    }
163
164    #[tokio::test]
165    async fn test_git_diff_no_changes() {
166        let temp_dir = setup_git_repo().await;
167
168        let tool = GitDiffTool::new(temp_dir.path().to_path_buf());
169        let result = tool.execute(json!({})).await.unwrap();
170
171        assert!(result["success"].as_bool().unwrap());
172        assert!(!result["has_changes"].as_bool().unwrap());
173    }
174
175    #[tokio::test]
176    async fn test_git_diff_schema() {
177        let temp_dir = setup_git_repo().await;
178        let tool = GitDiffTool::new(temp_dir.path().to_path_buf());
179        let schema = tool.parameters_schema();
180        let obj = schema.as_object().unwrap();
181        let props = obj.get("properties").unwrap().as_object().unwrap();
182        assert!(props.contains_key("staged"));
183        assert!(props.contains_key("file"));
184        assert!(props.contains_key("base"));
185        assert!(props.contains_key("stat"));
186    }
187
188    #[tokio::test]
189    async fn test_git_diff_with_changes() {
190        let temp_dir = setup_git_repo().await;
191        // Create, add, commit a file
192        std::fs::write(temp_dir.path().join("f.txt"), "original").unwrap();
193        Command::new("git").args(["add", "."]).current_dir(temp_dir.path()).output().await.unwrap();
194        Command::new("git").args(["commit", "-m", "init"]).current_dir(temp_dir.path()).output().await.unwrap();
195        // Modify the file
196        std::fs::write(temp_dir.path().join("f.txt"), "modified").unwrap();
197
198        let tool = GitDiffTool::new(temp_dir.path().into());
199        let result = tool.execute(json!({})).await.unwrap();
200        assert!(result["success"].as_bool().unwrap());
201        assert!(result["has_changes"].as_bool().unwrap());
202        assert!(result["diff"].as_str().unwrap().contains("modified"));
203    }
204}