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#[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
20pub 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 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 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 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 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), };
68
69 Ok(SaveResult {
70 pushed,
71 push_failed,
72 dirty_skipped,
73 sync_ok,
74 sync_error,
75 })
76}
77
78fn 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 match sync_git.pull_rebase() {
87 Ok(()) => {}
88 Err(_) => {
89 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 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 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 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 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 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 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}