feat(backend): add git_tools service module

Add git_tools module with Git operations: branch, commit, diff, tag, tree, types, ctx
This commit is contained in:
ZhenYi 2026-04-18 19:08:06 +08:00
parent 821b0e998d
commit 5579e6c58e
8 changed files with 1154 additions and 0 deletions

View File

@ -0,0 +1,93 @@
//! Git branch tools.
use super::ctx::GitToolCtx;
use agent::ToolRegistry;
async fn git_branch_list_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 remote_only = p.get("remote_only").and_then(|v| v.as_bool()).unwrap_or(false);
let domain = ctx.open_repo(project_name, repo_name).await?;
let branches = domain.branch_list(remote_only).map_err(|e| e.to_string())?;
let result: Vec<_> = branches.iter().map(|b| {
let oid = b.oid.to_string();
serde_json::json!({
"name": b.name, "oid": oid.clone(), "short_oid": oid.get(..7).unwrap_or(&oid).to_string(),
"is_head": b.is_head, "is_remote": b.is_remote, "is_current": b.is_current,
"upstream": b.upstream.clone()
})
}).collect();
Ok(serde_json::to_value(result).map_err(|e| e.to_string())?)
}
async fn git_branch_info_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 name = p.get("name").and_then(|v| v.as_str()).ok_or("missing name")?;
let domain = ctx.open_repo(project_name, repo_name).await?;
let info = domain.branch_get(name).map_err(|e| e.to_string())?;
let ahead_behind = if let Some(ref upstream) = info.upstream {
let (ahead, behind) = domain.branch_ahead_behind(name, upstream).unwrap_or((0, 0));
Some(serde_json::json!({ "ahead": ahead, "behind": behind }))
} else { None };
Ok(serde_json::json!({
"branch": { "name": info.name, "oid": info.oid.to_string(), "is_head": info.is_head,
"is_remote": info.is_remote, "is_current": info.is_current, "upstream": info.upstream },
"ahead_behind": ahead_behind
}))
}
async fn git_branches_merged_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 branch = p.get("branch").and_then(|v| v.as_str()).ok_or("missing branch")?;
let into = p.get("into").and_then(|v| v.as_str()).map(|s| s.to_string()).unwrap_or_else(|| "main".to_string());
let domain = ctx.open_repo(project_name, repo_name).await?;
let is_merged = domain.branch_is_merged(branch, &into).map_err(|e| e.to_string())?;
let merge_base = domain.merge_base(&git::commit::types::CommitOid::new(branch), &git::commit::types::CommitOid::new(&into))
.map(|oid| oid.to_string()).ok();
Ok(serde_json::json!({ "branch": branch, "into": into, "is_merged": is_merged, "merge_base": merge_base }))
}
async fn git_branch_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 local = p.get("local").and_then(|v| v.as_str()).ok_or("missing local")?;
let remote = p.get("remote").and_then(|v| v.as_str()).unwrap_or(local).to_string();
let domain = ctx.open_repo(project_name, repo_name).await?;
let diff = domain.branch_diff(local, &remote).map_err(|e| e.to_string())?;
Ok(serde_json::json!({ "ahead": diff.ahead, "behind": diff.behind, "diverged": diff.diverged }))
}
macro_rules! register_fn {
($registry:expr, $name:expr, $exec:expr) => {
let handler_fn = move |ctx: agent::ToolContext, args: serde_json::Value| async move {
let gctx = super::ctx::GitToolCtx::new(ctx);
$exec(gctx, args)
.await
.map_err(agent::ToolError::ExecutionError)
};
$registry.register_fn($name, handler_fn);
};
}
pub fn register_git_tools(registry: &mut ToolRegistry) {
register_fn!(registry, "git_branch_list", git_branch_list_exec);
register_fn!(registry, "git_branch_info", git_branch_info_exec);
register_fn!(registry, "git_branches_merged", git_branches_merged_exec);
register_fn!(registry, "git_branch_diff", git_branch_diff_exec);
}

View File

