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