Skip to main content

loom_core/workspace/
new.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4use chrono::Utc;
5
6use crate::config::Config;
7use crate::git::GitRepo;
8use crate::manifest::{self, RepoManifestEntry, WorkspaceIndex, WorkspaceManifest};
9use crate::registry::RepoEntry;
10
11/// Result of creating a workspace.
12#[derive(Debug)]
13pub struct NewWorkspaceResult {
14    pub path: PathBuf,
15    pub name: String,
16    pub branch: String,
17    pub repos_added: usize,
18    pub repos_failed: Vec<(String, String)>, // (name, error message)
19    pub matched_configs: Vec<crate::agent::MatchedRepoConfig>,
20}
21
22/// Options for creating a new workspace.
23pub struct NewWorkspaceOpts {
24    pub name: String,
25    /// Explicit branch name override (--branch).
26    pub branch: Option<String>,
27    /// Force random branch name instead of deriving from workspace name (--random-branch).
28    pub random_branch: bool,
29    pub repos: Vec<RepoEntry>,
30    pub base_branch: Option<String>,
31    pub preset: Option<String>,
32}
33
34/// Create a new workspace with worktrees for the selected repos.
35///
36/// The `on_progress` callback receives events for each repo processed,
37/// allowing the CLI layer to drive a progress bar.
38pub fn create_workspace(
39    config: &Config,
40    opts: NewWorkspaceOpts,
41    on_progress: impl Fn(super::ProgressEvent),
42) -> Result<NewWorkspaceResult> {
43    // Validate workspace name
44    manifest::validate_name(&opts.name)?;
45
46    // Check for name collision
47    let ws_path = config.workspace.root.join(&opts.name);
48    if ws_path.exists() {
49        anyhow::bail!(
50            "Workspace '{}' already exists at {}. Choose a different name or run `loom down {}` first.",
51            opts.name,
52            ws_path.display(),
53            opts.name
54        );
55    }
56
57    // Validate repos
58    if opts.repos.is_empty() {
59        anyhow::bail!("Select at least one repository. A workspace requires at least one repo.");
60    }
61
62    // Validate preset exists in config (if provided)
63    if let Some(ref preset_name) = opts.preset {
64        crate::config::validate_preset_exists(&config.agents.claude_code.presets, preset_name)?;
65    }
66
67    // If --base is set, fetch and validate all repos have the ref
68    if let Some(ref base) = opts.base_branch {
69        for repo in &opts.repos {
70            let git_repo = GitRepo::new(&repo.path);
71            // Fetch so remote refs are available for validation
72            if let Err(e) = git_repo.fetch() {
73                tracing::warn!(repo = %repo.name, error = %e, "could not fetch, using local state");
74            }
75            if !git_repo.ref_exists(base)? {
76                let hint = if !base.contains('/') {
77                    let remote_ref = format!("origin/{}", base);
78                    if git_repo.ref_exists(&remote_ref).unwrap_or(false) {
79                        format!(
80                            "\nHint: 'origin/{}' exists — use `--base origin/{}` for remote branches.",
81                            base, base
82                        )
83                    } else {
84                        String::new()
85                    }
86                } else {
87                    String::new()
88                };
89                anyhow::bail!("Ref '{}' not found in {}.{}", base, repo.name, hint);
90            }
91        }
92    }
93
94    // Create workspace directory
95    std::fs::create_dir_all(&ws_path).with_context(|| {
96        format!(
97            "Failed to create workspace directory at {}",
98            ws_path.display()
99        )
100    })?;
101
102    let branch_prefix = &config.defaults.branch_prefix;
103    let now = Utc::now();
104
105    // Determine branch name: explicit --branch > --random-branch > derive from workspace name
106    let repo_paths: Vec<PathBuf> = opts.repos.iter().map(|r| r.path.clone()).collect();
107    let branch_name = if let Some(explicit) = opts.branch {
108        let candidate = if explicit.contains('/') {
109            explicit
110        } else {
111            format!("{branch_prefix}/{explicit}")
112        };
113        // Validate using git's own ref-format rules (covers all edge cases)
114        let check = std::process::Command::new("git")
115            .args(["check-ref-format", "--branch", &candidate])
116            .env("LC_ALL", "C")
117            .output();
118        match check {
119            Ok(output) if !output.status.success() => {
120                anyhow::bail!(
121                    "Invalid branch name '{}'. git check-ref-format rejected it.",
122                    candidate
123                );
124            }
125            Err(e) => {
126                tracing::warn!("git check-ref-format failed: {e}, skipping validation");
127            }
128            _ => {}
129        }
130        candidate
131    } else if opts.random_branch {
132        crate::names::generate_unique_branch_name(
133            branch_prefix,
134            &repo_paths,
135            crate::names::MAX_NAME_RETRIES,
136        )?
137    } else {
138        format!("{branch_prefix}/{}", opts.name)
139    };
140
141    // Check for branch collisions in all repos
142    for repo in &opts.repos {
143        let git = GitRepo::new(&repo.path);
144        if git.ref_exists(&branch_name).unwrap_or(false) {
145            anyhow::bail!(
146                "Branch '{}' already exists in {}. Use `loom down` to remove the old workspace first, \
147                 or choose a different workspace name.",
148                branch_name,
149                repo.name
150            );
151        }
152    }
153
154    // Write state.json FIRST (with 0 repos) — Ctrl+C safety
155    let state_path = config.workspace.root.join(".loom").join("state.json");
156    let mut state = manifest::read_global_state(&state_path);
157    state.upsert(WorkspaceIndex {
158        name: opts.name.clone(),
159        path: ws_path.clone(),
160        created: now,
161        repo_count: 0,
162    });
163    manifest::write_global_state(&state_path, &state)?;
164
165    // Initialize workspace manifest
166    let mut ws_manifest = WorkspaceManifest {
167        name: opts.name.clone(),
168        branch: Some(branch_name.clone()),
169        created: now,
170        base_branch: opts.base_branch.clone(),
171        preset: opts.preset.clone(),
172        repos: Vec::new(),
173    };
174
175    let mut repos_added = 0;
176    let mut repos_failed = Vec::new();
177
178    // Process each repo
179    let total = opts.repos.len();
180    for (i, repo) in opts.repos.iter().enumerate() {
181        on_progress(super::ProgressEvent::RepoStarted {
182            name: repo.name.clone(),
183            index: i,
184            total,
185        });
186        tracing::debug!(
187            repo = %repo.name,
188            index = i,
189            total,
190            "adding repo to workspace"
191        );
192        match add_repo_to_workspace(
193            &ws_path,
194            repo,
195            &branch_name,
196            opts.base_branch.as_deref(),
197            &opts.name,
198        ) {
199            Ok(entry) => {
200                ws_manifest.repos.push(entry);
201                repos_added += 1;
202
203                // Write manifest after each successful repo (Ctrl+C safety)
204                manifest::write_manifest(&ws_path.join(super::MANIFEST_FILENAME), &ws_manifest)?;
205
206                // Update state.json repo count
207                state.upsert(WorkspaceIndex {
208                    name: opts.name.clone(),
209                    path: ws_path.clone(),
210                    created: now,
211                    repo_count: repos_added,
212                });
213                manifest::write_global_state(&state_path, &state)?;
214                on_progress(super::ProgressEvent::RepoComplete {
215                    name: repo.name.clone(),
216                });
217            }
218            Err(e) => {
219                let msg = e.to_string();
220                on_progress(super::ProgressEvent::RepoFailed {
221                    name: repo.name.clone(),
222                    error: msg.clone(),
223                });
224                repos_failed.push((repo.name.clone(), msg));
225            }
226        }
227    }
228
229    // Final manifest write
230    manifest::write_manifest(&ws_path.join(super::MANIFEST_FILENAME), &ws_manifest)?;
231
232    // Generate agent files (CLAUDE.md, .claude/settings.json)
233    let matched_configs = crate::agent::generate_agent_files(config, &ws_path, &ws_manifest)?;
234
235    Ok(NewWorkspaceResult {
236        path: ws_path,
237        name: opts.name,
238        branch: branch_name,
239        repos_added,
240        repos_failed,
241        matched_configs,
242    })
243}
244
245/// Add a single repo to a workspace: create worktree, lock it, update manifest.
246fn add_repo_to_workspace(
247    ws_path: &Path,
248    repo: &RepoEntry,
249    branch_name: &str,
250    base_branch: Option<&str>,
251    ws_name: &str,
252) -> Result<RepoManifestEntry> {
253    let git_repo = GitRepo::new(&repo.path);
254
255    // Fetch latest state from origin (non-fatal)
256    if let Err(e) = git_repo.fetch() {
257        tracing::warn!(repo = %repo.name, error = %e, "could not fetch, using local state");
258    }
259
260    // Determine base branch
261    let base = match base_branch {
262        Some(b) => b.to_string(),
263        None => {
264            let branch = git_repo
265                .default_branch()
266                .unwrap_or_else(|_| "main".to_string());
267            git_repo.resolve_start_point(&branch)
268        }
269    };
270
271    // Targeted stale cleanup: remove only LOOM-owned stale worktrees
272    cleanup_stale_loom_worktrees(&git_repo)?;
273
274    // Create worktree directory
275    let worktree_path = ws_path.join(&repo.name);
276
277    // Add worktree
278    match git_repo.worktree_add(&worktree_path, branch_name, &base) {
279        Ok(()) => {}
280        Err(crate::git::GitError::BranchConflict { .. }) => {
281            // Branch already exists — reuse it (safest non-destructive default)
282            // The worktree_add failed, so try with the existing branch
283            // Remove the -b flag by using worktree add with just the path and branch
284            git_repo.worktree_remove(&worktree_path, true).ok(); // Clean up partial add
285            std::process::Command::new("git")
286                .arg("-C")
287                .arg(git_repo.path())
288                .args([
289                    "worktree",
290                    "add",
291                    &worktree_path.to_string_lossy(),
292                    branch_name,
293                ])
294                .env("LC_ALL", "C")
295                .output()
296                .context("Failed to add worktree with existing branch")?;
297        }
298        Err(e) => return Err(e.into()),
299    }
300
301    // Lock worktree with loom identifier
302    let lock_reason = format!("loom:{ws_name}");
303    git_repo.worktree_lock(&worktree_path, &lock_reason)?;
304
305    let remote_url = git_repo.remote_url()?.unwrap_or_default();
306
307    Ok(RepoManifestEntry {
308        name: repo.name.clone(),
309        original_path: repo.path.clone(),
310        worktree_path,
311        branch: branch_name.to_string(),
312        remote_url,
313    })
314}
315
316/// Remove only LOOM-owned worktrees whose directory no longer exists.
317/// Does NOT use `git worktree prune` (which is global and would remove non-LOOM worktrees).
318fn cleanup_stale_loom_worktrees(git_repo: &GitRepo) -> Result<()> {
319    let worktrees = git_repo.worktree_list()?;
320
321    for wt in &worktrees {
322        // Only touch LOOM-owned worktrees
323        let is_loom_owned = wt
324            .lock_reason
325            .as_deref()
326            .is_some_and(|r| r.starts_with("loom:"))
327            || wt.branch.as_deref().is_some_and(|b| b.starts_with("loom/"));
328
329        if !is_loom_owned {
330            continue;
331        }
332
333        // Only remove if the directory no longer exists
334        if !wt.path.exists() {
335            // Unlock if locked
336            if wt.is_locked {
337                git_repo.worktree_unlock(&wt.path).ok();
338            }
339            git_repo.worktree_remove(&wt.path, true).ok();
340        }
341    }
342
343    Ok(())
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349    use crate::config::{
350        AgentsConfig, DefaultsConfig, RegistryConfig, UpdateConfig, WorkspaceConfig,
351    };
352    use std::collections::BTreeMap;
353
354    fn test_config(dir: &std::path::Path) -> Config {
355        let ws_root = dir.join("loom");
356        std::fs::create_dir_all(ws_root.join(".loom")).unwrap();
357        Config {
358            registry: RegistryConfig {
359                scan_roots: vec![],
360                scan_depth: 2,
361            },
362            workspace: WorkspaceConfig { root: ws_root },
363            sync: None,
364            terminal: None,
365            editor: None,
366            defaults: DefaultsConfig::default(),
367            groups: BTreeMap::new(),
368            repos: BTreeMap::new(),
369            specs: None,
370            agents: AgentsConfig::default(),
371            update: UpdateConfig::default(),
372        }
373    }
374
375    fn create_repo(dir: &std::path::Path, org: &str, name: &str) -> RepoEntry {
376        let path = dir.join(org).join(name);
377        std::fs::create_dir_all(&path).unwrap();
378        std::process::Command::new("git")
379            .args(["init", "-b", "main", &path.to_string_lossy()])
380            .env("LC_ALL", "C")
381            .output()
382            .unwrap();
383        std::process::Command::new("git")
384            .args([
385                "-C",
386                &path.to_string_lossy(),
387                "commit",
388                "--allow-empty",
389                "-m",
390                "init",
391            ])
392            .env("LC_ALL", "C")
393            .output()
394            .unwrap();
395
396        RepoEntry {
397            name: name.to_string(),
398            org: org.to_string(),
399            path,
400            remote_url: None,
401        }
402    }
403
404    #[test]
405    fn test_create_workspace_basic() {
406        let dir = tempfile::tempdir().unwrap();
407        let config = test_config(dir.path());
408        let repo = create_repo(dir.path(), "org", "my-repo");
409
410        let result = create_workspace(
411            &config,
412            NewWorkspaceOpts {
413                name: "test-ws".to_string(),
414                branch: None,
415                random_branch: false,
416                repos: vec![repo],
417                base_branch: None,
418                preset: None,
419            },
420            |_| {},
421        )
422        .unwrap();
423
424        assert_eq!(result.name, "test-ws");
425        assert_eq!(result.branch, "loom/test-ws");
426        assert_eq!(result.repos_added, 1);
427        assert!(result.repos_failed.is_empty());
428        assert!(result.path.join(super::super::MANIFEST_FILENAME).exists());
429    }
430
431    #[test]
432    fn test_create_workspace_name_collision() {
433        let dir = tempfile::tempdir().unwrap();
434        let config = test_config(dir.path());
435        let repo = create_repo(dir.path(), "org", "my-repo");
436
437        // Create the workspace directory to simulate collision
438        std::fs::create_dir_all(config.workspace.root.join("existing")).unwrap();
439
440        let result = create_workspace(
441            &config,
442            NewWorkspaceOpts {
443                name: "existing".to_string(),
444                branch: None,
445                random_branch: false,
446                repos: vec![repo],
447                base_branch: None,
448                preset: None,
449            },
450            |_| {},
451        );
452
453        assert!(result.is_err());
454        assert!(result.unwrap_err().to_string().contains("already exists"));
455    }
456
457    #[test]
458    fn test_create_workspace_empty_repos() {
459        let dir = tempfile::tempdir().unwrap();
460        let config = test_config(dir.path());
461
462        let result = create_workspace(
463            &config,
464            NewWorkspaceOpts {
465                name: "empty".to_string(),
466                branch: None,
467                random_branch: false,
468                repos: vec![],
469                base_branch: None,
470                preset: None,
471            },
472            |_| {},
473        );
474
475        assert!(result.is_err());
476        assert!(result.unwrap_err().to_string().contains("at least one"));
477    }
478
479    #[test]
480    fn test_create_workspace_invalid_name() {
481        let dir = tempfile::tempdir().unwrap();
482        let config = test_config(dir.path());
483
484        let result = create_workspace(
485            &config,
486            NewWorkspaceOpts {
487                name: "INVALID NAME".to_string(),
488                branch: None,
489                random_branch: false,
490                repos: vec![],
491                base_branch: None,
492                preset: None,
493            },
494            |_| {},
495        );
496
497        assert!(result.is_err());
498    }
499
500    #[test]
501    fn test_create_workspace_state_written_first() {
502        let dir = tempfile::tempdir().unwrap();
503        let config = test_config(dir.path());
504        let repo = create_repo(dir.path(), "org", "my-repo");
505
506        create_workspace(
507            &config,
508            NewWorkspaceOpts {
509                name: "state-test".to_string(),
510                branch: None,
511                random_branch: false,
512                repos: vec![repo],
513                base_branch: None,
514                preset: None,
515            },
516            |_| {},
517        )
518        .unwrap();
519
520        // Verify state.json exists and has the workspace
521        let state_path = config.workspace.root.join(".loom").join("state.json");
522        let state = manifest::read_global_state(&state_path);
523        assert!(state.find("state-test").is_some());
524        assert_eq!(state.find("state-test").unwrap().repo_count, 1);
525    }
526}