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_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 crate::operations::busy::active_claude_sessions(path).is_some() {
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)
}
}
pub fn format_selector_row(
branch: &str,
age: &str,
busy: bool,
path: &str,
branch_col: usize,
) -> String {
const AGE_COL: usize = 9;
const BUSY_COL: usize = 7;
let busy_cell: String = if busy {
format!("{} ", style("[busy]").yellow())
} else {
" ".repeat(BUSY_COL)
};
format!(
"{branch:<branch_col$} {age:<AGE_COL$} {busy_cell}{path}",
branch = branch,
age = age,
busy_cell = busy_cell,
path = path,
branch_col = branch_col,
AGE_COL = AGE_COL,
)
}
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,
path: std::path::PathBuf,
}
#[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,
path: self.path,
}
}
}
fn prewarm_busy_caches() {
std::thread::spawn(crate::operations::busy::prewarm_cwd_scan);
std::thread::spawn(crate::operations::claude_process::prewarm);
}
pub fn list_worktrees() -> Result<()> {
let cwd = std::env::current_dir()?;
let scope = crate::scope::discover_scope(&cwd)?;
if scope.is_empty() {
println!(" {}\n", style("No worktrees found.").dim());
return Ok(());
}
let mut groups: Vec<(std::path::PathBuf, Vec<(String, std::path::PathBuf)>)> = Vec::new();
for w in scope.worktrees() {
let key = &w.repo_root;
let entry = match groups.iter_mut().find(|(k, _)| k == key) {
Some(e) => e,
None => {
groups.push((key.clone(), Vec::new()));
groups.last_mut().unwrap()
}
};
let branch_raw = w.branch.clone().unwrap_or_else(|| "(detached)".to_string());
entry.1.push((branch_raw, w.path.clone()));
}
for (i, (repo, worktrees)) in groups.iter().enumerate() {
if i > 0 {
println!(); }
render_repo_section(repo, worktrees)?;
}
Ok(())
}
pub fn list_worktrees_tsv() -> Result<()> {
let cwd = std::env::current_dir()?;
let scope = crate::scope::discover_scope(&cwd)?;
if scope.is_empty() {
return Ok(());
}
let mut groups: Vec<(std::path::PathBuf, Vec<(String, std::path::PathBuf)>)> = Vec::new();
for w in scope.worktrees() {
let key = &w.repo_root;
let entry = match groups.iter_mut().find(|(k, _)| k == key) {
Some(e) => e,
None => {
groups.push((key.clone(), Vec::new()));
groups.last_mut().unwrap()
}
};
let branch_raw = w.branch.clone().unwrap_or_else(|| "(detached)".to_string());
entry.1.push((branch_raw, w.path.clone()));
}
for (repo, worktrees) in &groups {
let pr_cache = PrCache::load_or_fetch(repo, false);
let inputs: Vec<RowInput> = worktrees
.iter()
.map(|(branch, path)| {
let current_branch = git::normalize_branch_name(branch).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: String::new(),
}
})
.collect();
let rows: Vec<WorktreeRow> = 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();
for (row, (_, path)) in rows.iter().zip(worktrees.iter()) {
println!(
"{}\t{}\t{}\t{}\t{}\t{}",
row.worktree_id,
row.current_branch,
row.status,
row.age,
repo.display(),
path.display(),
);
}
}
Ok(())
}
fn render_repo_section(
repo: &std::path::Path,
worktrees: &[(String, std::path::PathBuf)],
) -> Result<()> {
println!(
"\n{} {}\n",
style("Worktrees for repository:").cyan().bold(),
repo.display()
);
let pr_cache = PrCache::load_or_fetch(repo, false);
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();
prewarm_busy_caches();
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_busy_details(&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))?;
let paths: Vec<std::path::PathBuf> = inputs.iter().map(|i| i.path.clone()).collect();
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()
.zip(paths)
.map(|(r, path)| WorktreeRow {
worktree_id: r.worktree_id,
current_branch: r.current_branch,
status: r.status,
age: r.age,
rel_path: r.rel_path,
path,
})
.collect())
}
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_busy_details(rows: &[WorktreeRow]) {
let busy_rows: Vec<&WorktreeRow> = rows.iter().filter(|r| r.status == "busy").collect();
if busy_rows.is_empty() {
return;
}
for row in busy_rows {
let (hard, soft) = crate::operations::busy::detect_busy_tiered(&row.path);
if hard.is_empty() && soft.is_empty() {
continue;
}
let block =
crate::operations::busy_messages::render_busy_block(&row.worktree_id, &hard, &soft);
println!();
print!("{}", block);
}
}
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(" "));
}
}
}
#[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]
fn format_selector_row_no_busy() {
let row = format_selector_row("feat/a", "2d ago", false, "feat-a", 30);
assert_eq!(
row,
"feat/a 2d ago feat-a"
);
}
#[test]
fn format_selector_row_busy_contains_badge() {
let row = format_selector_row("fix/b", "3w ago", true, "fix-b", 30);
assert!(
row.contains("[busy]"),
"expected [busy] in row, got: {:?}",
row
);
assert!(row.contains("fix/b"));
assert!(row.contains("3w ago"));
assert!(row.contains("fix-b"));
}
#[test]
fn format_selector_row_busy_visible_width_matches_no_busy() {
let plain = format_selector_row("x", "1d ago", false, "p", 30);
let busy = format_selector_row("x", "1d ago", true, "p", 30);
assert_eq!(
crate::tui::arrow_select::visible_len(&plain),
crate::tui::arrow_select::visible_len(&busy),
);
}
#[test]
fn format_selector_row_empty_age_pads_to_nine() {
let row = format_selector_row("feat/a", "", false, "feat-a", 30);
assert_eq!(&row[48..], "feat-a");
}
#[test]
fn format_selector_row_long_branch_does_not_truncate() {
let branch = "feat/extra-long-branch-name-well-past-thirty-chars";
let row = format_selector_row(branch, "1d ago", false, "p", 30);
assert!(
row.starts_with(branch),
"branch must not be truncated, got: {:?}",
row
);
}
#[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]
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn test_get_worktree_status_not_busy_when_jsonl_active_but_no_live_claude() {
use crate::operations::test_env::{env_lock, EnvGuard};
let _lock = env_lock();
let _guard = EnvGuard::capture(&["HOME"]);
let home = tempfile::TempDir::new().unwrap();
std::env::set_var("HOME", home.path());
let repo = tempfile::TempDir::new().unwrap();
let wt = repo.path().join("wt1");
std::fs::create_dir_all(wt.join(".git")).unwrap();
let wt_canon = wt.canonicalize().unwrap_or(wt.clone());
let encoded = wt_canon.to_string_lossy().replace(['/', '.'], "-");
let proj_dir = home.path().join(".claude").join("projects").join(encoded);
std::fs::create_dir_all(&proj_dir).unwrap();
let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
let line = serde_json::json!({
"timestamp": now,
"cwd": wt_canon.to_string_lossy(),
});
std::fs::write(
proj_dir.join("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee.jsonl"),
format!("{}\n", line),
)
.unwrap();
let status = get_worktree_status(&wt, repo.path(), Some("wt1"), &PrCache::default());
assert_ne!(
status, "busy",
"expected non-busy without a live claude process, got 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"
);
}
}