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
19pub struct GitStatusTool;
21
22impl GitStatusTool {
23 pub fn new() -> Self {
25 Self
26 }
27}
28
29impl Default for GitStatusTool {
30 fn default() -> Self {
31 Self::new()
32 }
33}
34
35pub struct GitDiffTool;
37
38impl GitDiffTool {
39 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 #[serde(default)]
55 path: Option<String>,
56 #[serde(default = "default_true")]
58 include_untracked: bool,
59 #[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 #[serde(default)]
88 path: Option<String>,
89 #[serde(default)]
91 staged: bool,
92 #[serde(default)]
94 paths: Vec<String>,
95 #[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}