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