//! Git tree and file tools. use super::ctx::GitToolCtx; use agent::{ToolDefinition, ToolHandler, ToolParam, ToolRegistry, ToolSchema}; use base64::Engine; use std::collections::HashMap; async fn git_file_content_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 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 }; let entry = domain.tree_entry_by_path_from_commit(&oid, path).map_err(|e| e.to_string())?; let blob_info = domain.blob_get(&entry.oid).map_err(|e| e.to_string())?; let content = domain.blob_content(&entry.oid).map_err(|e| e.to_string())?; let (display_content, is_binary) = if blob_info.is_binary { (base64::engine::general_purpose::STANDARD.encode(&content.content), true) } else { (String::from_utf8_lossy(&content.content).to_string(), false) }; Ok(serde_json::json!({ "path": path, "oid": entry.oid.to_string(), "size": blob_info.size, "content": display_content, "is_binary": is_binary })) } async fn git_tree_ls_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 dir_path = p.get("path").and_then(|v| v.as_str()).map(|s| s.to_string()); let rev = p.get("rev").and_then(|v| v.as_str()).map(|s| s.to_string()).unwrap_or_else(|| "HEAD".to_string()); let domain = ctx.open_repo(project_name, repo_name).await?; let commit_oid = if rev.len() >= 40 { git::commit::types::CommitOid::new(&rev) } else { domain.commit_get_prefix(&rev).map_err(|e| e.to_string())?.oid }; let entries = match dir_path { Some(ref dp) => { let entry = domain.tree_entry_by_path(&commit_oid, dp).map_err(|e| e.to_string())?; domain.tree_list(&entry.oid).map_err(|e| e.to_string())? } None => domain.tree_list(&commit_oid).map_err(|e| e.to_string())?, }; let result: Vec<_> = entries.iter().map(|e| { serde_json::json!({ "name": e.name, "oid": e.oid.to_string(), "kind": e.kind, "is_binary": e.is_binary }) }).collect(); Ok(serde_json::to_value(result).map_err(|e| e.to_string())?) } async fn git_file_history_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 limit = p.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize; let domain = ctx.open_repo(project_name, repo_name).await?; let commits = domain.commit_log(Some("HEAD"), 0, 500).map_err(|e| e.to_string())?; let result: Vec<_> = commits.iter() .filter(|c| domain.tree_entry_by_path(&c.tree_id, path).is_ok()) .take(limit) .map(|c| flatten_commit(c)) .collect(); Ok(serde_json::to_value(result).map_err(|e| e.to_string())?) } async fn git_blob_get_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(String::from).unwrap_or_else(|| "HEAD".to_string()); 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 }; let entry = domain.tree_entry_by_path_from_commit(&oid, path).map_err(|e| e.to_string())?; let blob_info = domain.blob_get(&entry.oid).map_err(|e| e.to_string())?; if blob_info.is_binary { return Err(format!("file '{}' is binary, cannot return as text", path)); } let content = domain.blob_content(&entry.oid).map_err(|e| e.to_string())?; let text = String::from_utf8_lossy(&content.content).to_string(); Ok(serde_json::json!({ "path": path, "oid": entry.oid.to_string(), "size": blob_info.size, "content": text, })) } fn flatten_commit(c: &git::commit::types::CommitMeta) -> serde_json::Value { use chrono::TimeZone; let ts = c.author.time_secs + (c.author.offset_minutes as i64 * 60); let author_time = chrono::Utc.timestamp_opt(ts, 0).single() .map(|dt| dt.to_rfc3339()).unwrap_or_else(|| format!("{}", c.author.time_secs)); let oid = c.oid.to_string(); serde_json::json!({ "oid": oid.clone(), "short_oid": oid.get(..7).unwrap_or(&oid).to_string(), "message": c.message, "summary": c.summary, "author_name": c.author.name, "author_email": c.author.email, "author_time": author_time, "committer_name": c.committer.name, "committer_email": c.committer.email, "parent_oids": c.parent_ids.iter().map(|p| p.to_string()).collect::>(), "tree_oid": c.tree_id.to_string() }) } pub fn register_git_tools(registry: &mut ToolRegistry) { // git_file_content 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 within the repository".into()), required: true, properties: None, items: None }), ("rev".into(), ToolParam { name: "rev".into(), param_type: "string".into(), description: Some("Revision to read file from (default: HEAD)".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_file_content").description("Read the full content of a file at a given revision. Handles both text and binary files.").parameters(schema), ToolHandler::new(|ctx, args| { let gctx = super::ctx::GitToolCtx::new(ctx); Box::pin(async move { git_file_content_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError) }) }), ); // git_tree_ls 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("Directory path to list (root if omitted)".into()), required: false, properties: None, items: None }), ("rev".into(), ToolParam { name: "rev".into(), param_type: "string".into(), description: Some("Revision to list tree from (default: HEAD)".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()]) }; registry.register( ToolDefinition::new("git_tree_ls").description("List the contents of a directory (tree) at a given revision, showing files and subdirectories.").parameters(schema), ToolHandler::new(|ctx, args| { let gctx = super::ctx::GitToolCtx::new(ctx); Box::pin(async move { git_tree_ls_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError) }) }), ); // git_file_history 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 trace history for".into()), required: true, properties: None, items: None }), ("limit".into(), ToolParam { name: "limit".into(), param_type: "integer".into(), description: Some("Maximum number of commits to return (default: 20)".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_file_history").description("Show the commit history for a specific file, listing all commits that modified it.").parameters(schema), ToolHandler::new(|ctx, args| { let gctx = super::ctx::GitToolCtx::new(ctx); Box::pin(async move { git_file_history_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError) }) }), ); // git_blob_get 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 within the repository".into()), required: true, properties: None, items: None }), ("rev".into(), ToolParam { name: "rev".into(), param_type: "string".into(), description: Some("Revision to read file from (default: HEAD)".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_blob_get").description("Retrieve the raw content of a single file (blob) at a given revision. Returns error if the file is binary.").parameters(schema), ToolHandler::new(|ctx, args| { let gctx = super::ctx::GitToolCtx::new(ctx); Box::pin(async move { git_blob_get_exec(gctx, args).await.map_err(agent::ToolError::ExecutionError) }) }), ); }