Skip to main content

git_worktree_manager/operations/
global_ops.rs

1//! Global worktree management operations.
2//!
3//! Business logic for cross-repository worktree commands (`gw -g`).
4
5use 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
20/// Collected row for global worktree display.
21struct 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
30/// Minimum terminal width for global table layout (wider than local due to REPO column).
31const MIN_GLOBAL_TABLE_WIDTH: usize = 125;
32
33/// List worktrees across all registered repositories.
34///
35/// `no_cache`: when true, bypasses the 60s PR-status cache and re-fetches
36/// from `gh pr list` for each repository.
37pub fn global_list_worktrees(no_cache: bool) -> Result<()> {
38    // TODO(perf): #38 — parallelize PrCache::load_or_fetch across repos using
39    // rayon: `repos.par_iter().map(|(n,r)| (n,r,PrCache::load_or_fetch(r,no_cache)))`.
40    // Deferred because the sequential per-repo loop below also prints/displays
41    // incrementally; parallelizing fetch while keeping serial display requires
42    // collecting into a pre-fetched Vec first. Acceptable for typical repo counts (~10s).
43    // Auto-prune stale entries before listing
44    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    // #2/#19: Pre-filter non-existent repos before the parallel cache fetch so
82    // we don't spend a `gh pr list` round-trip on missing repos. Missing repos
83    // are printed inline in the display loop below. A HashSet is used so each
84    // path is stat'd exactly once — the display loop just does O(1) lookups.
85    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    // Pre-fetch caches in parallel; the display loop must remain sequential
97    // to preserve output order. rayon handles thread pooling and join.
98    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        // #3/#14: use remove() to move the cache out of the map (avoids a clone).
133        // The loop has an .exists() check above (line ~99), but pr_caches is built
134        // from existing_repos, a snapshot taken before the loop. A repo that appears
135        // between the snapshot and this point passes the exists check yet has no entry
136        // in pr_caches — unwrap_or_default() handles that narrow timing window safely.
137        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            // Check intended branch for mismatch detection
145            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            // Compute age
150            let age = path_age_days(path).map(format_age).unwrap_or_default();
151
152            // Relative path
153            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    // Choose layout based on terminal width
185    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    // Summary footer
193    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!(); // blank line between repos
281            }
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
312/// Scan for repositories (improved format matching Python).
313pub 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
366/// Remove stale entries from the global registry (improved format).
367pub 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}