1use std::path::{Path, PathBuf};
8use std::process::Command;
9
10#[derive(Debug, Clone)]
11pub struct WorktreeEntry {
12 pub path: PathBuf,
13 pub branch: String,
14 pub sanitized_branch: String,
15 pub namespace: Option<String>,
17}
18
19pub fn discover_worktrees(project_dir: &Path) -> Vec<WorktreeEntry> {
20 if project_dir.join(".jj").exists() {
22 discover_jj_workspaces(project_dir)
23 } else if project_dir.join(".git").exists() {
24 discover_git_worktrees(project_dir)
25 } else {
26 vec![]
27 }
28}
29
30fn discover_jj_workspaces(project_dir: &Path) -> Vec<WorktreeEntry> {
33 let output = match Command::new("jj")
34 .args(["workspace", "list"])
35 .current_dir(project_dir)
36 .output()
37 {
38 Ok(o) if o.status.success() => o.stdout,
39 _ => return vec![],
40 };
41
42 let names = parse_jj_workspace_names(&output);
43 if names.is_empty() {
44 return vec![];
45 }
46
47 let non_default: Vec<&str> = names
49 .iter()
50 .filter(|n| **n != "default")
51 .map(|n| n.as_str())
52 .collect();
53 let mut roots = std::collections::HashMap::with_capacity(non_default.len());
54
55 std::thread::scope(|s| {
56 let handles: Vec<_> = non_default
57 .iter()
58 .map(|name| s.spawn(move || (*name, get_jj_workspace_root(project_dir, name))))
59 .collect();
60
61 for handle in handles {
62 let (name, root) = handle.join().unwrap();
63 roots.insert(name.to_string(), root);
64 }
65 });
66
67 let mut entries = Vec::with_capacity(names.len());
68 for name in &names {
69 let path = if name == "default" {
70 Some(project_dir.to_path_buf())
71 } else {
72 roots.get(name).cloned().unwrap_or(None)
73 };
74
75 let Some(path) = path else {
76 continue;
77 };
78
79 let sanitized = sanitize_branch(name);
80 if sanitized.is_empty() {
81 log::warn!(
82 "Skipping jj workspace '{}' because its sanitized name is empty \
83 (no ASCII alphanumeric characters)",
84 name,
85 );
86 continue;
87 }
88 entries.push(WorktreeEntry {
89 path,
90 branch: name.to_string(),
91 sanitized_branch: sanitized,
92 namespace: None,
93 });
94 }
95
96 entries
97}
98fn parse_jj_workspace_names(stdout: &[u8]) -> Vec<String> {
99 let text = String::from_utf8_lossy(stdout);
100 text.lines()
101 .filter_map(|line| {
102 let line = line.trim();
103 if line.is_empty() {
104 return None;
105 }
106 line.split_once(':')
107 .map(|(name, _)| name.trim().to_string())
108 .filter(|n| !n.is_empty())
109 })
110 .collect()
111}
112
113#[allow(dead_code)]
114fn parse_jj_workspace_list(
115 stdout: &[u8],
116 mut resolve_path: impl FnMut(&str) -> Option<PathBuf>,
117) -> Vec<WorktreeEntry> {
118 let text = String::from_utf8_lossy(stdout);
119 let mut entries = Vec::new();
120
121 for line in text.lines() {
122 let line = line.trim();
123 if line.is_empty() {
124 continue;
125 }
126
127 let Some((name, _)) = line.split_once(':') else {
128 continue;
129 };
130 let name = name.trim();
131 if name.is_empty() {
132 continue;
133 }
134
135 let path = match resolve_path(name) {
136 Some(p) => p,
137 None => continue,
138 };
139
140 let sanitized = sanitize_branch(name);
141 if sanitized.is_empty() {
142 log::warn!(
143 "Skipping jj workspace '{}' because its sanitized name is empty \
144 (no ASCII alphanumeric characters)",
145 name,
146 );
147 continue;
148 }
149 entries.push(WorktreeEntry {
150 path,
151 branch: name.to_string(),
152 sanitized_branch: sanitized,
153 namespace: None,
154 });
155 }
156
157 entries
158}
159
160fn get_jj_workspace_root(project_dir: &Path, name: &str) -> Option<PathBuf> {
161 let output = Command::new("jj")
162 .args(["workspace", "root", "--name", name])
163 .current_dir(project_dir)
164 .output()
165 .ok()?;
166
167 if !output.status.success() {
168 return None;
169 }
170
171 let path_str = String::from_utf8_lossy(&output.stdout);
172 let trimmed = path_str.trim();
173 if trimmed.is_empty() {
174 return None;
175 }
176
177 Some(PathBuf::from(trimmed))
178}
179
180fn discover_git_worktrees(project_dir: &Path) -> Vec<WorktreeEntry> {
183 let output = match Command::new("git")
184 .args(["worktree", "list", "--porcelain"])
185 .current_dir(project_dir)
186 .output()
187 {
188 Ok(o) if o.status.success() => o.stdout,
189 _ => return vec![],
190 };
191
192 parse_git_worktree_output(&output)
193}
194
195fn parse_git_worktree_output(stdout: &[u8]) -> Vec<WorktreeEntry> {
196 let text = String::from_utf8_lossy(stdout);
197 let mut entries = Vec::new();
198 let mut current_path = None;
199 let mut current_branch = None;
200
201 for line in text.lines() {
202 if let Some(path) = line.strip_prefix("worktree ") {
203 current_path = Some(PathBuf::from(path.trim()));
204 current_branch = None;
205 } else if let Some(branch) = line.strip_prefix("branch ") {
206 current_branch = Some(
207 branch
208 .trim()
209 .strip_prefix("refs/heads/")
210 .unwrap_or(branch.trim())
211 .to_string(),
212 );
213 }
214 if line.is_empty() {
215 flush_git_entry(&mut entries, &mut current_path, &mut current_branch);
216 }
217 }
218
219 flush_git_entry(&mut entries, &mut current_path, &mut current_branch);
220
221 entries
222}
223
224fn flush_git_entry(
225 entries: &mut Vec<WorktreeEntry>,
226 path: &mut Option<PathBuf>,
227 branch: &mut Option<String>,
228) {
229 if let (Some(p), Some(b)) = (path.take(), branch.take()) {
230 let sanitized = sanitize_branch(&b);
231 if sanitized.is_empty() {
232 log::warn!(
233 "Skipping git worktree at '{}' because branch '{}' sanitizes to empty \
234 (no ASCII alphanumeric characters)",
235 p.display(),
236 b,
237 );
238 return;
239 }
240 entries.push(WorktreeEntry {
241 path: p,
242 branch: b,
243 sanitized_branch: sanitized,
244 namespace: None,
245 });
246 }
247}
248
249fn sanitize_branch(branch: &str) -> String {
252 let sanitized: String = branch
253 .chars()
254 .map(|c| {
255 if c.is_ascii_alphanumeric() || c == '-' {
256 c
257 } else {
258 '-'
259 }
260 })
261 .collect();
262 sanitized.trim_matches('-').to_string()
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 #[test]
270 fn test_sanitize_branch_simple() {
271 assert_eq!(sanitize_branch("feature-a"), "feature-a");
272 }
273
274 #[test]
275 fn test_sanitize_branch_with_slash() {
276 assert_eq!(
277 sanitize_branch("feature/my-endpoint"),
278 "feature-my-endpoint"
279 );
280 }
281
282 #[test]
283 fn test_sanitize_branch_with_underscore() {
284 assert_eq!(sanitize_branch("fix_bug_123"), "fix-bug-123");
285 }
286
287 #[test]
290 fn test_parse_jj_workspace_list_two_workspaces() {
291 let input =
292 b"default: kkqmkqnm 6aa0ec8e main\nfeature-a: rrqxmqnm 8e9b1c2d feature/my-endpoint\n";
293 let entries = parse_jj_workspace_list(input, |name| {
294 Some(PathBuf::from(format!("/home/user/{}-ws", name)))
295 });
296 assert_eq!(entries.len(), 2);
297 assert_eq!(entries[0].path, PathBuf::from("/home/user/default-ws"));
298 assert_eq!(entries[0].branch, "default");
299 assert_eq!(entries[0].sanitized_branch, "default");
300 assert_eq!(entries[1].path, PathBuf::from("/home/user/feature-a-ws"));
301 assert_eq!(entries[1].branch, "feature-a");
302 assert_eq!(entries[1].sanitized_branch, "feature-a");
303 }
304
305 #[test]
306 fn test_parse_jj_workspace_list_no_colon() {
307 let input = b"some invalid line without colon\n";
308 let entries = parse_jj_workspace_list(input, |_| {
309 panic!("should not be called for unparseable lines")
310 });
311 assert_eq!(entries.len(), 0);
312 }
313
314 #[test]
315 fn test_parse_jj_workspace_list_no_trailing_newline() {
316 let input = b"default: kkqmkqnm 6aa0ec8e main";
317 let entries = parse_jj_workspace_list(input, |_| Some(PathBuf::from("/home/user/myapp")));
318 assert_eq!(entries.len(), 1);
319 assert_eq!(entries[0].path, PathBuf::from("/home/user/myapp"));
320 assert_eq!(entries[0].branch, "default");
321 assert_eq!(entries[0].sanitized_branch, "default");
322 }
323
324 #[test]
325 fn test_parse_jj_workspace_list_skips_unresolved() {
326 let input = b"default: abc123\norphan: def456\n";
327 let entries = parse_jj_workspace_list(input, |name| {
328 if name == "default" {
329 Some(PathBuf::from("/home/user/myapp"))
330 } else {
331 None
332 }
333 });
334 assert_eq!(entries.len(), 1);
335 assert_eq!(entries[0].branch, "default");
336 }
337
338 #[test]
341 fn test_parse_git_worktree_output_two_worktrees() {
342 let input = b"worktree /home/user/myapp\nHEAD abc123\nbranch refs/heads/main\n\nworktree /home/user/myapp-feature-a\nHEAD def456\nbranch refs/heads/feature-a\n";
343 let entries = parse_git_worktree_output(input);
344 assert_eq!(entries.len(), 2);
345 assert_eq!(entries[0].path, PathBuf::from("/home/user/myapp"));
346 assert_eq!(entries[0].branch, "main");
347 assert_eq!(entries[0].sanitized_branch, "main");
348 assert_eq!(entries[1].path, PathBuf::from("/home/user/myapp-feature-a"));
349 assert_eq!(entries[1].branch, "feature-a");
350 assert_eq!(entries[1].sanitized_branch, "feature-a");
351 }
352
353 #[test]
354 fn test_parse_git_worktree_output_detached_head() {
355 let input = b"worktree /home/user/myapp\nHEAD abc123\n\n";
356 let entries = parse_git_worktree_output(input);
357 assert_eq!(entries.len(), 0);
358 }
359
360 #[test]
361 fn test_parse_git_worktree_output_no_trailing_blank() {
362 let input = b"worktree /home/user/myapp\nHEAD abc123\nbranch refs/heads/main";
363 let entries = parse_git_worktree_output(input);
364 assert_eq!(entries.len(), 1);
365 assert_eq!(entries[0].branch, "main");
366 }
367
368 #[test]
369 fn test_sanitize_branch_non_ascii() {
370 assert_eq!(sanitize_branch("fix-バグ"), "fix");
371 assert_eq!(sanitize_branch("fix-ü"), "fix");
372 assert_eq!(sanitize_branch("fix-中"), "fix");
373 }
374
375 #[test]
376 fn test_sanitize_branch_empty() {
377 assert_eq!(sanitize_branch("---"), "");
378 assert_eq!(sanitize_branch("///"), "");
379 assert_eq!(sanitize_branch("___"), "");
380 }
381
382 #[test]
383 fn test_parse_git_worktree_output_empty_sanitized() {
384 let input = b"worktree /home/user/myapp\nHEAD abc123\nbranch refs/heads/---\n\n";
385 let entries = parse_git_worktree_output(input);
386 assert_eq!(entries.len(), 0);
387 }
388
389 #[test]
390 fn test_parse_jj_workspace_list_empty_sanitized() {
391 let input = b"---: kkqmkqnm 6aa0ec8e main\n";
392 let entries = parse_jj_workspace_list(input, |_| Some(PathBuf::from("/home/user")));
393 assert_eq!(entries.len(), 0);
394 }
395}