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