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:
parent
821b0e998d
commit
5579e6c58e
93
libs/service/git_tools/branch.rs
Normal file
93
libs/service/git_tools/branch.rs
Normal 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);
|
||||
}
|
||||
230
libs/service/git_tools/commit.rs
Normal file
230
libs/service/git_tools/commit.rs
Normal 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);
|
||||
}
|
||||
53
libs/service/git_tools/ctx.rs
Normal file
53
libs/service/git_tools/ctx.rs
Normal 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))
|
||||
}
|
||||
147
libs/service/git_tools/diff.rs
Normal file
147
libs/service/git_tools/diff.rs
Normal 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);
|
||||
}
|
||||
21
libs/service/git_tools/mod.rs
Normal file
21
libs/service/git_tools/mod.rs
Normal 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);
|
||||
}
|
||||
73
libs/service/git_tools/tag.rs
Normal file
73
libs/service/git_tools/tag.rs
Normal 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);
|
||||
}
|
||||
126
libs/service/git_tools/tree.rs
Normal file
126
libs/service/git_tools/tree.rs
Normal 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);
|
||||
}
|
||||
411
libs/service/git_tools/types.rs
Normal file
411
libs/service/git_tools/types.rs
Normal 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))
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user