use std::path::{Path, PathBuf};
use anyhow::{bail, Context, 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;
use crate::git::worktree_rebase::{
self, FetchOutcome, RebaseOptions, RebaseResult, Selection, SkipReason, WorktreeOutcome,
};
const SERVICE: &str = "worktrees";
#[derive(Parser)]
pub struct WorktreesCommand {
#[command(subcommand)]
pub command: WorktreesSubcommands,
}
#[derive(Subcommand)]
pub enum WorktreesSubcommands {
List(ListCommand),
Tree(TreeCommand),
Focus(FocusCommand),
Close(CloseCommand),
Rebase(RebaseCommand),
MergeQueue(MergeQueueCommand),
Reposition(RepositionCommand),
Reload(ReloadCommand),
ShowClosed(ShowClosedCommand),
Register(RegisterCommand),
Heartbeat(HeartbeatCommand),
Unregister(UnregisterCommand),
}
impl WorktreesCommand {
pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
match self.command {
WorktreesSubcommands::List(cmd) => cmd.execute().await,
WorktreesSubcommands::Tree(cmd) => cmd.execute().await,
WorktreesSubcommands::Focus(cmd) => cmd.execute().await,
WorktreesSubcommands::Close(cmd) => cmd.execute().await,
WorktreesSubcommands::Rebase(cmd) => cmd.execute(repo).await,
WorktreesSubcommands::MergeQueue(cmd) => cmd.execute().await,
WorktreesSubcommands::Reposition(cmd) => cmd.execute().await,
WorktreesSubcommands::Reload(cmd) => cmd.execute().await,
WorktreesSubcommands::ShowClosed(cmd) => cmd.execute().await,
WorktreesSubcommands::Register(cmd) => cmd.execute().await,
WorktreesSubcommands::Heartbeat(cmd) => cmd.execute().await,
WorktreesSubcommands::Unregister(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,
#[arg(short = 'f', long)]
pub follow: bool,
}
impl TreeCommand {
pub async fn execute(self) -> Result<()> {
let socket = server::resolve_socket(self.socket)?;
if self.follow {
return follow_tree_stream(&socket, self.output).await;
}
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 follow_tree_stream(socket: &Path, output: TableOrJson) -> Result<()> {
let mut sub = DaemonClient::new(socket)
.subscribe(DaemonEnvelope::service(SERVICE, "subscribe", Value::Null))
.await?;
loop {
tokio::select! {
frame = sub.next() => {
let Some(frame) = frame else { break };
let mut payload = reply_payload(frame?)?;
enrich_ahead_behind(socket, &mut payload).await;
match output {
TableOrJson::Json => println!("{}", serde_json::to_string(&payload)?),
TableOrJson::Table => println!("{}", render_tree(&payload)),
}
}
_ = tokio::signal::ctrl_c() => break,
}
}
Ok(())
}
#[derive(Parser)]
pub struct FocusCommand {
#[arg(value_name = "PATH")]
pub path: PathBuf,
#[arg(long, value_name = "PATH")]
pub socket: Option<PathBuf>,
}
impl FocusCommand {
pub async fn execute(self) -> Result<()> {
let path = std::fs::canonicalize(&self.path)
.with_context(|| format!("cannot resolve worktree path: {}", self.path.display()))?;
let socket = server::resolve_socket(self.socket)?;
call(&socket, "open", json!({ "path": path.to_string_lossy() })).await?;
println!("Focused {}", path.display());
Ok(())
}
}
#[derive(Parser)]
pub struct CloseCommand {
#[arg(value_name = "PATH")]
pub path: PathBuf,
#[arg(long)]
pub window_only: bool,
#[arg(long)]
pub dry_run: bool,
#[arg(short = 'y', long)]
pub yes: bool,
#[arg(long, value_name = "PATH")]
pub socket: Option<PathBuf>,
}
impl CloseCommand {
pub async fn execute(self) -> Result<()> {
self.execute_with(confirm_removal).await
}
async fn execute_with<F, Fut>(self, confirm: F) -> Result<()>
where
F: FnOnce(bool) -> Fut,
Fut: std::future::Future<Output = bool>,
{
let path = std::fs::canonicalize(&self.path)
.with_context(|| format!("cannot resolve worktree path: {}", self.path.display()))?;
let path_str = path.to_string_lossy().to_string();
let socket = server::resolve_socket(self.socket)?;
if self.window_only {
if self.dry_run {
println!(
"Would close the window for {} (dry run; nothing closed)",
path.display()
);
return Ok(());
}
call(
&socket,
"close",
json!({ "path": path_str, "remove": false }),
)
.await?;
println!("Closed the window for {}", path.display());
return Ok(());
}
let report = call(
&socket,
"close",
json!({ "path": path_str, "remove": true }),
)
.await?;
println!("{}", render_safety_report(&path, &report));
if self.dry_run {
return Ok(());
}
if report.get("removable").and_then(Value::as_bool) != Some(true) {
bail!(
"{} is not a removable worktree (nothing deleted); \
use --window-only to just close its window",
path.display()
);
}
let has_risks = report
.get("risks")
.and_then(Value::as_array)
.is_some_and(|r| !r.is_empty());
if !self.yes && !confirm(has_risks).await {
println!("Aborted; nothing was deleted.");
return Ok(());
}
call(
&socket,
"close",
json!({ "path": path_str, "remove": true, "confirmed": true }),
)
.await?;
println!("Deleted worktree {}", path.display());
Ok(())
}
}
#[derive(Parser)]
pub struct RebaseCommand {
#[arg(value_name = "PATH")]
pub paths: Vec<PathBuf>,
#[arg(long)]
pub all: bool,
#[arg(long, value_name = "REF")]
pub onto: Option<String>,
#[arg(long)]
pub autostash: bool,
#[arg(long)]
pub dry_run: bool,
#[arg(long)]
pub keep_conflicts: bool,
#[arg(short = 'y', long)]
pub yes: bool,
#[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
pub output: TableOrJson,
}
impl RebaseCommand {
pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
self.execute_with(repo, confirm_rebase).await
}
async fn execute_with<F, Fut>(self, repo: Option<&Path>, confirm: F) -> Result<()>
where
F: FnOnce(usize) -> Fut,
Fut: std::future::Future<Output = bool>,
{
let selection = self.selection(repo)?;
let opts = RebaseOptions {
onto: self.onto.clone(),
autostash: self.autostash,
dry_run: self.dry_run,
keep_conflicts: self.keep_conflicts,
git_bin: None,
};
let plan_opts = opts.clone();
let plan =
tokio::task::spawn_blocking(move || worktree_rebase::plan(&selection, &plan_opts))
.await
.context("rebase planning task panicked")??;
let json = matches!(self.output, TableOrJson::Json);
if self.dry_run || !plan.has_pending_rebases() {
self.print(json, &plan.fetches, &plan.worktrees)?;
return Ok(());
}
if !json {
println!("{}", render_fetches(&plan.fetches));
println!("{}", render_outcomes(&plan.worktrees));
}
let pending = plan.worktrees.iter().filter(|w| is_pending(w)).count();
if !self.yes && !confirm(pending).await {
println!("Aborted; no worktree was rebased.");
return Ok(());
}
let fetches = plan.fetches.clone();
let outcomes = tokio::task::spawn_blocking(move || worktree_rebase::execute(plan, &opts))
.await
.context("rebase task panicked")?;
if !json {
println!();
}
self.print(json, &fetches, &outcomes)
}
fn selection(&self, repo: Option<&Path>) -> Result<Selection> {
let base = repo.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
if self.all {
if !self.paths.is_empty() {
bail!("pass either <PATH>... or --all, not both");
}
return Ok(Selection::All { base });
}
if self.paths.is_empty() {
bail!(
"specify one or more <PATH> arguments, or --all to rebase \
every linked worktree of this repository"
);
}
let paths = self
.paths
.iter()
.map(|path| {
if path.is_absolute() {
path.clone()
} else {
base.join(path)
}
})
.collect();
Ok(Selection::Paths(paths))
}
fn print(
&self,
json: bool,
fetches: &[FetchOutcome],
outcomes: &[WorktreeOutcome],
) -> Result<()> {
if json {
let value = json!({
"dry_run": self.dry_run,
"fetches": fetches,
"worktrees": outcomes,
});
println!("{}", serde_json::to_string_pretty(&value)?);
} else {
println!("{}", render_fetches(fetches));
println!("{}", render_outcomes(outcomes));
}
Ok(())
}
}
fn is_pending(outcome: &WorktreeOutcome) -> bool {
matches!(outcome.result, RebaseResult::WouldRebase { .. })
}
fn render_fetches(fetches: &[FetchOutcome]) -> String {
if fetches.is_empty() {
return "No repository selected.".to_string();
}
fetches
.iter()
.map(fetch_line)
.collect::<Vec<_>>()
.join("\n")
}
fn fetch_line(fetch: &FetchOutcome) -> String {
let root = sanitize(&fetch.repo_root.display().to_string());
let onto = sanitize(&fetch.onto);
if !fetch.fetched {
return format!("Using {onto} in {root} (local ref; nothing fetched)");
}
if fetch.ok {
format!("Fetched {onto} once for {root}")
} else {
let detail = brief(fetch.detail.as_deref().unwrap_or(""));
format!("Fetch of {onto} FAILED for {root}: {detail}")
}
}
fn render_outcomes(outcomes: &[WorktreeOutcome]) -> String {
if outcomes.is_empty() {
return "No worktrees selected.".to_string();
}
let mut out = format!(
"{:<12} {:<24} {:<16} {}",
"STATUS", "BRANCH", "ONTO", "WORKTREE"
);
for outcome in outcomes {
out.push('\n');
out.push_str(&outcome_row(outcome));
}
out
}
fn outcome_row(outcome: &WorktreeOutcome) -> String {
let (status, detail) = status_and_detail(&outcome.result);
let branch = sanitize(outcome.branch.as_deref().unwrap_or("-"));
let onto = sanitize(&outcome.onto);
let path = sanitize(&outcome.path.display().to_string());
let suffix = if detail.is_empty() {
String::new()
} else {
format!(" ({detail})")
};
format!("{status:<12} {branch:<24} {onto:<16} {path}{suffix}")
}
fn status_and_detail(result: &RebaseResult) -> (&'static str, String) {
match result {
RebaseResult::Rebased { behind } => ("rebased", format!("was {behind} behind")),
RebaseResult::WouldRebase { behind } => ("would-rebase", format!("{behind} behind")),
RebaseResult::UpToDate => ("up-to-date", String::new()),
RebaseResult::Skipped { reason } => ("skipped", skip_reason_text(*reason).to_string()),
RebaseResult::Conflict {
detail,
left_in_place: true,
} => (
"conflict",
format!(
"left in place; resolve then `git rebase --continue`: {}",
brief(detail)
),
),
RebaseResult::Conflict { detail, .. } => ("conflict", brief(detail)),
RebaseResult::FetchFailed { detail } => ("fetch-failed", brief(detail)),
}
}
fn skip_reason_text(reason: SkipReason) -> &'static str {
match reason {
SkipReason::MainWorkingTree => "main working tree",
SkipReason::DetachedHead => "detached HEAD",
SkipReason::Dirty => "uncommitted changes; pass --autostash",
SkipReason::OperationInProgress => "a rebase/merge is already in progress",
SkipReason::NotAWorktree => "not a git worktree",
SkipReason::NoOntoRef => "could not resolve the target ref",
}
}
fn brief(detail: &str) -> String {
let first = detail
.lines()
.find(|line| !line.trim().is_empty())
.unwrap_or("");
let clean = sanitize(first.trim());
if clean.chars().count() > 100 {
let truncated: String = clean.chars().take(97).collect();
format!("{truncated}...")
} else {
clean
}
}
async fn confirm_rebase(pending: usize) -> bool {
confirm_rebase_with(pending, read_stdin_line()).await
}
async fn confirm_rebase_with(
pending: usize,
read: impl std::future::Future<Output = Option<String>>,
) -> bool {
use std::io::Write;
eprint!("{}", rebase_prompt(pending));
let _ = std::io::stderr().flush();
read.await.as_deref().is_some_and(answer_is_yes)
}
fn rebase_prompt(pending: usize) -> String {
let noun = if pending == 1 {
"worktree"
} else {
"worktrees"
};
format!("Rebase {pending} {noun} (this rewrites branch history)? [y/N] ")
}
#[derive(Parser)]
pub struct MergeQueueCommand {
#[arg(value_name = "PATH", required = true)]
pub paths: Vec<PathBuf>,
#[arg(long)]
pub check: bool,
#[arg(short = 'y', long)]
pub yes: bool,
#[arg(long, value_name = "PATH")]
pub socket: Option<PathBuf>,
}
impl MergeQueueCommand {
pub async fn execute(self) -> Result<()> {
self.execute_with(confirm_enqueue).await
}
async fn execute_with<F, Fut>(self, confirm: F) -> Result<()>
where
F: FnOnce(usize) -> Fut,
Fut: std::future::Future<Output = bool>,
{
let mut paths = Vec::with_capacity(self.paths.len());
for p in &self.paths {
let abs = std::fs::canonicalize(p)
.with_context(|| format!("cannot resolve worktree path: {}", p.display()))?;
paths.push(abs.to_string_lossy().to_string());
}
let socket = server::resolve_socket(self.socket)?;
let report = call(
&socket,
"merge-queue",
json!({ "paths": paths, "check": true }),
)
.await?;
println!("{}", render_eligibility_report(&report));
if self.check {
return Ok(());
}
let eligible = report
.get("eligible")
.and_then(Value::as_array)
.map_or(0, Vec::len);
if eligible == 0 {
println!("Nothing to enqueue.");
return Ok(());
}
if !self.yes && !confirm(eligible).await {
println!("Aborted; nothing was enqueued.");
return Ok(());
}
let result = call(
&socket,
"merge-queue",
json!({ "paths": paths, "confirmed": true }),
)
.await?;
println!("{}", render_enqueue_result(&result));
Ok(())
}
}
#[derive(Parser)]
pub struct RepositionCommand {
#[arg(value_name = "PATH")]
pub paths: Vec<PathBuf>,
#[arg(
long,
value_name = "PATH",
required_unless_present = "undo",
conflicts_with = "undo"
)]
pub reference: Option<PathBuf>,
#[arg(long, conflicts_with = "undo")]
pub dry_run: bool,
#[arg(long)]
pub undo: bool,
#[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
pub output: TableOrJson,
#[arg(long, value_name = "PATH")]
pub socket: Option<PathBuf>,
}
impl RepositionCommand {
pub async fn execute(self) -> Result<()> {
let output = self.output;
let socket = server::resolve_socket(self.socket)?;
if self.undo {
let reply = call(&socket, "reposition-undo", Value::Null).await?;
return print_reposition(output, &reply);
}
let Some(reference) = self.reference.as_deref() else {
bail!("`reposition` requires `--reference <PATH>`");
};
let windows = call(&socket, "list", Value::Null).await?;
let reference_key = window_key_for(&windows, reference, "repositioned")?;
let mut target_keys = Vec::with_capacity(self.paths.len());
for path in &self.paths {
target_keys.push(window_key_for(&windows, path, "repositioned")?);
}
let reply = call(
&socket,
"reposition",
json!({
"reference_key": reference_key,
"target_keys": target_keys,
"check": self.dry_run,
}),
)
.await?;
print_reposition(output, &reply)
}
}
fn print_reposition(output: TableOrJson, reply: &Value) -> Result<()> {
match output {
TableOrJson::Json => println!("{}", serde_json::to_string_pretty(reply)?),
TableOrJson::Table => println!("{}", render_reposition(reply)),
}
Ok(())
}
#[derive(Parser)]
pub struct ReloadCommand {
#[arg(value_name = "PATH", required = true)]
pub paths: Vec<PathBuf>,
#[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
pub output: TableOrJson,
#[arg(long, value_name = "PATH")]
pub socket: Option<PathBuf>,
}
impl ReloadCommand {
pub async fn execute(self) -> Result<()> {
let socket = server::resolve_socket(self.socket)?;
let windows = call(&socket, "list", Value::Null).await?;
let mut target_keys = Vec::with_capacity(self.paths.len());
for path in &self.paths {
target_keys.push(window_key_for(&windows, path, "reloaded")?);
}
let reply = call(&socket, "reload", json!({ "target_keys": target_keys })).await?;
match self.output {
TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&reply)?),
TableOrJson::Table => println!("{}", render_reload(&reply)),
}
Ok(())
}
}
fn render_reload(reply: &Value) -> String {
let requested = reply.get("requested").and_then(Value::as_u64).unwrap_or(0);
let signalled = reply.get("signalled").and_then(Value::as_u64).unwrap_or(0);
let unknown: Vec<String> = reply
.get("unknown")
.and_then(Value::as_array)
.map(|keys| {
keys.iter()
.filter_map(Value::as_str)
.map(sanitize)
.collect()
})
.unwrap_or_default();
let noun = if requested == 1 { "window" } else { "windows" };
let mut out = format!("Signalled {signalled} of {requested} {noun} to reload.");
if !unknown.is_empty() {
out.push_str(&format!(
"\nNo longer open, so not signalled: {}",
unknown.join(", ")
));
}
out
}
fn window_key_for(windows: &Value, path: &Path, verb: &str) -> Result<String> {
let wanted = std::fs::canonicalize(path)
.with_context(|| format!("cannot resolve worktree path: {}", path.display()))?;
windows
.get("windows")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default()
.iter()
.find(|window| {
window
.get("folders")
.and_then(Value::as_array)
.is_some_and(|folders| {
folders.iter().filter_map(Value::as_str).any(|folder| {
std::fs::canonicalize(folder).is_ok_and(|folder| folder == wanted)
})
})
})
.and_then(|window| window.get("key").and_then(Value::as_str))
.map(ToString::to_string)
.ok_or_else(|| {
anyhow::anyhow!(
"no VS Code window has {} open (only open windows can be {verb})",
wanted.display()
)
})
}
fn render_reposition(reply: &Value) -> String {
if reply.get("trusted").and_then(Value::as_bool) == Some(false) {
return "omni-dev does not hold the macOS Accessibility permission, so no window \
was touched.\nGrant it in System Settings → Privacy & Security → \
Accessibility (add the omni-dev binary), then run `omni-dev daemon restart`."
.to_string();
}
if let Some(blocked) = reply.get("blocked") {
let reason = sanitize(blocked.get("reason").and_then(Value::as_str).unwrap_or("-"));
let detail = sanitize(blocked.get("detail").and_then(Value::as_str).unwrap_or(""));
return format!("Nothing was moved [{reason}]: {detail}");
}
let moved = reply.get("moved").and_then(Value::as_u64).unwrap_or(0);
let skipped = reply.get("skipped").and_then(Value::as_u64).unwrap_or(0);
let mut out = String::new();
if let Some(reference) = reply.get("reference") {
let title = sanitize(
reference
.get("title")
.and_then(Value::as_str)
.unwrap_or("-"),
);
out.push_str(&format!(
"Reference: {title} {}\n",
render_frame(reference.get("frame"))
));
}
out.push_str(&format!("Moved: {moved} / Skipped: {skipped}"));
let results = reply
.get("results")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default();
for result in results {
let outcome = sanitize(result.get("outcome").and_then(Value::as_str).unwrap_or("-"));
let title = sanitize(
result
.get("title")
.and_then(Value::as_str)
.or_else(|| result.get("key").and_then(Value::as_str))
.unwrap_or("-"),
);
let detail = sanitize(result.get("detail").and_then(Value::as_str).unwrap_or(""));
out.push_str(&format!("\n {outcome}: {title} — {detail}"));
}
if results.is_empty() {
out.push_str("\n (nothing to report)");
}
out
}
fn render_frame(frame: Option<&Value>) -> String {
let Some(frame) = frame else {
return "-".to_string();
};
let field = |name: &str| {
frame
.get(name)
.and_then(Value::as_f64)
.unwrap_or(0.0)
.round()
};
format!(
"{}×{} at ({}, {})",
field("width"),
field("height"),
field("x"),
field("y")
)
}
#[derive(Parser)]
pub struct ShowClosedCommand {
#[arg(value_name = "BOOL", value_parser = clap::builder::BoolishValueParser::new())]
pub value: Option<bool>,
#[arg(long, value_name = "PATH")]
pub socket: Option<PathBuf>,
}
impl ShowClosedCommand {
pub async fn execute(self) -> Result<()> {
let socket = server::resolve_socket(self.socket)?;
if let Some(show_closed) = self.value {
call(
&socket,
"set-show-closed",
json!({ "show_closed": show_closed }),
)
.await?;
println!("show-closed: {show_closed}");
} else {
let tree = call(&socket, "tree", Value::Null).await?;
let current = tree
.get("show_closed")
.and_then(Value::as_bool)
.unwrap_or(true);
println!("show-closed: {current}");
}
Ok(())
}
}
#[derive(Parser)]
pub struct RegisterCommand {
#[arg(long, value_name = "KEY")]
pub key: String,
#[arg(long = "folder", value_name = "PATH")]
pub folders: Vec<PathBuf>,
#[arg(long, value_name = "REPO")]
pub repo_name: Option<String>,
#[arg(long, value_name = "TITLE")]
pub title: Option<String>,
#[arg(long, value_name = "PID")]
pub pid: Option<u32>,
#[arg(long, value_name = "PATH")]
pub socket: Option<PathBuf>,
}
impl RegisterCommand {
pub async fn execute(self) -> Result<()> {
let socket = server::resolve_socket(self.socket)?;
let payload = json!({
"key": self.key,
"folders": self.folders,
"repo": self.repo_name,
"title": self.title,
"pid": self.pid,
});
call(&socket, "register", payload).await?;
println!("Registered {}", self.key);
Ok(())
}
}
#[derive(Parser)]
pub struct HeartbeatCommand {
#[arg(long, value_name = "KEY")]
pub key: String,
#[arg(long, value_name = "PATH")]
pub socket: Option<PathBuf>,
}
impl HeartbeatCommand {
pub async fn execute(self) -> Result<()> {
let socket = server::resolve_socket(self.socket)?;
let reply = call(&socket, "heartbeat", json!({ "key": self.key })).await?;
let known = reply.get("known").and_then(Value::as_bool).unwrap_or(false);
let close = reply.get("close").and_then(Value::as_bool).unwrap_or(false);
let reload = reply
.get("reload")
.and_then(Value::as_bool)
.unwrap_or(false);
println!("known: {known}");
println!("close: {close}");
println!("reload: {reload}");
Ok(())
}
}
#[derive(Parser)]
pub struct UnregisterCommand {
#[arg(long, value_name = "KEY")]
pub key: String,
#[arg(long, value_name = "PATH")]
pub socket: Option<PathBuf>,
}
impl UnregisterCommand {
pub async fn execute(self) -> Result<()> {
let socket = server::resolve_socket(self.socket)?;
let reply = call(&socket, "unregister", json!({ "key": self.key })).await?;
let removed = reply
.get("removed")
.and_then(Value::as_bool)
.unwrap_or(false);
println!("removed: {removed}");
Ok(())
}
}
fn render_safety_report(path: &Path, report: &Value) -> String {
let removable = report
.get("removable")
.and_then(Value::as_bool)
.unwrap_or(false);
let is_main = report
.get("is_main")
.and_then(Value::as_bool)
.unwrap_or(false);
let open = report.get("open").and_then(Value::as_bool).unwrap_or(false);
let mut out = format!("Worktree: {}", path.display());
out.push_str(&format!("\n removable: {removable}"));
out.push_str(&format!("\n main working tree: {is_main}"));
if open {
let key = sanitize(
report
.get("window_key")
.and_then(Value::as_str)
.unwrap_or("-"),
);
let count = report
.get("window_folder_count")
.and_then(Value::as_u64)
.unwrap_or(0);
out.push_str(&format!(
"\n open in a window: yes (key {key}, {count} folder(s))"
));
} else {
out.push_str("\n open in a window: no");
}
out.push_str(&render_notes("risks", report.get("risks")));
out.push_str(&render_notes("info", report.get("info")));
out
}
fn render_notes(label: &str, notes: Option<&Value>) -> String {
let notes = notes
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default();
if notes.is_empty() {
return String::new();
}
let mut out = format!("\n {label}:");
for note in notes {
let kind = sanitize(note.get("kind").and_then(Value::as_str).unwrap_or("-"));
let detail = sanitize(note.get("detail").and_then(Value::as_str).unwrap_or(""));
out.push_str(&format!("\n - [{kind}] {detail}"));
}
out
}
fn render_eligibility_report(report: &Value) -> String {
let eligible = report
.get("eligible")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default();
let skipped = report
.get("skipped")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default();
let mut out = format!("Eligible: {} / Skipped: {}", eligible.len(), skipped.len());
for pr in eligible {
let number = pr.get("number").and_then(Value::as_u64).unwrap_or(0);
let branch = sanitize(pr.get("branch").and_then(Value::as_str).unwrap_or("-"));
let path = sanitize(pr.get("path").and_then(Value::as_str).unwrap_or(""));
out.push_str(&format!("\n eligible: PR #{number} [{branch}] {path}"));
}
for skip in skipped {
let kind = sanitize(skip.get("kind").and_then(Value::as_str).unwrap_or("-"));
let detail = sanitize(skip.get("detail").and_then(Value::as_str).unwrap_or(""));
let path = sanitize(skip.get("path").and_then(Value::as_str).unwrap_or(""));
out.push_str(&format!("\n skipped [{kind}]: {path} — {detail}"));
}
out
}
fn render_enqueue_result(result: &Value) -> String {
let queued = result
.get("queued")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default();
let failed = result
.get("failed")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or_default();
let skipped = result
.get("skipped")
.and_then(Value::as_array)
.map_or(0, Vec::len);
let mut out = format!(
"Queued: {} / Failed: {} / Skipped: {}",
queued.len(),
failed.len(),
skipped
);
for pr in queued {
let number = pr.get("number").and_then(Value::as_u64).unwrap_or(0);
let already = pr
.get("already_queued")
.and_then(Value::as_bool)
.unwrap_or(false);
let suffix = if already { " (already queued)" } else { "" };
out.push_str(&format!("\n queued: PR #{number}{suffix}"));
}
for pr in failed {
let number = pr.get("number").and_then(Value::as_u64).unwrap_or(0);
let error = sanitize(pr.get("error").and_then(Value::as_str).unwrap_or(""));
out.push_str(&format!("\n failed: PR #{number} — {error}"));
}
out
}
async fn confirm_removal(has_risks: bool) -> bool {
confirm_removal_with(has_risks, read_stdin_line()).await
}
async fn confirm_removal_with(
has_risks: bool,
read: impl std::future::Future<Output = Option<String>>,
) -> bool {
use std::io::Write;
eprint!("{}", confirm_prompt(has_risks));
let _ = std::io::stderr().flush();
read.await.as_deref().is_some_and(answer_is_yes)
}
async fn confirm_enqueue(count: usize) -> bool {
confirm_enqueue_with(count, read_stdin_line()).await
}
async fn confirm_enqueue_with(
count: usize,
read: impl std::future::Future<Output = Option<String>>,
) -> bool {
use std::io::Write;
eprint!("Add {count} PR(s) to the merge queue? [y/N] ");
let _ = std::io::stderr().flush();
read.await.as_deref().is_some_and(answer_is_yes)
}
async fn read_stdin_line() -> Option<String> {
tokio::task::spawn_blocking(|| read_line_from(&mut std::io::stdin().lock()))
.await
.ok()
.flatten()
}
fn read_line_from(reader: &mut impl std::io::BufRead) -> Option<String> {
let mut answer = String::new();
reader.read_line(&mut answer).ok().map(|_| answer)
}
fn confirm_prompt(has_risks: bool) -> &'static str {
if has_risks {
"Delete this worktree despite the risks above? [y/N] "
} else {
"Delete this worktree? [y/N] "
}
}
fn answer_is_yes(answer: &str) -> bool {
matches!(answer.trim().to_lowercase().as_str(), "y" | "yes")
}
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 focus_parses_path_and_socket() {
assert!(matches!(
parse(&["focus", "/home/me/wt"]),
WorktreesSubcommands::Focus(_)
));
let cmd = FocusCommand::try_parse_from(["focus", "/home/me/wt"]).unwrap();
assert_eq!(cmd.path, Path::new("/home/me/wt"));
assert!(cmd.socket.is_none());
let cmd = FocusCommand::try_parse_from(["focus", "/home/me/wt", "--socket", "/tmp/d.sock"])
.unwrap();
assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
assert!(FocusCommand::try_parse_from(["focus"]).is_err());
}
#[tokio::test]
async fn focus_errors_on_a_nonexistent_path_before_any_socket_call() {
let cmd = FocusCommand {
path: PathBuf::from("/nonexistent/omni-dev-focus-xyz"),
socket: Some(PathBuf::from("/nonexistent/omni-dev-focus.sock")),
};
let err = cmd.execute().await.unwrap_err();
assert!(
err.to_string().contains("cannot resolve worktree path"),
"{err}"
);
}
#[tokio::test]
async fn focus_sends_the_open_op_for_an_existing_folder() {
let (_dir, sock, server) =
fake_daemon_reply(json!({ "ok": true, "payload": { "ok": true } }));
let target = tempfile::tempdir().unwrap();
let cmd = WorktreesCommand {
command: WorktreesSubcommands::Focus(FocusCommand {
path: target.path().to_path_buf(),
socket: Some(sock),
}),
};
cmd.execute(None).await.unwrap();
server.await.unwrap();
}
#[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)
}
fn fake_daemon_replies(
replies: Vec<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 {
for reply in replies {
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}");
}
#[test]
fn new_subcommands_route_and_require_their_args() {
assert!(matches!(
parse(&["close", "/home/me/wt"]),
WorktreesSubcommands::Close(_)
));
assert!(matches!(
parse(&["show-closed"]),
WorktreesSubcommands::ShowClosed(_)
));
assert!(matches!(
parse(&["register", "--key", "w1"]),
WorktreesSubcommands::Register(_)
));
assert!(matches!(
parse(&["heartbeat", "--key", "w1"]),
WorktreesSubcommands::Heartbeat(_)
));
assert!(matches!(
parse(&["unregister", "--key", "w1"]),
WorktreesSubcommands::Unregister(_)
));
assert!(CloseCommand::try_parse_from(["close"]).is_err());
assert!(RegisterCommand::try_parse_from(["register"]).is_err());
assert!(HeartbeatCommand::try_parse_from(["heartbeat"]).is_err());
assert!(UnregisterCommand::try_parse_from(["unregister"]).is_err());
}
#[test]
fn close_parses_flags() {
let cmd = CloseCommand::try_parse_from([
"close",
"/home/me/wt",
"--window-only",
"--dry-run",
"-y",
"--socket",
"/tmp/d.sock",
])
.unwrap();
assert_eq!(cmd.path, Path::new("/home/me/wt"));
assert!(cmd.window_only && cmd.dry_run && cmd.yes);
assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
let cmd = CloseCommand::try_parse_from(["close", "/home/me/wt"]).unwrap();
assert!(!cmd.window_only && !cmd.dry_run && !cmd.yes);
}
#[test]
fn tree_follow_flag_parses() {
let cmd = TreeCommand::try_parse_from(["tree", "--follow"]).unwrap();
assert!(cmd.follow);
let cmd = TreeCommand::try_parse_from(["tree", "-f", "-o", "json"]).unwrap();
assert!(cmd.follow);
assert_eq!(cmd.output, TableOrJson::Json);
let cmd = TreeCommand::try_parse_from(["tree"]).unwrap();
assert!(!cmd.follow);
}
#[test]
fn show_closed_parses_optional_bool() {
assert!(ShowClosedCommand::try_parse_from(["show-closed"])
.unwrap()
.value
.is_none());
assert_eq!(
ShowClosedCommand::try_parse_from(["show-closed", "false"])
.unwrap()
.value,
Some(false)
);
assert_eq!(
ShowClosedCommand::try_parse_from(["show-closed", "true"])
.unwrap()
.value,
Some(true)
);
assert!(ShowClosedCommand::try_parse_from(["show-closed", "maybe"]).is_err());
}
#[test]
fn register_collects_repeated_folders() {
let cmd = RegisterCommand::try_parse_from([
"register",
"--key",
"w1",
"--folder",
"/a",
"--folder",
"/b",
"--repo-name",
"r",
"--pid",
"42",
])
.unwrap();
assert_eq!(cmd.key, "w1");
assert_eq!(cmd.folders, vec![PathBuf::from("/a"), PathBuf::from("/b")]);
assert_eq!(cmd.repo_name.as_deref(), Some("r"));
assert_eq!(cmd.pid, Some(42));
}
#[test]
fn answer_is_yes_accepts_only_affirmatives() {
for yes in ["y", "Y", "yes", "YES", " yes \n"] {
assert!(answer_is_yes(yes), "{yes:?}");
}
for no in ["", "n", "no", "nope", "true", "\n"] {
assert!(!answer_is_yes(no), "{no:?}");
}
}
#[test]
fn confirm_prompt_mentions_risks_only_when_present() {
assert!(confirm_prompt(true).contains("risks"));
assert!(!confirm_prompt(false).contains("risks"));
assert!(confirm_prompt(true).contains("[y/N]"));
assert!(confirm_prompt(false).contains("[y/N]"));
}
#[test]
fn read_line_from_maps_input_and_eof() {
use std::io::Cursor;
assert_eq!(
read_line_from(&mut Cursor::new("y\n")).as_deref(),
Some("y\n")
);
assert_eq!(read_line_from(&mut Cursor::new("")).as_deref(), Some(""));
assert_eq!(
read_line_from(&mut Cursor::new("no-newline")).as_deref(),
Some("no-newline")
);
}
#[test]
fn render_safety_report_renders_fields_and_notes() {
let report = json!({
"removable": true,
"is_main": false,
"open": true,
"window_key": "w1",
"window_folder_count": 2,
"risks": [{ "kind": "dirty", "detail": "uncommitted changes" }],
"info": [{ "kind": "unpushed", "detail": "2 unpushed commits" }],
});
let out = render_safety_report(Path::new("/home/me/wt"), &report);
assert!(out.contains("/home/me/wt"), "{out}");
assert!(out.contains("removable: true"), "{out}");
assert!(
out.contains("open in a window: yes (key w1, 2 folder(s))"),
"{out}"
);
assert!(out.contains("[dirty] uncommitted changes"), "{out}");
assert!(out.contains("[unpushed] 2 unpushed commits"), "{out}");
}
#[test]
fn render_safety_report_handles_no_window_and_no_notes() {
let report = json!({ "removable": false, "is_main": true, "open": false });
let out = render_safety_report(Path::new("/r"), &report);
assert!(out.contains("removable: false"), "{out}");
assert!(out.contains("main working tree: true"), "{out}");
assert!(out.contains("open in a window: no"), "{out}");
assert!(!out.contains("risks:"), "{out}");
assert!(!out.contains("info:"), "{out}");
}
#[test]
fn render_safety_report_strips_control_bytes() {
let report = json!({
"removable": true, "is_main": false, "open": true,
"window_key": "w\x1b[31m1", "window_folder_count": 1,
"risks": [{ "kind": "di\x07rty", "detail": "lost\r\nrow" }],
"info": [],
});
let out = render_safety_report(Path::new("/r"), &report);
assert!(
!out.contains(|c: char| c.is_control() && c != '\n'),
"{out:?}"
);
}
fn fake_daemon_seq(
replies: Vec<Value>,
) -> (
tempfile::TempDir,
PathBuf,
tokio::task::JoinHandle<Vec<Value>>,
) {
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 mut requests = Vec::new();
for reply in replies {
let (stream, _) = listener.accept().await.unwrap();
let mut framed = Framed::new(stream, LinesCodec::new());
let req = framed.next().await.unwrap().unwrap();
requests.push(serde_json::from_str::<Value>(&req).unwrap());
framed
.send(serde_json::to_string(&reply).unwrap())
.await
.unwrap();
}
requests
});
(dir, sock, server)
}
#[tokio::test]
async fn close_window_only_sends_remove_false() {
let (_dir, sock, server) =
fake_daemon_seq(vec![json!({ "ok": true, "payload": { "closed": true } })]);
let target = tempfile::tempdir().unwrap();
CloseCommand {
path: target.path().to_path_buf(),
window_only: true,
dry_run: false,
yes: false,
socket: Some(sock),
}
.execute()
.await
.unwrap();
let reqs = server.await.unwrap();
assert_eq!(reqs.len(), 1);
assert_eq!(reqs[0]["op"], "close");
assert_eq!(reqs[0]["payload"]["remove"], json!(false));
assert!(
reqs[0]["payload"].get("confirmed").is_none(),
"{:?}",
reqs[0]
);
let want = std::fs::canonicalize(target.path()).unwrap();
assert_eq!(reqs[0]["payload"]["path"], json!(want.to_string_lossy()));
}
#[tokio::test]
async fn close_window_only_dry_run_never_contacts_the_daemon() {
let target = tempfile::tempdir().unwrap();
CloseCommand {
path: target.path().to_path_buf(),
window_only: true,
dry_run: true,
yes: false,
socket: Some(PathBuf::from("/nonexistent/omni-dev-close-dry.sock")),
}
.execute()
.await
.unwrap();
}
#[tokio::test]
async fn close_dry_run_only_runs_phase_one() {
let (_dir, sock, server) = fake_daemon_seq(vec![json!({
"ok": true,
"payload": { "removable": true, "is_main": false, "open": false,
"window_folder_count": 0, "risks": [], "info": [] }
})]);
let target = tempfile::tempdir().unwrap();
CloseCommand {
path: target.path().to_path_buf(),
window_only: false,
dry_run: true,
yes: false,
socket: Some(sock),
}
.execute()
.await
.unwrap();
let reqs = server.await.unwrap();
assert_eq!(reqs.len(), 1);
assert_eq!(reqs[0]["op"], "close");
assert_eq!(reqs[0]["payload"]["remove"], json!(true));
assert!(
reqs[0]["payload"].get("confirmed").is_none(),
"{:?}",
reqs[0]
);
}
#[tokio::test]
async fn close_yes_executes_phase_two() {
let (_dir, sock, server) = fake_daemon_seq(vec![
json!({ "ok": true, "payload": { "removable": true, "is_main": false,
"open": false, "window_folder_count": 0, "risks": [], "info": [] } }),
json!({ "ok": true, "payload": { "removed": true } }),
]);
let target = tempfile::tempdir().unwrap();
CloseCommand {
path: target.path().to_path_buf(),
window_only: false,
dry_run: false,
yes: true,
socket: Some(sock),
}
.execute()
.await
.unwrap();
let reqs = server.await.unwrap();
assert_eq!(reqs.len(), 2);
assert_eq!(reqs[0]["op"], "close");
assert_eq!(reqs[0]["payload"]["remove"], json!(true));
assert!(
reqs[0]["payload"].get("confirmed").is_none(),
"{:?}",
reqs[0]
);
assert_eq!(reqs[1]["op"], "close");
assert_eq!(reqs[1]["payload"]["remove"], json!(true));
assert_eq!(reqs[1]["payload"]["confirmed"], json!(true));
assert!(
reqs[1]["payload"].get("requester_key").is_none(),
"{:?}",
reqs[1]
);
}
#[tokio::test]
async fn close_refuses_a_non_removable_target() {
let (_dir, sock, server) = fake_daemon_seq(vec![json!({
"ok": true,
"payload": { "removable": false, "is_main": true, "open": false,
"window_folder_count": 0, "risks": [], "info": [] }
})]);
let target = tempfile::tempdir().unwrap();
let err = CloseCommand {
path: target.path().to_path_buf(),
window_only: false,
dry_run: false,
yes: true,
socket: Some(sock),
}
.execute()
.await
.unwrap_err();
assert!(
err.to_string().contains("not a removable worktree"),
"{err}"
);
assert_eq!(server.await.unwrap().len(), 1);
}
#[tokio::test]
async fn close_errors_on_a_nonexistent_path_before_any_socket_call() {
let err = CloseCommand {
path: PathBuf::from("/nonexistent/omni-dev-close-xyz"),
window_only: false,
dry_run: false,
yes: true,
socket: Some(PathBuf::from("/nonexistent/omni-dev-close.sock")),
}
.execute()
.await
.unwrap_err();
assert!(
err.to_string().contains("cannot resolve worktree path"),
"{err}"
);
}
#[tokio::test]
async fn show_closed_sets_and_reads() {
let (_dir, sock, server) =
fake_daemon_seq(vec![json!({ "ok": true, "payload": { "ok": true } })]);
ShowClosedCommand {
value: Some(false),
socket: Some(sock),
}
.execute()
.await
.unwrap();
let reqs = server.await.unwrap();
assert_eq!(reqs[0]["op"], "set-show-closed");
assert_eq!(reqs[0]["payload"]["show_closed"], json!(false));
let (_dir, sock, server) = fake_daemon_seq(vec![
json!({ "ok": true, "payload": { "repos": [], "show_closed": false } }),
]);
ShowClosedCommand {
value: None,
socket: Some(sock),
}
.execute()
.await
.unwrap();
assert_eq!(server.await.unwrap()[0]["op"], "tree");
}
#[tokio::test]
async fn register_heartbeat_unregister_send_their_ops() {
let (_dir, sock, server) =
fake_daemon_seq(vec![json!({ "ok": true, "payload": { "ok": true } })]);
RegisterCommand {
key: "w1".to_string(),
folders: vec![PathBuf::from("/a")],
repo_name: Some("r".to_string()),
title: None,
pid: Some(7),
socket: Some(sock),
}
.execute()
.await
.unwrap();
let reqs = server.await.unwrap();
assert_eq!(reqs[0]["op"], "register");
assert_eq!(reqs[0]["payload"]["key"], json!("w1"));
assert_eq!(reqs[0]["payload"]["folders"], json!(["/a"]));
assert_eq!(reqs[0]["payload"]["repo"], json!("r"));
assert_eq!(reqs[0]["payload"]["pid"], json!(7));
let (_dir, sock, server) = fake_daemon_seq(vec![
json!({ "ok": true, "payload": { "known": true, "close": true } }),
]);
HeartbeatCommand {
key: "w1".to_string(),
socket: Some(sock),
}
.execute()
.await
.unwrap();
let reqs = server.await.unwrap();
assert_eq!(reqs[0]["op"], "heartbeat");
assert_eq!(reqs[0]["payload"]["key"], json!("w1"));
let (_dir, sock, server) =
fake_daemon_seq(vec![json!({ "ok": true, "payload": { "removed": true } })]);
UnregisterCommand {
key: "w1".to_string(),
socket: Some(sock),
}
.execute()
.await
.unwrap();
let reqs = server.await.unwrap();
assert_eq!(reqs[0]["op"], "unregister");
assert_eq!(reqs[0]["payload"]["key"], json!("w1"));
}
#[tokio::test]
async fn tree_follow_renders_each_pushed_frame() {
use crate::daemon::testutil::fake_daemon_stream;
let (_dir, sock, server) = fake_daemon_stream(vec![
json!({ "ok": true, "payload": { "repos": [], "show_closed": true } }),
json!({ "ok": true, "payload": { "repos": [], "show_closed": false } }),
]);
follow_tree_stream(&sock, TableOrJson::Json).await.unwrap();
server.await.unwrap();
let (_dir, sock, server) = fake_daemon_stream(vec![
json!({ "ok": true, "payload": { "repos": [], "show_closed": true } }),
]);
follow_tree_stream(&sock, TableOrJson::Table).await.unwrap();
server.await.unwrap();
let (_dir, sock, server) = fake_daemon_stream(vec![
json!({ "ok": true, "payload": { "repos": [], "show_closed": true } }),
]);
TreeCommand {
socket: Some(sock),
output: TableOrJson::Json,
follow: true,
}
.execute()
.await
.unwrap();
server.await.unwrap();
}
#[tokio::test]
async fn worktrees_command_routes_each_new_subcommand() {
let target = tempfile::tempdir().unwrap();
WorktreesCommand {
command: WorktreesSubcommands::Close(CloseCommand {
path: target.path().to_path_buf(),
window_only: true,
dry_run: true,
yes: false,
socket: Some(PathBuf::from("/nonexistent/omni-dev-route.sock")),
}),
}
.execute(None)
.await
.unwrap();
WorktreesCommand {
command: WorktreesSubcommands::Rebase(RebaseCommand {
paths: vec![target.path().to_path_buf()],
dry_run: true,
..rebase_cmd()
}),
}
.execute(None)
.await
.unwrap();
let (_d, sock, server) =
fake_daemon_seq(vec![json!({ "ok": true, "payload": { "ok": true } })]);
WorktreesCommand {
command: WorktreesSubcommands::ShowClosed(ShowClosedCommand {
value: Some(true),
socket: Some(sock),
}),
}
.execute(None)
.await
.unwrap();
server.await.unwrap();
let (_d, sock, server) = fake_daemon_seq(vec![json!({
"ok": true,
"payload": { "trusted": true, "moved": 0, "skipped": 0, "results": [] },
})]);
WorktreesCommand {
command: WorktreesSubcommands::Reposition(RepositionCommand {
paths: Vec::new(),
reference: None,
dry_run: false,
undo: true,
output: TableOrJson::Table,
socket: Some(sock),
}),
}
.execute(None)
.await
.unwrap();
server.await.unwrap();
let (_d, sock, server) = fake_daemon_seq(vec![
json!({ "ok": true, "payload": { "windows": [] } }),
json!({ "ok": true, "payload": { "requested": 0, "signalled": 0, "unknown": [] } }),
]);
WorktreesCommand {
command: WorktreesSubcommands::Reload(ReloadCommand {
paths: Vec::new(),
output: TableOrJson::Table,
socket: Some(sock),
}),
}
.execute(None)
.await
.unwrap();
server.await.unwrap();
let (_d, sock, server) =
fake_daemon_seq(vec![json!({ "ok": true, "payload": { "ok": true } })]);
WorktreesCommand {
command: WorktreesSubcommands::Register(RegisterCommand {
key: "w1".to_string(),
folders: vec![],
repo_name: None,
title: None,
pid: None,
socket: Some(sock),
}),
}
.execute(None)
.await
.unwrap();
server.await.unwrap();
let (_d, sock, server) =
fake_daemon_seq(vec![json!({ "ok": true, "payload": { "known": true } })]);
WorktreesCommand {
command: WorktreesSubcommands::Heartbeat(HeartbeatCommand {
key: "w1".to_string(),
socket: Some(sock),
}),
}
.execute(None)
.await
.unwrap();
server.await.unwrap();
let (_d, sock, server) =
fake_daemon_seq(vec![json!({ "ok": true, "payload": { "removed": true } })]);
WorktreesCommand {
command: WorktreesSubcommands::Unregister(UnregisterCommand {
key: "w1".to_string(),
socket: Some(sock),
}),
}
.execute(None)
.await
.unwrap();
server.await.unwrap();
}
#[tokio::test]
async fn close_aborts_when_confirmation_is_declined() {
let (_dir, sock, server) = fake_daemon_seq(vec![json!({
"ok": true,
"payload": { "removable": true, "is_main": false, "open": false,
"window_folder_count": 0, "risks": [], "info": [] }
})]);
let target = tempfile::tempdir().unwrap();
CloseCommand {
path: target.path().to_path_buf(),
window_only: false,
dry_run: false,
yes: false,
socket: Some(sock),
}
.execute_with(|_has_risks| async { false })
.await
.unwrap();
assert_eq!(server.await.unwrap().len(), 1);
}
#[tokio::test]
async fn close_deletes_when_confirmation_is_accepted() {
let (_dir, sock, server) = fake_daemon_seq(vec![
json!({ "ok": true, "payload": { "removable": true, "is_main": false,
"open": false, "window_folder_count": 0, "risks": [], "info": [] } }),
json!({ "ok": true, "payload": { "removed": true } }),
]);
let target = tempfile::tempdir().unwrap();
CloseCommand {
path: target.path().to_path_buf(),
window_only: false,
dry_run: false,
yes: false,
socket: Some(sock),
}
.execute_with(|_has_risks| async { true })
.await
.unwrap();
let reqs = server.await.unwrap();
assert_eq!(reqs.len(), 2);
assert_eq!(reqs[1]["payload"]["confirmed"], json!(true));
}
#[tokio::test]
async fn confirm_removal_with_decides_from_the_answer() {
assert!(confirm_removal_with(false, async { Some("y\n".to_string()) }).await);
assert!(confirm_removal_with(true, async { Some("YES".to_string()) }).await);
assert!(!confirm_removal_with(false, async { Some("n".to_string()) }).await);
assert!(!confirm_removal_with(true, async { Some(String::new()) }).await);
assert!(!confirm_removal_with(false, async { None }).await);
}
fn rebase_cmd() -> RebaseCommand {
RebaseCommand {
paths: Vec::new(),
all: false,
onto: None,
autostash: false,
dry_run: false,
keep_conflicts: false,
yes: false,
output: TableOrJson::Table,
}
}
#[test]
fn rebase_parses_paths_and_flags() {
let cmd = RebaseCommand::try_parse_from([
"rebase",
"/wt/a",
"/wt/b",
"--onto",
"origin/release",
"--autostash",
"--dry-run",
"--keep-conflicts",
"-y",
"-o",
"json",
])
.unwrap();
assert_eq!(
cmd.paths,
vec![PathBuf::from("/wt/a"), PathBuf::from("/wt/b")]
);
assert_eq!(cmd.onto.as_deref(), Some("origin/release"));
assert!(cmd.autostash && cmd.dry_run && cmd.keep_conflicts && cmd.yes);
assert!(matches!(cmd.output, TableOrJson::Json));
}
#[test]
fn rebase_defaults_are_conservative() {
let cmd = RebaseCommand::try_parse_from(["rebase", "/wt/a"]).unwrap();
assert!(!cmd.all && !cmd.autostash && !cmd.dry_run && !cmd.yes);
assert!(!cmd.keep_conflicts);
assert_eq!(cmd.onto, None);
assert!(matches!(cmd.output, TableOrJson::Table));
}
#[test]
fn rebase_requires_a_target() {
let err = rebase_cmd().selection(None).unwrap_err().to_string();
assert!(err.contains("--all"), "expected a usage hint, got: {err}");
}
#[test]
fn rebase_rejects_paths_together_with_all() {
let cmd = RebaseCommand {
paths: vec![PathBuf::from("/wt/a")],
all: true,
..rebase_cmd()
};
let err = cmd.selection(None).unwrap_err().to_string();
assert!(err.contains("not both"), "got: {err}");
}
#[test]
fn rebase_selection_maps_paths_and_all() {
let cmd = RebaseCommand {
paths: vec![PathBuf::from("/wt/a")],
..rebase_cmd()
};
assert!(matches!(cmd.selection(None).unwrap(), Selection::Paths(p) if p.len() == 1));
let all = RebaseCommand {
all: true,
..rebase_cmd()
};
assert!(matches!(
all.selection(None).unwrap(),
Selection::All { .. }
));
}
#[test]
fn rebase_prompt_agrees_in_number() {
assert!(rebase_prompt(1).contains("1 worktree ("));
assert!(rebase_prompt(3).contains("3 worktrees ("));
assert!(rebase_prompt(2).contains("rewrites branch history"));
}
#[tokio::test]
async fn confirm_rebase_with_decides_from_the_answer() {
assert!(confirm_rebase_with(1, async { Some("y\n".to_string()) }).await);
assert!(confirm_rebase_with(2, async { Some("YES".to_string()) }).await);
assert!(!confirm_rebase_with(1, async { Some("n".to_string()) }).await);
assert!(!confirm_rebase_with(1, async { Some(String::new()) }).await);
assert!(!confirm_rebase_with(1, async { None }).await);
}
#[test]
fn fetch_line_reports_each_repos_single_fetch() {
let ok = FetchOutcome {
repo_root: PathBuf::from("/repo"),
onto: "origin/main".to_string(),
fetched: true,
ok: true,
detail: None,
};
assert!(fetch_line(&ok).contains("Fetched origin/main once for /repo"));
let failed = FetchOutcome {
detail: Some("host unreachable".to_string()),
ok: false,
..ok.clone()
};
assert!(fetch_line(&failed).contains("FAILED"));
let local = FetchOutcome {
fetched: false,
onto: "develop".to_string(),
..ok
};
assert!(fetch_line(&local).contains("nothing fetched"));
}
#[test]
fn outcome_rows_render_each_status() {
let row = |result| {
outcome_row(&WorktreeOutcome {
path: PathBuf::from("/wt"),
branch: Some("feature".to_string()),
onto: "origin/main".to_string(),
result,
})
};
assert!(row(RebaseResult::Rebased { behind: 2 }).contains("rebased"));
assert!(row(RebaseResult::Rebased { behind: 2 }).contains("was 2 behind"));
assert!(row(RebaseResult::WouldRebase { behind: 1 }).contains("would-rebase"));
assert!(row(RebaseResult::UpToDate).contains("up-to-date"));
assert!(row(RebaseResult::Skipped {
reason: SkipReason::Dirty
})
.contains("--autostash"));
assert!(row(RebaseResult::Skipped {
reason: SkipReason::MainWorkingTree
})
.contains("main working tree"));
assert!(row(RebaseResult::Conflict {
detail: "CONFLICT (content)".to_string(),
left_in_place: false,
})
.contains("conflict"));
let kept = row(RebaseResult::Conflict {
detail: "CONFLICT (content)".to_string(),
left_in_place: true,
});
assert!(kept.contains("conflict"), "{kept}");
assert!(kept.contains("git rebase --continue"), "{kept}");
assert!(row(RebaseResult::FetchFailed {
detail: "host unreachable".to_string()
})
.contains("fetch-failed"));
assert!(row(RebaseResult::Skipped {
reason: SkipReason::DetachedHead
})
.contains("detached HEAD"));
assert!(row(RebaseResult::Skipped {
reason: SkipReason::OperationInProgress
})
.contains("in progress"));
assert!(row(RebaseResult::Skipped {
reason: SkipReason::NotAWorktree
})
.contains("not a git worktree"));
assert!(row(RebaseResult::Skipped {
reason: SkipReason::NoOntoRef
})
.contains("resolve the target ref"));
}
#[test]
fn print_emits_both_json_and_table_without_error() {
let fetches = vec![FetchOutcome {
repo_root: PathBuf::from("/r"),
onto: "origin/main".to_string(),
fetched: true,
ok: true,
detail: None,
}];
let outcomes = vec![WorktreeOutcome {
path: PathBuf::from("/wt"),
branch: Some("feature".to_string()),
onto: "origin/main".to_string(),
result: RebaseResult::UpToDate,
}];
let json_cmd = RebaseCommand {
dry_run: true,
output: TableOrJson::Json,
..rebase_cmd()
};
json_cmd.print(true, &fetches, &outcomes).unwrap();
rebase_cmd().print(false, &fetches, &outcomes).unwrap();
}
#[test]
fn brief_collapses_a_multiline_git_error_to_one_capped_line() {
assert_eq!(brief("\n\nfirst line\nsecond line\n"), "first line");
let long = "x".repeat(200);
let out = brief(&long);
assert_eq!(out.chars().count(), 100);
assert!(out.ends_with("..."));
assert_eq!(brief("a\u{7}b"), "ab");
}
#[test]
fn empty_report_renders_placeholders() {
assert_eq!(render_fetches(&[]), "No repository selected.");
assert_eq!(render_outcomes(&[]), "No worktrees selected.");
}
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn rebase_declined_confirmation_leaves_the_branch_untouched() {
let _guard = worktree_rebase::test_serial_lock();
let Some(scenario) = BehindScenario::build() else {
return; };
let before = scenario.worktree_head();
RebaseCommand {
paths: vec![scenario.worktree.clone()],
..rebase_cmd()
}
.execute_with(None, |pending| async move {
assert_eq!(pending, 1, "one worktree is behind and awaiting a rebase");
false
})
.await
.unwrap();
assert_eq!(
scenario.worktree_head(),
before,
"declining the confirm must not rebase"
);
}
#[allow(clippy::await_holding_lock)]
#[tokio::test]
async fn rebase_confirmed_rebases_the_behind_worktree() {
let _guard = worktree_rebase::test_serial_lock();
let Some(scenario) = BehindScenario::build() else {
return; };
let before = scenario.worktree_head();
RebaseCommand {
paths: vec![scenario.worktree.clone()],
..rebase_cmd()
}
.execute_with(None, |pending| async move {
assert_eq!(pending, 1);
true
})
.await
.unwrap();
assert_ne!(
scenario.worktree_head(),
before,
"confirming the prompt must rebase the worktree"
);
}
struct BehindScenario {
_root: tempfile::TempDir,
worktree: PathBuf,
}
impl BehindScenario {
fn build() -> Option<Self> {
use git2::Repository;
let root = tempfile::tempdir().ok()?;
let origin = root.path().join("origin.git");
let local = root.path().join("local");
let worktree = root.path().join("feature");
std::fs::create_dir_all(&origin).ok()?;
std::fs::create_dir_all(&local).ok()?;
run(&origin, &["init", "--bare", "-b", "main"])?;
run(&local, &["init", "-b", "main"])?;
Self::identity(&local)?;
std::fs::write(local.join("f.txt"), "one\n").ok()?;
run(&local, &["add", "f.txt"])?;
run(&local, &["commit", "-m", "one"])?;
run(&local, &["remote", "add", "origin", origin.to_str()?])?;
run(&local, &["push", "-u", "origin", "main"])?;
run(
&local,
&[
"worktree",
"add",
"-b",
"feature",
worktree.to_str()?,
"main",
],
)?;
let repo = Repository::open_bare(&origin).ok()?;
let parent = repo
.find_commit(repo.refname_to_id("refs/heads/main").ok()?)
.ok()?;
let mut builder = repo.treebuilder(Some(&parent.tree().ok()?)).ok()?;
let blob = repo.blob(b"two\n").ok()?;
builder.insert("f.txt", blob, 0o100_644).ok()?;
let tree = repo.find_tree(builder.write().ok()?).ok()?;
let sig = git2::Signature::now("Other", "other@example.com").ok()?;
repo.commit(
Some("refs/heads/main"),
&sig,
&sig,
"two",
&tree,
&[&parent],
)
.ok()?;
Some(Self {
_root: root,
worktree,
})
}
fn identity(dir: &Path) -> Option<()> {
run(dir, &["config", "user.name", "Test"])?;
run(dir, &["config", "user.email", "test@example.com"])?;
run(dir, &["config", "commit.gpgsign", "false"])
}
fn worktree_head(&self) -> String {
let out = std::process::Command::new("git")
.current_dir(&self.worktree)
.args(["rev-parse", "HEAD"])
.output();
out.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_default()
}
}
fn run(dir: &Path, args: &[&str]) -> Option<()> {
let output = std::process::Command::new("git")
.current_dir(dir)
.args(args)
.output()
.ok()?;
output.status.success().then_some(())
}
#[test]
fn merge_queue_parses_paths_and_flags() {
assert!(matches!(
parse(&["merge-queue", "/a"]),
WorktreesSubcommands::MergeQueue(_)
));
let cmd =
MergeQueueCommand::try_parse_from(["merge-queue", "/a", "/b", "--check"]).unwrap();
assert_eq!(cmd.paths.len(), 2);
assert!(cmd.check);
assert!(!cmd.yes);
assert!(cmd.socket.is_none());
let cmd = MergeQueueCommand::try_parse_from([
"merge-queue",
"/a",
"-y",
"--socket",
"/tmp/d.sock",
])
.unwrap();
assert!(cmd.yes);
assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
assert!(MergeQueueCommand::try_parse_from(["merge-queue"]).is_err());
}
#[test]
fn render_eligibility_report_lists_eligible_and_skipped() {
let report = json!({
"eligible": [{ "number": 10, "branch": "feature", "url": "u", "path": "/wt/a" }],
"skipped": [{ "path": "/wt/b", "kind": "dirty", "detail": "2 modified" }],
});
let out = render_eligibility_report(&report);
assert!(out.contains("Eligible: 1 / Skipped: 1"), "{out}");
assert!(out.contains("PR #10 [feature] /wt/a"), "{out}");
assert!(out.contains("skipped [dirty]: /wt/b — 2 modified"), "{out}");
}
#[test]
fn render_enqueue_result_marks_already_queued_and_failures() {
let result = json!({
"queued": [
{ "number": 10, "path": "/a" },
{ "number": 11, "path": "/b", "already_queued": true },
],
"failed": [{ "number": 12, "path": "/c", "error": "merge queue not enabled" }],
"skipped": [{ "path": "/d", "kind": "unpushed", "detail": "x" }],
});
let out = render_enqueue_result(&result);
assert!(out.contains("Queued: 2 / Failed: 1 / Skipped: 1"), "{out}");
assert!(out.contains("queued: PR #10"), "{out}");
assert!(out.contains("PR #11 (already queued)"), "{out}");
assert!(
out.contains("failed: PR #12 — merge queue not enabled"),
"{out}"
);
}
#[test]
fn render_eligibility_report_strips_control_bytes() {
let report = json!({
"eligible": [{ "number": 1, "branch": "br\x1b[31manch", "path": "/a\rb" }],
"skipped": [{ "path": "/e\x1b]0;x\x07vil", "kind": "d\x07irty", "detail": "l\u{9b}2J" }],
});
let out = render_eligibility_report(&report);
assert!(
!out.contains(|c: char| c.is_control() && c != '\n'),
"{out:?}"
);
}
#[tokio::test]
async fn confirm_enqueue_with_decides_from_the_answer() {
assert!(confirm_enqueue_with(3, async { Some("y\n".to_string()) }).await);
assert!(confirm_enqueue_with(1, async { Some("YES".to_string()) }).await);
assert!(!confirm_enqueue_with(3, async { Some("n".to_string()) }).await);
assert!(!confirm_enqueue_with(3, async { Some(String::new()) }).await);
assert!(!confirm_enqueue_with(3, async { None }).await);
}
#[tokio::test]
async fn merge_queue_errors_on_a_nonexistent_path_before_any_socket_call() {
let cmd = MergeQueueCommand {
paths: vec![PathBuf::from("/nonexistent/omni-dev-mq-xyz")],
check: true,
yes: false,
socket: Some(PathBuf::from("/nonexistent/omni-dev-mq.sock")),
};
let err = cmd.execute().await.unwrap_err();
assert!(
err.to_string().contains("cannot resolve worktree path"),
"{err}"
);
}
#[tokio::test]
async fn merge_queue_check_prints_the_report_and_never_confirms() {
let target = tempfile::tempdir().unwrap();
let (_dir, sock, server) = fake_daemon_replies(vec![json!({
"ok": true,
"payload": {
"eligible": [{ "path": "/a", "number": 9, "url": "u", "branch": "feature" }],
"skipped": [{ "path": "/b", "kind": "dirty", "detail": "2 modified" }],
}
})]);
let cmd = MergeQueueCommand {
paths: vec![target.path().to_path_buf()],
check: true,
yes: false,
socket: Some(sock),
};
cmd.execute_with(|_| async { panic!("must not confirm on --check") })
.await
.unwrap();
server.await.unwrap();
}
#[tokio::test]
async fn merge_queue_reports_nothing_to_enqueue_when_none_eligible() {
let target = tempfile::tempdir().unwrap();
let (_dir, sock, server) = fake_daemon_replies(vec![json!({
"ok": true,
"payload": {
"eligible": [],
"skipped": [{ "path": "/b", "kind": "no-pr", "detail": "no open PR" }],
}
})]);
let cmd = MergeQueueCommand {
paths: vec![target.path().to_path_buf()],
check: false,
yes: false,
socket: Some(sock),
};
cmd.execute_with(|_| async { panic!("must not confirm when nothing is eligible") })
.await
.unwrap();
server.await.unwrap();
}
#[tokio::test]
async fn merge_queue_aborts_when_confirmation_is_declined() {
let target = tempfile::tempdir().unwrap();
let (_dir, sock, server) = fake_daemon_replies(vec![json!({
"ok": true,
"payload": {
"eligible": [{ "path": "/a", "number": 9, "url": "u", "branch": "feature" }],
"skipped": [],
}
})]);
let cmd = MergeQueueCommand {
paths: vec![target.path().to_path_buf()],
check: false,
yes: false,
socket: Some(sock),
};
cmd.execute_with(|count| async move {
assert_eq!(count, 1);
false
})
.await
.unwrap();
server.await.unwrap();
}
#[tokio::test]
async fn merge_queue_enqueues_after_confirmation() {
let target = tempfile::tempdir().unwrap();
let (_dir, sock, server) = fake_daemon_replies(vec![
json!({
"ok": true,
"payload": {
"eligible": [{ "path": "/a", "number": 9, "url": "u", "branch": "feature" }],
"skipped": [],
}
}),
json!({
"ok": true,
"payload": {
"queued": [{ "path": "/a", "number": 9 }],
"skipped": [],
"failed": [],
}
}),
]);
let cmd = MergeQueueCommand {
paths: vec![target.path().to_path_buf()],
check: false,
yes: false,
socket: Some(sock),
};
cmd.execute_with(|_| async { true }).await.unwrap();
server.await.unwrap();
}
#[tokio::test]
async fn merge_queue_check_routes_through_the_worktrees_dispatch() {
let target = tempfile::tempdir().unwrap();
let (_dir, sock, server) = fake_daemon_replies(vec![json!({
"ok": true,
"payload": { "eligible": [], "skipped": [] }
})]);
let cmd = WorktreesCommand {
command: WorktreesSubcommands::MergeQueue(MergeQueueCommand {
paths: vec![target.path().to_path_buf()],
check: true,
yes: false,
socket: Some(sock),
}),
};
cmd.execute(None).await.unwrap();
server.await.unwrap();
}
#[test]
fn reposition_parses_flags_and_enforces_the_undo_split() {
let WorktreesSubcommands::Reposition(cmd) = parse(&[
"reposition",
"--reference",
"/wt/ref",
"/wt/a",
"/wt/b",
"--dry-run",
"-o",
"json",
]) else {
panic!("expected the Reposition variant");
};
assert_eq!(cmd.reference.as_deref(), Some(Path::new("/wt/ref")));
assert_eq!(
cmd.paths,
vec![PathBuf::from("/wt/a"), PathBuf::from("/wt/b")]
);
assert!(cmd.dry_run);
assert!(!cmd.undo);
assert_eq!(cmd.output, TableOrJson::Json);
let WorktreesSubcommands::Reposition(undo) = parse(&["reposition", "--undo"]) else {
panic!("expected the Reposition variant");
};
assert!(undo.undo);
assert!(undo.reference.is_none());
}
#[test]
fn reposition_rejects_a_missing_reference_and_undo_combinations() {
assert!(RepositionCommand::try_parse_from(["reposition", "/wt/a"]).is_err());
assert!(RepositionCommand::try_parse_from([
"reposition",
"--undo",
"--reference",
"/wt/ref",
])
.is_err());
assert!(RepositionCommand::try_parse_from(["reposition", "--undo", "--dry-run"]).is_err());
}
#[test]
fn window_key_for_matches_a_canonicalized_folder() {
let dir = tempfile::tempdir_in("/tmp").unwrap();
let wt = dir.path().join("tree");
std::fs::create_dir(&wt).unwrap();
let canonical = std::fs::canonicalize(&wt).unwrap();
let windows = json!({
"windows": [
{ "key": "other", "folders": ["/definitely/not/here"] },
{ "key": "wanted", "folders": [canonical.to_string_lossy()] },
]
});
assert_eq!(
window_key_for(&windows, &wt, "repositioned").unwrap(),
"wanted"
);
}
#[test]
fn window_key_for_errors_when_no_window_has_it_open() {
let dir = tempfile::tempdir_in("/tmp").unwrap();
let err = window_key_for(&json!({ "windows": [] }), dir.path(), "repositioned")
.expect_err("an unopened worktree must not resolve");
assert!(err.to_string().contains("no VS Code window has"), "{err:#}");
let err = window_key_for(&json!({ "windows": [] }), dir.path(), "reloaded")
.expect_err("an unopened worktree must not resolve");
assert!(err.to_string().contains("can be reloaded"), "{err:#}");
let missing = dir.path().join("gone");
let err = window_key_for(&json!({ "windows": [] }), &missing, "repositioned")
.expect_err("a nonexistent path must not resolve");
assert!(err.to_string().contains("cannot resolve"), "{err:#}");
}
#[test]
fn reload_command_requires_at_least_one_path() {
assert!(ReloadCommand::try_parse_from(["reload"]).is_err());
let cmd = ReloadCommand::try_parse_from(["reload", "/wt/a", "/wt/b"]).unwrap();
assert_eq!(cmd.paths.len(), 2);
assert!(matches!(cmd.output, TableOrJson::Table));
assert!(cmd.socket.is_none());
}
#[test]
fn render_reload_reports_what_was_signalled_not_reloaded() {
let out = render_reload(&json!({ "requested": 2, "signalled": 2, "unknown": [] }));
assert_eq!(out, "Signalled 2 of 2 windows to reload.");
assert!(!out.contains("Reloaded"), "{out}");
let one = render_reload(&json!({ "requested": 1, "signalled": 1, "unknown": [] }));
assert_eq!(one, "Signalled 1 of 1 window to reload.");
}
#[test]
fn render_reload_names_windows_that_had_already_closed() {
let out = render_reload(&json!({
"requested": 3,
"signalled": 1,
"unknown": ["w2", "w3"],
}));
assert!(
out.starts_with("Signalled 1 of 3 windows to reload."),
"{out}"
);
assert!(out.contains("No longer open"), "{out}");
assert!(out.contains("w2, w3"), "{out}");
}
#[test]
fn render_reload_tolerates_a_reply_missing_every_field() {
assert_eq!(
render_reload(&json!({})),
"Signalled 0 of 0 windows to reload."
);
}
#[test]
fn render_reposition_explains_a_missing_permission() {
let out = render_reposition(&json!({ "trusted": false, "results": [] }));
assert!(out.contains("Accessibility permission"), "{out}");
assert!(out.contains("daemon restart"), "{out}");
}
#[test]
fn render_reposition_reports_a_blocked_batch() {
let out = render_reposition(&json!({
"trusted": true,
"blocked": { "reason": "reference-ambiguous", "detail": "2 windows match “main”" },
"results": [],
}));
assert!(out.contains("Nothing was moved"), "{out}");
assert!(out.contains("reference-ambiguous"), "{out}");
assert!(out.contains("2 windows match"), "{out}");
}
#[test]
fn render_reposition_renders_the_reference_and_per_target_outcomes() {
let out = render_reposition(&json!({
"trusted": true,
"reference": {
"key": "r",
"title": "ref-tree",
"frame": { "x": 10.4, "y": 20.6, "width": 800.0, "height": 600.0 },
},
"moved": 1,
"skipped": 1,
"results": [
{ "key": "a", "title": "a-tree", "outcome": "moved", "detail": "moved into position" },
{ "key": "b", "title": "b-tree", "outcome": "ambiguous", "detail": "2 match" },
],
}));
assert!(
out.contains("Reference: ref-tree 800×600 at (10, 21)"),
"{out}"
);
assert!(out.contains("Moved: 1 / Skipped: 1"), "{out}");
assert!(out.contains("moved: a-tree"), "{out}");
assert!(out.contains("ambiguous: b-tree"), "{out}");
}
#[test]
fn render_reposition_falls_back_to_the_key_and_notes_an_empty_batch() {
let out = render_reposition(&json!({
"trusted": true,
"results": [{ "key": "keyless", "outcome": "no-window", "detail": "gone" }],
}));
assert!(out.contains("no-window: keyless"), "{out}");
let empty = render_reposition(&json!({ "trusted": true, "results": [] }));
assert!(empty.contains("(nothing to report)"), "{empty}");
}
#[test]
fn render_reposition_strips_control_bytes_from_daemon_strings() {
let out = render_reposition(&json!({
"trusted": true,
"results": [{
"key": "k",
"title": "evil\u{1b}[31mred",
"outcome": "moved",
"detail": "ok\u{7}",
}],
}));
assert!(!out.contains('\u{1b}'), "{out:?}");
assert!(!out.contains('\u{7}'), "{out:?}");
}
#[test]
fn render_frame_formats_or_dashes() {
assert_eq!(render_frame(None), "-");
assert_eq!(
render_frame(Some(
&json!({ "x": 1.5, "y": -2.4, "width": 100.0, "height": 50.0 })
)),
"100×50 at (2, -2)"
);
assert_eq!(render_frame(Some(&json!({}))), "0×0 at (0, 0)");
}
#[test]
fn print_reposition_emits_both_formats() {
let reply = json!({ "trusted": true, "results": [], "moved": 0, "skipped": 0 });
print_reposition(TableOrJson::Table, &reply).unwrap();
print_reposition(TableOrJson::Json, &reply).unwrap();
}
#[tokio::test]
async fn reposition_maps_paths_to_window_keys_and_sends_the_op() {
let dir = tempfile::tempdir_in("/tmp").unwrap();
let reference = dir.path().join("ref");
let target = dir.path().join("tgt");
std::fs::create_dir(&reference).unwrap();
std::fs::create_dir(&target).unwrap();
let (canon_ref, canon_tgt) = (
std::fs::canonicalize(&reference).unwrap(),
std::fs::canonicalize(&target).unwrap(),
);
let (_sock_dir, sock, server) = fake_daemon_replies(vec![
json!({ "ok": true, "payload": { "windows": [
{ "key": "ref-key", "folders": [canon_ref.to_string_lossy()] },
{ "key": "tgt-key", "folders": [canon_tgt.to_string_lossy()] },
] } }),
json!({ "ok": true, "payload": {
"trusted": true,
"moved": 1,
"skipped": 0,
"results": [{ "key": "tgt-key", "outcome": "moved", "detail": "moved into position" }],
} }),
]);
RepositionCommand {
paths: vec![target],
reference: Some(reference),
dry_run: false,
undo: false,
output: TableOrJson::Json,
socket: Some(sock),
}
.execute()
.await
.unwrap();
server.await.unwrap();
}
#[tokio::test]
async fn reposition_undo_skips_the_list_lookup_entirely() {
let (_dir, sock, server) = fake_daemon_reply(json!({
"ok": true,
"payload": { "trusted": true, "moved": 2, "skipped": 0, "results": [] },
}));
RepositionCommand {
paths: Vec::new(),
reference: None,
dry_run: false,
undo: true,
output: TableOrJson::Table,
socket: Some(sock),
}
.execute()
.await
.unwrap();
server.await.unwrap();
}
#[tokio::test]
async fn reposition_fails_before_the_op_when_a_target_has_no_window() {
let dir = tempfile::tempdir_in("/tmp").unwrap();
let reference = dir.path().join("ref");
let target = dir.path().join("tgt");
std::fs::create_dir(&reference).unwrap();
std::fs::create_dir(&target).unwrap();
let canon_ref = std::fs::canonicalize(&reference).unwrap();
let (_sock_dir, sock, server) = fake_daemon_reply(json!({
"ok": true,
"payload": { "windows": [
{ "key": "ref-key", "folders": [canon_ref.to_string_lossy()] },
] },
}));
let err = RepositionCommand {
paths: vec![target],
reference: Some(reference),
dry_run: true,
undo: false,
output: TableOrJson::Table,
socket: Some(sock),
}
.execute()
.await
.expect_err("an unopened target must abort the command");
assert!(err.to_string().contains("no VS Code window has"), "{err:#}");
server.await.unwrap();
}
#[tokio::test]
async fn reposition_surfaces_a_daemon_error() {
let (_dir, sock, server) = fake_daemon_reply(json!({
"ok": false,
"error": "unknown worktrees op: reposition",
}));
let err = RepositionCommand {
paths: Vec::new(),
reference: None,
dry_run: false,
undo: true,
output: TableOrJson::Table,
socket: Some(sock),
}
.execute()
.await
.expect_err("an `ok:false` reply must not be reported as success");
assert!(err.to_string().contains("unknown worktrees op"), "{err:#}");
server.await.unwrap();
}
fn reload_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, Value) {
let dir = tempfile::tempdir_in("/tmp").unwrap();
let a = dir.path().join("a");
let b = dir.path().join("b");
std::fs::create_dir(&a).unwrap();
std::fs::create_dir(&b).unwrap();
let list = json!({ "ok": true, "payload": { "windows": [
{ "key": "key-a", "folders": [std::fs::canonicalize(&a).unwrap().to_string_lossy()] },
{ "key": "key-b", "folders": [std::fs::canonicalize(&b).unwrap().to_string_lossy()] },
] } });
(dir, a, b, list)
}
#[tokio::test]
async fn reload_resolves_paths_to_window_keys_before_sending_the_op() {
let (_dir, a, b, list) = reload_fixture();
let (_sock_dir, sock, server) = fake_daemon_seq(vec![
list,
json!({ "ok": true, "payload": {
"requested": 2, "signalled": 2, "unknown": [],
} }),
]);
ReloadCommand {
paths: vec![a, b],
output: TableOrJson::Table,
socket: Some(sock),
}
.execute()
.await
.unwrap();
let requests = server.await.unwrap();
assert_eq!(requests[1]["op"], "reload");
assert_eq!(
requests[1]["payload"]["target_keys"],
json!(["key-a", "key-b"])
);
assert!(
requests[1]["payload"].get("requester_key").is_none(),
"a CLI process is not a window, so it must not claim to be one"
);
}
#[tokio::test]
async fn reload_json_output_passes_the_reply_through_verbatim() {
let (_dir, a, _b, list) = reload_fixture();
let (_sock_dir, sock, server) = fake_daemon_replies(vec![
list,
json!({ "ok": true, "payload": {
"requested": 1, "signalled": 0, "unknown": ["key-a"],
} }),
]);
ReloadCommand {
paths: vec![a],
output: TableOrJson::Json,
socket: Some(sock),
}
.execute()
.await
.unwrap();
server.await.unwrap();
}
#[tokio::test]
async fn reload_fails_before_the_op_when_a_target_has_no_window() {
let (_dir, a, b, _list) = reload_fixture();
let (_sock_dir, sock, server) = fake_daemon_reply(json!({
"ok": true,
"payload": { "windows": [
{ "key": "key-a", "folders": [std::fs::canonicalize(&a).unwrap().to_string_lossy()] },
] },
}));
let err = ReloadCommand {
paths: vec![a, b],
output: TableOrJson::Table,
socket: Some(sock),
}
.execute()
.await
.expect_err("an unopened target must abort the command");
assert!(err.to_string().contains("can be reloaded"), "{err:#}");
server.await.unwrap();
}
#[tokio::test]
async fn reload_surfaces_a_daemon_error() {
let (_dir, a, _b, list) = reload_fixture();
let (_sock_dir, sock, server) = fake_daemon_replies(vec![
list,
json!({ "ok": false, "error": "unknown worktrees op: reload" }),
]);
let err = ReloadCommand {
paths: vec![a],
output: TableOrJson::Table,
socket: Some(sock),
}
.execute()
.await
.expect_err("an `ok:false` reply must not be reported as success");
assert!(err.to_string().contains("unknown worktrees op"), "{err:#}");
server.await.unwrap();
}
}