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, and the parent repository a linked worktree
15//! belongs to) on read via `git2` (#1186), keeping the companion a thin reporter
16//! of raw folder paths (ADR-0040). The engine stores only what the companion
17//! sends; disk I/O for the enrichment lives here, alongside the launcher, never
18//! under the registry lock.
19
20use std::collections::BTreeSet;
21use std::path::{Path, PathBuf};
22use std::process::{Command, Stdio};
23use std::sync::{Arc, Mutex, PoisonError};
24use std::time::Duration;
25
26use anyhow::{anyhow, bail, Context, Result};
27use async_trait::async_trait;
28use git2::Repository;
29use serde::Serialize;
30use serde_json::{json, Value};
31use tokio::task::JoinHandle;
32use tokio_util::sync::CancellationToken;
33
34use crate::daemon::service::{DaemonService, MenuAction, MenuItem, MenuSnapshot, ServiceStatus};
35use crate::worktrees::{RegisterRequest, WindowEntry, WorktreesRegistry};
36
37/// The worktrees service name (the control-socket routing key).
38pub const SERVICE_NAME: &str = "worktrees";
39
40/// The tray submenu title.
41const SUBMENU_TITLE: &str = "Worktrees";
42
43/// Environment override for the VS Code launcher used by the "focus" tray
44/// action, for when the daemon runs under launchd with a minimal `PATH`.
45const VSCODE_BIN_ENV: &str = "OMNI_DEV_VSCODE_BIN";
46
47/// How often the background task recomputes the tray menu snapshot off the main
48/// thread. The macOS tray polls `menu()` ~1 Hz; caching a couple of seconds keeps
49/// the displayed branch/sync state fresh without ever doing git I/O on the GUI
50/// thread (which would peg a core and stall shutdown — the #1186 regression).
51const MENU_REFRESH_INTERVAL: Duration = Duration::from_secs(2);
52
53/// A running background menu-refresh task and the token that stops it.
54struct RefreshTask {
55    /// Cancelled by `shutdown` to end the refresh loop.
56    token: CancellationToken,
57    /// The spawned loop, awaited on shutdown so it fully unwinds.
58    handle: JoinHandle<()>,
59}
60
61/// Hosts the cross-window [`WorktreesRegistry`] as a [`DaemonService`].
62pub struct WorktreesService {
63    /// The cross-window registry this adapter routes ops to. Behind an `Arc` so
64    /// the background menu-refresh task can read it off the main thread.
65    registry: Arc<WorktreesRegistry>,
66    /// The most recent tray menu snapshot, recomputed off the main thread by
67    /// [`start_menu_refresh`](Self::start_menu_refresh). `menu()` serves a clone
68    /// of this so it never blocks on git enrichment. `None` until the first
69    /// refresh lands — or when no runtime started a task (e.g. unit tests) — in
70    /// which case `menu()` falls back to a one-off inline compute.
71    menu_cache: Arc<Mutex<Option<Vec<MenuItem>>>>,
72    /// The background refresh task, once started (`None` in tests / no runtime).
73    refresh: Mutex<Option<RefreshTask>>,
74}
75
76impl WorktreesService {
77    /// Creates the service with an empty registry. Cheap — no I/O and no task;
78    /// the daemon calls [`start_menu_refresh`](Self::start_menu_refresh) to begin
79    /// off-thread menu caching, while tests use the bare service (menu computed
80    /// inline on demand).
81    #[must_use]
82    pub fn new() -> Self {
83        Self {
84            registry: Arc::new(WorktreesRegistry::new()),
85            menu_cache: Arc::new(Mutex::new(None)),
86            refresh: Mutex::new(None),
87        }
88    }
89
90    /// Starts the background task that recomputes the tray menu snapshot every
91    /// [`MENU_REFRESH_INTERVAL`] **off the main thread** — git enrichment is
92    /// blocking disk I/O — and stores it in [`menu_cache`](Self::menu_cache), so
93    /// the macOS tray's `menu()` serves a cache instead of running git on the GUI
94    /// event loop. Idempotent, and a no-op outside a tokio runtime (mirroring the
95    /// Snowflake keep-alive heartbeat), so unit tests that build a bare service
96    /// keep computing the menu inline.
97    pub fn start_menu_refresh(&self) {
98        if tokio::runtime::Handle::try_current().is_err() {
99            tracing::debug!("no tokio runtime; worktrees menu refresh not started");
100            return;
101        }
102        let mut guard = self.refresh.lock().unwrap_or_else(PoisonError::into_inner);
103        if guard.is_some() {
104            return;
105        }
106        let token = CancellationToken::new();
107        let loop_token = token.clone();
108        let registry = self.registry.clone();
109        let cache = self.menu_cache.clone();
110        let handle = tokio::spawn(async move {
111            loop {
112                // Snapshot the registry (a cheap lock), then build the menu —
113                // which opens repos and parses git config — on a blocking thread,
114                // never on this async worker or the tray's main thread.
115                let entries = registry.list();
116                if let Ok(items) =
117                    tokio::task::spawn_blocking(move || menu_items_for(&entries)).await
118                {
119                    *cache.lock().unwrap_or_else(PoisonError::into_inner) = Some(items);
120                }
121                tokio::select! {
122                    () = loop_token.cancelled() => break,
123                    () = tokio::time::sleep(MENU_REFRESH_INTERVAL) => {}
124                }
125            }
126        });
127        *guard = Some(RefreshTask { token, handle });
128    }
129}
130
131impl Default for WorktreesService {
132    fn default() -> Self {
133        Self::new()
134    }
135}
136
137#[async_trait]
138impl DaemonService for WorktreesService {
139    fn name(&self) -> &'static str {
140        SERVICE_NAME
141    }
142
143    async fn handle(&self, op: &str, payload: Value) -> Result<Value> {
144        match op {
145            "register" => {
146                let req: RegisterRequest =
147                    serde_json::from_value(payload).context("invalid `register` payload")?;
148                if req.key.trim().is_empty() {
149                    bail!("`register` requires a non-empty `key`");
150                }
151                self.registry.register(req);
152                Ok(json!({ "ok": true }))
153            }
154            "heartbeat" => {
155                let key = require_key(&payload, "heartbeat")?;
156                Ok(json!({ "known": self.registry.heartbeat(key) }))
157            }
158            "unregister" => {
159                let key = require_key(&payload, "unregister")?;
160                Ok(json!({ "removed": self.registry.unregister(key) }))
161            }
162            "list" => Ok(json!({ "windows": enriched_windows(self.registry.list()).await })),
163            other => bail!("unknown worktrees op: {other}"),
164        }
165    }
166
167    fn menu(&self) -> MenuSnapshot {
168        // Serve the snapshot the background task maintains off the main thread;
169        // fall back to a one-off inline compute only before the first refresh
170        // lands (or with no runtime — the unit tests). Never blocks on git here
171        // in the daemon, honouring the trait's "cheap, must not block" contract.
172        let cached = self
173            .menu_cache
174            .lock()
175            .unwrap_or_else(PoisonError::into_inner)
176            .clone();
177        let items = cached.unwrap_or_else(|| menu_items_for(&self.registry.list()));
178        MenuSnapshot {
179            title: SUBMENU_TITLE.to_string(),
180            items,
181        }
182    }
183
184    async fn menu_action(&self, action_id: &str) -> Result<()> {
185        if let Some(key) = action_id.strip_prefix("focus:") {
186            // The registry resolves the folder under its own lock and clones it
187            // out, so the mutex is never held across the process launch.
188            let folder = self
189                .registry
190                .first_folder(key)
191                .ok_or_else(|| anyhow!("no open window with key {key} (it may have closed)"))?;
192            focus_window(&folder)?;
193            return Ok(());
194        }
195        bail!("unknown worktrees menu action: {action_id}")
196    }
197
198    async fn status(&self) -> ServiceStatus {
199        let entries = self.registry.list();
200        let repos: BTreeSet<&str> = entries.iter().filter_map(|e| e.repo.as_deref()).collect();
201        let summary = format!("{} window(s) across {} repo(s)", entries.len(), repos.len());
202        let windows = enriched_windows(entries).await;
203        ServiceStatus {
204            name: SERVICE_NAME.to_string(),
205            healthy: true,
206            summary,
207            detail: json!({ "windows": windows }),
208        }
209    }
210
211    async fn shutdown(&self) {
212        // Stop the background menu-refresh task; the registry itself is in-memory
213        // with nothing to drain or persist. Take the task out from under the lock
214        // first so the `std::Mutex` is never held across the `.await`.
215        let task = self
216            .refresh
217            .lock()
218            .unwrap_or_else(PoisonError::into_inner)
219            .take();
220        if let Some(task) = task {
221            task.token.cancel();
222            let _ = task.handle.await;
223        }
224    }
225}
226
227/// Extracts a required string `key` from an op payload, erroring with the op
228/// name when it is absent or not a string.
229fn require_key<'a>(payload: &'a Value, op: &str) -> Result<&'a str> {
230    payload
231        .get("key")
232        .and_then(Value::as_str)
233        .ok_or_else(|| anyhow!("`{op}` requires `key`"))
234}
235
236/// The live git state of a worktree folder: the checked-out branch and how far
237/// it has diverged from its upstream. Computed on read from the on-disk repo
238/// (#1186), so `list`/`status`/`menu` reflect the current branch rather than a
239/// snapshot taken at registration.
240///
241/// Every field is optional and degrades independently: a folder that is not a
242/// git repo, is on a detached HEAD, or whose branch tracks no upstream is still
243/// listed — just without the fields it cannot supply. The `skip_serializing_if`
244/// attributes let it flatten cleanly onto an entry (see [`EnrichedEntry`]),
245/// omitting each absent field on the wire.
246#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
247struct GitStatus {
248    /// The checked-out branch, or `None` when detached or not in a repo.
249    #[serde(skip_serializing_if = "Option::is_none")]
250    branch: Option<String>,
251    /// Commits the branch is ahead of its upstream (`None` without an upstream).
252    #[serde(skip_serializing_if = "Option::is_none")]
253    ahead: Option<usize>,
254    /// Commits the branch is behind its upstream (`None` without an upstream).
255    #[serde(skip_serializing_if = "Option::is_none")]
256    behind: Option<usize>,
257    /// The main repository's directory name — the parent repo for a linked
258    /// worktree, the checkout's own directory otherwise. Derived from git's
259    /// common dir so a worktree names the repo it belongs to rather than its
260    /// worktree-folder basename. `None` when not in a repo.
261    #[serde(skip_serializing_if = "Option::is_none")]
262    main_repo: Option<String>,
263    /// Whether the enriched folder is a **linked** git worktree rather than the
264    /// repository's main working tree. Omitted (false) for a normal checkout.
265    #[serde(skip_serializing_if = "is_false")]
266    is_worktree: bool,
267}
268
269/// `skip_serializing_if` predicate for a `bool` defaulting to `false`, so the
270/// field is dropped on the wire unless set — keeping older clients byte-identical
271/// (the protocol's forward-compatibility convention).
272#[allow(clippy::trivially_copy_pass_by_ref)]
273fn is_false(b: &bool) -> bool {
274    !*b
275}
276
277/// Computes the [`GitStatus`] of `folder` by discovering the repository that
278/// contains it — so a subdirectory or a linked worktree both resolve — and
279/// reading HEAD. Every failure mode degrades to an empty status rather than
280/// erroring: the enrichment is best-effort and must never sink a `list`.
281fn git_status(folder: &Path) -> GitStatus {
282    let Ok(repo) = Repository::discover(folder) else {
283        return GitStatus::default();
284    };
285    // Repo identity applies even when HEAD is unborn or detached, so a worktree
286    // still names its parent repo (and is flagged as a worktree) in those states.
287    let base = GitStatus {
288        main_repo: main_repo_name(repo.commondir()),
289        is_worktree: repo.is_worktree(),
290        ..GitStatus::default()
291    };
292    let Ok(head) = repo.head() else {
293        // An unborn branch (fresh repo, no commits) or an unreadable HEAD.
294        return base;
295    };
296    // A branch HEAD has a UTF-8 shorthand; anything else — a detached HEAD
297    // (mid-rebase or a checked-out tag/commit), or the rare non-UTF-8 branch
298    // name — degrades to no branch through this one path.
299    let Some(name) = head
300        .shorthand()
301        .ok()
302        .filter(|_| head.is_branch())
303        .map(str::to_string)
304    else {
305        return base;
306    };
307    let branch = git2::Branch::wrap(head);
308    let (ahead, behind) = match upstream_ahead_behind(&repo, &branch) {
309        Some((ahead, behind)) => (Some(ahead), Some(behind)),
310        None => (None, None),
311    };
312    GitStatus {
313        branch: Some(name),
314        ahead,
315        behind,
316        ..base
317    }
318}
319
320/// The main repository's directory name from git's common dir. For the usual
321/// `<repo>/.git` layout — shared by a checkout and all its linked worktrees —
322/// that is the working-tree directory's name; for a bare repo (`<name>.git`) it
323/// is that directory with a trailing `.git` stripped. Best-effort: `None` when
324/// no name can be derived.
325fn main_repo_name(commondir: &Path) -> Option<String> {
326    let file_name = commondir.file_name()?.to_string_lossy().into_owned();
327    if file_name == ".git" {
328        // Normal layout: the repo is the directory that contains `.git`.
329        commondir
330            .parent()
331            .and_then(Path::file_name)
332            .map(|n| n.to_string_lossy().into_owned())
333    } else {
334        // A bare repo: use its own directory name, without any `.git` suffix.
335        Some(
336            file_name
337                .strip_suffix(".git")
338                .unwrap_or(&file_name)
339                .to_string(),
340        )
341    }
342}
343
344/// Ahead/behind commit counts of `branch` versus its configured upstream, or
345/// `None` when the branch tracks no upstream (or either tip is unresolvable).
346fn upstream_ahead_behind(repo: &Repository, branch: &git2::Branch<'_>) -> Option<(usize, usize)> {
347    let upstream = branch.upstream().ok()?;
348    let local_oid = branch.get().target()?;
349    let upstream_oid = upstream.get().target()?;
350    repo.graph_ahead_behind(local_oid, upstream_oid).ok()
351}
352
353/// The wire shape of an enriched window: the stored entry fields plus the
354/// daemon-computed git state, flattened into one JSON object. Serializing
355/// through a single struct (rather than mutating a `Value`) keeps every present
356/// field on one code path and lets `skip_serializing_if` on [`GitStatus`] drop
357/// the absent git fields — no manual per-field insertion.
358#[derive(Serialize)]
359struct EnrichedEntry<'a> {
360    #[serde(flatten)]
361    entry: &'a WindowEntry,
362    #[serde(flatten)]
363    git: GitStatus,
364}
365
366/// Serializes a registry entry and folds in the live [`git_status`] of its
367/// primary (first) folder, producing the JSON object served on the wire
368/// (`list`/`status`) and read by the extension UI. Only the primary folder is
369/// enriched — it is the one the table shows and the "focus" action opens.
370fn enriched_entry(entry: &WindowEntry) -> Value {
371    let git = entry
372        .folders
373        .first()
374        .map(|folder| git_status(folder))
375        .unwrap_or_default();
376    serde_json::to_value(EnrichedEntry { entry, git }).unwrap_or_else(|_| json!({}))
377}
378
379/// Enriches a batch of entries with their git state on a blocking thread, since
380/// `git2` does synchronous disk I/O and this runs inside the async control-socket
381/// handler. A join failure degrades to an empty list rather than erroring.
382async fn enriched_windows(entries: Vec<WindowEntry>) -> Vec<Value> {
383    tokio::task::spawn_blocking(move || entries.iter().map(enriched_entry).collect())
384        .await
385        .unwrap_or_default()
386}
387
388/// A short human name for a window: its repo, else its first folder's basename,
389/// else a placeholder.
390fn display_name(entry: &WindowEntry) -> String {
391    if let Some(repo) = &entry.repo {
392        return repo.clone();
393    }
394    if let Some(folder) = entry.folders.first() {
395        return folder.file_name().map_or_else(
396            || folder.display().to_string(),
397            |n| n.to_string_lossy().into_owned(),
398        );
399    }
400    "(no folder)".to_string()
401}
402
403/// Separator between the repo name and branch for a normal working tree.
404const REPO_SEP: char = '·';
405/// Separator marking a **linked worktree** (a git "fork" glyph), so a worktree
406/// line is distinguishable at a glance from its parent repo's main checkout.
407const WORKTREE_SEP: char = '⑂';
408
409/// The full tray item list for a window set: the "No open windows" placeholder
410/// when empty, else one line per window via [`window_menu_items`]. Does the git
411/// enrichment (blocking disk I/O), so it runs on a blocking thread from the
412/// background refresh task — and inline only as a cold-start fallback in `menu`.
413fn menu_items_for(entries: &[WindowEntry]) -> Vec<MenuItem> {
414    if entries.is_empty() {
415        vec![MenuItem::Label("No open windows".to_string())]
416    } else {
417        window_menu_items(entries)
418    }
419}
420
421/// Builds the tray items for a non-empty window list: **one clickable line per
422/// window** whose label carries the live git state and whose click focuses that
423/// window. A window with no workspace folder has nothing for `code` to open, so
424/// it stays a non-clickable status line. The labels read each worktree from disk
425/// (via [`window_label`]) — cheap for a realistic window count and consistent
426/// with reap-on-read.
427fn window_menu_items(entries: &[WindowEntry]) -> Vec<MenuItem> {
428    entries
429        .iter()
430        .map(|entry| {
431            let label = window_label(entry);
432            if entry.folders.is_empty() {
433                MenuItem::Label(label)
434            } else {
435                MenuItem::Action(MenuAction {
436                    id: format!("focus:{}", entry.key),
437                    label,
438                    enabled: true,
439                })
440            }
441        })
442        .collect()
443}
444
445/// The tray label for one window: the **main repository** name, then live branch
446/// state (`omni-dev · branch (+2 -1)`) when the primary folder is a git repo. A
447/// linked worktree is set off with the [`WORKTREE_SEP`] fork glyph
448/// (`omni-dev ⑂ branch`) so it reads distinctly from the main checkout; a folder
449/// that is not a repo falls back to its reported title.
450fn window_label(entry: &WindowEntry) -> String {
451    let status = entry
452        .folders
453        .first()
454        .map(|folder| git_status(folder))
455        .unwrap_or_default();
456    // Prefer the git-derived main repo so a linked worktree names its parent
457    // repository rather than its worktree-folder basename.
458    let name = status
459        .main_repo
460        .clone()
461        .unwrap_or_else(|| display_name(entry));
462    if let Some(branch) = &status.branch {
463        let sep = if status.is_worktree {
464            WORKTREE_SEP
465        } else {
466            REPO_SEP
467        };
468        return match sync_indicator(status.ahead, status.behind) {
469            Some(sync) => format!("{name} {sep} {branch} {sync}"),
470            None => format!("{name} {sep} {branch}"),
471        };
472    }
473    // No git branch (not a repo / detached): fall back to the reported title.
474    match &entry.title {
475        Some(title) if title != &name => format!("{name} {REPO_SEP} {title}"),
476        _ => name,
477    }
478}
479
480/// A compact `(+ahead -behind)` divergence indicator, or `None` when the branch
481/// has no upstream to compare against.
482fn sync_indicator(ahead: Option<usize>, behind: Option<usize>) -> Option<String> {
483    match (ahead, behind) {
484        (Some(ahead), Some(behind)) => Some(format!("(+{ahead} -{behind})")),
485        _ => None,
486    }
487}
488
489/// Well-known absolute locations for the VS Code launcher, tried in order so a
490/// daemon running under launchd (with a minimal `PATH`) still finds it.
491const CODE_BINARY_CANDIDATES: &[&str] = &[
492    "/usr/local/bin/code",
493    "/opt/homebrew/bin/code",
494    "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code",
495    "/usr/bin/code",
496];
497
498/// Focuses (or opens, since VS Code reuses an already-open window) `folder` in
499/// VS Code by spawning its CLI, resolved via [`resolve_code_binary`].
500fn focus_window(folder: &Path) -> Result<()> {
501    focus_window_with(&resolve_code_binary(), folder)
502}
503
504/// Spawns `program` on `folder` after validating the folder. Split out from
505/// [`focus_window`] so the validation and spawn paths are testable with an
506/// explicit launcher (no environment or installed-editor dependency).
507///
508/// Best-effort and non-blocking: the spawned child is reaped on a detached
509/// thread so a long-lived daemon does not accumulate zombies one per focus.
510fn focus_window_with(program: &Path, folder: &Path) -> Result<()> {
511    // Workspace-folder paths are absolute; requiring it also rules out a path
512    // that begins with `-` being parsed by `code` as a flag.
513    if !folder.is_absolute() {
514        bail!(
515            "refusing to focus a non-absolute folder path: {}",
516            folder.display()
517        );
518    }
519    if !folder.is_dir() {
520        bail!("worktree folder no longer exists: {}", folder.display());
521    }
522    // Detach the launcher's stdio so its output never interleaves into the
523    // long-lived daemon's own stdout/stderr (or the test harness's).
524    let child = Command::new(program)
525        .arg(folder)
526        .stdin(Stdio::null())
527        .stdout(Stdio::null())
528        .stderr(Stdio::null())
529        .spawn()
530        .with_context(|| {
531            format!(
532                "failed to launch `{}` to focus {}",
533                program.display(),
534                folder.display()
535            )
536        })?;
537    // Reap the child without blocking so it never lingers as a zombie.
538    std::thread::spawn(move || {
539        let mut child = child;
540        let _ = child.wait();
541    });
542    Ok(())
543}
544
545/// Resolves the VS Code launcher from the real environment: the
546/// `OMNI_DEV_VSCODE_BIN` override, then [`CODE_BINARY_CANDIDATES`], then bare
547/// `code` on `PATH`. The pure resolution logic lives in
548/// [`resolve_code_binary_from`] for testing.
549fn resolve_code_binary() -> PathBuf {
550    resolve_code_binary_from(std::env::var_os(VSCODE_BIN_ENV), CODE_BINARY_CANDIDATES)
551}
552
553/// Pure launcher resolution: `env_override` wins; otherwise the first existing
554/// `candidate`; otherwise bare `code`.
555fn resolve_code_binary_from(
556    env_override: Option<std::ffi::OsString>,
557    candidates: &[&str],
558) -> PathBuf {
559    if let Some(path) = env_override {
560        return PathBuf::from(path);
561    }
562    for candidate in candidates {
563        let path = Path::new(candidate);
564        if path.exists() {
565            return path.to_path_buf();
566        }
567    }
568    PathBuf::from("code")
569}
570
571#[cfg(test)]
572#[allow(clippy::unwrap_used, clippy::expect_used)]
573mod tests {
574    use super::*;
575    use chrono::Utc;
576
577    fn register_payload(key: &str, repo: Option<&str>, folder: &str) -> Value {
578        json!({
579            "key": key,
580            "folders": [folder],
581            "repo": repo,
582            "title": format!("{key}-title"),
583            "pid": 1234,
584        })
585    }
586
587    /// Pulls the `windows` array out of a `list`/`status` payload.
588    fn windows_of(payload: &Value) -> &Vec<Value> {
589        payload
590            .get("windows")
591            .and_then(Value::as_array)
592            .expect("windows array")
593    }
594
595    #[tokio::test]
596    async fn name_and_unknown_op() {
597        let svc = WorktreesService::new();
598        assert_eq!(svc.name(), "worktrees");
599        assert!(svc.handle("frobnicate", Value::Null).await.is_err());
600    }
601
602    #[tokio::test]
603    async fn handle_routes_ops_and_shapes_payloads() {
604        let svc = WorktreesService::new();
605        // Empty to start.
606        let payload = svc.handle("list", Value::Null).await.unwrap();
607        assert_eq!(payload, json!({ "windows": [] }));
608
609        // register → { ok: true }, then it shows up in list.
610        let reply = svc
611            .handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
612            .await
613            .unwrap();
614        assert_eq!(reply, json!({ "ok": true }));
615        let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
616        assert_eq!(windows.len(), 1);
617        assert_eq!(windows[0].get("key").and_then(Value::as_str), Some("w1"));
618        assert!(windows[0].get("last_seen").is_some());
619
620        // heartbeat known/unknown.
621        let known = svc
622            .handle("heartbeat", json!({ "key": "w1" }))
623            .await
624            .unwrap();
625        assert_eq!(known, json!({ "known": true }));
626        let unknown = svc
627            .handle("heartbeat", json!({ "key": "nope" }))
628            .await
629            .unwrap();
630        assert_eq!(unknown, json!({ "known": false }));
631
632        // unregister removes, then repeats as a no-op success.
633        let gone = svc
634            .handle("unregister", json!({ "key": "w1" }))
635            .await
636            .unwrap();
637        assert_eq!(gone, json!({ "removed": true }));
638        let again = svc
639            .handle("unregister", json!({ "key": "w1" }))
640            .await
641            .unwrap();
642        assert_eq!(again, json!({ "removed": false }));
643    }
644
645    #[tokio::test]
646    async fn handle_rejects_missing_or_empty_key() {
647        let svc = WorktreesService::new();
648        // register validates a present, non-blank key.
649        assert!(svc.handle("register", json!({})).await.is_err());
650        assert!(svc
651            .handle("register", json!({ "key": "  " }))
652            .await
653            .is_err());
654        // heartbeat/unregister require the key via `require_key`.
655        assert!(svc.handle("heartbeat", json!({})).await.is_err());
656        assert!(svc.handle("unregister", json!({})).await.is_err());
657    }
658
659    #[test]
660    fn display_name_prefers_repo_then_folder_basename() {
661        let base = WindowEntry {
662            key: "k".to_string(),
663            folders: vec![PathBuf::from("/home/me/project")],
664            repo: Some("my-repo".to_string()),
665            title: None,
666            pid: None,
667            last_seen: Utc::now(),
668        };
669        assert_eq!(display_name(&base), "my-repo");
670
671        let no_repo = WindowEntry {
672            repo: None,
673            ..base.clone()
674        };
675        assert_eq!(display_name(&no_repo), "project");
676
677        let nothing = WindowEntry {
678            repo: None,
679            folders: vec![],
680            ..base.clone()
681        };
682        assert_eq!(display_name(&nothing), "(no folder)");
683
684        // A folder with no basename (the filesystem root) falls back to its
685        // displayed path rather than panicking or yielding an empty name.
686        let rootish = WindowEntry {
687            repo: None,
688            folders: vec![PathBuf::from("/")],
689            ..base
690        };
691        assert_eq!(display_name(&rootish), "/");
692    }
693
694    #[test]
695    fn window_menu_items_merge_stats_and_focus_into_one_clickable_line() {
696        let now = Utc::now();
697        let entries = vec![
698            // A folderless window has nothing to focus, so it stays a plain
699            // Label; a title equal to the name collapses to just the name. It
700            // leads the list so the focus-action lookup below is exercised
701            // against a leading non-Action item it has to skip.
702            WindowEntry {
703                key: "k2".to_string(),
704                folders: vec![],
705                repo: Some("solo".to_string()),
706                title: Some("solo".to_string()),
707                pid: None,
708                last_seen: now,
709            },
710            // A folder-bearing, non-repo window: one clickable Action whose label
711            // is the stats line ("name · title", since /tmp is not a git repo).
712            WindowEntry {
713                key: "k1".to_string(),
714                folders: vec![PathBuf::from("/tmp/a")],
715                repo: Some("repo".to_string()),
716                title: Some("a branch".to_string()),
717                pid: None,
718                last_seen: now,
719            },
720        ];
721        let items = window_menu_items(&entries);
722        // Exactly one item per window — no duplicate label, no separator.
723        assert_eq!(items.len(), 2);
724        assert!(!items.iter().any(|i| matches!(i, MenuItem::Separator)));
725
726        // The folder-bearing window is a single clickable action carrying the
727        // stats label (the old label + Focus action, merged).
728        let action = items
729            .iter()
730            .find_map(|i| match i {
731                MenuItem::Action(a) => Some(a),
732                _ => None,
733            })
734            .expect("a focus action");
735        assert_eq!(action.id, "focus:k1");
736        assert_eq!(action.label, "repo · a branch");
737
738        // The folderless window is a non-clickable label (not "solo · solo").
739        let labels: Vec<&str> = items
740            .iter()
741            .filter_map(|i| match i {
742                MenuItem::Label(t) => Some(t.as_str()),
743                _ => None,
744            })
745            .collect();
746        assert_eq!(labels, vec!["solo"]);
747    }
748
749    #[tokio::test]
750    async fn menu_and_status_shapes() {
751        let svc = WorktreesService::new();
752        // Empty.
753        let menu = svc.menu();
754        assert_eq!(menu.title, "Worktrees");
755        assert!(matches!(
756            menu.items.first(),
757            Some(MenuItem::Label(text)) if text == "No open windows"
758        ));
759        let status = svc.status().await;
760        assert_eq!(status.name, "worktrees");
761        assert!(status.healthy);
762        assert_eq!(status.summary, "0 window(s) across 0 repo(s)");
763
764        // Two folder-bearing windows in the same repo, plus one folderless
765        // window that shares the repo but has nothing for `code` to open.
766        svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
767            .await
768            .unwrap();
769        svc.handle("register", register_payload("w2", Some("repo-a"), "/tmp/b"))
770            .await
771            .unwrap();
772        svc.handle(
773            "register",
774            json!({ "key": "w3", "repo": "repo-a", "folders": [] }),
775        )
776        .await
777        .unwrap();
778        let status = svc.status().await;
779        assert_eq!(status.summary, "3 window(s) across 1 repo(s)");
780
781        let menu = svc.menu();
782        // One line per window — no separator, no duplicate label.
783        assert_eq!(menu.items.len(), 3);
784        assert!(!menu.items.iter().any(|i| matches!(i, MenuItem::Separator)));
785        let action_ids: Vec<&str> = menu
786            .items
787            .iter()
788            .filter_map(|i| match i {
789                MenuItem::Action(a) => Some(a.id.as_str()),
790                _ => None,
791            })
792            .collect();
793        // The two folder-bearing windows are clickable; the folderless one is a
794        // plain Label, so it never yields a focus action.
795        assert!(action_ids.contains(&"focus:w1"));
796        assert!(action_ids.contains(&"focus:w2"));
797        assert!(!action_ids.contains(&"focus:w3"));
798    }
799
800    #[test]
801    fn start_menu_refresh_is_a_noop_outside_a_runtime() {
802        // With no tokio runtime, the background task is never spawned, so the
803        // bare service keeps computing `menu()` inline (what the tests rely on).
804        let svc = WorktreesService::new();
805        svc.start_menu_refresh();
806        assert!(svc.refresh.lock().unwrap().is_none());
807    }
808
809    #[tokio::test]
810    async fn start_menu_refresh_populates_cache_and_shutdown_stops_it() {
811        let svc = WorktreesService::new();
812        svc.handle("register", register_payload("w1", Some("repo-a"), "/tmp/a"))
813            .await
814            .unwrap();
815        // Before the task runs, `menu()` computes inline from an empty cache.
816        assert!(svc.menu_cache.lock().unwrap().is_none());
817
818        svc.start_menu_refresh();
819        // Idempotent: a second call does not start a second task.
820        svc.start_menu_refresh();
821
822        // The task fills the cache off the main thread; poll briefly for it.
823        let mut filled = false;
824        for _ in 0..100 {
825            if svc.menu_cache.lock().unwrap().is_some() {
826                filled = true;
827                break;
828            }
829            tokio::time::sleep(Duration::from_millis(10)).await;
830        }
831        assert!(filled, "background refresh should populate the menu cache");
832
833        // `menu()` now serves the cache: one clickable line for the window.
834        let menu = svc.menu();
835        assert_eq!(menu.title, "Worktrees");
836        assert!(menu
837            .items
838            .iter()
839            .any(|i| matches!(i, MenuItem::Action(a) if a.id == "focus:w1")));
840
841        // Shutdown cancels and joins the task, clearing the handle.
842        svc.shutdown().await;
843        assert!(svc.refresh.lock().unwrap().is_none());
844    }
845
846    #[tokio::test]
847    async fn default_constructs_an_empty_service() {
848        let svc = WorktreesService::default();
849        let payload = svc.handle("list", Value::Null).await.unwrap();
850        assert_eq!(payload, json!({ "windows": [] }));
851    }
852
853    #[tokio::test]
854    async fn menu_action_rejects_unknown_and_missing_window() {
855        let svc = WorktreesService::new();
856        assert!(svc.menu_action("bogus").await.is_err());
857        // A focus for a key with no registration errors rather than spawning.
858        assert!(svc.menu_action("focus:nope").await.is_err());
859        svc.shutdown().await;
860    }
861
862    /// Restores `OMNI_DEV_VSCODE_BIN` on drop. Only this test reads the variable
863    /// (via `resolve_code_binary` → `focus_window`), so there is no cross-test
864    /// race despite the process-global mutation.
865    struct VscodeBinGuard(Option<std::ffi::OsString>);
866    impl Drop for VscodeBinGuard {
867        fn drop(&mut self) {
868            match self.0.take() {
869                Some(v) => std::env::set_var(VSCODE_BIN_ENV, v),
870                None => std::env::remove_var(VSCODE_BIN_ENV),
871            }
872        }
873    }
874
875    #[tokio::test]
876    async fn menu_action_focus_resolves_folder_and_spawns() {
877        let dir = tempfile::tempdir().unwrap();
878        let svc = WorktreesService::new();
879        svc.handle(
880            "register",
881            json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
882        )
883        .await
884        .unwrap();
885
886        // Point the launcher at a harmless binary so the spawn deterministically
887        // succeeds and the focus path returns Ok.
888        let _g = VscodeBinGuard(std::env::var_os(VSCODE_BIN_ENV));
889        std::env::set_var(VSCODE_BIN_ENV, "/bin/sh");
890        svc.menu_action("focus:w1").await.unwrap();
891    }
892
893    #[test]
894    fn focus_window_with_validates_folder_then_spawns() {
895        let dir = tempfile::tempdir().unwrap();
896        // Non-absolute and missing-directory folders are rejected before spawn.
897        assert!(focus_window_with(Path::new("/bin/sh"), Path::new("relative/dir")).is_err());
898        assert!(
899            focus_window_with(Path::new("/bin/sh"), Path::new("/no/such/abs/dir/xyzzy")).is_err()
900        );
901        // A valid absolute directory spawns the launcher successfully.
902        focus_window_with(Path::new("/bin/sh"), dir.path()).unwrap();
903        // A missing launcher surfaces the spawn error (with context), not Ok.
904        assert!(focus_window_with(Path::new("/no/such/launcher/xyzzy"), dir.path()).is_err());
905    }
906
907    #[test]
908    fn resolve_code_binary_from_prefers_env_then_candidate_then_fallback() {
909        // Env override wins outright.
910        assert_eq!(
911            resolve_code_binary_from(Some("/custom/code".into()), &["/usr/bin/code"]),
912            PathBuf::from("/custom/code")
913        );
914        // No override: the first existing candidate is chosen.
915        let existing = tempfile::NamedTempFile::new().unwrap();
916        let existing_path = existing.path().to_str().unwrap();
917        assert_eq!(
918            resolve_code_binary_from(None, &["/no/such/candidate/xyzzy", existing_path]),
919            PathBuf::from(existing_path)
920        );
921        // Nothing exists: fall back to bare `code` on PATH.
922        assert_eq!(
923            resolve_code_binary_from(None, &["/no/such/candidate/xyzzy"]),
924            PathBuf::from("code")
925        );
926        // The real-env wrapper resolves without panicking.
927        let _ = resolve_code_binary();
928    }
929
930    // --- Git enrichment (#1186) --------------------------------------------
931
932    /// Initializes a fresh repo with a deterministic identity so `commit()`
933    /// works without depending on a global git config.
934    fn init_repo(dir: &Path) -> Repository {
935        let repo = Repository::init(dir).unwrap();
936        let mut cfg = repo.config().unwrap();
937        cfg.set_str("user.name", "Test").unwrap();
938        cfg.set_str("user.email", "test@example.com").unwrap();
939        repo
940    }
941
942    /// Writes an empty-tree commit (file content is irrelevant to ahead/behind),
943    /// optionally moving `refname` to it, and returns its oid.
944    fn empty_commit(
945        repo: &Repository,
946        refname: Option<&str>,
947        parents: &[&git2::Commit<'_>],
948        msg: &str,
949    ) -> git2::Oid {
950        let sig = git2::Signature::now("Test", "test@example.com").unwrap();
951        let tree = repo
952            .find_tree(repo.treebuilder(None).unwrap().write().unwrap())
953            .unwrap();
954        repo.commit(refname, &sig, &sig, msg, &tree, parents)
955            .unwrap()
956    }
957
958    /// Builds a repo whose `main` is 1 commit ahead of and 1 behind a configured
959    /// `origin/main` upstream, so enrichment reports `ahead: 1, behind: 1`.
960    fn diverging_repo(dir: &Path) -> Repository {
961        let repo = init_repo(dir);
962        // A: the shared base on `main`.
963        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
964        let a_commit = repo.find_commit(a).unwrap();
965        // origin/main diverges to C, a sibling of the local tip.
966        let c = empty_commit(&repo, None, &[&a_commit], "C");
967        repo.reference("refs/remotes/origin/main", c, true, "origin main")
968            .unwrap();
969        // Local `main` advances to B → 1 ahead of / 1 behind origin/main.
970        empty_commit(&repo, Some("refs/heads/main"), &[&a_commit], "B");
971        // Release the commit's borrow of `repo` so it can be returned.
972        drop(a_commit);
973        repo.set_head("refs/heads/main").unwrap();
974        // Configure the tracking relationship so `upstream()` resolves.
975        let mut cfg = repo.config().unwrap();
976        cfg.set_str("remote.origin.url", "https://example.invalid/x.git")
977            .unwrap();
978        cfg.set_str("remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
979            .unwrap();
980        cfg.set_str("branch.main.remote", "origin").unwrap();
981        cfg.set_str("branch.main.merge", "refs/heads/main").unwrap();
982        repo
983    }
984
985    #[test]
986    fn git_status_reads_branch_and_ahead_behind() {
987        let dir = tempfile::tempdir().unwrap();
988        let _repo = diverging_repo(dir.path());
989        let status = git_status(dir.path());
990        assert_eq!(status.branch.as_deref(), Some("main"));
991        assert_eq!(status.ahead, Some(1));
992        assert_eq!(status.behind, Some(1));
993        // A normal checkout names itself and is not flagged a worktree.
994        assert_eq!(
995            status.main_repo.as_deref(),
996            dir.path().file_name().and_then(|n| n.to_str())
997        );
998        assert!(!status.is_worktree);
999    }
1000
1001    #[test]
1002    fn git_status_empty_repo_is_unborn() {
1003        // A repo with no commits has an unborn HEAD, so `head()` errors and the
1004        // branch/sync fields stay empty rather than panicking — but the repo
1005        // identity is still resolved from the common dir.
1006        let dir = tempfile::tempdir().unwrap();
1007        init_repo(dir.path());
1008        let status = git_status(dir.path());
1009        assert_eq!(status.branch, None);
1010        assert_eq!(status.ahead, None);
1011        assert_eq!(status.behind, None);
1012        assert_eq!(
1013            status.main_repo.as_deref(),
1014            dir.path().file_name().and_then(|n| n.to_str())
1015        );
1016        assert!(!status.is_worktree);
1017    }
1018
1019    #[test]
1020    fn git_status_no_upstream_reports_branch_only() {
1021        let dir = tempfile::tempdir().unwrap();
1022        let repo = init_repo(dir.path());
1023        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
1024        repo.set_head("refs/heads/main").unwrap();
1025        let status = git_status(dir.path());
1026        assert_eq!(status.branch.as_deref(), Some("main"));
1027        // No upstream → ahead/behind stay absent rather than zero.
1028        assert_eq!(status.ahead, None);
1029        assert_eq!(status.behind, None);
1030    }
1031
1032    #[test]
1033    fn git_status_non_repo_is_empty_detached_reports_repo_without_branch() {
1034        // A plain directory that is not a git repo yields nothing at all.
1035        let plain = tempfile::tempdir().unwrap();
1036        assert_eq!(git_status(plain.path()), GitStatus::default());
1037
1038        // A detached HEAD reports no branch (and thus no sync), but the repo
1039        // identity is still resolved from the common dir.
1040        let dir = tempfile::tempdir().unwrap();
1041        let repo = init_repo(dir.path());
1042        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
1043        repo.set_head_detached(a).unwrap();
1044        let status = git_status(dir.path());
1045        assert_eq!(status.branch, None);
1046        assert_eq!(status.ahead, None);
1047        assert_eq!(status.behind, None);
1048        assert_eq!(
1049            status.main_repo.as_deref(),
1050            dir.path().file_name().and_then(|n| n.to_str())
1051        );
1052        assert!(!status.is_worktree);
1053    }
1054
1055    #[test]
1056    fn sync_indicator_formats_only_with_upstream() {
1057        assert_eq!(sync_indicator(Some(2), Some(1)).as_deref(), Some("(+2 -1)"));
1058        assert_eq!(sync_indicator(Some(0), Some(0)).as_deref(), Some("(+0 -0)"));
1059        assert_eq!(sync_indicator(None, None), None);
1060        // A partial pair (no real upstream) yields nothing.
1061        assert_eq!(sync_indicator(Some(1), None), None);
1062    }
1063
1064    #[tokio::test]
1065    async fn list_enriches_entries_with_git_status() {
1066        let dir = tempfile::tempdir().unwrap();
1067        let repo = init_repo(dir.path());
1068        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
1069        repo.set_head("refs/heads/main").unwrap();
1070
1071        let svc = WorktreesService::new();
1072        svc.handle(
1073            "register",
1074            json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
1075        )
1076        .await
1077        .unwrap();
1078        let payload = svc.handle("list", Value::Null).await.unwrap();
1079        let windows = windows_of(&payload);
1080        assert_eq!(windows.len(), 1);
1081        assert_eq!(
1082            windows[0].get("branch").and_then(Value::as_str),
1083            Some("main")
1084        );
1085        // No upstream configured → the ahead/behind keys are absent, not zero.
1086        assert!(windows[0].get("ahead").is_none());
1087        assert!(windows[0].get("behind").is_none());
1088        // The main repo name is enriched onto the entry.
1089        assert_eq!(
1090            windows[0].get("main_repo").and_then(Value::as_str),
1091            dir.path().file_name().and_then(|n| n.to_str())
1092        );
1093
1094        // A non-repo folder is still listed, just without a branch or main repo.
1095        let plain = tempfile::tempdir().unwrap();
1096        svc.handle(
1097            "register",
1098            json!({ "key": "w2", "folders": [plain.path()], "repo": "plain" }),
1099        )
1100        .await
1101        .unwrap();
1102        let windows = windows_of(&svc.handle("list", Value::Null).await.unwrap()).clone();
1103        let w2 = windows
1104            .iter()
1105            .find(|w| w.get("key").and_then(Value::as_str) == Some("w2"))
1106            .unwrap();
1107        assert!(w2.get("branch").is_none());
1108        assert!(w2.get("main_repo").is_none());
1109    }
1110
1111    #[test]
1112    fn window_label_prefers_git_branch_over_title() {
1113        let dir = tempfile::tempdir().unwrap();
1114        let repo = init_repo(dir.path());
1115        empty_commit(&repo, Some("refs/heads/main"), &[], "A");
1116        repo.set_head("refs/heads/main").unwrap();
1117        let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
1118        let entry = WindowEntry {
1119            key: "k".to_string(),
1120            folders: vec![dir.path().to_path_buf()],
1121            // Both the companion `repo` and `title` are overridden by the
1122            // git-derived main repo name and computed branch.
1123            repo: Some("companion-repo".to_string()),
1124            title: Some("ignored title".to_string()),
1125            pid: None,
1126            last_seen: Utc::now(),
1127        };
1128        // Main checkout: `repo · branch`, and with no upstream there is no sync.
1129        assert_eq!(window_label(&entry), format!("{repo_name} · main"));
1130    }
1131
1132    #[tokio::test]
1133    async fn list_includes_ahead_behind_for_tracking_branch() {
1134        let dir = tempfile::tempdir().unwrap();
1135        let _repo = diverging_repo(dir.path());
1136
1137        let svc = WorktreesService::new();
1138        svc.handle(
1139            "register",
1140            json!({ "key": "w1", "folders": [dir.path()], "repo": "r" }),
1141        )
1142        .await
1143        .unwrap();
1144        let payload = svc.handle("list", Value::Null).await.unwrap();
1145        let windows = windows_of(&payload);
1146        // A tracking branch serializes branch plus both divergence counts.
1147        assert_eq!(
1148            windows[0].get("branch").and_then(Value::as_str),
1149            Some("main")
1150        );
1151        assert_eq!(windows[0].get("ahead").and_then(Value::as_u64), Some(1));
1152        assert_eq!(windows[0].get("behind").and_then(Value::as_u64), Some(1));
1153    }
1154
1155    #[test]
1156    fn window_label_includes_sync_for_tracking_branch() {
1157        let dir = tempfile::tempdir().unwrap();
1158        let _repo = diverging_repo(dir.path());
1159        let repo_name = dir.path().file_name().unwrap().to_str().unwrap();
1160        let entry = WindowEntry {
1161            key: "k".to_string(),
1162            folders: vec![dir.path().to_path_buf()],
1163            repo: Some("companion-repo".to_string()),
1164            title: None,
1165            pid: None,
1166            last_seen: Utc::now(),
1167        };
1168        // A tracking branch appends the `(+ahead -behind)` sync indicator.
1169        assert_eq!(window_label(&entry), format!("{repo_name} · main (+1 -1)"));
1170    }
1171
1172    /// Adds a linked worktree of `repo` at `wt_path` checked out on a new
1173    /// `branch` pointed at `base`, mirroring `git worktree add -b <branch>
1174    /// <wt_path>`.
1175    fn add_worktree(repo: &Repository, base: git2::Oid, wt_path: &Path, branch: &str) {
1176        let commit = repo.find_commit(base).unwrap();
1177        repo.branch(branch, &commit, false).unwrap();
1178        let reference = repo
1179            .find_reference(&format!("refs/heads/{branch}"))
1180            .unwrap();
1181        let mut opts = git2::WorktreeAddOptions::new();
1182        opts.reference(Some(&reference));
1183        repo.worktree(branch, wt_path, Some(&opts)).unwrap();
1184    }
1185
1186    #[test]
1187    fn git_status_marks_linked_worktree_and_names_parent_repo() {
1188        let main_dir = tempfile::tempdir().unwrap();
1189        let repo = init_repo(main_dir.path());
1190        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
1191        repo.set_head("refs/heads/main").unwrap();
1192
1193        // A linked worktree checked out on a new `feature` branch, in a
1194        // directory whose basename is deliberately *not* the repo name.
1195        let wt_parent = tempfile::tempdir().unwrap();
1196        let wt_path = wt_parent.path().join("feature-wt");
1197        add_worktree(&repo, a, &wt_path, "feature");
1198
1199        let status = git_status(&wt_path);
1200        assert!(status.is_worktree);
1201        assert_eq!(status.branch.as_deref(), Some("feature"));
1202        // The worktree names its *parent* repo, not its worktree-folder basename.
1203        assert_eq!(
1204            status.main_repo.as_deref(),
1205            main_dir.path().file_name().and_then(|n| n.to_str())
1206        );
1207
1208        // The main checkout resolves the same repo name and is not a worktree.
1209        let main_status = git_status(main_dir.path());
1210        assert!(!main_status.is_worktree);
1211        assert_eq!(main_status.main_repo, status.main_repo);
1212    }
1213
1214    #[test]
1215    fn window_label_marks_worktree_with_fork_glyph() {
1216        let main_dir = tempfile::tempdir().unwrap();
1217        let repo = init_repo(main_dir.path());
1218        let a = empty_commit(&repo, Some("refs/heads/main"), &[], "A");
1219        repo.set_head("refs/heads/main").unwrap();
1220        let wt_parent = tempfile::tempdir().unwrap();
1221        let wt_path = wt_parent.path().join("feature-wt");
1222        add_worktree(&repo, a, &wt_path, "feature");
1223
1224        let repo_name = main_dir.path().file_name().unwrap().to_str().unwrap();
1225        let entry = WindowEntry {
1226            key: "k".to_string(),
1227            folders: vec![wt_path],
1228            repo: Some("feature-wt".to_string()),
1229            title: None,
1230            pid: None,
1231            last_seen: Utc::now(),
1232        };
1233        // A worktree line: parent repo, the fork glyph, then the branch (no
1234        // upstream here, so no sync suffix).
1235        assert_eq!(window_label(&entry), format!("{repo_name} ⑂ feature"));
1236    }
1237
1238    #[test]
1239    fn main_repo_name_derives_from_common_dir() {
1240        // Normal layout: the repo is the directory that contains `.git`.
1241        assert_eq!(
1242            main_repo_name(Path::new("/home/me/omni-dev/.git")).as_deref(),
1243            Some("omni-dev")
1244        );
1245        // A trailing slash on the common dir does not change the answer.
1246        assert_eq!(
1247            main_repo_name(Path::new("/home/me/omni-dev/.git/")).as_deref(),
1248            Some("omni-dev")
1249        );
1250        // A bare repo: its own directory name, without the `.git` suffix.
1251        assert_eq!(
1252            main_repo_name(Path::new("/srv/git/omni-dev.git")).as_deref(),
1253            Some("omni-dev")
1254        );
1255        // A `.git` at the filesystem root has no parent name to use.
1256        assert_eq!(main_repo_name(Path::new("/.git")), None);
1257    }
1258}