| 1 | //! Git power tools: `git_status` and `git_diff`. |
| 2 | //! |
| 3 | //! These tools are read-only wrappers around common git inspection commands, |
| 4 | //! scoped to the workspace and optionally to a sub-path within it. |
| 5 | |
| 6 | use std::fs; |
| 7 | use std::path::{Path, PathBuf}; |
| 8 | use std::process::Command; |
| 9 | |
| 10 | use async_trait::async_trait; |
| 11 | use serde_json::{Value, json}; |
| 12 | |
| 13 | use super::spec::{ |
| 14 | ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, |
| 15 | optional_bool, optional_str, optional_u64, |
| 16 | }; |
| 17 | |
| 18 | const MAX_OUTPUT_CHARS: usize = 40_000; |
| 19 | const DEFAULT_UNIFIED: u64 = 3; |
| 20 | const MAX_UNIFIED: u64 = 50; |
| 21 | |
| 22 | // === GitStatusTool === |
| 23 | |
| 24 | /// Tool for reading the concise git status of the workspace. |
| 25 | pub struct GitStatusTool; |
| 26 | |
| 27 | #[async_trait] |
| 28 | impl ToolSpec for GitStatusTool { |
| 29 | fn name(&self) -> &'static str { |
| 30 | "git_status" |
| 31 | } |
| 32 | |
| 33 | fn description(&self) -> &'static str { |
| 34 | "Run `git status --porcelain=v1 -b` in the workspace (optionally scoped to a path)." |
| 35 | } |
| 36 | |
| 37 | fn input_schema(&self) -> Value { |
| 38 | json!({ |
| 39 | "type": "object", |
| 40 | "properties": { |
| 41 | "path": { |
| 42 | "type": "string", |
| 43 | "description": "Optional subdirectory or file to scope the status to (must be within the workspace)." |
| 44 | } |
| 45 | }, |
| 46 | "additionalProperties": false |
| 47 | }) |
| 48 | } |
| 49 | |
| 50 | fn capabilities(&self) -> Vec<ToolCapability> { |
| 51 | vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable] |
| 52 | } |
| 53 | |
| 54 | fn approval_requirement(&self) -> ApprovalRequirement { |
| 55 | ApprovalRequirement::Auto |
| 56 | } |
| 57 | |
| 58 | fn supports_parallel(&self) -> bool { |
| 59 | true |
| 60 | } |
| 61 | |
| 62 | async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> { |
| 63 | let git_ctx = resolve_git_context(context, optional_str(&input, "path"))?; |
| 64 | |
| 65 | let mut args = vec![ |
| 66 | "status".to_string(), |
| 67 | "--porcelain=v1".to_string(), |
| 68 | "-b".to_string(), |
| 69 | ]; |
| 70 | if let Some(pathspec) = &git_ctx.pathspec { |
| 71 | args.push("--".to_string()); |
| 72 | args.push(pathspec.display().to_string()); |
| 73 | } |
| 74 | |
| 75 | let command_str = format_command(&git_ctx.working_dir, &args); |
| 76 | let output = run_git_command(&git_ctx.working_dir, &args)?; |
| 77 | |
| 78 | if !output.status.success() { |
| 79 | let stderr = String::from_utf8_lossy(&output.stderr); |
| 80 | let message = format!("git status failed: {}", stderr.trim()); |
| 81 | return Ok(ToolResult::error(message).with_metadata(json!({ |
| 82 | "command": command_str, |
| 83 | "exit_code": output.status.code(), |
| 84 | "stderr": stderr.trim(), |
| 85 | }))); |
| 86 | } |
| 87 | |
| 88 | let stdout = String::from_utf8_lossy(&output.stdout); |
| 89 | let (content, truncated, omitted_chars) = truncate_with_note(&stdout, MAX_OUTPUT_CHARS); |
| 90 | |
| 91 | Ok(ToolResult::success(content).with_metadata(json!({ |
| 92 | "command": command_str, |
| 93 | "working_dir": git_ctx.working_dir, |
| 94 | "pathspec": git_ctx.pathspec, |
| 95 | "truncated": truncated, |
| 96 | "omitted_chars": omitted_chars, |
| 97 | }))) |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | // === GitDiffTool === |
| 102 | |
| 103 | /// Tool for reading git diffs in the workspace. |
| 104 | pub struct GitDiffTool; |
| 105 | |
| 106 | #[async_trait] |
| 107 | impl ToolSpec for GitDiffTool { |
| 108 | fn name(&self) -> &'static str { |
| 109 | "git_diff" |
| 110 | } |
| 111 | |
| 112 | fn description(&self) -> &'static str { |
| 113 | "Run `git diff` in the workspace with sensible defaults and safe truncation." |
| 114 | } |
| 115 | |
| 116 | fn input_schema(&self) -> Value { |
| 117 | json!({ |
| 118 | "type": "object", |
| 119 | "properties": { |
| 120 | "path": { |
| 121 | "type": "string", |
| 122 | "description": "Optional subdirectory or file to scope the diff to (must be within the workspace)." |
| 123 | }, |
| 124 | "cached": { |
| 125 | "type": "boolean", |
| 126 | "description": "When true, diff staged changes (`--cached`)." |
| 127 | }, |
| 128 | "unified": { |
| 129 | "type": "integer", |
| 130 | "minimum": 0, |
| 131 | "maximum": MAX_UNIFIED, |
| 132 | "default": DEFAULT_UNIFIED, |
| 133 | "description": "Number of context lines to include around changes." |
| 134 | } |
| 135 | }, |
| 136 | "additionalProperties": false |
| 137 | }) |
| 138 | } |
| 139 | |
| 140 | fn capabilities(&self) -> Vec<ToolCapability> { |
| 141 | vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable] |
| 142 | } |
| 143 | |
| 144 | fn approval_requirement(&self) -> ApprovalRequirement { |
| 145 | ApprovalRequirement::Auto |
| 146 | } |
| 147 | |
| 148 | fn supports_parallel(&self) -> bool { |
| 149 | true |
| 150 | } |
| 151 | |
| 152 | async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> { |
| 153 | let git_ctx = resolve_git_context(context, optional_str(&input, "path"))?; |
| 154 | let cached = optional_bool(&input, "cached", false); |
| 155 | let unified = optional_u64(&input, "unified", DEFAULT_UNIFIED).min(MAX_UNIFIED); |
| 156 | |
| 157 | let mut args = vec![ |
| 158 | "diff".to_string(), |
| 159 | "--no-color".to_string(), |
| 160 | "--no-ext-diff".to_string(), |
| 161 | format!("--unified={unified}"), |
| 162 | ]; |
| 163 | if cached { |
| 164 | args.push("--cached".to_string()); |
| 165 | } |
| 166 | if let Some(pathspec) = &git_ctx.pathspec { |
| 167 | args.push("--".to_string()); |
| 168 | args.push(pathspec.display().to_string()); |
| 169 | } |
| 170 | |
| 171 | let command_str = format_command(&git_ctx.working_dir, &args); |
| 172 | let output = run_git_command(&git_ctx.working_dir, &args)?; |
| 173 | |
| 174 | if !output.status.success() { |
| 175 | let stderr = String::from_utf8_lossy(&output.stderr); |
| 176 | let message = format!("git diff failed: {}", stderr.trim()); |
| 177 | return Ok(ToolResult::error(message).with_metadata(json!({ |
| 178 | "command": command_str, |
| 179 | "exit_code": output.status.code(), |
| 180 | "stderr": stderr.trim(), |
| 181 | }))); |
| 182 | } |
| 183 | |
| 184 | let stdout = String::from_utf8_lossy(&output.stdout); |
| 185 | let (content, truncated, omitted_chars) = truncate_with_note(&stdout, MAX_OUTPUT_CHARS); |
| 186 | |
| 187 | Ok(ToolResult::success(content).with_metadata(json!({ |
| 188 | "command": command_str, |
| 189 | "working_dir": git_ctx.working_dir, |
| 190 | "pathspec": git_ctx.pathspec, |
| 191 | "cached": cached, |
| 192 | "unified": unified, |
| 193 | "truncated": truncated, |
| 194 | "omitted_chars": omitted_chars, |
| 195 | }))) |
| 196 | } |
| 197 | } |
| 198 | |
| 199 | // === Helpers === |
| 200 | |
| 201 | struct GitContext { |
| 202 | working_dir: PathBuf, |
| 203 | pathspec: Option<PathBuf>, |
| 204 | } |
| 205 | |
| 206 | fn resolve_git_context(context: &ToolContext, path: Option<&str>) -> Result<GitContext, ToolError> { |
| 207 | let workspace = canonical_or_workspace(&context.workspace); |
| 208 | let mut working_dir = workspace.clone(); |
| 209 | let mut pathspec = None; |
| 210 | |
| 211 | if let Some(raw) = path { |
| 212 | let resolved = context.resolve_path(raw)?; |
| 213 | let metadata = fs::metadata(&resolved).map_err(|e| { |
| 214 | ToolError::invalid_input(format!( |
| 215 | "Path does not exist or is not accessible: {raw} ({e})" |
| 216 | )) |
| 217 | })?; |
| 218 | |
| 219 | if metadata.is_dir() { |
| 220 | working_dir = resolved; |
| 221 | pathspec = Some(PathBuf::from(".")); |
| 222 | } else { |
| 223 | // For file paths, run from the parent and scope to the file name. |
| 224 | let parent = resolved.parent().ok_or_else(|| { |
| 225 | ToolError::invalid_input(format!("Path has no parent directory: {raw}")) |
| 226 | })?; |
| 227 | working_dir = parent.to_path_buf(); |
| 228 | pathspec = Some(pathspec_from(&working_dir, &resolved)); |
| 229 | } |
| 230 | } |
| 231 | |
| 232 | if !working_dir.exists() { |
| 233 | return Err(ToolError::invalid_input(format!( |
| 234 | "Working directory does not exist: {}", |
| 235 | working_dir.display() |
| 236 | ))); |
| 237 | } |
| 238 | |
| 239 | Ok(GitContext { |
| 240 | working_dir, |
| 241 | pathspec, |
| 242 | }) |
| 243 | } |
| 244 | |
| 245 | fn canonical_or_workspace(workspace: &Path) -> PathBuf { |
| 246 | workspace |
| 247 | .canonicalize() |
| 248 | .unwrap_or_else(|_| workspace.to_path_buf()) |
| 249 | } |
| 250 | |
| 251 | fn pathspec_from(working_dir: &Path, resolved: &Path) -> PathBuf { |
| 252 | match resolved.strip_prefix(working_dir) { |
| 253 | Ok(rel) if rel.as_os_str().is_empty() => PathBuf::from("."), |
| 254 | Ok(rel) => rel.to_path_buf(), |
| 255 | Err(_) => PathBuf::from("."), |
| 256 | } |
| 257 | } |
| 258 | |
| 259 | fn run_git_command(working_dir: &Path, args: &[String]) -> Result<std::process::Output, ToolError> { |
| 260 | let mut cmd = Command::new("git"); |
| 261 | cmd.args(args).current_dir(working_dir); |
| 262 | cmd.output().map_err(|e| { |
| 263 | if e.kind() == std::io::ErrorKind::NotFound { |
| 264 | ToolError::not_available("git is not installed or not in PATH") |
| 265 | } else { |
| 266 | ToolError::execution_failed(format!("Failed to run git: {e}")) |
| 267 | } |
| 268 | }) |
| 269 | } |
| 270 | |
| 271 | fn format_command(working_dir: &Path, args: &[String]) -> String { |
| 272 | format!( |
| 273 | "git -C {} {}", |
| 274 | working_dir.display(), |
| 275 | args.iter() |
| 276 | .map(String::as_str) |
| 277 | .collect::<Vec<_>>() |
| 278 | .join(" ") |
| 279 | ) |
| 280 | } |
| 281 | |
| 282 | fn truncate_with_note(text: &str, max_chars: usize) -> (String, bool, usize) { |
| 283 | if text.chars().count() <= max_chars { |
| 284 | return (text.to_string(), false, 0); |
| 285 | } |
| 286 | let end = char_boundary_index(text, max_chars); |
| 287 | let truncated = &text[..end]; |
| 288 | let omitted_chars = text |
| 289 | .chars() |
| 290 | .count() |
| 291 | .saturating_sub(truncated.chars().count()); |
| 292 | let note = format!( |
| 293 | "\n\n[output truncated to {max_chars} characters; {omitted_chars} characters omitted]" |
| 294 | ); |
| 295 | (format!("{truncated}{note}"), true, omitted_chars) |
| 296 | } |
| 297 | |
| 298 | fn char_boundary_index(text: &str, max_chars: usize) -> usize { |
| 299 | if max_chars == 0 { |
| 300 | return 0; |
| 301 | } |
| 302 | for (count, (idx, _)) in text.char_indices().enumerate() { |
| 303 | if count == max_chars { |
| 304 | return idx; |
| 305 | } |
| 306 | } |
| 307 | text.len() |
| 308 | } |
| 309 | |
| 310 | #[cfg(test)] |
| 311 | mod tests { |
| 312 | use super::*; |
| 313 | use std::fs; |
| 314 | use std::process::Command; |
| 315 | use tempfile::tempdir; |
| 316 | |
| 317 | fn git_available() -> bool { |
| 318 | Command::new("git") |
| 319 | .arg("--version") |
| 320 | .output() |
| 321 | .map(|o| o.status.success()) |
| 322 | .unwrap_or(false) |
| 323 | } |
| 324 | |
| 325 | fn init_git_repo(root: &Path) { |
| 326 | let run = |args: &[&str]| { |
| 327 | let status = Command::new("git") |
| 328 | .args(args) |
| 329 | .current_dir(root) |
| 330 | .status() |
| 331 | .expect("git should spawn"); |
| 332 | assert!(status.success(), "git {:?} failed", args); |
| 333 | }; |
| 334 | |
| 335 | run(&["init", "-q"]); |
| 336 | run(&["config", "user.email", "test@example.com"]); |
| 337 | run(&["config", "user.name", "Test User"]); |
| 338 | } |
| 339 | |
| 340 | fn commit_all(root: &Path, message: &str) { |
| 341 | let run = |args: &[&str]| { |
| 342 | let status = Command::new("git") |
| 343 | .args(args) |
| 344 | .current_dir(root) |
| 345 | .status() |
| 346 | .expect("git should spawn"); |
| 347 | assert!(status.success(), "git {:?} failed", args); |
| 348 | }; |
| 349 | run(&["add", "."]); |
| 350 | run(&["commit", "-q", "-m", message]); |
| 351 | } |
| 352 | |
| 353 | #[tokio::test] |
| 354 | async fn git_status_reports_branch_and_changes() { |
| 355 | if !git_available() { |
| 356 | return; |
| 357 | } |
| 358 | let tmp = tempdir().expect("tempdir"); |
| 359 | init_git_repo(tmp.path()); |
| 360 | |
| 361 | let file = tmp.path().join("file.txt"); |
| 362 | fs::write(&file, "hello\n").expect("write"); |
| 363 | commit_all(tmp.path(), "init"); |
| 364 | |
| 365 | fs::write(&file, "hello\nworld\n").expect("modify"); |
| 366 | |
| 367 | let ctx = ToolContext::new(tmp.path()); |
| 368 | let tool = GitStatusTool; |
| 369 | let result = tool.execute(json!({}), &ctx).await.expect("execute"); |
| 370 | assert!(result.success); |
| 371 | assert!(result.content.contains("##")); |
| 372 | assert!(result.content.contains("file.txt")); |
| 373 | } |
| 374 | |
| 375 | #[tokio::test] |
| 376 | async fn git_diff_supports_cached_and_path_scoping() { |
| 377 | if !git_available() { |
| 378 | return; |
| 379 | } |
| 380 | let tmp = tempdir().expect("tempdir"); |
| 381 | init_git_repo(tmp.path()); |
| 382 | |
| 383 | let subdir = tmp.path().join("src"); |
| 384 | fs::create_dir_all(&subdir).expect("mkdir"); |
| 385 | let file = subdir.join("lib.rs"); |
| 386 | fs::write(&file, "pub fn one() -> i32 { 1 }\n").expect("write"); |
| 387 | commit_all(tmp.path(), "init"); |
| 388 | |
| 389 | fs::write(&file, "pub fn one() -> i32 { 2 }\n").expect("modify"); |
| 390 | |
| 391 | let ctx = ToolContext::new(tmp.path()); |
| 392 | let tool = GitDiffTool; |
| 393 | |
| 394 | let uncached = tool |
| 395 | .execute(json!({ "path": "src" }), &ctx) |
| 396 | .await |
| 397 | .expect("diff"); |
| 398 | assert!(uncached.success); |
| 399 | assert!(uncached.content.contains("diff --git")); |
| 400 | assert!(uncached.content.contains("lib.rs")); |
| 401 | |
| 402 | let _ = Command::new("git") |
| 403 | .args(["add", "src/lib.rs"]) |
| 404 | .current_dir(tmp.path()) |
| 405 | .status() |
| 406 | .expect("git add"); |
| 407 | |
| 408 | let cached = tool |
| 409 | .execute(json!({ "path": "src", "cached": true }), &ctx) |
| 410 | .await |
| 411 | .expect("diff cached"); |
| 412 | assert!(cached.success); |
| 413 | assert!(cached.content.contains("diff --git")); |
| 414 | assert!( |
| 415 | cached |
| 416 | .metadata |
| 417 | .as_ref() |
| 418 | .and_then(|m| m.get("cached")) |
| 419 | .and_then(Value::as_bool) |
| 420 | .unwrap_or(false) |
| 421 | ); |
| 422 | } |
| 423 | |
| 424 | #[test] |
| 425 | fn truncation_adds_note() { |
| 426 | let long = "a".repeat(MAX_OUTPUT_CHARS + 100); |
| 427 | let (truncated, did_truncate, omitted) = truncate_with_note(&long, MAX_OUTPUT_CHARS); |
| 428 | assert!(did_truncate); |
| 429 | assert!(omitted > 0); |
| 430 | assert!(truncated.contains("output truncated")); |
| 431 | } |
| 432 | } |
| 433 |