@ -0,0 +1,230 @@
//! Git commit-related tools.
use super::ctx::GitToolCtx;
use agent::ToolRegistry;
use chrono::TimeZone;
// --- Execution functions for each tool ---
async fn git_log_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 rev = p.get("rev").and_then(|v| v.as_str()).map(|s| s.to_string());
let limit = p.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
let skip = p.get("skip").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let domain = ctx.open_repo(project_name, repo_name).await?;
let commits = domain.commit_log(rev.as_deref(), skip, limit)
.map_err(|e| e.to_string())?;
// Flatten to simple JSON
let result: Vec<_> = commits.iter().map(|c| {
use chrono::TimeZone;
let ts = c.author.time_secs + (c.author.offset_minutes as i64 * 60);
let time_str = 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();
let short_oid = oid.get(..7).unwrap_or(&oid).to_string();
serde_json::json!({
"oid": oid,
"short_oid": short_oid,
"message": c.message,
"summary": c.summary,
"author_name": c.author.name,
"author_email": c.author.email,
"author_time": time_str,
"committer_name": c.committer.name,
"committer_email": c.committer.email,
"parent_oids": c.parent_ids.iter().map(|p| p.to_string()).collect::<Vec<_>>(),
"tree_oid": c.tree_id.to_string()
})
}).collect();
Ok(serde_json::to_value(result).map_err(|e| e.to_string())?)
}
async fn git_show_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 rev = p.get("rev").and_then(|v| v.as_str()).ok_or("missing rev")?;
let domain = ctx.open_repo(project_name, repo_name).await?;
let meta = if rev.len() >= 40 {
domain.commit_get(&git::commit::types::CommitOid::new(rev)).map_err(|e| e.to_string())?
} else {
domain.commit_get_prefix(rev).map_err(|e| e.to_string())?
};
let refs = domain.commit_refs(&meta.oid).map_err(|e| e.to_string())?;
use chrono::TimeZone;
let ts = meta.author.time_secs + (meta.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!("{}", meta.author.time_secs));
let oid = meta.oid.to_string();
let short_oid = oid.get(..7).unwrap_or(&oid).to_string();
Ok(serde_json::json!({
"commit": {
"oid": oid, "short_oid": short_oid, "message": meta.message, "summary": meta.summary,
"author_name": meta.author.name, "author_email": meta.author.email, "author_time": author_time,
"committer_name": meta.committer.name, "committer_email": meta.committer.email,
"parent_oids": meta.parent_ids.iter().map(|p| p.to_string()).collect::<Vec<_>>(),
"tree_oid": meta.tree_id.to_string()
},
"refs": refs.into_iter().map(|r| serde_json::json!({ "name": r.name, "is_tag": r.is_tag })).collect::<Vec<_>>()
}))
}
async fn git_search_commits_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 query = p.get("query").and_then(|v| v.as_str()).ok_or("missing query")?;
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, 100).map_err(|e| e.to_string())?;
let q = query.to_lowercase();
let result: Vec<_> = commits.iter()
.filter(|c| c.message.to_lowercase().contains(&q))
.take(limit)
.map(|c| flatten_commit(c))
.collect();
Ok(serde_json::to_value(result).map_err(|e| e.to_string())?)
}
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::<Vec<_>>(),
"tree_oid": c.tree_id.to_string()
})
}
async fn git_commit_info_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 rev = p.get("rev").and_then(|v| v.as_str()).ok_or("missing rev")?;
let domain = ctx.open_repo(project_name, repo_name).await?;
let meta = if rev.len() >= 40 {
domain.commit_get(&git::commit::types::CommitOid::new(rev)).map_err(|e| e.to_string())?
} else {
domain.commit_get_prefix(rev).map_err(|e| e.to_string())?
};
Ok(flatten_commit(&meta))
}
async fn git_graph_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 rev = p.get("rev").and_then(|v| v.as_str()).map(|s| s.to_string());
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(rev.as_deref(), 0, limit).map_err(|e| e.to_string())?;
let mut col_map: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
let lines: Vec<_> = commits.iter().map(|m| {
let lane_index = *col_map.get(m.oid.as_str()).unwrap_or(&0);
let oid = m.oid.to_string();
let refs = match domain.commit_refs(&m.oid) {
Ok(refs) => refs.iter().map(|r| {
if r.is_tag { format!("tag: {}", r.name.trim_start_matches("refs/tags/")) }
else if r.is_remote { r.name.trim_start_matches("refs/remotes/").to_string() }
else { r.name.trim_start_matches("refs/heads/").to_string() }
}).collect::<Vec<_>>().join(", "),
Err(_) => String::new(),
};
for (i, p) in m.parent_ids.iter().enumerate() {
if i == 0 { col_map.insert(p.to_string(), lane_index); } else { col_map.remove(p.as_str()); }
}
let ts = m.author.time_secs + (m.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!("{}", m.author.time_secs));
serde_json::json!({
"oid": oid.clone(),
"short_oid": oid.get(..7).unwrap_or(&oid).to_string(),
"refs": refs,
"short_message": m.summary,
"lane_index": lane_index,
"author_name": m.author.name,
"author_email": m.author.email,
"author_time": author_time,
"parent_oids": m.parent_ids.iter().map(|p| p.to_string()).collect::<Vec<_>>()
})
}).collect();
Ok(serde_json::to_value(lines).map_err(|e| e.to_string())?)
}
async fn git_reflog_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 ref_name = p.get("ref_name").and_then(|v| v.as_str()).map(|s| s.to_string());
let limit = p.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize;
let domain = ctx.open_repo(project_name, repo_name).await?;
let entries = domain.reflog_entries(ref_name.as_deref()).map_err(|e| e.to_string())?;
let result: Vec<_> = entries.iter()
.take(limit)
.map(|e| {
let ts = e.time_secs;
let time_str = chrono::Utc.timestamp_opt(ts, 0).single()
.map(|dt| dt.to_rfc3339()).unwrap_or_else(|| format!("{}", ts));
serde_json::json!({
"oid_new": e.oid_new.to_string(), "oid_old": e.oid_old.to_string(),
"committer_name": e.committer_name, "committer_email": e.committer_email,
"time": time_str, "message": e.message, "ref_name": e.ref_name
})
})
.collect();
Ok(serde_json::to_value(result).map_err(|e| e.to_string())?)
}
// --- Registration macro ---
macro_rules! register_fn {
($registry:expr, $name:expr, $exec:expr) => {
let handler_fn = move |ctx: agent::ToolContext, args: serde_json::Value| async move {
let gctx = super::ctx::GitToolCtx::new(ctx);
$exec(gctx, args)
.await
.map_err(agent::ToolError::ExecutionError)
};
$registry.register_fn($name, handler_fn);
};
}
pub fn register_git_tools(registry: &mut ToolRegistry) {
register_fn!(registry, "git_log", git_log_exec);
register_fn!(registry, "git_show", git_show_exec);
register_fn!(registry, "git_search_commits", git_search_commits_exec);
register_fn!(registry, "git_commit_info", git_commit_info_exec);
register_fn!(registry, "git_graph", git_graph_exec);
register_fn!(registry, "git_reflog", git_reflog_exec);
}

