Skip to main content

omni_dev/daemon/services/
worktrees.rs

1//! The worktrees daemon service.
2//!
3//! A thin adapter that hosts the cross-window [`WorktreesRegistry`] under the
4//! daemon's lifecycle and exposes register/heartbeat/unregister/list over the
5//! control socket, plus a tray submenu with a per-window "focus" action.
6//!
7//! All registry state and liveness logic (the `Mutex<HashMap>`, TTL reaping, the
8//! entry cap/eviction) lives in [`crate::worktrees`]; this adapter only routes
9//! ops, renders the menu/status, and drives the VS Code launcher. Like the
10//! Snowflake service it is a cheap, in-memory adapter — no async setup, no
11//! secret persisted.
12//!
13//! The adapter also computes the **per-worktree git enrichment** (current
14//! branch, ahead/behind counts) on read via `git2` (#1186), keeping the
15//! companion a thin reporter of raw folder paths (ADR-0040). The engine stores
16//! only what the companion sends; disk I/O for the enrichment lives here,
17//! alongside the launcher, never under the registry lock.
18
19use std::collections::BTreeSet;
20use std::path::{Path, PathBuf};
21use std::process::{Command, Stdio};
22
23use anyhow::{anyhow, bail, Context, Result};
24use async_trait::async_trait;
25use git2::Repository;
26use serde::Serialize;
27use serde_json::{json, Value};
28
29use crate::daemon::service::{DaemonService, MenuAction, MenuItem, MenuSnapshot, ServiceStatus};
30use crate::worktrees::{RegisterRequest, WindowEntry, WorktreesRegistry};
31
32/// The worktrees service name (the control-socket routing key).
33pub const SERVICE_NAME: &str = "worktrees";
34
35/// Environment override for the VS Code launcher used by the "focus" tray
36/// action, for when the daemon runs under launchd with a minimal `PATH`.
37const VSCODE_BIN_ENV: &str = "OMNI_DEV_VSCODE_BIN";
38
39/// Hosts the cross-window [`WorktreesRegistry`] as a [`DaemonService`].
40pub struct WorktreesService {
41    /// The cross-window registry this adapter routes ops to.
42    registry: WorktreesRegistry,
43}
44
45impl WorktreesService {
46    /// Creates the service with an empty registry. Cheap — no I/O.
47    #[must_use]
48    pub fn new() -> Self {
49        Self {
50            registry: WorktreesRegistry::new(),
51        }
52    }
53}
54
55impl Default for WorktreesService {
56    fn default() -> Self {
57        Self::new()
58    }
59}
60
61#[async_trait]
62impl DaemonService for WorktreesService {
63    fn name(&self) -> &'static str {
64        SERVICE_NAME
65    }
66
67    async fn handle(&self, op: &str, payload: Value) -> Result<Value> {
68        match op {
69            "register" => {
70                let req: RegisterRequest =
71                    serde_json::from_value(payload).context("invalid `register` payload")?;
72                if req.key.trim().is_empty() {
73                    bail!("`register` requires a non-empty `key`");
74                }
75                self.registry.register(req);
76                Ok(json!({ "ok": true }))
77            }
78            "heartbeat" => {
79                let key = require_key(&payload, "heartbeat")?;
80                Ok(json!({ "known": self.registry.heartbeat(key) }))
81            }
82            "unregister" => {
83                let key = require_key(&payload, "unregister")?;
84                Ok(json!({ "removed": self.registry.unregister(key) }))
85            }
86            "list" => Ok(json!({ "windows": enriched_windows(self.registry.list()).await })),
87            other => bail!("unknown worktrees op: {other}"),
88        }
89    }
90
91    fn menu(&self) -> MenuSnapshot {
92        let entries = self.registry.list();
93        let items = if entries.is_empty() {
94            vec![MenuItem::Label("No open windows".to_string())]
95        } else {
96            window_menu_items(&entries)
97        };
98        MenuSnapshot {
99            title: "Worktrees".to_string(),
100            items,
101        }
102    }
103
104    async fn menu_action(&self, action_id: &str) -> Result<()> {
105        if let Some(key) = action_id.strip_prefix("focus:") {
106            // The registry resolves the folder under its own lock and clones it
107            // out, so the mutex is never held across the process launch.
108            let folder = self
109                .registry
110                .first_folder(key)
111                .ok_or_else(|| anyhow!("no open window with key {key} (it may have closed)"))?;
112            focus_window(&folder)?;
113            return Ok(());
114        }
115        bail!("unknown worktrees menu action: {action_id}")
116    }
117
118    async fn status(&self) -> ServiceStatus {
119        let entries = self.registry.list();
120        let repos: BTreeSet<&str> = entries.iter().filter_map(|e| e.repo.as_deref()).collect();
121        let summary = format!("{} window(s) across {} repo(s)", entries.len(), repos.len());
122        let windows = enriched_windows(entries).await;
123        ServiceStatus {
124            name: SERVICE_NAME.to_string(),
125            healthy: true,
126            summary,
127            detail: json!({ "windows": windows }),
128        }
129    }
130
131    async fn shutdown(&self) {
132        // In-memory only; nothing to drain or persist.
133    }
134}
135
136/// Extracts a required string `key` from an op payload, erroring with the op
137/// name when it is absent or not a string.
138fn require_key<'a>(payload: &'a Value, op: &str) -> Result<&'a str> {
139    payload
140        .get("key")
141        .and_then(Value::as_str)
142        .ok_or_else(|| anyhow!("`{op}` requires `key`"))
143}
144
145/// The live git state of a worktree folder: the checked-out branch and how far
146/// it has diverged from its upstream. Computed on read from the on-disk repo
147/// (#1186), so `list`/`status`/`menu` reflect the current branch rather than a
148/// snapshot taken at registration.
149///
150/// Every field is optional and degrades independently: a folder that is not a
151/// git repo, is on a detached HEAD, or whose branch tracks no upstream is still
152/// listed — just without the fields it cannot supply. The `skip_serializing_if`
153/// attributes let it flatten cleanly onto an entry (see [`EnrichedEntry`]),
154/// omitting each absent field on the wire.
155#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
156struct GitStatus {
157    /// The checked-out branch, or `None` when detached or not in a repo.
158    #[serde(skip_serializing_if = "Option::is_none")]
159    branch: Option<String>,
160    /// Commits the branch is ahead of its upstream (`None` without an upstream).
161    #[serde(skip_serializing_if = "Option::is_none")]
162    ahead: Option<usize>,
163    /// Commits the branch is behind its upstream (`None` without an upstream).
164    #[serde(skip_serializing_if = "Option::is_none")]
165    behind: Option<usize>,
166}
167
168/// Computes the [`GitStatus`] of `folder` by discovering the repository that
169/// contains it — so a subdirectory or a linked worktree both resolve — and
170/// reading HEAD. Every failure mode degrades to an empty status rather than
171/// erroring: the enrichment is best-effort and must never sink a `list`.
172fn git_status(folder: &Path) -> GitStatus {
173    let Ok(repo) = Repository::discover(folder) else {
174        return GitStatus::default();
175    };
176    let Ok(head) = repo.head() else {
177        // An unborn branch (fresh repo, no commits) or an unreadable HEAD.
178        return GitStatus::default();
179    };
180    // A branch HEAD has a UTF-8 shorthand; anything else — a detached HEAD
181    // (mid-rebase or a checked-out tag/commit), or the rare non-UTF-8 branch
182    // name — degrades to no branch through this one path.
183    let Some(name) = head
184        .shorthand()
185        .ok()
186        .filter(|_| head.is_branch())
187        .map(str::to_string)
188    else {
189        return GitStatus::default();
190    };
191    let branch = git2::Branch::wrap(head);
192    let (ahead, behind) = match upstream_ahead_behind(&repo, &branch) {
193        Some((ahead, behind)) => (Some(ahead), Some(behind)),
194        None => (None, None),
195    };
196    GitStatus {
197        branch: Some(name),
198        ahead,
199        behind,
200    }
201}
202
203/// Ahead/behind commit counts of `branch` versus its configured upstream, or
204/// `None` when the branch tracks no upstream (or either tip is unresolvable).
205fn upstream_ahead_behind(repo: &Repository, branch: &git2::Branch<'_>) -> Option<(usize, usize)> {
206    let upstream = branch.upstream().ok()?;
207    let local_oid = branch.get().target()?;
208    let upstream_oid = upstream.get().target()?;
209    repo.graph_ahead_behind(local_oid, upstream_oid).ok()
210}
211
212/// The wire shape of an enriched window: the stored entry fields plus the
213/// daemon-computed git state, flattened into one JSON object. Serializing
214/// through a single struct (rather than mutating a `Value`) keeps every present
215/// field on one code path and lets `skip_serializing_if` on [`GitStatus`] drop
216/// the absent git fields — no manual per-field insertion.
217#[derive(Serialize)]
218struct EnrichedEntry<'a> {
219    #[serde(flatten)]
220    entry: &'a WindowEntry,
221    #[serde(flatten)]
222    git: GitStatus,
223}
224
225/// Serializes a registry entry and folds in the live [`git_status`] of its
226/// primary (first) folder, producing the JSON object served on the wire
227/// (`list`/`status`) and read by the extension UI. Only the primary folder is
228/// enriched — it is the one the table shows and the "focus" action opens.
229fn enriched_entry(entry: &WindowEntry) -> Value {
230    let git = entry
231        .folders
232        .first()
233        .map(|folder| git_status(folder))
234        .unwrap_or_default();
235    serde_json::to_value(EnrichedEntry { entry, git }).unwrap_or_else(|_| json!({}))
236}
237
238/// Enriches a batch of entries with their git state on a blocking thread, since
239/// `git2` does synchronous disk I/O and this runs inside the async control-socket
240/// handler. A join failure degrades to an empty list rather than erroring.
241async fn enriched_windows(entries: Vec<WindowEntry>) -> Vec<Value> {
242    tokio::task::spawn_blocking(move || entries.iter().map(enriched_entry).collect())
243        .await
244        .unwrap_or_default()
245}
246
247/// A short human name for a window: its repo, else its first folder's basename,
248/// else a placeholder.
249fn display_name(entry: &WindowEntry) -> String {
250    if let Some(repo) = &entry.repo {
251        return repo.clone();
252    }
253    if let Some(folder) = entry.folders.first() {
254        return folder.file_name().map_or_else(
255            || folder.display().to_string(),
256            |n| n.to_string_lossy().into_owned(),
257        );
258    }
259    "(no folder)".to_string()
260}
261
262/// Builds the tray items for a non-empty window list: a label per window, a
263/// separator, then a "Focus" action per window that has a folder to open. The
264/// labels carry live git state, so this reads each worktree from disk — cheap
265/// for a realistic window count and consistent with reap-on-read.
266fn window_menu_items(entries: &[WindowEntry]) -> Vec<MenuItem> {
267    let mut items = Vec::new();
268    for entry in entries {
269        items.push(MenuItem::Label(window_label(entry)));
270    }
271    items.push(MenuItem::Separator);
272    for entry in entries {
273        // No folder means nothing for `code` to open, so omit the action.
274        if !entry.folders.is_empty() {
275            items.push(MenuItem::Action(MenuAction {
276                id: format!("focus:{}", entry.key),
277                label: format!("Focus {}", display_name(entry)),
278                enabled: true,
279            }));
280        }
281    }
282    items
283}
284
285/// The tray label for one window: its display name, then live branch state
286/// (`repo · branch (+2 -1)`) when the primary folder is a git worktree,
287/// otherwise the reported title if it adds anything the name does not.
288fn window_label(entry: &WindowEntry) -> String {
289    let name = display_name(entry);
290    let status = entry
291        .folders
292        .first()
293        .map(|folder| git_status(folder))
294        .unwrap_or_default();
295    if let Some(branch) = &status.branch {
296        return match sync_indicator(status.ahead, status.behind) {
297            Some(sync) => format!("{name} · {branch} {sync}"),
298            None => format!("{name} · {branch}"),
299        };
300    }
301    // No git branch (not a repo / detached): fall back to the reported title.
302    match &entry.title {
303        Some(title) if title != &name => format!("{name} · {title}"),
304        _ => name,
305    }
306}
307
308/// A compact `(+ahead -behind)` divergence indicator, or `None` when the branch
309/// has no upstream to compare against.
310fn sync_indicator(ahead: Option<usize>, behind: Option<usize>) -> Option<String> {
311    match (ahead, behind) {
312        (Some(ahead), Some(behind)) => Some(format!("(+{ahead} -{behind})")),
313        _ => None,
314    }
315}
316
317/// Well-known absolute locations for the VS Code launcher, tried in order so a
318/// daemon running under launchd (with a minimal `PATH`) still finds it.
319const CODE_BINARY_CANDIDATES: &[&str] = &[
320    "/usr/local/bin/code",
321    "/opt/homebrew/bin/code",
322    "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code",
323    "/usr/bin/code",
324];
325
326/// Focuses (or opens, since VS Code reuses an already-open window) `folder` in
327/// VS Code by spawning its CLI, resolved via [`resolve_code_binary`].
328fn focus_window(folder: &Path) -> Result<()> {
329    focus_window_with(&resolve_code_binary(), folder)
330}
331
332/// Spawns `program` on `folder` after validating the folder. Split out from
333/// [`focus_window`] so the validation and spawn paths are testable with an
334/// explicit launcher (no environment or installed-editor dependency).
335///
336/// Best-effort and non-blocking: the spawned child is reaped on a detached
337/// thread so a long-lived daemon does not accumulate zombies one per focus.
338fn focus_window_with(program: &Path, folder: &Path) -> Result<()> {
339    // Workspace-folder paths are absolute; requiring it also rules out a path
340    // that begins with `-` being parsed by `code` as a flag.
341    if !folder.is_absolute() {
342        bail!(
343            "refusing to focus a non-absolute folder path: {}",
344            folder.display()
345        );
346    }
347    if !folder.is_dir() {
348        bail!("worktree folder no longer exists: {}", folder.display());
349    }
350    // Detach the launcher's stdio so its output never interleaves into the
351    // long-lived daemon's own stdout/stderr (or the test harness's).
352    let child = Command::new(program)
353        .arg(folder)
354        .stdin(Stdio::null())
355        .stdout(Stdio::null())
356        .stderr(Stdio::null())
357        .spawn()
358        .with_context(|| {
359            format!(
360                "failed to launch `{}` to focus {}",
361                program.display(),
362                folder.display()
363            )
364        })?;
365    // Reap the child without blocking so it never lingers as a zombie.
366    std::thread::spawn(move || {
367        let mut child = child;
368        let _ = child.wait();
369    });
370    Ok(())
371}
372
373/// Resolves the VS Code launcher from the real environment: the
374/// `OMNI_DEV_VSCODE_BIN` override, then [`CODE_BINARY_CANDIDATES`], then bare
375/// `code` on `PATH`. The pure resolution logic lives in
376/// [`resolve_code_binary_from`] for testing.
377fn resolve_code_binary() -> PathBuf {
378    resolve_code_binary_from(std::env::var_os(VSCODE_BIN_ENV), CODE_BINARY_CANDIDATES)
379}
380
381/// Pure launcher resolution: `env_override` wins; otherwise the first existing
382/// `candidate`; otherwise bare `code`.
383fn resolve_code_binary_from(
384    env_override: Option<std::ffi::OsString>,
385    candidates: &[&str],
386) -> PathBuf {
387    if let Some(path) = env_override {
388        return PathBuf::from(path);
389    }
390    for candidate in candidates {
391        let path = Path::new(candidate);
392        if path.exists() {
393            return path.to_path_buf();
394        }
395    }
396    PathBuf::from("code")
397}
398
399#[cfg(test)]
400#[allow(clippy::unwrap_used, clippy::expect_used)]
401mod tests {
402    use super::*;
403    use chrono::Utc;
404
405    fn register_payload(key: &str, repo: Option<&str>, folder: &str) -> Value {
406        json!({
407            "key": key,
408            "folders": [folder],
409            "repo": repo,
410            "title": format!("{key}-title"),
411            "pid": 1234,
412        })
413    }
414
415    /// Pulls the `windows` array out of a `list`/`status` payload.
416    fn windows_of(payload: &Value) -> &Vec<Value> {
417        payload
418            .get("windows")
419            .and_then(Value::as_array)
420            .expect("windows array")
421    }
422
423    #[tokio::test]
424    async fn name_and_unknown_op() {
425        let svc = WorktreesService::new();
426        assert_eq!(svc.name(), "worktrees");
427        assert!(svc.handle("frobnicate", Value::Null).await.is_err());
428    }
429
430    #[tokio::test]
431    async fn handle_routes_ops_and_shapes_payloads() {
432        let svc = WorktreesService::new();
433        // Empty to start.
434        let payload = svc.handle("list", Value::Null).await.unwrap();
435        assert_eq!(payload, json!({ "windows": [] }));
436
437        // register → { ok: true }, then it shows up in list.
438        let reply = svc
439            .handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
440            .await
441            .unwrap();
442        assert_eq!(reply, json!({ "ok": true }));
443        let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
444        assert_eq!(windows.len(), 1);
445        assert_eq!(windows[0].get("key").and_then(Value::as_str), Some("w1"));
446        assert!(windows[0].get("last_seen").is_some());
447
448        // heartbeat known/unknown.
449        let known = svc
450            .handle("heartbeat", json!({ "key": "w1" }))
451            .await
452            .unwrap();
453        assert_eq!(known, json!({ "known": true }));
454        let unknown = svc
455            .handle("heartbeat", json!({ "key": "nope" }))
456            .await
457            .unwrap();
458        assert_eq!(unknown, json!({ "known": false }));
459
460        // unregister removes, then repeats as a no-op success.
461        let gone = svc
462            .handle("unregister", json!({ "key": "w1" }))
463            .await
464            .unwrap();
465        assert_eq!(gone, json!({ "removed": true }));
466        let again = svc
467            .handle("unregister", json!({ "key": "w1" }))
468            .await
469            .unwrap();
470        assert_eq!(again, json!({ "removed": false }));
471    }
472
473    #[tokio::test]
474    async fn handle_rejects_missing_or_empty_key() {
475        let svc = WorktreesService::new();
476        // register validates a present, non-blank key.
477        assert!(svc.handle("register", json!({})).await.is_err());
478        assert!(svc
479            .handle("register", json!({ "key": "  " }))
480            .await
481            .is_err());
482        // heartbeat/unregister require the key via `require_key`.
483        assert!(svc.handle("heartbeat", json!({})).await.is_err());
484        assert!(svc.handle("unregister", json!({})).await.is_err());
485    }
486
487    #[test]
488    fn display_name_prefers_repo_then_folder_basename() {
489        let base = WindowEntry {
490            key: "k".to_string(),
491            folders: vec![PathBuf::from("/home/me/project")],
492            repo: Some("my-repo".to_string()),
493            title: None,
494            pid: None,
495            last_seen: Utc::now(),
496        };
497        assert_eq!(display_name(&base), "my-repo");
498
499        let no_repo = WindowEntry {
500            repo: None,
501            ..base.clone()
502        };
503        assert_eq!(display_name(&no_repo), "project");
504
505        let nothing = WindowEntry {
506            repo: None,
507            folders: vec![],
508            ..base.clone()
509        };
510        assert_eq!(display_name(&nothing), "(no folder)");
511
512        // A folder with no basename (the filesystem root) falls back to its
513        // displayed path rather than panicking or yielding an empty name.
514        let rootish = WindowEntry {
515            repo: None,
516            folders: vec![PathBuf::from("/")],
517            ..base
518        };
519        assert_eq!(display_name(&rootish), "/");
520    }
521
522    #[test]
523    fn window_menu_items_label_omits_redundant_title_and_skips_folderless_actions() {
524        let now = Utc::now();
525        let entries = vec![
526            // Title differs from the repo name → "name · title".
527            WindowEntry {
528                key: "k1".to_string(),
529                folders: vec![PathBuf::from("/tmp/a")],
530                repo: Some("repo".to_string()),
531                title: Some("a branch".to_string()),
532                pid: None,
533                last_seen: now,
534            },
535            // Title equals the display name → label is just the name (the
536            // `_ => name` arm), and no folder means no Focus action.
537            WindowEntry {
538                key: "k2".to_string(),
539                folders: vec![],
540                repo: Some("solo".to_string()),
541                title: Some("solo".to_string()),
542                pid: None,
543                last_seen: now,
544            },
545        ];
546        let items = window_menu_items(&entries);
547        let labels: Vec<&str> = items
548            .iter()
549            .filter_map(|i| match i {
550                MenuItem::Label(t) => Some(t.as_str()),
551                _ => None,
552            })
553            .collect();
554        assert!(labels.contains(&"repo · a branch"));
555        assert!(labels.contains(&"solo")); // not "solo · solo"
556
557        let action_ids: Vec<&str> = items
558            .iter()
559            .filter_map(|i| match i {
560                MenuItem::Action(a) => Some(a.id.as_str()),
561                _ => None,
562            })
563            .collect();
564        // Only the folder-bearing window gets a Focus action.
565        assert_eq!(action_ids, vec!["focus:k1"]);
566    }
567
568    #[tokio::test]
569    async fn menu_and_status_shapes() {
570        let svc = WorktreesService::new();
571        // Empty.
572        let menu = svc.menu();
573        assert_eq!(menu.title, "Worktrees");
574        assert!(matches!(
575            menu.items.first(),
576            Some(MenuItem::Label(text)) if text == "No open windows"
577        ));
578        let status = svc.status().await;
579        assert_eq!(status.name, "worktrees");
580        assert!(status.healthy);
581        assert_eq!(status.summary, "0 window(s) across 0 repo(s)");
582
583        // With two windows in the same repo.
584        svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
585            .await
586            .unwrap();
587        svc.handle("register", register_payload("w2", Some("repo-a"), "/tmp/b"))
588            .await
589            .unwrap();
590        let status = svc.status().await;
591        assert_eq!(status.summary, "2 window(s) across 1 repo(s)");
592
593        let menu = svc.menu();
594        // Two labels + a separator + two focus actions.
595        assert!(menu.items.iter().any(|i| matches!(i, MenuItem::Separator)));
596        let action_ids: Vec<&str> = menu
597            .items
598            .iter()
599            .filter_map(|i| match i {
600                MenuItem::Action(a) => Some(a.id.as_str()),
601                _ => None,
602            })
603            .collect();
604        assert!(action_ids.contains(&"focus:w1"));
605        assert!(action_ids.contains(&"focus:w2"));
606    }
607
608    #[tokio::test]
609    async fn default_constructs_an_empty_service() {
610        let svc = WorktreesService::default();
611        let payload = svc.handle("list", Value::Null).await.unwrap();
612        assert_eq!(payload, json!({ "windows": [] }));
613    }
614
615    #[tokio::test]
616    async fn menu_action_rejects_unknown_and_missing_window() {
617        let svc = WorktreesService::new();
618        assert!(svc.menu_action("bogus").await.is_err());
619        // A focus for a key with no registration errors rather than spawning.
620        assert!(svc.menu_action("focus:nope").await.is_err());
621        svc.shutdown().await;
622    }
623
624    /// Restores `OMNI_DEV_VSCODE_BIN` on drop. Only this test reads the variable
625    /// (via `resolve_code_binary` → `focus_window`), so there is no cross-test
626    /// race despite the process-global mutation.
627    struct VscodeBinGuard(Option<std::ffi::OsString>);
628    impl Drop for VscodeBinGuard {
629        fn drop(&mut self) {
630            match self.0.take() {
631                Some(v) => std::env::set_var(VSCODE_BIN_ENV, v),
632                None => std::env::remove_var(VSCODE_BIN_ENV),
633            }
634        }
635    }
636
637    #[tokio::test]
638    async fn menu_action_focus_resolves_folder_and_spawns() {
639        let dir = tempfile::tempdir().unwrap();
640        let svc = WorktreesService::new();
641        svc.handle(
642            "register",
643            json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
644        )
645        .await
646        .unwrap();
647
648        // Point the launcher at a harmless binary so the spawn deterministically
649        // succeeds and the focus path returns Ok.
650        let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
651        std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
652        svc.menu_action("focus:w1").await.unwrap();
653    }
654
655    #[test]
656    fn focus_window_with_validates_folder_then_spawns() {
657        let dir = tempfile::tempdir().unwrap();
658        // Non-absolute and missing-directory folders are rejected before spawn.
659        assert!(focus_window_with(Path::new("/bin/sh"), Path::new("relative/dir")).is_err());
660        assert!(
661            focus_window_with(Path::new("/bin/sh"), Path::new("/no/such/abs/dir/xyzzy")).is_err()
662        );
663        // A valid absolute directory spawns the launcher successfully.
664        focus_window_with(Path::new("/bin/sh"), dir.path()).unwrap();
665        // A missing launcher surfaces the spawn error (with context), not Ok.
666        assert!(focus_window_with(Path::new("/no/such/launcher/xyzzy"), dir.path()).is_err());
667    }
668
669    #[test]
670    fn resolve_code_binary_from_prefers_env_then_candidate_then_fallback() {
671        // Env override wins outright.
672        assert_eq!(
673            resolve_code_binary_from(Some("/custom/code".into()), &["/usr/bin/code"]),
674            PathBuf::from("/custom/code")
675        );
676        // No override: the first existing candidate is chosen.
677        let existing = tempfile::NamedTempFile::new().unwrap();
678        let existing_path = existing.path().to_str().unwrap();
679        assert_eq!(
680            resolve_code_binary_from(None, &["/no/such/candidate/xyzzy", existing_path]),
681            PathBuf::from(existing_path)
682        );
683        // Nothing exists: fall back to bare `code` on PATH.
684        assert_eq!(
685            resolve_code_binary_from(None, &["/no/such/candidate/xyzzy"]),
686            PathBuf::from("code")
687        );
688        // The real-env wrapper resolves without panicking.
689        let _ = resolve_code_binary();
690    }
691
692    // --- Git enrichment (#1186) --------------------------------------------
693
694    /// Initializes a fresh repo with a deterministic identity so `commit()`
695    /// works without depending on a global git config.
696    fn init_repo(dir: &Path) -> Repository {
697        let repo = Repository::init(dir).unwrap();
698        let mut cfg = repo.config().unwrap();
699        cfg.set_str("user.name", "Test").unwrap();
700        cfg.set_str("user.email", "test@example.com").unwrap();
701        repo
702    }
703
704    /// Writes an empty-tree commit (file content is irrelevant to ahead/behind),
705    /// optionally moving `refname` to it, and returns its oid.
706    fn empty_commit(
707        repo: &Repository,
708        refname: Option<&str>,
709        parents: &[&git2::Commit<'_>],
710        msg: &str,
711    ) -> git2::Oid {
712        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
713        let tree = repo
714            .find_tree(repo.treebuilder(None).unwrap().write().unwrap())
715            .unwrap();
716        repo.commit(refname, &sig, &sig, msg, &tree, parents)
717            .unwrap()
718    }
719
720    /// Builds a repo whose `main` is 1 commit ahead of and 1 behind a configured
721    /// `origin/main` upstream, so enrichment reports `ahead: 1, behind: 1`.
722    fn diverging_repo(dir: &Path) -> Repository {
723        let repo = init_repo(dir);
724        // A: the shared base on `main`.
725        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
726        let a_commit = repo.find_commit(a).unwrap();
727        // origin/main diverges to C, a sibling of the local tip.
728        let c = empty_commit(&repo, None, &[&a_commit], "C");
729        repo.reference("refs/remotes/origin/main", c, true, "origin main")
730            .unwrap();
731        // Local `main` advances to B → 1 ahead of / 1 behind origin/main.
732        empty_commit(&repo, Some("refs/heads/main"), &[&a_commit], "B");
733        // Release the commit's borrow of `repo` so it can be returned.
734        drop(a_commit);
735        repo.set_head("refs/heads/main").unwrap();
736        // Configure the tracking relationship so `upstream()` resolves.
737        let mut cfg = repo.config().unwrap();
738        cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
739            .unwrap();
740        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
741            .unwrap();
742        cfg.set_str("branch.main.remote", "origin").unwrap();
743        cfg.set_str("branch.main.merge", "refs/heads/main").unwrap();
744        repo
745    }
746
747    #[test]
748    fn git_status_reads_branch_and_ahead_behind() {
749        let dir = tempfile::tempdir().unwrap();
750        let _repo = diverging_repo(dir.path());
751        let status = git_status(dir.path());
752        assert_eq!(status.branch.as_deref(), Some("main"));
753        assert_eq!(status.ahead, Some(1));
754        assert_eq!(status.behind, Some(1));
755    }
756
757    #[test]
758    fn git_status_empty_repo_is_unborn() {
759        // A repo with no commits has an unborn HEAD, so `head()` errors and the
760        // status degrades to empty rather than panicking.
761        let dir = tempfile::tempdir().unwrap();
762        init_repo(dir.path());
763        assert_eq!(git_status(dir.path()), GitStatus::default());
764    }
765
766    #[test]
767    fn git_status_no_upstream_reports_branch_only() {
768        let dir = tempfile::tempdir().unwrap();
769        let repo = init_repo(dir.path());
770        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
771        repo.set_head("refs/heads/main").unwrap();
772        let status = git_status(dir.path());
773        assert_eq!(status.branch.as_deref(), Some("main"));
774        // No upstream → ahead/behind stay absent rather than zero.
775        assert_eq!(status.ahead, None);
776        assert_eq!(status.behind, None);
777    }
778
779    #[test]
780    fn git_status_non_repo_and_detached_are_empty() {
781        // A plain directory that is not a git repo.
782        let plain = tempfile::tempdir().unwrap();
783        assert_eq!(git_status(plain.path()), GitStatus::default());
784
785        // A detached HEAD reports no branch (and thus no sync).
786        let dir = tempfile::tempdir().unwrap();
787        let repo = init_repo(dir.path());
788        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
789        repo.set_head_detached(a).unwrap();
790        assert_eq!(git_status(dir.path()), GitStatus::default());
791    }
792
793    #[test]
794    fn sync_indicator_formats_only_with_upstream() {
795        assert_eq!(sync_indicator(Some(2), Some(1)).as_deref(), Some("(+2 -1)"));
796        assert_eq!(sync_indicator(Some(0), Some(0)).as_deref(), Some("(+0 -0)"));
797        assert_eq!(sync_indicator(None, None), None);
798        // A partial pair (no real upstream) yields nothing.
799        assert_eq!(sync_indicator(Some(1), None), None);
800    }
801
802    #[tokio::test]
803    async fn list_enriches_entries_with_git_status() {
804        let dir = tempfile::tempdir().unwrap();
805        let repo = init_repo(dir.path());
806        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
807        repo.set_head("refs/heads/main").unwrap();
808
809        let svc = WorktreesService::new();
810        svc.handle(
811            "register",
812            json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
813        )
814        .await
815        .unwrap();
816        let payload = svc.handle("list", Value::Null).await.unwrap();
817        let windows = windows_of(&payload);
818        assert_eq!(windows.len(), 1);
819        assert_eq!(
820            windows[0].get("branch").and_then(Value::as_str),
821            Some("main")
822        );
823        // No upstream configured → the ahead/behind keys are absent, not zero.
824        assert!(windows[0].get("ahead").is_none());
825        assert!(windows[0].get("behind").is_none());
826
827        // A non-repo folder is still listed, just without a branch.
828        let plain = tempfile::tempdir().unwrap();
829        svc.handle(
830            "register",
831            json!({ "key": "w2", "folders": [plain.path()], "repo": "plain" }),
832        )
833        .await
834        .unwrap();
835        let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
836        let w2 = windows
837            .iter()
838            .find(|w| w.get("key").and_then(Value::as_str) == Some("w2"))
839            .unwrap();
840        assert!(w2.get("branch").is_none());
841    }
842
843    #[test]
844    fn window_label_prefers_git_branch_over_title() {
845        let dir = tempfile::tempdir().unwrap();
846        let repo = init_repo(dir.path());
847        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
848        repo.set_head("refs/heads/main").unwrap();
849        let entry = WindowEntry {
850            key: "k".to_string(),
851            folders: vec![dir.path().to_path_buf()],
852            repo: Some("my-repo".to_string()),
853            title: Some("ignored title".to_string()),
854            pid: None,
855            last_seen: Utc::now(),
856        };
857        // The computed branch wins over the reported title; with no upstream
858        // there is no sync suffix.
859        assert_eq!(window_label(&entry), "my-repo · main");
860    }
861
862    #[tokio::test]
863    async fn list_includes_ahead_behind_for_tracking_branch() {
864        let dir = tempfile::tempdir().unwrap();
865        let _repo = diverging_repo(dir.path());
866
867        let svc = WorktreesService::new();
868        svc.handle(
869            "register",
870            json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
871        )
872        .await
873        .unwrap();
874        let payload = svc.handle("list", Value::Null).await.unwrap();
875        let windows = windows_of(&payload);
876        // A tracking branch serializes branch plus both divergence counts.
877        assert_eq!(
878            windows[0].get("branch").and_then(Value::as_str),
879            Some("main")
880        );
881        assert_eq!(windows[0].get("ahead").and_then(Value::as_u64), Some(1));
882        assert_eq!(windows[0].get("behind").and_then(Value::as_u64), Some(1));
883    }
884
885    #[test]
886    fn window_label_includes_sync_for_tracking_branch() {
887        let dir = tempfile::tempdir().unwrap();
888        let _repo = diverging_repo(dir.path());
889        let entry = WindowEntry {
890            key: "k".to_string(),
891            folders: vec![dir.path().to_path_buf()],
892            repo: Some("my-repo".to_string()),
893            title: None,
894            pid: None,
895            last_seen: Utc::now(),
896        };
897        // A tracking branch appends the `(+ahead -behind)` sync indicator.
898        assert_eq!(window_label(&entry), "my-repo · main (+1 -1)");
899    }
900}