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