| 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 | |
| 9 | use async_trait::async_trait; |
| 10 | use serde_json::{Value, json}; |
| 11 | |
| 12 | use crate::dependencies::ExternalTool; |
| 13 | |
| 14 | use super::spec::{ |
| 15 | ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, |
| 16 | optional_bool, optional_str, optional_u64, |
| 17 | }; |
| 18 | |
| 19 | const MAX_OUTPUT_CHARS: usize = 40_000; |
| 20 | const DEFAULT_UNIFIED: u64 = 3; |
| 21 | const MAX_UNIFIED: u64 = 50; |
| 22 | |
| 23 | // === GitStatusTool === |
| 24 | |
| 25 | /// Tool for reading the concise git status of the workspace. |
| 26 | pub struct GitStatusTool; |
| 27 | |
| 28 | #[async_trait] |
| 29 | impl ToolSpec for GitStatusTool { |
| 30 | fn name(&self) -> &'static str { |
| 31 | "git_status" |
| 32 | } |
| 33 | |
| 34 | fn model_visible(&self) -> bool { |
| 35 | false |
| 36 | } |
| 37 | |
| 38 | fn description(&self) -> &'static str { |
| 39 | "Run `git status --porcelain=v1 -b` in the workspace (optionally scoped to a path)." |
| 40 | } |
| 41 | |
| 42 | fn input_schema(&self) -> Value { |
| 43 | json!({ |
| 44 | "type": "object", |
| 45 | "properties": { |
| 46 | "path": { |
| 47 | "type": "string", |
| 48 | "description": "Optional subdirectory or file to scope the status to (must be within the workspace)." |
| 49 | } |
| 50 | }, |
| 51 | "additionalProperties": false |
| 52 | }) |
| 53 | } |
| 54 | |
| 55 | fn capabilities(&self) -> Vec<ToolCapability> { |
| 56 | vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable] |
| 57 | } |
| 58 | |
| 59 | fn approval_requirement(&self) -> ApprovalRequirement { |
| 60 | ApprovalRequirement::Auto |
| 61 | } |
| 62 | |
| 63 | fn supports_parallel(&self) -> bool { |
| 64 | true |
| 65 | } |
| 66 | |
| 67 | async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> { |
| 68 | let git_ctx = resolve_git_context(context, optional_str(&input, "path")?)?; |
| 69 | |
| 70 | let mut args = vec![ |
| 71 | "-c".to_string(), |
| 72 | "core.quotepath=false".to_string(), |
| 73 | "status".to_string(), |
| 74 | "--porcelain=v1".to_string(), |
| 75 | "-b".to_string(), |
| 76 | ]; |
| 77 | if let Some(pathspec) = &git_ctx.pathspec { |
| 78 | args.push("--".to_string()); |
| 79 | args.push(pathspec.display().to_string()); |
| 80 | } |
| 81 | |
| 82 | let command_str = format_command(&git_ctx.working_dir, &args); |
| 83 | let output = run_git_command(&git_ctx.working_dir, &args)?; |
| 84 | |
| 85 | if !output.status.success() { |
| 86 | let stderr = String::from_utf8_lossy(&output.stderr); |
| 87 | let message = format!("git status failed: {}", stderr.trim()); |
| 88 | return Ok(ToolResult::error(message).with_metadata(json!({ |
| 89 | "command": command_str, |
| 90 | "exit_code": output.status.code(), |
| 91 | "stderr": stderr.trim(), |
| 92 | }))); |
| 93 | } |
| 94 | |
| 95 | let stdout = String::from_utf8_lossy(&output.stdout); |
| 96 | let (content, truncated, omitted_chars) = truncate_with_note(&stdout, MAX_OUTPUT_CHARS); |
| 97 | |
| 98 | Ok(ToolResult::success(content).with_metadata(json!({ |
| 99 | "command": command_str, |
| 100 | "working_dir": git_ctx.working_dir, |
| 101 | "pathspec": git_ctx.pathspec, |
| 102 | "truncated": truncated, |
| 103 | "omitted_chars": omitted_chars, |
| 104 | }))) |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | // === GitDiffTool === |
| 109 | |
| 110 | /// Tool for reading git diffs in the workspace. |
| 111 | pub struct GitDiffTool; |
| 112 | |
| 113 | #[async_trait] |
| 114 | impl ToolSpec for GitDiffTool { |
| 115 | fn name(&self) -> &'static str { |
| 116 | "git_diff" |
| 117 | } |
| 118 | |
| 119 | fn model_visible(&self) -> bool { |
| 120 | false |
| 121 | } |
| 122 | |
| 123 | fn description(&self) -> &'static str { |
| 124 | "Run `git diff` in the workspace with sensible defaults and safe truncation." |
| 125 | } |
| 126 | |
| 127 | fn input_schema(&self) -> Value { |
| 128 | json!({ |
| 129 | "type": "object", |
| 130 | "properties": { |
| 131 | "path": { |
| 132 | "type": "string", |
| 133 | "description": "Optional subdirectory or file to scope the diff to (must be within the workspace)." |
| 134 | }, |
| 135 | "cached": { |
| 136 | "type": "boolean", |
| 137 | "description": "When true, diff staged changes (`--cached`)." |
| 138 | }, |
| 139 | "unified": { |
| 140 | "type": "integer", |
| 141 | "minimum": 0, |
| 142 | "maximum": MAX_UNIFIED, |
| 143 | "default": DEFAULT_UNIFIED, |
| 144 | "description": "Number of context lines to include around changes." |
| 145 | } |
| 146 | }, |
| 147 | "additionalProperties": false |
| 148 | }) |
| 149 | } |
| 150 | |
| 151 | fn capabilities(&self) -> Vec<ToolCapability> { |
| 152 | vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable] |
| 153 | } |
| 154 | |
| 155 | fn approval_requirement(&self) -> ApprovalRequirement { |
| 156 | ApprovalRequirement::Auto |
| 157 | } |
| 158 | |
| 159 | fn supports_parallel(&self) -> bool { |
| 160 | true |
| 161 | } |
| 162 | |
| 163 | async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> { |
| 164 | let git_ctx = resolve_git_context(context, optional_str(&input, "path")?)?; |
| 165 | let cached = optional_bool(&input, "cached", false)?; |
| 166 | let unified = optional_u64(&input, "unified", DEFAULT_UNIFIED)?.min(MAX_UNIFIED); |
| 167 | |
| 168 | let mut args = vec![ |
| 169 | "-c".to_string(), |
| 170 | "core.quotepath=false".to_string(), |
| 171 | "diff".to_string(), |
| 172 | "--no-color".to_string(), |
| 173 | "--no-ext-diff".to_string(), |
| 174 | format!("--unified={unified}"), |
| 175 | ]; |
| 176 | if cached { |
| 177 | args.push("--cached".to_string()); |
| 178 | } |
| 179 | if let Some(pathspec) = &git_ctx.pathspec { |
| 180 | args.push("--".to_string()); |
| 181 | args.push(pathspec.display().to_string()); |
| 182 | } |
| 183 | |
| 184 | let command_str = format_command(&git_ctx.working_dir, &args); |
| 185 | let output = run_git_command(&git_ctx.working_dir, &args)?; |
| 186 | |
| 187 | if !output.status.success() { |
| 188 | let stderr = String::from_utf8_lossy(&output.stderr); |
| 189 | let message = format!("git diff failed: {}", stderr.trim()); |
| 190 | return Ok(ToolResult::error(message).with_metadata(json!({ |
| 191 | "command": command_str, |
| 192 | "exit_code": output.status.code(), |
| 193 | "stderr": stderr.trim(), |
| 194 | }))); |
| 195 | } |
| 196 | |
| 197 | let stdout = String::from_utf8_lossy(&output.stdout); |
| 198 | let (content, truncated, omitted_chars) = truncate_with_note(&stdout, MAX_OUTPUT_CHARS); |
| 199 | |
| 200 | Ok(ToolResult::success(content).with_metadata(json!({ |
| 201 | "command": command_str, |
| 202 | "working_dir": git_ctx.working_dir, |
| 203 | "pathspec": git_ctx.pathspec, |
| 204 | "cached": cached, |
| 205 | "unified": unified, |
| 206 | "truncated": truncated, |
| 207 | "omitted_chars": omitted_chars, |
| 208 | }))) |
| 209 | } |
| 210 | } |
| 211 | |
| 212 | // === Helpers === |
| 213 | |
| 214 | struct GitContext { |
| 215 | working_dir: PathBuf, |
| 216 | pathspec: Option<PathBuf>, |
| 217 | } |
| 218 | |
| 219 | fn resolve_git_context(context: &ToolContext, path: Option<&str>) -> Result<GitContext, ToolError> { |
| 220 | let workspace = canonical_or_workspace(&context.workspace); |
| 221 | let mut working_dir = workspace.clone(); |
| 222 | let mut pathspec = None; |
| 223 | |
| 224 | if let Some(raw) = path { |
| 225 | let resolved = context.resolve_path(raw)?; |
| 226 | let metadata = fs::metadata(&resolved).map_err(|e| { |
| 227 | ToolError::invalid_input(format!( |
| 228 | "Path does not exist or is not accessible: {raw} ({e})" |
| 229 | )) |
| 230 | })?; |
| 231 | |
| 232 | if metadata.is_dir() { |
| 233 | working_dir = resolved; |
| 234 | pathspec = Some(PathBuf::from(".")); |
| 235 | } else { |
| 236 | // For file paths, run from the parent and scope to the file name. |
| 237 | let parent = resolved.parent().ok_or_else(|| { |
| 238 | ToolError::invalid_input(format!("Path has no parent directory: {raw}")) |
| 239 | })?; |
| 240 | working_dir = parent.to_path_buf(); |
| 241 | pathspec = Some(pathspec_from(&working_dir, &resolved)); |
| 242 | } |
| 243 | } |
| 244 | |
| 245 | if !working_dir.exists() { |
| 246 | return Err(ToolError::invalid_input(format!( |
| 247 | "Working directory does not exist: {}", |
| 248 | working_dir.display() |
| 249 | ))); |
| 250 | } |
| 251 | |
| 252 | Ok(GitContext { |
| 253 | working_dir, |
| 254 | pathspec, |
| 255 | }) |
| 256 | } |
| 257 | |
| 258 | fn canonical_or_workspace(workspace: &Path) -> PathBuf { |
| 259 | workspace |
| 260 | .canonicalize() |
| 261 | .unwrap_or_else(|_| workspace.to_path_buf()) |
| 262 | } |
| 263 | |
| 264 | fn pathspec_from(working_dir: &Path, resolved: &Path) -> PathBuf { |
| 265 | match resolved.strip_prefix(working_dir) { |
| 266 | Ok(rel) if rel.as_os_str().is_empty() => PathBuf::from("."), |
| 267 | Ok(rel) => rel.to_path_buf(), |
| 268 | Err(_) => PathBuf::from("."), |
| 269 | } |
| 270 | } |
| 271 | |
| 272 | fn run_git_command(working_dir: &Path, args: &[String]) -> Result<std::process::Output, ToolError> { |
| 273 | let Some(mut cmd) = crate::dependencies::Git::command() else { |
| 274 | return Err(ToolError::not_available( |
| 275 | "git is not installed or not in PATH", |
| 276 | )); |
| 277 | }; |
| 278 | cmd.args(args).current_dir(working_dir); |
| 279 | cmd.output().map_err(|e| { |
| 280 | if e.kind() == std::io::ErrorKind::NotFound { |
| 281 | ToolError::not_available("git is not installed or not in PATH") |
| 282 | } else { |
| 283 | ToolError::execution_failed(format!("Failed to run git: {e}")) |
| 284 | } |
| 285 | }) |
| 286 | } |
| 287 | |
| 288 | fn format_command(working_dir: &Path, args: &[String]) -> String { |
| 289 | // `[String]::join` produces the same string as collecting `&str` first, so |
| 290 | // join the slice directly and skip the intermediate `Vec<&str>` allocation. |
| 291 | format!("git -C {} {}", working_dir.display(), args.join(" ")) |
| 292 | } |
| 293 | |
| 294 | fn truncate_with_note(text: &str, max_chars: usize) -> (String, bool, usize) { |
| 295 | if text.chars().count() <= max_chars { |
| 296 | return (text.to_string(), false, 0); |
| 297 | } |
| 298 | let end = char_boundary_index(text, max_chars); |
| 299 | let truncated = &text[..end]; |
| 300 | let omitted_chars = text |
| 301 | .chars() |
| 302 | .count() |
| 303 | .saturating_sub(truncated.chars().count()); |
| 304 | let note = format!( |
| 305 | "\n\n[output truncated to {max_chars} characters; {omitted_chars} characters omitted]" |
| 306 | ); |
| 307 | (format!("{truncated}{note}"), true, omitted_chars) |
| 308 | } |
| 309 | |
| 310 | fn char_boundary_index(text: &str, max_chars: usize) -> usize { |
| 311 | if max_chars == 0 { |
| 312 | return 0; |
| 313 | } |
| 314 | for (count, (idx, _)) in text.char_indices().enumerate() { |
| 315 | if count == max_chars { |
| 316 | return idx; |
| 317 | } |
| 318 | } |
| 319 | text.len() |
| 320 | } |
| 321 | |
| 322 | #[cfg(test)] |
| 323 | mod tests { |
| 324 | use super::*; |
| 325 | use std::fs; |
| 326 | use tempfile::tempdir; |
| 327 | |
| 328 | fn git_available() -> bool { |
| 329 | crate::dependencies::Git::available() |
| 330 | } |
| 331 | |
| 332 | fn init_git_repo(root: &Path) { |
| 333 | let run = |args: &[&str]| { |
| 334 | let status = crate::dependencies::Git::status(args, root).expect("git should spawn"); |
| 335 | assert!(status.success(), "git {args:?} failed"); |
| 336 | }; |
| 337 | |
| 338 | run(&["init", "-q"]); |
| 339 | run(&["config", "core.autocrlf", "false"]); |
| 340 | run(&["config", "user.email", "test@example.com"]); |
| 341 | run(&["config", "user.name", "Test User"]); |
| 342 | } |
| 343 | |
| 344 | fn commit_all(root: &Path, message: &str) { |
| 345 | let run = |args: &[&str]| { |
| 346 | let status = crate::dependencies::Git::status(args, root).expect("git should spawn"); |
| 347 | assert!(status.success(), "git {args:?} failed"); |
| 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_status_reports_unquoted_unicode_paths() { |
| 377 | if !git_available() { |
| 378 | return; |
| 379 | } |
| 380 | |
| 381 | let tmp = tempdir().expect("tempdir"); |
| 382 | init_git_repo(tmp.path()); |
| 383 | |
| 384 | let file = tmp.path().join("中文-данные.txt"); |
| 385 | fs::write(&file, "hello\n").expect("write"); |
| 386 | commit_all(tmp.path(), "init"); |
| 387 | |
| 388 | fs::write(&file, "hello\nworld\n").expect("modify"); |
| 389 | |
| 390 | let ctx = ToolContext::new(tmp.path()); |
| 391 | let tool = GitStatusTool; |
| 392 | let result = tool.execute(json!({}), &ctx).await.expect("execute"); |
| 393 | assert!(result.success); |
| 394 | assert!( |
| 395 | result |
| 396 | .metadata |
| 397 | .as_ref() |
| 398 | .and_then(|m| m.get("command")) |
| 399 | .and_then(Value::as_str) |
| 400 | .is_some_and(|command| command.contains("-c core.quotepath=false")) |
| 401 | ); |
| 402 | assert!(result.content.contains("中文-данные.txt")); |
| 403 | assert!(!result.content.contains("\\344")); |
| 404 | assert!(!result.content.contains("\\320")); |
| 405 | } |
| 406 | |
| 407 | #[tokio::test] |
| 408 | async fn git_diff_supports_cached_and_path_scoping() { |
| 409 | if !git_available() { |
| 410 | return; |
| 411 | } |
| 412 | let tmp = tempdir().expect("tempdir"); |
| 413 | init_git_repo(tmp.path()); |
| 414 | |
| 415 | let subdir = tmp.path().join("src"); |
| 416 | fs::create_dir_all(&subdir).expect("mkdir"); |
| 417 | let file = subdir.join("lib.rs"); |
| 418 | fs::write(&file, "pub fn one() -> i32 { 1 }\n").expect("write"); |
| 419 | commit_all(tmp.path(), "init"); |
| 420 | |
| 421 | fs::write(&file, "pub fn one() -> i32 { 2 }\n").expect("modify"); |
| 422 | |
| 423 | let ctx = ToolContext::new(tmp.path()); |
| 424 | let tool = GitDiffTool; |
| 425 | |
| 426 | let uncached = tool |
| 427 | .execute(json!({ "path": "src" }), &ctx) |
| 428 | .await |
| 429 | .expect("diff"); |
| 430 | assert!(uncached.success); |
| 431 | assert!(uncached.content.contains("diff --git")); |
| 432 | assert!(uncached.content.contains("lib.rs")); |
| 433 | |
| 434 | let _ = |
| 435 | crate::dependencies::Git::status(&["add", "src/lib.rs"], tmp.path()).expect("git add"); |
| 436 | |
| 437 | let cached = tool |
| 438 | .execute(json!({ "path": "src", "cached": true }), &ctx) |
| 439 | .await |
| 440 | .expect("diff cached"); |
| 441 | assert!(cached.success); |
| 442 | assert!(cached.content.contains("diff --git")); |
| 443 | assert!( |
| 444 | cached |
| 445 | .metadata |
| 446 | .as_ref() |
| 447 | .and_then(|m| m.get("cached")) |
| 448 | .and_then(Value::as_bool) |
| 449 | .unwrap_or(false) |
| 450 | ); |
| 451 | } |
| 452 | |
| 453 | #[tokio::test] |
| 454 | async fn git_diff_reports_unquoted_unicode_paths() { |
| 455 | if !git_available() { |
| 456 | return; |
| 457 | } |
| 458 | |
| 459 | let tmp = tempdir().expect("tempdir"); |
| 460 | init_git_repo(tmp.path()); |
| 461 | |
| 462 | let unicode_name = "\u{4e2d}\u{6587}-\u{0434}\u{0430}\u{043d}\u{043d}\u{044b}\u{0435}.txt"; |
| 463 | let file = tmp.path().join(unicode_name); |
| 464 | fs::write(&file, "hello\n").expect("write"); |
| 465 | commit_all(tmp.path(), "init"); |
| 466 | |
| 467 | fs::write(&file, "hello\nworld\n").expect("modify"); |
| 468 | |
| 469 | let ctx = ToolContext::new(tmp.path()); |
| 470 | let tool = GitDiffTool; |
| 471 | let result = tool.execute(json!({}), &ctx).await.expect("execute"); |
| 472 | |
| 473 | assert!(result.success); |
| 474 | assert!( |
| 475 | result |
| 476 | .metadata |
| 477 | .as_ref() |
| 478 | .and_then(|m| m.get("command")) |
| 479 | .and_then(Value::as_str) |
| 480 | .is_some_and(|command| command.contains("-c core.quotepath=false")) |
| 481 | ); |
| 482 | assert!(result.content.contains(unicode_name)); |
| 483 | assert!(!result.content.contains("\\344")); |
| 484 | assert!(!result.content.contains("\\320")); |
| 485 | } |
| 486 | |
| 487 | #[test] |
| 488 | fn format_command_joins_args_without_intermediate_vec() { |
| 489 | // Locks the output shape after dropping the collect-before-join |
| 490 | // allocation: joining the `&[String]` slice directly must be byte-for-byte |
| 491 | // identical to the previous `.map(String::as_str).collect().join(" ")`. |
| 492 | let args = vec![ |
| 493 | "-c".to_string(), |
| 494 | "core.quotepath=false".to_string(), |
| 495 | "status".to_string(), |
| 496 | "--porcelain=v1".to_string(), |
| 497 | "-b".to_string(), |
| 498 | ]; |
| 499 | let rendered = format_command(Path::new("/tmp/repo"), &args); |
| 500 | assert_eq!( |
| 501 | rendered, |
| 502 | "git -C /tmp/repo -c core.quotepath=false status --porcelain=v1 -b" |
| 503 | ); |
| 504 | |
| 505 | // Empty args still render cleanly (trailing space, matching prior behavior). |
| 506 | assert_eq!( |
| 507 | format_command(Path::new("/tmp/repo"), &[]), |
| 508 | "git -C /tmp/repo " |
| 509 | ); |
| 510 | } |
| 511 | |
| 512 | #[test] |
| 513 | fn truncation_adds_note() { |
| 514 | let long = "a".repeat(MAX_OUTPUT_CHARS + 100); |
| 515 | let (truncated, did_truncate, omitted) = truncate_with_note(&long, MAX_OUTPUT_CHARS); |
| 516 | assert!(did_truncate); |
| 517 | assert!(omitted > 0); |
| 518 | assert!(truncated.contains("output truncated")); |
| 519 | } |
| 520 | } |
| 521 |