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