Files
huskies/server/src/http/mcp/git_tools.rs
T

691 lines
23 KiB
Rust

//! MCP git tools — status, diff, add, commit, and log operations on agent worktrees.
//!
//! This file is a thin adapter: it deserialises MCP payloads, delegates to
//! `crate::service::git_ops` for all business logic, and serialises responses.
use crate::http::context::AppContext;
use serde_json::{Value, json};
use std::path::PathBuf;
/// Validates that `worktree_path` exists and is inside the project's
/// `.huskies/worktrees/` directory. Returns the canonicalized path.
///
/// Thin wrapper that obtains the project root from `ctx` and delegates to
/// `service::git_ops::io::validate_worktree_path`.
fn validate_worktree_path(worktree_path: &str, ctx: &AppContext) -> Result<PathBuf, String> {
let project_root = ctx.agents.get_project_root(&ctx.state)?;
crate::service::git_ops::io::validate_worktree_path(worktree_path, &project_root)
.map_err(|e| e.to_string())
}
/// Run a git command in the given directory and return its output.
async fn run_git(args: Vec<&'static str>, dir: PathBuf) -> Result<std::process::Output, String> {
crate::service::git_ops::io::run_git(args, dir)
.await
.map_err(|e| e.to_string())
}
/// Run a git command with owned args in the given directory.
async fn run_git_owned(args: Vec<String>, dir: PathBuf) -> Result<std::process::Output, String> {
crate::service::git_ops::io::run_git_owned(args, dir)
.await
.map_err(|e| e.to_string())
}
/// git_status — returns working tree status (staged, unstaged, untracked files).
pub(super) async fn tool_git_status(args: &Value, ctx: &AppContext) -> Result<String, String> {
let worktree_path = args
.get("worktree_path")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: worktree_path")?;
let dir = validate_worktree_path(worktree_path, ctx)?;
let output = run_git(vec!["status", "--porcelain=v1", "-u"], dir).await?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success() {
return Err(format!(
"git status failed (exit {}): {stderr}",
output.status.code().unwrap_or(-1)
));
}
let (staged, unstaged, untracked) =
crate::service::git_ops::parse_git_status_porcelain(&stdout);
serde_json::to_string_pretty(&json!({
"staged": staged,
"unstaged": unstaged,
"untracked": untracked,
"clean": staged.is_empty() && unstaged.is_empty() && untracked.is_empty(),
}))
.map_err(|e| format!("Serialization error: {e}"))
}
/// git_diff — returns diff output. Supports staged/unstaged/commit range.
pub(super) async fn tool_git_diff(args: &Value, ctx: &AppContext) -> Result<String, String> {
let worktree_path = args
.get("worktree_path")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: worktree_path")?;
let dir = validate_worktree_path(worktree_path, ctx)?;
let staged = args
.get("staged")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let commit_range = args
.get("commit_range")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let mut git_args: Vec<String> = vec!["diff".to_string()];
if staged {
git_args.push("--staged".to_string());
}
if let Some(range) = commit_range {
git_args.push(range);
}
let output = run_git_owned(git_args, dir).await?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success() {
return Err(format!(
"git diff failed (exit {}): {stderr}",
output.status.code().unwrap_or(-1)
));
}
serde_json::to_string_pretty(&json!({
"diff": stdout.as_ref(),
"exit_code": output.status.code().unwrap_or(-1),
}))
.map_err(|e| format!("Serialization error: {e}"))
}
/// git_add — stages files by path.
pub(super) async fn tool_git_add(args: &Value, ctx: &AppContext) -> Result<String, String> {
let worktree_path = args
.get("worktree_path")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: worktree_path")?;
let paths: Vec<String> = args
.get("paths")
.and_then(|v| v.as_array())
.ok_or("Missing required argument: paths (must be an array of strings)")?
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
if paths.is_empty() {
return Err("paths must be a non-empty array of strings".to_string());
}
let dir = validate_worktree_path(worktree_path, ctx)?;
let mut git_args: Vec<String> = vec!["add".to_string(), "--".to_string()];
git_args.extend(paths.clone());
let output = run_git_owned(git_args, dir).await?;
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success() {
return Err(format!(
"git add failed (exit {}): {stderr}",
output.status.code().unwrap_or(-1)
));
}
serde_json::to_string_pretty(&json!({
"staged": paths,
"exit_code": output.status.code().unwrap_or(0),
}))
.map_err(|e| format!("Serialization error: {e}"))
}
/// git_commit — commits staged changes with a message.
pub(super) async fn tool_git_commit(args: &Value, ctx: &AppContext) -> Result<String, String> {
let worktree_path = args
.get("worktree_path")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: worktree_path")?;
let message = args
.get("message")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: message")?
.to_string();
if message.trim().is_empty() {
return Err("message must not be empty".to_string());
}
let dir = validate_worktree_path(worktree_path, ctx)?;
let git_args: Vec<String> = vec!["commit".to_string(), "--message".to_string(), message];
let output = run_git_owned(git_args, dir).await?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success() {
return Err(format!(
"git commit failed (exit {}): {stderr}",
output.status.code().unwrap_or(-1)
));
}
serde_json::to_string_pretty(&json!({
"output": stdout.as_ref(),
"exit_code": output.status.code().unwrap_or(0),
}))
.map_err(|e| format!("Serialization error: {e}"))
}
/// git_log — returns commit history with configurable count and format.
pub(super) async fn tool_git_log(args: &Value, ctx: &AppContext) -> Result<String, String> {
let worktree_path = args
.get("worktree_path")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: worktree_path")?;
let dir = validate_worktree_path(worktree_path, ctx)?;
let count = args
.get("count")
.and_then(|v| v.as_u64())
.unwrap_or(10)
.min(500);
let format = args
.get("format")
.and_then(|v| v.as_str())
.unwrap_or("%H%x09%s%x09%an%x09%ai")
.to_string();
let git_args: Vec<String> = vec![
"log".to_string(),
format!("--max-count={count}"),
format!("--pretty=format:{format}"),
];
let output = run_git_owned(git_args, dir).await?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
if !output.status.success() {
return Err(format!(
"git log failed (exit {}): {stderr}",
output.status.code().unwrap_or(-1)
));
}
serde_json::to_string_pretty(&json!({
"log": stdout.as_ref(),
"exit_code": output.status.code().unwrap_or(0),
}))
.map_err(|e| format!("Serialization error: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::http::context::AppContext;
use crate::http::test_helpers::test_ctx;
use serde_json::json;
/// Create a temp directory with a git worktree structure and init a repo.
fn setup_worktree() -> (tempfile::TempDir, PathBuf, AppContext) {
let tmp = tempfile::tempdir().unwrap();
let story_wt = tmp
.path()
.join(".huskies")
.join("worktrees")
.join("42_test_story");
std::fs::create_dir_all(&story_wt).unwrap();
// Init git repo in the worktree
std::process::Command::new("git")
.args(["init"])
.current_dir(&story_wt)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(&story_wt)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(&story_wt)
.output()
.unwrap();
let ctx = test_ctx(tmp.path());
(tmp, story_wt, ctx)
}
// ── validate_worktree_path ─────────────────────────────────────────
#[test]
fn validate_rejects_relative_path() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = validate_worktree_path("relative/path", &ctx);
assert!(result.is_err());
assert!(result.unwrap_err().contains("absolute"));
}
#[test]
fn validate_rejects_nonexistent_path() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = validate_worktree_path("/nonexistent_path_xyz_git", &ctx);
assert!(result.is_err());
assert!(result.unwrap_err().contains("does not exist"));
}
#[test]
fn validate_rejects_path_outside_worktrees() {
let tmp = tempfile::tempdir().unwrap();
let wt_dir = tmp.path().join(".huskies").join("worktrees");
std::fs::create_dir_all(&wt_dir).unwrap();
let ctx = test_ctx(tmp.path());
let result = validate_worktree_path(tmp.path().to_str().unwrap(), &ctx);
assert!(result.is_err());
assert!(result.unwrap_err().contains("inside .huskies/worktrees"));
}
#[test]
fn validate_accepts_path_inside_worktrees() {
let tmp = tempfile::tempdir().unwrap();
let story_wt = tmp
.path()
.join(".huskies")
.join("worktrees")
.join("42_test_story");
std::fs::create_dir_all(&story_wt).unwrap();
let ctx = test_ctx(tmp.path());
let result = validate_worktree_path(story_wt.to_str().unwrap(), &ctx);
assert!(result.is_ok(), "expected Ok, got: {:?}", result);
}
// ── git_status ────────────────────────────────────────────────────
#[tokio::test]
async fn git_status_missing_worktree_path() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_git_status(&json!({}), &ctx).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("worktree_path"));
}
#[tokio::test]
async fn git_status_clean_repo() {
let (_tmp, story_wt, ctx) = setup_worktree();
// Make an initial commit so HEAD exists
std::fs::write(story_wt.join("readme.txt"), "hello").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&story_wt)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(&story_wt)
.output()
.unwrap();
let result = tool_git_status(&json!({"worktree_path": story_wt.to_str().unwrap()}), &ctx)
.await
.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["clean"], true);
assert!(parsed["staged"].as_array().unwrap().is_empty());
assert!(parsed["unstaged"].as_array().unwrap().is_empty());
assert!(parsed["untracked"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn git_status_shows_untracked_file() {
let (_tmp, story_wt, ctx) = setup_worktree();
// Make initial commit
std::fs::write(story_wt.join("readme.txt"), "hello").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&story_wt)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(&story_wt)
.output()
.unwrap();
// Add untracked file
std::fs::write(story_wt.join("new_file.txt"), "content").unwrap();
let result = tool_git_status(&json!({"worktree_path": story_wt.to_str().unwrap()}), &ctx)
.await
.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["clean"], false);
let untracked = parsed["untracked"].as_array().unwrap();
assert!(
untracked
.iter()
.any(|v| v.as_str().unwrap().contains("new_file.txt")),
"expected new_file.txt in untracked: {parsed}"
);
}
// ── git_diff ──────────────────────────────────────────────────────
#[tokio::test]
async fn git_diff_missing_worktree_path() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_git_diff(&json!({}), &ctx).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("worktree_path"));
}
#[tokio::test]
async fn git_diff_returns_diff() {
let (_tmp, story_wt, ctx) = setup_worktree();
// Create initial commit
std::fs::write(story_wt.join("file.txt"), "line1\n").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&story_wt)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(&story_wt)
.output()
.unwrap();
// Modify file (unstaged)
std::fs::write(story_wt.join("file.txt"), "line1\nline2\n").unwrap();
let result = tool_git_diff(&json!({"worktree_path": story_wt.to_str().unwrap()}), &ctx)
.await
.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert!(
parsed["diff"].as_str().unwrap().contains("line2"),
"expected diff output: {parsed}"
);
}
#[tokio::test]
async fn git_diff_staged_flag() {
let (_tmp, story_wt, ctx) = setup_worktree();
// Create initial commit
std::fs::write(story_wt.join("file.txt"), "line1\n").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&story_wt)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(&story_wt)
.output()
.unwrap();
// Stage a modification
std::fs::write(story_wt.join("file.txt"), "line1\nstaged_change\n").unwrap();
std::process::Command::new("git")
.args(["add", "file.txt"])
.current_dir(&story_wt)
.output()
.unwrap();
let result = tool_git_diff(
&json!({"worktree_path": story_wt.to_str().unwrap(), "staged": true}),
&ctx,
)
.await
.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert!(
parsed["diff"].as_str().unwrap().contains("staged_change"),
"expected staged diff: {parsed}"
);
}
// ── git_add ───────────────────────────────────────────────────────
#[tokio::test]
async fn git_add_missing_worktree_path() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_git_add(&json!({"paths": ["file.txt"]}), &ctx).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("worktree_path"));
}
#[tokio::test]
async fn git_add_missing_paths() {
let (_tmp, story_wt, ctx) = setup_worktree();
let result =
tool_git_add(&json!({"worktree_path": story_wt.to_str().unwrap()}), &ctx).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("paths"));
}
#[tokio::test]
async fn git_add_empty_paths() {
let (_tmp, story_wt, ctx) = setup_worktree();
let result = tool_git_add(
&json!({"worktree_path": story_wt.to_str().unwrap(), "paths": []}),
&ctx,
)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("non-empty"));
}
#[tokio::test]
async fn git_add_stages_file() {
let (_tmp, story_wt, ctx) = setup_worktree();
std::fs::write(story_wt.join("file.txt"), "content").unwrap();
let result = tool_git_add(
&json!({
"worktree_path": story_wt.to_str().unwrap(),
"paths": ["file.txt"]
}),
&ctx,
)
.await
.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["exit_code"], 0);
let staged = parsed["staged"].as_array().unwrap();
assert!(staged.iter().any(|v| v.as_str().unwrap() == "file.txt"));
// Verify file is actually staged
let status = std::process::Command::new("git")
.args(["status", "--porcelain"])
.current_dir(&story_wt)
.output()
.unwrap();
let output = String::from_utf8_lossy(&status.stdout);
assert!(
output.contains("A file.txt"),
"file should be staged: {output}"
);
}
// ── git_commit ────────────────────────────────────────────────────
#[tokio::test]
async fn git_commit_missing_worktree_path() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_git_commit(&json!({"message": "test"}), &ctx).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("worktree_path"));
}
#[tokio::test]
async fn git_commit_missing_message() {
let (_tmp, story_wt, ctx) = setup_worktree();
let result =
tool_git_commit(&json!({"worktree_path": story_wt.to_str().unwrap()}), &ctx).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("message"));
}
#[tokio::test]
async fn git_commit_empty_message() {
let (_tmp, story_wt, ctx) = setup_worktree();
let result = tool_git_commit(
&json!({"worktree_path": story_wt.to_str().unwrap(), "message": " "}),
&ctx,
)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("empty"));
}
#[tokio::test]
async fn git_commit_creates_commit() {
let (_tmp, story_wt, ctx) = setup_worktree();
// Stage a file
std::fs::write(story_wt.join("file.txt"), "content").unwrap();
std::process::Command::new("git")
.args(["add", "file.txt"])
.current_dir(&story_wt)
.output()
.unwrap();
let result = tool_git_commit(
&json!({
"worktree_path": story_wt.to_str().unwrap(),
"message": "test commit message"
}),
&ctx,
)
.await
.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["exit_code"], 0);
// Verify commit exists
let log = std::process::Command::new("git")
.args(["log", "--oneline"])
.current_dir(&story_wt)
.output()
.unwrap();
let log_output = String::from_utf8_lossy(&log.stdout);
assert!(
log_output.contains("test commit message"),
"expected commit in log: {log_output}"
);
}
// ── git_log ───────────────────────────────────────────────────────
#[tokio::test]
async fn git_log_missing_worktree_path() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_git_log(&json!({}), &ctx).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("worktree_path"));
}
#[tokio::test]
async fn git_log_returns_history() {
let (_tmp, story_wt, ctx) = setup_worktree();
// Make a commit
std::fs::write(story_wt.join("file.txt"), "content").unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&story_wt)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "first commit"])
.current_dir(&story_wt)
.output()
.unwrap();
let result = tool_git_log(&json!({"worktree_path": story_wt.to_str().unwrap()}), &ctx)
.await
.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
assert_eq!(parsed["exit_code"], 0);
assert!(
parsed["log"].as_str().unwrap().contains("first commit"),
"expected commit in log: {parsed}"
);
}
#[tokio::test]
async fn git_log_respects_count() {
let (_tmp, story_wt, ctx) = setup_worktree();
// Make multiple commits
for i in 0..5 {
std::fs::write(story_wt.join("file.txt"), format!("content {i}")).unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(&story_wt)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", &format!("commit {i}")])
.current_dir(&story_wt)
.output()
.unwrap();
}
let result = tool_git_log(
&json!({"worktree_path": story_wt.to_str().unwrap(), "count": 2}),
&ctx,
)
.await
.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
// With count=2, only 2 commit entries should appear
let log = parsed["log"].as_str().unwrap();
// Each log line is tab-separated; count newlines
let lines: Vec<&str> = log.lines().collect();
assert_eq!(lines.len(), 2, "expected 2 log entries, got: {log}");
}
}