1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4
5use crate::config::Config;
6use crate::git::repo::{GitRepo, clone_repo};
7use crate::manifest::sync::{SyncManifest, SyncRepoEntry};
8use crate::manifest::{self, RepoManifestEntry, WorkspaceIndex, WorkspaceManifest};
9use crate::registry;
10use crate::registry::url::normalize_url;
11use crate::workspace::MANIFEST_FILENAME;
12
13#[derive(Debug)]
15pub struct OpenResult {
16 pub path: PathBuf,
17 pub name: String,
18 pub repos_restored: usize,
19 pub repos_cloned: Vec<String>,
20 pub repos_failed: Vec<(String, String)>,
21 pub warnings: Vec<String>,
22 pub matched_configs: Vec<crate::agent::MatchedRepoConfig>,
23}
24
25pub fn open_workspace(config: &Config, name: &str) -> Result<OpenResult> {
36 let sync_config = config
37 .sync
38 .as_ref()
39 .ok_or_else(|| anyhow::anyhow!(
40 "Sync is not configured. `loom open` requires a sync repo to reconstruct workspaces. \
41 Set `[sync]` in `~/.config/loom/config.toml` or use `loom new` to create a local workspace."
42 ))?;
43
44 let sync_git = GitRepo::new(&sync_config.repo);
46 if let Err(e) = sync_git.pull_rebase() {
47 tracing::warn!(error = %e, "could not pull sync repo, using local copy");
48 }
49
50 let manifest_path = sync_config
52 .repo
53 .join(&sync_config.path)
54 .join(format!("{name}.json"));
55 if !manifest_path.exists() {
56 anyhow::bail!(
57 "Workspace '{}' not found in sync repo at {}",
58 name,
59 manifest_path.display()
60 );
61 }
62
63 let content = std::fs::read_to_string(&manifest_path).with_context(|| {
64 format!(
65 "Failed to read sync manifest at {}",
66 manifest_path.display()
67 )
68 })?;
69 let sync_manifest: SyncManifest = serde_json::from_str(&content).with_context(|| {
70 format!(
71 "Failed to parse sync manifest at {}",
72 manifest_path.display()
73 )
74 })?;
75
76 let ws_path = config.workspace.root.join(name);
78 let existing_manifest = if ws_path.join(MANIFEST_FILENAME).exists() {
79 Some(manifest::read_manifest(&ws_path.join(MANIFEST_FILENAME))?)
80 } else {
81 None
82 };
83
84 let local_repos = registry::discover_repos(
86 &config.registry.scan_roots,
87 Some(&config.workspace.root),
88 config.registry.scan_depth,
89 );
90
91 let mut repos_restored = 0;
92 let mut repos_cloned = Vec::new();
93 let mut repos_failed = Vec::new();
94 let mut warnings = Vec::new();
95 let mut ws_repos = Vec::new();
96
97 for sync_repo in &sync_manifest.repos {
98 match restore_repo(
99 config,
100 &ws_path,
101 sync_repo,
102 &local_repos,
103 existing_manifest.as_ref(),
104 name,
105 ) {
106 Ok(RestoreResult::Restored(entry)) => {
107 ws_repos.push(entry);
108 repos_restored += 1;
109 }
110 Ok(RestoreResult::Cloned(entry)) => {
111 repos_cloned.push(sync_repo.name.clone());
112 ws_repos.push(entry);
113 repos_restored += 1;
114 }
115 Ok(RestoreResult::Skipped(warning)) => {
116 warnings.push(warning);
117 }
118 Err(e) => {
119 repos_failed.push((sync_repo.name.clone(), e.to_string()));
120 }
121 }
122 }
123
124 if let Some(ref existing) = existing_manifest {
126 for local_repo in &existing.repos {
127 let in_sync = sync_manifest
128 .repos
129 .iter()
130 .any(|r| r.name == local_repo.name);
131 if !in_sync {
132 warnings.push(format!(
133 "Repo '{}' exists locally but not in sync manifest (keeping it).",
134 local_repo.name
135 ));
136 ws_repos.push(local_repo.clone());
137 }
138 }
139 }
140
141 std::fs::create_dir_all(&ws_path)
143 .with_context(|| format!("Failed to create workspace directory {}", ws_path.display()))?;
144
145 let existing_preset = existing_manifest.as_ref().and_then(|m| m.preset.clone());
147 let ws_manifest = WorkspaceManifest {
148 name: name.to_string(),
149 branch: sync_manifest.branch.clone(),
150 created: sync_manifest.created,
151 base_branch: None,
152 preset: existing_preset,
153 repos: ws_repos,
154 };
155 manifest::write_manifest(&ws_path.join(MANIFEST_FILENAME), &ws_manifest)?;
156
157 let matched_configs = crate::agent::generate_agent_files(config, &ws_path, &ws_manifest)?;
159
160 let state_path = config.workspace.root.join(".loom").join("state.json");
162 std::fs::create_dir_all(state_path.parent().unwrap()).ok();
163 let mut state = manifest::read_global_state(&state_path);
164 state.upsert(WorkspaceIndex {
165 name: name.to_string(),
166 path: ws_path.clone(),
167 created: ws_manifest.created,
168 repo_count: ws_manifest.repos.len(),
169 });
170 manifest::write_global_state(&state_path, &state)?;
171
172 Ok(OpenResult {
173 path: ws_path,
174 name: name.to_string(),
175 repos_restored,
176 repos_cloned,
177 repos_failed,
178 warnings,
179 matched_configs,
180 })
181}
182
183enum RestoreResult {
184 Restored(RepoManifestEntry),
185 Cloned(RepoManifestEntry),
186 Skipped(String),
187}
188
189fn restore_repo(
191 config: &Config,
192 ws_path: &Path,
193 sync_repo: &SyncRepoEntry,
194 local_repos: &[registry::RepoEntry],
195 existing_manifest: Option<&WorkspaceManifest>,
196 ws_name: &str,
197) -> Result<RestoreResult> {
198 if let Some(existing) = existing_manifest
200 && let Some(entry) = existing.repos.iter().find(|r| r.name == sync_repo.name)
201 && entry.worktree_path.exists()
202 {
203 let git = GitRepo::new(&entry.worktree_path);
205 let current_branch = git.current_branch().unwrap_or_default();
206 if current_branch != sync_repo.branch {
207 return Ok(RestoreResult::Skipped(format!(
208 "Repo '{}' already exists on branch '{}' (sync expects '{}').",
209 sync_repo.name, current_branch, sync_repo.branch
210 )));
211 }
212 return Ok(RestoreResult::Restored(entry.clone()));
213 }
214
215 let sync_canonical = normalize_url(&sync_repo.remote_url);
217 let local_match = local_repos.iter().find(|r| {
218 r.remote_url
219 .as_deref()
220 .is_some_and(|url| normalize_url(url) == sync_canonical)
221 });
222
223 let repo_path = match local_match {
224 Some(local) => local.path.clone(),
225 None => {
226 let clone_target = derive_clone_path(config, &sync_repo.remote_url)?;
228
229 if clone_target.exists() {
231 let existing_git = GitRepo::new(&clone_target);
232 if let Ok(Some(url)) = existing_git.remote_url()
233 && normalize_url(&url) != sync_canonical
234 {
235 anyhow::bail!(
236 "Directory {} exists but remote URL differs (expected {}, found {}). \
237 Clone manually or update config.",
238 clone_target.display(),
239 sync_repo.remote_url,
240 url
241 );
242 }
243 clone_target
244 } else {
245 clone_repo(&sync_repo.remote_url, &clone_target).with_context(|| {
246 format!(
247 "Failed to clone {} to {}",
248 sync_repo.remote_url,
249 clone_target.display()
250 )
251 })?;
252 clone_target
253 }
254 }
255 };
256
257 let git = GitRepo::new(&repo_path);
259 git.fetch().ok(); let worktree_path = ws_path.join(&sync_repo.name);
263 let branch_name = sync_repo.branch.clone();
264
265 if !worktree_path.exists() {
266 match git.worktree_add(&worktree_path, &branch_name, &sync_repo.branch) {
268 Ok(()) => {}
269 Err(crate::git::GitError::BranchConflict { .. }) => {
270 git.worktree_remove(&worktree_path, true).ok();
272 std::process::Command::new("git")
273 .arg("-C")
274 .arg(git.path())
275 .args([
276 "worktree",
277 "add",
278 &worktree_path.to_string_lossy(),
279 &branch_name,
280 ])
281 .env("LC_ALL", "C")
282 .output()
283 .context("Failed to add worktree with existing branch")?;
284 }
285 Err(e) => return Err(e.into()),
286 }
287
288 let lock_reason = format!("loom:{ws_name}");
290 git.worktree_lock(&worktree_path, &lock_reason).ok();
291 }
292
293 let was_cloned = local_match.is_none();
294 let entry = RepoManifestEntry {
295 name: sync_repo.name.clone(),
296 original_path: repo_path,
297 worktree_path,
298 branch: branch_name,
299 remote_url: sync_repo.remote_url.clone(),
300 };
301
302 if was_cloned {
303 Ok(RestoreResult::Cloned(entry))
304 } else {
305 Ok(RestoreResult::Restored(entry))
306 }
307}
308
309fn derive_clone_path(config: &Config, remote_url: &str) -> Result<PathBuf> {
314 let scan_root =
315 config.registry.scan_roots.first().ok_or_else(|| {
316 anyhow::anyhow!("No scan roots configured. Cannot derive clone path.")
317 })?;
318
319 let canonical = normalize_url(remote_url)
320 .ok_or_else(|| anyhow::anyhow!("Cannot normalize URL '{}'", remote_url))?;
321 let canonical_str = canonical.as_str();
322 let parts: Vec<&str> = canonical_str.splitn(2, '/').collect();
325 if parts.len() < 2 {
326 anyhow::bail!(
327 "Cannot derive clone path from URL '{}' (canonical: '{}')",
328 remote_url,
329 canonical_str
330 );
331 }
332
333 Ok(scan_root.join(parts[1]))
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340 use crate::config::{
341 AgentsConfig, DefaultsConfig, RegistryConfig, SyncConfig, UpdateConfig, WorkspaceConfig,
342 };
343 use std::collections::BTreeMap;
344
345 #[test]
346 fn test_derive_clone_path() {
347 let config = Config {
348 registry: RegistryConfig {
349 scan_roots: vec![PathBuf::from("/code")],
350 scan_depth: 2,
351 },
352 workspace: WorkspaceConfig {
353 root: PathBuf::from("/loom"),
354 },
355 sync: None,
356 terminal: None,
357 editor: None,
358 defaults: DefaultsConfig::default(),
359 groups: BTreeMap::new(),
360 repos: BTreeMap::new(),
361 specs: None,
362 agents: AgentsConfig::default(),
363 update: UpdateConfig::default(),
364 };
365
366 let path = derive_clone_path(&config, "git@github.com:dasch-swiss/dsp-api.git").unwrap();
367 assert_eq!(path, PathBuf::from("/code/dasch-swiss/dsp-api"));
368 }
369
370 #[test]
371 fn test_derive_clone_path_https() {
372 let config = Config {
373 registry: RegistryConfig {
374 scan_roots: vec![PathBuf::from("/home/user/code")],
375 scan_depth: 2,
376 },
377 workspace: WorkspaceConfig {
378 root: PathBuf::from("/loom"),
379 },
380 sync: None,
381 terminal: None,
382 editor: None,
383 defaults: DefaultsConfig::default(),
384 groups: BTreeMap::new(),
385 repos: BTreeMap::new(),
386 specs: None,
387 agents: AgentsConfig::default(),
388 update: UpdateConfig::default(),
389 };
390
391 let path = derive_clone_path(&config, "https://github.com/org/repo.git").unwrap();
392 assert_eq!(path, PathBuf::from("/home/user/code/org/repo"));
393 }
394
395 #[test]
396 fn test_derive_clone_path_no_scan_roots() {
397 let config = Config {
398 registry: RegistryConfig {
399 scan_roots: vec![],
400 scan_depth: 2,
401 },
402 workspace: WorkspaceConfig {
403 root: PathBuf::from("/loom"),
404 },
405 sync: None,
406 terminal: None,
407 editor: None,
408 defaults: DefaultsConfig::default(),
409 groups: BTreeMap::new(),
410 repos: BTreeMap::new(),
411 specs: None,
412 agents: AgentsConfig::default(),
413 update: UpdateConfig::default(),
414 };
415
416 let result = derive_clone_path(&config, "git@github.com:org/repo.git");
417 assert!(result.is_err());
418 }
419
420 #[test]
421 fn test_open_no_sync_config() {
422 let dir = tempfile::tempdir().unwrap();
423 let config = Config {
424 registry: RegistryConfig {
425 scan_roots: vec![],
426 scan_depth: 2,
427 },
428 workspace: WorkspaceConfig {
429 root: dir.path().to_path_buf(),
430 },
431 sync: None,
432 terminal: None,
433 editor: None,
434 defaults: DefaultsConfig::default(),
435 groups: BTreeMap::new(),
436 repos: BTreeMap::new(),
437 specs: None,
438 agents: AgentsConfig::default(),
439 update: UpdateConfig::default(),
440 };
441
442 let result = open_workspace(&config, "test-ws");
443 assert!(result.is_err());
444 assert!(
445 result
446 .unwrap_err()
447 .to_string()
448 .contains("Sync is not configured")
449 );
450 }
451
452 #[test]
453 fn test_open_missing_manifest() {
454 let dir = tempfile::tempdir().unwrap();
455
456 let sync_repo_path = dir.path().join("sync-repo");
458 std::fs::create_dir_all(&sync_repo_path).unwrap();
459 std::process::Command::new("git")
460 .args(["init", "-b", "main", &sync_repo_path.to_string_lossy()])
461 .env("LC_ALL", "C")
462 .output()
463 .unwrap();
464 std::process::Command::new("git")
465 .args([
466 "-C",
467 &sync_repo_path.to_string_lossy(),
468 "commit",
469 "--allow-empty",
470 "-m",
471 "init",
472 ])
473 .env("LC_ALL", "C")
474 .output()
475 .unwrap();
476
477 let ws_root = dir.path().join("loom");
478 std::fs::create_dir_all(ws_root.join(".loom")).unwrap();
479
480 let config = Config {
481 registry: RegistryConfig {
482 scan_roots: vec![],
483 scan_depth: 2,
484 },
485 workspace: WorkspaceConfig { root: ws_root },
486 sync: Some(SyncConfig {
487 repo: sync_repo_path,
488 path: "loom".to_string(),
489 }),
490 terminal: None,
491 editor: None,
492 defaults: DefaultsConfig::default(),
493 groups: BTreeMap::new(),
494 repos: BTreeMap::new(),
495 specs: None,
496 agents: AgentsConfig::default(),
497 update: UpdateConfig::default(),
498 };
499
500 let result = open_workspace(&config, "nonexistent-ws");
501 assert!(result.is_err());
502 assert!(result.unwrap_err().to_string().contains("not found"));
503 }
504}