Skip to main content

ai_agents_tools/builtin/
git.rs

1use async_trait::async_trait;
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use std::collections::HashMap;
6use std::path::{Component, Path, PathBuf};
7use std::process::Command;
8
9use ai_agents_core::{
10    PathPolicyBinding, ResultLimitBinding, ResultLimitKind, Tool, ToolExecutionContext,
11    ToolOperationKind, ToolPolicyBindings, ToolResult, ToolSafetyMetadata, ToolSideEffectLevel,
12};
13
14use crate::generate_schema;
15
16const DEFAULT_MAX_RESULTS: usize = 200;
17const DEFAULT_MAX_OUTPUT_CHARS: usize = 20_000;
18
19/// Inspects repository status through a fixed read-only git command.
20pub struct GitStatusTool;
21
22impl GitStatusTool {
23    /// Create a read-only repository status tool.
24    pub fn new() -> Self {
25        Self
26    }
27}
28
29impl Default for GitStatusTool {
30    fn default() -> Self {
31        Self::new()
32    }
33}
34
35/// Inspects bounded repository diffs through fixed read-only git commands.
36pub struct GitDiffTool;
37
38impl GitDiffTool {
39    /// Create a read-only repository diff tool.
40    pub fn new() -> Self {
41        Self
42    }
43}
44
45impl Default for GitDiffTool {
46    fn default() -> Self {
47        Self::new()
48    }
49}
50
51#[derive(Debug, Deserialize, JsonSchema)]
52struct GitStatusInput {
53    /// Repository root or subdirectory. Defaults to current directory.
54    #[serde(default)]
55    path: Option<String>,
56    /// Include untracked files. Defaults to true.
57    #[serde(default = "default_true")]
58    include_untracked: bool,
59    /// Maximum changed paths. Defaults to 200.
60    #[serde(
61        default,
62        deserialize_with = "crate::deserialize_optional_positive_usize"
63    )]
64    #[schemars(range(min = 1))]
65    max_results: Option<usize>,
66}
67
68#[derive(Debug, Serialize)]
69struct GitStatusOutput {
70    branch: Option<String>,
71    staged: Vec<GitStatusEntry>,
72    unstaged: Vec<GitStatusEntry>,
73    untracked: Vec<GitStatusEntry>,
74    count: usize,
75    truncated: bool,
76}
77
78#[derive(Debug, Serialize, Clone)]
79struct GitStatusEntry {
80    path: String,
81    status: String,
82}
83
84#[derive(Debug, Deserialize, JsonSchema)]
85struct GitDiffInput {
86    /// Repository root or subdirectory. Defaults to current directory.
87    #[serde(default)]
88    path: Option<String>,
89    /// Show staged diff instead of working tree diff. Defaults to false.
90    #[serde(default)]
91    staged: bool,
92    /// Optional path filters.
93    #[serde(default)]
94    paths: Vec<String>,
95    /// Maximum output characters. Defaults to 20000.
96    #[serde(default)]
97    max_output_chars: Option<usize>,
98}
99
100#[derive(Debug, Serialize)]
101struct GitDiffOutput {
102    staged: bool,
103    paths: Vec<String>,
104    summary: Vec<String>,
105    diff: String,
106    truncated: bool,
107}
108
109#[async_trait]
110impl Tool for GitStatusTool {
111    fn id(&self) -> &str {
112        "git_status"
113    }
114
115    fn name(&self) -> &str {
116        "Git Status"
117    }
118
119    fn description(&self) -> &str {
120        "Inspect repository status using bounded read-only git status output."
121    }
122
123    fn input_schema(&self) -> Value {
124        generate_schema::<GitStatusInput>()
125    }
126
127    fn safety_metadata(&self) -> ToolSafetyMetadata {
128        vcs_metadata()
129    }
130
131    fn policy_bindings(&self) -> ToolPolicyBindings {
132        ToolPolicyBindings {
133            path_fields: vec![PathPolicyBinding::read("path").with_default_path(".")],
134            result_limit_fields: vec![ResultLimitBinding::new(
135                "max_results",
136                ResultLimitKind::MaxResults,
137            )],
138            ..Default::default()
139        }
140    }
141
142    async fn execute(&self, args: Value, ctx: ToolExecutionContext) -> ToolResult {
143        let input: GitStatusInput = match serde_json::from_value(args) {
144            Ok(input) => input,
145            Err(error) => return ToolResult::error(format!("Invalid input: {}", error)),
146        };
147        if let Err(error) = crate::validate_positive_max_results(ctx.limits.max_results) {
148            return ToolResult::error(format!("Invalid result limit: {error}"));
149        }
150        let cwd = PathBuf::from(input.path.unwrap_or_else(|| ".".to_string()));
151        if let Err(reason) = validate_path(&cwd) {
152            return ToolResult::error(reason);
153        }
154        let branch = run_git(&cwd, &["branch", "--show-current"])
155            .ok()
156            .map(|output| output.trim().to_string())
157            .filter(|output| !output.is_empty());
158        let mut args = vec!["status", "--porcelain=v1", "--branch"];
159        if !input.include_untracked {
160            args.push("--untracked-files=no");
161        }
162        let raw = match run_git(&cwd, &args) {
163            Ok(raw) => raw,
164            Err(error) => return ToolResult::error(error),
165        };
166        let max_results = input
167            .max_results
168            .unwrap_or(DEFAULT_MAX_RESULTS)
169            .min(ctx.limits.max_results.unwrap_or(DEFAULT_MAX_RESULTS));
170        let mut staged = Vec::new();
171        let mut unstaged = Vec::new();
172        let mut untracked = Vec::new();
173        for line in raw.lines() {
174            if line.starts_with("##") || line.len() < 3 {
175                continue;
176            }
177            let bytes = line.as_bytes();
178            let x = bytes[0] as char;
179            let y = bytes[1] as char;
180            let path = line[3..].to_string();
181            if path_contains_git(&path) {
182                continue;
183            }
184            if x == '?' && y == '?' {
185                untracked.push(GitStatusEntry {
186                    path,
187                    status: "untracked".to_string(),
188                });
189                continue;
190            }
191            if x != ' ' {
192                staged.push(GitStatusEntry {
193                    path: path.clone(),
194                    status: x.to_string(),
195                });
196            }
197            if y != ' ' {
198                unstaged.push(GitStatusEntry {
199                    path,
200                    status: y.to_string(),
201                });
202            }
203        }
204        let total = staged.len() + unstaged.len() + untracked.len();
205        truncate_status_entries(&mut staged, max_results);
206        let remaining = max_results.saturating_sub(staged.len());
207        truncate_status_entries(&mut unstaged, remaining);
208        let remaining = max_results.saturating_sub(staged.len() + unstaged.len());
209        truncate_status_entries(&mut untracked, remaining);
210        let count = staged.len() + unstaged.len() + untracked.len();
211        let output = GitStatusOutput {
212            branch,
213            staged,
214            unstaged,
215            untracked,
216            count,
217            truncated: count < total,
218        };
219        json_result(&output, output.truncated, None)
220    }
221}
222
223#[async_trait]
224impl Tool for GitDiffTool {
225    fn id(&self) -> &str {
226        "git_diff"
227    }
228
229    fn name(&self) -> &str {
230        "Git Diff"
231    }
232
233    fn description(&self) -> &str {
234        "Inspect bounded repository diffs using fixed read-only git diff commands."
235    }
236
237    fn input_schema(&self) -> Value {
238        generate_schema::<GitDiffInput>()
239    }
240
241    fn safety_metadata(&self) -> ToolSafetyMetadata {
242        vcs_metadata()
243    }
244
245    fn policy_bindings(&self) -> ToolPolicyBindings {
246        ToolPolicyBindings {
247            path_fields: vec![PathPolicyBinding::read("path").with_default_path(".")],
248            result_limit_fields: vec![ResultLimitBinding::new(
249                "max_output_chars",
250                ResultLimitKind::MaxOutputChars,
251            )],
252            ..Default::default()
253        }
254    }
255
256    async fn execute(&self, args: Value, ctx: ToolExecutionContext) -> ToolResult {
257        let input: GitDiffInput = match serde_json::from_value(args) {
258            Ok(input) => input,
259            Err(error) => return ToolResult::error(format!("Invalid input: {}", error)),
260        };
261        let cwd = PathBuf::from(input.path.clone().unwrap_or_else(|| ".".to_string()));
262        if let Err(reason) = validate_path(&cwd) {
263            return ToolResult::error(reason);
264        }
265        for path in &input.paths {
266            if path_contains_git(path) {
267                return ToolResult::error("Path filters cannot reference raw .git contents");
268            }
269        }
270        let max_output_chars = input
271            .max_output_chars
272            .unwrap_or(DEFAULT_MAX_OUTPUT_CHARS)
273            .min(
274                ctx.limits
275                    .max_output_chars
276                    .unwrap_or(DEFAULT_MAX_OUTPUT_CHARS),
277            );
278        let mut diff_args = vec![
279            "diff",
280            "--no-ext-diff",
281            "--src-prefix=a/",
282            "--dst-prefix=b/",
283        ];
284        if input.staged {
285            diff_args.push("--cached");
286        }
287        let mut summary_args = vec!["diff", "--name-status"];
288        if input.staged {
289            summary_args.push("--cached");
290        }
291        if !input.paths.is_empty() {
292            diff_args.push("--");
293            summary_args.push("--");
294            for path in &input.paths {
295                diff_args.push(path);
296                summary_args.push(path);
297            }
298        }
299        let summary_raw = run_git(&cwd, &summary_args).unwrap_or_default();
300        let diff_raw = match run_git(&cwd, &diff_args) {
301            Ok(diff) => diff,
302            Err(error) => return ToolResult::error(error),
303        };
304        let (diff, truncated) = truncate_chars(diff_raw, max_output_chars);
305        let output = GitDiffOutput {
306            staged: input.staged,
307            paths: input.paths,
308            summary: summary_raw.lines().map(str::to_string).collect(),
309            diff,
310            truncated,
311        };
312        json_result(&output, truncated, Some(max_output_chars))
313    }
314}
315
316fn vcs_metadata() -> ToolSafetyMetadata {
317    ToolSafetyMetadata {
318        read_only: true,
319        concurrency_safe: true,
320        operation: ToolOperationKind::VcsInspect,
321        side_effect_level: ToolSideEffectLevel::None,
322        requires_network: false,
323        destructive: false,
324        open_world: false,
325        host_dependent: true,
326        requires_user_interaction: false,
327        supports_cancellation: false,
328        default_requires_approval: false,
329        should_defer_schema: false,
330        max_output_chars: Some(DEFAULT_MAX_OUTPUT_CHARS),
331        max_result_size_chars: Some(DEFAULT_MAX_OUTPUT_CHARS),
332    }
333}
334
335fn run_git(cwd: &Path, args: &[&str]) -> Result<String, String> {
336    let output = Command::new("git")
337        .args(args)
338        .current_dir(cwd)
339        .output()
340        .map_err(|error| format!("Failed to run git: {}", error))?;
341    if !output.status.success() {
342        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
343        return Err(if stderr.is_empty() {
344            format!("git exited with status {}", output.status)
345        } else {
346            stderr
347        });
348    }
349    Ok(String::from_utf8_lossy(&output.stdout).to_string())
350}
351
352fn validate_path(path: &Path) -> Result<(), String> {
353    for component in path.components() {
354        let Component::Normal(value) = component else {
355            continue;
356        };
357        if value.to_string_lossy() == ".git" {
358            return Err(
359                "VCS tools inspect repository metadata but do not expose raw .git paths"
360                    .to_string(),
361            );
362        }
363    }
364    Ok(())
365}
366
367fn path_contains_git(path: &str) -> bool {
368    Path::new(path).components().any(|component| {
369        matches!(component, Component::Normal(value) if value.to_string_lossy() == ".git")
370    })
371}
372
373fn truncate_status_entries(entries: &mut Vec<GitStatusEntry>, max_len: usize) {
374    entries.truncate(max_len);
375}
376
377fn truncate_chars(text: String, max_chars: usize) -> (String, bool) {
378    let mut chars = text.chars();
379    let truncated: String = chars.by_ref().take(max_chars).collect();
380    if chars.next().is_some() {
381        (truncated, true)
382    } else {
383        (text, false)
384    }
385}
386
387fn json_result<T: Serialize>(
388    output: &T,
389    truncated: bool,
390    max_output_chars: Option<usize>,
391) -> ToolResult {
392    let json = match serde_json::to_string(output) {
393        Ok(json) => json,
394        Err(error) => return ToolResult::error(format!("Serialization error: {}", error)),
395    };
396    let mut metadata = HashMap::new();
397    metadata.insert("truncated".to_string(), Value::Bool(truncated));
398    if let Some(max) = max_output_chars {
399        metadata.insert("max_output_chars".to_string(), Value::from(max));
400    }
401    ToolResult::ok_with_metadata(json, metadata)
402}
403
404fn default_true() -> bool {
405    true
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use tempfile::tempdir;
412
413    fn git_available() -> bool {
414        Command::new("git")
415            .arg("--version")
416            .output()
417            .map(|output| output.status.success())
418            .unwrap_or(false)
419    }
420
421    fn init_repo() -> tempfile::TempDir {
422        let dir = tempdir().unwrap();
423        run_git(dir.path(), &["init"]).unwrap();
424        run_git(dir.path(), &["config", "user.email", "test@example.com"]).unwrap();
425        run_git(dir.path(), &["config", "user.name", "Test User"]).unwrap();
426        dir
427    }
428
429    #[tokio::test]
430    async fn git_status_rejects_zero_max_results_before_running_git() {
431        let result = GitStatusTool::new()
432            .execute(
433                serde_json::json!({"path": "missing", "max_results": 0}),
434                ai_agents_core::ToolExecutionContext::test("test"),
435            )
436            .await;
437        assert!(!result.success);
438        assert!(result.output.contains("max_results must be greater than 0"));
439    }
440
441    #[tokio::test]
442    async fn git_status_rejects_zero_execution_context_limit_before_running_git() {
443        let mut context = ai_agents_core::ToolExecutionContext::test("test");
444        context.limits.max_results = Some(0);
445        let result = GitStatusTool::new()
446            .execute(serde_json::json!({"path": "missing"}), context)
447            .await;
448        assert!(!result.success);
449        assert!(result.output.contains("max_results must be greater than 0"));
450    }
451
452    #[tokio::test]
453    async fn git_status_reports_untracked_files() {
454        if !git_available() {
455            return;
456        }
457        let dir = init_repo();
458        std::fs::write(dir.path().join("a.txt"), "hello").unwrap();
459        let result = GitStatusTool::new()
460            .execute(
461                serde_json::json!({"path": dir.path()}),
462                ai_agents_core::ToolExecutionContext::test("test"),
463            )
464            .await;
465        assert!(result.success);
466        assert!(result.output.contains("untracked"));
467    }
468
469    #[tokio::test]
470    async fn git_diff_returns_bounded_diff() {
471        if !git_available() {
472            return;
473        }
474        let dir = init_repo();
475        std::fs::write(dir.path().join("a.txt"), "hello\n").unwrap();
476        run_git(dir.path(), &["add", "a.txt"]).unwrap();
477        run_git(dir.path(), &["commit", "-m", "initial"]).unwrap();
478        std::fs::write(dir.path().join("a.txt"), "hello\nworld\n").unwrap();
479        let result = GitDiffTool::new()
480            .execute(
481                serde_json::json!({"path": dir.path(), "max_output_chars": 20}),
482                ai_agents_core::ToolExecutionContext::test("test"),
483            )
484            .await;
485        assert!(result.success);
486        assert!(result.output.contains("truncated"));
487    }
488}