use std::path::Path;
use std::sync::mpsc;
use console::style;
use ratatui::{backend::CrosstermBackend, Terminal, TerminalOptions, Viewport};
use crate::console as cwconsole;
use crate::constants::{
format_config_key, path_age_days, sanitize_branch_name, CONFIG_KEY_BASE_BRANCH,
CONFIG_KEY_BASE_PATH, CONFIG_KEY_INTENDED_BRANCH,
};
use crate::error::Result;
use crate::git;
use rayon::prelude::*;
use super::pr_cache::PrCache;
const MIN_TABLE_WIDTH: usize = 100;
pub fn get_worktree_status(
path: &Path,
repo: &Path,
branch: Option<&str>,
pr_cache: &PrCache,
) -> String {
if !path.exists() {
return "stale".to_string();
}
if !crate::operations::busy::detect_busy_lockfile_only(path).is_empty() {
return "busy".to_string();
}
if let Ok(cwd) = std::env::current_dir() {
let cwd_canon = cwd.canonicalize().unwrap_or(cwd);
let path_canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
if cwd_canon.starts_with(&path_canon) {
return "active".to_string();
}
}
if let Some(branch_name) = branch {
let base_branch = {
let key = format_config_key(CONFIG_KEY_BASE_BRANCH, branch_name);
git::get_config(&key, Some(repo))
.unwrap_or_else(|| git::detect_default_branch(Some(repo)))
};
if let Some(state) = pr_cache.state(branch_name) {
match state {
super::pr_cache::PrState::Merged => return "merged".to_string(),
super::pr_cache::PrState::Open => return "pr-open".to_string(),
_ => {}
}
}
if git::is_branch_merged(branch_name, &base_branch, Some(repo)) {
return "merged".to_string();
}
}
if let Ok(result) = git::git_command(&["status", "--porcelain"], Some(path), false, true) {
if result.returncode == 0 && !result.stdout.trim().is_empty() {
return "modified".to_string();
}
}
"clean".to_string()
}
pub fn format_age(age_days: f64) -> String {
if age_days < 1.0 {
let hours = (age_days * 24.0) as i64;
if hours > 0 {
format!("{}h ago", hours)
} else {
"just now".to_string()
}
} else if age_days < 7.0 {
format!("{}d ago", age_days as i64)
} else if age_days < 30.0 {
format!("{}w ago", (age_days / 7.0) as i64)
} else if age_days < 365.0 {
format!("{}mo ago", (age_days / 30.0) as i64)
} else {
format!("{}y ago", (age_days / 365.0) as i64)
}
}
fn path_age_str(path: &Path) -> String {
if !path.exists() {
return String::new();
}
path_age_days(path).map(format_age).unwrap_or_default()
}
struct WorktreeRow {
worktree_id: String,
current_branch: String,
status: String,
age: String,
rel_path: String,
}
#[derive(Clone)]
struct RowInput {
path: std::path::PathBuf,
current_branch: String,
worktree_id: String,
age: String,
rel_path: String,
}
impl RowInput {
fn into_row(self, status: String) -> WorktreeRow {
WorktreeRow {
worktree_id: self.worktree_id,
current_branch: self.current_branch,
status,
age: self.age,
rel_path: self.rel_path,
}
}
}
pub fn list_worktrees(no_cache: bool) -> Result<()> {
let repo = git::get_repo_root(None)?;
let worktrees = git::parse_worktrees(&repo)?;
println!(
"\n{} {}\n",
style("Worktrees for repository:").cyan().bold(),
repo.display()
);
let pr_cache = PrCache::load_or_fetch(&repo, no_cache);
let inputs: Vec<RowInput> = worktrees
.iter()
.map(|(branch, path)| {
let current_branch = git::normalize_branch_name(branch).to_string();
let rel_path = pathdiff::diff_paths(path, &repo)
.map(|p: std::path::PathBuf| p.to_string_lossy().to_string())
.unwrap_or_else(|| path.to_string_lossy().to_string());
let age = path_age_str(path);
let intended_branch = lookup_intended_branch(&repo, ¤t_branch, path);
let worktree_id = intended_branch.unwrap_or_else(|| current_branch.clone());
RowInput {
path: path.clone(),
current_branch,
worktree_id,
age,
rel_path,
}
})
.collect();
if inputs.is_empty() {
println!(" {}\n", style("No worktrees found.").dim());
return Ok(());
}
let is_tty = crate::tui::stdout_is_tty();
let term_width = cwconsole::terminal_width();
let narrow = term_width < MIN_TABLE_WIDTH;
let use_progressive = is_tty && !narrow;
let rows: Vec<WorktreeRow> = if use_progressive {
render_rows_progressive(&repo, &pr_cache, inputs)?
} else {
inputs
.into_par_iter()
.map(|i| {
let status =
get_worktree_status(&i.path, &repo, Some(&i.current_branch), &pr_cache);
i.into_row(status)
})
.collect()
};
if !use_progressive {
if narrow {
print_worktree_compact(&rows);
} else {
print_worktree_table(&rows);
}
}
print_summary_footer(&rows);
println!();
Ok(())
}
type CrosstermTerminal = ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stdout>>;
struct TerminalGuard(Option<CrosstermTerminal>);
impl TerminalGuard {
fn new(terminal: CrosstermTerminal) -> Self {
Self(Some(terminal))
}
fn as_mut(&mut self) -> &mut CrosstermTerminal {
self.0.as_mut().expect("terminal already taken")
}
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
let _ = self.0.take(); ratatui::restore();
crate::tui::mark_ratatui_inactive();
}
}
fn render_rows_progressive(
repo: &std::path::Path,
pr_cache: &PrCache,
inputs: Vec<RowInput>,
) -> Result<Vec<WorktreeRow>> {
let row_data: Vec<crate::tui::list_view::RowData> = inputs
.iter()
.map(|i| crate::tui::list_view::RowData {
worktree_id: i.worktree_id.clone(),
current_branch: i.current_branch.clone(),
status: crate::tui::list_view::PLACEHOLDER.to_string(),
age: i.age.clone(),
rel_path: i.rel_path.clone(),
})
.collect();
let mut app = crate::tui::list_view::ListApp::new(row_data);
let viewport_height = u16::try_from(inputs.len())
.unwrap_or(u16::MAX)
.saturating_add(2)
.max(3);
let stdout = std::io::stdout();
let backend = CrosstermBackend::new(stdout);
crate::tui::mark_ratatui_active();
let terminal = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Inline(viewport_height),
},
)
})) {
Ok(Ok(t)) => t,
Ok(Err(e)) => {
crate::tui::mark_ratatui_inactive();
return Err(e.into());
}
Err(panic) => {
crate::tui::mark_ratatui_inactive();
std::panic::resume_unwind(panic);
}
};
let mut guard = TerminalGuard::new(terminal);
let (tx, rx) = mpsc::channel();
guard.as_mut().draw(|f| app.render(f))?;
std::thread::scope(|s| -> Result<()> {
let producer = s.spawn(move || {
inputs
.par_iter()
.enumerate()
.for_each_with(tx, |tx, (i, input)| {
let status = get_worktree_status(
&input.path,
repo,
Some(&input.current_branch),
pr_cache,
);
let _ = tx.send((i, status));
});
});
let run_result = crate::tui::list_view::run(guard.as_mut(), &mut app, rx);
let producer_result = producer.join();
if let Err(panic) = producer_result {
let msg = panic
.downcast_ref::<&str>()
.map(|s| (*s).to_string())
.or_else(|| panic.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "non-string panic payload".to_string());
eprintln!(
"warning: status producer thread panicked, some rows may show \"unknown\": {}",
msg
);
}
run_result.map_err(crate::error::CwError::from)
})?;
if app.finalize_pending("unknown") {
guard.as_mut().draw(|f| app.render(f))?;
}
Ok(app.into_rows().into_iter().map(Into::into).collect())
}
impl From<crate::tui::list_view::RowData> for WorktreeRow {
fn from(r: crate::tui::list_view::RowData) -> Self {
let crate::tui::list_view::RowData {
worktree_id,
current_branch,
status,
age,
rel_path,
} = r;
WorktreeRow {
worktree_id,
current_branch,
status,
age,
rel_path,
}
}
}
fn lookup_intended_branch(repo: &Path, current_branch: &str, path: &Path) -> Option<String> {
let key = format_config_key(CONFIG_KEY_INTENDED_BRANCH, current_branch);
if let Some(intended) = git::get_config(&key, Some(repo)) {
return Some(intended);
}
let result = git::git_command(
&[
"config",
"--local",
"--get-regexp",
r"^worktree\..*\.intendedBranch",
],
Some(repo),
false,
true,
)
.ok()?;
if result.returncode != 0 {
return None;
}
let repo_name = repo.file_name()?.to_string_lossy().to_string();
for line in result.stdout.trim().lines() {
let parts: Vec<&str> = line.splitn(2, char::is_whitespace).collect();
if parts.len() == 2 {
let key_parts: Vec<&str> = parts[0].split('.').collect();
if key_parts.len() >= 2 {
let branch_from_key = key_parts[1];
let expected_path_name =
format!("{}-{}", repo_name, sanitize_branch_name(branch_from_key));
if let Some(name) = path.file_name() {
if name.to_string_lossy() == expected_path_name {
return Some(parts[1].to_string());
}
}
}
}
}
None
}
fn print_summary_footer(rows: &[WorktreeRow]) {
let feature_count = if rows.len() > 1 { rows.len() - 1 } else { 0 };
if feature_count == 0 {
return;
}
let mut counts: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
for row in rows {
*counts.entry(row.status.as_str()).or_insert(0) += 1;
}
let mut summary_parts = Vec::new();
for &status_name in &[
"clean", "modified", "busy", "active", "pr-open", "merged", "stale",
] {
if let Some(&count) = counts.get(status_name) {
if count > 0 {
let styled = cwconsole::status_style(status_name)
.apply_to(format!("{} {}", count, status_name));
summary_parts.push(styled.to_string());
}
}
}
let summary = if summary_parts.is_empty() {
format!("\n{} feature worktree(s)", feature_count)
} else {
format!(
"\n{} feature worktree(s) — {}",
feature_count,
summary_parts.join(", ")
)
};
println!("{}", summary);
}
fn print_worktree_table(rows: &[WorktreeRow]) {
let max_wt = rows.iter().map(|r| r.worktree_id.len()).max().unwrap_or(20);
let max_br = rows
.iter()
.map(|r| r.current_branch.len())
.max()
.unwrap_or(20);
let wt_col = max_wt.clamp(12, 35) + 2;
let br_col = max_br.clamp(12, 35) + 2;
println!(
" {} {:<wt_col$} {:<br_col$} {:<10} {:<12} {}",
style(" ").dim(),
style("WORKTREE").dim(),
style("BRANCH").dim(),
style("STATUS").dim(),
style("AGE").dim(),
style("PATH").dim(),
wt_col = wt_col,
br_col = br_col,
);
let line_width = (wt_col + br_col + 40).min(cwconsole::terminal_width().saturating_sub(4));
println!(" {}", style("─".repeat(line_width)).dim());
for row in rows {
let icon = cwconsole::status_icon(&row.status);
let st = cwconsole::status_style(&row.status);
let branch_display = if row.worktree_id != row.current_branch {
style(format!("{} ⚠", row.current_branch))
.yellow()
.to_string()
} else {
row.current_branch.clone()
};
let status_styled = st.apply_to(format!("{:<10}", row.status));
println!(
" {} {:<wt_col$} {:<br_col$} {} {:<12} {}",
st.apply_to(icon),
style(&row.worktree_id).bold(),
branch_display,
status_styled,
style(&row.age).dim(),
style(&row.rel_path).dim(),
wt_col = wt_col,
br_col = br_col,
);
}
}
fn print_worktree_compact(rows: &[WorktreeRow]) {
for row in rows {
let icon = cwconsole::status_icon(&row.status);
let st = cwconsole::status_style(&row.status);
let age_part = if row.age.is_empty() {
String::new()
} else {
format!(" {}", style(&row.age).dim())
};
println!(
" {} {} {}{}",
st.apply_to(icon),
style(&row.worktree_id).bold(),
st.apply_to(&row.status),
age_part,
);
let mut details = Vec::new();
if row.worktree_id != row.current_branch {
details.push(format!(
"branch: {}",
style(format!("{} ⚠", row.current_branch)).yellow()
));
}
if !row.rel_path.is_empty() {
details.push(format!("{}", style(&row.rel_path).dim()));
}
if !details.is_empty() {
println!(" {}", details.join(" "));
}
}
}
pub fn show_status(no_cache: bool) -> Result<()> {
let repo = git::get_repo_root(None)?;
match git::get_current_branch(Some(&std::env::current_dir().unwrap_or_default())) {
Ok(branch) => {
let base_key = format_config_key(CONFIG_KEY_BASE_BRANCH, &branch);
let path_key = format_config_key(CONFIG_KEY_BASE_PATH, &branch);
let base = git::get_config(&base_key, Some(&repo));
let base_path = git::get_config(&path_key, Some(&repo));
println!("\n{}", style("Current worktree:").cyan().bold());
println!(" Feature: {}", style(&branch).green());
println!(
" Base: {}",
style(base.as_deref().unwrap_or("N/A")).green()
);
println!(
" Base path: {}\n",
style(base_path.as_deref().unwrap_or("N/A")).blue()
);
}
Err(_) => {
println!(
"\n{}\n",
style("Current directory is not a feature worktree or is the main repository.")
.yellow()
);
}
}
list_worktrees(no_cache)
}
pub fn show_tree(no_cache: bool) -> Result<()> {
let repo = git::get_repo_root(None)?;
let cwd = std::env::current_dir().unwrap_or_default();
let repo_name = repo
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "repo".to_string());
println!(
"\n{} (base repository)",
style(format!("{}/", repo_name)).cyan().bold()
);
println!("{}\n", style(repo.display().to_string()).dim());
let feature_worktrees = git::get_feature_worktrees(Some(&repo))?;
if feature_worktrees.is_empty() {
println!("{}\n", style(" (no feature worktrees)").dim());
return Ok(());
}
let mut sorted = feature_worktrees;
sorted.sort_by(|a, b| a.0.cmp(&b.0));
let pr_cache = PrCache::load_or_fetch(&repo, no_cache);
for (i, (branch_name, path)) in sorted.iter().enumerate() {
let is_last = i == sorted.len() - 1;
let prefix = if is_last { "└── " } else { "├── " };
let status = get_worktree_status(path, &repo, Some(branch_name.as_str()), &pr_cache);
let is_current = cwd
.to_string_lossy()
.starts_with(&path.to_string_lossy().to_string());
let icon = cwconsole::status_icon(&status);
let st = cwconsole::status_style(&status);
let branch_display = if is_current {
st.clone()
.bold()
.apply_to(format!("★ {}", branch_name))
.to_string()
} else {
st.clone().apply_to(branch_name.as_str()).to_string()
};
let age = path_age_str(path);
let age_display = if age.is_empty() {
String::new()
} else {
format!(" {}", style(age).dim())
};
println!(
"{}{} {}{}",
prefix,
st.apply_to(icon),
branch_display,
age_display
);
let path_display = if let Ok(rel) = path.strip_prefix(repo.parent().unwrap_or(&repo)) {
format!("../{}", rel.display())
} else {
path.display().to_string()
};
let continuation = if is_last { " " } else { "│ " };
println!("{}{}", continuation, style(&path_display).dim());
}
println!("\n{}", style("Legend:").bold());
println!(
" {} active (current)",
cwconsole::status_style("active").apply_to("●")
);
println!(" {} clean", cwconsole::status_style("clean").apply_to("○"));
println!(
" {} modified",
cwconsole::status_style("modified").apply_to("◉")
);
println!(
" {} pr-open",
cwconsole::status_style("pr-open").apply_to("⬆")
);
println!(
" {} merged",
cwconsole::status_style("merged").apply_to("✓")
);
println!(
" {} busy (other session)",
cwconsole::status_style("busy").apply_to("🔒")
);
println!(" {} stale", cwconsole::status_style("stale").apply_to("x"));
println!(
" {} currently active worktree\n",
style("★").green().bold()
);
Ok(())
}
pub fn show_stats(no_cache: bool) -> Result<()> {
let repo = git::get_repo_root(None)?;
let feature_worktrees = git::get_feature_worktrees(Some(&repo))?;
if feature_worktrees.is_empty() {
println!("\n{}\n", style("No feature worktrees found").yellow());
return Ok(());
}
println!();
println!(" {}", style("Worktree Statistics").cyan().bold());
println!(" {}", style("─".repeat(40)).dim());
println!();
struct WtData {
branch: String,
status: String,
age_days: f64,
commit_count: usize,
}
let mut data: Vec<WtData> = Vec::new();
let pr_cache = PrCache::load_or_fetch(&repo, no_cache);
for (branch_name, path) in &feature_worktrees {
let status = get_worktree_status(path, &repo, Some(branch_name.as_str()), &pr_cache);
let age_days = path_age_days(path).unwrap_or(0.0);
let commit_count = git::git_command(
&["rev-list", "--count", branch_name],
Some(path),
false,
true,
)
.ok()
.and_then(|r| {
if r.returncode == 0 {
r.stdout.trim().parse::<usize>().ok()
} else {
None
}
})
.unwrap_or(0);
data.push(WtData {
branch: branch_name.clone(),
status,
age_days,
commit_count,
});
}
let mut status_counts: std::collections::HashMap<&str, usize> =
std::collections::HashMap::new();
for d in &data {
*status_counts.entry(d.status.as_str()).or_insert(0) += 1;
}
println!(" {} {}", style("Total:").bold(), data.len());
let total = data.len();
let bar_width = 30;
let clean = *status_counts.get("clean").unwrap_or(&0);
let modified = *status_counts.get("modified").unwrap_or(&0);
let active = *status_counts.get("active").unwrap_or(&0);
let pr_open = *status_counts.get("pr-open").unwrap_or(&0);
let merged = *status_counts.get("merged").unwrap_or(&0);
let busy = *status_counts.get("busy").unwrap_or(&0);
let stale = *status_counts.get("stale").unwrap_or(&0);
let bar_clean = (clean * bar_width) / total.max(1);
let bar_modified = (modified * bar_width) / total.max(1);
let bar_active = (active * bar_width) / total.max(1);
let bar_pr_open = (pr_open * bar_width) / total.max(1);
let bar_merged = (merged * bar_width) / total.max(1);
let bar_busy = (busy * bar_width) / total.max(1);
let bar_stale = (stale * bar_width) / total.max(1);
let bar_remainder = bar_width
- bar_clean
- bar_modified
- bar_active
- bar_pr_open
- bar_merged
- bar_busy
- bar_stale;
print!(" ");
print!("{}", style("█".repeat(bar_clean + bar_remainder)).green());
print!("{}", style("█".repeat(bar_modified)).yellow());
print!("{}", style("█".repeat(bar_active)).green().bold());
print!("{}", style("█".repeat(bar_pr_open)).cyan());
print!("{}", style("█".repeat(bar_merged)).magenta());
print!("{}", style("█".repeat(bar_busy)).red().bold());
print!("{}", style("█".repeat(bar_stale)).red());
println!();
let mut parts = Vec::new();
if clean > 0 {
parts.push(format!("{}", style(format!("○ {} clean", clean)).green()));
}
if modified > 0 {
parts.push(format!(
"{}",
style(format!("◉ {} modified", modified)).yellow()
));
}
if active > 0 {
parts.push(format!(
"{}",
style(format!("● {} active", active)).green().bold()
));
}
if pr_open > 0 {
parts.push(format!(
"{}",
style(format!("⬆ {} pr-open", pr_open)).cyan()
));
}
if merged > 0 {
parts.push(format!(
"{}",
style(format!("✓ {} merged", merged)).magenta()
));
}
if busy > 0 {
parts.push(format!(
"{}",
style(format!("🔒 {} busy", busy)).red().bold()
));
}
if stale > 0 {
parts.push(format!("{}", style(format!("x {} stale", stale)).red()));
}
println!(" {}", parts.join(" "));
println!();
let ages: Vec<f64> = data
.iter()
.filter(|d| d.age_days > 0.0)
.map(|d| d.age_days)
.collect();
if !ages.is_empty() {
let avg = ages.iter().sum::<f64>() / ages.len() as f64;
let oldest = ages.iter().cloned().fold(0.0_f64, f64::max);
let newest = ages.iter().cloned().fold(f64::MAX, f64::min);
println!(" {} Age", style("◷").dim());
println!(
" avg {} oldest {} newest {}",
style(format!("{:.1}d", avg)).bold(),
style(format!("{:.1}d", oldest)).yellow(),
style(format!("{:.1}d", newest)).green(),
);
println!();
}
let commits: Vec<usize> = data
.iter()
.filter(|d| d.commit_count > 0)
.map(|d| d.commit_count)
.collect();
if !commits.is_empty() {
let total: usize = commits.iter().sum();
let avg = total as f64 / commits.len() as f64;
let max_c = *commits.iter().max().unwrap_or(&0);
println!(" {} Commits", style("⟲").dim());
println!(
" total {} avg {:.1} max {}",
style(total).bold(),
avg,
style(max_c).bold(),
);
println!();
}
println!(" {}", style("Oldest Worktrees").bold());
let mut by_age = data.iter().collect::<Vec<_>>();
by_age.sort_by(|a, b| b.age_days.total_cmp(&a.age_days));
let max_age = by_age.first().map(|d| d.age_days).unwrap_or(1.0).max(1.0);
for d in by_age.iter().take(5) {
if d.age_days > 0.0 {
let icon = cwconsole::status_icon(&d.status);
let st = cwconsole::status_style(&d.status);
let bar_len = ((d.age_days / max_age) * 15.0) as usize;
println!(
" {} {:<25} {} {}",
st.apply_to(icon),
d.branch,
style("▓".repeat(bar_len.max(1))).dim(),
style(format_age(d.age_days)).dim(),
);
}
}
println!();
println!(" {}", style("Most Active (by commits)").bold());
let mut by_commits = data.iter().collect::<Vec<_>>();
by_commits.sort_by_key(|b| std::cmp::Reverse(b.commit_count));
let max_commits = by_commits
.first()
.map(|d| d.commit_count)
.unwrap_or(1)
.max(1);
for d in by_commits.iter().take(5) {
if d.commit_count > 0 {
let icon = cwconsole::status_icon(&d.status);
let st = cwconsole::status_style(&d.status);
let bar_len = (d.commit_count * 15) / max_commits;
println!(
" {} {:<25} {} {}",
st.apply_to(icon),
d.branch,
style("▓".repeat(bar_len.max(1))).cyan(),
style(format!("{} commits", d.commit_count)).dim(),
);
}
}
println!();
Ok(())
}
pub fn diff_worktrees(branch1: &str, branch2: &str, summary: bool, files: bool) -> Result<()> {
let repo = git::get_repo_root(None)?;
if !git::branch_exists(branch1, Some(&repo)) {
return Err(crate::error::CwError::InvalidBranch(format!(
"Branch '{}' not found",
branch1
)));
}
if !git::branch_exists(branch2, Some(&repo)) {
return Err(crate::error::CwError::InvalidBranch(format!(
"Branch '{}' not found",
branch2
)));
}
println!("\n{}", style("Comparing branches:").cyan().bold());
println!(" {} {} {}\n", branch1, style("...").yellow(), branch2);
if files {
let result = git::git_command(
&["diff", "--name-status", branch1, branch2],
Some(&repo),
true,
true,
)?;
println!("{}\n", style("Changed files:").bold());
if result.stdout.trim().is_empty() {
println!(" {}", style("No differences found").dim());
} else {
for line in result.stdout.trim().lines() {
let parts: Vec<&str> = line.splitn(2, char::is_whitespace).collect();
if parts.len() == 2 {
let (status_char, filename) = (parts[0], parts[1]);
let c = status_char.chars().next().unwrap_or('?');
let status_name = match c {
'M' => "Modified",
'A' => "Added",
'D' => "Deleted",
'R' => "Renamed",
'C' => "Copied",
_ => "Changed",
};
let styled_status = match c {
'M' => style(status_char).yellow(),
'A' => style(status_char).green(),
'D' => style(status_char).red(),
'R' | 'C' => style(status_char).cyan(),
_ => style(status_char),
};
println!(" {} {} ({})", styled_status, filename, status_name);
}
}
}
} else if summary {
let result = git::git_command(
&["diff", "--stat", branch1, branch2],
Some(&repo),
true,
true,
)?;
println!("{}\n", style("Diff summary:").bold());
if result.stdout.trim().is_empty() {
println!(" {}", style("No differences found").dim());
} else {
println!("{}", result.stdout);
}
} else {
let result = git::git_command(&["diff", branch1, branch2], Some(&repo), true, true)?;
if result.stdout.trim().is_empty() {
println!("{}\n", style("No differences found").dim());
} else {
println!("{}", result.stdout);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_age_just_now() {
assert_eq!(format_age(0.0), "just now");
assert_eq!(format_age(0.001), "just now"); }
#[test]
fn test_format_age_hours() {
assert_eq!(format_age(1.0 / 24.0), "1h ago"); assert_eq!(format_age(0.5), "12h ago"); assert_eq!(format_age(0.99), "23h ago"); }
#[test]
fn test_format_age_days() {
assert_eq!(format_age(1.0), "1d ago");
assert_eq!(format_age(1.5), "1d ago");
assert_eq!(format_age(6.9), "6d ago");
}
#[test]
fn test_format_age_weeks() {
assert_eq!(format_age(7.0), "1w ago");
assert_eq!(format_age(14.0), "2w ago");
assert_eq!(format_age(29.0), "4w ago");
}
#[test]
fn test_format_age_months() {
assert_eq!(format_age(30.0), "1mo ago");
assert_eq!(format_age(60.0), "2mo ago");
assert_eq!(format_age(364.0), "12mo ago");
}
#[test]
fn test_format_age_years() {
assert_eq!(format_age(365.0), "1y ago");
assert_eq!(format_age(730.0), "2y ago");
}
#[test]
fn test_format_age_boundary_below_one_hour() {
assert_eq!(format_age(0.04), "just now"); }
#[test]
#[cfg(unix)]
fn test_get_worktree_status_busy_from_lockfile() {
use crate::operations::lockfile::LockEntry;
use std::fs;
use std::process::{Command, Stdio};
let tmp = tempfile::TempDir::new().unwrap();
let repo = tmp.path();
let wt = repo.join("wt1");
fs::create_dir_all(wt.join(".git")).unwrap();
let mut child = Command::new("sleep")
.arg("30")
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn sleep");
let foreign_pid: u32 = child.id();
let entry = LockEntry {
version: crate::operations::lockfile::LOCK_VERSION,
pid: foreign_pid,
started_at: 0,
cmd: "claude".to_string(),
};
fs::write(
wt.join(".git").join("gw-session.lock"),
serde_json::to_string(&entry).unwrap(),
)
.unwrap();
let status = get_worktree_status(&wt, repo, Some("wt1"), &PrCache::default());
let _ = child.kill();
let _ = child.wait();
assert_eq!(status, "busy");
}
#[test]
fn test_get_worktree_status_stale() {
use std::path::PathBuf;
let non_existent = PathBuf::from("/tmp/gw-test-nonexistent-12345");
let repo = PathBuf::from("/tmp");
assert_eq!(
get_worktree_status(&non_existent, &repo, None, &PrCache::default()),
"stale"
);
}
}