gitdataai/libs/fctool/src/git_tools/diff.rs
ZhenYi c7a8bc0458 refactor(fctool): extract tool modules into standalone fctool crate
Move git_tools, file_tools, and project_tools from libs/service into a
new libs/fctool crate with correct workspace dependencies. Fixes the
rev.len() >= 40 bug in all git tool resolve functions (OID check needs
exact 40-char hex, not just >= 40). Adds 4 new git blob tools
(blob_get, blob_exists, blob_content, blob_create). Fixes parameter
naming inconsistency in repos.rs and adds project_name to list_repos
output. Removes unused excel/pdf/ppt/word file tools.
2026-04-26 23:58:16 +08:00

210 lines
12 KiB
Rust

//! 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<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = 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::<Vec<_>>()
});
let domain = ctx.open_repo(project_name, repo_name).await?;
let resolve = |rev: &str| -> Result<git::commit::types::CommitOid, String> {
if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) {
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) => {
// Check if repo has any commits before attempting to diff
if domain.repo().head().is_err() {
return Err("No commits found in repository".into());
}
let head_meta = domain.commit_get_prefix("HEAD").map_err(|e| e.to_string())?;
// Bare repos have no working tree — use tree-to-tree diff instead
if domain.repo().is_bare() {
domain.diff_tree_to_tree(None, Some(&head_meta.oid), opts).map_err(|e| e.to_string())?
} else {
domain.diff_commit_to_workdir(&head_meta.oid, opts).map_err(|e| e.to_string())?
}
}
(Some(base), None) => {
if domain.repo().is_bare() {
domain.diff_tree_to_tree(Some(base), None, opts).map_err(|e| e.to_string())?
} else {
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());
}
};
use git::diff::types::DiffDeltaStatus;
let files: Vec<_> = result.deltas.iter().map(|d| {
let (path, is_binary) = if d.status == DiffDeltaStatus::Deleted {
(d.old_file.path.clone(), d.old_file.is_binary)
} else {
(d.new_file.path.clone(), d.new_file.is_binary)
};
serde_json::json!({ "path": path, "status": format!("{:?}", d.status), "is_binary": 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<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = 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 resolve = |rev: &str| -> Result<git::commit::types::CommitOid, String> {
if rev.len() == 40 && rev.chars().all(|c| c.is_ascii_hexdigit()) {
Ok(git::commit::types::CommitOid::new(rev))
} else {
domain.commit_get_prefix(rev).map_err(|e| e.to_string()).map(|m| m.oid)
}
};
let b = resolve(base).map_err(|e| e.to_string())?;
let h = resolve(head).map_err(|e| e.to_string())?;
let stats = 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<serde_json::Value, String> {
let p: serde_json::Map<String, serde_json::Value> = 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 && rev.chars().all(|c| c.is_ascii_hexdigit()) {
Ok(git::commit::types::CommitOid::new(&rev))
} else {
domain.commit_get_prefix(&rev).map_err(|e| e.to_string()).map(|m| m.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)
})
}),
);
}