返回 CodeWhale
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::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 use crate::dependencies::ExternalTool;
18
19 const MAX_OUTPUT_CHARS: usize = 40_000;
20 const DEFAULT_LOG_MAX_COUNT: u64 = 20;
21 const MAX_LOG_MAX_COUNT: u64 = 200;
22 const DEFAULT_UNIFIED: u64 = 3;
23 const MAX_UNIFIED: u64 = 50;
24 const DEFAULT_BLAME_START_LINE: u64 = 1;
25 const DEFAULT_BLAME_MAX_LINES: u64 = 200;
26 const MAX_BLAME_MAX_LINES: u64 = 2_000;
27
28 /// Tool for reading recent commit history.
29 pub struct GitLogTool;
30
31 #[async_trait]
32 impl ToolSpec for GitLogTool {
33 fn name(&self) -> &'static str {
34 "git_log"
35 }
36
37 fn model_visible(&self) -> bool {
38 false
39 }
40
41 fn description(&self) -> &'static str {
42 "Run `git log` in the workspace with optional path and author/date filters."
43 }
44
45 fn input_schema(&self) -> Value {
46 json!({
47 "type": "object",
48 "properties": {
49 "path": {
50 "type": "string",
51 "description": "Optional subdirectory or file path to scope history to."
52 },
53 "max_count": {
54 "type": "integer",
55 "minimum": 1,
56 "maximum": MAX_LOG_MAX_COUNT,
57 "default": DEFAULT_LOG_MAX_COUNT,
58 "description": "Maximum number of commits to return."
59 },
60 "author": {
61 "type": "string",
62 "description": "Optional git author filter (same semantics as `git log --author`)."
63 },
64 "since": {
65 "type": "string",
66 "description": "Optional lower date bound, e.g. '2 weeks ago' or ISO date."
67 },
68 "until": {
69 "type": "string",
70 "description": "Optional upper date bound, e.g. 'yesterday' or ISO date."
71 }
72 },
73 "additionalProperties": false
74 })
75 }
76
77 fn capabilities(&self) -> Vec<ToolCapability> {
78 vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
79 }
80
81 fn approval_requirement(&self) -> ApprovalRequirement {
82 ApprovalRequirement::Auto
83 }
84
85 fn supports_parallel(&self) -> bool {
86 true
87 }
88
89 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
90 let git_ctx = resolve_git_context(context, optional_str(&input, "path")?)?;
91 let max_count =
92 optional_u64(&input, "max_count", DEFAULT_LOG_MAX_COUNT)?.clamp(1, MAX_LOG_MAX_COUNT);
93 let author = optional_str(&input, "author")?.map(ToOwned::to_owned);
94 let since = optional_str(&input, "since")?.map(ToOwned::to_owned);
95 let until = optional_str(&input, "until")?.map(ToOwned::to_owned);
96
97 let mut args = vec![
98 "log".to_string(),
99 "--no-color".to_string(),
100 format!("--max-count={max_count}"),
101 "--date=iso-strict".to_string(),
102 "--pretty=format:%H%nAuthor: %an <%ae>%nDate: %ad%nSubject: %s%n".to_string(),
103 ];
104 if let Some(author) = &author {
105 args.push(format!("--author={author}"));
106 }
107 if let Some(since) = &since {
108 args.push(format!("--since={since}"));
109 }
110 if let Some(until) = &until {
111 args.push(format!("--until={until}"));
112 }
113 if let Some(pathspec) = &git_ctx.pathspec {
114 args.push("--".to_string());
115 args.push(pathspec.display().to_string());
116 }
117
118 let command_str = format_command(&git_ctx.working_dir, &args);
119 let output = run_git_command_async(git_ctx.working_dir.clone(), args).await?;
120 if !output.status.success() {
121 let stderr = String::from_utf8_lossy(&output.stderr);
122 return Ok(
123 ToolResult::error(format!("git log failed: {}", stderr.trim())).with_metadata(
124 json!({
125 "command": command_str,
126 "exit_code": output.status.code(),
127 "stderr": stderr.trim(),
128 }),
129 ),
130 );
131 }
132
133 let stdout = String::from_utf8_lossy(&output.stdout);
134 let (content, truncated, omitted_chars) = truncate_with_note(&stdout, MAX_OUTPUT_CHARS);
135 Ok(ToolResult::success(content).with_metadata(json!({
136 "command": command_str,
137 "working_dir": git_ctx.working_dir,
138 "pathspec": git_ctx.pathspec,
139 "max_count": max_count,
140 "author": author,
141 "since": since,
142 "until": until,
143 "truncated": truncated,
144 "omitted_chars": omitted_chars,
145 })))
146 }
147 }
148
149 /// Tool for showing a specific commit with optional patch/stat output.
150 pub struct GitShowTool;
151
152 #[async_trait]
153 impl ToolSpec for GitShowTool {
154 fn name(&self) -> &'static str {
155 "git_show"
156 }
157
158 fn model_visible(&self) -> bool {
159 false
160 }
161
162 fn description(&self) -> &'static str {
163 "Run `git show` for a specific revision with optional patch and stats."
164 }
165
166 fn input_schema(&self) -> Value {
167 json!({
168 "type": "object",
169 "properties": {
170 "rev": {
171 "type": "string",
172 "description": "Revision to show (commit SHA, tag, branch, or ref expression)."
173 },
174 "path": {
175 "type": "string",
176 "description": "Optional subdirectory or file path to scope output."
177 },
178 "patch": {
179 "type": "boolean",
180 "default": true,
181 "description": "Include patch hunks (default true)."
182 },
183 "stat": {
184 "type": "boolean",
185 "default": true,
186 "description": "Include --stat summary (default true)."
187 },
188 "unified": {
189 "type": "integer",
190 "minimum": 0,
191 "maximum": MAX_UNIFIED,
192 "default": DEFAULT_UNIFIED,
193 "description": "Context lines for patch output when patch=true."
194 }
195 },
196 "required": ["rev"],
197 "additionalProperties": false
198 })
199 }
200
201 fn capabilities(&self) -> Vec<ToolCapability> {
202 vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
203 }
204
205 fn approval_requirement(&self) -> ApprovalRequirement {
206 ApprovalRequirement::Auto
207 }
208
209 fn supports_parallel(&self) -> bool {
210 true
211 }
212
213 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
214 let rev = required_str(&input, "rev")?;
215 validate_git_rev(rev)?;
216 let git_ctx = resolve_git_context(context, optional_str(&input, "path")?)?;
217 let patch = optional_bool(&input, "patch", true)?;
218 let stat = optional_bool(&input, "stat", true)?;
219 let unified = optional_u64(&input, "unified", DEFAULT_UNIFIED)?.min(MAX_UNIFIED);
220
221 let mut args = vec![
222 "show".to_string(),
223 "--no-color".to_string(),
224 "--no-ext-diff".to_string(),
225 ];
226 if patch {
227 args.push(format!("--unified={unified}"));
228 } else {
229 args.push("--no-patch".to_string());
230 }
231 if stat {
232 args.push("--stat".to_string());
233 }
234 args.push(rev.to_string());
235 if let Some(pathspec) = &git_ctx.pathspec {
236 args.push("--".to_string());
237 args.push(pathspec.display().to_string());
238 }
239
240 let command_str = format_command(&git_ctx.working_dir, &args);
241 let output = run_git_command_async(git_ctx.working_dir.clone(), args).await?;
242 if !output.status.success() {
243 let stderr = String::from_utf8_lossy(&output.stderr);
244 return Ok(ToolResult::error(format!(
245 "git show failed for '{rev}': {}",
246 stderr.trim()
247 ))
248 .with_metadata(json!({
249 "command": command_str,
250 "exit_code": output.status.code(),
251 "stderr": stderr.trim(),
252 })));
253 }
254
255 let stdout = String::from_utf8_lossy(&output.stdout);
256 let (content, truncated, omitted_chars) = truncate_with_note(&stdout, MAX_OUTPUT_CHARS);
257 Ok(ToolResult::success(content).with_metadata(json!({
258 "command": command_str,
259 "working_dir": git_ctx.working_dir,
260 "pathspec": git_ctx.pathspec,
261 "rev": rev,
262 "patch": patch,
263 "stat": stat,
264 "unified": if patch { Some(unified) } else { None },
265 "truncated": truncated,
266 "omitted_chars": omitted_chars,
267 })))
268 }
269 }
270
271 /// Tool for attributing lines in a file to commits and authors.
272 pub struct GitBlameTool;
273
274 #[async_trait]
275 impl ToolSpec for GitBlameTool {
276 fn name(&self) -> &'static str {
277 "git_blame"
278 }
279
280 fn model_visible(&self) -> bool {
281 false
282 }
283
284 fn description(&self) -> &'static str {
285 "Run `git blame` on a file with optional revision and line-range controls."
286 }
287
288 fn input_schema(&self) -> Value {
289 json!({
290 "type": "object",
291 "properties": {
292 "path": {
293 "type": "string",
294 "description": "Path to a tracked file within the workspace."
295 },
296 "rev": {
297 "type": "string",
298 "description": "Optional revision to blame against (default: HEAD)."
299 },
300 "start_line": {
301 "type": "integer",
302 "minimum": 1,
303 "default": DEFAULT_BLAME_START_LINE,
304 "description": "First line to include in blame output."
305 },
306 "max_lines": {
307 "type": "integer",
308 "minimum": 1,
309 "maximum": MAX_BLAME_MAX_LINES,
310 "default": DEFAULT_BLAME_MAX_LINES,
311 "description": "Maximum number of lines to include."
312 },
313 "porcelain": {
314 "type": "boolean",
315 "default": false,
316 "description": "When true, emit `--line-porcelain` output."
317 }
318 },
319 "required": ["path"],
320 "additionalProperties": false
321 })
322 }
323
324 fn capabilities(&self) -> Vec<ToolCapability> {
325 vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
326 }
327
328 fn approval_requirement(&self) -> ApprovalRequirement {
329 ApprovalRequirement::Auto
330 }
331
332 fn supports_parallel(&self) -> bool {
333 true
334 }
335
336 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
337 let path_str = required_str(&input, "path")?;
338 let resolved_path = context.resolve_path(path_str)?;
339 let metadata = tokio::fs::metadata(&resolved_path).await.map_err(|e| {
340 ToolError::invalid_input(format!(
341 "Path does not exist or is not accessible: {path_str} ({e})"
342 ))
343 })?;
344 if !metadata.is_file() {
345 return Err(ToolError::invalid_input(format!(
346 "Path must point to a file: {path_str}"
347 )));
348 }
349
350 let working_dir = resolved_path.parent().ok_or_else(|| {
351 ToolError::invalid_input(format!("Path has no parent directory: {path_str}"))
352 })?;
353 let pathspec = pathspec_from(working_dir, &resolved_path);
354 let rev = optional_str(&input, "rev")?.unwrap_or("HEAD");
355 validate_git_rev(rev)?;
356 let start_line = optional_u64(&input, "start_line", DEFAULT_BLAME_START_LINE)?.max(1);
357 let max_lines = optional_u64(&input, "max_lines", DEFAULT_BLAME_MAX_LINES)?
358 .clamp(1, MAX_BLAME_MAX_LINES);
359 let end_line = start_line.saturating_add(max_lines.saturating_sub(1));
360 let porcelain = optional_bool(&input, "porcelain", false)?;
361
362 let mut args = vec![
363 "blame".to_string(),
364 "--date=iso".to_string(),
365 format!("-L{start_line},{end_line}"),
366 ];
367 if porcelain {
368 args.push("--line-porcelain".to_string());
369 }
370 args.push(rev.to_string());
371 args.push("--".to_string());
372 args.push(pathspec.display().to_string());
373
374 let command_str = format_command(working_dir, &args);
375 let output = run_git_command_async(working_dir.to_path_buf(), args).await?;
376 if !output.status.success() {
377 let stderr = String::from_utf8_lossy(&output.stderr);
378 return Ok(ToolResult::error(format!(
379 "git blame failed for '{path_str}' at '{rev}': {}",
380 stderr.trim()
381 ))
382 .with_metadata(json!({
383 "command": command_str,
384 "exit_code": output.status.code(),
385 "stderr": stderr.trim(),
386 })));
387 }
388
389 let stdout = String::from_utf8_lossy(&output.stdout);
390 let (content, truncated, omitted_chars) = truncate_with_note(&stdout, MAX_OUTPUT_CHARS);
391 Ok(ToolResult::success(content).with_metadata(json!({
392 "command": command_str,
393 "working_dir": working_dir,
394 "pathspec": pathspec,
395 "rev": rev,
396 "start_line": start_line,
397 "max_lines": max_lines,
398 "porcelain": porcelain,
399 "truncated": truncated,
400 "omitted_chars": omitted_chars,
401 })))
402 }
403 }
404
405 /// Tool for fetching remote refs: `git fetch <remote> [<refspec>...]`.
406 ///
407 /// The bounded verify-mode git surface (#6298): a verifier child cannot reach
408 /// raw shell, so `git fetch` arrives as a structured call instead of a shell
409 /// command. The bound is structural — fixed argv, argv-direct spawning (no
410 /// shell), a remote that must be a *configured* remote name (never a URL, so
411 /// an operator-supplied address cannot exfiltrate or redirect), and refspecs
412 /// that pass the option/whitespace/control gates. Only remote-tracking refs
413 /// (plus `FETCH_HEAD` and the fetched objects) move: never a checkout, merge,
414 /// or push. The execution envelope classes this as bounded fetch — shell plus
415 /// network authority, not write authority.
416 ///
417 /// Known limitation: shares the existing git tools' no-timeout behavior; a
418 /// hung remote is bounded by the caller's wall clock, not the tool.
419 pub struct GitFetchTool;
420
421 #[async_trait]
422 impl ToolSpec for GitFetchTool {
423 fn name(&self) -> &'static str {
424 "git_fetch"
425 }
426
427 fn model_visible(&self) -> bool {
428 false
429 }
430
431 fn description(&self) -> &'static str {
432 "Run `git fetch` against a configured remote. Updates remote-tracking refs only; never checks out, merges, or pushes."
433 }
434
435 fn input_schema(&self) -> Value {
436 json!({
437 "type": "object",
438 "properties": {
439 "remote": {
440 "type": "string",
441 "default": "origin",
442 "description": "Configured remote name to fetch from (default origin). Must be a name from `git remote`, never a URL."
443 },
444 "refspecs": {
445 "type": "array",
446 "items": { "type": "string" },
447 "description": "Optional refspecs to fetch (e.g. pull/123/head). Empty fetches the remote's defaults."
448 },
449 "path": {
450 "type": "string",
451 "description": "Optional subdirectory to run from."
452 }
453 },
454 "additionalProperties": false
455 })
456 }
457
458 fn capabilities(&self) -> Vec<ToolCapability> {
459 vec![ToolCapability::Network, ToolCapability::Sandboxable]
460 }
461
462 fn approval_requirement(&self) -> ApprovalRequirement {
463 ApprovalRequirement::Required
464 }
465
466 fn supports_parallel(&self) -> bool {
467 false
468 }
469
470 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
471 let remote = optional_str(&input, "remote")?.unwrap_or("origin");
472 validate_git_remote_name(remote)?;
473 let refspecs = parse_git_refspecs(&input)?;
474 let git_ctx = resolve_git_context(context, optional_str(&input, "path")?)?;
475 require_configured_remote(&git_ctx.working_dir, remote).await?;
476
477 let mut args = vec!["fetch".to_string(), remote.to_string()];
478 args.extend(refspecs.clone());
479
480 let command_str = format_command(&git_ctx.working_dir, &args);
481 let output = run_git_command_async(git_ctx.working_dir.clone(), args).await?;
482 if !output.status.success() {
483 let stderr = String::from_utf8_lossy(&output.stderr);
484 return Ok(ToolResult::error(format!(
485 "git fetch failed for remote '{remote}': {}",
486 stderr.trim()
487 ))
488 .with_metadata(json!({
489 "command": command_str,
490 "exit_code": output.status.code(),
491 "stderr": stderr.trim(),
492 })));
493 }
494
495 let stdout = String::from_utf8_lossy(&output.stdout);
496 let stderr = String::from_utf8_lossy(&output.stderr);
497 let combined = if stderr.trim().is_empty() {
498 stdout.to_string()
499 } else {
500 format!("{stdout}\n{stderr}")
501 };
502 let (content, truncated, omitted_chars) = truncate_with_note(&combined, MAX_OUTPUT_CHARS);
503 Ok(ToolResult::success(content).with_metadata(json!({
504 "command": command_str,
505 "working_dir": git_ctx.working_dir,
506 "remote": remote,
507 "refspecs": refspecs,
508 "truncated": truncated,
509 "omitted_chars": omitted_chars,
510 })))
511 }
512 }
513
514 /// Tool for computing a merge result without touching the working tree.
515 ///
516 /// `git merge-tree` is a pure read: it performs the merge in memory and
517 /// prints the resulting tree plus conflicted-file info, writing nothing, so
518 /// the envelope classes it Bounded like the other inspection actions. Uses
519 /// the modern two-revision form (git 2.38+, 2022); an explicit base arrives
520 /// via `--merge-base`, and otherwise git finds the bases itself — including
521 /// the multi-base virtual-base case a hand-rolled `merge-base` call cannot
522 /// express. Older gits fail with their own usage error, surfaced below.
523 pub struct GitMergeTreeTool;
524
525 #[async_trait]
526 impl ToolSpec for GitMergeTreeTool {
527 fn name(&self) -> &'static str {
528 "git_merge_tree"
529 }
530
531 fn model_visible(&self) -> bool {
532 false
533 }
534
535 fn description(&self) -> &'static str {
536 "Compute the merge result of two revisions without touching the working tree (`git merge-tree`). Pure read."
537 }
538
539 fn input_schema(&self) -> Value {
540 json!({
541 "type": "object",
542 "properties": {
543 "ours": {
544 "type": "string",
545 "description": "First revision (e.g. main)."
546 },
547 "theirs": {
548 "type": "string",
549 "description": "Second revision (e.g. the PR head)."
550 },
551 "base": {
552 "type": "string",
553 "description": "Optional merge base (--merge-base). Omit it and git finds the bases itself."
554 },
555 "path": {
556 "type": "string",
557 "description": "Optional subdirectory to run from."
558 }
559 },
560 "required": ["ours", "theirs"],
561 "additionalProperties": false
562 })
563 }
564
565 fn capabilities(&self) -> Vec<ToolCapability> {
566 vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
567 }
568
569 fn approval_requirement(&self) -> ApprovalRequirement {
570 ApprovalRequirement::Auto
571 }
572
573 fn supports_parallel(&self) -> bool {
574 true
575 }
576
577 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
578 let ours = required_str(&input, "ours")?;
579 let theirs = required_str(&input, "theirs")?;
580 validate_git_rev(ours)?;
581 validate_git_rev(theirs)?;
582 let git_ctx = resolve_git_context(context, optional_str(&input, "path")?)?;
583
584 let mut args = vec!["merge-tree".to_string()];
585 let base = match optional_str(&input, "base")? {
586 Some(base) => {
587 validate_git_rev(base)?;
588 // `--opt=value` form: a validated rev can never split into a
589 // second argv element, so no option injection through `base`.
590 args.push(format!("--merge-base={base}"));
591 Some(base.to_string())
592 }
593 None => None,
594 };
595 args.push(ours.to_string());
596 args.push(theirs.to_string());
597 let command_str = format_command(&git_ctx.working_dir, &args);
598 let output = run_git_command_async(git_ctx.working_dir.clone(), args).await?;
599 // merge-tree exits 1 both for conflicts (a successful report on
600 // stdout) and for real failures (empty stdout, reason on stderr).
601 // The report is the answer; only the empty case is an error.
602 let stdout = String::from_utf8_lossy(&output.stdout);
603 if !output.status.success() && stdout.trim().is_empty() {
604 let stderr = String::from_utf8_lossy(&output.stderr);
605 return Ok(ToolResult::error(format!(
606 "git merge-tree failed for '{ours}' + '{theirs}': {}",
607 stderr.trim()
608 ))
609 .with_metadata(json!({
610 "command": command_str,
611 "exit_code": output.status.code(),
612 "stderr": stderr.trim(),
613 })));
614 }
615
616 let conflicts = !output.status.success();
617 let (content, truncated, omitted_chars) = truncate_with_note(&stdout, MAX_OUTPUT_CHARS);
618 Ok(ToolResult::success(content).with_metadata(json!({
619 "command": command_str,
620 "working_dir": git_ctx.working_dir,
621 "ours": ours,
622 "theirs": theirs,
623 "base": base,
624 "conflicts": conflicts,
625 "truncated": truncated,
626 "omitted_chars": omitted_chars,
627 })))
628 }
629 }
630
631 struct GitContext {
632 working_dir: PathBuf,
633 pathspec: Option<PathBuf>,
634 }
635
636 fn resolve_git_context(context: &ToolContext, path: Option<&str>) -> Result<GitContext, ToolError> {
637 let workspace = canonical_or_workspace(&context.workspace);
638 let mut working_dir = workspace.clone();
639 let mut pathspec = None;
640
641 if let Some(raw) = path {
642 let resolved = context.resolve_path(raw)?;
643 let metadata = fs::metadata(&resolved).map_err(|e| {
644 ToolError::invalid_input(format!(
645 "Path does not exist or is not accessible: {raw} ({e})"
646 ))
647 })?;
648
649 if metadata.is_dir() {
650 working_dir = resolved;
651 pathspec = Some(PathBuf::from("."));
652 } else {
653 let parent = resolved.parent().ok_or_else(|| {
654 ToolError::invalid_input(format!("Path has no parent directory: {raw}"))
655 })?;
656 working_dir = parent.to_path_buf();
657 pathspec = Some(pathspec_from(&working_dir, &resolved));
658 }
659 }
660
661 if !working_dir.exists() {
662 return Err(ToolError::invalid_input(format!(
663 "Working directory does not exist: {}",
664 working_dir.display()
665 )));
666 }
667
668 Ok(GitContext {
669 working_dir,
670 pathspec,
671 })
672 }
673
674 fn validate_git_rev(rev: &str) -> Result<(), ToolError> {
675 let trimmed = rev.trim();
676 if trimmed.is_empty() {
677 return Err(ToolError::invalid_input(
678 "git revision must not be empty".to_string(),
679 ));
680 }
681 if trimmed.starts_with('-') {
682 return Err(ToolError::invalid_input(
683 "git revision must not start with '-'".to_string(),
684 ));
685 }
686 if trimmed.chars().any(char::is_whitespace) {
687 return Err(ToolError::invalid_input(
688 "git revision must not contain whitespace".to_string(),
689 ));
690 }
691 if trimmed
692 .chars()
693 .any(|ch| ch == '\0' || ch.is_ascii_control())
694 {
695 return Err(ToolError::invalid_input(
696 "git revision must not contain control characters".to_string(),
697 ));
698 }
699 Ok(())
700 }
701
702 /// A fetch remote is a configured remote *name*, never a URL: URLs and paths
703 /// fail the membership check below, but rejecting their shapes here keeps the
704 /// refusal precise (`:` kills `https://`, `user@host:path`, and `file://`;
705 /// `/` kills paths) instead of "unknown remote".
706 fn validate_git_remote_name(remote: &str) -> Result<(), ToolError> {
707 let trimmed = remote.trim();
708 if trimmed.is_empty() {
709 return Err(ToolError::invalid_input(
710 "git remote must not be empty".to_string(),
711 ));
712 }
713 if trimmed.starts_with('-') {
714 return Err(ToolError::invalid_input(
715 "git remote must not start with '-'".to_string(),
716 ));
717 }
718 if trimmed.chars().any(char::is_whitespace) {
719 return Err(ToolError::invalid_input(
720 "git remote must not contain whitespace".to_string(),
721 ));
722 }
723 if trimmed
724 .chars()
725 .any(|ch| ch == '\0' || ch.is_ascii_control() || ch == ':' || ch == '/')
726 {
727 return Err(ToolError::invalid_input(
728 "git remote must be a configured remote name (from `git remote`), never a URL or path"
729 .to_string(),
730 ));
731 }
732 Ok(())
733 }
734
735 /// Parse the optional `refspecs` array: `[+]<src>[:<dst>]`, each side passing
736 /// the revision gates. A wrong type is an error, never a silent default.
737 fn parse_git_refspecs(input: &Value) -> Result<Vec<String>, ToolError> {
738 let items = match input.get("refspecs") {
739 None | Some(Value::Null) => return Ok(Vec::new()),
740 Some(Value::Array(items)) => items,
741 Some(other) => {
742 return Err(super::spec::type_mismatch(
743 "refspecs",
744 other,
745 "an array of strings",
746 ));
747 }
748 };
749 let mut refspecs = Vec::with_capacity(items.len());
750 for (index, item) in items.iter().enumerate() {
751 let Some(refspec) = item.as_str() else {
752 return Err(super::spec::type_mismatch(
753 &format!("refspecs[{index}]"),
754 item,
755 "a string",
756 ));
757 };
758 validate_git_refspec(refspec)?;
759 refspecs.push(refspec.to_string());
760 }
761 Ok(refspecs)
762 }
763
764 fn validate_git_refspec(refspec: &str) -> Result<(), ToolError> {
765 let body = refspec.strip_prefix('+').unwrap_or(refspec);
766 let parts: Vec<&str> = body.split(':').collect();
767 if parts.len() > 2 {
768 return Err(ToolError::invalid_input(format!(
769 "git refspec '{refspec}' must have at most one ':'"
770 )));
771 }
772 for side in parts {
773 validate_git_refspec_side(refspec, side)?;
774 }
775 Ok(())
776 }
777
778 fn validate_git_refspec_side(refspec: &str, side: &str) -> Result<(), ToolError> {
779 if side.is_empty() {
780 return Err(ToolError::invalid_input(format!(
781 "git refspec '{refspec}' has an empty side"
782 )));
783 }
784 validate_git_rev(side).map_err(|_| {
785 ToolError::invalid_input(format!(
786 "git refspec '{refspec}' is not a plain refspec (no options, whitespace, or control characters)"
787 ))
788 })
789 }
790
791 /// The remote must already be configured on this repository. A name git never
792 /// heard of fails here — before any network — so a typo cannot become a fetch
793 /// from somewhere else.
794 async fn require_configured_remote(working_dir: &Path, remote: &str) -> Result<(), ToolError> {
795 let output =
796 run_git_command_async(working_dir.to_path_buf(), vec!["remote".to_string()]).await?;
797 if !output.status.success() {
798 let stderr = String::from_utf8_lossy(&output.stderr);
799 return Err(ToolError::execution_failed(format!(
800 "git remote failed: {}",
801 stderr.trim()
802 )));
803 }
804 let stdout = String::from_utf8_lossy(&output.stdout);
805 let configured: Vec<&str> = stdout
806 .lines()
807 .map(str::trim)
808 .filter(|line| !line.is_empty())
809 .collect();
810 if configured.contains(&remote) {
811 Ok(())
812 } else {
813 Err(ToolError::invalid_input(format!(
814 "unknown git remote '{remote}'; configured remotes: {}",
815 if configured.is_empty() {
816 "(none)".to_string()
817 } else {
818 configured.join(", ")
819 }
820 )))
821 }
822 }
823
824 fn canonical_or_workspace(workspace: &Path) -> PathBuf {
825 workspace
826 .canonicalize()
827 .unwrap_or_else(|_| workspace.to_path_buf())
828 }
829
830 fn pathspec_from(working_dir: &Path, resolved: &Path) -> PathBuf {
831 match resolved.strip_prefix(working_dir) {
832 Ok(rel) if rel.as_os_str().is_empty() => PathBuf::from("."),
833 Ok(rel) => rel.to_path_buf(),
834 Err(_) => PathBuf::from("."),
835 }
836 }
837
838 fn run_git_command(working_dir: &Path, args: &[String]) -> Result<Output, ToolError> {
839 let Some(mut cmd) = crate::dependencies::Git::command() else {
840 return Err(ToolError::not_available(
841 "git is not installed or not in PATH",
842 ));
843 };
844 cmd.args(args).current_dir(working_dir);
845 cmd.output().map_err(|e| {
846 if e.kind() == std::io::ErrorKind::NotFound {
847 ToolError::not_available("git is not installed or not in PATH")
848 } else {
849 ToolError::execution_failed(format!("Failed to run git: {e}"))
850 }
851 })
852 }
853
854 /// Async wrapper that offloads the blocking `git` invocation onto a
855 /// blocking-capable thread so the tokio worker is not stalled.
856 async fn run_git_command_async(
857 working_dir: PathBuf,
858 args: Vec<String>,
859 ) -> Result<Output, ToolError> {
860 tokio::task::spawn_blocking(move || run_git_command(&working_dir, &args))
861 .await
862 .map_err(|e| ToolError::execution_failed(format!("git task panicked: {e}")))?
863 }
864
865 fn format_command(working_dir: &Path, args: &[String]) -> String {
866 format!(
867 "git -C {} {}",
868 working_dir.display(),
869 args.iter()
870 .map(String::as_str)
871 .collect::<Vec<_>>()
872 .join(" ")
873 )
874 }
875
876 fn truncate_with_note(text: &str, max_chars: usize) -> (String, bool, usize) {
877 if text.chars().count() <= max_chars {
878 return (text.to_string(), false, 0);
879 }
880 let end = char_boundary_index(text, max_chars);
881 let truncated = &text[..end];
882 let omitted_chars = text
883 .chars()
884 .count()
885 .saturating_sub(truncated.chars().count());
886 let note = format!(
887 "\n\n[output truncated to {max_chars} characters; {omitted_chars} characters omitted]"
888 );
889 (format!("{truncated}{note}"), true, omitted_chars)
890 }
891
892 fn char_boundary_index(text: &str, max_chars: usize) -> usize {
893 if max_chars == 0 {
894 return 0;
895 }
896 for (count, (idx, _)) in text.char_indices().enumerate() {
897 if count == max_chars {
898 return idx;
899 }
900 }
901 text.len()
902 }
903
904 #[cfg(test)]
905 mod tests {
906 use super::*;
907 use std::fs;
908 use std::path::Path;
909 use tempfile::tempdir;
910
911 fn git_available() -> bool {
912 crate::dependencies::Git::available()
913 }
914
915 fn run_git(root: &Path, args: &[&str]) {
916 let status = crate::dependencies::Git::status(args, root).expect("git should spawn");
917 assert!(status.success(), "git {args:?} failed");
918 }
919
920 fn init_git_repo(root: &Path) {
921 run_git(root, &["init", "-q"]);
922 run_git(root, &["config", "core.autocrlf", "false"]);
923 run_git(root, &["config", "user.email", "test@example.com"]);
924 run_git(root, &["config", "user.name", "Test User"]);
925 }
926
927 fn commit_all(root: &Path, message: &str) {
928 run_git(root, &["add", "."]);
929 run_git(root, &["commit", "-q", "-m", message]);
930 }
931
932 #[tokio::test]
933 async fn git_log_lists_recent_commits() {
934 if !git_available() {
935 return;
936 }
937
938 let tmp = tempdir().expect("tempdir");
939 init_git_repo(tmp.path());
940 fs::write(tmp.path().join("file.txt"), "one\n").expect("write");
941 commit_all(tmp.path(), "first");
942 fs::write(tmp.path().join("file.txt"), "two\n").expect("write");
943 commit_all(tmp.path(), "second");
944
945 let ctx = ToolContext::new(tmp.path());
946 let result = GitLogTool
947 .execute(json!({ "max_count": 1 }), &ctx)
948 .await
949 .expect("execute");
950 assert!(result.success);
951 assert!(result.content.contains("Subject: second"));
952 }
953
954 #[tokio::test]
955 async fn git_show_returns_patch_for_revision() {
956 if !git_available() {
957 return;
958 }
959
960 let tmp = tempdir().expect("tempdir");
961 init_git_repo(tmp.path());
962 fs::write(tmp.path().join("file.txt"), "one\n").expect("write");
963 commit_all(tmp.path(), "first");
964 fs::write(tmp.path().join("file.txt"), "one\ntwo\n").expect("write");
965 commit_all(tmp.path(), "second");
966
967 let ctx = ToolContext::new(tmp.path());
968 let result = GitShowTool
969 .execute(json!({ "rev": "HEAD", "stat": false }), &ctx)
970 .await
971 .expect("execute");
972 assert!(result.success);
973 assert!(result.content.contains("diff --git"));
974 assert!(result.content.contains("+two"));
975 }
976
977 #[tokio::test]
978 async fn git_show_rejects_option_like_revision() {
979 let tmp = tempdir().expect("tempdir");
980 let ctx = ToolContext::new(tmp.path());
981 let err = GitShowTool
982 .execute(json!({ "rev": "--stat" }), &ctx)
983 .await
984 .expect_err("option-shaped rev should fail before git runs");
985 assert!(matches!(err, ToolError::InvalidInput { .. }));
986 assert!(err.to_string().contains("must not start with '-'"));
987 }
988
989 #[tokio::test]
990 async fn git_show_rejects_whitespace_revision_payload() {
991 let tmp = tempdir().expect("tempdir");
992 let ctx = ToolContext::new(tmp.path());
993 let err = GitShowTool
994 .execute(
995 json!({ "rev": "HEAD --output=/tmp/codewhale-git-show" }),
996 &ctx,
997 )
998 .await
999 .expect_err("whitespace rev payload should fail before git runs");
1000 assert!(matches!(err, ToolError::InvalidInput { .. }));
1001 assert!(err.to_string().contains("must not contain whitespace"));
1002 }
1003
1004 #[tokio::test]
1005 async fn git_blame_reports_author_for_range() {
1006 if !git_available() {
1007 return;
1008 }
1009
1010 let tmp = tempdir().expect("tempdir");
1011 init_git_repo(tmp.path());
1012 let src = tmp.path().join("src");
1013 fs::create_dir_all(&src).expect("mkdir");
1014 let file = src.join("lib.rs");
1015 fs::write(&file, "pub fn one() -> i32 { 1 }\n").expect("write");
1016 commit_all(tmp.path(), "first");
1017 fs::write(&file, "pub fn one() -> i32 { 2 }\n").expect("write");
1018 commit_all(tmp.path(), "second");
1019
1020 let ctx = ToolContext::new(tmp.path());
1021 let result = GitBlameTool
1022 .execute(
1023 json!({
1024 "path": "src/lib.rs",
1025 "start_line": 1,
1026 "max_lines": 1
1027 }),
1028 &ctx,
1029 )
1030 .await
1031 .expect("execute");
1032 assert!(result.success);
1033 assert!(result.content.contains("Test User"));
1034 }
1035
1036 #[tokio::test]
1037 async fn git_blame_rejects_option_like_revision() {
1038 let tmp = tempdir().expect("tempdir");
1039 let file = tmp.path().join("file.txt");
1040 fs::write(&file, "one\n").expect("write");
1041 let ctx = ToolContext::new(tmp.path());
1042 let err = GitBlameTool
1043 .execute(
1044 json!({ "path": "file.txt", "rev": "--contents=/tmp/x" }),
1045 &ctx,
1046 )
1047 .await
1048 .expect_err("option-shaped rev should fail before git runs");
1049 assert!(matches!(err, ToolError::InvalidInput { .. }));
1050 assert!(err.to_string().contains("must not start with '-'"));
1051 }
1052
1053 #[tokio::test]
1054 async fn git_blame_rejects_whitespace_revision_payload() {
1055 let tmp = tempdir().expect("tempdir");
1056 let file = tmp.path().join("file.txt");
1057 fs::write(&file, "one\n").expect("write");
1058 let ctx = ToolContext::new(tmp.path());
1059 let err = GitBlameTool
1060 .execute(
1061 json!({ "path": "file.txt", "rev": "HEAD --contents=/tmp/codewhale-git-blame" }),
1062 &ctx,
1063 )
1064 .await
1065 .expect_err("whitespace rev payload should fail before git runs");
1066 assert!(matches!(err, ToolError::InvalidInput { .. }));
1067 assert!(err.to_string().contains("must not contain whitespace"));
1068 }
1069
1070 #[tokio::test]
1071 async fn git_blame_errors_for_non_file_path() {
1072 if !git_available() {
1073 return;
1074 }
1075
1076 let tmp = tempdir().expect("tempdir");
1077 init_git_repo(tmp.path());
1078
1079 let ctx = ToolContext::new(tmp.path());
1080 let result = GitBlameTool
1081 .execute(json!({ "path": "." }), &ctx)
1082 .await
1083 .expect_err("directory path should fail");
1084 assert!(matches!(result, ToolError::InvalidInput { .. }));
1085 }
1086
1087 #[tokio::test]
1088 async fn git_fetch_rejects_url_and_option_shaped_remotes() {
1089 let tmp = tempdir().expect("tempdir");
1090 let ctx = ToolContext::new(tmp.path());
1091 for remote in [
1092 "https://example.com/repo.git",
1093 "git@example.com:org/repo.git",
1094 "/tmp/other-checkout",
1095 "--upload-pack=evil",
1096 "origin --prune",
1097 ] {
1098 let err = GitFetchTool
1099 .execute(json!({ "remote": remote }), &ctx)
1100 .await
1101 .expect_err("non-name remote should fail before git runs");
1102 assert!(
1103 matches!(err, ToolError::InvalidInput { .. }),
1104 "{remote}: {err}"
1105 );
1106 }
1107 }
1108
1109 #[tokio::test]
1110 async fn git_fetch_rejects_unknown_remote_before_network() {
1111 if !git_available() {
1112 return;
1113 }
1114
1115 let tmp = tempdir().expect("tempdir");
1116 init_git_repo(tmp.path());
1117 let ctx = ToolContext::new(tmp.path());
1118 let err = GitFetchTool
1119 .execute(json!({ "remote": "origin" }), &ctx)
1120 .await
1121 .expect_err("unconfigured remote must be refused");
1122 let message = err.to_string();
1123 assert!(message.contains("unknown git remote 'origin'"), "{message}");
1124 assert!(message.contains("(none)"), "{message}");
1125 }
1126
1127 #[tokio::test]
1128 async fn git_fetch_rejects_malformed_refspecs() {
1129 if !git_available() {
1130 return;
1131 }
1132
1133 let tmp = tempdir().expect("tempdir");
1134 init_git_repo(tmp.path());
1135 let ctx = ToolContext::new(tmp.path());
1136 for refspec in ["a:b:c", "src:", ":dst", "--prune", "a b"] {
1137 let err = GitFetchTool
1138 .execute(json!({ "refspecs": [refspec] }), &ctx)
1139 .await
1140 .expect_err("malformed refspec should fail before git runs");
1141 assert!(
1142 matches!(err, ToolError::InvalidInput { .. }),
1143 "{refspec}: {err}"
1144 );
1145 }
1146 let err = GitFetchTool
1147 .execute(json!({ "refspecs": "pull/1/head" }), &ctx)
1148 .await
1149 .expect_err("wrongly typed refspecs should fail");
1150 assert!(err.to_string().contains("an array of strings"), "{err}");
1151 }
1152
1153 #[tokio::test]
1154 async fn git_fetch_brings_remote_refs_without_checkout() {
1155 if !git_available() {
1156 return;
1157 }
1158
1159 let origin = tempdir().expect("tempdir");
1160 init_git_repo(origin.path());
1161 fs::write(origin.path().join("file.txt"), "one\n").expect("write");
1162 commit_all(origin.path(), "first");
1163
1164 let work = tempdir().expect("tempdir");
1165 init_git_repo(work.path());
1166 run_git(
1167 work.path(),
1168 &[
1169 "remote",
1170 "add",
1171 "origin",
1172 &origin.path().display().to_string(),
1173 ],
1174 );
1175
1176 let ctx = ToolContext::new(work.path());
1177 let result = GitFetchTool
1178 .execute(json!({ "remote": "origin" }), &ctx)
1179 .await
1180 .expect("execute");
1181 assert!(result.success, "{}", result.content);
1182
1183 // The refs arrived, but nothing was checked out: the work tree has no
1184 // file.txt and no local branch moved.
1185 let refs = crate::dependencies::Git::output(&["branch", "-r"], work.path())
1186 .expect("git should spawn");
1187 assert!(refs.status.success());
1188 let refs = String::from_utf8_lossy(&refs.stdout);
1189 assert!(refs.contains("origin/"), "{refs}");
1190 assert!(!work.path().join("file.txt").exists());
1191 }
1192
1193 #[tokio::test]
1194 async fn git_merge_tree_reports_conflicts_without_touching_tree() {
1195 if !git_available() {
1196 return;
1197 }
1198
1199 let tmp = tempdir().expect("tempdir");
1200 init_git_repo(tmp.path());
1201 fs::write(tmp.path().join("file.txt"), "base\n").expect("write");
1202 commit_all(tmp.path(), "base");
1203 let main = current_branch(tmp.path());
1204 run_git(tmp.path(), &["checkout", "-qb", "side"]);
1205 fs::write(tmp.path().join("file.txt"), "side\n").expect("write");
1206 commit_all(tmp.path(), "side");
1207 run_git(tmp.path(), &["checkout", "-q", main.as_str()]);
1208 fs::write(tmp.path().join("file.txt"), "base\nmain\n").expect("write");
1209 commit_all(tmp.path(), "main");
1210
1211 let ctx = ToolContext::new(tmp.path());
1212 let before = fs::read(tmp.path().join("file.txt")).expect("read");
1213 let result = GitMergeTreeTool
1214 .execute(json!({ "ours": main, "theirs": "side" }), &ctx)
1215 .await
1216 .expect("execute");
1217 assert!(result.success, "{}", result.content);
1218 // Modern merge-tree shape: result tree plus the conflicted path in
1219 // the stage table and the CONFLICT notice.
1220 assert!(result.content.contains("file.txt"), "{}", result.content);
1221 assert!(result.content.contains("CONFLICT"), "{}", result.content);
1222 assert_eq!(
1223 fs::read(tmp.path().join("file.txt")).expect("read"),
1224 before,
1225 "merge-tree must not touch the working tree"
1226 );
1227 }
1228
1229 #[tokio::test]
1230 async fn git_merge_tree_rejects_option_shaped_revisions() {
1231 let tmp = tempdir().expect("tempdir");
1232 let ctx = ToolContext::new(tmp.path());
1233 let err = GitMergeTreeTool
1234 .execute(json!({ "ours": "--merge-base=x", "theirs": "HEAD" }), &ctx)
1235 .await
1236 .expect_err("option-shaped rev should fail before git runs");
1237 assert!(matches!(err, ToolError::InvalidInput { .. }));
1238 assert!(err.to_string().contains("must not start with '-'"));
1239 }
1240
1241 fn current_branch(root: &Path) -> String {
1242 let output = crate::dependencies::Git::output(&["branch", "--show-current"], root)
1243 .expect("git should spawn");
1244 assert!(output.status.success());
1245 String::from_utf8_lossy(&output.stdout).trim().to_string()
1246 }
1247 }
1248
1248 lines RUST