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(repo_name(window));
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/// The repo name to show for a window: the daemon-computed `main_repo` (which
134/// names the *parent* repository of a linked worktree, not its worktree-folder
135/// basename) when present, else the companion-reported `repo`, else `-`.
136fn repo_name(window: &Value) -> &str {
137    window
138        .get("main_repo")
139        .and_then(Value::as_str)
140        .or_else(|| window.get("repo").and_then(Value::as_str))
141        .unwrap_or("-")
142}
143
144/// A compact `+ahead -behind` divergence indicator for a window, or `-` when
145/// the branch tracks no upstream (or there is no branch at all). The counts are
146/// daemon-computed integers, so no sanitizing is needed.
147fn sync_summary(window: &Value) -> String {
148    let ahead = window.get("ahead").and_then(Value::as_u64);
149    let behind = window.get("behind").and_then(Value::as_u64);
150    match (ahead, behind) {
151        (Some(ahead), Some(behind)) => format!("+{ahead} -{behind}"),
152        _ => "-".to_string(),
153    }
154}
155
156/// The primary folder of a window, with a `(+N)` suffix when it has more than
157/// one workspace folder.
158fn folder_summary(window: &Value) -> String {
159    let folders = window
160        .get("folders")
161        .and_then(Value::as_array)
162        .map(Vec::as_slice)
163        .unwrap_or_default();
164    let first = sanitize(folders.first().and_then(Value::as_str).unwrap_or(""));
165    let extra = folders.len().saturating_sub(1);
166    if extra > 0 {
167        format!("{first} (+{extra})")
168    } else {
169        first
170    }
171}
172
173/// Strips control characters (C0, DEL, C1) from an untrusted registry string so
174/// a malicious `register` payload cannot inject terminal escape sequences into
175/// the rendered table (#1137). The `--json` path stays verbatim.
176fn sanitize(s: &str) -> String {
177    s.chars().filter(|c| !c.is_control()).collect()
178}
179
180/// Seconds elapsed since an RFC 3339 timestamp (0 if absent/unparseable).
181fn age_secs(ts: Option<&str>) -> i64 {
182    ts.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
183        .map_or(0, |t| {
184            (Utc::now() - t.with_timezone(&Utc)).num_seconds().max(0)
185        })
186}
187
188#[cfg(test)]
189#[allow(clippy::unwrap_used, clippy::expect_used)]
190mod tests {
191    use super::*;
192    use serde_json::json;
193
194    /// Mirrors the `omni-dev worktrees` argv surface for parse tests.
195    #[derive(Parser)]
196    struct Wrapper {
197        #[command(subcommand)]
198        cmd: WorktreesSubcommands,
199    }
200
201    fn parse(args: &[&str]) -> WorktreesSubcommands {
202        let mut full = vec!["omni-dev"];
203        full.extend_from_slice(args);
204        Wrapper::try_parse_from(full).unwrap().cmd
205    }
206
207    #[test]
208    fn list_parses_flags_and_defaults() {
209        let WorktreesSubcommands::List(cmd) = parse(&["list"]);
210        assert_eq!(cmd.output, TableOrJson::Table);
211        assert!(!cmd.json);
212        assert!(cmd.socket.is_none());
213
214        let WorktreesSubcommands::List(cmd) =
215            parse(&["list", "-o", "json", "--socket", "/tmp/d.sock"]);
216        assert_eq!(cmd.output, TableOrJson::Json);
217        assert_eq!(cmd.socket.as_deref(), Some(Path::new("/tmp/d.sock")));
218    }
219
220    #[test]
221    fn list_deprecated_json_flag_still_parses() {
222        // `--json` is captured separately; `execute` folds it into `output`.
223        let WorktreesSubcommands::List(cmd) = parse(&["list", "--json"]);
224        assert!(cmd.json);
225        assert_eq!(cmd.output, TableOrJson::Table);
226    }
227
228    #[test]
229    fn render_windows_handles_empty_replies() {
230        assert_eq!(
231            render_windows(&json!({ "windows": [] })),
232            "No open windows."
233        );
234        assert_eq!(render_windows(&json!({})), "No open windows.");
235    }
236
237    #[test]
238    fn render_windows_renders_rows() {
239        let result = json!({ "windows": [{
240            "key": "w1",
241            "repo": "omni-dev",
242            "branch": "issue-1011",
243            "ahead": 2,
244            "behind": 1,
245            "folders": ["/home/me/omni-dev", "/home/me/docs"],
246            "last_seen": "2000-01-01T00:00:00Z",
247        }]});
248        let table = render_windows(&result);
249        assert!(table.contains("omni-dev"), "{table}");
250        // The computed branch and its sync state both render.
251        assert!(table.contains("issue-1011"), "{table}");
252        assert!(table.contains("+2 -1"), "{table}");
253        // Primary folder plus a (+1) for the second workspace folder.
254        assert!(table.contains("/home/me/omni-dev (+1)"), "{table}");
255        // A header line plus exactly one data row.
256        assert_eq!(table.lines().count(), 2, "{table}");
257    }
258
259    #[test]
260    fn render_windows_prefers_main_repo_over_companion_repo() {
261        // A linked worktree: the companion reports the worktree-folder basename,
262        // but the daemon-computed `main_repo` names the parent repo, and that is
263        // what the REPO column shows.
264        let result = json!({ "windows": [{
265            "key": "w1",
266            "repo": "issue-1250",
267            "main_repo": "omni-dev",
268            "branch": "issue-1250",
269            "folders": ["/home/me/worktrees/issue-1250"],
270            "last_seen": "2000-01-01T00:00:00Z",
271        }]});
272        let table = render_windows(&result);
273        assert!(table.contains("omni-dev"), "{table}");
274        // The misleading worktree-folder basename does not appear in REPO (it is
275        // still visible in the FOLDER column path).
276        let data_row = table.lines().nth(1).unwrap();
277        assert!(data_row.starts_with("omni-dev"), "{data_row}");
278    }
279
280    #[test]
281    fn repo_name_falls_back_to_companion_repo_then_dash() {
282        assert_eq!(
283            repo_name(&json!({ "main_repo": "omni-dev", "repo": "wt" })),
284            "omni-dev"
285        );
286        assert_eq!(repo_name(&json!({ "repo": "wt" })), "wt");
287        assert_eq!(repo_name(&json!({})), "-");
288    }
289
290    #[test]
291    fn render_windows_strips_control_bytes() {
292        // C0 (ESC, CR, BEL), DEL, and C1 (CSI) bytes in every string-valued
293        // field must not reach the terminal (#1137).
294        let result = json!({ "windows": [{
295            "key": "w1",
296            "repo": "evil\x1b[31mrepo",
297            "branch": "br\ranch\x07\u{9b}2J",
298            "folders": ["/tmp/a\x1b]0;owned\x07\u{7f}", "/tmp/b"],
299            "last_seen": "2000-01-01T00:00:00Z",
300        }]});
301        let table = render_windows(&result);
302        assert!(
303            !table.contains(|c: char| c.is_control() && c != '\n'),
304            "{table:?}"
305        );
306        // Visible text survives with only the control bytes removed.
307        assert!(table.contains("evil[31mrepo"), "{table:?}");
308        assert!(table.contains("branch2J"), "{table:?}");
309        assert!(table.contains("/tmp/a]0;owned (+1)"), "{table:?}");
310        // Embedded CR/LF cannot forge extra rows: header plus one data row.
311        assert_eq!(table.lines().count(), 2, "{table:?}");
312    }
313
314    #[test]
315    fn sync_summary_formats_or_dashes() {
316        assert_eq!(sync_summary(&json!({ "ahead": 2, "behind": 1 })), "+2 -1");
317        assert_eq!(sync_summary(&json!({ "ahead": 0, "behind": 0 })), "+0 -0");
318        // Branch present but no upstream, or nothing at all → a dash.
319        assert_eq!(sync_summary(&json!({ "branch": "main" })), "-");
320        assert_eq!(sync_summary(&json!({})), "-");
321    }
322
323    #[test]
324    fn folder_summary_strips_control_bytes() {
325        assert_eq!(
326            folder_summary(&json!({ "folders": ["/a\x1b[2J/b"] })),
327            "/a[2J/b"
328        );
329    }
330
331    #[test]
332    fn folder_summary_counts_extra_folders() {
333        assert_eq!(folder_summary(&json!({ "folders": [] })), "");
334        assert_eq!(folder_summary(&json!({ "folders": ["/a"] })), "/a");
335        assert_eq!(
336            folder_summary(&json!({ "folders": ["/a", "/b", "/c"] })),
337            "/a (+2)"
338        );
339    }
340
341    #[test]
342    fn age_secs_handles_absent_and_unparseable_and_past() {
343        assert_eq!(age_secs(None), 0);
344        assert_eq!(age_secs(Some("not-a-timestamp")), 0);
345        assert!(age_secs(Some("2000-01-01T00:00:00Z")) > 0);
346    }
347
348    #[test]
349    fn reply_payload_unwraps_ok_and_maps_errors() {
350        // ok → payload.
351        assert_eq!(
352            reply_payload(DaemonReply::ok(json!({ "a": 1 }))).unwrap(),
353            json!({ "a": 1 })
354        );
355        // ok: false with a message → that message.
356        let err = reply_payload(DaemonReply::err("boom")).unwrap_err();
357        assert!(err.to_string().contains("boom"), "{err}");
358        // ok: false with no message → the "unknown error" fallback.
359        let err = reply_payload(DaemonReply {
360            ok: false,
361            payload: Value::Null,
362            error: None,
363        })
364        .unwrap_err();
365        assert!(err.to_string().contains("unknown error"), "{err}");
366    }
367}