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