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

@@ -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<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(
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();