use crate::cli::output::print_success;
use crate::core::branch::BranchType;
use crate::core::config::{Config, EnvironmentConfig};
use crate::core::port::{calculate_effective_ports, is_shared_service_port};
use crate::core::slot::allocate_lowest_slot;
use crate::infra::docker::generate_docker_compose_override;
use crate::infra::env_file::{
copy_and_merge_untracked_files, sanitize_compose_project_name, write_env_worktree,
};
use crate::infra::fs::link_shared_cache_directories;
use crate::infra::git::{resolve_git_common_dir, resolve_primary_repo_root, resolve_repo_name};
use crate::infra::hook::run_post_start_hook;
use crate::infra::lock::SlotLock;
use crate::infra::registry::{SlotRecord, SlotRegistry};
use crate::infra::worktree::create_worktree;
use crate::workflow::error::WorkflowError;
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
pub struct StartOptions<'a> {
pub branch_type: BranchType,
pub name: &'a str,
pub isolated: bool,
pub repo_root: Option<&'a Path>,
}
pub fn start_worktree(options: StartOptions<'_>) -> Result<u32, WorkflowError> {
let toplevel = resolve_primary_repo_root(options.repo_root)?;
let common_dir = resolve_git_common_dir(Some(&toplevel))?;
let repo_name = resolve_repo_name(&toplevel);
let config_path = toplevel.join(".git-claw.toml");
let config = Config::load_or_default(&config_path, &repo_name)?;
let branch_name = options.branch_type.format_branch_name(options.name)?;
let raw_worktree_root = config.worktree_root(&repo_name);
let worktree_root = if Path::new(&raw_worktree_root).is_absolute() {
PathBuf::from(raw_worktree_root)
} else {
toplevel.join(raw_worktree_root)
};
let worktree_path = worktree_root.join(options.name);
let lock_path = common_dir.join("claw/slots.lock");
let registry_path = common_dir.join("claw/slots.json");
let _lock = SlotLock::acquire(&lock_path)?;
let mut registry = SlotRegistry::load_or_empty(®istry_path)?;
let _ = registry.auto_gc(®istry_path, Some(&toplevel), true);
if registry.find_by_name(options.name).is_some() {
return Err(WorkflowError::DuplicateSlotName(options.name.to_string()));
}
let slot_id = allocate_lowest_slot(registry.occupied_ids());
let shiftable_ports: BTreeMap<String, u16> = config
.ports
.iter()
.filter(|(k, _)| !is_shared_service_port(k, &config.docker.shared_services))
.map(|(k, &v)| (k.clone(), v))
.collect();
let effective_ports = calculate_effective_ports(&shiftable_ports, slot_id)?;
create_worktree(
&toplevel,
&worktree_path,
&branch_name,
config.main_branch(),
)?;
let _ = propagate_environment_files(&toplevel, &worktree_path, &config.environment);
let mut effective_cache = config.cache.clone();
if !options.isolated
&& !effective_cache.directories.iter().any(|d| d == ".venv")
&& (config.environment.r#type.as_deref() == Some("venv")
|| config.environment.r#type.as_deref() == Some("uv"))
&& toplevel.join(".venv").exists()
{
effective_cache.directories.push(".venv".to_string());
}
let _ = link_shared_cache_directories(
&toplevel,
&worktree_path,
&effective_cache,
options.isolated,
);
let compose_project = sanitize_compose_project_name(&repo_name, options.name, slot_id);
let _ = copy_and_merge_untracked_files(
&toplevel,
&worktree_path,
&config.files.copy,
&shiftable_ports,
slot_id,
Some(&compose_project),
);
let _ = generate_docker_compose_override(&toplevel, &worktree_path, &config.docker);
write_env_worktree(
&worktree_path,
&repo_name,
options.name,
slot_id,
&effective_ports,
Some(&config.environment),
)?;
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
registry.slots.push(SlotRecord {
id: slot_id,
name: options.name.to_string(),
branch_type: options.branch_type.to_string(),
branch: branch_name.clone(),
path: worktree_path.to_string_lossy().to_string(),
allocated_at: now.to_string(),
});
registry.save_atomic(®istry_path)?;
drop(_lock);
let mut hook_env = BTreeMap::new();
hook_env.insert("CLAW_SLOT_ID".to_string(), slot_id.to_string());
hook_env.insert("CLAW_WORKTREE_NAME".to_string(), options.name.to_string());
hook_env.insert(
"CLAW_WORKTREE_PATH".to_string(),
worktree_path.to_string_lossy().to_string(),
);
for (k, v) in &effective_ports {
hook_env.insert(k.clone(), v.to_string());
}
let _ = run_post_start_hook(
config.hooks.post_start.as_deref(),
&worktree_path,
&hook_env,
);
crate::infra::herdr::open_worktree_in_herdr(&toplevel, &branch_name);
print_success(format!(
"Worktree '{}' started successfully (Slot: {}, Branch: {}, Path: {})",
options.name,
slot_id,
branch_name,
worktree_path.display()
));
Ok(slot_id)
}
pub fn propagate_environment_files(
toplevel: &Path,
worktree_path: &Path,
env_config: &EnvironmentConfig,
) -> std::io::Result<()> {
for file in &env_config.copy_files {
let file = file.trim();
if file.is_empty() {
continue;
}
let src = toplevel.join(file);
let dst = worktree_path.join(file);
if src.is_file() {
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(&src, &dst)?;
}
}
for file in &[".python-version", ".node-version", ".nvmrc", ".envrc"] {
let src = toplevel.join(file);
let dst = worktree_path.join(file);
if src.is_file() && !dst.exists() {
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(&src, &dst)?;
}
}
let py_version_dst = worktree_path.join(".python-version");
if !py_version_dst.exists() {
if let Some(ref name) = env_config.name {
let content = if name.ends_with('\n') {
name.clone()
} else {
format!("{}\n", name)
};
fs::write(&py_version_dst, content)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_propagate_environment_files_declared_and_standard() {
let top = tempdir().unwrap();
let wt = tempdir().unwrap();
fs::write(top.path().join(".python-version"), "tune\n").unwrap();
fs::write(top.path().join(".nvmrc"), "20\n").unwrap();
fs::write(top.path().join("custom.env"), "FOO=BAR\n").unwrap();
let env = EnvironmentConfig {
r#type: Some("pyenv".to_string()),
name: Some("tune".to_string()),
copy_files: vec!["custom.env".to_string()],
};
propagate_environment_files(top.path(), wt.path(), &env).unwrap();
assert_eq!(
fs::read_to_string(wt.path().join(".python-version")).unwrap(),
"tune\n"
);
assert_eq!(
fs::read_to_string(wt.path().join(".nvmrc")).unwrap(),
"20\n"
);
assert_eq!(
fs::read_to_string(wt.path().join("custom.env")).unwrap(),
"FOO=BAR\n"
);
}
#[test]
fn test_propagate_environment_fallback_name() {
let top = tempdir().unwrap();
let wt = tempdir().unwrap();
let env = EnvironmentConfig {
r#type: Some("pyenv".to_string()),
name: Some("my-virtualenv".to_string()),
copy_files: vec![],
};
propagate_environment_files(top.path(), wt.path(), &env).unwrap();
assert_eq!(
fs::read_to_string(wt.path().join(".python-version")).unwrap(),
"my-virtualenv\n"
);
}
}