返回 DeepSeek-TUI-2026
git_history.rs
根目录 / crates / tui / src / tools / git_history.rs
1 //! Git history tools: `git_log`, `git_show`, and `git_blame`.
2 //!
3 //! These tools provide read-only access to commit history and attribution
4 //! without exposing arbitrary shell execution.
5
6 use std::fs;
7 use std::path::{Path, PathBuf};
8 use std::process::{Command, Output};
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, required_str,
16 };
17
18 const MAX_OUTPUT_CHARS: usize = 40_000;
19 const DEFAULT_LOG_MAX_COUNT: u64 = 20;
20 const MAX_LOG_MAX_COUNT: u64 = 200;
21 const DEFAULT_UNIFIED: u64 = 3;
22 const MAX_UNIFIED: u64 = 50;
23 const DEFAULT_BLAME_START_LINE: u64 = 1;
24 const DEFAULT_BLAME_MAX_LINES: u64 = 200;
25 const MAX_BLAME_MAX_LINES: u64 = 2_000;
26
27 /// Tool for reading recent commit history.
28 pub struct GitLogTool;
29
30 #[async_trait]
31 impl ToolSpec for GitLogTool {
32 fn name(&self) -> &'static str {
33 "git_log"
34 }
35
36 fn description(&self) -> &'static str {
37 "Run `git log` in the workspace with optional path and author/date filters."
38 }
39
40 fn input_schema(&self) -> Value {
41 json!({
42 "type": "object",
43 "properties": {
44 "path": {
45 "type": "string",
46 "description": "Optional subdirectory or file path to scope history to."
47 },
48 "max_count": {
49 "type": "integer",
50 "minimum": 1,
51 "maximum": MAX_LOG_MAX_COUNT,
52 "default": DEFAULT_LOG_MAX_COUNT,
53 "description": "Maximum number of commits to return."
54 },
55 "author": {
56 "type": "string",
57 "description": "Optional git author filter (same semantics as `git log --author`)."
58 },
59 "since": {
60 "type": "string",
61 "description": "Optional lower date bound, e.g. '2 weeks ago' or ISO date."
62 },
63 "until": {
64 "type": "string",
65 "description": "Optional upper date bound, e.g. 'yesterday' or ISO date."
66 }
67 },
68 "additionalProperties": false
69 })
70 }
71
72 fn capabilities(&self) -> Vec<ToolCapability> {
73 vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
74 }
75
76 fn approval_requirement(&self) -> ApprovalRequirement {
77 ApprovalRequirement::Auto
78 }
79
80 fn supports_parallel(&self) -> bool {
81 true
82 }
83
84 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
85 let git_ctx = resolve_git_context(context, optional_str(&input, "path"))?;
86 let max_count =
87 optional_u64(&input, "max_count", DEFAULT_LOG_MAX_COUNT).clamp(1, MAX_LOG_MAX_COUNT);
88 let author = optional_str(&input, "author").map(ToOwned::to_owned);
89 let since = optional_str(&input, "since").map(ToOwned::to_owned);
90 let until = optional_str(&input, "until").map(ToOwned::to_owned);
91
92 let mut args = vec![
93 "log".to_string(),
94 "--no-color".to_string(),
95 format!("--max-count={max_count}"),
96 "--date=iso-strict".to_string(),
97 "--pretty=format:%H%nAuthor: %an <%ae>%nDate: %ad%nSubject: %s%n".to_string(),
98 ];
99 if let Some(author) = &author {
100 args.push(format!("--author={author}"));
101 }
102 if let Some(since) = &since {
103 args.push(format!("--since={since}"));
104 }
105 if let Some(until) = &until {
106 args.push(format!("--until={until}"));
107 }
108 if let Some(pathspec) = &git_ctx.pathspec {
109 args.push("--".to_string());
110 args.push(pathspec.display().to_string());
111 }
112
113 let command_str = format_command(&git_ctx.working_dir, &args);
114 let output = run_git_command(&git_ctx.working_dir, &args)?;
115 if !output.status.success() {
116 let stderr = String::from_utf8_lossy(&output.stderr);
117 return Ok(
118 ToolResult::error(format!("git log failed: {}", stderr.trim())).with_metadata(
119 json!({
120 "command": command_str,
121 "exit_code": output.status.code(),
122 "stderr": stderr.trim(),
123 }),
124 ),
125 );
126 }
127
128 let stdout = String::from_utf8_lossy(&output.stdout);
129 let (content, truncated, omitted_chars) = truncate_with_note(&stdout, MAX_OUTPUT_CHARS);
130 Ok(ToolResult::success(content).with_metadata(json!({
131 "command": command_str,
132 "working_dir": git_ctx.working_dir,
133 "pathspec": git_ctx.pathspec,
134 "max_count": max_count,
135 "author": author,
136 "since": since,
137 "until": until,
138 "truncated": truncated,
139 "omitted_chars": omitted_chars,
140 })))
141 }
142 }
143
144 /// Tool for showing a specific commit with optional patch/stat output.
145 pub struct GitShowTool;
146
147 #[async_trait]
148 impl ToolSpec for GitShowTool {
149 fn name(&self) -> &'static str {
150 "git_show"
151 }
152
153 fn description(&self) -> &'static str {
154 "Run `git show` for a specific revision with optional patch and stats."
155 }
156
157 fn input_schema(&self) -> Value {
158 json!({
159 "type": "object",
160 "properties": {
161 "rev": {
162 "type": "string",
163 "description": "Revision to show (commit SHA, tag, branch, or ref expression)."
164 },
165 "path": {
166 "type": "string",
167 "description": "Optional subdirectory or file path to scope output."
168 },
169 "patch": {
170 "type": "boolean",
171 "default": true,
172 "description": "Include patch hunks (default true)."
173 },
174 "stat": {
175 "type": "boolean",
176 "default": true,
177 "description": "Include --stat summary (default true)."
178 },
179 "unified": {
180 "type": "integer",
181 "minimum": 0,
182 "maximum": MAX_UNIFIED,
183 "default": DEFAULT_UNIFIED,
184 "description": "Context lines for patch output when patch=true."
185 }
186 },
187 "required": ["rev"],
188 "additionalProperties": false
189 })
190 }
191
192 fn capabilities(&self) -> Vec<ToolCapability> {
193 vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
194 }
195
196 fn approval_requirement(&self) -> ApprovalRequirement {
197 ApprovalRequirement::Auto
198 }
199
200 fn supports_parallel(&self) -> bool {
201 true
202 }
203
204 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
205 let rev = required_str(&input, "rev")?;
206 let git_ctx = resolve_git_context(context, optional_str(&input, "path"))?;
207 let patch = optional_bool(&input, "patch", true);
208 let stat = optional_bool(&input, "stat", true);
209 let unified = optional_u64(&input, "unified", DEFAULT_UNIFIED).min(MAX_UNIFIED);
210
211 let mut args = vec![
212 "show".to_string(),
213 "--no-color".to_string(),
214 "--no-ext-diff".to_string(),
215 ];
216 if patch {
217 args.push(format!("--unified={unified}"));
218 } else {
219 args.push("--no-patch".to_string());
220 }
221 if stat {
222 args.push("--stat".to_string());
223 }
224 args.push(rev.to_string());
225 if let Some(pathspec) = &git_ctx.pathspec {
226 args.push("--".to_string());
227 args.push(pathspec.display().to_string());
228 }
229
230 let command_str = format_command(&git_ctx.working_dir, &args);
231 let output = run_git_command(&git_ctx.working_dir, &args)?;
232 if !output.status.success() {
233 let stderr = String::from_utf8_lossy(&output.stderr);
234 return Ok(ToolResult::error(format!(
235 "git show failed for '{rev}': {}",
236 stderr.trim()
237 ))
238 .with_metadata(json!({
239 "command": command_str,
240 "exit_code": output.status.code(),
241 "stderr": stderr.trim(),
242 })));
243 }
244
245 let stdout = String::from_utf8_lossy(&output.stdout);
246 let (content, truncated, omitted_chars) = truncate_with_note(&stdout, MAX_OUTPUT_CHARS);
247 Ok(ToolResult::success(content).with_metadata(json!({
248 "command": command_str,
249 "working_dir": git_ctx.working_dir,
250 "pathspec": git_ctx.pathspec,
251 "rev": rev,
252 "patch": patch,
253 "stat": stat,
254 "unified": if patch { Some(unified) } else { None },
255 "truncated": truncated,
256 "omitted_chars": omitted_chars,
257 })))
258 }
259 }
260
261 /// Tool for attributing lines in a file to commits and authors.
262 pub struct GitBlameTool;
263
264 #[async_trait]
265 impl ToolSpec for GitBlameTool {
266 fn name(&self) -> &'static str {
267 "git_blame"
268 }
269
270 fn description(&self) -> &'static str {
271 "Run `git blame` on a file with optional revision and line-range controls."
272 }
273
274 fn input_schema(&self) -> Value {
275 json!({
276 "type": "object",
277 "properties": {
278 "path": {
279 "type": "string",
280 "description": "Path to a tracked file within the workspace."
281 },
282 "rev": {
283 "type": "string",
284 "description": "Optional revision to blame against (default: HEAD)."
285 },
286 "start_line": {
287 "type": "integer",
288 "minimum": 1,
289 "default": DEFAULT_BLAME_START_LINE,
290 "description": "First line to include in blame output."
291 },
292 "max_lines": {
293 "type": "integer",
294 "minimum": 1,
295 "maximum": MAX_BLAME_MAX_LINES,
296 "default": DEFAULT_BLAME_MAX_LINES,
297 "description": "Maximum number of lines to include."
298 },
299 "porcelain": {
300 "type": "boolean",
301 "default": false,
302 "description": "When true, emit `--line-porcelain` output."
303 }
304 },
305 "required": ["path"],
306 "additionalProperties": false
307 })
308 }
309
310 fn capabilities(&self) -> Vec<ToolCapability> {
311 vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
312 }
313
314 fn approval_requirement(&self) -> ApprovalRequirement {
315 ApprovalRequirement::Auto
316 }
317
318 fn supports_parallel(&self) -> bool {
319 true
320 }
321
322 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
323 let path_str = required_str(&input, "path")?;
324 let resolved_path = context.resolve_path(path_str)?;
325 let metadata = fs::metadata(&resolved_path).map_err(|e| {
326 ToolError::invalid_input(format!(
327 "Path does not exist or is not accessible: {path_str} ({e})"
328 ))
329 })?;
330 if !metadata.is_file() {
331 return Err(ToolError::invalid_input(format!(
332 "Path must point to a file: {path_str}"
333 )));
334 }
335
336 let working_dir = resolved_path.parent().ok_or_else(|| {
337 ToolError::invalid_input(format!("Path has no parent directory: {path_str}"))
338 })?;
339 let pathspec = pathspec_from(working_dir, &resolved_path);
340 let rev = optional_str(&input, "rev").unwrap_or("HEAD");
341 let start_line = optional_u64(&input, "start_line", DEFAULT_BLAME_START_LINE).max(1);
342 let max_lines = optional_u64(&input, "max_lines", DEFAULT_BLAME_MAX_LINES)
343 .clamp(1, MAX_BLAME_MAX_LINES);
344 let end_line = start_line.saturating_add(max_lines.saturating_sub(1));
345 let porcelain = optional_bool(&input, "porcelain", false);
346
347 let mut args = vec![
348 "blame".to_string(),
349 "--date=iso".to_string(),
350 format!("-L{start_line},{end_line}"),
351 ];
352 if porcelain {
353 args.push("--line-porcelain".to_string());
354 }
355 args.push(rev.to_string());
356 args.push("--".to_string());
357 args.push(pathspec.display().to_string());
358
359 let command_str = format_command(working_dir, &args);
360 let output = run_git_command(working_dir, &args)?;
361 if !output.status.success() {
362 let stderr = String::from_utf8_lossy(&output.stderr);
363 return Ok(ToolResult::error(format!(
364 "git blame failed for '{path_str}' at '{rev}': {}",
365 stderr.trim()
366 ))
367 .with_metadata(json!({
368 "command": command_str,
369 "exit_code": output.status.code(),
370 "stderr": stderr.trim(),
371 })));
372 }
373
374 let stdout = String::from_utf8_lossy(&output.stdout);
375 let (content, truncated, omitted_chars) = truncate_with_note(&stdout, MAX_OUTPUT_CHARS);
376 Ok(ToolResult::success(content).with_metadata(json!({
377 "command": command_str,
378 "working_dir": working_dir,
379 "pathspec": pathspec,
380 "rev": rev,
381 "start_line": start_line,
382 "max_lines": max_lines,
383 "porcelain": porcelain,
384 "truncated": truncated,
385 "omitted_chars": omitted_chars,
386 })))
387 }
388 }
389
390 struct GitContext {
391 working_dir: PathBuf,
392 pathspec: Option<PathBuf>,
393 }
394
395 fn resolve_git_context(context: &ToolContext, path: Option<&str>) -> Result<GitContext, ToolError> {
396 let workspace = canonical_or_workspace(&context.workspace);
397 let mut working_dir = workspace.clone();
398 let mut pathspec = None;
399
400 if let Some(raw) = path {
401 let resolved = context.resolve_path(raw)?;
402 let metadata = fs::metadata(&resolved).map_err(|e| {
403 ToolError::invalid_input(format!(
404 "Path does not exist or is not accessible: {raw} ({e})"
405 ))
406 })?;
407
408 if metadata.is_dir() {
409 working_dir = resolved;
410 pathspec = Some(PathBuf::from("."));
411 } else {
412 let parent = resolved.parent().ok_or_else(|| {
413 ToolError::invalid_input(format!("Path has no parent directory: {raw}"))
414 })?;
415 working_dir = parent.to_path_buf();
416 pathspec = Some(pathspec_from(&working_dir, &resolved));
417 }
418 }
419
420 if !working_dir.exists() {
421 return Err(ToolError::invalid_input(format!(
422 "Working directory does not exist: {}",
423 working_dir.display()
424 )));
425 }
426
427 Ok(GitContext {
428 working_dir,
429 pathspec,
430 })
431 }
432
433 fn canonical_or_workspace(workspace: &Path) -> PathBuf {
434 workspace
435 .canonicalize()
436 .unwrap_or_else(|_| workspace.to_path_buf())
437 }
438
439 fn pathspec_from(working_dir: &Path, resolved: &Path) -> PathBuf {
440 match resolved.strip_prefix(working_dir) {
441 Ok(rel) if rel.as_os_str().is_empty() => PathBuf::from("."),
442 Ok(rel) => rel.to_path_buf(),
443 Err(_) => PathBuf::from("."),
444 }
445 }
446
447 fn run_git_command(working_dir: &Path, args: &[String]) -> Result<Output, ToolError> {
448 let mut cmd = Command::new("git");
449 cmd.args(args).current_dir(working_dir);
450 cmd.output().map_err(|e| {
451 if e.kind() == std::io::ErrorKind::NotFound {
452 ToolError::not_available("git is not installed or not in PATH")
453 } else {
454 ToolError::execution_failed(format!("Failed to run git: {e}"))
455 }
456 })
457 }
458
459 fn format_command(working_dir: &Path, args: &[String]) -> String {
460 format!(
461 "git -C {} {}",
462 working_dir.display(),
463 args.iter()
464 .map(String::as_str)
465 .collect::<Vec<_>>()
466 .join(" ")
467 )
468 }
469
470 fn truncate_with_note(text: &str, max_chars: usize) -> (String, bool, usize) {
471 if text.chars().count() <= max_chars {
472 return (text.to_string(), false, 0);
473 }
474 let end = char_boundary_index(text, max_chars);
475 let truncated = &text[..end];
476 let omitted_chars = text
477 .chars()
478 .count()
479 .saturating_sub(truncated.chars().count());
480 let note = format!(
481 "\n\n[output truncated to {max_chars} characters; {omitted_chars} characters omitted]"
482 );
483 (format!("{truncated}{note}"), true, omitted_chars)
484 }
485
486 fn char_boundary_index(text: &str, max_chars: usize) -> usize {
487 if max_chars == 0 {
488 return 0;
489 }
490 for (count, (idx, _)) in text.char_indices().enumerate() {
491 if count == max_chars {
492 return idx;
493 }
494 }
495 text.len()
496 }
497
498 #[cfg(test)]
499 mod tests {
500 use super::*;
501 use std::fs;
502 use std::path::Path;
503 use std::process::Command;
504 use tempfile::tempdir;
505
506 fn git_available() -> bool {
507 Command::new("git")
508 .arg("--version")
509 .output()
510 .map(|o| o.status.success())
511 .unwrap_or(false)
512 }
513
514 fn run_git(root: &Path, args: &[&str]) {
515 let status = Command::new("git")
516 .args(args)
517 .current_dir(root)
518 .status()
519 .expect("git should spawn");
520 assert!(status.success(), "git {:?} failed", args);
521 }
522
523 fn init_git_repo(root: &Path) {
524 run_git(root, &["init", "-q"]);
525 run_git(root, &["config", "user.email", "test@example.com"]);
526 run_git(root, &["config", "user.name", "Test User"]);
527 }
528
529 fn commit_all(root: &Path, message: &str) {
530 run_git(root, &["add", "."]);
531 run_git(root, &["commit", "-q", "-m", message]);
532 }
533
534 #[tokio::test]
535 async fn git_log_lists_recent_commits() {
536 if !git_available() {
537 return;
538 }
539
540 let tmp = tempdir().expect("tempdir");
541 init_git_repo(tmp.path());
542 fs::write(tmp.path().join("file.txt"), "one\n").expect("write");
543 commit_all(tmp.path(), "first");
544 fs::write(tmp.path().join("file.txt"), "two\n").expect("write");
545 commit_all(tmp.path(), "second");
546
547 let ctx = ToolContext::new(tmp.path());
548 let result = GitLogTool
549 .execute(json!({ "max_count": 1 }), &ctx)
550 .await
551 .expect("execute");
552 assert!(result.success);
553 assert!(result.content.contains("Subject: second"));
554 }
555
556 #[tokio::test]
557 async fn git_show_returns_patch_for_revision() {
558 if !git_available() {
559 return;
560 }
561
562 let tmp = tempdir().expect("tempdir");
563 init_git_repo(tmp.path());
564 fs::write(tmp.path().join("file.txt"), "one\n").expect("write");
565 commit_all(tmp.path(), "first");
566 fs::write(tmp.path().join("file.txt"), "one\ntwo\n").expect("write");
567 commit_all(tmp.path(), "second");
568
569 let ctx = ToolContext::new(tmp.path());
570 let result = GitShowTool
571 .execute(json!({ "rev": "HEAD", "stat": false }), &ctx)
572 .await
573 .expect("execute");
574 assert!(result.success);
575 assert!(result.content.contains("diff --git"));
576 assert!(result.content.contains("+two"));
577 }
578
579 #[tokio::test]
580 async fn git_blame_reports_author_for_range() {
581 if !git_available() {
582 return;
583 }
584
585 let tmp = tempdir().expect("tempdir");
586 init_git_repo(tmp.path());
587 let src = tmp.path().join("src");
588 fs::create_dir_all(&src).expect("mkdir");
589 let file = src.join("lib.rs");
590 fs::write(&file, "pub fn one() -> i32 { 1 }\n").expect("write");
591 commit_all(tmp.path(), "first");
592 fs::write(&file, "pub fn one() -> i32 { 2 }\n").expect("write");
593 commit_all(tmp.path(), "second");
594
595 let ctx = ToolContext::new(tmp.path());
596 let result = GitBlameTool
597 .execute(
598 json!({
599 "path": "src/lib.rs",
600 "start_line": 1,
601 "max_lines": 1
602 }),
603 &ctx,
604 )
605 .await
606 .expect("execute");
607 assert!(result.success);
608 assert!(result.content.contains("Test User"));
609 }
610
611 #[tokio::test]
612 async fn git_blame_errors_for_non_file_path() {
613 if !git_available() {
614 return;
615 }
616
617 let tmp = tempdir().expect("tempdir");
618 init_git_repo(tmp.path());
619
620 let ctx = ToolContext::new(tmp.path());
621 let result = GitBlameTool
622 .execute(json!({ "path": "." }), &ctx)
623 .await
624 .expect_err("directory path should fail");
625 assert!(matches!(result, ToolError::InvalidInput { .. }));
626 }
627 }
628
628 lines RUST