返回 DeepSeek-TUI-2026
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 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
433 lines RUST