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