返回 CodeWhale
git.rs
根目录 / crates / tui / src / tools / git.rs
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
521 lines RUST