Skip to main content

git_iris/agents/tools/
git.rs

1//! Git operations tools for Rig-based agents
2//!
3//! This module provides Git operations using Rig's tool system.
4
5use 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
21/// Helper to add a change type if not already present
22fn add_change(changes: &mut Vec<&'static str>, change: &'static str) {
23    if !changes.contains(&change) {
24        changes.push(change);
25    }
26}
27
28/// Check for function definitions in a line based on language
29fn 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
49/// Check for import statements based on language
50fn 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
60/// Check for type definitions based on language
61fn 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/// Detect semantic change types from diff content
81#[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    // Get file extension
88    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    // Only analyze supported languages
95    let supported = matches!(
96        ext.as_str(),
97        "rs" | "ts" | "tsx" | "js" | "jsx" | "py" | "go"
98    );
99
100    if supported {
101        // Analyze added lines for patterns
102        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            // Rust-specific: impl blocks
118            if ext == "rs" && line.starts_with("impl ") {
119                add_change(&mut changes, "adds impl");
120            }
121        }
122    }
123
124    // Check for general change patterns
125    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/// Calculate relevance score for a file (0.0 - 1.0)
142/// Higher score = more important for commit message
143#[allow(clippy::case_sensitive_file_extension_comparisons)]
144fn calculate_relevance_score(file: &StagedFile) -> (f32, Vec<&'static str>) {
145    let mut score: f32 = 0.5; // Base score
146    let mut reasons = Vec::new();
147    let path = file.path.to_lowercase();
148
149    // Change type scoring
150    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    // File type scoring - source code is most important
165    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    // Path-based scoring
194    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    // Diff size scoring (estimate from diff length)
212    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    // Add semantic change detection
222    let semantic_changes = detect_semantic_changes(&file.diff, &file.path);
223    for change in semantic_changes {
224        if !reasons.contains(&change) {
225            // Boost score for structural changes
226            if change == "adds function" || change == "adds type" || change == "adds impl" {
227                score += 0.1;
228            }
229            reasons.push(change);
230        }
231    }
232
233    // Clamp to 0.0-1.0
234    score = score.clamp(0.0, 1.0);
235
236    (score, reasons)
237}
238
239/// Scored file for output
240struct ScoredFile<'a> {
241    file: &'a StagedFile,
242    score: f32,
243    reasons: Vec<&'static str>,
244}
245
246/// Build the diff output string from scored files
247fn 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    // Calculate stats
257    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    // Categorize size
268    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    // Header
282    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    // File list
292    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    // Diffs or hint
309    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// Git status tool
332#[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// Git diff tool
379#[derive(Debug, Clone, Serialize, Deserialize)]
380pub struct GitDiff;
381
382/// Detail level for diff output
383#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, Default)]
384#[serde(rename_all = "lowercase")]
385pub enum DetailLevel {
386    /// Summary only: file list with stats and relevance scores, no diffs (default)
387    #[default]
388    Summary,
389    /// Standard: includes full diffs (use with `files` filter for large changesets)
390    Standard,
391}
392
393#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
394pub struct GitDiffArgs {
395    /// Use "staged" or omit for staged changes, or specify commit/branch
396    #[serde(default)]
397    pub from: Option<String>,
398    /// Target commit/branch (use with from)
399    #[serde(default)]
400    pub to: Option<String>,
401    /// Detail level: "summary" (default) for overview, "standard" for full diffs
402    #[serde(default)]
403    pub detail: DetailLevel,
404    /// Filter to specific files (use with detail="standard" for targeted analysis)
405    #[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        // Normalize empty strings to None (LLMs often send "" instead of null)
427        let from = args.from.filter(|s| !s.is_empty());
428        let to = args.to.filter(|s| !s.is_empty());
429
430        // Handle the case where we want staged changes
431        // - No args: get staged changes
432        // - from="staged": get staged changes
433        // - Otherwise: get commit range
434        let files = match (from.as_deref(), to.as_deref()) {
435            (None | Some("staged"), None) | (Some("staged"), Some("HEAD")) => {
436                // Get staged changes
437                let files_info = repo.extract_files_info(false).map_err(GitError::from)?;
438                files_info.staged_files
439            }
440            (Some(from), Some(to)) => {
441                // Get changes between two commits/branches
442                repo.get_commit_range_files(from, to)
443                    .map_err(GitError::from)?
444            }
445            (None, Some(_)) => {
446                // Invalid: to without from
447                return Err(GitError(
448                    "Cannot specify 'to' without 'from'. Use both or neither.".to_string(),
449                ));
450            }
451            (Some(from), None) => {
452                // Get changes from a specific commit to HEAD (already handled "staged" above)
453                repo.get_commit_range_files(from, "HEAD")
454                    .map_err(GitError::from)?
455            }
456        };
457
458        // Score and sort files by relevance
459        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        // Sort by score descending (most important first)
472        scored_files.sort_by(|a, b| {
473            b.score
474                .partial_cmp(&a.score)
475                .unwrap_or(std::cmp::Ordering::Equal)
476        });
477
478        // Track total before filtering
479        let total_files = scored_files.len();
480
481        // Filter to specific files if requested
482        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        // Build output
488        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// Git log tool
499#[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    /// Commit, tag, or branch to inspect.
606    pub commit: String,
607    /// Optional repository-relative paths to filter the patch.
608    #[serde(default)]
609    pub files: Option<Vec<PathBuf>>,
610    /// Maximum characters to return. Defaults to 20000, clamped to 1000..=50000.
611    #[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// Git repository info tool
742#[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// Git changed files tool
782#[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        // Normalize empty strings to None (LLMs often send "" instead of null)
812        let from = args.from.filter(|s| !s.is_empty());
813        let mut to = args.to.filter(|s| !s.is_empty());
814
815        // Default to HEAD when the caller provides only a starting point.
816        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                // When both from and to are provided, get files changed between commits/branches
823                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                // When only to is provided, get files changed in that single commit
830                repo.get_file_paths_for_commit(&to)
831                    .map_err(GitError::from)?
832            }
833            (Some(_from), None) => {
834                // Invalid: from without to doesn't make sense for file listing
835                return Err(GitError(
836                    "Cannot specify 'from' without 'to' for file listing".to_string(),
837                ));
838            }
839            (None, None) => {
840                // When neither are provided, get staged files
841                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// Git blame tool
858#[derive(Debug, Clone, Serialize, Deserialize)]
859pub struct GitBlame;
860
861#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
862pub struct GitBlameArgs {
863    /// Repository-relative file path to inspect.
864    pub file: PathBuf,
865    /// First line to blame, 1-based.
866    #[serde(default = "default_start_line")]
867    pub start_line: u32,
868    /// Last line to blame. Defaults to `start_line`.
869    #[serde(default)]
870    pub end_line: Option<u32>,
871    /// Number of recent commits touching this file to include. Defaults to 3, max 10.
872    #[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}