From d838dd712754d9af2e7a76715fd7e452b89624f4 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 20 Mar 2026 15:17:10 +0000 Subject: [PATCH] storkit: merge 349_story_mcp_tools_for_git_operations --- server/src/http/mcp/git_tools.rs | 766 +++++++++++++++++++++++++++++++ server/src/http/mcp/mod.rs | 109 ++++- 2 files changed, 874 insertions(+), 1 deletion(-) create mode 100644 server/src/http/mcp/git_tools.rs diff --git a/server/src/http/mcp/git_tools.rs b/server/src/http/mcp/git_tools.rs new file mode 100644 index 0000000..0161eca --- /dev/null +++ b/server/src/http/mcp/git_tools.rs @@ -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 { + 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 { + 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, dir: PathBuf) -> Result { + 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 { + 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 = Vec::new(); + let mut unstaged: Vec = Vec::new(); + let mut untracked: Vec = 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 { + 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 = 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 { + let worktree_path = args + .get("worktree_path") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: worktree_path")?; + + let paths: Vec = 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 = 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 { + 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 = 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 { + 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 = 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}"); + } +} diff --git a/server/src/http/mcp/mod.rs b/server/src/http/mcp/mod.rs index 185a897..76beb1f 100644 --- a/server/src/http/mcp/mod.rs +++ b/server/src/http/mcp/mod.rs @@ -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) -> 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]