Skip to main content

loom_core/workspace/
down.rs

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/// Pre-flight check result for tearing down a workspace.
11#[derive(Debug)]
12pub struct DownCheck {
13    pub clean_repos: Vec<String>,
14    pub dirty_repos: Vec<(String, usize)>, // (name, change_count)
15    pub missing_repos: Vec<String>,
16}
17
18/// Check the state of all repos before tearing down.
19pub 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()), // Can't check, treat as clean
35        }
36    }
37
38    DownCheck {
39        clean_repos: clean,
40        dirty_repos: dirty,
41        missing_repos: missing,
42    }
43}
44
45/// Tear down a workspace: remove worktrees, delete branches, clean up state.
46///
47/// `repos_to_remove` specifies which repos to actually remove (allows partial teardown).
48/// If all repos are removed, the workspace directory and state entry are cleaned up.
49pub 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            // Unlock
71            original_git.worktree_unlock(&repo_entry.worktree_path).ok();
72
73            // Remove worktree
74            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            // Delete branch
83            original_git.branch_delete(&repo_entry.branch, force).ok();
84        }
85
86        removed.push(repo_name.clone());
87    }
88
89    // Remove successfully removed repos from manifest
90    manifest.repos.retain(|r| !removed.contains(&r.name));
91
92    let matched_configs = if manifest.repos.is_empty() {
93        // Full teardown: remove workspace directory and state entry
94        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        // Partial teardown: update manifest with remaining repos
104        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        // Regenerate agent files for remaining repos
117        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/// Result of a workspace teardown.
129#[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        // Add worktree
192        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        // Make dirty
245        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        // Register in state
279        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        // State should be cleaned up
303        let state = manifest::read_global_state(&state_path);
304        assert!(state.find("test-ws").is_none());
305    }
306}