use std::collections::HashSet;
use std::path::Path;
use std::process::Command;
use git2::Repository;
use serde::Deserialize;
use serde_json::Value;
use tracing::debug;
use crate::core::{detect_version, StorageVersion};
use crate::domain::WrappedNeuralCommit;
use crate::mcp::protocol::ToolCallResult;
use crate::storage::{
FileHeadStore, FileObjectStore, FileRefStore, GitObjectStore, GitRefStore, HeadStore,
ObjectStore, RefStore,
};
#[derive(Debug, Deserialize)]
struct GetFileHistoryParams {
filepath: String,
#[serde(default = "default_limit")]
limit: usize,
}
fn default_limit() -> usize {
3
}
pub fn execute(project_root: &Path, agit_dir: &Path, arguments: Option<Value>) -> ToolCallResult {
let args = match arguments {
Some(v) => v,
None => {
return ToolCallResult::error("Missing arguments: 'filepath' is required");
},
};
let params: GetFileHistoryParams = match serde_json::from_value(args) {
Ok(p) => p,
Err(e) => {
return ToolCallResult::error(&format!("Invalid parameters: {}", e));
},
};
if !agit_dir.exists() {
return ToolCallResult::error("AGIT not initialized. Run 'agit init' first.");
}
match find_file_history(project_root, agit_dir, ¶ms.filepath, params.limit) {
Ok(history) => {
if history.is_empty() {
ToolCallResult::text(&format!("No history found for file: {}", params.filepath))
} else {
ToolCallResult::text(&history)
}
},
Err(e) => {
debug!("Failed to get file history: {}", e);
ToolCallResult::error(&format!("Error: {}", e))
},
}
}
fn find_file_history(
project_root: &Path,
agit_dir: &Path,
filepath: &str,
limit: usize,
) -> Result<String, String> {
let output = Command::new("git")
.current_dir(project_root)
.args(["log", "--format=%H", "--", filepath])
.output()
.map_err(|e| format!("Failed to execute git log: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("does not have any commits yet") || stderr.contains("unknown revision") {
return Ok(String::new());
}
return Err(stderr.to_string());
}
let raw_output = String::from_utf8_lossy(&output.stdout);
let interesting_hashes: HashSet<&str> = raw_output.lines().map(|l| l.trim()).collect();
if interesting_hashes.is_empty() {
return Ok(String::new()); }
debug!(
"Found {} git commits that touched '{}'",
interesting_hashes.len(),
filepath
);
let is_v2 = match Repository::discover(project_root) {
Ok(repo) => matches!(detect_version(agit_dir, &repo), StorageVersion::V2GitNative),
Err(_) => false,
};
let head_store = FileHeadStore::new(agit_dir);
let branch = head_store
.get()
.map_err(|e| format!("Failed to get branch: {}", e))?
.unwrap_or_else(|| "main".to_string());
let start_hash = if is_v2 {
let ref_store = GitRefStore::new(project_root);
ref_store
.get(&branch)
.map_err(|e| format!("Failed to get ref: {}", e))?
} else {
let ref_store = FileRefStore::new(agit_dir);
ref_store
.get(&branch)
.map_err(|e| format!("Failed to get ref: {}", e))?
};
let start_hash = match start_hash {
Some(h) => h,
None => return Ok(String::new()),
};
let mut results: Vec<(String, String)> = Vec::new(); let mut current_hash = Some(start_hash);
let mut scanned_count = 0;
let max_scan_depth = 500;
while let Some(hash) = current_hash {
if results.len() >= limit || scanned_count > max_scan_depth {
break;
}
scanned_count += 1;
let commit_data = if is_v2 {
GitObjectStore::new(project_root)
.load(&hash)
.map_err(|e| format!("Failed to load commit: {}", e))?
} else {
FileObjectStore::new(agit_dir)
.load(&hash)
.map_err(|e| format!("Failed to load commit: {}", e))?
};
let wrapped: WrappedNeuralCommit = serde_json::from_slice(&commit_data)
.map_err(|e| format!("Failed to parse commit: {}", e))?;
let commit = wrapped.data;
if interesting_hashes.contains(commit.git_hash.as_str()) {
results.push((commit.git_hash.clone(), commit.summary.clone()));
}
current_hash = commit.first_parent().map(|s| s.to_string());
}
if results.is_empty() {
return Ok(String::new());
}
let formatted: Vec<String> = results
.iter()
.enumerate()
.map(|(i, (git_hash, summary))| {
let short_hash = &git_hash[..7.min(git_hash.len())];
format!("{}. [{}] {}", i + 1, short_hash, summary)
})
.collect();
Ok(format!(
"File history for '{}':\n\n{}",
filepath,
formatted.join("\n")
))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use tempfile::TempDir;
#[test]
fn test_get_file_history_not_initialized() {
let temp = TempDir::new().unwrap();
let project_root = temp.path();
let agit_dir = temp.path().join(".agit");
let args = json!({
"filepath": "src/main.rs"
});
let result = execute(project_root, &agit_dir, Some(args));
assert_eq!(result.is_error, Some(true));
}
#[test]
fn test_get_file_history_missing_args() {
let temp = TempDir::new().unwrap();
let project_root = temp.path();
let agit_dir = temp.path().join(".agit");
std::fs::create_dir_all(&agit_dir).unwrap();
let result = execute(project_root, &agit_dir, None);
assert_eq!(result.is_error, Some(true));
}
#[test]
fn test_get_file_history_invalid_params() {
let temp = TempDir::new().unwrap();
let project_root = temp.path();
let agit_dir = temp.path().join(".agit");
std::fs::create_dir_all(&agit_dir).unwrap();
let args = json!({
"wrong_field": "value"
});
let result = execute(project_root, &agit_dir, Some(args));
assert_eq!(result.is_error, Some(true));
}
#[test]
fn test_default_limit() {
assert_eq!(default_limit(), 3);
}
}