Skip to main content

pitchfork_cli/proxy/
worktree.rs

1//! Git worktree / jj workspace auto-discovery for proxy slug routing.
2//!
3//! Detects all git worktrees or jj workspaces for a project directory.
4//! Each entry carries the path, branch/workspace name, and a sanitized
5//! name suitable for use as a URL subdomain prefix.
6
7use 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    /// Namespace resolved at discovery time (cached to avoid per-request I/O).
16    pub namespace: Option<String>,
17}
18
19pub fn discover_worktrees(project_dir: &Path) -> Vec<WorktreeEntry> {
20    // Prefer jj workspace if .jj exists, fall back to git worktree if .git exists.
21    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
30// ─── jj workspace discovery ───────────────────────────────────────────────────
31
32fn 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    // Resolve paths in parallel for non-default workspaces.
48    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
180// ─── git worktree discovery ───────────────────────────────────────────────────
181
182fn 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
249// ─── shared ───────────────────────────────────────────────────────────────────
250
251fn 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    // ─── jj workspace tests ───────────────────────────────────────────────
288
289    #[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    // ─── git worktree tests ───────────────────────────────────────────────
339
340    #[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}