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;
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_repo_name, resolve_toplevel};
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::path::{Path, PathBuf};
21use std::time::{SystemTime, UNIX_EPOCH};
22
23pub struct StartOptions<'a> {
24    pub branch_type: BranchType,
25    pub name: &'a str,
26    pub isolated: bool,
27    pub repo_root: Option<&'a Path>,
28}
29
30/// Orchestrates starting a new worktree for `branch_type` and `name`.
31pub fn start_worktree(options: StartOptions<'_>) -> Result<u32, WorkflowError> {
32    let toplevel = resolve_toplevel(options.repo_root)?;
33    let common_dir = resolve_git_common_dir(Some(&toplevel))?;
34    let repo_name = resolve_repo_name(&toplevel);
35
36    let config_path = toplevel.join(".git-claw.toml");
37    let config = Config::load_or_default(&config_path, &repo_name)?;
38
39    let branch_name = options.branch_type.format_branch_name(options.name)?;
40
41    let raw_worktree_root = config.worktree_root(&repo_name);
42    let worktree_root = if Path::new(&raw_worktree_root).is_absolute() {
43        PathBuf::from(raw_worktree_root)
44    } else {
45        toplevel.join(raw_worktree_root)
46    };
47    let worktree_path = worktree_root.join(options.name);
48
49    let lock_path = common_dir.join("claw/slots.lock");
50    let registry_path = common_dir.join("claw/slots.json");
51
52    // Acquire exclusive advisory lock before touching registry or worktree
53    let _lock = SlotLock::acquire(&lock_path)?;
54
55    let mut registry = SlotRegistry::load_or_empty(&registry_path)?;
56
57    // Auto-GC stale worktrees
58    let _ = registry.auto_gc(&registry_path, Some(&toplevel), true);
59
60    if registry.find_by_name(options.name).is_some() {
61        return Err(WorkflowError::DuplicateSlotName(options.name.to_string()));
62    }
63
64    let slot_id = allocate_lowest_slot(registry.occupied_ids());
65
66    // Filter out ports associated with shared services so client connections to shared containers are never broken
67    let shiftable_ports: BTreeMap<String, u16> = config
68        .ports
69        .iter()
70        .filter(|(k, _)| !is_shared_service_port(k, &config.docker.shared_services))
71        .map(|(k, &v)| (k.clone(), v))
72        .collect();
73
74    let effective_ports = calculate_effective_ports(&shiftable_ports, slot_id)?;
75
76    // Create the Git worktree with the new branch
77    create_worktree(
78        &toplevel,
79        &worktree_path,
80        &branch_name,
81        config.main_branch(),
82    )?;
83
84    // Write .env.worktree
85    write_env_worktree(
86        &worktree_path,
87        &repo_name,
88        options.name,
89        slot_id,
90        &effective_ports,
91    )?;
92
93    let compose_project = sanitize_compose_project_name(&repo_name, options.name, slot_id);
94
95    // Copy declared untracked files and update port variables in-place
96    let _ = copy_and_merge_untracked_files(
97        &toplevel,
98        &worktree_path,
99        &config.files.copy,
100        &shiftable_ports,
101        slot_id,
102        Some(&compose_project),
103    );
104
105    // Generate docker-compose.claw.override.yml if configured
106    let _ = generate_docker_compose_override(&toplevel, &worktree_path, &config.docker);
107
108    // Link shared cache directories
109    let _ =
110        link_shared_cache_directories(&toplevel, &worktree_path, &config.cache, options.isolated);
111
112    // Record in registry and persist atomically
113    let now = SystemTime::now()
114        .duration_since(UNIX_EPOCH)
115        .unwrap_or_default()
116        .as_secs();
117
118    registry.slots.push(SlotRecord {
119        id: slot_id,
120        name: options.name.to_string(),
121        branch_type: options.branch_type.to_string(),
122        branch: branch_name.clone(),
123        path: worktree_path.to_string_lossy().to_string(),
124        allocated_at: now.to_string(),
125    });
126    registry.save_atomic(&registry_path)?;
127
128    // Drop lock before running hook
129    drop(_lock);
130
131    // Prepare hook environment
132    let mut hook_env = BTreeMap::new();
133    hook_env.insert("CLAW_SLOT_ID".to_string(), slot_id.to_string());
134    hook_env.insert("CLAW_WORKTREE_NAME".to_string(), options.name.to_string());
135    hook_env.insert(
136        "CLAW_WORKTREE_PATH".to_string(),
137        worktree_path.to_string_lossy().to_string(),
138    );
139    for (k, v) in &effective_ports {
140        hook_env.insert(k.clone(), v.to_string());
141    }
142
143    // Run post_start hook if configured
144    let _ = run_post_start_hook(
145        config.hooks.post_start.as_deref(),
146        &worktree_path,
147        &hook_env,
148    );
149
150    crate::infra::herdr::open_worktree_in_herdr(&toplevel, &branch_name);
151
152    print_success(format!(
153        "Worktree '{}' started successfully (Slot: {}, Branch: {}, Path: {})",
154        options.name,
155        slot_id,
156        branch_name,
157        worktree_path.display()
158    ));
159
160    Ok(slot_id)
161}