use std::borrow::Cow;
use std::collections::BTreeSet;
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use crate::config;
use crate::config::MuxMode;
use crate::multiplexer::{AgentStatus, create_backend, detect_backend};
use crate::util::format_compact_age;
use crate::workflow::types::AgentStatusSummary;
use crate::{git, nerdfont, workflow};
use anyhow::Result;
use pathdiff::diff_paths;
use serde::Serialize;
use tabled::{
Table, Tabled,
settings::{Padding, Style, disable::Remove, location::ByColumnName, object::Columns},
};
struct WorktreeRow {
project: String,
branch: String,
age: String,
pr_status: String,
agent_status: String,
mux_status: String,
unmerged_status: String,
path_str: String,
}
impl Tabled for WorktreeRow {
const LENGTH: usize = 8;
fn fields(&self) -> Vec<Cow<'_, str>> {
vec![
Cow::Borrowed(&self.project),
Cow::Borrowed(&self.branch),
Cow::Borrowed(&self.age),
Cow::Borrowed(&self.pr_status),
Cow::Borrowed(&self.agent_status),
Cow::Borrowed(&self.mux_status),
Cow::Borrowed(&self.unmerged_status),
Cow::Borrowed(&self.path_str),
]
}
fn headers() -> Vec<Cow<'static, str>> {
vec![
Cow::Borrowed("PROJECT"),
Cow::Borrowed("BRANCH"),
Cow::Borrowed("AGE"),
Cow::Borrowed("PR"),
Cow::Borrowed("AGENT"),
Cow::Borrowed("MUX"),
Cow::Borrowed("UNMERGED"),
Cow::Borrowed("PATH"),
]
}
}
fn format_pr_status(pr_info: Option<crate::github::PrSummary>) -> String {
pr_info
.map(|pr| {
let icons = nerdfont::pr_icons();
let (icon, color) = match pr.state.as_str() {
"OPEN" if pr.is_draft => (icons.draft, "\x1b[90m"), "OPEN" => (icons.open, "\x1b[32m"), "MERGED" => (icons.merged, "\x1b[35m"), "CLOSED" => (icons.closed, "\x1b[31m"), _ => (icons.open, "\x1b[32m"),
};
format!("#{} {}{}\x1b[0m", pr.number, color, icon)
})
.unwrap_or_else(|| "-".to_string())
}
fn format_status_label(status: AgentStatus, config: &config::Config, use_icons: bool) -> String {
if use_icons {
match status {
AgentStatus::Working => config.status_icons.working().to_string(),
AgentStatus::Waiting => config.status_icons.waiting().to_string(),
AgentStatus::Done => config.status_icons.done().to_string(),
}
} else {
match status {
AgentStatus::Working => "working".to_string(),
AgentStatus::Waiting => "waiting".to_string(),
AgentStatus::Done => "done".to_string(),
}
}
}
fn format_agent_status(
summary: Option<&AgentStatusSummary>,
config: &config::Config,
use_icons: bool,
) -> String {
let summary = match summary {
Some(s) if !s.statuses.is_empty() => s,
_ => return "-".to_string(),
};
let total = summary.statuses.len();
if total == 1 {
format_status_label(summary.statuses[0], config, use_icons)
} else {
let working = summary
.statuses
.iter()
.filter(|s| matches!(s, AgentStatus::Working))
.count();
let waiting = summary
.statuses
.iter()
.filter(|s| matches!(s, AgentStatus::Waiting))
.count();
let done = summary
.statuses
.iter()
.filter(|s| matches!(s, AgentStatus::Done))
.count();
let mut parts = Vec::new();
if working > 0 {
let label = format_status_label(AgentStatus::Working, config, use_icons);
parts.push(format!("{}{}", working, label));
}
if waiting > 0 {
let label = format_status_label(AgentStatus::Waiting, config, use_icons);
parts.push(format!("{}{}", waiting, label));
}
if done > 0 {
let label = format_status_label(AgentStatus::Done, config, use_icons);
parts.push(format!("{}{}", done, label));
}
parts.join(" ")
}
}
#[derive(Serialize)]
struct JsonWorktree {
project: String,
project_path: String,
handle: String,
branch: String,
path: String,
is_main: bool,
mode: String,
has_uncommitted_changes: bool,
is_open: bool,
agent_statuses: Vec<AgentStatus>,
created_at: Option<u64>,
}
struct ProjectWorktree {
project: String,
project_path: PathBuf,
worktree: workflow::types::WorktreeInfo,
}
fn project_name(root: &Path) -> String {
root.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| root.to_string_lossy().into_owned())
}
fn discover_project_roots(mux: &dyn crate::multiplexer::Multiplexer) -> Result<Vec<PathBuf>> {
let agents = crate::state::StateStore::new()?.load_reconciled_agents(mux)?;
let roots: BTreeSet<PathBuf> = agents
.iter()
.filter_map(|agent| git::get_main_worktree_root_in(Some(&agent.path)).ok())
.map(|root| crate::util::canon_or_self(&root))
.collect();
Ok(roots.into_iter().collect())
}
fn list_project(
root: &Path,
mux: &dyn crate::multiplexer::Multiplexer,
show_pr: bool,
filter: &[String],
) -> Result<Vec<ProjectWorktree>> {
let (config, _) = config::Config::load_with_location_from(root, None)?;
let project = project_name(root);
let worktrees = workflow::list_in(&config, mux, show_pr, filter, Some(root))?;
Ok(worktrees
.into_iter()
.map(|worktree| ProjectWorktree {
project: project.clone(),
project_path: root.to_path_buf(),
worktree,
})
.collect())
}
pub fn run(show_pr: bool, json: bool, all: bool, filter: &[String]) -> Result<()> {
let display_config = config::Config::load(None)?;
let mux = create_backend(detect_backend());
let mut worktrees = Vec::new();
if all {
for root in discover_project_roots(mux.as_ref())? {
worktrees.extend(list_project(&root, mux.as_ref(), show_pr && !json, filter)?);
}
} else {
let root = git::get_main_worktree_root()?;
let project = project_name(&root);
worktrees.extend(
workflow::list(&display_config, mux.as_ref(), show_pr && !json, filter)?
.into_iter()
.map(|worktree| ProjectWorktree {
project: project.clone(),
project_path: root.clone(),
worktree,
}),
);
}
if worktrees.is_empty() {
if json {
println!("[]");
} else {
println!("No worktrees found");
}
return Ok(());
}
if json {
let entries: Vec<JsonWorktree> = worktrees
.into_iter()
.map(|entry| {
let wt = entry.worktree;
JsonWorktree {
project: entry.project,
project_path: entry.project_path.to_string_lossy().into_owned(),
handle: wt.handle,
branch: wt.branch,
path: wt.path.to_string_lossy().to_string(),
is_main: wt.is_main,
mode: match wt.mode {
MuxMode::Window => "window".to_string(),
MuxMode::Session => "session".to_string(),
},
has_uncommitted_changes: git::has_uncommitted_changes(&wt.path).unwrap_or(true),
is_open: wt.has_mux_window,
agent_statuses: wt
.agent_status
.map(|summary| summary.statuses)
.unwrap_or_default(),
created_at: wt.created_at,
}
})
.collect();
println!("{}", serde_json::to_string(&entries)?);
return Ok(());
}
let use_icons = std::io::stdout().is_terminal();
let current_dir = std::env::current_dir()?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let display_data: Vec<WorktreeRow> = worktrees
.into_iter()
.map(|entry| {
let wt = entry.worktree;
let path_str = diff_paths(&wt.path, ¤t_dir)
.map(|p| {
let s = p.display().to_string();
if s.is_empty() || s == "." {
"(here)".to_string()
} else {
s
}
})
.unwrap_or_else(|| wt.path.display().to_string());
let age = if wt.is_main {
"-".to_string()
} else {
wt.created_at
.map(|ts| format_compact_age(now.saturating_sub(ts)))
.unwrap_or_else(|| "-".to_string())
};
WorktreeRow {
project: entry.project,
branch: wt.branch,
age,
pr_status: format_pr_status(wt.pr_info),
agent_status: format_agent_status(
wt.agent_status.as_ref(),
&display_config,
use_icons,
),
mux_status: if wt.has_mux_window {
"✓".to_string()
} else {
"-".to_string()
},
unmerged_status: if wt.has_unmerged {
"●".to_string()
} else {
"-".to_string()
},
path_str,
}
})
.collect();
let mut table = Table::new(display_data);
table
.with(Style::blank())
.modify(Columns::new(0..8), Padding::new(0, 1, 0, 0));
if !all {
table.with(Remove::column(ByColumnName::new("PROJECT")));
}
if !show_pr {
table.with(Remove::column(ByColumnName::new("PR")));
}
println!("{table}");
Ok(())
}