Skip to main content

agentzero_tools/
git_operations.rs

1use agentzero_core::{Tool, ToolContext, ToolResult};
2use anyhow::{anyhow, Context};
3use async_trait::async_trait;
4use serde::Deserialize;
5use std::path::{Component, Path};
6use std::process::Stdio;
7use tokio::io::AsyncReadExt;
8use tokio::process::Command;
9
10const DEFAULT_LOG_LIMIT: usize = 20;
11const MAX_OUTPUT_BYTES: usize = 65536;
12
13#[derive(Debug, Deserialize)]
14#[serde(tag = "op")]
15#[serde(rename_all = "snake_case")]
16enum GitOp {
17    Status,
18    Diff {
19        #[serde(default)]
20        path: Option<String>,
21        #[serde(default)]
22        staged: bool,
23    },
24    Log {
25        #[serde(default = "default_log_limit")]
26        limit: usize,
27        #[serde(default)]
28        path: Option<String>,
29    },
30    Branch {
31        #[serde(default)]
32        name: Option<String>,
33    },
34    Checkout {
35        branch: String,
36    },
37    Add {
38        paths: Vec<String>,
39    },
40    Commit {
41        message: String,
42    },
43    Push {
44        #[serde(default)]
45        remote: Option<String>,
46        #[serde(default)]
47        branch: Option<String>,
48    },
49    Pull {
50        #[serde(default)]
51        remote: Option<String>,
52        #[serde(default)]
53        branch: Option<String>,
54    },
55    Show {
56        #[serde(default)]
57        rev: Option<String>,
58    },
59}
60
61fn default_log_limit() -> usize {
62    DEFAULT_LOG_LIMIT
63}
64
65pub struct GitOperationsTool;
66
67impl Default for GitOperationsTool {
68    fn default() -> Self {
69        Self
70    }
71}
72
73impl GitOperationsTool {
74    pub fn new() -> Self {
75        Self
76    }
77
78    fn validate_path(path: &str) -> anyhow::Result<()> {
79        if path.trim().is_empty() {
80            return Err(anyhow!("path must not be empty"));
81        }
82        let p = Path::new(path);
83        if p.is_absolute() {
84            return Err(anyhow!("absolute paths are not allowed"));
85        }
86        if p.components().any(|c| matches!(c, Component::ParentDir)) {
87            return Err(anyhow!("path traversal is not allowed"));
88        }
89        Ok(())
90    }
91
92    fn validate_ref(s: &str) -> anyhow::Result<()> {
93        if s.trim().is_empty() {
94            return Err(anyhow!("ref must not be empty"));
95        }
96        // Block shell metacharacters in refs.
97        if s.chars()
98            .any(|c| matches!(c, ';' | '&' | '|' | '`' | '$' | '>' | '<' | '\n' | '\r'))
99        {
100            return Err(anyhow!("ref contains forbidden characters"));
101        }
102        Ok(())
103    }
104
105    async fn run_git(workspace_root: &str, args: &[&str]) -> anyhow::Result<ToolResult> {
106        let mut child = Command::new("git")
107            .args(args)
108            .current_dir(workspace_root)
109            .stdout(Stdio::piped())
110            .stderr(Stdio::piped())
111            .spawn()
112            .context("failed to spawn git")?;
113
114        let stdout_handle = child
115            .stdout
116            .take()
117            .context("stdout not piped on spawned child")?;
118        let stderr_handle = child
119            .stderr
120            .take()
121            .context("stderr not piped on spawned child")?;
122
123        let stdout_task = tokio::spawn(Self::read_limited(stdout_handle));
124        let stderr_task = tokio::spawn(Self::read_limited(stderr_handle));
125
126        let status = child.wait().await.context("git command failed")?;
127        let stdout = stdout_task.await.context("stdout join")??;
128        let stderr = stderr_task.await.context("stderr join")??;
129
130        let mut output = format!("exit={}\n", status.code().unwrap_or(-1));
131        if !stdout.is_empty() {
132            output.push_str(&stdout);
133        }
134        if !stderr.is_empty() {
135            output.push_str("\nstderr:\n");
136            output.push_str(&stderr);
137        }
138
139        Ok(ToolResult { output })
140    }
141
142    async fn read_limited<R: tokio::io::AsyncRead + Unpin>(
143        mut reader: R,
144    ) -> anyhow::Result<String> {
145        let mut buf = Vec::new();
146        let mut limited = (&mut reader).take((MAX_OUTPUT_BYTES + 1) as u64);
147        limited.read_to_end(&mut buf).await?;
148        let truncated = buf.len() > MAX_OUTPUT_BYTES;
149        if truncated {
150            buf.truncate(MAX_OUTPUT_BYTES);
151        }
152        let mut s = String::from_utf8_lossy(&buf).to_string();
153        if truncated {
154            s.push_str(&format!("\n<truncated at {} bytes>", MAX_OUTPUT_BYTES));
155        }
156        Ok(s)
157    }
158}
159
160#[async_trait]
161impl Tool for GitOperationsTool {
162    fn name(&self) -> &'static str {
163        "git_operations"
164    }
165
166    fn description(&self) -> &'static str {
167        "Perform git operations: status, diff, log, branch, add, commit, checkout, show, stash. Input is JSON with an \"op\" field."
168    }
169
170    fn input_schema(&self) -> Option<serde_json::Value> {
171        Some(serde_json::json!({
172            "type": "object",
173            "properties": {
174                "op": {
175                    "type": "string",
176                    "description": "Git operation: status, diff, log, branch, add, commit, checkout, show, stash",
177                    "enum": ["status", "diff", "log", "branch", "add", "commit", "checkout", "show", "stash"]
178                }
179            },
180            "required": ["op"]
181        }))
182    }
183
184    async fn execute(&self, input: &str, ctx: &ToolContext) -> anyhow::Result<ToolResult> {
185        let op: GitOp =
186            serde_json::from_str(input).context("git_operations expects JSON with \"op\" field")?;
187
188        match op {
189            GitOp::Status => Self::run_git(&ctx.workspace_root, &["status", "--short"]).await,
190
191            GitOp::Diff { path, staged } => {
192                let mut args = vec!["diff"];
193                if staged {
194                    args.push("--cached");
195                }
196                if let Some(ref p) = path {
197                    Self::validate_path(p)?;
198                    args.push("--");
199                    args.push(p);
200                }
201                Self::run_git(&ctx.workspace_root, &args).await
202            }
203
204            GitOp::Log { limit, path } => {
205                let limit_str = format!("-{}", limit.min(100));
206                let mut args = vec!["log", &limit_str, "--oneline"];
207                if let Some(ref p) = path {
208                    Self::validate_path(p)?;
209                    args.push("--");
210                    args.push(p);
211                }
212                Self::run_git(&ctx.workspace_root, &args).await
213            }
214
215            GitOp::Branch { name } => {
216                if let Some(ref n) = name {
217                    Self::validate_ref(n)?;
218                    Self::run_git(&ctx.workspace_root, &["branch", n]).await
219                } else {
220                    Self::run_git(&ctx.workspace_root, &["branch", "--list"]).await
221                }
222            }
223
224            GitOp::Checkout { ref branch } => {
225                Self::validate_ref(branch)?;
226                Self::run_git(&ctx.workspace_root, &["checkout", branch]).await
227            }
228
229            GitOp::Add { ref paths } => {
230                if paths.is_empty() {
231                    return Err(anyhow!("add requires at least one path"));
232                }
233                for p in paths {
234                    Self::validate_path(p)?;
235                }
236                let mut args = vec!["add"];
237                let path_refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
238                args.extend(path_refs);
239                Self::run_git(&ctx.workspace_root, &args).await
240            }
241
242            GitOp::Commit { ref message } => {
243                if message.trim().is_empty() {
244                    return Err(anyhow!("commit message must not be empty"));
245                }
246                Self::run_git(&ctx.workspace_root, &["commit", "-m", message]).await
247            }
248
249            GitOp::Push {
250                ref remote,
251                ref branch,
252            } => {
253                let mut args = vec!["push"];
254                if let Some(ref r) = remote {
255                    Self::validate_ref(r)?;
256                    args.push(r);
257                }
258                if let Some(ref b) = branch {
259                    Self::validate_ref(b)?;
260                    args.push(b);
261                }
262                Self::run_git(&ctx.workspace_root, &args).await
263            }
264
265            GitOp::Pull {
266                ref remote,
267                ref branch,
268            } => {
269                let mut args = vec!["pull"];
270                if let Some(ref r) = remote {
271                    Self::validate_ref(r)?;
272                    args.push(r);
273                }
274                if let Some(ref b) = branch {
275                    Self::validate_ref(b)?;
276                    args.push(b);
277                }
278                Self::run_git(&ctx.workspace_root, &args).await
279            }
280
281            GitOp::Show { ref rev } => {
282                let mut args = vec!["show", "--stat"];
283                if let Some(ref r) = rev {
284                    Self::validate_ref(r)?;
285                    args.push(r);
286                }
287                Self::run_git(&ctx.workspace_root, &args).await
288            }
289        }
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::GitOperationsTool;
296    use agentzero_core::{Tool, ToolContext};
297    use std::fs;
298    use std::path::PathBuf;
299    use std::sync::atomic::{AtomicU64, Ordering};
300    use std::time::{SystemTime, UNIX_EPOCH};
301
302    static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
303
304    /// Returns a `Command` for `git` with the working dir set to `dir` and all
305    /// inherited git environment variables removed so the subprocess cannot
306    /// accidentally operate on the parent repository's object store or index.
307    fn git_cmd(dir: &std::path::Path) -> std::process::Command {
308        let mut cmd = std::process::Command::new("git");
309        cmd.current_dir(dir);
310        for var in &[
311            "GIT_DIR",
312            "GIT_INDEX_FILE",
313            "GIT_OBJECT_DIRECTORY",
314            "GIT_ALTERNATE_OBJECT_DIRECTORIES",
315            "GIT_WORK_TREE",
316            "GIT_COMMON_DIR",
317        ] {
318            cmd.env_remove(var);
319        }
320        cmd
321    }
322
323    fn temp_git_dir() -> PathBuf {
324        let nanos = SystemTime::now()
325            .duration_since(UNIX_EPOCH)
326            .expect("clock")
327            .as_nanos();
328        let seq = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
329        let dir = std::env::temp_dir().join(format!(
330            "agentzero-git-ops-{}-{nanos}-{seq}",
331            std::process::id()
332        ));
333        fs::create_dir_all(&dir).expect("temp dir should be created");
334        // Initialize a git repo.
335        git_cmd(&dir)
336            .args(["init"])
337            .output()
338            .expect("git init should succeed");
339        git_cmd(&dir)
340            .args(["config", "user.email", "test@test.com"])
341            .output()
342            .ok();
343        git_cmd(&dir)
344            .args(["config", "user.name", "Test"])
345            .output()
346            .ok();
347        // Point hooksPath to an empty directory so inherited global/system
348        // hooks (e.g. core.hooksPath = .githooks) never fire in test repos.
349        let empty_hooks = dir.join(".no-hooks");
350        fs::create_dir_all(&empty_hooks).ok();
351        git_cmd(&dir)
352            .args(["config", "core.hooksPath", &empty_hooks.to_string_lossy()])
353            .output()
354            .ok();
355        // Disable GPG signing that may be inherited from global config.
356        git_cmd(&dir)
357            .args(["config", "commit.gpgsign", "false"])
358            .output()
359            .ok();
360        dir
361    }
362
363    #[tokio::test]
364    async fn git_status_in_repo() {
365        let dir = temp_git_dir();
366        let tool = GitOperationsTool::new();
367        let result = tool
368            .execute(
369                r#"{"op": "status"}"#,
370                &ToolContext::new(dir.to_string_lossy().to_string()),
371            )
372            .await
373            .expect("git status should succeed");
374        assert!(result.output.contains("exit=0"));
375        fs::remove_dir_all(dir).ok();
376    }
377
378    #[tokio::test]
379    async fn git_log_in_repo_with_commits() {
380        let dir = temp_git_dir();
381        fs::write(dir.join("test.txt"), "hello").unwrap();
382        git_cmd(&dir).args(["add", "test.txt"]).output().unwrap();
383        let commit_out = git_cmd(&dir)
384            .args(["commit", "-m", "initial"])
385            .output()
386            .unwrap();
387        assert!(
388            commit_out.status.success(),
389            "git commit failed: {}",
390            String::from_utf8_lossy(&commit_out.stderr)
391        );
392
393        let tool = GitOperationsTool::new();
394        let result = tool
395            .execute(
396                r#"{"op": "log", "limit": 5}"#,
397                &ToolContext::new(dir.to_string_lossy().to_string()),
398            )
399            .await
400            .expect("git log should succeed");
401        assert!(result.output.contains("initial"));
402        fs::remove_dir_all(dir).ok();
403    }
404
405    #[tokio::test]
406    async fn git_rejects_path_traversal_in_add_negative_path() {
407        let dir = temp_git_dir();
408        let tool = GitOperationsTool::new();
409        let err = tool
410            .execute(
411                r#"{"op": "add", "paths": ["../escape.txt"]}"#,
412                &ToolContext::new(dir.to_string_lossy().to_string()),
413            )
414            .await
415            .expect_err("path traversal should be denied");
416        assert!(err.to_string().contains("path traversal"));
417        fs::remove_dir_all(dir).ok();
418    }
419
420    #[tokio::test]
421    async fn git_rejects_metacharacters_in_ref_negative_path() {
422        let dir = temp_git_dir();
423        let tool = GitOperationsTool::new();
424        let err = tool
425            .execute(
426                r#"{"op": "checkout", "branch": "main;rm -rf /"}"#,
427                &ToolContext::new(dir.to_string_lossy().to_string()),
428            )
429            .await
430            .expect_err("metacharacters should be rejected");
431        assert!(err.to_string().contains("forbidden characters"));
432        fs::remove_dir_all(dir).ok();
433    }
434
435    #[tokio::test]
436    async fn git_rejects_empty_commit_message_negative_path() {
437        let dir = temp_git_dir();
438        let tool = GitOperationsTool::new();
439        let err = tool
440            .execute(
441                r#"{"op": "commit", "message": ""}"#,
442                &ToolContext::new(dir.to_string_lossy().to_string()),
443            )
444            .await
445            .expect_err("empty message should fail");
446        assert!(err.to_string().contains("commit message must not be empty"));
447        fs::remove_dir_all(dir).ok();
448    }
449}