git_worktree_manager/operations/
global_ops.rs1use console::style;
6
7use crate::console as cwconsole;
8use crate::constants::{
9 format_config_key, home_dir_or_fallback, path_age_days, CONFIG_KEY_INTENDED_BRANCH,
10};
11use crate::error::Result;
12use crate::git;
13use crate::registry;
14
15use rayon::prelude::*;
16
17use super::display::{format_age, get_worktree_status};
18use super::pr_cache::PrCache;
19
20struct GlobalWorktreeRow {
22 repo_name: String,
23 worktree_id: String,
24 current_branch: String,
25 status: String,
26 age: String,
27 rel_path: String,
28}
29
30const MIN_GLOBAL_TABLE_WIDTH: usize = 125;
32
33pub fn global_list_worktrees(no_cache: bool) -> Result<()> {
38 if let Ok(removed) = registry::prune_registry() {
45 if !removed.is_empty() {
46 println!(
47 "{}",
48 style(format!(
49 "Auto-pruned {} stale registry entry(s)",
50 removed.len()
51 ))
52 .dim()
53 );
54 }
55 }
56
57 let repos = registry::get_all_registered_repos();
58
59 if repos.is_empty() {
60 println!(
61 "\n{}\n\
62 Use {} to discover repositories,\n\
63 or run {} in a repository to auto-register it.\n",
64 style("No repositories registered.").yellow(),
65 style("gw -g scan").cyan(),
66 style("gw new").cyan(),
67 );
68 return Ok(());
69 }
70
71 println!("\n{}\n", style("Global Worktree Overview").cyan().bold());
72
73 let mut total_repos = 0usize;
74 let mut status_counts: std::collections::HashMap<String, usize> =
75 std::collections::HashMap::new();
76 let mut rows: Vec<GlobalWorktreeRow> = Vec::new();
77
78 let mut sorted_repos = repos;
79 sorted_repos.sort_by(|a, b| a.0.cmp(&b.0));
80
81 use std::collections::HashSet;
86 let missing: HashSet<std::path::PathBuf> = sorted_repos
87 .iter()
88 .filter(|(_, p)| !p.exists())
89 .map(|(_, p)| p.clone())
90 .collect();
91 let existing_repos: Vec<_> = sorted_repos
92 .iter()
93 .filter(|(_, p)| !missing.contains(p))
94 .collect();
95
96 let mut pr_caches: std::collections::HashMap<std::path::PathBuf, PrCache> = existing_repos
99 .par_iter()
100 .map(|(_, repo_path)| {
101 (
102 (*repo_path).clone(),
103 PrCache::load_or_fetch(repo_path, no_cache),
104 )
105 })
106 .collect();
107
108 for (name, repo_path) in &sorted_repos {
109 if missing.contains(repo_path) {
110 println!(
111 "{} {} — {}",
112 style(format!("⚠ {}", name)).yellow(),
113 style(format!("({})", repo_path.display())).dim(),
114 style("repository not found").red(),
115 );
116 continue;
117 }
118
119 let feature_wts = match git::get_feature_worktrees(Some(repo_path)) {
120 Ok(wts) => wts,
121 Err(_) => {
122 println!(
123 "{} {} — {}",
124 style(format!("⚠ {}", name)).yellow(),
125 style(format!("({})", repo_path.display())).dim(),
126 style("failed to read worktrees").red(),
127 );
128 continue;
129 }
130 };
131
132 let pr_cache = pr_caches.remove(repo_path).unwrap_or_default();
138
139 let mut has_feature = false;
140 for (branch_name, path) in &feature_wts {
141 let status =
142 get_worktree_status(path, repo_path, Some(branch_name.as_str()), &pr_cache);
143
144 let intended_key = format_config_key(CONFIG_KEY_INTENDED_BRANCH, branch_name);
146 let worktree_id =
147 git::get_config(&intended_key, Some(repo_path)).unwrap_or(branch_name.clone());
148
149 let age = path_age_days(path).map(format_age).unwrap_or_default();
151
152 let rel_path = pathdiff::diff_paths(path, repo_path)
154 .map(|p| p.to_string_lossy().to_string())
155 .unwrap_or_else(|| path.to_string_lossy().to_string());
156
157 *status_counts.entry(status.clone()).or_insert(0) += 1;
158
159 rows.push(GlobalWorktreeRow {
160 repo_name: name.clone(),
161 worktree_id,
162 current_branch: branch_name.clone(),
163 status,
164 age,
165 rel_path,
166 });
167
168 has_feature = true;
169 }
170
171 if has_feature {
172 total_repos += 1;
173 }
174 }
175
176 if rows.is_empty() {
177 println!(
178 "{}\n",
179 style("No repositories with active worktrees found.").yellow()
180 );
181 return Ok(());
182 }
183
184 let term_width = cwconsole::terminal_width();
186 if term_width >= MIN_GLOBAL_TABLE_WIDTH {
187 global_print_table(&rows);
188 } else {
189 global_print_compact(&rows);
190 }
191
192 let total_worktrees = rows.len();
194 let mut summary_parts = Vec::new();
195 for &status_name in &["clean", "modified", "active", "stale"] {
196 if let Some(&count) = status_counts.get(status_name) {
197 if count > 0 {
198 let styled = cwconsole::status_style(status_name)
199 .apply_to(format!("{} {}", count, status_name));
200 summary_parts.push(styled.to_string());
201 }
202 }
203 }
204
205 let summary = if summary_parts.is_empty() {
206 format!("\n{} repo(s), {} worktree(s)", total_repos, total_worktrees)
207 } else {
208 format!(
209 "\n{} repo(s), {} worktree(s) — {}",
210 total_repos,
211 total_worktrees,
212 summary_parts.join(", ")
213 )
214 };
215 println!("{}", summary);
216 println!();
217
218 Ok(())
219}
220
221fn global_print_table(rows: &[GlobalWorktreeRow]) {
222 let max_repo = rows.iter().map(|r| r.repo_name.len()).max().unwrap_or(12);
223 let max_wt = rows.iter().map(|r| r.worktree_id.len()).max().unwrap_or(20);
224 let max_br = rows
225 .iter()
226 .map(|r| r.current_branch.len())
227 .max()
228 .unwrap_or(20);
229
230 let repo_col = max_repo.clamp(12, 25) + 2;
231 let wt_col = max_wt.clamp(20, 35) + 2;
232 let br_col = max_br.clamp(20, 35) + 2;
233
234 println!(
235 "{:<repo_col$} {:<wt_col$} {:<br_col$} {:<10} {:<12} PATH",
236 "REPO",
237 "WORKTREE",
238 "CURRENT BRANCH",
239 "STATUS",
240 "AGE",
241 repo_col = repo_col,
242 wt_col = wt_col,
243 br_col = br_col,
244 );
245 println!("{}", "─".repeat(repo_col + wt_col + br_col + 82));
246
247 for row in rows {
248 let branch_display = if row.worktree_id != row.current_branch {
249 style(format!("{} (⚠️)", row.current_branch))
250 .yellow()
251 .to_string()
252 } else {
253 row.current_branch.clone()
254 };
255
256 let status_styled =
257 cwconsole::status_style(&row.status).apply_to(format!("{:<10}", row.status));
258
259 println!(
260 "{:<repo_col$} {:<wt_col$} {:<br_col$} {} {:<12} {}",
261 row.repo_name,
262 row.worktree_id,
263 branch_display,
264 status_styled,
265 row.age,
266 row.rel_path,
267 repo_col = repo_col,
268 wt_col = wt_col,
269 br_col = br_col,
270 );
271 }
272}
273
274fn global_print_compact(rows: &[GlobalWorktreeRow]) {
275 let mut current_repo = String::new();
276
277 for row in rows {
278 if row.repo_name != current_repo {
279 if !current_repo.is_empty() {
280 println!(); }
282 println!("{}", style(&row.repo_name).bold());
283 current_repo = row.repo_name.clone();
284 }
285
286 let status_styled = cwconsole::status_style(&row.status).apply_to(&row.status);
287 let age_part = if row.age.is_empty() {
288 String::new()
289 } else {
290 format!(" {}", row.age)
291 };
292
293 println!(
294 " {} {}{}",
295 style(&row.worktree_id).bold(),
296 status_styled,
297 age_part,
298 );
299
300 let mut details = Vec::new();
301 if row.worktree_id != row.current_branch {
302 details.push(format!(
303 "branch: {}",
304 style(format!("{} (⚠️)", row.current_branch)).yellow()
305 ));
306 }
307 details.push(format!("path: {}", row.rel_path));
308 println!(" {}", details.join(" · "));
309 }
310}
311
312pub fn global_scan(base_dir: Option<&std::path::Path>) -> Result<()> {
314 let scan_dir = base_dir
315 .map(|p| p.to_path_buf())
316 .unwrap_or_else(home_dir_or_fallback);
317
318 println!(
319 "\n{}\n Directory: {}\n",
320 style("Scanning for repositories...").cyan().bold(),
321 style(scan_dir.display()).blue(),
322 );
323
324 let found = registry::scan_for_repos(base_dir, 5);
325
326 if found.is_empty() {
327 println!(
328 "{}\n",
329 style("No repositories with worktrees found.").yellow()
330 );
331 return Ok(());
332 }
333
334 println!(
335 "{} Found {} repository(s):\n",
336 style("*").green().bold(),
337 found.len()
338 );
339
340 let mut sorted = found;
341 sorted.sort();
342 for repo_path in &sorted {
343 let _ = registry::register_repo(repo_path);
344 println!(
345 " {} {} {}",
346 style("+").green(),
347 repo_path
348 .file_name()
349 .map(|n| n.to_string_lossy().to_string())
350 .unwrap_or_default(),
351 style(format!("({})", repo_path.display())).dim(),
352 );
353 }
354
355 println!(
356 "\n{} Registered {} repository(s)\n\
357 Use {} to see all worktrees.\n",
358 style("*").green().bold(),
359 sorted.len(),
360 style("gw -g list").cyan(),
361 );
362
363 Ok(())
364}
365
366pub fn global_prune() -> Result<()> {
368 println!("\n{}\n", style("Pruning registry...").cyan().bold());
369
370 match registry::prune_registry() {
371 Ok(removed) => {
372 if removed.is_empty() {
373 println!(
374 "{} Registry is clean, nothing to prune.\n",
375 style("*").green().bold()
376 );
377 } else {
378 println!(
379 "{} Removed {} stale entry(s):\n",
380 style("*").green().bold(),
381 removed.len()
382 );
383 for path in &removed {
384 println!(" {} {}", style("-").red(), path);
385 }
386 println!();
387 }
388 Ok(())
389 }
390 Err(e) => Err(e),
391 }
392}