Skip to main content

loom_core/sync/
open.rs

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/// Result of an open operation.
14#[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
25/// Open (reconstruct) a workspace from a sync manifest.
26///
27/// Steps:
28/// 1. Pull sync repo
29/// 2. Read sync manifest
30/// 3. Match remote URLs to local repos
31/// 4. Clone missing repos
32/// 5. Create worktrees
33/// 6. Generate agent files
34/// 7. Update state
35pub 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    // Pull sync repo (best effort — may fail if no remote configured)
45    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    // Read sync manifest
51    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    // Check if workspace already exists locally
77    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    // Discover local repos for URL matching
85    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    // Check for repos that exist locally but not in sync manifest
125    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    // Create workspace directory
142    std::fs::create_dir_all(&ws_path)
143        .with_context(|| format!("Failed to create workspace directory {}", ws_path.display()))?;
144
145    // Write workspace manifest, preserving preset from existing local manifest
146    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    // Generate agent files
158    let matched_configs = crate::agent::generate_agent_files(config, &ws_path, &ws_manifest)?;
159
160    // Update state.json
161    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
189/// Restore a single repo: find locally or clone, then create worktree.
190fn 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    // Check if already exists in current workspace manifest
199    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        // Already present — check for branch divergence
204        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    // Find repo locally by URL matching
216    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            // Clone the repo
227            let clone_target = derive_clone_path(config, &sync_repo.remote_url)?;
228
229            // Check if target exists but has different remote
230            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    // Fetch to ensure remote refs are up-to-date
258    let git = GitRepo::new(&repo_path);
259    git.fetch().ok(); // Best effort
260
261    // Create worktree
262    let worktree_path = ws_path.join(&sync_repo.name);
263    let branch_name = sync_repo.branch.clone();
264
265    if !worktree_path.exists() {
266        // Try creating worktree with new branch
267        match git.worktree_add(&worktree_path, &branch_name, &sync_repo.branch) {
268            Ok(()) => {}
269            Err(crate::git::GitError::BranchConflict { .. }) => {
270                // Branch exists — try using it
271                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        // Lock worktree
289        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
309/// Derive a local path for cloning from a remote URL.
310///
311/// Uses the first scan_root and the canonical URL structure:
312/// `github.com/org/repo` → `{first_scan_root}/org/repo`
313fn 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    // canonical is like "github.com/org/repo"
323    // We want "{scan_root}/org/repo"
324    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    // Skip the host, use org/repo
334    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        // Create a sync repo
457        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}