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