Files
storkit/server/src/http/mcp/git_tools.rs

767 lines
25 KiB
Rust

use crate::http::context::AppContext;
use serde_json::{json, Value};
use std::path::PathBuf;
/// Validates that `worktree_path` exists and is inside the project's
/// `.storkit/worktrees/` directory. Returns the canonicalized path.
fn validate_worktree_path(worktree_path: &str, ctx: &AppContext) -> Result<PathBuf, String> {
let wd = PathBuf::from(worktree_path);
if !wd.is_absolute() {
return Err("worktree_path must be an absolute path".to_string());
}
if !wd.exists() {
return Err(format!(
"worktree_path does not exist: {worktree_path}"
));
}
let project_root = ctx.agents.get_project_root(&ctx.state)?;
let worktrees_root = project_root.join(".storkit").join("worktrees");
let canonical_wd = wd
.canonicalize()
.map_err(|e| format!("Cannot canonicalize worktree_path: {e}"))?;
let canonical_wt = if worktrees_root.exists() {
worktrees_root
.canonicalize()
.map_err(|e| format!("Cannot canonicalize worktrees root: {e}"))?
} else {
return Err("No worktrees directory found in project".to_string());
};
if !canonical_wd.starts_with(&canonical_wt) {
return Err(format!(
"worktree_path must be inside .storkit/worktrees/. Got: {worktree_path}"
));
}
Ok(canonical_wd)
}
/// 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> {
tokio::task::spawn_blocking(move || {
std::process::Command::new("git")
.args(&args)
.current_dir(&dir)
.output()
})
.await
.map_err(|e| format!("Task join error: {e}"))?
.map_err(|e| format!("Failed to run git: {e}"))
}
/// 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> {
tokio::task::spawn_blocking(move || {
std::process::Command::new("git")
.args(&args)
.current_dir(&dir)
.output()
})
.await
.map_err(|e| format!("Task join error: {e}"))?
.map_err(|e| format!("Failed to run git: {e}"))
}
/// 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 mut staged: Vec<String> = Vec::new();
let mut unstaged: Vec<String> = Vec::new();
let mut untracked: Vec<String> = Vec::new();
for line in stdout.lines() {
if line.len() < 3 {
continue;
}
let x = line.chars().next().unwrap_or(' ');
let y = line.chars().nth(1).unwrap_or(' ');
let path = line[3..].to_string();
match (x, y) {
('?', '?') => untracked.push(path),
(' ', _) => unstaged.push(path),
(_, ' ') => staged.push(path),
_ => {
// Both staged and unstaged modifications
staged.push(path.clone());
unstaged.push(path);
}
}
}
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 serde_json::json;
fn test_ctx(dir: &std::path::Path) -> AppContext {
AppContext::new_test(dir.to_path_buf())
}
/// 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(".storkit")
.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(".storkit").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 .storkit/worktrees"));
}
#[test]
fn validate_accepts_path_inside_worktrees() {
let tmp = tempfile::tempdir().unwrap();
let story_wt = tmp
.path()
.join(".storkit")
.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}");
}
}