use ai::error::{AiError, AiResult}; use ai::tool::tools::FunctionCall; use async_trait::async_trait; use git::rpc::proto as p; use git::rpc::proto::diff_service_client::DiffServiceClient; use serde_json::{json, Value}; use super::helpers::{arg_str, git_ctx, require_repo_member, rpc_err}; use crate::agent::run::AppAgentContext; pub struct GitDiffStatsTool; impl GitDiffStatsTool { pub fn new() -> Self { Self } } impl Default for GitDiffStatsTool { fn default() -> Self { Self::new() } } #[async_trait] impl FunctionCall for GitDiffStatsTool { type Context = AppAgentContext; fn name(&self) -> &'static str { "git_diff_stats" } fn description(&self) -> &'static str { "Get diff statistics between two commits: files changed, insertions, deletions." } fn schema(&self) -> Value { json!({ "type": "object", "properties": { "workspace": { "type": "string", "description": "Workspace name" }, "repo": { "type": "string", "description": "Repository name" }, "old_oid": { "type": "string", "description": "Base commit OID" }, "new_oid": { "type": "string", "description": "Target commit OID" } }, "required": ["workspace", "repo", "old_oid", "new_oid"] }) } async fn call(&self, ctx: &mut AppAgentContext, args: Value) -> AiResult { let git = git_ctx(ctx)?; let workspace = arg_str(&args, "workspace")?; let repo_name = arg_str(&args, "repo")?; let old_oid = arg_str(&args, "old_oid")?; let new_oid = arg_str(&args, "new_oid")?; let repo = require_repo_member(git, ctx.user_id, workspace, repo_name).await?; let mut client = DiffServiceClient::new(git.channel.clone()); let resp = client .diff_stats(p::DiffStatsRequest { repo_id: repo.id.to_string(), old_oid: Some(p::ObjectId { value: old_oid.to_string() }), new_oid: Some(p::ObjectId { value: new_oid.to_string() }), options: None, }) .await .map_err(rpc_err)? .into_inner(); let result = resp.result.ok_or_else(|| AiError::Response("no diff result".to_string()))?; let stats = result.stats.ok_or_else(|| AiError::Response("no stats".to_string()))?; let files: Vec = result.deltas.iter().map(|d| { let status = match p::DiffDeltaStatus::try_from(d.status) { Ok(p::DiffDeltaStatus::Added) => "added", Ok(p::DiffDeltaStatus::Deleted) => "deleted", Ok(p::DiffDeltaStatus::Modified) => "modified", Ok(p::DiffDeltaStatus::Renamed) => "renamed", _ => "unknown", }; let old_path = d.old_file.as_ref().and_then(|f| f.path.as_deref()).unwrap_or(""); let new_path = d.new_file.as_ref().and_then(|f| f.path.as_deref()).unwrap_or(""); json!({ "status": status, "old_path": old_path, "new_path": new_path, "hunks": d.hunks.len() }) }).collect(); Ok(json!({ "files_changed": stats.files_changed, "insertions": stats.insertions, "deletions": stats.deletions, "files": files, })) } } pub struct GitDiffPatchTool; impl GitDiffPatchTool { pub fn new() -> Self { Self } } impl Default for GitDiffPatchTool { fn default() -> Self { Self::new() } } #[async_trait] impl FunctionCall for GitDiffPatchTool { type Context = AppAgentContext; fn name(&self) -> &'static str { "git_diff_patch" } fn description(&self) -> &'static str { "Get the full diff (unified format) between two commits, including line-level changes." } fn schema(&self) -> Value { json!({ "type": "object", "properties": { "workspace": { "type": "string", "description": "Workspace name" }, "repo": { "type": "string", "description": "Repository name" }, "old_oid": { "type": "string", "description": "Base commit OID" }, "new_oid": { "type": "string", "description": "Target commit OID" }, "context_lines": { "type": "integer", "description": "Lines of context around changes (default 3)" } }, "required": ["workspace", "repo", "old_oid", "new_oid"] }) } async fn call(&self, ctx: &mut AppAgentContext, args: Value) -> AiResult { let git = git_ctx(ctx)?; let workspace = arg_str(&args, "workspace")?; let repo_name = arg_str(&args, "repo")?; let old_oid = arg_str(&args, "old_oid")?; let new_oid = arg_str(&args, "new_oid")?; let ctx_lines = args.get("context_lines").and_then(|v| v.as_u64()).unwrap_or(3) as u32; let repo = require_repo_member(git, ctx.user_id, workspace, repo_name).await?; let mut client = DiffServiceClient::new(git.channel.clone()); let resp = client .diff_patch(p::DiffPatchRequest { repo_id: repo.id.to_string(), old_oid: Some(p::ObjectId { value: old_oid.to_string() }), new_oid: Some(p::ObjectId { value: new_oid.to_string() }), options: Some(p::DiffOptions { context_lines: ctx_lines, ..Default::default() }), }) .await .map_err(rpc_err)? .into_inner(); let result = resp.result.ok_or_else(|| AiError::Response("no diff result".to_string()))?; let stats = result.stats.ok_or_else(|| AiError::Response("no stats".to_string()))?; let mut patch_text = String::new(); for delta in &result.deltas { let status = match p::DiffDeltaStatus::try_from(delta.status) { Ok(p::DiffDeltaStatus::Added) => "added", Ok(p::DiffDeltaStatus::Deleted) => "deleted", Ok(p::DiffDeltaStatus::Modified) => "modified", Ok(p::DiffDeltaStatus::Renamed) => "renamed", _ => "unknown", }; let old = delta.old_file.as_ref().and_then(|f| f.path.as_deref()).unwrap_or("unknown"); let new = delta.new_file.as_ref().and_then(|f| f.path.as_deref()).unwrap_or("unknown"); patch_text.push_str(&format!("--- {}\n+++ {}\n@@ status: {status} @@\n", old, new)); for hunk in &delta.hunks { patch_text.push_str(&hunk.header); patch_text.push('\n'); for line in &delta.lines { patch_text.push_str(&format!("{}{}\n", line.origin, line.content)); } patch_text.push('\n'); } } let truncated = patch_text.len() > 32_000; if truncated { patch_text = format!("{}...(truncated)", &patch_text[..32_000]); } Ok(json!({ "files_changed": stats.files_changed, "insertions": stats.insertions, "deletions": stats.deletions, "patch": patch_text, "truncated": truncated, })) } }