Skip to main content

git_worktree_manager/operations/
display.rs

1/// Display and information operations for git-worktree-manager.
2///
3use std::path::Path;
4
5use console::style;
6
7use crate::console as cwconsole;
8use crate::constants::{
9    format_config_key, path_age_days, sanitize_branch_name, CONFIG_KEY_BASE_BRANCH,
10    CONFIG_KEY_BASE_PATH, CONFIG_KEY_INTENDED_BRANCH,
11};
12use crate::error::Result;
13use crate::git;
14
15/// Minimum terminal width for table layout; below this, use compact layout.
16const MIN_TABLE_WIDTH: usize = 100;
17
18/// Determine the status of a worktree.
19///
20/// Status priority: stale > active > merged > pr-open > modified > clean
21///
22/// Merge detection strategy:
23/// 1. `gh pr view` (primary) — works with all merge strategies (merge commit,
24///    squash merge, rebase merge) because GitHub tracks PR state independently
25///    of commit SHAs.
26/// 2. `git branch --merged` (fallback) — only works when commit SHAs are
27///    preserved (merge commit strategy). Used when `gh` is not available.
28pub fn get_worktree_status(path: &Path, repo: &Path, branch: Option<&str>) -> String {
29    if !path.exists() {
30        return "stale".to_string();
31    }
32
33    // Check if cwd is inside this worktree
34    if let Ok(cwd) = std::env::current_dir() {
35        let cwd_str = cwd.to_string_lossy().to_string();
36        let path_str = path.to_string_lossy().to_string();
37        if cwd_str.starts_with(&path_str) {
38            return "active".to_string();
39        }
40    }
41
42    // Check merge/PR status if branch name is available
43    if let Some(branch_name) = branch {
44        let base_branch = {
45            let key = format_config_key(CONFIG_KEY_BASE_BRANCH, branch_name);
46            git::get_config(&key, Some(repo))
47                .unwrap_or_else(|| git::detect_default_branch(Some(repo)))
48        };
49
50        // Primary: GitHub PR status via `gh` CLI
51        // Handles all merge strategies (merge commit, squash, rebase)
52        if let Some(pr_state) = git::get_pr_state(branch_name, Some(repo)) {
53            match pr_state.as_str() {
54                "MERGED" => return "merged".to_string(),
55                "OPEN" => return "pr-open".to_string(),
56                _ => {} // CLOSED or other — fall through
57            }
58        }
59
60        // Fallback: git branch --merged (only works for merge-commit strategy)
61        // Used when `gh` is not installed or no PR was created
62        if git::is_branch_merged(branch_name, &base_branch, Some(repo)) {
63            return "merged".to_string();
64        }
65    }
66
67    // Check for uncommitted changes
68    if let Ok(result) = git::git_command(&["status", "--porcelain"], Some(path), false, true) {
69        if result.returncode == 0 && !result.stdout.trim().is_empty() {
70            return "modified".to_string();
71        }
72    }
73
74    "clean".to_string()
75}
76
77/// Format age in days to human-readable string.
78pub fn format_age(age_days: f64) -> String {
79    if age_days < 1.0 {
80        let hours = (age_days * 24.0) as i64;
81        if hours > 0 {
82            format!("{}h ago", hours)
83        } else {
84            "just now".to_string()
85        }
86    } else if age_days < 7.0 {
87        format!("{}d ago", age_days as i64)
88    } else if age_days < 30.0 {
89        format!("{}w ago", (age_days / 7.0) as i64)
90    } else if age_days < 365.0 {
91        format!("{}mo ago", (age_days / 30.0) as i64)
92    } else {
93        format!("{}y ago", (age_days / 365.0) as i64)
94    }
95}
96
97/// Compute age string for a path.
98fn path_age_str(path: &Path) -> String {
99    if !path.exists() {
100        return String::new();
101    }
102    path_age_days(path).map(format_age).unwrap_or_default()
103}
104
105/// Collected worktree data row for display.
106struct WorktreeRow {
107    worktree_id: String,
108    current_branch: String,
109    status: String,
110    age: String,
111    rel_path: String,
112}
113
114/// List all worktrees for the current repository.
115pub fn list_worktrees() -> Result<()> {
116    let repo = git::get_repo_root(None)?;
117    let worktrees = git::parse_worktrees(&repo)?;
118
119    println!(
120        "\n{}  {}\n",
121        style("Worktrees for repository:").cyan().bold(),
122        repo.display()
123    );
124
125    let mut rows: Vec<WorktreeRow> = Vec::new();
126
127    for (branch, path) in &worktrees {
128        let current_branch = git::normalize_branch_name(branch).to_string();
129        let status = get_worktree_status(path, &repo, Some(&current_branch));
130        let rel_path = pathdiff::diff_paths(path, &repo)
131            .map(|p: std::path::PathBuf| p.to_string_lossy().to_string())
132            .unwrap_or_else(|| path.to_string_lossy().to_string());
133        let age = path_age_str(path);
134
135        // Look up intended branch
136        let intended_branch = lookup_intended_branch(&repo, &current_branch, path);
137        let worktree_id = intended_branch.unwrap_or_else(|| current_branch.clone());
138
139        rows.push(WorktreeRow {
140            worktree_id,
141            current_branch,
142            status,
143            age,
144            rel_path,
145        });
146    }
147
148    let term_width = cwconsole::terminal_width();
149    if term_width >= MIN_TABLE_WIDTH {
150        print_worktree_table(&rows);
151    } else {
152        print_worktree_compact(&rows);
153    }
154
155    // Summary footer
156    let feature_count = if rows.len() > 1 { rows.len() - 1 } else { 0 };
157    if feature_count > 0 {
158        let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
159        for row in &rows {
160            *counts.entry(row.status.as_str()).or_insert(0) += 1;
161        }
162
163        let mut summary_parts = Vec::new();
164        for &status_name in &["clean", "modified", "active", "pr-open", "merged", "stale"] {
165            if let Some(&count) = counts.get(status_name) {
166                if count > 0 {
167                    let styled = cwconsole::status_style(status_name)
168                        .apply_to(format!("{} {}", count, status_name));
169                    summary_parts.push(styled.to_string());
170                }
171            }
172        }
173
174        let summary = if summary_parts.is_empty() {
175            format!("\n{} feature worktree(s)", feature_count)
176        } else {
177            format!(
178                "\n{} feature worktree(s) — {}",
179                feature_count,
180                summary_parts.join(", ")
181            )
182        };
183        println!("{}", summary);
184    }
185
186    println!();
187    Ok(())
188}
189
190/// Look up the intended branch for a worktree via git config metadata.
191fn lookup_intended_branch(repo: &Path, current_branch: &str, path: &Path) -> Option<String> {
192    // Try direct lookup
193    let key = format_config_key(CONFIG_KEY_INTENDED_BRANCH, current_branch);
194    if let Some(intended) = git::get_config(&key, Some(repo)) {
195        return Some(intended);
196    }
197
198    // Search all intended branch metadata
199    let result = git::git_command(
200        &[
201            "config",
202            "--local",
203            "--get-regexp",
204            r"^worktree\..*\.intendedBranch",
205        ],
206        Some(repo),
207        false,
208        true,
209    )
210    .ok()?;
211
212    if result.returncode != 0 {
213        return None;
214    }
215
216    let repo_name = repo.file_name()?.to_string_lossy().to_string();
217
218    for line in result.stdout.trim().lines() {
219        let parts: Vec<&str> = line.splitn(2, char::is_whitespace).collect();
220        if parts.len() == 2 {
221            let key_parts: Vec<&str> = parts[0].split('.').collect();
222            if key_parts.len() >= 2 {
223                let branch_from_key = key_parts[1];
224                let expected_path_name =
225                    format!("{}-{}", repo_name, sanitize_branch_name(branch_from_key));
226                if let Some(name) = path.file_name() {
227                    if name.to_string_lossy() == expected_path_name {
228                        return Some(parts[1].to_string());
229                    }
230                }
231            }
232        }
233    }
234
235    None
236}
237
238fn print_worktree_table(rows: &[WorktreeRow]) {
239    let max_wt = rows.iter().map(|r| r.worktree_id.len()).max().unwrap_or(20);
240    let max_br = rows
241        .iter()
242        .map(|r| r.current_branch.len())
243        .max()
244        .unwrap_or(20);
245    let wt_col = max_wt.clamp(12, 35) + 2;
246    let br_col = max_br.clamp(12, 35) + 2;
247
248    println!(
249        "  {} {:<wt_col$} {:<br_col$} {:<10} {:<12} {}",
250        style(" ").dim(),
251        style("WORKTREE").dim(),
252        style("BRANCH").dim(),
253        style("STATUS").dim(),
254        style("AGE").dim(),
255        style("PATH").dim(),
256        wt_col = wt_col,
257        br_col = br_col,
258    );
259    let line_width = (wt_col + br_col + 40).min(cwconsole::terminal_width().saturating_sub(4));
260    println!("  {}", style("─".repeat(line_width)).dim());
261
262    for row in rows {
263        let icon = cwconsole::status_icon(&row.status);
264        let st = cwconsole::status_style(&row.status);
265
266        let branch_display = if row.worktree_id != row.current_branch {
267            style(format!("{} ⚠", row.current_branch))
268                .yellow()
269                .to_string()
270        } else {
271            row.current_branch.clone()
272        };
273
274        let status_styled = st.apply_to(format!("{:<10}", row.status));
275
276        println!(
277            "  {} {:<wt_col$} {:<br_col$} {} {:<12} {}",
278            st.apply_to(icon),
279            style(&row.worktree_id).bold(),
280            branch_display,
281            status_styled,
282            style(&row.age).dim(),
283            style(&row.rel_path).dim(),
284            wt_col = wt_col,
285            br_col = br_col,
286        );
287    }
288}
289
290fn print_worktree_compact(rows: &[WorktreeRow]) {
291    for row in rows {
292        let icon = cwconsole::status_icon(&row.status);
293        let st = cwconsole::status_style(&row.status);
294        let age_part = if row.age.is_empty() {
295            String::new()
296        } else {
297            format!("  {}", style(&row.age).dim())
298        };
299
300        println!(
301            "  {} {}  {}{}",
302            st.apply_to(icon),
303            style(&row.worktree_id).bold(),
304            st.apply_to(&row.status),
305            age_part,
306        );
307
308        let mut details = Vec::new();
309        if row.worktree_id != row.current_branch {
310            details.push(format!(
311                "branch: {}",
312                style(format!("{} ⚠", row.current_branch)).yellow()
313            ));
314        }
315        if !row.rel_path.is_empty() {
316            details.push(format!("{}", style(&row.rel_path).dim()));
317        }
318        if !details.is_empty() {
319            println!("      {}", details.join("  "));
320        }
321    }
322}
323
324/// Show status of current worktree and list all worktrees.
325pub fn show_status() -> Result<()> {
326    let repo = git::get_repo_root(None)?;
327
328    match git::get_current_branch(Some(&std::env::current_dir().unwrap_or_default())) {
329        Ok(branch) => {
330            let base_key = format_config_key(CONFIG_KEY_BASE_BRANCH, &branch);
331            let path_key = format_config_key(CONFIG_KEY_BASE_PATH, &branch);
332            let base = git::get_config(&base_key, Some(&repo));
333            let base_path = git::get_config(&path_key, Some(&repo));
334
335            println!("\n{}", style("Current worktree:").cyan().bold());
336            println!("  Feature:  {}", style(&branch).green());
337            println!(
338                "  Base:     {}",
339                style(base.as_deref().unwrap_or("N/A")).green()
340            );
341            println!(
342                "  Base path: {}\n",
343                style(base_path.as_deref().unwrap_or("N/A")).blue()
344            );
345        }
346        Err(_) => {
347            println!(
348                "\n{}\n",
349                style("Current directory is not a feature worktree or is the main repository.")
350                    .yellow()
351            );
352        }
353    }
354
355    list_worktrees()
356}
357
358/// Display worktree hierarchy in a visual tree format.
359pub fn show_tree() -> Result<()> {
360    let repo = git::get_repo_root(None)?;
361    let cwd = std::env::current_dir().unwrap_or_default();
362
363    let repo_name = repo
364        .file_name()
365        .map(|n| n.to_string_lossy().to_string())
366        .unwrap_or_else(|| "repo".to_string());
367
368    println!(
369        "\n{} (base repository)",
370        style(format!("{}/", repo_name)).cyan().bold()
371    );
372    println!("{}\n", style(repo.display().to_string()).dim());
373
374    let feature_worktrees = git::get_feature_worktrees(Some(&repo))?;
375
376    if feature_worktrees.is_empty() {
377        println!("{}\n", style("  (no feature worktrees)").dim());
378        return Ok(());
379    }
380
381    let mut sorted = feature_worktrees;
382    sorted.sort_by(|a, b| a.0.cmp(&b.0));
383
384    for (i, (branch_name, path)) in sorted.iter().enumerate() {
385        let is_last = i == sorted.len() - 1;
386        let prefix = if is_last { "└── " } else { "├── " };
387
388        let status = get_worktree_status(path, &repo, Some(branch_name.as_str()));
389        let is_current = cwd
390            .to_string_lossy()
391            .starts_with(&path.to_string_lossy().to_string());
392
393        let icon = cwconsole::status_icon(&status);
394        let st = cwconsole::status_style(&status);
395
396        let branch_display = if is_current {
397            st.clone()
398                .bold()
399                .apply_to(format!("★ {}", branch_name))
400                .to_string()
401        } else {
402            st.clone().apply_to(branch_name.as_str()).to_string()
403        };
404
405        let age = path_age_str(path);
406        let age_display = if age.is_empty() {
407            String::new()
408        } else {
409            format!("  {}", style(age).dim())
410        };
411
412        println!(
413            "{}{} {}{}",
414            prefix,
415            st.apply_to(icon),
416            branch_display,
417            age_display
418        );
419
420        let path_display = if let Ok(rel) = path.strip_prefix(repo.parent().unwrap_or(&repo)) {
421            format!("../{}", rel.display())
422        } else {
423            path.display().to_string()
424        };
425
426        let continuation = if is_last { "    " } else { "│   " };
427        println!("{}{}", continuation, style(&path_display).dim());
428    }
429
430    // Legend
431    println!("\n{}", style("Legend:").bold());
432    println!(
433        "  {} active (current)",
434        cwconsole::status_style("active").apply_to("●")
435    );
436    println!("  {} clean", cwconsole::status_style("clean").apply_to("○"));
437    println!(
438        "  {} modified",
439        cwconsole::status_style("modified").apply_to("◉")
440    );
441    println!(
442        "  {} pr-open",
443        cwconsole::status_style("pr-open").apply_to("⬆")
444    );
445    println!(
446        "  {} merged",
447        cwconsole::status_style("merged").apply_to("✓")
448    );
449    println!("  {} stale", cwconsole::status_style("stale").apply_to("x"));
450    println!(
451        "  {} currently active worktree\n",
452        style("★").green().bold()
453    );
454
455    Ok(())
456}
457
458/// Display usage analytics for worktrees.
459pub fn show_stats() -> Result<()> {
460    let repo = git::get_repo_root(None)?;
461    let feature_worktrees = git::get_feature_worktrees(Some(&repo))?;
462
463    if feature_worktrees.is_empty() {
464        println!("\n{}\n", style("No feature worktrees found").yellow());
465        return Ok(());
466    }
467
468    println!();
469    println!("  {}", style("Worktree Statistics").cyan().bold());
470    println!("  {}", style("─".repeat(40)).dim());
471    println!();
472
473    struct WtData {
474        branch: String,
475        status: String,
476        age_days: f64,
477        commit_count: usize,
478    }
479
480    let mut data: Vec<WtData> = Vec::new();
481
482    for (branch_name, path) in &feature_worktrees {
483        let status = get_worktree_status(path, &repo, Some(branch_name.as_str()));
484        let age_days = path_age_days(path).unwrap_or(0.0);
485
486        let commit_count = git::git_command(
487            &["rev-list", "--count", branch_name],
488            Some(path),
489            false,
490            true,
491        )
492        .ok()
493        .and_then(|r| {
494            if r.returncode == 0 {
495                r.stdout.trim().parse::<usize>().ok()
496            } else {
497                None
498            }
499        })
500        .unwrap_or(0);
501
502        data.push(WtData {
503            branch: branch_name.clone(),
504            status,
505            age_days,
506            commit_count,
507        });
508    }
509
510    // Overview
511    let mut status_counts: std::collections::HashMap<&str, usize> =
512        std::collections::HashMap::new();
513    for d in &data {
514        *status_counts.entry(d.status.as_str()).or_insert(0) += 1;
515    }
516
517    println!("  {} {}", style("Total:").bold(), data.len());
518
519    // Status bar visualization
520    let total = data.len();
521    let bar_width = 30;
522    let clean = *status_counts.get("clean").unwrap_or(&0);
523    let modified = *status_counts.get("modified").unwrap_or(&0);
524    let active = *status_counts.get("active").unwrap_or(&0);
525    let pr_open = *status_counts.get("pr-open").unwrap_or(&0);
526    let merged = *status_counts.get("merged").unwrap_or(&0);
527    let stale = *status_counts.get("stale").unwrap_or(&0);
528
529    let bar_clean = (clean * bar_width) / total.max(1);
530    let bar_modified = (modified * bar_width) / total.max(1);
531    let bar_active = (active * bar_width) / total.max(1);
532    let bar_pr_open = (pr_open * bar_width) / total.max(1);
533    let bar_merged = (merged * bar_width) / total.max(1);
534    let bar_stale = (stale * bar_width) / total.max(1);
535    // Fill remaining with clean if rounding left gaps
536    let bar_remainder =
537        bar_width - bar_clean - bar_modified - bar_active - bar_pr_open - bar_merged - bar_stale;
538
539    print!("  ");
540    print!("{}", style("█".repeat(bar_clean + bar_remainder)).green());
541    print!("{}", style("█".repeat(bar_modified)).yellow());
542    print!("{}", style("█".repeat(bar_active)).green().bold());
543    print!("{}", style("█".repeat(bar_pr_open)).cyan());
544    print!("{}", style("█".repeat(bar_merged)).magenta());
545    print!("{}", style("█".repeat(bar_stale)).red());
546    println!();
547
548    let mut parts = Vec::new();
549    if clean > 0 {
550        parts.push(format!("{}", style(format!("○ {} clean", clean)).green()));
551    }
552    if modified > 0 {
553        parts.push(format!(
554            "{}",
555            style(format!("◉ {} modified", modified)).yellow()
556        ));
557    }
558    if active > 0 {
559        parts.push(format!(
560            "{}",
561            style(format!("● {} active", active)).green().bold()
562        ));
563    }
564    if pr_open > 0 {
565        parts.push(format!(
566            "{}",
567            style(format!("⬆ {} pr-open", pr_open)).cyan()
568        ));
569    }
570    if merged > 0 {
571        parts.push(format!(
572            "{}",
573            style(format!("✓ {} merged", merged)).magenta()
574        ));
575    }
576    if stale > 0 {
577        parts.push(format!("{}", style(format!("x {} stale", stale)).red()));
578    }
579    println!("  {}", parts.join("  "));
580    println!();
581
582    // Age statistics
583    let ages: Vec<f64> = data
584        .iter()
585        .filter(|d| d.age_days > 0.0)
586        .map(|d| d.age_days)
587        .collect();
588    if !ages.is_empty() {
589        let avg = ages.iter().sum::<f64>() / ages.len() as f64;
590        let oldest = ages.iter().cloned().fold(0.0_f64, f64::max);
591        let newest = ages.iter().cloned().fold(f64::MAX, f64::min);
592
593        println!("  {} Age", style("◷").dim());
594        println!(
595            "    avg {}  oldest {}  newest {}",
596            style(format!("{:.1}d", avg)).bold(),
597            style(format!("{:.1}d", oldest)).yellow(),
598            style(format!("{:.1}d", newest)).green(),
599        );
600        println!();
601    }
602
603    // Commit statistics
604    let commits: Vec<usize> = data
605        .iter()
606        .filter(|d| d.commit_count > 0)
607        .map(|d| d.commit_count)
608        .collect();
609    if !commits.is_empty() {
610        let total: usize = commits.iter().sum();
611        let avg = total as f64 / commits.len() as f64;
612        let max_c = *commits.iter().max().unwrap_or(&0);
613
614        println!("  {} Commits", style("⟲").dim());
615        println!(
616            "    total {}  avg {:.1}  max {}",
617            style(total).bold(),
618            avg,
619            style(max_c).bold(),
620        );
621        println!();
622    }
623
624    // Top by age
625    println!("  {}", style("Oldest Worktrees").bold());
626    let mut by_age = data.iter().collect::<Vec<_>>();
627    by_age.sort_by(|a, b| b.age_days.total_cmp(&a.age_days));
628    let max_age = by_age.first().map(|d| d.age_days).unwrap_or(1.0).max(1.0);
629    for d in by_age.iter().take(5) {
630        if d.age_days > 0.0 {
631            let icon = cwconsole::status_icon(&d.status);
632            let st = cwconsole::status_style(&d.status);
633            let bar_len = ((d.age_days / max_age) * 15.0) as usize;
634            println!(
635                "    {} {:<25} {} {}",
636                st.apply_to(icon),
637                d.branch,
638                style("▓".repeat(bar_len.max(1))).dim(),
639                style(format_age(d.age_days)).dim(),
640            );
641        }
642    }
643    println!();
644
645    // Top by commits
646    println!("  {}", style("Most Active (by commits)").bold());
647    let mut by_commits = data.iter().collect::<Vec<_>>();
648    by_commits.sort_by(|a, b| b.commit_count.cmp(&a.commit_count));
649    let max_commits = by_commits
650        .first()
651        .map(|d| d.commit_count)
652        .unwrap_or(1)
653        .max(1);
654    for d in by_commits.iter().take(5) {
655        if d.commit_count > 0 {
656            let icon = cwconsole::status_icon(&d.status);
657            let st = cwconsole::status_style(&d.status);
658            let bar_len = (d.commit_count * 15) / max_commits;
659            println!(
660                "    {} {:<25} {} {}",
661                st.apply_to(icon),
662                d.branch,
663                style("▓".repeat(bar_len.max(1))).cyan(),
664                style(format!("{} commits", d.commit_count)).dim(),
665            );
666        }
667    }
668    println!();
669
670    Ok(())
671}
672
673/// Compare two branches.
674pub fn diff_worktrees(branch1: &str, branch2: &str, summary: bool, files: bool) -> Result<()> {
675    let repo = git::get_repo_root(None)?;
676
677    if !git::branch_exists(branch1, Some(&repo)) {
678        return Err(crate::error::CwError::InvalidBranch(format!(
679            "Branch '{}' not found",
680            branch1
681        )));
682    }
683    if !git::branch_exists(branch2, Some(&repo)) {
684        return Err(crate::error::CwError::InvalidBranch(format!(
685            "Branch '{}' not found",
686            branch2
687        )));
688    }
689
690    println!("\n{}", style("Comparing branches:").cyan().bold());
691    println!("  {} {} {}\n", branch1, style("...").yellow(), branch2);
692
693    if files {
694        let result = git::git_command(
695            &["diff", "--name-status", branch1, branch2],
696            Some(&repo),
697            true,
698            true,
699        )?;
700        println!("{}\n", style("Changed files:").bold());
701        if result.stdout.trim().is_empty() {
702            println!("  {}", style("No differences found").dim());
703        } else {
704            for line in result.stdout.trim().lines() {
705                let parts: Vec<&str> = line.splitn(2, char::is_whitespace).collect();
706                if parts.len() == 2 {
707                    let (status_char, filename) = (parts[0], parts[1]);
708                    let c = status_char.chars().next().unwrap_or('?');
709                    let status_name = match c {
710                        'M' => "Modified",
711                        'A' => "Added",
712                        'D' => "Deleted",
713                        'R' => "Renamed",
714                        'C' => "Copied",
715                        _ => "Changed",
716                    };
717                    let styled_status = match c {
718                        'M' => style(status_char).yellow(),
719                        'A' => style(status_char).green(),
720                        'D' => style(status_char).red(),
721                        'R' | 'C' => style(status_char).cyan(),
722                        _ => style(status_char),
723                    };
724                    println!("  {}  {} ({})", styled_status, filename, status_name);
725                }
726            }
727        }
728    } else if summary {
729        let result = git::git_command(
730            &["diff", "--stat", branch1, branch2],
731            Some(&repo),
732            true,
733            true,
734        )?;
735        println!("{}\n", style("Diff summary:").bold());
736        if result.stdout.trim().is_empty() {
737            println!("  {}", style("No differences found").dim());
738        } else {
739            println!("{}", result.stdout);
740        }
741    } else {
742        let result = git::git_command(&["diff", branch1, branch2], Some(&repo), true, true)?;
743        if result.stdout.trim().is_empty() {
744            println!("{}\n", style("No differences found").dim());
745        } else {
746            println!("{}", result.stdout);
747        }
748    }
749
750    Ok(())
751}
752
753#[cfg(test)]
754mod tests {
755    use super::*;
756
757    #[test]
758    fn test_format_age_just_now() {
759        assert_eq!(format_age(0.0), "just now");
760        assert_eq!(format_age(0.001), "just now"); // ~1.4 minutes
761    }
762
763    #[test]
764    fn test_format_age_hours() {
765        assert_eq!(format_age(1.0 / 24.0), "1h ago"); // exactly 1 hour
766        assert_eq!(format_age(0.5), "12h ago"); // 12 hours
767        assert_eq!(format_age(0.99), "23h ago"); // ~23.7 hours
768    }
769
770    #[test]
771    fn test_format_age_days() {
772        assert_eq!(format_age(1.0), "1d ago");
773        assert_eq!(format_age(1.5), "1d ago");
774        assert_eq!(format_age(6.9), "6d ago");
775    }
776
777    #[test]
778    fn test_format_age_weeks() {
779        assert_eq!(format_age(7.0), "1w ago");
780        assert_eq!(format_age(14.0), "2w ago");
781        assert_eq!(format_age(29.0), "4w ago");
782    }
783
784    #[test]
785    fn test_format_age_months() {
786        assert_eq!(format_age(30.0), "1mo ago");
787        assert_eq!(format_age(60.0), "2mo ago");
788        assert_eq!(format_age(364.0), "12mo ago");
789    }
790
791    #[test]
792    fn test_format_age_years() {
793        assert_eq!(format_age(365.0), "1y ago");
794        assert_eq!(format_age(730.0), "2y ago");
795    }
796
797    #[test]
798    fn test_format_age_boundary_below_one_hour() {
799        // Less than 1 hour (1/24 day ≈ 0.0417)
800        assert_eq!(format_age(0.04), "just now"); // 0.04 * 24 = 0.96h → 0 as i64
801    }
802
803    #[test]
804    fn test_get_worktree_status_stale() {
805        use std::path::PathBuf;
806        let non_existent = PathBuf::from("/tmp/gw-test-nonexistent-12345");
807        let repo = PathBuf::from("/tmp");
808        assert_eq!(get_worktree_status(&non_existent, &repo, None), "stale");
809    }
810}