1use anyhow::Result;
6use rig::completion::ToolDefinition;
7use rig::tool::Tool;
8use serde::{Deserialize, Serialize};
9use std::collections::BTreeSet;
10use std::path::{Path, PathBuf};
11use std::process::Command;
12
13use crate::context::{ChangeType, RecentCommit};
14use crate::define_tool_error;
15use crate::git::StagedFile;
16
17use super::common::{get_current_repo, parameters_schema};
18
19define_tool_error!(GitError);
20
21fn add_change(changes: &mut Vec<&'static str>, change: &'static str) {
23 if !changes.contains(&change) {
24 changes.push(change);
25 }
26}
27
28fn is_function_def(line: &str, ext: &str) -> bool {
30 match ext {
31 "rs" => {
32 line.starts_with("pub fn ")
33 || line.starts_with("fn ")
34 || line.starts_with("pub async fn ")
35 || line.starts_with("async fn ")
36 }
37 "ts" | "tsx" | "js" | "jsx" => {
38 line.starts_with("function ")
39 || line.starts_with("async function ")
40 || line.contains(" = () =>")
41 || line.contains(" = async () =>")
42 }
43 "py" => line.starts_with("def ") || line.starts_with("async def "),
44 "go" => line.starts_with("func "),
45 _ => false,
46 }
47}
48
49fn is_import(line: &str, ext: &str) -> bool {
51 match ext {
52 "rs" => line.starts_with("use ") || line.starts_with("pub use "),
53 "ts" | "tsx" | "js" | "jsx" => line.starts_with("import ") || line.starts_with("export "),
54 "py" => line.starts_with("import ") || line.starts_with("from "),
55 "go" => line.starts_with("import "),
56 _ => false,
57 }
58}
59
60fn is_type_def(line: &str, ext: &str) -> bool {
62 match ext {
63 "rs" => {
64 line.starts_with("pub struct ")
65 || line.starts_with("struct ")
66 || line.starts_with("pub enum ")
67 || line.starts_with("enum ")
68 }
69 "ts" | "tsx" | "js" | "jsx" => {
70 line.starts_with("interface ")
71 || line.starts_with("type ")
72 || line.starts_with("class ")
73 }
74 "py" => line.starts_with("class "),
75 "go" => line.starts_with("type "),
76 _ => false,
77 }
78}
79
80#[allow(clippy::cognitive_complexity)]
82fn detect_semantic_changes(diff: &str, path: &str) -> Vec<&'static str> {
83 use std::path::Path;
84
85 let mut changes = Vec::new();
86
87 let ext = Path::new(path)
89 .extension()
90 .and_then(|e| e.to_str())
91 .map(str::to_lowercase)
92 .unwrap_or_default();
93
94 let supported = matches!(
96 ext.as_str(),
97 "rs" | "ts" | "tsx" | "js" | "jsx" | "py" | "go"
98 );
99
100 if supported {
101 for line in diff
103 .lines()
104 .filter(|l| l.starts_with('+') && !l.starts_with("+++"))
105 {
106 let line = line.trim_start_matches('+').trim();
107
108 if is_function_def(line, &ext) {
109 add_change(&mut changes, "adds function");
110 }
111 if is_import(line, &ext) {
112 add_change(&mut changes, "modifies imports");
113 }
114 if is_type_def(line, &ext) {
115 add_change(&mut changes, "adds type");
116 }
117 if ext == "rs" && line.starts_with("impl ") {
119 add_change(&mut changes, "adds impl");
120 }
121 }
122 }
123
124 let has_deletions = diff
126 .lines()
127 .any(|l| l.starts_with('-') && !l.starts_with("---"));
128 let has_additions = diff
129 .lines()
130 .any(|l| l.starts_with('+') && !l.starts_with("+++"));
131
132 if has_deletions && has_additions && changes.is_empty() {
133 changes.push("refactors code");
134 } else if has_deletions && !has_additions {
135 changes.push("removes code");
136 }
137
138 changes
139}
140
141#[allow(clippy::case_sensitive_file_extension_comparisons)]
144fn calculate_relevance_score(file: &StagedFile) -> (f32, Vec<&'static str>) {
145 let mut score: f32 = 0.5; let mut reasons = Vec::new();
147 let path = file.path.to_lowercase();
148
149 match file.change_type {
151 ChangeType::Added => {
152 score += 0.15;
153 reasons.push("new file");
154 }
155 ChangeType::Modified => {
156 score += 0.1;
157 }
158 ChangeType::Deleted => {
159 score += 0.05;
160 reasons.push("deleted");
161 }
162 }
163
164 if path.ends_with(".rs")
166 || path.ends_with(".py")
167 || path.ends_with(".ts")
168 || path.ends_with(".tsx")
169 || path.ends_with(".js")
170 || path.ends_with(".jsx")
171 || path.ends_with(".go")
172 || path.ends_with(".java")
173 || path.ends_with(".kt")
174 || path.ends_with(".swift")
175 || path.ends_with(".c")
176 || path.ends_with(".cpp")
177 || path.ends_with(".h")
178 {
179 score += 0.15;
180 reasons.push("source code");
181 } else if path.ends_with(".toml")
182 || path.ends_with(".json")
183 || path.ends_with(".yaml")
184 || path.ends_with(".yml")
185 {
186 score += 0.1;
187 reasons.push("config");
188 } else if path.ends_with(".md") || path.ends_with(".txt") || path.ends_with(".rst") {
189 score += 0.02;
190 reasons.push("docs");
191 }
192
193 if path.contains("/src/") || path.starts_with("src/") {
195 score += 0.1;
196 reasons.push("core source");
197 }
198 if path.contains("/test") || path.contains("_test.") || path.contains(".test.") {
199 score -= 0.1;
200 reasons.push("test file");
201 }
202 if path.contains("generated") || path.contains(".lock") || path.contains("package-lock") {
203 score -= 0.2;
204 reasons.push("generated/lock");
205 }
206 if path.contains("/vendor/") || path.contains("/node_modules/") {
207 score -= 0.3;
208 reasons.push("vendored");
209 }
210
211 let diff_lines = file.diff.lines().count();
213 if diff_lines > 10 && diff_lines < 200 {
214 score += 0.1;
215 reasons.push("substantive changes");
216 } else if diff_lines >= 200 {
217 score += 0.05;
218 reasons.push("large diff");
219 }
220
221 let semantic_changes = detect_semantic_changes(&file.diff, &file.path);
223 for change in semantic_changes {
224 if !reasons.contains(&change) {
225 if change == "adds function" || change == "adds type" || change == "adds impl" {
227 score += 0.1;
228 }
229 reasons.push(change);
230 }
231 }
232
233 score = score.clamp(0.0, 1.0);
235
236 (score, reasons)
237}
238
239struct ScoredFile<'a> {
241 file: &'a StagedFile,
242 score: f32,
243 reasons: Vec<&'static str>,
244}
245
246fn format_diff_output(
248 scored_files: &[ScoredFile],
249 total_files: usize,
250 is_filtered: bool,
251 include_diffs: bool,
252) -> String {
253 let mut output = String::new();
254 let showing = scored_files.len();
255
256 let additions: usize = scored_files
258 .iter()
259 .map(|sf| sf.file.diff.lines().filter(|l| l.starts_with('+')).count())
260 .sum();
261 let deletions: usize = scored_files
262 .iter()
263 .map(|sf| sf.file.diff.lines().filter(|l| l.starts_with('-')).count())
264 .sum();
265 let total_lines = additions + deletions;
266
267 let (size, guidance) = if is_filtered {
269 ("Filtered", "Showing requested files only.")
270 } else if total_files <= 3 && total_lines < 100 {
271 ("Small", "Focus on all files equally.")
272 } else if total_files <= 10 && total_lines < 500 {
273 ("Medium", "Prioritize files with >60% relevance.")
274 } else {
275 (
276 "Large",
277 "Use files=['path1','path2'] with detail='standard' to analyze specific files.",
278 )
279 };
280
281 let files_info = if is_filtered {
283 format!("{showing} of {total_files} files")
284 } else {
285 format!("{total_files} files")
286 };
287 output.push_str(&format!(
288 "=== CHANGES SUMMARY ===\n{files_info} | +{additions} -{deletions} | Size: {size} ({total_lines} lines)\nGuidance: {guidance}\n\n"
289 ));
290
291 output.push_str("Files by importance:\n");
293 for sf in scored_files {
294 let reasons = if sf.reasons.is_empty() {
295 String::new()
296 } else {
297 format!(" ({})", sf.reasons.join(", "))
298 };
299 output.push_str(&format!(
300 " [{:.0}%] {:?} {}{reasons}\n",
301 sf.score * 100.0,
302 sf.file.change_type,
303 sf.file.path
304 ));
305 }
306 output.push('\n');
307
308 if include_diffs {
310 output.push_str("=== DIFFS ===\n");
311 for sf in scored_files {
312 output.push_str(&format!(
313 "--- {} [{:.0}% relevance]\n",
314 sf.file.path,
315 sf.score * 100.0
316 ));
317 output.push_str(&sf.file.diff);
318 output.push('\n');
319 }
320 } else if is_filtered {
321 output.push_str("(Use detail='standard' to see full diffs for these files)\n");
322 } else {
323 output.push_str(
324 "(Use detail='standard' with files=['file1','file2'] to see specific diffs)\n",
325 );
326 }
327
328 output
329}
330
331#[derive(Debug, Clone, Serialize, Deserialize)]
333pub struct GitStatus;
334
335#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
336pub struct GitStatusArgs {
337 #[serde(default)]
338 pub include_unstaged: bool,
339}
340
341impl Tool for GitStatus {
342 const NAME: &'static str = "git_status";
343 type Error = GitError;
344 type Args = GitStatusArgs;
345 type Output = String;
346
347 async fn definition(&self, _: String) -> ToolDefinition {
348 ToolDefinition {
349 name: "git_status".to_string(),
350 description: "Get current Git repository status including staged and unstaged files"
351 .to_string(),
352 parameters: parameters_schema::<GitStatusArgs>(),
353 }
354 }
355
356 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
357 let repo = get_current_repo().map_err(GitError::from)?;
358
359 let files_info = repo
360 .extract_files_info(args.include_unstaged)
361 .map_err(GitError::from)?;
362
363 let mut output = String::new();
364 output.push_str(&format!("Branch: {}\n", files_info.branch));
365 output.push_str(&format!(
366 "Files changed: {}\n",
367 files_info.staged_files.len()
368 ));
369
370 for file in &files_info.staged_files {
371 output.push_str(&format!(" {}: {:?}\n", file.path, file.change_type));
372 }
373
374 Ok(output)
375 }
376}
377
378#[derive(Debug, Clone, Serialize, Deserialize)]
380pub struct GitDiff;
381
382#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, Default)]
384#[serde(rename_all = "lowercase")]
385pub enum DetailLevel {
386 #[default]
388 Summary,
389 Standard,
391}
392
393#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
394pub struct GitDiffArgs {
395 #[serde(default)]
397 pub from: Option<String>,
398 #[serde(default)]
400 pub to: Option<String>,
401 #[serde(default)]
403 pub detail: DetailLevel,
404 #[serde(default)]
406 pub files: Option<Vec<String>>,
407}
408
409impl Tool for GitDiff {
410 const NAME: &'static str = "git_diff";
411 type Error = GitError;
412 type Args = GitDiffArgs;
413 type Output = String;
414
415 async fn definition(&self, _: String) -> ToolDefinition {
416 ToolDefinition {
417 name: "git_diff".to_string(),
418 description: "Get Git diff for file changes. Returns summary by default (file list with relevance scores). Use detail='standard' with files=['path1','path2'] to get full diffs for specific files. Progressive approach: call once for summary, then again with files filter for important ones.".to_string(),
419 parameters: parameters_schema::<GitDiffArgs>(),
420 }
421 }
422
423 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
424 let repo = get_current_repo().map_err(GitError::from)?;
425
426 let from = args.from.filter(|s| !s.is_empty());
428 let to = args.to.filter(|s| !s.is_empty());
429
430 let files = match (from.as_deref(), to.as_deref()) {
435 (None | Some("staged"), None) | (Some("staged"), Some("HEAD")) => {
436 let files_info = repo.extract_files_info(false).map_err(GitError::from)?;
438 files_info.staged_files
439 }
440 (Some(from), Some(to)) => {
441 repo.get_commit_range_files(from, to)
443 .map_err(GitError::from)?
444 }
445 (None, Some(_)) => {
446 return Err(GitError(
448 "Cannot specify 'to' without 'from'. Use both or neither.".to_string(),
449 ));
450 }
451 (Some(from), None) => {
452 repo.get_commit_range_files(from, "HEAD")
454 .map_err(GitError::from)?
455 }
456 };
457
458 let mut scored_files: Vec<ScoredFile> = files
460 .iter()
461 .map(|file| {
462 let (score, reasons) = calculate_relevance_score(file);
463 ScoredFile {
464 file,
465 score,
466 reasons,
467 }
468 })
469 .collect();
470
471 scored_files.sort_by(|a, b| {
473 b.score
474 .partial_cmp(&a.score)
475 .unwrap_or(std::cmp::Ordering::Equal)
476 });
477
478 let total_files = scored_files.len();
480
481 let is_filtered = args.files.is_some();
483 if let Some(ref filter) = args.files {
484 scored_files.retain(|sf| filter.iter().any(|f| sf.file.path.contains(f)));
485 }
486
487 let include_diffs = matches!(args.detail, DetailLevel::Standard);
489 Ok(format_diff_output(
490 &scored_files,
491 total_files,
492 is_filtered,
493 include_diffs,
494 ))
495 }
496}
497
498#[derive(Debug, Clone, Serialize, Deserialize)]
500pub struct GitLog;
501
502#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
503pub struct GitLogArgs {
504 #[serde(default)]
505 pub count: Option<usize>,
506 #[serde(default)]
507 pub from: Option<String>,
508 #[serde(default)]
509 pub to: Option<String>,
510}
511
512impl Tool for GitLog {
513 const NAME: &'static str = "git_log";
514 type Error = GitError;
515 type Args = GitLogArgs;
516 type Output = String;
517
518 async fn definition(&self, _: String) -> ToolDefinition {
519 ToolDefinition {
520 name: "git_log".to_string(),
521 description: "Get Git commit history".to_string(),
522 parameters: parameters_schema::<GitLogArgs>(),
523 }
524 }
525
526 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
527 let repo = get_current_repo().map_err(GitError::from)?;
528
529 if let Some(from) = args.from {
530 let to = args.to.unwrap_or_else(|| "HEAD".to_string());
531 let commits = repo
532 .get_commits_in_range(&from, &to)
533 .map_err(GitError::from)?;
534 return Ok(format_git_log_output(
535 &format!("Commits from {from} to {to}:"),
536 &commits,
537 true,
538 ));
539 }
540
541 if args.to.is_some() {
542 return Err(GitError::from(anyhow::anyhow!(
543 "git_log requires `from` when `to` is provided"
544 )));
545 }
546
547 let commits = repo
548 .get_recent_commits(args.count.unwrap_or(10))
549 .map_err(GitError::from)?;
550
551 Ok(format_git_log_output("Recent commits:", &commits, false))
552 }
553}
554
555fn format_git_log_output(
556 header: &str,
557 commits: &[RecentCommit],
558 include_contributors: bool,
559) -> String {
560 let mut output = String::new();
561 output.push_str(header);
562 output.push('\n');
563
564 for commit in commits {
565 let title = commit.message.lines().next().unwrap_or_default().trim();
566 output.push_str(&format!("{}: {} ({})\n", commit.hash, title, commit.author));
567 }
568
569 if include_contributors {
570 let contributors: BTreeSet<String> = commits
571 .iter()
572 .map(|commit| commit.author.trim())
573 .filter(|author| !author.is_empty() && !is_bot_author(author))
574 .map(ToOwned::to_owned)
575 .collect();
576
577 if !contributors.is_empty() {
578 output.push_str("\nContributors (excluding bots):\n");
579 for contributor in contributors {
580 output.push_str(&format!("- {contributor}\n"));
581 }
582 }
583 }
584
585 output
586}
587
588fn is_bot_author(author: &str) -> bool {
589 let normalized = author.trim().to_ascii_lowercase();
590
591 normalized.contains("[bot]")
592 || normalized.contains("dependabot")
593 || normalized.contains("renovate")
594 || normalized.contains("github-actions")
595 || normalized.ends_with(" bot")
596 || normalized.ends_with("-bot")
597 || normalized == "bot"
598}
599
600#[derive(Debug, Clone, Serialize, Deserialize)]
601pub struct GitShow;
602
603#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
604pub struct GitShowArgs {
605 pub commit: String,
607 #[serde(default)]
609 pub files: Option<Vec<PathBuf>>,
610 #[serde(default = "default_git_show_max_output_chars")]
612 #[schemars(description = "Maximum characters to return. Values are clamped to 1000..=50000.")]
613 pub max_output_chars: usize,
614}
615
616fn default_git_show_max_output_chars() -> usize {
617 20_000
618}
619
620impl Tool for GitShow {
621 const NAME: &'static str = "git_show";
622 type Error = GitError;
623 type Args = GitShowArgs;
624 type Output = String;
625
626 async fn definition(&self, _: String) -> ToolDefinition {
627 ToolDefinition {
628 name: "git_show".to_string(),
629 description: "Show a commit message, metadata, stat, and patch for a commit, tag, or branch. Use this after git_log or git_blame when a historical commit's exact changes clarify intent, prior behavior, or regression risk.".to_string(),
630 parameters: parameters_schema::<GitShowArgs>(),
631 }
632 }
633
634 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
635 let repo = get_current_repo().map_err(GitError::from)?;
636 let repo_root = repo.repo_path();
637 let requested = args.commit.trim();
638 let commit = resolve_commit(repo_root, requested).map_err(GitError::from)?;
639 let files = args
640 .files
641 .unwrap_or_default()
642 .into_iter()
643 .map(|file| normalize_repo_relative_filter_path(&file))
644 .collect::<Result<Vec<_>>>()
645 .map_err(GitError::from)?;
646 let max_output_chars = args.max_output_chars.clamp(1_000, 50_000);
647
648 run_git_show(repo_root, requested, &commit, &files, max_output_chars)
649 .map_err(GitError::from)
650 }
651}
652
653fn resolve_commit(repo_root: &Path, commit: &str) -> Result<String> {
654 if commit.is_empty() {
655 anyhow::bail!("commit must not be empty");
656 }
657 if commit.starts_with('-') || commit.chars().any(char::is_whitespace) {
658 anyhow::bail!("commit must be a commit, tag, or branch name");
659 }
660
661 let rev = format!("{commit}^{{commit}}");
662 let output = Command::new("git")
663 .args(["rev-parse", "--verify", "--quiet", &rev])
664 .current_dir(repo_root)
665 .output()?;
666
667 if !output.status.success() {
668 anyhow::bail!("commit not found: {commit}");
669 }
670
671 let hash = String::from_utf8_lossy(&output.stdout).trim().to_string();
672 if hash.len() != 40 || !hash.chars().all(|ch| ch.is_ascii_hexdigit()) {
673 anyhow::bail!("git returned an invalid commit hash for {commit}");
674 }
675
676 Ok(hash)
677}
678
679fn run_git_show(
680 repo_root: &Path,
681 requested: &str,
682 commit: &str,
683 files: &[PathBuf],
684 max_output_chars: usize,
685) -> Result<String> {
686 let mut command = Command::new("git");
687 command.args([
688 "show",
689 "--no-ext-diff",
690 "--no-color",
691 "--stat",
692 "--format=fuller",
693 "--patch",
694 commit,
695 ]);
696
697 if !files.is_empty() {
698 command.arg("--");
699 command.args(files);
700 }
701
702 let output = command.current_dir(repo_root).output()?;
703 if !output.status.success() {
704 anyhow::bail!(
705 "git show failed: {}",
706 String::from_utf8_lossy(&output.stderr).trim()
707 );
708 }
709
710 let mut rendered = format!("Git show for {requested} ({commit})\n");
711 if !files.is_empty() {
712 let file_list = files
713 .iter()
714 .map(|file| file.display().to_string())
715 .collect::<Vec<_>>()
716 .join(", ");
717 rendered.push_str(&format!("Filtered paths: {file_list}\n"));
718 }
719 rendered.push('\n');
720 rendered.push_str(String::from_utf8_lossy(&output.stdout).trim_end());
721
722 Ok(truncate_git_show_output(&rendered, max_output_chars))
723}
724
725fn truncate_git_show_output(text: &str, max_chars: usize) -> String {
726 const SUFFIX: &str = "\n[git_show output truncated]";
727
728 let mut chars = text.chars();
729 let mut truncated = chars.by_ref().take(max_chars).collect::<String>();
730 if chars.next().is_none() {
731 return truncated;
732 }
733
734 let suffix_chars = SUFFIX.chars().count();
735 let reserved = max_chars.saturating_sub(suffix_chars);
736 truncated = text.chars().take(reserved).collect::<String>();
737 truncated.push_str(SUFFIX);
738 truncated
739}
740
741#[derive(Debug, Clone, Serialize, Deserialize)]
743pub struct GitRepoInfo;
744
745#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
746pub struct GitRepoInfoArgs {}
747
748impl Tool for GitRepoInfo {
749 const NAME: &'static str = "git_repo_info";
750 type Error = GitError;
751 type Args = GitRepoInfoArgs;
752 type Output = String;
753
754 async fn definition(&self, _: String) -> ToolDefinition {
755 ToolDefinition {
756 name: "git_repo_info".to_string(),
757 description: "Get general information about the Git repository".to_string(),
758 parameters: parameters_schema::<GitRepoInfoArgs>(),
759 }
760 }
761
762 async fn call(&self, _args: Self::Args) -> Result<Self::Output, Self::Error> {
763 let repo = get_current_repo().map_err(GitError::from)?;
764
765 let branch = repo.get_current_branch().map_err(GitError::from)?;
766 let remote_url = repo.get_remote_url().unwrap_or("None").to_string();
767
768 let mut output = String::new();
769 output.push_str("Repository Information:\n");
770 output.push_str(&format!("Current Branch: {branch}\n"));
771 output.push_str(&format!("Remote URL: {remote_url}\n"));
772 output.push_str(&format!(
773 "Repository Path: {}\n",
774 repo.repo_path().display()
775 ));
776
777 Ok(output)
778 }
779}
780
781#[derive(Debug, Clone, Serialize, Deserialize)]
783pub struct GitChangedFiles;
784
785#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
786pub struct GitChangedFilesArgs {
787 #[serde(default)]
788 pub from: Option<String>,
789 #[serde(default)]
790 pub to: Option<String>,
791}
792
793impl Tool for GitChangedFiles {
794 const NAME: &'static str = "git_changed_files";
795 type Error = GitError;
796 type Args = GitChangedFilesArgs;
797 type Output = String;
798
799 async fn definition(&self, _: String) -> ToolDefinition {
800 ToolDefinition {
801 name: "git_changed_files".to_string(),
802 description: "Get list of files that have changed between commits or branches"
803 .to_string(),
804 parameters: parameters_schema::<GitChangedFilesArgs>(),
805 }
806 }
807
808 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
809 let repo = get_current_repo().map_err(GitError::from)?;
810
811 let from = args.from.filter(|s| !s.is_empty());
813 let mut to = args.to.filter(|s| !s.is_empty());
814
815 if from.is_some() && to.is_none() {
817 to = Some("HEAD".to_string());
818 }
819
820 let files = match (from, to) {
821 (Some(from), Some(to)) => {
822 let range_files = repo
824 .get_commit_range_files(&from, &to)
825 .map_err(GitError::from)?;
826 range_files.iter().map(|f| f.path.clone()).collect()
827 }
828 (None, Some(to)) => {
829 repo.get_file_paths_for_commit(&to)
831 .map_err(GitError::from)?
832 }
833 (Some(_from), None) => {
834 return Err(GitError(
836 "Cannot specify 'from' without 'to' for file listing".to_string(),
837 ));
838 }
839 (None, None) => {
840 let files_info = repo.extract_files_info(false).map_err(GitError::from)?;
842 files_info.file_paths
843 }
844 };
845
846 let mut output = String::new();
847 output.push_str("Changed files:\n");
848
849 for file in files {
850 output.push_str(&format!(" {file}\n"));
851 }
852
853 Ok(output)
854 }
855}
856
857#[derive(Debug, Clone, Serialize, Deserialize)]
859pub struct GitBlame;
860
861#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
862pub struct GitBlameArgs {
863 pub file: PathBuf,
865 #[serde(default = "default_start_line")]
867 pub start_line: u32,
868 #[serde(default)]
870 pub end_line: Option<u32>,
871 #[serde(default = "default_recent_commits")]
873 pub recent_commits: usize,
874}
875
876#[derive(Debug, Clone, PartialEq, Eq)]
877struct BlameCommit {
878 hash: String,
879 author: String,
880 date: String,
881 summary: String,
882}
883
884fn default_start_line() -> u32 {
885 1
886}
887
888fn default_recent_commits() -> usize {
889 3
890}
891
892impl Tool for GitBlame {
893 const NAME: &'static str = "git_blame";
894 type Error = GitError;
895 type Args = GitBlameArgs;
896 type Output = String;
897
898 async fn definition(&self, _: String) -> ToolDefinition {
899 ToolDefinition {
900 name: "git_blame".to_string(),
901 description: "Get git blame context for a repository-relative file line range, plus recent commits that touched the file. Use this for history, ownership, and style context before commit messages, PR descriptions, or semantic explanations.".to_string(),
902 parameters: parameters_schema::<GitBlameArgs>(),
903 }
904 }
905
906 async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
907 let repo = get_current_repo().map_err(GitError::from)?;
908 let repo_root = repo.repo_path();
909 let file = normalize_repo_relative_path(repo_root, &args.file).map_err(GitError::from)?;
910 let start_line = args.start_line.max(1);
911 let end_line = args.end_line.unwrap_or(start_line).max(start_line);
912 let recent_commits = args.recent_commits.clamp(1, 10);
913
914 let code =
915 read_line_range(repo_root, &file, start_line, end_line).map_err(GitError::from)?;
916 let blame = if code.in_range {
917 run_git_blame(repo_root, &file, start_line, end_line).map_err(GitError::from)?
918 } else {
919 Vec::new()
920 };
921 let history =
922 recent_file_commits(repo_root, &file, recent_commits).map_err(GitError::from)?;
923
924 Ok(format_blame_output(
925 &file,
926 start_line,
927 end_line,
928 &code.content,
929 &blame,
930 &history,
931 ))
932 }
933}
934
935fn normalize_repo_relative_path(repo_root: &Path, path: &Path) -> Result<PathBuf> {
936 let normalized = normalize_repo_relative_filter_path(path)?;
937 let full_path = repo_root.join(&normalized);
938 if !full_path.is_file() {
939 anyhow::bail!("file does not exist: {}", normalized.display());
940 }
941
942 Ok(normalized)
943}
944
945fn normalize_repo_relative_filter_path(path: &Path) -> Result<PathBuf> {
946 if path.is_absolute() {
947 anyhow::bail!("file must be a repository-relative path");
948 }
949
950 let normalized = PathBuf::from(
951 path.to_string_lossy()
952 .replace('\\', "/")
953 .trim_start_matches("./"),
954 );
955
956 if normalized.components().any(|component| {
957 matches!(
958 component,
959 std::path::Component::ParentDir | std::path::Component::Prefix(_)
960 )
961 }) {
962 anyhow::bail!("file must be a repository-relative path");
963 }
964
965 Ok(normalized)
966}
967
968struct LineRangeContent {
969 content: String,
970 in_range: bool,
971}
972
973fn read_line_range(
974 repo_root: &Path,
975 file: &Path,
976 start_line: u32,
977 end_line: u32,
978) -> Result<LineRangeContent> {
979 let content = std::fs::read_to_string(repo_root.join(file))?;
980 let start_index = usize::try_from(start_line.saturating_sub(1))?;
981 let take_count = usize::try_from(end_line.saturating_sub(start_line) + 1)?;
982
983 let lines = content
984 .lines()
985 .enumerate()
986 .skip(start_index)
987 .take(take_count)
988 .map(|(index, line)| format!("{:>4} | {}", index + 1, line))
989 .collect::<Vec<_>>()
990 .join("\n");
991
992 if lines.is_empty() {
993 return Ok(LineRangeContent {
994 content: format!("<line range outside file: {}>", file.display()),
995 in_range: false,
996 });
997 }
998
999 Ok(LineRangeContent {
1000 content: lines,
1001 in_range: true,
1002 })
1003}
1004
1005fn run_git_blame(
1006 repo_root: &Path,
1007 file: &Path,
1008 start_line: u32,
1009 end_line: u32,
1010) -> Result<Vec<BlameCommit>> {
1011 let output = Command::new("git")
1012 .args([
1013 "blame",
1014 "-L",
1015 &format!("{start_line},{end_line}"),
1016 "--porcelain",
1017 "--",
1018 &file.to_string_lossy(),
1019 ])
1020 .current_dir(repo_root)
1021 .output()?;
1022
1023 if !output.status.success() {
1024 anyhow::bail!(
1025 "git blame failed: {}",
1026 String::from_utf8_lossy(&output.stderr).trim()
1027 );
1028 }
1029
1030 Ok(parse_blame_porcelain(&String::from_utf8_lossy(
1031 &output.stdout,
1032 )))
1033}
1034
1035fn parse_blame_porcelain(output: &str) -> Vec<BlameCommit> {
1036 let mut commits = Vec::new();
1037 let mut current_index = None;
1038
1039 for line in output.lines() {
1040 if let Some(hash) = line
1041 .split_whitespace()
1042 .next()
1043 .filter(|hash| hash.len() >= 40 && hash.chars().all(|ch| ch.is_ascii_hexdigit()))
1044 {
1045 let index = commits
1046 .iter()
1047 .position(|commit: &BlameCommit| commit.hash == hash)
1048 .unwrap_or_else(|| {
1049 commits.push(BlameCommit {
1050 hash: hash.to_string(),
1051 author: String::new(),
1052 date: String::new(),
1053 summary: String::new(),
1054 });
1055 commits.len() - 1
1056 });
1057 current_index = Some(index);
1058 continue;
1059 }
1060
1061 let Some(index) = current_index else {
1062 continue;
1063 };
1064 let Some(commit) = commits.get_mut(index) else {
1065 continue;
1066 };
1067
1068 if let Some(author) = line.strip_prefix("author ") {
1069 if commit.author.is_empty() {
1070 commit.author = author.to_string();
1071 }
1072 } else if let Some(timestamp) = line.strip_prefix("author-time ") {
1073 if commit.date.is_empty() {
1074 commit.date = timestamp
1075 .parse::<i64>()
1076 .ok()
1077 .and_then(|timestamp| chrono::DateTime::from_timestamp(timestamp, 0))
1078 .map_or_else(
1079 || "unknown date".to_string(),
1080 |datetime| datetime.format("%Y-%m-%d").to_string(),
1081 );
1082 }
1083 } else if let Some(summary) = line.strip_prefix("summary ")
1084 && commit.summary.is_empty()
1085 {
1086 commit.summary = summary.to_string();
1087 }
1088 }
1089
1090 commits
1091}
1092
1093fn recent_file_commits(repo_root: &Path, file: &Path, count: usize) -> Result<Vec<BlameCommit>> {
1094 let output = Command::new("git")
1095 .args([
1096 "log",
1097 "-n",
1098 &count.to_string(),
1099 "--format=%H%x1f%an%x1f%ad%x1f%s",
1100 "--date=short",
1101 "--",
1102 &file.to_string_lossy(),
1103 ])
1104 .current_dir(repo_root)
1105 .output()?;
1106
1107 if !output.status.success() {
1108 anyhow::bail!(
1109 "git log failed: {}",
1110 String::from_utf8_lossy(&output.stderr).trim()
1111 );
1112 }
1113
1114 Ok(String::from_utf8_lossy(&output.stdout)
1115 .lines()
1116 .filter_map(parse_log_commit)
1117 .collect())
1118}
1119
1120fn parse_log_commit(line: &str) -> Option<BlameCommit> {
1121 let mut parts = line.split('\x1f');
1122 Some(BlameCommit {
1123 hash: parts.next()?.to_string(),
1124 author: parts.next()?.to_string(),
1125 date: parts.next()?.to_string(),
1126 summary: parts.next()?.to_string(),
1127 })
1128}
1129
1130fn format_blame_output(
1131 file: &Path,
1132 start_line: u32,
1133 end_line: u32,
1134 code: &str,
1135 blame: &[BlameCommit],
1136 history: &[BlameCommit],
1137) -> String {
1138 let mut output = format!(
1139 "Git blame for {}:{start_line}-{end_line}\n\n",
1140 file.display()
1141 );
1142 output.push_str("Code:\n");
1143 output.push_str(code);
1144 output.push_str("\n\nBlame commits:\n");
1145
1146 if blame.is_empty() {
1147 output.push_str("- No blame data found\n");
1148 } else {
1149 for commit in blame {
1150 output.push_str(&format!(
1151 "- {}: {} ({}, {})\n",
1152 short_hash(&commit.hash),
1153 commit.summary,
1154 commit.author,
1155 commit.date
1156 ));
1157 }
1158 }
1159
1160 output.push_str("\nRecent commits touching this file:\n");
1161 if history.is_empty() {
1162 output.push_str("- No recent file history found\n");
1163 } else {
1164 for commit in history {
1165 output.push_str(&format!(
1166 "- {}: {} ({}, {})\n",
1167 short_hash(&commit.hash),
1168 commit.summary,
1169 commit.author,
1170 commit.date
1171 ));
1172 }
1173 }
1174
1175 output
1176}
1177
1178fn short_hash(hash: &str) -> &str {
1179 let end = hash
1180 .char_indices()
1181 .nth(8)
1182 .map_or(hash.len(), |(index, _)| index);
1183 &hash[..end]
1184}