Skip to main content

git_claw/workflow/
start.rs

1//! Start worktree lifecycle orchestration.
2
3use 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
31/// Orchestrates starting a new worktree for `branch_type` and `name`.
32pub 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    // Acquire exclusive advisory lock before touching registry or worktree
54    let _lock = SlotLock::acquire(&lock_path)?;
55
56    let mut registry = SlotRegistry::load_or_empty(&registry_path)?;
57
58    // Auto-GC stale worktrees
59    let _ = registry.auto_gc(&registry_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    // Filter out ports associated with shared services so client connections to shared containers are never broken
68    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 the Git worktree with the new branch
78    create_worktree(
79        &toplevel,
80        &worktree_path,
81        &branch_name,
82        config.main_branch(),
83    )?;
84
85    // Propagate runtime environment files
86    let _ = propagate_environment_files(&toplevel, &worktree_path, &config.environment);
87
88    // Link shared cache directories (including .venv if configured or if env type is venv/uv)
89    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    // Copy declared untracked files and update port variables in-place
109    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    // Generate docker-compose.claw.override.yml if configured
119    let _ = generate_docker_compose_override(&toplevel, &worktree_path, &config.docker);
120
121    // Write .env.worktree
122    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    // Record in registry and persist atomically
132    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(&registry_path)?;
146
147    // Drop lock before running hook
148    drop(_lock);
149
150    // Prepare hook environment
151    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    // Run post_start hook if configured
163    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
182/// Propagates environment configuration files from the primary repository to the worktree.
183///
184/// Handles declared files in `environment.copy_files`, standard runtime files (`.python-version`,
185/// `.node-version`, `.nvmrc`, `.envrc`), and writes `.python-version` from `environment.name`
186/// if absent.
187///
188/// # Examples
189///
190/// ```
191/// use git_claw::workflow::start::propagate_environment_files;
192/// use git_claw::core::config::EnvironmentConfig;
193/// use tempfile::tempdir;
194/// use std::fs;
195///
196/// let top = tempdir().unwrap();
197/// let wt = tempdir().unwrap();
198/// fs::write(top.path().join(".python-version"), "3.12.0\n").unwrap();
199///
200/// let env = EnvironmentConfig::default();
201/// propagate_environment_files(top.path(), wt.path(), &env).unwrap();
202/// assert_eq!(fs::read_to_string(wt.path().join(".python-version")).unwrap(), "3.12.0\n");
203/// ```
204pub fn propagate_environment_files(
205    toplevel: &Path,
206    worktree_path: &Path,
207    env_config: &EnvironmentConfig,
208) -> std::io::Result<()> {
209    // 1. Copy files declared in environment.copy_files
210    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    // 2. Check standard version files: .python-version, .node-version, .nvmrc, .envrc
226    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    // 3. Fallback: if environment.name is set and .python-version does not exist in worktree, write it
238    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}