Skip to main content

omni_dev/cli/
worktrees.rs

1//! `omni-dev worktrees` — a thin client for the daemon's cross-window worktree
2//! registry.
3//!
4//! Lifecycle stays on `omni-dev daemon` (`start`/`stop`/`status`/`restart`);
5//! this command sends the `worktrees` service's ops over the daemon's Unix
6//! control socket: the read views (`list`, `tree`, `tree --follow`), the actions
7//! (`focus`, `close`, `show-closed`), and — for typed parity with the companion
8//! (#1361) — the window feed ops (`register`/`heartbeat`/`unregister`) that let a
9//! scripted/headless reporter or an integration test drive the registry the way
10//! the VS Code extension does from each window.
11
12use std::path::{Path, PathBuf};
13
14use anyhow::{bail, Context, Result};
15use chrono::Utc;
16use clap::{Parser, Subcommand};
17use serde_json::{json, Value};
18
19use crate::cli::format::TableOrJson;
20use crate::daemon::client::DaemonClient;
21use crate::daemon::protocol::{DaemonEnvelope, DaemonReply};
22use crate::daemon::server;
23use crate::git::worktree_batch::Selection;
24use crate::git::worktree_push;
25use crate::git::worktree_rebase::{
26    self, FetchOutcome, RebaseOptions, RebaseResult, SkipReason, WorktreeOutcome,
27};
28
29/// The `worktrees` service routing key on the daemon control socket.
30const SERVICE: &str = "worktrees";
31
32/// Worktrees: see the repos/worktrees open across every VS Code window, kept
33/// live by the daemon.
34#[derive(Parser)]
35pub struct WorktreesCommand {
36    /// The worktrees subcommand to execute.
37    #[command(subcommand)]
38    pub command: WorktreesSubcommands,
39}
40
41/// Worktrees subcommands.
42#[derive(Subcommand)]
43pub enum WorktreesSubcommands {
44    /// List the repos/worktrees currently open across all windows.
45    List(ListCommand),
46    /// Show every repository and all its worktrees, grouped by repository.
47    Tree(TreeCommand),
48    /// Focus (raise) the VS Code window for a worktree folder.
49    Focus(FocusCommand),
50    /// Close a worktree's window and, for a linked worktree, delete it.
51    Close(CloseCommand),
52    /// Rebase worktrees onto the remote default branch, fetching it once per repo.
53    Rebase(RebaseCommand),
54    /// Publish worktrees' branches, force-pushing with a lease where needed.
55    Push(PushCommand),
56    /// Enqueue eligible worktrees' PRs into the GitHub merge queue.
57    MergeQueue(MergeQueueCommand),
58    /// Move and resize worktrees' open windows to match a reference window.
59    Reposition(RepositionCommand),
60    /// Signal worktrees' open windows to reload themselves.
61    Reload(ReloadCommand),
62    /// Show or set whether closed worktrees are shown across all windows.
63    ShowClosed(ShowClosedCommand),
64    /// Register a window's open worktree folders (companion feed op).
65    Register(RegisterCommand),
66    /// Refresh a window's liveness and read any pending close/reload directive.
67    Heartbeat(HeartbeatCommand),
68    /// Remove a window's registration (companion feed op).
69    Unregister(UnregisterCommand),
70}
71
72impl WorktreesCommand {
73    /// Executes the worktrees command.
74    ///
75    /// `repo` is the global `-C/--repo` location, resolved once in [`crate::cli`]
76    /// and threaded down rather than re-read from the ambient CWD. Only `rebase`
77    /// and `push` use it (they are the subcommands that act on the local
78    /// repository); the daemon-client subcommands address worktrees by absolute
79    /// path instead.
80    pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
81        match self.command {
82            WorktreesSubcommands::List(cmd) => cmd.execute().await,
83            WorktreesSubcommands::Tree(cmd) => cmd.execute().await,
84            WorktreesSubcommands::Focus(cmd) => cmd.execute().await,
85            WorktreesSubcommands::Close(cmd) => cmd.execute().await,
86            WorktreesSubcommands::Rebase(cmd) => cmd.execute(repo).await,
87            WorktreesSubcommands::Push(cmd) => cmd.execute(repo).await,
88            WorktreesSubcommands::MergeQueue(cmd) => cmd.execute().await,
89            WorktreesSubcommands::Reposition(cmd) => cmd.execute().await,
90            WorktreesSubcommands::Reload(cmd) => cmd.execute().await,
91            WorktreesSubcommands::ShowClosed(cmd) => cmd.execute().await,
92            WorktreesSubcommands::Register(cmd) => cmd.execute().await,
93            WorktreesSubcommands::Heartbeat(cmd) => cmd.execute().await,
94            WorktreesSubcommands::Unregister(cmd) => cmd.execute().await,
95        }
96    }
97}
98
99/// Lists the live cross-window set of open worktrees/repos.
100#[derive(Parser)]
101pub struct ListCommand {
102    /// Control-socket path. Defaults to the per-user runtime location.
103    #[arg(long, value_name = "PATH")]
104    pub socket: Option<PathBuf>,
105    /// Output format.
106    #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
107    pub output: TableOrJson,
108    /// Deprecated: use `-o`/`--output json` instead.
109    #[arg(long, hide = true)]
110    pub json: bool,
111}
112
113impl ListCommand {
114    /// Executes the list command.
115    pub async fn execute(mut self) -> Result<()> {
116        if self.json {
117            eprintln!("warning: --json is deprecated; use -o/--output json instead");
118            self.output = TableOrJson::Json;
119        }
120        let socket = server::resolve_socket(self.socket)?;
121        let result = call(&socket, "list", Value::Null).await?;
122        match self.output {
123            TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
124            TableOrJson::Table => println!("{}", render_windows(&result)),
125        }
126        Ok(())
127    }
128}
129
130/// Shows every repository and all of its worktrees (open or not), grouped by
131/// repository — the daemon's `tree` op, which derives the repos from the open
132/// windows and enumerates each repo's worktrees.
133#[derive(Parser)]
134pub struct TreeCommand {
135    /// Control-socket path. Defaults to the per-user runtime location.
136    #[arg(long, value_name = "PATH")]
137    pub socket: Option<PathBuf>,
138    /// Output format.
139    #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
140    pub output: TableOrJson,
141    /// Stream live snapshots: re-render on every change until interrupted
142    /// (Ctrl-C). Uses the daemon's `subscribe` push op.
143    #[arg(short = 'f', long)]
144    pub follow: bool,
145}
146
147impl TreeCommand {
148    /// Executes the tree command.
149    pub async fn execute(self) -> Result<()> {
150        let socket = server::resolve_socket(self.socket)?;
151        if self.follow {
152            return follow_tree_stream(&socket, self.output).await;
153        }
154        let mut result = call(&socket, "tree", Value::Null).await?;
155        // Ahead/behind is no longer part of the (cheap) streamed `tree` snapshot
156        // (#1306); fetch it on demand for the worktrees we are about to render and
157        // fold it back in, so `worktrees tree` shows the same `+ahead -behind` sync
158        // state as before. Best-effort: an older daemon without the `ahead-behind`
159        // op just renders `-`.
160        enrich_ahead_behind(&socket, &mut result).await;
161        match self.output {
162            TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
163            TableOrJson::Table => println!("{}", render_tree(&result)),
164        }
165        Ok(())
166    }
167}
168
169/// Follows the daemon's `subscribe` push stream, re-rendering the tree on each
170/// snapshot until the daemon closes the stream or the user interrupts (Ctrl-C).
171///
172/// Each frame is enriched with on-demand ahead/behind, exactly like the one-shot
173/// path, so a followed view — table **or** JSON — carries the same shape as a
174/// plain `tree` (the JSON stream stays one compact NDJSON frame per snapshot).
175async fn follow_tree_stream(socket: &Path, output: TableOrJson) -> Result<()> {
176    let mut sub = DaemonClient::new(socket)
177        .subscribe(DaemonEnvelope::service(SERVICE, "subscribe", Value::Null))
178        .await?;
179    loop {
180        tokio::select! {
181            frame = sub.next() => {
182                // `None` = the daemon closed the stream (shutdown); we are done.
183                let Some(frame) = frame else { break };
184                let mut payload = reply_payload(frame?)?;
185                // Enrich before either renderer so `tree --follow` matches the
186                // one-shot `tree` byte-for-byte in JSON and column-for-column in
187                // the table (the one-shot enriches ahead of both branches too).
188                enrich_ahead_behind(socket, &mut payload).await;
189                match output {
190                    // A compact one-line frame per snapshot (an NDJSON stream).
191                    TableOrJson::Json => println!("{}", serde_json::to_string(&payload)?),
192                    TableOrJson::Table => println!("{}", render_tree(&payload)),
193                }
194            }
195            // Ctrl-C ends the follow; dropping `sub` closes the connection,
196            // which the daemon reads as the stream's teardown.
197            _ = tokio::signal::ctrl_c() => break,
198        }
199    }
200    Ok(())
201}
202
203/// Focuses (raises) the VS Code window for a worktree folder.
204///
205/// Reuses the daemon's `open` op — the same launcher path the macOS tray's
206/// per-window "focus" action drives (`OMNI_DEV_VSCODE_BIN` → well-known paths →
207/// `code`), which VS Code uses to reuse an already-open window. This makes that
208/// tray-only capability reachable from the CLI on Linux/headless too (#1113).
209#[derive(Parser)]
210pub struct FocusCommand {
211    /// Worktree folder whose window to focus. Shown by `worktrees tree`/`list`.
212    #[arg(value_name = "PATH")]
213    pub path: PathBuf,
214    /// Control-socket path. Defaults to the per-user runtime location.
215    #[arg(long, value_name = "PATH")]
216    pub socket: Option<PathBuf>,
217}
218
219impl FocusCommand {
220    /// Executes the focus command.
221    pub async fn execute(self) -> Result<()> {
222        // Resolve to an absolute path client-side: the daemon runs in a different
223        // cwd and guards the `open` path as absolute-and-existing, so a relative
224        // path would be meaningless there. A clear error here beats the daemon's.
225        let path = std::fs::canonicalize(&self.path)
226            .with_context(|| format!("cannot resolve worktree path: {}", self.path.display()))?;
227        let socket = server::resolve_socket(self.socket)?;
228        call(&socket, "open", json!({ "path": path.to_string_lossy() })).await?;
229        println!("Focused {}", path.display());
230        Ok(())
231    }
232}
233
234/// Closes a worktree's window and, for a linked worktree, deletes it — the
235/// daemon's two-phase `close` op driven from the CLI.
236///
237/// A CLI process is never a VS Code window, so it omits `requester_key`: the
238/// daemon then treats the close as cross-window, signalling every owning window
239/// to close and waiting (bounded ~20s) for them to unregister before it prunes.
240/// All destructive/git logic (the `git2` prune, the main-tree refusal) stays in
241/// the daemon (ADR-0049); the CLI adds no new authority.
242#[derive(Parser)]
243pub struct CloseCommand {
244    /// Worktree folder to close. A linked worktree is deleted; the main working
245    /// tree only has its window closed (never deleted).
246    #[arg(value_name = "PATH")]
247    pub path: PathBuf,
248    /// Only close the worktree's window(s); never delete the worktree.
249    #[arg(long)]
250    pub window_only: bool,
251    /// Run the safety check and print the report, but do not close or delete.
252    #[arg(long)]
253    pub dry_run: bool,
254    /// Skip the interactive confirmation before deleting.
255    #[arg(short = 'y', long)]
256    pub yes: bool,
257    /// Control-socket path. Defaults to the per-user runtime location.
258    #[arg(long, value_name = "PATH")]
259    pub socket: Option<PathBuf>,
260}
261
262impl CloseCommand {
263    /// Executes the close command, confirming a delete interactively via stdin.
264    pub async fn execute(self) -> Result<()> {
265        self.execute_with(confirm_removal).await
266    }
267
268    /// The close core, with the destructive-confirm decision injected as
269    /// `confirm(has_risks) -> bool`. Splitting it this way keeps the abort and
270    /// confirmed-execute branches unit-testable without driving real stdin (which
271    /// would block a test on a TTY); production wires in [`confirm_removal`].
272    async fn execute_with<F, Fut>(self, confirm: F) -> Result<()>
273    where
274        F: FnOnce(bool) -> Fut,
275        Fut: std::future::Future<Output = bool>,
276    {
277        // Resolve to an absolute path client-side (like `focus`): the daemon runs
278        // in a different cwd and matches the target by canonical path.
279        let path = std::fs::canonicalize(&self.path)
280            .with_context(|| format!("cannot resolve worktree path: {}", self.path.display()))?;
281        let path_str = path.to_string_lossy().to_string();
282        let socket = server::resolve_socket(self.socket)?;
283
284        // "Close Window": non-destructive, no safety check — the daemon closes the
285        // owning window(s) and never inspects git. `--dry-run` is honoured here
286        // too, so the combination never has a side effect.
287        if self.window_only {
288            if self.dry_run {
289                println!(
290                    "Would close the window for {} (dry run; nothing closed)",
291                    path.display()
292                );
293                return Ok(());
294            }
295            call(
296                &socket,
297                "close",
298                json!({ "path": path_str, "remove": false }),
299            )
300            .await?;
301            println!("Closed the window for {}", path.display());
302            return Ok(());
303        }
304
305        // Phase 1: the side-effect-free safety check (remove:true, unconfirmed).
306        let report = call(
307            &socket,
308            "close",
309            json!({ "path": path_str, "remove": true }),
310        )
311        .await?;
312        println!("{}", render_safety_report(&path, &report));
313
314        if self.dry_run {
315            return Ok(());
316        }
317        // The daemon refuses to remove the main working tree; fail fast rather than
318        // send a phase-2 execute it would reject.
319        if report.get("removable").and_then(Value::as_bool) != Some(true) {
320            bail!(
321                "{} is not a removable worktree (nothing deleted); \
322                 use --window-only to just close its window",
323                path.display()
324            );
325        }
326        let has_risks = report
327            .get("risks")
328            .and_then(Value::as_array)
329            .is_some_and(|r| !r.is_empty());
330        if !self.yes && !confirm(has_risks).await {
331            println!("Aborted; nothing was deleted.");
332            return Ok(());
333        }
334
335        // Phase 2: execute the delete.
336        call(
337            &socket,
338            "close",
339            json!({ "path": path_str, "remove": true, "confirmed": true }),
340        )
341        .await?;
342        println!("Deleted worktree {}", path.display());
343        Ok(())
344    }
345}
346
347/// Rebases worktrees onto the repository's remote default branch, fetching that
348/// branch **exactly once per repository** (#1400).
349///
350/// Unlike every other `worktrees` subcommand this runs **entirely locally** and
351/// never talks to the daemon — **by choice, not by necessity** (ADR-0059). The
352/// daemon hosts the same engine behind its two-phase `rebase` op, which is what
353/// the tree view's "Rebase on main" drives; keeping this command local is what
354/// makes a batch rebase work with **no daemon running at all**, and keeps
355/// `--onto`/`--all` (CLI-only concerns) out of the wire protocol. The git work
356/// lives in [`crate::git::worktree_rebase`]; see ADR-0059, ADR-0055, ADR-0003.
357///
358/// A rebase rewrites branch history, so it confirms by default (`--dry-run` to
359/// preview, `-y` to skip the prompt) in the spirit of ADR-0027.
360#[derive(Parser)]
361pub struct RebaseCommand {
362    /// Worktree folders to rebase. Omit these and pass `--all` to rebase every
363    /// worktree of the current repository, including its main working tree.
364    #[arg(value_name = "PATH")]
365    pub paths: Vec<PathBuf>,
366    /// Rebase every worktree of the current repository, including its main
367    /// working tree.
368    #[arg(long)]
369    pub all: bool,
370    /// Rebase onto this ref instead of the remote default branch. A
371    /// `<remote>/<branch>` value is still fetched once up front.
372    #[arg(long, value_name = "REF")]
373    pub onto: Option<String>,
374    /// Stash uncommitted changes around each rebase instead of skipping the
375    /// worktree.
376    #[arg(long)]
377    pub autostash: bool,
378    /// Fetch and report what would be rebased, but rebase nothing.
379    #[arg(long)]
380    pub dry_run: bool,
381    /// Leave a conflicting worktree mid-rebase to resolve in place, instead of
382    /// aborting it back to its previous state.
383    #[arg(long)]
384    pub keep_conflicts: bool,
385    /// Skip the interactive confirmation before rebasing.
386    #[arg(short = 'y', long)]
387    pub yes: bool,
388    /// Output format.
389    #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
390    pub output: TableOrJson,
391}
392
393impl RebaseCommand {
394    /// Executes the rebase command, confirming interactively via stdin.
395    pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
396        self.execute_with(repo, confirm_rebase).await
397    }
398
399    /// The rebase core, with the confirm decision injected as
400    /// `confirm(pending) -> bool`. Splitting it this way keeps the abort and
401    /// confirmed branches unit-testable without driving real stdin (which would
402    /// block a test on a TTY); production wires in [`confirm_rebase`].
403    async fn execute_with<F, Fut>(self, repo: Option<&Path>, confirm: F) -> Result<()>
404    where
405        F: FnOnce(usize) -> Fut,
406        Fut: std::future::Future<Output = bool>,
407    {
408        let selection = self.selection(repo)?;
409        let opts = RebaseOptions {
410            onto: self.onto.clone(),
411            autostash: self.autostash,
412            dry_run: self.dry_run,
413            keep_conflicts: self.keep_conflicts,
414            // Resolved by the engine. Unlike the daemon, the CLI runs in the
415            // user's shell with their own `PATH`, so the well-known-path probe is
416            // belt-and-braces here rather than load-bearing.
417            git_bin: None,
418        };
419
420        // Planning shells out to `git fetch` (once per repo) and walks the object
421        // database, so it runs on a blocking thread rather than an async worker.
422        let plan_opts = opts.clone();
423        let plan =
424            tokio::task::spawn_blocking(move || worktree_rebase::plan(&selection, &plan_opts))
425                .await
426                .context("rebase planning task panicked")??;
427
428        let json = matches!(self.output, TableOrJson::Json);
429        // A dry run, or a plan with nothing left to do, reports and stops. The
430        // fetch has still happened — that is what pins the snapshot every worktree
431        // was measured against.
432        if self.dry_run || !plan.has_pending_rebases() {
433            self.print(json, &plan.fetches, &plan.worktrees)?;
434            return Ok(());
435        }
436
437        // Show what is about to happen, then confirm: a rebase rewrites history.
438        if !json {
439            println!("{}", render_fetches(&plan.fetches));
440            println!("{}", render_outcomes(&plan.worktrees));
441        }
442        let pending = plan.worktrees.iter().filter(|w| is_pending(w)).count();
443        if !self.yes && !confirm(pending).await {
444            println!("Aborted; no worktree was rebased.");
445            return Ok(());
446        }
447
448        let fetches = plan.fetches.clone();
449        let outcomes = tokio::task::spawn_blocking(move || worktree_rebase::execute(plan, &opts))
450            .await
451            .context("rebase task panicked")?;
452        if !json {
453            println!();
454        }
455        self.print(json, &fetches, &outcomes)
456    }
457
458    /// Resolves the CLI's target selection, rejecting an empty one rather than
459    /// silently rebasing everything.
460    ///
461    /// `repo` is the global `-C/--repo` location: it is both the repository
462    /// `--all` enumerates and the base that relative `<PATH>` arguments resolve
463    /// against, so the command behaves "as if started in `<PATH>`". With no
464    /// `-C` the base is `.`, which git resolves against the process CWD.
465    fn selection(&self, repo: Option<&Path>) -> Result<Selection> {
466        let base = repo.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
467        if self.all {
468            if !self.paths.is_empty() {
469                bail!("pass either <PATH>... or --all, not both");
470            }
471            return Ok(Selection::All { base });
472        }
473        if self.paths.is_empty() {
474            bail!(
475                "specify one or more <PATH> arguments, or --all to rebase \
476                 every worktree of this repository"
477            );
478        }
479        let paths = self
480            .paths
481            .iter()
482            .map(|path| {
483                if path.is_absolute() {
484                    path.clone()
485                } else {
486                    base.join(path)
487                }
488            })
489            .collect();
490        Ok(Selection::Paths(paths))
491    }
492
493    /// Prints a report as either pretty JSON or the human table.
494    fn print(
495        &self,
496        json: bool,
497        fetches: &[FetchOutcome],
498        outcomes: &[WorktreeOutcome],
499    ) -> Result<()> {
500        if json {
501            let value = json!({
502                "dry_run": self.dry_run,
503                "fetches": fetches,
504                "worktrees": outcomes,
505            });
506            println!("{}", serde_json::to_string_pretty(&value)?);
507        } else {
508            println!("{}", render_fetches(fetches));
509            println!("{}", render_outcomes(outcomes));
510        }
511        Ok(())
512    }
513}
514
515/// Whether an outcome is still awaiting a rebase (drives the confirm count).
516fn is_pending(outcome: &WorktreeOutcome) -> bool {
517    matches!(outcome.result, RebaseResult::WouldRebase { .. })
518}
519
520/// Renders the per-repository fetch lines — one per repo, which is the visible
521/// proof of the fetch-once-per-repo contract.
522fn render_fetches(fetches: &[FetchOutcome]) -> String {
523    if fetches.is_empty() {
524        return "No repository selected.".to_string();
525    }
526    fetches
527        .iter()
528        .map(fetch_line)
529        .collect::<Vec<_>>()
530        .join("\n")
531}
532
533/// One repository's fetch line.
534fn fetch_line(fetch: &FetchOutcome) -> String {
535    let root = sanitize(&fetch.repo_root.display().to_string());
536    let onto = sanitize(&fetch.onto);
537    if !fetch.fetched {
538        return format!("Using {onto} in {root} (local ref; nothing fetched)");
539    }
540    if fetch.ok {
541        format!("Fetched {onto} once for {root}")
542    } else {
543        let detail = brief(fetch.detail.as_deref().unwrap_or(""));
544        format!("Fetch of {onto} FAILED for {root}: {detail}")
545    }
546}
547
548/// Renders the per-worktree result table.
549fn render_outcomes(outcomes: &[WorktreeOutcome]) -> String {
550    if outcomes.is_empty() {
551        return "No worktrees selected.".to_string();
552    }
553    let mut out = format!(
554        "{:<12} {:<24} {:<16} {}",
555        "STATUS", "BRANCH", "ONTO", "WORKTREE"
556    );
557    for outcome in outcomes {
558        out.push('\n');
559        out.push_str(&outcome_row(outcome));
560    }
561    out
562}
563
564/// One worktree row: status, branch, target ref, path, and a parenthesised detail.
565fn outcome_row(outcome: &WorktreeOutcome) -> String {
566    let (status, detail) = status_and_detail(&outcome.result);
567    let branch = sanitize(outcome.branch.as_deref().unwrap_or("-"));
568    let onto = sanitize(&outcome.onto);
569    let path = sanitize(&outcome.path.display().to_string());
570    let suffix = if detail.is_empty() {
571        String::new()
572    } else {
573        format!("  ({detail})")
574    };
575    format!("{status:<12} {branch:<24} {onto:<16} {path}{suffix}")
576}
577
578/// The status word and human detail for one outcome.
579fn status_and_detail(result: &RebaseResult) -> (&'static str, String) {
580    match result {
581        RebaseResult::Rebased { behind } => ("rebased", format!("was {behind} behind")),
582        RebaseResult::WouldRebase { behind } => ("would-rebase", format!("{behind} behind")),
583        RebaseResult::UpToDate => ("up-to-date", String::new()),
584        RebaseResult::Skipped { reason } => ("skipped", skip_reason_text(*reason).to_string()),
585        // A left-in-place conflict is a *different instruction to the user* than an
586        // aborted one — the worktree is still mid-rebase and needs finishing — so
587        // the row says so instead of only quoting git's error.
588        RebaseResult::Conflict {
589            detail,
590            left_in_place: true,
591        } => (
592            "conflict",
593            format!(
594                "left in place; resolve then `git rebase --continue`: {}",
595                brief(detail)
596            ),
597        ),
598        RebaseResult::Conflict { detail, .. } => ("conflict", brief(detail)),
599        RebaseResult::FetchFailed { detail } => ("fetch-failed", brief(detail)),
600    }
601}
602
603/// The human explanation for why a worktree was skipped.
604fn skip_reason_text(reason: SkipReason) -> &'static str {
605    match reason {
606        SkipReason::DetachedHead => "detached HEAD",
607        SkipReason::Dirty => "uncommitted changes; pass --autostash",
608        SkipReason::OperationInProgress => "a rebase/merge is already in progress",
609        SkipReason::NotAWorktree => "not a git worktree",
610        SkipReason::NoOntoRef => "could not resolve the target ref",
611    }
612}
613
614/// A one-line, control-character-free, length-capped summary of a multi-line git
615/// error, so a long conflict message cannot wreck the table layout.
616fn brief(detail: &str) -> String {
617    let first = detail
618        .lines()
619        .find(|line| !line.trim().is_empty())
620        .unwrap_or("");
621    let clean = sanitize(first.trim());
622    if clean.chars().count() > 100 {
623        let truncated: String = clean.chars().take(97).collect();
624        format!("{truncated}...")
625    } else {
626        clean
627    }
628}
629
630/// Prompts on stderr before rewriting branch history, reading from real stdin.
631async fn confirm_rebase(pending: usize) -> bool {
632    confirm_rebase_with(pending, read_stdin_line()).await
633}
634
635/// Prints the rebase confirmation prompt and resolves the (injected) read into a
636/// yes/no decision. Any read error, EOF, or join failure is treated as "no", so a
637/// rebase never proceeds unattended.
638async fn confirm_rebase_with(
639    pending: usize,
640    read: impl std::future::Future<Output = Option<String>>,
641) -> bool {
642    use std::io::Write;
643    eprint!("{}", rebase_prompt(pending));
644    let _ = std::io::stderr().flush();
645    read.await.as_deref().is_some_and(answer_is_yes)
646}
647
648/// The confirmation prompt, naming how many worktrees would be rewritten. Pure, so
649/// the wording is unit-testable.
650fn rebase_prompt(pending: usize) -> String {
651    let noun = if pending == 1 {
652        "worktree"
653    } else {
654        "worktrees"
655    };
656    format!("Rebase {pending} {noun} (this rewrites branch history)? [y/N] ")
657}
658
659// --- worktrees push (#1443) --------------------------------------------------
660
661/// Publishes worktrees' branches to their upstreams, force-pushing **with a
662/// lease** where a rebase rewrote history (#1443).
663///
664/// The complement of [`RebaseCommand`], and local for the same reason: the daemon
665/// hosts the same engine behind its two-phase `push` op (which is what the tree
666/// view's **Push (force-with-lease)** drives), and keeping this command local is
667/// what makes a batch push work with **no daemon running at all**, and keeps
668/// `--all` out of the wire protocol. The git work lives in
669/// [`crate::git::worktree_push`]; see ADR-0061, ADR-0059, ADR-0003.
670///
671/// There is deliberately **no `--force`**: every non-fast-forward goes out as
672/// `--force-with-lease --force-if-includes`, and a refused lease is reported with
673/// `git fetch` named as the fix. There is likewise no remote override — a branch
674/// publishes to its own upstream's remote.
675///
676/// A force-push publishes rewritten history, so it confirms by default
677/// (`--dry-run` to preview, `-y` to skip the prompt) in the spirit of ADR-0027.
678#[derive(Parser)]
679pub struct PushCommand {
680    /// Worktree folders to publish. Omit these and pass `--all` to publish every
681    /// worktree of the current repository, including its main working tree.
682    #[arg(value_name = "PATH")]
683    pub paths: Vec<PathBuf>,
684    /// Publish every worktree of the current repository, including its main
685    /// working tree.
686    #[arg(long)]
687    pub all: bool,
688    /// Report what would be published, but push nothing.
689    #[arg(long)]
690    pub dry_run: bool,
691    /// Skip the interactive confirmation before pushing.
692    #[arg(short = 'y', long)]
693    pub yes: bool,
694    /// Output format.
695    #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
696    pub output: TableOrJson,
697}
698
699impl PushCommand {
700    /// Executes the push command, confirming interactively via stdin.
701    pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
702        self.execute_with(repo, confirm_push).await
703    }
704
705    /// The push core, with the confirm decision injected as
706    /// `confirm(pending, forced) -> bool` — the [`RebaseCommand::execute_with`]
707    /// split, so the abort and confirmed branches are unit-testable without
708    /// driving real stdin.
709    async fn execute_with<F, Fut>(self, repo: Option<&Path>, confirm: F) -> Result<()>
710    where
711        F: FnOnce(usize, usize) -> Fut,
712        Fut: std::future::Future<Output = bool>,
713    {
714        let selection = self.selection(repo)?;
715
716        // Planning walks each worktree's object database, so it runs on a blocking
717        // thread rather than an async worker. Unlike the rebase plan it contacts
718        // no remote at all (ADR-0061): the classification is against the local
719        // remote-tracking ref, which is exactly what the lease is checked against.
720        let plan = tokio::task::spawn_blocking(move || worktree_push::plan(&selection))
721            .await
722            .context("push planning task panicked")??;
723
724        let json = matches!(self.output, TableOrJson::Json);
725        // A dry run, or a plan with nothing left to publish, reports and stops.
726        if self.dry_run || !plan.has_pending_pushes() {
727            return self.print(json, &plan.worktrees);
728        }
729
730        // Show what is about to happen, then confirm: a force-push publishes a
731        // rewrite to everyone who has the branch.
732        if !json {
733            println!("{}", render_push_outcomes(&plan.worktrees));
734        }
735        let pending = plan
736            .worktrees
737            .iter()
738            .filter(|w| w.result.is_pending())
739            .count();
740        let forced = plan
741            .worktrees
742            .iter()
743            .filter(|w| matches!(w.result, worktree_push::PushResult::WouldForce { .. }))
744            .count();
745        if !self.yes && !confirm(pending, forced).await {
746            println!("Aborted; nothing was pushed.");
747            return Ok(());
748        }
749
750        // The CLI runs in the user's shell with their own `PATH`, so the engine's
751        // well-known-path probe is belt-and-braces here rather than load-bearing
752        // (unlike the daemon, which must resolve `git` explicitly).
753        let opts = worktree_push::PushOptions::default();
754        let outcomes = tokio::task::spawn_blocking(move || worktree_push::execute(plan, &opts))
755            .await
756            .context("push task panicked")?;
757        if !json {
758            println!();
759        }
760        self.print(json, &outcomes)
761    }
762
763    /// Resolves the CLI's target selection, rejecting an empty one rather than
764    /// silently publishing everything. Mirrors [`RebaseCommand::selection`].
765    fn selection(&self, repo: Option<&Path>) -> Result<Selection> {
766        let base = repo.map_or_else(|| PathBuf::from("."), Path::to_path_buf);
767        if self.all {
768            if !self.paths.is_empty() {
769                bail!("pass either <PATH>... or --all, not both");
770            }
771            return Ok(Selection::All { base });
772        }
773        if self.paths.is_empty() {
774            bail!(
775                "specify one or more <PATH> arguments, or --all to publish \
776                 every worktree of this repository"
777            );
778        }
779        let paths = self
780            .paths
781            .iter()
782            .map(|path| {
783                if path.is_absolute() {
784                    path.clone()
785                } else {
786                    base.join(path)
787                }
788            })
789            .collect();
790        Ok(Selection::Paths(paths))
791    }
792
793    /// Prints a report as either pretty JSON or the human table.
794    fn print(&self, json: bool, outcomes: &[worktree_push::WorktreeOutcome]) -> Result<()> {
795        if json {
796            let value = json!({ "dry_run": self.dry_run, "worktrees": outcomes });
797            println!("{}", serde_json::to_string_pretty(&value)?);
798        } else {
799            println!("{}", render_push_outcomes(outcomes));
800        }
801        Ok(())
802    }
803}
804
805/// Renders the per-worktree push result table.
806fn render_push_outcomes(outcomes: &[worktree_push::WorktreeOutcome]) -> String {
807    if outcomes.is_empty() {
808        return "No worktrees selected.".to_string();
809    }
810    let mut out = format!(
811        "{:<14} {:<24} {:<20} {}",
812        "STATUS", "BRANCH", "REMOTE", "WORKTREE"
813    );
814    for outcome in outcomes {
815        out.push('\n');
816        out.push_str(&push_outcome_row(outcome));
817    }
818    out
819}
820
821/// One worktree row: status, branch, destination, path, and a parenthesised detail.
822fn push_outcome_row(outcome: &worktree_push::WorktreeOutcome) -> String {
823    let (status, detail) = push_status_and_detail(&outcome.result);
824    let branch = sanitize(outcome.branch.as_deref().unwrap_or("-"));
825    let destination = if outcome.remote.is_empty() {
826        "-".to_string()
827    } else {
828        sanitize(&format!("{}/{}", outcome.remote, outcome.remote_branch))
829    };
830    let path = sanitize(&outcome.path.display().to_string());
831    let suffix = if detail.is_empty() {
832        String::new()
833    } else {
834        format!("  ({detail})")
835    };
836    format!("{status:<14} {branch:<24} {destination:<20} {path}{suffix}")
837}
838
839/// The status word and human detail for one push outcome.
840fn push_status_and_detail(result: &worktree_push::PushResult) -> (&'static str, String) {
841    use worktree_push::PushResult;
842    match result {
843        PushResult::UpToDate => ("up-to-date", String::new()),
844        PushResult::WouldFastForward { ahead } => {
845            ("would-push", format!("{ahead} ahead; fast-forward"))
846        }
847        PushResult::WouldForce { ahead, behind } => (
848            "would-force",
849            format!("{ahead} ahead, {behind} behind; needs --force-with-lease"),
850        ),
851        PushResult::WouldCreate => ("would-create", "no upstream yet".to_string()),
852        PushResult::Pushed { forced: true } => ("pushed", "forced with lease".to_string()),
853        PushResult::Pushed { forced: false } => ("pushed", "fast-forward".to_string()),
854        PushResult::Created => ("created", "upstream set".to_string()),
855        // A refused lease is a *different instruction to the user* than any other
856        // rejection: the remote moved, so the fix is to integrate their work — never
857        // a harder push, which this command has no way to make anyway.
858        PushResult::Rejected { detail, stale: true } => (
859            "rejected",
860            format!(
861                "the remote moved since you last fetched; run `git fetch` and rebase, then retry: {}",
862                brief(detail)
863            ),
864        ),
865        PushResult::Rejected { detail, .. } => ("rejected", brief(detail)),
866        PushResult::Skipped { reason } => ("skipped", push_skip_reason_text(*reason).to_string()),
867    }
868}
869
870/// The human explanation for why a worktree was not published.
871fn push_skip_reason_text(reason: worktree_push::SkipReason) -> &'static str {
872    use worktree_push::SkipReason;
873    match reason {
874        SkipReason::DetachedHead => "detached HEAD",
875        SkipReason::NotAWorktree => "not a git worktree",
876        SkipReason::NoRemote => "no remote to publish to",
877        SkipReason::DefaultBranchForcePush => {
878            "refusing to force-push the remote default branch; \
879             fast-forward it or open a PR instead"
880        }
881    }
882}
883
884/// Prompts on stderr before publishing, reading from real stdin.
885async fn confirm_push(pending: usize, forced: usize) -> bool {
886    confirm_push_with(pending, forced, read_stdin_line()).await
887}
888
889/// Prints the push confirmation prompt and resolves the (injected) read into a
890/// yes/no decision. Any read error, EOF, or join failure is treated as "no", so a
891/// force-push never proceeds unattended.
892async fn confirm_push_with(
893    pending: usize,
894    forced: usize,
895    read: impl std::future::Future<Output = Option<String>>,
896) -> bool {
897    use std::io::Write;
898    eprint!("{}", push_prompt(pending, forced));
899    let _ = std::io::stderr().flush();
900    read.await.as_deref().is_some_and(answer_is_yes)
901}
902
903/// The confirmation prompt. Pure, so the wording is unit-testable.
904///
905/// The force count is called out separately from the total: publishing a
906/// fast-forward and publishing a rewrite are different acts, and the number that
907/// matters when deciding is how many branches other people may already have.
908fn push_prompt(pending: usize, forced: usize) -> String {
909    let noun = if pending == 1 { "branch" } else { "branches" };
910    if forced == 0 {
911        return format!("Push {pending} {noun}? [y/N] ");
912    }
913    let forced_noun = if forced == 1 { "one" } else { "them" };
914    format!(
915        "Push {pending} {noun}, force-pushing {forced} with a lease \
916         (this publishes rewritten history — anyone who has {forced_noun} \
917         will need to reset)? [y/N] "
918    )
919}
920
921/// Enqueues eligible worktrees' PRs into the GitHub merge queue — the daemon's
922/// two-phase `merge-queue` op driven from the CLI (#1401).
923///
924/// Only worktrees that pass every eligibility gate (clean, committed, pushed, with
925/// an open non-draft, conflict-free, CI-green PR) are enqueued; the rest are
926/// reported as skipped-with-reason. Like `close`, the daemon re-validates on
927/// execute, so the CLI adds no authority — enqueue authenticates through the
928/// user's own `gh`.
929#[derive(Parser)]
930pub struct MergeQueueCommand {
931    /// Worktree folder(s) to consider. Each is canonicalized client-side, as the
932    /// daemon runs in a different cwd and matches targets by canonical path.
933    #[arg(value_name = "PATH", required = true)]
934    pub paths: Vec<PathBuf>,
935    /// Print the eligibility report and exit; never enqueue.
936    #[arg(long)]
937    pub check: bool,
938    /// Skip the interactive confirmation before enqueuing.
939    #[arg(short = 'y', long)]
940    pub yes: bool,
941    /// Control-socket path. Defaults to the per-user runtime location.
942    #[arg(long, value_name = "PATH")]
943    pub socket: Option<PathBuf>,
944}
945
946impl MergeQueueCommand {
947    /// Executes the merge-queue command, confirming the enqueue interactively via
948    /// stdin.
949    pub async fn execute(self) -> Result<()> {
950        self.execute_with(confirm_enqueue).await
951    }
952
953    /// The merge-queue core, with the confirm decision injected as
954    /// `confirm(eligible_count) -> bool`, so the abort and confirmed-execute
955    /// branches are unit-testable without driving real stdin (which would block a
956    /// test on a TTY); production wires in [`confirm_enqueue`].
957    async fn execute_with<F, Fut>(self, confirm: F) -> Result<()>
958    where
959        F: FnOnce(usize) -> Fut,
960        Fut: std::future::Future<Output = bool>,
961    {
962        // Canonicalize every path client-side (like `close`/`focus`): the daemon
963        // runs in a different cwd and matches targets by canonical path.
964        let mut paths = Vec::with_capacity(self.paths.len());
965        for p in &self.paths {
966            let abs = std::fs::canonicalize(p)
967                .with_context(|| format!("cannot resolve worktree path: {}", p.display()))?;
968            paths.push(abs.to_string_lossy().to_string());
969        }
970        let socket = server::resolve_socket(self.socket)?;
971
972        // Phase 1: the side-effect-free eligibility check.
973        let report = call(
974            &socket,
975            "merge-queue",
976            json!({ "paths": paths, "check": true }),
977        )
978        .await?;
979        println!("{}", render_eligibility_report(&report));
980
981        if self.check {
982            return Ok(());
983        }
984        let eligible = report
985            .get("eligible")
986            .and_then(Value::as_array)
987            .map_or(0, Vec::len);
988        if eligible == 0 {
989            println!("Nothing to enqueue.");
990            return Ok(());
991        }
992        if !self.yes && !confirm(eligible).await {
993            println!("Aborted; nothing was enqueued.");
994            return Ok(());
995        }
996
997        // Phase 2: execute the enqueue (the daemon re-validates eligibility).
998        let result = call(
999            &socket,
1000            "merge-queue",
1001            json!({ "paths": paths, "confirmed": true }),
1002        )
1003        .await?;
1004        println!("{}", render_enqueue_result(&result));
1005        Ok(())
1006    }
1007}
1008
1009/// Moves and resizes worktrees' open VS Code windows to match a reference
1010/// window's geometry (#1407).
1011///
1012/// The CLI counterpart of the tree view's "Reposition Windows to Match", and the
1013/// diagnostic surface for it: `--dry-run` reports exactly which OS window each
1014/// worktree resolved to without touching anything, which is how a title-matching
1015/// problem is diagnosed.
1016///
1017/// Paths, not window keys, are the CLI's currency (as for `focus`/`close`), so a
1018/// `list` call up front maps each folder to the key of the window that has it
1019/// open. The daemon does all the OS work — it holds the macOS Accessibility grant,
1020/// which a terminal-launched process would not.
1021#[derive(Parser)]
1022pub struct RepositionCommand {
1023    /// Worktree folders whose windows to move. Each is canonicalized client-side,
1024    /// as the daemon runs in a different cwd and matches by canonical path.
1025    #[arg(value_name = "PATH")]
1026    pub paths: Vec<PathBuf>,
1027    /// The worktree whose window supplies the target position and size. It is
1028    /// never itself moved.
1029    #[arg(
1030        long,
1031        value_name = "PATH",
1032        required_unless_present = "undo",
1033        conflicts_with = "undo"
1034    )]
1035    pub reference: Option<PathBuf>,
1036    /// Report which window each worktree resolves to and stop; move nothing.
1037    #[arg(long, conflicts_with = "undo")]
1038    pub dry_run: bool,
1039    /// Put the windows the last reposition moved back where they were.
1040    #[arg(long)]
1041    pub undo: bool,
1042    /// Output format.
1043    #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
1044    pub output: TableOrJson,
1045    /// Control-socket path. Defaults to the per-user runtime location.
1046    #[arg(long, value_name = "PATH")]
1047    pub socket: Option<PathBuf>,
1048}
1049
1050impl RepositionCommand {
1051    /// Executes the reposition command.
1052    pub async fn execute(self) -> Result<()> {
1053        let output = self.output;
1054        let socket = server::resolve_socket(self.socket)?;
1055        if self.undo {
1056            let reply = call(&socket, "reposition-undo", Value::Null).await?;
1057            return print_reposition(output, &reply);
1058        }
1059        // `required_unless_present` guarantees this on the non-undo path.
1060        let Some(reference) = self.reference.as_deref() else {
1061            bail!("`reposition` requires `--reference <PATH>`");
1062        };
1063
1064        // Resolve folders to the keys of the windows that have them open. The op
1065        // addresses *windows*, since geometry belongs to the OS window rather than
1066        // to the worktree, and only the registry knows which window that is.
1067        let windows = call(&socket, "list", Value::Null).await?;
1068        let reference_key = window_key_for(&windows, reference, "repositioned")?;
1069        let mut target_keys = Vec::with_capacity(self.paths.len());
1070        for path in &self.paths {
1071            target_keys.push(window_key_for(&windows, path, "repositioned")?);
1072        }
1073
1074        let reply = call(
1075            &socket,
1076            "reposition",
1077            json!({
1078                "reference_key": reference_key,
1079                "target_keys": target_keys,
1080                "check": self.dry_run,
1081            }),
1082        )
1083        .await?;
1084        print_reposition(output, &reply)
1085    }
1086}
1087
1088/// Prints a `reposition` / `reposition-undo` reply in the requested format.
1089fn print_reposition(output: TableOrJson, reply: &Value) -> Result<()> {
1090    match output {
1091        TableOrJson::Json => println!("{}", serde_json::to_string_pretty(reply)?),
1092        TableOrJson::Table => println!("{}", render_reposition(reply)),
1093    }
1094    Ok(())
1095}
1096
1097/// Signals worktrees' open VS Code windows to reload themselves (#1417).
1098///
1099/// The CLI counterpart of the tree view's "Reload Window" — the batch form of
1100/// `Developer: Reload Window`, which otherwise has to be run by hand in each
1101/// window in turn.
1102///
1103/// Addresses windows by key like `reposition`, so a `list` call up front maps
1104/// each folder to the key of the window that has it open. Unlike the tree view,
1105/// a worktree with **no** open window is an error rather than a silent skip:
1106/// there the selection is a sweep, here the user named each target explicitly.
1107///
1108/// The daemon only marks a directive per target; each window acts on it on its
1109/// next heartbeat, up to ~10s later. Nothing here waits for that, which is why
1110/// the output says how many windows were *signalled*.
1111#[derive(Parser)]
1112pub struct ReloadCommand {
1113    /// Worktree folders whose windows to reload. Each is canonicalized
1114    /// client-side, as the daemon runs in a different cwd and matches by
1115    /// canonical path.
1116    #[arg(value_name = "PATH", required = true)]
1117    pub paths: Vec<PathBuf>,
1118    /// Output format.
1119    #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
1120    pub output: TableOrJson,
1121    /// Control-socket path. Defaults to the per-user runtime location.
1122    #[arg(long, value_name = "PATH")]
1123    pub socket: Option<PathBuf>,
1124}
1125
1126impl ReloadCommand {
1127    /// Executes the reload command.
1128    pub async fn execute(self) -> Result<()> {
1129        let socket = server::resolve_socket(self.socket)?;
1130        let windows = call(&socket, "list", Value::Null).await?;
1131        let mut target_keys = Vec::with_capacity(self.paths.len());
1132        for path in &self.paths {
1133            target_keys.push(window_key_for(&windows, path, "reloaded")?);
1134        }
1135
1136        let reply = call(&socket, "reload", json!({ "target_keys": target_keys })).await?;
1137        match self.output {
1138            TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&reply)?),
1139            TableOrJson::Table => println!("{}", render_reload(&reply)),
1140        }
1141        Ok(())
1142    }
1143}
1144
1145/// Renders a `reload` reply as a one-line human summary.
1146///
1147/// Says "Signalled", never "Reloaded": the directive rides each window's ~10s
1148/// heartbeat, so when this prints nothing has reloaded yet — and the daemon
1149/// could not observe it if it had, since the window re-registers under the same
1150/// key. Any key the daemon had no live window for is named rather than dropped.
1151fn render_reload(reply: &Value) -> String {
1152    let requested = reply.get("requested").and_then(Value::as_u64).unwrap_or(0);
1153    let signalled = reply.get("signalled").and_then(Value::as_u64).unwrap_or(0);
1154    let unknown: Vec<String> = reply
1155        .get("unknown")
1156        .and_then(Value::as_array)
1157        .map(|keys| {
1158            keys.iter()
1159                .filter_map(Value::as_str)
1160                .map(sanitize)
1161                .collect()
1162        })
1163        .unwrap_or_default();
1164
1165    // The noun agrees with `requested`, the number it sits next to: "1 of 3
1166    // windows", not "1 of 3 window".
1167    let noun = if requested == 1 { "window" } else { "windows" };
1168    let mut out = format!("Signalled {signalled} of {requested} {noun} to reload.");
1169    if !unknown.is_empty() {
1170        // A window that closed between the `list` above and the op landing. Rare
1171        // but real, and never worth swallowing.
1172        out.push_str(&format!(
1173            "\nNo longer open, so not signalled: {}",
1174            unknown.join(", ")
1175        ));
1176    }
1177    out
1178}
1179
1180/// Finds the registry key of the window that has `path` open.
1181///
1182/// Canonicalizes client-side and compares against each window's canonicalized
1183/// folders, the same convention `close`/`focus` use. A worktree with no open
1184/// window is an error rather than a silent skip: the CLI names its targets one by
1185/// one, so an unmatched one is a mistake worth reporting, unlike a multi-select in
1186/// the tree view where a stale row is expected.
1187///
1188/// `verb` is the past participle of the caller's action ("repositioned",
1189/// "reloaded"), so the error names what the user was actually trying to do.
1190fn window_key_for(windows: &Value, path: &Path, verb: &str) -> Result<String> {
1191    let wanted = std::fs::canonicalize(path)
1192        .with_context(|| format!("cannot resolve worktree path: {}", path.display()))?;
1193    windows
1194        .get("windows")
1195        .and_then(Value::as_array)
1196        .map(Vec::as_slice)
1197        .unwrap_or_default()
1198        .iter()
1199        .find(|window| {
1200            window
1201                .get("folders")
1202                .and_then(Value::as_array)
1203                .is_some_and(|folders| {
1204                    folders.iter().filter_map(Value::as_str).any(|folder| {
1205                        std::fs::canonicalize(folder).is_ok_and(|folder| folder == wanted)
1206                    })
1207                })
1208        })
1209        .and_then(|window| window.get("key").and_then(Value::as_str))
1210        .map(ToString::to_string)
1211        .ok_or_else(|| {
1212            anyhow::anyhow!(
1213                "no VS Code window has {} open (only open windows can be {verb})",
1214                wanted.display()
1215            )
1216        })
1217}
1218
1219/// Renders a `reposition` / `reposition-undo` reply as a human-readable report:
1220/// the permission state, the reference geometry, a summary count, and one line per
1221/// target with its outcome.
1222fn render_reposition(reply: &Value) -> String {
1223    if reply.get("trusted").and_then(Value::as_bool) == Some(false) {
1224        return "omni-dev does not hold the macOS Accessibility permission, so no window \
1225                was touched.\nGrant it in System Settings → Privacy & Security → \
1226                Accessibility (add the omni-dev binary), then run `omni-dev daemon restart`."
1227            .to_string();
1228    }
1229    if let Some(blocked) = reply.get("blocked") {
1230        let reason = sanitize(blocked.get("reason").and_then(Value::as_str).unwrap_or("-"));
1231        let detail = sanitize(blocked.get("detail").and_then(Value::as_str).unwrap_or(""));
1232        return format!("Nothing was moved [{reason}]: {detail}");
1233    }
1234
1235    let moved = reply.get("moved").and_then(Value::as_u64).unwrap_or(0);
1236    let skipped = reply.get("skipped").and_then(Value::as_u64).unwrap_or(0);
1237    let mut out = String::new();
1238    if let Some(reference) = reply.get("reference") {
1239        let title = sanitize(
1240            reference
1241                .get("title")
1242                .and_then(Value::as_str)
1243                .unwrap_or("-"),
1244        );
1245        out.push_str(&format!(
1246            "Reference: {title} {}\n",
1247            render_frame(reference.get("frame"))
1248        ));
1249    }
1250    out.push_str(&format!("Moved: {moved} / Skipped: {skipped}"));
1251    let results = reply
1252        .get("results")
1253        .and_then(Value::as_array)
1254        .map(Vec::as_slice)
1255        .unwrap_or_default();
1256    for result in results {
1257        let outcome = sanitize(result.get("outcome").and_then(Value::as_str).unwrap_or("-"));
1258        let title = sanitize(
1259            result
1260                .get("title")
1261                .and_then(Value::as_str)
1262                .or_else(|| result.get("key").and_then(Value::as_str))
1263                .unwrap_or("-"),
1264        );
1265        let detail = sanitize(result.get("detail").and_then(Value::as_str).unwrap_or(""));
1266        out.push_str(&format!("\n  {outcome}: {title} — {detail}"));
1267    }
1268    if results.is_empty() {
1269        out.push_str("\n  (nothing to report)");
1270    }
1271    out
1272}
1273
1274/// Renders a frame as `WxH at (X, Y)`, or `-` when absent.
1275fn render_frame(frame: Option<&Value>) -> String {
1276    let Some(frame) = frame else {
1277        return "-".to_string();
1278    };
1279    let field = |name: &str| {
1280        frame
1281            .get(name)
1282            .and_then(Value::as_f64)
1283            .unwrap_or(0.0)
1284            .round()
1285    };
1286    format!(
1287        "{}×{} at ({}, {})",
1288        field("width"),
1289        field("height"),
1290        field("x"),
1291        field("y")
1292    )
1293}
1294
1295/// Shows or sets the cross-window "show closed worktrees" toggle.
1296///
1297/// With a boolean argument it sets the daemon-backed value (`set-show-closed`),
1298/// which every subscribed window re-reads; with no argument it reads the current
1299/// value from the top-level `show_closed` of a `tree` snapshot.
1300#[derive(Parser)]
1301pub struct ShowClosedCommand {
1302    /// New value (`true`/`false`). Omit to read the current value.
1303    #[arg(value_name = "BOOL", value_parser = clap::builder::BoolishValueParser::new())]
1304    pub value: Option<bool>,
1305    /// Control-socket path. Defaults to the per-user runtime location.
1306    #[arg(long, value_name = "PATH")]
1307    pub socket: Option<PathBuf>,
1308}
1309
1310impl ShowClosedCommand {
1311    /// Executes the show-closed command.
1312    pub async fn execute(self) -> Result<()> {
1313        let socket = server::resolve_socket(self.socket)?;
1314        if let Some(show_closed) = self.value {
1315            call(
1316                &socket,
1317                "set-show-closed",
1318                json!({ "show_closed": show_closed }),
1319            )
1320            .await?;
1321            println!("show-closed: {show_closed}");
1322        } else {
1323            // The value is not a dedicated op — it rides the `tree` snapshot.
1324            let tree = call(&socket, "tree", Value::Null).await?;
1325            let current = tree
1326                .get("show_closed")
1327                .and_then(Value::as_bool)
1328                .unwrap_or(true);
1329            println!("show-closed: {current}");
1330        }
1331        Ok(())
1332    }
1333}
1334
1335/// Registers a window's open worktree folders (a companion feed op).
1336///
1337/// Exposed as a typed command so scripted/headless reporters and integration
1338/// tests can drive the registry the way the VS Code companion does. Mirrors
1339/// `RegisterRequest`.
1340#[derive(Parser)]
1341pub struct RegisterCommand {
1342    /// Stable per-window identity (the companion generates a per-activate UUID).
1343    #[arg(long, value_name = "KEY")]
1344    pub key: String,
1345    /// A workspace-folder path (repeatable).
1346    #[arg(long = "folder", value_name = "PATH")]
1347    pub folders: Vec<PathBuf>,
1348    /// Repository root or name, when the window has one.
1349    // Named `repo_name`, not `repo`, and so spelled `--repo-name`: clap
1350    // propagates a `global = true` arg by **arg id**, and the derive's id is the
1351    // field name. A local `repo` id therefore displaced the global `-C/--repo`
1352    // under this subcommand and its `String` was copied back into the root
1353    // matches, panicking `Cli`'s `PathBuf` read (#1420). Renaming only the long
1354    // spelling would not have been enough. The wire key stays `repo`.
1355    #[arg(long, value_name = "REPO")]
1356    pub repo_name: Option<String>,
1357    /// Window title, for display.
1358    #[arg(long, value_name = "TITLE")]
1359    pub title: Option<String>,
1360    /// Reporting process id.
1361    #[arg(long, value_name = "PID")]
1362    pub pid: Option<u32>,
1363    /// Control-socket path. Defaults to the per-user runtime location.
1364    #[arg(long, value_name = "PATH")]
1365    pub socket: Option<PathBuf>,
1366}
1367
1368impl RegisterCommand {
1369    /// Executes the register command.
1370    pub async fn execute(self) -> Result<()> {
1371        let socket = server::resolve_socket(self.socket)?;
1372        let payload = json!({
1373            "key": self.key,
1374            "folders": self.folders,
1375            "repo": self.repo_name,
1376            "title": self.title,
1377            "pid": self.pid,
1378        });
1379        call(&socket, "register", payload).await?;
1380        println!("Registered {}", self.key);
1381        Ok(())
1382    }
1383}
1384
1385/// Refreshes a window's liveness and reports the daemon's reply.
1386///
1387/// A companion feed op made typed: the reply carries `known` (false asks the
1388/// window to re-register after a daemon restart) and, when present, the
1389/// cross-window directives `close` and `reload`. Both are omitted from the reply
1390/// when nothing is pending, and both read as `false` here when absent.
1391#[derive(Parser)]
1392pub struct HeartbeatCommand {
1393    /// The window key to heartbeat.
1394    #[arg(long, value_name = "KEY")]
1395    pub key: String,
1396    /// Control-socket path. Defaults to the per-user runtime location.
1397    #[arg(long, value_name = "PATH")]
1398    pub socket: Option<PathBuf>,
1399}
1400
1401impl HeartbeatCommand {
1402    /// Executes the heartbeat command.
1403    pub async fn execute(self) -> Result<()> {
1404        let socket = server::resolve_socket(self.socket)?;
1405        let reply = call(&socket, "heartbeat", json!({ "key": self.key })).await?;
1406        let known = reply.get("known").and_then(Value::as_bool).unwrap_or(false);
1407        // Both directives are omitted from the reply when false; treat absent as
1408        // false, which is also what a pre-#1417 daemon's reply reads as.
1409        let close = reply.get("close").and_then(Value::as_bool).unwrap_or(false);
1410        let reload = reply
1411            .get("reload")
1412            .and_then(Value::as_bool)
1413            .unwrap_or(false);
1414        println!("known: {known}");
1415        println!("close: {close}");
1416        println!("reload: {reload}");
1417        Ok(())
1418    }
1419}
1420
1421/// Removes a window's registration — a companion feed op made typed. Prints
1422/// whether an entry was actually removed.
1423#[derive(Parser)]
1424pub struct UnregisterCommand {
1425    /// The window key to unregister.
1426    #[arg(long, value_name = "KEY")]
1427    pub key: String,
1428    /// Control-socket path. Defaults to the per-user runtime location.
1429    #[arg(long, value_name = "PATH")]
1430    pub socket: Option<PathBuf>,
1431}
1432
1433impl UnregisterCommand {
1434    /// Executes the unregister command.
1435    pub async fn execute(self) -> Result<()> {
1436        let socket = server::resolve_socket(self.socket)?;
1437        let reply = call(&socket, "unregister", json!({ "key": self.key })).await?;
1438        let removed = reply
1439            .get("removed")
1440            .and_then(Value::as_bool)
1441            .unwrap_or(false);
1442        println!("removed: {removed}");
1443        Ok(())
1444    }
1445}
1446
1447/// Renders a phase-1 `close` `SafetyReport` as a human-readable block: whether
1448/// the target is removable, whether it is the main tree, whether a window has it
1449/// open (and which), and any `risks`/`info` notes. Every daemon-supplied string is
1450/// `sanitize`d (#1137); the booleans/counts are daemon-computed and safe.
1451fn render_safety_report(path: &Path, report: &Value) -> String {
1452    let removable = report
1453        .get("removable")
1454        .and_then(Value::as_bool)
1455        .unwrap_or(false);
1456    let is_main = report
1457        .get("is_main")
1458        .and_then(Value::as_bool)
1459        .unwrap_or(false);
1460    let open = report.get("open").and_then(Value::as_bool).unwrap_or(false);
1461    let mut out = format!("Worktree: {}", path.display());
1462    out.push_str(&format!("\n  removable:        {removable}"));
1463    out.push_str(&format!("\n  main working tree: {is_main}"));
1464    if open {
1465        let key = sanitize(
1466            report
1467                .get("window_key")
1468                .and_then(Value::as_str)
1469                .unwrap_or("-"),
1470        );
1471        let count = report
1472            .get("window_folder_count")
1473            .and_then(Value::as_u64)
1474            .unwrap_or(0);
1475        out.push_str(&format!(
1476            "\n  open in a window:  yes (key {key}, {count} folder(s))"
1477        ));
1478    } else {
1479        out.push_str("\n  open in a window:  no");
1480    }
1481    out.push_str(&render_notes("risks", report.get("risks")));
1482    out.push_str(&render_notes("info", report.get("info")));
1483    out
1484}
1485
1486/// Renders a labelled list of `close` safety notes (`risks` or `info`), each a
1487/// `- [kind] detail` line with both fields `sanitize`d. Empty when there are none.
1488fn render_notes(label: &str, notes: Option<&Value>) -> String {
1489    let notes = notes
1490        .and_then(Value::as_array)
1491        .map(Vec::as_slice)
1492        .unwrap_or_default();
1493    if notes.is_empty() {
1494        return String::new();
1495    }
1496    let mut out = format!("\n  {label}:");
1497    for note in notes {
1498        let kind = sanitize(note.get("kind").and_then(Value::as_str).unwrap_or("-"));
1499        let detail = sanitize(note.get("detail").and_then(Value::as_str).unwrap_or(""));
1500        out.push_str(&format!("\n    - [{kind}] {detail}"));
1501    }
1502    out
1503}
1504
1505/// Renders a `merge-queue` phase-1 `EligibilityReport`: a summary count, then one
1506/// line per enqueue-eligible worktree (`PR #N [branch] path`) and per
1507/// skipped-with-reason worktree (`[kind] path — detail`). Every daemon-supplied
1508/// string is `sanitize`d (#1137); the counts are daemon-computed and safe.
1509fn render_eligibility_report(report: &Value) -> String {
1510    let eligible = report
1511        .get("eligible")
1512        .and_then(Value::as_array)
1513        .map(Vec::as_slice)
1514        .unwrap_or_default();
1515    let skipped = report
1516        .get("skipped")
1517        .and_then(Value::as_array)
1518        .map(Vec::as_slice)
1519        .unwrap_or_default();
1520    let mut out = format!("Eligible: {} / Skipped: {}", eligible.len(), skipped.len());
1521    for pr in eligible {
1522        let number = pr.get("number").and_then(Value::as_u64).unwrap_or(0);
1523        let branch = sanitize(pr.get("branch").and_then(Value::as_str).unwrap_or("-"));
1524        let path = sanitize(pr.get("path").and_then(Value::as_str).unwrap_or(""));
1525        out.push_str(&format!("\n  eligible: PR #{number} [{branch}] {path}"));
1526    }
1527    for skip in skipped {
1528        let kind = sanitize(skip.get("kind").and_then(Value::as_str).unwrap_or("-"));
1529        let detail = sanitize(skip.get("detail").and_then(Value::as_str).unwrap_or(""));
1530        let path = sanitize(skip.get("path").and_then(Value::as_str).unwrap_or(""));
1531        out.push_str(&format!("\n  skipped [{kind}]: {path} — {detail}"));
1532    }
1533    out
1534}
1535
1536/// Renders a `merge-queue` phase-2 `EnqueueResult`: a summary count, then one line
1537/// per queued PR (`PR #N`, with `(already queued)` for an idempotent no-op) and per
1538/// failed PR (`PR #N — error`). Skips (a worktree that became ineligible between
1539/// phases) are folded into the summary count. Strings are `sanitize`d (#1137).
1540fn render_enqueue_result(result: &Value) -> String {
1541    let queued = result
1542        .get("queued")
1543        .and_then(Value::as_array)
1544        .map(Vec::as_slice)
1545        .unwrap_or_default();
1546    let failed = result
1547        .get("failed")
1548        .and_then(Value::as_array)
1549        .map(Vec::as_slice)
1550        .unwrap_or_default();
1551    let skipped = result
1552        .get("skipped")
1553        .and_then(Value::as_array)
1554        .map_or(0, Vec::len);
1555    let mut out = format!(
1556        "Queued: {} / Failed: {} / Skipped: {}",
1557        queued.len(),
1558        failed.len(),
1559        skipped
1560    );
1561    for pr in queued {
1562        let number = pr.get("number").and_then(Value::as_u64).unwrap_or(0);
1563        let already = pr
1564            .get("already_queued")
1565            .and_then(Value::as_bool)
1566            .unwrap_or(false);
1567        let suffix = if already { " (already queued)" } else { "" };
1568        out.push_str(&format!("\n  queued: PR #{number}{suffix}"));
1569    }
1570    for pr in failed {
1571        let number = pr.get("number").and_then(Value::as_u64).unwrap_or(0);
1572        let error = sanitize(pr.get("error").and_then(Value::as_str).unwrap_or(""));
1573        out.push_str(&format!("\n  failed: PR #{number} — {error}"));
1574    }
1575    out
1576}
1577
1578/// Prompts on stderr for confirmation before a destructive delete and returns
1579/// whether the user assented, reading the answer from real stdin.
1580///
1581/// A thin wrapper over [`confirm_removal_with`] that supplies the live stdin
1582/// reader; the prompt-and-decide logic is factored out so it stays testable
1583/// without driving real stdin.
1584async fn confirm_removal(has_risks: bool) -> bool {
1585    confirm_removal_with(has_risks, read_stdin_line()).await
1586}
1587
1588/// Prints the confirmation prompt and resolves the (already-injected) read of the
1589/// user's answer into a yes/no decision. Any read error, a closed stdin (EOF), or
1590/// a join failure surfaces as `None` and is treated as "no", so a delete never
1591/// proceeds unattended.
1592async fn confirm_removal_with(
1593    has_risks: bool,
1594    read: impl std::future::Future<Output = Option<String>>,
1595) -> bool {
1596    use std::io::Write;
1597    eprint!("{}", confirm_prompt(has_risks));
1598    let _ = std::io::stderr().flush();
1599    read.await.as_deref().is_some_and(answer_is_yes)
1600}
1601
1602/// Prompts on stderr before enqueuing and returns whether the user assented,
1603/// reading the answer from real stdin. A thin wrapper over [`confirm_enqueue_with`]
1604/// supplying the live stdin reader.
1605async fn confirm_enqueue(count: usize) -> bool {
1606    confirm_enqueue_with(count, read_stdin_line()).await
1607}
1608
1609/// Prints the enqueue confirmation prompt and resolves the (already-injected) read
1610/// of the user's answer into a yes/no decision. A read error, closed stdin (EOF),
1611/// or join failure is treated as "no", so an enqueue never proceeds unattended.
1612async fn confirm_enqueue_with(
1613    count: usize,
1614    read: impl std::future::Future<Output = Option<String>>,
1615) -> bool {
1616    use std::io::Write;
1617    eprint!("Add {count} PR(s) to the merge queue? [y/N] ");
1618    let _ = std::io::stderr().flush();
1619    read.await.as_deref().is_some_and(answer_is_yes)
1620}
1621
1622/// Reads one line from stdin on a dedicated thread (`spawn_blocking`) so it never
1623/// stalls an async worker while it waits for input. Returns `None` on any read
1624/// error, EOF, or join failure.
1625async fn read_stdin_line() -> Option<String> {
1626    tokio::task::spawn_blocking(|| read_line_from(&mut std::io::stdin().lock()))
1627        .await
1628        .ok()
1629        .flatten()
1630}
1631
1632/// Reads one line from `reader`, mapping EOF and read errors to the same
1633/// `Option<String>` the stdin caller consumes. Split out of [`read_stdin_line`]
1634/// so the read logic is testable with an in-memory reader — real stdin can't be
1635/// driven from a test without blocking on a TTY.
1636fn read_line_from(reader: &mut impl std::io::BufRead) -> Option<String> {
1637    let mut answer = String::new();
1638    reader.read_line(&mut answer).ok().map(|_| answer)
1639}
1640
1641/// The confirmation prompt shown before a delete — it names the risks when the
1642/// safety report flagged any. Pure, so the wording is unit-testable.
1643fn confirm_prompt(has_risks: bool) -> &'static str {
1644    if has_risks {
1645        "Delete this worktree despite the risks above? [y/N] "
1646    } else {
1647        "Delete this worktree? [y/N] "
1648    }
1649}
1650
1651/// Whether a confirmation answer is an affirmative (`y`/`yes`, case-insensitive).
1652/// Split out so the yes/no decision is unit-testable without real stdin.
1653fn answer_is_yes(answer: &str) -> bool {
1654    matches!(answer.trim().to_lowercase().as_str(), "y" | "yes")
1655}
1656
1657/// Fetches ahead/behind on demand for every worktree in a `tree` reply and folds
1658/// the counts back into each worktree object, so `worktrees tree` renders the same
1659/// `+ahead -behind` sync state the cheap snapshot no longer carries (#1306). A
1660/// best-effort enrichment: if there are no worktrees, the daemon lacks the
1661/// `ahead-behind` op (older daemon), or the call fails, `result` is left as-is and
1662/// the tree still renders — just with `-` for sync.
1663async fn enrich_ahead_behind(socket: &Path, result: &mut Value) {
1664    let paths = worktree_paths(result);
1665    if paths.is_empty() {
1666        return;
1667    }
1668    let Ok(reply) = call(socket, "ahead-behind", json!({ "paths": paths })).await else {
1669        return;
1670    };
1671    if let Some(results) = reply.get("results").and_then(Value::as_object) {
1672        merge_ahead_behind(result, results);
1673    }
1674}
1675
1676/// Every worktree path in a `tree` reply, in render order — the batch the
1677/// on-demand `ahead-behind` op is asked about.
1678fn worktree_paths(result: &Value) -> Vec<String> {
1679    let mut paths = Vec::new();
1680    for repo in result
1681        .get("repos")
1682        .and_then(Value::as_array)
1683        .map(Vec::as_slice)
1684        .unwrap_or_default()
1685    {
1686        for worktree in repo
1687            .get("worktrees")
1688            .and_then(Value::as_array)
1689            .map(Vec::as_slice)
1690            .unwrap_or_default()
1691        {
1692            if let Some(path) = worktree.get("path").and_then(Value::as_str) {
1693                paths.push(path.to_string());
1694            }
1695        }
1696    }
1697    paths
1698}
1699
1700/// Folds `{ ahead, behind, main_behind }` counts (keyed by worktree path) from an
1701/// `ahead-behind` reply back into a `tree` reply's worktree objects. A worktree
1702/// whose path is absent from `results` (neither resolves) is left untouched. Pure,
1703/// so the merge is unit-testable without a socket.
1704fn merge_ahead_behind(result: &mut Value, results: &serde_json::Map<String, Value>) {
1705    for repo in result
1706        .get_mut("repos")
1707        .and_then(Value::as_array_mut)
1708        .into_iter()
1709        .flatten()
1710    {
1711        for worktree in repo
1712            .get_mut("worktrees")
1713            .and_then(Value::as_array_mut)
1714            .into_iter()
1715            .flatten()
1716        {
1717            // Take the worktree object up front so the insert reuses this handle
1718            // rather than a second, always-succeeding `as_object_mut` (a non-object
1719            // element in the array is skipped here).
1720            let Some(obj) = worktree.as_object_mut() else {
1721                continue;
1722            };
1723            let Some(path) = obj.get("path").and_then(Value::as_str).map(str::to_string) else {
1724                continue;
1725            };
1726            let Some(counts) = results.get(&path) else {
1727                continue;
1728            };
1729            // Fold both counts in together, or neither — a malformed entry missing
1730            // a side is left as no-sync rather than half-applied.
1731            if let (Some(ahead), Some(behind)) =
1732                (counts.get("ahead").cloned(), counts.get("behind").cloned())
1733            {
1734                obj.insert("ahead".to_string(), ahead);
1735                obj.insert("behind".to_string(), behind);
1736            }
1737            // `main_behind` (#1457) folds in independently of `ahead`/`behind` —
1738            // it comes from a wholly separate walk and can be present when they
1739            // are absent (no upstream at all) or absent when they are present
1740            // (the upstream-is-the-default-branch skip case).
1741            if let Some(main_behind) = counts.get("main_behind").cloned() {
1742                obj.insert("main_behind".to_string(), main_behind);
1743            }
1744        }
1745    }
1746}
1747
1748/// Sends one `worktrees` service op over the control socket, returning its
1749/// payload or turning an `ok: false` reply into an error.
1750async fn call(socket: &Path, op: &str, payload: Value) -> Result<Value> {
1751    let reply = DaemonClient::new(socket)
1752        .request(DaemonEnvelope::service(SERVICE, op, payload))
1753        .await?;
1754    reply_payload(reply)
1755}
1756
1757/// Unwraps a daemon reply into its payload, turning an `ok: false` reply into an
1758/// error. Pure (no socket), so both mappings are unit-testable.
1759fn reply_payload(reply: DaemonReply) -> Result<Value> {
1760    if reply.ok {
1761        Ok(reply.payload)
1762    } else {
1763        bail!(
1764            "daemon returned an error: {}",
1765            reply.error.as_deref().unwrap_or("unknown error")
1766        )
1767    }
1768}
1769
1770/// Renders a `list` reply as a human-readable table: a header and one row per
1771/// open window (repo, the daemon-computed branch and its ahead/behind sync
1772/// state, the primary folder, and how long ago it was last seen). Returns a
1773/// placeholder line when nothing is open.
1774fn render_windows(result: &Value) -> String {
1775    let windows = result
1776        .get("windows")
1777        .and_then(Value::as_array)
1778        .map(Vec::as_slice)
1779        .unwrap_or_default();
1780    if windows.is_empty() {
1781        return "No open windows.".to_string();
1782    }
1783    let mut out = format!(
1784        "{:<22} {:<24} {:<9} {:<40} {:>5}",
1785        "REPO", "BRANCH", "SYNC", "FOLDER", "AGE"
1786    );
1787    for window in windows {
1788        let repo = sanitize(repo_name(window));
1789        let branch = sanitize(window.get("branch").and_then(Value::as_str).unwrap_or("-"));
1790        let sync = sync_summary(window);
1791        let folder_disp = folder_summary(window);
1792        let age = age_secs(window.get("last_seen").and_then(Value::as_str));
1793        out.push_str(&format!(
1794            "\n{repo:<22} {branch:<24} {sync:<9} {folder_disp:<40} {age:>4}s"
1795        ));
1796    }
1797    out
1798}
1799
1800/// Renders a `tree` reply as a repo-grouped view: a header line per repository
1801/// (its name, GitHub `owner/name` when present, and root path), then one indented
1802/// row per worktree — a `*` marks the main working tree, followed by the branch,
1803/// its `+ahead -behind` sync state, an `open` flag when a live window has it open,
1804/// and the worktree path. Returns a placeholder when no repository is open.
1805fn render_tree(result: &Value) -> String {
1806    let repos = result
1807        .get("repos")
1808        .and_then(Value::as_array)
1809        .map(Vec::as_slice)
1810        .unwrap_or_default();
1811    if repos.is_empty() {
1812        return "No repositories open.".to_string();
1813    }
1814    let mut out = String::new();
1815    for (i, repo) in repos.iter().enumerate() {
1816        // A blank line separates repositories (but not before the first): the
1817        // previous worktree row has no trailing newline, so two are needed.
1818        if i > 0 {
1819            out.push_str("\n\n");
1820        }
1821        out.push_str(&repo_header(repo));
1822        for worktree in repo
1823            .get("worktrees")
1824            .and_then(Value::as_array)
1825            .map(Vec::as_slice)
1826            .unwrap_or_default()
1827        {
1828            out.push('\n');
1829            out.push_str(&worktree_row(worktree));
1830        }
1831    }
1832    out
1833}
1834
1835/// The header line for one repo in the tree view: `<name>  (github: owner/name)
1836/// <root>`, with the GitHub clause omitted for a non-GitHub repo.
1837fn repo_header(repo: &Value) -> String {
1838    let name = sanitize(repo.get("main_repo").and_then(Value::as_str).unwrap_or("-"));
1839    let root = sanitize(repo.get("root").and_then(Value::as_str).unwrap_or(""));
1840    match github_summary(repo) {
1841        Some(github) => format!("{name}  ({github})  {root}"),
1842        None => format!("{name}  {root}"),
1843    }
1844}
1845
1846/// A `github: owner/name` summary for a repo, or `None` when it has no GitHub
1847/// identity (a non-GitHub or remote-less repo).
1848fn github_summary(repo: &Value) -> Option<String> {
1849    let owner = repo.pointer("/github/owner").and_then(Value::as_str)?;
1850    let name = repo.pointer("/github/name").and_then(Value::as_str)?;
1851    Some(format!("github: {}/{}", sanitize(owner), sanitize(name)))
1852}
1853
1854/// One indented worktree row: a `*` for the main working tree, the branch, the
1855/// `+ahead -behind` sync state, an `open` flag when a window has it open, and the
1856/// worktree path.
1857fn worktree_row(worktree: &Value) -> String {
1858    let marker = if worktree.get("is_main").and_then(Value::as_bool) == Some(true) {
1859        '*'
1860    } else {
1861        ' '
1862    };
1863    let branch = sanitize(
1864        worktree
1865            .get("branch")
1866            .and_then(Value::as_str)
1867            .unwrap_or("-"),
1868    );
1869    let sync = sync_summary(worktree);
1870    let open = if worktree.get("open").and_then(Value::as_bool) == Some(true) {
1871        "open"
1872    } else {
1873        ""
1874    };
1875    let path = sanitize(worktree.get("path").and_then(Value::as_str).unwrap_or(""));
1876    format!("  {marker} {branch:<24} {sync:<16} {open:<5} {path}")
1877}
1878
1879/// The repo name to show for a window: the daemon-computed `main_repo` (which
1880/// names the *parent* repository of a linked worktree, not its worktree-folder
1881/// basename) when present, else the companion-reported `repo`, else `-`.
1882fn repo_name(window: &Value) -> &str {
1883    window
1884        .get("main_repo")
1885        .and_then(Value::as_str)
1886        .or_else(|| window.get("repo").and_then(Value::as_str))
1887        .unwrap_or("-")
1888}
1889
1890/// A compact `+ahead -behind` divergence indicator for a window, or `-` when
1891/// the branch tracks no upstream (or there is no branch at all), with a trailing
1892/// `main-N` fragment (#1457) when the `tree` reply carries `main_behind` — the
1893/// `list` reply's eagerly-computed window objects never do (that path is out of
1894/// scope for #1457), so this degrades to the pre-#1457 rendering there. The
1895/// counts are daemon-computed integers, so no sanitizing is needed.
1896fn sync_summary(window: &Value) -> String {
1897    let ahead = window.get("ahead").and_then(Value::as_u64);
1898    let behind = window.get("behind").and_then(Value::as_u64);
1899    let base = match (ahead, behind) {
1900        (Some(ahead), Some(behind)) => format!("+{ahead} -{behind}"),
1901        _ => "-".to_string(),
1902    };
1903    match window.get("main_behind").and_then(Value::as_u64) {
1904        Some(main_behind) => format!("{base} main-{main_behind}"),
1905        None => base,
1906    }
1907}
1908
1909/// The primary folder of a window, with a `(+N)` suffix when it has more than
1910/// one workspace folder.
1911fn folder_summary(window: &Value) -> String {
1912    let folders = window
1913        .get("folders")
1914        .and_then(Value::as_array)
1915        .map(Vec::as_slice)
1916        .unwrap_or_default();
1917    let first = sanitize(folders.first().and_then(Value::as_str).unwrap_or(""));
1918    let extra = folders.len().saturating_sub(1);
1919    if extra > 0 {
1920        format!("{first} (+{extra})")
1921    } else {
1922        first
1923    }
1924}
1925
1926/// Strips control characters (C0, DEL, C1) from an untrusted registry string so
1927/// a malicious `register` payload cannot inject terminal escape sequences into
1928/// the rendered table (#1137). The `--json` path stays verbatim.
1929fn sanitize(s: &str) -> String {
1930    s.chars().filter(|c| !c.is_control()).collect()
1931}
1932
1933/// Seconds elapsed since an RFC 3339 timestamp (0 if absent/unparseable).
1934fn age_secs(ts: Option<&str>) -> i64 {
1935    ts.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
1936        .map_or(0, |t| {
1937            (Utc::now() - t.with_timezone(&Utc)).num_seconds().max(0)
1938        })
1939}
1940
1941#[cfg(test)]
1942#[allow(clippy::unwrap_used, clippy::expect_used)]
1943mod tests {
1944    use super::*;
1945    use serde_json::json;
1946
1947    /// Mirrors the `omni-dev worktrees` argv surface for parse tests.
1948    #[derive(Parser)]
1949    struct Wrapper {
1950        #[command(subcommand)]
1951        cmd: WorktreesSubcommands,
1952    }
1953
1954    fn parse(args: &[&str]) -> WorktreesSubcommands {
1955        let mut full = vec!["omni-dev"];
1956        full.extend_from_slice(args);
1957        Wrapper::try_parse_from(full).unwrap().cmd
1958    }
1959
1960    #[test]
1961    fn list_parses_flags_and_defaults() {
1962        // Routing: `worktrees list` maps to the List variant.
1963        assert!(matches!(parse(&["list"]), WorktreesSubcommands::List(_)));
1964        // Flags, via the leaf parser (clap treats argv[0] as the command name).
1965        let cmd = ListCommand::try_parse_from(["list"]).unwrap();
1966        assert_eq!(cmd.output, TableOrJson::Table);
1967        assert!(!cmd.json);
1968        assert!(cmd.socket.is_none());
1969
1970        let cmd =
1971            ListCommand::try_parse_from(["list", "-o", "json", "--socket", "/tmp/d.sock"]).unwrap();
1972        assert_eq!(cmd.output, TableOrJson::Json);
1973        assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
1974    }
1975
1976    #[test]
1977    fn list_deprecated_json_flag_still_parses() {
1978        // `--json` is captured separately; `execute` folds it into `output`.
1979        let cmd = ListCommand::try_parse_from(["list", "--json"]).unwrap();
1980        assert!(cmd.json);
1981        assert_eq!(cmd.output, TableOrJson::Table);
1982    }
1983
1984    #[test]
1985    fn tree_parses_flags_and_defaults() {
1986        // Routing: `worktrees tree` maps to the Tree variant.
1987        assert!(matches!(parse(&["tree"]), WorktreesSubcommands::Tree(_)));
1988        let cmd = TreeCommand::try_parse_from(["tree"]).unwrap();
1989        assert_eq!(cmd.output, TableOrJson::Table);
1990        assert!(cmd.socket.is_none());
1991
1992        let cmd =
1993            TreeCommand::try_parse_from(["tree", "-o", "json", "--socket", "/tmp/d.sock"]).unwrap();
1994        assert_eq!(cmd.output, TableOrJson::Json);
1995        assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
1996    }
1997
1998    #[test]
1999    fn focus_parses_path_and_socket() {
2000        // Routing: `worktrees focus` maps to the Focus variant.
2001        assert!(matches!(
2002            parse(&["focus", "/home/me/wt"]),
2003            WorktreesSubcommands::Focus(_)
2004        ));
2005        // The path is a required positional; `--socket` is optional.
2006        let cmd = FocusCommand::try_parse_from(["focus", "/home/me/wt"]).unwrap();
2007        assert_eq!(cmd.path, Path::new("/home/me/wt"));
2008        assert!(cmd.socket.is_none());
2009
2010        let cmd = FocusCommand::try_parse_from(["focus", "/home/me/wt", "--socket", "/tmp/d.sock"])
2011            .unwrap();
2012        assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
2013
2014        // The path is required.
2015        assert!(FocusCommand::try_parse_from(["focus"]).is_err());
2016    }
2017
2018    #[tokio::test]
2019    async fn focus_errors_on_a_nonexistent_path_before_any_socket_call() {
2020        // Canonicalisation fails for a path that does not exist, so `focus`
2021        // reports a clear error without needing a daemon.
2022        let cmd = FocusCommand {
2023            path: PathBuf::from("/nonexistent/omni-dev-focus-xyz"),
2024            socket: Some(PathBuf::from("/nonexistent/omni-dev-focus.sock")),
2025        };
2026        let err = cmd.execute().await.unwrap_err();
2027        assert!(
2028            err.to_string().contains("cannot resolve worktree path"),
2029            "{err}"
2030        );
2031    }
2032
2033    #[tokio::test]
2034    async fn focus_sends_the_open_op_for_an_existing_folder() {
2035        // A real (temp) folder canonicalises, so `focus` sends the `open` op to
2036        // the daemon; the fake daemon acknowledges it. Routed through the top-level
2037        // `WorktreesCommand::execute` so its `Focus` dispatch arm is exercised too.
2038        let (_dir, sock, server) =
2039            fake_daemon_reply(json!({ "ok": true, "payload": { "ok": true } }));
2040        let target = tempfile::tempdir().unwrap();
2041        let cmd = WorktreesCommand {
2042            command: WorktreesSubcommands::Focus(FocusCommand {
2043                path: target.path().to_path_buf(),
2044                socket: Some(sock),
2045            }),
2046        };
2047        cmd.execute(None).await.unwrap();
2048        server.await.unwrap();
2049    }
2050
2051    #[test]
2052    fn render_windows_handles_empty_replies() {
2053        assert_eq!(
2054            render_windows(&json!({ "windows": [] })),
2055            "No open windows."
2056        );
2057        assert_eq!(render_windows(&json!({})), "No open windows.");
2058    }
2059
2060    #[test]
2061    fn render_windows_renders_rows() {
2062        let result = json!({ "windows": [{
2063            "key": "w1",
2064            "repo": "omni-dev",
2065            "branch": "issue-1011",
2066            "ahead": 2,
2067            "behind": 1,
2068            "folders": ["/home/me/omni-dev", "/home/me/docs"],
2069            "last_seen": "2000-01-01T00:00:00Z",
2070        }]});
2071        let table = render_windows(&result);
2072        assert!(table.contains("omni-dev"), "{table}");
2073        // The computed branch and its sync state both render.
2074        assert!(table.contains("issue-1011"), "{table}");
2075        assert!(table.contains("+2 -1"), "{table}");
2076        // Primary folder plus a (+1) for the second workspace folder.
2077        assert!(table.contains("/home/me/omni-dev (+1)"), "{table}");
2078        // A header line plus exactly one data row.
2079        assert_eq!(table.lines().count(), 2, "{table}");
2080    }
2081
2082    #[test]
2083    fn render_windows_prefers_main_repo_over_companion_repo() {
2084        // A linked worktree: the companion reports the worktree-folder basename,
2085        // but the daemon-computed `main_repo` names the parent repo, and that is
2086        // what the REPO column shows.
2087        let result = json!({ "windows": [{
2088            "key": "w1",
2089            "repo": "issue-1250",
2090            "main_repo": "omni-dev",
2091            "branch": "issue-1250",
2092            "folders": ["/home/me/worktrees/issue-1250"],
2093            "last_seen": "2000-01-01T00:00:00Z",
2094        }]});
2095        let table = render_windows(&result);
2096        assert!(table.contains("omni-dev"), "{table}");
2097        // The misleading worktree-folder basename does not appear in REPO (it is
2098        // still visible in the FOLDER column path).
2099        let data_row = table.lines().nth(1).unwrap();
2100        assert!(data_row.starts_with("omni-dev"), "{data_row}");
2101    }
2102
2103    #[test]
2104    fn repo_name_falls_back_to_companion_repo_then_dash() {
2105        assert_eq!(
2106            repo_name(&json!({ "main_repo": "omni-dev", "repo": "wt" })),
2107            "omni-dev"
2108        );
2109        assert_eq!(repo_name(&json!({ "repo": "wt" })), "wt");
2110        assert_eq!(repo_name(&json!({})), "-");
2111    }
2112
2113    #[test]
2114    fn render_windows_strips_control_bytes() {
2115        // C0 (ESC, CR, BEL), DEL, and C1 (CSI) bytes in every string-valued
2116        // field must not reach the terminal (#1137).
2117        let result = json!({ "windows": [{
2118            "key": "w1",
2119            "repo": "evil\x1b[31mrepo",
2120            "branch": "br\ranch\x07\u{9b}2J",
2121            "folders": ["/tmp/a\x1b]0;owned\x07\u{7f}", "/tmp/b"],
2122            "last_seen": "2000-01-01T00:00:00Z",
2123        }]});
2124        let table = render_windows(&result);
2125        assert!(
2126            !table.contains(|c: char| c.is_control() && c != '\n'),
2127            "{table:?}"
2128        );
2129        // Visible text survives with only the control bytes removed.
2130        assert!(table.contains("evil[31mrepo"), "{table:?}");
2131        assert!(table.contains("branch2J"), "{table:?}");
2132        assert!(table.contains("/tmp/a]0;owned (+1)"), "{table:?}");
2133        // Embedded CR/LF cannot forge extra rows: header plus one data row.
2134        assert_eq!(table.lines().count(), 2, "{table:?}");
2135    }
2136
2137    #[test]
2138    fn sync_summary_formats_or_dashes() {
2139        assert_eq!(sync_summary(&json!({ "ahead": 2, "behind": 1 })), "+2 -1");
2140        assert_eq!(sync_summary(&json!({ "ahead": 0, "behind": 0 })), "+0 -0");
2141        // Branch present but no upstream, or nothing at all → a dash.
2142        assert_eq!(sync_summary(&json!({ "branch": "main" })), "-");
2143        assert_eq!(sync_summary(&json!({})), "-");
2144    }
2145
2146    #[test]
2147    fn sync_summary_appends_main_behind_when_present() {
2148        // `main_behind` (#1457) trails the existing summary, whether or not the
2149        // branch has its own upstream.
2150        assert_eq!(
2151            sync_summary(&json!({ "ahead": 2, "behind": 1, "main_behind": 5 })),
2152            "+2 -1 main-5"
2153        );
2154        assert_eq!(sync_summary(&json!({ "main_behind": 7 })), "- main-7");
2155        // Absent `main_behind` (a pre-#1457 daemon, or the skip case) renders
2156        // exactly as before.
2157        assert_eq!(sync_summary(&json!({ "ahead": 2, "behind": 1 })), "+2 -1");
2158    }
2159
2160    #[test]
2161    fn folder_summary_strips_control_bytes() {
2162        assert_eq!(
2163            folder_summary(&json!({ "folders": ["/a\x1b[2J/b"] })),
2164            "/a[2J/b"
2165        );
2166    }
2167
2168    #[test]
2169    fn folder_summary_counts_extra_folders() {
2170        assert_eq!(folder_summary(&json!({ "folders": [] })), "");
2171        assert_eq!(folder_summary(&json!({ "folders": ["/a"] })), "/a");
2172        assert_eq!(
2173            folder_summary(&json!({ "folders": ["/a", "/b", "/c"] })),
2174            "/a (+2)"
2175        );
2176    }
2177
2178    #[test]
2179    fn age_secs_handles_absent_and_unparseable_and_past() {
2180        assert_eq!(age_secs(None), 0);
2181        assert_eq!(age_secs(Some("not-a-timestamp")), 0);
2182        assert!(age_secs(Some("2000-01-01T00:00:00Z")) > 0);
2183    }
2184
2185    #[test]
2186    fn render_tree_handles_empty_replies() {
2187        assert_eq!(
2188            render_tree(&json!({ "repos": [] })),
2189            "No repositories open."
2190        );
2191        assert_eq!(render_tree(&json!({})), "No repositories open.");
2192    }
2193
2194    #[test]
2195    fn worktree_paths_collects_every_worktree_in_render_order() {
2196        let result = json!({ "repos": [
2197            // The middle worktree has no `path` and is skipped, not collected.
2198            { "worktrees": [ { "path": "/a" }, { "branch": "detached" }, { "path": "/b" } ] },
2199            { "worktrees": [ { "path": "/c" } ] },
2200        ]});
2201        assert_eq!(worktree_paths(&result), vec!["/a", "/b", "/c"]);
2202        // No repos / no worktrees → an empty batch (nothing to fetch).
2203        assert!(worktree_paths(&json!({})).is_empty());
2204        assert!(worktree_paths(&json!({ "repos": [{ "worktrees": [] }] })).is_empty());
2205    }
2206
2207    #[test]
2208    fn merge_ahead_behind_folds_counts_by_path_and_leaves_others() {
2209        // The on-demand `ahead-behind` op reports one worktree diverging and omits
2210        // the other (no upstream). The merge folds the counts onto the matching
2211        // path and leaves the untracked worktree without sync fields.
2212        let mut result = json!({ "repos": [{ "worktrees": [
2213            { "path": "/a", "branch": "main" },
2214            { "path": "/b", "branch": "feature" },
2215        ]}]});
2216        let results = json!({ "/a": { "ahead": 2, "behind": 1 } });
2217        merge_ahead_behind(&mut result, results.as_object().unwrap());
2218
2219        let worktrees = result.pointer("/repos/0/worktrees").unwrap();
2220        let a = &worktrees[0];
2221        assert_eq!(a.get("ahead").and_then(Value::as_u64), Some(2));
2222        assert_eq!(a.get("behind").and_then(Value::as_u64), Some(1));
2223        // And it renders exactly as an eager snapshot would have.
2224        assert_eq!(sync_summary(a), "+2 -1");
2225        let b = &worktrees[1];
2226        assert!(b.get("ahead").is_none(), "{b:?}");
2227        assert!(b.get("behind").is_none(), "{b:?}");
2228        assert_eq!(sync_summary(b), "-");
2229    }
2230
2231    #[test]
2232    fn merge_ahead_behind_folds_main_behind_independently_of_ahead_behind() {
2233        // `/a` carries all three fields; `/b` has no upstream at all, only
2234        // `main_behind`; `/c` has `ahead`/`behind` but no `main_behind` (the
2235        // branch's own upstream is the resolved default branch).
2236        let mut result = json!({ "repos": [{ "worktrees": [
2237            { "path": "/a", "branch": "feature" },
2238            { "path": "/b", "branch": "no-upstream" },
2239            { "path": "/c", "branch": "main" },
2240        ]}]});
2241        let results = json!({
2242            "/a": { "ahead": 1, "behind": 1, "main_behind": 3 },
2243            "/b": { "main_behind": 7 },
2244            "/c": { "ahead": 1, "behind": 1 },
2245        });
2246        merge_ahead_behind(&mut result, results.as_object().unwrap());
2247
2248        let worktrees = result.pointer("/repos/0/worktrees").unwrap();
2249        let a = &worktrees[0];
2250        assert_eq!(a.get("ahead").and_then(Value::as_u64), Some(1));
2251        assert_eq!(a.get("behind").and_then(Value::as_u64), Some(1));
2252        assert_eq!(a.get("main_behind").and_then(Value::as_u64), Some(3));
2253
2254        let b = &worktrees[1];
2255        assert!(b.get("ahead").is_none(), "{b:?}");
2256        assert!(b.get("behind").is_none(), "{b:?}");
2257        assert_eq!(b.get("main_behind").and_then(Value::as_u64), Some(7));
2258
2259        let c = &worktrees[2];
2260        assert_eq!(c.get("ahead").and_then(Value::as_u64), Some(1));
2261        assert_eq!(c.get("behind").and_then(Value::as_u64), Some(1));
2262        assert!(c.get("main_behind").is_none(), "{c:?}");
2263    }
2264
2265    #[test]
2266    fn merge_ahead_behind_skips_malformed_worktrees_and_counts() {
2267        // Every defensive guard, on malformed input that never comes from a real
2268        // daemon: a non-object array element, a worktree with no `path`, and a
2269        // results entry missing a side. None panics; none is half-applied.
2270        let mut result = json!({ "repos": [{ "worktrees": [
2271            "not-an-object",                       // non-object element → skipped
2272            { "branch": "detached" },              // object, but no path → skipped
2273            { "path": "/a", "branch": "main" },    // matched, but counts malformed
2274        ]}]});
2275        let results = json!({ "/a": { "ahead": 2 } }); // missing `behind`
2276        merge_ahead_behind(&mut result, results.as_object().unwrap());
2277
2278        let worktrees = result.pointer("/repos/0/worktrees").unwrap();
2279        // Non-object element is untouched.
2280        assert_eq!(worktrees[0], json!("not-an-object"));
2281        // Pathless worktree: no sync fields inserted.
2282        assert!(worktrees[1].get("ahead").is_none(), "{:?}", worktrees[1]);
2283        // Malformed counts: neither side folded in (both-or-nothing).
2284        assert!(worktrees[2].get("ahead").is_none(), "{:?}", worktrees[2]);
2285        assert!(worktrees[2].get("behind").is_none(), "{:?}", worktrees[2]);
2286    }
2287
2288    #[tokio::test]
2289    async fn enrich_ahead_behind_is_a_noop_when_there_are_no_worktrees() {
2290        // No worktrees → no batch to fetch → early return before any socket call,
2291        // so even a nonexistent socket leaves the tree untouched.
2292        let mut result = json!({ "repos": [] });
2293        let before = result.clone();
2294        enrich_ahead_behind(Path::new("/nonexistent/omni-dev-ab.sock"), &mut result).await;
2295        assert_eq!(result, before);
2296    }
2297
2298    #[tokio::test]
2299    async fn enrich_ahead_behind_leaves_the_tree_when_the_daemon_is_unreachable() {
2300        // A real worktree but no daemon at the socket → the call fails and the tree
2301        // is returned as-is (rendered with `-` for sync), never erroring.
2302        let mut result =
2303            json!({ "repos": [{ "worktrees": [{ "path": "/x", "branch": "main" }] }] });
2304        enrich_ahead_behind(Path::new("/nonexistent/omni-dev-ab.sock"), &mut result).await;
2305        let wt = result.pointer("/repos/0/worktrees/0").unwrap();
2306        assert!(wt.get("ahead").is_none(), "{wt:?}");
2307        assert!(wt.get("behind").is_none(), "{wt:?}");
2308    }
2309
2310    /// Spawns a minimal fake daemon on a short-path Unix socket that answers the
2311    /// one `ahead-behind` request with `reply` (the daemon's NDJSON reply shape).
2312    /// Returns the temp dir (kept alive for the socket's lifetime), the socket
2313    /// path, and the server task.
2314    fn fake_daemon_reply(
2315        reply: Value,
2316    ) -> (tempfile::TempDir, PathBuf, tokio::task::JoinHandle<()>) {
2317        use futures::{SinkExt, StreamExt};
2318        use tokio::net::UnixListener;
2319        use tokio_util::codec::{Framed, LinesCodec};
2320
2321        // A short base path keeps the socket under the 104-byte `sockaddr_un` limit.
2322        let dir = tempfile::tempdir_in("/tmp").unwrap();
2323        let sock = dir.path().join("d.sock");
2324        let listener = UnixListener::bind(&sock).unwrap();
2325        let server = tokio::spawn(async move {
2326            let (stream, _) = listener.accept().await.unwrap();
2327            let mut framed = Framed::new(stream, LinesCodec::new());
2328            let _req = framed.next().await.unwrap().unwrap();
2329            framed
2330                .send(serde_json::to_string(&reply).unwrap())
2331                .await
2332                .unwrap();
2333        });
2334        (dir, sock, server)
2335    }
2336
2337    /// A [`fake_daemon_reply`] that answers a **sequence** of requests — one fresh
2338    /// connection per reply, in order — so a two-phase client (a `merge-queue`
2339    /// check then execute) can be driven end-to-end over one socket.
2340    fn fake_daemon_replies(
2341        replies: Vec<Value>,
2342    ) -> (tempfile::TempDir, PathBuf, tokio::task::JoinHandle<()>) {
2343        use futures::{SinkExt, StreamExt};
2344        use tokio::net::UnixListener;
2345        use tokio_util::codec::{Framed, LinesCodec};
2346
2347        let dir = tempfile::tempdir_in("/tmp").unwrap();
2348        let sock = dir.path().join("d.sock");
2349        let listener = UnixListener::bind(&sock).unwrap();
2350        let server = tokio::spawn(async move {
2351            for reply in replies {
2352                let (stream, _) = listener.accept().await.unwrap();
2353                let mut framed = Framed::new(stream, LinesCodec::new());
2354                let _req = framed.next().await.unwrap().unwrap();
2355                framed
2356                    .send(serde_json::to_string(&reply).unwrap())
2357                    .await
2358                    .unwrap();
2359            }
2360        });
2361        (dir, sock, server)
2362    }
2363
2364    #[tokio::test]
2365    async fn enrich_ahead_behind_folds_counts_from_a_live_socket() {
2366        let (_dir, sock, server) = fake_daemon_reply(
2367            json!({ "ok": true, "payload": { "results": { "/x": { "ahead": 3, "behind": 4 } } } }),
2368        );
2369        let mut result =
2370            json!({ "repos": [{ "worktrees": [{ "path": "/x", "branch": "main" }] }] });
2371        enrich_ahead_behind(&sock, &mut result).await;
2372        server.await.unwrap();
2373
2374        let wt = result.pointer("/repos/0/worktrees/0").unwrap();
2375        assert_eq!(wt.get("ahead").and_then(Value::as_u64), Some(3));
2376        assert_eq!(wt.get("behind").and_then(Value::as_u64), Some(4));
2377    }
2378
2379    #[tokio::test]
2380    async fn enrich_ahead_behind_ignores_a_reply_without_results() {
2381        // An `ok` reply carrying no `results` object (an older/oddly-shaped daemon)
2382        // leaves the tree unchanged rather than erroring.
2383        let (_dir, sock, server) = fake_daemon_reply(json!({ "ok": true, "payload": {} }));
2384        let mut result =
2385            json!({ "repos": [{ "worktrees": [{ "path": "/x", "branch": "main" }] }] });
2386        enrich_ahead_behind(&sock, &mut result).await;
2387        server.await.unwrap();
2388
2389        let wt = result.pointer("/repos/0/worktrees/0").unwrap();
2390        assert!(wt.get("ahead").is_none(), "{wt:?}");
2391        assert!(wt.get("behind").is_none(), "{wt:?}");
2392    }
2393
2394    #[test]
2395    fn render_tree_groups_repos_and_worktrees() {
2396        let result = json!({ "repos": [{
2397            "main_repo": "omni-dev",
2398            "github": { "owner": "rust-works", "name": "omni-dev" },
2399            "root": "/home/me/omni-dev",
2400            "worktrees": [
2401                { "path": "/home/me/omni-dev", "branch": "main", "ahead": 2, "behind": 0,
2402                  "is_main": true, "open": true, "window_key": "w1" },
2403                { "path": "/home/me/wt/issue-1300", "branch": "issue-1300", "ahead": 1, "behind": 3,
2404                  "is_main": false, "open": false },
2405            ],
2406        }]});
2407        let out = render_tree(&result);
2408        // Repo header carries the GitHub identity and root.
2409        let header = out.lines().next().unwrap();
2410        assert!(header.contains("omni-dev"), "{out}");
2411        assert!(header.contains("github: rust-works/omni-dev"), "{out}");
2412        assert!(header.contains("/home/me/omni-dev"), "{out}");
2413        // The main working tree is marked with `*`, its sync, and `open`.
2414        assert!(
2415            out.lines()
2416                .any(|l| l.contains("* main") && l.contains("+2 -0") && l.contains("open")),
2417            "{out}"
2418        );
2419        // The linked worktree is unmarked and not flagged open.
2420        let linked = out
2421            .lines()
2422            .find(|l| l.contains("issue-1300"))
2423            .unwrap_or_default();
2424        assert!(!linked.contains('*'), "{linked}");
2425        assert!(!linked.contains("open"), "{linked}");
2426        assert!(linked.contains("+1 -3"), "{linked}");
2427        // Header + two worktree rows.
2428        assert_eq!(out.lines().count(), 3, "{out}");
2429    }
2430
2431    #[test]
2432    fn render_tree_separates_multiple_repos_with_blank_line() {
2433        let result = json!({ "repos": [
2434            {
2435                "main_repo": "alpha",
2436                "root": "/r/alpha",
2437                "worktrees": [
2438                    { "path": "/r/alpha", "branch": "main", "is_main": true, "open": false },
2439                ],
2440            },
2441            {
2442                "main_repo": "beta",
2443                "root": "/r/beta",
2444                "worktrees": [
2445                    { "path": "/r/beta", "branch": "main", "is_main": true, "open": false },
2446                ],
2447            },
2448        ]});
2449        let out = render_tree(&result);
2450        // Two headers, two worktree rows, and one blank separator between repos.
2451        assert!(
2452            out.contains("\n\nbeta"),
2453            "repos not blank-separated: {out:?}"
2454        );
2455        let alpha = out.find("alpha").unwrap();
2456        let beta = out.find("beta").unwrap();
2457        assert!(alpha < beta, "repo order not preserved: {out}");
2458        assert_eq!(out.lines().count(), 5, "{out:?}");
2459    }
2460
2461    #[test]
2462    fn render_tree_omits_github_for_non_github_repo() {
2463        let result = json!({ "repos": [{
2464            "main_repo": "internal",
2465            "root": "/srv/internal",
2466            "worktrees": [
2467                { "path": "/srv/internal", "branch": "main", "is_main": true, "open": false },
2468            ],
2469        }]});
2470        let out = render_tree(&result);
2471        assert!(!out.contains("github:"), "{out}");
2472        assert!(out.lines().next().unwrap().contains("internal"), "{out}");
2473    }
2474
2475    #[test]
2476    fn render_tree_strips_control_bytes() {
2477        // Control bytes in the repo name, github identity, branch, and path must
2478        // not reach the terminal (#1137), matching the `list` renderer.
2479        let result = json!({ "repos": [{
2480            "main_repo": "evil\x1b[31mrepo",
2481            "github": { "owner": "ow\x07ner", "name": "na\u{9b}2Jme" },
2482            "root": "/tmp/r\x1b]0;x\x07oot",
2483            "worktrees": [
2484                { "path": "/tmp/w\rt", "branch": "br\x1b[2Janch", "is_main": true, "open": true },
2485            ],
2486        }]});
2487        let out = render_tree(&result);
2488        assert!(
2489            !out.contains(|c: char| c.is_control() && c != '\n'),
2490            "{out:?}"
2491        );
2492        // Embedded CR/LF cannot forge extra lines: header plus one worktree row.
2493        assert_eq!(out.lines().count(), 2, "{out:?}");
2494    }
2495
2496    #[test]
2497    fn github_summary_needs_both_owner_and_name() {
2498        assert_eq!(
2499            github_summary(&json!({ "github": { "owner": "o", "name": "n" } })).as_deref(),
2500            Some("github: o/n")
2501        );
2502        assert_eq!(github_summary(&json!({ "github": { "owner": "o" } })), None);
2503        assert_eq!(github_summary(&json!({})), None);
2504    }
2505
2506    #[test]
2507    fn reply_payload_unwraps_ok_and_maps_errors() {
2508        // ok → payload.
2509        assert_eq!(
2510            reply_payload(DaemonReply::ok(json!({ "a": 1 }))).unwrap(),
2511            json!({ "a": 1 })
2512        );
2513        // ok: false with a message → that message.
2514        let err = reply_payload(DaemonReply::err("boom")).unwrap_err();
2515        assert!(err.to_string().contains("boom"), "{err}");
2516        // ok: false with no message → the "unknown error" fallback.
2517        let err = reply_payload(DaemonReply {
2518            ok: false,
2519            payload: Value::Null,
2520            error: None,
2521        })
2522        .unwrap_err();
2523        assert!(err.to_string().contains("unknown error"), "{err}");
2524    }
2525
2526    // --- #1361 typed op-parity commands -------------------------------------
2527
2528    #[test]
2529    fn new_subcommands_route_and_require_their_args() {
2530        assert!(matches!(
2531            parse(&["close", "/home/me/wt"]),
2532            WorktreesSubcommands::Close(_)
2533        ));
2534        assert!(matches!(
2535            parse(&["show-closed"]),
2536            WorktreesSubcommands::ShowClosed(_)
2537        ));
2538        assert!(matches!(
2539            parse(&["register", "--key", "w1"]),
2540            WorktreesSubcommands::Register(_)
2541        ));
2542        assert!(matches!(
2543            parse(&["heartbeat", "--key", "w1"]),
2544            WorktreesSubcommands::Heartbeat(_)
2545        ));
2546        assert!(matches!(
2547            parse(&["unregister", "--key", "w1"]),
2548            WorktreesSubcommands::Unregister(_)
2549        ));
2550
2551        // Required args are enforced.
2552        assert!(CloseCommand::try_parse_from(["close"]).is_err());
2553        assert!(RegisterCommand::try_parse_from(["register"]).is_err());
2554        assert!(HeartbeatCommand::try_parse_from(["heartbeat"]).is_err());
2555        assert!(UnregisterCommand::try_parse_from(["unregister"]).is_err());
2556    }
2557
2558    #[test]
2559    fn close_parses_flags() {
2560        let cmd = CloseCommand::try_parse_from([
2561            "close",
2562            "/home/me/wt",
2563            "--window-only",
2564            "--dry-run",
2565            "-y",
2566            "--socket",
2567            "/tmp/d.sock",
2568        ])
2569        .unwrap();
2570        assert_eq!(cmd.path, Path::new("/home/me/wt"));
2571        assert!(cmd.window_only && cmd.dry_run && cmd.yes);
2572        assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
2573
2574        // Defaults: no flags set.
2575        let cmd = CloseCommand::try_parse_from(["close", "/home/me/wt"]).unwrap();
2576        assert!(!cmd.window_only && !cmd.dry_run && !cmd.yes);
2577    }
2578
2579    #[test]
2580    fn tree_follow_flag_parses() {
2581        let cmd = TreeCommand::try_parse_from(["tree", "--follow"]).unwrap();
2582        assert!(cmd.follow);
2583        let cmd = TreeCommand::try_parse_from(["tree", "-f", "-o", "json"]).unwrap();
2584        assert!(cmd.follow);
2585        assert_eq!(cmd.output, TableOrJson::Json);
2586        let cmd = TreeCommand::try_parse_from(["tree"]).unwrap();
2587        assert!(!cmd.follow);
2588    }
2589
2590    #[test]
2591    fn show_closed_parses_optional_bool() {
2592        assert!(ShowClosedCommand::try_parse_from(["show-closed"])
2593            .unwrap()
2594            .value
2595            .is_none());
2596        assert_eq!(
2597            ShowClosedCommand::try_parse_from(["show-closed", "false"])
2598                .unwrap()
2599                .value,
2600            Some(false)
2601        );
2602        assert_eq!(
2603            ShowClosedCommand::try_parse_from(["show-closed", "true"])
2604                .unwrap()
2605                .value,
2606            Some(true)
2607        );
2608        // A non-boolean value is rejected.
2609        assert!(ShowClosedCommand::try_parse_from(["show-closed", "maybe"]).is_err());
2610    }
2611
2612    #[test]
2613    fn register_collects_repeated_folders() {
2614        let cmd = RegisterCommand::try_parse_from([
2615            "register",
2616            "--key",
2617            "w1",
2618            "--folder",
2619            "/a",
2620            "--folder",
2621            "/b",
2622            "--repo-name",
2623            "r",
2624            "--pid",
2625            "42",
2626        ])
2627        .unwrap();
2628        assert_eq!(cmd.key, "w1");
2629        assert_eq!(cmd.folders, vec![PathBuf::from("/a"), PathBuf::from("/b")]);
2630        assert_eq!(cmd.repo_name.as_deref(), Some("r"));
2631        assert_eq!(cmd.pid, Some(42));
2632    }
2633
2634    #[test]
2635    fn answer_is_yes_accepts_only_affirmatives() {
2636        for yes in ["y", "Y", "yes", "YES", " yes \n"] {
2637            assert!(answer_is_yes(yes), "{yes:?}");
2638        }
2639        for no in ["", "n", "no", "nope", "true", "\n"] {
2640            assert!(!answer_is_yes(no), "{no:?}");
2641        }
2642    }
2643
2644    #[test]
2645    fn confirm_prompt_mentions_risks_only_when_present() {
2646        // The risky wording names the risks; the clean one does not. Both default
2647        // to No. (No failure-message args — the conditions are self-describing, and
2648        // an unevaluated arg would just read as an uncovered line.)
2649        assert!(confirm_prompt(true).contains("risks"));
2650        assert!(!confirm_prompt(false).contains("risks"));
2651        assert!(confirm_prompt(true).contains("[y/N]"));
2652        assert!(confirm_prompt(false).contains("[y/N]"));
2653    }
2654
2655    #[test]
2656    fn read_line_from_maps_input_and_eof() {
2657        use std::io::Cursor;
2658        // A line (with or without a trailing newline) comes back verbatim; EOF is
2659        // an empty read (`Ok(0)`), which maps to `Some("")` — the decision layer
2660        // then treats it as "no".
2661        assert_eq!(
2662            read_line_from(&mut Cursor::new("y\n")).as_deref(),
2663            Some("y\n")
2664        );
2665        assert_eq!(read_line_from(&mut Cursor::new("")).as_deref(), Some(""));
2666        assert_eq!(
2667            read_line_from(&mut Cursor::new("no-newline")).as_deref(),
2668            Some("no-newline")
2669        );
2670    }
2671
2672    #[test]
2673    fn render_safety_report_renders_fields_and_notes() {
2674        let report = json!({
2675            "removable": true,
2676            "is_main": false,
2677            "open": true,
2678            "window_key": "w1",
2679            "window_folder_count": 2,
2680            "risks": [{ "kind": "dirty", "detail": "uncommitted changes" }],
2681            "info": [{ "kind": "unpushed", "detail": "2 unpushed commits" }],
2682        });
2683        let out = render_safety_report(Path::new("/home/me/wt"), &report);
2684        assert!(out.contains("/home/me/wt"), "{out}");
2685        assert!(out.contains("removable:        true"), "{out}");
2686        assert!(
2687            out.contains("open in a window:  yes (key w1, 2 folder(s))"),
2688            "{out}"
2689        );
2690        assert!(out.contains("[dirty] uncommitted changes"), "{out}");
2691        assert!(out.contains("[unpushed] 2 unpushed commits"), "{out}");
2692    }
2693
2694    #[test]
2695    fn render_safety_report_handles_no_window_and_no_notes() {
2696        let report = json!({ "removable": false, "is_main": true, "open": false });
2697        let out = render_safety_report(Path::new("/r"), &report);
2698        assert!(out.contains("removable:        false"), "{out}");
2699        assert!(out.contains("main working tree: true"), "{out}");
2700        assert!(out.contains("open in a window:  no"), "{out}");
2701        // No risks/info sections are emitted when both are absent.
2702        assert!(!out.contains("risks:"), "{out}");
2703        assert!(!out.contains("info:"), "{out}");
2704    }
2705
2706    #[test]
2707    fn render_safety_report_strips_control_bytes() {
2708        // Daemon-supplied strings (window key, note kind/detail) must not inject
2709        // terminal escapes (#1137).
2710        let report = json!({
2711            "removable": true, "is_main": false, "open": true,
2712            "window_key": "w\x1b[31m1", "window_folder_count": 1,
2713            "risks": [{ "kind": "di\x07rty", "detail": "lost\r\nrow" }],
2714            "info": [],
2715        });
2716        let out = render_safety_report(Path::new("/r"), &report);
2717        assert!(
2718            !out.contains(|c: char| c.is_control() && c != '\n'),
2719            "{out:?}"
2720        );
2721    }
2722
2723    /// Spawns a fake daemon that answers `replies.len()` sequential connections,
2724    /// each with the next reply, and **returns the request envelope(s) it
2725    /// received** (via the join handle) so a test can assert the exact wire shape
2726    /// — op and payload — that the client sent, not just the round-trip. Same
2727    /// short-path `/tmp` socket as `fake_daemon_reply`.
2728    fn fake_daemon_seq(
2729        replies: Vec<Value>,
2730    ) -> (
2731        tempfile::TempDir,
2732        PathBuf,
2733        tokio::task::JoinHandle<Vec<Value>>,
2734    ) {
2735        use futures::{SinkExt, StreamExt};
2736        use tokio::net::UnixListener;
2737        use tokio_util::codec::{Framed, LinesCodec};
2738
2739        let dir = tempfile::tempdir_in("/tmp").unwrap();
2740        let sock = dir.path().join("d.sock");
2741        let listener = UnixListener::bind(&sock).unwrap();
2742        let server = tokio::spawn(async move {
2743            let mut requests = Vec::new();
2744            for reply in replies {
2745                let (stream, _) = listener.accept().await.unwrap();
2746                let mut framed = Framed::new(stream, LinesCodec::new());
2747                let req = framed.next().await.unwrap().unwrap();
2748                requests.push(serde_json::from_str::<Value>(&req).unwrap());
2749                framed
2750                    .send(serde_json::to_string(&reply).unwrap())
2751                    .await
2752                    .unwrap();
2753            }
2754            requests
2755        });
2756        (dir, sock, server)
2757    }
2758
2759    #[tokio::test]
2760    async fn close_window_only_sends_remove_false() {
2761        let (_dir, sock, server) =
2762            fake_daemon_seq(vec![json!({ "ok": true, "payload": { "closed": true } })]);
2763        let target = tempfile::tempdir().unwrap();
2764        CloseCommand {
2765            path: target.path().to_path_buf(),
2766            window_only: true,
2767            dry_run: false,
2768            yes: false,
2769            socket: Some(sock),
2770        }
2771        .execute()
2772        .await
2773        .unwrap();
2774        let reqs = server.await.unwrap();
2775        // Exactly one op, and it is a non-destructive close: remove:false, never
2776        // confirmed. A payload-field rename would fail here.
2777        assert_eq!(reqs.len(), 1);
2778        assert_eq!(reqs[0]["op"], "close");
2779        assert_eq!(reqs[0]["payload"]["remove"], json!(false));
2780        assert!(
2781            reqs[0]["payload"].get("confirmed").is_none(),
2782            "{:?}",
2783            reqs[0]
2784        );
2785        // The path is canonicalized client-side before it is sent.
2786        let want = std::fs::canonicalize(target.path()).unwrap();
2787        assert_eq!(reqs[0]["payload"]["path"], json!(want.to_string_lossy()));
2788    }
2789
2790    #[tokio::test]
2791    async fn close_window_only_dry_run_never_contacts_the_daemon() {
2792        // `--window-only --dry-run` must have no side effect: it prints what would
2793        // happen and returns without a socket call, so a nonexistent socket is fine.
2794        let target = tempfile::tempdir().unwrap();
2795        CloseCommand {
2796            path: target.path().to_path_buf(),
2797            window_only: true,
2798            dry_run: true,
2799            yes: false,
2800            socket: Some(PathBuf::from("/nonexistent/omni-dev-close-dry.sock")),
2801        }
2802        .execute()
2803        .await
2804        .unwrap();
2805    }
2806
2807    #[tokio::test]
2808    async fn close_dry_run_only_runs_phase_one() {
2809        // A single connection: the safety check. `--dry-run` never sends phase-2.
2810        let (_dir, sock, server) = fake_daemon_seq(vec![json!({
2811            "ok": true,
2812            "payload": { "removable": true, "is_main": false, "open": false,
2813                         "window_folder_count": 0, "risks": [], "info": [] }
2814        })]);
2815        let target = tempfile::tempdir().unwrap();
2816        CloseCommand {
2817            path: target.path().to_path_buf(),
2818            window_only: false,
2819            dry_run: true,
2820            yes: false,
2821            socket: Some(sock),
2822        }
2823        .execute()
2824        .await
2825        .unwrap();
2826        let reqs = server.await.unwrap();
2827        // Only the phase-1 safety check: remove:true, unconfirmed. No phase-2.
2828        assert_eq!(reqs.len(), 1);
2829        assert_eq!(reqs[0]["op"], "close");
2830        assert_eq!(reqs[0]["payload"]["remove"], json!(true));
2831        assert!(
2832            reqs[0]["payload"].get("confirmed").is_none(),
2833            "{:?}",
2834            reqs[0]
2835        );
2836    }
2837
2838    #[tokio::test]
2839    async fn close_yes_executes_phase_two() {
2840        // Two connections: phase-1 safety report (removable), then phase-2 delete.
2841        let (_dir, sock, server) = fake_daemon_seq(vec![
2842            json!({ "ok": true, "payload": { "removable": true, "is_main": false,
2843                    "open": false, "window_folder_count": 0, "risks": [], "info": [] } }),
2844            json!({ "ok": true, "payload": { "removed": true } }),
2845        ]);
2846        let target = tempfile::tempdir().unwrap();
2847        CloseCommand {
2848            path: target.path().to_path_buf(),
2849            window_only: false,
2850            dry_run: false,
2851            yes: true,
2852            socket: Some(sock),
2853        }
2854        .execute()
2855        .await
2856        .unwrap();
2857        let reqs = server.await.unwrap();
2858        // Phase 1 is the unconfirmed safety check; phase 2 carries confirmed:true.
2859        assert_eq!(reqs.len(), 2);
2860        assert_eq!(reqs[0]["op"], "close");
2861        assert_eq!(reqs[0]["payload"]["remove"], json!(true));
2862        assert!(
2863            reqs[0]["payload"].get("confirmed").is_none(),
2864            "{:?}",
2865            reqs[0]
2866        );
2867        assert_eq!(reqs[1]["op"], "close");
2868        assert_eq!(reqs[1]["payload"]["remove"], json!(true));
2869        assert_eq!(reqs[1]["payload"]["confirmed"], json!(true));
2870        // A CLI is never a VS Code window, so it never claims a requester_key.
2871        assert!(
2872            reqs[1]["payload"].get("requester_key").is_none(),
2873            "{:?}",
2874            reqs[1]
2875        );
2876    }
2877
2878    #[tokio::test]
2879    async fn close_refuses_a_non_removable_target() {
2880        // Phase-1 reports not-removable (e.g. the main tree); the command prints
2881        // the report then errors without a phase-2 execute (one connection only).
2882        let (_dir, sock, server) = fake_daemon_seq(vec![json!({
2883            "ok": true,
2884            "payload": { "removable": false, "is_main": true, "open": false,
2885                         "window_folder_count": 0, "risks": [], "info": [] }
2886        })]);
2887        let target = tempfile::tempdir().unwrap();
2888        let err = CloseCommand {
2889            path: target.path().to_path_buf(),
2890            window_only: false,
2891            dry_run: false,
2892            yes: true,
2893            socket: Some(sock),
2894        }
2895        .execute()
2896        .await
2897        .unwrap_err();
2898        assert!(
2899            err.to_string().contains("not a removable worktree"),
2900            "{err}"
2901        );
2902        // Only the phase-1 check ran — no destructive phase-2 was sent.
2903        assert_eq!(server.await.unwrap().len(), 1);
2904    }
2905
2906    #[tokio::test]
2907    async fn close_errors_on_a_nonexistent_path_before_any_socket_call() {
2908        let err = CloseCommand {
2909            path: PathBuf::from("/nonexistent/omni-dev-close-xyz"),
2910            window_only: false,
2911            dry_run: false,
2912            yes: true,
2913            socket: Some(PathBuf::from("/nonexistent/omni-dev-close.sock")),
2914        }
2915        .execute()
2916        .await
2917        .unwrap_err();
2918        assert!(
2919            err.to_string().contains("cannot resolve worktree path"),
2920            "{err}"
2921        );
2922    }
2923
2924    #[tokio::test]
2925    async fn show_closed_sets_and_reads() {
2926        // Set: one connection acknowledging set-show-closed.
2927        let (_dir, sock, server) =
2928            fake_daemon_seq(vec![json!({ "ok": true, "payload": { "ok": true } })]);
2929        ShowClosedCommand {
2930            value: Some(false),
2931            socket: Some(sock),
2932        }
2933        .execute()
2934        .await
2935        .unwrap();
2936        let reqs = server.await.unwrap();
2937        assert_eq!(reqs[0]["op"], "set-show-closed");
2938        assert_eq!(reqs[0]["payload"]["show_closed"], json!(false));
2939
2940        // Read: one connection returning a `tree` snapshot's `show_closed`.
2941        let (_dir, sock, server) = fake_daemon_seq(vec![
2942            json!({ "ok": true, "payload": { "repos": [], "show_closed": false } }),
2943        ]);
2944        ShowClosedCommand {
2945            value: None,
2946            socket: Some(sock),
2947        }
2948        .execute()
2949        .await
2950        .unwrap();
2951        // The no-arg read is served by a plain `tree` fetch, not a dedicated op.
2952        assert_eq!(server.await.unwrap()[0]["op"], "tree");
2953    }
2954
2955    #[tokio::test]
2956    async fn register_heartbeat_unregister_send_their_ops() {
2957        let (_dir, sock, server) =
2958            fake_daemon_seq(vec![json!({ "ok": true, "payload": { "ok": true } })]);
2959        RegisterCommand {
2960            key: "w1".to_string(),
2961            folders: vec![PathBuf::from("/a")],
2962            repo_name: Some("r".to_string()),
2963            title: None,
2964            pid: Some(7),
2965            socket: Some(sock),
2966        }
2967        .execute()
2968        .await
2969        .unwrap();
2970        let reqs = server.await.unwrap();
2971        // The RegisterRequest wire shape: op + every field the daemon reads.
2972        assert_eq!(reqs[0]["op"], "register");
2973        assert_eq!(reqs[0]["payload"]["key"], json!("w1"));
2974        assert_eq!(reqs[0]["payload"]["folders"], json!(["/a"]));
2975        assert_eq!(reqs[0]["payload"]["repo"], json!("r"));
2976        assert_eq!(reqs[0]["payload"]["pid"], json!(7));
2977
2978        let (_dir, sock, server) = fake_daemon_seq(vec![
2979            json!({ "ok": true, "payload": { "known": true, "close": true } }),
2980        ]);
2981        HeartbeatCommand {
2982            key: "w1".to_string(),
2983            socket: Some(sock),
2984        }
2985        .execute()
2986        .await
2987        .unwrap();
2988        let reqs = server.await.unwrap();
2989        assert_eq!(reqs[0]["op"], "heartbeat");
2990        assert_eq!(reqs[0]["payload"]["key"], json!("w1"));
2991
2992        let (_dir, sock, server) =
2993            fake_daemon_seq(vec![json!({ "ok": true, "payload": { "removed": true } })]);
2994        UnregisterCommand {
2995            key: "w1".to_string(),
2996            socket: Some(sock),
2997        }
2998        .execute()
2999        .await
3000        .unwrap();
3001        let reqs = server.await.unwrap();
3002        assert_eq!(reqs[0]["op"], "unregister");
3003        assert_eq!(reqs[0]["payload"]["key"], json!("w1"));
3004    }
3005
3006    #[tokio::test]
3007    async fn tree_follow_renders_each_pushed_frame() {
3008        use crate::daemon::testutil::fake_daemon_stream;
3009
3010        // JSON follow: two non-empty frames printed as an NDJSON stream, then EOF.
3011        let (_dir, sock, server) = fake_daemon_stream(vec![
3012            json!({ "ok": true, "payload": { "repos": [], "show_closed": true } }),
3013            json!({ "ok": true, "payload": { "repos": [], "show_closed": false } }),
3014        ]);
3015        follow_tree_stream(&sock, TableOrJson::Json).await.unwrap();
3016        server.await.unwrap();
3017
3018        // Table follow: empty-repos frames render "No repositories open." and never
3019        // trigger an ahead/behind socket call (the enrich guard early-returns).
3020        let (_dir, sock, server) = fake_daemon_stream(vec![
3021            json!({ "ok": true, "payload": { "repos": [], "show_closed": true } }),
3022        ]);
3023        follow_tree_stream(&sock, TableOrJson::Table).await.unwrap();
3024        server.await.unwrap();
3025
3026        // Through `TreeCommand::execute` with `--follow`, covering the follow-dispatch
3027        // branch (not just the free `follow_tree_stream`).
3028        let (_dir, sock, server) = fake_daemon_stream(vec![
3029            json!({ "ok": true, "payload": { "repos": [], "show_closed": true } }),
3030        ]);
3031        TreeCommand {
3032            socket: Some(sock),
3033            output: TableOrJson::Json,
3034            follow: true,
3035        }
3036        .execute()
3037        .await
3038        .unwrap();
3039        server.await.unwrap();
3040    }
3041
3042    #[tokio::test]
3043    async fn worktrees_command_routes_each_new_subcommand() {
3044        // Route every new variant through the outer `WorktreesCommand::execute` so
3045        // its dispatch arms are exercised (the wire-shape tests drive the leaf
3046        // `execute` directly).
3047        let target = tempfile::tempdir().unwrap();
3048        // Close: `--window-only --dry-run` contacts no daemon.
3049        WorktreesCommand {
3050            command: WorktreesSubcommands::Close(CloseCommand {
3051                path: target.path().to_path_buf(),
3052                window_only: true,
3053                dry_run: true,
3054                yes: false,
3055                socket: Some(PathBuf::from("/nonexistent/omni-dev-route.sock")),
3056            }),
3057        }
3058        .execute(None)
3059        .await
3060        .unwrap();
3061
3062        // Rebase: a non-worktree path with `--dry-run` reaches no daemon and no
3063        // remote (it classifies as `not a git worktree` and stops).
3064        WorktreesCommand {
3065            command: WorktreesSubcommands::Rebase(RebaseCommand {
3066                paths: vec![target.path().to_path_buf()],
3067                dry_run: true,
3068                ..rebase_cmd()
3069            }),
3070        }
3071        .execute(None)
3072        .await
3073        .unwrap();
3074
3075        // ShowClosed (set).
3076        let (_d, sock, server) =
3077            fake_daemon_seq(vec![json!({ "ok": true, "payload": { "ok": true } })]);
3078        WorktreesCommand {
3079            command: WorktreesSubcommands::ShowClosed(ShowClosedCommand {
3080                value: Some(true),
3081                socket: Some(sock),
3082            }),
3083        }
3084        .execute(None)
3085        .await
3086        .unwrap();
3087        server.await.unwrap();
3088
3089        // Reposition: `--undo` needs no path resolution, so one reply suffices.
3090        let (_d, sock, server) = fake_daemon_seq(vec![json!({
3091            "ok": true,
3092            "payload": { "trusted": true, "moved": 0, "skipped": 0, "results": [] },
3093        })]);
3094        WorktreesCommand {
3095            command: WorktreesSubcommands::Reposition(RepositionCommand {
3096                paths: Vec::new(),
3097                reference: None,
3098                dry_run: false,
3099                undo: true,
3100                output: TableOrJson::Table,
3101                socket: Some(sock),
3102            }),
3103        }
3104        .execute(None)
3105        .await
3106        .unwrap();
3107        server.await.unwrap();
3108
3109        // Reload: an empty path list resolves nothing, so `list` is the only
3110        // request before the op.
3111        let (_d, sock, server) = fake_daemon_seq(vec![
3112            json!({ "ok": true, "payload": { "windows": [] } }),
3113            json!({ "ok": true, "payload": { "requested": 0, "signalled": 0, "unknown": [] } }),
3114        ]);
3115        WorktreesCommand {
3116            command: WorktreesSubcommands::Reload(ReloadCommand {
3117                paths: Vec::new(),
3118                output: TableOrJson::Table,
3119                socket: Some(sock),
3120            }),
3121        }
3122        .execute(None)
3123        .await
3124        .unwrap();
3125        server.await.unwrap();
3126
3127        // Register.
3128        let (_d, sock, server) =
3129            fake_daemon_seq(vec![json!({ "ok": true, "payload": { "ok": true } })]);
3130        WorktreesCommand {
3131            command: WorktreesSubcommands::Register(RegisterCommand {
3132                key: "w1".to_string(),
3133                folders: vec![],
3134                repo_name: None,
3135                title: None,
3136                pid: None,
3137                socket: Some(sock),
3138            }),
3139        }
3140        .execute(None)
3141        .await
3142        .unwrap();
3143        server.await.unwrap();
3144
3145        // Heartbeat.
3146        let (_d, sock, server) =
3147            fake_daemon_seq(vec![json!({ "ok": true, "payload": { "known": true } })]);
3148        WorktreesCommand {
3149            command: WorktreesSubcommands::Heartbeat(HeartbeatCommand {
3150                key: "w1".to_string(),
3151                socket: Some(sock),
3152            }),
3153        }
3154        .execute(None)
3155        .await
3156        .unwrap();
3157        server.await.unwrap();
3158
3159        // Unregister.
3160        let (_d, sock, server) =
3161            fake_daemon_seq(vec![json!({ "ok": true, "payload": { "removed": true } })]);
3162        WorktreesCommand {
3163            command: WorktreesSubcommands::Unregister(UnregisterCommand {
3164                key: "w1".to_string(),
3165                socket: Some(sock),
3166            }),
3167        }
3168        .execute(None)
3169        .await
3170        .unwrap();
3171        server.await.unwrap();
3172    }
3173
3174    #[tokio::test]
3175    async fn close_aborts_when_confirmation_is_declined() {
3176        // Phase-1 says removable; the injected confirmer declines → the "Aborted"
3177        // branch runs and no phase-2 delete is sent (one connection only). This
3178        // covers the interactive-decline path without driving real stdin.
3179        let (_dir, sock, server) = fake_daemon_seq(vec![json!({
3180            "ok": true,
3181            "payload": { "removable": true, "is_main": false, "open": false,
3182                         "window_folder_count": 0, "risks": [], "info": [] }
3183        })]);
3184        let target = tempfile::tempdir().unwrap();
3185        CloseCommand {
3186            path: target.path().to_path_buf(),
3187            window_only: false,
3188            dry_run: false,
3189            yes: false,
3190            socket: Some(sock),
3191        }
3192        .execute_with(|_has_risks| async { false })
3193        .await
3194        .unwrap();
3195        assert_eq!(server.await.unwrap().len(), 1);
3196    }
3197
3198    #[tokio::test]
3199    async fn close_deletes_when_confirmation_is_accepted() {
3200        // Phase-1 removable, the injected confirmer accepts → phase-2 executes with
3201        // confirmed:true.
3202        let (_dir, sock, server) = fake_daemon_seq(vec![
3203            json!({ "ok": true, "payload": { "removable": true, "is_main": false,
3204                    "open": false, "window_folder_count": 0, "risks": [], "info": [] } }),
3205            json!({ "ok": true, "payload": { "removed": true } }),
3206        ]);
3207        let target = tempfile::tempdir().unwrap();
3208        CloseCommand {
3209            path: target.path().to_path_buf(),
3210            window_only: false,
3211            dry_run: false,
3212            yes: false,
3213            socket: Some(sock),
3214        }
3215        .execute_with(|_has_risks| async { true })
3216        .await
3217        .unwrap();
3218        let reqs = server.await.unwrap();
3219        assert_eq!(reqs.len(), 2);
3220        assert_eq!(reqs[1]["payload"]["confirmed"], json!(true));
3221    }
3222
3223    #[tokio::test]
3224    async fn confirm_removal_with_decides_from_the_answer() {
3225        // A "yes"/"y" answer confirms; "no", an empty line, and a `None` (EOF/read
3226        // error) all decline — for both the risky and clean prompt wordings.
3227        assert!(confirm_removal_with(false, async { Some("y\n".to_string()) }).await);
3228        assert!(confirm_removal_with(true, async { Some("YES".to_string()) }).await);
3229        assert!(!confirm_removal_with(false, async { Some("n".to_string()) }).await);
3230        assert!(!confirm_removal_with(true, async { Some(String::new()) }).await);
3231        assert!(!confirm_removal_with(false, async { None }).await);
3232    }
3233
3234    // ── worktrees rebase (#1400) ──────────────────────────────────────────
3235
3236    /// A `RebaseCommand` with every field defaulted, for terse test construction.
3237    fn rebase_cmd() -> RebaseCommand {
3238        RebaseCommand {
3239            paths: Vec::new(),
3240            all: false,
3241            onto: None,
3242            autostash: false,
3243            dry_run: false,
3244            keep_conflicts: false,
3245            yes: false,
3246            output: TableOrJson::Table,
3247        }
3248    }
3249
3250    #[test]
3251    fn rebase_parses_paths_and_flags() {
3252        let cmd = RebaseCommand::try_parse_from([
3253            "rebase",
3254            "/wt/a",
3255            "/wt/b",
3256            "--onto",
3257            "origin/release",
3258            "--autostash",
3259            "--dry-run",
3260            "--keep-conflicts",
3261            "-y",
3262            "-o",
3263            "json",
3264        ])
3265        .unwrap();
3266        assert_eq!(
3267            cmd.paths,
3268            vec![PathBuf::from("/wt/a"), PathBuf::from("/wt/b")]
3269        );
3270        assert_eq!(cmd.onto.as_deref(), Some("origin/release"));
3271        assert!(cmd.autostash && cmd.dry_run && cmd.keep_conflicts && cmd.yes);
3272        assert!(matches!(cmd.output, TableOrJson::Json));
3273    }
3274
3275    #[test]
3276    fn rebase_defaults_are_conservative() {
3277        let cmd = RebaseCommand::try_parse_from(["rebase", "/wt/a"]).unwrap();
3278        assert!(!cmd.all && !cmd.autostash && !cmd.dry_run && !cmd.yes);
3279        // Aborting a conflict stays the default: the opt-in is `--keep-conflicts`,
3280        // so an unattended batch never leaves a worktree mid-rebase by surprise.
3281        assert!(!cmd.keep_conflicts);
3282        assert_eq!(cmd.onto, None);
3283        assert!(matches!(cmd.output, TableOrJson::Table));
3284    }
3285
3286    #[test]
3287    fn rebase_requires_a_target() {
3288        // A bare `rebase` parses, but resolving its selection refuses rather than
3289        // silently rebasing everything.
3290        let err = rebase_cmd().selection(None).unwrap_err().to_string();
3291        assert!(err.contains("--all"), "expected a usage hint, got: {err}");
3292    }
3293
3294    #[test]
3295    fn rebase_rejects_paths_together_with_all() {
3296        let cmd = RebaseCommand {
3297            paths: vec![PathBuf::from("/wt/a")],
3298            all: true,
3299            ..rebase_cmd()
3300        };
3301        let err = cmd.selection(None).unwrap_err().to_string();
3302        assert!(err.contains("not both"), "got: {err}");
3303    }
3304
3305    #[test]
3306    fn rebase_selection_maps_paths_and_all() {
3307        let cmd = RebaseCommand {
3308            paths: vec![PathBuf::from("/wt/a")],
3309            ..rebase_cmd()
3310        };
3311        assert!(matches!(cmd.selection(None).unwrap(), Selection::Paths(p) if p.len() == 1));
3312        let all = RebaseCommand {
3313            all: true,
3314            ..rebase_cmd()
3315        };
3316        assert!(matches!(
3317            all.selection(None).unwrap(),
3318            Selection::All { .. }
3319        ));
3320    }
3321
3322    #[test]
3323    fn rebase_prompt_agrees_in_number() {
3324        assert!(rebase_prompt(1).contains("1 worktree ("));
3325        assert!(rebase_prompt(3).contains("3 worktrees ("));
3326        // Always names the consequence.
3327        assert!(rebase_prompt(2).contains("rewrites branch history"));
3328    }
3329
3330    #[tokio::test]
3331    async fn confirm_rebase_with_decides_from_the_answer() {
3332        assert!(confirm_rebase_with(1, async { Some("y\n".to_string()) }).await);
3333        assert!(confirm_rebase_with(2, async { Some("YES".to_string()) }).await);
3334        assert!(!confirm_rebase_with(1, async { Some("n".to_string()) }).await);
3335        assert!(!confirm_rebase_with(1, async { Some(String::new()) }).await);
3336        assert!(!confirm_rebase_with(1, async { None }).await);
3337    }
3338
3339    #[test]
3340    fn fetch_line_reports_each_repos_single_fetch() {
3341        let ok = FetchOutcome {
3342            repo_root: PathBuf::from("/repo"),
3343            onto: "origin/main".to_string(),
3344            fetched: true,
3345            ok: true,
3346            detail: None,
3347        };
3348        assert!(fetch_line(&ok).contains("Fetched origin/main once for /repo"));
3349
3350        let failed = FetchOutcome {
3351            detail: Some("host unreachable".to_string()),
3352            ok: false,
3353            ..ok.clone()
3354        };
3355        assert!(fetch_line(&failed).contains("FAILED"));
3356
3357        let local = FetchOutcome {
3358            fetched: false,
3359            onto: "develop".to_string(),
3360            ..ok
3361        };
3362        assert!(fetch_line(&local).contains("nothing fetched"));
3363    }
3364
3365    #[test]
3366    fn outcome_rows_render_each_status() {
3367        let row = |result| {
3368            outcome_row(&WorktreeOutcome {
3369                path: PathBuf::from("/wt"),
3370                branch: Some("feature".to_string()),
3371                onto: "origin/main".to_string(),
3372                result,
3373            })
3374        };
3375        assert!(row(RebaseResult::Rebased { behind: 2 }).contains("rebased"));
3376        assert!(row(RebaseResult::Rebased { behind: 2 }).contains("was 2 behind"));
3377        assert!(row(RebaseResult::WouldRebase { behind: 1 }).contains("would-rebase"));
3378        assert!(row(RebaseResult::UpToDate).contains("up-to-date"));
3379        assert!(row(RebaseResult::Skipped {
3380            reason: SkipReason::Dirty
3381        })
3382        .contains("--autostash"));
3383        assert!(row(RebaseResult::Conflict {
3384            detail: "CONFLICT (content)".to_string(),
3385            left_in_place: false,
3386        })
3387        .contains("conflict"));
3388        // A left-in-place conflict tells the user the worktree still needs
3389        // finishing — a different instruction than an aborted one (#1415).
3390        let kept = row(RebaseResult::Conflict {
3391            detail: "CONFLICT (content)".to_string(),
3392            left_in_place: true,
3393        });
3394        assert!(kept.contains("conflict"), "{kept}");
3395        assert!(kept.contains("git rebase --continue"), "{kept}");
3396        assert!(row(RebaseResult::FetchFailed {
3397            detail: "host unreachable".to_string()
3398        })
3399        .contains("fetch-failed"));
3400        // The remaining skip reasons render their human text.
3401        assert!(row(RebaseResult::Skipped {
3402            reason: SkipReason::DetachedHead
3403        })
3404        .contains("detached HEAD"));
3405        assert!(row(RebaseResult::Skipped {
3406            reason: SkipReason::OperationInProgress
3407        })
3408        .contains("in progress"));
3409        assert!(row(RebaseResult::Skipped {
3410            reason: SkipReason::NotAWorktree
3411        })
3412        .contains("not a git worktree"));
3413        assert!(row(RebaseResult::Skipped {
3414            reason: SkipReason::NoOntoRef
3415        })
3416        .contains("resolve the target ref"));
3417    }
3418
3419    #[test]
3420    fn print_emits_both_json_and_table_without_error() {
3421        let fetches = vec![FetchOutcome {
3422            repo_root: PathBuf::from("/r"),
3423            onto: "origin/main".to_string(),
3424            fetched: true,
3425            ok: true,
3426            detail: None,
3427        }];
3428        let outcomes = vec![WorktreeOutcome {
3429            path: PathBuf::from("/wt"),
3430            branch: Some("feature".to_string()),
3431            onto: "origin/main".to_string(),
3432            result: RebaseResult::UpToDate,
3433        }];
3434        // The JSON branch (serializes the whole report) and the table branch.
3435        let json_cmd = RebaseCommand {
3436            dry_run: true,
3437            output: TableOrJson::Json,
3438            ..rebase_cmd()
3439        };
3440        json_cmd.print(true, &fetches, &outcomes).unwrap();
3441        rebase_cmd().print(false, &fetches, &outcomes).unwrap();
3442    }
3443
3444    #[test]
3445    fn brief_collapses_a_multiline_git_error_to_one_capped_line() {
3446        assert_eq!(brief("\n\nfirst line\nsecond line\n"), "first line");
3447        let long = "x".repeat(200);
3448        let out = brief(&long);
3449        assert_eq!(out.chars().count(), 100);
3450        assert!(out.ends_with("..."));
3451        // Control characters are stripped (the table is untrusted-string safe).
3452        assert_eq!(brief("a\u{7}b"), "ab");
3453    }
3454
3455    #[test]
3456    fn empty_report_renders_placeholders() {
3457        assert_eq!(render_fetches(&[]), "No repository selected.");
3458        assert_eq!(render_outcomes(&[]), "No worktrees selected.");
3459    }
3460
3461    // The serialization guard is a `std::sync::Mutex` held across the `.await`
3462    // below on purpose: the git fetch it serializes runs *during* that await (on a
3463    // `spawn_blocking` thread inside `execute_with`). It is deadlock-safe — the
3464    // awaited work never re-acquires this lock — so the general "no std mutex
3465    // across await" rule does not apply to this test-only load limiter.
3466    #[allow(clippy::await_holding_lock)]
3467    #[tokio::test]
3468    async fn rebase_declined_confirmation_leaves_the_branch_untouched() {
3469        // The safety-critical branch of a history-rewriting command: declining the
3470        // prompt must return before `worktree_rebase::execute` is ever reached.
3471        // Shares the engine tests' git-load lock so the whole suite's concurrent
3472        // `git` spawns never starve the daemon's timing-sensitive poller tests.
3473        let _guard = crate::git::worktree_batch::test_serial_lock();
3474        let Some(scenario) = BehindScenario::build() else {
3475            return; // git unavailable — the engine tests cover the git behaviour.
3476        };
3477        let before = scenario.worktree_head();
3478        RebaseCommand {
3479            paths: vec![scenario.worktree.clone()],
3480            ..rebase_cmd()
3481        }
3482        .execute_with(None, |pending| async move {
3483            assert_eq!(pending, 1, "one worktree is behind and awaiting a rebase");
3484            false
3485        })
3486        .await
3487        .unwrap();
3488        assert_eq!(
3489            scenario.worktree_head(),
3490            before,
3491            "declining the confirm must not rebase"
3492        );
3493    }
3494
3495    // Holds the git-load lock across `.await` for the same deadlock-safe reason as
3496    // the declined-confirm test above.
3497    #[allow(clippy::await_holding_lock)]
3498    #[tokio::test]
3499    async fn rebase_confirmed_rebases_the_behind_worktree() {
3500        // The accepted branch: confirming drives plan → execute → report, and the
3501        // behind worktree fast-forwards onto the freshly fetched origin/main.
3502        let _guard = crate::git::worktree_batch::test_serial_lock();
3503        let Some(scenario) = BehindScenario::build() else {
3504            return; // git unavailable — the engine tests cover the git behaviour.
3505        };
3506        let before = scenario.worktree_head();
3507        RebaseCommand {
3508            paths: vec![scenario.worktree.clone()],
3509            ..rebase_cmd()
3510        }
3511        .execute_with(None, |pending| async move {
3512            assert_eq!(pending, 1);
3513            true
3514        })
3515        .await
3516        .unwrap();
3517        assert_ne!(
3518            scenario.worktree_head(),
3519            before,
3520            "confirming the prompt must rebase the worktree"
3521        );
3522    }
3523
3524    /// A repo whose one linked worktree is a commit behind `origin/main`, built with
3525    /// the real `git` CLI (the command under test shells out too). Returns `None` if
3526    /// any setup step fails, so the suite degrades rather than flaking.
3527    struct BehindScenario {
3528        _root: tempfile::TempDir,
3529        worktree: PathBuf,
3530    }
3531
3532    impl BehindScenario {
3533        fn build() -> Option<Self> {
3534            use git2::Repository;
3535            let root = tempfile::tempdir().ok()?;
3536            let origin = root.path().join("origin.git");
3537            let local = root.path().join("local");
3538            let worktree = root.path().join("feature");
3539            std::fs::create_dir_all(&origin).ok()?;
3540            std::fs::create_dir_all(&local).ok()?;
3541            run(&origin, &["init", "--bare", "-b", "main"])?;
3542            run(&local, &["init", "-b", "main"])?;
3543            Self::identity(&local)?;
3544            std::fs::write(local.join("f.txt"), "one\n").ok()?;
3545            run(&local, &["add", "f.txt"])?;
3546            run(&local, &["commit", "-m", "one"])?;
3547            run(&local, &["remote", "add", "origin", origin.to_str()?])?;
3548            run(&local, &["push", "-u", "origin", "main"])?;
3549            run(
3550                &local,
3551                &[
3552                    "worktree",
3553                    "add",
3554                    "-b",
3555                    "feature",
3556                    worktree.to_str()?,
3557                    "main",
3558                ],
3559            )?;
3560            // Advance origin/main in-process with git2 (no `git clone` subprocess),
3561            // so `local` only learns of it when the command under test fetches.
3562            let repo = Repository::open_bare(&origin).ok()?;
3563            let parent = repo
3564                .find_commit(repo.refname_to_id("refs/heads/main").ok()?)
3565                .ok()?;
3566            let mut builder = repo.treebuilder(Some(&parent.tree().ok()?)).ok()?;
3567            let blob = repo.blob(b"two\n").ok()?;
3568            builder.insert("f.txt", blob, 0o100_644).ok()?;
3569            let tree = repo.find_tree(builder.write().ok()?).ok()?;
3570            let sig = git2::Signature::now("Other", "other@example.com").ok()?;
3571            repo.commit(
3572                Some("refs/heads/main"),
3573                &sig,
3574                &sig,
3575                "two",
3576                &tree,
3577                &[&parent],
3578            )
3579            .ok()?;
3580            Some(Self {
3581                _root: root,
3582                worktree,
3583            })
3584        }
3585
3586        /// Pins identity and disables commit signing, so a developer's global
3587        /// `commit.gpgsign = true` cannot make these repos depend on gpg.
3588        fn identity(dir: &Path) -> Option<()> {
3589            run(dir, &["config", "user.name", "Test"])?;
3590            run(dir, &["config", "user.email", "test@example.com"])?;
3591            run(dir, &["config", "commit.gpgsign", "false"])
3592        }
3593
3594        fn worktree_head(&self) -> String {
3595            let out = std::process::Command::new("git")
3596                .current_dir(&self.worktree)
3597                .args(["rev-parse", "HEAD"])
3598                .output();
3599            out.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
3600                .unwrap_or_default()
3601        }
3602    }
3603
3604    /// Runs `git` in `dir`, returning `None` on any failure.
3605    fn run(dir: &Path, args: &[&str]) -> Option<()> {
3606        let output = std::process::Command::new("git")
3607            .current_dir(dir)
3608            .args(args)
3609            .output()
3610            .ok()?;
3611        output.status.success().then_some(())
3612    }
3613
3614    // --- Merge-queue command (#1401) ---------------------------------------
3615
3616    #[test]
3617    fn merge_queue_parses_paths_and_flags() {
3618        // Routing: `worktrees merge-queue` maps to the MergeQueue variant.
3619        assert!(matches!(
3620            parse(&["merge-queue", "/a"]),
3621            WorktreesSubcommands::MergeQueue(_)
3622        ));
3623        // Multiple positional paths plus `--check`.
3624        let cmd =
3625            MergeQueueCommand::try_parse_from(["merge-queue", "/a", "/b", "--check"]).unwrap();
3626        assert_eq!(cmd.paths.len(), 2);
3627        assert!(cmd.check);
3628        assert!(!cmd.yes);
3629        assert!(cmd.socket.is_none());
3630        // `-y` and `--socket`.
3631        let cmd = MergeQueueCommand::try_parse_from([
3632            "merge-queue",
3633            "/a",
3634            "-y",
3635            "--socket",
3636            "/tmp/d.sock",
3637        ])
3638        .unwrap();
3639        assert!(cmd.yes);
3640        assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
3641        // At least one path is required.
3642        assert!(MergeQueueCommand::try_parse_from(["merge-queue"]).is_err());
3643    }
3644
3645    #[test]
3646    fn render_eligibility_report_lists_eligible_and_skipped() {
3647        let report = json!({
3648            "eligible": [{ "number": 10, "branch": "feature", "url": "u", "path": "/wt/a" }],
3649            "skipped": [{ "path": "/wt/b", "kind": "dirty", "detail": "2 modified" }],
3650        });
3651        let out = render_eligibility_report(&report);
3652        assert!(out.contains("Eligible: 1 / Skipped: 1"), "{out}");
3653        assert!(out.contains("PR #10 [feature] /wt/a"), "{out}");
3654        assert!(out.contains("skipped [dirty]: /wt/b — 2 modified"), "{out}");
3655    }
3656
3657    #[test]
3658    fn render_enqueue_result_marks_already_queued_and_failures() {
3659        let result = json!({
3660            "queued": [
3661                { "number": 10, "path": "/a" },
3662                { "number": 11, "path": "/b", "already_queued": true },
3663            ],
3664            "failed": [{ "number": 12, "path": "/c", "error": "merge queue not enabled" }],
3665            "skipped": [{ "path": "/d", "kind": "unpushed", "detail": "x" }],
3666        });
3667        let out = render_enqueue_result(&result);
3668        assert!(out.contains("Queued: 2 / Failed: 1 / Skipped: 1"), "{out}");
3669        assert!(out.contains("queued: PR #10"), "{out}");
3670        assert!(out.contains("PR #11 (already queued)"), "{out}");
3671        assert!(
3672            out.contains("failed: PR #12 — merge queue not enabled"),
3673            "{out}"
3674        );
3675    }
3676
3677    #[test]
3678    fn render_eligibility_report_strips_control_bytes() {
3679        // Control bytes in every daemon-supplied string must not reach the terminal
3680        // (#1137), matching the close/list/tree renderers.
3681        let report = json!({
3682            "eligible": [{ "number": 1, "branch": "br\x1b[31manch", "path": "/a\rb" }],
3683            "skipped": [{ "path": "/e\x1b]0;x\x07vil", "kind": "d\x07irty", "detail": "l\u{9b}2J" }],
3684        });
3685        let out = render_eligibility_report(&report);
3686        assert!(
3687            !out.contains(|c: char| c.is_control() && c != '\n'),
3688            "{out:?}"
3689        );
3690    }
3691
3692    #[tokio::test]
3693    async fn confirm_enqueue_with_decides_from_the_answer() {
3694        assert!(confirm_enqueue_with(3, async { Some("y\n".to_string()) }).await);
3695        assert!(confirm_enqueue_with(1, async { Some("YES".to_string()) }).await);
3696        assert!(!confirm_enqueue_with(3, async { Some("n".to_string()) }).await);
3697        assert!(!confirm_enqueue_with(3, async { Some(String::new()) }).await);
3698        assert!(!confirm_enqueue_with(3, async { None }).await);
3699    }
3700
3701    #[tokio::test]
3702    async fn merge_queue_errors_on_a_nonexistent_path_before_any_socket_call() {
3703        // Canonicalization fails for a path that does not exist, so the command
3704        // reports a clear error without needing a daemon. Also drives `execute`.
3705        let cmd = MergeQueueCommand {
3706            paths: vec![PathBuf::from("/nonexistent/omni-dev-mq-xyz")],
3707            check: true,
3708            yes: false,
3709            socket: Some(PathBuf::from("/nonexistent/omni-dev-mq.sock")),
3710        };
3711        let err = cmd.execute().await.unwrap_err();
3712        assert!(
3713            err.to_string().contains("cannot resolve worktree path"),
3714            "{err}"
3715        );
3716    }
3717
3718    #[tokio::test]
3719    async fn merge_queue_check_prints_the_report_and_never_confirms() {
3720        let target = tempfile::tempdir().unwrap();
3721        let (_dir, sock, server) = fake_daemon_replies(vec![json!({
3722            "ok": true,
3723            "payload": {
3724                "eligible": [{ "path": "/a", "number": 9, "url": "u", "branch": "feature" }],
3725                "skipped": [{ "path": "/b", "kind": "dirty", "detail": "2 modified" }],
3726            }
3727        })]);
3728        let cmd = MergeQueueCommand {
3729            paths: vec![target.path().to_path_buf()],
3730            check: true,
3731            yes: false,
3732            socket: Some(sock),
3733        };
3734        // `--check` returns after phase 1, so the confirm closure must never run.
3735        cmd.execute_with(|_| async { panic!("must not confirm on --check") })
3736            .await
3737            .unwrap();
3738        server.await.unwrap();
3739    }
3740
3741    #[tokio::test]
3742    async fn merge_queue_reports_nothing_to_enqueue_when_none_eligible() {
3743        let target = tempfile::tempdir().unwrap();
3744        let (_dir, sock, server) = fake_daemon_replies(vec![json!({
3745            "ok": true,
3746            "payload": {
3747                "eligible": [],
3748                "skipped": [{ "path": "/b", "kind": "no-pr", "detail": "no open PR" }],
3749            }
3750        })]);
3751        let cmd = MergeQueueCommand {
3752            paths: vec![target.path().to_path_buf()],
3753            check: false,
3754            yes: false,
3755            socket: Some(sock),
3756        };
3757        // Nothing eligible → no confirm, no phase-2 call.
3758        cmd.execute_with(|_| async { panic!("must not confirm when nothing is eligible") })
3759            .await
3760            .unwrap();
3761        server.await.unwrap();
3762    }
3763
3764    #[tokio::test]
3765    async fn merge_queue_aborts_when_confirmation_is_declined() {
3766        let target = tempfile::tempdir().unwrap();
3767        let (_dir, sock, server) = fake_daemon_replies(vec![json!({
3768            "ok": true,
3769            "payload": {
3770                "eligible": [{ "path": "/a", "number": 9, "url": "u", "branch": "feature" }],
3771                "skipped": [],
3772            }
3773        })]);
3774        let cmd = MergeQueueCommand {
3775            paths: vec![target.path().to_path_buf()],
3776            check: false,
3777            yes: false,
3778            socket: Some(sock),
3779        };
3780        // Declining aborts before the phase-2 call, so only one reply is consumed.
3781        cmd.execute_with(|count| async move {
3782            assert_eq!(count, 1);
3783            false
3784        })
3785        .await
3786        .unwrap();
3787        server.await.unwrap();
3788    }
3789
3790    #[tokio::test]
3791    async fn merge_queue_enqueues_after_confirmation() {
3792        let target = tempfile::tempdir().unwrap();
3793        let (_dir, sock, server) = fake_daemon_replies(vec![
3794            json!({
3795                "ok": true,
3796                "payload": {
3797                    "eligible": [{ "path": "/a", "number": 9, "url": "u", "branch": "feature" }],
3798                    "skipped": [],
3799                }
3800            }),
3801            json!({
3802                "ok": true,
3803                "payload": {
3804                    "queued": [{ "path": "/a", "number": 9 }],
3805                    "skipped": [],
3806                    "failed": [],
3807                }
3808            }),
3809        ]);
3810        let cmd = MergeQueueCommand {
3811            paths: vec![target.path().to_path_buf()],
3812            check: false,
3813            yes: false,
3814            socket: Some(sock),
3815        };
3816        // Confirming drives the phase-2 execute, consuming the second reply.
3817        cmd.execute_with(|_| async { true }).await.unwrap();
3818        server.await.unwrap();
3819    }
3820
3821    #[tokio::test]
3822    async fn merge_queue_check_routes_through_the_worktrees_dispatch() {
3823        // Drives `WorktreesCommand::execute` → `MergeQueue` arm → `execute` (the
3824        // real-`confirm_enqueue` wiring), which `--check` returns from before any
3825        // confirmation, so no stdin is touched.
3826        let target = tempfile::tempdir().unwrap();
3827        let (_dir, sock, server) = fake_daemon_replies(vec![json!({
3828            "ok": true,
3829            "payload": { "eligible": [], "skipped": [] }
3830        })]);
3831        let cmd = WorktreesCommand {
3832            command: WorktreesSubcommands::MergeQueue(MergeQueueCommand {
3833                paths: vec![target.path().to_path_buf()],
3834                check: true,
3835                yes: false,
3836                socket: Some(sock),
3837            }),
3838        };
3839        cmd.execute(None).await.unwrap();
3840        server.await.unwrap();
3841    }
3842
3843    // --- Reposition (#1407) ---------------------------------------------------
3844
3845    #[test]
3846    fn reposition_parses_flags_and_enforces_the_undo_split() {
3847        let WorktreesSubcommands::Reposition(cmd) = parse(&[
3848            "reposition",
3849            "--reference",
3850            "/wt/ref",
3851            "/wt/a",
3852            "/wt/b",
3853            "--dry-run",
3854            "-o",
3855            "json",
3856        ]) else {
3857            panic!("expected the Reposition variant");
3858        };
3859        assert_eq!(cmd.reference.as_deref(), Some(Path::new("/wt/ref")));
3860        assert_eq!(
3861            cmd.paths,
3862            vec![PathBuf::from("/wt/a"), PathBuf::from("/wt/b")]
3863        );
3864        assert!(cmd.dry_run);
3865        assert!(!cmd.undo);
3866        assert_eq!(cmd.output, TableOrJson::Json);
3867
3868        // `--undo` stands alone: no reference needed.
3869        let WorktreesSubcommands::Reposition(undo) = parse(&["reposition", "--undo"]) else {
3870            panic!("expected the Reposition variant");
3871        };
3872        assert!(undo.undo);
3873        assert!(undo.reference.is_none());
3874    }
3875
3876    #[test]
3877    fn reposition_rejects_a_missing_reference_and_undo_combinations() {
3878        // Without `--undo`, a reference is mandatory — otherwise there is no
3879        // geometry to copy and the request cannot mean anything.
3880        assert!(RepositionCommand::try_parse_from(["reposition", "/wt/a"]).is_err());
3881        // `--undo` restores a recorded batch, so pairing it with a reference or a
3882        // dry run would be contradictory rather than merely redundant.
3883        assert!(RepositionCommand::try_parse_from([
3884            "reposition",
3885            "--undo",
3886            "--reference",
3887            "/wt/ref",
3888        ])
3889        .is_err());
3890        assert!(RepositionCommand::try_parse_from(["reposition", "--undo", "--dry-run"]).is_err());
3891    }
3892
3893    #[test]
3894    fn window_key_for_matches_a_canonicalized_folder() {
3895        let dir = tempfile::tempdir_in("/tmp").unwrap();
3896        let wt = dir.path().join("tree");
3897        std::fs::create_dir(&wt).unwrap();
3898        let canonical = std::fs::canonicalize(&wt).unwrap();
3899        let windows = json!({
3900            "windows": [
3901                { "key": "other", "folders": ["/definitely/not/here"] },
3902                { "key": "wanted", "folders": [canonical.to_string_lossy()] },
3903            ]
3904        });
3905        assert_eq!(
3906            window_key_for(&windows, &wt, "repositioned").unwrap(),
3907            "wanted"
3908        );
3909    }
3910
3911    #[test]
3912    fn window_key_for_errors_when_no_window_has_it_open() {
3913        // The CLI names its targets one at a time, so an unmatched one is a
3914        // mistake worth reporting — unlike a tree multi-select, where a stale row
3915        // is expected and skipped.
3916        let dir = tempfile::tempdir_in("/tmp").unwrap();
3917        let err = window_key_for(&json!({ "windows": [] }), dir.path(), "repositioned")
3918            .expect_err("an unopened worktree must not resolve");
3919        assert!(err.to_string().contains("no VS Code window has"), "{err:#}");
3920        // The verb names the caller's action, so the same helper serves both
3921        // commands without either one's error mentioning the other.
3922        let err = window_key_for(&json!({ "windows": [] }), dir.path(), "reloaded")
3923            .expect_err("an unopened worktree must not resolve");
3924        assert!(err.to_string().contains("can be reloaded"), "{err:#}");
3925        // A path that does not exist at all fails earlier, on canonicalization.
3926        let missing = dir.path().join("gone");
3927        let err = window_key_for(&json!({ "windows": [] }), &missing, "repositioned")
3928            .expect_err("a nonexistent path must not resolve");
3929        assert!(err.to_string().contains("cannot resolve"), "{err:#}");
3930    }
3931
3932    #[test]
3933    fn reload_command_requires_at_least_one_path() {
3934        // Unlike the tree view's sweep, the CLI names each target, so an empty
3935        // invocation is a mistake rather than an empty batch.
3936        assert!(ReloadCommand::try_parse_from(["reload"]).is_err());
3937        let cmd = ReloadCommand::try_parse_from(["reload", "/wt/a", "/wt/b"]).unwrap();
3938        assert_eq!(cmd.paths.len(), 2);
3939        assert!(matches!(cmd.output, TableOrJson::Table));
3940        assert!(cmd.socket.is_none());
3941    }
3942
3943    #[test]
3944    fn render_reload_reports_what_was_signalled_not_reloaded() {
3945        // "Signalled" is the only honest word: the directive rides each window's
3946        // ~10s heartbeat, so nothing has reloaded when this prints.
3947        let out = render_reload(&json!({ "requested": 2, "signalled": 2, "unknown": [] }));
3948        assert_eq!(out, "Signalled 2 of 2 windows to reload.");
3949        assert!(!out.contains("Reloaded"), "{out}");
3950        // Singular when exactly one window was signalled.
3951        let one = render_reload(&json!({ "requested": 1, "signalled": 1, "unknown": [] }));
3952        assert_eq!(one, "Signalled 1 of 1 window to reload.");
3953    }
3954
3955    #[test]
3956    fn render_reload_names_windows_that_had_already_closed() {
3957        // A window that closed between the `list` and the op landing is named,
3958        // never silently dropped from the count.
3959        let out = render_reload(&json!({
3960            "requested": 3,
3961            "signalled": 1,
3962            "unknown": ["w2", "w3"],
3963        }));
3964        assert!(
3965            out.starts_with("Signalled 1 of 3 windows to reload."),
3966            "{out}"
3967        );
3968        assert!(out.contains("No longer open"), "{out}");
3969        assert!(out.contains("w2, w3"), "{out}");
3970    }
3971
3972    #[test]
3973    fn render_reload_tolerates_a_reply_missing_every_field() {
3974        // Forward-compatible like the other renderers: a field the daemon did not
3975        // send reads as zero rather than panicking.
3976        assert_eq!(
3977            render_reload(&json!({})),
3978            "Signalled 0 of 0 windows to reload."
3979        );
3980    }
3981
3982    #[test]
3983    fn render_reposition_explains_a_missing_permission() {
3984        let out = render_reposition(&json!({ "trusted": false, "results": [] }));
3985        assert!(out.contains("Accessibility permission"), "{out}");
3986        assert!(out.contains("daemon restart"), "{out}");
3987    }
3988
3989    #[test]
3990    fn render_reposition_reports_a_blocked_batch() {
3991        let out = render_reposition(&json!({
3992            "trusted": true,
3993            "blocked": { "reason": "reference-ambiguous", "detail": "2 windows match “main”" },
3994            "results": [],
3995        }));
3996        assert!(out.contains("Nothing was moved"), "{out}");
3997        assert!(out.contains("reference-ambiguous"), "{out}");
3998        assert!(out.contains("2 windows match"), "{out}");
3999    }
4000
4001    #[test]
4002    fn render_reposition_renders_the_reference_and_per_target_outcomes() {
4003        let out = render_reposition(&json!({
4004            "trusted": true,
4005            "reference": {
4006                "key": "r",
4007                "title": "ref-tree",
4008                "frame": { "x": 10.4, "y": 20.6, "width": 800.0, "height": 600.0 },
4009            },
4010            "moved": 1,
4011            "skipped": 1,
4012            "results": [
4013                { "key": "a", "title": "a-tree", "outcome": "moved", "detail": "moved into position" },
4014                { "key": "b", "title": "b-tree", "outcome": "ambiguous", "detail": "2 match" },
4015            ],
4016        }));
4017        assert!(
4018            out.contains("Reference: ref-tree 800×600 at (10, 21)"),
4019            "{out}"
4020        );
4021        assert!(out.contains("Moved: 1 / Skipped: 1"), "{out}");
4022        assert!(out.contains("moved: a-tree"), "{out}");
4023        assert!(out.contains("ambiguous: b-tree"), "{out}");
4024    }
4025
4026    #[test]
4027    fn render_reposition_falls_back_to_the_key_and_notes_an_empty_batch() {
4028        // A target the daemon could not name (no title reported) is still
4029        // identified, by key.
4030        let out = render_reposition(&json!({
4031            "trusted": true,
4032            "results": [{ "key": "keyless", "outcome": "no-window", "detail": "gone" }],
4033        }));
4034        assert!(out.contains("no-window: keyless"), "{out}");
4035        // An undo with nothing recorded reports rather than printing a bare header.
4036        let empty = render_reposition(&json!({ "trusted": true, "results": [] }));
4037        assert!(empty.contains("(nothing to report)"), "{empty}");
4038    }
4039
4040    #[test]
4041    fn render_reposition_strips_control_bytes_from_daemon_strings() {
4042        // A window title is companion-supplied metadata, so it cannot be allowed
4043        // to inject escape sequences into the operator's terminal (#1137).
4044        let out = render_reposition(&json!({
4045            "trusted": true,
4046            "results": [{
4047                "key": "k",
4048                "title": "evil\u{1b}[31mred",
4049                "outcome": "moved",
4050                "detail": "ok\u{7}",
4051            }],
4052        }));
4053        assert!(!out.contains('\u{1b}'), "{out:?}");
4054        assert!(!out.contains('\u{7}'), "{out:?}");
4055    }
4056
4057    #[test]
4058    fn render_frame_formats_or_dashes() {
4059        assert_eq!(render_frame(None), "-");
4060        assert_eq!(
4061            render_frame(Some(
4062                &json!({ "x": 1.5, "y": -2.4, "width": 100.0, "height": 50.0 })
4063            )),
4064            "100×50 at (2, -2)"
4065        );
4066        // A malformed frame degrades to zeroes rather than panicking.
4067        assert_eq!(render_frame(Some(&json!({}))), "0×0 at (0, 0)");
4068    }
4069
4070    #[test]
4071    fn print_reposition_emits_both_formats() {
4072        let reply = json!({ "trusted": true, "results": [], "moved": 0, "skipped": 0 });
4073        print_reposition(TableOrJson::Table, &reply).unwrap();
4074        print_reposition(TableOrJson::Json, &reply).unwrap();
4075    }
4076
4077    #[tokio::test]
4078    async fn reposition_maps_paths_to_window_keys_and_sends_the_op() {
4079        let dir = tempfile::tempdir_in("/tmp").unwrap();
4080        let reference = dir.path().join("ref");
4081        let target = dir.path().join("tgt");
4082        std::fs::create_dir(&reference).unwrap();
4083        std::fs::create_dir(&target).unwrap();
4084        let (canon_ref, canon_tgt) = (
4085            std::fs::canonicalize(&reference).unwrap(),
4086            std::fs::canonicalize(&target).unwrap(),
4087        );
4088
4089        // Two round trips: the `list` that resolves paths to window keys, then the
4090        // op itself.
4091        let (_sock_dir, sock, server) = fake_daemon_replies(vec![
4092            json!({ "ok": true, "payload": { "windows": [
4093                { "key": "ref-key", "folders": [canon_ref.to_string_lossy()] },
4094                { "key": "tgt-key", "folders": [canon_tgt.to_string_lossy()] },
4095            ] } }),
4096            json!({ "ok": true, "payload": {
4097                "trusted": true,
4098                "moved": 1,
4099                "skipped": 0,
4100                "results": [{ "key": "tgt-key", "outcome": "moved", "detail": "moved into position" }],
4101            } }),
4102        ]);
4103
4104        RepositionCommand {
4105            paths: vec![target],
4106            reference: Some(reference),
4107            dry_run: false,
4108            undo: false,
4109            output: TableOrJson::Json,
4110            socket: Some(sock),
4111        }
4112        .execute()
4113        .await
4114        .unwrap();
4115        server.await.unwrap();
4116    }
4117
4118    #[tokio::test]
4119    async fn reposition_undo_skips_the_list_lookup_entirely() {
4120        // Undo addresses whatever the daemon recorded, so it needs no path
4121        // resolution and issues exactly one request.
4122        let (_dir, sock, server) = fake_daemon_reply(json!({
4123            "ok": true,
4124            "payload": { "trusted": true, "moved": 2, "skipped": 0, "results": [] },
4125        }));
4126        RepositionCommand {
4127            paths: Vec::new(),
4128            reference: None,
4129            dry_run: false,
4130            undo: true,
4131            output: TableOrJson::Table,
4132            socket: Some(sock),
4133        }
4134        .execute()
4135        .await
4136        .unwrap();
4137        server.await.unwrap();
4138    }
4139
4140    #[tokio::test]
4141    async fn reposition_fails_before_the_op_when_a_target_has_no_window() {
4142        let dir = tempfile::tempdir_in("/tmp").unwrap();
4143        let reference = dir.path().join("ref");
4144        let target = dir.path().join("tgt");
4145        std::fs::create_dir(&reference).unwrap();
4146        std::fs::create_dir(&target).unwrap();
4147        let canon_ref = std::fs::canonicalize(&reference).unwrap();
4148
4149        // Only the reference is open, so the target cannot be resolved — and the
4150        // `reposition` op is never sent, which is why one canned reply suffices.
4151        let (_sock_dir, sock, server) = fake_daemon_reply(json!({
4152            "ok": true,
4153            "payload": { "windows": [
4154                { "key": "ref-key", "folders": [canon_ref.to_string_lossy()] },
4155            ] },
4156        }));
4157        let err = RepositionCommand {
4158            paths: vec![target],
4159            reference: Some(reference),
4160            dry_run: true,
4161            undo: false,
4162            output: TableOrJson::Table,
4163            socket: Some(sock),
4164        }
4165        .execute()
4166        .await
4167        .expect_err("an unopened target must abort the command");
4168        assert!(err.to_string().contains("no VS Code window has"), "{err:#}");
4169        server.await.unwrap();
4170    }
4171
4172    #[tokio::test]
4173    async fn reposition_surfaces_a_daemon_error() {
4174        let (_dir, sock, server) = fake_daemon_reply(json!({
4175            "ok": false,
4176            "error": "unknown worktrees op: reposition",
4177        }));
4178        let err = RepositionCommand {
4179            paths: Vec::new(),
4180            reference: None,
4181            dry_run: false,
4182            undo: true,
4183            output: TableOrJson::Table,
4184            socket: Some(sock),
4185        }
4186        .execute()
4187        .await
4188        .expect_err("an `ok:false` reply must not be reported as success");
4189        assert!(err.to_string().contains("unknown worktrees op"), "{err:#}");
4190        server.await.unwrap();
4191    }
4192
4193    /// Two open worktrees plus the canned `list` reply that resolves both, so a
4194    /// reload test only has to supply the op's own reply.
4195    fn reload_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, Value) {
4196        let dir = tempfile::tempdir_in("/tmp").unwrap();
4197        let a = dir.path().join("a");
4198        let b = dir.path().join("b");
4199        std::fs::create_dir(&a).unwrap();
4200        std::fs::create_dir(&b).unwrap();
4201        let list = json!({ "ok": true, "payload": { "windows": [
4202            { "key": "key-a", "folders": [std::fs::canonicalize(&a).unwrap().to_string_lossy()] },
4203            { "key": "key-b", "folders": [std::fs::canonicalize(&b).unwrap().to_string_lossy()] },
4204        ] } });
4205        (dir, a, b, list)
4206    }
4207
4208    #[tokio::test]
4209    async fn reload_resolves_paths_to_window_keys_before_sending_the_op() {
4210        let (_dir, a, b, list) = reload_fixture();
4211        // Two requests: the `list` that maps paths to windows, then the op itself.
4212        // `fake_daemon_seq` (not `_replies`) so the sent payloads can be asserted.
4213        let (_sock_dir, sock, server) = fake_daemon_seq(vec![
4214            list,
4215            json!({ "ok": true, "payload": {
4216                "requested": 2, "signalled": 2, "unknown": [],
4217            } }),
4218        ]);
4219
4220        ReloadCommand {
4221            paths: vec![a, b],
4222            output: TableOrJson::Table,
4223            socket: Some(sock),
4224        }
4225        .execute()
4226        .await
4227        .unwrap();
4228
4229        // The op addresses *windows*: the daemon must receive the resolved keys,
4230        // never the paths the user typed.
4231        let requests = server.await.unwrap();
4232        assert_eq!(requests[1]["op"], "reload");
4233        assert_eq!(
4234            requests[1]["payload"]["target_keys"],
4235            json!(["key-a", "key-b"])
4236        );
4237        assert!(
4238            requests[1]["payload"].get("requester_key").is_none(),
4239            "a CLI process is not a window, so it must not claim to be one"
4240        );
4241    }
4242
4243    #[tokio::test]
4244    async fn reload_json_output_passes_the_reply_through_verbatim() {
4245        let (_dir, a, _b, list) = reload_fixture();
4246        let (_sock_dir, sock, server) = fake_daemon_replies(vec![
4247            list,
4248            json!({ "ok": true, "payload": {
4249                "requested": 1, "signalled": 0, "unknown": ["key-a"],
4250            } }),
4251        ]);
4252        // The `-o json` arm is the machine-readable surface, so it must not go
4253        // through the human renderer.
4254        ReloadCommand {
4255            paths: vec![a],
4256            output: TableOrJson::Json,
4257            socket: Some(sock),
4258        }
4259        .execute()
4260        .await
4261        .unwrap();
4262        server.await.unwrap();
4263    }
4264
4265    #[tokio::test]
4266    async fn reload_fails_before_the_op_when_a_target_has_no_window() {
4267        let (_dir, a, b, _list) = reload_fixture();
4268        // Only `a` is open, so `b` cannot be resolved and the `reload` op is never
4269        // sent — which is why one canned reply suffices. Unlike the tree view's
4270        // silent skip, the CLI named this target explicitly, so it is an error.
4271        let (_sock_dir, sock, server) = fake_daemon_reply(json!({
4272            "ok": true,
4273            "payload": { "windows": [
4274                { "key": "key-a", "folders": [std::fs::canonicalize(&a).unwrap().to_string_lossy()] },
4275            ] },
4276        }));
4277        let err = ReloadCommand {
4278            paths: vec![a, b],
4279            output: TableOrJson::Table,
4280            socket: Some(sock),
4281        }
4282        .execute()
4283        .await
4284        .expect_err("an unopened target must abort the command");
4285        assert!(err.to_string().contains("can be reloaded"), "{err:#}");
4286        server.await.unwrap();
4287    }
4288
4289    #[tokio::test]
4290    async fn reload_surfaces_a_daemon_error() {
4291        let (_dir, a, _b, list) = reload_fixture();
4292        let (_sock_dir, sock, server) = fake_daemon_replies(vec![
4293            list,
4294            json!({ "ok": false, "error": "unknown worktrees op: reload" }),
4295        ]);
4296        // An older daemon that predates #1417 rejects the op; that must surface
4297        // rather than read as a successful no-op.
4298        let err = ReloadCommand {
4299            paths: vec![a],
4300            output: TableOrJson::Table,
4301            socket: Some(sock),
4302        }
4303        .execute()
4304        .await
4305        .expect_err("an `ok:false` reply must not be reported as success");
4306        assert!(err.to_string().contains("unknown worktrees op"), "{err:#}");
4307        server.await.unwrap();
4308    }
4309
4310    // ── worktrees push (#1443) ────────────────────────────────────────────
4311
4312    /// A `PushCommand` with every field defaulted, for terse test construction.
4313    fn push_cmd() -> PushCommand {
4314        PushCommand {
4315            paths: Vec::new(),
4316            all: false,
4317            dry_run: false,
4318            yes: false,
4319            output: TableOrJson::Table,
4320        }
4321    }
4322
4323    #[test]
4324    fn push_parses_paths_and_flags() {
4325        let cmd = PushCommand::try_parse_from(["push", "/a", "/b", "--dry-run", "-y"]).unwrap();
4326        assert_eq!(cmd.paths, vec![PathBuf::from("/a"), PathBuf::from("/b")]);
4327        assert!(cmd.dry_run && cmd.yes);
4328        assert!(!cmd.all);
4329    }
4330
4331    #[test]
4332    fn push_exposes_no_force_escape_hatch() {
4333        // The whole point of ADR-0061 §2: there must be no way to ask this command
4334        // for a lease-free force, so a `--force` argument has to be a parse error.
4335        for flag in ["--force", "-f", "--no-force-if-includes"] {
4336            assert!(
4337                PushCommand::try_parse_from(["push", "/a", flag]).is_err(),
4338                "{flag} must not be accepted"
4339            );
4340        }
4341    }
4342
4343    #[test]
4344    fn push_selection_requires_paths_or_all() {
4345        let err = push_cmd().selection(None).unwrap_err().to_string();
4346        assert!(err.contains("--all"), "{err}");
4347
4348        let both = PushCommand {
4349            paths: vec![PathBuf::from("/a")],
4350            all: true,
4351            ..push_cmd()
4352        };
4353        let err = both.selection(None).unwrap_err().to_string();
4354        assert!(err.contains("not both"), "{err}");
4355    }
4356
4357    #[test]
4358    fn push_selection_resolves_relative_paths_against_the_repo_flag() {
4359        let cmd = PushCommand {
4360            paths: vec![PathBuf::from("wt-a"), PathBuf::from("/abs/wt-b")],
4361            ..push_cmd()
4362        };
4363        let Selection::Paths(paths) = cmd.selection(Some(Path::new("/base"))).unwrap() else {
4364            panic!("expected an explicit path selection");
4365        };
4366        assert_eq!(
4367            paths,
4368            vec![PathBuf::from("/base/wt-a"), PathBuf::from("/abs/wt-b")],
4369            "a relative path resolves against -C, an absolute one is left alone"
4370        );
4371    }
4372
4373    #[tokio::test]
4374    async fn push_dry_run_reaches_no_remote_and_pushes_nothing() {
4375        // A non-worktree path with --dry-run is a complete, side-effect-free run:
4376        // it opens no socket and contacts no remote.
4377        let dir = tempfile::tempdir().unwrap();
4378        PushCommand {
4379            paths: vec![dir.path().to_path_buf()],
4380            dry_run: true,
4381            ..push_cmd()
4382        }
4383        .execute_with(None, |_, _| async { panic!("a dry run must not confirm") })
4384        .await
4385        .unwrap();
4386    }
4387
4388    #[tokio::test]
4389    async fn push_declining_the_confirmation_publishes_nothing() {
4390        let (_root, origin, wt) = push_scenario();
4391        let before = origin_tip(&origin, "refs/heads/feature");
4392
4393        PushCommand {
4394            paths: vec![wt],
4395            ..push_cmd()
4396        }
4397        .execute_with(None, |pending, forced| async move {
4398            assert_eq!((pending, forced), (1, 1));
4399            false
4400        })
4401        .await
4402        .unwrap();
4403
4404        assert_eq!(
4405            origin_tip(&origin, "refs/heads/feature"),
4406            before,
4407            "declining must leave the remote exactly as it was"
4408        );
4409    }
4410
4411    #[tokio::test]
4412    async fn push_confirming_force_pushes_with_the_lease() {
4413        let (_root, origin, wt) = push_scenario();
4414        let rewritten = git2::Repository::open(&wt)
4415            .unwrap()
4416            .head()
4417            .unwrap()
4418            .target();
4419
4420        PushCommand {
4421            paths: vec![wt],
4422            ..push_cmd()
4423        }
4424        .execute_with(None, |_, _| async { true })
4425        .await
4426        .unwrap();
4427
4428        assert_eq!(
4429            origin_tip(&origin, "refs/heads/feature"),
4430            rewritten,
4431            "confirming publishes the rewritten tip"
4432        );
4433    }
4434
4435    #[test]
4436    fn push_prompt_calls_out_the_force_count_separately() {
4437        assert_eq!(push_prompt(1, 0), "Push 1 branch? [y/N] ");
4438        assert_eq!(push_prompt(3, 0), "Push 3 branches? [y/N] ");
4439
4440        let forced = push_prompt(3, 2);
4441        assert!(forced.contains("force-pushing 2 with a lease"), "{forced}");
4442        assert!(
4443            forced.contains("rewritten history"),
4444            "the prompt must say what is actually being published: {forced}"
4445        );
4446    }
4447
4448    #[tokio::test]
4449    async fn push_confirmation_treats_anything_but_yes_as_no() {
4450        assert!(confirm_push_with(1, 1, async { Some("y\n".into()) }).await);
4451        assert!(confirm_push_with(1, 1, async { Some("YES".into()) }).await);
4452        assert!(!confirm_push_with(1, 1, async { Some("n".into()) }).await);
4453        assert!(
4454            !confirm_push_with(1, 1, async { None }).await,
4455            "EOF must never be read as consent"
4456        );
4457    }
4458
4459    #[test]
4460    fn push_rows_render_each_status_with_its_own_instruction() {
4461        let outcome = |result| worktree_push::WorktreeOutcome {
4462            path: PathBuf::from("/wt"),
4463            branch: Some("feature".into()),
4464            remote: "origin".into(),
4465            remote_branch: "feature".into(),
4466            result,
4467        };
4468        let rendered = render_push_outcomes(&[
4469            outcome(worktree_push::PushResult::WouldForce {
4470                ahead: 2,
4471                behind: 1,
4472            }),
4473            outcome(worktree_push::PushResult::Rejected {
4474                detail: "stale info".into(),
4475                stale: true,
4476            }),
4477            outcome(worktree_push::PushResult::Skipped {
4478                reason: worktree_push::SkipReason::DefaultBranchForcePush,
4479            }),
4480        ]);
4481        assert!(rendered.contains("would-force"), "{rendered}");
4482        assert!(rendered.contains("origin/feature"), "{rendered}");
4483        assert!(
4484            rendered.contains("`git fetch` and rebase"),
4485            "a lease refusal must name the fix, not just quote git: {rendered}"
4486        );
4487        assert!(
4488            rendered.contains("refusing to force-push the remote default branch"),
4489            "{rendered}"
4490        );
4491    }
4492
4493    #[test]
4494    fn push_renders_an_empty_selection_without_a_bare_header() {
4495        assert_eq!(render_push_outcomes(&[]), "No worktrees selected.");
4496    }
4497
4498    #[test]
4499    fn push_rows_render_every_remaining_status_and_skip_reason() {
4500        // The complement of `push_rows_render_each_status_with_its_own_instruction`:
4501        // every arm the interesting-cases test does not reach, so a new variant
4502        // cannot be added without a row rendering for it.
4503        use worktree_push::{PushResult, SkipReason};
4504        let outcome = |result| worktree_push::WorktreeOutcome {
4505            path: PathBuf::from("/wt"),
4506            branch: Some("feature".into()),
4507            remote: "origin".into(),
4508            remote_branch: "feature".into(),
4509            result,
4510        };
4511        let rendered = render_push_outcomes(&[
4512            outcome(PushResult::UpToDate),
4513            outcome(PushResult::WouldFastForward { ahead: 3 }),
4514            outcome(PushResult::WouldCreate),
4515            outcome(PushResult::Pushed { forced: true }),
4516            outcome(PushResult::Pushed { forced: false }),
4517            outcome(PushResult::Created),
4518            outcome(PushResult::Rejected {
4519                detail: "pre-receive hook declined".into(),
4520                stale: false,
4521            }),
4522            outcome(PushResult::Skipped {
4523                reason: SkipReason::DetachedHead,
4524            }),
4525            outcome(PushResult::Skipped {
4526                reason: SkipReason::NotAWorktree,
4527            }),
4528            outcome(PushResult::Skipped {
4529                reason: SkipReason::NoRemote,
4530            }),
4531        ]);
4532
4533        for expected in [
4534            "up-to-date",
4535            "3 ahead; fast-forward",
4536            "no upstream yet",
4537            "forced with lease",
4538            "fast-forward",
4539            "upstream set",
4540            "pre-receive hook declined",
4541            "detached HEAD",
4542            "not a git worktree",
4543            "no remote to publish to",
4544        ] {
4545            assert!(
4546                rendered.contains(expected),
4547                "missing {expected:?}: {rendered}"
4548            );
4549        }
4550        assert!(
4551            !rendered.contains("`git fetch` and rebase"),
4552            "only a *lease* refusal earns the fetch-and-rebase instruction: {rendered}"
4553        );
4554    }
4555
4556    #[test]
4557    fn push_rows_render_an_unresolved_destination_as_a_dash() {
4558        // A structural skip resolves no remote, so there is nothing to print in the
4559        // REMOTE column — and an empty cell would read as a rendering bug.
4560        let rendered = render_push_outcomes(&[worktree_push::WorktreeOutcome {
4561            path: PathBuf::from("/wt"),
4562            branch: None,
4563            remote: String::new(),
4564            remote_branch: String::new(),
4565            result: worktree_push::PushResult::Skipped {
4566                reason: worktree_push::SkipReason::NotAWorktree,
4567            },
4568        }]);
4569        let row = rendered.lines().nth(1).unwrap();
4570        assert!(
4571            row.contains(" - "),
4572            "branch and remote both render as `-`: {row}"
4573        );
4574    }
4575
4576    #[test]
4577    fn push_all_selects_the_repository_rather_than_named_paths() {
4578        let cmd = PushCommand {
4579            all: true,
4580            ..push_cmd()
4581        };
4582        let selection = cmd.selection(Some(Path::new("/base"))).unwrap();
4583        match selection {
4584            Selection::All { base } => assert_eq!(base, PathBuf::from("/base")),
4585            other @ Selection::Paths(_) => panic!("expected an --all selection, got {other:?}"),
4586        }
4587    }
4588
4589    #[tokio::test]
4590    async fn push_json_output_carries_the_dry_run_flag_and_the_outcomes() {
4591        // `-o json` is the machine surface, so it must stay a superset of the table:
4592        // the same per-worktree results, plus whether this was a preview.
4593        let (_root, _origin, wt) = push_scenario();
4594        let cmd = PushCommand {
4595            paths: vec![wt.clone()],
4596            dry_run: true,
4597            output: TableOrJson::Json,
4598            ..push_cmd()
4599        };
4600        // Exercises the JSON arm of `print`; the human arm is covered by the
4601        // renderer tests above.
4602        cmd.execute_with(None, |_, _| async { false })
4603            .await
4604            .expect("a dry run must succeed");
4605    }
4606
4607    /// A bare `origin`, a local `main`, and a linked `feature` worktree whose
4608    /// published branch has been **rewritten** — the state a rebase leaves and
4609    /// `push` exists for. Returns `(temp root, origin path, worktree path)`.
4610    fn push_scenario() -> (tempfile::TempDir, PathBuf, PathBuf) {
4611        // Held for the fixture only — that dozen-subprocess burst is what the lock
4612        // exists to cap — and released on return, before any `.await` in the test.
4613        let _guard = crate::git::worktree_batch::test_serial_lock();
4614        let root = tempfile::tempdir().unwrap();
4615        let origin = root.path().join("origin.git");
4616        let local = root.path().join("local");
4617        let wt = root.path().join("feature-wt");
4618        std::fs::create_dir_all(&origin).unwrap();
4619        std::fs::create_dir_all(&local).unwrap();
4620
4621        let git = |dir: &Path, args: &[&str]| {
4622            let out = crate::git::worktree_batch::run_git_in(
4623                &crate::git::resolve_git_binary(),
4624                dir,
4625                args,
4626            )
4627            .unwrap();
4628            assert!(
4629                out.status.success(),
4630                "git {args:?} failed: {}",
4631                String::from_utf8_lossy(&out.stderr)
4632            );
4633        };
4634
4635        git(&origin, &["init", "--bare", "-b", "main"]);
4636        git(&local, &["init", "-b", "main"]);
4637        git(&local, &["config", "user.name", "Test"]);
4638        git(&local, &["config", "user.email", "test@example.com"]);
4639        git(&local, &["config", "commit.gpgsign", "false"]);
4640        std::fs::write(local.join("f.txt"), "base\n").unwrap();
4641        git(&local, &["add", "f.txt"]);
4642        git(&local, &["commit", "-m", "base"]);
4643        git(
4644            &local,
4645            &["remote", "add", "origin", origin.to_str().unwrap()],
4646        );
4647        git(&local, &["push", "-u", "origin", "main"]);
4648        git(
4649            &local,
4650            &[
4651                "worktree",
4652                "add",
4653                "-b",
4654                "feature",
4655                wt.to_str().unwrap(),
4656                "main",
4657            ],
4658        );
4659        std::fs::write(wt.join("g.txt"), "work\n").unwrap();
4660        git(&wt, &["add", "g.txt"]);
4661        git(&wt, &["commit", "-m", "work"]);
4662        git(&wt, &["push", "-u", "origin", "feature"]);
4663        git(&wt, &["commit", "--amend", "-m", "rewritten"]);
4664
4665        (root, origin, std::fs::canonicalize(&wt).unwrap())
4666    }
4667
4668    /// The tip of `refname` in a bare origin, when it exists.
4669    fn origin_tip(origin: &Path, refname: &str) -> Option<git2::Oid> {
4670        git2::Repository::open_bare(origin)
4671            .unwrap()
4672            .refname_to_id(refname)
4673            .ok()
4674    }
4675}