View File

@ -0,0 +1,53 @@
//! Context wrapper for git tool handlers.
//!
//! Provides `GitToolCtx` which wraps `ToolContext` and adds git-domain operations.
use agent::ToolContext;
use git::GitDomain;
use models::projects::project;
use models::repos::repo;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
/// Wrapper around `ToolContext` providing git-domain operations for tool handlers.
#[derive(Clone)]
pub struct GitToolCtx {
pub ctx: ToolContext,
}
impl GitToolCtx {
pub fn new(ctx: ToolContext) -> Self {
Self { ctx }
}
/// Opens a git repository by project name and repo name.
pub async fn open_repo(&self, project_name: &str, repo_name: &str) -> Result<GitDomain, String> {
let db = self.ctx.db();
resolve_project_and_repo(db, project_name, repo_name)
.await
.and_then(|(_, path)| GitDomain::open(&path).map_err(|e| e.to_string()))
}
}
/// Free helper to resolve project_id + storage_path from names. Used by registry.
async fn resolve_project_and_repo(
db: &db::database::AppDatabase,
project_name: &str,
repo_name: &str,
) -> Result<(uuid::Uuid, String), String> {
let project = project::Entity::find()
.filter(project::Column::Name.eq(project_name))
.one(db)
.await
.map_err(|e| format!("DB error looking up project '{}': {}", project_name, e))?
.ok_or_else(|| format!("project '{}' not found", project_name))?;
let repo_model = repo::Entity::find()
.filter(repo::Column::Project.eq(project.id))
.filter(repo::Column::RepoName.eq(repo_name))
.one(db)
.await
.map_err(|e| format!("DB error looking up repo '{}/{}': {}", project_name, repo_name, e))?
.ok_or_else(|| format!("repo '{}/{}' not found", project_name, repo_name))?;
Ok((project.id, repo_model.storage_path))
}

