use async_trait::async_trait;
use git2::{Repository, Status, StatusOptions, BlameOptions, DiffOptions};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::collections::HashMap;
use crate::integration::HostIntegration;
use crate::tools::{Tool, ToolResponse, ToolError, Permission, validation};
pub struct GitTool {
repo_cache: std::sync::Arc<std::sync::Mutex<HashMap<PathBuf, Repository>>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitStatus {
pub branch: String,
pub ahead: usize,
pub behind: usize,
pub modified_files: Vec<String>,
pub added_files: Vec<String>,
pub deleted_files: Vec<String>,
pub untracked_files: Vec<String>,
pub conflicted_files: Vec<String>,
pub is_clean: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitDiffInfo {
pub file_path: String,
pub additions: usize,
pub deletions: usize,
pub hunks: Vec<DiffHunk>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiffHunk {
pub old_start: u32,
pub old_lines: u32,
pub new_start: u32,
pub new_lines: u32,
pub header: String,
pub lines: Vec<DiffLine>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiffLine {
pub line_type: String, pub content: String,
pub line_number: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommitInfo {
pub id: String,
pub message: String,
pub author: String,
pub email: String,
pub timestamp: i64,
pub files_changed: Vec<String>,
pub additions: usize,
pub deletions: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlameInfo {
pub file_path: String,
pub lines: Vec<BlameLine>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlameLine {
pub line_number: usize,
pub content: String,
pub commit_id: String,
pub author: String,
pub timestamp: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BranchInfo {
pub name: String,
pub is_current: bool,
pub is_remote: bool,
pub last_commit: String,
pub ahead: usize,
pub behind: usize,
}
impl GitTool {
pub fn new() -> Self {
Self {
repo_cache: std::sync::Arc::new(std::sync::Mutex::new(HashMap::new())),
}
}
fn get_repository(&self, repo_path: &Path) -> Result<Repository, ToolError> {
let mut cache = self.repo_cache.lock().unwrap();
if let Some(repo) = cache.get(repo_path) {
if repo.path().exists() {
return Ok(Repository::open(repo.path())?);
}
}
let repo = Repository::discover(repo_path)
.map_err(|e| ToolError::ExecutionFailed(format!("Not a git repository: {}", e)))?;
let repo_root = repo.workdir()
.ok_or_else(|| ToolError::ExecutionFailed("Bare repository not supported".to_string()))?
.to_path_buf();
cache.insert(repo_root, Repository::open(repo.path())?);
Ok(repo)
}
async fn get_status(&self, repo_path: &Path) -> Result<GitStatus, ToolError> {
let repo = self.get_repository(repo_path)?;
let head = repo.head()?;
let branch = if head.is_branch() {
head.shorthand().unwrap_or("HEAD").to_string()
} else {
"HEAD".to_string()
};
let mut status_opts = StatusOptions::new();
status_opts.include_untracked(true);
status_opts.include_ignored(false);
let statuses = repo.statuses(Some(&mut status_opts))?;
let mut modified_files = Vec::new();
let mut added_files = Vec::new();
let mut deleted_files = Vec::new();
let mut untracked_files = Vec::new();
let mut conflicted_files = Vec::new();
for entry in statuses.iter() {
let path = entry.path().unwrap_or("").to_string();
let status = entry.status();
if status.contains(Status::CONFLICTED) {
conflicted_files.push(path.clone());
}
if status.contains(Status::WT_MODIFIED) || status.contains(Status::INDEX_MODIFIED) {
modified_files.push(path.clone());
}
if status.contains(Status::WT_NEW) || status.contains(Status::INDEX_NEW) {
if status.contains(Status::WT_NEW) {
untracked_files.push(path.clone());
} else {
added_files.push(path.clone());
}
}
if status.contains(Status::WT_DELETED) || status.contains(Status::INDEX_DELETED) {
deleted_files.push(path.clone());
}
}
let is_clean = modified_files.is_empty() && added_files.is_empty() &&
deleted_files.is_empty() && untracked_files.is_empty() &&
conflicted_files.is_empty();
let (ahead, behind) = self.get_ahead_behind(&repo, &branch)?;
Ok(GitStatus {
branch,
ahead,
behind,
modified_files,
added_files,
deleted_files,
untracked_files,
conflicted_files,
is_clean,
})
}
fn get_ahead_behind(&self, repo: &Repository, branch: &str) -> Result<(usize, usize), ToolError> {
let local_ref = repo.find_reference(&format!("refs/heads/{}", branch));
let remote_ref = repo.find_reference(&format!("refs/remotes/origin/{}", branch));
match (local_ref, remote_ref) {
(Ok(local), Ok(remote)) => {
let local_oid = local.target().ok_or_else(|| ToolError::ExecutionFailed("Invalid local ref".to_string()))?;
let remote_oid = remote.target().ok_or_else(|| ToolError::ExecutionFailed("Invalid remote ref".to_string()))?;
let (ahead, behind) = repo.graph_ahead_behind(local_oid, remote_oid)?;
Ok((ahead, behind))
}
_ => Ok((0, 0))
}
}
async fn get_diff(&self, repo_path: &Path, params: &serde_json::Value) -> Result<Vec<GitDiffInfo>, ToolError> {
let repo = self.get_repository(repo_path)?;
let staged = validation::optional_string(params, "staged").unwrap_or_else(|| "false".to_string()) == "true";
let file_path = validation::optional_string(params, "file_path");
let diff = if staged {
let tree = repo.head()?.peel_to_tree()?;
let mut diff_opts = DiffOptions::new();
if let Some(path) = &file_path {
diff_opts.pathspec(path);
}
repo.diff_tree_to_index(Some(&tree), None, Some(&mut diff_opts))?
} else {
let mut diff_opts = DiffOptions::new();
if let Some(path) = &file_path {
diff_opts.pathspec(path);
}
repo.diff_index_to_workdir(None, Some(&mut diff_opts))?
};
let mut diff_infos = Vec::new();
diff.foreach(
&mut |delta, _progress| {
if let Some(path) = delta.new_file().path() {
diff_infos.push(GitDiffInfo {
file_path: path.to_string_lossy().to_string(),
additions: 0,
deletions: 0,
hunks: Vec::new(),
});
}
true
},
None,
None,
None,
)?;
Ok(diff_infos)
}
async fn get_log(&self, repo_path: &Path, params: &serde_json::Value) -> Result<Vec<CommitInfo>, ToolError> {
let repo = self.get_repository(repo_path)?;
let limit = validation::optional_string(params, "limit")
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(10);
let file_path = validation::optional_string(params, "file_path");
let mut revwalk = repo.revwalk()?;
revwalk.push_head()?;
revwalk.set_sorting(git2::Sort::TIME)?;
let mut commits = Vec::new();
let mut count = 0;
for oid in revwalk {
if count >= limit {
break;
}
let oid = oid?;
let commit = repo.find_commit(oid)?;
if let Some(path) = &file_path {
let tree = commit.tree()?;
if tree.get_path(Path::new(path)).is_err() {
continue; }
}
let author = commit.author();
let message = commit.message().unwrap_or("").to_string();
commits.push(CommitInfo {
id: oid.to_string(),
message,
author: author.name().unwrap_or("").to_string(),
email: author.email().unwrap_or("").to_string(),
timestamp: author.when().seconds(),
files_changed: Vec::new(), additions: 0, deletions: 0, });
count += 1;
}
Ok(commits)
}
async fn get_blame(&self, repo_path: &Path, params: &serde_json::Value) -> Result<BlameInfo, ToolError> {
let repo = self.get_repository(repo_path)?;
let file_path = validation::require_string(params, "file_path")?;
let mut blame_opts = BlameOptions::new();
let blame = repo.blame_file(Path::new(&file_path), Some(&mut blame_opts))?;
let full_path = repo.workdir()
.ok_or_else(|| ToolError::ExecutionFailed("Bare repository not supported".to_string()))?
.join(&file_path);
let content = std::fs::read_to_string(&full_path)
.map_err(|e| ToolError::ExecutionFailed(format!("Failed to read file: {}", e)))?;
let lines: Vec<&str> = content.lines().collect();
let mut blame_lines = Vec::new();
for (line_num, line_content) in lines.iter().enumerate() {
if let Some(hunk) = blame.get_line(line_num + 1) {
let commit = repo.find_commit(hunk.final_commit_id())?;
let author = commit.author();
blame_lines.push(BlameLine {
line_number: line_num + 1,
content: line_content.to_string(),
commit_id: hunk.final_commit_id().to_string(),
author: author.name().unwrap_or("").to_string(),
timestamp: author.when().seconds(),
});
}
}
Ok(BlameInfo {
file_path,
lines: blame_lines,
})
}
async fn get_branches(&self, repo_path: &Path) -> Result<Vec<BranchInfo>, ToolError> {
let repo = self.get_repository(repo_path)?;
let branches = repo.branches(Some(git2::BranchType::Local))?;
let mut branch_infos = Vec::new();
let current_branch = repo.head()?.shorthand().unwrap_or("").to_string();
for branch_result in branches {
let (branch, _branch_type) = branch_result?;
if let Some(name) = branch.name()? {
let is_current = name == current_branch;
let last_commit = if let Some(oid) = branch.get().target() {
oid.to_string()
} else {
"".to_string()
};
let (ahead, behind) = if is_current {
self.get_ahead_behind(&repo, name)?
} else {
(0, 0)
};
branch_infos.push(BranchInfo {
name: name.to_string(),
is_current,
is_remote: false,
last_commit,
ahead,
behind,
});
}
}
Ok(branch_infos)
}
}
#[async_trait]
impl Tool for GitTool {
async fn execute(
&self,
parameters: serde_json::Value,
_host: &dyn HostIntegration,
) -> Result<ToolResponse, ToolError> {
let action = validation::require_string(¶meters, "action")?;
let repo_path = validation::optional_path(¶meters, "repo_path")
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
match action.as_str() {
"status" => {
let status = self.get_status(&repo_path).await?;
let content = format!(
"Git Status\n\
Branch: {}\n\
Ahead: {} commits\n\
Behind: {} commits\n\
Modified: {} files\n\
Added: {} files\n\
Deleted: {} files\n\
Untracked: {} files\n\
Conflicted: {} files\n\
Clean: {}",
status.branch,
status.ahead,
status.behind,
status.modified_files.len(),
status.added_files.len(),
status.deleted_files.len(),
status.untracked_files.len(),
status.conflicted_files.len(),
status.is_clean
);
Ok(ToolResponse::with_metadata(content, serde_json::to_value(status)?))
}
"diff" => {
let diff_info = self.get_diff(&repo_path, ¶meters).await?;
let content = format!(
"Git Diff\n\
Files changed: {}\n\
Total changes: {} files",
diff_info.len(),
diff_info.len()
);
Ok(ToolResponse::with_metadata(content, serde_json::to_value(diff_info)?))
}
"log" => {
let commits = self.get_log(&repo_path, ¶meters).await?;
let content = format!(
"Git Log\n\
Showing {} commits\n\
Latest: {}",
commits.len(),
commits.first().map(|c| c.message.as_str()).unwrap_or("No commits")
);
Ok(ToolResponse::with_metadata(content, serde_json::to_value(commits)?))
}
"blame" => {
let blame_info = self.get_blame(&repo_path, ¶meters).await?;
let content = format!(
"Git Blame for {}\n\
Lines: {}",
blame_info.file_path,
blame_info.lines.len()
);
Ok(ToolResponse::with_metadata(content, serde_json::to_value(blame_info)?))
}
"branches" => {
let branches = self.get_branches(&repo_path).await?;
let current = branches.iter().find(|b| b.is_current);
let content = format!(
"Git Branches\n\
Total: {}\n\
Current: {}",
branches.len(),
current.map(|b| b.name.as_str()).unwrap_or("None")
);
Ok(ToolResponse::with_metadata(content, serde_json::to_value(branches)?))
}
_ => Err(ToolError::InvalidParameters(format!("Unknown git action: {}", action)))
}
}
fn requires_permission(&self) -> Permission {
Permission::None }
fn description(&self) -> &str {
"Analyze Git repository status, history, and changes"
}
fn name(&self) -> &str {
"git"
}
fn parameter_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"description": "Git action to perform",
"enum": ["status", "diff", "log", "blame", "branches"]
},
"repo_path": {
"type": "string",
"description": "Path to git repository (optional, defaults to current directory)"
},
"file_path": {
"type": "string",
"description": "Specific file path for blame, log, or diff operations"
},
"limit": {
"type": "string",
"description": "Number of commits to show in log (default: 10)"
},
"staged": {
"type": "string",
"description": "Show staged changes in diff (true/false, default: false)"
}
},
"required": ["action"]
})
}
fn clone_box(&self) -> Box<dyn Tool> {
Box::new(Self::new())
}
}
impl Default for GitTool {
fn default() -> Self {
Self::new()
}
}