1use crate::cli::output::print_success;
4use crate::core::branch::BranchType;
5use crate::core::config::{Config, EnvironmentConfig};
6use crate::core::port::{calculate_effective_ports, is_shared_service_port};
7use crate::core::slot::allocate_lowest_slot;
8use crate::infra::docker::generate_docker_compose_override;
9use crate::infra::env_file::{
10 copy_and_merge_untracked_files, sanitize_compose_project_name, write_env_worktree,
11};
12use crate::infra::fs::link_shared_cache_directories;
13use crate::infra::git::{resolve_git_common_dir, resolve_primary_repo_root, resolve_repo_name};
14use crate::infra::hook::run_post_start_hook;
15use crate::infra::lock::SlotLock;
16use crate::infra::registry::{SlotRecord, SlotRegistry};
17use crate::infra::worktree::create_worktree;
18use crate::workflow::error::WorkflowError;
19use std::collections::BTreeMap;
20use std::fs;
21use std::path::{Path, PathBuf};
22use std::time::{SystemTime, UNIX_EPOCH};
23
24pub struct StartOptions<'a> {
25 pub branch_type: BranchType,
26 pub name: &'a str,
27 pub isolated: bool,
28 pub repo_root: Option<&'a Path>,
29}
30
31pub fn start_worktree(options: StartOptions<'_>) -> Result<u32, WorkflowError> {
33 let toplevel = resolve_primary_repo_root(options.repo_root)?;
34 let common_dir = resolve_git_common_dir(Some(&toplevel))?;
35 let repo_name = resolve_repo_name(&toplevel);
36
37 let config_path = toplevel.join(".git-claw.toml");
38 let config = Config::load_or_default(&config_path, &repo_name)?;
39
40 let branch_name = options.branch_type.format_branch_name(options.name)?;
41
42 let raw_worktree_root = config.worktree_root(&repo_name);
43 let worktree_root = if Path::new(&raw_worktree_root).is_absolute() {
44 PathBuf::from(raw_worktree_root)
45 } else {
46 toplevel.join(raw_worktree_root)
47 };
48 let worktree_path = worktree_root.join(options.name);
49
50 let lock_path = common_dir.join("claw/slots.lock");
51 let registry_path = common_dir.join("claw/slots.json");
52
53 let _lock = SlotLock::acquire(&lock_path)?;
55
56 let mut registry = SlotRegistry::load_or_empty(®istry_path)?;
57
58 let _ = registry.auto_gc(®istry_path, Some(&toplevel), true);
60
61 if registry.find_by_name(options.name).is_some() {
62 return Err(WorkflowError::DuplicateSlotName(options.name.to_string()));
63 }
64
65 let slot_id = allocate_lowest_slot(registry.occupied_ids());
66
67 let shiftable_ports: BTreeMap<String, u16> = config
69 .ports
70 .iter()
71 .filter(|(k, _)| !is_shared_service_port(k, &config.docker.shared_services))
72 .map(|(k, &v)| (k.clone(), v))
73 .collect();
74
75 let effective_ports = calculate_effective_ports(&shiftable_ports, slot_id)?;
76
77 create_worktree(
79 &toplevel,
80 &worktree_path,
81 &branch_name,
82 config.main_branch(),
83 )?;
84
85 let _ = propagate_environment_files(&toplevel, &worktree_path, &config.environment);
87
88 let mut effective_cache = config.cache.clone();
90 if !options.isolated
91 && !effective_cache.directories.iter().any(|d| d == ".venv")
92 && (config.environment.r#type.as_deref() == Some("venv")
93 || config.environment.r#type.as_deref() == Some("uv"))
94 && toplevel.join(".venv").exists()
95 {
96 effective_cache.directories.push(".venv".to_string());
97 }
98
99 let _ = link_shared_cache_directories(
100 &toplevel,
101 &worktree_path,
102 &effective_cache,
103 options.isolated,
104 );
105
106 let compose_project = sanitize_compose_project_name(&repo_name, options.name, slot_id);
107
108 let _ = copy_and_merge_untracked_files(
110 &toplevel,
111 &worktree_path,
112 &config.files.copy,
113 &shiftable_ports,
114 slot_id,
115 Some(&compose_project),
116 );
117
118 let _ = generate_docker_compose_override(&toplevel, &worktree_path, &config.docker);
120
121 write_env_worktree(
123 &worktree_path,
124 &repo_name,
125 options.name,
126 slot_id,
127 &effective_ports,
128 Some(&config.environment),
129 )?;
130
131 let now = SystemTime::now()
133 .duration_since(UNIX_EPOCH)
134 .unwrap_or_default()
135 .as_secs();
136
137 registry.slots.push(SlotRecord {
138 id: slot_id,
139 name: options.name.to_string(),
140 branch_type: options.branch_type.to_string(),
141 branch: branch_name.clone(),
142 path: worktree_path.to_string_lossy().to_string(),
143 allocated_at: now.to_string(),
144 });
145 registry.save_atomic(®istry_path)?;
146
147 drop(_lock);
149
150 let mut hook_env = BTreeMap::new();
152 hook_env.insert("CLAW_SLOT_ID".to_string(), slot_id.to_string());
153 hook_env.insert("CLAW_WORKTREE_NAME".to_string(), options.name.to_string());
154 hook_env.insert(
155 "CLAW_WORKTREE_PATH".to_string(),
156 worktree_path.to_string_lossy().to_string(),
157 );
158 for (k, v) in &effective_ports {
159 hook_env.insert(k.clone(), v.to_string());
160 }
161
162 let _ = run_post_start_hook(
164 config.hooks.post_start.as_deref(),
165 &worktree_path,
166 &hook_env,
167 );
168
169 crate::infra::herdr::open_worktree_in_herdr(&toplevel, &branch_name);
170
171 print_success(format!(
172 "Worktree '{}' started successfully (Slot: {}, Branch: {}, Path: {})",
173 options.name,
174 slot_id,
175 branch_name,
176 worktree_path.display()
177 ));
178
179 Ok(slot_id)
180}
181
182pub fn propagate_environment_files(
205 toplevel: &Path,
206 worktree_path: &Path,
207 env_config: &EnvironmentConfig,
208) -> std::io::Result<()> {
209 for file in &env_config.copy_files {
211 let file = file.trim();
212 if file.is_empty() {
213 continue;
214 }
215 let src = toplevel.join(file);
216 let dst = worktree_path.join(file);
217 if src.is_file() {
218 if let Some(parent) = dst.parent() {
219 fs::create_dir_all(parent)?;
220 }
221 fs::copy(&src, &dst)?;
222 }
223 }
224
225 for file in &[".python-version", ".node-version", ".nvmrc", ".envrc"] {
227 let src = toplevel.join(file);
228 let dst = worktree_path.join(file);
229 if src.is_file() && !dst.exists() {
230 if let Some(parent) = dst.parent() {
231 fs::create_dir_all(parent)?;
232 }
233 fs::copy(&src, &dst)?;
234 }
235 }
236
237 let py_version_dst = worktree_path.join(".python-version");
239 if !py_version_dst.exists() {
240 if let Some(ref name) = env_config.name {
241 let content = if name.ends_with('\n') {
242 name.clone()
243 } else {
244 format!("{}\n", name)
245 };
246 fs::write(&py_version_dst, content)?;
247 }
248 }
249
250 Ok(())
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256 use tempfile::tempdir;
257
258 #[test]
259 fn test_propagate_environment_files_declared_and_standard() {
260 let top = tempdir().unwrap();
261 let wt = tempdir().unwrap();
262
263 fs::write(top.path().join(".python-version"), "tune\n").unwrap();
264 fs::write(top.path().join(".nvmrc"), "20\n").unwrap();
265 fs::write(top.path().join("custom.env"), "FOO=BAR\n").unwrap();
266
267 let env = EnvironmentConfig {
268 r#type: Some("pyenv".to_string()),
269 name: Some("tune".to_string()),
270 copy_files: vec!["custom.env".to_string()],
271 };
272
273 propagate_environment_files(top.path(), wt.path(), &env).unwrap();
274
275 assert_eq!(
276 fs::read_to_string(wt.path().join(".python-version")).unwrap(),
277 "tune\n"
278 );
279 assert_eq!(
280 fs::read_to_string(wt.path().join(".nvmrc")).unwrap(),
281 "20\n"
282 );
283 assert_eq!(
284 fs::read_to_string(wt.path().join("custom.env")).unwrap(),
285 "FOO=BAR\n"
286 );
287 }
288
289 #[test]
290 fn test_propagate_environment_fallback_name() {
291 let top = tempdir().unwrap();
292 let wt = tempdir().unwrap();
293
294 let env = EnvironmentConfig {
295 r#type: Some("pyenv".to_string()),
296 name: Some("my-virtualenv".to_string()),
297 copy_files: vec![],
298 };
299
300 propagate_environment_files(top.path(), wt.path(), &env).unwrap();
301
302 assert_eq!(
303 fs::read_to_string(wt.path().join(".python-version")).unwrap(),
304 "my-virtualenv\n"
305 );
306 }
307}