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}"); } }