use std::path::{Path, PathBuf};
use anyhow::{bail, Result};
use chrono::Utc;
use clap::{Parser, Subcommand};
use serde_json::{json, Value};
use crate::cli::format::TableOrJson;
use crate::daemon::client::DaemonClient;
use crate::daemon::protocol::{DaemonEnvelope, DaemonReply};
use crate::daemon::server;
const SERVICE: &str = "worktrees";
#[derive(Parser)]
pub struct WorktreesCommand {
#[command(subcommand)]
pub command: WorktreesSubcommands,
}
#[derive(Subcommand)]
pub enum WorktreesSubcommands {
List(ListCommand),
Tree(TreeCommand),
}
impl WorktreesCommand {
pub async fn execute(self) -> Result<()> {
match self.command {
WorktreesSubcommands::List(cmd) => cmd.execute().await,
WorktreesSubcommands::Tree(cmd) => cmd.execute().await,
}
}
}
#[derive(Parser)]
pub struct ListCommand {
#[arg(long, value_name = "PATH")]
pub socket: Option<PathBuf>,
#[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
pub output: TableOrJson,
#[arg(long, hide = true)]
pub json: bool,
}
impl ListCommand {
pub async fn execute(mut self) -> Result<()> {
if self.json {
eprintln!("warning: --json is deprecated; use -o/--output json instead");
self.output = TableOrJson::Json;
}
let socket = server::resolve_socket(self.socket)?;
let result = call(&socket, "list", Value::Null).await?;
match self.output {
TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
TableOrJson::Table => println!("{}", render_windows(&result)),
}
Ok(())
}
}
#[derive(Parser)]
pub struct TreeCommand {
#[arg(long, value_name = "PATH")]
pub socket: Option<PathBuf>,
#[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
pub output: TableOrJson,
}
impl TreeCommand {
pub async fn execute(self) -> Result<()> {
let socket = server::resolve_socket(self.socket)?;
let mut result = call(&socket, "tree", Value::Null).await?;
enrich_ahead_behind(&socket, &mut result).await;
match self.output {
TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
TableOrJson::Table => println!("{}", render_tree(&result)),
}
Ok(())
}
}
async fn enrich_ahead_behind(socket: &Path, result: &mut Value) {
let paths = worktree_paths(result);
if paths.is_empty() {
return;
}
let Ok(reply) = call(socket, "ahead-behind", json!({ "paths": paths })).await else {
return;
};
if let Some(results) = reply.get("results").and_then(Value::as_object) {
merge_ahead_behind(result, results);
}
}
fn worktree_paths(result: &Value) -> Vec<String> {
let mut paths = Vec::new();
for repo in result
.get("repos")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default()
{
for worktree in repo
.get("worktrees")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default()
{
if let Some(path) = worktree.get("path").and_then(Value::as_str) {
paths.push(path.to_string());
}
}
}
paths
}
fn merge_ahead_behind(result: &mut Value, results: &serde_json::Map<String, Value>) {
for repo in result
.get_mut("repos")
.and_then(Value::as_array_mut)
.into_iter()
.flatten()
{
for worktree in repo
.get_mut("worktrees")
.and_then(Value::as_array_mut)
.into_iter()
.flatten()
{
let Some(obj) = worktree.as_object_mut() else {
continue;
};
let Some(path) = obj.get("path").and_then(Value::as_str).map(str::to_string) else {
continue;
};
let Some(counts) = results.get(&path) else {
continue;
};
if let (Some(ahead), Some(behind)) =
(counts.get("ahead").cloned(), counts.get("behind").cloned())
{
obj.insert("ahead".to_string(), ahead);
obj.insert("behind".to_string(), behind);
}
}
}
}
async fn call(socket: &Path, op: &str, payload: Value) -> Result<Value> {
let reply = DaemonClient::new(socket)
.request(DaemonEnvelope::service(SERVICE, op, payload))
.await?;
reply_payload(reply)
}
fn reply_payload(reply: DaemonReply) -> Result<Value> {
if reply.ok {
Ok(reply.payload)
} else {
bail!(
"daemon returned an error: {}",
reply.error.as_deref().unwrap_or("unknown error")
)
}
}
fn render_windows(result: &Value) -> String {
let windows = result
.get("windows")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default();
if windows.is_empty() {
return "No open windows.".to_string();
}
let mut out = format!(
"{:<22} {:<24} {:<9} {:<40} {:>5}",
"REPO", "BRANCH", "SYNC", "FOLDER", "AGE"
);
for window in windows {
let repo = sanitize(repo_name(window));
let branch = sanitize(window.get("branch").and_then(Value::as_str).unwrap_or("-"));
let sync = sync_summary(window);
let folder_disp = folder_summary(window);
let age = age_secs(window.get("last_seen").and_then(Value::as_str));
out.push_str(&format!(
"\n{repo:<22} {branch:<24} {sync:<9} {folder_disp:<40} {age:>4}s"
));
}
out
}
fn render_tree(result: &Value) -> String {
let repos = result
.get("repos")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default();
if repos.is_empty() {
return "No repositories open.".to_string();
}
let mut out = String::new();
for (i, repo) in repos.iter().enumerate() {
if i > 0 {
out.push_str("\n\n");
}
out.push_str(&repo_header(repo));
for worktree in repo
.get("worktrees")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default()
{
out.push('\n');
out.push_str(&worktree_row(worktree));
}
}
out
}
fn repo_header(repo: &Value) -> String {
let name = sanitize(repo.get("main_repo").and_then(Value::as_str).unwrap_or("-"));
let root = sanitize(repo.get("root").and_then(Value::as_str).unwrap_or(""));
match github_summary(repo) {
Some(github) => format!("{name} ({github}) {root}"),
None => format!("{name} {root}"),
}
}
fn github_summary(repo: &Value) -> Option<String> {
let owner = repo.pointer("/github/owner").and_then(Value::as_str)?;
let name = repo.pointer("/github/name").and_then(Value::as_str)?;
Some(format!("github: {}/{}", sanitize(owner), sanitize(name)))
}
fn worktree_row(worktree: &Value) -> String {
let marker = if worktree.get("is_main").and_then(Value::as_bool) == Some(true) {
'*'
} else {
' '
};
let branch = sanitize(
worktree
.get("branch")
.and_then(Value::as_str)
.unwrap_or("-"),
);
let sync = sync_summary(worktree);
let open = if worktree.get("open").and_then(Value::as_bool) == Some(true) {
"open"
} else {
""
};
let path = sanitize(worktree.get("path").and_then(Value::as_str).unwrap_or(""));
format!(" {marker} {branch:<24} {sync:<9} {open:<5} {path}")
}
fn repo_name(window: &Value) -> &str {
window
.get("main_repo")
.and_then(Value::as_str)
.or_else(|| window.get("repo").and_then(Value::as_str))
.unwrap_or("-")
}
fn sync_summary(window: &Value) -> String {
let ahead = window.get("ahead").and_then(Value::as_u64);
let behind = window.get("behind").and_then(Value::as_u64);
match (ahead, behind) {
(Some(ahead), Some(behind)) => format!("+{ahead} -{behind}"),
_ => "-".to_string(),
}
}
fn folder_summary(window: &Value) -> String {
let folders = window
.get("folders")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default();
let first = sanitize(folders.first().and_then(Value::as_str).unwrap_or(""));
let extra = folders.len().saturating_sub(1);
if extra > 0 {
format!("{first} (+{extra})")
} else {
first
}
}
fn sanitize(s: &str) -> String {
s.chars().filter(|c| !c.is_control()).collect()
}
fn age_secs(ts: Option<&str>) -> i64 {
ts.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
.map_or(0, |t| {
(Utc::now() - t.with_timezone(&Utc)).num_seconds().max(0)
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use serde_json::json;
#[derive(Parser)]
struct Wrapper {
#[command(subcommand)]
cmd: WorktreesSubcommands,
}
fn parse(args: &[&str]) -> WorktreesSubcommands {
let mut full = vec!["omni-dev"];
full.extend_from_slice(args);
Wrapper::try_parse_from(full).unwrap().cmd
}
#[test]
fn list_parses_flags_and_defaults() {
assert!(matches!(parse(&["list"]), WorktreesSubcommands::List(_)));
let cmd = ListCommand::try_parse_from(["list"]).unwrap();
assert_eq!(cmd.output, TableOrJson::Table);
assert!(!cmd.json);
assert!(cmd.socket.is_none());
let cmd =
ListCommand::try_parse_from(["list", "-o", "json", "--socket", "/tmp/d.sock"]).unwrap();
assert_eq!(cmd.output, TableOrJson::Json);
assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
}
#[test]
fn list_deprecated_json_flag_still_parses() {
let cmd = ListCommand::try_parse_from(["list", "--json"]).unwrap();
assert!(cmd.json);
assert_eq!(cmd.output, TableOrJson::Table);
}
#[test]
fn tree_parses_flags_and_defaults() {
assert!(matches!(parse(&["tree"]), WorktreesSubcommands::Tree(_)));
let cmd = TreeCommand::try_parse_from(["tree"]).unwrap();
assert_eq!(cmd.output, TableOrJson::Table);
assert!(cmd.socket.is_none());
let cmd =
TreeCommand::try_parse_from(["tree", "-o", "json", "--socket", "/tmp/d.sock"]).unwrap();
assert_eq!(cmd.output, TableOrJson::Json);
assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
}
#[test]
fn render_windows_handles_empty_replies() {
assert_eq!(
render_windows(&json!({ "windows": [] })),
"No open windows."
);
assert_eq!(render_windows(&json!({})), "No open windows.");
}
#[test]
fn render_windows_renders_rows() {
let result = json!({ "windows": [{
"key": "w1",
"repo": "omni-dev",
"branch": "issue-1011",
"ahead": 2,
"behind": 1,
"folders": ["/home/me/omni-dev", "/home/me/docs"],
"last_seen": "2000-01-01T00:00:00Z",
}]});
let table = render_windows(&result);
assert!(table.contains("omni-dev"), "{table}");
assert!(table.contains("issue-1011"), "{table}");
assert!(table.contains("+2 -1"), "{table}");
assert!(table.contains("/home/me/omni-dev (+1)"), "{table}");
assert_eq!(table.lines().count(), 2, "{table}");
}
#[test]
fn render_windows_prefers_main_repo_over_companion_repo() {
let result = json!({ "windows": [{
"key": "w1",
"repo": "issue-1250",
"main_repo": "omni-dev",
"branch": "issue-1250",
"folders": ["/home/me/worktrees/issue-1250"],
"last_seen": "2000-01-01T00:00:00Z",
}]});
let table = render_windows(&result);
assert!(table.contains("omni-dev"), "{table}");
let data_row = table.lines().nth(1).unwrap();
assert!(data_row.starts_with("omni-dev"), "{data_row}");
}
#[test]
fn repo_name_falls_back_to_companion_repo_then_dash() {
assert_eq!(
repo_name(&json!({ "main_repo": "omni-dev", "repo": "wt" })),
"omni-dev"
);
assert_eq!(repo_name(&json!({ "repo": "wt" })), "wt");
assert_eq!(repo_name(&json!({})), "-");
}
#[test]
fn render_windows_strips_control_bytes() {
let result = json!({ "windows": [{
"key": "w1",
"repo": "evil\x1b[31mrepo",
"branch": "br\ranch\x07\u{9b}2J",
"folders": ["/tmp/a\x1b]0;owned\x07\u{7f}", "/tmp/b"],
"last_seen": "2000-01-01T00:00:00Z",
}]});
let table = render_windows(&result);
assert!(
!table.contains(|c: char| c.is_control() && c != '\n'),
"{table:?}"
);
assert!(table.contains("evil[31mrepo"), "{table:?}");
assert!(table.contains("branch2J"), "{table:?}");
assert!(table.contains("/tmp/a]0;owned (+1)"), "{table:?}");
assert_eq!(table.lines().count(), 2, "{table:?}");
}
#[test]
fn sync_summary_formats_or_dashes() {
assert_eq!(sync_summary(&json!({ "ahead": 2, "behind": 1 })), "+2 -1");
assert_eq!(sync_summary(&json!({ "ahead": 0, "behind": 0 })), "+0 -0");
assert_eq!(sync_summary(&json!({ "branch": "main" })), "-");
assert_eq!(sync_summary(&json!({})), "-");
}
#[test]
fn folder_summary_strips_control_bytes() {
assert_eq!(
folder_summary(&json!({ "folders": ["/a\x1b[2J/b"] })),
"/a[2J/b"
);
}
#[test]
fn folder_summary_counts_extra_folders() {
assert_eq!(folder_summary(&json!({ "folders": [] })), "");
assert_eq!(folder_summary(&json!({ "folders": ["/a"] })), "/a");
assert_eq!(
folder_summary(&json!({ "folders": ["/a", "/b", "/c"] })),
"/a (+2)"
);
}
#[test]
fn age_secs_handles_absent_and_unparseable_and_past() {
assert_eq!(age_secs(None), 0);
assert_eq!(age_secs(Some("not-a-timestamp")), 0);
assert!(age_secs(Some("2000-01-01T00:00:00Z")) > 0);
}
#[test]
fn render_tree_handles_empty_replies() {
assert_eq!(
render_tree(&json!({ "repos": [] })),
"No repositories open."
);
assert_eq!(render_tree(&json!({})), "No repositories open.");
}
#[test]
fn worktree_paths_collects_every_worktree_in_render_order() {
let result = json!({ "repos": [
{ "worktrees": [ { "path": "/a" }, { "branch": "detached" }, { "path": "/b" } ] },
{ "worktrees": [ { "path": "/c" } ] },
]});
assert_eq!(worktree_paths(&result), vec!["/a", "/b", "/c"]);
assert!(worktree_paths(&json!({})).is_empty());
assert!(worktree_paths(&json!({ "repos": [{ "worktrees": [] }] })).is_empty());
}
#[test]
fn merge_ahead_behind_folds_counts_by_path_and_leaves_others() {
let mut result = json!({ "repos": [{ "worktrees": [
{ "path": "/a", "branch": "main" },
{ "path": "/b", "branch": "feature" },
]}]});
let results = json!({ "/a": { "ahead": 2, "behind": 1 } });
merge_ahead_behind(&mut result, results.as_object().unwrap());
let worktrees = result.pointer("/repos/0/worktrees").unwrap();
let a = &worktrees[0];
assert_eq!(a.get("ahead").and_then(Value::as_u64), Some(2));
assert_eq!(a.get("behind").and_then(Value::as_u64), Some(1));
assert_eq!(sync_summary(a), "+2 -1");
let b = &worktrees[1];
assert!(b.get("ahead").is_none(), "{b:?}");
assert!(b.get("behind").is_none(), "{b:?}");
assert_eq!(sync_summary(b), "-");
}
#[test]
fn merge_ahead_behind_skips_malformed_worktrees_and_counts() {
let mut result = json!({ "repos": [{ "worktrees": [
"not-an-object", { "branch": "detached" }, { "path": "/a", "branch": "main" }, ]}]});
let results = json!({ "/a": { "ahead": 2 } }); merge_ahead_behind(&mut result, results.as_object().unwrap());
let worktrees = result.pointer("/repos/0/worktrees").unwrap();
assert_eq!(worktrees[0], json!("not-an-object"));
assert!(worktrees[1].get("ahead").is_none(), "{:?}", worktrees[1]);
assert!(worktrees[2].get("ahead").is_none(), "{:?}", worktrees[2]);
assert!(worktrees[2].get("behind").is_none(), "{:?}", worktrees[2]);
}
#[tokio::test]
async fn enrich_ahead_behind_is_a_noop_when_there_are_no_worktrees() {
let mut result = json!({ "repos": [] });
let before = result.clone();
enrich_ahead_behind(Path::new("/nonexistent/omni-dev-ab.sock"), &mut result).await;
assert_eq!(result, before);
}
#[tokio::test]
async fn enrich_ahead_behind_leaves_the_tree_when_the_daemon_is_unreachable() {
let mut result =
json!({ "repos": [{ "worktrees": [{ "path": "/x", "branch": "main" }] }] });
enrich_ahead_behind(Path::new("/nonexistent/omni-dev-ab.sock"), &mut result).await;
let wt = result.pointer("/repos/0/worktrees/0").unwrap();
assert!(wt.get("ahead").is_none(), "{wt:?}");
assert!(wt.get("behind").is_none(), "{wt:?}");
}
fn fake_daemon_reply(
reply: Value,
) -> (tempfile::TempDir, PathBuf, tokio::task::JoinHandle<()>) {
use futures::{SinkExt, StreamExt};
use tokio::net::UnixListener;
use tokio_util::codec::{Framed, LinesCodec};
let dir = tempfile::tempdir_in("/tmp").unwrap();
let sock = dir.path().join("d.sock");
let listener = UnixListener::bind(&sock).unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
let mut framed = Framed::new(stream, LinesCodec::new());
let _req = framed.next().await.unwrap().unwrap();
framed
.send(serde_json::to_string(&reply).unwrap())
.await
.unwrap();
});
(dir, sock, server)
}
#[tokio::test]
async fn enrich_ahead_behind_folds_counts_from_a_live_socket() {
let (_dir, sock, server) = fake_daemon_reply(
json!({ "ok": true, "payload": { "results": { "/x": { "ahead": 3, "behind": 4 } } } }),
);
let mut result =
json!({ "repos": [{ "worktrees": [{ "path": "/x", "branch": "main" }] }] });
enrich_ahead_behind(&sock, &mut result).await;
server.await.unwrap();
let wt = result.pointer("/repos/0/worktrees/0").unwrap();
assert_eq!(wt.get("ahead").and_then(Value::as_u64), Some(3));
assert_eq!(wt.get("behind").and_then(Value::as_u64), Some(4));
}
#[tokio::test]
async fn enrich_ahead_behind_ignores_a_reply_without_results() {
let (_dir, sock, server) = fake_daemon_reply(json!({ "ok": true, "payload": {} }));
let mut result =
json!({ "repos": [{ "worktrees": [{ "path": "/x", "branch": "main" }] }] });
enrich_ahead_behind(&sock, &mut result).await;
server.await.unwrap();
let wt = result.pointer("/repos/0/worktrees/0").unwrap();
assert!(wt.get("ahead").is_none(), "{wt:?}");
assert!(wt.get("behind").is_none(), "{wt:?}");
}
#[test]
fn render_tree_groups_repos_and_worktrees() {
let result = json!({ "repos": [{
"main_repo": "omni-dev",
"github": { "owner": "rust-works", "name": "omni-dev" },
"root": "/home/me/omni-dev",
"worktrees": [
{ "path": "/home/me/omni-dev", "branch": "main", "ahead": 2, "behind": 0,
"is_main": true, "open": true, "window_key": "w1" },
{ "path": "/home/me/wt/issue-1300", "branch": "issue-1300", "ahead": 1, "behind": 3,
"is_main": false, "open": false },
],
}]});
let out = render_tree(&result);
let header = out.lines().next().unwrap();
assert!(header.contains("omni-dev"), "{out}");
assert!(header.contains("github: rust-works/omni-dev"), "{out}");
assert!(header.contains("/home/me/omni-dev"), "{out}");
assert!(
out.lines()
.any(|l| l.contains("* main") && l.contains("+2 -0") && l.contains("open")),
"{out}"
);
let linked = out
.lines()
.find(|l| l.contains("issue-1300"))
.unwrap_or_default();
assert!(!linked.contains('*'), "{linked}");
assert!(!linked.contains("open"), "{linked}");
assert!(linked.contains("+1 -3"), "{linked}");
assert_eq!(out.lines().count(), 3, "{out}");
}
#[test]
fn render_tree_separates_multiple_repos_with_blank_line() {
let result = json!({ "repos": [
{
"main_repo": "alpha",
"root": "/r/alpha",
"worktrees": [
{ "path": "/r/alpha", "branch": "main", "is_main": true, "open": false },
],
},
{
"main_repo": "beta",
"root": "/r/beta",
"worktrees": [
{ "path": "/r/beta", "branch": "main", "is_main": true, "open": false },
],
},
]});
let out = render_tree(&result);
assert!(
out.contains("\n\nbeta"),
"repos not blank-separated: {out:?}"
);
let alpha = out.find("alpha").unwrap();
let beta = out.find("beta").unwrap();
assert!(alpha < beta, "repo order not preserved: {out}");
assert_eq!(out.lines().count(), 5, "{out:?}");
}
#[test]
fn render_tree_omits_github_for_non_github_repo() {
let result = json!({ "repos": [{
"main_repo": "internal",
"root": "/srv/internal",
"worktrees": [
{ "path": "/srv/internal", "branch": "main", "is_main": true, "open": false },
],
}]});
let out = render_tree(&result);
assert!(!out.contains("github:"), "{out}");
assert!(out.lines().next().unwrap().contains("internal"), "{out}");
}
#[test]
fn render_tree_strips_control_bytes() {
let result = json!({ "repos": [{
"main_repo": "evil\x1b[31mrepo",
"github": { "owner": "ow\x07ner", "name": "na\u{9b}2Jme" },
"root": "/tmp/r\x1b]0;x\x07oot",
"worktrees": [
{ "path": "/tmp/w\rt", "branch": "br\x1b[2Janch", "is_main": true, "open": true },
],
}]});
let out = render_tree(&result);
assert!(
!out.contains(|c: char| c.is_control() && c != '\n'),
"{out:?}"
);
assert_eq!(out.lines().count(), 2, "{out:?}");
}
#[test]
fn github_summary_needs_both_owner_and_name() {
assert_eq!(
github_summary(&json!({ "github": { "owner": "o", "name": "n" } })).as_deref(),
Some("github: o/n")
);
assert_eq!(github_summary(&json!({ "github": { "owner": "o" } })), None);
assert_eq!(github_summary(&json!({})), None);
}
#[test]
fn reply_payload_unwraps_ok_and_maps_errors() {
assert_eq!(
reply_payload(DaemonReply::ok(json!({ "a": 1 }))).unwrap(),
json!({ "a": 1 })
);
let err = reply_payload(DaemonReply::err("boom")).unwrap_err();
assert!(err.to_string().contains("boom"), "{err}");
let err = reply_payload(DaemonReply {
ok: false,
payload: Value::Null,
error: None,
})
.unwrap_err();
assert!(err.to_string().contains("unknown error"), "{err}");
}
}