Skip to main content

loom_core/sync/
save.rs

1use std::path::Path;
2
3use anyhow::{Context, Result};
4
5use crate::config::Config;
6use crate::git::GitRepo;
7use crate::manifest::WorkspaceManifest;
8use crate::manifest::sync::{SyncManifest, SyncRepoEntry, SyncStatus};
9
10/// Result of a save operation.
11#[derive(Debug)]
12pub struct SaveResult {
13    pub pushed: Vec<String>,
14    pub push_failed: Vec<(String, String)>,
15    pub dirty_skipped: Vec<String>,
16    pub sync_ok: bool,
17    pub sync_error: Option<String>,
18}
19
20/// Push branches and optionally sync the workspace manifest.
21///
22/// For each repo:
23/// - If clean (or force): push the branch
24/// - If dirty and not force: skip with warning
25///
26/// If sync is configured, write the sync manifest to the sync repo.
27pub fn save_workspace(
28    config: &Config,
29    _ws_path: &Path,
30    manifest: &WorkspaceManifest,
31    force: bool,
32) -> Result<SaveResult> {
33    let mut pushed = Vec::new();
34    let mut push_failed = Vec::new();
35    let mut dirty_skipped = Vec::new();
36
37    // Push each repo's branch
38    for repo in &manifest.repos {
39        if !repo.worktree_path.exists() {
40            push_failed.push((repo.name.clone(), "worktree missing".to_string()));
41            continue;
42        }
43
44        let git = GitRepo::new(&repo.worktree_path);
45
46        // Check dirty status
47        let is_dirty = git.is_dirty().unwrap_or(false);
48        if is_dirty && !force {
49            dirty_skipped.push(repo.name.clone());
50            continue;
51        }
52
53        // Push the branch
54        match git.push_tracking(&repo.branch) {
55            Ok(()) => pushed.push(repo.name.clone()),
56            Err(e) => push_failed.push((repo.name.clone(), e.to_string())),
57        }
58    }
59
60    // Sync manifest to sync repo (if configured)
61    let (sync_ok, sync_error) = match &config.sync {
62        Some(sync_config) => match write_sync_manifest(sync_config, manifest) {
63            Ok(()) => (true, None),
64            Err(e) => (false, Some(e.to_string())),
65        },
66        None => (true, None), // No sync configured = success (no-op)
67    };
68
69    Ok(SaveResult {
70        pushed,
71        push_failed,
72        dirty_skipped,
73        sync_ok,
74        sync_error,
75    })
76}
77
78/// Write workspace manifest to sync repo, commit, and push.
79fn write_sync_manifest(
80    sync_config: &crate::config::SyncConfig,
81    manifest: &WorkspaceManifest,
82) -> Result<()> {
83    let sync_git = GitRepo::new(&sync_config.repo);
84
85    // Pull sync repo with rebase
86    match sync_git.pull_rebase() {
87        Ok(()) => {}
88        Err(_) => {
89            // Abort rebase if it failed
90            sync_git.rebase_abort().ok();
91            anyhow::bail!(
92                "Sync repo has conflicts. Resolve manually in {} and re-run `loom save`.",
93                sync_config.repo.display()
94            );
95        }
96    }
97
98    // Generate sync manifest
99    let sync_manifest = SyncManifest {
100        name: manifest.name.clone(),
101        created: manifest.created,
102        status: SyncStatus::Active,
103        branch: manifest.branch.clone(),
104        repos: manifest
105            .repos
106            .iter()
107            .map(|r| SyncRepoEntry {
108                name: r.name.clone(),
109                remote_url: r.remote_url.clone(),
110                branch: r.branch.clone(),
111            })
112            .collect(),
113    };
114
115    let json = serde_json::to_string_pretty(&sync_manifest)
116        .context("Failed to serialize sync manifest")?;
117
118    // Write to sync repo
119    let sync_dir = sync_config.repo.join(&sync_config.path);
120    std::fs::create_dir_all(&sync_dir)
121        .with_context(|| format!("Failed to create sync directory {}", sync_dir.display()))?;
122
123    let manifest_path = sync_dir.join(format!("{}.json", manifest.name));
124    std::fs::write(&manifest_path, &json)
125        .with_context(|| format!("Failed to write sync manifest {}", manifest_path.display()))?;
126
127    // Git add, commit, push
128    let relative_path = format!("{}/{}.json", sync_config.path, manifest.name);
129    sync_git
130        .add(&relative_path)
131        .context("Failed to stage sync manifest")?;
132
133    let commit_msg = format!("loom: update {}", manifest.name);
134    // Commit may fail if nothing changed (same content) — that's OK
135    sync_git.commit(&commit_msg).ok();
136
137    sync_git
138        .push()
139        .context("Failed to push sync repo. Changes committed locally but not pushed.")?;
140
141    Ok(())
142}
143
144#[cfg(test)]
145mod tests {
146    use super::*;
147    use crate::config::{
148        AgentsConfig, DefaultsConfig, RegistryConfig, UpdateConfig, WorkspaceConfig,
149    };
150    use crate::manifest::RepoManifestEntry;
151    use std::collections::BTreeMap;
152    use std::path::PathBuf;
153
154    fn test_config(dir: &Path) -> Config {
155        let ws_root = dir.join("loom");
156        std::fs::create_dir_all(ws_root.join(".loom")).unwrap();
157        Config {
158            registry: RegistryConfig {
159                scan_roots: vec![],
160                scan_depth: 2,
161            },
162            workspace: WorkspaceConfig { root: ws_root },
163            sync: None,
164            terminal: None,
165            editor: None,
166            defaults: DefaultsConfig::default(),
167            groups: BTreeMap::new(),
168            repos: BTreeMap::new(),
169            specs: None,
170            agents: AgentsConfig::default(),
171            update: UpdateConfig::default(),
172        }
173    }
174
175    #[test]
176    fn test_save_missing_worktree() {
177        let dir = tempfile::tempdir().unwrap();
178        let config = test_config(dir.path());
179        let ws_path = config.workspace.root.join("test-ws");
180
181        let manifest = WorkspaceManifest {
182            name: "test-ws".to_string(),
183            branch: None,
184            created: chrono::Utc::now(),
185            base_branch: None,
186            preset: None,
187            repos: vec![RepoManifestEntry {
188                name: "missing-repo".to_string(),
189                original_path: PathBuf::from("/nonexistent"),
190                worktree_path: PathBuf::from("/nonexistent/wt"),
191                branch: "loom/test-ws".to_string(),
192                remote_url: String::new(),
193            }],
194        };
195
196        let result = save_workspace(&config, &ws_path, &manifest, false).unwrap();
197        assert!(result.pushed.is_empty());
198        assert_eq!(result.push_failed.len(), 1);
199        assert!(result.dirty_skipped.is_empty());
200    }
201
202    #[test]
203    fn test_save_dirty_skipped() {
204        let dir = tempfile::tempdir().unwrap();
205        let config = test_config(dir.path());
206        let ws_path = config.workspace.root.join("test-ws");
207
208        // Create a git repo with dirty state
209        let repo_path = dir.path().join("repos").join("my-repo");
210        std::fs::create_dir_all(&repo_path).unwrap();
211        std::process::Command::new("git")
212            .args(["init", "-b", "main", &repo_path.to_string_lossy()])
213            .env("LC_ALL", "C")
214            .output()
215            .unwrap();
216        std::process::Command::new("git")
217            .args([
218                "-C",
219                &repo_path.to_string_lossy(),
220                "commit",
221                "--allow-empty",
222                "-m",
223                "init",
224            ])
225            .env("LC_ALL", "C")
226            .output()
227            .unwrap();
228        // Make dirty
229        std::fs::write(repo_path.join("dirty.txt"), "content").unwrap();
230
231        let manifest = WorkspaceManifest {
232            name: "test-ws".to_string(),
233            branch: None,
234            created: chrono::Utc::now(),
235            base_branch: None,
236            preset: None,
237            repos: vec![RepoManifestEntry {
238                name: "my-repo".to_string(),
239                original_path: repo_path.clone(),
240                worktree_path: repo_path,
241                branch: "main".to_string(),
242                remote_url: String::new(),
243            }],
244        };
245
246        let result = save_workspace(&config, &ws_path, &manifest, false).unwrap();
247        assert!(result.pushed.is_empty());
248        assert!(result.push_failed.is_empty());
249        assert_eq!(result.dirty_skipped.len(), 1);
250        assert_eq!(result.dirty_skipped[0], "my-repo");
251    }
252
253    #[test]
254    fn test_save_empty_workspace() {
255        let dir = tempfile::tempdir().unwrap();
256        let config = test_config(dir.path());
257        let ws_path = config.workspace.root.join("test-ws");
258
259        let manifest = WorkspaceManifest {
260            name: "test-ws".to_string(),
261            branch: None,
262            created: chrono::Utc::now(),
263            base_branch: None,
264            preset: None,
265            repos: vec![],
266        };
267
268        let result = save_workspace(&config, &ws_path, &manifest, false).unwrap();
269        assert!(result.pushed.is_empty());
270        assert!(result.push_failed.is_empty());
271        assert!(result.dirty_skipped.is_empty());
272        assert!(result.sync_ok);
273    }
274}