Files
storkit/server/src/worktree.rs

309 lines
9.4 KiB
Rust
Raw Normal View History

use crate::config::ProjectConfig;
use std::path::{Path, PathBuf};
use std::process::Command;
/// Write a `.mcp.json` file in the given directory pointing to the MCP server
/// at the given port.
pub fn write_mcp_json(dir: &Path, port: u16) -> Result<(), String> {
let content = format!(
"{{\n \"mcpServers\": {{\n \"story-kit\": {{\n \"type\": \"http\",\n \"url\": \"http://localhost:{port}/mcp\"\n }}\n }}\n}}\n"
);
std::fs::write(dir.join(".mcp.json"), content).map_err(|e| format!("Write .mcp.json: {e}"))
}
#[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}"))
}
fn branch_name(story_id: &str) -> String {
format!("feature/story-{story_id}")
}
/// Detect the current branch of the project root (the base branch worktrees fork from).
fn detect_base_branch(project_root: &Path) -> String {
Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(project_root)
.output()
.ok()
.and_then(|o| {
if o.status.success() {
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
} else {
None
}
})
.unwrap_or_else(|| "master".to_string())
}
/// Create a git worktree for the given story.
///
/// - Creates the worktree at `{project_root}-story-{story_id}` (sibling directory)
/// 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.
/// - If the worktree/branch already exists, reuses rather than errors.
pub async fn create_worktree(
project_root: &Path,
story_id: &str,
config: &ProjectConfig,
port: u16,
) -> Result<WorktreeInfo, String> {
let wt_path = worktree_path(project_root, story_id);
let branch = branch_name(story_id);
let base_branch = detect_base_branch(project_root);
let root = project_root.to_path_buf();
// Already exists — reuse
if wt_path.exists() {
write_mcp_json(&wt_path, port)?;
run_setup_commands(&wt_path, config).await?;
return Ok(WorktreeInfo {
path: wt_path,
branch,
base_branch,
});
}
let wt = wt_path.clone();
let br = branch.clone();
tokio::task::spawn_blocking(move || create_worktree_sync(&root, &wt, &br))
.await
.map_err(|e| format!("spawn_blocking: {e}"))??;
write_mcp_json(&wt_path, port)?;
run_setup_commands(&wt_path, config).await?;
Ok(WorktreeInfo {
path: wt_path,
branch,
base_branch,
})
}
fn create_worktree_sync(
project_root: &Path,
wt_path: &Path,
branch: &str,
) -> Result<(), String> {
// Ensure the parent directory exists
if let Some(parent) = wt_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Create worktree dir: {e}"))?;
}
// Prune stale worktree references (e.g. directories deleted externally)
let _ = Command::new("git")
.args(["worktree", "prune"])
.current_dir(project_root)
.output();
// Try to create branch. If it already exists that's fine.
let _ = Command::new("git")
.args(["branch", branch])
.current_dir(project_root)
.output();
// Create worktree
let output = Command::new("git")
.args([
"worktree",
"add",
&wt_path.to_string_lossy(),
branch,
])
.current_dir(project_root)
.output()
.map_err(|e| format!("git worktree add: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
// If it says already checked out, that's fine
if stderr.contains("already checked out") || stderr.contains("already exists") {
return Ok(());
}
return Err(format!("git worktree add failed: {stderr}"));
}
Ok(())
}
/// Remove a git worktree and its branch.
#[allow(dead_code)]
pub async fn remove_worktree(
project_root: &Path,
info: &WorktreeInfo,
config: &ProjectConfig,
) -> Result<(), String> {
run_teardown_commands(&info.path, config).await?;
let root = project_root.to_path_buf();
let wt_path = info.path.clone();
let branch = info.branch.clone();
tokio::task::spawn_blocking(move || remove_worktree_sync(&root, &wt_path, &branch))
.await
.map_err(|e| format!("spawn_blocking: {e}"))?
}
#[allow(dead_code)]
fn remove_worktree_sync(
project_root: &Path,
wt_path: &Path,
branch: &str,
) -> Result<(), String> {
// Remove worktree
let output = Command::new("git")
.args([
"worktree",
"remove",
"--force",
&wt_path.to_string_lossy(),
])
.current_dir(project_root)
.output()
.map_err(|e| format!("git worktree remove: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
eprintln!("[worktree] remove warning: {stderr}");
}
// Delete branch (best effort)
let _ = Command::new("git")
.args(["branch", "-d", branch])
.current_dir(project_root)
.output();
Ok(())
}
async fn run_setup_commands(wt_path: &Path, config: &ProjectConfig) -> Result<(), String> {
for component in &config.component {
let cmd_dir = wt_path.join(&component.path);
for cmd in &component.setup {
run_shell_command(cmd, &cmd_dir).await?;
}
}
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);
for cmd in &component.teardown {
// Best effort — don't fail teardown
if let Err(e) = run_shell_command(cmd, &cmd_dir).await {
eprintln!("[worktree] teardown warning for {}: {e}", component.name);
}
}
}
Ok(())
}
async fn run_shell_command(cmd: &str, cwd: &Path) -> Result<(), String> {
let cmd = cmd.to_string();
let cwd = cwd.to_path_buf();
tokio::task::spawn_blocking(move || {
eprintln!("[worktree] Running: {cmd} in {}", cwd.display());
let output = Command::new("sh")
.args(["-c", &cmd])
.current_dir(&cwd)
.output()
.map_err(|e| format!("Run '{cmd}': {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Command '{cmd}' failed: {stderr}"));
}
Ok(())
})
.await
.map_err(|e| format!("spawn_blocking: {e}"))?
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
/// Initialise a bare-minimum git repo so worktree operations work.
fn init_git_repo(dir: &Path) {
Command::new("git")
.args(["init"])
.current_dir(dir)
.output()
.expect("git init");
Command::new("git")
.args(["commit", "--allow-empty", "-m", "init"])
.current_dir(dir)
.output()
.expect("git commit");
}
#[test]
fn write_mcp_json_uses_given_port() {
let tmp = TempDir::new().unwrap();
write_mcp_json(tmp.path(), 4242).unwrap();
let content = std::fs::read_to_string(tmp.path().join(".mcp.json")).unwrap();
assert!(content.contains("http://localhost:4242/mcp"));
}
#[test]
fn write_mcp_json_default_port() {
let tmp = TempDir::new().unwrap();
write_mcp_json(tmp.path(), 3001).unwrap();
let content = std::fs::read_to_string(tmp.path().join(".mcp.json")).unwrap();
assert!(content.contains("http://localhost:3001/mcp"));
}
#[test]
fn create_worktree_after_stale_reference() {
let tmp = TempDir::new().unwrap();
let project_root = tmp.path().join("my-project");
fs::create_dir_all(&project_root).unwrap();
init_git_repo(&project_root);
let wt_path = tmp.path().join("my-worktree");
let branch = "feature/test-stale";
// First creation should succeed
create_worktree_sync(&project_root, &wt_path, branch).unwrap();
assert!(wt_path.exists());
// Simulate external deletion (e.g., rm -rf by another agent)
fs::remove_dir_all(&wt_path).unwrap();
assert!(!wt_path.exists());
// Second creation should succeed despite stale git reference.
// Without `git worktree prune`, this fails with "already checked out"
// or "already exists".
let result = create_worktree_sync(&project_root, &wt_path, branch);
assert!(
result.is_ok(),
"Expected worktree creation to succeed after stale reference, got: {:?}",
result.err()
);
assert!(wt_path.exists());
}
}