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 op (`list`) over the
6//! daemon's Unix control socket. The companion VS Code extension is what *feeds*
7//! the registry (`register`/`heartbeat`/`unregister`), talking to the same
8//! socket directly from each window.
9
10use std::path::{Path, PathBuf};
11
12use anyhow::{bail, Result};
13use chrono::Utc;
14use clap::{Parser, Subcommand};
15use serde_json::Value;
16
17use crate::cli::format::TableOrJson;
18use crate::daemon::client::DaemonClient;
19use crate::daemon::protocol::{DaemonEnvelope, DaemonReply};
20use crate::daemon::server;
21
22/// The `worktrees` service routing key on the daemon control socket.
23const SERVICE: &str = "worktrees";
24
25/// Worktrees: see the repos/worktrees open across every VS Code window, kept
26/// live by the daemon.
27#[derive(Parser)]
28pub struct WorktreesCommand {
29    /// The worktrees subcommand to execute.
30    #[command(subcommand)]
31    pub command: WorktreesSubcommands,
32}
33
34/// Worktrees subcommands.
35#[derive(Subcommand)]
36pub enum WorktreesSubcommands {
37    /// List the repos/worktrees currently open across all windows.
38    List(ListCommand),
39}
40
41impl WorktreesCommand {
42    /// Executes the worktrees command.
43    pub async fn execute(self) -> Result<()> {
44        match self.command {
45            WorktreesSubcommands::List(cmd) => cmd.execute().await,
46        }
47    }
48}
49
50/// Lists the live cross-window set of open worktrees/repos.
51#[derive(Parser)]
52pub struct ListCommand {
53    /// Control-socket path. Defaults to the per-user runtime location.
54    #[arg(long, value_name = "PATH")]
55    pub socket: Option<PathBuf>,
56    /// Output format.
57    #[arg(short = 'o', long, value_enum, default_value_t = TableOrJson::Table)]
58    pub output: TableOrJson,
59    /// Deprecated: use `-o`/`--output json` instead.
60    #[arg(long, hide = true)]
61    pub json: bool,
62}
63
64impl ListCommand {
65    /// Executes the list command.
66    pub async fn execute(mut self) -> Result<()> {
67        if self.json {
68            eprintln!("warning: --json is deprecated; use -o/--output json instead");
69            self.output = TableOrJson::Json;
70        }
71        let socket = server::resolve_socket(self.socket)?;
72        let result = call(&socket, "list", Value::Null).await?;
73        match self.output {
74            TableOrJson::Json => println!("{}", serde_json::to_string_pretty(&result)?),
75            TableOrJson::Table => println!("{}", render_windows(&result)),
76        }
77        Ok(())
78    }
79}
80
81/// Sends one `worktrees` service op over the control socket, returning its
82/// payload or turning an `ok: false` reply into an error.
83async fn call(socket: &Path, op: &str, payload: Value) -> Result<Value> {
84    let reply = DaemonClient::new(socket)
85        .request(DaemonEnvelope::service(SERVICE, op, payload))
86        .await?;
87    reply_payload(reply)
88}
89
90/// Unwraps a daemon reply into its payload, turning an `ok: false` reply into an
91/// error. Pure (no socket), so both mappings are unit-testable.
92fn reply_payload(reply: DaemonReply) -> Result<Value> {
93    if reply.ok {
94        Ok(reply.payload)
95    } else {
96        bail!(
97            "daemon returned an error: {}",
98            reply.error.as_deref().unwrap_or("unknown error")
99        )
100    }
101}
102
103/// Renders a `list` reply as a human-readable table: a header and one row per
104/// open window (repo, the daemon-computed branch and its ahead/behind sync
105/// state, the primary folder, and how long ago it was last seen). Returns a
106/// placeholder line when nothing is open.
107fn render_windows(result: &Value) -> String {
108    let windows = result
109        .get("windows")
110        .and_then(Value::as_array)
111        .map(Vec::as_slice)
112        .unwrap_or_default();
113    if windows.is_empty() {
114        return "No open windows.".to_string();
115    }
116    let mut out = format!(
117        "{:<22} {:<24} {:<9} {:<40} {:>5}",
118        "REPO", "BRANCH", "SYNC", "FOLDER", "AGE"
119    );
120    for window in windows {
121        let repo = sanitize(window.get("repo").and_then(Value::as_str).unwrap_or("-"));
122        let branch = sanitize(window.get("branch").and_then(Value::as_str).unwrap_or("-"));
123        let sync = sync_summary(window);
124        let folder_disp = folder_summary(window);
125        let age = age_secs(window.get("last_seen").and_then(Value::as_str));
126        out.push_str(&format!(
127            "\n{repo:<22} {branch:<24} {sync:<9} {folder_disp:<40} {age:>4}s"
128        ));
129    }
130    out
131}
132
133/// A compact `+ahead -behind` divergence indicator for a window, or `-` when
134/// the branch tracks no upstream (or there is no branch at all). The counts are
135/// daemon-computed integers, so no sanitizing is needed.
136fn sync_summary(window: &Value) -> String {
137    let ahead = window.get("ahead").and_then(Value::as_u64);
138    let behind = window.get("behind").and_then(Value::as_u64);
139    match (ahead, behind) {
140        (Some(ahead), Some(behind)) => format!("+{ahead} -{behind}"),
141        _ => "-".to_string(),
142    }
143}
144
145/// The primary folder of a window, with a `(+N)` suffix when it has more than
146/// one workspace folder.
147fn folder_summary(window: &Value) -> String {
148    let folders = window
149        .get("folders")
150        .and_then(Value::as_array)
151        .map(Vec::as_slice)
152        .unwrap_or_default();
153    let first = sanitize(folders.first().and_then(Value::as_str).unwrap_or(""));
154    let extra = folders.len().saturating_sub(1);
155    if extra > 0 {
156        format!("{first} (+{extra})")
157    } else {
158        first
159    }
160}
161
162/// Strips control characters (C0, DEL, C1) from an untrusted registry string so
163/// a malicious `register` payload cannot inject terminal escape sequences into
164/// the rendered table (#1137). The `--json` path stays verbatim.
165fn sanitize(s: &str) -> String {
166    s.chars().filter(|c| !c.is_control()).collect()
167}
168
169/// Seconds elapsed since an RFC 3339 timestamp (0 if absent/unparseable).
170fn age_secs(ts: Option<&str>) -> i64 {
171    ts.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
172        .map_or(0, |t| {
173            (Utc::now() - t.with_timezone(&Utc)).num_seconds().max(0)
174        })
175}
176
177#[cfg(test)]
178#[allow(clippy::unwrap_used, clippy::expect_used)]
179mod tests {
180    use super::*;
181    use serde_json::json;
182
183    /// Mirrors the `omni-dev worktrees` argv surface for parse tests.
184    #[derive(Parser)]
185    struct Wrapper {
186        #[command(subcommand)]
187        cmd: WorktreesSubcommands,
188    }
189
190    fn parse(args: &[&str]) -> WorktreesSubcommands {
191        let mut full = vec!["omni-dev"];
192        full.extend_from_slice(args);
193        Wrapper::try_parse_from(full).unwrap().cmd
194    }
195
196    #[test]
197    fn list_parses_flags_and_defaults() {
198        let WorktreesSubcommands::List(cmd) = parse(&["list"]);
199        assert_eq!(cmd.output, TableOrJson::Table);
200        assert!(!cmd.json);
201        assert!(cmd.socket.is_none());
202
203        let WorktreesSubcommands::List(cmd) =
204            parse(&["list", "-o", "json", "--socket", "/tmp/d.sock"]);
205        assert_eq!(cmd.output, TableOrJson::Json);
206        assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
207    }
208
209    #[test]
210    fn list_deprecated_json_flag_still_parses() {
211        // `--json` is captured separately; `execute` folds it into `output`.
212        let WorktreesSubcommands::List(cmd) = parse(&["list", "--json"]);
213        assert!(cmd.json);
214        assert_eq!(cmd.output, TableOrJson::Table);
215    }
216
217    #[test]
218    fn render_windows_handles_empty_replies() {
219        assert_eq!(
220            render_windows(&json!({ "windows": [] })),
221            "No open windows."
222        );
223        assert_eq!(render_windows(&json!({})), "No open windows.");
224    }
225
226    #[test]
227    fn render_windows_renders_rows() {
228        let result = json!({ "windows": [{
229            "key": "w1",
230            "repo": "omni-dev",
231            "branch": "issue-1011",
232            "ahead": 2,
233            "behind": 1,
234            "folders": ["/home/me/omni-dev", "/home/me/docs"],
235            "last_seen": "2000-01-01T00:00:00Z",
236        }]});
237        let table = render_windows(&result);
238        assert!(table.contains("omni-dev"), "{table}");
239        // The computed branch and its sync state both render.
240        assert!(table.contains("issue-1011"), "{table}");
241        assert!(table.contains("+2 -1"), "{table}");
242        // Primary folder plus a (+1) for the second workspace folder.
243        assert!(table.contains("/home/me/omni-dev (+1)"), "{table}");
244        // A header line plus exactly one data row.
245        assert_eq!(table.lines().count(), 2, "{table}");
246    }
247
248    #[test]
249    fn render_windows_strips_control_bytes() {
250        // C0 (ESC, CR, BEL), DEL, and C1 (CSI) bytes in every string-valued
251        // field must not reach the terminal (#1137).
252        let result = json!({ "windows": [{
253            "key": "w1",
254            "repo": "evil\x1b[31mrepo",
255            "branch": "br\ranch\x07\u{9b}2J",
256            "folders": ["/tmp/a\x1b]0;owned\x07\u{7f}", "/tmp/b"],
257            "last_seen": "2000-01-01T00:00:00Z",
258        }]});
259        let table = render_windows(&result);
260        assert!(
261            !table.contains(|c: char| c.is_control() && c != '\n'),
262            "{table:?}"
263        );
264        // Visible text survives with only the control bytes removed.
265        assert!(table.contains("evil[31mrepo"), "{table:?}");
266        assert!(table.contains("branch2J"), "{table:?}");
267        assert!(table.contains("/tmp/a]0;owned (+1)"), "{table:?}");
268        // Embedded CR/LF cannot forge extra rows: header plus one data row.
269        assert_eq!(table.lines().count(), 2, "{table:?}");
270    }
271
272    #[test]
273    fn sync_summary_formats_or_dashes() {
274        assert_eq!(sync_summary(&json!({ "ahead": 2, "behind": 1 })), "+2 -1");
275        assert_eq!(sync_summary(&json!({ "ahead": 0, "behind": 0 })), "+0 -0");
276        // Branch present but no upstream, or nothing at all → a dash.
277        assert_eq!(sync_summary(&json!({ "branch": "main" })), "-");
278        assert_eq!(sync_summary(&json!({})), "-");
279    }
280
281    #[test]
282    fn folder_summary_strips_control_bytes() {
283        assert_eq!(
284            folder_summary(&json!({ "folders": ["/a\x1b[2J/b"] })),
285            "/a[2J/b"
286        );
287    }
288
289    #[test]
290    fn folder_summary_counts_extra_folders() {
291        assert_eq!(folder_summary(&json!({ "folders": [] })), "");
292        assert_eq!(folder_summary(&json!({ "folders": ["/a"] })), "/a");
293        assert_eq!(
294            folder_summary(&json!({ "folders": ["/a", "/b", "/c"] })),
295            "/a (+2)"
296        );
297    }
298
299    #[test]
300    fn age_secs_handles_absent_and_unparseable_and_past() {
301        assert_eq!(age_secs(None), 0);
302        assert_eq!(age_secs(Some("not-a-timestamp")), 0);
303        assert!(age_secs(Some("2000-01-01T00:00:00Z")) > 0);
304    }
305
306    #[test]
307    fn reply_payload_unwraps_ok_and_maps_errors() {
308        // ok → payload.
309        assert_eq!(
310            reply_payload(DaemonReply::ok(json!({ "a": 1 }))).unwrap(),
311            json!({ "a": 1 })
312        );
313        // ok: false with a message → that message.
314        let err = reply_payload(DaemonReply::err("boom")).unwrap_err();
315        assert!(err.to_string().contains("boom"), "{err}");
316        // ok: false with no message → the "unknown error" fallback.
317        let err = reply_payload(DaemonReply {
318            ok: false,
319            payload: Value::Null,
320            error: None,
321        })
322        .unwrap_err();
323        assert!(err.to_string().contains("unknown error"), "{err}");
324    }
325}