use serde::Serialize;
use crate::cli::{ListArgs, OutputFormat};
use crate::config::load_config;
use crate::error::Result;
use crate::git::{
get_worktrees, get_worktrees_with_details, Worktree, CHANGES_LEGEND, STATUS_LEGEND,
};
use crate::ui::widgets::{calculate_column_widths, truncate_and_pad, truncate_start, ColumnWidths};
const DEFAULT_TERMINAL_SIZE: (u16, u16) = (120, 24);
fn print_empty_worktrees_message() {
println!("\x1b[33mNo worktrees found\x1b[0m");
println!("\x1b[90mUse \x1b[36mgwm add\x1b[90m to create one\x1b[0m");
}
fn print_list_header(worktree_count: usize, base_path: &str) {
println!("\x1b[1;36mWorktrees\x1b[0m");
println!("\x1b[90mTotal: \x1b[1;37m{}\x1b[0m", worktree_count);
println!("\x1b[90m${{B}} = {}\x1b[0m", base_path);
println!();
}
const SYNC_WIDTH: usize = 8;
const CHANGES_WIDTH: usize = 10;
const ACTIVITY_WIDTH: usize = 10;
#[derive(Serialize)]
struct SyncJson {
ahead: usize,
behind: usize,
}
#[derive(Serialize)]
struct ChangesJson {
modified: usize,
added: usize,
deleted: usize,
untracked: usize,
}
#[derive(Serialize)]
struct WorktreeJson {
branch: String,
path: String,
status: String,
head: String,
#[serde(skip_serializing_if = "Option::is_none")]
sync: Option<SyncJson>,
#[serde(skip_serializing_if = "Option::is_none")]
changes: Option<ChangesJson>,
#[serde(skip_serializing_if = "Option::is_none")]
last_activity: Option<String>,
}
pub fn run_list(args: ListArgs) -> Result<()> {
match args.format {
OutputFormat::Json => run_list_json(),
OutputFormat::Names => run_list_names(),
OutputFormat::Table => {
if args.compact {
run_list_compact()
} else {
run_list_detailed()
}
}
}
}
fn run_list_detailed() -> Result<()> {
let config = load_config();
let worktrees = get_worktrees_with_details()?;
if worktrees.is_empty() {
print_empty_worktrees_message();
return Ok(());
}
let (width, _) = crossterm::terminal::size().unwrap_or(DEFAULT_TERMINAL_SIZE);
let extra_width = SYNC_WIDTH + CHANGES_WIDTH + ACTIVITY_WIDTH + 3; let adjusted_width = width.saturating_sub(extra_width as u16);
let items: Vec<(String, String)> = worktrees
.iter()
.map(|w| (w.display_branch().to_string(), w.path.display().to_string()))
.collect();
let column_widths = calculate_column_widths(&items, adjusted_width);
print_list_header(worktrees.len(), &config.worktree_base_path);
let expanded_base_path = config
.expanded_worktree_base_path()
.map(|p| p.display().to_string())
.unwrap_or_default();
println!(
"\x1b[1;36m {:<branch$} {:^SYNC_WIDTH$} {:<CHANGES_WIDTH$} {:<path$} {:<ACTIVITY_WIDTH$}\x1b[0m",
"BRANCH",
"SYNC",
"CHANGES",
"PATH",
"ACTIVITY",
branch = column_widths.branch,
path = column_widths.path,
);
println!(
"\x1b[90m {} {:^SYNC_WIDTH$} {:<CHANGES_WIDTH$} {} {:<ACTIVITY_WIDTH$}\x1b[0m",
"═".repeat(column_widths.branch),
"════",
"═══════",
"═".repeat(column_widths.path),
"════════",
);
for worktree in &worktrees {
print_worktree_row_detailed(worktree, &expanded_base_path, &column_widths);
}
println!();
println!("\x1b[90m{STATUS_LEGEND}\x1b[0m");
println!("\x1b[90m{CHANGES_LEGEND}\x1b[0m");
Ok(())
}
fn print_worktree_row_detailed(worktree: &Worktree, base_path: &str, widths: &ColumnWidths) {
let status = &worktree.status;
let branch = truncate_and_pad(worktree.display_branch(), widths.branch);
let sync_str = worktree
.sync_status
.as_ref()
.map(|s| s.display())
.unwrap_or_else(|| "-".to_string());
let sync_display = format!("{:^SYNC_WIDTH$}", sync_str);
let sync_color = if worktree.sync_status.as_ref().is_some_and(|s| s.is_synced()) {
"\x1b[32m" } else {
"\x1b[37m" };
let changes_str = worktree
.change_status
.as_ref()
.map(|c| c.display())
.unwrap_or_else(|| "-".to_string());
let changes_display = format!("{:<CHANGES_WIDTH$}", changes_str);
let changes_color = if worktree
.change_status
.as_ref()
.is_some_and(|c| c.is_clean())
{
"\x1b[32m" } else {
"\x1b[33m" };
let path_str = worktree.path.display().to_string();
let short_path = if let Some(suffix) = path_str.strip_prefix(base_path) {
let suffix = suffix.trim_start_matches('/');
format!("${{B}}/{}", suffix)
} else {
path_str
};
let path = truncate_start(&short_path, widths.path);
let activity = worktree.last_activity.as_deref().unwrap_or("-");
let activity_display = format!("{:<ACTIVITY_WIDTH$}", activity);
let branch_display = format!("{}{}\x1b[0m", status.ansi_bold_color(), branch);
println!(
"{}{}\x1b[0m {} {}{}\x1b[0m {}{}\x1b[0m \x1b[90m{}\x1b[0m \x1b[90m{}\x1b[0m",
status.ansi_color(),
status.bracketed_icon(),
branch_display,
sync_color,
sync_display,
changes_color,
changes_display,
path,
activity_display,
);
}
fn run_list_compact() -> Result<()> {
let config = load_config();
let worktrees = get_worktrees()?;
if worktrees.is_empty() {
print_empty_worktrees_message();
return Ok(());
}
let (width, _) = crossterm::terminal::size().unwrap_or(DEFAULT_TERMINAL_SIZE);
let items: Vec<(String, String)> = worktrees
.iter()
.map(|w| (w.display_branch().to_string(), w.path.display().to_string()))
.collect();
let column_widths = calculate_column_widths(&items, width);
const HEAD_HEADER_WIDTH: usize = 10;
print_list_header(worktrees.len(), &config.worktree_base_path);
let expanded_base_path = config
.expanded_worktree_base_path()
.map(|p| p.display().to_string())
.unwrap_or_default();
println!(
"\x1b[1;36m {:<branch$} {:<path$} {:<HEAD_HEADER_WIDTH$}\x1b[0m",
"BRANCH",
"DIR_PATH",
"HEAD",
branch = column_widths.branch,
path = column_widths.path,
);
println!(
"\x1b[90m {} {} {:<HEAD_HEADER_WIDTH$}\x1b[0m",
"═".repeat(column_widths.branch),
"═".repeat(column_widths.path),
"══════════",
);
for worktree in &worktrees {
print_worktree_row_compact(worktree, &expanded_base_path, &column_widths);
}
println!();
println!("\x1b[90m{STATUS_LEGEND}\x1b[0m");
println!(
"\x1b[90mUse \x1b[36mgwm go [query]\x1b[90m to navigate, \x1b[36mgwm remove\x1b[90m to delete\x1b[0m"
);
Ok(())
}
fn print_worktree_row_compact(worktree: &Worktree, base_path: &str, widths: &ColumnWidths) {
let status = &worktree.status;
let branch = truncate_and_pad(worktree.display_branch(), widths.branch);
let path_str = worktree.path.display().to_string();
let short_path = if let Some(suffix) = path_str.strip_prefix(base_path) {
let suffix = suffix.trim_start_matches('/');
format!("${{B}}/{}", suffix)
} else {
path_str
};
let path = truncate_start(&short_path, widths.path);
let head = worktree.short_head();
let branch_display = format!("{}{}\x1b[0m", status.ansi_bold_color(), branch);
println!(
"{}{}\x1b[0m {} \x1b[90m{}\x1b[0m \x1b[36m{}\x1b[0m",
status.ansi_color(),
status.bracketed_icon(),
branch_display,
path,
head,
);
}
fn run_list_json() -> Result<()> {
let worktrees = get_worktrees_with_details()?;
let json_data: Vec<WorktreeJson> = worktrees
.iter()
.map(|w| WorktreeJson {
branch: w.display_branch().to_string(),
path: w.path.display().to_string(),
status: w.status.label().to_lowercase(),
head: w.head.clone(),
sync: w.sync_status.as_ref().map(|s| SyncJson {
ahead: s.ahead,
behind: s.behind,
}),
changes: w.change_status.as_ref().map(|c| ChangesJson {
modified: c.modified,
added: c.added,
deleted: c.deleted,
untracked: c.untracked,
}),
last_activity: w.last_activity.clone(),
})
.collect();
println!("{}", serde_json::to_string_pretty(&json_data)?);
Ok(())
}
fn run_list_names() -> Result<()> {
let worktrees = get_worktrees()?;
for worktree in &worktrees {
println!("{}", worktree.display_branch());
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sync_json_construction() {
let sync = SyncJson {
ahead: 5,
behind: 3,
};
assert_eq!(sync.ahead, 5);
assert_eq!(sync.behind, 3);
}
#[test]
fn test_changes_json_construction() {
let changes = ChangesJson {
modified: 1,
added: 2,
deleted: 3,
untracked: 4,
};
assert_eq!(changes.modified, 1);
assert_eq!(changes.added, 2);
assert_eq!(changes.deleted, 3);
assert_eq!(changes.untracked, 4);
}
#[test]
fn test_worktree_json_serialization() {
let json = WorktreeJson {
branch: "feature/test".to_string(),
path: "/path/to/worktree".to_string(),
status: "other".to_string(),
head: "abc1234".to_string(),
sync: Some(SyncJson {
ahead: 2,
behind: 1,
}),
changes: Some(ChangesJson {
modified: 3,
added: 1,
deleted: 0,
untracked: 2,
}),
last_activity: Some("2d ago".to_string()),
};
let serialized = serde_json::to_string(&json).unwrap();
assert!(serialized.contains("feature/test"));
assert!(serialized.contains("ahead"));
}
#[test]
fn test_worktree_json_skip_serializing_none() {
let json = WorktreeJson {
branch: "main".to_string(),
path: "/path".to_string(),
status: "main".to_string(),
head: "def5678".to_string(),
sync: None,
changes: None,
last_activity: None,
};
let serialized = serde_json::to_string(&json).unwrap();
assert!(!serialized.contains("sync"));
assert!(!serialized.contains("changes"));
assert!(!serialized.contains("last_activity"));
}
#[test]
fn test_default_terminal_size() {
assert_eq!(DEFAULT_TERMINAL_SIZE, (120, 24));
}
#[test]
fn test_column_width_constants() {
assert_eq!(SYNC_WIDTH, 8);
assert_eq!(CHANGES_WIDTH, 10);
assert_eq!(ACTIVITY_WIDTH, 10);
}
#[test]
fn test_worktree_json_all_fields() {
let json = WorktreeJson {
branch: "feature/x".to_string(),
path: "/p".to_string(),
status: "other".to_string(),
head: "1234567".to_string(),
sync: Some(SyncJson {
ahead: 0,
behind: 0,
}),
changes: Some(ChangesJson {
modified: 0,
added: 0,
deleted: 0,
untracked: 0,
}),
last_activity: Some("just now".to_string()),
};
let serialized = serde_json::to_string(&json).unwrap();
assert!(serialized.contains("\"ahead\":0"));
assert!(serialized.contains("\"modified\":0"));
}
#[test]
fn test_sync_json_zero_values() {
let sync = SyncJson {
ahead: 0,
behind: 0,
};
assert_eq!(sync.ahead, 0);
assert_eq!(sync.behind, 0);
}
}