View File

@ -0,0 +1,147 @@
//! Git diff and blame tools.
use super::ctx::GitToolCtx;
use agent::ToolRegistry;
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 {
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<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 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<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 {
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())?)
}
macro_rules! register_fn {
($registry:expr, $name:expr, $exec:expr) => {
let handler_fn = move |ctx: agent::ToolContext, args: serde_json::Value| async move {
let gctx = super::ctx::GitToolCtx::new(ctx);
$exec(gctx, args)
.await
.map_err(agent::ToolError::ExecutionError)
};
$registry.register_fn($name, handler_fn);
};
}
pub fn register_git_tools(registry: &mut ToolRegistry) {
register_fn!(registry, "git_diff", git_diff_exec);
register_fn!(registry, "git_diff_stats", git_diff_stats_exec);
register_fn!(registry, "git_blame", git_blame_exec);
}

View File

@ -0,0 +1,21 @@
//! Git tools for AI agent function calling.
//!
//! Each module defines async exec functions + a `register_git_tools()` call.
//! All tools take `project_name` + `repo_name` as required params.
pub mod branch;
pub mod commit;
pub mod ctx;
pub mod diff;
pub mod tag;
pub mod tree;
pub mod types;
/// Batch-register all git tools into a ToolRegistry.
pub fn register_all(registry: &mut agent::ToolRegistry) {
commit::register_git_tools(registry);
branch::register_git_tools(registry);
diff::register_git_tools(registry);
tree::register_git_tools(registry);
tag::register_git_tools(registry);
}

View File

@ -0,0 +1,73 @@
//! Git tag tools.
use super::ctx::GitToolCtx;
use agent::ToolRegistry;
async fn git_tag_list_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 pattern = p.get("pattern").and_then(|v| v.as_str()).map(|s| s.to_string());
let domain = ctx.open_repo(project_name, repo_name).await?;
let all_tags = domain.tag_list().map_err(|e| e.to_string())?;
let result: Vec<_> = match pattern {
Some(ref pat) => {
let pat_lower = pat.to_lowercase();
let has_wildcard = pat.contains('*');
all_tags.iter()
.filter(|t| {
let n = t.name.to_lowercase();
if has_wildcard { n.contains(&pat_lower.replace('*', "")) }
else { n.contains(&pat_lower) }
})
.map(|t| tag_to_json(t))
.collect()
}
None => all_tags.into_iter().map(|t| tag_to_json(&t)).collect(),
};
Ok(serde_json::to_value(result).map_err(|e| e.to_string())?)
}
fn tag_to_json(t: &git::tags::types::TagInfo) -> serde_json::Value {
serde_json::json!({
"name": t.name,
"oid": t.oid.to_string(),
"target": t.target.to_string(),
"is_annotated": t.is_annotated,
"message": t.message.clone(),
"tagger_name": t.tagger.clone(),
"tagger_email": t.tagger_email.clone()
})
}
async fn git_tag_info_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 name = p.get("name").and_then(|v| v.as_str()).ok_or("missing name")?;
let domain = ctx.open_repo(project_name, repo_name).await?;
let info = domain.tag_get(name).map_err(|e| e.to_string())?;
Ok(tag_to_json(&info))
}
macro_rules! register_fn {
($registry:expr, $name:expr, $exec:expr) => {
let handler_fn = move |ctx: agent::ToolContext, args: serde_json::Value| async move {
let gctx = super::ctx::GitToolCtx::new(ctx);
$exec(gctx, args)
.await
.map_err(agent::ToolError::ExecutionError)
};
$registry.register_fn($name, handler_fn);
};
}
pub fn register_git_tools(registry: &mut ToolRegistry) {
register_fn!(registry, "git_tag_list", git_tag_list_exec);
register_fn!(registry, "git_tag_info", git_tag_info_exec);
}

