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