1use std::path::{Path, PathBuf};
2
3use anyhow::Result;
4use serde::Serialize;
5
6use crate::git::GitRepo;
7use crate::manifest::WorkspaceManifest;
8
9#[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#[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
31pub 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 if fetch {
47 git.fetch().ok(); }
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 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 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 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 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}