View File

@ -0,0 +1,126 @@
//! Git tree and file tools.
use super::ctx::GitToolCtx;
use agent::ToolRegistry;
use base64::Engine;
async fn git_file_content_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 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<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 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<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 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())?)
}
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::<Vec<_>>(),
"tree_oid": c.tree_id.to_string()
})
}
macro_rules! register_fn {
($registry:expr, $name:expr, $exec:expr) => {
let handler_fn = move |ctx: agent::ToolContext, args: serde_json::Value| async move {
let gctx = super::ctx::GitToolCtx::new(ctx);
$exec(gctx, args)
.await
.map_err(agent::ToolError::ExecutionError)
};
$registry.register_fn($name, handler_fn);
};
}
pub fn register_git_tools(registry: &mut ToolRegistry) {
register_fn!(registry, "git_file_content", git_file_content_exec);
register_fn!(registry, "git_tree_ls", git_tree_ls_exec);
register_fn!(registry, "git_file_history", git_file_history_exec);
}

View File

@ -0,0 +1,411 @@
//! Flat, JSON-friendly output types for git tools.
//!
//! These types convert from internal `git` crate types to clean, flat structures
//! suitable for JSON serialization (function call responses).
use base64::Engine;
use chrono::TimeZone;
use git::commit::types::{CommitMeta, CommitReflogEntry};
use git::diff::types::{DiffDelta, DiffStats};
use git::tree::types::TreeEntry;
use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
// Parameter structs used by all tool modules
// ---------------------------------------------------------------------------
#[derive(serde::Deserialize)]
pub struct RevQuery { pub rev: String }
#[derive(serde::Deserialize)]
pub struct SearchCommits { pub query: String, #[serde(default = "dl")] pub limit: u32 }
fn dl() -> u32 { 20 }
#[derive(serde::Deserialize)]
pub struct GraphParams { #[serde(default)] pub rev: Option<String>, #[serde(default = "dl")] pub limit: u32 }
#[derive(serde::Deserialize)]
pub struct ReflogParams { #[serde(default)] pub ref_name: Option<String>, #[serde(default = "reflog_def")] pub limit: u32 }
fn reflog_def() -> u32 { 50 }
#[derive(serde::Deserialize)]
pub struct SingleBranch { pub name: String }
#[derive(serde::Deserialize)]
pub struct BranchesMerged { pub branch: String, #[serde(default)] pub into: Option<String> }
#[derive(serde::Deserialize)]
pub struct BranchDiffP { pub local: String, #[serde(default)] pub remote: Option<String> }
#[derive(serde::Deserialize)]
pub struct DiffP { #[serde(default)] pub base: Option<String>, #[serde(default)] pub head: Option<String>, #[serde(default)] pub paths: Option<Vec<String>> }
#[derive(serde::Deserialize)]
pub struct DiffStatsP { pub base: String, pub head: String }
#[derive(serde::Deserialize)]
pub struct BlameP { pub path: String, #[serde(default)] pub rev: Option<String>, #[serde(default)] pub from_line: Option<u32>, #[serde(default)] pub to_line: Option<u32> }
#[derive(serde::Deserialize)]
pub struct FileContentP { pub path: String, #[serde(default)] pub rev: Option<String> }
#[derive(serde::Deserialize)]
pub struct TreeLsP { #[serde(default)] pub path: Option<String>, #[serde(default)] pub rev: Option<String> }
#[derive(serde::Deserialize)]
pub struct FileHistoryP { pub path: String, #[serde(default = "dl")] pub limit: u32 }
#[derive(serde::Deserialize)]
pub struct TagListP { #[serde(default)] pub pattern: Option<String> }
#[derive(serde::Deserialize)]
pub struct SingleTagP { pub name: String }
#[derive(serde::Deserialize)]
pub struct GitLogP { #[serde(default)] pub rev: Option<String>, #[serde(default = "dl")] pub limit: u32, #[serde(default)] pub skip: u32 }
// ---------------------------------------------------------------------------
// Commit types
// ---------------------------------------------------------------------------
/// Flat commit information for tool responses.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommitInfo {
pub oid: String,
pub short_oid: String,
pub message: String,
pub summary: String,
pub author_name: String,
pub author_email: String,
pub author_time: String,
pub committer_name: String,
pub committer_email: String,
pub parent_oids: Vec<String>,
pub tree_oid: String,
}
impl CommitInfo {
pub fn from_meta(meta: &CommitMeta) -> Self {
let ts = meta.author.time_secs;
let offset = meta.author.offset_minutes;
let author_time = format_rfc3339(ts, offset);
Self {
oid: meta.oid.to_string(),
short_oid: meta.oid.to_string().get(..7).unwrap_or(&meta.oid.to_string()).to_string(),
message: meta.message.clone(),
summary: meta.summary.clone(),
author_name: meta.author.name.clone(),
author_email: meta.author.email.clone(),
author_time,
committer_name: meta.committer.name.clone(),
committer_email: meta.committer.email.clone(),
parent_oids: meta.parent_ids.iter().map(|p| p.to_string()).collect(),
tree_oid: meta.tree_id.to_string(),
}
}
}
/// Commit reflog entry for tool responses.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReflogEntryInfo {
pub oid_new: String,
pub oid_old: String,
pub committer_name: String,
pub committer_email: String,
pub time: String,
pub message: Option<String>,
pub ref_name: String,
}
impl ReflogEntryInfo {
pub fn from_entry(entry: &CommitReflogEntry) -> Self {
let ts = entry.time_secs;
let time = format_rfc3339(ts, 0);
Self {
oid_new: entry.oid_new.to_string(),
oid_old: entry.oid_old.to_string(),
committer_name: entry.committer_name.clone(),
committer_email: entry.committer_email.clone(),
time,
message: entry.message.clone(),
ref_name: entry.ref_name.clone(),
}
}
}
// ---------------------------------------------------------------------------
// Branch types
// ---------------------------------------------------------------------------
/// Flat branch info for tool responses.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BranchInfoOut {
pub name: String,
pub oid: String,
pub short_oid: String,
pub is_head: bool,
pub is_remote: bool,
pub is_current: bool,
pub upstream: Option<String>,
}
impl From<&git::branch::types::BranchInfo> for BranchInfoOut {
fn from(b: &git::branch::types::BranchInfo) -> Self {
let oid = b.oid.to_string();
Self {
name: b.name.clone(),
oid: oid.clone(),
short_oid: oid.get(..7).unwrap_or(&oid).to_string(),
is_head: b.is_head,
is_remote: b.is_remote,
is_current: b.is_current,
upstream: b.upstream.clone(),
}
}
}
/// Branch diff (ahead/behind) for tool responses.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BranchDiffOut {
pub ahead: usize,
pub behind: usize,
pub diverged: bool,
}
impl From<&git::branch::types::BranchDiff> for BranchDiffOut {
fn from(d: &git::branch::types::BranchDiff) -> Self {
Self {
ahead: d.ahead,
behind: d.behind,
diverged: d.diverged,
}
}
}
// ---------------------------------------------------------------------------
// Diff types
// ---------------------------------------------------------------------------
/// Diff statistics for tool responses.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiffStatsOut {
pub files_changed: u32,
pub insertions: u32,
pub deletions: u32,
}
impl From<&DiffStats> for DiffStatsOut {
fn from(s: &DiffStats) -> Self {
Self {
files_changed: s.files_changed as u32,
insertions: s.insertions as u32,
deletions: s.deletions as u32,
}
}
}
/// A single diff file change for tool responses.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiffFileOut {
pub path: Option<String>,
pub status: String,
pub is_binary: bool,
pub size: u64,
}
impl DiffFileOut {
pub fn from_delta(delta: &DiffDelta) -> Self {
Self {
path: delta.new_file.path.clone(),
status: format!("{:?}", delta.status),
is_binary: delta.new_file.is_binary,
size: delta.new_file.size,
}
}
}
/// Diff summary (files + stats) for tool responses.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiffOut {
pub stats: DiffStatsOut,
pub files: Vec<DiffFileOut>,
}
impl DiffOut {
pub fn from_result(result: &git::diff::types::DiffResult) -> Self {
let stats = DiffStatsOut::from(&result.stats);
let files = result.deltas.iter().map(DiffFileOut::from_delta).collect();
Self { stats, files }
}
}
/// Blame hunk for tool responses.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlameHunkOut {
pub commit_oid: String,
pub short_oid: String,
pub final_start_line: u32,
pub final_lines: u32,
pub orig_start_line: u32,
pub orig_path: Option<String>,
pub boundary: bool,
}
impl From<&git::commit::types::CommitBlameHunk> for BlameHunkOut {
fn from(h: &git::commit::types::CommitBlameHunk) -> Self {
let oid = h.commit_oid.to_string();
Self {
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.clone(),
boundary: h.boundary,
}
}
}
// ---------------------------------------------------------------------------
// Tree types
// ---------------------------------------------------------------------------
/// Directory entry for tool responses.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TreeLsEntry {
pub name: String,
pub oid: String,
pub kind: String,
pub is_binary: bool,
}
impl From<&TreeEntry> for TreeLsEntry {
fn from(entry: &TreeEntry) -> Self {
Self {
name: entry.name.clone(),
oid: entry.oid.to_string(),
kind: entry.kind.clone(),
is_binary: entry.is_binary,
}
}
}
/// File content for tool responses.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileContentOut {
pub path: String,
pub oid: String,
pub size: u64,
pub content: String,
pub is_binary: bool,
}
impl FileContentOut {
pub fn from_blob(
path: String,
oid: &git::commit::types::CommitOid,
content: &[u8],
is_binary: bool,
) -> Self {
let (display_content, size) = if is_binary {
(
base64::engine::general_purpose::STANDARD.encode(content),
content.len() as u64,
)
} else {
(
String::from_utf8_lossy(content).to_string(),
content.len() as u64,
)
};
Self {
path,
oid: oid.to_string(),
size,
content: display_content,
is_binary,
}
}
}
// ---------------------------------------------------------------------------
// Tag types
// ---------------------------------------------------------------------------
/// Tag info for tool responses.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TagInfoOut {
pub name: String,
pub oid: String,
pub target: String,
pub is_annotated: bool,
pub message: Option<String>,
pub tagger_name: Option<String>,
pub tagger_email: Option<String>,
}
impl From<&git::tags::types::TagInfo> for TagInfoOut {
fn from(t: &git::tags::types::TagInfo) -> Self {
Self {
name: t.name.clone(),
oid: t.oid.to_string(),
target: t.target.to_string(),
is_annotated: t.is_annotated,
message: t.message.clone(),
tagger_name: t.tagger.clone(),
tagger_email: t.tagger_email.clone(),
}
}
}
// ---------------------------------------------------------------------------
// Graph types
// ---------------------------------------------------------------------------
/// Commit graph line for tool responses.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphLineOut {
pub oid: String,
pub short_oid: String,
pub refs: String,
pub short_message: String,
pub lane_index: usize,
pub author_name: String,
pub author_email: String,
pub author_time: String,
pub parent_oids: Vec<String>,
}
impl From<&git::commit::graph::CommitGraphLine> for GraphLineOut {
fn from(line: &git::commit::graph::CommitGraphLine) -> Self {
let oid = line.oid.to_string();
let ts = line.meta.author.time_secs;
let offset = line.meta.author.offset_minutes;
Self {
oid: oid.clone(),
short_oid: oid.get(..7).unwrap_or(&oid).to_string(),
refs: line.refs.clone(),
short_message: line.short_message.clone(),
lane_index: line.lane_index,
author_name: line.meta.author.name.clone(),
author_email: line.meta.author.email.clone(),
author_time: format_rfc3339(ts, offset),
parent_oids: line.meta.parent_ids.iter().map(|p| p.to_string()).collect(),
}
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn format_rfc3339(time_secs: i64, offset_minutes: i32) -> String {
let secs = time_secs + (offset_minutes as i64 * 60);
chrono::Utc
.timestamp_opt(secs, 0)
.single()
.map(|dt| dt.to_rfc3339())
.unwrap_or_else(|| format!("{}", time_secs))
}