//! Git diff and blame tools. use super::ctx::GitToolCtx; use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema}; use std::collections::HashMap; async fn git_diff_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result { let p: serde_json::Map = serde_json::from_value(args).map_err(|e| e.to_string())?; let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?; let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?; let base = p.get("base").and_then(|v| v.as_str()).map(|s| s.to_string()); let head = p.get("head").and_then(|v| v.as_str()).map(|s| s.to_string()); let paths = p.get("paths").and_then(|v| v.as_array()).map(|a| { a.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect::>() }); let domain = ctx.open_repo(project_name, repo_name).await?; let resolve = |rev: &str| -> Result { if rev.len() >= 40 { Ok(git::commit::types::CommitOid::new(rev)) } else { domain.commit_get_prefix(rev).map_err(|e| e.to_string()).map(|m| m.oid) } }; let base_oid = match &base { Some(b) => Some(resolve(b)?), None => None, }; let head_oid = match &head { Some(h) => Some(resolve(h)?), None => None, }; let opts = paths.map(|ps| { let mut o = git::diff::types::DiffOptions::new(); for p in ps { o = o.pathspec(&p); } Some(o) }).flatten(); let result = match (&base_oid, &head_oid) { (None, None) => { let head_meta = domain.commit_get_prefix("HEAD").map_err(|e| e.to_string())?; domain.diff_commit_to_workdir(&head_meta.oid, opts).map_err(|e| e.to_string())? } (Some(base), None) => { domain.diff_commit_to_workdir(base, opts).map_err(|e| e.to_string())? } (Some(base), Some(head_oid_val)) => { domain.diff_tree_to_tree(Some(base), Some(head_oid_val), opts).map_err(|e| e.to_string())? } (None, Some(_)) => { return Err("base revision required when head is specified".into()); } }; let files: Vec<_> = result.deltas.iter().map(|d| { serde_json::json!({ "path": d.new_file.path, "status": format!("{:?}", d.status), "is_binary": d.new_file.is_binary }) }).collect(); Ok(serde_json::json!({ "stats": { "files_changed": result.stats.files_changed, "insertions": result.stats.insertions, "deletions": result.stats.deletions }, "files": files })) } async fn git_diff_stats_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result { let p: serde_json::Map = serde_json::from_value(args).map_err(|e| e.to_string())?; let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?; let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?; let base = p.get("base").and_then(|v| v.as_str()).ok_or("missing base")?; let head = p.get("head").and_then(|v| v.as_str()).ok_or("missing head")?; let domain = ctx.open_repo(project_name, repo_name).await?; let stats = if base.len() >= 40 || head.len() >= 40 { domain.diff_stats(&git::commit::types::CommitOid::new(base), &git::commit::types::CommitOid::new(head)) .map_err(|e| e.to_string())? } else { let b = domain.commit_get_prefix(base).map_err(|e| e.to_string())?.oid; let h = domain.commit_get_prefix(head).map_err(|e| e.to_string())?.oid; domain.diff_stats(&b, &h).map_err(|e| e.to_string())? }; Ok(serde_json::json!({ "files_changed": stats.files_changed, "insertions": stats.insertions, "deletions": stats.deletions })) } async fn git_blame_exec(ctx: GitToolCtx, args: serde_json::Value) -> Result { let p: serde_json::Map = serde_json::from_value(args).map_err(|e| e.to_string())?; let project_name = p.get("project_name").and_then(|v| v.as_str()).ok_or("missing project_name")?; let repo_name = p.get("repo_name").and_then(|v| v.as_str()).ok_or("missing repo_name")?; let path = p.get("path").and_then(|v| v.as_str()).ok_or("missing path")?; let rev = p.get("rev").and_then(|v| v.as_str()).map(|s| s.to_string()).unwrap_or_else(|| "HEAD".to_string()); let from_line = p.get("from_line").and_then(|v| v.as_u64().map(|n| n as u32)); let to_line = p.get("to_line").and_then(|v| v.as_u64().map(|n| n as u32)); let domain = ctx.open_repo(project_name, repo_name).await?; let oid = if rev.len() >= 40 { git::commit::types::CommitOid::new(&rev) } else { domain.commit_get_prefix(&rev).map_err(|e| e.to_string())?.oid }; use git::blame::ops::BlameOptions; let mut bopts = BlameOptions::new(); if let Some(fl) = from_line { bopts = bopts.min_line(fl as usize); } if let Some(tl) = to_line { bopts = bopts.max_line(tl as usize); } let hunks = domain.blame_file(&oid, path, Some(bopts)).map_err(|e| e.to_string())?; let result: Vec<_> = hunks.iter().map(|h| { let oid = h.commit_oid.to_string(); serde_json::json!({ "commit_oid": oid.clone(), "short_oid": oid.get(..7).unwrap_or(&oid).to_string(), "final_start_line": h.final_start_line, "final_lines": h.final_lines, "orig_start_line": h.orig_start_line, "orig_path": h.orig_path, "boundary": h.boundary }) }).collect(); Ok(serde_json::to_value(result).map_err(|e| e.to_string())?) } pub fn register_git_tools(registry: &mut ToolRegistry) { // git_diff let p = HashMap::from([ ("project_name".into(), ToolParam { name: "project_name".into(), param_type: "string".into(), description: Some("Project name (slug)".into()), required: true, properties: None, items: None }), ("repo_name".into(), ToolParam { name: "repo_name".into(), param_type: "string".into(), description: Some("Repository name".into()), required: true, properties: None, items: None }), ("base".into(), ToolParam { name: "base".into(), param_type: "string".into(), description: Some("Base revision (commit hash or branch). Defaults to HEAD.".into()), required: false, properties: None, items: None }), ("head".into(), ToolParam { name: "head".into(), param_type: "string".into(), description: Some("Head revision to diff against base. Requires base to be set.".into()), required: false, properties: None, items: None }), ("paths".into(), ToolParam { name: "paths".into(), param_type: "array".into(), description: Some("Filter diff to specific file paths".into()), required: false, properties: None, items: Some(Box::new(ToolParam { name: "".into(), param_type: "string".into(), description: None, required: false, properties: None, items: None })) }), ]); let schema = ToolSchema { schema_type: "object".into(), properties: Some(p), required: Some(vec!["project_name".into(), "repo_name".into()]) }; registry.register( ToolDefinition::new("git_diff").description("Show file changes between two commits, or between a commit and the working directory.").parameters(schema), ToolHandler::new(|ctx, args| { let gctx = super::ctx::GitToolCtx::new(ctx); Box::pin(async move { git_diff_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError) }) }), ); // git_diff_stats let p = HashMap::from([ ("project_name".into(), ToolParam { name: "project_name".into(), param_type: "string".into(), description: Some("Project name (slug)".into()), required: true, properties: None, items: None }), ("repo_name".into(), ToolParam { name: "repo_name".into(), param_type: "string".into(), description: Some("Repository name".into()), required: true, properties: None, items: None }), ("base".into(), ToolParam { name: "base".into(), param_type: "string".into(), description: Some("Base revision".into()), required: true, properties: None, items: None }), ("head".into(), ToolParam { name: "head".into(), param_type: "string".into(), description: Some("Head revision".into()), required: true, properties: None, items: None }), ]); let schema = ToolSchema { schema_type: "object".into(), properties: Some(p), required: Some(vec!["project_name".into(), "repo_name".into(), "base".into(), "head".into()]) }; registry.register( ToolDefinition::new("git_diff_stats").description("Get aggregated diff statistics (files changed, insertions, deletions) between two revisions.").parameters(schema), ToolHandler::new(|ctx, args| { let gctx = super::ctx::GitToolCtx::new(ctx); Box::pin(async move { git_diff_stats_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError) }) }), ); // git_blame let p = HashMap::from([ ("project_name".into(), ToolParam { name: "project_name".into(), param_type: "string".into(), description: Some("Project name (slug)".into()), required: true, properties: None, items: None }), ("repo_name".into(), ToolParam { name: "repo_name".into(), param_type: "string".into(), description: Some("Repository name".into()), required: true, properties: None, items: None }), ("path".into(), ToolParam { name: "path".into(), param_type: "string".into(), description: Some("File path to blame".into()), required: true, properties: None, items: None }), ("rev".into(), ToolParam { name: "rev".into(), param_type: "string".into(), description: Some("Revision to blame from (default: HEAD)".into()), required: false, properties: None, items: None }), ("from_line".into(), ToolParam { name: "from_line".into(), param_type: "integer".into(), description: Some("Start line number for blame range".into()), required: false, properties: None, items: None }), ("to_line".into(), ToolParam { name: "to_line".into(), param_type: "integer".into(), description: Some("End line number for blame range".into()), required: false, properties: None, items: None }), ]); let schema = ToolSchema { schema_type: "object".into(), properties: Some(p), required: Some(vec!["project_name".into(), "repo_name".into(), "path".into()]) }; registry.register( ToolDefinition::new("git_blame").description("Show what revision and author last modified each line of a file (git blame).").parameters(schema), ToolHandler::new(|ctx, args| { let gctx = super::ctx::GitToolCtx::new(ctx); Box::pin(async move { git_blame_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError) }) }), ); }