2026-02-19 17:58:53 +00:00
|
|
|
use crate::config::ProjectConfig;
|
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
use std::process::Command;
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone)]
|
2026-02-20 11:57:25 +00:00
|
|
|
#[allow(dead_code)]
|
2026-02-19 17:58:53 +00:00
|
|
|
pub struct WorktreeInfo {
|
|
|
|
|
pub path: PathBuf,
|
|
|
|
|
pub branch: String,
|
2026-02-20 12:48:50 +00:00
|
|
|
pub base_branch: String,
|
2026-02-19 17:58:53 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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}")
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 12:48:50 +00:00
|
|
|
/// 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())
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 17:58:53 +00:00
|
|
|
/// 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}`.
|
|
|
|
|
/// - 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,
|
|
|
|
|
) -> Result<WorktreeInfo, String> {
|
|
|
|
|
let wt_path = worktree_path(project_root, story_id);
|
|
|
|
|
let branch = branch_name(story_id);
|
2026-02-20 12:48:50 +00:00
|
|
|
let base_branch = detect_base_branch(project_root);
|
2026-02-19 17:58:53 +00:00
|
|
|
let root = project_root.to_path_buf();
|
|
|
|
|
|
|
|
|
|
// Already exists — reuse
|
|
|
|
|
if wt_path.exists() {
|
|
|
|
|
run_setup_commands(&wt_path, config).await?;
|
|
|
|
|
return Ok(WorktreeInfo {
|
|
|
|
|
path: wt_path,
|
|
|
|
|
branch,
|
2026-02-20 12:48:50 +00:00
|
|
|
base_branch,
|
2026-02-19 17:58:53 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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}"))??;
|
|
|
|
|
|
|
|
|
|
run_setup_commands(&wt_path, config).await?;
|
|
|
|
|
|
|
|
|
|
Ok(WorktreeInfo {
|
|
|
|
|
path: wt_path,
|
|
|
|
|
branch,
|
2026-02-20 12:48:50 +00:00
|
|
|
base_branch,
|
2026-02-19 17:58:53 +00:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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}"))?;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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.
|
2026-02-20 11:57:25 +00:00
|
|
|
#[allow(dead_code)]
|
2026-02-19 17:58:53 +00:00
|
|
|
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}"))?
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 11:57:25 +00:00
|
|
|
#[allow(dead_code)]
|
2026-02-19 17:58:53 +00:00
|
|
|
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(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 11:57:25 +00:00
|
|
|
#[allow(dead_code)]
|
2026-02-19 17:58:53 +00:00
|
|
|
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}"))?
|
|
|
|
|
}
|