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_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 rm`) still use the full `detect_busy`.
63    if !crate::operations::busy::detect_busy_lockfile_only(path).is_empty() {
64        return "busy".to_string();
65    }
66
67    // Also flag worktrees occupied by an active Claude Code session.
68    // Shares the two-stage gate (jsonl event + live `claude` process) with
69    // `detect_busy_tiered` via `busy::active_claude_sessions` so the two
70    // surfaces cannot drift. The process scan is OnceLock-cached.
71    if crate::operations::busy::active_claude_sessions(path).is_some() {
72        return "busy".to_string();
73    }
74
75    // Check if cwd is inside this worktree. Canonicalize both sides so that
76    // symlink skew (e.g. macOS /var vs /private/var) does not miss a match.
77    if let Ok(cwd) = std::env::current_dir() {
78        let cwd_canon = cwd.canonicalize().unwrap_or(cwd);
79        let path_canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
80        if cwd_canon.starts_with(&path_canon) {
81            return "active".to_string();
82        }
83    }
84
85    // Check merge/PR status if branch name is available
86    if let Some(branch_name) = branch {
87        let base_branch = {
88            let key = format_config_key(CONFIG_KEY_BASE_BRANCH, branch_name);
89            git::get_config(&key, Some(repo))
90                .unwrap_or_else(|| git::detect_default_branch(Some(repo)))
91        };
92
93        // Primary: cached PR state from a single `gh pr list` call.
94        if let Some(state) = pr_cache.state(branch_name) {
95            match state {
96                super::pr_cache::PrState::Merged => return "merged".to_string(),
97                super::pr_cache::PrState::Open => return "pr-open".to_string(),
98                // Closed/Other: fall through to git-based merge detection
99                _ => {}
100            }
101        }
102
103        // Fallback: git branch --merged (only works for merge-commit strategy)
104        // Used when `gh` is not installed or no PR was created
105        if git::is_branch_merged(branch_name, &base_branch, Some(repo)) {
106            return "merged".to_string();
107        }
108    }
109
110    // Check for uncommitted changes
111    if let Ok(result) = git::git_command(&["status", "--porcelain"], Some(path), false, true) {
112        if result.returncode == 0 && !result.stdout.trim().is_empty() {
113            return "modified".to_string();
114        }
115    }
116
117    "clean".to_string()
118}
119
120/// Format age in days to human-readable string.
121pub fn format_age(age_days: f64) -> String {
122    if age_days < 1.0 {
123        let hours = (age_days * 24.0) as i64;
124        if hours > 0 {
125            format!("{}h ago", hours)
126        } else {
127            "just now".to_string()
128        }
129    } else if age_days < 7.0 {
130        format!("{}d ago", age_days as i64)
131    } else if age_days < 30.0 {
132        format!("{}w ago", (age_days / 7.0) as i64)
133    } else if age_days < 365.0 {
134        format!("{}mo ago", (age_days / 30.0) as i64)
135    } else {
136        format!("{}y ago", (age_days / 365.0) as i64)
137    }
138}
139
140/// Compose a single row for the `gw rm -i` multi-select TUI.
141///
142/// Columns, left to right, separated by one space:
143///   branch (padded to `branch_col`) | age (padded to 9) | busy (7, colored) | path
144///
145/// The busy column carries an ANSI-colored `[busy]` token when `busy` is true,
146/// or 7 spaces when false. `arrow_select::visible_len` is ANSI-aware, so the
147/// colored and plain variants have identical visible width.
148///
149/// The path column is appended verbatim. The caller is expected to run the
150/// returned string through `arrow_select::truncate` to cap line width; that
151/// truncation clips the trailing path column, which is the correct behavior
152/// per the row-decorations spec (badges and age must survive, path may shrink).
153pub fn format_selector_row(
154    branch: &str,
155    age: &str,
156    busy: bool,
157    path: &str,
158    branch_col: usize,
159) -> String {
160    const AGE_COL: usize = 9;
161    const BUSY_COL: usize = 7;
162    let busy_cell: String = if busy {
163        // "[busy]" is 6 visible chars; pad to BUSY_COL with one trailing space.
164        format!("{} ", style("[busy]").yellow())
165    } else {
166        " ".repeat(BUSY_COL)
167    };
168    format!(
169        "{branch:<branch_col$} {age:<AGE_COL$} {busy_cell}{path}",
170        branch = branch,
171        age = age,
172        busy_cell = busy_cell,
173        path = path,
174        branch_col = branch_col,
175        AGE_COL = AGE_COL,
176    )
177}
178
179/// Compute age string for a path.
180fn path_age_str(path: &Path) -> String {
181    if !path.exists() {
182        return String::new();
183    }
184    path_age_days(path).map(format_age).unwrap_or_default()
185}
186
187/// Collected worktree data row for display.
188struct WorktreeRow {
189    worktree_id: String,
190    current_branch: String,
191    status: String,
192    age: String,
193    rel_path: String,
194    /// Worktree path; retained so post-render `print_busy_details` can
195    /// scan busy rows without re-parsing `git worktree list`.
196    path: std::path::PathBuf,
197}
198
199/// Serial-prep input passed to status computation.
200/// Shares all fields with `WorktreeRow` except `status` (filled in by the
201/// parallel worker).
202#[derive(Clone)]
203struct RowInput {
204    path: std::path::PathBuf,
205    current_branch: String,
206    worktree_id: String,
207    age: String,
208    rel_path: String,
209}
210
211impl RowInput {
212    fn into_row(self, status: String) -> WorktreeRow {
213        WorktreeRow {
214            worktree_id: self.worktree_id,
215            current_branch: self.current_branch,
216            status,
217            age: self.age,
218            rel_path: self.rel_path,
219            path: self.path,
220        }
221    }
222}
223
224/// Prewarm the two `lsof`-backed caches (`busy::cwd_scan`,
225/// `claude_process::snapshot`) on detached background threads so they run
226/// concurrently with each other and with the foreground status loop.
227/// Later callers (`get_worktree_status`, `print_busy_details`) hit the
228/// cache instead of paying the lsof round-trip serially.
229///
230/// Detached threads: the join handles are dropped immediately. Both
231/// workers only mutate process-static `OnceLock`s, so a slow/stuck
232/// thread does not block process exit any more than a slow lsof already
233/// would; the foreground caller will race against them via
234/// `OnceLock::get_or_init`. We don't `join` here because the prewarm is
235/// best-effort — if it's still running when a caller hits the cache,
236/// `get_or_init` blocks once and continues. If the thread completes
237/// first, callers find the cache already populated.
238fn prewarm_busy_caches() {
239    std::thread::spawn(crate::operations::busy::prewarm_cwd_scan);
240    std::thread::spawn(crate::operations::claude_process::prewarm);
241}
242
243/// List all worktrees, grouped by repository, using cwd-based scope discovery.
244pub fn list_worktrees() -> Result<()> {
245    let cwd = std::env::current_dir()?;
246    let scope = crate::scope::discover_scope(&cwd)?;
247
248    if scope.is_empty() {
249        println!("  {}\n", style("No worktrees found.").dim());
250        return Ok(());
251    }
252
253    // Group by repo_root, preserving first-seen order.
254    // Using Vec<(PathBuf, Vec<(String, PathBuf)>)> instead of HashMap to keep
255    // a stable, deterministic order for multi-repo output.
256    let mut groups: Vec<(std::path::PathBuf, Vec<(String, std::path::PathBuf)>)> = Vec::new();
257    for w in scope.worktrees() {
258        let key = &w.repo_root;
259        let entry = match groups.iter_mut().find(|(k, _)| k == key) {
260            Some(e) => e,
261            None => {
262                groups.push((key.clone(), Vec::new()));
263                groups.last_mut().unwrap()
264            }
265        };
266        // Re-derive (branch_raw, path) tuple shape that the existing rendering
267        // pipeline expects. For detached HEAD, use "(detached)" so downstream
268        // normalize_branch_name keeps working.
269        let branch_raw = w.branch.clone().unwrap_or_else(|| "(detached)".to_string());
270        entry.1.push((branch_raw, w.path.clone()));
271    }
272
273    for (i, (repo, worktrees)) in groups.iter().enumerate() {
274        if i > 0 {
275            println!(); // separator between sections
276        }
277        render_repo_section(repo, worktrees)?;
278    }
279    Ok(())
280}
281
282/// Print all worktrees as TSV — scriptable, machine-readable surface.
283///
284/// Six tab-separated columns, no header, no colors:
285///   `<worktree_id>\t<branch>\t<status>\t<age>\t<repo_root>\t<path>`
286///
287/// Silent when no worktrees are found (unlike `list_worktrees` which prints a
288/// message). Order matches `discover_scope`, grouped by repo in first-seen order.
289pub fn list_worktrees_tsv() -> Result<()> {
290    let cwd = std::env::current_dir()?;
291    let scope = crate::scope::discover_scope(&cwd)?;
292
293    if scope.is_empty() {
294        return Ok(());
295    }
296
297    // Group by repo_root in first-seen order.
298    let mut groups: Vec<(std::path::PathBuf, Vec<(String, std::path::PathBuf)>)> = Vec::new();
299    for w in scope.worktrees() {
300        let key = &w.repo_root;
301        let entry = match groups.iter_mut().find(|(k, _)| k == key) {
302            Some(e) => e,
303            None => {
304                groups.push((key.clone(), Vec::new()));
305                groups.last_mut().unwrap()
306            }
307        };
308        let branch_raw = w.branch.clone().unwrap_or_else(|| "(detached)".to_string());
309        entry.1.push((branch_raw, w.path.clone()));
310    }
311
312    for (repo, worktrees) in &groups {
313        let pr_cache = PrCache::load_or_fetch(repo, false);
314
315        // Serial prep: cheap local work (branch normalization, age, intended_branch).
316        let inputs: Vec<RowInput> = worktrees
317            .iter()
318            .map(|(branch, path)| {
319                let current_branch = git::normalize_branch_name(branch).to_string();
320                let age = path_age_str(path);
321                let intended_branch = lookup_intended_branch(repo, &current_branch, path);
322                let worktree_id = intended_branch.unwrap_or_else(|| current_branch.clone());
323                RowInput {
324                    path: path.clone(),
325                    current_branch,
326                    worktree_id,
327                    age,
328                    // rel_path not needed for TSV; repo_root and abs path are the columns.
329                    rel_path: String::new(),
330                }
331            })
332            .collect();
333
334        // Parallel status computation (same pattern as the static path in render_repo_section).
335        // IndexedParallelIterator preserves source-vec order on collect — required for the zip below.
336        let rows: Vec<WorktreeRow> = inputs
337            .into_par_iter()
338            .map(|i| {
339                let status = get_worktree_status(&i.path, repo, Some(&i.current_branch), &pr_cache);
340                i.into_row(status)
341            })
342            .collect();
343
344        for (row, (_, path)) in rows.iter().zip(worktrees.iter()) {
345            println!(
346                "{}\t{}\t{}\t{}\t{}\t{}",
347                row.worktree_id,
348                row.current_branch,
349                row.status,
350                row.age,
351                repo.display(),
352                path.display(),
353            );
354        }
355    }
356
357    Ok(())
358}
359
360/// Render a single repository's worktree list section.
361///
362/// This is the extracted body of the original `list_worktrees`, parameterised
363/// over `(repo, worktrees)` so the outer function can call it once per repo
364/// family found by `scope::discover_scope`.
365fn render_repo_section(
366    repo: &std::path::Path,
367    worktrees: &[(String, std::path::PathBuf)],
368) -> Result<()> {
369    println!(
370        "\n{}  {}\n",
371        style("Worktrees for repository:").cyan().bold(),
372        repo.display()
373    );
374
375    let pr_cache = PrCache::load_or_fetch(repo, false);
376
377    // Serial prep: cheap local work. Keep single-threaded for clarity.
378    let inputs: Vec<RowInput> = worktrees
379        .iter()
380        .map(|(branch, path)| {
381            let current_branch = git::normalize_branch_name(branch).to_string();
382            let rel_path = pathdiff::diff_paths(path, repo)
383                .map(|p: std::path::PathBuf| p.to_string_lossy().to_string())
384                .unwrap_or_else(|| path.to_string_lossy().to_string());
385            let age = path_age_str(path);
386            let intended_branch = lookup_intended_branch(repo, &current_branch, path);
387            let worktree_id = intended_branch.unwrap_or_else(|| current_branch.clone());
388            RowInput {
389                path: path.clone(),
390                current_branch,
391                worktree_id,
392                age,
393                rel_path,
394            }
395        })
396        .collect();
397
398    // A repo section with zero worktrees produces an empty table — acceptable
399    // in practice since a repo always has at least the main worktree.
400    // (The outer scope-level empty check handles the truly empty case.)
401
402    prewarm_busy_caches();
403
404    let is_tty = crate::tui::stdout_is_tty();
405    // #18/#33/#35: cache terminal_width() once — used in both the progressive/static
406    // branch decision and the post-render print guard.
407    let term_width = cwconsole::terminal_width();
408    // #35: extract narrow so the two places that check MIN_TABLE_WIDTH share
409    // a single bool and cannot drift out of sync.
410    let narrow = term_width < MIN_TABLE_WIDTH;
411    let use_progressive = is_tty && !narrow;
412
413    let rows: Vec<WorktreeRow> = if use_progressive {
414        render_rows_progressive(repo, &pr_cache, inputs)?
415    } else {
416        // rayon borrows &pr_cache across workers via the type system.
417        inputs
418            .into_par_iter()
419            .map(|i| {
420                let status = get_worktree_status(&i.path, repo, Some(&i.current_branch), &pr_cache);
421                i.into_row(status)
422            })
423            .collect()
424    };
425
426    // In the TTY+wide path the Inline Viewport has already drawn the table.
427    // In the static path (narrow terminal or non-TTY) we still need to print.
428    if !use_progressive {
429        if narrow {
430            print_worktree_compact(&rows);
431        } else {
432            print_worktree_table(&rows);
433        }
434    }
435
436    // Footer is printed via println! after the Inline Viewport drops, so it
437    // appears below the table in scrollback. Alignment is correct for the
438    // static path; in the TTY path the viewport already committed the table
439    // rows and the footer follows naturally. Using terminal.insert_before()
440    // could align it inside the viewport, but the current behaviour is
441    // acceptable and avoids extra ratatui complexity.
442    print_busy_details(&rows);
443    print_summary_footer(&rows);
444
445    println!();
446    Ok(())
447}
448
449/// RAII guard: drops the terminal before calling `ratatui::restore()`.
450/// Ensures terminal modes are restored deterministically even on early return
451/// or panic, without relying on a closure-then-restore pattern.
452///
453/// #19: concrete backend type avoids unnecessary generics — this guard is only
454/// ever created for the crossterm+stdout path used in `render_rows_progressive`.
455type CrosstermTerminal = ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stdout>>;
456
457/// Wraps a ratatui Terminal with deterministic cleanup.
458///
459/// # Contract
460/// - The caller must call `mark_ratatui_active()` before constructing the terminal.
461/// - On Drop, this guard drops the terminal first, then calls `mark_ratatui_inactive()`
462///   followed by `ratatui::restore()`.
463struct TerminalGuard(Option<CrosstermTerminal>);
464
465impl TerminalGuard {
466    fn new(terminal: CrosstermTerminal) -> Self {
467        // #1/#20: flag is already set by the caller before Terminal::with_options;
468        // the caller's error path calls mark_ratatui_inactive if construction fails.
469        // Here we just store the terminal — the flag is already live.
470        Self(Some(terminal))
471    }
472
473    fn as_mut(&mut self) -> &mut CrosstermTerminal {
474        self.0.as_mut().expect("terminal already taken")
475    }
476}
477
478impl Drop for TerminalGuard {
479    fn drop(&mut self) {
480        let _ = self.0.take(); // drop terminal first, releasing raw mode if any
481        ratatui::restore();
482        // #20: clear the panic-hook flag after restore — a subsequent panic
483        // (unlikely but possible) must not try to restore a non-existent terminal.
484        crate::tui::mark_ratatui_inactive();
485    }
486}
487
488fn render_rows_progressive(
489    repo: &std::path::Path,
490    pr_cache: &PrCache,
491    inputs: Vec<RowInput>,
492) -> Result<Vec<WorktreeRow>> {
493    // Build skeleton app.
494    let row_data: Vec<crate::tui::list_view::RowData> = inputs
495        .iter()
496        .map(|i| crate::tui::list_view::RowData {
497            worktree_id: i.worktree_id.clone(),
498            current_branch: i.current_branch.clone(),
499            status: crate::tui::list_view::PLACEHOLDER.to_string(),
500            age: i.age.clone(),
501            rel_path: i.rel_path.clone(),
502        })
503        .collect();
504    let mut app = crate::tui::list_view::ListApp::new(row_data);
505
506    // `+2` accounts for the header row plus a trailing blank line. Borders are
507    // disabled (`Borders::NONE`); the spec's `+4` figure assumed bordered layout.
508    let viewport_height = u16::try_from(inputs.len())
509        .unwrap_or(u16::MAX)
510        .saturating_add(2)
511        .max(3);
512
513    let stdout = std::io::stdout();
514    let backend = CrosstermBackend::new(stdout);
515    // #1/#5: mark active BEFORE construction so the panic hook fires correctly
516    // if Terminal::with_options itself panics. If it returns Err or panics, we
517    // clear the flag before propagating so a non-ratatui panic later is not
518    // mishandled.
519    // Restore is idempotent — if construction fails or panics, the panic hook
520    // may still call `ratatui::restore()`, which is documented safe.
521    crate::tui::mark_ratatui_active();
522    let terminal = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
523        Terminal::with_options(
524            backend,
525            TerminalOptions {
526                viewport: Viewport::Inline(viewport_height),
527            },
528        )
529    })) {
530        Ok(Ok(t)) => t,
531        Ok(Err(e)) => {
532            crate::tui::mark_ratatui_inactive();
533            return Err(e.into());
534        }
535        Err(panic) => {
536            crate::tui::mark_ratatui_inactive();
537            std::panic::resume_unwind(panic);
538        }
539    };
540    let mut guard = TerminalGuard::new(terminal);
541    // Note: no test exercises a panicking Terminal::with_options. The double-restore
542    // path (panic hook + TerminalGuard::Drop) is documented safe in ratatui.
543
544    // Producer: parallel per-worktree status computation on a dedicated OS
545    // thread; rayon parallelism is used within that thread.
546    //
547    // Uses std::thread::scope so the consumer (list_view::run) on the main
548    // thread can interleave with the producer thread, giving true progressive
549    // rendering. Producer panics are caught by the scope's join-on-exit and
550    // the sweep below promotes remaining "..." placeholders to "unknown".
551    //
552    // Uses rayon's default pool (CPU cores). Each worker spawns `git`
553    // subprocesses, which is I/O-bound but small enough that oversubscription
554    // doesn't help.
555    let (tx, rx) = mpsc::channel();
556
557    // Draw skeleton immediately so the user sees the table even before any
558    // status computations finish. `list_view::run` would draw this on its
559    // first iteration, but for very fast producers (small repos) the rows
560    // can fill before that initial draw.
561    guard.as_mut().draw(|f| app.render(f))?;
562
563    // Retain paths in row order so the post-render `print_busy_details` block
564    // can scan busy rows. The producer takes `inputs` by move into the worker
565    // thread, so we capture the paths up-front.
566    let paths: Vec<std::path::PathBuf> = inputs.iter().map(|i| i.path.clone()).collect();
567
568    // `thread::scope` blocks until all spawned threads finish (when the closure
569    // returns). The explicit `producer.join()` here is solely to extract the
570    // panic payload for diagnostics; the actual join would happen automatically
571    // at scope exit.
572    std::thread::scope(|s| -> Result<()> {
573        let producer = s.spawn(move || {
574            inputs
575                .par_iter()
576                .enumerate()
577                .for_each_with(tx, |tx, (i, input)| {
578                    let status = get_worktree_status(
579                        &input.path,
580                        repo,
581                        Some(&input.current_branch),
582                        pr_cache,
583                    );
584                    let _ = tx.send((i, status));
585                });
586        });
587
588        let run_result = crate::tui::list_view::run(guard.as_mut(), &mut app, rx);
589        let producer_result = producer.join();
590        if let Err(panic) = producer_result {
591            // #3: extract a readable message from the panic payload.
592            let msg = panic
593                .downcast_ref::<&str>()
594                .map(|s| (*s).to_string())
595                .or_else(|| panic.downcast_ref::<String>().cloned())
596                .unwrap_or_else(|| "non-string panic payload".to_string());
597            eprintln!(
598                "warning: status producer thread panicked, some rows may show \"unknown\": {}",
599                msg
600            );
601        }
602        run_result.map_err(crate::error::CwError::from)
603    })?;
604
605    // Defensive sweep: if the producer panicked, some rows may still carry
606    // the skeleton placeholder. Promote those to a visible "unknown" status
607    // so the footer summary doesn't count the placeholder literal.
608    // #5/#39: only redraw when something actually changed to avoid adding a
609    // duplicate table frame to scrollback. finalize_pending returns true iff
610    // at least one placeholder was replaced.
611    if app.finalize_pending("unknown") {
612        guard.as_mut().draw(|f| app.render(f))?;
613    }
614
615    Ok(app
616        .into_rows()
617        .into_iter()
618        .zip(paths)
619        .map(|(r, path)| WorktreeRow {
620            worktree_id: r.worktree_id,
621            current_branch: r.current_branch,
622            status: r.status,
623            age: r.age,
624            rel_path: r.rel_path,
625            path,
626        })
627        .collect())
628}
629
630// Note: `WorktreeRow` is no longer derived `From<RowData>`. The path field
631// is plumbed through the zip in `render_rows_progressive` so the busy-details
632// printer can recover the worktree path post-render.
633
634/// Look up the intended branch for a worktree via git config metadata.
635fn lookup_intended_branch(repo: &Path, current_branch: &str, path: &Path) -> Option<String> {
636    // Try direct lookup
637    let key = format_config_key(CONFIG_KEY_INTENDED_BRANCH, current_branch);
638    if let Some(intended) = git::get_config(&key, Some(repo)) {
639        return Some(intended);
640    }
641
642    // Search all intended branch metadata
643    let result = git::git_command(
644        &[
645            "config",
646            "--local",
647            "--get-regexp",
648            r"^worktree\..*\.intendedBranch",
649        ],
650        Some(repo),
651        false,
652        true,
653    )
654    .ok()?;
655
656    if result.returncode != 0 {
657        return None;
658    }
659
660    let repo_name = repo.file_name()?.to_string_lossy().to_string();
661
662    for line in result.stdout.trim().lines() {
663        let parts: Vec<&str> = line.splitn(2, char::is_whitespace).collect();
664        if parts.len() == 2 {
665            let key_parts: Vec<&str> = parts[0].split('.').collect();
666            if key_parts.len() >= 2 {
667                let branch_from_key = key_parts[1];
668                let expected_path_name =
669                    format!("{}-{}", repo_name, sanitize_branch_name(branch_from_key));
670                if let Some(name) = path.file_name() {
671                    if name.to_string_lossy() == expected_path_name {
672                        return Some(parts[1].to_string());
673                    }
674                }
675            }
676        }
677    }
678
679    None
680}
681
682/// Print a multi-line block per busy worktree showing the same body
683/// sections `gw rm` uses (Active Claude session / Lockfile holder /
684/// processes with cwd in this worktree), via the shared
685/// `busy_messages::render_busy_block`. Skips the `--force` guidance —
686/// `gw list` is read-only.
687///
688/// No-op when there are zero busy rows. The cwd scan is `OnceLock`-cached
689/// for the process, so calling this after `get_worktree_status` adds no
690/// extra scans.
691fn print_busy_details(rows: &[WorktreeRow]) {
692    let busy_rows: Vec<&WorktreeRow> = rows.iter().filter(|r| r.status == "busy").collect();
693    if busy_rows.is_empty() {
694        return;
695    }
696
697    for row in busy_rows {
698        let (hard, soft) = crate::operations::busy::detect_busy_tiered(&row.path);
699        // detect_busy_tiered may return empty if a process exited between
700        // get_worktree_status and now. Skip silently — the table already
701        // showed it as busy and the user can re-run.
702        if hard.is_empty() && soft.is_empty() {
703            continue;
704        }
705        let block =
706            crate::operations::busy_messages::render_busy_block(&row.worktree_id, &hard, &soft);
707        println!();
708        // The block already ends with a trailing newline; print as-is.
709        print!("{}", block);
710    }
711}
712
713fn print_summary_footer(rows: &[WorktreeRow]) {
714    // The first worktree is the primary repo checkout — exclude it from the
715    // "feature worktree" count.
716    let feature_count = if rows.len() > 1 { rows.len() - 1 } else { 0 };
717    if feature_count == 0 {
718        return;
719    }
720
721    let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
722    for row in rows {
723        *counts.entry(row.status.as_str()).or_insert(0) += 1;
724    }
725
726    let mut summary_parts = Vec::new();
727    for &status_name in &[
728        "clean", "modified", "busy", "active", "pr-open", "merged", "stale",
729    ] {
730        if let Some(&count) = counts.get(status_name) {
731            if count > 0 {
732                let styled = cwconsole::status_style(status_name)
733                    .apply_to(format!("{} {}", count, status_name));
734                summary_parts.push(styled.to_string());
735            }
736        }
737    }
738
739    let summary = if summary_parts.is_empty() {
740        format!("\n{} feature worktree(s)", feature_count)
741    } else {
742        format!(
743            "\n{} feature worktree(s) — {}",
744            feature_count,
745            summary_parts.join(", ")
746        )
747    };
748    println!("{}", summary);
749}
750
751fn print_worktree_table(rows: &[WorktreeRow]) {
752    let max_wt = rows.iter().map(|r| r.worktree_id.len()).max().unwrap_or(20);
753    let max_br = rows
754        .iter()
755        .map(|r| r.current_branch.len())
756        .max()
757        .unwrap_or(20);
758    let wt_col = max_wt.clamp(12, 35) + 2;
759    let br_col = max_br.clamp(12, 35) + 2;
760
761    println!(
762        "  {} {:<wt_col$} {:<br_col$} {:<10} {:<12} {}",
763        style(" ").dim(),
764        style("WORKTREE").dim(),
765        style("BRANCH").dim(),
766        style("STATUS").dim(),
767        style("AGE").dim(),
768        style("PATH").dim(),
769        wt_col = wt_col,
770        br_col = br_col,
771    );
772    let line_width = (wt_col + br_col + 40).min(cwconsole::terminal_width().saturating_sub(4));
773    println!("  {}", style("─".repeat(line_width)).dim());
774
775    for row in rows {
776        let icon = cwconsole::status_icon(&row.status);
777        let st = cwconsole::status_style(&row.status);
778
779        let branch_display = if row.worktree_id != row.current_branch {
780            style(format!("{} ⚠", row.current_branch))
781                .yellow()
782                .to_string()
783        } else {
784            row.current_branch.clone()
785        };
786
787        let status_styled = st.apply_to(format!("{:<10}", row.status));
788
789        println!(
790            "  {} {:<wt_col$} {:<br_col$} {} {:<12} {}",
791            st.apply_to(icon),
792            style(&row.worktree_id).bold(),
793            branch_display,
794            status_styled,
795            style(&row.age).dim(),
796            style(&row.rel_path).dim(),
797            wt_col = wt_col,
798            br_col = br_col,
799        );
800    }
801}
802
803fn print_worktree_compact(rows: &[WorktreeRow]) {
804    for row in rows {
805        let icon = cwconsole::status_icon(&row.status);
806        let st = cwconsole::status_style(&row.status);
807        let age_part = if row.age.is_empty() {
808            String::new()
809        } else {
810            format!("  {}", style(&row.age).dim())
811        };
812
813        println!(
814            "  {} {}  {}{}",
815            st.apply_to(icon),
816            style(&row.worktree_id).bold(),
817            st.apply_to(&row.status),
818            age_part,
819        );
820
821        let mut details = Vec::new();
822        if row.worktree_id != row.current_branch {
823            details.push(format!(
824                "branch: {}",
825                style(format!("{} ⚠", row.current_branch)).yellow()
826            ));
827        }
828        if !row.rel_path.is_empty() {
829            details.push(format!("{}", style(&row.rel_path).dim()));
830        }
831        if !details.is_empty() {
832            println!("      {}", details.join("  "));
833        }
834    }
835}
836
837#[cfg(test)]
838mod tests {
839    use super::*;
840
841    #[test]
842    fn test_format_age_just_now() {
843        assert_eq!(format_age(0.0), "just now");
844        assert_eq!(format_age(0.001), "just now"); // ~1.4 minutes
845    }
846
847    #[test]
848    fn test_format_age_hours() {
849        assert_eq!(format_age(1.0 / 24.0), "1h ago"); // exactly 1 hour
850        assert_eq!(format_age(0.5), "12h ago"); // 12 hours
851        assert_eq!(format_age(0.99), "23h ago"); // ~23.7 hours
852    }
853
854    #[test]
855    fn test_format_age_days() {
856        assert_eq!(format_age(1.0), "1d ago");
857        assert_eq!(format_age(1.5), "1d ago");
858        assert_eq!(format_age(6.9), "6d ago");
859    }
860
861    #[test]
862    fn test_format_age_weeks() {
863        assert_eq!(format_age(7.0), "1w ago");
864        assert_eq!(format_age(14.0), "2w ago");
865        assert_eq!(format_age(29.0), "4w ago");
866    }
867
868    #[test]
869    fn test_format_age_months() {
870        assert_eq!(format_age(30.0), "1mo ago");
871        assert_eq!(format_age(60.0), "2mo ago");
872        assert_eq!(format_age(364.0), "12mo ago");
873    }
874
875    #[test]
876    fn test_format_age_years() {
877        assert_eq!(format_age(365.0), "1y ago");
878        assert_eq!(format_age(730.0), "2y ago");
879    }
880
881    #[test]
882    fn test_format_age_boundary_below_one_hour() {
883        // Less than 1 hour (1/24 day ≈ 0.0417)
884        assert_eq!(format_age(0.04), "just now"); // 0.04 * 24 = 0.96h → 0 as i64
885    }
886
887    #[test]
888    fn format_selector_row_no_busy() {
889        let row = format_selector_row("feat/a", "2d ago", false, "feat-a", 30);
890        // branch (30) + space + age (9) + space + busy_pad (7) + path
891        assert_eq!(
892            row,
893            "feat/a                         2d ago           feat-a"
894        );
895    }
896
897    #[test]
898    fn format_selector_row_busy_contains_badge() {
899        let row = format_selector_row("fix/b", "3w ago", true, "fix-b", 30);
900        assert!(
901            row.contains("[busy]"),
902            "expected [busy] in row, got: {:?}",
903            row
904        );
905        assert!(row.contains("fix/b"));
906        assert!(row.contains("3w ago"));
907        assert!(row.contains("fix-b"));
908    }
909
910    #[test]
911    fn format_selector_row_busy_visible_width_matches_no_busy() {
912        // ANSI-colored [busy] must occupy the same visible width as 7 spaces,
913        // so columns stay aligned under and not-under the cursor.
914        let plain = format_selector_row("x", "1d ago", false, "p", 30);
915        let busy = format_selector_row("x", "1d ago", true, "p", 30);
916        assert_eq!(
917            crate::tui::arrow_select::visible_len(&plain),
918            crate::tui::arrow_select::visible_len(&busy),
919        );
920    }
921
922    #[test]
923    fn format_selector_row_empty_age_pads_to_nine() {
924        let row = format_selector_row("feat/a", "", false, "feat-a", 30);
925        // 30 branch + 1 sep + 9 age + 1 sep + 7 busy_pad = 48, then path starts.
926        // Verify the path "feat-a" starts at byte 48.
927        assert_eq!(&row[48..], "feat-a");
928    }
929
930    #[test]
931    fn format_selector_row_long_branch_does_not_truncate() {
932        let branch = "feat/extra-long-branch-name-well-past-thirty-chars";
933        let row = format_selector_row(branch, "1d ago", false, "p", 30);
934        assert!(
935            row.starts_with(branch),
936            "branch must not be truncated, got: {:?}",
937            row
938        );
939    }
940
941    // Note: this test exercises only the busy signal — repo/worktree
942    // wiring (git::parse_worktrees etc.) is not exercised; the path is
943    // used as a bare directory.
944    #[test]
945    #[cfg(unix)]
946    fn test_get_worktree_status_busy_from_lockfile() {
947        use crate::operations::lockfile::LockEntry;
948        use std::fs;
949        use std::process::{Command, Stdio};
950
951        let tmp = tempfile::TempDir::new().unwrap();
952        let repo = tmp.path();
953        let wt = repo.join("wt1");
954        fs::create_dir_all(wt.join(".git")).unwrap();
955
956        // Spawn a child process: its PID is a descendant (not ancestor) of
957        // the current process, so self_process_tree() will not contain it.
958        // This gives us a live foreign PID to prove the busy signal fires.
959        let mut child = Command::new("sleep")
960            .arg("30")
961            .stdout(Stdio::null())
962            .stderr(Stdio::null())
963            .spawn()
964            .expect("spawn sleep");
965        let foreign_pid: u32 = child.id();
966
967        let entry = LockEntry {
968            version: crate::operations::lockfile::LOCK_VERSION,
969            pid: foreign_pid,
970            started_at: 0,
971            cmd: "claude".to_string(),
972        };
973        fs::write(
974            wt.join(".git").join("gw-session.lock"),
975            serde_json::to_string(&entry).unwrap(),
976        )
977        .unwrap();
978
979        let status = get_worktree_status(&wt, repo, Some("wt1"), &PrCache::default());
980
981        // Clean up child before asserting, so a failed assert still reaps it.
982        let _ = child.kill();
983        let _ = child.wait();
984
985        assert_eq!(status, "busy");
986    }
987
988    /// Regression: `get_worktree_status` must not mark a worktree busy on
989    /// the strength of a stale jsonl alone. Same scenario as the
990    /// detect_busy_tiered regression — we plant a fresh-looking jsonl
991    /// without any live claude process, and verify the worktree is NOT
992    /// reported as busy.
993    #[test]
994    #[cfg(any(target_os = "linux", target_os = "macos"))]
995    fn test_get_worktree_status_not_busy_when_jsonl_active_but_no_live_claude() {
996        use crate::operations::test_env::{env_lock, EnvGuard};
997        let _lock = env_lock();
998        let _guard = EnvGuard::capture(&["HOME"]);
999
1000        let home = tempfile::TempDir::new().unwrap();
1001        std::env::set_var("HOME", home.path());
1002
1003        let repo = tempfile::TempDir::new().unwrap();
1004        let wt = repo.path().join("wt1");
1005        std::fs::create_dir_all(wt.join(".git")).unwrap();
1006        let wt_canon = wt.canonicalize().unwrap_or(wt.clone());
1007
1008        // Plant a jsonl whose newest event is now (well within the 10-minute
1009        // threshold) and whose `cwd` matches the worktree.
1010        let encoded = wt_canon.to_string_lossy().replace(['/', '.'], "-");
1011        let proj_dir = home.path().join(".claude").join("projects").join(encoded);
1012        std::fs::create_dir_all(&proj_dir).unwrap();
1013        let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
1014        let line = serde_json::json!({
1015            "timestamp": now,
1016            "cwd": wt_canon.to_string_lossy(),
1017        });
1018        std::fs::write(
1019            proj_dir.join("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.jsonl"),
1020            format!("{}\n", line),
1021        )
1022        .unwrap();
1023
1024        let status = get_worktree_status(&wt, repo.path(), Some("wt1"), &PrCache::default());
1025        assert_ne!(
1026            status, "busy",
1027            "expected non-busy without a live claude process, got busy"
1028        );
1029    }
1030
1031    #[test]
1032    fn test_get_worktree_status_stale() {
1033        use std::path::PathBuf;
1034        let non_existent = PathBuf::from("/tmp/gw-test-nonexistent-12345");
1035        let repo = PathBuf::from("/tmp");
1036        assert_eq!(
1037            get_worktree_status(&non_existent, &repo, None, &PrCache::default()),
1038            "stale"
1039        );
1040    }
1041}