Skip to main content

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