Skip to main content

git_worktree_manager/operations/
display.rs

1/// Display and information operations for git-worktree-manager.
2///
3use std::path::Path;
4use std::sync::mpsc;
5
6// #35: two `console`-related imports are intentional:
7// - `console::style` is from the external `console` crate (ANSI styling)
8// - `crate::console as cwconsole` is this crate's own console helpers (terminal_width, etc.)
9// Aliasing avoids a name collision that would shadow the crate's `style` function.
10use console::style;
11use ratatui::{backend::CrosstermBackend, Terminal, TerminalOptions, Viewport};
12
13use crate::console as cwconsole;
14use crate::constants::{
15    format_config_key, path_age_days, sanitize_branch_name, CONFIG_KEY_BASE_BRANCH,
16    CONFIG_KEY_BASE_PATH, CONFIG_KEY_INTENDED_BRANCH,
17};
18use crate::error::Result;
19use crate::git;
20
21use rayon::prelude::*;
22
23use super::pr_cache::PrCache;
24
25/// Minimum terminal width for table layout; below this, use compact layout.
26const MIN_TABLE_WIDTH: usize = 100;
27
28// TODO(perf): hoist `base_branch` and `cwd_canon` lookups out of `get_worktree_status`
29// to avoid N×git-config calls. ~6 call sites; consider a `WorktreeContext` struct.
30// (Deferred in this PR to keep diff scope manageable.)
31
32/// Determine the status of a worktree.
33///
34/// Status priority: stale > busy > active > merged > pr-open > modified > clean
35///
36/// Merge detection strategy:
37/// 1. Cached `gh pr list` (primary) — works with all merge strategies (merge
38///    commit, squash merge, rebase merge) because GitHub tracks PR state
39///    independently of commit SHAs. One `gh` call per repo, cached 60 s.
40/// 2. `git branch --merged` (fallback) — only works when commit SHAs are
41///    preserved (merge commit strategy). Used when `gh` is not available.
42///
43/// See `pr_cache::PrCache` for the batched fetch and TTL details.
44pub fn get_worktree_status(
45    path: &Path,
46    repo: &Path,
47    branch: Option<&str>,
48    pr_cache: &PrCache,
49) -> String {
50    if !path.exists() {
51        return "stale".to_string();
52    }
53
54    // Busy beats "active": another session (claude, shell, editor) holds this
55    // worktree. The current process and its ancestors are excluded inside
56    // detect_busy so the caller's own shell does not self-report.
57    if !crate::operations::busy::detect_busy(path).is_empty() {
58        return "busy".to_string();
59    }
60
61    // Check if cwd is inside this worktree. Canonicalize both sides so that
62    // symlink skew (e.g. macOS /var vs /private/var) does not miss a match.
63    if let Ok(cwd) = std::env::current_dir() {
64        let cwd_canon = cwd.canonicalize().unwrap_or(cwd);
65        let path_canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
66        if cwd_canon.starts_with(&path_canon) {
67            return "active".to_string();
68        }
69    }
70
71    // Check merge/PR status if branch name is available
72    if let Some(branch_name) = branch {
73        let base_branch = {
74            let key = format_config_key(CONFIG_KEY_BASE_BRANCH, branch_name);
75            git::get_config(&key, Some(repo))
76                .unwrap_or_else(|| git::detect_default_branch(Some(repo)))
77        };
78
79        // Primary: cached PR state from a single `gh pr list` call.
80        if let Some(state) = pr_cache.state(branch_name) {
81            match state {
82                super::pr_cache::PrState::Merged => return "merged".to_string(),
83                super::pr_cache::PrState::Open => return "pr-open".to_string(),
84                // Closed/Other: fall through to git-based merge detection
85                _ => {}
86            }
87        }
88
89        // Fallback: git branch --merged (only works for merge-commit strategy)
90        // Used when `gh` is not installed or no PR was created
91        if git::is_branch_merged(branch_name, &base_branch, Some(repo)) {
92            return "merged".to_string();
93        }
94    }
95
96    // Check for uncommitted changes
97    if let Ok(result) = git::git_command(&["status", "--porcelain"], Some(path), false, true) {
98        if result.returncode == 0 && !result.stdout.trim().is_empty() {
99            return "modified".to_string();
100        }
101    }
102
103    "clean".to_string()
104}
105
106/// Format age in days to human-readable string.
107pub fn format_age(age_days: f64) -> String {
108    if age_days < 1.0 {
109        let hours = (age_days * 24.0) as i64;
110        if hours > 0 {
111            format!("{}h ago", hours)
112        } else {
113            "just now".to_string()
114        }
115    } else if age_days < 7.0 {
116        format!("{}d ago", age_days as i64)
117    } else if age_days < 30.0 {
118        format!("{}w ago", (age_days / 7.0) as i64)
119    } else if age_days < 365.0 {
120        format!("{}mo ago", (age_days / 30.0) as i64)
121    } else {
122        format!("{}y ago", (age_days / 365.0) as i64)
123    }
124}
125
126/// Compute age string for a path.
127fn path_age_str(path: &Path) -> String {
128    if !path.exists() {
129        return String::new();
130    }
131    path_age_days(path).map(format_age).unwrap_or_default()
132}
133
134/// Collected worktree data row for display.
135struct WorktreeRow {
136    worktree_id: String,
137    current_branch: String,
138    status: String,
139    age: String,
140    rel_path: String,
141}
142
143/// Serial-prep input passed to status computation.
144/// Shares all fields with `WorktreeRow` except `path` (used to compute status)
145/// and `status` itself (filled in by the parallel worker).
146#[derive(Clone)]
147struct RowInput {
148    path: std::path::PathBuf,
149    current_branch: String,
150    worktree_id: String,
151    age: String,
152    rel_path: String,
153}
154
155impl RowInput {
156    fn into_row(self, status: String) -> WorktreeRow {
157        WorktreeRow {
158            worktree_id: self.worktree_id,
159            current_branch: self.current_branch,
160            status,
161            age: self.age,
162            rel_path: self.rel_path,
163        }
164    }
165}
166
167/// List all worktrees for the current repository.
168pub fn list_worktrees(no_cache: bool) -> Result<()> {
169    let repo = git::get_repo_root(None)?;
170    let worktrees = git::parse_worktrees(&repo)?;
171
172    println!(
173        "\n{}  {}\n",
174        style("Worktrees for repository:").cyan().bold(),
175        repo.display()
176    );
177
178    let pr_cache = PrCache::load_or_fetch(&repo, no_cache);
179
180    // Serial prep: cheap local work. Keep single-threaded for clarity.
181    let inputs: Vec<RowInput> = worktrees
182        .iter()
183        .map(|(branch, path)| {
184            let current_branch = git::normalize_branch_name(branch).to_string();
185            let rel_path = pathdiff::diff_paths(path, &repo)
186                .map(|p: std::path::PathBuf| p.to_string_lossy().to_string())
187                .unwrap_or_else(|| path.to_string_lossy().to_string());
188            let age = path_age_str(path);
189            let intended_branch = lookup_intended_branch(&repo, &current_branch, path);
190            let worktree_id = intended_branch.unwrap_or_else(|| current_branch.clone());
191            RowInput {
192                path: path.clone(),
193                current_branch,
194                worktree_id,
195                age,
196                rel_path,
197            }
198        })
199        .collect();
200
201    if inputs.is_empty() {
202        println!("  {}\n", style("No worktrees found.").dim());
203        return Ok(());
204    }
205
206    let is_tty = crate::tui::stdout_is_tty();
207    // #18/#33/#35: cache terminal_width() once — used in both the progressive/static
208    // branch decision and the post-render print guard.
209    let term_width = cwconsole::terminal_width();
210    // #35: extract narrow so the two places that check MIN_TABLE_WIDTH share
211    // a single bool and cannot drift out of sync.
212    let narrow = term_width < MIN_TABLE_WIDTH;
213    let use_progressive = is_tty && !narrow;
214
215    let rows: Vec<WorktreeRow> = if use_progressive {
216        render_rows_progressive(&repo, &pr_cache, inputs)?
217    } else {
218        // rayon borrows &pr_cache across workers via the type system.
219        inputs
220            .into_par_iter()
221            .map(|i| {
222                let status =
223                    get_worktree_status(&i.path, &repo, Some(&i.current_branch), &pr_cache);
224                i.into_row(status)
225            })
226            .collect()
227    };
228
229    // In the TTY+wide path the Inline Viewport has already drawn the table.
230    // In the static path (narrow terminal or non-TTY) we still need to print.
231    if !use_progressive {
232        if narrow {
233            print_worktree_compact(&rows);
234        } else {
235            print_worktree_table(&rows);
236        }
237    }
238
239    // Footer is printed via println! after the Inline Viewport drops, so it
240    // appears below the table in scrollback. Alignment is correct for the
241    // static path; in the TTY path the viewport already committed the table
242    // rows and the footer follows naturally. Using terminal.insert_before()
243    // could align it inside the viewport, but the current behaviour is
244    // acceptable and avoids extra ratatui complexity.
245    print_summary_footer(&rows);
246
247    println!();
248    Ok(())
249}
250
251/// RAII guard: drops the terminal before calling `ratatui::restore()`.
252/// Ensures terminal modes are restored deterministically even on early return
253/// or panic, without relying on a closure-then-restore pattern.
254///
255/// #19: concrete backend type avoids unnecessary generics — this guard is only
256/// ever created for the crossterm+stdout path used in `render_rows_progressive`.
257type CrosstermTerminal = ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stdout>>;
258
259/// Wraps a ratatui Terminal with deterministic cleanup.
260///
261/// # Contract
262/// - The caller must call `mark_ratatui_active()` before constructing the terminal.
263/// - On Drop, this guard drops the terminal first, then calls `mark_ratatui_inactive()`
264///   followed by `ratatui::restore()`.
265struct TerminalGuard(Option<CrosstermTerminal>);
266
267impl TerminalGuard {
268    fn new(terminal: CrosstermTerminal) -> Self {
269        // #1/#20: flag is already set by the caller before Terminal::with_options;
270        // the caller's error path calls mark_ratatui_inactive if construction fails.
271        // Here we just store the terminal — the flag is already live.
272        Self(Some(terminal))
273    }
274
275    fn as_mut(&mut self) -> &mut CrosstermTerminal {
276        self.0.as_mut().expect("terminal already taken")
277    }
278}
279
280impl Drop for TerminalGuard {
281    fn drop(&mut self) {
282        let _ = self.0.take(); // drop terminal first, releasing raw mode if any
283        ratatui::restore();
284        // #20: clear the panic-hook flag after restore — a subsequent panic
285        // (unlikely but possible) must not try to restore a non-existent terminal.
286        crate::tui::mark_ratatui_inactive();
287    }
288}
289
290fn render_rows_progressive(
291    repo: &std::path::Path,
292    pr_cache: &PrCache,
293    inputs: Vec<RowInput>,
294) -> Result<Vec<WorktreeRow>> {
295    // Build skeleton app.
296    let row_data: Vec<crate::tui::list_view::RowData> = inputs
297        .iter()
298        .map(|i| crate::tui::list_view::RowData {
299            worktree_id: i.worktree_id.clone(),
300            current_branch: i.current_branch.clone(),
301            status: crate::tui::list_view::PLACEHOLDER.to_string(),
302            age: i.age.clone(),
303            rel_path: i.rel_path.clone(),
304        })
305        .collect();
306    let mut app = crate::tui::list_view::ListApp::new(row_data);
307
308    // `+2` accounts for the header row plus a trailing blank line. Borders are
309    // disabled (`Borders::NONE`); the spec's `+4` figure assumed bordered layout.
310    let viewport_height = u16::try_from(inputs.len())
311        .unwrap_or(u16::MAX)
312        .saturating_add(2)
313        .max(3);
314
315    let stdout = std::io::stdout();
316    let backend = CrosstermBackend::new(stdout);
317    // #1/#5: mark active BEFORE construction so the panic hook fires correctly
318    // if Terminal::with_options itself panics. If it returns Err or panics, we
319    // clear the flag before propagating so a non-ratatui panic later is not
320    // mishandled.
321    // Restore is idempotent — if construction fails or panics, the panic hook
322    // may still call `ratatui::restore()`, which is documented safe.
323    crate::tui::mark_ratatui_active();
324    let terminal = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
325        Terminal::with_options(
326            backend,
327            TerminalOptions {
328                viewport: Viewport::Inline(viewport_height),
329            },
330        )
331    })) {
332        Ok(Ok(t)) => t,
333        Ok(Err(e)) => {
334            crate::tui::mark_ratatui_inactive();
335            return Err(e.into());
336        }
337        Err(panic) => {
338            crate::tui::mark_ratatui_inactive();
339            std::panic::resume_unwind(panic);
340        }
341    };
342    let mut guard = TerminalGuard::new(terminal);
343    // Note: no test exercises a panicking Terminal::with_options. The double-restore
344    // path (panic hook + TerminalGuard::Drop) is documented safe in ratatui 0.28.
345
346    // Producer: parallel per-worktree status computation on a dedicated OS
347    // thread; rayon parallelism is used within that thread.
348    //
349    // Uses std::thread::scope so the consumer (list_view::run) on the main
350    // thread can interleave with the producer thread, giving true progressive
351    // rendering. Producer panics are caught by the scope's join-on-exit and
352    // the sweep below promotes remaining "..." placeholders to "unknown".
353    //
354    // Uses rayon's default pool (CPU cores). Each worker spawns `git`
355    // subprocesses, which is I/O-bound but small enough that oversubscription
356    // doesn't help.
357    let (tx, rx) = mpsc::channel();
358
359    // Draw skeleton immediately so the user sees the table even before any
360    // status computations finish. `list_view::run` would draw this on its
361    // first iteration, but for very fast producers (small repos) the rows
362    // can fill before that initial draw.
363    guard.as_mut().draw(|f| app.render(f))?;
364
365    // `thread::scope` blocks until all spawned threads finish (when the closure
366    // returns). The explicit `producer.join()` here is solely to extract the
367    // panic payload for diagnostics; the actual join would happen automatically
368    // at scope exit.
369    std::thread::scope(|s| -> Result<()> {
370        let producer = s.spawn(move || {
371            inputs
372                .par_iter()
373                .enumerate()
374                .for_each_with(tx, |tx, (i, input)| {
375                    let status = get_worktree_status(
376                        &input.path,
377                        repo,
378                        Some(&input.current_branch),
379                        pr_cache,
380                    );
381                    let _ = tx.send((i, status));
382                });
383        });
384
385        let run_result = crate::tui::list_view::run(guard.as_mut(), &mut app, rx);
386        let producer_result = producer.join();
387        if let Err(panic) = producer_result {
388            // #3: extract a readable message from the panic payload.
389            let msg = panic
390                .downcast_ref::<&str>()
391                .map(|s| (*s).to_string())
392                .or_else(|| panic.downcast_ref::<String>().cloned())
393                .unwrap_or_else(|| "non-string panic payload".to_string());
394            eprintln!(
395                "warning: status producer thread panicked, some rows may show \"unknown\": {}",
396                msg
397            );
398        }
399        run_result.map_err(crate::error::CwError::from)
400    })?;
401
402    // Defensive sweep: if the producer panicked, some rows may still carry
403    // the skeleton placeholder. Promote those to a visible "unknown" status
404    // so the footer summary doesn't count the placeholder literal.
405    // #5/#39: only redraw when something actually changed to avoid adding a
406    // duplicate table frame to scrollback. finalize_pending returns true iff
407    // at least one placeholder was replaced.
408    if app.finalize_pending("unknown") {
409        guard.as_mut().draw(|f| app.render(f))?;
410    }
411
412    Ok(app.into_rows().into_iter().map(Into::into).collect())
413}
414
415/// Field-for-field 1:1 mapping from TUI row data to the internal display row.
416/// Both structs are intentionally isomorphic; the destructuring below makes a
417/// new field in `RowData` a compile error here — exactly the safety the reviewer wanted.
418impl From<crate::tui::list_view::RowData> for WorktreeRow {
419    fn from(r: crate::tui::list_view::RowData) -> Self {
420        let crate::tui::list_view::RowData {
421            worktree_id,
422            current_branch,
423            status,
424            age,
425            rel_path,
426        } = r;
427        WorktreeRow {
428            worktree_id,
429            current_branch,
430            status,
431            age,
432            rel_path,
433        }
434    }
435}
436
437/// Look up the intended branch for a worktree via git config metadata.
438fn lookup_intended_branch(repo: &Path, current_branch: &str, path: &Path) -> Option<String> {
439    // Try direct lookup
440    let key = format_config_key(CONFIG_KEY_INTENDED_BRANCH, current_branch);
441    if let Some(intended) = git::get_config(&key, Some(repo)) {
442        return Some(intended);
443    }
444
445    // Search all intended branch metadata
446    let result = git::git_command(
447        &[
448            "config",
449            "--local",
450            "--get-regexp",
451            r"^worktree\..*\.intendedBranch",
452        ],
453        Some(repo),
454        false,
455        true,
456    )
457    .ok()?;
458
459    if result.returncode != 0 {
460        return None;
461    }
462
463    let repo_name = repo.file_name()?.to_string_lossy().to_string();
464
465    for line in result.stdout.trim().lines() {
466        let parts: Vec<&str> = line.splitn(2, char::is_whitespace).collect();
467        if parts.len() == 2 {
468            let key_parts: Vec<&str> = parts[0].split('.').collect();
469            if key_parts.len() >= 2 {
470                let branch_from_key = key_parts[1];
471                let expected_path_name =
472                    format!("{}-{}", repo_name, sanitize_branch_name(branch_from_key));
473                if let Some(name) = path.file_name() {
474                    if name.to_string_lossy() == expected_path_name {
475                        return Some(parts[1].to_string());
476                    }
477                }
478            }
479        }
480    }
481
482    None
483}
484
485fn print_summary_footer(rows: &[WorktreeRow]) {
486    // The first worktree is the primary repo checkout — exclude it from the
487    // "feature worktree" count.
488    let feature_count = if rows.len() > 1 { rows.len() - 1 } else { 0 };
489    if feature_count == 0 {
490        return;
491    }
492
493    let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
494    for row in rows {
495        *counts.entry(row.status.as_str()).or_insert(0) += 1;
496    }
497
498    let mut summary_parts = Vec::new();
499    for &status_name in &[
500        "clean", "modified", "busy", "active", "pr-open", "merged", "stale",
501    ] {
502        if let Some(&count) = counts.get(status_name) {
503            if count > 0 {
504                let styled = cwconsole::status_style(status_name)
505                    .apply_to(format!("{} {}", count, status_name));
506                summary_parts.push(styled.to_string());
507            }
508        }
509    }
510
511    let summary = if summary_parts.is_empty() {
512        format!("\n{} feature worktree(s)", feature_count)
513    } else {
514        format!(
515            "\n{} feature worktree(s) — {}",
516            feature_count,
517            summary_parts.join(", ")
518        )
519    };
520    println!("{}", summary);
521}
522
523fn print_worktree_table(rows: &[WorktreeRow]) {
524    let max_wt = rows.iter().map(|r| r.worktree_id.len()).max().unwrap_or(20);
525    let max_br = rows
526        .iter()
527        .map(|r| r.current_branch.len())
528        .max()
529        .unwrap_or(20);
530    let wt_col = max_wt.clamp(12, 35) + 2;
531    let br_col = max_br.clamp(12, 35) + 2;
532
533    println!(
534        "  {} {:<wt_col$} {:<br_col$} {:<10} {:<12} {}",
535        style(" ").dim(),
536        style("WORKTREE").dim(),
537        style("BRANCH").dim(),
538        style("STATUS").dim(),
539        style("AGE").dim(),
540        style("PATH").dim(),
541        wt_col = wt_col,
542        br_col = br_col,
543    );
544    let line_width = (wt_col + br_col + 40).min(cwconsole::terminal_width().saturating_sub(4));
545    println!("  {}", style("─".repeat(line_width)).dim());
546
547    for row in rows {
548        let icon = cwconsole::status_icon(&row.status);
549        let st = cwconsole::status_style(&row.status);
550
551        let branch_display = if row.worktree_id != row.current_branch {
552            style(format!("{} ⚠", row.current_branch))
553                .yellow()
554                .to_string()
555        } else {
556            row.current_branch.clone()
557        };
558
559        let status_styled = st.apply_to(format!("{:<10}", row.status));
560
561        println!(
562            "  {} {:<wt_col$} {:<br_col$} {} {:<12} {}",
563            st.apply_to(icon),
564            style(&row.worktree_id).bold(),
565            branch_display,
566            status_styled,
567            style(&row.age).dim(),
568            style(&row.rel_path).dim(),
569            wt_col = wt_col,
570            br_col = br_col,
571        );
572    }
573}
574
575fn print_worktree_compact(rows: &[WorktreeRow]) {
576    for row in rows {
577        let icon = cwconsole::status_icon(&row.status);
578        let st = cwconsole::status_style(&row.status);
579        let age_part = if row.age.is_empty() {
580            String::new()
581        } else {
582            format!("  {}", style(&row.age).dim())
583        };
584
585        println!(
586            "  {} {}  {}{}",
587            st.apply_to(icon),
588            style(&row.worktree_id).bold(),
589            st.apply_to(&row.status),
590            age_part,
591        );
592
593        let mut details = Vec::new();
594        if row.worktree_id != row.current_branch {
595            details.push(format!(
596                "branch: {}",
597                style(format!("{} ⚠", row.current_branch)).yellow()
598            ));
599        }
600        if !row.rel_path.is_empty() {
601            details.push(format!("{}", style(&row.rel_path).dim()));
602        }
603        if !details.is_empty() {
604            println!("      {}", details.join("  "));
605        }
606    }
607}
608
609/// Show status of current worktree and list all worktrees.
610pub fn show_status(no_cache: bool) -> Result<()> {
611    let repo = git::get_repo_root(None)?;
612
613    match git::get_current_branch(Some(&std::env::current_dir().unwrap_or_default())) {
614        Ok(branch) => {
615            let base_key = format_config_key(CONFIG_KEY_BASE_BRANCH, &branch);
616            let path_key = format_config_key(CONFIG_KEY_BASE_PATH, &branch);
617            let base = git::get_config(&base_key, Some(&repo));
618            let base_path = git::get_config(&path_key, Some(&repo));
619
620            println!("\n{}", style("Current worktree:").cyan().bold());
621            println!("  Feature:  {}", style(&branch).green());
622            println!(
623                "  Base:     {}",
624                style(base.as_deref().unwrap_or("N/A")).green()
625            );
626            println!(
627                "  Base path: {}\n",
628                style(base_path.as_deref().unwrap_or("N/A")).blue()
629            );
630        }
631        Err(_) => {
632            println!(
633                "\n{}\n",
634                style("Current directory is not a feature worktree or is the main repository.")
635                    .yellow()
636            );
637        }
638    }
639
640    list_worktrees(no_cache)
641}
642
643/// Display worktree hierarchy in a visual tree format.
644pub fn show_tree(no_cache: bool) -> Result<()> {
645    let repo = git::get_repo_root(None)?;
646    let cwd = std::env::current_dir().unwrap_or_default();
647
648    let repo_name = repo
649        .file_name()
650        .map(|n| n.to_string_lossy().to_string())
651        .unwrap_or_else(|| "repo".to_string());
652
653    println!(
654        "\n{} (base repository)",
655        style(format!("{}/", repo_name)).cyan().bold()
656    );
657    println!("{}\n", style(repo.display().to_string()).dim());
658
659    let feature_worktrees = git::get_feature_worktrees(Some(&repo))?;
660
661    if feature_worktrees.is_empty() {
662        println!("{}\n", style("  (no feature worktrees)").dim());
663        return Ok(());
664    }
665
666    let mut sorted = feature_worktrees;
667    sorted.sort_by(|a, b| a.0.cmp(&b.0));
668
669    let pr_cache = PrCache::load_or_fetch(&repo, no_cache);
670
671    for (i, (branch_name, path)) in sorted.iter().enumerate() {
672        let is_last = i == sorted.len() - 1;
673        let prefix = if is_last { "└── " } else { "├── " };
674
675        let status = get_worktree_status(path, &repo, Some(branch_name.as_str()), &pr_cache);
676        let is_current = cwd
677            .to_string_lossy()
678            .starts_with(&path.to_string_lossy().to_string());
679
680        let icon = cwconsole::status_icon(&status);
681        let st = cwconsole::status_style(&status);
682
683        let branch_display = if is_current {
684            st.clone()
685                .bold()
686                .apply_to(format!("★ {}", branch_name))
687                .to_string()
688        } else {
689            st.clone().apply_to(branch_name.as_str()).to_string()
690        };
691
692        let age = path_age_str(path);
693        let age_display = if age.is_empty() {
694            String::new()
695        } else {
696            format!("  {}", style(age).dim())
697        };
698
699        println!(
700            "{}{} {}{}",
701            prefix,
702            st.apply_to(icon),
703            branch_display,
704            age_display
705        );
706
707        let path_display = if let Ok(rel) = path.strip_prefix(repo.parent().unwrap_or(&repo)) {
708            format!("../{}", rel.display())
709        } else {
710            path.display().to_string()
711        };
712
713        let continuation = if is_last { "    " } else { "│   " };
714        println!("{}{}", continuation, style(&path_display).dim());
715    }
716
717    // Legend
718    println!("\n{}", style("Legend:").bold());
719    println!(
720        "  {} active (current)",
721        cwconsole::status_style("active").apply_to("●")
722    );
723    println!("  {} clean", cwconsole::status_style("clean").apply_to("○"));
724    println!(
725        "  {} modified",
726        cwconsole::status_style("modified").apply_to("◉")
727    );
728    println!(
729        "  {} pr-open",
730        cwconsole::status_style("pr-open").apply_to("⬆")
731    );
732    println!(
733        "  {} merged",
734        cwconsole::status_style("merged").apply_to("✓")
735    );
736    println!(
737        "  {} busy (other session)",
738        cwconsole::status_style("busy").apply_to("🔒")
739    );
740    println!("  {} stale", cwconsole::status_style("stale").apply_to("x"));
741    println!(
742        "  {} currently active worktree\n",
743        style("★").green().bold()
744    );
745
746    Ok(())
747}
748
749/// Display usage analytics for worktrees.
750pub fn show_stats(no_cache: bool) -> Result<()> {
751    let repo = git::get_repo_root(None)?;
752    let feature_worktrees = git::get_feature_worktrees(Some(&repo))?;
753
754    if feature_worktrees.is_empty() {
755        println!("\n{}\n", style("No feature worktrees found").yellow());
756        return Ok(());
757    }
758
759    println!();
760    println!("  {}", style("Worktree Statistics").cyan().bold());
761    println!("  {}", style("─".repeat(40)).dim());
762    println!();
763
764    struct WtData {
765        branch: String,
766        status: String,
767        age_days: f64,
768        commit_count: usize,
769    }
770
771    let mut data: Vec<WtData> = Vec::new();
772
773    let pr_cache = PrCache::load_or_fetch(&repo, no_cache);
774
775    for (branch_name, path) in &feature_worktrees {
776        let status = get_worktree_status(path, &repo, Some(branch_name.as_str()), &pr_cache);
777        let age_days = path_age_days(path).unwrap_or(0.0);
778
779        let commit_count = git::git_command(
780            &["rev-list", "--count", branch_name],
781            Some(path),
782            false,
783            true,
784        )
785        .ok()
786        .and_then(|r| {
787            if r.returncode == 0 {
788                r.stdout.trim().parse::<usize>().ok()
789            } else {
790                None
791            }
792        })
793        .unwrap_or(0);
794
795        data.push(WtData {
796            branch: branch_name.clone(),
797            status,
798            age_days,
799            commit_count,
800        });
801    }
802
803    // Overview
804    let mut status_counts: std::collections::HashMap<&str, usize> =
805        std::collections::HashMap::new();
806    for d in &data {
807        *status_counts.entry(d.status.as_str()).or_insert(0) += 1;
808    }
809
810    println!("  {} {}", style("Total:").bold(), data.len());
811
812    // Status bar visualization
813    let total = data.len();
814    let bar_width = 30;
815    let clean = *status_counts.get("clean").unwrap_or(&0);
816    let modified = *status_counts.get("modified").unwrap_or(&0);
817    let active = *status_counts.get("active").unwrap_or(&0);
818    let pr_open = *status_counts.get("pr-open").unwrap_or(&0);
819    let merged = *status_counts.get("merged").unwrap_or(&0);
820    let busy = *status_counts.get("busy").unwrap_or(&0);
821    let stale = *status_counts.get("stale").unwrap_or(&0);
822
823    let bar_clean = (clean * bar_width) / total.max(1);
824    let bar_modified = (modified * bar_width) / total.max(1);
825    let bar_active = (active * bar_width) / total.max(1);
826    let bar_pr_open = (pr_open * bar_width) / total.max(1);
827    let bar_merged = (merged * bar_width) / total.max(1);
828    let bar_busy = (busy * bar_width) / total.max(1);
829    let bar_stale = (stale * bar_width) / total.max(1);
830    // Fill remaining with clean if rounding left gaps
831    let bar_remainder = bar_width
832        - bar_clean
833        - bar_modified
834        - bar_active
835        - bar_pr_open
836        - bar_merged
837        - bar_busy
838        - bar_stale;
839
840    print!("  ");
841    print!("{}", style("█".repeat(bar_clean + bar_remainder)).green());
842    print!("{}", style("█".repeat(bar_modified)).yellow());
843    print!("{}", style("█".repeat(bar_active)).green().bold());
844    print!("{}", style("█".repeat(bar_pr_open)).cyan());
845    print!("{}", style("█".repeat(bar_merged)).magenta());
846    print!("{}", style("█".repeat(bar_busy)).red().bold());
847    print!("{}", style("█".repeat(bar_stale)).red());
848    println!();
849
850    let mut parts = Vec::new();
851    if clean > 0 {
852        parts.push(format!("{}", style(format!("○ {} clean", clean)).green()));
853    }
854    if modified > 0 {
855        parts.push(format!(
856            "{}",
857            style(format!("◉ {} modified", modified)).yellow()
858        ));
859    }
860    if active > 0 {
861        parts.push(format!(
862            "{}",
863            style(format!("● {} active", active)).green().bold()
864        ));
865    }
866    if pr_open > 0 {
867        parts.push(format!(
868            "{}",
869            style(format!("⬆ {} pr-open", pr_open)).cyan()
870        ));
871    }
872    if merged > 0 {
873        parts.push(format!(
874            "{}",
875            style(format!("✓ {} merged", merged)).magenta()
876        ));
877    }
878    if busy > 0 {
879        parts.push(format!(
880            "{}",
881            style(format!("🔒 {} busy", busy)).red().bold()
882        ));
883    }
884    if stale > 0 {
885        parts.push(format!("{}", style(format!("x {} stale", stale)).red()));
886    }
887    println!("  {}", parts.join("  "));
888    println!();
889
890    // Age statistics
891    let ages: Vec<f64> = data
892        .iter()
893        .filter(|d| d.age_days > 0.0)
894        .map(|d| d.age_days)
895        .collect();
896    if !ages.is_empty() {
897        let avg = ages.iter().sum::<f64>() / ages.len() as f64;
898        let oldest = ages.iter().cloned().fold(0.0_f64, f64::max);
899        let newest = ages.iter().cloned().fold(f64::MAX, f64::min);
900
901        println!("  {} Age", style("◷").dim());
902        println!(
903            "    avg {}  oldest {}  newest {}",
904            style(format!("{:.1}d", avg)).bold(),
905            style(format!("{:.1}d", oldest)).yellow(),
906            style(format!("{:.1}d", newest)).green(),
907        );
908        println!();
909    }
910
911    // Commit statistics
912    let commits: Vec<usize> = data
913        .iter()
914        .filter(|d| d.commit_count > 0)
915        .map(|d| d.commit_count)
916        .collect();
917    if !commits.is_empty() {
918        let total: usize = commits.iter().sum();
919        let avg = total as f64 / commits.len() as f64;
920        let max_c = *commits.iter().max().unwrap_or(&0);
921
922        println!("  {} Commits", style("⟲").dim());
923        println!(
924            "    total {}  avg {:.1}  max {}",
925            style(total).bold(),
926            avg,
927            style(max_c).bold(),
928        );
929        println!();
930    }
931
932    // Top by age
933    println!("  {}", style("Oldest Worktrees").bold());
934    let mut by_age = data.iter().collect::<Vec<_>>();
935    by_age.sort_by(|a, b| b.age_days.total_cmp(&a.age_days));
936    let max_age = by_age.first().map(|d| d.age_days).unwrap_or(1.0).max(1.0);
937    for d in by_age.iter().take(5) {
938        if d.age_days > 0.0 {
939            let icon = cwconsole::status_icon(&d.status);
940            let st = cwconsole::status_style(&d.status);
941            let bar_len = ((d.age_days / max_age) * 15.0) as usize;
942            println!(
943                "    {} {:<25} {} {}",
944                st.apply_to(icon),
945                d.branch,
946                style("▓".repeat(bar_len.max(1))).dim(),
947                style(format_age(d.age_days)).dim(),
948            );
949        }
950    }
951    println!();
952
953    // Top by commits
954    println!("  {}", style("Most Active (by commits)").bold());
955    let mut by_commits = data.iter().collect::<Vec<_>>();
956    by_commits.sort_by(|a, b| b.commit_count.cmp(&a.commit_count));
957    let max_commits = by_commits
958        .first()
959        .map(|d| d.commit_count)
960        .unwrap_or(1)
961        .max(1);
962    for d in by_commits.iter().take(5) {
963        if d.commit_count > 0 {
964            let icon = cwconsole::status_icon(&d.status);
965            let st = cwconsole::status_style(&d.status);
966            let bar_len = (d.commit_count * 15) / max_commits;
967            println!(
968                "    {} {:<25} {} {}",
969                st.apply_to(icon),
970                d.branch,
971                style("▓".repeat(bar_len.max(1))).cyan(),
972                style(format!("{} commits", d.commit_count)).dim(),
973            );
974        }
975    }
976    println!();
977
978    Ok(())
979}
980
981/// Compare two branches.
982pub fn diff_worktrees(branch1: &str, branch2: &str, summary: bool, files: bool) -> Result<()> {
983    let repo = git::get_repo_root(None)?;
984
985    if !git::branch_exists(branch1, Some(&repo)) {
986        return Err(crate::error::CwError::InvalidBranch(format!(
987            "Branch '{}' not found",
988            branch1
989        )));
990    }
991    if !git::branch_exists(branch2, Some(&repo)) {
992        return Err(crate::error::CwError::InvalidBranch(format!(
993            "Branch '{}' not found",
994            branch2
995        )));
996    }
997
998    println!("\n{}", style("Comparing branches:").cyan().bold());
999    println!("  {} {} {}\n", branch1, style("...").yellow(), branch2);
1000
1001    if files {
1002        let result = git::git_command(
1003            &["diff", "--name-status", branch1, branch2],
1004            Some(&repo),
1005            true,
1006            true,
1007        )?;
1008        println!("{}\n", style("Changed files:").bold());
1009        if result.stdout.trim().is_empty() {
1010            println!("  {}", style("No differences found").dim());
1011        } else {
1012            for line in result.stdout.trim().lines() {
1013                let parts: Vec<&str> = line.splitn(2, char::is_whitespace).collect();
1014                if parts.len() == 2 {
1015                    let (status_char, filename) = (parts[0], parts[1]);
1016                    let c = status_char.chars().next().unwrap_or('?');
1017                    let status_name = match c {
1018                        'M' => "Modified",
1019                        'A' => "Added",
1020                        'D' => "Deleted",
1021                        'R' => "Renamed",
1022                        'C' => "Copied",
1023                        _ => "Changed",
1024                    };
1025                    let styled_status = match c {
1026                        'M' => style(status_char).yellow(),
1027                        'A' => style(status_char).green(),
1028                        'D' => style(status_char).red(),
1029                        'R' | 'C' => style(status_char).cyan(),
1030                        _ => style(status_char),
1031                    };
1032                    println!("  {}  {} ({})", styled_status, filename, status_name);
1033                }
1034            }
1035        }
1036    } else if summary {
1037        let result = git::git_command(
1038            &["diff", "--stat", branch1, branch2],
1039            Some(&repo),
1040            true,
1041            true,
1042        )?;
1043        println!("{}\n", style("Diff summary:").bold());
1044        if result.stdout.trim().is_empty() {
1045            println!("  {}", style("No differences found").dim());
1046        } else {
1047            println!("{}", result.stdout);
1048        }
1049    } else {
1050        let result = git::git_command(&["diff", branch1, branch2], Some(&repo), true, true)?;
1051        if result.stdout.trim().is_empty() {
1052            println!("{}\n", style("No differences found").dim());
1053        } else {
1054            println!("{}", result.stdout);
1055        }
1056    }
1057
1058    Ok(())
1059}
1060
1061#[cfg(test)]
1062mod tests {
1063    use super::*;
1064
1065    #[test]
1066    fn test_format_age_just_now() {
1067        assert_eq!(format_age(0.0), "just now");
1068        assert_eq!(format_age(0.001), "just now"); // ~1.4 minutes
1069    }
1070
1071    #[test]
1072    fn test_format_age_hours() {
1073        assert_eq!(format_age(1.0 / 24.0), "1h ago"); // exactly 1 hour
1074        assert_eq!(format_age(0.5), "12h ago"); // 12 hours
1075        assert_eq!(format_age(0.99), "23h ago"); // ~23.7 hours
1076    }
1077
1078    #[test]
1079    fn test_format_age_days() {
1080        assert_eq!(format_age(1.0), "1d ago");
1081        assert_eq!(format_age(1.5), "1d ago");
1082        assert_eq!(format_age(6.9), "6d ago");
1083    }
1084
1085    #[test]
1086    fn test_format_age_weeks() {
1087        assert_eq!(format_age(7.0), "1w ago");
1088        assert_eq!(format_age(14.0), "2w ago");
1089        assert_eq!(format_age(29.0), "4w ago");
1090    }
1091
1092    #[test]
1093    fn test_format_age_months() {
1094        assert_eq!(format_age(30.0), "1mo ago");
1095        assert_eq!(format_age(60.0), "2mo ago");
1096        assert_eq!(format_age(364.0), "12mo ago");
1097    }
1098
1099    #[test]
1100    fn test_format_age_years() {
1101        assert_eq!(format_age(365.0), "1y ago");
1102        assert_eq!(format_age(730.0), "2y ago");
1103    }
1104
1105    #[test]
1106    fn test_format_age_boundary_below_one_hour() {
1107        // Less than 1 hour (1/24 day ≈ 0.0417)
1108        assert_eq!(format_age(0.04), "just now"); // 0.04 * 24 = 0.96h → 0 as i64
1109    }
1110
1111    // Note: this test exercises only the busy signal — repo/worktree
1112    // wiring (git::parse_worktrees etc.) is not exercised; the path is
1113    // used as a bare directory.
1114    #[test]
1115    #[cfg(unix)]
1116    fn test_get_worktree_status_busy_from_lockfile() {
1117        use crate::operations::lockfile::LockEntry;
1118        use std::fs;
1119        use std::process::{Command, Stdio};
1120
1121        let tmp = tempfile::TempDir::new().unwrap();
1122        let repo = tmp.path();
1123        let wt = repo.join("wt1");
1124        fs::create_dir_all(wt.join(".git")).unwrap();
1125
1126        // Spawn a child process: its PID is a descendant (not ancestor) of
1127        // the current process, so self_process_tree() will not contain it.
1128        // This gives us a live foreign PID to prove the busy signal fires.
1129        let mut child = Command::new("sleep")
1130            .arg("30")
1131            .stdout(Stdio::null())
1132            .stderr(Stdio::null())
1133            .spawn()
1134            .expect("spawn sleep");
1135        let foreign_pid: u32 = child.id();
1136
1137        let entry = LockEntry {
1138            version: crate::operations::lockfile::LOCK_VERSION,
1139            pid: foreign_pid,
1140            started_at: 0,
1141            cmd: "claude".to_string(),
1142        };
1143        fs::write(
1144            wt.join(".git").join("gw-session.lock"),
1145            serde_json::to_string(&entry).unwrap(),
1146        )
1147        .unwrap();
1148
1149        let status = get_worktree_status(&wt, repo, Some("wt1"), &PrCache::default());
1150
1151        // Clean up child before asserting, so a failed assert still reaps it.
1152        let _ = child.kill();
1153        let _ = child.wait();
1154
1155        assert_eq!(status, "busy");
1156    }
1157
1158    #[test]
1159    fn test_get_worktree_status_stale() {
1160        use std::path::PathBuf;
1161        let non_existent = PathBuf::from("/tmp/gw-test-nonexistent-12345");
1162        let repo = PathBuf::from("/tmp");
1163        assert_eq!(
1164            get_worktree_status(&non_existent, &repo, None, &PrCache::default()),
1165            "stale"
1166        );
1167    }
1168}