Skip to main content

loom_core/workspace/
status.rs

1use std::path::{Path, PathBuf};
2
3use anyhow::Result;
4use serde::Serialize;
5
6use crate::git::GitRepo;
7use crate::manifest::WorkspaceManifest;
8
9/// Detailed status for a single repo in a workspace.
10#[derive(Debug, Serialize)]
11pub struct RepoStatus {
12    pub name: String,
13    pub worktree_path: PathBuf,
14    pub branch: String,
15    pub is_dirty: bool,
16    pub change_count: usize,
17    pub ahead: u32,
18    pub behind: u32,
19    pub exists: bool,
20}
21
22/// Status of an entire workspace.
23#[derive(Debug, Serialize)]
24pub struct WorkspaceStatus {
25    pub name: String,
26    pub path: PathBuf,
27    pub base_branch: Option<String>,
28    pub repos: Vec<RepoStatus>,
29}
30
31/// Get detailed status for a workspace.
32///
33/// If `fetch` is true, runs `git fetch origin` per repo before computing ahead/behind.
34pub fn workspace_status(
35    manifest: &WorkspaceManifest,
36    ws_path: &Path,
37    fetch: bool,
38) -> Result<WorkspaceStatus> {
39    let mut repos = Vec::new();
40
41    for repo_entry in &manifest.repos {
42        let status = if repo_entry.worktree_path.exists() {
43            let git = GitRepo::new(&repo_entry.worktree_path);
44
45            // Optional fetch
46            if fetch {
47                git.fetch().ok(); // Best-effort, don't fail the whole status
48            }
49
50            let is_dirty = git.is_dirty().unwrap_or(false);
51            let change_count = git.change_count().unwrap_or(0);
52            let branch = git
53                .current_branch()
54                .unwrap_or_else(|_| repo_entry.branch.clone());
55
56            // Compute ahead/behind against the base branch
57            let base = manifest.base_branch.as_deref().unwrap_or("main");
58            let (ahead, behind) = git.ahead_behind(base).unwrap_or((0, 0));
59
60            RepoStatus {
61                name: repo_entry.name.clone(),
62                worktree_path: repo_entry.worktree_path.clone(),
63                branch,
64                is_dirty,
65                change_count,
66                ahead,
67                behind,
68                exists: true,
69            }
70        } else {
71            // Worktree path doesn't exist — orphaned entry
72            RepoStatus {
73                name: repo_entry.name.clone(),
74                worktree_path: repo_entry.worktree_path.clone(),
75                branch: repo_entry.branch.clone(),
76                is_dirty: false,
77                change_count: 0,
78                ahead: 0,
79                behind: 0,
80                exists: false,
81            }
82        };
83
84        repos.push(status);
85    }
86
87    Ok(WorkspaceStatus {
88        name: manifest.name.clone(),
89        path: ws_path.to_path_buf(),
90        base_branch: manifest.base_branch.clone(),
91        repos,
92    })
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use crate::manifest::RepoManifestEntry;
99
100    fn create_git_repo(path: &std::path::Path) {
101        std::fs::create_dir_all(path).unwrap();
102        std::process::Command::new("git")
103            .args(["init", "-b", "main", &path.to_string_lossy()])
104            .env("LC_ALL", "C")
105            .output()
106            .unwrap();
107        std::process::Command::new("git")
108            .args([
109                "-C",
110                &path.to_string_lossy(),
111                "commit",
112                "--allow-empty",
113                "-m",
114                "init",
115            ])
116            .env("LC_ALL", "C")
117            .output()
118            .unwrap();
119    }
120
121    #[test]
122    fn test_status_clean_repo() {
123        let dir = tempfile::tempdir().unwrap();
124        let repo_path = dir.path().join("my-repo");
125        create_git_repo(&repo_path);
126
127        let manifest = WorkspaceManifest {
128            name: "test-ws".to_string(),
129            branch: None,
130            created: chrono::Utc::now(),
131            base_branch: None,
132            preset: None,
133            repos: vec![RepoManifestEntry {
134                name: "my-repo".to_string(),
135                original_path: repo_path.clone(),
136                worktree_path: repo_path,
137                branch: "loom/test-ws".to_string(),
138                remote_url: String::new(),
139            }],
140        };
141
142        let ws_path = dir.path().join("ws");
143        let status = workspace_status(&manifest, &ws_path, false).unwrap();
144        assert_eq!(status.repos.len(), 1);
145        assert!(status.repos[0].exists);
146        assert!(!status.repos[0].is_dirty);
147        assert_eq!(status.repos[0].change_count, 0);
148    }
149
150    #[test]
151    fn test_status_json_snapshot() {
152        let dir = tempfile::tempdir().unwrap();
153        let repo_path = dir.path().join("my-repo");
154        create_git_repo(&repo_path);
155
156        let manifest = WorkspaceManifest {
157            name: "test-ws".to_string(),
158            branch: Some("loom/test-ws".to_string()),
159            created: chrono::Utc::now(),
160            base_branch: Some("main".to_string()),
161            preset: None,
162            repos: vec![RepoManifestEntry {
163                name: "my-repo".to_string(),
164                original_path: repo_path.clone(),
165                worktree_path: repo_path,
166                branch: "loom/test-ws".to_string(),
167                remote_url: String::new(),
168            }],
169        };
170
171        let ws_path = dir.path().join("ws");
172        let status = workspace_status(&manifest, &ws_path, false).unwrap();
173
174        // Redact dynamic paths for snapshot stability
175        let mut redacted = status;
176        redacted.path = PathBuf::from("<WORKSPACE>");
177        for repo in &mut redacted.repos {
178            repo.worktree_path = PathBuf::from("<WORKTREE>");
179        }
180        insta::assert_snapshot!(serde_json::to_string_pretty(&redacted).unwrap());
181    }
182
183    #[test]
184    fn test_status_dirty_repo() {
185        let dir = tempfile::tempdir().unwrap();
186        let repo_path = dir.path().join("my-repo");
187        create_git_repo(&repo_path);
188
189        // Make it dirty
190        std::fs::write(repo_path.join("dirty.txt"), "content").unwrap();
191
192        let manifest = WorkspaceManifest {
193            name: "test-ws".to_string(),
194            branch: None,
195            created: chrono::Utc::now(),
196            base_branch: None,
197            preset: None,
198            repos: vec![RepoManifestEntry {
199                name: "my-repo".to_string(),
200                original_path: repo_path.clone(),
201                worktree_path: repo_path,
202                branch: "loom/test-ws".to_string(),
203                remote_url: String::new(),
204            }],
205        };
206
207        let ws_path = dir.path().join("ws");
208        let status = workspace_status(&manifest, &ws_path, false).unwrap();
209        assert!(status.repos[0].is_dirty);
210        assert!(status.repos[0].change_count > 0);
211    }
212
213    #[test]
214    fn test_status_missing_worktree() {
215        let dir = tempfile::tempdir().unwrap();
216
217        let manifest = WorkspaceManifest {
218            name: "test-ws".to_string(),
219            branch: None,
220            created: chrono::Utc::now(),
221            base_branch: None,
222            preset: None,
223            repos: vec![RepoManifestEntry {
224                name: "my-repo".to_string(),
225                original_path: dir.path().join("nonexistent"),
226                worktree_path: dir.path().join("nonexistent"),
227                branch: "loom/test-ws".to_string(),
228                remote_url: String::new(),
229            }],
230        };
231
232        let ws_path = dir.path().join("ws");
233        let status = workspace_status(&manifest, &ws_path, false).unwrap();
234        assert!(!status.repos[0].exists);
235    }
236}