1use std::path::Path;
2
3use anyhow::Result;
4
5use crate::config::Config;
6use crate::git::GitRepo;
7use crate::manifest::{self, WorkspaceManifest};
8use crate::workspace::MANIFEST_FILENAME;
9
10#[derive(Debug)]
12pub struct DownCheck {
13 pub clean_repos: Vec<String>,
14 pub dirty_repos: Vec<(String, usize)>, pub missing_repos: Vec<String>,
16}
17
18pub fn check_workspace(manifest: &WorkspaceManifest) -> DownCheck {
20 let mut clean = Vec::new();
21 let mut dirty = Vec::new();
22 let mut missing = Vec::new();
23
24 for repo in &manifest.repos {
25 if !repo.worktree_path.exists() {
26 missing.push(repo.name.clone());
27 continue;
28 }
29
30 let git = GitRepo::new(&repo.worktree_path);
31 match git.change_count() {
32 Ok(0) => clean.push(repo.name.clone()),
33 Ok(n) => dirty.push((repo.name.clone(), n)),
34 Err(_) => clean.push(repo.name.clone()), }
36 }
37
38 DownCheck {
39 clean_repos: clean,
40 dirty_repos: dirty,
41 missing_repos: missing,
42 }
43}
44
45pub fn teardown_workspace(
50 config: &Config,
51 ws_path: &Path,
52 manifest: &mut WorkspaceManifest,
53 repos_to_remove: &[String],
54 force: bool,
55) -> Result<TeardownResult> {
56 let mut removed = Vec::new();
57 let mut failed = Vec::new();
58
59 for repo_name in repos_to_remove {
60 let repo_idx = match manifest.repos.iter().position(|r| r.name == *repo_name) {
61 Some(idx) => idx,
62 None => continue,
63 };
64
65 let repo_entry = &manifest.repos[repo_idx];
66
67 if repo_entry.worktree_path.exists() {
68 let original_git = GitRepo::new(&repo_entry.original_path);
69
70 original_git.worktree_unlock(&repo_entry.worktree_path).ok();
72
73 match original_git.worktree_remove(&repo_entry.worktree_path, force) {
75 Ok(()) => {}
76 Err(e) => {
77 failed.push((repo_name.clone(), e.to_string()));
78 continue;
79 }
80 }
81
82 original_git.branch_delete(&repo_entry.branch, force).ok();
84 }
85
86 removed.push(repo_name.clone());
87 }
88
89 manifest.repos.retain(|r| !removed.contains(&r.name));
91
92 let matched_configs = if manifest.repos.is_empty() {
93 std::fs::remove_dir_all(ws_path).ok();
95
96 let state_path = config.workspace.root.join(".loom").join("state.json");
97 let mut state = manifest::read_global_state(&state_path);
98 state.remove(&manifest.name);
99 manifest::write_global_state(&state_path, &state)?;
100
101 Vec::new()
102 } else {
103 manifest::write_manifest(&ws_path.join(MANIFEST_FILENAME), manifest)?;
105
106 let state_path = config.workspace.root.join(".loom").join("state.json");
107 let mut state = manifest::read_global_state(&state_path);
108 state.upsert(crate::manifest::WorkspaceIndex {
109 name: manifest.name.clone(),
110 path: ws_path.to_path_buf(),
111 created: manifest.created,
112 repo_count: manifest.repos.len(),
113 });
114 manifest::write_global_state(&state_path, &state)?;
115
116 crate::agent::generate_agent_files(config, ws_path, manifest)?
118 };
119
120 Ok(TeardownResult {
121 removed,
122 failed,
123 remaining: manifest.repos.iter().map(|r| r.name.clone()).collect(),
124 matched_configs,
125 })
126}
127
128#[derive(Debug)]
130pub struct TeardownResult {
131 pub removed: Vec<String>,
132 pub failed: Vec<(String, String)>,
133 pub remaining: Vec<String>,
134 pub matched_configs: Vec<crate::agent::MatchedRepoConfig>,
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140 use crate::config::{
141 AgentsConfig, DefaultsConfig, RegistryConfig, UpdateConfig, WorkspaceConfig,
142 };
143 use crate::manifest::{RepoManifestEntry, WorkspaceIndex};
144 use std::collections::BTreeMap;
145
146 fn test_config(dir: &Path) -> Config {
147 let ws_root = dir.join("loom");
148 std::fs::create_dir_all(ws_root.join(".loom")).unwrap();
149 Config {
150 registry: RegistryConfig {
151 scan_roots: vec![],
152 scan_depth: 2,
153 },
154 workspace: WorkspaceConfig { root: ws_root },
155 sync: None,
156 terminal: None,
157 editor: None,
158 defaults: DefaultsConfig::default(),
159 groups: BTreeMap::new(),
160 repos: BTreeMap::new(),
161 specs: None,
162 agents: AgentsConfig::default(),
163 update: UpdateConfig::default(),
164 }
165 }
166
167 fn create_repo_with_worktree(
168 dir: &Path,
169 name: &str,
170 ) -> (RepoManifestEntry, std::path::PathBuf) {
171 let repo_path = dir.join("repos").join(name);
172 std::fs::create_dir_all(&repo_path).unwrap();
173 std::process::Command::new("git")
174 .args(["init", "-b", "main", &repo_path.to_string_lossy()])
175 .env("LC_ALL", "C")
176 .output()
177 .unwrap();
178 std::process::Command::new("git")
179 .args([
180 "-C",
181 &repo_path.to_string_lossy(),
182 "commit",
183 "--allow-empty",
184 "-m",
185 "init",
186 ])
187 .env("LC_ALL", "C")
188 .output()
189 .unwrap();
190
191 let ws_path = dir.join("loom").join("test-ws");
193 let wt_path = ws_path.join(name);
194 std::process::Command::new("git")
195 .args([
196 "-C",
197 &repo_path.to_string_lossy(),
198 "worktree",
199 "add",
200 "-b",
201 "loom/test-ws",
202 &wt_path.to_string_lossy(),
203 ])
204 .env("LC_ALL", "C")
205 .output()
206 .unwrap();
207
208 let entry = RepoManifestEntry {
209 name: name.to_string(),
210 original_path: repo_path,
211 worktree_path: wt_path,
212 branch: "loom/test-ws".to_string(),
213 remote_url: String::new(),
214 };
215
216 (entry, ws_path)
217 }
218
219 #[test]
220 fn test_check_workspace_clean() {
221 let dir = tempfile::tempdir().unwrap();
222 let (entry, _) = create_repo_with_worktree(dir.path(), "repo-a");
223
224 let manifest = WorkspaceManifest {
225 name: "test-ws".to_string(),
226 branch: None,
227 created: chrono::Utc::now(),
228 base_branch: None,
229 preset: None,
230 repos: vec![entry],
231 };
232
233 let check = check_workspace(&manifest);
234 assert_eq!(check.clean_repos.len(), 1);
235 assert!(check.dirty_repos.is_empty());
236 assert!(check.missing_repos.is_empty());
237 }
238
239 #[test]
240 fn test_check_workspace_dirty() {
241 let dir = tempfile::tempdir().unwrap();
242 let (entry, _) = create_repo_with_worktree(dir.path(), "repo-a");
243
244 std::fs::write(entry.worktree_path.join("dirty.txt"), "content").unwrap();
246
247 let manifest = WorkspaceManifest {
248 name: "test-ws".to_string(),
249 branch: None,
250 created: chrono::Utc::now(),
251 base_branch: None,
252 preset: None,
253 repos: vec![entry],
254 };
255
256 let check = check_workspace(&manifest);
257 assert!(check.clean_repos.is_empty());
258 assert_eq!(check.dirty_repos.len(), 1);
259 }
260
261 #[test]
262 fn test_teardown_full() {
263 let dir = tempfile::tempdir().unwrap();
264 let config = test_config(dir.path());
265 let (entry, ws_path) = create_repo_with_worktree(dir.path(), "repo-a");
266 std::fs::create_dir_all(&ws_path).unwrap();
267
268 let mut manifest = WorkspaceManifest {
269 name: "test-ws".to_string(),
270 branch: None,
271 created: chrono::Utc::now(),
272 base_branch: None,
273 preset: None,
274 repos: vec![entry],
275 };
276 manifest::write_manifest(&ws_path.join(MANIFEST_FILENAME), &manifest).unwrap();
277
278 let state_path = config.workspace.root.join(".loom").join("state.json");
280 let mut state = manifest::read_global_state(&state_path);
281 state.upsert(WorkspaceIndex {
282 name: "test-ws".to_string(),
283 path: ws_path.clone(),
284 created: manifest.created,
285 repo_count: 1,
286 });
287 manifest::write_global_state(&state_path, &state).unwrap();
288
289 let result = teardown_workspace(
290 &config,
291 &ws_path,
292 &mut manifest,
293 &["repo-a".to_string()],
294 true,
295 )
296 .unwrap();
297
298 assert_eq!(result.removed.len(), 1);
299 assert!(result.failed.is_empty());
300 assert!(result.remaining.is_empty());
301
302 let state = manifest::read_global_state(&state_path);
304 assert!(state.find("test-ws").is_none());
305 }
306}