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 super::display::{format_age, get_worktree_status};
16
17/// Collected row for global worktree display.
18struct GlobalWorktreeRow {
19    repo_name: String,
20    worktree_id: String,
21    current_branch: String,
22    status: String,
23    age: String,
24    rel_path: String,
25}
26
27/// Minimum terminal width for global table layout (wider than local due to REPO column).
28const MIN_GLOBAL_TABLE_WIDTH: usize = 125;
29
30/// List worktrees across all registered repositories.
31pub fn global_list_worktrees() -> Result<()> {
32    // Auto-prune stale entries before listing
33    if let Ok(removed) = registry::prune_registry() {
34        if !removed.is_empty() {
35            println!(
36                "{}",
37                style(format!(
38                    "Auto-pruned {} stale registry entry(s)",
39                    removed.len()
40                ))
41                .dim()
42            );
43        }
44    }
45
46    let repos = registry::get_all_registered_repos();
47
48    if repos.is_empty() {
49        println!(
50            "\n{}\n\
51             Use {} to discover repositories,\n\
52             or run {} in a repository to auto-register it.\n",
53            style("No repositories registered.").yellow(),
54            style("gw -g scan").cyan(),
55            style("gw new").cyan(),
56        );
57        return Ok(());
58    }
59
60    println!("\n{}\n", style("Global Worktree Overview").cyan().bold());
61
62    let mut total_repos = 0usize;
63    let mut status_counts: std::collections::HashMap<String, usize> =
64        std::collections::HashMap::new();
65    let mut rows: Vec<GlobalWorktreeRow> = Vec::new();
66
67    let mut sorted_repos = repos;
68    sorted_repos.sort_by(|a, b| a.0.cmp(&b.0));
69
70    for (name, repo_path) in &sorted_repos {
71        if !repo_path.exists() {
72            println!(
73                "{} {} — {}",
74                style(format!("⚠ {}", name)).yellow(),
75                style(format!("({})", repo_path.display())).dim(),
76                style("repository not found").red(),
77            );
78            continue;
79        }
80
81        let feature_wts = match git::get_feature_worktrees(Some(repo_path)) {
82            Ok(wts) => wts,
83            Err(_) => {
84                println!(
85                    "{} {} — {}",
86                    style(format!("⚠ {}", name)).yellow(),
87                    style(format!("({})", repo_path.display())).dim(),
88                    style("failed to read worktrees").red(),
89                );
90                continue;
91            }
92        };
93
94        let mut has_feature = false;
95        for (branch_name, path) in &feature_wts {
96            let status = get_worktree_status(path, repo_path);
97
98            // Check intended branch for mismatch detection
99            let intended_key = format_config_key(CONFIG_KEY_INTENDED_BRANCH, branch_name);
100            let worktree_id =
101                git::get_config(&intended_key, Some(repo_path)).unwrap_or(branch_name.clone());
102
103            // Compute age
104            let age = path_age_days(path).map(format_age).unwrap_or_default();
105
106            // Relative path
107            let rel_path = pathdiff::diff_paths(path, repo_path)
108                .map(|p| p.to_string_lossy().to_string())
109                .unwrap_or_else(|| path.to_string_lossy().to_string());
110
111            *status_counts.entry(status.clone()).or_insert(0) += 1;
112
113            rows.push(GlobalWorktreeRow {
114                repo_name: name.clone(),
115                worktree_id,
116                current_branch: branch_name.clone(),
117                status,
118                age,
119                rel_path,
120            });
121
122            has_feature = true;
123        }
124
125        if has_feature {
126            total_repos += 1;
127        }
128    }
129
130    if rows.is_empty() {
131        println!(
132            "{}\n",
133            style("No repositories with active worktrees found.").yellow()
134        );
135        return Ok(());
136    }
137
138    // Choose layout based on terminal width
139    let term_width = cwconsole::terminal_width();
140    if term_width >= MIN_GLOBAL_TABLE_WIDTH {
141        global_print_table(&rows);
142    } else {
143        global_print_compact(&rows);
144    }
145
146    // Summary footer
147    let total_worktrees = rows.len();
148    let mut summary_parts = Vec::new();
149    for &status_name in &["clean", "modified", "active", "stale"] {
150        if let Some(&count) = status_counts.get(status_name) {
151            if count > 0 {
152                let styled = cwconsole::status_style(status_name)
153                    .apply_to(format!("{} {}", count, status_name));
154                summary_parts.push(styled.to_string());
155            }
156        }
157    }
158
159    let summary = if summary_parts.is_empty() {
160        format!("\n{} repo(s), {} worktree(s)", total_repos, total_worktrees)
161    } else {
162        format!(
163            "\n{} repo(s), {} worktree(s) — {}",
164            total_repos,
165            total_worktrees,
166            summary_parts.join(", ")
167        )
168    };
169    println!("{}", summary);
170    println!();
171
172    Ok(())
173}
174
175fn global_print_table(rows: &[GlobalWorktreeRow]) {
176    let max_repo = rows.iter().map(|r| r.repo_name.len()).max().unwrap_or(12);
177    let max_wt = rows.iter().map(|r| r.worktree_id.len()).max().unwrap_or(20);
178    let max_br = rows
179        .iter()
180        .map(|r| r.current_branch.len())
181        .max()
182        .unwrap_or(20);
183
184    let repo_col = max_repo.clamp(12, 25) + 2;
185    let wt_col = max_wt.clamp(20, 35) + 2;
186    let br_col = max_br.clamp(20, 35) + 2;
187
188    println!(
189        "{:<repo_col$} {:<wt_col$} {:<br_col$} {:<10} {:<12} PATH",
190        "REPO",
191        "WORKTREE",
192        "CURRENT BRANCH",
193        "STATUS",
194        "AGE",
195        repo_col = repo_col,
196        wt_col = wt_col,
197        br_col = br_col,
198    );
199    println!("{}", "─".repeat(repo_col + wt_col + br_col + 82));
200
201    for row in rows {
202        let branch_display = if row.worktree_id != row.current_branch {
203            style(format!("{} (⚠️)", row.current_branch))
204                .yellow()
205                .to_string()
206        } else {
207            row.current_branch.clone()
208        };
209
210        let status_styled =
211            cwconsole::status_style(&row.status).apply_to(format!("{:<10}", row.status));
212
213        println!(
214            "{:<repo_col$} {:<wt_col$} {:<br_col$} {} {:<12} {}",
215            row.repo_name,
216            row.worktree_id,
217            branch_display,
218            status_styled,
219            row.age,
220            row.rel_path,
221            repo_col = repo_col,
222            wt_col = wt_col,
223            br_col = br_col,
224        );
225    }
226}
227
228fn global_print_compact(rows: &[GlobalWorktreeRow]) {
229    let mut current_repo = String::new();
230
231    for row in rows {
232        if row.repo_name != current_repo {
233            if !current_repo.is_empty() {
234                println!(); // blank line between repos
235            }
236            println!("{}", style(&row.repo_name).bold());
237            current_repo = row.repo_name.clone();
238        }
239
240        let status_styled = cwconsole::status_style(&row.status).apply_to(&row.status);
241        let age_part = if row.age.is_empty() {
242            String::new()
243        } else {
244            format!("  {}", row.age)
245        };
246
247        println!(
248            "  {}  {}{}",
249            style(&row.worktree_id).bold(),
250            status_styled,
251            age_part,
252        );
253
254        let mut details = Vec::new();
255        if row.worktree_id != row.current_branch {
256            details.push(format!(
257                "branch: {}",
258                style(format!("{} (⚠️)", row.current_branch)).yellow()
259            ));
260        }
261        details.push(format!("path: {}", row.rel_path));
262        println!("    {}", details.join(" · "));
263    }
264}
265
266/// Scan for repositories (improved format matching Python).
267pub fn global_scan(base_dir: Option<&std::path::Path>) -> Result<()> {
268    let scan_dir = base_dir
269        .map(|p| p.to_path_buf())
270        .unwrap_or_else(home_dir_or_fallback);
271
272    println!(
273        "\n{}\n  Directory: {}\n",
274        style("Scanning for repositories...").cyan().bold(),
275        style(scan_dir.display()).blue(),
276    );
277
278    let found = registry::scan_for_repos(base_dir, 5);
279
280    if found.is_empty() {
281        println!(
282            "{}\n",
283            style("No repositories with worktrees found.").yellow()
284        );
285        return Ok(());
286    }
287
288    println!(
289        "{} Found {} repository(s):\n",
290        style("*").green().bold(),
291        found.len()
292    );
293
294    let mut sorted = found;
295    sorted.sort();
296    for repo_path in &sorted {
297        let _ = registry::register_repo(repo_path);
298        println!(
299            "  {} {} {}",
300            style("+").green(),
301            repo_path
302                .file_name()
303                .map(|n| n.to_string_lossy().to_string())
304                .unwrap_or_default(),
305            style(format!("({})", repo_path.display())).dim(),
306        );
307    }
308
309    println!(
310        "\n{} Registered {} repository(s)\n\
311         Use {} to see all worktrees.\n",
312        style("*").green().bold(),
313        sorted.len(),
314        style("gw -g list").cyan(),
315    );
316
317    Ok(())
318}
319
320/// Remove stale entries from the global registry (improved format).
321pub fn global_prune() -> Result<()> {
322    println!("\n{}\n", style("Pruning registry...").cyan().bold());
323
324    match registry::prune_registry() {
325        Ok(removed) => {
326            if removed.is_empty() {
327                println!(
328                    "{} Registry is clean, nothing to prune.\n",
329                    style("*").green().bold()
330                );
331            } else {
332                println!(
333                    "{} Removed {} stale entry(s):\n",
334                    style("*").green().bold(),
335                    removed.len()
336                );
337                for path in &removed {
338                    println!("  {} {}", style("-").red(), path);
339                }
340                println!();
341            }
342            Ok(())
343        }
344        Err(e) => Err(e),
345    }
346}