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 only sends the `worktrees` service's read ops (`list` for the
6//! open windows, `tree` for the repos-and-all-their-worktrees view) over the
7//! daemon's Unix control socket. The companion VS Code extension is what *feeds*
8//! the registry (`register`/`heartbeat`/`unregister`), talking to the same
9//! socket directly from each window.
10
11use std::path::{Path, PathBuf};
12
13use anyhow::{bail, Context, Result};
14use chrono::Utc;
15use clap::{Parser, Subcommand};
16use serde_json::{json, Value};
17
18use crate::cli::format::TableOrJson;
19use crate::daemon::client::DaemonClient;
20use crate::daemon::protocol::{DaemonEnvelope, DaemonReply};
21use crate::daemon::server;
22
23/// The `worktrees` service routing key on the daemon control socket.
24const SERVICE: &str = "worktrees";
25
26/// Worktrees: see the repos/worktrees open across every VS Code window, kept
27/// live by the daemon.
28#[derive(Parser)]
29pub struct WorktreesCommand {
30    /// The worktrees subcommand to execute.
31    #[command(subcommand)]
32    pub command: WorktreesSubcommands,
33}
34
35/// Worktrees subcommands.
36#[derive(Subcommand)]
37pub enum WorktreesSubcommands {
38    /// List the repos/worktrees currently open across all windows.
39    List(ListCommand),
40    /// Show every repository and all its worktrees, grouped by repository.
41    Tree(TreeCommand),
42    /// Focus (raise) the VS Code window for a worktree folder.
43    Focus(FocusCommand),
44}
45
46impl WorktreesCommand {
47    /// Executes the worktrees command.
48    pub async fn execute(self) -> Result<()> {
49        match self.command {
50            WorktreesSubcommands::List(cmd) => cmd.execute().await,
51            WorktreesSubcommands::Tree(cmd) => cmd.execute().await,
52            WorktreesSubcommands::Focus(cmd) => cmd.execute().await,
53        }
54    }
55}
56
57/// Lists the live cross-window set of open worktrees/repos.
58#[derive(Parser)]
59pub struct ListCommand {
60    /// Control-socket path. Defaults to the per-user runtime location.
61    #[arg(long, value_name = "PATH")]
62    pub socket: Option<PathBuf>,
63    /// Output format.
64    #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
65    pub output: TableOrJson,
66    /// Deprecated: use `-o`/`--output json` instead.
67    #[arg(long, hide = true)]
68    pub json: bool,
69}
70
71impl ListCommand {
72    /// Executes the list command.
73    pub async fn execute(mut self) -> Result<()> {
74        if self.json {
75            eprintln!("warning: --json is deprecated; use -o/--output json instead");
76            self.output = TableOrJson::Json;
77        }
78        let socket = server::resolve_socket(self.socket)?;
79        let result = call(&socket, "list", Value::Null).await?;
80        match self.output {
81            TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
82            TableOrJson::Table => println!("{}", render_windows(&result)),
83        }
84        Ok(())
85    }
86}
87
88/// Shows every repository and all of its worktrees (open or not), grouped by
89/// repository — the daemon's `tree` op, which derives the repos from the open
90/// windows and enumerates each repo's worktrees.
91#[derive(Parser)]
92pub struct TreeCommand {
93    /// Control-socket path. Defaults to the per-user runtime location.
94    #[arg(long, value_name = "PATH")]
95    pub socket: Option<PathBuf>,
96    /// Output format.
97    #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
98    pub output: TableOrJson,
99}
100
101impl TreeCommand {
102    /// Executes the tree command.
103    pub async fn execute(self) -> Result<()> {
104        let socket = server::resolve_socket(self.socket)?;
105        let mut result = call(&socket, "tree", Value::Null).await?;
106        // Ahead/behind is no longer part of the (cheap) streamed `tree` snapshot
107        // (#1306); fetch it on demand for the worktrees we are about to render and
108        // fold it back in, so `worktrees tree` shows the same `+ahead -behind` sync
109        // state as before. Best-effort: an older daemon without the `ahead-behind`
110        // op just renders `-`.
111        enrich_ahead_behind(&socket, &mut result).await;
112        match self.output {
113            TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
114            TableOrJson::Table => println!("{}", render_tree(&result)),
115        }
116        Ok(())
117    }
118}
119
120/// Focuses (raises) the VS Code window for a worktree folder.
121///
122/// Reuses the daemon's `open` op — the same launcher path the macOS tray's
123/// per-window "focus" action drives (`OMNI_DEV_VSCODE_BIN` → well-known paths →
124/// `code`), which VS Code uses to reuse an already-open window. This makes that
125/// tray-only capability reachable from the CLI on Linux/headless too (#1113).
126#[derive(Parser)]
127pub struct FocusCommand {
128    /// Worktree folder whose window to focus. Shown by `worktrees tree`/`list`.
129    #[arg(value_name = "PATH")]
130    pub path: PathBuf,
131    /// Control-socket path. Defaults to the per-user runtime location.
132    #[arg(long, value_name = "PATH")]
133    pub socket: Option<PathBuf>,
134}
135
136impl FocusCommand {
137    /// Executes the focus command.
138    pub async fn execute(self) -> Result<()> {
139        // Resolve to an absolute path client-side: the daemon runs in a different
140        // cwd and guards the `open` path as absolute-and-existing, so a relative
141        // path would be meaningless there. A clear error here beats the daemon's.
142        let path = std::fs::canonicalize(&self.path)
143            .with_context(|| format!("cannot resolve worktree path: {}", self.path.display()))?;
144        let socket = server::resolve_socket(self.socket)?;
145        call(&socket, "open", json!({ "path": path.to_string_lossy() })).await?;
146        println!("Focused {}", path.display());
147        Ok(())
148    }
149}
150
151/// Fetches ahead/behind on demand for every worktree in a `tree` reply and folds
152/// the counts back into each worktree object, so `worktrees tree` renders the same
153/// `+ahead -behind` sync state the cheap snapshot no longer carries (#1306). A
154/// best-effort enrichment: if there are no worktrees, the daemon lacks the
155/// `ahead-behind` op (older daemon), or the call fails, `result` is left as-is and
156/// the tree still renders — just with `-` for sync.
157async fn enrich_ahead_behind(socket: &Path, result: &mut Value) {
158    let paths = worktree_paths(result);
159    if paths.is_empty() {
160        return;
161    }
162    let Ok(reply) = call(socket, "ahead-behind", json!({ "paths": paths })).await else {
163        return;
164    };
165    if let Some(results) = reply.get("results").and_then(Value::as_object) {
166        merge_ahead_behind(result, results);
167    }
168}
169
170/// Every worktree path in a `tree` reply, in render order — the batch the
171/// on-demand `ahead-behind` op is asked about.
172fn worktree_paths(result: &Value) -> Vec<String> {
173    let mut paths = Vec::new();
174    for repo in result
175        .get("repos")
176        .and_then(Value::as_array)
177        .map(Vec::as_slice)
178        .unwrap_or_default()
179    {
180        for worktree in repo
181            .get("worktrees")
182            .and_then(Value::as_array)
183            .map(Vec::as_slice)
184            .unwrap_or_default()
185        {
186            if let Some(path) = worktree.get("path").and_then(Value::as_str) {
187                paths.push(path.to_string());
188            }
189        }
190    }
191    paths
192}
193
194/// Folds `{ ahead, behind }` counts (keyed by worktree path) from an `ahead-behind`
195/// reply back into a `tree` reply's worktree objects. A worktree whose path is
196/// absent from `results` (no upstream) is left untouched. Pure, so the merge is
197/// unit-testable without a socket.
198fn merge_ahead_behind(result: &mut Value, results: &serde_json::Map<String, Value>) {
199    for repo in result
200        .get_mut("repos")
201        .and_then(Value::as_array_mut)
202        .into_iter()
203        .flatten()
204    {
205        for worktree in repo
206            .get_mut("worktrees")
207            .and_then(Value::as_array_mut)
208            .into_iter()
209            .flatten()
210        {
211            // Take the worktree object up front so the insert reuses this handle
212            // rather than a second, always-succeeding `as_object_mut` (a non-object
213            // element in the array is skipped here).
214            let Some(obj) = worktree.as_object_mut() else {
215                continue;
216            };
217            let Some(path) = obj.get("path").and_then(Value::as_str).map(str::to_string) else {
218                continue;
219            };
220            let Some(counts) = results.get(&path) else {
221                continue;
222            };
223            // Fold both counts in together, or neither — a malformed entry missing
224            // a side is left as no-sync rather than half-applied.
225            if let (Some(ahead), Some(behind)) =
226                (counts.get("ahead").cloned(), counts.get("behind").cloned())
227            {
228                obj.insert("ahead".to_string(), ahead);
229                obj.insert("behind".to_string(), behind);
230            }
231        }
232    }
233}
234
235/// Sends one `worktrees` service op over the control socket, returning its
236/// payload or turning an `ok: false` reply into an error.
237async fn call(socket: &Path, op: &str, payload: Value) -> Result<Value> {
238    let reply = DaemonClient::new(socket)
239        .request(DaemonEnvelope::service(SERVICE, op, payload))
240        .await?;
241    reply_payload(reply)
242}
243
244/// Unwraps a daemon reply into its payload, turning an `ok: false` reply into an
245/// error. Pure (no socket), so both mappings are unit-testable.
246fn reply_payload(reply: DaemonReply) -> Result<Value> {
247    if reply.ok {
248        Ok(reply.payload)
249    } else {
250        bail!(
251            "daemon returned an error: {}",
252            reply.error.as_deref().unwrap_or("unknown error")
253        )
254    }
255}
256
257/// Renders a `list` reply as a human-readable table: a header and one row per
258/// open window (repo, the daemon-computed branch and its ahead/behind sync
259/// state, the primary folder, and how long ago it was last seen). Returns a
260/// placeholder line when nothing is open.
261fn render_windows(result: &Value) -> String {
262    let windows = result
263        .get("windows")
264        .and_then(Value::as_array)
265        .map(Vec::as_slice)
266        .unwrap_or_default();
267    if windows.is_empty() {
268        return "No open windows.".to_string();
269    }
270    let mut out = format!(
271        "{:<22} {:<24} {:<9} {:<40} {:>5}",
272        "REPO", "BRANCH", "SYNC", "FOLDER", "AGE"
273    );
274    for window in windows {
275        let repo = sanitize(repo_name(window));
276        let branch = sanitize(window.get("branch").and_then(Value::as_str).unwrap_or("-"));
277        let sync = sync_summary(window);
278        let folder_disp = folder_summary(window);
279        let age = age_secs(window.get("last_seen").and_then(Value::as_str));
280        out.push_str(&format!(
281            "\n{repo:<22} {branch:<24} {sync:<9} {folder_disp:<40} {age:>4}s"
282        ));
283    }
284    out
285}
286
287/// Renders a `tree` reply as a repo-grouped view: a header line per repository
288/// (its name, GitHub `owner/name` when present, and root path), then one indented
289/// row per worktree — a `*` marks the main working tree, followed by the branch,
290/// its `+ahead -behind` sync state, an `open` flag when a live window has it open,
291/// and the worktree path. Returns a placeholder when no repository is open.
292fn render_tree(result: &Value) -> String {
293    let repos = result
294        .get("repos")
295        .and_then(Value::as_array)
296        .map(Vec::as_slice)
297        .unwrap_or_default();
298    if repos.is_empty() {
299        return "No repositories open.".to_string();
300    }
301    let mut out = String::new();
302    for (i, repo) in repos.iter().enumerate() {
303        // A blank line separates repositories (but not before the first): the
304        // previous worktree row has no trailing newline, so two are needed.
305        if i > 0 {
306            out.push_str("\n\n");
307        }
308        out.push_str(&repo_header(repo));
309        for worktree in repo
310            .get("worktrees")
311            .and_then(Value::as_array)
312            .map(Vec::as_slice)
313            .unwrap_or_default()
314        {
315            out.push('\n');
316            out.push_str(&worktree_row(worktree));
317        }
318    }
319    out
320}
321
322/// The header line for one repo in the tree view: `<name>  (github: owner/name)
323/// <root>`, with the GitHub clause omitted for a non-GitHub repo.
324fn repo_header(repo: &Value) -> String {
325    let name = sanitize(repo.get("main_repo").and_then(Value::as_str).unwrap_or("-"));
326    let root = sanitize(repo.get("root").and_then(Value::as_str).unwrap_or(""));
327    match github_summary(repo) {
328        Some(github) => format!("{name}  ({github})  {root}"),
329        None => format!("{name}  {root}"),
330    }
331}
332
333/// A `github: owner/name` summary for a repo, or `None` when it has no GitHub
334/// identity (a non-GitHub or remote-less repo).
335fn github_summary(repo: &Value) -> Option<String> {
336    let owner = repo.pointer("/github/owner").and_then(Value::as_str)?;
337    let name = repo.pointer("/github/name").and_then(Value::as_str)?;
338    Some(format!("github: {}/{}", sanitize(owner), sanitize(name)))
339}
340
341/// One indented worktree row: a `*` for the main working tree, the branch, the
342/// `+ahead -behind` sync state, an `open` flag when a window has it open, and the
343/// worktree path.
344fn worktree_row(worktree: &Value) -> String {
345    let marker = if worktree.get("is_main").and_then(Value::as_bool) == Some(true) {
346        '*'
347    } else {
348        ' '
349    };
350    let branch = sanitize(
351        worktree
352            .get("branch")
353            .and_then(Value::as_str)
354            .unwrap_or("-"),
355    );
356    let sync = sync_summary(worktree);
357    let open = if worktree.get("open").and_then(Value::as_bool) == Some(true) {
358        "open"
359    } else {
360        ""
361    };
362    let path = sanitize(worktree.get("path").and_then(Value::as_str).unwrap_or(""));
363    format!("  {marker} {branch:<24} {sync:<9} {open:<5} {path}")
364}
365
366/// The repo name to show for a window: the daemon-computed `main_repo` (which
367/// names the *parent* repository of a linked worktree, not its worktree-folder
368/// basename) when present, else the companion-reported `repo`, else `-`.
369fn repo_name(window: &Value) -> &str {
370    window
371        .get("main_repo")
372        .and_then(Value::as_str)
373        .or_else(|| window.get("repo").and_then(Value::as_str))
374        .unwrap_or("-")
375}
376
377/// A compact `+ahead -behind` divergence indicator for a window, or `-` when
378/// the branch tracks no upstream (or there is no branch at all). The counts are
379/// daemon-computed integers, so no sanitizing is needed.
380fn sync_summary(window: &Value) -> String {
381    let ahead = window.get("ahead").and_then(Value::as_u64);
382    let behind = window.get("behind").and_then(Value::as_u64);
383    match (ahead, behind) {
384        (Some(ahead), Some(behind)) => format!("+{ahead} -{behind}"),
385        _ => "-".to_string(),
386    }
387}
388
389/// The primary folder of a window, with a `(+N)` suffix when it has more than
390/// one workspace folder.
391fn folder_summary(window: &Value) -> String {
392    let folders = window
393        .get("folders")
394        .and_then(Value::as_array)
395        .map(Vec::as_slice)
396        .unwrap_or_default();
397    let first = sanitize(folders.first().and_then(Value::as_str).unwrap_or(""));
398    let extra = folders.len().saturating_sub(1);
399    if extra > 0 {
400        format!("{first} (+{extra})")
401    } else {
402        first
403    }
404}
405
406/// Strips control characters (C0, DEL, C1) from an untrusted registry string so
407/// a malicious `register` payload cannot inject terminal escape sequences into
408/// the rendered table (#1137). The `--json` path stays verbatim.
409fn sanitize(s: &str) -> String {
410    s.chars().filter(|c| !c.is_control()).collect()
411}
412
413/// Seconds elapsed since an RFC 3339 timestamp (0 if absent/unparseable).
414fn age_secs(ts: Option<&str>) -> i64 {
415    ts.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
416        .map_or(0, |t| {
417            (Utc::now() - t.with_timezone(&Utc)).num_seconds().max(0)
418        })
419}
420
421#[cfg(test)]
422#[allow(clippy::unwrap_used, clippy::expect_used)]
423mod tests {
424    use super::*;
425    use serde_json::json;
426
427    /// Mirrors the `omni-dev worktrees` argv surface for parse tests.
428    #[derive(Parser)]
429    struct Wrapper {
430        #[command(subcommand)]
431        cmd: WorktreesSubcommands,
432    }
433
434    fn parse(args: &[&str]) -> WorktreesSubcommands {
435        let mut full = vec!["omni-dev"];
436        full.extend_from_slice(args);
437        Wrapper::try_parse_from(full).unwrap().cmd
438    }
439
440    #[test]
441    fn list_parses_flags_and_defaults() {
442        // Routing: `worktrees list` maps to the List variant.
443        assert!(matches!(parse(&["list"]), WorktreesSubcommands::List(_)));
444        // Flags, via the leaf parser (clap treats argv[0] as the command name).
445        let cmd = ListCommand::try_parse_from(["list"]).unwrap();
446        assert_eq!(cmd.output, TableOrJson::Table);
447        assert!(!cmd.json);
448        assert!(cmd.socket.is_none());
449
450        let cmd =
451            ListCommand::try_parse_from(["list", "-o", "json", "--socket", "/tmp/d.sock"]).unwrap();
452        assert_eq!(cmd.output, TableOrJson::Json);
453        assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
454    }
455
456    #[test]
457    fn list_deprecated_json_flag_still_parses() {
458        // `--json` is captured separately; `execute` folds it into `output`.
459        let cmd = ListCommand::try_parse_from(["list", "--json"]).unwrap();
460        assert!(cmd.json);
461        assert_eq!(cmd.output, TableOrJson::Table);
462    }
463
464    #[test]
465    fn tree_parses_flags_and_defaults() {
466        // Routing: `worktrees tree` maps to the Tree variant.
467        assert!(matches!(parse(&["tree"]), WorktreesSubcommands::Tree(_)));
468        let cmd = TreeCommand::try_parse_from(["tree"]).unwrap();
469        assert_eq!(cmd.output, TableOrJson::Table);
470        assert!(cmd.socket.is_none());
471
472        let cmd =
473            TreeCommand::try_parse_from(["tree", "-o", "json", "--socket", "/tmp/d.sock"]).unwrap();
474        assert_eq!(cmd.output, TableOrJson::Json);
475        assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
476    }
477
478    #[test]
479    fn focus_parses_path_and_socket() {
480        // Routing: `worktrees focus` maps to the Focus variant.
481        assert!(matches!(
482            parse(&["focus", "/home/me/wt"]),
483            WorktreesSubcommands::Focus(_)
484        ));
485        // The path is a required positional; `--socket` is optional.
486        let cmd = FocusCommand::try_parse_from(["focus", "/home/me/wt"]).unwrap();
487        assert_eq!(cmd.path, Path::new("/home/me/wt"));
488        assert!(cmd.socket.is_none());
489
490        let cmd = FocusCommand::try_parse_from(["focus", "/home/me/wt", "--socket", "/tmp/d.sock"])
491            .unwrap();
492        assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
493
494        // The path is required.
495        assert!(FocusCommand::try_parse_from(["focus"]).is_err());
496    }
497
498    #[tokio::test]
499    async fn focus_errors_on_a_nonexistent_path_before_any_socket_call() {
500        // Canonicalisation fails for a path that does not exist, so `focus`
501        // reports a clear error without needing a daemon.
502        let cmd = FocusCommand {
503            path: PathBuf::from("/nonexistent/omni-dev-focus-xyz"),
504            socket: Some(PathBuf::from("/nonexistent/omni-dev-focus.sock")),
505        };
506        let err = cmd.execute().await.unwrap_err();
507        assert!(
508            err.to_string().contains("cannot resolve worktree path"),
509            "{err}"
510        );
511    }
512
513    #[tokio::test]
514    async fn focus_sends_the_open_op_for_an_existing_folder() {
515        // A real (temp) folder canonicalises, so `focus` sends the `open` op to
516        // the daemon; the fake daemon acknowledges it. Routed through the top-level
517        // `WorktreesCommand::execute` so its `Focus` dispatch arm is exercised too.
518        let (_dir, sock, server) =
519            fake_daemon_reply(json!({ "ok": true, "payload": { "ok": true } }));
520        let target = tempfile::tempdir().unwrap();
521        let cmd = WorktreesCommand {
522            command: WorktreesSubcommands::Focus(FocusCommand {
523                path: target.path().to_path_buf(),
524                socket: Some(sock),
525            }),
526        };
527        cmd.execute().await.unwrap();
528        server.await.unwrap();
529    }
530
531    #[test]
532    fn render_windows_handles_empty_replies() {
533        assert_eq!(
534            render_windows(&json!({ "windows": [] })),
535            "No open windows."
536        );
537        assert_eq!(render_windows(&json!({})), "No open windows.");
538    }
539
540    #[test]
541    fn render_windows_renders_rows() {
542        let result = json!({ "windows": [{
543            "key": "w1",
544            "repo": "omni-dev",
545            "branch": "issue-1011",
546            "ahead": 2,
547            "behind": 1,
548            "folders": ["/home/me/omni-dev", "/home/me/docs"],
549            "last_seen": "2000-01-01T00:00:00Z",
550        }]});
551        let table = render_windows(&result);
552        assert!(table.contains("omni-dev"), "{table}");
553        // The computed branch and its sync state both render.
554        assert!(table.contains("issue-1011"), "{table}");
555        assert!(table.contains("+2 -1"), "{table}");
556        // Primary folder plus a (+1) for the second workspace folder.
557        assert!(table.contains("/home/me/omni-dev (+1)"), "{table}");
558        // A header line plus exactly one data row.
559        assert_eq!(table.lines().count(), 2, "{table}");
560    }
561
562    #[test]
563    fn render_windows_prefers_main_repo_over_companion_repo() {
564        // A linked worktree: the companion reports the worktree-folder basename,
565        // but the daemon-computed `main_repo` names the parent repo, and that is
566        // what the REPO column shows.
567        let result = json!({ "windows": [{
568            "key": "w1",
569            "repo": "issue-1250",
570            "main_repo": "omni-dev",
571            "branch": "issue-1250",
572            "folders": ["/home/me/worktrees/issue-1250"],
573            "last_seen": "2000-01-01T00:00:00Z",
574        }]});
575        let table = render_windows(&result);
576        assert!(table.contains("omni-dev"), "{table}");
577        // The misleading worktree-folder basename does not appear in REPO (it is
578        // still visible in the FOLDER column path).
579        let data_row = table.lines().nth(1).unwrap();
580        assert!(data_row.starts_with("omni-dev"), "{data_row}");
581    }
582
583    #[test]
584    fn repo_name_falls_back_to_companion_repo_then_dash() {
585        assert_eq!(
586            repo_name(&json!({ "main_repo": "omni-dev", "repo": "wt" })),
587            "omni-dev"
588        );
589        assert_eq!(repo_name(&json!({ "repo": "wt" })), "wt");
590        assert_eq!(repo_name(&json!({})), "-");
591    }
592
593    #[test]
594    fn render_windows_strips_control_bytes() {
595        // C0 (ESC, CR, BEL), DEL, and C1 (CSI) bytes in every string-valued
596        // field must not reach the terminal (#1137).
597        let result = json!({ "windows": [{
598            "key": "w1",
599            "repo": "evil\x1b[31mrepo",
600            "branch": "br\ranch\x07\u{9b}2J",
601            "folders": ["/tmp/a\x1b]0;owned\x07\u{7f}", "/tmp/b"],
602            "last_seen": "2000-01-01T00:00:00Z",
603        }]});
604        let table = render_windows(&result);
605        assert!(
606            !table.contains(|c: char| c.is_control() && c != '\n'),
607            "{table:?}"
608        );
609        // Visible text survives with only the control bytes removed.
610        assert!(table.contains("evil[31mrepo"), "{table:?}");
611        assert!(table.contains("branch2J"), "{table:?}");
612        assert!(table.contains("/tmp/a]0;owned (+1)"), "{table:?}");
613        // Embedded CR/LF cannot forge extra rows: header plus one data row.
614        assert_eq!(table.lines().count(), 2, "{table:?}");
615    }
616
617    #[test]
618    fn sync_summary_formats_or_dashes() {
619        assert_eq!(sync_summary(&json!({ "ahead": 2, "behind": 1 })), "+2 -1");
620        assert_eq!(sync_summary(&json!({ "ahead": 0, "behind": 0 })), "+0 -0");
621        // Branch present but no upstream, or nothing at all → a dash.
622        assert_eq!(sync_summary(&json!({ "branch": "main" })), "-");
623        assert_eq!(sync_summary(&json!({})), "-");
624    }
625
626    #[test]
627    fn folder_summary_strips_control_bytes() {
628        assert_eq!(
629            folder_summary(&json!({ "folders": ["/a\x1b[2J/b"] })),
630            "/a[2J/b"
631        );
632    }
633
634    #[test]
635    fn folder_summary_counts_extra_folders() {
636        assert_eq!(folder_summary(&json!({ "folders": [] })), "");
637        assert_eq!(folder_summary(&json!({ "folders": ["/a"] })), "/a");
638        assert_eq!(
639            folder_summary(&json!({ "folders": ["/a", "/b", "/c"] })),
640            "/a (+2)"
641        );
642    }
643
644    #[test]
645    fn age_secs_handles_absent_and_unparseable_and_past() {
646        assert_eq!(age_secs(None), 0);
647        assert_eq!(age_secs(Some("not-a-timestamp")), 0);
648        assert!(age_secs(Some("2000-01-01T00:00:00Z")) > 0);
649    }
650
651    #[test]
652    fn render_tree_handles_empty_replies() {
653        assert_eq!(
654            render_tree(&json!({ "repos": [] })),
655            "No repositories open."
656        );
657        assert_eq!(render_tree(&json!({})), "No repositories open.");
658    }
659
660    #[test]
661    fn worktree_paths_collects_every_worktree_in_render_order() {
662        let result = json!({ "repos": [
663            // The middle worktree has no `path` and is skipped, not collected.
664            { "worktrees": [ { "path": "/a" }, { "branch": "detached" }, { "path": "/b" } ] },
665            { "worktrees": [ { "path": "/c" } ] },
666        ]});
667        assert_eq!(worktree_paths(&result), vec!["/a", "/b", "/c"]);
668        // No repos / no worktrees → an empty batch (nothing to fetch).
669        assert!(worktree_paths(&json!({})).is_empty());
670        assert!(worktree_paths(&json!({ "repos": [{ "worktrees": [] }] })).is_empty());
671    }
672
673    #[test]
674    fn merge_ahead_behind_folds_counts_by_path_and_leaves_others() {
675        // The on-demand `ahead-behind` op reports one worktree diverging and omits
676        // the other (no upstream). The merge folds the counts onto the matching
677        // path and leaves the untracked worktree without sync fields.
678        let mut result = json!({ "repos": [{ "worktrees": [
679            { "path": "/a", "branch": "main" },
680            { "path": "/b", "branch": "feature" },
681        ]}]});
682        let results = json!({ "/a": { "ahead": 2, "behind": 1 } });
683        merge_ahead_behind(&mut result, results.as_object().unwrap());
684
685        let worktrees = result.pointer("/repos/0/worktrees").unwrap();
686        let a = &worktrees[0];
687        assert_eq!(a.get("ahead").and_then(Value::as_u64), Some(2));
688        assert_eq!(a.get("behind").and_then(Value::as_u64), Some(1));
689        // And it renders exactly as an eager snapshot would have.
690        assert_eq!(sync_summary(a), "+2 -1");
691        let b = &worktrees[1];
692        assert!(b.get("ahead").is_none(), "{b:?}");
693        assert!(b.get("behind").is_none(), "{b:?}");
694        assert_eq!(sync_summary(b), "-");
695    }
696
697    #[test]
698    fn merge_ahead_behind_skips_malformed_worktrees_and_counts() {
699        // Every defensive guard, on malformed input that never comes from a real
700        // daemon: a non-object array element, a worktree with no `path`, and a
701        // results entry missing a side. None panics; none is half-applied.
702        let mut result = json!({ "repos": [{ "worktrees": [
703            "not-an-object",                       // non-object element → skipped
704            { "branch": "detached" },              // object, but no path → skipped
705            { "path": "/a", "branch": "main" },    // matched, but counts malformed
706        ]}]});
707        let results = json!({ "/a": { "ahead": 2 } }); // missing `behind`
708        merge_ahead_behind(&mut result, results.as_object().unwrap());
709
710        let worktrees = result.pointer("/repos/0/worktrees").unwrap();
711        // Non-object element is untouched.
712        assert_eq!(worktrees[0], json!("not-an-object"));
713        // Pathless worktree: no sync fields inserted.
714        assert!(worktrees[1].get("ahead").is_none(), "{:?}", worktrees[1]);
715        // Malformed counts: neither side folded in (both-or-nothing).
716        assert!(worktrees[2].get("ahead").is_none(), "{:?}", worktrees[2]);
717        assert!(worktrees[2].get("behind").is_none(), "{:?}", worktrees[2]);
718    }
719
720    #[tokio::test]
721    async fn enrich_ahead_behind_is_a_noop_when_there_are_no_worktrees() {
722        // No worktrees → no batch to fetch → early return before any socket call,
723        // so even a nonexistent socket leaves the tree untouched.
724        let mut result = json!({ "repos": [] });
725        let before = result.clone();
726        enrich_ahead_behind(Path::new("/nonexistent/omni-dev-ab.sock"), &mut result).await;
727        assert_eq!(result, before);
728    }
729
730    #[tokio::test]
731    async fn enrich_ahead_behind_leaves_the_tree_when_the_daemon_is_unreachable() {
732        // A real worktree but no daemon at the socket → the call fails and the tree
733        // is returned as-is (rendered with `-` for sync), never erroring.
734        let mut result =
735            json!({ "repos": [{ "worktrees": [{ "path": "/x", "branch": "main" }] }] });
736        enrich_ahead_behind(Path::new("/nonexistent/omni-dev-ab.sock"), &mut result).await;
737        let wt = result.pointer("/repos/0/worktrees/0").unwrap();
738        assert!(wt.get("ahead").is_none(), "{wt:?}");
739        assert!(wt.get("behind").is_none(), "{wt:?}");
740    }
741
742    /// Spawns a minimal fake daemon on a short-path Unix socket that answers the
743    /// one `ahead-behind` request with `reply` (the daemon's NDJSON reply shape).
744    /// Returns the temp dir (kept alive for the socket's lifetime), the socket
745    /// path, and the server task.
746    fn fake_daemon_reply(
747        reply: Value,
748    ) -> (tempfile::TempDir, PathBuf, tokio::task::JoinHandle<()>) {
749        use futures::{SinkExt, StreamExt};
750        use tokio::net::UnixListener;
751        use tokio_util::codec::{Framed, LinesCodec};
752
753        // A short base path keeps the socket under the 104-byte `sockaddr_un` limit.
754        let dir = tempfile::tempdir_in("/tmp").unwrap();
755        let sock = dir.path().join("d.sock");
756        let listener = UnixListener::bind(&sock).unwrap();
757        let server = tokio::spawn(async move {
758            let (stream, _) = listener.accept().await.unwrap();
759            let mut framed = Framed::new(stream, LinesCodec::new());
760            let _req = framed.next().await.unwrap().unwrap();
761            framed
762                .send(serde_json::to_string(&reply).unwrap())
763                .await
764                .unwrap();
765        });
766        (dir, sock, server)
767    }
768
769    #[tokio::test]
770    async fn enrich_ahead_behind_folds_counts_from_a_live_socket() {
771        let (_dir, sock, server) = fake_daemon_reply(
772            json!({ "ok": true, "payload": { "results": { "/x": { "ahead": 3, "behind": 4 } } } }),
773        );
774        let mut result =
775            json!({ "repos": [{ "worktrees": [{ "path": "/x", "branch": "main" }] }] });
776        enrich_ahead_behind(&sock, &mut result).await;
777        server.await.unwrap();
778
779        let wt = result.pointer("/repos/0/worktrees/0").unwrap();
780        assert_eq!(wt.get("ahead").and_then(Value::as_u64), Some(3));
781        assert_eq!(wt.get("behind").and_then(Value::as_u64), Some(4));
782    }
783
784    #[tokio::test]
785    async fn enrich_ahead_behind_ignores_a_reply_without_results() {
786        // An `ok` reply carrying no `results` object (an older/oddly-shaped daemon)
787        // leaves the tree unchanged rather than erroring.
788        let (_dir, sock, server) = fake_daemon_reply(json!({ "ok": true, "payload": {} }));
789        let mut result =
790            json!({ "repos": [{ "worktrees": [{ "path": "/x", "branch": "main" }] }] });
791        enrich_ahead_behind(&sock, &mut result).await;
792        server.await.unwrap();
793
794        let wt = result.pointer("/repos/0/worktrees/0").unwrap();
795        assert!(wt.get("ahead").is_none(), "{wt:?}");
796        assert!(wt.get("behind").is_none(), "{wt:?}");
797    }
798
799    #[test]
800    fn render_tree_groups_repos_and_worktrees() {
801        let result = json!({ "repos": [{
802            "main_repo": "omni-dev",
803            "github": { "owner": "rust-works", "name": "omni-dev" },
804            "root": "/home/me/omni-dev",
805            "worktrees": [
806                { "path": "/home/me/omni-dev", "branch": "main", "ahead": 2, "behind": 0,
807                  "is_main": true, "open": true, "window_key": "w1" },
808                { "path": "/home/me/wt/issue-1300", "branch": "issue-1300", "ahead": 1, "behind": 3,
809                  "is_main": false, "open": false },
810            ],
811        }]});
812        let out = render_tree(&result);
813        // Repo header carries the GitHub identity and root.
814        let header = out.lines().next().unwrap();
815        assert!(header.contains("omni-dev"), "{out}");
816        assert!(header.contains("github: rust-works/omni-dev"), "{out}");
817        assert!(header.contains("/home/me/omni-dev"), "{out}");
818        // The main working tree is marked with `*`, its sync, and `open`.
819        assert!(
820            out.lines()
821                .any(|l| l.contains("* main") && l.contains("+2 -0") && l.contains("open")),
822            "{out}"
823        );
824        // The linked worktree is unmarked and not flagged open.
825        let linked = out
826            .lines()
827            .find(|l| l.contains("issue-1300"))
828            .unwrap_or_default();
829        assert!(!linked.contains('*'), "{linked}");
830        assert!(!linked.contains("open"), "{linked}");
831        assert!(linked.contains("+1 -3"), "{linked}");
832        // Header + two worktree rows.
833        assert_eq!(out.lines().count(), 3, "{out}");
834    }
835
836    #[test]
837    fn render_tree_separates_multiple_repos_with_blank_line() {
838        let result = json!({ "repos": [
839            {
840                "main_repo": "alpha",
841                "root": "/r/alpha",
842                "worktrees": [
843                    { "path": "/r/alpha", "branch": "main", "is_main": true, "open": false },
844                ],
845            },
846            {
847                "main_repo": "beta",
848                "root": "/r/beta",
849                "worktrees": [
850                    { "path": "/r/beta", "branch": "main", "is_main": true, "open": false },
851                ],
852            },
853        ]});
854        let out = render_tree(&result);
855        // Two headers, two worktree rows, and one blank separator between repos.
856        assert!(
857            out.contains("\n\nbeta"),
858            "repos not blank-separated: {out:?}"
859        );
860        let alpha = out.find("alpha").unwrap();
861        let beta = out.find("beta").unwrap();
862        assert!(alpha < beta, "repo order not preserved: {out}");
863        assert_eq!(out.lines().count(), 5, "{out:?}");
864    }
865
866    #[test]
867    fn render_tree_omits_github_for_non_github_repo() {
868        let result = json!({ "repos": [{
869            "main_repo": "internal",
870            "root": "/srv/internal",
871            "worktrees": [
872                { "path": "/srv/internal", "branch": "main", "is_main": true, "open": false },
873            ],
874        }]});
875        let out = render_tree(&result);
876        assert!(!out.contains("github:"), "{out}");
877        assert!(out.lines().next().unwrap().contains("internal"), "{out}");
878    }
879
880    #[test]
881    fn render_tree_strips_control_bytes() {
882        // Control bytes in the repo name, github identity, branch, and path must
883        // not reach the terminal (#1137), matching the `list` renderer.
884        let result = json!({ "repos": [{
885            "main_repo": "evil\x1b[31mrepo",
886            "github": { "owner": "ow\x07ner", "name": "na\u{9b}2Jme" },
887            "root": "/tmp/r\x1b]0;x\x07oot",
888            "worktrees": [
889                { "path": "/tmp/w\rt", "branch": "br\x1b[2Janch", "is_main": true, "open": true },
890            ],
891        }]});
892        let out = render_tree(&result);
893        assert!(
894            !out.contains(|c: char| c.is_control() && c != '\n'),
895            "{out:?}"
896        );
897        // Embedded CR/LF cannot forge extra lines: header plus one worktree row.
898        assert_eq!(out.lines().count(), 2, "{out:?}");
899    }
900
901    #[test]
902    fn github_summary_needs_both_owner_and_name() {
903        assert_eq!(
904            github_summary(&json!({ "github": { "owner": "o", "name": "n" } })).as_deref(),
905            Some("github: o/n")
906        );
907        assert_eq!(github_summary(&json!({ "github": { "owner": "o" } })), None);
908        assert_eq!(github_summary(&json!({})), None);
909    }
910
911    #[test]
912    fn reply_payload_unwraps_ok_and_maps_errors() {
913        // ok → payload.
914        assert_eq!(
915            reply_payload(DaemonReply::ok(json!({ "a": 1 }))).unwrap(),
916            json!({ "a": 1 })
917        );
918        // ok: false with a message → that message.
919        let err = reply_payload(DaemonReply::err("boom")).unwrap_err();
920        assert!(err.to_string().contains("boom"), "{err}");
921        // ok: false with no message → the "unknown error" fallback.
922        let err = reply_payload(DaemonReply {
923            ok: false,
924            payload: Value::Null,
925            error: None,
926        })
927        .unwrap_err();
928        assert!(err.to_string().contains("unknown error"), "{err}");
929    }
930}