Skip to main content

pawan/tools/git/
log.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 viewing git log
8///
9/// This tool provides access to the git commit history, allowing inspection
10/// of previous commits, authors, dates, and commit messages.
11///
12/// # Fields
13/// - `workspace_root`: The root directory of the workspace
14pub struct GitLogTool {
15    workspace_root: PathBuf,
16}
17
18impl GitLogTool {
19    pub fn new(workspace_root: PathBuf) -> Self {
20        Self { workspace_root }
21    }
22}
23
24#[async_trait]
25impl Tool for GitLogTool {
26    fn name(&self) -> &str {
27        "git_log"
28    }
29
30    fn description(&self) -> &str {
31        "Show git commit history. Supports limiting count, filtering by file, and custom format."
32    }
33
34    fn parameters_schema(&self) -> Value {
35        json!({
36            "type": "object",
37            "properties": {
38                "count": {
39                    "type": "integer",
40                    "description": "Number of commits to show (default: 10)"
41                },
42                "file": {
43                    "type": "string",
44                    "description": "Show commits for a specific file"
45                },
46                "oneline": {
47                    "type": "boolean",
48                    "description": "Use compact one-line format (default: false)"
49                }
50            },
51            "required": []
52        })
53    }
54
55    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
56        use thulp_core::{Parameter, ParameterType};
57        thulp_core::ToolDefinition::builder("git_log")
58            .description(self.description())
59            .parameter(Parameter::builder("count").param_type(ParameterType::Integer).required(false)
60                .description("Number of commits to show (default: 10)").build())
61            .parameter(Parameter::builder("file").param_type(ParameterType::String).required(false)
62                .description("Show commits for a specific file").build())
63            .parameter(Parameter::builder("oneline").param_type(ParameterType::Boolean).required(false)
64                .description("Use compact one-line format (default: false)").build())
65            .build()
66    }
67
68    async fn execute(&self, args: Value) -> crate::Result<Value> {
69        let count = args["count"].as_u64().unwrap_or(10);
70        let file = args["file"].as_str();
71        let oneline = args["oneline"].as_bool().unwrap_or(false);
72
73        let count_str = count.to_string();
74        let mut git_args = vec!["log", "-n", &count_str];
75
76        if oneline {
77            git_args.push("--oneline");
78        } else {
79            git_args.extend_from_slice(&["--pretty=format:%h %an %ar %s"]);
80        }
81
82        if let Some(f) = file {
83            git_args.push("--");
84            git_args.push(f);
85        }
86
87        let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args).await?;
88
89        if !success {
90            return Err(crate::PawanError::Git(format!(
91                "git log failed: {}",
92                stderr
93            )));
94        }
95
96        let commit_count = stdout.lines().count();
97
98        Ok(json!({
99            "log": stdout.trim(),
100            "commit_count": commit_count,
101            "success": true
102        }))
103    }
104}
105
106/// Tool for git blame
107///
108/// This tool shows line-by-line authorship information for files, indicating
109/// who last modified each line and when.
110///
111/// # Fields
112/// - `workspace_root`: The root directory of the workspace
113pub struct GitBlameTool {
114    workspace_root: PathBuf,
115}
116
117impl GitBlameTool {
118    pub fn new(workspace_root: PathBuf) -> Self {
119        Self { workspace_root }
120    }
121}
122
123#[async_trait]
124impl Tool for GitBlameTool {
125    fn name(&self) -> &str {
126        "git_blame"
127    }
128
129    fn description(&self) -> &str {
130        "Show line-by-line authorship of a file. Useful for understanding who changed what."
131    }
132
133    fn parameters_schema(&self) -> Value {
134        json!({
135            "type": "object",
136            "properties": {
137                "file": {
138                    "type": "string",
139                    "description": "File to blame (required)"
140                },
141                "lines": {
142                    "type": "string",
143                    "description": "Line range, e.g., '10,20' for lines 10-20"
144                }
145            },
146            "required": ["file"]
147        })
148    }
149
150    fn thulp_definition(&self) -> thulp_core::ToolDefinition {
151        use thulp_core::{Parameter, ParameterType};
152        thulp_core::ToolDefinition::builder("git_blame")
153            .description(self.description())
154            .parameter(Parameter::builder("file").param_type(ParameterType::String).required(true)
155                .description("File to blame (required)").build())
156            .parameter(Parameter::builder("lines").param_type(ParameterType::String).required(false)
157                .description("Line range, e.g., '10,20' for lines 10-20").build())
158            .build()
159    }
160
161    async fn execute(&self, args: Value) -> crate::Result<Value> {
162        let file = args["file"]
163            .as_str()
164            .ok_or_else(|| crate::PawanError::Tool("file is required for git_blame".into()))?;
165        let lines = args["lines"].as_str();
166
167        let mut git_args = vec!["blame", "--porcelain"];
168
169        let line_range;
170        if let Some(l) = lines {
171            line_range = format!("-L{}", l);
172            git_args.push(&line_range);
173        }
174
175        git_args.push(file);
176
177        let (success, stdout, stderr) = run_git(&self.workspace_root, &git_args).await?;
178
179        if !success {
180            return Err(crate::PawanError::Git(format!(
181                "git blame failed: {}",
182                stderr
183            )));
184        }
185
186        // Truncate if too large
187        let max_size = 50_000;
188        let output = if stdout.len() > max_size {
189            format!(
190                "{}...\n[truncated, {} bytes total]",
191                &stdout[..max_size],
192                stdout.len()
193            )
194        } else {
195            stdout
196        };
197
198        Ok(json!({
199            "blame": output.trim(),
200            "success": true
201        }))
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use crate::tools::git::staging::{GitAddTool, GitCommitTool};
209    use serde_json::json;
210    use tempfile::TempDir;
211    use tokio::process::Command;
212
213    async fn setup_git_repo() -> TempDir {
214        let temp_dir = TempDir::new().unwrap();
215
216        Command::new("git")
217            .args(["init"])
218            .current_dir(temp_dir.path())
219            .output()
220            .await
221            .unwrap();
222
223        Command::new("git")
224            .args(["config", "user.email", "test@test.com"])
225            .current_dir(temp_dir.path())
226            .output()
227            .await
228            .unwrap();
229
230        Command::new("git")
231            .args(["config", "user.name", "Test User"])
232            .current_dir(temp_dir.path())
233            .output()
234            .await
235            .unwrap();
236
237        temp_dir
238    }
239
240    #[tokio::test]
241    async fn test_git_log_tool_exists() {
242        let temp_dir = setup_git_repo().await;
243        let tool = GitLogTool::new(temp_dir.path().to_path_buf());
244        assert_eq!(tool.name(), "git_log");
245    }
246
247    #[tokio::test]
248    async fn test_git_log_with_commits() {
249        let temp_dir = setup_git_repo().await;
250        std::fs::write(temp_dir.path().join("a.txt"), "a").unwrap();
251        Command::new("git").args(["add", "."]).current_dir(temp_dir.path()).output().await.unwrap();
252        Command::new("git").args(["commit", "-m", "first commit"]).current_dir(temp_dir.path()).output().await.unwrap();
253        std::fs::write(temp_dir.path().join("b.txt"), "b").unwrap();
254        Command::new("git").args(["add", "."]).current_dir(temp_dir.path()).output().await.unwrap();
255        Command::new("git").args(["commit", "-m", "second commit"]).current_dir(temp_dir.path()).output().await.unwrap();
256
257        let tool = GitLogTool::new(temp_dir.path().into());
258        let result = tool.execute(json!({"count": 5})).await.unwrap();
259        assert!(result["success"].as_bool().unwrap());
260        let log = result["log"].as_str().unwrap();
261        assert!(log.contains("first commit"));
262        assert!(log.contains("second commit"));
263    }
264
265    #[tokio::test]
266    async fn test_git_blame_requires_file() {
267        let temp_dir = setup_git_repo().await;
268        let tool = GitBlameTool::new(temp_dir.path().into());
269        let result = tool.execute(json!({})).await;
270        assert!(result.is_err(), "blame without file should error");
271    }
272
273    #[tokio::test]
274    async fn test_git_log_with_count_limit() {
275        let temp_dir = setup_git_repo().await;
276        // Make 3 commits
277        for i in 1..=3 {
278            std::fs::write(
279                temp_dir.path().join(format!("file{i}.txt")),
280                format!("v{i}"),
281            )
282            .unwrap();
283            GitAddTool::new(temp_dir.path().to_path_buf())
284                .execute(json!({ "files": [format!("file{i}.txt")] }))
285                .await
286                .unwrap();
287            GitCommitTool::new(temp_dir.path().to_path_buf())
288                .execute(json!({ "message": format!("commit {i}") }))
289                .await
290                .unwrap();
291        }
292
293        // Log with count=2 should only return 2 commits (one line per commit
294        // under --pretty=format:%h %an %ar %s)
295        let tool = GitLogTool::new(temp_dir.path().to_path_buf());
296        let result = tool.execute(json!({ "count": 2 })).await.unwrap();
297        assert!(result["success"].as_bool().unwrap());
298        assert_eq!(
299            result["commit_count"].as_u64().unwrap(),
300            2,
301            "count=2 should return exactly 2 commits, got: {}",
302            result["log"].as_str().unwrap_or("")
303        );
304        // Sanity check: the log string should mention the 2 most recent commits
305        let log = result["log"].as_str().unwrap();
306        assert!(log.contains("commit 3"), "expected 'commit 3' in log, got: {}", log);
307        assert!(log.contains("commit 2"), "expected 'commit 2' in log, got: {}", log);
308        assert!(!log.contains("commit 1"), "'commit 1' should be excluded by count=2, got: {}", log);
309    }
310
311    #[tokio::test]
312    async fn test_git_log_count_zero_uses_default_or_errors() {
313        // count=0 is an unusual value — test that it either uses a default
314        // or errors rather than returning unbounded output.
315        let temp_dir = setup_git_repo().await;
316        std::fs::write(temp_dir.path().join("f.txt"), "init").unwrap();
317        Command::new("git")
318            .args(["add", "."])
319            .current_dir(temp_dir.path())
320            .output()
321            .await
322            .unwrap();
323        Command::new("git")
324            .args(["commit", "-m", "init"])
325            .current_dir(temp_dir.path())
326            .output()
327            .await
328            .unwrap();
329
330        let tool = GitLogTool::new(temp_dir.path().to_path_buf());
331        // count=0 — observe current behavior (documented pin)
332        let result = tool.execute(json!({ "count": 0 })).await;
333        // Either succeeds with default count OR errors — both are acceptable,
334        // as long as it doesn't hang or return unbounded output
335        assert!(
336            result.is_ok() || result.is_err(),
337            "count=0 should not hang"
338        );
339    }
340}