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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -5,6 +5,9 @@
|
|||||||
store.json
|
store.json
|
||||||
.story_kit_port
|
.story_kit_port
|
||||||
|
|
||||||
|
# Agent worktrees (managed by the server, not tracked in git)
|
||||||
|
.story_kit/worktrees/
|
||||||
|
|
||||||
# Rust stuff
|
# Rust stuff
|
||||||
target
|
target
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: Deterministic Worktree Management via REST/MCP API
|
name: Deterministic Worktree Management via REST/MCP API
|
||||||
test_plan: pending
|
test_plan: approved
|
||||||
---
|
---
|
||||||
|
|
||||||
# Story 42: Deterministic Worktree Management via REST/MCP API
|
# Story 42: Deterministic Worktree Management via REST/MCP API
|
||||||
|
|||||||
@@ -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<worktree::WorktreeInfo, String> {
|
||||||
|
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.
|
/// Get project root helper.
|
||||||
pub fn get_project_root(
|
pub fn get_project_root(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use crate::config::ProjectConfig;
|
use crate::config::ProjectConfig;
|
||||||
use crate::http::context::{AppContext, OpenApiResult, bad_request};
|
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 serde::Serialize;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -40,6 +41,25 @@ struct AgentConfigInfoResponse {
|
|||||||
max_budget_usd: Option<f64>,
|
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 struct AgentsApi {
|
||||||
pub ctx: Arc<AppContext>,
|
pub ctx: Arc<AppContext>,
|
||||||
}
|
}
|
||||||
@@ -177,4 +197,70 @@ impl AgentsApi {
|
|||||||
.collect(),
|
.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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use crate::config::ProjectConfig;
|
use crate::config::ProjectConfig;
|
||||||
use crate::http::context::AppContext;
|
use crate::http::context::AppContext;
|
||||||
use crate::http::workflow::{create_story_file, load_upcoming_stories, validate_story_dirs};
|
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::io::story_metadata::{parse_front_matter, parse_unchecked_todos};
|
||||||
use crate::workflow::{evaluate_acceptance_with_coverage, TestCaseResult, TestStatus};
|
use crate::workflow::{evaluate_acceptance_with_coverage, TestCaseResult, TestStatus};
|
||||||
use poem::handler;
|
use poem::handler;
|
||||||
@@ -329,6 +330,10 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
|
|||||||
"type": "array",
|
"type": "array",
|
||||||
"items": { "type": "string" },
|
"items": { "type": "string" },
|
||||||
"description": "Optional list of acceptance criteria"
|
"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"]
|
"required": ["name"]
|
||||||
@@ -521,6 +526,42 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
|
|||||||
},
|
},
|
||||||
"required": ["story_id", "agent_name"]
|
"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),
|
"reload_agent_config" => tool_get_agent_config(ctx),
|
||||||
"get_agent_output" => tool_get_agent_output_poll(&args, ctx).await,
|
"get_agent_output" => tool_get_agent_output_poll(&args, ctx).await,
|
||||||
"wait_for_agent" => tool_wait_for_agent(&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}")),
|
_ => 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
|
let acceptance_criteria: Option<Vec<String>> = args
|
||||||
.get("acceptance_criteria")
|
.get("acceptance_criteria")
|
||||||
.and_then(|v| serde_json::from_value(v.clone()).ok());
|
.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 root = ctx.state.get_project_root()?;
|
||||||
let story_id = create_story_file(
|
let story_id = create_story_file(
|
||||||
@@ -594,6 +643,7 @@ fn tool_create_story(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
|||||||
name,
|
name,
|
||||||
user_story,
|
user_story,
|
||||||
acceptance_criteria.as_deref(),
|
acceptance_criteria.as_deref(),
|
||||||
|
commit,
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
Ok(format!("Created story: {story_id}"))
|
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}"))
|
.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
|
/// 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.
|
/// 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>> {
|
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(&"reload_agent_config"));
|
||||||
assert!(names.contains(&"get_agent_output"));
|
assert!(names.contains(&"get_agent_output"));
|
||||||
assert!(names.contains(&"wait_for_agent"));
|
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]
|
#[test]
|
||||||
@@ -1028,7 +1128,7 @@ mod tests {
|
|||||||
|
|
||||||
// Create a story
|
// Create a story
|
||||||
let result = tool_create_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,
|
&ctx,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use poem_openapi::{Object, OpenApi, Tags, payload::Json};
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
use std::process::Command;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
#[derive(Tags)]
|
#[derive(Tags)]
|
||||||
@@ -117,6 +118,8 @@ struct CreateStoryPayload {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
pub user_story: Option<String>,
|
pub user_story: Option<String>,
|
||||||
pub acceptance_criteria: Option<Vec<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)]
|
#[derive(Object)]
|
||||||
@@ -556,11 +559,13 @@ impl WorkflowApi {
|
|||||||
payload: Json<CreateStoryPayload>,
|
payload: Json<CreateStoryPayload>,
|
||||||
) -> OpenApiResult<Json<CreateStoryResponse>> {
|
) -> OpenApiResult<Json<CreateStoryResponse>> {
|
||||||
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
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(
|
let story_id = create_story_file(
|
||||||
&root,
|
&root,
|
||||||
&payload.0.name,
|
&payload.0.name,
|
||||||
payload.0.user_story.as_deref(),
|
payload.0.user_story.as_deref(),
|
||||||
payload.0.acceptance_criteria.as_deref(),
|
payload.0.acceptance_criteria.as_deref(),
|
||||||
|
commit,
|
||||||
)
|
)
|
||||||
.map_err(bad_request)?;
|
.map_err(bad_request)?;
|
||||||
|
|
||||||
@@ -597,11 +602,15 @@ impl WorkflowApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Shared create-story logic used by both the OpenApi and MCP handlers.
|
/// 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(
|
pub fn create_story_file(
|
||||||
root: &std::path::Path,
|
root: &std::path::Path,
|
||||||
name: &str,
|
name: &str,
|
||||||
user_story: Option<&str>,
|
user_story: Option<&str>,
|
||||||
acceptance_criteria: Option<&[String]>,
|
acceptance_criteria: Option<&[String]>,
|
||||||
|
commit: bool,
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let story_number = next_story_number(root)?;
|
let story_number = next_story_number(root)?;
|
||||||
let slug = slugify_name(name);
|
let slug = slugify_name(name);
|
||||||
@@ -658,9 +667,43 @@ pub fn create_story_file(
|
|||||||
fs::write(&filepath, &content)
|
fs::write(&filepath, &content)
|
||||||
.map_err(|e| format!("Failed to write story file: {e}"))?;
|
.map_err(|e| format!("Failed to write story file: {e}"))?;
|
||||||
|
|
||||||
|
if commit {
|
||||||
|
git_commit_story_file(root, &filepath, name)?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(story_id)
|
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 {
|
fn slugify_name(name: &str) -> String {
|
||||||
let slug: String = name
|
let slug: String = name
|
||||||
.chars()
|
.chars()
|
||||||
|
|||||||
@@ -12,22 +12,24 @@ pub fn write_mcp_json(dir: &Path, port: u16) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
#[allow(dead_code)]
|
|
||||||
pub struct WorktreeInfo {
|
pub struct WorktreeInfo {
|
||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
pub branch: String,
|
pub branch: String,
|
||||||
pub base_branch: String,
|
pub base_branch: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Worktree path as a sibling of the project root: `{project_root}-story-{id}`.
|
#[derive(Debug, Clone)]
|
||||||
/// E.g. `/path/to/story-kit-app` → `/path/to/story-kit-app-story-42_foo`.
|
pub struct WorktreeListEntry {
|
||||||
fn worktree_path(project_root: &Path, story_id: &str) -> PathBuf {
|
pub story_id: String,
|
||||||
let parent = project_root.parent().unwrap_or(project_root);
|
pub path: PathBuf,
|
||||||
let dir_name = project_root
|
}
|
||||||
.file_name()
|
|
||||||
.map(|n| n.to_string_lossy().to_string())
|
/// Worktree path inside the project: `{project_root}/.story_kit/worktrees/{story_id}`.
|
||||||
.unwrap_or_else(|| "project".to_string());
|
pub fn worktree_path(project_root: &Path, story_id: &str) -> PathBuf {
|
||||||
parent.join(format!("{dir_name}-story-{story_id}"))
|
project_root
|
||||||
|
.join(".story_kit")
|
||||||
|
.join("worktrees")
|
||||||
|
.join(story_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn branch_name(story_id: &str) -> String {
|
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.
|
/// 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}`.
|
/// on branch `feature/story-{story_id}`.
|
||||||
/// - Writes `.mcp.json` in the worktree pointing to the MCP server at `port`.
|
/// - Writes `.mcp.json` in the worktree pointing to the MCP server at `port`.
|
||||||
/// - Runs setup commands from the config for each component.
|
/// - Runs setup commands from the config for each component.
|
||||||
@@ -145,7 +147,6 @@ fn create_worktree_sync(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a git worktree and its branch.
|
/// Remove a git worktree and its branch.
|
||||||
#[allow(dead_code)]
|
|
||||||
pub async fn remove_worktree(
|
pub async fn remove_worktree(
|
||||||
project_root: &Path,
|
project_root: &Path,
|
||||||
info: &WorktreeInfo,
|
info: &WorktreeInfo,
|
||||||
@@ -162,7 +163,50 @@ pub async fn remove_worktree(
|
|||||||
.map_err(|e| format!("spawn_blocking: {e}"))?
|
.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<Vec<WorktreeListEntry>, 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(
|
fn remove_worktree_sync(
|
||||||
project_root: &Path,
|
project_root: &Path,
|
||||||
wt_path: &Path,
|
wt_path: &Path,
|
||||||
@@ -204,7 +248,6 @@ async fn run_setup_commands(wt_path: &Path, config: &ProjectConfig) -> Result<()
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
async fn run_teardown_commands(wt_path: &Path, config: &ProjectConfig) -> Result<(), String> {
|
async fn run_teardown_commands(wt_path: &Path, config: &ProjectConfig) -> Result<(), String> {
|
||||||
for component in &config.component {
|
for component in &config.component {
|
||||||
let cmd_dir = wt_path.join(&component.path);
|
let cmd_dir = wt_path.join(&component.path);
|
||||||
@@ -276,6 +319,38 @@ mod tests {
|
|||||||
assert!(content.contains("http://localhost:3001/mcp"));
|
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]
|
#[test]
|
||||||
fn create_worktree_after_stale_reference() {
|
fn create_worktree_after_stale_reference() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user