storkit: merge 349_story_mcp_tools_for_git_operations
This commit is contained in:
766
server/src/http/mcp/git_tools.rs
Normal file
766
server/src/http/mcp/git_tools.rs
Normal file
@@ -0,0 +1,766 @@
|
||||
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}");
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use std::sync::Arc;
|
||||
|
||||
pub mod agent_tools;
|
||||
pub mod diagnostics;
|
||||
pub mod git_tools;
|
||||
pub mod merge_tools;
|
||||
pub mod qa_tools;
|
||||
pub mod shell_tools;
|
||||
@@ -1025,6 +1026,101 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
|
||||
},
|
||||
"required": ["command", "working_dir"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "git_status",
|
||||
"description": "Return the working tree status of an agent's worktree (staged, unstaged, and untracked files). The worktree_path must be inside .storkit/worktrees/. Push and remote operations are not available.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"worktree_path": {
|
||||
"type": "string",
|
||||
"description": "Absolute path to the worktree directory. Must be inside .storkit/worktrees/."
|
||||
}
|
||||
},
|
||||
"required": ["worktree_path"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "git_diff",
|
||||
"description": "Return diff output for an agent's worktree. Supports unstaged (default), staged, or a commit range. The worktree_path must be inside .storkit/worktrees/.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"worktree_path": {
|
||||
"type": "string",
|
||||
"description": "Absolute path to the worktree directory. Must be inside .storkit/worktrees/."
|
||||
},
|
||||
"staged": {
|
||||
"type": "boolean",
|
||||
"description": "If true, show staged diff (--staged). Default: false."
|
||||
},
|
||||
"commit_range": {
|
||||
"type": "string",
|
||||
"description": "Optional commit range (e.g. 'HEAD~3..HEAD', 'abc123..def456')."
|
||||
}
|
||||
},
|
||||
"required": ["worktree_path"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "git_add",
|
||||
"description": "Stage files by path in an agent's worktree. The worktree_path must be inside .storkit/worktrees/.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"worktree_path": {
|
||||
"type": "string",
|
||||
"description": "Absolute path to the worktree directory. Must be inside .storkit/worktrees/."
|
||||
},
|
||||
"paths": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "List of file paths to stage (relative to worktree_path)."
|
||||
}
|
||||
},
|
||||
"required": ["worktree_path", "paths"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "git_commit",
|
||||
"description": "Commit staged changes in an agent's worktree with the given message. The worktree_path must be inside .storkit/worktrees/. Push and remote operations are not available.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"worktree_path": {
|
||||
"type": "string",
|
||||
"description": "Absolute path to the worktree directory. Must be inside .storkit/worktrees/."
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "Commit message."
|
||||
}
|
||||
},
|
||||
"required": ["worktree_path", "message"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "git_log",
|
||||
"description": "Return commit history for an agent's worktree with configurable count and format. The worktree_path must be inside .storkit/worktrees/.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"worktree_path": {
|
||||
"type": "string",
|
||||
"description": "Absolute path to the worktree directory. Must be inside .storkit/worktrees/."
|
||||
},
|
||||
"count": {
|
||||
"type": "integer",
|
||||
"description": "Number of commits to return (default: 10, max: 500)."
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"description": "git pretty-format string (default: '%H%x09%s%x09%an%x09%ai')."
|
||||
}
|
||||
},
|
||||
"required": ["worktree_path"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}),
|
||||
@@ -1107,6 +1203,12 @@ async fn handle_tools_call(
|
||||
"move_story" => diagnostics::tool_move_story(&args, ctx),
|
||||
// Shell command execution
|
||||
"run_command" => shell_tools::tool_run_command(&args, ctx).await,
|
||||
// Git operations
|
||||
"git_status" => git_tools::tool_git_status(&args, ctx).await,
|
||||
"git_diff" => git_tools::tool_git_diff(&args, ctx).await,
|
||||
"git_add" => git_tools::tool_git_add(&args, ctx).await,
|
||||
"git_commit" => git_tools::tool_git_commit(&args, ctx).await,
|
||||
"git_log" => git_tools::tool_git_log(&args, ctx).await,
|
||||
_ => Err(format!("Unknown tool: {tool_name}")),
|
||||
};
|
||||
|
||||
@@ -1217,7 +1319,12 @@ mod tests {
|
||||
assert!(names.contains(&"move_story"));
|
||||
assert!(names.contains(&"delete_story"));
|
||||
assert!(names.contains(&"run_command"));
|
||||
assert_eq!(tools.len(), 43);
|
||||
assert!(names.contains(&"git_status"));
|
||||
assert!(names.contains(&"git_diff"));
|
||||
assert!(names.contains(&"git_add"));
|
||||
assert!(names.contains(&"git_commit"));
|
||||
assert!(names.contains(&"git_log"));
|
||||
assert_eq!(tools.len(), 48);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user