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;
10
11use crate::context::{ChangeType, RecentCommit};
12use crate::define_tool_error;
13use crate::git::StagedFile;
14
15use super::common::{get_current_repo, parameters_schema};
16
17define_tool_error!(GitError);
18
19/// Helper to add a change type if not already present
20fn add_change(changes: &mut Vec<&'static str>, change: &'static str) {
21    if !changes.contains(&change) {
22        changes.push(change);
23    }
24}
25
26/// Check for function definitions in a line based on language
27fn is_function_def(line: &str, ext: &str) -> bool {
28    match ext {
29        "rs" => {
30            line.starts_with("pub fn ")
31                || line.starts_with("fn ")
32                || line.starts_with("pub async fn ")
33                || line.starts_with("async fn ")
34        }
35        "ts" | "tsx" | "js" | "jsx" => {
36            line.starts_with("function ")
37                || line.starts_with("async function ")
38                || line.contains(" = () =>")
39                || line.contains(" = async () =>")
40        }
41        "py" => line.starts_with("def ") || line.starts_with("async def "),
42        "go" => line.starts_with("func "),
43        _ => false,
44    }
45}
46
47/// Check for import statements based on language
48fn is_import(line: &str, ext: &str) -> bool {
49    match ext {
50        "rs" => line.starts_with("use ") || line.starts_with("pub use "),
51        "ts" | "tsx" | "js" | "jsx" => line.starts_with("import ") || line.starts_with("export "),
52        "py" => line.starts_with("import ") || line.starts_with("from "),
53        "go" => line.starts_with("import "),
54        _ => false,
55    }
56}
57
58/// Check for type definitions based on language
59fn is_type_def(line: &str, ext: &str) -> bool {
60    match ext {
61        "rs" => {
62            line.starts_with("pub struct ")
63                || line.starts_with("struct ")
64                || line.starts_with("pub enum ")
65                || line.starts_with("enum ")
66        }
67        "ts" | "tsx" | "js" | "jsx" => {
68            line.starts_with("interface ")
69                || line.starts_with("type ")
70                || line.starts_with("class ")
71        }
72        "py" => line.starts_with("class "),
73        "go" => line.starts_with("type "),
74        _ => false,
75    }
76}
77
78/// Detect semantic change types from diff content
79#[allow(clippy::cognitive_complexity)]
80fn detect_semantic_changes(diff: &str, path: &str) -> Vec<&'static str> {
81    use std::path::Path;
82
83    let mut changes = Vec::new();
84
85    // Get file extension
86    let ext = Path::new(path)
87        .extension()
88        .and_then(|e| e.to_str())
89        .map(str::to_lowercase)
90        .unwrap_or_default();
91
92    // Only analyze supported languages
93    let supported = matches!(
94        ext.as_str(),
95        "rs" | "ts" | "tsx" | "js" | "jsx" | "py" | "go"
96    );
97
98    if supported {
99        // Analyze added lines for patterns
100        for line in diff
101            .lines()
102            .filter(|l| l.starts_with('+') && !l.starts_with("+++"))
103        {
104            let line = line.trim_start_matches('+').trim();
105
106            if is_function_def(line, &ext) {
107                add_change(&mut changes, "adds function");
108            }
109            if is_import(line, &ext) {
110                add_change(&mut changes, "modifies imports");
111            }
112            if is_type_def(line, &ext) {
113                add_change(&mut changes, "adds type");
114            }
115            // Rust-specific: impl blocks
116            if ext == "rs" && line.starts_with("impl ") {
117                add_change(&mut changes, "adds impl");
118            }
119        }
120    }
121
122    // Check for general change patterns
123    let has_deletions = diff
124        .lines()
125        .any(|l| l.starts_with('-') && !l.starts_with("---"));
126    let has_additions = diff
127        .lines()
128        .any(|l| l.starts_with('+') && !l.starts_with("+++"));
129
130    if has_deletions && has_additions && changes.is_empty() {
131        changes.push("refactors code");
132    } else if has_deletions && !has_additions {
133        changes.push("removes code");
134    }
135
136    changes
137}
138
139/// Calculate relevance score for a file (0.0 - 1.0)
140/// Higher score = more important for commit message
141#[allow(clippy::case_sensitive_file_extension_comparisons)]
142fn calculate_relevance_score(file: &StagedFile) -> (f32, Vec<&'static str>) {
143    let mut score: f32 = 0.5; // Base score
144    let mut reasons = Vec::new();
145    let path = file.path.to_lowercase();
146
147    // Change type scoring
148    match file.change_type {
149        ChangeType::Added => {
150            score += 0.15;
151            reasons.push("new file");
152        }
153        ChangeType::Modified => {
154            score += 0.1;
155        }
156        ChangeType::Deleted => {
157            score += 0.05;
158            reasons.push("deleted");
159        }
160    }
161
162    // File type scoring - source code is most important
163    if path.ends_with(".rs")
164        || path.ends_with(".py")
165        || path.ends_with(".ts")
166        || path.ends_with(".tsx")
167        || path.ends_with(".js")
168        || path.ends_with(".jsx")
169        || path.ends_with(".go")
170        || path.ends_with(".java")
171        || path.ends_with(".kt")
172        || path.ends_with(".swift")
173        || path.ends_with(".c")
174        || path.ends_with(".cpp")
175        || path.ends_with(".h")
176    {
177        score += 0.15;
178        reasons.push("source code");
179    } else if path.ends_with(".toml")
180        || path.ends_with(".json")
181        || path.ends_with(".yaml")
182        || path.ends_with(".yml")
183    {
184        score += 0.1;
185        reasons.push("config");
186    } else if path.ends_with(".md") || path.ends_with(".txt") || path.ends_with(".rst") {
187        score += 0.02;
188        reasons.push("docs");
189    }
190
191    // Path-based scoring
192    if path.contains("/src/") || path.starts_with("src/") {
193        score += 0.1;
194        reasons.push("core source");
195    }
196    if path.contains("/test") || path.contains("_test.") || path.contains(".test.") {
197        score -= 0.1;
198        reasons.push("test file");
199    }
200    if path.contains("generated") || path.contains(".lock") || path.contains("package-lock") {
201        score -= 0.2;
202        reasons.push("generated/lock");
203    }
204    if path.contains("/vendor/") || path.contains("/node_modules/") {
205        score -= 0.3;
206        reasons.push("vendored");
207    }
208
209    // Diff size scoring (estimate from diff length)
210    let diff_lines = file.diff.lines().count();
211    if diff_lines > 10 && diff_lines < 200 {
212        score += 0.1;
213        reasons.push("substantive changes");
214    } else if diff_lines >= 200 {
215        score += 0.05;
216        reasons.push("large diff");
217    }
218
219    // Add semantic change detection
220    let semantic_changes = detect_semantic_changes(&file.diff, &file.path);
221    for change in semantic_changes {
222        if !reasons.contains(&change) {
223            // Boost score for structural changes
224            if change == "adds function" || change == "adds type" || change == "adds impl" {
225                score += 0.1;
226            }
227            reasons.push(change);
228        }
229    }
230
231    // Clamp to 0.0-1.0
232    score = score.clamp(0.0, 1.0);
233
234    (score, reasons)
235}
236
237/// Scored file for output
238struct ScoredFile<'a> {
239    file: &'a StagedFile,
240    score: f32,
241    reasons: Vec<&'static str>,
242}
243
244/// Build the diff output string from scored files
245fn format_diff_output(
246    scored_files: &[ScoredFile],
247    total_files: usize,
248    is_filtered: bool,
249    include_diffs: bool,
250) -> String {
251    let mut output = String::new();
252    let showing = scored_files.len();
253
254    // Calculate stats
255    let additions: usize = scored_files
256        .iter()
257        .map(|sf| sf.file.diff.lines().filter(|l| l.starts_with('+')).count())
258        .sum();
259    let deletions: usize = scored_files
260        .iter()
261        .map(|sf| sf.file.diff.lines().filter(|l| l.starts_with('-')).count())
262        .sum();
263    let total_lines = additions + deletions;
264
265    // Categorize size
266    let (size, guidance) = if is_filtered {
267        ("Filtered", "Showing requested files only.")
268    } else if total_files <= 3 && total_lines < 100 {
269        ("Small", "Focus on all files equally.")
270    } else if total_files <= 10 && total_lines < 500 {
271        ("Medium", "Prioritize files with >60% relevance.")
272    } else {
273        (
274            "Large",
275            "Use files=['path1','path2'] with detail='standard' to analyze specific files.",
276        )
277    };
278
279    // Header
280    let files_info = if is_filtered {
281        format!("{showing} of {total_files} files")
282    } else {
283        format!("{total_files} files")
284    };
285    output.push_str(&format!(
286        "=== CHANGES SUMMARY ===\n{files_info} | +{additions} -{deletions} | Size: {size} ({total_lines} lines)\nGuidance: {guidance}\n\n"
287    ));
288
289    // File list
290    output.push_str("Files by importance:\n");
291    for sf in scored_files {
292        let reasons = if sf.reasons.is_empty() {
293            String::new()
294        } else {
295            format!(" ({})", sf.reasons.join(", "))
296        };
297        output.push_str(&format!(
298            "  [{:.0}%] {:?} {}{reasons}\n",
299            sf.score * 100.0,
300            sf.file.change_type,
301            sf.file.path
302        ));
303    }
304    output.push('\n');
305
306    // Diffs or hint
307    if include_diffs {
308        output.push_str("=== DIFFS ===\n");
309        for sf in scored_files {
310            output.push_str(&format!(
311                "--- {} [{:.0}% relevance]\n",
312                sf.file.path,
313                sf.score * 100.0
314            ));
315            output.push_str(&sf.file.diff);
316            output.push('\n');
317        }
318    } else if is_filtered {
319        output.push_str("(Use detail='standard' to see full diffs for these files)\n");
320    } else {
321        output.push_str(
322            "(Use detail='standard' with files=['file1','file2'] to see specific diffs)\n",
323        );
324    }
325
326    output
327}
328
329// Git status tool
330#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct GitStatus;
332
333#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
334pub struct GitStatusArgs {
335    #[serde(default)]
336    pub include_unstaged: bool,
337}
338
339impl Tool for GitStatus {
340    const NAME: &'static str = "git_status";
341    type Error = GitError;
342    type Args = GitStatusArgs;
343    type Output = String;
344
345    async fn definition(&self, _: String) -> ToolDefinition {
346        ToolDefinition {
347            name: "git_status".to_string(),
348            description: "Get current Git repository status including staged and unstaged files"
349                .to_string(),
350            parameters: parameters_schema::<GitStatusArgs>(),
351        }
352    }
353
354    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
355        let repo = get_current_repo().map_err(GitError::from)?;
356
357        let files_info = repo
358            .extract_files_info(args.include_unstaged)
359            .map_err(GitError::from)?;
360
361        let mut output = String::new();
362        output.push_str(&format!("Branch: {}\n", files_info.branch));
363        output.push_str(&format!(
364            "Files changed: {}\n",
365            files_info.staged_files.len()
366        ));
367
368        for file in &files_info.staged_files {
369            output.push_str(&format!("  {}: {:?}\n", file.path, file.change_type));
370        }
371
372        Ok(output)
373    }
374}
375
376// Git diff tool
377#[derive(Debug, Clone, Serialize, Deserialize)]
378pub struct GitDiff;
379
380/// Detail level for diff output
381#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, Default)]
382#[serde(rename_all = "lowercase")]
383pub enum DetailLevel {
384    /// Summary only: file list with stats and relevance scores, no diffs (default)
385    #[default]
386    Summary,
387    /// Standard: includes full diffs (use with `files` filter for large changesets)
388    Standard,
389}
390
391#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
392pub struct GitDiffArgs {
393    /// Use "staged" or omit for staged changes, or specify commit/branch
394    #[serde(default)]
395    pub from: Option<String>,
396    /// Target commit/branch (use with from)
397    #[serde(default)]
398    pub to: Option<String>,
399    /// Detail level: "summary" (default) for overview, "standard" for full diffs
400    #[serde(default)]
401    pub detail: DetailLevel,
402    /// Filter to specific files (use with detail="standard" for targeted analysis)
403    #[serde(default)]
404    pub files: Option<Vec<String>>,
405}
406
407impl Tool for GitDiff {
408    const NAME: &'static str = "git_diff";
409    type Error = GitError;
410    type Args = GitDiffArgs;
411    type Output = String;
412
413    async fn definition(&self, _: String) -> ToolDefinition {
414        ToolDefinition {
415            name: "git_diff".to_string(),
416            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(),
417            parameters: parameters_schema::<GitDiffArgs>(),
418        }
419    }
420
421    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
422        let repo = get_current_repo().map_err(GitError::from)?;
423
424        // Normalize empty strings to None (LLMs often send "" instead of null)
425        let from = args.from.filter(|s| !s.is_empty());
426        let to = args.to.filter(|s| !s.is_empty());
427
428        // Handle the case where we want staged changes
429        // - No args: get staged changes
430        // - from="staged": get staged changes
431        // - Otherwise: get commit range
432        let files = match (from.as_deref(), to.as_deref()) {
433            (None | Some("staged"), None) | (Some("staged"), Some("HEAD")) => {
434                // Get staged changes
435                let files_info = repo.extract_files_info(false).map_err(GitError::from)?;
436                files_info.staged_files
437            }
438            (Some(from), Some(to)) => {
439                // Get changes between two commits/branches
440                repo.get_commit_range_files(from, to)
441                    .map_err(GitError::from)?
442            }
443            (None, Some(_)) => {
444                // Invalid: to without from
445                return Err(GitError(
446                    "Cannot specify 'to' without 'from'. Use both or neither.".to_string(),
447                ));
448            }
449            (Some(from), None) => {
450                // Get changes from a specific commit to HEAD (already handled "staged" above)
451                repo.get_commit_range_files(from, "HEAD")
452                    .map_err(GitError::from)?
453            }
454        };
455
456        // Score and sort files by relevance
457        let mut scored_files: Vec<ScoredFile> = files
458            .iter()
459            .map(|file| {
460                let (score, reasons) = calculate_relevance_score(file);
461                ScoredFile {
462                    file,
463                    score,
464                    reasons,
465                }
466            })
467            .collect();
468
469        // Sort by score descending (most important first)
470        scored_files.sort_by(|a, b| {
471            b.score
472                .partial_cmp(&a.score)
473                .unwrap_or(std::cmp::Ordering::Equal)
474        });
475
476        // Track total before filtering
477        let total_files = scored_files.len();
478
479        // Filter to specific files if requested
480        let is_filtered = args.files.is_some();
481        if let Some(ref filter) = args.files {
482            scored_files.retain(|sf| filter.iter().any(|f| sf.file.path.contains(f)));
483        }
484
485        // Build output
486        let include_diffs = matches!(args.detail, DetailLevel::Standard);
487        Ok(format_diff_output(
488            &scored_files,
489            total_files,
490            is_filtered,
491            include_diffs,
492        ))
493    }
494}
495
496// Git log tool
497#[derive(Debug, Clone, Serialize, Deserialize)]
498pub struct GitLog;
499
500#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
501pub struct GitLogArgs {
502    #[serde(default)]
503    pub count: Option<usize>,
504    #[serde(default)]
505    pub from: Option<String>,
506    #[serde(default)]
507    pub to: Option<String>,
508}
509
510impl Tool for GitLog {
511    const NAME: &'static str = "git_log";
512    type Error = GitError;
513    type Args = GitLogArgs;
514    type Output = String;
515
516    async fn definition(&self, _: String) -> ToolDefinition {
517        ToolDefinition {
518            name: "git_log".to_string(),
519            description: "Get Git commit history".to_string(),
520            parameters: parameters_schema::<GitLogArgs>(),
521        }
522    }
523
524    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
525        let repo = get_current_repo().map_err(GitError::from)?;
526
527        if let Some(from) = args.from {
528            let to = args.to.unwrap_or_else(|| "HEAD".to_string());
529            let commits = repo
530                .get_commits_in_range(&from, &to)
531                .map_err(GitError::from)?;
532            return Ok(format_git_log_output(
533                &format!("Commits from {from} to {to}:"),
534                &commits,
535                true,
536            ));
537        }
538
539        if args.to.is_some() {
540            return Err(GitError::from(anyhow::anyhow!(
541                "git_log requires `from` when `to` is provided"
542            )));
543        }
544
545        let commits = repo
546            .get_recent_commits(args.count.unwrap_or(10))
547            .map_err(GitError::from)?;
548
549        Ok(format_git_log_output("Recent commits:", &commits, false))
550    }
551}
552
553fn format_git_log_output(
554    header: &str,
555    commits: &[RecentCommit],
556    include_contributors: bool,
557) -> String {
558    let mut output = String::new();
559    output.push_str(header);
560    output.push('\n');
561
562    for commit in commits {
563        let title = commit.message.lines().next().unwrap_or_default().trim();
564        output.push_str(&format!("{}: {} ({})\n", commit.hash, title, commit.author));
565    }
566
567    if include_contributors {
568        let contributors: BTreeSet<String> = commits
569            .iter()
570            .map(|commit| commit.author.trim())
571            .filter(|author| !author.is_empty() && !is_bot_author(author))
572            .map(ToOwned::to_owned)
573            .collect();
574
575        if !contributors.is_empty() {
576            output.push_str("\nContributors (excluding bots):\n");
577            for contributor in contributors {
578                output.push_str(&format!("- {contributor}\n"));
579            }
580        }
581    }
582
583    output
584}
585
586fn is_bot_author(author: &str) -> bool {
587    let normalized = author.trim().to_ascii_lowercase();
588
589    normalized.contains("[bot]")
590        || normalized.contains("dependabot")
591        || normalized.contains("renovate")
592        || normalized.contains("github-actions")
593        || normalized.ends_with(" bot")
594        || normalized.ends_with("-bot")
595        || normalized == "bot"
596}
597
598// Git repository info tool
599#[derive(Debug, Clone, Serialize, Deserialize)]
600pub struct GitRepoInfo;
601
602#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
603pub struct GitRepoInfoArgs {}
604
605impl Tool for GitRepoInfo {
606    const NAME: &'static str = "git_repo_info";
607    type Error = GitError;
608    type Args = GitRepoInfoArgs;
609    type Output = String;
610
611    async fn definition(&self, _: String) -> ToolDefinition {
612        ToolDefinition {
613            name: "git_repo_info".to_string(),
614            description: "Get general information about the Git repository".to_string(),
615            parameters: parameters_schema::<GitRepoInfoArgs>(),
616        }
617    }
618
619    async fn call(&self, _args: Self::Args) -> Result<Self::Output, Self::Error> {
620        let repo = get_current_repo().map_err(GitError::from)?;
621
622        let branch = repo.get_current_branch().map_err(GitError::from)?;
623        let remote_url = repo.get_remote_url().unwrap_or("None").to_string();
624
625        let mut output = String::new();
626        output.push_str("Repository Information:\n");
627        output.push_str(&format!("Current Branch: {branch}\n"));
628        output.push_str(&format!("Remote URL: {remote_url}\n"));
629        output.push_str(&format!(
630            "Repository Path: {}\n",
631            repo.repo_path().display()
632        ));
633
634        Ok(output)
635    }
636}
637
638// Git changed files tool
639#[derive(Debug, Clone, Serialize, Deserialize)]
640pub struct GitChangedFiles;
641
642#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
643pub struct GitChangedFilesArgs {
644    #[serde(default)]
645    pub from: Option<String>,
646    #[serde(default)]
647    pub to: Option<String>,
648}
649
650impl Tool for GitChangedFiles {
651    const NAME: &'static str = "git_changed_files";
652    type Error = GitError;
653    type Args = GitChangedFilesArgs;
654    type Output = String;
655
656    async fn definition(&self, _: String) -> ToolDefinition {
657        ToolDefinition {
658            name: "git_changed_files".to_string(),
659            description: "Get list of files that have changed between commits or branches"
660                .to_string(),
661            parameters: parameters_schema::<GitChangedFilesArgs>(),
662        }
663    }
664
665    async fn call(&self, args: Self::Args) -> Result<Self::Output, Self::Error> {
666        let repo = get_current_repo().map_err(GitError::from)?;
667
668        // Normalize empty strings to None (LLMs often send "" instead of null)
669        let from = args.from.filter(|s| !s.is_empty());
670        let mut to = args.to.filter(|s| !s.is_empty());
671
672        // Default to HEAD when the caller provides only a starting point.
673        if from.is_some() && to.is_none() {
674            to = Some("HEAD".to_string());
675        }
676
677        let files = match (from, to) {
678            (Some(from), Some(to)) => {
679                // When both from and to are provided, get files changed between commits/branches
680                let range_files = repo
681                    .get_commit_range_files(&from, &to)
682                    .map_err(GitError::from)?;
683                range_files.iter().map(|f| f.path.clone()).collect()
684            }
685            (None, Some(to)) => {
686                // When only to is provided, get files changed in that single commit
687                repo.get_file_paths_for_commit(&to)
688                    .map_err(GitError::from)?
689            }
690            (Some(_from), None) => {
691                // Invalid: from without to doesn't make sense for file listing
692                return Err(GitError(
693                    "Cannot specify 'from' without 'to' for file listing".to_string(),
694                ));
695            }
696            (None, None) => {
697                // When neither are provided, get staged files
698                let files_info = repo.extract_files_info(false).map_err(GitError::from)?;
699                files_info.file_paths
700            }
701        };
702
703        let mut output = String::new();
704        output.push_str("Changed files:\n");
705
706        for file in files {
707            output.push_str(&format!("  {file}\n"));
708        }
709
710        Ok(output)
711    }
712}