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#[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)>, pub matched_configs: Vec<crate::agent::MatchedRepoConfig>,
20}
21
22pub struct NewWorkspaceOpts {
24 pub name: String,
25 pub branch: Option<String>,
27 pub random_branch: bool,
29 pub repos: Vec<RepoEntry>,
30 pub base_branch: Option<String>,
31 pub preset: Option<String>,
32}
33
34pub fn create_workspace(
39 config: &Config,
40 opts: NewWorkspaceOpts,
41 on_progress: impl Fn(super::ProgressEvent),
42) -> Result<NewWorkspaceResult> {
43 manifest::validate_name(&opts.name)?;
45
46 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 if opts.repos.is_empty() {
59 anyhow::bail!("Select at least one repository. A workspace requires at least one repo.");
60 }
61
62 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 let Some(ref base) = opts.base_branch {
69 for repo in &opts.repos {
70 let git_repo = GitRepo::new(&repo.path);
71 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 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 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 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 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 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 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 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 manifest::write_manifest(&ws_path.join(super::MANIFEST_FILENAME), &ws_manifest)?;
205
206 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 manifest::write_manifest(&ws_path.join(super::MANIFEST_FILENAME), &ws_manifest)?;
231
232 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
245fn 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 if let Err(e) = git_repo.fetch() {
257 tracing::warn!(repo = %repo.name, error = %e, "could not fetch, using local state");
258 }
259
260 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 cleanup_stale_loom_worktrees(&git_repo)?;
273
274 let worktree_path = ws_path.join(&repo.name);
276
277 match git_repo.worktree_add(&worktree_path, branch_name, &base) {
279 Ok(()) => {}
280 Err(crate::git::GitError::BranchConflict { .. }) => {
281 git_repo.worktree_remove(&worktree_path, true).ok(); 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 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
316fn cleanup_stale_loom_worktrees(git_repo: &GitRepo) -> Result<()> {
319 let worktrees = git_repo.worktree_list()?;
320
321 for wt in &worktrees {
322 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 if !wt.path.exists() {
335 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 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 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}