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, Result};
14use chrono::Utc;
15use clap::{Parser, Subcommand};
16use serde_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}
43
44impl WorktreesCommand {
45    /// Executes the worktrees command.
46    pub async fn execute(self) -> Result<()> {
47        match self.command {
48            WorktreesSubcommands::List(cmd) => cmd.execute().await,
49            WorktreesSubcommands::Tree(cmd) => cmd.execute().await,
50        }
51    }
52}
53
54/// Lists the live cross-window set of open worktrees/repos.
55#[derive(Parser)]
56pub struct ListCommand {
57    /// Control-socket path. Defaults to the per-user runtime location.
58    #[arg(long, value_name = "PATH")]
59    pub socket: Option<PathBuf>,
60    /// Output format.
61    #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
62    pub output: TableOrJson,
63    /// Deprecated: use `-o`/`--output json` instead.
64    #[arg(long, hide = true)]
65    pub json: bool,
66}
67
68impl ListCommand {
69    /// Executes the list command.
70    pub async fn execute(mut self) -> Result<()> {
71        if self.json {
72            eprintln!("warning: --json is deprecated; use -o/--output json instead");
73            self.output = TableOrJson::Json;
74        }
75        let socket = server::resolve_socket(self.socket)?;
76        let result = call(&socket, "list", Value::Null).await?;
77        match self.output {
78            TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
79            TableOrJson::Table => println!("{}", render_windows(&result)),
80        }
81        Ok(())
82    }
83}
84
85/// Shows every repository and all of its worktrees (open or not), grouped by
86/// repository — the daemon's `tree` op, which derives the repos from the open
87/// windows and enumerates each repo's worktrees.
88#[derive(Parser)]
89pub struct TreeCommand {
90    /// Control-socket path. Defaults to the per-user runtime location.
91    #[arg(long, value_name = "PATH")]
92    pub socket: Option<PathBuf>,
93    /// Output format.
94    #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
95    pub output: TableOrJson,
96}
97
98impl TreeCommand {
99    /// Executes the tree command.
100    pub async fn execute(self) -> Result<()> {
101        let socket = server::resolve_socket(self.socket)?;
102        let result = call(&socket, "tree", Value::Null).await?;
103        match self.output {
104            TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
105            TableOrJson::Table => println!("{}", render_tree(&result)),
106        }
107        Ok(())
108    }
109}
110
111/// Sends one `worktrees` service op over the control socket, returning its
112/// payload or turning an `ok: false` reply into an error.
113async fn call(socket: &Path, op: &str, payload: Value) -> Result<Value> {
114    let reply = DaemonClient::new(socket)
115        .request(DaemonEnvelope::service(SERVICE, op, payload))
116        .await?;
117    reply_payload(reply)
118}
119
120/// Unwraps a daemon reply into its payload, turning an `ok: false` reply into an
121/// error. Pure (no socket), so both mappings are unit-testable.
122fn reply_payload(reply: DaemonReply) -> Result<Value> {
123    if reply.ok {
124        Ok(reply.payload)
125    } else {
126        bail!(
127            "daemon returned an error: {}",
128            reply.error.as_deref().unwrap_or("unknown error")
129        )
130    }
131}
132
133/// Renders a `list` reply as a human-readable table: a header and one row per
134/// open window (repo, the daemon-computed branch and its ahead/behind sync
135/// state, the primary folder, and how long ago it was last seen). Returns a
136/// placeholder line when nothing is open.
137fn render_windows(result: &Value) -> String {
138    let windows = result
139        .get("windows")
140        .and_then(Value::as_array)
141        .map(Vec::as_slice)
142        .unwrap_or_default();
143    if windows.is_empty() {
144        return "No open windows.".to_string();
145    }
146    let mut out = format!(
147        "{:<22} {:<24} {:<9} {:<40} {:>5}",
148        "REPO", "BRANCH", "SYNC", "FOLDER", "AGE"
149    );
150    for window in windows {
151        let repo = sanitize(repo_name(window));
152        let branch = sanitize(window.get("branch").and_then(Value::as_str).unwrap_or("-"));
153        let sync = sync_summary(window);
154        let folder_disp = folder_summary(window);
155        let age = age_secs(window.get("last_seen").and_then(Value::as_str));
156        out.push_str(&format!(
157            "\n{repo:<22} {branch:<24} {sync:<9} {folder_disp:<40} {age:>4}s"
158        ));
159    }
160    out
161}
162
163/// Renders a `tree` reply as a repo-grouped view: a header line per repository
164/// (its name, GitHub `owner/name` when present, and root path), then one indented
165/// row per worktree — a `*` marks the main working tree, followed by the branch,
166/// its `+ahead -behind` sync state, an `open` flag when a live window has it open,
167/// and the worktree path. Returns a placeholder when no repository is open.
168fn render_tree(result: &Value) -> String {
169    let repos = result
170        .get("repos")
171        .and_then(Value::as_array)
172        .map(Vec::as_slice)
173        .unwrap_or_default();
174    if repos.is_empty() {
175        return "No repositories open.".to_string();
176    }
177    let mut out = String::new();
178    for (i, repo) in repos.iter().enumerate() {
179        // A blank line separates repositories (but not before the first): the
180        // previous worktree row has no trailing newline, so two are needed.
181        if i > 0 {
182            out.push_str("\n\n");
183        }
184        out.push_str(&repo_header(repo));
185        for worktree in repo
186            .get("worktrees")
187            .and_then(Value::as_array)
188            .map(Vec::as_slice)
189            .unwrap_or_default()
190        {
191            out.push('\n');
192            out.push_str(&worktree_row(worktree));
193        }
194    }
195    out
196}
197
198/// The header line for one repo in the tree view: `<name>  (github: owner/name)
199/// <root>`, with the GitHub clause omitted for a non-GitHub repo.
200fn repo_header(repo: &Value) -> String {
201    let name = sanitize(repo.get("main_repo").and_then(Value::as_str).unwrap_or("-"));
202    let root = sanitize(repo.get("root").and_then(Value::as_str).unwrap_or(""));
203    match github_summary(repo) {
204        Some(github) => format!("{name}  ({github})  {root}"),
205        None => format!("{name}  {root}"),
206    }
207}
208
209/// A `github: owner/name` summary for a repo, or `None` when it has no GitHub
210/// identity (a non-GitHub or remote-less repo).
211fn github_summary(repo: &Value) -> Option<String> {
212    let owner = repo.pointer("/github/owner").and_then(Value::as_str)?;
213    let name = repo.pointer("/github/name").and_then(Value::as_str)?;
214    Some(format!("github: {}/{}", sanitize(owner), sanitize(name)))
215}
216
217/// One indented worktree row: a `*` for the main working tree, the branch, the
218/// `+ahead -behind` sync state, an `open` flag when a window has it open, and the
219/// worktree path.
220fn worktree_row(worktree: &Value) -> String {
221    let marker = if worktree.get("is_main").and_then(Value::as_bool) == Some(true) {
222        '*'
223    } else {
224        ' '
225    };
226    let branch = sanitize(
227        worktree
228            .get("branch")
229            .and_then(Value::as_str)
230            .unwrap_or("-"),
231    );
232    let sync = sync_summary(worktree);
233    let open = if worktree.get("open").and_then(Value::as_bool) == Some(true) {
234        "open"
235    } else {
236        ""
237    };
238    let path = sanitize(worktree.get("path").and_then(Value::as_str).unwrap_or(""));
239    format!("  {marker} {branch:<24} {sync:<9} {open:<5} {path}")
240}
241
242/// The repo name to show for a window: the daemon-computed `main_repo` (which
243/// names the *parent* repository of a linked worktree, not its worktree-folder
244/// basename) when present, else the companion-reported `repo`, else `-`.
245fn repo_name(window: &Value) -> &str {
246    window
247        .get("main_repo")
248        .and_then(Value::as_str)
249        .or_else(|| window.get("repo").and_then(Value::as_str))
250        .unwrap_or("-")
251}
252
253/// A compact `+ahead -behind` divergence indicator for a window, or `-` when
254/// the branch tracks no upstream (or there is no branch at all). The counts are
255/// daemon-computed integers, so no sanitizing is needed.
256fn sync_summary(window: &Value) -> String {
257    let ahead = window.get("ahead").and_then(Value::as_u64);
258    let behind = window.get("behind").and_then(Value::as_u64);
259    match (ahead, behind) {
260        (Some(ahead), Some(behind)) => format!("+{ahead} -{behind}"),
261        _ => "-".to_string(),
262    }
263}
264
265/// The primary folder of a window, with a `(+N)` suffix when it has more than
266/// one workspace folder.
267fn folder_summary(window: &Value) -> String {
268    let folders = window
269        .get("folders")
270        .and_then(Value::as_array)
271        .map(Vec::as_slice)
272        .unwrap_or_default();
273    let first = sanitize(folders.first().and_then(Value::as_str).unwrap_or(""));
274    let extra = folders.len().saturating_sub(1);
275    if extra > 0 {
276        format!("{first} (+{extra})")
277    } else {
278        first
279    }
280}
281
282/// Strips control characters (C0, DEL, C1) from an untrusted registry string so
283/// a malicious `register` payload cannot inject terminal escape sequences into
284/// the rendered table (#1137). The `--json` path stays verbatim.
285fn sanitize(s: &str) -> String {
286    s.chars().filter(|c| !c.is_control()).collect()
287}
288
289/// Seconds elapsed since an RFC 3339 timestamp (0 if absent/unparseable).
290fn age_secs(ts: Option<&str>) -> i64 {
291    ts.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
292        .map_or(0, |t| {
293            (Utc::now() - t.with_timezone(&Utc)).num_seconds().max(0)
294        })
295}
296
297#[cfg(test)]
298#[allow(clippy::unwrap_used, clippy::expect_used)]
299mod tests {
300    use super::*;
301    use serde_json::json;
302
303    /// Mirrors the `omni-dev worktrees` argv surface for parse tests.
304    #[derive(Parser)]
305    struct Wrapper {
306        #[command(subcommand)]
307        cmd: WorktreesSubcommands,
308    }
309
310    fn parse(args: &[&str]) -> WorktreesSubcommands {
311        let mut full = vec!["omni-dev"];
312        full.extend_from_slice(args);
313        Wrapper::try_parse_from(full).unwrap().cmd
314    }
315
316    #[test]
317    fn list_parses_flags_and_defaults() {
318        // Routing: `worktrees list` maps to the List variant.
319        assert!(matches!(parse(&["list"]), WorktreesSubcommands::List(_)));
320        // Flags, via the leaf parser (clap treats argv[0] as the command name).
321        let cmd = ListCommand::try_parse_from(["list"]).unwrap();
322        assert_eq!(cmd.output, TableOrJson::Table);
323        assert!(!cmd.json);
324        assert!(cmd.socket.is_none());
325
326        let cmd =
327            ListCommand::try_parse_from(["list", "-o", "json", "--socket", "/tmp/d.sock"]).unwrap();
328        assert_eq!(cmd.output, TableOrJson::Json);
329        assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
330    }
331
332    #[test]
333    fn list_deprecated_json_flag_still_parses() {
334        // `--json` is captured separately; `execute` folds it into `output`.
335        let cmd = ListCommand::try_parse_from(["list", "--json"]).unwrap();
336        assert!(cmd.json);
337        assert_eq!(cmd.output, TableOrJson::Table);
338    }
339
340    #[test]
341    fn tree_parses_flags_and_defaults() {
342        // Routing: `worktrees tree` maps to the Tree variant.
343        assert!(matches!(parse(&["tree"]), WorktreesSubcommands::Tree(_)));
344        let cmd = TreeCommand::try_parse_from(["tree"]).unwrap();
345        assert_eq!(cmd.output, TableOrJson::Table);
346        assert!(cmd.socket.is_none());
347
348        let cmd =
349            TreeCommand::try_parse_from(["tree", "-o", "json", "--socket", "/tmp/d.sock"]).unwrap();
350        assert_eq!(cmd.output, TableOrJson::Json);
351        assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
352    }
353
354    #[test]
355    fn render_windows_handles_empty_replies() {
356        assert_eq!(
357            render_windows(&json!({ "windows": [] })),
358            "No open windows."
359        );
360        assert_eq!(render_windows(&json!({})), "No open windows.");
361    }
362
363    #[test]
364    fn render_windows_renders_rows() {
365        let result = json!({ "windows": [{
366            "key": "w1",
367            "repo": "omni-dev",
368            "branch": "issue-1011",
369            "ahead": 2,
370            "behind": 1,
371            "folders": ["/home/me/omni-dev", "/home/me/docs"],
372            "last_seen": "2000-01-01T00:00:00Z",
373        }]});
374        let table = render_windows(&result);
375        assert!(table.contains("omni-dev"), "{table}");
376        // The computed branch and its sync state both render.
377        assert!(table.contains("issue-1011"), "{table}");
378        assert!(table.contains("+2 -1"), "{table}");
379        // Primary folder plus a (+1) for the second workspace folder.
380        assert!(table.contains("/home/me/omni-dev (+1)"), "{table}");
381        // A header line plus exactly one data row.
382        assert_eq!(table.lines().count(), 2, "{table}");
383    }
384
385    #[test]
386    fn render_windows_prefers_main_repo_over_companion_repo() {
387        // A linked worktree: the companion reports the worktree-folder basename,
388        // but the daemon-computed `main_repo` names the parent repo, and that is
389        // what the REPO column shows.
390        let result = json!({ "windows": [{
391            "key": "w1",
392            "repo": "issue-1250",
393            "main_repo": "omni-dev",
394            "branch": "issue-1250",
395            "folders": ["/home/me/worktrees/issue-1250"],
396            "last_seen": "2000-01-01T00:00:00Z",
397        }]});
398        let table = render_windows(&result);
399        assert!(table.contains("omni-dev"), "{table}");
400        // The misleading worktree-folder basename does not appear in REPO (it is
401        // still visible in the FOLDER column path).
402        let data_row = table.lines().nth(1).unwrap();
403        assert!(data_row.starts_with("omni-dev"), "{data_row}");
404    }
405
406    #[test]
407    fn repo_name_falls_back_to_companion_repo_then_dash() {
408        assert_eq!(
409            repo_name(&json!({ "main_repo": "omni-dev", "repo": "wt" })),
410            "omni-dev"
411        );
412        assert_eq!(repo_name(&json!({ "repo": "wt" })), "wt");
413        assert_eq!(repo_name(&json!({})), "-");
414    }
415
416    #[test]
417    fn render_windows_strips_control_bytes() {
418        // C0 (ESC, CR, BEL), DEL, and C1 (CSI) bytes in every string-valued
419        // field must not reach the terminal (#1137).
420        let result = json!({ "windows": [{
421            "key": "w1",
422            "repo": "evil\x1b[31mrepo",
423            "branch": "br\ranch\x07\u{9b}2J",
424            "folders": ["/tmp/a\x1b]0;owned\x07\u{7f}", "/tmp/b"],
425            "last_seen": "2000-01-01T00:00:00Z",
426        }]});
427        let table = render_windows(&result);
428        assert!(
429            !table.contains(|c: char| c.is_control() && c != '\n'),
430            "{table:?}"
431        );
432        // Visible text survives with only the control bytes removed.
433        assert!(table.contains("evil[31mrepo"), "{table:?}");
434        assert!(table.contains("branch2J"), "{table:?}");
435        assert!(table.contains("/tmp/a]0;owned (+1)"), "{table:?}");
436        // Embedded CR/LF cannot forge extra rows: header plus one data row.
437        assert_eq!(table.lines().count(), 2, "{table:?}");
438    }
439
440    #[test]
441    fn sync_summary_formats_or_dashes() {
442        assert_eq!(sync_summary(&json!({ "ahead": 2, "behind": 1 })), "+2 -1");
443        assert_eq!(sync_summary(&json!({ "ahead": 0, "behind": 0 })), "+0 -0");
444        // Branch present but no upstream, or nothing at all → a dash.
445        assert_eq!(sync_summary(&json!({ "branch": "main" })), "-");
446        assert_eq!(sync_summary(&json!({})), "-");
447    }
448
449    #[test]
450    fn folder_summary_strips_control_bytes() {
451        assert_eq!(
452            folder_summary(&json!({ "folders": ["/a\x1b[2J/b"] })),
453            "/a[2J/b"
454        );
455    }
456
457    #[test]
458    fn folder_summary_counts_extra_folders() {
459        assert_eq!(folder_summary(&json!({ "folders": [] })), "");
460        assert_eq!(folder_summary(&json!({ "folders": ["/a"] })), "/a");
461        assert_eq!(
462            folder_summary(&json!({ "folders": ["/a", "/b", "/c"] })),
463            "/a (+2)"
464        );
465    }
466
467    #[test]
468    fn age_secs_handles_absent_and_unparseable_and_past() {
469        assert_eq!(age_secs(None), 0);
470        assert_eq!(age_secs(Some("not-a-timestamp")), 0);
471        assert!(age_secs(Some("2000-01-01T00:00:00Z")) > 0);
472    }
473
474    #[test]
475    fn render_tree_handles_empty_replies() {
476        assert_eq!(
477            render_tree(&json!({ "repos": [] })),
478            "No repositories open."
479        );
480        assert_eq!(render_tree(&json!({})), "No repositories open.");
481    }
482
483    #[test]
484    fn render_tree_groups_repos_and_worktrees() {
485        let result = json!({ "repos": [{
486            "main_repo": "omni-dev",
487            "github": { "owner": "rust-works", "name": "omni-dev" },
488            "root": "/home/me/omni-dev",
489            "worktrees": [
490                { "path": "/home/me/omni-dev", "branch": "main", "ahead": 2, "behind": 0,
491                  "is_main": true, "open": true, "window_key": "w1" },
492                { "path": "/home/me/wt/issue-1300", "branch": "issue-1300", "ahead": 1, "behind": 3,
493                  "is_main": false, "open": false },
494            ],
495        }]});
496        let out = render_tree(&result);
497        // Repo header carries the GitHub identity and root.
498        let header = out.lines().next().unwrap();
499        assert!(header.contains("omni-dev"), "{out}");
500        assert!(header.contains("github: rust-works/omni-dev"), "{out}");
501        assert!(header.contains("/home/me/omni-dev"), "{out}");
502        // The main working tree is marked with `*`, its sync, and `open`.
503        assert!(
504            out.lines()
505                .any(|l| l.contains("* main") && l.contains("+2 -0") && l.contains("open")),
506            "{out}"
507        );
508        // The linked worktree is unmarked and not flagged open.
509        let linked = out
510            .lines()
511            .find(|l| l.contains("issue-1300"))
512            .unwrap_or_default();
513        assert!(!linked.contains('*'), "{linked}");
514        assert!(!linked.contains("open"), "{linked}");
515        assert!(linked.contains("+1 -3"), "{linked}");
516        // Header + two worktree rows.
517        assert_eq!(out.lines().count(), 3, "{out}");
518    }
519
520    #[test]
521    fn render_tree_separates_multiple_repos_with_blank_line() {
522        let result = json!({ "repos": [
523            {
524                "main_repo": "alpha",
525                "root": "/r/alpha",
526                "worktrees": [
527                    { "path": "/r/alpha", "branch": "main", "is_main": true, "open": false },
528                ],
529            },
530            {
531                "main_repo": "beta",
532                "root": "/r/beta",
533                "worktrees": [
534                    { "path": "/r/beta", "branch": "main", "is_main": true, "open": false },
535                ],
536            },
537        ]});
538        let out = render_tree(&result);
539        // Two headers, two worktree rows, and one blank separator between repos.
540        assert!(
541            out.contains("\n\nbeta"),
542            "repos not blank-separated: {out:?}"
543        );
544        let alpha = out.find("alpha").unwrap();
545        let beta = out.find("beta").unwrap();
546        assert!(alpha < beta, "repo order not preserved: {out}");
547        assert_eq!(out.lines().count(), 5, "{out:?}");
548    }
549
550    #[test]
551    fn render_tree_omits_github_for_non_github_repo() {
552        let result = json!({ "repos": [{
553            "main_repo": "internal",
554            "root": "/srv/internal",
555            "worktrees": [
556                { "path": "/srv/internal", "branch": "main", "is_main": true, "open": false },
557            ],
558        }]});
559        let out = render_tree(&result);
560        assert!(!out.contains("github:"), "{out}");
561        assert!(out.lines().next().unwrap().contains("internal"), "{out}");
562    }
563
564    #[test]
565    fn render_tree_strips_control_bytes() {
566        // Control bytes in the repo name, github identity, branch, and path must
567        // not reach the terminal (#1137), matching the `list` renderer.
568        let result = json!({ "repos": [{
569            "main_repo": "evil\x1b[31mrepo",
570            "github": { "owner": "ow\x07ner", "name": "na\u{9b}2Jme" },
571            "root": "/tmp/r\x1b]0;x\x07oot",
572            "worktrees": [
573                { "path": "/tmp/w\rt", "branch": "br\x1b[2Janch", "is_main": true, "open": true },
574            ],
575        }]});
576        let out = render_tree(&result);
577        assert!(
578            !out.contains(|c: char| c.is_control() && c != '\n'),
579            "{out:?}"
580        );
581        // Embedded CR/LF cannot forge extra lines: header plus one worktree row.
582        assert_eq!(out.lines().count(), 2, "{out:?}");
583    }
584
585    #[test]
586    fn github_summary_needs_both_owner_and_name() {
587        assert_eq!(
588            github_summary(&json!({ "github": { "owner": "o", "name": "n" } })).as_deref(),
589            Some("github: o/n")
590        );
591        assert_eq!(github_summary(&json!({ "github": { "owner": "o" } })), None);
592        assert_eq!(github_summary(&json!({})), None);
593    }
594
595    #[test]
596    fn reply_payload_unwraps_ok_and_maps_errors() {
597        // ok → payload.
598        assert_eq!(
599            reply_payload(DaemonReply::ok(json!({ "a": 1 }))).unwrap(),
600            json!({ "a": 1 })
601        );
602        // ok: false with a message → that message.
603        let err = reply_payload(DaemonReply::err("boom")).unwrap_err();
604        assert!(err.to_string().contains("boom"), "{err}");
605        // ok: false with no message → the "unknown error" fallback.
606        let err = reply_payload(DaemonReply {
607            ok: false,
608            payload: Value::Null,
609            error: None,
610        })
611        .unwrap_err();
612        assert!(err.to_string().contains("unknown error"), "{err}");
613    }
614}