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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user