git_worktree_manager/
scope.rs1use std::collections::HashSet;
12use std::path::{Path, PathBuf};
13
14use crate::error::{CwError, Result};
15use crate::git;
16
17#[derive(Debug, Clone)]
18pub struct ScopedWorktree {
19 pub name: String,
21 pub branch: Option<String>,
23 pub path: PathBuf,
25 pub repo_root: PathBuf,
28 pub is_main: bool,
30}
31
32#[derive(Debug, Clone, Default)]
33pub struct Scope {
34 worktrees: Vec<ScopedWorktree>,
35}
36
37impl Scope {
38 pub fn worktrees(&self) -> &[ScopedWorktree] {
39 &self.worktrees
40 }
41
42 pub fn is_empty(&self) -> bool {
43 self.worktrees.is_empty()
44 }
45}
46
47const MAX_WALK_DEPTH: usize = 4;
48
49pub fn discover_scope(cwd: &Path) -> Result<Scope> {
50 if let Ok(repo_root) = git::get_main_repo_root(Some(cwd)) {
51 let worktrees = collect_family(&repo_root)?;
52 return Ok(Scope { worktrees });
53 }
54
55 let mut all = Vec::new();
56 let mut seen_roots: HashSet<PathBuf> = HashSet::new();
57 walk_for_repos(cwd, 0, &mut all, &mut seen_roots)?;
58
59 if all.is_empty() {
60 return Err(CwError::Other(
61 "no worktrees in scope (cwd is not in a git repo and no repos found below)".into(),
62 ));
63 }
64 Ok(Scope { worktrees: all })
65}
66
67fn collect_family(repo_root: &Path) -> Result<Vec<ScopedWorktree>> {
68 let raw = git::parse_worktrees(repo_root)?;
69 let main_canon = git::canonicalize_or(repo_root);
70 let repo_root_canon = main_canon.clone();
71 let mut out = Vec::with_capacity(raw.len());
72 for (branch_raw, path) in raw {
73 let normalized = git::normalize_branch_name(&branch_raw);
74 let branch = if normalized == "(detached)" {
75 None
76 } else {
77 Some(normalized.to_string())
78 };
79 let name = path
80 .file_name()
81 .map(|n| n.to_string_lossy().into_owned())
82 .unwrap_or_default();
83 let is_main = git::canonicalize_or(&path) == main_canon;
84 out.push(ScopedWorktree {
85 name,
86 branch,
87 path,
88 repo_root: repo_root_canon.clone(),
89 is_main,
90 });
91 }
92 Ok(out)
93}
94
95fn walk_for_repos(
96 dir: &Path,
97 depth: usize,
98 out: &mut Vec<ScopedWorktree>,
99 seen: &mut HashSet<PathBuf>,
100) -> Result<()> {
101 if depth > MAX_WALK_DEPTH {
102 return Ok(());
103 }
104 let entries = match std::fs::read_dir(dir) {
105 Ok(e) => e,
106 Err(_) => return Ok(()),
107 };
108 for entry in entries.flatten() {
109 let path = entry.path();
110 let file_type = match entry.file_type() {
111 Ok(ft) => ft,
112 Err(_) => continue,
113 };
114 if file_type.is_symlink() {
115 continue;
116 }
117 if !file_type.is_dir() {
118 continue;
119 }
120 if let Some(name) = path.file_name().and_then(|s| s.to_str()) {
121 if name.starts_with('.') && name != ".git" {
122 continue;
123 }
124 if name == "node_modules" || name == "target" {
125 continue;
126 }
127 }
128 if let Ok(repo) = git::get_main_repo_root(Some(&path)) {
129 if seen.insert(git::canonicalize_or(&repo)) {
130 out.extend(collect_family(&repo)?);
131 }
132 continue;
133 }
134 walk_for_repos(&path, depth + 1, out, seen)?;
135 }
136 Ok(())
137}