1use crate::scanner::{default_branch, find_repos, git};
3use anyhow::Result;
4use chrono::{DateTime, Utc};
5use std::collections::HashSet;
6use std::path::{Path, PathBuf};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
10pub enum Verdict {
11 Home,
13 Prunable,
15 Active,
17 Deletable,
19 Cold,
21}
22
23impl Verdict {
24 pub fn label(&self) -> &'static str {
25 match self {
26 Verdict::Home => "home",
27 Verdict::Prunable => "prunable",
28 Verdict::Active => "active",
29 Verdict::Deletable => "deletable",
30 Verdict::Cold => "cold",
31 }
32 }
33}
34
35#[derive(Debug, Clone)]
37pub struct Worktree {
38 pub repo_name: String,
39 pub repo_path: PathBuf,
40 pub worktree_path: PathBuf,
41 pub branch: Option<String>,
42 pub last_commit: Option<DateTime<Utc>>,
43 pub merged: bool,
44 pub dirty: bool,
45 pub prunable: bool,
46 pub is_main: bool,
47}
48
49impl Worktree {
50 pub fn verdict(&self) -> Verdict {
52 if self.is_main {
53 return Verdict::Home;
54 }
55 if self.prunable {
56 return Verdict::Prunable;
57 }
58 if self.dirty {
59 return Verdict::Active;
60 }
61 match self.branch {
62 None => Verdict::Active, Some(_) if self.merged => Verdict::Deletable,
64 Some(_) => Verdict::Cold,
65 }
66 }
67
68 pub fn short_name(&self) -> String {
70 let base = self
71 .worktree_path
72 .file_name()
73 .map(|n| n.to_string_lossy().into_owned())
74 .unwrap_or_else(|| self.worktree_path.display().to_string());
75 format!("{}/{}", self.repo_name, base)
76 }
77}
78
79pub fn worktrees(repo: &Path) -> Result<Vec<Worktree>> {
85 let raw = git(repo, &["worktree", "list", "--porcelain"])?;
86 let default = default_branch(repo).ok();
87 let merged_set: HashSet<String> = match &default {
88 Some(d) => git(
89 repo,
90 &["branch", "--merged", d, "--format=%(refname:short)"],
91 )
92 .unwrap_or_default()
93 .lines()
94 .map(|s| s.trim().to_string())
95 .filter(|b| !b.is_empty() && b != d)
97 .collect(),
98 None => HashSet::new(),
99 };
100 let repo_name = repo
101 .file_name()
102 .map(|n| n.to_string_lossy().into_owned())
103 .unwrap_or_else(|| repo.display().to_string());
104
105 let mut out = Vec::new();
106 let mut first = true;
107 for block in raw.split("\n\n") {
108 let block = block.trim();
109 if block.is_empty() {
110 continue;
111 }
112 let mut wt_path: Option<PathBuf> = None;
113 let mut branch: Option<String> = None;
114 let mut prunable = false;
115 let mut bare = false;
116 for line in block.lines() {
117 if let Some(p) = line.strip_prefix("worktree ") {
118 wt_path = Some(PathBuf::from(p));
119 } else if let Some(b) = line.strip_prefix("branch ") {
120 branch = Some(b.strip_prefix("refs/heads/").unwrap_or(b).to_string());
121 } else if line == "bare" {
122 bare = true;
123 } else if line == "prunable" || line.starts_with("prunable ") {
124 prunable = true;
125 }
126 }
128 let Some(wt_path) = wt_path else { continue };
129 if bare {
130 continue;
131 }
132 let is_main = first;
133 first = false;
134
135 let (last_commit, dirty) = if prunable {
136 (None, false)
137 } else {
138 let lc = git(&wt_path, &["log", "-1", "--format=%cI"])
139 .ok()
140 .and_then(|s| DateTime::parse_from_rfc3339(s.trim()).ok())
141 .map(|d| d.with_timezone(&Utc));
142 let status = git(&wt_path, &["status", "--porcelain"]).unwrap_or_default();
143 (lc, !status.trim().is_empty())
144 };
145 let merged = branch
146 .as_ref()
147 .map(|b| merged_set.contains(b))
148 .unwrap_or(false);
149
150 out.push(Worktree {
151 repo_name: repo_name.clone(),
152 repo_path: repo.to_path_buf(),
153 worktree_path: wt_path,
154 branch,
155 last_commit,
156 merged,
157 dirty,
158 prunable,
159 is_main,
160 });
161 }
162 Ok(out)
163}
164
165pub fn scan_worktrees(roots: &[PathBuf], scan_depth: usize) -> (Vec<Worktree>, Vec<String>) {
169 let (repos, mut warnings) = find_repos(roots, scan_depth);
170 let results: Vec<Result<Vec<Worktree>>> = std::thread::scope(|s| {
171 let handles: Vec<_> = repos
172 .iter()
173 .map(|r| s.spawn(move || worktrees(r)))
174 .collect();
175 handles
176 .into_iter()
177 .map(|h| {
178 h.join()
179 .unwrap_or_else(|_| Err(anyhow::anyhow!("panic while scanning worktrees")))
180 })
181 .collect()
182 });
183 let mut all = Vec::new();
184 for (repo, res) in repos.iter().zip(results) {
185 match res {
186 Ok(mut w) => all.append(&mut w),
187 Err(e) => warnings.push(format!("{}: {e:#}", repo.display())),
188 }
189 }
190 (all, warnings)
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196 use crate::testutil;
197
198 fn wt(
199 branch: Option<&str>,
200 merged: bool,
201 dirty: bool,
202 prunable: bool,
203 is_main: bool,
204 ) -> Worktree {
205 Worktree {
206 repo_name: "app".into(),
207 repo_path: PathBuf::from("/tmp/app"),
208 worktree_path: PathBuf::from("/tmp/app/.wt/x"),
209 branch: branch.map(|b| b.into()),
210 last_commit: None,
211 merged,
212 dirty,
213 prunable,
214 is_main,
215 }
216 }
217
218 #[test]
219 fn verdict_covers_all_combinations() {
220 assert_eq!(
221 wt(Some("main"), true, false, false, true).verdict(),
222 Verdict::Home
223 );
224 assert_eq!(
225 wt(Some("x"), false, false, true, false).verdict(),
226 Verdict::Prunable
227 );
228 assert_eq!(
229 wt(Some("x"), false, true, false, false).verdict(),
230 Verdict::Active
231 );
232 assert_eq!(
233 wt(Some("x"), true, false, false, false).verdict(),
234 Verdict::Deletable
235 );
236 assert_eq!(
237 wt(Some("x"), false, false, false, false).verdict(),
238 Verdict::Cold
239 );
240 assert_eq!(
242 wt(None, false, false, false, false).verdict(),
243 Verdict::Active
244 );
245 assert_eq!(
247 wt(Some("main"), false, true, true, true).verdict(),
248 Verdict::Home
249 );
250 }
251
252 #[test]
253 fn short_name_uses_basename() {
254 let w = wt(Some("x"), false, false, false, false);
255 assert_eq!(w.short_name(), "app/x");
256 }
257
258 #[test]
259 fn worktrees_classifies_deletable_cold_and_dirty() {
260 let tmp = tempfile::tempdir().unwrap();
261 let repo = tmp.path().join("app");
262 testutil::init_repo(&repo);
263
264 let del = tmp.path().join("wt-del");
266 testutil::add_worktree(&repo, &del, "feat/done");
267
268 let cold = tmp.path().join("wt-cold");
270 testutil::add_worktree(&repo, &cold, "feat/cold");
271 std::fs::write(cold.join("c.txt"), "c").unwrap();
272 testutil::git(&cold, &["add", "."]);
273 testutil::git(&cold, &["commit", "-m", "wip cold"]);
274
275 let dirty = tmp.path().join("wt-dirty");
277 testutil::add_worktree(&repo, &dirty, "feat/dirty");
278 std::fs::write(dirty.join("d.txt"), "d").unwrap();
279
280 let all = worktrees(&repo).unwrap();
281 let by_branch = |b: &str| {
282 all.iter()
283 .find(|w| w.branch.as_deref() == Some(b))
284 .unwrap_or_else(|| panic!("branch {b} missing"))
285 };
286 assert_eq!(by_branch("feat/done").verdict(), Verdict::Deletable);
287 assert_eq!(by_branch("feat/cold").verdict(), Verdict::Cold);
288 assert_eq!(by_branch("feat/dirty").verdict(), Verdict::Active);
289
290 let main = all.iter().find(|w| w.is_main).expect("main worktree");
292 assert_eq!(main.verdict(), Verdict::Home);
293 }
294
295 #[test]
296 fn scan_worktrees_aggregates_and_does_not_abort() {
297 let tmp = tempfile::tempdir().unwrap();
298 let repo = tmp.path().join("app");
299 testutil::init_repo(&repo);
300 let extra = tmp.path().join("wt-extra");
301 testutil::add_worktree(&repo, &extra, "feat/extra");
302
303 let (all, warnings) = scan_worktrees(&[tmp.path().to_path_buf()], 4);
304 assert!(all
305 .iter()
306 .any(|w| w.branch.as_deref() == Some("feat/extra")));
307 assert!(warnings.is_empty());
308 }
309}