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 <noreply@anthropic.com>
This commit is contained in:
Dave
2026-02-20 14:09:52 +00:00
parent 91534b4a59
commit a9d45bbcd5
7 changed files with 340 additions and 18 deletions

View File

@@ -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<f64>,
}
#[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<AppContext>,
}
@@ -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<CreateWorktreePayload>,
) -> OpenApiResult<Json<WorktreeInfoResponse>> {
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<Json<Vec<WorktreeListEntry>>> {
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<String>) -> OpenApiResult<Json<bool>> {
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))
}
}

View File

@@ -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<Value>) -> 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<Value>) -> 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<String, String> {
let acceptance_criteria: Option<Vec<String>> = 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<String, String> {
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<String, S
.map_err(|e| format!("Serialization error: {e}"))
}
// ── Worktree tool implementations ────────────────────────────────
async fn tool_create_worktree(args: &Value, ctx: &AppContext) -> Result<String, String> {
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<String, String> {
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::<Vec<_>>()))
.map_err(|e| format!("Serialization error: {e}"))
}
async fn tool_remove_worktree(args: &Value, ctx: &AppContext) -> Result<String, String> {
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 <base>..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<Vec<String>> {
@@ -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();

View File

@@ -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<String>,
pub acceptance_criteria: Option<Vec<String>>,
/// If true, git-add and git-commit the new story file to the current branch.
pub commit: Option<bool>,
}
#[derive(Object)]
@@ -556,11 +559,13 @@ impl WorkflowApi {
payload: Json<CreateStoryPayload>,
) -> OpenApiResult<Json<CreateStoryResponse>> {
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<String, String> {
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()