Skip to main content

omni_dev/cli/
worktrees.rs

1//! `omni-dev worktrees` — a thin client for the daemon's cross-window worktree
2//! registry.
3//!
4//! Lifecycle stays on `omni-dev daemon` (`start`/`stop`/`status`/`restart`);
5//! this command sends the `worktrees` service's ops over the daemon's Unix
6//! control socket: the read views (`list`, `tree`, `tree --follow`), the actions
7//! (`focus`, `close`, `show-closed`), and — for typed parity with the companion
8//! (#1361) — the window feed ops (`register`/`heartbeat`/`unregister`) that let a
9//! scripted/headless reporter or an integration test drive the registry the way
10//! the VS Code extension does from each window.
11
12use std::path::{Path, PathBuf};
13
14use anyhow::{bail, Context, Result};
15use chrono::Utc;
16use clap::{Parser, Subcommand};
17use serde_json::{json, Value};
18
19use crate::cli::format::TableOrJson;
20use crate::daemon::client::DaemonClient;
21use crate::daemon::protocol::{DaemonEnvelope, DaemonReply};
22use crate::daemon::server;
23
24/// The `worktrees` service routing key on the daemon control socket.
25const SERVICE: &str = "worktrees";
26
27/// Worktrees: see the repos/worktrees open across every VS Code window, kept
28/// live by the daemon.
29#[derive(Parser)]
30pub struct WorktreesCommand {
31    /// The worktrees subcommand to execute.
32    #[command(subcommand)]
33    pub command: WorktreesSubcommands,
34}
35
36/// Worktrees subcommands.
37#[derive(Subcommand)]
38pub enum WorktreesSubcommands {
39    /// List the repos/worktrees currently open across all windows.
40    List(ListCommand),
41    /// Show every repository and all its worktrees, grouped by repository.
42    Tree(TreeCommand),
43    /// Focus (raise) the VS Code window for a worktree folder.
44    Focus(FocusCommand),
45    /// Close a worktree's window and, for a linked worktree, delete it.
46    Close(CloseCommand),
47    /// Show or set whether closed worktrees are shown across all windows.
48    ShowClosed(ShowClosedCommand),
49    /// Register a window's open worktree folders (companion feed op).
50    Register(RegisterCommand),
51    /// Refresh a window's liveness and read any pending close directive.
52    Heartbeat(HeartbeatCommand),
53    /// Remove a window's registration (companion feed op).
54    Unregister(UnregisterCommand),
55}
56
57impl WorktreesCommand {
58    /// Executes the worktrees command.
59    pub async fn execute(self) -> Result<()> {
60        match self.command {
61            WorktreesSubcommands::List(cmd) => cmd.execute().await,
62            WorktreesSubcommands::Tree(cmd) => cmd.execute().await,
63            WorktreesSubcommands::Focus(cmd) => cmd.execute().await,
64            WorktreesSubcommands::Close(cmd) => cmd.execute().await,
65            WorktreesSubcommands::ShowClosed(cmd) => cmd.execute().await,
66            WorktreesSubcommands::Register(cmd) => cmd.execute().await,
67            WorktreesSubcommands::Heartbeat(cmd) => cmd.execute().await,
68            WorktreesSubcommands::Unregister(cmd) => cmd.execute().await,
69        }
70    }
71}
72
73/// Lists the live cross-window set of open worktrees/repos.
74#[derive(Parser)]
75pub struct ListCommand {
76    /// Control-socket path. Defaults to the per-user runtime location.
77    #[arg(long, value_name = "PATH")]
78    pub socket: Option<PathBuf>,
79    /// Output format.
80    #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
81    pub output: TableOrJson,
82    /// Deprecated: use `-o`/`--output json` instead.
83    #[arg(long, hide = true)]
84    pub json: bool,
85}
86
87impl ListCommand {
88    /// Executes the list command.
89    pub async fn execute(mut self) -> Result<()> {
90        if self.json {
91            eprintln!("warning: --json is deprecated; use -o/--output json instead");
92            self.output = TableOrJson::Json;
93        }
94        let socket = server::resolve_socket(self.socket)?;
95        let result = call(&socket, "list", Value::Null).await?;
96        match self.output {
97            TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
98            TableOrJson::Table => println!("{}", render_windows(&result)),
99        }
100        Ok(())
101    }
102}
103
104/// Shows every repository and all of its worktrees (open or not), grouped by
105/// repository — the daemon's `tree` op, which derives the repos from the open
106/// windows and enumerates each repo's worktrees.
107#[derive(Parser)]
108pub struct TreeCommand {
109    /// Control-socket path. Defaults to the per-user runtime location.
110    #[arg(long, value_name = "PATH")]
111    pub socket: Option<PathBuf>,
112    /// Output format.
113    #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
114    pub output: TableOrJson,
115    /// Stream live snapshots: re-render on every change until interrupted
116    /// (Ctrl-C). Uses the daemon's `subscribe` push op.
117    #[arg(short = 'f', long)]
118    pub follow: bool,
119}
120
121impl TreeCommand {
122    /// Executes the tree command.
123    pub async fn execute(self) -> Result<()> {
124        let socket = server::resolve_socket(self.socket)?;
125        if self.follow {
126            return follow_tree_stream(&socket, self.output).await;
127        }
128        let mut result = call(&socket, "tree", Value::Null).await?;
129        // Ahead/behind is no longer part of the (cheap) streamed `tree` snapshot
130        // (#1306); fetch it on demand for the worktrees we are about to render and
131        // fold it back in, so `worktrees tree` shows the same `+ahead -behind` sync
132        // state as before. Best-effort: an older daemon without the `ahead-behind`
133        // op just renders `-`.
134        enrich_ahead_behind(&socket, &mut result).await;
135        match self.output {
136            TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
137            TableOrJson::Table => println!("{}", render_tree(&result)),
138        }
139        Ok(())
140    }
141}
142
143/// Follows the daemon's `subscribe` push stream, re-rendering the tree on each
144/// snapshot until the daemon closes the stream or the user interrupts (Ctrl-C).
145///
146/// Each frame is enriched with on-demand ahead/behind, exactly like the one-shot
147/// path, so a followed view — table **or** JSON — carries the same shape as a
148/// plain `tree` (the JSON stream stays one compact NDJSON frame per snapshot).
149async fn follow_tree_stream(socket: &Path, output: TableOrJson) -> Result<()> {
150    let mut sub = DaemonClient::new(socket)
151        .subscribe(DaemonEnvelope::service(SERVICE, "subscribe", Value::Null))
152        .await?;
153    loop {
154        tokio::select! {
155            frame = sub.next() => {
156                // `None` = the daemon closed the stream (shutdown); we are done.
157                let Some(frame) = frame else { break };
158                let mut payload = reply_payload(frame?)?;
159                // Enrich before either renderer so `tree --follow` matches the
160                // one-shot `tree` byte-for-byte in JSON and column-for-column in
161                // the table (the one-shot enriches ahead of both branches too).
162                enrich_ahead_behind(socket, &mut payload).await;
163                match output {
164                    // A compact one-line frame per snapshot (an NDJSON stream).
165                    TableOrJson::Json => println!("{}", serde_json::to_string(&payload)?),
166                    TableOrJson::Table => println!("{}", render_tree(&payload)),
167                }
168            }
169            // Ctrl-C ends the follow; dropping `sub` closes the connection,
170            // which the daemon reads as the stream's teardown.
171            _ = tokio::signal::ctrl_c() => break,
172        }
173    }
174    Ok(())
175}
176
177/// Focuses (raises) the VS Code window for a worktree folder.
178///
179/// Reuses the daemon's `open` op — the same launcher path the macOS tray's
180/// per-window "focus" action drives (`OMNI_DEV_VSCODE_BIN` → well-known paths →
181/// `code`), which VS Code uses to reuse an already-open window. This makes that
182/// tray-only capability reachable from the CLI on Linux/headless too (#1113).
183#[derive(Parser)]
184pub struct FocusCommand {
185    /// Worktree folder whose window to focus. Shown by `worktrees tree`/`list`.
186    #[arg(value_name = "PATH")]
187    pub path: PathBuf,
188    /// Control-socket path. Defaults to the per-user runtime location.
189    #[arg(long, value_name = "PATH")]
190    pub socket: Option<PathBuf>,
191}
192
193impl FocusCommand {
194    /// Executes the focus command.
195    pub async fn execute(self) -> Result<()> {
196        // Resolve to an absolute path client-side: the daemon runs in a different
197        // cwd and guards the `open` path as absolute-and-existing, so a relative
198        // path would be meaningless there. A clear error here beats the daemon's.
199        let path = std::fs::canonicalize(&self.path)
200            .with_context(|| format!("cannot resolve worktree path: {}", self.path.display()))?;
201        let socket = server::resolve_socket(self.socket)?;
202        call(&socket, "open", json!({ "path": path.to_string_lossy() })).await?;
203        println!("Focused {}", path.display());
204        Ok(())
205    }
206}
207
208/// Closes a worktree's window and, for a linked worktree, deletes it — the
209/// daemon's two-phase `close` op driven from the CLI.
210///
211/// A CLI process is never a VS Code window, so it omits `requester_key`: the
212/// daemon then treats the close as cross-window, signalling every owning window
213/// to close and waiting (bounded ~20s) for them to unregister before it prunes.
214/// All destructive/git logic (the `git2` prune, the main-tree refusal) stays in
215/// the daemon (ADR-0049); the CLI adds no new authority.
216#[derive(Parser)]
217pub struct CloseCommand {
218    /// Worktree folder to close. A linked worktree is deleted; the main working
219    /// tree only has its window closed (never deleted).
220    #[arg(value_name = "PATH")]
221    pub path: PathBuf,
222    /// Only close the worktree's window(s); never delete the worktree.
223    #[arg(long)]
224    pub window_only: bool,
225    /// Run the safety check and print the report, but do not close or delete.
226    #[arg(long)]
227    pub dry_run: bool,
228    /// Skip the interactive confirmation before deleting.
229    #[arg(short = 'y', long)]
230    pub yes: bool,
231    /// Control-socket path. Defaults to the per-user runtime location.
232    #[arg(long, value_name = "PATH")]
233    pub socket: Option<PathBuf>,
234}
235
236impl CloseCommand {
237    /// Executes the close command, confirming a delete interactively via stdin.
238    pub async fn execute(self) -> Result<()> {
239        self.execute_with(confirm_removal).await
240    }
241
242    /// The close core, with the destructive-confirm decision injected as
243    /// `confirm(has_risks) -> bool`. Splitting it this way keeps the abort and
244    /// confirmed-execute branches unit-testable without driving real stdin (which
245    /// would block a test on a TTY); production wires in [`confirm_removal`].
246    async fn execute_with<F, Fut>(self, confirm: F) -> Result<()>
247    where
248        F: FnOnce(bool) -> Fut,
249        Fut: std::future::Future<Output = bool>,
250    {
251        // Resolve to an absolute path client-side (like `focus`): the daemon runs
252        // in a different cwd and matches the target by canonical path.
253        let path = std::fs::canonicalize(&self.path)
254            .with_context(|| format!("cannot resolve worktree path: {}", self.path.display()))?;
255        let path_str = path.to_string_lossy().to_string();
256        let socket = server::resolve_socket(self.socket)?;
257
258        // "Close Window": non-destructive, no safety check — the daemon closes the
259        // owning window(s) and never inspects git. `--dry-run` is honoured here
260        // too, so the combination never has a side effect.
261        if self.window_only {
262            if self.dry_run {
263                println!(
264                    "Would close the window for {} (dry run; nothing closed)",
265                    path.display()
266                );
267                return Ok(());
268            }
269            call(
270                &socket,
271                "close",
272                json!({ "path": path_str, "remove": false }),
273            )
274            .await?;
275            println!("Closed the window for {}", path.display());
276            return Ok(());
277        }
278
279        // Phase 1: the side-effect-free safety check (remove:true, unconfirmed).
280        let report = call(
281            &socket,
282            "close",
283            json!({ "path": path_str, "remove": true }),
284        )
285        .await?;
286        println!("{}", render_safety_report(&path, &report));
287
288        if self.dry_run {
289            return Ok(());
290        }
291        // The daemon refuses to remove the main working tree; fail fast rather than
292        // send a phase-2 execute it would reject.
293        if report.get("removable").and_then(Value::as_bool) != Some(true) {
294            bail!(
295                "{} is not a removable worktree (nothing deleted); \
296                 use --window-only to just close its window",
297                path.display()
298            );
299        }
300        let has_risks = report
301            .get("risks")
302            .and_then(Value::as_array)
303            .is_some_and(|r| !r.is_empty());
304        if !self.yes && !confirm(has_risks).await {
305            println!("Aborted; nothing was deleted.");
306            return Ok(());
307        }
308
309        // Phase 2: execute the delete.
310        call(
311            &socket,
312            "close",
313            json!({ "path": path_str, "remove": true, "confirmed": true }),
314        )
315        .await?;
316        println!("Deleted worktree {}", path.display());
317        Ok(())
318    }
319}
320
321/// Shows or sets the cross-window "show closed worktrees" toggle.
322///
323/// With a boolean argument it sets the daemon-backed value (`set-show-closed`),
324/// which every subscribed window re-reads; with no argument it reads the current
325/// value from the top-level `show_closed` of a `tree` snapshot.
326#[derive(Parser)]
327pub struct ShowClosedCommand {
328    /// New value (`true`/`false`). Omit to read the current value.
329    #[arg(value_name = "BOOL", value_parser = clap::builder::BoolishValueParser::new())]
330    pub value: Option<bool>,
331    /// Control-socket path. Defaults to the per-user runtime location.
332    #[arg(long, value_name = "PATH")]
333    pub socket: Option<PathBuf>,
334}
335
336impl ShowClosedCommand {
337    /// Executes the show-closed command.
338    pub async fn execute(self) -> Result<()> {
339        let socket = server::resolve_socket(self.socket)?;
340        if let Some(show_closed) = self.value {
341            call(
342                &socket,
343                "set-show-closed",
344                json!({ "show_closed": show_closed }),
345            )
346            .await?;
347            println!("show-closed: {show_closed}");
348        } else {
349            // The value is not a dedicated op — it rides the `tree` snapshot.
350            let tree = call(&socket, "tree", Value::Null).await?;
351            let current = tree
352                .get("show_closed")
353                .and_then(Value::as_bool)
354                .unwrap_or(true);
355            println!("show-closed: {current}");
356        }
357        Ok(())
358    }
359}
360
361/// Registers a window's open worktree folders (a companion feed op).
362///
363/// Exposed as a typed command so scripted/headless reporters and integration
364/// tests can drive the registry the way the VS Code companion does. Mirrors
365/// `RegisterRequest`.
366#[derive(Parser)]
367pub struct RegisterCommand {
368    /// Stable per-window identity (the companion generates a per-activate UUID).
369    #[arg(long, value_name = "KEY")]
370    pub key: String,
371    /// A workspace-folder path (repeatable).
372    #[arg(long = "folder", value_name = "PATH")]
373    pub folders: Vec<PathBuf>,
374    /// Repository root or name, when the window has one.
375    #[arg(long, value_name = "REPO")]
376    pub repo: Option<String>,
377    /// Window title, for display.
378    #[arg(long, value_name = "TITLE")]
379    pub title: Option<String>,
380    /// Reporting process id.
381    #[arg(long, value_name = "PID")]
382    pub pid: Option<u32>,
383    /// Control-socket path. Defaults to the per-user runtime location.
384    #[arg(long, value_name = "PATH")]
385    pub socket: Option<PathBuf>,
386}
387
388impl RegisterCommand {
389    /// Executes the register command.
390    pub async fn execute(self) -> Result<()> {
391        let socket = server::resolve_socket(self.socket)?;
392        let payload = json!({
393            "key": self.key,
394            "folders": self.folders,
395            "repo": self.repo,
396            "title": self.title,
397            "pid": self.pid,
398        });
399        call(&socket, "register", payload).await?;
400        println!("Registered {}", self.key);
401        Ok(())
402    }
403}
404
405/// Refreshes a window's liveness and reports the daemon's reply.
406///
407/// A companion feed op made typed: the reply carries `known` (false asks the
408/// window to re-register after a daemon restart) and, when present, `close` (a
409/// cross-window close directive).
410#[derive(Parser)]
411pub struct HeartbeatCommand {
412    /// The window key to heartbeat.
413    #[arg(long, value_name = "KEY")]
414    pub key: String,
415    /// Control-socket path. Defaults to the per-user runtime location.
416    #[arg(long, value_name = "PATH")]
417    pub socket: Option<PathBuf>,
418}
419
420impl HeartbeatCommand {
421    /// Executes the heartbeat command.
422    pub async fn execute(self) -> Result<()> {
423        let socket = server::resolve_socket(self.socket)?;
424        let reply = call(&socket, "heartbeat", json!({ "key": self.key })).await?;
425        let known = reply.get("known").and_then(Value::as_bool).unwrap_or(false);
426        // `close` is omitted from the reply when false; treat absent as false.
427        let close = reply.get("close").and_then(Value::as_bool).unwrap_or(false);
428        println!("known: {known}");
429        println!("close: {close}");
430        Ok(())
431    }
432}
433
434/// Removes a window's registration — a companion feed op made typed. Prints
435/// whether an entry was actually removed.
436#[derive(Parser)]
437pub struct UnregisterCommand {
438    /// The window key to unregister.
439    #[arg(long, value_name = "KEY")]
440    pub key: String,
441    /// Control-socket path. Defaults to the per-user runtime location.
442    #[arg(long, value_name = "PATH")]
443    pub socket: Option<PathBuf>,
444}
445
446impl UnregisterCommand {
447    /// Executes the unregister command.
448    pub async fn execute(self) -> Result<()> {
449        let socket = server::resolve_socket(self.socket)?;
450        let reply = call(&socket, "unregister", json!({ "key": self.key })).await?;
451        let removed = reply
452            .get("removed")
453            .and_then(Value::as_bool)
454            .unwrap_or(false);
455        println!("removed: {removed}");
456        Ok(())
457    }
458}
459
460/// Renders a phase-1 `close` `SafetyReport` as a human-readable block: whether
461/// the target is removable, whether it is the main tree, whether a window has it
462/// open (and which), and any `risks`/`info` notes. Every daemon-supplied string is
463/// `sanitize`d (#1137); the booleans/counts are daemon-computed and safe.
464fn render_safety_report(path: &Path, report: &Value) -> String {
465    let removable = report
466        .get("removable")
467        .and_then(Value::as_bool)
468        .unwrap_or(false);
469    let is_main = report
470        .get("is_main")
471        .and_then(Value::as_bool)
472        .unwrap_or(false);
473    let open = report.get("open").and_then(Value::as_bool).unwrap_or(false);
474    let mut out = format!("Worktree: {}", path.display());
475    out.push_str(&format!("\n  removable:        {removable}"));
476    out.push_str(&format!("\n  main working tree: {is_main}"));
477    if open {
478        let key = sanitize(
479            report
480                .get("window_key")
481                .and_then(Value::as_str)
482                .unwrap_or("-"),
483        );
484        let count = report
485            .get("window_folder_count")
486            .and_then(Value::as_u64)
487            .unwrap_or(0);
488        out.push_str(&format!(
489            "\n  open in a window:  yes (key {key}, {count} folder(s))"
490        ));
491    } else {
492        out.push_str("\n  open in a window:  no");
493    }
494    out.push_str(&render_notes("risks", report.get("risks")));
495    out.push_str(&render_notes("info", report.get("info")));
496    out
497}
498
499/// Renders a labelled list of `close` safety notes (`risks` or `info`), each a
500/// `- [kind] detail` line with both fields `sanitize`d. Empty when there are none.
501fn render_notes(label: &str, notes: Option<&Value>) -> String {
502    let notes = notes
503        .and_then(Value::as_array)
504        .map(Vec::as_slice)
505        .unwrap_or_default();
506    if notes.is_empty() {
507        return String::new();
508    }
509    let mut out = format!("\n  {label}:");
510    for note in notes {
511        let kind = sanitize(note.get("kind").and_then(Value::as_str).unwrap_or("-"));
512        let detail = sanitize(note.get("detail").and_then(Value::as_str).unwrap_or(""));
513        out.push_str(&format!("\n    - [{kind}] {detail}"));
514    }
515    out
516}
517
518/// Prompts on stderr for confirmation before a destructive delete and returns
519/// whether the user assented, reading the answer from real stdin.
520///
521/// A thin wrapper over [`confirm_removal_with`] that supplies the live stdin
522/// reader; the prompt-and-decide logic is factored out so it stays testable
523/// without driving real stdin.
524async fn confirm_removal(has_risks: bool) -> bool {
525    confirm_removal_with(has_risks, read_stdin_line()).await
526}
527
528/// Prints the confirmation prompt and resolves the (already-injected) read of the
529/// user's answer into a yes/no decision. Any read error, a closed stdin (EOF), or
530/// a join failure surfaces as `None` and is treated as "no", so a delete never
531/// proceeds unattended.
532async fn confirm_removal_with(
533    has_risks: bool,
534    read: impl std::future::Future<Output = Option<String>>,
535) -> bool {
536    use std::io::Write;
537    eprint!("{}", confirm_prompt(has_risks));
538    let _ = std::io::stderr().flush();
539    read.await.as_deref().is_some_and(answer_is_yes)
540}
541
542/// Reads one line from stdin on a dedicated thread (`spawn_blocking`) so it never
543/// stalls an async worker while it waits for input. Returns `None` on any read
544/// error, EOF, or join failure.
545async fn read_stdin_line() -> Option<String> {
546    tokio::task::spawn_blocking(|| read_line_from(&mut std::io::stdin().lock()))
547        .await
548        .ok()
549        .flatten()
550}
551
552/// Reads one line from `reader`, mapping EOF and read errors to the same
553/// `Option<String>` the stdin caller consumes. Split out of [`read_stdin_line`]
554/// so the read logic is testable with an in-memory reader — real stdin can't be
555/// driven from a test without blocking on a TTY.
556fn read_line_from(reader: &mut impl std::io::BufRead) -> Option<String> {
557    let mut answer = String::new();
558    reader.read_line(&mut answer).ok().map(|_| answer)
559}
560
561/// The confirmation prompt shown before a delete — it names the risks when the
562/// safety report flagged any. Pure, so the wording is unit-testable.
563fn confirm_prompt(has_risks: bool) -> &'static str {
564    if has_risks {
565        "Delete this worktree despite the risks above? [y/N] "
566    } else {
567        "Delete this worktree? [y/N] "
568    }
569}
570
571/// Whether a confirmation answer is an affirmative (`y`/`yes`, case-insensitive).
572/// Split out so the yes/no decision is unit-testable without real stdin.
573fn answer_is_yes(answer: &str) -> bool {
574    matches!(answer.trim().to_lowercase().as_str(), "y" | "yes")
575}
576
577/// Fetches ahead/behind on demand for every worktree in a `tree` reply and folds
578/// the counts back into each worktree object, so `worktrees tree` renders the same
579/// `+ahead -behind` sync state the cheap snapshot no longer carries (#1306). A
580/// best-effort enrichment: if there are no worktrees, the daemon lacks the
581/// `ahead-behind` op (older daemon), or the call fails, `result` is left as-is and
582/// the tree still renders — just with `-` for sync.
583async fn enrich_ahead_behind(socket: &Path, result: &mut Value) {
584    let paths = worktree_paths(result);
585    if paths.is_empty() {
586        return;
587    }
588    let Ok(reply) = call(socket, "ahead-behind", json!({ "paths": paths })).await else {
589        return;
590    };
591    if let Some(results) = reply.get("results").and_then(Value::as_object) {
592        merge_ahead_behind(result, results);
593    }
594}
595
596/// Every worktree path in a `tree` reply, in render order — the batch the
597/// on-demand `ahead-behind` op is asked about.
598fn worktree_paths(result: &Value) -> Vec<String> {
599    let mut paths = Vec::new();
600    for repo in result
601        .get("repos")
602        .and_then(Value::as_array)
603        .map(Vec::as_slice)
604        .unwrap_or_default()
605    {
606        for worktree in repo
607            .get("worktrees")
608            .and_then(Value::as_array)
609            .map(Vec::as_slice)
610            .unwrap_or_default()
611        {
612            if let Some(path) = worktree.get("path").and_then(Value::as_str) {
613                paths.push(path.to_string());
614            }
615        }
616    }
617    paths
618}
619
620/// Folds `{ ahead, behind }` counts (keyed by worktree path) from an `ahead-behind`
621/// reply back into a `tree` reply's worktree objects. A worktree whose path is
622/// absent from `results` (no upstream) is left untouched. Pure, so the merge is
623/// unit-testable without a socket.
624fn merge_ahead_behind(result: &mut Value, results: &serde_json::Map<String, Value>) {
625    for repo in result
626        .get_mut("repos")
627        .and_then(Value::as_array_mut)
628        .into_iter()
629        .flatten()
630    {
631        for worktree in repo
632            .get_mut("worktrees")
633            .and_then(Value::as_array_mut)
634            .into_iter()
635            .flatten()
636        {
637            // Take the worktree object up front so the insert reuses this handle
638            // rather than a second, always-succeeding `as_object_mut` (a non-object
639            // element in the array is skipped here).
640            let Some(obj) = worktree.as_object_mut() else {
641                continue;
642            };
643            let Some(path) = obj.get("path").and_then(Value::as_str).map(str::to_string) else {
644                continue;
645            };
646            let Some(counts) = results.get(&path) else {
647                continue;
648            };
649            // Fold both counts in together, or neither — a malformed entry missing
650            // a side is left as no-sync rather than half-applied.
651            if let (Some(ahead), Some(behind)) =
652                (counts.get("ahead").cloned(), counts.get("behind").cloned())
653            {
654                obj.insert("ahead".to_string(), ahead);
655                obj.insert("behind".to_string(), behind);
656            }
657        }
658    }
659}
660
661/// Sends one `worktrees` service op over the control socket, returning its
662/// payload or turning an `ok: false` reply into an error.
663async fn call(socket: &Path, op: &str, payload: Value) -> Result<Value> {
664    let reply = DaemonClient::new(socket)
665        .request(DaemonEnvelope::service(SERVICE, op, payload))
666        .await?;
667    reply_payload(reply)
668}
669
670/// Unwraps a daemon reply into its payload, turning an `ok: false` reply into an
671/// error. Pure (no socket), so both mappings are unit-testable.
672fn reply_payload(reply: DaemonReply) -> Result<Value> {
673    if reply.ok {
674        Ok(reply.payload)
675    } else {
676        bail!(
677            "daemon returned an error: {}",
678            reply.error.as_deref().unwrap_or("unknown error")
679        )
680    }
681}
682
683/// Renders a `list` reply as a human-readable table: a header and one row per
684/// open window (repo, the daemon-computed branch and its ahead/behind sync
685/// state, the primary folder, and how long ago it was last seen). Returns a
686/// placeholder line when nothing is open.
687fn render_windows(result: &Value) -> String {
688    let windows = result
689        .get("windows")
690        .and_then(Value::as_array)
691        .map(Vec::as_slice)
692        .unwrap_or_default();
693    if windows.is_empty() {
694        return "No open windows.".to_string();
695    }
696    let mut out = format!(
697        "{:<22} {:<24} {:<9} {:<40} {:>5}",
698        "REPO", "BRANCH", "SYNC", "FOLDER", "AGE"
699    );
700    for window in windows {
701        let repo = sanitize(repo_name(window));
702        let branch = sanitize(window.get("branch").and_then(Value::as_str).unwrap_or("-"));
703        let sync = sync_summary(window);
704        let folder_disp = folder_summary(window);
705        let age = age_secs(window.get("last_seen").and_then(Value::as_str));
706        out.push_str(&format!(
707            "\n{repo:<22} {branch:<24} {sync:<9} {folder_disp:<40} {age:>4}s"
708        ));
709    }
710    out
711}
712
713/// Renders a `tree` reply as a repo-grouped view: a header line per repository
714/// (its name, GitHub `owner/name` when present, and root path), then one indented
715/// row per worktree — a `*` marks the main working tree, followed by the branch,
716/// its `+ahead -behind` sync state, an `open` flag when a live window has it open,
717/// and the worktree path. Returns a placeholder when no repository is open.
718fn render_tree(result: &Value) -> String {
719    let repos = result
720        .get("repos")
721        .and_then(Value::as_array)
722        .map(Vec::as_slice)
723        .unwrap_or_default();
724    if repos.is_empty() {
725        return "No repositories open.".to_string();
726    }
727    let mut out = String::new();
728    for (i, repo) in repos.iter().enumerate() {
729        // A blank line separates repositories (but not before the first): the
730        // previous worktree row has no trailing newline, so two are needed.
731        if i > 0 {
732            out.push_str("\n\n");
733        }
734        out.push_str(&repo_header(repo));
735        for worktree in repo
736            .get("worktrees")
737            .and_then(Value::as_array)
738            .map(Vec::as_slice)
739            .unwrap_or_default()
740        {
741            out.push('\n');
742            out.push_str(&worktree_row(worktree));
743        }
744    }
745    out
746}
747
748/// The header line for one repo in the tree view: `<name>  (github: owner/name)
749/// <root>`, with the GitHub clause omitted for a non-GitHub repo.
750fn repo_header(repo: &Value) -> String {
751    let name = sanitize(repo.get("main_repo").and_then(Value::as_str).unwrap_or("-"));
752    let root = sanitize(repo.get("root").and_then(Value::as_str).unwrap_or(""));
753    match github_summary(repo) {
754        Some(github) => format!("{name}  ({github})  {root}"),
755        None => format!("{name}  {root}"),
756    }
757}
758
759/// A `github: owner/name` summary for a repo, or `None` when it has no GitHub
760/// identity (a non-GitHub or remote-less repo).
761fn github_summary(repo: &Value) -> Option<String> {
762    let owner = repo.pointer("/github/owner").and_then(Value::as_str)?;
763    let name = repo.pointer("/github/name").and_then(Value::as_str)?;
764    Some(format!("github: {}/{}", sanitize(owner), sanitize(name)))
765}
766
767/// One indented worktree row: a `*` for the main working tree, the branch, the
768/// `+ahead -behind` sync state, an `open` flag when a window has it open, and the
769/// worktree path.
770fn worktree_row(worktree: &Value) -> String {
771    let marker = if worktree.get("is_main").and_then(Value::as_bool) == Some(true) {
772        '*'
773    } else {
774        ' '
775    };
776    let branch = sanitize(
777        worktree
778            .get("branch")
779            .and_then(Value::as_str)
780            .unwrap_or("-"),
781    );
782    let sync = sync_summary(worktree);
783    let open = if worktree.get("open").and_then(Value::as_bool) == Some(true) {
784        "open"
785    } else {
786        ""
787    };
788    let path = sanitize(worktree.get("path").and_then(Value::as_str).unwrap_or(""));
789    format!("  {marker} {branch:<24} {sync:<9} {open:<5} {path}")
790}
791
792/// The repo name to show for a window: the daemon-computed `main_repo` (which
793/// names the *parent* repository of a linked worktree, not its worktree-folder
794/// basename) when present, else the companion-reported `repo`, else `-`.
795fn repo_name(window: &Value) -> &str {
796    window
797        .get("main_repo")
798        .and_then(Value::as_str)
799        .or_else(|| window.get("repo").and_then(Value::as_str))
800        .unwrap_or("-")
801}
802
803/// A compact `+ahead -behind` divergence indicator for a window, or `-` when
804/// the branch tracks no upstream (or there is no branch at all). The counts are
805/// daemon-computed integers, so no sanitizing is needed.
806fn sync_summary(window: &Value) -> String {
807    let ahead = window.get("ahead").and_then(Value::as_u64);
808    let behind = window.get("behind").and_then(Value::as_u64);
809    match (ahead, behind) {
810        (Some(ahead), Some(behind)) => format!("+{ahead} -{behind}"),
811        _ => "-".to_string(),
812    }
813}
814
815/// The primary folder of a window, with a `(+N)` suffix when it has more than
816/// one workspace folder.
817fn folder_summary(window: &Value) -> String {
818    let folders = window
819        .get("folders")
820        .and_then(Value::as_array)
821        .map(Vec::as_slice)
822        .unwrap_or_default();
823    let first = sanitize(folders.first().and_then(Value::as_str).unwrap_or(""));
824    let extra = folders.len().saturating_sub(1);
825    if extra > 0 {
826        format!("{first} (+{extra})")
827    } else {
828        first
829    }
830}
831
832/// Strips control characters (C0, DEL, C1) from an untrusted registry string so
833/// a malicious `register` payload cannot inject terminal escape sequences into
834/// the rendered table (#1137). The `--json` path stays verbatim.
835fn sanitize(s: &str) -> String {
836    s.chars().filter(|c| !c.is_control()).collect()
837}
838
839/// Seconds elapsed since an RFC 3339 timestamp (0 if absent/unparseable).
840fn age_secs(ts: Option<&str>) -> i64 {
841    ts.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
842        .map_or(0, |t| {
843            (Utc::now() - t.with_timezone(&Utc)).num_seconds().max(0)
844        })
845}
846
847#[cfg(test)]
848#[allow(clippy::unwrap_used, clippy::expect_used)]
849mod tests {
850    use super::*;
851    use serde_json::json;
852
853    /// Mirrors the `omni-dev worktrees` argv surface for parse tests.
854    #[derive(Parser)]
855    struct Wrapper {
856        #[command(subcommand)]
857        cmd: WorktreesSubcommands,
858    }
859
860    fn parse(args: &[&str]) -> WorktreesSubcommands {
861        let mut full = vec!["omni-dev"];
862        full.extend_from_slice(args);
863        Wrapper::try_parse_from(full).unwrap().cmd
864    }
865
866    #[test]
867    fn list_parses_flags_and_defaults() {
868        // Routing: `worktrees list` maps to the List variant.
869        assert!(matches!(parse(&["list"]), WorktreesSubcommands::List(_)));
870        // Flags, via the leaf parser (clap treats argv[0] as the command name).
871        let cmd = ListCommand::try_parse_from(["list"]).unwrap();
872        assert_eq!(cmd.output, TableOrJson::Table);
873        assert!(!cmd.json);
874        assert!(cmd.socket.is_none());
875
876        let cmd =
877            ListCommand::try_parse_from(["list", "-o", "json", "--socket", "/tmp/d.sock"]).unwrap();
878        assert_eq!(cmd.output, TableOrJson::Json);
879        assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
880    }
881
882    #[test]
883    fn list_deprecated_json_flag_still_parses() {
884        // `--json` is captured separately; `execute` folds it into `output`.
885        let cmd = ListCommand::try_parse_from(["list", "--json"]).unwrap();
886        assert!(cmd.json);
887        assert_eq!(cmd.output, TableOrJson::Table);
888    }
889
890    #[test]
891    fn tree_parses_flags_and_defaults() {
892        // Routing: `worktrees tree` maps to the Tree variant.
893        assert!(matches!(parse(&["tree"]), WorktreesSubcommands::Tree(_)));
894        let cmd = TreeCommand::try_parse_from(["tree"]).unwrap();
895        assert_eq!(cmd.output, TableOrJson::Table);
896        assert!(cmd.socket.is_none());
897
898        let cmd =
899            TreeCommand::try_parse_from(["tree", "-o", "json", "--socket", "/tmp/d.sock"]).unwrap();
900        assert_eq!(cmd.output, TableOrJson::Json);
901        assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
902    }
903
904    #[test]
905    fn focus_parses_path_and_socket() {
906        // Routing: `worktrees focus` maps to the Focus variant.
907        assert!(matches!(
908            parse(&["focus", "/home/me/wt"]),
909            WorktreesSubcommands::Focus(_)
910        ));
911        // The path is a required positional; `--socket` is optional.
912        let cmd = FocusCommand::try_parse_from(["focus", "/home/me/wt"]).unwrap();
913        assert_eq!(cmd.path, Path::new("/home/me/wt"));
914        assert!(cmd.socket.is_none());
915
916        let cmd = FocusCommand::try_parse_from(["focus", "/home/me/wt", "--socket", "/tmp/d.sock"])
917            .unwrap();
918        assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
919
920        // The path is required.
921        assert!(FocusCommand::try_parse_from(["focus"]).is_err());
922    }
923
924    #[tokio::test]
925    async fn focus_errors_on_a_nonexistent_path_before_any_socket_call() {
926        // Canonicalisation fails for a path that does not exist, so `focus`
927        // reports a clear error without needing a daemon.
928        let cmd = FocusCommand {
929            path: PathBuf::from("/nonexistent/omni-dev-focus-xyz"),
930            socket: Some(PathBuf::from("/nonexistent/omni-dev-focus.sock")),
931        };
932        let err = cmd.execute().await.unwrap_err();
933        assert!(
934            err.to_string().contains("cannot resolve worktree path"),
935            "{err}"
936        );
937    }
938
939    #[tokio::test]
940    async fn focus_sends_the_open_op_for_an_existing_folder() {
941        // A real (temp) folder canonicalises, so `focus` sends the `open` op to
942        // the daemon; the fake daemon acknowledges it. Routed through the top-level
943        // `WorktreesCommand::execute` so its `Focus` dispatch arm is exercised too.
944        let (_dir, sock, server) =
945            fake_daemon_reply(json!({ "ok": true, "payload": { "ok": true } }));
946        let target = tempfile::tempdir().unwrap();
947        let cmd = WorktreesCommand {
948            command: WorktreesSubcommands::Focus(FocusCommand {
949                path: target.path().to_path_buf(),
950                socket: Some(sock),
951            }),
952        };
953        cmd.execute().await.unwrap();
954        server.await.unwrap();
955    }
956
957    #[test]
958    fn render_windows_handles_empty_replies() {
959        assert_eq!(
960            render_windows(&json!({ "windows": [] })),
961            "No open windows."
962        );
963        assert_eq!(render_windows(&json!({})), "No open windows.");
964    }
965
966    #[test]
967    fn render_windows_renders_rows() {
968        let result = json!({ "windows": [{
969            "key": "w1",
970            "repo": "omni-dev",
971            "branch": "issue-1011",
972            "ahead": 2,
973            "behind": 1,
974            "folders": ["/home/me/omni-dev", "/home/me/docs"],
975            "last_seen": "2000-01-01T00:00:00Z",
976        }]});
977        let table = render_windows(&result);
978        assert!(table.contains("omni-dev"), "{table}");
979        // The computed branch and its sync state both render.
980        assert!(table.contains("issue-1011"), "{table}");
981        assert!(table.contains("+2 -1"), "{table}");
982        // Primary folder plus a (+1) for the second workspace folder.
983        assert!(table.contains("/home/me/omni-dev (+1)"), "{table}");
984        // A header line plus exactly one data row.
985        assert_eq!(table.lines().count(), 2, "{table}");
986    }
987
988    #[test]
989    fn render_windows_prefers_main_repo_over_companion_repo() {
990        // A linked worktree: the companion reports the worktree-folder basename,
991        // but the daemon-computed `main_repo` names the parent repo, and that is
992        // what the REPO column shows.
993        let result = json!({ "windows": [{
994            "key": "w1",
995            "repo": "issue-1250",
996            "main_repo": "omni-dev",
997            "branch": "issue-1250",
998            "folders": ["/home/me/worktrees/issue-1250"],
999            "last_seen": "2000-01-01T00:00:00Z",
1000        }]});
1001        let table = render_windows(&result);
1002        assert!(table.contains("omni-dev"), "{table}");
1003        // The misleading worktree-folder basename does not appear in REPO (it is
1004        // still visible in the FOLDER column path).
1005        let data_row = table.lines().nth(1).unwrap();
1006        assert!(data_row.starts_with("omni-dev"), "{data_row}");
1007    }
1008
1009    #[test]
1010    fn repo_name_falls_back_to_companion_repo_then_dash() {
1011        assert_eq!(
1012            repo_name(&json!({ "main_repo": "omni-dev", "repo": "wt" })),
1013            "omni-dev"
1014        );
1015        assert_eq!(repo_name(&json!({ "repo": "wt" })), "wt");
1016        assert_eq!(repo_name(&json!({})), "-");
1017    }
1018
1019    #[test]
1020    fn render_windows_strips_control_bytes() {
1021        // C0 (ESC, CR, BEL), DEL, and C1 (CSI) bytes in every string-valued
1022        // field must not reach the terminal (#1137).
1023        let result = json!({ "windows": [{
1024            "key": "w1",
1025            "repo": "evil\x1b[31mrepo",
1026            "branch": "br\ranch\x07\u{9b}2J",
1027            "folders": ["/tmp/a\x1b]0;owned\x07\u{7f}", "/tmp/b"],
1028            "last_seen": "2000-01-01T00:00:00Z",
1029        }]});
1030        let table = render_windows(&result);
1031        assert!(
1032            !table.contains(|c: char| c.is_control() && c != '\n'),
1033            "{table:?}"
1034        );
1035        // Visible text survives with only the control bytes removed.
1036        assert!(table.contains("evil[31mrepo"), "{table:?}");
1037        assert!(table.contains("branch2J"), "{table:?}");
1038        assert!(table.contains("/tmp/a]0;owned (+1)"), "{table:?}");
1039        // Embedded CR/LF cannot forge extra rows: header plus one data row.
1040        assert_eq!(table.lines().count(), 2, "{table:?}");
1041    }
1042
1043    #[test]
1044    fn sync_summary_formats_or_dashes() {
1045        assert_eq!(sync_summary(&json!({ "ahead": 2, "behind": 1 })), "+2 -1");
1046        assert_eq!(sync_summary(&json!({ "ahead": 0, "behind": 0 })), "+0 -0");
1047        // Branch present but no upstream, or nothing at all → a dash.
1048        assert_eq!(sync_summary(&json!({ "branch": "main" })), "-");
1049        assert_eq!(sync_summary(&json!({})), "-");
1050    }
1051
1052    #[test]
1053    fn folder_summary_strips_control_bytes() {
1054        assert_eq!(
1055            folder_summary(&json!({ "folders": ["/a\x1b[2J/b"] })),
1056            "/a[2J/b"
1057        );
1058    }
1059
1060    #[test]
1061    fn folder_summary_counts_extra_folders() {
1062        assert_eq!(folder_summary(&json!({ "folders": [] })), "");
1063        assert_eq!(folder_summary(&json!({ "folders": ["/a"] })), "/a");
1064        assert_eq!(
1065            folder_summary(&json!({ "folders": ["/a", "/b", "/c"] })),
1066            "/a (+2)"
1067        );
1068    }
1069
1070    #[test]
1071    fn age_secs_handles_absent_and_unparseable_and_past() {
1072        assert_eq!(age_secs(None), 0);
1073        assert_eq!(age_secs(Some("not-a-timestamp")), 0);
1074        assert!(age_secs(Some("2000-01-01T00:00:00Z")) > 0);
1075    }
1076
1077    #[test]
1078    fn render_tree_handles_empty_replies() {
1079        assert_eq!(
1080            render_tree(&json!({ "repos": [] })),
1081            "No repositories open."
1082        );
1083        assert_eq!(render_tree(&json!({})), "No repositories open.");
1084    }
1085
1086    #[test]
1087    fn worktree_paths_collects_every_worktree_in_render_order() {
1088        let result = json!({ "repos": [
1089            // The middle worktree has no `path` and is skipped, not collected.
1090            { "worktrees": [ { "path": "/a" }, { "branch": "detached" }, { "path": "/b" } ] },
1091            { "worktrees": [ { "path": "/c" } ] },
1092        ]});
1093        assert_eq!(worktree_paths(&result), vec!["/a", "/b", "/c"]);
1094        // No repos / no worktrees → an empty batch (nothing to fetch).
1095        assert!(worktree_paths(&json!({})).is_empty());
1096        assert!(worktree_paths(&json!({ "repos": [{ "worktrees": [] }] })).is_empty());
1097    }
1098
1099    #[test]
1100    fn merge_ahead_behind_folds_counts_by_path_and_leaves_others() {
1101        // The on-demand `ahead-behind` op reports one worktree diverging and omits
1102        // the other (no upstream). The merge folds the counts onto the matching
1103        // path and leaves the untracked worktree without sync fields.
1104        let mut result = json!({ "repos": [{ "worktrees": [
1105            { "path": "/a", "branch": "main" },
1106            { "path": "/b", "branch": "feature" },
1107        ]}]});
1108        let results = json!({ "/a": { "ahead": 2, "behind": 1 } });
1109        merge_ahead_behind(&mut result, results.as_object().unwrap());
1110
1111        let worktrees = result.pointer("/repos/0/worktrees").unwrap();
1112        let a = &worktrees[0];
1113        assert_eq!(a.get("ahead").and_then(Value::as_u64), Some(2));
1114        assert_eq!(a.get("behind").and_then(Value::as_u64), Some(1));
1115        // And it renders exactly as an eager snapshot would have.
1116        assert_eq!(sync_summary(a), "+2 -1");
1117        let b = &worktrees[1];
1118        assert!(b.get("ahead").is_none(), "{b:?}");
1119        assert!(b.get("behind").is_none(), "{b:?}");
1120        assert_eq!(sync_summary(b), "-");
1121    }
1122
1123    #[test]
1124    fn merge_ahead_behind_skips_malformed_worktrees_and_counts() {
1125        // Every defensive guard, on malformed input that never comes from a real
1126        // daemon: a non-object array element, a worktree with no `path`, and a
1127        // results entry missing a side. None panics; none is half-applied.
1128        let mut result = json!({ "repos": [{ "worktrees": [
1129            "not-an-object",                       // non-object element → skipped
1130            { "branch": "detached" },              // object, but no path → skipped
1131            { "path": "/a", "branch": "main" },    // matched, but counts malformed
1132        ]}]});
1133        let results = json!({ "/a": { "ahead": 2 } }); // missing `behind`
1134        merge_ahead_behind(&mut result, results.as_object().unwrap());
1135
1136        let worktrees = result.pointer("/repos/0/worktrees").unwrap();
1137        // Non-object element is untouched.
1138        assert_eq!(worktrees[0], json!("not-an-object"));
1139        // Pathless worktree: no sync fields inserted.
1140        assert!(worktrees[1].get("ahead").is_none(), "{:?}", worktrees[1]);
1141        // Malformed counts: neither side folded in (both-or-nothing).
1142        assert!(worktrees[2].get("ahead").is_none(), "{:?}", worktrees[2]);
1143        assert!(worktrees[2].get("behind").is_none(), "{:?}", worktrees[2]);
1144    }
1145
1146    #[tokio::test]
1147    async fn enrich_ahead_behind_is_a_noop_when_there_are_no_worktrees() {
1148        // No worktrees → no batch to fetch → early return before any socket call,
1149        // so even a nonexistent socket leaves the tree untouched.
1150        let mut result = json!({ "repos": [] });
1151        let before = result.clone();
1152        enrich_ahead_behind(Path::new("/nonexistent/omni-dev-ab.sock"), &mut result).await;
1153        assert_eq!(result, before);
1154    }
1155
1156    #[tokio::test]
1157    async fn enrich_ahead_behind_leaves_the_tree_when_the_daemon_is_unreachable() {
1158        // A real worktree but no daemon at the socket → the call fails and the tree
1159        // is returned as-is (rendered with `-` for sync), never erroring.
1160        let mut result =
1161            json!({ "repos": [{ "worktrees": [{ "path": "/x", "branch": "main" }] }] });
1162        enrich_ahead_behind(Path::new("/nonexistent/omni-dev-ab.sock"), &mut result).await;
1163        let wt = result.pointer("/repos/0/worktrees/0").unwrap();
1164        assert!(wt.get("ahead").is_none(), "{wt:?}");
1165        assert!(wt.get("behind").is_none(), "{wt:?}");
1166    }
1167
1168    /// Spawns a minimal fake daemon on a short-path Unix socket that answers the
1169    /// one `ahead-behind` request with `reply` (the daemon's NDJSON reply shape).
1170    /// Returns the temp dir (kept alive for the socket's lifetime), the socket
1171    /// path, and the server task.
1172    fn fake_daemon_reply(
1173        reply: Value,
1174    ) -> (tempfile::TempDir, PathBuf, tokio::task::JoinHandle<()>) {
1175        use futures::{SinkExt, StreamExt};
1176        use tokio::net::UnixListener;
1177        use tokio_util::codec::{Framed, LinesCodec};
1178
1179        // A short base path keeps the socket under the 104-byte `sockaddr_un` limit.
1180        let dir = tempfile::tempdir_in("/tmp").unwrap();
1181        let sock = dir.path().join("d.sock");
1182        let listener = UnixListener::bind(&sock).unwrap();
1183        let server = tokio::spawn(async move {
1184            let (stream, _) = listener.accept().await.unwrap();
1185            let mut framed = Framed::new(stream, LinesCodec::new());
1186            let _req = framed.next().await.unwrap().unwrap();
1187            framed
1188                .send(serde_json::to_string(&reply).unwrap())
1189                .await
1190                .unwrap();
1191        });
1192        (dir, sock, server)
1193    }
1194
1195    #[tokio::test]
1196    async fn enrich_ahead_behind_folds_counts_from_a_live_socket() {
1197        let (_dir, sock, server) = fake_daemon_reply(
1198            json!({ "ok": true, "payload": { "results": { "/x": { "ahead": 3, "behind": 4 } } } }),
1199        );
1200        let mut result =
1201            json!({ "repos": [{ "worktrees": [{ "path": "/x", "branch": "main" }] }] });
1202        enrich_ahead_behind(&sock, &mut result).await;
1203        server.await.unwrap();
1204
1205        let wt = result.pointer("/repos/0/worktrees/0").unwrap();
1206        assert_eq!(wt.get("ahead").and_then(Value::as_u64), Some(3));
1207        assert_eq!(wt.get("behind").and_then(Value::as_u64), Some(4));
1208    }
1209
1210    #[tokio::test]
1211    async fn enrich_ahead_behind_ignores_a_reply_without_results() {
1212        // An `ok` reply carrying no `results` object (an older/oddly-shaped daemon)
1213        // leaves the tree unchanged rather than erroring.
1214        let (_dir, sock, server) = fake_daemon_reply(json!({ "ok": true, "payload": {} }));
1215        let mut result =
1216            json!({ "repos": [{ "worktrees": [{ "path": "/x", "branch": "main" }] }] });
1217        enrich_ahead_behind(&sock, &mut result).await;
1218        server.await.unwrap();
1219
1220        let wt = result.pointer("/repos/0/worktrees/0").unwrap();
1221        assert!(wt.get("ahead").is_none(), "{wt:?}");
1222        assert!(wt.get("behind").is_none(), "{wt:?}");
1223    }
1224
1225    #[test]
1226    fn render_tree_groups_repos_and_worktrees() {
1227        let result = json!({ "repos": [{
1228            "main_repo": "omni-dev",
1229            "github": { "owner": "rust-works", "name": "omni-dev" },
1230            "root": "/home/me/omni-dev",
1231            "worktrees": [
1232                { "path": "/home/me/omni-dev", "branch": "main", "ahead": 2, "behind": 0,
1233                  "is_main": true, "open": true, "window_key": "w1" },
1234                { "path": "/home/me/wt/issue-1300", "branch": "issue-1300", "ahead": 1, "behind": 3,
1235                  "is_main": false, "open": false },
1236            ],
1237        }]});
1238        let out = render_tree(&result);
1239        // Repo header carries the GitHub identity and root.
1240        let header = out.lines().next().unwrap();
1241        assert!(header.contains("omni-dev"), "{out}");
1242        assert!(header.contains("github: rust-works/omni-dev"), "{out}");
1243        assert!(header.contains("/home/me/omni-dev"), "{out}");
1244        // The main working tree is marked with `*`, its sync, and `open`.
1245        assert!(
1246            out.lines()
1247                .any(|l| l.contains("* main") && l.contains("+2 -0") && l.contains("open")),
1248            "{out}"
1249        );
1250        // The linked worktree is unmarked and not flagged open.
1251        let linked = out
1252            .lines()
1253            .find(|l| l.contains("issue-1300"))
1254            .unwrap_or_default();
1255        assert!(!linked.contains('*'), "{linked}");
1256        assert!(!linked.contains("open"), "{linked}");
1257        assert!(linked.contains("+1 -3"), "{linked}");
1258        // Header + two worktree rows.
1259        assert_eq!(out.lines().count(), 3, "{out}");
1260    }
1261
1262    #[test]
1263    fn render_tree_separates_multiple_repos_with_blank_line() {
1264        let result = json!({ "repos": [
1265            {
1266                "main_repo": "alpha",
1267                "root": "/r/alpha",
1268                "worktrees": [
1269                    { "path": "/r/alpha", "branch": "main", "is_main": true, "open": false },
1270                ],
1271            },
1272            {
1273                "main_repo": "beta",
1274                "root": "/r/beta",
1275                "worktrees": [
1276                    { "path": "/r/beta", "branch": "main", "is_main": true, "open": false },
1277                ],
1278            },
1279        ]});
1280        let out = render_tree(&result);
1281        // Two headers, two worktree rows, and one blank separator between repos.
1282        assert!(
1283            out.contains("\n\nbeta"),
1284            "repos not blank-separated: {out:?}"
1285        );
1286        let alpha = out.find("alpha").unwrap();
1287        let beta = out.find("beta").unwrap();
1288        assert!(alpha < beta, "repo order not preserved: {out}");
1289        assert_eq!(out.lines().count(), 5, "{out:?}");
1290    }
1291
1292    #[test]
1293    fn render_tree_omits_github_for_non_github_repo() {
1294        let result = json!({ "repos": [{
1295            "main_repo": "internal",
1296            "root": "/srv/internal",
1297            "worktrees": [
1298                { "path": "/srv/internal", "branch": "main", "is_main": true, "open": false },
1299            ],
1300        }]});
1301        let out = render_tree(&result);
1302        assert!(!out.contains("github:"), "{out}");
1303        assert!(out.lines().next().unwrap().contains("internal"), "{out}");
1304    }
1305
1306    #[test]
1307    fn render_tree_strips_control_bytes() {
1308        // Control bytes in the repo name, github identity, branch, and path must
1309        // not reach the terminal (#1137), matching the `list` renderer.
1310        let result = json!({ "repos": [{
1311            "main_repo": "evil\x1b[31mrepo",
1312            "github": { "owner": "ow\x07ner", "name": "na\u{9b}2Jme" },
1313            "root": "/tmp/r\x1b]0;x\x07oot",
1314            "worktrees": [
1315                { "path": "/tmp/w\rt", "branch": "br\x1b[2Janch", "is_main": true, "open": true },
1316            ],
1317        }]});
1318        let out = render_tree(&result);
1319        assert!(
1320            !out.contains(|c: char| c.is_control() && c != '\n'),
1321            "{out:?}"
1322        );
1323        // Embedded CR/LF cannot forge extra lines: header plus one worktree row.
1324        assert_eq!(out.lines().count(), 2, "{out:?}");
1325    }
1326
1327    #[test]
1328    fn github_summary_needs_both_owner_and_name() {
1329        assert_eq!(
1330            github_summary(&json!({ "github": { "owner": "o", "name": "n" } })).as_deref(),
1331            Some("github: o/n")
1332        );
1333        assert_eq!(github_summary(&json!({ "github": { "owner": "o" } })), None);
1334        assert_eq!(github_summary(&json!({})), None);
1335    }
1336
1337    #[test]
1338    fn reply_payload_unwraps_ok_and_maps_errors() {
1339        // ok → payload.
1340        assert_eq!(
1341            reply_payload(DaemonReply::ok(json!({ "a": 1 }))).unwrap(),
1342            json!({ "a": 1 })
1343        );
1344        // ok: false with a message → that message.
1345        let err = reply_payload(DaemonReply::err("boom")).unwrap_err();
1346        assert!(err.to_string().contains("boom"), "{err}");
1347        // ok: false with no message → the "unknown error" fallback.
1348        let err = reply_payload(DaemonReply {
1349            ok: false,
1350            payload: Value::Null,
1351            error: None,
1352        })
1353        .unwrap_err();
1354        assert!(err.to_string().contains("unknown error"), "{err}");
1355    }
1356
1357    // --- #1361 typed op-parity commands -------------------------------------
1358
1359    #[test]
1360    fn new_subcommands_route_and_require_their_args() {
1361        assert!(matches!(
1362            parse(&["close", "/home/me/wt"]),
1363            WorktreesSubcommands::Close(_)
1364        ));
1365        assert!(matches!(
1366            parse(&["show-closed"]),
1367            WorktreesSubcommands::ShowClosed(_)
1368        ));
1369        assert!(matches!(
1370            parse(&["register", "--key", "w1"]),
1371            WorktreesSubcommands::Register(_)
1372        ));
1373        assert!(matches!(
1374            parse(&["heartbeat", "--key", "w1"]),
1375            WorktreesSubcommands::Heartbeat(_)
1376        ));
1377        assert!(matches!(
1378            parse(&["unregister", "--key", "w1"]),
1379            WorktreesSubcommands::Unregister(_)
1380        ));
1381
1382        // Required args are enforced.
1383        assert!(CloseCommand::try_parse_from(["close"]).is_err());
1384        assert!(RegisterCommand::try_parse_from(["register"]).is_err());
1385        assert!(HeartbeatCommand::try_parse_from(["heartbeat"]).is_err());
1386        assert!(UnregisterCommand::try_parse_from(["unregister"]).is_err());
1387    }
1388
1389    #[test]
1390    fn close_parses_flags() {
1391        let cmd = CloseCommand::try_parse_from([
1392            "close",
1393            "/home/me/wt",
1394            "--window-only",
1395            "--dry-run",
1396            "-y",
1397            "--socket",
1398            "/tmp/d.sock",
1399        ])
1400        .unwrap();
1401        assert_eq!(cmd.path, Path::new("/home/me/wt"));
1402        assert!(cmd.window_only && cmd.dry_run && cmd.yes);
1403        assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
1404
1405        // Defaults: no flags set.
1406        let cmd = CloseCommand::try_parse_from(["close", "/home/me/wt"]).unwrap();
1407        assert!(!cmd.window_only && !cmd.dry_run && !cmd.yes);
1408    }
1409
1410    #[test]
1411    fn tree_follow_flag_parses() {
1412        let cmd = TreeCommand::try_parse_from(["tree", "--follow"]).unwrap();
1413        assert!(cmd.follow);
1414        let cmd = TreeCommand::try_parse_from(["tree", "-f", "-o", "json"]).unwrap();
1415        assert!(cmd.follow);
1416        assert_eq!(cmd.output, TableOrJson::Json);
1417        let cmd = TreeCommand::try_parse_from(["tree"]).unwrap();
1418        assert!(!cmd.follow);
1419    }
1420
1421    #[test]
1422    fn show_closed_parses_optional_bool() {
1423        assert!(ShowClosedCommand::try_parse_from(["show-closed"])
1424            .unwrap()
1425            .value
1426            .is_none());
1427        assert_eq!(
1428            ShowClosedCommand::try_parse_from(["show-closed", "false"])
1429                .unwrap()
1430                .value,
1431            Some(false)
1432        );
1433        assert_eq!(
1434            ShowClosedCommand::try_parse_from(["show-closed", "true"])
1435                .unwrap()
1436                .value,
1437            Some(true)
1438        );
1439        // A non-boolean value is rejected.
1440        assert!(ShowClosedCommand::try_parse_from(["show-closed", "maybe"]).is_err());
1441    }
1442
1443    #[test]
1444    fn register_collects_repeated_folders() {
1445        let cmd = RegisterCommand::try_parse_from([
1446            "register", "--key", "w1", "--folder", "/a", "--folder", "/b", "--repo", "r", "--pid",
1447            "42",
1448        ])
1449        .unwrap();
1450        assert_eq!(cmd.key, "w1");
1451        assert_eq!(cmd.folders, vec![PathBuf::from("/a"), PathBuf::from("/b")]);
1452        assert_eq!(cmd.repo.as_deref(), Some("r"));
1453        assert_eq!(cmd.pid, Some(42));
1454    }
1455
1456    #[test]
1457    fn answer_is_yes_accepts_only_affirmatives() {
1458        for yes in ["y", "Y", "yes", "YES", " yes \n"] {
1459            assert!(answer_is_yes(yes), "{yes:?}");
1460        }
1461        for no in ["", "n", "no", "nope", "true", "\n"] {
1462            assert!(!answer_is_yes(no), "{no:?}");
1463        }
1464    }
1465
1466    #[test]
1467    fn confirm_prompt_mentions_risks_only_when_present() {
1468        // The risky wording names the risks; the clean one does not. Both default
1469        // to No. (No failure-message args — the conditions are self-describing, and
1470        // an unevaluated arg would just read as an uncovered line.)
1471        assert!(confirm_prompt(true).contains("risks"));
1472        assert!(!confirm_prompt(false).contains("risks"));
1473        assert!(confirm_prompt(true).contains("[y/N]"));
1474        assert!(confirm_prompt(false).contains("[y/N]"));
1475    }
1476
1477    #[test]
1478    fn read_line_from_maps_input_and_eof() {
1479        use std::io::Cursor;
1480        // A line (with or without a trailing newline) comes back verbatim; EOF is
1481        // an empty read (`Ok(0)`), which maps to `Some("")` — the decision layer
1482        // then treats it as "no".
1483        assert_eq!(
1484            read_line_from(&mut Cursor::new("y\n")).as_deref(),
1485            Some("y\n")
1486        );
1487        assert_eq!(read_line_from(&mut Cursor::new("")).as_deref(), Some(""));
1488        assert_eq!(
1489            read_line_from(&mut Cursor::new("no-newline")).as_deref(),
1490            Some("no-newline")
1491        );
1492    }
1493
1494    #[test]
1495    fn render_safety_report_renders_fields_and_notes() {
1496        let report = json!({
1497            "removable": true,
1498            "is_main": false,
1499            "open": true,
1500            "window_key": "w1",
1501            "window_folder_count": 2,
1502            "risks": [{ "kind": "dirty", "detail": "uncommitted changes" }],
1503            "info": [{ "kind": "unpushed", "detail": "2 unpushed commits" }],
1504        });
1505        let out = render_safety_report(Path::new("/home/me/wt"), &report);
1506        assert!(out.contains("/home/me/wt"), "{out}");
1507        assert!(out.contains("removable:        true"), "{out}");
1508        assert!(
1509            out.contains("open in a window:  yes (key w1, 2 folder(s))"),
1510            "{out}"
1511        );
1512        assert!(out.contains("[dirty] uncommitted changes"), "{out}");
1513        assert!(out.contains("[unpushed] 2 unpushed commits"), "{out}");
1514    }
1515
1516    #[test]
1517    fn render_safety_report_handles_no_window_and_no_notes() {
1518        let report = json!({ "removable": false, "is_main": true, "open": false });
1519        let out = render_safety_report(Path::new("/r"), &report);
1520        assert!(out.contains("removable:        false"), "{out}");
1521        assert!(out.contains("main working tree: true"), "{out}");
1522        assert!(out.contains("open in a window:  no"), "{out}");
1523        // No risks/info sections are emitted when both are absent.
1524        assert!(!out.contains("risks:"), "{out}");
1525        assert!(!out.contains("info:"), "{out}");
1526    }
1527
1528    #[test]
1529    fn render_safety_report_strips_control_bytes() {
1530        // Daemon-supplied strings (window key, note kind/detail) must not inject
1531        // terminal escapes (#1137).
1532        let report = json!({
1533            "removable": true, "is_main": false, "open": true,
1534            "window_key": "w\x1b[31m1", "window_folder_count": 1,
1535            "risks": [{ "kind": "di\x07rty", "detail": "lost\r\nrow" }],
1536            "info": [],
1537        });
1538        let out = render_safety_report(Path::new("/r"), &report);
1539        assert!(
1540            !out.contains(|c: char| c.is_control() && c != '\n'),
1541            "{out:?}"
1542        );
1543    }
1544
1545    /// Spawns a fake daemon that answers `replies.len()` sequential connections,
1546    /// each with the next reply, and **returns the request envelope(s) it
1547    /// received** (via the join handle) so a test can assert the exact wire shape
1548    /// — op and payload — that the client sent, not just the round-trip. Same
1549    /// short-path `/tmp` socket as `fake_daemon_reply`.
1550    fn fake_daemon_seq(
1551        replies: Vec<Value>,
1552    ) -> (
1553        tempfile::TempDir,
1554        PathBuf,
1555        tokio::task::JoinHandle<Vec<Value>>,
1556    ) {
1557        use futures::{SinkExt, StreamExt};
1558        use tokio::net::UnixListener;
1559        use tokio_util::codec::{Framed, LinesCodec};
1560
1561        let dir = tempfile::tempdir_in("/tmp").unwrap();
1562        let sock = dir.path().join("d.sock");
1563        let listener = UnixListener::bind(&sock).unwrap();
1564        let server = tokio::spawn(async move {
1565            let mut requests = Vec::new();
1566            for reply in replies {
1567                let (stream, _) = listener.accept().await.unwrap();
1568                let mut framed = Framed::new(stream, LinesCodec::new());
1569                let req = framed.next().await.unwrap().unwrap();
1570                requests.push(serde_json::from_str::<Value>(&req).unwrap());
1571                framed
1572                    .send(serde_json::to_string(&reply).unwrap())
1573                    .await
1574                    .unwrap();
1575            }
1576            requests
1577        });
1578        (dir, sock, server)
1579    }
1580
1581    #[tokio::test]
1582    async fn close_window_only_sends_remove_false() {
1583        let (_dir, sock, server) =
1584            fake_daemon_seq(vec![json!({ "ok": true, "payload": { "closed": true } })]);
1585        let target = tempfile::tempdir().unwrap();
1586        CloseCommand {
1587            path: target.path().to_path_buf(),
1588            window_only: true,
1589            dry_run: false,
1590            yes: false,
1591            socket: Some(sock),
1592        }
1593        .execute()
1594        .await
1595        .unwrap();
1596        let reqs = server.await.unwrap();
1597        // Exactly one op, and it is a non-destructive close: remove:false, never
1598        // confirmed. A payload-field rename would fail here.
1599        assert_eq!(reqs.len(), 1);
1600        assert_eq!(reqs[0]["op"], "close");
1601        assert_eq!(reqs[0]["payload"]["remove"], json!(false));
1602        assert!(
1603            reqs[0]["payload"].get("confirmed").is_none(),
1604            "{:?}",
1605            reqs[0]
1606        );
1607        // The path is canonicalized client-side before it is sent.
1608        let want = std::fs::canonicalize(target.path()).unwrap();
1609        assert_eq!(reqs[0]["payload"]["path"], json!(want.to_string_lossy()));
1610    }
1611
1612    #[tokio::test]
1613    async fn close_window_only_dry_run_never_contacts_the_daemon() {
1614        // `--window-only --dry-run` must have no side effect: it prints what would
1615        // happen and returns without a socket call, so a nonexistent socket is fine.
1616        let target = tempfile::tempdir().unwrap();
1617        CloseCommand {
1618            path: target.path().to_path_buf(),
1619            window_only: true,
1620            dry_run: true,
1621            yes: false,
1622            socket: Some(PathBuf::from("/nonexistent/omni-dev-close-dry.sock")),
1623        }
1624        .execute()
1625        .await
1626        .unwrap();
1627    }
1628
1629    #[tokio::test]
1630    async fn close_dry_run_only_runs_phase_one() {
1631        // A single connection: the safety check. `--dry-run` never sends phase-2.
1632        let (_dir, sock, server) = fake_daemon_seq(vec![json!({
1633            "ok": true,
1634            "payload": { "removable": true, "is_main": false, "open": false,
1635                         "window_folder_count": 0, "risks": [], "info": [] }
1636        })]);
1637        let target = tempfile::tempdir().unwrap();
1638        CloseCommand {
1639            path: target.path().to_path_buf(),
1640            window_only: false,
1641            dry_run: true,
1642            yes: false,
1643            socket: Some(sock),
1644        }
1645        .execute()
1646        .await
1647        .unwrap();
1648        let reqs = server.await.unwrap();
1649        // Only the phase-1 safety check: remove:true, unconfirmed. No phase-2.
1650        assert_eq!(reqs.len(), 1);
1651        assert_eq!(reqs[0]["op"], "close");
1652        assert_eq!(reqs[0]["payload"]["remove"], json!(true));
1653        assert!(
1654            reqs[0]["payload"].get("confirmed").is_none(),
1655            "{:?}",
1656            reqs[0]
1657        );
1658    }
1659
1660    #[tokio::test]
1661    async fn close_yes_executes_phase_two() {
1662        // Two connections: phase-1 safety report (removable), then phase-2 delete.
1663        let (_dir, sock, server) = fake_daemon_seq(vec![
1664            json!({ "ok": true, "payload": { "removable": true, "is_main": false,
1665                    "open": false, "window_folder_count": 0, "risks": [], "info": [] } }),
1666            json!({ "ok": true, "payload": { "removed": true } }),
1667        ]);
1668        let target = tempfile::tempdir().unwrap();
1669        CloseCommand {
1670            path: target.path().to_path_buf(),
1671            window_only: false,
1672            dry_run: false,
1673            yes: true,
1674            socket: Some(sock),
1675        }
1676        .execute()
1677        .await
1678        .unwrap();
1679        let reqs = server.await.unwrap();
1680        // Phase 1 is the unconfirmed safety check; phase 2 carries confirmed:true.
1681        assert_eq!(reqs.len(), 2);
1682        assert_eq!(reqs[0]["op"], "close");
1683        assert_eq!(reqs[0]["payload"]["remove"], json!(true));
1684        assert!(
1685            reqs[0]["payload"].get("confirmed").is_none(),
1686            "{:?}",
1687            reqs[0]
1688        );
1689        assert_eq!(reqs[1]["op"], "close");
1690        assert_eq!(reqs[1]["payload"]["remove"], json!(true));
1691        assert_eq!(reqs[1]["payload"]["confirmed"], json!(true));
1692        // A CLI is never a VS Code window, so it never claims a requester_key.
1693        assert!(
1694            reqs[1]["payload"].get("requester_key").is_none(),
1695            "{:?}",
1696            reqs[1]
1697        );
1698    }
1699
1700    #[tokio::test]
1701    async fn close_refuses_a_non_removable_target() {
1702        // Phase-1 reports not-removable (e.g. the main tree); the command prints
1703        // the report then errors without a phase-2 execute (one connection only).
1704        let (_dir, sock, server) = fake_daemon_seq(vec![json!({
1705            "ok": true,
1706            "payload": { "removable": false, "is_main": true, "open": false,
1707                         "window_folder_count": 0, "risks": [], "info": [] }
1708        })]);
1709        let target = tempfile::tempdir().unwrap();
1710        let err = CloseCommand {
1711            path: target.path().to_path_buf(),
1712            window_only: false,
1713            dry_run: false,
1714            yes: true,
1715            socket: Some(sock),
1716        }
1717        .execute()
1718        .await
1719        .unwrap_err();
1720        assert!(
1721            err.to_string().contains("not a removable worktree"),
1722            "{err}"
1723        );
1724        // Only the phase-1 check ran — no destructive phase-2 was sent.
1725        assert_eq!(server.await.unwrap().len(), 1);
1726    }
1727
1728    #[tokio::test]
1729    async fn close_errors_on_a_nonexistent_path_before_any_socket_call() {
1730        let err = CloseCommand {
1731            path: PathBuf::from("/nonexistent/omni-dev-close-xyz"),
1732            window_only: false,
1733            dry_run: false,
1734            yes: true,
1735            socket: Some(PathBuf::from("/nonexistent/omni-dev-close.sock")),
1736        }
1737        .execute()
1738        .await
1739        .unwrap_err();
1740        assert!(
1741            err.to_string().contains("cannot resolve worktree path"),
1742            "{err}"
1743        );
1744    }
1745
1746    #[tokio::test]
1747    async fn show_closed_sets_and_reads() {
1748        // Set: one connection acknowledging set-show-closed.
1749        let (_dir, sock, server) =
1750            fake_daemon_seq(vec![json!({ "ok": true, "payload": { "ok": true } })]);
1751        ShowClosedCommand {
1752            value: Some(false),
1753            socket: Some(sock),
1754        }
1755        .execute()
1756        .await
1757        .unwrap();
1758        let reqs = server.await.unwrap();
1759        assert_eq!(reqs[0]["op"], "set-show-closed");
1760        assert_eq!(reqs[0]["payload"]["show_closed"], json!(false));
1761
1762        // Read: one connection returning a `tree` snapshot's `show_closed`.
1763        let (_dir, sock, server) = fake_daemon_seq(vec![
1764            json!({ "ok": true, "payload": { "repos": [], "show_closed": false } }),
1765        ]);
1766        ShowClosedCommand {
1767            value: None,
1768            socket: Some(sock),
1769        }
1770        .execute()
1771        .await
1772        .unwrap();
1773        // The no-arg read is served by a plain `tree` fetch, not a dedicated op.
1774        assert_eq!(server.await.unwrap()[0]["op"], "tree");
1775    }
1776
1777    #[tokio::test]
1778    async fn register_heartbeat_unregister_send_their_ops() {
1779        let (_dir, sock, server) =
1780            fake_daemon_seq(vec![json!({ "ok": true, "payload": { "ok": true } })]);
1781        RegisterCommand {
1782            key: "w1".to_string(),
1783            folders: vec![PathBuf::from("/a")],
1784            repo: Some("r".to_string()),
1785            title: None,
1786            pid: Some(7),
1787            socket: Some(sock),
1788        }
1789        .execute()
1790        .await
1791        .unwrap();
1792        let reqs = server.await.unwrap();
1793        // The RegisterRequest wire shape: op + every field the daemon reads.
1794        assert_eq!(reqs[0]["op"], "register");
1795        assert_eq!(reqs[0]["payload"]["key"], json!("w1"));
1796        assert_eq!(reqs[0]["payload"]["folders"], json!(["/a"]));
1797        assert_eq!(reqs[0]["payload"]["repo"], json!("r"));
1798        assert_eq!(reqs[0]["payload"]["pid"], json!(7));
1799
1800        let (_dir, sock, server) = fake_daemon_seq(vec![
1801            json!({ "ok": true, "payload": { "known": true, "close": true } }),
1802        ]);
1803        HeartbeatCommand {
1804            key: "w1".to_string(),
1805            socket: Some(sock),
1806        }
1807        .execute()
1808        .await
1809        .unwrap();
1810        let reqs = server.await.unwrap();
1811        assert_eq!(reqs[0]["op"], "heartbeat");
1812        assert_eq!(reqs[0]["payload"]["key"], json!("w1"));
1813
1814        let (_dir, sock, server) =
1815            fake_daemon_seq(vec![json!({ "ok": true, "payload": { "removed": true } })]);
1816        UnregisterCommand {
1817            key: "w1".to_string(),
1818            socket: Some(sock),
1819        }
1820        .execute()
1821        .await
1822        .unwrap();
1823        let reqs = server.await.unwrap();
1824        assert_eq!(reqs[0]["op"], "unregister");
1825        assert_eq!(reqs[0]["payload"]["key"], json!("w1"));
1826    }
1827
1828    #[tokio::test]
1829    async fn tree_follow_renders_each_pushed_frame() {
1830        use crate::daemon::testutil::fake_daemon_stream;
1831
1832        // JSON follow: two non-empty frames printed as an NDJSON stream, then EOF.
1833        let (_dir, sock, server) = fake_daemon_stream(vec![
1834            json!({ "ok": true, "payload": { "repos": [], "show_closed": true } }),
1835            json!({ "ok": true, "payload": { "repos": [], "show_closed": false } }),
1836        ]);
1837        follow_tree_stream(&sock, TableOrJson::Json).await.unwrap();
1838        server.await.unwrap();
1839
1840        // Table follow: empty-repos frames render "No repositories open." and never
1841        // trigger an ahead/behind socket call (the enrich guard early-returns).
1842        let (_dir, sock, server) = fake_daemon_stream(vec![
1843            json!({ "ok": true, "payload": { "repos": [], "show_closed": true } }),
1844        ]);
1845        follow_tree_stream(&sock, TableOrJson::Table).await.unwrap();
1846        server.await.unwrap();
1847
1848        // Through `TreeCommand::execute` with `--follow`, covering the follow-dispatch
1849        // branch (not just the free `follow_tree_stream`).
1850        let (_dir, sock, server) = fake_daemon_stream(vec![
1851            json!({ "ok": true, "payload": { "repos": [], "show_closed": true } }),
1852        ]);
1853        TreeCommand {
1854            socket: Some(sock),
1855            output: TableOrJson::Json,
1856            follow: true,
1857        }
1858        .execute()
1859        .await
1860        .unwrap();
1861        server.await.unwrap();
1862    }
1863
1864    #[tokio::test]
1865    async fn worktrees_command_routes_each_new_subcommand() {
1866        // Route every new variant through the outer `WorktreesCommand::execute` so
1867        // its dispatch arms are exercised (the wire-shape tests drive the leaf
1868        // `execute` directly).
1869        let target = tempfile::tempdir().unwrap();
1870        // Close: `--window-only --dry-run` contacts no daemon.
1871        WorktreesCommand {
1872            command: WorktreesSubcommands::Close(CloseCommand {
1873                path: target.path().to_path_buf(),
1874                window_only: true,
1875                dry_run: true,
1876                yes: false,
1877                socket: Some(PathBuf::from("/nonexistent/omni-dev-route.sock")),
1878            }),
1879        }
1880        .execute()
1881        .await
1882        .unwrap();
1883
1884        // ShowClosed (set).
1885        let (_d, sock, server) =
1886            fake_daemon_seq(vec![json!({ "ok": true, "payload": { "ok": true } })]);
1887        WorktreesCommand {
1888            command: WorktreesSubcommands::ShowClosed(ShowClosedCommand {
1889                value: Some(true),
1890                socket: Some(sock),
1891            }),
1892        }
1893        .execute()
1894        .await
1895        .unwrap();
1896        server.await.unwrap();
1897
1898        // Register.
1899        let (_d, sock, server) =
1900            fake_daemon_seq(vec![json!({ "ok": true, "payload": { "ok": true } })]);
1901        WorktreesCommand {
1902            command: WorktreesSubcommands::Register(RegisterCommand {
1903                key: "w1".to_string(),
1904                folders: vec![],
1905                repo: None,
1906                title: None,
1907                pid: None,
1908                socket: Some(sock),
1909            }),
1910        }
1911        .execute()
1912        .await
1913        .unwrap();
1914        server.await.unwrap();
1915
1916        // Heartbeat.
1917        let (_d, sock, server) =
1918            fake_daemon_seq(vec![json!({ "ok": true, "payload": { "known": true } })]);
1919        WorktreesCommand {
1920            command: WorktreesSubcommands::Heartbeat(HeartbeatCommand {
1921                key: "w1".to_string(),
1922                socket: Some(sock),
1923            }),
1924        }
1925        .execute()
1926        .await
1927        .unwrap();
1928        server.await.unwrap();
1929
1930        // Unregister.
1931        let (_d, sock, server) =
1932            fake_daemon_seq(vec![json!({ "ok": true, "payload": { "removed": true } })]);
1933        WorktreesCommand {
1934            command: WorktreesSubcommands::Unregister(UnregisterCommand {
1935                key: "w1".to_string(),
1936                socket: Some(sock),
1937            }),
1938        }
1939        .execute()
1940        .await
1941        .unwrap();
1942        server.await.unwrap();
1943    }
1944
1945    #[tokio::test]
1946    async fn close_aborts_when_confirmation_is_declined() {
1947        // Phase-1 says removable; the injected confirmer declines → the "Aborted"
1948        // branch runs and no phase-2 delete is sent (one connection only). This
1949        // covers the interactive-decline path without driving real stdin.
1950        let (_dir, sock, server) = fake_daemon_seq(vec![json!({
1951            "ok": true,
1952            "payload": { "removable": true, "is_main": false, "open": false,
1953                         "window_folder_count": 0, "risks": [], "info": [] }
1954        })]);
1955        let target = tempfile::tempdir().unwrap();
1956        CloseCommand {
1957            path: target.path().to_path_buf(),
1958            window_only: false,
1959            dry_run: false,
1960            yes: false,
1961            socket: Some(sock),
1962        }
1963        .execute_with(|_has_risks| async { false })
1964        .await
1965        .unwrap();
1966        assert_eq!(server.await.unwrap().len(), 1);
1967    }
1968
1969    #[tokio::test]
1970    async fn close_deletes_when_confirmation_is_accepted() {
1971        // Phase-1 removable, the injected confirmer accepts → phase-2 executes with
1972        // confirmed:true.
1973        let (_dir, sock, server) = fake_daemon_seq(vec![
1974            json!({ "ok": true, "payload": { "removable": true, "is_main": false,
1975                    "open": false, "window_folder_count": 0, "risks": [], "info": [] } }),
1976            json!({ "ok": true, "payload": { "removed": true } }),
1977        ]);
1978        let target = tempfile::tempdir().unwrap();
1979        CloseCommand {
1980            path: target.path().to_path_buf(),
1981            window_only: false,
1982            dry_run: false,
1983            yes: false,
1984            socket: Some(sock),
1985        }
1986        .execute_with(|_has_risks| async { true })
1987        .await
1988        .unwrap();
1989        let reqs = server.await.unwrap();
1990        assert_eq!(reqs.len(), 2);
1991        assert_eq!(reqs[1]["payload"]["confirmed"], json!(true));
1992    }
1993
1994    #[tokio::test]
1995    async fn confirm_removal_with_decides_from_the_answer() {
1996        // A "yes"/"y" answer confirms; "no", an empty line, and a `None` (EOF/read
1997        // error) all decline — for both the risky and clean prompt wordings.
1998        assert!(confirm_removal_with(false, async { Some("y\n".to_string()) }).await);
1999        assert!(confirm_removal_with(true, async { Some("YES".to_string()) }).await);
2000        assert!(!confirm_removal_with(false, async { Some("n".to_string()) }).await);
2001        assert!(!confirm_removal_with(true, async { Some(String::new()) }).await);
2002        assert!(!confirm_removal_with(false, async { None }).await);
2003    }
2004}