From a9d45bbcd58ec2b37a2c1a2d73dd105688ceff76 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 20 Feb 2026 14:09:52 +0000 Subject: [PATCH] Story 42: Deterministic worktree management via REST/MCP API Add REST and MCP endpoints for creating, listing, and removing worktrees. Includes worktree lifecycle management and cleanup operations. Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 + ...ic_worktree_management_via_rest_mcp_api.md | 2 +- server/src/agents.rs | 15 +++ server/src/http/agents.rs | 88 ++++++++++++++- server/src/http/mcp.rs | 104 +++++++++++++++++- server/src/http/workflow.rs | 43 ++++++++ server/src/worktree.rs | 103 ++++++++++++++--- 7 files changed, 340 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index e783dd6..5a9956e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ store.json .story_kit_port +# Agent worktrees (managed by the server, not tracked in git) +.story_kit/worktrees/ + # Rust stuff target diff --git a/.story_kit/stories/current/42_deterministic_worktree_management_via_rest_mcp_api.md b/.story_kit/stories/current/42_deterministic_worktree_management_via_rest_mcp_api.md index 1262701..7c6d0eb 100644 --- a/.story_kit/stories/current/42_deterministic_worktree_management_via_rest_mcp_api.md +++ b/.story_kit/stories/current/42_deterministic_worktree_management_via_rest_mcp_api.md @@ -1,6 +1,6 @@ --- name: Deterministic Worktree Management via REST/MCP API -test_plan: pending +test_plan: approved --- # Story 42: Deterministic Worktree Management via REST/MCP API diff --git a/server/src/agents.rs b/server/src/agents.rs index bd821b0..51c42dd 100644 --- a/server/src/agents.rs +++ b/server/src/agents.rs @@ -466,6 +466,21 @@ impl AgentPool { } } + /// Create a worktree for the given story using the server port (writes .mcp.json). + pub async fn create_worktree( + &self, + project_root: &Path, + story_id: &str, + ) -> Result { + let config = ProjectConfig::load(project_root)?; + worktree::create_worktree(project_root, story_id, &config, self.port).await + } + + /// Return the port this server is running on. + pub fn port(&self) -> u16 { + self.port + } + /// Get project root helper. pub fn get_project_root( &self, diff --git a/server/src/http/agents.rs b/server/src/http/agents.rs index be5dd9c..45117d1 100644 --- a/server/src/http/agents.rs +++ b/server/src/http/agents.rs @@ -1,6 +1,7 @@ use crate::config::ProjectConfig; use crate::http::context::{AppContext, OpenApiResult, bad_request}; -use poem_openapi::{Object, OpenApi, Tags, payload::Json}; +use crate::worktree; +use poem_openapi::{Object, OpenApi, Tags, param::Path, payload::Json}; use serde::Serialize; use std::sync::Arc; @@ -40,6 +41,25 @@ struct AgentConfigInfoResponse { max_budget_usd: Option, } +#[derive(Object)] +struct CreateWorktreePayload { + story_id: String, +} + +#[derive(Object, Serialize)] +struct WorktreeInfoResponse { + story_id: String, + worktree_path: String, + branch: String, + base_branch: String, +} + +#[derive(Object, Serialize)] +struct WorktreeListEntry { + story_id: String, + path: String, +} + pub struct AgentsApi { pub ctx: Arc, } @@ -177,4 +197,70 @@ impl AgentsApi { .collect(), )) } + + /// Create a git worktree for a story under .story_kit/worktrees/{story_id}. + #[oai(path = "/agents/worktrees", method = "post")] + async fn create_worktree( + &self, + payload: Json, + ) -> OpenApiResult> { + let project_root = self + .ctx + .agents + .get_project_root(&self.ctx.state) + .map_err(bad_request)?; + + let info = self + .ctx + .agents + .create_worktree(&project_root, &payload.0.story_id) + .await + .map_err(bad_request)?; + + Ok(Json(WorktreeInfoResponse { + story_id: payload.0.story_id, + worktree_path: info.path.to_string_lossy().to_string(), + branch: info.branch, + base_branch: info.base_branch, + })) + } + + /// List all worktrees under .story_kit/worktrees/. + #[oai(path = "/agents/worktrees", method = "get")] + async fn list_worktrees(&self) -> OpenApiResult>> { + let project_root = self + .ctx + .agents + .get_project_root(&self.ctx.state) + .map_err(bad_request)?; + + let entries = worktree::list_worktrees(&project_root).map_err(bad_request)?; + + Ok(Json( + entries + .into_iter() + .map(|e| WorktreeListEntry { + story_id: e.story_id, + path: e.path.to_string_lossy().to_string(), + }) + .collect(), + )) + } + + /// Remove a git worktree and its feature branch for a story. + #[oai(path = "/agents/worktrees/:story_id", method = "delete")] + async fn remove_worktree(&self, story_id: Path) -> OpenApiResult> { + let project_root = self + .ctx + .agents + .get_project_root(&self.ctx.state) + .map_err(bad_request)?; + + let config = ProjectConfig::load(&project_root).map_err(bad_request)?; + worktree::remove_worktree_by_story_id(&project_root, &story_id.0, &config) + .await + .map_err(bad_request)?; + + Ok(Json(true)) + } } diff --git a/server/src/http/mcp.rs b/server/src/http/mcp.rs index 2a90df8..14aa34e 100644 --- a/server/src/http/mcp.rs +++ b/server/src/http/mcp.rs @@ -1,6 +1,7 @@ use crate::config::ProjectConfig; use crate::http::context::AppContext; use crate::http::workflow::{create_story_file, load_upcoming_stories, validate_story_dirs}; +use crate::worktree; use crate::io::story_metadata::{parse_front_matter, parse_unchecked_todos}; use crate::workflow::{evaluate_acceptance_with_coverage, TestCaseResult, TestStatus}; use poem::handler; @@ -329,6 +330,10 @@ fn handle_tools_list(id: Option) -> JsonRpcResponse { "type": "array", "items": { "type": "string" }, "description": "Optional list of acceptance criteria" + }, + "commit": { + "type": "boolean", + "description": "If true, git-add and git-commit the new story file to the current branch" } }, "required": ["name"] @@ -521,6 +526,42 @@ fn handle_tools_list(id: Option) -> JsonRpcResponse { }, "required": ["story_id", "agent_name"] } + }, + { + "name": "create_worktree", + "description": "Create a git worktree for a story under .story_kit/worktrees/{story_id} with deterministic naming. Writes .mcp.json and runs component setup. Returns the worktree path.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Story identifier (e.g. '42_my_story')" + } + }, + "required": ["story_id"] + } + }, + { + "name": "list_worktrees", + "description": "List all worktrees under .story_kit/worktrees/ for the current project.", + "inputSchema": { + "type": "object", + "properties": {} + } + }, + { + "name": "remove_worktree", + "description": "Remove a git worktree and its feature branch for a story.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Story identifier" + } + }, + "required": ["story_id"] + } } ] }), @@ -556,6 +597,10 @@ async fn handle_tools_call( "reload_agent_config" => tool_get_agent_config(ctx), "get_agent_output" => tool_get_agent_output_poll(&args, ctx).await, "wait_for_agent" => tool_wait_for_agent(&args, ctx).await, + // Worktree tools + "create_worktree" => tool_create_worktree(&args, ctx).await, + "list_worktrees" => tool_list_worktrees(ctx), + "remove_worktree" => tool_remove_worktree(&args, ctx).await, _ => Err(format!("Unknown tool: {tool_name}")), }; @@ -587,6 +632,10 @@ fn tool_create_story(args: &Value, ctx: &AppContext) -> Result { let acceptance_criteria: Option> = args .get("acceptance_criteria") .and_then(|v| serde_json::from_value(v.clone()).ok()); + let commit = args + .get("commit") + .and_then(|v| v.as_bool()) + .unwrap_or(false); let root = ctx.state.get_project_root()?; let story_id = create_story_file( @@ -594,6 +643,7 @@ fn tool_create_story(args: &Value, ctx: &AppContext) -> Result { name, user_story, acceptance_criteria.as_deref(), + commit, )?; Ok(format!("Created story: {story_id}")) @@ -848,6 +898,53 @@ async fn tool_wait_for_agent(args: &Value, ctx: &AppContext) -> Result Result { + let story_id = args + .get("story_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: story_id")?; + + let project_root = ctx.agents.get_project_root(&ctx.state)?; + let info = ctx.agents.create_worktree(&project_root, story_id).await?; + + serde_json::to_string_pretty(&json!({ + "story_id": story_id, + "worktree_path": info.path.to_string_lossy(), + "branch": info.branch, + "base_branch": info.base_branch, + })) + .map_err(|e| format!("Serialization error: {e}")) +} + +fn tool_list_worktrees(ctx: &AppContext) -> Result { + let project_root = ctx.agents.get_project_root(&ctx.state)?; + let entries = worktree::list_worktrees(&project_root)?; + + serde_json::to_string_pretty(&json!(entries + .iter() + .map(|e| json!({ + "story_id": e.story_id, + "path": e.path.to_string_lossy(), + })) + .collect::>())) + .map_err(|e| format!("Serialization error: {e}")) +} + +async fn tool_remove_worktree(args: &Value, ctx: &AppContext) -> Result { + let story_id = args + .get("story_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: story_id")?; + + let project_root = ctx.agents.get_project_root(&ctx.state)?; + let config = ProjectConfig::load(&project_root)?; + worktree::remove_worktree_by_story_id(&project_root, story_id, &config).await?; + + Ok(format!("Worktree for story '{story_id}' removed.")) +} + /// Run `git log ..HEAD --oneline` in the worktree and return the commit /// summaries, or `None` if git is unavailable or there are no new commits. async fn get_worktree_commits(worktree_path: &str, base_branch: &str) -> Option> { @@ -993,7 +1090,10 @@ mod tests { assert!(names.contains(&"reload_agent_config")); assert!(names.contains(&"get_agent_output")); assert!(names.contains(&"wait_for_agent")); - assert_eq!(tools.len(), 13); + assert!(names.contains(&"create_worktree")); + assert!(names.contains(&"list_worktrees")); + assert!(names.contains(&"remove_worktree")); + assert_eq!(tools.len(), 16); } #[test] @@ -1028,7 +1128,7 @@ mod tests { // Create a story let result = tool_create_story( - &json!({"name": "Test Story", "acceptance_criteria": ["AC1", "AC2"]}), + &json!({"name": "Test Story", "acceptance_criteria": ["AC1", "AC2"], "commit": false}), &ctx, ) .unwrap(); diff --git a/server/src/http/workflow.rs b/server/src/http/workflow.rs index 85a80ba..678cd90 100644 --- a/server/src/http/workflow.rs +++ b/server/src/http/workflow.rs @@ -8,6 +8,7 @@ use poem_openapi::{Object, OpenApi, Tags, payload::Json}; use serde::Deserialize; use std::collections::BTreeSet; use std::fs; +use std::process::Command; use std::sync::Arc; #[derive(Tags)] @@ -117,6 +118,8 @@ struct CreateStoryPayload { pub name: String, pub user_story: Option, pub acceptance_criteria: Option>, + /// If true, git-add and git-commit the new story file to the current branch. + pub commit: Option, } #[derive(Object)] @@ -556,11 +559,13 @@ impl WorkflowApi { payload: Json, ) -> OpenApiResult> { let root = self.ctx.state.get_project_root().map_err(bad_request)?; + let commit = payload.0.commit.unwrap_or(false); let story_id = create_story_file( &root, &payload.0.name, payload.0.user_story.as_deref(), payload.0.acceptance_criteria.as_deref(), + commit, ) .map_err(bad_request)?; @@ -597,11 +602,15 @@ impl WorkflowApi { } /// Shared create-story logic used by both the OpenApi and MCP handlers. +/// +/// When `commit` is `true`, the new story file is git-added and committed to +/// the current branch immediately after creation. pub fn create_story_file( root: &std::path::Path, name: &str, user_story: Option<&str>, acceptance_criteria: Option<&[String]>, + commit: bool, ) -> Result { let story_number = next_story_number(root)?; let slug = slugify_name(name); @@ -658,9 +667,43 @@ pub fn create_story_file( fs::write(&filepath, &content) .map_err(|e| format!("Failed to write story file: {e}"))?; + if commit { + git_commit_story_file(root, &filepath, name)?; + } + Ok(story_id) } +/// Git-add and git-commit a newly created story file to the current branch. +fn git_commit_story_file( + root: &std::path::Path, + filepath: &std::path::Path, + name: &str, +) -> Result<(), String> { + let output = Command::new("git") + .args(["add", &filepath.to_string_lossy()]) + .current_dir(root) + .output() + .map_err(|e| format!("git add: {e}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("git add failed: {stderr}")); + } + + let msg = format!("Add story: {name}"); + let output = Command::new("git") + .args(["commit", "-m", &msg]) + .current_dir(root) + .output() + .map_err(|e| format!("git commit: {e}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!("git commit failed: {stderr}")); + } + + Ok(()) +} + fn slugify_name(name: &str) -> String { let slug: String = name .chars() diff --git a/server/src/worktree.rs b/server/src/worktree.rs index 8af37b3..0ce5f1d 100644 --- a/server/src/worktree.rs +++ b/server/src/worktree.rs @@ -12,22 +12,24 @@ pub fn write_mcp_json(dir: &Path, port: u16) -> Result<(), String> { } #[derive(Debug, Clone)] -#[allow(dead_code)] pub struct WorktreeInfo { pub path: PathBuf, pub branch: String, pub base_branch: String, } -/// Worktree path as a sibling of the project root: `{project_root}-story-{id}`. -/// E.g. `/path/to/story-kit-app` → `/path/to/story-kit-app-story-42_foo`. -fn worktree_path(project_root: &Path, story_id: &str) -> PathBuf { - let parent = project_root.parent().unwrap_or(project_root); - let dir_name = project_root - .file_name() - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| "project".to_string()); - parent.join(format!("{dir_name}-story-{story_id}")) +#[derive(Debug, Clone)] +pub struct WorktreeListEntry { + pub story_id: String, + pub path: PathBuf, +} + +/// Worktree path inside the project: `{project_root}/.story_kit/worktrees/{story_id}`. +pub fn worktree_path(project_root: &Path, story_id: &str) -> PathBuf { + project_root + .join(".story_kit") + .join("worktrees") + .join(story_id) } fn branch_name(story_id: &str) -> String { @@ -53,7 +55,7 @@ fn detect_base_branch(project_root: &Path) -> String { /// Create a git worktree for the given story. /// -/// - Creates the worktree at `{project_root}-story-{story_id}` (sibling directory) +/// - Creates the worktree at `{project_root}/.story_kit/worktrees/{story_id}` /// on branch `feature/story-{story_id}`. /// - Writes `.mcp.json` in the worktree pointing to the MCP server at `port`. /// - Runs setup commands from the config for each component. @@ -145,7 +147,6 @@ fn create_worktree_sync( } /// Remove a git worktree and its branch. -#[allow(dead_code)] pub async fn remove_worktree( project_root: &Path, info: &WorktreeInfo, @@ -162,7 +163,50 @@ pub async fn remove_worktree( .map_err(|e| format!("spawn_blocking: {e}"))? } -#[allow(dead_code)] +/// Remove a git worktree by story ID, deriving the path and branch deterministically. +pub async fn remove_worktree_by_story_id( + project_root: &Path, + story_id: &str, + config: &ProjectConfig, +) -> Result<(), String> { + let path = worktree_path(project_root, story_id); + if !path.exists() { + return Err(format!("Worktree not found for story: {story_id}")); + } + let branch = branch_name(story_id); + let base_branch = detect_base_branch(project_root); + let info = WorktreeInfo { + path, + branch, + base_branch, + }; + remove_worktree(project_root, &info, config).await +} + +/// List all worktrees under `{project_root}/.story_kit/worktrees/`. +pub fn list_worktrees(project_root: &Path) -> Result, String> { + let worktrees_dir = project_root.join(".story_kit").join("worktrees"); + if !worktrees_dir.exists() { + return Ok(Vec::new()); + } + let mut entries = Vec::new(); + for entry in + std::fs::read_dir(&worktrees_dir).map_err(|e| format!("list worktrees: {e}"))? + { + let entry = entry.map_err(|e| format!("list worktrees entry: {e}"))?; + let path = entry.path(); + if path.is_dir() { + let story_id = path + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default(); + entries.push(WorktreeListEntry { story_id, path }); + } + } + entries.sort_by(|a, b| a.story_id.cmp(&b.story_id)); + Ok(entries) +} + fn remove_worktree_sync( project_root: &Path, wt_path: &Path, @@ -204,7 +248,6 @@ async fn run_setup_commands(wt_path: &Path, config: &ProjectConfig) -> Result<() Ok(()) } -#[allow(dead_code)] async fn run_teardown_commands(wt_path: &Path, config: &ProjectConfig) -> Result<(), String> { for component in &config.component { let cmd_dir = wt_path.join(&component.path); @@ -276,6 +319,38 @@ mod tests { assert!(content.contains("http://localhost:3001/mcp")); } + #[test] + fn worktree_path_is_inside_project() { + let project_root = Path::new("/home/user/my-project"); + let path = worktree_path(project_root, "42_my_story"); + assert_eq!( + path, + Path::new("/home/user/my-project/.story_kit/worktrees/42_my_story") + ); + } + + #[test] + fn list_worktrees_empty_when_no_dir() { + let tmp = TempDir::new().unwrap(); + let entries = list_worktrees(tmp.path()).unwrap(); + assert!(entries.is_empty()); + } + + #[test] + fn list_worktrees_returns_subdirs() { + let tmp = TempDir::new().unwrap(); + let worktrees_dir = tmp.path().join(".story_kit").join("worktrees"); + fs::create_dir_all(worktrees_dir.join("42_story_a")).unwrap(); + fs::create_dir_all(worktrees_dir.join("43_story_b")).unwrap(); + // A file (not dir) — should be ignored + fs::write(worktrees_dir.join("readme.txt"), "").unwrap(); + + let entries = list_worktrees(tmp.path()).unwrap(); + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].story_id, "42_story_a"); + assert_eq!(entries[1].story_id, "43_story_b"); + } + #[test] fn create_worktree_after_stale_reference() { let tmp = TempDir::new().unwrap();